From 72f243409cb7556d4ac87c8318f1651b95a9d5e7 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:49:18 +0200 Subject: [PATCH 01/33] Add optional checksum-verified reference loading for Shinylive --- shinyRam/R/reference-loader.R | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 shinyRam/R/reference-loader.R diff --git a/shinyRam/R/reference-loader.R b/shinyRam/R/reference-loader.R new file mode 100644 index 0000000..edcf7d7 --- /dev/null +++ b/shinyRam/R/reference-loader.R @@ -0,0 +1,55 @@ +# Reference access for normal and thin Shinylive deployments. +# A thin export keeps an index and fetches each original RDS file only when used. + +ram_reference_index <- function(directory) { + index_file <- file.path(directory, ".reference-index.tsv") + if (!file.exists(index_file)) return(NULL) + index <- utils::read.delim(index_file, colClasses = "character", + stringsAsFactors = FALSE, check.names = FALSE) + if (!identical(names(index), c("file", "md5")) || !nrow(index) || + anyDuplicated(index$file) || + any(!grepl("^[A-Za-z0-9_.-]+$", index$file)) || + any(!grepl("^[a-fA-F0-9]{32}$", index$md5))) { + stop("Invalid reference index", call. = FALSE) + } + index +} + +ram_reference_choices <- function(directory) { + index <- ram_reference_index(directory) + if (is.null(index)) list.files(directory) else index$file +} + +ram_ensure_reference <- function(path) { + if (file.exists(path)) return(invisible(path)) + index <- ram_reference_index(dirname(path)) + name <- basename(path) + position <- if (is.null(index)) NA_integer_ else match(name, index$file) + if (is.na(position)) stop("Reference file not found: ", path, call. = FALSE) + base <- getOption("ramplotr.reference_base_url", "") + if (length(base) != 1L || is.na(base) || !grepl("^https?://", base)) { + stop("No reference download URL configured", call. = FALSE) + } + url <- paste0(sub("/+$", "", base), "/", + basename(dirname(path)), "/", name) + dir.create(dirname(path), recursive = TRUE, showWarnings = FALSE) + tmp <- tempfile("reference-", tmpdir = dirname(path)) + on.exit(unlink(tmp), add = TRUE) + result <- tryCatch( + utils::download.file(url, tmp, mode = "wb", quiet = TRUE), + error = function(e) stop("Reference download failed: ", + conditionMessage(e), call. = FALSE) + ) + if (!identical(result, 0L) || !file.exists(tmp)) { + stop("Reference download failed: ", url, call. = FALSE) + } + actual <- unname(tools::md5sum(tmp)) + if (!identical(tolower(actual), tolower(index$md5[[position]]))) { + stop("Reference checksum mismatch: ", name, + ". Re-export the app and its reference-data together.", call. = FALSE) + } + if (!file.rename(tmp, path) && !file.exists(path)) { + stop("Could not cache reference file: ", path, call. = FALSE) + } + invisible(path) +} From 134118b4b550804a240adc42afbe0a0bb661e498 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:49:26 +0200 Subject: [PATCH 02/33] Fetch immutable reference data on first use when absent --- shinyRam/R/ramachandran.R | 1 + 1 file changed, 1 insertion(+) diff --git a/shinyRam/R/ramachandran.R b/shinyRam/R/ramachandran.R index 3bdaa94..442caef 100644 --- a/shinyRam/R/ramachandran.R +++ b/shinyRam/R/ramachandran.R @@ -78,6 +78,7 @@ ram_density_thresholds <- function(reference, percentages = c(85, 98, 99.95)) { ram_read_reference <- local({ cache <- new.env(parent = emptyenv()) function(path) { + ram_ensure_reference(path) key <- normalizePath(path, mustWork = TRUE) if (!exists(key, envir = cache, inherits = FALSE)) { assign(key, readRDS(key), envir = cache) From 1aa8443eca2534449a51d6853e0c76921520bfad Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:49:39 +0200 Subject: [PATCH 03/33] Allow deferred grids in cached reference profiles --- shinyRam/R/ramachandran.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shinyRam/R/ramachandran.R b/shinyRam/R/ramachandran.R index 442caef..422dc19 100644 --- a/shinyRam/R/ramachandran.R +++ b/shinyRam/R/ramachandran.R @@ -92,9 +92,9 @@ ram_read_reference <- local({ ram_reference_profile <- local({ cache <- new.env(parent = emptyenv()) function(path) { + reference <- ram_read_reference(path) key <- normalizePath(path, mustWork = TRUE) if (!exists(key, envir = cache, inherits = FALSE)) { - reference <- ram_read_reference(key) z <- as.numeric(reference$z) if (!length(z) || any(!is.finite(z)) || any(z < 0) || sum(z) <= 0) { stop("Invalid reference density grid") From 93fde3610448105ba7809cff6c5a7edfdfc0555c Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:49:41 +0200 Subject: [PATCH 04/33] Load deferred references and preserve all background choices --- shinyRam/app.R | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shinyRam/app.R b/shinyRam/app.R index 98299c2..d0d76d8 100644 --- a/shinyRam/app.R +++ b/shinyRam/app.R @@ -23,6 +23,7 @@ options(shiny.maxRequestSize = 40 * 1024^2) # Used for processing data +source(file.path("R", "reference-loader.R"), local = TRUE) source(file.path("R", "ramachandran.R"), local = TRUE) source(file.path("R", "backbone.R"), local = TRUE) source(file.path("R", "io.R"), local = TRUE) @@ -584,7 +585,7 @@ server <- function(input, output, session) { observeEvent(input$bgtype, { req(input$bgtype) - files <- list.files(file.path("static", input$bgtype)) + files <- ram_reference_choices(file.path("static", input$bgtype)) if (!length(files)) return() choices <- list( "Commonly used" = files[!files %in% allAA], From cdaab72a5b86c66caefd6661b104d1edee20d13c Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:49:57 +0200 Subject: [PATCH 05/33] Add thin Shinylive export with external reference assets and manifests --- scripts/export-shinylive.R | 75 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 scripts/export-shinylive.R diff --git a/scripts/export-shinylive.R b/scripts/export-shinylive.R new file mode 100644 index 0000000..533f354 --- /dev/null +++ b/scripts/export-shinylive.R @@ -0,0 +1,75 @@ +# Usage: Rscript scripts/export-shinylive.R bikc.be https://bikc.be/RamplotR/reference-data +# Run from repository root. Source scientific files are never modified. +args <- commandArgs(trailingOnly = TRUE) +if (length(args) != 2L || + !grepl("^https?://", args[[2L]])) { + stop("Usage: Rscript scripts/export-shinylive.R OUTPUT_DIR ABSOLUTE_REFERENCE_URL") +} +if (!requireNamespace("shinylive", quietly = TRUE)) { + stop("Install the shinylive R package") +} +target <- normalizePath(args[[1L]], mustWork = FALSE) +base_url <- sub("/+$", "", args[[2L]]) +source_app <- normalizePath("shinyRam", mustWork = TRUE) +reference_dirs <- c("original", "alphafold", "alphafold_filtered", + "astral2.08", "custom_high_resolution") +stage <- tempfile("ramplotr-thin-") +dir.create(stage) +on.exit(unlink(stage, recursive = TRUE), add = TRUE) +thin <- file.path(stage, "shinyRam") +dir.create(thin) +dir.create(file.path(thin, "static")) +# Copy all app logic and web assets, but not the large reference datasets. +for (entry in c("app.R", "R", "www")) { + if (!file.copy(file.path(source_app, entry), thin, recursive = TRUE)) { + stop("Failed to copy ", entry) + } +} +# Preserve only the small manifest inside the app; the original RDS bytes +# remain unchanged and are published alongside the static website. +public <- file.path(target, "RamplotR", "reference-data") +dir.create(public, recursive = TRUE, showWarnings = FALSE) +for (dataset in reference_dirs) { + source_dir <- file.path(source_app, "static", dataset) + names <- list.files(source_dir, all.files = FALSE) + names <- names[file.info(file.path(source_dir, names))$isdir %in% FALSE] + if (!length(names)) stop("Missing reference dataset: ", dataset) + from <- file.path(source_dir, names) + manifest <- data.frame(file = names, md5 = unname(tools::md5sum(from))) + index_dir <- file.path(thin, "static", dataset) + dir.create(index_dir) + utils::write.table(manifest, file.path(index_dir, ".reference-index.tsv"), + sep = "\t", row.names = FALSE, quote = FALSE) + public_dir <- file.path(public, dataset) + dir.create(public_dir, recursive = TRUE, showWarnings = FALSE) + ok <- file.copy(from, public_dir, overwrite = TRUE) + if (!all(ok)) stop("Failed to publish reference files: ", dataset) + published <- file.path(public_dir, names) + if (!identical(unname(tools::md5sum(published)), manifest$md5)) { + stop("Published references differ: ", dataset) + } +} +# Set the public base URL inside the staged app only. +app <- file.path(thin, "app.R") +lines <- readLines(app, warn = FALSE) +lines <- append(lines, paste0("options(ramplotr.reference_base_url = ", + deparse(base_url), ")"), after = 0L) +writeLines(lines, app) +# Export into a fresh directory so old packed RDS files cannot survive. +# Keep published reference-data aside while removing an earlier app export. +tmp_public <- tempfile("ramplotr-reference-data-") +dir.create(tmp_public) +if (!file.copy(public, tmp_public, recursive = TRUE)) { + stop("Cannot preserve reference-data during rebuild") +} +unlink(file.path(target, "RamplotR"), recursive = TRUE) +shinylive::export(thin, target, subdir = "RamplotR") +dir.create(file.path(target, "RamplotR"), recursive = TRUE, + showWarnings = FALSE) +restored <- file.path(tmp_public, "reference-data") +if (!file.copy(restored, file.path(target, "RamplotR"), recursive = TRUE)) { + stop("Failed to restore reference-data after export") +} +message("Thin Shinylive export complete: ", file.path(target, "RamplotR")) +message("Publish the full output directory so reference-data/ is accessible at ", + base_url) From e13d82b30d277fbe23cc7ccc59e960e0aef4e4cb Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:50:12 +0200 Subject: [PATCH 06/33] Use visible manifest filenames for Shinylive export --- shinyRam/R/reference-loader.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shinyRam/R/reference-loader.R b/shinyRam/R/reference-loader.R index edcf7d7..b80a760 100644 --- a/shinyRam/R/reference-loader.R +++ b/shinyRam/R/reference-loader.R @@ -2,7 +2,7 @@ # A thin export keeps an index and fetches each original RDS file only when used. ram_reference_index <- function(directory) { - index_file <- file.path(directory, ".reference-index.tsv") + index_file <- file.path(directory, "reference-index.tsv") if (!file.exists(index_file)) return(NULL) index <- utils::read.delim(index_file, colClasses = "character", stringsAsFactors = FALSE, check.names = FALSE) From 06fcf98cdcbb40967a21a5639c4820606a54808b Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:50:15 +0200 Subject: [PATCH 07/33] Include visible index in thin export --- scripts/export-shinylive.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/export-shinylive.R b/scripts/export-shinylive.R index 533f354..30e7c0f 100644 --- a/scripts/export-shinylive.R +++ b/scripts/export-shinylive.R @@ -38,7 +38,7 @@ for (dataset in reference_dirs) { manifest <- data.frame(file = names, md5 = unname(tools::md5sum(from))) index_dir <- file.path(thin, "static", dataset) dir.create(index_dir) - utils::write.table(manifest, file.path(index_dir, ".reference-index.tsv"), + utils::write.table(manifest, file.path(index_dir, "reference-index.tsv"), sep = "\t", row.names = FALSE, quote = FALSE) public_dir <- file.path(public, dataset) dir.create(public_dir, recursive = TRUE, showWarnings = FALSE) From 65635adfb0e5a9c2d8cde512dc22a07a6daab4ee Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:50:18 +0200 Subject: [PATCH 08/33] Retain standalone scientific test compatibility --- shinyRam/R/ramachandran.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shinyRam/R/ramachandran.R b/shinyRam/R/ramachandran.R index 422dc19..dc32a4d 100644 --- a/shinyRam/R/ramachandran.R +++ b/shinyRam/R/ramachandran.R @@ -78,7 +78,7 @@ ram_density_thresholds <- function(reference, percentages = c(85, 98, 99.95)) { ram_read_reference <- local({ cache <- new.env(parent = emptyenv()) function(path) { - ram_ensure_reference(path) + if (exists("ram_ensure_reference", mode = "function")) ram_ensure_reference(path) key <- normalizePath(path, mustWork = TRUE) if (!exists(key, envir = cache, inherits = FALSE)) { assign(key, readRDS(key), envir = cache) From 1e1bd72251487fd9f97f82651038d64336a584d4 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:50:40 +0200 Subject: [PATCH 09/33] Document reference-data export, XML dependency and deployment checks --- docs/shinylive-deployment.md | 71 ++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/shinylive-deployment.md diff --git a/docs/shinylive-deployment.md b/docs/shinylive-deployment.md new file mode 100644 index 0000000..ee0aa89 --- /dev/null +++ b/docs/shinylive-deployment.md @@ -0,0 +1,71 @@ +# Thin Shinylive deployment + +The normal app and batch CLI continue using all five local RDS reference +directories. A thin browser deployment keeps the same reference options, +but moves the original, unchanged RDS files out of the initial app.json. + +## Build + +From the repository root, with shinylive installed: + +```r +system2("Rscript", c("scripts/export-shinylive.R", "bikc.be", + "https://bikc.be/RamplotR/reference-data")) +``` + +On Windows in an R session you can also use: + +```r +source("scripts/export-shinylive.R", echo = TRUE) +``` + +The sourced form needs `commandArgs(trailingOnly = TRUE)`, so the +Rscript invocation above is the supported approach. For a custom hostname +or path, pass its actual absolute reference-data URL instead. + +Upload the entire generated `bikc.be` directory to the site root. This +includes `RamplotR/reference-data`, `RamplotR/app.json`, the assets in +`shinylive`, and the static HTML entry point. The data must be available +at exactly the URL passed during export. Same-origin hosting is preferred. +If serving data from another origin, configure that server's CORS policy. + +`reference-index.tsv` inside each staged reference directory lists +every available group and the MD5 of its original RDS file. The browser +downloads only the files it requests and checks the downloaded bytes before +loading them. A mismatch fails closed: rebuild and redeploy the HTML app +and reference-data together. Keep the URL and deployed files in sync. + +This is a **first-load** optimization, not a reduction in the number of +scientific reference datasets: users who select many datasets will download +them during their session. Ordinary Shiny and offline batch analysis still +work directly with the original checked-in local files. + +## Check the export + +1. Build into an empty destination or use the script to replace its app + directory. +2. Verify `RamplotR/app.json` is substantially smaller than the original + export and that five directories exist under `RamplotR/reference-data`. +3. Serve the output locally with `httpuv::runStaticServer("bikc.be")`; + for a local test, export with the correct local absolute URL, not the + production URL. +4. Check browser Network and Console tabs. Load a structure, change between + all five reference datasets, select a per-amino-acid background, and + exercise residue-aware and legacy classification. +5. Compare representative reference-grid MD5s and classification outputs + between the local app and browser app. Exported source files retain + identical bytes; independent execution in webR should still be tested. +6. Verify ordinary PNG/SVG/HTML exports and that production hosting allows + same-origin requests to `reference-data`. + +## xml2 + +`xml2` is not needed for routine PDB/mmCIF Ramachandran analysis. +It is needed to import official wwPDB validation XML files. A local +`shinylive::export()` warning about a missing local `xml2` is not +proof that it is absent from or supported by the selected webR package +repository. Install it locally to eliminate the local missing-package +warning. Test the XML attachment feature specifically in the exported +browser app. If no compatible webR binary exists, keep that feature in +the regular Shiny version and explain its browser limitation rather than +silently disabling it. From 8ca3ed2d7396e0ad200ba179eeb88bdfab2052fd Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:51:00 +0200 Subject: [PATCH 10/33] Add local and thin-manifest reference loader regression tests --- tests/reference-loader.R | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/reference-loader.R diff --git a/tests/reference-loader.R b/tests/reference-loader.R new file mode 100644 index 0000000..a9f34a3 --- /dev/null +++ b/tests/reference-loader.R @@ -0,0 +1,23 @@ +# Run with Rscript tests/reference-loader.R from the repository root. +source("shinyRam/R/reference-loader.R") +source("shinyRam/R/ramachandran.R") +root <- tempfile("ramplotr-reference-test-") +dir.create(root) +on.exit(unlink(root, recursive = TRUE), add = TRUE) +original <- file.path(root, "original") +dir.create(original) +grid <- list(x = -1:1, y = -1:1, z = matrix(1:9, nrow = 3)) +saveRDS(grid, file.path(original, "General"), compress = "gzip") +stopifnot(identical(ram_read_reference(file.path(original, "General")), grid)) +stopifnot("General" %in% ram_reference_choices(original)) +manifest <- data.frame(file = "General", + md5 = unname(tools::md5sum(file.path(original, "General")))) +write.table(manifest, file.path(original, "reference-index.tsv"), + row.names = FALSE, quote = FALSE, sep = "\t") +stopifnot(identical(ram_reference_choices(original), "General")) +stopifnot(identical(ram_reference_index(original)$md5, manifest$md5)) +stopifnot(identical(ram_read_reference(file.path(original, "General")), grid)) +writeLines("bad data", file.path(original, "reference-index.tsv")) +stopifnot(inherits(try(ram_reference_index(original), silent = TRUE), + "try-error")) +cat("Local reference-loader and manifest checks passed.\n") From 56131bcfe149acb35e1bc101c4653e098b705c67 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:51:07 +0200 Subject: [PATCH 11/33] Make reference loader smoke test runnable at top level --- tests/reference-loader.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/reference-loader.R b/tests/reference-loader.R index a9f34a3..e459a27 100644 --- a/tests/reference-loader.R +++ b/tests/reference-loader.R @@ -3,7 +3,6 @@ source("shinyRam/R/reference-loader.R") source("shinyRam/R/ramachandran.R") root <- tempfile("ramplotr-reference-test-") dir.create(root) -on.exit(unlink(root, recursive = TRUE), add = TRUE) original <- file.path(root, "original") dir.create(original) grid <- list(x = -1:1, y = -1:1, z = matrix(1:9, nrow = 3)) @@ -20,4 +19,5 @@ stopifnot(identical(ram_read_reference(file.path(original, "General")), grid)) writeLines("bad data", file.path(original, "reference-index.tsv")) stopifnot(inherits(try(ram_reference_index(original), silent = TRUE), "try-error")) +unlink(root, recursive = TRUE) cat("Local reference-loader and manifest checks passed.\n") From 0d5dee19e70e30a1b62065b8231fcf3d4ebbab40 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:06:40 +0200 Subject: [PATCH 12/33] Add RamplotR phi/psi SVG favicon --- shinyRam/www/favicon.svg | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 shinyRam/www/favicon.svg diff --git a/shinyRam/www/favicon.svg b/shinyRam/www/favicon.svg new file mode 100644 index 0000000..fd8295d --- /dev/null +++ b/shinyRam/www/favicon.svg @@ -0,0 +1,10 @@ + + RamplotR: phi psi + + + + + + + + From 80130757bfc158aa2cf2c87fb3eeb9f04a7617f6 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:06:43 +0200 Subject: [PATCH 13/33] Load Plotly only after a structure needs plotting --- shinyRam/www/plotly-loader.js | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 shinyRam/www/plotly-loader.js diff --git a/shinyRam/www/plotly-loader.js b/shinyRam/www/plotly-loader.js new file mode 100644 index 0000000..8ca8b20 --- /dev/null +++ b/shinyRam/www/plotly-loader.js @@ -0,0 +1,33 @@ +/* The first visit should show the input form without downloading Plotly. + * Shiny calls this when a plot is actually needed. In-flight requests share + * one promise; an unsuccessful download can be retried. + */ +(function (window, document) { + "use strict"; + let loading = null; + const url = "https://cdn.plot.ly/plotly-2.14.0.min.js"; + + window.ramLoadPlotly = function () { + if (window.Plotly) return Promise.resolve(window.Plotly); + if (loading) return loading; + + loading = new Promise(function (resolve, reject) { + const script = document.createElement("script"); + script.src = url; + script.async = true; + script.onload = function () { + if (window.Plotly) resolve(window.Plotly); + else reject(new Error("Plotly loaded without its global API")); + }; + script.onerror = function () { + if (script.parentNode) script.parentNode.removeChild(script); + reject(new Error("Could not download Plotly. Check your internet connection.")); + }; + document.head.appendChild(script); + }).catch(function (error) { + loading = null; + throw error; + }); + return loading; + }; +})(window, document); From 0471aa94dfd0b02b403a92def4751368c5ff9049 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:06:51 +0200 Subject: [PATCH 14/33] Use new favicon and defer Plotly until a plot is requested --- shinyRam/app.R | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shinyRam/app.R b/shinyRam/app.R index d0d76d8..3eb8944 100644 --- a/shinyRam/app.R +++ b/shinyRam/app.R @@ -95,7 +95,7 @@ ui <- fluidPage( tags$meta(name = "viewport", content = "width=device-width, initial-scale=1"), tags$title("RamplotR | Ramachandran analysis"), tags$link(rel = "stylesheet", type = "text/css", href = "styles.css"), - tags$script(src = "https://cdn.plot.ly/plotly-2.14.0.min.js") + tags$link(rel = "icon", type = "image/svg+xml", href = "favicon.svg") ), tags$div( class = "ram-app", @@ -537,6 +537,7 @@ ui <- fluidPage( ) ) ), + tags$script(src = "plotly-loader.js"), tags$script(src = "custom.js"), tags$script(src = "prediction.js"), tags$script(src = "density.js") From 49677d82523d88b037ab62b68ddf6be39220c1b5 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:07:05 +0200 Subject: [PATCH 15/33] Queue plot rendering during lazy Plotly download and preserve WebGL comparisons --- shinyRam/www/custom.js | 44 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/shinyRam/www/custom.js b/shinyRam/www/custom.js index d6119d4..53b2839 100644 --- a/shinyRam/www/custom.js +++ b/shinyRam/www/custom.js @@ -19,6 +19,10 @@ let boundPickHandler = null; let focusedKey = ""; let focusedStage = null; + let deferredPlot = null; + let plotLoadPending = false; + let deferredComparison = null; + let comparisonLoadPending = false; // A residue identity is made of chain, sequence position and insertion // code. Do not focus an arbitrary selector received from a browser event. @@ -310,11 +314,29 @@ function drawPlot(obj) { if (!plot) return; if (!window.Plotly) { + // A structure has arrived before the plotting library. Keep only the + // latest reactive update; palette/filter changes may arrive meanwhile. + deferredPlot = obj; if (empty) { empty.hidden = false; const message = empty.querySelector("p"); - if (message) message.textContent = - "The plotting library could not load. Check your connection and reload."; + if (message) message.textContent = "Loading the plot renderer…"; + } + if (!plotLoadPending) { + plotLoadPending = true; + window.ramLoadPlotly().then(function () { + plotLoadPending = false; + const latest = deferredPlot; + deferredPlot = null; + if (latest) drawPlot(latest); + }).catch(function () { + plotLoadPending = false; + if (empty) { + const message = empty.querySelector("p"); + if (message) message.textContent = + "The plotting library could not load. Check your connection and try again."; + } + }); } return; } @@ -519,7 +541,23 @@ } function drawComparison(obj) { - if (!comparePlot || !window.Plotly || !obj) return; + if (!comparePlot || !obj) return; + if (!window.Plotly) { + deferredComparison = obj; + if (!comparisonLoadPending) { + comparisonLoadPending = true; + window.ramLoadPlotly().then(function () { + comparisonLoadPending = false; + const latest = deferredComparison; + deferredComparison = null; + if (latest) drawComparison(latest); + }).catch(function (error) { + comparisonLoadPending = false; + console.error("RamplotR comparison plot:", error); + }); + } + return; + } const aPhi = array(obj.phiA), aPsi = array(obj.psiA); const bPhi = array(obj.phiB), bPsi = array(obj.psiB); const asPoints = function (phi, psi) { From d9117c2ce155067a89de308d522a05eda812798b Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:07:16 +0200 Subject: [PATCH 16/33] Load Plotly on demand when a confidence heatmap is opened --- shinyRam/www/prediction.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/shinyRam/www/prediction.js b/shinyRam/www/prediction.js index 860747a..48d8d96 100644 --- a/shinyRam/www/prediction.js +++ b/shinyRam/www/prediction.js @@ -6,6 +6,8 @@ let payload = null; let selected = null; let boundPlot = null; + let plotlyPending = false; + let plotlyFailed = false; function key(value) { return value && [String(value.chain || ""), String(value.resi), @@ -23,7 +25,21 @@ const node = document.getElementById("ram-pae-plot"); const panel = document.getElementById("ram-confidence-panel"); if (!node || !panel || !panel.open || !payload || - !Array.isArray(payload.z) || !window.Plotly) return; + !Array.isArray(payload.z)) return; + if (!window.Plotly) { + if (plotlyPending || plotlyFailed) return; + plotlyPending = true; + window.ramLoadPlotly().then(function () { + plotlyPending = false; + deferRender(); + }).catch(function (error) { + plotlyPending = false; + plotlyFailed = true; + const note = document.getElementById("ram-pae-note"); + if (note) note.textContent = "PAE could not be rendered: " + error.message; + }); + return; + } const n = payload.residues.length; const ticks = []; From fcf18a2cfa2b8d3a8774640d167f00de761649c6 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:08:01 +0200 Subject: [PATCH 17/33] Publish favicon and stage thin Shinylive references without redundant copies --- scripts/export-shinylive.R | 175 ++++++++++++++++++++++--------------- 1 file changed, 103 insertions(+), 72 deletions(-) diff --git a/scripts/export-shinylive.R b/scripts/export-shinylive.R index 30e7c0f..a2e0e4c 100644 --- a/scripts/export-shinylive.R +++ b/scripts/export-shinylive.R @@ -1,75 +1,106 @@ -# Usage: Rscript scripts/export-shinylive.R bikc.be https://bikc.be/RamplotR/reference-data -# Run from repository root. Source scientific files are never modified. -args <- commandArgs(trailingOnly = TRUE) -if (length(args) != 2L || - !grepl("^https?://", args[[2L]])) { - stop("Usage: Rscript scripts/export-shinylive.R OUTPUT_DIR ABSOLUTE_REFERENCE_URL") -} -if (!requireNamespace("shinylive", quietly = TRUE)) { - stop("Install the shinylive R package") -} -target <- normalizePath(args[[1L]], mustWork = FALSE) -base_url <- sub("/+$", "", args[[2L]]) -source_app <- normalizePath("shinyRam", mustWork = TRUE) -reference_dirs <- c("original", "alphafold", "alphafold_filtered", - "astral2.08", "custom_high_resolution") -stage <- tempfile("ramplotr-thin-") -dir.create(stage) -on.exit(unlink(stage, recursive = TRUE), add = TRUE) -thin <- file.path(stage, "shinyRam") -dir.create(thin) -dir.create(file.path(thin, "static")) -# Copy all app logic and web assets, but not the large reference datasets. -for (entry in c("app.R", "R", "www")) { - if (!file.copy(file.path(source_app, entry), thin, recursive = TRUE)) { - stop("Failed to copy ", entry) +# Run from repository root: +# Rscript scripts/export-shinylive.R bikc.be https://bikc.be/RamplotR/reference-data +# Normal Shiny and the original reference files are unchanged. +ramplotr_export_shinylive <- function(args) { + if (length(args) != 2L || !grepl("^https?://", args[[2L]])) { + stop("Usage: Rscript scripts/export-shinylive.R OUTPUT_DIR ABSOLUTE_REFERENCE_URL") } -} -# Preserve only the small manifest inside the app; the original RDS bytes -# remain unchanged and are published alongside the static website. -public <- file.path(target, "RamplotR", "reference-data") -dir.create(public, recursive = TRUE, showWarnings = FALSE) -for (dataset in reference_dirs) { - source_dir <- file.path(source_app, "static", dataset) - names <- list.files(source_dir, all.files = FALSE) - names <- names[file.info(file.path(source_dir, names))$isdir %in% FALSE] - if (!length(names)) stop("Missing reference dataset: ", dataset) - from <- file.path(source_dir, names) - manifest <- data.frame(file = names, md5 = unname(tools::md5sum(from))) - index_dir <- file.path(thin, "static", dataset) - dir.create(index_dir) - utils::write.table(manifest, file.path(index_dir, "reference-index.tsv"), - sep = "\t", row.names = FALSE, quote = FALSE) - public_dir <- file.path(public, dataset) - dir.create(public_dir, recursive = TRUE, showWarnings = FALSE) - ok <- file.copy(from, public_dir, overwrite = TRUE) - if (!all(ok)) stop("Failed to publish reference files: ", dataset) - published <- file.path(public_dir, names) - if (!identical(unname(tools::md5sum(published)), manifest$md5)) { - stop("Published references differ: ", dataset) + if (!requireNamespace("shinylive", quietly = TRUE)) { + stop("Install the shinylive R package") } + target <- normalizePath(args[[1L]], mustWork = FALSE) + dir.create(target, recursive = TRUE, showWarnings = FALSE) + base_url <- sub("/+$", "", args[[2L]]) + source_app <- normalizePath("shinyRam", mustWork = TRUE) + datasets <- c("original", "alphafold", "alphafold_filtered", + "astral2.08", "custom_high_resolution") + stage <- tempfile("ramplotr-thin-") + dir.create(stage) + on.exit(unlink(stage, recursive = TRUE), add = TRUE) + thin <- file.path(stage, "shinyRam") + dir.create(thin) + dir.create(file.path(thin, "static")) + + # Keep scripts, assets and tiny reference indexes in app.json, not the RDS + # grids. Ordinary Shiny uses the original complete shinyRam directory. + for (entry in c("app.R", "R", "www")) { + if (!file.copy(file.path(source_app, entry), thin, recursive = TRUE)) { + stop("Failed to copy ", entry) + } + } + staging <- tempfile("ramplotr-public-data-") + dir.create(staging) + on.exit(unlink(staging, recursive = TRUE), add = TRUE) + public <- file.path(staging, "reference-data") + dir.create(public) + + for (dataset in datasets) { + source_dir <- file.path(source_app, "static", dataset) + names <- list.files(source_dir) + names <- names[!file.info(file.path(source_dir, names))$isdir] + if (!length(names)) stop("Missing reference dataset: ", dataset) + from <- file.path(source_dir, names) + manifest <- data.frame( + file = names, + md5 = unname(tools::md5sum(from)), + stringsAsFactors = FALSE + ) + dir.create(file.path(thin, "static", dataset)) + utils::write.table( + manifest, file.path(thin, "static", dataset, "reference-index.tsv"), + sep = "\t", row.names = FALSE, quote = FALSE + ) + dest_dir <- file.path(public, dataset) + dir.create(dest_dir) + if (!all(file.copy(from, dest_dir))) { + stop("Failed to copy reference files: ", dataset) + } + if (!identical( + unname(tools::md5sum(file.path(dest_dir, names))), manifest$md5 + )) stop("Published references differ: ", dataset) + } + + # This option is only added to the temporary app for the browser. + app <- file.path(thin, "app.R") + lines <- readLines(app, warn = FALSE) + lines <- append( + lines, + paste0("options(ramplotr.reference_base_url = ", deparse(base_url), ")"), + after = 0L + ) + writeLines(lines, app) + + # Replacing only RamplotR retains shared shinylive assets and any other + # static apps previously published beneath the same destination. + destination <- file.path(target, "RamplotR") + unlink(destination, recursive = TRUE) + shinylive::export( + thin, target, subdir = "RamplotR", + template_params = list( + title = "RamplotR | Protein structure analysis", + include_in_head = paste0( + '' + ) + ) + ) + + # The outer Shinylive page and Shiny's iframe both get the same favicon. + if (!file.copy( + file.path(source_app, "www", "favicon.svg"), + file.path(destination, "favicon.svg"), + overwrite = TRUE + )) stop("Could not publish favicon") + external <- file.path(destination, "reference-data") + if (!file.rename(public, external)) { + dir.create(external, recursive = TRUE, showWarnings = FALSE) + if (!all(file.copy(list.files(public, full.names = TRUE), + external, recursive = TRUE))) { + stop("Failed to publish reference-data") + } + } + message("Thin Shinylive export complete: ", destination) + message("Serve reference-data from: ", base_url) + invisible(destination) } -# Set the public base URL inside the staged app only. -app <- file.path(thin, "app.R") -lines <- readLines(app, warn = FALSE) -lines <- append(lines, paste0("options(ramplotr.reference_base_url = ", - deparse(base_url), ")"), after = 0L) -writeLines(lines, app) -# Export into a fresh directory so old packed RDS files cannot survive. -# Keep published reference-data aside while removing an earlier app export. -tmp_public <- tempfile("ramplotr-reference-data-") -dir.create(tmp_public) -if (!file.copy(public, tmp_public, recursive = TRUE)) { - stop("Cannot preserve reference-data during rebuild") -} -unlink(file.path(target, "RamplotR"), recursive = TRUE) -shinylive::export(thin, target, subdir = "RamplotR") -dir.create(file.path(target, "RamplotR"), recursive = TRUE, - showWarnings = FALSE) -restored <- file.path(tmp_public, "reference-data") -if (!file.copy(restored, file.path(target, "RamplotR"), recursive = TRUE)) { - stop("Failed to restore reference-data after export") -} -message("Thin Shinylive export complete: ", file.path(target, "RamplotR")) -message("Publish the full output directory so reference-data/ is accessible at ", - base_url) + +ramplotr_export_shinylive(commandArgs(trailingOnly = TRUE)) From 23960293ad9e0281bc452b26ec3c47ebddda3e57 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:08:13 +0200 Subject: [PATCH 18/33] Prefetch Plotly on Analyse clicks while R processes the structure --- shinyRam/www/plotly-loader.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/shinyRam/www/plotly-loader.js b/shinyRam/www/plotly-loader.js index 8ca8b20..6ed5313 100644 --- a/shinyRam/www/plotly-loader.js +++ b/shinyRam/www/plotly-loader.js @@ -30,4 +30,18 @@ }); return loading; }; + + // Start the download while Shiny parses the selected structure. The form + // remains fast to display and the plotting code keeps its normal fallback + // for keyboard-triggered or programmatic submissions. + document.addEventListener("click", function (event) { + const control = event.target && typeof event.target.closest === "function" + ? event.target.closest("button") : null; + if (control && (control.id === "submit" || + control.id === "compareSubmit")) { + window.ramLoadPlotly().catch(function () { + // The plot itself displays the actionable error if loading fails. + }); + } + }, true); })(window, document); From c0d1a2b14d72340e9414ef056134ea497e430a9c Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:08:28 +0200 Subject: [PATCH 19/33] Test deferred Plotly loading, shared downloads and failure retries --- tests/plotly-loader.test.cjs | 59 ++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/plotly-loader.test.cjs diff --git a/tests/plotly-loader.test.cjs b/tests/plotly-loader.test.cjs new file mode 100644 index 0000000..caa7ee8 --- /dev/null +++ b/tests/plotly-loader.test.cjs @@ -0,0 +1,59 @@ +// No network required: verify that Plotly downloads only after it is needed. +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const vm = require("node:vm"); + +const source = fs.readFileSync("shinyRam/www/plotly-loader.js", "utf8"); + +function setup() { + const scripts = []; + const events = {}; + const document = { + createElement(tag) { + assert.equal(tag, "script"); + return { parentNode: { removeChild() {} } }; + }, + head: { appendChild(script) { scripts.push(script); } }, + addEventListener(name, callback) { events[name] = callback; } + }; + const window = {}; + vm.runInNewContext(source, { window, document, Promise, Error }); + return { scripts, events, window }; +} + +(async function () { + const app = setup(); + assert.equal(app.scripts.length, 0, "No Plotly request on initial load"); + assert.equal(typeof app.window.ramLoadPlotly, "function"); + const first = app.window.ramLoadPlotly(); + const second = app.window.ramLoadPlotly(); + assert.equal(first, second, "Concurrent plot requests share one download"); + assert.equal(app.scripts.length, 1); + assert.match(app.scripts[0].src, /plotly-2\.14\.0\.min\.js$/); + app.window.Plotly = { react() {} }; + app.scripts[0].onload(); + assert.equal(await first, app.window.Plotly); + assert.equal(await app.window.ramLoadPlotly(), app.window.Plotly); + assert.equal(app.scripts.length, 1, "Cached Plotly needs no new request"); + + const early = setup(); + early.events.click({target: {closest() {return {id: "submit"};}}}); + assert.equal(early.scripts.length, 1, "Analyse starts Plotly download"); + early.window.Plotly = {}; + early.scripts[0].onload(); + await early.window.ramLoadPlotly(); + + const failed = setup(); + const request = failed.window.ramLoadPlotly(); + failed.scripts[0].onerror(); + await assert.rejects(request, /Could not download Plotly/); + const retry = failed.window.ramLoadPlotly(); + assert.equal(failed.scripts.length, 2, "Failed downloads can be retried"); + failed.window.Plotly = {}; + failed.scripts[1].onload(); + await retry; + console.log("Deferred Plotly loader tests passed."); +})().catch(error => { + console.error(error); + process.exitCode = 1; +}); From 5d790824a2df3e928cf5f0775207a49b2070b15b Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:08:37 +0200 Subject: [PATCH 20/33] Run deferred Plotly and reference loader regression tests in CI --- .github/workflows/scientific-tests.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/scientific-tests.yml b/.github/workflows/scientific-tests.yml index e399a76..501a668 100644 --- a/.github/workflows/scientific-tests.yml +++ b/.github/workflows/scientific-tests.yml @@ -33,6 +33,8 @@ jobs: node --check shinyRam/www/custom.js node --check shinyRam/www/prediction.js node --check shinyRam/www/density.js + node --check shinyRam/www/plotly-loader.js + node tests/plotly-loader.test.cjs node tests/density-ui.test.cjs node tests/ui.test.cjs node tests/prediction-ui.test.cjs @@ -65,6 +67,9 @@ jobs: - name: Verify cached reference profiles run: Rscript tests/profile.R shell: bash + - name: Verify local and thin-manifest reference access + run: Rscript tests/reference-loader.R + shell: bash - name: Verify structure input and reference-specific classifications run: | Rscript tests/input.R From f9666b8ac585c86b2752db342f41f41651a08842 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:09:11 +0200 Subject: [PATCH 21/33] Verify deferred library and favicon in UI contract --- tests/ui-contract.R | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/ui-contract.R b/tests/ui-contract.R index f8bd5c3..fa70e2e 100644 --- a/tests/ui-contract.R +++ b/tests/ui-contract.R @@ -21,6 +21,9 @@ if (length(missing)) stop("Missing original UI ID(s): ", paste(missing, collapse = ", ")) stopifnot( grepl('href = "styles.css"', ui, fixed = TRUE), + grepl('href = "favicon.svg"', ui, fixed = TRUE), + grepl('src = "plotly-loader.js"', ui, fixed = TRUE), + !grepl('tags$script(src = "https://cdn.plot.ly', ui, fixed = TRUE), grepl('src = "custom.js"', ui, fixed = TRUE), grepl('class = "ram-workspace"', ui, fixed = TRUE), grepl('class = "ram-charts"', ui, fixed = TRUE), From f56ca3a8a5327048a5d030b68188f1668dd2daeb Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:09:13 +0200 Subject: [PATCH 22/33] Assert form loads before Plotly in real browser tests --- tests/ui-browser.cjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/ui-browser.cjs b/tests/ui-browser.cjs index 4b3eb07..6a6a490 100644 --- a/tests/ui-browser.cjs +++ b/tests/ui-browser.cjs @@ -28,6 +28,9 @@ const assert = require("node:assert/strict"); waitUntil: "networkidle2", timeout: 60000 }); await page.waitForSelector(".ram-workspace"); + assert.equal(await page.evaluate(() => !!window.Plotly), false, + "Opening the input form must not download Plotly yet."); + assert.ok(await page.$('link[rel="icon"][href="favicon.svg"]')); assert.ok(await page.$("#ram-pdb-wrap")); assert.ok(await page.$("#ram-upload-wrap")); assert.ok(await page.$("#NGL")); From 6f4c85da4165927f52d77ceb27c983c433f2b8c0 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:09:16 +0200 Subject: [PATCH 23/33] Document cold-start measurement and static asset caching --- docs/shinylive-deployment.md | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/shinylive-deployment.md b/docs/shinylive-deployment.md index ee0aa89..b221537 100644 --- a/docs/shinylive-deployment.md +++ b/docs/shinylive-deployment.md @@ -69,3 +69,40 @@ warning. Test the XML attachment feature specifically in the exported browser app. If no compatible webR binary exists, keep that feature in the regular Shiny version and explain its browser limitation rather than silently disabling it. + +## First-load performance + +Shinylive runs webR and Shiny entirely in the browser. A cold visitor must +download and initialize webR and the required R WebAssembly packages before +interacting with the app. The reference-data split reduces `app.json`, +but does not remove the webR boot cost. + +The app no longer downloads and parses the full Plotly JavaScript bundle +before displaying the input form. Selecting **Analyse** begins fetching +Plotly while R parses the selected structure. Plot rendering waits for this +download when necessary; a prediction-only PAE heatmap can also request it. +The full bundle remains intentional: RamplotR's comparison view uses +`scattergl` for large structures, and Plotly's smaller Cartesian partial +bundle does not include this trace type. + +The generated Shinylive index page uses the same small SVG favicon as the +Shiny app's embedded page. + +### Measure your deployment + +1. Open browser Developer Tools, Network, disable cache, and reload the + published RamplotR page. Record the transferred size and time for + `app.json`, `webr` assets and the downloaded `*.wasm` / R package files. +2. Without loading a structure, verify Plotly is absent in Network. Click + **Analyse** and check that the Plotly request begins at the click. +3. Repeat with the browser cache enabled. A faster second visit points to + downloadable webR/assets and HTTP caching as the cold-start cost. Compare + with a locally hosted normal Shiny app if CPU startup remains slow. + +On the static web server, enable Brotli or gzip for HTML, JSON, JavaScript +and other text assets, and provide sensible caching for the webR runtime, +WASM packages and unchanged reference files. Prefer `Cache-Control: +no-cache` for the generated `index.html` so deployments refresh. +Do not give mutable `app.json` or `reference-data` long immutable caching +unless the deployment uses a versioned URL, since a stale manifest paired +with new RDS files will correctly fail checksum verification. From 3de6b0592fe05fc83dadab20efa8deb39e01630f Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:18:49 +0200 Subject: [PATCH 24/33] Add scoped one.com compression and safe caching for RamplotR --- config/onecom/ramplotr.htaccess | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 config/onecom/ramplotr.htaccess diff --git a/config/onecom/ramplotr.htaccess b/config/onecom/ramplotr.htaccess new file mode 100644 index 0000000..1fbfb40 --- /dev/null +++ b/config/onecom/ramplotr.htaccess @@ -0,0 +1,24 @@ +# Optional one.com/Apache settings for /RamplotR/ only. +# Keep other bikc.be applications and any root .htaccess unchanged. +# One.com may restrict modules. If a server error appears, remove this file. +# +# Compress text assets when the corresponding Apache module is available. + + AddOutputFilterByType BROTLI_COMPRESS text/html text/css text/plain text/xml image/svg+xml application/javascript text/javascript application/json + + + + AddOutputFilterByType DEFLATE text/html text/css text/plain text/xml image/svg+xml application/javascript text/javascript application/json + + + + + # App and manifest changes must be revalidated after a deployment. + + Header set Cache-Control "no-cache" + + # Other local assets can be reused briefly but may change on deployment. + + Header set Cache-Control "public, max-age=86400" + + From ed93a948dea42439df9a4b5285ce44f5a01d02bb Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:18:51 +0200 Subject: [PATCH 25/33] Add scoped one.com compression and caching for shared Shinylive assets --- config/onecom/shinylive.htaccess | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 config/onecom/shinylive.htaccess diff --git a/config/onecom/shinylive.htaccess b/config/onecom/shinylive.htaccess new file mode 100644 index 0000000..354fc3f --- /dev/null +++ b/config/onecom/shinylive.htaccess @@ -0,0 +1,19 @@ +# Optional one.com/Apache settings for the shared /shinylive/ runtime. +# Do not replace an existing .htaccess: merge these rules if needed. + + AddOutputFilterByType BROTLI_COMPRESS text/html text/css text/plain text/xml image/svg+xml application/javascript text/javascript application/json application/wasm + + + + AddOutputFilterByType DEFLATE text/html text/css text/plain text/xml image/svg+xml application/javascript text/javascript application/json application/wasm + + + + + Header set Cache-Control "public, max-age=86400" + + # Package metadata may change after rebuilding and must be revalidated. + + Header set Cache-Control "no-cache" + + From 3e6304b655dd2f976652731e16de9bf977ebd139 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:19:05 +0200 Subject: [PATCH 26/33] Correct Apache FilesMatch regex escaping --- config/onecom/ramplotr.htaccess | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/onecom/ramplotr.htaccess b/config/onecom/ramplotr.htaccess index 1fbfb40..e649f28 100644 --- a/config/onecom/ramplotr.htaccess +++ b/config/onecom/ramplotr.htaccess @@ -14,11 +14,11 @@ # App and manifest changes must be revalidated after a deployment. - + Header set Cache-Control "no-cache" # Other local assets can be reused briefly but may change on deployment. - + Header set Cache-Control "public, max-age=86400" From eb44a468d3740ddc92bb555b04828fe86d0c9c0f Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:19:07 +0200 Subject: [PATCH 27/33] Correct Apache FilesMatch regex escaping --- config/onecom/shinylive.htaccess | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/onecom/shinylive.htaccess b/config/onecom/shinylive.htaccess index 354fc3f..d050e15 100644 --- a/config/onecom/shinylive.htaccess +++ b/config/onecom/shinylive.htaccess @@ -9,11 +9,11 @@ - + Header set Cache-Control "public, max-age=86400" # Package metadata may change after rebuilding and must be revalidated. - + Header set Cache-Control "no-cache" From 74d7359b71894a7a33928cad7599200263a118c0 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:19:18 +0200 Subject: [PATCH 28/33] Include scoped one.com .htaccess files in static export --- scripts/export-shinylive.R | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scripts/export-shinylive.R b/scripts/export-shinylive.R index a2e0e4c..a6dc29a 100644 --- a/scripts/export-shinylive.R +++ b/scripts/export-shinylive.R @@ -98,6 +98,22 @@ ramplotr_export_shinylive <- function(args) { stop("Failed to publish reference-data") } } + # Optional Apache settings for one.com. Scope rules to RamplotR and the + # shared Shinylive asset folder. Never touch the website root .htaccess. + # An existing shared .htaccess is left intact for manual merging. + config <- file.path("config", "onecom") + app_htaccess <- file.path(destination, ".htaccess") + if (!file.copy(file.path(config, "ramplotr.htaccess"), app_htaccess, + overwrite = TRUE)) stop("Failed to include app .htaccess") + shared_htaccess <- file.path(target, "shinylive", ".htaccess") + if (!file.exists(shared_htaccess)) { + if (!file.copy(file.path(config, "shinylive.htaccess"), + shared_htaccess)) { + warning("Could not include the shared Shinylive .htaccess") + } + } else { + message("Preserved existing shinylive/.htaccess; review it for cache rules.") + } message("Thin Shinylive export complete: ", destination) message("Serve reference-data from: ", base_url) invisible(destination) From 0fe029e1ebc622f2d71c98149e88117da230c0ee Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:19:36 +0200 Subject: [PATCH 29/33] Document public Shinylive app, optimized export and one.com hosting in README --- README.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2acfd34..dc6629c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ RamplotR is an open-source R Shiny application that brings backbone geometry, a *RamplotR's default publication palette. Example: PDB [1CRN](https://www.rcsb.org/structure/1CRN). [More screenshots](#screenshots).* -[**Get started**](#get-started) · [**Explore the features**](#what-you-can-do) · [**Scientific interpretation**](#scientific-interpretation) · [**Documentation**](#documentation) +[**Try the browser app**](https://bikc.be/RamplotR/) · [**Get started**](#get-started) · [**Explore the features**](#what-you-can-do) · [**Scientific interpretation**](#scientific-interpretation) · [**Documentation**](#documentation) ## What you can do @@ -57,6 +57,24 @@ Enter a four-character **PDB ID** (for example, `1CRN`), **upload** your own PDB The project has also been hosted at [bioit.shinyapps.io/RamplotR](https://bioit.shinyapps.io/RamplotR/), but that deployment may not reflect the latest GitHub version. Running locally is the most reliable way to use the current implementation. Public-accession retrieval requires an internet connection; uploaded coordinates and local map files can be inspected without an external folding service. +### Use the browser version + +[**Open RamplotR at bikc.be/RamplotR**](https://bikc.be/RamplotR/). The public version runs through **Shinylive** on static one.com hosting. R runs in your browser through webR, so no R installation is required. A first visit downloads and starts webR and its R packages; subsequent visits can reuse cached assets. Loading large structures still depends on the visitor's device. + +The browser build uses the same five reference datasets and original RDS distributions as the desktop/server app. To avoid including all 110 distributions in the initial `app.json`, reference files are hosted separately and downloaded on first use. Every file is checked against the export's MD5 manifest, and loaded references are cached within the session. The full Plotly library starts downloading when you click **Analyse** instead of delaying the initial form. A small φ/ψ favicon matches the app's teal colour scheme. + +For a reproducible deployment, run this from the repository root with the [shinylive R package](https://posit-dev.github.io/r-shinylive/) installed: + +```bash +Rscript scripts/export-shinylive.R bikc.be https://bikc.be/RamplotR/reference-data +``` + +Upload the **contents** of the generated `bikc.be/` directory to the site's document root on one.com, including `RamplotR/reference-data/` and the shared `shinylive/` assets. The export also includes optional, directory-scoped `.htaccess` files for gzip/Brotli (when available) and cautious browser caching; these do not alter the website's root configuration. one.com restricts some Apache features, so check the HTTP response headers after deployment rather than assuming that compression is active. + +Ordinary PDB/mmCIF analysis does not require `xml2`. Importing official wwPDB validation XML does require it; compatibility of that optional feature should be checked in the specific exported webR build. The local/server Shiny app and the batch command remain available when a browser package is unsupported. + +See the [Shinylive export and one.com deployment guide](docs/shinylive-deployment.md) for hosting checks, caching rules and troubleshooting. + ### Analyse many structures From the repository root, process a structure or a directory containing supported structure files: @@ -86,6 +104,7 @@ The default RamplotR teal contour palette provides consistent, recognisable publ - [Geometry, official wwPDB evidence, cryo-EM overlays, ensembles and batch mode](docs/structural-verification.md) - [Independent wwPDB validation protocol and benchmark results](docs/wwpdb-validation.md) · [Results](docs/validation-results.md) - [Performance and large-structure benchmarks](docs/benchmark-results.md) · [Scaling results](docs/scaling-results.md) +- [Shinylive browser deployment and one.com caching](docs/shinylive-deployment.md) Developers can run the pure-R scientific regression suite from the repository root with `Rscript tests/scientific.R`. Additional tests cover structure parsing, validation imports, confidence formats, geometry, ensembles and the batch CLI. GitHub Actions also exercises the application in a real browser and runs the scientific tests on Ubuntu and Windows. From c11ca92266210ea23de7709d5702e403c25232ad Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:19:48 +0200 Subject: [PATCH 30/33] Revalidate unchanged RDS reference files across deployments --- config/onecom/reference-data.htaccess | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 config/onecom/reference-data.htaccess diff --git a/config/onecom/reference-data.htaccess b/config/onecom/reference-data.htaccess new file mode 100644 index 0000000..ff19c62 --- /dev/null +++ b/config/onecom/reference-data.htaccess @@ -0,0 +1,5 @@ +# Original reference RDS distributions are already compressed on disk. +# The MD5 manifest and the RDS payload must always come from the same export. + + Header set Cache-Control "no-cache" + From 458e718cc3fb62a659f2034f795ebb51526d98f6 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:19:54 +0200 Subject: [PATCH 31/33] Add reference-data cache revalidation rules to generated export --- scripts/export-shinylive.R | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/export-shinylive.R b/scripts/export-shinylive.R index a6dc29a..3924613 100644 --- a/scripts/export-shinylive.R +++ b/scripts/export-shinylive.R @@ -105,6 +105,12 @@ ramplotr_export_shinylive <- function(args) { app_htaccess <- file.path(destination, ".htaccess") if (!file.copy(file.path(config, "ramplotr.htaccess"), app_htaccess, overwrite = TRUE)) stop("Failed to include app .htaccess") + # Reference files lack extensions; give this subdirectory its own explicit + # revalidation policy so the per-build manifest cannot become stale. + if (!file.copy(file.path(config, "reference-data.htaccess"), + file.path(external, ".htaccess"), overwrite = TRUE)) { + stop("Failed to include reference-data .htaccess") + } shared_htaccess <- file.path(target, "shinylive", ".htaccess") if (!file.exists(shared_htaccess)) { if (!file.copy(file.path(config, "shinylive.htaccess"), From 80f224d346ecf51539a020f2428c329374cec997 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:20:13 +0200 Subject: [PATCH 32/33] Document one.com compression, cache templates and optimized Shinylive deployment --- docs/shinylive-deployment.md | 70 ++++++++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/docs/shinylive-deployment.md b/docs/shinylive-deployment.md index b221537..ca78d64 100644 --- a/docs/shinylive-deployment.md +++ b/docs/shinylive-deployment.md @@ -6,24 +6,19 @@ but moves the original, unchanged RDS files out of the initial app.json. ## Build -From the repository root, with shinylive installed: +From the repository root, install `shinylive` and run the optimized export: -```r -system2("Rscript", c("scripts/export-shinylive.R", "bikc.be", - "https://bikc.be/RamplotR/reference-data")) +```bash +Rscript scripts/export-shinylive.R bikc.be https://bikc.be/RamplotR/reference-data ``` -On Windows in an R session you can also use: +On Windows, use `Rscript.exe` from your R installation if it is not on +`PATH`. The second argument must be the public URL of the deployed +reference-data directory. The ordinary `shinylive::export()` command +still creates a full package containing every bundled RDS file, so use the +repository script for the smaller browser deployment. -```r -source("scripts/export-shinylive.R", echo = TRUE) -``` - -The sourced form needs `commandArgs(trailingOnly = TRUE)`, so the -Rscript invocation above is the supported approach. For a custom hostname -or path, pass its actual absolute reference-data URL instead. - -Upload the entire generated `bikc.be` directory to the site root. This +Upload the **contents** of the generated `bikc.be` directory to the site document root. This includes `RamplotR/reference-data`, `RamplotR/app.json`, the assets in `shinylive`, and the static HTML entry point. The data must be available at exactly the URL passed during export. Same-origin hosting is preferred. @@ -106,3 +101,50 @@ no-cache` for the generated `index.html` so deployments refresh. Do not give mutable `app.json` or `reference-data` long immutable caching unless the deployment uses a versioned URL, since a stale manifest paired with new RDS files will correctly fail checksum verification. + +## one.com hosting + +one.com supports Apache `.htaccess` files but restricts some directives. +The export includes three optional, scoped configurations. They **do not** +change the website root `.htaccess` or the setup of other apps: + +| Generated file | Effect | +| --- | --- | +| `RamplotR/.htaccess` | Enables Brotli or gzip for the browser app's HTML, JSON, JS and CSS if the matching Apache module is available. Revalidates `index.html` and `app.json`; caches local assets for one day. | +| `RamplotR/reference-data/.htaccess` | Revalidates the RDS files between deployments. They are already gzip-compressed R objects and should not be recompressed. | +| `shinylive/.htaccess` | Optionally compresses shared webR and WASM assets and caches static assets for one day, but revalidates metadata. An existing shared `.htaccess` is **not overwritten**. If one exists, review and merge the template under `config/onecom/` manually. | + +The files are in `config/onecom/` if you need to inspect or adjust them. +One.com may not allow every `mod_brotli`, `mod_deflate` or `mod_headers` +directive. Conditional module blocks mean missing modules are skipped, but +they do not bypass one.com's hosting restrictions. If you get a 500 error +after uploading, remove the generated `.htaccess` files and ask one.com +support whether those directives are permitted on your hosting plan. +Your Shiny app does not require these rules to function. + +Do not enable the WordPress-specific Performance Cache plugin as a +requirement for this static app. It is separate from ordinary HTTP caching. + +### Confirm HTTP compression and caching + +In a terminal, inspect response headers (use `curl.exe` on Windows if +PowerShell's `curl` alias is active): + +```bash +curl -I -H "Accept-Encoding: br,gzip" https://bikc.be/RamplotR/app.json +curl -I -H "Accept-Encoding: br,gzip" https://bikc.be/RamplotR/favicon.svg +``` + +The `app.json` request should show `Cache-Control: no-cache` when `mod_headers` +is enabled. For text resources, `Content-Encoding: br` or `gzip` indicates +compression is active. No such header means the host is not compressing +that response or the requested file is not found; check with the browser +Network panel using the correct asset URL from the generated page. +For WASM and other webR assets, inspect the actual paths reported in the +Network panel rather than assuming their locations. + +To assess a first visit, disable browser cache and record network transfers +separately from webR startup and R-package initialization. Then reload +with caching enabled. A long download points to hosting and network costs; +slow initialization after all downloads points to webR/package startup +or device CPU. This is useful before making further application changes. From 813d7ae38edf008cf51850e07177f0202aecd17d Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:20:30 +0200 Subject: [PATCH 33/33] Validate optimized export syntax and one.com templates in CI --- .github/workflows/scientific-tests.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/scientific-tests.yml b/.github/workflows/scientific-tests.yml index 501a668..96d8855 100644 --- a/.github/workflows/scientific-tests.yml +++ b/.github/workflows/scientific-tests.yml @@ -23,7 +23,15 @@ jobs: with: use-public-rspm: true - name: Check source syntax - run: Rscript -e 'for (p in c("shinyRam/app.R", list.files("shinyRam/R", full.names=TRUE))) parse(file=p)' + run: Rscript -e 'for (p in c("shinyRam/app.R", "scripts/export-shinylive.R", list.files("shinyRam/R", full.names=TRUE))) parse(file=p)' + shell: bash + - name: Check scoped one.com deployment templates + run: | + test -s config/onecom/ramplotr.htaccess + test -s config/onecom/shinylive.htaccess + test -s config/onecom/reference-data.htaccess + grep -q 'shinylive.htaccess' scripts/export-shinylive.R + grep -q 'reference-data.htaccess' scripts/export-shinylive.R shell: bash - name: Check the responsive interface and preserved Shiny IDs run: Rscript tests/ui-contract.R