From 802885926c5858065e8146dc144ef55ba9727bc8 Mon Sep 17 00:00:00 2001 From: peymanvahidi Date: Fri, 26 Jun 2026 16:33:56 +0200 Subject: [PATCH 1/2] fix(figure-editor): always render the default unzoomed view (#294, #297) Editor now ignores the live transform and renders the fit-all view at all capture sites via a resetView flag; also resets zoom on isolation enter/exit to prevent stale state. Closes #294, #297 --- .../publish/publish-compositor.test.ts | 29 ++++++ .../components/publish/publish-compositor.ts | 7 +- .../components/publish/publish-modal.test.ts | 13 ++- .../src/components/publish/publish-modal.ts | 8 ++ .../scatter-plot.isolation.test.ts | 36 +++++++ .../components/scatter-plot/scatter-plot.ts | 23 ++++- .../webgl-renderer.export-transform.test.ts | 99 +++++++++++++++++++ .../webgl/renderer/webgl-renderer.ts | 7 +- 8 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.export-transform.test.ts diff --git a/packages/core/src/components/publish/publish-compositor.test.ts b/packages/core/src/components/publish/publish-compositor.test.ts index 083e7bc8..03580d0a 100644 --- a/packages/core/src/components/publish/publish-compositor.test.ts +++ b/packages/core/src/components/publish/publish-compositor.test.ts @@ -328,6 +328,35 @@ describe('publish-compositor', () => { expect(result).toBe(mockCanvas); }); + // The figure editor relies on this passthrough to force the default + // (unzoomed) view (#294); guard it so a dropped forward is caught. + it('forwards resetView to captureAtResolution', () => { + const mockCanvas = document.createElement('canvas'); + const seen: Array<{ resetView?: boolean }> = []; + const plotEl = document.createElement('div') as HTMLElement & { + captureAtResolution?: ( + w: number, + h: number, + opts: { resetView?: boolean }, + ) => HTMLCanvasElement; + }; + plotEl.captureAtResolution = (_w, _h, opts) => { + seen.push(opts); + return mockCanvas; + }; + + capturePlotCanvas(plotEl, { + width: 800, + height: 400, + backgroundColor: '#ffffff', + resetView: true, + }); + capturePlotCanvas(plotEl, { width: 800, height: 400, backgroundColor: '#ffffff' }); + + expect(seen[0].resetView).toBe(true); + expect(seen[1].resetView).toBeUndefined(); + }); + /** * jsdom doesn't implement canvas 2d. Instead of pixel sampling, spy on * the output canvas's 2d context and assert drawImage's call signature. diff --git a/packages/core/src/components/publish/publish-compositor.ts b/packages/core/src/components/publish/publish-compositor.ts index 1e830b56..479898e4 100644 --- a/packages/core/src/components/publish/publish-compositor.ts +++ b/packages/core/src/components/publish/publish-compositor.ts @@ -41,6 +41,10 @@ interface CaptureOptions { width: number; height: number; backgroundColor: string; + /** Render the default fit-all view, ignoring any live zoom/pan. The figure + * editor always captures the unzoomed view so a zoomed plot doesn't leak + * into the figure (and the zoom-inset mapping stays correct). */ + resetView?: boolean; } /** @@ -52,7 +56,7 @@ export function capturePlotCanvas( captureAtResolution?: ( w: number, h: number, - opts: { dpr?: number; backgroundColor?: string }, + opts: { dpr?: number; backgroundColor?: string; resetView?: boolean }, ) => HTMLCanvasElement; }, opts: CaptureOptions, @@ -61,6 +65,7 @@ export function capturePlotCanvas( return plotEl.captureAtResolution(opts.width, opts.height, { dpr: 1, backgroundColor: opts.backgroundColor, + resetView: opts.resetView, }); } // Fallback: grab whatever canvas the component has diff --git a/packages/core/src/components/publish/publish-modal.test.ts b/packages/core/src/components/publish/publish-modal.test.ts index 82451ca6..edcc3e40 100644 --- a/packages/core/src/components/publish/publish-modal.test.ts +++ b/packages/core/src/components/publish/publish-modal.test.ts @@ -819,10 +819,14 @@ describe(' plot cache key', () => { }); it('invalidates the plot cache when background toggles white ↔ transparent', async () => { - const captures: Array<{ bg: string }> = []; + const captures: Array<{ bg: string; resetView?: boolean }> = []; const fakePlotEl = { - captureAtResolution: (w: number, h: number, opts: { backgroundColor?: string }) => { - captures.push({ bg: opts.backgroundColor ?? '' }); + captureAtResolution: ( + w: number, + h: number, + opts: { backgroundColor?: string; resetView?: boolean }, + ) => { + captures.push({ bg: opts.backgroundColor ?? '', resetView: opts.resetView }); const c = document.createElement('canvas'); c.width = w; c.height = h; @@ -865,6 +869,9 @@ describe(' plot cache key', () => { expect(captures.some((c) => c.bg === '#ffffff')).toBe(true); expect(captures.some((c) => c.bg === 'rgba(0,0,0,0)')).toBe(true); + // #294: the editor preview always captures the default (unzoomed) view. + expect(captures.length).toBeGreaterThan(0); + expect(captures.every((c) => c.resetView === true)).toBe(true); modal.remove(); } finally { HTMLCanvasElement.prototype.getContext = origGetContext; diff --git a/packages/core/src/components/publish/publish-modal.ts b/packages/core/src/components/publish/publish-modal.ts index f9537383..f33790b2 100644 --- a/packages/core/src/components/publish/publish-modal.ts +++ b/packages/core/src/components/publish/publish-modal.ts @@ -86,6 +86,7 @@ interface CaptureablePlotElement extends HTMLElement { backgroundColor?: string; dataDomain?: { xMin: number; xMax: number; yMin: number; yMax: number }; pointSizeReference?: { width: number; height: number }; + resetView?: boolean; }, ) => HTMLCanvasElement; getDataExtent?: (options?: { @@ -372,6 +373,8 @@ export class ProtspacePublishModal extends LitElement { width: plotRect.w, height: plotRect.h, backgroundColor: bgColor, + // Always render the unzoomed, fit-all view in the editor (#294). + resetView: true, }); this._plotCacheKey = cacheKey; } @@ -616,6 +619,9 @@ export class ProtspacePublishModal extends LitElement { width: plotRect.w * pointScale, height: plotRect.h * pointScale, }, + // dataDomain is derived from the full (default-view) extent, so the + // live zoom must not be re-applied on top of it (#294). + resetView: true, }); this._insetRenderCache.set(key, canvas); this._lastInsetCanvases[i] = canvas; @@ -722,6 +728,8 @@ export class ProtspacePublishModal extends LitElement { width: plotRect.w, height: plotRect.h, backgroundColor: bgColor, + // Export the unzoomed, fit-all view, matching the live preview (#294). + resetView: true, }); // Per-inset geometric renders for the export. diff --git a/packages/core/src/components/scatter-plot/scatter-plot.isolation.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.isolation.test.ts index 653df5bd..c556429b 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.isolation.test.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.isolation.test.ts @@ -202,6 +202,7 @@ describe('scatter-plot isolation render-refresh sequence', () => { _reprocessAndRefresh(): void; isolateSelection(): void; resetIsolation(): void; + resetZoom(): void; }; function buildData(): VisualizationData { @@ -317,4 +318,39 @@ describe('scatter-plot isolation render-refresh sequence', () => { el.resetIsolation(); expect(spy).toHaveBeenCalledTimes(2); }); + + // #297: zooming into a region and then isolating should snap back to the full + // view of the isolated subset, not keep the stale pre-isolation zoom transform. + it('isolateSelection resets the zoom to the full view', () => { + const el = makeEl(); + el.selectedProteinIds = ['p1', 'p3']; + const resetZoom = vi.spyOn(el, 'resetZoom'); + + el.isolateSelection(); + + expect(resetZoom).toHaveBeenCalledTimes(1); + }); + + it('does not reset the zoom when isolateSelection bails (no valid selection)', () => { + const el = makeEl(); + el.selectedProteinIds = []; + const resetZoom = vi.spyOn(el, 'resetZoom'); + + el.isolateSelection(); + + expect(resetZoom).not.toHaveBeenCalled(); + }); + + // Symmetry with isolateSelection: exiting isolation restores the full dataset, + // so the view should also snap back to the full extent (#297). + it('resetIsolation resets the zoom to the full view', () => { + const el = makeEl(); + el._isolationMode = true; + el._isolationHistory = [['p1', 'p3']]; + const resetZoom = vi.spyOn(el, 'resetZoom'); + + el.resetIsolation(); + + expect(resetZoom).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/core/src/components/scatter-plot/scatter-plot.ts b/packages/core/src/components/scatter-plot/scatter-plot.ts index 024eb128..86aac754 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.ts @@ -1919,6 +1919,11 @@ export class ProtspaceScatterplot extends LitElement { this._reprocessAndRefresh(); + // Snap back to the full view of the isolated subset. Without this, a zoom + // applied before isolating lingers and the isolated points render under a + // stale transform instead of filling the plot (#297). + this.resetZoom(); + this.dispatchEvent( new CustomEvent('data-isolation', { detail: { @@ -2004,6 +2009,11 @@ export class ProtspaceScatterplot extends LitElement { this._reprocessAndRefresh(); + // Exiting isolation restores the full dataset, so snap back to the full + // view — mirrors isolateSelection() and the data-change reset so a zoom + // applied inside the isolated view doesn't linger over the full plot (#297). + this.resetZoom(); + this.dispatchEvent( new CustomEvent('data-isolation-reset', { detail: { @@ -2110,6 +2120,10 @@ export class ProtspaceScatterplot extends LitElement { * pass the source plot's render size so dots stay the same visual size * as in the main plot, instead of shrinking with the inset. */ pointSizeReference?: { width: number; height: number }; + /** Ignore the live zoom/pan transform and render the default fit-all + * view (what a double-click reset shows). The figure editor sets this + * so a zoomed-in plot doesn't leak into the exported/preview figure. */ + resetView?: boolean; } = {}, ): HTMLCanvasElement { if (!this._webglRenderer) { @@ -2120,7 +2134,13 @@ export class ProtspaceScatterplot extends LitElement { throw new Error('Width and height must be positive numbers'); } - const { dpr = 1, backgroundColor = '#ffffff', dataDomain, pointSizeReference } = options; + const { + dpr = 1, + backgroundColor = '#ffffff', + dataDomain, + pointSizeReference, + resetView = false, + } = options; // Capture WebGL content using native off-screen rendering const webglCanvas = this._webglRenderer.renderToCanvas( @@ -2129,6 +2149,7 @@ export class ProtspaceScatterplot extends LitElement { dpr, dataDomain, pointSizeReference, + resetView, ); // Composite with badges canvas if present diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.export-transform.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.export-transform.test.ts new file mode 100644 index 00000000..08c39780 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.export-transform.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as d3 from 'd3'; +import { WebGLRenderer } from './webgl-renderer'; +import type { ScalePair, WebGLStyleGetters } from '../types'; +import { createMockCanvas } from './test-support/mock-webgl2'; + +/** + * #294: the figure editor (publish modal) captures the scatterplot via + * `renderToCanvas`. By default that capture preserves the live zoom/pan + * transform (used by the "export current view" path). When `resetView` is + * requested, the capture must ignore the live transform and render the + * default, fit-all view — the same thing a double-click reset shows — so the + * editor never inherits a stale zoom and its zoom-inset mapping stays correct. + */ + +const scales = (): ScalePair => ({ + x: d3.scaleLinear().domain([0, 1]).range([0, 800]), + y: d3.scaleLinear().domain([0, 1]).range([0, 600]), +}); +const style = (): WebGLStyleGetters => ({ + getColors: () => ['#f00'], + getPointSize: () => 9, + getOpacity: () => 1, + getDepth: () => 0, + getShape: () => 'circle', +}); + +type ExportRendererSeam = { + exportRenderer: { + renderToCanvas: (...args: unknown[]) => HTMLCanvasElement; + }; +}; + +function setup(transform: d3.ZoomTransform) { + const { canvas } = createMockCanvas({}); + const renderer = new WebGLRenderer( + canvas, + scales, + () => transform, + () => ({ width: 800, height: 600 }), + style(), + ); + // Intercept the off-screen export pass (needs a real WebGL2 context we don't + // have under jsdom). We only assert which transform the facade forwards. + const spy = vi + .spyOn((renderer as unknown as ExportRendererSeam).exportRenderer, 'renderToCanvas') + .mockReturnValue(document.createElement('canvas')); + return { renderer, spy }; +} + +function forwardedTransform(spy: ReturnType) { + const opts = spy.mock.calls[0][3] as { transform: { x: number; y: number; k: number } }; + return { x: opts.transform.x, y: opts.transform.y, k: opts.transform.k }; +} + +describe('WebGLRenderer.renderToCanvas — resetView transform handling (#294)', () => { + afterEach(() => vi.restoreAllMocks()); + + it('forwards the live transform when resetView is not requested', () => { + const live = d3.zoomIdentity.translate(120, 60).scale(3); + const { renderer, spy } = setup(live); + + renderer.renderToCanvas(400, 300); + + expect(forwardedTransform(spy as unknown as ReturnType)).toEqual({ + x: 120, + y: 60, + k: 3, + }); + }); + + it('forwards an identity transform (default view) when resetView is true', () => { + const live = d3.zoomIdentity.translate(120, 60).scale(3); + const { renderer, spy } = setup(live); + + renderer.renderToCanvas(400, 300, 1, undefined, undefined, true); + + expect(forwardedTransform(spy as unknown as ReturnType)).toEqual({ + x: 0, + y: 0, + k: 1, + }); + }); + + it('resetView is independent of the dataDomain (inset) path', () => { + const live = d3.zoomIdentity.translate(50, 50).scale(2); + const { renderer, spy } = setup(live); + const dataDomain = { xMin: 0.1, xMax: 0.4, yMin: 0.1, yMax: 0.4 }; + + renderer.renderToCanvas(200, 200, 1, dataDomain, undefined, true); + + expect(forwardedTransform(spy as unknown as ReturnType)).toEqual({ + x: 0, + y: 0, + k: 1, + }); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.ts b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.ts index ae813f0e..aa53912d 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.ts @@ -474,6 +474,10 @@ export class WebGLRenderer { * @param width Target width in CSS pixels (will be multiplied by DPR) * @param height Target height in CSS pixels * @param dpr Device pixel ratio to use (defaults to 1 for max resolution control) + * @param resetView When true, ignore the live zoom/pan transform and render + * the default, fit-all view (identity transform) — what a double-click + * reset shows. The figure editor uses this so it never inherits a stale + * zoom. Defaults to false, preserving the current view for plain exports. * @returns 2D canvas containing the rendered frame */ public renderToCanvas( @@ -482,6 +486,7 @@ export class WebGLRenderer { dpr: number = 1, dataDomain?: { xMin: number; xMax: number; yMin: number; yMax: number }, pointSizeReference?: { width: number; height: number }, + resetView: boolean = false, ): HTMLCanvasElement { return this.exportRenderer.renderToCanvas(this.lastRenderedData, this.getConfig(), this.style, { width, @@ -490,7 +495,7 @@ export class WebGLRenderer { dataDomain, pointSizeReference, selectionActive: this.selectionActive, - transform: this.getTransform(), + transform: resetView ? ({ x: 0, y: 0, k: 1 } as d3.ZoomTransform) : this.getTransform(), gamma: this.gamma, }); } From 9e21aa348c6f8f27cda80d0ade278b3c924c66e1 Mon Sep 17 00:00:00 2001 From: peymanvahidi Date: Fri, 26 Jun 2026 16:33:56 +0200 Subject: [PATCH 2/2] fix(figure-editor): render badges at the unzoomed view (#294) Duplicate-stack badges were captured at the live zoom position; added captureBadges(transform) to re-render them at the identity transform alongside the fit-all points during captureAtResolution. --- ...-overlay-controller.capture-badges.test.ts | 48 +++++++++++ .../duplicate-stack-overlay-controller.ts | 38 +++++++++ .../scatter-plot.capture-badges.test.ts | 84 +++++++++++++++++++ .../components/scatter-plot/scatter-plot.ts | 12 ++- 4 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-overlay-controller.capture-badges.test.ts create mode 100644 packages/core/src/components/scatter-plot/scatter-plot.capture-badges.test.ts diff --git a/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-overlay-controller.capture-badges.test.ts b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-overlay-controller.capture-badges.test.ts new file mode 100644 index 00000000..3236f312 --- /dev/null +++ b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-overlay-controller.capture-badges.test.ts @@ -0,0 +1,48 @@ +/** @vitest-environment jsdom */ +import { describe, it, expect } from 'vitest'; +import { zoomIdentity } from 'd3'; +import { DuplicateStackOverlayController } from './duplicate-stack-overlay-controller'; + +/** + * #294: captureBadges renders the duplicate-stack badges at a caller-supplied + * transform (the figure editor passes identity) into a fresh off-screen canvas. + * When the overlay is disabled or no stacks are in view it returns null, so the + * caller (scatter-plot.captureAtResolution) skips compositing rather than + * pasting a blank or stale canvas onto the unzoomed render. + */ +function makeController(opts: { isEnabled?: boolean } = {}) { + const config = { + width: 800, + height: 600, + margin: { top: 20, right: 20, bottom: 20, left: 20 }, + }; + const deps = { + getOverlayGroup: () => null, + getBadgesCanvas: () => undefined, + getTransform: () => zoomIdentity, + getConfig: () => config, + getScales: () => null, + getPlotData: () => ({}), + getQuadtree: () => ({}), + isEnabled: () => opts.isEnabled ?? true, + isSelectionMode: () => false, + getColor: () => '#000000', + onPointActivate: () => {}, + onHover: () => {}, + onHoverEnd: () => {}, + }; + return new DuplicateStackOverlayController( + deps as unknown as ConstructorParameters[0], + ); +} + +describe('DuplicateStackOverlayController.captureBadges (#294)', () => { + it('returns null when the overlay is disabled', () => { + expect(makeController({ isEnabled: false }).captureBadges(zoomIdentity)).toBeNull(); + }); + + it('returns null when there are no duplicate stacks in view', () => { + // Fresh controller: no viewport compute has run, so there is nothing to draw. + expect(makeController({ isEnabled: true }).captureBadges(zoomIdentity)).toBeNull(); + }); +}); diff --git a/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-overlay-controller.ts b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-overlay-controller.ts index a164f7c5..291d418a 100644 --- a/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-overlay-controller.ts +++ b/packages/core/src/components/scatter-plot/duplicate-stacks/duplicate-stack-overlay-controller.ts @@ -94,6 +94,44 @@ export class DuplicateStackOverlayController { }); } + /** + * Render the current duplicate-stack badges into a fresh off-screen canvas + * using `transform` instead of the live zoom, leaving the on-screen badge + * canvas untouched. The figure editor passes `d3.zoomIdentity` so the captured + * badges line up with the unzoomed, fit-all points (#294) — the live badge + * canvas is positioned for the live zoom/pan and would otherwise be composited + * at the wrong place. The canvas is sized exactly like the live badge canvas + * (config size × devicePixelRatio) so the caller's existing draw-image stretch + * behaves identically. Returns null (so the caller skips compositing) when the + * overlay is disabled or no stacks are currently in view. + * + * Coverage note: badges reflect the stacks computed for the last live viewport + * (`ensureForViewport` queries only the visible region for perf), so a capture + * taken while the live plot is zoomed in shows that region's badges — the same + * coverage as the live overlay, now drawn at their correct fit-all positions. + */ + captureBadges(transform: ZoomTransform): HTMLCanvasElement | null { + if (!this.deps.isEnabled()) return null; + const config = this.deps.getConfig(); + const win = computeViewportWindow(transform, config, DUPLICATE_BADGES_VIEWPORT_PADDING); + const stacksToRender = cullAndCapStacks(this.stacks, win, this.expandedKey, this.byKey); + if (stacksToRender.length === 0) return null; + + const dpr = window.devicePixelRatio || 1; + const target = document.createElement('canvas'); + target.width = Math.max(1, Math.floor(config.width * dpr)); + target.height = Math.max(1, Math.floor(config.height * dpr)); + + const renderer = new DuplicateBadgesCanvasRenderer({ + getCanvas: () => target, + getTransform: () => transform, + getSize: () => ({ width: config.width, height: config.height }), + getExpandedKey: () => this.expandedKey, + }); + renderer.render(stacksToRender); + return target; + } + // ----- Public surface (1:1 with the old private methods on the host) ----- updateSelectionOverlays(options: { duplicateImmediate?: boolean } = {}): void { diff --git a/packages/core/src/components/scatter-plot/scatter-plot.capture-badges.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.capture-badges.test.ts new file mode 100644 index 00000000..307d06cb --- /dev/null +++ b/packages/core/src/components/scatter-plot/scatter-plot.capture-badges.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment jsdom + * + * #294: the figure editor captures the scatter-plot with `resetView: true` so + * the points render at the default fit-all view. The duplicate-stack badges are + * a separate Canvas2D overlay whose live `_badgesCanvas` is positioned for the + * live zoom/pan — compositing it as-is onto an unzoomed render would paste the + * badges at their zoomed positions. These tests pin the fix: on the resetView + * (non-inset) path, captureAtResolution re-renders the badges at the identity + * transform via the overlay controller instead of using the live canvas. + * + * Constructed via createElement without appending (no connectedCallback), so the + * WebGL/canvas init never runs under jsdom. ResizeObserver is stubbed before the + * element module is imported (the constructor news one up). + */ +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +vi.hoisted(() => { + if (!('ResizeObserver' in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); + +import './scatter-plot'; + +type CaptureInternals = HTMLElement & { + _webglRenderer: unknown; + _dupOverlay: { captureBadges(transform: { x: number; y: number; k: number }): unknown }; + captureAtResolution( + width: number, + height: number, + options?: { + resetView?: boolean; + dataDomain?: { xMin: number; xMax: number; yMin: number; yMax: number }; + }, + ): HTMLCanvasElement; +}; + +describe('scatter-plot captureAtResolution — badges respect resetView (#294)', () => { + let el: CaptureInternals; + + beforeEach(() => { + el = document.createElement('protspace-scatterplot') as CaptureInternals; + // Stub the WebGL renderer so capture proceeds to the badge-composite step + // without a real WebGL2 context (unavailable under jsdom). + el._webglRenderer = { renderToCanvas: vi.fn(() => document.createElement('canvas')) }; + }); + + it('re-renders badges at the identity transform when resetView is true', () => { + const captureBadges = vi.spyOn(el._dupOverlay, 'captureBadges').mockReturnValue(null); + + el.captureAtResolution(800, 600, { resetView: true }); + + expect(captureBadges).toHaveBeenCalledTimes(1); + const transform = captureBadges.mock.calls[0][0]; + // d3.zoomIdentity — the default, unzoomed view. + expect(transform.x).toBe(0); + expect(transform.y).toBe(0); + expect(transform.k).toBe(1); + }); + + it('uses the live badge canvas (no re-render) when resetView is not requested', () => { + const captureBadges = vi.spyOn(el._dupOverlay, 'captureBadges').mockReturnValue(null); + + el.captureAtResolution(800, 600); + + expect(captureBadges).not.toHaveBeenCalled(); + }); + + it('does not re-render badges on the inset (dataDomain) path even with resetView', () => { + const captureBadges = vi.spyOn(el._dupOverlay, 'captureBadges').mockReturnValue(null); + + el.captureAtResolution(200, 200, { + resetView: true, + dataDomain: { xMin: 0.1, xMax: 0.4, yMin: 0.1, yMax: 0.4 }, + }); + + expect(captureBadges).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/components/scatter-plot/scatter-plot.ts b/packages/core/src/components/scatter-plot/scatter-plot.ts index 86aac754..968a8fb7 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.ts @@ -2152,8 +2152,16 @@ export class ProtspaceScatterplot extends LitElement { resetView, ); - // Composite with badges canvas if present - const badgesCanvas = this._badgesCanvas; + // Composite with badges canvas if present. For the unzoomed (resetView) + // capture, re-render the badges at the identity transform so they line up + // with the fit-all points; the live _badgesCanvas is positioned for the + // live zoom/pan and would otherwise leak the zoom into the figure (#294). + // The inset path (dataDomain set) keeps the live canvas — its badge handling + // is a separate, pre-existing concern. + const badgesCanvas = + resetView && !dataDomain + ? (this._dupOverlay.captureBadges(d3.zoomIdentity) ?? undefined) + : this._badgesCanvas; if (badgesCanvas && badgesCanvas.width > 0 && badgesCanvas.height > 0) { const ctx = webglCanvas.getContext('2d'); if (ctx) {