Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions components/CircularPlot.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@


import { useEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useState, useCallback, type RefObject } from 'react';
import type { CircularPlotData, PlotViewState } from '@/lib/types';
import type { ImagePropertiesConfig } from './ImageProperties';
import { CanvasPlotRenderer } from '@/lib/canvas-renderer';
Expand All @@ -12,14 +12,15 @@ interface CircularPlotProps {
onViewStateChange?: (state: PlotViewState) => void;
squarePlot?: boolean;
centreViewSignal?: number;
exportCanvasRef?: RefObject<HTMLCanvasElement | null>;
}

interface TooltipInfo extends PlotTooltip {
x: number;
y: number;
}

export default function CircularPlot({ data, imageProperties, onViewStateChange, squarePlot = false, centreViewSignal = 0 }: CircularPlotProps) {
export default function CircularPlot({ data, imageProperties, onViewStateChange, squarePlot = false, centreViewSignal = 0, exportCanvasRef }: CircularPlotProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rendererRef = useRef<CanvasPlotRenderer | null>(null);
const [tooltip, setTooltip] = useState<TooltipInfo | null>(null);
Expand All @@ -44,6 +45,11 @@ export default function CircularPlot({ data, imageProperties, onViewStateChange,
const previousCentreViewSignal = useRef(centreViewSignal);
onViewStateChangeRef.current = onViewStateChange;

const setCanvasElement = useCallback((canvas: HTMLCanvasElement | null) => {
canvasRef.current = canvas;
if (exportCanvasRef) exportCanvasRef.current = canvas;
}, [exportCanvasRef]);

/** Emit current view state to parent (for SVG export). */
const emitViewState = useCallback(() => {
const cb = onViewStateChangeRef.current;
Expand Down Expand Up @@ -290,7 +296,7 @@ export default function CircularPlot({ data, imageProperties, onViewStateChange,
style={{ cursor: draggingLegend ? 'move' : isDragging ? 'grabbing' : 'grab', background: 'var(--gx-bg-alt)' }}
>
<canvas
ref={canvasRef}
ref={setCanvasElement}
style={{ display: 'block', maxWidth: '100%', maxHeight: '100%', background: 'white', boxShadow: '0 0 0 1px var(--gx-border)' }}
/>
{tooltip && tooltip.x != null && tooltip.y != null && (
Expand Down
30 changes: 8 additions & 22 deletions components/ExportPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@


import { useState } from 'react';
import { useState, type RefObject } from 'react';
import toast from 'react-hot-toast';
import type { CircularPlotData, PlotViewState } from '@/lib/types';
import { CircularPlotRenderer } from '@/lib/renderer';
Expand All @@ -10,6 +10,7 @@ interface ExportPanelProps {
plotData: CircularPlotData;
imageProperties: ImagePropertiesConfig;
viewState?: PlotViewState | null;
plotCanvasRef?: RefObject<HTMLCanvasElement | null>;
}

// The interactive canvas uses a fixed 1000x1000 logical coordinate system.
Expand Down Expand Up @@ -71,7 +72,7 @@ function canvasToBlob(canvas: HTMLCanvasElement, type: string): Promise<Blob> {
});
}

export default function ExportPanel({ plotData, imageProperties, viewState }: ExportPanelProps) {
export default function ExportPanel({ plotData, imageProperties, viewState, plotCanvasRef }: ExportPanelProps) {
const [isExporting, setIsExporting] = useState(false);
const exportSVG = () => {
const svgString = renderPlotSVG(plotData, imageProperties, viewState);
Expand All @@ -83,9 +84,11 @@ export default function ExportPanel({ plotData, imageProperties, viewState }: Ex
const exportPNG = async () => {
setIsExporting(true);
try {
const svgString = renderPlotSVG(plotData, imageProperties, viewState);
const sourceCanvas = plotCanvasRef?.current;
if (!sourceCanvas) throw new Error('The plot canvas is not ready');

// Convert SVG to PNG
// Rasterise the live interactive canvas so PNG output preserves the
// exact current zoom, pan, labels and dragged legend positions.
const canvas = document.createElement('canvas');
canvas.width = 1200;
canvas.height = 1200;
Expand All @@ -98,24 +101,7 @@ export default function ExportPanel({ plotData, imageProperties, viewState }: Ex
// Fill white background
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);

// Create image from SVG
const img = new Image();
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);

await new Promise((resolve, reject) => {
img.onload = () => {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(url);
resolve(undefined);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load SVG image'));
};
img.src = url;
});
ctx.drawImage(sourceCanvas, 0, 0, canvas.width, canvas.height);

const pngBlob = await canvasToBlob(canvas, 'image/png');
downloadBlob(pngBlob, `brig-plot-${Date.now()}.png`);
Expand Down
5 changes: 3 additions & 2 deletions src/pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export default function Home() {
const [searchParams] = useSearchParams();
const sessionUrl = searchParams.get('url');
const loadedSessionUrlRef = useRef<string | null>(null);
const plotCanvasRef = useRef<HTMLCanvasElement | null>(null);
const [plotViewState, setPlotViewState] = useReactState<PlotViewState | null>(null);
const [plotCentreSignal, setPlotCentreSignal] = useReactState(0);
const [bugReportOpen, setBugReportOpen] = useReactState(false);
Expand Down Expand Up @@ -128,14 +129,14 @@ export default function Home() {
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" /></svg>
)}
</button>
{plotData && <ExportPanel plotData={plotData} imageProperties={imageProperties} viewState={plotViewState} />}
{plotData && <ExportPanel plotData={plotData} imageProperties={imageProperties} viewState={plotViewState} plotCanvasRef={plotCanvasRef} />}
</div>
</div>

{displayedPlotData ? (
<div className={plotExpanded ? 'flex-1 min-h-0' : ''}>
<ErrorBoundary>
<CircularPlot data={displayedPlotData} imageProperties={imageProperties} onViewStateChange={handleViewStateChange} centreViewSignal={plotCentreSignal} />
<CircularPlot data={displayedPlotData} imageProperties={imageProperties} onViewStateChange={handleViewStateChange} centreViewSignal={plotCentreSignal} exportCanvasRef={plotCanvasRef} />
</ErrorBoundary>
</div>
) : (
Expand Down
61 changes: 61 additions & 0 deletions tests/e2e/brigx.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,67 @@ test.describe('BRIGX e2e — circular genome plot', () => {
await expect(page.getByRole('button', { name: 'Expand plot' })).toBeVisible()
})

test('PNG export matches the live canvas after moving a legend', async ({ page }) => {
await page.goto('/app')
await page.getByLabel('Reference genome file').setInputFiles(REFERENCE)
await expect(page.getByRole('heading', { name: 'Statistics' })).toBeVisible({ timeout: 30_000 })

const plotArea = page.getByTestId('plot-area')
await plotArea.scrollIntoViewIfNeeded()
const canvas = plotArea.locator('canvas')
const canvasBox = await canvas.boundingBox()
expect(canvasBox).not.toBeNull()

// Move the GC legend well away from its default corner, then zoom the map.
const scale = canvasBox!.width / 1000
const legendX = canvasBox!.x + 60 * scale
const legendY = canvasBox!.y + 45 * scale
await page.mouse.move(legendX, legendY)
await page.mouse.down()
await page.mouse.move(legendX + 360 * scale, legendY + 260 * scale, { steps: 8 })
await page.mouse.up()
await page.mouse.move(canvasBox!.x + canvasBox!.width / 2, canvasBox!.y + canvasBox!.height / 2)
await page.mouse.wheel(0, 100)
await page.mouse.wheel(0, 100)
await expect(page.getByTestId('plot-zoom')).toHaveText('81%')

// Observe the pixels handed to the PNG encoder and compare them with the
// live canvas rendered to the same 1200 x 1200 output dimensions.
await page.evaluate(() => {
const originalToBlob = HTMLCanvasElement.prototype.toBlob
HTMLCanvasElement.prototype.toBlob = function (callback, type, quality) {
if (this.width === 1200 && this.height === 1200) {
const source = document.querySelector<HTMLCanvasElement>('[data-testid="plot-area"] canvas')
const expected = document.createElement('canvas')
expected.width = 1200
expected.height = 1200
const expectedContext = expected.getContext('2d')!
expectedContext.fillStyle = 'white'
expectedContext.fillRect(0, 0, expected.width, expected.height)
expectedContext.drawImage(source!, 0, 0, expected.width, expected.height)

const expectedPixels = expectedContext.getImageData(0, 0, 1200, 1200).data
const actualPixels = this.getContext('2d')!.getImageData(0, 0, 1200, 1200).data
let matches = true
for (let index = 0; index < expectedPixels.length; index += 1) {
if (expectedPixels[index] !== actualPixels[index]) {
matches = false
break
}
}
document.body.dataset.pngExportMatchesCanvas = String(matches)
}
return originalToBlob.call(this, callback, type, quality)
}
})

const downloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: 'PNG', exact: true }).click()
const download = await downloadPromise
expect(download.suggestedFilename()).toMatch(/^brig-plot-\d+\.png$/)
await expect(page.locator('body')).toHaveAttribute('data-png-export-matches-canvas', 'true')
})

test('legend and zoom resets are separate controls', async ({ page }) => {
await page.goto('/app')
await page.getByLabel('Reference genome file').setInputFiles(REFERENCE)
Expand Down