<!DOCTYPE html> <html lang="en" dir="ltr"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>TPH Remediation Cost Simulator</title> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@600&family=Archivo:wght@400;500&family=Noto+Sans:wght@400;600&family=Noto+Sans+Arabic:wght@400;600&family=Noto+Sans+Devanagari:wght@400;600&display=swap" rel="stylesheet"> <style> :root{ --midnight:#134455; --platinum:#e9e8e3; --eerie:#252829; --accent:#00e5ff; --ok:#35c46a; --warn:#f5a623; --bad:#e5484d; --radius:10px; --font-head:'Poppins','Noto Sans',sans-serif; --font-body:'Archivo','Noto Sans',sans-serif; --border:rgba(233,232,227,0.15); } html[lang="ar"]{--font-head:'Noto Sans Arabic',sans-serif;--font-body:'Noto Sans Arabic',sans-serif;} html[lang="hi"]{--font-head:'Noto Sans Devanagari',sans-serif;--font-body:'Noto Sans Devanagari',sans-serif;} html[lang="ru"]{--font-head:'Noto Sans',sans-serif;--font-body:'Noto Sans',sans-serif;} *{box-sizing:border-box;margin:0;padding:0;} body{background:var(--eerie);color:var(--platinum);font-family:var(--font-body);font-size:15px;line-height:1.5;} h1,h2,h3{font-family:var(--font-head);font-weight:600;line-height:1.25;} h1{font-size:1.35rem;} h2{font-size:1.1rem;margin-block-end:12px;} main{max-width:1080px;margin-inline:auto;padding:16px;} header{display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:space-between;padding:14px 16px;max-width:1080px;margin-inline:auto;} .logo{font-family:var(--font-head);font-weight:600;font-size:1.3rem;color:var(--platinum);letter-spacing:0.5px;} .tagline{font-size:0.8rem;opacity:0.7;display:block;} .hdr-controls{display:flex;gap:8px;flex-wrap:wrap;align-items:center;} select,input[type=number]{background:var(--eerie);color:var(--platinum);border:1px solid var(--border);border-radius:var(--radius);padding:8px 10px;font-family:var(--font-body);font-size:0.95rem;max-width:100%;} select:focus,input:focus,button:focus{outline:2px solid var(--accent);outline-offset:2px;} section{background:var(--midnight);border:1px solid var(--border);border-radius:var(--radius);padding:18px;margin-block-end:16px;} label{display:block;font-size:0.8rem;opacity:0.85;margin-block-end:4px;} .field{display:flex;flex-direction:column;} .grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px;} .grid3{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:14px;} @media(max-width:700px){.grid2{grid-template-columns:1fr;}} .moneyfield{display:flex;align-items:center;gap:6px;} .moneyfield .cur{font-size:0.8rem;opacity:0.7;min-width:34px;} .moneyfield input{flex:1;min-width:0;} button{font-family:var(--font-head);font-weight:600;border:none;border-radius:var(--radius);padding:10px 18px;cursor:pointer;font-size:0.95rem;} .btn-accent{background:var(--accent);color:var(--eerie);box-shadow:0 0 12px rgba(0,229,255,0.35);} .btn-ghost{background:transparent;color:var(--platinum);border:1px solid var(--border);} .btn-ghost[aria-pressed="true"]{border-color:var(--accent);color:var(--accent);} .presets{display:flex;gap:8px;flex-wrap:wrap;margin-block-end:16px;} .banner{background:rgba(0,229,255,0.08);border:1px solid rgba(0,229,255,0.3);border-radius:var(--radius);padding:8px 12px;font-size:0.8rem;margin-block-end:14px;display:none;} .banner.show{display:block;} input[type=range]{width:100%;accent-color:var(--accent);} .slider-val{font-family:var(--font-head);font-weight:600;color:var(--accent);font-size:1rem;} .adv{display:none;margin-block-start:16px;border-block-start:1px solid var(--border);padding-block-start:16px;} .adv.open{display:block;} .adv h3{font-size:0.85rem;text-transform:uppercase;letter-spacing:1px;opacity:0.7;margin-block:14px 8px;} .checkrow{display:flex;align-items:center;gap:8px;} .checkrow input{width:18px;height:18px;accent-color:var(--accent);} .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;} .stat{border:1px solid var(--border);border-radius:var(--radius);padding:14px;} .stat .v{font-family:var(--font-head);font-weight:600;font-size:1.5rem;} .stat.primary .v{color:var(--accent);font-size:1.9rem;text-shadow:0 0 12px rgba(0,229,255,0.35);} .stat .l{font-size:0.75rem;opacity:0.8;margin-block-start:4px;} .totalsline{margin-block-start:12px;font-size:0.85rem;opacity:0.85;} .anim-wrap{display:grid;grid-template-columns:1fr 1fr;gap:14px;} @media(max-width:700px){.anim-wrap{grid-template-columns:1fr;}} .panel{border:1px solid var(--border);border-radius:var(--radius);padding:8px;transition:opacity 0.4s;} .panel.waiting{opacity:0.55;} .panel h3{font-size:0.85rem;margin-block-end:2px;} .caption{font-size:0.72rem;opacity:0.7;margin-block-end:6px;min-height:2.2em;} @keyframes scanp{0%,100%{opacity:0.25;}50%{opacity:0.95;}} .scanp{animation:scanp 0.8s ease-in-out infinite;} @media(prefers-reduced-motion:reduce){.scanp{animation:none;opacity:0.6;}} .panel .status{font-size:0.78rem;opacity:0.85;margin-block-start:4px;min-height:1.2em;} .anim-head{display:flex;justify-content:space-between;align-items:center;margin-block-end:8px;} .boom-g{transform-box:fill-box;transform-origin:20% 80%;} .digging .boom-g{animation:dig 1.2s ease-in-out infinite;} @keyframes dig{0%,100%{transform:rotate(0deg);}50%{transform:rotate(14deg);}} .pulse{animation:pulse 1.2s ease-in-out infinite;} @keyframes pulse{0%,100%{opacity:0.4;}50%{opacity:1;}} .flash{animation:pulse 0.5s ease-in-out infinite;} @media(prefers-reduced-motion:reduce){.digging .boom-g,.pulse,.flash{animation:none;}} svg text{font-family:var(--font-body);fill:var(--platinum);} .legend{display:flex;flex-wrap:wrap;gap:10px;margin-block-start:10px;font-size:0.75rem;} .legend span{display:inline-flex;align-items:center;gap:5px;} .legend i{width:11px;height:11px;border-radius:2px;display:inline-block;} #tooltip{position:fixed;background:var(--eerie);border:1px solid var(--accent);border-radius:6px;padding:6px 10px;font-size:0.8rem;pointer-events:none;display:none;z-index:10;} details{border:1px solid var(--border);border-radius:var(--radius);padding:12px;} details summary{cursor:pointer;font-family:var(--font-head);font-weight:600;} .assume-notes{margin-block:12px;font-size:0.85rem;} .assume-notes p{margin-block-end:6px;opacity:0.9;} .assume-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:6px 20px;font-size:0.8rem;} .assume-grid div{display:flex;justify-content:space-between;gap:10px;border-block-end:1px dashed var(--border);padding-block:3px;} .assume-grid b{font-weight:500;color:var(--accent);} #cta{text-align:center;} #cta p{max-width:520px;margin:8px auto 16px;opacity:0.9;} footer{text-align:center;font-size:0.75rem;opacity:0.6;padding:20px;max-width:1080px;margin-inline:auto;} .bar-label{font-size:0.8rem;margin-block:10px 4px;opacity:0.85;} .results-head{display:flex;justify-content:flex-end;margin-block-end:10px;} #printReport{display:none;} @media print{ body{background:#fff;color:#252829;} body > header, body > main, body > footer{display:none !important;} #printReport{display:block !important;font-family:var(--font-body);color:#252829;padding:0;font-size:12px;line-height:1.45;} #printReport h1{font-family:var(--font-head);color:#134455;font-size:20px;margin:0 0 2px;} #printReport h2{font-family:var(--font-head);color:#134455;font-size:14px;margin:18px 0 6px;} #printReport .rep-sub{color:#555;font-size:11px;margin-block-end:14px;} #printReport .rep-hero{background:#134455;color:#e9e8e3;border-radius:8px;padding:12px 16px;margin-block:10px 16px;display:flex;gap:24px;flex-wrap:wrap;} #printReport .rep-hero div{min-width:120px;} #printReport .rep-hero .v{font-family:var(--font-head);font-size:17px;} #printReport .rep-hero .l{font-size:10px;opacity:0.85;} #printReport table{width:100%;border-collapse:collapse;font-size:11px;margin-block-end:8px;} #printReport th,#printReport td{border:1px solid #c9c9c4;padding:4px 7px;text-align:start;} #printReport th{background:#e9e8e3;font-family:var(--font-head);font-weight:600;color:#134455;} #printReport td.num{text-align:end;font-variant-numeric:tabular-nums;} #printReport .rep-note{font-size:10px;color:#555;margin-block-start:14px;} #printReport .rep-total td{font-weight:600;background:#f2f1ec;} } </style> </head> <body> <header> <div><span class="logo">GXLab</span><span class="tagline" data-i18n="app.tagline"></span></div> <div class="hdr-controls"> <select id="localeSel" aria-label="Language"></select> <select id="currencySel" aria-label="Currency"> <option>USD</option><option>AUD</option><option>CAD</option><option>EUR</option> <option>NGN</option><option>SAR</option><option>AED</option><option>BRL</option><option>IDR</option> </select> <button id="unitsBtn" class="btn-ghost" aria-pressed="false"></button> </div> </header> <main> <h1 data-i18n="app.title" style="margin-block-end:14px;"></h1> <section id="inputs"> <div class="bar-label" data-i18n="inputs.presets"></div> <div class="presets"> <button class="btn-ghost preset" data-preset="ust" data-i18n="preset.ust"></button> <button class="btn-ghost preset" data-preset="pipeline" data-i18n="preset.pipeline"></button> <button class="btn-ghost preset" data-preset="terminal" data-i18n="preset.terminal"></button> </div> <div class="grid2"> <div class="field"> <label for="volSlider"><span data-i18n="inputs.volume"></span> <span class="slider-val" id="volVal"></span></label> <input type="range" id="volSlider" min="0" max="1000" step="1"> </div> <div class="field"> <label for="in_labCostPerSample" data-i18n="inputs.labCost"></label> <div class="moneyfield"><span class="cur"></span><input type="number" id="in_labCostPerSample" min="0" step="5"></div> </div> <div class="field"> <label for="in_labTurnaroundDays" data-i18n="inputs.turnaround"></label> <input type="number" id="in_labTurnaroundDays" min="1" step="1"> </div> <div class="field"> <label for="in_crewDayRate" data-i18n="inputs.crewRate"></label> <div class="moneyfield"><span class="cur"></span><input type="number" id="in_crewDayRate" min="0" step="100"></div> </div> <div class="field"> <label for="in_disposalContamPerT" data-i18n="inputs.disposalContam"></label> <div class="moneyfield"><span class="cur"></span><input type="number" id="in_disposalContamPerT" min="0" step="5"></div> </div> </div> <button id="advBtn" class="btn-ghost" style="margin-block-start:16px;" data-i18n="inputs.advanced" aria-expanded="false"></button> <div id="advPanel" class="adv"></div> </section> <section id="results"> <div class="results-head"><button id="pdfBtn" class="btn-ghost" data-i18n="pdf.buttonFull"></button></div> <div class="stats"> <div class="stat primary"><div class="v" id="st_saving">0</div><div class="l" data-i18n="stats.saving"></div></div> <div class="stat"><div class="v" id="st_pct">0%</div><div class="l" data-i18n="stats.savingPct"></div></div> <div class="stat"><div class="v" id="st_days">0</div><div class="l" data-i18n="stats.days"></div></div> <div class="stat"><div class="v" id="st_tonnes">0</div><div class="l" data-i18n="stats.tonnes"></div></div> </div> <div class="totalsline"><span data-i18n="stats.totalBase"></span>: <span id="st_totB"></span> · <span data-i18n="stats.totalRem"></span>: <span id="st_totR"></span></div> </section> <section> <div class="anim-head"><h2 data-i18n="anim.title" style="margin:0;"></h2><button id="playBtn" class="btn-ghost"></button></div> <div class="anim-wrap"> <div class="panel" id="panelB"><h3 data-i18n="tl.base"></h3><div class="caption" data-i18n="anim.caption.base"></div><div class="svgbox"></div><div class="status"></div></div> <div class="panel" id="panelR"><h3 data-i18n="tl.rem"></h3><div class="caption" data-i18n="anim.caption.rem"></div><div class="svgbox"></div><div class="status"></div></div> </div> </section> <section> <h2 data-i18n="tl.title"></h2> <div id="timelineBox"></div> </section> <section> <h2 data-i18n="chart.title"></h2> <div class="bar-label" data-i18n="tl.base"></div> <div id="breakB"></div> <div class="bar-label" data-i18n="tl.rem"></div> <div id="breakR"></div> <div class="legend" id="breakLegend"></div> <h2 data-i18n="flow.title" style="margin-block-start:22px;"></h2> <div id="flowBox"></div> </section> <section id="assumeSec"> <details> <summary data-i18n="assume.title"></summary> <div class="assume-notes"> <p data-i18n="assume.note1"></p> <p data-i18n="assume.note2"></p> <p data-i18n="assume.note3"></p> </div> <div class="assume-grid" id="assumeGrid"></div> </details> </section> <section id="cta"> <h2 data-i18n="cta.title"></h2> <p data-i18n="cta.body"></p> <button id="ctaBtn" class="btn-accent" data-i18n="cta.button"></button> </section> </main> <div id="printReport" dir="auto"></div> <footer data-i18n="footer.disclaimer"></footer> <div id="tooltip" role="tooltip"></div> <script> "use strict"; /* ============================================================ 1. CONSTANTS ============================================================ */ const OVERDIG = { uniform:{base:1.15, rem:1.05}, hotspots:{base:1.35, rem:1.10} }; const SEG = { baseOverdigContamFrac:0.90, remOverdigContamFrac:1.00 }; const BIAS = { conservative:{failBaseMul:0.6, overdigMul:0.9}, typical:{failBaseMul:1.0, overdigMul:1.0}, favourable:{failBaseMul:1.4, overdigMul:1.15} }; const DEMOB_THRESHOLD_DAYS = 7; const REDIG_M3_PER_FAILURE = 25; const WET_DRY_DAYS_FACTOR = 0.05; const ANIM_LOOP_SECONDS = 30; const M3_TO_YD3 = 1.30795; const T_TO_USTON = 1.10231; const VOL_MIN = 100, VOL_MAX = 50000; const BUCKET_COLORS = { standby:"#e5484d", excavation:"#5b8fa3", haulage:"#7aa5b8", disposalContam:"#f5a623", disposalClean:"#35c46a", lab:"#c9d4d9", consultant:"#9aa8ae", mob:"#6d7a80", backfill:"#4a6b7a", other:"#00e5ff" }; const BUCKET_ORDER = ["standby","excavation","haulage","disposalContam","disposalClean","lab","consultant","mob","backfill","other"]; /* ============================================================ 2. I18N ============================================================ */ const EN = { "app.title":"TPH Remediation Cost Simulator", "app.tagline":"Compare traditional lab workflows with RemScan + GXLab", "inputs.presets":"Example projects", "preset.ust":"Service station tank removal", "preset.pipeline":"Remote pipeline spill", "preset.terminal":"Fuel terminal closure", "inputs.volume":"Contaminated volume", "inputs.labCost":"Lab cost per TPH sample", "inputs.turnaround":"Lab turnaround (days)", "inputs.crewRate":"Crew day rate", "inputs.disposalContam":"Disposal rate, contaminated soil", "inputs.advanced":"Advanced settings", "adv.site":"Site","adv.rates":"Rates","adv.sampling":"Sampling","adv.remscan":"RemScan and GXLab", "adv.bulkDensity":"Bulk density (t/m3)", "adv.pctAbove":"Share of volume above criteria", "adv.distribution":"Contamination pattern", "adv.dist.uniform":"Uniform","adv.dist.hotspots":"Hotspots", "adv.disposalClean":"Disposal rate, clean soil", "adv.haulage":"Haulage per tonne", "adv.backfill":"Backfill per tonne", "adv.consultant":"Consultant day rate", "adv.mob":"Mobilisation cost", "adv.stockpile":"Stockpile holding per day", "adv.classSamples":"Classification samples per 100 m3", "adv.valSamples":"Validation samples per 250 m3", "adv.failBase":"Validation failure rate, traditional", "adv.failRem":"Validation failure rate, RemScan pre-screened", "adv.chase":"Delineation samples per round", "adv.digRate":"Excavation rate (m3/day)", "adv.batch":"Batch size (m3)", "adv.pid":"PID rental per day", "adv.bias":"Scenario bias", "adv.bias.conservative":"Conservative","adv.bias.typical":"Typical","adv.bias.favourable":"Favourable", "adv.wet":"Wet site (moisture above 5%)", "adv.remMode":"RemScan access", "adv.remMode.rental":"Rental","adv.remMode.purchase":"Purchase", "adv.remRental":"RemScan rental per month", "adv.remPurchase":"RemScan purchase price", "adv.remLife":"Instrument life for costing (years)", "adv.gxlabSub":"GXLab annual licence", "adv.calibration":"Site calibration (large projects)", "stats.saving":"Estimated saving", "stats.savingPct":"Cost reduction", "stats.days":"Days saved", "stats.tonnes":"Tonnes kept out of contaminated disposal", "stats.totalBase":"Traditional total", "stats.totalRem":"RemScan + GXLab total", "tl.title":"Project timeline", "tl.base":"Traditional (PID + lab)", "tl.rem":"RemScan + GXLab", "tl.days":"days", "chart.title":"Where the money goes", "chart.standby":"Crew standby","chart.excavation":"Excavation","chart.haulage":"Haulage", "chart.disposalContam":"Disposal, contaminated","chart.disposalClean":"Disposal, clean", "chart.lab":"Laboratory analysis","chart.consultant":"Consultant","chart.mob":"Mobilisation", "chart.backfill":"Backfill","chart.other":"Field equipment and software", "flow.title":"Where the soil goes", "flow.dug":"Excavated","flow.contam":"Contaminated disposal","flow.clean":"Clean disposal or reuse", "anim.title":"The dig, side by side", "anim.caption.base":"Digs blind. Waits on lab results between cuts and over-excavates for safety.", "anim.caption.rem":"The operator scans each bucket load as digging continues. The dig stops at the contamination line.", "anim.digging":"Digging","anim.waiting":"Waiting for lab results","anim.scanning":"Scanning and digging", "anim.rework":"Rework after failed validation","anim.done":"Complete","anim.day":"Day", "assume.title":"Assumptions and every number used", "assume.note1":"RemScan complements accredited laboratory analysis. Both scenarios include laboratory compliance samples for classification and validation.", "assume.note2":"All defaults are indicative. Adjust every figure to your project and local regulatory requirements.", "assume.note3":"Scope is TPH only. Groundwater, asbestos and PFAS are outside this model.", "pdf.button":"Export PDF","pdf.buttonFull":"Export cost-benefit analysis","pdf.title":"Cost benefit analysis","pdf.prepared":"Prepared with the GXLab TPH Remediation Cost Simulator","pdf.item":"Cost item","pdf.delta":"Difference","pdf.date":"Date", "cta.title":"Get a detailed assessment for your site", "cta.body":"Send us your project outline and a GXLab specialist will respond with a tailored comparison.", "cta.button":"Contact GXLab", "footer.disclaimer":"Estimates are indicative only and do not constitute engineering or financial advice.", "units.metric":"Metric","units.us":"US", "play":"Play","pause":"Pause" }; /* MACHINE DRAFT, pending native review */ const FR = { "app.title":"Simulateur de coûts de dépollution TPH", "app.tagline":"Comparez les flux de laboratoire traditionnels avec RemScan + GXLab", "inputs.presets":"Projets types", "preset.ust":"Retrait de cuve de station-service", "preset.pipeline":"Déversement de pipeline isolé", "preset.terminal":"Fermeture de dépôt pétrolier", "inputs.volume":"Volume contaminé", "inputs.labCost":"Coût labo par échantillon TPH", "inputs.turnaround":"Délai labo (jours)", "inputs.crewRate":"Tarif journalier de l'équipe", "inputs.disposalContam":"Tarif d'élimination, sol contaminé", "inputs.advanced":"Paramètres avancés", "adv.site":"Site","adv.rates":"Tarifs","adv.sampling":"Échantillonnage","adv.remscan":"RemScan et GXLab", "adv.bulkDensity":"Densité apparente (t/m3)", "adv.pctAbove":"Part du volume au-dessus des critères", "adv.distribution":"Répartition de la contamination", "adv.dist.uniform":"Uniforme","adv.dist.hotspots":"Points chauds", "adv.disposalClean":"Tarif d'élimination, sol propre", "adv.haulage":"Transport par tonne", "adv.backfill":"Remblai par tonne", "adv.consultant":"Tarif journalier du consultant", "adv.mob":"Coût de mobilisation", "adv.stockpile":"Stockage par jour", "adv.classSamples":"Échantillons de classification par 100 m3", "adv.valSamples":"Échantillons de validation par 250 m3", "adv.failBase":"Taux d'échec de validation, traditionnel", "adv.failRem":"Taux d'échec de validation, pré-criblage RemScan", "adv.chase":"Échantillons de délimitation par cycle", "adv.digRate":"Cadence d'excavation (m3/jour)", "adv.batch":"Taille de lot (m3)", "adv.pid":"Location PID par jour", "adv.bias":"Biais de scénario", "adv.bias.conservative":"Prudent","adv.bias.typical":"Typique","adv.bias.favourable":"Favorable", "adv.wet":"Site humide (humidité supérieure à 5 %)", "adv.remMode":"Accès RemScan", "adv.remMode.rental":"Location","adv.remMode.purchase":"Achat", "adv.remRental":"Location RemScan par mois", "adv.remPurchase":"Prix d'achat RemScan", "adv.remLife":"Durée d'amortissement de l'instrument (années)", "adv.gxlabSub":"Licence annuelle GXLab", "adv.calibration":"Calibration de site (grands projets)", "stats.saving":"Économie estimée", "stats.savingPct":"Réduction des coûts", "stats.days":"Jours gagnés", "stats.tonnes":"Tonnes évitant l'élimination contaminée", "stats.totalBase":"Total traditionnel", "stats.totalRem":"Total RemScan + GXLab", "tl.title":"Calendrier du projet", "tl.base":"Traditionnel (PID + labo)", "tl.rem":"RemScan + GXLab", "tl.days":"jours", "chart.title":"Où va l'argent", "chart.standby":"Attente de l'équipe","chart.excavation":"Excavation","chart.haulage":"Transport", "chart.disposalContam":"Élimination, contaminé","chart.disposalClean":"Élimination, propre", "chart.lab":"Analyses de laboratoire","chart.consultant":"Consultant","chart.mob":"Mobilisation", "chart.backfill":"Remblai","chart.other":"Équipement de terrain et logiciel", "flow.title":"Où va le sol", "flow.dug":"Excavé","flow.contam":"Élimination contaminée","flow.clean":"Élimination propre ou réemploi", "anim.title":"L'excavation, côte à côte", "anim.caption.base":"Creuse à l'aveugle. Attend les résultats labo entre les passes et surexcave par sécurité.", "anim.caption.rem":"L'opérateur scanne chaque godet pendant que l'excavation continue. Le creusement s'arrête à la limite de contamination.", "anim.digging":"Excavation","anim.waiting":"Attente des résultats labo","anim.scanning":"Scan et excavation", "anim.rework":"Reprise après échec de validation","anim.done":"Terminé","anim.day":"Jour", "assume.title":"Hypothèses et tous les chiffres utilisés", "assume.note1":"RemScan complète les analyses de laboratoire accréditées. Les deux scénarios incluent des échantillons de conformité pour la classification et la validation.", "assume.note2":"Toutes les valeurs par défaut sont indicatives. Ajustez chaque chiffre à votre projet et à la réglementation locale.", "assume.note3":"Portée limitée aux TPH. Eaux souterraines, amiante et PFAS sont hors modèle.", "pdf.button":"Exporter en PDF","pdf.buttonFull":"Exporter l'analyse coûts-avantages","pdf.title":"Analyse coûts-avantages","pdf.prepared":"Préparé avec le simulateur de coûts de dépollution TPH de GXLab","pdf.item":"Poste de coût","pdf.delta":"Écart","pdf.date":"Date", "cta.title":"Obtenez une évaluation détaillée de votre site", "cta.body":"Envoyez-nous les grandes lignes de votre projet et un spécialiste GXLab vous répondra avec une comparaison sur mesure.", "cta.button":"Contacter GXLab", "footer.disclaimer":"Les estimations sont indicatives et ne constituent pas un conseil d'ingénierie ou financier.", "units.metric":"Métrique","units.us":"US", "play":"Lecture","pause":"Pause" }; /* MACHINE DRAFT, pending native review */ const ES = { "app.title":"Simulador de costos de remediación TPH", "app.tagline":"Compare el flujo de laboratorio tradicional con RemScan + GXLab", "inputs.presets":"Proyectos de ejemplo", "preset.ust":"Retiro de tanque en estación de servicio", "preset.pipeline":"Derrame de oleoducto remoto", "preset.terminal":"Cierre de terminal de combustible", "inputs.volume":"Volumen contaminado", "inputs.labCost":"Costo de laboratorio por muestra TPH", "inputs.turnaround":"Plazo de laboratorio (días)", "inputs.crewRate":"Tarifa diaria de la cuadrilla", "inputs.disposalContam":"Tarifa de disposición, suelo contaminado", "inputs.advanced":"Configuración avanzada", "adv.site":"Sitio","adv.rates":"Tarifas","adv.sampling":"Muestreo","adv.remscan":"RemScan y GXLab", "adv.bulkDensity":"Densidad aparente (t/m3)", "adv.pctAbove":"Fracción del volumen sobre criterios", "adv.distribution":"Patrón de contaminación", "adv.dist.uniform":"Uniforme","adv.dist.hotspots":"Focos", "adv.disposalClean":"Tarifa de disposición, suelo limpio", "adv.haulage":"Transporte por tonelada", "adv.backfill":"Relleno por tonelada", "adv.consultant":"Tarifa diaria del consultor", "adv.mob":"Costo de movilización", "adv.stockpile":"Acopio por día", "adv.classSamples":"Muestras de clasificación por 100 m3", "adv.valSamples":"Muestras de validación por 250 m3", "adv.failBase":"Tasa de falla de validación, tradicional", "adv.failRem":"Tasa de falla de validación, precribado RemScan", "adv.chase":"Muestras de delimitación por ronda", "adv.digRate":"Ritmo de excavación (m3/día)", "adv.batch":"Tamaño de lote (m3)", "adv.pid":"Alquiler de PID por día", "adv.bias":"Sesgo del escenario", "adv.bias.conservative":"Conservador","adv.bias.typical":"Típico","adv.bias.favourable":"Favorable", "adv.wet":"Sitio húmedo (humedad mayor al 5%)", "adv.remMode":"Acceso a RemScan", "adv.remMode.rental":"Alquiler","adv.remMode.purchase":"Compra", "adv.remRental":"Alquiler de RemScan por mes", "adv.remPurchase":"Precio de compra de RemScan", "adv.remLife":"Vida útil del instrumento para el costeo (años)", "adv.gxlabSub":"Licencia anual de GXLab", "adv.calibration":"Calibración de sitio (proyectos grandes)", "stats.saving":"Ahorro estimado", "stats.savingPct":"Reducción de costos", "stats.days":"Días ahorrados", "stats.tonnes":"Toneladas fuera de disposición contaminada", "stats.totalBase":"Total tradicional", "stats.totalRem":"Total RemScan + GXLab", "tl.title":"Cronograma del proyecto", "tl.base":"Tradicional (PID + laboratorio)", "tl.rem":"RemScan + GXLab", "tl.days":"días", "chart.title":"A dónde va el dinero", "chart.standby":"Espera de cuadrilla","chart.excavation":"Excavación","chart.haulage":"Transporte", "chart.disposalContam":"Disposición, contaminado","chart.disposalClean":"Disposición, limpio", "chart.lab":"Análisis de laboratorio","chart.consultant":"Consultor","chart.mob":"Movilización", "chart.backfill":"Relleno","chart.other":"Equipo de campo y software", "flow.title":"A dónde va el suelo", "flow.dug":"Excavado","flow.contam":"Disposición contaminada","flow.clean":"Disposición limpia o reúso", "anim.title":"La excavación, lado a lado", "anim.caption.base":"Excava a ciegas. Espera resultados de laboratorio entre cortes y sobreexcava por seguridad.", "anim.caption.rem":"El operador escanea cada balde mientras la excavación continúa. La excavación se detiene en el límite de contaminación.", "anim.digging":"Excavando","anim.waiting":"Esperando resultados de laboratorio","anim.scanning":"Escaneando y excavando", "anim.rework":"Retrabajo tras falla de validación","anim.done":"Completo","anim.day":"Día", "assume.title":"Supuestos y todas las cifras usadas", "assume.note1":"RemScan complementa el análisis de laboratorio acreditado. Ambos escenarios incluyen muestras de cumplimiento para clasificación y validación.", "assume.note2":"Todos los valores por defecto son indicativos. Ajuste cada cifra a su proyecto y a la normativa local.", "assume.note3":"Alcance limitado a TPH. Agua subterránea, asbesto y PFAS quedan fuera del modelo.", "pdf.button":"Exportar PDF","pdf.buttonFull":"Exportar análisis de costo-beneficio","pdf.title":"Análisis de costo-beneficio","pdf.prepared":"Elaborado con el simulador de costos de remediación TPH de GXLab","pdf.item":"Concepto de costo","pdf.delta":"Diferencia","pdf.date":"Fecha", "cta.title":"Obtenga una evaluación detallada de su sitio", "cta.body":"Envíenos un resumen de su proyecto y un especialista de GXLab responderá con una comparación a medida.", "cta.button":"Contactar a GXLab", "footer.disclaimer":"Las estimaciones son indicativas y no constituyen asesoramiento de ingeniería ni financiero.", "units.metric":"Métrico","units.us":"EE. UU.", "play":"Reproducir","pause":"Pausa" }; /* MACHINE DRAFT, pending native review */ const AR = { "app.title":"محاكي تكاليف معالجة التلوث النفطي TPH", "app.tagline":"قارن بين المختبر التقليدي وRemScan + GXLab", "inputs.presets":"مشاريع نموذجية", "preset.ust":"إزالة خزان محطة وقود", "preset.pipeline":"تسرب خط أنابيب في موقع نائٍ", "preset.terminal":"إغلاق مستودع وقود", "inputs.volume":"الحجم الملوث", "inputs.labCost":"تكلفة المختبر لكل عينة TPH", "inputs.turnaround":"مدة نتائج المختبر (أيام)", "inputs.crewRate":"الأجر اليومي للطاقم", "inputs.disposalContam":"تكلفة التخلص، تربة ملوثة", "inputs.advanced":"إعدادات متقدمة", "adv.site":"الموقع","adv.rates":"الأسعار","adv.sampling":"أخذ العينات","adv.remscan":"RemScan وGXLab", "adv.bulkDensity":"الكثافة الظاهرية (طن/م3)", "adv.pctAbove":"نسبة الحجم فوق المعايير", "adv.distribution":"نمط التلوث", "adv.dist.uniform":"منتظم","adv.dist.hotspots":"بؤر", "adv.disposalClean":"تكلفة التخلص، تربة نظيفة", "adv.haulage":"النقل لكل طن", "adv.backfill":"الردم لكل طن", "adv.consultant":"الأجر اليومي للاستشاري", "adv.mob":"تكلفة التعبئة", "adv.stockpile":"تخزين الأكوام لكل يوم", "adv.classSamples":"عينات التصنيف لكل 100 م3", "adv.valSamples":"عينات التحقق لكل 250 م3", "adv.failBase":"معدل فشل التحقق، تقليدي", "adv.failRem":"معدل فشل التحقق، فحص RemScan المسبق", "adv.chase":"عينات تحديد الحدود لكل جولة", "adv.digRate":"معدل الحفر (م3/يوم)", "adv.batch":"حجم الدفعة (م3)", "adv.pid":"إيجار PID لكل يوم", "adv.bias":"انحياز السيناريو", "adv.bias.conservative":"متحفظ","adv.bias.typical":"نموذجي","adv.bias.favourable":"مواتٍ", "adv.wet":"موقع رطب (رطوبة فوق 5%)", "adv.remMode":"الحصول على RemScan", "adv.remMode.rental":"إيجار","adv.remMode.purchase":"شراء", "adv.remRental":"إيجار RemScan شهرياً", "adv.remPurchase":"سعر شراء RemScan", "adv.remLife":"عمر الجهاز لاحتساب التكلفة (سنوات)", "adv.gxlabSub":"ترخيص GXLab السنوي", "adv.calibration":"معايرة الموقع (المشاريع الكبيرة)", "stats.saving":"الوفر المقدر", "stats.savingPct":"خفض التكلفة", "stats.days":"الأيام الموفرة", "stats.tonnes":"أطنان جُنّبت التخلص الملوث", "stats.totalBase":"إجمالي التقليدي", "stats.totalRem":"إجمالي RemScan + GXLab", "tl.title":"الجدول الزمني للمشروع", "tl.base":"تقليدي (PID + مختبر)", "tl.rem":"RemScan + GXLab", "tl.days":"يوم", "chart.title":"أين تذهب الأموال", "chart.standby":"انتظار الطاقم","chart.excavation":"الحفر","chart.haulage":"النقل", "chart.disposalContam":"تخلص، ملوث","chart.disposalClean":"تخلص، نظيف", "chart.lab":"تحاليل المختبر","chart.consultant":"استشاري","chart.mob":"تعبئة", "chart.backfill":"ردم","chart.other":"معدات ميدانية وبرمجيات", "flow.title":"أين تذهب التربة", "flow.dug":"محفور","flow.contam":"تخلص ملوث","flow.clean":"تخلص نظيف أو إعادة استخدام", "anim.title":"الحفر، جنباً إلى جنب", "anim.caption.base":"يحفر دون رؤية. ينتظر نتائج المختبر بين القطعات ويحفر زيادة للأمان.", "anim.caption.rem":"يفحص المشغل كل حمولة قادوس أثناء استمرار الحفر. يتوقف الحفر عند حد التلوث.", "anim.digging":"جارٍ الحفر","anim.waiting":"بانتظار نتائج المختبر","anim.scanning":"مسح وحفر", "anim.rework":"إعادة عمل بعد فشل التحقق","anim.done":"مكتمل","anim.day":"اليوم", "assume.title":"الافتراضات وكل الأرقام المستخدمة", "assume.note1":"يكمل RemScan تحاليل المختبرات المعتمدة. يشمل كلا السيناريوهين عينات امتثال مخبرية للتصنيف والتحقق.", "assume.note2":"جميع القيم الافتراضية إرشادية. عدّل كل رقم بحسب مشروعك واللوائح المحلية.", "assume.note3":"النطاق يقتصر على TPH. المياه الجوفية والأسبستوس وPFAS خارج النموذج.", "pdf.button":"تصدير PDF","pdf.buttonFull":"تصدير تحليل التكلفة والعائد","pdf.title":"تحليل التكلفة والعائد","pdf.prepared":"أُعد باستخدام محاكي GXLab لتكاليف معالجة TPH","pdf.item":"بند التكلفة","pdf.delta":"الفرق","pdf.date":"التاريخ", "cta.title":"احصل على تقييم مفصل لموقعك", "cta.body":"أرسل لنا ملخص مشروعك وسيرد عليك مختص من GXLab بمقارنة مخصصة.", "cta.button":"تواصل مع GXLab", "footer.disclaimer":"التقديرات إرشادية فقط ولا تشكل استشارة هندسية أو مالية.", "units.metric":"متري","units.us":"أمريكي", "play":"تشغيل","pause":"إيقاف" }; /* MACHINE DRAFT, pending native review */ const HI = { "app.title":"TPH उपचार लागत सिम्युलेटर", "app.tagline":"पारंपरिक लैब प्रक्रिया की तुलना RemScan + GXLab से करें", "inputs.presets":"उदाहरण परियोजनाएँ", "preset.ust":"पेट्रोल पंप टैंक हटाना", "preset.pipeline":"दूरस्थ पाइपलाइन रिसाव", "preset.terminal":"ईंधन टर्मिनल बंदी", "inputs.volume":"दूषित आयतन", "inputs.labCost":"प्रति TPH नमूना लैब लागत", "inputs.turnaround":"लैब समय (दिन)", "inputs.crewRate":"दल की दैनिक दर", "inputs.disposalContam":"निपटान दर, दूषित मिट्टी", "inputs.advanced":"उन्नत सेटिंग्स", "adv.site":"साइट","adv.rates":"दरें","adv.sampling":"नमूनाकरण","adv.remscan":"RemScan और GXLab", "adv.bulkDensity":"थोक घनत्व (टन/मी3)", "adv.pctAbove":"मानदंड से ऊपर आयतन का हिस्सा", "adv.distribution":"संदूषण का पैटर्न", "adv.dist.uniform":"एकसमान","adv.dist.hotspots":"हॉटस्पॉट", "adv.disposalClean":"निपटान दर, स्वच्छ मिट्टी", "adv.haulage":"ढुलाई प्रति टन", "adv.backfill":"भराई प्रति टन", "adv.consultant":"सलाहकार दैनिक दर", "adv.mob":"मोबिलाइज़ेशन लागत", "adv.stockpile":"भंडारण प्रति दिन", "adv.classSamples":"वर्गीकरण नमूने प्रति 100 मी3", "adv.valSamples":"सत्यापन नमूने प्रति 250 मी3", "adv.failBase":"सत्यापन विफलता दर, पारंपरिक", "adv.failRem":"सत्यापन विफलता दर, RemScan पूर्व-जांच", "adv.chase":"सीमांकन नमूने प्रति चक्र", "adv.digRate":"खुदाई दर (मी3/दिन)", "adv.batch":"बैच आकार (मी3)", "adv.pid":"PID किराया प्रति दिन", "adv.bias":"परिदृश्य झुकाव", "adv.bias.conservative":"सतर्क","adv.bias.typical":"सामान्य","adv.bias.favourable":"अनुकूल", "adv.wet":"गीली साइट (नमी 5% से अधिक)", "adv.remMode":"RemScan पहुँच", "adv.remMode.rental":"किराया","adv.remMode.purchase":"खरीद", "adv.remRental":"RemScan किराया प्रति माह", "adv.remPurchase":"RemScan खरीद मूल्य", "adv.remLife":"लागत हेतु उपकरण का जीवनकाल (वर्ष)", "adv.gxlabSub":"GXLab वार्षिक लाइसेंस", "adv.calibration":"साइट अंशांकन (बड़ी परियोजनाएँ)", "stats.saving":"अनुमानित बचत", "stats.savingPct":"लागत में कमी", "stats.days":"बचाए गए दिन", "stats.tonnes":"दूषित निपटान से बचाए गए टन", "stats.totalBase":"पारंपरिक कुल", "stats.totalRem":"RemScan + GXLab कुल", "tl.title":"परियोजना समयरेखा", "tl.base":"पारंपरिक (PID + लैब)", "tl.rem":"RemScan + GXLab", "tl.days":"दिन", "chart.title":"पैसा कहाँ जाता है", "chart.standby":"दल की प्रतीक्षा","chart.excavation":"खुदाई","chart.haulage":"ढुलाई", "chart.disposalContam":"निपटान, दूषित","chart.disposalClean":"निपटान, स्वच्छ", "chart.lab":"प्रयोगशाला विश्लेषण","chart.consultant":"सलाहकार","chart.mob":"मोबिलाइज़ेशन", "chart.backfill":"भराई","chart.other":"फील्ड उपकरण और सॉफ़्टवेयर", "flow.title":"मिट्टी कहाँ जाती है", "flow.dug":"खोदी गई","flow.contam":"दूषित निपटान","flow.clean":"स्वच्छ निपटान या पुनः उपयोग", "anim.title":"खुदाई, साथ-साथ", "anim.caption.base":"बिना जानकारी खुदाई। हर कट के बीच लैब परिणामों की प्रतीक्षा और सुरक्षा के लिए अधिक खुदाई।", "anim.caption.rem":"खुदाई जारी रहते हुए ऑपरेटर हर बकेट लोड को स्कैन करता है। खुदाई संदूषण सीमा पर रुकती है।", "anim.digging":"खुदाई जारी","anim.waiting":"लैब परिणामों की प्रतीक्षा","anim.scanning":"स्कैन और खुदाई", "anim.rework":"सत्यापन विफल होने पर पुनर्कार्य","anim.done":"पूर्ण","anim.day":"दिन", "assume.title":"मान्यताएँ और उपयोग की गई सभी संख्याएँ", "assume.note1":"RemScan मान्यता प्राप्त प्रयोगशाला विश्लेषण का पूरक है। दोनों परिदृश्यों में वर्गीकरण और सत्यापन के लिए अनुपालन नमूने शामिल हैं।", "assume.note2":"सभी डिफ़ॉल्ट सांकेतिक हैं। हर संख्या अपनी परियोजना और स्थानीय नियमों के अनुसार बदलें।", "assume.note3":"दायरा केवल TPH तक सीमित है। भूजल, एस्बेस्टस और PFAS मॉडल से बाहर हैं।", "pdf.button":"PDF निर्यात करें","pdf.buttonFull":"लागत-लाभ विश्लेषण निर्यात करें","pdf.title":"लागत-लाभ विश्लेषण","pdf.prepared":"GXLab TPH उपचार लागत सिम्युलेटर से तैयार","pdf.item":"लागत मद","pdf.delta":"अंतर","pdf.date":"तिथि", "cta.title":"अपनी साइट का विस्तृत आकलन प्राप्त करें", "cta.body":"हमें अपनी परियोजना की रूपरेखा भेजें और GXLab विशेषज्ञ अनुकूलित तुलना के साथ उत्तर देंगे।", "cta.button":"GXLab से संपर्क करें", "footer.disclaimer":"अनुमान केवल सांकेतिक हैं और इंजीनियरिंग या वित्तीय सलाह नहीं हैं।", "units.metric":"मीट्रिक","units.us":"US", "play":"चलाएँ","pause":"रोकें" }; /* MACHINE DRAFT, pending native review */ const ID = { "app.title":"Simulator Biaya Remediasi TPH", "app.tagline":"Bandingkan alur lab tradisional dengan RemScan + GXLab", "inputs.presets":"Contoh proyek", "preset.ust":"Pembongkaran tangki SPBU", "preset.pipeline":"Tumpahan pipa di lokasi terpencil", "preset.terminal":"Penutupan terminal bahan bakar", "inputs.volume":"Volume terkontaminasi", "inputs.labCost":"Biaya lab per sampel TPH", "inputs.turnaround":"Waktu hasil lab (hari)", "inputs.crewRate":"Tarif harian kru", "inputs.disposalContam":"Tarif pembuangan, tanah terkontaminasi", "inputs.advanced":"Pengaturan lanjutan", "adv.site":"Lokasi","adv.rates":"Tarif","adv.sampling":"Pengambilan sampel","adv.remscan":"RemScan dan GXLab", "adv.bulkDensity":"Kepadatan curah (t/m3)", "adv.pctAbove":"Porsi volume di atas kriteria", "adv.distribution":"Pola kontaminasi", "adv.dist.uniform":"Merata","adv.dist.hotspots":"Titik panas", "adv.disposalClean":"Tarif pembuangan, tanah bersih", "adv.haulage":"Pengangkutan per ton", "adv.backfill":"Urugan per ton", "adv.consultant":"Tarif harian konsultan", "adv.mob":"Biaya mobilisasi", "adv.stockpile":"Penimbunan per hari", "adv.classSamples":"Sampel klasifikasi per 100 m3", "adv.valSamples":"Sampel validasi per 250 m3", "adv.failBase":"Tingkat gagal validasi, tradisional", "adv.failRem":"Tingkat gagal validasi, prapemindaian RemScan", "adv.chase":"Sampel delineasi per putaran", "adv.digRate":"Laju penggalian (m3/hari)", "adv.batch":"Ukuran batch (m3)", "adv.pid":"Sewa PID per hari", "adv.bias":"Bias skenario", "adv.bias.conservative":"Konservatif","adv.bias.typical":"Tipikal","adv.bias.favourable":"Menguntungkan", "adv.wet":"Lokasi basah (kelembapan di atas 5%)", "adv.remMode":"Akses RemScan", "adv.remMode.rental":"Sewa","adv.remMode.purchase":"Beli", "adv.remRental":"Sewa RemScan per bulan", "adv.remPurchase":"Harga beli RemScan", "adv.remLife":"Umur alat untuk perhitungan biaya (tahun)", "adv.gxlabSub":"Lisensi tahunan GXLab", "adv.calibration":"Kalibrasi lokasi (proyek besar)", "stats.saving":"Perkiraan penghematan", "stats.savingPct":"Pengurangan biaya", "stats.days":"Hari yang dihemat", "stats.tonnes":"Ton terhindar dari pembuangan terkontaminasi", "stats.totalBase":"Total tradisional", "stats.totalRem":"Total RemScan + GXLab", "tl.title":"Linimasa proyek", "tl.base":"Tradisional (PID + lab)", "tl.rem":"RemScan + GXLab", "tl.days":"hari", "chart.title":"Ke mana uang mengalir", "chart.standby":"Kru menunggu","chart.excavation":"Penggalian","chart.haulage":"Pengangkutan", "chart.disposalContam":"Pembuangan, terkontaminasi","chart.disposalClean":"Pembuangan, bersih", "chart.lab":"Analisis laboratorium","chart.consultant":"Konsultan","chart.mob":"Mobilisasi", "chart.backfill":"Urugan","chart.other":"Peralatan lapangan dan perangkat lunak", "flow.title":"Ke mana tanah pergi", "flow.dug":"Digali","flow.contam":"Pembuangan terkontaminasi","flow.clean":"Pembuangan bersih atau guna ulang", "anim.title":"Penggalian, berdampingan", "anim.caption.base":"Menggali tanpa data. Menunggu hasil lab di antara galian dan menggali berlebih demi aman.", "anim.caption.rem":"Operator memindai setiap muatan bucket saat penggalian berlanjut. Galian berhenti di batas kontaminasi.", "anim.digging":"Menggali","anim.waiting":"Menunggu hasil lab","anim.scanning":"Memindai dan menggali", "anim.rework":"Pengerjaan ulang setelah gagal validasi","anim.done":"Selesai","anim.day":"Hari", "assume.title":"Asumsi dan semua angka yang digunakan", "assume.note1":"RemScan melengkapi analisis laboratorium terakreditasi. Kedua skenario mencakup sampel kepatuhan untuk klasifikasi dan validasi.", "assume.note2":"Semua nilai default bersifat indikatif. Sesuaikan setiap angka dengan proyek dan peraturan setempat.", "assume.note3":"Cakupan hanya TPH. Air tanah, asbes, dan PFAS di luar model ini.", "pdf.button":"Ekspor PDF","pdf.buttonFull":"Ekspor analisis biaya-manfaat","pdf.title":"Analisis biaya-manfaat","pdf.prepared":"Disusun dengan Simulator Biaya Remediasi TPH GXLab","pdf.item":"Pos biaya","pdf.delta":"Selisih","pdf.date":"Tanggal", "cta.title":"Dapatkan penilaian terperinci untuk lokasi Anda", "cta.body":"Kirimkan garis besar proyek Anda dan spesialis GXLab akan membalas dengan perbandingan yang disesuaikan.", "cta.button":"Hubungi GXLab", "footer.disclaimer":"Perkiraan hanya indikatif dan bukan nasihat teknik atau keuangan.", "units.metric":"Metrik","units.us":"AS", "play":"Putar","pause":"Jeda" }; /* MACHINE DRAFT, pending native review */ const RU = { "app.title":"Симулятор затрат на ремедиацию TPH", "app.tagline":"Сравните традиционную лабораторию с RemScan + GXLab", "inputs.presets":"Примеры проектов", "preset.ust":"Демонтаж резервуара АЗС", "preset.pipeline":"Разлив на удалённом трубопроводе", "preset.terminal":"Закрытие топливного терминала", "inputs.volume":"Загрязнённый объём", "inputs.labCost":"Стоимость анализа TPH за пробу", "inputs.turnaround":"Срок лаборатории (дни)", "inputs.crewRate":"Дневная ставка бригады", "inputs.disposalContam":"Тариф утилизации, загрязнённый грунт", "inputs.advanced":"Расширенные настройки", "adv.site":"Площадка","adv.rates":"Тарифы","adv.sampling":"Отбор проб","adv.remscan":"RemScan и GXLab", "adv.bulkDensity":"Насыпная плотность (т/м3)", "adv.pctAbove":"Доля объёма выше критериев", "adv.distribution":"Характер загрязнения", "adv.dist.uniform":"Равномерное","adv.dist.hotspots":"Очаги", "adv.disposalClean":"Тариф утилизации, чистый грунт", "adv.haulage":"Перевозка за тонну", "adv.backfill":"Обратная засыпка за тонну", "adv.consultant":"Дневная ставка консультанта", "adv.mob":"Стоимость мобилизации", "adv.stockpile":"Хранение отвала в день", "adv.classSamples":"Пробы классификации на 100 м3", "adv.valSamples":"Пробы валидации на 250 м3", "adv.failBase":"Доля неудачных валидаций, традиционно", "adv.failRem":"Доля неудачных валидаций, предскрининг RemScan", "adv.chase":"Пробы оконтуривания за цикл", "adv.digRate":"Темп выемки (м3/день)", "adv.batch":"Размер партии (м3)", "adv.pid":"Аренда PID в день", "adv.bias":"Смещение сценария", "adv.bias.conservative":"Консервативный","adv.bias.typical":"Типичный","adv.bias.favourable":"Благоприятный", "adv.wet":"Влажная площадка (влажность выше 5%)", "adv.remMode":"Доступ к RemScan", "adv.remMode.rental":"Аренда","adv.remMode.purchase":"Покупка", "adv.remRental":"Аренда RemScan в месяц", "adv.remPurchase":"Цена покупки RemScan", "adv.remLife":"Срок службы прибора для расчёта (лет)", "adv.gxlabSub":"Годовая лицензия GXLab", "adv.calibration":"Калибровка площадки (крупные проекты)", "stats.saving":"Оценочная экономия", "stats.savingPct":"Снижение затрат", "stats.days":"Сэкономленные дни", "stats.tonnes":"Тонны вне загрязнённой утилизации", "stats.totalBase":"Итого традиционно", "stats.totalRem":"Итого RemScan + GXLab", "tl.title":"График проекта", "tl.base":"Традиционно (PID + лаборатория)", "tl.rem":"RemScan + GXLab", "tl.days":"дней", "chart.title":"Куда уходят деньги", "chart.standby":"Простой бригады","chart.excavation":"Выемка","chart.haulage":"Перевозка", "chart.disposalContam":"Утилизация, загрязнённый","chart.disposalClean":"Утилизация, чистый", "chart.lab":"Лабораторные анализы","chart.consultant":"Консультант","chart.mob":"Мобилизация", "chart.backfill":"Засыпка","chart.other":"Полевое оборудование и ПО", "flow.title":"Куда уходит грунт", "flow.dug":"Вынуто","flow.contam":"Загрязнённая утилизация","flow.clean":"Чистая утилизация или повторное использование", "anim.title":"Выемка, бок о бок", "anim.caption.base":"Копает вслепую. Ждёт результатов лаборатории между заходами и вынимает лишний грунт для запаса.", "anim.caption.rem":"Оператор сканирует каждый ковш, выемка не останавливается. Копка прекращается на границе загрязнения.", "anim.digging":"Выемка","anim.waiting":"Ожидание результатов лаборатории","anim.scanning":"Сканирование и выемка", "anim.rework":"Переделка после неудачной валидации","anim.done":"Завершено","anim.day":"День", "assume.title":"Допущения и все использованные цифры", "assume.note1":"RemScan дополняет аккредитованный лабораторный анализ. Оба сценария включают пробы для классификации и валидации.", "assume.note2":"Все значения по умолчанию ориентировочные. Скорректируйте каждую цифру под свой проект и местные требования.", "assume.note3":"Область охвата только TPH. Грунтовые воды, асбест и PFAS вне модели.", "pdf.button":"Экспорт в PDF","pdf.buttonFull":"Экспорт анализа затрат и выгод","pdf.title":"Анализ затрат и выгод","pdf.prepared":"Подготовлено с помощью симулятора затрат GXLab TPH","pdf.item":"Статья затрат","pdf.delta":"Разница","pdf.date":"Дата", "cta.title":"Получите детальную оценку вашей площадки", "cta.body":"Пришлите нам описание проекта, и специалист GXLab ответит с индивидуальным сравнением.", "cta.button":"Связаться с GXLab", "footer.disclaimer":"Оценки носят ориентировочный характер и не являются инженерной или финансовой консультацией.", "units.metric":"Метрические","units.us":"США", "play":"Пуск","pause":"Пауза" }; const I18N = { en:EN, fr:FR, es:ES, ar:AR, hi:HI, id:ID, ru:RU }; const LOCALE_NAMES = { en:"English", fr:"Français", es:"Español", ar:"العربية", hi:"हिन्दी", id:"Bahasa Indonesia", ru:"Русский" }; let L = I18N.en; function t(key){ return (L && L[key]) ?? I18N.en[key] ?? key; } /* ============================================================ 3a. FX (static mid-market snapshot, 3 July 2026, per 1 USD) ============================================================ */ const FX = { USD:1, AUD:1.45, CAD:1.42, EUR:0.87, NGN:1370, SAR:3.75, AED:3.67, BRL:5.15, IDR:17990 }; /* RemScan list prices. Currencies not listed convert from the USD list price. */ const REMSCAN_PRICES = { USD:{unit:65000, lic:15000}, AUD:{unit:100000, lic:20000} }; const MONEY_KEYS = ['labCostPerSample','crewDayRate','disposalContamPerT','disposalCleanPerT', 'haulagePerT','backfillPerT','consultantDayRate','mobilisationCost','stockpileHoldPerDay', 'pidPerDay','calibrationCost']; function roundNice(v){ if(!isFinite(v) || v===0) return 0; const m = Math.pow(10, Math.floor(Math.log10(Math.abs(v)))-2); return Math.round(v/m)*m; } function convertMoney(oldCur, newCur){ if(oldCur===newCur || !FX[oldCur] || !FX[newCur]) return; const k = FX[newCur]/FX[oldCur]; for(const key of MONEY_KEYS) state[key] = roundNice(state[key]*k); } function setRemscanPrices(cur){ const pl = REMSCAN_PRICES[cur]; if(pl){ state.remscanPurchase = pl.unit; state.gxlabLicensePerYear = pl.lic; } else { state.remscanPurchase = roundNice(REMSCAN_PRICES.USD.unit * FX[cur]); state.gxlabLicensePerYear = roundNice(REMSCAN_PRICES.USD.lic * FX[cur]); } } /* ============================================================ 4. PRESETS ============================================================ */ /* preset money values are USD and convert to the active currency on apply */ const PRESETS = { ust: {volume_m3:400, distribution:'hotspots', pctAboveCriteria:0.5, wetSite:true, labTurnaroundDays:8, usd:{mobilisationCost:15000, disposalContamPerT:250}}, pipeline: {volume_m3:1000, distribution:'hotspots', pctAboveCriteria:0.7, wetSite:false, labTurnaroundDays:21, usd:{mobilisationCost:100000, disposalContamPerT:100}}, terminal: {volume_m3:15000, distribution:'uniform', pctAboveCriteria:0.6, wetSite:false, labTurnaroundDays:10, usd:{mobilisationCost:20000, disposalContamPerT:90}}, }; /* ============================================================ 5. STATE + DEFAULTS ============================================================ */ const state = { locale:'en', dir:'ltr', currency:'USD', units:'metric', volume_m3:2000, labCostPerSample:120, labTurnaroundDays:8, crewDayRate:4500, disposalContamPerT:250, bulkDensity:1.6, pctAboveCriteria:0.6, distribution:'hotspots', disposalCleanPerT:40, haulagePerT:25, backfillPerT:35, consultantDayRate:1600, mobilisationCost:15000, stockpileHoldPerDay:200, classSamplesPer100m3:1.5, valSamplesPer250m3:1, failRateBase:0.15, failRateRem:0.02, chaseSamplesPerRound:10, digRateM3PerDay:300, batchM3:500, pidPerDay:150, scenarioBias:'typical', wetSite:false, remscanPurchase:65000, /* USD, confirmed */ gxlabLicensePerYear:15000, /* USD, confirmed, pro-rata by project months */ remscanLifeYears:5, /* capex amortisation horizon */ calibrationCost:5000, }; /* ============================================================ 6. ENGINE ============================================================ */ /*ENGINE-START*/ function simulate(s){ const Vc = s.volume_m3 * s.pctAboveCriteria; const bias = BIAS[s.scenarioBias]; const odB = OVERDIG[s.distribution].base * bias.overdigMul; const odR = OVERDIG[s.distribution].rem; const VdigB = Vc * odB; const VdigR = Vc * odR; const TdigB = VdigB * s.bulkDensity; const TdigR = VdigR * s.bulkDensity; const waitDays = s.labTurnaroundDays; /* ---- baseline ---- */ const roundsB = Math.max(1, Math.ceil(VdigB / s.batchM3)); const digDaysPerRnd = s.batchM3 / s.digRateM3PerDay; const standbyCostPerWait = waitDays * s.crewDayRate; const remobChoice = (waitDays > DEMOB_THRESHOLD_DAYS) && (s.mobilisationCost < standbyCostPerWait); const remobsB = remobChoice ? Math.max(0, roundsB - 1) : 0; const digDaysB = roundsB * digDaysPerRnd; const waitDaysTotB = roundsB * waitDays; const nVal = Math.max(5, Math.ceil(s.volume_m3 / 250) * s.valSamplesPer250m3); const failB = Math.round(nVal * s.failRateBase * bias.failBaseMul); const failDaysB = failB > 0 ? (REDIG_M3_PER_FAILURE * failB / s.digRateM3PerDay + waitDays) : 0; const daysB = Math.ceil(digDaysB + waitDaysTotB + failDaysB + 2); const nChaseB = roundsB * s.chaseSamplesPerRound; const nClass = Math.ceil(s.volume_m3 / 100 * s.classSamplesPer100m3); const nValB = nVal + failB; const trueContamT = Vc * s.bulkDensity; const baseOverdigT = Math.max(0, TdigB - trueContamT); const tContamB = Math.min(TdigB, trueContamT + baseOverdigT * SEG.baseOverdigContamFrac); const tCleanB = TdigB - tContamB; const baseCost = { standby: roundsB * (remobChoice ? 0 : standbyCostPerWait) + ((failB>0 && !remobChoice) ? waitDays * s.crewDayRate : 0), mob: s.mobilisationCost * (1 + remobsB + ((failB>0 && remobChoice) ? 1 : 0)), excavation: (digDaysB + (failB>0 ? REDIG_M3_PER_FAILURE*failB/s.digRateM3PerDay : 0)) * s.crewDayRate, pid: daysB * s.pidPerDay, labChase: nChaseB * s.labCostPerSample, labClass: nClass * s.labCostPerSample, labVal: nValB * s.labCostPerSample, haulage: TdigB * s.haulagePerT, disposalContam: tContamB * s.disposalContamPerT, disposalClean: tCleanB * s.disposalCleanPerT, backfill: TdigB * s.backfillPerT, consultant: (roundsB * 1 + 2) * s.consultantDayRate, stockpile: 0, remscan: 0, }; /* baseline segments */ const segB = []; let tb = 0; segB.push({type:'mob', startDay:tb, endDay:tb+1}); tb += 1; for(let r=0;r<roundsB;r++){ segB.push({type:'dig', startDay:tb, endDay:tb+digDaysPerRnd}); tb += digDaysPerRnd; segB.push({type:'wait', startDay:tb, endDay:tb+waitDays}); tb += waitDays; } if(failB>0){ segB.push({type:'rework', startDay:tb, endDay:tb+failDaysB}); tb += failDaysB; } segB.push({type:'demob', startDay:tb, endDay:tb+1}); tb += 1; /* ---- RemScan ---- */ const digDaysR = VdigR / s.digRateM3PerDay; const wetExtra = s.wetSite ? digDaysR * WET_DRY_DAYS_FACTOR : 0; const calDays = s.volume_m3 > 5000 ? 2 : 0; const failR = Math.round(nVal * s.failRateRem); const failDaysR = failR > 0 ? (REDIG_M3_PER_FAILURE * failR / s.digRateM3PerDay + waitDays) : 0; const finalWaitCost = Math.min(waitDays * s.crewDayRate, s.mobilisationCost); const daysR = Math.ceil(digDaysR + wetExtra + calDays + waitDays + failDaysR + 2); const nBatchesR = Math.max(1, Math.ceil(VdigR / s.batchM3)); const remOverdigT = Math.max(0, TdigR - trueContamT); const tContamR = Math.min(TdigR, trueContamT + remOverdigT * SEG.remOverdigContamFrac); const tCleanR = TdigR - tContamR; const months = Math.max(1, Math.ceil(daysR / 30)); const remCost = { standby: finalWaitCost, mob: s.mobilisationCost, excavation: (digDaysR + wetExtra + (failR>0 ? REDIG_M3_PER_FAILURE*failR/s.digRateM3PerDay : 0)) * s.crewDayRate, pid: 0, labChase: 0, labClass: nClass * s.labCostPerSample, labVal: (nVal + failR) * s.labCostPerSample, haulage: TdigR * s.haulagePerT, disposalContam: tContamR * s.disposalContamPerT, disposalClean: tCleanR * s.disposalCleanPerT, backfill: TdigR * s.backfillPerT, consultant: (nBatchesR * 0.5 + 2) * s.consultantDayRate, stockpile: nBatchesR * waitDays * s.stockpileHoldPerDay, remscan: s.remscanPurchase * (months / (s.remscanLifeYears * 12)) + s.gxlabLicensePerYear * (months / 12) + (s.volume_m3 > 5000 ? s.calibrationCost : 0), }; const segR = []; let tr = 0; segR.push({type:'mob', startDay:tr, endDay:tr+1}); tr += 1; segR.push({type:'dig', startDay:tr, endDay:tr + digDaysR + wetExtra + calDays}); tr += digDaysR + wetExtra + calDays; segR.push({type:'wait', startDay:tr, endDay:tr + waitDays}); tr += waitDays; if(failR>0){ segR.push({type:'rework', startDay:tr, endDay:tr+failDaysR}); tr += failDaysR; } segR.push({type:'demob', startDay:tr, endDay:tr+1}); tr += 1; const sum = c => Object.values(c).reduce((a,b)=>a+b,0); const totalB = sum(baseCost), totalR = sum(remCost); return { base:{ days:daysB, cost:baseCost, tonnes:{contam:tContamB, clean:tCleanB, dug:TdigB}, segments:segB, failures:failB, remobs:remobsB, total:totalB }, rem:{ days:daysR, cost:remCost, tonnes:{contam:tContamR, clean:tCleanR, dug:TdigR}, segments:segR, failures:failR, remobs:0, total:totalR }, saving: totalB - totalR, savingPct: totalB > 0 ? (totalB - totalR) / totalB : 0, daysSaved: daysB - daysR, tonnesDowngraded: Math.max(0, tContamB - tContamR), payback: s.remscanPurchase / Math.max(totalB - totalR, 1), }; } /*ENGINE-END*/ /* ============================================================ 7. FORMATTERS ============================================================ */ const LOCALE_TAGS = { en:'en', fr:'fr', es:'es', ar:'ar', hi:'hi', id:'id', ru:'ru' }; function fmtMoney(v){ try{ return new Intl.NumberFormat(LOCALE_TAGS[state.locale], {style:'currency', currency:state.currency, maximumFractionDigits:0}).format(v); } catch(e){ return state.currency + ' ' + Math.round(v); } } function fmtNum(v, dp){ return new Intl.NumberFormat(LOCALE_TAGS[state.locale], {maximumFractionDigits: (dp == null ? 0 : dp)}).format(v); } function fmtVol(m3){ if(state.units==='us') return fmtNum(m3 * M3_TO_YD3) + ' yd\u00B3'; return fmtNum(m3) + ' m\u00B3'; } function fmtTonnes(tv){ if(state.units==='us') return fmtNum(tv * T_TO_USTON) + ' t (US)'; return fmtNum(tv) + ' t'; } /* ============================================================ 8. ADVANCED FIELD CONFIG ============================================================ */ const ADV_FIELDS = [ {group:'adv.site', fields:[ {key:'bulkDensity', i18n:'adv.bulkDensity', type:'number', step:0.1, min:0.5}, {key:'pctAboveCriteria', i18n:'adv.pctAbove', type:'pct', step:0.05, min:0.1, max:1}, {key:'distribution', i18n:'adv.distribution', type:'select', options:[['uniform','adv.dist.uniform'],['hotspots','adv.dist.hotspots']]}, {key:'wetSite', i18n:'adv.wet', type:'check'}, ]}, {group:'adv.rates', fields:[ {key:'disposalCleanPerT', i18n:'adv.disposalClean', type:'money', step:5}, {key:'haulagePerT', i18n:'adv.haulage', type:'money', step:5}, {key:'backfillPerT', i18n:'adv.backfill', type:'money', step:5}, {key:'consultantDayRate', i18n:'adv.consultant', type:'money', step:100}, {key:'mobilisationCost', i18n:'adv.mob', type:'money', step:1000}, {key:'stockpileHoldPerDay', i18n:'adv.stockpile', type:'money', step:50}, {key:'pidPerDay', i18n:'adv.pid', type:'money', step:25}, ]}, {group:'adv.sampling', fields:[ {key:'classSamplesPer100m3', i18n:'adv.classSamples', type:'number', step:0.1, min:0}, {key:'valSamplesPer250m3', i18n:'adv.valSamples', type:'number', step:1, min:0}, {key:'failRateBase', i18n:'adv.failBase', type:'pct', step:0.01, min:0, max:0.5}, {key:'failRateRem', i18n:'adv.failRem', type:'pct', step:0.01, min:0, max:0.5}, {key:'chaseSamplesPerRound', i18n:'adv.chase', type:'number', step:1, min:0}, {key:'digRateM3PerDay', i18n:'adv.digRate', type:'number', step:50, min:50}, {key:'batchM3', i18n:'adv.batch', type:'number', step:50, min:50}, {key:'scenarioBias', i18n:'adv.bias', type:'select', options:[['conservative','adv.bias.conservative'],['typical','adv.bias.typical'],['favourable','adv.bias.favourable']]}, ]}, /* RemScan and GXLab costings are intentionally not exposed in the UI. Edit REMSCAN_PRICES, state.remscanLifeYears and state.calibrationCost in code to change them. They still feed the engine and the "Field equipment and software" bucket. */ ]; /* ============================================================ 9. UI BUILD ============================================================ */ const $ = id => document.getElementById(id); let R = null; /* latest results */ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; function buildLocaleSel(){ const sel = $('localeSel'); sel.innerHTML = ''; for(const code of Object.keys(I18N)){ const o = document.createElement('option'); o.value = code; o.textContent = LOCALE_NAMES[code]; sel.appendChild(o); } sel.value = state.locale; } function buildAdvPanel(){ const p = $('advPanel'); p.innerHTML = ''; for(const g of ADV_FIELDS){ const h = document.createElement('h3'); h.textContent = t(g.group); h.dataset.i18n = g.group; p.appendChild(h); const grid = document.createElement('div'); grid.className = 'grid3'; for(const f of g.fields){ const wrap = document.createElement('div'); wrap.className = 'field'; const lab = document.createElement('label'); lab.textContent = t(f.i18n); lab.dataset.i18n = f.i18n; lab.htmlFor = 'adv_'+f.key; wrap.appendChild(lab); let inp; if(f.type==='select'){ inp = document.createElement('select'); for(const [v,k] of f.options){ const o = document.createElement('option'); o.value=v; o.textContent=t(k); o.dataset.i18n=k; inp.appendChild(o); } inp.value = state[f.key]; } else if(f.type==='check'){ wrap.className = 'field checkrow'; inp = document.createElement('input'); inp.type='checkbox'; inp.checked = state[f.key]; wrap.insertBefore(inp, lab); lab.style.marginBlockEnd='0'; } else { inp = document.createElement('input'); inp.type='number'; inp.step = f.step; if(f.min!=null) inp.min=f.min; if(f.max!=null) inp.max=f.max; inp.value = f.type==='pct' ? state[f.key] : state[f.key]; if(f.type==='money'){ const mf = document.createElement('div'); mf.className='moneyfield'; const cur = document.createElement('span'); cur.className='cur'; mf.appendChild(cur); mf.appendChild(inp); wrap.appendChild(mf); } } inp.id = 'adv_'+f.key; if(f.type!=='money' || !inp.parentElement) wrap.appendChild(inp); inp.addEventListener('change', ()=>{ if(f.type==='check') state[f.key] = inp.checked; else if(f.type==='select') state[f.key] = inp.value; else state[f.key] = parseFloat(inp.value) || 0; recalc(); }); grid.appendChild(wrap); } p.appendChild(grid); } updateCurLabels(); } function updateCurLabels(){ document.querySelectorAll('.cur').forEach(el=>{ el.textContent = state.currency; }); } /* volume slider: log scale */ function posToVol(pos){ const v = Math.exp(Math.log(VOL_MIN) + (pos/1000)*(Math.log(VOL_MAX)-Math.log(VOL_MIN))); return Math.max(VOL_MIN, Math.min(VOL_MAX, Math.round(v/50)*50)); } function volToPos(v){ return Math.round((Math.log(v)-Math.log(VOL_MIN))/(Math.log(VOL_MAX)-Math.log(VOL_MIN))*1000); } function syncInputsFromState(){ $('volSlider').value = volToPos(state.volume_m3); $('volVal').textContent = fmtVol(state.volume_m3); $('volSlider').setAttribute('aria-valuetext', fmtVol(state.volume_m3)); for(const k of ['labCostPerSample','labTurnaroundDays','crewDayRate','disposalContamPerT']){ const el = $('in_'+k); if(el) el.value = state[k]; } for(const g of ADV_FIELDS) for(const f of g.fields){ const el = $('adv_'+f.key); if(!el) continue; if(f.type==='check') el.checked = state[f.key]; else el.value = state[f.key]; } $('currencySel').value = state.currency; updateCurLabels(); } function applyPreset(name){ const pr = PRESETS[name]; if(!pr) return; state.volume_m3 = pr.volume_m3; state.distribution = pr.distribution; state.pctAboveCriteria = pr.pctAboveCriteria; state.wetSite = pr.wetSite; state.labTurnaroundDays = pr.labTurnaroundDays; const k = FX[state.currency]; for(const [key,v] of Object.entries(pr.usd)) state[key] = roundNice(v * k); syncInputsFromState(); recalc(); } /* ============================================================ 10. RENDERERS ============================================================ */ let lastSaving = 0; function renderStats(){ const target = R.saving; const start = lastSaving; lastSaving = target; const el = $('st_saving'); if(reducedMotion){ el.textContent = fmtMoney(target); } else { const t0 = performance.now(); (function step(now){ const p = Math.min(1,(now-t0)/400); el.textContent = fmtMoney(start + (target-start)*p); if(p<1) requestAnimationFrame(step); })(t0); } $('st_pct').textContent = fmtNum(R.savingPct*100,1) + '%'; $('st_days').textContent = fmtNum(R.daysSaved); $('st_tonnes').textContent = fmtTonnes(R.tonnesDowngraded); $('st_totB').textContent = fmtMoney(R.base.total); $('st_totR').textContent = fmtMoney(R.rem.total); } function mergedBuckets(cost){ return { standby: cost.standby, excavation: cost.excavation, haulage: cost.haulage, disposalContam: cost.disposalContam, disposalClean: cost.disposalClean, lab: cost.labChase + cost.labClass + cost.labVal, consultant: cost.consultant, mob: cost.mob, backfill: cost.backfill, other: cost.pid + cost.stockpile + cost.remscan, }; } function renderBreakBar(containerId, cost, total, maxTotal){ const b = mergedBuckets(cost); const W = 800, H = 56; const scale = W * (total/maxTotal) / Math.max(total,1); let x = 0, segs = ''; for(const k of BUCKET_ORDER){ const w = b[k]*scale; if(w>0.5){ segs += `<rect x="${x.toFixed(1)}" y="8" width="${Math.max(w,1).toFixed(1)}" height="40" fill="${BUCKET_COLORS[k]}" data-bucket="${k}" data-val="${b[k]}" rx="2"></rect>`; } x += w; } const label = t('chart.title')+': '+BUCKET_ORDER.map(k=>t('chart.'+k)+' '+fmtMoney(b[k])).join(', '); $(containerId).innerHTML = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${label.replace(/"/g,'')}" style="width:100%;height:auto;">${segs}</svg>`; } function renderBreakLegend(){ const lg = $('breakLegend'); lg.innerHTML=''; for(const k of BUCKET_ORDER){ const s = document.createElement('span'); s.innerHTML = `<i style="background:${BUCKET_COLORS[k]}"></i>` ; const txt = document.createElement('em'); txt.style.fontStyle='normal'; txt.textContent = t('chart.'+k); txt.dataset.i18n='chart.'+k; s.appendChild(txt); lg.appendChild(s); } } function wireTooltips(){ const tip = $('tooltip'); document.querySelectorAll('#breakB rect, #breakR rect').forEach(r=>{ const show = e=>{ tip.textContent = t('chart.'+r.dataset.bucket) + ': ' + fmtMoney(parseFloat(r.dataset.val)); tip.style.display='block'; const px = (e.touches? e.touches[0].clientX : e.clientX); const py = (e.touches? e.touches[0].clientY : e.clientY); tip.style.left = Math.min(px+12, window.innerWidth-160)+'px'; tip.style.top = (py+12)+'px'; }; r.addEventListener('mousemove', show); r.addEventListener('click', show); r.addEventListener('mouseleave', ()=>{ tip.style.display='none'; }); }); } function renderTimeline(){ const maxD = Math.max(R.base.days, R.rem.days, 1); const W=800, H=150, X0=10, XW=760; const sc = XW/maxD; const colors = {mob:'#6d7a80', demob:'#6d7a80', dig:'#5b8fa3', wait:'url(#hatch)', rework:'#e5484d'}; function row(segs, y, endLabel, accent){ let out = ''; for(const sg of segs){ out += `<rect x="${(X0+sg.startDay*sc).toFixed(1)}" y="${y}" width="${Math.max((sg.endDay-sg.startDay)*sc,1).toFixed(1)}" height="26" fill="${colors[sg.type]}" rx="3"></rect>`; } out += `<text x="${(X0+segs[segs.length-1].endDay*sc+6).toFixed(1)}" y="${y+18}" font-size="13" ${accent?'fill="#00e5ff" style="text-shadow:0 0 8px rgba(0,229,255,.5)"':''}>${endLabel}</text>`; return out; } let ticks=''; for(let d=7; d<maxD; d+=7){ ticks += `<line x1="${X0+d*sc}" y1="20" x2="${X0+d*sc}" y2="130" stroke="rgba(233,232,227,0.12)"></line> <text x="${X0+d*sc}" y="146" font-size="10" text-anchor="middle" opacity="0.6">D${d}</text>`; } const lblB = fmtNum(R.base.days)+' '+t('tl.days'); const lblR = fmtNum(R.rem.days)+' '+t('tl.days'); $('timelineBox').innerHTML = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${t('tl.base')} ${lblB}. ${t('tl.rem')} ${lblR}." style="width:100%;height:auto;"> <defs><pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse"> <rect width="7" height="7" fill="#3a3f41"></rect><line x1="0" y1="0" x2="0" y2="7" stroke="rgba(233,232,227,0.25)" stroke-width="2"></line> </pattern></defs> ${ticks} <text x="${X0}" y="34" font-size="11" opacity="0.7">${t('tl.base')}</text> ${row(R.base.segments, 40, lblB, false)} <text x="${X0}" y="94" font-size="11" opacity="0.7">${t('tl.rem')}</text> ${row(R.rem.segments, 100, lblR, true)} </svg>`; } function renderFlow(){ const W=800, H=230; const maxDug = Math.max(R.base.tonnes.dug, R.rem.tonnes.dug, 1); function half(res, x0, title){ const hw = 360; const srcW = hw * (res.tonnes.dug/maxDug); const srcX = x0 + (hw-srcW)/2; const cW = Math.max(srcW * (res.tonnes.contam/Math.max(res.tonnes.dug,1)), 2); const kW = Math.max(srcW - cW, 2); const cX = x0 + (hw - (cW+kW+20))/2; const kX = cX + cW + 20; return ` <text x="${x0+hw/2}" y="18" font-size="12" text-anchor="middle" opacity="0.8">${title}</text> <rect x="${srcX}" y="30" width="${srcW}" height="34" fill="#5b8fa3" rx="4"></rect> <text x="${x0+hw/2}" y="52" font-size="11" text-anchor="middle">${t('flow.dug')} ${fmtTonnes(res.tonnes.dug)}</text> <path d="M ${srcX} 64 L ${srcX+cW*(srcW/Math.max(cW+kW,1))||srcX} 64 L ${cX+cW} 140 L ${cX} 140 Z" fill="#f5a623" opacity="0.35"></path> <path d="M ${srcX+srcW-kW*(srcW/Math.max(cW+kW,1))||srcX+srcW} 64 L ${srcX+srcW} 64 L ${kX+kW} 140 L ${kX} 140 Z" fill="#35c46a" opacity="0.35"></path> <rect x="${cX}" y="140" width="${cW}" height="34" fill="#f5a623" rx="4"></rect> <rect x="${kX}" y="140" width="${kW}" height="34" fill="#35c46a" rx="4"></rect> <text x="${cX+cW/2}" y="192" font-size="10" text-anchor="middle" opacity="0.85">${t('flow.contam')}</text> <text x="${cX+cW/2}" y="206" font-size="11" text-anchor="middle">${fmtTonnes(res.tonnes.contam)}</text> <text x="${kX+kW/2}" y="192" font-size="10" text-anchor="middle" opacity="0.85">${t('flow.clean')}</text> <text x="${kX+kW/2}" y="206" font-size="11" text-anchor="middle">${fmtTonnes(res.tonnes.clean)}</text>`; } $('flowBox').innerHTML = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${t('flow.title')}" style="width:100%;height:auto;"> ${half(R.base, 20, t('tl.base'))} <line x1="400" y1="10" x2="400" y2="${H-10}" stroke="rgba(233,232,227,0.12)"></line> ${half(R.rem, 420, t('tl.rem'))} </svg>`; } function assumptionRows(){ return [ ['inputs.volume', fmtVol(state.volume_m3)], ['adv.bulkDensity', fmtNum(state.bulkDensity,2)], ['adv.pctAbove', fmtNum(state.pctAboveCriteria*100)+'%'], ['adv.distribution', t(state.distribution==='uniform'?'adv.dist.uniform':'adv.dist.hotspots')], ['inputs.labCost', fmtMoney(state.labCostPerSample)], ['inputs.turnaround', fmtNum(state.labTurnaroundDays)], ['inputs.crewRate', fmtMoney(state.crewDayRate)], ['inputs.disposalContam', fmtMoney(state.disposalContamPerT)], ['adv.disposalClean', fmtMoney(state.disposalCleanPerT)], ['adv.haulage', fmtMoney(state.haulagePerT)], ['adv.backfill', fmtMoney(state.backfillPerT)], ['adv.consultant', fmtMoney(state.consultantDayRate)], ['adv.mob', fmtMoney(state.mobilisationCost)], ['adv.stockpile', fmtMoney(state.stockpileHoldPerDay)], ['adv.pid', fmtMoney(state.pidPerDay)], ['adv.classSamples', fmtNum(state.classSamplesPer100m3,1)], ['adv.valSamples', fmtNum(state.valSamplesPer250m3)], ['adv.failBase', fmtNum(state.failRateBase*100)+'%'], ['adv.failRem', fmtNum(state.failRateRem*100)+'%'], ['adv.chase', fmtNum(state.chaseSamplesPerRound)], ['adv.digRate', fmtNum(state.digRateM3PerDay)], ['adv.batch', fmtNum(state.batchM3)], ['adv.bias', t('adv.bias.'+state.scenarioBias)], ['adv.wet', state.wetSite ? '\u2713' : '\u2013'], ]; } function renderAssumptions(){ const g = $('assumeGrid'); g.innerHTML=''; for(const [k,v] of assumptionRows()){ const d = document.createElement('div'); const s1 = document.createElement('span'); s1.textContent = t(k); s1.dataset.i18n = k; const s2 = document.createElement('b'); s2.textContent = v; d.appendChild(s1); d.appendChild(s2); g.appendChild(d); } } function buildReport(){ const esc = x => String(x).replace(/&/g,'&').replace(/</g,'<'); const mB = mergedBuckets(R.base.cost), mR = mergedBuckets(R.rem.cost); let bucketRows=''; for(const k of BUCKET_ORDER){ bucketRows += `<tr><td>${esc(t('chart.'+k))}</td><td class="num">${esc(fmtMoney(mB[k]))}</td><td class="num">${esc(fmtMoney(mR[k]))}</td><td class="num">${esc(fmtMoney(mB[k]-mR[k]))}</td></tr>`; } bucketRows += `<tr class="rep-total"><td>${esc(t('stats.totalBase'))} / ${esc(t('stats.totalRem'))}</td><td class="num">${esc(fmtMoney(R.base.total))}</td><td class="num">${esc(fmtMoney(R.rem.total))}</td><td class="num">${esc(fmtMoney(R.saving))}</td></tr>`; let assumeRows=''; for(const [k,v] of assumptionRows()){ assumeRows += `<tr><td>${esc(t(k))}</td><td class="num">${esc(v)}</td></tr>`; } const dateStr = new Intl.DateTimeFormat(LOCALE_TAGS[state.locale], {dateStyle:'long'}).format(new Date()); $('printReport').innerHTML = ` <h1>${esc(t('app.title'))}</h1> <div class="rep-sub">${esc(t('pdf.title'))} \u00B7 ${esc(t('pdf.date'))}: ${esc(dateStr)} \u00B7 ${esc(state.currency)}</div> <div class="rep-hero"> <div><div class="v">${esc(fmtMoney(R.saving))}</div><div class="l">${esc(t('stats.saving'))}</div></div> <div><div class="v">${esc(fmtNum(R.savingPct*100,1))}%</div><div class="l">${esc(t('stats.savingPct'))}</div></div> <div><div class="v">${esc(fmtNum(R.daysSaved))}</div><div class="l">${esc(t('stats.days'))}</div></div> <div><div class="v">${esc(fmtTonnes(R.tonnesDowngraded))}</div><div class="l">${esc(t('stats.tonnes'))}</div></div> </div> <h2>${esc(t('chart.title'))}</h2> <table><thead><tr><th>${esc(t('pdf.item'))}</th><th>${esc(t('tl.base'))}</th><th>${esc(t('tl.rem'))}</th><th>${esc(t('pdf.delta'))}</th></tr></thead> <tbody>${bucketRows}</tbody></table> <h2>${esc(t('flow.title'))}</h2> <table><thead><tr><th></th><th>${esc(t('tl.base'))}</th><th>${esc(t('tl.rem'))}</th></tr></thead><tbody> <tr><td>${esc(t('flow.dug'))}</td><td class="num">${esc(fmtTonnes(R.base.tonnes.dug))}</td><td class="num">${esc(fmtTonnes(R.rem.tonnes.dug))}</td></tr> <tr><td>${esc(t('flow.contam'))}</td><td class="num">${esc(fmtTonnes(R.base.tonnes.contam))}</td><td class="num">${esc(fmtTonnes(R.rem.tonnes.contam))}</td></tr> <tr><td>${esc(t('flow.clean'))}</td><td class="num">${esc(fmtTonnes(R.base.tonnes.clean))}</td><td class="num">${esc(fmtTonnes(R.rem.tonnes.clean))}</td></tr> <tr><td>${esc(t('tl.title'))}</td><td class="num">${esc(fmtNum(R.base.days))} ${esc(t('tl.days'))}</td><td class="num">${esc(fmtNum(R.rem.days))} ${esc(t('tl.days'))}</td></tr> </tbody></table> <h2>${esc(t('assume.title'))}</h2> <table><tbody>${assumeRows}</tbody></table> <div class="rep-note">${esc(t('assume.note1'))} ${esc(t('assume.note2'))} ${esc(t('assume.note3'))}<br>${esc(t('pdf.prepared'))} \u00B7 ${esc(t('footer.disclaimer'))}</div>`; } /* ============================================================ 11. ANIMATION The ground shows its true contamination colours from the start, so the viewer sees the site condition before excavation begins. Excavated cells fade to transparent ghosts of their colour: the remediated footprint. The traditional crew cannot see what the viewer sees, digs a wide blind safety margin and pauses for every lab round; the ghosted pit is visibly larger and swallows clean green soil. The RemScan operator scans each bucket load as the dig proceeds and stops exactly at the contamination line. ============================================================ */ const GRID = { cols:20, rows:10, cw:24, ch:20, y0:100 }; let cellVals = [], digOrderB = [], digOrderR = []; let animPlaying = !reducedMotion; let animStart = null; function makeCellPattern(){ const vals = []; const hot = state.distribution==='hotspots'; const centers = [[4,4],[11,6],[16,3]]; for(let r=0;r<GRID.rows;r++){ for(let c=0;c<GRID.cols;c++){ let v=0; if(hot){ for(const [cx,cy] of centers){ const d = Math.hypot((c-cx)/3.2,(r-cy)/2.2); if(d<0.7) v=Math.max(v,2); else if(d<1.3) v=Math.max(v,1); } } else { if(r>=3 && r<=7) v = (r>=4 && r<=6) ? 2 : 1; } vals.push(v); } } return vals; } /* dig footprint: contaminated cells dilated by `margin` cells (Chebyshev). Baseline margin 2 = blind over-excavation. RemScan margin 0 = stops at the line. */ function digOrder(vals, margin){ const inSet = new Array(vals.length).fill(false); for(let r=0;r<GRID.rows;r++) for(let c=0;c<GRID.cols;c++){ if(vals[r*GRID.cols+c]>0){ for(let dr=-margin;dr<=margin;dr++) for(let dc=-margin;dc<=margin;dc++){ const rr=r+dr, cc=c+dc; if(rr>=0&&rr<GRID.rows&&cc>=0&&cc<GRID.cols) inSet[rr*GRID.cols+cc]=true; } } } const order=[]; for(let c=0;c<GRID.cols;c++) for(let r=0;r<GRID.rows;r++){ if(inSet[r*GRID.cols+c]) order.push(r*GRID.cols+c); } return order; } const CELL_FILL = {0:'rgba(53,196,106,0.35)',1:'rgba(245,166,35,0.45)',2:'rgba(229,72,77,0.5)'}; const GHOST_FILL = {0:'rgba(53,196,106,0.08)',1:'rgba(245,166,35,0.10)',2:'rgba(229,72,77,0.10)'}; const BAY_COLORS = ['#35c46a','#f5a623','#e5484d']; function panelSVG(id, isRem){ let grid=''; for(let y=20;y<320;y+=20) grid+=`<line x1="0" y1="${y}" x2="480" y2="${y}" stroke="rgba(233,232,227,0.08)"></line>`; for(let x=0;x<480;x+=24) grid+=`<line x1="${x}" y1="0" x2="480" y2="0"></line><line x1="${x}" y1="0" x2="${x}" y2="320" stroke="rgba(233,232,227,0.08)"></line>`; grid += `<line x1="0" y1="90" x2="480" y2="90" stroke="#00e5ff" opacity="0.4" style="filter:drop-shadow(0 0 4px rgba(0,229,255,0.6))"></line>`; /* the ground shows its true contamination colours from the start */ let cells=''; for(let r=0;r<GRID.rows;r++) for(let c=0;c<GRID.cols;c++){ const v = cellVals[r*GRID.cols+c]; cells += `<rect id="${id}_c${r}_${c}" x="${c*GRID.cw}" y="${GRID.y0+r*GRID.ch}" width="${GRID.cw}" height="${GRID.ch}" fill="${CELL_FILL[v]}" stroke="rgba(233,232,227,0.12)" stroke-width="1"></rect>`; } const exc = ` <g id="${id}_exc"> <rect x="0" y="72" width="44" height="10" fill="none" stroke="#e9e8e3" stroke-width="1.5" rx="5"></rect> <rect x="8" y="56" width="22" height="16" fill="none" stroke="#e9e8e3" stroke-width="1.5" rx="3"></rect> <g class="boom-g"> <line x1="30" y1="62" x2="58" y2="44" stroke="#00e5ff" stroke-width="2.5" style="filter:drop-shadow(0 0 3px rgba(0,229,255,0.6))"></line> <line x1="58" y1="44" x2="72" y2="70" stroke="#e9e8e3" stroke-width="2"></line> <polygon points="72,70 82,70 78,82 68,80" fill="none" stroke="#e9e8e3" stroke-width="1.5"></polygon> </g> </g>`; /* RemScan: handheld operator + pulsing scan beam at the dig face */ const scan = isRem ? ` <g id="${id}_op"> <circle cx="0" cy="52" r="5" fill="none" stroke="#e9e8e3" stroke-width="1.5"></circle> <line x1="0" y1="57" x2="0" y2="76" stroke="#e9e8e3" stroke-width="1.5"></line> <line x1="0" y1="64" x2="9" y2="70" stroke="#00e5ff" stroke-width="2"></line> </g> <line id="${id}_scan" class="scanp" x1="0" y1="94" x2="0" y2="300" stroke="#00e5ff" stroke-width="2" opacity="0" style="filter:drop-shadow(0 0 5px rgba(0,229,255,0.8))"></line>` : ''; const clock = `<g id="${id}_clock" opacity="0"><circle cx="440" cy="40" r="12" fill="none" stroke="#e9e8e3" stroke-width="1.5" class="pulse"></circle><line x1="440" y1="40" x2="440" y2="32" stroke="#e9e8e3" stroke-width="1.5" class="pulse"></line><line x1="440" y1="40" x2="446" y2="40" stroke="#e9e8e3" stroke-width="1.5" class="pulse"></line></g>`; let bays=''; for(let i=0;i<3;i++){ bays += `<g transform="translate(${380+i*32},60)"> <polygon id="${id}_bay${i}" points="4,0 28,0 32,26 0,26" fill="${BAY_COLORS[2-i]}" opacity="0.7" transform="scale(1,0.05)" transform-origin="16 26"></polygon> </g>`; } return `<svg viewBox="0 0 480 320" style="width:100%;height:auto;" role="img" aria-label="${t('anim.title')}">${grid}${cells}${bays}${exc}${scan}${clock}</svg>`; } function buildAnimation(){ cellVals = makeCellPattern(); digOrderB = digOrder(cellVals, 2); /* blind over-excavation footprint */ digOrderR = digOrder(cellVals, 0); /* stops at the contamination line */ document.querySelector('#panelB .svgbox').innerHTML = panelSVG('pb', false); document.querySelector('#panelR .svgbox').innerHTML = panelSVG('pr', true); animStart = null; } function digFraction(segs, day){ let totalDig=0, done=0; for(const sg of segs) if(sg.type==='dig') totalDig += sg.endDay-sg.startDay; for(const sg of segs){ if(sg.type!=='dig') continue; if(day>=sg.endDay) done += sg.endDay-sg.startDay; else if(day>sg.startDay) done += day-sg.startDay; } return totalDig>0 ? done/totalDig : 1; } function activeSeg(segs, day){ for(const sg of segs) if(day>=sg.startDay && day<sg.endDay) return sg; return null; } function updatePanel(prefix, panelEl, res, order, day, isRem){ const seg = activeSeg(res.segments, day); const frac = digFraction(res.segments, day); const dugCount = Math.floor(frac*order.length); const counts=[0,0,0]; /* dug cells fade to a transparent ghost of their colour: remediated ground */ for(let i=0;i<order.length;i++){ const idx = order[i]; const r = Math.floor(idx/GRID.cols), c = idx%GRID.cols; const el = document.getElementById(prefix+'_c'+r+'_'+c); if(!el) continue; if(i < dugCount){ el.setAttribute('fill', GHOST_FILL[cellVals[idx]]); el.setAttribute('stroke','rgba(233,232,227,0.05)'); counts[cellVals[idx]]++; } else { el.setAttribute('fill', CELL_FILL[cellVals[idx]]); el.setAttribute('stroke','rgba(233,232,227,0.12)'); } } /* dig front column */ const frontIdx = order[Math.min(dugCount, order.length-1)]; const frontCol = frontIdx!=null ? frontIdx%GRID.cols : 0; const frontX = Math.min(frontCol*GRID.cw, 420); for(let i=0;i<3;i++){ const bay = document.getElementById(prefix+'_bay'+i); if(bay){ const sc = Math.max(0.05, counts[2-i]/(order.length/2)); bay.setAttribute('transform','scale(1,'+Math.min(sc,1)+')'); } } const exc = document.getElementById(prefix+'_exc'); if(exc) exc.setAttribute('transform','translate('+frontX+',0)'); if(isRem){ const sl = document.getElementById(prefix+'_scan'); const op = document.getElementById(prefix+'_op'); const digging = seg && seg.type==='dig'; if(sl){ sl.setAttribute('opacity', digging?'0.9':'0'); sl.setAttribute('x1',frontX+GRID.cw+8); sl.setAttribute('x2',frontX+GRID.cw+8); } if(op) op.setAttribute('transform','translate('+(frontX+GRID.cw+20)+',0)'); } const clock = document.getElementById(prefix+'_clock'); if(clock) clock.setAttribute('opacity', seg && seg.type==='wait' ? '1' : '0'); if(seg && seg.type==='rework'){ for(const f of [0.3,0.5,0.7]){ const idx = order[Math.floor(order.length*f)]; const r = Math.floor(idx/GRID.cols), c = idx%GRID.cols; const el = document.getElementById(prefix+'_c'+r+'_'+c); if(el){ el.setAttribute('fill','#e5484d'); el.classList.add('flash'); } } } else { document.querySelectorAll('#'+ (prefix==='pb'?'panelB':'panelR') +' .flash').forEach(el=>{ el.classList.remove('flash'); }); } const statusEl = panelEl.querySelector('.status'); const done = day >= res.segments[res.segments.length-1].endDay; let stateTxt; if(done){ stateTxt = t('anim.done')+' \u00B7 '+fmtNum(res.days)+' '+t('tl.days'); } else if(seg){ const map = {dig: isRem?'anim.scanning':'anim.digging', wait:'anim.waiting', rework:'anim.rework', mob:'anim.digging', demob:'anim.done'}; stateTxt = t('anim.day')+' '+fmtNum(Math.floor(day))+' \u00B7 '+t(map[seg.type] || 'anim.digging'); } else stateTxt = ''; statusEl.textContent = stateTxt; panelEl.classList.toggle('waiting', !!(seg && seg.type==='wait')); const excG = document.getElementById(prefix+'_exc'); if(excG) excG.parentElement.parentElement.classList.toggle('digging', !!(seg && seg.type==='dig' && !reducedMotion)); } function animFrame(ts){ if(!animPlaying) return; if(animStart===null) animStart = ts; const maxDays = Math.max(R.base.days, R.rem.days); const loopMs = ANIM_LOOP_SECONDS*1000; let el = (ts - animStart); if(el > loopMs + 2000){ animStart = ts; el = 0; } const day = Math.min(el/loopMs, 1) * maxDays; updatePanel('pb', $('panelB'), R.base, digOrderB, day, false); updatePanel('pr', $('panelR'), R.rem, digOrderR, day, true); requestAnimationFrame(animFrame); } function startAnim(){ buildAnimation(); if(reducedMotion || !animPlaying){ const maxDays = Math.max(R.base.days, R.rem.days); updatePanel('pb', $('panelB'), R.base, digOrderB, maxDays, false); updatePanel('pr', $('panelR'), R.rem, digOrderR, maxDays, true); } else { animStart = null; requestAnimationFrame(animFrame); } } /* ============================================================ 12. RECALC + LOCALE ============================================================ */ function recalc(){ R = simulate(state); renderStats(); renderBreakBar('breakB', R.base.cost, R.base.total, Math.max(R.base.total, R.rem.total)); renderBreakBar('breakR', R.rem.cost, R.rem.total, Math.max(R.base.total, R.rem.total)); renderBreakLegend(); wireTooltips(); renderTimeline(); renderFlow(); renderAssumptions(); startAnim(); } function applyLocale(code){ if(!I18N[code]) code='en'; state.locale = code; L = I18N[code]; state.dir = code==='ar' ? 'rtl' : 'ltr'; document.documentElement.lang = code; document.documentElement.dir = state.dir; document.querySelectorAll('[data-i18n]').forEach(el=>{ el.textContent = t(el.dataset.i18n); }); $('unitsBtn').textContent = state.units==='metric' ? t('units.metric') : t('units.us'); $('playBtn').textContent = animPlaying ? t('pause') : t('play'); $('volVal').textContent = fmtVol(state.volume_m3); if(R) recalc(); } /* ============================================================ 13. WIRING + INIT ============================================================ */ function debounce(fn, ms){ let h; return (...a)=>{ clearTimeout(h); h=setTimeout(()=>fn(...a),ms); }; } function wire(){ $('localeSel').addEventListener('change', e=> applyLocale(e.target.value)); $('currencySel').addEventListener('change', e=>{ const newCur = e.target.value; convertMoney(state.currency, newCur); state.currency = newCur; setRemscanPrices(newCur); updateCurLabels(); syncInputsFromState(); recalc(); }); $('unitsBtn').addEventListener('click', ()=>{ state.units = state.units==='metric' ? 'us' : 'metric'; $('unitsBtn').textContent = state.units==='metric' ? t('units.metric') : t('units.us'); $('unitsBtn').setAttribute('aria-pressed', state.units==='us'); $('volVal').textContent = fmtVol(state.volume_m3); recalc(); }); const volHandler = debounce(()=>{ state.volume_m3 = posToVol(parseFloat($('volSlider').value)); $('volVal').textContent = fmtVol(state.volume_m3); $('volSlider').setAttribute('aria-valuetext', fmtVol(state.volume_m3)); recalc(); }, 80); $('volSlider').addEventListener('input', ()=>{ state.volume_m3 = posToVol(parseFloat($('volSlider').value)); $('volVal').textContent = fmtVol(state.volume_m3); volHandler(); }); for(const k of ['labCostPerSample','labTurnaroundDays','crewDayRate','disposalContamPerT']){ $('in_'+k).addEventListener('change', e=>{ state[k] = parseFloat(e.target.value)||0; recalc(); }); } $('advBtn').addEventListener('click', ()=>{ const p = $('advPanel'); const open = !p.classList.contains('open'); p.classList.toggle('open', open); $('advBtn').setAttribute('aria-expanded', open); }); document.querySelectorAll('.preset').forEach(b=> b.addEventListener('click', ()=> applyPreset(b.dataset.preset))); $('playBtn').addEventListener('click', ()=>{ animPlaying = !animPlaying; $('playBtn').textContent = animPlaying ? t('pause') : t('play'); if(animPlaying && !reducedMotion){ animStart=null; requestAnimationFrame(animFrame); } }); $('pdfBtn').addEventListener('click', ()=>{ buildReport(); const oldTitle = document.title; document.title = 'GXLab-TPH-cost-benefit-analysis'; window.print(); setTimeout(()=>{ document.title = oldTitle; }, 500); }); $('ctaBtn').addEventListener('click', ()=>{ const body = [ t('inputs.volume')+': '+fmtVol(state.volume_m3), t('stats.totalBase')+': '+fmtMoney(R.base.total), t('stats.totalRem')+': '+fmtMoney(R.rem.total), t('stats.saving')+': '+fmtMoney(R.saving), window.location.origin + window.location.pathname + '?lang=' + state.locale ].join('\n'); const href = 'mailto:support@gxlab.com?subject=' + encodeURIComponent('RemScan project inquiry') + '&body=' + encodeURIComponent(body); window.location.href = href; }); } function init(){ const params = new URLSearchParams(window.location.search); const lang = params.get('lang'); buildLocaleSel(); buildAdvPanel(); wire(); applyLocale(lang && I18N[lang] ? lang : 'en'); $('localeSel').value = state.locale; syncInputsFromState(); recalc(); } /* ---- iframe embedding: report height to parent so no inner scrollbar ---- */ function postHeight(){ if(window.parent === window) return; /* standalone, nothing to do */ const h = Math.ceil(document.documentElement.getBoundingClientRect().height); window.parent.postMessage({ gxsimHeight: h }, '*'); } let _phTimer=null; function scheduleHeight(){ clearTimeout(_phTimer); _phTimer=setTimeout(postHeight,60); } window.addEventListener('load', scheduleHeight); window.addEventListener('resize', scheduleHeight); const _origRecalc = recalc; recalc = function(){ _origRecalc(); scheduleHeight(); }; document.addEventListener('DOMContentLoaded', ()=>{ init(); if('ResizeObserver' in window){ new ResizeObserver(scheduleHeight).observe(document.body); } scheduleHeight(); }); </script> </body> </html>