diff --git a/ChangeLog b/ChangeLog index 68eb270b..f785ee9c 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,10 @@ ## Interface and handling +- select the first browser result and show its interaction visualization + automatically; reuse CSV help text as result-header tooltips and store bundled + examples in directly importable/exportable parameter JSON (#315) + - explain all browser input, CopomuS ranking and candidate-filter controls with tooltips; annotate compensatory mutations in one vaRRI view and compare all four-state energies in an adjacent Plotly chart (#313) @@ -341,6 +345,17 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261027 Alexander Mitrofanov + * gui/{results.js,sessions.js,index.html}, scripts/wasm/test-browser.py + * Select the first energy-sorted interaction when a computation completes and + load its vaRRI visualization; retain the selection through later table + sorting and filtering. + * Apply each CSV catalog description to both its result-header cell and sort + button, matching the corresponding CSV-selection tooltip (issue #315). + * gui/{app.js,examples/,tests/parameter-file.test.mjs}, gui/docs/ + * Move example display metadata into the example index so every example file + is an ordinary Store/Load parameter document with no wrapper (issue #315). + 261027 Alexander Mitrofanov * gui/{index.html,app.js,options.js,copomus-options.js,styles.css} + Add explanatory tooltips to every input label and all CopomuS ranking diff --git a/gui/app.js b/gui/app.js index a1e84390..2e4eee9f 100644 --- a/gui/app.js +++ b/gui/app.js @@ -357,15 +357,17 @@ async function loadExamples() { 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 (!Array.isArray(files) || !files.length || files.some((example) => + typeof example?.file !== 'string' || !/^[a-z0-9-]+\.json$/.test(example.file) + || typeof example.name !== 'string' || !example.name.trim() + || typeof example.description !== 'string' || !example.description.trim() + )) throw new Error('Invalid example list.'); + const examples = await Promise.all(files.map(async (metadata) => { + const response = await fetch(new URL(`./examples/${metadata.file}`, 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); + const request = importParameters(await response.json()); prepareRequest(request); - return { ...example, request }; + return { ...metadata, request }; })); container.replaceChildren(); for (const example of examples) { diff --git a/gui/docs/json-workflow.md b/gui/docs/json-workflow.md index bd1f8dee..d5883075 100644 --- a/gui/docs/json-workflow.md +++ b/gui/docs/json-workflow.md @@ -71,12 +71,14 @@ fields show the short demonstration sequences as placeholders only. The Examples disclosure menu follows the Input Parameters label and lists only examples for the selected Prediction, Evaluation or Mutation Design task. -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. Three replaceable -examples cover prediction, evaluation and mutation design. 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. +Maintainers edit `gui/examples/index.json`, whose entries provide a local JSON +filename, display name and description. Each listed example file is directly the +same native JSON parameter document used by Store and Load: a maintainer can set +up an input in the GUI, download it with Store and replace an example file without +adding an example-only wrapper. Three replaceable examples cover prediction, +evaluation and mutation design. 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 diff --git a/gui/docs/step4-results-and-deployment.md b/gui/docs/step4-results-and-deployment.md index c94ab322..7ce27b0a 100644 --- a/gui/docs/step4-results-and-deployment.md +++ b/gui/docs/step4-results-and-deployment.md @@ -20,15 +20,16 @@ computed **mfe Δ** is `E - min(reported E for the same query–target pair)`, r to hundredths. Evaluation uses the best supplied structure as its reference. This is relative suboptimality, not a free-energy change or probability. -Every heading is a keyboard-operable sort button with `aria-sort`. Search covers -all returned values. A separate checklist shows/hides table columns after the -run; it does not change selected CSV output. All identifiers, output previews -and diagnostics render with `textContent`. The table has a 500 px maximum height -with vertical scrolling for longer results and a sticky heading; CSV downloads -still contain every selected row. Selected extra files appear below the -table with their original filename, covered sequence/sequence-pair names, a -header-level download, and bounded text previews, including SVG source. -Empty output is successful and still permits CSV/accessibility downloads. +Every heading is a keyboard-operable sort button with `aria-sort`; its hover +tooltip repeats the matching CSV-selection description. Search covers all +returned values. A separate checklist shows/hides table columns after the run; +it does not change selected CSV output. All identifiers, output previews and +diagnostics render with `textContent`. The table has a 500 px maximum height with +vertical scrolling for longer results and a sticky heading; CSV downloads still +contain every selected row. Selected extra files appear below the table with +their original filename, covered sequence/sequence-pair names, a header-level +download, and bounded text previews, including SVG source. Empty output is +successful and still permits CSV/accessibility downloads. ## Additional output visualizations @@ -52,10 +53,11 @@ deleted. Non-plot formats keep the direct content view. ## 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 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. +When computation returns interactions, the first row in the initial energy-sorted +table is selected and visualized automatically. 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 @@ -73,9 +75,9 @@ viewer. The local status always identifies the selected pair and coordinates. Zero-based CLI start indices remain accepted for computation/export, but vaRRI requires nonzero start indices; choosing View for such a run explains this limit. -No external viewer loads until a row is selected. The interface explains that -selection shares that pair and structure through vaRRI's URL. Computation and -ZIP generation remain local. CSP permits frames only at the vaRRI deployment; +The first successful interaction result loads the external viewer automatically; +choosing another row shares that pair and structure through vaRRI's URL. Computation +and ZIP generation remain local. CSP permits frames only at the vaRRI deployment; the iframe sends no referrer. Viewer availability depends on that external site; the full-viewer link permits retry, and scientific outputs remain downloadable. diff --git a/gui/examples/evaluation.json b/gui/examples/evaluation.json index e019806f..7d81ebd6 100644 --- a/gui/examples/evaluation.json +++ b/gui/examples/evaluation.json @@ -1,18 +1,14 @@ { - "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": ">ncRNA1\nAGGAUGGGGGAAACCCCAUACUCCUCACACACCAAAUCGCCCGAUUUAUCGGGCUUUUUU\n" - }, - "target": { - "text": ">mRNA1\nUUUAAAUUAAAAAAUCAUAGAAAAAGUAUCGUUUGAUACUUGUGAUUAUACUCAGUUAUA\nCAGUAUCUUAAGGUGUUAUUAAUAGUGGUGAGGAGAAUUUAUGAAGCUUUUCAAAAGCUU\nGCUUGUGGCACCUGCAACUCUUGGUCUUUUAGCACCAAUGACCGCUACUGCUAAU\n" - }, - "rri": "85|||||||||||&21||||||||.|||", - "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" - } + "schema": "IntaRNA-parameters-v1", + "parameters": { + "personality": "IntaRNAeval", + "query": { + "text": ">ncRNA1\nAGGAUGGGGGAAACCCCAUACUCCUCACACACCAAAUCGCCCGAUUUAUCGGGCUUUUUU\n" + }, + "target": { + "text": ">mRNA1\nUUUAAAUUAAAAAAUCAUAGAAAAAGUAUCGUUUGAUACUUGUGAUUAUACUCAGUUAUA\nCAGUAUCUUAAGGUGUUAUUAAUAGUGGUGAGGAGAAUUUAUGAAGCUUUUCAAAAGCUU\nGCUUGUGGCACCUGCAACUCUUGGUCUUUUAGCACCAAUGACCGCUACUGCUAAU\n" + }, + "rri": "85|||||||||||&21||||||||.|||", + "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" } } diff --git a/gui/examples/index.json b/gui/examples/index.json index 1980f591..00d9d1b2 100644 --- a/gui/examples/index.json +++ b/gui/examples/index.json @@ -1 +1,17 @@ -["prediction.json", "evaluation.json", "mutation-design.json"] +[ + { + "file": "prediction.json", + "name": "Short duplex", + "description": "Predict interactions between two complementary ten-nucleotide RNAs." + }, + { + "file": "evaluation.json", + "name": "Evaluate a duplex", + "description": "Evaluate the energy of a supplied ten-base-pair RNA interaction." + }, + { + "file": "mutation-design.json", + "name": "Compensatory duplex mutation", + "description": "Rank an explicit compensatory mutation for a short RNA duplex." + } +] diff --git a/gui/examples/mutation-design.json b/gui/examples/mutation-design.json index 471dbde1..e4bccfa4 100644 --- a/gui/examples/mutation-design.json +++ b/gui/examples/mutation-design.json @@ -1,27 +1,23 @@ { - "name": "Compensatory duplex mutation", - "description": "Rank an explicit compensatory mutation for a short RNA duplex.", - "configuration": { - "schema": "IntaRNA-parameters-v1", - "parameters": { - "query": { - "text": ">query\nGGGAGGAGGG\n" - }, - "target": { - "text": ">target\nCCCUCCUCCC\n" - } + "schema": "IntaRNA-parameters-v1", + "parameters": { + "query": { + "text": ">query\nGGGAGGAGGG\n" }, - "gui": { - "task": "mutation", - "copomus": { - "measures": ["mfeCover", "E", "minDeltaE"], - "candidateSelection": "mfe", - "candidateFilters": [], - "generator": "flip", - "mutationEncoding": "G1C&C10G", - "alpha": 1, - "beta": 1 - } + "target": { + "text": ">target\nCCCUCCUCCC\n" + } + }, + "gui": { + "task": "mutation", + "copomus": { + "measures": ["mfeCover", "E", "minDeltaE"], + "candidateSelection": "mfe", + "candidateFilters": [], + "generator": "flip", + "mutationEncoding": "G1C&C10G", + "alpha": 1, + "beta": 1 } } } diff --git a/gui/examples/prediction.json b/gui/examples/prediction.json index 50d7600d..1a3e2c07 100644 --- a/gui/examples/prediction.json +++ b/gui/examples/prediction.json @@ -1,17 +1,13 @@ { - "name": "Short duplex", - "description": "Predict interactions between two complementary ten-nucleotide RNAs.", - "configuration": { - "schema": "IntaRNA-parameters-v1", - "parameters": { - "query": { - "text": ">ncRNA1\nAGGAUGGGGGAAACCCCAUACUCCUCACACACCAAAUCGCCCGAUUUAUCGGGCUUUUUU\n>ncRNA2\nACUGAGGUACGAUCGUGGCAGGGCCUUUGACUGACUUUCGUACUUU" - }, - "target": { - "text": ">mRNA1\nUUUAAAUUAAAAAAUCAUAGAAAAAGUAUCGUUUGAUACUUGUGAUUAUACUCAGUUAUA\nCAGUAUCUUAAGGUGUUAUUAAUAGUGGUGAGGAGAAUUUAUGAAGCUUUUCAAAAGCUU\nGCUUGUGGCACCUGCAACUCUUGGUCUUUUAGCACCAAUGACCGCUACUGCUAAU\n>mRNA2\nUGCUUCUUAGUAAUGGCCAGCUCGGGGAUUUGGAACUGGUGGUUGUUAAUUCUAGGCAAA\nUAGAUUAAAAUUAAAUUACUCAAAUUGUGUGACGAGUUUUAUGAAGCUUUUUAAAAGCUU\nGUUGGUAGCUCCAGCAACGAUUGGGCUACUAGCUCCAUUUUCAACGUUUGCUGGC\n" - }, - "outNumber": 10, - "outCsvCols": "id1,start1,end1,id2,start2,end2,subseqDP,hybridDP,E,ED1,ED2,seedE,seedStart1,seedStart2" - } + "schema": "IntaRNA-parameters-v1", + "parameters": { + "query": { + "text": ">ncRNA1\nAGGAUGGGGGAAACCCCAUACUCCUCACACACCAAAUCGCCCGAUUUAUCGGGCUUUUUU\n>ncRNA2\nACUGAGGUACGAUCGUGGCAGGGCCUUUGACUGACUUUCGUACUUU" + }, + "target": { + "text": ">mRNA1\nUUUAAAUUAAAAAAUCAUAGAAAAAGUAUCGUUUGAUACUUGUGAUUAUACUCAGUUAUA\nCAGUAUCUUAAGGUGUUAUUAAUAGUGGUGAGGAGAAUUUAUGAAGCUUUUCAAAAGCUU\nGCUUGUGGCACCUGCAACUCUUGGUCUUUUAGCACCAAUGACCGCUACUGCUAAU\n>mRNA2\nUGCUUCUUAGUAAUGGCCAGCUCGGGGAUUUGGAACUGGUGGUUGUUAAUUCUAGGCAAA\nUAGAUUAAAAUUAAAUUACUCAAAUUGUGUGACGAGUUUUAUGAAGCUUUUUAAAAGCUU\nGUUGGUAGCUCCAGCAACGAUUGGGCUACUAGCUCCAUUUUCAACGUUUGCUGGC\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 e31b3eea..d982ccb6 100644 --- a/gui/index.html +++ b/gui/index.html @@ -96,7 +96,7 @@

