Deploy test site / deploy (push) Successful in 44s
User request (October 2026): an interactive business plan calculator where the plant configuration is chosen and everything follows. Built into the fleet calculator (same page and URL, one model): - Plant configuration card: galvanics in-house or bought stampers (and Pheenix estimate vs OMS reference), mastering and cutting in-house or outsourced, metrology full or launch tier, cassette and CD-R, solar buy / PPA / none, electric or biomass steam, Thai or Chinese utilities, crane buy or rent, visitor corridor now or later, ramp-up reserve; stamper supply to other plants; disabled-not-hidden controls. - Investment built from the total-investment blocks (low / mid / high, sources and kind badges); boiler and chiller modules sized from the fleet and flagged past the four positions; phase 2 building past the 870 m2 press hall. Default reproduces 160.9 / 226.7 / 292.5M THB. - Budget card against 180M and 216M THB; "Investor levers" preset gives 193.6M mid, 203.6 to 213.6M with the reserve. - Money follows the configuration: outsourced title steps passed through at cost (fee split 51.1 / 37.0 / 11.9 %), electric steam energy and demand charge, PPA power price, optional cassette/CD-R revenue. - Ten-year cash view: editable ramp and discount rate, payback, NPV, IRR, chart and table; pre-tax, pre-financing sketch. - Presets: launch plant, investor levers, lean, five-press, full hall. - Verified headless on Edge: 62 checks, 390 / 1024 / 1440 px, dark and light, no overflow, no page errors. Business plan 9.4 cross-check updated to the calculator's figures (EBITDA 1.68M, payback 4.1 yr; cash payback year ~5.7, IRR ~13 %); docx and pdf rebuilt. README and landing entry renamed.
1693 lines
126 KiB
HTML
1693 lines
126 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 · plant and business plan 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; }
|
||
@media (max-width: 640px) { header h1 { font-size: 22px; padding-right: 128px; } }
|
||
* { 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; }
|
||
.presets { display: flex; flex-wrap: wrap; gap: 8px; margin: -8px 0 22px; align-items: center; }
|
||
.presets > span { color: var(--mute); font-size: 13px; margin-right: 2px; }
|
||
.presets button {
|
||
background: var(--panel); color: var(--fg); border: 1px solid var(--stroke); border-radius: 5px;
|
||
padding: 6px 11px; font: inherit; font-size: 13.5px; cursor: pointer;
|
||
}
|
||
.presets button:hover { border-color: var(--accent); }
|
||
.presets button.on { border-color: var(--accent); color: var(--accent); background: var(--panel2); }
|
||
.cfg { border-top: 1px solid var(--line); padding: 9px 0 3px; }
|
||
.cfg:first-of-type { border-top: 0; padding-top: 0; }
|
||
.cfg .ct { font-size: 14px; font-weight: 600; }
|
||
.cfg .ct small { display: block; font-weight: 400; color: var(--mute); font-size: 12px; }
|
||
.cfg .seg { margin: 6px 0 4px; }
|
||
.cfg .seg button { padding: 6px 6px; font-size: 13.5px; line-height: 1.25; }
|
||
.seg button small.dl { display: block; font-size: 11.5px; color: var(--mute); font-variant-numeric: tabular-nums; }
|
||
.seg button.on small.dl { color: var(--accent); }
|
||
.seg button:disabled { opacity: 0.42; cursor: not-allowed; pointer-events: none; }
|
||
.cfg.off .ct { opacity: 0.55; }
|
||
.cfg .hint { margin: 0 0 4px; font-size: 12px; }
|
||
.cfg .row { margin: 4px 0 6px; }
|
||
.cfg .row.off { opacity: 0.5; }
|
||
input[type=number]:disabled { opacity: 0.55; cursor: not-allowed; }
|
||
.bud { display: grid; grid-template-columns: 74px 1fr; gap: 14px 10px; align-items: center; margin: 22px 0 6px; }
|
||
.bud .bl { font-size: 13px; color: var(--mute); line-height: 1.2; }
|
||
.bud .bl b { display: block; color: var(--fg); font-size: 14px; }
|
||
.btrack { position: relative; height: 22px; background: var(--panel2); border-radius: 4px; }
|
||
.bfill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 4px 0 0 4px; background: var(--dstd); }
|
||
.bfill.res { background: repeating-linear-gradient(45deg, var(--dspec) 0 5px, transparent 5px 9px); border-radius: 0; opacity: 0.85; }
|
||
.bmark { position: absolute; top: -5px; bottom: -5px; width: 2px; background: var(--fg); opacity: 0.75; }
|
||
.bmark.lo { background: var(--warn); opacity: 1; }
|
||
.bmark i { position: absolute; top: -17px; width: 44px; font-style: normal; font-size: 11px; color: var(--mute); }
|
||
.bmark.lo i { right: 4px; text-align: right; }
|
||
.bmark:not(.lo) i { left: 4px; text-align: left; }
|
||
.bgap { grid-column: 2; margin-top: -10px; font-size: 12.5px; color: var(--mute); }
|
||
.bgap .over { color: var(--bad); } .bgap .fit { color: var(--accent); }
|
||
#cashSvg { display: block; width: 100%; height: auto; margin: 6px 0 4px; }
|
||
.cv-pos { fill: var(--accent); opacity: 0.8; } .cv-neg { fill: var(--bad); opacity: 0.8; }
|
||
.cv-line { fill: none; stroke: var(--dstd); stroke-width: 2.2; } .cv-dot { fill: var(--dstd); }
|
||
.cv-axis { stroke: var(--stroke); stroke-width: 1; } .cv-zero { stroke: var(--mute); stroke-width: 1; stroke-dasharray: 3 3; }
|
||
.cv-t { fill: var(--mute); font-size: 11px; }
|
||
.inl { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; margin: 4px 0 8px; font-size: 13.5px; }
|
||
.inl label { display: flex; align-items: center; gap: 6px; }
|
||
.inl input[type=number] { width: 64px; }
|
||
#ivT td.n, #cashT td.n, #cashT td:first-child { white-space: nowrap; }
|
||
.pk-off { opacity: 0.32; }
|
||
.pk-tag { fill: var(--warn); font-size: 10.5px; font-weight: 600; }
|
||
[hidden] { display: none !important; }
|
||
.grid > div { min-width: 0; }
|
||
.tscroll { overflow-x: auto; }
|
||
.tscroll table { min-width: 560px; }
|
||
#ladT { min-width: 1140px; }
|
||
#cashSvg { min-width: 480px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<button class="themebtn" id="themeBtn" type="button">Light mode</button>
|
||
<h1>Plant and business plan 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, then configure the plant: galvanics in-house or bought stampers,
|
||
cutting in-house or outsourced, metrology, cassette and CD-R, solar, steam
|
||
fuel, utility sourcing, crane, visitor corridor and the ramp-up reserve. 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 launch plant: one Duplex system and one
|
||
Classic. It builds the investment from the blocks in total-investment.md,
|
||
checks it against the investor's 180M and 216M THB, runs the P&L and a
|
||
ten-year cash sketch, and lists what the plant needs: people, power, central
|
||
hydraulics, steam, cooling, compressed air, floor, compound and stampers.
|
||
Vendor figures are marked as such; the rest are planning figures and
|
||
estimates from the CNX Vinyl documents. Every figure can be changed under
|
||
Assumptions.
|
||
</p>
|
||
<div class="presets" id="presets">
|
||
<span>Presets:</span>
|
||
<button type="button" data-p="launch">Launch plant (decided)</button>
|
||
<button type="button" data-p="levers">Investor levers (216M)</button>
|
||
<button type="button" data-p="lean">Lean: bought stampers, outsourced cutting</button>
|
||
<button type="button" data-p="five">Five-press fleet (second Duplex)</button>
|
||
<button type="button" data-p="full">Full hall, Classic-led (2 D + 11 C)</button>
|
||
</div>
|
||
</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>Plant configuration</h2>
|
||
<p class="hint" style="margin:0 0 10px">Each switch changes the investment, the budget answer and the money
|
||
a year. Under each option: what it adds to or saves on the capex against the current choice, at the
|
||
midpoint with contingency. An outsourced step of the title services is passed through at cost: its
|
||
revenue stays, its contribution is zero.</p>
|
||
<div id="cfg"></div>
|
||
</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>
|
||
<div class="row" style="margin-top:4px"><span>Capex used for depreciation, payback and the cash view<br><small>adverse takes the high end, central the midpoint, favourable the low end</small></span></div>
|
||
<div class="seg" id="capexEnd">
|
||
<button data-v="low">Low end</button>
|
||
<button data-v="mid">Midpoint</button>
|
||
<button data-v="high">High end</button>
|
||
</div>
|
||
<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>
|
||
<div class="row" style="margin-top:12px"><span>BOI promotion</span></div>
|
||
<div class="seg" id="boi">
|
||
<button data-v="on">Promoted</button>
|
||
<button data-v="off">Not promoted</button>
|
||
</div>
|
||
<label class="row"><span>Records exported, %<br><small>exports: no duty on their raw material (BOI), VAT zero-rated</small></span><input type="number" id="exportShare" min="0" max="100" step="5"></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>Budget: the investor's question</h2>
|
||
<p class="note" style="margin-top:0">The investor asked whether the plant can be built for 180M THB, possibly
|
||
20 % more (216M THB). Bars: the investment at the low end, the midpoint and the high end of every block
|
||
(contingency included), plus the ramp-up reserve when it is in the budget (hatched).</p>
|
||
<div class="kpis" id="budK"></div>
|
||
<div class="bud" id="bud"></div>
|
||
<div id="budV"></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 id="boiNote"></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>Ten years of cash</h2>
|
||
<p class="note" style="margin-top:0">A sketch, not a forecast: pre-tax, pre-financing, no BOI corporate
|
||
income tax holiday modelled, no terminal value. Year 0 is the investment (at the capex end chosen under
|
||
Prices and money) plus the ramp-up reserve when it is in the budget; from year 1 the plant sells the
|
||
ramp share of its output. Payroll and overheads are paid in full from year 1; materials, energy and
|
||
title services follow output.</p>
|
||
<div class="inl">
|
||
<span>Sold output, % of the mature year <span class="badge est">estimate</span></span>
|
||
<label>Y1 <input type="number" data-a="ramp1" min="0" max="150" step="5" aria-label="Year 1 output share"></label>
|
||
<label>Y2 <input type="number" data-a="ramp2" min="0" max="150" step="5" aria-label="Year 2 output share"></label>
|
||
<label>Y3 <input type="number" data-a="ramp3" min="0" max="150" step="5" aria-label="Year 3 output share"></label>
|
||
<label>Y4 on <input type="number" data-a="ramp4" min="0" max="150" step="5" aria-label="Output share from year 4"></label>
|
||
<label>Discount rate, % <input type="number" data-a="discRate" min="0" max="50" step="0.5" aria-label="Discount rate"></label>
|
||
</div>
|
||
<div class="kpis" id="cashK"></div>
|
||
<div class="tscroll"><svg id="cashSvg" viewBox="0 0 640 230" role="img" aria-label="Cumulative cash over ten years"></svg></div>
|
||
<div class="legend" id="cashLeg"></div>
|
||
<div class="tscroll" style="margin-top:10px"><table id="cashT"></table></div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>Investment</h2>
|
||
<p class="note" style="margin-top:0">Built from the blocks of total-investment.md (launch plant, October 2026)
|
||
for the fleet and configuration above. Vendor prices without Thai duty or import VAT, so BOI promotion is
|
||
assumed unless switched off. The default reproduces the launch plant roll-up.</p>
|
||
<div class="inl"><label>Contingency, % <input type="number" data-a="contPct" min="0" max="60" step="1" aria-label="Contingency"></label></div>
|
||
<div class="kpis" id="ivK"></div>
|
||
<div id="ivFlags"></div>
|
||
<div class="tscroll" style="margin-top:12px"><table id="ivT"></table></div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>What the fleet needs</h2>
|
||
<div class="tscroll"><table id="reqT"></table></div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wrap">
|
||
<section class="card">
|
||
<h2>Machine park and stamper production</h2>
|
||
<p class="note" style="margin-top:0">The fleet above as a schematic, not to scale: each Duplex cell as
|
||
in the cell sketch (presses either side of the operator, EX 50 and EX 35 behind him, trimmer in
|
||
front), each Classic with its built-in extruder; above it the stamper line that feeds the presses,
|
||
sized from the cycle times in galvanics.md; below it the central utilities. Bars show how busy
|
||
each station is; an amber box exceeds the plant as planned today.</p>
|
||
<div class="tscroll"><svg id="park" viewBox="0 0 1200 600" role="img" aria-label="Machine park and stamper production schematic"></svg></div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>From small to fully equipped</h2>
|
||
<p class="note" style="margin-top:0">Fleets from the launch plant 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 and plant configuration. 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, the investment from the blocks of finance/total-investment.md (launch plant,
|
||
October 2026) as the configuration selects them, 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. Electric steam pays the PEA energy charge and a demand charge per installed boiler module;
|
||
biomass steam pays the biomass heat price. Bought solar PV offsets the bill at no running cost; through
|
||
a PPA its yield is paid at the PPA price (to quote). 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.</p>
|
||
<p><b>Title services.</b> The fee per title (580 / 1,155 / 1,730 USD by case) is split by the artist demos'
|
||
per-title price of 37,836 THB: mastering, lacquer and cutting 51.1 %, electroforming 37.0 %, press setup
|
||
and test pressings 11.9 %. The steps done in-house earn the revenue model's 60 to 70 % contribution; the
|
||
lacquer blanks and plating chemistry are counted as their own lines and the balance up to that cost is
|
||
styli, remakes and other title costs (when the two lines alone cost more, the contribution is lower and
|
||
the page shows it). <b>An outsourced step is passed through at cost: its revenue stays, its contribution
|
||
is zero.</b> Stamper supply to other pressing plants is booked at its contribution only (business plan
|
||
draft 0.1: 300 titles at wholesale might net 60k to 100k USD); the wholesale price is not on file.
|
||
Cassette and CD-R revenue is illustrative, with no market data and no running costs on file, so it
|
||
stays out of the totals unless switched on.</p>
|
||
<p><b>Investment.</b> Utilities are built from modules so they follow the fleet: one 500 kg/h boiler module
|
||
per 500 kg/h of peak steam, one 250 kW chiller module per 250 kW of peak cooling, on a cooling backbone and
|
||
in a boiler hall and chiller compound built for four modules each; the clean-room adder is full with
|
||
galvanics and cutting in-house, half with one of them. Freight, installation and training scale with the
|
||
machinery the line names (Newbilt, Pheenix, Sibert, Sillitoe), the working capital with the records a
|
||
year against the revenue model's 0.84M (never below 60 %). Both scalings are estimates.</p>
|
||
<p><b>Ten years of cash.</b> Pre-tax and pre-financing, with no terminal value: it shows how long the
|
||
investment takes to come back at the chosen case and ramp, not what the plant is worth.</p>
|
||
<p><b>BOI.</b> The capex blocks in total-investment.md are vendor prices without Thai import duty or
|
||
VAT, so they already assume BOI promotion: machinery imported under it pays no duty (Investment
|
||
Promotion Act section 28) and, with the duty, no import VAT (Revenue Department, BOI OSOS FAQ).
|
||
"Not promoted" adds the duty on the imported machinery the configuration selects (presses, galvanics
|
||
line, Sibert, cutting room, metrology, ZLD and DI water) to the capex and shows the import VAT as cash
|
||
paid at the border, reclaimable as input tax. Raw material: no duty on compound for exported
|
||
records, 90 % off for domestic sales. Revenue is net of VAT (exports zero-rated, 7 % on domestic
|
||
sales passed on). Corporate income tax and BOI's CIT exemptions (solar, automation, R&D) are not
|
||
modelled.</p>
|
||
<p><b>Labour.</b> Base salaries per role (revenue-model.md, its loaded rates divided by 1.3; the cutting
|
||
engineer a team figure), plus the employer's 5 % social security up to the 17,500 THB ceiling, 25 %
|
||
for overtime, bonus and PPE, and the company's health care for every member of staff, office
|
||
included: group health insurance, a yearly check-up, and the risk-factor tests Thai law requires
|
||
for the press floor, galvanics and maintenance; and unified Engelbert Strauss workwear with the
|
||
company logo for everyone, shipped from Germany with Thai duty (worker-safety.md).</p>
|
||
<p><b>Not scaled here:</b> admin, sales and logistics wages (in the overheads line); cover for
|
||
leave and sickness. <b>Not in the investment</b> (as in total-investment.md): CD replication, financing
|
||
costs, currency moves, the archive's mobile shelving (to quote) and the central hydraulic plant around
|
||
the Newbilt aggregates (standby pump set, accumulator station, ring main; not costed yet); the ramp-up
|
||
reserve is shown on its own line.</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/total-investment.md, finance/utilities-investment.csv,
|
||
suppliers/china-utilities.md, quality-automation.md, finance/revenue-model.md, artist-order.html (per-title
|
||
price), 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: 850000, spec: 20, grow: "classic", dMin: 1, cMin: 1,
|
||
dFleet: 1, 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", boi: "on", exportShare: 70, capexEnd: "mid",
|
||
// plant configuration (defaults = the launch plant, total-investment.md October 2026)
|
||
galv: "in", galvPrice: "pheenix", cut: "in", metro: "full", cass: "on", cdr: "on", countCD: "off",
|
||
solar: "buy", steamFuel: "electric", sourcing: "thai", crane: "buy", corridor: "now", reserve: "in", stSupply: 0,
|
||
};
|
||
// allowed values of every switch, default first where it matters for readHash
|
||
const ENUM = {
|
||
mode: ["target", "fleet"], grow: ["classic", "duplex"], cur: ["USD", "THB", "EUR"], boi: ["on", "off"],
|
||
capexEnd: ["low", "mid", "high"], galv: ["in", "bought"], galvPrice: ["pheenix", "oms"], cut: ["in", "out"],
|
||
metro: ["full", "launch"], cass: ["on", "off"], cdr: ["on", "off"], countCD: ["off", "on"],
|
||
solar: ["buy", "ppa", "none"], steamFuel: ["electric", "biomass"], sourcing: ["thai", "china"],
|
||
crane: ["buy", "rent"], corridor: ["now", "later"], reserve: ["in", "sep"],
|
||
};
|
||
const CFGK = ["galv", "galvPrice", "cut", "metro", "cass", "cdr", "countCD", "solar", "steamFuel", "sourcing", "crane", "corridor", "reserve", "stSupply"];
|
||
// [group, id, label, value, unit, kind, source]
|
||
const AS = [];
|
||
function grp(g, rows) { rows.forEach(r => AS.push([g].concat(r))); }
|
||
// a low-to-high pair, shown in one row of the assumptions table: [id, label, lo, hi, unit, kind, source]
|
||
function rng(g, rows) { rows.forEach(([id, l, lo, hi, u, k, s]) => { AS.push([g, id + "Lo", l, lo, u, k, s, 1]); AS.push([g, id + "Hi", l, hi, u, k, s, 2]); }); }
|
||
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)"],
|
||
["deprYears", "Depreciation period", 12.5, "years", "doc", "revenue-model.md: 10 to 15 years"],
|
||
]);
|
||
grp("BOI and imports", [
|
||
["dutyMach", "Import duty on imported machinery without BOI", 5, "%", "est", "Estimate: Thai MFN averages 4.1 % on HS 8477 plastics machinery (customs-compliance.ai, March 2026), 0 to 10 % across HS 84; set per line once the HS codes are known. Imported machinery = the selected presses, galvanics line, Sibert, cutting room, metrology and ZLD/DI blocks"],
|
||
["vatRate", "VAT", 7, "%", "doc", "Thai VAT; with BOI duty exemption the import VAT is exempt too (Revenue Department, BOI OSOS FAQ; boi.md)"],
|
||
["pvcImp", "Share of PVC compound imported", 100, "%", "est", "Estimate: record-grade compound from the specialist suppliers"],
|
||
["dutyPVC", "Import duty on PVC compound without BOI", 5, "%", "est", "Estimate; no Thai tariff line checked for record compound yet"],
|
||
["boiRawRed", "BOI duty reduction on raw material for domestic sales", 90, "%", "doc", "boi.md: exempt for export production (section 36(1)), up to 90 % off for domestic sales (section 30)"],
|
||
]);
|
||
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.5 to 6.8M 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"],
|
||
["eBoilEff", "Electric boiler efficiency", 98, "%", "est", "Estimate for an electric steam boiler (~98 %)"],
|
||
["eBoilKW", "Electric boiler demand per installed module", 360, "kW", "est", "Estimate: a 500 kg/h electric module at full load; the demand charge counts every installed module, so it errs high"],
|
||
["ppaPrice", "Solar PPA power price", 3.0, "THB/kWh", "est", "Estimate, to quote: the EPC owns and finances the array, the plant buys its yield (total-investment.md investor levers)"],
|
||
]);
|
||
grp("Labour: base salaries, THB a month", [
|
||
["salOp", "Press and finishing operator", 15000, "THB", "doc", "revenue-model.md: ~19,500 loaded at ×1.3"],
|
||
["salQC", "QC", 20000, "THB", "doc", "revenue-model.md: ~26,000 loaded"],
|
||
["salMaint", "Maintenance", 25000, "THB", "doc", "revenue-model.md: ~32,500 loaded"],
|
||
["salGalv", "Galvanics", 25000, "THB", "doc", "revenue-model.md: ~32,500 loaded"],
|
||
["salCut", "Cutting engineer", 50000, "THB", "user", "Team figure, October 2026 (revenue-model.md)"],
|
||
["officeHeads", "Admin, sales and logistics staff", 4, "people", "doc", "scale.md: 3 to 5; their wages sit in the overheads, they count here for health care"],
|
||
["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("Labour: on-costs and health care", [
|
||
["ssRate", "Employer social security", 5, "% of salary", "doc", "Social Security Act, section 33: 5 % up to the wage ceiling (worker-safety.md)"],
|
||
["ssCap", "Social security wage ceiling", 17500, "THB a month", "doc", "Raised from 15,000 on 1 January 2026, so at most 875 THB a month (worker-safety.md)"],
|
||
["otherOn", "Overtime, bonus, PPE and other on-costs", 25, "% of salary", "doc", "With social security this is the revenue model's ×1.3 at 15,000 THB"],
|
||
["healthIns", "Group health insurance (inpatient and outpatient)", 15000, "THB a head a year", "doc", "worker-safety.md: AXA SME plans 9,394 to 18,006 THB at 25 staff (CheckDi, October 2026); local plans 8,000 to 12,000"],
|
||
["checkup", "Annual health check-up", 2500, "THB a head a year", "est", "Estimate for an age mix; Rajavej Chiang Mai 2026 programmes 1,290 to 5,990 THB by age (worker-safety.md)"],
|
||
["occCheck", "Risk-factor tests (hearing, lungs, metals)", 1500, "THB a head a year", "est", "Estimate; required yearly for press floor, galvanics and maintenance (Ministerial Regulation B.E. 2547, worker-safety.md)"],
|
||
]);
|
||
grp("Workwear: Engelbert Strauss for all staff", [
|
||
["wwProd", "Production kit, with logo", 338, "EUR", "doc", "worker-safety.md: 3 trousers e.s.motion 2020, 3 polos, 2 T-shirts, sweatshirt, S1 safety shoes; Strauss prices without German VAT, October 2026; embroidery ~5 EUR a piece (estimate)"],
|
||
["wwOffice", "Office kit, with logo", 127, "EUR", "doc", "worker-safety.md: 4 polo shirts and a softshell jacket"],
|
||
["wwProdYr", "Production kits a head a year", 1, "", "est", "Estimate: daily wear and washing"],
|
||
["wwOffYr", "Office kits a head a year", 0.5, "", "est", "Estimate"],
|
||
["wwFreight", "Shipping to Thailand", 15, "% of goods", "est", "Estimate; Strauss quotes shipping with the order confirmation"],
|
||
["wwDuty", "Thai import duty on workwear", 14, "%", "doc", "HS 6203 work trousers 13.8 % (customs-compliance.ai, March 2026); BOI does not cover clothing; import VAT reclaimable"],
|
||
]);
|
||
grp("Planned plant, for the checks", [
|
||
["boilerMod", "Steam boiler module", 500, "kg/h", "doc", "scale.md utility phasing and steam-cooling.html: 500 kg/h modules, B-1 and B-2 at launch (proposal, estimate)"],
|
||
["chillerMod", "Chiller module", 250, "kW", "doc", "scale.md launch plant: 250 kW air-cooled modules at 40 °C, CH-1 and CH-2 at launch (proposal, estimate)"],
|
||
["modRooms", "Modules the boiler hall and the chiller compound are built for", 4, "each", "doc", "Utility yard decided October 2026: rooms for four boiler and four chiller modules (scale.md)"],
|
||
["hall", "Payap press hall", 870, "m²", "doc", "site-candidates.md, Payap scenario model"],
|
||
]);
|
||
grp("Title services and add-on lines", [
|
||
["titleContrib", "Contribution on title services done in-house", 65, "%", "doc", "revenue-model.md: 60 to 70 % after lacquers, styli and nickel (adverse 60, central 65, favourable 70)"],
|
||
["shareCut", "Share of the title fee: mastering, lacquer and cutting", 51.1, "%", "est", "Artist demos' per-title price 37,836 THB: mastering 10,000 + lacquers 5,336 + cutting 4,000 THB (artist-order.html price list, estimates)"],
|
||
["sharePlate", "Share of the title fee: electroforming (stampers)", 37.0, "%", "est", "Same price list: father, mother and stamper 14,000 THB of 37,836"],
|
||
["shareSetup", "Share of the title fee: press setup and test pressings", 11.9, "%", "est", "Same price list: setup 3,000 + five test pressings 1,500 THB of 37,836"],
|
||
["stSupplyC", "Stamper supply to other plants: contribution a title", 267, "USD", "est", "Business plan draft 0.1 and revenue-model.md: 300 titles at wholesale might net 60k to 100k USD, so 200 to 333 a title (adverse 200, favourable 333)"],
|
||
["cassN", "Cassettes a year, if counted", 40000, "", "est", "Business plan draft 0.1: illustratively 20k to 60k a year; no market data"],
|
||
["cassPrice", "Cassette price", 3.25, "USD", "est", "Business plan draft 0.1: 2.50 to 4.00 USD; no market data"],
|
||
["cdrN", "CD-Rs a year, if counted", 25000, "", "est", "Business plan draft 0.1: illustratively 10k to 40k a year; no market data"],
|
||
["cdrPrice", "CD-R price", 2.25, "USD", "est", "Business plan draft 0.1: 1.50 to 3.00 USD; no market data"],
|
||
]);
|
||
rng("Investment blocks (USD unless THB)", [
|
||
["p0", "Phase 0: BOI consultant, legal and lease work, soil test, re-quotes", 40000, 80000, "USD", "est", "total-investment.md launch plant, estimate"],
|
||
["dup1", "Newbilt Duplex system, the first", 350000, 370000, "USD", "doc", "total-investment.md: 2024 quote EUR 200,500 for the Single 2.1 plus the quoted second press and extruder ~USD 115k; expired, re-quote"],
|
||
["dupN", "Each further Duplex system", 230000, 300000, "USD", "est", "total-investment.md added scope: two presses at the quoted ~115k per press and extruder set, plus hydraulic unit and EX 35; no EX 35 price on file"],
|
||
["cla", "Each Newbilt Classic", 290000, 460000, "USD", "est", "total-investment.md: placeholder (the old AD12 band per press); no Newbilt price on file"],
|
||
["gPh", "Pheenix galvanics line (PASU, PPPU, PDPU, PMPB), Pheenix estimate", 320000, 650000, "USD", "est", "total-investment.md: no Pheenix price on file"],
|
||
["gOms", "Galvanics line priced near OMS's reference", 259000, 359000, "USD", "vendor", "OMS letter, 9 October 2026: 4-cell wet room EUR 224k basic to 311k advanced, at 1.155 USD; used as a price basis for the Pheenix line (investor lever)"],
|
||
["gWat", "Galvanics water: ZLD evaporator and DI water", 3200000, 8500000, "THB", "est", "Utilities CSV rows ZLD 3.0 to 8.0M and RO/DI 0.2 to 0.5M THB, market estimates"],
|
||
["wtb", "Water-treatment building next to galvanics, ~12 × 10 m", 1700000, 2900000, "THB", "est", "total-investment.md: estimate at the building rate"],
|
||
["sib", "Sibert VSF, VSP, VSC", 250000, 250000, "USD", "vendor", "Sibert's budget confirmation, 5 October 2026; its time, spares, shipping and travel in the freight and working-capital lines"],
|
||
["cutR", "Cutting room: Sillitoe SDMS, spare cutterhead, console and chain, monitoring, room", 200000, 350000, "USD", "est", "SDMS list price USD 125k (vendor, ex tax and shipping); the rest estimate (total-investment.md)"],
|
||
["metF", "Metrology, full stack (XRF, laser confocal, wide-area profiler, inline vision, audio station)", 350000, 810000, "USD", "est", "total-investment.md, quality-automation.md; phaseable"],
|
||
["metL", "Metrology, launch tier (XRF, inline vision, audio station)", 170000, 460000, "USD", "est", "total-investment.md investor lever: confocal and profiler bought from revenue, saves ~10.1M THB at the midpoint"],
|
||
["pvcL", "PVC lab, day-one tier", 55000, 150000, "USD", "est", "quality-automation.md"],
|
||
["bTh", "Electric boiler module, 500 kg/h, Thai supplier, installed", 2250000, 3500000, "THB", "est", "Utilities CSV: bank of two 4.5 to 7.0M THB, engineering estimate"],
|
||
["bCn", "Electric boiler module, 500 kg/h, Chinese route, installed", 1400000, 2750000, "THB", "est", "china-utilities.md: two ASME-stamped modules 2.8 to 5.5M THB installed"],
|
||
["bBio", "Biomass boiler module, 500 kg/h, installed", 3000000, 5000000, "THB", "est", "Utilities CSV: biomass package 6 to 10M THB for ~1 t/h (Getabec class), estimate"],
|
||
["cBk", "Cooling backbone: plate HX, tanks, pumps, mains for four modules", 1700000, 2700000, "THB", "est", "Utilities CSV: chiller bank of two 4.9 to 7.9M THB less two modules"],
|
||
["chM", "Chiller module, 250 kW air-cooled, installed", 1600000, 2600000, "THB", "est", "Utilities CSV: one module ~1.6 to 2.6M THB, estimate"],
|
||
["airT", "Compressed air, Thai supplier", 350000, 600000, "THB", "est", "Utilities CSV: screw compressor skid, dryer, receiver"],
|
||
["airC", "Compressed air, Chinese brand through its Thai office", 150000, 300000, "THB", "est", "china-utilities.md: PM VSD screw set 0.15 to 0.3M THB installed"],
|
||
["clR", "Clean-room adder (galvanics and cutting room)", 1200000, 3400000, "THB", "est", "Utilities CSV, ~2 × 60 m² ISO 7/8; half with only one of the two rooms (estimate)"],
|
||
["uRest", "Rest of the utilities baseline: softener, loop treatment, racking, whole-factory HVAC", 8620000, 17300000, "THB", "est", "Utilities CSV baseline 28.47 to 48.73M THB less boilers, chillers, air, clean room, PV and battery"],
|
||
["pv", "Rooftop PV EPC", 7500000, 10500000, "THB", "est", "Utilities CSV: illustrative 300 kWp at 25,000 to 35,000 THB/kWp; final kWp open (scale.md 247 kWp for the yield)"],
|
||
["bat", "Battery 271 kWh (Gobel)", 1400000, 2030000, "THB", "vendor", "Utilities CSV: vendor listed price USD 39,900 to 58,000"],
|
||
["bld", "Building ~1,500 m² plus civil and site works", 650000, 1100000, "USD", "est", "total-investment.md: 12,000 to 20,000 THB/m² plus ~20 % site works; soil is the main variable"],
|
||
["ph2", "Phase 2 building, when the press floor outgrows the press hall", 750000, 1250000, "USD", "est", "Business plan draft 0.1: ~750k to 1,250k on 1,710 m², estimate"],
|
||
["crn", "Overhead crane, runway, frame premium, machine door", 97000, 227000, "USD", "est", "crane-workshop-tours.md via total-investment.md"],
|
||
["wsh", "Maintenance workshop and machine bay fit-out", 30000, 76000, "USD", "est", "total-investment.md"],
|
||
["cor", "Visitor corridor and accessible entrance", 67000, 148000, "USD", "est", "total-investment.md"],
|
||
["yard", "Utility yard for four boiler and four chiller modules", 73000, 122000, "USD", "est", "scale.md, total-investment.md"],
|
||
["fsw", "Factory software", 60000, 170000, "USD", "est", "total-investment.md, digital-factory.md"],
|
||
["erp", "ERPNext implementation", 15000, 50000, "USD", "est", "digital-factory.md"],
|
||
["apf", "Artist platform, public side", 40000, 90000, "USD", "est", "artist-platform.md"],
|
||
["cas", "Cassette duplication", 40000, 150000, "USD", "est", "Business plan section 4.8 (total-investment.md: with CD-R 55k to 190k)"],
|
||
["cdr", "CD-R duplication", 15000, 40000, "USD", "est", "Business plan section 4.9"],
|
||
["frt", "Freight, installation, commissioning, training at the launch plant", 150000, 300000, "USD", "est", "total-investment.md; scaled by the Newbilt, Pheenix, Sibert and Sillitoe machinery against the launch plant (estimate)"],
|
||
["wc", "Working capital, PVC stock, spares, lease deposit, first-year rent", 135000, 290000, "USD", "est", "total-investment.md; scaled by records a year against 0.84M (estimate)"],
|
||
["res", "Ramp-up reserve: staff and overheads before volume covers them", 10000000, 20000000, "THB", "est", "total-investment.md: not in the roll-up, ~10M to 20M THB"],
|
||
]);
|
||
grp("Investment rules", [
|
||
["contPct", "Contingency", 15, "%", "doc", "total-investment.md"],
|
||
["craneRent", "Crane rented for the installations, share of the crane block", 40, "%", "est", "Frame stays crane-ready; total-investment.md: saves ~3.7M THB at the midpoint"],
|
||
["wcRef", "Records a year the working capital is sized for", 840000, "", "doc", "revenue-model.md launch plant, central volume"],
|
||
["wcMin", "Working capital never below", 60, "%", "est", "Estimate"],
|
||
["budgetLo", "Investor's budget", 180000000, "THB", "doc", "total-investment.md: the investor's question, 180M THB"],
|
||
["budgetHi", "Investor's budget plus 20 %", 216000000, "THB", "doc", "total-investment.md: 216M THB"],
|
||
]);
|
||
grp("Ten years of cash", [
|
||
["ramp1", "Sold output in year 1", 35, "% of the mature year", "est", "Estimate"],
|
||
["ramp2", "Sold output in year 2", 70, "% of the mature year", "est", "Estimate"],
|
||
["ramp3", "Sold output in year 3", 90, "% of the mature year", "est", "Estimate"],
|
||
["ramp4", "Sold output from year 4", 100, "% of the mature year", "est", "Estimate"],
|
||
["discRate", "Discount rate for the NPV", 10, "%", "est", "Estimate"],
|
||
]);
|
||
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 and the high capex end, favourable the reverse
|
||
const CASES = {
|
||
adverse: { S: { priceStd: 3.4, premSpec: 0.6, titleFee: 580, capexEnd: "high" },
|
||
A: { pvcPrice: 2.9, labelCost: 0.09, sleeveCost: 0.17, jacketCost: 0.92, shrinkCost: 0.03, maintCost: 0.23, overheads: 450000, heatPrice: 0.9, deprYears: 10,
|
||
titleContrib: 60, stSupplyC: 200, cassN: 20000, cassPrice: 2.5, cdrN: 10000, cdrPrice: 1.5 } },
|
||
central: { S: { priceStd: 3.8, premSpec: 1.15, titleFee: 1155, capexEnd: "mid" },
|
||
A: { pvcPrice: 2.3, labelCost: 0.06, sleeveCost: 0.13, jacketCost: 0.66, shrinkCost: 0.025, maintCost: 0.17, overheads: 330000, heatPrice: 0.7, deprYears: 12.5,
|
||
titleContrib: 65, stSupplyC: 267, cassN: 40000, cassPrice: 3.25, cdrN: 25000, cdrPrice: 2.25 } },
|
||
favourable: { S: { priceStd: 4.25, premSpec: 1.7, titleFee: 1730, capexEnd: "low" },
|
||
A: { pvcPrice: 1.75, labelCost: 0.03, sleeveCost: 0.09, jacketCost: 0.4, shrinkCost: 0.02, maintCost: 0.12, overheads: 200000, heatPrice: 0.5, deprYears: 15,
|
||
titleContrib: 70, stSupplyC: 333, cassN: 60000, cassPrice: 4, cdrN: 40000, cdrPrice: 3 } },
|
||
};
|
||
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 = [
|
||
["Launch plant", 1, 1], ["Second Duplex", 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],
|
||
];
|
||
// presets: fleet plus plant configuration; prices, case and assumptions stay as they are
|
||
const CFG0 = Object.fromEntries(CFGK.map(k => [k, S0[k]]));
|
||
const LAUNCH = { mode: "target", target: 850000, grow: "classic", dMin: 1, cMin: 1, dFleet: 1, cFleet: 1, ...CFG0 };
|
||
const PRESETS = {
|
||
launch: LAUNCH,
|
||
levers: { ...LAUNCH, solar: "ppa", metro: "launch", galvPrice: "oms", corridor: "later" },
|
||
lean: { ...LAUNCH, galv: "bought", cut: "out" },
|
||
five: { mode: "fleet", dFleet: 2, cFleet: 1, ...CFG0 },
|
||
full: { mode: "fleet", dFleet: 2, cFleet: 11, ...CFG0 },
|
||
};
|
||
|
||
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 galvIn = S.galv !== "bought", cutIn = S.cut !== "out";
|
||
const supply = galvIn ? Math.max(0, S.stSupply || 0) : 0; // titles plated for other plants
|
||
const partsYr = galvIn ? titles * partsTitle : 0; // own titles' electroformed parts
|
||
const partsAll = galvIn ? (titles + supply) * partsTitle : 0;
|
||
const partsDay = partsAll / 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), gDay = (titles + supply) / Math.max(1, S.days);
|
||
const flow = { titlesDay: tDay, supDay: supply / Math.max(1, S.days), lacq: 2 * tDay, glacq: 2 * gDay,
|
||
fathers: 2 * gDay, mothers: 2 * gDay, stampers: 2 * perSide * gDay };
|
||
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 off = { n: 0, u: 0, off: true };
|
||
const galv = galvIn ? {
|
||
pasu: need(flow.glacq * A.silverMin, avail),
|
||
pppu: need(flow.glacq * (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),
|
||
} : { pasu: off, pppu: off, pdpu: off, pmpb: off, vsf: off, vsp: off, vsc: off };
|
||
galv.lathe = cutIn ? need(flow.lacq, A.lathe) : off;
|
||
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;
|
||
const steam = [P * A.steamLo, P * A.steamHi];
|
||
const mods = (v, m) => v > 0 && m > 0 ? Math.ceil(v / m - 1e-9) : 0;
|
||
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, supply, partsYr, partsAll, partsDay, flow, gst: galv, galvIn, cutIn,
|
||
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, coolPeak, nb: mods(steam[1], A.boilerMod), nc: mods(coolPeak, A.chillerMod),
|
||
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,
|
||
};
|
||
}
|
||
// Investment from the blocks of total-investment.md (launch plant, October 2026), in USD.
|
||
// c = the plant configuration (S by default, a modified copy for the live deltas).
|
||
function capexOf(r, c = S, boiOn = c.boi !== "off") {
|
||
const fx = A.thbUsd > 0 ? A.thbUsd : 33, T = v => v / fx;
|
||
const rows = [], meta = id => AS.find(x => x[1] === id + "Lo");
|
||
// one block: id in AS (Lo/Hi pair), n units, thb = AS figures in THB; o.imp = imported machinery, o.frt = in the freight basis
|
||
const blk = (g, id, l, n = 1, thb = false, o = {}) => {
|
||
const m = meta(id), lo = thb ? T(A[id + "Lo"]) : A[id + "Lo"], hi = thb ? T(A[id + "Hi"]) : A[id + "Hi"];
|
||
rows.push(Object.assign({ g, id, l, lo: n * lo, hi: n * hi, k: m[5], s: m[6] }, o));
|
||
};
|
||
const galvIn = c.galv !== "bought", cutIn = c.cut !== "out", pl = (n, w) => `${n} × ${w}${n === 1 ? "" : "s"}`;
|
||
blk("Preparation", "p0", "Phase 0: BOI consultant, legal and lease work, soil test, re-quotes");
|
||
if (r.D > 0) blk("Presses", "dup1", "Newbilt Duplex system 1: two HRP 12, EX 50 + EX 35, trimmer, label oven, shrink wrapper, grinder, label punch", 1, false, { imp: 1, frt: 1 });
|
||
if (r.D > 1) blk("Presses", "dupN", `Further Duplex systems: ${r.D - 1} ×`, r.D - 1, false, { imp: 1, frt: 1 });
|
||
if (r.C > 0) blk("Presses", "cla", r.C === 1 ? "Newbilt Classic" : `Newbilt Classic: ${r.C} ×`, r.C, false, { imp: 1, frt: 1 });
|
||
if (galvIn) {
|
||
const oms = c.galvPrice === "oms";
|
||
blk("Galvanics and stamper prep", oms ? "gOms" : "gPh", `Pheenix galvanics line (PASU, PPPU, PDPU, PMPB), ${oms ? "priced near OMS's reference" : "Pheenix estimate"}`,
|
||
1, false, { imp: 1, frt: 1, frtMid: (A.gPhLo + A.gPhHi) / 2 });
|
||
blk("Galvanics and stamper prep", "gWat", "Galvanics water: ZLD evaporator and DI water", 1, true, { imp: 1 });
|
||
blk("Galvanics and stamper prep", "wtb", "Water-treatment building next to galvanics, ~12 × 10 m", 1, true);
|
||
blk("Galvanics and stamper prep", "sib", "Sibert VSF, VSP, VSC", 1, false, { imp: 1, frt: 1 });
|
||
}
|
||
if (cutIn) blk("Cutting room", "cutR", "Cutting room: Sillitoe SDMS, spare cutterhead, console and chain, monitoring, room", 1, false, { imp: 1, frt: 1 });
|
||
if (c.metro === "launch") blk("Quality", "metL", "Metrology, launch tier: XRF, inline vision, audio station", 1, false, { imp: 1 });
|
||
else blk("Quality", "metF", "Metrology, full stack: XRF, laser confocal, wide-area profiler, inline vision, audio station", 1, false, { imp: 1 });
|
||
blk("Quality", "pvcL", "PVC lab, day-one tier");
|
||
const bio = c.steamFuel === "biomass", cn = c.sourcing === "china";
|
||
if (r.nb > 0) blk("Utilities", bio ? "bBio" : cn ? "bCn" : "bTh",
|
||
`Steam: ${pl(r.nb, `${f0(A.boilerMod)} kg/h ${bio ? "biomass" : "electric"} boiler module`)}${bio ? "" : cn ? ", Chinese route" : ", Thai supplier"}`, r.nb, true);
|
||
blk("Utilities", "cBk", "Cooling backbone: plate HX, tanks, pumps and mains for four modules", 1, true);
|
||
if (r.nc > 0) blk("Utilities", "chM", `Chillers: ${pl(r.nc, `${f0(A.chillerMod)} kW air-cooled module`)}`, r.nc, true);
|
||
blk("Utilities", cn ? "airC" : "airT", `Compressed air: screw compressor, dryer, receiver, ${cn ? "Chinese brand through its Thai office" : "Thai supplier"}`, 1, true);
|
||
const cr = (galvIn ? 0.5 : 0) + (cutIn ? 0.5 : 0);
|
||
if (cr > 0) blk("Utilities", "clR", cr === 1 ? "Clean-room adder: galvanics and cutting room" : `Clean-room adder, half: ${galvIn ? "galvanics" : "cutting room"} only`, cr, true);
|
||
blk("Utilities", "uRest", "Rest of the utilities baseline: softener, loop treatment, racking, whole-factory HVAC", 1, true);
|
||
if (c.solar === "buy") {
|
||
blk("Utilities", "pv", "Rooftop PV, EPC", 1, true);
|
||
blk("Utilities", "bat", "Battery 271 kWh (Gobel)", 1, true);
|
||
}
|
||
blk("Building and site", "bld", "Building ~1,500 m² plus civil and site works");
|
||
if (r.floor > A.hall) blk("Building and site", "ph2", `Phase 2 building: ~${f0(r.floor)} m² of press floor outgrow the ${f0(A.hall)} m² press hall`);
|
||
if (c.crane === "rent") blk("Building and site", "crn", `Crane rented for the installations (${f0(A.craneRent)} % of the crane block); frame stays crane-ready`, A.craneRent / 100);
|
||
else blk("Building and site", "crn", "Overhead crane, runway, frame premium, machine door");
|
||
blk("Building and site", "wsh", "Maintenance workshop and machine bay fit-out");
|
||
if (c.corridor !== "later") blk("Building and site", "cor", "Visitor corridor and accessible entrance");
|
||
blk("Building and site", "yard", "Utility yard for four boiler and four chiller modules");
|
||
blk("Software", "fsw", "Factory software");
|
||
blk("Software", "erp", "ERPNext implementation");
|
||
blk("Software", "apf", "Artist platform, public side");
|
||
if (c.cass !== "off") blk("Add-on lines", "cas", "Cassette duplication");
|
||
if (c.cdr !== "off") blk("Add-on lines", "cdr", "CD-R duplication");
|
||
// freight follows the machinery the line names (Newbilt, Pheenix, Sibert, Sillitoe) at block midpoints;
|
||
// the galvanics price basis does not change what is shipped, so the Pheenix midpoint counts either way
|
||
const mid = x => (x.lo + x.hi) / 2;
|
||
const frtRef = (A.dup1Lo + A.dup1Hi + A.claLo + A.claHi + A.gPhLo + A.gPhHi + A.sibLo + A.sibHi + A.cutRLo + A.cutRHi) / 2;
|
||
const frtBase = rows.filter(x => x.frt).reduce((a, x) => a + (x.frtMid != null ? x.frtMid : mid(x)), 0);
|
||
const fr = frtRef > 0 ? frtBase / frtRef : 1;
|
||
blk("Freight and working capital", "frt", `Freight, installation, commissioning, training: ${pct(fr)} of the launch plant's`, fr);
|
||
const wf = Math.max(A.wcMin / 100, A.wcRef > 0 ? r.X / A.wcRef : 1);
|
||
blk("Freight and working capital", "wc", `Working capital, PVC stock, spares, lease deposit, first-year rent: ${pct(wf)} of the launch plant's`, wf);
|
||
const tri = (lo, hi) => ({ lo, mid: (lo + hi) / 2, hi });
|
||
const sumOf = (f, k) => rows.filter(f).reduce((a, x) => a + x[k], 0), all = () => true;
|
||
const cont = Math.max(0, A.contPct) / 100;
|
||
const sub = tri(sumOf(all, "lo"), sumOf(all, "hi"));
|
||
const ctg = tri(sub.lo * cont, sub.hi * cont);
|
||
const tot = tri(sub.lo + ctg.lo, sub.hi + ctg.hi);
|
||
const imp = tri(sumOf(x => x.imp, "lo"), sumOf(x => x.imp, "hi"));
|
||
const dr = boiOn ? 0 : Math.max(0, A.dutyMach) / 100;
|
||
const duty = tri(imp.lo * dr, imp.hi * dr);
|
||
const vat = tri(boiOn ? 0 : (imp.lo + duty.lo) * A.vatRate / 100, boiOn ? 0 : (imp.hi + duty.hi) * A.vatRate / 100);
|
||
const res = tri(T(A.resLo), T(A.resHi));
|
||
const withDuty = tri(tot.lo + duty.lo, tot.hi + duty.hi);
|
||
return { rows, sub, ctg, cont, tot, imp, duty, vat, res, all: withDuty, fr, wf, boiOn };
|
||
}
|
||
// Money a year in USD. Lines: [group, label, USD, basis].
|
||
function econ(r, b, boiOn = S.boi !== "off") {
|
||
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 cdOn = S.countCD === "on";
|
||
const revStd = st * S.priceStd, revSpec = r.sp * (S.priceStd + S.premSpec), revTitle = r.titles * S.titleFee;
|
||
const revSup = r.supply * A.stSupplyC;
|
||
const revCass = cdOn && S.cass !== "off" ? A.cassN * A.cassPrice : 0, revCdr = cdOn && S.cdr !== "off" ? A.cdrN * A.cdrPrice : 0;
|
||
const rev = revStd + revSpec + revTitle + revSup + revCass + revCdr;
|
||
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`);
|
||
const expSh = Math.min(100, Math.max(0, S.exportShare)) / 100;
|
||
const pvcImported = r.compound * 1000 * A.pvcPrice * A.pvcImp / 100;
|
||
const pvcDuty = pvcImported * A.dutyPVC / 100 * (boiOn ? (1 - expSh) * (1 - A.boiRawRed / 100) : 1);
|
||
add("mat", "Import duty on PVC compound", pvcDuty, boiOn
|
||
? `BOI: none on the ${pct(expSh)} exported, ${f0(A.boiRawRed)} % off the rest`
|
||
: `${f1(A.dutyPVC)} % on ${pct(A.pvcImp / 100)} imported compound, no BOI`);
|
||
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, "");
|
||
// title services: the steps done in-house earn the contribution, outsourced steps pass through at cost
|
||
const shC = Math.max(0, A.shareCut), shP = Math.max(0, A.sharePlate), shS = Math.max(0, A.shareSetup);
|
||
const shT = shC + shP + shS > 0 ? shC + shP + shS : 1;
|
||
const inSh = (shS + (r.cutIn ? shC : 0) + (r.galvIn ? shP : 0)) / shT;
|
||
const lacq = r.cutIn ? r.titles * 2 * A.lacquerCost * A.usdEur : 0, chem = r.partsYr * A.chemPart;
|
||
if (r.cutIn) add("title", "Lacquer blanks", lacq, `${f0(r.titles * 2)} lacquers at ${f0(A.lacquerCost)} EUR`);
|
||
if (r.galvIn) add("title", "Nickel, silver, plating chemistry", chem, `${f0(r.partsYr)} electroformed parts at ${f0(A.chemPart)} USD`);
|
||
const tc = Math.min(100, Math.max(0, A.titleContrib)) / 100;
|
||
add("title", "Styli, remakes, other title costs", Math.max(0, revTitle * inSh * (1 - tc) - lacq - chem),
|
||
`balance to the ${f0(tc * 100)} % contribution on the in-house steps (${pct(inSh)} of the fee)`);
|
||
if (!r.cutIn) add("title", "Mastering and cutting bought in, at cost", revTitle * shC / shT, `${pct(shC / shT)} of the title fee passed through; no contribution`);
|
||
if (!r.galvIn) add("title", "Stampers bought in, at cost", revTitle * shP / shT, `${pct(shP / shT)} of the title fee passed through; no contribution`);
|
||
// energy
|
||
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 = S.solar === "none" ? 0 : 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${S.solar === "none" ? " (no PV)" : S.solar === "ppa" ? " (PPA)" : ""}; ~${f0(demandKW)} kW demand`);
|
||
if (S.solar === "ppa") add("energy", "Solar PPA power", t2u(pvMWh * 1000 * A.ppaPrice), `${f0(pvMWh)} MWh at ${f2(A.ppaPrice)} THB/kWh (estimate, to quote)`);
|
||
if (S.steamFuel === "biomass")
|
||
add("energy", "Steam heat (biomass)", t2u(heatMWh * 1000 * A.heatPrice), `${f0(steamT)} t of steam, ${f0(heatMWh)} MWh of heat at ${f2(A.heatPrice)} THB/kWh`);
|
||
else {
|
||
const eMWh = heatMWh / Math.max(0.5, A.eBoilEff / 100), dKW = X > 0 ? r.nb * A.eBoilKW : 0;
|
||
add("energy", "Steam (electric boilers)", t2u(eMWh * 1000 * A.kwhPrice + dKW * A.demandPrice * 12),
|
||
`${f0(steamT)} t of steam: ${f0(eMWh)} MWh at ${f2(A.kwhPrice)} THB/kWh plus demand on ${r.nb} × ${f0(A.eBoilKW)} kW`);
|
||
}
|
||
const h = headsOf(r), ppl = n => n + (n === 1 ? " person" : " people");
|
||
let base = 0, ss = 0;
|
||
for (const [l, n, sal, why] of [["Press operators and finishing", h.ops, A.salOp], ["QC", h.qc, A.salQC],
|
||
["Maintenance", h.maint, A.salMaint], ["Galvanics", h.galv, A.salGalv, r.galvIn ? "" : "stampers bought in"],
|
||
["Cutting engineers", h.cut, A.salCut, r.cutIn ? "" : "cutting outsourced"]]) {
|
||
add("lab", l, t2u(n * sal * 12), why || `${ppl(n)} at ${f0(sal)} THB a month`);
|
||
base += n * sal * 12; ss += n * Math.min(sal, A.ssCap) * A.ssRate / 100 * 12;
|
||
}
|
||
add("lab", "Employer social security", t2u(ss), `${f1(A.ssRate)} % of salary up to ${f0(A.ssCap)} THB a month`);
|
||
add("lab", "Overtime, bonus, PPE", t2u(base * A.otherOn / 100), `${f1(A.otherOn)} % of base salaries`);
|
||
h.prod = h.ops + h.qc + h.maint + h.galv + h.cut; h.office = Math.max(0, Math.round(A.officeHeads));
|
||
h.all = h.prod + h.office; h.exposed = h.ops + h.galv + h.maint;
|
||
add("lab", "Health insurance, all staff", t2u(h.all * A.healthIns), `${ppl(h.all)}, office included, at ${f0(A.healthIns)} THB a year`);
|
||
add("lab", "Health check-ups, all staff", t2u(h.all * A.checkup + h.exposed * A.occCheck),
|
||
`yearly for everyone, plus risk-factor tests for ${ppl(h.exposed)} on the press floor, in galvanics and maintenance`);
|
||
const wwEur = (h.prod * A.wwProd * A.wwProdYr + h.office * A.wwOffice * A.wwOffYr) * (1 + A.wwFreight / 100) * (1 + A.wwDuty / 100);
|
||
add("lab", "Workwear, Engelbert Strauss, all staff", wwEur * A.usdEur,
|
||
`${f1(h.prod * A.wwProdYr)} production and ${f1(h.office * A.wwOffYr)} office kits a year, shipping and Thai duty included`);
|
||
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 cx = capexOf(r, S, boiOn), end = ENUM.capexEnd.includes(S.capexEnd) ? S.capexEnd : "mid";
|
||
const ek = { low: "lo", mid: "mid", high: "hi" }[end];
|
||
const capex = cx.all[ek], depr = capex / Math.max(1, A.deprYears);
|
||
const titleC = revTitle - sum(["title"]);
|
||
return { rev, revStd, revSpec, revTitle, revSup, revCass, revCdr, L, sum, direct, contrib, ebitda, capex, depr, op: ebitda - depr,
|
||
payback: ebitda > 0 ? capex / ebitda : Infinity, heads: h, imp: cx.imp[ek], dutyM: cx.duty[ek], vatM: cx.vat[ek], pvcDuty, boiOn,
|
||
cx, end, ek, titleC, titleRate: revTitle > 0 ? titleC / revTitle : 0, inSh, steamT, heatMWh, pvMWh };
|
||
}
|
||
// Ten years of cash, USD: year 0 the investment (+ the reserve when it is in the budget), then the ramp.
|
||
function cashView(e, r) {
|
||
const lab = e.sum(["lab"]), fixed = lab + A.overheads, varCost = e.direct - lab;
|
||
const res = S.reserve === "in" ? e.cx.res[e.ek] : 0, c0 = -(e.capex + res);
|
||
const Y = [{ y: 0, k: 0, rec: 0, rev: 0, ebitda: 0, cf: c0, cum: c0 }];
|
||
for (let y = 1; y <= 10; y++) {
|
||
const k = Math.max(0, A["ramp" + Math.min(y, 4)]) / 100;
|
||
const ebitda = (e.rev - varCost) * k - fixed;
|
||
Y.push({ y, k, rec: r.X * k, rev: e.rev * k, ebitda, cf: ebitda, cum: Y[y - 1].cum + ebitda });
|
||
}
|
||
let pay = null;
|
||
for (let y = 1; y <= 10; y++) if (Y[y].cum >= 0) { pay = y - 1 + (Y[y].cf > 0 ? -Y[y - 1].cum / Y[y].cf : 1); break; }
|
||
const npvAt = d => Y.reduce((a, x) => a + x.cf / Math.pow(1 + d, x.y), 0);
|
||
const npv = npvAt(A.discRate / 100);
|
||
let irr = null, lo = -0.95, hi = 1;
|
||
if (npvAt(lo) * npvAt(hi) > 0) hi = 10;
|
||
if (npvAt(lo) * npvAt(hi) <= 0) {
|
||
for (let i = 0; i < 200; i++) { const m = (lo + hi) / 2; if (npvAt(lo) * npvAt(m) <= 0) hi = m; else lo = m; }
|
||
irr = (lo + hi) / 2;
|
||
}
|
||
return { Y, pay, npv, irr, res, fixed, varCost };
|
||
}
|
||
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 Object.keys(ENUM)) { const el = $(g); if (el) for (const bt of el.querySelectorAll("button[data-v]")) 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);
|
||
for (const bt of $("presets").querySelectorAll("button[data-p]"))
|
||
bt.classList.toggle("on", Object.entries(PRESETS[bt.dataset.p]).every(([k, v]) => S[k] === v));
|
||
|
||
$("fleetK").innerHTML =
|
||
kpi(r.D, `Duplex systems<br>${2 * r.D} HRP 12 presses, ${r.D} EX 50, ${r.D} EX 35, ${r.D} trimmer${r.D === 1 ? "" : "s"}`, "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.`]);
|
||
const fuel = S.steamFuel === "biomass" ? "biomass" : "electric";
|
||
if (r.nb > A.modRooms)
|
||
flags.push(["warn", `Steam up to ${f0(r.steam[1])} kg/h needs ${r.nb} boiler modules of ${f0(A.boilerMod)} kg/h: beyond the boiler hall built for ${f0(A.modRooms)} modules (a second hall is not costed).`]);
|
||
if (r.nc > A.modRooms)
|
||
flags.push(["warn", `Cooling up to ${f0(r.coolPeak)} kW needs ${r.nc} chiller modules of ${f0(A.chillerMod)} kW: beyond the chiller compound built for ${f0(A.modRooms)} modules (more pads are not costed).`]);
|
||
if (r.nb <= A.modRooms && r.nc <= A.modRooms && r.P > 0)
|
||
flags.push(["ok", `Steam ${f0(r.steam[0])} to ${f0(r.steam[1])} kg/h on ${r.nb} × ${f0(A.boilerMod)} kg/h ${fuel} boiler modules; cooling up to ${f0(r.coolPeak)} kW on ${r.nc} × ${f0(A.chillerMod)} kW chiller modules (rooms for ${f0(A.modRooms)} each).`]);
|
||
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, so the investment adds a Phase 2 building.`]);
|
||
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.`;
|
||
const ENDS = { low: "low end", mid: "midpoint", high: "high end" };
|
||
$("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 (${ENDS[e.end]})`);
|
||
const eOn = econ(r, b, true), eOff = econ(r, b, false);
|
||
const pb = x => isFinite(x.payback) ? f1(x.payback) + " years" : "none";
|
||
$("boiNote").innerHTML = e.boiOn
|
||
? `<div class="flag ok">BOI promotion: no import duty (~${money(eOff.dutyM)}) and no import VAT (~${money(eOff.vatM)}) on ${money(eOn.imp)} of imported machinery, and ${money(eOff.pvcDuty - eOn.pvcDuty)} a year less duty on PVC compound. Without promotion the capex would be ${money(eOff.capex)} and the payback ${pb(eOff)}.</div>`
|
||
: `<div class="flag">Not promoted: ${money(eOff.dutyM)} import duty on ${money(eOff.imp)} of imported machinery goes into the capex and its depreciation; ${money(eOff.vatM)} import VAT is paid at the border and only comes back later as input tax (exports are zero-rated, so mostly as a refund claim); ${money(eOff.pvcDuty)} a year duty on PVC compound. With BOI the payback would be ${pb(eOn)}.</div>`;
|
||
const SEG = [["PVC and labels", e.sum(["mat"]), "#b07d48"], ["Packaging", e.sum(["pack"]), "#c9a24a"],
|
||
["Title services", e.sum(["title"]), "#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)}; ${pct(e.inSh)} of it done in-house`);
|
||
if (r.galvIn) PR("Stamper supply to other plants", e.revSup, r.supply > 0 ? `${f0(r.supply)} titles at ${money(A.stSupplyC)} contribution (booked at contribution; estimate)` : "none set (Plant configuration)");
|
||
const cdNote = S.countCD === "on" ? "" : "not counted (switch under Plant configuration)";
|
||
if (S.cass !== "off") PR("Cassettes", e.revCass, cdNote || `${f0(A.cassN)} at ${unit(A.cassPrice)}; illustrative, no running costs on file`);
|
||
if (S.cdr !== "off") PR("CD-Rs", e.revCdr, cdNote || `${f0(A.cdrN)} at ${unit(A.cdrPrice)}; illustrative, no running costs on file`);
|
||
PR("Revenue", e.rev, "", true);
|
||
const GRP = [["Materials, packaging, upkeep", ["mat", "pack", "maint"]], ["Title services: lacquers, plating, bought-in steps", ["title"]], ["Energy", ["energy"]], ["Labour and health care", ["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("of which title services", e.titleC, e.revTitle > 0 ? `${pct(e.titleRate)} of the title fee (in-house steps at ${f0(A.titleContrib)} %, outsourced steps at zero)` : "");
|
||
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 (${ENDS[e.end]})${e.dutyM > 0 ? ` incl. ${money(e.dutyM)} import duty` : ""} 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, r.galvIn ? `${f1(r.partsDay)} parts a day at about ${f0(A.galvRate)} per person (OMS)` : "none: stampers bought in");
|
||
R("Cutting engineers (day work)", e.heads.cut, r.cutIn ? "one per lathe (estimate)" : "none: cutting outsourced");
|
||
R("Production headcount", e.heads.prod, "add cover for leave");
|
||
R("Admin, sales and logistics", e.heads.office, "wages in the overheads (scale.md 3 to 5)");
|
||
R("All staff, with health insurance and yearly check-ups", e.heads.all, `risk-factor tests for ${e.heads.exposed} (press floor, galvanics, maintenance)`);
|
||
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`, `${r.nb} × ${f0(A.boilerMod)} kg/h ${fuel} boiler modules, hall for ${f0(A.modRooms)}`);
|
||
G("Cooling");
|
||
R("Cooling capacity, peak", f0(r.coolPeak) + " kW", `about 98 % for the moulds; ${r.nc} × ${f0(A.chillerMod)} kW chiller modules, compound for ${f0(A.modRooms)}`);
|
||
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`);
|
||
if (r.galvIn) {
|
||
R("Electroformed parts a year", f0(r.partsAll), `${r.partsTitle} a title: father, mother and ${r.perSide} stamper${r.perSide > 1 ? "s" : ""} per side${r.supply > 0 ? `; incl. ${f0(r.supply)} titles for other plants` : ""}`);
|
||
R("Per working day", `${f1(r.flow.glacq)} / ${f1(r.flow.fathers)} / ${f1(r.flow.mothers)} / ${f1(r.flow.stampers)}`, "lacquers / fathers / mothers / stampers, before remakes");
|
||
} else R("Stampers bought in a year", f0(r.titles * 2 * r.perSide), `${r.perSide} per side a title; electroforming outsourced`);
|
||
const GS = r.gst, su = k => GS[k].off ? (k === "lathe" ? "outsourced" : "bought in") : `${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.off ? "bought in" : `${GS.vsf.n} / ${GS.vsp.n} / ${GS.vsc.n}`, GS.vsf.off ? "stamper prep with the bought stampers" : `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">Capex, mid</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.cx.all.mid)}</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);
|
||
renderCfg(r);
|
||
renderInvest(e, r);
|
||
renderBudget(e);
|
||
renderCash(e, r);
|
||
|
||
for (const tr of document.querySelectorAll("#asT tr[data-id]"))
|
||
tr.classList.toggle("chg", tr.dataset.id.split(",").some(id => A[id] !== A0[id]));
|
||
writeHash();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- plant configuration, investment, budget, cash
|
||
const CFG = [
|
||
{ k: "galv", t: "Galvanics", sub: "Pheenix line, ZLD and DI water, water-treatment building, Sibert stamper prep",
|
||
o: [["in", "In-house"], ["bought", "Bought stampers"]],
|
||
h: { in: "Fathers, mothers and stampers made here; the plating share of the title fee earns its contribution.",
|
||
bought: "Stampers bought in and passed through at cost: no galvanics line, operators, ZLD or water-treatment building." } },
|
||
{ k: "galvPrice", t: "Galvanics price basis", sub: "only with galvanics in-house",
|
||
o: [["pheenix", "Pheenix estimate"], ["oms", "Near OMS reference"]],
|
||
h: { pheenix: () => `${kU("gPh")} USD; no Pheenix price on file.`,
|
||
oms: () => `${kU("gOms")} USD: OMS's reference for a 4-cell wet room, EUR 224k to 311k (letter, 9 October 2026).` },
|
||
dis: () => S.galv === "bought" ? "Not applicable: the stampers are bought in." : "",
|
||
extra: `<label class="row" id="row_stSupply"><span>Stamper supply to other plants, titles a year<br><small>contribution 200 to 333 USD a title (estimate); galvanics in-house only</small></span><input type="number" id="stSupply" min="0" step="50"></label>` },
|
||
{ k: "cut", t: "Mastering and cutting room", sub: "Sillitoe SDMS lathe, console and chain, room",
|
||
o: [["in", "In-house"], ["out", "Outsourced"]],
|
||
h: { in: "Lacquers cut here: cutting engineer and lacquer blanks in the costs, the mastering share earns its contribution.",
|
||
out: "Lacquers cut by an outside studio and passed through at cost: no cutting engineer, no lacquer blanks." } },
|
||
{ k: "metro", t: "Metrology", sub: "stamper and record measurement",
|
||
o: [["full", "Full stack"], ["launch", "Launch tier"]],
|
||
h: { full: () => `XRF, laser confocal, wide-area profiler, inline vision, audio station: ${kU("metF")} USD.`,
|
||
launch: () => `XRF, inline vision, audio station: ${kU("metL")} USD; confocal and profiler bought from revenue later.` } },
|
||
{ k: "cass", t: "Cassette duplication", sub: "small add-on line",
|
||
o: [["on", "On"], ["off", "Off"]], h: { on: () => `${kU("cas")} USD (estimate).`, off: "Not at launch." } },
|
||
{ k: "cdr", t: "CD-R duplication", sub: "small add-on line; CD replication is a separate option",
|
||
o: [["on", "On"], ["off", "Off"]], h: { on: () => `${kU("cdr")} USD (estimate).`, off: "Not at launch." } },
|
||
{ k: "countCD", t: "Count cassette and CD-R revenue", sub: "off in the totals, as in the revenue model",
|
||
o: [["off", "Not counted"], ["on", "Counted"]],
|
||
h: { off: "Illustrative volumes and prices with no market data; left out of the money.",
|
||
on: "Counted at the illustrative volumes and prices, with no running costs on file: it flatters the result." },
|
||
dis: () => S.cass === "off" && S.cdr === "off" ? "Not applicable: neither line is in the plant." : "" },
|
||
{ k: "solar", t: "Solar PV and 271 kWh battery", sub: "utilities CSV: illustrative 300 kWp EPC plus the Gobel battery",
|
||
o: [["buy", "Buy"], ["ppa", "Solar PPA"], ["none", "None"]],
|
||
h: { buy: () => `${mT("pv")} THB PV plus ${mT("bat")} battery; the yield offsets the bill.`,
|
||
ppa: "No capex: the EPC owns the array, the plant pays the PPA price on the yield.",
|
||
none: "No PV: the whole bill comes from the grid." },
|
||
extra: `<label class="row" id="row_ppa"><span>PPA power price, THB/kWh<br><small>estimate, to quote</small></span><input type="number" id="ppaIn" data-a="ppaPrice" min="0" step="0.05"></label>` },
|
||
{ k: "steamFuel", t: "Steam fuel", sub: "the boiler modules follow the fleet's peak steam",
|
||
o: [["electric", "Electric"], ["biomass", "Biomass"]],
|
||
h: { electric: "Electric modules on PEA power: energy charge plus demand charge per installed module.",
|
||
biomass: "Biomass modules (utilities CSV package); heat at the biomass price." } },
|
||
{ k: "sourcing", t: "Utilities sourcing", sub: "electric boiler modules and the compressor",
|
||
o: [["thai", "Thai suppliers"], ["china", "Chinese route"]],
|
||
h: { thai: "Thai suppliers, installed (utilities CSV).",
|
||
china: "ASME-stamped bodies installed by a Thai contractor, compressor through a Chinese brand's Thai office (china-utilities.md)." } },
|
||
{ k: "crane", t: "Overhead crane", sub: "5 t crane over the production compartment",
|
||
o: [["buy", "Buy"], ["rent", "Rent for installations"]],
|
||
h: { buy: () => `Crane, runway, frame premium, machine door: ${kU("crn")} USD.`,
|
||
rent: () => `A mobile crane for the installations; the frame stays crane-ready (${f0(A.craneRent)} % of the crane block, estimate).` } },
|
||
{ k: "corridor", t: "Visitor corridor", sub: "glazed corridor along the process, accessible entrance",
|
||
o: [["now", "At launch"], ["later", "Later"]],
|
||
h: { now: () => `${kU("cor")} USD at launch.`, later: "Built later; no public tours at first." } },
|
||
{ k: "reserve", t: "Ramp-up reserve", sub: "10M to 20M THB: staff and overheads before volume covers them",
|
||
o: [["in", "In the budget"], ["sep", "Shown separately"]],
|
||
h: { in: "Added to the budget bars and to year 0 of the cash view; never to the investment total.",
|
||
sep: "Left out of the budget bars and year 0; shown as a line under the investment." } },
|
||
];
|
||
function buildCfg() {
|
||
$("cfg").innerHTML = CFG.map(c => `<div class="cfg" id="cfg_${c.k}"><div class="ct">${c.t}<small>${c.sub}</small></div>` +
|
||
`<div class="seg" id="${c.k}">${c.o.map(([v, l]) => `<button type="button" data-v="${v}"><span>${l}</span><small class="dl"></small></button>`).join("")}</div>` +
|
||
`<p class="hint" id="h_${c.k}"></p>${c.extra || ""}</div>`).join("");
|
||
}
|
||
const kU = id => `${f0(A[id + "Lo"] / 1e3)}k to ${f0(A[id + "Hi"] / 1e3)}k`;
|
||
const mT = id => `${nf(2).format(A[id + "Lo"] / 1e6)}M to ${nf(2).format(A[id + "Hi"] / 1e6)}M`;
|
||
const sgn = usd => Math.abs(usd) < 1 ? "no change" : (usd > 0 ? "+" : "\u2212") + money(Math.abs(usd));
|
||
function renderCfg(r) {
|
||
const base = capexOf(r, S);
|
||
for (const c of CFG) {
|
||
const why = c.dis ? c.dis() : "", box = $("cfg_" + c.k);
|
||
box.classList.toggle("off", !!why);
|
||
for (const bt of $(c.k).querySelectorAll("button[data-v]")) {
|
||
const v = bt.dataset.v, dl = bt.querySelector(".dl");
|
||
bt.disabled = !!why; bt.title = why;
|
||
let t = "";
|
||
if (why) t = "n/a";
|
||
else if (v === S[c.k]) t = "current";
|
||
else if (c.k === "reserve") t = sgn((v === "in" ? 1 : -1) * base.res.mid) + " budget";
|
||
else if (c.k === "countCD") t = v === "on" ? "revenue only" : "";
|
||
else t = sgn(capexOf(r, { ...S, [c.k]: v }).all.mid - base.all.mid);
|
||
dl.textContent = t;
|
||
}
|
||
const hv = c.h[S[c.k]];
|
||
$("h_" + c.k).textContent = why || (typeof hv === "function" ? hv() : hv) || "";
|
||
}
|
||
const supOff = S.galv === "bought", ppaOff = S.solar !== "ppa";
|
||
$("stSupply").disabled = supOff; $("row_stSupply").classList.toggle("off", supOff);
|
||
$("stSupply").title = supOff ? "Not applicable: the stampers are bought in." : "";
|
||
$("ppaIn").disabled = ppaOff; $("row_ppa").classList.toggle("off", ppaOff);
|
||
$("ppaIn").title = ppaOff ? "Only with a solar PPA." : "";
|
||
}
|
||
const thbM = usd => nf(1).format(usd * A.thbUsd / 1e6) + "M THB";
|
||
function renderInvest(e, r) {
|
||
const cx = e.cx, H = [];
|
||
let last = "";
|
||
for (const x of cx.rows) {
|
||
if (x.g !== last) { H.push(`<tr class="grp"><td colspan="5">${esc(x.g)}</td></tr>`); last = x.g; }
|
||
H.push(`<tr><td>${esc(x.l)}</td><td class="n">${money(x.lo)}</td><td class="n">${money((x.lo + x.hi) / 2)}</td><td class="n">${money(x.hi)}</td>` +
|
||
`<td class="src"><span class="badge ${x.k}">${KIND[x.k]}</span> ${esc(x.s)}</td></tr>`);
|
||
}
|
||
const TR = (l, o, s, tot) => H.push(`<tr${tot ? ' class="tot"' : ""}><td>${l}</td><td class="n">${money(o.lo)}</td><td class="n">${money(o.mid)}</td><td class="n">${money(o.hi)}</td><td class="src">${s || ""}</td></tr>`);
|
||
H.push(`<tr class="grp"><td colspan="5">Totals</td></tr>`);
|
||
TR("Subtotal", cx.sub, "", true);
|
||
TR(`Contingency ${f1(A.contPct)} %`, cx.ctg, "total-investment.md");
|
||
TR("Total investment", cx.tot, "for reference, the launch plant in total-investment.md: ~161M to 292M THB, midpoint ~227M", true);
|
||
if (!cx.boiOn) {
|
||
TR(`Import duty without BOI, ${f1(A.dutyMach)} %`, cx.duty, `on ${money(cx.imp.lo)} to ${money(cx.imp.hi)} of imported machinery; import VAT ${money(cx.vat.mid)} at the midpoint, reclaimable`);
|
||
TR("Total with import duty", cx.all, "", true);
|
||
}
|
||
TR("Ramp-up reserve (not in the total)", cx.res, S.reserve === "in" ? "in the budget bars and year 0 of the cash view" : "shown separately");
|
||
if (S.reserve === "in") TR("Total with the reserve", { lo: cx.all.lo + cx.res.lo, mid: cx.all.mid + cx.res.mid, hi: cx.all.hi + cx.res.hi }, "what the budget card compares", true);
|
||
$("ivT").innerHTML = `<thead><tr><th>Block</th><th class="n">Low</th><th class="n">Mid</th><th class="n">High</th><th>Basis</th></tr></thead><tbody>${H.join("")}</tbody>`;
|
||
$("ivK").innerHTML = kpi(money(cx.all.lo), "low end, contingency included") + kpi(money(cx.all.mid), "midpoint") +
|
||
kpi(money(cx.all.hi), "high end") + kpi(thbM(cx.all.mid), `midpoint in THB (${thbM(cx.all.lo)} to ${thbM(cx.all.hi)})`);
|
||
const fl = [];
|
||
if (r.nb > A.modRooms) fl.push(["warn", `${r.nb} boiler modules: beyond the boiler hall built for ${f0(A.modRooms)}; a second hall is not in the investment.`]);
|
||
if (r.nc > A.modRooms) fl.push(["warn", `${r.nc} chiller modules: beyond the chiller compound built for ${f0(A.modRooms)}; more pads are not in the investment.`]);
|
||
if (r.floor > A.hall) fl.push(["warn", `Press floor ~${f0(r.floor)} m² outgrows the ${f0(A.hall)} m² press hall: a Phase 2 building is included (business plan draft 0.1, estimate).`]);
|
||
if (cx.fr > 1.05 || cx.wf > 1.05) fl.push(["", `Scaled from the launch plant (estimates): freight and installation ${pct(cx.fr)}, working capital ${pct(cx.wf)}.`]);
|
||
$("ivFlags").innerHTML = fl.map(([c, t]) => `<div class="flag ${c}">${esc(t)}</div>`).join("");
|
||
}
|
||
function renderBudget(e) {
|
||
const cx = e.cx, fx = A.thbUsd > 0 ? A.thbUsd : 33, inB = S.reserve === "in";
|
||
const B1 = A.budgetLo, B2 = A.budgetHi, M = v => nf(1).format(v / 1e6) + "M";
|
||
const ends = [["lo", "Low end"], ["mid", "Midpoint"], ["hi", "High end"]];
|
||
const V = ends.map(([k, l]) => ({ k, l, cap: cx.all[k] * fx, res: inB ? cx.res[k] * fx : 0 }));
|
||
const max = Math.max(B2, ...V.map(v => v.cap + v.res)) * 1.05;
|
||
const gap = (v, B) => v <= B ? `<span class="fit">${M(B - v)} to spare</span>` : `<span class="over">${M(v - B)} over</span>`;
|
||
$("bud").innerHTML = V.map((v, i) => {
|
||
const tot = v.cap + v.res, w = x => (100 * x / max).toFixed(2) + "%";
|
||
return `<div class="bl"><b>${v.l}</b>${M(tot)} THB</div><div class="btrack" title="${v.l}: ${M(v.cap)} THB investment${inB ? ` + ${M(v.res)} reserve` : ""}">` +
|
||
`<div class="bfill" style="width:${w(v.cap)}"></div>` +
|
||
(v.res > 0 ? `<div class="bfill res" style="left:${w(v.cap)};width:${w(v.res)}"></div>` : "") +
|
||
`<div class="bmark lo" style="left:${w(B1)}">${i === 0 ? `<i>${M(B1)}</i>` : ""}</div>` +
|
||
`<div class="bmark" style="left:${w(B2)}">${i === 0 ? `<i>${M(B2)}</i>` : ""}</div></div>` +
|
||
`<div class="bgap">against ${M(B1)}: ${gap(tot, B1)} · against ${M(B2)}: ${gap(tot, B2)}</div>`;
|
||
}).join("");
|
||
const mid = V[1], mt = mid.cap + mid.res, rLo = cx.res.lo * fx, rHi = cx.res.hi * fx;
|
||
$("budK").innerHTML = kpi(M(mid.cap) + " THB", "investment at the midpoint, contingency included") +
|
||
kpi(`${M(mid.cap + rLo)} to ${M(mid.cap + rHi)}`, `THB with the ramp-up reserve (${M(rLo)} to ${M(rHi)})`) +
|
||
kpi(mt <= B2 ? M(B2 - mt) : "+" + M(mt - B2), `${mt <= B2 ? "to spare under" : "over"} ${M(B2)} THB at the midpoint${inB ? ", reserve included" : ""}`);
|
||
const verdict = mt <= B2 ? `Mid fits ${M(B2)} with ${M(B2 - mt)} to spare` : `Mid is ${M(mt - B2)} over ${M(B2)}`;
|
||
const detail = inB
|
||
? `the investment ${M(mid.cap)} plus the ramp-up reserve, ${M(mid.cap + rLo)} to ${M(mid.cap + rHi)} THB with the reserve at ${M(rLo)} to ${M(rHi)}`
|
||
: `the investment ${M(mid.cap)} THB without the reserve; with it, ${M(mid.cap + rLo)} to ${M(mid.cap + rHi)}`;
|
||
const vs180 = mt <= B1 ? `it fits ${M(B1)} too, with ${M(B1 - mt)} to spare` : `against ${M(B1)} it is ${M(mt - B1)} over`;
|
||
$("budV").innerHTML = `<div class="flag ${mt <= B2 ? "ok" : "bad"}"><b>${verdict}</b>: ${detail}; ${vs180}. ` +
|
||
`The low end is ${M(V[0].cap + V[0].res)}, the high end ${M(V[2].cap + V[2].res)} THB.</div>`;
|
||
}
|
||
function renderCash(e, r) {
|
||
const cv = cashView(e, r), Y = cv.Y, d = A.discRate;
|
||
const pay = cv.pay == null ? "beyond year 10" : "year " + f1(cv.pay);
|
||
$("cashK").innerHTML = kpi(pay, "payback, cumulative cash back to zero") +
|
||
kpi(money(cv.npv), `NPV at ${f1(d)} %, ten years, no terminal value`) +
|
||
kpi(cv.irr == null ? "none" : nf(1).format(cv.irr * 100) + " %", "IRR over ten years") +
|
||
kpi(money(Y[10].cum), "cumulative cash after year 10");
|
||
// chart: yearly cash flow as bars, cumulative cash as a line
|
||
const W = 640, Ht = 230, l = 70, rt = 12, t = 14, bt = 26;
|
||
const vals = Y.flatMap(y => [y.cf, y.cum]).concat(0), lo = Math.min(...vals), hi = Math.max(...vals), span = hi - lo || 1;
|
||
const yy = v => t + (hi - v) / span * (Ht - t - bt), xs = (W - l - rt) / 11, xc = i => l + xs * (i + 0.5);
|
||
const o = [`<line x1="${l}" y1="${t}" x2="${l}" y2="${Ht - bt}" class="cv-axis"/>`, `<line x1="${l}" y1="${yy(0)}" x2="${W - rt}" y2="${yy(0)}" class="cv-zero"/>`];
|
||
for (const v of [hi, 0, lo]) o.push(`<text x="${l - 6}" y="${yy(v) + 4}" class="cv-t" text-anchor="end">${money(v)}</text>`);
|
||
Y.forEach((y, i) => {
|
||
const y0 = yy(0), y1 = yy(y.cf);
|
||
o.push(`<rect x="${(xc(i) - xs * 0.3).toFixed(1)}" y="${Math.min(y0, y1).toFixed(1)}" width="${(xs * 0.6).toFixed(1)}" height="${Math.max(0.5, Math.abs(y1 - y0)).toFixed(1)}" class="${y.cf < 0 ? "cv-neg" : "cv-pos"}"/>`);
|
||
o.push(`<text x="${xc(i).toFixed(1)}" y="${Ht - 8}" class="cv-t" text-anchor="middle">${i === 0 ? "Y0" : i}</text>`);
|
||
});
|
||
o.push(`<path d="${Y.map((y, i) => (i ? "L" : "M") + xc(i).toFixed(1) + "," + yy(y.cum).toFixed(1)).join(" ")}" class="cv-line"/>`);
|
||
Y.forEach((y, i) => o.push(`<circle cx="${xc(i).toFixed(1)}" cy="${yy(y.cum).toFixed(1)}" r="3" class="cv-dot"/>`));
|
||
$("cashSvg").innerHTML = o.join("");
|
||
$("cashLeg").innerHTML = `<span><i style="background:var(--accent)"></i>cash in a year</span><span><i style="background:var(--bad)"></i>cash out a year</span>` +
|
||
`<span><i style="background:var(--dstd)"></i>cumulative cash</span><span>Year 0: ${money(e.capex)} investment (${{ low: "low end", mid: "midpoint", high: "high end" }[e.end]})${cv.res > 0 ? ` + ${money(cv.res)} reserve` : ""}</span>`;
|
||
const rows = Y.map(y => `<tr><td>${y.y === 0 ? "Year 0" : "Year " + y.y}</td><td class="n">${y.y ? pct(y.k) : "–"}</td><td class="n">${y.y ? big(y.rec) : "–"}</td>` +
|
||
`<td class="n">${y.y ? money(y.rev) : "–"}</td><td class="n${y.ebitda < 0 ? " neg" : ""}">${y.y ? money(y.ebitda) : "–"}</td>` +
|
||
`<td class="n${y.cf < 0 ? " neg" : ""}">${money(y.cf)}</td><td class="n${y.cum < 0 ? " neg" : ""}">${money(y.cum)}</td></tr>`);
|
||
$("cashT").innerHTML = `<thead><tr><th>Year</th><th class="n">Sold output</th><th class="n">Records</th><th class="n">Revenue</th><th class="n">EBITDA</th><th class="n">Cash flow</th><th class="n">Cumulative</th></tr></thead><tbody>${rows.join("")}</tbody>`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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", r.galvIn
|
||
? `STAMPER PRODUCTION · ${f1(f.titlesDay)} TITLES A WORKING DAY (${f0(r.titles)} A YEAR)${r.supply > 0 ? ` + ${f1(f.supDay)} FOR OTHER PLANTS` : ""} · ${f1(f.parts)} ELECTROFORMED PARTS A DAY`
|
||
: `STAMPER SUPPLY · ${f1(f.titlesDay)} TITLES A WORKING DAY (${f0(r.titles)} A YEAR) · STAMPERS BOUGHT IN${r.cutIn ? ", LACQUERS CUT HERE" : ""}`);
|
||
const ST = [
|
||
["Mastering", "lathe", g.lathe, `${f1(f.lacq)} lacquer sides`, "lathe"],
|
||
["Silvering", "Pheenix PASU", g.pasu, `${f1(f.glacq)} lacquers`, "pasu"],
|
||
["Pre-plating", "Pheenix PPPU", g.pppu, `${f1(f.glacq)} 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);
|
||
if (u.off) {
|
||
o.push(`<g class="pk-off">`);
|
||
box(x, y0, bw, bh, "pk-box");
|
||
T(x + 8, y0 + 17, "pk-t", t); T(x + 8, y0 + 33, "pk-s", sub);
|
||
glyph(k, x + 8, y0 + 38);
|
||
o.push(`</g>`);
|
||
T(x + 8, y0 + 80, "pk-tag", k === "lathe" ? "OUTSOURCED" : "BOUGHT IN");
|
||
T(x + 8, y0 + 96, "pk-s", k === "lathe" ? "cut by a studio" : "stampers bought");
|
||
} else {
|
||
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", r.galvIn ? "sets to the presses" : "bought-in sets");
|
||
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} ${r.fin === 1 ? "person" : "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`, `${r.nb} × ${f0(A.boilerMod)} kg/h ${S.steamFuel === "biomass" ? "biomass" : "electric"}`, "pk-steam", r.nb > A.modRooms],
|
||
["Cooling", `${f0(r.coolPeak)} kW peak`, `${f0(r.water[0])} to ${f0(r.water[1])} l/min`, `${r.nc} × ${f0(A.chillerMod)} kW chillers`, "pk-cool", r.nc > A.modRooms],
|
||
["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", "exportShare", "stSupply"];
|
||
function syncA(id, except) {
|
||
const el = $("a_" + id); if (el && el !== except) el.value = A[id];
|
||
for (const m of document.querySelectorAll(`[data-a="${id}"]`)) if (m !== except) m.value = A[id];
|
||
}
|
||
function syncInputs() {
|
||
for (const k of NUM) $(k).value = S[k];
|
||
$("targetR").value = S.target;
|
||
$("spec").value = S.spec;
|
||
for (const [, id] of AS) syncA(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>`;
|
||
const inp = (id, label) => `<input type="number" id="a_${id}" step="any" aria-label="${esc(label)}">`;
|
||
for (const [g, id, label, , unit, kind, src, pair] of AS) {
|
||
if (pair === 2) continue; // the high end sits in the low end's row
|
||
if (g !== last) { html += `<tr class="grp"><td colspan="4">${esc(g)}</td></tr>`; last = g; }
|
||
const hi = pair === 1 ? id.replace(/Lo$/, "Hi") : "";
|
||
html += `<tr data-id="${pair === 1 ? id + "," + hi : id}"><td>${esc(label)}</td><td class="n">` +
|
||
(pair === 1 ? `${inp(id, label + ", low")} to ${inp(hi, label + ", high")}` : inp(id, 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;
|
||
}
|
||
for (const k in ENUM) if (!ENUM[k].includes(S[k])) S[k] = S0[k];
|
||
}
|
||
function num(el, fallback) { const v = parseFloat(el.value); return isFinite(v) ? v : fallback; }
|
||
|
||
buildAssumptions();
|
||
buildCfg();
|
||
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 bt = e.target.closest("button[data-v]"), v = bt && bt.dataset.v; if (!v || !CASES[v]) return;
|
||
Object.assign(S, CASES[v].S); Object.assign(A, CASES[v].A); syncInputs(); render();
|
||
});
|
||
// every segmented switch; a disabled option never changes the state
|
||
for (const g of Object.keys(ENUM)) { const el = $(g); if (el) el.addEventListener("click", e => {
|
||
const bt = e.target.closest("button[data-v]"); if (!bt || !el.contains(bt) || bt.disabled) return;
|
||
const v = bt.dataset.v; if (!ENUM[g].includes(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();
|
||
}); }
|
||
$("presets").addEventListener("click", e => {
|
||
const bt = e.target.closest("button[data-p]"); if (!bt || !PRESETS[bt.dataset.p]) return;
|
||
Object.assign(S, PRESETS[bt.dataset.p]); syncInputs(); render();
|
||
});
|
||
$("asT").addEventListener("input", e => {
|
||
const id = e.target.id.replace(/^a_/, ""); if (!(id in A0)) return;
|
||
A[id] = num(e.target, A0[id]); syncA(id, e.target); render();
|
||
});
|
||
document.addEventListener("input", e => {
|
||
const id = e.target.dataset && e.target.dataset.a; if (!id || !(id in A0) || e.target.disabled) return;
|
||
A[id] = num(e.target, A0[id]); syncA(id, e.target); 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();
|
||
// hooks for headless checks
|
||
window.__fc = { get S() { return S; }, get A() { return A; }, A0, S0, AS, PRESETS, scenario, econ, capexOf, cashView, render, syncInputs };
|
||
(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>
|