From 5cd21f3eb8fd0f0aea29369bfe5116712bc178fe Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Tue, 27 Oct 2026 12:07:58 +0100 Subject: [PATCH] feat(gui): refine mutation design results --- ChangeLog | 18 ++++++++ README.md | 2 +- gui/app.js | 13 ++++-- gui/copomus-options.js | 22 +++++---- gui/copomus.js | 88 ++++++++++++++++++++++++------------ gui/docs/copomus.md | 15 +++--- gui/index.html | 36 +++++++-------- gui/options.js | 66 ++++++++++++++++++++++++++- gui/styles.css | 31 +++++++------ gui/tests/copomus.test.mjs | 32 +++++++++++++ gui/visualization.js | 7 +-- scripts/wasm/test-browser.py | 20 ++++++-- 12 files changed, 262 insertions(+), 88 deletions(-) create mode 100644 gui/tests/copomus.test.mjs diff --git a/ChangeLog b/ChangeLog index b8cbf96d..68eb270b 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,10 @@ ## Interface and handling +- 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) + - isolate real-browser numerical fixtures from editable GUI examples and keep CSV export checks synchronized with selected columns and seed-end defaults @@ -337,6 +341,20 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +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 + measures and candidate filters; set both FASTA input minima to 400 pixels. + * gui/{copomus.js,visualization.js,index.html,styles.css}, gui/tests/, + scripts/wasm/test-browser.py + * Replace the four selectable mutation-state viewers with one wild-type vaRRI + view that annotates both designed substitutions in dark green at their + original signed coordinates. + + Plot the ww, wm, mw and mm interaction energies beside vaRRI, including a + dotted blue wild-type baseline and responsive stacked layout (issue #313). + * README.md, gui/docs/copomus.md + * Document the annotated single-view and four-state energy comparison. + 261026 Alexander Mitrofanov * scripts/wasm/test-browser.py * Stop loading editable GUI examples as numerical browser-test fixtures; seed diff --git a/README.md b/README.md index 30ac8f69..8299dd0d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ seeding of interaction sites** Predict short RNA pairs, evaluate supplied structures or rank compensatory mutations locally with the [browser interface](https://backofenlab.github.io/IntaRNA/gui/). The browser accepts up to 50 records per strand, 2–5,000 nucleotides each, with longest selected query × longest selected target at most 1,000,000. Prediction defaults to 10 interactions per pair and includes opening and seed energies; evaluation selects all energy columns. -Mutation Design accepts one unambiguous query/target pair, runs CopomuS in a self-hosted Pyodide worker, and uses the same IntaRNA WebAssembly engine for every wildtype, single-mutant and compensatory prediction. Its extended table and vaRRI controls compare all four mutation states. +Mutation Design accepts one unambiguous query/target pair, runs CopomuS in a self-hosted Pyodide worker, and uses the same IntaRNA WebAssembly engine for every wildtype, single-mutant and compensatory prediction. Its extended table links one annotated vaRRI view with a four-state interaction-energy chart. 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. Minimum-energy and interaction-probability output files include local Plotly visualizations with PNG export; every additional file retains its original text view and download. diff --git a/gui/app.js b/gui/app.js index 51ddae30..a1e84390 100644 --- a/gui/app.js +++ b/gui/app.js @@ -26,8 +26,8 @@ function element(tag, text, className) { return node; } const copomusMeasureInputs = [], copomusMeasureRows = new Map(); -for (const [key, labelText] of COPOMUS_MEASURES) { - const input = checkbox(byId('copomus-measures'), key, labelText), label = input.parentElement; +for (const [key, labelText, description] of COPOMUS_MEASURES) { + const input = checkbox(byId('copomus-measures'), key, labelText, description), label = input.parentElement; const row = element('div', '', 'measure-choice'), actions = element('span', '', 'measure-priority'); label.replaceWith(row); row.append(label, actions); copomusMeasureRows.set(key, row); copomusMeasureInputs.push(input); for (const [text, direction] of [['↑', -1], ['↓', 1]]) { @@ -42,7 +42,7 @@ for (const [key, labelText] of COPOMUS_MEASURES) { actions.append(button); } } -const copomusFilterInputs = COPOMUS_FILTERS.map(([key, label]) => checkbox(byId('copomus-filters'), key, label)); +const copomusFilterInputs = COPOMUS_FILTERS.map(([key, label, description]) => checkbox(byId('copomus-filters'), key, label, description)); function orderCopomusMeasures(selected) { const order = [...selected, ...COPOMUS_MEASURES.map(([key]) => key).filter((key) => !selected.includes(key))]; for (const key of order) byId('copomus-measures').append(copomusMeasureRows.get(key)); @@ -79,9 +79,10 @@ byId('parameter-tabs').addEventListener('keydown', (event) => { // 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 label = element('label', field.label); label.htmlFor = id; label.title = field.help; const input = element(field.type === 'select' ? 'select' : field.type === 'textarea' ? 'textarea' : 'input'); input.id = id; if (input.tagName === 'INPUT') input.type = field.type; + input.title = field.help; 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); } @@ -116,7 +117,9 @@ for (const definition of GROUPS) { const primary = element('div', '', 'seed-primary-row'); const wrapper = element('div', '', 'field seed-mode'); const label = element('label', 'Seed constraint'); label.htmlFor = 'seed-mode'; + label.title = 'Chooses whether prediction uses no seed, one or more explicitly encoded seeds, or automatically identified seed interactions.'; const select = element('select'); select.id = 'seed-mode'; + select.title = label.title; 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); } @@ -146,7 +149,7 @@ for (const definition of GROUPS) { } function checkbox(container, key, labelText, description) { const label = element('label'), input = element('input'); input.type = 'checkbox'; input.value = key; - if (description) label.title = description; + if (description) { label.title = description; input.title = description; } label.append(input, document.createTextNode(labelText)); container.append(label); return input; } const csvBody = byId('csv-options').querySelector('.group-body'); diff --git a/gui/copomus-options.js b/gui/copomus-options.js index 17c5d362..e1ae4885 100644 --- a/gui/copomus-options.js +++ b/gui/copomus-options.js @@ -1,14 +1,20 @@ export const COPOMUS_MEASURES = Object.freeze([ - ['mfeCover', 'MFE coverage'], ['E', 'Interaction energy'], - ['minDeltaE', 'Minimum energy difference'], ['EDqi', 'Query accessibility'], - ['cEDqi', 'Constant query accessibility'], ['Eqi', 'Query energy profile'], - ['Eti', 'Target energy profile'], + ['mfeCover', 'MFE coverage', 'Prefers mutations whose designed positions lie inside both the wild-type and compensatory minimum-free-energy interactions.'], + ['E', 'Interaction energy', 'Checks that both matching pairs interact favorably while each single mutant weakens the interaction by the configured separation threshold.'], + ['minDeltaE', 'Minimum energy difference', 'Ranks by the smallest energy separation between a matching pair and either single-mutant combination.'], + ['EDqi', 'Query accessibility', 'Prefers mutations whose compensatory query nucleotide is less accessible than its wild-type counterpart.'], + ['cEDqi', 'Constant query accessibility', 'Prefers mutations that preserve the query nucleotide accessibility within one kcal/mol.'], + ['Eqi', 'Query energy profile', 'Checks the interaction-energy profile at the mutated query position across all four sequence combinations.'], + ['Eti', 'Target energy profile', 'Checks the interaction-energy profile at the mutated target position across all four sequence combinations.'], ]); export const COPOMUS_FILTERS = Object.freeze([ - ['GU', 'Exclude GU/UG pairs'], ['AU', 'Exclude AU/UA pairs'], - ['GC', 'Exclude GC/CG pairs'], ['lp', 'Exclude unstackable lonely pairs'], - ['lpMfe', 'Exclude lonely pairs in the MFE'], ['he', 'Exclude potential helix ends'], - ['heMfe', 'Exclude helix ends in the MFE'], + ['GU', 'Exclude GU/UG pairs', 'Removes candidate positions whose wild-type base pair is GU or UG.'], + ['AU', 'Exclude AU/UA pairs', 'Removes candidate positions whose wild-type base pair is AU or UA.'], + ['GC', 'Exclude GC/CG pairs', 'Removes candidate positions whose wild-type base pair is GC or CG.'], + ['lp', 'Exclude unstackable lonely pairs', 'Removes candidates that cannot stack with a neighboring canonical base pair in the input sequences.'], + ['lpMfe', 'Exclude lonely pairs in the MFE', 'Removes candidates that form a lonely base pair in the wild-type minimum-free-energy interaction.'], + ['he', 'Exclude potential helix ends', 'Removes candidates that could occur at either end of an interaction helix.'], + ['heMfe', 'Exclude helix ends in the MFE', 'Removes candidates located at a helix end in the wild-type minimum-free-energy interaction.'], ]); export const COPOMUS_DEFAULTS = Object.freeze({ measures: ['mfeCover', 'E', 'minDeltaE'], diff --git a/gui/copomus.js b/gui/copomus.js index db522bfd..b9e2ddfd 100644 --- a/gui/copomus.js +++ b/gui/copomus.js @@ -3,54 +3,86 @@ import { InteractionVisualization } from './visualization.js'; export { COPOMUS_MEASURES, COPOMUS_FILTERS, COPOMUS_DEFAULTS, normalizeCopomus } from './copomus-options.js'; const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); +const MUTATION_COLOR = '006400'; +const ENERGY_STATES = Object.freeze(['ww', 'wm', 'mw', 'mm']); -const MODE_LABELS = Object.freeze({ - ww: 'Wild type', wm: 'Target mutant', mw: 'Query mutant', mm: 'Compensatory mutant', -}); +function numeric(value) { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +export function mutationAnnotations(candidate) { + const queryPosition = Number(candidate.values.qIndex), targetPosition = Number(candidate.values.tIndex); + const mutated = String(candidate.values.bpMutated ?? ''); + if (!Number.isInteger(queryPosition) || queryPosition === 0 || !Number.isInteger(targetPosition) || targetPosition === 0 || !/^[ACGU]{2}$/.test(mutated)) throw new Error('The selected mutation has invalid sequence coordinates or bases.'); + // vaRRI receives native sequence 1 (target) before sequence 2 (query), while + // CopomuS stores each mutation and base pair in query-first order. + return `1:${targetPosition}${mutated[1]}:${MUTATION_COLOR},2:${queryPosition}${mutated[0]}:${MUTATION_COLOR}`; +} + +export function mutationEnergyPlot(candidate) { + const energies = ENERGY_STATES.map((state) => numeric(candidate.interactions[state]?.E)); + const wildtype = energies[0]; + return { + data: [{ + type: 'bar', x: [...ENERGY_STATES], y: energies, + marker: { color: '#79a9cc', line: { color: '#145a96', width: 1 } }, + hovertemplate: '%{x}: %{y:.2f} kcal/mol', + }], + layout: { + autosize: true, height: 398, margin: { l: 58, r: 14, t: 18, b: 48 }, + showlegend: false, paper_bgcolor: '#ffffff', plot_bgcolor: '#ffffff', + font: { family: '"Segoe UI", Tahoma, Geneva, sans-serif', color: '#1c2b39', size: 12 }, + xaxis: { type: 'category', fixedrange: true }, + yaxis: { title: { text: 'E [kcal/mol]' }, fixedrange: true, zerolinecolor: '#ccd9e5', gridcolor: '#e0e8f0' }, + shapes: wildtype === null ? [] : [{ + type: 'line', xref: 'paper', x0: 0, x1: 1, yref: 'y', y0: wildtype, y1: wildtype, + line: { color: '#145a96', width: 2, dash: 'dot' }, + }], + }, + config: { displayModeBar: false, responsive: true, staticPlot: false }, + }; +} class MutationVisualization { constructor(root) { this.root = root; this.viewer = new InteractionVisualization(root, 'mutation-'); - this.controls = root.querySelector('[data-role=mutation-modes]'); this.title = root.querySelector('[data-role=mutation-visualization-title]'); - for (const button of this.controls.querySelectorAll('button')) button.addEventListener('click', () => this.showMode(button.value)); + this.plot = root.querySelector('[data-role=mutation-energy-plot]'); } clear() { - this.candidate = null; this.request = null; this.controls.hidden = true; + this.candidate = null; this.request = null; + if (globalThis.Plotly?.purge) globalThis.Plotly.purge(this.plot); + this.plot.hidden = true; this.viewer.clear(); this.title.textContent = 'Compensatory-mutation visualization'; } show(candidate, request) { - this.candidate = candidate; this.request = request; this.controls.hidden = false; - this.showMode(candidate.interactions.mm ? 'mm' : Object.keys(MODE_LABELS).find((mode) => candidate.interactions[mode]) ?? 'mm'); - } - - showMode(mode) { - for (const button of this.controls.querySelectorAll('button')) button.setAttribute('aria-pressed', String(button.value === mode)); - this.title.textContent = `${this.candidate.values.mutation}: ${MODE_LABELS[mode]}`; - const interaction = this.candidate.interactions[mode]; + this.candidate = candidate; this.request = request; + this.title.textContent = candidate.values.mutation; + const interaction = candidate.interactions.ww; if (!interaction) { this.viewer.clear(); - this.viewer.status.textContent = `${MODE_LABELS[mode]} has no favorable reported interaction.`; - return; + this.viewer.status.textContent = 'The wild type has no favorable reported interaction to visualize.'; + } else { + const visualRequest = { + ...request, + query: { ...request.query, records: [{ ...request.query.records[0], sequence: candidate.queryWildtype }] }, + target: { ...request.target, records: [{ ...request.target.records[0], sequence: candidate.targetWildtype }] }, + }; + try { + this.viewer.show(interaction, visualRequest, { mutations: mutationAnnotations(candidate) }); + if (!this.viewer.frame.hidden) this.viewer.status.textContent += ' Designed point mutations are annotated in dark green.'; + } catch (error) { this.viewer.clear(); this.viewer.status.textContent = error.message; } } - const querySequence = mode[0] === 'w' ? this.candidate.queryWildtype : this.candidate.queryMutated; - const targetSequence = mode[1] === 'w' ? this.candidate.targetWildtype : this.candidate.targetMutated; - const visualRequest = { - ...this.request, - query: { ...this.request.query, records: [{ ...this.request.query.records[0], sequence: querySequence }] }, - target: { ...this.request.target, records: [{ ...this.request.target.records[0], sequence: targetSequence }] }, - }; - this.viewer.show(interaction, visualRequest); + const plot = mutationEnergyPlot(candidate); + this.plot.hidden = false; + this.plot.setAttribute('aria-label', `Interaction energies for ${candidate.values.mutation}: ${ENERGY_STATES.map((state, index) => `${state} ${plot.data[0].y[index] ?? 'not available'}`).join(', ')} kcal/mol`); + if (globalThis.Plotly?.newPlot) globalThis.Plotly.newPlot(this.plot, plot.data, plot.layout, plot.config); } } -function numeric(value) { - return typeof value === 'number' && Number.isFinite(value) ? value : null; -} - export class CopomusResultsView { constructor(root) { this.root = root; this.candidates = []; this.key = 'rank'; this.direction = 1; diff --git a/gui/docs/copomus.md b/gui/docs/copomus.md index 5ed6a533..8c037d4f 100644 --- a/gui/docs/copomus.md +++ b/gui/docs/copomus.md @@ -38,12 +38,15 @@ folding runs; larger screens remain available through `python/CopomuS.py`. ## Results and reproducibility The sortable/filterable table contains the complete extended CopomuS TSV. Select -**View** to switch the embedded vaRRI visualization between the four mutation -states; a missing favorable interaction is reported explicitly. Session ZIPs -contain `parameter.json` and `copomus-mutations.tsv`. The JSON keeps native -IntaRNA settings and records the task and CopomuS settings under `gui`, so Load -restores the complete editable run. As with other GUI sessions, all data stays -in the tab and disappears on reload unless downloaded. +**View** to show the wild-type interaction once in vaRRI, with both designed +substitutions annotated at their original signed sequence coordinates in dark +green. A compact Plotly bar chart beside it compares the `ww`, `wm`, `mw` and +`mm` interaction energies and marks `ww` with a dotted baseline; unavailable +interactions remain empty. Session ZIPs contain `parameter.json` and +`copomus-mutations.tsv`. The JSON keeps native IntaRNA settings and records the +task and CopomuS settings under `gui`, so Load restores the complete editable +run. As with other GUI sessions, all data stays in the tab and disappears on +reload unless downloaded. The WASM workflow vendors no generated runtime files in Git. Build staging downloads Pyodide 314.0.7 from its npm release, verifies the pinned SHA-256, diff --git a/gui/index.html b/gui/index.html index 2e9bbca6..e31b3eea 100644 --- a/gui/index.html +++ b/gui/index.html @@ -30,9 +30,9 @@ Task, sequences and parameters
Analysis task - - - + + +

