From 4cda432c74718c9c343cf4de2bebfa1b2c014442 Mon Sep 17 00:00:00 2001 From: Ante Bing Date: Tue, 1 Sep 2026 16:57:11 +0000 Subject: [PATCH 01/12] Add an interactive webR playground page A new documentation page (pkgdown/assets/playground.html) turns the browser demo into an interactive LAGO trial designer: it boots webR (pinned v0.6.0), installs LAGOtrials from the site's /webr-repo, and reads the vendored D3 and inst/js/lago-report.js out of the installed package so it renders with the package's own charts. Users pick a bundled dataset (BB_data/mtcars) or upload a CSV, choose the outcome and intervention components, set bounds, per-unit costs and the goal, then Run: the recommendation shows as the console summary plus the interactive confidence-set plot and per-component cost curves. Adds a Playground navbar item and extends tests/js/test-webr-demo.js to guard both webR pages. Robustness (from review): an empty confidence set serializes to {} (truthy), so the confidence-set renderer was called with a non-array and threw, hiding the cost curves; cost curves now render first and the confidence-set plot only draws for a non-empty array. The confidence-set grid step is derived per component from its range (~20 steps) instead of a hard-coded 1, so a wide continuous range (mtcars) no longer builds a runaway grid. Switching datasets or uploading a CSV clears the previous run's output and plots. --- _pkgdown.yml | 5 +- pkgdown/assets/playground.html | 410 +++++++++++++++++++++++++++++++++ tests/js/test-webr-demo.js | 62 +++-- 3 files changed, 444 insertions(+), 33 deletions(-) create mode 100644 pkgdown/assets/playground.html diff --git a/_pkgdown.yml b/_pkgdown.yml index 523db40..e275f16 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -9,12 +9,15 @@ authors: navbar: structure: - left: [reference, articles, news, demo] + left: [reference, articles, news, demo, playground] right: [search, github] components: demo: text: Live demo href: live-demo.html + playground: + text: Playground + href: playground.html news: releases: diff --git a/pkgdown/assets/playground.html b/pkgdown/assets/playground.html new file mode 100644 index 0000000..9e0e3ae --- /dev/null +++ b/pkgdown/assets/playground.html @@ -0,0 +1,410 @@ + + + + + + LAGOtrials playground + + + + +
+
+

LAGOtrials — playground

+ +
+
+ +
+

+ Design a LAGO optimization interactively, in your browser, with no install. Pick a bundled dataset or upload your + own CSV, choose the outcome and intervention components, set the bounds, per-unit costs and outcome goal, then press + Run. The recommendation is drawn with the package's own interactive + webR-powered charts. +

+ +
Starting webR…
+ +
+

1. Data

+
+
+ + +
+
+ +
Drop a .csv here, or click to choose
+ +
+
+

+
+ +
+

2. Model

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ + +
+ +

Result

+

+    
+
+ +

+ webR runs a WebAssembly build of R (4.6.0) entirely client-side; nothing you load or type leaves your browser. The + confidence-set plot is drawn for one- or two-component interventions (three or more still get the cost curves and the + full console summary). See the package documentation for the complete API. +

+
+ + + + diff --git a/tests/js/test-webr-demo.js b/tests/js/test-webr-demo.js index 28ebc03..b35382d 100644 --- a/tests/js/test-webr-demo.js +++ b/tests/js/test-webr-demo.js @@ -20,11 +20,6 @@ var workflow = fs.readFileSync( path.join(root, ".github/workflows/webr-repo.yaml"), "utf8" ); -var page = fs.readFileSync( - path.join(root, "pkgdown/assets/live-demo.html"), - "utf8" -); - var passed = 0; function check(name, cond) { if (!cond) { @@ -40,36 +35,39 @@ var buildMatch = workflow.match(/ghcr\.io\/r-wasm\/webr:v(\d+\.\d+\.\d+)/); check("workflow pins a webr-image version", !!buildMatch); var buildVersion = buildMatch && buildMatch[1]; -// webR version the page loads: the WEBR_VERSION constant and the CDN import URL. -var constMatch = page.match(/WEBR_VERSION\s*=\s*"(\d+\.\d+\.\d+)"/); -check("page declares a WEBR_VERSION", !!constMatch); -var pageVersion = constMatch && constMatch[1]; +// The repo folder the workflow deploys to (target-folder). +var targetMatch = workflow.match(/target-folder:\s*([^\s#]+)/); +check("workflow declares a deploy target-folder", !!targetMatch); +var targetFolder = targetMatch && targetMatch[1].trim(); -// The CDN import URL is built from WEBR_VERSION, so pinning is single-sourced; -// assert the import references that constant rather than a hard-coded version. -check( - "page imports webr.mjs pinned to WEBR_VERSION", - page.indexOf("webr.r-wasm.org/v${WEBR_VERSION}/webr.mjs") !== -1 -); +// Both webR pages must agree with the build on the ABI-critical version and +// install LAGOtrials from the deployed repo. +["live-demo.html", "playground.html"].forEach(function (name) { + var page = fs.readFileSync(path.join(root, "pkgdown/assets", name), "utf8"); -// The ABI lock: build image version == page runtime version. -check( - "build webR version (" + buildVersion + ") == page webR version (" + pageVersion + ")", - buildVersion === pageVersion -); + var constMatch = page.match(/WEBR_VERSION\s*=\s*"(\d+\.\d+\.\d+)"/); + check(name + ": declares a WEBR_VERSION", !!constMatch); + var pageVersion = constMatch && constMatch[1]; -// The page installs LAGOtrials. -check("page installs LAGOtrials", /webr::install\("LAGOtrials"/.test(page)); + // The CDN import URL is built from WEBR_VERSION, so pinning is single-sourced. + check( + name + ": imports webr.mjs pinned to WEBR_VERSION", + page.indexOf("webr.r-wasm.org/v${WEBR_VERSION}/webr.mjs") !== -1 + ); -// The repo folder the workflow deploys to (target-folder) is the folder the -// page installs from. -var targetMatch = workflow.match(/target-folder:\s*([^\s#]+)/); -check("workflow declares a deploy target-folder", !!targetMatch); -var targetFolder = targetMatch && targetMatch[1].trim(); -check( - "page install URL points at the deployed /" + targetFolder + " repo", - page.indexOf("/" + targetFolder) !== -1 && - /correspondmerchant\.github\.io\/LAGO-R-Package\//.test(page) -); + // The ABI lock: build image version == page runtime version. + check( + name + ": webR version (" + pageVersion + ") == build (" + buildVersion + ")", + buildVersion === pageVersion + ); + + check(name + ": installs LAGOtrials", /webr::install\("LAGOtrials"/.test(page)); + + check( + name + ": install URL points at the deployed /" + targetFolder + " repo", + page.indexOf("/" + targetFolder) !== -1 && + /correspondmerchant\.github\.io\/LAGO-R-Package\//.test(page) + ); +}); console.log("\nAll " + passed + " webR demo wiring assertions passed."); From 582d5a7782c84d4b9c4cf7b9f4f7178f87a23b41 Mon Sep 17 00:00:00 2001 From: Ante Bing Date: Tue, 1 Sep 2026 17:26:57 +0000 Subject: [PATCH 02/12] Make the playground controls interactive (sliders + toggle) Replace the model-panel number inputs with tactile controls: each intervention component gets draggable lower/upper bound sliders spanning its observed data range (with live readouts and lower<=upper clamping) plus a unit-cost stepper (- / + buttons and a value), the outcome goal is a slider that re-ranges to 0-1 for a binary outcome or the outcome column's range for a continuous one, and the maximize/minimize direction is a segmented toggle button. Run reads the values from these controls; the optimization pipeline is unchanged. --- pkgdown/assets/playground.html | 106 ++++++++++++++++++++++++++++----- 1 file changed, 91 insertions(+), 15 deletions(-) diff --git a/pkgdown/assets/playground.html b/pkgdown/assets/playground.html index 9e0e3ae..eb630d1 100644 --- a/pkgdown/assets/playground.html +++ b/pkgdown/assets/playground.html @@ -49,8 +49,18 @@ .chip input { margin:0; } .chip.on { border-color:var(--accent); background:#eef4fc; color:var(--accent-dark); } #comp-config { margin-top:12px; display:flex; flex-direction:column; gap:8px; } - .comp-row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; font-size:.85rem; } + .comp-row { display:flex; gap:20px; align-items:center; flex-wrap:wrap; font-size:.85rem; padding:8px 0; border-top:1px solid #f0f2f5; } .comp-row .name { font-weight:600; min-width:130px; } + .comp-row .ctl { display:flex; flex-direction:column; gap:2px; } + .comp-row .ctl > span:first-child { font-weight:500; font-size:.75rem; color:var(--muted); } + .val { color:var(--accent-dark); font-weight:700; } + input[type=range] { width:180px; accent-color:var(--accent); cursor:pointer; vertical-align:middle; } + .seg { display:inline-flex; border:1px solid var(--border); border-radius:8px; overflow:hidden; } + .seg button { background:#fff; color:#333; border:0; border-radius:0; padding:8px 14px; font-weight:600; } + .seg button.on { background:var(--accent); color:#fff; } + .stepper { display:inline-flex; align-items:center; gap:8px; } + .stepper button { padding:2px 10px; border-radius:6px; font-size:1rem; line-height:1; } + .stepper input { width:80px; } button { font:inherit; font-weight:600; color:#fff; background:var(--accent); border:0; padding:10px 20px; border-radius:8px; cursor:pointer; } button:hover { background:var(--accent-dark); } button:disabled { background:#9db8d6; cursor:not-allowed; } @@ -124,16 +134,16 @@

2. Model

-
- - +
+ +
- - + +
+ + +
@@ -275,15 +285,39 @@

Result

cb.addEventListener("change", () => { chip.classList.toggle("on", cb.checked); renderCompConfig(); }); comps.appendChild(chip); }); + updateGoalRange(); renderCompConfig(); } + // The goal slider spans 0-1 for a binary outcome, or the selected outcome + // column's observed range for a continuous one. + function updateGoalRange() { + const g = $("goal"); + const oc = columns.find((c) => c.name === $("outcome").value); + if ($("otype").value === "binary" || !oc || !Number.isFinite(oc.min) || !Number.isFinite(oc.max) || oc.max <= oc.min) { + g.min = 0; g.max = 1; g.step = 0.01; + if (!(Number(g.value) >= 0 && Number(g.value) <= 1)) g.value = 0.85; + } else { + const span = oc.max - oc.min; + g.min = oc.min; g.max = oc.max; g.step = span / 100; + if (!(Number(g.value) >= oc.min && Number(g.value) <= oc.max)) g.value = oc.min + span / 2; + } + $("goal-val").textContent = fmt(g.value); + } + function selectedComponents() { return Array.from(document.querySelectorAll("#components input:checked")).map((c) => c.value); } - // For each selected component, a bounds + per-unit cost row (bounds default - // to the observed range, rounded outward a little). + // Show a value at a sensible precision for a readout. + const fmt = (v) => { + const n = Number(v); + return Number.isInteger(n) ? String(n) : String(Math.round(n * 1000) / 1000); + }; + + // For each selected component, draggable lower/upper bound sliders (spanning + // the observed data range) plus a unit-cost stepper, each with a live + // readout. The lower and upper handles are kept from crossing. function renderCompConfig() { const box = $("comp-config"); const chosen = selectedComponents(); @@ -292,15 +326,43 @@

