Deploy test site / deploy (push) Successful in 39s
Team figures, October 2026: - revenue-model.md: a lacquer blank costs about 70 EUR, one per side, so ~160 USD a two-sided title before recuts; a cutting engineer costs about 50,000 THB a month; - fleet calculator: the lacquer assumption is now in EUR (70, one per side, converted at 1.155 USD per EUR; styli and recuts not included) and the cutting engineer 50,000 THB a month, both marked as team figures instead of estimates. At the defaults lacquers come to ~54k USD a year and direct cost to 1.88 USD a record (was 1.86).
1034 lines
67 KiB
HTML
1034 lines
67 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;
|
||
}
|
||
: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; }
|
||
tr.tot td { font-weight: 600; border-top: 1px solid var(--stroke); }
|
||
td.neg { color: var(--bad); }
|
||
#ecoBar div { min-width: 0; }
|
||
.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: 1040px; }
|
||
</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>Prices and money</h2>
|
||
<div class="seg" id="case">
|
||
<button data-v="adverse">Adverse</button>
|
||
<button data-v="central">Central</button>
|
||
<button data-v="favourable">Favourable</button>
|
||
</div>
|
||
<p class="hint" style="margin-bottom:6px">The price and cost ends of the revenue model; editing a figure makes it your own case.</p>
|
||
<label class="row"><span>Standard record, USD<br><small>black or single colour; revenue model: 3.50 to 4.60 blended</small></span><input type="number" id="priceStd" min="0" step="0.05"></label>
|
||
<label class="row"><span>Specials premium, USD<br><small>splatter and effects: +0.60 to 1.70</small></span><input type="number" id="premSpec" min="0" step="0.05"></label>
|
||
<label class="row"><span>Title services, USD a title<br><small>mastering, cutting, stampers, test pressings: 580 to 1,730</small></span><input type="number" id="titleFee" min="0" step="5"></label>
|
||
<div class="row" style="margin-top:12px"><span>Show money in</span></div>
|
||
<div class="seg" id="cur">
|
||
<button data-v="USD">USD</button>
|
||
<button data-v="THB">THB</button>
|
||
<button data-v="EUR">EUR</button>
|
||
</div>
|
||
<p class="hint" id="priceHint"></p>
|
||
</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>Money a year</h2>
|
||
<div class="kpis" id="ecoK"></div>
|
||
<div class="bar" id="ecoBar" style="height:28px"></div>
|
||
<div class="legend" id="ecoLeg"></div>
|
||
<div class="tscroll" style="margin-top:12px"><table id="ecoT"></table></div>
|
||
<p class="note" style="margin-bottom:0">Central is the revenue model's midpoint. The revenue model itself calls
|
||
that case optimistic: its EBITDA sits well above the 15 to 25 % pressing plants earn, because the cost
|
||
table still misses indirect labour, inbound freight, inventory, effluent and sales commission. Read the
|
||
adverse case as the honest one until those lines exist. Prices are a Western benchmark; Thai domestic
|
||
invoices run far higher (650 to 950 THB all-in at 300 units).</p>
|
||
</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>Money.</b> Prices, per-record costs, wages, overheads and depreciation come from
|
||
finance/revenue-model.md, capex from finance/total-investment.md (the start fleet plan plus each
|
||
Duplex system or Classic added or removed), the electricity tariff and PV from scale.md, the biomass
|
||
heat price from biomass.md. Electricity and steam follow the fleet: machines, chiller and compressors
|
||
at the hours run, steam from the presses' running time, plus an estimated base load for the rest of
|
||
the plant. Lacquer blanks (70 EUR each) and the cutting engineer (50,000 THB a month) are team
|
||
figures; shipping cartons have no figure on file and are an estimate. Missing as the revenue model
|
||
lists: indirect labour, freight, inventory, effluent, commission, styli, extra galvanics units' capex.</p>
|
||
<p><b>Not scaled here:</b> admin, sales and logistics (in the overheads line); cover for leave
|
||
and sickness.</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,
|
||
priceStd: 3.8, premSpec: 1.15, titleFee: 1155, cur: "USD",
|
||
};
|
||
// [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("Money", [
|
||
["thbUsd", "THB per USD", 33, "THB", "doc", "revenue-model.md and total-investment.md, FX of 5 August 2026"],
|
||
["usdEur", "USD per EUR", 1.155, "USD", "doc", "revenue-model.md"],
|
||
["overheads", "Cash overheads a year", 330000, "USD", "doc", "revenue-model.md: 200 to 450k (admin, sales, insurance, facility, IT, fees, marketing)"],
|
||
["capexBase", "Capex, start fleet plan", 6800000, "USD", "doc", "total-investment.md: 4.7 to 8.9M full vision; added scope not yet rolled up"],
|
||
["capexD", "Capex per Duplex system beyond two", 265000, "USD", "doc", "total-investment.md: system 2 ~230 to 300k, estimate"],
|
||
["capexC", "Capex per Classic beyond one", 375000, "USD", "doc", "total-investment.md: ~290 to 460k, no Newbilt price on file"],
|
||
["deprYears", "Depreciation period", 12.5, "years", "doc", "revenue-model.md: 10 to 15 years"],
|
||
]);
|
||
grp("Materials and packaging", [
|
||
["pvcPrice", "PVC compound", 2.3, "USD/kg", "doc", "revenue-model.md: 1.75 to 2.90 USD/kg record grade"],
|
||
["labelCost", "Labels, a pair", 0.06, "USD", "doc", "revenue-model.md: 0.03 to 0.09"],
|
||
["sleeveCost", "Inner sleeve", 0.13, "USD", "doc", "revenue-model.md: 0.09 to 0.17, poly-lined"],
|
||
["jacketCost", "Jacket (printed carton)", 0.66, "USD", "doc", "revenue-model.md: 0.40 to 0.92, local carton partners"],
|
||
["shrinkCost", "Shrink wrap", 0.025, "USD", "doc", "revenue-model.md: 0.02 to 0.03"],
|
||
["cartonCost", "Shipping cartons and pallets, a record", 0.04, "USD", "est", "Estimate: a 25-record shipping carton at about 1 USD"],
|
||
["maintCost", "Maintenance, spares, consumables, a record", 0.17, "USD", "doc", "revenue-model.md: 0.12 to 0.23"],
|
||
]);
|
||
grp("Stamper making", [
|
||
["lacquerCost", "Lacquer blank, one per side", 70, "EUR", "user", "Team figure, October 2026 (revenue-model.md); styli not included"],
|
||
["chemPart", "Nickel, silver, chemistry, an electroformed part", 25, "USD", "doc", "revenue-model.md: 60 to 150 USD marginal per three-generation set, so 20 to 50 a part"],
|
||
]);
|
||
grp("Energy", [
|
||
["kwhPrice", "Electricity, blended", 3.81, "THB/kWh", "doc", "scale.md: PEA TOU 3.2.2 at two shifts, before the demand charge (Ft September to December 2026)"],
|
||
["demandPrice", "Demand charge", 132.93, "THB/kW a month", "doc", "PEA 3.2.2 (scale.md)"],
|
||
["restKW", "Rest of the plant while running", 120, "kW", "est", "Estimate: HVAC, lighting, galvanics, finishing, offices; puts the start fleet in scale.md's 4.3 to 6.4M THB a year before PV"],
|
||
["pvKWp", "Roof PV", 247, "kWp", "doc", "scale.md"],
|
||
["pvYield", "PV yield, all self-consumed", 1400, "kWh/kWp", "doc", "scale.md: 1,400 to 1,500 kWh/kWp a year in northern Thailand"],
|
||
["heatPrice", "Steam heat from biomass", 0.7, "THB/kWh", "doc", "biomass.md: 0.5 to 0.9 at 1.35 to 2 THB/kg delivered"],
|
||
["steamKWh", "Heat per kg of steam", 0.68, "kWh/kg", "est", "Estimate: 10 bar saturated steam from ~80 °C feed water"],
|
||
]);
|
||
grp("Labour (THB a month a head, loaded ×1.3)", [
|
||
["wageOp", "Press and finishing operator", 19500, "THB", "doc", "revenue-model.md labour build-up"],
|
||
["wageQC", "QC", 26000, "THB", "doc", "revenue-model.md"],
|
||
["wageMaint", "Maintenance", 32500, "THB", "doc", "revenue-model.md"],
|
||
["wageGalv", "Galvanics", 32500, "THB", "doc", "revenue-model.md"],
|
||
["wageCut", "Cutting engineer", 50000, "THB", "user", "Team figure, October 2026 (revenue-model.md), taken as the full monthly cost"],
|
||
["qcPerShift", "QC inspectors per shift", 1, "", "est", "Estimate; scale.md: 1 to 2 QC"],
|
||
["pressPerTech", "Presses per maintenance technician (at least 2)", 8, "", "est", "Estimate; scale.md: 2 in maintenance for the start fleet"],
|
||
]);
|
||
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" };
|
||
// revenue-model.md ranges: adverse pairs low price with high cost, favourable the reverse
|
||
const CASES = {
|
||
adverse: { S: { priceStd: 3.4, premSpec: 0.6, titleFee: 580 },
|
||
A: { pvcPrice: 2.9, labelCost: 0.09, sleeveCost: 0.17, jacketCost: 0.92, shrinkCost: 0.03, maintCost: 0.23, overheads: 450000, capexBase: 8900000, heatPrice: 0.9, deprYears: 10 } },
|
||
central: { S: { priceStd: 3.8, premSpec: 1.15, titleFee: 1155 },
|
||
A: { pvcPrice: 2.3, labelCost: 0.06, sleeveCost: 0.13, jacketCost: 0.66, shrinkCost: 0.025, maintCost: 0.17, overheads: 330000, capexBase: 6800000, heatPrice: 0.7, deprYears: 12.5 } },
|
||
favourable: { S: { priceStd: 4.25, premSpec: 1.7, titleFee: 1730 },
|
||
A: { pvcPrice: 1.75, labelCost: 0.03, sleeveCost: 0.09, jacketCost: 0.4, shrinkCost: 0.02, maintCost: 0.12, overheads: 200000, capexBase: 4700000, heatPrice: 0.5, deprYears: 15 } },
|
||
};
|
||
function caseNow() {
|
||
for (const k in CASES) {
|
||
const c = CASES[k];
|
||
if (Object.keys(c.S).every(x => S[x] === c.S[x]) && Object.keys(c.A).every(x => A[x] === c.A[x])) return k;
|
||
}
|
||
return "";
|
||
}
|
||
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 headsOf(r) {
|
||
return {
|
||
ops: r.staff, qc: r.X > 0 ? Math.ceil(A.qcPerShift * S.shifts) : 0, galv: r.galv,
|
||
maint: r.P > 0 ? Math.max(2, Math.ceil(r.P / Math.max(1, A.pressPerTech))) : 0, cut: r.gst.lathe.n,
|
||
};
|
||
}
|
||
// Money a year in USD. Lines: [group, label, USD, basis].
|
||
function econ(r, b) {
|
||
const fx = A.thbUsd > 0 ? A.thbUsd : 33, t2u = v => v / fx;
|
||
const X = r.X, pressed = X * (1 + A.reject / 100), st = X - r.sp, L = [];
|
||
const add = (g, l, v, s) => L.push([g, l, v, s]);
|
||
const revStd = st * S.priceStd, revSpec = r.sp * (S.priceStd + S.premSpec), revTitle = r.titles * S.titleFee;
|
||
const rev = revStd + revSpec + revTitle;
|
||
add("mat", "PVC compound", r.compound * 1000 * A.pvcPrice, `${f0(r.compound)} t at ${f2(A.pvcPrice)} USD/kg incl. rejects; trim back as regrind`);
|
||
add("mat", "Labels", pressed * A.labelCost, "a printed pair per pressed record");
|
||
add("pack", "Inner sleeves", X * A.sleeveCost, "");
|
||
add("pack", "Jackets (printed cartons)", X * A.jacketCost, "local carton partners");
|
||
add("pack", "Shrink wrap", X * A.shrinkCost, "");
|
||
add("pack", "Shipping cartons and pallets", X * A.cartonCost, "estimate");
|
||
add("maint", "Maintenance, spares, consumables", X * A.maintCost, "");
|
||
add("metal", "Lacquer blanks", r.titles * 2 * A.lacquerCost * A.usdEur, `${f0(r.titles * 2)} lacquers at ${f0(A.lacquerCost)} EUR; styli and recuts not included`);
|
||
add("metal", "Nickel, silver, plating chemistry", r.partsYr * A.chemPart, `${f0(r.partsYr)} electroformed parts`);
|
||
const cap = capacityOf(r.D, r.C, b), use = cap > 0 ? Math.min(1, X / cap) : 0;
|
||
const runH = b.hoursYr * S.eff / 100 * use; // press running hours a year
|
||
const steamT = r.P * (A.steamLo + A.steamHi) / 2 * runH / 1000;
|
||
const heatMWh = steamT * A.steamKWh;
|
||
const elecMWh = X > 0 ? r.mwh + heatMWh / Math.max(0.5, A.cop) + (r.compKW + A.restKW) * b.hoursYr / 1000 : 0;
|
||
const pvMWh = Math.min(elecMWh, A.pvKWp * A.pvYield / 1000);
|
||
const demandKW = X > 0 ? r.kw + r.chillerKW + r.compKW + A.restKW : 0;
|
||
add("energy", "Electricity", t2u((elecMWh - pvMWh) * 1000 * A.kwhPrice + demandKW * A.demandPrice * 12),
|
||
`${f0(elecMWh - pvMWh)} MWh from the grid after ${f0(pvMWh)} MWh roof PV; ~${f0(demandKW)} kW demand`);
|
||
add("energy", "Steam heat (biomass)", t2u(heatMWh * 1000 * A.heatPrice), `${f0(steamT)} t of steam, ${f0(heatMWh)} MWh of heat`);
|
||
const h = headsOf(r), wage = (n, w) => t2u(n * w * 12), ppl = n => n + (n === 1 ? " person" : " people");
|
||
add("lab", "Press operators and finishing", wage(h.ops, A.wageOp), ppl(h.ops));
|
||
add("lab", "QC", wage(h.qc, A.wageQC), ppl(h.qc));
|
||
add("lab", "Maintenance", wage(h.maint, A.wageMaint), ppl(h.maint));
|
||
add("lab", "Galvanics", wage(h.galv, A.wageGalv), ppl(h.galv));
|
||
add("lab", "Cutting engineers", wage(h.cut, A.wageCut), ppl(h.cut));
|
||
const sum = gs => L.filter(l => gs.includes(l[0])).reduce((a, l) => a + l[2], 0);
|
||
const direct = L.reduce((a, l) => a + l[2], 0), contrib = rev - direct, ebitda = contrib - A.overheads;
|
||
const capex = A.capexBase + (r.D - 2) * A.capexD + (r.C - 1) * A.capexC, depr = capex / Math.max(1, A.deprYears);
|
||
return { rev, revStd, revSpec, revTitle, L, sum, direct, contrib, ebitda, capex, depr, op: ebitda - depr,
|
||
payback: ebitda > 0 ? capex / ebitda : Infinity, heads: h };
|
||
}
|
||
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 f2 = x => nf(2).format(x);
|
||
const fix2 = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
const cv = usd => S.cur === "THB" ? usd * A.thbUsd : S.cur === "EUR" ? usd / Math.max(0.01, A.usdEur) : usd;
|
||
function money(usd) {
|
||
const v = cv(usd), a = Math.abs(v);
|
||
return (a >= 1e6 ? nf(2).format(v / 1e6) + " M" : a >= 1e4 ? f0(v / 1e3) + " k" : f0(v)) + " " + S.cur;
|
||
}
|
||
const unit = usd => fix2.format(cv(usd)) + " " + S.cur;
|
||
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", "cur"]) for (const bt of $(g).children) bt.classList.toggle("on", bt.dataset.v === S[g]);
|
||
const cs = caseNow();
|
||
for (const bt of $("case").children) bt.classList.toggle("on", bt.dataset.v === cs);
|
||
|
||
$("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>`;
|
||
|
||
// ---- money
|
||
const e = econ(r, b), Xr = Math.max(1, r.X), perRec = usd => r.X > 0 ? unit(usd / Xr) : "–";
|
||
const blended = r.X > 0 ? (e.revStd + e.revSpec) / r.X : S.priceStd;
|
||
$("priceHint").textContent = `Blended record price at this mix: ${unit(blended)}. Prices are entered in USD; 1 USD = ${f2(A.thbUsd)} THB, 1 EUR = ${nf(3).format(A.usdEur)} USD.`;
|
||
$("ecoK").innerHTML =
|
||
kpi(money(e.rev), "revenue a year") +
|
||
kpi(money(e.ebitda), `EBITDA, ${e.rev > 0 ? pct(e.ebitda / e.rev) : "0 %"} of revenue`) +
|
||
kpi(money(e.op), "operating profit after depreciation") +
|
||
kpi(perRec(e.direct), "direct cost per record") +
|
||
kpi(isFinite(e.payback) ? f1(e.payback) + " years" : "never", `payback on ${money(e.capex)} capex`);
|
||
const SEG = [["PVC and labels", e.sum(["mat"]), "#b07d48"], ["Packaging", e.sum(["pack"]), "#c9a24a"],
|
||
["Lacquers and plating", e.sum(["metal"]), "#8e98a6"], ["Energy", e.sum(["energy"]), "#e2462a"],
|
||
["Labour", e.sum(["lab"]), "#2f7de1"], ["Maintenance", e.sum(["maint"]), "#7a6fd6"],
|
||
["Overheads", A.overheads, "#6b7a90"], ["Depreciation", e.depr, "#4a5f85"]];
|
||
const costAll = SEG.reduce((a, x) => a + x[1], 0), scale = Math.max(e.rev, costAll, 1);
|
||
$("ecoBar").innerHTML = SEG.map(([l, v, c]) => `<div style="width:${100 * v / scale}%;background:${c}" title="${l}: ${unit(v / Xr)} a record"></div>`).join("") +
|
||
(e.op > 0 ? `<div style="width:${100 * e.op / scale}%;background:var(--accent)" title="Operating profit"></div>` : "");
|
||
$("ecoLeg").innerHTML = `<span>Per record, revenue ${perRec(e.rev)}:</span>` +
|
||
SEG.map(([l, v, c]) => `<span><i style="background:${c}"></i>${l} ${perRec(v)}</span>`).join("") +
|
||
(e.op >= 0 ? `<span><i style="background:var(--accent)"></i>Profit ${perRec(e.op)}</span>`
|
||
: `<span style="color:var(--bad)">Loss ${perRec(-e.op)} a record</span>`);
|
||
const P = [];
|
||
const PG = t => P.push(`<tr class="grp"><td colspan="4">${t}</td></tr>`);
|
||
const PR = (l, v, s, tot) => P.push(`<tr${tot ? ' class="tot"' : ""}><td>${l}</td><td class="n${v < 0 ? " neg" : ""}">${money(v)}</td><td class="n${v < 0 ? " neg" : ""}">${perRec(v)}</td><td class="src">${s || ""}</td></tr>`);
|
||
PG("Revenue");
|
||
PR("Standard records", e.revStd, `${big(r.X - r.sp)} at ${unit(S.priceStd)}`);
|
||
PR("Splatter and specials", e.revSpec, `${big(r.sp)} at ${unit(S.priceStd + S.premSpec)}`);
|
||
PR("Title services", e.revTitle, `${f0(r.titles)} titles at ${money(S.titleFee)}`);
|
||
PR("Revenue", e.rev, "", true);
|
||
const GRP = [["Materials, packaging, upkeep", ["mat", "pack", "maint"]], ["Stamper making", ["metal"]], ["Energy", ["energy"]], ["Labour, loaded wages", ["lab"]]];
|
||
for (const [t, gs] of GRP) { PG(t); e.L.filter(l => gs.includes(l[0])).forEach(l => PR(l[1], l[2], l[3])); }
|
||
PG("Result");
|
||
PR("Direct cost", e.direct, "everything above", true);
|
||
PR("Contribution", e.contrib, e.rev > 0 ? pct(e.contrib / e.rev) + " of revenue" : "", true);
|
||
PR("Overheads", A.overheads, "admin, sales, insurance, facility, IT, fees, marketing");
|
||
PR("EBITDA", e.ebitda, e.rev > 0 ? pct(e.ebitda / e.rev) + " of revenue" : "", true);
|
||
PR("Depreciation", e.depr, `${money(e.capex)} capex over ${f1(A.deprYears)} years`);
|
||
PR("Operating profit", e.op, isFinite(e.payback) ? `payback ${f1(e.payback)} years on EBITDA` : "no payback", true);
|
||
$("ecoT").innerHTML = `<thead><tr><th>Line</th><th class="n">A year</th><th class="n">A record</th><th>Basis</th></tr></thead><tbody>${P.join("")}</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("QC inspectors, all shifts", e.heads.qc, `${f1(A.qcPerShift)} per shift (estimate; scale.md 1 to 2)`);
|
||
R("Maintenance technicians", e.heads.maint, `one per ${f0(A.pressPerTech)} presses, at least 2 (estimate)`);
|
||
R("Galvanics operators (day work)", r.galv, `${f1(r.partsDay)} parts a day at about ${f0(A.galvRate)} per person (OMS)`);
|
||
R("Cutting engineers (day work)", e.heads.cut, "one per lathe (estimate)");
|
||
R("Production headcount", e.heads.ops + e.heads.qc + e.heads.maint + e.heads.galv + e.heads.cut, "admin, sales and logistics sit in overheads; add cover for leave");
|
||
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><th class="n">Revenue</th><th class="n">EBITDA</th></tr></thead><tbody>` +
|
||
LADDER.map(([n, d, c], i) => {
|
||
const x = capacityOf(d, c, b), q = compute(d, c, x, b), qe = econ(q, 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><td class="n">${money(qe.rev)}</td><td class="n${qe.ebitda < 0 ? " neg" : ""}">${money(qe.ebitda)}</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", "priceStd", "premSpec", "titleFee"];
|
||
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";
|
||
if (!["USD", "THB", "EUR"].includes(S.cur)) S.cur = "USD";
|
||
}
|
||
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(); });
|
||
$("case").addEventListener("click", e => {
|
||
const v = e.target.dataset && e.target.dataset.v; if (!v || !CASES[v]) return;
|
||
Object.assign(S, CASES[v].S); Object.assign(A, CASES[v].A); syncInputs(); render();
|
||
});
|
||
for (const g of ["mode", "grow", "cur"]) $(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>
|