"use strict"; const LABELS = { biodegradation_time_days: "Prediction day", adjusted_hb_ratio_formulation_mol: "Adjusted HB ratio (mol)", adjusted_hv_ratio_formulation_mol: "Adjusted HV ratio (mol)", t_biodeg: "Biodegradation temperature (°C)", additive1_percentage_wt: "Additive 1 (wt%)", additive2_percentage_wt: "Additive 2 (wt%)", additive3_percentage_wt: "Additive 3 (wt%)", biodegradation_condition: "Oxygen condition", degradation_mechanism: "Degradation mechanism", additives: "Additives present", 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", }; const GROUPS = { "Time and formulation": [ "biodegradation_time_days", "adjusted_hb_ratio_formulation_mol", "adjusted_hv_ratio_formulation_mol", "sample_shape_morphology", ], "Environment and biological context": [ "t_biodeg", "biodegradation_condition", "degradation_environment", "degradation_mechanism", "pha_degrading_microbes", ], "Additive package": [ "additives", "additive_type_1", "additive1_percentage_wt", "additive_type_2", "additive2_percentage_wt", "additive_type_3", "additive3_percentage_wt", ], }; const $ = id => document.getElementById(id); const escapeHtml = value => String(value).replace(/[&<>'"]/g, character => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """, })[character]); const humanise = value => String(value).replaceAll("_", " ").replace(/\b\w/g, letter => letter.toUpperCase()); const format = value => Number(value).toFixed(1); let model; let latestCurve; let latestNotes = []; function fieldMarkup(feature, prefix) { const id = `${prefix}_${feature}`; const value = model.default_record[feature]; if (model.numeric_features.includes(feature)) { const schema = model.numeric_schema[feature]; const step = feature === "biodegradation_time_days" || feature === "t_biodeg" ? 1 : .1; return `
Recorded ${schema.minimum}–${schema.maximum}
`; } const options = model.categorical_schema[feature].map(level => ( `` )).join(""); return `
`; } function renderForm(formId, prefix, excludeTime) { $(formId).innerHTML = Object.entries(GROUPS).map(([name, features]) => { const available = new Set([...model.numeric_features, ...model.categorical_features]); const visible = features.filter(feature => available.has(feature) && !(excludeTime && feature === "biodegradation_time_days")); return `
${name}
${visible.map(feature => fieldMarkup(feature, prefix)).join("")}
`; }).join(""); } function readForm(formId, timeOverride) { const data = new FormData($(formId)); const record = {}; [...model.numeric_features, ...model.categorical_features].forEach(feature => { if (feature === "biodegradation_time_days" && timeOverride !== undefined) record[feature] = Number(timeOverride); else if (model.numeric_features.includes(feature)) record[feature] = Number(data.get(feature)); else record[feature] = String(data.get(feature)); }); model.numeric_features.forEach(feature => { if (!Number.isFinite(record[feature])) throw new Error(`${LABELS[feature]} must be a finite number.`); }); return record; } function applicabilityNotes(record, additionalTimes = []) { const notes = []; const numericRecords = [record, ...additionalTimes.map(time => ({...record, biodegradation_time_days: time}))]; model.numeric_features.forEach(feature => { const schema = model.numeric_schema[feature]; const observed = numericRecords.map(candidate => candidate[feature]); if (observed.some(value => value < schema.minimum || value > schema.maximum)) { const display = feature === "biodegradation_time_days" && additionalTimes.length ? `${Math.min(...observed)}–${Math.max(...observed)}` : String(record[feature]); notes.push(`${LABELS[feature]} (${display}) extends beyond the recorded range ${schema.minimum}–${schema.maximum}.`); } }); model.categorical_features.forEach(feature => { if (!model.categorical_schema[feature].includes(record[feature])) notes.push(`${LABELS[feature]} is an unseen category.`); }); if (!notes.length) { notes.push("All inputs are inside recorded marginal ranges. This does not establish that the joint scenario or an unobserved study is represented."); } else { notes.push("Applicability flags are warnings only; distance did not reliably calibrate study-level error."); } return notes; } function notesMarkup(notes) { return `

Applicability notes

`; } 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 result = PHBVPredictor.predictRecord(model, record); $("predictionValue").textContent = format(result.estimate); $("baselineValue").textContent = `${format(result.global_baseline)}%`; $("representedInterval").textContent = `${format(result.represented_lower)}% to ${format(result.represented_upper)}%`; $("stressInterval").textContent = `${format(result.cross_study_lower)}% to ${format(result.cross_study_upper)}%`; $("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 exploratory estimate"; } } 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(curve) { const svg = $("curveChart"); svg.replaceChildren(); const width = 840; const height = 470; const left = 70; const right = 22; const top = 24; const bottom = 62; const innerWidth = width - left - right; const innerHeight = height - top - bottom; const minX = curve.time_days[0]; const maxX = curve.time_days[curve.time_days.length - 1]; 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 index = 0; index <= 4; index += 1) { const value = minX + ((maxX - minX) * index) / 4; svg.append(svgNode("text", {x: x(value), y: height - 25, "text-anchor": "middle", fill: "currentColor", opacity: ".65", "font-size": "12"}, value.toFixed(value % 1 ? 1 : 0))); } const polygon = (upper, lower, fill, stroke) => { const upperPoints = curve.time_days.map((day, index) => `${x(day)},${y(upper[index])}`).join(" "); const lowerPoints = [...curve.time_days].reverse().map((day, reverseIndex) => { const index = curve.time_days.length - reverseIndex - 1; return `${x(day)},${y(lower[index])}`; }).join(" "); svg.append(svgNode("polygon", {points: `${upperPoints} ${lowerPoints}`, fill, stroke, "stroke-opacity": ".4"})); }; polygon(curve.cross_study_upper, curve.cross_study_lower, "var(--clay-soft)", "var(--clay)"); polygon(curve.represented_upper, curve.represented_lower, "var(--spruce-soft)", "var(--spruce)"); const path = values => curve.time_days.map((day, index) => `${index ? "L" : "M"}${x(day)} ${y(values[index])}`).join(" "); svg.append(svgNode("path", {d: path(curve.global_baseline), fill: "none", stroke: "var(--gold)", "stroke-width": "3", "stroke-dasharray": "7 5"})); svg.append(svgNode("path", {d: path(curve.estimate), 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 - 3, "text-anchor": "middle", fill: "currentColor", opacity: ".75", "font-size": "13"}, "Biodegradation time (days)")); svg.append(svgNode("text", {x: 17, y: (top + height - bottom) / 2, "text-anchor": "middle", fill: "currentColor", opacity: ".75", "font-size": "13", transform: `rotate(-90 17 ${(top + height - bottom) / 2})`}, "Mineralization (%)")); } function runCurve() { const button = $("predictCurve"); button.disabled = true; button.textContent = "Generating…"; $("curveError").hidden = true; try { const startDay = Number($("startDay").value); const endDay = Number($("endDay").value); const points = Number($("curvePoints").value); const monotone = $("monotone").checked; const record = readForm("curveForm", startDay); latestCurve = PHBVPredictor.predictCurve(model, record, {startDay, endDay, points, monotone}); latestNotes = applicabilityNotes(record, [endDay]); drawChart(latestCurve); $("curveNotes").innerHTML = notesMarkup(latestNotes); $("curveNotes").hidden = false; $("downloadCsv").disabled = false; } catch (error) { $("curveError").textContent = error.message || String(error); $("curveError").hidden = false; $("downloadCsv").disabled = true; } finally { button.disabled = false; button.textContent = "Generate trajectory"; } } function csvCell(value) { return `"${String(value).replaceAll('"', '""')}"`; } function downloadCsv() { if (!latestCurve) return; const header = "time_days,rf_estimate_pct,global_time_reference_pct,represented_lower_pct,represented_upper_pct,cross_study_lower_pct,cross_study_upper_pct,model_version,endpoint,interpretation_boundary,applicability_notes"; const rows = latestCurve.time_days.map((day, index) => [ day.toFixed(6), latestCurve.estimate[index].toFixed(6), latestCurve.global_baseline[index].toFixed(6), latestCurve.represented_lower[index].toFixed(6), latestCurve.represented_upper[index].toFixed(6), latestCurve.cross_study_lower[index].toFixed(6), latestCurve.cross_study_upper[index].toFixed(6), csvCell(model.model_version), csvCell("CO2-based PHBV mineralization percentage"), csvCell("research-use empirical intervals; not prospective coverage, certification, or a decision guarantee"), csvCell(latestNotes.join("; ")), ].join(",")); const blob = new Blob([[header, ...rows].join("\n")], {type: "text/csv;charset=utf-8"}); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "phbv_mineralization_trajectory.csv"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 0); } function switchTab(target) { const single = target === "single"; $("singleTab").setAttribute("aria-selected", String(single)); $("curveTab").setAttribute("aria-selected", String(!single)); $("singlePanel").classList.toggle("active", single); $("singlePanel").hidden = !single; $("curvePanel").classList.toggle("active", !single); $("curvePanel").hidden = 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 ? "Paper theme" : "Ink theme"; localStorage.setItem("phbv-mineralization-theme", dark ? "dark" : "light"); if (latestCurve) drawChart(latestCurve); } async function initialise() { try { const response = await fetch("browser_model.json", {cache: "no-store"}); if (!response.ok) throw new Error(`HTTP ${response.status}`); model = await response.json(); if (model.format !== "phbv-sklearn-random-forest-json" || model.model_version !== "3.0.0") { throw new Error("The downloaded model is not the ex-ante 3.0.0 browser release."); } renderForm("singleForm", "single", false); renderForm("curveForm", "curve", true); $("predictSingle").disabled = false; $("predictCurve").disabled = false; $("modelStatus").textContent = "Model v3.0.0 ready · local inference"; $("modelStatus").classList.add("ready"); runSingle(); } catch (error) { $("modelStatus").textContent = "Model load failed"; $("modelStatus").classList.add("error"); showSingleError(new Error(`Initialisation failed: ${error.message}`)); } } $("singleTab").addEventListener("click", () => switchTab("single")); $("curveTab").addEventListener("click", () => switchTab("curve")); $("predictSingle").addEventListener("click", runSingle); $("predictCurve").addEventListener("click", runCurve); $("downloadCsv").addEventListener("click", downloadCsv); $("themeToggle").addEventListener("click", toggleTheme); const savedTheme = localStorage.getItem("phbv-mineralization-theme"); if (savedTheme === "dark" || (!savedTheme && window.matchMedia("(prefers-color-scheme: dark)").matches)) toggleTheme(); initialise();