Result

const col = columns.find((c) => c.name === name) || {}; const lo = Number.isFinite(col.min) ? Math.floor(col.min) : 0; const hi = Number.isFinite(col.max) ? Math.ceil(col.max) : 1; + const step = hi > lo ? (hi - lo) / 100 : 1; const row = document.createElement("div"); row.className = "comp-row"; row.dataset.comp = name; row.innerHTML = `${name}` + - `lower ` + - `upper ` + - `unit cost `; + `
lower: ` + + `
` + + `
upper: ` + + `
` + + `
unit cost: ` + + `` + + `` + + `
`; box.appendChild(row); + + const lb = row.querySelector(".lb"), ub = row.querySelector(".ub"); + const cost = row.querySelector(".cost"); + const sync = () => { + // keep lower <= upper + if (Number(lb.value) > Number(ub.value)) { + if (document.activeElement === lb) ub.value = lb.value; else lb.value = ub.value; + } + row.querySelector(".lbval").textContent = fmt(lb.value); + row.querySelector(".ubval").textContent = fmt(ub.value); + row.querySelector(".costval").textContent = fmt(cost.value); + }; + lb.addEventListener("input", sync); + ub.addEventListener("input", sync); + cost.addEventListener("input", sync); + row.querySelector(".cost-dec").addEventListener("click", () => { + cost.value = Math.max(0, Number(cost.value) - 1); sync(); + }); + row.querySelector(".cost-inc").addEventListener("click", () => { + cost.value = Number(cost.value) + 1; sync(); + }); + sync(); }); } @@ -330,7 +392,7 @@

Result

` intervention_upper_bounds = ${rnum(rows.map((r) => r.ub))},\n` + ` cost_list_of_vectors = list(${rows.map((r) => `c(0, ${Number(r.cost)})`).join(", ")}),\n` + ` outcome_goal = ${Number($("goal").value)},\n` + - ` outcome_goal_intention = ${rstr($("intent").value)},\n` + + ` outcome_goal_intention = ${rstr($("intent").dataset.value)},\n` + ` confidence_set_grid_step_size = ${rnum(rows.map((r) => { // Roughly 20 grid steps across each component's range, so a wide // continuous range (e.g. mtcars qsec, 0-350) does not blow up the @@ -404,6 +466,20 @@

Result

} $("run").addEventListener("click", run); + // Goal slider readout, and re-range the goal when the outcome or its type + // changes (binary -> 0-1; continuous -> the column's range). + $("goal").addEventListener("input", () => { $("goal-val").textContent = fmt($("goal").value); }); + $("outcome").addEventListener("change", updateGoalRange); + $("otype").addEventListener("change", updateGoalRange); + + // Direction segmented toggle: track the choice on the container's dataset. + $("intent").querySelectorAll("button").forEach((btn) => { + btn.addEventListener("click", () => { + $("intent").dataset.value = btn.dataset.value; + $("intent").querySelectorAll("button").forEach((b) => b.classList.toggle("on", b === btn)); + }); + }); + boot(); From c5b11131db85e46e71ce4dfa82482ddcd0fb87c5 Mon Sep 17 00:00:00 2001 From: Ante Bing Date: Tue, 1 Sep 2026 17:41:19 +0000 Subject: [PATCH 03/12] Clamp a manually-typed negative unit cost to zero The unit-cost input's min=0 only constrains the spinner, so a typed negative value could flow a negative cost into the optimization. Clamp it in the row's sync() handler so both the readout and the value passed to lago_optimization() stay non-negative. --- pkgdown/assets/playground.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkgdown/assets/playground.html b/pkgdown/assets/playground.html index eb630d1..9a79f9b 100644 --- a/pkgdown/assets/playground.html +++ b/pkgdown/assets/playground.html @@ -349,6 +349,8 @@

Result

if (Number(lb.value) > Number(ub.value)) { if (document.activeElement === lb) ub.value = lb.value; else lb.value = ub.value; } + // a cost cannot be negative (a typed value bypasses the input's min) + if (Number(cost.value) < 0) cost.value = 0; row.querySelector(".lbval").textContent = fmt(lb.value); row.querySelector(".ubval").textContent = fmt(ub.value); row.querySelector(".costval").textContent = fmt(cost.value); From 7e8accd0d129eb1a8035adde1e31ba816e251253 Mon Sep 17 00:00:00 2001 From: Ante Bing Date: Tue, 1 Sep 2026 17:49:39 +0000 Subject: [PATCH 04/12] Improve CSV/column selection UX in the playground Make the outcome and component pickers behave sensibly for arbitrary uploaded data: - the outcome dropdown lists only numeric columns (LAGO fits a GLM on the outcome, so a character/id column can't be one), with a hint when there are too few numeric columns; - the component chips exclude whichever column is chosen as the outcome, and are rebuilt (preserving ticks) whenever the outcome changes, so a column can't be both the outcome and its own predictor; - the outcome type is auto-detected from the selected column (values in {0,1} -> binary, otherwise continuous) and shown as a segmented toggle the user can override, and the goal slider re-ranges to match (0-1 for binary, the column's range for continuous). --- pkgdown/assets/playground.html | 86 ++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 25 deletions(-) diff --git a/pkgdown/assets/playground.html b/pkgdown/assets/playground.html index 9a79f9b..f210fe4 100644 --- a/pkgdown/assets/playground.html +++ b/pkgdown/assets/playground.html @@ -128,11 +128,11 @@

2. Model

- - + +
+ + +
@@ -247,44 +247,77 @@

Result

await refreshSchema(`uploaded ${filename}`); } - // Read column names, which are numeric, and each numeric column's range, so - // the UI can offer components and pre-fill sensible bounds. + // Read the schema: which columns are numeric, each numeric column's range, + // and whether it looks binary (only 0/1), so the UI can offer sensible + // outcome/component choices, pre-fill bounds, and auto-pick the outcome type. async function refreshSchema(sourceLabel) { const json = await webR.evalRString(`jsonlite::toJSON(list( nrow = nrow(PG_DATA), ncol = ncol(PG_DATA), cols = names(PG_DATA), numeric = vapply(PG_DATA, is.numeric, logical(1), USE.NAMES = FALSE), mins = vapply(PG_DATA, function(v) if (is.numeric(v)) min(v, na.rm = TRUE) else NA_real_, numeric(1), USE.NAMES = FALSE), - maxs = vapply(PG_DATA, function(v) if (is.numeric(v)) max(v, na.rm = TRUE) else NA_real_, numeric(1), USE.NAMES = FALSE) + maxs = vapply(PG_DATA, function(v) if (is.numeric(v)) max(v, na.rm = TRUE) else NA_real_, numeric(1), USE.NAMES = FALSE), + binary = vapply(PG_DATA, function(v) is.numeric(v) && all(stats::na.omit(v) %in% c(0, 1)), logical(1), USE.NAMES = FALSE) ), auto_unbox = TRUE, na = "null", digits = NA)`); const meta = JSON.parse(json); columns = meta.cols.map((name, i) => ({ - name, numeric: meta.numeric[i], min: meta.mins[i], max: meta.maxs[i], + name, numeric: meta.numeric[i], min: meta.mins[i], max: meta.maxs[i], binary: meta.binary[i], })); $("data-info").textContent = `${sourceLabel}: ${meta.nrow} rows, ${meta.ncol} columns.`; buildModelControls(); } function buildModelControls() { - // Outcome: any column. Components: numeric columns. + // Outcome + components must be numeric (LAGO fits a GLM on them), so only + // numeric columns are offered. + const numeric = columns.filter((c) => c.numeric); const outcome = $("outcome"); outcome.innerHTML = ""; - columns.forEach((c) => { + numeric.forEach((c) => { const o = document.createElement("option"); o.value = c.name; o.textContent = c.name; outcome.appendChild(o); }); + if (numeric.length < 2) { + $("data-info").textContent += + " Need at least two numeric columns (one outcome + one component); this data has " + numeric.length + "."; + } + onOutcomeChanged(); + } + + // Set a segmented toggle (#otype / #intent) programmatically. + function setSeg(id, value) { + const seg = $(id); + seg.dataset.value = value; + seg.querySelectorAll("button").forEach((b) => b.classList.toggle("on", b.dataset.value === value)); + } + + // (Re)build the component chips: numeric columns except the chosen outcome, + // preserving which were already ticked. + function renderComponentChips() { const comps = $("components"); + const checked = new Set(selectedComponents()); + const outcomeName = $("outcome").value; comps.innerHTML = ""; - columns.filter((c) => c.numeric).forEach((c) => { - const id = "comp_" + c.name; + columns.filter((c) => c.numeric && c.name !== outcomeName).forEach((c) => { const chip = document.createElement("label"); chip.className = "chip"; chip.innerHTML = ` ${c.name}`; const cb = chip.querySelector("input"); + if (checked.has(c.name)) { cb.checked = true; chip.classList.add("on"); } cb.addEventListener("change", () => { chip.classList.toggle("on", cb.checked); renderCompConfig(); }); comps.appendChild(chip); }); + } + + // On load and whenever the outcome column changes: auto-pick the outcome + // type from the column (0/1 -> binary, else continuous), drop the outcome + // from the component choices, and re-range the goal. + function onOutcomeChanged() { + const oc = columns.find((c) => c.name === $("outcome").value); + setSeg("otype", oc && oc.binary ? "binary" : "continuous"); + $("otype-auto").textContent = "(auto-detected)"; + renderComponentChips(); updateGoalRange(); renderCompConfig(); } @@ -294,7 +327,7 @@

Result

function updateGoalRange() { const g = $("goal"); const oc = columns.find((c) => c.name === $("outcome").value); - if ($("otype").value === "binary" || !oc || !Number.isFinite(oc.min) || !Number.isFinite(oc.max) || oc.max <= oc.min) { + if ($("otype").dataset.value === "binary" || !oc || !Number.isFinite(oc.min) || !Number.isFinite(oc.max) || oc.max <= oc.min) { g.min = 0; g.max = 1; g.step = 0.01; if (!(Number(g.value) >= 0 && Number(g.value) <= 1)) g.value = 0.85; } else { @@ -388,7 +421,7 @@

Result

`res <- lago_optimization(\n` + ` data = PG_DATA,\n` + ` outcome_name = ${rstr($("outcome").value)},\n` + - ` outcome_type = ${rstr($("otype").value)},\n` + + ` outcome_type = ${rstr($("otype").dataset.value)},\n` + ` intervention_components = ${rchar(rows.map((r) => r.name))},\n` + ` intervention_lower_bounds = ${rnum(rows.map((r) => r.lb))},\n` + ` intervention_upper_bounds = ${rnum(rows.map((r) => r.ub))},\n` + @@ -468,17 +501,20 @@

Result

} $("run").addEventListener("click", run); - // Goal slider readout, and re-range the goal when the outcome or its type - // changes (binary -> 0-1; continuous -> the column's range). + // Goal slider readout; changing the outcome re-detects type, components and + // the goal range. $("goal").addEventListener("input", () => { $("goal-val").textContent = fmt($("goal").value); }); - $("outcome").addEventListener("change", updateGoalRange); - $("otype").addEventListener("change", updateGoalRange); - - // Direction segmented toggle: track the choice on the container's dataset. - $("intent").querySelectorAll("button").forEach((btn) => { - btn.addEventListener("click", () => { - $("intent").dataset.value = btn.dataset.value; - $("intent").querySelectorAll("button").forEach((b) => b.classList.toggle("on", b === btn)); + $("outcome").addEventListener("change", onOutcomeChanged); + + // Segmented toggles set their choice on the container's dataset. The outcome + // type additionally re-ranges the goal and marks that the user overrode the + // auto-detection. + ["intent", "otype"].forEach((id) => { + $(id).querySelectorAll("button").forEach((btn) => { + btn.addEventListener("click", () => { + setSeg(id, btn.dataset.value); + if (id === "otype") { $("otype-auto").textContent = "(manual)"; updateGoalRange(); } + }); }); }); From cac37fff5235326e4f5672578dcdc6962d61b3e0 Mon Sep 17 00:00:00 2001 From: Ante Bing Date: Tue, 1 Sep 2026 17:53:56 +0000 Subject: [PATCH 05/12] Preserve tuned component bounds and cost when the outcome changes renderCompConfig() rebuilt every component row from column defaults, so switching the outcome (which triggers a rebuild via onOutcomeChanged) discarded any bound sliders or unit costs the user had already tuned. Snapshot the existing rows' values by data-comp before wiping the container, and restore them on the rebuilt rows (clamped to each row's current slider range), so an outcome-column change no longer resets the user's edits. --- pkgdown/assets/playground.html | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pkgdown/assets/playground.html b/pkgdown/assets/playground.html index f210fe4..9088941 100644 --- a/pkgdown/assets/playground.html +++ b/pkgdown/assets/playground.html @@ -354,6 +354,16 @@

Result

function renderCompConfig() { const box = $("comp-config"); const chosen = selectedComponents(); + // Snapshot any already-tuned bounds/cost so rebuilding (e.g. after the + // outcome column changes) does not throw the user's edits away. + const prev = {}; + box.querySelectorAll(".comp-row").forEach((r) => { + prev[r.dataset.comp] = { + lb: r.querySelector(".lb").value, + ub: r.querySelector(".ub").value, + cost: r.querySelector(".cost").value, + }; + }); box.innerHTML = ""; chosen.forEach((name) => { const col = columns.find((c) => c.name === name) || {}; @@ -377,6 +387,15 @@

Result

const lb = row.querySelector(".lb"), ub = row.querySelector(".ub"); const cost = row.querySelector(".cost"); + // Restore any prior tuning for this component, clamped to the current + // range (so a snapshot from a wider range still falls inside). + const p = prev[name]; + if (p) { + const clamp = (v) => Math.min(Math.max(Number(v), Number(lb.min)), Number(lb.max)); + lb.value = clamp(p.lb); + ub.value = clamp(p.ub); + cost.value = p.cost; + } const sync = () => { // keep lower <= upper if (Number(lb.value) > Number(ub.value)) { From 530babcedd588f60ff250e274459bf9b62b3127f Mon Sep 17 00:00:00 2001 From: Ante Bing Date: Tue, 1 Sep 2026 18:04:30 +0000 Subject: [PATCH 06/12] Polish playground UX: reproducible R code, loading feedback, prefill, a11y - Show the R code for the current configuration in a panel with a Copy button, live-updated from the controls (buildCall/updateRCode), so the playground doubles as a code generator to paste into RStudio. - Set loading expectations: a one-time-download note under the status line, and a "Computing the recommendation..." placeholder during a run, so neither the first load nor a run looks frozen. - Prefill a runnable example on first load: prefer a binary column as the outcome and tick up to two other numeric columns, so a newcomer can press Run immediately. - Accessibility: aria-labels on the bound/goal sliders and cost stepper, and the CSV drop zone is keyboard-operable (role=button, tabindex, Enter/Space). --- pkgdown/assets/playground.html | 90 +++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 13 deletions(-) diff --git a/pkgdown/assets/playground.html b/pkgdown/assets/playground.html index 9088941..0787345 100644 --- a/pkgdown/assets/playground.html +++ b/pkgdown/assets/playground.html @@ -100,6 +100,7 @@

LAGOtrials — playground

Starting webR…
+

First load downloads R and the package (tens of MB) once and can take up to a minute; after that, runs are quick.

1. Data

@@ -113,7 +114,7 @@

1. Data

-
Drop a .csv here, or click to choose
+
Drop a .csv here, or click to choose
@@ -136,7 +137,7 @@

2. Model

- +
@@ -156,6 +157,15 @@

2. Model

+
+
+

R code for this configuration

+ +
+

Paste this into R (after library(LAGOtrials)) to reproduce the run.

+

+    
+

Result


     
@@ -185,7 +195,7 @@

Result

const rchar = (arr) => "c(" + arr.map(rstr).join(", ") + ")"; const rnum = (arr) => "c(" + arr.map((x) => Number(x)).join(", ") + ")"; - let webR, shelter, columns = []; + let webR, shelter, columns = [], firstBuild = true; async function boot() { try { @@ -282,7 +292,22 @@

Result

$("data-info").textContent += " Need at least two numeric columns (one outcome + one component); this data has " + numeric.length + "."; } + // On the very first load, prefill a runnable example: prefer a binary + // column as the outcome, then tick up to two other numeric columns as + // components, so a newcomer can just press Run. + if (firstBuild) { + const bin = numeric.find((c) => c.binary); + if (bin) outcome.value = bin.name; + } onOutcomeChanged(); + if (firstBuild) { + firstBuild = false; + Array.from(document.querySelectorAll("#components input")).slice(0, 2).forEach((cb) => { + cb.checked = true; + cb.dispatchEvent(new Event("change", { bubbles: true })); + }); + } + updateRCode(); } // Set a segmented toggle (#otype / #intent) programmatically. @@ -376,12 +401,12 @@

Result

row.innerHTML = `${name}` + `
lower: ` + - `
` + + `` + `
upper: ` + - `
` + + `` + `
unit cost: ` + - `` + - `` + + `` + + `` + `
`; box.appendChild(row); @@ -420,13 +445,11 @@

Result

}); } - async function run() { - if (!shelter) return; + // Build the lago_optimization() call from the current controls. Returns + // {ok:false,msg} when nothing is selected yet, else {ok:true, call}. + function buildCall() { const comps = selectedComponents(); - if (comps.length < 1) { $("run-hint").textContent = "pick at least one component"; return; } - $("run").disabled = true; $("run-hint").textContent = "running…"; - clearResults(); - + if (comps.length < 1) return { ok: false, msg: "pick at least one component" }; const rows = comps.map((name) => { const r = document.querySelector(`.comp-row[data-comp="${CSS.escape(name)}"]`); return { @@ -455,6 +478,25 @@

Result

return span > 0 ? Math.max(span / 20, 1e-6) : 1; }))},\n` + ` quiet = TRUE\n)`; + return { ok: true, call }; + } + + // Live-mirror the R code for the current configuration into the code panel. + function updateRCode() { + const built = buildCall(); + $("rcode").textContent = built.ok + ? built.call + : "# choose an outcome and at least one intervention component"; + } + + async function run() { + if (!shelter) return; + const built = buildCall(); + if (!built.ok) { $("run-hint").textContent = built.msg; return; } + $("run").disabled = true; $("run-hint").textContent = "running…"; + clearResults(); + $("output").textContent = "Computing the recommendation…"; + const call = built.call; try { const cap = await shelter.captureR(call + "\nprint(res)", { @@ -509,6 +551,10 @@

Result

}); const drop = $("drop"), file = $("file"); drop.addEventListener("click", () => file.click()); + // keyboard access: the drop zone is a div, so open the picker on Enter/Space + drop.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); file.click(); } + }); drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("hot"); }); drop.addEventListener("dragleave", () => drop.classList.remove("hot")); drop.addEventListener("drop", (e) => { e.preventDefault(); drop.classList.remove("hot"); if (e.dataTransfer.files[0]) readFile(e.dataTransfer.files[0]); }); @@ -537,6 +583,24 @@

