| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <meta name="description" content="Research-use PHBV degradation-induced mass-loss prediction with study-aware validation boundaries."> |
| <link rel="icon" href="data:,"> |
| <title>PHBV Mass-Loss Reliability Explorer</title> |
| <style> |
| :root { |
| color-scheme: light; |
| --paper: #f4f0e7; |
| --surface: #fffdf8; |
| --surface-2: #ebe6dc; |
| --ink: #17261f; |
| --muted: #59655f; |
| --line: #cec8bc; |
| --spruce: #1e6552; |
| --spruce-2: #164b3e; |
| --spruce-soft: #d9e8e1; |
| --clay: #b95f32; |
| --clay-soft: #f2dfd3; |
| --gold: #9a7a32; |
| --shadow: 0 18px 48px rgba(31, 45, 38, .10); |
| --radius-sm: 6px; |
| --radius-lg: 14px; |
| --font-display: Georgia, "Times New Roman", serif; |
| --font-body: Aptos, "Segoe UI", sans-serif; |
| --font-mono: "Cascadia Mono", Consolas, monospace; |
| } |
| |
| [data-theme="dark"] { |
| color-scheme: dark; |
| --paper: #15211c; |
| --surface: #1d2b25; |
| --surface-2: #26362f; |
| --ink: #edf1eb; |
| --muted: #b6c2bb; |
| --line: #405048; |
| --spruce: #7cc4a8; |
| --spruce-2: #a5d7c3; |
| --spruce-soft: #29483d; |
| --clay: #ed9568; |
| --clay-soft: #513627; |
| --gold: #cfb66e; |
| --shadow: 0 18px 48px rgba(0, 0, 0, .28); |
| } |
| |
| * { box-sizing: border-box; } |
| html { scroll-behavior: smooth; } |
| body { |
| margin: 0; |
| background: |
| linear-gradient(rgba(23, 38, 31, .035) 1px, transparent 1px), |
| linear-gradient(90deg, rgba(23, 38, 31, .035) 1px, transparent 1px), |
| var(--paper); |
| background-size: 32px 32px; |
| color: var(--ink); |
| font-family: var(--font-body); |
| font-size: 17px; |
| line-height: 1.55; |
| text-wrap: pretty; |
| } |
| |
| button, input, select { font: inherit; } |
| button, a, input, select, summary { -webkit-tap-highlight-color: transparent; } |
| :focus-visible { outline: 3px solid var(--clay); outline-offset: 3px; } |
| |
| .shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; } |
| .topbar { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| min-height: 64px; |
| border-bottom: 1px solid var(--line); |
| } |
| .wordmark { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| color: var(--ink); |
| font-weight: 700; |
| letter-spacing: .01em; |
| } |
| .mark { |
| display: grid; |
| place-items: center; |
| width: 30px; |
| height: 30px; |
| border: 1px solid var(--spruce); |
| border-radius: 50%; |
| color: var(--spruce); |
| font-family: var(--font-mono); |
| font-size: 12px; |
| font-weight: 700; |
| } |
| .quiet-button { |
| min-height: 42px; |
| padding: 8px 12px; |
| border: 1px solid var(--line); |
| border-radius: var(--radius-sm); |
| background: var(--surface); |
| color: var(--ink); |
| cursor: pointer; |
| transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; |
| } |
| .quiet-button:hover { border-color: var(--spruce); background: var(--spruce-soft); } |
| .quiet-button:active { transform: translateY(1px); } |
| |
| .hero { |
| display: grid; |
| grid-template-columns: minmax(0, 1.5fr) minmax(280px, .72fr); |
| gap: clamp(32px, 6vw, 80px); |
| padding: clamp(56px, 9vw, 112px) 0 48px; |
| align-items: end; |
| } |
| .eyebrow { |
| margin: 0 0 14px; |
| color: var(--spruce); |
| font-family: var(--font-mono); |
| font-size: 12px; |
| font-weight: 700; |
| letter-spacing: .12em; |
| text-transform: uppercase; |
| } |
| h1, h2, h3 { font-family: var(--font-display); font-weight: 500; line-height: 1.08; } |
| h1 { margin: 0; font-size: clamp(44px, 7vw, 82px); letter-spacing: -.045em; } |
| h2 { margin: 0 0 12px; font-size: clamp(28px, 4vw, 44px); letter-spacing: -.025em; } |
| h3 { margin: 0; font-size: 24px; } |
| .lede { max-width: 760px; margin: 24px 0 0; color: var(--muted); font-size: clamp(18px, 2.1vw, 23px); } |
| .endpoint-note { |
| padding: 24px; |
| border-top: 4px solid var(--clay); |
| background: var(--surface); |
| box-shadow: var(--shadow); |
| } |
| .endpoint-note strong { display: block; margin-bottom: 8px; color: var(--clay); font-family: var(--font-mono); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; } |
| .endpoint-note p { margin: 0; } |
| |
| .evidence-strip { |
| display: grid; |
| grid-template-columns: repeat(4, 1fr); |
| margin: 0 auto 40px; |
| border: 1px solid var(--line); |
| background: var(--surface); |
| } |
| .evidence-item { min-height: 124px; padding: 22px; border-right: 1px solid var(--line); } |
| .evidence-item:last-child { border-right: 0; } |
| .metric { display: block; margin-bottom: 8px; font-family: var(--font-display); font-size: 34px; line-height: 1; } |
| .metric-label { color: var(--muted); font-size: 14px; } |
| |
| .warning-banner { |
| display: grid; |
| grid-template-columns: auto 1fr; |
| gap: 16px; |
| margin-bottom: 40px; |
| padding: 22px 24px; |
| border: 1px solid color-mix(in srgb, var(--clay) 45%, var(--line)); |
| border-radius: var(--radius-sm); |
| background: var(--clay-soft); |
| } |
| .warning-code { color: var(--clay); font-family: var(--font-mono); font-size: 13px; font-weight: 700; } |
| .warning-banner p { margin: 0; } |
| |
| .workspace { |
| overflow: hidden; |
| margin-bottom: 56px; |
| border: 1px solid var(--line); |
| border-radius: var(--radius-lg); |
| background: var(--surface); |
| box-shadow: var(--shadow); |
| } |
| .tabs { display: flex; gap: 0; border-bottom: 1px solid var(--line); background: var(--surface-2); } |
| .tab { |
| min-height: 54px; |
| padding: 12px 22px; |
| border: 0; |
| border-right: 1px solid var(--line); |
| background: transparent; |
| color: var(--muted); |
| cursor: pointer; |
| font-weight: 650; |
| transition: background 160ms ease, color 160ms ease; |
| } |
| .tab:hover { background: var(--surface); color: var(--ink); } |
| .tab[aria-selected="true"] { background: var(--surface); color: var(--spruce-2); box-shadow: inset 0 -3px var(--spruce); } |
| .panel { display: none; } |
| .panel.active { display: block; animation: enter 220ms ease both; } |
| @keyframes enter { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } |
| |
| .tool-grid { display: grid; grid-template-columns: minmax(0, 1.16fr) minmax(340px, .84fr); min-height: 660px; } |
| .form-pane { padding: clamp(24px, 4vw, 44px); border-right: 1px solid var(--line); } |
| .result-pane { padding: clamp(24px, 4vw, 44px); background: color-mix(in srgb, var(--spruce-soft) 32%, var(--surface)); } |
| .section-intro { margin-bottom: 30px; } |
| .section-intro p { margin: 8px 0 0; color: var(--muted); } |
| .field-groups { display: grid; gap: 22px; } |
| fieldset { margin: 0; padding: 0; border: 0; } |
| legend { |
| width: 100%; |
| margin-bottom: 12px; |
| padding-bottom: 8px; |
| border-bottom: 1px solid var(--line); |
| color: var(--spruce-2); |
| font-family: var(--font-mono); |
| font-size: 12px; |
| font-weight: 700; |
| letter-spacing: .08em; |
| text-transform: uppercase; |
| } |
| .fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 16px; } |
| .field { min-width: 0; } |
| label { display: block; margin-bottom: 6px; font-size: 14px; font-weight: 650; } |
| input, select { |
| width: 100%; |
| min-height: 46px; |
| padding: 9px 11px; |
| border: 1px solid var(--line); |
| border-radius: var(--radius-sm); |
| background: var(--surface); |
| color: var(--ink); |
| transition: border-color 160ms ease, box-shadow 160ms ease; |
| } |
| input:hover, select:hover { border-color: var(--spruce); } |
| input:focus, select:focus { border-color: var(--spruce); box-shadow: 0 0 0 3px color-mix(in srgb, var(--spruce) 18%, transparent); outline: 0; } |
| .range { display: block; margin-top: 4px; color: var(--muted); font-family: var(--font-mono); font-size: 11px; } |
| .primary-button { |
| width: 100%; |
| min-height: 52px; |
| margin-top: 26px; |
| border: 1px solid var(--spruce-2); |
| border-radius: var(--radius-sm); |
| background: var(--spruce-2); |
| color: var(--surface); |
| cursor: pointer; |
| font-weight: 750; |
| letter-spacing: .01em; |
| transition: transform 160ms ease, filter 160ms ease, box-shadow 160ms ease; |
| } |
| [data-theme="dark"] .primary-button { color: #13221b; } |
| .primary-button:hover { filter: brightness(1.08); box-shadow: 0 10px 24px rgba(22, 75, 62, .18); transform: translateY(-1px); } |
| .primary-button:active { transform: translateY(1px); } |
| .primary-button:disabled { cursor: wait; opacity: .62; transform: none; } |
| |
| .empty-state { min-height: 340px; display: grid; place-items: center; text-align: center; color: var(--muted); } |
| .empty-state .ring { display: grid; place-items: center; width: 92px; height: 92px; margin: 0 auto 20px; border: 1px solid var(--line); border-radius: 50%; font-family: var(--font-mono); color: var(--spruce); } |
| .result-block[hidden], .error-block[hidden] { display: none; } |
| .result-kicker { color: var(--spruce-2); font-family: var(--font-mono); font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } |
| .prediction { margin: 10px 0 6px; font-family: var(--font-display); font-size: clamp(52px, 7vw, 78px); line-height: 1; letter-spacing: -.04em; } |
| .prediction small { font-family: var(--font-body); font-size: 18px; letter-spacing: 0; } |
| .band { margin: 0 0 26px; color: var(--muted); } |
| .band strong { color: var(--ink); } |
| .notes { margin: 20px 0 0; padding: 18px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface); } |
| .notes h4 { margin: 0 0 10px; font-size: 15px; } |
| .notes ul { margin: 0; padding-left: 20px; } |
| .notes li + li { margin-top: 6px; } |
| .error-block { padding: 18px; border: 1px solid var(--clay); border-radius: var(--radius-sm); background: var(--clay-soft); color: var(--ink); } |
| |
| .trajectory-pane { padding: clamp(24px, 4vw, 44px); } |
| .trajectory-layout { display: grid; grid-template-columns: minmax(340px, .72fr) minmax(0, 1.28fr); gap: 30px; } |
| .time-fields { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 22px; } |
| .chart-card { min-height: 420px; padding: 18px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface); } |
| #trajectoryChart { display: block; width: 100%; height: auto; overflow: visible; } |
| .chart-legend { display: flex; flex-wrap: wrap; gap: 14px; margin: 12px 0 0; color: var(--muted); font-size: 13px; } |
| .legend-line::before, .legend-band::before { content: ""; display: inline-block; width: 24px; height: 3px; margin-right: 7px; vertical-align: middle; background: var(--spruce); } |
| .legend-band::before { height: 10px; background: var(--spruce-soft); border: 1px solid var(--spruce); } |
| .table-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin: 22px 0 10px; } |
| .table-wrap { max-height: 320px; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius-sm); } |
| table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; font-size: 14px; } |
| th, td { padding: 9px 12px; border-bottom: 1px solid var(--line); text-align: right; white-space: nowrap; } |
| th { position: sticky; top: 0; background: var(--surface-2); color: var(--ink); font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em; } |
| th:first-child, td:first-child { text-align: left; } |
| |
| .methods { display: grid; grid-template-columns: .65fr 1.35fr; gap: clamp(28px, 6vw, 72px); padding: 64px 0 80px; } |
| .methods-copy p { color: var(--muted); } |
| details { border-top: 1px solid var(--line); } |
| details:last-child { border-bottom: 1px solid var(--line); } |
| summary { padding: 18px 0; cursor: pointer; font-weight: 700; list-style: none; } |
| summary::after { content: "+"; float: right; color: var(--spruce); font-family: var(--font-mono); } |
| details[open] summary::after { content: "−"; } |
| details p, details ul { color: var(--muted); } |
| details > :not(summary) { margin-top: 0; margin-bottom: 20px; } |
| |
| footer { padding: 28px 0 44px; border-top: 1px solid var(--line); color: var(--muted); font-size: 14px; } |
| footer a { color: var(--spruce-2); } |
| |
| @media (max-width: 900px) { |
| .hero, .tool-grid, .trajectory-layout, .methods { grid-template-columns: 1fr; } |
| .evidence-strip { grid-template-columns: repeat(2, 1fr); } |
| .evidence-item:nth-child(2) { border-right: 0; } |
| .evidence-item:nth-child(-n+2) { border-bottom: 1px solid var(--line); } |
| .form-pane { border-right: 0; border-bottom: 1px solid var(--line); } |
| .result-pane { min-height: 430px; } |
| } |
| @media (max-width: 600px) { |
| body { font-size: 16px; } |
| .shell { width: min(100% - 20px, 1180px); } |
| .topbar { min-height: 58px; } |
| .wordmark span:last-child { display: none; } |
| .hero { padding-top: 48px; } |
| .evidence-strip, .fields, .time-fields { grid-template-columns: 1fr; } |
| .evidence-item { border-right: 0; border-bottom: 1px solid var(--line); } |
| .evidence-item:last-child { border-bottom: 0; } |
| .warning-banner { grid-template-columns: 1fr; } |
| .tabs { overflow-x: auto; } |
| .tab { flex: 1 0 auto; min-height: 48px; padding: 10px 14px; } |
| .form-pane, .result-pane, .trajectory-pane { padding: 22px 18px; } |
| .table-toolbar { align-items: flex-start; flex-direction: column; } |
| } |
| @media (prefers-reduced-motion: reduce) { |
| *, *::before, *::after { scroll-behavior: auto !important; animation: none !important; transition-duration: .01ms !important; } |
| } |
| </style> |
| </head> |
| <body> |
| <header class="shell topbar"> |
| <div class="wordmark"><span class="mark" aria-hidden="true">P/H</span><span>PHBV Reliability Explorer</span></div> |
| <button id="themeToggle" class="quiet-button" type="button" aria-pressed="false">Switch theme</button> |
| </header> |
|
|
| <main> |
| <section class="shell hero" aria-labelledby="pageTitle"> |
| <div> |
| <p class="eyebrow">Study-aware polymer modelling</p> |
| <h1 id="pageTitle">Predict a trajectory.<br>Keep the uncertainty visible.</h1> |
| <p class="lede">A browser-native, bounded and time-monotonic XGBoost model for literature-derived PHBV degradation-induced mass loss.</p> |
| </div> |
| <aside class="endpoint-note"> |
| <strong>Endpoint boundary</strong> |
| <p>Mass loss can reflect disintegration, leaching, fragment recovery, and biological conversion. It is not proof of ultimate biodegradation or mineralisation.</p> |
| </aside> |
| </section> |
|
|
| <section class="shell evidence-strip" aria-label="Strict validation summary"> |
| <div class="evidence-item"><span class="metric">16</span><span class="metric-label">independent source studies</span></div> |
| <div class="evidence-item"><span class="metric">23.55</span><span class="metric-label">macro-study MAE, percentage points</span></div> |
| <div class="evidence-item"><span class="metric">0.145</span><span class="metric-label">nested LOSO point R²</span></div> |
| <div class="evidence-item"><span class="metric">91.9</span><span class="metric-label">mean width of post-selection 90% intervals</span></div> |
| </section> |
|
|
| <section class="shell warning-banner" aria-label="Research use warning"> |
| <span class="warning-code">RESEARCH USE</span> |
| <p>Cross-study errors were large and heterogeneous. The released corpus is additive-enriched and inherited source-level preprocessing. Use this tool to explore hypotheses and plan experiments—not for certification, regulatory decisions, safety claims, causal inference, or replacement of laboratory tests.</p> |
| </section> |
|
|
| <section class="shell workspace" aria-label="Prediction workspace"> |
| <div class="tabs" role="tablist" aria-label="Prediction mode"> |
| <button class="tab" id="singleTab" role="tab" aria-controls="singlePanel" aria-selected="true">Single time point</button> |
| <button class="tab" id="trajectoryTab" role="tab" aria-controls="trajectoryPanel" aria-selected="false">Full trajectory</button> |
| </div> |
|
|
| <section id="singlePanel" class="panel active" role="tabpanel" aria-labelledby="singleTab"> |
| <div class="tool-grid"> |
| <div class="form-pane"> |
| <div class="section-intro"><h2>Describe one observation</h2><p>All fields mirror the released modelling table. Recorded marginal ranges are shown beneath numeric inputs.</p></div> |
| <form id="singleForm" class="field-groups"></form> |
| <button id="predictSingle" class="primary-button" type="button">Calculate mass-loss prediction</button> |
| </div> |
| <div class="result-pane" aria-live="polite"> |
| <div id="singleEmpty" class="empty-state"><div><div class="ring">0–100</div><strong>Ready for a study-aware estimate</strong><p>Enter a scenario, then calculate. The reliability band will remain visible beside the point estimate.</p></div></div> |
| <div id="singleResult" class="result-block" hidden> |
| <span class="result-kicker">Bounded monotonic estimate</span> |
| <div class="prediction"><span id="predictionValue">—</span><small>% mass loss</small></div> |
| <p class="band"><strong id="intervalValue">—</strong><br>Post-selection 90% grouped residual band; not guaranteed for a new study.</p> |
| <div id="singleNotes" class="notes"></div> |
| </div> |
| <div id="singleError" class="error-block" hidden role="alert"></div> |
| </div> |
| </div> |
| </section> |
|
|
| <section id="trajectoryPanel" class="panel" role="tabpanel" aria-labelledby="trajectoryTab"> |
| <div class="trajectory-pane"> |
| <div class="section-intro"><h2>Trace a fixed-profile trajectory</h2><p>Only time changes along the curve. Every other formulation and environmental descriptor remains fixed.</p></div> |
| <div class="trajectory-layout"> |
| <div> |
| <div class="time-fields"> |
| <div class="field"><label for="startTime">Start day</label><input id="startTime" type="number" value="0" step="1"><span class="range">Suggested 0–360</span></div> |
| <div class="field"><label for="endTime">End day</label><input id="endTime" type="number" value="180" step="1"><span class="range">Must exceed start</span></div> |
| <div class="field"><label for="pointCount">Points</label><input id="pointCount" type="number" value="61" min="2" max="200" step="1"><span class="range">2–200</span></div> |
| </div> |
| <form id="trajectoryForm" class="field-groups"></form> |
| <button id="predictTrajectory" class="primary-button" type="button">Generate bounded trajectory</button> |
| <div id="trajectoryNotes" class="notes" hidden aria-live="polite"></div> |
| <div id="trajectoryError" class="error-block" hidden role="alert"></div> |
| </div> |
| <div> |
| <div class="chart-card"> |
| <svg id="trajectoryChart" viewBox="0 0 800 430" role="img" aria-label="Predicted PHBV mass-loss trajectory with descriptive interval"> |
| <text x="400" y="215" text-anchor="middle" fill="currentColor" opacity=".55">Generate a trajectory to populate the chart</text> |
| </svg> |
| <div class="chart-legend"><span class="legend-line">Prediction</span><span class="legend-band">Post-selection 90% residual band</span></div> |
| </div> |
| <div id="tableSection" hidden> |
| <div class="table-toolbar"><strong>Trajectory values</strong><button id="downloadCsv" class="quiet-button" type="button">Download CSV</button></div> |
| <div class="table-wrap"><table><thead><tr><th>Day</th><th>Prediction (%)</th><th>90% lower</th><th>90% upper</th></tr></thead><tbody id="trajectoryRows"></tbody></table></div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </section> |
| </section> |
|
|
| <section class="shell methods" aria-labelledby="methodTitle"> |
| <div class="methods-copy"><p class="eyebrow">Read before use</p><h2 id="methodTitle">A model card inside the interface</h2><p>The page reports the strict research estimate, not training fit. Browser inference keeps the method accessible without hiding its evidential limits.</p></div> |
| <div> |
| <details open><summary>What the model guarantees</summary><p>Predictions remain within 0–100% through a bounded-logit transform plus explicit output clipping and are nondecreasing with degradation time for fixed descriptors. Three raw training responses slightly above 100% were clipped for bounded fitting. These are output-shape guarantees, not guarantees of accuracy.</p></details> |
| <details><summary>How it was evaluated</summary><p>The primary analysis used 16 outer leave-one-study-out folds with study-grouped inner tuning. Macro-study MAE was 23.554 percentage points (95% study-bootstrap CI 17.421–30.251). Random-row R² of 0.915 is shown only as an optimistic interpolation benchmark.</p></details> |
| <details><summary>Why the interval is so broad</summary><p>The nominal 90% post-selection grouped residual intervals covered 97.6% overall but averaged 91.9 percentage points wide. One held-out study achieved only 37.5% coverage. The same inner folds informed tuning and residual generation, so the displayed band is not independent conformal calibration or a conditional guarantee.</p></details> |
| <details><summary>What applicability notes mean</summary><p>Range and category checks can flag obvious marginal departures from the recorded evidence. They do not validate a joint scenario, detect every study shift, omitted protocol variable, or new material–environment interaction, and they do not certify a prediction as reliable. Unvalidated combinations are exploratory.</p></details> |
| <details><summary>What population supports the model</summary><p>The released evidence base contains 1,467 rows from 129 curves and 16 studies, with 1,247 rows marked additive-present. It inherited global temperature completion for 210 rows, category coarsening, and zero filling of unreported additive concentrations. It is not a probability sample of PHBV experiments.</p></details> |
| <details><summary>Privacy and computation</summary><p>The model, preprocessing map, and tree traversal run locally in this browser. Inputs are not sent to a prediction API. The static files are hosted by Hugging Face Spaces.</p></details> |
| </div> |
| </section> |
| </main> |
|
|
| <footer> |
| <div class="shell">Source corpus: <a href="https://doi.org/10.3390/polym18070897" rel="noopener">Kotzabasaki et al. (2026)</a>. Companion reliability manuscript and archival repository citation will be added after publication. Model export: <code>phbv-bm-xgb-2026-07-26-v1</code>; released 26 July 2026.</div> |
| </footer> |
|
|
| <script> |
| "use strict"; |
| |
| const featureLabels = { |
| degradation_time_days: "Degradation time (days)", adjusted_hb_ratio_formulation_mol: "Adjusted HB ratio (mol%)", |
| adjusted_hv_ratio_formulation_mol: "Adjusted HV ratio (mol%)", t_deg: "Temperature (°C)", |
| additive1_percentage_wt: "Additive 1 (wt%)", additive2_percentage_wt: "Additive 2 (wt%)", |
| additive3_percentage_wt: "Additive 3 (wt%)", degradation_condition: "Oxygen condition", |
| degradation_mechanism: "Recorded degradation mechanism", additives: "Any additives?", |
| degradation_environment: "Environment", additive_type_1: "Additive type 1", additive_type_2: "Additive type 2", |
| additive_type_3: "Additive type 3", sample_shape_morphology: "Specimen morphology", |
| pha_degrading_microbes: "PHA-degrading microbes", experimental_scale: "Experimental scale" |
| }; |
| const defaults = { |
| degradation_time_days: 30, adjusted_hb_ratio_formulation_mol: 90, adjusted_hv_ratio_formulation_mol: 10, |
| t_deg: 27, additive1_percentage_wt: 0, additive2_percentage_wt: 0, additive3_percentage_wt: 0, |
| degradation_condition: "aerobic", degradation_mechanism: "microbial_enzymatic", additives: "no", |
| degradation_environment: "soil", additive_type_1: "not_applicable", additive_type_2: "not_applicable", |
| additive_type_3: "not_applicable", sample_shape_morphology: "films", |
| pha_degrading_microbes: "diverse_environmental", experimental_scale: "lab" |
| }; |
| const groups = { |
| "Time and formulation": ["degradation_time_days", "adjusted_hb_ratio_formulation_mol", "adjusted_hv_ratio_formulation_mol", "t_deg"], |
| "Experimental context": ["degradation_condition", "degradation_mechanism", "degradation_environment", "sample_shape_morphology", "pha_degrading_microbes", "experimental_scale"], |
| "Additives": ["additives", "additive1_percentage_wt", "additive_type_1", "additive2_percentage_wt", "additive_type_2", "additive3_percentage_wt", "additive_type_3"] |
| }; |
| let metadata; |
| let browserModel; |
| let latestRows = []; |
| let latestApplicabilityNotes = []; |
| |
| const $ = id => document.getElementById(id); |
| const escapeHtml = value => String(value).replace(/[&<>'"]/g, character => ({"&":"&","<":"<",">":">","'":"'",'"':"""})[character]); |
| |
| function fieldMarkup(feature, prefix) { |
| const id = `${prefix}_${feature}`; |
| if (metadata.numeric_features.includes(feature)) { |
| const range = metadata.numeric_ranges[feature]; |
| const step = feature === "degradation_time_days" || feature === "t_deg" ? 1 : .1; |
| return `<div class="field"><label for="${id}">${featureLabels[feature]}</label><input id="${id}" name="${feature}" type="number" value="${defaults[feature]}" step="${step}"><span class="range">Recorded ${range.minimum}–${range.maximum}</span></div>`; |
| } |
| const options = metadata.categorical_levels[feature].map(level => `<option value="${escapeHtml(level)}"${level === defaults[feature] ? " selected" : ""}>${escapeHtml(level.replaceAll("_", " "))}</option>`).join(""); |
| return `<div class="field"><label for="${id}">${featureLabels[feature]}</label><select id="${id}" name="${feature}">${options}</select></div>`; |
| } |
| |
| function renderForm(formId, prefix, excludeTime) { |
| const form = $(formId); |
| form.innerHTML = Object.entries(groups).map(([name, features]) => { |
| const visible = features.filter(feature => !(excludeTime && feature === "degradation_time_days")); |
| return `<fieldset><legend>${name}</legend><div class="fields">${visible.map(feature => fieldMarkup(feature, prefix)).join("")}</div></fieldset>`; |
| }).join(""); |
| } |
| |
| function readForm(formId, timeOverride) { |
| const data = new FormData($(formId)); |
| const record = {}; |
| metadata.features.forEach(feature => { |
| if (feature === "degradation_time_days" && timeOverride !== undefined) record[feature] = Number(timeOverride); |
| else if (metadata.numeric_features.includes(feature)) record[feature] = Number(data.get(feature)); |
| else record[feature] = String(data.get(feature)); |
| }); |
| for (const feature of metadata.numeric_features) { |
| if (!Number.isFinite(record[feature])) throw new Error(`${featureLabels[feature]} must be a finite number.`); |
| } |
| return record; |
| } |
| |
| function transform(record) { |
| const vector = []; |
| browserModel.numeric_features.forEach((feature, index) => { |
| const value = Number(record[feature]); |
| vector.push(Number.isFinite(value) ? value : browserModel.numeric_imputer_medians[index]); |
| }); |
| browserModel.categorical_features.forEach((feature, index) => { |
| const selected = String(record[feature]); |
| browserModel.encoder_categories[index].forEach(category => vector.push(selected === category ? 1 : 0)); |
| }); |
| if (vector.length !== browserModel.num_transformed_features) throw new Error("The browser preprocessing map is inconsistent with the model."); |
| return vector; |
| } |
| |
| function predict(record) { |
| const vector = transform(record); |
| let margin = browserModel.base_score; |
| for (const tree of browserModel.trees) { |
| let node = 0; |
| while (tree.left[node] !== -1) { |
| const value = vector[tree.split_index[node]]; |
| const goLeft = Number.isNaN(value) ? Boolean(tree.default_left[node]) : value < tree.split_condition[node]; |
| node = goLeft ? tree.left[node] : tree.right[node]; |
| } |
| margin += tree.split_condition[node]; |
| } |
| const epsilon = browserModel.target_epsilon; |
| const probability = 1 / (1 + Math.exp(-Math.max(-40, Math.min(40, margin)))); |
| const prediction = probability * (100 + 2 * epsilon) - epsilon; |
| return Math.max(0, Math.min(100, prediction)); |
| } |
| |
| function applicabilityNotes(record) { |
| const notes = []; |
| metadata.numeric_features.forEach(feature => { |
| const {minimum, maximum} = metadata.numeric_ranges[feature]; |
| if (record[feature] < minimum || record[feature] > maximum) notes.push(`${featureLabels[feature]}=${record[feature]} is outside the recorded range ${minimum}–${maximum}.`); |
| }); |
| metadata.categorical_features.forEach(feature => { |
| if (!metadata.categorical_levels[feature].includes(record[feature])) notes.push(`${featureLabels[feature]} is an unseen category.`); |
| }); |
| const additiveTotal = record.additive1_percentage_wt + record.additive2_percentage_wt + record.additive3_percentage_wt; |
| if (record.additives === "no" && additiveTotal > 0) notes.push("Additives is 'no', but at least one additive percentage is positive."); |
| if (record.additives === "yes" && additiveTotal === 0) notes.push("Additives is 'yes', but all three recorded percentages are zero."); |
| if (!notes.length) notes.push("All values are within recorded marginal ranges and category levels. Marginal checks do not validate this joint scenario or guarantee study-level similarity or accuracy."); |
| return notes; |
| } |
| |
| function notesMarkup(notes) { return `<h4>Applicability notes</h4><ul>${notes.map(note => `<li>${escapeHtml(note)}</li>`).join("")}</ul>`; } |
| |
| function showSingleError(error) { |
| $("singleEmpty").hidden = true; $("singleResult").hidden = true; $("singleError").hidden = false; |
| $("singleError").textContent = error.message || String(error); |
| } |
| |
| function runSingle() { |
| const button = $("predictSingle"); |
| button.disabled = true; button.textContent = "Calculating…"; |
| try { |
| const record = readForm("singleForm"); |
| const value = predict(record); |
| const halfWidth = metadata.descriptive_interval_half_widths["0.90"]; |
| const lower = Math.max(0, value - halfWidth); |
| const upper = Math.min(100, value + halfWidth); |
| $("predictionValue").textContent = value.toFixed(2); |
| $("intervalValue").textContent = `${lower.toFixed(2)}% to ${upper.toFixed(2)}%`; |
| $("singleNotes").innerHTML = notesMarkup(applicabilityNotes(record)); |
| $("singleEmpty").hidden = true; $("singleError").hidden = true; $("singleResult").hidden = false; |
| } catch (error) { showSingleError(error); } |
| finally { button.disabled = false; button.textContent = "Calculate mass-loss prediction"; } |
| } |
| |
| function svgNode(name, attributes = {}, text = "") { |
| const node = document.createElementNS("http://www.w3.org/2000/svg", name); |
| Object.entries(attributes).forEach(([key, value]) => node.setAttribute(key, value)); |
| if (text) node.textContent = text; |
| return node; |
| } |
| |
| function drawChart(rows) { |
| const svg = $("trajectoryChart"); |
| svg.replaceChildren(); |
| const width = 800, height = 430, left = 68, right = 20, top = 24, bottom = 58; |
| const innerWidth = width - left - right, innerHeight = height - top - bottom; |
| const minX = rows[0].day, maxX = rows.at(-1).day; |
| const x = value => left + (value - minX) / (maxX - minX) * innerWidth; |
| const y = value => top + (100 - value) / 100 * innerHeight; |
| for (let tick = 0; tick <= 100; tick += 25) { |
| svg.append(svgNode("line", {x1:left, x2:width-right, y1:y(tick), y2:y(tick), stroke:"currentColor", opacity:".12"})); |
| svg.append(svgNode("text", {x:left-12, y:y(tick)+4, "text-anchor":"end", fill:"currentColor", opacity:".65", "font-size":"12"}, String(tick))); |
| } |
| for (let i = 0; i <= 4; i++) { |
| const value = minX + (maxX - minX) * i / 4; |
| svg.append(svgNode("text", {x:x(value), y:height-24, "text-anchor":"middle", fill:"currentColor", opacity:".65", "font-size":"12"}, value.toFixed(value % 1 ? 1 : 0))); |
| } |
| const upper = rows.map(row => `${x(row.day)},${y(row.upper)}`).join(" "); |
| const lower = [...rows].reverse().map(row => `${x(row.day)},${y(row.lower)}`).join(" "); |
| svg.append(svgNode("polygon", {points:`${upper} ${lower}`, fill:"var(--spruce-soft)", stroke:"var(--spruce)", "stroke-opacity":".35"})); |
| const line = rows.map((row, index) => `${index ? "L" : "M"}${x(row.day)} ${y(row.prediction)}`).join(" "); |
| svg.append(svgNode("path", {d:line, fill:"none", stroke:"var(--spruce)", "stroke-width":"4", "stroke-linecap":"round", "stroke-linejoin":"round"})); |
| svg.append(svgNode("line", {x1:left, x2:left, y1:top, y2:height-bottom, stroke:"currentColor", opacity:".55"})); |
| svg.append(svgNode("line", {x1:left, x2:width-right, y1:height-bottom, y2:height-bottom, stroke:"currentColor", opacity:".55"})); |
| svg.append(svgNode("text", {x:(left+width-right)/2, y:height-2, "text-anchor":"middle", fill:"currentColor", opacity:".75", "font-size":"13"}, "Degradation time (days)")); |
| const yLabel = svgNode("text", {x:15, y:(top+height-bottom)/2, "text-anchor":"middle", fill:"currentColor", opacity:".75", "font-size":"13", transform:`rotate(-90 15 ${(top+height-bottom)/2})`}, "Predicted mass loss (%)"); |
| svg.append(yLabel); |
| } |
| |
| function renderTable(rows) { |
| $("trajectoryRows").innerHTML = rows.map(row => `<tr><td>${row.day.toFixed(3)}</td><td>${row.prediction.toFixed(3)}</td><td>${row.lower.toFixed(3)}</td><td>${row.upper.toFixed(3)}</td></tr>`).join(""); |
| $("tableSection").hidden = false; |
| } |
| |
| function runTrajectory() { |
| const button = $("predictTrajectory"); |
| button.disabled = true; button.textContent = "Generating…"; |
| $("trajectoryError").hidden = true; |
| try { |
| const start = Number($("startTime").value), end = Number($("endTime").value), points = Number($("pointCount").value); |
| if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) throw new Error("End day must be greater than start day."); |
| if (!Number.isInteger(points) || points < 2 || points > 200) throw new Error("Points must be an integer between 2 and 200."); |
| const halfWidth = metadata.descriptive_interval_half_widths["0.90"]; |
| latestRows = []; |
| for (let index = 0; index < points; index++) { |
| const day = start + (end - start) * index / (points - 1); |
| const record = readForm("trajectoryForm", day); |
| const prediction = predict(record); |
| latestRows.push({day, prediction, lower:Math.max(0,prediction-halfWidth), upper:Math.min(100,prediction+halfWidth)}); |
| } |
| drawChart(latestRows); renderTable(latestRows); |
| const noteRecord = readForm("trajectoryForm", start); |
| latestApplicabilityNotes = applicabilityNotes(noteRecord); |
| $("trajectoryNotes").innerHTML = notesMarkup(latestApplicabilityNotes); |
| $("trajectoryNotes").hidden = false; |
| } catch (error) { |
| $("trajectoryError").textContent = error.message || String(error); $("trajectoryError").hidden = false; |
| } finally { button.disabled = false; button.textContent = "Generate bounded trajectory"; } |
| } |
| |
| function downloadCsv() { |
| if (!latestRows.length) return; |
| const csvCell = value => `"${String(value).replaceAll('"', '""')}"`; |
| const modelId = metadata.model_export_id || "phbv-bm-xgb-2026-07-26-v1"; |
| const releaseDate = metadata.release_date || "2026-07-26"; |
| const endpoint = "degradation-induced specimen mass loss; not ultimate biodegradation or mineralisation"; |
| const intervalStatus = "post-selection grouped residual diagnostic; not a conditional guarantee"; |
| const intendedUse = metadata.intended_use_limits || "research hypothesis exploration only; not for certification, regulatory or safety decisions, causal inference, or replacement of laboratory testing"; |
| const applicability = latestApplicabilityNotes.join("; "); |
| const header = "time_days,prediction_pct,post_selection_90_lower,post_selection_90_upper,model_export_id,release_date,endpoint,interval_status,intended_use_limits,applicability_notes"; |
| const lines = [header, ...latestRows.map(row => [row.day.toFixed(6),row.prediction.toFixed(6),row.lower.toFixed(6),row.upper.toFixed(6),csvCell(modelId),csvCell(releaseDate),csvCell(endpoint),csvCell(intervalStatus),csvCell(intendedUse),csvCell(applicability)].join(","))]; |
| const blob = new Blob([lines.join("\n")], {type:"text/csv;charset=utf-8"}); |
| const url = URL.createObjectURL(blob); |
| const link = document.createElement("a"); link.href = url; link.download = "phbv_mass_loss_trajectory.csv"; link.click(); |
| setTimeout(() => URL.revokeObjectURL(url), 0); |
| } |
| |
| function switchTab(target) { |
| const single = target === "single"; |
| $("singleTab").setAttribute("aria-selected", String(single)); |
| $("trajectoryTab").setAttribute("aria-selected", String(!single)); |
| $("singlePanel").classList.toggle("active", single); |
| $("trajectoryPanel").classList.toggle("active", !single); |
| } |
| |
| function toggleTheme() { |
| const dark = document.documentElement.dataset.theme !== "dark"; |
| document.documentElement.dataset.theme = dark ? "dark" : "light"; |
| $("themeToggle").setAttribute("aria-pressed", String(dark)); |
| $("themeToggle").textContent = dark ? "Use paper theme" : "Use ink theme"; |
| localStorage.setItem("phbv-theme", dark ? "dark" : "light"); |
| if (latestRows.length) drawChart(latestRows); |
| } |
| |
| async function initialise() { |
| try { |
| const [metadataResponse, modelResponse] = await Promise.all([fetch("model_metadata.json"), fetch("browser_model.json")]); |
| if (!metadataResponse.ok || !modelResponse.ok) throw new Error("Model files could not be loaded."); |
| [metadata, browserModel] = await Promise.all([metadataResponse.json(), modelResponse.json()]); |
| renderForm("singleForm", "single", false); |
| renderForm("trajectoryForm", "trajectory", true); |
| } catch (error) { |
| showSingleError(new Error(`Initialisation failed: ${error.message}`)); |
| $("predictSingle").disabled = true; $("predictTrajectory").disabled = true; |
| } |
| } |
| |
| $("singleTab").addEventListener("click", () => switchTab("single")); |
| $("trajectoryTab").addEventListener("click", () => switchTab("trajectory")); |
| $("predictSingle").addEventListener("click", runSingle); |
| $("predictTrajectory").addEventListener("click", runTrajectory); |
| $("downloadCsv").addEventListener("click", downloadCsv); |
| $("themeToggle").addEventListener("click", toggleTheme); |
| const savedTheme = localStorage.getItem("phbv-theme"); |
| if (savedTheme === "dark" || (!savedTheme && window.matchMedia("(prefers-color-scheme: dark)").matches)) toggleTheme(); |
| initialise(); |
| </script> |
| </body> |
| </html> |
|
|