From f87a6c9936663c1d6fb078c6aed3c97a6ff031f3 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Mon, 5 Oct 2026 14:27:55 +0200 Subject: [PATCH 01/11] Enable canvas selection and grouped dragging in both layouts --- docs/d3-upgrade.md | 9 +- docs/viewer-guide.md | 9 +- scripts/browser-interactions.js | 211 ++++++++++++++++------- scripts/browser-selection.js | 54 ++++++ scripts/browser-text-ui.js | 28 ++- src/core/canvas/export.js | 2 + src/core/canvas/graph/force.js | 46 ----- src/core/canvas/graph/index.js | 25 ++- src/core/canvas/graph/svg.js | 5 +- src/core/canvas/interaction-selection.js | 64 +++++++ src/core/canvas/interactions.js | 146 ++++++++++++++++ src/core/canvas/layout/linear-helix.js | 2 +- src/core/canvas/render.js | 6 + src/core/canvas/rotation.js | 1 + src/core/canvas/text-annotation-drag.js | 60 ------- src/core/canvas/text-annotations.js | 24 ++- tests/text-annotation-canvas.test.js | 51 +++++- 17 files changed, 546 insertions(+), 197 deletions(-) create mode 100644 scripts/browser-selection.js create mode 100644 src/core/canvas/interaction-selection.js create mode 100644 src/core/canvas/interactions.js delete mode 100644 src/core/canvas/text-annotation-drag.js diff --git a/docs/d3-upgrade.md b/docs/d3-upgrade.md index 41ba9b3..ee829de 100644 --- a/docs/d3-upgrade.md +++ b/docs/d3-upgrade.md @@ -42,10 +42,11 @@ Restarting explicitly refreshes those caches after changing pseudoknot strength or removing exterior helpers. Old link arrays are cleared before replacing nodes. Stopping does not emit convergence or restart a cancelled simulation. -Drag movement is measured in the zoomed plot's coordinates. Selection modifiers -and prior fixed state are preserved. Disposal detaches active mouse gestures, -restores browser text selection, and prevents late callbacks from restarting -the old simulation. Programmatic fitting updates D3's stored zoom transform, +The subsequent #74 interaction update measures pointer movement through each +element's zoomed and rotated coordinate frame. Ctrl-click and rectangle selection +include nucleotides, numbering, and text. Dragged graph nodes remain fixed for the +current rendering; text positions update the model. Disposal detaches active +gestures and prevents late callbacks from restarting the old simulation. Programmatic fitting updates D3's stored zoom transform, so subsequent wheel and pan gestures start at the fitted view. ## Verification and visual review diff --git a/docs/viewer-guide.md b/docs/viewer-guide.md index de24c82..dbef89a 100644 --- a/docs/viewer-guide.md +++ b/docs/viewer-guide.md @@ -214,12 +214,13 @@ both positioned and unpositioned labels, including their formatting. ### Additional Features - **Zooming**: Use the mouse wheel to zoom in and out. -- **Panning**: Click and drag the visualisation to pan around. This is useful when zoomed in to focus on a certain region of the structure. +- **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. -- **Nucleotide Nodes** - - .. can be dragged to new positions in the force-directed layout mode. - - .. show a tooltip with the nucleotide index and probability value (if present) when hovered over. +- **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 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. +- **Saving positions**: SVG and PNG exports preserve the current layout and omit selection outlines. Share links 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). - **Resize Canvas**: The visualisation canvas size can be adjusted by dragging the bottom-right corner of the canvas. This is useful when visualizing large interactions on large screens, or when preparing figures for publication. The canvas size is preserved when exporting the visualisation. ### Export diff --git a/scripts/browser-interactions.js b/scripts/browser-interactions.js index 625d527..b07175f 100644 --- a/scripts/browser-interactions.js +++ b/scripts/browser-interactions.js @@ -1,79 +1,162 @@ import assert from 'node:assert/strict'; +import { exerciseRectangleSelection } from './browser-selection.js'; -/** Exercise actual pointer events against either the native or bundled API. */ -export async function exerciseInteractions(page) { - await page.evaluate(async () => { - const api = window.exportApi; - const v = api.validate({ sequence: 'AAAA&UUUU', structure: '((..&..))', - highlighting: 'nothing', backgroundhighlighting: 'nothing' }); - await api.render('second', v, { forceLayout: true }); - const graph = document.querySelector('#second circle[node_type="nucleotide"]').__data__.rna; - // Pin the graph to isolate pointer displacement from ongoing simulation. - graph.nodes.forEach(node => { node.fixed = 1; node.px = node.x; node.py = node.y; }); - window.interactionGraph = graph; - }); - const circle = page.locator('#second circle[node_type="nucleotide"]').first(); - const before = await circle.evaluate(element => { - const { x, y } = element.__data__; - return { x, y, zoom: document.querySelector('#second svg').__zoom.k }; - }); - const box = await circle.boundingBox(); - assert.ok(box, 'The nucleotide can be dragged'); - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); +const near = (actual, expected, message) => assert.ok(Math.abs(actual - expected) < 0.6, + `${message}: expected ${expected}, got ${actual}`); +const origin = locator => locator.evaluate(element => { + const matrix = element.getScreenCTM(); + return { x: matrix.e, y: matrix.f }; +}); +async function click(page, locator, control = false) { + const box = await locator.boundingBox(); + if (control) await page.keyboard.down('Control'); + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + if (control) await page.keyboard.up('Control'); +} +async function drag(page, locator, dx, dy) { + const box = await locator.boundingBox(); + const x = box.x + box.width / 2, y = box.y + box.height / 2; + await page.mouse.move(x, y); await page.mouse.down(); - await page.mouse.move(box.x + box.width / 2 + 40, box.y + box.height / 2 + 25, { steps: 5 }); + await page.mouse.move(x + dx, y + dy, { steps: 5 }); await page.mouse.up(); - const after = await circle.evaluate(element => ({ x: element.__data__.x, y: element.__data__.y, - fixed: element.__data__.fixed })); - assert.ok(Math.abs(after.x - before.x - 40 / before.zoom) < 0.5, 'Drag respects zoom scale (x)'); - assert.ok(Math.abs(after.y - before.y - 25 / before.zoom) < 0.5, 'Drag respects zoom scale (y)'); - assert.equal(after.fixed, 1, 'Dragging restores a previously fixed node'); +} - const secondCircle = page.locator('#second circle[node_type="nucleotide"]').nth(1); - const secondBox = await secondCircle.boundingBox(); - await page.keyboard.down('Control'); - await page.mouse.click(secondBox.x + secondBox.width / 2, secondBox.y + secondBox.height / 2); - await page.keyboard.up('Control'); - assert.equal(await page.evaluate(() => window.interactionGraph.nodes.filter(node => node.selected).length), - 2, 'Ctrl-click preserves multi-selection'); +/** Exercise real input against native and bundled APIs, in both layout modes. */ +export async function exerciseInteractions(page) { + for (const forceLayout of [false, true]) { + await page.evaluate(async forceLayout => { + const api = window.exportApi; + api.clearTextAnnotations(); + const input = api.validate({ sequence: 'AAAA&UUUU', structure: '((..&..))', + highlighting: 'nothing', backgroundhighlighting: 'nothing', textAnnotations: [] }); + await api.render('second', input, { forceLayout }); + const graph = document.querySelector('#second circle[node_type="nucleotide"]').__data__.rna; + // Hold unrelated nodes still to isolate pointer displacement from forces. + graph.nodes.forEach(node => { node.fixed = 1; node.px = node.x; node.py = node.y; }); + window.interactionGraph = graph; + const item = api.registerTextAnnotation({ text: 'Group label', size: 7 }); + const box = document.querySelector('#second svg').getBoundingClientRect(); + api.placeTextAnnotation(item.id, box.x + 430, box.y + 110); + window.interactionTextId = item.id; + }, forceLayout); + const host = page.locator('#second'); + const svg = host.locator('svg'); + const node = host.locator('g.gnode').filter({ has: page.locator('circle[node_type="nucleotide"]') }).first(); + const other = host.locator('g.gnode').filter({ has: page.locator('circle[node_type="nucleotide"]') }).nth(2); + const label = host.locator('g.gnode').filter({ has: page.locator('circle[node_type="label"]') }).first(); + const text = host.locator('[data-varri-text]'); + const plotBefore = await host.locator('.fornac-plot').getAttribute('transform'); + const before = await origin(node); + // The dragged nucleotide begins unpinned; release must retain its new place. + await node.evaluate(element => { element.__data__.fixed = 0; }); + await drag(page, node, -30, 20); + const after = await origin(node); + // A live force may move an unpinned node before mouse-down. The gesture still + // fixes it and the remaining checks use fully stationary targets. + if (!forceLayout) { + near(after.x - before.x, -30, 'Single nucleotide follows pointer x'); + near(after.y - before.y, 20, 'Single nucleotide follows pointer y'); + } + assert.equal(await node.evaluate(element => element.__data__.fixed), 1, 'Released node stays fixed'); + await page.waitForTimeout(100); + assert.deepEqual(await origin(node), after, 'Force cannot pull a released node back'); + const labelBefore = await origin(label); + await drag(page, label, -20, 15); + const labelAfter = await origin(label); + near(labelAfter.x - labelBefore.x, -20, 'Numbering label drags independently x'); + near(labelAfter.y - labelBefore.y, 15, 'Numbering label drags independently y'); + assert.equal(await host.locator('.fornac-plot').getAttribute('transform'), plotBefore, 'Element drags do not pan'); - const svg = page.locator('#second svg'); - const transform = await svg.evaluate(element => ({ k: element.__zoom.k, x: element.__zoom.x })); - await svg.hover({ position: { x: 100, y: 100 } }); - await page.mouse.wheel(0, -200); - await page.waitForFunction(k => document.querySelector('#second svg').__zoom.k > k, transform.k); - // Wait for the wheel gesture to finish before testing a separate pan gesture. - await page.waitForTimeout(200); - const svgBox = await svg.boundingBox(); - const panBefore = await svg.evaluate(element => element.__zoom.x); - await page.mouse.move(svgBox.x + 20, svgBox.y + 20); - await page.mouse.down(); - await page.mouse.move(svgBox.x + 45, svgBox.y + 40, { steps: 3 }); - await page.mouse.up(); - assert.ok(Math.abs(await svg.evaluate(element => element.__zoom.x) - panBefore - 25) < 0.5, - 'Background panning updates the stored zoom transform'); + await click(page, node, true); + await click(page, other, true); + await click(page, label, true); + await click(page, text, true); + assert.equal(await host.locator('[data-varri-selected]').count(), 4, 'Mixed selection adds all target types'); + await click(page, other, true); + assert.equal(await host.locator('[data-varri-selected]').count(), 3, 'Ctrl-click toggles an existing member off'); + await click(page, other, true); + const targets = [node, other, label, text]; + for (const handle of [node, text]) { + const starts = await Promise.all(targets.map(origin)); + await drag(page, handle, 18, -12); + const ends = await Promise.all(targets.map(origin)); + ends.forEach((end, i) => { + near(end.x - starts[i].x, 18, `Group member ${i} follows x`); + near(end.y - starts[i].y, -12, `Group member ${i} follows y`); + }); + } + assert.equal(await host.locator('[data-varri-interaction-overlay] rect').count(), 4, 'Selection is visible'); - const finalBox = await circle.boundingBox(); - await page.mouse.move(finalBox.x + finalBox.width / 2, finalBox.y + finalBox.height / 2); - await page.mouse.down(); - await page.evaluate(() => window.exportApi.cancelActiveRender()); - const cancelled = await page.evaluate(() => window.interactionGraph.nodes.map(node => [node.x, node.y])); - await page.mouse.move(finalBox.x + 90, finalBox.y + 60); - await page.mouse.up(); - assert.deepEqual(await page.evaluate(() => window.interactionGraph.nodes.map(node => [node.x, node.y])), - cancelled, 'Cancelling an active drag detaches handlers and cannot restart the old simulation'); + // Rotation and zoom must be inverted for every type within one group. + await page.evaluate(() => window.exportApi.rotateVisualization('second', 37, { mode: 'absolute' })); + const zoomBefore = await svg.evaluate(element => element.__zoom.k); + await svg.hover({ position: { x: 320, y: 240 } }); + await page.mouse.wheel(0, 80); + await page.waitForFunction(k => document.querySelector('#second svg').__zoom.k < k, zoomBefore); + await page.waitForTimeout(200); + const rotatedStarts = await Promise.all(targets.map(origin)); + await drag(page, label, -12, 16); + const rotatedEnds = await Promise.all(targets.map(origin)); + rotatedEnds.forEach((end, i) => { + near(end.x - rotatedStarts[i].x, -12, `Rotated group ${i} follows x`); + near(end.y - rotatedStarts[i].y, 16, `Rotated group ${i} follows y`); + }); + + const exported = await page.evaluate(() => { + const live = document.querySelector('#second svg'); + const svg = new DOMParser().parseFromString(window.exportApi.buildSVGString('second'), 'image/svg+xml'); + const transforms = root => [...root.querySelectorAll('g.gnode, [data-varri-text]')] + .map(element => element.getAttribute('transform')); + return { live: transforms(live), exported: transforms(svg), + feedback: svg.querySelectorAll('[data-varri-interaction-overlay], [data-varri-selected]').length }; + }); + assert.deepEqual(exported.exported, exported.live, 'SVG keeps every manually moved position'); + assert.equal(exported.feedback, 0, 'Selection feedback is absent from exports'); + const [download] = await Promise.all([page.waitForEvent('download'), + page.evaluate(() => window.exportApi.downloadPNG('second'))]); + assert.equal(await download.failure(), null, 'Edited layout also exports to PNG'); + await download.saveAs(`output/playwright/core-contract/dragged-${forceLayout ? 'force' : 'static'}.png`); + + const outsider = host.locator('g.gnode').filter({ has: page.locator('circle[node_type="nucleotide"]') }).nth(5); + const untouched = await origin(other); + await drag(page, outsider, 8, 8); + assert.equal(await host.locator('[data-varri-selected]').count(), 0, 'Dragging outside selection clears it'); + assert.deepEqual(await origin(other), untouched, 'Previously selected node does not move with outsider'); + await exerciseRectangleSelection(page, host); + + const svgBox = await svg.boundingBox(); + const panBefore = await svg.evaluate(element => ({ x: element.__zoom.x, y: element.__zoom.y })); + await page.mouse.move(svgBox.x + 15, svgBox.y + 15); + await page.mouse.down(); + await page.mouse.move(svgBox.x + 40, svgBox.y + 35, { steps: 3 }); + await page.mouse.up(); + near(await svg.evaluate(element => element.__zoom.x) - panBefore.x, 25, 'Background pans x'); + near(await svg.evaluate(element => element.__zoom.y) - panBefore.y, 20, 'Background pans y'); + + const box = await node.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.evaluate(() => window.exportApi.cancelActiveRender()); + const cancelled = await page.evaluate(() => window.interactionGraph.nodes.map(node => [node.x, node.y])); + await page.mouse.move(box.x + 90, box.y + 60); + await page.mouse.up(); + assert.deepEqual(await page.evaluate(() => window.interactionGraph.nodes.map(node => [node.x, node.y])), + cancelled, 'Cancellation detaches the active drag without restarting forces'); + } await page.evaluate(async () => { const api = window.exportApi; - await api.render('second', api.validate({ sequence: 'AAAA', structure: '....' })); + await api.render('second', api.validate({ sequence: 'AAAA', structure: '....', textAnnotations: [] })); }); - const nextBox = await svg.boundingBox(); - await page.mouse.move(nextBox.x + 20, nextBox.y + 20); + const svg = page.locator('#second svg'); + const box = await svg.boundingBox(); + await page.mouse.move(box.x + 20, box.y + 20); await page.mouse.down(); await page.evaluate(() => window.exportApi.cancelActiveRender()); - const stoppedTransform = await page.locator('#second .fornac-plot').getAttribute('transform'); - await page.mouse.move(nextBox.x + 70, nextBox.y + 60); + const stopped = await page.locator('#second .fornac-plot').getAttribute('transform'); + await page.mouse.move(box.x + 70, box.y + 60); await page.mouse.up(); - assert.equal(await page.locator('#second .fornac-plot').getAttribute('transform'), stoppedTransform, - 'Cancelling an active pan detaches its window handlers'); + assert.equal(await page.locator('#second .fornac-plot').getAttribute('transform'), stopped, + 'Cancellation detaches an active background pan'); } diff --git a/scripts/browser-selection.js b/scripts/browser-selection.js new file mode 100644 index 0000000..00bbf87 --- /dev/null +++ b/scripts/browser-selection.js @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; + +/** Marquee selection uses full screen bounds, including counterrotated labels. */ +export async function exerciseRectangleSelection(page, host) { + const svg = host.locator('svg'); + const box = await svg.boundingBox(); + const bounds = await host.locator('g.gnode, [data-varri-text]').evaluateAll(elements => elements.map((element, i) => { + element.setAttribute('data-test-target', i); + const { left, right, top, bottom } = element.getBoundingClientRect(); + return { i, left, right, top, bottom }; + })); + const left = box.x + 10, top = box.y + 10; + const right = box.x + box.width - 10, bottom = box.y + box.height - 10; + const expected = bounds.filter(b => b.left >= left && b.right <= right && b.top >= top && b.bottom <= bottom) + .map(b => String(b.i)); + assert.ok(expected.length > 4, 'Rectangle fixture contains several objects'); + const firstViewer = await page.locator('#first').innerHTML(); + const plot = await host.locator('.fornac-plot').getAttribute('transform'); + for (const reverse of [false, true]) { + await page.keyboard.down('Control'); + await page.mouse.move(reverse ? right : left, reverse ? bottom : top); + await page.mouse.down(); + await page.mouse.move(reverse ? left : right, reverse ? top : bottom, { steps: 6 }); + assert.equal(await host.locator('[data-varri-interaction-overlay] [stroke-dasharray]').count(), 1, + 'Marquee is drawn during the gesture'); + await page.mouse.up(); + await page.keyboard.up('Control'); + assert.deepEqual(await host.locator('[data-varri-selected]').evaluateAll(elements => + elements.map(element => element.getAttribute('data-test-target'))), expected, 'Rectangle selects every fully enclosed element'); + assert.equal(await host.locator('.fornac-plot').getAttribute('transform'), plot, 'Ctrl-drag does not pan'); + } + assert.equal(await page.locator('#first').innerHTML(), firstViewer, 'Selection is isolated to its viewer'); + + const partial = bounds.find(b => b.left > left + 10 && b.right < right && b.top > top + 10 && b.bottom < bottom); + assert.ok(partial); + const cutoff = (partial.left + partial.right) / 2; + await page.keyboard.down('Control'); + await page.mouse.move(left, top); + await page.mouse.down(); + await page.mouse.move(cutoff, bottom, { steps: 4 }); + await page.mouse.up(); + await page.keyboard.up('Control'); + assert.equal(await host.locator(`[data-test-target="${partial.i}"][data-varri-selected]`).count(), 0, + 'Partly intersecting objects are excluded'); + + await page.keyboard.down('Control'); + await page.mouse.move(left, top); + await page.mouse.down(); + await page.mouse.move(left + 3, top + 3); + await page.mouse.up(); + await page.keyboard.up('Control'); + assert.equal(await host.locator('[data-varri-selected]').count(), 0, 'An empty rectangle clears selection'); + await host.locator('[data-test-target]').evaluateAll(elements => elements.forEach(element => element.removeAttribute('data-test-target'))); +} diff --git a/scripts/browser-text-ui.js b/scripts/browser-text-ui.js index 2d309f7..825359d 100644 --- a/scripts/browser-text-ui.js +++ b/scripts/browser-text-ui.js @@ -71,6 +71,32 @@ export async function exerciseTextAnnotationUI(page, origin, output) { assert.ok(Math.abs(moved.x - placed.x - 50) < 1 && Math.abs(moved.y - placed.y - 30) < 1, 'SVG drag follows pointer with force disabled'); assert.equal(await page.locator('#rendering-canvas .fornac-plot').getAttribute('transform'), plotBefore); + // A mixed group must notify Vue with the final centroid-relative text position. + const nodePoint = await page.locator('#rendering-canvas circle[node_type="nucleotide"]').evaluateAll(elements => { + for (const element of elements) { + const box = element.getBoundingClientRect(); + const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + if (document.elementFromPoint(point.x, point.y)?.closest('g.gnode') === element.parentElement) return point; + } + return null; + }); + assert.ok(nodePoint, 'A nucleotide is available for mixed selection'); + const selectedText = await group.locator('text').boundingBox(); + const handle = { x: selectedText.x + selectedText.width / 2, y: selectedText.y + selectedText.height / 2 }; + await page.keyboard.down('Control'); + await page.mouse.click(nodePoint.x, nodePoint.y); + await page.mouse.click(handle.x, handle.y); + await page.keyboard.up('Control'); + assert.equal(await page.locator('#rendering-canvas [data-varri-selected]').count(), 2); + await page.mouse.move(handle.x, handle.y); + await page.mouse.down(); + await page.mouse.move(handle.x + 16, handle.y - 10, { steps: 4 }); + await page.mouse.up(); + const groupPosition = await page.evaluate(async id => { + const { default: view } = await import('/index.js'); + return view.state.annotations.texts.find(item => item.id === Number(id)).position; + }, id); + await preview.click(); await page.locator('#textAnnotationText').fill(content + ' edited'); await page.locator('#textAnnotationDialog button[value="ok"]').click(); @@ -85,7 +111,7 @@ export async function exerciseTextAnnotationUI(page, origin, output) { assert.equal(saved[2].bold, true); assert.equal(saved[2].italic, true); assert.equal(saved[2].size, 10); - assert.ok(saved[2].position); + assert.deepEqual(saved[2].position, groupPosition, 'Share link saves the grouped drag position'); assert.ok(!saved[2].anchor); await page.goto(shared, { waitUntil: 'networkidle' }); await ready(); diff --git a/src/core/canvas/export.js b/src/core/canvas/export.js index 8e4c553..6cdd590 100644 --- a/src/core/canvas/export.js +++ b/src/core/canvas/export.js @@ -72,6 +72,8 @@ export function buildSVGString(session, containerId) { // so that class-based CSS rules, relative units, and inherited values // are all baked into the clone as plain inline style attributes. inlineComputedStyles(session, svgEl, clone); + clone.querySelectorAll('[data-varri-interaction-overlay]').forEach(element => element.remove()); + clone.querySelectorAll('[data-varri-selected]').forEach(element => element.removeAttribute('data-varri-selected')); // Required namespace declarations for a standalone SVG file. clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); diff --git a/src/core/canvas/graph/force.js b/src/core/canvas/graph/force.js index 2fc667b..9ce3858 100644 --- a/src/core/canvas/graph/force.js +++ b/src/core/canvas/graph/force.js @@ -37,49 +37,3 @@ export function createForce(container, d3) { return createSimulation(container, d3) .on('tick.graph', () => { collideNodes(container.graph, d3); syncPositions(container, d3); }); } - -export function attachDragging(container, groups, d3) { - if (!container.options.animation) return; - const allGroups = () => d3.select(container.layers.nodes).selectAll('g.gnode'); - const selected = () => container.graph.nodes.filter(node => node.selected); - const release = () => selected().forEach(node => { - node.fixed = node.dragFixed; - delete node.dragFixed; - }); - const drag = d3.drag() - .filter(event => !event.button && !container.destroyed) - .on('start.graph', (event, node) => { - const source = event.sourceEvent; - source.stopPropagation(); - if (!node.selected) { - if (!source.ctrlKey && !source.metaKey) container.graph.nodes.forEach(item => { item.selected = false; }); - node.selected = true; - } - selected().forEach(item => { item.dragFixed = item.fixed || 0; item.fixed = 1; }); - container.cancelDrag = () => { - if (source.view) { - d3.select(source.view).on('mousemove.drag mouseup.drag', null); - d3.dragEnable(source.view); - } - release(); - container.cancelDrag = null; - }; - allGroups().classed('fornac-selectedNode', item => !!item.selected); - }) - .on('drag.graph', event => { - if (container.destroyed) return; - selected().forEach(node => { - node.x += event.dx; node.px += event.dx; - node.y += event.dy; node.py += event.dy; - }); - syncPositions(container, d3); - container.force.resume(); - }) - .on('end.graph', () => { - if (container.destroyed) return; - release(); - container.cancelDrag = null; - container.force.resume(); - }); - groups.call(drag); -} diff --git a/src/core/canvas/graph/index.js b/src/core/canvas/graph/index.js index 0b592a2..5ae02ef 100644 --- a/src/core/canvas/graph/index.js +++ b/src/core/canvas/graph/index.js @@ -1,7 +1,9 @@ import getD3 from '../../vendor/d3.js'; import { createRnaGraph } from './structure.js'; -import { createSvg, updateSvg } from './svg.js'; -import { createForce, attachDragging } from './force.js'; +import { createSvg, updateSvg, syncPositions } from './svg.js'; +import { createForce } from './force.js'; +import { createCanvasInteractions } from '../interactions.js'; +import { INTERACTION_TARGET } from '../interaction-selection.js'; const DEFAULT_OPTIONS = { animation: false, labelInterval: 1, chargeDistance: 110, friction: 0.35, @@ -28,7 +30,9 @@ export function createGraphCanvas(target, options = {}) { let destroyed = false; const svg = d3.select(layers.svg); const plot = d3.select(layers.plot); - const zoom = d3.zoom().on('start.graph', event => { + const zoom = d3.zoom().filter(event => !event.button && (event.type === 'wheel' || + (!event.ctrlKey && !event.metaKey && !event.target.closest?.(INTERACTION_TARGET)))) + .on('start.graph', event => { const source = event.sourceEvent; if (source?.type !== 'mousedown' || !source.view) return; container.cancelZoom = () => { @@ -40,6 +44,7 @@ export function createGraphCanvas(target, options = {}) { if (destroyed) return; const { x, y, k } = event.transform; plot.attr('transform', `translate(${x},${y}) scale(${k})`); + container.interactions?.refresh(); }); if (container.options.zoomable) svg.call(zoom); @@ -54,6 +59,10 @@ export function createGraphCanvas(target, options = {}) { } sizeCanvas(); container.force = createForce(container, d3); + container.interactions = createCanvasInteractions(container, () => { + syncPositions(container, d3); + container.onManualMove?.(); + }); container.centerView = () => { if (destroyed || !container.graph.nodes.length) return; const nodes = container.graph.nodes.filter(node => Number.isFinite(node.x) && Number.isFinite(node.y)); @@ -71,13 +80,15 @@ export function createGraphCanvas(target, options = {}) { container.update = () => { if (destroyed) return; container.force.nodes(container.graph.nodes).links(container.graph.links); - const newGroups = updateSvg(container, d3); - attachDragging(container, newGroups, d3); + updateSvg(container, d3); + container.interactions.refresh(); if (container.options.animation) container.force.start(); }; container.addRNA = (structure, rnaOptions = {}) => { if (destroyed) throw new Error('Cannot add RNA to a destroyed canvas.'); + container.interactions.clear(); container.force.stop(); + container.hasManualPositions = false; container.graph = createRnaGraph(structure, { labelInterval: container.options.labelInterval, ...rnaOptions, }); @@ -96,12 +107,12 @@ export function createGraphCanvas(target, options = {}) { if (destroyed) return; destroyed = true; container.destroyed = true; - container.cancelDrag?.(); + container.interactions.dispose(); + delete container.onManualMove; container.cancelZoom?.(); observer?.disconnect(); container.force.on('tick.graph', null).on('end.graph', null).stop(); svg.on('.zoom', null); - d3.select(layers.nodes).selectAll('g.gnode').on('.drag', null); }; return container; } diff --git a/src/core/canvas/graph/svg.js b/src/core/canvas/graph/svg.js index 4509d85..a5a7a21 100644 --- a/src/core/canvas/graph/svg.js +++ b/src/core/canvas/graph/svg.js @@ -63,7 +63,8 @@ export function updateSvg(container, d3) { const nodes = d3.select(container.layers.nodes).selectAll('g.gnode') .data(visibleNodes, node => node.uid); const groups = nodes.enter().append('g').attr('class', 'gnode') - .attr('num', node => `n${node.num}`).attr('struct_name', node => node.structName); + .attr('num', node => `n${node.num}`).attr('struct_name', node => node.structName) + .style('cursor', 'move').style('touch-action', 'none'); const nucleotides = groups.filter(node => node.nodeType === 'nucleotide'); nucleotides.append('path').attr('class', 'fornac-directionArrow') .attr('node_num', node => node.num); @@ -73,7 +74,7 @@ export function updateSvg(container, d3) { .style('fill', node => node.nodeType === 'nucleotide' ? LOOP_COLORS[node.elemType] : 'white') .append('title').text(node => `${node.structName}:${node.num}`); groups.append('text').attr('class', 'fornac-nodeLabel') - .attr('label_type', node => node.nodeType).text(node => node.name); + .attr('label_type', node => node.nodeType).style('pointer-events', 'auto').text(node => node.name); nodes.exit().remove(); syncPositions(container, d3); return groups; diff --git a/src/core/canvas/interaction-selection.js b/src/core/canvas/interaction-selection.js new file mode 100644 index 0000000..ee8fdd6 --- /dev/null +++ b/src/core/canvas/interaction-selection.js @@ -0,0 +1,64 @@ +import { clientToGraphPosition } from './text-annotation-geometry.js'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; +export const INTERACTION_TARGET = 'g.gnode, [data-varri-text]'; + +/** Only rendered, visible objects are selectable; force scaffolds never are. */ +export function interactionTargets(container) { + const nodes = Array.from(container.svg.querySelectorAll('g.gnode')).map(element => ({ + element, node: element.__data__, layer: element.parentNode, + position: { x: element.__data__.x, y: element.__data__.y }, + })); + const state = container.varriTextAnnotations; + const texts = Array.from(state?.entries || []).filter(([, entry]) => entry.position) + .map(([id, entry]) => ({ element: entry.group, id, layer: state.layer, position: { ...entry.position } })); + return [...nodes, ...texts].filter(target => { + const style = container.svg.ownerDocument.defaultView.getComputedStyle(target.element); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); +} + +export function enclosed(element, rectangle) { + const box = element.getBoundingClientRect(); + return (box.width > 0 || box.height > 0) && box.left >= rectangle.left && + box.right <= rectangle.right && box.top >= rectangle.top && box.bottom <= rectangle.bottom; +} + +export function selectionRectangle(start, end) { + return { left: Math.min(start.x, end.x), right: Math.max(start.x, end.x), + top: Math.min(start.y, end.y), bottom: Math.max(start.y, end.y) }; +} + +/** Screen-aligned feedback lives outside the graph and never affects its bounds. */ +export function createSelectionOverlay(svg) { + const layer = svg.ownerDocument.createElementNS(SVG_NS, 'g'); + layer.setAttribute('data-varri-interaction-overlay', 'true'); + layer.setAttribute('pointer-events', 'none'); + svg.appendChild(layer); + function outline(bounds, marquee = false) { + if (!svg.getScreenCTM?.()) return; + const start = clientToGraphPosition(svg, bounds.left, bounds.top); + const end = clientToGraphPosition(svg, bounds.right, bounds.bottom); + const rect = svg.ownerDocument.createElementNS(SVG_NS, 'rect'); + const padding = marquee ? 0 : 2; + rect.setAttribute('x', start.x - padding); + rect.setAttribute('y', start.y - padding); + rect.setAttribute('width', Math.max(0, end.x - start.x) + padding * 2); + rect.setAttribute('height', Math.max(0, end.y - start.y) + padding * 2); + rect.setAttribute('fill', '#2563eb'); + rect.setAttribute('fill-opacity', marquee ? '0.10' : '0.06'); + rect.setAttribute('stroke', '#2563eb'); + rect.setAttribute('stroke-width', '1.5'); + rect.setAttribute('vector-effect', 'non-scaling-stroke'); + if (marquee) rect.setAttribute('stroke-dasharray', '5 3'); + layer.appendChild(rect); + } + return { + draw(selected, rectangle) { + layer.replaceChildren(); + selected.forEach(element => outline(element.getBoundingClientRect())); + if (rectangle) outline(rectangle, true); + }, + dispose() { layer.remove(); }, + }; +} diff --git a/src/core/canvas/interactions.js b/src/core/canvas/interactions.js new file mode 100644 index 0000000..706cf20 --- /dev/null +++ b/src/core/canvas/interactions.js @@ -0,0 +1,146 @@ +import { clientToGraphPosition } from './text-annotation-geometry.js'; +import { createSelectionOverlay, enclosed, interactionTargets, INTERACTION_TARGET, + selectionRectangle } from './interaction-selection.js'; + +/** One pointer owner and one selection for nodes, numbering, and free text. */ +export function createCanvasInteractions(container, syncGraph = () => {}) { + const { svg } = container; + const window = svg.ownerDocument.defaultView; + const selected = new Set(); + const overlay = createSelectionOverlay(svg); + let active = null; + let hadSelection = false; + + function refresh() { + if (!selected.size && !hadSelection && !active?.rectangle) return; + const targets = interactionTargets(container); + const live = new Set(targets.map(target => target.element)); + for (const element of selected) if (!live.has(element)) selected.delete(element); + for (const target of targets) { + const value = selected.has(target.element); + if (target.node) target.node.selected = value; + if (value) target.element.setAttribute('data-varri-selected', 'true'); + else target.element.removeAttribute('data-varri-selected'); + } + overlay.draw(selected, active?.rectangle); + hadSelection = selected.size > 0 || !!active?.rectangle; + } + + function stop() { + if (!active) return; + const { pointerId } = active; + active = null; + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onEnd); + window.removeEventListener('pointercancel', onCancel); + window.removeEventListener('blur', onCancel); + if (svg.hasPointerCapture?.(pointerId)) svg.releasePointerCapture(pointerId); + refresh(); + } + + function beginMove() { + if (!selected.has(active.target.element)) selected.clear(); + const targets = selected.size ? interactionTargets(container).filter(target => selected.has(target.element)) + : [active.target]; + active.targets = targets.map(target => ({ ...target, + origin: clientToGraphPosition(target.layer, active.start.x, active.start.y) })); + active.moved = true; + container.hasManualPositions = targets.some(target => target.node) || container.hasManualPositions; + } + + function onMove(event) { + if (!active || event.pointerId !== active.pointerId || container.destroyed) return; + const point = { x: event.clientX, y: event.clientY }; + if (!active.moved && Math.hypot(point.x - active.start.x, point.y - active.start.y) < 2) return; + event.preventDefault(); + if (!active.target) { + active.moved = true; + active.rectangle = selectionRectangle(active.start, point); + selected.clear(); + interactionTargets(container).filter(target => enclosed(target.element, active.rectangle)) + .forEach(target => selected.add(target.element)); + } else { + if (!active.target.element.isConnected) { stop(); return; } + if (!active.moved) beginMove(); + const texts = []; + let movedNodes = false; + for (const target of active.targets) { + if (!target.element.isConnected) continue; + const current = clientToGraphPosition(target.layer, point.x, point.y); + const x = target.position.x + current.x - target.origin.x; + const y = target.position.y + current.y - target.origin.y; + if (target.node) { + // Keep the released position for this rendering, including force and + // rail constraints. No graph positions enter the serializable model. + Object.assign(target.node, { x, y, px: x, py: y, fx: x, fy: y, vx: 0, vy: 0, fixed: 1 }); + movedNodes = true; + } else texts.push({ id: target.id, position: { x, y } }); + } + if (movedNodes) syncGraph(); + // Apply the entire text batch after moving graph nodes: its model frame + // uses the new nucleotide centroid, avoiding double movement in a group. + container.varriTextAnnotations?.move(texts); + if (texts.length) active.changedText = true; + if (movedNodes && container.options?.animation) container.force.resume(); + } + refresh(); + } + + function onEnd(event) { + if (!active || event.pointerId !== active.pointerId) return; + if (!active.moved && active.target && active.toggle) { + const element = active.target.element; + if (selected.has(element)) selected.delete(element); + else selected.add(element); + } + const changedText = active.changedText; + stop(); + if (changedText) container.varriTextAnnotations?.notify(); + } + + function onCancel() { + const changedText = active?.changedText; + stop(); + if (changedText) container.varriTextAnnotations?.notify(); + } + + function onStart(event) { + if (event.button !== 0 || active || container.destroyed) return; + const element = event.target.closest?.(INTERACTION_TARGET); + const target = interactionTargets(container).find(item => item.element === element); + const toggle = event.ctrlKey || event.metaKey; + if (!target && !toggle) return; // Ordinary background gestures belong to zoom/pan. + event.preventDefault(); + event.stopPropagation(); + active = { target, toggle, pointerId: event.pointerId, + start: { x: event.clientX, y: event.clientY }, moved: false, changedText: false }; + svg.setPointerCapture?.(event.pointerId); + window.addEventListener('pointermove', onMove, { passive: false }); + window.addEventListener('pointerup', onEnd); + window.addEventListener('pointercancel', onCancel); + window.addEventListener('blur', onCancel); + } + + const stopPan = event => { + if (active || event.target.closest?.(INTERACTION_TARGET) || event.ctrlKey || event.metaKey) { + event.stopImmediatePropagation(); + } + }; + svg.addEventListener('pointerdown', onStart); + svg.addEventListener('lostpointercapture', onCancel); + for (const type of ['mousedown', 'touchstart', 'dblclick']) svg.addEventListener(type, stopPan, true); + return { + refresh, + cancel: stop, + clear() { stop(); selected.clear(); refresh(); }, + dispose() { + stop(); + selected.clear(); + refresh(); + overlay.dispose(); + svg.removeEventListener('pointerdown', onStart); + svg.removeEventListener('lostpointercapture', onCancel); + for (const type of ['mousedown', 'touchstart', 'dblclick']) svg.removeEventListener(type, stopPan, true); + }, + }; +} diff --git a/src/core/canvas/layout/linear-helix.js b/src/core/canvas/layout/linear-helix.js index d9961ac..a5ac6af 100644 --- a/src/core/canvas/layout/linear-helix.js +++ b/src/core/canvas/layout/linear-helix.js @@ -62,7 +62,7 @@ export function applyLinearHelixSprings(session, container, v, options = {}) { let hasRefittedAtRest = false; const enforceSyncAndRefit = () => { enforceAndSync(); - if (!hasRefittedAtRest && typeof container.centerView === 'function') { + if (!hasRefittedAtRest && !container.hasManualPositions && typeof container.centerView === 'function') { hasRefittedAtRest = true; container.centerView(); } diff --git a/src/core/canvas/render.js b/src/core/canvas/render.js index d2c4ce0..1e43d17 100644 --- a/src/core/canvas/render.js +++ b/src/core/canvas/render.js @@ -148,6 +148,12 @@ export function render(session, containerId, v, options = {}) { container.centerView(); } + container.onManualMove = () => { + session.dom.querySelectorAll('[data-varri-region], [data-varri-subseq]').forEach(el => el.remove()); + applyRegionHighlights(session, v); + applySubsequenceHighlights(session, v); + }; + // When animation is on, keep the background-highlight polygon in sync // with the force-layout by redrawing it on every animation frame. if (forceLayout) { diff --git a/src/core/canvas/rotation.js b/src/core/canvas/rotation.js index 0009bf3..e08c5bf 100644 --- a/src/core/canvas/rotation.js +++ b/src/core/canvas/rotation.js @@ -139,5 +139,6 @@ export function rotateVisualization(session, containerId, degrees, options = {}) counterrotateTextLabels(layer, target, center); const textLayer = hostEl.querySelector('[data-varri-text-layer]'); if (textLayer) syncTextAnnotationRotation(svgEl, textLayer); + session.runtime.activeContainer?.interactions?.refresh(); return target; } diff --git a/src/core/canvas/text-annotation-drag.js b/src/core/canvas/text-annotation-drag.js deleted file mode 100644 index 358fd02..0000000 --- a/src/core/canvas/text-annotation-drag.js +++ /dev/null @@ -1,60 +0,0 @@ -import { clientToGraphPosition } from './text-annotation-geometry.js'; - -/** Pointer ownership stays on the canvas and is cancelled with its render. */ -export function attachTextAnnotationDragging(state, move, notify) { - const { layer, window } = state; - let active = null; - const stopMouse = event => event.stopPropagation(); - const stop = () => { - if (!active) return; - const pointerId = active.pointerId; - active = null; - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onEnd); - window.removeEventListener('pointercancel', onEnd); - if (layer.hasPointerCapture?.(pointerId)) layer.releasePointerCapture(pointerId); - }; - const onMove = event => { - if (!active || active.pointerId !== event.pointerId) return; - if (!state.entries.has(active.id)) { stop(); return; } - event.preventDefault(); - const point = clientToGraphPosition(layer, event.clientX, event.clientY); - move(active.id, { x: point.x + active.offset.x, y: point.y + active.offset.y }); - active.changed = true; - }; - const onEnd = event => { - if (!active || active.pointerId !== event.pointerId) return; - const changed = active.changed; - stop(); - if (changed) notify(); - }; - const onStart = event => { - if (event.button !== 0 || active) return; - const group = event.target.closest('[data-varri-text]'); - if (!group || !layer.contains(group)) return; - const id = Number(group.getAttribute('data-varri-text')); - const entry = state.entries.get(id); - if (!entry?.position) return; - const point = clientToGraphPosition(layer, event.clientX, event.clientY); - event.preventDefault(); - event.stopPropagation(); - active = { id, pointerId: event.pointerId, changed: false, - offset: { x: entry.position.x - point.x, y: entry.position.y - point.y } }; - layer.setPointerCapture?.(event.pointerId); - window.addEventListener('pointermove', onMove, { passive: false }); - window.addEventListener('pointerup', onEnd); - window.addEventListener('pointercancel', onEnd); - }; - layer.addEventListener('pointerdown', onStart); - // Prevent D3's mouse-based pan from starting after a pointer press on text. - layer.addEventListener('mousedown', stopMouse); - layer.addEventListener('touchstart', stopMouse); - layer.addEventListener('dblclick', stopMouse); - return () => { - stop(); - layer.removeEventListener('pointerdown', onStart); - layer.removeEventListener('mousedown', stopMouse); - layer.removeEventListener('touchstart', stopMouse); - layer.removeEventListener('dblclick', stopMouse); - }; -} diff --git a/src/core/canvas/text-annotations.js b/src/core/canvas/text-annotations.js index f05d9a8..c458074 100644 --- a/src/core/canvas/text-annotations.js +++ b/src/core/canvas/text-annotations.js @@ -2,7 +2,6 @@ import { clearTextAnnotations, getTextAnnotations, initializeDefaultTextAnnotati registerTextAnnotation, updateTextAnnotation } from '../model/text-annotations.js'; import { syncTextAnnotationRotation } from './rotation.js'; import { clientToGraphPosition, nucleotideCentroid, terminalPosition } from './text-annotation-geometry.js'; -import { attachTextAnnotationDragging } from './text-annotation-drag.js'; import { defaultTextOffset } from './text-annotation-defaults.js'; import { getSequenceNames, setSequenceNames } from '../model/sequence-names.js'; @@ -117,6 +116,7 @@ export function refreshTextAnnotations(session) { } syncPositions(session, state); syncTextAnnotationRotation(state.container.svg, state.layer); + state.container.interactions?.refresh(); return items.length; } @@ -153,13 +153,25 @@ export function initializeTextAnnotations(session, container, validated, options const layer = session.dom.createElementNS(SVG_NS, 'g'); layer.setAttribute('data-varri-text-layer', 'true'); container.plot.appendChild(layer); - const state = { layer, container, validated, entries: new Map(), window: session.window, + const state = { layer, container, validated, entries: new Map(), notify: () => options.onTextAnnotationsChange?.(getTextAnnotations(session.modelState)) }; container.varriTextAnnotations = state; - state.disposeDrag = attachTextAnnotationDragging(state, - (id, position) => moveTextAnnotation(session, state, id, position), state.notify); + state.move = positions => { + const center = nucleotideCentroid(container.graph); + for (const { id, position } of positions) { + if (!state.entries.has(id)) continue; + updateTextAnnotation(session.modelState, id, { + position: { x: position.x - center.x, y: position.y - center.y }, + }); + } + if (positions.length) refreshTextAnnotations(session); + else syncPositions(session, state); + }; refreshTextAnnotations(session); - const sync = () => syncPositions(session, state); + const sync = () => { + syncPositions(session, state); + container.interactions?.refresh(); + }; container.force?.on('tick.varriTextAnnotations', sync).on('end.varriTextAnnotations', sync); state.notify(); } @@ -167,7 +179,7 @@ export function initializeTextAnnotations(session, container, validated, options export function clearTextAnnotationState(container) { const state = container?.varriTextAnnotations; if (!state) return; - state.disposeDrag(); + container.interactions?.cancel(); container.force?.on('tick.varriTextAnnotations', null).on('end.varriTextAnnotations', null); delete container.varriTextAnnotations; } diff --git a/tests/text-annotation-canvas.test.js b/tests/text-annotation-canvas.test.js index 13bc23a..27bb340 100644 --- a/tests/text-annotation-canvas.test.js +++ b/tests/text-annotation-canvas.test.js @@ -1,3 +1,4 @@ +import { createCanvasInteractions } from '../src/core/canvas/interactions.js'; import { jest } from '@jest/globals'; import { JSDOM } from 'jsdom'; import { createSession } from '../src/core/session.js'; @@ -31,15 +32,16 @@ function fixture({ defaults = false } = {}) { ] } }; dom.window.SVGElement.prototype.getBBox = () => ({ x: -5, y: -4, width: 10, height: 8 }); session.runtime.activeContainer = container; + container.interactions = createCanvasInteractions(container); if (!defaults) clearTextAnnotations(session.modelState); const notify = jest.fn(); initializeTextAnnotations(session, container, { sequence1: 'AA', sequence2: 'UU' }, { onTextAnnotationsChange: notify }); const layer = container.varriTextAnnotations.layer; layer.getScreenCTM = () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); const tick = () => handlers.get('tick.varriTextAnnotations')(); - const pointer = (target, type, x, y) => { + const pointer = (target, type, x, y, options = {}) => { const event = new dom.window.MouseEvent(type, { bubbles: true, cancelable: true, button: 0, - clientX: x, clientY: y }); + clientX: x, clientY: y, ...options }); Object.defineProperty(event, 'pointerId', { value: 1 }); target.dispatchEvent(event); }; @@ -208,3 +210,48 @@ test('live changes at a nonzero angle counterrotate text without changing the RN expect(f.layer.children).toHaveLength(0); f.dom.window.close(); }); + + +test('Ctrl-click toggles text without changing placement, and grouped text persists exactly once', () => { + const f = fixture(); + const first = registerTextAnnotation(f.session.modelState, { text: 'First', position: { x: 10, y: 10 } }); + const second = registerTextAnnotation(f.session.modelState, { text: 'Second', position: { x: 70, y: 20 } }); + refreshTextAnnotations(f.session); + const groups = [...f.layer.querySelectorAll('[data-varri-text]')]; + const initial = getTextAnnotations(f.session.modelState); + const toggle = group => { + f.pointer(group, 'pointerdown', 20, 10, { ctrlKey: true }); + f.pointer(f.dom.window, 'pointerup', 20, 10, { ctrlKey: true }); + }; + toggle(groups[0]); + toggle(groups[1]); + toggle(groups[1]); + expect(f.layer.querySelectorAll('[data-varri-selected]')).toHaveLength(1); + expect(getTextAnnotations(f.session.modelState)).toEqual(initial); + toggle(groups[1]); + f.notify.mockClear(); + f.pointer(groups[1], 'pointerdown', 83, 22); + f.pointer(f.dom.window, 'pointermove', 103, 52); + f.pointer(f.dom.window, 'pointerup', 103, 52); + const placed = getTextAnnotations(f.session.modelState); + expect(placed.find(item => item.id === first.id).position).toEqual({ x: 30, y: 40 }); + expect(placed.find(item => item.id === second.id).position).toEqual({ x: 90, y: 50 }); + expect(f.notify).toHaveBeenCalledTimes(1); + expect(placed.every(item => !Object.hasOwn(item, 'selected'))).toBe(true); + f.dom.window.close(); +}); + +test('pointer cancellation ends an active text gesture and ignores later moves', () => { + const f = fixture(); + registerTextAnnotation(f.session.modelState, { text: 'Cancelled', position: { x: 10, y: 10 } }); + refreshTextAnnotations(f.session); + const group = f.layer.querySelector('[data-varri-text]'); + f.pointer(group, 'pointerdown', 22, 13); + f.pointer(f.dom.window, 'pointermove', 42, 33); + f.pointer(f.dom.window, 'pointercancel', 42, 33); + const saved = getTextAnnotations(f.session.modelState); + f.pointer(f.dom.window, 'pointermove', 80, 90); + f.pointer(f.dom.window, 'pointerup', 80, 90); + expect(getTextAnnotations(f.session.modelState)).toEqual(saved); + f.dom.window.close(); +}); From 9d6b04db6d65c386cdb5cd15cb0b12d2edee166e Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Mon, 5 Oct 2026 22:54:16 +0200 Subject: [PATCH 02/11] Add reversible manual positioning and selection rotation --- docs/viewer-guide.md | 4 +- scripts/browser-invariants.js | 3 + scripts/browser-position-editing.js | 136 ++++++++++++++++++++++++++ scripts/browser-position-ui.js | 47 +++++++++ scripts/browser-ui.js | 3 + src/README.md | 20 ++++ src/core/api.js | 5 + src/core/canvas/graph/index.js | 4 +- src/core/canvas/interactions.js | 109 +++++++++++++++------ src/core/canvas/manual-positions.js | 79 +++++++++++++++ src/core/canvas/render.js | 3 +- src/core/canvas/selection-rotation.js | 49 ++++++++++ src/core/canvas/text-annotations.js | 11 +++ src/core/index.js | 4 + src/ui/components/viewer-panel.js | 10 +- src/ui/render-actions.js | 9 +- src/ui/state.js | 1 + src/ui/styles/canvas.css | 20 +++- tests/manual-positions.test.js | 64 ++++++++++++ 19 files changed, 542 insertions(+), 39 deletions(-) create mode 100644 scripts/browser-position-editing.js create mode 100644 scripts/browser-position-ui.js create mode 100644 src/core/canvas/manual-positions.js create mode 100644 src/core/canvas/selection-rotation.js create mode 100644 tests/manual-positions.test.js diff --git a/docs/viewer-guide.md b/docs/viewer-guide.md index dbef89a..8bbcc00 100644 --- a/docs/viewer-guide.md +++ b/docs/viewer-guide.md @@ -217,8 +217,10 @@ both positioned and unpositioned labels, including their formatting. - **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. -- **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 clears the selection. Command also works on macOS. +- **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. **Reset** restores selected manual nodes to their positions and fixation before the first edit. Text labels are not counted or reset. **Undo** (or Ctrl+Z / Command+Z while the canvas is focused) reverses the last drag, selection rotation, or reset. History is kept for the current rendering. +- **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 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). - **Resize Canvas**: The visualisation canvas size can be adjusted by dragging the bottom-right corner of the canvas. This is useful when visualizing large interactions on large screens, or when preparing figures for publication. The canvas size is preserved when exporting the visualisation. diff --git a/scripts/browser-invariants.js b/scripts/browser-invariants.js index 7de0fe6..f1e7ccd 100644 --- a/scripts/browser-invariants.js +++ b/scripts/browser-invariants.js @@ -4,6 +4,7 @@ import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import { chromium } from 'playwright'; +import { exercisePositionEditing } from './browser-position-editing.js'; import { exerciseInteractions } from './browser-interactions.js'; import { exerciseCoreTextAnnotations } from './browser-text-core.js'; @@ -133,6 +134,8 @@ async function main() { assert.deepEqual(errors, [], mode + ': browser errors'); phase = mode + ' dragging, zooming and interaction disposal'; await exerciseInteractions(page); + phase = mode + ' manual position reset, undo, and selection rotation'; + await exercisePositionEditing(page); phase = mode + ' text annotations, coordinates, drift and disposal'; await exerciseCoreTextAnnotations(page); assert.deepEqual(errors, [], mode + ': interaction browser errors'); diff --git a/scripts/browser-position-editing.js b/scripts/browser-position-editing.js new file mode 100644 index 0000000..8d2c9a8 --- /dev/null +++ b/scripts/browser-position-editing.js @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; + +const point = locator => locator.evaluate(element => { + const { e: x, f: y } = element.getScreenCTM(); + return { x, y }; +}); +const near = (a, b, message) => assert.ok(Math.abs(a - b) < 0.6, `${message}: ${a} vs ${b}`); +async function press(page, locator, control = false) { + const { x, y } = await point(locator); + if (control) await page.keyboard.down('Control'); + await page.mouse.click(x, y); + if (control) await page.keyboard.up('Control'); +} +async function drag(page, locator, dx, dy) { + const { x, y } = await point(locator); + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + dx, y + dy, { steps: 5 }); + await page.mouse.up(); +} +const state = page => page.evaluate(() => window.exportApi.getCanvasInteractionState()); + +/** Review #97: reversible manual positions and pointer-centered selection rotation. */ +export async function exercisePositionEditing(page) { + for (const forceLayout of [false, true]) { + await page.evaluate(async forceLayout => { + const api = window.exportApi; + await api.render('second', api.validate({ sequence: 'AAAA&UUUU', structure: '((..&..))', + textAnnotations: [], highlighting: 'nothing', backgroundhighlighting: 'nothing' }), { forceLayout }); + const graph = document.querySelector('#second circle').__data__.rna; + graph.nodes.forEach(node => { node.fixed = 1; node.px = node.x; node.py = node.y; }); + api.rotateVisualization('second', 29, { mode: 'absolute' }); + const text = api.registerTextAnnotation({ text: 'Rotate me', size: 7 }); + const box = document.querySelector('#second svg').getBoundingClientRect(); + api.placeTextAnnotation(text.id, box.x + 450, box.y + 130); + }, forceLayout); + const host = page.locator('#second'); + const svg = host.locator('svg'); + const node = host.locator('g.gnode').filter({ has: page.locator('circle[node_type="nucleotide"]') }).first(); + const number = host.locator('g.gnode').filter({ has: page.locator('circle[node_type="label"]') }).first(); + const text = host.locator('[data-varri-text]'); + const original = await point(node); + const box = await svg.boundingBox(); + const selected = () => host.locator('[data-varri-selected]').count(); + await press(page, node, true); + const plot = await host.locator('.fornac-plot').getAttribute('transform'); + await page.keyboard.down('Control'); + await page.mouse.click(box.x + 15, box.y + 15); + await page.keyboard.up('Control'); + assert.equal(await selected(), 0, 'Ctrl-click on background clears without dragging'); + assert.equal(await host.locator('.fornac-plot').getAttribute('transform'), plot); + await press(page, node, true); + await press(page, number, true); + await press(page, node); + assert.equal(await selected(), 0, 'A plain click on a selected member clears selection'); + await press(page, node, true); + await press(page, number); + assert.equal(await selected(), 0, 'A plain click on an unselected element also clears'); + assert.equal((await state(page)).movedCount, 0, 'Clicks do not create fixations'); + + await drag(page, node, -35, 20); + assert.equal((await state(page)).movedCount, 1); + await drag(page, node, -8, 2); + assert.equal((await state(page)).movedCount, 1, 'Repeated drags count a node once'); + await page.keyboard.press('Control+z'); + const afterUndo = await point(node); + near(afterUndo.x - original.x, -35, 'Undo restores the prior drag x'); + near(afterUndo.y - original.y, 20, 'Undo restores the prior drag y'); + const saved = await point(node); + await drag(page, number, -15, -10); + await drag(page, text, 10, -10); + assert.equal((await state(page)).movedCount, 2, 'Text annotations are excluded from the moved count'); + await page.evaluate(() => window.exportApi.selectManuallyPositionedElements()); + assert.equal(await selected(), 2, 'Select moved selects nodes and numbering only'); + assert.equal(await text.getAttribute('data-varri-selected'), null); + await press(page, number, true); + await page.evaluate(() => window.exportApi.resetSelectedPositions()); + assert.equal((await state(page)).movedCount, 1, 'Reset changes only selected manual nodes'); + const reset = await point(node); + near(reset.x, original.x, 'Reset returns to the position before the first drag x'); + near(reset.y, original.y, 'Reset returns to the position before the first drag y'); + await page.evaluate(() => window.exportApi.undoCanvasEdit()); + const restored = await point(node); + near(restored.x, saved.x, 'Reset itself is undoable x'); + near(restored.y, saved.y, 'Reset itself is undoable y'); + assert.equal((await state(page)).movedCount, 2); + + await page.evaluate(() => window.exportApi.selectManuallyPositionedElements()); + await press(page, text, true); + const targets = [node, number, text]; + const before = await Promise.all(targets.map(point)); + const textsBefore = await page.evaluate(() => window.exportApi.getTextAnnotations()); + const pivot = { x: box.x + 330, y: box.y + 240 }; + const viewport = await svg.evaluate(element => ({ ...element.__zoom })); + await page.mouse.move(pivot.x, pivot.y); + await page.keyboard.down('Control'); + await page.mouse.wheel(0, 60); + await page.mouse.wheel(0, 60); + await page.keyboard.up('Control'); + await page.waitForTimeout(250); + const after = await Promise.all(targets.map(point)); + const angle = Math.atan2(after[0].y - pivot.y, after[0].x - pivot.x) - + Math.atan2(before[0].y - pivot.y, before[0].x - pivot.x); + assert.ok(Math.abs(angle) > 0.05, 'Ctrl-wheel rotates the selection'); + after.forEach((p, i) => { + const dx = before[i].x - pivot.x, dy = before[i].y - pivot.y; + near(p.x, pivot.x + dx * Math.cos(angle) - dy * Math.sin(angle), `Rigid rotation ${i} x`); + near(p.y, pivot.y + dx * Math.sin(angle) + dy * Math.cos(angle), `Rigid rotation ${i} y`); + }); + assert.deepEqual(await svg.evaluate(element => ({ ...element.__zoom })), viewport, 'Ctrl-wheel does not zoom or pan'); + const textAngle = await text.locator('text').evaluate(element => { + const m = element.getScreenCTM(); return Math.atan2(m.b, m.a); + }); + near(textAngle, 0, 'Rotated text stays readable'); + await page.keyboard.press('Control+z'); + const undone = await Promise.all(targets.map(point)); + undone.forEach((p, i) => { near(p.x, before[i].x, `Undo wheel burst ${i} x`); near(p.y, before[i].y, `Undo wheel burst ${i} y`); }); + assert.deepEqual(await page.evaluate(() => window.exportApi.getTextAnnotations()), textsBefore, 'Undo restores model text positions'); + + await press(page, node); + await press(page, node, true); + const single = await point(node); + await page.mouse.move(pivot.x, pivot.y); + await page.keyboard.down('Control'); + await page.mouse.wheel(0, 100); + await page.keyboard.up('Control'); + await page.waitForTimeout(220); + assert.deepEqual(await point(node), single, 'One selected element cannot rotate'); + assert.deepEqual(await svg.evaluate(element => ({ ...element.__zoom })), viewport, 'Ctrl-wheel is reserved even with one selection'); + + await page.evaluate(() => window.exportApi.cancelActiveRender()); + assert.deepEqual(await state(page), { movedCount: 0, selectedNodeCount: 0, selectedMovedCount: 0, canUndo: false }, + 'Disposal clears counts and history'); + assert.equal(await page.evaluate(() => window.exportApi.undoCanvasEdit()), false); + } +} diff --git a/scripts/browser-position-ui.js b/scripts/browser-position-ui.js new file mode 100644 index 0000000..8bf91b3 --- /dev/null +++ b/scripts/browser-position-ui.js @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +export async function exercisePositionControlsUI(page, origin, output) { + const params = new URLSearchParams({ sequence: 'AAAA&UUUU', structure: '((..&..))', + highlighting: 'nothing', backgroundhighlighting: 'nothing', forceLayout: '0', textAnnotations: '[]' }); + 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 moved = page.locator('#selectMovedBtn'), reset = page.locator('#resetPositionsBtn'), undo = page.locator('#undoCanvasBtn'); + assert.equal(await moved.textContent(), 'Moved: 0'); + assert.equal(await moved.isDisabled(), true); + assert.equal(await reset.isDisabled(), true); + assert.equal(await undo.isDisabled(), true); + const node = page.locator('#rendering-canvas circle[node_type="nucleotide"]').first(); + const initial = await node.evaluate(element => ({ x: element.__data__.x, y: element.__data__.y })); + const box = await node.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + 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(); + 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 moved.click(); + assert.equal(await page.locator('#rendering-canvas [data-varri-selected]').count(), 1); + assert.equal(await reset.isEnabled(), true); + 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'); + await page.screenshot({ path: path.join(output, 'manual-position-controls.png'), fullPage: true }); + 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'); + await page.locator('#rendering-canvas').focus(); + await page.keyboard.press('Control+z'); + assert.equal(await moved.textContent(), 'Moved: 0', 'Canvas shortcut undoes the original drag'); + assert.equal(await undo.isDisabled(), true); + // An exhausted canvas history leaves positions unchanged. + await moved.evaluate(() => document.getElementById('rendering-canvas').focus()); + await page.keyboard.press('Control+z'); + assert.deepEqual(await node.evaluate(element => ({ x: element.__data__.x, y: element.__data__.y })), initial); +} diff --git a/scripts/browser-ui.js b/scripts/browser-ui.js index 86f11a9..81d9758 100644 --- a/scripts/browser-ui.js +++ b/scripts/browser-ui.js @@ -6,6 +6,7 @@ import http from 'node:http'; import path from 'node:path'; import { chromium, firefox } from 'playwright'; import { checkPNGDialog, checkPNGResize } from './browser-png-export.js'; +import { exercisePositionControlsUI } from './browser-position-ui.js'; import { exerciseTextAnnotationUI } from './browser-text-ui.js'; import { exerciseSequenceNames } from './browser-sequence-names.js'; import { checkInputHighlights } from './browser-input-highlights.js'; @@ -235,6 +236,8 @@ async function main() { assert.equal(fullPage.searchParams.has('showRenderingOnly'), false); assert.equal(fullPage.searchParams.get('sequence'), 'ACGU&UGCA'); await page.screenshot({ path: path.join(output, 'render-only.png') }); + phase = 'manual position controls, reset and undo'; + await exercisePositionControlsUI(page, origin, output); phase = 'text annotation defaults, editing, dragging and sharing'; await exerciseTextAnnotationUI(page, origin, output); phase = 'sequence names, protected labels, text dialogs and FASTA'; diff --git a/src/README.md b/src/README.md index 3b68397..1928c8a 100644 --- a/src/README.md +++ b/src/README.md @@ -299,6 +299,26 @@ should call `render()` and let it coordinate them. | `updateNodeToolTips(validated)` | Add biological positions to node tooltips. | | `visualiseAccessibility(data, sequence1Length, colors?, colorMode?)` | Draw probability overlays. | +## Manual canvas positions + +`getCanvasInteractionState()` returns a plain summary with `movedCount`, +`selectedNodeCount`, `selectedMovedCount`, and `canUndo`. The optional render +callback `onCanvasInteractionChange(summary)` receives changes to that summary. +Graph objects remain inside the renderer; text annotations are excluded from +manual-node counts. + +- `selectManuallyPositionedElements()` selects moved nucleotides and numbering labels. +- `resetSelectedPositions()` restores selected manual nodes to their positions and + fixation before the first manual edit. Other elements are retained. +- `undoCanvasEdit()` undoes the last drag, wheel-rotation burst, or position reset. + It restores text positions and anchors when those were edited, and returns + whether an edit was undone. History holds at most 100 edits for the current render. + +Ctrl-click toggles selection; plain element clicks and Ctrl-clicks on the background +clear it. Drag a selected member to move its group. Ctrl-wheel rotates two or more +selected elements around the pointer without changing zoom. Ctrl+Z (or Command+Z) +undoes while the canvas has focus. A new render or cancellation clears history. + ## Export - `vaRRI.buildSVGString(containerId)` returns a self-contained SVG string with diff --git a/src/core/api.js b/src/core/api.js index e3e7f0b..9f0d7b9 100644 --- a/src/core/api.js +++ b/src/core/api.js @@ -37,6 +37,11 @@ export function createVaRRI(options = {}) { normaliseRotationDegrees: normaliseRotationDegrees, render: render.bind(null, session), rotateVisualization: rotateVisualization.bind(null, session), + getCanvasInteractionState: () => session.runtime.activeContainer?.interactions?.status() || + { movedCount: 0, selectedNodeCount: 0, selectedMovedCount: 0, canUndo: false }, + selectManuallyPositionedElements: () => session.runtime.activeContainer?.interactions?.selectMoved(), + resetSelectedPositions: () => session.runtime.activeContainer?.interactions?.resetSelected() || false, + undoCanvasEdit: () => session.runtime.activeContainer?.interactions?.undo() || false, validate: args => validate(args, modelState.colors), getColors: getColors.bind(null, modelState), setColors: setColors.bind(null, modelState), diff --git a/src/core/canvas/graph/index.js b/src/core/canvas/graph/index.js index 5ae02ef..84df051 100644 --- a/src/core/canvas/graph/index.js +++ b/src/core/canvas/graph/index.js @@ -30,8 +30,8 @@ export function createGraphCanvas(target, options = {}) { let destroyed = false; const svg = d3.select(layers.svg); const plot = d3.select(layers.plot); - const zoom = d3.zoom().filter(event => !event.button && (event.type === 'wheel' || - (!event.ctrlKey && !event.metaKey && !event.target.closest?.(INTERACTION_TARGET)))) + const zoom = d3.zoom().filter(event => !event.button && ((event.type === 'wheel' && !event.ctrlKey && !event.metaKey) || + (event.type !== 'wheel' && !event.ctrlKey && !event.metaKey && !event.target.closest?.(INTERACTION_TARGET)))) .on('start.graph', event => { const source = event.sourceEvent; if (source?.type !== 'mousedown' || !source.view) return; diff --git a/src/core/canvas/interactions.js b/src/core/canvas/interactions.js index 706cf20..b62da88 100644 --- a/src/core/canvas/interactions.js +++ b/src/core/canvas/interactions.js @@ -1,3 +1,5 @@ +import { createManualPositions } from './manual-positions.js'; +import { attachSelectionRotation } from './selection-rotation.js'; import { clientToGraphPosition } from './text-annotation-geometry.js'; import { createSelectionOverlay, enclosed, interactionTargets, INTERACTION_TARGET, selectionRectangle } from './interaction-selection.js'; @@ -10,8 +12,33 @@ export function createCanvasInteractions(container, syncGraph = () => {}) { const overlay = createSelectionOverlay(svg); let active = null; let hadSelection = false; + let lastStatus = ''; + const positions = createManualPositions(container, syncGraph); + const selectedTargets = () => selected.size + ? interactionTargets(container).filter(target => selected.has(target.element)) : []; + const status = () => { + const nodes = selectedTargets().filter(target => target.node); + return { movedCount: positions.count, selectedNodeCount: nodes.length, + selectedMovedCount: nodes.filter(target => positions.has(target.node)).length, canUndo: positions.canUndo }; + }; + function notify() { + const value = status(); + const key = JSON.stringify(value); + if (key === lastStatus) return; + lastStatus = key; + container.options?.onCanvasInteractionChange?.(value); + } + const focus = () => { + const host = container.element || svg; + if (!host.hasAttribute('tabindex')) host.setAttribute('tabindex', '0'); + host.focus({ preventScroll: true }); + }; + const rotation = attachSelectionRotation(container, { + targets: selectedTargets, positions, refresh, busy: () => !!active, focus, + }); function refresh() { + notify(); if (!selected.size && !hadSelection && !active?.rectangle) return; const targets = interactionTargets(container); const live = new Set(targets.map(target => target.element)); @@ -28,8 +55,9 @@ export function createCanvasInteractions(container, syncGraph = () => {}) { function stop() { if (!active) return; - const { pointerId } = active; + const { pointerId, records } = active; active = null; + positions.commit(records); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onEnd); window.removeEventListener('pointercancel', onCancel); @@ -44,8 +72,8 @@ export function createCanvasInteractions(container, syncGraph = () => {}) { : [active.target]; active.targets = targets.map(target => ({ ...target, origin: clientToGraphPosition(target.layer, active.start.x, active.start.y) })); + active.records = positions.capture(targets); active.moved = true; - container.hasManualPositions = targets.some(target => target.node) || container.hasManualPositions; } function onMove(event) { @@ -62,26 +90,14 @@ export function createCanvasInteractions(container, syncGraph = () => {}) { } else { if (!active.target.element.isConnected) { stop(); return; } if (!active.moved) beginMove(); - const texts = []; - let movedNodes = false; - for (const target of active.targets) { - if (!target.element.isConnected) continue; + const updates = active.targets.map(target => { const current = clientToGraphPosition(target.layer, point.x, point.y); - const x = target.position.x + current.x - target.origin.x; - const y = target.position.y + current.y - target.origin.y; - if (target.node) { - // Keep the released position for this rendering, including force and - // rail constraints. No graph positions enter the serializable model. - Object.assign(target.node, { x, y, px: x, py: y, fx: x, fy: y, vx: 0, vy: 0, fixed: 1 }); - movedNodes = true; - } else texts.push({ id: target.id, position: { x, y } }); - } - if (movedNodes) syncGraph(); - // Apply the entire text batch after moving graph nodes: its model frame - // uses the new nucleotide centroid, avoiding double movement in a group. - container.varriTextAnnotations?.move(texts); - if (texts.length) active.changedText = true; - if (movedNodes && container.options?.animation) container.force.resume(); + return { target, position: { + x: target.position.x + current.x - target.origin.x, + y: target.position.y + current.y - target.origin.y, + } }; + }); + positions.apply(updates); } refresh(); } @@ -93,15 +109,13 @@ export function createCanvasInteractions(container, syncGraph = () => {}) { if (selected.has(element)) selected.delete(element); else selected.add(element); } - const changedText = active.changedText; + if (!active.moved && (!active.target || !active.toggle)) selected.clear(); stop(); - if (changedText) container.varriTextAnnotations?.notify(); } - function onCancel() { - const changedText = active?.changedText; + function onCancel(event) { + if (event?.pointerId !== undefined && event.pointerId !== active?.pointerId) return; stop(); - if (changedText) container.varriTextAnnotations?.notify(); } function onStart(event) { @@ -110,10 +124,14 @@ export function createCanvasInteractions(container, syncGraph = () => {}) { const target = interactionTargets(container).find(item => item.element === element); const toggle = event.ctrlKey || event.metaKey; if (!target && !toggle) return; // Ordinary background gestures belong to zoom/pan. + rotation.finish(); + focus(); + if (!target || (!toggle && !selected.has(target.element))) selected.clear(); event.preventDefault(); event.stopPropagation(); active = { target, toggle, pointerId: event.pointerId, - start: { x: event.clientX, y: event.clientY }, moved: false, changedText: false }; + start: { x: event.clientX, y: event.clientY }, moved: false }; + refresh(); svg.setPointerCapture?.(event.pointerId); window.addEventListener('pointermove', onMove, { passive: false }); window.addEventListener('pointerup', onEnd); @@ -126,15 +144,46 @@ export function createCanvasInteractions(container, syncGraph = () => {}) { event.stopImmediatePropagation(); } }; + function undo() { + if (container.destroyed) return false; + stop(); rotation.finish(); + const changed = positions.undo(); + refresh(); + return changed; + } + const keydown = event => { + if (event.key.toLowerCase() !== 'z' || (!event.ctrlKey && !event.metaKey) || event.shiftKey || event.altKey || + event.target.closest?.('input, textarea, select, [contenteditable="true"]')) return; + if (undo()) { event.preventDefault(); event.stopPropagation(); } + }; + const host = container.element || svg; + host.addEventListener('keydown', keydown); svg.addEventListener('pointerdown', onStart); svg.addEventListener('lostpointercapture', onCancel); for (const type of ['mousedown', 'touchstart', 'dblclick']) svg.addEventListener(type, stopPan, true); return { - refresh, - cancel: stop, - clear() { stop(); selected.clear(); refresh(); }, + refresh, status, undo, + cancel() { stop(); rotation.finish(); }, + selectMoved() { + if (container.destroyed) return; + stop(); rotation.finish(); selected.clear(); + interactionTargets(container).filter(target => positions.has(target.node)) + .forEach(target => selected.add(target.element)); + refresh(); + }, + resetSelected() { + if (container.destroyed) return false; + stop(); rotation.finish(); + const changed = positions.reset(selectedTargets()); + refresh(); + return changed; + }, + clear() { stop(); rotation.finish(); selected.clear(); positions.clear(); refresh(); }, dispose() { stop(); + rotation.dispose(); + positions.clear(); + host.removeEventListener('keydown', keydown); selected.clear(); refresh(); overlay.dispose(); diff --git a/src/core/canvas/manual-positions.js b/src/core/canvas/manual-positions.js new file mode 100644 index 0000000..92b3adb --- /dev/null +++ b/src/core/canvas/manual-positions.js @@ -0,0 +1,79 @@ +const NODE_FIELDS = ['x', 'y', 'px', 'py', 'fx', 'fy', 'vx', 'vy', 'fixed']; +const snapshotNode = node => Object.fromEntries(NODE_FIELDS.map(key => [key, node[key]])); + +/** Render-local positions and bounded undo history; no graph objects enter Vue. */ +export function createManualPositions(container, syncGraph) { + const originals = new Map(); + const history = []; + const updateCount = () => { container.hasManualPositions = originals.size > 0; }; + const resume = changed => { + if (changed && container.options?.animation && !container.destroyed) container.force.resume(); + }; + const capture = targets => targets.map(target => target.node + ? { node: target.node, position: snapshotNode(target.node), original: originals.get(target.node) } + : { id: target.id, text: container.varriTextAnnotations?.capture(target.id) }); + function commit(records) { + if (!records?.length) return; + history.push(records); + if (history.length > 100) history.shift(); + if (records.some(record => record.text)) container.varriTextAnnotations?.notify(); + } + function apply(updates) { + const texts = []; + let movedNodes = false; + for (const { target, position: { x, y } } of updates) { + if (!target.element.isConnected) continue; + if (target.node) { + if (!originals.has(target.node)) originals.set(target.node, snapshotNode(target.node)); + Object.assign(target.node, { x, y, px: x, py: y, fx: x, fy: y, vx: 0, vy: 0, fixed: 1 }); + movedNodes = true; + } else texts.push({ id: target.id, position: { x, y } }); + } + updateCount(); + if (movedNodes) syncGraph(); + // Text offsets use the new nucleotide centroid after all graph nodes move. + container.varriTextAnnotations?.move(texts); + resume(movedNodes); + } + function reset(targets) { + const resettable = targets.filter(target => originals.has(target.node)); + if (!resettable.length) return false; + const records = capture(resettable); + for (const { node } of resettable) { + Object.assign(node, originals.get(node)); + originals.delete(node); + } + updateCount(); + syncGraph(); + container.varriTextAnnotations?.move([]); + commit(records); + resume(true); + return true; + } + function undo() { + const records = history.pop(); + if (!records) return false; + let changedNodes = false; + const texts = []; + for (const record of records) { + if (record.node && container.graph.nodes.includes(record.node)) { + Object.assign(record.node, record.position); + if (record.original) originals.set(record.node, record.original); + else originals.delete(record.node); + changedNodes = true; + } else if (record.text) texts.push({ id: record.id, ...record.text }); + } + updateCount(); + if (changedNodes) syncGraph(); + container.varriTextAnnotations?.restore(texts); + resume(changedNodes); + return true; + } + return { + capture, commit, apply, reset, undo, + has: node => originals.has(node), + get count() { return originals.size; }, + get canUndo() { return history.length > 0; }, + clear() { originals.clear(); history.length = 0; updateCount(); }, + }; +} diff --git a/src/core/canvas/render.js b/src/core/canvas/render.js index 1e43d17..ea0c1e0 100644 --- a/src/core/canvas/render.js +++ b/src/core/canvas/render.js @@ -80,7 +80,8 @@ export function render(session, containerId, v, options = {}) { // Create the independent SVG/force renderer. const container = (session.createCanvas || createGraphCanvas)(session.root, { animation: forceLayout, - labelInterval: 1 + labelInterval: 1, + onCanvasInteractionChange: options.onCanvasInteractionChange }); session.runtime.activeContainer = container; container.addRNA(v.structure, { diff --git a/src/core/canvas/selection-rotation.js b/src/core/canvas/selection-rotation.js new file mode 100644 index 0000000..52b57f4 --- /dev/null +++ b/src/core/canvas/selection-rotation.js @@ -0,0 +1,49 @@ +import { clientToGraphPosition } from './text-annotation-geometry.js'; + +/** Rotate positions in screen space so the pointer is the pivot at every zoom. */ +export function rotateTargets(targets, pivot, radians) { + const cosine = Math.cos(radians), sine = Math.sin(radians); + return targets.map(target => { + const matrix = target.layer.getScreenCTM(); + const { x, y } = target.position; + const dx = matrix.a * x + matrix.c * y + matrix.e - pivot.x; + const dy = matrix.b * x + matrix.d * y + matrix.f - pivot.y; + return { target, position: clientToGraphPosition(target.layer, + pivot.x + dx * cosine - dy * sine, pivot.y + dx * sine + dy * cosine) }; + }); +} + +/** A wheel burst is one undoable edit, and cannot leak into D3/browser zoom. */ +export function attachSelectionRotation(container, { targets, positions, refresh, busy, focus }) { + const { svg } = container; + const window = svg.ownerDocument.defaultView; + let records = null, timer = null; + function finish() { + if (timer !== null) window.clearTimeout(timer); + timer = null; + if (!records) return; + positions.commit(records); + records = null; + refresh(); + } + function wheel(event) { + if ((!event.ctrlKey && !event.metaKey) || container.destroyed) return; + event.preventDefault(); + event.stopImmediatePropagation(); + const selected = targets(); + if (busy() || selected.length < 2 || !event.deltaY) return; + focus(); + if (!records) records = positions.capture(selected); + const pixels = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? svg.clientHeight : 1); + const radians = Math.max(-45, Math.min(45, pixels * 0.15)) * Math.PI / 180; + positions.apply(rotateTargets(selected, { x: event.clientX, y: event.clientY }, radians)); + refresh(); + if (timer !== null) window.clearTimeout(timer); + timer = window.setTimeout(finish, 200); + } + svg.addEventListener('wheel', wheel, { capture: true, passive: false }); + return { + finish, + dispose() { finish(); svg.removeEventListener('wheel', wheel, true); }, + }; +} diff --git a/src/core/canvas/text-annotations.js b/src/core/canvas/text-annotations.js index c458074..a3346f2 100644 --- a/src/core/canvas/text-annotations.js +++ b/src/core/canvas/text-annotations.js @@ -156,6 +156,17 @@ export function initializeTextAnnotations(session, container, validated, options const state = { layer, container, validated, entries: new Map(), notify: () => options.onTextAnnotationsChange?.(getTextAnnotations(session.modelState)) }; container.varriTextAnnotations = state; + state.capture = id => { + const item = getTextAnnotations(session.modelState).find(item => item.id === id); + return item && { position: item.position, anchor: item.anchor }; + }; + state.restore = records => { + for (const { id, position, anchor } of records) { + if (state.entries.has(id)) updateTextAnnotation(session.modelState, id, { position, anchor }); + } + refreshTextAnnotations(session); + if (records.length) state.notify(); + }; state.move = positions => { const center = nucleotideCentroid(container.graph); for (const { id, position } of positions) { diff --git a/src/core/index.js b/src/core/index.js index 833ccff..9d6baae 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -9,6 +9,10 @@ export const { normaliseRotationDegrees, render, rotateVisualization, + getCanvasInteractionState, + selectManuallyPositionedElements, + resetSelectedPositions, + undoCanvasEdit, validate, getColors, setColors, diff --git a/src/ui/components/viewer-panel.js b/src/ui/components/viewer-panel.js index 48389d3..8ecb79d 100644 --- a/src/ui/components/viewer-panel.js +++ b/src/ui/components/viewer-panel.js @@ -8,10 +8,18 @@ export default viewerComponent('viewer-panel', `
-
+
{{ state.errors.cropping || '' }}
+
+ + + +
diff --git a/src/ui/render-actions.js b/src/ui/render-actions.js index c8409fd..cdbcd14 100644 --- a/src/ui/render-actions.js +++ b/src/ui/render-actions.js @@ -49,6 +49,7 @@ export function createRenderActions({ api, state, actions, document }) { const f = state.fields; try { const result = await api.render('rendering-canvas', validated, { + onCanvasInteractionChange: summary => { if (run === latestRun) state.canvasInteraction = summary; }, onTextAnnotationsChange: () => actions.syncAnnotations(), forceLayout: f.forceLayout, forceLayoutLinearRRI: f.forceLayout && f.forceLayoutLinearRRI, @@ -73,7 +74,13 @@ export function createRenderActions({ api, state, actions, document }) { } return { runVisualization, applyColors, syncGeneratedRegionHighlight, applySliderRotation, commitSliderRotation, - cancelRendering() { latestRun++; api.cancelActiveRender(); }, + selectMovedElements() { api.selectManuallyPositionedElements(); }, + resetSelectedPositions() { api.resetSelectedPositions(); }, + undoCanvasEdit() { api.undoCanvasEdit(); }, + cancelRendering() { + latestRun++; api.cancelActiveRender(); + state.canvasInteraction = api.getCanvasInteractionState(); + }, enableForceLayoutForSelectedLinearOptions() { if (state.fields.forceLayoutLinearRRI || state.fields.forceLayoutLinearStructure) state.fields.forceLayout = true; }, diff --git a/src/ui/state.js b/src/ui/state.js index 3ba7160..3e5b6c1 100644 --- a/src/ui/state.js +++ b/src/ui/state.js @@ -34,6 +34,7 @@ export function createViewerState(api, colors) { renderingOnly: false, showFullPage: false, shareCopied: false, pngExport: { width: '', height: '', dpi: '96', error: '', busy: false }, inputCaret: { field: '', index: -1 }, + canvasInteraction: { movedCount: 0, selectedNodeCount: 0, selectedMovedCount: 0, canUndo: false }, dialog: { id: '', title: '', value: '', left: '', top: '' }, }); return { state, defaults, initialColors: palette }; diff --git a/src/ui/styles/canvas.css b/src/ui/styles/canvas.css index 84be84a..36fc52c 100644 --- a/src/ui/styles/canvas.css +++ b/src/ui/styles/canvas.css @@ -179,11 +179,12 @@ background: var(--surface); border-radius: 8px; box-shadow: 0 2px 8px rgba(15, 46, 74, .12); - padding: .6rem 1.2rem; + padding: .6rem .8rem; margin-top: .75rem; display: flex; align-items: center; - gap: .75rem; + flex-wrap: wrap; + gap: .5rem; } .viz-control label { margin: 0; @@ -195,7 +196,7 @@ .viz-control input[type="range"] { flex: 1; margin: 0; - min-width: 50; + min-width: 50px; } @@ -234,3 +235,16 @@ width: 2.5em; } + +.viz-control > #rotationSlider { flex: 1 1 60px; width: 0; } +.viz-control > .cropping-control { flex: 0.6 1 50px; width: auto; min-width: 50px; } +.cropping-control > input { width: 100%; display: block; } +.manual-position-controls { display: flex; gap: .25rem; align-items: center; margin-left: auto; } +.manual-position-controls button { + font: inherit; font-size: .75rem; line-height: 18px; height: 20px; + padding: 0 .4rem; white-space: nowrap; + color: var(--brand-blue-dark); background: #f5f8fb; + border: 1px solid #cbd7e2; border-radius: 4px; cursor: pointer; +} +.manual-position-controls button:hover:enabled { background: #e8f0f8; } +.manual-position-controls button:disabled { color: #8b929a; background: #f3f4f5; cursor: default; } diff --git a/tests/manual-positions.test.js b/tests/manual-positions.test.js new file mode 100644 index 0000000..8a86218 --- /dev/null +++ b/tests/manual-positions.test.js @@ -0,0 +1,64 @@ +import { jest } from '@jest/globals'; +import { createManualPositions } from '../src/core/canvas/manual-positions.js'; + +function fixture(animation = false) { + const node = { x: 2, y: 3, px: 2, py: 3, fixed: 0, vx: 0, vy: 0 }; + const other = { ...node, x: 20, px: 20, fixed: 1, fx: 20, fy: 3 }; + const force = { resume: jest.fn() }; + const container = { options: { animation }, graph: { nodes: [node, other] }, force }; + const sync = jest.fn(); + const positions = createManualPositions(container, sync); + const target = node => ({ node, element: { isConnected: true } }); + const edit = (node, x, y) => { + const records = positions.capture([target(node)]); + positions.apply([{ target: target(node), position: { x, y } }]); + positions.commit(records); + }; + return { node, other, container, force, positions, target, edit }; +} + +test.each([false, true])('reset releases originally free nodes and preserves prior fixation, force=%s', animation => { + const f = fixture(animation); + f.edit(f.node, 10, 12); + f.edit(f.node, 15, 18); + f.edit(f.other, 25, 30); + expect(f.positions.count).toBe(2); + expect(f.positions.reset([f.target(f.node)])).toBe(true); + expect(f.node).toMatchObject({ x: 2, y: 3, fixed: 0 }); + expect(f.node.fx).toBeUndefined(); + expect(f.node.fy).toBeUndefined(); + expect(f.other).toMatchObject({ x: 25, y: 30, fixed: 1 }); + expect(f.positions.count).toBe(1); + f.positions.reset([f.target(f.other)]); + expect(f.other).toMatchObject({ x: 20, y: 3, fixed: 1, fx: 20, fy: 3 }); + expect(f.positions.count).toBe(0); + expect(f.force.resume).toHaveBeenCalledTimes(animation ? 5 : 0); +}); + +test('undo restores successive gestures and undoing reset retains the first pre-edit position', () => { + const f = fixture(); + f.edit(f.node, 10, 12); + f.edit(f.node, 15, 18); + f.positions.reset([f.target(f.node)]); + f.positions.undo(); + expect(f.node).toMatchObject({ x: 15, y: 18, fixed: 1 }); + expect(f.positions.count).toBe(1); + f.positions.undo(); + expect(f.node).toMatchObject({ x: 10, y: 12, fixed: 1 }); + f.positions.reset([f.target(f.node)]); + expect(f.node).toMatchObject({ x: 2, y: 3, fixed: 0 }); +}); + +test('undoing the first drag removes its fixation, and a new render discards history', () => { + const f = fixture(); + f.edit(f.node, 10, 12); + expect(f.positions.undo()).toBe(true); + expect(f.node).toMatchObject({ x: 2, y: 3, fixed: 0 }); + expect(f.positions.count).toBe(0); + expect(f.positions.canUndo).toBe(false); + f.edit(f.node, 30, 40); + f.positions.clear(); + expect(f.positions.undo()).toBe(false); + expect(f.positions.count).toBe(0); + expect(f.container.hasManualPositions).toBe(false); +}); From 60d0160f11b2622676df020d0cadbcf674584a8c Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Wed, 7 Oct 2026 11:45:11 +0200 Subject: [PATCH 03/11] Address canvas interaction review with visible controls and release --- docs/viewer-guide.md | 2 +- scripts/browser-node-selection.js | 73 ++++++++++++++++++++++++ scripts/browser-position-editing.js | 58 +++++++++++++------ scripts/browser-position-layout.js | 51 +++++++++++++++++ scripts/browser-position-ui.js | 25 +++++++- scripts/browser-selection.js | 8 +-- scripts/browser-ui.js | 7 +++ src/README.md | 9 ++- src/core/api.js | 1 + src/core/canvas/interaction-selection.js | 10 +++- src/core/canvas/interactions.js | 16 +++++- src/core/canvas/manual-positions.js | 16 +++++- src/core/index.js | 1 + src/ui/components/app.js | 5 +- src/ui/components/canvas-help-dialog.js | 30 ++++++++++ src/ui/components/viewer-panel.js | 6 +- src/ui/render-actions.js | 1 + src/ui/styles/canvas.css | 17 +++++- src/ui/styles/dialogs.css | 15 +++++ src/ui/styles/responsive.css | 2 + tests/manual-positions.test.js | 41 +++++++++++++ 21 files changed, 357 insertions(+), 37 deletions(-) create mode 100644 scripts/browser-node-selection.js create mode 100644 scripts/browser-position-layout.js create mode 100644 src/ui/components/canvas-help-dialog.js diff --git a/docs/viewer-guide.md b/docs/viewer-guide.md index 8bbcc00..4834cca 100644 --- a/docs/viewer-guide.md +++ b/docs/viewer-guide.md @@ -219,7 +219,7 @@ both positioned and unpositioned labels, including their formatting. - **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. - **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. **Reset** restores selected manual nodes to their positions and fixation before the first edit. Text labels are not counted or reset. **Undo** (or Ctrl+Z / Command+Z while the canvas is focused) reverses the last drag, selection rotation, or reset. History is kept for the current rendering. +- **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. - **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 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). diff --git a/scripts/browser-node-selection.js b/scripts/browser-node-selection.js new file mode 100644 index 0000000..954ecce --- /dev/null +++ b/scripts/browser-node-selection.js @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; + +const point = locator => locator.evaluate(element => { + const m = element.getScreenCTM(); return { x: m.e, y: m.f }; +}); +const near = (a, b, message) => assert.ok(Math.abs(a - b) < 0.6, `${message}: ${a} vs ${b}`); + +/** Check nucleotide-only bounds and both platform modifiers in each UI browser. */ +export async function exerciseNodeSelection(page) { + const host = page.locator('#rendering-canvas'), svg = host.locator('svg'); + const circle = host.locator('circle[node_type="nucleotide"]').nth(1); + const node = circle.locator('..'); + const other = host.locator('circle[node_type="nucleotide"]').nth(2).locator('..'); + const initial = await point(node); + await page.mouse.click(initial.x, initial.y); + const box = await circle.evaluate(element => element.getBoundingClientRect().toJSON()); + const group = await node.evaluate(element => element.getBoundingClientRect().toJSON()); + assert.ok(group.width > box.width + 1 || group.height > box.height + 1, 'Fixture has an attached direction arrow'); + for (const modifier of ['Control', 'Meta']) { + await page.keyboard.down(modifier); + await page.mouse.click(initial.x, initial.y); + await page.keyboard.up(modifier); + assert.equal(await host.locator('[data-varri-selected]').count(), 1, `${modifier}-click selects`); + const outline = await host.locator('[data-varri-interaction-overlay] rect').evaluate(element => { + // Firefox includes stroke in getBoundingClientRect; compare the outline path itself. + const box = element.getBBox(), matrix = element.getScreenCTM(); + const start = new DOMPoint(box.x, box.y).matrixTransform(matrix); + const end = new DOMPoint(box.x + box.width, box.y + box.height).matrixTransform(matrix); + return { x: start.x, y: start.y, width: end.x - start.x, height: end.y - start.y }; + }); + near(outline.x, box.x - 2, 'Selection outlines the nucleotide left edge'); + near(outline.y, box.y - 2, 'Selection outlines the nucleotide top edge'); + near(outline.width, box.width + 4, 'Selection excludes the direction arrow width'); + near(outline.height, box.height + 4, 'Selection excludes the direction arrow height'); + await page.keyboard.down(modifier); + await page.mouse.click(initial.x, initial.y); + await page.keyboard.up(modifier); + assert.equal(await host.locator('[data-varri-selected]').count(), 0, `${modifier}-click toggles off`); + + // A tight rectangle encloses the circle but deliberately excludes its arrow. + await page.keyboard.down(modifier); + await page.mouse.move(box.x - 1, box.y - 1); + await page.mouse.down(); + await page.mouse.move(box.x + box.width + 1, box.y + box.height + 1, { steps: 5 }); + await page.mouse.up(); + await page.keyboard.up(modifier); + assert.equal(await node.getAttribute('data-varri-selected'), 'true', `${modifier}-rectangle uses nucleotide bounds`); + + const second = await point(other); + await page.keyboard.down(modifier); + await page.mouse.click(second.x, second.y); + await page.keyboard.up(modifier); + assert.equal(await host.locator('[data-varri-selected]').count(), 2); + const zoom = await svg.evaluate(element => ({ ...element.__zoom })); + const bounds = await svg.boundingBox(); + await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + await page.keyboard.down(modifier); + await page.mouse.wheel(0, 90); + await page.keyboard.up(modifier); + await page.waitForTimeout(250); + const rotated = await point(node); + assert.ok(Math.hypot(rotated.x - initial.x, rotated.y - initial.y) > 1, `${modifier}-wheel rotates`); + assert.deepEqual(await svg.evaluate(element => ({ ...element.__zoom })), zoom, `${modifier}-wheel does not zoom`); + await page.keyboard.press(`${modifier}+z`); + const restored = await point(node); + near(restored.x, initial.x, `${modifier}+Z restores x`); + near(restored.y, initial.y, `${modifier}+Z restores y`); + await page.keyboard.down(modifier); + await page.mouse.click(bounds.x + 5, bounds.y + 5); + await page.keyboard.up(modifier); + assert.equal(await host.locator('[data-varri-selected]').count(), 0, `${modifier}-background click clears`); + } +} diff --git a/scripts/browser-position-editing.js b/scripts/browser-position-editing.js index 8d2c9a8..25d4e6d 100644 --- a/scripts/browser-position-editing.js +++ b/scripts/browser-position-editing.js @@ -5,11 +5,11 @@ const point = locator => locator.evaluate(element => { return { x, y }; }); const near = (a, b, message) => assert.ok(Math.abs(a - b) < 0.6, `${message}: ${a} vs ${b}`); -async function press(page, locator, control = false) { +async function press(page, locator, modifier = null) { const { x, y } = await point(locator); - if (control) await page.keyboard.down('Control'); + if (modifier) await page.keyboard.down(modifier); await page.mouse.click(x, y); - if (control) await page.keyboard.up('Control'); + if (modifier) await page.keyboard.up(modifier); } async function drag(page, locator, dx, dy) { const { x, y } = await point(locator); @@ -22,6 +22,7 @@ const state = page => page.evaluate(() => window.exportApi.getCanvasInteractionS /** Review #97: reversible manual positions and pointer-centered selection rotation. */ export async function exercisePositionEditing(page) { + for (const modifier of ['Control', 'Meta']) for (const forceLayout of [false, true]) { await page.evaluate(async forceLayout => { const api = window.exportApi; @@ -42,18 +43,18 @@ export async function exercisePositionEditing(page) { const original = await point(node); const box = await svg.boundingBox(); const selected = () => host.locator('[data-varri-selected]').count(); - await press(page, node, true); + await press(page, node, modifier); const plot = await host.locator('.fornac-plot').getAttribute('transform'); - await page.keyboard.down('Control'); + await page.keyboard.down(modifier); await page.mouse.click(box.x + 15, box.y + 15); - await page.keyboard.up('Control'); + await page.keyboard.up(modifier); assert.equal(await selected(), 0, 'Ctrl-click on background clears without dragging'); assert.equal(await host.locator('.fornac-plot').getAttribute('transform'), plot); - await press(page, node, true); - await press(page, number, true); + await press(page, node, modifier); + await press(page, number, modifier); await press(page, node); assert.equal(await selected(), 0, 'A plain click on a selected member clears selection'); - await press(page, node, true); + await press(page, node, modifier); await press(page, number); assert.equal(await selected(), 0, 'A plain click on an unselected element also clears'); assert.equal((await state(page)).movedCount, 0, 'Clicks do not create fixations'); @@ -62,7 +63,7 @@ export async function exercisePositionEditing(page) { assert.equal((await state(page)).movedCount, 1); await drag(page, node, -8, 2); assert.equal((await state(page)).movedCount, 1, 'Repeated drags count a node once'); - await page.keyboard.press('Control+z'); + await page.keyboard.press(`${modifier}+z`); const afterUndo = await point(node); near(afterUndo.x - original.x, -35, 'Undo restores the prior drag x'); near(afterUndo.y - original.y, 20, 'Undo restores the prior drag y'); @@ -73,7 +74,7 @@ export async function exercisePositionEditing(page) { await page.evaluate(() => window.exportApi.selectManuallyPositionedElements()); assert.equal(await selected(), 2, 'Select moved selects nodes and numbering only'); assert.equal(await text.getAttribute('data-varri-selected'), null); - await press(page, number, true); + await press(page, number, modifier); await page.evaluate(() => window.exportApi.resetSelectedPositions()); assert.equal((await state(page)).movedCount, 1, 'Reset changes only selected manual nodes'); const reset = await point(node); @@ -86,17 +87,17 @@ export async function exercisePositionEditing(page) { assert.equal((await state(page)).movedCount, 2); await page.evaluate(() => window.exportApi.selectManuallyPositionedElements()); - await press(page, text, true); + await press(page, text, modifier); const targets = [node, number, text]; const before = await Promise.all(targets.map(point)); const textsBefore = await page.evaluate(() => window.exportApi.getTextAnnotations()); const pivot = { x: box.x + 330, y: box.y + 240 }; const viewport = await svg.evaluate(element => ({ ...element.__zoom })); await page.mouse.move(pivot.x, pivot.y); - await page.keyboard.down('Control'); + await page.keyboard.down(modifier); await page.mouse.wheel(0, 60); await page.mouse.wheel(0, 60); - await page.keyboard.up('Control'); + await page.keyboard.up(modifier); await page.waitForTimeout(250); const after = await Promise.all(targets.map(point)); const angle = Math.atan2(after[0].y - pivot.y, after[0].x - pivot.x) - @@ -112,25 +113,46 @@ export async function exercisePositionEditing(page) { const m = element.getScreenCTM(); return Math.atan2(m.b, m.a); }); near(textAngle, 0, 'Rotated text stays readable'); - await page.keyboard.press('Control+z'); + await page.keyboard.press(`${modifier}+z`); const undone = await Promise.all(targets.map(point)); undone.forEach((p, i) => { near(p.x, before[i].x, `Undo wheel burst ${i} x`); near(p.y, before[i].y, `Undo wheel burst ${i} y`); }); assert.deepEqual(await page.evaluate(() => window.exportApi.getTextAnnotations()), textsBefore, 'Undo restores model text positions'); await press(page, node); - await press(page, node, true); + await press(page, node, modifier); const single = await point(node); await page.mouse.move(pivot.x, pivot.y); - await page.keyboard.down('Control'); + await page.keyboard.down(modifier); await page.mouse.wheel(0, 100); - await page.keyboard.up('Control'); + await page.keyboard.up(modifier); await page.waitForTimeout(220); assert.deepEqual(await point(node), single, 'One selected element cannot rotate'); assert.deepEqual(await svg.evaluate(element => ({ ...element.__zoom })), viewport, 'Ctrl-wheel is reserved even with one selection'); + const released = await page.evaluate(() => { + const node = document.querySelector('#second [data-varri-selected]').__data__; + const snapshot = () => ({ x: node.x, y: node.y, fixed: node.fixed, fx: node.fx, fy: node.fy }); + const before = snapshot(); + const changed = window.exportApi.releaseSelectedPositions(); + return { before, after: snapshot(), changed }; + }); + assert.equal(released.changed, true); + assert.deepEqual(released.after, { ...released.before, fixed: 0, fx: null, fy: null }, + 'Release removes fixation without returning to the original position'); + if (forceLayout) await page.waitForFunction(before => { + const node = document.querySelector('#second [data-varri-selected]').__data__; + return Math.hypot(node.x - before.x, node.y - before.y) > 0.01; + }, released.before); + else assert.deepEqual(await point(node), single, 'Released static nodes stay in place'); + await page.evaluate(() => window.exportApi.undoCanvasEdit()); + assert.deepEqual(await node.evaluate(element => { + const { x, y, fixed, fx, fy } = element.__data__; return { x, y, fixed, fx, fy }; + }), released.before, 'Undo restores the position and fixation before release'); + await page.evaluate(() => window.exportApi.cancelActiveRender()); assert.deepEqual(await state(page), { movedCount: 0, selectedNodeCount: 0, selectedMovedCount: 0, canUndo: false }, 'Disposal clears counts and history'); assert.equal(await page.evaluate(() => window.exportApi.undoCanvasEdit()), false); + assert.equal(await page.evaluate(() => window.exportApi.releaseSelectedPositions()), false); } } diff --git a/scripts/browser-position-layout.js b/scripts/browser-position-layout.js new file mode 100644 index 0000000..43ab2a7 --- /dev/null +++ b/scripts/browser-position-layout.js @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +/** Controls must be visible and hit-testable without Playwright scrolling them into view. */ +export async function exercisePositionLayout(page, origin, output) { + const original = page.viewportSize(); + try { + for (const [width, height, embedded] of [[901, 731, false], [1280, 720, false], [640, 480, true]]) { + await page.setViewportSize({ width, height }); + await page.goto(origin + '/index.html' + (embedded + ? '?showRenderingOnly=1&sequence=AAAA%26UUUU&structure=((..%26..))&forceLayout=0' : ''), { waitUntil: 'networkidle' }); + await page.waitForFunction(() => document.querySelector('#rendering-canvas circle') && + document.getElementById('rendering-canvas').style.visibility !== 'hidden'); + if (!embedded) { + await page.locator('#exampleDropdownTrigger').click(); + await page.locator('[data-example]').first().click(); + 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 panel = document.querySelector('.result-panel').getBoundingClientRect(); + return elements.filter(element => getComputedStyle(element).display !== 'none').map(element => { + const r = element.getBoundingClientRect(); + return { id: element.id, inside: r.left >= panel.left && r.right <= panel.right && + r.top >= panel.top && r.bottom <= panel.bottom && r.bottom <= innerHeight, + reachable: element.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) }; + }); + }); + for (const button of controls) { + assert.ok(button.inside && button.reachable, `${button.id} must remain visible and reachable at ${width}×${height}`); + } + await page.locator('#canvasHelpBtn').click(); + const dialog = page.locator('#canvasHelpDialog'); + assert.equal(await dialog.evaluate(element => element.open), true); + assert.equal(await dialog.evaluate(element => element.scrollTop), 0, 'Help opens at its introduction'); + for (const text of ['Command', 'Select', 'Move', 'Rotate', 'Release', 'Reset', 'Undo']) { + assert.ok((await dialog.textContent()).includes(text), `Help explains ${text}`); + } + const box = await dialog.boundingBox(); + assert.ok(box.x >= 0 && box.y >= 0 && box.x + box.width <= width && box.y + box.height <= height); + await page.screenshot({ path: path.join(output, `canvas-help-${width}.png`) }); + await page.keyboard.press('Escape'); + assert.equal(await dialog.evaluate(element => element.open), false); + assert.equal(await page.locator('#canvasHelpBtn').evaluate(element => element === document.activeElement), true); + await page.locator('#canvasHelpBtn').click(); + await dialog.getByRole('button', { name: 'Close', exact: true }).click(); + assert.equal(await dialog.evaluate(element => element.open), false); + await page.screenshot({ path: path.join(output, `canvas-controls-${width}.png`) }); + } + } finally { await page.setViewportSize(original); } +} diff --git a/scripts/browser-position-ui.js b/scripts/browser-position-ui.js index 8bf91b3..c1852c7 100644 --- a/scripts/browser-position-ui.js +++ b/scripts/browser-position-ui.js @@ -1,17 +1,22 @@ import assert from 'node:assert/strict'; import path from 'node:path'; +import { exercisePositionLayout } from './browser-position-layout.js'; +import { exerciseNodeSelection } from './browser-node-selection.js'; export async function exercisePositionControlsUI(page, origin, output) { + await exercisePositionLayout(page, origin, output); const params = new URLSearchParams({ sequence: 'AAAA&UUUU', structure: '((..&..))', highlighting: 'nothing', backgroundhighlighting: 'nothing', forceLayout: '0', textAnnotations: '[]' }); 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 moved = page.locator('#selectMovedBtn'), reset = page.locator('#resetPositionsBtn'), undo = page.locator('#undoCanvasBtn'); + const release = page.locator('#releasePositionsBtn'); assert.equal(await moved.textContent(), 'Moved: 0'); assert.equal(await moved.isDisabled(), true); assert.equal(await reset.isDisabled(), true); assert.equal(await undo.isDisabled(), true); + assert.equal(await release.isDisabled(), true); const node = page.locator('#rendering-canvas circle[node_type="nucleotide"]').first(); const initial = await node.evaluate(element => ({ x: element.__data__.x, y: element.__data__.y })); const box = await node.boundingBox(); @@ -19,17 +24,34 @@ 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 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 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); 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'); 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'); @@ -37,11 +59,12 @@ export async function exercisePositionControlsUI(page, origin, output) { await undo.click(); assert.equal(await moved.textContent(), 'Moved: 1', 'Undo restores reset manual positions'); await page.locator('#rendering-canvas').focus(); - await page.keyboard.press('Control+z'); + await page.keyboard.press('Meta+z'); assert.equal(await moved.textContent(), 'Moved: 0', 'Canvas shortcut undoes the original drag'); assert.equal(await undo.isDisabled(), true); // An exhausted canvas history leaves positions unchanged. await moved.evaluate(() => document.getElementById('rendering-canvas').focus()); await page.keyboard.press('Control+z'); assert.deepEqual(await node.evaluate(element => ({ x: element.__data__.x, y: element.__data__.y })), initial); + await exerciseNodeSelection(page); } diff --git a/scripts/browser-selection.js b/scripts/browser-selection.js index 00bbf87..ac29e8c 100644 --- a/scripts/browser-selection.js +++ b/scripts/browser-selection.js @@ -6,7 +6,7 @@ export async function exerciseRectangleSelection(page, host) { const box = await svg.boundingBox(); const bounds = await host.locator('g.gnode, [data-varri-text]').evaluateAll(elements => elements.map((element, i) => { element.setAttribute('data-test-target', i); - const { left, right, top, bottom } = element.getBoundingClientRect(); + const { left, right, top, bottom } = (element.querySelector('circle[node_type="nucleotide"]') || element).getBoundingClientRect(); return { i, left, right, top, bottom }; })); const left = box.x + 10, top = box.y + 10; @@ -16,15 +16,15 @@ export async function exerciseRectangleSelection(page, host) { assert.ok(expected.length > 4, 'Rectangle fixture contains several objects'); const firstViewer = await page.locator('#first').innerHTML(); const plot = await host.locator('.fornac-plot').getAttribute('transform'); - for (const reverse of [false, true]) { - await page.keyboard.down('Control'); + for (const [reverse, modifier] of [[false, 'Control'], [true, 'Meta']]) { + await page.keyboard.down(modifier); await page.mouse.move(reverse ? right : left, reverse ? bottom : top); await page.mouse.down(); await page.mouse.move(reverse ? left : right, reverse ? top : bottom, { steps: 6 }); assert.equal(await host.locator('[data-varri-interaction-overlay] [stroke-dasharray]').count(), 1, 'Marquee is drawn during the gesture'); await page.mouse.up(); - await page.keyboard.up('Control'); + await page.keyboard.up(modifier); assert.deepEqual(await host.locator('[data-varri-selected]').evaluateAll(elements => elements.map(element => element.getAttribute('data-test-target'))), expected, 'Rectangle selects every fully enclosed element'); assert.equal(await host.locator('.fornac-plot').getAttribute('transform'), plot, 'Ctrl-drag does not pan'); diff --git a/scripts/browser-ui.js b/scripts/browser-ui.js index 81d9758..e29801f 100644 --- a/scripts/browser-ui.js +++ b/scripts/browser-ui.js @@ -91,6 +91,13 @@ async function main() { phase = 'initial viewer load'; await page.goto(origin + '/index.html', { waitUntil: 'networkidle' }); await ready(page); + if (process.argv.includes('--positions-only')) { + phase = 'manual positions, platform modifiers, responsive controls and help'; + await exercisePositionControlsUI(page, origin, output); + assert.deepEqual(errors, [], 'Browser errors during canvas interaction workflow'); + console.log(`Canvas interaction UI passed (${browserType.name()} ${browser.version()}).`); + return; + } if (process.argv.includes('--png-only')) { phase = 'PNG dialog and download'; await checkPNGDialog(page, output); diff --git a/src/README.md b/src/README.md index 1928c8a..218e912 100644 --- a/src/README.md +++ b/src/README.md @@ -308,16 +308,21 @@ Graph objects remain inside the renderer; text annotations are excluded from manual-node counts. - `selectManuallyPositionedElements()` selects moved nucleotides and numbering labels. +- `releaseSelectedPositions()` unfixes selected nodes at their current positions, + resumes the force when animation is enabled, and removes them from the moved + count. Text labels are unaffected. Returns whether any fixation was released. - `resetSelectedPositions()` restores selected manual nodes to their positions and fixation before the first manual edit. Other elements are retained. -- `undoCanvasEdit()` undoes the last drag, wheel-rotation burst, or position reset. +- `undoCanvasEdit()` undoes the last drag, wheel-rotation burst, release, or position reset. It restores text positions and anchors when those were edited, and returns whether an edit was undone. History holds at most 100 edits for the current render. Ctrl-click toggles selection; plain element clicks and Ctrl-clicks on the background clear it. Drag a selected member to move its group. Ctrl-wheel rotates two or more selected elements around the pointer without changing zoom. Ctrl+Z (or Command+Z) -undoes while the canvas has focus. A new render or cancellation clears history. +undoes while the canvas has focus. Command works in place of Ctrl for all mouse +gestures on macOS. Undo a release before resetting a node to its pre-edit position. +A new render or cancellation clears history. ## Export diff --git a/src/core/api.js b/src/core/api.js index 9f0d7b9..96c0cae 100644 --- a/src/core/api.js +++ b/src/core/api.js @@ -41,6 +41,7 @@ export function createVaRRI(options = {}) { { movedCount: 0, selectedNodeCount: 0, selectedMovedCount: 0, canUndo: false }, selectManuallyPositionedElements: () => session.runtime.activeContainer?.interactions?.selectMoved(), resetSelectedPositions: () => session.runtime.activeContainer?.interactions?.resetSelected() || false, + releaseSelectedPositions: () => session.runtime.activeContainer?.interactions?.releaseSelected() || false, undoCanvasEdit: () => session.runtime.activeContainer?.interactions?.undo() || false, validate: args => validate(args, modelState.colors), getColors: getColors.bind(null, modelState), diff --git a/src/core/canvas/interaction-selection.js b/src/core/canvas/interaction-selection.js index ee8fdd6..2f1ac27 100644 --- a/src/core/canvas/interaction-selection.js +++ b/src/core/canvas/interaction-selection.js @@ -18,8 +18,14 @@ export function interactionTargets(container) { }); } +/** Direction arrows belong to the backbone, not the selectable nucleotide. */ +export function selectionBounds(element) { + const nucleotide = element.querySelector('circle[node_type="nucleotide"]'); + return (nucleotide || element).getBoundingClientRect(); +} + export function enclosed(element, rectangle) { - const box = element.getBoundingClientRect(); + const box = selectionBounds(element); return (box.width > 0 || box.height > 0) && box.left >= rectangle.left && box.right <= rectangle.right && box.top >= rectangle.top && box.bottom <= rectangle.bottom; } @@ -56,7 +62,7 @@ export function createSelectionOverlay(svg) { return { draw(selected, rectangle) { layer.replaceChildren(); - selected.forEach(element => outline(element.getBoundingClientRect())); + selected.forEach(element => outline(selectionBounds(element))); if (rectangle) outline(rectangle, true); }, dispose() { layer.remove(); }, diff --git a/src/core/canvas/interactions.js b/src/core/canvas/interactions.js index b62da88..833df7b 100644 --- a/src/core/canvas/interactions.js +++ b/src/core/canvas/interactions.js @@ -7,6 +7,7 @@ import { createSelectionOverlay, enclosed, interactionTargets, INTERACTION_TARGE /** One pointer owner and one selection for nodes, numbering, and free text. */ export function createCanvasInteractions(container, syncGraph = () => {}) { const { svg } = container; + const host = container.element || svg; const window = svg.ownerDocument.defaultView; const selected = new Set(); const overlay = createSelectionOverlay(svg); @@ -29,7 +30,7 @@ export function createCanvasInteractions(container, syncGraph = () => {}) { container.options?.onCanvasInteractionChange?.(value); } const focus = () => { - const host = container.element || svg; + host.setAttribute('data-varri-pointer-focus', 'true'); if (!host.hasAttribute('tabindex')) host.setAttribute('tabindex', '0'); host.focus({ preventScroll: true }); }; @@ -152,11 +153,13 @@ export function createCanvasInteractions(container, syncGraph = () => {}) { return changed; } const keydown = event => { + clearPointerFocus(); if (event.key.toLowerCase() !== 'z' || (!event.ctrlKey && !event.metaKey) || event.shiftKey || event.altKey || event.target.closest?.('input, textarea, select, [contenteditable="true"]')) return; if (undo()) { event.preventDefault(); event.stopPropagation(); } }; - const host = container.element || svg; + const clearPointerFocus = () => host.removeAttribute('data-varri-pointer-focus'); + host.addEventListener('blur', clearPointerFocus); host.addEventListener('keydown', keydown); svg.addEventListener('pointerdown', onStart); svg.addEventListener('lostpointercapture', onCancel); @@ -178,12 +181,21 @@ export function createCanvasInteractions(container, syncGraph = () => {}) { refresh(); return changed; }, + releaseSelected() { + if (container.destroyed) return false; + stop(); rotation.finish(); + const changed = positions.release(selectedTargets()); + refresh(); + return changed; + }, clear() { stop(); rotation.finish(); selected.clear(); positions.clear(); refresh(); }, dispose() { stop(); rotation.dispose(); positions.clear(); host.removeEventListener('keydown', keydown); + host.removeEventListener('blur', clearPointerFocus); + clearPointerFocus(); selected.clear(); refresh(); overlay.dispose(); diff --git a/src/core/canvas/manual-positions.js b/src/core/canvas/manual-positions.js index 92b3adb..07404f7 100644 --- a/src/core/canvas/manual-positions.js +++ b/src/core/canvas/manual-positions.js @@ -50,6 +50,20 @@ export function createManualPositions(container, syncGraph) { resume(true); return true; } + function release(targets) { + const releasable = targets.filter(({ node }) => node && + (node.fixed || node.fx != null || node.fy != null || originals.has(node))); + if (!releasable.length) return false; + const records = capture(releasable); + for (const { node } of releasable) { + Object.assign(node, { px: node.x, py: node.y, fx: null, fy: null, vx: 0, vy: 0, fixed: 0 }); + originals.delete(node); + } + updateCount(); + commit(records); + resume(true); + return true; + } function undo() { const records = history.pop(); if (!records) return false; @@ -70,7 +84,7 @@ export function createManualPositions(container, syncGraph) { return true; } return { - capture, commit, apply, reset, undo, + capture, commit, apply, reset, release, undo, has: node => originals.has(node), get count() { return originals.size; }, get canUndo() { return history.length > 0; }, diff --git a/src/core/index.js b/src/core/index.js index 9d6baae..fe98cca 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -12,6 +12,7 @@ export const { getCanvasInteractionState, selectManuallyPositionedElements, resetSelectedPositions, + releaseSelectedPositions, undoCanvasEdit, validate, getColors, diff --git a/src/ui/components/app.js b/src/ui/components/app.js index 8542396..c8bd864 100644 --- a/src/ui/components/app.js +++ b/src/ui/components/app.js @@ -16,6 +16,7 @@ import RegionHighlightDialog from './region-highlight-dialog.js'; import MutationDialog from './mutation-dialog.js'; import FastaDialog from './fasta-dialog.js'; import PNGExportDialog from './png-export-dialog.js'; +import CanvasHelpDialog from './canvas-help-dialog.js'; export default viewerComponent('VaRRIApp', ` @@ -29,7 +30,7 @@ export default viewerComponent('VaRRIApp', ` - + `, { SiteHeader, SiteFooter, SequenceInput, VisualizationSettings, AnnotationPanel, TextAnnotationPanel, ProfilePanel, ViewerPanel, HelpPanel, NumberDialog, TextDialog, SubseqHighlightDialog, - RegionHighlightDialog, MutationDialog, FastaDialog, PNGExportDialog, TextAnnotationDialog }); + RegionHighlightDialog, MutationDialog, FastaDialog, PNGExportDialog, TextAnnotationDialog, CanvasHelpDialog }); diff --git a/src/ui/components/canvas-help-dialog.js b/src/ui/components/canvas-help-dialog.js new file mode 100644 index 0000000..a272a9c --- /dev/null +++ b/src/ui/components/canvas-help-dialog.js @@ -0,0 +1,30 @@ +import { viewerComponent } from './context.js'; + +export default viewerComponent('canvas-help-dialog', ` +
+
Canvas interactions
+
+

Use Ctrl on Linux and Windows, or Command (⌘) on macOS, for the shortcuts below.

+
+
Select
Ctrl/Command-click a nucleotide, numbering label, or text label to toggle selection. + Ctrl/Command-drag the background to select all elements fully inside the rectangle. + A plain click on an element, Ctrl/Command-click on the background, or an empty selection rectangle clears selection.
+
Move
Drag an element to move it. Drag a selected element to move the whole selection. + Dragging an unselected element clears the selection. Moved nucleotides and numbering labels stay fixed, even with Force layout enabled.
+
Rotate
Select at least two elements, then Ctrl/Command-scroll to rotate them around the mouse pointer. + Text stays upright. Ordinary scrolling zooms, and dragging the background pans.
+
Moved
The count includes manually positioned nucleotides and numbering labels. Click it to select them all.
+
Release
Unfix selected nodes at their current positions. They resume moving when Force layout is enabled. + Released nodes leave the Moved count. Text labels are unaffected.
+
Reset
Restore selected manually positioned nodes to their positions and fixation before their first edit. + Text labels are unaffected. To reset a released node, undo its release first.
+
Undo
Reverse the last drag, rotation, release, or reset, including text movements. + Ctrl/Command+Z also works while the canvas is focused. Up to 100 edits are kept for the current rendering.
+
+

Rerendering clears temporary node positions and undo history. SVG and PNG exports preserve the layout without selection outlines. + Share links preserve text-label positions only.

+
+
+
+
`); diff --git a/src/ui/components/viewer-panel.js b/src/ui/components/viewer-panel.js index 8ecb79d..ada8c8f 100644 --- a/src/ui/components/viewer-panel.js +++ b/src/ui/components/viewer-panel.js @@ -15,10 +15,14 @@ export default viewerComponent('viewer-panel', `
+ + v-bind:disabled="!state.canvasInteraction.canUndo" title="Undo the last drag, selection rotation, release, or reset. Ctrl+Z / Command+Z while the canvas is focused.">Undo +
diff --git a/src/ui/render-actions.js b/src/ui/render-actions.js index cdbcd14..fe5f03f 100644 --- a/src/ui/render-actions.js +++ b/src/ui/render-actions.js @@ -76,6 +76,7 @@ export function createRenderActions({ api, state, actions, document }) { runVisualization, applyColors, syncGeneratedRegionHighlight, applySliderRotation, commitSliderRotation, selectMovedElements() { api.selectManuallyPositionedElements(); }, resetSelectedPositions() { api.resetSelectedPositions(); }, + releaseSelectedPositions() { api.releaseSelectedPositions(); }, undoCanvasEdit() { api.undoCanvasEdit(); }, cancelRendering() { latestRun++; api.cancelActiveRender(); diff --git a/src/ui/styles/canvas.css b/src/ui/styles/canvas.css index 36fc52c..ce0a1cb 100644 --- a/src/ui/styles/canvas.css +++ b/src/ui/styles/canvas.css @@ -151,17 +151,28 @@ background: var(--surface); border-radius: 8px; box-shadow: 0 2px 8px rgba(15, 46, 74, .12); - flex: 1; - min-height: 450px; + flex: 1 1 0; + min-height: 0; overflow: hidden; position: relative; } #rendering-canvas svg { + display: block; width: 100%; height: 100%; } +#rendering-canvas:focus { outline: none; } +#rendering-canvas:focus-visible:not([data-varri-pointer-focus]) { + outline: 2px solid var(--brand-blue); outline-offset: -2px; +} + +/* Reserve space for controls and captions when the viewer is short or resized. */ +.result-panel > .viz-control, +.result-panel > .export-bar, +.result-panel > #rendering-caption { flex: none; } + #rendering-caption { background: var(--surface); border-radius: 8px; @@ -239,7 +250,7 @@ .viz-control > #rotationSlider { flex: 1 1 60px; width: 0; } .viz-control > .cropping-control { flex: 0.6 1 50px; width: auto; min-width: 50px; } .cropping-control > input { width: 100%; display: block; } -.manual-position-controls { display: flex; gap: .25rem; align-items: center; margin-left: auto; } +.manual-position-controls { display: flex; flex: none; gap: .25rem; align-items: center; margin-left: auto; } .manual-position-controls button { font: inherit; font-size: .75rem; line-height: 18px; height: 20px; padding: 0 .4rem; white-space: nowrap; diff --git a/src/ui/styles/dialogs.css b/src/ui/styles/dialogs.css index 32c3a0c..7680b8c 100644 --- a/src/ui/styles/dialogs.css +++ b/src/ui/styles/dialogs.css @@ -134,3 +134,18 @@ dialog.png-export-dialog { .png-hint:last-child { margin-bottom: 0; } .png-export-error { margin: 8px 0 0; color: #a12622; font-size: 13px; } .png-export-dialog button:disabled { opacity: 0.6; cursor: wait; } + +dialog.canvas-help-dialog { + inset: 0; + margin: auto; + width: 540px; + min-width: 0; + max-width: calc(100vw - 32px); + max-height: calc(100vh - 32px); + overflow: auto; + font-size: 13px; + line-height: 1.45; +} +.canvas-help-dialog .dialog-header { cursor: default; } +.canvas-help-dialog dl { margin: 12px 0; } +.canvas-help-dialog dt { font-weight: 700; margin-top: 8px; } diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index a3ee9ab..9b91a8f 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -105,6 +105,7 @@ body.hide-footer-and-header .result-panel { width: 100%; max-width: 100%; height: 100%; + min-height: 0; } @@ -129,6 +130,7 @@ body.rendering-only main { body.rendering-only .result-panel { width: 100%; height: 100%; + min-height: 0; } /* Keep exports and embedded rendering backgrounds transparent. */ diff --git a/tests/manual-positions.test.js b/tests/manual-positions.test.js index 8a86218..32e2a9e 100644 --- a/tests/manual-positions.test.js +++ b/tests/manual-positions.test.js @@ -62,3 +62,44 @@ test('undoing the first drag removes its fixation, and a new render discards his expect(f.positions.count).toBe(0); expect(f.container.hasManualPositions).toBe(false); }); + +test.each([false, true])('release keeps current positions and resumes only animated layouts, force=%s', animation => { + const f = fixture(animation); + f.edit(f.node, 10, 12); + f.edit(f.other, 25, 30); + f.force.resume.mockClear(); + expect(f.positions.release([f.target(f.node), { id: 'text' }])).toBe(true); + expect(f.node).toMatchObject({ x: 10, y: 12, px: 10, py: 12, fx: null, fy: null, fixed: 0, vx: 0, vy: 0 }); + expect(f.other).toMatchObject({ x: 25, y: 30, fixed: 1, fx: 25, fy: 30 }); + expect(f.positions.count).toBe(1); + expect(f.force.resume).toHaveBeenCalledTimes(animation ? 1 : 0); + // A force tick after release must not change what Undo restores. + Object.assign(f.node, { x: 9, y: 11 }); + expect(f.positions.undo()).toBe(true); + expect(f.node).toMatchObject({ x: 10, y: 12, fixed: 1, fx: 10, fy: 12 }); + expect(f.positions.count).toBe(2); + f.positions.reset([f.target(f.node)]); + expect(f.node).toMatchObject({ x: 2, y: 3, fixed: 0 }); +}); + +test('release can unfix previously fixed nodes, and a later drag gets a new reset origin', () => { + const f = fixture(); + expect(f.positions.release([f.target(f.other)])).toBe(true); + expect(f.other).toMatchObject({ x: 20, y: 3, fixed: 0, fx: null, fy: null }); + f.positions.undo(); + expect(f.other).toMatchObject({ x: 20, y: 3, fixed: 1, fx: 20, fy: 3 }); + f.edit(f.node, 10, 12); + f.positions.release([f.target(f.node)]); + f.edit(f.node, 30, 40); + f.positions.reset([f.target(f.node)]); + expect(f.node).toMatchObject({ x: 10, y: 12, fixed: 0, fx: null, fy: null }); + expect(f.positions.count).toBe(0); +}); + +test('release of free nodes or text does not add undo history or start a force', () => { + const f = fixture(true); + expect(f.positions.release([f.target(f.node), { id: 'text' }])).toBe(false); + expect(f.positions.release([])).toBe(false); + expect(f.positions.canUndo).toBe(false); + expect(f.force.resume).not.toHaveBeenCalled(); +}); From 8383c248d300e45bb38c2f647dc6f4ca7690af5a Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Wed, 7 Oct 2026 12:27:42 +0200 Subject: [PATCH 04/11] Keep canvas controls visible and mount help with its panel --- scripts/browser-canvas-help.js | 31 ++++++++++++++++++++++++++++++ scripts/browser-position-layout.js | 10 +++++++--- scripts/browser-position-ui.js | 2 ++ src/ui/components/app.js | 5 ++--- src/ui/components/viewer-panel.js | 4 +++- src/ui/styles/base.css | 7 ++++--- src/ui/styles/canvas.css | 4 ++-- 7 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 scripts/browser-canvas-help.js diff --git a/scripts/browser-canvas-help.js b/scripts/browser-canvas-help.js new file mode 100644 index 0000000..904fe8f --- /dev/null +++ b/scripts/browser-canvas-help.js @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; + +/** The panel must supply working help even when its host knows only ViewerPanel. */ +export async function exerciseStandaloneCanvasHelp(page, origin) { + const appURL = origin + '/src/ui/components/app.js'; + await page.route(appURL, route => route.fulfill({ contentType: 'text/javascript', body: ` + import { viewerComponent } from './context.js'; + import ViewerPanel from './viewer-panel.js'; + export default viewerComponent('ViewerHost', '', { ViewerPanel }); + ` })); + try { + await page.goto(origin + '/index.html?showRenderingOnly=1&sequence=AAAA%26UUUU&structure=((..%26..))&forceLayout=0', + { waitUntil: 'networkidle' }); + await page.waitForFunction(() => document.querySelector('#rendering-canvas circle') && + document.getElementById('rendering-canvas').style.visibility !== 'hidden'); + const help = page.getByRole('button', { name: 'Canvas interaction help', exact: true }); + await help.focus(); + await page.keyboard.press('Enter'); + const dialog = page.getByRole('dialog', { name: 'Canvas interactions', exact: true }); + assert.equal(await dialog.count(), 1, 'The help button supplies exactly one accessible dialog'); + assert.equal(await dialog.isVisible(), true, 'Keyboard activation opens visible help'); + assert.equal(await dialog.evaluate(element => element.matches(':modal')), true, 'Help enters the modal top layer'); + await page.keyboard.press('Escape'); + assert.equal(await help.evaluate(element => element === document.activeElement), true); + await help.click(); + await dialog.getByRole('button', { name: 'Close', exact: true }).click(); + assert.equal(await dialog.count(), 0, 'Close dismisses help from the accessibility tree'); + } finally { + await page.unroute(appURL); + } +} diff --git a/scripts/browser-position-layout.js b/scripts/browser-position-layout.js index 43ab2a7..f7051a0 100644 --- a/scripts/browser-position-layout.js +++ b/scripts/browser-position-layout.js @@ -5,7 +5,8 @@ import path from 'node:path'; export async function exercisePositionLayout(page, origin, output) { const original = page.viewportSize(); try { - for (const [width, height, embedded] of [[901, 731, false], [1280, 720, false], [640, 480, true]]) { + for (const [width, height, embedded] of [[938, 480, false], [1280, 569, false], [938, 569, false], + [901, 731, false], [1280, 720, false], [640, 480, true]]) { await page.setViewportSize({ width, height }); await page.goto(origin + '/index.html' + (embedded ? '?showRenderingOnly=1&sequence=AAAA%26UUUU&structure=((..%26..))&forceLayout=0' : ''), { waitUntil: 'networkidle' }); @@ -38,14 +39,17 @@ export async function exercisePositionLayout(page, origin, output) { } const box = await dialog.boundingBox(); assert.ok(box.x >= 0 && box.y >= 0 && box.x + box.width <= width && box.y + box.height <= height); - await page.screenshot({ path: path.join(output, `canvas-help-${width}.png`) }); + await page.screenshot({ path: path.join(output, `canvas-help-${width}x${height}.png`) }); await page.keyboard.press('Escape'); assert.equal(await dialog.evaluate(element => element.open), false); assert.equal(await page.locator('#canvasHelpBtn').evaluate(element => element === document.activeElement), true); await page.locator('#canvasHelpBtn').click(); await dialog.getByRole('button', { name: 'Close', exact: true }).click(); assert.equal(await dialog.evaluate(element => element.open), false); - await page.screenshot({ path: path.join(output, `canvas-controls-${width}.png`) }); + await page.screenshot({ path: path.join(output, `canvas-controls-${width}x${height}.png`) }); } + } catch (error) { + await page.screenshot({ path: path.join(output, 'canvas-layout-failure.png') }); + throw error; } finally { await page.setViewportSize(original); } } diff --git a/scripts/browser-position-ui.js b/scripts/browser-position-ui.js index c1852c7..04251cb 100644 --- a/scripts/browser-position-ui.js +++ b/scripts/browser-position-ui.js @@ -2,8 +2,10 @@ import assert from 'node:assert/strict'; import path from 'node:path'; import { exercisePositionLayout } from './browser-position-layout.js'; import { exerciseNodeSelection } from './browser-node-selection.js'; +import { exerciseStandaloneCanvasHelp } from './browser-canvas-help.js'; export async function exercisePositionControlsUI(page, origin, output) { + await exerciseStandaloneCanvasHelp(page, origin); await exercisePositionLayout(page, origin, output); const params = new URLSearchParams({ sequence: 'AAAA&UUUU', structure: '((..&..))', highlighting: 'nothing', backgroundhighlighting: 'nothing', forceLayout: '0', textAnnotations: '[]' }); diff --git a/src/ui/components/app.js b/src/ui/components/app.js index c8bd864..8542396 100644 --- a/src/ui/components/app.js +++ b/src/ui/components/app.js @@ -16,7 +16,6 @@ import RegionHighlightDialog from './region-highlight-dialog.js'; import MutationDialog from './mutation-dialog.js'; import FastaDialog from './fasta-dialog.js'; import PNGExportDialog from './png-export-dialog.js'; -import CanvasHelpDialog from './canvas-help-dialog.js'; export default viewerComponent('VaRRIApp', ` @@ -30,7 +29,7 @@ export default viewerComponent('VaRRIApp', ` - + `, { SiteHeader, SiteFooter, SequenceInput, VisualizationSettings, AnnotationPanel, TextAnnotationPanel, ProfilePanel, ViewerPanel, HelpPanel, NumberDialog, TextDialog, SubseqHighlightDialog, - RegionHighlightDialog, MutationDialog, FastaDialog, PNGExportDialog, TextAnnotationDialog, CanvasHelpDialog }); + RegionHighlightDialog, MutationDialog, FastaDialog, PNGExportDialog, TextAnnotationDialog }); diff --git a/src/ui/components/viewer-panel.js b/src/ui/components/viewer-panel.js index ada8c8f..f3d4fa8 100644 --- a/src/ui/components/viewer-panel.js +++ b/src/ui/components/viewer-panel.js @@ -1,4 +1,5 @@ import { viewerComponent } from './context.js'; +import CanvasHelpDialog from './canvas-help-dialog.js'; export default viewerComponent('viewer-panel', `
-
`); + +
`, { CanvasHelpDialog }); diff --git a/src/ui/styles/base.css b/src/ui/styles/base.css index 6cbbf6b..033c0c5 100644 --- a/src/ui/styles/base.css +++ b/src/ui/styles/base.css @@ -8,6 +8,8 @@ --text-primary: #1c2b39; --text-muted: #4f6478; --border-soft: #ccd9e5; + /* Keep both columns above the viewport bottom, including in short windows. */ + --viewer-height: min(max(520px, calc(100vh - 260px)), calc(100vh - 130px)); } /* ------------------------------------------------------------------ */ @@ -104,8 +106,8 @@ main { overflow: auto; height: 100%; - min-height: 520px; - max-height: max(520px, calc(100vh - 260px)); + min-height: 0; + max-height: var(--viewer-height); } .control-panel { @@ -152,4 +154,3 @@ main { padding-left: 2rem; } - diff --git a/src/ui/styles/canvas.css b/src/ui/styles/canvas.css index ce0a1cb..064e0fe 100644 --- a/src/ui/styles/canvas.css +++ b/src/ui/styles/canvas.css @@ -9,8 +9,8 @@ min-width: 0; max-width: calc(50vw +350px); - min-height: 520px; - height: max(520px, calc(100vh - 260px));; + min-height: 0; + height: var(--viewer-height); } .highlight-list { From a95814000d9fb147bfd3dfc8660994f22ea416ea Mon Sep 17 00:00:00 2001 From: Martin Raden Date: Wed, 7 Oct 2026 13:49:43 +0200 Subject: [PATCH 05/11] reenable correct heights --- src/ui/styles/base.css | 4 +--- src/ui/styles/canvas.css | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/ui/styles/base.css b/src/ui/styles/base.css index 033c0c5..770962f 100644 --- a/src/ui/styles/base.css +++ b/src/ui/styles/base.css @@ -8,8 +8,6 @@ --text-primary: #1c2b39; --text-muted: #4f6478; --border-soft: #ccd9e5; - /* Keep both columns above the viewport bottom, including in short windows. */ - --viewer-height: min(max(520px, calc(100vh - 260px)), calc(100vh - 130px)); } /* ------------------------------------------------------------------ */ @@ -107,7 +105,7 @@ main { height: 100%; min-height: 0; - max-height: var(--viewer-height); + max-height: max(520px, calc(100vh - 260px)); } .control-panel { diff --git a/src/ui/styles/canvas.css b/src/ui/styles/canvas.css index 064e0fe..0df1c1d 100644 --- a/src/ui/styles/canvas.css +++ b/src/ui/styles/canvas.css @@ -10,7 +10,7 @@ max-width: calc(50vw +350px); min-height: 0; - height: var(--viewer-height); + height: max(520px, calc(100vh - 260px)); } .highlight-list { From a1add4e9f11b5c9219ba744b4c8c0037bc42edda Mon Sep 17 00:00:00 2001 From: Martin Raden Date: Wed, 7 Oct 2026 15:10:33 +0200 Subject: [PATCH 06/11] lower min canvas size --- src/ui/styles/canvas.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/styles/canvas.css b/src/ui/styles/canvas.css index 0df1c1d..61f2f98 100644 --- a/src/ui/styles/canvas.css +++ b/src/ui/styles/canvas.css @@ -10,7 +10,7 @@ max-width: calc(50vw +350px); min-height: 0; - height: max(520px, calc(100vh - 260px)); + height: max(300px, calc(100vh - 260px)); } .highlight-list { From 01203dc60da38757181a6ab793e0d9afc8c77395 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Wed, 7 Oct 2026 15:51:12 +0200 Subject: [PATCH 07/11] Keep canvas help styled with cached host CSS --- scripts/browser-canvas-help.js | 38 +++++++++++++++++++++++++ scripts/browser-position-layout.js | 4 ++- src/ui/components/canvas-help-dialog.js | 14 +++++---- src/ui/styles/canvas-help.css | 24 ++++++++++++++++ src/ui/styles/canvas.css | 7 +---- src/ui/styles/dialogs.css | 15 ---------- 6 files changed, 75 insertions(+), 27 deletions(-) create mode 100644 src/ui/styles/canvas-help.css diff --git a/scripts/browser-canvas-help.js b/scripts/browser-canvas-help.js index 904fe8f..e3e1859 100644 --- a/scripts/browser-canvas-help.js +++ b/scripts/browser-canvas-help.js @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; /** The panel must supply working help even when its host knows only ViewerPanel. */ export async function exerciseStandaloneCanvasHelp(page, origin) { @@ -8,6 +9,12 @@ export async function exerciseStandaloneCanvasHelp(page, origin) { import ViewerPanel from './viewer-panel.js'; export default viewerComponent('ViewerHost', '', { ViewerPanel }); ` })); + // Reproduce the review screenshot: the general dialog theme is cached without + // the newer help-specific rules. Component styles must still center the help. + const cssURL = origin + '/src/ui/styles/dialogs.css'; + const oldCSS = fs.readFileSync(new URL('../src/ui/styles/dialogs.css', import.meta.url), 'utf8') + .split('dialog.canvas-help-dialog')[0]; + await page.route(cssURL, route => route.fulfill({ contentType: 'text/css', body: oldCSS })); try { await page.goto(origin + '/index.html?showRenderingOnly=1&sequence=AAAA%26UUUU&structure=((..%26..))&forceLayout=0', { waitUntil: 'networkidle' }); @@ -20,6 +27,7 @@ export async function exerciseStandaloneCanvasHelp(page, origin) { assert.equal(await dialog.count(), 1, 'The help button supplies exactly one accessible dialog'); assert.equal(await dialog.isVisible(), true, 'Keyboard activation opens visible help'); assert.equal(await dialog.evaluate(element => element.matches(':modal')), true, 'Help enters the modal top layer'); + await checkCanvasHelpStyles(page); await page.keyboard.press('Escape'); assert.equal(await help.evaluate(element => element === document.activeElement), true); await help.click(); @@ -27,5 +35,35 @@ export async function exerciseStandaloneCanvasHelp(page, origin) { assert.equal(await dialog.count(), 0, 'Close dismisses help from the accessibility tree'); } finally { await page.unroute(appURL); + await page.unroute(cssURL); } } + +/** Check geometry and recognizable, noninteractive button names in the help. */ +export async function checkCanvasHelpStyles(page) { + const dialog = page.locator('#canvasHelpDialog'); + const box = await dialog.boundingBox(); + const viewport = page.viewportSize(); + assert.ok(box.width <= 542 && box.width <= viewport.width - 30, 'Help has a bounded width'); + assert.ok(Math.abs(box.x + box.width / 2 - viewport.width / 2) < 2, 'Help is horizontally centered'); + assert.ok(Math.abs(box.y + box.height / 2 - viewport.height / 2) < 2, 'Help is vertically centered'); + assert.equal(await dialog.locator('dt').first().evaluate(el => getComputedStyle(el).fontWeight), '700'); + const keys = dialog.locator('dt .canvas-position-key'); + assert.deepEqual(await keys.allTextContents(), ['Moved', 'Release', 'Reset', 'Undo']); + const appearances = await keys.evaluateAll(elements => elements.map(el => { + const style = getComputedStyle(el); + return { border: style.border, radius: style.borderRadius, padding: style.padding, + height: style.height, fontSize: style.fontSize, weight: style.fontWeight, tag: el.tagName }; + })); + // Enable a toolbar button only to compare its normal appearance with the key. + const expected = await page.locator('#selectMovedBtn').evaluate(el => { + const disabled = el.disabled; + el.disabled = false; + const style = getComputedStyle(el); + const result = { border: style.border, radius: style.borderRadius, padding: style.padding, + height: style.height, fontSize: style.fontSize, weight: '700', tag: 'SPAN' }; + el.disabled = disabled; + return result; + }); + appearances.forEach(appearance => assert.deepEqual(appearance, expected)); +} diff --git a/scripts/browser-position-layout.js b/scripts/browser-position-layout.js index f7051a0..5a579d0 100644 --- a/scripts/browser-position-layout.js +++ b/scripts/browser-position-layout.js @@ -1,12 +1,13 @@ 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. */ export async function exercisePositionLayout(page, origin, output) { const original = page.viewportSize(); try { for (const [width, height, embedded] of [[938, 480, false], [1280, 569, false], [938, 569, false], - [901, 731, false], [1280, 720, false], [640, 480, true]]) { + [1759, 570, false], [901, 731, false], [1280, 720, false], [640, 480, true]]) { await page.setViewportSize({ width, height }); await page.goto(origin + '/index.html' + (embedded ? '?showRenderingOnly=1&sequence=AAAA%26UUUU&structure=((..%26..))&forceLayout=0' : ''), { waitUntil: 'networkidle' }); @@ -37,6 +38,7 @@ export async function exercisePositionLayout(page, origin, output) { for (const text of ['Command', 'Select', 'Move', 'Rotate', 'Release', 'Reset', 'Undo']) { assert.ok((await dialog.textContent()).includes(text), `Help explains ${text}`); } + await checkCanvasHelpStyles(page); const box = await dialog.boundingBox(); assert.ok(box.x >= 0 && box.y >= 0 && box.x + box.width <= width && box.y + box.height <= height); await page.screenshot({ path: path.join(output, `canvas-help-${width}x${height}.png`) }); diff --git a/src/ui/components/canvas-help-dialog.js b/src/ui/components/canvas-help-dialog.js index a272a9c..674069a 100644 --- a/src/ui/components/canvas-help-dialog.js +++ b/src/ui/components/canvas-help-dialog.js @@ -1,6 +1,10 @@ import { viewerComponent } from './context.js'; -export default viewerComponent('canvas-help-dialog', ` +
Canvas interactions
@@ -14,12 +18,12 @@ export default viewerComponent('canvas-help-dialog', `Moved
The count includes manually positioned nucleotides and numbering labels. Click it to select them all.
+
Release
Unfix selected nodes at their current positions. They resume moving when Force layout is enabled. Released nodes leave the Moved count. Text labels are unaffected.
-
Reset
Restore selected manually positioned nodes to their positions and fixation before their first edit. +
Reset
Restore selected manually positioned nodes to their positions and fixation before their first edit. Text labels are unaffected. To reset a released node, undo its release first.
-
Undo
Reverse the last drag, rotation, release, or reset, including text movements. +
Undo
Reverse the last drag, rotation, release, or reset, including text movements. Ctrl/Command+Z also works while the canvas is focused. Up to 100 edits are kept for the current rendering.

Rerendering clears temporary node positions and undo history. SVG and PNG exports preserve the layout without selection outlines. diff --git a/src/ui/styles/canvas-help.css b/src/ui/styles/canvas-help.css new file mode 100644 index 0000000..204808b --- /dev/null +++ b/src/ui/styles/canvas-help.css @@ -0,0 +1,24 @@ +dialog.canvas-help-dialog { + inset: 0; + margin: auto; + width: 540px; + height: fit-content; + min-width: 0; + max-width: calc(100vw - 32px); + max-height: calc(100vh - 32px); + overflow: auto; + font-size: 13px; + line-height: 1.45; +} +.canvas-help-dialog .dialog-header { cursor: default; } +.canvas-help-dialog dl { margin: 12px 0; } +.canvas-help-dialog dt { font-weight: 700; margin-top: 8px; } + +.manual-position-controls button, +.canvas-position-key { + font: inherit; font-size: .75rem; line-height: 18px; height: 20px; + padding: 0 .4rem; white-space: nowrap; + color: var(--brand-blue-dark); background: #f5f8fb; + border: 1px solid #cbd7e2; border-radius: 4px; +} +.canvas-position-key { display: inline-block; font-weight: 700; } diff --git a/src/ui/styles/canvas.css b/src/ui/styles/canvas.css index 61f2f98..2a198d4 100644 --- a/src/ui/styles/canvas.css +++ b/src/ui/styles/canvas.css @@ -251,11 +251,6 @@ .viz-control > .cropping-control { flex: 0.6 1 50px; width: auto; min-width: 50px; } .cropping-control > input { width: 100%; display: block; } .manual-position-controls { display: flex; flex: none; gap: .25rem; align-items: center; margin-left: auto; } -.manual-position-controls button { - font: inherit; font-size: .75rem; line-height: 18px; height: 20px; - padding: 0 .4rem; white-space: nowrap; - color: var(--brand-blue-dark); background: #f5f8fb; - border: 1px solid #cbd7e2; border-radius: 4px; cursor: pointer; -} +.manual-position-controls button { cursor: pointer; } .manual-position-controls button:hover:enabled { background: #e8f0f8; } .manual-position-controls button:disabled { color: #8b929a; background: #f3f4f5; cursor: default; } diff --git a/src/ui/styles/dialogs.css b/src/ui/styles/dialogs.css index 7680b8c..32c3a0c 100644 --- a/src/ui/styles/dialogs.css +++ b/src/ui/styles/dialogs.css @@ -134,18 +134,3 @@ dialog.png-export-dialog { .png-hint:last-child { margin-bottom: 0; } .png-export-error { margin: 8px 0 0; color: #a12622; font-size: 13px; } .png-export-dialog button:disabled { opacity: 0.6; cursor: wait; } - -dialog.canvas-help-dialog { - inset: 0; - margin: auto; - width: 540px; - min-width: 0; - max-width: calc(100vw - 32px); - max-height: calc(100vh - 32px); - overflow: auto; - font-size: 13px; - line-height: 1.45; -} -.canvas-help-dialog .dialog-header { cursor: default; } -.canvas-help-dialog dl { margin: 12px 0; } -.canvas-help-dialog dt { font-weight: 700; margin-top: 8px; } From ad0f55fea8231d1759d89285e29967c30ad9d00d Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Wed, 7 Oct 2026 15:56:18 +0200 Subject: [PATCH 08/11] Allow static linear layouts independently of force animation --- docs/force-field-graph.md | 6 +- docs/sharing-and-input.md | 4 +- docs/static-linear-layout.md | 41 +++++++ docs/viewer-guide.md | 4 +- scripts/browser-invariants.js | 3 + scripts/browser-static-linear.js | 49 ++++++++ scripts/browser-ui.js | 6 +- src/README.md | 4 +- src/core/canvas/layout/linear-helix.js | 18 ++- src/core/canvas/render.js | 7 +- src/ui/components/visualization-settings.js | 16 +-- src/ui/field-actions.js | 3 - src/ui/navigation-actions.js | 1 - src/ui/render-actions.js | 9 +- tests/static-linear-layout.test.js | 82 +++++++++++++ tests/ui-bootstrap.test.js | 128 ++++++-------------- tests/ui-structure.test.js | 2 + 17 files changed, 261 insertions(+), 122 deletions(-) create mode 100644 docs/static-linear-layout.md create mode 100644 scripts/browser-static-linear.js create mode 100644 tests/static-linear-layout.test.js diff --git a/docs/force-field-graph.md b/docs/force-field-graph.md index 6d55bb5..010ae2c 100644 --- a/docs/force-field-graph.md +++ b/docs/force-field-graph.md @@ -61,7 +61,11 @@ Linear RRI and intramolecular options construct two-rail helix templates from the validated pair structure. Projection preserves handedness, handles bulges and interior loops, and respects fixed nodes. Index labels receive a bounded outward adjustment. RRI horizontalization is a rigid graph rotation, and the -viewport refits once at force convergence. +viewport refits once at force convergence. Both linear options also work with +Force layout disabled: the same constraints settle for at most 60 synchronous +ticks, then the force and its constraint listeners stop before rendering is +shown. This creates a static linear layout without fixing its nodes; dragging, +release, reset, and undo remain available. ## Drawing and lifecycle diff --git a/docs/sharing-and-input.md b/docs/sharing-and-input.md index f33d21b..fc6e439 100644 --- a/docs/sharing-and-input.md +++ b/docs/sharing-and-input.md @@ -19,8 +19,8 @@ See the [viewer guide](viewer-guide.md) and the [Input Format Reference](#input- | **`colorRriNodes` / `colorRriRegion` / `colorBasepair`** | Hex color codes for nucleotide highlights, background highlights, and base pairs. | | **`distinctBpTypes`** | Toggle display of G-U Wobble base pairs as dashed lines (`true` / `false`). | | **`forceLayout`** | Enable or disable the force-layout physics simulation (`true` / `false`). | -| **`forceLayoutLinearRRI`** | Enforce a linear horizontal layout of all noncrossing RRI helices. Enabling it also enables `forceLayout`. | -| **`forceLayoutLinearStructure`** | Enforce a linear layout of intramolecular stems containing bulges or interior loops. Enabling it also enables `forceLayout`. | +| **`forceLayoutLinearRRI`** | Enforce a linear horizontal layout of all noncrossing RRI helices. Independent of `forceLayout`; when it is off, the linear layout settles briefly and then stops. | +| **`forceLayoutLinearStructure`** | Enforce a linear layout of intramolecular stems containing bulges or interior loops. Independent of `forceLayout`; when it is off, the linear layout settles briefly and then stops. | | **`textAnnotations`** | JSON definitions with text, styles, positions, and sequence-name identities. An explicit `[]` leaves names unpositioned; use Share Link to generate this value. | To simplify sequence and structure input validation, sequence and structure inputs are highlighted with the chosen strand-specific colors. diff --git a/docs/static-linear-layout.md b/docs/static-linear-layout.md new file mode 100644 index 0000000..c252c58 --- /dev/null +++ b/docs/static-linear-layout.md @@ -0,0 +1,41 @@ +# Static linear layouts (issue #101) + +Both linear options are independent of Force layout in the UI, URL state, +examples, and standalone rendering API. They appear above Force layout in +Visualization Settings. Disabling Force layout keeps either linear option +selected; free trailing ends and pseudoknot pulling remain force-only options. + +The existing rail projection and D3 engine are unchanged. With animation off, +applicable helices settle for at most 60 synchronous force ticks. The renderer +then removes linear tick/end listeners and stops the force before returning +control. Annotation placement and viewport fitting use the settled coordinates. +No nodes are fixed by this initialization, and no background overlay animation +is started. Manual moves, release, reset, undo, exports, cancellation, and +rerendering keep their static-layout behavior. + +## Geometry review + +Compared ordinary static rendering against both linear options enabled with +Force layout off on 7 October 2026, using Chromium 153.0.8010.12, an 800×600 +render-only viewport, seed 83, and each example's existing cropping/annotations. +The source is based on `a6743a2` with the changes in this issue's pull request. +Screenshots and measurements were saved under +`output/playwright/static-linear-review/` for inspection, not as new baselines. + +- `2mol`: the RRI axis becomes horizontal; the intramolecular stem straightens. + All 47 graph nucleotides and the colored annotations remain visible. +- `coronel-tellez-2022`: the bent interaction becomes horizontal; all 92 graph + nucleotides, probability colors, labels, and region overlays remain present. +- `wu-2024`: the interaction straightens while retaining the large unpaired loop, + all 61 graph nucleotides, signed biological labels, and mutation labels. +- `IntaRNA-seeds`: the long interaction becomes horizontal with its unpaired + loops and seed highlights retained; all 127 graph nucleotides remain present. +- `crossing-rri`: the existing crossing-pair restriction leaves the 30-node graph + unchanged because it has no applicable noncrossing rail template. + +All ten compared scenes stayed motionless after rendering. The original five +visual regression fixtures still match exactly with linear options disabled; +no baselines were replaced. Automated tests additionally check each linear +option separately, both together, unpaired/crossing inputs, rail spacing, +listener cleanup, cancellation, native/bundle parity, static release/undo, +SVG output, and URL/example/control state. diff --git a/docs/viewer-guide.md b/docs/viewer-guide.md index 4834cca..605d96b 100644 --- a/docs/viewer-guide.md +++ b/docs/viewer-guide.md @@ -76,11 +76,11 @@ Open the **Example** dropdown to choose another RNA-RNA interaction. Each open m | **Base pair color** | Chose the color used for all base pairs (intra- and intermolecular). | | **Color Choice** | Use the color pickers to customize the highlighting colors. | | **G-U basepairs dashed** | When checked, G-U basepairs are drawn with a dashed stroke. | +| **Linear horizontal RRI layout** | Keeps all noncrossing intermolecular helixes on straight linear layout and rotates the complete two-molecule interaction so its RRI axis is horizontal. Works with or without Force layout; when it is off, the linear layout settles briefly and then stops. | +| **Linear intramolecular stem layout** | Keeps intramolecular stems (i.e. helices containing bulges or interior loops) on straight linear layout. Stems stop at multiloops or crossing pairs. Works with or without Force layout; when it is off, the linear layout settles briefly and then stops. | | **Force layout** | When checked, the rendered structure is shown in an interactive force-directed layout. When unchecked, the structure is drawn in a fixed layout. | -| - **Linear horizontal RRI layout** | Keeps all noncrossing intermolecular helixes on straight linear layout and rotates the complete two-molecule interaction so its RRI axis is horizontal. Enabling it also enables the force layout. | | - **Free trailing ends** | When checked, the trailing ends of the sequences are not fixed in the force-directed layout and can move freely. | | - **Pull Pseudoknot Basepairs** | When checked, pseudoknot basepairs are pulled together in the force-directed layout. | -| - **Linear intramolecular stem layout** | Keeps intramolecular stems (i.e. helices containing bulges or interior loops) on straight linear layout. Stems stop at multiloops or crossing pairs. Enabling it also enables the force layout. | ### Region Highlights diff --git a/scripts/browser-invariants.js b/scripts/browser-invariants.js index f1e7ccd..258f2ca 100644 --- a/scripts/browser-invariants.js +++ b/scripts/browser-invariants.js @@ -4,6 +4,7 @@ import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import { chromium } from 'playwright'; +import { exerciseStaticLinearLayouts } from './browser-static-linear.js'; import { exercisePositionEditing } from './browser-position-editing.js'; import { exerciseInteractions } from './browser-interactions.js'; import { exerciseCoreTextAnnotations } from './browser-text-core.js'; @@ -132,6 +133,8 @@ async function main() { else assert.ok(bytes.toString().includes(' { + const api = window.testApi.createVaRRI(); + window.staticLinearApi = api; + const structure = '((..(&)..))((..(..)..))'; + const v = api.validate({ sequence: structure.replace(/[^&]/g, 'A'), structure, + startIndex1: '-2', startIndex2: '7', backgroundhighlighting: 'region' }); + await api.render('first', v, { forceLayout: false, + forceLayoutLinearRRI: true, forceLayoutLinearStructure: true }); + const read = () => [...document.querySelectorAll('#first circle[node_type="nucleotide"]')] + .map(el => { const n = el.__data__; return { num: n.num, x: n.x, y: n.y, fixed: Boolean(n.fixed) }; }); + const before = read(); + await new Promise(resolve => setTimeout(resolve, 250)); + const after = read(); + return { before, after, svg: api.buildSVGString('first') }; + }); + assert.equal(scene.before.length, 22); + assert.deepEqual(scene.after, scene.before, 'Static linear coordinates remain frozen after rendering'); + assert.ok(scene.before.every(node => !node.fixed && Number.isFinite(node.x) && Number.isFinite(node.y))); + const nodes = new Map(scene.before.map(node => [node.num, node])); + const y = nodes.get(1).y; + for (const [a, b] of [[1, 10], [2, 9], [5, 6]]) { + assert.ok(Math.abs(nodes.get(a).y - y) < 1e-6, 'RRI rail is horizontal'); + assert.ok(Math.abs(Math.hypot(nodes.get(a).x - nodes.get(b).x, nodes.get(a).y - nodes.get(b).y) - 15) < 1e-6); + } + assert.ok(scene.svg.includes(' window.staticLinearApi.getCanvasInteractionState().movedCount), 1); + await page.evaluate(() => window.staticLinearApi.selectManuallyPositionedElements()); + await page.evaluate(() => window.staticLinearApi.releaseSelectedPositions()); + const released = await node.evaluate(el => [el.__data__.x, el.__data__.y]); + await page.waitForTimeout(150); + assert.deepEqual(await node.evaluate(el => [el.__data__.x, el.__data__.y]), released, + 'Release must not restart static forces'); + await page.evaluate(() => { window.staticLinearApi.undoCanvasEdit(); window.staticLinearApi.undoCanvasEdit(); }); + const restored = await node.evaluate(el => [el.__data__.x, el.__data__.y]); + assert.deepEqual(restored, [scene.before[0].x, scene.before[0].y], 'Undo restores the static linear placement'); + await page.evaluate(() => window.staticLinearApi.cancelActiveRender()); + return scene.before; +} diff --git a/scripts/browser-ui.js b/scripts/browser-ui.js index e29801f..2bd9bb2 100644 --- a/scripts/browser-ui.js +++ b/scripts/browser-ui.js @@ -179,9 +179,11 @@ async function main() { await ready(page); assert.equal((await page.locator('#profileCounterUI').textContent()).trim(), '(1)'); await openPanel(page, '#forceLayoutLinearRRI'); + await page.locator('#forceLayout').uncheck(); + await ready(page); await page.locator('#forceLayoutLinearRRI').check(); await ready(page); - assert.equal(await page.locator('#forceLayout').isChecked(), true); + assert.equal(await page.locator('#forceLayout').isChecked(), false); await page.locator('#rotationSlider').evaluate(element => { element.value = '35'; element.dispatchEvent(new Event('input', { bubbles: true })); @@ -198,6 +200,7 @@ async function main() { const params = new URL(shared).searchParams; assert.equal(params.get('profileData1'), '1 0.2\n2 0.8'); assert.equal(params.get('forceLayoutLinearRRI'), '1'); + assert.equal(params.get('forceLayout'), '0'); assert.equal(params.get('rotation'), '35'); for (const key of ['subseqHighlights', 'regionHighlights', 'mutations']) assert.ok(params.get(key), key + ' must be shared'); assert.match(params.get('subseqHighlights'), /^1:1-1,3-4:[0-9a-f]{6}:0\.3$/i); @@ -207,6 +210,7 @@ async function main() { assert.equal((await page.locator('#' + id).textContent()).trim(), '(1)', id + ': restored state'); } assert.equal(await page.locator('#forceLayoutLinearRRI').isChecked(), true); + assert.equal(await page.locator('#forceLayout').isChecked(), false); assert.ok((await page.locator('#highlight-list').textContent()).includes('1-1,3-4'), 'All ranges stay in one restored annotation'); assert.equal(await page.locator('#rotationSlider').inputValue(), '0'); assert.ok((await page.locator('#rotation').textContent()).includes('35')); diff --git a/src/README.md b/src/README.md index 218e912..48ad6e4 100644 --- a/src/README.md +++ b/src/README.md @@ -87,8 +87,8 @@ linear-layout listeners, and resolves any pending render as cancelled. | Option | Type | Default | Description | |---|---|---|---| | `forceLayout` | `boolean` | `false` | Enable D3 force-layout animation. | -| `forceLayoutLinearStructure` | `boolean` | `false` | Apply a rigid two-rail constraint independently to intramolecular stems containing bulges or interior loops. Requires `forceLayout`. | -| `forceLayoutLinearRRI` | `boolean` | `false` | Keep a noncrossing RRI helix on two parallel rails and rotate the complete two-molecule interaction so its axis is horizontal. Requires `forceLayout`. | +| `forceLayoutLinearStructure` | `boolean` | `false` | Apply a rigid two-rail constraint independently to intramolecular stems containing bulges or interior loops. Works with or without `forceLayout`; static layouts settle briefly and then stop. | +| `forceLayoutLinearRRI` | `boolean` | `false` | Keep a noncrossing RRI helix on two parallel rails and rotate the complete two-molecule interaction so its axis is horizontal. Works with or without `forceLayout`; static layouts settle briefly and then stop. | | `freeTrailingEnds` | `boolean` | `false` | Relax the external-loop closure scaffold when force layout is active. | | `pullPseudoknotBasepairs` | `boolean` | `false` | Increase pseudoknot link strength when force layout is active. | | `accessData` | `Object\|null` | `null` | Node-ID to probability map. | diff --git a/src/core/canvas/layout/linear-helix.js b/src/core/canvas/layout/linear-helix.js index a5ac6af..6f83e41 100644 --- a/src/core/canvas/layout/linear-helix.js +++ b/src/core/canvas/layout/linear-helix.js @@ -20,11 +20,12 @@ export function clearLinearHelixConstraintState(container) { /** * Apply rigid, invisible two-rail constraints for the requested RRI and/or - * intramolecular helices, then restart the live D3 force once. + * intramolecular helices. Static layouts settle for a bounded number of ticks + * before returning; animated layouts keep enforcing their rails as they move. * * @param {Object} container Live graph canvas. * @param {Object} v Validated parameter dictionary. - * @param {{rri?:boolean,structure?:boolean}} [options] + * @param {{rri?:boolean,structure?:boolean,animate?:boolean}} [options] * @returns {number} Number of measured same-strand loop-span constraints. */ export function applyLinearHelixSprings(session, container, v, options = {}) { @@ -79,6 +80,19 @@ export function applyLinearHelixSprings(session, container, v, options = {}) { if (typeof container.force.start === 'function') { container.force.start(); } + if (options.animate === false) { + // Synchronous ticks avoid a timer-dependent layout and finish before the + // render becomes interactive. Leave nodes free for later manual edits. + try { + for (let tick = 0; tick < 60 && typeof container.force.tick === 'function'; tick++) { + if (container.force.tick()) break; + } + } finally { + clearLinearHelixConstraintState(container); + container.force.stop?.(); + } + enforceAndSync(); + } } return activeConstraints.length; } diff --git a/src/core/canvas/render.js b/src/core/canvas/render.js index ea0c1e0..add71bf 100644 --- a/src/core/canvas/render.js +++ b/src/core/canvas/render.js @@ -94,10 +94,11 @@ export function render(session, containerId, v, options = {}) { if (forceLayout && pullPseudoknotBasepairs) { applyPseudoknotLinkStrength(container, true); } - if (forceLayout && (forceLayoutLinearRRI || forceLayoutLinearStructure)) { + if (forceLayoutLinearRRI || forceLayoutLinearStructure) { applyLinearHelixSprings(session, container, v, { rri: forceLayoutLinearRRI, - structure: forceLayoutLinearStructure + structure: forceLayoutLinearStructure, + animate: forceLayout }); } function applyModifications() { @@ -145,7 +146,7 @@ export function render(session, containerId, v, options = {}) { // Linear-helix constraints may extend the initial bounds. Refit // after the first force ticks and annotation updates. - if (forceLayout && (forceLayoutLinearRRI || forceLayoutLinearStructure) && typeof container.centerView === 'function') { + if ((forceLayoutLinearRRI || forceLayoutLinearStructure) && typeof container.centerView === 'function') { container.centerView(); } diff --git a/src/ui/components/visualization-settings.js b/src/ui/components/visualization-settings.js index 65eee7f..d4c11e0 100644 --- a/src/ui/components/visualization-settings.js +++ b/src/ui/components/visualization-settings.js @@ -35,13 +35,17 @@ export default viewerComponent('visualization-settings', `

`); diff --git a/src/ui/field-actions.js b/src/ui/field-actions.js index 6a31374..9b5dafa 100644 --- a/src/ui/field-actions.js +++ b/src/ui/field-actions.js @@ -50,9 +50,6 @@ export function createFieldActions({ state, actions, document }) { if (id === 'hideFooterAndHeader') { actions.applyBodyClasses(); return; } if (id === 'seqName1' || id === 'seqName2') { actions.commitSequenceName(id); return; } if (validateDraft(id)) return; - if ((id === 'forceLayoutLinearRRI' || id === 'forceLayoutLinearStructure') && state.fields[id]) { - actions.enableForceLayoutForSelectedLinearOptions(); - } if (id.startsWith('forceLayout')) actions.syncAnimationDependentControls(); actions.runVisualization(); } diff --git a/src/ui/navigation-actions.js b/src/ui/navigation-actions.js index bd9e7bd..91ac3c1 100644 --- a/src/ui/navigation-actions.js +++ b/src/ui/navigation-actions.js @@ -63,7 +63,6 @@ export function createNavigationActions({ api, state, defaults, initialColors, a .map(key => [key, state.fields[key]])); if (Object.keys(names).length) api.setSequenceNames(names); actions.syncAnnotations(); - actions.enableForceLayoutForSelectedLinearOptions(); actions.syncAnimationDependentControls(); applyBodyClasses(); if (revealProfilePanel && Object.keys(decoded.fields).some(key => key.startsWith('profileData'))) { diff --git a/src/ui/render-actions.js b/src/ui/render-actions.js index fe5f03f..162606b 100644 --- a/src/ui/render-actions.js +++ b/src/ui/render-actions.js @@ -52,8 +52,8 @@ export function createRenderActions({ api, state, actions, document }) { onCanvasInteractionChange: summary => { if (run === latestRun) state.canvasInteraction = summary; }, onTextAnnotationsChange: () => actions.syncAnnotations(), forceLayout: f.forceLayout, - forceLayoutLinearRRI: f.forceLayout && f.forceLayoutLinearRRI, - forceLayoutLinearStructure: f.forceLayout && f.forceLayoutLinearStructure, + forceLayoutLinearRRI: f.forceLayoutLinearRRI, + forceLayoutLinearStructure: f.forceLayoutLinearStructure, freeTrailingEnds: f.forceLayout && f.forceLayoutFreeTails, pullPseudoknotBasepairs: f.forceLayout && f.forceLayoutPullCrossing, accessData, @@ -82,12 +82,9 @@ export function createRenderActions({ api, state, actions, document }) { latestRun++; api.cancelActiveRender(); state.canvasInteraction = api.getCanvasInteractionState(); }, - enableForceLayoutForSelectedLinearOptions() { - if (state.fields.forceLayoutLinearRRI || state.fields.forceLayoutLinearStructure) state.fields.forceLayout = true; - }, syncAnimationDependentControls() { if (state.fields.forceLayout) return; - ['forceLayoutLinearRRI', 'forceLayoutLinearStructure', 'forceLayoutFreeTails', 'forceLayoutPullCrossing'] + ['forceLayoutFreeTails', 'forceLayoutPullCrossing'] .forEach(id => { state.fields[id] = false; }); }, exportSVG() { diff --git a/tests/static-linear-layout.test.js b/tests/static-linear-layout.test.js new file mode 100644 index 0000000..5889e5a --- /dev/null +++ b/tests/static-linear-layout.test.js @@ -0,0 +1,82 @@ +import { jest } from '@jest/globals'; +import { JSDOM } from 'jsdom'; +import { createVaRRI } from '../src/core/index.js'; +import { createGraphCanvas } from '../src/core/canvas/graph/index.js'; + +const rriPairs = [[1, 10], [2, 9], [5, 6]]; +const stemPairs = [[1, 12], [2, 11], [5, 8]]; +const fixtures = [ + ['RRI', '((..(&)..))', true, false, [rriPairs]], + ['stem', '((..(..)..))', false, true, [stemPairs]], + ['both', '((..(&)..))((..(..)..))', true, true, + [rriPairs, stemPairs.map(pair => pair.map(n => n + 10))]], + ['unpaired', '....&....', true, true, []], + ['crossing RRI', '([&)]', true, true, []], +]; + +const positions = container => container.graph.nodes.map(({ x, y, px, py }) => [x, y, px, py]); + +function expectRails(nodes, pairs, horizontal) { + const columns = pairs.map(pair => pair.map(n => nodes.find(node => node.num === n))); + const centers = columns.map(([a, b]) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 })); + const axis = { x: centers.at(-1).x - centers[0].x, y: centers.at(-1).y - centers[0].y }; + const length = Math.hypot(axis.x, axis.y); + expect(length).toBeGreaterThan(1); + if (horizontal) expect(Math.abs(axis.y)).toBeLessThan(1e-6); + for (const [index, [a, b]] of columns.entries()) { + expect(Math.hypot(a.x - b.x, a.y - b.y)).toBeCloseTo(15, 6); + expect(((a.x - b.x) * axis.x + (a.y - b.y) * axis.y) / length).toBeCloseTo(0, 6); + const center = centers[index]; + expect(((center.x - centers[0].x) * axis.y - (center.y - centers[0].y) * axis.x) / length).toBeCloseTo(0, 6); + } +} + +test.each(fixtures)('%s linear layout settles, renders, and stays stopped without Force layout', + async (_name, structure, rri, stem, groups) => { + const dom = new JSDOM('
', { pretendToBeVisual: true }); + let container; + const api = createVaRRI({ document: dom.window.document, + createCanvas(root, options) { container = createGraphCanvas(root, options); return container; } }); + const animation = jest.spyOn(dom.window, 'requestAnimationFrame'); + try { + const v = api.validate({ sequence: structure.replace(/[^&]/g, 'A'), structure }); + await api.render('rna', v, { forceLayout: false, + forceLayoutLinearRRI: rri, forceLayoutLinearStructure: stem }); + expect(container.options.animation).toBe(false); + expect(container.force.alpha()).toBe(0); + expect(container.force.on('tick.varriLinearHelix')).toBeUndefined(); + expect(container.force.on('end.varriLinearHelix')).toBeUndefined(); + expect(container.graph.nodes.every(node => !node.fixed)).toBe(true); + expect(positions(container).flat().every(Number.isFinite)).toBe(true); + expect(container.element.querySelectorAll('circle[node_type="nucleotide"]')) + .toHaveLength(structure.replace('&', '').length); + groups.forEach((pairs, index) => expectRails(container.graph.nodes, pairs, rri && index === 0)); + const snapshot = positions(container); + animation.mockClear(); + await new Promise(resolve => setTimeout(resolve, 80)); + expect(positions(container)).toEqual(snapshot); + expect(animation).not.toHaveBeenCalled(); + const exported = api.buildSVGString('rna'); + expect(exported).toContain(' { + const dom = new JSDOM('
'); + const containers = []; + const api = createVaRRI({ document: dom.window.document, createCanvas(root, options) { + const container = createGraphCanvas(root, options); containers.push(container); return container; + } }); + try { + const v = api.validate({ sequence: 'AAAAA&UUUUU', structure: '((..(&)..))' }); + const pending = api.render('rna', v, { forceLayoutLinearRRI: true }); + const replacement = api.render('rna', v); + await expect(pending).resolves.toEqual({ cancelled: true }); + await expect(replacement).resolves.toEqual({ cancelled: false }); + expect(containers[0].destroyed).toBe(true); + expect(containers.every(container => container.force.alpha() === 0)).toBe(true); + expect(containers[1].graph.nodes.some(node => node.varriLinearHelix)).toBe(false); + expect(dom.window.document.querySelectorAll('#rna svg')).toHaveLength(1); + } finally { api.cancelActiveRender(); dom.window.close(); } +}); diff --git a/tests/ui-bootstrap.test.js b/tests/ui-bootstrap.test.js index 9437af7..c0ee43d 100644 --- a/tests/ui-bootstrap.test.js +++ b/tests/ui-bootstrap.test.js @@ -47,6 +47,10 @@ test('boots through bound UI actions without inline handlers', async () => { return button; }; + const forceLayout = document.getElementById('forceLayout'); + forceLayout.checked = true; + forceLayout.dispatchEvent(new window.Event('change')); + await flush(); freeTails.checked = true; freeTails.dispatchEvent(new window.Event('change')); pullCrossing.checked = true; @@ -135,118 +139,60 @@ test('boots through bound UI actions without inline handlers', async () => { await close(); }); -test('linear layout controls enable force, survive example loading, share, and forward render flags', async () => { - const { dom, renderSpy, flush, close } = await mountViewer({ +test('linear controls preserve the force choice through examples, toggles, and sharing', async () => { + const { renderSpy, flush, close } = await mountViewer({ modifyExamples(examples) { - const featureOverview = examples['2mol']; - examples['linear-test'] = { - ...featureOverview, - name: 'Linear layout test', nameShort: 'Linear layout test', - descriptionShort: 'Exercises the linear RRI option.', - vaRRIParams: { ...featureOverview.vaRRIParams, forceLayout: '0', forceLayoutLinearRRI: '1' }, - }; + examples['linear-test'] = { ...examples['2mol'], name: 'Linear layout test', + nameShort: 'Linear layout test', descriptionShort: 'Static linear RRI.', + vaRRIParams: { ...examples['2mol'].vaRRIParams, forceLayout: '0', forceLayoutLinearRRI: '1' } }; }, }); const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); - - const forceLayout = document.getElementById('forceLayout'); - const linearStructure = document.getElementById('forceLayoutLinearStructure'); - const linearRri = document.getElementById('forceLayoutLinearRRI'); - const options = document.getElementById('exampleDropdownOptions'); - - expect(linearStructure.checked).toBe(false); - expect(linearRri.checked).toBe(true); - expect(linearStructure.disabled).toBe(false); - expect(linearRri.disabled).toBe(false); - - options.querySelector('[data-example="linear-test"]').click(); + const force = document.getElementById('forceLayout'); + const rri = document.getElementById('forceLayoutLinearRRI'); + const structure = document.getElementById('forceLayoutLinearStructure'); + document.querySelector('[data-example="linear-test"]').click(); await flush(); - - expect(forceLayout.checked).toBe(true); - expect(linearStructure.checked).toBe(false); - expect(linearRri.checked).toBe(true); - expect(renderSpy).toHaveBeenLastCalledWith( - expect.any(String), - expect.any(Object), - expect.objectContaining({ - forceLayout: true, - forceLayoutLinearStructure: false, - forceLayoutLinearRRI: true, - }) - ); - - options.querySelector('[data-example="2mol"]').click(); + expect(force.checked).toBe(false); + expect(rri.checked).toBe(true); + expect(renderSpy).toHaveBeenLastCalledWith(expect.any(String), expect.any(Object), + expect.objectContaining({ forceLayout: false, forceLayoutLinearRRI: true, forceLayoutLinearStructure: false })); + structure.checked = true; + structure.dispatchEvent(new window.Event('change')); await flush(); - expect(linearStructure.checked).toBe(false); - expect(linearRri.checked).toBe(true); - - linearStructure.checked = true; - linearStructure.dispatchEvent(new window.Event('change')); - await flush(); - expect(forceLayout.checked).toBe(true); - expect(renderSpy).toHaveBeenLastCalledWith( - expect.any(String), - expect.any(Object), - expect.objectContaining({ - forceLayout: true, - forceLayoutLinearStructure: true, - forceLayoutLinearRRI: true, - }) - ); - - linearRri.checked = true; - linearRri.dispatchEvent(new window.Event('change')); - await flush(); - expect(forceLayout.checked).toBe(true); - expect(renderSpy).toHaveBeenLastCalledWith( - expect.any(String), - expect.any(Object), - expect.objectContaining({ - forceLayout: true, - forceLayoutLinearStructure: true, - forceLayoutLinearRRI: true, - }) - ); - + expect(force.checked).toBe(false); + for (const checked of [true, false]) { + force.checked = checked; + force.dispatchEvent(new window.Event('change')); + await flush(); + expect(rri.checked).toBe(true); + expect(structure.checked).toBe(true); + expect(rri.disabled || structure.disabled).toBe(false); + expect(renderSpy).toHaveBeenLastCalledWith(expect.any(String), expect.any(Object), + expect.objectContaining({ forceLayout: checked, forceLayoutLinearRRI: true, forceLayoutLinearStructure: true })); + } document.getElementById('openVarriBtn').click(); await flush(); - const sharedUrl = new URL(openSpy.mock.calls.at(-1)[0]); - expect(sharedUrl.searchParams.get('forceLayoutLinearStructure')).toBe('1'); - expect(sharedUrl.searchParams.get('forceLayoutLinearRRI')).toBe('1'); - - forceLayout.checked = false; - forceLayout.dispatchEvent(new window.Event('change')); - await flush(); - expect(linearStructure.checked).toBe(false); - expect(linearRri.checked).toBe(false); - expect(linearStructure.disabled).toBe(false); - expect(linearRri.disabled).toBe(false); - expect(renderSpy).toHaveBeenLastCalledWith( - expect.any(String), - expect.any(Object), - expect.objectContaining({ - forceLayout: false, - forceLayoutLinearStructure: false, - forceLayoutLinearRRI: false, - }) - ); - + const shared = new URL(openSpy.mock.calls.at(-1)[0]); + expect(shared.searchParams.get('forceLayout')).toBe('0'); + expect(shared.searchParams.get('forceLayoutLinearRRI')).toBe('1'); + expect(shared.searchParams.get('forceLayoutLinearStructure')).toBe('1'); await close(); }); test.each([ 'forceLayoutLinearStructure', 'forceLayoutLinearRRI', -])('URL-loaded %s enables force layout before the initial render', async optionId => { +])('URL-loaded %s preserves a static layout before the initial render', async optionId => { const url = `http://localhost/?sequence=AAAA&structure=....&forceLayout=0&${optionId}=1`; const { renderSpy, close } = await mountViewer({ url }); expect(document.getElementById(optionId).checked).toBe(true); - expect(document.getElementById('forceLayout').checked).toBe(true); + expect(document.getElementById('forceLayout').checked).toBe(false); expect(renderSpy).toHaveBeenLastCalledWith( expect.any(String), expect.any(Object), - expect.objectContaining({ forceLayout: true, [optionId]: true }) + expect.objectContaining({ forceLayout: false, [optionId]: true }) ); await close(); diff --git a/tests/ui-structure.test.js b/tests/ui-structure.test.js index 401838a..929c211 100644 --- a/tests/ui-structure.test.js +++ b/tests/ui-structure.test.js @@ -87,6 +87,8 @@ describe('UI document structure', () => { ); expect(mountedHtml.indexOf('id="forceLayoutLinearRRI"')) .toBeLessThan(mountedHtml.indexOf('id="forceLayoutLinearStructure"')); + expect(mountedHtml.indexOf('id="forceLayoutLinearStructure"')) + .toBeLessThan(mountedHtml.indexOf('id="forceLayout"')); }); test('documents every public API function', () => { From a7ea4e92d72ab916e39c73118978ba43105f37c4 Mon Sep 17 00:00:00 2001 From: Martin Raden Date: Wed, 7 Oct 2026 18:18:10 +0200 Subject: [PATCH 09/11] details --- src/ui/components/canvas-help-dialog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/components/canvas-help-dialog.js b/src/ui/components/canvas-help-dialog.js index 674069a..e873987 100644 --- a/src/ui/components/canvas-help-dialog.js +++ b/src/ui/components/canvas-help-dialog.js @@ -18,7 +18,7 @@ export default viewerComponent('canvas-help-dialog', `
Rotate
Select at least two elements, then Ctrl/Command-scroll to rotate them around the mouse pointer. Text stays upright. Ordinary scrolling zooms, and dragging the background pans.
-
Moved
The count includes manually positioned nucleotides and numbering labels. Click it to select them all.
+
Moved: X
The count 'X' includes manually positioned nucleotides and numbering labels. Click it to select them all.
Release
Unfix selected nodes at their current positions. They resume moving when Force layout is enabled. Released nodes leave the Moved count. Text labels are unaffected.
Reset
Restore selected manually positioned nodes to their positions and fixation before their first edit. From 6e64e465327177941c490a999c661a8fb7b0fc27 Mon Sep 17 00:00:00 2001 From: Martin Raden Date: Wed, 7 Oct 2026 18:29:41 +0200 Subject: [PATCH 10/11] increase number of ticks for better stabilization --- src/core/canvas/layout/linear-helix.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/canvas/layout/linear-helix.js b/src/core/canvas/layout/linear-helix.js index 6f83e41..ab8bf01 100644 --- a/src/core/canvas/layout/linear-helix.js +++ b/src/core/canvas/layout/linear-helix.js @@ -84,7 +84,7 @@ export function applyLinearHelixSprings(session, container, v, options = {}) { // Synchronous ticks avoid a timer-dependent layout and finish before the // render becomes interactive. Leave nodes free for later manual edits. try { - for (let tick = 0; tick < 60 && typeof container.force.tick === 'function'; tick++) { + for (let tick = 0; tick < 200 && typeof container.force.tick === 'function'; tick++) { if (container.force.tick()) break; } } finally { From 59f4a0c96b1bd12b3dede37a7ab588baefd4beda Mon Sep 17 00:00:00 2001 From: Martin Raden Date: Wed, 7 Oct 2026 18:32:24 +0200 Subject: [PATCH 11/11] update test --- scripts/browser-canvas-help.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/browser-canvas-help.js b/scripts/browser-canvas-help.js index e3e1859..f5fdb95 100644 --- a/scripts/browser-canvas-help.js +++ b/scripts/browser-canvas-help.js @@ -49,7 +49,7 @@ export async function checkCanvasHelpStyles(page) { assert.ok(Math.abs(box.y + box.height / 2 - viewport.height / 2) < 2, 'Help is vertically centered'); assert.equal(await dialog.locator('dt').first().evaluate(el => getComputedStyle(el).fontWeight), '700'); const keys = dialog.locator('dt .canvas-position-key'); - assert.deepEqual(await keys.allTextContents(), ['Moved', 'Release', 'Reset', 'Undo']); + assert.deepEqual(await keys.allTextContents(), ['Moved: X', 'Release', 'Reset', 'Undo']); const appearances = await keys.evaluateAll(elements => elements.map(el => { const style = getComputedStyle(el); return { border: style.border, radius: style.borderRadius, padding: style.padding,