From b849a0a1c052b720e106c52b26fa793fa8879d9b Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Mon, 26 Oct 2026 18:55:37 +0100 Subject: [PATCH] feat(gui): add plot and seed visualization controls --- ChangeLog | 13 +++++ gui/columns.js | 2 +- gui/docs/step4-results-and-deployment.md | 7 ++- gui/output-visualization.js | 60 +++++++++++++++++++++--- gui/styles.css | 9 +++- gui/tests/export.test.mjs | 14 ++++++ gui/tests/output-visualization.test.mjs | 15 +++++- gui/tests/protocol.test.mjs | 2 +- gui/visualization.js | 23 ++++++++- 9 files changed, 131 insertions(+), 14 deletions(-) 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; }