Compensatory mutation settings

Interaction visualization

Select View on an interaction to visualize it.

-

Selecting View loads vaRRI with that sequence pair and structure in its URL.

+

The first interaction is selected automatically. Selecting another View loads vaRRI with that sequence pair and structure in its URL.

diff --git a/gui/results.js b/gui/results.js index 92a06f70..0029f18f 100644 --- a/gui/results.js +++ b/gui/results.js @@ -97,11 +97,17 @@ export class ResultsView { label.append(input, document.createTextNode(column.label)); this.picker.append(label); } this.search.value = ''; this.key = columns.includes('E') ? 'E' : columns[0]; this.direction = 1; - this.root.hidden = false; this.render(); return this.rows.length; + this.root.hidden = false; this.render(true); return this.rows.length; } - render() { + render(selectFirst = false) { const rows = selectRows(this.rows, this.search.value, this.key, this.direction); const columns = this.columns.filter((column) => this.visible.has(column.key)); + // The completed run opens on the same interaction shown at the top of the + // initial table; later sorting and filtering retain that selection. + if (selectFirst && rows.length) { + this.selected = rows[0]; + this.visualization.show(this.selected, this.request); + } this.header.replaceChildren(); const viewHeading = document.createElement('th'); viewHeading.scope = 'col'; viewHeading.setAttribute('aria-label', 'Interaction visualization'); this.header.append(viewHeading); @@ -111,7 +117,7 @@ export class ResultsView { 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; + cell.title = 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/gui/sessions.js b/gui/sessions.js index f7fa033c..24ceaa3e 100644 --- a/gui/sessions.js +++ b/gui/sessions.js @@ -25,10 +25,11 @@ export class SessionsView { 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. + // and can be visualized again without recomputing the prediction. A newly + // rendered result already has its initial viewer URL and must not reload it. if (!expanded) session.view.visualization.clear(); else { - if (session.view.selected) session.view.visualization.show(session.view.selected, session.view.request); + if (session.view.selected && !session.view.visualization.frame.hasAttribute('src')) session.view.visualization.show(session.view.selected, session.view.request); for (const fileView of session.fileViews) fileView.resize(); } } diff --git a/gui/tests/parameter-file.test.mjs b/gui/tests/parameter-file.test.mjs index 40357c6b..4bbe922e 100644 --- a/gui/tests/parameter-file.test.mjs +++ b/gui/tests/parameter-file.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { DEFAULTS, EVALUATION_EXAMPLE, prepareRequest, CSV_COLUMNS } from '../protocol.js'; +import { DEFAULTS, EVALUATION_EXAMPLE, prepareRequest, CSV_COLUMNS, JSON_SCHEMA } from '../protocol.js'; import { FIELDS } from '../options.js'; import { exportParameters, serializeParameters, importParameters } from '../parameter-file.js'; const provenance = 'IntaRNA test\nViennaRNA test\n'; @@ -50,10 +50,12 @@ test('maintainer examples use the same JSON import and prediction validation', a const examples = JSON.parse(await readFile(new URL('../examples/index.json', import.meta.url))); assert.equal(examples.length, 3); 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); + for (const metadata of examples) { + assert.ok(metadata.file && metadata.name && metadata.description); + const example = JSON.parse(await readFile(new URL(`../examples/${metadata.file}`, import.meta.url))); + assert.equal(example.schema, JSON_SCHEMA); + assert.equal(example.configuration, undefined); + tasks.add(prepareRequest(importParameters(example)).task); } assert.deepEqual([...tasks].sort(), ['evaluation', 'mutation', 'prediction']); }); diff --git a/scripts/wasm/test-browser.py b/scripts/wasm/test-browser.py index 00e992fe..b8fdeadf 100644 --- a/scripts/wasm/test-browser.py +++ b/scripts/wasm/test-browser.py @@ -319,8 +319,13 @@ def predict(): fill('outNumber', 10) open_group('csv-options') page.locator('#outCsvSort').select_option('E') + visualization_count = len(visualization_urls) predict() expect(rows).to_have_count(10) + expect(rows.first.locator('button')).to_have_attribute('aria-pressed', 'true') + 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() + assert len(visualization_urls) == visualization_count + 1 assert active.locator('[data-role=results-view] .table-scroll').evaluate('''node => { const body = node.querySelector('tbody'); const copies = [...body.children].map(row => row.cloneNode(true)); @@ -357,11 +362,11 @@ def predict(): assert 'deltaE' not in csv assert active.locator('thead th').first.inner_text() == '' assert active.locator('thead th button br').count() >= 4 + energy_heading = active.get_by_role('button', name='Sort by Energy (kcal/mol)', exact=False) + assert energy_heading.get_attribute('title') == page.locator('#csv-options input[value="E"]').get_attribute('title') + assert energy_heading.locator('xpath=..').get_attribute('title') == energy_heading.get_attribute('title') assert rows.first.locator('td').first.evaluate('node => getComputedStyle(node).paddingLeft') == '5px' - assert not visualization_urls # Third-party content loads only on selection. - rows.first.get_by_role('button', name='Visualize').click() - 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() + assert len(visualization_urls) == visualization_count + 1 # Sorting and filtering retain the initial selection. expect(rows.first.locator('button')).to_have_attribute('aria-pressed', 'true') query = parse_qs(urlparse(active.locator('[data-role=varri-frame]').get_attribute('src')).query) assert query['sequence'] == ['CCCUCCUCCC&GGGAGGAGGG']