diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..cb6403b9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Keep the third-party parser separate in reviews and preserve upstream bytes. +src/bin/nlohmann/json.hpp linguist-vendored +src/bin/nlohmann/LICENSE.MIT linguist-vendored -whitespace diff --git a/.github/workflows/build-wasm.yml b/.github/workflows/build-wasm.yml index aadec7f3..8befa501 100644 --- a/.github/workflows/build-wasm.yml +++ b/.github/workflows/build-wasm.yml @@ -35,7 +35,7 @@ jobs: 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 + run: npm test --prefix gui - name: Resolve newest stable ViennaRNA 2.x release and checksum env: GITHUB_TOKEN: ${{ github.token }} @@ -68,7 +68,7 @@ jobs: python scripts/wasm/test-browser.py http://127.0.0.1:8765/site/ --browser firefox - uses: actions/upload-artifact@v4 with: - name: intarna-webinterface + name: intarna-gui path: build-wasm/site if-no-files-found: error @@ -82,14 +82,14 @@ jobs: path: _site - uses: actions/download-artifact@v4 with: - name: intarna-webinterface - path: _site/webinterface + name: intarna-gui + path: _site/gui - 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 + test -s _site/gui/index.html + test -s _site/gui/wasm/intarna.wasm - uses: actions/upload-artifact@v4 with: name: intarna-pages-preview diff --git a/.gitignore b/.gitignore index aecb0872..f09485a9 100644 --- a/.gitignore +++ b/.gitignore @@ -85,7 +85,7 @@ python/intarnapvalue.* # Browser build outputs and local test dependencies /build-wasm/ -/webinterface/wasm/ -/webinterface/node_modules/ +/gui/wasm/ +/gui/node_modules/ /scripts/wasm/__pycache__/ diff --git a/ChangeLog b/ChangeLog index 02b0b5f7..c204105e 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,35 @@ ## Interface and handling +- reorganize GUI parameters, add conditional seed/accessibility controls and + grouped CSV defaults, expand FASTA limits and refine result headers (PR #299) + +- retain computed GUI runs as named, foldable sessions with JSON input restoration, + independent outputs, per-session ZIP export and deletion (PR #297 review) + +- permit clearing inherited CSV sorting when replaying JSON as text reports; + validate JSON/GUI behavior in native release/debug and real browsers (#287) + +- show prediction/evaluation tasks as always-visible choices with task-specific + descriptions and an expanded Seeds/Accessibility heading (PR #287 review) + +- upload or drop local FASTA files into each GUI strand with validation and + protection against stale asynchronous reads (PR #287 review) + +- clear all inputs on GUI reset and load named examples from maintainable JSON + files through an Example menu (PR #287 review) + +- load/store GUI settings as portable JSON; ZIPs contain parameter.json with + embedded inputs/provenance and all outputs (PR #287 review) + +- use one embedded-input JSON parameter document for each WASM invocation + (PR #287 review) + +- accept JSON parameter files with embedded text inputs and personality defaults + through --parameterFile while retaining legacy configuration (PR #287 review) + +- rename the browser interface directory and Pages URL to gui (PR #287 review) + - 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) @@ -84,6 +113,9 @@ ## Technical changes and Optimizations +- record JSON/WASM/browser regression validation and preserve pinned JSON vendor + checksums while marking upstream files as vendored for review (PR #287) + - 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) @@ -253,6 +285,115 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261024 Alexander Mitrofanov + * gui/{app,options,columns}.js, gui/{index.html,styles.css} + * organize tabs/rows as Sequences, Seed, Output, Model & Energy, Query, + Target and CLI-only; align wrapped labels and place Compute first + + add a three-way seed selector, synchronized accessibility flags and + mode-dependent input availability; place SHAPE beside accessibility + * group compact CSV choices with descriptions and group selection actions; + default to 10 predictions and expanded task-specific energy columns, + explicitly preserving browser defaults in native-compatible JSON + * gui/{protocol,uploads,parameter-file}.js, gui/examples/ + * accept 50 FASTA records per strand of 2-5,000 nt each, with longest + selected query x longest selected target <= 1,000,000; allow 1 MB FASTA + + warn about ignored FASTA header descriptions while using first-token IDs + * gui/{sessions,results}.js + * select results from the header/name, order Show/Hide before the name and + ZIP/delete actions, compact table headings and distinguish selected rows + * gui/tests/, scripts/wasm/test-browser.py, README.md, gui/docs/ + + cover limits, native JSON replay/defaults, dependent controls, grouped CSV + choices, desktop/mobile alignment and result header selection + * address https://github.com/BackofenLab/IntaRNA/pull/299#issuecomment-6375752155 + +261023 Alexander Mitrofanov + * gui/{app,sessions,results,visualization}.js, gui/{index.html,styles.css} + + retain successful runs in browser memory with unique IDs, editable names, + independent results/diagnostics and JSON snapshots of their computed inputs + * restore inputs and fold other output areas on selection; preserve sessions + across edits, reset, imports, failures and aborts; unload folded viewers + + export each session's original JSON/outputs from its header and delete it + without overwriting editable inputs; keep controls keyboard accessible + * scripts/wasm/{stage.sh,test-browser.py}, README.md, gui/docs/ + * stage the sessions module, exercise session lifetime and archive isolation + in real browsers, and document temporary storage and updated reset behavior + * follow-up to https://github.com/BackofenLab/IntaRNA/pull/297#issuecomment-6367326708 + +261023 Alexander Mitrofanov + * .gitattributes, gui/docs/json-workflow.md + + Preserve exact upstream license whitespace and mark the JSON header and + license as vendored. Record native release/debug, WASM replay, browser, + distribution and provenance validation for the PR #287 revisions. + +261023 Alexander Mitrofanov + * src/bin/CommandLineParsing.cpp, ParameterFile.h, tests/testParameterFile.py + * Accept an explicitly empty CSV sort value to clear a parameter-file + setting when reproducing non-CSV reports from the same JSON document. + * gui/app.js, gui/styles.css, scripts/wasm/test-browser.py + * Keep pending auxiliary-file reads from restoring reset inputs, return + Reset to Sequence input and make the entire task label clickable. + * doc/{Makefile.am,json-parameters.md} + + Include JSON format documentation in source distributions and record the + observed WASM footprint and reproducible dependency comparison (#287). + +261023 Alexander Mitrofanov + * gui/{index.html,app.js,styles.css}, scripts/wasm/test-browser.py + * Replace the task dropdown with visible, highlighted native radio choices + and task descriptions; update the heading to include Seeds/Accessibility. + * Keep Example, Reset, Load and Store adjacent and test keyboard selection + and responsive layout (PR #287 review). + +261023 Alexander Mitrofanov + * gui/{uploads.js,app.js,index.html,styles.css}, gui/docs/json-workflow.md + + Add Upload controls and file-drop hints to both FASTA headers; validate + file size/contents before replacing a strand and invalidate stale reads. + * gui/tests/uploads.test.mjs, scripts/wasm/{test-browser.py,stage.sh} + + Test local upload/drop, invalid files and reset during a pending read; + stage the file-input module (PR #287 review). + +261023 Alexander Mitrofanov + * gui/{app.js,index.html,styles.css,examples/}, gui/docs/json-workflow.md + * Reset clears inputs/results and restores defaults; example FASTA is shown + only as placeholders. Add a described Example dropdown beside Reset. + + Ship two editable JSON examples and a manifest, using the common importer. + * gui/tests/parameter-file.test.mjs, scripts/wasm/{stage.sh,test-browser.py} + + Validate/stage example files and check empty startup/reset behavior (#287). + +261023 Alexander Mitrofanov + * gui/parameter-file.js, archive.js, app.js, index.html, protocol.js + + Load and Store non-default settings and embedded inputs using native JSON; + validate imports before replacing the form and retain CSV selection order. + * ZIP exports contain only parameter.json and outputs. Record version and + native replay commands, including overrides for additional report formats. + * gui/tests/, scripts/wasm/{smoke-protocol.mjs,test-browser.py,stage.sh}, + gui/docs/{json-workflow.md,step4-results-and-deployment.md} + + Test JSON round trips, invalid imports, real WASM replay and output equality; + stage the serializer and nlohmann/json license (PR #287 review). + +261023 Alexander Mitrofanov + * gui/protocol.js, gui/intarna-worker.js, gui/tests/protocol.test.mjs, + gui/docs/step2-worker-architecture.md + * Pass --parameterFile=/parameter.json to each WASM instance using the + native JSON format with embedded FASTA, SHAPE and accessibility text. + Preserve native output routing and independent report invocations (#287). + +261023 Alexander Mitrofanov + * src/bin/ParameterFile.{h,cpp}, CommandLineParsing.{h,cpp}, Makefile.am + + Detect JSON parameter files; reuse native CLI validation and precedence, + support embedded input text and retain files until computation finishes. + * Honor parameter-file personalities when selecting native defaults. + * src/bin/nlohmann/, doc/json-parameters.md, README.md, scripts/wasm/stage.sh + + Vendor nlohmann/json 3.12.0 with its license, document the schema, + dependency tradeoff and reproduction contract (PR #287 review). + * tests/runParameterFile.sh, tests/testParameterFile.py, tests/Makefile.am + + Check legacy/JSON equivalence, embedded inputs, precedence and errors. + +261023 Alexander Mitrofanov + * gui/, scripts/wasm/, .github/workflows/build-wasm.yml, README.md, + doc/api-documentation.md, .gitignore + * Rename webinterface to gui, including imports, staging, tests and the + combined Pages artifact at /IntaRNA/gui/ (PR #287 review). + 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 diff --git a/README.md b/README.md index 3fd6a84d..ce94edbd 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,12 @@ **Efficient RNA-RNA interaction prediction incorporating accessibility and 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). +Predict short RNA pairs or evaluate supplied structures locally with the [browser interface](https://backofenlab.github.io/IntaRNA/gui/). +The browser accepts up to 50 records per strand, 2–5,000 nucleotides each, with longest selected query × longest selected target at most 1,000,000. Prediction defaults to 10 interactions per pair and includes opening and seed energies; evaluation selects all energy columns. +Computed runs stay in separate, foldable sessions under Results in the current tab. Name and select sessions to restore their inputs and compare results; editing or resetting parameters keeps earlier runs. +Select an interaction to view it in vaRRI, or download a session’s ZIP with inputs, compiled versions, reproducible parameters and all outputs. Sessions are temporary and are cleared when the page is reloaded. +See the [WebAssembly build instructions](gui/docs/step1-wasm-compilation.md) and +[interface/deployment notes](gui/docs/step4-results-and-deployment.md). During the last few years, several new small regulatory RNAs @@ -30,7 +32,7 @@ as well as the existence of a user-definable seed interaction. We successfully a IntaRNA to the prediction of bacterial sRNA targets and determined the exact locations of the interactions with a higher accuracy than competing programs. -For testing or ad hoc use of IntaRNA, you can use its webinterface at the +For testing or ad hoc use of IntaRNA, you can use its gui at the **==> [Freiburg RNA tools IntaRNA webserver](http://rna.informatik.uni-freiburg.de/IntaRNA/) <==** @@ -504,6 +506,11 @@ using `configure --disable-multithreading`.
+JSON parameter files, including embedded FASTA, accessibility, SHAPE and energy +parameter text, are also accepted by `--parameterFile` with automatic format +detection. See [JSON format, examples and precedence](doc/json-parameters.md). + + ## Load arguments from file If you are using IntaRNA with similar command line arguments (parameters), you diff --git a/doc/Makefile.am b/doc/Makefile.am index 25910558..cf899e71 100644 --- a/doc/Makefile.am +++ b/doc/Makefile.am @@ -6,6 +6,7 @@ EXTRA_DIST = \ conda.txt \ api-documentation.md \ + json-parameters.md \ build-api.sh \ check-api.py \ doxygen.cfg \ diff --git a/doc/api-documentation.md b/doc/api-documentation.md index 3a9dc2a2..cd9ad808 100644 --- a/doc/api-documentation.md +++ b/doc/api-documentation.md @@ -45,7 +45,7 @@ manually to inspect documentation artifacts. The documentation build uploads under `api/`. `build-wasm.yml` combines that artifact with the tested browser interface under -`webinterface/` and publishes **one** Pages artifact. This preserves the existing +`gui/` 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 @@ -53,7 +53,7 @@ and OIDC write permissions, and deployments share one concurrency group. - User guide: - Development API: -- Browser interface: +- Browser interface: ### Pages configuration @@ -67,5 +67,5 @@ change live publishing settings. See GitHub's 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) +See [the browser deployment notes](../gui/docs/step4-results-and-deployment.md) for the complete build and validation sequence. diff --git a/doc/json-parameters.md b/doc/json-parameters.md new file mode 100644 index 00000000..2a694d12 --- /dev/null +++ b/doc/json-parameters.md @@ -0,0 +1,79 @@ +# JSON parameter files + +`IntaRNA --parameterFile=parameter.json` accepts the same CLI option names and +values as a legacy `key = value` file. Detection uses the first non-whitespace +character, after an optional UTF-8 BOM, independent of the filename suffix. +Legacy files remain supported. Explicit scalar CLI options override file values; +composing options such as `out` accumulate as before. A file's `personality` +selects defaults before parsing; an explicit CLI personality takes precedence. + +```json +{ + "schema": "IntaRNA-parameters-v1", + "_comment": ["Reproduce: IntaRNA --parameterFile=parameter.json"], + "parameters": { + "query": {"text": ">query\nGGGAGGAGGG\n"}, + "target": {"text": ">target\nCCCUCCUCCC\n"}, + "temperature": 25, + "outMode": "C", + "out": ["interactions.csv", "qPu:query-pu.txt"] + } +} +``` + +`parameters` contains strings, JSON numbers and booleans. Repeated/composing +options accept nonempty arrays. Flags without values accept `true` or `false`. +Unknown options, duplicate keys, null values, unsupported nested values and +recursive `parameterFile` are errors. Existing native type, range and cross-option +validation still applies. JSON uses normal JSON escapes; `#`, whitespace and +newlines in string values are not interpreted as legacy configuration syntax. + +The optional `_comment` field holds provenance and reproduction instructions as +ordinary JSON data, so files work with standard JSON parsers. Optional `gui` +metadata describes browser-only output selections and is ignored by the CLI. +Other document fields and unsupported schema versions are rejected. + +## Embedded inputs + +For `query`, `target`, `qAccFile`, `tAccFile`, `qShape`, `tShape` and `energyVRNA`, +use `{"text": "file contents"}` to embed UTF-8 text. FASTA may contain multiple +records. Accessibility uses RNAplfold text; SHAPE and energy parameters retain +their native formats. Ordinary strings retain their original meaning (sequence, +preset or filesystem path). Relative paths remain relative to the working +directory, as with legacy configuration files. Binary `.agz` caches can still be +referenced by path; they are not a text encoding. + +For accessibility of multiple selected records, `{"texts": ["record 1 data", +"record 2 data"]}` materializes files with the existing `-sN` suffixes, indexed by +original FASTA record number. Use `text` when selecting a single record. No +user-specified paths are used for embedded files. A private temporary directory +keeps generated files alive for the entire prediction and removes them on normal +exit or exception unwinding. WASM uses its private in-memory filesystem. Like +other temporary resources, abrupt process termination can leave native files. + +## Dependency choice + +We evaluated [Boost.JSON](https://www.boost.org/doc/libs/1_87_0/libs/json/doc/html/json/overview.html) +and [nlohmann/json](https://json.nlohmann.me/integration/). Both provide typed JSON +and can avoid a separately linked JSON library. Boost.JSON would raise our +current Boost 1.50 minimum to a release containing JSON, or require another +vendored Boost component and its dependency integration. Boost.PropertyTree is +already available but loses JSON scalar/array type distinctions needed here. + +We chose the unmodified, MIT-licensed nlohmann/json 3.12.0 single header (953,436 +bytes), compiled in one CLI translation unit. It adds no system package, linker +flag, public library API or WASM build dependency. The tradeoff is a vendored +header to maintain and extra compile/binary size; the parser is outside prediction +loops. Checksums and update instructions live in `src/bin/nlohmann/README.md`. +The source distribution and staged GUI include the license. + +### Observed WASM footprint + +With Emscripten 4.0.23, ViennaRNA 2.7.2 and Boost 1.87.0 using +`scripts/wasm/build.sh`, the cached pre-JSON engine at `d44d7a6` is 3,217,224 bytes +(781,737 bytes with Python `gzip.compress(..., mtime=0)`). The JSON-enabled engine +is 3,362,458 bytes (823,241 gzip): an increase of 145,234 bytes raw and 41,504 +compressed. These are local artifact-size observations with matching dependency +versions, not runtime benchmarks or guarantees for other toolchains. Reproduce +by building both revisions, comparing `site/wasm/intarna.wasm`, and checking +`site/build-info.json` for compiler/library provenance. diff --git a/gui/app.js b/gui/app.js new file mode 100644 index 00000000..446a7f60 --- /dev/null +++ b/gui/app.js @@ -0,0 +1,386 @@ +import { parseFasta, prepareRequest, CSV_COLUMNS } from './protocol.js'; +import { GROUPS, FIELDS, EXTRA_OUTPUTS, UI_DEFAULTS } from './options.js'; +import { CSV_CATALOG, CSV_GROUPS, defaultColumns } from './columns.js'; +import { PredictionRunner } from './runner.js'; +import { downloadText } from './results.js'; +import { SessionsView } from './sessions.js'; +import { importParameters, serializeParameters, MAX_PARAMETER_BYTES } from './parameter-file.js'; +import { readFastaFile } from './uploads.js'; + +const byId = (id) => document.getElementById(id); +const form = byId('prediction-form'), runner = new PredictionRunner(); +const getTask = () => form.querySelector('input[name=task]:checked').value; +const setTask = (task) => { for (const input of form.querySelectorAll('input[name=task]')) input.checked = input.value === task; }; +const dialog = byId('loading'), status = byId('result-status'), errorBox = byId('input-error'); +const sessions = new SessionsView(byId('sessions'), byId('session-template'), (request, name) => { + applyParameters(request, `${name} selected. Its computed inputs have been restored.`); +}, showInputError, (message) => { status.textContent = message; }); +const csvInputs = [], outputInputs = []; +let inputRevision = 0; +let csvOrder = CSV_COLUMNS.split(','); +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 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', 'Sequences'); +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); +}); +// Render each requested row explicitly; labels share a grid row so wrapped +// labels do not push neighboring controls out of alignment. +function renderField(field, id = field.key) { + const wrapper = element('div', '', `field${field.type === 'checkbox' ? ' check' : ''}`); + const label = element('label', field.label); label.htmlFor = id; label.title = `--${field.key}`; + const input = element(field.type === 'select' ? 'select' : field.type === 'textarea' ? 'textarea' : 'input'); input.id = id; + 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 = `${id}-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]; upload.value = ''; if (!file) return; + const revision = ++inputRevision; + try { + if (file.size > 1000000) throw new Error('Choose a text file of at most 1 MB.'); + const content = await file.text(); + if (inputRevision !== revision || input.disabled) return; + input.value = content; input.dispatchEvent(new Event('input', { bubbles: true })); + } catch (error) { if (inputRevision === revision) showInputError(error); } + }); wrapper.append(upload); + } + if (field.key === 'noSeed') wrapper.hidden = true; + return wrapper; +} +function fieldRow(body, fields, suffix = '') { + const grid = element('div', '', `field-grid columns-${fields.length}`); + for (const field of fields) grid.append(renderField(field, field.key + suffix)); + body.append(grid); return grid; +} +for (const definition of GROUPS) { + const body = group(definition.key, definition.label); + if (definition.key === 'seed-options') { + const wrapper = element('div', '', 'field seed-mode'); + const label = element('label', 'Seed constraint'); label.htmlFor = 'seed-mode'; + const select = element('select'); select.id = 'seed-mode'; + for (const [value, name] of [['none', 'Disable seed constraint'], ['explicit', 'Explicit seed'], ['identify', 'Identify seeds']]) { + const option = element('option', name); option.value = value; select.append(option); + } + select.value = 'identify'; wrapper.append(label, select); body.append(wrapper); + } + if (definition.rows) { + for (const row of definition.rows) fieldRow(body, row); + } else fieldRow(body, definition.fields); + if (definition.strand) { + const p = definition.strand; + const shared = FIELDS.filter((field) => ['accNoLP', 'accNoGUend'].includes(field.key)); + fieldRow(body, shared, p === 't' ? '-target' : ''); + body.append(element('p', 'These two accessibility settings apply to both strands.', 'input-help')); + const columns = element('div', '', 'accessibility-columns'); body.append(columns); + const accessibility = element('div'), shape = element('div'); columns.append(accessibility, shape); + const field = (key) => FIELDS.find((entry) => entry.key === p + key); + fieldRow(accessibility, [field('AccConstr')]); + fieldRow(accessibility, [field('AccFile')]); + fieldRow(shape, [field('Shape')]); + fieldRow(shape, [field('ShapeMethod'), field('ShapeConversion')]); + } +} +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. Hover over a column name for details. Select columns for the table and CSV download; table visibility can be changed after computation.', 'input-help')); +const selectionTools = element('div', '', 'selection-tools'); outputBody.append(selectionTools); +const selectColumns = (selection) => { + csvOrder = [...selection]; for (const input of csvInputs) input.checked = selection.includes(input.value); inputsChanged(); +}; +for (const [name, selection] of [['Select all', () => CSV_CATALOG.map((c) => c.key)], ['Task defaults', () => defaultColumns(getTask())], ['Clear selection', () => []]]) { + const button = element('button', name); button.type = 'button'; + button.addEventListener('click', () => selectColumns(selection())); selectionTools.append(button); +} +for (const group of CSV_GROUPS) { + const section = element('fieldset', '', 'csv-group'), legend = element('legend', group.label); + section.dataset.csvGroup = group.label; + for (const [name, checked] of [['Select all', true], ['Deselect all', false]]) { + const button = element('button', name, 'text-button'); button.type = 'button'; + button.setAttribute('aria-label', `${name} ${group.label} columns`); + button.addEventListener('click', () => { + for (const input of csvInputs.filter((input) => group.keys.includes(input.value))) input.checked = checked; + inputsChanged(); + }); legend.append(button); + } + const picker = element('div', '', 'checkbox-grid csv-columns'); section.append(legend, picker); outputBody.append(section); + for (const key of group.keys) { + const column = CSV_CATALOG.find((column) => column.key === key); + csvInputs.push(checkbox(picker, key, key, column.description)); + } +} +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', 'CLI-only'); +generalBody.append(element('p', '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); + +function inputsChanged(message = 'Inputs changed. Compute to update the interactions.') { + inputRevision++; + sessions.deselect(); + byId('diagnostics').hidden = true; byId('diagnostic-text').textContent = ''; status.textContent = message; +} +function updateLength(name) { + const warning = byId(`${name}-warning`); warning.hidden = true; warning.textContent = ''; + if (!byId(name).value.trim()) { byId(`${name}-length`).textContent = 'No sequence entered'; return; } + try { + const parsed = parseFasta(byId(name).value, name === 'query' ? 'Query' : 'Target', getTask() === 'prediction'); + warning.textContent = parsed.warnings.join(' '); warning.hidden = !parsed.warnings.length; + 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 updateAvailability() { + const evaluation = getTask() === '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 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 || field.key.startsWith('helix') && byId('model').value !== 'B' || field.key === 'kineticScore' && byId('mode').value !== 'K'; + } + } + byId('noSeed').checked = byId('seed-mode').value === 'none'; + for (const field of FIELDS.filter((field) => field.key.startsWith('seed'))) { + byId(field.key).disabled = evaluation || (field.key === 'seedTQ' ? byId('seed-mode').value !== 'explicit' : byId('seed-mode').value !== 'identify'); + } + for (const p of ['q', 't']) { + const mode = byId(`${p}Acc`).value; + for (const key of ['AccW', 'AccL', 'PfScale', 'AccConstr', 'AccFile', 'Shape', 'ShapeMethod', 'ShapeConversion']) { + byId(p + key).disabled = mode === 'N' || ['AccW', 'AccL'].includes(key) && mode !== 'C' || key === 'AccFile' && !['P', 'E'].includes(mode); + } + for (const key of ['accNoLP', 'accNoGUend']) byId(key + (p === 't' ? '-target' : '')).disabled = mode === 'N'; + } + // Upload controls follow the text area they populate, including async reads. + for (const upload of form.querySelectorAll('input[data-for]')) upload.disabled = byId(upload.dataset.for).disabled; + 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'); +} +function resetParameters({ clearInputs = false } = {}) { + if (clearInputs) { + setTask('prediction'); + byId('query').value = ''; byId('target').value = ''; + for (const input of form.querySelectorAll('input[type=file]')) input.value = ''; + byId('examples-menu').open = false; activateGroup('sequence-input'); + } + csvOrder = defaultColumns(getTask()); + byId('seed-mode').value = 'identify'; + for (const field of FIELDS) { const input = byId(field.key); if (field.type === 'checkbox') input.checked = UI_DEFAULTS[field.key]; else input.value = UI_DEFAULTS[field.key]; } + syncAccessibilityFlags(); + for (const input of csvInputs) input.checked = csvOrder.includes(input.value); + for (const input of outputInputs) input.checked = false; + byId('qIdxPos0').value = 1; byId('tIdxPos0').value = 1; byId('rri').value = ''; + updateAvailability(); errorBox.hidden = true; inputsChanged(clearInputs ? 'Inputs cleared. Enter sequences or choose an example.' : 'Parameters reset to task defaults. Compute to update the interactions.'); +} +byId('task').addEventListener('change', resetParameters); +byId('reset').addEventListener('click', () => resetParameters({ clearInputs: true })); +function syncAccessibilityFlags(source) { + for (const key of ['accNoLP', 'accNoGUend']) { + if (source?.id === key + '-target') byId(key).checked = source.checked; + byId(key + '-target').checked = byId(key).checked; + } +} +form.addEventListener('input', (event) => { + if (event.target.type === 'file') return; + syncAccessibilityFlags(event.target); + updateAvailability(); errorBox.hidden = true; inputsChanged(); +}); +function showInputError(error) { + errorBox.textContent = error.message; errorBox.hidden = false; +} +function applyParameters(request, message = 'Parameters loaded. Compute to analyze the inputs.') { + // Import validates every setting before this atomic UI update. + setTask(request.task); + resetParameters(); + for (const key of ['query', 'target', 'qIdxPos0', 'tIdxPos0', 'rri']) byId(key).value = request[key]; + for (const field of FIELDS) { + const value = request.parameters[field.key] ?? field.value; + if (field.type === 'checkbox') byId(field.key).checked = value; + else byId(field.key).value = value; + } + byId('seed-mode').value = request.parameters.noSeed ? 'none' : request.parameters.seedTQ ? 'explicit' : 'identify'; + syncAccessibilityFlags(); + csvOrder = [...request.csvColumns]; + for (const input of csvInputs) input.checked = request.csvColumns.includes(input.value); + for (const input of outputInputs) input.checked = request.outputs.includes(input.value); + updateAvailability(); activateGroup('sequence-input'); inputsChanged(message); +} +for (const strand of ['query', 'target']) { + const input = byId(strand), picker = byId(`${strand}-upload`); + const loadFasta = async (files) => { + if (byId('inputs').disabled) return; + const revision = ++inputRevision; + try { + if (files.length !== 1) throw new Error('Choose or drop one FASTA file per field.'); + const content = await readFastaFile(files[0], strand === 'query' ? 'Query' : 'Target', getTask()); + if (inputRevision !== revision) return; + input.value = content; + updateAvailability(); errorBox.hidden = true; + inputsChanged(`${strand === 'query' ? 'Query' : 'Target'} FASTA loaded. Compute to analyze the inputs.`); + } catch (error) { if (inputRevision === revision) showInputError(error); } + }; + byId(`${strand}-upload-button`).addEventListener('click', () => picker.click()); + picker.addEventListener('change', () => { const files = [...picker.files]; picker.value = ''; if (files.length) loadFasta(files); }); + input.addEventListener('dragover', (event) => { + if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; input.classList.add('drag-over'); } + }); + input.addEventListener('dragleave', () => input.classList.remove('drag-over')); + input.addEventListener('drop', (event) => { + input.classList.remove('drag-over'); + if (!event.dataTransfer.types.includes('Files')) return; + event.preventDefault(); loadFasta([...event.dataTransfer.files]); + }); +} +byId('load-parameters').addEventListener('click', () => byId('parameter-upload').click()); +byId('parameter-upload').addEventListener('change', async (event) => { + const file = event.target.files[0]; event.target.value = ''; if (!file) return; + const revision = ++inputRevision; + try { + if (file.size > MAX_PARAMETER_BYTES) throw new Error('Choose a JSON file of at most 5 MB.'); + const imported = importParameters(await file.text()); + if (inputRevision !== revision) return; + applyParameters(imported); + } catch (error) { if (inputRevision === revision) showInputError(error); } +}); +byId('store-parameters').addEventListener('click', async () => { + // Snapshot before waiting for build metadata so edits cannot change the download. + const request = currentRequest(); + try { downloadText('parameter.json', serializeParameters(request, await buildInfo)); } + catch (error) { showInputError(error); } +}); +async function loadExamples() { + const container = byId('example-options'); + try { + const response = await fetch(new URL('./examples/index.json', import.meta.url)); + if (!response.ok) throw new Error('Example list is unavailable.'); + const files = await response.json(); + if (!Array.isArray(files) || !files.length || files.some((name) => typeof name !== 'string' || !/^[a-z0-9-]+\.json$/.test(name))) throw new Error('Invalid example list.'); + const examples = await Promise.all(files.map(async (filename) => { + const response = await fetch(new URL(`./examples/${filename}`, import.meta.url)); + if (!response.ok) throw new Error('An example is unavailable.'); + const example = await response.json(); + if (typeof example.name !== 'string' || typeof example.description !== 'string') throw new Error('Invalid example description.'); + const request = importParameters(example.configuration); + prepareRequest(request); + return { ...example, request }; + })); + container.replaceChildren(); + for (const example of examples) { + const button = element('button'); button.type = 'button'; + button.append(element('strong', example.name), element('span', example.description)); + button.addEventListener('click', () => { + if (byId('inputs').disabled) return; + applyParameters(example.request, `${example.name} loaded. Compute to analyze the example.`); + byId('examples-menu').open = false; + }); + container.append(button); + } + } catch (error) { container.replaceChildren(element('p', `${error.message} Reload to try again.`, 'input-help')); } +} +loadExamples(); +byId('examples-menu').addEventListener('keydown', (event) => { + if (event.key === 'Escape') { byId('examples-menu').open = false; byId('examples-menu').querySelector('summary').focus(); } +}); +byId('abort').addEventListener('click', () => runner.abort()); +dialog.addEventListener('cancel', (event) => { event.preventDefault(); runner.abort(); }); +window.addEventListener('pagehide', () => runner.abort()); +function currentParameters() { + // Keep inactive edits in the form for toggling back, but exclude them from + // validation, saved JSON and native computation. Shared flags stay active + // whenever either strand uses accessibility. + return Object.fromEntries(FIELDS.map((field) => { + const input = byId(field.key); + const inactive = ['accNoLP', 'accNoGUend'].includes(field.key) + ? input.disabled && byId(field.key + '-target').disabled : input.disabled; + return [field.key, inactive ? field.value : field.type === 'checkbox' ? input.checked : input.value]; + })); +} +function currentRequest() { + const selected = csvInputs.filter((input) => input.checked).map((input) => input.value); + const columns = [...csvOrder.filter((key) => selected.includes(key)), ...selected.filter((key) => !csvOrder.includes(key))]; + return { + task: getTask(), query: byId('query').value, target: byId('target').value, + qIdxPos0: byId('qIdxPos0').value, tIdxPos0: byId('tIdxPos0').value, rri: byId('rri').value, + parameters: currentParameters(), + csvColumns: columns, outputs: outputInputs.filter((input) => input.checked && !input.disabled).map((input) => input.value), + }; +} +form.addEventListener('submit', async (event) => { + event.preventDefault(); + const request = currentRequest(); + errorBox.hidden = true; + let prepared; + try { + if (getTask() === 'prediction' && byId('seed-mode').value === 'explicit' && !request.parameters.seedTQ.trim()) throw new Error('Explicit seed: enter target&query dot-bar encodings.'); + prepared = prepareRequest(request); } catch (error) { errorBox.textContent = error.message; errorBox.hidden = false; return; } + inputsChanged('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; }); + // Keep the executed inputs and outputs together, independent of later edits. + const provenance = await buildInfo.catch(() => 'Compiled version information is unavailable.'); + status.textContent = sessions.add(prepared, result, provenance); + } catch (error) { + if (error.name === 'AbortError') status.textContent = 'Computation aborted. You can edit the sequences and try again.'; + else { status.textContent = 'Computation failed.'; errorBox.textContent = error.message; errorBox.hidden = false; byId('diagnostic-text').textContent = error.diagnostics || ''; byId('diagnostics').hidden = !error.diagnostics; } + } finally { + byId('inputs').disabled = false; updateAvailability(); byId('results').setAttribute('aria-busy', 'false'); dialog.close(); byId('compute').focus(); + } +}); +activateGroup('sequence-input'); +resetParameters({ clearInputs: true }); +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; } diff --git a/webinterface/archive.js b/gui/archive.js similarity index 60% rename from webinterface/archive.js rename to gui/archive.js index 63a510c9..8b1995ca 100644 --- a/webinterface/archive.js +++ b/gui/archive.js @@ -1,4 +1,4 @@ -import { makeArguments } from './protocol.js'; +import { serializeParameters } from './parameter-file.js'; import { projectCsv } from './results.js'; const encoder = new TextEncoder(); @@ -48,32 +48,11 @@ export function zipFiles(files) { } 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 }, + return [ + { name: 'parameter.json', text: serializeParameters({ ...request, qIdxPos0: request.parameters.qIdxPos0, tIdxPos0: request.parameters.tIdxPos0 }, 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) { diff --git a/webinterface/assets/README.md b/gui/assets/README.md similarity index 100% rename from webinterface/assets/README.md rename to gui/assets/README.md diff --git a/webinterface/assets/bioinf-fr-logo-blau.png b/gui/assets/bioinf-fr-logo-blau.png similarity index 100% rename from webinterface/assets/bioinf-fr-logo-blau.png rename to gui/assets/bioinf-fr-logo-blau.png diff --git a/webinterface/assets/logo-uni-freiburg.png b/gui/assets/logo-uni-freiburg.png similarity index 100% rename from webinterface/assets/logo-uni-freiburg.png rename to gui/assets/logo-uni-freiburg.png diff --git a/webinterface/columns.js b/gui/columns.js similarity index 83% rename from webinterface/columns.js rename to gui/columns.js index 6b42b93a..9c096021 100644 --- a/webinterface/columns.js +++ b/gui/columns.js @@ -313,3 +313,23 @@ export const CSV_CATALOG = [ "numeric": true } ]; + +// Every native column appears once. Ensemble energies and their components are +// grouped with probabilities so users can see the cost/meaning of those outputs. +export const CSV_GROUPS = [ + { label: 'Energy', keys: ['E', 'ED1', 'ED2', 'E_init', 'E_loops', 'E_dangleL', 'E_dangleR', 'E_endL', 'E_endR', 'E_hybrid', 'E_norm', 'E_hybridNorm', 'E_add', 'seedE', 'seedED1', 'seedED2'] }, + { label: 'Output', keys: ['subseqDP', 'subseqDB', 'hybridDP', 'hybridDB', 'hybridDPfull', 'hybridDBfull', 'bpList'] }, + { label: 'Sequence', keys: ['id1', 'id2', 'seq1', 'seq2', 'subseq1', 'subseq2'] }, + { label: 'Interaction', keys: ['start1', 'end1', 'start2', 'end2', 'seedStart1', 'seedEnd1', 'seedStart2', 'seedEnd2'] }, + { label: 'Partition Function', keys: ['Pu1', 'Pu2', 'seedPu1', 'seedPu2', 'w', 'Etotal', 'Eall', 'Eall1', 'Eall2', 'Zall', 'Zall1', 'Zall2', 'EallTotal', 'P_E', 'RT'] }, +]; + +export function defaultColumns(task) { + const context = ['id1', 'start1', 'end1', 'id2', 'start2', 'end2', 'subseqDP', 'hybridDP', 'E']; + // Seed energies remain available in evaluation, where native output is NAN. + // List them explicitly: seedEnd1/2 are coordinates, not energy columns. + const extra = task === 'evaluation' + ? [...CSV_CATALOG.filter(({ key }) => key.startsWith('E')).map(({ key }) => key), 'seedE', 'seedED1', 'seedED2'] + : ['ED1', 'ED2', 'seedE', 'seedStart1', 'seedStart2']; + return [...new Set([...context, ...extra])]; +} diff --git a/gui/docs/json-workflow.md b/gui/docs/json-workflow.md new file mode 100644 index 00000000..df5f7dfb --- /dev/null +++ b/gui/docs/json-workflow.md @@ -0,0 +1,149 @@ +# JSON settings and reproducible runs + +The action bar starts with Compute/Evaluate, followed by Input Parameters: +Examples, Store, Load and Reset. Store downloads +`parameter.json` containing non-default settings and embedded input text in the +[native JSON format](../../doc/json-parameters.md). `_comment` records compiled +tool/library versions and executable reproduction commands. It is JSON metadata, +not a nonstandard comment syntax. Settings can be saved before sequences are +complete; Compute still validates the complete job before loading WASM. + +Load validates the complete document before replacing the form, including the +task, signed start indices, input text, CSV selection/order and additional +outputs. Malformed documents and settings the browser cannot represent leave +the form unchanged with an error. Native-only settings and external file paths +must be used in the CLI; the browser requires embedded text. JSON uploads are +limited to 5 MB. The application never uploads these files to a server. + +ZIP uses the same serializer on the completed run, including normalized FASTA. +It contains **only** `parameter.json` and output files. Native replay uses: + +```sh +IntaRNA --parameterFile=parameter.json --out=intarna-interactions.csv +``` + +Additional report commands are listed in `_comment`. They override `outMode` +and CSV-only settings while retaining the same inputs and analysis settings. +Reports run independently, matching the worker. The WASM integration test +replays the JSON in fresh engines and compares every output byte. + +## Computed sessions + +Each successful computation, including a run with no interaction rows, creates a +new session with a unique ID within the page and a default name such as +`Prediction 1` or `Evaluation 2`. The session retains the computed inputs in JSON, +CSV output, additional files and diagnostics. Editing parameters, Load, examples, +Reset, invalid inputs, failed computations and cancellation preserve prior +sessions. Failed or aborted computations do not create a session. + +The newest session opens automatically under **Results**. Use **Show**, select +its header/name, or select its output area to restore that session’s +computed inputs and fold the other sessions. The task, sequence text, signed +indices, SHAPE/accessibility text, CSV selection/order and additional output +selection are restored together. **Hide** folds the output. Each session +keeps its own filter, sorting and visible columns; folded vaRRI frames are unloaded +and restored when their session is reopened. + +Edit **Session name** directly in the header. Names are plain text; an empty name +returns to the default. **Download ZIP** remains available while folded and always +archives that session’s original JSON and complete outputs, regardless of the +current form or table filters. The session name supplies a safe download filename; +ZIP members remain `parameter.json` and output files. The garbage-bin button +removes only its session and releases its output; it does not change form inputs. + +Sessions exist only in the current page’s browser memory. They are not written to +browser storage or uploaded. Reloading or closing the page discards them; use ZIP +exports to retain results. Memory use grows with the runs kept open, so delete +sessions that are no longer needed. Store continues to save the editable form. +If compiled metadata cannot be fetched, a completed run is still retained and its +JSON explicitly records that version information is unavailable. + +## Reset and examples + +Reset clears the editable sequences, interactions and uploaded files and restores +prediction defaults (10 interactions per pair, expanded energy/seed columns). +Evaluation defaults select all energy columns. These GUI defaults are written +explicitly in JSON when they differ from the CLI; importing existing JSON still +uses native defaults for omitted parameters. Inactive controls keep their edits +in the form for switching modes, but their values are omitted from executed and +stored settings. Previously computed sessions remain available. Empty FASTA +fields show the short demonstration sequences as placeholders only. The Examples +disclosure menu follows the Input Parameters label. + +Maintainers edit `gui/examples/index.json` (an array of local JSON filenames). +Each listed file contains `name`, `description`, and `configuration`, where the +configuration is the same native JSON document used by Load. Two replaceable +examples cover prediction and evaluation. Loading validates their settings and +sequences through the same importer and computation validator. Missing or invalid +examples show an error in the menu; they never overwrite the form. + +## Local FASTA files + +Each strand has an Upload button and accepts a text-file drop into its textarea. +Files are read locally (at most 1 MB), validated with the same FASTA checks as +manual entry, and replace only that strand after successful validation. Prediction +accepts up to 50 FASTA records per strand, each 2–5,000 nt; evaluation requires +one record. The longest selected query × longest selected target must be at most +1,000,000. Names end at the first whitespace; ignored header descriptions produce +a warning below the corresponding field. Both FASTA fields scroll horizontally +and vertically. Invalid files leave the +previous input intact. A later edit, reset, import or computation discards any +pending file read, so delayed reads cannot replace newer input. + +## Validation of the PR #287 revision (2026-10-23) + +- GCC 14 release and debug: `make tests -j2` passed all nine API/CLI groups, + including `runParameterFile.sh`. The existing out-of-tree CLI fixture harness + required source data files linked into each build's `tests/data` directory. +- `npm test --prefix gui`: all 18 tests passed. +- Emscripten 4.0.23 / ViennaRNA 2.7.2 / Boost 1.87.0: actual WASM build, + `node scripts/wasm/smoke.mjs` and `node scripts/wasm/smoke-protocol.mjs` passed. + Exported JSON reproduced selected CSV, signed/multiple sequences, additional + outputs and reports byte for byte in fresh engines. +- `scripts/wasm/test-browser.py` passed in Chromium and Firefox at a nested URL, + covering blank startup/reset, both JSON examples, Load/Store round trips, + FASTA file upload/drop, stale-read protection, task keyboard controls, JSON ZIP + contents, result exploration, cancellation/recovery and mobile overflow. + The desktop screenshot was inspected. +- Embedded ViennaRNA energy-parameter text matched native file input. Existing + dependency-download and ViennaRNA-release tests, shell syntax, workflow YAML, + source-distribution contents, vendor checksums and `git diff --check` passed. + +## Session follow-up validation (2026-10-23) + +- `npm test --prefix gui`: all 18 Node tests passed. +- `scripts/wasm/test-browser.py` passed in Chromium and Firefox with real WASM + at nested URLs. Session checks cover input/JSON restoration, task changes, + signed indices, SHAPE text, CSV order, reports, independent filters/columns, + edits/reset/imports, failed/aborted runs, keyboard selection, safe renaming, + folded-session archives, deletion, stale file reads and page reload. +- Desktop screenshots were inspected; session controls remain accessible at a + 390 px viewport without document overflow. +- `node scripts/wasm/smoke-protocol.mjs` passed native-reference checks and + byte-identical archive replay. Browser checks reused the parent branch’s + compiled Emscripten 4.0.23 / ViennaRNA 2.7.2 assets with their original metadata; + this follow-up changes no C++ code or worker protocol. +- A fresh GCC 14 release build passed `make tests -j2`: all nine native API/CLI + groups passed. Tracked fixtures were linked into the out-of-tree build’s + `tests/data` directory for the existing CLI harness. +- Static staging (including the new session module and engine digest checks), + shell syntax and `git diff --check` passed. + +## PR #299 review validation (2026-10-24) + +- `npm test --prefix gui`: all 20 tests passed, including FASTA limits, header + warnings, complete column grouping and explicit browser-default JSON replay. +- `scripts/wasm/test-browser.py` passed with real WASM in Chromium and Firefox. + It covers the reordered panels, aligned controls, shared accessibility flags, + seed/accessibility/helix modes, inactive values, explicit-seed Load/Store, + group selection, FASTA scrolling/warnings and result header/name selection, + plus the existing prediction/evaluation, ZIP, abort and session checks. +- Desktop and 390 px mobile screenshots were inspected. All seven panels have + no document overflow; table contents retain their own horizontal scrolling. +- `node scripts/wasm/smoke-protocol.mjs ` passed native-reference + checks and byte-identical JSON archive replay. The unchanged engine uses the + parent branch's Emscripten 4.0.23 / ViennaRNA 2.7.2 assets and original metadata. +- A fresh GCC 14 release build passed `make tests -j2`: all nine API/CLI groups. + Existing CLI fixtures were linked into the out-of-tree `tests/data` directory. +- Static staging with engine digest validation, JavaScript/Python syntax checks, + shell syntax and `git diff --check` passed. diff --git a/webinterface/docs/step1-wasm-compilation.md b/gui/docs/step1-wasm-compilation.md similarity index 100% rename from webinterface/docs/step1-wasm-compilation.md rename to gui/docs/step1-wasm-compilation.md diff --git a/webinterface/docs/step2-worker-architecture.md b/gui/docs/step2-worker-architecture.md similarity index 85% rename from webinterface/docs/step2-worker-architecture.md rename to gui/docs/step2-worker-architecture.md index 9617fbbd..018d3470 100644 --- a/webinterface/docs/step2-worker-architecture.md +++ b/gui/docs/step2-worker-architecture.md @@ -7,9 +7,10 @@ messages. `prepareRequest` validates both the UI and worker boundary; argument construction only accepts schema keys, enumerated output formats and fixed paths. 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`; +asset and creates an isolated `createIntaRNA` instance. It writes one `/parameter.json` document using the native +[JSON contract](../../doc/json-parameters.md), embedding normalized multi-FASTA +and SHAPE/accessibility text. Each invocation passes only +`--parameterFile=/parameter.json`. 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. @@ -30,5 +31,5 @@ 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 +Run `npm test --prefix gui`, 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/gui/docs/step3-frontend-ui.md similarity index 62% rename from webinterface/docs/step3-frontend-ui.md rename to gui/docs/step3-frontend-ui.md index c16dcbde..e0436b75 100644 --- a/webinterface/docs/step3-frontend-ui.md +++ b/gui/docs/step3-frontend-ui.md @@ -7,7 +7,7 @@ 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/`. +Their local copies and source attribution are in `gui/assets/`. The layout puts the scientific task first, then mutually exclusive parameter groups, followed by the compute button, results, additional files and versions. @@ -21,25 +21,26 @@ 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 +Group logo IntaRNA package / RNA–RNA interaction analysis University logo RNA–RNA interactions Computations stay on your device -Task: Prediction / Evaluation Load example -[ Sequence input | Query | Target | Seed | Helix | SHAPE | Energy | Output | General ] +Task: Prediction / Evaluation +[ Sequences | Seed | Output | Model & Energy | Query | Target | CLI-only ] [ Active panel: e.g. query + target, start indices, evaluation structures ] -Reset parameters Compute -Interactions / search / show-hide columns / CSV + ZIP download +Compute Input Parameters: Examples / Store / Load / Reset +Results / Show-Hide / name / ZIP download / delete +Interactions / search / show-hide columns / CSV download Selected interaction / embedded vaRRI Additional output files / diagnostics / compiled versions ``` -Every CLI group has a tab with one visible panel. Sequence input is selected +Every CLI group has a tab with one visible panel. Sequences 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. +evaluation; if the selected tab becomes unavailable, Sequences is selected. +Changing task resets parameters to their task defaults while preserving sequence text; Load example also restores the +complementary pair. The browser defaults to 10 interactions per pair (the native default remains +one), and accepts 0–100. Zero enables accessibility-only output. ## Inputs, defaults and validation @@ -53,15 +54,29 @@ 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. +excluded. The helix-block interaction model B is supported, with helix controls at the end +of Model & Energy, enabled only for model B. Seed constraint offers Disable, +Explicit seed and Identify seeds, enabling only the relevant controls. Query and +Target include their own accessibility/SHAPE columns and synchronized copies of +the two shared accessibility flags. Ignore disables accessibility controls; +import modes disable window/span and enable the data input. Disabled settings +remain editable when re-enabled but do not enter the request. Explicit row grids +align inputs even when their labels wrap. -Prediction accepts plain sequence or up to 20 FASTA records per strand. +CSV columns are grouped as Energy, Output, Sequence, Interaction and Partition +Function, with per-group selection actions and name-only labels with description +tooltips. Prediction adds ED1, ED2, seedE, seedStart1 and seedStart2 to the native +CSV columns; evaluation selects all energy outputs. JSON records these browser +defaults explicitly so native replay retains the same output. + +Prediction accepts plain sequence or up to 50 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 +are accepted. Each record is 2–5,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. +native CSV semicolon replacement) are rejected. Ignored header descriptions are reported below the input. The longest selected +query length × longest selected target length is capped at 1,000,000, including +pairwise mode; the cap is not summed over records. The per-strand start index applies to all records and defaults to one. Negative indices skip zero, matching `RnaSequence`; output coordinates remain inclusive. @@ -78,7 +93,7 @@ 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, +Edits update the form while completed sessions retain their inputs and results; +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/gui/docs/step4-results-and-deployment.md similarity index 85% rename from webinterface/docs/step4-results-and-deployment.md rename to gui/docs/step4-results-and-deployment.md index 8d2e3aaf..0d665211 100644 --- a/webinterface/docs/step4-results-and-deployment.md +++ b/gui/docs/step4-results-and-deployment.md @@ -30,8 +30,10 @@ 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. +Selection survives table sorting, filtering and visibility changes within each +session. Input edits, new runs, errors and cancellation preserve prior sessions +and their archive snapshots. Folding a session unloads its viewer; reopening +restores its selected interaction. `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 @@ -54,31 +56,23 @@ 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 +Download ZIP sits in each session header, including while its output is folded. +`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. +column visibility. Editing inputs preserves every completed snapshot. + +The archive contains only `parameter.json`, `intarna-interactions.csv`, and every +generated additional output with its original filename and contents. JSON embeds +normalized FASTA and any SHAPE/accessibility text, non-default settings, selected +CSV columns, compiled provenance and native replay commands in `_comment`. +Additional report commands override CSV-only settings and use another native +invocation, matching the worker. All JSON is valid with standard parsers. + +Load/Store use this same JSON format. Store snapshots the current editable form; +ZIP snapshots the completed run and ignores table filtering/sorting/visibility. +Store requires the compiled version file. Completed sessions still retain their +results if that file is unavailable, recording the missing version information +explicitly in JSON. See [the JSON workflow](json-workflow.md). ## Packaging and deployment @@ -87,7 +81,7 @@ 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 +npm test --prefix gui 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 @@ -115,7 +109,7 @@ the actual default branch, `master`. After JS tests, the real WASM smoke test an Chromium/Firefox integration tests pass, the package job combines: - Existing Jekyll guide at `/IntaRNA/` and API at `/IntaRNA/api/`. -- Tested application at `/IntaRNA/webinterface/`. +- Tested application at `/IntaRNA/gui/`. 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 @@ -146,7 +140,7 @@ and active C++, clean restart, empty predictions and asset-load recovery. reference reports E = -28.04, ED1 = 4.60, ED2 = 4.86 kcal/mol; the short example reports E = -21.40 with target/query coordinates 1–10 and ten suboptimals. Invalid CLI input returns a nonzero status through C++ error handling. -- `npm test --prefix webinterface`: all five input/lifecycle/CSV test groups pass. +- `npm test --prefix gui`: all five input/lifecycle/CSV test groups pass. - Playwright 1.64.0, Chromium and Firefox: full integration suite passes with the official-dependency engine, including a nested `/site/` URL as used by Pages, active-C++ cancellation, literal HTML-like identifiers and retry after failure. @@ -177,7 +171,7 @@ claim a live Pages deployment. 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. +- `npm test --prefix gui`: 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 diff --git a/gui/examples/evaluation.json b/gui/examples/evaluation.json new file mode 100644 index 00000000..84cab246 --- /dev/null +++ b/gui/examples/evaluation.json @@ -0,0 +1,18 @@ +{ + "name": "Evaluate a duplex", + "description": "Evaluate the energy of a supplied ten-base-pair RNA interaction.", + "configuration": { + "schema": "IntaRNA-parameters-v1", + "parameters": { + "personality": "IntaRNAeval", + "query": { + "text": ">query\nGGGAGGAGGG\n" + }, + "target": { + "text": ">target\nCCCUCCUCCC\n" + }, + "rri": "1||||||||||&1||||||||||", + "outCsvCols": "id1,start1,end1,id2,start2,end2,subseqDP,hybridDP,E,Etotal,ED1,ED2,E_init,E_loops,E_dangleL,E_dangleR,E_endL,E_endR,E_hybrid,E_norm,E_hybridNorm,E_add,Eall,Eall1,Eall2,EallTotal,seedE,seedED1,seedED2" + } + } +} diff --git a/gui/examples/index.json b/gui/examples/index.json new file mode 100644 index 00000000..947fd53d --- /dev/null +++ b/gui/examples/index.json @@ -0,0 +1 @@ +["prediction.json", "evaluation.json"] diff --git a/gui/examples/prediction.json b/gui/examples/prediction.json new file mode 100644 index 00000000..bd76029a --- /dev/null +++ b/gui/examples/prediction.json @@ -0,0 +1,17 @@ +{ + "name": "Short duplex", + "description": "Predict interactions between two complementary ten-nucleotide RNAs.", + "configuration": { + "schema": "IntaRNA-parameters-v1", + "parameters": { + "query": { + "text": ">query\nGGGAGGAGGG\n" + }, + "target": { + "text": ">target\nCCCUCCUCCC\n" + }, + "outNumber": 10, + "outCsvCols": "id1,start1,end1,id2,start2,end2,subseqDP,hybridDP,E,ED1,ED2,seedE,seedStart1,seedStart2" + } + } +} diff --git a/gui/index.html b/gui/index.html new file mode 100644 index 00000000..a77b74e7 --- /dev/null +++ b/gui/index.html @@ -0,0 +1,96 @@ + + + + + + + + IntaRNA · RNA–RNA interactions + + + + + + + +
+
+

