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/force-field-graph.md b/docs/force-field-graph.md index 2971760..46bffff 100644 --- a/docs/force-field-graph.md +++ b/docs/force-field-graph.md @@ -95,7 +95,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 e036cdf..1e3a0a0 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** | Pulls pseudoknot basepairs together and stabilizes stacked pairs 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. | +| - **Pull Pseudoknot Basepairs** | When checked, pulls pseudoknot basepairs together and stabilizes stacked pairs in the force-directed layout. | ### Region Highlights @@ -214,12 +214,15 @@ 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, Ctrl-click on the background, or a plain click on an element clears the selection. Command also works on macOS. +- **Moving elements**: Drag any nucleotide, numbering label, or text label in either layout mode. Drag a selected element to move the whole selection; dragging an unselected element clears the selection and moves that element alone. Released nodes and numbering labels stay in place until the next render, including in force mode. +- **Manual positions**: The **Moved** button beside Crop counts manually positioned nucleotides and numbering labels; click it to select them all. **Release** unfixes selected nodes at their current positions, allowing them to move again when Force layout is enabled, and removes them from the Moved count. **Reset** restores selected manual nodes to their positions and fixation before the first edit. Text labels are not counted, released, or reset. **Undo** (or Ctrl+Z / Command+Z while the canvas is focused) reverses the last drag, selection rotation, release, or reset. Undo a release before resetting that node to its original position. History is kept for the current rendering. The **?** button opens the full interaction guide. +- **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. ### Export diff --git a/scripts/browser-canvas-help.js b/scripts/browser-canvas-help.js new file mode 100644 index 0000000..f5fdb95 --- /dev/null +++ b/scripts/browser-canvas-help.js @@ -0,0 +1,69 @@ +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) { + 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 }); + ` })); + // 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' }); + 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 checkCanvasHelpStyles(page); + 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); + 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: 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, + 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-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-invariants.js b/scripts/browser-invariants.js index 1689e49..46fc4ad 100644 --- a/scripts/browser-invariants.js +++ b/scripts/browser-invariants.js @@ -4,6 +4,8 @@ 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'; import { exercisePseudoknotStacks } from './browser-pseudoknot-stacks.js'; @@ -132,8 +134,12 @@ async function main() { else assert.ok(bytes.toString().includes('