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 @@
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.
-
Each strand uses its start coordinate and dots/bars in 5′ to 3′ order; pair bars antiparallel. Example: 1|||&1|||. Separate structures with a colon. Evaluation retains energy/accessibility settings and ignores prediction filters.
+
Each strand uses its start coordinate and dots/bars in 5′ to 3′ order; pair bars antiparallel. Example: 1|||&1|||. Separate structures with a colon. Evaluation retains energy/accessibility settings and ignores prediction filters.
Compensatory mutation settings
-
-
-
-
+
+
+
+
-
Query-first substitutions separated by commas. When supplied, explicit mutations replace candidate selection, filters and the generator. Browser runs are limited to 100 generated mutations.
+
Query-first substitutions separated by commas. When supplied, explicit mutations replace candidate selection, filters and the generator. Browser runs are limited to 100 generated mutations.
@@ -108,12 +108,12 @@
Compensatory mutation settings
Ranks and scores follow the selected CopomuS measures. NA denotes an unavailable interaction or profile score.
Select View on a mutation to inspect its annotated interaction and energy states.
+
vaRRI shows the wild-type interaction with the designed query and target substitutions annotated in dark green. The chart compares the wild-type, single-mutant and compensatory energies.
+
+
+
-
Select View on a mutation to compare its four interaction states.
-
The compensatory state mutates both strands; the single-mutant states show the intended interaction disruption.
-
Additional output files
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')