Result

}); }); + // Keep the R-code panel in sync with any control change in the model panel + // (component ticks, sliders, steppers, goal, and the toggle buttons). + ["input", "change", "click"].forEach((evt) => + $("model-panel").addEventListener(evt, updateRCode) + ); + + // Copy the current R code to the clipboard. + $("copy-code").addEventListener("click", async () => { + try { + await navigator.clipboard.writeText($("rcode").textContent); + const btn = $("copy-code"), label = btn.textContent; + btn.textContent = "Copied!"; + setTimeout(() => { btn.textContent = label; }, 1200); + } catch (e) { + $("run-hint").textContent = "copy failed; select the code and copy manually"; + } + }); + boot(); From 05dcc19faf14af4670e33bc7684c6f94168a8a76 Mon Sep 17 00:00:00 2001 From: Ante Bing Date: Tue, 1 Sep 2026 18:11:26 +0000 Subject: [PATCH 07/12] Fix playground R-code panel edge cases from review - buildCall() now reads only rows that are actually rendered: ticking a component fires the delegated click/input listener (which rebuilds the R code) a tick before the checkbox 'change' builds the row, so the previous code threw a TypeError on the not-yet-rendered row. It self-healed on the following 'change', but now no exception is thrown; the transient component is simply omitted until its row exists. - The R-code panel no longer shows the Result panel's placeholder text during the webR load: the :empty::before placeholder is scoped to #output, and #rcode carries its own initial "choose an outcome..." hint. - The increase-cost button gets a per-component aria-label, matching its siblings. --- pkgdown/assets/playground.html | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/pkgdown/assets/playground.html b/pkgdown/assets/playground.html index 0787345..a38fae4 100644 --- a/pkgdown/assets/playground.html +++ b/pkgdown/assets/playground.html @@ -71,7 +71,7 @@ #drop.hot { border-color:var(--accent); background:#eef4fc; } h2.section { font-size:1rem; margin:26px 0 8px; } pre.out { background:#0f1620; color:#e7edf3; padding:14px; border-radius:8px; overflow:auto; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:12.5px; white-space:pre-wrap; word-break:break-word; min-height:40px; margin:0; } - pre.out:empty::before { content:"Run to see the recommendation and console output here."; color:#7d8794; } + #output:empty::before { content:"Run to see the recommendation and console output here."; color:#7d8794; } #cs-plot, #cost-curves { margin-top:12px; } #cs-plot svg, #cost-curves svg { max-width:100%; } .note { color:var(--muted); font-size:.85rem; margin-top:26px; border-top:1px solid var(--border); padding-top:14px; } @@ -163,7 +163,7 @@

