diff --git a/ChangeLog b/ChangeLog
index c32a35ad..f64439de 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -31,6 +31,9 @@
- refine GUI seed and CSV control widths, filter examples by task, cap result
table height and use dark active tabs for additional output views (PR #303 review)
+- let users resize and transpose Plotly results, export their current dimensions,
+ and highlight all reported seed regions in vaRRI (#305)
+
- compact and left-align GUI parameter rows, split CSV controls into their own
tab, describe every additional output format and plot energy/probability files
with sequence names, source tabs and PNG export (PR #287 review)
@@ -328,6 +331,16 @@ energies, restricted partition sums, and trackers.
################################################################################
################################################################################
+261026 Alexander Mitrofanov
+ * gui/output-visualization.js, gui/styles.css, gui/tests/output-visualization.test.mjs
+ + Add resizable Plotly canvases and matrix-axis transposition controls.
+ * Export PNGs at the current plotting dimensions instead of a fixed size.
+ * gui/visualization.js, gui/columns.js, gui/tests/{export,protocol}.test.mjs
+ + Pass every complete reported seed boundary to vaRRI as a translucent purple
+ region highlight and include seed ends in prediction CSV defaults (#305).
+ * gui/docs/step4-results-and-deployment.md
+ * Document plot resizing, axis swapping, current-size export and seed overlays.
+
261024 Alexander Mitrofanov
* python/tests/test_common_tools.py
* Compare executable lookup against canonical fixture paths so macOS /var
diff --git a/gui/columns.js b/gui/columns.js
index 9c096021..6849075a 100644
--- a/gui/columns.js
+++ b/gui/columns.js
@@ -330,6 +330,6 @@ export function defaultColumns(task) {
// 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'];
+ : ['ED1', 'ED2', 'seedE', 'seedStart1', 'seedEnd1', 'seedStart2', 'seedEnd2'];
return [...new Set([...context, ...extra])];
}
diff --git a/gui/docs/step4-results-and-deployment.md b/gui/docs/step4-results-and-deployment.md
index 451181bf..aaf1c2ad 100644
--- a/gui/docs/step4-results-and-deployment.md
+++ b/gui/docs/step4-results-and-deployment.md
@@ -42,7 +42,9 @@ and additional-file headers identify the target/query records covered by native
`-t#q#` or `-s#` filename suffixes.
Plotly renders locally from the pinned Cartesian bundle. A separate Export PNG
-button produces a 1400 × 850 image for the active plot; the normal file download
+button uses the active plot's current dimensions. The plotting area can be resized
+from its lower-right corner, and matrix plots provide Swap X/Y to transpose query
+and target axes without changing the data. The normal file download
and reproducible ZIP retain untouched native data. Plots render lazily when their
file is unfolded, resize with restored sessions, and are purged when a session is
deleted. Non-plot formats keep the direct content view.
@@ -59,6 +61,9 @@ CSV identifiers to the completed run's normalized FASTA records (including
semicolon-to-underscore ID conversion). It pads the interaction dot-bracket
structure with unpaired positions to the full input lengths, using each start
index and the native negative-to-positive transition that skips zero.
+When all four seed-boundary columns are present, each reported seed is passed to
+vaRRI as a translucent purple paired-region highlight. Prediction defaults select
+those columns; custom CSV selections without complete boundaries omit highlights.
The URL follows [vaRRI's embedding API](https://github.com/BackofenLab/vaRRI/blob/main-v2/docs/sharing-and-input.md):
`sequence`, `structure`, `seqName1/2`, `startIndex1/2`, and
diff --git a/gui/output-visualization.js b/gui/output-visualization.js
index 9c901089..757f214d 100644
--- a/gui/output-visualization.js
+++ b/gui/output-visualization.js
@@ -67,7 +67,6 @@ function baseLayout(title) {
return {
title: { text: title, x: 0.04, xanchor: 'left', font: { size: 17 } },
autosize: true,
- height: 420,
margin: { l: 72, r: 28, t: 62, b: 78 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: '#fff',
@@ -122,7 +121,7 @@ export function outputVisualization(file, request) {
const z = parsed.rows.map((row) => row.slice(1).map(number));
const target = context.target || 'Target', query = context.query || 'Query';
return {
- kind: 'matrix', context,
+ kind: 'matrix', context, axesSwapped: false,
traces: [{
type: 'heatmap', x, y, z, colorscale: matrix.colorscale, hoverongaps: false,
colorbar: { title: { text: matrix.colorTitle, side: 'right' } },
@@ -136,6 +135,30 @@ export function outputVisualization(file, request) {
};
}
+export function swapVisualizationAxes(spec) {
+ if (spec.kind !== 'matrix') return spec;
+ const [trace, ...otherTraces] = spec.traces, axesSwapped = !spec.axesSwapped;
+ const target = spec.context.target || 'Target', query = spec.context.query || 'Query';
+ return {
+ ...spec,
+ axesSwapped,
+ traces: [{
+ ...trace,
+ x: [...trace.y], y: [...trace.x],
+ z: trace.x.map((_, column) => trace.z.map((row) => row[column])),
+ hovertemplate: axesSwapped
+ ? `${plotText(target)} %{x}
${plotText(query)} %{y}
Value %{z}`
+ : `${plotText(target)} %{y}
${plotText(query)} %{x}
Value %{z}`,
+ }, ...otherTraces],
+ layout: { ...spec.layout, xaxis: { ...spec.layout.yaxis }, yaxis: { ...spec.layout.xaxis } },
+ };
+}
+
+export function plotImageOptions(plot) {
+ const bounds = plot.getBoundingClientRect();
+ return { format: 'png', width: Math.round(bounds.width), height: Math.round(bounds.height) };
+}
+
function button(text, className = '') {
const node = document.createElement('button'); node.type = 'button'; node.textContent = text; node.className = className; return node;
}
@@ -195,15 +218,39 @@ export class AdditionalOutputView {
exportButton.addEventListener('click', async () => {
try {
await this.ready;
- const image = await globalThis.Plotly.toImage(this.plot, { format: 'png', width: 1400, height: 850 });
+ const image = await globalThis.Plotly.toImage(this.plot, plotImageOptions(this.plot));
const encoded = image.slice(image.indexOf(',') + 1), bytes = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0));
const url = URL.createObjectURL(new Blob([bytes], { type: 'image/png' }));
const link = document.createElement('a'); link.href = url; link.download = file.name.replace(/\.csv$/i, '.png');
document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (error) { status.textContent = `PNG export failed: ${error.message}`; }
});
+ if (spec.kind === 'matrix') {
+ const swapButton = button('Swap X/Y', 'secondary'); swapButton.disabled = true;
+ swapButton.addEventListener('click', async () => {
+ const previous = this.spec;
+ swapButton.disabled = true;
+ try {
+ await this.ready;
+ this.spec = swapVisualizationAxes(this.spec);
+ this.ready = Promise.resolve(globalThis.Plotly.react(this.plot, this.spec.traces, this.spec.layout, this.plotConfig));
+ await this.ready; status.textContent = '';
+ } catch (error) {
+ this.spec = previous; status.textContent = `Axis swap failed: ${error.message}`;
+ } finally { swapButton.disabled = false; }
+ });
+ tools.append(swapButton); this.swapButton = swapButton;
+ }
tools.append(exportButton); this.exportButton = exportButton;
this.plot = document.createElement('div'); this.plot.className = 'output-plot'; visualizationPanel.append(this.plot); this.spec = spec;
+ this.plotConfig = {
+ responsive: true, displaylogo: false, scrollZoom: false,
+ modeBarButtonsToRemove: ['sendDataToCloud', 'lasso2d', 'select2d'],
+ };
+ if (globalThis.ResizeObserver) {
+ this.resizeObserver = new globalThis.ResizeObserver(() => this.resize());
+ this.resizeObserver.observe(this.plot);
+ }
this.root.addEventListener('toggle', () => { if (this.root.open && !visualizationPanel.hidden) this.render(); });
this.selectTab(0);
}
@@ -212,11 +259,9 @@ export class AdditionalOutputView {
if (!this.spec || this.rendered || !this.root.open) return;
if (!globalThis.Plotly?.newPlot) { this.status.textContent = 'Visualization unavailable: Plotly did not load.'; return; }
this.rendered = true; this.status.textContent = 'Rendering visualization…'; this.plot.setAttribute('aria-busy', 'true');
- this.ready = Promise.resolve(globalThis.Plotly.newPlot(this.plot, this.spec.traces, this.spec.layout, {
- responsive: true, displaylogo: false, scrollZoom: false,
- modeBarButtonsToRemove: ['sendDataToCloud', 'lasso2d', 'select2d'],
- })).then(() => {
+ this.ready = Promise.resolve(globalThis.Plotly.newPlot(this.plot, this.spec.traces, this.spec.layout, this.plotConfig)).then(() => {
this.plot.removeAttribute('aria-busy'); this.status.textContent = ''; this.exportButton.disabled = false;
+ if (this.swapButton) this.swapButton.disabled = false;
}).catch((error) => { this.plot.removeAttribute('aria-busy'); this.status.textContent = `Visualization unavailable: ${error.message}`; });
}
@@ -225,6 +270,7 @@ export class AdditionalOutputView {
}
destroy() {
+ this.resizeObserver?.disconnect();
if (this.rendered) globalThis.Plotly?.purge?.(this.plot);
}
}
diff --git a/gui/styles.css b/gui/styles.css
index ea60e124..35942d40 100644
--- a/gui/styles.css
+++ b/gui/styles.css
@@ -657,11 +657,13 @@ tbody tr.selected { background:var(--table-selection); }
.output-tab-panel {
padding-top:12px;
min-width:0;
+ overflow:auto;
}
.plot-tools {
display:flex;
justify-content:flex-end;
+ gap:.5rem;
min-height:2.6rem;
}
@@ -672,7 +674,12 @@ tbody tr.selected { background:var(--table-selection); }
.output-plot {
width:100%;
- min-height:420px;
+ height:420px;
+ min-width:320px;
+ min-height:320px;
+ max-width:none;
+ resize:both;
+ overflow:hidden;
}
.output-plot-status {
diff --git a/gui/tests/export.test.mjs b/gui/tests/export.test.mjs
index 6884cf2c..56c894ee 100644
--- a/gui/tests/export.test.mjs
+++ b/gui/tests/export.test.mjs
@@ -14,11 +14,25 @@ test('visualization retains full strand orientation, bulges, names and indices c
assert.equal(url.searchParams.get('startIndex1'), '-5');
assert.equal(url.searchParams.get('startIndex2'), '-3');
assert.equal(url.searchParams.get('showRenderingOnly'), 'true');
+ assert.equal(url.searchParams.has('regionHighlights'), false);
assert.throws(() => visualizationUrl({ id1: 'missing', hybridDP: '(&)' }, request), /not found/);
assert.throws(() => visualizationUrl({ id1: 'target', id2: 'q_β', start1: -5, end1: -4, hybridDP: '(((&)))' }, request), /coordinates/);
assert.throws(() => visualizationUrl({ id1: 'target', hybridDP: '(&)' }, { ...request, parameters: { ...request.parameters, tIdxPos0: 0 } }), /nonzero/);
});
+test('visualization highlights every available seed region in transparent purple', () => {
+ const request = prepareRequest({ ...DEFAULTS, query: 'G'.repeat(20), target: 'C'.repeat(20), qIdxPos0: -3, tIdxPos0: -5 });
+ const row = {
+ id1: 'target', id2: 'query', start1: -5, end1: 15, start2: -3, end2: 17,
+ E: -1, hybridDP: `${'('.repeat(10)}${'.'.repeat(10)}&${'.'.repeat(10)}${')'.repeat(10)}`,
+ seedStart1: '-3:5', seedEnd1: '2:8', seedStart2: '-1:7', seedEnd2: '4:10',
+ };
+ const url = new URL(visualizationUrl(row, request));
+ assert.equal(url.searchParams.get('regionHighlights'), '-3-2&-1-4:800080:0.3,5-8&7-10:800080:0.3');
+ row.seedEnd2 = 'NAN';
+ assert.equal(new URL(visualizationUrl(row, request)).searchParams.has('regionHighlights'), false);
+});
+
test('archive preserves effective non-default settings, selected CSV, auxiliary inputs and reports', () => {
const request = prepareRequest({ ...DEFAULTS, task: 'evaluation', rri: '1||||||||||&3||||||||||', qIdxPos0: 3, parameters: { temperature: 21, qShape: '1 0.2\n2 0.3' }, csvColumns: ['E'] });
request.outputs = ['detailed'];
diff --git a/gui/tests/output-visualization.test.mjs b/gui/tests/output-visualization.test.mjs
index d8aa08f8..90ef782d 100644
--- a/gui/tests/output-visualization.test.mjs
+++ b/gui/tests/output-visualization.test.mjs
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { outputFileContext, outputVisualization } from '../output-visualization.js';
+import { outputFileContext, outputVisualization, plotImageOptions, swapVisualizationAxes } from '../output-visualization.js';
const request = {
target: { records: [{ id: 'target-1' }, { id: 'target-2' }] },
@@ -31,6 +31,14 @@ test('pair energy and probability matrices use literal category axes', () => {
assert.equal(energy.layout.xaxis.type, 'category');
assert.equal(energy.layout.yaxis.type, 'category');
assert.match(energy.layout.title.text, /target-2 : query-1/);
+ const swapped = swapVisualizationAxes(energy);
+ assert.deepEqual(swapped.traces[0].x, ['G_5', 'U_6']);
+ assert.deepEqual(swapped.traces[0].y, ['A_-1', 'C_1']);
+ assert.deepEqual(swapped.traces[0].z, [[-4.2, -2], [null, -1.5]]);
+ assert.equal(swapped.layout.xaxis.title.text, 'target-2 index');
+ assert.equal(swapped.layout.yaxis.title.text, 'query-1 index');
+ assert.match(swapped.traces[0].hovertemplate, /target-2 %\{x\}.*query-1 %\{y\}/);
+ assert.deepEqual(swapVisualizationAxes(swapped).traces, energy.traces);
const spots = outputVisualization({
name: 'spotProb-t1q2.csv',
@@ -41,6 +49,11 @@ test('pair energy and probability matrices use literal category axes', () => {
assert.deepEqual(spots.layout.xaxis.categoryarray, ['A_-1', 'C_1']);
});
+test('PNG export uses the current rendered plot dimensions', () => {
+ assert.deepEqual(plotImageOptions({ getBoundingClientRect: () => ({ width: 812.4, height: 533.6 }) }),
+ { format: 'png', width: 812, height: 534 });
+});
+
test('selected spot probabilities use a categorical bar plot', () => {
const onePair = { target: { records: [{ id: 'target' }] }, query: { records: [{ id: 'query' }] } };
const spec = outputVisualization({ name: 'spotProb.csv', text: 'spot;probability\n0&0;0.4\n5&-2;0.6\n' }, onePair);
diff --git a/gui/tests/protocol.test.mjs b/gui/tests/protocol.test.mjs
index 4fa301e6..c20f2502 100644
--- a/gui/tests/protocol.test.mjs
+++ b/gui/tests/protocol.test.mjs
@@ -121,7 +121,7 @@ test('CSV groups cover native columns once and task defaults serialize explicitl
const energy = [...CSV_GROUPS.find((group) => group.label === 'Energy').keys, 'Etotal', 'Eall', 'Eall1', 'Eall2', 'EallTotal'];
for (const key of energy) assert.ok(defaultColumns('evaluation').includes(key), key);
for (const key of ['seedStart1', 'seedStart2', 'seedEnd1', 'seedEnd2']) assert.ok(!defaultColumns('evaluation').includes(key), key);
- for (const key of ['ED1', 'ED2', 'seedE', 'seedStart1', 'seedStart2']) assert.ok(defaultColumns('prediction').includes(key), key);
+ for (const key of ['ED1', 'ED2', 'seedE', 'seedStart1', 'seedEnd1', 'seedStart2', 'seedEnd2']) assert.ok(defaultColumns('prediction').includes(key), key);
const { exportParameters, importParameters } = await import('../parameter-file.js');
const input = request({ outNumber: 10, outNumberPerSeed: 2 }, { csvColumns: defaultColumns('prediction') });
const saved = exportParameters(input, 'test');
diff --git a/gui/visualization.js b/gui/visualization.js
index 7e9c46f0..059bea0a 100644
--- a/gui/visualization.js
+++ b/gui/visualization.js
@@ -1,5 +1,22 @@
import { sequenceOffset } from './protocol.js';
+const SEED_HIGHLIGHT = '800080:0.3';
+
+function seedHighlights(row) {
+ const values = ['seedStart1', 'seedEnd1', 'seedStart2', 'seedEnd2'].map((key) => row[key]);
+ if (values.some((value) => value === undefined || /^(?:NA|NAN)$/i.test(String(value).trim()))) return '';
+ const coordinates = values.map((value) => String(value).split(':').map(Number));
+ const count = coordinates[0].length;
+ if (!count || coordinates.some((list) => list.length !== count || list.some((value) => !Number.isInteger(value) || value === 0))) return '';
+ const highlights = [];
+ for (let index = 0; index < count; index++) {
+ const [start1, end1, start2, end2] = coordinates.map((list) => list[index]);
+ if (start1 > end1 || start2 > end2) return '';
+ highlights.push(`${start1}-${end1}&${start2}-${end2}:${SEED_HIGHLIGHT}`);
+ }
+ return highlights.join(',');
+}
+
export function visualizationUrl(row, request) {
// Native seq1 is target and seq2 is query; both structures run 5′ to 3′.
const structures = row.hybridDP?.split('&');
@@ -16,13 +33,15 @@ export function visualizationUrl(row, request) {
sequences.push(record.sequence); starts.push(start);
fullStructures.push('.'.repeat(offset) + structure + '.'.repeat(record.sequence.length - end - 1));
}
- const url = new URL('https://backofenlab.github.io/vaRRI/');
- url.search = new URLSearchParams({
+ const url = new URL('https://backofenlab.github.io/vaRRI/'), parameters = new URLSearchParams({
sequence: sequences.join('&'), structure: fullStructures.join('&'),
seqName1: row.id1, seqName2: row.id2, startIndex1: starts[0], startIndex2: starts[1],
showRenderingOnly: 'true', forceLayout: 'false', forceLayoutLinearRRI: 'true',
cropping: 2
});
+ const highlights = seedHighlights(row);
+ if (highlights) parameters.set('regionHighlights', highlights);
+ url.search = parameters;
return url.href;
}