Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion gui/columns.js
Original file line number Diff line number Diff line change
Expand Up @@ -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])];
}
7 changes: 6 additions & 1 deletion gui/docs/step4-results-and-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
60 changes: 53 additions & 7 deletions gui/output-visualization.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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' } },
Expand All @@ -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}<br>${plotText(query)} %{y}<br>Value %{z}<extra></extra>`
: `${plotText(target)} %{y}<br>${plotText(query)} %{x}<br>Value %{z}<extra></extra>`,
}, ...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;
}
Expand Down Expand Up @@ -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);
}
Expand All @@ -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}`; });
}

Expand All @@ -225,6 +270,7 @@ export class AdditionalOutputView {
}

destroy() {
this.resizeObserver?.disconnect();
if (this.rendered) globalThis.Plotly?.purge?.(this.plot);
}
}
9 changes: 8 additions & 1 deletion gui/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions gui/tests/export.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
15 changes: 14 additions & 1 deletion gui/tests/output-visualization.test.mjs
Original file line number Diff line number Diff line change
@@ -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' }] },
Expand Down Expand Up @@ -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',
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion gui/tests/protocol.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
23 changes: 21 additions & 2 deletions gui/visualization.js
Original file line number Diff line number Diff line change
@@ -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('&');
Expand All @@ -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;
}

Expand Down
Loading