R code for this configuration

Paste this into R (after library(LAGOtrials)) to reproduce the run.

-

+      
# choose an outcome and at least one intervention component

Result

@@ -407,7 +407,7 @@

Result

`
unit cost: ` + `` + `` + - `
`; + ``; box.appendChild(row); const lb = row.querySelector(".lb"), ub = row.querySelector(".ub"); @@ -448,17 +448,20 @@

Result

// Build the lago_optimization() call from the current controls. Returns // {ok:false,msg} when nothing is selected yet, else {ok:true, call}. function buildCall() { - const comps = selectedComponents(); - if (comps.length < 1) return { ok: false, msg: "pick at least one component" }; - const rows = comps.map((name) => { - const r = document.querySelector(`.comp-row[data-comp="${CSS.escape(name)}"]`); - return { - name, + // Read from the rendered rows. A component can be ticked a tick before its + // row exists (the delegated click/input listener fires updateRCode before + // the checkbox 'change' builds the row), so skip any not-yet-rendered row; + // the following 'change' event rebuilds the code with the row present. + const rows = selectedComponents() + .map((name) => document.querySelector(`.comp-row[data-comp="${CSS.escape(name)}"]`)) + .filter(Boolean) + .map((r) => ({ + name: r.dataset.comp, lb: r.querySelector(".lb").value, ub: r.querySelector(".ub").value, cost: r.querySelector(".cost").value, - }; - }); + })); + if (!rows.length) return { ok: false, msg: "pick at least one component" }; const call = `res <- lago_optimization(\n` + ` data = PG_DATA,\n` + From 74915773a7a5b0bf8a3bb27344e8cca7a83f9ac4 Mon Sep 17 00:00:00 2001 From: Ante Bing Date: Tue, 1 Sep 2026 18:14:57 +0000 Subject: [PATCH 08/12] Document and cross-link the playground; note its scope - NEWS: extend the in-browser demo entry to cover the interactive playground. - README: the "try it in your browser" callout now also points to the playground. - live-demo.html: add a Playground link to its nav (the playground already links back to the quick demo). - playground.html: note that it exposes the common options and that advanced ones (power goal, center characteristics/fixed effects, icc, custom GLM family/link) are available via lago_optimization() in R; add a CSV format hint (comma-separated, header row, numeric columns) on the upload field. --- NEWS.md | 2 +- README.md | 2 +- pkgdown/assets/live-demo.html | 1 + pkgdown/assets/playground.html | 6 +++++- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/NEWS.md b/NEWS.md index eb3808f..108f171 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,6 @@ # LAGOtrials 1.1.0 -* Added a live in-browser demo to the documentation site (`live-demo.html`) that runs the real package client-side with webR (R compiled to WebAssembly), so anyone can try `lago_optimization()` with no installation. A GitHub Actions workflow builds the package to WebAssembly with the rwasm toolchain and publishes it as a small CRAN-like repository alongside the site. +* Added a live in-browser demo to the documentation site (`live-demo.html`) that runs the real package client-side with webR (R compiled to WebAssembly), so anyone can try `lago_optimization()` with no installation, plus an interactive playground (`playground.html`) where you pick a bundled dataset or upload a CSV, configure the model with sliders and toggles, and see the recommendation drawn with the package's own D3 charts alongside a copy-pasteable R snippet. A GitHub Actions workflow builds the package to WebAssembly with the rwasm toolchain and publishes it as a small CRAN-like repository alongside the site. * `lago_report()` now renders an interactive HTML dashboard: the confidence set is a hover-enabled D3 plot (a scatter for two components, a strip for one) with the recommended intervention highlighted, and each intervention component gets interactive total-cost and marginal-cost curves. The report stays a single self-contained offline file (D3 is inlined, no CDN or server) and its API is unchanged; rendering now also uses `jsonlite` (a new Suggests). * Added an MCP (Model Context Protocol) server to the Python package (`python -m lago.mcp_server`) that exposes `optimize` and `sensitivity` as tools any MCP-aware AI agent can call, plus a `sensitivity()` function in the Python wrapper. * Added `lago_sensitivity()`, which re-runs an optimization across a sweep of one input (an outcome or power goal, or a `"cost_multiplier"` that scales all costs) and reports how the recommended intervention, its cost, and the estimated outcome move, with `print()` and `plot()` methods. diff --git a/README.md b/README.md index 92cd0b1..de973b1 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ The LAGOtrials R package bridges the gap between theoretical advances in Learn-A 3) estimating the optimal intervention based on data from all stages, 4) calculating the 95% confidence sets for the recommended interventions and the optimal interventions. -> **Try it in your browser, no installation needed:** the [live demo](https://correspondmerchant.github.io/LAGO-R-Package/live-demo.html) runs the real `LAGOtrials` package client-side with [webR](https://docs.r-wasm.org/webr/latest/) (R compiled to WebAssembly). Edit the example and press Run. +> **Try it in your browser, no installation needed:** the [live demo](https://correspondmerchant.github.io/LAGO-R-Package/live-demo.html) runs the real `LAGOtrials` package client-side with [webR](https://docs.r-wasm.org/webr/latest/) (R compiled to WebAssembly) — edit the example and press Run. For a guided version, the [playground](https://correspondmerchant.github.io/LAGO-R-Package/playground.html) lets you pick a bundled dataset or upload your own CSV, configure the model with sliders and toggles, and see the recommendation as interactive charts plus a copy-pasteable R snippet. ## Table of Contents 1. [How to install the R package](#how-to-install-the-r-package) diff --git a/pkgdown/assets/live-demo.html b/pkgdown/assets/live-demo.html index a8b5157..4b14d6d 100644 --- a/pkgdown/assets/live-demo.html +++ b/pkgdown/assets/live-demo.html @@ -78,6 +78,7 @@

