From f465abc189186e82832c21e8b32a595c7e9d0973 Mon Sep 17 00:00:00 2001 From: Nabil-Fareed Alikhan Date: Thu, 6 Aug 2026 18:12:58 +0100 Subject: [PATCH] Make PNG export match preview --- components/CircularPlot.tsx | 12 ++++++-- components/ExportPanel.tsx | 30 +++++------------- src/pages/Home.tsx | 5 +-- tests/e2e/brigx.spec.ts | 61 +++++++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 27 deletions(-) diff --git a/components/CircularPlot.tsx b/components/CircularPlot.tsx index da061ca..1600449 100644 --- a/components/CircularPlot.tsx +++ b/components/CircularPlot.tsx @@ -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'; @@ -12,6 +12,7 @@ interface CircularPlotProps { onViewStateChange?: (state: PlotViewState) => void; squarePlot?: boolean; centreViewSignal?: number; + exportCanvasRef?: RefObject; } interface TooltipInfo extends PlotTooltip { @@ -19,7 +20,7 @@ interface TooltipInfo extends PlotTooltip { 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(null); const rendererRef = useRef(null); const [tooltip, setTooltip] = useState(null); @@ -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; @@ -290,7 +296,7 @@ export default function CircularPlot({ data, imageProperties, onViewStateChange, style={{ cursor: draggingLegend ? 'move' : isDragging ? 'grabbing' : 'grab', background: 'var(--gx-bg-alt)' }} > {tooltip && tooltip.x != null && tooltip.y != null && ( diff --git a/components/ExportPanel.tsx b/components/ExportPanel.tsx index 8b1a78e..80c78d3 100644 --- a/components/ExportPanel.tsx +++ b/components/ExportPanel.tsx @@ -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'; @@ -10,6 +10,7 @@ interface ExportPanelProps { plotData: CircularPlotData; imageProperties: ImagePropertiesConfig; viewState?: PlotViewState | null; + plotCanvasRef?: RefObject; } // The interactive canvas uses a fixed 1000x1000 logical coordinate system. @@ -71,7 +72,7 @@ function canvasToBlob(canvas: HTMLCanvasElement, type: string): Promise { }); } -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); @@ -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; @@ -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`); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 0e52d3c..1b97190 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -53,6 +53,7 @@ export default function Home() { const [searchParams] = useSearchParams(); const sessionUrl = searchParams.get('url'); const loadedSessionUrlRef = useRef(null); + const plotCanvasRef = useRef(null); const [plotViewState, setPlotViewState] = useReactState(null); const [plotCentreSignal, setPlotCentreSignal] = useReactState(0); const [bugReportOpen, setBugReportOpen] = useReactState(false); @@ -128,14 +129,14 @@ export default function Home() { )} - {plotData && } + {plotData && } {displayedPlotData ? (
- +
) : ( diff --git a/tests/e2e/brigx.spec.ts b/tests/e2e/brigx.spec.ts index f55b052..5c3303a 100644 --- a/tests/e2e/brigx.spec.ts +++ b/tests/e2e/brigx.spec.ts @@ -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('[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)