RNA–RNA interactions considering Seeds and Accessibility

+

Computations stay on your device

+
+ +
+
+ Task, sequences and parameters +
+ Analysis task + + +
+

Predict interactions for all query–target sequence pairs.

+
+
+
+
+
+
5′ to 3′
or drop in the field
+ +
No sequence entered
+
+
5′ to 3′
or drop in the field
+ +
No sequence entered
+
+
+

Plain sequence or multi-FASTA (prediction only), A/C/G/U/T/N. Up to 50 records per field and 2–5,000 nt per record; longest selected query × longest selected target ≤ 1,000,000. FASTA names stop at the first whitespace. 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.

+ +
+
+
+
Input Parameters:
Examples

Loading examples…

+
+ +
+
+

Results

Enter your sequences or choose an example, then compute.

+

Completed runs stay in this tab until deleted or the page is reloaded. Select a session to restore its inputs; download its ZIP to keep a copy.

+
+ + +
+ +
+

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/gui/intarna-worker.js similarity index 81% rename from webinterface/intarna-worker.js rename to gui/intarna-worker.js index d54cb06c..09568516 100644 --- a/webinterface/intarna-worker.js +++ b/gui/intarna-worker.js @@ -1,4 +1,4 @@ -import { prepareRequest, makeArguments } from './protocol.js'; +import { prepareRequest, makeParameterDocument } from './protocol.js'; // Each task owns a worker; each CLI invocation owns a fresh WASM instance. let started = false; @@ -20,14 +20,10 @@ self.onmessage = async ({ data }) => { 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.writeFile('/parameter.json', JSON.stringify(makeParameterDocument(request, format))); 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)); + const code = module.callMain(['--parameterFile=/parameter.json']); if (code !== 0) throw new Error(`IntaRNA exited with status ${code}. Check the diagnostics and input settings.`); return module.FS; }; diff --git a/webinterface/mark.svg b/gui/mark.svg similarity index 100% rename from webinterface/mark.svg rename to gui/mark.svg diff --git a/gui/options.js b/gui/options.js new file mode 100644 index 00000000..4e95bccf --- /dev/null +++ b/gui/options.js @@ -0,0 +1,109 @@ +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' }), +]; +const fields = new Map([ + ...strand('q', 'Query'), ...strand('t', 'Target'), + flag('accNoLP', 'Exclude lonely pairs in accessibility'), + flag('accNoGUend', 'Exclude GU helix ends in accessibility'), + 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), + 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), + 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'), + 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), + 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.' }), + ...['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.' }, + ]), +].map((field) => [field.key, field])); + +// Explicit rows separate visual order from native parameter defaults. +const rows = (keys) => keys.map((row) => row.map((key) => fields.get(key))); +const strandRows = (p) => rows([ + [`${p}Set`, `${p}Region`, `${p}IntLenMax`, `${p}IntLoopMax`, `${p}RegionLenMax`], + [`${p}Acc`, `${p}AccW`, `${p}AccL`, `${p}PfScale`], +]); +const strandGroup = (p, label) => ({ + key: `${label.toLowerCase()}-options`, label, strand: p, + rows: strandRows(p), + fields: [...strandRows(p).flat(), ...rows([ + ...(p === 'q' ? [['accNoLP', 'accNoGUend']] : []), + [`${p}AccConstr`, `${p}AccFile`, `${p}Shape`, `${p}ShapeMethod`, `${p}ShapeConversion`], + ]).flat()], +}); +export const GROUPS = [ + { key: 'seed-options', label: 'Seed', prediction: true, rows: rows([ + ['noSeed'], ['seedTQ'], ['seedBP', 'seedMaxUP', 'seedQMaxUP', 'seedTMaxUP'], + ['seedMaxE', 'seedMinPu', 'seedMaxEhybrid'], ['seedQRange', 'seedTRange'], ['seedNoGU', 'seedNoGUend'], + ]) }, + { key: 'output-options', label: 'Output', fields: [...fields.values()].filter((field) => field.key.startsWith('out')) }, + { key: 'interaction-options', label: 'Model & Energy', rows: rows([ + ['model', 'mode'], ['energyVRNA', 'temperature', 'energyAdd'], ['energyNoDangles'], + ['windowWidth', 'windowOverlap'], ['kineticScore'], + ['helixMinBP', 'helixMaxBP', 'helixMaxIL', 'helixMinPu', 'helixMaxE'], ['helixFullE'], + ]) }, + strandGroup('q', 'Query'), strandGroup('t', 'Target'), +].map((group) => ({ ...group, fields: group.fields ?? group.rows.flat() })); +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 UI_DEFAULTS = Object.freeze({ ...PARAMETER_DEFAULTS, outNumber: 10 }); +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/package.json b/gui/package.json similarity index 78% rename from webinterface/package.json rename to gui/package.json index 81e5b5f8..307fd5df 100644 --- a/webinterface/package.json +++ b/gui/package.json @@ -1,5 +1,5 @@ { - "name": "intarna-webinterface", + "name": "intarna-gui", "private": true, "type": "module", "scripts": { "test": "node --test --test-isolation=none tests/*.test.mjs" } diff --git a/gui/parameter-file.js b/gui/parameter-file.js new file mode 100644 index 00000000..7e52f341 --- /dev/null +++ b/gui/parameter-file.js @@ -0,0 +1,116 @@ +import { JSON_SCHEMA, CSV_COLUMNS, LIMITS, normalizeParameters } from './protocol.js'; +import { FIELDS, PARAMETER_DEFAULTS, EXTRA_OUTPUTS } from './options.js'; +import { CSV_CATALOG } from './columns.js'; + +export const MAX_PARAMETER_BYTES = 5000000; +const reports = { normal: 'N', detailed: 'D', ensemble: 'E' }; +const embeddedKeys = ['qShape', 'tShape', 'qAccFile', 'tAccFile']; +const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); +function text(value, label, limit = 1000000) { + if (typeof value !== 'string' || value.length > limit || value.includes('\0')) throw new Error(`${label}: invalid or oversized text.`); + return value; +} +function selections(request) { + const columns = request.csvColumns ?? CSV_COLUMNS.split(','), outputs = request.outputs ?? []; + for (const [values, catalog, label] of [[columns, CSV_CATALOG, 'CSV columns'], [outputs, EXTRA_OUTPUTS.filter((entry) => request.task !== 'evaluation' || !entry.prediction), 'output files']]) { + if (!Array.isArray(values) || new Set(values).size !== values.length || values.some((key) => !catalog.some((entry) => entry.key === key))) throw new Error(`Choose available ${label}, without duplicates.`); + } + return { columns, outputs }; +} + +// Save editable settings, even before sequences have been entered. Computation +// performs full sequence-dependent validation later through prepareRequest. +export function exportParameters(request, provenance) { + if (!provenance?.trim()) throw new Error('Compiled version information is unavailable'); + const normalized = normalizeParameters(request); + const { columns, outputs } = selections(request); + const parameters = { + query: { text: text(typeof request.query === 'string' ? request.query : request.query.fasta, 'Query', LIMITS.maxFastaBytes) }, + target: { text: text(typeof request.target === 'string' ? request.target : request.target.fasta, 'Target', LIMITS.maxFastaBytes) }, + }; + if (request.task === 'evaluation') parameters.personality = 'IntaRNAeval'; + for (const [key, value] of Object.entries(normalized)) { + if (key === 'outSpotProbSpots' || value === '' || value === (PARAMETER_DEFAULTS[key] ?? 1)) continue; + parameters[key] = embeddedKeys.includes(key) ? { text: value } : value; + } + if (request.task === 'evaluation' && request.rri) parameters.rri = text(request.rri, 'Interactions', 10000); + parameters.outMode = 'C'; + if (columns.join(',') !== CSV_COLUMNS) parameters.outCsvCols = columns.join(','); + const nativeOutputs = outputs.filter((key) => !Object.hasOwn(reports, key)).map((key) => { + const spots = key === 'spotProb' && normalized.outSpotProbSpots ? `${normalized.outSpotProbSpots}:` : ''; + return `${key}:${spots}${key}${key === 'bpsvg' ? '.svg' : '.csv'}`; + }); + if (nativeOutputs.length) parameters.out = nativeOutputs; + const selectedReports = outputs.filter((key) => Object.hasOwn(reports, key)); + const comments = [ + ...provenance.trimEnd().split(/\r?\n/), + 'Reproduce: IntaRNA --parameterFile=parameter.json --out=intarna-interactions.csv', + ...selectedReports.map((key) => `Additional report: IntaRNA --parameterFile=parameter.json --outMode=${reports[key]} --outCsvCols=${CSV_COLUMNS} --outCsvSort '' --out=intarna-${key}.txt`), + ]; + const gui = {}; + if (selectedReports.length) gui.reports = selectedReports; + if (normalized.outSpotProbSpots) gui.outSpotProbSpots = normalized.outSpotProbSpots; + return { schema: JSON_SCHEMA, _comment: comments, parameters, ...(Object.keys(gui).length ? { gui } : {}) }; +} + +export function serializeParameters(request, provenance) { + return JSON.stringify(exportParameters(request, provenance), null, 2) + '\n'; +} + +// Import only controls the browser can represent. Native-only settings produce +// an actionable error instead of being silently discarded or reaching WASM. +export function importParameters(source) { + if (typeof source === 'string' && source.length > MAX_PARAMETER_BYTES) throw new Error('Choose a JSON file of at most 5 MB.'); + const document = typeof source === 'string' ? JSON.parse(source.replace(/^\uFEFF/, '')) : source; + if (!object(document) || document.schema !== JSON_SCHEMA || !object(document.parameters)) throw new Error(`Expected a ${JSON_SCHEMA} parameter document.`); + for (const key of Object.keys(document)) if (!['schema', '_comment', 'parameters', 'gui'].includes(key)) throw new Error(`Unknown JSON document field: ${key}`); + const request = { task: 'prediction', query: '', target: '', qIdxPos0: 1, tIdxPos0: 1, rri: '', parameters: {}, outputs: [], csvColumns: CSV_COLUMNS.split(',') }; + const known = new Set(FIELDS.map((field) => field.key)); + for (const [key, value] of Object.entries(document.parameters)) { + if (value === null) throw new Error(`${key}: null is not a parameter value.`); + if (['query', 'target', ...embeddedKeys].includes(key)) { + const content = object(value) && Object.keys(value).length === 1 && typeof value.text === 'string' ? value.text : value; + // Plain nucleotide strings are native inline sequences; file paths need + // explicit embedding because the browser cannot open local paths. + if (typeof content !== 'string' || (content === value && (embeddedKeys.includes(key) || !/^[ACGUTNacgutn]*$/.test(value)))) throw new Error(`${key}: embed the input using {"text":"file contents"}.`); + if (key === 'query' || key === 'target') request[key] = text(content, key, LIMITS.maxFastaBytes); + else request.parameters[key] = text(content, key); + } else if (key === 'personality') { + if (!['IntaRNA', 'IntaRNAeval'].includes(value)) throw new Error('The browser supports IntaRNA prediction and IntaRNAeval evaluation.'); + request.task = value === 'IntaRNAeval' ? 'evaluation' : 'prediction'; + } else if (key === 'qIdxPos0' || key === 'tIdxPos0') request[key] = value; + else if (key === 'rri') request.rri = text(value, key, 10000); + else if (key === 'outMode') { if (value !== 'C') throw new Error('Use CSV outMode C and gui.reports for additional browser reports.'); } + else if (key === 'outCsvCols') request.csvColumns = text(value, key).split(',').filter(Boolean); + else if (key === 'out') { + const entries = Array.isArray(value) ? value : [value]; + for (const entry of entries) { + const match = /^([A-Za-z]+):(?:(-?[\d&,\-]+):)?([^:]+)$/.exec(text(entry, 'out')); + if (!match || !EXTRA_OUTPUTS.some((output) => output.key === match[1] && !Object.hasOwn(reports, output.key))) throw new Error('The browser supports named additional outputs; omit main output paths from the parameter file.'); + request.outputs.push(match[1]); + if (match[2]) { + if (match[1] !== 'spotProb') throw new Error('Only spotProb accepts explicit probability spots.'); + request.parameters.outSpotProbSpots = match[2]; + } + } + } else if (known.has(key)) request.parameters[key] = value; + else throw new Error(`The browser cannot represent parameter ${key}; use the native CLI.`); + } + if (document.gui !== undefined) { + if (!object(document.gui)) throw new Error('gui must be an object.'); + for (const [key, value] of Object.entries(document.gui)) { + if (key === 'reports' && Array.isArray(value) && value.every((entry) => Object.hasOwn(reports, entry))) request.outputs.push(...value); + else if (key === 'outSpotProbSpots') { + if (request.parameters.outSpotProbSpots && request.parameters.outSpotProbSpots !== value) throw new Error('Conflicting spotProb selections.'); + request.parameters.outSpotProbSpots = text(value, key, 10000); + } else throw new Error(`Unsupported GUI setting: ${key}`); + } + } + selections(request); + // Keep prediction-only controls from being silently ignored on import. + if (request.task === 'evaluation' && FIELDS.some((field) => field.prediction && Object.hasOwn(request.parameters, field.key))) throw new Error('Evaluation documents must omit prediction-only parameters.'); + const normalized = normalizeParameters(request); + request.qIdxPos0 = normalized.qIdxPos0; request.tIdxPos0 = normalized.tIdxPos0; + request.parameters = normalized; + return request; +} diff --git a/webinterface/protocol.js b/gui/protocol.js similarity index 87% rename from webinterface/protocol.js rename to gui/protocol.js index d70c0a40..d452de28 100644 --- a/webinterface/protocol.js +++ b/gui/protocol.js @@ -1,41 +1,41 @@ import { FIELDS, PARAMETER_DEFAULTS, EXTRA_OUTPUTS, ENSEMBLE_COLUMNS } from './options.js'; import { CSV_CATALOG } from './columns.js'; -// 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 }); +// Bound the largest pair's matrix while allowing batches of shorter sequences. +export const LIMITS = Object.freeze({ minLength: 2, maxLength: 5000, maxRecords: 50, maxPairs: 1000000, maxFastaBytes: 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.`); + if (typeof text !== 'string' || text.length > LIMITS.maxFastaBytes || !text.trim()) throw new Error(`${label}: enter a sequence or FASTA records (at most 1 MB).`); const lines = text.trim().split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - const records = []; + const records = [], warnings = []; let current; for (const line of lines) { if (line.startsWith('>')) { - current = { id: line.slice(1).trim(), sequence: '' }; + const header = line.slice(1).trim(); + current = { id: header.split(/\s+/)[0], 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]; + if (/\s+\S/.test(header)) warnings.push(`${label}: ignoring FASTA description after the name “${current.id}”.`); 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 ((!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'}.`); + if ((!multiple && records.length !== 1) || records.length > LIMITS.maxRecords) throw new Error(`${label}: ${multiple ? 'use at most 50 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.`); + if (record.sequence.length < LIMITS.minLength || record.sequence.length > LIMITS.maxLength) throw new Error(`${label}: use 2–5,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); } - return { records, fasta: records.map(({ id, sequence }) => `>${id}\n${sequence}\n`).join('') }; + return { records, warnings, fasta: records.map(({ id, sequence }) => `>${id}\n${sequence}\n`).join('') }; } export function parseSequence(text, label) { const parsed = parseFasta(text, label, false); @@ -109,11 +109,10 @@ export function validateInteractions(encoding, query, target, qStart, tStart, se } } -export function prepareRequest(request) { - 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); +// Validate scalar controls independently so unfinished forms can be saved/loaded. +export function normalizeParameters(request) { + if (!['prediction', 'evaluation'].includes(request.task ?? 'prediction')) throw new Error('Choose prediction or evaluation.'); + const evaluating = request.task === 'evaluation'; const parameters = {}; const supplied = request.parameters ?? {}; for (const field of FIELDS) { @@ -138,10 +137,19 @@ export function prepareRequest(request) { 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 parameters; +} + +export function prepareRequest(request) { + 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 = normalizeParameters(request); 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.'); + const work = Math.max(...qRecords.map((q) => q.sequence.length)) * Math.max(...tRecords.map((t) => t.sequence.length)); + if (work > LIMITS.maxPairs) throw new Error('The longest selected query × longest selected target must be at most 1,000,000.'); // Validate sequence-dependent settings before loading the engine. for (const [p, records] of [['q', qRecords], ['t', tRecords]]) { @@ -213,7 +221,7 @@ 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; + if (key === 'outCsvSort' && format !== 'C' || 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; @@ -230,3 +238,21 @@ export function makeArguments(request, format = 'C') { } return args; } + +export const JSON_SCHEMA = 'IntaRNA-parameters-v1'; + +// One parameter document crosses the JS/native boundary. File contents use the +// native JSON embedding contract, so no browser-specific input paths are needed. +export function makeParameterDocument(request, format = 'C') { + const parameters = {}; + for (const argument of makeArguments(request, format)) { + const separator = argument.indexOf('='), key = argument.slice(2, separator); + let value = argument.slice(separator + 1); + if (key === 'query' || key === 'target') value = { text: request[key].fasta }; + else if (/^[qt](Shape|AccFile)$/.test(key)) value = { text: request.parameters[key] + '\n' }; + else if (Object.hasOwn(request.parameters, key)) value = request.parameters[key]; + if (key === 'out') (parameters.out ??= []).push(value); + else if (!(['qIdxPos0', 'tIdxPos0'].includes(key) && value === 1)) parameters[key] = value; + } + return { schema: JSON_SCHEMA, parameters }; +} diff --git a/webinterface/results.js b/gui/results.js similarity index 89% rename from webinterface/results.js rename to gui/results.js index aeceb38a..92a06f70 100644 --- a/webinterface/results.js +++ b/gui/results.js @@ -66,15 +66,15 @@ 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.search = root.querySelector('[data-role=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.count = root.querySelector('[data-role=visible-count]'); + this.empty = root.querySelector('[data-role=no-matches]'); + this.picker = root.querySelector('[data-role=visible-columns]'); this.search.addEventListener('input', () => this.render()); - root.querySelector('#download').addEventListener('click', () => downloadText('intarna-interactions.csv', this.csv)); + root.querySelector('[data-role=download]').addEventListener('click', () => downloadText('intarna-interactions.csv', this.csv)); } clear() { this.rows = []; this.csv = ''; this.body.replaceChildren(); this.root.hidden = true; @@ -103,11 +103,15 @@ 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'; + const viewHeading = document.createElement('th'); viewHeading.scope = 'col'; viewHeading.setAttribute('aria-label', 'Interaction visualization'); 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; + cell.scope = 'col'; button.type = 'button'; + const split = column.numeric ? column.label.indexOf(' ') : -1; + if (split > 0) button.append(document.createTextNode(column.label.slice(0, split)), document.createElement('br'), document.createTextNode(column.label.slice(split + 1))); + else button.textContent = column.label; + button.title = column.description ?? 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}` : ''}`); diff --git a/webinterface/runner.js b/gui/runner.js similarity index 100% rename from webinterface/runner.js rename to gui/runner.js diff --git a/gui/sessions.js b/gui/sessions.js new file mode 100644 index 00000000..71b712d0 --- /dev/null +++ b/gui/sessions.js @@ -0,0 +1,124 @@ +import { ResultsView, downloadBlob } from './results.js'; +import { serializeParameters, importParameters } from './parameter-file.js'; +import { zipFiles } from './archive.js'; + +// Sessions live only in this page. Each owns a JSON snapshot and its own view; +// editing the form or exploring another session cannot change an earlier run. +export class SessionsView { + constructor(root, template, restore, reportError, announce) { + this.root = root; this.template = template; this.restore = restore; this.reportError = reportError; this.announce = announce; + this.entries = new Map(); this.nextNumber = 1; this.selected = null; + } + + deselect() { + this.selected = null; + for (const session of this.entries.values()) { + session.root.dataset.selected = 'false'; + session.toggle.textContent = 'Show'; + } + } + + setExpanded(session, expanded) { + session.panel.hidden = !expanded; + session.toggle.setAttribute('aria-expanded', String(expanded)); + session.toggle.textContent = expanded ? 'Hide' : 'Show'; + // Unload third-party viewers while folded. The table selection is retained + // and can be visualized again without recomputing the prediction. + if (!expanded) session.view.visualization.clear(); + else if (session.view.selected) session.view.visualization.show(session.view.selected, session.view.request); + } + + select(session, restore = true) { + if (restore) this.restore(importParameters(session.parameterJson), session.name); + this.selected = session.id; + for (const entry of this.entries.values()) { + entry.root.dataset.selected = String(entry === session); + this.setExpanded(entry, entry === session); + } + } + + add(request, result, provenance) { + // Prepared indices live in parameters, whereas the JSON serializer accepts + // editable requests with top-level indices. Preserve signed coordinates. + const parameterJson = serializeParameters({ ...request, qIdxPos0: request.parameters.qIdxPos0, tIdxPos0: request.parameters.tIdxPos0 }, provenance); + const root = this.template.content.firstElementChild.cloneNode(true); + const find = (role) => root.querySelector(`[data-role=${role}]`); + const id = `session-${this.nextNumber}`, task = request.task === 'evaluation' ? 'Evaluation' : 'Prediction'; + const session = { + id, name: `${task} ${this.nextNumber}`, parameterJson, root, + panel: find('panel'), toggle: find('toggle'), view: new ResultsView(find('results-view')), + }; + const count = session.view.show(result.csv, result.csvColumns, request); + const summary = count ? `${task} complete: ${count} interaction${count === 1 ? '' : 's'}.` : request.task === 'prediction' && request.parameters.outNumber === 0 ? 'Accessibility computation complete. No interaction rows requested.' : 'No favorable interactions found with these sequences and settings.'; + find('summary').textContent = summary; + root.dataset.sessionId = id; + session.panel.id = `${id}-panel`; session.toggle.setAttribute('aria-controls', session.panel.id); + const name = find('name'); name.value = session.name; + const rename = () => { + session.name = name.value.trim() || `${task} ${id.slice('session-'.length)}`; + root.setAttribute('aria-label', session.name); + session.toggle.setAttribute('aria-label', `Show or hide results and restore inputs for ${session.name}`); + find('delete').setAttribute('aria-label', `Delete session ${session.name}`); + find('download-zip').setAttribute('aria-label', `Download ZIP for ${session.name}`); + }; + name.addEventListener('input', rename); + name.addEventListener('change', () => { rename(); name.value = session.name; }); + name.addEventListener('keydown', (event) => { if (event.key === 'Enter') name.blur(); }); + rename(); + // The name and header background also select a run. Action buttons retain + // their own behavior, so exporting/deleting cannot overwrite edited inputs. + const header = root.querySelector('.session-header'); + header.tabIndex = 0; header.setAttribute('role', 'group'); + header.setAttribute('aria-label', 'Select result and restore its inputs'); + for (const event of ['click', 'focusin']) header.addEventListener(event, (event) => { + if (!event.target.closest('button') && (this.selected !== id || session.panel.hidden)) this.select(session); + }); + header.addEventListener('keydown', (event) => { + if (event.target === header && ['Enter', ' '].includes(event.key)) { event.preventDefault(); this.select(session); } + }); + // Clicking or keyboard-focusing an output area selects its computed inputs. + for (const event of ['click', 'focusin']) session.panel.addEventListener(event, () => { + if (this.selected !== id) this.select(session); + }); + session.toggle.addEventListener('click', () => { + if (this.selected === id && !session.panel.hidden) this.setExpanded(session, false); + else this.select(session); + }); + + // Each archive uses the saved JSON and full outputs, never the current form, + // filtered table, or the session selected at download time. + find('download-zip').addEventListener('click', () => { + try { + const files = [{ name: 'parameter.json', text: parameterJson }, { name: 'intarna-interactions.csv', text: session.view.csv }, ...result.files]; + const filename = session.name.replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^[.-]+|[.-]+$/g, '').slice(0, 80) || id; + downloadBlob(`${filename}.zip`, new Blob([zipFiles(files)], { type: 'application/zip' })); + } catch (error) { this.reportError(new Error(`ZIP download failed: ${error.message}`)); } + }); + for (const file of result.files) { + const details = document.createElement('details'), label = document.createElement('summary'); label.textContent = file.name; + const preview = document.createElement('pre'); preview.textContent = file.text.slice(0, 100000); + const download = document.createElement('button'); download.type = 'button'; download.className = 'secondary'; download.textContent = 'Download file'; + download.addEventListener('click', () => downloadBlob(file.name, new Blob([file.text], { type: 'text/plain;charset=utf-8' }))); + details.append(label, download, preview); + if (file.text.length > 100000) { + const note = document.createElement('p'); note.textContent = 'Preview limited to 100,000 characters. Download contains the complete file.'; details.append(note); + } + find('file-list').append(details); + } + find('output-files').hidden = !result.files.length; + find('diagnostic-text').textContent = result.diagnostics; find('diagnostics').hidden = !result.diagnostics; + + find('delete').addEventListener('click', () => { + const entries = [...this.entries.values()], index = entries.indexOf(session); + const next = entries[index + 1] ?? entries[index - 1]; + session.view.clear(); root.remove(); this.entries.delete(id); + // Deleting output does not silently overwrite an edited form. + if (this.selected === id) this.selected = null; + this.announce(`${session.name} deleted. ${this.entries.size} session${this.entries.size === 1 ? '' : 's'} remaining.`); + (next?.toggle ?? document.getElementById('compute')).focus(); + }); + this.entries.set(id, session); this.nextNumber++; + this.root.prepend(root); this.select(session, false); + return summary; + } +} diff --git a/webinterface/styles.css b/gui/styles.css similarity index 64% rename from webinterface/styles.css rename to gui/styles.css index b776078c..360ba4c4 100644 --- a/webinterface/styles.css +++ b/gui/styles.css @@ -4,6 +4,8 @@ --brand-blue:#145a96; --brand-blue-dark:#0f4676; --brand-blue-soft:#eaf2f8; + --table-header:#d9e8f6; + --table-selection:#c6e5f4; --brand-accent:#f4bb44; --surface:#fff; --surface-muted:#f3f7fb; @@ -221,7 +223,7 @@ fieldset { min-width:0; } -.task-bar,.compute-bar,.table-tools { +.compute-bar,.table-tools { display:flex; align-items:center; flex-wrap:wrap; @@ -229,16 +231,6 @@ fieldset { 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); @@ -292,8 +284,26 @@ fieldset { grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:20px 24px; align-items:start; + margin-bottom:20px; } +/* Explicit rows preserve the requested grouping; subgrid aligns inputs below + labels of different heights without fixing a font-dependent label height. */ +.field-grid.columns-1 { grid-template-columns:minmax(0,1fr); } +.field-grid.columns-2 { grid-template-columns:repeat(2,minmax(0,1fr)); } +.field-grid.columns-3 { grid-template-columns:repeat(3,minmax(0,1fr)); } +.field-grid.columns-4 { grid-template-columns:repeat(4,minmax(0,1fr)); } +.field-grid.columns-5 { grid-template-columns:repeat(5,minmax(0,1fr)); } +.field-grid > .field:not(.check) { display:grid; grid-row:span 4; grid-template-rows:subgrid; row-gap:5px; } +.field-grid > .field:not(.check) > :nth-child(2) { align-self:start; } +.accessibility-columns { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; margin-top:20px; } +.accessibility-columns > div { min-width:0; } +.seed-mode { max-width:24rem; margin-bottom:20px; } +.field-grid:has(#noSeed) { display:none; } +#query,#target { white-space:pre; overflow:auto; } +#sequence-help,#general-options p { max-width:none; } +.input-warning { font-size:.85rem; color:#79510b; max-width:none; overflow-wrap:anywhere; } + .strand-heading,.sequence-meta { display:flex; align-items:center; @@ -364,10 +374,6 @@ fieldset { background:transparent; } -.field:has(textarea) { - grid-column:1/-1; -} - .field:has(:disabled) { opacity:.65; } @@ -406,6 +412,11 @@ fieldset { padding:.3rem .6rem; } +.csv-group { margin-top:16px; } +.csv-group legend { font-weight:600; } +.text-button { background:none; border:0; padding:.15rem .5rem; color:var(--brand-blue); font-size:.8rem; text-decoration:underline; font-weight:400; } +.csv-columns { grid-template-columns:repeat(auto-fill,minmax(125px,1fr)); gap:6px 16px; padding:6px 0; } + .results { background:var(--surface); border:1px solid var(--border); @@ -436,7 +447,7 @@ fieldset { font-size:.9rem; } -#visible-count { +[data-role=visible-count] { font-size:.85rem; color:var(--muted); } @@ -460,14 +471,15 @@ table { } th { - background:var(--brand-blue-soft); + background:var(--table-header); text-align:left; + padding:0 5px; } th button { background:transparent; border:0; - padding:12px; + padding:10px 0; color:var(--brand-blue-dark); width:100%; text-align:left; @@ -483,7 +495,7 @@ th[aria-sort=descending] button::after { td { border-top:1px solid #e0e8f0; - padding:10px 12px; + padding:10px 5px; } tr:nth-child(even) { @@ -501,7 +513,7 @@ td.number { } .view-interaction { - padding:.25rem .6rem; + padding:.25rem .35rem; color:var(--brand-blue-dark); background:var(--surface); } @@ -510,16 +522,17 @@ tbody tr { cursor:pointer; } -tbody tr:hover,tbody tr.selected { +tbody tr:hover { background:var(--brand-blue-soft); } +tbody tr.selected { background:var(--table-selection); } .view-interaction[aria-pressed=true] { background:var(--brand-blue); color:white; } -#visualization { +[data-role=visualization] { margin-top:20px; } @@ -530,7 +543,7 @@ tbody tr:hover,tbody tr.selected { gap:16px; } -#varri-frame { +[data-role=varri-frame] { display:block; width:100%; height:600px; @@ -540,17 +553,17 @@ tbody tr:hover,tbody tr.selected { background:white; } -#visualization-status { +[data-role=visualization-status] { max-width:none; overflow-wrap:anywhere; font-size:.9rem; } -#output-files { +[data-role=output-files] { margin-top:24px; } -#file-list details,#diagnostics { +[data-role=file-list] details,:is(#diagnostics, [data-role=diagnostics]) { border-top:1px solid var(--border); margin-top:12px; padding-top:12px; @@ -565,7 +578,7 @@ pre { padding:12px; } -#diagnostics pre { +:is(#diagnostics, [data-role=diagnostics]) pre { white-space:pre-wrap; overflow-wrap:anywhere; } @@ -684,6 +697,7 @@ dialog::backdrop { .field-grid { grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr)); } + .field-grid[class*=columns-],.accessibility-columns { grid-template-columns:1fr; } .results { padding:14px; } @@ -693,9 +707,6 @@ dialog::backdrop { .table-tools input { width:100%; } - .task-bar label { - flex-wrap:wrap; - } h1 { font-size:1.55rem; } @@ -709,3 +720,45 @@ dialog::backdrop { animation:none; } } + +/* Examples are a compact disclosure list with descriptions, not another form. */ +.examples-menu { position:relative; } +.examples-menu > summary { list-style:none; padding:.6rem 1rem; border:1px solid var(--border); border-radius:5px; background:var(--surface); color:var(--brand-blue-dark); font-weight:600; } +.examples-menu > summary::after { content:' ▾'; } +.examples-menu > div { position:absolute; bottom:calc(100% + .5rem); left:0; width:min(26rem,80vw); padding:.4rem; background:var(--surface); border:1px solid var(--border); border-radius:5px; box-shadow:0 6px 20px #1c2b3920; z-index:2; } +.examples-menu button { width:100%; text-align:left; background:var(--surface); border:0; } +.examples-menu button:hover { background:var(--brand-blue-soft); } +.examples-menu button strong,.examples-menu button span { display:block; } +.examples-menu button span { font-size:.85rem; font-weight:400; color:var(--muted); } +textarea::placeholder { color:#687d8e; opacity:1; } + +.strand-heading { flex-wrap:wrap; gap:.4rem .7rem; } +.upload-controls { display:flex; align-items:center; flex-wrap:wrap; gap:.4rem; margin-left:auto; } +button.small-button { padding:.25rem .65rem; font-size:.85rem; } +textarea.drag-over { outline:3px solid var(--brand-accent); background:var(--brand-blue-soft); } + +/* Native radio semantics keep the task overview visible and keyboard operable. */ +.task-selector { display:flex; flex-wrap:wrap; gap:.5rem; margin-bottom:.6rem; } +.task-choice { position:relative; display:flex; align-items:baseline; gap:.6rem; padding:.55rem 1rem; border:1px solid var(--border); border-radius:5px; background:var(--surface); cursor:pointer; } +.task-choice input { position:absolute; inset:0; opacity:0; width:100%; height:100%; margin:0; cursor:pointer; z-index:1; } +.task-choice:has(:checked) { background:var(--brand-blue); color:white; border-color:var(--brand-blue); } +.task-choice:has(:checked) small { color:white; } +.task-choice:has(:focus-visible) { outline:3px solid var(--brand-accent); outline-offset:3px; } +.compute-bar { justify-content:flex-start; gap:8px; } +#compute { margin-right:24px; } +.parameter-actions-label { font-size:.9rem; } +@media(max-width:480px) { #compute { flex-basis:100%; margin-right:0; } .parameter-actions-label { flex-basis:100%; } } + +/* Every completed run owns its controls and output, even while folded. */ +.session { margin-top:18px; border:1px solid var(--border); border-radius:6px; min-width:0; } +.session[data-selected=true] { border-color:var(--brand-blue); } +.session-header { display:flex; align-items:center; flex-wrap:wrap; gap:10px; padding:14px; background:var(--surface-muted); border-radius:6px; } +.session-name { display:flex; align-items:center; flex:1 1 20rem; gap:10px; font-size:.85rem; } +.session-name input { flex:1; width:12rem; font-weight:600; } +.session-header button { font-size:.85rem; } +.session-header [data-role=toggle]::before { content:'▸ '; } +.session-header [aria-expanded=true]::before { content:'▾ '; } +.delete-session { display:inline-flex; align-items:center; } +.session-panel { padding:16px; } +.session-summary { color:var(--muted); } +@media(max-width:480px) { .session-header,.session-panel { padding:10px; } } diff --git a/webinterface/tests/export.test.mjs b/gui/tests/export.test.mjs similarity index 74% rename from webinterface/tests/export.test.mjs rename to gui/tests/export.test.mjs index 60d83edd..6884cf2c 100644 --- a/webinterface/tests/export.test.mjs +++ b/gui/tests/export.test.mjs @@ -24,15 +24,17 @@ test('archive preserves effective non-default settings, selected CSV, auxiliary 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.deepEqual(Object.keys(entries), ['parameter.json', 'intarna-interactions.csv', 'intarna-detailed.txt']); + const document = JSON.parse(entries['parameter.json']); + assert.equal(document.parameters.query.text, '>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(document.parameters.qShape.text, '1 0.2\n2 0.3'); + assert.ok(document._comment.includes('ViennaRNA 2.7.0')); + for (const [key, value] of Object.entries({ personality: 'IntaRNAeval', temperature: 21, qIdxPos0: 3, outCsvCols: 'E' })) assert.equal(document.parameters[key], value); + assert.equal(document.parameters.tIdxPos0, undefined); + assert.equal(document.parameters.seedBP, undefined); + assert.deepEqual(document.gui.reports, ['detailed']); + assert.ok(document._comment.some((line) => line.includes('IntaRNA --parameterFile=parameter.json'))); assert.equal(entries['intarna-detailed.txt'], 'report'); assert.throws(() => archiveFiles(request, { csv: '', files: [] }, ''), /version/); }); diff --git a/gui/tests/parameter-file.test.mjs b/gui/tests/parameter-file.test.mjs new file mode 100644 index 00000000..25f9ba6d --- /dev/null +++ b/gui/tests/parameter-file.test.mjs @@ -0,0 +1,58 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULTS, EVALUATION_EXAMPLE, prepareRequest, CSV_COLUMNS } from '../protocol.js'; +import { FIELDS } from '../options.js'; +import { exportParameters, serializeParameters, importParameters } from '../parameter-file.js'; +const provenance = 'IntaRNA test\nViennaRNA test\n'; + +test('JSON round trip retains embedded inputs, typed options, selected columns and reports', () => { + for (const request of [ + { ...DEFAULTS, query: '>q1\nGGGAGGAGGG\n>q2\nGGGAGGAGGG', qIdxPos0: -3, tIdxPos0: -5, parameters: { temperature: 22, noSeed: true, mode: 'M', qRegion: '-3-7', outSpotProbSpots: '-5&-3' }, csvColumns: ['E', 'id2'], outputs: ['normal', 'qPu', 'spotProb'] }, + { ...DEFAULTS, task: 'evaluation', rri: EVALUATION_EXAMPLE, parameters: { qShape: '1 0.2\n2 0.3' }, outputs: ['detailed', 'ensemble'] }, + ]) { + const document = exportParameters(request, provenance); + const imported = importParameters(JSON.stringify(document)); + const actual = prepareRequest(imported), expected = prepareRequest(request); + actual.outputs.sort(); expected.outputs.sort(); + assert.deepEqual(actual, expected); + assert.deepEqual(exportParameters(imported, provenance), document); + assert.ok(!JSON.stringify(document).includes('/query.fa')); + } +}); + +test('empty inputs and unfinished forms remain editable after Load/Store', () => { + const request = { query: '', target: '', parameters: { seedBP: 12 }, csvColumns: [], outputs: [] }; + const document = serializeParameters(request, provenance); + const loaded = importParameters(document); + assert.equal(loaded.query, ''); assert.equal(loaded.target, ''); + assert.equal(loaded.parameters.seedBP, 12); assert.deepEqual(loaded.csvColumns, []); + assert.throws(() => prepareRequest(loaded), /enter a sequence/); + const defaults = exportParameters(DEFAULTS, provenance).parameters; + for (const field of FIELDS) assert.equal(defaults[field.key], undefined, field.key); + assert.equal(defaults.qIdxPos0, undefined); + assert.equal(defaults.outCsvCols, undefined); +}); + +test('invalid/native-only settings are rejected before replacing the form', () => { + const base = exportParameters(DEFAULTS, provenance); + for (const values of [{ threads: 8 }, { parameterFile: 'other.json' }, { query: '/input.fa' }, { qShape: '/data.txt' }, { noSeed: 'false' }, { temperature: null }, { out: ['../file'] }, { out: ['unknown:data'] }, { personality: 'IntaRNAens' }, { energyVRNA: { text: 'x' } }, { outMode: 'D' }, { query: { text: 'a', path: 'x' } }]) { + assert.throws(() => importParameters({ ...base, parameters: { ...base.parameters, ...values } }), JSON.stringify(values)); + } + for (const document of [[], null, { ...base, schema: 'new' }, { ...base, other: true }, { ...base, gui: [] }, { ...base, gui: { reports: ['unknown'] } }]) assert.throws(() => importParameters(document)); + assert.throws(() => importParameters('{invalid')); + assert.throws(() => importParameters(' '.repeat(5000001)), /5 MB/); + assert.deepEqual(importParameters('\ufeff'+JSON.stringify(base)).csvColumns, CSV_COLUMNS.split(',')); +}); + +test('maintainer examples use the same JSON import and prediction validation', async () => { + const { readFile } = await import('node:fs/promises'); + const examples = JSON.parse(await readFile(new URL('../examples/index.json', import.meta.url))); + assert.equal(examples.length, 2); + const tasks = new Set(); + for (const file of examples) { + const example = JSON.parse(await readFile(new URL(`../examples/${file}`, import.meta.url))); + assert.ok(example.name && example.description); + tasks.add(prepareRequest(importParameters(example.configuration)).task); + } + assert.deepEqual([...tasks].sort(), ['evaluation', 'prediction']); +}); diff --git a/webinterface/tests/protocol.test.mjs b/gui/tests/protocol.test.mjs similarity index 61% rename from webinterface/tests/protocol.test.mjs rename to gui/tests/protocol.test.mjs index a6608d7d..07a60071 100644 --- a/webinterface/tests/protocol.test.mjs +++ b/gui/tests/protocol.test.mjs @@ -3,7 +3,7 @@ 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'; +import { CSV_CATALOG, CSV_GROUPS, defaultColumns } from '../columns.js'; const request = (parameters = {}, extra = {}) => ({ ...DEFAULTS, parameters, ...extra }); test('FASTA normalization, identifiers, bounded multi-record work and subsets', () => { @@ -12,11 +12,11 @@ test('FASTA normalization, identifiers, bounded multi-record work and subsets', 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({}, { query: '>a\n'+'A'.repeat(5000)+'\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(5000)+'\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) }))); + assert.throws(() => prepareRequest(request({}, { query: 'A'.repeat(5001) }))); }); test('CLI defaults, ranges and strict typed controls reject invalid values', () => { @@ -69,3 +69,51 @@ test('browser catalog covers every native CSV column and every control names a C 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); }); + +test('JSON worker boundary embeds all inputs with typed non-default settings', async () => { + const { makeParameterDocument, JSON_SCHEMA } = await import('../protocol.js'); + const prepared = prepareRequest(request({ temperature: 20, noSeed: true, qShape: '1 0.2' }, { outputs: ['qPu'] })); + const document = JSON.parse(JSON.stringify(makeParameterDocument(prepared))); + assert.equal(document.schema, JSON_SCHEMA); + assert.equal(document.parameters.query.text, prepared.query.fasta); + assert.equal(document.parameters.target.text, prepared.target.fasta); + assert.equal(document.parameters.qShape.text, '1 0.2\n'); + assert.equal(document.parameters.temperature, 20); + assert.equal(document.parameters.noSeed, true); + assert.equal(document.parameters.qIdxPos0, undefined); + assert.deepEqual(document.parameters.out, ['/results.csv', 'qPu:/output/qPu.csv']); + const worker = readFileSync(new URL('../intarna-worker.js', import.meta.url), 'utf8'); + assert.ok(worker.includes("callMain(['--parameterFile=/parameter.json'])")); +}); + + +test('browser batches use longest selected strands and round-trip full FASTA inputs', async () => { + const batch = Array.from({ length: 50 }, (_, i) => `>q${i}\n${'A'.repeat(5000)}\n`).join(''); + assert.equal(parseFasta(batch, 'Query').records.length, 50); + assert.throws(() => parseFasta(batch + '>extra\nAA', 'Query'), /50 FASTA/); + assert.doesNotThrow(() => prepareRequest(request({}, { query: batch, target: 'C'.repeat(200) }))); + assert.throws(() => prepareRequest(request({}, { query: batch, target: 'C'.repeat(201) })), /1,000,000/); + const crossed = { query: '>q1\n' + 'A'.repeat(5000) + '\n>q2\nAA', target: '>t1\nCC\n>t2\n' + 'C'.repeat(5000) }; + assert.throws(() => prepareRequest(request({ outPairwise: true, noSeed: true }, crossed)), /1,000,000/); + const { serializeParameters, importParameters } = await import('../parameter-file.js'); + assert.equal(importParameters(serializeParameters(request({}, { query: batch }), 'test')).query, batch); + const parsed = parseFasta('>one\tdescription here\nACGU\n>two \nACGU', 'Query'); + assert.deepEqual(parsed.records.map((record) => record.id), ['one', 'two']); + assert.equal(parsed.warnings.length, 1); + assert.match(parsed.warnings[0], /one/); + assert.equal(parsed.fasta, '>one\nACGU\n>two\nACGU\n'); +}); + +test('CSV groups cover native columns once and task defaults serialize explicitly', async () => { + assert.deepEqual(CSV_GROUPS.flatMap((group) => group.keys).toSorted(), CSV_CATALOG.map((column) => column.key).toSorted()); + const energy = [...CSV_GROUPS.find((group) => group.label === 'Energy').keys, 'Etotal', 'Eall', 'Eall1', 'Eall2', 'EallTotal']; + for (const key of energy) assert.ok(defaultColumns('evaluation').includes(key), key); + for (const key of ['seedStart1', 'seedStart2', 'seedEnd1', 'seedEnd2']) assert.ok(!defaultColumns('evaluation').includes(key), key); + for (const key of ['ED1', 'ED2', 'seedE', 'seedStart1', 'seedStart2']) assert.ok(defaultColumns('prediction').includes(key), key); + const { exportParameters, importParameters } = await import('../parameter-file.js'); + const input = request({ outNumber: 10 }, { csvColumns: defaultColumns('prediction') }); + const saved = exportParameters(input, 'test'); + assert.equal(saved.parameters.outNumber, 10); + assert.equal(saved.parameters.outCsvCols, input.csvColumns.join(',')); + assert.deepEqual(prepareRequest(importParameters(saved)), prepareRequest(input)); +}); diff --git a/webinterface/tests/results.test.mjs b/gui/tests/results.test.mjs similarity index 100% rename from webinterface/tests/results.test.mjs rename to gui/tests/results.test.mjs diff --git a/gui/tests/uploads.test.mjs b/gui/tests/uploads.test.mjs new file mode 100644 index 00000000..76a13200 --- /dev/null +++ b/gui/tests/uploads.test.mjs @@ -0,0 +1,15 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFastaFile } from '../uploads.js'; +const file = (text) => ({ size: new TextEncoder().encode(text).length, text: async () => text }); +test('FASTA uploads accept text/CRLF and validate size, format and task before replacement', async () => { + const input = '>q\r\ngggaggaggg\r\n'; + assert.equal(await readFastaFile(file(input), 'Query', 'prediction'), input); + const multi = '>q1\nGGGAGGAGGG\n>q2\nGGGAGGAGGG'; + assert.equal(await readFastaFile(file(multi), 'Query', 'prediction'), multi); + await assert.rejects(readFastaFile(file(multi), 'Query', 'evaluation'), /exactly one/); + for (const invalid of ['', '\u0000bin', '>q\n--!', '>q\n']) await assert.rejects(readFastaFile(file(invalid), 'Query', 'prediction')); + const batch = Array.from({ length: 50 }, (_, i) => `>q${i}\n${'A'.repeat(5000)}\n`).join(''); + assert.equal(await readFastaFile(file(batch), 'Query', 'prediction'), batch); + await assert.rejects(readFastaFile({ size: 1000001, text: () => { throw Error('must not read'); } }, 'Query', 'prediction'), /1 MB/); +}); diff --git a/webinterface/tests/worker.test.mjs b/gui/tests/worker.test.mjs similarity index 100% rename from webinterface/tests/worker.test.mjs rename to gui/tests/worker.test.mjs diff --git a/gui/uploads.js b/gui/uploads.js new file mode 100644 index 00000000..81748047 --- /dev/null +++ b/gui/uploads.js @@ -0,0 +1,9 @@ +import { parseFasta, LIMITS } from './protocol.js'; + +/** Read and validate a local text file before replacing a FASTA input. */ +export async function readFastaFile(file, label, task) { + if (!file || file.size > LIMITS.maxFastaBytes) throw new Error(`${label}: choose a FASTA text file of at most 1 MB.`); + const text = await file.text(); + parseFasta(text, label, task !== 'evaluation'); + return text; +} diff --git a/webinterface/visualization.js b/gui/visualization.js similarity index 93% rename from webinterface/visualization.js rename to gui/visualization.js index 46837c57..0bc7b397 100644 --- a/webinterface/visualization.js +++ b/gui/visualization.js @@ -27,8 +27,8 @@ export function visualizationUrl(row, request) { export class InteractionVisualization { constructor(root) { - this.frame = root.querySelector('#varri-frame'); this.link = root.querySelector('#varri-link'); - this.status = root.querySelector('#visualization-status'); + this.frame = root.querySelector('[data-role=varri-frame]'); this.link = root.querySelector('[data-role=varri-link]'); + this.status = root.querySelector('[data-role=visualization-status]'); } clear() { this.frame.removeAttribute('src'); this.frame.hidden = true; diff --git a/scripts/wasm/smoke-protocol.mjs b/scripts/wasm/smoke-protocol.mjs index ee27ad8b..9dd90369 100644 --- a/scripts/wasm/smoke-protocol.mjs +++ b/scripts/wasm/smoke-protocol.mjs @@ -3,10 +3,10 @@ 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'; -import { CSV_CATALOG } from '../../webinterface/columns.js'; -import { parseResults, projectCsv } from '../../webinterface/results.js'; -import { archiveFiles } from '../../webinterface/archive.js'; +import { DEFAULTS, CSV_COLUMNS, EVALUATION_EXAMPLE, prepareRequest, makeParameterDocument } from '../../gui/protocol.js'; +import { CSV_CATALOG } from '../../gui/columns.js'; +import { parseResults, projectCsv } from '../../gui/results.js'; +import { archiveFiles } from '../../gui/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'); @@ -14,11 +14,9 @@ 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.writeFile('/parameter.json', JSON.stringify(makeParameterDocument(request, format))); 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); + const args = ['--parameterFile=/parameter.json']; 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, request }; @@ -60,8 +58,10 @@ async function replayArchive(input) { 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')); + module.FS.writeFile('/parameter.json', files.find((file) => file.name === 'parameter.json').text); + const arguments_ = ['--parameterFile=/parameter.json', `--out=${config === 'parameters' ? 'intarna-interactions.csv' : `intarna-${config}.txt`}`]; + if (config !== 'parameters') arguments_.push(`--outMode=${reports.find(([key]) => key === config)[1]}`, `--outCsvCols=${CSV_COLUMNS}`, "--outCsvSort", ""); + assert.equal(module.callMain(arguments_), 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); } diff --git a/scripts/wasm/stage.sh b/scripts/wasm/stage.sh index 20367c2e..040744ba 100644 --- a/scripts/wasm/stage.sh +++ b/scripts/wasm/stage.sh @@ -25,12 +25,13 @@ 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 - cp "$repo/webinterface/$file" "$site/$file" +cp -R "$repo/gui/assets" "$repo/gui/examples" "$site/" +for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js options.js columns.js archive.js sessions.js visualization.js parameter-file.js uploads.js; do + cp "$repo/gui/$file" "$site/$file" done -cp "$repo/webinterface/intarna-worker.js" "$site/intarna-worker.js" +cp "$repo/gui/intarna-worker.js" "$site/intarna-worker.js" touch "$site/.nojekyll" +cp "$repo/src/bin/nlohmann/LICENSE.MIT" "$site/licenses/nlohmann-json.txt" 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" @@ -52,6 +53,7 @@ cat > "$site/licenses/index.html" <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.