LAGOtrials — live demo

diff --git a/pkgdown/assets/playground.html b/pkgdown/assets/playground.html index a38fae4..6f15e96 100644 --- a/pkgdown/assets/playground.html +++ b/pkgdown/assets/playground.html @@ -116,6 +116,7 @@

1. Data

Drop a .csv here, or click to choose
+

Comma-separated with a header row; numeric columns become the outcome and component choices.

@@ -174,7 +175,10 @@

Result

webR runs a WebAssembly build of R (4.6.0) entirely client-side; nothing you load or type leaves your browser. The confidence-set plot is drawn for one- or two-component interventions (three or more still get the cost curves and the - full console summary). See the package documentation for the complete API. + full console summary). This playground exposes the common options; for the rest — a power goal, center + characteristics and fixed effects, clustering (icc), a custom GLM family/link, and more — call + lago_optimization() in R (copy the snippet above as a starting point). See the + package documentation for the complete API.

From 588e87ce381e93684fe8abc36b83f69f7ca9f622 Mon Sep 17 00:00:00 2001 From: Ante Bing Date: Tue, 1 Sep 2026 18:23:48 +0000 Subject: [PATCH 09/12] Playground: friendlier failures, clearer states, a11y From a completeness/UX gap audit of the in-browser feature: - Boot errors: split the webR start and the LAGOtrials install into separate try/catch blocks with distinct, actionable messages (browser/WASM/network vs package repo not published yet), so a first-visit-before-publish failure reads correctly instead of "Failed to start webR". - Run failures: frame a stop() from lago_optimization as a settings problem with a suggested change, keeping the raw R message as detail. - Run button is enabled only once webR is ready and at least one component is ticked, with the reason shown while disabled; empty component list now shows an inline hint instead of going silently blank. - Continuous outcome goal slider is padded half a span past the observed range so a goal beyond anything observed can be targeted; default stays in range. - Added a Reset button that restores the prefilled defaults and clears results. - Large CSV uploads warn that the tab may be slow rather than looking hung. - Accessibility: #status is a polite live region (both pages) and the segmented outcome-type/direction toggles expose aria-pressed and a group label. - README install callout also links the playground. - webr-repo workflow now also runs on release: published, matching pkgdown so a release rebuilds the wasm binary the docs install from. --- .github/workflows/webr-repo.yaml | 4 + README.md | 2 +- pkgdown/assets/live-demo.html | 2 +- pkgdown/assets/playground.html | 124 ++++++++++++++++++++++++------- 4 files changed, 102 insertions(+), 30 deletions(-) diff --git a/.github/workflows/webr-repo.yaml b/.github/workflows/webr-repo.yaml index cce5a97..0b4baec 100644 --- a/.github/workflows/webr-repo.yaml +++ b/.github/workflows/webr-repo.yaml @@ -24,6 +24,10 @@ on: branches: [main, master] pull_request: branches: [main, master] + # Match the pkgdown workflow's release trigger so publishing a release rebuilds + # the wasm binary too, keeping it in sync with the docs that install from it. + release: + types: [published] workflow_dispatch: name: webr-repo.yaml diff --git a/README.md b/README.md index de973b1..4e1e3ed 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ The LAGOtrials R package bridges the gap between theoretical advances in Learn-A ``` - Method 2: Clone this repo into RStudio, you can follow the directions provided [in this video](https://www.youtube.com/watch?v=NInwldFZgwA&t=275s). -Not ready to install? Try the package in your browser on the [live demo page](https://correspondmerchant.github.io/LAGO-R-Package/live-demo.html). +Not ready to install? Try the package in your browser on the [live demo page](https://correspondmerchant.github.io/LAGO-R-Package/live-demo.html), or design an optimization interactively with the [guided playground](https://correspondmerchant.github.io/LAGO-R-Package/playground.html). ## The main functions The LAGOtrials R package has four user-facing functions `lago_optimization()`, `lago_sensitivity()`, `visualize_cost()`, and `lago_report()`. diff --git a/pkgdown/assets/live-demo.html b/pkgdown/assets/live-demo.html index 4b14d6d..77df462 100644 --- a/pkgdown/assets/live-demo.html +++ b/pkgdown/assets/live-demo.html @@ -93,7 +93,7 @@

