-
- Each ring can contain sequence files, graph data or a custom feature overlay. Ring order runs from the inside out.
-
-
+
+
+
+
+ {effectiveFloating && !minimised && (
+
+ )}
);
}
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 53d5218..2d4cb53 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -6,7 +6,6 @@ This walkthrough produces a basic whole-genome comparison. No installation or se
Open the BRIGX workspace and choose a reference file in **Reference Genome**. FASTA, GenBank and GBFF files are accepted, including gzipped variants.
-- GenBank or GBFF CDS features are displayed automatically.
- Multi-FASTA records are joined into one circular coordinate system.
- GC content and GC skew are calculated from the reference.
@@ -21,6 +20,8 @@ In **Ring Configuration**, select **Add New Ring**.
Several files in one ring are treated as one combined query. Create separate rings when you want separate legend entries or colours.
+On desktop, select **Float panel** beside **Ring Configuration** to keep the controls over the workspace while inspecting the plot. Drag its header to move it, use the bottom-right handle to resize it, or select **Dock** to return it to the page. The panel remains docked on small screens.
+
## 3. Run the alignment
Select **Run Alignments**. Progress and diagnostic messages appear in the debug console. The work is performed by browser workers, so a large comparison can take time without blocking the rest of the page.
@@ -38,7 +39,6 @@ Select **Run Alignments**. Progress and diagnostic messages appear in the debug
- **PNG** creates a raster image.
- **Data** exports the computed plot data as JSON.
- **Save session** stores an editable BRIGX session, including the result when available.
-- **Preview result** opens a temporary read-only view in the same browser.
+- **Viewer** in the top navigation opens a read-only result or session from a local file or public GitHub URL.
-!!! warning
- A local preview URL is temporary and is not a shareable publication link. See [Sessions and sharing](reference/sessions-and-sharing.md) for public GitHub-hosted sessions.
+See [Sessions and sharing](reference/sessions-and-sharing.md) for public GitHub-hosted sessions.
diff --git a/docs/guides/custom-annotations.md b/docs/guides/custom-annotations.md
index b024f59..91264d0 100644
--- a/docs/guides/custom-annotations.md
+++ b/docs/guides/custom-annotations.md
@@ -28,7 +28,7 @@ Rows can be pasted from a spreadsheet as tab-separated values.
Use the import controls inside **Custom Ring Overlay** to extract features from GenBank or GFF3. Review coordinates and labels after import, especially if the annotation file was generated against a different reference build.
-Embedded CDS annotations from a GenBank/GBFF reference are handled automatically and do not need to be imported as a custom overlay.
+Annotations embedded in a GenBank/GBFF reference are not displayed automatically. Import the features you want through **Custom Ring Overlay**.
!!! warning
An annotation interval is drawn against reference coordinates. BRIGX does not lift coordinates between assemblies.
diff --git a/docs/guides/whole-genome-comparison.md b/docs/guides/whole-genome-comparison.md
index 55074e1..65cc41a 100644
--- a/docs/guides/whole-genome-comparison.md
+++ b/docs/guides/whole-genome-comparison.md
@@ -6,7 +6,7 @@ This is the standard BRIGX workflow: one reference genome in the centre and one
Use a complete or well-characterised genome when possible. The reference defines every coordinate around the circle; changing it changes the visual and biological frame of the comparison.
-FASTA is sufficient for sequence comparison. Use GenBank or GBFF when you also want embedded CDS annotations on the reference track.
+FASTA, GenBank and GBFF references can all be used for sequence comparison. To display selected annotations, import them explicitly through **Custom Ring Overlay**.
## Configure the rings
diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md
index 82abbd9..bde6145 100644
--- a/docs/reference/file-formats.md
+++ b/docs/reference/file-formats.md
@@ -3,7 +3,7 @@
| Format | Extensions | Use |
|---|---|---|
| FASTA | `.fasta`, `.fa`, `.fna` | Reference or query sequences |
-| GenBank | `.gbk`, `.gb`, `.gbff`, `.genbank` | Sequences and embedded features |
+| GenBank | `.gbk`, `.gb`, `.gbff`, `.genbank` | Reference or query sequences; embedded features can be imported into a custom ring overlay |
| Gzip | `.gz` variants | Compressed FASTA or GenBank inputs |
| BRIG graph | `.graph` | Coordinate-value graph tracks |
| BedGraph | `.bedgraph` | Coordinate-value graph tracks |
diff --git a/docs/reference/sessions-and-sharing.md b/docs/reference/sessions-and-sharing.md
index 0968a37..de793df 100644
--- a/docs/reference/sessions-and-sharing.md
+++ b/docs/reference/sessions-and-sharing.md
@@ -8,13 +8,9 @@ Select **Save session** to download a JSON session file. Select **Load session**
Keep the original genome files as part of the analysis record even when a result is embedded in the session.
-## Local preview
-
-After a result exists, **Preview result** opens a clean, read-only viewer. The generated link is stored in the current browser for 24 hours. It is useful for checking presentation but is not shareable with another person or browser.
-
## Public GitHub session
-The preview page can load a public BRIGX result or session from a GitHub file URL. A viewer can inspect the result without editing it, then choose **Edit session** when the source contains an editable session.
+The **Viewer** can load a public BRIGX result or session from a GitHub file URL. It shows the interactive plot, reference and query statistics, per-ring coverage and average identity. Where the saved result contains the original tabular BLAST output, each query ring also provides a **Download** action. A viewer can inspect and download these results without editing them, then choose **Edit session** when the source contains an editable session.
Only publish session files whose sequence and metadata are safe to make public.
@@ -26,4 +22,8 @@ An editable workspace can load a public session through the `url` query paramete
/app?url=https://github.com/OWNER/REPOSITORY/blob/BRANCH/session.json
```
-The read-only preview page accepts the same public GitHub file URL through its form.
+The read-only viewer accepts the same public GitHub file URL through its form or through the `url` query parameter:
+
+```text
+/viewer?url=https://github.com/OWNER/REPOSITORY/blob/BRANCH/session.json
+```
diff --git a/lib/controller.ts b/lib/controller.ts
index f347b82..cd3c946 100644
--- a/lib/controller.ts
+++ b/lib/controller.ts
@@ -9,10 +9,8 @@ import type {
PipelineParams,
ProgressUpdate,
ContigBoundary,
- GraphPoint,
- Feature
+ GraphPoint
} from './types';
-import { extractGenBankFeatures } from './featureParser';
import { parseSAMCoverage } from '@/lib/samParser';
import { parseGraphFile } from '@/lib/graphParser';
import { normaliseFileAccessError, readFileText } from '@/lib/fileAccess';
@@ -364,26 +362,6 @@ export class BRIGController {
}
console.log(`[Controller] Reference: ${reference.name}, ${(reference.length / 1_000_000).toFixed(2)} Mbp`);
- // Extract features from GenBank reference if applicable
- let referenceFeatures: Feature[] = [];
- const refExt = referenceFile.name.toLowerCase();
- if (refExt.match(/\.(gbk|gb|genbank|gbff)(\.gz)?$/)) {
- try {
- const refText = await readFileText(referenceFile);
- const annots = extractGenBankFeatures(refText, 'CDS');
- referenceFeatures = annots.map(a => ({
- type: 'CDS',
- start: a.start,
- end: a.end,
- strand: (a.shape === 'arrow-forward' ? '+' : '-') as '+' | '-',
- name: a.label,
- }));
- console.log(`[Controller] Extracted ${referenceFeatures.length} CDS features from reference`);
- } catch (e) {
- console.warn('[Controller] Failed to extract reference features:', e);
- }
- }
-
console.log('[Controller] Step 2: Calculating GC content and skew');
this.updateProgress('Calculating GC content and skew', 10);
// Adapt window size to reference length: aim for ~500-5000 windows
@@ -406,7 +384,6 @@ export class BRIGController {
length: reference.length,
gcContent,
gcSkew,
- features: referenceFeatures,
contigs: contigBoundaries
},
rings: [], // Empty rings array initially
@@ -651,7 +628,6 @@ export class BRIGController {
length: reference.length,
gcContent,
gcSkew,
- features: referenceFeatures,
contigs: contigBoundaries
},
rings: [...ringDataArray], // Send all rings processed so far
@@ -674,7 +650,6 @@ export class BRIGController {
length: reference.length,
gcContent,
gcSkew,
- features: referenceFeatures,
contigs: contigBoundaries
},
rings: ringDataArray,
diff --git a/lib/resultPreviewStore.ts b/lib/resultPreviewStore.ts
deleted file mode 100644
index e2f3d3d..0000000
--- a/lib/resultPreviewStore.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { parseResultSnapshot, type ResultSnapshot } from './resultSnapshot';
-
-const DATABASE_NAME = 'brigx-result-previews';
-const STORE_NAME = 'snapshots';
-const DATABASE_VERSION = 1;
-const PREVIEW_MAX_AGE_MS = 24 * 60 * 60 * 1000;
-
-interface StoredPreview {
- id: string;
- snapshot: ResultSnapshot;
-}
-
-function openDatabase(): Promise
{
- return new Promise((resolve, reject) => {
- const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
- request.onupgradeneeded = () => {
- if (!request.result.objectStoreNames.contains(STORE_NAME)) {
- request.result.createObjectStore(STORE_NAME, { keyPath: 'id' });
- }
- };
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => reject(request.error ?? new Error('Could not open preview storage'));
- });
-}
-
-function transactionComplete(transaction: IDBTransaction): Promise {
- return new Promise((resolve, reject) => {
- transaction.oncomplete = () => resolve();
- transaction.onerror = () => reject(transaction.error ?? new Error('Preview storage failed'));
- transaction.onabort = () => reject(transaction.error ?? new Error('Preview storage was cancelled'));
- });
-}
-
-export function createPreviewId(): string {
- return crypto.randomUUID();
-}
-
-export async function saveResultPreview(id: string, snapshot: ResultSnapshot): Promise {
- const database = await openDatabase();
- try {
- const transaction = database.transaction(STORE_NAME, 'readwrite');
- const store = transaction.objectStore(STORE_NAME);
- store.put({ id, snapshot } satisfies StoredPreview);
-
- const allRequest = store.getAll();
- allRequest.onsuccess = () => {
- const cutoff = Date.now() - PREVIEW_MAX_AGE_MS;
- for (const entry of allRequest.result as StoredPreview[]) {
- if (entry.snapshot.createdAt < cutoff) store.delete(entry.id);
- }
- };
- await transactionComplete(transaction);
- } finally {
- database.close();
- }
-}
-
-export async function loadResultPreview(id: string): Promise {
- const database = await openDatabase();
- try {
- const transaction = database.transaction(STORE_NAME, 'readonly');
- const request = transaction.objectStore(STORE_NAME).get(id);
- const stored = await new Promise((resolve, reject) => {
- request.onsuccess = () => resolve(request.result as StoredPreview | undefined);
- request.onerror = () => reject(request.error ?? new Error('Could not read preview'));
- });
- if (!stored) return null;
- return parseResultSnapshot(stored.snapshot);
- } finally {
- database.close();
- }
-}
diff --git a/src/App.tsx b/src/App.tsx
index d1f42e4..5365b03 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,18 +1,23 @@
-import { Navigate, Route, Routes } from 'react-router-dom';
+import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import About from './pages/About';
import Home from './pages/Home';
import Landing from './pages/Landing';
-import Preview from './pages/Preview';
+import Viewer from './pages/Preview';
import Publication from './pages/Publication';
+function LegacyPreviewRedirect() {
+ const { search } = useLocation();
+ return ;
+}
+
export default function App() {
return (
} />
} />
} />
- } />
- } />
+ } />
+ } />
} />
} />
diff --git a/src/globals.css b/src/globals.css
index 0ae5215..be10012 100644
--- a/src/globals.css
+++ b/src/globals.css
@@ -12,6 +12,8 @@
--brigx-rule-strong: color-mix(in srgb, var(--gx-text) 34%, transparent);
--brigx-measure: 80rem;
--brigx-text-measure: 44rem;
+ --brigx-layer-floating-panel: 9000;
+ --brigx-layer-modal: 10000;
}
html {
@@ -228,6 +230,11 @@ input:focus-visible {
height: 100%;
}
+.publication-statistics {
+ max-width: 90rem;
+ margin: 0 auto;
+}
+
.publication-message {
display: grid;
place-content: center;
@@ -789,18 +796,13 @@ input:focus-visible {
.browser-session-inner {
display: flex;
- justify-content: space-between;
+ justify-content: flex-end;
align-items: center;
min-height: 2.5rem;
font-family: var(--gx-font-mono);
font-size: 0.72rem;
}
-.browser-session-inner > span {
- color: var(--gx-text-muted);
- text-transform: uppercase;
-}
-
.browser-session-inner > div {
display: flex;
}
@@ -835,6 +837,119 @@ input:focus-visible {
align-items: start;
}
+.rings-panel {
+ position: relative;
+ min-width: 0;
+ container-type: inline-size;
+}
+
+.rings-panel.is-floating {
+ position: fixed;
+ z-index: var(--brigx-layer-floating-panel);
+ max-width: calc(100vw - 24px);
+ max-height: calc(100vh - 24px);
+ overflow: hidden;
+ box-shadow: 0 24px 64px color-mix(in srgb, #000 34%, transparent);
+}
+
+.rings-panel-scroll-area {
+ height: 100%;
+ padding-right: 0.35rem;
+ padding-bottom: 1.75rem;
+ overflow: auto;
+ overscroll-behavior: contain;
+}
+
+.annotation-editor-backdrop {
+ z-index: var(--brigx-layer-modal);
+}
+
+.rings-panel.is-minimised {
+ overflow: hidden;
+}
+
+.ring-configuration-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.ring-configuration-header.is-draggable {
+ cursor: move;
+ touch-action: none;
+ user-select: none;
+}
+
+.ring-configuration-header-actions,
+.rings-panel-controls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.rings-panel-controls {
+ padding-right: 0.2rem;
+ border-right: 1px solid var(--gx-border);
+}
+
+.rings-panel-controls button {
+ min-height: 1.85rem;
+ padding-inline: 0.55rem;
+ color: var(--gx-text-muted);
+ background: color-mix(in srgb, var(--gx-bg) 75%, transparent);
+ border: 1px solid var(--gx-border);
+ border-radius: var(--gx-radius-sm);
+ font-size: 0.72rem;
+ font-weight: 650;
+}
+
+.rings-panel-controls button:hover,
+.rings-panel-controls button:focus-visible {
+ color: var(--gx-accent);
+ border-color: var(--gx-accent);
+}
+
+.rings-panel-resize-handle {
+ position: absolute;
+ right: 0.25rem;
+ bottom: 0.25rem;
+ width: 1.4rem;
+ height: 1.4rem;
+ padding: 0;
+ background: linear-gradient(135deg, transparent 45%, var(--gx-text-muted) 46% 54%, transparent 55% 64%, var(--gx-text-muted) 65% 73%, transparent 74%);
+ border: 0;
+ cursor: nwse-resize;
+ touch-action: none;
+}
+
+.rings-panel-restore {
+ display: flex;
+ justify-content: flex-end;
+}
+
+@container (max-width: 560px) {
+ .ring-configuration-header,
+ .ring-editor-header {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .ring-configuration-header-actions {
+ justify-content: space-between;
+ }
+
+ .ring-editor-card-actions {
+ align-self: flex-end;
+ }
+
+ .ring-editor-fields {
+ grid-template-columns: 1fr;
+ }
+}
+
.ring-editor-shell {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
@@ -1231,6 +1346,14 @@ input:focus-visible {
white-space: nowrap;
}
+.annotation-table thead th {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ background: var(--gx-bg-alt);
+ box-shadow: inset 0 -1px 0 var(--gx-border);
+}
+
@media (max-width: 520px) {
.console-error-heading {
flex-direction: column;
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index b5c7726..a958057 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -1,6 +1,6 @@
import { lazy, Suspense, useState as useReactState, useCallback, useEffect, useMemo, useRef } from 'react';
-import toast, { Toaster } from 'react-hot-toast';
+import { Toaster } from 'react-hot-toast';
import { useSearchParams } from 'react-router-dom';
import { LogConsole } from '@genomicx/ui';
import BrowserSessionBar from '@/components/BrowserSessionBar';
@@ -18,8 +18,6 @@ import ProductFooter from '@/components/ProductFooter';
import ProductNav from '@/components/ProductNav';
import { useBRIGController } from '@/hooks/useBRIGController';
import { formatVisibleConsoleLine, isUsefulConsoleLine } from '@/hooks/useConsoleCapture';
-import { createPreviewId, saveResultPreview } from '@/lib/resultPreviewStore';
-import { createResultSnapshot } from '@/lib/resultSnapshot';
import type { PlotViewState } from '@/lib/types';
const AnnotationEditor = lazy(() => import('@/components/AnnotationEditor'));
@@ -75,27 +73,6 @@ export default function Home() {
() => consoleLogs.filter(isUsefulConsoleLine).map(formatVisibleConsoleLine),
[consoleLogs],
);
- const handlePreview = useCallback(() => {
- if (!displayedPlotData) return;
- const id = createPreviewId();
- const previewUrl = `/preview/${id}`;
- const previewWindow = window.open('', '_blank');
- void saveResultPreview(id, createResultSnapshot(displayedPlotData, imageProperties))
- .then(() => {
- if (previewWindow) {
- previewWindow.opener = null;
- previewWindow.location.href = previewUrl;
- } else {
- window.location.href = previewUrl;
- }
- })
- .catch(error => {
- previewWindow?.close();
- console.error('Preview error:', error);
- toast.error('Could not create the read-only preview');
- });
- }, [displayedPlotData, imageProperties]);
-
useEffect(() => {
if (!sessionUrl || loadedSessionUrlRef.current === sessionUrl) return;
loadedSessionUrlRef.current = sessionUrl;
@@ -110,8 +87,6 @@ export default function Home() {
setBugReportOpen(true)}
/>
diff --git a/src/pages/Preview.tsx b/src/pages/Preview.tsx
index b7ea5e3..a6af81c 100644
--- a/src/pages/Preview.tsx
+++ b/src/pages/Preview.tsx
@@ -1,18 +1,18 @@
import { useEffect, useRef, useState } from 'react';
-import { Link, useParams, useSearchParams } from 'react-router-dom';
+import { Link, useSearchParams } from 'react-router-dom';
+import ProductNav from '@/components/ProductNav';
import ResultViewer from '@/components/ResultViewer';
import { readFileText } from '@/lib/fileAccess';
-import { loadResultPreview } from '@/lib/resultPreviewStore';
import { parseResultSnapshotJson, type ResultSnapshot } from '@/lib/resultSnapshot';
import { fetchGitHubJson } from '@/lib/remoteJson';
import { importSession } from '@/lib/session';
-interface PreviewPayload {
+interface ViewerPayload {
snapshot: ResultSnapshot;
editableSession: boolean;
}
-function parsePreviewPayload(json: string): PreviewPayload {
+function parseViewerPayload(json: string): ViewerPayload {
try {
const session = importSession(json);
if (!session.result) {
@@ -27,8 +27,7 @@ function parsePreviewPayload(json: string): PreviewPayload {
}
}
-export default function PreviewPage() {
- const { id } = useParams();
+export default function ViewerPage() {
const [searchParams, setSearchParams] = useSearchParams();
const sourceUrl = searchParams.get('url');
const [snapshot, setSnapshot] = useState(null);
@@ -38,27 +37,12 @@ export default function PreviewPage() {
const inputRef = useRef(null);
useEffect(() => {
- if (!id) return;
- let cancelled = false;
- loadResultPreview(id)
- .then(result => {
- if (cancelled) return;
- if (result) setSnapshot(result);
- else setError('This local preview is unavailable or has expired.');
- })
- .catch(() => {
- if (!cancelled) setError('This local preview could not be opened.');
- });
- return () => { cancelled = true; };
- }, [id]);
-
- useEffect(() => {
- if (id || !sourceUrl) return;
+ if (!sourceUrl) return;
let cancelled = false;
setSnapshot(null);
setError(null);
fetchGitHubJson(sourceUrl)
- .then(parsePreviewPayload)
+ .then(parseViewerPayload)
.then(payload => {
if (cancelled) return;
setSnapshot(payload.snapshot);
@@ -68,13 +52,13 @@ export default function PreviewPage() {
if (!cancelled) setError(cause instanceof Error ? cause.message : 'Could not load the GitHub file');
});
return () => { cancelled = true; };
- }, [id, sourceUrl]);
+ }, [sourceUrl]);
const handleFile = async (event: React.ChangeEvent) => {
const file = event.target.files?.[0];
if (!file) return;
try {
- const payload = parsePreviewPayload(await readFileText(file));
+ const payload = parseViewerPayload(await readFileText(file));
setSnapshot(payload.snapshot);
setEditableSession(payload.editableSession);
setError(null);
@@ -101,10 +85,11 @@ export default function PreviewPage() {
: undefined;
return (
<>
+
{fileInput}
inputRef.current?.click()}
editUrl={editUrl}
/>
@@ -112,39 +97,47 @@ export default function PreviewPage() {
);
}
- if ((id || sourceUrl) && !error) {
- return Opening read-only result… ;
+ if (sourceUrl && !error) {
+ return (
+ <>
+
+ Opening read-only result…
+ >
+ );
}
return (
-
- BRIGX result viewer
- {error ?? 'Open a read-only BRIGX result'}
- Paste a public GitHub session URL, or choose a BRIGX result or session file. Files are loaded directly in your browser.
-
- inputRef.current?.click()}>Choose result or session file
- Return to BRIGX
- {fileInput}
-
+ <>
+
+
+ BRIGX result viewer
+ {error ?? 'Open a read-only BRIGX result'}
+ Paste a public GitHub session URL, or choose a BRIGX result or session file. Files are loaded directly in your browser.
+
+ inputRef.current?.click()}>Choose result or session file
+ Return to BRIGX
+ {fileInput}
+
+ >
);
}
diff --git a/tests/e2e/brigx.spec.ts b/tests/e2e/brigx.spec.ts
index 976a2f7..493cec3 100644
--- a/tests/e2e/brigx.spec.ts
+++ b/tests/e2e/brigx.spec.ts
@@ -48,6 +48,7 @@ test.describe('BRIGX e2e — circular genome plot', () => {
await expect(page.getByTitle('Hex colour code')).toBeVisible()
await expect(page.getByTitle('Ring colour')).toBeVisible()
await expect(page.getByTitle('Remove ring')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Float panel' })).toHaveCount(0)
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
await expect(page.locator('.gx-console-body')).not.toContainText('[LOG]')
await expect(page.locator('.gx-console-body')).not.toContainText('[RingConfiguration]')
@@ -94,6 +95,88 @@ test.describe('BRIGX e2e — circular genome plot', () => {
await expect(card.locator('.ring-editor-fields')).toHaveCount(1)
})
+ test('ring configuration can float, move, resize, minimise, dock and close', async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 800 })
+ await page.goto('/app')
+
+ const panel = page.getByTestId('rings-panel')
+ await page.getByRole('button', { name: 'Float panel' }).click()
+ await expect(panel).toHaveClass(/is-floating/)
+
+ const initialBox = await panel.boundingBox()
+ const headingBox = await page.getByRole('heading', { name: 'Ring Configuration' }).boundingBox()
+ expect(initialBox).not.toBeNull()
+ expect(headingBox).not.toBeNull()
+
+ await page.mouse.move(headingBox!.x + headingBox!.width / 2, headingBox!.y + headingBox!.height / 2)
+ await page.mouse.down()
+ await page.mouse.move(headingBox!.x - 150, headingBox!.y + 90, { steps: 8 })
+ await page.mouse.up()
+
+ const movedBox = await panel.boundingBox()
+ expect(movedBox).not.toBeNull()
+ expect(movedBox!.x).toBeLessThan(initialBox!.x - 100)
+ expect(movedBox!.y).toBeGreaterThan(initialBox!.y + 50)
+
+ const resizeHandle = page.getByRole('button', { name: 'Resize Ring Configuration panel' })
+ const resizeBox = await resizeHandle.boundingBox()
+ expect(resizeBox).not.toBeNull()
+ await page.mouse.move(resizeBox!.x + resizeBox!.width / 2, resizeBox!.y + resizeBox!.height / 2)
+ await page.mouse.down()
+ await page.mouse.move(resizeBox!.x + 80, resizeBox!.y + 50, { steps: 6 })
+ await page.mouse.up()
+
+ const resizedBox = await panel.boundingBox()
+ expect(resizedBox).not.toBeNull()
+ expect(resizedBox!.width).toBeGreaterThan(movedBox!.width + 50)
+ expect(resizedBox!.height).toBeGreaterThan(movedBox!.height + 30)
+
+ await page.reload()
+ await page.getByRole('button', { name: 'Float panel' }).click()
+ const restoredFrameBox = await page.getByTestId('rings-panel').boundingBox()
+ expect(restoredFrameBox).not.toBeNull()
+ expect(Math.abs(restoredFrameBox!.x - resizedBox!.x)).toBeLessThan(3)
+ expect(Math.abs(restoredFrameBox!.y - resizedBox!.y)).toBeLessThan(3)
+ expect(Math.abs(restoredFrameBox!.width - resizedBox!.width)).toBeLessThan(3)
+ expect(Math.abs(restoredFrameBox!.height - resizedBox!.height)).toBeLessThan(3)
+
+ await page.getByRole('button', { name: 'Minimise' }).click()
+ await expect(panel).toHaveClass(/is-minimised/)
+ expect((await panel.boundingBox())!.height).toBeLessThan(120)
+ await expect(page.getByRole('button', { name: 'Add New Ring' })).toHaveCount(0)
+
+ await page.getByRole('button', { name: 'Restore' }).click()
+ await page.getByRole('button', { name: 'Dock' }).click()
+ await expect(panel).toHaveClass(/is-docked/)
+
+ await page.getByRole('button', { name: 'Close', exact: true }).click()
+ await expect(panel).toHaveCount(0)
+ await page.getByRole('button', { name: 'Show Ring Configuration' }).click()
+ await expect(page.getByTestId('rings-panel')).toHaveClass(/is-docked/)
+ })
+
+ test('floating ring panel keeps its resize handle pinned while rings scroll', async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 800 })
+ await page.goto('/app')
+
+ await page.getByRole('button', { name: 'Float panel' }).click()
+ for (let index = 0; index < 4; index += 1) {
+ await page.getByRole('button', { name: 'Add New Ring' }).click()
+ }
+
+ const panel = page.getByTestId('rings-panel')
+ const scrollArea = panel.locator('.rings-panel-scroll-area')
+ await expect(scrollArea).toBeVisible()
+ await scrollArea.evaluate(element => { element.scrollTop = element.scrollHeight })
+
+ const panelBox = await panel.boundingBox()
+ const handleBox = await page.getByRole('button', { name: 'Resize Ring Configuration panel' }).boundingBox()
+ expect(panelBox).not.toBeNull()
+ expect(handleBox).not.toBeNull()
+ expect(Math.abs(panelBox!.x + panelBox!.width - handleBox!.x - handleBox!.width)).toBeLessThan(8)
+ expect(Math.abs(panelBox!.y + panelBox!.height - handleBox!.y - handleBox!.height)).toBeLessThan(8)
+ })
+
test('opens a published comparison as an interactive read-only viewer', async ({ page }) => {
await page.goto('/publication/ecoli-comparison')
@@ -103,29 +186,29 @@ test.describe('BRIGX e2e — circular genome plot', () => {
await expect(page.getByRole('heading', { name: 'Reference Genome' })).toHaveCount(0)
})
- test('opens a generated plot as a browser-local read-only preview', async ({ page, context }) => {
- await page.goto('/app')
- await expect(page.getByRole('button', { name: 'Preview result' })).toBeDisabled()
- await page.getByLabel('Reference genome file').setInputFiles(REFERENCE)
- await expect(page.getByRole('heading', { name: 'Statistics' })).toBeVisible({ timeout: 30_000 })
-
- const previewPagePromise = context.waitForEvent('page')
- await page.getByRole('button', { name: 'Preview result' }).click()
- const previewPage = await previewPagePromise
- await previewPage.waitForURL(/\/preview\/[a-f0-9-]+$/)
-
- await expect(previewPage.getByText(/Local preview.*not shareable/)).toBeVisible()
- await expect(previewPage.getByRole('region', { name: 'Read-only interactive genome comparison' })).toBeVisible()
- await expect(previewPage.getByRole('button', { name: 'Download result' })).toBeVisible()
- await expect(previewPage.getByRole('heading', { name: 'Reference Genome' })).toHaveCount(0)
- })
-
test('opens a portable result file in the read-only viewer', async ({ page }) => {
- await page.goto('/preview')
+ await page.goto('/viewer')
+ await expect(page.getByRole('link', { name: 'Viewer' })).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Open a read-only BRIGX result' })).toBeVisible()
await page.getByLabel('BRIGX result file').setInputFiles(path.join(process.cwd(), 'public/publications/ecoli-comparison.json'))
await expect(page.getByRole('heading', { name: 'BRIGX Example' })).toBeVisible()
await expect(page.getByRole('region', { name: 'Read-only interactive genome comparison' })).toBeVisible()
+ await expect(page.getByText('BRIGX read-only result', { exact: true })).toHaveCount(0)
+ const statistics = page.getByRole('region', { name: 'Comparison statistics' })
+ await expect(statistics.getByRole('heading', { name: 'Statistics' })).toBeVisible()
+ await expect(statistics.getByText('Coverage: 78.5%')).toBeVisible()
+
+ const [download] = await Promise.all([
+ page.waitForEvent('download'),
+ statistics.getByRole('button', { name: 'Download' }).first().click(),
+ ])
+ expect(download.suggestedFilename()).toBe('Ring 1_alignment.txt')
+ const downloadPath = await download.path()
+ expect(downloadPath).not.toBeNull()
+ const alignmentText = await fs.readFile(downloadPath!, 'utf8')
+ expect(alignmentText).toMatch(/^#query\tsubject\t%identity\talignment_length/)
+ expect(alignmentText.split('\n').length).toBeGreaterThan(100)
})
test('previews a GitHub session URL as a read-only result', async ({ page }) => {
@@ -133,10 +216,10 @@ test.describe('BRIGX e2e — circular genome plot', () => {
path: EXAMPLE_SESSION,
contentType: 'application/json',
}))
- await page.goto(`/preview?url=${encodeURIComponent(GITHUB_SESSION_URL)}`)
+ await page.goto(`/viewer?url=${encodeURIComponent(GITHUB_SESSION_URL)}`)
await expect(page.getByRole('heading', { name: 'BRIGX Example' })).toBeVisible()
- await expect(page.getByText('Loaded from a public GitHub session · read-only preview')).toBeVisible()
+ await expect(page.getByText('Loaded from a public GitHub session · read-only viewer')).toBeVisible()
await expect(page.getByRole('region', { name: 'Read-only interactive genome comparison' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Edit session' })).toHaveAttribute(
'href',
@@ -156,7 +239,8 @@ test.describe('BRIGX e2e — circular genome plot', () => {
await expect(page.getByPlaceholder('Plot title...')).toHaveValue('BRIGX Example')
await expect(page.getByText('Ring 1', { exact: true })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Statistics' })).toBeVisible()
- await expect(page.getByRole('button', { name: 'Preview result' })).toBeEnabled()
+ await expect(page.getByRole('button', { name: 'Preview result' })).toHaveCount(0)
+ await expect(page.getByRole('link', { name: 'Viewer' })).toBeVisible()
const plotArea = page.getByTestId('plot-area')
await plotArea.scrollIntoViewIfNeeded()
@@ -246,8 +330,13 @@ test.describe('BRIGX e2e — circular genome plot', () => {
page.on('pageerror', error => pageErrors.push(error))
await page.getByRole('button', { name: 'Add New Ring' }).click()
+ await page.getByRole('button', { name: 'Float panel' }).click()
await page.getByRole('button', { name: 'Custom Ring Overlay' }).click()
- await expect(page.getByRole('heading', { name: 'Annotations for Ring 1' })).toBeVisible()
+ const annotationDialog = page.getByRole('dialog', { name: 'Annotations for Ring 1' })
+ await expect(annotationDialog).toBeVisible()
+ const modalLayer = await annotationDialog.evaluate(element => Number(getComputedStyle(element).zIndex))
+ const floatingPanelLayer = await page.getByTestId('rings-panel').evaluate(element => Number(getComputedStyle(element).zIndex))
+ expect(modalLayer).toBeGreaterThan(floatingPanelLayer)
await page.getByRole('button', { name: 'Add New', exact: true }).click()
const firstLabelCell = page.getByRole('textbox', { name: 'Label row 1' })