Skip to content
Open
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
7 changes: 4 additions & 3 deletions docs/viewer-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,12 @@ both positioned and unpositioned labels, including their formatting.

- **Zooming**: Use the mouse wheel to zoom in and out.
- **Panning**: Click and drag the empty background to pan around. This is useful when zoomed in to focus on a certain region of the structure.
- **Rotation**: Use the *Rotation* slider below the visualisation to rotate the structure. Rotation preserves text orientation and is useful to align the structure for better visibility or to match a certain orientation in a publication figure.
- **Cropping**: Use the *Crop* slider to reduce the unpaired nucleotides at the ends of each sequence to the given number. This is useful to focus on the interaction region and reduce the size of the visualisation. A value of `-1` disables cropping and shows the full sequences.
- **View controls**: Select the *View controls* button on the right of the visualisation to fold or unfold rotation, cropping, and manual-position controls. The panel starts open, closes when you begin interacting with the visualisation, and overlays the unused side of the canvas, so toggling it does not resize or move the visualisation or other page elements.
- **Rotation**: Use the *Rotation* slider in *View controls* to rotate the structure. Rotation preserves text orientation and is useful to align the structure for better visibility or to match a certain orientation in a publication figure.
- **Cropping**: Use the *Cropping* slider in *View controls* to reduce the unpaired nucleotides at the ends of each sequence to the given number. This is useful to focus on the interaction region and reduce the size of the visualisation. A value of `-1` disables cropping and shows the full sequences.
- **Selection**: Ctrl-click a nucleotide, numbering label, or text label to add it to or remove it from the selection. Ctrl-drag the background to replace the selection with all elements fully inside the rectangle. An empty rectangle, Ctrl-click on the background, or a plain click on an element clears the selection. Command also works on macOS.
- **Moving elements**: Drag any nucleotide, numbering label, or text label in either layout mode. Drag a selected element to move the whole selection; dragging an unselected element clears the selection and moves that element alone. Released nodes and numbering labels stay in place until the next render, including in force mode.
- **Manual positions**: The **Moved** button beside Crop counts manually positioned nucleotides and numbering labels; click it to select them all. **Release** unfixes selected nodes at their current positions, allowing them to move again when Force layout is enabled, and removes them from the Moved count. **Reset** restores selected manual nodes to their positions and fixation before the first edit. Text labels are not counted, released, or reset. **Undo** (or Ctrl+Z / Command+Z while the canvas is focused) reverses the last drag, selection rotation, release, or reset. Undo a release before resetting that node to its original position. History is kept for the current rendering. The **?** button opens the full interaction guide.
- **Manual positions**: The **Moved** button in *View controls* counts manually positioned nucleotides and numbering labels; click it to select them all. **Release** is enabled only in Force layout mode; it unfixes selected nodes at their current positions, allows them to move again, and removes them from the Moved count. **Reset** restores selected manual nodes to their positions and fixation before the first edit. Text labels are not counted, released, or reset. **Undo** (or Ctrl+Z / Command+Z while the canvas is focused) reverses the last drag, selection rotation, release, or reset. Undo a release before resetting that node to its original position. History is kept for the current rendering. The **Help** button opens the full interaction guide.
- **Selection rotation**: With at least two elements selected, hold Ctrl (or Command) and use the mouse wheel to rotate them around the pointer. The ordinary wheel still zooms.
- **Saving positions**: SVG and PNG exports preserve the current layout and omit selection outlines. Share links and JSON sessions save text-label positions; manually moved nucleotide and numbering positions last only for the current rendering.
- **Nucleotide tooltips**: Hover to see the nucleotide index and probability value (if present).
Expand Down
60 changes: 58 additions & 2 deletions scripts/browser-position-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import path from 'node:path';
import { checkCanvasHelpStyles } from './browser-canvas-help.js';