LAGOtrials — live demo

so give it a moment.

-
Starting webR…
+
Starting webR…
diff --git a/pkgdown/assets/playground.html b/pkgdown/assets/playground.html index 6f15e96..118a4a7 100644 --- a/pkgdown/assets/playground.html +++ b/pkgdown/assets/playground.html @@ -99,7 +99,7 @@

LAGOtrials — playground

webR-powered charts.

-
Starting webR…
+
Starting webR…

First load downloads R and the package (tens of MB) once and can take up to a minute; after that, runs are quick.

@@ -131,9 +131,9 @@

2. Model

-
- - +
+ +
@@ -142,9 +142,9 @@

2. Model

-
- - +
+ +
@@ -155,6 +155,7 @@

2. Model

+
@@ -193,21 +194,45 @@

Result

const $ = (id) => document.getElementById(id); const statusEl = $("status"), statusText = $("status-text"); const setStatus = (t, k) => { statusText.textContent = t; statusEl.className = "status" + (k ? " " + k : ""); }; + const errMsg = (e) => (e && e.message ? e.message : String(e)); const clearResults = () => { $("output").textContent = ""; $("cs-plot").innerHTML = ""; $("cost-curves").innerHTML = ""; }; // Escape a value for safe interpolation into an R character string. const rstr = (s) => '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; const rchar = (arr) => "c(" + arr.map(rstr).join(", ") + ")"; const rnum = (arr) => "c(" + arr.map((x) => Number(x)).join(", ") + ")"; - let webR, shelter, columns = [], firstBuild = true; + let webR, shelter, columns = [], firstBuild = true, bootReady = false; + + // Run is enabled only once webR is ready AND at least one component is + // ticked, with the reason shown when it stays disabled, so the incomplete + // state is visible instead of only complaining after a click. + function updateRunEnabled() { + if (!bootReady) return; + const ok = selectedComponents().length > 0; + $("run").disabled = !ok; + if ($("run-hint").textContent === "running…") return; + $("run-hint").textContent = ok ? "" : "tick at least one intervention component to run"; + } async function boot() { + // Starting webR (downloading R) and installing LAGOtrials are separate + // failure modes with different fixes, so report them separately: a webR + // start failure is usually the browser blocking WebAssembly or the + // network, while an install failure usually means the in-browser package + // repository has not been published yet (fresh deploy) or is unreachable. try { setStatus("Downloading R (happens once)…"); const { WebR } = await import(WEBR_URL); webR = new WebR(); await webR.init(); + } catch (err) { + setStatus("Couldn't start webR: " + errMsg(err), "error"); + $("run-hint").textContent = + "your browser may be blocking WebAssembly or the network request; try a current Chrome, Firefox or Safari."; + return; + } + try { setStatus("Installing LAGOtrials…"); const reposR = "c(" + REPOS.map((r) => JSON.stringify(r)).join(", ") + ")"; await webR.evalRVoid(`webr::install("LAGOtrials", repos = ${reposR})`); @@ -228,14 +253,18 @@

Result

