diff --git a/.github/workflows/scientific-tests.yml b/.github/workflows/scientific-tests.yml
index e399a76..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
@@ -33,6 +41,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 +75,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
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.
diff --git a/config/onecom/ramplotr.htaccess b/config/onecom/ramplotr.htaccess
new file mode 100644
index 0000000..e649f28
--- /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"
+
+
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"
+
diff --git a/config/onecom/shinylive.htaccess b/config/onecom/shinylive.htaccess
new file mode 100644
index 0000000..d050e15
--- /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"
+
+
diff --git a/docs/shinylive-deployment.md b/docs/shinylive-deployment.md
new file mode 100644
index 0000000..ca78d64
--- /dev/null
+++ b/docs/shinylive-deployment.md
@@ -0,0 +1,150 @@
+# 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, install `shinylive` and run the optimized export:
+
+```bash
+Rscript scripts/export-shinylive.R bikc.be https://bikc.be/RamplotR/reference-data
+```
+
+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.
+
+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.
+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.
+
+## 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.
+
+## 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.
diff --git a/scripts/export-shinylive.R b/scripts/export-shinylive.R
new file mode 100644
index 0000000..3924613
--- /dev/null
+++ b/scripts/export-shinylive.R
@@ -0,0 +1,128 @@
+# 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")
+ }
+ 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")
+ }
+ }
+ # 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")
+ # 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"),
+ 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)
+}
+
+ramplotr_export_shinylive(commandArgs(trailingOnly = TRUE))
diff --git a/shinyRam/R/ramachandran.R b/shinyRam/R/ramachandran.R
index 3bdaa94..dc32a4d 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) {
+ 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)
@@ -91,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")
diff --git a/shinyRam/R/reference-loader.R b/shinyRam/R/reference-loader.R
new file mode 100644
index 0000000..b80a760
--- /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)
+}
diff --git a/shinyRam/app.R b/shinyRam/app.R
index 98299c2..3eb8944 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)
@@ -94,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",
@@ -536,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")
@@ -584,7 +586,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],
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) {
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 @@
+
diff --git a/shinyRam/www/plotly-loader.js b/shinyRam/www/plotly-loader.js
new file mode 100644
index 0000000..6ed5313
--- /dev/null
+++ b/shinyRam/www/plotly-loader.js
@@ -0,0 +1,47 @@
+/* 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;
+ };
+
+ // 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);
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 = [];
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;
+});
diff --git a/tests/reference-loader.R b/tests/reference-loader.R
new file mode 100644
index 0000000..e459a27
--- /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)
+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"))
+unlink(root, recursive = TRUE)
+cat("Local reference-loader and manifest checks passed.\n")
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"));
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),