| "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 `<div class="field"><label for="${id}">${LABELS[feature]}</label><input id="${id}" name="${feature}" type="number" value="${escapeHtml(value)}" step="${step}"><span class="range">Recorded ${schema.minimum}–${schema.maximum}</span></div>`; |
| } |
| const options = model.categorical_schema[feature].map(level => ( |
| `<option value="${escapeHtml(level)}"${level === value ? " selected" : ""}>${escapeHtml(humanise(level))}</option>` |
| )).join(""); |
| return `<div class="field"><label for="${id}">${LABELS[feature]}</label><select id="${id}" name="${feature}">${options}</select></div>`; |
| } |
|
|
| 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 `<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 = {}; |
| [...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 `<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 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(); |
|
|