Predict interactions for all query–target sequence pairs.

@@ -40,30 +40,30 @@
-
5′ to 3′
or drop in the field
+
5′ to 3′
or drop in the field
-
No sequence entered
+
No sequence entered
-
5′ to 3′
or drop in the field
+
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 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.

- +
@@ -108,12 +108,12 @@

Compensatory mutation settings

Ranks and scores follow the selected CopomuS measures. NA denotes an unavailable interaction or profile score.

Compensatory-mutation visualization

-
diff --git a/gui/options.js b/gui/options.js index 23010c7b..9f06d224 100644 --- a/gui/options.js +++ b/gui/options.js @@ -7,6 +7,70 @@ const select = (key, label, value, choices, extra = {}) => ({ key, label, value, const flag = (key, label, extra = {}) => ({ key, label, value: false, type: 'checkbox', ...extra }); const text = (key, label, extra = {}) => ({ key, label, value: '', type: 'text', ...extra }); const prediction = { prediction: true }; +const strandHelp = (strand) => ({ + Set: `Selects which ${strand} FASTA records participate in the computation; an empty value uses every record.`, + Acc: `Chooses whether ${strand} accessibility is computed, ignored, or imported as unpaired probabilities or opening energies.`, + AccW: `Sets the sliding window used to compute local ${strand} accessibility; zero folds the complete sequence.`, + AccL: `Limits the base-pair span considered while computing ${strand} accessibility; zero uses the accessibility-window size.`, + AccConstr: `Constrains nucleotides to be paired, unpaired or blocked during ${strand} accessibility folding.`, + AccFile: `Provides precomputed ${strand} unpaired probabilities or opening energies instead of calculating accessibility.`, + IntLenMax: `Limits how many ${strand} nucleotides a reported interaction may span; zero uses the complete available region.`, + IntLoopMax: `Limits the number of unpaired ${strand} nucleotides allowed in one interaction interior loop.`, + Region: `Restricts ${strand} interaction prediction to the listed inclusive sequence-coordinate ranges.`, + RegionLenMax: `Splits the ${strand} into automatically detected accessible regions no longer than this value; zero disables the split.`, + PfScale: `Sets the ${strand} partition-function scaling factor used for accessibility computations.`, + Shape: `Supplies nucleotide-position and reactivity pairs that constrain ${strand} accessibility folding.`, + ShapeMethod: `Chooses how ${strand} SHAPE pseudo-energies are added to the folding model.`, + ShapeConversion: `Chooses how ${strand} SHAPE reactivities are converted before pseudo-energy calculation.`, +}); +const qHelp = strandHelp('query'), tHelp = strandHelp('target'); +const FIELD_HELP = Object.freeze({ + ...Object.fromEntries(Object.entries(qHelp).map(([suffix, help]) => [`q${suffix}`, help])), + ...Object.fromEntries(Object.entries(tHelp).map(([suffix, help]) => [`t${suffix}`, help])), + accNoLP: 'Excludes lonely base pairs from the intramolecular structures used to calculate accessibility on both strands.', + accNoGUend: 'Excludes GU pairs at intramolecular helix ends when calculating accessibility on both strands.', + noSeed: 'Disables the requirement that every predicted interaction contain a seed region.', + seedTQ: 'Defines exact target-and-query seed structures instead of identifying seeds automatically.', + seedBP: 'Sets the minimum number of base pairs required in an automatically identified seed.', + seedMaxUP: 'Limits the total unpaired nucleotides across both strands within a seed.', + seedQMaxUP: 'Limits unpaired query nucleotides within a seed; minus one uses the combined seed limit.', + seedTMaxUP: 'Limits unpaired target nucleotides within a seed.', + seedMaxE: 'Rejects seeds whose complete energy exceeds this threshold in kcal/mol.', + seedMaxEhybrid: 'Rejects seeds whose intermolecular hybridization energy exceeds this threshold in kcal/mol.', + seedMinPu: 'Requires at least this joint probability that the seed nucleotides are unpaired before interaction.', + seedQRange: 'Restricts query seed positions to the listed inclusive sequence-coordinate ranges.', + seedTRange: 'Restricts target seed positions to the listed inclusive sequence-coordinate ranges.', + seedNoGU: 'Disallows GU wobble pairs anywhere inside an automatically identified seed.', + seedNoGUend: 'Disallows GU wobble pairs at either end of an automatically identified seed.', + helixMinBP: 'Sets the minimum number of base pairs in each helix block of the helix-block interaction model.', + helixMaxBP: 'Sets the maximum number of base pairs in each helix block of the helix-block interaction model.', + helixMaxIL: 'Limits the number of unpaired nucleotides allowed in an interior loop inside a helix block.', + helixMinPu: 'Requires at least this probability that the nucleotides of a helix block are initially unpaired.', + helixMaxE: 'Rejects helix blocks whose energy exceeds this threshold in kcal/mol.', + helixFullE: 'Applies the helix energy limit to the full helix energy instead of its hybridization contribution.', + model: 'Chooses the thermodynamic interaction model used for prediction.', + mode: 'Chooses the algorithm used to search the selected interaction model.', + kineticScore: 'Chooses how candidate extensions are scored by the greedy kinetic seed-extension algorithm.', + energyVRNA: 'Selects the ViennaRNA nearest-neighbor energy parameter set.', + temperature: 'Sets the folding and interaction temperature in degrees Celsius.', + energyAdd: 'Adds this constant energy in kcal/mol to every reported interaction.', + energyNoDangles: 'Omits dangling-end contributions from interaction-energy calculations.', + windowWidth: 'Divides long sequences into prediction windows of this width; zero disables prediction windows.', + windowOverlap: 'Sets how many nucleotides adjacent prediction windows share.', + outNumber: 'Limits the number of reported interactions per query-target pair; zero calculates accessibility without reporting interactions.', + outNumberPerSeed: 'Keeps this many best candidate sites per seed before final filtering; zero disables per-seed selection.', + outOverlap: 'Controls on which strand separately reported interactions may overlap.', + outMaxE: 'Reports only interactions with energy below this exclusive threshold in kcal/mol.', + outDeltaE: 'Reports interactions no more than this many kcal/mol above the best interaction for the same sequence pair.', + outMinPu: 'Requires at least this joint probability that the interaction sites are initially unpaired.', + outBestSeedOnly: 'Retains only the energetically best seed when an interaction contains multiple valid seeds.', + outNoLP: 'Excludes interactions containing isolated intermolecular base pairs.', + outNoGUend: 'Excludes interactions with a GU wobble pair at either interaction-helix end.', + outPerRegion: 'Reports and filters interaction candidates independently for every selected region combination.', + outPairwise: 'Pairs query and target FASTA records by position instead of predicting every record combination.', + outCsvSort: 'Sorts the downloaded CSV rows by the selected native output column.', + outSpotProbSpots: 'Restricts interaction-spot probability output to the listed target-and-query coordinate pairs.', +}); const strand = (prefix, label) => [ text(`${prefix}Set`, 'FASTA record subset', { ...prediction, hint: 'Record numbers, e.g. 1-3,5-5. Empty selects all.' }), select(`${prefix}Acc`, 'Accessibility', 'C', { C: 'Compute', N: 'Ignore', P: 'Import unpaired probabilities', E: 'Import opening energies' }), @@ -66,7 +130,7 @@ const fields = new Map([ { ...text(`${p}ShapeMethod`, 'SHAPE method'), value: 'D', hint: 'D, Dm1.8b-0.6, Z, Zb0.89 or W.' }, { ...text(`${p}ShapeConversion`, 'SHAPE conversion'), value: 'Os1.6i-2.29', hint: 'M, C0.25, S, Ls0.68i0.2 or Os1.6i-2.29.' }, ]), -].map((field) => [field.key, field])); +].map((field) => [field.key, { ...field, help: FIELD_HELP[field.key] }])); // Explicit rows separate visual order from native parameter defaults. const rows = (keys) => keys.map((row) => row.map((key) => fields.get(key))); diff --git a/gui/styles.css b/gui/styles.css index 00906995..d495bbca 100644 --- a/gui/styles.css +++ b/gui/styles.css @@ -315,7 +315,7 @@ fieldset { #query,#target { white-space:pre; overflow:auto; - min-height:20rem; + min-height:400px; } #sequence-help,#general-options p { max-width:none; } .input-warning { font-size:.85rem; color:#79510b; max-width:none; overflow-wrap:anywhere; } @@ -623,23 +623,22 @@ tbody tr.selected { background:var(--table-selection); } font-size:.9rem; } -.mutation-modes { - display:flex; - flex-wrap:wrap; - gap:8px; - margin-bottom:12px; +.mutation-visualization-layout { + display:grid; + grid-template-columns:minmax(0,1fr) minmax(260px,300px); + gap:16px; + align-items:start; } -.mutation-modes button { - padding:.35rem .7rem; +.mutation-energy-plot { + width:100%; + max-width:300px; + height:400px; + margin-top:12px; + justify-self:end; + border:1px solid var(--border); + border-radius:5px; background:var(--surface); - color:var(--brand-blue-dark); -} - -.mutation-modes button[aria-pressed=true] { - color:white; - background:var(--brand-blue); - border-color:var(--brand-blue); } [data-role=output-files] { @@ -884,6 +883,8 @@ dialog::backdrop { grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr)); } .field-grid[class*=columns-],.accessibility-columns,.mutation-option-grid { grid-template-columns:1fr; } + .mutation-visualization-layout { grid-template-columns:1fr; } + .mutation-energy-plot { justify-self:start; } .seed-primary-row { grid-template-columns:1fr; } #csv-options .field-grid.columns-1 { grid-template-columns:1fr; } .results { diff --git a/gui/tests/copomus.test.mjs b/gui/tests/copomus.test.mjs new file mode 100644 index 00000000..88a30503 --- /dev/null +++ b/gui/tests/copomus.test.mjs @@ -0,0 +1,32 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mutationAnnotations, mutationEnergyPlot } from '../copomus.js'; + +const candidate = { + values: { mutation: 'G-3C&C5G', qIndex: -3, tIndex: 5, bpMutated: 'CG' }, + interactions: { + ww: { E: -12.5 }, wm: { E: -6.25 }, mw: null, mm: { E: -11 }, + }, +}; + +test('CopomuS annotations preserve signed query-first coordinates in vaRRI target-first order', () => { + assert.equal(mutationAnnotations(candidate), '1:5G:006400,2:-3C:006400'); + for (const values of [ + { ...candidate.values, qIndex: 0 }, + { ...candidate.values, tIndex: 1.5 }, + { ...candidate.values, bpMutated: 'NN' }, + ]) assert.throws(() => mutationAnnotations({ ...candidate, values })); +}); + +test('CopomuS energy plot keeps requested state order and wild-type baseline', () => { + const plot = mutationEnergyPlot(candidate); + assert.deepEqual(plot.data[0].x, ['ww', 'wm', 'mw', 'mm']); + assert.deepEqual(plot.data[0].y, [-12.5, -6.25, null, -11]); + assert.equal(plot.layout.xaxis.title, undefined); + assert.equal(plot.layout.yaxis.title.text, 'E [kcal/mol]'); + assert.deepEqual(plot.layout.shapes[0], { + type: 'line', xref: 'paper', x0: 0, x1: 1, yref: 'y', y0: -12.5, y1: -12.5, + line: { color: '#145a96', width: 2, dash: 'dot' }, + }); + assert.deepEqual(mutationEnergyPlot({ ...candidate, interactions: { ...candidate.interactions, ww: null } }).layout.shapes, []); +}); diff --git a/gui/visualization.js b/gui/visualization.js index 03fdd498..5078d073 100644 --- a/gui/visualization.js +++ b/gui/visualization.js @@ -17,7 +17,7 @@ function seedHighlights(row) { return highlights.join(','); } -export function visualizationUrl(row, request) { +export function visualizationUrl(row, request, annotations = {}) { // Native seq1 is target and seq2 is query; both structures run 5′ to 3′. const structures = row.hybridDP?.split('&'); if (structures?.length !== 2) throw new Error('The interaction has no dot-bracket structure.'); @@ -41,6 +41,7 @@ export function visualizationUrl(row, request) { }); const highlights = seedHighlights(row); if (highlights) parameters.set('regionHighlights', highlights); + if (annotations.mutations) parameters.set('mutations', annotations.mutations); url.search = parameters; return url.href; } @@ -55,10 +56,10 @@ export class InteractionVisualization { this.link.removeAttribute('href'); this.link.hidden = true; this.status.textContent = 'Select View on an interaction to visualize it.'; } - show(row, request) { + show(row, request, annotations = {}) { this.clear(); try { - const url = visualizationUrl(row, request); + const url = visualizationUrl(row, request, annotations); this.frame.src = url; this.frame.hidden = false; const fullUrl = new URL(url); fullUrl.searchParams.delete('showRenderingOnly'); this.link.href = fullUrl.href; this.link.hidden = false; diff --git a/scripts/wasm/test-browser.py b/scripts/wasm/test-browser.py index ff2fbe38..00e992fe 100644 --- a/scripts/wasm/test-browser.py +++ b/scripts/wasm/test-browser.py @@ -82,6 +82,10 @@ def predict(): expect(page.locator('#query')).to_have_value('') expect(page.locator('#target')).to_have_value('') expect(page.locator('#query')).to_have_attribute('placeholder', '>query\nGGGAGGAGGG') + form_labels = page.locator('#prediction-form label') + assert form_labels.count() > 50 + assert all(label.get_attribute('title') for label in form_labels.all()) + assert page.locator('#query').evaluate('node => getComputedStyle(node).minHeight') == '400px' compute.click() expect(page.locator('#input-error')).to_contain_text('enter a sequence') metadata = page.request.get(urljoin(args.url, 'build-info.json')).json() @@ -707,6 +711,8 @@ def predict(): expect(page.locator('#mutation-input')).to_be_visible() expect(page.locator('#tab-output-options')).not_to_be_visible() expect(page.locator('#tab-csv-options')).not_to_be_visible() + assert all(label.get_attribute('title') for label in page.locator('#copomus-measures label, #copomus-filters label').all()) + expect(page.locator('#copomus-measures input[value="E"]').locator('xpath=..')).to_have_attribute('title', re.compile('single mutant')) for measure in ['mfeCover', 'minDeltaE']: page.locator(f'#copomus-measures input[value="{measure}"]').uncheck() page.locator('#copomus-measures input[value="EDqi"]').check() @@ -720,11 +726,19 @@ def predict(): expect(mutation_view.locator('tbody tr')).to_have_count(1) expect(mutation_view.locator('tbody tr')).to_contain_text('G1C&C10G') mutation_view.locator('tbody tr').click() - expect(mutation_view.locator('[data-role=mutation-modes]')).to_be_visible() - expect(mutation_view.locator('[data-role=mutation-modes] button[value="mm"]')).to_have_attribute('aria-pressed', 'true') expect(mutation_view.locator('[data-role=mutation-varri-frame]')).to_be_visible() + expect(mutation_view.locator('[data-role=mutation-energy-plot]')).to_be_visible() + expect(mutation_view.locator('[data-role=mutation-energy-plot] .barlayer')).to_be_visible() + assert mutation_view.locator('[data-role=mutation-energy-plot] .xtick').all_text_contents() == ['ww', 'wm', 'mw', 'mm'] + expect(mutation_view.locator('[data-role=mutation-energy-plot] .ytitle')).to_contain_text('E [kcal/mol]') mutation_url = mutation_view.locator('[data-role=mutation-varri-frame]').get_attribute('src') - assert parse_qs(urlparse(mutation_url).query)['sequence'] == ['CCCUCCUCCG&CGGAGGAGGG'] + mutation_query = parse_qs(urlparse(mutation_url).query) + assert mutation_query['sequence'] == ['CCCUCCUCCC&GGGAGGAGGG'] + assert mutation_query['mutations'] == ['1:10G:006400,2:1C:006400'] + assert mutation_view.locator('[data-role=mutation-varri-frame]').bounding_box()['x'] < mutation_view.locator('[data-role=mutation-energy-plot]').bounding_box()['x'] + if args.screenshot: + path = Path(args.screenshot) + page.screenshot(path=str(path.with_name(f'{path.stem}-mutation.png')), full_page=True) mutation_archive = download_archive(mutation_session) assert 'copomus-mutations.tsv' in mutation_archive assert mutation_archive['copomus-mutations.tsv'].startswith('mutation\trank\tqIndex\ttIndex')