From ce16b8ecde2d63e14cb379793ada1035b505de28 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Fri, 23 Oct 2026 16:49:43 +0200 Subject: [PATCH 1/2] Retain computed GUI runs as selectable sessions (#297) --- ChangeLog | 16 ++ README.md | 3 +- gui/app.js | 54 ++---- gui/docs/json-workflow.md | 57 +++++- gui/docs/step3-frontend-ui.md | 4 +- gui/docs/step4-results-and-deployment.md | 16 +- gui/index.html | 46 +++-- gui/results.js | 10 +- gui/sessions.js | 113 ++++++++++++ gui/styles.css | 28 ++- gui/visualization.js | 4 +- scripts/wasm/stage.sh | 2 +- scripts/wasm/test-browser.py | 223 +++++++++++++++++++---- 13 files changed, 463 insertions(+), 113 deletions(-) create mode 100644 gui/sessions.js diff --git a/ChangeLog b/ChangeLog index 3855d263..a83bb3cf 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,9 @@ ## Interface and handling +- 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) @@ -279,6 +282,19 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +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 diff --git a/README.md b/README.md index 4748d01a..e5b5f6b0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ seeding of interaction sites** Predict short RNA pairs or evaluate supplied structures locally with the [browser interface](https://backofenlab.github.io/IntaRNA/gui/). -Select an interaction to view it in vaRRI, or download a ZIP with inputs, compiled versions, reproducible parameters and all outputs. +Computed runs stay in separate, foldable sessions 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). diff --git a/gui/app.js b/gui/app.js index 6889c1ce..d265b466 100644 --- a/gui/app.js +++ b/gui/app.js @@ -2,8 +2,8 @@ import { parseFasta, prepareRequest, CSV_COLUMNS } from './protocol.js'; import { GROUPS, FIELDS, EXTRA_OUTPUTS } from './options.js'; import { CSV_CATALOG } from './columns.js'; import { PredictionRunner } from './runner.js'; -import { ResultsView, downloadText, downloadBlob } from './results.js'; -import { createArchive } from './archive.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'; @@ -12,9 +12,10 @@ 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 results = new ResultsView(byId('results-view')); +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 completedRun = null; let inputRevision = 0; let csvOrder = CSV_COLUMNS.split(','); function element(tag, text, className) { @@ -94,7 +95,7 @@ const csvPicker = element('div', '', 'checkbox-grid'); outputBody.append(csvPick for (const column of [...CSV_COLUMNS.split(',').map((key) => CSV_CATALOG.find((column) => column.key === key)), ...CSV_CATALOG.filter((column) => !CSV_COLUMNS.split(',').includes(column.key))]) csvInputs.push(checkbox(csvPicker, column.key, `${column.key} — ${column.description}`, column.description)); for (const [name, selection] of [['Select all', CSV_CATALOG.map((c) => c.key)], ['CLI defaults', CSV_COLUMNS.split(',')], ['Clear selection', []]]) { const button = element('button', name); button.type = 'button'; - button.addEventListener('click', () => { csvOrder = [...selection]; for (const input of csvInputs) input.checked = selection.includes(input.value); clearResults(); }); selectionTools.append(button); + button.addEventListener('click', () => { csvOrder = [...selection]; for (const input of csvInputs) input.checked = selection.includes(input.value); inputsChanged(); }); selectionTools.append(button); } outputBody.append(element('h3', 'Additional output files', 'subheading')); outputBody.append(element('p', 'Reports are computed with the same settings. ASCII, detailed and ensemble reports each run a fresh engine instance and can take extra time.', 'input-help')); @@ -104,9 +105,9 @@ const generalBody = group('general-options', 'General'); generalBody.append(element('p', 'Tasks run locally in one worker. Abort or Escape stops computation immediately. Process-level settings (threads, log paths, parameter-file paths and terminal help) are available in the CLI.')); const docs = element('a', 'Full CLI parameter documentation'); docs.href = 'https://github.com/BackofenLab/IntaRNA#readme'; generalBody.append(docs); -function clearResults(message = 'Inputs changed. Compute to update the interactions.') { +function inputsChanged(message = 'Inputs changed. Compute to update the interactions.') { inputRevision++; - completedRun = null; results.clear(); byId('output-files').hidden = true; byId('file-list').replaceChildren(); + sessions.deselect(); byId('diagnostics').hidden = true; byId('diagnostic-text').textContent = ''; status.textContent = message; } function updateLength(name) { @@ -147,13 +148,13 @@ function resetParameters({ clearInputs = false } = {}) { for (const input of csvInputs) input.checked = CSV_COLUMNS.split(',').includes(input.value); for (const input of outputInputs) input.checked = false; byId('qIdxPos0').value = 1; byId('tIdxPos0').value = 1; byId('rri').value = ''; - updateAvailability(); errorBox.hidden = true; clearResults(clearInputs ? 'Inputs cleared. Enter sequences or choose an example.' : 'Parameters reset to CLI defaults. Compute to update the interactions.'); + updateAvailability(); errorBox.hidden = true; inputsChanged(clearInputs ? 'Inputs cleared. Enter sequences or choose an example.' : 'Parameters reset to CLI defaults. Compute to update the interactions.'); } byId('task').addEventListener('change', resetParameters); byId('reset').addEventListener('click', () => resetParameters({ clearInputs: true })); form.addEventListener('input', (event) => { if (event.target.type === 'file') return; - updateAvailability(); errorBox.hidden = true; clearResults(); + updateAvailability(); errorBox.hidden = true; inputsChanged(); }); function showInputError(error) { errorBox.textContent = error.message; errorBox.hidden = false; @@ -171,7 +172,7 @@ function applyParameters(request, message = 'Parameters loaded. Compute to analy 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'); clearResults(message); + updateAvailability(); activateGroup('sequence-input'); inputsChanged(message); } for (const strand of ['query', 'target']) { const input = byId(strand), picker = byId(`${strand}-upload`); @@ -184,7 +185,7 @@ for (const strand of ['query', 'target']) { if (inputRevision !== revision) return; input.value = content; updateAvailability(); errorBox.hidden = true; - clearResults(`${strand === 'query' ? 'Query' : 'Target'} FASTA loaded. Compute to analyze the inputs.`); + 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()); @@ -252,16 +253,6 @@ byId('examples-menu').addEventListener('keydown', (event) => { byId('abort').addEventListener('click', () => runner.abort()); dialog.addEventListener('cancel', (event) => { event.preventDefault(); runner.abort(); }); window.addEventListener('pagehide', () => runner.abort()); -function showFiles(files) { - for (const file of files) { - const details = element('details'), summary = element('summary', file.name), preview = element('pre', file.text.slice(0, 100000)); - const download = element('button', 'Download file', 'secondary'); download.type = 'button'; download.addEventListener('click', () => downloadText(file.name, file.text)); - details.append(summary, download, preview); - if (file.text.length > 100000) details.append(element('p', 'Preview limited to 100,000 characters. Download contains the complete file.')); - byId('file-list').append(details); - } - byId('output-files').hidden = !files.length; -} 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))]; @@ -278,14 +269,13 @@ form.addEventListener('submit', async (event) => { errorBox.hidden = true; let prepared; try { prepared = prepareRequest(request); } catch (error) { errorBox.textContent = error.message; errorBox.hidden = false; return; } - clearResults('Computing interactions…'); byId('loading-status').textContent = 'Loading the prediction engine…'; + 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; }); - const count = results.show(result.csv, result.csvColumns, prepared); - status.textContent = count ? `${request.task === 'evaluation' ? 'Evaluation' : 'Prediction'} complete: ${count} interaction${count === 1 ? '' : 's'}.` : request.task === 'prediction' && Number(request.parameters.outNumber) === 0 ? 'Accessibility computation complete. No interaction rows requested.' : 'No favorable interactions found with these sequences and settings.'; - completedRun = { request: prepared, result }; - showFiles(result.files); byId('diagnostic-text').textContent = result.diagnostics; byId('diagnostics').hidden = !result.diagnostics; + // 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; } @@ -304,15 +294,3 @@ fetch(new URL('./build-info.json', import.meta.url)).then((response) => { if (!response.ok) throw new Error('Missing build provenance'); return response.json(); }).then((info) => { byId('version-info').textContent = `IntaRNA ${info.intarna} (${info.commit.slice(0, 12)}${info.dirty ? ', modified sources' : ''}) · ViennaRNA ${info.viennarna} · Emscripten ${info.emscripten}`; }).catch(() => { byId('version-info').textContent = 'Build versions unavailable. Serve the staged interface with its compiled engine and build-info files.'; }); if (!globalThis.Worker || !globalThis.WebAssembly) { byId('compute').disabled = true; errorBox.textContent = 'This browser needs WebAssembly and Web Workers. Use a current browser or the command line version.'; errorBox.hidden = false; } - -byId('download-zip').addEventListener('click', async () => { - const run = completedRun; if (!run) return; - const button = byId('download-zip'); button.disabled = true; - try { - const provenance = await buildInfo; - if (completedRun !== run) return; - downloadBlob('intarna-results.zip', new Blob([createArchive(run.request, run.result, provenance)], { type: 'application/zip' })); - } catch (error) { - errorBox.textContent = `ZIP download failed: ${error.message}. Reload the staged interface with its compiled version files.`; errorBox.hidden = false; - } finally { button.disabled = false; } -}); diff --git a/gui/docs/json-workflow.md b/gui/docs/json-workflow.md index f833e9e2..342d1f24 100644 --- a/gui/docs/json-workflow.md +++ b/gui/docs/json-workflow.md @@ -26,11 +26,42 @@ 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. Use **Show results** (or **Restore +inputs** after editing), 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 results** 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 sequences, interactions, uploaded files and results and restores -prediction defaults. Empty FASTA fields show the short demonstration sequences -as placeholders only. The Example disclosure menu sits to the left of Reset. +Reset clears the editable sequences, interactions and uploaded files and restores +prediction defaults. Previously computed sessions remain available. Empty FASTA +fields show the short demonstration sequences as placeholders only. The Example disclosure menu sits to the left of Reset. Maintainers edit `gui/examples/index.json` (an array of local JSON filenames). Each listed file contains `name`, `description`, and `configuration`, where the @@ -66,3 +97,23 @@ pending file read, so delayed reads cannot replace newer input. - 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. diff --git a/gui/docs/step3-frontend-ui.md b/gui/docs/step3-frontend-ui.md index 7f8f8106..5cc524bd 100644 --- a/gui/docs/step3-frontend-ui.md +++ b/gui/docs/step3-frontend-ui.md @@ -78,7 +78,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/gui/docs/step4-results-and-deployment.md b/gui/docs/step4-results-and-deployment.md index c216f88f..0d665211 100644 --- a/gui/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,10 +56,10 @@ 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. +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 @@ -68,7 +70,9 @@ 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. -Both require the compiled version file. See [the JSON workflow](json-workflow.md). +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 diff --git a/gui/index.html b/gui/index.html index c4727dca..679c15b0 100644 --- a/gui/index.html +++ b/gui/index.html @@ -56,22 +56,38 @@
-

Interactions

Enter your sequences or choose an example, then compute.

- - +

Sessions

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.

+
+
diff --git a/gui/results.js b/gui/results.js index aeceb38a..8f4385f0 100644 --- a/gui/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; diff --git a/gui/sessions.js b/gui/sessions.js new file mode 100644 index 00000000..a4b6d3d0 --- /dev/null +++ b/gui/sessions.js @@ -0,0 +1,113 @@ +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 = session.panel.hidden ? 'Show results' : 'Restore inputs'; + } + } + + setExpanded(session, expanded) { + session.panel.hidden = !expanded; + session.toggle.setAttribute('aria-expanded', String(expanded)); + session.toggle.textContent = expanded ? 'Hide results' : 'Show results'; + // 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(); + // 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/gui/styles.css b/gui/styles.css index 3cb26b9c..f6e4e451 100644 --- a/gui/styles.css +++ b/gui/styles.css @@ -426,7 +426,7 @@ fieldset { font-size:.9rem; } -#visible-count { +[data-role=visible-count] { font-size:.85rem; color:var(--muted); } @@ -509,7 +509,7 @@ tbody tr:hover,tbody tr.selected { color:white; } -#visualization { +[data-role=visualization] { margin-top:20px; } @@ -520,7 +520,7 @@ tbody tr:hover,tbody tr.selected { gap:16px; } -#varri-frame { +[data-role=varri-frame] { display:block; width:100%; height:600px; @@ -530,17 +530,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; @@ -555,7 +555,7 @@ pre { padding:12px; } -#diagnostics pre { +:is(#diagnostics, [data-role=diagnostics]) pre { white-space:pre-wrap; overflow-wrap:anywhere; } @@ -723,3 +723,17 @@ textarea.drag-over { outline:3px solid var(--brand-accent); background:var(--bra .compute-bar { justify-content:flex-start; gap:8px; } #compute { margin-left:auto; } @media(max-width:480px) { #compute { flex-basis:100%; margin-left:0; } } + +/* 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/gui/visualization.js b/gui/visualization.js index 46837c57..0bc7b397 100644 --- a/gui/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/stage.sh b/scripts/wasm/stage.sh index a8fbdc52..040744ba 100644 --- a/scripts/wasm/stage.sh +++ b/scripts/wasm/stage.sh @@ -26,7 +26,7 @@ PYSOURCE vienna_source="$build/sources/vienna-$vienna_version-$vienna_sha256/ViennaRNA-$vienna_version" mkdir -p "$site/licenses" 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 visualization.js parameter-file.js uploads.js; do +for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js options.js columns.js archive.js sessions.js visualization.js parameter-file.js uploads.js; do cp "$repo/gui/$file" "$site/$file" done cp "$repo/gui/intarna-worker.js" "$site/intarna-worker.js" diff --git a/scripts/wasm/test-browser.py b/scripts/wasm/test-browser.py index a9af63f8..34b09e23 100644 --- a/scripts/wasm/test-browser.py +++ b/scripts/wasm/test-browser.py @@ -29,7 +29,9 @@ def viewer(route): page.wait_for_load_state('networkidle') compute = page.locator('#compute') status = page.locator('#result-status') - rows = page.locator('tbody tr') + sessions = page.locator('.session') + active = page.locator('.session:has([data-role=panel]:not([hidden]))') + rows = active.locator('tbody tr') def open_group(key): page.locator('#tab-' + key).click() @@ -42,9 +44,9 @@ def load_example(task='prediction'): page.get_by_role('button', name='Evaluate a duplex' if task == 'evaluation' else 'Short duplex', exact=False).click() expect(page.locator('#query')).to_have_value('>query\nGGGAGGAGGG\n') - def download_archive(): + def download_archive(session=None): with page.expect_download() as downloaded: - page.locator('#download-zip').click() + (active if session is None else session).locator('[data-role=download-zip]').click() with ZipFile(BytesIO(Path(downloaded.value.path()).read_bytes())) as archive: assert archive.testzip() is None return {name: archive.read(name).decode() for name in archive.namelist()} @@ -56,9 +58,11 @@ def fill(key, value): page.locator('#' + key).fill(str(value)) def predict(): + count = sessions.count() compute.click() expect(status).to_contain_text('complete:', timeout=120000) expect(page.locator('#input-error')).not_to_be_visible() + expect(sessions).to_have_count(count + 1) expect(page.locator('#query')).to_have_value('') expect(page.locator('#target')).to_have_value('') @@ -141,33 +145,33 @@ def predict(): page.get_by_role('button', name='Sort by Energy (kcal/mol)', exact=False).click() assert float(rows.first.locator('td').nth(9).inner_text()) == -14.3 page.get_by_role('button', name='Sort by Energy (kcal/mol)', exact=False).click() - page.locator('#search').fill('-21.4') + active.locator('[data-role=search]').fill('-21.4') expect(rows).to_have_count(1) - page.locator('#search').fill('no match') - expect(page.locator('#no-matches')).to_be_visible() - page.locator('#search').fill('') - page.locator('.column-picker summary').click() - page.locator('#visible-columns input[value="start1"]').uncheck() - assert 'Target start' not in page.locator('thead').inner_text() - page.locator('#visible-columns input[value="start1"]').check() + active.locator('[data-role=search]').fill('no match') + expect(active.locator('[data-role=no-matches]')).to_be_visible() + active.locator('[data-role=search]').fill('') + active.locator('.column-picker summary').click() + active.locator('[data-role=visible-columns] input[value="start1"]').uncheck() + assert 'Target start' not in active.locator('thead').inner_text() + active.locator('[data-role=visible-columns] input[value="start1"]').check() with page.expect_download() as downloaded: - page.locator('#download').click() + active.locator('[data-role=download]').click() csv = Path(downloaded.value.path()).read_text() assert csv.splitlines()[0] == 'id1;start1;end1;id2;start2;end2;subseqDP;hybridDP;E' assert 'target;1;10;query;1;10;' in csv assert 'deltaE' not in csv assert not visualization_urls # Third-party content loads only on selection. rows.first.get_by_role('button', name='Visualize').click() - expect(page.locator('#varri-frame')).to_be_visible() - expect(page.frame_locator('#varri-frame').get_by_role('heading', name='vaRRI viewer')).to_be_visible() + expect(active.locator('[data-role=varri-frame]')).to_be_visible() + expect(active.frame_locator('[data-role=varri-frame]').get_by_role('heading', name='vaRRI viewer')).to_be_visible() expect(rows.first.locator('button')).to_have_attribute('aria-pressed', 'true') - query = parse_qs(urlparse(page.locator('#varri-frame').get_attribute('src')).query) + query = parse_qs(urlparse(active.locator('[data-role=varri-frame]').get_attribute('src')).query) assert query['sequence'] == ['CCCUCCUCCC&GGGAGGAGGG'] assert query['structure'] == ['((((((((((&))))))))))'] assert query['seqName1'] == ['target'] and query['seqName2'] == ['query'] assert query['startIndex1'] == ['1'] assert query['showRenderingOnly'] == ['true'] - assert 'showRenderingOnly' not in page.locator('#varri-link').get_attribute('href') + assert 'showRenderingOnly' not in active.locator('[data-role=varri-link]').get_attribute('href') archive = download_archive() assert archive['intarna-interactions.csv'] == csv saved = json.loads(archive['parameter.json']) @@ -187,7 +191,7 @@ def predict(): imported['parameters']['temperature'] = 24 page.locator('#parameter-upload').set_input_files({'name': 'settings.json', 'mimeType': 'application/json', 'buffer': json.dumps(imported).encode()}) expect(page.locator('#temperature')).to_have_value('24') - expect(page.locator('#results-view')).not_to_be_visible() + expect(active.locator('[data-role=results-view]')).to_be_visible() with page.expect_download() as stored_again: page.locator('#store-parameters').click() assert json.loads(Path(stored_again.value.path()).read_text())['parameters']['outCsvCols'] == 'E,id2' @@ -215,16 +219,16 @@ def predict(): predict() expect(rows).to_have_count(2) assert rows.first.locator('td').all_text_contents()[1:7] == ['target', '-5', '5', 'q1', '-3', '7'] - expect(page.locator('#file-list details')).to_have_count(2) - page.locator('#file-list summary').first.click() - expect(page.locator('#file-list pre').first).to_contain_text('minE') + expect(active.locator('[data-role=file-list] details')).to_have_count(2) + active.locator('[data-role=file-list] summary').first.click() + expect(active.locator('[data-role=file-list] pre').first).to_contain_text('minE') archive = download_archive() assert len([name for name in archive if name.startswith('qMinE')]) == 2 saved = json.loads(archive['parameter.json']) assert saved['parameters']['query']['text'].count('>') == 2 assert saved['parameters']['qIdxPos0'] == -3 rows.nth(1).get_by_role('button', name='Visualize').click() - query = parse_qs(urlparse(page.locator('#varri-frame').get_attribute('src')).query) + query = parse_qs(urlparse(active.locator('[data-role=varri-frame]').get_attribute('src')).query) assert query['seqName2'] == ['q2'] assert query['startIndex1'] == ['-5'] and query['startIndex2'] == ['-3'] @@ -232,9 +236,9 @@ def predict(): fill('query', '>q first\nGGGAGGAGGG\n>q second\nGGGAGGAGGG') compute.click() expect(page.locator('#input-error')).to_contain_text('unique') - expect(page.locator('#varri-frame')).not_to_be_visible() - assert page.locator('#varri-frame').get_attribute('src') is None - expect(page.locator('#download-zip')).not_to_be_visible() + expect(active.locator('[data-role=varri-frame]')).to_be_visible() + expect(active.locator('[data-role=download-zip]')).to_be_visible() + assert download_archive() == archive # Evaluation resets native defaults and removes prediction-only controls. fill('query', '>q1\nGGGAGGAGGG\n>q2\nGGGAGGAGGG') @@ -252,12 +256,12 @@ def predict(): predict() expect(status).to_contain_text('Evaluation complete') expect(rows).to_have_count(2) - expect(page.locator('thead')).to_contain_text('ED1') - expect(page.locator('#file-list summary')).to_contain_text('intarna-detailed.txt') - page.locator('#file-list summary').click() - expect(page.locator('#file-list pre')).to_contain_text('-21.4') + expect(active.locator('thead')).to_contain_text('ED1') + expect(active.locator('[data-role=file-list] summary')).to_contain_text('intarna-detailed.txt') + active.locator('[data-role=file-list] summary').click() + expect(active.locator('[data-role=file-list] pre')).to_contain_text('-21.4') with page.expect_download() as report: - page.locator('#file-list button').click() + active.locator('[data-role=file-list] button').click() assert '-21.4' in Path(report.value.path()).read_text() archive = download_archive() assert '-21.4' in archive['intarna-detailed.txt'] @@ -273,7 +277,7 @@ def predict(): page.locator('#output-options input[value="E"]').check() predict() rows.first.get_by_role('button', name='Visualize').click() - expect(page.locator('#varri-frame')).to_be_visible() + expect(active.locator('[data-role=varri-frame]')).to_be_visible() archive = download_archive() assert archive['intarna-interactions.csv'].splitlines()[0] == 'E' assert json.loads(archive['parameter.json'])['parameters']['outCsvCols'] == 'E' @@ -285,11 +289,13 @@ def predict(): page.get_by_role('radio', name='Prediction').check() load_example() held_routes = [] + session_count = sessions.count() page.route('**/wasm/intarna.wasm', lambda route: held_routes.append(route)) compute.click() expect(page.locator('#loading')).to_be_visible() page.locator('#abort').click() expect(status).to_contain_text('aborted') + expect(sessions).to_have_count(session_count) for route in held_routes: route.abort() page.unroute('**/wasm/intarna.wasm') @@ -299,6 +305,7 @@ def predict(): expect(page.locator('#loading-status')).to_contain_text('Predicting RNA', timeout=120000) page.keyboard.press('Escape') expect(status).to_contain_text('aborted') + expect(sessions).to_have_count(session_count) load_example() predict() @@ -306,21 +313,171 @@ def predict(): compute.click() expect(status).to_contain_text('No favorable interactions', timeout=120000) expect(rows).to_have_count(0) - expect(page.locator('#varri-frame')).not_to_be_visible() + expect(active.locator('[data-role=varri-frame]')).not_to_be_visible() assert download_archive()['intarna-interactions.csv'].count('\n') == 1 fill('query', '>\nGGGAGGAGGG') predict() - assert page.locator('tbody img').count() == 0 + assert active.locator('tbody img').count() == 0 assert ' 1 + page.reload() + page.wait_for_load_state('networkidle') + expect(sessions).to_have_count(0) + load_example() + fill('temperature', 24) + fill('qIdxPos0', -3) + fill('tIdxPos0', -5) + fill('qShape', '1 0.2\n2 0.3') + fill('outNumber', 3) + open_group('output-options') + page.locator('#output-options input[value="qMinE"]').check() + predict() + first_id = active.get_attribute('data-session-id') + first = page.locator(f'[data-session-id="{first_id}"]') + first.get_by_label('Session name', exact=True).fill('Cool duplex') + first.get_by_label('Session name', exact=True).press('Enter') + first_archive = download_archive(first) + first_csv = first_archive['intarna-interactions.csv'] + first.locator('[data-role=search]').fill('no match') + expect(first.locator('[data-role=no-matches]')).to_be_visible() + first.locator('.column-picker summary').click() + first.locator('[data-role=visible-columns] input[value="start1"]').uncheck() + first_diagnostics = first.locator('[data-role=diagnostic-text]').text_content() + + # Edits and Reset preserve outputs. Restore also works for an unfolded run. + fill('temperature', 31) + expect(first.locator('[data-role=panel]')).to_be_visible() + assert download_archive(first) == first_archive + first.locator('[data-role=summary]').click() + expect(page.locator('#temperature')).to_have_value('24') + fill('temperature', 31) + first.locator('[data-role=toggle]').click() + expect(page.locator('#temperature')).to_have_value('24') + expect(first.locator('[data-role=panel]')).to_be_visible() + first.locator('[data-role=toggle]').click() + expect(first.locator('[data-role=panel]')).not_to_be_visible() + first.locator('[data-role=toggle]').press('Enter') + expect(first.locator('[data-role=panel]')).to_be_visible() + page.locator('#reset').click() + expect(sessions).to_have_count(1) + expect(page.locator('#query')).to_have_value('') + assert download_archive(first) == first_archive + + # A different task, CSV order and output report own a second result area. + load_example('evaluation') + with page.expect_download() as stored: + page.locator('#store-parameters').click() + second_settings = json.loads(Path(stored.value.path()).read_text()) + second_settings['parameters']['temperature'] = 42 + second_settings['parameters']['outCsvCols'] = 'E,id2' + second_settings['gui'] = {'reports': ['detailed']} + page.locator('#parameter-upload').set_input_files({'name': 'evaluation.json', 'mimeType': 'application/json', 'buffer': json.dumps(second_settings).encode()}) + predict() + expect(sessions).to_have_count(2) + second_id = active.get_attribute('data-session-id') + assert second_id != first_id + second = page.locator(f'[data-session-id="{second_id}"]') + expect(first.locator('[data-role=panel]')).not_to_be_visible() + expect(first.locator('[data-role=download-zip]')).to_be_visible() + assert download_archive(first) == first_archive + expect(page.get_by_role('radio', name='Evaluation')).to_be_checked() + expect(page.locator('#temperature')).to_have_value('42') + second_archive = download_archive(second) + assert second_archive['intarna-interactions.csv'].splitlines()[0] == 'E;id2' + assert 'intarna-detailed.txt' in second_archive + + # Selecting reloads all computed inputs while retaining each table's filter. + first.locator('[data-role=toggle]').press('Space') + expect(second.locator('[data-role=panel]')).not_to_be_visible() + expect(first.locator('[data-role=panel]')).to_be_visible() + expect(page.get_by_role('radio', name='Prediction')).to_be_checked() + expect(page.locator('#qIdxPos0')).to_have_value('-3') + expect(page.locator('#tIdxPos0')).to_have_value('-5') + expect(page.locator('#qShape')).to_have_value('1 0.2\n2 0.3') + expect(page.locator('#temperature')).to_have_value('24') + expect(page.locator('#output-options input[value="qMinE"]')).to_be_checked() + expect(page.locator('#output-options input[value="detailed"]')).not_to_be_checked() + expect(first.locator('[data-role=search]')).to_have_value('no match') + expect(first.locator('[data-role=visible-columns] input[value="start1"]')).not_to_be_checked() + assert first.locator('[data-role=diagnostic-text]').text_content() == first_diagnostics + with page.expect_download() as restored: + page.locator('#store-parameters').click() + assert Path(restored.value.path()).read_text() == first_archive['parameter.json'] + first.locator('[data-role=search]').fill('') + expect(first.locator('tbody tr')).to_have_count(3) + first.locator('tbody tr').first.get_by_role('button', name='Visualize').click() + first_url = first.locator('[data-role=varri-frame]').get_attribute('src') + assert parse_qs(urlparse(first_url).query)['startIndex1'] == ['-5'] + + # A pending file read must not overwrite a selected session's snapshot. + page.evaluate(r"""() => { window.originalFileText = File.prototype.text; File.prototype.text = function() { return new Promise(resolve => { window.finishRead = () => resolve('>late\nGGGAGGAGGG\n'); }); }; }""") + page.locator('#query-upload').set_input_files({'name': 'late.fa', 'mimeType': 'text/plain', 'buffer': b'>late\nGGGAGGAGGG\n'}) + second.locator('[data-role=toggle]').click() + page.evaluate('() => { File.prototype.text = window.originalFileText; window.finishRead(); }') + expect(page.locator('#query')).to_have_value('>query\nGGGAGGAGGG\n') + expect(page.locator('#qShape')).to_have_value('') + expect(page.locator('#temperature')).to_have_value('42') + expect(page.get_by_role('radio', name='Evaluation')).to_be_checked() + expect(page.locator('#rri')).to_have_value(second_settings['parameters']['rri']) + expect(page.locator('#output-options input[value="detailed"]')).to_be_checked() + assert first.locator('[data-role=varri-frame]').get_attribute('src') is None + with page.expect_download() as restored: + page.locator('#store-parameters').click() + assert Path(restored.value.path()).read_text() == second_archive['parameter.json'] + assert download_archive(first)['intarna-interactions.csv'] == first_csv + + # Names are literal text and cannot introduce markup, duplicate IDs or paths. + first.get_by_label('Session name', exact=True).fill(' / β') + first.get_by_label('Session name', exact=True).press('Enter') + assert first.locator('img').count() == 0 + with page.expect_download() as renamed: + first.locator('[data-role=download-zip]').click() + assert '/' not in renamed.value.suggested_filename + assert '\\' not in renamed.value.suggested_filename + ids = page.locator('[id]').evaluate_all('nodes => nodes.map(node => node.id)') + assert len(ids) == len(set(ids)) + first.get_by_label('Session name', exact=True).fill('') + first.get_by_label('Session name', exact=True).press('Enter') + expect(first.get_by_label('Session name', exact=True)).to_have_value('Prediction 1') + first.get_by_label('Session name', exact=True).fill('Cool duplex — 24 °C') + first.get_by_label('Session name', exact=True).press('Enter') + if args.screenshot: + page.screenshot(path=args.screenshot, full_page=True) + page.set_viewport_size({'width': 390, 'height': 844}) + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + for session in [first, second]: + expect(session.locator('[data-role=download-zip]')).to_be_visible() + expect(session.locator('[data-role=delete]')).to_be_visible() + page.set_viewport_size({'width': 1440, 'height': 1100}) + + # Delete releases only that output; current inputs and other sessions survive. + first.locator('[data-role=delete]').click() + expect(sessions).to_have_count(1) + expect(page.locator('#temperature')).to_have_value('42') + assert download_archive(second) == second_archive + second.locator('[data-role=delete]').click() + expect(sessions).to_have_count(0) + expect(compute).to_be_focused() + expect(page.locator('#temperature')).to_have_value('42') + predict() + assert active.get_attribute('data-session-id') not in [first_id, second_id] + assert download_archive() == second_archive + page.reload() + page.wait_for_load_state('networkidle') + expect(sessions).to_have_count(0) assert errors == [], errors browser.close() -print(f'{args.browser}: real WASM, groups/defaults/versions, prediction/evaluation, FASTA/coordinates, tabs/keyboard, visualization URLs/selection, ZIP replay contents, columns/reports, mobile and abort/recovery passed') +print(f'{args.browser}: real WASM, groups/defaults/versions, prediction/evaluation, FASTA/coordinates, tabs/keyboard, visualization URLs/selection, ZIP replay contents, columns/reports, mobile, abort/recovery and retained session lifecycle passed') From 53594c2f6a85eef5e7a0c7b9b55ad76bf126c1bc Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Sat, 24 Oct 2026 00:25:06 +0200 Subject: [PATCH 2/2] Revise GUI inputs and result controls from PR #299 review --- ChangeLog | 24 +++++ README.md | 3 +- gui/app.js | 176 +++++++++++++++++++++++++--------- gui/columns.js | 20 ++++ gui/docs/json-workflow.md | 46 +++++++-- gui/docs/step3-frontend-ui.md | 45 ++++++--- gui/examples/evaluation.json | 11 ++- gui/examples/prediction.json | 10 +- gui/index.html | 18 ++-- gui/options.js | 121 +++++++++++++---------- gui/parameter-file.js | 8 +- gui/protocol.js | 24 ++--- gui/results.js | 8 +- gui/sessions.js | 15 ++- gui/styles.css | 47 ++++++--- gui/tests/protocol.test.mjs | 40 +++++++- gui/tests/uploads.test.mjs | 4 +- gui/uploads.js | 4 +- scripts/wasm/test-browser.py | 136 ++++++++++++++++++++++++-- 19 files changed, 585 insertions(+), 175 deletions(-) diff --git a/ChangeLog b/ChangeLog index a83bb3cf..c204105e 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,9 @@ ## 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) @@ -282,6 +285,27 @@ 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, diff --git a/README.md b/README.md index e5b5f6b0..ce94edbd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ seeding of interaction sites** Predict short RNA pairs or evaluate supplied structures locally with the [browser interface](https://backofenlab.github.io/IntaRNA/gui/). -Computed runs stay in separate, foldable sessions in the current tab. Name and select sessions to restore their inputs and compare results; editing or resetting parameters keeps earlier runs. +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). diff --git a/gui/app.js b/gui/app.js index d265b466..446a7f60 100644 --- a/gui/app.js +++ b/gui/app.js @@ -1,6 +1,6 @@ import { parseFasta, prepareRequest, CSV_COLUMNS } from './protocol.js'; -import { GROUPS, FIELDS, EXTRA_OUTPUTS } from './options.js'; -import { CSV_CATALOG } from './columns.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'; @@ -44,7 +44,7 @@ function group(key, label) { const body = element('div', '', 'group-body'); panel.append(body); byId('parameter-groups').append(panel); addTab(key, label); return body; } -addTab('sequence-input', 'Sequence input'); +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); @@ -52,34 +52,67 @@ byId('parameter-tabs').addEventListener('keydown', (event) => { 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), grid = element('div', '', 'field-grid'); body.append(grid); - for (const field of definition.fields) { - const wrapper = element('div', '', `field${field.type === 'checkbox' ? ' check' : ''}`); - const label = element('label', field.label); label.htmlFor = field.key; label.title = `--${field.key}`; - const input = element(field.type === 'select' ? 'select' : field.type === 'textarea' ? 'textarea' : 'input'); input.id = field.key; - if (input.tagName === 'INPUT') input.type = field.type; - if (field.type === 'select') for (const [value, name] of Object.entries(field.choices)) { const option = element('option', name); option.value = value; input.append(option); } - if (field.type === 'number') { input.min = field.min; input.max = field.max; input.step = field.step ?? 1; } - if (field.type === 'checkbox') { input.checked = field.value; wrapper.append(input, label); } - else { input.value = field.value; wrapper.append(label, input); } - if (field.hint) { const hint = element('p', field.hint, 'field-hint'); hint.id = `${field.key}-help`; input.setAttribute('aria-describedby', hint.id); wrapper.append(hint); } - if (field.type === 'textarea') { - input.spellcheck = false; - const upload = element('input'); upload.type = 'file'; upload.accept = '.txt,.csv,.shape,.dat'; upload.setAttribute('aria-label', `Load ${field.label} from a local text file`); - upload.dataset.for = field.key; - upload.addEventListener('change', async () => { - const file = upload.files[0]; 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) return; - input.value = content; input.dispatchEvent(new Event('input', { bubbles: true })); - } catch (error) { if (inputRevision === revision) showInputError(error); } - }); wrapper.append(upload); + 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); } - grid.append(wrapper); + 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) { @@ -89,20 +122,38 @@ function checkbox(container, key, labelText, description) { } const outputBody = byId('output-options').querySelector('.group-body'); outputBody.append(element('h3', 'CSV output columns', 'subheading')); -outputBody.append(element('p', 'seq1 is the target; seq2 is the query. Select columns for both the table and CSV download. You can hide table columns after computation without changing the CSV.', 'input-help')); +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 csvPicker = element('div', '', 'checkbox-grid'); outputBody.append(csvPicker); -for (const column of [...CSV_COLUMNS.split(',').map((key) => CSV_CATALOG.find((column) => column.key === key)), ...CSV_CATALOG.filter((column) => !CSV_COLUMNS.split(',').includes(column.key))]) csvInputs.push(checkbox(csvPicker, column.key, `${column.key} — ${column.description}`, column.description)); -for (const [name, selection] of [['Select all', CSV_CATALOG.map((c) => c.key)], ['CLI defaults', CSV_COLUMNS.split(',')], ['Clear selection', []]]) { +const 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', () => { csvOrder = [...selection]; for (const input of csvInputs) input.checked = selection.includes(input.value); inputsChanged(); }); selectionTools.append(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', 'General'); -generalBody.append(element('p', 'Tasks run locally in one worker. Abort or Escape stops computation immediately. Process-level settings (threads, log paths, parameter-file paths and terminal help) are available in the CLI.')); +const 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.') { @@ -111,9 +162,11 @@ function inputsChanged(message = 'Inputs changed. Compute to update the interact 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'; } } @@ -130,9 +183,22 @@ function updateAvailability() { } for (const field of definition.fields) { const input = byId(field.key); - input.disabled = evaluation && !!field.prediction || !evaluation && definition.key === 'helix-options' && byId('model').value !== 'B' || field.key === 'kineticScore' && byId('mode').value !== 'K'; + 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'); } @@ -143,17 +209,26 @@ function resetParameters({ clearInputs = false } = {}) { for (const input of form.querySelectorAll('input[type=file]')) input.value = ''; byId('examples-menu').open = false; activateGroup('sequence-input'); } - csvOrder = CSV_COLUMNS.split(','); - for (const field of FIELDS) { const input = byId(field.key); if (field.type === 'checkbox') input.checked = field.value; else input.value = field.value; } - for (const input of csvInputs) input.checked = CSV_COLUMNS.split(',').includes(input.value); + 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 CLI defaults. Compute to update the interactions.'); + 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) { @@ -169,6 +244,8 @@ function applyParameters(request, message = 'Parameters loaded. Compute to analy 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); @@ -253,13 +330,24 @@ byId('examples-menu').addEventListener('keydown', (event) => { 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: Object.fromEntries(FIELDS.map((field) => [field.key, field.type === 'checkbox' ? byId(field.key).checked : byId(field.key).value])), + parameters: currentParameters(), csvColumns: columns, outputs: outputInputs.filter((input) => input.checked && !input.disabled).map((input) => input.value), }; } @@ -268,7 +356,9 @@ form.addEventListener('submit', async (event) => { const request = currentRequest(); errorBox.hidden = true; let prepared; - try { prepared = prepareRequest(request); } catch (error) { errorBox.textContent = error.message; errorBox.hidden = false; return; } + 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 { diff --git a/gui/columns.js b/gui/columns.js index 6b42b93a..9c096021 100644 --- a/gui/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 index 342d1f24..df5f7dfb 100644 --- a/gui/docs/json-workflow.md +++ b/gui/docs/json-workflow.md @@ -1,6 +1,7 @@ # JSON settings and reproducible runs -Load and Store sit to the right of Reset parameters. Store downloads +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, @@ -35,11 +36,11 @@ 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. Use **Show results** (or **Restore -inputs** after editing), or select its output area, to restore that session’s +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 results** folds the output. Each session +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. @@ -60,8 +61,14 @@ JSON explicitly records that version information is unavailable. ## Reset and examples Reset clears the editable sequences, interactions and uploaded files and restores -prediction defaults. Previously computed sessions remain available. Empty FASTA -fields show the short demonstration sequences as placeholders only. The Example disclosure menu sits to the left of Reset. +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 @@ -73,9 +80,13 @@ 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 60 KB), validated with the same FASTA checks as +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 multi-FASTA; evaluation requires one record. Invalid files leave the +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. @@ -117,3 +128,22 @@ pending file read, so delayed reads cannot replace newer input. `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/gui/docs/step3-frontend-ui.md b/gui/docs/step3-frontend-ui.md index 5cc524bd..e0436b75 100644 --- a/gui/docs/step3-frontend-ui.md +++ b/gui/docs/step3-frontend-ui.md @@ -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. diff --git a/gui/examples/evaluation.json b/gui/examples/evaluation.json index b8994f18..84cab246 100644 --- a/gui/examples/evaluation.json +++ b/gui/examples/evaluation.json @@ -5,9 +5,14 @@ "schema": "IntaRNA-parameters-v1", "parameters": { "personality": "IntaRNAeval", - "query": {"text": ">query\nGGGAGGAGGG\n"}, - "target": {"text": ">target\nCCCUCCUCCC\n"}, - "rri": "1||||||||||&1||||||||||" + "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/prediction.json b/gui/examples/prediction.json index 2b834042..bd76029a 100644 --- a/gui/examples/prediction.json +++ b/gui/examples/prediction.json @@ -4,8 +4,14 @@ "configuration": { "schema": "IntaRNA-parameters-v1", "parameters": { - "query": {"text": ">query\nGGGAGGAGGG\n"}, - "target": {"text": ">target\nCCCUCCUCCC\n"} + "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 index 679c15b0..a77b74e7 100644 --- a/gui/index.html +++ b/gui/index.html @@ -14,7 +14,7 @@
@@ -38,33 +38,33 @@
5′ to 3′
or drop in the field
- -
No sequence entered
+ +
No sequence entered
5′ to 3′
or drop in the field
- -
No sequence entered
+ +
No sequence entered
-

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

+

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.

-
Example

Loading examples…

+
Input Parameters:
Examples

Loading examples…

-

Sessions

Enter your sequences or choose an example, then compute.

+

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.