/** Controls must be visible and hit-testable without Playwright scrolling them into view. */
/** Controls must be visible or reachable in the drawer and remain hit-testable. */
export async function exercisePositionLayout(page, origin, output) {
const original = page.viewportSize();
try {
Expand All @@ -19,7 +19,63 @@ export async function exercisePositionLayout(page, origin, output) {
await page.waitForFunction(() => document.querySelector('#rendering-caption').textContent.trim() &&
document.getElementById('rendering-canvas').style.visibility !== 'hidden');
}
const controls = await page.locator('.manual-position-controls button, .export-bar button').evaluateAll(elements => {
const toggle = page.locator('#viewControlsToggle');
const panel = page.locator('#viewControlsPanel');
const readGeometry = () => page.locator('.canvas-workspace').evaluate(element => {
const rect = selector => {
const { left, top, right, bottom, width: rectWidth, height: rectHeight } =
element.querySelector(selector).getBoundingClientRect();
return { left, top, right, bottom, width: rectWidth, height: rectHeight };
};
const { left, top, right, bottom, width: rectWidth, height: rectHeight } =
element.getBoundingClientRect();
return { workspace: { left, top, right, bottom, width: rectWidth, height: rectHeight },
button: rect('#viewControlsToggle'), panel: rect('#viewControlsPanel'),
canvas: rect('#rendering-canvas') };
});
const assertCanvasUnmoved = (actual, expected, state) => {
for (const property of ['left', 'top', 'right', 'bottom', 'width', 'height']) {
assert.ok(Math.abs(actual.canvas[property] - expected.canvas[property]) < 1,
`Folding ${state} must not change the canvas ${property}`);
}
};
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', 'View controls start open');
assert.equal(await panel.isVisible(), true, 'The open panel is visible by default');
const openGeometry = await readGeometry();
assert.ok(Math.abs(openGeometry.button.right - openGeometry.canvas.right) < 1,
'The View controls button sits on the canvas right edge');
assert.ok(Math.abs(openGeometry.panel.right - openGeometry.button.left) < 1 &&
openGeometry.panel.left >= openGeometry.canvas.left - 1,
'View controls expand leftward over the right side of the canvas');
assert.ok(Math.abs(openGeometry.canvas.left - openGeometry.workspace.left) < 1 &&
Math.abs(openGeometry.canvas.right - openGeometry.workspace.right) < 1,
'The overlaid controls leave the canvas at full workspace width');
await toggle.click();
assert.equal(await toggle.getAttribute('aria-expanded'), 'false');
assert.equal(await panel.isVisible(), false);
const foldedGeometry = await readGeometry();
assertCanvasUnmoved(foldedGeometry, openGeometry, 'closed');
assert.ok(Math.abs(foldedGeometry.button.right - foldedGeometry.canvas.right) < 1,
'The folded View controls button remains on the canvas right edge');
await toggle.click();
assert.equal(await toggle.getAttribute('aria-expanded'), 'true');
assert.equal(await panel.isVisible(), true);
assertCanvasUnmoved(await readGeometry(), openGeometry, 'open');
const panelControls = page.locator('.view-controls-panel button, .view-controls-panel input');
for (let index = 0; index < await panelControls.count(); index += 1) {
const control = panelControls.nth(index);
await control.scrollIntoViewIfNeeded();
const geometry = await control.evaluate(element => {
const drawer = document.getElementById('viewControlsPanel').getBoundingClientRect();
const r = element.getBoundingClientRect();
return { id: element.id, inside: r.left >= drawer.left && r.right <= drawer.right &&
r.top >= drawer.top && r.bottom <= drawer.bottom,
reachable: element.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) };
});
assert.ok(geometry.inside && geometry.reachable,
`${geometry.id} must remain reachable in View controls at ${width}×${height}`);
}
const controls = await page.locator('.export-bar button').evaluateAll(elements => {
const panel = document.querySelector('.result-panel').getBoundingClientRect();
return elements.filter(element => getComputedStyle(element).display !== 'none').map(element => {
const r = element.getBoundingClientRect();
Expand Down
42 changes: 27 additions & 15 deletions scripts/browser-position-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ export async function exercisePositionControlsUI(page, origin, output) {
await page.goto(`${origin}/index.html?${params}`, { waitUntil: 'networkidle' });
await page.waitForFunction(() => document.querySelector('#rendering-canvas circle') &&
document.getElementById('rendering-canvas').style.visibility !== 'hidden');
const viewControls = page.locator('#viewControlsToggle');
assert.equal(await viewControls.getAttribute('aria-expanded'), 'true');
await page.waitForFunction(() => {
const canvas = document.getElementById('rendering-canvas');
const values = canvas.querySelector('svg')?.getAttribute('viewBox')?.split(/\s+/).map(Number);
return values?.length === 4 && Math.abs(values[2] - canvas.clientWidth) < 1 &&
Math.abs(values[3] - canvas.clientHeight) < 1;
});
const moved = page.locator('#selectMovedBtn'), reset = page.locator('#resetPositionsBtn'), undo = page.locator('#undoCanvasBtn');
const release = page.locator('#releasePositionsBtn');
assert.equal(await moved.textContent(), 'Moved: 0');
Expand All @@ -26,40 +34,44 @@ export async function exercisePositionControlsUI(page, origin, output) {
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 - 30, box.y + box.height / 2 + 20, { steps: 5 });
await page.mouse.up();
assert.equal(await viewControls.getAttribute('aria-expanded'), 'false',
'Starting an interaction in the visualization closes View controls');
assert.equal(await page.locator('#viewControlsPanel').isVisible(), false);
assert.equal(await page.locator('#rendering-canvas').evaluate(element => getComputedStyle(element).outlineStyle), 'none',
'Pointer gestures do not add a frame around the canvas');
await page.waitForFunction(() => document.getElementById('selectMovedBtn').textContent === 'Moved: 1');
assert.equal(await reset.isDisabled(), true, 'Reset needs a selected manually moved node');
assert.equal(await undo.isEnabled(), true);
await page.keyboard.press('Tab');
await page.keyboard.press('Shift+Tab');
assert.equal(await page.locator('#rendering-canvas').evaluate(element => element === document.activeElement &&
getComputedStyle(element).outlineStyle === 'solid'), true, 'Keyboard navigation retains a visible canvas focus indicator');
await viewControls.click();
assert.equal(await reset.isDisabled(), true, 'Reset needs a selected manually moved node');
assert.equal(await undo.isEnabled(), true);
await moved.click();
assert.equal(await page.locator('#rendering-canvas [data-varri-selected]').count(), 1);
assert.equal(await reset.isEnabled(), true);
assert.equal(await release.isEnabled(), true);
assert.equal(await release.isDisabled(), true, 'Release stays disabled outside Force layout mode');
assert.ok((await release.getAttribute('title')).includes('Force layout'));
const cropBox = await page.locator('#cropping').boundingBox();
const movedBox = await moved.boundingBox();
assert.ok(movedBox.x >= cropBox.x + cropBox.width, 'Manual controls sit to the right of Crop');
assert.ok(Math.abs(movedBox.y - cropBox.y) < 15, 'Controls fit on the same compact row');
const panelBox = await page.locator('#viewControlsPanel').boundingBox();
const canvasBox = await page.locator('#rendering-canvas').boundingBox();
const toggleBox = await viewControls.boundingBox();
assert.ok(panelBox.x >= canvasBox.x && panelBox.x + panelBox.width <= toggleBox.x + 1 &&
toggleBox.x + toggleBox.width <= canvasBox.x + canvasBox.width + 1,
'View controls overlay the right side of the canvas');
assert.ok(movedBox.y >= cropBox.y + cropBox.height, 'Manual controls follow Cropping in the side panel');
await page.screenshot({ path: path.join(output, 'manual-position-controls.png'), fullPage: true });
const edited = await node.evaluate(element => ({ x: element.__data__.x, y: element.__data__.y }));
await release.click();
assert.deepEqual(await node.evaluate(element => ({ x: element.__data__.x, y: element.__data__.y })), edited,
'Release retains the current position in a static layout');
assert.equal(await node.evaluate(element => element.__data__.fixed), 0);
assert.equal(await moved.textContent(), 'Moved: 0');
assert.equal(await reset.isDisabled(), true);
await undo.click();
assert.equal(await node.evaluate(element => element.__data__.fixed), 1);
assert.equal(await moved.textContent(), 'Moved: 1');
await reset.click();
assert.deepEqual(await node.evaluate(element => ({ x: element.__data__.x, y: element.__data__.y })), initial);
assert.equal(await moved.textContent(), 'Moved: 0');
assert.equal(await reset.isDisabled(), true);
await undo.click();
assert.equal(await moved.textContent(), 'Moved: 1', 'Undo restores reset manual positions');
assert.equal(await node.evaluate(element => element.__data__.fixed), 1);
assert.equal(await moved.textContent(), 'Moved: 1');
assert.deepEqual(await node.evaluate(element => ({ x: element.__data__.x, y: element.__data__.y })), edited,
'Undo restores reset manual positions');
await page.locator('#rendering-canvas').focus();
await page.keyboard.press('Meta+z');
assert.equal(await moved.textContent(), 'Moved: 0', 'Canvas shortcut undoes the original drag');
Expand Down
35 changes: 31 additions & 4 deletions scripts/visual-baseline.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const baseline = path.resolve(option('--baseline', path.join(import.meta.dirname
const output = path.resolve(option('--output', path.join(import.meta.dirname, '../output/playwright')));
const update = args.includes('--update');
const revision = option('--revision', null);
const canvasFixtureSize = { width: 866, height: 626, renderHeight: 625 };
const pixelDifferenceLimit = 0.0025;
const fixtures = ['2mol', 'coronel-tellez-2022', 'wu-2024', 'IntaRNA-seeds', 'crossing-rri'];
const catalog = await import(pathToFileURL(path.join(root, 'example-data.js')).href);
const examples = catalog.default || catalog;
Expand Down Expand Up @@ -75,14 +77,22 @@ async function compareImage(name) {
}
// Permit minor platform antialiasing, but not missing nucleotides or annotations.
const ratio = different / (actual.width * actual.height);
assert.ok(ratio <= 0.002, `${name}: ${(ratio * 100).toFixed(3)}% changed pixels (limit 0.2%). Compare ${path.join(output, name + '.png')} with ${path.join(baseline, name + '.png')}`);
assert.ok(ratio <= pixelDifferenceLimit, `${name}: ${(ratio * 100).toFixed(3)}% changed pixels (limit ${(pixelDifferenceLimit * 100).toFixed(2)}%). Compare ${path.join(output, name + '.png')} with ${path.join(baseline, name + '.png')}`);
return ratio;
}

function compareScene(name, actual, expected) {
assert.deepEqual(actual.counts, expected.counts, name + ': SVG element counts changed');
assert.equal(actual.shapes.length, expected.shapes.length, name + ': SVG shape count changed');
const index = actual.shapes.findIndex((shape, position) => !isDeepStrictEqual(shape, expected.shapes[position]));
const sameShape = (shape, reference) => {
const { transform, ...values } = shape;
const { transform: expectedTransform, ...expectedValues } = reference;
if (!isDeepStrictEqual(values, expectedValues)) return false;
if (transform === null || expectedTransform === null) return transform === expectedTransform;
return transform.length === expectedTransform.length &&
transform.every((value, index) => Math.abs(value - expectedTransform[index]) <= 0.05);
};
const index = actual.shapes.findIndex((shape, position) => !sameShape(shape, expected.shapes[position]));
if (index !== -1) assert.deepEqual(actual.shapes[index], expected.shapes[index],
`${name}: SVG shape ${index} changed; full actual scene: ${path.join(output, name + '.json')}`);
}
Expand Down Expand Up @@ -136,14 +146,31 @@ async function main() {
// External branding and Markdown CDN are irrelevant to the RNA canvas baseline.
await page.route('**/*', route => route.request().url().startsWith(origin)
? route.continue() : route.fulfill({ status: 200, body: '' }));
await page.addInitScript(() => {
await page.addInitScript(({ width, height }) => {
let seed = 83;
Math.random = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296);
});
const installFixtureStyle = () => {
if (!document.head || document.getElementById('visual-fixture-style')) return;
const style = document.createElement('style');
style.id = 'visual-fixture-style';
style.textContent = `#viewControlsToggle, #viewControlsPanel { display: none !important; }
#rendering-canvas { border-bottom: 1px solid transparent !important;
flex: 0 0 ${width}px !important; height: ${height}px !important; }`;
document.head.append(style);
};
installFixtureStyle();
new MutationObserver(installFixtureStyle).observe(document, { childList: true, subtree: true });
}, canvasFixtureSize);
const parameters = await exampleParameters(examples[name]);
await page.goto(`${origin}/index.html?${parameters}`, { waitUntil: 'networkidle' });
await page.waitForFunction(() => document.querySelector('#rendering-canvas circle[node_num]') &&
document.getElementById('rendering-canvas')?.style.visibility !== 'hidden');
await page.waitForFunction(({ width, renderHeight }) => {
const canvas = document.getElementById('rendering-canvas');
const viewBox = canvas.querySelector('svg')?.getAttribute('viewBox')?.split(/\s+/).map(Number);
return canvas.clientWidth === width && canvas.clientHeight === renderHeight &&
viewBox?.[2] === width && viewBox?.[3] === renderHeight;
}, canvasFixtureSize);
if (await page.locator('script[type="module"][src="src/main.js"]').count()) {
assert.equal(await page.evaluate(async () => {
const entry = await import('./src/main.js');
Expand Down
Loading
Loading