Files
cnx_vinyl/docs/fleet-calculator.html
T
grabowski 485b3b630a
Deploy test site / deploy (push) Successful in 44s
Plant and business plan calculator: configuration, investment, budget, ten-year cash
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.
2026-10-16 16:15:22 +07:00

1693 lines
126 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>CNX Vinyl · 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&amp;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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const $ = id => document.getElementById(id);
// ---------------------------------------------------------------- render
function kpi(v, l, cls) { return `<div class="kpi ${cls || ""}"><b>${v}</b><span>${l}</span></div>`; }
function render() {
const { b, cap, r } = scenario();
$("specV").textContent = S.spec + " %";
$("targetBox").hidden = S.mode !== "target";
$("fleetBox").hidden = S.mode !== "fleet";
for (const g of 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>