shelter = await new webR.Shelter(); await loadBundled($("dataset").value); - - setStatus("Ready. Configure the model and press Run.", "ready"); - $("data-panel").style.opacity = $("model-panel").style.opacity = "1"; - $("data-panel").style.pointerEvents = $("model-panel").style.pointerEvents = "auto"; - $("run").disabled = false; } catch (err) { - setStatus("Failed to start webR: " + (err && err.message ? err.message : err), "error"); + setStatus("Couldn't install LAGOtrials: " + errMsg(err), "error"); + $("run-hint").textContent = + "the in-browser package repository may still be building; if this is a fresh deploy, try again in a few minutes."; + return; } + + setStatus("Ready. Configure the model and press Run.", "ready"); + $("data-panel").style.opacity = $("model-panel").style.opacity = "1"; + $("data-panel").style.pointerEvents = $("model-panel").style.pointerEvents = "auto"; + bootReady = true; + updateRunEnabled(); } function injectScript(src) { @@ -318,7 +347,11 @@

Result

function setSeg(id, value) { const seg = $(id); seg.dataset.value = value; - seg.querySelectorAll("button").forEach((b) => b.classList.toggle("on", b.dataset.value === value)); + seg.querySelectorAll("button").forEach((b) => { + const on = b.dataset.value === value; + b.classList.toggle("on", on); + b.setAttribute("aria-pressed", on ? "true" : "false"); + }); } // (Re)build the component chips: numeric columns except the chosen outcome, @@ -328,13 +361,19 @@

Result

const checked = new Set(selectedComponents()); const outcomeName = $("outcome").value; comps.innerHTML = ""; - columns.filter((c) => c.numeric && c.name !== outcomeName).forEach((c) => { + const eligible = columns.filter((c) => c.numeric && c.name !== outcomeName); + if (!eligible.length) { + comps.innerHTML = + '

No other numeric columns to use as components — pick a different outcome or add a numeric column.

'; + return; + } + eligible.forEach((c) => { const chip = document.createElement("label"); chip.className = "chip"; chip.innerHTML = ` ${c.name}`; const cb = chip.querySelector("input"); if (checked.has(c.name)) { cb.checked = true; chip.classList.add("on"); } - cb.addEventListener("change", () => { chip.classList.toggle("on", cb.checked); renderCompConfig(); }); + cb.addEventListener("change", () => { chip.classList.toggle("on", cb.checked); renderCompConfig(); updateRunEnabled(); }); comps.appendChild(chip); }); } @@ -352,21 +391,38 @@

Result

} // The goal slider spans 0-1 for a binary outcome, or the selected outcome - // column's observed range for a continuous one. - function updateGoalRange() { + // column's observed range (padded by half a span on each side) for a + // continuous one, so a user can also target a goal beyond anything observed + // in the data, which is the common point of intervening. The default value + // stays inside the observed range. + function updateGoalRange(forceDefault) { const g = $("goal"); const oc = columns.find((c) => c.name === $("outcome").value); if ($("otype").dataset.value === "binary" || !oc || !Number.isFinite(oc.min) || !Number.isFinite(oc.max) || oc.max <= oc.min) { g.min = 0; g.max = 1; g.step = 0.01; - if (!(Number(g.value) >= 0 && Number(g.value) <= 1)) g.value = 0.85; + if (forceDefault || !(Number(g.value) >= 0 && Number(g.value) <= 1)) g.value = 0.85; } else { const span = oc.max - oc.min; - g.min = oc.min; g.max = oc.max; g.step = span / 100; - if (!(Number(g.value) >= oc.min && Number(g.value) <= oc.max)) g.value = oc.min + span / 2; + g.min = oc.min - span / 2; g.max = oc.max + span / 2; g.step = span / 100; + if (forceDefault || !(Number(g.value) >= Number(g.min) && Number(g.value) <= Number(g.max))) g.value = oc.min + span / 2; } $("goal-val").textContent = fmt(g.value); } + // Restore the prefilled defaults for the current dataset: clear ticks and + // any tuned bounds/costs, re-pick a binary outcome, re-tick two components, + // reset the goal and direction, and clear results. + function resetModel() { + clearResults(); + document.querySelectorAll("#components input:checked").forEach((cb) => { cb.checked = false; }); + $("comp-config").innerHTML = ""; + setSeg("intent", "maximize"); + firstBuild = true; + buildModelControls(); + updateGoalRange(true); + updateRunEnabled(); + } + function selectedComponents() { return Array.from(document.querySelectorAll("#components input:checked")).map((c) => c.value); } @@ -544,9 +600,15 @@

Result

} } } catch (err) { - $("output").textContent = "Error: " + (err && err.message ? err.message : err); + // A stop() from lago_optimization (e.g. an unreachable goal, a + // non-convergent fit, or a degenerate column) propagates here; frame it + // as a settings problem and keep the raw R message as the detail. + $("output").textContent = + "The optimization couldn't run with these settings:\n\n" + errMsg(err) + + "\n\nTry a less aggressive goal, wider bounds, or a different outcome/component."; } finally { $("run").disabled = false; $("run-hint").textContent = ""; + updateRunEnabled(); } } @@ -567,11 +629,17 @@

Result

drop.addEventListener("drop", (e) => { e.preventDefault(); drop.classList.remove("hot"); if (e.dataTransfer.files[0]) readFile(e.dataTransfer.files[0]); }); file.addEventListener("change", () => { if (file.files[0]) readFile(file.files[0]); }); async function readFile(f) { - clearResults(); setStatus("Reading " + f.name + "…"); + clearResults(); + // Everything (parse, schema round-trip, and the fit) runs on the webR + // thread, so a very large file can make the tab unresponsive; warn rather + // than let a long parse look like a crash. + const LARGE = 5 * 1024 * 1024; + setStatus("Reading " + f.name + (f.size > LARGE ? " (large file, this may be slow)…" : "…")); try { await loadCSV(await f.text(), f.name); setStatus("Ready.", "ready"); } - catch (err) { setStatus("Could not read CSV: " + err, "error"); } + catch (err) { setStatus("Could not read CSV: " + errMsg(err), "error"); } } $("run").addEventListener("click", run); + $("reset").addEventListener("click", resetModel); // Goal slider readout; changing the outcome re-detects type, components and // the goal range. @@ -590,10 +658,10 @@

Result

}); }); - // Keep the R-code panel in sync with any control change in the model panel - // (component ticks, sliders, steppers, goal, and the toggle buttons). + // Keep the R-code panel and the Run button in sync with any control change + // in the model panel (component ticks, sliders, steppers, goal, toggles). ["input", "change", "click"].forEach((evt) => - $("model-panel").addEventListener(evt, updateRCode) + $("model-panel").addEventListener(evt, () => { updateRCode(); updateRunEnabled(); }) ); // Copy the current R code to the clipboard. From c2c6b4d9d5a62e9cbc79c2b0ef1ac1499f7310be Mon Sep 17 00:00:00 2001 From: Ante Bing Date: Tue, 1 Sep 2026 18:34:21 +0000 Subject: [PATCH 10/12] Playground: fix review findings (concurrent run, stale gate, reset) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the two-reviewer round: - Guard against a second concurrent optimization: a mid-run model-panel edit fired updateRunEnabled(), which re-enabled Run before the "running…" hint guard. Add a `running` flag so Run stays disabled while a run is in flight (single-threaded webR shares global res/PG_DATA and shelter). - Fix the Run gate going stale on dataset switch / CSV upload: the dataset select is outside #model-panel so the delegated listener never fired; buildModelControls (the single rebuild funnel) now calls updateRunEnabled. - Reset no longer silently flips the outcome/type: it keeps the selected outcome and re-derives its type instead of re-running the first-load binary-outcome preference (which switched mtcars to a 0/1 column). - Don't classify an all-NA numeric column as binary (all() of an empty vector is TRUE); require a non-NA observation. Defensive; not reachable via read.csv. - Use errMsg() in the dataset-change catch, matching the other catches. - Associate the "Intervention components" label with its chip group (role=group + aria-labelledby). - test-webr-demo.js header now states it guards both webR pages. --- pkgdown/assets/playground.html | 56 ++++++++++++++++++++++------------ tests/js/test-webr-demo.js | 11 ++++--- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/pkgdown/assets/playground.html b/pkgdown/assets/playground.html index 118a4a7..488a768 100644 --- a/pkgdown/assets/playground.html +++ b/pkgdown/assets/playground.html @@ -148,8 +148,8 @@

2. Model

- -
+ +
@@ -201,15 +201,18 @@

Result

const rchar = (arr) => "c(" + arr.map(rstr).join(", ") + ")"; const rnum = (arr) => "c(" + arr.map((x) => Number(x)).join(", ") + ")"; - let webR, shelter, columns = [], firstBuild = true, bootReady = false; + let webR, shelter, columns = [], firstBuild = true, bootReady = false, running = false; - // Run is enabled only once webR is ready AND at least one component is - // ticked, with the reason shown when it stays disabled, so the incomplete - // state is visible instead of only complaining after a click. + // Run is enabled only once webR is ready, no run is in flight, AND at least + // one component is ticked, with the reason shown when it stays disabled, so + // the incomplete state is visible instead of only complaining after a click. + // The `running` guard matters because any model-panel interaction fires this + // via the delegated listener; without it a mid-run edit would re-enable Run + // and let a second optimization race the first on single-threaded webR. function updateRunEnabled() { if (!bootReady) return; const ok = selectedComponents().length > 0; - $("run").disabled = !ok; + $("run").disabled = !ok || running; if ($("run-hint").textContent === "running…") return; $("run-hint").textContent = ok ? "" : "tick at least one intervention component to run"; } @@ -300,7 +303,7 @@

Result

