Deploy test site / deploy (push) Successful in 47s
User feedback on the isometric view (October 2026): - "the cars are above the car park roof": cars and scooters were their own sprites and the depth sort put some above the canopy; they are now drawn inside the canopy sprite before the posts and the roof, so the roof always covers them in every rotation. - "a light pole in the middle of the yard": the two yard lamps at the service-lane edge (one in front of the boiler hall) move to the yard's back fence; one LAMPS list feeds both the poles and the light pools. - "add solar panels to the car park": the car and scooter canopies now take PV first (107 modules, ~48 kWp; construction RFQ has canopy PV), the factory roof the rest (560 of 667 modules at 300 kWp); canopy tooltips give their kWp. - "arrange the pvc storage outside to be black, red, yellow": the silos read black, black, red, yellow from the default north-east view. Renderer checks rerun (288 + functional, no failures); calculator regression 62 checks pass; 390 / 1024 / 1440 px, dark and light, no page errors or overflow.
3783 lines
266 KiB
HTML
3783 lines
266 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="robots" content="noindex, nofollow">
|
||
<title>CNX Vinyl · plant and business plan calculator</title>
|
||
<script>
|
||
(function(){var t=null;try{t=localStorage.getItem("cnx-theme")}catch(e){}
|
||
if(t!=="light"&&t!=="dark")t=window.matchMedia&&matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";
|
||
document.documentElement.setAttribute("data-theme",t);})();
|
||
</script>
|
||
<style>
|
||
:root {
|
||
--bg: #0a0f1a; --panel: #0f1626; --panel2: #131c30; --stroke: #2a3a56;
|
||
--line: #1c2840; --fg: #d6deeb; --mute: #8a97b0; --accent: #3ddc84;
|
||
--warn: #ffb547; --bad: #ff6b6b; --dspec: #c58bff; --dstd: #6aa8ff;
|
||
--classic: #3ddc84;
|
||
}
|
||
:root { color-scheme: dark; --input: #0a1120; --flagw: #1b1a12; --flago: #0f1a14; --flagb: #1d1214; --cur: #12261c;
|
||
--bv: #9fd0ff; --bvb: #2c4c73; --bd: #b5e8c7; --bdb: #2b5a3d; --be: #ffd59a; --beb: #6b5426; --bu: #e2c4ff; --bub: #563a73; }
|
||
html[data-theme=light] {
|
||
color-scheme: light;
|
||
--bg: #f4f6fa; --panel: #ffffff; --panel2: #eef2f8; --stroke: #cfd8e6; --line: #e4e9f1;
|
||
--fg: #1b2533; --mute: #5b6880; --accent: #0e8f4f; --warn: #c27c00; --bad: #c62828;
|
||
--dspec: #9b5de5; --dstd: #2f7de1; --classic: #1fae68;
|
||
--input: #ffffff; --flagw: #fff6e0; --flago: #e8f6ee; --flagb: #fdeaea; --cur: #e1f3e8;
|
||
--bv: #1f5f9e; --bvb: #9cc0e6; --bd: #1e7a45; --bdb: #9fd3b3; --be: #8a5a00; --beb: #e3c27a; --bu: #6b3fa0; --bub: #c9a8ec;
|
||
}
|
||
:root { --pk-press: #2a3a56; --pk-pressS: #6d86b3; --pk-glyph: #3a4d70; }
|
||
html[data-theme=light] { --pk-press: #c7d1df; --pk-pressS: #8e9db4; --pk-glyph: #b8c4d6; }
|
||
#park { display: block; width: 100%; height: auto; min-width: 900px; }
|
||
#park text { font-family: inherit; }
|
||
.pk-box { fill: var(--panel2); stroke: var(--stroke); }
|
||
.pk-box.warn { stroke: var(--warn); stroke-width: 2; }
|
||
.pk-hall { fill: none; stroke: var(--stroke); stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||
.pk-cell { fill: var(--panel); stroke: var(--stroke); }
|
||
.pk-press { fill: var(--pk-press); stroke: var(--pk-pressS); stroke-width: 1.2; }
|
||
.pk-col { fill: var(--pk-pressS); }
|
||
.pk-ex50 { fill: var(--dstd); } .pk-ex35 { fill: var(--dspec); }
|
||
.pk-op { fill: #e8b400; } .pk-trim { fill: var(--mute); } .pk-disc { fill: #111; stroke: var(--mute); }
|
||
.pk-glyph { fill: var(--pk-glyph); stroke: var(--pk-pressS); }
|
||
.pk-feed { stroke: var(--mute); stroke-width: 1; fill: none; stroke-dasharray: 2 3; }
|
||
.pk-t { fill: var(--fg); font-size: 12px; font-weight: 600; }
|
||
.pk-s { fill: var(--mute); font-size: 10.5px; }
|
||
.pk-v { fill: var(--fg); font-size: 15px; font-weight: 700; }
|
||
.pk-n { fill: var(--fg); font-size: 19px; font-weight: 700; }
|
||
.pk-lane { fill: var(--mute); font-size: 11px; letter-spacing: 0.12em; font-weight: 600; }
|
||
.pk-arrow { stroke: var(--mute); stroke-width: 1.6; fill: none; }
|
||
.pk-head { fill: var(--mute); }
|
||
.pk-util { fill: var(--line); } .pk-utilv { fill: var(--accent); } .pk-utilv.hi { fill: var(--warn); }
|
||
.pk-pipe { fill: none; stroke-width: 3; stroke-dasharray: 8 5; opacity: 0.85; }
|
||
.pk-steam { stroke: #e2462a; } .pk-cool { stroke: #2f7de1; } .pk-hyd { stroke: #d48600; }
|
||
.pk-air { stroke: #8a97b0; } .pk-pow { stroke: #c9a800; }
|
||
tr.tot td { font-weight: 600; border-top: 1px solid var(--stroke); }
|
||
td.neg { color: var(--bad); }
|
||
#ecoBar div { min-width: 0; }
|
||
.themebtn {
|
||
position: absolute; top: 24px; right: 20px; background: var(--panel); color: var(--fg);
|
||
border: 1px solid var(--stroke); border-radius: 5px; padding: 6px 12px; font: inherit; font-size: 14px; cursor: pointer;
|
||
}
|
||
.themebtn:hover { border-color: var(--accent); }
|
||
header { position: relative; }
|
||
@media (max-width: 640px) { header h1 { font-size: 22px; padding-right: 128px; } }
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0; background: var(--bg); color: var(--fg);
|
||
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
}
|
||
a { color: var(--accent); }
|
||
header, .wrap { max-width: 1320px; margin: 0 auto; padding: 0 20px; }
|
||
header { padding-top: 28px; }
|
||
h1 { margin: 0; font-size: 26px; letter-spacing: 0.02em; }
|
||
.tag {
|
||
display: inline-block; margin-top: 6px; padding: 2px 8px;
|
||
border: 1px solid var(--stroke); border-radius: 4px; color: var(--mute); font-size: 13px;
|
||
}
|
||
.lead { color: var(--mute); max-width: 920px; margin: 14px 0 22px; }
|
||
.grid { display: grid; grid-template-columns: 370px 1fr; gap: 18px; align-items: start; }
|
||
@media (max-width: 960px) { .grid { grid-template-columns: 1fr; } }
|
||
.card {
|
||
background: var(--panel); border: 1px solid var(--stroke); border-radius: 8px;
|
||
padding: 16px 18px; margin-bottom: 18px;
|
||
}
|
||
h2 {
|
||
font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em;
|
||
color: var(--mute); margin: 0 0 12px;
|
||
}
|
||
.row { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin: 9px 0; }
|
||
.row > span { flex: 1; }
|
||
.row small, .hint { color: var(--mute); font-size: 12.5px; }
|
||
.hint { margin: 6px 0 0; }
|
||
input[type=number] {
|
||
width: 118px; background: var(--input); color: var(--fg); border: 1px solid var(--stroke);
|
||
border-radius: 4px; padding: 5px 7px; font: inherit; text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
input[type=number]:focus { outline: none; border-color: var(--accent); }
|
||
input[type=range] { width: 100%; accent-color: var(--accent); }
|
||
.seg { display: flex; border: 1px solid var(--stroke); border-radius: 6px; overflow: hidden; margin: 6px 0 10px; }
|
||
.seg button {
|
||
flex: 1; background: transparent; color: var(--mute); border: 0; padding: 7px 8px;
|
||
font: inherit; font-size: 14px; cursor: pointer;
|
||
}
|
||
.seg button + button { border-left: 1px solid var(--stroke); }
|
||
.seg button.on { background: var(--panel2); color: var(--accent); }
|
||
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||
.pair .row { margin: 4px 0; }
|
||
.pair input[type=number] { width: 70px; }
|
||
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; }
|
||
.kpi { background: var(--panel2); border: 1px solid var(--stroke); border-radius: 6px; padding: 10px 12px; }
|
||
.kpi b { display: block; font-size: 24px; font-variant-numeric: tabular-nums; }
|
||
.kpi span { color: var(--mute); font-size: 13px; }
|
||
.kpi.d b { color: var(--dstd); } .kpi.c b { color: var(--classic); }
|
||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||
th, td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
||
th { color: var(--mute); font-weight: 500; font-size: 12.5px; }
|
||
td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||
tr.grp td {
|
||
color: var(--accent); font-size: 12px; text-transform: uppercase; letter-spacing: 0.07em;
|
||
padding-top: 14px; border-bottom-color: var(--stroke);
|
||
}
|
||
td.src { color: var(--mute); font-size: 12.5px; }
|
||
.flag { border-left: 3px solid var(--warn); background: var(--flagw); padding: 7px 10px; margin: 8px 0 0; font-size: 14px; }
|
||
.flag.ok { border-color: var(--accent); background: var(--flago); }
|
||
.flag.bad { border-color: var(--bad); background: var(--flagb); }
|
||
.bar { display: flex; height: 24px; border-radius: 4px; overflow: hidden; margin: 14px 0 8px; background: var(--panel2); }
|
||
.bar div { height: 100%; }
|
||
.legend { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 13.5px; color: var(--mute); }
|
||
.legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; }
|
||
#ladT tbody tr { cursor: pointer; }
|
||
#ladT tbody tr:hover td { background: var(--panel2); }
|
||
#ladT tbody tr.cur td { background: var(--cur); }
|
||
.badge {
|
||
display: inline-block; font-size: 11px; padding: 0 6px; border-radius: 3px;
|
||
border: 1px solid var(--stroke); color: var(--mute); white-space: nowrap;
|
||
}
|
||
.badge.vendor { color: var(--bv); border-color: var(--bvb); }
|
||
.badge.doc { color: var(--bd); border-color: var(--bdb); }
|
||
.badge.est { color: var(--be); border-color: var(--beb); }
|
||
.badge.user { color: var(--bu); border-color: var(--bub); }
|
||
#asT input[type=number] { width: 92px; }
|
||
#asT tr.chg input { border-color: var(--warn); }
|
||
.btns { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
|
||
.btns button {
|
||
background: var(--panel2); color: var(--fg); border: 1px solid var(--stroke); border-radius: 5px;
|
||
padding: 7px 12px; font: inherit; font-size: 14px; cursor: pointer;
|
||
}
|
||
.btns button:hover { border-color: var(--accent); }
|
||
.note { color: var(--mute); font-size: 13.5px; }
|
||
.note p { margin: 6px 0; }
|
||
footer { color: var(--mute); font-size: 13px; padding: 10px 20px 40px; max-width: 1320px; margin: 0 auto; }
|
||
.presets { display: flex; flex-wrap: wrap; gap: 8px; margin: -8px 0 22px; align-items: center; }
|
||
.presets > span { color: var(--mute); font-size: 13px; margin-right: 2px; }
|
||
.presets button {
|
||
background: var(--panel); color: var(--fg); border: 1px solid var(--stroke); border-radius: 5px;
|
||
padding: 6px 11px; font: inherit; font-size: 13.5px; cursor: pointer;
|
||
}
|
||
.presets button:hover { border-color: var(--accent); }
|
||
.presets button.on { border-color: var(--accent); color: var(--accent); background: var(--panel2); }
|
||
.cfg { border-top: 1px solid var(--line); padding: 9px 0 3px; }
|
||
.cfg:first-of-type { border-top: 0; padding-top: 0; }
|
||
.cfg .ct { font-size: 14px; font-weight: 600; }
|
||
.cfg .ct small { display: block; font-weight: 400; color: var(--mute); font-size: 12px; }
|
||
.cfg .seg { margin: 6px 0 4px; }
|
||
.cfg .seg button { padding: 6px 6px; font-size: 13.5px; line-height: 1.25; }
|
||
.seg button small.dl { display: block; font-size: 11.5px; color: var(--mute); font-variant-numeric: tabular-nums; }
|
||
.seg button.on small.dl { color: var(--accent); }
|
||
.seg button:disabled { opacity: 0.42; cursor: not-allowed; pointer-events: none; }
|
||
.cfg.off .ct { opacity: 0.55; }
|
||
.cfg .hint { margin: 0 0 4px; font-size: 12px; }
|
||
.cfg .row { margin: 4px 0 6px; }
|
||
.cfg .row.off { opacity: 0.5; }
|
||
input[type=number]:disabled { opacity: 0.55; cursor: not-allowed; }
|
||
.bud { display: grid; grid-template-columns: 74px 1fr; gap: 14px 10px; align-items: center; margin: 22px 0 6px; }
|
||
.bud .bl { font-size: 13px; color: var(--mute); line-height: 1.2; }
|
||
.bud .bl b { display: block; color: var(--fg); font-size: 14px; }
|
||
.btrack { position: relative; height: 22px; background: var(--panel2); border-radius: 4px; }
|
||
.bfill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 4px 0 0 4px; background: var(--dstd); }
|
||
.bfill.res { background: repeating-linear-gradient(45deg, var(--dspec) 0 5px, transparent 5px 9px); border-radius: 0; opacity: 0.85; }
|
||
.bmark { position: absolute; top: -5px; bottom: -5px; width: 2px; background: var(--fg); opacity: 0.75; }
|
||
.bmark.lo { background: var(--warn); opacity: 1; }
|
||
.bmark i { position: absolute; top: -17px; width: 44px; font-style: normal; font-size: 11px; color: var(--mute); }
|
||
.bmark.lo i { right: 4px; text-align: right; }
|
||
.bmark:not(.lo) i { left: 4px; text-align: left; }
|
||
.bgap { grid-column: 2; margin-top: -10px; font-size: 12.5px; color: var(--mute); }
|
||
.bgap .over { color: var(--bad); } .bgap .fit { color: var(--accent); }
|
||
#cashSvg { display: block; width: 100%; height: auto; margin: 6px 0 4px; }
|
||
.cv-pos { fill: var(--accent); opacity: 0.8; } .cv-neg { fill: var(--bad); opacity: 0.8; }
|
||
.cv-line { fill: none; stroke: var(--dstd); stroke-width: 2.2; } .cv-dot { fill: var(--dstd); }
|
||
.cv-axis { stroke: var(--stroke); stroke-width: 1; } .cv-zero { stroke: var(--mute); stroke-width: 1; stroke-dasharray: 3 3; }
|
||
.cv-t { fill: var(--mute); font-size: 11px; }
|
||
.inl { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; margin: 4px 0 8px; font-size: 13.5px; }
|
||
.inl label { display: flex; align-items: center; gap: 6px; }
|
||
.inl input[type=number] { width: 64px; }
|
||
#ivT td.n, #cashT td.n, #cashT td:first-child { white-space: nowrap; }
|
||
.pk-off { opacity: 0.32; }
|
||
.pk-tag { fill: var(--warn); font-size: 10.5px; font-weight: 600; }
|
||
[hidden] { display: none !important; }
|
||
.grid > div { min-width: 0; }
|
||
.tscroll { overflow-x: auto; }
|
||
.tscroll table { min-width: 560px; }
|
||
#ladT { min-width: 1140px; }
|
||
#cashSvg { min-width: 480px; }
|
||
</style>
|
||
<style id="iso-plant-css">
|
||
/* Isometric plant view (iso-plant.css): all classes iso-, colours as --iso-* variables */
|
||
/* iso-plant.css: CNX Vinyl isometric plant view. All colours are CSS
|
||
variables: dark (dusk, lit windows) by default, html[data-theme=light] is day.
|
||
Class names are prefixed iso-. Faces: iso-t top, iso-l left, iso-c diagonal,
|
||
iso-r right; each material class sets --b and the faces mix it with shade. */
|
||
|
||
.iso-wrap {
|
||
--iso-sky1: #0b1428; --iso-sky2: #251f42; --iso-star: rgba(255, 255, 255, .55);
|
||
--iso-shade: #04060c; --iso-kl: 78%; --iso-kc: 70%; --iso-kr: 58%;
|
||
--iso-edge: rgba(4, 7, 14, .72); --iso-edgew: .32;
|
||
--iso-grass: #1d3a2b; --iso-dirt: #3d3026; --iso-grid: rgba(190, 230, 210, .06);
|
||
--iso-asph: #252a34; --iso-conc: #3c4250; --iso-pad: #454c5a; --iso-path: #4b5161;
|
||
--iso-dash: #cdbb68; --iso-kerb: #59606e; --iso-bayline: #8d96a8; --iso-hazard: #b58a12;
|
||
--iso-plinth: #5a606c; --iso-wall: #7a8496; --iso-wall2: #737d8f; --iso-roof: #8792a6; --iso-roof2: #808a9c; --iso-roof3: #8f8c9a; --iso-roofin: #777484;
|
||
--iso-office: #8a8796; --iso-yardb: #6f798b; --iso-cut: #222733; --iso-firewall: #8c4a48;
|
||
--iso-floor: #4a5263; --iso-part: #161b27; --iso-mark: #d9ad2e; --iso-trench: #2b303b;
|
||
--iso-glass: #f2c56e; --iso-glasso: .72; --iso-glassw: #9fd0ff; --iso-glasswo: .16;
|
||
--iso-win: #283248; --iso-winlit: #ffcd6a; --iso-door: #353d4c; --iso-dock: #8a93a1; --iso-open: #12161e;
|
||
--iso-pv: #17275a; --iso-pvl: #4d68ad; --iso-glint: #ffffff;
|
||
--iso-led-r: #ff5a3c; --iso-led-g: #46f59a; --iso-led-o: #ffae34; --iso-fire: #ff8a2a; --iso-lamp: #ffd98a; --iso-lampo: .5;
|
||
--iso-steam: #dfe7f1; --iso-smoke: #7b7f88;
|
||
--iso-steel: #8b95a6; --iso-press: #2f3a4f; --iso-press2: #3e4b64; --iso-ex50: #3a7bd2; --iso-ex35: #9a68d4;
|
||
--iso-hopper: #bfc6d1; --iso-classic: #2c9a60; --iso-vinyl: #121419; --iso-hivis: #f0b400; --iso-head: #e9c9a3;
|
||
--iso-galv: #4d97a3; --iso-galv2: #5aa9b5; --iso-bath: #3fd0a8; --iso-sibert: #b6c1ce; --iso-bench: #8b7b63;
|
||
--iso-rack: #c45c2b; --iso-carton: #9c7a4e; --iso-crane: #e3b100; --iso-col: #687386; --iso-rail: #c7a300;
|
||
--iso-boilere: #c6cbd4; --iso-boilerb: #b0412e; --iso-padc: #686e7a; --iso-padempty: #535a67;
|
||
--iso-chiller: #a7b1be; --iso-screen: #6d7685; --iso-bess: #c9cfd8; --iso-inv: #39414f; --iso-trafo: #58626f;
|
||
--iso-water: #6f8fb0; --iso-firetank: #b03a3a; --iso-stack: #99a0ac; --iso-chips: #9a7744; --iso-tank: #7f8ea3;
|
||
--iso-silok: #22252c; --iso-siloy: #d4b229; --iso-silor: #b5403a; --iso-canopy: #687182;
|
||
--iso-car1: #b8382b; --iso-car2: #2e7fd2; --iso-car3: #d5dbe2; --iso-car4: #2d3238; --iso-glassd: #1c2433;
|
||
--iso-cab: #e6ebf2; --iso-truckbox: #3a6db4; --iso-chassis: #20242c; --iso-sacks: #ddd2b8;
|
||
--iso-fence: #9aa3b1; --iso-fenceo: .22; --iso-gate: #8a94a4; --iso-boom: #e04b3a; --iso-ac: #a1a9b6;
|
||
--iso-trunk: #5a4430; --iso-leaf: #2c6640; --iso-person: #f0b400;
|
||
--iso-tagbg: rgba(10, 16, 30, .9); --iso-tagline: #3b5277; --iso-tagfg: #e4ecf7;
|
||
--iso-signbg: #5e4300; --iso-signline: #d9a300; --iso-signfg: #ffe2a2;
|
||
--iso-warnbg: #5a1f1f; --iso-warnline: #ff6b6b; --iso-warnfg: #ffd4d4;
|
||
--iso-tipbg: #0f1626; --iso-tipfg: #d6deeb; --iso-tipline: #2a3a56; --iso-tipmute: #8a97b0;
|
||
--iso-btn: #0f1626; --iso-btnfg: #d6deeb; --iso-btnline: #2a3a56; --iso-btnon: #12261c; --iso-accent: #3ddc84;
|
||
--iso-sel: #3ddc84; --iso-hov: #9fd0ff; --iso-mute: #8a97b0;
|
||
position: relative;
|
||
color: var(--iso-tipfg);
|
||
}
|
||
html[data-theme=light] .iso-wrap {
|
||
--iso-sky1: #a9d4fb; --iso-sky2: #e8f4ff; --iso-star: transparent;
|
||
--iso-shade: #263040; --iso-kl: 86%; --iso-kc: 78%; --iso-kr: 68%;
|
||
--iso-edge: rgba(36, 46, 62, .6); --iso-edgew: .3;
|
||
--iso-grass: #95c477; --iso-dirt: #8f6c4c; --iso-grid: rgba(20, 60, 20, .07);
|
||
--iso-asph: #6b717c; --iso-conc: #c3c7ce; --iso-pad: #b6bbc3; --iso-path: #d6d0c1;
|
||
--iso-dash: #ffffff; --iso-kerb: #9aa0aa; --iso-bayline: #f4f6f8; --iso-hazard: #e7b70f;
|
||
--iso-plinth: #b5bac3; --iso-wall: #e3e7ec; --iso-wall2: #dce1e7; --iso-roof: #f1f4f8; --iso-roof2: #e9edf2; --iso-roof3: #ece6da; --iso-roofin: #ddd6c8;
|
||
--iso-office: #efe8db; --iso-yardb: #d6dbe2; --iso-cut: #565d6a; --iso-firewall: #cf8a82;
|
||
--iso-floor: #d4d8df; --iso-part: #6f7889; --iso-mark: #d39d00; --iso-trench: #8d939c;
|
||
--iso-glass: #8fc0e6; --iso-glasso: .85; --iso-glassw: #5aa6e6; --iso-glasswo: .14;
|
||
--iso-win: #86b3dc; --iso-winlit: #9ac3e6; --iso-door: #858e9c; --iso-dock: #b6bdc7; --iso-open: #444a55;
|
||
--iso-pv: #22448d; --iso-pvl: #8aa8e2; --iso-glint: #ffffff;
|
||
--iso-led-r: #d63b1f; --iso-led-g: #18a35a; --iso-led-o: #d98300; --iso-fire: #f07b1c; --iso-lamp: #fff2c4; --iso-lampo: 0;
|
||
--iso-steam: #ffffff; --iso-smoke: #8d9097;
|
||
--iso-steel: #a9b2bf; --iso-press: #46546c; --iso-press2: #5a6984; --iso-ex50: #2f7de1; --iso-ex35: #9b5de5;
|
||
--iso-hopper: #dde2e8; --iso-classic: #1fae68; --iso-vinyl: #1b1e25; --iso-hivis: #f2b600; --iso-head: #e2b892;
|
||
--iso-galv: #6ab3bd; --iso-galv2: #7cc2cb; --iso-bath: #2fbf98; --iso-sibert: #ccd5df; --iso-bench: #c8b28c;
|
||
--iso-rack: #d76b32; --iso-carton: #c59b64; --iso-crane: #f2c200; --iso-col: #9aa4b3; --iso-rail: #d6b000;
|
||
--iso-boilere: #eef0f4; --iso-boilerb: #c9523b; --iso-padc: #c1c5cc; --iso-padempty: #b1b6be;
|
||
--iso-chiller: #dbe0e6; --iso-screen: #a0a9b6; --iso-bess: #f2f4f7; --iso-inv: #5b6370; --iso-trafo: #7d8794;
|
||
--iso-water: #9cb9d6; --iso-firetank: #d0453f; --iso-stack: #b9bfc8; --iso-chips: #b88b4c; --iso-tank: #a7b4c5;
|
||
--iso-silok: #2b2f37; --iso-siloy: #e9c22e; --iso-silor: #d0453f; --iso-canopy: #a8b0bd;
|
||
--iso-car1: #d0453f; --iso-car2: #2f7de1; --iso-car3: #f3f5f8; --iso-car4: #3a3f47; --iso-glassd: #3d4c62;
|
||
--iso-cab: #ffffff; --iso-truckbox: #2f6fd0; --iso-chassis: #2a2e36; --iso-sacks: #efe6cf;
|
||
--iso-fence: #7d8794; --iso-fenceo: .25; --iso-gate: #9aa3b1; --iso-boom: #e0412f; --iso-ac: #ced3da;
|
||
--iso-trunk: #6e5238; --iso-leaf: #4c9a4b; --iso-person: #f2b600;
|
||
--iso-tagbg: rgba(255, 255, 255, .93); --iso-tagline: #a9b8cc; --iso-tagfg: #1b2533;
|
||
--iso-signbg: #ffe7a6; --iso-signline: #c27c00; --iso-signfg: #5b3b00;
|
||
--iso-warnbg: #fdeaea; --iso-warnline: #c62828; --iso-warnfg: #8b1a1a;
|
||
--iso-tipbg: #ffffff; --iso-tipfg: #1b2533; --iso-tipline: #cfd8e6; --iso-tipmute: #5b6880;
|
||
--iso-btn: #ffffff; --iso-btnfg: #1b2533; --iso-btnline: #cfd8e6; --iso-btnon: #e1f3e8; --iso-accent: #0e8f4f;
|
||
--iso-sel: #0e8f4f; --iso-hov: #2f7de1; --iso-mute: #5b6880;
|
||
}
|
||
|
||
/* stage, toolbar, tooltip, note */
|
||
.iso-stage {
|
||
position: relative; width: 100%; aspect-ratio: 16 / 9; min-height: 340px; max-height: 82vh;
|
||
overflow: hidden; border-radius: 8px; border: 1px solid var(--iso-btnline);
|
||
background:
|
||
radial-gradient(1px 1px at 12% 18%, var(--iso-star) 50%, transparent 51%),
|
||
radial-gradient(1px 1px at 33% 9%, var(--iso-star) 50%, transparent 51%),
|
||
radial-gradient(1px 1px at 58% 14%, var(--iso-star) 50%, transparent 51%),
|
||
radial-gradient(1.2px 1.2px at 81% 7%, var(--iso-star) 50%, transparent 51%),
|
||
radial-gradient(1px 1px at 91% 22%, var(--iso-star) 50%, transparent 51%),
|
||
linear-gradient(180deg, var(--iso-sky1), var(--iso-sky2));
|
||
}
|
||
.iso-svg { display: block; width: 100%; height: 100%; touch-action: pan-y; cursor: default; user-select: none; -webkit-user-select: none; outline: none; }
|
||
.iso-zoomed .iso-svg { cursor: grab; }
|
||
.iso-svg:focus-visible { outline: 2px solid var(--iso-accent); outline-offset: -2px; }
|
||
.iso-bar {
|
||
position: absolute; top: 8px; left: 8px; right: 8px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
|
||
pointer-events: none; font: 13px/1.2 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
}
|
||
.iso-bar > *, .iso-bar button { pointer-events: auto; }
|
||
.iso-bar button, .iso-compass {
|
||
background: var(--iso-btn); color: var(--iso-btnfg); border: 1px solid var(--iso-btnline); border-radius: 5px;
|
||
padding: 5px 10px; font: inherit; cursor: pointer; min-width: 32px; min-height: 30px;
|
||
}
|
||
.iso-bar button:hover { border-color: var(--iso-accent); }
|
||
.iso-bar button:focus-visible { outline: 2px solid var(--iso-accent); outline-offset: 1px; }
|
||
.iso-seg { display: inline-flex; }
|
||
.iso-seg button { border-radius: 0; }
|
||
.iso-seg button:first-child { border-radius: 5px 0 0 5px; }
|
||
.iso-seg button:last-child { border-radius: 0 5px 5px 0; border-left: 0; }
|
||
.iso-seg button[aria-pressed=true] { background: var(--iso-btnon); border-color: var(--iso-accent); color: var(--iso-btnfg); font-weight: 600; }
|
||
.iso-compass { display: inline-flex; align-items: center; gap: 2px; padding: 3px 7px; cursor: default; }
|
||
.iso-compass svg { width: 20px; height: 20px; }
|
||
.iso-compass path { fill: var(--iso-accent); }
|
||
.iso-compass b { font-size: 11px; }
|
||
.iso-tip {
|
||
position: absolute; z-index: 3; max-width: min(330px, calc(100% - 12px)); padding: 8px 10px; pointer-events: none;
|
||
background: var(--iso-tipbg); color: var(--iso-tipfg); border: 1px solid var(--iso-tipline); border-radius: 6px;
|
||
font: 13px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; box-shadow: 0 4px 14px rgba(0, 0, 0, .28);
|
||
}
|
||
.iso-tip b { display: block; font-weight: 600; margin-bottom: 2px; }
|
||
.iso-tip span { display: block; color: var(--iso-tipmute); font-size: 12px; }
|
||
.iso-tip[hidden], .iso-wrap [hidden] { display: none !important; }
|
||
.iso-note { margin: 6px 2px 0; font: 12px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--iso-mute); }
|
||
|
||
/* faces */
|
||
.iso-svg .iso-t { fill: var(--b, #888); }
|
||
.iso-svg .iso-l { fill: color-mix(in srgb, var(--b, #888) var(--iso-kl), var(--iso-shade)); }
|
||
.iso-svg .iso-c { fill: color-mix(in srgb, var(--b, #888) var(--iso-kc), var(--iso-shade)); }
|
||
.iso-svg .iso-r { fill: color-mix(in srgb, var(--b, #888) var(--iso-kr), var(--iso-shade)); }
|
||
.iso-sprites path, .iso-sprites ellipse, .iso-sprites circle { stroke: var(--iso-edge); stroke-width: var(--iso-edgew); stroke-linejoin: round; }
|
||
.iso-ground path, .iso-ground ellipse { stroke: none; }
|
||
.iso-ground .iso-dirt { stroke: var(--iso-edge); stroke-width: .4; }
|
||
|
||
/* browsers without color-mix(): flat faces instead of black */
|
||
@supports not (color: color-mix(in srgb, red 50%, blue)) {
|
||
.iso-svg .iso-l, .iso-svg .iso-c, .iso-svg .iso-r { fill: var(--b, #888); }
|
||
.iso-svg .iso-r { fill-opacity: .82; }
|
||
}
|
||
|
||
/* ground */
|
||
.iso-dirt { --b: var(--iso-dirt); }
|
||
.iso-g-grass { --b: var(--iso-grass); }
|
||
.iso-g-asph { --b: var(--iso-asph); }
|
||
.iso-g-conc { --b: var(--iso-conc); }
|
||
.iso-g-pad { --b: var(--iso-pad); }
|
||
.iso-g-path { --b: var(--iso-path); }
|
||
.iso-svg .iso-grid { fill: none; stroke: var(--iso-grid); stroke-width: .5; }
|
||
.iso-svg .iso-dash { fill: none; stroke: var(--iso-dash); stroke-width: .6; stroke-dasharray: 8 8; }
|
||
.iso-svg .iso-kerb { fill: none; stroke: var(--iso-kerb); stroke-width: .6; }
|
||
.iso-svg .iso-bayline { fill: none; stroke: var(--iso-bayline); stroke-width: .45; opacity: .8; }
|
||
.iso-svg .iso-hazard { fill: var(--iso-hazard); opacity: .85; }
|
||
.iso-svg .iso-buried { fill: none; stroke: var(--iso-mark); stroke-width: .5; stroke-dasharray: 2 3; opacity: .7; }
|
||
.iso-svg .iso-pool { opacity: var(--iso-lampo); pointer-events: none; }
|
||
.iso-pool0 { stop-color: var(--iso-lamp); stop-opacity: .55; }
|
||
.iso-pool1 { stop-color: var(--iso-lamp); stop-opacity: 0; }
|
||
|
||
/* materials */
|
||
.iso-plinth { --b: var(--iso-plinth); } .iso-wall { --b: var(--iso-wall); } .iso-wall2 { --b: var(--iso-wall2); }
|
||
.iso-roof { --b: var(--iso-roof); } .iso-roof2 { --b: var(--iso-roof2); } .iso-roof3 { --b: var(--iso-roof3); }
|
||
.iso-office { --b: var(--iso-office); } .iso-yardb { --b: var(--iso-yardb); } .iso-cut { --b: var(--iso-cut); }
|
||
.iso-firewall { --b: var(--iso-firewall); } .iso-conc { --b: var(--iso-plinth); } .iso-steel { --b: var(--iso-steel); }
|
||
.iso-press { --b: var(--iso-press); } .iso-press2 { --b: var(--iso-press2); } .iso-ex50 { --b: var(--iso-ex50); } .iso-ex35 { --b: var(--iso-ex35); }
|
||
.iso-hopper { --b: var(--iso-hopper); } .iso-classic { --b: var(--iso-classic); } .iso-vinyl { --b: var(--iso-vinyl); }
|
||
.iso-hivis { --b: var(--iso-hivis); } .iso-guide { --b: var(--iso-accent); } .iso-visitor { --b: #d77a5a; } .iso-visitor2 { --b: #7a8fd8; }
|
||
.iso-galv { --b: var(--iso-galv); } .iso-galv2 { --b: var(--iso-galv2); } .iso-bath { --b: var(--iso-bath); } .iso-sibert { --b: var(--iso-sibert); }
|
||
.iso-bench { --b: var(--iso-bench); } .iso-desk { --b: var(--iso-bench); } .iso-table { --b: var(--iso-bench); } .iso-sofa { --b: #6b5a8e; }
|
||
.iso-rackf { --b: var(--iso-rack); } .iso-shelf { --b: var(--iso-steel); } .iso-archive { --b: #7d8aa0; }
|
||
.iso-carton { --b: var(--iso-carton); } .iso-crane { --b: var(--iso-crane); } .iso-hoist { --b: #b98f00; } .iso-hook { --b: #39404c; }
|
||
.iso-col { --b: var(--iso-col); } .iso-rail { --b: var(--iso-rail); }
|
||
.iso-boilere { --b: var(--iso-boilere); } .iso-boilerb { --b: var(--iso-boilerb); } .iso-panel { --b: #3a4352; }
|
||
.iso-pad { --b: var(--iso-padc); } .iso-padempty { --b: var(--iso-padempty); } .iso-chiller { --b: var(--iso-chiller); }
|
||
.iso-screen { --b: var(--iso-screen); } .iso-bess { --b: var(--iso-bess); } .iso-inv { --b: var(--iso-inv); } .iso-trafo { --b: var(--iso-trafo); }
|
||
.iso-water { --b: var(--iso-water); } .iso-firetank { --b: var(--iso-firetank); } .iso-stack { --b: var(--iso-stack); } .iso-stackband { --b: #b8413a; }
|
||
.iso-chips { --b: var(--iso-chips); } .iso-tank { --b: var(--iso-tank); } .iso-evap { --b: #9aa8bb; } .iso-softener { --b: #4f7fc0; }
|
||
.iso-silok { --b: var(--iso-silok); } .iso-siloy { --b: var(--iso-siloy); } .iso-silor { --b: var(--iso-silor); }
|
||
.iso-frame { --b: var(--iso-steel); } .iso-bigbag { --b: #e8e2d0; } .iso-canopy { --b: var(--iso-canopy); }
|
||
.iso-car1 { --b: var(--iso-car1); } .iso-car2 { --b: var(--iso-car2); } .iso-car3 { --b: var(--iso-car3); } .iso-car4 { --b: var(--iso-car4); }
|
||
.iso-glassd { --b: var(--iso-glassd); } .iso-cab { --b: var(--iso-cab); } .iso-truckbox { --b: var(--iso-truckbox); }
|
||
.iso-chassis { --b: var(--iso-chassis); } .iso-sacks { --b: var(--iso-sacks); }
|
||
.iso-gate { --b: var(--iso-gate); } .iso-boom { --b: var(--iso-boom); } .iso-ac { --b: var(--iso-ac); }
|
||
.iso-lampost { --b: #4a5160; } .iso-roofcap { --b: #6d7889; } .iso-roofcap2 { --b: var(--iso-roof2); }
|
||
.iso-locker { --b: #6d7f99; } .iso-printer { --b: #d5d9df; } .iso-mill { --b: #5d7aa0; } .iso-granul { --b: #4c8a8f; }
|
||
.iso-instr { --b: #dfe3e8; } .iso-instr2 { --b: #4b6a9c; } .iso-optic { --b: #d9dee5; } .iso-dome { --b: #2c3442; } .iso-bin { --b: #b8413a; }
|
||
.iso-speaker { --b: #2b2f37; } .iso-shrink { --b: #9aa4b2; } .iso-cass { --b: #333a46; } .iso-cdtower { --b: #c7ccd4; }
|
||
.iso-forklift { --b: #e3a400; } .iso-pump { --b: #3f78c0; } .iso-motor { --b: #59606b; } .iso-accu { --b: #c9473a; }
|
||
.iso-comp { --b: #4f8d58; } .iso-oil { --b: #b88a2a; } .iso-belt { --b: #2b313b; } .iso-hx { --b: #8aa0b8; }
|
||
.iso-lathe { --b: #3c4658; } .iso-stair { --b: #9aa0aa; } .iso-sign { --b: var(--iso-signline); } .iso-fanbox { --b: #6b7585; }
|
||
.iso-bushing { --b: #c9b8a4; } .iso-pipes { --b: #c9473a; } .iso-pipec { --b: #3f78c0; } .iso-pipec2 { --b: #6aa8ff; }
|
||
.iso-svg .iso-headc { fill: var(--iso-head); }
|
||
.iso-svg .iso-fence, .iso-svg .iso-fence.iso-t { fill-opacity: var(--iso-fenceo); --b: var(--iso-fence); }
|
||
.iso-fence { --b: var(--iso-fence); }
|
||
.iso-svg .iso-screen.iso-l, .iso-svg .iso-screen.iso-r, .iso-svg .iso-screen.iso-c { fill-opacity: .55; }
|
||
.iso-svg .iso-glassw { --b: var(--iso-glassw); fill-opacity: var(--iso-glasswo); }
|
||
.iso-svg path.iso-glassw.iso-t { fill-opacity: .5; }
|
||
|
||
/* openings, panels, details */
|
||
.iso-svg .iso-win { fill: var(--iso-win); stroke: none; }
|
||
.iso-svg .iso-win.iso-lit { fill: var(--iso-winlit); }
|
||
.iso-svg .iso-glass { fill: var(--iso-glass); fill-opacity: var(--iso-glasso); stroke: none; }
|
||
.iso-svg .iso-door { fill: var(--iso-door); stroke: none; }
|
||
.iso-svg .iso-door.iso-on { fill: color-mix(in srgb, var(--iso-door) 70%, var(--iso-winlit)); }
|
||
.iso-svg .iso-door2 { fill: var(--iso-dock); stroke: var(--iso-edge); stroke-width: .3; }
|
||
.iso-svg .iso-dock { fill: var(--iso-dock); stroke: var(--iso-hazard); stroke-width: .5; }
|
||
.iso-svg .iso-open { fill: var(--iso-open); stroke: none; }
|
||
.iso-svg .iso-louvre, .iso-svg .iso-fins, .iso-svg .iso-grille { fill: var(--iso-open); fill-opacity: .55; stroke: none; }
|
||
.iso-svg .iso-slats { fill: var(--iso-open); fill-opacity: .35; stroke: none; }
|
||
.iso-svg .iso-bars { fill: var(--iso-open); stroke: none; }
|
||
.iso-svg .iso-bessdoor { fill: none; stroke: var(--iso-edge); stroke-width: .35; }
|
||
.iso-svg .iso-chipsin { fill: var(--iso-chips); stroke: none; }
|
||
.iso-svg .iso-fire { fill: var(--iso-fire); stroke: none; }
|
||
.iso-svg .iso-boxes { fill: #b79b6a; fill-opacity: .75; stroke: none; }
|
||
.iso-svg .iso-pv { fill: var(--iso-pv); stroke: var(--iso-pvl); stroke-width: .28; }
|
||
.iso-svg .iso-glint { fill: var(--iso-glint); stroke: none; opacity: 0; pointer-events: none; }
|
||
.iso-svg .iso-roofin { fill: var(--iso-roofin); stroke: none; }
|
||
.iso-svg .iso-seam { fill: none; stroke: var(--iso-edge); stroke-width: .3; opacity: .55; }
|
||
.iso-svg .iso-reserve { fill: var(--iso-mark); fill-opacity: .05; stroke: var(--iso-mark); stroke-width: .6; stroke-dasharray: 4 3; opacity: .7; }
|
||
.iso-svg .iso-ventc { fill: #1d222b; }
|
||
.iso-svg .iso-tankring { fill: none; stroke: var(--iso-edge); stroke-width: .4; }
|
||
.iso-svg .iso-fanring { fill: #252b35; }
|
||
.iso-svg .iso-fan path { fill: #a9b3c1; stroke: none; }
|
||
.iso-svg .iso-sink { fill: #263241; }
|
||
.iso-svg .iso-cathode { fill: #7f8b9c; }
|
||
.iso-svg .iso-vinylc { fill: #101217; }
|
||
.iso-svg .iso-platter { fill: #9aa3b1; }
|
||
.iso-svg .iso-lacquer { fill: #1a1d24; }
|
||
.iso-svg .iso-lab1 { fill: #ffcf4a; } .iso-svg .iso-lab2 { fill: #ff6b6b; } .iso-svg .iso-lab3 { fill: #6aa8ff; } .iso-svg .iso-lab4 { fill: #f2f2f2; }
|
||
.iso-svg .iso-screenm { --b: #2a3343; }
|
||
.iso-pal-k { --b: #1f2228; } .iso-pal-w { --b: #e6eaef; } .iso-pal-r { --b: #d0453f; } .iso-pal-y { --b: #e8c22e; }
|
||
.iso-pal-b { --b: #2f7de1; } .iso-pal-g { --b: #2fae68; } .iso-pal-p { --b: #9b5de5; } .iso-pal-o { --b: #f08a2a; } .iso-pal-bx { --b: var(--iso-carton); }
|
||
.iso-svg .iso-steps { fill: none; stroke: var(--iso-edge); stroke-width: .35; }
|
||
.iso-svg .iso-later { fill: none; stroke: var(--iso-mark); stroke-width: .4; stroke-dasharray: 1.5 1.5; }
|
||
.iso-svg .iso-trunk { fill: var(--iso-trunk); stroke: none; }
|
||
.iso-leaf { --b: var(--iso-leaf); }
|
||
.iso-svg .iso-rope { fill: none; stroke: #2a2f38; stroke-width: .5; }
|
||
.iso-svg .iso-lamp { fill: var(--iso-lamp); stroke: none; }
|
||
html[data-theme=light] .iso-svg .iso-lamp { fill: #c9ced6; }
|
||
.iso-svg .iso-led { stroke: none; }
|
||
.iso-svg .iso-ledr { fill: var(--iso-led-r); } .iso-svg .iso-ledg { fill: var(--iso-led-g); } .iso-svg .iso-ledo { fill: var(--iso-led-o); }
|
||
|
||
/* floors and markings (inside view) */
|
||
.iso-z-floor, .iso-z-press, .iso-z-aisle, .iso-z-yard { --b: var(--iso-floor); }
|
||
.iso-z-staff { --b: color-mix(in srgb, var(--iso-floor) 82%, #b48ae0); }
|
||
.iso-z-shop, .iso-z-shop2 { --b: color-mix(in srgb, var(--iso-floor) 82%, #e0c26a); }
|
||
.iso-z-galv { --b: color-mix(in srgb, var(--iso-floor) 76%, #4fc3d0); }
|
||
.iso-z-lab { --b: color-mix(in srgb, var(--iso-floor) 80%, #a77ad8); }
|
||
.iso-z-prep { --b: color-mix(in srgb, var(--iso-floor) 80%, #d8c76a); }
|
||
.iso-z-colour { --b: color-mix(in srgb, var(--iso-floor) 82%, #e07a4a); }
|
||
.iso-z-plant { --b: color-mix(in srgb, var(--iso-floor) 78%, #5fbf72); }
|
||
.iso-z-trim, .iso-z-qc { --b: color-mix(in srgb, var(--iso-floor) 80%, #6aa8ff); }
|
||
.iso-z-pack { --b: color-mix(in srgb, var(--iso-floor) 84%, #c8a070); }
|
||
.iso-z-wh, .iso-z-disp { --b: color-mix(in srgb, var(--iso-floor) 84%, #8fa6c8); }
|
||
.iso-z-corr { --b: color-mix(in srgb, var(--iso-floor) 74%, #9fd0ff); }
|
||
.iso-z-lobby, .iso-z-canteen { --b: color-mix(in srgb, var(--iso-office) 70%, var(--iso-floor)); }
|
||
.iso-z-cut, .iso-z-listen { --b: color-mix(in srgb, var(--iso-floor) 72%, #6b4a8e); }
|
||
.iso-z-arch { --b: color-mix(in srgb, var(--iso-floor) 76%, #6aa8ff); }
|
||
.iso-svg .iso-part { fill: none; stroke: var(--iso-part); stroke-width: .9; stroke-linecap: square; }
|
||
.iso-svg .iso-mark { fill: none; stroke: var(--iso-mark); stroke-width: .7; stroke-dasharray: 3 2; }
|
||
.iso-svg .iso-trench { fill: var(--iso-trench); stroke: none; }
|
||
.iso-svg .iso-trenchl { fill: none; stroke: var(--iso-trench); stroke-width: 2.2; }
|
||
.iso-svg .iso-grate { fill: none; stroke: var(--iso-part); stroke-width: .35; }
|
||
.iso-svg .iso-slot { fill: none; stroke: var(--iso-mark); stroke-width: .6; }
|
||
.iso-svg .iso-slot.iso-empty { stroke-dasharray: 3 2.4; fill: var(--iso-mark); fill-opacity: .06; }
|
||
.iso-svg .iso-cell { fill: none; stroke: var(--iso-hivis); stroke-width: .45; stroke-dasharray: 1.4 1.4; opacity: .8; }
|
||
.iso-svg .iso-bay { fill: var(--iso-mark); fill-opacity: .1; stroke: var(--iso-mark); stroke-width: .5; stroke-dasharray: 2 2; }
|
||
|
||
/* tags (counter-scaled by --iso-k so they keep their screen size) */
|
||
.iso-tag path { fill: var(--iso-tagbg); stroke: var(--iso-tagline); stroke-width: 1; }
|
||
.iso-tag text { fill: var(--iso-tagfg); font: 600 10.5px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; text-anchor: middle; }
|
||
.iso-signtag path { fill: var(--iso-signbg); stroke: var(--iso-signline); }
|
||
.iso-signtag text { fill: var(--iso-signfg); }
|
||
.iso-warntag path { fill: var(--iso-warnbg); stroke: var(--iso-warnline); }
|
||
.iso-warntag text { fill: var(--iso-warnfg); }
|
||
.iso-tag { cursor: default; }
|
||
.iso-svg .iso-hide { display: none; }
|
||
.iso-svg .iso-ghost { opacity: .38; }
|
||
|
||
/* hover and selection */
|
||
.iso-svg .iso-hov > path, .iso-svg .iso-hov > ellipse, .iso-svg path.iso-hov { stroke: var(--iso-hov); stroke-width: .8; }
|
||
.iso-svg .iso-sel > path, .iso-svg .iso-sel > ellipse, .iso-svg .iso-sel > g > path, .iso-svg path.iso-sel { stroke: var(--iso-sel); stroke-width: 1.1; }
|
||
|
||
/* animation: CSS only; paused off-screen (iso-off), when stopped (iso-still) and for reduced motion */
|
||
.iso-svg .iso-fan { transform-box: fill-box; transform-origin: center; animation: iso-spin 1.1s linear infinite; }
|
||
.iso-svg .iso-puff, .iso-svg .iso-puff2, .iso-svg .iso-smoke { transform-box: fill-box; transform-origin: center; animation: iso-puff 3.2s ease-out infinite; opacity: 0; pointer-events: none; stroke: none; }
|
||
.iso-svg .iso-puff, .iso-svg .iso-puff2 { fill: var(--iso-steam); }
|
||
.iso-svg .iso-smoke { fill: var(--iso-smoke); }
|
||
.iso-svg .iso-glint { animation: iso-glint 7s ease-in-out infinite; }
|
||
.iso-svg .iso-glint { display: none; }
|
||
html[data-theme=light] .iso-svg .iso-glint { display: inline; }
|
||
.iso-svg .iso-disc { animation: iso-move 16s linear infinite; }
|
||
.iso-svg .iso-walk { animation: iso-shuttle 70s ease-in-out infinite; }
|
||
.iso-svg .iso-crane { animation: iso-shuttle 48s ease-in-out infinite; }
|
||
.iso-svg .iso-tin-r { animation: iso-tin-r 40s linear infinite; }
|
||
.iso-svg .iso-tin-g { animation: iso-tin-g 40s linear infinite; }
|
||
.iso-svg .iso-tin-d { animation: iso-tin-d 40s linear infinite; }
|
||
.iso-svg .iso-tout-g { animation: iso-tout-g 40s linear infinite; }
|
||
.iso-svg .iso-tout-r { animation: iso-tout-r 40s linear infinite; }
|
||
.iso-svg .iso-ledr, .iso-svg .iso-ledo { animation: iso-blink 2.4s ease-in-out infinite; }
|
||
.iso-svg .iso-park { display: none; }
|
||
.iso-off .iso-a, .iso-still .iso-a { animation-play-state: paused !important; }
|
||
.iso-still .iso-park { display: inline; }
|
||
@keyframes iso-spin { to { transform: rotate(360deg); } }
|
||
@keyframes iso-puff {
|
||
0% { transform: translate(0, 0) scale(.35); opacity: 0; }
|
||
12% { opacity: .75; }
|
||
100% { transform: translate(5px, -28px) scale(2); opacity: 0; }
|
||
}
|
||
@keyframes iso-glint { 0%, 84%, 100% { opacity: 0; } 91% { opacity: .9; } }
|
||
@keyframes iso-blink { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
|
||
@keyframes iso-move { from { transform: translate(0, 0); } to { transform: translate(var(--dx), var(--dy)); } }
|
||
@keyframes iso-shuttle {
|
||
0%, 8% { transform: translate(0, 0); }
|
||
46%, 56% { transform: translate(var(--dx), var(--dy)); }
|
||
94%, 100% { transform: translate(0, 0); }
|
||
}
|
||
@keyframes iso-tin-r {
|
||
0% { transform: translate(0, 0); opacity: 0; }
|
||
2% { opacity: 1; }
|
||
22% { transform: translate(var(--dx), var(--dy)); opacity: 1; }
|
||
22.2%, 100% { transform: translate(var(--dx), var(--dy)); opacity: 0; }
|
||
}
|
||
@keyframes iso-tin-g {
|
||
0%, 22% { transform: translate(0, 0); opacity: 0; }
|
||
22.2% { transform: translate(0, 0); opacity: 1; }
|
||
36% { transform: translate(var(--dx), var(--dy)); opacity: 1; }
|
||
36.2%, 100% { transform: translate(var(--dx), var(--dy)); opacity: 0; }
|
||
}
|
||
@keyframes iso-tin-d {
|
||
0%, 36% { opacity: 0; }
|
||
36.2%, 90% { opacity: 1; }
|
||
95%, 100% { opacity: 0; }
|
||
}
|
||
@keyframes iso-tout-g {
|
||
0% { transform: translate(0, 0); opacity: 0; }
|
||
4%, 45% { transform: translate(0, 0); opacity: 1; }
|
||
62% { transform: translate(var(--dx), var(--dy)); opacity: 1; }
|
||
62.2%, 100% { transform: translate(var(--dx), var(--dy)); opacity: 0; }
|
||
}
|
||
@keyframes iso-tout-r {
|
||
0%, 62% { transform: translate(0, 0); opacity: 0; }
|
||
62.2% { transform: translate(0, 0); opacity: 1; }
|
||
86% { transform: translate(var(--dx), var(--dy)); opacity: 1; }
|
||
88%, 100% { transform: translate(var(--dx), var(--dy)); opacity: 0; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.iso-svg .iso-a { animation: none !important; }
|
||
.iso-svg .iso-puff, .iso-svg .iso-puff2, .iso-svg .iso-smoke, .iso-svg .iso-disc, .iso-svg .iso-mv, .iso-svg .iso-glint { display: none; }
|
||
.iso-svg .iso-park { display: inline; }
|
||
}
|
||
@media (max-width: 640px) {
|
||
.iso-stage { aspect-ratio: 1 / 1; min-height: 320px; }
|
||
.iso-bar { gap: 4px; top: 6px; left: 6px; right: 6px; }
|
||
.iso-bar button { padding: 4px 7px; min-width: 28px; min-height: 30px; }
|
||
.iso-compass { padding: 3px 4px; min-height: 30px; }
|
||
.iso-compass b { display: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<button class="themebtn" id="themeBtn" type="button">Light mode</button>
|
||
<h1>Plant and business plan calculator</h1>
|
||
<span class="tag">planning estimates · not a quote</span>
|
||
<p class="lead">
|
||
Set the records you want a year and the share of splatter and other
|
||
specials, then configure the plant: galvanics in-house or bought stampers,
|
||
cutting in-house or outsourced, metrology, cassette and CD-R, solar, steam
|
||
fuel, utility sourcing, crane, visitor corridor and the ramp-up reserve. The
|
||
calculator sizes the press fleet from Newbilt Duplex systems (two HRP 12
|
||
presses, an EX 50 and an EX 35 colour extruder each) and Newbilt Classic
|
||
automatic presses, starting from the launch plant: one Duplex system and one
|
||
Classic. It builds the investment from the blocks in total-investment.md,
|
||
checks it against the investor's 180M and 216M THB, runs the P&L and a
|
||
ten-year cash sketch, and lists what the plant needs: people, power, central
|
||
hydraulics, steam, cooling, compressed air, floor, compound and stampers.
|
||
Vendor figures are marked as such; the rest are planning figures and
|
||
estimates from the CNX Vinyl documents. Every figure can be changed under
|
||
Assumptions.
|
||
</p>
|
||
<div class="presets" id="presets">
|
||
<span>Presets:</span>
|
||
<button type="button" data-p="launch">Launch plant (decided)</button>
|
||
<button type="button" data-p="levers">Investor levers (216M)</button>
|
||
<button type="button" data-p="lean">Lean: bought stampers, outsourced cutting</button>
|
||
<button type="button" data-p="five">Five-press fleet (second Duplex)</button>
|
||
<button type="button" data-p="full">Full hall, Classic-led (2 D + 11 C)</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="wrap">
|
||
<section class="card" id="isoCard">
|
||
<h2>The plant at a glance</h2>
|
||
<p class="note" style="margin-top:0">The current plant configuration (preset or the switches below) as a
|
||
tile-game view of the site: presses,
|
||
stamper line, cutting room, utility yard with its boiler and chiller modules, solar and battery.
|
||
Switch between the roof and the inside, rotate, zoom and tap anything for its figures. Schematic,
|
||
not to scale where the vendors publish no footprint.</p>
|
||
<div id="isoPlant"></div>
|
||
</section>
|
||
</div>
|
||
|
||
<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>
|
||
/* Isometric plant view (iso-plant.js, global CNXIso) */
|
||
/*
|
||
* iso-plant.js: the CNX Vinyl plant as an isometric "tile game" scene (SVG).
|
||
* Plain ES2019, no dependencies, no build step, one global: CNXIso.
|
||
*
|
||
* var view = CNXIso.mount(containerEl, cfg, { onPick: function (info) {} });
|
||
* view.update(cfg); // merges cfg over the current config and redraws
|
||
* view.destroy();
|
||
*
|
||
* Layout: the Payap scenario model (docs/site-candidates.md), the detached
|
||
* utility yard (docs/scale.md), crane, workshop and visitor corridor
|
||
* (docs/crane-workshop-tours.md), archive shelving (docs/facility-security.md),
|
||
* the Duplex cell (docs/equipment/pressing-line.md), galvanics and Sibert
|
||
* (docs/equipment/galvanics.md). Schematic: positions and envelopes are
|
||
* estimates unless a tooltip names the source of a figure.
|
||
*/
|
||
(function (root) {
|
||
"use strict";
|
||
|
||
var VERSION = "1.0";
|
||
var DEF = {
|
||
duplex: 1, classic: 1, galvanics: true, cutting: true, metrology: "full",
|
||
cassette: true, cdr: true, solar: "buy", pvKwp: 300, batteryKwh: 271,
|
||
fuel: "electric", boilerModules: 2, chillerModules: 2, crane: "buy",
|
||
corridor: true, phase2: false, running: true, labels: true
|
||
};
|
||
var OFFW = { off: 1, out: 1, bought: 1, no: 1, none: 1, later: 1, "false": 1, "0": 1, "": 1 };
|
||
function bool(v, d) {
|
||
if (v === undefined || v === null) return d;
|
||
if (typeof v === "string") return !OFFW[v.toLowerCase()];
|
||
return !!v;
|
||
}
|
||
function numv(v, d, lo, hi, round) {
|
||
var n = Number(v);
|
||
if (v === undefined || v === null || v === "" || !isFinite(n)) n = d;
|
||
if (round) n = Math.round(n);
|
||
return Math.max(lo, Math.min(hi, n));
|
||
}
|
||
function norm(c) {
|
||
c = c || {};
|
||
return {
|
||
duplex: numv(c.duplex, DEF.duplex, 0, 12, true),
|
||
classic: numv(c.classic, DEF.classic, 0, 40, true),
|
||
galvanics: bool(c.galvanics, true),
|
||
cutting: bool(c.cutting, true),
|
||
metrology: c.metrology === "launch" ? "launch" : "full",
|
||
cassette: bool(c.cassette, true),
|
||
cdr: bool(c.cdr, true),
|
||
solar: c.solar === "ppa" || c.solar === "none" ? c.solar : "buy",
|
||
pvKwp: numv(c.pvKwp, DEF.pvKwp, 0, 3000, false),
|
||
batteryKwh: numv(c.batteryKwh, DEF.batteryKwh, 0, 5000, false),
|
||
fuel: c.fuel === "biomass" ? "biomass" : "electric",
|
||
boilerModules: numv(c.boilerModules, DEF.boilerModules, 0, 12, true),
|
||
chillerModules: numv(c.chillerModules, DEF.chillerModules, 0, 12, true),
|
||
crane: c.crane === "rent" ? "rent" : "buy",
|
||
corridor: bool(c.corridor, true),
|
||
phase2: bool(c.phase2, false),
|
||
running: bool(c.running, true),
|
||
labels: bool(c.labels, true)
|
||
};
|
||
}
|
||
function fmt(n) { return Math.round(n).toLocaleString("en-US"); }
|
||
function esc(s) {
|
||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
function r1(v) { return Math.round(v * 10) / 10; }
|
||
function hash(i) { var x = Math.sin(i * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); }
|
||
|
||
// ------------------------------------------------------------ projection
|
||
// World: x east, y north, z up, metres. View coordinates (vx, vy): the
|
||
// viewer sits at +vx +vy, so faces facing +vy show on the left (mid tone),
|
||
// faces facing +vx on the right (dark). Four 90 degree view rotations.
|
||
var C = 4, CZ = 4, SQ2 = Math.SQRT2;
|
||
var ROT = [
|
||
function (x, y) { return [x, -y]; }, // from the south-east
|
||
function (x, y) { return [-y, -x]; }, // from the south-west
|
||
function (x, y) { return [-x, y]; }, // from the north-west
|
||
function (x, y) { return [y, x]; } // from the north-east
|
||
];
|
||
function Proj(rot) { this.rot = ((rot % 4) + 4) % 4; this.R = ROT[this.rot]; }
|
||
Proj.prototype.s = function (x, y, z) {
|
||
var v = this.R(x, y);
|
||
return [(v[0] - v[1]) * C, (v[0] + v[1]) * C / 2 - z * CZ];
|
||
};
|
||
Proj.prototype.d = function (pts, open) {
|
||
var o = "", i, s;
|
||
for (i = 0; i < pts.length; i++) { s = this.s(pts[i][0], pts[i][1], pts[i][2]); o += (i ? "L" : "M") + r1(s[0]) + " " + r1(s[1]); }
|
||
return open ? o : o + "Z";
|
||
};
|
||
Proj.prototype.vis = function (nx, ny) { var v = this.R(nx, ny); return v[0] + v[1] > 1e-6 ? v : null; };
|
||
Proj.prototype.dv = function (dx, dy, dz) {
|
||
var a = this.s(dx, dy, dz || 0), b = this.s(0, 0, 0);
|
||
return [a[0] - b[0], a[1] - b[1]];
|
||
};
|
||
Proj.prototype.depth = function (x, y) { var v = this.R(x, y); return v[0] + v[1]; };
|
||
function shade(v) {
|
||
var a = Math.atan2(v[1], v[0]) * 180 / Math.PI;
|
||
return a > 67.5 ? "iso-l" : a < 22.5 ? "iso-r" : "iso-c";
|
||
}
|
||
function rect(x0, y0, x1, y1) { return [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; }
|
||
function did(id) { return id ? ' data-id="' + id + '"' : ""; }
|
||
|
||
// prism with a convex ccw footprint; z0/z1 numbers or one value per vertex
|
||
function prismS(P, pts, z0, z1, mat, o) {
|
||
o = o || {};
|
||
var n = pts.length, zt = [], zb = [], i, a, b, v, out = "", at = did(o.id), tp = [];
|
||
for (i = 0; i < n; i++) { zt.push(typeof z1 === "number" ? z1 : z1[i]); zb.push(typeof z0 === "number" ? z0 : z0[i]); }
|
||
if (!o.noSide) {
|
||
for (i = 0; i < n; i++) {
|
||
a = pts[i]; b = pts[(i + 1) % n];
|
||
v = P.vis(b[1] - a[1], a[0] - b[0]);
|
||
if (!v) continue;
|
||
var j = (i + 1) % n;
|
||
if (zt[i] - zb[i] < 1e-6 && zt[j] - zb[j] < 1e-6) continue;
|
||
out += '<path class="' + (o.side || mat) + " " + shade(v) + '"' + at + ' d="' +
|
||
P.d([[a[0], a[1], zb[i]], [b[0], b[1], zb[j]], [b[0], b[1], zt[j]], [a[0], a[1], zt[i]]]) + '"/>';
|
||
}
|
||
}
|
||
if (!o.noTop) {
|
||
for (i = 0; i < n; i++) tp.push([pts[i][0], pts[i][1], zt[i]]);
|
||
out += '<path class="' + (o.top || mat) + ' iso-t"' + at + ' d="' + P.d(tp) + '"/>';
|
||
}
|
||
return out;
|
||
}
|
||
function cylS(P, cx, cy, r, z0, z1, mat, o) {
|
||
o = o || {};
|
||
var b = P.s(cx, cy, z0), t = P.s(cx, cy, z1), rx = r * C * SQ2, ry = rx / 2, at = did(o.id), s = "";
|
||
var bx = r1(b[0]), by = r1(b[1]), ty = r1(t[1]), L = r1(b[0] - rx), R = r1(b[0] + rx), RX = r1(rx), RY = r1(ry);
|
||
var fb = r1(b[1] + ry), ft = r1(t[1] + ry);
|
||
if (z1 > z0) {
|
||
s += '<path class="' + (o.side || mat) + ' iso-l"' + at + ' d="M' + L + " " + ty + "L" + L + " " + by + "A" + RX + " " + RY +
|
||
" 0 0 0 " + bx + " " + fb + "L" + bx + " " + ft + "A" + RX + " " + RY + " 0 0 1 " + L + " " + ty + 'Z"/>';
|
||
s += '<path class="' + (o.side || mat) + ' iso-r"' + at + ' d="M' + bx + " " + fb + "A" + RX + " " + RY + " 0 0 0 " + R + " " + by +
|
||
"L" + R + " " + ty + "A" + RX + " " + RY + " 0 0 1 " + bx + " " + ft + 'Z"/>';
|
||
}
|
||
if (!o.noTop) s += '<ellipse class="' + (o.top || mat) + ' iso-t"' + at + ' cx="' + bx + '" cy="' + ty + '" rx="' + RX + '" ry="' + RY + '"/>';
|
||
return s;
|
||
}
|
||
// frustum (cone) with n sides: radius r0 at z0, r1 at z1
|
||
function frustS(P, cx, cy, r0, r1_, z0, z1, mat, n, o) {
|
||
o = o || {};
|
||
n = n || 10;
|
||
var i, a0, a1, out = "", top = [], v, at = did(o.id);
|
||
for (i = 0; i < n; i++) {
|
||
a0 = i / n * Math.PI * 2; a1 = (i + 1) / n * Math.PI * 2;
|
||
var am = (a0 + a1) / 2;
|
||
v = P.vis(Math.cos(am), Math.sin(am));
|
||
top.push([cx + r1_ * Math.cos(a0), cy + r1_ * Math.sin(a0), z1]);
|
||
if (!v) continue;
|
||
out += '<path class="' + mat + " " + shade(v) + '"' + at + ' d="' + P.d([
|
||
[cx + r0 * Math.cos(a0), cy + r0 * Math.sin(a0), z0], [cx + r0 * Math.cos(a1), cy + r0 * Math.sin(a1), z0],
|
||
[cx + r1_ * Math.cos(a1), cy + r1_ * Math.sin(a1), z1], [cx + r1_ * Math.cos(a0), cy + r1_ * Math.sin(a0), z1]]) + '"/>';
|
||
}
|
||
if (r1_ > 0.01 && !o.noTop) out += '<path class="' + mat + ' iso-t"' + at + ' d="' + P.d(top) + '"/>';
|
||
return out;
|
||
}
|
||
// openings (windows, doors) on the vertical face through A -> B (outward normal on the right)
|
||
function openS(P, A, B, list, cls, id) {
|
||
var vv = list.length ? P.vis(B[1] - A[1], A[0] - B[0]) : null;
|
||
if (!vv) return "";
|
||
var dx = B[0] - A[0], dy = B[1] - A[1], L = Math.sqrt(dx * dx + dy * dy), ux = dx / L, uy = dy / L, d = "", i, q;
|
||
for (i = 0; i < list.length; i++) {
|
||
q = list[i];
|
||
d += P.d([[A[0] + ux * q[0], A[1] + uy * q[0], q[2]], [A[0] + ux * q[1], A[1] + uy * q[1], q[2]],
|
||
[A[0] + ux * q[1], A[1] + uy * q[1], q[3]], [A[0] + ux * q[0], A[1] + uy * q[0], q[3]]]);
|
||
}
|
||
return '<path class="' + cls + " " + shade(vv) + '"' + did(id) + ' d="' + d + '"/>';
|
||
}
|
||
function sides(x0, y0, x1, y1) {
|
||
return { S: [[x0, y0], [x1, y0]], E: [[x1, y0], [x1, y1]], N: [[x1, y1], [x0, y1]], W: [[x0, y1], [x0, y0]] };
|
||
}
|
||
function flatS(P, pts, z, cls, id, extra) {
|
||
var p3 = [], i;
|
||
for (i = 0; i < pts.length; i++) p3.push([pts[i][0], pts[i][1], z]);
|
||
return '<path class="' + cls + '"' + did(id) + (extra || "") + ' d="' + P.d(p3) + '"/>';
|
||
}
|
||
function linesS(P, segs, z, cls) { // segs: [[x0,y0,x1,y1], ...]
|
||
var d = "", i, a, b;
|
||
for (i = 0; i < segs.length; i++) {
|
||
a = P.s(segs[i][0], segs[i][1], z); b = P.s(segs[i][2], segs[i][3], z);
|
||
d += "M" + r1(a[0]) + " " + r1(a[1]) + "L" + r1(b[0]) + " " + r1(b[1]);
|
||
}
|
||
return d ? '<path class="' + cls + '" d="' + d + '"/>' : "";
|
||
}
|
||
function discS(P, cx, cy, z, r, cls, id) {
|
||
var s = P.s(cx, cy, z), rx = r * C * SQ2;
|
||
return '<ellipse class="' + cls + '"' + did(id) + ' cx="' + r1(s[0]) + '" cy="' + r1(s[1]) + '" rx="' + r1(rx) + '" ry="' + r1(rx / 2) + '"/>';
|
||
}
|
||
|
||
// ------------------------------------------------------------ sprites
|
||
function Sprite(sc, id, cls) {
|
||
this.sc = sc; this.P = sc.P; this.id = id || ""; this.cls = cls || ""; this.h = [];
|
||
this.bb = [1e9, 1e9, -1e9, -1e9, 1e9, -1e9]; this.sb = [1e9, 1e9, -1e9, -1e9];
|
||
}
|
||
var SP = Sprite.prototype;
|
||
SP.ext = function (x, y, z) {
|
||
var v = this.P.R(x, y), b = this.bb, s = this.P.s(x, y, z), q = this.sb;
|
||
if (v[0] < b[0]) b[0] = v[0]; if (v[1] < b[1]) b[1] = v[1];
|
||
if (v[0] > b[2]) b[2] = v[0]; if (v[1] > b[3]) b[3] = v[1];
|
||
if (z < b[4]) b[4] = z; if (z > b[5]) b[5] = z;
|
||
if (s[0] < q[0]) q[0] = s[0]; if (s[1] < q[1]) q[1] = s[1];
|
||
if (s[0] > q[2]) q[2] = s[0]; if (s[1] > q[3]) q[3] = s[1];
|
||
return this;
|
||
};
|
||
SP.extBox = function (x0, y0, x1, y1, z0, z1) {
|
||
this.ext(x0, y0, z0).ext(x1, y0, z0).ext(x1, y1, z0).ext(x0, y1, z0);
|
||
return this.ext(x0, y0, z1).ext(x1, y0, z1).ext(x1, y1, z1).ext(x0, y1, z1);
|
||
};
|
||
SP.box = function (x0, y0, x1, y1, z0, z1, mat, o) {
|
||
this.extBox(x0, y0, x1, y1, z0, z1);
|
||
this.h.push(prismS(this.P, rect(x0, y0, x1, y1), z0, z1, mat, o));
|
||
return this;
|
||
};
|
||
SP.prism = function (pts, z0, z1, mat, o) {
|
||
for (var i = 0; i < pts.length; i++) {
|
||
this.ext(pts[i][0], pts[i][1], typeof z0 === "number" ? z0 : z0[i]);
|
||
this.ext(pts[i][0], pts[i][1], typeof z1 === "number" ? z1 : z1[i]);
|
||
}
|
||
this.h.push(prismS(this.P, pts, z0, z1, mat, o));
|
||
return this;
|
||
};
|
||
SP.cyl = function (cx, cy, r, z0, z1, mat, o) {
|
||
this.extBox(cx - r, cy - r, cx + r, cy + r, z0, z1);
|
||
this.h.push(cylS(this.P, cx, cy, r, z0, z1, mat, o));
|
||
return this;
|
||
};
|
||
SP.frust = function (cx, cy, r0, r1_, z0, z1, mat, n, o) {
|
||
var r = Math.max(r0, r1_);
|
||
this.extBox(cx - r, cy - r, cx + r, cy + r, z0, z1);
|
||
this.h.push(frustS(this.P, cx, cy, r0, r1_, z0, z1, mat, n, o));
|
||
return this;
|
||
};
|
||
SP.open = function (A, B, list, cls, id) { this.h.push(openS(this.P, A, B, list, cls, id)); return this; };
|
||
SP.flat = function (pts, z, cls, id, extra) { this.h.push(flatS(this.P, pts, z, cls, id, extra)); return this; };
|
||
SP.lines = function (segs, z, cls) { this.h.push(linesS(this.P, segs, z, cls)); return this; };
|
||
SP.disc = function (cx, cy, z, r, cls, id) { this.h.push(discS(this.P, cx, cy, z, r, cls, id)); return this; };
|
||
SP.raw = function (s) { this.h.push(s); return this; };
|
||
SP.ga = function (cls, style) { this.h.push('<g class="' + cls + '"' + (style ? ' style="' + style + '"' : "") + ">"); return this; };
|
||
SP.ge = function () { this.h.push("</g>"); return this; };
|
||
// draw items in back-to-front order of their plan centre: [[x, y, fn], ...]
|
||
SP.seq = function (items) {
|
||
var P = this.P, self = this;
|
||
items.slice().sort(function (a, b) { return P.depth(a[0], a[1]) - P.depth(b[0], b[1]); })
|
||
.forEach(function (it) { it[2](self); });
|
||
return this;
|
||
};
|
||
SP.person = function (x, y, z, cls) {
|
||
this.cyl(x, y, 0.2, z, z + 1.2, cls || "iso-hivis", { noTop: true });
|
||
var s = this.P.s(x, y, z + 1.45);
|
||
this.ext(x, y, z + 1.65);
|
||
this.h.push('<circle class="iso-headc" cx="' + r1(s[0]) + '" cy="' + r1(s[1]) + '" r="' + r1(0.2 * C * 1.2) + '"/>');
|
||
return this;
|
||
};
|
||
SP.led = function (x, y, z, cls) {
|
||
var s = this.P.s(x, y, z);
|
||
this.h.push('<circle class="iso-led ' + (cls || "") + '" cx="' + r1(s[0]) + '" cy="' + r1(s[1]) + '" r="0.9"/>');
|
||
return this;
|
||
};
|
||
SP.puffs = function (x, y, z, kind, n, dur) {
|
||
var s = this.P.s(x, y, z), i, h = '<g class="iso-vent" transform="translate(' + r1(s[0]) + " " + r1(s[1]) + ')">';
|
||
n = n || 3; dur = dur || 3.2;
|
||
for (i = 0; i < n; i++) {
|
||
h += '<circle class="iso-a ' + (kind || "iso-puff") + '" r="' + (kind === "iso-smoke" ? 3.2 : kind === "iso-puff2" ? 2.4 : 1.5) +
|
||
'" style="animation-delay:-' + (i * dur / n + hash(x + y + i) * 0.4).toFixed(2) + 's;animation-duration:' + dur + 's"/>';
|
||
}
|
||
this.h.push(h + "</g>");
|
||
return this;
|
||
};
|
||
SP.fan = function (x, y, z, r) {
|
||
var P = this.P, s = P.s(x, y, z), w = 0.12;
|
||
this.h.push(discS(P, x, y, z, r, "iso-fanring"));
|
||
this.h.push('<g transform="matrix(' + C + " " + C / 2 + " " + (-C) + " " + C / 2 + " " + r1(s[0]) + " " + r1(s[1]) + ')"><g class="iso-a iso-fan">' +
|
||
'<path d="M' + (-r * 0.85) + " " + (-w) + "H" + r * 0.85 + "V" + w + "H" + (-r * 0.85) + "ZM" + (-w) + " " + (-r * 0.85) + "V" + r * 0.85 + "H" + w + "V" + (-r * 0.85) + 'Z"/></g></g>');
|
||
return this;
|
||
};
|
||
|
||
function Scene(rot, uid) {
|
||
this.P = new Proj(rot); this.uid = uid || 0;
|
||
this.ground = []; this.sp = []; this.tags = []; this.info = {}; this.flags = [];
|
||
}
|
||
Scene.prototype.sprite = function (id, cls) { var s = new Sprite(this, id, cls); this.sp.push(s); return s; };
|
||
Scene.prototype.g = function (s) { this.ground.push(s); };
|
||
Scene.prototype.inf = function (id, name, detail) { this.info[id] = { id: id, name: name, detail: detail || "" }; };
|
||
Scene.prototype.tag = function (x, y, z, text, prio, id, kind) {
|
||
this.tags.push({ x: x, y: y, z: z, t: text, p: prio || 1, id: id || "", k: kind || "" });
|
||
};
|
||
|
||
// ------------------------------------------------------------ depth sort
|
||
function cmpBB(a, b) {
|
||
var e = 1e-6;
|
||
if (a[2] <= b[0] + e) return -1; if (b[2] <= a[0] + e) return 1;
|
||
if (a[3] <= b[1] + e) return -1; if (b[3] <= a[1] + e) return 1;
|
||
if (a[5] <= b[4] + e) return -1; if (b[5] <= a[4] + e) return 1;
|
||
var ka = a[0] + a[2] + a[1] + a[3], kb = b[0] + b[2] + b[1] + b[3];
|
||
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
||
}
|
||
function sortSprites(list) {
|
||
var n = list.length, adj = [], indeg = [], key = [], done = [], out = [], i, j, a, b, c, k, best;
|
||
for (i = 0; i < n; i++) {
|
||
adj.push([]); indeg.push(0); done.push(false);
|
||
b = list[i].bb; key.push((b[0] + b[2]) / 2 + (b[1] + b[3]) / 2 + (b[4] + b[5]) * 0.05);
|
||
}
|
||
for (i = 0; i < n; i++) {
|
||
a = list[i];
|
||
for (j = i + 1; j < n; j++) {
|
||
b = list[j];
|
||
if (a.sb[2] <= b.sb[0] || b.sb[2] <= a.sb[0] || a.sb[3] <= b.sb[1] || b.sb[3] <= a.sb[1]) continue;
|
||
c = cmpBB(a.bb, b.bb);
|
||
if (c < 0) { adj[i].push(j); indeg[j]++; } else if (c > 0) { adj[j].push(i); indeg[i]++; }
|
||
}
|
||
}
|
||
for (k = 0; k < n; k++) {
|
||
best = -1;
|
||
for (i = 0; i < n; i++) if (!done[i] && indeg[i] === 0 && (best < 0 || key[i] < key[best])) best = i;
|
||
if (best < 0) for (i = 0; i < n; i++) if (!done[i] && (best < 0 || indeg[i] < indeg[best] || (indeg[i] === indeg[best] && key[i] < key[best]))) best = i;
|
||
done[best] = true; out.push(list[best]);
|
||
for (j = 0; j < adj[best].length; j++) indeg[adj[best][j]]--;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ------------------------------------------------------------ plant geometry
|
||
// Factory 128 x 25 m (Payap scenario model), floor +1.2 m, monopitch roof
|
||
// with the low eave (7.0 m) on the car-park side and 11.4 m at the back.
|
||
var FX = 8, FY = 69, FL = 128, FW = 25, FFL = 1.2, EAVE = 7.0, HIGH = 11.4;
|
||
function roofZ(fy) { return FFL + EAVE + (HIGH - EAVE) * fy / FW; }
|
||
function X(fx) { return FX + fx; }
|
||
function Y(fy) { return FY + fy; }
|
||
// Press hall: 2 rows, 8 m deep. Slot lengths follow the calculator's floor
|
||
// figures (110 m2 per Duplex bay, 50 m2 per Classic, 870 m2 hall).
|
||
var HALL = { cap: 870, aD: 110, aC: 50 };
|
||
var HROWS = [{ x: X(40), y0: Y(3.6), y1: Y(11.6), len: 52 }, { x: X(47.8), y0: Y(12.4), y1: Y(20.4), len: 44.2 }];
|
||
var LTOT = 52 + 44.2, WD = HALL.aD / HALL.cap * LTOT, WC = HALL.aC / HALL.cap * LTOT;
|
||
var P2 = { x0: 54, y0: 31, x1: 96, y1: 57, eave: 7.0, high: 9.4 };
|
||
var P2ROWS = [{ x: 55.5, y0: 34, y1: 42, len: 39 }, { x: 55.5, y0: 43, y1: 51, len: 39 }];
|
||
var OFC = { x0: 14, y0: 42, x1: 46, y1: 57, z0: 0.3, z1: 8.3 };
|
||
|
||
function tryPlace(list, rows, f, partial) {
|
||
var out = [], ri = 0, pos = 0, i, w, it, lost = 0;
|
||
for (i = 0; i < list.length; i++) {
|
||
it = list[i]; w = (it.t === "D" ? WD : WC) * f;
|
||
while (ri < rows.length && pos + w > rows[ri].len + 1e-6) { ri++; pos = 0; }
|
||
if (ri >= rows.length) {
|
||
if (!partial) return null;
|
||
for (; i < list.length; i++) if (!list[i].e) lost++;
|
||
break;
|
||
}
|
||
out.push({ t: it.t, n: it.n, e: it.e, x0: rows[ri].x + pos, x1: rows[ri].x + pos + w, y0: rows[ri].y0, y1: rows[ri].y1 });
|
||
pos += w;
|
||
}
|
||
return partial ? { placed: out, lost: lost } : out;
|
||
}
|
||
function placeSlots(items, rows) {
|
||
var list = items.slice(), f = 1, res, i, k;
|
||
for (;;) {
|
||
res = tryPlace(list, rows, f, false);
|
||
if (res) return { placed: res, lost: 0 };
|
||
k = -1;
|
||
for (i = list.length - 1; i >= 0; i--) if (list[i].e) { k = i; break; }
|
||
if (k >= 0) { list.splice(k, 1); continue; }
|
||
if (f > 0.83) { f -= 0.02; continue; }
|
||
return tryPlace(list, rows, f, true);
|
||
}
|
||
}
|
||
function allocate(cfg) {
|
||
var D = cfg.duplex, Cn = cfg.classic, A = 0, Dh = 0, Ch = 0, i, items = [];
|
||
while (Dh < D && A + HALL.aD <= HALL.cap + 1e-9) { Dh++; A += HALL.aD; }
|
||
while (Ch < Cn && A + HALL.aC <= HALL.cap + 1e-9) { Ch++; A += HALL.aC; }
|
||
var eD = Math.max(0, 2 - Dh); while (eD > 0 && A + eD * HALL.aD > HALL.cap) eD--;
|
||
var eC = Math.max(0, 11 - Ch); while (eC > 0 && A + eD * HALL.aD + eC * HALL.aC > HALL.cap) eC--;
|
||
for (i = 0; i < Dh; i++) items.push({ t: "D", n: i + 1 });
|
||
for (i = 0; i < eD; i++) items.push({ t: "D", e: 1 });
|
||
for (i = 0; i < Ch; i++) items.push({ t: "C", n: i + 1 });
|
||
for (i = 0; i < eC; i++) items.push({ t: "C", e: 1 });
|
||
var hall = placeSlots(items, HROWS);
|
||
var p2i = [];
|
||
for (i = Dh; i < D; i++) p2i.push({ t: "D", n: i + 1 });
|
||
for (i = Ch; i < Cn; i++) p2i.push({ t: "C", n: i + 1 });
|
||
var need = p2i.length > 0, show = need || cfg.phase2, p2 = { placed: [], lost: 0 };
|
||
if (show) { for (i = 0; i < 16; i++) p2i.push({ t: "C", e: 1 }); p2 = placeSlots(p2i, P2ROWS); }
|
||
return { hall: hall.placed, p2: p2.placed, p2show: show, p2need: need, ovD: D - Dh, ovC: Cn - Ch, Dh: Dh, Ch: Ch,
|
||
lost: hall.lost + p2.lost };
|
||
}
|
||
|
||
// street lamps: site roads, gate and car park; the utility yard gets two on its back fence
|
||
// (earlier positions at y 99.6 stood in the middle of the yard in front of the boiler hall)
|
||
var LAMPS = [[116.5, 15], [116.5, 32], [132, 45], [146.6, 62], [101, 62], [56, 15], [13, 34], [45, 113.2], [88, 113.2], [8.5, 97]];
|
||
|
||
// ------------------------------------------------------------ PV placement
|
||
var PV_ROWS = [1, 3, 5, 7, 10, 12, 14, 16, 19, 21];
|
||
function pvFields(cfg, al) {
|
||
var f = [];
|
||
// the parking canopies carry PV first (construction RFQ: canopy PV), the factory roof takes the rest
|
||
f.push({ key: "cars", x0: 18.35, cols: 27, y0: 6, rows: [0.15, 2.45, 4.75], z: function (y) { return 2.9 + (y - 6) / 7 * 1.2 + 0.08; } });
|
||
f.push({ key: "scoot", x0: 20.35, cols: 13, y0: 26, rows: [0.4, 2.85], z: function (y) { return 2.45 + (y - 26) / 5.5 * 1.05 + 0.08; } });
|
||
f.push({ key: "roofW", x0: X(1), cols: 103, y0: FY, rows: PV_ROWS, z: function (y) { return roofZ(y - FY) + 0.12; } });
|
||
f.push({ key: "roofE", x0: X(107.25), cols: 19, y0: FY, rows: PV_ROWS, z: function (y) { return roofZ(y - FY) + 0.12; } });
|
||
if (al.p2show) f.push({ key: "p2", x0: P2.x0 + 1, cols: 40, y0: P2.y0, rows: [1, 3, 5, 7, 10, 12, 14, 16, 19, 21, 23],
|
||
z: function (y) { return FFL + P2.eave + (P2.high - P2.eave) * (y - P2.y0) / (P2.y1 - P2.y0) + 0.12; } });
|
||
return f;
|
||
}
|
||
function pvPlace(cfg, al) {
|
||
var want = cfg.solar === "none" ? 0 : Math.round(cfg.pvKwp / 0.45), left = want, out = {}, fields = pvFields(cfg, al), i, k, r, c;
|
||
for (i = 0; i < fields.length; i++) {
|
||
var F = fields[i], list = [];
|
||
for (r = 0; r < F.rows.length && left > 0; r++) {
|
||
for (c = 0; c < F.cols && left > 0; c++) { list.push([F.x0 + c, F.y0 + F.rows[r]]); left--; }
|
||
}
|
||
out[F.key] = { F: F, panels: list };
|
||
}
|
||
k = 0; for (i in out) k += out[i].panels.length;
|
||
return { want: want, placed: k, left: left, by: out };
|
||
}
|
||
function pvPath(P, F, panels) {
|
||
var d = "", i, x, y;
|
||
for (i = 0; i < panels.length; i++) {
|
||
x = panels[i][0]; y = panels[i][1];
|
||
d += P.d([[x + 0.03, y + 0.03, F.z(y + 0.03)], [x + 0.97, y + 0.03, F.z(y + 0.03)], [x + 0.97, y + 1.97, F.z(y + 1.97)], [x + 0.03, y + 1.97, F.z(y + 1.97)]]);
|
||
}
|
||
return d;
|
||
}
|
||
function pvDraw(s, F, panels) {
|
||
if (!panels.length) return;
|
||
var P = s.P, d = pvPath(P, F, panels), g = "", i, idx, x, y;
|
||
s.raw('<path class="iso-pv"' + did("pv") + ' d="' + d + '"/>');
|
||
for (i = 0; i < Math.min(8, Math.ceil(panels.length / 40)); i++) {
|
||
idx = Math.floor(hash(i * 7 + panels.length) * panels.length);
|
||
x = panels[idx][0]; y = panels[idx][1];
|
||
g += '<path class="iso-a iso-glint" style="animation-delay:-' + (i * 1.7 + hash(i) * 3).toFixed(1) + 's" d="' +
|
||
P.d([[x + 0.1, y + 0.2, F.z(y) + 0.02], [x + 0.9, y + 0.2, F.z(y) + 0.02], [x + 0.9, y + 1.8, F.z(y + 1.8) + 0.02], [x + 0.1, y + 1.8, F.z(y + 1.8) + 0.02]]) + '"/>';
|
||
}
|
||
s.raw(g);
|
||
}
|
||
|
||
// ------------------------------------------------------------ machines
|
||
function pressM(sc, id, cx, cy, z, rot45, running) {
|
||
var s = sc.sprite(id);
|
||
function foot(r) { return rot45 ? [[cx, cy - r], [cx + r, cy], [cx, cy + r], [cx - r, cy]] : rect(cx - r, cy - r, cx + r, cy + r); }
|
||
var k = rot45 ? SQ2 : 1;
|
||
s.prism(foot(0.475 * k), z, z + 0.95, "iso-press");
|
||
s.prism(foot(0.33 * k), z + 0.95, z + 2.05, "iso-press2");
|
||
s.prism(foot(0.43 * k), z + 2.05, z + 2.3, "iso-steel");
|
||
var P = sc.P, best = null, f = foot(0.4 * k);
|
||
f.forEach(function (p) { if (!best || P.depth(p[0], p[1]) > P.depth(best[0], best[1])) best = p; });
|
||
s.led(best[0], best[1], z + 0.97, "iso-ledr");
|
||
if (running) s.puffs(cx, cy, z + 2.4, "iso-puff", 2, 3.6);
|
||
return s;
|
||
}
|
||
function extruderM(sc, id, x0, y0, x1, y1, z, h, mat, hr) {
|
||
var s = sc.sprite(id);
|
||
s.box(x0, y0, x1, y1, z, z + h, mat);
|
||
var cx = (x0 + x1) / 2, P = sc.P;
|
||
var hy = P.depth(0, 1) > 0 ? y1 - hr - 0.08 : y0 + hr + 0.08; // hopper at the far end from the viewer
|
||
s.cyl(cx, hy, hr, z + h, z + h + 0.5, "iso-hopper");
|
||
s.led(cx, (y0 + y1) / 2, z + h + 0.02, "iso-ledo");
|
||
return s;
|
||
}
|
||
function duplexCell(sc, n, cx, cy, z, running) {
|
||
var id = "duplex-" + n;
|
||
pressM(sc, id, cx - 1.75, cy - 0.1, z, true, running);
|
||
pressM(sc, id, cx + 1.75, cy - 0.1, z, true, running);
|
||
extruderM(sc, id, cx - 0.62, cy + 0.55, cx - 0.14, cy + 1.85, z, 1.25, "iso-ex50", 0.22);
|
||
extruderM(sc, id, cx + 0.1, cy + 0.75, cx + 0.42, cy + 1.7, z, 1.05, "iso-ex35", 0.16);
|
||
var t = sc.sprite(id);
|
||
t.box(cx + 0.75, cy - 1.65, cx + 1.35, cy - 1.05, z, z + 1.0, "iso-steel");
|
||
t.disc(cx + 1.05, cy - 1.35, z + 1.02, 0.22, "iso-vinylc");
|
||
sc.sprite(id).person(cx, cy - 0.35, z);
|
||
sc.inf(id, "Duplex system " + n + ": 2 HRP 12 presses, EX 50 + EX 35",
|
||
"One operator's cell (~5 x 3 m): presses turned 45 degrees to the operator, EX 50 main and EX 35 colour extruder behind him, trimmer front-left. HRP 12 ~150 t; steam 150 to 180 kg/h and cooling up to 125 kW per press (Newbilt).");
|
||
}
|
||
function classicM(sc, n, cx, cy, z, running) {
|
||
var id = "classic-" + n, s = sc.sprite(id), P = sc.P;
|
||
s.box(cx - 1.1, cy - 0.65, cx + 1.1, cy + 0.65, z, z + 1.45, "iso-classic");
|
||
s.seq([
|
||
[cx - 0.55, cy, function (q) { q.box(cx - 1.0, cy - 0.5, cx - 0.1, cy + 0.5, z + 1.45, z + 2.55, "iso-press2", { top: "iso-steel" }); }],
|
||
[cx + 0.6, cy + 0.25, function (q) { q.cyl(cx + 0.6, cy + 0.25, 0.22, z + 1.45, z + 2.0, "iso-hopper"); }],
|
||
[cx + 0.6, cy - 0.3, function (q) { q.disc(cx + 0.6, cy - 0.3, z + 1.47, 0.24, "iso-vinylc"); }]
|
||
]);
|
||
var p = P.depth(1, 0) > 0 ? cx + 1.0 : cx - 1.0;
|
||
s.led(p, cy, z + 1.47, "iso-ledg");
|
||
if (running) s.puffs(cx - 0.55, cy, z + 2.6, "iso-puff", 2, 3.9);
|
||
sc.inf(id, "Classic " + n + ": Newbilt Classic, fully automatic",
|
||
"Built-in extruder, automatic cake loading, label feed and spindle stacking; 28 s cycle at 180 g, ~128 records/h (vendor flyer).");
|
||
}
|
||
|
||
// ------------------------------------------------------------ the site
|
||
function siteGround(sc, cfg, al) {
|
||
var P = sc.P, g = [];
|
||
g.push(prismS(P, rect(-8, -24, 158, 126), -3, 0, "iso-dirt", { top: "iso-g-grass" }));
|
||
var d = "", x, y, a, b;
|
||
for (x = 0; x <= 150; x += 10) { a = P.s(x, -24, 0); b = P.s(x, 126, 0); d += "M" + r1(a[0]) + " " + r1(a[1]) + "L" + r1(b[0]) + " " + r1(b[1]); }
|
||
for (y = -20; y <= 120; y += 10) { a = P.s(-8, y, 0); b = P.s(158, y, 0); d += "M" + r1(a[0]) + " " + r1(a[1]) + "L" + r1(b[0]) + " " + r1(b[1]); }
|
||
g.push('<path class="iso-grid" d="' + d + '"/>');
|
||
// public road (assumed on the south side) and the plot paving
|
||
g.push(flatS(P, rect(-8, -16, 158, -6), 0, "iso-g-asph iso-t", "road"));
|
||
g.push(linesS(P, [[-8, -11, 158, -11]], 0, "iso-dash"));
|
||
g.push(linesS(P, [[-8, -6.3, 158, -6.3], [-8, -15.7, 158, -15.7]], 0, "iso-kerb"));
|
||
g.push(flatS(P, rect(118, -6, 130, 37), 0, "iso-g-asph iso-t", "apron"));
|
||
g.push(flatS(P, rect(100, 37, 148, 69), 0, "iso-g-conc iso-t", "apron"));
|
||
g.push(flatS(P, rect(136, 69, 148, 100), 0, "iso-g-asph iso-t", "apron"));
|
||
g.push(flatS(P, rect(4, 94, 148, 100), 0, "iso-g-asph iso-t", "lane"));
|
||
g.push(flatS(P, rect(54, 6, 118, 13), 0, "iso-g-asph iso-t", "road"));
|
||
g.push(flatS(P, rect(12, 3, 54, 36), 0, "iso-g-asph iso-t", "parking"));
|
||
g.push(flatS(P, rect(28, 36, 30.5, 42), 0, "iso-g-path iso-t", "office"));
|
||
g.push(flatS(P, rect(15.5, 57, 18.5, 69), 0, "iso-g-path iso-t", "link"));
|
||
g.push(flatS(P, rect(19, 100, 136, 114), 0, "iso-g-conc iso-t", "yard"));
|
||
g.push(flatS(P, rect(91.6, 100.6, 104.9, 112.2), 0.01, "iso-g-pad iso-t", "chillers"));
|
||
if (al.p2show) g.push(flatS(P, rect(72, 57, 76, 69), 0, "iso-g-path iso-t", "phase2"));
|
||
else g.push(flatS(P, rect(P2.x0, P2.y0, P2.x1, P2.y1), 0.01, "iso-reserve", "phase2"));
|
||
// markings: gate road centre line, parking bays, dock stripes, scooter bays
|
||
g.push(linesS(P, [[124, -6, 124, 37]], 0.01, "iso-dash"));
|
||
var segs = [], k;
|
||
for (k = 0; k <= 10; k++) segs.push([18 + 2.77 * k, 6, 18 + 2.77 * k, 11.8]);
|
||
for (k = 0; k <= 10; k++) segs.push([20 + 1.37 * k, 26.2, 20 + 1.37 * k, 28.6]);
|
||
for (k = 0; k <= 8; k++) segs.push([14 + 4.5 * k, 20, 14 + 4.5 * k, 22.5]);
|
||
g.push(linesS(P, segs, 0.01, "iso-bayline"));
|
||
g.push(flatS(P, rect(119.25, 65.5, 122.75, 69), 0.01, "iso-hazard"));
|
||
g.push(flatS(P, rect(125.25, 65.5, 128.75, 69), 0.01, "iso-hazard"));
|
||
// buried conveying line from the silos to the colour store
|
||
g.push(linesS(P, [[43.5, 101.5, 43.5, 94]], 0.02, "iso-buried"));
|
||
// lamp light pools (visible at dusk)
|
||
LAMPS.forEach(function (p) {
|
||
var s = P.s(p[0], p[1], 0);
|
||
g.push('<ellipse class="iso-pool" cx="' + r1(s[0]) + '" cy="' + r1(s[1]) + '" rx="34" ry="17" fill="url(#iso-pool-' + sc.uid + ')"/>');
|
||
});
|
||
g.forEach(function (x) { sc.g(x); });
|
||
sc.inf("road", "Public road", "Road side assumed south of the plot, as in the Payap scenario model");
|
||
sc.inf("apron", "Truck apron and dock face", "Gate road to the two dock doors of dispatch; the apron joins the service road round the east end");
|
||
sc.inf("lane", "Service lane", "6 m lane behind the factory: utility yard, silos, water treatment and the ramp at the machine door");
|
||
sc.inf("yard", "Utility yard (detached)", "Rooms built once for four boiler and four chiller modules; modules arrive one press stage at a time (scale.md)");
|
||
sc.inf("parking", "Car park", "Roofed staff parking: 10 car bays under a 27.7 x 7.0 m canopy, ~30 scooter spaces under 13.7 x 5.5 m (construction RFQ)");
|
||
}
|
||
function fence(sc, x0, y0, x1, y1) {
|
||
var s = sc.sprite("site");
|
||
if (y0 === y1) s.box(x0, y0 - 0.08, x1, y0 + 0.08, 0, 1.8, "iso-fence");
|
||
else s.box(x0 - 0.08, y0, x0 + 0.08, y1, 0, 1.8, "iso-fence");
|
||
}
|
||
function tree(sc, x, y, r) {
|
||
var s = sc.sprite("trees"), P = sc.P, h = 1.4 + r * 0.6, c = P.s(x, y, h + r * 0.9), b = P.s(x, y, 0), t = P.s(x, y, h);
|
||
s.extBox(x - r, y - r, x + r, y + r, 0, h + r * 1.8);
|
||
s.raw('<path class="iso-trunk" d="M' + r1(b[0] - 0.7) + " " + r1(b[1]) + "L" + r1(t[0] - 0.7) + " " + r1(t[1]) + "L" + r1(t[0] + 0.7) + " " + r1(t[1]) + "L" + r1(b[0] + 0.7) + " " + r1(b[1]) + 'Z"/>');
|
||
var R = r * C * 1.18;
|
||
s.raw('<circle class="iso-leaf iso-l" cx="' + r1(c[0]) + '" cy="' + r1(c[1]) + '" r="' + r1(R) + '"/>');
|
||
s.raw('<circle class="iso-leaf iso-t" cx="' + r1(c[0] - R * 0.28) + '" cy="' + r1(c[1] - R * 0.3) + '" r="' + r1(R * 0.58) + '"/>');
|
||
}
|
||
function lamp(sc, x, y) {
|
||
var s = sc.sprite("site"), P = sc.P;
|
||
s.box(x - 0.08, y - 0.08, x + 0.08, y + 0.08, 0, 6, "iso-lampost", { noTop: true });
|
||
var p = P.s(x, y, 6.1);
|
||
s.raw('<circle class="iso-lamp" cx="' + r1(p[0]) + '" cy="' + r1(p[1]) + '" r="1.3"/>');
|
||
}
|
||
function sitePieces(sc, cfg, al) {
|
||
// plot fence with the gate opening
|
||
fence(sc, 0, 0, 117.2, 0); fence(sc, 130.8, 0, 150, 0); fence(sc, 150, 0, 150, 120);
|
||
fence(sc, 0, 120, 150, 120); fence(sc, 0, 0, 0, 120);
|
||
// gate piers, sliding leaves (open), boom barrier
|
||
sc.sprite("gate").box(117.2, 0, 118, 0.8, 0, 2.6, "iso-conc");
|
||
sc.sprite("gate").box(130, 0, 130.8, 0.8, 0, 2.6, "iso-conc");
|
||
var lv = sc.sprite("gate"); lv.box(107, 0.15, 117.1, 0.4, 0.1, 2.0, "iso-gate");
|
||
lv.open([107, 0.15], [117.1, 0.15], [[0.3, 9.8, 0.4, 0.6], [0.3, 9.8, 1.4, 1.6]], "iso-bars");
|
||
var bm = sc.sprite("gate"); bm.box(116.6, 4.2, 117.2, 4.8, 0, 1.1, "iso-steel"); bm.box(116.75, 4.35, 117.05, 4.65, 1.1, 6.6, "iso-boom");
|
||
sc.inf("gate", "Main gate", "Piers and two 5 m sliding leaves, 10 m clear; boom barrier just inside that cars and trucks both pass");
|
||
// guard house
|
||
var gh = sc.sprite("guardhouse"), S = sides(111, 1, 115, 4);
|
||
gh.box(111, 1, 115, 4, 0, 0.2, "iso-plinth").box(111, 1, 115, 4, 0.2, 3.0, "iso-office", { top: "iso-roof3" });
|
||
gh.open(S.S[0], S.S[1], [[0.6, 2.2, 1.1, 2.2]], "iso-win iso-lit").open(S.E[0], S.E[1], [[0.8, 1.8, 0.2, 2.3]], "iso-door")
|
||
.open(S.W[0], S.W[1], [[0.8, 2.2, 1.1, 2.2]], "iso-win iso-lit").open(S.N[0], S.N[1], [[1, 2.6, 1.1, 2.2]], "iso-win");
|
||
gh.box(112.2, 2.0, 113.2, 2.8, 3.0, 3.5, "iso-ac");
|
||
sc.inf("guardhouse", "Guard house, 4 x 3 m", "WC and air conditioning, at the gate's north-west side (construction RFQ)");
|
||
// fire water tank and pump house
|
||
var ft = sc.sprite("firewater"); ft.cyl(84, 21, 4.5, 0, 4.0, "iso-firetank");
|
||
ft.disc(84, 21, 4.02, 3.6, "iso-tankring");
|
||
var ph = sc.sprite("firewater"), S2 = sides(76, 18.5, 79.5, 21.5);
|
||
ph.box(76, 18.5, 79.5, 21.5, 0, 3.0, "iso-office", { top: "iso-roof3" });
|
||
ph.open(S2.S[0], S2.S[1], [[1, 2.2, 0, 2.2]], "iso-door").open(S2.E[0], S2.E[1], [[0.6, 2.4, 1.6, 2.4]], "iso-louvre");
|
||
sc.inf("firewater", "Fire water tank and pump house", "9 m x 4 m tank, ~200 m3 usable (planning figure); pumps: duty electric, standby diesel, jockey (facility-security.md)");
|
||
// lamps
|
||
LAMPS.forEach(function (p) { lamp(sc, p[0], p[1]); });
|
||
// trees
|
||
var T = [], x, y;
|
||
for (y = 8; y <= 112; y += 13) T.push([2.6, y, 1.6 + hash(y) * 0.6]);
|
||
for (x = 8; x <= 146; x += 12) T.push([x, 117.6, 1.5 + hash(x) * 0.5]);
|
||
for (y = 4; y <= 120; y += 14) T.push([154, y, 1.7 + hash(y + 3) * 0.6]);
|
||
for (x = 6; x <= 104; x += 14) T.push([x, -3, 1.4 + hash(x + 1) * 0.4]);
|
||
[136, 146, 155].forEach(function (x) { T.push([x, -3, 1.5]); });
|
||
T.push([10.5, 39, 1.5], [49.5, 40, 1.5], [8, 60, 1.8], [8, 48, 1.6], [50, 64, 1.5], [104, 20, 1.8], [110, 30, 1.6], [62, 24, 1.7], [70, 26, 1.4], [96, 15, 1.5]);
|
||
if (!al.p2show) T.push([51, 50, 1.6], [99, 40, 1.8], [99, 52, 1.6]);
|
||
T.forEach(function (t) { tree(sc, t[0], t[1], t[2]); });
|
||
sc.inf("trees", "Trees", "Schematic planting on the plot boundary");
|
||
sc.inf("site", "Site plot (schematic)", "Payap scenario layout on a placeholder plot; road assumed south (site-candidates.md)");
|
||
}
|
||
function parking(sc, cfg, pv) {
|
||
// cars and scooters are drawn inside their canopy's sprite, before the posts and the roof,
|
||
// so the roof always covers them (as separate sprites the depth sort put some above the roof)
|
||
var cols = ["iso-car1", "iso-car2", "iso-car3", "iso-car4"];
|
||
var cp = sc.sprite("canopy-cars");
|
||
[0, 1, 3, 4, 6, 8, 9].forEach(function (b, i) {
|
||
var x = 18 + 2.77 * b + 0.5;
|
||
cp.box(x, 6.6, x + 1.75, 11.2, 0.25, 1.05, cols[i % 4]).box(x + 0.15, 7.8, x + 1.6, 10.2, 1.05, 1.55, "iso-glassd");
|
||
});
|
||
[18.1, 31.85, 45.6].forEach(function (x) { cp.box(x - 0.13, 9.37, x + 0.13, 9.63, 0, 3.4, "iso-steel", { noTop: true }); });
|
||
cp.prism(rect(18, 6, 45.7, 13), [2.75, 2.75, 3.95, 3.95], [2.9, 2.9, 4.1, 4.1], "iso-canopy");
|
||
pvDraw(cp, pv.by.cars.F, pv.by.cars.panels);
|
||
var sp = sc.sprite("canopy-scooters");
|
||
for (var k = 0; k < 7; k++) sp.box(20.4 + 1.37 * k * 1.4, 26.9, 20.8 + 1.37 * k * 1.4, 28.1, 0.2, 1.0, cols[(k + 1) % 4]);
|
||
[20.1, 33.6].forEach(function (x) { sp.box(x - 0.12, 28.6, x + 0.12, 28.85, 0, 2.9, "iso-steel", { noTop: true }); });
|
||
sp.prism(rect(20, 26, 33.7, 31.5), [2.3, 2.3, 3.35, 3.35], [2.45, 2.45, 3.5, 3.5], "iso-canopy");
|
||
pvDraw(sp, pv.by.scoot.F, pv.by.scoot.panels);
|
||
var nc = pv.by.cars.panels.length, ns = pv.by.scoot.panels.length;
|
||
sc.inf("canopy-cars", "Car canopy, 10 bays", "27.7 x 7.0 m at 4.1 m (construction RFQ)" + (nc ? "; " + nc + " PV modules (~" + fmt(nc * 0.45) + " kWp)" : ""));
|
||
sc.inf("canopy-scooters", "Scooter canopy, ~30 spaces", "13.7 x 5.5 m at 3.5 m (construction RFQ)" + (ns ? "; " + ns + " PV modules (~" + fmt(ns * 0.45) + " kWp)" : ""));
|
||
}
|
||
|
||
// ------------------------------------------------------------ factory
|
||
function zoneList(cfg) {
|
||
return [
|
||
[0, 0, 8, 10, "iso-z-staff", "staffentry"],
|
||
[0, 10, 16, 25, "iso-z-shop", "workshop"],
|
||
[8, 2.6, 16, 10, "iso-z-shop2", "workshop"],
|
||
[16, 2.6, 23, 20.5, "iso-z-galv", "galvanics"],
|
||
[23, 2.6, 30, 11.5, "iso-z-lab", "pvclab"],
|
||
[23, 11.5, 30, 20.5, "iso-z-prep", "stamperprep"],
|
||
[30, 2.6, 40, 20.5, "iso-z-colour", "colourstore"],
|
||
[40, 2.6, 92, 20.5, "iso-z-press", "presshall"],
|
||
[40, 12.4, 47.8, 20.5, "iso-z-plant", "plantroom"],
|
||
[16, 20.5, 92, 25, "iso-z-aisle", "aisle"],
|
||
[92, 2.6, 99, 10, "iso-z-trim", "trim"],
|
||
[99, 2.6, 106, 10, "iso-z-qc", "qc"],
|
||
[92, 10, 106, 25, "iso-z-pack", "packaging"],
|
||
[106, 0, 120, 25, "iso-z-wh", "warehouse"],
|
||
[120, 0, 128, 25, "iso-z-disp", "dispatch"],
|
||
[8, 0, 106, 2.6, cfg.corridor ? "iso-z-corr" : "iso-z-floor", "corridor"]
|
||
];
|
||
}
|
||
function factoryInfo(sc, cfg, al) {
|
||
sc.inf("factory", "Factory, 128 x 25 m",
|
||
"Production floor only (Payap scenario model): workshop, galvanics, stamper prep, PVC lab, colour store, press hall, finishing block; warehouse and dispatch behind the fire wall. Monopitch roof, low eave 7.0 m, high side 11.4 m.");
|
||
sc.inf("presshall", "Press hall, ~870 m2",
|
||
al.Dh + " Duplex system" + (al.Dh === 1 ? "" : "s") + " and " + al.Ch + " Classic" + (al.Ch === 1 ? "" : "s") + " in the hall; laid out for 2 Duplex bays + 11 Classic positions (Payap scenario model)" +
|
||
(al.p2need ? "; " + (al.ovD + al.ovC) + " more in the phase 2 building" : ""));
|
||
sc.inf("galvanics", cfg.galvanics ? "Galvanics clean room (Pheenix Alpha line)" : "Galvanics room: stampers bought in",
|
||
cfg.galvanics ? "PASU, PPPU, PDPU, PMPB; one PDPU covers full-capacity demand, a second is redundancy (galvanics.md). Room-in-room under the crane, open to stamper prep." :
|
||
"No electroforming on site: stampers are bought in, and the water-treatment building is not built.");
|
||
sc.inf("stamperprep", cfg.galvanics ? "Stamper prep: Sibert VSF, VSP, VSC" : "Stamper prep: stampers bought in",
|
||
cfg.galvanics ? "Line-up 4958 x 833 mm, operation order VSF back-finish, VSP punch, VSC coin (galvanics.md); stamper metrology bench." : "Bought stampers arrive press-ready; the room holds the stamper rack.");
|
||
sc.inf("pvclab", "PVC lab, regrind and conveying", "Compound lot checks before release, granulator and vacuum conveying (quality-automation.md tiers)");
|
||
sc.inf("colourstore", "Colour store, ~180 m2", "About 100 pallet positions on four levels for tens of colours (estimate); weighing, mixing and vacuum conveying");
|
||
sc.inf("plantroom", "Central hydraulic and air plant room, ~62 m2", "Pump sets on one manifold with the accumulators, one compressed-air plant; ring mains with a valved tee at every press position (pressing-line.md)");
|
||
sc.inf("aisle", "Crane and forklift aisle, 4.5 m", "Along the back wall from the machine door to the fire wall: heavy moves need a floor-level path");
|
||
sc.inf("trim", "Trimming and 100 % optical inspection", "Edge trimmer and the optical station at the end of the record path (quality-automation.md)");
|
||
sc.inf("qc", "QC and listening", "Sampled audio comparison (about 1 in 50) and the listening point on the tour (quality-automation.md)");
|
||
sc.inf("packaging", "Finishing and packaging", "Packing tables, shrink wrap and pallet wrapper; the finishing and print block is 14 x 25 m on two levels with the print room reserved upstairs");
|
||
sc.inf("warehouse", "Warehouse behind the fire wall", "Three racking rows; fire wall EI 60/120; forklift territory, no crane");
|
||
sc.inf("dispatch", "Dispatch, two dock doors", "Pallets staged for the trucks on the apron");
|
||
sc.inf("workshop", "Workshop and machine bay, ~200 m2", "Service bay, benches, spares, 3D printers, a lathe and a small CNC mill; machine door 4.5 x 5.0 m with the ramp; the crane parks here (crane-workshop-tours.md)");
|
||
sc.inf("staffentry", "Staff entry (PPE, WC)", "Staff change in the office building and enter here");
|
||
sc.inf("corridor", cfg.corridor ? "Visitor corridor, glazed, ~98 m" : "Visitor corridor: later",
|
||
cfg.corridor ? "Along the car-park wall in process order: cutting, galvanics, stamper prep, material lab, press hall, trimming and inspection, QC and listening, finishing. No PPE, filtered air." :
|
||
"The strip along the car-park wall stays free; the glazed corridor is built later.");
|
||
sc.inf("crane", cfg.crane === "buy" ? "Overhead crane, 5 t" : "Crane-ready frame (crane rented)",
|
||
cfg.crane === "buy" ? "EOT crane over the production compartment, runway to the end of the press hall, rail at ~5.6 m; parks over the workshop (crane-workshop-tours.md)" :
|
||
"Runway brackets and rails in the frame; a crane is rented for installations");
|
||
sc.inf("conveyor", "Records to finishing", "Schematic: pressed records travel from the presses to trimming and optical inspection");
|
||
sc.inf("pipebridge", "Pipe bridge", "Steam, condensate and cooling water from the yard to the service trench along the press rows");
|
||
}
|
||
function factoryRoof(sc, cfg, pv) {
|
||
var x0 = X(0), y0 = Y(0), x1 = X(FL), y1 = Y(FW), zS = roofZ(0), zN = roofZ(FW), P = sc.P;
|
||
var s = sc.sprite("factory");
|
||
s.box(x0, y0, x1, y1, 0, FFL, "iso-plinth");
|
||
s.prism(rect(x0, y0, x1, y1), FFL, [zS, zS, zN, zN], "iso-wall", { top: "iso-roof" });
|
||
var S = sides(x0, y0, x1, y1), win = [], lit = [], i, u;
|
||
for (u = 10, i = 0; u < 90; u += 4, i++) (hash(i) > 0.3 ? lit : win).push([u, u + 2.6, FFL + 4.4, FFL + 5.6]);
|
||
[93, 96.5, 100, 103].forEach(function (u, k) { (k % 2 ? win : lit).push([u, u + 2.4, FFL + 4.4, FFL + 5.6]); });
|
||
lit.push([5.8, 7.4, FFL + 1, FFL + 2.2], [123, 126, FFL + 1, FFL + 2.2]);
|
||
s.open(S.S[0], S.S[1], win, "iso-win").open(S.S[0], S.S[1], lit, "iso-win iso-lit");
|
||
if (cfg.corridor) s.open(S.S[0], S.S[1], [[8.3, 105.7, FFL + 0.4, FFL + 2.7]], "iso-glass", "corridor");
|
||
else {
|
||
var cw = []; for (u = 10; u < 104; u += 6) cw.push([u, u + 2, FFL + 1, FFL + 2.2]);
|
||
s.open(S.S[0], S.S[1], cw, "iso-win");
|
||
}
|
||
s.open(S.S[0], S.S[1], [[3, 5, FFL, FFL + 2.5], [107, 108.2, FFL, FFL + 2.2]], "iso-door");
|
||
s.open(S.S[0], S.S[1], [[111.25, 114.75, FFL, FFL + 4.0], [117.25, 120.75, FFL, FFL + 4.0]], "iso-dock", "dispatch");
|
||
s.open(S.E[0], S.E[1], [[3, 6, FFL + 1, FFL + 2.3], [8, 11, FFL + 1, FFL + 2.3]], "iso-win iso-lit")
|
||
.open(S.E[0], S.E[1], [[14, 16, FFL, FFL + 2.5]], "iso-door")
|
||
.open(S.E[0], S.E[1], [[4, 7, FFL + 5, FFL + 6], [10, 13, FFL + 5.3, FFL + 6.3], [17, 20, FFL + 5.6, FFL + 6.6]], "iso-win");
|
||
var nw = [], nl = [];
|
||
for (u = 36, i = 0; u < 118; u += 4, i++) (hash(i + 40) > 0.4 ? nl : nw).push([u, u + 2.6, FFL + 5.6, FFL + 6.8]);
|
||
s.open(S.N[0], S.N[1], nw, "iso-win").open(S.N[0], S.N[1], nl, "iso-win iso-lit")
|
||
.open(S.N[0], S.N[1], [[121.5, 126, FFL, FFL + 5.0]], "iso-door2", "workshop")
|
||
.open(S.N[0], S.N[1], [[108, 110, FFL, FFL + 2.6]], "iso-door", "galvanics");
|
||
s.open(S.W[0], S.W[1], [[3, 6, FFL + 1.2, FFL + 2.6], [8, 11, FFL + 1.2, FFL + 2.6], [16, 19, FFL + 1.2, FFL + 2.6]], "iso-win iso-lit")
|
||
.open(S.W[0], S.W[1], [[5, 8, FFL + 6, FFL + 7.2], [13, 16, FFL + 5.6, FFL + 6.8]], "iso-win");
|
||
// roof: PV fields either side of the fire wall parapet, vents
|
||
var fwX0 = X(105.8), fwX1 = X(106.2);
|
||
var parapet = function (q) {
|
||
q.prism(rect(fwX0, y0, fwX1, y1), [zS, zS, zN, zN], [zS + 0.5, zS + 0.5, zN + 0.5, zN + 0.5], "iso-firewall");
|
||
};
|
||
var W = pv.by.roofW, E = pv.by.roofE, seams = "", fxs;
|
||
for (fxs = 8; fxs < FL; fxs += 8) {
|
||
if (Math.abs(fxs - 106) < 1) continue;
|
||
seams += P.d([[X(fxs), y0, zS + 0.02], [X(fxs), y1, zN + 0.02]], true);
|
||
}
|
||
s.raw('<path class="iso-seam" d="' + seams + P.d([[x0, Y(FW / 2), roofZ(FW / 2) + 0.02], [x1, Y(FW / 2), roofZ(FW / 2) + 0.02]], true) + '"/>');
|
||
var vents = function (q) {
|
||
[48, 64, 80].forEach(function (fx) {
|
||
var zr = roofZ(18.5);
|
||
q.cyl(X(fx), Y(18.5), 0.45, zr - 0.2, zr + 1.0, "iso-steel");
|
||
q.disc(X(fx), Y(18.5), zr + 1.0, 0.3, "iso-ventc");
|
||
if (cfg.running) q.puffs(X(fx), Y(18.5), zr + 1.1, "iso-puff2", 3, 4.2);
|
||
});
|
||
};
|
||
s.seq([
|
||
[X(52), Y(12), function (q) { pvDraw(q, W.F, W.panels); vents(q); }],
|
||
[X(106), Y(12), parapet],
|
||
[X(117), Y(12), function (q) { pvDraw(q, E.F, E.panels); }]
|
||
]);
|
||
// pipe bridges and the covered crossing to water treatment are separate sprites
|
||
}
|
||
function factoryInside(sc, cfg, al, pv) {
|
||
var x0 = X(0), y0 = Y(0), x1 = X(FL), y1 = Y(FW), P = sc.P, z = FFL;
|
||
var fl = sc.sprite("factory");
|
||
fl.box(x0, y0, x1, y1, 0, FFL, "iso-plinth");
|
||
var zones = zoneList(cfg);
|
||
zones.forEach(function (q) { fl.flat(rect(X(q[0]), Y(q[1]), X(q[2]), Y(q[3])), z + 0.005, q[4] + " iso-t", q[5]); });
|
||
var segs = [];
|
||
zones.forEach(function (q) {
|
||
if (q[5] === "presshall" || q[5] === "aisle") return;
|
||
var a = X(q[0]), b = Y(q[1]), c = X(q[2]), d = Y(q[3]);
|
||
segs.push([a, b, c, b], [c, b, c, d], [c, d, a, d], [a, d, a, b]);
|
||
});
|
||
fl.lines(segs, z + 0.01, "iso-part");
|
||
fl.lines([[X(16), Y(20.5), X(92), Y(20.5)], [X(16), Y(24.6), X(92), Y(24.6)]], z + 0.01, "iso-mark");
|
||
// service trench along the press rows
|
||
fl.flat(rect(X(40), Y(11.6), X(92), Y(12.4)), z + 0.01, "iso-trench");
|
||
var gr = []; for (var gx = 40.5; gx < 92; gx += 1.2) gr.push([X(gx), Y(11.65), X(gx), Y(12.35)]);
|
||
fl.lines(gr, z + 0.012, "iso-grate");
|
||
// slot outlines: hall and the empty positions carry their own ids
|
||
var eD = 0, eC = 0;
|
||
al.hall.forEach(function (sl) {
|
||
var pts = rect(sl.x0 + 0.25, sl.y0 + 0.25, sl.x1 - 0.25, sl.y1 - 0.25);
|
||
if (sl.e) {
|
||
var id = sl.t === "D" ? "pos-d" + (++eD) : "pos-c" + (++eC);
|
||
fl.flat(pts, z + 0.015, "iso-slot iso-empty", id);
|
||
sc.inf(id, sl.t === "D" ? "Duplex bay (room reserved)" : "Classic position (reserved)",
|
||
sl.t === "D" ? "Growth option: room, utility modules and ring-main tees reserved, not in the investment (pressing-line.md)" :
|
||
"Floor kept for up to ten more Classics (option, not in the plan)");
|
||
} else fl.flat(pts, z + 0.015, "iso-slot", sl.t === "D" ? "duplex-" + sl.n : "classic-" + sl.n);
|
||
if (!sl.e && sl.t === "D") {
|
||
var cx = (sl.x0 + sl.x1) / 2, cy = (sl.y0 + sl.y1) / 2;
|
||
fl.flat(rect(cx - 2.6, cy - 2.0, cx + 2.6, cy + 2.15), z + 0.02, "iso-cell", "duplex-" + sl.n);
|
||
}
|
||
});
|
||
// walls cut: back walls taller than front walls (cutaway)
|
||
wallsCut(sc, "factory", x0, y0, x1, y1, z, 1.0, 3.0, 0.3, "iso-wall");
|
||
sc.sprite("factory").box(X(105.85), Y(0.3), X(106.15), Y(FW - 0.3), z, z + 2.2, "iso-firewall", { top: "iso-cut" });
|
||
if (cfg.corridor) sc.sprite("corridor").box(X(8), Y(2.55), X(106), Y(2.65), z, z + 2.6, "iso-glassw");
|
||
// frame columns and crane runway (production compartment, west wall to the end of the press hall)
|
||
[[0.3, 0.62], [24.38, 24.7]].forEach(function (yy) {
|
||
var s = sc.sprite("crane", P.vis(0, yy[0] < 12 ? -1 : 1) ? "iso-ghost" : "");
|
||
var cols = [];
|
||
for (var fx = 0.5; fx <= 92; fx += 8) cols.push(fx);
|
||
cols.push(91.6);
|
||
s.seq(cols.map(function (fx) { return [X(fx), Y(yy[0]), function (q) { q.box(X(fx) - 0.16, Y(yy[0]), X(fx) + 0.16, Y(yy[1]), z, z + 5.6, "iso-col", { noTop: true }); }]; }));
|
||
s.box(X(0.3), Y(yy[0]), X(92), Y(yy[1]), z + 5.6, z + 5.95, "iso-rail");
|
||
});
|
||
if (cfg.crane === "buy") craneM(sc, z, cfg);
|
||
else sc.tag(X(46), Y(24.5), z + 6.5, "Crane-ready (rented)", 5, "crane");
|
||
machinesFactory(sc, cfg, al, z);
|
||
// presses in the hall
|
||
al.hall.forEach(function (sl) {
|
||
if (sl.e) return;
|
||
var cx = (sl.x0 + sl.x1) / 2, cy = (sl.y0 + sl.y1) / 2;
|
||
if (sl.t === "D") { duplexCell(sc, sl.n, cx, cy, z, cfg.running); sc.tag(cx, cy, z + 3.2, "Duplex " + sl.n, 8, "duplex-" + sl.n); }
|
||
else { classicM(sc, sl.n, cx, cy, z, cfg.running); sc.tag(cx, cy, z + 3.2, "Classic " + sl.n, 6, "classic-" + sl.n); }
|
||
});
|
||
// record path to finishing (schematic conveyor along the corridor glass)
|
||
conveyor(sc, z, cfg);
|
||
if (cfg.corridor) visitors(sc, z, cfg);
|
||
}
|
||
function wallsCut(sc, id, x0, y0, x1, y1, z0, lo, hi, t, mat) {
|
||
var P = sc.P;
|
||
[["S", x0, y0, x1, y0 + t, 0, -1], ["N", x0, y1 - t, x1, y1, 0, 1], ["W", x0, y0 + t, x0 + t, y1 - t, -1, 0], ["E", x1 - t, y0 + t, x1, y1 - t, 1, 0]].forEach(function (w) {
|
||
var front = !!P.vis(w[5], w[6]);
|
||
sc.sprite(id).box(w[1], w[2], w[3], w[4], z0, z0 + (front ? lo : hi), mat, { top: "iso-cut" });
|
||
});
|
||
}
|
||
function craneM(sc, z, cfg) {
|
||
var P = sc.P, bx = X(6), travel = 56, v = P.dv(travel, 0, 0), st = "--dx:" + r1(v[0]) + "px;--dy:" + r1(v[1]) + "px";
|
||
var s = sc.sprite("crane");
|
||
s.extBox(bx - 0.9, Y(0.3), bx + travel + 0.9, Y(24.7), z + 5.95, z + 7.3);
|
||
s.ga("iso-a iso-crane", st);
|
||
s.seq([
|
||
[bx, Y(0.6), function (q) { q.box(bx - 0.9, Y(0.3), bx + 0.9, Y(0.95), z + 5.95, z + 6.45, "iso-crane"); }],
|
||
[bx, Y(24.4), function (q) { q.box(bx - 0.9, Y(24.05), bx + 0.9, Y(24.7), z + 5.95, z + 6.45, "iso-crane"); }]
|
||
]);
|
||
s.box(bx - 0.3, Y(0.5), bx + 0.3, Y(24.5), z + 6.0, z + 6.65, "iso-crane");
|
||
s.box(bx - 0.6, Y(11.5), bx + 0.6, Y(12.7), z + 6.65, z + 7.25, "iso-hoist");
|
||
s.ge();
|
||
var hk = sc.sprite("crane");
|
||
hk.extBox(bx - 0.25, Y(11.85), bx + travel + 0.25, Y(12.35), z + 5.0, z + 5.95);
|
||
hk.ga("iso-a iso-crane", st);
|
||
var a = P.s(bx, Y(12.1), z + 6.0), b = P.s(bx, Y(12.1), z + 5.4);
|
||
hk.raw('<path class="iso-rope" d="M' + r1(a[0]) + " " + r1(a[1]) + "L" + r1(b[0]) + " " + r1(b[1]) + '"/>');
|
||
hk.box(bx - 0.22, Y(11.88), bx + 0.22, Y(12.32), z + 5.0, z + 5.4, "iso-hook");
|
||
hk.ge();
|
||
sc.tag(X(30), Y(12), z + 7.6, "Crane 5 t", 6, "crane");
|
||
}
|
||
function conveyor(sc, z, cfg) {
|
||
var P = sc.P, s = sc.sprite("conveyor"), xa = X(40.6), xb = X(95), y0 = Y(2.9), y1 = Y(3.35);
|
||
s.box(xa, y0, xb, y1, z + 0.72, z + 0.85, "iso-belt");
|
||
if (!cfg.running) return;
|
||
var L = xb - xa - 1, v = P.dv(L, 0, 0), n = 8, dur = 16, i, p = P.s(xa + 0.5, (y0 + y1) / 2, z + 0.88), q = P.s(xa + 0.5, (y0 + y1) / 2, z + 0.88);
|
||
var rx = 0.26 * C * SQ2, lab = ["iso-lab1", "iso-lab2", "iso-lab3", "iso-lab4"];
|
||
for (i = 0; i < n; i++) {
|
||
s.raw('<g class="iso-a iso-disc" style="--dx:' + r1(v[0]) + "px;--dy:" + r1(v[1]) + "px;animation-delay:-" + (i * dur / n).toFixed(2) + 's">' +
|
||
'<ellipse class="iso-vinylc" cx="' + r1(p[0]) + '" cy="' + r1(p[1]) + '" rx="' + r1(rx) + '" ry="' + r1(rx / 2) + '"/>' +
|
||
'<ellipse class="' + lab[i % 4] + '" cx="' + r1(q[0]) + '" cy="' + r1(q[1]) + '" rx="' + r1(rx * 0.38) + '" ry="' + r1(rx * 0.19) + '"/></g>');
|
||
}
|
||
}
|
||
function visitors(sc, z, cfg) {
|
||
var P = sc.P, s = sc.sprite("corridor"), xa = X(12), travel = 86, v = P.dv(travel, 0, 0);
|
||
s.extBox(xa - 1, Y(0.75), xa + travel + 3, Y(2.4), z, z + 1.7);
|
||
s.ga("iso-a iso-walk", "--dx:" + r1(v[0]) + "px;--dy:" + r1(v[1]) + "px");
|
||
s.seq([[xa + 2.2, Y(1.2), function (q) { q.person(xa + 2.2, Y(1.2), z, "iso-guide"); }],
|
||
[xa, Y(1.0), function (q) { q.person(xa, Y(1.0), z, "iso-visitor"); }],
|
||
[xa + 0.9, Y(1.9), function (q) { q.person(xa + 0.9, Y(1.9), z, "iso-visitor2"); }],
|
||
[xa - 0.6, Y(1.7), function (q) { q.person(xa - 0.6, Y(1.7), z, "iso-visitor"); }]]);
|
||
s.ge();
|
||
sc.tag(X(20), Y(1.3), z + 2.4, "Visitor corridor", 6, "corridor");
|
||
}
|
||
function rackRow(sc, id, x0, y0, x1, y1, z, h, levels, bays, palette, mat) {
|
||
var s = sc.sprite(id), S = sides(x0, y0, x1, y1), groups = {};
|
||
s.box(x0, y0, x1, y1, z, z + h, mat || "iso-rackf");
|
||
["S", "E", "N", "W"].forEach(function (k) {
|
||
var A = S[k][0], B = S[k][1], L = Math.hypot(B[0] - A[0], B[1] - A[1]), nb = Math.max(1, Math.round(L / (L > 3 ? 1.35 : 1.1))), bw = L / nb, lv, b, cls;
|
||
for (lv = 0; lv < levels; lv++) for (b = 0; b < nb; b++) {
|
||
cls = palette[Math.floor(hash(lv * 31 + b * 7 + x0 + y0 + k.charCodeAt(0)) * palette.length)];
|
||
if (!cls) continue;
|
||
(groups[k + cls] = groups[k + cls] || { A: A, B: B, cls: cls, l: [] }).l.push([b * bw + 0.08, (b + 1) * bw - 0.08, z + 0.15 + lv * h / levels, z + (lv + 0.8) * h / levels]);
|
||
}
|
||
});
|
||
Object.keys(groups).forEach(function (k) { var g = groups[k]; s.open(g.A, g.B, g.l, g.cls); });
|
||
return s;
|
||
}
|
||
function machinesFactory(sc, cfg, al, z) {
|
||
var P = sc.P, i;
|
||
// staff entry: lockers
|
||
sc.sprite("staffentry").box(X(0.6), Y(2), X(1.1), Y(8.5), z, z + 1.9, "iso-locker");
|
||
// spares store (fx 8..16, fy 2.6..10): shelving
|
||
rackRow(sc, "workshop", X(9), Y(4.2), X(15.5), Y(4.8), z, 2.3, 4, 0, ["iso-pal-bx", "iso-pal-w", "", "iso-pal-bx"], "iso-shelf");
|
||
rackRow(sc, "workshop", X(9), Y(7.4), X(15.5), Y(8.0), z, 2.3, 4, 0, ["iso-pal-bx", "", "iso-pal-w", "iso-pal-bx"], "iso-shelf");
|
||
// workshop: service bay with a press on it, benches, 3D printers, lathe, CNC mill
|
||
var fl = sc.sprite("workshop");
|
||
fl.flat(rect(X(2), Y(14), X(9), Y(22)), z + 0.02, "iso-bay");
|
||
pressM(sc, "workshop", X(5.5), Y(18), z + 0.02, false, false);
|
||
sc.sprite("workshop").box(X(0.5), Y(11), X(1.3), Y(16), z, z + 0.9, "iso-bench");
|
||
sc.sprite("workshop").box(X(0.5), Y(17.5), X(1.3), Y(22.5), z, z + 0.9, "iso-bench");
|
||
sc.sprite("workshop").seq([0, 1, 2].map(function (k) { return [X(11 + k * 1.3), Y(11.5), function (q) { q.box(X(10.6 + k * 1.3), Y(11), X(11.5 + k * 1.3), Y(11.9), z, z + 1.1, "iso-printer"); }]; }));
|
||
sc.sprite("workshop").box(X(11), Y(16.8), X(13.6), Y(17.7), z, z + 1.2, "iso-steel");
|
||
sc.sprite("workshop").box(X(12.4), Y(21.2), X(14.3), Y(23.2), z, z + 2.0, "iso-mill");
|
||
sc.sprite("workshop").cyl(X(10.2), Y(22.3), 0.3, z, z + 1.7, "iso-steel");
|
||
sc.tag(X(6), Y(16), z + 3.4, "Workshop", 6, "workshop");
|
||
sc.tag(X(4), Y(5), z + 2.6, "Staff entry", 2, "staffentry");
|
||
// galvanics (fx 16..23)
|
||
if (cfg.galvanics) {
|
||
var gx = X(16.45);
|
||
var u = sc.sprite("pasu"); u.box(gx, Y(3.4), gx + 0.72, Y(5.59), z, z + 0.9, "iso-galv"); u.box(gx + 0.05, Y(4.7), gx + 0.67, Y(5.5), z + 0.9, z + 1.84, "iso-galv2");
|
||
u.disc(gx + 0.36, Y(3.95), z + 0.92, 0.25, "iso-sink");
|
||
var p1 = sc.sprite("pppu"); p1.box(gx, Y(6.4), gx + 1.25, Y(7.46), z, z + 1.1, "iso-galv", { top: "iso-bath" }); p1.box(gx, Y(7.1), gx + 0.3, Y(7.46), z + 1.1, z + 1.75, "iso-galv2");
|
||
var p2 = sc.sprite("pdpu"); p2.box(gx, Y(8.3), gx + 1.35, Y(9.37), z, z + 1.1, "iso-galv", { top: "iso-bath" });
|
||
p2.disc(gx + 0.45, Y(8.84), z + 1.12, 0.22, "iso-cathode"); p2.disc(gx + 0.95, Y(8.84), z + 1.12, 0.22, "iso-cathode");
|
||
p2.box(gx + 1.05, Y(8.3), gx + 1.35, Y(9.37), z + 1.1, z + 1.8, "iso-galv2"); p2.led(gx + 1.2, Y(8.84), z + 1.82, "iso-ledg");
|
||
var pm = sc.sprite("pmpb"); pm.box(gx, Y(10.3), gx + 0.78, Y(11.89), z, z + 0.92, "iso-galv"); pm.box(gx, Y(11.3), gx + 0.78, Y(11.89), z + 0.92, z + 1.7, "iso-galv2");
|
||
pm.disc(gx + 0.39, Y(10.8), z + 0.94, 0.25, "iso-sink");
|
||
sc.sprite("pdpu2").flat(rect(X(21), Y(8.3), X(22.35), Y(9.4)), z + 0.02, "iso-slot iso-empty", "pdpu2");
|
||
var cs = sc.sprite("galvanics"); cs.seq([0, 1, 2, 3].map(function (k) { return [X(17 + k * 0.9), Y(18.5), function (q) { q.cyl(X(17 + k * 0.9), Y(18.5), 0.32, z, z + 0.9, k % 2 ? "iso-drum2" : "iso-drum"); }]; }));
|
||
sc.sprite("galvanics").box(X(20.5), Y(17.4), X(22.6), Y(17.9), z, z + 1.8, "iso-shelf");
|
||
sc.inf("pasu", "Pheenix PASU, automatic silvering unit", "Cleaning station plus silver spray unit, 2188 x 715 x 1835 mm (Pheenix flyer)");
|
||
sc.inf("pppu", "Pheenix PPPU, pre-plating unit", "Up to four masters at once, 385 L tank, 1062 x 1245 x 1750 mm (Pheenix flyer)");
|
||
sc.inf("pdpu", "Pheenix PDPU, dual plating unit", "Two cells, up to 15 VDC and 200 A per cell, 1070 x 1350 x 1800 mm; one PDPU covers full-capacity demand (galvanics.md)");
|
||
sc.inf("pmpb", "Pheenix PMPB, modular preparation bench", "Passivation, cleaning and drying between generations, 1588 x 775 x 1700 mm (Pheenix flyer)");
|
||
sc.inf("pdpu2", "Second PDPU position", "Redundancy rather than capacity; not in the launch line (galvanics.md)");
|
||
sc.tag(X(17.2), Y(7), z + 2.6, "PASU PPPU PDPU PMPB", 5, "galvanics");
|
||
// stamper prep: Sibert line along y, metrology bench
|
||
var sx = X(28.8);
|
||
sc.sprite("vsf").box(sx, Y(13.0), sx + 0.83, Y(13.98), z, z + 1.5, "iso-sibert");
|
||
sc.sprite("vsp").box(sx, Y(14.48), sx + 0.83, Y(15.46), z, z + 1.6, "iso-sibert");
|
||
var vc = sc.sprite("vsc"); vc.box(sx, Y(15.96), sx + 0.83, Y(17.96), z, z + 1.6, "iso-sibert"); vc.led(sx + 0.42, Y(16.96), z + 1.62, "iso-ledg");
|
||
sc.inf("vsf", "Sibert VSF, stamper finisher", "Automatic wet back-finishing with stamper cleaning; 250 kg (Sibert spec)");
|
||
sc.inf("vsp", "Sibert VSP, stamper punch", "Optical centring on the groove, punches the centre hole; 200 kg (Sibert spec)");
|
||
sc.inf("vsc", "Sibert VSC, stamper coiner", "Forms ID and OD automatically; 1000 kg, ~2.0 x 0.8 x 1.6 m, a crane or forklift lift (Sibert spec)");
|
||
sc.tag(sx + 0.4, Y(15.5), z + 2.6, "Sibert VSF VSP VSC", 6, "stamperprep");
|
||
var mb = sc.sprite("metrology"); mb.box(X(23.4), Y(12.6), X(24.25), Y(17.6), z, z + 0.85, "iso-bench");
|
||
mb.box(X(23.55), Y(12.9), X(24.1), Y(13.6), z + 0.85, z + 1.3, "iso-instr");
|
||
var full = cfg.metrology === "full";
|
||
mb.seq([[X(23.8), Y(14.6), function (q) { if (full) { q.box(X(23.55), Y(14.2), X(24.1), Y(15.0), z + 0.85, z + 1.5, "iso-instr2"); } else q.flat(rect(X(23.55), Y(14.2), X(24.1), Y(15.0)), z + 0.86, "iso-later"); }],
|
||
[X(23.8), Y(16.3), function (q) { if (full) { q.box(X(23.55), Y(15.8), X(24.1), Y(16.9), z + 0.85, z + 1.35, "iso-instr"); } else q.flat(rect(X(23.55), Y(15.8), X(24.1), Y(16.9)), z + 0.86, "iso-later"); }]]);
|
||
sc.inf("metrology", full ? "Stamper metrology, full stack" : "Stamper metrology, launch tier",
|
||
full ? "Laser confocal (Keyence VK-X class), wide-area profiler (VR-6000 class) and XRF thickness gauge; inline vision and the audio station on the record side (quality-automation.md)" :
|
||
"XRF thickness gauge, inline vision and the audio station; laser confocal and wide-area profiler later, bought from revenue (total-investment.md)");
|
||
sc.tag(X(23.8), Y(15), z + 2.2, full ? "Metrology" : "Metrology (launch)", 4, "metrology");
|
||
} else {
|
||
var sg = sc.sprite("galvanics"); sg.box(X(19), Y(9.6), X(19.15), Y(11.4), z, z + 1.0, "iso-steel");
|
||
sg.box(X(18.95), Y(9.5), X(19.2), Y(11.5), z + 1.0, z + 2.0, "iso-sign");
|
||
sc.tag(X(19.1), Y(10.5), z + 2.6, "Stampers bought in", 100, "galvanics", "iso-signtag");
|
||
rackRow(sc, "stamperprep", X(28.8), Y(13), X(29.5), Y(15.5), z, 1.6, 3, 0, ["iso-pal-k", "", "iso-pal-k"], "iso-shelf");
|
||
}
|
||
// PVC lab: bench, fume cupboard, granulator, conveying station
|
||
sc.sprite("pvclab").box(X(23.3), Y(3.0), X(24.1), Y(10.5), z, z + 0.9, "iso-bench");
|
||
sc.sprite("pvclab").box(X(25.8), Y(10.4), X(27.4), Y(11.2), z, z + 2.2, "iso-instr");
|
||
var gn = sc.sprite("pvclab"); gn.box(X(27.8), Y(3.8), X(29.0), Y(5.0), z, z + 1.3, "iso-granul"); gn.frust(X(28.4), Y(4.4), 0.35, 0.55, z + 1.3, z + 1.8, "iso-granul", 8);
|
||
sc.sprite("pvclab").cyl(X(28.6), Y(8.0), 0.55, z, z + 2.4, "iso-hopper");
|
||
sc.tag(X(26), Y(7), z + 2.8, "PVC lab", 4, "pvclab");
|
||
// colour store racking
|
||
var pal = ["iso-pal-k", "iso-pal-w", "iso-pal-r", "iso-pal-y", "iso-pal-b", "iso-pal-g", "iso-pal-p", "iso-pal-o", "iso-pal-k", ""];
|
||
[[30.5, 31.6], [34.2, 35.9], [38.4, 39.5]].forEach(function (xx) { rackRow(sc, "colourstore", X(xx[0]), Y(4.2), X(xx[1]), Y(18.8), z, 4.4, 4, 0, pal, "iso-rackf"); });
|
||
sc.tag(X(35), Y(11), z + 5.4, "Colour store", 6, "colourstore");
|
||
// plant room: pump sets, accumulators, compressor, oil tank
|
||
var nps = Math.max(1, Math.min(4, al.Dh + (al.Ch > 0 ? 1 : 0)));
|
||
sc.sprite("plantroom").seq([0, 1, 2, 3].slice(0, nps).map(function (k) {
|
||
return [X(41.3 + k * 1.6), Y(13.4), function (q) { q.box(X(40.7 + k * 1.6), Y(13.0), X(41.9 + k * 1.6), Y(13.8), z, z + 1.0, "iso-pump"); q.cyl(X(41.3 + k * 1.6), Y(13.4), 0.22, z + 1.0, z + 1.35, "iso-motor"); }];
|
||
}));
|
||
sc.sprite("plantroom").seq([0, 1, 2].map(function (k) { return [X(41 + k * 0.75), Y(19.6), function (q) { q.cyl(X(41 + k * 0.75), Y(19.6), 0.26, z, z + 2.2, "iso-accu"); }]; }));
|
||
sc.sprite("plantroom").box(X(45.4), Y(17.3), X(47.3), Y(19.0), z, z + 1.5, "iso-comp");
|
||
sc.sprite("plantroom").box(X(41), Y(16.0), X(43.6), Y(17.2), z, z + 1.2, "iso-oil");
|
||
sc.tag(X(44), Y(16.5), z + 3, "Plant room", 4, "plantroom");
|
||
// finishing block: trimmer + optical station, QC, packaging, cassette, CD-R
|
||
var tr = sc.sprite("trim"); tr.box(X(95.3), Y(3.0), X(96.4), Y(4.1), z, z + 1.1, "iso-steel"); tr.disc(X(95.85), Y(3.55), z + 1.12, 0.26, "iso-vinylc");
|
||
var op = sc.sprite("trim"); op.box(X(96.9), Y(2.95), X(98.6), Y(4.3), z, z + 1.5, "iso-optic"); op.cyl(X(97.75), Y(3.6), 0.35, z + 1.5, z + 1.8, "iso-dome"); op.led(X(97.75), Y(3.6), z + 1.82, "iso-ledg");
|
||
sc.sprite("trim").box(X(96.9), Y(5.0), X(97.8), Y(5.8), z, z + 0.8, "iso-bin");
|
||
sc.tag(X(97), Y(4), z + 2.6, "Trim + optical", 4, "trim");
|
||
var qb = sc.sprite("qc"); qb.box(X(99.6), Y(7.6), X(105.6), Y(8.4), z, z + 0.85, "iso-bench");
|
||
qb.seq([[X(101), Y(8), function (q) { q.box(X(100.4), Y(7.7), X(101.6), Y(8.3), z + 0.85, z + 1.0, "iso-instr"); q.disc(X(101), Y(8), z + 1.02, 0.24, "iso-vinylc"); }],
|
||
[X(103.5), Y(8), function (q) { q.box(X(103.1), Y(7.75), X(103.9), Y(8.25), z + 0.85, z + 1.45, "iso-instr2"); }],
|
||
[X(105), Y(8), function (q) { q.box(X(104.7), Y(7.8), X(105.3), Y(8.2), z + 0.85, z + 1.6, "iso-speaker"); }]]);
|
||
sc.tag(X(102.5), Y(6), z + 2.4, "QC + listening", 4, "qc");
|
||
sc.sprite("packaging").box(X(93), Y(12), X(97), Y(13.2), z, z + 0.9, "iso-bench");
|
||
sc.sprite("packaging").box(X(93), Y(15), X(97), Y(16.2), z, z + 0.9, "iso-bench");
|
||
var sh = sc.sprite("packaging"); sh.box(X(99), Y(12), X(103.4), Y(13.1), z, z + 1.3, "iso-shrink"); sh.open([X(99), Y(12)], [X(103.4), Y(12)], [[1.4, 3.0, z + 0.3, z + 1.1]], "iso-open");
|
||
var pw = sc.sprite("packaging"); pw.cyl(X(103.4), Y(19), 1.0, z, z + 0.15, "iso-steel"); pw.box(X(103.2), Y(17.8), X(103.6), Y(18.1), z, z + 2.4, "iso-steel");
|
||
pw.box(X(102.85), Y(18.45), X(103.95), Y(19.55), z + 0.15, z + 1.4, "iso-carton");
|
||
[[94.5, 19.5], [97, 19.5], [94.5, 17.8]].forEach(function (p) { sc.sprite("packaging").box(X(p[0] - 0.6), Y(p[1] - 0.5), X(p[0] + 0.6), Y(p[1] + 0.5), z, z + 1.2, "iso-carton"); });
|
||
sc.tag(X(99), Y(16.5), z + 2.6, "Packaging", 5, "packaging");
|
||
if (cfg.cassette) {
|
||
var cb = sc.sprite("cassette"); cb.box(X(93), Y(22.2), X(96.6), Y(23.2), z, z + 0.85, "iso-bench");
|
||
cb.seq([0, 1, 2].map(function (k) { return [X(93.6 + k * 1.1), Y(22.7), function (q) { q.box(X(93.3 + k * 1.1), Y(22.4), X(94.0 + k * 1.1), Y(23.0), z + 0.85, z + 1.15, "iso-cass"); }]; }));
|
||
sc.inf("cassette", "Cassette duplication", "Bench line, ~40 to 80 m2 (project-timeline.md)");
|
||
sc.tag(X(94.8), Y(22.7), z + 2.2, "Cassette", 3, "cassette");
|
||
}
|
||
if (cfg.cdr) {
|
||
var cd = sc.sprite("cdr"); cd.box(X(98), Y(22.2), X(100.6), Y(23.2), z, z + 0.85, "iso-bench"); cd.box(X(98.4), Y(22.35), X(99.2), Y(23.05), z + 0.85, z + 1.6, "iso-cdtower"); cd.led(X(98.8), Y(22.7), z + 1.62, "iso-ledg");
|
||
sc.inf("cdr", "CD-R duplication, short run", "Tower duplicator and printer bench, ~20 to 40 m2 (project-timeline.md)");
|
||
sc.tag(X(99.3), Y(22.7), z + 2.2, "CD-R", 3, "cdr");
|
||
}
|
||
// warehouse: three racking rows; dispatch: staged pallets and a forklift
|
||
[5, 11.5, 18].forEach(function (fy) { rackRow(sc, "warehouse", X(107.2), Y(fy), X(119.2), Y(fy + 1.2), z, 5.6, 4, 0, ["iso-pal-bx", "iso-pal-bx", "iso-pal-w", "", "iso-pal-bx"], "iso-rackf"); });
|
||
sc.tag(X(113), Y(12), z + 6.4, "Warehouse", 6, "warehouse");
|
||
[[122, 4.5], [124, 4.5], [126, 4.5], [122, 7], [126, 7]].forEach(function (p) { sc.sprite("dispatch").box(X(p[0] - 0.6), Y(p[1] - 0.5), X(p[0] + 0.6), Y(p[1] + 0.5), z, z + 1.3, "iso-carton"); });
|
||
var fk = sc.sprite("dispatch"); fk.box(X(123.4), Y(12), X(124.6), Y(14.2), z, z + 1.3, "iso-forklift"); fk.box(X(123.5), Y(11.6), X(124.5), Y(11.8), z, z + 2.6, "iso-steel");
|
||
sc.tag(X(124), Y(10), z + 2.6, "Dispatch", 3, "dispatch");
|
||
sc.tag(X(66), Y(16), z + 3.8, "Press hall", 7, "presshall");
|
||
sc.tag(X(19.5), Y(14), z + 3.4, cfg.galvanics ? "Galvanics" : "Galvanics (empty)", 6, "galvanics");
|
||
sc.tag(X(99), Y(4), z + 3.4, "Finishing", 5, "packaging");
|
||
}
|
||
function pipeBridge(sc, x) {
|
||
var s = sc.sprite("pipebridge");
|
||
s.box(x - 0.15, 96.85, x + 0.15, 97.15, 0, 5.0, "iso-steel", { noTop: true });
|
||
s.box(x - 0.9, 94, x + 0.9, 101, 5.0, 5.3, "iso-steel");
|
||
s.seq([[x - 0.55, 97.5, function (q) { q.box(x - 0.7, 94, x - 0.4, 101, 5.3, 5.62, "iso-pipes"); }],
|
||
[x - 0.05, 97.5, function (q) { q.box(x - 0.2, 94, x + 0.1, 101, 5.3, 5.62, "iso-pipec"); }],
|
||
[x + 0.45, 97.5, function (q) { q.box(x + 0.3, 94, x + 0.6, 101, 5.3, 5.62, "iso-pipec2"); }]]);
|
||
}
|
||
|
||
// ------------------------------------------------------------ utility yard
|
||
function yard(sc, cfg, inside, pv) {
|
||
var P = sc.P, nb = Math.min(4, cfg.boilerModules), nc = Math.min(4, cfg.chillerModules), bio = cfg.fuel === "biomass", i;
|
||
var bx0 = 56, by0 = 101, bx1 = 74, by1 = 110, H = 6.0;
|
||
var bname = function (k) { return "boiler-B" + (k + 1); };
|
||
for (i = 0; i < 4; i++) {
|
||
if (i < nb) sc.inf(bname(i), "Boiler module B-" + (i + 1) + ", 500 kg/h, " + cfg.fuel,
|
||
(bio ? "Fired module; largest piece ~3.3 x 2.0 x 2.7 m (AIX DZL0.5 listing)" : "Electric module, ~3.0 x 1.5 m per 500 kg/h (vendor listing)") +
|
||
"; steam at 10 bar / 180 C to the press ring main" + (i === 2 && nb >= 3 ? " (B-3 is the N+1 position, scale.md)" : ""));
|
||
else sc.inf(bname(i), "Boiler bay B-" + (i + 1) + ": empty position", "The hall is built once for four 500 kg/h modules; modules arrive with the presses (scale.md utility phasing)");
|
||
}
|
||
sc.inf("boilerhall", "Boiler hall, " + nb + " of 4 modules (" + fmt(nb * 500) + " kg/h)",
|
||
"~18 x 9 m, 6.0 m high (estimate): four 3.5 m bays plus a 4 m bay for feed tank, softener, feed pumps and steam header" +
|
||
(cfg.boilerModules > 4 ? ". The fleet needs " + cfg.boilerModules + " modules: beyond the four-module hall, a utility phase of its own" : ""));
|
||
if (!inside) {
|
||
var s = sc.sprite("boilerhall"), S = sides(bx0, by0, bx1, by1);
|
||
s.box(bx0, by0, bx1, by1, 0, H, "iso-yardb", { top: "iso-roof2" });
|
||
for (i = 0; i < 4; i++) {
|
||
s.open(S.S[0], S.S[1], [[3.5 * i + 0.7, 3.5 * i + 2.8, 0, 3.4]], i < nb ? "iso-door iso-on" : "iso-door", bname(i));
|
||
s.open(S.S[0], S.S[1], [[3.5 * i + 0.9, 3.5 * i + 2.6, 4.0, 4.9]], i < nb ? "iso-win iso-lit" : "iso-win", bname(i));
|
||
}
|
||
s.open(S.S[0], S.S[1], [[15, 17, 0, 2.6]], "iso-door").open(S.E[0], S.E[1], [[2, 7, 4, 4.9]], "iso-louvre").open(S.W[0], S.W[1], [[2, 7, 4, 4.9]], "iso-louvre");
|
||
s.seq([0, 1, 2, 3].map(function (k) {
|
||
var cx = bx0 + 3.5 * k + 1.75;
|
||
return [cx, by0 + 6, function (q) {
|
||
q.box(cx - 0.9, by0 + 5.2, cx + 0.9, by0 + 6.8, H, H + 0.25, k < nb ? "iso-roofcap" : "iso-roofcap2", { id: bname(k) });
|
||
if (k < nb && !bio) { q.cyl(cx, by0 + 6, 0.2, H + 0.25, H + 1.4, "iso-steel"); if (cfg.running) q.puffs(cx, by0 + 6, H + 1.5, "iso-puff2", 3, 3.8); }
|
||
}];
|
||
}));
|
||
} else {
|
||
var f = sc.sprite("boilerhall");
|
||
f.box(bx0, by0, bx1, by1, 0, 0.15, "iso-plinth");
|
||
f.flat(rect(bx0, by0, bx1, by1), 0.16, "iso-z-yard iso-t", "boilerhall");
|
||
var bl = [];
|
||
for (i = 0; i <= 4; i++) bl.push([bx0 + 3.5 * i, by0 + 0.3, bx0 + 3.5 * i, by1 - 0.3]);
|
||
bl.push([bx0, by0 + 2.5, bx0 + 14, by0 + 2.5]);
|
||
f.lines(bl, 0.17, "iso-mark");
|
||
wallsCut(sc, "boilerhall", bx0, by0, bx1, by1, 0.15, 1.0, 3.0, 0.25, "iso-yardb");
|
||
for (i = 0; i < 4; i++) {
|
||
var cx = bx0 + 3.5 * i + 1.75, m = sc.sprite(bname(i));
|
||
if (i < nb) {
|
||
if (bio) {
|
||
m.box(cx - 1.0, by0 + 4.0, cx + 1.0, by0 + 7.3, 0.15, 2.85, "iso-boilerb");
|
||
m.box(cx - 0.4, by0 + 2.6, cx + 0.4, by0 + 4.0, 0.6, 1.3, "iso-steel");
|
||
m.open([cx - 1.0, by0 + 4.0], [cx + 1.0, by0 + 4.0], [[0.6, 1.4, 0.8, 1.6]], "iso-fire");
|
||
m.cyl(cx, by0 + 6.3, 0.22, 2.85, 4.0, "iso-steel");
|
||
} else {
|
||
m.box(cx - 0.75, by0 + 3.6, cx + 0.75, by0 + 6.6, 0.15, 2.05, "iso-boilere");
|
||
m.box(cx - 0.55, by0 + 3.2, cx + 0.55, by0 + 3.6, 0.15, 1.7, "iso-panel");
|
||
m.led(cx, by0 + 3.4, 1.72, "iso-ledg");
|
||
m.cyl(cx, by0 + 5.6, 0.16, 2.05, 3.2, "iso-steel");
|
||
if (cfg.running) m.puffs(cx, by0 + 5.6, 3.3, "iso-puff", 2, 3.4);
|
||
}
|
||
} else {
|
||
m.box(cx - 1.1, by0 + 3.4, cx + 1.1, by0 + 7.4, 0.15, 0.3, "iso-padempty");
|
||
sc.tag(cx, by0 + 5.4, 1.2, "B-" + (i + 1) + " free", 3, bname(i));
|
||
}
|
||
if (i < nb) sc.tag(cx, by0 + 5.0, 3.6, "B-" + (i + 1), 4, bname(i));
|
||
}
|
||
var hb = sc.sprite("boilerhall");
|
||
hb.seq([[bx0 + 15.2, by0 + 6.5, function (q) { q.cyl(bx0 + 15.2, by0 + 6.5, 0.9, 0.15, 2.6, "iso-tank"); }],
|
||
[bx0 + 16.9, by0 + 4.5, function (q) { q.cyl(bx0 + 16.9, by0 + 4.5, 0.35, 0.15, 1.8, "iso-softener"); q.cyl(bx0 + 16.9, by0 + 5.4, 0.35, 0.15, 1.8, "iso-softener"); }],
|
||
[bx0 + 16.6, by0 + 7.8, function (q) { q.box(bx0 + 15.6, by0 + 7.6, bx0 + 17.6, by0 + 8.3, 0.15, 0.9, "iso-pump"); }]]);
|
||
}
|
||
sc.tag((bx0 + bx1) / 2, (by0 + by1) / 2, inside ? 4.2 : H + 1.2, "Boiler hall " + nb + "/4", 9, "boilerhall");
|
||
if (cfg.boilerModules > 4) sc.tag(bx0 + 2, by0, inside ? 3.4 : H + 0.6, "+" + (cfg.boilerModules - 4) + " boilers beyond the hall", 99, "boilerhall", "iso-warntag");
|
||
// flue stack, biomass extras
|
||
var st = sc.sprite("stack");
|
||
st.cyl(65, 112.4, 0.5, 0, 14, "iso-stack");
|
||
st.cyl(65, 112.4, 0.58, 13.2, 14, "iso-stackband");
|
||
if (bio && cfg.running && nb > 0) st.puffs(65, 112.4, 14.2, "iso-smoke", 4, 5.5);
|
||
sc.inf("stack", "Flue stack, 14 m", bio ? "Biomass boiler flue; dust collectors and fans along the hall's back wall (scale.md)" : "Built with the yard for the fired case; idle while steam is electric");
|
||
sc.tag(65, 112.4, 14.6, "Stack", 2, "stack");
|
||
if (bio) {
|
||
var dc = sc.sprite("stack"); dc.cyl(60.5, 111.8, 0.65, 1.0, 4.6, "iso-steel"); dc.frust(60.5, 111.8, 0.18, 0.65, 0.2, 1.0, "iso-steel", 8);
|
||
sc.sprite("stack").box(61.9, 110.6, 63.5, 112.3, 0, 1.4, "iso-fanbox");
|
||
var fs = sc.sprite("fuelstore"), F = sides(74.5, 104, 81.5, 110);
|
||
fs.box(74.5, 104, 81.5, 110, 0, 5.0, "iso-yardb", { top: "iso-roof2" });
|
||
fs.open(F.S[0], F.S[1], [[0.5, 6.5, 0, 4.3]], "iso-open").open(F.S[0], F.S[1], [[0.6, 6.4, 0, 1.4]], "iso-chipsin");
|
||
sc.sprite("chips").frust(78, 102.1, 1.9, 0.5, 0, 1.6, "iso-chips", 10);
|
||
sc.inf("fuelstore", "Biomass fuel store, 42 m2", "5.0 m high; air-dry wood, ~350 to 400 t a year at ~1.0 million records (scale.md)");
|
||
sc.inf("chips", "Wood chip pile", "Fuel follows records pressed, not presses installed (scale.md)");
|
||
sc.tag(78, 107, 5.6, "Fuel store", 6, "fuelstore");
|
||
}
|
||
// pump and HX room
|
||
var px0 = 82.5, py0 = 101, px1 = 90.5, py1 = 106;
|
||
sc.inf("pumproom", "Cooling pump and heat-exchanger room", "~40 m2 (8 x 5 m), 4.0 m high: plate heat exchanger, cold tank and pumps behind the chiller bank (scale.md)");
|
||
if (!inside) {
|
||
var pr = sc.sprite("pumproom"), Q = sides(px0, py0, px1, py1);
|
||
pr.box(px0, py0, px1, py1, 0, 4.0, "iso-yardb", { top: "iso-roof2" });
|
||
pr.open(Q.S[0], Q.S[1], [[1, 2.6, 0, 2.5]], "iso-door").open(Q.S[0], Q.S[1], [[4, 7, 2.6, 3.4]], "iso-louvre").open(Q.E[0], Q.E[1], [[1, 4, 2.4, 3.3]], "iso-louvre");
|
||
} else {
|
||
var pf = sc.sprite("pumproom"); pf.box(px0, py0, px1, py1, 0, 0.15, "iso-plinth"); pf.flat(rect(px0, py0, px1, py1), 0.16, "iso-z-yard iso-t", "pumproom");
|
||
wallsCut(sc, "pumproom", px0, py0, px1, py1, 0.15, 1.0, 2.6, 0.25, "iso-yardb");
|
||
sc.sprite("pumproom").cyl(85, 104.2, 0.95, 0.15, 2.8, "iso-tank");
|
||
var hx = sc.sprite("pumproom"); hx.box(86.8, 103.6, 88.6, 104.2, 0.15, 1.9, "iso-hx");
|
||
sc.sprite("pumproom").seq([0, 1, 2].map(function (k) { return [87 + k * 1.0, 102.0, function (q) { q.box(86.7 + k * 1.0, 101.7, 87.3 + k * 1.0, 102.3, 0.15, 0.6, "iso-pump"); q.cyl(87 + k * 1.0, 102, 0.17, 0.6, 0.95, "iso-motor"); }]; }));
|
||
}
|
||
sc.tag((px0 + px1) / 2, (py0 + py1) / 2, inside ? 3 : 4.6, "Pumps + HX", 3, "pumproom");
|
||
// chiller compound: screened, open to the sky, four pads in two rows of two
|
||
var cx0 = 92, cy0 = 101, cx1 = 104.5, cy1 = 111.8;
|
||
var pads = [[93.8, 102.8], [98.7, 102.8], [93.8, 107.4], [98.7, 107.4]];
|
||
sc.inf("chillers", "Chiller compound, " + nc + " of 4 chillers (" + fmt(nc * 250) + " kW)",
|
||
"~12.5 x 10.8 m, open to the sky, screened (~2.5 m); two rows of two 4.0 x 2.6 m pads; no cooling tower (scale.md)" +
|
||
(cfg.chillerModules > 4 ? ". The fleet needs " + cfg.chillerModules + " modules: beyond the four-pad compound" : ""));
|
||
[["S", cx0, cy0, cx1, cy0 + 0.15], ["N", cx0, cy1 - 0.15, cx1, cy1], ["W", cx0, cy0 + 0.15, cx0 + 0.15, cy1 - 0.15], ["E", cx1 - 0.15, cy0 + 0.15, cx1, cy1 - 0.15]].forEach(function (w) {
|
||
var s = sc.sprite("chillers"), SS = sides(w[1], w[2], w[3], w[4]), L = [], k, len = (w[0] === "S" || w[0] === "N") ? w[3] - w[1] : w[4] - w[2];
|
||
s.box(w[1], w[2], w[3], w[4], 0, 2.5, "iso-screen");
|
||
for (k = 0; k < 6; k++) L.push([0.1, len - 0.1, 0.3 + k * 0.36, 0.42 + k * 0.36]);
|
||
var face = w[0] === "S" ? SS.S : w[0] === "N" ? SS.N : w[0] === "W" ? SS.W : SS.E;
|
||
s.open(face[0], face[1], L, "iso-slats");
|
||
var inner = w[0] === "S" ? SS.N : w[0] === "N" ? SS.S : w[0] === "W" ? SS.E : SS.W;
|
||
s.open(inner[0], inner[1], L, "iso-slats");
|
||
});
|
||
for (i = 0; i < 4; i++) {
|
||
var id = "chiller-CH" + (i + 1), p = pads[i], s2 = sc.sprite(id);
|
||
s2.box(p[0], p[1], p[0] + 4.0, p[1] + 2.6, 0, 0.2, i < nc ? "iso-pad" : "iso-padempty");
|
||
if (i >= nc) s2.flat(rect(p[0] + 0.25, p[1] + 0.25, p[0] + 3.75, p[1] + 2.35), 0.21, "iso-slot iso-empty", id);
|
||
if (i < nc) {
|
||
var ux0 = p[0] + 0.2, uy0 = p[1] + 0.18, ux1 = p[0] + 3.8, uy1 = p[1] + 2.42, U = sides(ux0, uy0, ux1, uy1), gl = [];
|
||
s2.box(ux0, uy0, ux1, uy1, 0.2, 2.48, "iso-chiller");
|
||
for (var k2 = 0; k2 < 6; k2++) gl.push([0.2 + k2 * 0.56, 0.62 + k2 * 0.56, 0.5, 2.1]);
|
||
s2.open(U.S[0], U.S[1], gl, "iso-grille").open(U.N[0], U.N[1], gl, "iso-grille");
|
||
s2.fan(p[0] + 1.1, p[1] + 1.3, 2.5, 0.7).fan(p[0] + 2.9, p[1] + 1.3, 2.5, 0.7);
|
||
s2.led(ux1 - 0.2, uy0, 1.6, "iso-ledg");
|
||
sc.inf(id, "Chiller CH-" + (i + 1) + ", 250 kW", "Air-cooled, rated at 40 C ambient (Carrier 30RB 080 to 090 class, 2.4 to 3.6 x 2.24 x 2.28 m); pad 4.0 x 2.6 m" + (i === 3 ? "; CH-4 is the N+1 reserve" : "") + " (scale.md)");
|
||
if (inside) sc.tag(p[0] + 2, p[1] + 1.3, 3.2, "CH-" + (i + 1), 4, id);
|
||
} else {
|
||
sc.inf(id, "Pad CH-" + (i + 1) + ": empty", "The compound is built once for four 250 kW chillers; this pad waits for its module (scale.md)");
|
||
sc.tag(p[0] + 2, p[1] + 1.3, 1.0, "CH-" + (i + 1) + " free", 3, id);
|
||
}
|
||
}
|
||
sc.tag((cx0 + cx1) / 2, (cy0 + cy1) / 2, 4.6, "Chillers " + nc + "/4", 9, "chillers");
|
||
if (cfg.chillerModules > 4) sc.tag(cx0 + 3, cy0, 3.4, "+" + (cfg.chillerModules - 4) + " chillers beyond the compound", 99, "chillers", "iso-warntag");
|
||
// raw water tank, transformer
|
||
var rw = sc.sprite("rawwater"); rw.cyl(108.5, 104.4, 2.5, 0, 3.0, "iso-water"); rw.disc(108.5, 104.4, 3.02, 2.0, "iso-tankring");
|
||
sc.inf("rawwater", "Raw water tank, 5 m x 3 m", "Phase 1 construction RFQ");
|
||
var tf = sc.sprite("transformer"), TS = sides(113, 102.2, 116, 105.2);
|
||
tf.box(113, 102.2, 116, 105.2, 0, 2.3, "iso-trafo");
|
||
var fins = []; for (var fk = 0; fk < 7; fk++) fins.push([0.25 + fk * 0.4, 0.45 + fk * 0.4, 0.3, 2.0]);
|
||
tf.open(TS.S[0], TS.S[1], fins, "iso-fins").open(TS.E[0], TS.E[1], fins, "iso-fins").open(TS.W[0], TS.W[1], fins, "iso-fins").open(TS.N[0], TS.N[1], fins, "iso-fins");
|
||
tf.seq([0, 1, 2].map(function (k) { return [113.8 + k * 0.7, 103.7, function (q) { q.cyl(113.8 + k * 0.7, 103.7, 0.12, 2.3, 2.9, "iso-bushing"); }]; }));
|
||
[[112.2, 101.4, 116.8, 101.5], [112.2, 106.0, 116.8, 106.1], [112.2, 101.5, 112.3, 106.0], [116.7, 101.5, 116.8, 106.0]].forEach(function (w) { sc.sprite("transformer").box(w[0], w[1], w[2], w[3], 0, 2.0, "iso-fence"); });
|
||
sc.inf("transformer", "PEA transformer",
|
||
(bio ? "800 to 1,000 kVA class without electric steam" : "1,250 to 1,600 kVA class with electric steam") + " (five-press sizing, scale.md); medium-voltage connection at 22 kV, PEA TOU 3.2.2");
|
||
sc.tag(114.5, 103.7, 3.6, "Transformer", 3, "transformer");
|
||
// battery (part of the solar package)
|
||
if (cfg.solar !== "none" && cfg.batteryKwh > 0) {
|
||
var packs = Math.max(1, Math.round(cfg.batteryKwh / 18)), Lb = Math.max(2.4, Math.min(13, packs * 0.4)), b0 = 119.5;
|
||
var bt = sc.sprite("battery"), BS = sides(b0, 101.8, b0 + Lb, 104.24), dl = [];
|
||
bt.box(b0, 101.8, b0 + Lb, 104.24, 0, 2.6, "iso-bess");
|
||
for (var dk = 0; dk < Math.floor(Lb / 2); dk++) dl.push([0.3 + dk * 2, 1.7 + dk * 2, 0.2, 2.3]);
|
||
bt.open(BS.S[0], BS.S[1], dl, "iso-bessdoor").open(BS.N[0], BS.N[1], dl, "iso-bessdoor");
|
||
bt.led(b0 + 0.3, 101.8, 2.2, "iso-ledg");
|
||
var iv = sc.sprite("battery");
|
||
iv.seq([0, 1].map(function (k) { return [b0 + Lb + 0.9 + k * 1.1, 102.4, function (q) { q.box(b0 + Lb + 0.5 + k * 1.1, 102.1, b0 + Lb + 1.3 + k * 1.1, 102.6, 0, 1.6, "iso-inv"); }]; }));
|
||
var ppa = cfg.solar === "ppa" ? " (PPA)" : "";
|
||
sc.inf("battery", "Battery " + fmt(cfg.batteryKwh) + " kWh" + ppa,
|
||
"Gobel LiFePO4 stack, " + packs + " x 18 kWh packs, Deye hybrid inverters; ride-through, not peak shaving (scale.md, sustainability.md)" + (ppa ? "; under the PPA the EPC owns the system" : ""));
|
||
sc.tag(b0 + Lb / 2, 103, 3.4, "Battery " + fmt(cfg.batteryKwh) + " kWh", 8, "battery");
|
||
}
|
||
// water treatment (galvanics in-house)
|
||
if (cfg.galvanics) {
|
||
var wx0 = 21, wy0 = 101, wx1 = 33, wy1 = 111, WH = 6.5;
|
||
sc.inf("wt", "Water treatment building, 12 x 10 m", "Zero-discharge effluent treatment, DI water plant and chemical store, directly behind galvanics; buried gravity duct and a covered crossing 6 m clear (site-candidates.md)");
|
||
if (!inside) {
|
||
var w = sc.sprite("wt"), WS = sides(wx0, wy0, wx1, wy1);
|
||
w.box(wx0, wy0, wx1, wy1, 0, WH, "iso-yardb", { top: "iso-roof2" });
|
||
w.open(WS.S[0], WS.S[1], [[1.5, 4, 0, 3]], "iso-door").open(WS.S[0], WS.S[1], [[7, 9, 3.6, 4.6], [9.6, 11.4, 3.6, 4.6]], "iso-win iso-lit")
|
||
.open(WS.E[0], WS.E[1], [[2, 4, 3.6, 4.6], [6, 8, 3.6, 4.6]], "iso-win").open(WS.W[0], WS.W[1], [[2, 8, 4.8, 5.6]], "iso-louvre");
|
||
w.box(wx0 + 7, wy0 + 5, wx0 + 8.5, wy0 + 6.5, WH, WH + 1.0, "iso-ac");
|
||
} else {
|
||
var wf = sc.sprite("wt"); wf.box(wx0, wy0, wx1, wy1, 0, 0.15, "iso-plinth"); wf.flat(rect(wx0, wy0, wx1, wy1), 0.16, "iso-z-galv iso-t", "wt");
|
||
wallsCut(sc, "wt", wx0, wy0, wx1, wy1, 0.15, 1.0, 3.0, 0.25, "iso-yardb");
|
||
sc.sprite("wt").cyl(24.5, 106.5, 1.3, 0.15, 4.6, "iso-evap");
|
||
sc.sprite("wt").seq([0, 1].map(function (k) { return [28 + k * 2.4, 103.6, function (q) { q.cyl(28 + k * 2.4, 103.6, 1.0, 0.15, 2.3, "iso-tank"); }]; }));
|
||
sc.sprite("wt").seq([0, 1].map(function (k) { return [29 + k * 0.8, 108.6, function (q) { q.cyl(29 + k * 0.8, 108.6, 0.32, 0.15, 1.7, "iso-softener"); }]; }));
|
||
sc.sprite("wt").box(31, 107.8, 32.4, 109.6, 0.15, 1.6, "iso-instr");
|
||
sc.sprite("wt").box(21.5, 102, 22.1, 105.5, 0.15, 1.8, "iso-shelf");
|
||
}
|
||
var cr = sc.sprite("wt");
|
||
cr.seq([[25.2, 94.4], [28.8, 94.4], [25.2, 100.6], [28.8, 100.6]].map(function (p) { return [p[0], p[1], function (q) { q.box(p[0] - 0.12, p[1] - 0.12, p[0] + 0.12, p[1] + 0.12, 0, 6.0, "iso-steel", { noTop: true }); }]; }));
|
||
cr.box(25, 94, 29, 101, 6.0, 6.3, "iso-roof2");
|
||
sc.tag(27, 106, inside ? 5.2 : WH + 1, "Water treatment", 7, "wt");
|
||
}
|
||
// PVC silos and big-bag station
|
||
// x ascending; from the default view (north-east) they read black, black, red, yellow (user, October 2026)
|
||
var cols = ["iso-siloy", "iso-silor", "iso-silok", "iso-silok"];
|
||
[37.5, 41.5, 45.5, 49.5].forEach(function (x, k) {
|
||
var s = sc.sprite("silos");
|
||
s.cyl(x, 103.6, 0.9, 0, 2.2, "iso-steel", { noTop: true });
|
||
s.frust(x, 103.6, 0.4, 1.7, 2.2, 3.6, cols[k], 10);
|
||
s.cyl(x, 103.6, 1.7, 3.6, 9.6, cols[k]);
|
||
s.frust(x, 103.6, 1.7, 0.35, 9.6, 10.3, cols[k], 10);
|
||
});
|
||
var bb = sc.sprite("silos"); bb.box(52.3, 101.6, 54.5, 104.2, 0, 3.4, "iso-frame"); bb.box(52.7, 102.2, 54.1, 103.6, 1.4, 2.6, "iso-bigbag");
|
||
sc.inf("silos", "PVC silos (proposed)", "Two for black, two for standard colours, ~50 m3 each (estimate); big-bag station; buried conveying lines to the colour store (site-candidates.md)");
|
||
sc.tag(43.5, 103.6, 11, "PVC silos", 6, "silos");
|
||
// pipe bridges across the lane
|
||
pipeBridge(sc, 72); pipeBridge(sc, 86.5);
|
||
return { nb: nb, nc: nc };
|
||
}
|
||
|
||
// ------------------------------------------------------------ office building
|
||
function office(sc, cfg, inside) {
|
||
var o = OFC, P = sc.P, x0 = o.x0, y0 = o.y0, x1 = o.x1, y1 = o.y1;
|
||
sc.inf("office", "Office building, 32 x 15 m", "Two storeys (~960 m2): reception, canteen, changing rooms, mastering and cutting, listening, archive; offices, training and IT upstairs. Isolated from the factory floor, joined by a covered link (site-candidates.md).");
|
||
sc.inf("cutting", cfg.cutting ? "Mastering and cutting room" : "Cutting room: cut abroad",
|
||
cfg.cutting ? "Sillitoe SDMS lathe (USD 125k list) on the ground slab, away from press vibration and outside the crane zone; inner acoustic lining" :
|
||
"Lacquers are cut by an outside studio (SST Frankfurt cuts Poy Records' lacquers today); the room stays for mastering");
|
||
sc.inf("lathe", "Sillitoe SDMS cutting lathe", "Integrated Space / Time Control automation, USD 125k published list price (vendor-landscape.md)");
|
||
sc.inf("archive", "Archive: mobile shelving", "The father and the mother of every side; two fixed runs and four double-sided carriages, ~150 shelf metres, ~50,000 parts (facility-security.md, estimates)");
|
||
sc.inf("listening", "Listening room", "Start of the tour with the cutting-room window");
|
||
sc.inf("canteen", "Canteen and kitchen", "For about 40");
|
||
sc.inf("changing", "Changing rooms", "Women and men, four showers each; staff change here before crossing to the factory");
|
||
sc.inf("link", "Covered link", "Office to the start of the visitor corridor");
|
||
// covered link (both modes)
|
||
var lk = sc.sprite("link");
|
||
lk.seq([[15.7, 57.4], [18.3, 57.4], [15.7, 68.6], [18.3, 68.6]].map(function (p) { return [p[0], p[1], function (q) { q.box(p[0] - 0.1, p[1] - 0.1, p[0] + 0.1, p[1] + 0.1, 0, 3.5, "iso-steel", { noTop: true }); }]; }));
|
||
lk.box(15.5, 57, 18.5, 69, 3.5, 3.75, "iso-roof3");
|
||
if (!inside) {
|
||
var s = sc.sprite("office"), S = sides(x0, y0, x1, y1), gl = [], ul = [], wl = [], u, i;
|
||
s.box(x0, y0, x1, y1, 0, o.z0, "iso-plinth");
|
||
s.box(x0, y0, x1, y1, o.z0, o.z1, "iso-office", { top: "iso-roof3" });
|
||
for (u = 8.5, i = 0; u < 31; u += 3, i++) (hash(i + 3) > 0.35 ? gl : wl).push([u, u + 1.8, o.z0 + 0.9, o.z0 + 2.7]);
|
||
for (u = 1, i = 0; u < 31; u += 3, i++) (hash(i + 9) > 0.4 ? ul : wl).push([u, u + 1.8, o.z0 + 4.8, o.z0 + 6.8]);
|
||
s.open(S.S[0], S.S[1], wl, "iso-win").open(S.S[0], S.S[1], gl.concat(ul), "iso-win iso-lit").open(S.S[0], S.S[1], [[0.6, 7.2, o.z0, o.z0 + 3.2]], "iso-glass", "office");
|
||
["E", "W", "N"].forEach(function (k, j) {
|
||
var L = k === "N" ? 32 : 15, a = [], b = [];
|
||
for (u = 1.2, i = 0; u < L - 1.5; u += 3, i++) { (hash(i + j * 17) > 0.45 ? a : b).push([u, u + 1.8, o.z0 + 0.9, o.z0 + 2.7]); (hash(i + j * 29 + 5) > 0.45 ? a : b).push([u, u + 1.8, o.z0 + 4.8, o.z0 + 6.8]); }
|
||
s.open(S[k][0], S[k][1], b, "iso-win").open(S[k][0], S[k][1], a, "iso-win iso-lit");
|
||
});
|
||
s.flat(rect(x0 + 0.4, y0 + 0.4, x1 - 0.4, y1 - 0.4), o.z1 + 0.01, "iso-roofin");
|
||
s.seq([[x0 + 20.8, y0 + 5.6], [x0 + 23.4, y0 + 5.6], [x0 + 26, y0 + 9.4]].map(function (p) {
|
||
return [p[0], p[1], function (q) { q.box(p[0] - 0.8, p[1] - 0.6, p[0] + 0.8, p[1] + 0.6, o.z1, o.z1 + 1.0, "iso-ac"); q.disc(p[0], p[1], o.z1 + 1.01, 0.42, "iso-fanring"); }];
|
||
}));
|
||
sc.tag((x0 + x1) / 2, (y0 + y1) / 2, o.z1 + 1.5, "Office", 9, "office");
|
||
return;
|
||
}
|
||
var f = sc.sprite("office"), z = o.z0;
|
||
f.box(x0, y0, x1, y1, 0, z, "iso-plinth");
|
||
var rooms = [[0, 0, 7, 15, "iso-z-lobby", "office"], [7, 8, 15, 15, cfg.cutting ? "iso-z-cut" : "iso-z-floor", "cutting"], [15, 8, 20, 15, "iso-z-listen", "listening"],
|
||
[20, 8, 24, 15, "iso-z-arch", "archive"], [24, 8, 32, 15, "iso-z-staff", "changing"], [7, 0, 25, 8, "iso-z-canteen", "canteen"], [25, 0, 32, 8, "iso-z-staff", "canteen"]];
|
||
var segs = [];
|
||
rooms.forEach(function (r) {
|
||
f.flat(rect(x0 + r[0], y0 + r[1], x0 + r[2], y0 + r[3]), z + 0.005, r[4] + " iso-t", r[5]);
|
||
var a = x0 + r[0], b = y0 + r[1], c = x0 + r[2], d = y0 + r[3];
|
||
segs.push([a, b, c, b], [c, b, c, d], [c, d, a, d], [a, d, a, b]);
|
||
});
|
||
f.lines(segs, z + 0.01, "iso-part");
|
||
wallsCut(sc, "office", x0, y0, x1, y1, z, 1.0, 3.0, 0.25, "iso-office");
|
||
// lobby: reception desk, stair
|
||
sc.sprite("office").box(x0 + 1, y0 + 3, x0 + 4, y0 + 3.8, z, z + 1.1, "iso-desk");
|
||
var stair = sc.sprite("office"); stair.box(x0 + 4.4, y0 + 9.6, x0 + 6.6, y0 + 14.4, z, z + 1.6, "iso-stair");
|
||
var steps = []; for (var si = 1; si < 8; si++) steps.push([x0 + 4.4, y0 + 9.6 + si * 0.6, x0 + 6.6, y0 + 9.6 + si * 0.6]);
|
||
stair.lines(steps, z + 1.61, "iso-steps");
|
||
// cutting room
|
||
if (cfg.cutting) {
|
||
var lt = sc.sprite("lathe");
|
||
lt.box(x0 + 9, y0 + 11, x0 + 11.2, y0 + 12.3, z, z + 1.0, "iso-lathe");
|
||
lt.seq([[x0 + 9.7, y0 + 11.65, function (q) { q.disc(x0 + 9.7, y0 + 11.65, z + 1.02, 0.42, "iso-platter"); q.disc(x0 + 9.7, y0 + 11.65, z + 1.04, 0.36, "iso-lacquer"); }],
|
||
[x0 + 10.7, y0 + 11.65, function (q) { q.box(x0 + 10.45, y0 + 11.3, x0 + 10.95, y0 + 12.0, z + 1.0, z + 1.5, "iso-steel"); q.box(x0 + 10.0, y0 + 11.55, x0 + 10.6, y0 + 11.75, z + 1.4, z + 1.55, "iso-steel"); }]]);
|
||
lt.led(x0 + 11.2, y0 + 11.3, z + 0.8, "iso-ledo");
|
||
var md = sc.sprite("cutting"); md.box(x0 + 12.4, y0 + 8.8, x0 + 14.6, y0 + 9.7, z, z + 0.75, "iso-desk");
|
||
md.seq([[x0 + 13, y0 + 9.4, function (q) { q.box(x0 + 12.7, y0 + 9.3, x0 + 13.3, y0 + 9.45, z + 0.75, z + 1.2, "iso-screenm"); }],
|
||
[x0 + 14, y0 + 9.4, function (q) { q.box(x0 + 13.7, y0 + 9.3, x0 + 14.3, y0 + 9.45, z + 0.75, z + 1.2, "iso-screenm"); }]]);
|
||
sc.sprite("cutting").box(x0 + 7.6, y0 + 14.2, x0 + 8.2, y0 + 14.7, z, z + 1.1, "iso-speaker");
|
||
sc.sprite("cutting").box(x0 + 13.8, y0 + 14.2, x0 + 14.4, y0 + 14.7, z, z + 1.1, "iso-speaker");
|
||
sc.tag(x0 + 10.1, y0 + 11.6, z + 2.6, "Lathe", 7, "lathe");
|
||
} else {
|
||
var sg = sc.sprite("cutting"); sg.box(x0 + 10.9, y0 + 11, x0 + 11.05, y0 + 12.6, z, z + 1.0, "iso-steel"); sg.box(x0 + 10.85, y0 + 10.9, x0 + 11.1, y0 + 12.7, z + 1.0, z + 2.0, "iso-sign");
|
||
sc.tag(x0 + 11, y0 + 11.8, z + 2.6, "Cut abroad", 100, "cutting", "iso-signtag");
|
||
}
|
||
sc.tag(x0 + 11, y0 + 9.5, z + 3.4, cfg.cutting ? "Cutting room" : "Cutting room (empty)", 6, "cutting");
|
||
// listening room
|
||
sc.sprite("listening").box(x0 + 16, y0 + 9, x0 + 19, y0 + 9.9, z, z + 0.6, "iso-sofa");
|
||
sc.sprite("listening").box(x0 + 15.6, y0 + 14, x0 + 16.2, y0 + 14.6, z, z + 1.2, "iso-speaker");
|
||
sc.sprite("listening").box(x0 + 18.8, y0 + 14, x0 + 19.4, y0 + 14.6, z, z + 1.2, "iso-speaker");
|
||
// archive: two fixed runs, four double-sided carriages and one aisle (rails along the long side)
|
||
var ar = [[8.3, 8.7], [8.75, 9.55], [9.6, 10.4], [10.45, 11.25], [12.15, 12.95], [13.0, 13.4]];
|
||
sc.sprite("archive").seq(ar.map(function (r) {
|
||
return [x0 + 22, y0 + (r[0] + r[1]) / 2, function (q) {
|
||
var S = sides(x0 + 20.5, y0 + r[0], x0 + 23.5, y0 + r[1]), L = [], lv;
|
||
q.box(x0 + 20.5, y0 + r[0], x0 + 23.5, y0 + r[1], z, z + 2.2, "iso-archive");
|
||
for (lv = 0; lv < 5; lv++) L.push([0.1, 2.9, z + 0.15 + lv * 0.42, z + 0.42 + lv * 0.42]);
|
||
q.open(S.S[0], S.S[1], L, "iso-boxes").open(S.N[0], S.N[1], L, "iso-boxes");
|
||
}];
|
||
}));
|
||
sc.tag(x0 + 22, y0 + 11, z + 3.0, "Archive", 6, "archive");
|
||
// canteen tables, kitchen counter, lockers
|
||
var tb = sc.sprite("canteen");
|
||
var tl = []; for (var tx = 0; tx < 4; tx++) for (var ty = 0; ty < 2; ty++) tl.push([x0 + 9.5 + tx * 3.6, y0 + 2.4 + ty * 3.2]);
|
||
tb.seq(tl.map(function (p) { return [p[0], p[1], function (q) { q.box(p[0] - 0.9, p[1] - 0.45, p[0] + 0.9, p[1] + 0.45, z, z + 0.75, "iso-table"); }]; }));
|
||
sc.sprite("canteen").box(x0 + 25.6, y0 + 6.6, x0 + 31.4, y0 + 7.4, z, z + 0.9, "iso-bench");
|
||
sc.sprite("changing").box(x0 + 24.6, y0 + 14.2, x0 + 31.4, y0 + 14.7, z, z + 1.9, "iso-locker");
|
||
sc.sprite("changing").box(x0 + 24.6, y0 + 10.6, x0 + 29.4, y0 + 11.1, z, z + 1.9, "iso-locker");
|
||
sc.tag(x0 + 3.5, y0 + 6, z + 2.6, "Reception", 3, "office");
|
||
sc.tag(x0 + 16, y0 + 4, z + 2.2, "Canteen", 3, "canteen");
|
||
sc.tag(x0 + 17.5, y0 + 11.5, z + 2.6, "Listening", 3, "listening");
|
||
}
|
||
|
||
// ------------------------------------------------------------ phase 2
|
||
function phase2(sc, cfg, inside, al, pv) {
|
||
var b = P2, P = sc.P, zS = FFL + b.eave, zN = FFL + b.high, n = al.ovD + al.ovC;
|
||
sc.inf("phase2", "Phase 2 building" + (al.p2need ? ": " + n + " press" + (n === 1 ? "" : "es") : " (reserved)"),
|
||
(al.p2need ? n + " press" + (n === 1 ? "" : "es") + " beyond the ~870 m2 press hall. " : "") +
|
||
"A second building is expansion beyond the current program: more pressing, or finished goods moved out to free production floor (scale.md). Drawn schematically; size and position not planned yet.");
|
||
var lk = sc.sprite("phase2");
|
||
lk.seq([[72.2, 57.4], [75.8, 57.4], [72.2, 68.6], [75.8, 68.6]].map(function (p) { return [p[0], p[1], function (q) { q.box(p[0] - 0.1, p[1] - 0.1, p[0] + 0.1, p[1] + 0.1, 0, 3.6, "iso-steel", { noTop: true }); }]; }));
|
||
lk.box(72, 57, 76, 69, 3.6, 3.85, "iso-roof3");
|
||
if (!inside) {
|
||
var s = sc.sprite("phase2"), S = sides(b.x0, b.y0, b.x1, b.y1), w = [], l = [], u, i;
|
||
s.box(b.x0, b.y0, b.x1, b.y1, 0, FFL, "iso-plinth");
|
||
s.prism(rect(b.x0, b.y0, b.x1, b.y1), FFL, [zS, zS, zN, zN], "iso-wall2", { top: "iso-roof" });
|
||
for (u = 2, i = 0; u < 40; u += 4, i++) (al.p2need && hash(i + 70) > 0.3 ? l : w).push([u, u + 2.6, FFL + 4.2, FFL + 5.4]);
|
||
s.open(S.S[0], S.S[1], w, "iso-win").open(S.S[0], S.S[1], l, "iso-win iso-lit").open(S.S[0], S.S[1], [[4, 8, FFL, FFL + 4.5]], "iso-door2");
|
||
s.open(S.E[0], S.E[1], [[3, 6, FFL + 4.4, FFL + 5.4], [10, 13, FFL + 4.6, FFL + 5.8], [17, 20, FFL + 4.9, FFL + 6.1]], al.p2need ? "iso-win iso-lit" : "iso-win");
|
||
s.open(S.N[0], S.N[1], [[18, 22, FFL, FFL + 3.4]], "iso-door");
|
||
var pp = pv.by.p2; if (pp) pvDraw(s, pp.F, pp.panels);
|
||
sc.tag((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, zN + 1, al.p2need ? "Phase 2 (" + n + " presses)" : "Phase 2 (reserved)", 9, "phase2");
|
||
return;
|
||
}
|
||
var f = sc.sprite("phase2"), z = FFL;
|
||
f.box(b.x0, b.y0, b.x1, b.y1, 0, FFL, "iso-plinth");
|
||
f.flat(rect(b.x0, b.y0, b.x1, b.y1), z + 0.005, "iso-z-press iso-t", "phase2");
|
||
f.flat(rect(b.x0 + 0.3, 51.5, b.x1 - 0.3, b.y1 - 0.3), z + 0.01, "iso-z-wh iso-t", "phase2");
|
||
f.lines([[b.x0 + 1, 42.5, b.x1 - 1, 42.5]], z + 0.01, "iso-trenchl");
|
||
var eC = 0;
|
||
al.p2.forEach(function (sl) {
|
||
var pts = rect(sl.x0 + 0.25, sl.y0 + 0.25, sl.x1 - 0.25, sl.y1 - 0.25);
|
||
if (sl.e) { var id = "pos2-c" + (++eC); f.flat(pts, z + 0.015, "iso-slot iso-empty", id); sc.inf(id, "Phase 2 position (free)", "Floor in the second building"); }
|
||
else f.flat(pts, z + 0.015, "iso-slot", sl.t === "D" ? "duplex-" + sl.n : "classic-" + sl.n);
|
||
});
|
||
wallsCut(sc, "phase2", b.x0, b.y0, b.x1, b.y1, z, 1.0, 3.0, 0.3, "iso-wall2");
|
||
al.p2.forEach(function (sl) {
|
||
if (sl.e) return;
|
||
var cx = (sl.x0 + sl.x1) / 2, cy = (sl.y0 + sl.y1) / 2;
|
||
if (sl.t === "D") { duplexCell(sc, sl.n, cx, cy, z, cfg.running); sc.tag(cx, cy, z + 3.2, "Duplex " + sl.n, 8, "duplex-" + sl.n); }
|
||
else { classicM(sc, sl.n, cx, cy, z, cfg.running); sc.tag(cx, cy, z + 3.2, "Classic " + sl.n, 6, "classic-" + sl.n); }
|
||
});
|
||
[52.4, 54.6].forEach(function (y) { rackRow(sc, "phase2", b.x0 + 3, y, b.x1 - 3, y + 1.1, z, 4.0, 3, 0, ["iso-pal-bx", "iso-pal-w", "", "iso-pal-bx"], "iso-rackf"); });
|
||
sc.tag((b.x0 + b.x1) / 2, b.y0 + 2, z + 3.5, al.p2need ? "Phase 2" : "Phase 2 (reserved)", 9, "phase2");
|
||
}
|
||
|
||
// ------------------------------------------------------------ vehicles
|
||
function truckDraw(s, cx, cy, dir, kind) {
|
||
var along = dir === "E" || dir === "W", sg = dir === "E" || dir === "N" ? 1 : -1, P = s.P;
|
||
function R(a0, a1, b0, b1) {
|
||
var A0 = sg > 0 ? a0 : -a1, A1 = sg > 0 ? a1 : -a0;
|
||
return along ? [cx + A0, cy + b0, cx + A1, cy + b1] : [cx + b0, cy + A0, cx + b1, cy + A1];
|
||
}
|
||
function B(r, z0, z1, mat, o) { s.box(r[0], r[1], r[2], r[3], z0, z1, mat, o); }
|
||
var ch = R(-5, 5, -1.1, 1.1); B(ch, 0.3, 1.0, "iso-chassis");
|
||
var cab = R(2.7, 5, -1.25, 1.25), body = R(-5, 2.45, -1.25, 1.25);
|
||
var ck = along ? [(cab[0] + cab[2]) / 2, cy] : [cx, (cab[1] + cab[3]) / 2], bk = along ? [(body[0] + body[2]) / 2, cy] : [cx, (body[1] + body[3]) / 2];
|
||
var parts = [[ck[0], ck[1], function () {
|
||
B(cab, 1.0, 3.1, "iso-cab");
|
||
var fr = along ? (sg > 0 ? [[cab[2], cab[1]], [cab[2], cab[3]]] : [[cab[0], cab[3]], [cab[0], cab[1]]]) : (sg > 0 ? [[cab[2], cab[3]], [cab[0], cab[3]]] : [[cab[0], cab[1]], [cab[2], cab[1]]]);
|
||
s.open(fr[0], fr[1], [[0.25, 2.25, 2.0, 2.9]], "iso-glassd");
|
||
}], [bk[0], bk[1], function () {
|
||
if (kind === "box") { B(body, 1.0, 3.7, "iso-truckbox"); }
|
||
else {
|
||
B(body, 1.0, 1.3, "iso-chassis");
|
||
var segs = along ? [[-4.7, -2.6], [-2.4, -0.3], [-0.1, 2.1]] : [[-4.7, -2.6], [-2.4, -0.3], [-0.1, 2.1]];
|
||
s.seq(segs.map(function (q) { var r = R(q[0], q[1], -1.1, 1.1); return [(r[0] + r[2]) / 2, (r[1] + r[3]) / 2, function () { B(r, 1.3, 2.35, "iso-sacks"); }]; }));
|
||
}
|
||
}]];
|
||
parts.sort(function (a, b) { return P.depth(a[0], a[1]) - P.depth(b[0], b[1]); }).forEach(function (p) { p[2](); });
|
||
}
|
||
function vehicles(sc, cfg) {
|
||
var P = sc.P;
|
||
sc.inf("truck-in", "Truck: PVC compound in", "Sacks of compound for the colour store and the presses");
|
||
sc.inf("truck-out", "Truck: finished records out", "Pallets from dispatch");
|
||
function mover(id, cls, x0, y0, dir, kind, dx, dy, bbx) {
|
||
var s = sc.sprite(id, "iso-mv"), v = P.dv(dx, dy, 0);
|
||
s.extBox(bbx[0], bbx[1], bbx[2], bbx[3], 0, 3.8);
|
||
s.ga("iso-a " + cls, "--dx:" + r1(v[0]) + "px;--dy:" + r1(v[1]) + "px");
|
||
truckDraw(s, x0, y0, dir, kind);
|
||
s.ge();
|
||
}
|
||
if (cfg.running) {
|
||
mover("truck-in", "iso-tin-r", -3, -8.5, "E", "sacks", 124, 0, [-8.5, -9.8, 126.2, -7.2]);
|
||
mover("truck-in", "iso-tin-g", 121, -1, "N", "sacks", 0, 50, [119.7, -6.2, 122.3, 54.2]);
|
||
mover("truck-in", "iso-tin-d", 121, 63.6, "S", "sacks", 0, 0, [119.7, 58.4, 122.3, 68.65]);
|
||
mover("truck-out", "iso-tout-g", 127, 63.6, "S", "box", 0, -64.6, [125.7, -6.2, 128.3, 68.65]);
|
||
mover("truck-out", "iso-tout-r", 127, -13.5, "W", "box", -130, 0, [-8.5, -14.8, 132.2, -12.2]);
|
||
}
|
||
var a = sc.sprite("truck-in", cfg.running ? "iso-park" : ""); truckDraw(a, 121, 63.6, "S", "sacks");
|
||
var b = sc.sprite("truck-out", cfg.running ? "iso-park" : ""); truckDraw(b, 127, 63.6, "S", "box");
|
||
}
|
||
|
||
// ------------------------------------------------------------ scene
|
||
function buildScene(cfg, mode, rot, uid) {
|
||
var sc = new Scene(rot, uid), inside = mode === "inside", al = allocate(cfg), pv = pvPlace(cfg, al), P = sc.P;
|
||
siteGround(sc, cfg, al);
|
||
sitePieces(sc, cfg, al);
|
||
parking(sc, cfg, pv);
|
||
factoryInfo(sc, cfg, al);
|
||
if (inside) factoryInside(sc, cfg, al, pv); else factoryRoof(sc, cfg, pv);
|
||
office(sc, cfg, inside);
|
||
var y = yard(sc, cfg, inside, pv);
|
||
if (al.p2show) phase2(sc, cfg, inside, al, pv);
|
||
else {
|
||
sc.inf("phase2", "Phase 2 reserve", "Ground kept for a second building: expansion beyond the current program, more pressing or finished goods (scale.md). Switch phase 2 on, or add presses beyond the ~870 m2 hall.");
|
||
sc.tag((P2.x0 + P2.x1) / 2, (P2.y0 + P2.y1) / 2, 0.5, "Phase 2 reserve", 1, "phase2");
|
||
}
|
||
vehicles(sc, cfg);
|
||
// PV info and roof tags
|
||
var ppa = cfg.solar === "ppa", onRoof = pv.by.roofW.panels.length + pv.by.roofE.panels.length,
|
||
onCp = pv.by.cars.panels.length + pv.by.scoot.panels.length, onP2 = pv.by.p2 ? pv.by.p2.panels.length : 0;
|
||
if (cfg.solar !== "none" && pv.want > 0) {
|
||
var kwp = cfg.pvKwp, parts = [onRoof + " on the factory roof"];
|
||
if (onCp) parts.push(onCp + " on the parking canopies");
|
||
if (onP2) parts.push(onP2 + " on the phase 2 roof");
|
||
sc.inf("pv", "PV " + fmt(kwp) + " kWp, " + fmt(cfg.batteryKwh) + " kWh battery" + (ppa ? " (PPA)" : ""),
|
||
fmt(pv.want) + " modules of ~0.45 kWp (~2 m2 each): " + parts.join(", ") + (pv.left ? "; " + fmt(pv.left) + " do not fit the roofs (ground mount)" : "") +
|
||
". About " + fmt(kwp * 1.4) + " to " + fmt(kwp * 1.5) + " MWh a year at 1,400 to 1,500 kWh/kWp (scale.md)" + (ppa ? ". PPA: the EPC owns the panels, the plant buys the power." : "."));
|
||
if (!inside && onRoof) {
|
||
sc.tag(X(52), Y(10), roofZ(10) + 1.5, "PV " + fmt(kwp) + " kWp", 10, "pv");
|
||
if (ppa) sc.tag(X(80), Y(6), roofZ(6) + 1.0, "PPA panels", 100, "pv", "iso-signtag");
|
||
}
|
||
if (onCp) sc.tag(32, 9.5, 5.2, "PV canopy", 5, "pv");
|
||
if (pv.left) sc.tag(X(118), Y(6), inside ? 9 : roofZ(6) + 1.5, "+" + fmt(pv.left) + " modules need ground mount", 99, "pv", "iso-warntag");
|
||
}
|
||
if (!inside) {
|
||
sc.tag(X(20), Y(12), roofZ(12) + 1.4, "Galvanics", 5, "galvanics");
|
||
sc.tag(X(66), Y(16), roofZ(16) + 1.4, "Press hall", 8, "presshall");
|
||
sc.tag(X(99), Y(14), roofZ(14) + 1.4, "Finishing", 5, "packaging");
|
||
sc.tag(X(117), Y(14), roofZ(14) + 1.4, "Warehouse", 5, "warehouse");
|
||
sc.tag(X(6), Y(16), roofZ(16) + 1.4, "Workshop", 5, "workshop");
|
||
if (cfg.corridor) sc.tag(X(30), Y(0), FFL + 3.2, "Visitor corridor", 4, "corridor");
|
||
}
|
||
sc.tag(114, 2.5, 3.8, "Gate", 4, "gate");
|
||
sc.tag(32, 20, 1.5, "Car park", 4, "parking");
|
||
sc.tag(84, 21, 5, "Fire water", 4, "firewater");
|
||
if (al.lost) sc.tag(75, 44, 9, "+" + al.lost + " presses not drawn", 99, "phase2", "iso-warntag");
|
||
// render
|
||
var spr = sortSprites(sc.sp), h = [], i, s, sb = [1e9, 1e9, -1e9, -1e9];
|
||
var slab = [[-8, -24], [158, -24], [158, 126], [-8, 126]];
|
||
slab.forEach(function (p) { [0, -3].forEach(function (z) { var q = P.s(p[0], p[1], z); sb[0] = Math.min(sb[0], q[0]); sb[1] = Math.min(sb[1], q[1]); sb[2] = Math.max(sb[2], q[0]); sb[3] = Math.max(sb[3], q[1]); }); });
|
||
h.push('<defs><radialGradient id="iso-pool-' + uid + '"><stop offset="0" class="iso-pool0"/><stop offset="1" class="iso-pool1"/></radialGradient></defs>');
|
||
h.push('<g class="iso-ground">' + sc.ground.join("") + "</g><g class=\"iso-sprites\">");
|
||
for (i = 0; i < spr.length; i++) {
|
||
s = spr[i];
|
||
if (!s.h.length) continue;
|
||
h.push("<g" + did(s.id) + (s.cls ? ' class="' + s.cls + '"' : "") + ">" + s.h.join("") + "</g>");
|
||
if (s.sb[1] < sb[1]) sb[1] = s.sb[1];
|
||
}
|
||
h.push("</g>");
|
||
// tags: signs always, names when labels are on
|
||
var tags = sc.tags.filter(function (t) { return t.t && (t.k || cfg.labels); });
|
||
h.push('<g class="iso-tags">');
|
||
tags.forEach(function (t) {
|
||
var q = P.s(t.x, t.y, t.z), w = Math.round(t.t.length * 5.6 + 12), hh = 15, d;
|
||
d = "M" + (-w / 2) + " -21h" + w + "v" + hh + "h" + (-(w / 2 - 4)) + "l-4 5l-4 -5h" + (-(w / 2 - 4)) + "z";
|
||
h.push('<g class="iso-tag' + (t.k ? " " + t.k : "") + '"' + did(t.id) + ' data-p="' + t.p + '" data-w="' + w + '" data-x="' + r1(q[0]) + '" data-y="' + r1(q[1]) +
|
||
'" style="transform:translate(' + r1(q[0]) + "px," + r1(q[1]) + 'px) scale(var(--iso-k,1))"><path d="' + d + '"/><text y="-10">' + esc(t.t) + "</text></g>");
|
||
});
|
||
h.push("</g>");
|
||
var html = h.join("");
|
||
return { html: html, info: sc.info, bbox: { x: sb[0], y: sb[1] - 30, w: sb[2] - sb[0], h: sb[3] - sb[1] + 30 },
|
||
sprites: sc.sp.length, alloc: al, pv: pv, yard: y, focus: P.s(X(64), Y(12), 3) };
|
||
}
|
||
|
||
// ------------------------------------------------------------ mount
|
||
var UID = 0;
|
||
var NOTE = "Schematic plant view after the Payap scenario model and the utility yard of scale.md; positions and envelopes are estimates. Drag to pan, pinch or Ctrl + wheel to zoom, tap an object for its figures.";
|
||
function mount(el, cfg, opts) {
|
||
if (!el) throw new Error("CNXIso.mount: no container");
|
||
opts = opts || {};
|
||
var st = { raw: Object.assign({}, cfg || {}), cfg: norm(cfg), mode: opts.mode === "inside" ? "inside" : "roof", rot: opts.rotation === undefined ? 3 : ((opts.rotation | 0) % 4 + 4) % 4, sel: null, uid: ++UID };
|
||
var wrap = document.createElement("div");
|
||
wrap.className = "iso-wrap";
|
||
wrap.innerHTML = '<div class="iso-stage"><svg class="iso-svg" xmlns="http://www.w3.org/2000/svg" role="img" tabindex="0" aria-label="Isometric view of the CNX Vinyl plant"></svg>' +
|
||
'<div class="iso-bar" role="toolbar" aria-label="Plant view">' +
|
||
'<span class="iso-seg"><button type="button" data-a="roof">Roof</button><button type="button" data-a="inside">Inside</button></span>' +
|
||
'<button type="button" data-a="rotl" title="Rotate left" aria-label="Rotate the view left">↺</button>' +
|
||
'<button type="button" data-a="rotr" title="Rotate right" aria-label="Rotate the view right">↻</button>' +
|
||
'<span class="iso-compass" title="North" aria-hidden="true"><svg viewBox="-10 -10 20 20"><g class="iso-needle"><path d="M0 -8L3 3L0 1.4L-3 3Z"/></g></svg><b>N</b></span>' +
|
||
'<button type="button" data-a="zout" title="Zoom out" aria-label="Zoom out">−</button>' +
|
||
'<button type="button" data-a="zin" title="Zoom in" aria-label="Zoom in">+</button>' +
|
||
'<button type="button" data-a="fit" title="Show the whole site" aria-label="Fit the whole site">Fit</button></div>' +
|
||
'<div class="iso-tip" role="status" hidden></div></div>' + (opts.note === false ? "" : '<p class="iso-note">' + NOTE + "</p>");
|
||
el.appendChild(wrap);
|
||
var stage = wrap.querySelector(".iso-stage"), svg = wrap.querySelector(".iso-svg"), tip = wrap.querySelector(".iso-tip"), bar = wrap.querySelector(".iso-bar");
|
||
var scn = null, vb = null, fitW = 1, atFit = true, metas = [], declT = 0, alive = true, lastSize = [0, 0];
|
||
|
||
function size() { var r = stage.getBoundingClientRect(); return [Math.max(1, r.width), Math.max(1, r.height)]; }
|
||
function build() {
|
||
scn = buildScene(st.cfg, st.mode, st.rot, st.uid);
|
||
svg.innerHTML = scn.html;
|
||
wrap.classList.toggle("iso-still", !st.cfg.running);
|
||
wrap.classList.toggle("iso-inside", st.mode === "inside");
|
||
metas = Array.prototype.map.call(svg.querySelectorAll(".iso-tag"), function (g, i) {
|
||
return { el: g, p: +g.getAttribute("data-p"), w: +g.getAttribute("data-w"), x: +g.getAttribute("data-x"), y: +g.getAttribute("data-y"), i: i, hid: false };
|
||
}).sort(function (a, b) { return b.p - a.p || a.i - b.i; });
|
||
Array.prototype.forEach.call(bar.querySelectorAll(".iso-seg button"), function (b) { b.setAttribute("aria-pressed", String(b.getAttribute("data-a") === st.mode)); });
|
||
var n = ROT[st.rot](0, 1), ang = Math.atan2((n[0] + n[1]) / 2, n[0] - n[1]) * 180 / Math.PI + 90;
|
||
wrap.querySelector(".iso-needle").setAttribute("transform", "rotate(" + ang.toFixed(1) + ")");
|
||
if (st.sel) select(st.sel, true);
|
||
}
|
||
function computeFit() {
|
||
var b = scn.bbox, sz = size(), ar = sz[0] / sz[1], w = b.w * 1.02, h = b.h * 1.04;
|
||
if (w / h > ar) h = w / ar; else w = h * ar;
|
||
fitW = w;
|
||
return { x: b.x + b.w / 2 - w / 2, y: b.y + b.h / 2 - h / 2, w: w, h: h };
|
||
}
|
||
function apply() {
|
||
if (!vb) return;
|
||
svg.setAttribute("viewBox", r1(vb.x) + " " + r1(vb.y) + " " + r1(vb.w) + " " + r1(vb.h));
|
||
var sz = size();
|
||
svg.style.setProperty("--iso-k", (vb.w / sz[0]).toFixed(4));
|
||
atFit = vb.w >= fitW * 0.995;
|
||
svg.style.touchAction = atFit ? "pan-y" : "none";
|
||
wrap.classList.toggle("iso-zoomed", !atFit);
|
||
schedule();
|
||
}
|
||
function fit() { vb = computeFit(); apply(); }
|
||
function clampVB() {
|
||
var b = scn.bbox, cx = vb.x + vb.w / 2, cy = vb.y + vb.h / 2;
|
||
cx = Math.max(b.x, Math.min(b.x + b.w, cx)); cy = Math.max(b.y, Math.min(b.y + b.h, cy));
|
||
vb.x = cx - vb.w / 2; vb.y = cy - vb.h / 2;
|
||
}
|
||
function zoomAt(f, px, py) { // px, py: 0..1 within the stage
|
||
if (!vb) return;
|
||
var sz = size(), nw = Math.max(fitW / 14, Math.min(fitW, vb.w / f)), nh = nw * sz[1] / sz[0];
|
||
if (px === undefined) { px = 0.5; py = 0.5; }
|
||
var ux = vb.x + px * vb.w, uy = vb.y + py * vb.h;
|
||
vb = { x: ux - px * nw, y: uy - py * nh, w: nw, h: nh };
|
||
if (nw >= fitW * 0.999) vb = computeFit(); else clampVB();
|
||
apply();
|
||
}
|
||
function zoomClient(f, cx, cy) { var r = stage.getBoundingClientRect(); zoomAt(f, (cx - r.left) / r.width, (cy - r.top) / r.height); }
|
||
function panPx(dx, dy) { var sz = size(), k = vb.w / sz[0]; vb.x -= dx * k; vb.y -= dy * k; clampVB(); apply(); }
|
||
function focusDefault() {
|
||
fit();
|
||
var sz = size();
|
||
if (sz[0] < 640 && scn) {
|
||
var f = 2.4, nw = fitW / f, nh = nw * sz[1] / sz[0];
|
||
vb = { x: scn.focus[0] - nw / 2, y: scn.focus[1] - nh / 2, w: nw, h: nh };
|
||
clampVB(); apply();
|
||
}
|
||
}
|
||
function schedule() {
|
||
if (declT) return;
|
||
declT = (window.requestAnimationFrame || setTimeout)(function () { declT = 0; declutter(); });
|
||
}
|
||
function declutter() {
|
||
if (!alive || !vb) return;
|
||
var sz = size(), k = vb.w / sz[0], kept = [], i, j, t, x, y, r, vis;
|
||
var bt = bar.offsetTop + bar.offsetHeight + 2, bw = bar.offsetLeft + bar.offsetWidth;
|
||
for (i = 0; i < metas.length; i++) {
|
||
t = metas[i]; x = (t.x - vb.x) / k; y = (t.y - vb.y) / k;
|
||
r = [x - t.w / 2 - 2, y - 23, x + t.w / 2 + 2, y - 4];
|
||
vis = r[0] >= 2 && r[2] <= sz[0] - 2 && r[1] >= 2 && r[3] <= sz[1] - 2 && !(r[1] < bt && r[0] < bw);
|
||
for (j = 0; vis && j < kept.length; j++) if (!(r[2] < kept[j][0] || r[0] > kept[j][2] || r[3] < kept[j][1] || r[1] > kept[j][3])) vis = false;
|
||
if (vis) kept.push(r);
|
||
if (t.hid === vis) { t.hid = !vis; t.el.classList.toggle("iso-hide", !vis); }
|
||
}
|
||
}
|
||
function infoOf(id) { return (scn && scn.info[id]) || { id: id, name: id, detail: "" }; }
|
||
function showTip(info, cx, cy) {
|
||
tip.innerHTML = "<b>" + esc(info.name) + "</b>" + (info.detail ? "<span>" + esc(info.detail) + "</span>" : "");
|
||
tip.hidden = false;
|
||
var r = stage.getBoundingClientRect(), tw = tip.offsetWidth, th = tip.offsetHeight, x = cx - r.left + 14, y = cy - r.top + 14;
|
||
if (x + tw > r.width - 6) x = Math.max(6, cx - r.left - tw - 14);
|
||
if (y + th > r.height - 6) y = Math.max(6, cy - r.top - th - 14);
|
||
tip.style.left = x + "px"; tip.style.top = y + "px";
|
||
}
|
||
function hideTip() { tip.hidden = true; }
|
||
function select(id, quiet) {
|
||
Array.prototype.forEach.call(svg.querySelectorAll(".iso-sel"), function (e) { e.classList.remove("iso-sel"); });
|
||
st.sel = id || null;
|
||
if (!id) return;
|
||
Array.prototype.forEach.call(svg.querySelectorAll('[data-id="' + String(id).replace(/"/g, "") + '"]'), function (e) { if (!e.classList.contains("iso-tag")) e.classList.add("iso-sel"); });
|
||
}
|
||
function targetId(t) { var e = t && t.closest ? t.closest("[data-id]") : null; return e && svg.contains(e) ? e.getAttribute("data-id") : null; }
|
||
function pick(t, cx, cy) {
|
||
var id = targetId(t);
|
||
if (!id) { select(null); hideTip(); return; }
|
||
var info = infoOf(id);
|
||
select(id);
|
||
showTip(info, cx, cy);
|
||
if (typeof opts.onPick === "function") { try { opts.onPick({ id: info.id, name: info.name, detail: info.detail }); } catch (e) { if (window.console) console.error(e); } }
|
||
}
|
||
// pointer: hover tips, drag pan, pinch zoom, tap pick
|
||
var ptrs = {}, down = null, moved = false, pinch = null, hoverId = null;
|
||
function npt() { return Object.keys(ptrs).length; }
|
||
svg.addEventListener("pointerdown", function (e) {
|
||
ptrs[e.pointerId] = { x: e.clientX, y: e.clientY };
|
||
if (npt() === 1) { down = { x: e.clientX, y: e.clientY, id: e.pointerId, t: e.target }; moved = false; }
|
||
if (npt() === 2) {
|
||
var k = Object.keys(ptrs), a = ptrs[k[0]], b = ptrs[k[1]];
|
||
pinch = { d: Math.hypot(a.x - b.x, a.y - b.y) || 1 }; moved = true;
|
||
}
|
||
});
|
||
svg.addEventListener("pointermove", function (e) {
|
||
var p = ptrs[e.pointerId];
|
||
if (p) {
|
||
var dx = e.clientX - p.x, dy = e.clientY - p.y;
|
||
p.x = e.clientX; p.y = e.clientY;
|
||
if (pinch && npt() >= 2) {
|
||
var k = Object.keys(ptrs), a = ptrs[k[0]], b = ptrs[k[1]], d = Math.hypot(a.x - b.x, a.y - b.y) || 1;
|
||
zoomClient(d / pinch.d, (a.x + b.x) / 2, (a.y + b.y) / 2); pinch.d = d; return;
|
||
}
|
||
if (down && !moved && Math.hypot(e.clientX - down.x, e.clientY - down.y) > 5) {
|
||
moved = true; hideTip();
|
||
try { svg.setPointerCapture(e.pointerId); } catch (er) { /* ignore */ }
|
||
}
|
||
if (moved && !atFit) { panPx(dx, dy); e.preventDefault(); }
|
||
return;
|
||
}
|
||
if (e.pointerType === "mouse") {
|
||
var id = targetId(e.target);
|
||
if (id) {
|
||
if (id !== hoverId) { if (hoverId) setHover(hoverId, false); setHover(id, true); hoverId = id; }
|
||
showTip(infoOf(id), e.clientX, e.clientY);
|
||
} else if (hoverId) { setHover(hoverId, false); hoverId = null; if (!st.sel) hideTip(); }
|
||
}
|
||
});
|
||
function setHover(id, on) {
|
||
Array.prototype.forEach.call(svg.querySelectorAll('[data-id="' + id + '"]'), function (e) { if (!e.classList.contains("iso-tag")) e.classList.toggle("iso-hov", on); });
|
||
}
|
||
function up(e) {
|
||
var wasDown = down && down.id === e.pointerId;
|
||
delete ptrs[e.pointerId];
|
||
if (npt() < 2) pinch = null;
|
||
if (wasDown && !moved && e.type === "pointerup") pick(e.target, e.clientX, e.clientY);
|
||
if (npt() === 0) down = null;
|
||
}
|
||
svg.addEventListener("pointerup", up);
|
||
svg.addEventListener("pointercancel", up);
|
||
svg.addEventListener("pointerleave", function (e) { if (e.pointerType === "mouse" && hoverId) { setHover(hoverId, false); hoverId = null; if (!st.sel) hideTip(); } });
|
||
svg.addEventListener("wheel", function (e) {
|
||
if (!(e.ctrlKey || e.metaKey)) return;
|
||
e.preventDefault();
|
||
zoomClient(Math.exp(-e.deltaY * 0.004), e.clientX, e.clientY);
|
||
}, { passive: false });
|
||
svg.addEventListener("dblclick", function (e) { e.preventDefault(); zoomClient(1.8, e.clientX, e.clientY); });
|
||
svg.addEventListener("keydown", function (e) {
|
||
var k = e.key;
|
||
if (k === "+" || k === "=") zoomAt(1.4); else if (k === "-") zoomAt(1 / 1.4); else if (k === "0") fit();
|
||
else if (k === "ArrowLeft") panPx(60, 0); else if (k === "ArrowRight") panPx(-60, 0); else if (k === "ArrowUp") panPx(0, 60); else if (k === "ArrowDown") panPx(0, -60);
|
||
else if (k === "Escape") { select(null); hideTip(); } else return;
|
||
e.preventDefault();
|
||
});
|
||
bar.addEventListener("click", function (e) {
|
||
var b = e.target.closest ? e.target.closest("button") : null;
|
||
if (!b) return;
|
||
var a = b.getAttribute("data-a");
|
||
if (a === "roof" || a === "inside") setMode(a);
|
||
else if (a === "rotl") rotate(-1); else if (a === "rotr") rotate(1);
|
||
else if (a === "zin") zoomAt(1.5); else if (a === "zout") zoomAt(1 / 1.5); else if (a === "fit") fit();
|
||
});
|
||
function setMode(m) {
|
||
m = m === "inside" ? "inside" : "roof";
|
||
if (m === st.mode) return;
|
||
st.mode = m; build(); computeFit(); apply();
|
||
if (typeof opts.onMode === "function") opts.onMode(m);
|
||
}
|
||
function rotate(d) {
|
||
var keepZoom = vb ? vb.w / fitW : 1;
|
||
st.rot = ((st.rot + (d || 1)) % 4 + 4) % 4; build(); hideTip();
|
||
if (keepZoom >= 0.995) fit();
|
||
else { computeFit(); var sz = size(), nw = fitW * keepZoom, nh = nw * sz[1] / sz[0]; vb = { x: scn.focus[0] - nw / 2, y: scn.focus[1] - nh / 2, w: nw, h: nh }; clampVB(); apply(); }
|
||
}
|
||
var ro = null, io = null;
|
||
function onResize() {
|
||
var sz = size();
|
||
if (Math.abs(sz[0] - lastSize[0]) < 0.5 && Math.abs(sz[1] - lastSize[1]) < 0.5) return;
|
||
var old = lastSize; lastSize = sz;
|
||
if (!vb || !old[0]) { focusDefault(); return; }
|
||
var wasFit = atFit, k = vb.w / old[0], cx = vb.x + vb.w / 2, cy = vb.y + vb.h / 2;
|
||
computeFit();
|
||
if (wasFit) { fit(); return; }
|
||
var nw = Math.min(fitW, k * sz[0]), nh = nw * sz[1] / sz[0];
|
||
vb = { x: cx - nw / 2, y: cy - nh / 2, w: nw, h: nh }; clampVB(); apply();
|
||
}
|
||
if (window.ResizeObserver) { ro = new ResizeObserver(onResize); ro.observe(stage); }
|
||
else window.addEventListener("resize", onResize);
|
||
if (window.IntersectionObserver) {
|
||
io = new IntersectionObserver(function (es) { es.forEach(function (en) { wrap.classList.toggle("iso-off", !en.isIntersecting); }); });
|
||
io.observe(stage);
|
||
}
|
||
build();
|
||
lastSize = size();
|
||
focusDefault();
|
||
|
||
var view = {
|
||
el: wrap,
|
||
svg: svg,
|
||
update: function (c) { st.raw = Object.assign({}, st.raw, c || {}); st.cfg = norm(st.raw); build(); var wasFit = atFit; computeFit(); if (wasFit) fit(); else { clampVB(); apply(); } return view; },
|
||
setMode: function (m) { setMode(m); return view; },
|
||
getMode: function () { return st.mode; },
|
||
rotate: function (d) { rotate(d); return view; },
|
||
setRotation: function (r) { var d = (((r | 0) - st.rot) % 4 + 4) % 4; if (d) rotate(d); return view; },
|
||
select: function (id) { select(id); return view; },
|
||
// centre the view on an object id (or a world point [x, y, z]) at a zoom factor relative to fit
|
||
focus: function (target, f) {
|
||
var c = null;
|
||
if (Array.isArray(target)) c = new Proj(st.rot).s(target[0], target[1], target[2] || 0);
|
||
else if (target) {
|
||
var bx = [1e9, 1e9, -1e9, -1e9];
|
||
Array.prototype.forEach.call(svg.querySelectorAll('[data-id="' + String(target).replace(/"/g, "") + '"]'), function (e) {
|
||
if (e.classList.contains("iso-tag")) return;
|
||
try { var b = e.getBBox(); if (b.width || b.height) { bx[0] = Math.min(bx[0], b.x); bx[1] = Math.min(bx[1], b.y); bx[2] = Math.max(bx[2], b.x + b.width); bx[3] = Math.max(bx[3], b.y + b.height); } } catch (er) { /* not rendered */ }
|
||
});
|
||
if (bx[0] < 1e9) c = [(bx[0] + bx[2]) / 2, (bx[1] + bx[3]) / 2];
|
||
}
|
||
if (!c) return view;
|
||
computeFit();
|
||
var sz = size(), nw = fitW / Math.max(1, f || 3), nh = nw * sz[1] / sz[0];
|
||
vb = { x: c[0] - nw / 2, y: c[1] - nh / 2, w: nw, h: nh }; clampVB(); apply();
|
||
return view;
|
||
},
|
||
zoom: function (f) { zoomAt(f || 1.5); return view; },
|
||
fit: function () { fit(); return view; },
|
||
info: function (id) { return id ? infoOf(id) : Object.keys(scn.info).map(infoOf); },
|
||
config: function () { return Object.assign({}, st.cfg); },
|
||
stats: function () { return { nodes: svg.querySelectorAll("*").length, sprites: scn.sprites, presses: scn.alloc, pv: { want: scn.pv.want, placed: scn.pv.placed, left: scn.pv.left } }; },
|
||
destroy: function () {
|
||
alive = false;
|
||
if (ro) ro.disconnect(); else window.removeEventListener("resize", onResize);
|
||
if (io) io.disconnect();
|
||
if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
|
||
}
|
||
};
|
||
return view;
|
||
}
|
||
|
||
root.CNXIso = { mount: mount, defaults: function () { return Object.assign({}, DEF); }, normalize: norm, version: VERSION,
|
||
_build: function (cfg, mode, rot) { return buildScene(norm(cfg), mode, rot || 0, 0); } };
|
||
})(typeof window !== "undefined" ? window : this);
|
||
</script>
|
||
<script>
|
||
"use strict";
|
||
// ---------------------------------------------------------------- inputs
|
||
const S0 = {
|
||
mode: "target", target: 850000, spec: 20, grow: "classic", dMin: 1, cMin: 1,
|
||
dFleet: 1, cFleet: 1, shifts: 2, hrs: 8, days: 300, eff: 65, rateD: 3, rateC: 2, specF: 100,
|
||
priceStd: 3.8, premSpec: 1.15, titleFee: 1155, cur: "USD", boi: "on", exportShare: 70, capexEnd: "mid",
|
||
// plant configuration (defaults = the launch plant, total-investment.md October 2026)
|
||
galv: "in", galvPrice: "pheenix", cut: "in", metro: "full", cass: "on", cdr: "on", countCD: "off",
|
||
solar: "buy", steamFuel: "electric", sourcing: "thai", crane: "buy", corridor: "now", reserve: "in", stSupply: 0,
|
||
};
|
||
// allowed values of every switch, default first where it matters for readHash
|
||
const ENUM = {
|
||
mode: ["target", "fleet"], grow: ["classic", "duplex"], cur: ["USD", "THB", "EUR"], boi: ["on", "off"],
|
||
capexEnd: ["low", "mid", "high"], galv: ["in", "bought"], galvPrice: ["pheenix", "oms"], cut: ["in", "out"],
|
||
metro: ["full", "launch"], cass: ["on", "off"], cdr: ["on", "off"], countCD: ["off", "on"],
|
||
solar: ["buy", "ppa", "none"], steamFuel: ["electric", "biomass"], sourcing: ["thai", "china"],
|
||
crane: ["buy", "rent"], corridor: ["now", "later"], reserve: ["in", "sep"],
|
||
};
|
||
const CFGK = ["galv", "galvPrice", "cut", "metro", "cass", "cdr", "countCD", "solar", "steamFuel", "sourcing", "crane", "corridor", "reserve", "stSupply"];
|
||
// [group, id, label, value, unit, kind, source]
|
||
const AS = [];
|
||
function grp(g, rows) { rows.forEach(r => AS.push([g].concat(r))); }
|
||
// a low-to-high pair, shown in one row of the assumptions table: [id, label, lo, hi, unit, kind, source]
|
||
function rng(g, rows) { rows.forEach(([id, l, lo, hi, u, k, s]) => { AS.push([g, id + "Lo", l, lo, u, k, s, 1]); AS.push([g, id + "Hi", l, hi, u, k, s, 2]); }); }
|
||
grp("Personnel", [
|
||
["opD", "Operators per Duplex system, per shift", 1, "", "doc", "Duplex cell layout: one operator in the middle of each cell (pressing-line.md)"],
|
||
["cPerAtt", "Classics one attendant looks after", 4, "", "est", "Assumption: Newbilt has not stated it"],
|
||
["finRate", "Finishing output per person", 200, "records/h", "est", "Inspection, sleeving, packing; in line with scale.md (4 to 6 press and finishing people per shift for the start fleet)"],
|
||
["galvRate", "Electroformed parts per galvanics operator", 20, "a day", "vendor", "OMS rule of thumb: one person runs about 20 stampers a day (galvanics.md)"],
|
||
]);
|
||
grp("Per press (HRP 12; a Classic counts as one)", [
|
||
["pressKVA", "Connected load", 1, "kVA", "vendor", "Newbilt Duplex site requirements 3.2.1"],
|
||
["pressKW", "Average power", 0.5, "kW", "vendor", "Site requirements 3.2.1"],
|
||
["steamLo", "Steam, low", 150, "kg/h", "vendor", "Site requirements 3.2.3: 150 to 180 kg/h, 10 bar, 180 °C"],
|
||
["steamHi", "Steam, high", 180, "kg/h", "vendor", "Site requirements 3.2.3"],
|
||
["coolPress", "Cooling capacity, peak", 125, "kW", "vendor", "Site requirements 3.2.2: up to 125 kW depending on cycle time"],
|
||
["waterTyp", "Cooling water, typical", 53, "l/min", "vendor", "Site requirements 3.2.2: about 53 l/min used"],
|
||
["waterPeak", "Cooling water, peak", 100, "l/min", "vendor", "Site requirements 3.2.2: up to 100 l/min"],
|
||
["airPress", "Compressed air", 6, "m³/h", "vendor", "Site requirements 3.2.3: 6 to 10 bar"],
|
||
]);
|
||
grp("Per extruder (EX 50; EX 35 and Classic extruder at the same figures)", [
|
||
["extKVA", "Connected load", 15, "kVA", "vendor", "Site requirements 3.3.1 (EX 35 and Classic: estimate)"],
|
||
["extKW", "Average power", 8, "kW", "vendor", "Site requirements 3.3.1"],
|
||
["extAir", "Compressed air", 2, "m³/h", "vendor", "Site requirements 3.3.2"],
|
||
["extWater", "Cooling water", 4, "l/min", "vendor", "Site requirements 3.3.3"],
|
||
["extCool", "Cooling capacity", 3, "kW", "vendor", "Site requirements 3.3.3: about 2 to 3 kW"],
|
||
]);
|
||
grp("Central hydraulics, per press (Newbilt's aggregate serves two)", [
|
||
["hydFlow", "Pump flow", 16, "l/min", "vendor", "Offer 240620-04: 32 l/min pump per aggregate"],
|
||
["hydMotor", "Installed motor power", 7.5, "kW", "vendor", "Offer 240620-04: 11/15 kW motor per aggregate"],
|
||
["hydKVA", "Connected load", 7.5, "kVA", "vendor", "Site requirements 3.4.1: 15 kVA per aggregate"],
|
||
["hydKW", "Average power", 6, "kW", "vendor", "Site requirements 3.4.1: 12 kW per aggregate"],
|
||
["hydOil", "Oil fill (HLP 46)", 100, "l", "vendor", "Site requirements 3.4.3: 200 l per aggregate, 250 l tank"],
|
||
["hydAcc", "Accumulator volume", 25, "l", "vendor", "Offer 240620-04: 50 l per aggregate, up to 200 bar"],
|
||
["hydWater", "Oil cooler water", 4, "l/min", "vendor", "Site requirements 3.4.2: about 8 l/min per aggregate"],
|
||
["hydCool", "Oil cooler capacity", 2.5, "kW", "vendor", "Site requirements 3.4.2: up to 5 kW per aggregate"],
|
||
]);
|
||
grp("Utilities to supply it", [
|
||
["cop", "Chiller COP", 4, "", "est", "Estimate for a water-cooled chiller"],
|
||
["compSpec", "Compressor specific power", 0.11, "kW per m³/h", "est", "Estimate: screw compressor at about 6.5 kW per m³/min"],
|
||
]);
|
||
grp("Floor", [
|
||
["floorD", "Press floor per Duplex system", 110, "m²", "doc", "Payap model bay, 9.5 × 11.4 m; Newbilt recommends about 200 m² including storage and side rooms"],
|
||
["floorC", "Press floor per Classic", 50, "m²", "est", "Payap model position, 5.5 × 9 m; pressing-line.md estimates 50 to 75 m²"],
|
||
]);
|
||
grp("Material", [
|
||
["weight", "Average record weight", 160, "g", "est", "Estimate across the 140 and 180 g formats"],
|
||
["trim", "Edge trim per record", 30, "g", "vendor", "Newbilt granulator flyer: 20 to 40 g per 12-inch record, back as regrind"],
|
||
["reject", "Rejects", 5, "%", "est", "Estimate; Newbilt claims at least 95 % yield for the Classic"],
|
||
]);
|
||
grp("Stampers", [
|
||
["runLen", "Records per title", 3000, "", "doc", "Revenue model: 2,500 to 3,300 records per title"],
|
||
["stLife", "Pressings per stamper", 1500, "", "doc", "galvanics.md: 1,000 to 2,000"],
|
||
]);
|
||
grp("Mastering, galvanics and stamper prep", [
|
||
["lathe", "Lacquer sides one lathe cuts a day", 8, "sides", "est", "Estimate: about 20 min cutting per side plus setup and checks, one shift"],
|
||
["galvEff", "Usable share of galvanics and prep hours", 80, "%", "est", "Estimate; OMS: 6 to 8 runs on 4 cells in a 7.5-hour day (galvanics.md). Galvanics runs the plant's shifts"],
|
||
["silverMin", "Silvering per lacquer (PASU)", 15, "min", "est", "galvanics.md: ~15 min, estimate"],
|
||
["preMin", "Pre-plate per master (PPPU, four at once)", 25, "min", "doc", "galvanics.md: 15 to 30 min"],
|
||
["fatherMin", "Father in a PDPU cell", 75, "min", "doc", "galvanics.md: ~1 h 15 min"],
|
||
["motherMin", "Mother in a PDPU cell", 70, "min", "doc", "galvanics.md: ~1 to 1 1/4 h"],
|
||
["stamperMin", "Stamper in a PDPU cell", 45, "min", "doc", "galvanics.md: ~45 min"],
|
||
["handleMin", "Loading, rinsing, unloading per part", 15, "min", "doc", "galvanics.md: ~15 min on top of deposition"],
|
||
["pmpbMin", "Separation, passivation, cleaning per part (PMPB)", 12, "min", "est", "galvanics.md: ~10 to 15 min, estimate"],
|
||
["vsfMin", "Back-finish per stamper (Sibert VSF)", 8, "min", "est", "galvanics.md: 5 to 10 min, estimate; Sibert publishes none"],
|
||
["vspMin", "Centre punch per stamper (Sibert VSP)", 4, "min", "est", "galvanics.md: 3 to 5 min, estimate"],
|
||
["vscMin", "Coin per stamper (Sibert VSC)", 2, "min", "est", "galvanics.md: 1 to 2 min, estimate"],
|
||
]);
|
||
grp("Money", [
|
||
["thbUsd", "THB per USD", 33, "THB", "doc", "revenue-model.md and total-investment.md, FX of 5 August 2026"],
|
||
["usdEur", "USD per EUR", 1.155, "USD", "doc", "revenue-model.md"],
|
||
["overheads", "Cash overheads a year", 330000, "USD", "doc", "revenue-model.md: 200 to 450k (admin, sales, insurance, facility, IT, fees, marketing)"],
|
||
["deprYears", "Depreciation period", 12.5, "years", "doc", "revenue-model.md: 10 to 15 years"],
|
||
]);
|
||
grp("BOI and imports", [
|
||
["dutyMach", "Import duty on imported machinery without BOI", 5, "%", "est", "Estimate: Thai MFN averages 4.1 % on HS 8477 plastics machinery (customs-compliance.ai, March 2026), 0 to 10 % across HS 84; set per line once the HS codes are known. Imported machinery = the selected presses, galvanics line, Sibert, cutting room, metrology and ZLD/DI blocks"],
|
||
["vatRate", "VAT", 7, "%", "doc", "Thai VAT; with BOI duty exemption the import VAT is exempt too (Revenue Department, BOI OSOS FAQ; boi.md)"],
|
||
["pvcImp", "Share of PVC compound imported", 100, "%", "est", "Estimate: record-grade compound from the specialist suppliers"],
|
||
["dutyPVC", "Import duty on PVC compound without BOI", 5, "%", "est", "Estimate; no Thai tariff line checked for record compound yet"],
|
||
["boiRawRed", "BOI duty reduction on raw material for domestic sales", 90, "%", "doc", "boi.md: exempt for export production (section 36(1)), up to 90 % off for domestic sales (section 30)"],
|
||
]);
|
||
grp("Materials and packaging", [
|
||
["pvcPrice", "PVC compound", 2.3, "USD/kg", "doc", "revenue-model.md: 1.75 to 2.90 USD/kg record grade"],
|
||
["labelCost", "Labels, a pair", 0.06, "USD", "doc", "revenue-model.md: 0.03 to 0.09"],
|
||
["sleeveCost", "Inner sleeve", 0.13, "USD", "doc", "revenue-model.md: 0.09 to 0.17, poly-lined"],
|
||
["jacketCost", "Jacket (printed carton)", 0.66, "USD", "doc", "revenue-model.md: 0.40 to 0.92, local carton partners"],
|
||
["shrinkCost", "Shrink wrap", 0.025, "USD", "doc", "revenue-model.md: 0.02 to 0.03"],
|
||
["cartonCost", "Shipping cartons and pallets, a record", 0.04, "USD", "est", "Estimate: a 25-record shipping carton at about 1 USD"],
|
||
["maintCost", "Maintenance, spares, consumables, a record", 0.17, "USD", "doc", "revenue-model.md: 0.12 to 0.23"],
|
||
]);
|
||
grp("Stamper making", [
|
||
["lacquerCost", "Lacquer blank, one per side", 70, "EUR", "user", "Team figure, October 2026 (revenue-model.md); styli not included"],
|
||
["chemPart", "Nickel, silver, chemistry, an electroformed part", 25, "USD", "doc", "revenue-model.md: 60 to 150 USD marginal per three-generation set, so 20 to 50 a part"],
|
||
]);
|
||
grp("Energy", [
|
||
["kwhPrice", "Electricity, blended", 3.81, "THB/kWh", "doc", "scale.md: PEA TOU 3.2.2 at two shifts, before the demand charge (Ft September to December 2026)"],
|
||
["demandPrice", "Demand charge", 132.93, "THB/kW a month", "doc", "PEA 3.2.2 (scale.md)"],
|
||
["restKW", "Rest of the plant while running", 120, "kW", "est", "Estimate: HVAC, lighting, galvanics, finishing, offices; puts the start fleet in scale.md's 4.5 to 6.8M THB a year before PV"],
|
||
["pvKWp", "Roof PV", 247, "kWp", "doc", "scale.md"],
|
||
["pvYield", "PV yield, all self-consumed", 1400, "kWh/kWp", "doc", "scale.md: 1,400 to 1,500 kWh/kWp a year in northern Thailand"],
|
||
["heatPrice", "Steam heat from biomass", 0.7, "THB/kWh", "doc", "biomass.md: 0.5 to 0.9 at 1.35 to 2 THB/kg delivered"],
|
||
["steamKWh", "Heat per kg of steam", 0.68, "kWh/kg", "est", "Estimate: 10 bar saturated steam from ~80 °C feed water"],
|
||
["eBoilEff", "Electric boiler efficiency", 98, "%", "est", "Estimate for an electric steam boiler (~98 %)"],
|
||
["eBoilKW", "Electric boiler demand per installed module", 360, "kW", "est", "Estimate: a 500 kg/h electric module at full load; the demand charge counts every installed module, so it errs high"],
|
||
["ppaPrice", "Solar PPA power price", 3.0, "THB/kWh", "est", "Estimate, to quote: the EPC owns and finances the array, the plant buys its yield (total-investment.md investor levers)"],
|
||
]);
|
||
grp("Labour: base salaries, THB a month", [
|
||
["salOp", "Press and finishing operator", 15000, "THB", "doc", "revenue-model.md: ~19,500 loaded at ×1.3"],
|
||
["salQC", "QC", 20000, "THB", "doc", "revenue-model.md: ~26,000 loaded"],
|
||
["salMaint", "Maintenance", 25000, "THB", "doc", "revenue-model.md: ~32,500 loaded"],
|
||
["salGalv", "Galvanics", 25000, "THB", "doc", "revenue-model.md: ~32,500 loaded"],
|
||
["salCut", "Cutting engineer", 50000, "THB", "user", "Team figure, October 2026 (revenue-model.md)"],
|
||
["officeHeads", "Admin, sales and logistics staff", 4, "people", "doc", "scale.md: 3 to 5; their wages sit in the overheads, they count here for health care"],
|
||
["qcPerShift", "QC inspectors per shift", 1, "", "est", "Estimate; scale.md: 1 to 2 QC"],
|
||
["pressPerTech", "Presses per maintenance technician (at least 2)", 8, "", "est", "Estimate; scale.md: 2 in maintenance for the start fleet"],
|
||
]);
|
||
grp("Labour: on-costs and health care", [
|
||
["ssRate", "Employer social security", 5, "% of salary", "doc", "Social Security Act, section 33: 5 % up to the wage ceiling (worker-safety.md)"],
|
||
["ssCap", "Social security wage ceiling", 17500, "THB a month", "doc", "Raised from 15,000 on 1 January 2026, so at most 875 THB a month (worker-safety.md)"],
|
||
["otherOn", "Overtime, bonus, PPE and other on-costs", 25, "% of salary", "doc", "With social security this is the revenue model's ×1.3 at 15,000 THB"],
|
||
["healthIns", "Group health insurance (inpatient and outpatient)", 15000, "THB a head a year", "doc", "worker-safety.md: AXA SME plans 9,394 to 18,006 THB at 25 staff (CheckDi, October 2026); local plans 8,000 to 12,000"],
|
||
["checkup", "Annual health check-up", 2500, "THB a head a year", "est", "Estimate for an age mix; Rajavej Chiang Mai 2026 programmes 1,290 to 5,990 THB by age (worker-safety.md)"],
|
||
["occCheck", "Risk-factor tests (hearing, lungs, metals)", 1500, "THB a head a year", "est", "Estimate; required yearly for press floor, galvanics and maintenance (Ministerial Regulation B.E. 2547, worker-safety.md)"],
|
||
]);
|
||
grp("Workwear: Engelbert Strauss for all staff", [
|
||
["wwProd", "Production kit, with logo", 338, "EUR", "doc", "worker-safety.md: 3 trousers e.s.motion 2020, 3 polos, 2 T-shirts, sweatshirt, S1 safety shoes; Strauss prices without German VAT, October 2026; embroidery ~5 EUR a piece (estimate)"],
|
||
["wwOffice", "Office kit, with logo", 127, "EUR", "doc", "worker-safety.md: 4 polo shirts and a softshell jacket"],
|
||
["wwProdYr", "Production kits a head a year", 1, "", "est", "Estimate: daily wear and washing"],
|
||
["wwOffYr", "Office kits a head a year", 0.5, "", "est", "Estimate"],
|
||
["wwFreight", "Shipping to Thailand", 15, "% of goods", "est", "Estimate; Strauss quotes shipping with the order confirmation"],
|
||
["wwDuty", "Thai import duty on workwear", 14, "%", "doc", "HS 6203 work trousers 13.8 % (customs-compliance.ai, March 2026); BOI does not cover clothing; import VAT reclaimable"],
|
||
]);
|
||
grp("Planned plant, for the checks", [
|
||
["boilerMod", "Steam boiler module", 500, "kg/h", "doc", "scale.md utility phasing and steam-cooling.html: 500 kg/h modules, B-1 and B-2 at launch (proposal, estimate)"],
|
||
["chillerMod", "Chiller module", 250, "kW", "doc", "scale.md launch plant: 250 kW air-cooled modules at 40 °C, CH-1 and CH-2 at launch (proposal, estimate)"],
|
||
["modRooms", "Modules the boiler hall and the chiller compound are built for", 4, "each", "doc", "Utility yard decided October 2026: rooms for four boiler and four chiller modules (scale.md)"],
|
||
["hall", "Payap press hall", 870, "m²", "doc", "site-candidates.md, Payap scenario model"],
|
||
]);
|
||
grp("Title services and add-on lines", [
|
||
["titleContrib", "Contribution on title services done in-house", 65, "%", "doc", "revenue-model.md: 60 to 70 % after lacquers, styli and nickel (adverse 60, central 65, favourable 70)"],
|
||
["shareCut", "Share of the title fee: mastering, lacquer and cutting", 51.1, "%", "est", "Artist demos' per-title price 37,836 THB: mastering 10,000 + lacquers 5,336 + cutting 4,000 THB (artist-order.html price list, estimates)"],
|
||
["sharePlate", "Share of the title fee: electroforming (stampers)", 37.0, "%", "est", "Same price list: father, mother and stamper 14,000 THB of 37,836"],
|
||
["shareSetup", "Share of the title fee: press setup and test pressings", 11.9, "%", "est", "Same price list: setup 3,000 + five test pressings 1,500 THB of 37,836"],
|
||
["stSupplyC", "Stamper supply to other plants: contribution a title", 267, "USD", "est", "Business plan draft 0.1 and revenue-model.md: 300 titles at wholesale might net 60k to 100k USD, so 200 to 333 a title (adverse 200, favourable 333)"],
|
||
["cassN", "Cassettes a year, if counted", 40000, "", "est", "Business plan draft 0.1: illustratively 20k to 60k a year; no market data"],
|
||
["cassPrice", "Cassette price", 3.25, "USD", "est", "Business plan draft 0.1: 2.50 to 4.00 USD; no market data"],
|
||
["cdrN", "CD-Rs a year, if counted", 25000, "", "est", "Business plan draft 0.1: illustratively 10k to 40k a year; no market data"],
|
||
["cdrPrice", "CD-R price", 2.25, "USD", "est", "Business plan draft 0.1: 1.50 to 3.00 USD; no market data"],
|
||
]);
|
||
rng("Investment blocks (USD unless THB)", [
|
||
["p0", "Phase 0: BOI consultant, legal and lease work, soil test, re-quotes", 40000, 80000, "USD", "est", "total-investment.md launch plant, estimate"],
|
||
["dup1", "Newbilt Duplex system, the first", 350000, 370000, "USD", "doc", "total-investment.md: 2024 quote EUR 200,500 for the Single 2.1 plus the quoted second press and extruder ~USD 115k; expired, re-quote"],
|
||
["dupN", "Each further Duplex system", 230000, 300000, "USD", "est", "total-investment.md added scope: two presses at the quoted ~115k per press and extruder set, plus hydraulic unit and EX 35; no EX 35 price on file"],
|
||
["cla", "Each Newbilt Classic", 290000, 460000, "USD", "est", "total-investment.md: placeholder (the old AD12 band per press); no Newbilt price on file"],
|
||
["gPh", "Pheenix galvanics line (PASU, PPPU, PDPU, PMPB), Pheenix estimate", 320000, 650000, "USD", "est", "total-investment.md: no Pheenix price on file"],
|
||
["gOms", "Galvanics line priced near OMS's reference", 259000, 359000, "USD", "vendor", "OMS letter, 9 October 2026: 4-cell wet room EUR 224k basic to 311k advanced, at 1.155 USD; used as a price basis for the Pheenix line (investor lever)"],
|
||
["gWat", "Galvanics water: ZLD evaporator and DI water", 3200000, 8500000, "THB", "est", "Utilities CSV rows ZLD 3.0 to 8.0M and RO/DI 0.2 to 0.5M THB, market estimates"],
|
||
["wtb", "Water-treatment building next to galvanics, ~12 × 10 m", 1700000, 2900000, "THB", "est", "total-investment.md: estimate at the building rate"],
|
||
["sib", "Sibert VSF, VSP, VSC", 250000, 250000, "USD", "vendor", "Sibert's budget confirmation, 5 October 2026; its time, spares, shipping and travel in the freight and working-capital lines"],
|
||
["cutR", "Cutting room: Sillitoe SDMS, spare cutterhead, console and chain, monitoring, room", 200000, 350000, "USD", "est", "SDMS list price USD 125k (vendor, ex tax and shipping); the rest estimate (total-investment.md)"],
|
||
["metF", "Metrology, full stack (XRF, laser confocal, wide-area profiler, inline vision, audio station)", 350000, 810000, "USD", "est", "total-investment.md, quality-automation.md; phaseable"],
|
||
["metL", "Metrology, launch tier (XRF, inline vision, audio station)", 170000, 460000, "USD", "est", "total-investment.md investor lever: confocal and profiler bought from revenue, saves ~10.1M THB at the midpoint"],
|
||
["pvcL", "PVC lab, day-one tier", 55000, 150000, "USD", "est", "quality-automation.md"],
|
||
["bTh", "Electric boiler module, 500 kg/h, Thai supplier, installed", 2250000, 3500000, "THB", "est", "Utilities CSV: bank of two 4.5 to 7.0M THB, engineering estimate"],
|
||
["bCn", "Electric boiler module, 500 kg/h, Chinese route, installed", 1400000, 2750000, "THB", "est", "china-utilities.md: two ASME-stamped modules 2.8 to 5.5M THB installed"],
|
||
["bBio", "Biomass boiler module, 500 kg/h, installed", 3000000, 5000000, "THB", "est", "Utilities CSV: biomass package 6 to 10M THB for ~1 t/h (Getabec class), estimate"],
|
||
["cBk", "Cooling backbone: plate HX, tanks, pumps, mains for four modules", 1700000, 2700000, "THB", "est", "Utilities CSV: chiller bank of two 4.9 to 7.9M THB less two modules"],
|
||
["chM", "Chiller module, 250 kW air-cooled, installed", 1600000, 2600000, "THB", "est", "Utilities CSV: one module ~1.6 to 2.6M THB, estimate"],
|
||
["airT", "Compressed air, Thai supplier", 350000, 600000, "THB", "est", "Utilities CSV: screw compressor skid, dryer, receiver"],
|
||
["airC", "Compressed air, Chinese brand through its Thai office", 150000, 300000, "THB", "est", "china-utilities.md: PM VSD screw set 0.15 to 0.3M THB installed"],
|
||
["clR", "Clean-room adder (galvanics and cutting room)", 1200000, 3400000, "THB", "est", "Utilities CSV, ~2 × 60 m² ISO 7/8; half with only one of the two rooms (estimate)"],
|
||
["uRest", "Rest of the utilities baseline: softener, loop treatment, racking, whole-factory HVAC", 8620000, 17300000, "THB", "est", "Utilities CSV baseline 28.47 to 48.73M THB less boilers, chillers, air, clean room, PV and battery"],
|
||
["pv", "Rooftop PV EPC", 7500000, 10500000, "THB", "est", "Utilities CSV: illustrative 300 kWp at 25,000 to 35,000 THB/kWp; final kWp open (scale.md 247 kWp for the yield)"],
|
||
["bat", "Battery 271 kWh (Gobel)", 1400000, 2030000, "THB", "vendor", "Utilities CSV: vendor listed price USD 39,900 to 58,000"],
|
||
["bld", "Building ~1,500 m² plus civil and site works", 650000, 1100000, "USD", "est", "total-investment.md: 12,000 to 20,000 THB/m² plus ~20 % site works; soil is the main variable"],
|
||
["ph2", "Phase 2 building, when the press floor outgrows the press hall", 750000, 1250000, "USD", "est", "Business plan draft 0.1: ~750k to 1,250k on 1,710 m², estimate"],
|
||
["crn", "Overhead crane, runway, frame premium, machine door", 97000, 227000, "USD", "est", "crane-workshop-tours.md via total-investment.md"],
|
||
["wsh", "Maintenance workshop and machine bay fit-out", 30000, 76000, "USD", "est", "total-investment.md"],
|
||
["cor", "Visitor corridor and accessible entrance", 67000, 148000, "USD", "est", "total-investment.md"],
|
||
["yard", "Utility yard for four boiler and four chiller modules", 73000, 122000, "USD", "est", "scale.md, total-investment.md"],
|
||
["fsw", "Factory software", 60000, 170000, "USD", "est", "total-investment.md, digital-factory.md"],
|
||
["erp", "ERPNext implementation", 15000, 50000, "USD", "est", "digital-factory.md"],
|
||
["apf", "Artist platform, public side", 40000, 90000, "USD", "est", "artist-platform.md"],
|
||
["cas", "Cassette duplication", 40000, 150000, "USD", "est", "Business plan section 4.8 (total-investment.md: with CD-R 55k to 190k)"],
|
||
["cdr", "CD-R duplication", 15000, 40000, "USD", "est", "Business plan section 4.9"],
|
||
["frt", "Freight, installation, commissioning, training at the launch plant", 150000, 300000, "USD", "est", "total-investment.md; scaled by the Newbilt, Pheenix, Sibert and Sillitoe machinery against the launch plant (estimate)"],
|
||
["wc", "Working capital, PVC stock, spares, lease deposit, first-year rent", 135000, 290000, "USD", "est", "total-investment.md; scaled by records a year against 0.84M (estimate)"],
|
||
["res", "Ramp-up reserve: staff and overheads before volume covers them", 10000000, 20000000, "THB", "est", "total-investment.md: not in the roll-up, ~10M to 20M THB"],
|
||
]);
|
||
grp("Investment rules", [
|
||
["contPct", "Contingency", 15, "%", "doc", "total-investment.md"],
|
||
["craneRent", "Crane rented for the installations, share of the crane block", 40, "%", "est", "Frame stays crane-ready; total-investment.md: saves ~3.7M THB at the midpoint"],
|
||
["wcRef", "Records a year the working capital is sized for", 840000, "", "doc", "revenue-model.md launch plant, central volume"],
|
||
["wcMin", "Working capital never below", 60, "%", "est", "Estimate"],
|
||
["budgetLo", "Investor's budget", 180000000, "THB", "doc", "total-investment.md: the investor's question, 180M THB"],
|
||
["budgetHi", "Investor's budget plus 20 %", 216000000, "THB", "doc", "total-investment.md: 216M THB"],
|
||
]);
|
||
grp("Ten years of cash", [
|
||
["ramp1", "Sold output in year 1", 35, "% of the mature year", "est", "Estimate"],
|
||
["ramp2", "Sold output in year 2", 70, "% of the mature year", "est", "Estimate"],
|
||
["ramp3", "Sold output in year 3", 90, "% of the mature year", "est", "Estimate"],
|
||
["ramp4", "Sold output from year 4", 100, "% of the mature year", "est", "Estimate"],
|
||
["discRate", "Discount rate for the NPV", 10, "%", "est", "Estimate"],
|
||
]);
|
||
const A0 = Object.fromEntries(AS.map(r => [r[1], r[3]]));
|
||
const KIND = { vendor: "vendor", doc: "doc", est: "estimate", user: "your figure" };
|
||
// revenue-model.md ranges: adverse pairs low price with high cost and the high capex end, favourable the reverse
|
||
const CASES = {
|
||
adverse: { S: { priceStd: 3.4, premSpec: 0.6, titleFee: 580, capexEnd: "high" },
|
||
A: { pvcPrice: 2.9, labelCost: 0.09, sleeveCost: 0.17, jacketCost: 0.92, shrinkCost: 0.03, maintCost: 0.23, overheads: 450000, heatPrice: 0.9, deprYears: 10,
|
||
titleContrib: 60, stSupplyC: 200, cassN: 20000, cassPrice: 2.5, cdrN: 10000, cdrPrice: 1.5 } },
|
||
central: { S: { priceStd: 3.8, premSpec: 1.15, titleFee: 1155, capexEnd: "mid" },
|
||
A: { pvcPrice: 2.3, labelCost: 0.06, sleeveCost: 0.13, jacketCost: 0.66, shrinkCost: 0.025, maintCost: 0.17, overheads: 330000, heatPrice: 0.7, deprYears: 12.5,
|
||
titleContrib: 65, stSupplyC: 267, cassN: 40000, cassPrice: 3.25, cdrN: 25000, cdrPrice: 2.25 } },
|
||
favourable: { S: { priceStd: 4.25, premSpec: 1.7, titleFee: 1730, capexEnd: "low" },
|
||
A: { pvcPrice: 1.75, labelCost: 0.03, sleeveCost: 0.09, jacketCost: 0.4, shrinkCost: 0.02, maintCost: 0.12, overheads: 200000, heatPrice: 0.5, deprYears: 15,
|
||
titleContrib: 70, stSupplyC: 333, cassN: 60000, cassPrice: 4, cdrN: 40000, cdrPrice: 3 } },
|
||
};
|
||
function caseNow() {
|
||
for (const k in CASES) {
|
||
const c = CASES[k];
|
||
if (Object.keys(c.S).every(x => S[x] === c.S[x]) && Object.keys(c.A).every(x => A[x] === c.A[x])) return k;
|
||
}
|
||
return "";
|
||
}
|
||
const LADDER = [
|
||
["Launch plant", 1, 1], ["Second Duplex", 2, 1], ["Step 1", 2, 3], ["Step 2", 2, 5], ["Step 3", 2, 8],
|
||
["Full hall, Classic-led", 2, 11], ["Full hall, mixed", 4, 7], ["Full hall, specials-led", 6, 3],
|
||
];
|
||
// presets: fleet plus plant configuration; prices, case and assumptions stay as they are
|
||
const CFG0 = Object.fromEntries(CFGK.map(k => [k, S0[k]]));
|
||
const LAUNCH = { mode: "target", target: 850000, grow: "classic", dMin: 1, cMin: 1, dFleet: 1, cFleet: 1, ...CFG0 };
|
||
const PRESETS = {
|
||
launch: LAUNCH,
|
||
levers: { ...LAUNCH, solar: "ppa", metro: "launch", galvPrice: "oms", corridor: "later" },
|
||
lean: { ...LAUNCH, galv: "bought", cut: "out" },
|
||
five: { mode: "fleet", dFleet: 2, cFleet: 1, ...CFG0 },
|
||
full: { mode: "fleet", dFleet: 2, cFleet: 11, ...CFG0 },
|
||
};
|
||
|
||
let S = { ...S0 }, A = { ...A0 };
|
||
|
||
// ---------------------------------------------------------------- model
|
||
function basis(S) {
|
||
const hoursYr = S.shifts * S.hrs * S.days, eff = S.eff / 100;
|
||
return {
|
||
hoursYr,
|
||
capD: Math.max(0, S.rateD) * 60 * hoursYr * eff,
|
||
capC: Math.max(0, S.rateC) * 60 * hoursYr * eff,
|
||
s: Math.min(1, Math.max(0, S.spec / 100)),
|
||
f: Math.min(1, Math.max(0.1, S.specF / 100)),
|
||
};
|
||
}
|
||
// Largest yearly output a fleet makes at specials share s: specials only on
|
||
// Duplex (at rate factor f), standard on Classics first, then on spare Duplex time.
|
||
function capacityOf(D, C, b) {
|
||
const x1 = (D * b.capD + C * b.capC) / (b.s / b.f + 1 - b.s);
|
||
const x2 = b.s > 0 ? D * b.capD * b.f / b.s : Infinity;
|
||
return Math.max(0, Math.min(x1, x2));
|
||
}
|
||
function sizeFleet(S, b) {
|
||
const T = Math.max(0, S.target), sp = T * b.s, st = T - sp;
|
||
const dEq = b.capD > 0 ? sp / (b.capD * b.f) : 0;
|
||
const dMin = Math.max(0, Math.round(S.dMin)), cMin = Math.max(0, Math.round(S.cMin));
|
||
let D, C;
|
||
if (b.capD <= 0) return { D: dMin, C: b.capC > 0 ? Math.max(cMin, Math.ceil(st / b.capC - 1e-9)) : cMin };
|
||
if (S.grow === "classic" && b.capC > 0) {
|
||
D = Math.max(dMin, Math.ceil(dEq - 1e-9));
|
||
const spare = (D - dEq) * b.capD;
|
||
C = Math.max(cMin, Math.ceil(Math.max(0, st - spare) / b.capC - 1e-9));
|
||
} else {
|
||
C = cMin;
|
||
const stC = Math.min(st, C * b.capC);
|
||
D = Math.max(dMin, Math.ceil(dEq + (st - stC) / b.capD - 1e-9));
|
||
}
|
||
return { D, C };
|
||
}
|
||
function compute(D, C, X, b) {
|
||
const sp = X * b.s, st = X - sp;
|
||
const stC = Math.min(st, C * b.capC), stD = st - stC;
|
||
const dUse = D > 0 && b.capD > 0 ? (sp / (b.capD * b.f) + stD / b.capD) / D : 0;
|
||
const cUse = C > 0 && b.capC > 0 ? stC / (C * b.capC) : 0;
|
||
const P = 2 * D + C, E = 2 * D + C; // presses; extruders (EX 50 + EX 35, Classic built in)
|
||
const perHour = b.hoursYr > 0 ? X / b.hoursYr : 0;
|
||
const att = C > 0 ? Math.ceil(C / Math.max(1, A.cPerAtt)) : 0;
|
||
const fin = X > 0 ? Math.ceil(perHour / Math.max(1, A.finRate)) : 0;
|
||
const perShift = D * A.opD + att + fin;
|
||
const perSide = Math.ceil(A.runLen / Math.max(1, A.stLife));
|
||
const partsTitle = 2 * (2 + perSide);
|
||
const titles = X / Math.max(1, A.runLen);
|
||
const galvIn = S.galv !== "bought", cutIn = S.cut !== "out";
|
||
const supply = galvIn ? Math.max(0, S.stSupply || 0) : 0; // titles plated for other plants
|
||
const partsYr = galvIn ? titles * partsTitle : 0; // own titles' electroformed parts
|
||
const partsAll = galvIn ? (titles + supply) * partsTitle : 0;
|
||
const partsDay = partsAll / Math.max(1, S.days);
|
||
// stamper production per working day, stations sized from galvanics.md cycle times
|
||
const tDay = titles / Math.max(1, S.days), gDay = (titles + supply) / Math.max(1, S.days);
|
||
const flow = { titlesDay: tDay, supDay: supply / Math.max(1, S.days), lacq: 2 * tDay, glacq: 2 * gDay,
|
||
fathers: 2 * gDay, mothers: 2 * gDay, stampers: 2 * perSide * gDay };
|
||
flow.parts = flow.fathers + flow.mothers + flow.stampers;
|
||
const avail = S.shifts * S.hrs * 60 * Math.max(1, A.galvEff) / 100;
|
||
const need = (dem, cap) => dem <= 0 || cap <= 0 ? { n: 0, u: 0 }
|
||
: (n => ({ n, u: dem / (n * cap) }))(Math.max(1, Math.ceil(dem / cap - 1e-9)));
|
||
const off = { n: 0, u: 0, off: true };
|
||
const galv = galvIn ? {
|
||
pasu: need(flow.glacq * A.silverMin, avail),
|
||
pppu: need(flow.glacq * (A.preMin + A.handleMin) / 4, avail),
|
||
pdpu: need(flow.fathers * (A.fatherMin + A.handleMin) + flow.mothers * (A.motherMin + A.handleMin)
|
||
+ flow.stampers * (A.stamperMin + A.handleMin), 2 * avail),
|
||
pmpb: need(flow.parts * A.pmpbMin, avail),
|
||
vsf: need(flow.stampers * A.vsfMin, avail),
|
||
vsp: need(flow.stampers * A.vspMin, avail),
|
||
vsc: need(flow.stampers * A.vscMin, avail),
|
||
} : { pasu: off, pppu: off, pdpu: off, pmpb: off, vsf: off, vsp: off, vsc: off };
|
||
galv.lathe = cutIn ? need(flow.lacq, A.lathe) : off;
|
||
const coolPeak = P * A.coolPress + E * A.extCool + P * A.hydCool;
|
||
const air = P * A.airPress + E * A.extAir;
|
||
const kvaMach = P * A.pressKVA + E * A.extKVA, kwMach = P * A.pressKW + E * A.extKW;
|
||
const kvaHyd = P * A.hydKVA, kwHyd = P * A.hydKW;
|
||
const steam = [P * A.steamLo, P * A.steamHi];
|
||
const mods = (v, m) => v > 0 && m > 0 ? Math.ceil(v / m - 1e-9) : 0;
|
||
return {
|
||
D, C, X, P, E, sp, st, stC, stD, dUse, cUse, perHour, att, fin, perShift,
|
||
staff: perShift * S.shifts, galv: partsDay > 0 ? Math.ceil(partsDay / Math.max(1, A.galvRate)) : 0,
|
||
perSide, partsTitle, titles, supply, partsYr, partsAll, partsDay, flow, gst: galv, galvIn, cutIn,
|
||
kvaMach, kwMach, kvaHyd, kwHyd, kva: kvaMach + kvaHyd, kw: kwMach + kwHyd,
|
||
mwh: (kwMach + kwHyd) * b.hoursYr / 1000,
|
||
chillerKW: coolPeak / Math.max(0.5, A.cop), compKW: air * A.compSpec,
|
||
steam, coolPeak, nb: mods(steam[1], A.boilerMod), nc: mods(coolPeak, A.chillerMod),
|
||
water: [P * A.waterTyp + E * A.extWater + P * A.hydWater, P * A.waterPeak + E * A.extWater + P * A.hydWater],
|
||
air, hyd: { flow: P * A.hydFlow, motor: P * A.hydMotor, oil: P * A.hydOil, acc: P * A.hydAcc },
|
||
floor: D * A.floorD + C * A.floorC,
|
||
compound: X * A.weight * (1 + A.reject / 100) / 1e6,
|
||
extKgH: perHour * (A.weight + A.trim) * (1 + A.reject / 100) / 1000,
|
||
trimT: X * (1 + A.reject / 100) * A.trim / 1e6,
|
||
};
|
||
}
|
||
function headsOf(r) {
|
||
return {
|
||
ops: r.staff, qc: r.X > 0 ? Math.ceil(A.qcPerShift * S.shifts) : 0, galv: r.galv,
|
||
maint: r.P > 0 ? Math.max(2, Math.ceil(r.P / Math.max(1, A.pressPerTech))) : 0, cut: r.gst.lathe.n,
|
||
};
|
||
}
|
||
// Investment from the blocks of total-investment.md (launch plant, October 2026), in USD.
|
||
// c = the plant configuration (S by default, a modified copy for the live deltas).
|
||
function capexOf(r, c = S, boiOn = c.boi !== "off") {
|
||
const fx = A.thbUsd > 0 ? A.thbUsd : 33, T = v => v / fx;
|
||
const rows = [], meta = id => AS.find(x => x[1] === id + "Lo");
|
||
// one block: id in AS (Lo/Hi pair), n units, thb = AS figures in THB; o.imp = imported machinery, o.frt = in the freight basis
|
||
const blk = (g, id, l, n = 1, thb = false, o = {}) => {
|
||
const m = meta(id), lo = thb ? T(A[id + "Lo"]) : A[id + "Lo"], hi = thb ? T(A[id + "Hi"]) : A[id + "Hi"];
|
||
rows.push(Object.assign({ g, id, l, lo: n * lo, hi: n * hi, k: m[5], s: m[6] }, o));
|
||
};
|
||
const galvIn = c.galv !== "bought", cutIn = c.cut !== "out", pl = (n, w) => `${n} × ${w}${n === 1 ? "" : "s"}`;
|
||
blk("Preparation", "p0", "Phase 0: BOI consultant, legal and lease work, soil test, re-quotes");
|
||
if (r.D > 0) blk("Presses", "dup1", "Newbilt Duplex system 1: two HRP 12, EX 50 + EX 35, trimmer, label oven, shrink wrapper, grinder, label punch", 1, false, { imp: 1, frt: 1 });
|
||
if (r.D > 1) blk("Presses", "dupN", `Further Duplex systems: ${r.D - 1} ×`, r.D - 1, false, { imp: 1, frt: 1 });
|
||
if (r.C > 0) blk("Presses", "cla", r.C === 1 ? "Newbilt Classic" : `Newbilt Classic: ${r.C} ×`, r.C, false, { imp: 1, frt: 1 });
|
||
if (galvIn) {
|
||
const oms = c.galvPrice === "oms";
|
||
blk("Galvanics and stamper prep", oms ? "gOms" : "gPh", `Pheenix galvanics line (PASU, PPPU, PDPU, PMPB), ${oms ? "priced near OMS's reference" : "Pheenix estimate"}`,
|
||
1, false, { imp: 1, frt: 1, frtMid: (A.gPhLo + A.gPhHi) / 2 });
|
||
blk("Galvanics and stamper prep", "gWat", "Galvanics water: ZLD evaporator and DI water", 1, true, { imp: 1 });
|
||
blk("Galvanics and stamper prep", "wtb", "Water-treatment building next to galvanics, ~12 × 10 m", 1, true);
|
||
blk("Galvanics and stamper prep", "sib", "Sibert VSF, VSP, VSC", 1, false, { imp: 1, frt: 1 });
|
||
}
|
||
if (cutIn) blk("Cutting room", "cutR", "Cutting room: Sillitoe SDMS, spare cutterhead, console and chain, monitoring, room", 1, false, { imp: 1, frt: 1 });
|
||
if (c.metro === "launch") blk("Quality", "metL", "Metrology, launch tier: XRF, inline vision, audio station", 1, false, { imp: 1 });
|
||
else blk("Quality", "metF", "Metrology, full stack: XRF, laser confocal, wide-area profiler, inline vision, audio station", 1, false, { imp: 1 });
|
||
blk("Quality", "pvcL", "PVC lab, day-one tier");
|
||
const bio = c.steamFuel === "biomass", cn = c.sourcing === "china";
|
||
if (r.nb > 0) blk("Utilities", bio ? "bBio" : cn ? "bCn" : "bTh",
|
||
`Steam: ${pl(r.nb, `${f0(A.boilerMod)} kg/h ${bio ? "biomass" : "electric"} boiler module`)}${bio ? "" : cn ? ", Chinese route" : ", Thai supplier"}`, r.nb, true);
|
||
blk("Utilities", "cBk", "Cooling backbone: plate HX, tanks, pumps and mains for four modules", 1, true);
|
||
if (r.nc > 0) blk("Utilities", "chM", `Chillers: ${pl(r.nc, `${f0(A.chillerMod)} kW air-cooled module`)}`, r.nc, true);
|
||
blk("Utilities", cn ? "airC" : "airT", `Compressed air: screw compressor, dryer, receiver, ${cn ? "Chinese brand through its Thai office" : "Thai supplier"}`, 1, true);
|
||
const cr = (galvIn ? 0.5 : 0) + (cutIn ? 0.5 : 0);
|
||
if (cr > 0) blk("Utilities", "clR", cr === 1 ? "Clean-room adder: galvanics and cutting room" : `Clean-room adder, half: ${galvIn ? "galvanics" : "cutting room"} only`, cr, true);
|
||
blk("Utilities", "uRest", "Rest of the utilities baseline: softener, loop treatment, racking, whole-factory HVAC", 1, true);
|
||
if (c.solar === "buy") {
|
||
blk("Utilities", "pv", "Rooftop PV, EPC", 1, true);
|
||
blk("Utilities", "bat", "Battery 271 kWh (Gobel)", 1, true);
|
||
}
|
||
blk("Building and site", "bld", "Building ~1,500 m² plus civil and site works");
|
||
if (r.floor > A.hall) blk("Building and site", "ph2", `Phase 2 building: ~${f0(r.floor)} m² of press floor outgrow the ${f0(A.hall)} m² press hall`);
|
||
if (c.crane === "rent") blk("Building and site", "crn", `Crane rented for the installations (${f0(A.craneRent)} % of the crane block); frame stays crane-ready`, A.craneRent / 100);
|
||
else blk("Building and site", "crn", "Overhead crane, runway, frame premium, machine door");
|
||
blk("Building and site", "wsh", "Maintenance workshop and machine bay fit-out");
|
||
if (c.corridor !== "later") blk("Building and site", "cor", "Visitor corridor and accessible entrance");
|
||
blk("Building and site", "yard", "Utility yard for four boiler and four chiller modules");
|
||
blk("Software", "fsw", "Factory software");
|
||
blk("Software", "erp", "ERPNext implementation");
|
||
blk("Software", "apf", "Artist platform, public side");
|
||
if (c.cass !== "off") blk("Add-on lines", "cas", "Cassette duplication");
|
||
if (c.cdr !== "off") blk("Add-on lines", "cdr", "CD-R duplication");
|
||
// freight follows the machinery the line names (Newbilt, Pheenix, Sibert, Sillitoe) at block midpoints;
|
||
// the galvanics price basis does not change what is shipped, so the Pheenix midpoint counts either way
|
||
const mid = x => (x.lo + x.hi) / 2;
|
||
const frtRef = (A.dup1Lo + A.dup1Hi + A.claLo + A.claHi + A.gPhLo + A.gPhHi + A.sibLo + A.sibHi + A.cutRLo + A.cutRHi) / 2;
|
||
const frtBase = rows.filter(x => x.frt).reduce((a, x) => a + (x.frtMid != null ? x.frtMid : mid(x)), 0);
|
||
const fr = frtRef > 0 ? frtBase / frtRef : 1;
|
||
blk("Freight and working capital", "frt", `Freight, installation, commissioning, training: ${pct(fr)} of the launch plant's`, fr);
|
||
const wf = Math.max(A.wcMin / 100, A.wcRef > 0 ? r.X / A.wcRef : 1);
|
||
blk("Freight and working capital", "wc", `Working capital, PVC stock, spares, lease deposit, first-year rent: ${pct(wf)} of the launch plant's`, wf);
|
||
const tri = (lo, hi) => ({ lo, mid: (lo + hi) / 2, hi });
|
||
const sumOf = (f, k) => rows.filter(f).reduce((a, x) => a + x[k], 0), all = () => true;
|
||
const cont = Math.max(0, A.contPct) / 100;
|
||
const sub = tri(sumOf(all, "lo"), sumOf(all, "hi"));
|
||
const ctg = tri(sub.lo * cont, sub.hi * cont);
|
||
const tot = tri(sub.lo + ctg.lo, sub.hi + ctg.hi);
|
||
const imp = tri(sumOf(x => x.imp, "lo"), sumOf(x => x.imp, "hi"));
|
||
const dr = boiOn ? 0 : Math.max(0, A.dutyMach) / 100;
|
||
const duty = tri(imp.lo * dr, imp.hi * dr);
|
||
const vat = tri(boiOn ? 0 : (imp.lo + duty.lo) * A.vatRate / 100, boiOn ? 0 : (imp.hi + duty.hi) * A.vatRate / 100);
|
||
const res = tri(T(A.resLo), T(A.resHi));
|
||
const withDuty = tri(tot.lo + duty.lo, tot.hi + duty.hi);
|
||
return { rows, sub, ctg, cont, tot, imp, duty, vat, res, all: withDuty, fr, wf, boiOn };
|
||
}
|
||
// Money a year in USD. Lines: [group, label, USD, basis].
|
||
function econ(r, b, boiOn = S.boi !== "off") {
|
||
const fx = A.thbUsd > 0 ? A.thbUsd : 33, t2u = v => v / fx;
|
||
const X = r.X, pressed = X * (1 + A.reject / 100), st = X - r.sp, L = [];
|
||
const add = (g, l, v, s) => L.push([g, l, v, s]);
|
||
const cdOn = S.countCD === "on";
|
||
const revStd = st * S.priceStd, revSpec = r.sp * (S.priceStd + S.premSpec), revTitle = r.titles * S.titleFee;
|
||
const revSup = r.supply * A.stSupplyC;
|
||
const revCass = cdOn && S.cass !== "off" ? A.cassN * A.cassPrice : 0, revCdr = cdOn && S.cdr !== "off" ? A.cdrN * A.cdrPrice : 0;
|
||
const rev = revStd + revSpec + revTitle + revSup + revCass + revCdr;
|
||
add("mat", "PVC compound", r.compound * 1000 * A.pvcPrice, `${f0(r.compound)} t at ${f2(A.pvcPrice)} USD/kg incl. rejects; trim back as regrind`);
|
||
const expSh = Math.min(100, Math.max(0, S.exportShare)) / 100;
|
||
const pvcImported = r.compound * 1000 * A.pvcPrice * A.pvcImp / 100;
|
||
const pvcDuty = pvcImported * A.dutyPVC / 100 * (boiOn ? (1 - expSh) * (1 - A.boiRawRed / 100) : 1);
|
||
add("mat", "Import duty on PVC compound", pvcDuty, boiOn
|
||
? `BOI: none on the ${pct(expSh)} exported, ${f0(A.boiRawRed)} % off the rest`
|
||
: `${f1(A.dutyPVC)} % on ${pct(A.pvcImp / 100)} imported compound, no BOI`);
|
||
add("mat", "Labels", pressed * A.labelCost, "a printed pair per pressed record");
|
||
add("pack", "Inner sleeves", X * A.sleeveCost, "");
|
||
add("pack", "Jackets (printed cartons)", X * A.jacketCost, "local carton partners");
|
||
add("pack", "Shrink wrap", X * A.shrinkCost, "");
|
||
add("pack", "Shipping cartons and pallets", X * A.cartonCost, "estimate");
|
||
add("maint", "Maintenance, spares, consumables", X * A.maintCost, "");
|
||
// title services: the steps done in-house earn the contribution, outsourced steps pass through at cost
|
||
const shC = Math.max(0, A.shareCut), shP = Math.max(0, A.sharePlate), shS = Math.max(0, A.shareSetup);
|
||
const shT = shC + shP + shS > 0 ? shC + shP + shS : 1;
|
||
const inSh = (shS + (r.cutIn ? shC : 0) + (r.galvIn ? shP : 0)) / shT;
|
||
const lacq = r.cutIn ? r.titles * 2 * A.lacquerCost * A.usdEur : 0, chem = r.partsYr * A.chemPart;
|
||
if (r.cutIn) add("title", "Lacquer blanks", lacq, `${f0(r.titles * 2)} lacquers at ${f0(A.lacquerCost)} EUR`);
|
||
if (r.galvIn) add("title", "Nickel, silver, plating chemistry", chem, `${f0(r.partsYr)} electroformed parts at ${f0(A.chemPart)} USD`);
|
||
const tc = Math.min(100, Math.max(0, A.titleContrib)) / 100;
|
||
add("title", "Styli, remakes, other title costs", Math.max(0, revTitle * inSh * (1 - tc) - lacq - chem),
|
||
`balance to the ${f0(tc * 100)} % contribution on the in-house steps (${pct(inSh)} of the fee)`);
|
||
if (!r.cutIn) add("title", "Mastering and cutting bought in, at cost", revTitle * shC / shT, `${pct(shC / shT)} of the title fee passed through; no contribution`);
|
||
if (!r.galvIn) add("title", "Stampers bought in, at cost", revTitle * shP / shT, `${pct(shP / shT)} of the title fee passed through; no contribution`);
|
||
// energy
|
||
const cap = capacityOf(r.D, r.C, b), use = cap > 0 ? Math.min(1, X / cap) : 0;
|
||
const runH = b.hoursYr * S.eff / 100 * use; // press running hours a year
|
||
const steamT = r.P * (A.steamLo + A.steamHi) / 2 * runH / 1000;
|
||
const heatMWh = steamT * A.steamKWh;
|
||
const elecMWh = X > 0 ? r.mwh + heatMWh / Math.max(0.5, A.cop) + (r.compKW + A.restKW) * b.hoursYr / 1000 : 0;
|
||
const pvMWh = S.solar === "none" ? 0 : Math.min(elecMWh, A.pvKWp * A.pvYield / 1000);
|
||
const demandKW = X > 0 ? r.kw + r.chillerKW + r.compKW + A.restKW : 0;
|
||
add("energy", "Electricity", t2u((elecMWh - pvMWh) * 1000 * A.kwhPrice + demandKW * A.demandPrice * 12),
|
||
`${f0(elecMWh - pvMWh)} MWh from the grid after ${f0(pvMWh)} MWh roof PV${S.solar === "none" ? " (no PV)" : S.solar === "ppa" ? " (PPA)" : ""}; ~${f0(demandKW)} kW demand`);
|
||
if (S.solar === "ppa") add("energy", "Solar PPA power", t2u(pvMWh * 1000 * A.ppaPrice), `${f0(pvMWh)} MWh at ${f2(A.ppaPrice)} THB/kWh (estimate, to quote)`);
|
||
if (S.steamFuel === "biomass")
|
||
add("energy", "Steam heat (biomass)", t2u(heatMWh * 1000 * A.heatPrice), `${f0(steamT)} t of steam, ${f0(heatMWh)} MWh of heat at ${f2(A.heatPrice)} THB/kWh`);
|
||
else {
|
||
const eMWh = heatMWh / Math.max(0.5, A.eBoilEff / 100), dKW = X > 0 ? r.nb * A.eBoilKW : 0;
|
||
add("energy", "Steam (electric boilers)", t2u(eMWh * 1000 * A.kwhPrice + dKW * A.demandPrice * 12),
|
||
`${f0(steamT)} t of steam: ${f0(eMWh)} MWh at ${f2(A.kwhPrice)} THB/kWh plus demand on ${r.nb} × ${f0(A.eBoilKW)} kW`);
|
||
}
|
||
const h = headsOf(r), ppl = n => n + (n === 1 ? " person" : " people");
|
||
let base = 0, ss = 0;
|
||
for (const [l, n, sal, why] of [["Press operators and finishing", h.ops, A.salOp], ["QC", h.qc, A.salQC],
|
||
["Maintenance", h.maint, A.salMaint], ["Galvanics", h.galv, A.salGalv, r.galvIn ? "" : "stampers bought in"],
|
||
["Cutting engineers", h.cut, A.salCut, r.cutIn ? "" : "cutting outsourced"]]) {
|
||
add("lab", l, t2u(n * sal * 12), why || `${ppl(n)} at ${f0(sal)} THB a month`);
|
||
base += n * sal * 12; ss += n * Math.min(sal, A.ssCap) * A.ssRate / 100 * 12;
|
||
}
|
||
add("lab", "Employer social security", t2u(ss), `${f1(A.ssRate)} % of salary up to ${f0(A.ssCap)} THB a month`);
|
||
add("lab", "Overtime, bonus, PPE", t2u(base * A.otherOn / 100), `${f1(A.otherOn)} % of base salaries`);
|
||
h.prod = h.ops + h.qc + h.maint + h.galv + h.cut; h.office = Math.max(0, Math.round(A.officeHeads));
|
||
h.all = h.prod + h.office; h.exposed = h.ops + h.galv + h.maint;
|
||
add("lab", "Health insurance, all staff", t2u(h.all * A.healthIns), `${ppl(h.all)}, office included, at ${f0(A.healthIns)} THB a year`);
|
||
add("lab", "Health check-ups, all staff", t2u(h.all * A.checkup + h.exposed * A.occCheck),
|
||
`yearly for everyone, plus risk-factor tests for ${ppl(h.exposed)} on the press floor, in galvanics and maintenance`);
|
||
const wwEur = (h.prod * A.wwProd * A.wwProdYr + h.office * A.wwOffice * A.wwOffYr) * (1 + A.wwFreight / 100) * (1 + A.wwDuty / 100);
|
||
add("lab", "Workwear, Engelbert Strauss, all staff", wwEur * A.usdEur,
|
||
`${f1(h.prod * A.wwProdYr)} production and ${f1(h.office * A.wwOffYr)} office kits a year, shipping and Thai duty included`);
|
||
const sum = gs => L.filter(l => gs.includes(l[0])).reduce((a, l) => a + l[2], 0);
|
||
const direct = L.reduce((a, l) => a + l[2], 0), contrib = rev - direct, ebitda = contrib - A.overheads;
|
||
const cx = capexOf(r, S, boiOn), end = ENUM.capexEnd.includes(S.capexEnd) ? S.capexEnd : "mid";
|
||
const ek = { low: "lo", mid: "mid", high: "hi" }[end];
|
||
const capex = cx.all[ek], depr = capex / Math.max(1, A.deprYears);
|
||
const titleC = revTitle - sum(["title"]);
|
||
return { rev, revStd, revSpec, revTitle, revSup, revCass, revCdr, L, sum, direct, contrib, ebitda, capex, depr, op: ebitda - depr,
|
||
payback: ebitda > 0 ? capex / ebitda : Infinity, heads: h, imp: cx.imp[ek], dutyM: cx.duty[ek], vatM: cx.vat[ek], pvcDuty, boiOn,
|
||
cx, end, ek, titleC, titleRate: revTitle > 0 ? titleC / revTitle : 0, inSh, steamT, heatMWh, pvMWh };
|
||
}
|
||
// Ten years of cash, USD: year 0 the investment (+ the reserve when it is in the budget), then the ramp.
|
||
function cashView(e, r) {
|
||
const lab = e.sum(["lab"]), fixed = lab + A.overheads, varCost = e.direct - lab;
|
||
const res = S.reserve === "in" ? e.cx.res[e.ek] : 0, c0 = -(e.capex + res);
|
||
const Y = [{ y: 0, k: 0, rec: 0, rev: 0, ebitda: 0, cf: c0, cum: c0 }];
|
||
for (let y = 1; y <= 10; y++) {
|
||
const k = Math.max(0, A["ramp" + Math.min(y, 4)]) / 100;
|
||
const ebitda = (e.rev - varCost) * k - fixed;
|
||
Y.push({ y, k, rec: r.X * k, rev: e.rev * k, ebitda, cf: ebitda, cum: Y[y - 1].cum + ebitda });
|
||
}
|
||
let pay = null;
|
||
for (let y = 1; y <= 10; y++) if (Y[y].cum >= 0) { pay = y - 1 + (Y[y].cf > 0 ? -Y[y - 1].cum / Y[y].cf : 1); break; }
|
||
const npvAt = d => Y.reduce((a, x) => a + x.cf / Math.pow(1 + d, x.y), 0);
|
||
const npv = npvAt(A.discRate / 100);
|
||
let irr = null, lo = -0.95, hi = 1;
|
||
if (npvAt(lo) * npvAt(hi) > 0) hi = 10;
|
||
if (npvAt(lo) * npvAt(hi) <= 0) {
|
||
for (let i = 0; i < 200; i++) { const m = (lo + hi) / 2; if (npvAt(lo) * npvAt(m) <= 0) hi = m; else lo = m; }
|
||
irr = (lo + hi) / 2;
|
||
}
|
||
return { Y, pay, npv, irr, res, fixed, varCost };
|
||
}
|
||
function scenario() {
|
||
const b = basis(S);
|
||
let D, C, X, cap;
|
||
if (S.mode === "fleet") {
|
||
D = Math.max(0, Math.round(S.dFleet)); C = Math.max(0, Math.round(S.cFleet));
|
||
if (D === 0 && b.s > 0) { b.s = 0; b.noSpecials = true; }
|
||
cap = capacityOf(D, C, b); X = cap;
|
||
} else {
|
||
({ D, C } = sizeFleet(S, b));
|
||
cap = capacityOf(D, C, b); X = Math.min(Math.max(0, S.target), cap);
|
||
}
|
||
return { b, cap, r: compute(D, C, X, b) };
|
||
}
|
||
|
||
// ---------------------------------------------------------------- format
|
||
const nf = d => new Intl.NumberFormat("en-US", { maximumFractionDigits: d, minimumFractionDigits: 0 });
|
||
const f0 = x => nf(0).format(Math.round(x));
|
||
const f1 = x => nf(1).format(x);
|
||
const big = x => x >= 1e6 ? nf(2).format(x / 1e6) + " M" : f0(x);
|
||
const f2 = x => nf(2).format(x);
|
||
const fix2 = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
const cv = usd => S.cur === "THB" ? usd * A.thbUsd : S.cur === "EUR" ? usd / Math.max(0.01, A.usdEur) : usd;
|
||
function money(usd) {
|
||
const v = cv(usd), a = Math.abs(v);
|
||
return (a >= 1e6 ? nf(2).format(v / 1e6) + " M" : a >= 1e4 ? f0(v / 1e3) + " k" : f0(v)) + " " + S.cur;
|
||
}
|
||
const unit = usd => fix2.format(cv(usd)) + " " + S.cur;
|
||
const pct = x => Math.round(x * 100) + " %";
|
||
const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||
const $ = id => document.getElementById(id);
|
||
|
||
// ---------------------------------------------------------------- render
|
||
function kpi(v, l, cls) { return `<div class="kpi ${cls || ""}"><b>${v}</b><span>${l}</span></div>`; }
|
||
function render() {
|
||
const { b, cap, r } = scenario();
|
||
$("specV").textContent = S.spec + " %";
|
||
$("targetBox").hidden = S.mode !== "target";
|
||
$("fleetBox").hidden = S.mode !== "fleet";
|
||
for (const g of Object.keys(ENUM)) { const el = $(g); if (el) for (const bt of el.querySelectorAll("button[data-v]")) bt.classList.toggle("on", bt.dataset.v === S[g]); }
|
||
const cs = caseNow();
|
||
for (const bt of $("case").children) bt.classList.toggle("on", bt.dataset.v === cs);
|
||
for (const bt of $("presets").querySelectorAll("button[data-p]"))
|
||
bt.classList.toggle("on", Object.entries(PRESETS[bt.dataset.p]).every(([k, v]) => S[k] === v));
|
||
|
||
$("fleetK").innerHTML =
|
||
kpi(r.D, `Duplex systems<br>${2 * r.D} HRP 12 presses, ${r.D} EX 50, ${r.D} EX 35, ${r.D} trimmer${r.D === 1 ? "" : "s"}`, "d") +
|
||
kpi(r.C, "Newbilt Classic<br>automatic presses", "c") +
|
||
kpi(r.P, "presses in total") +
|
||
kpi(r.perShift, `people at the presses and in finishing, per shift (${r.staff} on ${S.shifts} shift${S.shifts > 1 ? "s" : ""})`);
|
||
|
||
const flags = [];
|
||
if (b.noSpecials)
|
||
flags.push(["bad", "No Duplex system: this fleet cannot press splatter or specials, so the output shown is black and single colour only."]);
|
||
if (S.mode === "target" && S.target > cap + 1)
|
||
flags.push(["bad", `The fleet cannot reach ${big(S.target)} records: check the press rates.`]);
|
||
if (S.mode === "target" && b.s > 0 && r.D > Math.max(0, Math.round(S.dMin)) && S.grow === "classic")
|
||
flags.push(["warn", `Specials alone need ${r.D} Duplex systems at this share.`]);
|
||
const fuel = S.steamFuel === "biomass" ? "biomass" : "electric";
|
||
if (r.nb > A.modRooms)
|
||
flags.push(["warn", `Steam up to ${f0(r.steam[1])} kg/h needs ${r.nb} boiler modules of ${f0(A.boilerMod)} kg/h: beyond the boiler hall built for ${f0(A.modRooms)} modules (a second hall is not costed).`]);
|
||
if (r.nc > A.modRooms)
|
||
flags.push(["warn", `Cooling up to ${f0(r.coolPeak)} kW needs ${r.nc} chiller modules of ${f0(A.chillerMod)} kW: beyond the chiller compound built for ${f0(A.modRooms)} modules (more pads are not costed).`]);
|
||
if (r.nb <= A.modRooms && r.nc <= A.modRooms && r.P > 0)
|
||
flags.push(["ok", `Steam ${f0(r.steam[0])} to ${f0(r.steam[1])} kg/h on ${r.nb} × ${f0(A.boilerMod)} kg/h ${fuel} boiler modules; cooling up to ${f0(r.coolPeak)} kW on ${r.nc} × ${f0(A.chillerMod)} kW chiller modules (rooms for ${f0(A.modRooms)} each).`]);
|
||
if (r.floor > A.hall)
|
||
flags.push(["warn", `About ${f0(r.floor)} m² of press floor: more than the ${f0(A.hall)} m² Payap press hall, so the investment adds a Phase 2 building.`]);
|
||
else flags.push(["ok", `About ${f0(r.floor)} m² of press floor: fits the ${f0(A.hall)} m² Payap press hall (${pct(r.floor / A.hall)}).`]);
|
||
$("flags").innerHTML = flags.map(([c, t]) => `<div class="flag ${c}">${esc(t)}</div>`).join("");
|
||
|
||
const use = cap > 0 ? r.X / cap : 0;
|
||
$("outK").innerHTML =
|
||
kpi(big(r.X), S.mode === "target" ? "records a year (target)" : "records a year at this mix") +
|
||
kpi(big(cap), "fleet capacity a year") +
|
||
kpi(pct(use), "of capacity used") +
|
||
kpi(f0(r.X / Math.max(1, S.days)), "records per working day") +
|
||
kpi(f0(r.perHour), "records per operating hour");
|
||
|
||
const W = r.X > 0 ? r.X : 1;
|
||
$("bar").innerHTML =
|
||
`<div style="width:${100 * r.sp / W}%;background:var(--dspec)" title="Duplex, specials"></div>` +
|
||
`<div style="width:${100 * r.stD / W}%;background:var(--dstd)" title="Duplex, standard"></div>` +
|
||
`<div style="width:${100 * r.stC / W}%;background:var(--classic)" title="Classic, standard"></div>`;
|
||
$("legend").innerHTML =
|
||
`<span><i style="background:var(--dspec)"></i>Duplex, specials ${big(r.sp)} (${pct(r.sp / W)})</span>` +
|
||
`<span><i style="background:var(--dstd)"></i>Duplex, black and single colour ${big(r.stD)} (${pct(r.stD / W)})</span>` +
|
||
`<span><i style="background:var(--classic)"></i>Classic, black and single colour ${big(r.stC)} (${pct(r.stC / W)})</span>`;
|
||
$("mixT").innerHTML =
|
||
`<thead><tr><th>Machine</th><th class="n">Units</th><th class="n">Capacity a year</th><th class="n">Makes a year</th><th class="n">Utilisation</th><th class="n">Share of output</th></tr></thead><tbody>` +
|
||
`<tr><td>Duplex system (2 presses)</td><td class="n">${r.D}</td><td class="n">${big(r.D * b.capD)}</td><td class="n">${big(r.sp + r.stD)}</td><td class="n">${pct(r.dUse)}</td><td class="n">${pct((r.sp + r.stD) / W)}</td></tr>` +
|
||
`<tr><td>Classic</td><td class="n">${r.C}</td><td class="n">${big(r.C * b.capC)}</td><td class="n">${big(r.stC)}</td><td class="n">${pct(r.cUse)}</td><td class="n">${pct(r.stC / W)}</td></tr></tbody>`;
|
||
|
||
// ---- money
|
||
const e = econ(r, b), Xr = Math.max(1, r.X), perRec = usd => r.X > 0 ? unit(usd / Xr) : "–";
|
||
const blended = r.X > 0 ? (e.revStd + e.revSpec) / r.X : S.priceStd;
|
||
$("priceHint").textContent = `Blended record price at this mix: ${unit(blended)}. Prices are entered in USD; 1 USD = ${f2(A.thbUsd)} THB, 1 EUR = ${nf(3).format(A.usdEur)} USD.`;
|
||
const ENDS = { low: "low end", mid: "midpoint", high: "high end" };
|
||
$("ecoK").innerHTML =
|
||
kpi(money(e.rev), "revenue a year") +
|
||
kpi(money(e.ebitda), `EBITDA, ${e.rev > 0 ? pct(e.ebitda / e.rev) : "0 %"} of revenue`) +
|
||
kpi(money(e.op), "operating profit after depreciation") +
|
||
kpi(perRec(e.direct), "direct cost per record") +
|
||
kpi(isFinite(e.payback) ? f1(e.payback) + " years" : "never", `payback on ${money(e.capex)} capex (${ENDS[e.end]})`);
|
||
const eOn = econ(r, b, true), eOff = econ(r, b, false);
|
||
const pb = x => isFinite(x.payback) ? f1(x.payback) + " years" : "none";
|
||
$("boiNote").innerHTML = e.boiOn
|
||
? `<div class="flag ok">BOI promotion: no import duty (~${money(eOff.dutyM)}) and no import VAT (~${money(eOff.vatM)}) on ${money(eOn.imp)} of imported machinery, and ${money(eOff.pvcDuty - eOn.pvcDuty)} a year less duty on PVC compound. Without promotion the capex would be ${money(eOff.capex)} and the payback ${pb(eOff)}.</div>`
|
||
: `<div class="flag">Not promoted: ${money(eOff.dutyM)} import duty on ${money(eOff.imp)} of imported machinery goes into the capex and its depreciation; ${money(eOff.vatM)} import VAT is paid at the border and only comes back later as input tax (exports are zero-rated, so mostly as a refund claim); ${money(eOff.pvcDuty)} a year duty on PVC compound. With BOI the payback would be ${pb(eOn)}.</div>`;
|
||
const SEG = [["PVC and labels", e.sum(["mat"]), "#b07d48"], ["Packaging", e.sum(["pack"]), "#c9a24a"],
|
||
["Title services", e.sum(["title"]), "#8e98a6"], ["Energy", e.sum(["energy"]), "#e2462a"],
|
||
["Labour", e.sum(["lab"]), "#2f7de1"], ["Maintenance", e.sum(["maint"]), "#7a6fd6"],
|
||
["Overheads", A.overheads, "#6b7a90"], ["Depreciation", e.depr, "#4a5f85"]];
|
||
const costAll = SEG.reduce((a, x) => a + x[1], 0), scale = Math.max(e.rev, costAll, 1);
|
||
$("ecoBar").innerHTML = SEG.map(([l, v, c]) => `<div style="width:${100 * v / scale}%;background:${c}" title="${l}: ${unit(v / Xr)} a record"></div>`).join("") +
|
||
(e.op > 0 ? `<div style="width:${100 * e.op / scale}%;background:var(--accent)" title="Operating profit"></div>` : "");
|
||
$("ecoLeg").innerHTML = `<span>Per record, revenue ${perRec(e.rev)}:</span>` +
|
||
SEG.map(([l, v, c]) => `<span><i style="background:${c}"></i>${l} ${perRec(v)}</span>`).join("") +
|
||
(e.op >= 0 ? `<span><i style="background:var(--accent)"></i>Profit ${perRec(e.op)}</span>`
|
||
: `<span style="color:var(--bad)">Loss ${perRec(-e.op)} a record</span>`);
|
||
const P = [];
|
||
const PG = t => P.push(`<tr class="grp"><td colspan="4">${t}</td></tr>`);
|
||
const PR = (l, v, s, tot) => P.push(`<tr${tot ? ' class="tot"' : ""}><td>${l}</td><td class="n${v < 0 ? " neg" : ""}">${money(v)}</td><td class="n${v < 0 ? " neg" : ""}">${perRec(v)}</td><td class="src">${s || ""}</td></tr>`);
|
||
PG("Revenue");
|
||
PR("Standard records", e.revStd, `${big(r.X - r.sp)} at ${unit(S.priceStd)}`);
|
||
PR("Splatter and specials", e.revSpec, `${big(r.sp)} at ${unit(S.priceStd + S.premSpec)}`);
|
||
PR("Title services", e.revTitle, `${f0(r.titles)} titles at ${money(S.titleFee)}; ${pct(e.inSh)} of it done in-house`);
|
||
if (r.galvIn) PR("Stamper supply to other plants", e.revSup, r.supply > 0 ? `${f0(r.supply)} titles at ${money(A.stSupplyC)} contribution (booked at contribution; estimate)` : "none set (Plant configuration)");
|
||
const cdNote = S.countCD === "on" ? "" : "not counted (switch under Plant configuration)";
|
||
if (S.cass !== "off") PR("Cassettes", e.revCass, cdNote || `${f0(A.cassN)} at ${unit(A.cassPrice)}; illustrative, no running costs on file`);
|
||
if (S.cdr !== "off") PR("CD-Rs", e.revCdr, cdNote || `${f0(A.cdrN)} at ${unit(A.cdrPrice)}; illustrative, no running costs on file`);
|
||
PR("Revenue", e.rev, "", true);
|
||
const GRP = [["Materials, packaging, upkeep", ["mat", "pack", "maint"]], ["Title services: lacquers, plating, bought-in steps", ["title"]], ["Energy", ["energy"]], ["Labour and health care", ["lab"]]];
|
||
for (const [t, gs] of GRP) { PG(t); e.L.filter(l => gs.includes(l[0])).forEach(l => PR(l[1], l[2], l[3])); }
|
||
PG("Result");
|
||
PR("Direct cost", e.direct, "everything above", true);
|
||
PR("Contribution", e.contrib, e.rev > 0 ? pct(e.contrib / e.rev) + " of revenue" : "", true);
|
||
PR("of which title services", e.titleC, e.revTitle > 0 ? `${pct(e.titleRate)} of the title fee (in-house steps at ${f0(A.titleContrib)} %, outsourced steps at zero)` : "");
|
||
PR("Overheads", A.overheads, "admin, sales, insurance, facility, IT, fees, marketing");
|
||
PR("EBITDA", e.ebitda, e.rev > 0 ? pct(e.ebitda / e.rev) + " of revenue" : "", true);
|
||
PR("Depreciation", e.depr, `${money(e.capex)} capex (${ENDS[e.end]})${e.dutyM > 0 ? ` incl. ${money(e.dutyM)} import duty` : ""} over ${f1(A.deprYears)} years`);
|
||
PR("Operating profit", e.op, isFinite(e.payback) ? `payback ${f1(e.payback)} years on EBITDA` : "no payback", true);
|
||
$("ecoT").innerHTML = `<thead><tr><th>Line</th><th class="n">A year</th><th class="n">A record</th><th>Basis</th></tr></thead><tbody>${P.join("")}</tbody>`;
|
||
|
||
const rows = [];
|
||
const G = t => rows.push(`<tr class="grp"><td colspan="3">${t}</td></tr>`);
|
||
const R = (l, v, s) => rows.push(`<tr><td>${l}</td><td class="n">${v}</td><td class="src">${s || ""}</td></tr>`);
|
||
G("People");
|
||
R("Duplex operators, per shift", r.D * A.opD, "one operator per Duplex cell");
|
||
R("Classic attendants, per shift", r.att, `one per ${f0(A.cPerAtt)} Classics (assumption)`);
|
||
R("Finishing: inspection, sleeving, packing, per shift", r.fin, `at ${f0(A.finRate)} records an hour per person (estimate)`);
|
||
R("Production staff on all shifts", r.staff, `${r.perShift} per shift × ${S.shifts}; add cover for leave`);
|
||
R("QC inspectors, all shifts", e.heads.qc, `${f1(A.qcPerShift)} per shift (estimate; scale.md 1 to 2)`);
|
||
R("Maintenance technicians", e.heads.maint, `one per ${f0(A.pressPerTech)} presses, at least 2 (estimate)`);
|
||
R("Galvanics operators (day work)", r.galv, r.galvIn ? `${f1(r.partsDay)} parts a day at about ${f0(A.galvRate)} per person (OMS)` : "none: stampers bought in");
|
||
R("Cutting engineers (day work)", e.heads.cut, r.cutIn ? "one per lathe (estimate)" : "none: cutting outsourced");
|
||
R("Production headcount", e.heads.prod, "add cover for leave");
|
||
R("Admin, sales and logistics", e.heads.office, "wages in the overheads (scale.md 3 to 5)");
|
||
R("All staff, with health insurance and yearly check-ups", e.heads.all, `risk-factor tests for ${e.heads.exposed} (press floor, galvanics, maintenance)`);
|
||
G("Machines");
|
||
R("HRP 12 presses (Duplex)", 2 * r.D, "");
|
||
R("Classic automatic presses", r.C, "extruder built in; trimming not stated by Newbilt");
|
||
R("Extruders", `${r.D} EX 50 + ${r.D} EX 35`, "one main and one colour extruder per Duplex system");
|
||
R("Trimmers", r.D, "one per Duplex cell");
|
||
R("Heaviest lift", r.C > 0 ? "about 1.85 t" : "1.35 t", r.C > 0 ? "Classic, if it weighs like the Toolex Alpha (unconfirmed)" : "HRP 12 press (Newbilt)");
|
||
G("Electrical");
|
||
R("Connected load, presses and extruders", f0(r.kvaMach) + " kVA", `${r.P} presses at ${f1(A.pressKVA)} kVA, ${r.E} extruders at ${f1(A.extKVA)} kVA`);
|
||
R("Connected load, central hydraulics", f0(r.kvaHyd) + " kVA", `${f1(A.hydKVA)} kVA per press`);
|
||
R("Average power while running", f0(r.kw) + " kW", "Newbilt average figures, machines and hydraulics");
|
||
R("Energy a year, machines and hydraulics", f0(r.mwh) + " MWh", `${f0(b.hoursYr)} operating hours`);
|
||
R("Chiller power at peak cooling", f0(r.chillerKW) + " kW", `COP ${f1(A.cop)} (estimate)`);
|
||
R("Air compressors", f1(r.compKW) + " kW", "estimate");
|
||
G("Central hydraulics");
|
||
R("Pump flow", f0(r.hyd.flow) + " l/min", "up to 200 bar");
|
||
R("Installed motor power", f1(r.hyd.motor) + " kW", "");
|
||
R("Oil fill, HLP 46", f0(r.hyd.oil) + " l", "");
|
||
R("Accumulator volume", f0(r.hyd.acc) + " l", "gas loaded");
|
||
G("Steam");
|
||
R("Saturated steam, 10 bar, 180 °C", `${f0(r.steam[0])} to ${f0(r.steam[1])} kg/h`, `${r.nb} × ${f0(A.boilerMod)} kg/h ${fuel} boiler modules, hall for ${f0(A.modRooms)}`);
|
||
G("Cooling");
|
||
R("Cooling capacity, peak", f0(r.coolPeak) + " kW", `about 98 % for the moulds; ${r.nc} × ${f0(A.chillerMod)} kW chiller modules, compound for ${f0(A.modRooms)}`);
|
||
R("Cooling water, typical to peak", `${f0(r.water[0])} to ${f0(r.water[1])} l/min`, "inlet 20 to 25 °C, 10 bar");
|
||
G("Compressed air");
|
||
R("Compressed air", f0(r.air) + " m³/h", "6 to 10 bar, dew point 3 °C, filtered to 5 µm");
|
||
G("Floor and room");
|
||
R("Press floor", f0(r.floor) + " m²", `Payap press hall ${f0(A.hall)} m²`);
|
||
R("Press room climate", "20 to 26 °C", "35 to 60 % r.H., at least 2.5 m clear height (Newbilt)");
|
||
G("Material");
|
||
R("PVC compound a year", f0(r.compound) + " t", `${f0(A.weight)} g a record plus ${f0(A.reject)} % rejects`);
|
||
R("Through the extruders", f0(r.extKgH) + " kg/h", "record plus trim, per operating hour");
|
||
R("Trim back to the granulator", f0(r.trimT) + " t a year", `${f0(A.trim)} g a record, reused as regrind`);
|
||
G("Stampers and galvanics");
|
||
R("Titles a year", f0(r.titles), `${f0(A.runLen)} records a title`);
|
||
if (r.galvIn) {
|
||
R("Electroformed parts a year", f0(r.partsAll), `${r.partsTitle} a title: father, mother and ${r.perSide} stamper${r.perSide > 1 ? "s" : ""} per side${r.supply > 0 ? `; incl. ${f0(r.supply)} titles for other plants` : ""}`);
|
||
R("Per working day", `${f1(r.flow.glacq)} / ${f1(r.flow.fathers)} / ${f1(r.flow.mothers)} / ${f1(r.flow.stampers)}`, "lacquers / fathers / mothers / stampers, before remakes");
|
||
} else R("Stampers bought in a year", f0(r.titles * 2 * r.perSide), `${r.perSide} per side a title; electroforming outsourced`);
|
||
const GS = r.gst, su = k => GS[k].off ? (k === "lathe" ? "outsourced" : "bought in") : `${GS[k].n} · ${pct(GS[k].u)}`;
|
||
R("Lathes", su("lathe"), `units · busy; ${f0(A.lathe)} lacquer sides a day each`);
|
||
R("Silvering, Pheenix PASU", su("pasu"), "");
|
||
R("Pre-plating, Pheenix PPPU", su("pppu"), "four masters at once");
|
||
R("Plating, Pheenix PDPU (2 cells)", su("pdpu"), "a second unit adds redundancy, not capacity, at low load");
|
||
R("Passivation, Pheenix PMPB", su("pmpb"), "");
|
||
R("Sibert VSF / VSP / VSC", GS.vsf.off ? "bought in" : `${GS.vsf.n} / ${GS.vsp.n} / ${GS.vsc.n}`, GS.vsf.off ? "stamper prep with the bought stampers" : `busiest ${pct(Math.max(GS.vsf.u, GS.vsp.u, GS.vsc.u))}`);
|
||
$("reqT").innerHTML = `<tbody>${rows.join("")}</tbody>`;
|
||
|
||
$("ladT").innerHTML =
|
||
`<thead><tr><th>Fleet</th><th class="n">Duplex</th><th class="n">Classic</th><th class="n">Presses</th><th class="n">Records a year</th><th class="n">Specials a year</th><th class="n">Staff per shift</th><th class="n">Steam, kg/h</th><th class="n">Cooling, kW</th><th class="n">Connected, kVA</th><th class="n">Press floor, m²</th><th class="n">Capex, mid</th><th class="n">Revenue</th><th class="n">EBITDA</th></tr></thead><tbody>` +
|
||
LADDER.map(([n, d, c], i) => {
|
||
const x = capacityOf(d, c, b), q = compute(d, c, x, b), qe = econ(q, b);
|
||
const cur = r.D === d && r.C === c ? " cur" : "";
|
||
return `<tr class="lad${cur}" data-i="${i}"><td>${n}</td><td class="n">${d}</td><td class="n">${c}</td><td class="n">${q.P}</td><td class="n">${big(x)}</td><td class="n">${big(q.sp)}</td><td class="n">${q.perShift}</td><td class="n">${f0(q.steam[1])}</td><td class="n">${f0(q.coolPeak)}</td><td class="n">${f0(q.kva)}</td><td class="n">${f0(q.floor)}</td><td class="n">${money(qe.cx.all.mid)}</td><td class="n">${money(qe.rev)}</td><td class="n${qe.ebitda < 0 ? " neg" : ""}">${money(qe.ebitda)}</td></tr>`;
|
||
}).join("") + "</tbody>";
|
||
|
||
drawPark(r, b);
|
||
renderCfg(r);
|
||
renderInvest(e, r);
|
||
renderBudget(e);
|
||
renderCash(e, r);
|
||
isoUpdate(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
|
||
// ---------------------------------------------------------------- plant view (CNXIso)
|
||
let ISO = null, ISOK = "";
|
||
function isoCfg(r) {
|
||
return { duplex: r.D, classic: r.C, galvanics: S.galv, cutting: S.cut, metrology: S.metro,
|
||
cassette: S.cass, cdr: S.cdr, solar: S.solar, pvKwp: 300, batteryKwh: 271, fuel: S.steamFuel,
|
||
boilerModules: r.nb, chillerModules: r.nc, crane: S.crane, corridor: S.corridor, phase2: r.floor > A.hall };
|
||
}
|
||
function isoUpdate(r) {
|
||
const el = document.getElementById("isoPlant");
|
||
if (!el || typeof CNXIso === "undefined") return;
|
||
const cfg = isoCfg(r), k = JSON.stringify(cfg);
|
||
if (!ISO) { ISO = CNXIso.mount(el, cfg, { note: true }); ISOK = k; return; }
|
||
if (k !== ISOK) { ISO.update(cfg); ISOK = k; }
|
||
}
|
||
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; }, get iso() { return ISO; }, 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>
|