From ffcd4e991bea78e4733e1e8f7518763785929c04 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Tue, 20 Oct 2026 21:14:37 +0200 Subject: [PATCH 01/31] build(wasm): cross-compile IntaRNA and its dependencies (#286) --- .github/workflows/build-wasm.yml | 31 +++++++++++++ .gitignore | 5 ++ ChangeLog | 10 ++++ configure.ac | 5 +- docs/step1-wasm-compilation.md | 56 ++++++++++++++++++++++ scripts/wasm/build.sh | 80 ++++++++++++++++++++++++++++++++ scripts/wasm/smoke.mjs | 24 ++++++++++ 7 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build-wasm.yml create mode 100644 docs/step1-wasm-compilation.md create mode 100644 scripts/wasm/build.sh create mode 100644 scripts/wasm/smoke.mjs diff --git a/.github/workflows/build-wasm.yml b/.github/workflows/build-wasm.yml new file mode 100644 index 00000000..5c7b493e --- /dev/null +++ b/.github/workflows/build-wasm.yml @@ -0,0 +1,31 @@ +name: WebAssembly and web interface + +on: + pull_request: + branches: [master] + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + - uses: emscripten-core/setup-emsdk@v15 + with: + version: '4.0.23' + actions-cache-folder: emsdk-cache + - name: Install native build tools + run: sudo apt-get update && sudo apt-get install --yes autoconf automake libtool pkg-config + - name: Cross-compile dependencies and IntaRNA + run: bash scripts/wasm/build.sh + - uses: actions/upload-artifact@v4 + with: + name: intarna-wasm + path: build-wasm/site + if-no-files-found: error diff --git a/.gitignore b/.gitignore index ee819cc0..6a616f0f 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,8 @@ python/intarnapvalue.* /IntaRNA.pc /IntaRNA.exe.stackdump /py-compile + +# Browser build outputs and local test dependencies +/build-wasm/ +/webinterface/wasm/ +/webinterface/node_modules/ diff --git a/ChangeLog b/ChangeLog index 09158edc..9407b9fb 100644 --- a/ChangeLog +++ b/ChangeLog @@ -68,6 +68,8 @@ ## Technical changes and Optimizations +- cross-compile IntaRNA and pinned dependencies to WebAssembly (#286, step 1) + - reuse MFE seed-extension scratch matrices and bound predecessor scans in exact and heuristic prediction; evaluate physical energies directly (PR #276 review) @@ -221,6 +223,14 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261020 Alexander Mitrofanov + * scripts/wasm/*, .github/workflows/build-wasm.yml, configure.ac + + Build pinned zlib, Boost and ViennaRNA for wasm32, then the existing + IntaRNA CLI as an isolated ES module with MEMFS and callMain exports. + * Omit pthread flags for single-thread Emscripten builds; retain native flags. + + Document the toolchain and validate native-reference predictions in WASM + (https://github.com/BackofenLab/IntaRNA/issues/286, step 1). + 261020 Alexander Mitrofanov * PredictorMfe2dSeedExtension.*, PredictorMfe2dHeuristicSeedExtension.cpp - Remove extensionLoopE(), the local loop-energy table, known-value flags, diff --git a/configure.ac b/configure.ac index aeb4e0c9..11d72849 100644 --- a/configure.ac +++ b/configure.ac @@ -252,7 +252,10 @@ AS_IF([test x"$multithreadingEnabled" = x"yes"], [ ], [ AC_DEFINE([INTARNA_MULITHREADING], [0], [Disabling multi-threading support]) AC_SUBST([INTARNA_MULITHREADING],[0]) - AM_CXXFLAGS="$AM_CXXFLAGS -pthread" + # Emscripten's single-thread runtime supplies mutex stubs. -pthread would + # instead require SharedArrayBuffer and cross-origin isolation on Pages. + AS_CASE([$host_os], [emscripten*], [], + [AM_CXXFLAGS="$AM_CXXFLAGS -pthread"]) ]) ############################################################################### diff --git a/docs/step1-wasm-compilation.md b/docs/step1-wasm-compilation.md new file mode 100644 index 00000000..aa6efeb6 --- /dev/null +++ b/docs/step1-wasm-compilation.md @@ -0,0 +1,56 @@ +# Step 1: WebAssembly compilation (#286) + +`scripts/wasm/build.sh` cross-compiles the unmodified CLI entry point and the +Autotools library source lists. `.github/workflows/build-wasm.yml` uses the official +[setup-emsdk action](https://github.com/emscripten-core/setup-emsdk) with Emscripten +4.0.23. It runs on pull requests, pushes to this repository's `master` branch, +and manual dispatches. Build tools (Autotools, pkg-config, a native C++ compiler, +curl, tar and bzip2) must be available. + +## Reproduce locally + +Activate Emscripten 4.0.23 with `source /path/to/emsdk/emsdk_env.sh`, then run: + +```sh +bash scripts/wasm/build.sh +node scripts/wasm/smoke.mjs +``` + +`WASM_BUILD_DIR` changes the build directory; `JOBS` defaults to 2. Source +archives are downloaded into `build-wasm/downloads` and verified by pinned +SHA-256 hashes on every invocation. The build directory is ignored. Delete +`build-wasm/deps` and `build-wasm/sources` for a fresh dependency build after +changing dependency versions or compiler flags. Do not reuse this prefix with a +different SDK. Native libraries are never accepted from system pkg-config paths. + +## Dependencies and runtime + +- zlib 1.3.1: static library for compressed accessibility and Boost.Iostreams. +- Boost 1.87.0: headers plus static filesystem, program_options, regex, + iostreams, serialization and system libraries, built with Boost.Build's + Emscripten toolset. Optional bzip2, lzma and zstd backends are disabled. +- ViennaRNA 2.7.0: static RNAlib, without OpenMP, pthreads, SIMD, external + solvers, bindings, documentation or companion applications. Compiling only + RNAlib avoids executing cross-compiled helper applications on the host. +- IntaRNA: C++23, bundled Kokkos mdspan, standard CLI, default energy models + and accessibility calculations. Single-thread Emscripten builds omit + `-pthread`; native builds retain their existing threading flags. + +All C++ compilation and linking enables exceptions (`-fexceptions`), including +Boost and the CLI's input/error handling. The SDK's single-thread mutex support +retains ViennaRNA's normal setup/logger locking without requiring browser +`SharedArrayBuffer` or COOP/COEP headers (unavailable on ordinary GitHub Pages). + +The output is an ES module factory (`createIntaRNA`) in +`build-wasm/site/wasm/intarna.js`, paired with `intarna.wasm`. The factory does +not run main automatically; it exports `FS` and `callMain`. The runtime exits +and flushes C++ streams after one call. Each prediction must use a new instance. +Memory grows on demand to a maximum of 1 GiB; stack size is 8 MiB. Allocation +failure is reported as a failed prediction; devices may impose lower limits. + +See [Emscripten's Autotools guidance](https://emscripten.org/docs/compiling/Building-Projects.html) +for `emconfigure`/`emmake`. Generated configure files, dependency sources and WASM +binaries are build outputs, not tracked source. The artifact must keep JS and +WASM from the same build together. The Node smoke test runs a real +Turner/default-accessibility prediction and checks its energy, coordinates and +suboptimal count against the native CLI. diff --git a/scripts/wasm/build.sh b/scripts/wasm/build.sh new file mode 100644 index 00000000..1c6812e1 --- /dev/null +++ b/scripts/wasm/build.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Build every linked dependency for wasm32; never reuse native libraries. +set -euo pipefail +repo=$(cd "$(dirname "$0")/../.." && pwd) +build=${WASM_BUILD_DIR:-"$repo/build-wasm"} +mkdir -p "$build" +build=$(cd "$build" && pwd) +prefix="$build/deps" +jobs=${JOBS:-2} +for tool in emcc em++ emar emranlib emconfigure emmake curl pkg-config; do + command -v "$tool" >/dev/null || { echo "Missing tool: $tool" >&2; exit 1; } +done +mkdir -p "$build/downloads" "$build/sources" "$prefix" "$build/site/wasm" +fetch() { + local url=$1 name=$2 checksum=$3 + if [[ ! -f "$build/downloads/$name" ]]; then + curl --fail --location --retry 3 "$url" -o "$build/downloads/$name" + fi + echo "$checksum $build/downloads/$name" | sha256sum --check - +} +fetch https://zlib.net/fossils/zlib-1.3.1.tar.gz zlib-1.3.1.tar.gz \ + 9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23 +fetch https://archives.boost.io/release/1.87.0/source/boost_1_87_0.tar.bz2 boost_1_87_0.tar.bz2 \ + af57be25cb4c4f4b413ed692fe378affb4352ea50fbe294a11ef548f4d527d89 +fetch https://www.tbi.univie.ac.at/RNA/download/sourcecode/2_7_x/ViennaRNA-2.7.0.tar.gz ViennaRNA-2.7.0.tar.gz \ + e2addf33e7c0dd4fff31d8e9a46fda8e9efcde0d199f789f20eb0a13b7f7135d + +# Keep pkg-config searches inside the WASM prefix, including configure probes. +export PKG_CONFIG_LIBDIR="$prefix/lib/pkgconfig" +export PKG_CONFIG_PATH="$PKG_CONFIG_LIBDIR" +export CFLAGS='-O3 -fexceptions' +export CXXFLAGS='-O3 -fexceptions' +export CPPFLAGS="-I$prefix/include" +export LDFLAGS="-L$prefix/lib -fexceptions" +if [[ ! -f "$prefix/lib/libz.a" ]]; then + tar -xzf "$build/downloads/zlib-1.3.1.tar.gz" -C "$build/sources" + (cd "$build/sources/zlib-1.3.1" + emconfigure ./configure --static --prefix="$prefix" + emmake make -j "$jobs" + emmake make install) +fi +if [[ ! -f "$prefix/lib/libboost_serialization.a" ]]; then + tar -xjf "$build/downloads/boost_1_87_0.tar.bz2" -C "$build/sources" + (cd "$build/sources/boost_1_87_0" + ./bootstrap.sh --with-libraries=filesystem,program_options,regex,iostreams,serialization,system + ./b2 -j "$jobs" toolset=emscripten variant=release link=static threading=single \ + --with-filesystem --with-program_options --with-regex --with-iostreams \ + --with-serialization --with-system cxxflags="-std=c++23 -fexceptions" \ + -sNO_BZIP2=1 -sNO_LZMA=1 -sNO_ZSTD=1 -sZLIB_INCLUDE="$prefix/include" \ + -sZLIB_LIBPATH="$prefix/lib" --prefix="$prefix" install) +fi +if [[ ! -f "$prefix/lib/libRNA.a" ]]; then + tar -xzf "$build/downloads/ViennaRNA-2.7.0.tar.gz" -C "$build/sources" + (cd "$build/sources/ViennaRNA-2.7.0" + emconfigure ./configure --host=wasm32-unknown-emscripten --prefix="$prefix" \ + --disable-shared --enable-static --disable-openmp --disable-pthreads \ + --disable-lto --disable-simd --disable-vectorize --disable-mpfr \ + --without-svm --without-gsl --without-swig --without-perl --without-python \ + --without-doc --without-cla --without-check --without-kinfold \ + --without-forester --without-rnalocmin --without-rnaxplorer + emmake make -C src/ViennaRNA -j "$jobs" + emmake make -C src/ViennaRNA install + emmake make install-pkgconfigDATA) +fi + +# Use the native Autotools source lists and CLI entry point, with a JS output. +(cd "$repo" && bash autotools-init.sh) +mkdir -p "$build/intarna" +(cd "$build/intarna" + emconfigure "$repo/configure" --host=wasm32-unknown-emscripten \ + --disable-multithreading --disable-log-coloring --with-mdspan=kokkos \ + --with-vrna="$prefix" --with-boost="$prefix" \ + --with-boost-libdir="$prefix/lib" --with-zlib="$prefix" + emmake make -C src -j "$jobs" EXEEXT=.js \ + LDFLAGS="$LDFLAGS -sMODULARIZE=1 -sEXPORT_ES6=1 -sEXPORT_NAME=createIntaRNA -sENVIRONMENT=web,worker,node -sINVOKE_RUN=0 -sEXIT_RUNTIME=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1073741824 -sSTACK_SIZE=8388608" + cp src/bin/IntaRNA.js "$build/site/wasm/intarna.js" + cp src/bin/IntaRNA.wasm "$build/site/wasm/intarna.wasm") +# ES modules also work in Node smoke tests without changing package scope. +printf '{"type":"module"}\n' > "$build/site/wasm/package.json" +echo "WASM assets: $build/site/wasm" diff --git a/scripts/wasm/smoke.mjs b/scripts/wasm/smoke.mjs new file mode 100644 index 00000000..7e7152b9 --- /dev/null +++ b/scripts/wasm/smoke.mjs @@ -0,0 +1,24 @@ +// Exercise the actual CLI and RNAlib in a fresh WASM instance. +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +const asset = resolve(process.argv[2] || 'build-wasm/site/wasm/intarna.js'); +const { default: createIntaRNA } = await import(pathToFileURL(asset)); +const diagnostics = []; +const module = await createIntaRNA({ + noInitialRun: true, + print: (line) => diagnostics.push(line), + printErr: (line) => diagnostics.push(line), +}); +const code = module.callMain([ + '--query=GGGAGGAGGG', '--target=CCCUCCUCCC', '--outMode=C', + '--outNumber=10', '--outOverlap=B', + '--outCsvCols=id1,start1,end1,id2,start2,end2,E', '--out=/results.csv', +]); +assert.equal(code, 0, diagnostics.join('\n')); +const csv = module.FS.readFile('/results.csv', { encoding: 'utf8' }); +assert.match(csv, /^id1;start1;end1;id2;start2;end2;E\n/); +// Reference from the native CLI with the same Turner/default-accessibility model. +assert.equal(csv.trim().split('\n')[1], 'target;1;10;query;1;10;-21.4'); +assert.equal(csv.trim().split('\n').length, 11); +console.log('WASM CLI: native-reference energy/coordinates and 10 suboptimals passed'); From dda614b7f4c64e79e3b3f1ebdad95b6e1d292c76 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Tue, 20 Oct 2026 21:17:38 +0200 Subject: [PATCH 02/31] build(wasm): adapt bundled dlib to current libc++ and Clang --- docs/step1-wasm-compilation.md | 9 +++++++++ scripts/wasm/build.sh | 5 ++++- scripts/wasm/patch-vienna.py | 25 +++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 scripts/wasm/patch-vienna.py diff --git a/docs/step1-wasm-compilation.md b/docs/step1-wasm-compilation.md index aa6efeb6..5e943f8f 100644 --- a/docs/step1-wasm-compilation.md +++ b/docs/step1-wasm-compilation.md @@ -23,6 +23,15 @@ SHA-256 hashes on every invocation. The build directory is ignored. Delete changing dependency versions or compiler flags. Do not reuse this prefix with a different SDK. Native libraries are never accepted from system pkg-config paths. +The script supplies absolute source and generated-header include paths for the +out-of-tree IntaRNA build. ViennaRNA 2.7.0 bundles dlib 19.24, whose Unicode +`uint32` string requires a generic `std::char_traits` removed in modern libc++. +The script backports [dlib's upstream `char32_t` typedef](https://github.com/davisking/dlib/blob/master/dlib/unicode/unicode.h) +in the downloaded source only, adds its matching serialization overload and +supplies a missing dependent template argument rejected by current Clang. +`scripts/wasm/patch-vienna.py` checks the expected source before each replacement. +It does not change the RNA algorithms. + ## Dependencies and runtime - zlib 1.3.1: static library for compressed accessibility and Boost.Iostreams. diff --git a/scripts/wasm/build.sh b/scripts/wasm/build.sh index 1c6812e1..acf42205 100644 --- a/scripts/wasm/build.sh +++ b/scripts/wasm/build.sh @@ -58,6 +58,9 @@ if [[ ! -f "$prefix/lib/libRNA.a" ]]; then --without-svm --without-gsl --without-swig --without-perl --without-python \ --without-doc --without-cla --without-check --without-kinfold \ --without-forester --without-rnalocmin --without-rnaxplorer + # Backport dlib's upstream char32_t typedef for modern libc++ char_traits. + # Only the downloaded dependency is patched; RNAlib algorithms are unchanged. + python3 "$repo/scripts/wasm/patch-vienna.py" "$PWD" emmake make -C src/ViennaRNA -j "$jobs" emmake make -C src/ViennaRNA install emmake make install-pkgconfigDATA) @@ -67,7 +70,7 @@ fi (cd "$repo" && bash autotools-init.sh) mkdir -p "$build/intarna" (cd "$build/intarna" - emconfigure "$repo/configure" --host=wasm32-unknown-emscripten \ + CPPFLAGS="-I$build/intarna/src -I$repo/src $CPPFLAGS" emconfigure "$repo/configure" --host=wasm32-unknown-emscripten \ --disable-multithreading --disable-log-coloring --with-mdspan=kokkos \ --with-vrna="$prefix" --with-boost="$prefix" \ --with-boost-libdir="$prefix/lib" --with-zlib="$prefix" diff --git a/scripts/wasm/patch-vienna.py b/scripts/wasm/patch-vienna.py new file mode 100644 index 00000000..4b636aad --- /dev/null +++ b/scripts/wasm/patch-vienna.py @@ -0,0 +1,25 @@ +"""Apply narrow dlib 19.24 compatibility fixes in the downloaded ViennaRNA tree. + +Modern libc++ only defines char_traits for standard character types. dlib now +uses char32_t upstream. Its old serialization overloads and dependent template +call also need the matching C++ fixes. RNA models and recurrences are untouched. +""" +import sys +from pathlib import Path + +root = Path(sys.argv[1]) / "src/dlib-19.24/dlib" +patches = [ + ("unicode/unicode.h", "typedef uint32 unichar;", "typedef char32_t unichar;"), + ("serialize.h", " USE_DEFAULT_INT_SERIALIZATION_FOR(wchar_t)", + " USE_DEFAULT_INT_SERIALIZATION_FOR(wchar_t)\n USE_DEFAULT_INT_SERIALIZATION_FOR(char32_t)"), + ("global_optimization/find_max_global.h", "::template go(std::forward(f),a)", + "::template go(std::forward(f),a)"), +] +for filename, before, after in patches: + path = root / filename + source = path.read_text() + if after in source: + continue + if source.count(before) != 1: + raise SystemExit(f"Unexpected dlib source: {path}; review compatibility patch") + path.write_text(source.replace(before, after)) From ccc376f17925f192f596f8ebe0c19629fd52a730 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Tue, 20 Oct 2026 21:17:38 +0200 Subject: [PATCH 03/31] feat(web): isolate predictions in cancellable workers (#286 step 2) --- ChangeLog | 9 +++ .../docs/step2-worker-architecture.md | 25 +++++++++ webinterface/intarna-worker.js | 33 +++++++++++ webinterface/package.json | 6 ++ webinterface/protocol.js | 55 +++++++++++++++++++ webinterface/runner.js | 49 +++++++++++++++++ webinterface/tests/worker.test.mjs | 50 +++++++++++++++++ 7 files changed, 227 insertions(+) create mode 100644 webinterface/docs/step2-worker-architecture.md create mode 100644 webinterface/intarna-worker.js create mode 100644 webinterface/package.json create mode 100644 webinterface/protocol.js create mode 100644 webinterface/runner.js create mode 100644 webinterface/tests/worker.test.mjs diff --git a/ChangeLog b/ChangeLog index 9407b9fb..42b319c4 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,8 @@ ## Interface and handling +- run browser predictions in cancellable, isolated Web Workers (#286, step 2) + - orient probability SVGs from the upper-left corner and highlight every MFE pair, splitting seed/MFE cell borders into orange and green (PR #259 review) @@ -223,6 +225,13 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261020 Alexander Mitrofanov + * webinterface/protocol.js, intarna-worker.js, runner.js, tests/* + + Validate FASTA at the worker boundary, run the existing CLI with MEMFS + input/output, and report CSV and bounded diagnostics asynchronously. + + Terminate each worker on completion, failure or abort; test clean restart + and stale-message handling (#286, step 2); document the worker protocol. + 261020 Alexander Mitrofanov * scripts/wasm/*, .github/workflows/build-wasm.yml, configure.ac + Build pinned zlib, Boost and ViennaRNA for wasm32, then the existing diff --git a/webinterface/docs/step2-worker-architecture.md b/webinterface/docs/step2-worker-architecture.md new file mode 100644 index 00000000..24a28800 --- /dev/null +++ b/webinterface/docs/step2-worker-architecture.md @@ -0,0 +1,25 @@ +# Step 2: worker and I/O architecture (#286) + +`PredictionRunner` creates an ES module Web Worker on demand. The main thread +sends `{query, target, count}` as text and receives `status`, `result` or `error` +messages. `protocol.js` validates both the UI and worker boundary and constructs +fixed CLI options; users cannot inject CLI arguments or file paths. + +The worker lazily imports `wasm/intarna.js`, awaits `createIntaRNA`, writes +normalized single-record FASTA to `/query.fa` and `/target.fa` in MEMFS, and calls +`callMain` once. It selects semicolon-delimited CSV output in `/results.csv` and +reads that file after main returns and the C++ output streams close. Logs are +separate from CSV, bounded to the most recent 25 lines of 2,000 characters. +Nonzero exit codes, missing assets, allocation failures and JS errors reject +the prediction. Warnings travel with successful results as diagnostics. + +Every computation gets fresh C++ globals, RNAlib state and files. Both success +and failure terminate the worker. Abort uses `Worker.terminate()` from the main +thread, so it works during download, initialization and synchronous C++ execution. +Posting an abort message to a busy worker would not interrupt C++. Cancellation +rejects with `AbortError`, releases references and ignores queued stale messages; +the next computation creates a fresh worker. No shared memory, cross-origin +isolation, server, sequence upload or persistent sequence storage is needed. + +Run protocol and lifecycle tests with `cd webinterface && npm test`. Browser +integration tests exercise the real worker and WASM assets after staging. diff --git a/webinterface/intarna-worker.js b/webinterface/intarna-worker.js new file mode 100644 index 00000000..979aecc3 --- /dev/null +++ b/webinterface/intarna-worker.js @@ -0,0 +1,33 @@ +import { prepareRequest, makeArguments } from './protocol.js'; + +// One main() call per worker: C++ globals and MEMFS never cross predictions. +let started = false; +self.onmessage = async ({ data }) => { + if (started) return; + started = true; + const diagnostics = []; + const log = (line) => { + diagnostics.push(String(line).slice(0, 2000)); + if (diagnostics.length > 25) diagnostics.shift(); + }; + try { + // Validate again at the worker boundary before allocating the WASM runtime. + const request = prepareRequest(data); + self.postMessage({ type: 'status', message: 'Loading the prediction engine…' }); + const { default: createIntaRNA } = await import('./wasm/intarna.js'); + const module = await createIntaRNA({ noInitialRun: true, print: log, printErr: log }); + module.FS.writeFile('/query.fa', request.query.fasta); + module.FS.writeFile('/target.fa', request.target.fasta); + self.postMessage({ type: 'status', message: 'Predicting RNA interactions…' }); + const code = module.callMain(makeArguments(request.count)); + if (code !== 0) throw new Error(`IntaRNA exited with status ${code}.`); + const csv = module.FS.readFile('/results.csv', { encoding: 'utf8' }); + self.postMessage({ type: 'result', csv, diagnostics: diagnostics.join('\n') }); + } catch (error) { + self.postMessage({ + type: 'error', + message: error instanceof Error ? error.message : String(error), + diagnostics: diagnostics.join('\n'), + }); + } +}; diff --git a/webinterface/package.json b/webinterface/package.json new file mode 100644 index 00000000..9657fd7e --- /dev/null +++ b/webinterface/package.json @@ -0,0 +1,6 @@ +{ + "name": "intarna-webinterface", + "private": true, + "type": "module", + "scripts": { "test": "node --test tests/*.test.mjs" } +} diff --git a/webinterface/protocol.js b/webinterface/protocol.js new file mode 100644 index 00000000..7ed9da91 --- /dev/null +++ b/webinterface/protocol.js @@ -0,0 +1,55 @@ +// Browser limits bound memory use; the native CLI remains unrestricted. +export const LIMITS = Object.freeze({ minLength: 7, maxLength: 2000, maxPairs: 1000000 }); +export const CSV_COLUMNS = 'id1,start1,end1,id2,start2,end2,E'; +export const DEFAULTS = Object.freeze({ + query: '>query\nGGGAGGAGGG', + target: '>target\nCCCUCCUCCC', + count: 10, +}); + +export function parseSequence(text, label) { + if (typeof text !== 'string' || text.length > 10000) { + throw new Error(`${label}: enter one sequence of at most ${LIMITS.maxLength} nucleotides.`); + } + const lines = text.trim().split(/\r?\n/).map((line) => line.trim()); + let id = label.toLowerCase(); + if (lines[0].startsWith('>')) { + id = lines.shift().slice(1).trim(); + if (!id || id.length > 200 || /[\x00-\x1f\x7f]/.test(id)) { + throw new Error(`${label}: the FASTA header needs a name (at most 200 characters).`); + } + } + if (lines.some((line) => line.includes('>'))) { + throw new Error(`${label}: enter exactly one FASTA record, with its header on the first line.`); + } + const sequence = lines.join('').replace(/\s/g, '').toUpperCase(); + if (!sequence || /[^ACGUTN]/.test(sequence)) { + throw new Error(`${label}: use only A, C, G, U, T or N in the sequence.`); + } + if (sequence.length < LIMITS.minLength || sequence.length > LIMITS.maxLength) { + throw new Error(`${label}: use ${LIMITS.minLength}–${LIMITS.maxLength} nucleotides (the default seed needs 7).`); + } + const rna = sequence.replace(/T/g, 'U'); + return { id, sequence: rna, fasta: `>${id}\n${rna}\n` }; +} + +export function prepareRequest(request) { + const query = parseSequence(request?.query, 'Query'); + const target = parseSequence(request?.target, 'Target'); + if (query.sequence.length * target.sequence.length > LIMITS.maxPairs) { + throw new Error('For this browser version, query length × target length must be at most 1,000,000. Use shorter sequences or the command line.'); + } + const count = Number(request.count); + if (!Number.isInteger(count) || count < 1 || count > 100) { + throw new Error('Choose between 1 and 100 interactions.'); + } + return { query, target, count }; +} + +export function makeArguments(count) { + return [ + '--query=/query.fa', '--target=/target.fa', '--outMode=C', + `--outNumber=${count}`, '--outOverlap=B', + `--outCsvCols=${CSV_COLUMNS}`, '--out=/results.csv', + ]; +} diff --git a/webinterface/runner.js b/webinterface/runner.js new file mode 100644 index 00000000..91ccc6b2 --- /dev/null +++ b/webinterface/runner.js @@ -0,0 +1,49 @@ +// The main thread stays available even while synchronous C++ is calculating. +export class PredictionRunner { + constructor(workerFactory = () => new Worker(new URL('./intarna-worker.js', import.meta.url), { type: 'module' })) { + this.workerFactory = workerFactory; + this.worker = null; + this.cancel = null; + } + + run(request, onStatus = () => {}) { + if (this.worker) return Promise.reject(new Error('A prediction is already running.')); + return new Promise((resolve, reject) => { + const worker = this.workerFactory(); + this.worker = worker; + const finish = (error, result) => { + if (this.worker !== worker) return; // Ignore queued messages after abort. + worker.terminate(); + this.worker = null; + this.cancel = null; + if (error) reject(error); + else resolve(result); + }; + this.cancel = () => finish(new DOMException('Computation aborted.', 'AbortError')); + worker.onmessage = ({ data }) => { + if (this.worker !== worker) return; + if (data.type === 'status') onStatus(data.message); + else if (data.type === 'result') finish(null, data); + else if (data.type === 'error') { + const error = new Error(data.message); + error.diagnostics = data.diagnostics; + finish(error); + } + }; + worker.onerror = (event) => { + event.preventDefault(); + finish(new Error('Could not load or run the prediction engine. Reload the page and check that intarna.js and intarna.wasm are served together.')); + }; + worker.onmessageerror = () => finish(new Error('Could not read the prediction engine response.')); + try { + worker.postMessage(request); + } catch (error) { + finish(error); + } + }); + } + + abort() { + this.cancel?.(); + } +} diff --git a/webinterface/tests/worker.test.mjs b/webinterface/tests/worker.test.mjs new file mode 100644 index 00000000..689f59ed --- /dev/null +++ b/webinterface/tests/worker.test.mjs @@ -0,0 +1,50 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { PredictionRunner } from '../runner.js'; +import { DEFAULTS, parseSequence, prepareRequest, makeArguments } from '../protocol.js'; + +test('FASTA normalization and boundary validation', () => { + assert.equal(parseSequence('>test\r\nacgt n\r\nACG', 'Query').fasta, '>test\nACGUNACG\n'); + for (const input of ['', 'ACGU', '>\nACGUACGU', '>one\nACGUACGU\n>two\nACGUACGU', 'ACGU-ACGU', 'ACGU\n>bad']) { + assert.throws(() => parseSequence(input, 'Query')); + } + assert.equal(prepareRequest(DEFAULTS).query.sequence, 'GGGAGGAGGG'); + assert.throws(() => prepareRequest({ ...DEFAULTS, query: 'A'.repeat(2001) })); + assert.throws(() => prepareRequest({ ...DEFAULTS, query: 'A'.repeat(1001), target: 'C'.repeat(1000) })); + for (const count of [0, 101, 1.5, NaN, '--help']) assert.throws(() => prepareRequest({ ...DEFAULTS, count })); + assert.ok(makeArguments(10).includes('--out=/results.csv')); +}); + +function fakeWorker() { + return { terminated: false, terminate() { this.terminated = true; }, postMessage() {}, send(data) { this.onmessage({ data }); } }; +} + +test('abort terminates computation, rejects and permits a clean restart', async () => { + const workers = []; + const runner = new PredictionRunner(() => { const worker = fakeWorker(); workers.push(worker); return worker; }); + const first = runner.run(DEFAULTS); + await assert.rejects(runner.run(DEFAULTS), /already running/); + runner.abort(); + await assert.rejects(first, { name: 'AbortError' }); + assert.equal(workers[0].terminated, true); + const second = runner.run(DEFAULTS); + workers[0].send({ type: 'result', csv: 'stale' }); + workers[1].send({ type: 'result', csv: 'fresh' }); + assert.equal((await second).csv, 'fresh'); + assert.equal(workers[1].terminated, true); +}); + +test('runtime, load and message errors release the worker', async () => { + for (const kind of ['error', 'load', 'message', 'post']) { + const worker = fakeWorker(); + if (kind === 'post') worker.postMessage = () => { throw new Error('post failed'); }; + const runner = new PredictionRunner(() => worker); + const result = runner.run(DEFAULTS); + if (kind === 'error') worker.send({ type: 'error', message: 'allocation failed', diagnostics: 'details' }); + if (kind === 'load') worker.onerror({ preventDefault() {} }); + if (kind === 'message') worker.onmessageerror(); + await assert.rejects(result); + assert.equal(worker.terminated, true); + assert.equal(runner.worker, null); + } +}); From e0333ac217569f99054fe829836664b31f8b1888 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Tue, 20 Oct 2026 21:19:48 +0200 Subject: [PATCH 04/31] feat(web): add responsive FASTA prediction workspace (#286 step 3) --- ChangeLog | 9 +++ webinterface/app.js | 94 ++++++++++++++++++++++++++ webinterface/docs/step3-frontend-ui.md | 50 ++++++++++++++ webinterface/index.html | 71 +++++++++++++++++++ webinterface/mark.svg | 1 + webinterface/styles.css | 67 ++++++++++++++++++ 6 files changed, 292 insertions(+) create mode 100644 webinterface/app.js create mode 100644 webinterface/docs/step3-frontend-ui.md create mode 100644 webinterface/index.html create mode 100644 webinterface/mark.svg create mode 100644 webinterface/styles.css diff --git a/ChangeLog b/ChangeLog index 42b319c4..97d09369 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,8 @@ ## Interface and handling +- add responsive browser FASTA inputs, validation and abort dialog (#286, step 3) + - run browser predictions in cancellable, isolated Web Workers (#286, step 2) - orient probability SVGs from the upper-left corner and highlight every MFE @@ -225,6 +227,13 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261020 Alexander Mitrofanov + * webinterface/index.html, app.js, styles.css, mark.svg, docs/* + + Add paired query/target editors, a valid example, bounded RNA/FASTA + validation and result counts; normalize lowercase and DNA input. + + Keep the form responsive during predictions, display errors/diagnostics, + and abort by button or Escape (#286, step 3). + 261020 Alexander Mitrofanov * webinterface/protocol.js, intarna-worker.js, runner.js, tests/* + Validate FASTA at the worker boundary, run the existing CLI with MEMFS diff --git a/webinterface/app.js b/webinterface/app.js new file mode 100644 index 00000000..f8e124c9 --- /dev/null +++ b/webinterface/app.js @@ -0,0 +1,94 @@ +import { DEFAULTS, parseSequence, prepareRequest } from './protocol.js'; +import { PredictionRunner } from './runner.js'; + +const byId = (id) => document.getElementById(id); +const form = byId('prediction-form'); +const runner = new PredictionRunner(); +const dialog = byId('loading'); +const status = byId('result-status'); +const errorBox = byId('input-error'); + +function clearResults(message = 'Inputs changed. Compute to update the interactions.') { + byId('csv-preview').hidden = true; + byId('csv-preview').textContent = ''; + byId('diagnostics').hidden = true; + status.textContent = message; +} + +function updateLength(name) { + try { + const rna = parseSequence(byId(name).value, name === 'query' ? 'Query' : 'Target'); + byId(`${name}-length`).textContent = `${rna.sequence.length.toLocaleString()} nucleotides`; + } catch { + byId(`${name}-length`).textContent = 'Check sequence format and length'; + } +} + +function setBusy(busy) { + byId('inputs').disabled = busy; + byId('results').setAttribute('aria-busy', String(busy)); + if (busy) dialog.showModal(); + else { + dialog.close(); + byId('compute').focus(); + } +} + +for (const name of ['query', 'target', 'count']) { + byId(name).addEventListener('input', () => { + if (name !== 'count') updateLength(name); + errorBox.hidden = true; + clearResults(); + }); +} +byId('example').addEventListener('click', () => { + for (const name of ['query', 'target', 'count']) byId(name).value = DEFAULTS[name]; + updateLength('query'); + updateLength('target'); + errorBox.hidden = true; + clearResults('Example loaded. Compute to predict its interactions.'); +}); +byId('abort').addEventListener('click', () => runner.abort()); +dialog.addEventListener('cancel', (event) => { event.preventDefault(); runner.abort(); }); +window.addEventListener('pagehide', () => runner.abort()); + +form.addEventListener('submit', async (event) => { + event.preventDefault(); + const request = { query: byId('query').value, target: byId('target').value, count: Number(byId('count').value) }; + errorBox.hidden = true; + try { + prepareRequest(request); + } catch (error) { + errorBox.textContent = error.message; + errorBox.hidden = false; + return; + } + clearResults('Computing interactions…'); + byId('loading-status').textContent = 'Loading the prediction engine…'; + setBusy(true); + try { + const result = await runner.run(request, (message) => { byId('loading-status').textContent = message; }); + byId('csv-preview').textContent = result.csv; + byId('csv-preview').hidden = false; + status.textContent = 'Prediction complete. Coordinates are 1-based and inclusive.'; + byId('diagnostic-text').textContent = result.diagnostics; + byId('diagnostics').hidden = !result.diagnostics; + } catch (error) { + if (error.name === 'AbortError') status.textContent = 'Computation aborted. You can edit the sequences and try again.'; + else { + status.textContent = 'Prediction failed.'; + errorBox.textContent = error.message; + errorBox.hidden = false; + byId('diagnostic-text').textContent = error.diagnostics || ''; + byId('diagnostics').hidden = !error.diagnostics; + } + } finally { + setBusy(false); + } +}); + +if (!globalThis.Worker || !globalThis.WebAssembly) { + byId('compute').disabled = true; + errorBox.textContent = 'This browser needs WebAssembly and Web Workers. Use a current browser or the command line version.'; + errorBox.hidden = false; +} diff --git a/webinterface/docs/step3-frontend-ui.md b/webinterface/docs/step3-frontend-ui.md new file mode 100644 index 00000000..00ff2941 --- /dev/null +++ b/webinterface/docs/step3-frontend-ui.md @@ -0,0 +1,50 @@ +# Step 3: sequence input interface (#286) + +## Design + +The interface is a small scientific workspace: two equally weighted strand +editors above one results region. Query uses teal (`#087f83`), target uses blue +(`#315fb8`), with navy text (`#17324d`), pale blue background (`#f1f7fa`), white +working surfaces and rust (`#aa4b1d`) for errors. Trebuchet/Segoe UI system fonts +keep the page self-contained; only nucleotide input and structure data use +monospace. The paired-strand mark is the single visual motif. All working text +is left aligned. A generic dashboard with metric cards was rejected because +sequence comparison, input direction and coordinate interpretation matter here. + +```text +IntaRNA Documentation / Source +RNA–RNA interaction prediction Runs on this device +[ Query 5′ → 3′ ][ Target 5′ → 3′ ] +[ FASTA editor ][ FASTA editor ] +Maximum results / Load example Compute +Results / search / export +[ sortable interaction coordinates and energy ] +``` + +## Input and state + +`protocol.js` accepts one FASTA record or one plain sequence per input. Wrapped +lines, lowercase and DNA T are normalized to uppercase RNA U. The only allowed +nucleotides are A/C/G/U/T/N; N does not match a complementary base. Empty names, +multiple/misplaced headers, invalid symbols and lengths outside 7–2,000 nt are +rejected. Query length × target length is limited to 1,000,000 for browser +memory. The result count is an integer from 1 to 100. These limits are also +enforced in the worker and do not change the native CLI. + +The example is a short complementary pair with a seven-base seed. Predictions +use the native defaults (heuristic prediction, Turner energy model, accessibility +included, 37 °C, seed length 7) and allow overlapping suboptimal interactions. +All sequences are entered 5′ to 3′; the CLI handles reverse query accessibility. + +The loading dialog reports initialization versus calculation without inventing +percentages. Abort and Escape terminate the worker and restore the form. Inputs +are disabled during a prediction; the results are cleared when inputs change or +a new prediction begins. Errors and empty output have explicit messages. +Diagnostics are available separately from CSV. Focus returns to Compute after +the dialog closes. There are visible keyboard focus styles, associated labels, +live status/error regions, reduced-motion support and a single-column mobile +layout. Browser tests cover validation, real computation, abort/restart, worker +load failure, responsive overflow and result interactions. + +Serve the staged directory over HTTP; module workers cannot load through +`file://`. No frontend bundler, external font, CDN or API service is needed. diff --git a/webinterface/index.html b/webinterface/index.html new file mode 100644 index 00000000..aa565c9c --- /dev/null +++ b/webinterface/index.html @@ -0,0 +1,71 @@ + + + + + + + + IntaRNA · RNA–RNA interaction prediction + + + + + + + +
+
+

RNA–RNA interaction prediction

+

Explore how two RNA sequences interact, with accessibility and seed pairing included.

+
+

Sequences stay on your device

+
+ +
+
+ Sequences and prediction settings +
+
+
5′ to 3′
+ +

10 nucleotides

+
+
+
5′ to 3′
+ +

10 nucleotides

+
+
+

One FASTA record or plain sequence per field. A, C, G, U/T and N; 7–2,000 nucleotides each, with query length × target length ≤ 1,000,000.

+
+
+ +
+
+ +
+
Prediction settings

37 °C · Turner energy model · accessibility included · 7-base seed · heuristic prediction · overlapping suboptimals

+
+

Interactions

Enter your sequences or try the example, then compute.

+ + +
+

Positions are 1-based and inclusive. Energy is reported in kcal/mol; lower values indicate more favorable interactions.

IntaRNA runs locally in your browser. For longer sequences and advanced settings, use the command line version.

Licenses and third-party notices
+
+ + +

Computing interactions

+

Loading the prediction engine…

+

The first run downloads the engine. You can stop at any time.

+ +
+ + diff --git a/webinterface/mark.svg b/webinterface/mark.svg new file mode 100644 index 00000000..cb535a7b --- /dev/null +++ b/webinterface/mark.svg @@ -0,0 +1 @@ + diff --git a/webinterface/styles.css b/webinterface/styles.css new file mode 100644 index 00000000..611d7b74 --- /dev/null +++ b/webinterface/styles.css @@ -0,0 +1,67 @@ +:root { color-scheme: light; --ink: #17324d; --muted: #49647a; --teal: #087f83; --blue: #315fb8; --line: #cad9e2; --paper: #fff; --background: #f1f7fa; --error: #aa4b1d; font: 16px/1.55 "Segoe UI", Arial, sans-serif; color: var(--ink); background: var(--background); } +* { box-sizing: border-box; } +body { margin: 0; } +a { color: var(--blue); text-underline-offset: .2em; } +button, input, textarea { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: wait; opacity: .6; } +:focus-visible { outline: 3px solid #bf6b20; outline-offset: 4px; } +[hidden] { display: none !important; } +.site-header, main { width: min(1180px, calc(100% - 64px)); margin-inline: auto; } +.site-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 28px 0; border-bottom: 1px solid var(--line); } +.brand { display: flex; align-items: center; gap: 12px; font: bold 28px/1.1 "Trebuchet MS", "Segoe UI", sans-serif; color: var(--ink); text-decoration: none; } +.brand span { font: 14px/1.3 "Segoe UI", Arial, sans-serif; color: var(--muted); border-left: 1px solid var(--line); padding-left: 16px; margin-left: 4px; max-width: 100px; } +nav { display: flex; gap: 24px; font-size: 14px; } +.introduction { display: flex; align-items: center; justify-content: space-between; gap: 32px; padding: 48px 0 30px; } +h1, h2 { font-family: "Trebuchet MS", "Segoe UI", sans-serif; line-height: 1.18; } +h1 { font-size: clamp(28px, 3.2vw, 40px); letter-spacing: -.035em; margin: 0 0 12px; } +h2 { font-size: 25px; margin: 0; } +p { margin: 0 0 12px; max-width: 80ch; } +.introduction p { color: var(--muted); margin-bottom: 0; } +.introduction .local-note { font-size: 13px; white-space: nowrap; } +.local-note span { color: var(--teal); margin-right: 5px; } +fieldset { padding: 0; border: 0; margin: 0; min-width: 0; } +#prediction-form { background: var(--paper); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; } +.sequence-grid { display: grid; grid-template-columns: 1fr 1fr; } +.strand { min-width: 0; padding: 24px 28px 18px; border-top: 4px solid var(--teal); } +.target-strand { border-top-color: var(--blue); border-left: 1px solid var(--line); } +.strand-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } +.strand-heading label { font-size: 19px; font-weight: 650; color: var(--teal); } +.target-strand label { color: var(--blue); } +.strand-heading span, .sequence-meta { color: var(--muted); font-size: 13px; } +textarea { display: block; width: 100%; height: 150px; min-height: 100px; resize: vertical; background: #f8fbfd; border: 1px solid var(--line); border-radius: 6px; padding: 14px; color: var(--ink); font: 15px/1.8 "Cascadia Mono", Consolas, monospace; overflow-wrap: anywhere; } +.sequence-meta { margin: 8px 0 0; } +.input-help { font-size: 13px; color: var(--muted); padding: 0 28px 20px; margin: 0; max-width: none; } +.compute-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 20px; padding: 20px 28px; background: #f5fafc; border-top: 1px solid var(--line); } +.form-options { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; font-size: 14px; } +input[type="number"] { width: 72px; padding: 6px 8px; margin-left: 8px; color: var(--ink); background: white; border: 1px solid var(--line); border-radius: 4px; } +.primary, .secondary { border-radius: 6px; padding: 11px 20px; font-weight: 600; border: 1px solid transparent; } +.primary { background: var(--teal); color: white; } +.primary:hover { background: #076a6e; } +.secondary { background: white; border-color: #9bb1c1; color: var(--ink); } +.secondary:hover { background: #eaf2f8; } +.error { color: var(--error); background: #fff4ec; border-left: 4px solid var(--error); padding: 16px 24px; margin: 0; max-width: none; } +.model-note { display: flex; flex-wrap: wrap; gap: 4px 16px; padding: 20px 4px; color: var(--muted); font-size: 13px; } +.model-note strong { color: var(--ink); font-weight: 600; } +.model-note p { margin: 0; max-width: none; } +.results { margin-top: 26px; min-height: 140px; } +.results-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 24px; align-items: baseline; margin-bottom: 22px; } +.results-heading p { color: var(--muted); font-size: 14px; margin: 0; } +pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; } +details { margin: 20px 0; } +summary { cursor: pointer; color: var(--muted); } +footer { padding: 30px 0 40px; border-top: 1px solid var(--line); font-size: 13px; color: var(--muted); } +footer p { margin-bottom: 10px; } +dialog { border: 1px solid var(--line); border-radius: 14px; color: var(--ink); padding: 36px; max-width: min(460px, calc(100% - 32px)); box-shadow: 0 20px 70px #17324d26; } +dialog::backdrop { background: #17324d66; } +dialog h2 { margin: 20px 0 14px; } +dialog p { color: var(--muted); } +dialog button { margin-top: 14px; } +.spinner { width: 32px; height: 32px; border: 3px solid #d1e7e9; border-top-color: var(--teal); border-radius: 50%; animation: spin 1s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } +.skip-link { position: absolute; top: -100px; left: 20px; background: white; padding: 8px; z-index: 2; } +.skip-link:focus { top: 10px; } +@media (max-width: 800px) { .introduction { display: block; padding-top: 32px; } .introduction .local-note { margin-top: 14px; } .brand span { display: none; } } +@media (max-width: 600px) { .site-header, main { width: calc(100% - 32px); } .site-header { gap: 12px; padding: 20px 0; } nav { gap: 12px; font-size: 12px; } .brand { font-size: 24px; gap: 8px; } .brand img { width: 36px; height: 36px; } .sequence-grid { grid-template-columns: 1fr; } .target-strand { border-left: 0; } .strand { padding: 18px; } .input-help { padding: 0 18px 18px; } .compute-bar { padding: 18px; } .compute-bar .primary { width: 100%; } .results-heading { display: block; } .results-heading p { margin-top: 12px; } } +@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } From bac25b0853b4a7c2f61de7bdd42c80986f29497b Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Tue, 20 Oct 2026 21:31:08 +0200 Subject: [PATCH 05/31] feat(web): add result exploration and combined Pages deployment (#286 step 4) --- .github/workflows/build-wasm.yml | 79 ++++++++++- .github/workflows/documentation.yml | 26 +--- .gitignore | 2 + ChangeLog | 18 ++- README.md | 6 + doc/api-documentation.md | 40 +++--- docs/step1-wasm-compilation.md | 14 +- scripts/wasm/build.sh | 23 +++- scripts/wasm/dependency-probe.cpp | 26 ++++ scripts/wasm/requirements-browser.txt | 1 + scripts/wasm/smoke.mjs | 1 + scripts/wasm/stage.sh | 59 +++++++++ scripts/wasm/test-browser.py | 112 ++++++++++++++++ src/IntaRNA/Makefile.am | 2 +- src/bin/Makefile.am | 2 +- webinterface/app.js | 10 +- .../docs/step2-worker-architecture.md | 2 +- .../docs/step4-results-and-deployment.md | 77 +++++++++++ webinterface/index.html | 13 +- webinterface/intarna-worker.js | 7 +- webinterface/package.json | 2 +- webinterface/results.js | 125 ++++++++++++++++++ webinterface/styles.css | 16 +++ webinterface/tests/results.test.mjs | 25 ++++ 24 files changed, 631 insertions(+), 57 deletions(-) create mode 100644 scripts/wasm/dependency-probe.cpp create mode 100644 scripts/wasm/requirements-browser.txt create mode 100644 scripts/wasm/stage.sh create mode 100644 scripts/wasm/test-browser.py create mode 100644 webinterface/docs/step4-results-and-deployment.md create mode 100644 webinterface/results.js create mode 100644 webinterface/tests/results.test.mjs diff --git a/.github/workflows/build-wasm.yml b/.github/workflows/build-wasm.yml index 5c7b493e..4324e813 100644 --- a/.github/workflows/build-wasm.yml +++ b/.github/workflows/build-wasm.yml @@ -11,6 +11,9 @@ permissions: contents: read jobs: + documentation: + uses: ./.github/workflows/documentation.yml + build: runs-on: ubuntu-24.04 timeout-minutes: 60 @@ -22,10 +25,84 @@ jobs: actions-cache-folder: emsdk-cache - name: Install native build tools run: sudo apt-get update && sudo apt-get install --yes autoconf automake libtool pkg-config + - uses: actions/setup-node@v6 + with: + node-version: '24' + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Test input, worker lifecycle and CSV handling + run: npm test --prefix webinterface - name: Cross-compile dependencies and IntaRNA run: bash scripts/wasm/build.sh + - name: Check real WASM predictions against the native reference + run: node scripts/wasm/smoke.mjs + - name: Stage the static site and notices + run: bash scripts/wasm/stage.sh + - name: Install browser test tools + run: | + python -m pip install -r scripts/wasm/requirements-browser.txt + python -m playwright install --with-deps chromium firefox + - name: Exercise the browser with real WASM + run: | + python -m http.server 8765 --bind 127.0.0.1 --directory build-wasm/site > /tmp/intarna-http.log 2>&1 & + server_pid=$! + trap 'kill "$server_pid"' EXIT + for attempt in {1..30}; do + if curl --fail --silent http://127.0.0.1:8765/ > /dev/null; then break; fi + sleep 1 + done + python scripts/wasm/test-browser.py http://127.0.0.1:8765 --screenshot build-wasm/browser.png + python scripts/wasm/test-browser.py http://127.0.0.1:8765 --browser firefox - uses: actions/upload-artifact@v4 with: - name: intarna-wasm + name: intarna-webinterface path: build-wasm/site if-no-files-found: error + + package: + needs: [build, documentation] + runs-on: ubuntu-24.04 + steps: + - uses: actions/download-artifact@v4 + with: + name: intarna-documentation + path: _site + - uses: actions/download-artifact@v4 + with: + name: intarna-webinterface + path: _site/webinterface + - name: Verify documentation and browser interface coexist + run: | + test -s _site/index.html + test -s _site/api/index.html + test -s _site/webinterface/index.html + test -s _site/webinterface/wasm/intarna.wasm + - uses: actions/upload-artifact@v4 + with: + name: intarna-pages-preview + path: _site + - name: Upload GitHub Pages artifact + if: github.repository == 'BackofenLab/IntaRNA' && github.ref == 'refs/heads/master' && github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@v4 + with: + path: _site + + deploy: + if: github.repository == 'BackofenLab/IntaRNA' && github.ref == 'refs/heads/master' && github.event_name != 'pull_request' + needs: package + runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write + concurrency: + group: github-pages + cancel-in-progress: false + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/configure-pages@v5 + - name: Deploy tested site and existing documentation + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index c0b774f4..79a365fa 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -1,10 +1,7 @@ name: Documentation on: - push: - branches: [master] - pull_request: - branches: [master] + workflow_call: workflow_dispatch: permissions: @@ -47,24 +44,9 @@ jobs: test -s _site/index.html test -s _site/doc/figures/icon-up.28.png - - name: Upload documentation for review and deployment - uses: actions/upload-pages-artifact@v4 + - name: Upload documentation for the combined Pages site + uses: actions/upload-artifact@v4 with: + name: intarna-documentation path: _site retention-days: 7 - - deploy: - name: Publish documentation - if: github.repository == 'BackofenLab/IntaRNA' && github.ref == 'refs/heads/master' && github.event_name != 'pull_request' - needs: build - runs-on: ubuntu-24.04 - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 6a616f0f..aecb0872 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,5 @@ python/intarnapvalue.* /build-wasm/ /webinterface/wasm/ /webinterface/node_modules/ + +/scripts/wasm/__pycache__/ diff --git a/ChangeLog b/ChangeLog index 97d09369..ac49b18a 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,9 @@ ## Interface and handling +- display sortable/filterable browser results and native CSV downloads; + publish the tested interface alongside existing Pages documentation (#286) + - add responsive browser FASTA inputs, validation and abort dialog (#286, step 3) - run browser predictions in cancellable, isolated Web Workers (#286, step 2) @@ -227,6 +230,16 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261020 Alexander Mitrofanov + * webinterface/results.js, app.js, index.html, styles.css, tests/*, README.md + + Parse native CSV, preserve target/query orientation and inclusive indices, + and show energy/relative suboptimality with sorting, search and export. + * scripts/wasm/stage.sh, test-browser.py, .github/workflows/* + + Stage matching WASM assets, notices and provenance; verify the real engine + in Chromium and Firefox, including cancellation/restart and error recovery. + * Publish one combined Pages artifact with the existing guide/API and the + browser application at /webinterface/ (#286, step 4). + 261020 Alexander Mitrofanov * webinterface/index.html, app.js, styles.css, mark.svg, docs/* + Add paired query/target editors, a valid example, bounded RNA/FASTA @@ -242,10 +255,13 @@ energies, restricted partition sums, and trackers. and stale-message handling (#286, step 2); document the worker protocol. 261020 Alexander Mitrofanov - * scripts/wasm/*, .github/workflows/build-wasm.yml, configure.ac + * scripts/wasm/*, .github/workflows/build-wasm.yml, configure.ac, + src/bin/Makefile.am, src/IntaRNA/Makefile.am + Build pinned zlib, Boost and ViennaRNA for wasm32, then the existing IntaRNA CLI as an isolated ES module with MEMFS and callMain exports. * Omit pthread flags for single-thread Emscripten builds; retain native flags. + * Resolve generated CLI headers/pkg-config data in the build tree; adapt + bundled dlib Unicode and template code to the SDK libc++ and Clang. + Document the toolchain and validate native-reference predictions in WASM (https://github.com/BackofenLab/IntaRNA/issues/286, step 1). diff --git a/README.md b/README.md index 5bbfd8d0..5142f6cb 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,15 @@ + # IntaRNA [![releases](https://img.shields.io/github/tag/BackofenLab/IntaRNA.svg)](https://github.com/BackofenLab/IntaRNA/releases) [![Bioconda](https://anaconda.org/bioconda/intarna/badges/version.svg)](https://anaconda.org/bioconda/intarna) **Efficient RNA-RNA interaction prediction incorporating accessibility and seeding of interaction sites** +Predict short RNA pairs locally with the [browser interface](https://backofenlab.github.io/IntaRNA/webinterface/). +See the [WebAssembly build instructions](docs/step1-wasm-compilation.md) and +[interface/deployment notes](webinterface/docs/step4-results-and-deployment.md). + + During the last few years, several new small regulatory RNAs (sRNAs) have been discovered in bacteria. Most of them act as post-transcriptional regulators by base pairing to a target mRNA, causing translational repressionex diff --git a/doc/api-documentation.md b/doc/api-documentation.md index e7382175..3a9dc2a2 100644 --- a/doc/api-documentation.md +++ b/doc/api-documentation.md @@ -38,28 +38,34 @@ fatal. New unresolved references should be fixed before merging. ## GitHub Pages -`.github/workflows/documentation.yml` builds documentation on pull requests to -`master`, on pushes to `master`, and on manual dispatch. Every successful build -uploads a `github-pages` artifact for review. Only a push or manual dispatch on -`BackofenLab/IntaRNA`'s `master` can deploy; pull requests and forks cannot publish. -The deployment job alone receives Pages and OIDC write permissions. +`.github/workflows/documentation.yml` is a reusable documentation build, called +by `build-wasm.yml` for pull requests and pushes to `master`; it can also be run +manually to inspect documentation artifacts. The documentation build uploads +`intarna-documentation`, with the README-based Jekyll guide and Doxygen output +under `api/`. -The workflow first builds the existing README-based Jekyll site with its Slate -theme, then adds Doxygen output under `api/`. It publishes both together: +`build-wasm.yml` combines that artifact with the tested browser interface under +`webinterface/` and publishes **one** Pages artifact. This preserves the existing +guide and API; two independent publishers would overwrite each other's site. +Only a push or manual dispatch on `BackofenLab/IntaRNA`'s `master` can deploy. +Pull requests and forks cannot publish. The deployment job alone receives Pages +and OIDC write permissions, and deployments share one concurrency group. - User guide: - Development API: +- Browser interface: -### One-time activation after merge +### Pages configuration -The repository currently publishes from the root of the `master` branch. After -merging the documentation workflow, a repository administrator must select -**Settings → Pages → Build and deployment → Source → GitHub Actions**, then run -the **Documentation** workflow on `master` (or push a subsequent commit). -Retain any required approval rules on the `github-pages` environment. The PR -does not change the live publishing settings. See GitHub's +The repository already uses **Settings → Pages → Build and deployment → Source +→ GitHub Actions**. For another repository, select that source before deploying, +and deliberately adjust the deployment repository guard. Run the **WebAssembly +and web interface** workflow on `master` or push a subsequent commit. Retain any +required approval rules on the `github-pages` environment. This PR does not +change live publishing settings. See GitHub's [custom Pages workflow documentation](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages). -No generated HTML, access token, or separate documentation branch is needed. -Both the existing guide and the API are included in the deployment artifact; -deploying only the API would replace the guide at the site's root. +No generated HTML, access token, or separate documentation branch is committed. +The `intarna-pages-preview` artifact contains the combined site for PR review. +See [the browser deployment notes](../webinterface/docs/step4-results-and-deployment.md) +for the complete build and validation sequence. diff --git a/docs/step1-wasm-compilation.md b/docs/step1-wasm-compilation.md index 5e943f8f..f993ffc1 100644 --- a/docs/step1-wasm-compilation.md +++ b/docs/step1-wasm-compilation.md @@ -24,7 +24,9 @@ changing dependency versions or compiler flags. Do not reuse this prefix with a different SDK. Native libraries are never accepted from system pkg-config paths. The script supplies absolute source and generated-header include paths for the -out-of-tree IntaRNA build. ViennaRNA 2.7.0 bundles dlib 19.24, whose Unicode +out-of-tree IntaRNA build, and uses generated files from the build directory. +ViennaRNA also needs an explicit source include path when its no-SVM build +omits the target's normal include flags. ViennaRNA 2.7.0 bundles dlib 19.24, whose Unicode `uint32` string requires a generic `std::char_traits` removed in modern libc++. The script backports [dlib's upstream `char32_t` typedef](https://github.com/davisking/dlib/blob/master/dlib/unicode/unicode.h) in the downloaded source only, adds its matching serialization overload and @@ -32,6 +34,14 @@ supplies a missing dependent template argument rejected by current Clang. `scripts/wasm/patch-vienna.py` checks the expected source before each replacement. It does not change the RNA algorithms. +The legacy Boost and zlib configure macros declare dummy test functions with +incorrect signatures (`exit()` and `inflateEnd()`), which wasm-ld correctly +rejects. Before caching just those probes, `dependency-probe.cpp` links actual +filesystem, regex, options, archive, gzip and RNAlib APIs against the WASM +libraries. Configure still checks C++23, headers, serialization and RNAlib APIs; +the smoke test executes the real prediction. `VRNA_LIBS` supplies the static +RNAlib link flags because the upstream `.pc` includes native-only linker flags. + ## Dependencies and runtime - zlib 1.3.1: static library for compressed accessibility and Boost.Iostreams. @@ -46,7 +56,7 @@ It does not change the RNA algorithms. `-pthread`; native builds retain their existing threading flags. All C++ compilation and linking enables exceptions (`-fexceptions`), including -Boost and the CLI's input/error handling. The SDK's single-thread mutex support +Boost (`exception-handling-method=js`) and the CLI's input/error handling. The SDK's single-thread mutex support retains ViennaRNA's normal setup/logger locking without requiring browser `SharedArrayBuffer` or COOP/COEP headers (unavailable on ordinary GitHub Pages). diff --git a/scripts/wasm/build.sh b/scripts/wasm/build.sh index acf42205..9c94db03 100644 --- a/scripts/wasm/build.sh +++ b/scripts/wasm/build.sh @@ -23,7 +23,7 @@ fetch https://zlib.net/fossils/zlib-1.3.1.tar.gz zlib-1.3.1.tar.gz \ fetch https://archives.boost.io/release/1.87.0/source/boost_1_87_0.tar.bz2 boost_1_87_0.tar.bz2 \ af57be25cb4c4f4b413ed692fe378affb4352ea50fbe294a11ef548f4d527d89 fetch https://www.tbi.univie.ac.at/RNA/download/sourcecode/2_7_x/ViennaRNA-2.7.0.tar.gz ViennaRNA-2.7.0.tar.gz \ - e2addf33e7c0dd4fff31d8e9a46fda8e9efcde0d199f789f20eb0a13b7f7135d + 9a99fd68ed380894defb4d5e6a8a2871629270028cdf7f16f0a05da6e8c71473 # Keep pkg-config searches inside the WASM prefix, including configure probes. export PKG_CONFIG_LIBDIR="$prefix/lib/pkgconfig" @@ -39,20 +39,22 @@ if [[ ! -f "$prefix/lib/libz.a" ]]; then emmake make -j "$jobs" emmake make install) fi -if [[ ! -f "$prefix/lib/libboost_serialization.a" ]]; then +if [[ ! -f "$prefix/.boost-1.87.0-js-exceptions-complete" ]]; then tar -xjf "$build/downloads/boost_1_87_0.tar.bz2" -C "$build/sources" (cd "$build/sources/boost_1_87_0" ./bootstrap.sh --with-libraries=filesystem,program_options,regex,iostreams,serialization,system ./b2 -j "$jobs" toolset=emscripten variant=release link=static threading=single \ + exception-handling=on exception-handling-method=js \ --with-filesystem --with-program_options --with-regex --with-iostreams \ --with-serialization --with-system cxxflags="-std=c++23 -fexceptions" \ -sNO_BZIP2=1 -sNO_LZMA=1 -sNO_ZSTD=1 -sZLIB_INCLUDE="$prefix/include" \ -sZLIB_LIBPATH="$prefix/lib" --prefix="$prefix" install) + touch "$prefix/.boost-1.87.0-js-exceptions-complete" fi if [[ ! -f "$prefix/lib/libRNA.a" ]]; then tar -xzf "$build/downloads/ViennaRNA-2.7.0.tar.gz" -C "$build/sources" (cd "$build/sources/ViennaRNA-2.7.0" - emconfigure ./configure --host=wasm32-unknown-emscripten --prefix="$prefix" \ + CPPFLAGS="$CPPFLAGS -I$PWD/src" emconfigure ./configure --host=wasm32-unknown-emscripten --prefix="$prefix" \ --disable-shared --enable-static --disable-openmp --disable-pthreads \ --disable-lto --disable-simd --disable-vectorize --disable-mpfr \ --without-svm --without-gsl --without-swig --without-perl --without-python \ @@ -66,6 +68,21 @@ if [[ ! -f "$prefix/lib/libRNA.a" ]]; then emmake make install-pkgconfigDATA) fi +# Legacy Autoconf probes declare exit()/inflateEnd() with the wrong signatures, +# which wasm-ld rejects. Verify real dependency APIs before caching those probes. +em++ -std=c++23 -O2 -fexceptions -I"$prefix/include" \ + "$repo/scripts/wasm/dependency-probe.cpp" -L"$prefix/lib" \ + -lboost_filesystem -lboost_system -lboost_program_options -lboost_regex \ + -lboost_iostreams -lboost_serialization -lRNA -lz -o "$build/dependency-probe.js" +export ac_cv_lib_boost_system_exit=yes +export ac_cv_lib_boost_filesystem_exit=yes +export ac_cv_lib_boost_program_options_exit=yes +export ac_cv_lib_boost_regex_exit=yes +export ac_cv_lib_boost_iostreams_exit=yes +export ac_cv_lib_z_inflateEnd=yes +# RNAlib's .pc embeds a native-only -Wl,-fno-lto; this is a static WASM build. +export VRNA_LIBS="-L$prefix/lib -lRNA -lm" + # Use the native Autotools source lists and CLI entry point, with a JS output. (cd "$repo" && bash autotools-init.sh) mkdir -p "$build/intarna" diff --git a/scripts/wasm/dependency-probe.cpp b/scripts/wasm/dependency-probe.cpp new file mode 100644 index 00000000..bf428cb2 --- /dev/null +++ b/scripts/wasm/dependency-probe.cpp @@ -0,0 +1,26 @@ +// Link real APIs rather than the legacy configure macros' misdeclared exit(). +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include +} +int main() { + std::stringstream data; + boost::archive::binary_oarchive archive(data); + archive << 42; + boost::iostreams::filtering_ostream compressed; + compressed.push(boost::iostreams::gzip_compressor()); + compressed.push(data); + compressed << "RNA"; + compressed.reset(); + boost::program_options::options_description options("probe"); + options.add_options()("query", "RNA sequence"); + vrna_md_t model; + vrna_md_set_default(&model); + return boost::filesystem::exists("/") && boost::regex_match("RNA", boost::regex("RNA")) ? 0 : 1; +} diff --git a/scripts/wasm/requirements-browser.txt b/scripts/wasm/requirements-browser.txt new file mode 100644 index 00000000..718f8e96 --- /dev/null +++ b/scripts/wasm/requirements-browser.txt @@ -0,0 +1 @@ +playwright==1.64.0 diff --git a/scripts/wasm/smoke.mjs b/scripts/wasm/smoke.mjs index 7e7152b9..ec93d750 100644 --- a/scripts/wasm/smoke.mjs +++ b/scripts/wasm/smoke.mjs @@ -7,6 +7,7 @@ const { default: createIntaRNA } = await import(pathToFileURL(asset)); const diagnostics = []; const module = await createIntaRNA({ noInitialRun: true, + locateFile: () => new URL('./intarna.wasm', pathToFileURL(asset)).href, print: (line) => diagnostics.push(line), printErr: (line) => diagnostics.push(line), }); diff --git a/scripts/wasm/stage.sh b/scripts/wasm/stage.sh new file mode 100644 index 00000000..d6d32450 --- /dev/null +++ b/scripts/wasm/stage.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Package only the static application, matched WASM assets and distribution notices. +set -euo pipefail +repo=$(cd "$(dirname "$0")/../.." && pwd) +build=${WASM_BUILD_DIR:-"$repo/build-wasm"} +site="$build/site" +test -s "$site/wasm/intarna.js" +test -s "$site/wasm/intarna.wasm" +mkdir -p "$site/licenses" +for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js; do + cp "$repo/webinterface/$file" "$site/$file" +done +cp "$repo/webinterface/intarna-worker.js" "$site/intarna-worker.js" +touch "$site/.nojekyll" +cp "$repo/LICENSE" "$site/licenses/IntaRNA.txt" +cp "$repo/src/easylogging++.LICENCE.txt" "$site/licenses/easylogging.txt" +cp "$repo/src/mdspan/LICENSE" "$site/licenses/mdspan.txt" +cp "$build/sources/boost_1_87_0/LICENSE_1_0.txt" "$site/licenses/Boost.txt" +cp "$build/sources/zlib-1.3.1/zlib.h" "$site/licenses/zlib.h" +cp "$build/sources/ViennaRNA-2.7.0/COPYING" "$site/licenses/ViennaRNA.txt" +cp "$build/sources/ViennaRNA-2.7.0/src/dlib-19.24/dlib/LICENSE.txt" "$site/licenses/dlib.txt" +cp "$build/sources/ViennaRNA-2.7.0/src/ViennaRNA/plotting/naview/naview.c" "$site/licenses/naview.c" +emscripten_dir=$(dirname "$(command -v emcc)") +cp "$emscripten_dir/LICENSE" "$site/licenses/Emscripten.txt" +cp "$emscripten_dir/system/lib/libc/musl/COPYRIGHT" "$site/licenses/musl.txt" +for library in libcxx libcxxabi libunwind compiler-rt; do + cp "$emscripten_dir/system/lib/$library/LICENSE.TXT" "$site/licenses/$library.txt" +done +cat > "$site/licenses/index.html" <<'HTML' +IntaRNA licenses +

IntaRNA and third-party notices

+

IntaRNA: Bioinformatics Group of Prof. Backofen, University of Freiburg.
ViennaRNA: authors and Institute for Theoretical Chemistry, University of Vienna.

+

This distribution uses ViennaRNA 2.7.0, Boost 1.87.0, zlib 1.3.1, bundled dlib 19.24 (with Unicode/template compatibility fixes), Kokkos mdspan, Easylogging++ and the Emscripten 4.0.23 runtime.

+

Build provenance · Back to IntaRNA

+ +HTML +python3 - "$repo" "$site" <<'PY' +import json +import subprocess +import sys +from pathlib import Path +repo, site = sys.argv[1:] +metadata = { + "source": "https://github.com/BackofenLab/IntaRNA", + "commit": subprocess.check_output(["git", "-C", repo, "rev-parse", "HEAD"], text=True).strip(), + "emscripten": "4.0.23", "boost": "1.87.0", "viennarna": "2.7.0", "zlib": "1.3.1", + "threading": "single worker per prediction", +} +Path(site, "build-info.json").write_text(json.dumps(metadata, indent=2) + "\n") +PY +echo "Static site: $site" diff --git a/scripts/wasm/test-browser.py b/scripts/wasm/test-browser.py new file mode 100644 index 00000000..295fde57 --- /dev/null +++ b/scripts/wasm/test-browser.py @@ -0,0 +1,112 @@ +"""Browser integration tests against staged assets, including the real WASM engine. + +Start an HTTP server serving build-wasm/site, then pass its URL. Optional +--browser selects Firefox; --screenshot saves the populated desktop workspace. +""" +import argparse +from pathlib import Path +from playwright.sync_api import sync_playwright, expect + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("url") +parser.add_argument("--browser", choices=["chromium", "firefox"], default="chromium") +parser.add_argument("--screenshot") +args = parser.parse_args() + +with sync_playwright() as playwright: + browser = getattr(playwright, args.browser).launch(headless=True) + page = browser.new_page(viewport={"width": 1440, "height": 1100}) + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + page.goto(args.url) + page.wait_for_load_state("networkidle") + compute = page.get_by_role("button", name="Compute interactions", exact=True) + status = page.locator("#result-status") + + # Invalid bases and multiple records must fail before engine startup. + page.locator("#query").fill(">bad\nACGU-ACGU") + compute.click() + expect(page.locator("#input-error")).to_contain_text("use only") + expect(page.locator("#loading")).not_to_be_visible() + page.locator("#query").fill(">a\nACGUACGU\n>b\nACGUACGU") + compute.click() + expect(page.locator("#input-error")).to_contain_text("exactly one") + page.get_by_role("button", name="Load example").click() + + # Real RNAlib prediction: orientation, native-reference energy and suboptimals. + compute.click() + expect(status).to_contain_text("Prediction complete", timeout=120000) + rows = page.locator("tbody tr") + expect(rows).to_have_count(10) + assert rows.first.locator("td").all_text_contents() == ["query", "1", "10", "target", "1", "10", "-21.40", "0.00"] + assert page.locator(".table-scroll").evaluate("node => node.scrollWidth") > 0 + for key in ["Query", "Query start", "Query end", "Target", "Target start", "Target end", "Energy (kcal/mol)", "ΔE (kcal/mol)"]: + page.get_by_role("button", name=f"Sort by {key}", exact=False).first.click() + # Select energy, then check numeric descending and ascending order. + page.get_by_role("button", name="Sort by Energy (kcal/mol)", exact=False).click() + page.get_by_role("button", name="Sort by Energy (kcal/mol)", exact=False).click() + expect(page.locator('th[aria-sort="descending"]')).to_contain_text("Energy") + assert float(rows.first.locator("td").nth(6).inner_text()) == -14.3 + page.get_by_role("button", name="Sort by Energy (kcal/mol)", exact=False).click() + assert float(rows.first.locator("td").nth(6).inner_text()) == -21.4 + page.locator("#search").fill("-21.4") + expect(rows).to_have_count(1) + page.locator("#search").fill("no match") + expect(page.locator("#no-matches")).to_be_visible() + page.locator("#search").fill("") + with page.expect_download() as downloaded: + page.get_by_role("button", name="Download CSV").click() + csv = Path(downloaded.value.path()).read_text() + assert "target;1;10;query;1;10;-21.4" in csv + if args.screenshot: + page.screenshot(path=args.screenshot, full_page=True) + + # Mobile content stays inside the viewport; the data table scrolls itself. + page.set_viewport_size({"width": 390, "height": 844}) + assert page.evaluate("document.documentElement.scrollWidth <= innerWidth") + page.set_viewport_size({"width": 1440, "height": 1100}) + + # Deliberately hold WASM download so Abort deterministically covers loading. + held_routes = [] + page.route("**/wasm/intarna.wasm", lambda route: held_routes.append(route)) + compute.click() + expect(page.locator("#loading")).to_be_visible() + page.get_by_role("button", name="Abort computation").click() + expect(status).to_contain_text("aborted") + expect(compute).to_be_enabled() + # Settle any intercepted request after terminating the worker. + for route in held_routes: + route.abort() + page.unroute("**/wasm/intarna.wasm") + + # Wait until actual synchronous C++ starts, then Escape must still work. + page.locator("#query").fill(">long_query\n" + "G" * 1000) + page.locator("#target").fill(">long_target\n" + "C" * 1000) + compute.click() + expect(page.locator("#loading-status")).to_contain_text("Predicting RNA", timeout=120000) + page.keyboard.press("Escape") + expect(status).to_contain_text("aborted") + page.get_by_role("button", name="Load example").click() + compute.click() + expect(status).to_contain_text("Prediction complete", timeout=120000) + expect(rows).to_have_count(10) + + # Ambiguous bases produce a valid empty prediction, not a fabricated result. + page.locator("#query").fill(">no_pairs\nNNNNNNNNNN") + compute.click() + expect(status).to_contain_text("No favorable interactions", timeout=120000) + expect(page.locator("#results-view")).not_to_be_visible() + + # Engine-load failures settle the dialog and allow the user to try again. + page.get_by_role("button", name="Load example").click() + page.route("**/wasm/intarna.js", lambda route: route.abort()) + compute.click() + expect(status).to_contain_text("failed", timeout=30000) + expect(page.locator("#loading")).not_to_be_visible() + expect(compute).to_be_enabled() + page.unroute("**/wasm/intarna.js") + compute.click() + expect(status).to_contain_text("Prediction complete", timeout=120000) + assert errors == [], errors + browser.close() +print(f"{args.browser}: real WASM, validation, sorting/filtering/export, mobile, empty/error and abort/restart passed") diff --git a/src/IntaRNA/Makefile.am b/src/IntaRNA/Makefile.am index 178691d6..7ed89752 100644 --- a/src/IntaRNA/Makefile.am +++ b/src/IntaRNA/Makefile.am @@ -178,7 +178,7 @@ nodist_libIntaRNA_a_SOURCES = \ if enable_pkg_config pkgconfigdir = $(libdir)/pkgconfig -pkgconfig_DATA = $(top_srcdir)/IntaRNA.pc +pkgconfig_DATA = $(top_builddir)/IntaRNA.pc endif ############################################################################### diff --git a/src/bin/Makefile.am b/src/bin/Makefile.am index e85a84a6..db72af6f 100644 --- a/src/bin/Makefile.am +++ b/src/bin/Makefile.am @@ -24,7 +24,7 @@ IntaRNA_CXXFLAGS = $(AM_CXXFLAGS) @INTARNA_SYSTEM_CPPFLAGS@ # generated intaRNA sources nodist_IntaRNA_SOURCES = \ ../config.h \ - IntaRNA/intarna_config.h + ../IntaRNA/intarna_config.h # intaRNA sources IntaRNA_SOURCES = IntaRNA.cpp \ diff --git a/webinterface/app.js b/webinterface/app.js index f8e124c9..f93c7c57 100644 --- a/webinterface/app.js +++ b/webinterface/app.js @@ -1,5 +1,6 @@ import { DEFAULTS, parseSequence, prepareRequest } from './protocol.js'; import { PredictionRunner } from './runner.js'; +import { ResultsView } from './results.js'; const byId = (id) => document.getElementById(id); const form = byId('prediction-form'); @@ -7,10 +8,10 @@ const runner = new PredictionRunner(); const dialog = byId('loading'); const status = byId('result-status'); const errorBox = byId('input-error'); +const results = new ResultsView(byId('results-view')); function clearResults(message = 'Inputs changed. Compute to update the interactions.') { - byId('csv-preview').hidden = true; - byId('csv-preview').textContent = ''; + results.clear(); byId('diagnostics').hidden = true; status.textContent = message; } @@ -68,9 +69,8 @@ form.addEventListener('submit', async (event) => { setBusy(true); try { const result = await runner.run(request, (message) => { byId('loading-status').textContent = message; }); - byId('csv-preview').textContent = result.csv; - byId('csv-preview').hidden = false; - status.textContent = 'Prediction complete. Coordinates are 1-based and inclusive.'; + const count = results.show(result.csv); + status.textContent = count ? `Prediction complete: ${count} interaction${count === 1 ? '' : 's'}.` : 'No favorable interactions found with these sequences and the default settings.'; byId('diagnostic-text').textContent = result.diagnostics; byId('diagnostics').hidden = !result.diagnostics; } catch (error) { diff --git a/webinterface/docs/step2-worker-architecture.md b/webinterface/docs/step2-worker-architecture.md index 24a28800..da5a8a60 100644 --- a/webinterface/docs/step2-worker-architecture.md +++ b/webinterface/docs/step2-worker-architecture.md @@ -5,7 +5,7 @@ sends `{query, target, count}` as text and receives `status`, `result` or `error messages. `protocol.js` validates both the UI and worker boundary and constructs fixed CLI options; users cannot inject CLI arguments or file paths. -The worker lazily imports `wasm/intarna.js`, awaits `createIntaRNA`, writes +The worker lazily imports `wasm/intarna.js`, awaits `createIntaRNA` (mapping `locateFile` to the staged lowercase WASM name), writes normalized single-record FASTA to `/query.fa` and `/target.fa` in MEMFS, and calls `callMain` once. It selects semicolon-delimited CSV output in `/results.csv` and reads that file after main returns and the C++ output streams close. Logs are diff --git a/webinterface/docs/step4-results-and-deployment.md b/webinterface/docs/step4-results-and-deployment.md new file mode 100644 index 00000000..85112c26 --- /dev/null +++ b/webinterface/docs/step4-results-and-deployment.md @@ -0,0 +1,77 @@ +# Step 4: results and Pages deployment (#286) + +## Results contract + +The worker requests exactly `id1,start1,end1,id2,start2,end2,E`. IntaRNA's native +CSV is unquoted and semicolon-delimited; its output handler replaces semicolons +inside sequence identifiers with underscores. The browser parser treats quotes +as literal ID characters, checks the header, validates finite numeric values +and positive inclusive coordinates, and rejects malformed rows. Header control +characters/multirecord input are rejected before the CLI runs. Empty CSV or a +header-only result is a successful prediction with no favorable interactions. + +Sequence 1 is **target** and sequence 2 is **query**. The table deliberately +shows query first, with each strand's original 1-based inclusive coordinates. +Energy uses kcal/mol. ΔE is derived as `E - min(reported E)` rounded to hundredths; +it is not a probability. Suboptimal interactions have distinct site boundaries +and may overlap. Scientific prediction defaults are unchanged. + +Every heading is a keyboard-operable sort button with `aria-sort`. Numerical +columns compare numbers, IDs compare text, and the search matches all columns +without changing the raw prediction. All values are rendered through +`textContent`. CSV export returns the original native output, including all +reported rows regardless of filtering. Input edits clear the old result. + +## Packaging and deployment + +```sh +bash scripts/wasm/build.sh +node scripts/wasm/smoke.mjs +bash scripts/wasm/stage.sh +npm test --prefix webinterface +python3 -m pip install -r scripts/wasm/requirements-browser.txt +python3 -m playwright install chromium firefox +python3 -m http.server 8765 --directory build-wasm/site +# In another terminal: +python3 scripts/wasm/test-browser.py http://127.0.0.1:8765 +python3 scripts/wasm/test-browser.py http://127.0.0.1:8765 --browser firefox +``` + +The staging script includes matched WASM assets, only the application source +files needed at runtime, source commit/version metadata and dependency notices. +It needs the same activated SDK and dependency build directory as the build. +No compiled artifacts are committed. HTTP(S), module workers and WebAssembly +are required; `file://` and legacy browsers are unsupported. No backend, +SharedArrayBuffer, credentials, CDN or cross-origin headers are needed. + +The existing repository already publishes its user guide and API with Pages. +Publishing a second independent artifact would overwrite them. Therefore +`documentation.yml` is now a reusable build (also manually dispatchable), and +`build-wasm.yml` is the **single publisher**. Both builds run on PRs and on the +actual default branch, `master`. After JS tests, the real WASM smoke test and +Chromium/Firefox integration tests pass, the package job combines: + +- Existing Jekyll guide at `/IntaRNA/` and API at `/IntaRNA/api/`. +- Tested application at `/IntaRNA/webinterface/`. + +The artifact `intarna-pages-preview` permits PR review of the complete site. +Pull requests and manual runs on feature branches do not publish. Only a push +or manual run on `BackofenLab/IntaRNA`'s `master` may upload the Pages artifact +and use the `github-pages` deployment environment. Pages permissions are scoped +to that deploy job. Concurrent deployments are serialized without cancellation. + +The repository's current Pages source is already **GitHub Actions**. A fork +would need that source enabled, its repository guard adjusted deliberately, and +an environment allowing its default branch. Existing environment protection +rules still apply. After merging this PR, check the workflow's deploy job and +its returned environment URL. This implementation PR does not merge itself or +claim that deployment happened before merge. + +## Validation scope + +Node tests cover input boundaries, lifecycle races, parser failures, numeric +sorting and suboptimal differences. The WASM smoke test compares a real +Turner/default-accessibility prediction with the native reference. Browser +integration runs real WASM (no mocked scientific results), checking coordinates, +energy, sorting, filtering, CSV download, mobile overflow, abort during download +and active C++, clean restart, empty predictions and asset-load recovery. diff --git a/webinterface/index.html b/webinterface/index.html index aa565c9c..b4858cfb 100644 --- a/webinterface/index.html +++ b/webinterface/index.html @@ -55,7 +55,18 @@
Prediction settings

37 °C · Turner energy model · accessibility included · 7-base seed · heuristic prediction · overlapping suboptimals

Interactions

Enter your sequences or try the example, then compute.

- +

Positions are 1-based and inclusive. Energy is reported in kcal/mol; lower values indicate more favorable interactions.

IntaRNA runs locally in your browser. For longer sequences and advanced settings, use the command line version.

Licenses and third-party notices
diff --git a/webinterface/intarna-worker.js b/webinterface/intarna-worker.js index 979aecc3..b86d3cf7 100644 --- a/webinterface/intarna-worker.js +++ b/webinterface/intarna-worker.js @@ -15,7 +15,12 @@ self.onmessage = async ({ data }) => { const request = prepareRequest(data); self.postMessage({ type: 'status', message: 'Loading the prediction engine…' }); const { default: createIntaRNA } = await import('./wasm/intarna.js'); - const module = await createIntaRNA({ noInitialRun: true, print: log, printErr: log }); + const module = await createIntaRNA({ + noInitialRun: true, + locateFile: () => new URL('./wasm/intarna.wasm', import.meta.url).href, + print: log, + printErr: log, + }); module.FS.writeFile('/query.fa', request.query.fasta); module.FS.writeFile('/target.fa', request.target.fasta); self.postMessage({ type: 'status', message: 'Predicting RNA interactions…' }); diff --git a/webinterface/package.json b/webinterface/package.json index 9657fd7e..81e5b5f8 100644 --- a/webinterface/package.json +++ b/webinterface/package.json @@ -2,5 +2,5 @@ "name": "intarna-webinterface", "private": true, "type": "module", - "scripts": { "test": "node --test tests/*.test.mjs" } + "scripts": { "test": "node --test --test-isolation=none tests/*.test.mjs" } } diff --git a/webinterface/results.js b/webinterface/results.js new file mode 100644 index 00000000..84410344 --- /dev/null +++ b/webinterface/results.js @@ -0,0 +1,125 @@ +import { CSV_COLUMNS } from './protocol.js'; + +export const COLUMNS = Object.freeze([ + { key: 'id2', label: 'Query', numeric: false }, + { key: 'start2', label: 'Query start', numeric: true }, + { key: 'end2', label: 'Query end', numeric: true }, + { key: 'id1', label: 'Target', numeric: false }, + { key: 'start1', label: 'Target start', numeric: true }, + { key: 'end1', label: 'Target end', numeric: true }, + { key: 'E', label: 'Energy (kcal/mol)', numeric: true }, + { key: 'deltaE', label: 'ΔE (kcal/mol)', numeric: true }, +]); + +// IntaRNA emits unquoted semicolon records and replaces delimiters in IDs. +// Header control characters are rejected before prediction; quotes are literal. +export function parseResults(csv) { + if (typeof csv !== 'string') throw new Error('The prediction did not return CSV text.'); + const lines = csv.trim().split(/\r?\n/); + if (!csv.trim()) return []; + if (lines.shift() !== CSV_COLUMNS.replaceAll(',', ';')) { + throw new Error('Unexpected IntaRNA CSV columns. Rebuild and serve the matching web interface and engine.'); + } + const keys = CSV_COLUMNS.split(','); + const rows = lines.filter((line) => line.trim()).map((line) => { + const values = line.split(';'); + if (values.length !== keys.length) throw new Error('Malformed IntaRNA CSV row.'); + const row = Object.fromEntries(keys.map((key, index) => [key, values[index]])); + for (const key of ['start1', 'end1', 'start2', 'end2', 'E']) { + if (!row[key].trim() || !Number.isFinite(Number(row[key]))) throw new Error(`Invalid ${key} in prediction output.`); + row[key] = Number(row[key]); + } + for (const key of ['start1', 'end1', 'start2', 'end2']) { + if (!Number.isInteger(row[key]) || row[key] < 1) throw new Error('Invalid interaction coordinates.'); + } + if (row.start1 > row.end1 || row.start2 > row.end2) throw new Error('Reversed interaction coordinates.'); + return row; + }); + const best = Math.min(...rows.map((row) => row.E)); + return rows.map((row) => ({ ...row, deltaE: Number((row.E - best).toFixed(2)) })); +} + +export function selectRows(rows, search, key, direction) { + const query = search.toLowerCase().trim(); + const column = COLUMNS.find((entry) => entry.key === key); + return rows.filter((row) => COLUMNS.some((entry) => String(row[entry.key]).toLowerCase().includes(query))) + .toSorted((a, b) => direction * (column.numeric ? a[key] - b[key] : a[key].localeCompare(b[key]))); +} + +export class ResultsView { + constructor(root) { + this.root = root; + this.rows = []; + this.key = 'E'; + this.direction = 1; + this.search = root.querySelector('#search'); + this.table = root.querySelector('table'); + this.body = this.table.querySelector('tbody'); + this.count = root.querySelector('#visible-count'); + this.empty = root.querySelector('#no-matches'); + this.search.addEventListener('input', () => this.render()); + const header = this.table.querySelector('thead tr'); + for (const column of COLUMNS) { + const cell = document.createElement('th'); + cell.scope = 'col'; + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = column.label; + button.addEventListener('click', () => { + this.direction = this.key === column.key ? -this.direction : 1; + this.key = column.key; + this.render(); + }); + cell.append(button); + header.append(cell); + } + root.querySelector('#download').addEventListener('click', () => { + const url = URL.createObjectURL(new Blob([this.csv], { type: 'text/csv;charset=utf-8' })); + const link = document.createElement('a'); + link.href = url; + link.download = 'intarna-interactions.csv'; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }); + } + + clear() { + this.rows = []; + this.csv = ''; + this.body.replaceChildren(); + this.root.hidden = true; + } + + show(csv) { + this.rows = parseResults(csv); + this.csv = csv; + this.search.value = ''; + this.key = 'E'; + this.direction = 1; + this.root.hidden = this.rows.length === 0; + this.render(); + return this.rows.length; + } + + render() { + const visible = selectRows(this.rows, this.search.value, this.key, this.direction); + this.body.replaceChildren(); + for (const row of visible) { + const tr = document.createElement('tr'); + for (const column of COLUMNS) { + const cell = document.createElement('td'); + cell.textContent = ['E', 'deltaE'].includes(column.key) ? row[column.key].toFixed(2) : String(row[column.key]); + if (column.numeric) cell.className = 'number'; + tr.append(cell); + } + this.body.append(tr); + } + this.table.querySelectorAll('th').forEach((cell, index) => { + const active = COLUMNS[index].key === this.key; + cell.setAttribute('aria-sort', active ? (this.direction === 1 ? 'ascending' : 'descending') : 'none'); + cell.firstChild.setAttribute('aria-label', `Sort by ${COLUMNS[index].label}${active ? `, currently ${this.direction === 1 ? 'ascending' : 'descending'}` : ''}`); + }); + this.count.textContent = `${visible.length} of ${this.rows.length} interactions`; + this.empty.hidden = visible.length > 0; + } +} diff --git a/webinterface/styles.css b/webinterface/styles.css index 611d7b74..c17f56fd 100644 --- a/webinterface/styles.css +++ b/webinterface/styles.css @@ -65,3 +65,19 @@ dialog button { margin-top: 14px; } @media (max-width: 800px) { .introduction { display: block; padding-top: 32px; } .introduction .local-note { margin-top: 14px; } .brand span { display: none; } } @media (max-width: 600px) { .site-header, main { width: calc(100% - 32px); } .site-header { gap: 12px; padding: 20px 0; } nav { gap: 12px; font-size: 12px; } .brand { font-size: 24px; gap: 8px; } .brand img { width: 36px; height: 36px; } .sequence-grid { grid-template-columns: 1fr; } .target-strand { border-left: 0; } .strand { padding: 18px; } .input-help { padding: 0 18px 18px; } .compute-bar { padding: 18px; } .compute-bar .primary { width: 100%; } .results-heading { display: block; } .results-heading p { margin-top: 12px; } } @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } + +.table-tools { display: flex; align-items: center; flex-wrap: wrap; gap: 16px 24px; padding-bottom: 18px; font-size: 14px; } +.table-tools input { display: block; margin-top: 6px; border: 1px solid #9bb1c1; border-radius: 5px; padding: 8px 12px; width: 260px; max-width: 100%; color: var(--ink); background: white; } +#visible-count { color: var(--muted); margin-right: auto; } +.table-scroll { width: 100%; overflow-x: auto; border: 1px solid var(--line); border-radius: 8px; background: white; } +table { width: 100%; border-collapse: collapse; text-align: left; font-size: 14px; font-variant-numeric: tabular-nums; } +th { background: #e8f1f6; padding: 0; white-space: nowrap; } +th button { display: block; width: 100%; padding: 16px 14px; border: 0; color: var(--ink); background: transparent; text-align: left; font-size: 13px; font-weight: 650; } +th button:hover { background: #dce9f1; } +th[aria-sort="ascending"] button::after { content: " ▴"; } +th[aria-sort="descending"] button::after { content: " ▾"; } +td { border-top: 1px solid #e0e9ee; padding: 12px 14px; max-width: 260px; overflow-wrap: anywhere; } +td.number { text-align: right; } +tr:hover td { background: #f4fafb; } +td:nth-last-child(2) { color: #075e62; font-weight: 650; } +.table-note, #no-matches { color: var(--muted); font-size: 13px; margin: 14px 0 20px; } diff --git a/webinterface/tests/results.test.mjs b/webinterface/tests/results.test.mjs new file mode 100644 index 00000000..090fe521 --- /dev/null +++ b/webinterface/tests/results.test.mjs @@ -0,0 +1,25 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseResults, selectRows } from '../results.js'; +const header = 'id1;start1;end1;id2;start2;end2;E\n'; + +test('native CSV orientation, numeric coordinates, energy and relative suboptimality', () => { + const rows = parseResults(header + 'target;2;10;query;1;9;-19.6\ntarget;1;10;query;1;10;-21.4\n'); + assert.equal(rows[0].start1, 2); + assert.equal(rows[0].start2, 1); + assert.equal(rows[0].deltaE, 1.8); + assert.equal(rows[1].deltaE, 0); + assert.deepEqual(selectRows(rows, '', 'E', 1).map((row) => row.E), [-21.4, -19.6]); + assert.deepEqual(selectRows(rows, '', 'start1', -1).map((row) => row.start1), [2, 1]); + assert.equal(selectRows(rows, 'QUERY', 'id2', 1).length, 2); + assert.equal(selectRows(rows, 'missing', 'id2', 1).length, 0); +}); + +test('empty output and literal native ID quotes; malformed output fails clearly', () => { + assert.deepEqual(parseResults(''), []); + assert.deepEqual(parseResults(header), []); + assert.equal(parseResults(header + '"name";1;7;query;1;7;-3\n')[0].id1, '"name"'); + for (const csv of ['unexpected\n', header + 'too;few\n', header + 't;0;1;q;1;2;-3\n', header + 't;2;1;q;1;2;-3\n', header + 't;1;7;q;1;7;NAN\n', header + 't;1;7;q;1;7;\n']) { + assert.throws(() => parseResults(csv)); + } +}); From 317258360c22556bee66a30403f6c995fe9dd223 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Tue, 20 Oct 2026 21:36:04 +0200 Subject: [PATCH 06/31] test(web): verify native accessibility and real browser cancellation --- .github/workflows/build-wasm.yml | 8 +-- ChangeLog | 10 ++++ docs/step1-wasm-compilation.md | 9 ++-- scripts/wasm/build.sh | 6 ++- scripts/wasm/smoke.mjs | 50 +++++++++++++------ scripts/wasm/test-browser.py | 7 +++ webinterface/app.js | 1 + .../docs/step4-results-and-deployment.md | 23 +++++++++ 8 files changed, 89 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build-wasm.yml b/.github/workflows/build-wasm.yml index 4324e813..46e82f90 100644 --- a/.github/workflows/build-wasm.yml +++ b/.github/workflows/build-wasm.yml @@ -45,15 +45,15 @@ jobs: python -m playwright install --with-deps chromium firefox - name: Exercise the browser with real WASM run: | - python -m http.server 8765 --bind 127.0.0.1 --directory build-wasm/site > /tmp/intarna-http.log 2>&1 & + python -m http.server 8765 --bind 127.0.0.1 --directory build-wasm > /tmp/intarna-http.log 2>&1 & server_pid=$! trap 'kill "$server_pid"' EXIT for attempt in {1..30}; do - if curl --fail --silent http://127.0.0.1:8765/ > /dev/null; then break; fi + if curl --fail --silent http://127.0.0.1:8765/site/ > /dev/null; then break; fi sleep 1 done - python scripts/wasm/test-browser.py http://127.0.0.1:8765 --screenshot build-wasm/browser.png - python scripts/wasm/test-browser.py http://127.0.0.1:8765 --browser firefox + python scripts/wasm/test-browser.py http://127.0.0.1:8765/site/ --screenshot build-wasm/browser.png + python scripts/wasm/test-browser.py http://127.0.0.1:8765/site/ --browser firefox - uses: actions/upload-artifact@v4 with: name: intarna-webinterface diff --git a/ChangeLog b/ChangeLog index ac49b18a..2b5c79c0 100644 --- a/ChangeLog +++ b/ChangeLog @@ -76,6 +76,8 @@ ## Technical changes and Optimizations - cross-compile IntaRNA and pinned dependencies to WebAssembly (#286, step 1) +- verify browser predictions against native accessibility/energy references and + exercise real WASM cancellation and nested Pages paths in Chromium/Firefox - reuse MFE seed-extension scratch matrices and bound predecessor scans in exact and heuristic prediction; evaluate physical energies directly (PR #276 review) @@ -230,6 +232,14 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261020 Alexander Mitrofanov + * scripts/wasm/*, .github/workflows/build-wasm.yml, webinterface/app.js + + Execute dependency API probes and test structured accessibility costs, + native prediction references, C++ errors, nested paths and literal IDs. + * Download archives atomically and clear obsolete diagnostics on input edits. + * webinterface/docs/step4-results-and-deployment.md + + Record native API/CLI, real WASM and Chromium/Firefox validation (#286). + 261020 Alexander Mitrofanov * webinterface/results.js, app.js, index.html, styles.css, tests/*, README.md + Parse native CSV, preserve target/query orientation and inclusive indices, diff --git a/docs/step1-wasm-compilation.md b/docs/step1-wasm-compilation.md index f993ffc1..ffa6d170 100644 --- a/docs/step1-wasm-compilation.md +++ b/docs/step1-wasm-compilation.md @@ -36,7 +36,7 @@ It does not change the RNA algorithms. The legacy Boost and zlib configure macros declare dummy test functions with incorrect signatures (`exit()` and `inflateEnd()`), which wasm-ld correctly -rejects. Before caching just those probes, `dependency-probe.cpp` links actual +rejects. Before caching just those probes, `dependency-probe.cpp` links and executes actual filesystem, regex, options, archive, gzip and RNAlib APIs against the WASM libraries. Configure still checks C++23, headers, serialization and RNAlib APIs; the smoke test executes the real prediction. `VRNA_LIBS` supplies the static @@ -70,6 +70,7 @@ failure is reported as a failed prediction; devices may impose lower limits. See [Emscripten's Autotools guidance](https://emscripten.org/docs/compiling/Building-Projects.html) for `emconfigure`/`emmake`. Generated configure files, dependency sources and WASM binaries are build outputs, not tracked source. The artifact must keep JS and -WASM from the same build together. The Node smoke test runs a real -Turner/default-accessibility prediction and checks its energy, coordinates and -suboptimal count against the native CLI. +WASM from the same build together. The Node smoke test runs real +Turner/default-accessibility predictions and checks energy, coordinates, +nonzero accessibility costs, suboptimal counts and C++ error handling against +the native CLI. diff --git a/scripts/wasm/build.sh b/scripts/wasm/build.sh index 9c94db03..33ccaa17 100644 --- a/scripts/wasm/build.sh +++ b/scripts/wasm/build.sh @@ -7,14 +7,15 @@ mkdir -p "$build" build=$(cd "$build" && pwd) prefix="$build/deps" jobs=${JOBS:-2} -for tool in emcc em++ emar emranlib emconfigure emmake curl pkg-config; do +for tool in emcc em++ emar emranlib emconfigure emmake curl pkg-config node; do command -v "$tool" >/dev/null || { echo "Missing tool: $tool" >&2; exit 1; } done mkdir -p "$build/downloads" "$build/sources" "$prefix" "$build/site/wasm" fetch() { local url=$1 name=$2 checksum=$3 if [[ ! -f "$build/downloads/$name" ]]; then - curl --fail --location --retry 3 "$url" -o "$build/downloads/$name" + curl --fail --location --retry 3 "$url" -o "$build/downloads/$name.part" + mv "$build/downloads/$name.part" "$build/downloads/$name" fi echo "$checksum $build/downloads/$name" | sha256sum --check - } @@ -74,6 +75,7 @@ em++ -std=c++23 -O2 -fexceptions -I"$prefix/include" \ "$repo/scripts/wasm/dependency-probe.cpp" -L"$prefix/lib" \ -lboost_filesystem -lboost_system -lboost_program_options -lboost_regex \ -lboost_iostreams -lboost_serialization -lRNA -lz -o "$build/dependency-probe.js" +node "$build/dependency-probe.js" export ac_cv_lib_boost_system_exit=yes export ac_cv_lib_boost_filesystem_exit=yes export ac_cv_lib_boost_program_options_exit=yes diff --git a/scripts/wasm/smoke.mjs b/scripts/wasm/smoke.mjs index ec93d750..5bab9756 100644 --- a/scripts/wasm/smoke.mjs +++ b/scripts/wasm/smoke.mjs @@ -1,25 +1,45 @@ -// Exercise the actual CLI and RNAlib in a fresh WASM instance. +// Exercise the actual CLI and RNAlib in fresh WASM instances. import assert from 'node:assert/strict'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; const asset = resolve(process.argv[2] || 'build-wasm/site/wasm/intarna.js'); const { default: createIntaRNA } = await import(pathToFileURL(asset)); -const diagnostics = []; -const module = await createIntaRNA({ - noInitialRun: true, - locateFile: () => new URL('./intarna.wasm', pathToFileURL(asset)).href, - print: (line) => diagnostics.push(line), - printErr: (line) => diagnostics.push(line), -}); -const code = module.callMain([ - '--query=GGGAGGAGGG', '--target=CCCUCCUCCC', '--outMode=C', - '--outNumber=10', '--outOverlap=B', - '--outCsvCols=id1,start1,end1,id2,start2,end2,E', '--out=/results.csv', +async function predict(args) { + const diagnostics = []; + const module = await createIntaRNA({ + noInitialRun: true, + locateFile: () => new URL('./intarna.wasm', pathToFileURL(asset)).href, + print: (line) => diagnostics.push(line), + printErr: (line) => diagnostics.push(line), + }); + const code = module.callMain([...args, '--outMode=C', '--outOverlap=B', '--out=/results.csv']); + return { module, code, diagnostics: diagnostics.join('\n') }; +} +const columns = '--outCsvCols=id1,start1,end1,id2,start2,end2,E'; +const first = await predict([ + '--query=GGGAGGAGGG', '--target=CCCUCCUCCC', '--outNumber=10', columns, ]); -assert.equal(code, 0, diagnostics.join('\n')); -const csv = module.FS.readFile('/results.csv', { encoding: 'utf8' }); +assert.equal(first.code, 0, first.diagnostics); +const csv = first.module.FS.readFile('/results.csv', { encoding: 'utf8' }); assert.match(csv, /^id1;start1;end1;id2;start2;end2;E\n/); // Reference from the native CLI with the same Turner/default-accessibility model. assert.equal(csv.trim().split('\n')[1], 'target;1;10;query;1;10;-21.4'); assert.equal(csv.trim().split('\n').length, 11); -console.log('WASM CLI: native-reference energy/coordinates and 10 suboptimals passed'); + +// Hairpin-forming strands check nonzero accessibility costs as well as pairing. +const structured = await predict([ + '--query=GGGAAACCCGGGAAACCC', '--target=GGGUUUCCCGGGUUUCCC', + '--outNumber=3', `${columns},ED1,ED2`, +]); +assert.equal(structured.code, 0, structured.diagnostics); +const structuredCsv = structured.module.FS.readFile('/results.csv', { encoding: 'utf8' }); +assert.equal(structuredCsv.trim().split('\n')[1], 'target;1;18;query;1;18;-28.04;4.6;4.86'); +assert.equal(structuredCsv.trim().split('\n').length, 4); + +// Input errors must travel through the C++ exception handler, not trap WASM. +const invalid = await predict(['--query=ACGU!ACGU', '--target=ACGUACGU', columns]); +assert.notEqual(invalid.code, 0); +assert.match(invalid.diagnostics, /error/i); +// The CLI's nonzero exit status is local to the deliberately invalid instance. +process.exitCode = 0; +console.log('WASM CLI: native-reference coordinates/energy, structured accessibility, suboptimals and C++ error handling passed'); diff --git a/scripts/wasm/test-browser.py b/scripts/wasm/test-browser.py index 295fde57..b712bde1 100644 --- a/scripts/wasm/test-browser.py +++ b/scripts/wasm/test-browser.py @@ -97,6 +97,13 @@ expect(status).to_contain_text("No favorable interactions", timeout=120000) expect(page.locator("#results-view")).not_to_be_visible() + # Sequence identifiers remain text, even if they resemble HTML. + page.locator("#query").fill(">\nGGGAGGAGGG") + compute.click() + expect(status).to_contain_text("Prediction complete", timeout=120000) + assert page.locator("tbody img").count() == 0 + assert " Date: Wed, 21 Oct 2026 11:09:56 +0200 Subject: [PATCH 07/31] move step 1 file --- {docs => webinterface/docs}/step1-wasm-compilation.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {docs => webinterface/docs}/step1-wasm-compilation.md (100%) diff --git a/docs/step1-wasm-compilation.md b/webinterface/docs/step1-wasm-compilation.md similarity index 100% rename from docs/step1-wasm-compilation.md rename to webinterface/docs/step1-wasm-compilation.md From 89a951e687d5fe0476858a67e7dd9a342541df19 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Wed, 21 Oct 2026 17:04:46 +0200 Subject: [PATCH 08/31] fix: mark inherited IntaRNA virtual methods as override --- src/IntaRNA/AccessibilityBasePair.h | 4 +- src/IntaRNA/AccessibilityDisabled.h | 4 +- src/IntaRNA/AccessibilityFromStream.h | 6 +- src/IntaRNA/AccessibilityVrna.h | 4 +- src/IntaRNA/HelixHandlerIdxOffset.h | 28 ++++---- src/IntaRNA/HelixHandlerNoBulgeMax.h | 28 ++++---- src/IntaRNA/HelixHandlerUnpaired.h | 28 ++++---- src/IntaRNA/InteractionEnergyBasePair.h | 30 ++++----- src/IntaRNA/InteractionEnergyIdxOffset.h | 64 +++++++++---------- src/IntaRNA/InteractionEnergyVrna.h | 30 ++++----- src/IntaRNA/OutputHandlerCsv.h | 4 +- src/IntaRNA/OutputHandlerEnsemble.h | 4 +- src/IntaRNA/OutputHandlerHub.h | 10 +-- src/IntaRNA/OutputHandlerInteractionList.h | 4 +- src/IntaRNA/OutputHandlerText.h | 4 +- src/IntaRNA/OutputStreamHandlerSortedCsv.h | 4 +- src/IntaRNA/PredictionTrackerHub.h | 4 +- src/IntaRNA/PredictionTrackerPairMinE.h | 4 +- src/IntaRNA/PredictionTrackerProfileMinE.h | 4 +- .../PredictionTrackerProfileSpotProb.h | 4 +- src/IntaRNA/PredictionTrackerSpotProb.h | 4 +- src/IntaRNA/PredictionTrackerSpotProbAll.h | 4 +- src/IntaRNA/PredictorMfe.h | 8 +-- src/IntaRNA/PredictorMfe2d.h | 6 +- .../PredictorMfe2dHelixBlockHeuristic.h | 12 ++-- .../PredictorMfe2dHelixBlockHeuristicSeed.h | 8 +-- src/IntaRNA/PredictorMfe2dHeuristic.h | 10 +-- src/IntaRNA/PredictorMfe2dHeuristicSeed.h | 10 +-- .../PredictorMfe2dHeuristicSeedExtension.h | 4 +- src/IntaRNA/PredictorMfe2dSeed.h | 8 +-- src/IntaRNA/PredictorMfe2dSeedExtension.h | 6 +- .../PredictorMfe2dSeedExtensionRIblast.h | 6 +- src/IntaRNA/PredictorMfeEns.h | 6 +- src/IntaRNA/PredictorMfeEns2d.h | 4 +- src/IntaRNA/PredictorMfeEns2dHeuristic.h | 4 +- .../PredictorMfeEns2dHeuristicSeedExtension.h | 6 +- src/IntaRNA/PredictorMfeEns2dSeedExtension.h | 6 +- src/IntaRNA/PredictorMfeEnsSeedOnly.h | 6 +- src/IntaRNA/PredictorMfeSeedOnly.h | 6 +- src/IntaRNA/PredictorSeedExtensionKinetic.h | 2 +- src/IntaRNA/ReverseAccessibility.h | 8 +-- src/IntaRNA/SeedHandlerExplicit.h | 16 ++--- src/IntaRNA/SeedHandlerIdxOffset.h | 26 ++++---- src/IntaRNA/SeedHandlerMfe.h | 14 ++-- src/IntaRNA/SeedHandlerNoBulge.h | 16 ++--- 45 files changed, 239 insertions(+), 239 deletions(-) diff --git a/src/IntaRNA/AccessibilityBasePair.h b/src/IntaRNA/AccessibilityBasePair.h index 54cf9471..f8866c0d 100644 --- a/src/IntaRNA/AccessibilityBasePair.h +++ b/src/IntaRNA/AccessibilityBasePair.h @@ -47,7 +47,7 @@ class AccessibilityBasePair: public Accessibility { /*** * Destructor of AccessibilityBasePair */ - virtual ~AccessibilityBasePair(); + virtual ~AccessibilityBasePair() override; /*** * Returns the accessibility energy value between the indices (from, to) @@ -59,7 +59,7 @@ class AccessibilityBasePair: public Accessibility { * * @throw std::runtime_error in case it does not hold 0 <= from <= to < seq.length */ - virtual E_type getED( const size_t from, const size_t to ) const; + virtual E_type getED( const size_t from, const size_t to ) const override; protected: diff --git a/src/IntaRNA/AccessibilityDisabled.h b/src/IntaRNA/AccessibilityDisabled.h index f50ba243..d72f9215 100644 --- a/src/IntaRNA/AccessibilityDisabled.h +++ b/src/IntaRNA/AccessibilityDisabled.h @@ -38,7 +38,7 @@ class AccessibilityDisabled: public Accessibility { /** * destruction */ - virtual ~AccessibilityDisabled(); + virtual ~AccessibilityDisabled() override; /** * Always returns a zero accessibility energy value. @@ -50,7 +50,7 @@ class AccessibilityDisabled: public Accessibility { */ virtual E_type - getED( const size_t from, const size_t to ) const; + getED( const size_t from, const size_t to ) const override; }; diff --git a/src/IntaRNA/AccessibilityFromStream.h b/src/IntaRNA/AccessibilityFromStream.h index 79493c0e..a3169214 100644 --- a/src/IntaRNA/AccessibilityFromStream.h +++ b/src/IntaRNA/AccessibilityFromStream.h @@ -52,7 +52,7 @@ class AccessibilityFromStream: public Accessibility /** * destruction */ - virtual ~AccessibilityFromStream(); + virtual ~AccessibilityFromStream() override; /** @@ -68,7 +68,7 @@ class AccessibilityFromStream: public Accessibility */ virtual E_type - getED( const size_t from, const size_t to ) const; + getED( const size_t from, const size_t to ) const override; /** * Access to the maximal length of accessible regions (>0) to be considered. @@ -80,7 +80,7 @@ class AccessibilityFromStream: public Accessibility */ virtual size_t - getMaxLength() const; + getMaxLength() const override; /** diff --git a/src/IntaRNA/AccessibilityVrna.h b/src/IntaRNA/AccessibilityVrna.h index 83a03c07..1f4ac3f4 100644 --- a/src/IntaRNA/AccessibilityVrna.h +++ b/src/IntaRNA/AccessibilityVrna.h @@ -57,7 +57,7 @@ class AccessibilityVrna : public Accessibility { /** * destruction */ - virtual ~AccessibilityVrna(); + virtual ~AccessibilityVrna() override; /** * Returns the accessibility energy value for the given range in the @@ -72,7 +72,7 @@ class AccessibilityVrna : public Accessibility { */ virtual E_type - getED( const size_t from, const size_t to ) const; + getED( const size_t from, const size_t to ) const override; /** diff --git a/src/IntaRNA/HelixHandlerIdxOffset.h b/src/IntaRNA/HelixHandlerIdxOffset.h index bd8dfe32..d06153bf 100644 --- a/src/IntaRNA/HelixHandlerIdxOffset.h +++ b/src/IntaRNA/HelixHandlerIdxOffset.h @@ -31,7 +31,7 @@ class HelixHandlerIdxOffset : public HelixHandler /** * destruction */ - virtual ~HelixHandlerIdxOffset(); + virtual ~HelixHandlerIdxOffset() override; /** * Access to the underlying interaction energy function @@ -39,7 +39,7 @@ class HelixHandlerIdxOffset : public HelixHandler */ virtual const InteractionEnergy& - getInteractionEnergy() const; + getInteractionEnergy() const override; /** * Access to the currently used index offset for sequence 1 @@ -73,7 +73,7 @@ class HelixHandlerIdxOffset : public HelixHandler */ virtual const HelixConstraint& - getConstraint() const; + getConstraint() const override; /** * Computes the helix matrix for the given interval boundaries @@ -89,7 +89,7 @@ class HelixHandlerIdxOffset : public HelixHandler */ virtual size_t - fillHelix(const size_t i1, const size_t j1, const size_t i2, const size_t j2); + fillHelix(const size_t i1, const size_t j1, const size_t i2, const size_t j2) override; /** * Computes the helix matrix for the given interval boundaries for helices containing a seed @@ -104,7 +104,7 @@ class HelixHandlerIdxOffset : public HelixHandler */ virtual size_t - fillHelixSeed(const size_t i1, const size_t j1, const size_t i2, const size_t j2); + fillHelixSeed(const size_t i1, const size_t j1, const size_t i2, const size_t j2) override; /** * Identifies the base pairs of the mfe helix interaction starting at i1,i2 @@ -120,7 +120,7 @@ class HelixHandlerIdxOffset : public HelixHandler */ virtual void - traceBackHelix( Interaction & interaction, const size_t i1, const size_t i2); + traceBackHelix( Interaction & interaction, const size_t i1, const size_t i2) override; /** * Identifies the base pairs of the mfe helix interaction, containing a seed, starting at i1,i2 @@ -134,7 +134,7 @@ class HelixHandlerIdxOffset : public HelixHandler */ virtual void - traceBackHelixSeed( Interaction & interaction, const size_t i1, const size_t i2); + traceBackHelixSeed( Interaction & interaction, const size_t i1, const size_t i2) override; /** * Access to the mfe of any helix with left-most base pair (i1,i2) @@ -147,7 +147,7 @@ class HelixHandlerIdxOffset : public HelixHandler */ virtual E_type - getHelixE( const size_t i1, const size_t i2 ) const; + getHelixE( const size_t i1, const size_t i2 ) const override; /** * Access to the mfe of any helix with left-most base pair (i1,i2) and containing a seed @@ -160,7 +160,7 @@ class HelixHandlerIdxOffset : public HelixHandler */ virtual E_type - getHelixSeedE( const size_t i1, const size_t i2 ) const; + getHelixSeedE( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq1 of the mfe helix with left-most base pair (i1,i2) @@ -173,7 +173,7 @@ class HelixHandlerIdxOffset : public HelixHandler */ virtual size_t - getHelixLength1( const size_t i1, const size_t i2 ) const; + getHelixLength1( const size_t i1, const size_t i2 ) const override; /** @@ -187,7 +187,7 @@ class HelixHandlerIdxOffset : public HelixHandler */ virtual size_t - getHelixLength2( const size_t i1, const size_t i2 ) const; + getHelixLength2( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq1 of the mfe helix with left-most base pair (i1,i2), containing a seed @@ -200,7 +200,7 @@ class HelixHandlerIdxOffset : public HelixHandler */ virtual size_t - getHelixSeedLength1( const size_t i1, const size_t i2 ) const; + getHelixSeedLength1( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq2 of the mfe helix with left-most base pair (i1,i2), containing a seed @@ -213,7 +213,7 @@ class HelixHandlerIdxOffset : public HelixHandler */ virtual size_t - getHelixSeedLength2( const size_t i1, const size_t i2 ) const; + getHelixSeedLength2( const size_t i1, const size_t i2 ) const override; /** * Check whether the given seedHandler is of type SeedHandlerIdxOffset @@ -223,7 +223,7 @@ class HelixHandlerIdxOffset : public HelixHandler */ virtual void - setSeedHandler( SeedHandler & seedHandler); + setSeedHandler( SeedHandler & seedHandler) override; protected: diff --git a/src/IntaRNA/HelixHandlerNoBulgeMax.h b/src/IntaRNA/HelixHandlerNoBulgeMax.h index 485ec935..cfbe5ca3 100644 --- a/src/IntaRNA/HelixHandlerNoBulgeMax.h +++ b/src/IntaRNA/HelixHandlerNoBulgeMax.h @@ -49,7 +49,7 @@ class HelixHandlerNoBulgeMax : public HelixHandler { /** * destructor */ - virtual ~HelixHandlerNoBulgeMax(); + virtual ~HelixHandlerNoBulgeMax() override; /** * Access to the underlying helix constraint @@ -57,7 +57,7 @@ class HelixHandlerNoBulgeMax : public HelixHandler { */ virtual const HelixConstraint& - getConstraint() const; + getConstraint() const override; /** * Access to the underlying interaction energy function @@ -65,7 +65,7 @@ class HelixHandlerNoBulgeMax : public HelixHandler { */ virtual const InteractionEnergy& - getInteractionEnergy() const; + getInteractionEnergy() const override; /** @@ -78,7 +78,7 @@ class HelixHandlerNoBulgeMax : public HelixHandler { */ virtual size_t - fillHelix( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ); + fillHelix( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ) override; /** * Compute the helix matrix, containing a seed, for the given interval boundaries @@ -90,7 +90,7 @@ class HelixHandlerNoBulgeMax : public HelixHandler { */ virtual size_t - fillHelixSeed( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ); + fillHelixSeed( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ) override; /** * Identifies the base pairs of the mfe helix interaction starting at i1,i2 @@ -104,7 +104,7 @@ class HelixHandlerNoBulgeMax : public HelixHandler { */ virtual void - traceBackHelix( Interaction & interaction, const size_t i1, const size_t i2); + traceBackHelix( Interaction & interaction, const size_t i1, const size_t i2) override; /** * Identifies the base pairs of the mfe helix interaction, containing a seed, starting at i1,i2 @@ -116,7 +116,7 @@ class HelixHandlerNoBulgeMax : public HelixHandler { */ virtual void - traceBackHelixSeed( Interaction & interaction, const size_t i1, size_t i2); + traceBackHelixSeed( Interaction & interaction, const size_t i1, size_t i2) override; /** * Access to the mfe of any helix with left-most base pair (i1, i2) @@ -126,7 +126,7 @@ class HelixHandlerNoBulgeMax : public HelixHandler { * or return 0 if bp is 0 or 1 (used in helixHandlerSeed computation to avoid too many conditions) */ virtual - E_type getHelixE( const size_t i1, const size_t i2 ) const; + E_type getHelixE( const size_t i1, const size_t i2 ) const override; /** * Access to the mfe of any helix, containing a seed, with left-most base pair (i1, i2) @@ -135,7 +135,7 @@ class HelixHandlerNoBulgeMax : public HelixHandler { * @return the mfe of any helix starting at (i1,i2) or E_INF if none possible */ virtual - E_type getHelixSeedE( const size_t i1, const size_t i2 ) const; + E_type getHelixSeedE( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq1 of the mfe helix with left-most base pair (i1,i2) @@ -145,7 +145,7 @@ class HelixHandlerNoBulgeMax : public HelixHandler { */ virtual size_t - getHelixLength1( const size_t i1, const size_t i2 ) const; + getHelixLength1( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq2 of the mfe helix with left-most base pair (i1,i2) @@ -155,7 +155,7 @@ class HelixHandlerNoBulgeMax : public HelixHandler { */ virtual size_t - getHelixLength2( const size_t i1, const size_t i2 ) const; + getHelixLength2( const size_t i1, const size_t i2 ) const override; /** @@ -166,7 +166,7 @@ class HelixHandlerNoBulgeMax : public HelixHandler { */ virtual size_t - getHelixSeedLength1( const size_t i1, const size_t i2 ) const; + getHelixSeedLength1( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq2 of the mfe helix, containing a seed, with left-most base pair (i1,i2) @@ -176,13 +176,13 @@ class HelixHandlerNoBulgeMax : public HelixHandler { */ virtual size_t - getHelixSeedLength2( const size_t i1, const size_t i2 ) const; + getHelixSeedLength2( const size_t i1, const size_t i2 ) const override; /** * Set the seedHandler in order to compute helixSeed * @param seedHandler seedHandler to be used in the helix computation */ - void setSeedHandler(SeedHandler & seedHandler); + void setSeedHandler(SeedHandler & seedHandler) override; protected: diff --git a/src/IntaRNA/HelixHandlerUnpaired.h b/src/IntaRNA/HelixHandlerUnpaired.h index 0be76450..dddafa95 100644 --- a/src/IntaRNA/HelixHandlerUnpaired.h +++ b/src/IntaRNA/HelixHandlerUnpaired.h @@ -58,7 +58,7 @@ class HelixHandlerUnpaired : public HelixHandler { /** * destructor */ - virtual ~HelixHandlerUnpaired(); + virtual ~HelixHandlerUnpaired() override; /** * Access to the underlying helix constraint @@ -66,7 +66,7 @@ class HelixHandlerUnpaired : public HelixHandler { */ virtual const HelixConstraint& - getConstraint() const; + getConstraint() const override; /** * Access to the underlying interaction energy function @@ -74,7 +74,7 @@ class HelixHandlerUnpaired : public HelixHandler { */ virtual const InteractionEnergy& - getInteractionEnergy() const; + getInteractionEnergy() const override; /** * Compute the helix matrix for the given interval boundaries @@ -86,7 +86,7 @@ class HelixHandlerUnpaired : public HelixHandler { */ virtual size_t - fillHelix( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ); + fillHelix( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ) override; /** * Computes the helix information for the given interval boundaries for helices containing a seed @@ -98,7 +98,7 @@ class HelixHandlerUnpaired : public HelixHandler { */ virtual size_t - fillHelixSeed(const size_t i1, const size_t j1, const size_t i2, const size_t j2); + fillHelixSeed(const size_t i1, const size_t j1, const size_t i2, const size_t j2) override; /** * Identifies the base pairs of the mfe helix interaction starting at i1,i2 @@ -112,7 +112,7 @@ class HelixHandlerUnpaired : public HelixHandler { */ virtual void - traceBackHelix( Interaction & interaction, const size_t i1, const size_t i2); + traceBackHelix( Interaction & interaction, const size_t i1, const size_t i2) override; /** * Identifies the base pairs of the mfe helix interaction starting at i1,i2 @@ -126,7 +126,7 @@ class HelixHandlerUnpaired : public HelixHandler { */ virtual void - traceBackHelixSeed( Interaction & interaction, const size_t i1, const size_t i2); + traceBackHelixSeed( Interaction & interaction, const size_t i1, const size_t i2) override; /** * Access to the mfe of any helix with left-most base pair (i1, i2) @@ -136,7 +136,7 @@ class HelixHandlerUnpaired : public HelixHandler { */ virtual E_type - getHelixE( const size_t i1, const size_t i2 ) const; + getHelixE( const size_t i1, const size_t i2 ) const override; /** * Access to the mfe of any helix with left-most base pair (i1,i2) @@ -146,7 +146,7 @@ class HelixHandlerUnpaired : public HelixHandler { */ virtual E_type - getHelixSeedE( const size_t i1, const size_t i2 ) const; + getHelixSeedE( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq1 of the mfe helix with left-most base pair (i1,i2) @@ -156,7 +156,7 @@ class HelixHandlerUnpaired : public HelixHandler { */ virtual size_t - getHelixLength1( const size_t i1, const size_t i2 ) const; + getHelixLength1( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq2 of the mfe helix with left-most base pair (i1,i2) @@ -166,7 +166,7 @@ class HelixHandlerUnpaired : public HelixHandler { */ virtual size_t - getHelixLength2( const size_t i1, const size_t i2 ) const; + getHelixLength2( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq1 of the mfe helix with left-most base pair (i1,i2) @@ -198,7 +198,7 @@ class HelixHandlerUnpaired : public HelixHandler { */ virtual size_t - getHelixSeedLength1( const size_t i1, const size_t i2 ) const; + getHelixSeedLength1( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq2 of the mfe helix with left-most base pair (i1,i2) containing a seed @@ -208,13 +208,13 @@ class HelixHandlerUnpaired : public HelixHandler { */ virtual size_t - getHelixSeedLength2( const size_t i1, const size_t i2 ) const; + getHelixSeedLength2( const size_t i1, const size_t i2 ) const override; /** * Set the seedHandler in order to compute helixSeed * @param seedHandler seedHandler to be used in the helix computation */ - void setSeedHandler(SeedHandler & seedHandler); + void setSeedHandler(SeedHandler & seedHandler) override; protected: diff --git a/src/IntaRNA/InteractionEnergyBasePair.h b/src/IntaRNA/InteractionEnergyBasePair.h index 27804aeb..1d39ac36 100644 --- a/src/IntaRNA/InteractionEnergyBasePair.h +++ b/src/IntaRNA/InteractionEnergyBasePair.h @@ -61,7 +61,7 @@ class InteractionEnergyBasePair: public InteractionEnergy { , const bool internalLoopGU = true ); - virtual ~InteractionEnergyBasePair(); + virtual ~InteractionEnergyBasePair() override; /** @@ -79,7 +79,7 @@ class InteractionEnergyBasePair: public InteractionEnergy { */ virtual E_type - getES1( const size_t i1, const size_t j1 ) const; + getES1( const size_t i1, const size_t j1 ) const override; /** * Provides the ensemble energy (ES) of all intramolecular substructures @@ -96,7 +96,7 @@ class InteractionEnergyBasePair: public InteractionEnergy { */ virtual E_type - getES2( const size_t i2, const size_t j2 ) const; + getES2( const size_t i2, const size_t j2 ) const override; /** * Provides the energy contribution for a given number of unpaired @@ -109,7 +109,7 @@ class InteractionEnergyBasePair: public InteractionEnergy { */ virtual E_type - getE_multiUnpaired( const size_t numUnpaired ) const; + getE_multiUnpaired( const size_t numUnpaired ) const override; /** @@ -125,7 +125,7 @@ class InteractionEnergyBasePair: public InteractionEnergy { */ virtual E_type - getE_multiHelix( const size_t j1, const size_t j2 ) const; + getE_multiHelix( const size_t j1, const size_t j2 ) const override; /** * Provides the energy contribution/penalty for closing an intermolecular @@ -135,7 +135,7 @@ class InteractionEnergyBasePair: public InteractionEnergy { */ virtual E_type - getE_multiClosing() const; + getE_multiClosing() const override; /** @@ -145,7 +145,7 @@ class InteractionEnergyBasePair: public InteractionEnergy { */ virtual E_type - getE_init() const; + getE_init() const override; /** @@ -165,7 +165,7 @@ class InteractionEnergyBasePair: public InteractionEnergy { */ virtual E_type - getE_interLeft( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ) const; + getE_interLeft( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ) const override; /** * Computes the dangling end energy penalty estimate for the left side of @@ -181,7 +181,7 @@ class InteractionEnergyBasePair: public InteractionEnergy { */ virtual E_type - getE_danglingLeft( const size_t i1, const size_t i2 ) const; + getE_danglingLeft( const size_t i1, const size_t i2 ) const override; /** @@ -196,7 +196,7 @@ class InteractionEnergyBasePair: public InteractionEnergy { */ virtual E_type - getE_danglingRight( const size_t j1, const size_t j2 ) const; + getE_danglingRight( const size_t j1, const size_t j2 ) const override; /** * Provides the penalty for closing an interaction with the given @@ -209,7 +209,7 @@ class InteractionEnergyBasePair: public InteractionEnergy { */ virtual E_type - getE_endLeft( const size_t i1, const size_t i2 ) const; + getE_endLeft( const size_t i1, const size_t i2 ) const override; /** * Provides the penalty for closing an interaction with the given @@ -222,7 +222,7 @@ class InteractionEnergyBasePair: public InteractionEnergy { */ virtual E_type - getE_endRight( const size_t j1, const size_t j2 ) const; + getE_endRight( const size_t j1, const size_t j2 ) const override; /** * Returns always RT=1 due to the lack of reasonable values for this energy @@ -231,7 +231,7 @@ class InteractionEnergyBasePair: public InteractionEnergy { */ virtual Z_type - getRT() const; + getRT() const override; /** * Provides the (constant) energy contribution of a base pair (sequence independent) @@ -249,7 +249,7 @@ class InteractionEnergyBasePair: public InteractionEnergy { */ virtual E_type - getEall1() const; + getEall1() const override; /** * Provides the overall ensemble energy for sequence 2 @@ -258,7 +258,7 @@ class InteractionEnergyBasePair: public InteractionEnergy { */ virtual E_type - getEall2() const; + getEall2() const override; private: diff --git a/src/IntaRNA/InteractionEnergyIdxOffset.h b/src/IntaRNA/InteractionEnergyIdxOffset.h index 1d0c7b69..f43e016e 100644 --- a/src/IntaRNA/InteractionEnergyIdxOffset.h +++ b/src/IntaRNA/InteractionEnergyIdxOffset.h @@ -30,7 +30,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy , const size_t offset2 = 0 ); - virtual ~InteractionEnergyIdxOffset(); + virtual ~InteractionEnergyIdxOffset() override; /** * Access to the currently used index offset for sequence 1 @@ -77,7 +77,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy E_type getE( const size_t i1, const size_t j1 , const size_t i2, const size_t j2 - , const E_type hybridE ) const; + , const E_type hybridE ) const override; /** * Provides the ensemble energy for a given partition function Z. @@ -88,7 +88,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getE( const Z_type Z ) const; + getE( const Z_type Z ) const override; /** * Checks whether or not two positions (shifted by offset) can form a base pair @@ -98,7 +98,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual bool - areComplementary( const size_t i1, const size_t i2 ) const; + areComplementary( const size_t i1, const size_t i2 ) const override; /** * Checks whether or not two positions can form a GU base pair @@ -108,7 +108,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual bool - isGU( const size_t i1, const size_t i2 ) const; + isGU( const size_t i1, const size_t i2 ) const override; /** * Length of sequence 1 excluding the index offset @@ -116,7 +116,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual size_t - size1() const; + size1() const override; /** * Length of sequence 2 excluding index offset @@ -124,7 +124,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual size_t - size2() const; + size2() const override; /** * Provides the ED penalty for making a region with sequence 1 accessible @@ -137,7 +137,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getED1( const size_t i1, const size_t j1 ) const; + getED1( const size_t i1, const size_t j1 ) const override; /** * Provides the ED penalty for making a region with (the reversed) @@ -151,7 +151,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getED2( const size_t i2, const size_t j2 ) const; + getED2( const size_t i2, const size_t j2 ) const override; /** * Whether or not position i is accessible for interaction in sequence 1 @@ -163,7 +163,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual bool - isAccessible1( const size_t i ) const; + isAccessible1( const size_t i ) const override; /** * Whether or not position i is accessible for interaction in sequence 2 @@ -175,7 +175,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual bool - isAccessible2( const size_t i ) const; + isAccessible2( const size_t i ) const override; /** * Provides the energy contribution of an interaction site gap, i.e. the @@ -197,7 +197,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy E_type getE_multi( const size_t i1, const size_t j1 , const size_t i2, const size_t j2 - , const ES_multi_mode ES_mode ) const; + , const ES_multi_mode ES_mode ) const override; /** * Provides the ensemble energy (ES) of all intramolecular substructures @@ -214,7 +214,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getES1( const size_t i1, const size_t j1 ) const; + getES1( const size_t i1, const size_t j1 ) const override; /** * Provides the ensemble energy (ES) of all intramolecular substructures @@ -231,7 +231,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getES2( const size_t i2, const size_t j2 ) const; + getES2( const size_t i2, const size_t j2 ) const override; /** * Provides the energy contribution for a given number of unpaired @@ -244,7 +244,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getE_multiUnpaired( const size_t numUnpaired ) const; + getE_multiUnpaired( const size_t numUnpaired ) const override; /** @@ -262,7 +262,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getE_multiHelix( const size_t j1, const size_t j2 ) const; + getE_multiHelix( const size_t j1, const size_t j2 ) const override; /** * Provides the energy contribution/penalty for closing an intermolecular @@ -273,7 +273,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getE_multiClosing() const; + getE_multiClosing() const override; /** * Provides the duplex initiation energy. @@ -282,7 +282,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getE_init( ) const; + getE_init( ) const override; /** @@ -306,7 +306,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getE_interLeft( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ) const; + getE_interLeft( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ) const override; /** @@ -323,7 +323,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getE_danglingLeft( const size_t i1, const size_t i2 ) const; + getE_danglingLeft( const size_t i1, const size_t i2 ) const override; /** @@ -340,7 +340,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getE_danglingRight( const size_t j1, const size_t j2 ) const; + getE_danglingRight( const size_t j1, const size_t j2 ) const override; /** * Provides the penalty for closing an interaction with the given @@ -355,7 +355,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getE_endLeft( const size_t i1, const size_t i2 ) const; + getE_endLeft( const size_t i1, const size_t i2 ) const override; /** * Provides the penalty for closing an interaction with the given @@ -370,7 +370,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getE_endRight( const size_t j1, const size_t j2 ) const; + getE_endRight( const size_t j1, const size_t j2 ) const override; /** * Computes the probability of the dangling ends for the left side @@ -388,7 +388,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual Z_type - getPr_danglingLeft( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ) const; + getPr_danglingLeft( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ) const override; /** * Computes the probability of the dangling ends for the right side @@ -406,7 +406,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual Z_type - getPr_danglingRight( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ) const; + getPr_danglingRight( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ) const override; @@ -415,7 +415,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual Z_type - getRT() const; + getRT() const override; /** * Provides the base pair encoding for the given indices after shifting by @@ -426,7 +426,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual Interaction::BasePair - getBasePair( const size_t i1, const size_t i2 ) const; + getBasePair( const size_t i1, const size_t i2 ) const override; /** @@ -436,7 +436,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual size_t - getIndex1( const Interaction::BasePair & bp ) const; + getIndex1( const Interaction::BasePair & bp ) const override; /** @@ -446,7 +446,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual size_t - getIndex2( const Interaction::BasePair & bp ) const; + getIndex2( const Interaction::BasePair & bp ) const override; /** @@ -467,7 +467,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual bool - isValidInternalLoop( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ) const; + isValidInternalLoop( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ) const override; /** * Provides the overall ensemble energy for sequence 1 @@ -476,7 +476,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getEall1() const; + getEall1() const override; /** * Provides the overall ensemble energy for sequence 2 @@ -485,7 +485,7 @@ class InteractionEnergyIdxOffset : public InteractionEnergy */ virtual E_type - getEall2() const; + getEall2() const override; protected: diff --git a/src/IntaRNA/InteractionEnergyVrna.h b/src/IntaRNA/InteractionEnergyVrna.h index 5374f1fb..4b17653b 100644 --- a/src/IntaRNA/InteractionEnergyVrna.h +++ b/src/IntaRNA/InteractionEnergyVrna.h @@ -75,7 +75,7 @@ class InteractionEnergyVrna: public InteractionEnergy { , const bool internalLoopGU = true ); - virtual ~InteractionEnergyVrna(); + virtual ~InteractionEnergyVrna() override; /** @@ -93,7 +93,7 @@ class InteractionEnergyVrna: public InteractionEnergy { */ virtual E_type - getES1( const size_t i1, const size_t j1 ) const; + getES1( const size_t i1, const size_t j1 ) const override; /** * Provides the ensemble energy (ES) of all intramolecular substructures @@ -110,7 +110,7 @@ class InteractionEnergyVrna: public InteractionEnergy { */ virtual E_type - getES2( const size_t i2, const size_t j2 ) const; + getES2( const size_t i2, const size_t j2 ) const override; /** * Provides the energy contribution for a given number of unpaired @@ -123,7 +123,7 @@ class InteractionEnergyVrna: public InteractionEnergy { */ virtual E_type - getE_multiUnpaired( const size_t numUnpaired ) const; + getE_multiUnpaired( const size_t numUnpaired ) const override; /** * Provides the energy contribution/penalty of the helix repesented by the @@ -139,7 +139,7 @@ class InteractionEnergyVrna: public InteractionEnergy { */ virtual E_type - getE_multiHelix( const size_t j1, const size_t j2 ) const; + getE_multiHelix( const size_t j1, const size_t j2 ) const override; /** * Provides the energy contribution/penalty for closing an intermolecular @@ -150,7 +150,7 @@ class InteractionEnergyVrna: public InteractionEnergy { */ virtual E_type - getE_multiClosing() const; + getE_multiClosing() const override; /** * Provides the duplex initiation energy. @@ -159,7 +159,7 @@ class InteractionEnergyVrna: public InteractionEnergy { */ virtual E_type - getE_init() const; + getE_init() const override; /** * Computes the energy estimate for the interaction loop region closed by @@ -180,7 +180,7 @@ class InteractionEnergyVrna: public InteractionEnergy { */ virtual E_type - getE_interLeft( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ) const; + getE_interLeft( const size_t i1, const size_t j1, const size_t i2, const size_t j2 ) const override; /** @@ -195,7 +195,7 @@ class InteractionEnergyVrna: public InteractionEnergy { */ virtual E_type - getE_danglingLeft( const size_t i1, const size_t i2 ) const; + getE_danglingLeft( const size_t i1, const size_t i2 ) const override; /** @@ -210,7 +210,7 @@ class InteractionEnergyVrna: public InteractionEnergy { */ virtual E_type - getE_danglingRight( const size_t j1, const size_t j2 ) const; + getE_danglingRight( const size_t j1, const size_t j2 ) const override; /** @@ -224,7 +224,7 @@ class InteractionEnergyVrna: public InteractionEnergy { */ virtual E_type - getE_endLeft( const size_t i1, const size_t i2 ) const; + getE_endLeft( const size_t i1, const size_t i2 ) const override; /** * Provides the penalty for closing an interaction with the given @@ -237,7 +237,7 @@ class InteractionEnergyVrna: public InteractionEnergy { */ virtual E_type - getE_endRight( const size_t j1, const size_t j2 ) const; + getE_endRight( const size_t j1, const size_t j2 ) const override; /** * Provides the overall ensemble energy for sequence 1 @@ -246,7 +246,7 @@ class InteractionEnergyVrna: public InteractionEnergy { */ virtual E_type - getEall1() const; + getEall1() const override; /** * Provides the overall ensemble energy for sequence 2 @@ -255,7 +255,7 @@ class InteractionEnergyVrna: public InteractionEnergy { */ virtual E_type - getEall2() const; + getEall2() const override; /** * Provides the overall partition function for sequence 2 @@ -264,7 +264,7 @@ class InteractionEnergyVrna: public InteractionEnergy { */ virtual Z_type - getRT() const; + getRT() const override; protected: diff --git a/src/IntaRNA/OutputHandlerCsv.h b/src/IntaRNA/OutputHandlerCsv.h index 7c707ec6..4a5e5480 100644 --- a/src/IntaRNA/OutputHandlerCsv.h +++ b/src/IntaRNA/OutputHandlerCsv.h @@ -202,7 +202,7 @@ class OutputHandlerCsv : public OutputHandler /** * destruction */ - virtual ~OutputHandlerCsv(); + virtual ~OutputHandlerCsv() override; /** * Write a given RNA-RNA interaction in simple text format to the output @@ -212,7 +212,7 @@ class OutputHandlerCsv : public OutputHandler */ virtual void - add( const Interaction & interaction ); + add( const Interaction & interaction ) override; /** * Converts a list of Coltypes to their string representation. diff --git a/src/IntaRNA/OutputHandlerEnsemble.h b/src/IntaRNA/OutputHandlerEnsemble.h index e19b408b..9e2391bf 100644 --- a/src/IntaRNA/OutputHandlerEnsemble.h +++ b/src/IntaRNA/OutputHandlerEnsemble.h @@ -37,7 +37,7 @@ class OutputHandlerEnsemble: public OutputHandler { * - prints ensemble information to stream and * - enforces a flush on the output stream. */ - virtual ~OutputHandlerEnsemble(); + virtual ~OutputHandlerEnsemble() override; /** * Does nothing but counts the call @@ -47,7 +47,7 @@ class OutputHandlerEnsemble: public OutputHandler { */ virtual void - add( const Interaction & interaction ); + add( const Interaction & interaction ) override; protected: diff --git a/src/IntaRNA/OutputHandlerHub.h b/src/IntaRNA/OutputHandlerHub.h index ac5e4cb2..9af6bb81 100644 --- a/src/IntaRNA/OutputHandlerHub.h +++ b/src/IntaRNA/OutputHandlerHub.h @@ -50,7 +50,7 @@ class OutputHandlerHub: public OutputHandler /** * destruction */ - virtual ~OutputHandlerHub(); + virtual ~OutputHandlerHub() override; /** @@ -61,7 +61,7 @@ class OutputHandlerHub: public OutputHandler */ virtual void - add( const Interaction & interaction ); + add( const Interaction & interaction ) override; /** * Returns the maximal number of reported interactions among all handlers @@ -70,7 +70,7 @@ class OutputHandlerHub: public OutputHandler */ virtual size_t - reported() const; + reported() const override; /** * Increments the overall partition function with the given value. @@ -83,7 +83,7 @@ class OutputHandlerHub: public OutputHandler */ virtual void - incrementZ( const Z_type subZ ); + incrementZ( const Z_type subZ ) override; /** * Access to the partition function (initialized with 0) aggregated via @@ -93,7 +93,7 @@ class OutputHandlerHub: public OutputHandler */ virtual Z_type - getZ() const; + getZ() const override; /** * Adds a new OutputHandler to the forwarding list. diff --git a/src/IntaRNA/OutputHandlerInteractionList.h b/src/IntaRNA/OutputHandlerInteractionList.h index abfbd905..c2735168 100644 --- a/src/IntaRNA/OutputHandlerInteractionList.h +++ b/src/IntaRNA/OutputHandlerInteractionList.h @@ -41,7 +41,7 @@ class OutputHandlerInteractionList: public OutputHandler , const size_t maxToStore); //! destruction - virtual ~OutputHandlerInteractionList(); + virtual ~OutputHandlerInteractionList() override; /** * Adds a given RNA-RNA interaction to the storage/output. @@ -50,7 +50,7 @@ class OutputHandlerInteractionList: public OutputHandler */ virtual void - add( const Interaction & interaction ); + add( const Interaction & interaction ) override; //! whether or not the container is empty //! @return true if no elements are stored diff --git a/src/IntaRNA/OutputHandlerText.h b/src/IntaRNA/OutputHandlerText.h index d95fb331..253cd33b 100644 --- a/src/IntaRNA/OutputHandlerText.h +++ b/src/IntaRNA/OutputHandlerText.h @@ -41,7 +41,7 @@ class OutputHandlerText: public OutputHandler { /** * destruction, enforces a flush on the output stream. */ - virtual ~OutputHandlerText(); + virtual ~OutputHandlerText() override; /** * Write a given RNA-RNA interaction in simple text format to the output @@ -51,7 +51,7 @@ class OutputHandlerText: public OutputHandler { */ virtual void - add( const Interaction & interaction ); + add( const Interaction & interaction ) override; protected: diff --git a/src/IntaRNA/OutputStreamHandlerSortedCsv.h b/src/IntaRNA/OutputStreamHandlerSortedCsv.h index d0108983..6d16683b 100644 --- a/src/IntaRNA/OutputStreamHandlerSortedCsv.h +++ b/src/IntaRNA/OutputStreamHandlerSortedCsv.h @@ -46,14 +46,14 @@ class OutputStreamHandlerSortedCsv : public OutputStreamHandler * - calls flush() on the final output * - destruction of the underlying OutputStreamHandler */ - virtual ~OutputStreamHandlerSortedCsv(); + virtual ~OutputStreamHandlerSortedCsv() override; /** * Access to the ostream to which unsorted CSV output is to be written. * @return the output stream to write unsorted CSV output to */ virtual - std::ostream& getOutStream(); + std::ostream& getOutStream() override; protected: diff --git a/src/IntaRNA/PredictionTrackerHub.h b/src/IntaRNA/PredictionTrackerHub.h index 2718f92b..6e7e4297 100644 --- a/src/IntaRNA/PredictionTrackerHub.h +++ b/src/IntaRNA/PredictionTrackerHub.h @@ -44,7 +44,7 @@ class PredictionTrackerHub: public PredictionTracker /** * destruction */ - virtual ~PredictionTrackerHub(); + virtual ~PredictionTrackerHub() override; /** * Informs all registered trackers that Predictor.updateOptima() was called @@ -61,7 +61,7 @@ class PredictionTrackerHub: public PredictionTracker updateOptimumCalled( const size_t i1, const size_t j1 , const size_t i2, const size_t j2 , const E_type energy - ); + ) override; /** * Adds a new PredictionTracker to the forwarding list. diff --git a/src/IntaRNA/PredictionTrackerPairMinE.h b/src/IntaRNA/PredictionTrackerPairMinE.h index 06bd9c2f..cf9fc55b 100644 --- a/src/IntaRNA/PredictionTrackerPairMinE.h +++ b/src/IntaRNA/PredictionTrackerPairMinE.h @@ -69,7 +69,7 @@ class PredictionTrackerPairMinE: public PredictionTracker /** * destruction: write the pair information to the according stream. */ - virtual ~PredictionTrackerPairMinE(); + virtual ~PredictionTrackerPairMinE() override; /** @@ -86,7 +86,7 @@ class PredictionTrackerPairMinE: public PredictionTracker updateOptimumCalled( const size_t i1, const size_t j1 , const size_t i2, const size_t j2 , const E_type energy - ); + ) override; protected: diff --git a/src/IntaRNA/PredictionTrackerProfileMinE.h b/src/IntaRNA/PredictionTrackerProfileMinE.h index f8747456..f36c1067 100644 --- a/src/IntaRNA/PredictionTrackerProfileMinE.h +++ b/src/IntaRNA/PredictionTrackerProfileMinE.h @@ -71,7 +71,7 @@ class PredictionTrackerProfileMinE: public PredictionTracker /** * destruction: write the profile(s) to the according streams. */ - virtual ~PredictionTrackerProfileMinE(); + virtual ~PredictionTrackerProfileMinE() override; /** @@ -88,7 +88,7 @@ class PredictionTrackerProfileMinE: public PredictionTracker updateOptimumCalled( const size_t i1, const size_t j1 , const size_t i2, const size_t j2 , const E_type energy - ); + ) override; protected: diff --git a/src/IntaRNA/PredictionTrackerProfileSpotProb.h b/src/IntaRNA/PredictionTrackerProfileSpotProb.h index 0a127fb6..632b2853 100644 --- a/src/IntaRNA/PredictionTrackerProfileSpotProb.h +++ b/src/IntaRNA/PredictionTrackerProfileSpotProb.h @@ -75,7 +75,7 @@ class PredictionTrackerProfileSpotProb: public PredictionTracker /** * destruction: write the profile(s) to the according streams. */ - virtual ~PredictionTrackerProfileSpotProb(); + virtual ~PredictionTrackerProfileSpotProb() override; /** @@ -92,7 +92,7 @@ class PredictionTrackerProfileSpotProb: public PredictionTracker updateOptimumCalled( const size_t i1, const size_t j1 , const size_t i2, const size_t j2 , const E_type energy - ); + ) override; protected: diff --git a/src/IntaRNA/PredictionTrackerSpotProb.h b/src/IntaRNA/PredictionTrackerSpotProb.h index a6e9afc4..01bdb05e 100644 --- a/src/IntaRNA/PredictionTrackerSpotProb.h +++ b/src/IntaRNA/PredictionTrackerSpotProb.h @@ -71,7 +71,7 @@ class PredictionTrackerSpotProb: public PredictionTracker /** * destruction: write the probabilities to stream. */ - virtual ~PredictionTrackerSpotProb(); + virtual ~PredictionTrackerSpotProb() override; /** @@ -88,7 +88,7 @@ class PredictionTrackerSpotProb: public PredictionTracker updateOptimumCalled( const size_t i1, const size_t j1 , const size_t i2, const size_t j2 , const E_type energy - ); + ) override; protected: diff --git a/src/IntaRNA/PredictionTrackerSpotProbAll.h b/src/IntaRNA/PredictionTrackerSpotProbAll.h index 906b49ba..0d6cffc0 100644 --- a/src/IntaRNA/PredictionTrackerSpotProbAll.h +++ b/src/IntaRNA/PredictionTrackerSpotProbAll.h @@ -69,7 +69,7 @@ class PredictionTrackerSpotProbAll: public PredictionTracker /** * destruction: write the profile(s) to the according streams. */ - virtual ~PredictionTrackerSpotProbAll(); + virtual ~PredictionTrackerSpotProbAll() override; /** @@ -87,7 +87,7 @@ class PredictionTrackerSpotProbAll: public PredictionTracker updateOptimumCalled( const size_t i1, const size_t j1 , const size_t i2, const size_t j2 , const E_type energy - ); + ) override; protected: diff --git a/src/IntaRNA/PredictorMfe.h b/src/IntaRNA/PredictorMfe.h index 35501d33..dd7fa7ea 100644 --- a/src/IntaRNA/PredictorMfe.h +++ b/src/IntaRNA/PredictorMfe.h @@ -75,7 +75,7 @@ class PredictorMfe : public Predictor { , OutputHandler & output , PredictionTracker * predTracker ); - virtual ~PredictorMfe(); + virtual ~PredictorMfe() override; protected: @@ -112,7 +112,7 @@ class PredictorMfe : public Predictor { */ virtual void - initOptima(); + initOptima() override; /** * Checks terminal GU and accessibility constraints for a complete site. @@ -145,7 +145,7 @@ class PredictorMfe : public Predictor { , const size_t i2, const size_t j2 , const E_type energy , const bool isHybridE - , const bool incrementZall ); + , const bool incrementZall ) override; /** @@ -199,7 +199,7 @@ class PredictorMfe : public Predictor { */ virtual void - reportOptima(); + reportOptima() override; }; diff --git a/src/IntaRNA/PredictorMfe2d.h b/src/IntaRNA/PredictorMfe2d.h index 2e060f49..de3d62d5 100644 --- a/src/IntaRNA/PredictorMfe2d.h +++ b/src/IntaRNA/PredictorMfe2d.h @@ -38,7 +38,7 @@ class PredictorMfe2d: public PredictorMfe { , OutputHandler & output , PredictionTracker * predTracker ); - virtual ~PredictorMfe2d(); + virtual ~PredictorMfe2d() override; /** * Computes the mfe for the given sequence ranges (i1-j1) in the first @@ -53,7 +53,7 @@ class PredictorMfe2d: public PredictorMfe { void predict( const IndexRange & r1 = IndexRange(0,RnaSequence::lastPos) , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) - ); + ) override; protected: @@ -94,7 +94,7 @@ class PredictorMfe2d: public PredictorMfe { */ virtual void - traceBack( Interaction & interaction ); + traceBack( Interaction & interaction ) override; }; diff --git a/src/IntaRNA/PredictorMfe2dHelixBlockHeuristic.h b/src/IntaRNA/PredictorMfe2dHelixBlockHeuristic.h index 2370901c..7fb1e18e 100644 --- a/src/IntaRNA/PredictorMfe2dHelixBlockHeuristic.h +++ b/src/IntaRNA/PredictorMfe2dHelixBlockHeuristic.h @@ -47,7 +47,7 @@ class PredictorMfe2dHelixBlockHeuristic: public PredictorMfe2dHeuristic { , PredictionTracker * predTracker , const HelixConstraint & helixConstraint); - virtual ~PredictorMfe2dHelixBlockHeuristic(); + virtual ~PredictorMfe2dHelixBlockHeuristic() override; /** * Computes the mfe for the given sequence ranges (i1-j1) in the first @@ -61,7 +61,7 @@ class PredictorMfe2dHelixBlockHeuristic: public PredictorMfe2dHeuristic { virtual void predict( const IndexRange & r1 = IndexRange(0,RnaSequence::lastPos) - , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ); + , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ) override; protected: @@ -88,7 +88,7 @@ class PredictorMfe2dHelixBlockHeuristic: public PredictorMfe2dHeuristic { */ virtual void - fillHybridE(); + fillHybridE() override; /** * Fills a given interaction (boundaries given) with the according @@ -97,7 +97,7 @@ class PredictorMfe2dHelixBlockHeuristic: public PredictorMfe2dHeuristic { */ virtual void - traceBack( Interaction & interaction ); + traceBack( Interaction & interaction ) override; /** * Identifies the next best interaction with an energy equal to or higher @@ -111,7 +111,7 @@ class PredictorMfe2dHelixBlockHeuristic: public PredictorMfe2dHeuristic { */ virtual void - getNextBest( Interaction & curBest ); + getNextBest( Interaction & curBest ) override; /** * Overwrites function of super class to surpress the update. @@ -126,7 +126,7 @@ class PredictorMfe2dHelixBlockHeuristic: public PredictorMfe2dHeuristic { void updateMfe4leftEnd(const size_t i1, const size_t j1 , const size_t i2, const size_t j2 - , const Interaction & curInteraction ); + , const Interaction & curInteraction ) override; }; diff --git a/src/IntaRNA/PredictorMfe2dHelixBlockHeuristicSeed.h b/src/IntaRNA/PredictorMfe2dHelixBlockHeuristicSeed.h index 342a8c65..6238905b 100644 --- a/src/IntaRNA/PredictorMfe2dHelixBlockHeuristicSeed.h +++ b/src/IntaRNA/PredictorMfe2dHelixBlockHeuristicSeed.h @@ -49,7 +49,7 @@ class PredictorMfe2dHelixBlockHeuristicSeed: public PredictorMfe2dHelixBlockHeur , SeedHandler * seedHandlerInstance ); - virtual ~PredictorMfe2dHelixBlockHeuristicSeed(); + virtual ~PredictorMfe2dHelixBlockHeuristicSeed() override; /** * Computes the mfe for the given sequence ranges (i1-j1) in the first @@ -63,7 +63,7 @@ class PredictorMfe2dHelixBlockHeuristicSeed: public PredictorMfe2dHelixBlockHeur virtual void predict( const IndexRange & r1 = IndexRange(0,RnaSequence::lastPos) - , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos)); + , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos)) override; protected: @@ -98,7 +98,7 @@ class PredictorMfe2dHelixBlockHeuristicSeed: public PredictorMfe2dHelixBlockHeur */ virtual void - traceBack( Interaction & interaction ); + traceBack( Interaction & interaction ) override; /** @@ -114,7 +114,7 @@ class PredictorMfe2dHelixBlockHeuristicSeed: public PredictorMfe2dHelixBlockHeur */ virtual void - getNextBest( Interaction & curBest ); + getNextBest( Interaction & curBest ) override; /** * disables predictionTracker usage diff --git a/src/IntaRNA/PredictorMfe2dHeuristic.h b/src/IntaRNA/PredictorMfe2dHeuristic.h index 857c5f0f..059ece2a 100644 --- a/src/IntaRNA/PredictorMfe2dHeuristic.h +++ b/src/IntaRNA/PredictorMfe2dHeuristic.h @@ -44,7 +44,7 @@ class PredictorMfe2dHeuristic: public PredictorMfe { , OutputHandler & output , PredictionTracker * predTracker ); - virtual ~PredictorMfe2dHeuristic(); + virtual ~PredictorMfe2dHeuristic() override; /** * Computes the mfe for the given sequence ranges (i1-j1) in the first @@ -58,7 +58,7 @@ class PredictorMfe2dHeuristic: public PredictorMfe { virtual void predict( const IndexRange & r1 = IndexRange(0,RnaSequence::lastPos) - , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ); + , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ) override; protected: @@ -91,7 +91,7 @@ class PredictorMfe2dHeuristic: public PredictorMfe { */ virtual void - traceBack( Interaction & interaction ); + traceBack( Interaction & interaction ) override; /** * Identifies the next best interaction with an energy equal to or higher @@ -105,7 +105,7 @@ class PredictorMfe2dHeuristic: public PredictorMfe { */ virtual void - getNextBest( Interaction & curBest ); + getNextBest( Interaction & curBest ) override; /** * Overwrites function of super class to surpress the update. @@ -120,7 +120,7 @@ class PredictorMfe2dHeuristic: public PredictorMfe { void updateMfe4leftEnd(const size_t i1, const size_t j1 , const size_t i2, const size_t j2 - , const Interaction & curInteraction ); + , const Interaction & curInteraction ) override; }; diff --git a/src/IntaRNA/PredictorMfe2dHeuristicSeed.h b/src/IntaRNA/PredictorMfe2dHeuristicSeed.h index ef6fe824..5e4fcef3 100644 --- a/src/IntaRNA/PredictorMfe2dHeuristicSeed.h +++ b/src/IntaRNA/PredictorMfe2dHeuristicSeed.h @@ -45,7 +45,7 @@ class PredictorMfe2dHeuristicSeed: public PredictorMfe2dHeuristic { , PredictionTracker * predTracker , SeedHandler* seedHandler ); - virtual ~PredictorMfe2dHeuristicSeed(); + virtual ~PredictorMfe2dHeuristicSeed() override; /** * Computes the mfe for the given sequence ranges (i1-j1) in the first @@ -59,7 +59,7 @@ class PredictorMfe2dHeuristicSeed: public PredictorMfe2dHeuristic { virtual void predict( const IndexRange & r1 = IndexRange(0,RnaSequence::lastPos) - , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos)); + , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos)) override; protected: @@ -91,7 +91,7 @@ class PredictorMfe2dHeuristicSeed: public PredictorMfe2dHeuristic { */ virtual void - traceBack( Interaction & interaction ); + traceBack( Interaction & interaction ) override; /** @@ -100,7 +100,7 @@ class PredictorMfe2dHeuristicSeed: public PredictorMfe2dHeuristic { */ virtual void - fillHybridE(); + fillHybridE() override; /** @@ -116,7 +116,7 @@ class PredictorMfe2dHeuristicSeed: public PredictorMfe2dHeuristic { */ virtual void - getNextBest( Interaction & curBest ); + getNextBest( Interaction & curBest ) override; }; diff --git a/src/IntaRNA/PredictorMfe2dHeuristicSeedExtension.h b/src/IntaRNA/PredictorMfe2dHeuristicSeedExtension.h index 8d43a315..696ce5db 100644 --- a/src/IntaRNA/PredictorMfe2dHeuristicSeedExtension.h +++ b/src/IntaRNA/PredictorMfe2dHeuristicSeedExtension.h @@ -48,7 +48,7 @@ class PredictorMfe2dHeuristicSeedExtension: public PredictorMfe2dSeedExtension { /** * data cleanup */ - virtual ~PredictorMfe2dHeuristicSeedExtension(); + virtual ~PredictorMfe2dHeuristicSeedExtension() override; /** @@ -66,7 +66,7 @@ class PredictorMfe2dHeuristicSeedExtension: public PredictorMfe2dSeedExtension { virtual void predict( const IndexRange & r1 = IndexRange(0,RnaSequence::lastPos) - , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ); + , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ) override; protected: diff --git a/src/IntaRNA/PredictorMfe2dSeed.h b/src/IntaRNA/PredictorMfe2dSeed.h index d959f97d..e4d1330a 100644 --- a/src/IntaRNA/PredictorMfe2dSeed.h +++ b/src/IntaRNA/PredictorMfe2dSeed.h @@ -53,7 +53,7 @@ class PredictorMfe2dSeed: public PredictorMfe2d { /** * data cleanup */ - virtual ~PredictorMfe2dSeed(); + virtual ~PredictorMfe2dSeed() override; /** @@ -71,7 +71,7 @@ class PredictorMfe2dSeed: public PredictorMfe2d { virtual void predict( const IndexRange & r1 = IndexRange(0,RnaSequence::lastPos) - , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ); + , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ) override; protected: @@ -112,7 +112,7 @@ class PredictorMfe2dSeed: public PredictorMfe2d { fillHybridE( const size_t j1, const size_t j2 , const size_t i1init, const size_t i2init , const bool callUpdateOptima - ); + ) override; /** * Fills a given interaction (boundaries given) with the according @@ -121,7 +121,7 @@ class PredictorMfe2dSeed: public PredictorMfe2d { */ virtual void - traceBack( Interaction & interaction ); + traceBack( Interaction & interaction ) override; }; diff --git a/src/IntaRNA/PredictorMfe2dSeedExtension.h b/src/IntaRNA/PredictorMfe2dSeedExtension.h index 176e14a1..5fef17d9 100644 --- a/src/IntaRNA/PredictorMfe2dSeedExtension.h +++ b/src/IntaRNA/PredictorMfe2dSeedExtension.h @@ -52,7 +52,7 @@ class PredictorMfe2dSeedExtension: public PredictorMfe2d { /** * data cleanup */ - virtual ~PredictorMfe2dSeedExtension(); + virtual ~PredictorMfe2dSeedExtension() override; /** @@ -70,7 +70,7 @@ class PredictorMfe2dSeedExtension: public PredictorMfe2d { virtual void predict( const IndexRange & r1 = IndexRange(0,RnaSequence::lastPos) - , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ); + , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ) override; protected: @@ -122,7 +122,7 @@ class PredictorMfe2dSeedExtension: public PredictorMfe2d { */ virtual void - traceBack( Interaction & interaction ); + traceBack( Interaction & interaction ) override; void printMatrix( const E2dMatrix & matrix ); diff --git a/src/IntaRNA/PredictorMfe2dSeedExtensionRIblast.h b/src/IntaRNA/PredictorMfe2dSeedExtensionRIblast.h index c352cebf..788e70cf 100644 --- a/src/IntaRNA/PredictorMfe2dSeedExtensionRIblast.h +++ b/src/IntaRNA/PredictorMfe2dSeedExtensionRIblast.h @@ -60,7 +60,7 @@ class PredictorMfe2dSeedExtensionRIblast: public PredictorMfe2d { /** * data cleanup */ - virtual ~PredictorMfe2dSeedExtensionRIblast(); + virtual ~PredictorMfe2dSeedExtensionRIblast() override; /** @@ -78,7 +78,7 @@ class PredictorMfe2dSeedExtensionRIblast: public PredictorMfe2d { virtual void predict( const IndexRange & r1 = IndexRange(0,RnaSequence::lastPos) - , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ); + , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ) override; protected: @@ -141,7 +141,7 @@ class PredictorMfe2dSeedExtensionRIblast: public PredictorMfe2d { */ virtual void - traceBack( Interaction & interaction ); + traceBack( Interaction & interaction ) override; void printMatrix( const E2dMatrix & matrix ); diff --git a/src/IntaRNA/PredictorMfeEns.h b/src/IntaRNA/PredictorMfeEns.h index c51fd654..670b8b2d 100644 --- a/src/IntaRNA/PredictorMfeEns.h +++ b/src/IntaRNA/PredictorMfeEns.h @@ -36,7 +36,7 @@ class PredictorMfeEns : public PredictorMfe { , OutputHandler & output , PredictionTracker * predTracker ); - virtual ~PredictorMfeEns(); + virtual ~PredictorMfeEns() override; protected: @@ -127,7 +127,7 @@ class PredictorMfeEns : public PredictorMfe { */ virtual void - reportOptima(); + reportOptima() override; /** * Reports interaction boundaries only (no base pair tracking) @@ -135,7 +135,7 @@ class PredictorMfeEns : public PredictorMfe { */ virtual void - traceBack( Interaction & interaction ); + traceBack( Interaction & interaction ) override; private: diff --git a/src/IntaRNA/PredictorMfeEns2d.h b/src/IntaRNA/PredictorMfeEns2d.h index 2b77bb67..539d4576 100644 --- a/src/IntaRNA/PredictorMfeEns2d.h +++ b/src/IntaRNA/PredictorMfeEns2d.h @@ -45,7 +45,7 @@ class PredictorMfeEns2d: public PredictorMfeEns { , PredictionTracker * predTracker , BasePairProbabilities * pairProbabilities = nullptr ); - virtual ~PredictorMfeEns2d(); + virtual ~PredictorMfeEns2d() override; /** * Computes the mfe for the given sequence ranges (i1-j1) in the first @@ -60,7 +60,7 @@ class PredictorMfeEns2d: public PredictorMfeEns { void predict( const IndexRange & r1 = IndexRange(0,RnaSequence::lastPos) , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) - ); + ) override; protected: diff --git a/src/IntaRNA/PredictorMfeEns2dHeuristic.h b/src/IntaRNA/PredictorMfeEns2dHeuristic.h index 75e5b935..5c789a25 100644 --- a/src/IntaRNA/PredictorMfeEns2dHeuristic.h +++ b/src/IntaRNA/PredictorMfeEns2dHeuristic.h @@ -52,7 +52,7 @@ class PredictorMfeEns2dHeuristic: public PredictorMfeEns2d { , PredictionTracker * predTracker , BasePairProbabilities * pairProbabilities = nullptr ); - virtual ~PredictorMfeEns2dHeuristic(); + virtual ~PredictorMfeEns2dHeuristic() override; /** * Computes the mfe for the given sequence ranges (i1-j1) in the first @@ -66,7 +66,7 @@ class PredictorMfeEns2dHeuristic: public PredictorMfeEns2d { virtual void predict( const IndexRange & r1 = IndexRange(0,RnaSequence::lastPos) - , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ); + , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ) override; protected: diff --git a/src/IntaRNA/PredictorMfeEns2dHeuristicSeedExtension.h b/src/IntaRNA/PredictorMfeEns2dHeuristicSeedExtension.h index 27b87452..7ab478eb 100644 --- a/src/IntaRNA/PredictorMfeEns2dHeuristicSeedExtension.h +++ b/src/IntaRNA/PredictorMfeEns2dHeuristicSeedExtension.h @@ -52,7 +52,7 @@ class PredictorMfeEns2dHeuristicSeedExtension: public PredictorMfeEns2dSeedExten /** * data cleanup */ - virtual ~PredictorMfeEns2dHeuristicSeedExtension(); + virtual ~PredictorMfeEns2dHeuristicSeedExtension() override; /** @@ -70,7 +70,7 @@ class PredictorMfeEns2dHeuristicSeedExtension: public PredictorMfeEns2dSeedExten virtual void predict( const IndexRange & r1 = IndexRange(0,RnaSequence::lastPos) - , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ); + , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ) override; protected: @@ -157,7 +157,7 @@ class PredictorMfeEns2dHeuristicSeedExtension: public PredictorMfeEns2dSeedExten */ virtual void - fillHybridZ_left( const size_t j1, const size_t j2 ); + fillHybridZ_left( const size_t j1, const size_t j2 ) override; }; diff --git a/src/IntaRNA/PredictorMfeEns2dSeedExtension.h b/src/IntaRNA/PredictorMfeEns2dSeedExtension.h index 5c509295..21255565 100644 --- a/src/IntaRNA/PredictorMfeEns2dSeedExtension.h +++ b/src/IntaRNA/PredictorMfeEns2dSeedExtension.h @@ -60,7 +60,7 @@ class PredictorMfeEns2dSeedExtension: public PredictorMfeEns { /** * data cleanup */ - virtual ~PredictorMfeEns2dSeedExtension(); + virtual ~PredictorMfeEns2dSeedExtension() override; /** @@ -78,7 +78,7 @@ class PredictorMfeEns2dSeedExtension: public PredictorMfeEns { virtual void predict( const IndexRange & r1 = IndexRange(0,RnaSequence::lastPos) - , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ); + , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ) override; protected: @@ -177,7 +177,7 @@ class PredictorMfeEns2dSeedExtension: public PredictorMfeEns { */ virtual void - traceBack( Interaction & interaction ); + traceBack( Interaction & interaction ) override; /** * Returns the hybridization energy of the non overlapping part of seeds diff --git a/src/IntaRNA/PredictorMfeEnsSeedOnly.h b/src/IntaRNA/PredictorMfeEnsSeedOnly.h index 3507917c..a93cd85f 100644 --- a/src/IntaRNA/PredictorMfeEnsSeedOnly.h +++ b/src/IntaRNA/PredictorMfeEnsSeedOnly.h @@ -37,7 +37,7 @@ class PredictorMfeEnsSeedOnly: public PredictorMfeEns { /** * data cleanup */ - virtual ~PredictorMfeEnsSeedOnly(); + virtual ~PredictorMfeEnsSeedOnly() override; /** * Computes the mfe seed for the given sequence ranges (i1-j1) in the first @@ -51,7 +51,7 @@ class PredictorMfeEnsSeedOnly: public PredictorMfeEns { virtual void predict( const IndexRange & r1 = IndexRange(0,RnaSequence::lastPos) - , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ); + , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ) override; protected: @@ -83,7 +83,7 @@ class PredictorMfeEnsSeedOnly: public PredictorMfeEns { */ virtual void - traceBack( Interaction & interaction ); + traceBack( Interaction & interaction ) override; }; diff --git a/src/IntaRNA/PredictorMfeSeedOnly.h b/src/IntaRNA/PredictorMfeSeedOnly.h index fee5fbee..00bcc940 100644 --- a/src/IntaRNA/PredictorMfeSeedOnly.h +++ b/src/IntaRNA/PredictorMfeSeedOnly.h @@ -37,7 +37,7 @@ class PredictorMfeSeedOnly: public PredictorMfe { /** * data cleanup */ - virtual ~PredictorMfeSeedOnly(); + virtual ~PredictorMfeSeedOnly() override; /** @@ -52,7 +52,7 @@ class PredictorMfeSeedOnly: public PredictorMfe { virtual void predict( const IndexRange & r1 = IndexRange(0,RnaSequence::lastPos) - , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ); + , const IndexRange & r2 = IndexRange(0,RnaSequence::lastPos) ) override; protected: @@ -84,7 +84,7 @@ class PredictorMfeSeedOnly: public PredictorMfe { */ virtual void - traceBack( Interaction & interaction ); + traceBack( Interaction & interaction ) override; }; diff --git a/src/IntaRNA/PredictorSeedExtensionKinetic.h b/src/IntaRNA/PredictorSeedExtensionKinetic.h index fd996428..390bffc1 100644 --- a/src/IntaRNA/PredictorSeedExtensionKinetic.h +++ b/src/IntaRNA/PredictorSeedExtensionKinetic.h @@ -51,7 +51,7 @@ class PredictorSeedExtensionKinetic : public PredictorMfe { SeedHandler * seedHandler, const char score = 'A'); /** Frees the owned seed handler and prediction tracker. */ - virtual ~PredictorSeedExtensionKinetic(); + virtual ~PredictorSeedExtensionKinetic() override; /** * Extends all feasible seeds within inclusive, zero-based sequence ranges. diff --git a/src/IntaRNA/ReverseAccessibility.h b/src/IntaRNA/ReverseAccessibility.h index 983e74d4..49ce5001 100644 --- a/src/IntaRNA/ReverseAccessibility.h +++ b/src/IntaRNA/ReverseAccessibility.h @@ -23,7 +23,7 @@ class ReverseAccessibility: public Accessibility { /** * destruction */ - virtual ~ReverseAccessibility(); + virtual ~ReverseAccessibility() override; /** * Access to the accessibility energy value (according to reversed indices). @@ -35,7 +35,7 @@ class ReverseAccessibility: public Accessibility { */ virtual E_type - getED( const size_t from, const size_t to ) const; + getED( const size_t from, const size_t to ) const override; /** @@ -44,7 +44,7 @@ class ReverseAccessibility: public Accessibility { */ virtual const RnaSequence & - getSequence() const; + getSequence() const override; /** @@ -56,7 +56,7 @@ class ReverseAccessibility: public Accessibility { */ virtual const AccessibilityConstraint& - getAccConstraint() const; + getAccConstraint() const override; /** * Access to the original not-reversed accessibility object. diff --git a/src/IntaRNA/SeedHandlerExplicit.h b/src/IntaRNA/SeedHandlerExplicit.h index a5af80c3..171fb7b4 100644 --- a/src/IntaRNA/SeedHandlerExplicit.h +++ b/src/IntaRNA/SeedHandlerExplicit.h @@ -37,7 +37,7 @@ class SeedHandlerExplicit : public SeedHandler /** * destruction */ - virtual ~SeedHandlerExplicit(); + virtual ~SeedHandlerExplicit() override; /** @@ -50,7 +50,7 @@ class SeedHandlerExplicit : public SeedHandler */ virtual size_t - fillSeed(const size_t i1, const size_t j1, const size_t i2, const size_t j2); + fillSeed(const size_t i1, const size_t j1, const size_t i2, const size_t j2) override; /** * Identifies the base pairs of the seed interaction starting at i1,i2 @@ -64,7 +64,7 @@ class SeedHandlerExplicit : public SeedHandler */ virtual void - traceBackSeed( Interaction & interaction, const size_t i1, const size_t i2) const; + traceBackSeed( Interaction & interaction, const size_t i1, const size_t i2) const override; /** @@ -75,7 +75,7 @@ class SeedHandlerExplicit : public SeedHandler */ virtual E_type - getSeedE( const size_t i1, const size_t i2 ) const; + getSeedE( const size_t i1, const size_t i2 ) const override; /** * Checks whether or not a given base pair is the left-most base pair of @@ -87,7 +87,7 @@ class SeedHandlerExplicit : public SeedHandler */ virtual bool - isSeedBound( const size_t i1, const size_t i2 ) const; + isSeedBound( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq1 of the seed with left-most base pair (i1,i2) @@ -97,7 +97,7 @@ class SeedHandlerExplicit : public SeedHandler */ virtual size_t - getSeedLength1( const size_t i1, const size_t i2 ) const; + getSeedLength1( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq2 of the seed with left-most base pair (i1,i2) @@ -107,7 +107,7 @@ class SeedHandlerExplicit : public SeedHandler */ virtual size_t - getSeedLength2( const size_t i1, const size_t i2 ) const; + getSeedLength2( const size_t i1, const size_t i2 ) const override; @@ -160,7 +160,7 @@ class SeedHandlerExplicit : public SeedHandler updateToNextSeed( size_t & i1, size_t & i2 , const size_t i1min = 0, const size_t i1max = RnaSequence::lastPos , const size_t i2min = 0, const size_t i2max = RnaSequence::lastPos - ) const; + ) const override; public: diff --git a/src/IntaRNA/SeedHandlerIdxOffset.h b/src/IntaRNA/SeedHandlerIdxOffset.h index 11b30f96..5b3b4445 100644 --- a/src/IntaRNA/SeedHandlerIdxOffset.h +++ b/src/IntaRNA/SeedHandlerIdxOffset.h @@ -43,7 +43,7 @@ class SeedHandlerIdxOffset : public SeedHandler /** * destruction of this object and the wrapped seed handler */ - virtual ~SeedHandlerIdxOffset(); + virtual ~SeedHandlerIdxOffset() override; /** * Access to the currently used index offset for sequence 1 @@ -79,7 +79,7 @@ class SeedHandlerIdxOffset : public SeedHandler */ virtual const SeedConstraint& - getConstraint() const; + getConstraint() const override; /** * Access to the underlying interaction energy function @@ -87,7 +87,7 @@ class SeedHandlerIdxOffset : public SeedHandler */ virtual const InteractionEnergy& - getInteractionEnergy() const; + getInteractionEnergy() const override; /** @@ -104,7 +104,7 @@ class SeedHandlerIdxOffset : public SeedHandler */ virtual size_t - fillSeed(const size_t i1, const size_t j1, const size_t i2, const size_t j2); + fillSeed(const size_t i1, const size_t j1, const size_t i2, const size_t j2) override; /** * Identifies the base pairs of the mfe seed interaction starting at i1,i2 @@ -120,7 +120,7 @@ class SeedHandlerIdxOffset : public SeedHandler */ virtual void - traceBackSeed( Interaction & interaction, const size_t i1, const size_t i2) const; + traceBackSeed( Interaction & interaction, const size_t i1, const size_t i2) const override; /** @@ -134,7 +134,7 @@ class SeedHandlerIdxOffset : public SeedHandler */ virtual E_type - getSeedE( const size_t i1, const size_t i2 ) const; + getSeedE( const size_t i1, const size_t i2 ) const override; /** * Checks whether or not a given base pair is the left-most base pair of @@ -146,7 +146,7 @@ class SeedHandlerIdxOffset : public SeedHandler */ virtual bool - isSeedBound( const size_t i1, const size_t i2 ) const; + isSeedBound( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq1 of the mfe seed with left-most base pair (i1,i2) @@ -159,7 +159,7 @@ class SeedHandlerIdxOffset : public SeedHandler */ virtual size_t - getSeedLength1( const size_t i1, const size_t i2 ) const; + getSeedLength1( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq2 of the mfe seed with left-most base pair (i1,i2) @@ -172,7 +172,7 @@ class SeedHandlerIdxOffset : public SeedHandler */ virtual size_t - getSeedLength2( const size_t i1, const size_t i2 ) const; + getSeedLength2( const size_t i1, const size_t i2 ) const override; /** @@ -212,7 +212,7 @@ class SeedHandlerIdxOffset : public SeedHandler updateToNextSeed( size_t & i1, size_t & i2 , const size_t i1min = 0, const size_t i1max = RnaSequence::lastPos , const size_t i2min = 0, const size_t i2max = RnaSequence::lastPos - ) const; + ) const override; /** * Adds all seeds to a given interaction that are completely covered by its @@ -223,7 +223,7 @@ class SeedHandlerIdxOffset : public SeedHandler */ virtual void - addSeeds( Interaction & i ) const; + addSeeds( Interaction & i ) const override; /** * Checks whether or not two seeds are loop overlapping, ie. given i1 < k1, the @@ -240,7 +240,7 @@ class SeedHandlerIdxOffset : public SeedHandler virtual bool areLoopOverlapping( const size_t i1, const size_t i2 - , const size_t k1, const size_t k2 ) const; + , const size_t k1, const size_t k2 ) const override; /** * Checks whether or not a given index pair is a valid seed base pair @@ -255,7 +255,7 @@ class SeedHandlerIdxOffset : public SeedHandler bool isFeasibleSeedBasePair( const size_t i1 , const size_t i2 - , const bool atEndOfSeed = false ) const; + , const bool atEndOfSeed = false ) const override; protected: diff --git a/src/IntaRNA/SeedHandlerMfe.h b/src/IntaRNA/SeedHandlerMfe.h index 94979fdf..00cee2c4 100644 --- a/src/IntaRNA/SeedHandlerMfe.h +++ b/src/IntaRNA/SeedHandlerMfe.h @@ -62,7 +62,7 @@ class SeedHandlerMfe : public SeedHandler /** * destruction */ - virtual ~SeedHandlerMfe(); + virtual ~SeedHandlerMfe() override; /** * Computes the seed matrix for the given interval boundaries @@ -74,7 +74,7 @@ class SeedHandlerMfe : public SeedHandler */ virtual size_t - fillSeed(const size_t i1, const size_t j1, const size_t i2, const size_t j2); + fillSeed(const size_t i1, const size_t j1, const size_t i2, const size_t j2) override; /** * Identifies the base pairs of the mfe seed interaction starting at i1,i2 @@ -88,7 +88,7 @@ class SeedHandlerMfe : public SeedHandler */ virtual void - traceBackSeed( Interaction & interaction, const size_t i1, const size_t i2) const; + traceBackSeed( Interaction & interaction, const size_t i1, const size_t i2) const override; /** @@ -99,7 +99,7 @@ class SeedHandlerMfe : public SeedHandler */ virtual E_type - getSeedE( const size_t i1, const size_t i2 ) const; + getSeedE( const size_t i1, const size_t i2 ) const override; /** * Checks whether or not a given base pair is the left-most base pair of @@ -111,7 +111,7 @@ class SeedHandlerMfe : public SeedHandler */ virtual bool - isSeedBound( const size_t i1, const size_t i2 ) const; + isSeedBound( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq1 of the mfe seed with left-most base pair (i1,i2) @@ -121,7 +121,7 @@ class SeedHandlerMfe : public SeedHandler */ virtual size_t - getSeedLength1( const size_t i1, const size_t i2 ) const; + getSeedLength1( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq2 of the mfe seed with left-most base pair (i1,i2) @@ -131,7 +131,7 @@ class SeedHandlerMfe : public SeedHandler */ virtual size_t - getSeedLength2( const size_t i1, const size_t i2 ) const; + getSeedLength2( const size_t i1, const size_t i2 ) const override; diff --git a/src/IntaRNA/SeedHandlerNoBulge.h b/src/IntaRNA/SeedHandlerNoBulge.h index 927643c9..8d504ffa 100644 --- a/src/IntaRNA/SeedHandlerNoBulge.h +++ b/src/IntaRNA/SeedHandlerNoBulge.h @@ -54,7 +54,7 @@ class SeedHandlerNoBulge : public SeedHandler /** * destruction */ - virtual ~SeedHandlerNoBulge(); + virtual ~SeedHandlerNoBulge() override; /** * Computes the seed information for the given interval boundaries @@ -66,7 +66,7 @@ class SeedHandlerNoBulge : public SeedHandler */ virtual size_t - fillSeed(const size_t i1, const size_t j1, const size_t i2, const size_t j2); + fillSeed(const size_t i1, const size_t j1, const size_t i2, const size_t j2) override; /** * Identifies the base pairs of the mfe seed interaction starting at i1,i2 @@ -80,7 +80,7 @@ class SeedHandlerNoBulge : public SeedHandler */ virtual void - traceBackSeed( Interaction & interaction, const size_t i1, const size_t i2) const; + traceBackSeed( Interaction & interaction, const size_t i1, const size_t i2) const override; /** @@ -91,7 +91,7 @@ class SeedHandlerNoBulge : public SeedHandler */ virtual E_type - getSeedE( const size_t i1, const size_t i2 ) const; + getSeedE( const size_t i1, const size_t i2 ) const override; /** * Checks whether or not a given base pair is the left-most base pair of @@ -103,7 +103,7 @@ class SeedHandlerNoBulge : public SeedHandler */ virtual bool - isSeedBound( const size_t i1, const size_t i2 ) const; + isSeedBound( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq1 of the mfe seed with left-most base pair (i1,i2) @@ -113,7 +113,7 @@ class SeedHandlerNoBulge : public SeedHandler */ virtual size_t - getSeedLength1( const size_t i1, const size_t i2 ) const; + getSeedLength1( const size_t i1, const size_t i2 ) const override; /** * Access to the length in seq2 of the mfe seed with left-most base pair (i1,i2) @@ -123,7 +123,7 @@ class SeedHandlerNoBulge : public SeedHandler */ virtual size_t - getSeedLength2( const size_t i1, const size_t i2 ) const; + getSeedLength2( const size_t i1, const size_t i2 ) const override; /** * Replace the input variables i1 and i2 to values to within the given range @@ -153,7 +153,7 @@ class SeedHandlerNoBulge : public SeedHandler updateToNextSeed( size_t & i1, size_t & i2 , const size_t i1min = 0, const size_t i1max = RnaSequence::lastPos , const size_t i2min = 0, const size_t i2max = RnaSequence::lastPos - ) const; + ) const override; protected: From fc62a23ebbedc591a729a708df03427fbae84793 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Wed, 21 Oct 2026 17:05:02 +0200 Subject: [PATCH 09/31] feat: address browser interface review on PR 287 --- .github/workflows/build-wasm.yml | 5 +- .github/workflows/build.yml | 1 - ChangeLog | 32 + README.md | 4 +- scripts/wasm/build.sh | 5 +- scripts/wasm/smoke-protocol.mjs | 47 ++ scripts/wasm/stage.sh | 27 +- scripts/wasm/test-browser.py | 224 +++--- scripts/wasm/write-build-info.mjs | 23 + webinterface/app.js | 198 +++-- webinterface/assets/README.md | 12 + webinterface/assets/bioinf-fr-logo-blau.png | Bin 0 -> 187836 bytes webinterface/assets/logo-uni-freiburg.png | Bin 0 -> 24542 bytes webinterface/columns.js | 315 ++++++++ webinterface/docs/step1-wasm-compilation.md | 16 + .../docs/step2-worker-architecture.md | 48 +- webinterface/docs/step3-frontend-ui.md | 115 +-- .../docs/step4-results-and-deployment.md | 83 ++- webinterface/index.html | 86 +-- webinterface/intarna-worker.js | 48 +- webinterface/options.js | 88 +++ webinterface/protocol.js | 259 ++++++- webinterface/results.js | 168 ++--- webinterface/styles.css | 703 +++++++++++++++--- webinterface/tests/protocol.test.mjs | 71 ++ webinterface/tests/results.test.mjs | 18 +- webinterface/tests/worker.test.mjs | 14 +- 27 files changed, 2060 insertions(+), 550 deletions(-) create mode 100644 scripts/wasm/smoke-protocol.mjs create mode 100644 scripts/wasm/write-build-info.mjs create mode 100644 webinterface/assets/README.md create mode 100644 webinterface/assets/bioinf-fr-logo-blau.png create mode 100644 webinterface/assets/logo-uni-freiburg.png create mode 100644 webinterface/columns.js create mode 100644 webinterface/options.js create mode 100644 webinterface/tests/protocol.test.mjs diff --git a/.github/workflows/build-wasm.yml b/.github/workflows/build-wasm.yml index 46e82f90..1790b36a 100644 --- a/.github/workflows/build-wasm.yml +++ b/.github/workflows/build-wasm.yml @@ -2,7 +2,6 @@ name: WebAssembly and web interface on: pull_request: - branches: [master] push: branches: [master] workflow_dispatch: @@ -36,7 +35,9 @@ jobs: - name: Cross-compile dependencies and IntaRNA run: bash scripts/wasm/build.sh - name: Check real WASM predictions against the native reference - run: node scripts/wasm/smoke.mjs + run: | + node scripts/wasm/smoke.mjs + node scripts/wasm/smoke-protocol.mjs - name: Stage the static site and notices run: bash scripts/wasm/stage.sh - name: Install browser test tools diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 18668a64..b8baddd9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,6 @@ on: push: branches: [master] pull_request: - branches: [master] defaults: run: diff --git a/ChangeLog b/ChangeLog index 2b5c79c0..9a9be5b4 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,11 @@ ## Interface and handling +- extend the browser interface with vaRRI styling, prediction/evaluation tasks, + multi-FASTA, start indices, CLI parameter groups, selectable CSV columns and + additional output previews/downloads; label pair-relative differences mfe Δ + and display actual compiled versions (PR #287 review) + - display sortable/filterable browser results and native CSV downloads; publish the tested interface alongside existing Pages documentation (#286) @@ -75,6 +80,10 @@ ## Technical changes and Optimizations +- annotate inherited virtual methods/destructors with override, preserve compiled + WASM asset provenance through staging, and expand protocol/browser validation + for the PR #287 review + - cross-compile IntaRNA and pinned dependencies to WebAssembly (#286, step 1) - verify browser predictions against native accessibility/energy references and exercise real WASM cancellation and nested Pages paths in Chromium/Firefox @@ -232,6 +241,29 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261021 Alexander Mitrofanov + * src/IntaRNA/*.h + * Mark 194 inherited virtual methods and 45 derived destructors override; + keep algorithms, interfaces and third-party headers unchanged. + * webinterface/{options,columns,protocol,app,results,intarna-worker}.js + + Add CLI-based parameter groups and validation, multi-FASTA prediction, + signed per-strand start indices and IntaRNAeval structure evaluation. + + Provide all CSV columns, table visibility controls, pair-relative mfe Δ, + local SHAPE/accessibility data and previews/downloads of native extra files. + * webinterface/index.html, styles.css, assets/* + * Follow vaRRI main-v2 colors and institutional header; keep one parameter + group open at a time and adapt controls/defaults to the selected task. + * scripts/wasm/{build,stage}.sh, write-build-info.mjs + + Record actual compiled engine/library versions and asset hashes in text/JSON + at build time; preserve/check provenance during staging and show it in UI. + * Reject inconsistent missing overrides in the Emscripten build. + * webinterface/tests/*, scripts/wasm/{smoke-protocol.mjs,test-browser.py} + + Exercise task validation/defaults, real WASM evaluation/prediction, multiple + records/coordinates, imported data, columns/reports and worker lifecycle. + * .github/workflows/{build,build-wasm}.yml, README.md, webinterface/docs/* + * Run WASM/browser checks on dependent PRs and document revised behavior, + provenance, native option replacements and browser limits (PR #287 review). + 261020 Alexander Mitrofanov * scripts/wasm/*, .github/workflows/build-wasm.yml, webinterface/app.js + Execute dependency API probes and test structured accessibility costs, diff --git a/README.md b/README.md index 5142f6cb..1200e7fb 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ **Efficient RNA-RNA interaction prediction incorporating accessibility and seeding of interaction sites** -Predict short RNA pairs locally with the [browser interface](https://backofenlab.github.io/IntaRNA/webinterface/). -See the [WebAssembly build instructions](docs/step1-wasm-compilation.md) and +Predict short RNA pairs or evaluate supplied structures locally with the [browser interface](https://backofenlab.github.io/IntaRNA/webinterface/). +See the [WebAssembly build instructions](webinterface/docs/step1-wasm-compilation.md) and [interface/deployment notes](webinterface/docs/step4-results-and-deployment.md). diff --git a/scripts/wasm/build.sh b/scripts/wasm/build.sh index 33ccaa17..b6dd493d 100644 --- a/scripts/wasm/build.sh +++ b/scripts/wasm/build.sh @@ -89,7 +89,7 @@ export VRNA_LIBS="-L$prefix/lib -lRNA -lm" (cd "$repo" && bash autotools-init.sh) mkdir -p "$build/intarna" (cd "$build/intarna" - CPPFLAGS="-I$build/intarna/src -I$repo/src $CPPFLAGS" emconfigure "$repo/configure" --host=wasm32-unknown-emscripten \ + CXXFLAGS="$CXXFLAGS -Werror=inconsistent-missing-override" CPPFLAGS="-I$build/intarna/src -I$repo/src $CPPFLAGS" emconfigure "$repo/configure" --host=wasm32-unknown-emscripten \ --disable-multithreading --disable-log-coloring --with-mdspan=kokkos \ --with-vrna="$prefix" --with-boost="$prefix" \ --with-boost-libdir="$prefix/lib" --with-zlib="$prefix" @@ -99,4 +99,7 @@ mkdir -p "$build/intarna" cp src/bin/IntaRNA.wasm "$build/site/wasm/intarna.wasm") # ES modules also work in Node smoke tests without changing package scope. printf '{"type":"module"}\n' > "$build/site/wasm/package.json" +node "$repo/scripts/wasm/write-build-info.mjs" "$repo" "$build/site" \ + "$(emcc -dumpversion)" "$(git -C "$repo" rev-parse HEAD)" \ + "$(git -C "$repo" status --porcelain --untracked-files=no)" echo "WASM assets: $build/site/wasm" diff --git a/scripts/wasm/smoke-protocol.mjs b/scripts/wasm/smoke-protocol.mjs new file mode 100644 index 00000000..d3947aef --- /dev/null +++ b/scripts/wasm/smoke-protocol.mjs @@ -0,0 +1,47 @@ +// Integration coverage of browser argument translation against the real CLI. +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { DEFAULTS, EVALUATION_EXAMPLE, prepareRequest, makeArguments } from '../../webinterface/protocol.js'; +import { CSV_CATALOG } from '../../webinterface/columns.js'; +import { parseResults } from '../../webinterface/results.js'; +const asset = resolve(process.argv[2] || 'build-wasm/site/wasm/intarna.js'); +const { default: createIntaRNA } = await import(pathToFileURL(asset)); +async function run(input, format = 'C') { + const request = prepareRequest({ ...DEFAULTS, ...input }); + const diagnostics = []; + const module = await createIntaRNA({ noInitialRun: true, locateFile: () => new URL('./intarna.wasm', pathToFileURL(asset)).href, print: (line) => diagnostics.push(line), printErr: (line) => diagnostics.push(line) }); + module.FS.writeFile('/query.fa', request.query.fasta); + module.FS.writeFile('/target.fa', request.target.fasta); + module.FS.mkdir('/output'); + for (const [key, value] of Object.entries(request.parameters)) if (/^(q|t)(Shape|AccFile)$/.test(key) && value) module.FS.writeFile(`/${key}.txt`, value + '\n'); + const args = makeArguments(request, format); + assert.equal(module.callMain(args), 0, `${args.join(' ')}\n${diagnostics.join('\n')}`); + const text = module.FS.readFile(format === 'C' ? '/results.csv' : '/report.txt', { encoding: 'utf8' }); + return { rows: format === 'C' ? parseResults(text) : [], text, fs: module.FS }; +} +const defaults = await run({ parameters: { outNumber: 10 } }); +assert.equal(defaults.rows.length, 10); +assert.equal(defaults.rows[0].E, -21.4); +const shifted = await run({ qIdxPos0: -3, tIdxPos0: -5 }); +assert.deepEqual(['start1', 'end1', 'start2', 'end2'].map((key) => shifted.rows[0][key]), [-5, 5, -3, 7]); +const multi = await run({ query: '>q1\nGGGAGGAGGG\n>q2\nGGGAGGAGGG', target: '>t1\nCCCUCCUCCC\n>t2\nCCCUCCUCCC', outputs: ['qMinE', 'tMinE'] }); +assert.equal(multi.rows.length, 4); +assert.ok(multi.rows.every((row) => row.deltaE === 0)); +assert.equal(multi.fs.readdir('/output').filter((name) => name.endsWith('.csv')).length, 8); +const pairwise = await run({ query: '>q1\nGGGAGGAGGG\n>q2\nGGGAGGAGGG', target: '>t1\nCCCUCCUCCC\n>t2\nCCCUCCUCCC', parameters: { outPairwise: true } }); +assert.equal(pairwise.rows.length, 2); +const evaluation = await run({ task: 'evaluation', rri: EVALUATION_EXAMPLE + ':1|||||||||&2|||||||||', outputs: ['qAcc', 'tPu'], csvColumns: CSV_CATALOG.map((c) => c.key) }); +assert.equal(evaluation.rows.length, 2); +assert.equal(evaluation.rows[0].E, -21.4); +assert.equal(evaluation.rows[0].seedE, 'NAN'); +assert.ok((await run({ task: 'evaluation', rri: EVALUATION_EXAMPLE }, 'D')).text.includes('-21.4')); +assert.ok((await run({ task: 'evaluation', rri: EVALUATION_EXAMPLE }, 'E')).text.length > 0); +const probabilities = await run({ parameters: { model: 'P' }, csvColumns: CSV_CATALOG.map((c) => c.key), outputs: ['bpProb', 'bpsvg', 'qSpotProb', 'tSpotProb', 'spotProb', 'pMinE'] }); +assert.equal(probabilities.rows.length, 1); +assert.ok(probabilities.fs.readFile('/output/bpsvg.svg', { encoding: 'utf8' }).includes('= 1); +console.log('Browser protocol: native-reference prediction/evaluation, signed coordinates, multi-FASTA, all columns, imported accessibility, energy/seed/helix/SHAPE options and output files passed'); diff --git a/scripts/wasm/stage.sh b/scripts/wasm/stage.sh index d6d32450..148ae02e 100644 --- a/scripts/wasm/stage.sh +++ b/scripts/wasm/stage.sh @@ -7,7 +7,8 @@ site="$build/site" test -s "$site/wasm/intarna.js" test -s "$site/wasm/intarna.wasm" mkdir -p "$site/licenses" -for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js; do +cp -R "$repo/webinterface/assets" "$site/" +for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js options.js columns.js; do cp "$repo/webinterface/$file" "$site/$file" done cp "$repo/webinterface/intarna-worker.js" "$site/intarna-worker.js" @@ -42,18 +43,16 @@ cat > "$site/licenses/index.html" <<'HTML'

Build provenance · Back to IntaRNA

HTML -python3 - "$repo" "$site" <<'PY' -import json -import subprocess -import sys +# Provenance belongs to the compiled assets, never the later staging revision. +python3 - "$site" <<'PYINFO' +import hashlib, json, sys from pathlib import Path -repo, site = sys.argv[1:] -metadata = { - "source": "https://github.com/BackofenLab/IntaRNA", - "commit": subprocess.check_output(["git", "-C", repo, "rev-parse", "HEAD"], text=True).strip(), - "emscripten": "4.0.23", "boost": "1.87.0", "viennarna": "2.7.0", "zlib": "1.3.1", - "threading": "single worker per prediction", -} -Path(site, "build-info.json").write_text(json.dumps(metadata, indent=2) + "\n") -PY +site = Path(sys.argv[1]) +metadata = json.loads((site / "build-info.json").read_text()) +if not (site / "build-info.txt").is_file(): + raise SystemExit("Missing compiled version text") +for name, digest in metadata["sha256"].items(): + if hashlib.sha256((site / "wasm" / name).read_bytes()).hexdigest() != digest: + raise SystemExit(f"Mismatched engine: {name}") +PYINFO echo "Static site: $site" diff --git a/scripts/wasm/test-browser.py b/scripts/wasm/test-browser.py index b712bde1..29b19f11 100644 --- a/scripts/wasm/test-browser.py +++ b/scripts/wasm/test-browser.py @@ -1,119 +1,177 @@ -"""Browser integration tests against staged assets, including the real WASM engine. - -Start an HTTP server serving build-wasm/site, then pass its URL. Optional ---browser selects Firefox; --screenshot saves the populated desktop workspace. -""" +"""Exercise the staged browser interface with real WASM at a nested URL.""" import argparse from pathlib import Path from playwright.sync_api import sync_playwright, expect parser = argparse.ArgumentParser(description=__doc__) -parser.add_argument("url") -parser.add_argument("--browser", choices=["chromium", "firefox"], default="chromium") -parser.add_argument("--screenshot") +parser.add_argument('url') +parser.add_argument('--browser', choices=['chromium', 'firefox'], default='chromium') +parser.add_argument('--screenshot') args = parser.parse_args() with sync_playwright() as playwright: browser = getattr(playwright, args.browser).launch(headless=True) - page = browser.new_page(viewport={"width": 1440, "height": 1100}) + page = browser.new_page(viewport={'width': 1440, 'height': 1100}) errors = [] - page.on("pageerror", lambda error: errors.append(str(error))) + page.on('pageerror', lambda error: errors.append(str(error))) page.goto(args.url) - page.wait_for_load_state("networkidle") - compute = page.get_by_role("button", name="Compute interactions", exact=True) - status = page.locator("#result-status") + page.wait_for_load_state('networkidle') + compute = page.locator('#compute') + status = page.locator('#result-status') + rows = page.locator('tbody tr') + + def open_group(key): + group = page.locator('#' + key) + if not group.evaluate('node => node.open'): + group.locator('summary').first.click() + expect(group).to_have_attribute('open', '') + assert page.locator('.parameter-group[open]').count() == 1 + + def fill(key, value): + parent = page.locator('#' + key).evaluate('node => node.closest(".parameter-group")?.id') + if parent: + open_group(parent) + page.locator('#' + key).fill(str(value)) + + def predict(): + compute.click() + expect(status).to_contain_text('complete:', timeout=120000) + expect(page.locator('#input-error')).not_to_be_visible() - # Invalid bases and multiple records must fail before engine startup. - page.locator("#query").fill(">bad\nACGU-ACGU") + expect(page.locator('#version-info')).to_contain_text('ViennaRNA 2.7.0') + for logo in page.locator('.site-header img').all(): + assert logo.evaluate('node => node.complete && node.naturalWidth > 0') + for key in ['query-options', 'target-options', 'seed-options', 'helix-options', 'shape-options', 'interaction-options', 'output-options', 'general-options', 'sequence-input']: + open_group(key) + + # JavaScript validation works even when the offending control is folded. + fill('query', '>bad\nACGU-ACGU') compute.click() - expect(page.locator("#input-error")).to_contain_text("use only") - expect(page.locator("#loading")).not_to_be_visible() - page.locator("#query").fill(">a\nACGUACGU\n>b\nACGUACGU") + expect(page.locator('#input-error')).to_contain_text('use only') + expect(page.locator('#loading')).not_to_be_visible() + page.locator('#example').click() + fill('temperature', '101') + open_group('sequence-input') compute.click() - expect(page.locator("#input-error")).to_contain_text("exactly one") - page.get_by_role("button", name="Load example").click() + expect(page.locator('#input-error')).to_contain_text('Temperature') + page.locator('#reset').click() - # Real RNAlib prediction: orientation, native-reference energy and suboptimals. - compute.click() - expect(status).to_contain_text("Prediction complete", timeout=120000) - rows = page.locator("tbody tr") + # Reference prediction, selectable CSV output and independent table visibility. + fill('outNumber', 10) + page.locator('#outCsvSort').select_option('E') + predict() expect(rows).to_have_count(10) - assert rows.first.locator("td").all_text_contents() == ["query", "1", "10", "target", "1", "10", "-21.40", "0.00"] - assert page.locator(".table-scroll").evaluate("node => node.scrollWidth") > 0 - for key in ["Query", "Query start", "Query end", "Target", "Target start", "Target end", "Energy (kcal/mol)", "ΔE (kcal/mol)"]: - page.get_by_role("button", name=f"Sort by {key}", exact=False).first.click() - # Select energy, then check numeric descending and ascending order. - page.get_by_role("button", name="Sort by Energy (kcal/mol)", exact=False).click() - page.get_by_role("button", name="Sort by Energy (kcal/mol)", exact=False).click() - expect(page.locator('th[aria-sort="descending"]')).to_contain_text("Energy") - assert float(rows.first.locator("td").nth(6).inner_text()) == -14.3 - page.get_by_role("button", name="Sort by Energy (kcal/mol)", exact=False).click() - assert float(rows.first.locator("td").nth(6).inner_text()) == -21.4 - page.locator("#search").fill("-21.4") + assert rows.first.locator('td').all_text_contents()[:6] == ['target', '1', '10', 'query', '1', '10'] + assert rows.first.locator('td').all_text_contents()[-2:] == ['-21.40', '0.00'] + page.get_by_role('button', name='Sort by Energy (kcal/mol)', exact=False).click() + assert float(rows.first.locator('td').nth(8).inner_text()) == -14.3 + page.get_by_role('button', name='Sort by Energy (kcal/mol)', exact=False).click() + page.locator('#search').fill('-21.4') expect(rows).to_have_count(1) - page.locator("#search").fill("no match") - expect(page.locator("#no-matches")).to_be_visible() - page.locator("#search").fill("") + page.locator('#search').fill('no match') + expect(page.locator('#no-matches')).to_be_visible() + page.locator('#search').fill('') + page.locator('.column-picker summary').click() + page.locator('#visible-columns input[value="start1"]').uncheck() + assert 'Target start' not in page.locator('thead').inner_text() + page.locator('#visible-columns input[value="start1"]').check() with page.expect_download() as downloaded: - page.get_by_role("button", name="Download CSV").click() + page.locator('#download').click() csv = Path(downloaded.value.path()).read_text() - assert "target;1;10;query;1;10;-21.4" in csv + assert csv.splitlines()[0] == 'id1;start1;end1;id2;start2;end2;subseqDP;hybridDP;E' + assert 'target;1;10;query;1;10;' in csv + assert 'deltaE' not in csv if args.screenshot: + open_group('sequence-input') page.screenshot(path=args.screenshot, full_page=True) + page.set_viewport_size({'width': 390, 'height': 844}) + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + page.set_viewport_size({'width': 1440, 'height': 1100}) - # Mobile content stays inside the viewport; the data table scrolls itself. - page.set_viewport_size({"width": 390, "height": 844}) - assert page.evaluate("document.documentElement.scrollWidth <= innerWidth") - page.set_viewport_size({"width": 1440, "height": 1100}) + # Multi-FASTA prediction, signed inclusive output and additional files. + page.locator('#example').click() + fill('query', '>q1\nGGGAGGAGGG\n>q2\nGGGAGGAGGG') + fill('qIdxPos0', -3) + fill('tIdxPos0', -5) + open_group('output-options') + page.locator('#output-options input[value="qMinE"]').check() + predict() + expect(rows).to_have_count(2) + assert rows.first.locator('td').all_text_contents()[:6] == ['target', '-5', '5', 'q1', '-3', '7'] + expect(page.locator('#file-list details')).to_have_count(2) + page.locator('#file-list summary').first.click() + expect(page.locator('#file-list pre').first).to_contain_text('minE') - # Deliberately hold WASM download so Abort deterministically covers loading. - held_routes = [] - page.route("**/wasm/intarna.wasm", lambda route: held_routes.append(route)) + # FASTA identity follows native first-token naming, including collisions. + fill('query', '>q first\nGGGAGGAGGG\n>q second\nGGGAGGAGGG') compute.click() - expect(page.locator("#loading")).to_be_visible() - page.get_by_role("button", name="Abort computation").click() - expect(status).to_contain_text("aborted") - expect(compute).to_be_enabled() - # Settle any intercepted request after terminating the worker. - for route in held_routes: - route.abort() - page.unroute("**/wasm/intarna.wasm") + expect(page.locator('#input-error')).to_contain_text('unique') - # Wait until actual synchronous C++ starts, then Escape must still work. - page.locator("#query").fill(">long_query\n" + "G" * 1000) - page.locator("#target").fill(">long_target\n" + "C" * 1000) + # Evaluation resets native defaults and removes prediction-only controls. + fill('query', '>q1\nGGGAGGAGGG\n>q2\nGGGAGGAGGG') + page.locator('#task').select_option('evaluation') + expect(page.locator('#seed-options')).not_to_be_visible() + expect(page.locator('#outNumber')).to_be_disabled() + fill('rri', '1||||||||||&1||||||||||') compute.click() - expect(page.locator("#loading-status")).to_contain_text("Predicting RNA", timeout=120000) - page.keyboard.press("Escape") - expect(status).to_contain_text("aborted") - page.get_by_role("button", name="Load example").click() + expect(page.locator('#input-error')).to_contain_text('exactly one') + page.locator('#example').click() + fill('rri', '1||||||||||&1||||||||||:1|||||||||&2|||||||||') + open_group('output-options') + page.locator('#output-options input[value="detailed"]').check() + page.locator('#output-options input[value="ED1"]').check() + predict() + expect(status).to_contain_text('Evaluation complete') + expect(rows).to_have_count(2) + expect(page.locator('thead')).to_contain_text('ED1') + expect(page.locator('#file-list summary')).to_contain_text('intarna-detailed.txt') + page.locator('#file-list summary').click() + expect(page.locator('#file-list pre')).to_contain_text('-21.4') + with page.expect_download() as report: + page.locator('#file-list button').click() + assert '-21.4' in Path(report.value.path()).read_text() + fill('rri', '1|||&1||') compute.click() - expect(status).to_contain_text("Prediction complete", timeout=120000) - expect(rows).to_have_count(10) + expect(page.locator('#input-error')).to_contain_text('same nonzero') - # Ambiguous bases produce a valid empty prediction, not a fabricated result. - page.locator("#query").fill(">no_pairs\nNNNNNNNNNN") + # Startup and synchronous C++ cancellation both allow a fresh prediction. + page.locator('#task').select_option('prediction') + page.locator('#example').click() + held_routes = [] + page.route('**/wasm/intarna.wasm', lambda route: held_routes.append(route)) compute.click() - expect(status).to_contain_text("No favorable interactions", timeout=120000) - expect(page.locator("#results-view")).not_to_be_visible() + expect(page.locator('#loading')).to_be_visible() + page.locator('#abort').click() + expect(status).to_contain_text('aborted') + for route in held_routes: + route.abort() + page.unroute('**/wasm/intarna.wasm') + fill('query', '>long_query\n' + 'G' * 1000) + fill('target', '>long_target\n' + 'C' * 1000) + compute.click() + expect(page.locator('#loading-status')).to_contain_text('Predicting RNA', timeout=120000) + page.keyboard.press('Escape') + expect(status).to_contain_text('aborted') + page.locator('#example').click() + predict() - # Sequence identifiers remain text, even if they resemble HTML. - page.locator("#query").fill(">\nGGGAGGAGGG") + fill('query', '>no_pairs\nNNNNNNNNNN') compute.click() - expect(status).to_contain_text("Prediction complete", timeout=120000) - assert page.locator("tbody img").count() == 0 - assert "\nGGGAGGAGGG') + predict() + assert page.locator('tbody img').count() == 0 + assert ' pathToFileURL(resolve(site, 'wasm/intarna.wasm')).href, print: (line) => lines.push(line), printErr: (line) => lines.push(line) }); +if (module.callMain(['--version']) !== 0) throw new Error('Could not read compiled engine versions'); +const version = lines.join('\n'); +const match = /IntaRNA\s+(\S+)[\s\S]*Vienna RNA package\s+(\S+)\s+and boost\s+(\S+)/i.exec(version); +if (!match) throw new Error(`Unrecognized engine version output: ${version}`); +const metadata = { + source: 'https://github.com/BackofenLab/IntaRNA', commit, + dirty: !!changes, + intarna: match[1], viennarna: match[2], boost: match[3], emscripten, zlib: '1.3.1', + threading: 'single worker per task', + sha256: Object.fromEntries(['intarna.js', 'intarna.wasm'].map((name) => [name, createHash('sha256').update(readFileSync(resolve(site, 'wasm', name))).digest('hex')])), +}; +writeFileSync(resolve(site, 'build-info.json'), JSON.stringify(metadata, null, 2) + '\n'); +writeFileSync(resolve(site, 'build-info.txt'), `${version}\nIntaRNA repository: ${metadata.source}\nIntaRNA revision: ${metadata.commit}${metadata.dirty ? ' (modified sources)' : ''}\nEmscripten: ${emscripten}\nzlib: ${metadata.zlib}\n${Object.entries(metadata.sha256).map(([name, hash]) => `SHA-256 ${name}: ${hash}`).join('\n')}\n`); +console.log(`Recorded compiled versions: IntaRNA ${match[1]}, ViennaRNA ${match[2]}, Boost ${match[3]}`); diff --git a/webinterface/app.js b/webinterface/app.js index 652c0c82..eeb37665 100644 --- a/webinterface/app.js +++ b/webinterface/app.js @@ -1,95 +1,159 @@ -import { DEFAULTS, parseSequence, prepareRequest } from './protocol.js'; +import { DEFAULTS, EVALUATION_EXAMPLE, parseFasta, prepareRequest, CSV_COLUMNS } from './protocol.js'; +import { GROUPS, FIELDS, EXTRA_OUTPUTS } from './options.js'; +import { CSV_CATALOG } from './columns.js'; import { PredictionRunner } from './runner.js'; -import { ResultsView } from './results.js'; +import { ResultsView, downloadText } from './results.js'; const byId = (id) => document.getElementById(id); -const form = byId('prediction-form'); -const runner = new PredictionRunner(); -const dialog = byId('loading'); -const status = byId('result-status'); -const errorBox = byId('input-error'); +const form = byId('prediction-form'), runner = new PredictionRunner(); +const dialog = byId('loading'), status = byId('result-status'), errorBox = byId('input-error'); const results = new ResultsView(byId('results-view')); +const csvInputs = [], outputInputs = []; +function element(tag, text, className) { + const node = document.createElement(tag); + if (text) node.textContent = text; + if (className) node.className = className; + return node; +} +function group(key, label) { + const details = element('details', '', 'parameter-group'); + details.id = key; details.name = 'parameters'; details.append(element('summary', label)); + const body = element('div', '', 'group-body'); details.append(body); + byId('parameter-groups').append(details); return body; +} +for (const definition of GROUPS) { + const body = group(definition.key, definition.label), grid = element('div', '', 'field-grid'); body.append(grid); + for (const field of definition.fields) { + const wrapper = element('div', '', `field${field.type === 'checkbox' ? ' check' : ''}`); + const label = element('label', field.label); label.htmlFor = field.key; label.title = `--${field.key}`; + const input = element(field.type === 'select' ? 'select' : field.type === 'textarea' ? 'textarea' : 'input'); input.id = field.key; + if (input.tagName === 'INPUT') input.type = field.type; + if (field.type === 'select') for (const [value, name] of Object.entries(field.choices)) { const option = element('option', name); option.value = value; input.append(option); } + if (field.type === 'number') { input.min = field.min; input.max = field.max; input.step = field.step ?? 1; } + if (field.type === 'checkbox') { input.checked = field.value; wrapper.append(input, label); } + else { input.value = field.value; wrapper.append(label, input); } + if (field.hint) { const hint = element('p', field.hint, 'field-hint'); hint.id = `${field.key}-help`; input.setAttribute('aria-describedby', hint.id); wrapper.append(hint); } + if (field.type === 'textarea') { + input.spellcheck = false; + const upload = element('input'); upload.type = 'file'; upload.accept = '.txt,.csv,.shape,.dat'; upload.setAttribute('aria-label', `Load ${field.label} from a local text file`); + upload.dataset.for = field.key; + upload.addEventListener('change', async () => { + const file = upload.files[0]; if (!file) return; + if (file.size > 1000000) { errorBox.textContent = 'Choose a text file of at most 1 MB.'; errorBox.hidden = false; return; } + input.value = await file.text(); input.dispatchEvent(new Event('input', { bubbles: true })); upload.value = ''; + }); wrapper.append(upload); + } + grid.append(wrapper); + } +} +function checkbox(container, key, labelText, description) { + const label = element('label'), input = element('input'); input.type = 'checkbox'; input.value = key; + if (description) label.title = description; + label.append(input, document.createTextNode(labelText)); container.append(label); return input; +} +const outputBody = byId('output-options').querySelector('.group-body'); +outputBody.append(element('h3', 'CSV output columns', 'subheading')); +outputBody.append(element('p', 'seq1 is the target; seq2 is the query. Select columns for both the table and CSV download. You can hide table columns after computation without changing the CSV.', 'input-help')); +const selectionTools = element('div', '', 'selection-tools'); outputBody.append(selectionTools); +const csvPicker = element('div', '', 'checkbox-grid'); outputBody.append(csvPicker); +for (const column of [...CSV_COLUMNS.split(',').map((key) => CSV_CATALOG.find((column) => column.key === key)), ...CSV_CATALOG.filter((column) => !CSV_COLUMNS.split(',').includes(column.key))]) csvInputs.push(checkbox(csvPicker, column.key, `${column.key} — ${column.description}`, column.description)); +for (const [name, selection] of [['Select all', CSV_CATALOG.map((c) => c.key)], ['CLI defaults', CSV_COLUMNS.split(',')], ['Clear selection', []]]) { + const button = element('button', name); button.type = 'button'; + button.addEventListener('click', () => { for (const input of csvInputs) input.checked = selection.includes(input.value); clearResults(); }); selectionTools.append(button); +} +outputBody.append(element('h3', 'Additional output files', 'subheading')); +outputBody.append(element('p', 'Reports are computed with the same settings. ASCII, detailed and ensemble reports each run a fresh engine instance and can take extra time.', 'input-help')); +const outputPicker = element('div', '', 'checkbox-grid'); outputBody.append(outputPicker); +for (const output of EXTRA_OUTPUTS) outputInputs.push(checkbox(outputPicker, output.key, output.label)); +const generalBody = group('general-options', 'General'); +generalBody.append(element('p', 'Tasks run locally in one worker. Abort or Escape stops computation immediately. Process-level settings (threads, log paths, parameter-file paths and terminal help) are available in the CLI.')); +const docs = element('a', 'Full CLI parameter documentation'); docs.href = 'https://github.com/BackofenLab/IntaRNA#readme'; generalBody.append(docs); +// Use explicit grouping as a fallback for browsers without details[name]. +for (const details of document.querySelectorAll('.parameter-group')) { + details.addEventListener('toggle', () => { if (details.open) for (const other of document.querySelectorAll('.parameter-group')) if (other !== details) other.open = false; }); +} function clearResults(message = 'Inputs changed. Compute to update the interactions.') { - results.clear(); - byId('diagnostics').hidden = true; - byId('diagnostic-text').textContent = ''; - status.textContent = message; + results.clear(); byId('output-files').hidden = true; byId('file-list').replaceChildren(); + byId('diagnostics').hidden = true; byId('diagnostic-text').textContent = ''; status.textContent = message; } - function updateLength(name) { try { - const rna = parseSequence(byId(name).value, name === 'query' ? 'Query' : 'Target'); - byId(`${name}-length`).textContent = `${rna.sequence.length.toLocaleString()} nucleotides`; - } catch { - byId(`${name}-length`).textContent = 'Check sequence format and length'; - } + const parsed = parseFasta(byId(name).value, name === 'query' ? 'Query' : 'Target', byId('task').value === 'prediction'); + byId(`${name}-length`).textContent = `${parsed.records.length} record${parsed.records.length === 1 ? '' : 's'}, ${parsed.records.reduce((sum, r) => sum + r.sequence.length, 0).toLocaleString()} nucleotides`; + } catch { byId(`${name}-length`).textContent = 'Check sequence format and length'; } } - -function setBusy(busy) { - byId('inputs').disabled = busy; - byId('results').setAttribute('aria-busy', String(busy)); - if (busy) dialog.showModal(); - else { - dialog.close(); - byId('compute').focus(); +function updateAvailability() { + const evaluation = byId('task').value === 'evaluation'; + byId('evaluation-input').hidden = !evaluation; + byId('task-help').textContent = evaluation ? 'Evaluate supplied structures for one query and one target. Prediction constraints are ignored.' : 'Predict interactions for all query–target sequence pairs.'; + byId('compute').textContent = evaluation ? 'Evaluate interactions' : 'Compute interactions'; + for (const definition of GROUPS) { + const details = byId(definition.key); details.hidden = evaluation && !!definition.prediction; + for (const field of definition.fields) { + const input = byId(field.key); + input.disabled = evaluation && !!field.prediction || !evaluation && definition.key === 'helix-options' && byId('model').value !== 'B' || field.key === 'kineticScore' && byId('mode').value !== 'K'; + } } + for (const input of outputInputs) { input.disabled = evaluation && EXTRA_OUTPUTS.find((entry) => entry.key === input.value).prediction; if (input.disabled) input.checked = false; } + updateLength('query'); updateLength('target'); } - -for (const name of ['query', 'target', 'count']) { - byId(name).addEventListener('input', () => { - if (name !== 'count') updateLength(name); - errorBox.hidden = true; - clearResults(); - }); +function resetParameters() { + for (const field of FIELDS) { const input = byId(field.key); if (field.type === 'checkbox') input.checked = field.value; else input.value = field.value; } + for (const input of csvInputs) input.checked = CSV_COLUMNS.split(',').includes(input.value); + for (const input of outputInputs) input.checked = false; + byId('qIdxPos0').value = 1; byId('tIdxPos0').value = 1; byId('rri').value = EVALUATION_EXAMPLE; + updateAvailability(); errorBox.hidden = true; clearResults('Parameters reset to CLI defaults. Compute to update the interactions.'); } +byId('task').addEventListener('change', resetParameters); +byId('reset').addEventListener('click', resetParameters); +form.addEventListener('input', (event) => { + if (event.target.type === 'file') return; + updateAvailability(); errorBox.hidden = true; clearResults(); +}); byId('example').addEventListener('click', () => { - for (const name of ['query', 'target', 'count']) byId(name).value = DEFAULTS[name]; - updateLength('query'); - updateLength('target'); - errorBox.hidden = true; - clearResults('Example loaded. Compute to predict its interactions.'); + byId('query').value = DEFAULTS.query; byId('target').value = DEFAULTS.target; + resetParameters(); byId('sequence-input').open = true; clearResults('Example loaded. Compute to analyze its interactions.'); }); byId('abort').addEventListener('click', () => runner.abort()); dialog.addEventListener('cancel', (event) => { event.preventDefault(); runner.abort(); }); window.addEventListener('pagehide', () => runner.abort()); - +function showFiles(files) { + for (const file of files) { + const details = element('details'), summary = element('summary', file.name), preview = element('pre', file.text.slice(0, 100000)); + const download = element('button', 'Download file', 'secondary'); download.type = 'button'; download.addEventListener('click', () => downloadText(file.name, file.text)); + details.append(summary, download, preview); + if (file.text.length > 100000) details.append(element('p', 'Preview limited to 100,000 characters. Download contains the complete file.')); + byId('file-list').append(details); + } + byId('output-files').hidden = !files.length; +} form.addEventListener('submit', async (event) => { event.preventDefault(); - const request = { query: byId('query').value, target: byId('target').value, count: Number(byId('count').value) }; + const request = { + task: byId('task').value, query: byId('query').value, target: byId('target').value, + qIdxPos0: byId('qIdxPos0').value, tIdxPos0: byId('tIdxPos0').value, rri: byId('rri').value, + parameters: Object.fromEntries(FIELDS.map((field) => [field.key, field.type === 'checkbox' ? byId(field.key).checked : byId(field.key).value])), + csvColumns: csvInputs.filter((input) => input.checked).map((input) => input.value), outputs: outputInputs.filter((input) => input.checked && !input.disabled).map((input) => input.value), + }; errorBox.hidden = true; - try { - prepareRequest(request); - } catch (error) { - errorBox.textContent = error.message; - errorBox.hidden = false; - return; - } - clearResults('Computing interactions…'); - byId('loading-status').textContent = 'Loading the prediction engine…'; - setBusy(true); + try { prepareRequest(request); } catch (error) { errorBox.textContent = error.message; errorBox.hidden = false; return; } + clearResults('Computing interactions…'); byId('loading-status').textContent = 'Loading the prediction engine…'; + byId('inputs').disabled = true; byId('results').setAttribute('aria-busy', 'true'); dialog.showModal(); try { const result = await runner.run(request, (message) => { byId('loading-status').textContent = message; }); - const count = results.show(result.csv); - status.textContent = count ? `Prediction complete: ${count} interaction${count === 1 ? '' : 's'}.` : 'No favorable interactions found with these sequences and the default settings.'; - byId('diagnostic-text').textContent = result.diagnostics; - byId('diagnostics').hidden = !result.diagnostics; + const count = results.show(result.csv, result.csvColumns); + status.textContent = count ? `${request.task === 'evaluation' ? 'Evaluation' : 'Prediction'} complete: ${count} interaction${count === 1 ? '' : 's'}.` : request.task === 'prediction' && Number(request.parameters.outNumber) === 0 ? 'Accessibility computation complete. No interaction rows requested.' : 'No favorable interactions found with these sequences and settings.'; + showFiles(result.files); byId('diagnostic-text').textContent = result.diagnostics; byId('diagnostics').hidden = !result.diagnostics; } catch (error) { if (error.name === 'AbortError') status.textContent = 'Computation aborted. You can edit the sequences and try again.'; - else { - status.textContent = 'Prediction failed.'; - errorBox.textContent = error.message; - errorBox.hidden = false; - byId('diagnostic-text').textContent = error.diagnostics || ''; - byId('diagnostics').hidden = !error.diagnostics; - } + else { status.textContent = 'Computation failed.'; errorBox.textContent = error.message; errorBox.hidden = false; byId('diagnostic-text').textContent = error.diagnostics || ''; byId('diagnostics').hidden = !error.diagnostics; } } finally { - setBusy(false); + byId('inputs').disabled = false; updateAvailability(); byId('results').setAttribute('aria-busy', 'false'); dialog.close(); byId('compute').focus(); } }); - -if (!globalThis.Worker || !globalThis.WebAssembly) { - byId('compute').disabled = true; - errorBox.textContent = 'This browser needs WebAssembly and Web Workers. Use a current browser or the command line version.'; - errorBox.hidden = false; -} +resetParameters(); +fetch(new URL('./build-info.json', import.meta.url)).then((response) => { + if (!response.ok) throw new Error('Missing build provenance'); return response.json(); +}).then((info) => { byId('version-info').textContent = `IntaRNA ${info.intarna} (${info.commit.slice(0, 12)}${info.dirty ? ', modified sources' : ''}) · ViennaRNA ${info.viennarna} · Emscripten ${info.emscripten}`; }).catch(() => { byId('version-info').textContent = 'Build versions unavailable. Serve the staged interface with its compiled engine and build-info files.'; }); +if (!globalThis.Worker || !globalThis.WebAssembly) { byId('compute').disabled = true; errorBox.textContent = 'This browser needs WebAssembly and Web Workers. Use a current browser or the command line version.'; errorBox.hidden = false; } diff --git a/webinterface/assets/README.md b/webinterface/assets/README.md new file mode 100644 index 00000000..6ede01c4 --- /dev/null +++ b/webinterface/assets/README.md @@ -0,0 +1,12 @@ +# Institutional header assets + +The header follows [vaRRI main-v2](https://github.com/BackofenLab/vaRRI/blob/main-v2/src/ui/components/site-header.js). +The original institutional images were downloaded on 2026-10-21 from: + +- https://www.bioinf.uni-freiburg.de/assets/images/bioinf-fr-logo-blau.png +- https://www.bioinf.uni-freiburg.de/assets/images/logo-uni-freiburg.png + +These are institutional marks of the Bioinformatics Group Freiburg and the +University of Freiburg. They identify the affiliated research group/university; +they are not covered by the IntaRNA software license. Keep their proportions and +attribution links. Local copies avoid third-party requests from the interface. diff --git a/webinterface/assets/bioinf-fr-logo-blau.png b/webinterface/assets/bioinf-fr-logo-blau.png new file mode 100644 index 0000000000000000000000000000000000000000..0d8409ba90153521d0ce2da58ab040b2d7777e7a GIT binary patch literal 187836 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 =i`=D|1+{79w6b?(tBC{ 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
  • 9-hb_n>HI@_y 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�T{at+zQAKHtO9V|ISAKtra7I|;mhbG&dfYH(0k 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 literal 0 HcmV?d00001 diff --git a/webinterface/assets/logo-uni-freiburg.png b/webinterface/assets/logo-uni-freiburg.png new file mode 100644 index 0000000000000000000000000000000000000000..1e2bb249218cd8ad4e57b79c0da035708dc86651 GIT binary patch literal 24542 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 literal 0 HcmV?d00001 diff --git a/webinterface/columns.js b/webinterface/columns.js new file mode 100644 index 00000000..6b42b93a --- /dev/null +++ b/webinterface/columns.js @@ -0,0 +1,315 @@ +// Column IDs and descriptions from OutputHandlerCsv.h; seq1 = target, seq2 = query. +export const CSV_CATALOG = [ + { + "key": "id1", + "label": "Target", + "description": "id of first sequence", + "numeric": false + }, + { + "key": "id2", + "label": "Query", + "description": "id of second sequence", + "numeric": false + }, + { + "key": "seq1", + "label": "seq1", + "description": "full first sequence", + "numeric": false + }, + { + "key": "seq2", + "label": "seq2", + "description": "full second sequence", + "numeric": false + }, + { + "key": "subseq1", + "label": "subseq1", + "description": "interacting subsequence of first sequence", + "numeric": false + }, + { + "key": "subseq2", + "label": "subseq2", + "description": "interacting subsequence of second sequence", + "numeric": false + }, + { + "key": "subseqDP", + "label": "subseqDP", + "description": "hybrid subsequences compatible with hybridDP", + "numeric": false + }, + { + "key": "subseqDB", + "label": "subseqDB", + "description": "hybrid subsequences compatible with hybridDB", + "numeric": false + }, + { + "key": "start1", + "label": "Target start", + "description": "start index of hybrid in seq1", + "numeric": true + }, + { + "key": "end1", + "label": "Target end", + "description": "end index of hybrid in seq1", + "numeric": true + }, + { + "key": "start2", + "label": "Query start", + "description": "start index of hybrid in seq2", + "numeric": true + }, + { + "key": "end2", + "label": "Query end", + "description": "end index of hybrid in seq2", + "numeric": true + }, + { + "key": "hybridDP", + "label": "hybridDP", + "description": "hybrid in VRNA dot-bracket notation", + "numeric": false + }, + { + "key": "hybridDB", + "label": "hybridDB", + "description": "hybrid in dot-bar notation", + "numeric": false + }, + { + "key": "hybridDPfull", + "label": "hybridDPfull", + "description": "hybrid in VRNA dot-bracket notation for full sequence lengths", + "numeric": false + }, + { + "key": "hybridDBfull", + "label": "hybridDBfull", + "description": "hybrid in dot-bar notation for full sequence lengths", + "numeric": false + }, + { + "key": "bpList", + "label": "bpList", + "description": "list of hybrid base pairs like (1,3),(4,2),...", + "numeric": false + }, + { + "key": "E", + "label": "Energy (kcal/mol)", + "description": "overall interaction energy", + "numeric": true + }, + { + "key": "Etotal", + "label": "Etotal", + "description": "overall energy of the interaction including the intra-molecular ensemble energies (E+Eall1+Eall2)", + "numeric": true + }, + { + "key": "ED1", + "label": "ED1", + "description": "ED value of seq1", + "numeric": true + }, + { + "key": "ED2", + "label": "ED2", + "description": "ED value of seq2", + "numeric": true + }, + { + "key": "Pu1", + "label": "Pu1", + "description": "probability to be accessible for seq1", + "numeric": true + }, + { + "key": "Pu2", + "label": "Pu2", + "description": "probability to be accessible for seq2", + "numeric": true + }, + { + "key": "E_init", + "label": "E_init", + "description": "initiation energy", + "numeric": true + }, + { + "key": "E_loops", + "label": "E_loops", + "description": "sum of loop energies (excluding E_init)", + "numeric": true + }, + { + "key": "E_dangleL", + "label": "E_dangleL", + "description": "dangling end contribution of base pair (start1,end2)", + "numeric": true + }, + { + "key": "E_dangleR", + "label": "E_dangleR", + "description": "dangling end contribution of base pair (end1,start2)", + "numeric": true + }, + { + "key": "E_endL", + "label": "E_endL", + "description": "penalty of closing base pair (start1,end2)", + "numeric": true + }, + { + "key": "E_endR", + "label": "E_endR", + "description": "penalty of closing base pair (end1,start2)", + "numeric": true + }, + { + "key": "E_hybrid", + "label": "E_hybrid", + "description": "energy of hybridization only = E - ED1 - ED2", + "numeric": true + }, + { + "key": "E_norm", + "label": "E_norm", + "description": "length normalized energy = E/ln(length(seq1)*length(seq2))", + "numeric": true + }, + { + "key": "E_hybridNorm", + "label": "E_hybridNorm", + "description": "length normalized energy of hybridization only = E_hybrid / ln(length(seq1)*length(seq2))", + "numeric": true + }, + { + "key": "E_add", + "label": "E_add", + "description": "user provided energy shift", + "numeric": true + }, + { + "key": "seedStart1", + "label": "seedStart1", + "description": "start index of the seed in seq1", + "numeric": true + }, + { + "key": "seedEnd1", + "label": "seedEnd1", + "description": "end index of the seed in seq1", + "numeric": true + }, + { + "key": "seedStart2", + "label": "seedStart2", + "description": "start index of the seed in seq2", + "numeric": true + }, + { + "key": "seedEnd2", + "label": "seedEnd2", + "description": "end index of the seed in seq2", + "numeric": true + }, + { + "key": "seedE", + "label": "seedE", + "description": "overall energy of the seed only (including accessibility etc)", + "numeric": true + }, + { + "key": "seedED1", + "label": "seedED1", + "description": "ED value of seq1 of the seed only (excluding rest)", + "numeric": true + }, + { + "key": "seedED2", + "label": "seedED2", + "description": "ED value of seq2 of the seed only (excluding rest)", + "numeric": true + }, + { + "key": "seedPu1", + "label": "seedPu1", + "description": "probability of seed region to be accessible for seq1", + "numeric": true + }, + { + "key": "seedPu2", + "label": "seedPu2", + "description": "probability of seed region to be accessible for seq2", + "numeric": true + }, + { + "key": "w", + "label": "w", + "description": "Boltzmann weight of the interaction energy", + "numeric": true + }, + { + "key": "Eall", + "label": "Eall", + "description": "ensemble energy of all interactions (outConstraint.needZall)", + "numeric": true + }, + { + "key": "Eall1", + "label": "Eall1", + "description": "ensemble energy of all intra-molecular structures of seq1", + "numeric": true + }, + { + "key": "Eall2", + "label": "Eall2", + "description": "ensemble energy of all intra-molecular structures of seq2", + "numeric": true + }, + { + "key": "Zall", + "label": "Zall", + "description": "partition function of all interactions (outConstraint.needZall)", + "numeric": true + }, + { + "key": "Zall1", + "label": "Zall1", + "description": "partition function of all intra-molecular structures of seq1", + "numeric": true + }, + { + "key": "Zall2", + "label": "Zall2", + "description": "partition function of all intra-molecular structures of seq2", + "numeric": true + }, + { + "key": "EallTotal", + "label": "EallTotal", + "description": "total ensemble energy (Eall+Eall1+Eall2) of all interactions including the intra-molecular ensemble energies (outConstraint.needZall)", + "numeric": true + }, + { + "key": "P_E", + "label": "P_E", + "description": "probability of mfe within interaction ensemble (outConstraint.needZall)", + "numeric": true + }, + { + "key": "RT", + "label": "RT", + "description": "the scaled temperature used for Boltzmann weight computation", + "numeric": true + } +]; diff --git a/webinterface/docs/step1-wasm-compilation.md b/webinterface/docs/step1-wasm-compilation.md index ffa6d170..4fae4a7c 100644 --- a/webinterface/docs/step1-wasm-compilation.md +++ b/webinterface/docs/step1-wasm-compilation.md @@ -14,6 +14,7 @@ Activate Emscripten 4.0.23 with `source /path/to/emsdk/emsdk_env.sh`, then run: ```sh bash scripts/wasm/build.sh node scripts/wasm/smoke.mjs +node scripts/wasm/smoke-protocol.mjs ``` `WASM_BUILD_DIR` changes the build directory; `JOBS` defaults to 2. Source @@ -74,3 +75,18 @@ WASM from the same build together. The Node smoke test runs real Turner/default-accessibility predictions and checks energy, coordinates, nonzero accessibility costs, suboptimal counts and C++ error handling against the native CLI. + +## Compiled version information and virtual overrides (PR #287 review) + +After copying the paired JS/WASM assets, `write-build-info.mjs` invokes their +actual `--version` entry point. It records IntaRNA package version, ViennaRNA and +Boost versions, source repository/revision and dirty status, Emscripten version, +zlib version and asset hashes in `build-info.txt` and `build-info.json`. The +interface displays those versions and links both files. Staging checks the +hashes and preserves provenance even if the staging checkout has moved. + +IntaRNA's inherited virtual methods and destructors declare `override`, including +previously unannotated energy/accessibility, seed/helix, predictor and output +families. The Emscripten build treats inconsistent missing overrides as errors. +Vendored Easylogging++ remains unchanged. This annotation-only C++ change does +not alter algorithms, virtual extension points or scientific defaults. diff --git a/webinterface/docs/step2-worker-architecture.md b/webinterface/docs/step2-worker-architecture.md index da5a8a60..8a2f8090 100644 --- a/webinterface/docs/step2-worker-architecture.md +++ b/webinterface/docs/step2-worker-architecture.md @@ -1,25 +1,31 @@ -# Step 2: worker and I/O architecture (#286) +# Step 2: worker and I/O architecture (#286, PR #287 review) -`PredictionRunner` creates an ES module Web Worker on demand. The main thread -sends `{query, target, count}` as text and receives `status`, `result` or `error` -messages. `protocol.js` validates both the UI and worker boundary and constructs -fixed CLI options; users cannot inject CLI arguments or file paths. +`PredictionRunner` creates one module Web Worker per task and sends sequence +text, task, start indices, validated parameter values, CSV column IDs, optional +output IDs and evaluation structures. It receives status, result or error +messages. `prepareRequest` validates both the UI and worker boundary; argument +construction only accepts schema keys, enumerated output formats and fixed paths. -The worker lazily imports `wasm/intarna.js`, awaits `createIntaRNA` (mapping `locateFile` to the staged lowercase WASM name), writes -normalized single-record FASTA to `/query.fa` and `/target.fa` in MEMFS, and calls -`callMain` once. It selects semicolon-delimited CSV output in `/results.csv` and -reads that file after main returns and the C++ output streams close. Logs are -separate from CSV, bounded to the most recent 25 lines of 2,000 characters. -Nonzero exit codes, missing assets, allocation failures and JS errors reject -the prediction. Warnings travel with successful results as diagnostics. +The worker imports `wasm/intarna.js`, maps `locateFile` to the lowercase WASM +asset and creates an isolated `createIntaRNA` instance. It writes normalized +multi-FASTA to `/query.fa` and `/target.fa`; local SHAPE/accessibility text uses +fixed `q/tShape.txt` or `q/tAccFile.txt` paths. CSV is written to `/results.csv`; +additional native output prefixes write files under `/output/`. Multi-FASTA +filenames use the CLI's native record-number suffixes. -Every computation gets fresh C++ globals, RNAlib state and files. Both success -and failure terminate the worker. Abort uses `Worker.terminate()` from the main -thread, so it works during download, initialization and synchronous C++ execution. -Posting an abort message to a busy worker would not interrupt C++. Cancellation -rejects with `AbortError`, releases references and ignores queued stale messages; -the next computation creates a fresh worker. No shared memory, cross-origin -isolation, server, sequence upload or persistent sequence storage is needed. +Main is called once per WASM instance, keeping C++ globals, RNAlib state and files +isolated. Optional ASCII/detailed/ensemble reports each use another fresh +instance with the same inputs/settings. This costs an additional computation per +report and is explained next to those controls. Files are read only after main +returns and streams close. The worker sends CSV, selected columns, additional +file text and bounded diagnostics (25 lines, 2,000 characters each). Nonzero +exits and JS/runtime failures settle as errors, without partial successful files. -Run protocol and lifecycle tests with `cd webinterface && npm test`. Browser -integration tests exercise the real worker and WASM assets after staging. +Both success and failure terminate the worker. Abort uses `Worker.terminate()` +on the main thread and immediately rejects with `AbortError`, including during +synchronous C++ execution. Stale messages are ignored and the next task starts +fresh. Inputs are never uploaded or persisted. No backend, shared memory or +cross-origin isolation is needed. A single WASM instance can grow up to 1 GiB. + +Run `npm test --prefix webinterface`, then the native-reference WASM smoke tests +and Chromium/Firefox suites described in step 4. diff --git a/webinterface/docs/step3-frontend-ui.md b/webinterface/docs/step3-frontend-ui.md index 00ff2941..49289403 100644 --- a/webinterface/docs/step3-frontend-ui.md +++ b/webinterface/docs/step3-frontend-ui.md @@ -1,50 +1,77 @@ -# Step 3: sequence input interface (#286) +# Step 3: browser controls (#286, PR #287 review) -## Design +## Design and layout -The interface is a small scientific workspace: two equally weighted strand -editors above one results region. Query uses teal (`#087f83`), target uses blue -(`#315fb8`), with navy text (`#17324d`), pale blue background (`#f1f7fa`), white -working surfaces and rust (`#aa4b1d`) for errors. Trebuchet/Segoe UI system fonts -keep the page self-contained; only nucleotide input and structure data use -monospace. The paired-strand mark is the single visual motif. All working text -is left aligned. A generic dashboard with metric cards was rejected because -sequence comparison, input direction and coordinate interpretation matter here. +The interface follows [vaRRI main-v2](https://github.com/BackofenLab/vaRRI/tree/main-v2): +header indigo `#344a9a`, action blue `#145a96`, dark blue `#0f4676`, pale blue +`#eaf2f8`, white `#ffffff` and focus gold `#f4bb44`. Segoe UI/Tahoma/Geneva serve +labels and body text; Consolas/Liberation Mono serve sequence and structure data. +The official group and university logos share the header with the IntaRNA mark. +Their local copies and source attribution are in `webinterface/assets/`. + +The layout puts the scientific task first, then mutually exclusive parameter +groups, followed by the compute button, results, additional files and versions. +All working content is left aligned; two equally sized strand editors collapse +into one column on mobile. The shared institutional header is the strongest +visual element. Parameter borders and blue summaries identify functional groups. ```text -IntaRNA Documentation / Source -RNA–RNA interaction prediction Runs on this device -[ Query 5′ → 3′ ][ Target 5′ → 3′ ] -[ FASTA editor ][ FASTA editor ] -Maximum results / Load example Compute -Results / search / export -[ sortable interaction coordinates and energy ] +Group logo IntaRNA / RNA–RNA interaction analysis University logo +RNA–RNA interactions Sequences stay on your device +Task: Prediction / Evaluation Load example +[ Sequence input: query + target, start indices, evaluation structures ] +[ Query ] [ Target ] [ Seed ] [ Helix ] [ SHAPE ] [ Interaction/energy ] +[ Output: filters, CSV columns, additional files ] [ General ] +Reset parameters Compute +Interactions / search / show-hide columns / CSV download +Additional output files / diagnostics / compiled versions ``` -## Input and state - -`protocol.js` accepts one FASTA record or one plain sequence per input. Wrapped -lines, lowercase and DNA T are normalized to uppercase RNA U. The only allowed -nucleotides are A/C/G/U/T/N; N does not match a complementary base. Empty names, -multiple/misplaced headers, invalid symbols and lengths outside 7–2,000 nt are -rejected. Query length × target length is limited to 1,000,000 for browser -memory. The result count is an integer from 1 to 100. These limits are also -enforced in the worker and do not change the native CLI. - -The example is a short complementary pair with a seven-base seed. Predictions -use the native defaults (heuristic prediction, Turner energy model, accessibility -included, 37 °C, seed length 7) and allow overlapping suboptimal interactions. -All sequences are entered 5′ to 3′; the CLI handles reverse query accessibility. - -The loading dialog reports initialization versus calculation without inventing -percentages. Abort and Escape terminate the worker and restore the form. Inputs -are disabled during a prediction; the results are cleared when inputs change or -a new prediction begins. Errors and empty output have explicit messages. -Diagnostics are available separately from CSV. Focus returns to Compute after -the dialog closes. There are visible keyboard focus styles, associated labels, -live status/error regions, reduced-motion support and a single-column mobile -layout. Browser tests cover validation, real computation, abort/restart, worker -load failure, responsive overflow and result interactions. - -Serve the staged directory over HTTP; module workers cannot load through -`file://`. No frontend bundler, external font, CDN or API service is needed. +Every CLI group has a menu. `details[name]` plus a toggle fallback keep at most +one parameter menu open. Sequence input starts open. Prediction-only controls +are disabled/hidden during evaluation. Changing task resets parameters to their +CLI defaults while preserving sequence text; Load example also restores the +complementary pair. The native default output count is one; browser users can +request 0–100 interactions per pair. Zero enables accessibility-only output. + +## Inputs, defaults and validation + +`options.js` defines labels, types, defaults and ranges from +`src/bin/CommandLineParsing.cpp`. `protocol.js` validates them on both sides of +the worker boundary. Query/target retain their distinct interior-loop defaults +(16 and 10). Meta-options such as `acc`, `accW`, `accL`, `intLenMax` and +`intLoopMax` are represented by per-strand controls to avoid conflicting values. +FASTA headers supply sequence names instead of `qId`/`tId`. `outCsvSort` selects a native download sort column; table sorting is independent. +CSV uses a fixed semicolon delimiter. +Process settings (threads, file/log paths, parameter files, help/version commands) +are replaced by the worker lifecycle, local data inputs, downloads and displayed +compiled versions. The base-pair maximization energy model is intentionally +excluded. The helix-block interaction model B is supported. + +Prediction accepts plain sequence or up to 20 FASTA records per strand. +Evaluation requires one query and target and 1–100 predefined structures. +Wrapped/lowercase sequence text and DNA T normalize to uppercase RNA U. A/C/G/U/N +are accepted. Each record is 2–2,000 nt; seed predictions additionally require +at least `seedBP` nucleotides. FASTA IDs end at the first whitespace, matching the CLI. Duplicate IDs (including collisions after +native CSV semicolon replacement) are rejected. The total selected all-vs-all +query length × target length, or sum of pairwise products, is capped at 1,000,000. +The per-strand start index applies to all records and defaults to one. Negative +indices skip zero, matching `RnaSequence`; output coordinates remain inclusive. + +Evaluation encodes target then query in 5′ to 3′ dot-bar notation, such as +`1|||&1|||`, separated by colons. Bars pair antiparallel. Validation checks bounds, +equal nonzero pair counts and AU/GC/GU complementarity. Evaluation retains energy, +accessibility and SHAPE controls while excluding prediction/seed/helix/region +and output-filter constraints, matching the native personality. + +Validation also covers numeric ranges, accessibility windows/spans, selected +FASTA subsets, regions, structural constraints, seed/helix compatibility, +probability-output requirements and window restrictions. SHAPE and RNAplfold +accessibility text can be pasted or loaded from a local file (up to 1 MB). Data +are written to fixed MEMFS paths, never used as user-provided CLI arguments. +Native diagnostics remain available for model-specific failures. + +Inputs/results clear on edits; the form disables during execution. Abort and +Escape terminate the worker during startup or C++ execution. Status, errors, +empty output and diagnostic text have dedicated regions. Keyboard focus, +reduced motion and mobile overflow are covered by real browser tests. diff --git a/webinterface/docs/step4-results-and-deployment.md b/webinterface/docs/step4-results-and-deployment.md index 1ba086d1..2c8ffe99 100644 --- a/webinterface/docs/step4-results-and-deployment.md +++ b/webinterface/docs/step4-results-and-deployment.md @@ -2,31 +2,37 @@ ## Results contract -The worker requests exactly `id1,start1,end1,id2,start2,end2,E`. IntaRNA's native -CSV is unquoted and semicolon-delimited; its output handler replaces semicolons -inside sequence identifiers with underscores. The browser parser treats quotes -as literal ID characters, checks the header, validates finite numeric values -and positive inclusive coordinates, and rejects malformed rows. Header control -characters/multirecord input are rejected before the CLI runs. Empty CSV or a -header-only result is a successful prediction with no favorable interactions. - -Sequence 1 is **target** and sequence 2 is **query**. The table deliberately -shows query first, with each strand's original 1-based inclusive coordinates. -Energy uses kcal/mol. ΔE is derived as `E - min(reported E)` rounded to hundredths; -it is not a probability. Suboptimal interactions have distinct site boundaries -and may overlap. Scientific prediction defaults are unchanged. - -Every heading is a keyboard-operable sort button with `aria-sort`. Numerical -columns compare numbers, IDs compare text, and the search matches all columns -without changing the raw prediction. All values are rendered through -`textContent`. CSV export returns the original native output, including all -reported rows regardless of filtering. Input edits clear the old result. +The CSV checklist covers all `OutputHandlerCsv` column IDs. The worker requests +those columns plus internal target/query IDs, coordinates and energy needed for +pair grouping and result validation. Downloads project the untouched native CSV +cells onto the user's selected columns, in selection order, retaining every row +regardless of table filtering/visibility. No computed mfe difference is inserted +into native CSV. The selected-column catalog is checked against the C++ mapping. + +Native output is unquoted and semicolon-delimited. Semicolons in IDs become +underscores; quotes remain literal. The parser checks column IDs, finite core +energy/coordinate values, integer inclusive coordinates and ascending bounds. +Signed coordinates are allowed. Optional `NAN` and colon-separated seed lists +remain strings; numeric sorting puts unavailable/list values after numbers. + +Sequence 1 is **target**, sequence 2 is **query**. Energy uses kcal/mol. The +computed **mfe Δ** is `E - min(reported E for the same query–target pair)`, rounded +to hundredths. Evaluation uses the best supplied structure as its reference. +This is relative suboptimality, not a free-energy change or probability. + +Every heading is a keyboard-operable sort button with `aria-sort`. Search covers +all returned values. A separate checklist shows/hides table columns after the +run; it does not change selected CSV output. All identifiers, output previews +and diagnostics render with `textContent`. Selected extra files appear below the +table with bounded text previews and complete downloads, including SVG source. +Empty output is successful and still permits CSV/accessibility downloads. ## Packaging and deployment ```sh bash scripts/wasm/build.sh node scripts/wasm/smoke.mjs +node scripts/wasm/smoke-protocol.mjs bash scripts/wasm/stage.sh npm test --prefix webinterface python3 -m pip install -r scripts/wasm/requirements-browser.txt @@ -38,7 +44,11 @@ python3 scripts/wasm/test-browser.py http://127.0.0.1:8765 --browser firefox ``` The staging script includes matched WASM assets, only the application source -files needed at runtime, source commit/version metadata and dependency notices. +files needed at runtime, compiled version metadata and dependency notices. Build-time `build-info.json` +and `build-info.txt` record actual CLI IntaRNA/ViennaRNA/Boost versions, source +commit and tracked modifications, SDK version and asset SHA-256 digests. Staging +checks these digests and preserves the original metadata; it never replaces the +compiled revision with a later staging checkout. The footer displays versions. It needs the same activated SDK and dependency build directory as the build. No compiled artifacts are committed. HTTP(S), module workers and WebAssembly are required; `file://` and legacy browsers are unsupported. No backend, @@ -47,8 +57,8 @@ SharedArrayBuffer, credentials, CDN or cross-origin headers are needed. The existing repository already publishes its user guide and API with Pages. Publishing a second independent artifact would overwrite them. Therefore `documentation.yml` is now a reusable build (also manually dispatchable), and -`build-wasm.yml` is the **single publisher**. Both builds run on PRs and on the -actual default branch, `master`. After JS tests, the real WASM smoke test and +`build-wasm.yml` is the **single publisher**. Both builds run on PRs (including dependent feature-branch PRs) and on +the actual default branch, `master`. After JS tests, the real WASM smoke test and Chromium/Firefox integration tests pass, the package job combines: - Existing Jekyll guide at `/IntaRNA/` and API at `/IntaRNA/api/`. @@ -98,3 +108,32 @@ and active C++, clean restart, empty predictions and asset-load recovery. GitHub's platform checks independently cover GCC 14 release/debug and Apple Clang. Deployment itself occurs only after merge; local browser tests do not claim a live Pages deployment. + +### PR #287 review verification, 2026-10-21 + +- Emscripten 4.0.23 rebuild using the pinned official dependency archives passes + with `-Werror=inconsistent-missing-override`. An all-public-header audit using + `-Wsuggest-override -Wsuggest-destructor-override` reports no project override + diagnostics (third-party Easylogging++ is intentionally unchanged). +- `node scripts/wasm/smoke.mjs` retains the original native-reference energy, + coordinates, structured accessibility and C++ error-handling checks. +- `node scripts/wasm/smoke-protocol.mjs` passes real CLI translations for default + prediction, shifted negative coordinates, all-vs-all/pairwise multi-FASTA, + evaluation with all CSV columns, extra reports/probability SVGs, imported + accessibility and non-default energy/seed/helix/SHAPE/overlap settings. + Native imported accessibility reserves one length column for dangling-end + treatment; its energy is compared with the corresponding restricted native + interaction length rather than the unrestricted prediction. +- `npm test --prefix webinterface`: ten protocol/results/lifecycle groups pass. +- Real Chromium/Firefox integration covers all parameter groups, CLI defaults, + compiled versions/logos, task changes, multiple records, signed coordinates, + selected/visible CSV columns, additional previews/downloads and the original + cancellation, empty/error, literal-ID and mobile checks. Desktop screenshot + reviewed; the 390 px viewport has no document overflow. +- Native OpenMP release build with GCC 16.2.0/ViennaRNA 2.7.2: + `make tests -j3` passes all eight groups, including 462,689 assertions across + 119 API cases. Tracked CLI fixtures are linked into the separate build data + directory as in the previous verification; expected results are unchanged. +- Staging preserves compiled metadata byte-for-byte; a deliberately changed + copied JS engine is rejected by its asset digest check. Shell syntax, + workflow YAML, Python syntax and `git diff --check` pass. diff --git a/webinterface/index.html b/webinterface/index.html index b4858cfb..318c101f 100644 --- a/webinterface/index.html +++ b/webinterface/index.html @@ -3,9 +3,9 @@ - + - IntaRNA · RNA–RNA interaction prediction + IntaRNA · RNA–RNA interactions @@ -13,70 +13,60 @@
    -

    RNA–RNA interaction prediction

    -

    Explore how two RNA sequences interact, with accessibility and seed pairing included.

    -
    -

    Sequences stay on your device

    +

    RNA–RNA interactions

    Predict interactions or evaluate the energy of structures you provide.

    +

    Sequences stay on your device

    -
    +
    - Sequences and prediction settings -
    -
    -
    5′ to 3′
    - -

    10 nucleotides

    -
    -
    -
    5′ to 3′
    - -

    10 nucleotides

    -
    -
    -

    One FASTA record or plain sequence per field. A, C, G, U/T and N; 7–2,000 nucleotides each, with query length × target length ≤ 1,000,000.

    -
    -
    - +
    1 record, 10 nucleotides
    + +
    +

    Plain sequence or multi-FASTA (prediction only), A/C/G/U/T/N. Up to 20 records per field and 2–2,000 nt per record; total query × target length across selected pairs ≤ 1,000,000. Seed predictions also require at least as many nucleotides as seed base pairs. Signed start indices apply to every record on that strand; negative indexing skips zero.

    + + + +
    -
    Prediction settings

    37 °C · Turner energy model · accessibility included · 7-base seed · heuristic prediction · overlapping suboptimals

    Interactions

    Enter your sequences or try the example, then compute.

    - + +
    -

    Positions are 1-based and inclusive. Energy is reported in kcal/mol; lower values indicate more favorable interactions.

    IntaRNA runs locally in your browser. For longer sequences and advanced settings, use the command line version.

    Licenses and third-party notices
    +
    - - -

    Computing interactions

    -

    Loading the prediction engine…

    -

    The first run downloads the engine. You can stop at any time.

    - -
    +

    Computing interactions

    Loading the prediction engine…

    The first run downloads the engine. You can stop at any time.

    diff --git a/webinterface/intarna-worker.js b/webinterface/intarna-worker.js index b86d3cf7..d54cb06c 100644 --- a/webinterface/intarna-worker.js +++ b/webinterface/intarna-worker.js @@ -1,6 +1,6 @@ import { prepareRequest, makeArguments } from './protocol.js'; -// One main() call per worker: C++ globals and MEMFS never cross predictions. +// Each task owns a worker; each CLI invocation owns a fresh WASM instance. let started = false; self.onmessage = async ({ data }) => { if (started) return; @@ -11,28 +11,36 @@ self.onmessage = async ({ data }) => { if (diagnostics.length > 25) diagnostics.shift(); }; try { - // Validate again at the worker boundary before allocating the WASM runtime. const request = prepareRequest(data); self.postMessage({ type: 'status', message: 'Loading the prediction engine…' }); const { default: createIntaRNA } = await import('./wasm/intarna.js'); - const module = await createIntaRNA({ - noInitialRun: true, - locateFile: () => new URL('./wasm/intarna.wasm', import.meta.url).href, - print: log, - printErr: log, - }); - module.FS.writeFile('/query.fa', request.query.fasta); - module.FS.writeFile('/target.fa', request.target.fasta); - self.postMessage({ type: 'status', message: 'Predicting RNA interactions…' }); - const code = module.callMain(makeArguments(request.count)); - if (code !== 0) throw new Error(`IntaRNA exited with status ${code}.`); - const csv = module.FS.readFile('/results.csv', { encoding: 'utf8' }); - self.postMessage({ type: 'result', csv, diagnostics: diagnostics.join('\n') }); + const run = async (format) => { + const module = await createIntaRNA({ + noInitialRun: true, + locateFile: () => new URL('./wasm/intarna.wasm', import.meta.url).href, + print: log, printErr: log, + }); + module.FS.writeFile('/query.fa', request.query.fasta); + module.FS.writeFile('/target.fa', request.target.fasta); + module.FS.mkdir('/output'); + for (const [key, value] of Object.entries(request.parameters)) { + if (/^(q|t)(Shape|AccFile)$/.test(key) && value) module.FS.writeFile(`/${key}.txt`, value + '\n'); + } + self.postMessage({ type: 'status', message: request.task === 'evaluation' ? 'Evaluating supplied RNA interactions…' : 'Predicting RNA interactions…' }); + const code = module.callMain(makeArguments(request, format)); + if (code !== 0) throw new Error(`IntaRNA exited with status ${code}. Check the diagnostics and input settings.`); + return module.FS; + }; + const fs = await run('C'); + const csv = fs.readFile('/results.csv', { encoding: 'utf8' }); + const files = fs.readdir('/output').filter((name) => name !== '.' && name !== '..').map((name) => ({ name, text: fs.readFile(`/output/${name}`, { encoding: 'utf8' }) })); + for (const [key, format] of [['normal', 'N'], ['detailed', 'D'], ['ensemble', 'E']]) { + if (!request.outputs.includes(key)) continue; + const reportFS = await run(format); + files.push({ name: `intarna-${key}.txt`, text: reportFS.readFile('/report.txt', { encoding: 'utf8' }) }); + } + self.postMessage({ type: 'result', csv, csvColumns: request.csvColumns, files, diagnostics: diagnostics.join('\n') }); } catch (error) { - self.postMessage({ - type: 'error', - message: error instanceof Error ? error.message : String(error), - diagnostics: diagnostics.join('\n'), - }); + self.postMessage({ type: 'error', message: error instanceof Error ? error.message : String(error), diagnostics: diagnostics.join('\n') }); } }; diff --git a/webinterface/options.js b/webinterface/options.js new file mode 100644 index 00000000..efd310b0 --- /dev/null +++ b/webinterface/options.js @@ -0,0 +1,88 @@ +import { CSV_CATALOG } from './columns.js'; + +// Browser controls mirror CommandLineParsing.cpp; process/file-management options +// are replaced by local text inputs, downloads and a single cancellable worker. +const number = (key, label, value, min, max, extra = {}) => ({ key, label, value, min, max, type: 'number', ...extra }); +const select = (key, label, value, choices, extra = {}) => ({ key, label, value, choices, type: 'select', ...extra }); +const flag = (key, label, extra = {}) => ({ key, label, value: false, type: 'checkbox', ...extra }); +const text = (key, label, extra = {}) => ({ key, label, value: '', type: 'text', ...extra }); +const prediction = { prediction: true }; +const strand = (prefix, label) => [ + text(`${prefix}Set`, 'FASTA record subset', { ...prediction, hint: 'Record numbers, e.g. 1-3,5-5. Empty selects all.' }), + select(`${prefix}Acc`, 'Accessibility', 'C', { C: 'Compute', N: 'Ignore', P: 'Import unpaired probabilities', E: 'Import opening energies' }), + number(`${prefix}AccW`, 'Accessibility window', 150, 0, 99999, { exclude: [1, 2], hint: '0 uses the full sequence; otherwise at least 3 nt.' }), + number(`${prefix}AccL`, 'Maximum base-pair span', 100, 0, 99999, { exclude: [1, 2], hint: '0 uses the accessibility window.' }), + text(`${prefix}AccConstr`, 'Accessibility constraints', { hint: 'One sequence only: ./x/p/b per base, or ranges such as b:3-4,p:6-7.' }), + text(`${prefix}AccFile`, 'Accessibility data', { type: 'textarea', hint: 'Paste RNAplfold text for one sequence when importing P or E.' }), + number(`${prefix}IntLenMax`, 'Maximum interaction length', 0, 0, 99999, { ...prediction, hint: '0 uses the full sequence, bounded by the accessibility window.' }), + number(`${prefix}IntLoopMax`, 'Maximum unpaired bases per interior loop', prefix === 'q' ? 16 : 10, 0, 30, prediction), + text(`${prefix}Region`, 'Interaction regions', { ...prediction, hint: 'Inclusive sequence coordinates, e.g. 1-20,30-50.' }), + number(`${prefix}RegionLenMax`, 'Automatic region length limit', 0, 0, 99999, { ...prediction, hint: '0 disables automatic region detection.' }), + number(`${prefix}PfScale`, `${label} partition-function scale`, 1.07, 1, 99999, { step: 'any' }), +]; +export const GROUPS = [ + { key: 'query-options', label: 'Query', fields: strand('q', 'Query') }, + { key: 'target-options', label: 'Target', fields: strand('t', 'Target') }, + { key: 'seed-options', label: 'Seed', prediction: true, fields: [ + flag('noSeed', 'Do not require a seed', prediction), + text('seedTQ', 'Explicit seeds', { ...prediction, hint: 'Target&query dot-bar encodings, e.g. 1|||&1|||; separate seeds with commas.' }), + number('seedBP', 'Seed base pairs', 7, 2, 20, prediction), + number('seedMaxUP', 'Maximum unpaired seed bases (both strands)', 0, 0, 20, prediction), + number('seedQMaxUP', 'Maximum unpaired query seed bases', -1, -1, 20, { ...prediction, hint: '-1 uses the combined limit.' }), + number('seedTMaxUP', 'Maximum unpaired target seed bases', -1, -1, 20, prediction), + number('seedMaxE', 'Maximum seed energy (kcal/mol)', 0, -999, 999, { ...prediction, step: 'any' }), + number('seedMaxEhybrid', 'Maximum seed hybridization energy (kcal/mol)', 999, -999, 999, { ...prediction, step: 'any' }), + number('seedMinPu', 'Minimum seed unpaired probability', 0, 0, 1, { ...prediction, step: 'any' }), + text('seedQRange', 'Query seed regions', prediction), text('seedTRange', 'Target seed regions', prediction), + flag('seedNoGU', 'Exclude GU pairs in seeds', prediction), flag('seedNoGUend', 'Exclude GU pairs at seed ends', prediction), + ] }, + { key: 'helix-options', label: 'Helix (model B)', prediction: true, fields: [ + number('helixMinBP', 'Minimum helix base pairs', 2, 2, 4, prediction), + number('helixMaxBP', 'Maximum helix base pairs', 10, 2, 20, prediction), + number('helixMaxIL', 'Maximum helix interior-loop size', 0, 0, 2, prediction), + number('helixMinPu', 'Minimum helix unpaired probability', 0, 0, 1, { ...prediction, step: 'any' }), + number('helixMaxE', 'Maximum helix energy (kcal/mol)', 0, -999, 999, { ...prediction, step: 'any' }), + flag('helixFullE', 'Use full helix energy for the energy limit', prediction), + ] }, + { key: 'shape-options', label: 'SHAPE', fields: ['q', 't'].flatMap((p) => [ + text(`${p}Shape`, `${p === 'q' ? 'Query' : 'Target'} SHAPE data`, { type: 'textarea', hint: 'One sequence only. Paste position and reactivity, one pair per line; positions start at 1.' }), + { ...text(`${p}ShapeMethod`, 'SHAPE method'), value: 'D', hint: 'D, Dm1.8b-0.6, Z, Zb0.89 or W.' }, + { ...text(`${p}ShapeConversion`, 'SHAPE conversion'), value: 'Os1.6i-2.29', hint: 'M, C0.25, S, Ls0.68i0.2 or Os1.6i-2.29.' }, + ]) }, + { key: 'interaction-options', label: 'Interaction and energy', fields: [ + select('model', 'Interaction model', 'X', { X: 'Seed extension (MFE)', S: 'Single site (MFE)', P: 'Ensemble energy', B: 'Helix blocks (MFE)' }, prediction), + select('mode', 'Prediction algorithm', 'H', { H: 'Heuristic', M: 'Exact', S: 'Seed only', K: 'Greedy seed extension' }, prediction), + select('kineticScore', 'Greedy extension score', 'A', { A: 'Energy change', B: 'Change / (1 + s1 + s2)', C: 'Change / (1 + 2 max(s1,s2))' }, prediction), + select('energyVRNA', 'ViennaRNA parameter set', 'Turner04', { Turner04: 'Turner 2004', Turner99: 'Turner 1999', Andronescu07: 'Andronescu 2007' }), + number('temperature', 'Temperature (°C)', 37, 0, 100, { step: 'any' }), + number('energyAdd', 'Additional energy (kcal/mol)', 0, -999, 999, { step: 'any' }), + flag('energyNoDangles', 'Exclude dangling-end energy'), flag('accNoLP', 'Exclude lonely pairs in accessibility'), + flag('accNoGUend', 'Exclude GU helix ends in accessibility'), + number('windowWidth', 'Prediction window width', 0, 0, 99999, { ...prediction, hint: '0 disables prediction windows; otherwise at least 10 nt.' }), + number('windowOverlap', 'Prediction window overlap', 150, 0, 99999, prediction), + ] }, + { key: 'output-options', label: 'Output', fields: [ + number('outNumber', 'Maximum interactions per sequence pair', 1, 0, 100, { ...prediction, hint: 'Browser limit: 100; 0 computes accessibility only.' }), + select('outOverlap', 'Allow overlapping interactions', 'B', { B: 'Both strands', N: 'Neither strand', T: 'Target only', Q: 'Query only' }, prediction), + number('outMaxE', 'Maximum interaction energy (exclusive, kcal/mol)', 0, -999, 999, { ...prediction, step: 'any' }), + number('outDeltaE', 'Maximum difference from MFE (kcal/mol)', 100, 0, 100, { ...prediction, step: 'any' }), + number('outMinPu', 'Minimum unpaired probability', 0, 0, 1, { ...prediction, step: 'any' }), + flag('outBestSeedOnly', 'Report only the best seed', prediction), + flag('outNoLP', 'Exclude lonely interaction pairs', prediction), flag('outNoGUend', 'Exclude GU interaction helix ends', prediction), + flag('outPerRegion', 'Report each region combination separately', prediction), flag('outPairwise', 'Match FASTA records pairwise', prediction), + select('outCsvSort', 'CSV download sort column', '', { '': 'Native computation order', ...Object.fromEntries(CSV_CATALOG.map((column) => [column.key, column.label])) }), + text('outSpotProbSpots', 'Interaction probability spots', { ...prediction, hint: 'Optional target&query coordinate pairs, e.g. 3&7,5&9. Empty selects all spots.' }), + ] }, +]; +export const FIELDS = GROUPS.flatMap((group) => group.fields); +export const PARAMETER_DEFAULTS = Object.freeze(Object.fromEntries(FIELDS.map((field) => [field.key, field.value]))); +export const EXTRA_OUTPUTS = [ + ['qMinE', 'Query minimum energies', true], ['tMinE', 'Target minimum energies', true], + ['qAcc', 'Query opening energies'], ['tAcc', 'Target opening energies'], + ['qPu', 'Query unpaired probabilities'], ['tPu', 'Target unpaired probabilities'], + ['qSpotProb', 'Query interaction probabilities', true], ['tSpotProb', 'Target interaction probabilities', true], + ['pMinE', 'Pair minimum energies', true], ['spotProb', 'Interaction spot probabilities', true], + ['bpProb', 'Actual base-pair probabilities (model P)', true], ['bpsvg', 'Base-pair probability SVG (model P)', true], + ['normal', 'ASCII interaction structures'], ['detailed', 'Detailed interaction report'], ['ensemble', 'Ensemble report'], +].map(([key, label, predictionOnly = false]) => ({ key, label, prediction: predictionOnly })); +export const ENSEMBLE_COLUMNS = ['Eall', 'Zall', 'EallTotal', 'P_E']; diff --git a/webinterface/protocol.js b/webinterface/protocol.js index 7ed9da91..cfc9451e 100644 --- a/webinterface/protocol.js +++ b/webinterface/protocol.js @@ -1,55 +1,232 @@ -// Browser limits bound memory use; the native CLI remains unrestricted. -export const LIMITS = Object.freeze({ minLength: 7, maxLength: 2000, maxPairs: 1000000 }); -export const CSV_COLUMNS = 'id1,start1,end1,id2,start2,end2,E'; -export const DEFAULTS = Object.freeze({ - query: '>query\nGGGAGGAGGG', - target: '>target\nCCCUCCUCCC', - count: 10, -}); +import { FIELDS, PARAMETER_DEFAULTS, EXTRA_OUTPUTS, ENSEMBLE_COLUMNS } from './options.js'; +import { CSV_CATALOG } from './columns.js'; -export function parseSequence(text, label) { - if (typeof text !== 'string' || text.length > 10000) { - throw new Error(`${label}: enter one sequence of at most ${LIMITS.maxLength} nucleotides.`); - } - const lines = text.trim().split(/\r?\n/).map((line) => line.trim()); - let id = label.toLowerCase(); - if (lines[0].startsWith('>')) { - id = lines.shift().slice(1).trim(); - if (!id || id.length > 200 || /[\x00-\x1f\x7f]/.test(id)) { - throw new Error(`${label}: the FASTA header needs a name (at most 200 characters).`); +// Bound total work as well as per-strand length, including all-vs-all FASTA jobs. +export const LIMITS = Object.freeze({ minLength: 2, maxLength: 2000, maxRecords: 20, maxPairs: 1000000 }); +export const CSV_COLUMNS = 'id1,start1,end1,id2,start2,end2,subseqDP,hybridDP,E'; +export const CONTEXT_COLUMNS = ['id1', 'start1', 'end1', 'id2', 'start2', 'end2', 'E']; +export const DEFAULTS = Object.freeze({ query: '>query\nGGGAGGAGGG', target: '>target\nCCCUCCUCCC', task: 'prediction' }); +export const EVALUATION_EXAMPLE = '1||||||||||&1||||||||||'; + +export function parseFasta(text, label, multiple = true) { + if (typeof text !== 'string' || text.length > 60000 || !text.trim()) throw new Error(`${label}: enter a sequence or FASTA records.`); + const lines = text.trim().split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const records = []; + let current; + for (const line of lines) { + if (line.startsWith('>')) { + current = { id: line.slice(1).trim(), sequence: '' }; + if (!current.id || current.id.length > 200 || /[\x00-\x1f\x7f]/.test(current.id)) throw new Error(`${label}: the FASTA header needs a name (at most 200 characters).`); + // The native FASTA parser takes the identifier before the first space. + current.id = current.id.split(/\s+/)[0]; + records.push(current); + } else { + if (!current) { current = { id: label.toLowerCase(), sequence: '' }; records.push(current); } + current.sequence += line.replace(/\s/g, '').toUpperCase().replaceAll('T', 'U'); } } - if (lines.some((line) => line.includes('>'))) { - throw new Error(`${label}: enter exactly one FASTA record, with its header on the first line.`); + if ((!multiple && records.length !== 1) || records.length > LIMITS.maxRecords) throw new Error(`${label}: ${multiple ? 'use at most 20 FASTA records' : 'evaluation requires exactly one FASTA record'}.`); + const ids = new Set(); + for (const record of records) { + if (!record.sequence || /[^ACGUN]/.test(record.sequence)) throw new Error(`${label}: use only A, C, G, U, T or N in every sequence.`); + if (record.sequence.length < LIMITS.minLength || record.sequence.length > LIMITS.maxLength) throw new Error(`${label}: use 2–2,000 nucleotides per record.`); + // Native CSV replaces semicolons with underscores. Keep pair grouping unique. + const id = record.id.replaceAll(';', '_'); + if (ids.has(id)) throw new Error(`${label}: FASTA identifiers must be unique (also after replacing semicolons with underscores).`); + ids.add(id); } - const sequence = lines.join('').replace(/\s/g, '').toUpperCase(); - if (!sequence || /[^ACGUTN]/.test(sequence)) { - throw new Error(`${label}: use only A, C, G, U, T or N in the sequence.`); + return { records, fasta: records.map(({ id, sequence }) => `>${id}\n${sequence}\n`).join('') }; +} +export function parseSequence(text, label) { + const parsed = parseFasta(text, label, false); + return { ...parsed.records[0], fasta: parsed.fasta }; +} + +// IntaRNA omits coordinate zero when an index range crosses from negative to positive. +export function sequenceOffset(index, start) { + return index - start - (start < 0 && index > 0 ? 1 : 0); +} +function ranges(value, key, length, start = 1) { + if (!value) return []; + return value.split(',').map((part) => { + const match = /^(-?[1-9]\d*)-(-?[1-9]\d*)$/.exec(part); + if (!match) throw new Error(`${key}: use inclusive ranges such as 1-5,8-10 (coordinate zero is skipped).`); + const from = sequenceOffset(Number(match[1]), start), to = sequenceOffset(Number(match[2]), start); + if (from < 0 || to < from || to >= length) throw new Error(`${key}: ranges must be ascending and within the selected sequence.`); + return [from, to]; + }); +} +function selectedRecords(parsed, subset, key) { + const intervals = ranges(subset, key, parsed.records.length); + return intervals.length ? parsed.records.filter((_, index) => intervals.some(([a, b]) => a <= index && index <= b)) : parsed.records; +} +function oneRecord(records, key) { + if (records.length !== 1) throw new Error(`${key}: this setting requires exactly one selected sequence.`); + return records[0]; +} +function validateConstraints(value, records, start, key) { + const record = oneRecord(records, key); + if (/^[.xpb]+$/.test(value)) { + if (value.length !== record.sequence.length) throw new Error(`${key}: provide one constraint character per nucleotide.`); + return; } - if (sequence.length < LIMITS.minLength || sequence.length > LIMITS.maxLength) { - throw new Error(`${label}: use ${LIMITS.minLength}–${LIMITS.maxLength} nucleotides (the default seed needs 7).`); + const sections = value.match(/[xpb]:[^xpb]+/g); + if (!sections || sections.join('') !== value) throw new Error(`${key}: use ./x/p/b per base or ranges such as b:3-4,p:6-7.`); + for (const section of sections) ranges(section.slice(2).replace(/,$/, ''), key, record.sequence.length, start); +} +function validateAccessibility(value, record, mode, key) { + const lines = value.split(/\r?\n/); + if (!/^#[\w\s]+$/.test(lines[0]) || !/^\s*#i.\s+l=1(\s+\d+)*\s*$/.test(lines[1] ?? '')) throw new Error(`${key}: paste RNAplfold text with its comment and length header lines.`); + const lengths = lines[1].match(/\d+/g).map(Number); + if (lengths.length < 2 || lengths.some((n, i) => n !== i + 1)) throw new Error(`${key}: accessibility lengths must be consecutive, starting at 1, with at least two columns.`); + const data = lines.slice(2).filter((line) => line.trim()); + if (data.length < record.sequence.length) throw new Error(`${key}: provide one data row per nucleotide.`); + for (let index = 0; index < record.sequence.length; index++) { + const cells = data[index].trim().split(/\s+/); + const values = cells.slice(1, 1 + Math.min(index + 1, lengths.length)); + if (Number(cells[0]) !== index + 1 || values.length !== Math.min(index + 1, lengths.length) || values.some((v) => !Number.isFinite(Number(v)) || Number(v) < 0 || mode === 'P' && Number(v) > 1)) throw new Error(`${key}: rows must be consecutive; required values must be finite ${mode === 'P' ? 'probabilities in [0,1]' : 'opening energies ≥ 0'}.`); + } +} +function complementary(a, b) { return ['AU', 'UA', 'GC', 'CG', 'GU', 'UG'].includes(a + b); } +export function validateInteractions(encoding, query, target, qStart, tStart, separator = ':') { + if (typeof encoding !== 'string' || !encoding || encoding.length > 10000 || encoding.split(separator).length > 100) throw new Error('Provide 1–100 target&query dot-bar interactions.'); + for (const entry of encoding.split(separator)) { + const strands = entry.split('&'); + if (strands.length !== 2) throw new Error('Each interaction needs target&query dot-bar encodings.'); + const positions = strands.map((strand, i) => { + const match = /^(-?[1-9]\d*)([.|]+)$/.exec(strand); + if (!match) throw new Error('Use a nonzero start index followed by dots and pairing bars, e.g. 1|||&1|||.'); + if (separator === ',' && (!match[2].startsWith('|') || !match[2].endsWith('|'))) throw new Error('Explicit seed strands must start and end with a pairing bar.'); + const record = i ? query : target, start = i ? qStart : tStart; + const offset = sequenceOffset(Number(match[1]), start); + if (offset < 0 || offset + match[2].length > record.sequence.length) throw new Error('A dot-bar interaction extends outside its sequence.'); + return [...match[2]].flatMap((c, p) => c === '|' ? [offset + p] : []); + }); + if (!positions[0].length || positions[0].length !== positions[1].length) throw new Error('Interaction strands must contain the same nonzero number of pairing bars.'); + for (let p = 0; p < positions[0].length; p++) { + if (!complementary(target.sequence[positions[0][p]], query.sequence[positions[1].at(-1 - p)])) throw new Error('The supplied interaction contains a non-complementary base pair.'); + } } - const rna = sequence.replace(/T/g, 'U'); - return { id, sequence: rna, fasta: `>${id}\n${rna}\n` }; } export function prepareRequest(request) { - const query = parseSequence(request?.query, 'Query'); - const target = parseSequence(request?.target, 'Target'); - if (query.sequence.length * target.sequence.length > LIMITS.maxPairs) { - throw new Error('For this browser version, query length × target length must be at most 1,000,000. Use shorter sequences or the command line.'); + const task = request?.task ?? 'prediction'; + if (!['prediction', 'evaluation'].includes(task)) throw new Error('Choose prediction or evaluation.'); + const evaluating = task === 'evaluation'; + const query = parseFasta(request.query, 'Query', !evaluating), target = parseFasta(request.target, 'Target', !evaluating); + const parameters = {}; + const supplied = request.parameters ?? {}; + for (const field of FIELDS) { + if (evaluating && field.prediction) continue; + const value = supplied[field.key] ?? field.value; + if (field.type === 'checkbox') { + if (typeof value !== 'boolean') throw new Error(`${field.label}: choose on or off.`); + parameters[field.key] = value; + } else if (field.type === 'number') { + const n = typeof value === 'number' ? value : (typeof value === 'string' && value.trim() ? Number(value) : NaN); + if (!Number.isFinite(n) || n < field.min || n > field.max || (field.step !== 'any' && !Number.isInteger(n)) || field.exclude?.includes(n)) throw new Error(`${field.label}: use ${field.min}–${field.max}${field.exclude ? ', excluding 1 and 2' : ''}${field.step !== 'any' ? ' (whole numbers)' : ''}.`); + parameters[field.key] = n; + } else { + if (typeof value !== 'string' || value.length > (field.type === 'textarea' ? 1000000 : 10000) || /\x00/.test(value)) throw new Error(`${field.label}: invalid or oversized text input.`); + if (field.choices && !Object.hasOwn(field.choices, value)) throw new Error(`${field.label}: choose an available value.`); + parameters[field.key] = value.trim(); + } } - const count = Number(request.count); - if (!Number.isInteger(count) || count < 1 || count > 100) { - throw new Error('Choose between 1 and 100 interactions.'); + for (const key of ['qIdxPos0', 'tIdxPos0']) { + const raw = request[key] ?? 1; + const n = typeof raw === 'number' || typeof raw === 'string' && raw.trim() ? Number(raw) : NaN; + if (!Number.isInteger(n) || n < -2000000000 || n > 2000000000) throw new Error(`${key}: use an integer between -2,000,000,000 and 2,000,000,000.`); + parameters[key] = n; } - return { query, target, count }; + const qRecords = selectedRecords(query, parameters.qSet, 'qSet'), tRecords = selectedRecords(target, parameters.tSet, 'tSet'); + if (parameters.outPairwise && qRecords.length !== tRecords.length) throw new Error('Pairwise output requires the same number of selected query and target records.'); + const work = parameters.outPairwise ? qRecords.reduce((sum, q, i) => sum + q.sequence.length * tRecords[i].sequence.length, 0) : qRecords.reduce((sum, q) => sum + q.sequence.length, 0) * tRecords.reduce((sum, t) => sum + t.sequence.length, 0); + if (work > LIMITS.maxPairs) throw new Error('The total query × target length across selected sequence pairs must be at most 1,000,000.'); + + // Validate sequence-dependent settings before loading the engine. + for (const [p, records] of [['q', qRecords], ['t', tRecords]]) { + const start = parameters[`${p}IdxPos0`]; + if (parameters[`${p}AccW`] && parameters[`${p}AccL`] > parameters[`${p}AccW`]) throw new Error(`${p}AccL must not exceed ${p}AccW.`); + if (parameters[`${p}RegionLenMax`] && (parameters[`${p}Acc`] === 'N' || parameters[`${p}Region`])) throw new Error(`${p}RegionLenMax: automatic regions require accessibility and cannot be combined with manual regions.`); + for (const record of records) ranges(parameters[`${p}Region`], `${p}Region`, record.sequence.length, start); + const seedRange = parameters[`seed${p.toUpperCase()}Range`]; + if (seedRange) ranges(seedRange, `seed${p.toUpperCase()}Range`, oneRecord(records, 'Seed regions').sequence.length, start); + if (parameters[`${p}AccConstr`]) validateConstraints(parameters[`${p}AccConstr`], records, start, `${p}AccConstr`); + if (['P', 'E'].includes(parameters[`${p}Acc`])) { + const record = oneRecord(records, 'Accessibility import'); + if (!parameters[`${p}AccFile`]) throw new Error(`${p}Acc: paste accessibility data for the selected import mode.`); + validateAccessibility(parameters[`${p}AccFile`], record, parameters[`${p}Acc`], `${p}AccFile`); + } + if (parameters[`${p}Shape`]) { + const record = oneRecord(records, 'SHAPE'); + for (const line of parameters[`${p}Shape`].split(/\r?\n/).filter((line) => line.trim() && !line.trim().startsWith('#'))) { + const cells = line.trim().split(/\s+/); + if (cells.length !== 2 || !/^\d+$/.test(cells[0]) || Number(cells[0]) < 1 || Number(cells[0]) > record.sequence.length || !Number.isFinite(Number(cells[1]))) throw new Error(`${p}Shape: use position (1-based) and finite reactivity on each line.`); + } + } + if (!/^(D(m-?\d+(\.\d+)?)?(b-?\d+(\.\d+)?)?|Z(b\d+(\.\d+)?)?|W)$/.test(parameters[`${p}ShapeMethod`])) throw new Error(`${p}ShapeMethod: invalid SHAPE method encoding.`); + if (!/^(M|C(\d+(\.\d+)?)?|S|[LO](s-?\d+(\.\d+)?i-?\d+(\.\d+)?)?)$/.test(parameters[`${p}ShapeConversion`])) throw new Error(`${p}ShapeConversion: invalid SHAPE conversion encoding.`); + } + const csvColumns = request.csvColumns ?? CSV_COLUMNS.split(','); + if (!Array.isArray(csvColumns) || !csvColumns.length || new Set(csvColumns).size !== csvColumns.length || csvColumns.some((key) => !CSV_CATALOG.some((c) => c.key === key))) throw new Error('Select at least one available CSV column, without duplicates.'); + if (parameters.outCsvSort && !csvColumns.includes(parameters.outCsvSort)) throw new Error('The CSV sort column must be selected for output.'); + const outputs = request.outputs ?? []; + if (!Array.isArray(outputs) || new Set(outputs).size !== outputs.length || outputs.some((key) => !EXTRA_OUTPUTS.some((entry) => entry.key === key && (!evaluating || !entry.prediction)))) throw new Error('Choose available additional output formats for this task.'); + if (outputs.includes('ensemble') && (qRecords.length !== 1 || tRecords.length !== 1)) throw new Error('Ensemble reports require one query and one target.'); + const needsEnsemble = csvColumns.some((c) => ENSEMBLE_COLUMNS.includes(c)) || outputs.some((c) => ['ensemble', 'spotProb', 'qSpotProb', 'tSpotProb'].includes(c)); + if (evaluating) { + validateInteractions(request.rri, query.records[0], target.records[0], parameters.qIdxPos0, parameters.tIdxPos0); + } else { + if (parameters.model === 'B' && parameters.mode !== 'H') throw new Error('Helix-block model B supports heuristic mode only.'); + if (parameters.noSeed && ['S', 'K'].includes(parameters.mode)) throw new Error('Seed-only and greedy prediction require a seed.'); + if (parameters.mode === 'K' && (parameters.model !== 'X' || needsEnsemble)) throw new Error('Greedy prediction requires model X and cannot produce ensemble/probability output.'); + if (parameters.seedTQ) validateInteractions(parameters.seedTQ, oneRecord(qRecords, 'Explicit seeds'), oneRecord(tRecords, 'Explicit seeds'), parameters.qIdxPos0, parameters.tIdxPos0, ','); + const minimumSeedLength = parameters.seedTQ ? Math.min(...parameters.seedTQ.split(',').map((seed) => seed.split('&')[0].split('|').length - 1)) : parameters.seedBP; + if (!parameters.noSeed && ['q', 't'].some((p) => parameters[`${p}IntLenMax`] && parameters[`${p}IntLenMax`] < minimumSeedLength)) throw new Error('Maximum interaction lengths must cover the required seed base pairs.'); + if (!parameters.noSeed && !parameters.seedTQ && [...qRecords, ...tRecords].some((r) => r.sequence.length < parameters.seedBP)) throw new Error('Every selected sequence must be at least as long as the seed; reduce Seed base pairs or disable the seed.'); + if (parameters.model === 'B' && (parameters.helixMinBP > parameters.helixMaxBP || !parameters.noSeed && minimumSeedLength > parameters.helixMaxBP)) throw new Error('Helix maximum must cover the helix minimum and required seed base pairs.'); + if (outputs.some((key) => ['bpProb', 'bpsvg'].includes(key)) && (parameters.model !== 'P' || !['H', 'M'].includes(parameters.mode) || parameters.windowWidth || parameters.mode === 'M' && !parameters.noSeed && (parameters.seedTQ || parameters.seedMaxUP || parameters.seedQMaxUP > 0 || parameters.seedTMaxUP > 0))) throw new Error('Base-pair probabilities require model P, heuristic/exact mode and no windows; exact mode supports no seed or stack-only seeds.'); + if (parameters.windowWidth) { + const maxLength = Math.max(...['q', 't'].map((p) => parameters[`${p}Acc`] === 'N' ? parameters[`${p}IntLenMax`] : Math.max(parameters[`${p}AccW`], parameters[`${p}IntLenMax`]))); + if (parameters.windowWidth < 10 || parameters.windowOverlap >= parameters.windowWidth || parameters.windowOverlap < maxLength || ['q', 't'].some((p) => !parameters[`${p}IntLenMax`] && (parameters[`${p}Acc`] === 'N' || !parameters[`${p}AccW`])) || parameters.outNumber > 1 && parameters.outOverlap !== 'B' || needsEnsemble || parameters.model === 'P') throw new Error('Prediction windows require width ≥ 10, overlap below width and at least the bounded interaction lengths, overlapping output, and no ensemble statistics/model P.'); + } + // Non-overlap constraints in native merged regions require a single region on + // the relevant strand; automatic region detection is only safe per region. + if (!parameters.outPerRegion && parameters.outOverlap !== 'B') { + for (const p of ['q', 't']) { + if ((parameters.outOverlap === 'N' || parameters.outOverlap === (p === 'q' ? 'T' : 'Q')) && (parameters[`${p}Region`].includes(',') || parameters[`${p}RegionLenMax`])) throw new Error('Non-overlapping output with multiple/automatic regions requires output per region.'); + } + } + if (parameters.outSpotProbSpots && !/^(-?[1-9]\d*&-?[1-9]\d*)(,-?[1-9]\d*&-?[1-9]\d*)*$/.test(parameters.outSpotProbSpots)) throw new Error('Probability spots must be target&query coordinate pairs, e.g. 3&7,5&9.'); + for (const spot of parameters.outSpotProbSpots ? parameters.outSpotProbSpots.split(',') : []) { + const [t, q] = spot.split('&').map(Number); + for (const [records, coordinate, start] of [[tRecords, t, parameters.tIdxPos0], [qRecords, q, parameters.qIdxPos0]]) { + const offset = sequenceOffset(coordinate, start); + if (records.some((record) => offset < 0 || offset >= record.sequence.length)) throw new Error('Probability spots must fall within every selected sequence.'); + } + } + } + return { task, query, target, parameters, csvColumns, outputs, rri: evaluating ? request.rri : '' }; } -export function makeArguments(count) { - return [ - '--query=/query.fa', '--target=/target.fa', '--outMode=C', - `--outNumber=${count}`, '--outOverlap=B', - `--outCsvCols=${CSV_COLUMNS}`, '--out=/results.csv', - ]; +export function makeArguments(request, format = 'C') { + const args = ['--query=/query.fa', '--target=/target.fa', `--outMode=${format}`, `--out=${format === 'C' ? '/results.csv' : '/report.txt'}`]; + if (request.task === 'evaluation') args.push('--personality=IntaRNAeval'); + for (const [key, value] of Object.entries(request.parameters)) { + if (key === 'outSpotProbSpots' || key === 'kineticScore' && request.parameters.mode !== 'K') continue; + // Omit defaults: the native parser distinguishes explicit values in several + // cross-option checks, especially accessibility/region constraints. + if (value === '' || value === PARAMETER_DEFAULTS[key]) continue; + const fileInput = /^(q|t)(Shape|AccFile)$/.test(key); + args.push(`--${key}=${fileInput ? `/${key}.txt` : value}`); + } + if (request.rri) args.push(`--rri=${request.rri}`); + if (format === 'C') { + args.push(`--outCsvCols=${[...new Set([...request.csvColumns, ...CONTEXT_COLUMNS])].join(',')}`); + for (const key of request.outputs.filter((key) => !['normal', 'detailed', 'ensemble'].includes(key))) { + const spots = key === 'spotProb' && request.parameters.outSpotProbSpots ? `${request.parameters.outSpotProbSpots}:` : ''; + args.push(`--out=${key}:${spots}/output/${key}${key === 'bpsvg' ? '.svg' : '.csv'}`); + } + } + return args; } diff --git a/webinterface/results.js b/webinterface/results.js index 84410344..1c4b5d41 100644 --- a/webinterface/results.js +++ b/webinterface/results.js @@ -1,125 +1,121 @@ -import { CSV_COLUMNS } from './protocol.js'; +import { CSV_CATALOG } from './columns.js'; +import { CONTEXT_COLUMNS } from './protocol.js'; -export const COLUMNS = Object.freeze([ - { key: 'id2', label: 'Query', numeric: false }, - { key: 'start2', label: 'Query start', numeric: true }, - { key: 'end2', label: 'Query end', numeric: true }, - { key: 'id1', label: 'Target', numeric: false }, - { key: 'start1', label: 'Target start', numeric: true }, - { key: 'end1', label: 'Target end', numeric: true }, - { key: 'E', label: 'Energy (kcal/mol)', numeric: true }, - { key: 'deltaE', label: 'ΔE (kcal/mol)', numeric: true }, -]); +export const COLUMNS = Object.freeze([...CSV_CATALOG, { key: 'deltaE', label: 'mfe Δ (kcal/mol)', numeric: true }]); +const catalog = new Map(COLUMNS.map((column) => [column.key, column])); -// IntaRNA emits unquoted semicolon records and replaces delimiters in IDs. -// Header control characters are rejected before prediction; quotes are literal. +// Native CSV is unquoted, semicolon-separated; unavailable optional fields use +// NAN and seed fields may contain colon-separated lists. Preserve those strings. export function parseResults(csv) { - if (typeof csv !== 'string') throw new Error('The prediction did not return CSV text.'); - const lines = csv.trim().split(/\r?\n/); + if (typeof csv !== 'string') throw new Error('The engine did not return CSV text.'); if (!csv.trim()) return []; - if (lines.shift() !== CSV_COLUMNS.replaceAll(',', ';')) { - throw new Error('Unexpected IntaRNA CSV columns. Rebuild and serve the matching web interface and engine.'); - } - const keys = CSV_COLUMNS.split(','); + const lines = csv.trimEnd().split(/\r?\n/), keys = lines.shift().split(';'); + if (new Set(keys).size !== keys.length || keys.some((key) => !catalog.has(key)) || CONTEXT_COLUMNS.some((key) => !keys.includes(key))) throw new Error('Unexpected IntaRNA CSV columns. Serve the matching interface and engine.'); const rows = lines.filter((line) => line.trim()).map((line) => { const values = line.split(';'); if (values.length !== keys.length) throw new Error('Malformed IntaRNA CSV row.'); const row = Object.fromEntries(keys.map((key, index) => [key, values[index]])); - for (const key of ['start1', 'end1', 'start2', 'end2', 'E']) { - if (!row[key].trim() || !Number.isFinite(Number(row[key]))) throw new Error(`Invalid ${key} in prediction output.`); + for (const key of CONTEXT_COLUMNS.filter((key) => key !== 'id1' && key !== 'id2')) { + if (!row[key].trim() || !Number.isFinite(Number(row[key]))) throw new Error(`Invalid ${key} in interaction output.`); row[key] = Number(row[key]); } for (const key of ['start1', 'end1', 'start2', 'end2']) { - if (!Number.isInteger(row[key]) || row[key] < 1) throw new Error('Invalid interaction coordinates.'); + if (!Number.isInteger(row[key])) throw new Error('Invalid interaction coordinates.'); } if (row.start1 > row.end1 || row.start2 > row.end2) throw new Error('Reversed interaction coordinates.'); return row; }); - const best = Math.min(...rows.map((row) => row.E)); - return rows.map((row) => ({ ...row, deltaE: Number((row.E - best).toFixed(2)) })); + // Each sequence pair has its own optimum in a multi-FASTA screen. + const best = new Map(); + for (const row of rows) { + const pair = JSON.stringify([row.id1, row.id2]); + best.set(pair, Math.min(best.get(pair) ?? Infinity, row.E)); + } + return rows.map((row) => ({ ...row, deltaE: Number((row.E - best.get(JSON.stringify([row.id1, row.id2]))).toFixed(2)) })); } - +function numeric(value) { return String(value).trim() && Number.isFinite(Number(value)) ? Number(value) : null; } export function selectRows(rows, search, key, direction) { - const query = search.toLowerCase().trim(); - const column = COLUMNS.find((entry) => entry.key === key); - return rows.filter((row) => COLUMNS.some((entry) => String(row[entry.key]).toLowerCase().includes(query))) - .toSorted((a, b) => direction * (column.numeric ? a[key] - b[key] : a[key].localeCompare(b[key]))); + const query = search.toLowerCase().trim(), column = catalog.get(key); + return rows.filter((row) => Object.values(row).some((value) => String(value).toLowerCase().includes(query))).toSorted((a, b) => { + if (column?.numeric) { + const x = numeric(a[key]), y = numeric(b[key]); + if (x !== null && y !== null) return direction * (x - y); + if (x === null && y !== null) return 1; + if (x !== null && y === null) return -1; + } + return direction * String(a[key] ?? '').localeCompare(String(b[key] ?? '')); + }); +} +export function projectCsv(csv, columns) { + const lines = csv.trimEnd().split(/\r?\n/), keys = lines[0].split(';'); + const indices = columns.map((key) => keys.indexOf(key)); + if (indices.some((index) => index < 0)) throw new Error('Selected columns are absent from the engine output.'); + return lines.map((line) => { const cells = line.split(';'); return indices.map((index) => cells[index]).join(';'); }).join('\n') + '\n'; +} +export function downloadText(name, text) { + const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })); + const link = document.createElement('a'); + link.href = url; link.download = name; link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); } - export class ResultsView { constructor(root) { - this.root = root; - this.rows = []; - this.key = 'E'; - this.direction = 1; + this.root = root; this.rows = []; this.key = 'E'; this.direction = 1; this.search = root.querySelector('#search'); this.table = root.querySelector('table'); this.body = this.table.querySelector('tbody'); + this.header = this.table.querySelector('thead tr'); this.count = root.querySelector('#visible-count'); this.empty = root.querySelector('#no-matches'); + this.picker = root.querySelector('#visible-columns'); this.search.addEventListener('input', () => this.render()); - const header = this.table.querySelector('thead tr'); - for (const column of COLUMNS) { - const cell = document.createElement('th'); - cell.scope = 'col'; - const button = document.createElement('button'); - button.type = 'button'; - button.textContent = column.label; - button.addEventListener('click', () => { - this.direction = this.key === column.key ? -this.direction : 1; - this.key = column.key; - this.render(); - }); - cell.append(button); - header.append(cell); - } - root.querySelector('#download').addEventListener('click', () => { - const url = URL.createObjectURL(new Blob([this.csv], { type: 'text/csv;charset=utf-8' })); - const link = document.createElement('a'); - link.href = url; - link.download = 'intarna-interactions.csv'; - link.click(); - setTimeout(() => URL.revokeObjectURL(url), 1000); - }); + root.querySelector('#download').addEventListener('click', () => downloadText('intarna-interactions.csv', this.csv)); } - clear() { - this.rows = []; - this.csv = ''; - this.body.replaceChildren(); - this.root.hidden = true; + this.rows = []; this.csv = ''; this.body.replaceChildren(); this.root.hidden = true; } - - show(csv) { - this.rows = parseResults(csv); - this.csv = csv; - this.search.value = ''; - this.key = 'E'; - this.direction = 1; - this.root.hidden = this.rows.length === 0; - this.render(); - return this.rows.length; + show(csv, columns = csv.trim().split('\n')[0].split(';')) { + this.rows = parseResults(csv); this.csv = projectCsv(csv, columns); + this.columns = columns.map((key) => catalog.get(key)); + if (columns.includes('E')) this.columns.push(catalog.get('deltaE')); + this.visible = new Set(this.columns.map((c) => c.key)); + this.picker.replaceChildren(); + for (const column of this.columns) { + const label = document.createElement('label'), input = document.createElement('input'); + input.type = 'checkbox'; input.checked = true; input.value = column.key; + input.addEventListener('change', () => { + if (input.checked) this.visible.add(column.key); else this.visible.delete(column.key); + this.render(); + }); + label.append(input, document.createTextNode(column.label)); this.picker.append(label); + } + this.search.value = ''; this.key = columns.includes('E') ? 'E' : columns[0]; this.direction = 1; + this.root.hidden = false; this.render(); return this.rows.length; } - render() { - const visible = selectRows(this.rows, this.search.value, this.key, this.direction); + const rows = selectRows(this.rows, this.search.value, this.key, this.direction); + const columns = this.columns.filter((column) => this.visible.has(column.key)); + this.header.replaceChildren(); + for (const column of columns) { + const cell = document.createElement('th'), button = document.createElement('button'); + cell.scope = 'col'; button.type = 'button'; button.textContent = column.label; + const active = column.key === this.key, direction = this.direction === 1 ? 'ascending' : 'descending'; + cell.setAttribute('aria-sort', active ? direction : 'none'); + button.setAttribute('aria-label', `Sort by ${column.label}${active ? `, currently ${direction}` : ''}`); + button.addEventListener('click', () => { this.direction = active ? -this.direction : 1; this.key = column.key; this.render(); }); + cell.append(button); this.header.append(cell); + } this.body.replaceChildren(); - for (const row of visible) { + for (const row of rows) { const tr = document.createElement('tr'); - for (const column of COLUMNS) { + for (const column of columns) { const cell = document.createElement('td'); - cell.textContent = ['E', 'deltaE'].includes(column.key) ? row[column.key].toFixed(2) : String(row[column.key]); - if (column.numeric) cell.className = 'number'; - tr.append(cell); + cell.textContent = ['E', 'deltaE'].includes(column.key) ? Number(row[column.key]).toFixed(2) : String(row[column.key]); + if (column.numeric) cell.className = 'number'; tr.append(cell); } this.body.append(tr); } - this.table.querySelectorAll('th').forEach((cell, index) => { - const active = COLUMNS[index].key === this.key; - cell.setAttribute('aria-sort', active ? (this.direction === 1 ? 'ascending' : 'descending') : 'none'); - cell.firstChild.setAttribute('aria-label', `Sort by ${COLUMNS[index].label}${active ? `, currently ${this.direction === 1 ? 'ascending' : 'descending'}` : ''}`); - }); - this.count.textContent = `${visible.length} of ${this.rows.length} interactions`; - this.empty.hidden = visible.length > 0; + this.count.textContent = `${rows.length} of ${this.rows.length} interactions`; + this.empty.hidden = rows.length > 0; } } diff --git a/webinterface/styles.css b/webinterface/styles.css index c17f56fd..30e7a8a9 100644 --- a/webinterface/styles.css +++ b/webinterface/styles.css @@ -1,83 +1,620 @@ -:root { color-scheme: light; --ink: #17324d; --muted: #49647a; --teal: #087f83; --blue: #315fb8; --line: #cad9e2; --paper: #fff; --background: #f1f7fa; --error: #aa4b1d; font: 16px/1.55 "Segoe UI", Arial, sans-serif; color: var(--ink); background: var(--background); } -* { box-sizing: border-box; } -body { margin: 0; } -a { color: var(--blue); text-underline-offset: .2em; } -button, input, textarea { font: inherit; } -button { cursor: pointer; } -button:disabled { cursor: wait; opacity: .6; } -:focus-visible { outline: 3px solid #bf6b20; outline-offset: 4px; } -[hidden] { display: none !important; } -.site-header, main { width: min(1180px, calc(100% - 64px)); margin-inline: auto; } -.site-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 28px 0; border-bottom: 1px solid var(--line); } -.brand { display: flex; align-items: center; gap: 12px; font: bold 28px/1.1 "Trebuchet MS", "Segoe UI", sans-serif; color: var(--ink); text-decoration: none; } -.brand span { font: 14px/1.3 "Segoe UI", Arial, sans-serif; color: var(--muted); border-left: 1px solid var(--line); padding-left: 16px; margin-left: 4px; max-width: 100px; } -nav { display: flex; gap: 24px; font-size: 14px; } -.introduction { display: flex; align-items: center; justify-content: space-between; gap: 32px; padding: 48px 0 30px; } -h1, h2 { font-family: "Trebuchet MS", "Segoe UI", sans-serif; line-height: 1.18; } -h1 { font-size: clamp(28px, 3.2vw, 40px); letter-spacing: -.035em; margin: 0 0 12px; } -h2 { font-size: 25px; margin: 0; } -p { margin: 0 0 12px; max-width: 80ch; } -.introduction p { color: var(--muted); margin-bottom: 0; } -.introduction .local-note { font-size: 13px; white-space: nowrap; } -.local-note span { color: var(--teal); margin-right: 5px; } -fieldset { padding: 0; border: 0; margin: 0; min-width: 0; } -#prediction-form { background: var(--paper); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; } -.sequence-grid { display: grid; grid-template-columns: 1fr 1fr; } -.strand { min-width: 0; padding: 24px 28px 18px; border-top: 4px solid var(--teal); } -.target-strand { border-top-color: var(--blue); border-left: 1px solid var(--line); } -.strand-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } -.strand-heading label { font-size: 19px; font-weight: 650; color: var(--teal); } -.target-strand label { color: var(--blue); } -.strand-heading span, .sequence-meta { color: var(--muted); font-size: 13px; } -textarea { display: block; width: 100%; height: 150px; min-height: 100px; resize: vertical; background: #f8fbfd; border: 1px solid var(--line); border-radius: 6px; padding: 14px; color: var(--ink); font: 15px/1.8 "Cascadia Mono", Consolas, monospace; overflow-wrap: anywhere; } -.sequence-meta { margin: 8px 0 0; } -.input-help { font-size: 13px; color: var(--muted); padding: 0 28px 20px; margin: 0; max-width: none; } -.compute-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 20px; padding: 20px 28px; background: #f5fafc; border-top: 1px solid var(--line); } -.form-options { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; font-size: 14px; } -input[type="number"] { width: 72px; padding: 6px 8px; margin-left: 8px; color: var(--ink); background: white; border: 1px solid var(--line); border-radius: 4px; } -.primary, .secondary { border-radius: 6px; padding: 11px 20px; font-weight: 600; border: 1px solid transparent; } -.primary { background: var(--teal); color: white; } -.primary:hover { background: #076a6e; } -.secondary { background: white; border-color: #9bb1c1; color: var(--ink); } -.secondary:hover { background: #eaf2f8; } -.error { color: var(--error); background: #fff4ec; border-left: 4px solid var(--error); padding: 16px 24px; margin: 0; max-width: none; } -.model-note { display: flex; flex-wrap: wrap; gap: 4px 16px; padding: 20px 4px; color: var(--muted); font-size: 13px; } -.model-note strong { color: var(--ink); font-weight: 600; } -.model-note p { margin: 0; max-width: none; } -.results { margin-top: 26px; min-height: 140px; } -.results-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 24px; align-items: baseline; margin-bottom: 22px; } -.results-heading p { color: var(--muted); font-size: 14px; margin: 0; } -pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; } -details { margin: 20px 0; } -summary { cursor: pointer; color: var(--muted); } -footer { padding: 30px 0 40px; border-top: 1px solid var(--line); font-size: 13px; color: var(--muted); } -footer p { margin-bottom: 10px; } -dialog { border: 1px solid var(--line); border-radius: 14px; color: var(--ink); padding: 36px; max-width: min(460px, calc(100% - 32px)); box-shadow: 0 20px 70px #17324d26; } -dialog::backdrop { background: #17324d66; } -dialog h2 { margin: 20px 0 14px; } -dialog p { color: var(--muted); } -dialog button { margin-top: 14px; } -.spinner { width: 32px; height: 32px; border: 3px solid #d1e7e9; border-top-color: var(--teal); border-radius: 50%; animation: spin 1s linear infinite; } -@keyframes spin { to { transform: rotate(360deg); } } -.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } -.skip-link { position: absolute; top: -100px; left: 20px; background: white; padding: 8px; z-index: 2; } -.skip-link:focus { top: 10px; } -@media (max-width: 800px) { .introduction { display: block; padding-top: 32px; } .introduction .local-note { margin-top: 14px; } .brand span { display: none; } } -@media (max-width: 600px) { .site-header, main { width: calc(100% - 32px); } .site-header { gap: 12px; padding: 20px 0; } nav { gap: 12px; font-size: 12px; } .brand { font-size: 24px; gap: 8px; } .brand img { width: 36px; height: 36px; } .sequence-grid { grid-template-columns: 1fr; } .target-strand { border-left: 0; } .strand { padding: 18px; } .input-help { padding: 0 18px 18px; } .compute-bar { padding: 18px; } .compute-bar .primary { width: 100%; } .results-heading { display: block; } .results-heading p { margin-top: 12px; } } -@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } - -.table-tools { display: flex; align-items: center; flex-wrap: wrap; gap: 16px 24px; padding-bottom: 18px; font-size: 14px; } -.table-tools input { display: block; margin-top: 6px; border: 1px solid #9bb1c1; border-radius: 5px; padding: 8px 12px; width: 260px; max-width: 100%; color: var(--ink); background: white; } -#visible-count { color: var(--muted); margin-right: auto; } -.table-scroll { width: 100%; overflow-x: auto; border: 1px solid var(--line); border-radius: 8px; background: white; } -table { width: 100%; border-collapse: collapse; text-align: left; font-size: 14px; font-variant-numeric: tabular-nums; } -th { background: #e8f1f6; padding: 0; white-space: nowrap; } -th button { display: block; width: 100%; padding: 16px 14px; border: 0; color: var(--ink); background: transparent; text-align: left; font-size: 13px; font-weight: 650; } -th button:hover { background: #dce9f1; } -th[aria-sort="ascending"] button::after { content: " ▴"; } -th[aria-sort="descending"] button::after { content: " ▾"; } -td { border-top: 1px solid #e0e9ee; padding: 12px 14px; max-width: 260px; overflow-wrap: anywhere; } -td.number { text-align: right; } -tr:hover td { background: #f4fafb; } -td:nth-last-child(2) { color: #075e62; font-weight: 650; } -.table-note, #no-matches { color: var(--muted); font-size: 13px; margin: 14px 0 20px; } +/* Shared vaRRI main-v2 palette and institutional header. Keep all assets local. */ +:root { + color-scheme: light; + --brand-blue:#145a96; + --brand-blue-dark:#0f4676; + --brand-blue-soft:#eaf2f8; + --brand-accent:#f4bb44; + --surface:#fff; + --surface-muted:#f3f7fb; + --text:#1c2b39; + --muted:#4f6478; + --border:#ccd9e5; + font-family:"Segoe UI",Tahoma,Geneva,sans-serif; + color:var(--text); + background:#f3f7fb; + font-size:16px; + line-height:1.5; +} + +* { + box-sizing:border-box; +} + +body { + margin:0; + background:linear-gradient(180deg,#f8fbff,#eef3fb 45%,#fff); + min-height:100vh; +} + +[hidden] { + display:none !important; +} + +a { + color:var(--brand-blue); + text-underline-offset:3px; +} + +button,input,select,textarea { + font:inherit; +} + +button,summary { + cursor:pointer; +} + +button { + border:1px solid var(--border); + border-radius:5px; + padding:.6rem 1rem; + font-weight:600; +} + +button:disabled { + opacity:.6; + cursor:wait; +} + +button.primary { + color:white; + background:var(--brand-blue); + border-color:var(--brand-blue); +} + +button.primary:hover { + background:var(--brand-blue-dark); +} + +button.secondary { + color:var(--brand-blue-dark); + background:var(--surface); +} + +button.secondary:hover { + background:var(--brand-blue-soft); +} + +:focus-visible { + outline:3px solid var(--brand-accent); + outline-offset:3px; +} + +input,select,textarea { + border:1px solid #9bafc2; + border-radius:4px; + color:var(--text); + background:var(--surface); + padding:.45rem .55rem; + min-width:0; + max-width:100%; +} + +input[type=checkbox] { + accent-color:var(--brand-blue); + width:1rem; + height:1rem; + margin:.2rem .4rem .2rem 0; + flex-shrink:0; +} + +textarea { + display:block; + width:100%; + resize:vertical; + min-height:7rem; + font-family:Consolas,"Liberation Mono",monospace; + line-height:1.65; +} + +label { + font-weight:600; +} + +small,.input-help,.table-note,.field-hint { + font-size:.85rem; + color:var(--muted); +} + +p { + max-width:80ch; +} + +h1,h2,h3,p { + margin:0 0 .75rem; +} + +h1 { + font-size:1.8rem; + line-height:1.2; +} + +h2 { + font-size:1.35rem; +} + +h3 { + font-size:1.1rem; +} + +.site-header { + min-height:100px; + padding:18px 28px; + display:flex; + align-items:center; + gap:24px; + background:#344a9a; + color:#f7fbff; +} + +.site-header a { + color:inherit; + text-decoration:none; +} + +.group-logo { + display:block; + width:auto; + height:60px; +} + +.uni-logo { + display:block; + width:auto; + height:52px; +} + +.brand { + display:flex; + align-items:center; + gap:12px; + font-weight:700; + font-size:1.6rem; + line-height:1.3; +} + +.brand small { + display:block; + font-size:.85rem; + font-weight:400; + color:#d9e8f6; +} + +.university-link { + margin-left:auto; +} + +main { + width:min(1300px,100%); + margin:auto; + padding:30px 20px; +} + +.introduction { + display:flex; + justify-content:space-between; + gap:24px; + align-items:start; + margin-bottom:20px; +} + +.introduction p { + color:var(--muted); +} + +.local-note { + flex-shrink:0; + font-size:.85rem; + padding-top:.3rem; +} + +fieldset { + border:0; + margin:0; + padding:0; + min-width:0; +} + +.task-bar,.compute-bar,.table-tools { + display:flex; + align-items:center; + flex-wrap:wrap; + justify-content:space-between; + gap:12px; +} + +.task-bar { + margin-bottom:8px; +} + +.task-bar label { + display:flex; + align-items:center; + gap:12px; +} + +#task-help { + font-size:.9rem; + color:var(--muted); +} + +.parameter-group { + border:1px solid var(--border); + background:#fbfdff; + border-radius:8px; + margin-bottom:8px; +} + +.parameter-group > summary { + padding:.75rem 1rem; + color:var(--brand-blue-dark); + font-weight:700; + user-select:none; +} + +.parameter-group[open] > summary { + background:var(--brand-blue-soft); + border-bottom:1px solid var(--border); + border-radius:7px 7px 0 0; +} + +.group-body { + padding:18px; +} + +.sequence-grid,.field-grid { + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + gap:18px 24px; +} + +.strand-heading,.sequence-meta { + display:flex; + align-items:center; + justify-content:space-between; + gap:8px; + margin-bottom:8px; +} + +.strand-heading span { + color:var(--muted); + font-size:.85rem; +} + +.sequence-meta { + margin-top:8px; + font-size:.85rem; + flex-wrap:wrap; + color:var(--muted); +} + +.sequence-meta input { + width:8rem; +} + +.sequence-meta label { + font-weight:400; +} + +.input-help { + margin:12px 0 0; +} + +#evaluation-input { + margin-top:20px; +} + +.field { + display:flex; + flex-direction:column; + align-items:stretch; + gap:5px; + min-width:0; +} + +.field.check { + flex-direction:row; + align-items:center; + align-self:start; + gap:4px; +} + +.field label { + font-size:.9rem; +} + +.field-hint { + margin:0; +} + +.field input[type=file] { + font-size:.8rem; + padding:.2rem; + border:0; + background:transparent; +} + +.field:has(textarea) { + grid-column:1/-1; +} + +.field:has(:disabled) { + opacity:.65; +} + +.compute-bar { + margin:20px 0; +} + +.checkbox-grid { + display:grid; + grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); + gap:8px 16px; + padding:12px 0; +} + +.checkbox-grid label { + display:flex; + align-items:flex-start; + font-weight:400; + font-size:.85rem; + overflow-wrap:anywhere; +} + +.subheading { + margin-top:24px; +} + +.selection-tools { + display:flex; + gap:8px; + flex-wrap:wrap; +} + +.selection-tools button { + font-size:.8rem; + padding:.3rem .6rem; +} + +.results { + background:var(--surface); + border:1px solid var(--border); + border-radius:8px; + margin-top:24px; + padding:22px; + min-width:0; +} + +#result-status { + color:var(--muted); +} + +.table-tools { + margin:18px 0 12px; +} + +.table-tools label { + display:flex; + gap:8px; + align-items:center; + font-size:.9rem; +} + +#visible-count { + font-size:.85rem; + color:var(--muted); +} + +.column-picker { + margin-bottom:12px; + font-size:.9rem; +} + +.table-scroll { + width:100%; + overflow:auto; + border:1px solid var(--border); +} + +table { + border-collapse:collapse; + width:100%; + white-space:nowrap; + font-size:.85rem; +} + +th { + background:var(--brand-blue-soft); + text-align:left; +} + +th button { + background:transparent; + border:0; + padding:12px; + color:var(--brand-blue-dark); + width:100%; + text-align:left; +} + +th[aria-sort=ascending] button::after { + content:' ↑'; +} + +th[aria-sort=descending] button::after { + content:' ↓'; +} + +td { + border-top:1px solid #e0e8f0; + padding:10px 12px; +} + +tr:nth-child(even) { + background:#f8fbff; +} + +td.number { + text-align:right; + font-variant-numeric:tabular-nums; +} + +.table-note { + margin:12px 0; +} + +#output-files { + margin-top:24px; +} + +#file-list details,#diagnostics { + border-top:1px solid var(--border); + margin-top:12px; + padding-top:12px; +} + +pre { + white-space:pre; + overflow:auto; + max-height:24rem; + font-size:.8rem; + background:var(--surface-muted); + padding:12px; +} + +#diagnostics pre { + white-space:pre-wrap; + overflow-wrap:anywhere; +} + +footer { + padding:24px 0; + font-size:.85rem; + color:var(--muted); +} + +footer p { + margin-bottom:8px; +} + +.error { + color:#932d28; + border:1px solid #d8a8a5; + background:#fff1f0; + border-radius:5px; + padding:12px; + max-width:none; +} + +dialog { + border:1px solid var(--border); + border-radius:10px; + max-width:min(450px,calc(100% - 32px)); + padding:28px; + color:var(--text); +} + +dialog::backdrop { + background:rgba(20,45,75,.4); +} + +.spinner { + width:28px; + height:28px; + border:3px solid var(--border); + border-top-color:var(--brand-blue); + border-radius:50%; + animation:spin 1s linear infinite; + margin-bottom:14px; +} + +@keyframes spin { + to { + transform:rotate(360deg); + } +} + +.sr-only { + position:absolute; + width:1px; + height:1px; + padding:0; + margin:-1px; + overflow:hidden; + clip:rect(0,0,0,0); + white-space:nowrap; + border:0; +} + +.skip-link { + position:absolute; + left:12px; + top:-100px; + background:white; + padding:10px; + z-index:10; +} + +.skip-link:focus { + top:10px; +} + +@media(max-width:760px) { + .site-header { + padding:14px; + gap:14px; + flex-wrap:wrap; + } + .group-logo { + height:40px; + } + .uni-logo { + height:36px; + } + .brand { + font-size:1.2rem; + } + .brand img { + width:32px; + height:32px; + } + .brand small { + display:none; + } + main { + padding:22px 12px; + } + .introduction { + display:block; + } + .local-note { + margin-top:12px; + } + .sequence-grid,.field-grid { + grid-template-columns:1fr; + } + .results { + padding:14px; + } + .table-tools label { + flex-wrap:wrap; + } + .table-tools input { + width:100%; + } + .task-bar label { + flex-wrap:wrap; + } + h1 { + font-size:1.55rem; + } + .group-body { + padding:14px; + } +} + +@media(prefers-reduced-motion:reduce) { + .spinner { + animation:none; + } +} diff --git a/webinterface/tests/protocol.test.mjs b/webinterface/tests/protocol.test.mjs new file mode 100644 index 00000000..a6608d7d --- /dev/null +++ b/webinterface/tests/protocol.test.mjs @@ -0,0 +1,71 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { DEFAULTS, EVALUATION_EXAMPLE, parseSequence, parseFasta, prepareRequest, makeArguments, CSV_COLUMNS, sequenceOffset } from '../protocol.js'; +import { FIELDS } from '../options.js'; +import { CSV_CATALOG } from '../columns.js'; +const request = (parameters = {}, extra = {}) => ({ ...DEFAULTS, parameters, ...extra }); + +test('FASTA normalization, identifiers, bounded multi-record work and subsets', () => { + assert.equal(parseSequence('>test\r\nacgt n\r\nACG', 'Query').fasta, '>test\nACGUNACG\n'); + for (const input of ['', 'A', '>\nACGUACGU', '>one\n', 'ACGU-ACGU', '>a\nACGU\n>a\nACGU', '>a;b\nACGU\n>a_b\nACGU', '>a first\nACGU\n>a second\nACGU']) assert.throws(() => parseFasta(input, 'Query')); + const fasta = '>a\nGGGAGGAGGG\n>b\nGGGAGGAGGG'; + assert.equal(prepareRequest(request({}, { query: fasta })).query.records.length, 2); + assert.throws(() => prepareRequest(request({}, { task: 'evaluation', query: fasta, rri: EVALUATION_EXAMPLE })), /exactly one/); + assert.throws(() => prepareRequest(request({}, { query: '>a\n'+'A'.repeat(2000)+'\n>b\n'+'A'.repeat(2000), target: 'C'.repeat(300) })), /1,000,000/); + assert.doesNotThrow(() => prepareRequest(request({ qSet: '2-2' }, { query: '>a\n'+'A'.repeat(2000)+'\n>b\n'+'A'.repeat(2000), target: 'C'.repeat(300) }))); + assert.throws(() => prepareRequest(request({ outPairwise: true }, { query: fasta })), /same number/); + assert.throws(() => prepareRequest(request({ qSet: '3-3' })), /within/); + assert.throws(() => prepareRequest(request({}, { query: 'A'.repeat(2001) }))); +}); + +test('CLI defaults, ranges and strict typed controls reject invalid values', () => { + const prepared = prepareRequest(DEFAULTS); + assert.equal(prepared.parameters.outNumber, 1); + assert.equal(prepared.parameters.qIntLoopMax, 16); + assert.equal(prepared.parameters.tIntLoopMax, 10); + assert.equal(prepared.csvColumns.join(','), CSV_COLUMNS); + assert.ok(!makeArguments(prepared).some((arg) => arg.startsWith('--personality='))); + assert.ok(!makeArguments(prepared).some((arg) => arg.startsWith('--kineticScore='))); + for (const parameters of [{ outNumber: 101 }, { outNumber: 1.5 }, { temperature: '' }, { temperature: Infinity }, { qAccW: 2 }, { qAccW: 50 }, { model: '--help' }, { noSeed: 'false' }, { seedBP: 11 }]) assert.throws(() => prepareRequest(request(parameters))); + for (const qIdxPos0 of ['--help', NaN, 2e9+1, '', false]) assert.throws(() => prepareRequest(request({}, { qIdxPos0 }))); + assert.doesNotThrow(() => prepareRequest(request({}, { qIdxPos0: -2e9 }))); + assert.doesNotThrow(() => prepareRequest(request({}, { qIdxPos0: 0 }))); + assert.equal(sequenceOffset(1, -3), 3); + assert.equal(sequenceOffset(-1, -3), 2); + assert.throws(() => prepareRequest(request({}, { csvColumns: [] })), /at least one/); + assert.throws(() => prepareRequest(request({}, { outputs: ['../other'] })), /available/); +}); + +test('evaluation validates antiparallel supplied structures in signed coordinates', () => { + const evalRequest = request({ seedBP: 'ignored', model: 'ignored' }, { task: 'evaluation', rri: EVALUATION_EXAMPLE }); + const prepared = prepareRequest(evalRequest); + assert.equal(prepared.parameters.model, undefined); + assert.ok(makeArguments(prepared).includes('--personality=IntaRNAeval')); + assert.ok(makeArguments(prepared).includes(`--rri=${EVALUATION_EXAMPLE}`)); + for (const rri of ['', '1|||&1||', '1|||||||||||&1|||||||||||', '0|||&1|||', '1|||&1|||:', '1|||&1...']) assert.throws(() => prepareRequest({ ...evalRequest, rri })); + assert.doesNotThrow(() => prepareRequest({ ...evalRequest, qIdxPos0: -3, tIdxPos0: -5, rri: '-5||||||||||&-3||||||||||' })); + assert.throws(() => prepareRequest({ ...evalRequest, outputs: ['bpsvg'] }), /available/); +}); + +test('cross-option constraints, regions, probabilities, SHAPE and local file arguments', () => { + for (const parameters of [{ noSeed: true, mode: 'S' }, { mode: 'K', model: 'P' }, { model: 'B', mode: 'M' }, { model: 'B', helixMaxBP: 4 }, { qRegion: '8-2' }, { qAccConstr: 'b:1-20' }, { qAccConstr: 'bbb' }, { qShapeMethod: 'X' }, { tShapeConversion: 'bad' }, { qShape: '1 nope' }, { qAcc: 'P' }, { qAcc: 'P', qAccFile: 'bad' }, { outSpotProbSpots: '20&1' }, { windowWidth: 100 }, { qIntLenMax: 3 }, { qAcc: 'N', qRegionLenMax: 20 }, { qRegion: '1-10', qRegionLenMax: 20 }, { seedTQ: '1.|||||||||&1|||||||||.' }]) assert.throws(() => prepareRequest(request(parameters))); + assert.doesNotThrow(() => prepareRequest(request({ noSeed: true, qAccConstr: 'b:1-2,p:8-9', qRegion: '1-10' }))); + assert.throws(() => prepareRequest(request({ mode: 'K' }, { csvColumns: ['Eall'] })), /ensemble/); + assert.throws(() => prepareRequest(request({}, { outputs: ['bpsvg'] })), /model P/); + assert.throws(() => prepareRequest(request({ outCsvSort: 'ED1' })), /sort column/); + const prepared = prepareRequest(request({ model: 'P', mode: 'M', qShape: '1 0.2\n2 -999', outSpotProbSpots: '1&2' }, { outputs: ['bpProb', 'bpsvg', 'spotProb'] })); + const args = makeArguments(prepared); + assert.ok(args.includes('--qShape=/qShape.txt')); + assert.ok(args.includes('--out=spotProb:1&2:/output/spotProb.csv')); + assert.ok(args.includes('--out=bpsvg:/output/bpsvg.svg')); + assert.ok(!makeArguments(prepared, 'D').some((arg) => arg.startsWith('--outCsvCols'))); +}); + +test('browser catalog covers every native CSV column and every control names a CLI option', () => { + const header = readFileSync(new URL('../../src/IntaRNA/OutputHandlerCsv.h', import.meta.url), 'utf8'); + const ids = [...header.matchAll(/colType2string\[\w+\] = "(\w+)"/g)].map((match) => match[1]); + assert.deepEqual(CSV_CATALOG.map((c) => c.key).toSorted(), ids.toSorted()); + const source = readFileSync(new URL('../../src/bin/CommandLineParsing.cpp', import.meta.url), 'utf8'); + for (const field of FIELDS.filter((field) => field.key !== 'outSpotProbSpots')) assert.ok(source.includes(`"${field.key}"`), field.key); +}); diff --git a/webinterface/tests/results.test.mjs b/webinterface/tests/results.test.mjs index 090fe521..5ea65311 100644 --- a/webinterface/tests/results.test.mjs +++ b/webinterface/tests/results.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { parseResults, selectRows } from '../results.js'; +import { parseResults, selectRows, projectCsv } from '../results.js'; const header = 'id1;start1;end1;id2;start2;end2;E\n'; test('native CSV orientation, numeric coordinates, energy and relative suboptimality', () => { @@ -19,7 +19,21 @@ test('empty output and literal native ID quotes; malformed output fails clearly' assert.deepEqual(parseResults(''), []); assert.deepEqual(parseResults(header), []); assert.equal(parseResults(header + '"name";1;7;query;1;7;-3\n')[0].id1, '"name"'); - for (const csv of ['unexpected\n', header + 'too;few\n', header + 't;0;1;q;1;2;-3\n', header + 't;2;1;q;1;2;-3\n', header + 't;1;7;q;1;7;NAN\n', header + 't;1;7;q;1;7;\n']) { + for (const csv of ['unexpected\n', header + 'too;few\n', header + 't;0.5;1;q;1;2;-3\n', header + 't;2;1;q;1;2;-3\n', header + 't;1;7;q;1;7;NAN\n', header + 't;1;7;q;1;7;\n']) { assert.throws(() => parseResults(csv)); } }); + + +test('mfe difference is per sequence pair; signed coordinates and optional NAN/list fields survive', () => { + const csv = header.trim() + ';seedE;ED1\n' + + 't;-3;5;q;1;8;-20;NAN;0\n' + + 't;-3;4;q;1;7;-18;-3:-2;1.4\n' + + 't2;1;8;q;1;8;-5;NAN;0\n'; + const rows = parseResults(csv); + assert.deepEqual(rows.map((r) => r.deltaE), [0, 2, 0]); + assert.equal(rows[1].seedE, '-3:-2'); + assert.equal(rows[0].seedE, 'NAN'); + assert.equal(projectCsv(csv, ['id2','E']), 'id2;E\nq;-20\nq;-18\nq;-5\n'); + assert.throws(() => projectCsv(csv, ['absent'])); +}); diff --git a/webinterface/tests/worker.test.mjs b/webinterface/tests/worker.test.mjs index 689f59ed..e831c28c 100644 --- a/webinterface/tests/worker.test.mjs +++ b/webinterface/tests/worker.test.mjs @@ -1,19 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { PredictionRunner } from '../runner.js'; -import { DEFAULTS, parseSequence, prepareRequest, makeArguments } from '../protocol.js'; - -test('FASTA normalization and boundary validation', () => { - assert.equal(parseSequence('>test\r\nacgt n\r\nACG', 'Query').fasta, '>test\nACGUNACG\n'); - for (const input of ['', 'ACGU', '>\nACGUACGU', '>one\nACGUACGU\n>two\nACGUACGU', 'ACGU-ACGU', 'ACGU\n>bad']) { - assert.throws(() => parseSequence(input, 'Query')); - } - assert.equal(prepareRequest(DEFAULTS).query.sequence, 'GGGAGGAGGG'); - assert.throws(() => prepareRequest({ ...DEFAULTS, query: 'A'.repeat(2001) })); - assert.throws(() => prepareRequest({ ...DEFAULTS, query: 'A'.repeat(1001), target: 'C'.repeat(1000) })); - for (const count of [0, 101, 1.5, NaN, '--help']) assert.throws(() => prepareRequest({ ...DEFAULTS, count })); - assert.ok(makeArguments(10).includes('--out=/results.csv')); -}); +import { DEFAULTS } from '../protocol.js'; function fakeWorker() { return { terminated: false, terminate() { this.terminated = true; }, postMessage() {}, send(data) { this.onmessage({ data }); } }; From 44c2691ad0d79662bfbabc3ff1bebb13fdb99acd Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Thu, 22 Oct 2026 12:03:08 +0200 Subject: [PATCH 10/31] feat(web): add vaRRI visualization and reproducible ZIP exports --- ChangeLog | 25 ++++ README.md | 1 + scripts/wasm/smoke-protocol.mjs | 27 +++- scripts/wasm/stage.sh | 2 +- scripts/wasm/test-browser.py | 96 +++++++++++++-- webinterface/app.js | 72 ++++++++--- webinterface/archive.js | 81 ++++++++++++ .../docs/step2-worker-architecture.md | 7 +- webinterface/docs/step3-frontend-ui.md | 27 ++-- .../docs/step4-results-and-deployment.md | 80 +++++++++++- webinterface/index.html | 20 +-- webinterface/protocol.js | 2 +- webinterface/results.js | 26 +++- webinterface/styles.css | 115 ++++++++++++++++-- webinterface/tests/export.test.mjs | 38 ++++++ webinterface/visualization.js | 48 ++++++++ 16 files changed, 604 insertions(+), 63 deletions(-) create mode 100644 webinterface/archive.js create mode 100644 webinterface/tests/export.test.mjs create mode 100644 webinterface/visualization.js diff --git a/ChangeLog b/ChangeLog index 9a9be5b4..63803d54 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,10 @@ ## Interface and handling +- add selected-interaction vaRRI embeds and reproducible all-output ZIP downloads + to the browser interface; compact parameters into accessible tabs and update + the Freiburg group header and full-width result/footer notes (PR #287 review) + - extend the browser interface with vaRRI styling, prediction/evaluation tasks, multi-FASTA, start indices, CLI parameter groups, selectable CSV columns and additional output previews/downloads; label pair-relative differences mfe Δ @@ -241,6 +245,27 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261022 Alexander Mitrofanov + * webinterface/{archive,visualization}.js + + Build local UTF-8 ZIPs with normalized FASTA, compiled version provenance, + portable parameter files, selected CSV, auxiliary inputs and all outputs; + include separate replay configurations for native text report modes. + + Construct selected-row vaRRI URLs from full target/query sequences, native + dot-bracket output, names and signed indices, preserving strand direction. + * webinterface/{app,results,protocol}.js, index.html, styles.css + + Add row selection, embedded vaRRI/full-viewer link and Download ZIP action; + preserve completed-run inputs and clear exports/viewer when inputs change. + * Replace folded groups with keyboard-accessible tabs and compact parameter + grids; link Bioinformatics - University of Freiburg to the group website + and remove artificial width limits from table/footer notes. + * scripts/wasm/{stage.sh,smoke-protocol.mjs,test-browser.py} + * Stage the new modules; verify byte-identical replay through fresh engines + and browser tab/selection/ZIP behavior in Chromium and Firefox. + * webinterface/tests/export.test.mjs, README.md, webinterface/docs/* + + Cover coordinate/identity mapping, effective settings and archive inputs; + document replay commands, external visualization and browser limitations + (https://github.com/BackofenLab/IntaRNA/pull/287#issuecomment-6321503136). + 261021 Alexander Mitrofanov * src/IntaRNA/*.h * Mark 194 inherited virtual methods and 45 derived destructors override; diff --git a/README.md b/README.md index 1200e7fb..3fd6a84d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ seeding of interaction sites** Predict short RNA pairs or evaluate supplied structures locally with the [browser interface](https://backofenlab.github.io/IntaRNA/webinterface/). +Select an interaction to view it in vaRRI, or download a ZIP with inputs, compiled versions, reproducible parameters and all outputs. See the [WebAssembly build instructions](webinterface/docs/step1-wasm-compilation.md) and [interface/deployment notes](webinterface/docs/step4-results-and-deployment.md). diff --git a/scripts/wasm/smoke-protocol.mjs b/scripts/wasm/smoke-protocol.mjs index d3947aef..f168a7b1 100644 --- a/scripts/wasm/smoke-protocol.mjs +++ b/scripts/wasm/smoke-protocol.mjs @@ -4,7 +4,8 @@ import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { DEFAULTS, EVALUATION_EXAMPLE, prepareRequest, makeArguments } from '../../webinterface/protocol.js'; import { CSV_CATALOG } from '../../webinterface/columns.js'; -import { parseResults } from '../../webinterface/results.js'; +import { parseResults, projectCsv } from '../../webinterface/results.js'; +import { archiveFiles } from '../../webinterface/archive.js'; const asset = resolve(process.argv[2] || 'build-wasm/site/wasm/intarna.js'); const { default: createIntaRNA } = await import(pathToFileURL(asset)); async function run(input, format = 'C') { @@ -18,7 +19,7 @@ async function run(input, format = 'C') { const args = makeArguments(request, format); assert.equal(module.callMain(args), 0, `${args.join(' ')}\n${diagnostics.join('\n')}`); const text = module.FS.readFile(format === 'C' ? '/results.csv' : '/report.txt', { encoding: 'utf8' }); - return { rows: format === 'C' ? parseResults(text) : [], text, fs: module.FS }; + return { rows: format === 'C' ? parseResults(text) : [], text, fs: module.FS, request }; } const defaults = await run({ parameters: { outNumber: 10 } }); assert.equal(defaults.rows.length, 10); @@ -45,3 +46,25 @@ const imported = await run({ parameters: { qAcc: 'P', qAccFile: accessibility.fs assert.equal(imported.rows[0].E, (await run({ parameters: { qIntLenMax: 9 } })).rows[0].E); for (const parameters of [{ energyVRNA: 'Turner99', temperature: 20 }, { energyVRNA: 'Andronescu07' }, { model: 'B' }, { mode: 'K' }, { noSeed: true, mode: 'M' }, { seedMaxUP: 1 }, { qAccConstr: '..........', qShape: '1 0.2\n2 0.3' }, { outOverlap: 'N', outNumber: 3 }, { outCsvSort: 'E', outNumber: 3 }]) assert.ok((await run({ parameters })).rows.length >= 1); console.log('Browser protocol: native-reference prediction/evaluation, signed coordinates, multi-FASTA, all columns, imported accessibility, energy/seed/helix/SHAPE options and output files passed'); + +// Execute exported parameter files in fresh engines with only archived inputs. +// Byte comparisons cover selected CSV, per-record extra files and report modes. +async function replayArchive(input) { + const original = await run(input), { request, fs } = original; + const outputs = fs.readdir('/output').filter((name) => name !== '.' && name !== '..').map((name) => ({ name, text: fs.readFile(`/output/${name}`, { encoding: 'utf8' }) })); + const reports = [['normal', 'N'], ['detailed', 'D'], ['ensemble', 'E']].filter(([key]) => request.outputs.includes(key)); + for (const [key, format] of reports) outputs.push({ name: `intarna-${key}.txt`, text: (await run(input, format)).text }); + const files = archiveFiles(request, { csv: original.text, files: outputs }, 'IntaRNA 3.4.1\nViennaRNA 2.7.0\n'); + for (const config of ['parameters', ...reports.map(([key]) => key)]) { + const diagnostics = []; + const module = await createIntaRNA({ noInitialRun: true, locateFile: () => new URL('./intarna.wasm', pathToFileURL(asset)).href, print: (line) => diagnostics.push(line), printErr: (line) => diagnostics.push(line) }); + for (const file of files.filter((file) => /\.cfg$|^(query|target)\.fa$|^[qt](Shape|AccFile)\.txt$/.test(file.name))) module.FS.writeFile(`/${file.name}`, file.text); + assert.equal(module.callMain([`--parameterFile=${config}.cfg`]), 0, diagnostics.join('\n')); + const expected = config === 'parameters' ? [{ name: 'intarna-interactions.csv', text: projectCsv(original.text, request.csvColumns) }, ...outputs.filter((file) => !file.name.startsWith('intarna-'))] : outputs.filter((file) => file.name === `intarna-${config}.txt`); + for (const file of expected) assert.equal(module.FS.readFile(`/${file.name}`, { encoding: 'utf8' }), file.text, file.name); + } +} +await replayArchive({ query: '>q1\nGGGAGGAGGG\n>q2\nGGGAGGAGGG', qIdxPos0: -3, tIdxPos0: -5, parameters: { temperature: 20, outNumber: 3, noSeed: true }, outputs: ['qMinE', 'tPu', 'normal', 'detailed'], csvColumns: ['id2', 'E'] }); +await replayArchive({ task: 'evaluation', rri: EVALUATION_EXAMPLE, parameters: { qShape: '1 0.2\n2 0.3' }, outputs: ['detailed', 'ensemble'] }); +await replayArchive({ parameters: { qAcc: 'P', qAccFile: accessibility.fs.readFile('/output/qPu.csv', { encoding: 'utf8' }), model: 'P' }, outputs: ['bpProb', 'bpsvg'], csvColumns: ['E'] }); +console.log('ZIP parameter replay: CSV, multi-FASTA/signed indices, non-default energy/seed options, SHAPE/accessibility imports, per-record outputs, probability SVG and ASCII/detailed/ensemble reports are byte-identical'); diff --git a/scripts/wasm/stage.sh b/scripts/wasm/stage.sh index 148ae02e..d123f2b1 100644 --- a/scripts/wasm/stage.sh +++ b/scripts/wasm/stage.sh @@ -8,7 +8,7 @@ test -s "$site/wasm/intarna.js" test -s "$site/wasm/intarna.wasm" mkdir -p "$site/licenses" cp -R "$repo/webinterface/assets" "$site/" -for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js options.js columns.js; do +for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js options.js columns.js archive.js visualization.js; do cp "$repo/webinterface/$file" "$site/$file" done cp "$repo/webinterface/intarna-worker.js" "$site/intarna-worker.js" diff --git a/scripts/wasm/test-browser.py b/scripts/wasm/test-browser.py index 29b19f11..3bcdadbc 100644 --- a/scripts/wasm/test-browser.py +++ b/scripts/wasm/test-browser.py @@ -1,6 +1,9 @@ """Exercise the staged browser interface with real WASM at a nested URL.""" import argparse from pathlib import Path +from io import BytesIO +from urllib.parse import parse_qs, urlparse +from zipfile import ZipFile from playwright.sync_api import sync_playwright, expect parser = argparse.ArgumentParser(description=__doc__) @@ -14,6 +17,12 @@ page = browser.new_page(viewport={'width': 1440, 'height': 1100}) errors = [] page.on('pageerror', lambda error: errors.append(str(error))) + # Keep browser tests offline and inspect the exact third-party URL separately. + visualization_urls = [] + def viewer(route): + visualization_urls.append(route.request.url) + route.fulfill(content_type='text/html', body='

    vaRRI viewer

    ') + page.route('https://backofenlab.github.io/vaRRI/**', viewer) page.goto(args.url) page.wait_for_load_state('networkidle') compute = page.locator('#compute') @@ -21,11 +30,17 @@ rows = page.locator('tbody tr') def open_group(key): - group = page.locator('#' + key) - if not group.evaluate('node => node.open'): - group.locator('summary').first.click() - expect(group).to_have_attribute('open', '') - assert page.locator('.parameter-group[open]').count() == 1 + page.locator('#tab-' + key).click() + expect(page.locator('#' + key)).to_be_visible() + expect(page.locator('#tab-' + key)).to_have_attribute('aria-selected', 'true') + assert page.locator('.parameter-group:visible').count() == 1 + + def download_archive(): + with page.expect_download() as downloaded: + page.locator('#download-zip').click() + with ZipFile(BytesIO(Path(downloaded.value.path()).read_bytes())) as archive: + assert archive.testzip() is None + return {name: archive.read(name).decode() for name in archive.namelist()} def fill(key, value): parent = page.locator('#' + key).evaluate('node => node.closest(".parameter-group")?.id') @@ -44,7 +59,17 @@ def predict(): for key in ['query-options', 'target-options', 'seed-options', 'helix-options', 'shape-options', 'interaction-options', 'output-options', 'general-options', 'sequence-input']: open_group(key) - # JavaScript validation works even when the offending control is folded. + # Tabs support keyboard navigation and wrap without page overflow. + page.locator('#tab-sequence-input').focus() + page.keyboard.press('ArrowRight') + expect(page.locator('#tab-query-options')).to_be_focused() + expect(page.locator('#query-options')).to_be_visible() + page.keyboard.press('End') + expect(page.locator('#tab-general-options')).to_be_focused() + page.keyboard.press('Home') + expect(page.locator('#tab-sequence-input')).to_be_focused() + + # JavaScript validation works even when the offending control is in another tab. fill('query', '>bad\nACGU-ACGU') compute.click() expect(page.locator('#input-error')).to_contain_text('use only') @@ -61,10 +86,10 @@ def predict(): page.locator('#outCsvSort').select_option('E') predict() expect(rows).to_have_count(10) - assert rows.first.locator('td').all_text_contents()[:6] == ['target', '1', '10', 'query', '1', '10'] + assert rows.first.locator('td').all_text_contents()[1:7] == ['target', '1', '10', 'query', '1', '10'] assert rows.first.locator('td').all_text_contents()[-2:] == ['-21.40', '0.00'] page.get_by_role('button', name='Sort by Energy (kcal/mol)', exact=False).click() - assert float(rows.first.locator('td').nth(8).inner_text()) == -14.3 + assert float(rows.first.locator('td').nth(9).inner_text()) == -14.3 page.get_by_role('button', name='Sort by Energy (kcal/mol)', exact=False).click() page.locator('#search').fill('-21.4') expect(rows).to_have_count(1) @@ -81,7 +106,28 @@ def predict(): assert csv.splitlines()[0] == 'id1;start1;end1;id2;start2;end2;subseqDP;hybridDP;E' assert 'target;1;10;query;1;10;' in csv assert 'deltaE' not in csv + assert not visualization_urls # Third-party content loads only on selection. + rows.first.get_by_role('button', name='Visualize').click() + expect(page.locator('#varri-frame')).to_be_visible() + expect(page.frame_locator('#varri-frame').get_by_role('heading', name='vaRRI viewer')).to_be_visible() + expect(rows.first.locator('button')).to_have_attribute('aria-pressed', 'true') + query = parse_qs(urlparse(page.locator('#varri-frame').get_attribute('src')).query) + assert query['sequence'] == ['CCCUCCUCCC&GGGAGGAGGG'] + assert query['structure'] == ['((((((((((&))))))))))'] + assert query['seqName1'] == ['target'] and query['seqName2'] == ['query'] + assert query['startIndex1'] == ['1'] + assert query['showRenderingOnly'] == ['true'] + assert 'showRenderingOnly' not in page.locator('#varri-link').get_attribute('href') + archive = download_archive() + assert archive['intarna-interactions.csv'] == csv + assert archive['query.fa'] == '>query\nGGGAGGAGGG\n' + assert '# IntaRNA 3.4.1' in archive['parameters.cfg'] + assert 'Vienna RNA package 2.7.0' in archive['parameters.cfg'] + assert 'outNumber = 10' in archive['parameters.cfg'] + assert 'IntaRNA --parameterFile=parameters.cfg' in archive['parameters.cfg'] + assert 'temperature =' not in archive['parameters.cfg'] if args.screenshot: + open_group('sequence-input') page.screenshot(path=args.screenshot, full_page=True) page.set_viewport_size({'width': 390, 'height': 844}) @@ -97,15 +143,26 @@ def predict(): page.locator('#output-options input[value="qMinE"]').check() predict() expect(rows).to_have_count(2) - assert rows.first.locator('td').all_text_contents()[:6] == ['target', '-5', '5', 'q1', '-3', '7'] + assert rows.first.locator('td').all_text_contents()[1:7] == ['target', '-5', '5', 'q1', '-3', '7'] expect(page.locator('#file-list details')).to_have_count(2) page.locator('#file-list summary').first.click() expect(page.locator('#file-list pre').first).to_contain_text('minE') + archive = download_archive() + assert len([name for name in archive if name.startswith('qMinE')]) == 2 + assert archive['query.fa'].count('>') == 2 + assert 'qIdxPos0 = -3' in archive['parameters.cfg'] + rows.nth(1).get_by_role('button', name='Visualize').click() + query = parse_qs(urlparse(page.locator('#varri-frame').get_attribute('src')).query) + assert query['seqName2'] == ['q2'] + assert query['startIndex1'] == ['-5'] and query['startIndex2'] == ['-3'] # FASTA identity follows native first-token naming, including collisions. fill('query', '>q first\nGGGAGGAGGG\n>q second\nGGGAGGAGGG') compute.click() expect(page.locator('#input-error')).to_contain_text('unique') + expect(page.locator('#varri-frame')).not_to_be_visible() + assert page.locator('#varri-frame').get_attribute('src') is None + expect(page.locator('#download-zip')).not_to_be_visible() # Evaluation resets native defaults and removes prediction-only controls. fill('query', '>q1\nGGGAGGAGGG\n>q2\nGGGAGGAGGG') @@ -130,6 +187,23 @@ def predict(): with page.expect_download() as report: page.locator('#file-list button').click() assert '-21.4' in Path(report.value.path()).read_text() + archive = download_archive() + assert '-21.4' in archive['intarna-detailed.txt'] + assert 'personality = IntaRNAeval' in archive['parameters.cfg'] + assert 'rri = 1||||||||||&1||||||||||:1|||||||||&2|||||||||' in archive['parameters.cfg'] + assert 'seedBP =' not in archive['parameters.cfg'] + assert 'out = intarna-detailed.txt' in archive['detailed.cfg'] + + # Visualization stays available when structure/identity columns are deselected. + open_group('output-options') + page.get_by_role('button', name='Clear selection', exact=True).click() + page.locator('#output-options input[value="E"]').check() + predict() + rows.first.get_by_role('button', name='Visualize').click() + expect(page.locator('#varri-frame')).to_be_visible() + archive = download_archive() + assert archive['intarna-interactions.csv'].splitlines()[0] == 'E' + assert 'hybridDP' not in archive['parameters.cfg'] fill('rri', '1|||&1||') compute.click() expect(page.locator('#input-error')).to_contain_text('same nonzero') @@ -159,6 +233,8 @@ def predict(): compute.click() expect(status).to_contain_text('No favorable interactions', timeout=120000) expect(rows).to_have_count(0) + expect(page.locator('#varri-frame')).not_to_be_visible() + assert download_archive()['intarna-interactions.csv'].count('\n') == 1 fill('query', '>\nGGGAGGAGGG') predict() assert page.locator('tbody img').count() == 0 @@ -174,4 +250,4 @@ def predict(): predict() assert errors == [], errors browser.close() -print(f'{args.browser}: real WASM, groups/defaults/versions, prediction/evaluation, FASTA/coordinates, columns/reports, mobile and abort/recovery passed') +print(f'{args.browser}: real WASM, groups/defaults/versions, prediction/evaluation, FASTA/coordinates, tabs/keyboard, visualization URLs/selection, ZIP replay contents, columns/reports, mobile and abort/recovery passed') diff --git a/webinterface/app.js b/webinterface/app.js index eeb37665..9ecdaecc 100644 --- a/webinterface/app.js +++ b/webinterface/app.js @@ -2,25 +2,49 @@ import { DEFAULTS, EVALUATION_EXAMPLE, parseFasta, prepareRequest, CSV_COLUMNS } import { GROUPS, FIELDS, EXTRA_OUTPUTS } from './options.js'; import { CSV_CATALOG } from './columns.js'; import { PredictionRunner } from './runner.js'; -import { ResultsView, downloadText } from './results.js'; +import { ResultsView, downloadText, downloadBlob } from './results.js'; +import { createArchive } from './archive.js'; const byId = (id) => document.getElementById(id); const form = byId('prediction-form'), runner = new PredictionRunner(); const dialog = byId('loading'), status = byId('result-status'), errorBox = byId('input-error'); const results = new ResultsView(byId('results-view')); const csvInputs = [], outputInputs = []; +let completedRun = null; function element(tag, text, className) { const node = document.createElement(tag); if (text) node.textContent = text; if (className) node.className = className; return node; } +function activateGroup(key, focus = false) { + for (const tab of byId('parameter-tabs').children) { + const active = tab.getAttribute('aria-controls') === key; + tab.setAttribute('aria-selected', String(active)); tab.tabIndex = active ? 0 : -1; + byId(tab.getAttribute('aria-controls')).hidden = !active; + if (active && focus) tab.focus(); + } +} +function addTab(key, label) { + const tab = element('button', label); tab.type = 'button'; tab.id = `tab-${key}`; + tab.setAttribute('role', 'tab'); tab.setAttribute('aria-controls', key); + tab.addEventListener('click', () => activateGroup(key)); + byId('parameter-tabs').append(tab); +} function group(key, label) { - const details = element('details', '', 'parameter-group'); - details.id = key; details.name = 'parameters'; details.append(element('summary', label)); - const body = element('div', '', 'group-body'); details.append(body); - byId('parameter-groups').append(details); return body; + const panel = element('section', '', 'parameter-group'); panel.id = key; + panel.setAttribute('role', 'tabpanel'); panel.setAttribute('aria-labelledby', `tab-${key}`); + const body = element('div', '', 'group-body'); panel.append(body); + byId('parameter-groups').append(panel); addTab(key, label); return body; } +addTab('sequence-input', 'Sequence input'); +byId('parameter-tabs').addEventListener('keydown', (event) => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + const tabs = [...byId('parameter-tabs').children].filter((tab) => !tab.hidden); + const current = tabs.indexOf(document.activeElement); + const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length; + event.preventDefault(); activateGroup(tabs[next].getAttribute('aria-controls'), true); +}); for (const definition of GROUPS) { const body = group(definition.key, definition.label), grid = element('div', '', 'field-grid'); body.append(grid); for (const field of definition.fields) { @@ -69,12 +93,8 @@ const generalBody = group('general-options', 'General'); generalBody.append(element('p', 'Tasks run locally in one worker. Abort or Escape stops computation immediately. Process-level settings (threads, log paths, parameter-file paths and terminal help) are available in the CLI.')); const docs = element('a', 'Full CLI parameter documentation'); docs.href = 'https://github.com/BackofenLab/IntaRNA#readme'; generalBody.append(docs); -// Use explicit grouping as a fallback for browsers without details[name]. -for (const details of document.querySelectorAll('.parameter-group')) { - details.addEventListener('toggle', () => { if (details.open) for (const other of document.querySelectorAll('.parameter-group')) if (other !== details) other.open = false; }); -} function clearResults(message = 'Inputs changed. Compute to update the interactions.') { - results.clear(); byId('output-files').hidden = true; byId('file-list').replaceChildren(); + completedRun = null; results.clear(); byId('output-files').hidden = true; byId('file-list').replaceChildren(); byId('diagnostics').hidden = true; byId('diagnostic-text').textContent = ''; status.textContent = message; } function updateLength(name) { @@ -89,7 +109,11 @@ function updateAvailability() { byId('task-help').textContent = evaluation ? 'Evaluate supplied structures for one query and one target. Prediction constraints are ignored.' : 'Predict interactions for all query–target sequence pairs.'; byId('compute').textContent = evaluation ? 'Evaluate interactions' : 'Compute interactions'; for (const definition of GROUPS) { - const details = byId(definition.key); details.hidden = evaluation && !!definition.prediction; + const tab = byId(`tab-${definition.key}`); tab.hidden = evaluation && !!definition.prediction; + if (tab.hidden) { + byId(definition.key).hidden = true; + if (tab.getAttribute('aria-selected') === 'true') activateGroup('sequence-input'); + } for (const field of definition.fields) { const input = byId(field.key); input.disabled = evaluation && !!field.prediction || !evaluation && definition.key === 'helix-options' && byId('model').value !== 'B' || field.key === 'kineticScore' && byId('mode').value !== 'K'; @@ -113,7 +137,7 @@ form.addEventListener('input', (event) => { }); byId('example').addEventListener('click', () => { byId('query').value = DEFAULTS.query; byId('target').value = DEFAULTS.target; - resetParameters(); byId('sequence-input').open = true; clearResults('Example loaded. Compute to analyze its interactions.'); + resetParameters(); activateGroup('sequence-input'); clearResults('Example loaded. Compute to analyze its interactions.'); }); byId('abort').addEventListener('click', () => runner.abort()); dialog.addEventListener('cancel', (event) => { event.preventDefault(); runner.abort(); }); @@ -137,13 +161,15 @@ form.addEventListener('submit', async (event) => { csvColumns: csvInputs.filter((input) => input.checked).map((input) => input.value), outputs: outputInputs.filter((input) => input.checked && !input.disabled).map((input) => input.value), }; errorBox.hidden = true; - try { prepareRequest(request); } catch (error) { errorBox.textContent = error.message; errorBox.hidden = false; return; } + let prepared; + try { prepared = prepareRequest(request); } catch (error) { errorBox.textContent = error.message; errorBox.hidden = false; return; } clearResults('Computing interactions…'); byId('loading-status').textContent = 'Loading the prediction engine…'; byId('inputs').disabled = true; byId('results').setAttribute('aria-busy', 'true'); dialog.showModal(); try { const result = await runner.run(request, (message) => { byId('loading-status').textContent = message; }); - const count = results.show(result.csv, result.csvColumns); + const count = results.show(result.csv, result.csvColumns, prepared); status.textContent = count ? `${request.task === 'evaluation' ? 'Evaluation' : 'Prediction'} complete: ${count} interaction${count === 1 ? '' : 's'}.` : request.task === 'prediction' && Number(request.parameters.outNumber) === 0 ? 'Accessibility computation complete. No interaction rows requested.' : 'No favorable interactions found with these sequences and settings.'; + completedRun = { request: prepared, result }; showFiles(result.files); byId('diagnostic-text').textContent = result.diagnostics; byId('diagnostics').hidden = !result.diagnostics; } catch (error) { if (error.name === 'AbortError') status.textContent = 'Computation aborted. You can edit the sequences and try again.'; @@ -152,8 +178,26 @@ form.addEventListener('submit', async (event) => { byId('inputs').disabled = false; updateAvailability(); byId('results').setAttribute('aria-busy', 'false'); dialog.close(); byId('compute').focus(); } }); +activateGroup('sequence-input'); resetParameters(); +const buildInfo = fetch(new URL('./build-info.txt', import.meta.url)).then((response) => { + if (!response.ok) throw new Error('Missing build provenance'); return response.text(); +}); +// Keep the rejection handled even if no archive is requested. +buildInfo.catch(() => {}); fetch(new URL('./build-info.json', import.meta.url)).then((response) => { if (!response.ok) throw new Error('Missing build provenance'); return response.json(); }).then((info) => { byId('version-info').textContent = `IntaRNA ${info.intarna} (${info.commit.slice(0, 12)}${info.dirty ? ', modified sources' : ''}) · ViennaRNA ${info.viennarna} · Emscripten ${info.emscripten}`; }).catch(() => { byId('version-info').textContent = 'Build versions unavailable. Serve the staged interface with its compiled engine and build-info files.'; }); if (!globalThis.Worker || !globalThis.WebAssembly) { byId('compute').disabled = true; errorBox.textContent = 'This browser needs WebAssembly and Web Workers. Use a current browser or the command line version.'; errorBox.hidden = false; } + +byId('download-zip').addEventListener('click', async () => { + const run = completedRun; if (!run) return; + const button = byId('download-zip'); button.disabled = true; + try { + const provenance = await buildInfo; + if (completedRun !== run) return; + downloadBlob('intarna-results.zip', new Blob([createArchive(run.request, run.result, provenance)], { type: 'application/zip' })); + } catch (error) { + errorBox.textContent = `ZIP download failed: ${error.message}. Reload the staged interface with its compiled version files.`; errorBox.hidden = false; + } finally { button.disabled = false; } +}); diff --git a/webinterface/archive.js b/webinterface/archive.js new file mode 100644 index 00000000..63a510c9 --- /dev/null +++ b/webinterface/archive.js @@ -0,0 +1,81 @@ +import { makeArguments } from './protocol.js'; +import { projectCsv } from './results.js'; + +const encoder = new TextEncoder(); +const crcTable = Uint32Array.from({ length: 256 }, (_, value) => { + for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ (value & 1 ? 0xedb88320 : 0); + return value >>> 0; +}); +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) crc = crcTable[(crc ^ byte) & 255] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +// A standard, uncompressed ZIP: local generation needs no CDN or dependency. +// Fixed timestamps and UTF-8 names make the same completed run reproducible. +export function zipFiles(files) { + const local = [], central = [], names = new Set(); + let offset = 0, directorySize = 0; + if (files.length > 65535) throw new Error('Too many files for a ZIP archive.'); + for (const file of files) { + if (!file.name || /[/\\\x00]/.test(file.name) || ['.', '..'].includes(file.name) || names.has(file.name)) throw new Error('Invalid or duplicate archive filename.'); + names.add(file.name); + const name = encoder.encode(file.name), data = encoder.encode(file.text), crc = crc32(data); + if (name.length > 65535 || offset + name.length + data.length + 30 > 0xffffffff) throw new Error('ZIP archive is too large.'); + const header = new Uint8Array(30), view = new DataView(header.buffer); + view.setUint32(0, 0x04034b50, true); view.setUint16(4, 20, true); + view.setUint16(6, 0x800, true); view.setUint16(12, 33, true); // 1980-01-01 + view.setUint32(14, crc, true); view.setUint32(18, data.length, true); view.setUint32(22, data.length, true); + view.setUint16(26, name.length, true); + local.push(header, name, data); + const entry = new Uint8Array(46), record = new DataView(entry.buffer); + record.setUint32(0, 0x02014b50, true); record.setUint16(4, 20, true); record.setUint16(6, 20, true); + record.setUint16(8, 0x800, true); record.setUint16(14, 33, true); + record.setUint32(16, crc, true); record.setUint32(20, data.length, true); record.setUint32(24, data.length, true); + record.setUint16(28, name.length, true); record.setUint32(42, offset, true); + central.push(entry, name); directorySize += entry.length + name.length; + offset += header.length + name.length + data.length; + } + const end = new Uint8Array(22), view = new DataView(end.buffer); + view.setUint32(0, 0x06054b50, true); + view.setUint16(8, files.length, true); view.setUint16(10, files.length, true); + view.setUint32(12, directorySize, true); view.setUint32(16, offset, true); + const zip = new Uint8Array(offset + directorySize + end.length); + let position = 0; + for (const bytes of [...local, ...central, end]) { zip.set(bytes, position); position += bytes.length; } + return zip; +} + +export function archiveFiles(request, result, provenance) { + if (!provenance?.trim()) throw new Error('Compiled version information is unavailable'); + const versionComments = provenance.trimEnd().split(/\r?\n/).map((line) => `# ${line}`).join('\n'); + const reports = [['normal', 'N'], ['detailed', 'D'], ['ensemble', 'E']].filter(([key]) => request.outputs.includes(key)); + const config = (format, filename) => { + // Reuse the worker's effective arguments, replacing only MEMFS paths and + // internal visualization columns. Optional data are shipped as text files. + const args = makeArguments(request, format).filter((arg) => !['--qIdxPos0=1', '--tIdxPos0=1'].includes(arg)); + const settings = args.map((arg) => { + if (arg.startsWith('--outCsvCols=')) return `outCsvCols = ${request.csvColumns.join(',')}`; + return arg.slice(2).replace('=', ' = ').replace(/\/(query\.fa|target\.fa|[qt](?:Shape|AccFile)\.txt)/g, '$1') + .replace('/results.csv', 'intarna-interactions.csv').replace('/report.txt', `intarna-${filename}.txt`).replace('/output/', ''); + }); + return `${versionComments}\n# Run from the extracted archive directory with these compiled library versions.\n# Reproduce: IntaRNA --parameterFile=${filename}.cfg\n# Only non-default analysis settings are listed; input/output routing is explicit.\n${settings.join('\n')}\n`; + }; + const parameters = config('C', 'parameters') + reports.map(([key]) => `# Additional report: IntaRNA --parameterFile=${key}.cfg\n`).join(''); + const files = [ + { name: 'query.fa', text: request.query.fasta }, { name: 'target.fa', text: request.target.fasta }, + { name: 'parameters.cfg', text: parameters }, { name: 'build-info.txt', text: provenance }, + { name: 'intarna-interactions.csv', text: projectCsv(result.csv, request.csvColumns) }, + ...result.files, + ...reports.map(([key, format]) => ({ name: `${key}.cfg`, text: config(format, key) })), + ]; + for (const [key, value] of Object.entries(request.parameters)) { + if (/^(q|t)(Shape|AccFile)$/.test(key) && value) files.push({ name: `${key}.txt`, text: value + '\n' }); + } + return files; +} + +export function createArchive(request, result, provenance) { + return zipFiles(archiveFiles(request, result, provenance)); +} diff --git a/webinterface/docs/step2-worker-architecture.md b/webinterface/docs/step2-worker-architecture.md index 8a2f8090..9617fbbd 100644 --- a/webinterface/docs/step2-worker-architecture.md +++ b/webinterface/docs/step2-worker-architecture.md @@ -24,8 +24,11 @@ exits and JS/runtime failures settle as errors, without partial successful files Both success and failure terminate the worker. Abort uses `Worker.terminate()` on the main thread and immediately rejects with `AbortError`, including during synchronous C++ execution. Stale messages are ignored and the next task starts -fresh. Inputs are never uploaded or persisted. No backend, shared memory or -cross-origin isolation is needed. A single WASM instance can grow up to 1 GiB. +fresh. Computation never uploads or persists inputs. Selecting a result to +visualize it loads the external vaRRI viewer with that pair and its structure +in the URL. The iframe is cleared on input edits; no third-party viewer is +loaded before selection. No backend, shared memory or cross-origin isolation +is needed for computation. A single WASM instance can grow up to 1 GiB. Run `npm test --prefix webinterface`, then the native-reference WASM smoke tests and Chromium/Firefox suites described in step 4. diff --git a/webinterface/docs/step3-frontend-ui.md b/webinterface/docs/step3-frontend-ui.md index 49289403..c16dcbde 100644 --- a/webinterface/docs/step3-frontend-ui.md +++ b/webinterface/docs/step3-frontend-ui.md @@ -13,24 +13,31 @@ The layout puts the scientific task first, then mutually exclusive parameter groups, followed by the compute button, results, additional files and versions. All working content is left aligned; two equally sized strand editors collapse into one column on mobile. The shared institutional header is the strongest -visual element. Parameter borders and blue summaries identify functional groups. +visual element. The wrapping tab bar identifies parameter groups. Numeric inputs +are 9 rem wide within a responsive grid of compact controls; text/data fields +retain useful width. +The institutional link reads “Bioinformatics - University of Freiburg” and opens +the group website. Footer and table notes use the available width, wrapping only +when the viewport requires it. ```text Group logo IntaRNA / RNA–RNA interaction analysis University logo -RNA–RNA interactions Sequences stay on your device +RNA–RNA interactions Computations stay on your device Task: Prediction / Evaluation Load example -[ Sequence input: query + target, start indices, evaluation structures ] -[ Query ] [ Target ] [ Seed ] [ Helix ] [ SHAPE ] [ Interaction/energy ] -[ Output: filters, CSV columns, additional files ] [ General ] +[ Sequence input | Query | Target | Seed | Helix | SHAPE | Energy | Output | General ] +[ Active panel: e.g. query + target, start indices, evaluation structures ] Reset parameters Compute -Interactions / search / show-hide columns / CSV download +Interactions / search / show-hide columns / CSV + ZIP download +Selected interaction / embedded vaRRI Additional output files / diagnostics / compiled versions ``` -Every CLI group has a menu. `details[name]` plus a toggle fallback keep at most -one parameter menu open. Sequence input starts open. Prediction-only controls -are disabled/hidden during evaluation. Changing task resets parameters to their -CLI defaults while preserving sequence text; Load example also restores the +Every CLI group has a tab with one visible panel. Sequence input is selected +initially. Tabs expose `aria-selected`, `aria-controls` and roving focus; Left/Right +move between available tabs, and Home/End select the first/last. Tab names wrap +on narrow screens. Prediction-only tabs and controls are disabled/hidden during +evaluation; if the selected tab becomes unavailable, Sequence input is selected. +Changing task resets parameters to their CLI defaults while preserving sequence text; Load example also restores the complementary pair. The native default output count is one; browser users can request 0–100 interactions per pair. Zero enables accessibility-only output. diff --git a/webinterface/docs/step4-results-and-deployment.md b/webinterface/docs/step4-results-and-deployment.md index 2c8ffe99..8d2e3aaf 100644 --- a/webinterface/docs/step4-results-and-deployment.md +++ b/webinterface/docs/step4-results-and-deployment.md @@ -3,8 +3,8 @@ ## Results contract The CSV checklist covers all `OutputHandlerCsv` column IDs. The worker requests -those columns plus internal target/query IDs, coordinates and energy needed for -pair grouping and result validation. Downloads project the untouched native CSV +those columns plus internal target/query IDs, coordinates, energy and `hybridDP` +needed for pair grouping, result validation and visualization. Downloads project the untouched native CSV cells onto the user's selected columns, in selection order, retaining every row regardless of table filtering/visibility. No computed mfe difference is inserted into native CSV. The selected-column catalog is checked against the C++ mapping. @@ -27,6 +27,59 @@ and diagnostics render with `textContent`. Selected extra files appear below the table with bounded text previews and complete downloads, including SVG source. Empty output is successful and still permits CSV/accessibility downloads. +## Selected interaction in vaRRI + +Each row has a keyboard-accessible View button; clicking a row also selects it. +Selection survives table sorting, filtering and visibility changes. Input edits, +new runs, errors and cancellation clear the viewer and archive snapshot. +`visualization.js` uses native seq1 = target, seq2 = query and maps the native +CSV identifiers to the completed run's normalized FASTA records (including +semicolon-to-underscore ID conversion). It pads the interaction dot-bracket +structure with unpaired positions to the full input lengths, using each start +index and the native negative-to-positive transition that skips zero. + +The URL follows [vaRRI's embedding API](https://github.com/BackofenLab/vaRRI/blob/main-v2/docs/sharing-and-input.md): +`sequence`, `structure`, `seqName1/2`, `startIndex1/2`, and +`showRenderingOnly=true`. Names use the main-v2 API; older deployed vaRRI versions +may retain generic strand names. A separate Open in vaRRI link opens the full +viewer. The local status always identifies the selected pair and coordinates. +Zero-based CLI start indices remain accepted for computation/export, but vaRRI +requires nonzero start indices; choosing View for such a run explains this limit. + +No external viewer loads until a row is selected. The interface explains that +selection shares that pair and structure through vaRRI's URL. Computation and +ZIP generation remain local. CSP permits frames only at the vaRRI deployment; +the iframe sends no referrer. Viewer availability depends on that external site; +the full-viewer link permits retry, and scientific outputs remain downloadable. + +## Reproducible ZIP download + +Download ZIP sits beside Download CSV. `archive.js` creates a standard UTF-8, +uncompressed ZIP with CRC-32 checksums locally, without a library or CDN. It uses +the completed request and result, independent of table sorting/filtering and +column visibility. Editing inputs discards the completed snapshot. + +The archive contains: + +- `query.fa` and `target.fa`: the exact normalized FASTA consumed by the worker. +- `parameters.cfg`: native parameter-file syntax, non-default effective analysis + settings, explicit input/output routing and the selected CSV columns. Comments + include the exact compiled version/provenance text and the replay command + `IntaRNA --parameterFile=parameters.cfg` from the extracted directory. +- `build-info.txt`: original compiled engine/library versions and asset hashes. +- `intarna-interactions.csv`: all results with the selected CSV columns, identical + to Download CSV even if the table has been sorted, filtered or columns hidden. +- Every generated additional output, with its original filename and contents. +- Local SHAPE/accessibility input files when supplied, referenced by relative paths. +- Separate `normal.cfg`, `detailed.cfg` or `ensemble.cfg` when those reports were + requested, with their replay commands also listed in `parameters.cfg`. Each + report requires a separate native invocation, matching worker behavior. + +Replay with the recorded IntaRNA and library versions. Export translates MEMFS +paths to portable relative paths and omits defaults using the worker's own +argument builder. ZIP download reports a clear error if compiled version text +is unavailable, rather than claiming incomplete provenance. + ## Packaging and deployment ```sh @@ -137,3 +190,26 @@ claim a live Pages deployment. - Staging preserves compiled metadata byte-for-byte; a deliberately changed copied JS engine is rejected by its asset digest check. Shell syntax, workflow YAML, Python syntax and `git diff --check` pass. + +### PR #287 follow-up verification, 2026-10-22 + +- All 12 Node test groups pass, including coordinate/strand mapping, non-default + export settings, auxiliary inputs and report configurations. +- `node scripts/wasm/smoke.mjs` and `node scripts/wasm/smoke-protocol.mjs` pass + using the previously compiled Emscripten 4.0.23 engine (revision `fc62a23`). + The latter executes exported parameter files in fresh engines with only + archived inputs and verifies byte-identical CSV, per-record outputs, + probability SVGs and ASCII/detailed/ensemble reports. Cases include signed + multi-FASTA, changed energy/seed settings, SHAPE and accessibility imports. +- Chromium 156 and Firefox 157 with Playwright 1.64.0 pass the real-WASM browser + suite at a nested URL. It covers tab keyboard behavior, selection/encoded + viewer URLs, Python `zipfile` validation of downloaded ZIP contents, hidden + CSV columns, evaluation reports, empty results and the existing lifecycle + checks. Only the third-party iframe response is stubbed in CI; computation + runs the real engine. Desktop/parameter/mobile screenshots were reviewed. +- A separate live Chromium check renders RNA base circles in the actual hosted + vaRRI iframe. An independent Python `zipfile` check verifies CRCs, Unicode + filenames/data and empty files in the ZIP writer's output. +- Native `make tests -j2` passes all eight API/CLI groups (462,689 assertions in + 119 API cases). No C++ or compiled engine changes were needed for this follow-up. +- Staging, shell/Python syntax and `git diff --check` pass. diff --git a/webinterface/index.html b/webinterface/index.html index 318c101f..e7d56a5d 100644 --- a/webinterface/index.html +++ b/webinterface/index.html @@ -4,7 +4,7 @@ - + IntaRNA · RNA–RNA interactions @@ -15,12 +15,12 @@

    RNA–RNA interactions

    Predict interactions or evaluate the energy of structures you provide.

    -

    Sequences stay on your device

    +

    Computations stay on your device

    @@ -29,8 +29,8 @@

    Predict interactions for all query–target sequence pairs.

    -
    - Sequence input +
    +
    5′ to 3′
    @@ -47,7 +47,7 @@

    Plain sequence or multi-FASTA (prediction only), A/C/G/U/T/N. Up to 20 records per field and 2–2,000 nt per record; total query × target length across selected pairs ≤ 1,000,000. Seed predictions also require at least as many nucleotides as seed base pairs. Signed start indices apply to every record on that strand; negative indexing skips zero.

    -
    +
    @@ -56,11 +56,17 @@

    Interactions

    Enter your sequences or try the example, then compute.

    diff --git a/webinterface/protocol.js b/webinterface/protocol.js index cfc9451e..d70c0a40 100644 --- a/webinterface/protocol.js +++ b/webinterface/protocol.js @@ -222,7 +222,7 @@ export function makeArguments(request, format = 'C') { } if (request.rri) args.push(`--rri=${request.rri}`); if (format === 'C') { - args.push(`--outCsvCols=${[...new Set([...request.csvColumns, ...CONTEXT_COLUMNS])].join(',')}`); + args.push(`--outCsvCols=${[...new Set([...request.csvColumns, ...CONTEXT_COLUMNS, 'hybridDP'])].join(',')}`); for (const key of request.outputs.filter((key) => !['normal', 'detailed', 'ensemble'].includes(key))) { const spots = key === 'spotProb' && request.parameters.outSpotProbSpots ? `${request.parameters.outSpotProbSpots}:` : ''; args.push(`--out=${key}:${spots}/output/${key}${key === 'bpsvg' ? '.svg' : '.csv'}`); diff --git a/webinterface/results.js b/webinterface/results.js index 1c4b5d41..aeceb38a 100644 --- a/webinterface/results.js +++ b/webinterface/results.js @@ -1,5 +1,6 @@ import { CSV_CATALOG } from './columns.js'; import { CONTEXT_COLUMNS } from './protocol.js'; +import { InteractionVisualization } from './visualization.js'; export const COLUMNS = Object.freeze([...CSV_CATALOG, { key: 'deltaE', label: 'mfe Δ (kcal/mol)', numeric: true }]); const catalog = new Map(COLUMNS.map((column) => [column.key, column])); @@ -53,7 +54,10 @@ export function projectCsv(csv, columns) { return lines.map((line) => { const cells = line.split(';'); return indices.map((index) => cells[index]).join(';'); }).join('\n') + '\n'; } export function downloadText(name, text) { - const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })); + downloadBlob(name, new Blob([text], { type: 'text/plain;charset=utf-8' })); +} +export function downloadBlob(name, blob) { + const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = name; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); @@ -61,6 +65,7 @@ export function downloadText(name, text) { export class ResultsView { constructor(root) { this.root = root; this.rows = []; this.key = 'E'; this.direction = 1; + this.visualization = new InteractionVisualization(root); this.search = root.querySelector('#search'); this.table = root.querySelector('table'); this.body = this.table.querySelector('tbody'); @@ -73,9 +78,11 @@ export class ResultsView { } clear() { this.rows = []; this.csv = ''; this.body.replaceChildren(); this.root.hidden = true; + this.selected = null; this.visualization.clear(); } - show(csv, columns = csv.trim().split('\n')[0].split(';')) { + show(csv, columns = csv.trim().split('\n')[0].split(';'), request) { this.rows = parseResults(csv); this.csv = projectCsv(csv, columns); + this.request = request; this.selected = null; this.visualization.clear(); this.columns = columns.map((key) => catalog.get(key)); if (columns.includes('E')) this.columns.push(catalog.get('deltaE')); this.visible = new Set(this.columns.map((c) => c.key)); @@ -96,6 +103,8 @@ export class ResultsView { const rows = selectRows(this.rows, this.search.value, this.key, this.direction); const columns = this.columns.filter((column) => this.visible.has(column.key)); this.header.replaceChildren(); + const viewHeading = document.createElement('th'); viewHeading.scope = 'col'; viewHeading.textContent = 'vaRRI'; + this.header.append(viewHeading); for (const column of columns) { const cell = document.createElement('th'), button = document.createElement('button'); cell.scope = 'col'; button.type = 'button'; button.textContent = column.label; @@ -108,6 +117,19 @@ export class ResultsView { this.body.replaceChildren(); for (const row of rows) { const tr = document.createElement('tr'); + tr.classList.toggle('selected', row === this.selected); + const action = document.createElement('td'), view = document.createElement('button'); + view.type = 'button'; view.textContent = 'View'; view.className = 'view-interaction'; + view.setAttribute('aria-label', `Visualize ${row.id1} ${row.start1}–${row.end1} with ${row.id2} ${row.start2}–${row.end2}`); + view.setAttribute('aria-pressed', String(row === this.selected)); + tr.addEventListener('click', () => { + this.selected = row; this.visualization.show(row, this.request); + for (const other of this.body.children) { + other.classList.toggle('selected', other === tr); + other.querySelector('.view-interaction').setAttribute('aria-pressed', String(other === tr)); + } + }); + action.append(view); tr.append(action); for (const column of columns) { const cell = document.createElement('td'); cell.textContent = ['E', 'deltaE'].includes(column.key) ? Number(row[column.key]).toFixed(2) : String(row[column.key]); diff --git a/webinterface/styles.css b/webinterface/styles.css index 30e7a8a9..b776078c 100644 --- a/webinterface/styles.css +++ b/webinterface/styles.css @@ -182,6 +182,12 @@ h3 { .university-link { margin-left:auto; + display:flex; + align-items:center; + gap:12px; + font-size:.85rem; + line-height:1.35; + text-align:right; } main { @@ -238,36 +244,56 @@ fieldset { color:var(--muted); } -.parameter-group { +#parameter-groups { border:1px solid var(--border); background:#fbfdff; border-radius:8px; - margin-bottom:8px; } -.parameter-group > summary { - padding:.75rem 1rem; +#parameter-tabs { + display:flex; + flex-wrap:wrap; + gap:4px; + padding:8px; + border-bottom:1px solid var(--border); + background:var(--brand-blue-soft); + border-radius:7px 7px 0 0; +} + +[role=tab] { + border-color:transparent; + background:transparent; color:var(--brand-blue-dark); - font-weight:700; - user-select:none; + padding:.55rem .75rem; + font-size:.85rem; } -.parameter-group[open] > summary { - background:var(--brand-blue-soft); - border-bottom:1px solid var(--border); - border-radius:7px 7px 0 0; +[role=tab][aria-selected=true] { + background:var(--brand-blue); + color:white; +} + +[role=tab]:hover { + border-color:var(--brand-blue); } .group-body { padding:18px; } -.sequence-grid,.field-grid { +.sequence-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px 24px; } +.field-grid { + display:grid; + grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); + gap:20px 24px; + align-items:start; +} + .strand-heading,.sequence-meta { display:flex; align-items:center; @@ -323,6 +349,10 @@ fieldset { font-size:.9rem; } +.field input[type=number] { + width:9rem; +} + .field-hint { margin:0; } @@ -393,6 +423,12 @@ fieldset { margin:18px 0 12px; } +.download-actions { + display:flex; + flex-wrap:wrap; + gap:8px; +} + .table-tools label { display:flex; gap:8px; @@ -461,6 +497,53 @@ td.number { .table-note { margin:12px 0; + max-width:none; +} + +.view-interaction { + padding:.25rem .6rem; + color:var(--brand-blue-dark); + background:var(--surface); +} + +tbody tr { + cursor:pointer; +} + +tbody tr:hover,tbody tr.selected { + background:var(--brand-blue-soft); +} + +.view-interaction[aria-pressed=true] { + background:var(--brand-blue); + color:white; +} + +#visualization { + margin-top:20px; +} + +.visualization-heading { + display:flex; + align-items:baseline; + flex-wrap:wrap; + gap:16px; +} + +#varri-frame { + display:block; + width:100%; + height:600px; + margin-top:12px; + border:1px solid var(--border); + border-radius:5px; + background:white; +} + +#visualization-status { + max-width:none; + overflow-wrap:anywhere; + font-size:.9rem; } #output-files { @@ -495,6 +578,7 @@ footer { footer p { margin-bottom:8px; + max-width:none; } .error { @@ -571,6 +655,10 @@ dialog::backdrop { .uni-logo { height:36px; } + .university-link { + font-size:.75rem; + gap:8px; + } .brand { font-size:1.2rem; } @@ -590,9 +678,12 @@ dialog::backdrop { .local-note { margin-top:12px; } - .sequence-grid,.field-grid { + .sequence-grid { grid-template-columns:1fr; } + .field-grid { + grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr)); + } .results { padding:14px; } diff --git a/webinterface/tests/export.test.mjs b/webinterface/tests/export.test.mjs new file mode 100644 index 00000000..60d83edd --- /dev/null +++ b/webinterface/tests/export.test.mjs @@ -0,0 +1,38 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULTS, prepareRequest } from '../protocol.js'; +import { archiveFiles } from '../archive.js'; +import { visualizationUrl } from '../visualization.js'; + +test('visualization retains full strand orientation, bulges, names and indices crossing zero', () => { + const request = prepareRequest({ ...DEFAULTS, query: '>other\nGGGAGGAGGG\n>q;β\nGGGAGGAGGG', qIdxPos0: -3, tIdxPos0: -5 }); + const url = new URL(visualizationUrl({ id1: 'target', id2: 'q_β', start1: -3, end1: 3, start2: -2, end2: 4, E: -1, hybridDP: '((..((&)).)).' }, request)); + assert.equal(url.origin + url.pathname, 'https://backofenlab.github.io/vaRRI/'); + assert.equal(url.searchParams.get('sequence'), 'CCCUCCUCCC&GGGAGGAGGG'); + assert.equal(url.searchParams.get('structure'), '..((..((..&.)).))....'); + assert.equal(url.searchParams.get('seqName2'), 'q_β'); + assert.equal(url.searchParams.get('startIndex1'), '-5'); + assert.equal(url.searchParams.get('startIndex2'), '-3'); + assert.equal(url.searchParams.get('showRenderingOnly'), 'true'); + assert.throws(() => visualizationUrl({ id1: 'missing', hybridDP: '(&)' }, request), /not found/); + assert.throws(() => visualizationUrl({ id1: 'target', id2: 'q_β', start1: -5, end1: -4, hybridDP: '(((&)))' }, request), /coordinates/); + assert.throws(() => visualizationUrl({ id1: 'target', hybridDP: '(&)' }, { ...request, parameters: { ...request.parameters, tIdxPos0: 0 } }), /nonzero/); +}); + +test('archive preserves effective non-default settings, selected CSV, auxiliary inputs and reports', () => { + const request = prepareRequest({ ...DEFAULTS, task: 'evaluation', rri: '1||||||||||&3||||||||||', qIdxPos0: 3, parameters: { temperature: 21, qShape: '1 0.2\n2 0.3' }, csvColumns: ['E'] }); + request.outputs = ['detailed']; + const files = archiveFiles(request, { csv: 'id1;start1;end1;id2;start2;end2;E;hybridDP\ntarget;1;10;query;3;12;-21.4;((((((((((&))))))))))\n', files: [{ name: 'intarna-detailed.txt', text: 'report' }] }, 'IntaRNA 3.4.1\nViennaRNA 2.7.0\n'); + const entries = Object.fromEntries(files.map((file) => [file.name, file.text])); + assert.equal(entries['query.fa'], '>query\nGGGAGGAGGG\n'); + assert.equal(entries['intarna-interactions.csv'], 'E\n-21.4\n'); + assert.equal(entries['qShape.txt'], '1 0.2\n2 0.3\n'); + assert.match(entries['parameters.cfg'], /# ViennaRNA 2.7.0/); + for (const line of ['personality = IntaRNAeval', 'temperature = 21', 'qIdxPos0 = 3', 'qShape = qShape.txt', 'outCsvCols = E']) assert.ok(entries['parameters.cfg'].includes(line), line); + assert.ok(!entries['parameters.cfg'].includes('tIdxPos0 =')); + assert.ok(!entries['parameters.cfg'].includes('seedBP =')); + assert.match(entries['detailed.cfg'], /out = intarna-detailed.txt/); + assert.match(entries['parameters.cfg'], /IntaRNA --parameterFile=parameters.cfg/); + assert.equal(entries['intarna-detailed.txt'], 'report'); + assert.throws(() => archiveFiles(request, { csv: '', files: [] }, ''), /version/); +}); diff --git a/webinterface/visualization.js b/webinterface/visualization.js new file mode 100644 index 00000000..46837c57 --- /dev/null +++ b/webinterface/visualization.js @@ -0,0 +1,48 @@ +import { sequenceOffset } from './protocol.js'; + +export function visualizationUrl(row, request) { + // Native seq1 is target and seq2 is query; both structures run 5′ to 3′. + const structures = row.hybridDP?.split('&'); + if (structures?.length !== 2) throw new Error('The interaction has no dot-bracket structure.'); + const sequences = [], fullStructures = [], starts = []; + for (const [index, strand] of ['target', 'query'].entries()) { + const number = index + 1, start = request.parameters[index ? 'qIdxPos0' : 'tIdxPos0']; + const record = request[strand].records.find((record) => record.id.replaceAll(';', '_') === row[`id${number}`]); + if (!record) throw new Error('The interaction sequence was not found in this run.'); + if (start === 0) throw new Error('vaRRI requires nonzero sequence start indices. Choose a nonzero start index to visualize this pair.'); + const offset = sequenceOffset(row[`start${number}`], start), structure = structures[index]; + const end = sequenceOffset(row[`end${number}`], start); + if (offset < 0 || end >= record.sequence.length || structure.length !== end - offset + 1 || /[^.()]/.test(structure)) throw new Error('The interaction structure does not match its sequence coordinates.'); + sequences.push(record.sequence); starts.push(start); + fullStructures.push('.'.repeat(offset) + structure + '.'.repeat(record.sequence.length - end - 1)); + } + const url = new URL('https://backofenlab.github.io/vaRRI/'); + url.search = new URLSearchParams({ + sequence: sequences.join('&'), structure: fullStructures.join('&'), + seqName1: row.id1, seqName2: row.id2, startIndex1: starts[0], startIndex2: starts[1], + showRenderingOnly: 'true', forceLayout: 'false', forceLayoutLinearRRI: 'true', + }); + return url.href; +} + +export class InteractionVisualization { + constructor(root) { + this.frame = root.querySelector('#varri-frame'); this.link = root.querySelector('#varri-link'); + this.status = root.querySelector('#visualization-status'); + } + clear() { + this.frame.removeAttribute('src'); this.frame.hidden = true; + this.link.removeAttribute('href'); this.link.hidden = true; + this.status.textContent = 'Select View on an interaction to visualize it.'; + } + show(row, request) { + this.clear(); + try { + const url = visualizationUrl(row, request); + this.frame.src = url; this.frame.hidden = false; + const fullUrl = new URL(url); fullUrl.searchParams.delete('showRenderingOnly'); + this.link.href = fullUrl.href; this.link.hidden = false; + this.status.textContent = `${row.id1} (${row.start1}–${row.end1}) & ${row.id2} (${row.start2}–${row.end2}), ${row.E.toFixed(2)} kcal/mol. If the viewer is unavailable, use Open in vaRRI to retry.`; + } catch (error) { this.status.textContent = error.message; } + } +} From 77eb89d64b8ac498bdda2b7504193054920659f9 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Thu, 22 Oct 2026 15:06:37 +0200 Subject: [PATCH 11/31] fix(wasm): repair ViennaRNA download and cache recovery --- .github/workflows/build-wasm.yml | 2 + ChangeLog | 18 ++++ scripts/wasm/build.sh | 11 +-- scripts/wasm/download.sh | 23 ++++++ scripts/wasm/test-downloads.py | 92 +++++++++++++++++++++ webinterface/docs/step1-wasm-compilation.md | 10 ++- 6 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 scripts/wasm/download.sh create mode 100644 scripts/wasm/test-downloads.py diff --git a/.github/workflows/build-wasm.yml b/.github/workflows/build-wasm.yml index 1790b36a..c6baf0d0 100644 --- a/.github/workflows/build-wasm.yml +++ b/.github/workflows/build-wasm.yml @@ -30,6 +30,8 @@ jobs: - uses: actions/setup-python@v6 with: python-version: '3.12' + - name: Test dependency download integrity and cache recovery + run: python scripts/wasm/test-downloads.py - name: Test input, worker lifecycle and CSV handling run: npm test --prefix webinterface - name: Cross-compile dependencies and IntaRNA diff --git a/ChangeLog b/ChangeLog index 63803d54..01272691 100644 --- a/ChangeLog +++ b/ChangeLog @@ -84,6 +84,10 @@ ## Technical changes and Optimizations +- BUGFIX : restore WASM dependency downloads using the pinned official ViennaRNA + release asset; verify downloads before caching and recover invalid caches + (PR #291) + - annotate inherited virtual methods/destructors with override, preserve compiled WASM asset provenance through staging, and expand protocol/browser validation for the PR #287 review @@ -245,6 +249,20 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261022 Alexander Mitrofanov + * scripts/wasm/{build,download}.sh + * Use the official GitHub ViennaRNA 2.7.0 release asset and pin its SHA-256; + the university endpoint returned a short HTTP 200 text response, failing + the WASM build before compilation (PR #291). + + Verify archives before caching, discard invalid cached responses and remove + failed partial downloads so rerunning a build can recover automatically. + * scripts/wasm/test-downloads.py, .github/workflows/build-wasm.yml + + Test verified-cache reuse, invalid-cache recovery, checksum failures, + HTTP errors and truncated transfers against a local HTTP server in CI. + * webinterface/docs/step1-wasm-compilation.md + * Document the release source, archive verification and download regression + command, including recovery behavior for failed or outdated caches. + 261022 Alexander Mitrofanov * webinterface/{archive,visualization}.js + Build local UTF-8 ZIPs with normalized FASTA, compiled version provenance, diff --git a/scripts/wasm/build.sh b/scripts/wasm/build.sh index b6dd493d..60140119 100644 --- a/scripts/wasm/build.sh +++ b/scripts/wasm/build.sh @@ -12,19 +12,14 @@ for tool in emcc em++ emar emranlib emconfigure emmake curl pkg-config node; do done mkdir -p "$build/downloads" "$build/sources" "$prefix" "$build/site/wasm" fetch() { - local url=$1 name=$2 checksum=$3 - if [[ ! -f "$build/downloads/$name" ]]; then - curl --fail --location --retry 3 "$url" -o "$build/downloads/$name.part" - mv "$build/downloads/$name.part" "$build/downloads/$name" - fi - echo "$checksum $build/downloads/$name" | sha256sum --check - + bash "$repo/scripts/wasm/download.sh" "$1" "$build/downloads/$2" "$3" } fetch https://zlib.net/fossils/zlib-1.3.1.tar.gz zlib-1.3.1.tar.gz \ 9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23 fetch https://archives.boost.io/release/1.87.0/source/boost_1_87_0.tar.bz2 boost_1_87_0.tar.bz2 \ af57be25cb4c4f4b413ed692fe378affb4352ea50fbe294a11ef548f4d527d89 -fetch https://www.tbi.univie.ac.at/RNA/download/sourcecode/2_7_x/ViennaRNA-2.7.0.tar.gz ViennaRNA-2.7.0.tar.gz \ - 9a99fd68ed380894defb4d5e6a8a2871629270028cdf7f16f0a05da6e8c71473 +fetch https://github.com/ViennaRNA/ViennaRNA/releases/download/v2.7.0/ViennaRNA-2.7.0.tar.gz ViennaRNA-2.7.0.tar.gz \ + e2addf33e7c0dd4fff31d8e9a46fda8e9efcde0d199f789f20eb0a13b7f7135d # Keep pkg-config searches inside the WASM prefix, including configure probes. export PKG_CONFIG_LIBDIR="$prefix/lib/pkgconfig" diff --git a/scripts/wasm/download.sh b/scripts/wasm/download.sh new file mode 100644 index 00000000..359f7142 --- /dev/null +++ b/scripts/wasm/download.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Cache only verified dependency archives; recover from invalid cached responses. +set -euo pipefail +url=$1 +destination=$2 +checksum=$3 + +# Verify existing archives too, including caches from earlier build scripts. +if [[ -f "$destination" ]]; then + if printf '%s %s\n' "$checksum" "$destination" | sha256sum --check --status; then + echo "$destination: OK (cached)" + exit 0 + fi + echo "Discarding invalid cached archive: $destination" >&2 + rm -- "$destination" +fi + +# Never promote HTTP error pages or incomplete transfers to the download cache. +partial="$destination.part" +trap 'rm -f -- "$partial"' EXIT +curl --fail --location --retry 3 "$url" -o "$partial" +printf '%s %s\n' "$checksum" "$partial" | sha256sum --check - +mv -- "$partial" "$destination" diff --git a/scripts/wasm/test-downloads.py b/scripts/wasm/test-downloads.py new file mode 100644 index 00000000..6b0f7bc3 --- /dev/null +++ b/scripts/wasm/test-downloads.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Exercise archive downloads against a local HTTP server, without Emscripten.""" +import hashlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +import subprocess +from tempfile import TemporaryDirectory +from threading import Thread +import unittest + + +ARCHIVE = b"pinned dependency archive\n" +CHECKSUM = hashlib.sha256(ARCHIVE).hexdigest() +DOWNLOADER = Path(__file__).with_name("download.sh") + + +class DownloadHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.server.requests.append(self.path) + if self.path == "/missing": + self.send_error(404) + return + body = ARCHIVE if self.path == "/archive" else b"not an archive\n" + self.send_response(200) + self.send_header("Content-Length", str(len(body) + (10 if self.path == "/partial" else 0))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + + +class DownloadTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.server = ThreadingHTTPServer(("127.0.0.1", 0), DownloadHandler) + cls.thread = Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.thread.join() + + def setUp(self): + self.directory = TemporaryDirectory(prefix="intarna-download-") + self.addCleanup(self.directory.cleanup) + # Exercise quoted paths and the same .part/cache names used by builds. + self.destination = Path(self.directory.name) / "dependency archive.tar.gz" + self.server.requests = [] + + def download(self, path="/archive"): + url = f"http://127.0.0.1:{self.server.server_port}{path}" + return subprocess.run( + ["bash", str(DOWNLOADER), url, str(self.destination), CHECKSUM], + capture_output=True, text=True, timeout=30, + ) + + def test_download_and_reuse_verified_cache(self): + result = self.download() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.destination.read_bytes(), ARCHIVE) + self.assertFalse(Path(str(self.destination) + ".part").exists()) + # A valid cache must work even when the remote archive is unavailable. + result = self.download("/missing") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.server.requests, ["/archive"]) + + def test_replace_invalid_cache_and_stale_partial(self): + self.destination.write_bytes(b"old archive or cached HTTP error") + Path(str(self.destination) + ".part").write_bytes(b"interrupted download") + result = self.download() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.destination.read_bytes(), ARCHIVE) + self.assertFalse(Path(str(self.destination) + ".part").exists()) + self.assertEqual(self.server.requests, ["/archive"]) + + def test_failed_responses_are_not_cached_and_next_run_recovers(self): + for path in ("/invalid", "/missing", "/partial"): + with self.subTest(path=path): + result = self.download(path) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.destination.exists()) + self.assertFalse(Path(str(self.destination) + ".part").exists()) + result = self.download() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.destination.read_bytes(), ARCHIVE) + + +if __name__ == "__main__": + unittest.main() diff --git a/webinterface/docs/step1-wasm-compilation.md b/webinterface/docs/step1-wasm-compilation.md index 4fae4a7c..06aba756 100644 --- a/webinterface/docs/step1-wasm-compilation.md +++ b/webinterface/docs/step1-wasm-compilation.md @@ -19,7 +19,15 @@ node scripts/wasm/smoke-protocol.mjs `WASM_BUILD_DIR` changes the build directory; `JOBS` defaults to 2. Source archives are downloaded into `build-wasm/downloads` and verified by pinned -SHA-256 hashes on every invocation. The build directory is ignored. Delete +SHA-256 hashes on every invocation. ViennaRNA 2.7.0 uses the official GitHub +release asset, with its own pinned checksum; the university download endpoint +returned an HTTP 200 text response instead of the archive in PR #291's build. +`scripts/wasm/download.sh` removes invalid cached archives and checks new +downloads before moving them from `.part` into the cache. Failed transfers and +checksum mismatches leave no cached archive or partial file, so a subsequent +build can retry. Run `python3 scripts/wasm/test-downloads.py` to check download, +cache recovery and failure handling against a local HTTP server, without an SDK +or external network access. The build directory is ignored. Delete `build-wasm/deps` and `build-wasm/sources` for a fresh dependency build after changing dependency versions or compiler flags. Do not reuse this prefix with a different SDK. Native libraries are never accepted from system pkg-config paths. From 87e548c4cc97e4c2aca9ce433ea672873054364f Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Thu, 22 Oct 2026 15:30:13 +0200 Subject: [PATCH 12/31] fix(wasm): build against the latest stable ViennaRNA 2.x --- .github/workflows/build-wasm.yml | 11 ++- ChangeLog | 23 +++++ scripts/wasm/build.sh | 53 +++++++----- scripts/wasm/patch-vienna.py | 25 ------ scripts/wasm/smoke-protocol.mjs | 4 +- scripts/wasm/stage.sh | 28 +++++-- scripts/wasm/test-browser.py | 11 ++- scripts/wasm/test-vienna-release.py | 90 ++++++++++++++++++++ scripts/wasm/vienna-release.json | 5 ++ scripts/wasm/vienna-release.py | 93 +++++++++++++++++++++ scripts/wasm/write-build-info.mjs | 6 +- webinterface/docs/step1-wasm-compilation.md | 53 ++++++++---- 12 files changed, 327 insertions(+), 75 deletions(-) delete mode 100644 scripts/wasm/patch-vienna.py create mode 100644 scripts/wasm/test-vienna-release.py create mode 100644 scripts/wasm/vienna-release.json create mode 100644 scripts/wasm/vienna-release.py diff --git a/.github/workflows/build-wasm.yml b/.github/workflows/build-wasm.yml index c6baf0d0..aadec7f3 100644 --- a/.github/workflows/build-wasm.yml +++ b/.github/workflows/build-wasm.yml @@ -31,9 +31,18 @@ jobs: with: python-version: '3.12' - name: Test dependency download integrity and cache recovery - run: python scripts/wasm/test-downloads.py + run: | + python scripts/wasm/test-downloads.py + python scripts/wasm/test-vienna-release.py - name: Test input, worker lifecycle and CSV handling run: npm test --prefix webinterface + - name: Resolve newest stable ViennaRNA 2.x release and checksum + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + python scripts/wasm/vienna-release.py --latest > "$RUNNER_TEMP/vienna-release.json" + cat "$RUNNER_TEMP/vienna-release.json" + echo "WASM_VIENNA_RELEASE=$RUNNER_TEMP/vienna-release.json" >> "$GITHUB_ENV" - name: Cross-compile dependencies and IntaRNA run: bash scripts/wasm/build.sh - name: Check real WASM predictions against the native reference diff --git a/ChangeLog b/ChangeLog index 01272691..cafc21d7 100644 --- a/ChangeLog +++ b/ChangeLog @@ -84,6 +84,10 @@ ## Technical changes and Optimizations +- build WASM with ViennaRNA 2.7.2 locally and the newest stable 2.x in CI; + verify published archive checksums, isolate versioned caches and retain + selected dependency provenance and notices (PR #292 review) + - BUGFIX : restore WASM dependency downloads using the pinned official ViennaRNA release asset; verify downloads before caching and recover invalid caches (PR #291) @@ -249,6 +253,25 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261022 Alexander Mitrofanov + * scripts/wasm/{vienna-release.py,vienna-release.json}, build.sh + + Resolve the newest stable ViennaRNA >=2.7.2 and <3 from all official GitHub + release pages in CI; require its release tarball and published SHA-256. + * Pin 2.7.2 for reproducible local builds and accept saved release manifests. + Isolate RNAlib sources/installations and IntaRNA objects by archive identity + to prevent reuse of libraries or headers from another release (PR #292). + * scripts/wasm/patch-vienna.py + - Remove the dlib 19.24 compatibility patch; ViennaRNA 2.7.2 bundles dlib 20.0. + * scripts/wasm/{write-build-info.mjs,stage.sh,smoke-protocol.mjs,test-browser.py} + * Record the selected archive URL/checksum, check the compiled ViennaRNA + version, stage matching dependency notices and test actual version exports. + * scripts/wasm/test-vienna-release.py, .github/workflows/build-wasm.yml + + Cover numeric version selection, pagination, major-3/prerelease exclusion, + missing/invalid asset metadata, API failures and manifest validation. + * webinterface/docs/step1-wasm-compilation.md + * Document latest-2.x CI selection, pinned local reproduction, versioned + caches, current bundled dlib and archive provenance. + 261022 Alexander Mitrofanov * scripts/wasm/{build,download}.sh * Use the official GitHub ViennaRNA 2.7.0 release asset and pin its SHA-256; diff --git a/scripts/wasm/build.sh b/scripts/wasm/build.sh index 60140119..1ca91ac0 100644 --- a/scripts/wasm/build.sh +++ b/scripts/wasm/build.sh @@ -7,7 +7,7 @@ mkdir -p "$build" build=$(cd "$build" && pwd) prefix="$build/deps" jobs=${JOBS:-2} -for tool in emcc em++ emar emranlib emconfigure emmake curl pkg-config node; do +for tool in emcc em++ emar emranlib emconfigure emmake curl pkg-config node python3; do command -v "$tool" >/dev/null || { echo "Missing tool: $tool" >&2; exit 1; } done mkdir -p "$build/downloads" "$build/sources" "$prefix" "$build/site/wasm" @@ -18,16 +18,27 @@ fetch https://zlib.net/fossils/zlib-1.3.1.tar.gz zlib-1.3.1.tar.gz \ 9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23 fetch https://archives.boost.io/release/1.87.0/source/boost_1_87_0.tar.bz2 boost_1_87_0.tar.bz2 \ af57be25cb4c4f4b413ed692fe378affb4352ea50fbe294a11ef548f4d527d89 -fetch https://github.com/ViennaRNA/ViennaRNA/releases/download/v2.7.0/ViennaRNA-2.7.0.tar.gz ViennaRNA-2.7.0.tar.gz \ - e2addf33e7c0dd4fff31d8e9a46fda8e9efcde0d199f789f20eb0a13b7f7135d +# CI supplies the newest stable 2.x manifest; local builds use the checked-in pin. +read -r vienna_version vienna_url vienna_sha256 < <( + python3 "$repo/scripts/wasm/vienna-release.py" --fields \ + --manifest "${WASM_VIENNA_RELEASE:-$repo/scripts/wasm/vienna-release.json}" +) +echo "Building ViennaRNA $vienna_version (SHA-256 $vienna_sha256)" +fetch "$vienna_url" "ViennaRNA-$vienna_version.tar.gz" "$vienna_sha256" +# Separate source, installed RNAlib and IntaRNA objects by archive identity so +# changing the release cannot reuse headers, libraries or objects from an old one. +vienna_id="vienna-$vienna_version-$vienna_sha256" +vienna_prefix="$prefix/$vienna_id" +vienna_sources="$build/sources/$vienna_id" +intarna_build="$build/intarna-$vienna_id" # Keep pkg-config searches inside the WASM prefix, including configure probes. -export PKG_CONFIG_LIBDIR="$prefix/lib/pkgconfig" +export PKG_CONFIG_LIBDIR="$vienna_prefix/lib/pkgconfig:$prefix/lib/pkgconfig" export PKG_CONFIG_PATH="$PKG_CONFIG_LIBDIR" export CFLAGS='-O3 -fexceptions' export CXXFLAGS='-O3 -fexceptions' -export CPPFLAGS="-I$prefix/include" -export LDFLAGS="-L$prefix/lib -fexceptions" +export CPPFLAGS="-I$vienna_prefix/include -I$prefix/include" +export LDFLAGS="-L$vienna_prefix/lib -L$prefix/lib -fexceptions" if [[ ! -f "$prefix/lib/libz.a" ]]; then tar -xzf "$build/downloads/zlib-1.3.1.tar.gz" -C "$build/sources" (cd "$build/sources/zlib-1.3.1" @@ -47,27 +58,26 @@ if [[ ! -f "$prefix/.boost-1.87.0-js-exceptions-complete" ]]; then -sZLIB_LIBPATH="$prefix/lib" --prefix="$prefix" install) touch "$prefix/.boost-1.87.0-js-exceptions-complete" fi -if [[ ! -f "$prefix/lib/libRNA.a" ]]; then - tar -xzf "$build/downloads/ViennaRNA-2.7.0.tar.gz" -C "$build/sources" - (cd "$build/sources/ViennaRNA-2.7.0" - CPPFLAGS="$CPPFLAGS -I$PWD/src" emconfigure ./configure --host=wasm32-unknown-emscripten --prefix="$prefix" \ +if [[ ! -f "$vienna_prefix/.complete" ]]; then + mkdir -p "$vienna_sources" + tar -xzf "$build/downloads/ViennaRNA-$vienna_version.tar.gz" -C "$vienna_sources" + (cd "$vienna_sources/ViennaRNA-$vienna_version" + CPPFLAGS="$CPPFLAGS -I$PWD/src" emconfigure ./configure --host=wasm32-unknown-emscripten --prefix="$vienna_prefix" \ --disable-shared --enable-static --disable-openmp --disable-pthreads \ --disable-lto --disable-simd --disable-vectorize --disable-mpfr \ --without-svm --without-gsl --without-swig --without-perl --without-python \ --without-doc --without-cla --without-check --without-kinfold \ --without-forester --without-rnalocmin --without-rnaxplorer - # Backport dlib's upstream char32_t typedef for modern libc++ char_traits. - # Only the downloaded dependency is patched; RNAlib algorithms are unchanged. - python3 "$repo/scripts/wasm/patch-vienna.py" "$PWD" emmake make -C src/ViennaRNA -j "$jobs" emmake make -C src/ViennaRNA install emmake make install-pkgconfigDATA) + touch "$vienna_prefix/.complete" fi # Legacy Autoconf probes declare exit()/inflateEnd() with the wrong signatures, # which wasm-ld rejects. Verify real dependency APIs before caching those probes. -em++ -std=c++23 -O2 -fexceptions -I"$prefix/include" \ - "$repo/scripts/wasm/dependency-probe.cpp" -L"$prefix/lib" \ +em++ -std=c++23 -O2 -fexceptions -I"$vienna_prefix/include" -I"$prefix/include" \ + "$repo/scripts/wasm/dependency-probe.cpp" -L"$vienna_prefix/lib" -L"$prefix/lib" \ -lboost_filesystem -lboost_system -lboost_program_options -lboost_regex \ -lboost_iostreams -lboost_serialization -lRNA -lz -o "$build/dependency-probe.js" node "$build/dependency-probe.js" @@ -78,15 +88,15 @@ export ac_cv_lib_boost_regex_exit=yes export ac_cv_lib_boost_iostreams_exit=yes export ac_cv_lib_z_inflateEnd=yes # RNAlib's .pc embeds a native-only -Wl,-fno-lto; this is a static WASM build. -export VRNA_LIBS="-L$prefix/lib -lRNA -lm" +export VRNA_LIBS="-L$vienna_prefix/lib -lRNA -lm" # Use the native Autotools source lists and CLI entry point, with a JS output. (cd "$repo" && bash autotools-init.sh) -mkdir -p "$build/intarna" -(cd "$build/intarna" - CXXFLAGS="$CXXFLAGS -Werror=inconsistent-missing-override" CPPFLAGS="-I$build/intarna/src -I$repo/src $CPPFLAGS" emconfigure "$repo/configure" --host=wasm32-unknown-emscripten \ +mkdir -p "$intarna_build" +(cd "$intarna_build" + CXXFLAGS="$CXXFLAGS -Werror=inconsistent-missing-override" CPPFLAGS="-I$intarna_build/src -I$repo/src $CPPFLAGS" emconfigure "$repo/configure" --host=wasm32-unknown-emscripten \ --disable-multithreading --disable-log-coloring --with-mdspan=kokkos \ - --with-vrna="$prefix" --with-boost="$prefix" \ + --with-vrna="$vienna_prefix" --with-boost="$prefix" \ --with-boost-libdir="$prefix/lib" --with-zlib="$prefix" emmake make -C src -j "$jobs" EXEEXT=.js \ LDFLAGS="$LDFLAGS -sMODULARIZE=1 -sEXPORT_ES6=1 -sEXPORT_NAME=createIntaRNA -sENVIRONMENT=web,worker,node -sINVOKE_RUN=0 -sEXIT_RUNTIME=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1073741824 -sSTACK_SIZE=8388608" @@ -96,5 +106,6 @@ mkdir -p "$build/intarna" printf '{"type":"module"}\n' > "$build/site/wasm/package.json" node "$repo/scripts/wasm/write-build-info.mjs" "$repo" "$build/site" \ "$(emcc -dumpversion)" "$(git -C "$repo" rev-parse HEAD)" \ - "$(git -C "$repo" status --porcelain --untracked-files=no)" + "$(git -C "$repo" status --porcelain --untracked-files=no)" \ + "$vienna_version" "$vienna_url" "$vienna_sha256" echo "WASM assets: $build/site/wasm" diff --git a/scripts/wasm/patch-vienna.py b/scripts/wasm/patch-vienna.py deleted file mode 100644 index 4b636aad..00000000 --- a/scripts/wasm/patch-vienna.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Apply narrow dlib 19.24 compatibility fixes in the downloaded ViennaRNA tree. - -Modern libc++ only defines char_traits for standard character types. dlib now -uses char32_t upstream. Its old serialization overloads and dependent template -call also need the matching C++ fixes. RNA models and recurrences are untouched. -""" -import sys -from pathlib import Path - -root = Path(sys.argv[1]) / "src/dlib-19.24/dlib" -patches = [ - ("unicode/unicode.h", "typedef uint32 unichar;", "typedef char32_t unichar;"), - ("serialize.h", " USE_DEFAULT_INT_SERIALIZATION_FOR(wchar_t)", - " USE_DEFAULT_INT_SERIALIZATION_FOR(wchar_t)\n USE_DEFAULT_INT_SERIALIZATION_FOR(char32_t)"), - ("global_optimization/find_max_global.h", "::template go(std::forward(f),a)", - "::template go(std::forward(f),a)"), -] -for filename, before, after in patches: - path = root / filename - source = path.read_text() - if after in source: - continue - if source.count(before) != 1: - raise SystemExit(f"Unexpected dlib source: {path}; review compatibility patch") - path.write_text(source.replace(before, after)) diff --git a/scripts/wasm/smoke-protocol.mjs b/scripts/wasm/smoke-protocol.mjs index f168a7b1..ee27ad8b 100644 --- a/scripts/wasm/smoke-protocol.mjs +++ b/scripts/wasm/smoke-protocol.mjs @@ -1,5 +1,6 @@ // Integration coverage of browser argument translation against the real CLI. import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { DEFAULTS, EVALUATION_EXAMPLE, prepareRequest, makeArguments } from '../../webinterface/protocol.js'; @@ -8,6 +9,7 @@ import { parseResults, projectCsv } from '../../webinterface/results.js'; import { archiveFiles } from '../../webinterface/archive.js'; const asset = resolve(process.argv[2] || 'build-wasm/site/wasm/intarna.js'); const { default: createIntaRNA } = await import(pathToFileURL(asset)); +const compiledVersion = readFileSync(new URL('../build-info.txt', pathToFileURL(asset)), 'utf8'); async function run(input, format = 'C') { const request = prepareRequest({ ...DEFAULTS, ...input }); const diagnostics = []; @@ -54,7 +56,7 @@ async function replayArchive(input) { const outputs = fs.readdir('/output').filter((name) => name !== '.' && name !== '..').map((name) => ({ name, text: fs.readFile(`/output/${name}`, { encoding: 'utf8' }) })); const reports = [['normal', 'N'], ['detailed', 'D'], ['ensemble', 'E']].filter(([key]) => request.outputs.includes(key)); for (const [key, format] of reports) outputs.push({ name: `intarna-${key}.txt`, text: (await run(input, format)).text }); - const files = archiveFiles(request, { csv: original.text, files: outputs }, 'IntaRNA 3.4.1\nViennaRNA 2.7.0\n'); + const files = archiveFiles(request, { csv: original.text, files: outputs }, compiledVersion); for (const config of ['parameters', ...reports.map(([key]) => key)]) { const diagnostics = []; const module = await createIntaRNA({ noInitialRun: true, locateFile: () => new URL('./intarna.wasm', pathToFileURL(asset)).href, print: (line) => diagnostics.push(line), printErr: (line) => diagnostics.push(line) }); diff --git a/scripts/wasm/stage.sh b/scripts/wasm/stage.sh index d123f2b1..20367c2e 100644 --- a/scripts/wasm/stage.sh +++ b/scripts/wasm/stage.sh @@ -6,6 +6,24 @@ build=${WASM_BUILD_DIR:-"$repo/build-wasm"} site="$build/site" test -s "$site/wasm/intarna.js" test -s "$site/wasm/intarna.wasm" +# Select notices from the archive used by these assets, including dlib upgrades. +read -r vienna_version vienna_sha256 dlib_directory < <(python3 - "$site" "$build" <<'PYSOURCE' +import json, re, sys +from pathlib import Path +site, build = map(Path, sys.argv[1:]) +metadata = json.loads((site / "build-info.json").read_text()) +archive = metadata["viennarnaArchive"] +version, digest = archive["version"], archive["sha256"] +if version != metadata["viennarna"] or not re.fullmatch(r"2\.\d+\.\d+", version) or not re.fullmatch(r"[0-9a-f]{64}", digest): + raise SystemExit("Invalid compiled ViennaRNA archive identity") +source = build / "sources" / f"vienna-{version}-{digest}" / f"ViennaRNA-{version}" +dlib = list((source / "src").glob("dlib-*/dlib/LICENSE.txt")) +if len(dlib) != 1 or not re.fullmatch(r"dlib-[0-9.]+", dlib[0].parent.parent.name): + raise SystemExit("Expected one bundled dlib license in the compiled ViennaRNA source") +print(version, digest, dlib[0].parent.parent.name) +PYSOURCE +) +vienna_source="$build/sources/vienna-$vienna_version-$vienna_sha256/ViennaRNA-$vienna_version" mkdir -p "$site/licenses" cp -R "$repo/webinterface/assets" "$site/" for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js options.js columns.js archive.js visualization.js; do @@ -18,20 +36,20 @@ cp "$repo/src/easylogging++.LICENCE.txt" "$site/licenses/easylogging.txt" cp "$repo/src/mdspan/LICENSE" "$site/licenses/mdspan.txt" cp "$build/sources/boost_1_87_0/LICENSE_1_0.txt" "$site/licenses/Boost.txt" cp "$build/sources/zlib-1.3.1/zlib.h" "$site/licenses/zlib.h" -cp "$build/sources/ViennaRNA-2.7.0/COPYING" "$site/licenses/ViennaRNA.txt" -cp "$build/sources/ViennaRNA-2.7.0/src/dlib-19.24/dlib/LICENSE.txt" "$site/licenses/dlib.txt" -cp "$build/sources/ViennaRNA-2.7.0/src/ViennaRNA/plotting/naview/naview.c" "$site/licenses/naview.c" +cp "$vienna_source/COPYING" "$site/licenses/ViennaRNA.txt" +cp "$vienna_source/src/$dlib_directory/dlib/LICENSE.txt" "$site/licenses/dlib.txt" +cp "$vienna_source/src/ViennaRNA/plotting/naview/naview.c" "$site/licenses/naview.c" emscripten_dir=$(dirname "$(command -v emcc)") cp "$emscripten_dir/LICENSE" "$site/licenses/Emscripten.txt" cp "$emscripten_dir/system/lib/libc/musl/COPYRIGHT" "$site/licenses/musl.txt" for library in libcxx libcxxabi libunwind compiler-rt; do cp "$emscripten_dir/system/lib/$library/LICENSE.TXT" "$site/licenses/$library.txt" done -cat > "$site/licenses/index.html" <<'HTML' +cat > "$site/licenses/index.html" <IntaRNA licenses

    IntaRNA and third-party notices

    IntaRNA: Bioinformatics Group of Prof. Backofen, University of Freiburg.
    ViennaRNA: authors and Institute for Theoretical Chemistry, University of Vienna.

    -

    This distribution uses ViennaRNA 2.7.0, Boost 1.87.0, zlib 1.3.1, bundled dlib 19.24 (with Unicode/template compatibility fixes), Kokkos mdspan, Easylogging++ and the Emscripten 4.0.23 runtime.

    +

    This distribution uses ViennaRNA $vienna_version, Boost 1.87.0, zlib 1.3.1, bundled dlib ${dlib_directory#dlib-}, Kokkos mdspan, Easylogging++ and the Emscripten 4.0.23 runtime.