numeric = vapply(PG_DATA, is.numeric, logical(1), USE.NAMES = FALSE), mins = vapply(PG_DATA, function(v) if (is.numeric(v)) min(v, na.rm = TRUE) else NA_real_, numeric(1), USE.NAMES = FALSE), maxs = vapply(PG_DATA, function(v) if (is.numeric(v)) max(v, na.rm = TRUE) else NA_real_, numeric(1), USE.NAMES = FALSE), - binary = vapply(PG_DATA, function(v) is.numeric(v) && all(stats::na.omit(v) %in% c(0, 1)), logical(1), USE.NAMES = FALSE) + binary = vapply(PG_DATA, function(v) is.numeric(v) && length(stats::na.omit(v)) > 0 && all(stats::na.omit(v) %in% c(0, 1)), logical(1), USE.NAMES = FALSE) ), auto_unbox = TRUE, na = "null", digits = NA)`); const meta = JSON.parse(json); columns = meta.cols.map((name, i) => ({ @@ -335,12 +338,22 @@

Result

onOutcomeChanged(); if (firstBuild) { firstBuild = false; - Array.from(document.querySelectorAll("#components input")).slice(0, 2).forEach((cb) => { - cb.checked = true; - cb.dispatchEvent(new Event("change", { bubbles: true })); - }); + prefillComponents(); } updateRCode(); + // buildModelControls is the single funnel for every schema rebuild + // (dataset switch, CSV upload, reset); the dataset select lives outside + // #model-panel, so its change never reaches the delegated listener. Refresh + // the Run gate here so it can never sit enabled with nothing ticked. + updateRunEnabled(); + } + + // Tick up to two eligible components, as a runnable starting point. + function prefillComponents() { + Array.from(document.querySelectorAll("#components input")).slice(0, 2).forEach((cb) => { + cb.checked = true; + cb.dispatchEvent(new Event("change", { bubbles: true })); + }); } // Set a segmented toggle (#otype / #intent) programmatically. @@ -409,16 +422,19 @@

Result

$("goal-val").textContent = fmt(g.value); } - // Restore the prefilled defaults for the current dataset: clear ticks and - // any tuned bounds/costs, re-pick a binary outcome, re-tick two components, - // reset the goal and direction, and clear results. + // Restore a clean runnable baseline for the current dataset: clear ticks and + // any tuned bounds/costs, re-derive the (currently selected) outcome's type, + // re-tick two components, and reset the goal and direction. Reset keeps the + // selected outcome rather than re-running the first-load binary preference, + // so it never silently flips a continuous outcome (e.g. mtcars mpg) to a + // binary column. function resetModel() { clearResults(); document.querySelectorAll("#components input:checked").forEach((cb) => { cb.checked = false; }); $("comp-config").innerHTML = ""; setSeg("intent", "maximize"); - firstBuild = true; - buildModelControls(); + onOutcomeChanged(); + prefillComponents(); updateGoalRange(true); updateRunEnabled(); } @@ -556,6 +572,7 @@

Result

if (!shelter) return; const built = buildCall(); if (!built.ok) { $("run-hint").textContent = built.msg; return; } + running = true; $("run").disabled = true; $("run-hint").textContent = "running…"; clearResults(); $("output").textContent = "Computing the recommendation…"; @@ -607,7 +624,8 @@

Result

"The optimization couldn't run with these settings:\n\n" + errMsg(err) + "\n\nTry a less aggressive goal, wider bounds, or a different outcome/component."; } finally { - $("run").disabled = false; $("run-hint").textContent = ""; + running = false; + $("run-hint").textContent = ""; updateRunEnabled(); } } @@ -616,7 +634,7 @@

Result

$("dataset").addEventListener("change", async (e) => { clearResults(); setStatus("Loading " + e.target.value + "…"); try { await loadBundled(e.target.value); setStatus("Ready.", "ready"); } - catch (err) { setStatus("Could not load dataset: " + err, "error"); } + catch (err) { setStatus("Could not load dataset: " + errMsg(err), "error"); } }); const drop = $("drop"), file = $("file"); drop.addEventListener("click", () => file.click()); diff --git a/tests/js/test-webr-demo.js b/tests/js/test-webr-demo.js index b35382d..6ec926c 100644 --- a/tests/js/test-webr-demo.js +++ b/tests/js/test-webr-demo.js @@ -1,13 +1,14 @@ -// Guards the live webR demo wiring (pkgdown/assets/live-demo.html) against the -// wasm-repo build workflow (.github/workflows/webr-repo.yaml). +// Guards both webR pages' wiring (pkgdown/assets/live-demo.html and +// pkgdown/assets/playground.html) against the wasm-repo build workflow +// (.github/workflows/webr-repo.yaml). // // Run with: node tests/js/test-webr-demo.js // // A wasm package binary built against one webR ABI will not load in a different -// webR runtime, so the webR version the page loads from the CDN MUST equal the +// webR runtime, so the webR version each page loads from the CDN MUST equal the // webR Docker image the binary is built with. This test also checks that the -// package repository URL the page installs from matches the folder the workflow -// deploys to, and that the page actually installs LAGOtrials. These are the +// package repository URL each page installs from matches the folder the workflow +// deploys to, and that each page actually installs LAGOtrials. These are the // wiring mistakes that would silently break the demo without any R/JS error at // build time. From eec43c43112079b9c47967873a35ab1cdfb96920 Mon Sep 17 00:00:00 2001 From: Ante Bing Date: Tue, 1 Sep 2026 18:40:49 +0000 Subject: [PATCH 11/12] Playground: lock data/reset controls during an in-flight run Re-review follow-up: Run was gated by the running flag but Reset, the dataset select and the file input stayed live, so a mid-run reset/switch/upload rebuilt the controls while the in-flight result rendered against the old config, leaving the panels disagreeing. Disable those three alongside Run for the duration of a run and re-enable them in the finally. No state corruption before (webR serializes evals); this removes the transient visual mismatch. --- pkgdown/assets/playground.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkgdown/assets/playground.html b/pkgdown/assets/playground.html index 488a768..1c21b5a 100644 --- a/pkgdown/assets/playground.html +++ b/pkgdown/assets/playground.html @@ -574,6 +574,10 @@

Result

if (!built.ok) { $("run-hint").textContent = built.msg; return; } running = true; $("run").disabled = true; $("run-hint").textContent = "running…"; + // Lock the data/reset controls too: a reset, dataset switch or upload + // mid-run would rebuild the controls while the in-flight result still + // renders against the old config, leaving the panels disagreeing. + $("reset").disabled = $("dataset").disabled = $("file").disabled = true; clearResults(); $("output").textContent = "Computing the recommendation…"; const call = built.call; @@ -625,6 +629,7 @@

Result

"\n\nTry a less aggressive goal, wider bounds, or a different outcome/component."; } finally { running = false; + $("reset").disabled = $("dataset").disabled = $("file").disabled = false; $("run-hint").textContent = ""; updateRunEnabled(); } From dcc4a2aab5854afa642688f7fbb1423dd3e783a5 Mon Sep 17 00:00:00 2001 From: Ante Bing Date: Tue, 1 Sep 2026 18:43:36 +0000 Subject: [PATCH 12/12] Playground: also lock the drag-and-drop upload during a run The control lock disabled #file, but drag-and-drop calls readFile() directly and bypasses the disabled input, so a mid-run drop still rebuilt the controls under the in-flight result. Guard readFile with the running flag (covers both the drop and the file-change paths) and skip the drop-zone hover styling while running. --- pkgdown/assets/playground.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkgdown/assets/playground.html b/pkgdown/assets/playground.html index 1c21b5a..50ccce9 100644 --- a/pkgdown/assets/playground.html +++ b/pkgdown/assets/playground.html @@ -647,11 +647,15 @@

Result

drop.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); file.click(); } }); - drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("hot"); }); + drop.addEventListener("dragover", (e) => { e.preventDefault(); if (!running) drop.classList.add("hot"); }); drop.addEventListener("dragleave", () => drop.classList.remove("hot")); drop.addEventListener("drop", (e) => { e.preventDefault(); drop.classList.remove("hot"); if (e.dataTransfer.files[0]) readFile(e.dataTransfer.files[0]); }); file.addEventListener("change", () => { if (file.files[0]) readFile(file.files[0]); }); async function readFile(f) { + // Drag-and-drop reaches here directly, bypassing the disabled #file input, + // so guard the in-flight run here too or a mid-run drop would rebuild the + // controls under the running result. + if (running) return; clearResults(); // Everything (parse, schema round-trip, and the fit) runs on the webR // thread, so a very large file can make the tab unresponsive; warn rather