From 2015e8acfb44cd9469ad4b98f79188589e1c3dcb Mon Sep 17 00:00:00 2001 From: Florin Senoner <23100806+FlorinSenoner@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:50:34 +0200 Subject: [PATCH 1/4] feat(scatterplot): show zoomed-in view indicator --- apps/web/tests/playwright.config.ts | 8 ++ apps/web/tests/zoom-indicator.spec.ts | 32 +++++++ .../show-zoom-indicator/.openspec.yaml | 4 + .../changes/show-zoom-indicator/design.md | 50 ++++++++++ .../changes/show-zoom-indicator/proposal.md | 27 ++++++ .../specs/scatterplot-zoom-indicator/spec.md | 40 ++++++++ openspec/changes/show-zoom-indicator/tasks.md | 17 ++++ .../components/scatter-plot/scatter-plot.ts | 14 ++- .../scatter-plot.zoom-indicator.test.ts | 92 +++++++++++++++++++ 9 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 apps/web/tests/zoom-indicator.spec.ts create mode 100644 openspec/changes/show-zoom-indicator/.openspec.yaml create mode 100644 openspec/changes/show-zoom-indicator/design.md create mode 100644 openspec/changes/show-zoom-indicator/proposal.md create mode 100644 openspec/changes/show-zoom-indicator/specs/scatterplot-zoom-indicator/spec.md create mode 100644 openspec/changes/show-zoom-indicator/tasks.md create mode 100644 packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts diff --git a/apps/web/tests/playwright.config.ts b/apps/web/tests/playwright.config.ts index c63f4a35..42469399 100644 --- a/apps/web/tests/playwright.config.ts +++ b/apps/web/tests/playwright.config.ts @@ -109,6 +109,14 @@ export default defineConfig({ }, testMatch: /brush-selection\.spec\.ts/, }, + { + name: 'zoom-indicator', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1280, height: 720 }, + }, + testMatch: /zoom-indicator\.spec\.ts/, + }, { name: 'url-view-state', use: { diff --git a/apps/web/tests/zoom-indicator.spec.ts b/apps/web/tests/zoom-indicator.spec.ts new file mode 100644 index 00000000..b4013e07 --- /dev/null +++ b/apps/web/tests/zoom-indicator.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; +import { waitForExploreDataLoad, waitForExploreInteractionReady } from './helpers/explore'; + +test.describe('scatterplot zoom indicator (#343)', () => { + test('shows after wheel zoom and disappears after double-click reset', async ({ page }) => { + await page.goto('/explore'); + await waitForExploreDataLoad(page); + await waitForExploreInteractionReady(page); + + const plot = page.locator('#myPlot'); + const bounds = await plot.boundingBox(); + expect(bounds).not.toBeNull(); + + const center = { + x: bounds!.x + bounds!.width / 2, + y: bounds!.y + bounds!.height / 2, + }; + const zoomMarker = plot.locator('.zoom-indicator'); + await expect(zoomMarker).toHaveCount(0); + + await page.mouse.move(center.x, center.y); + await page.mouse.wheel(0, -500); + await expect + .poll(() => plot.evaluate((element: any) => element._transform.k)) + .toBeGreaterThan(1); + await expect(zoomMarker).toHaveText('Zoomed in'); + + await page.mouse.dblclick(center.x, center.y); + await expect.poll(() => plot.evaluate((element: any) => element._transform.k)).toBe(1); + await expect(zoomMarker).toHaveCount(0); + }); +}); diff --git a/openspec/changes/show-zoom-indicator/.openspec.yaml b/openspec/changes/show-zoom-indicator/.openspec.yaml new file mode 100644 index 00000000..11d5d1ae --- /dev/null +++ b/openspec/changes/show-zoom-indicator/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +created: 2026-08-01 +goal: Show an on-plot indicator whenever the scatterplot is zoomed in and remove + it when the view returns to identity. diff --git a/openspec/changes/show-zoom-indicator/design.md b/openspec/changes/show-zoom-indicator/design.md new file mode 100644 index 00000000..18dbf3cc --- /dev/null +++ b/openspec/changes/show-zoom-indicator/design.md @@ -0,0 +1,50 @@ +## Context + +`PlotInteractionController.applyZoom()` forwards every D3 transform through the scatterplot host's `onTransform` callback. The host deliberately stores `_transform` as a plain field because making the full transform reactive caused an unnecessary Lit update and WebGL render on every gesture frame (the F-48 invariant). The existing bottom-left `.plot-indicator` renders the visible point count and is already the established persistent status surface for the plot. + +Issue #343 requires a visible signal specifically for zooming in. The current `zoomExtent` also permits zooming out, so the indicator must distinguish `k > 1` from both identity (`k === 1`) and zoomed-out (`k < 1`) views. + +## Goals / Non-Goals + +**Goals:** + +- Show `Zoomed in` next to the existing point count while the active D3 scale is greater than `1`. +- Remove the marker once reset reaches identity. +- Preserve the non-reactive `_transform` performance invariant by scheduling Lit updates only when the boolean zoomed-in state changes. +- Cover the boundary in component tests and the real wheel/reset interaction in the Explore app. + +**Non-Goals:** + +- Add a new reset button, change the existing double-click reset gesture, or display a numeric zoom percentage. +- Show an indicator for panning at identity scale or for zooming out below identity. +- Refactor the interaction controller, point-count computation, or overlay layout. + +## Decisions + +### Reuse the existing point-count chip + +The marker will render as `N points · Zoomed in` inside the existing bottom-left `.plot-indicator`. This avoids collisions with the top-right selection-mode indicator, the bottom-right numeric-recompute chip, and the bottom-center provenance status. A separate overlay was rejected because it would need new collision rules for no additional user value. + +### Store only a reactive boolean boundary + +The scatterplot will add a reactive `_isZoomedIn` boolean. The host callback will continue assigning every transform to the plain `_transform` field, then compute `nextIsZoomedIn = t.k > 1` and assign the boolean only when it differs from the current value. Lit therefore renders once when zoom crosses above identity and once when reset reaches identity, rather than once per D3 frame. + +Keeping the boolean on the scatterplot host, rather than the interaction controller, preserves the controller's role as a generic transform dispatcher and keeps rendering state beside the template that consumes it. + +### Verify both state propagation and user-visible behavior + +A focused jsdom test will drive the real host bridge, assert the marker appears and disappears in the component render, and lock the no-repeat-update boundary for additional `k > 1` transforms. A Playwright regression will wheel over the real Explore scatterplot, assert `Zoomed in`, double-click reset, and assert the marker disappears. + +## Risks / Trade-offs + +- **Floating-point values during reset could keep the marker visible until the transition finishes** → This is intentional: the plot remains non-identity until D3 emits the final `k === 1` transform. +- **A marker appended to the count chip is less prominent than a dedicated badge** → The count chip is persistent, unobtrusive, and has no collision risk; the text remains visible throughout the zoomed state. +- **Reactive state could regress zoom performance if written every frame** → Compare the derived boolean before assignment and include a request-update count assertion in the focused test. + +## Migration Plan + +No migration is required. The change is additive UI behavior with no public API or persisted state. Rollback consists of removing the boolean derivation and conditional template text. + +## Open Questions + +None. The count-chip presentation and non-interactive scope were approved before implementation. diff --git a/openspec/changes/show-zoom-indicator/proposal.md b/openspec/changes/show-zoom-indicator/proposal.md new file mode 100644 index 00000000..c3c3531b --- /dev/null +++ b/openspec/changes/show-zoom-indicator/proposal.md @@ -0,0 +1,27 @@ +## Why + +The scatterplot can be zoomed substantially without any persistent UI signal that the current view differs from the fitted identity view. This makes a zoomed subset easy to mistake for the complete dataset view and leaves users without visible confirmation that double-click reset is applicable. + +## What Changes + +- Show a concise `Zoomed in` marker inside the existing point-count chip whenever the scatterplot scale is greater than the identity scale. +- Remove the marker when the view returns to identity, including after the existing double-click and programmatic reset paths complete. +- Update the marker only when zoom state crosses the identity boundary so ordinary zoom frames do not trigger unnecessary Lit renders. +- Add focused component and browser regression coverage for the zoomed and reset states. + +## Capabilities + +### New Capabilities + +- `scatterplot-zoom-indicator`: Defines how the scatterplot exposes zoomed-versus-identity view state in its existing point-count indicator. + +### Modified Capabilities + +None. + +## Impact + +- `packages/core/src/components/scatter-plot/scatter-plot.ts`: track the zoom-state boundary and render the conditional marker. +- `packages/core/src/components/scatter-plot/interaction/plot-interaction-controller.test.ts` and/or a focused scatterplot render test: cover transform propagation and rendered state without mocking the behavior under test. +- `apps/web/tests/`: cover the user-visible wheel-zoom and reset journey in the real Explore page. +- No public API, dependency, persisted-data, or bundle-format changes. diff --git a/openspec/changes/show-zoom-indicator/specs/scatterplot-zoom-indicator/spec.md b/openspec/changes/show-zoom-indicator/specs/scatterplot-zoom-indicator/spec.md new file mode 100644 index 00000000..51f9058a --- /dev/null +++ b/openspec/changes/show-zoom-indicator/specs/scatterplot-zoom-indicator/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: Scatterplot indicates a zoomed-in view + +The scatterplot SHALL append the text `Zoomed in` to its existing visible point-count indicator whenever the active view scale is greater than the identity scale of `1`. The scatterplot SHALL NOT show that marker at identity scale, while zoomed out below identity, or for translation alone at identity scale. + +#### Scenario: User zooms in with the wheel + +- **WHEN** the user wheel-zooms the scatterplot to a scale greater than `1` +- **THEN** the point-count indicator includes `Zoomed in` + +#### Scenario: User zooms out without crossing identity + +- **WHEN** the user changes between two scales that are both greater than `1` +- **THEN** the point-count indicator continues to include `Zoomed in` +- **AND** the scatterplot does not schedule a new Lit update solely because the zoomed-in boolean remained true + +#### Scenario: User resets the zoom + +- **WHEN** the existing reset behavior returns the view to identity scale `1` +- **THEN** the point-count indicator no longer includes `Zoomed in` + +#### Scenario: User pans or zooms out + +- **WHEN** the view is translated at scale `1` or has a scale below `1` +- **THEN** the point-count indicator does not include `Zoomed in` + +### Requirement: Zoom indication preserves transform rendering performance + +The scatterplot MUST keep the full D3 transform non-reactive and SHALL derive a separate reactive boolean for the zoomed-in boundary. Transform updates SHALL schedule marker-related Lit rendering only when that boolean changes. + +#### Scenario: Zoom gesture emits multiple zoomed-in frames + +- **WHEN** consecutive transform frames all have scales greater than `1` +- **THEN** only the first frame that crosses above identity changes the reactive zoom-indicator state + +#### Scenario: Reset transition reaches identity + +- **WHEN** a reset transition emits zoomed-in frames followed by its final identity frame +- **THEN** the reactive zoom-indicator state changes once on the final identity frame diff --git a/openspec/changes/show-zoom-indicator/tasks.md b/openspec/changes/show-zoom-indicator/tasks.md new file mode 100644 index 00000000..b2d84968 --- /dev/null +++ b/openspec/changes/show-zoom-indicator/tasks.md @@ -0,0 +1,17 @@ +## 1. Regression Tests (RED) + +- [x] 1.1 Add a focused jsdom component test that drives the real scatterplot host transform callback, verifies `Zoomed in` appears only for `k > 1`, and verifies additional zoomed-in frames do not schedule marker updates. +- [x] 1.2 Add a focused Playwright project and test that wheel-zooms the loaded Explore scatterplot, observes `Zoomed in`, double-clicks to reset, and observes the marker disappear. +- [x] 1.3 Run both focused tests against the unmodified implementation and record that they fail because the zoom marker is absent. + +## 2. Minimal Implementation (GREEN) + +- [x] 2.1 Add a reactive boolean zoom-boundary state while keeping `_transform` non-reactive, and update the boolean only when `t.k > 1` changes truth value. +- [x] 2.2 Append `· Zoomed in` to the existing point-count chip only while the boolean state is true. +- [x] 2.3 Run both focused tests and record that they pass. + +## 3. Verification and Publication + +- [x] 3.1 Repeat the original browser reproduction and confirm the marker appears above identity and disappears after reset with no new relevant console errors. +- [x] 3.2 Run the affected core and Playwright test projects, then run `pnpm precommit`. +- [x] 3.3 Validate the OpenSpec change and review the final diff for issue-only scope. diff --git a/packages/core/src/components/scatter-plot/scatter-plot.ts b/packages/core/src/components/scatter-plot/scatter-plot.ts index ae0297ae..13d1e30f 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.ts @@ -161,6 +161,7 @@ export class ProtspaceScatterplot extends LitElement { @state() private _canvasKey = 0; @state() private _numericRecomputeRunning = false; @state() private _connectorStatus: ProvenanceConnectorStatus | null = null; + @state() private _isZoomedIn = false; // Queries @query('canvas') private _canvas?: HTMLCanvasElement; @@ -1225,6 +1226,10 @@ export class ProtspaceScatterplot extends LitElement { resolveSlotsToIds: (slots) => this._slotsToInteractiveIds(slots), onTransform: (t) => { this._transform = t; + const isZoomedIn = t.k > 1; + if (isZoomedIn !== this._isZoomedIn) { + this._isZoomedIn = isZoomedIn; + } this._connectorOverlay.updateZoomScale(t.k); }, onSelect: (ids, clearVisual) => this._commitSelection(ids, clearVisual), @@ -1980,7 +1985,14 @@ export class ProtspaceScatterplot extends LitElement { ` : ''} ${this.data - ? html`
${this._getVisiblePointCount()} points
` + ? html` +
+ ${this._getVisiblePointCount()} points + ${this._isZoomedIn + ? html` · Zoomed in` + : ''} +
+ ` : ''} ${this._numericRecomputeRunning ? html` diff --git a/packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts new file mode 100644 index 00000000..a6e72acc --- /dev/null +++ b/packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment jsdom + * + * Issue #343: the plot's existing point-count chip exposes whether the active + * D3 view is zoomed in. The full transform remains non-reactive (F-48); only + * crossings between identity and k > 1 schedule a Lit update for the marker. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as d3 from 'd3'; +import type { VisualizationData } from '@protspace/utils'; +import type { PlotInteractionHost } from './interaction/plot-interaction-controller'; + +vi.hoisted(() => { + if (!('ResizeObserver' in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => null); +}); + +import './scatter-plot'; + +type ZoomIndicatorInternals = HTMLElement & { + data: VisualizationData; + selectedAnnotation: string; + updateComplete: Promise; + firstUpdated(): void; + _interactionHost(): PlotInteractionHost; + requestUpdate(name?: PropertyKey, oldValue?: unknown): void; +}; + +function makeData(): VisualizationData { + return { + protein_ids: ['p0'], + projections: [{ name: 'umap', data: new Float32Array([0, 0]), dimension: 2 }], + annotations: { + family: { + values: ['A'], + colors: ['#ff0000'], + shapes: ['circle'], + }, + }, + annotation_data: { family: [[0]] }, + } as unknown as VisualizationData; +} + +function pointCountText(plot: ZoomIndicatorInternals): string { + return ( + plot.shadowRoot?.querySelector('.plot-indicator')?.textContent?.replace(/\s+/g, ' ').trim() ?? + '' + ); +} + +describe('scatterplot zoom indicator (#343)', () => { + afterEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); + }); + + it('shows only above identity and updates only when that boundary changes', async () => { + const plot = document.createElement('protspace-scatterplot') as ZoomIndicatorInternals; + // Avoid WebGL/controller startup; this test drives the real host bridge directly. + plot.firstUpdated = () => {}; + plot.data = makeData(); + plot.selectedAnnotation = 'family'; + document.body.appendChild(plot); + await plot.updateComplete; + + expect(pointCountText(plot)).not.toContain('Zoomed in'); + + const host = plot._interactionHost(); + host.onTransform(d3.zoomIdentity.scale(2)); + await plot.updateComplete; + expect(pointCountText(plot)).toContain('Zoomed in'); + + const requestUpdate = vi.spyOn(plot, 'requestUpdate'); + host.onTransform(d3.zoomIdentity.scale(3)); + expect(requestUpdate).not.toHaveBeenCalled(); + + host.onTransform(d3.zoomIdentity.translate(30, 20)); + await plot.updateComplete; + expect(pointCountText(plot)).not.toContain('Zoomed in'); + + host.onTransform(d3.zoomIdentity.scale(0.5)); + expect(requestUpdate).toHaveBeenCalledTimes(1); + await plot.updateComplete; + expect(pointCountText(plot)).not.toContain('Zoomed in'); + }); +}); From 09ecf5352bf2d20c39ab2642f05dc498d3733df2 Mon Sep 17 00:00:00 2001 From: Florin Senoner <23100806+FlorinSenoner@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:01:59 +0200 Subject: [PATCH 2/4] fix(scatterplot): correct zoom indicator presentation --- apps/web/tests/zoom-indicator.spec.ts | 17 ++++- .../changes/show-zoom-indicator/design.md | 6 +- openspec/changes/show-zoom-indicator/tasks.md | 6 +- .../scatter-plot/scatter-plot.styles.ts | 1 + .../components/scatter-plot/scatter-plot.ts | 11 +--- .../scatter-plot.zoom-indicator.test.ts | 62 ++++++++++++------- 6 files changed, 66 insertions(+), 37 deletions(-) diff --git a/apps/web/tests/zoom-indicator.spec.ts b/apps/web/tests/zoom-indicator.spec.ts index b4013e07..6e95063e 100644 --- a/apps/web/tests/zoom-indicator.spec.ts +++ b/apps/web/tests/zoom-indicator.spec.ts @@ -15,7 +15,9 @@ test.describe('scatterplot zoom indicator (#343)', () => { x: bounds!.x + bounds!.width / 2, y: bounds!.y + bounds!.height / 2, }; - const zoomMarker = plot.locator('.zoom-indicator'); + const pointCount = plot.locator('.point-count'); + const pointCountChip = pointCount.locator('..'); + const zoomMarker = pointCountChip.locator('.zoom-indicator'); await expect(zoomMarker).toHaveCount(0); await page.mouse.move(center.x, center.y); @@ -23,7 +25,18 @@ test.describe('scatterplot zoom indicator (#343)', () => { await expect .poll(() => plot.evaluate((element: any) => element._transform.k)) .toBeGreaterThan(1); - await expect(zoomMarker).toHaveText('Zoomed in'); + await expect(pointCount).toHaveText(/^\d+ points$/); + await expect(zoomMarker).toHaveText('· Zoomed in'); + const chipSpacing = await pointCountChip.evaluate((chip) => { + const count = chip.querySelector('.point-count')?.getBoundingClientRect(); + const marker = chip.querySelector('.zoom-indicator')?.getBoundingClientRect(); + return { + actual: count && marker ? marker.left - count.right : 0, + configured: Number.parseFloat(getComputedStyle(chip).columnGap), + }; + }); + expect(chipSpacing.actual).toBeGreaterThan(0); + expect(chipSpacing.actual).toBeCloseTo(chipSpacing.configured, 1); await page.mouse.dblclick(center.x, center.y); await expect.poll(() => plot.evaluate((element: any) => element._transform.k)).toBe(1); diff --git a/openspec/changes/show-zoom-indicator/design.md b/openspec/changes/show-zoom-indicator/design.md index 18dbf3cc..bca809b3 100644 --- a/openspec/changes/show-zoom-indicator/design.md +++ b/openspec/changes/show-zoom-indicator/design.md @@ -27,19 +27,19 @@ The marker will render as `N points · Zoomed in` inside the existing bottom-lef ### Store only a reactive boolean boundary -The scatterplot will add a reactive `_isZoomedIn` boolean. The host callback will continue assigning every transform to the plain `_transform` field, then compute `nextIsZoomedIn = t.k > 1` and assign the boolean only when it differs from the current value. Lit therefore renders once when zoom crosses above identity and once when reset reaches identity, rather than once per D3 frame. +The scatterplot will add a reactive `_isZoomedIn` boolean. The host callback will continue assigning every transform to the plain `_transform` field, then assign `t.k > 1` to the boolean. Lit's default change detection ignores equal boolean values, so it enqueues a render once when zoom crosses above identity and once when reset reaches identity, rather than once per D3 frame. Keeping the boolean on the scatterplot host, rather than the interaction controller, preserves the controller's role as a generic transform dispatcher and keeps rendering state beside the template that consumes it. ### Verify both state propagation and user-visible behavior -A focused jsdom test will drive the real host bridge, assert the marker appears and disappears in the component render, and lock the no-repeat-update boundary for additional `k > 1` transforms. A Playwright regression will wheel over the real Explore scatterplot, assert `Zoomed in`, double-click reset, and assert the marker disappears. +A focused jsdom test will drive the real host bridge, assert the count and marker render as explicit presentation items, and observe actual plot rendering across the no-repeat-update boundary for additional `k > 1` transforms. A Playwright regression will wheel over the real Explore scatterplot, verify the complete `N points · Zoomed in` chip and its spacing, double-click reset, and assert the marker disappears. ## Risks / Trade-offs - **Floating-point values during reset could keep the marker visible until the transition finishes** → This is intentional: the plot remains non-identity until D3 emits the final `k === 1` transform. - **A marker appended to the count chip is less prominent than a dedicated badge** → The count chip is persistent, unobtrusive, and has no collision risk; the text remains visible throughout the zoomed state. -- **Reactive state could regress zoom performance if written every frame** → Compare the derived boolean before assignment and include a request-update count assertion in the focused test. +- **Reactive state could regress zoom performance if equal values enqueue updates** → Exercise consecutive same-side transforms and assert that no additional plot render runs. ## Migration Plan diff --git a/openspec/changes/show-zoom-indicator/tasks.md b/openspec/changes/show-zoom-indicator/tasks.md index b2d84968..8a97aed3 100644 --- a/openspec/changes/show-zoom-indicator/tasks.md +++ b/openspec/changes/show-zoom-indicator/tasks.md @@ -1,12 +1,12 @@ ## 1. Regression Tests (RED) -- [x] 1.1 Add a focused jsdom component test that drives the real scatterplot host transform callback, verifies `Zoomed in` appears only for `k > 1`, and verifies additional zoomed-in frames do not schedule marker updates. -- [x] 1.2 Add a focused Playwright project and test that wheel-zooms the loaded Explore scatterplot, observes `Zoomed in`, double-clicks to reset, and observes the marker disappear. +- [x] 1.1 Add a focused jsdom component test that drives the real scatterplot host transform callback, verifies the full count/marker presentation, and observes that additional zoomed-in frames do not run another plot render. +- [x] 1.2 Add a focused Playwright project and test that wheel-zooms the loaded Explore scatterplot, verifies the complete spaced `N points · Zoomed in` chip, double-clicks to reset, and observes the marker disappear. - [x] 1.3 Run both focused tests against the unmodified implementation and record that they fail because the zoom marker is absent. ## 2. Minimal Implementation (GREEN) -- [x] 2.1 Add a reactive boolean zoom-boundary state while keeping `_transform` non-reactive, and update the boolean only when `t.k > 1` changes truth value. +- [x] 2.1 Add a reactive boolean zoom-boundary state while keeping `_transform` non-reactive, and rely on Lit's boolean change detection to deduplicate repeated same-side transforms. - [x] 2.2 Append `· Zoomed in` to the existing point-count chip only while the boolean state is true. - [x] 2.3 Run both focused tests and record that they pass. diff --git a/packages/core/src/components/scatter-plot/scatter-plot.styles.ts b/packages/core/src/components/scatter-plot/scatter-plot.styles.ts index 0470dc90..1f5b9ddc 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.styles.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.styles.ts @@ -140,6 +140,7 @@ const scatterplotStylesCore = css` z-index: var(--z-overlay); display: flex; align-items: center; + gap: 0.25rem; height: 2rem; padding: 0 0.625rem; box-sizing: border-box; diff --git a/packages/core/src/components/scatter-plot/scatter-plot.ts b/packages/core/src/components/scatter-plot/scatter-plot.ts index 13d1e30f..9263059f 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.ts @@ -1226,10 +1226,7 @@ export class ProtspaceScatterplot extends LitElement { resolveSlotsToIds: (slots) => this._slotsToInteractiveIds(slots), onTransform: (t) => { this._transform = t; - const isZoomedIn = t.k > 1; - if (isZoomedIn !== this._isZoomedIn) { - this._isZoomedIn = isZoomedIn; - } + this._isZoomedIn = t.k > 1; this._connectorOverlay.updateZoomScale(t.k); }, onSelect: (ids, clearVisual) => this._commitSelection(ids, clearVisual), @@ -1987,10 +1984,8 @@ export class ProtspaceScatterplot extends LitElement { ${this.data ? html`
- ${this._getVisiblePointCount()} points - ${this._isZoomedIn - ? html` · Zoomed in` - : ''} + ${this._getVisiblePointCount()} points + ${this._isZoomedIn ? html`· Zoomed in` : ''}
` : ''} diff --git a/packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts index a6e72acc..2cf2f643 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts @@ -5,7 +5,7 @@ * D3 view is zoomed in. The full transform remains non-reactive (F-48); only * crossings between identity and k > 1 schedule a Lit update for the marker. */ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as d3 from 'd3'; import type { VisualizationData } from '@protspace/utils'; import type { PlotInteractionHost } from './interaction/plot-interaction-controller'; @@ -29,7 +29,7 @@ type ZoomIndicatorInternals = HTMLElement & { updateComplete: Promise; firstUpdated(): void; _interactionHost(): PlotInteractionHost; - requestUpdate(name?: PropertyKey, oldValue?: unknown): void; + _renderPlot(): void; }; function makeData(): VisualizationData { @@ -47,46 +47,66 @@ function makeData(): VisualizationData { } as unknown as VisualizationData; } -function pointCountText(plot: ZoomIndicatorInternals): string { - return ( - plot.shadowRoot?.querySelector('.plot-indicator')?.textContent?.replace(/\s+/g, ' ').trim() ?? - '' - ); +async function makePlot(): Promise { + const plot = document.createElement('protspace-scatterplot') as ZoomIndicatorInternals; + // Avoid WebGL/controller startup; these tests drive the real host bridge directly. + plot.firstUpdated = () => {}; + plot.data = makeData(); + plot.selectedAnnotation = 'family'; + document.body.appendChild(plot); + while (!(await plot.updateComplete)) { + // Lit reports false while an update triggered by the previous cycle is pending. + } + return plot; } describe('scatterplot zoom indicator (#343)', () => { + beforeEach(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => null); + }); + afterEach(() => { document.body.replaceChildren(); vi.restoreAllMocks(); }); - it('shows only above identity and updates only when that boundary changes', async () => { - const plot = document.createElement('protspace-scatterplot') as ZoomIndicatorInternals; - // Avoid WebGL/controller startup; this test drives the real host bridge directly. - plot.firstUpdated = () => {}; - plot.data = makeData(); - plot.selectedAnnotation = 'family'; - document.body.appendChild(plot); + it('renders the point count and zoom label as spaced presentation items', async () => { + const plot = await makePlot(); + const host = plot._interactionHost(); + host.onTransform(d3.zoomIdentity.scale(2)); await plot.updateComplete; - expect(pointCountText(plot)).not.toContain('Zoomed in'); + const chip = plot.shadowRoot?.querySelector('.plot-indicator'); + expect(chip?.querySelector('.point-count')?.textContent).toBe('1 points'); + expect(chip?.querySelector('.zoom-indicator')?.textContent).toBe('· Zoomed in'); + expect(Array.from(chip?.children ?? []).map((child) => child.className)).toEqual([ + 'point-count', + 'zoom-indicator', + ]); + }); + it('schedules rendering only when the zoomed-in boundary changes', async () => { + const plot = await makePlot(); const host = plot._interactionHost(); + const renderPlot = vi.spyOn(plot, '_renderPlot').mockImplementation(() => {}); + host.onTransform(d3.zoomIdentity.scale(2)); await plot.updateComplete; - expect(pointCountText(plot)).toContain('Zoomed in'); + expect(renderPlot).toHaveBeenCalledTimes(1); - const requestUpdate = vi.spyOn(plot, 'requestUpdate'); + renderPlot.mockClear(); host.onTransform(d3.zoomIdentity.scale(3)); - expect(requestUpdate).not.toHaveBeenCalled(); + await plot.updateComplete; + expect(renderPlot).not.toHaveBeenCalled(); host.onTransform(d3.zoomIdentity.translate(30, 20)); await plot.updateComplete; - expect(pointCountText(plot)).not.toContain('Zoomed in'); + expect(renderPlot).toHaveBeenCalledTimes(1); + expect(plot.shadowRoot?.querySelector('.zoom-indicator')).toBeNull(); + renderPlot.mockClear(); host.onTransform(d3.zoomIdentity.scale(0.5)); - expect(requestUpdate).toHaveBeenCalledTimes(1); await plot.updateComplete; - expect(pointCountText(plot)).not.toContain('Zoomed in'); + expect(renderPlot).not.toHaveBeenCalled(); }); }); From 732de70bb870fe50dd891d5563c16e02df5963f4 Mon Sep 17 00:00:00 2001 From: Florin Senoner <23100806+FlorinSenoner@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:33:59 +0200 Subject: [PATCH 3/4] fix(scatterplot): announce zoom status changes --- apps/web/tests/zoom-indicator.spec.ts | 6 ++++-- openspec/changes/show-zoom-indicator/design.md | 5 +++-- .../specs/scatterplot-zoom-indicator/spec.md | 8 +++++++- openspec/changes/show-zoom-indicator/tasks.md | 7 ++++--- packages/core/src/components/scatter-plot/scatter-plot.ts | 2 +- .../scatter-plot/scatter-plot.zoom-indicator.test.ts | 4 ++-- 6 files changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/zoom-indicator.spec.ts b/apps/web/tests/zoom-indicator.spec.ts index 6e95063e..afd67d47 100644 --- a/apps/web/tests/zoom-indicator.spec.ts +++ b/apps/web/tests/zoom-indicator.spec.ts @@ -15,8 +15,9 @@ test.describe('scatterplot zoom indicator (#343)', () => { x: bounds!.x + bounds!.width / 2, y: bounds!.y + bounds!.height / 2, }; - const pointCount = plot.locator('.point-count'); - const pointCountChip = pointCount.locator('..'); + const pointCountChip = plot.getByRole('status'); + await expect(pointCountChip).toHaveAttribute('aria-live', 'polite'); + const pointCount = pointCountChip.locator('.point-count'); const zoomMarker = pointCountChip.locator('.zoom-indicator'); await expect(zoomMarker).toHaveCount(0); @@ -41,5 +42,6 @@ test.describe('scatterplot zoom indicator (#343)', () => { await page.mouse.dblclick(center.x, center.y); await expect.poll(() => plot.evaluate((element: any) => element._transform.k)).toBe(1); await expect(zoomMarker).toHaveCount(0); + await expect(pointCountChip).toHaveText(/^\s*\d+ points\s*$/); }); }); diff --git a/openspec/changes/show-zoom-indicator/design.md b/openspec/changes/show-zoom-indicator/design.md index bca809b3..f754f146 100644 --- a/openspec/changes/show-zoom-indicator/design.md +++ b/openspec/changes/show-zoom-indicator/design.md @@ -10,6 +10,7 @@ Issue #343 requires a visible signal specifically for zooming in. The current `z - Show `Zoomed in` next to the existing point count while the active D3 scale is greater than `1`. - Remove the marker once reset reaches identity. +- Announce zoom and reset status changes to assistive technology without moving focus. - Preserve the non-reactive `_transform` performance invariant by scheduling Lit updates only when the boolean zoomed-in state changes. - Cover the boundary in component tests and the real wheel/reset interaction in the Explore app. @@ -23,7 +24,7 @@ Issue #343 requires a visible signal specifically for zooming in. The current `z ### Reuse the existing point-count chip -The marker will render as `N points · Zoomed in` inside the existing bottom-left `.plot-indicator`. This avoids collisions with the top-right selection-mode indicator, the bottom-right numeric-recompute chip, and the bottom-center provenance status. A separate overlay was rejected because it would need new collision rules for no additional user value. +The marker will render as `N points · Zoomed in` inside the existing bottom-left `.plot-indicator`. The chip reuses the component's established `role="status" aria-live="polite"` pattern so its changing content is announced without focus movement. A separate overlay was rejected because it would need new collision rules for no additional user value. ### Store only a reactive boolean boundary @@ -33,7 +34,7 @@ Keeping the boolean on the scatterplot host, rather than the interaction control ### Verify both state propagation and user-visible behavior -A focused jsdom test will drive the real host bridge, assert the count and marker render as explicit presentation items, and observe actual plot rendering across the no-repeat-update boundary for additional `k > 1` transforms. A Playwright regression will wheel over the real Explore scatterplot, verify the complete `N points · Zoomed in` chip and its spacing, double-click reset, and assert the marker disappears. +A focused jsdom test will drive the real host bridge, assert the count and marker render as a polite status, and observe actual plot rendering across the no-repeat-update boundary for additional `k > 1` transforms. A Playwright regression will locate the chip by its status role, wheel over the real Explore scatterplot, verify the complete `N points · Zoomed in` presentation, double-click reset, and assert the status returns to the point count alone. ## Risks / Trade-offs diff --git a/openspec/changes/show-zoom-indicator/specs/scatterplot-zoom-indicator/spec.md b/openspec/changes/show-zoom-indicator/specs/scatterplot-zoom-indicator/spec.md index 51f9058a..32436e58 100644 --- a/openspec/changes/show-zoom-indicator/specs/scatterplot-zoom-indicator/spec.md +++ b/openspec/changes/show-zoom-indicator/specs/scatterplot-zoom-indicator/spec.md @@ -2,7 +2,7 @@ ### Requirement: Scatterplot indicates a zoomed-in view -The scatterplot SHALL append the text `Zoomed in` to its existing visible point-count indicator whenever the active view scale is greater than the identity scale of `1`. The scatterplot SHALL NOT show that marker at identity scale, while zoomed out below identity, or for translation alone at identity scale. +The scatterplot SHALL append the text `Zoomed in` to its existing visible point-count indicator whenever the active view scale is greater than the identity scale of `1`. The scatterplot SHALL NOT show that marker at identity scale, while zoomed out below identity, or for translation alone at identity scale. The point-count indicator SHALL expose its changing content as a polite status message so assistive technology can announce zoom and reset changes without moving focus. #### Scenario: User zooms in with the wheel @@ -20,6 +20,12 @@ The scatterplot SHALL append the text `Zoomed in` to its existing visible point- - **WHEN** the existing reset behavior returns the view to identity scale `1` - **THEN** the point-count indicator no longer includes `Zoomed in` +#### Scenario: Assistive technology receives zoom-state changes + +- **WHEN** the point-count indicator changes between identity and zoomed-in content +- **THEN** the updated content is exposed as a polite status message +- **AND** the change does not require focus to move to the indicator + #### Scenario: User pans or zooms out - **WHEN** the view is translated at scale `1` or has a scale below `1` diff --git a/openspec/changes/show-zoom-indicator/tasks.md b/openspec/changes/show-zoom-indicator/tasks.md index 8a97aed3..8ce26e9f 100644 --- a/openspec/changes/show-zoom-indicator/tasks.md +++ b/openspec/changes/show-zoom-indicator/tasks.md @@ -1,13 +1,14 @@ ## 1. Regression Tests (RED) -- [x] 1.1 Add a focused jsdom component test that drives the real scatterplot host transform callback, verifies the full count/marker presentation, and observes that additional zoomed-in frames do not run another plot render. -- [x] 1.2 Add a focused Playwright project and test that wheel-zooms the loaded Explore scatterplot, verifies the complete spaced `N points · Zoomed in` chip, double-clicks to reset, and observes the marker disappear. +- [x] 1.1 Add a focused jsdom component test that drives the real scatterplot host transform callback, verifies the full count/marker presentation as a polite status, and observes that additional zoomed-in frames do not run another plot render. +- [x] 1.2 Add a focused Playwright project and test that locates the chip by its status role, wheel-zooms the loaded Explore scatterplot, verifies the complete spaced `N points · Zoomed in` presentation, double-clicks to reset, and observes the status return to the point count alone. - [x] 1.3 Run both focused tests against the unmodified implementation and record that they fail because the zoom marker is absent. +- [x] 1.4 Run the accessibility regression against the review head and record that it fails because the chip has no polite status semantics. ## 2. Minimal Implementation (GREEN) - [x] 2.1 Add a reactive boolean zoom-boundary state while keeping `_transform` non-reactive, and rely on Lit's boolean change detection to deduplicate repeated same-side transforms. -- [x] 2.2 Append `· Zoomed in` to the existing point-count chip only while the boolean state is true. +- [x] 2.2 Append `· Zoomed in` to the existing point-count chip only while the boolean state is true, and expose the chip as a polite status. - [x] 2.3 Run both focused tests and record that they pass. ## 3. Verification and Publication diff --git a/packages/core/src/components/scatter-plot/scatter-plot.ts b/packages/core/src/components/scatter-plot/scatter-plot.ts index 9263059f..7e67a932 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.ts @@ -1983,7 +1983,7 @@ export class ProtspaceScatterplot extends LitElement { : ''} ${this.data ? html` -
+
${this._getVisiblePointCount()} points ${this._isZoomedIn ? html`· Zoomed in` : ''}
diff --git a/packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts index 2cf2f643..1e59eafc 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts @@ -70,13 +70,13 @@ describe('scatterplot zoom indicator (#343)', () => { vi.restoreAllMocks(); }); - it('renders the point count and zoom label as spaced presentation items', async () => { + it('renders the point count and zoom label as a polite status', async () => { const plot = await makePlot(); const host = plot._interactionHost(); host.onTransform(d3.zoomIdentity.scale(2)); await plot.updateComplete; - const chip = plot.shadowRoot?.querySelector('.plot-indicator'); + const chip = plot.shadowRoot?.querySelector('[role="status"][aria-live="polite"]'); expect(chip?.querySelector('.point-count')?.textContent).toBe('1 points'); expect(chip?.querySelector('.zoom-indicator')?.textContent).toBe('· Zoomed in'); expect(Array.from(chip?.children ?? []).map((child) => child.className)).toEqual([ From fb660dd6610b7ae2ea82c2a43bf4160fd3790069 Mon Sep 17 00:00:00 2001 From: Florin Senoner <23100806+FlorinSenoner@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:15:39 +0200 Subject: [PATCH 4/4] fix(scatterplot): harden zoom indicator boundary --- apps/web/tests/zoom-indicator.spec.ts | 18 +------- .../changes/show-zoom-indicator/design.md | 12 +++--- .../specs/scatterplot-zoom-indicator/spec.md | 14 +++++- openspec/changes/show-zoom-indicator/tasks.md | 7 +++ .../scatter-plot/scatter-plot.styles.ts | 1 - .../components/scatter-plot/scatter-plot.ts | 20 +++++---- .../scatter-plot.zoom-indicator.test.ts | 43 ++++++++++++------- 7 files changed, 67 insertions(+), 48 deletions(-) diff --git a/apps/web/tests/zoom-indicator.spec.ts b/apps/web/tests/zoom-indicator.spec.ts index afd67d47..f98022a1 100644 --- a/apps/web/tests/zoom-indicator.spec.ts +++ b/apps/web/tests/zoom-indicator.spec.ts @@ -17,31 +17,17 @@ test.describe('scatterplot zoom indicator (#343)', () => { }; const pointCountChip = plot.getByRole('status'); await expect(pointCountChip).toHaveAttribute('aria-live', 'polite'); - const pointCount = pointCountChip.locator('.point-count'); - const zoomMarker = pointCountChip.locator('.zoom-indicator'); - await expect(zoomMarker).toHaveCount(0); + await expect(pointCountChip).toHaveText(/^\s*\d+ points\s*$/); await page.mouse.move(center.x, center.y); await page.mouse.wheel(0, -500); await expect .poll(() => plot.evaluate((element: any) => element._transform.k)) .toBeGreaterThan(1); - await expect(pointCount).toHaveText(/^\d+ points$/); - await expect(zoomMarker).toHaveText('· Zoomed in'); - const chipSpacing = await pointCountChip.evaluate((chip) => { - const count = chip.querySelector('.point-count')?.getBoundingClientRect(); - const marker = chip.querySelector('.zoom-indicator')?.getBoundingClientRect(); - return { - actual: count && marker ? marker.left - count.right : 0, - configured: Number.parseFloat(getComputedStyle(chip).columnGap), - }; - }); - expect(chipSpacing.actual).toBeGreaterThan(0); - expect(chipSpacing.actual).toBeCloseTo(chipSpacing.configured, 1); + await expect(pointCountChip).toHaveText(/^\s*\d+ points · Zoomed in\s*$/); await page.mouse.dblclick(center.x, center.y); await expect.poll(() => plot.evaluate((element: any) => element._transform.k)).toBe(1); - await expect(zoomMarker).toHaveCount(0); await expect(pointCountChip).toHaveText(/^\s*\d+ points\s*$/); }); }); diff --git a/openspec/changes/show-zoom-indicator/design.md b/openspec/changes/show-zoom-indicator/design.md index f754f146..e5349f6d 100644 --- a/openspec/changes/show-zoom-indicator/design.md +++ b/openspec/changes/show-zoom-indicator/design.md @@ -8,7 +8,7 @@ Issue #343 requires a visible signal specifically for zooming in. The current `z **Goals:** -- Show `Zoomed in` next to the existing point count while the active D3 scale is greater than `1`. +- Show `Zoomed in` next to the existing point count while the active D3 scale is meaningfully greater than `1`, ignoring near-identity floating-point residue from symmetric wheel gestures. - Remove the marker once reset reaches identity. - Announce zoom and reset status changes to assistive technology without moving focus. - Preserve the non-reactive `_transform` performance invariant by scheduling Lit updates only when the boolean zoomed-in state changes. @@ -24,23 +24,23 @@ Issue #343 requires a visible signal specifically for zooming in. The current `z ### Reuse the existing point-count chip -The marker will render as `N points · Zoomed in` inside the existing bottom-left `.plot-indicator`. The chip reuses the component's established `role="status" aria-live="polite"` pattern so its changing content is announced without focus movement. A separate overlay was rejected because it would need new collision rules for no additional user value. +The marker will render as the single text run `N points · Zoomed in` inside the existing bottom-left `.plot-indicator`. The chip reuses the component's established `role="status" aria-live="polite"` pattern so its changing content is announced without focus movement. A separate overlay was rejected because it would need new collision rules for no additional user value. ### Store only a reactive boolean boundary -The scatterplot will add a reactive `_isZoomedIn` boolean. The host callback will continue assigning every transform to the plain `_transform` field, then assign `t.k > 1` to the boolean. Lit's default change detection ignores equal boolean values, so it enqueues a render once when zoom crosses above identity and once when reset reaches identity, rather than once per D3 frame. +The scatterplot will add a reactive `_isZoomedIn` boolean. The host callback will continue assigning every transform to the plain `_transform` field, then compare the scale against identity plus a small fixed tolerance before assigning the boolean. The tolerance treats multiplicative wheel round-trip residue as identity without suppressing a perceptible zoom. Lit's default change detection ignores equal boolean values, so it enqueues a template render once when zoom crosses above identity and once when reset reaches identity, rather than once per D3 frame. Because the marker is light-DOM text and the controller already renders transformed plot content imperatively, marker-only updates will be excluded from the `updated()` path that redraws WebGL and rebuilds selection overlays. Keeping the boolean on the scatterplot host, rather than the interaction controller, preserves the controller's role as a generic transform dispatcher and keeps rendering state beside the template that consumes it. ### Verify both state propagation and user-visible behavior -A focused jsdom test will drive the real host bridge, assert the count and marker render as a polite status, and observe actual plot rendering across the no-repeat-update boundary for additional `k > 1` transforms. A Playwright regression will locate the chip by its status role, wheel over the real Explore scatterplot, verify the complete `N points · Zoomed in` presentation, double-click reset, and assert the status returns to the point count alone. +A focused jsdom test will drive the real host bridge, assert the exact count/marker text renders as a polite status, observe `isUpdatePending` across repeated same-side transforms, verify marker-only state changes do not redraw WebGL, and cover near-identity wheel residue. A Playwright regression will locate the chip by its status role, wheel over the real Explore scatterplot, verify the complete `N points · Zoomed in` presentation, double-click reset, and assert the status returns to the point count alone. ## Risks / Trade-offs -- **Floating-point values during reset could keep the marker visible until the transition finishes** → This is intentional: the plot remains non-identity until D3 emits the final `k === 1` transform. +- **Multiplicative wheel accumulation can finish a symmetric gesture a few ULPs above `1`** → Treat scales within a small fixed tolerance of identity as identity; the exact reset transform remains covered as well. - **A marker appended to the count chip is less prominent than a dedicated badge** → The count chip is persistent, unobtrusive, and has no collision risk; the text remains visible throughout the zoomed state. -- **Reactive state could regress zoom performance if equal values enqueue updates** → Exercise consecutive same-side transforms and assert that no additional plot render runs. +- **Reactive state could regress zoom performance** → Exercise consecutive same-side transforms through Lit's scheduling signal and independently assert that marker-only updates do not enter the WebGL/overlay redraw path. ## Migration Plan diff --git a/openspec/changes/show-zoom-indicator/specs/scatterplot-zoom-indicator/spec.md b/openspec/changes/show-zoom-indicator/specs/scatterplot-zoom-indicator/spec.md index 32436e58..88c5ecbd 100644 --- a/openspec/changes/show-zoom-indicator/specs/scatterplot-zoom-indicator/spec.md +++ b/openspec/changes/show-zoom-indicator/specs/scatterplot-zoom-indicator/spec.md @@ -2,7 +2,7 @@ ### Requirement: Scatterplot indicates a zoomed-in view -The scatterplot SHALL append the text `Zoomed in` to its existing visible point-count indicator whenever the active view scale is greater than the identity scale of `1`. The scatterplot SHALL NOT show that marker at identity scale, while zoomed out below identity, or for translation alone at identity scale. The point-count indicator SHALL expose its changing content as a polite status message so assistive technology can announce zoom and reset changes without moving focus. +The scatterplot SHALL append the text `Zoomed in` to its existing visible point-count indicator whenever the active view scale is greater than the identity scale of `1` by more than the implementation's small numerical tolerance. The scatterplot SHALL NOT show that marker at identity scale, for near-identity floating-point residue, while zoomed out below identity, or for translation alone at identity scale. The point-count indicator SHALL expose its changing content as a polite status message so assistive technology can announce zoom and reset changes without moving focus. #### Scenario: User zooms in with the wheel @@ -20,6 +20,11 @@ The scatterplot SHALL append the text `Zoomed in` to its existing visible point- - **WHEN** the existing reset behavior returns the view to identity scale `1` - **THEN** the point-count indicator no longer includes `Zoomed in` +#### Scenario: Symmetric wheel gesture returns near identity + +- **WHEN** accumulated wheel transforms leave the scale only within the numerical tolerance above `1` +- **THEN** the point-count indicator does not include `Zoomed in` + #### Scenario: Assistive technology receives zoom-state changes - **WHEN** the point-count indicator changes between identity and zoomed-in content @@ -33,7 +38,7 @@ The scatterplot SHALL append the text `Zoomed in` to its existing visible point- ### Requirement: Zoom indication preserves transform rendering performance -The scatterplot MUST keep the full D3 transform non-reactive and SHALL derive a separate reactive boolean for the zoomed-in boundary. Transform updates SHALL schedule marker-related Lit rendering only when that boolean changes. +The scatterplot MUST keep the full D3 transform non-reactive and SHALL derive a separate reactive boolean for the zoomed-in boundary. Transform updates SHALL schedule marker-related Lit rendering only when that boolean changes. Marker-only Lit updates SHALL NOT redraw WebGL content or rebuild selection overlays already handled by the imperative zoom path. #### Scenario: Zoom gesture emits multiple zoomed-in frames @@ -44,3 +49,8 @@ The scatterplot MUST keep the full D3 transform non-reactive and SHALL derive a - **WHEN** a reset transition emits zoomed-in frames followed by its final identity frame - **THEN** the reactive zoom-indicator state changes once on the final identity frame + +#### Scenario: Zoom-indicator state crosses its boundary + +- **WHEN** a transform changes only the reactive zoom-indicator state +- **THEN** the status text updates without an additional WebGL redraw or selection-overlay rebuild diff --git a/openspec/changes/show-zoom-indicator/tasks.md b/openspec/changes/show-zoom-indicator/tasks.md index 8ce26e9f..cce4e0f4 100644 --- a/openspec/changes/show-zoom-indicator/tasks.md +++ b/openspec/changes/show-zoom-indicator/tasks.md @@ -16,3 +16,10 @@ - [x] 3.1 Repeat the original browser reproduction and confirm the marker appears above identity and disappears after reset with no new relevant console errors. - [x] 3.2 Run the affected core and Playwright test projects, then run `pnpm precommit`. - [x] 3.3 Validate the OpenSpec change and review the final diff for issue-only scope. + +## 4. Adversarial Review Follow-up + +- [x] 4.1 Rewrite the boundary-scheduling regression around Lit's `isUpdatePending` signal and add a failing regression showing marker-only updates must not redraw WebGL. +- [x] 4.2 Add a failing regression for near-identity floating-point residue from symmetric wheel accumulation. +- [x] 4.3 Render the count and conditional marker as one exact text run and remove the marker-only gap styling and query-hook spans. +- [x] 4.4 Run focused component and browser tests, strict OpenSpec validation, and the repository precommit checks. diff --git a/packages/core/src/components/scatter-plot/scatter-plot.styles.ts b/packages/core/src/components/scatter-plot/scatter-plot.styles.ts index 1f5b9ddc..0470dc90 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.styles.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.styles.ts @@ -140,7 +140,6 @@ const scatterplotStylesCore = css` z-index: var(--z-overlay); display: flex; align-items: center; - gap: 0.25rem; height: 2rem; padding: 0 0.625rem; box-sizing: border-box; diff --git a/packages/core/src/components/scatter-plot/scatter-plot.ts b/packages/core/src/components/scatter-plot/scatter-plot.ts index 7e67a932..c9d5e030 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.ts @@ -83,6 +83,10 @@ const VIRTUALIZATION_PADDING = 100; const HIT_TEST_SEARCH_RADIUS_PX = 15; const POINT_RADIUS_SIZE_DIVISOR = 3; +// D3 wheel zoom accumulates scale multiplicatively, so a symmetric round trip +// can finish a few ULPs above identity even though the view is visually reset. +const ZOOM_IDENTITY_EPSILON = 1e-6; + /** Default number of bins for numeric→categorical materialization. Mirrors * materializeVisualizationData's `defaultBinCount = 10` default. */ const DEFAULT_NUMERIC_BIN_COUNT = 10; @@ -866,12 +870,13 @@ export class ProtspaceScatterplot extends LitElement { this._webglRenderer?.invalidateStyleCache(); this._renderPlot(); } - // Render for other changes - const selectionKeys = ['selectedProteinIds', 'highlightedProteinIds']; + // These keys affect only the template or are rendered by the selection block + // above. Zoom transforms already redraw through the interaction controller's RAF. + const noAdditionalRenderKeys = ['selectedProteinIds', 'highlightedProteinIds', '_isZoomedIn']; const changedKeys = Array.from(changedProperties.keys()).map(String); - const onlySelectionChanged = - changedKeys.length > 0 && changedKeys.every((k) => selectionKeys.includes(k)); - if (!onlySelectionChanged) { + const onlyNoAdditionalRenderKeysChanged = + changedKeys.length > 0 && changedKeys.every((k) => noAdditionalRenderKeys.includes(k)); + if (!onlyNoAdditionalRenderKeysChanged) { this._renderPlot(); this._updateSelectionOverlays(); } @@ -1226,7 +1231,7 @@ export class ProtspaceScatterplot extends LitElement { resolveSlotsToIds: (slots) => this._slotsToInteractiveIds(slots), onTransform: (t) => { this._transform = t; - this._isZoomedIn = t.k > 1; + this._isZoomedIn = t.k > 1 + ZOOM_IDENTITY_EPSILON; this._connectorOverlay.updateZoomScale(t.k); }, onSelect: (ids, clearVisual) => this._commitSelection(ids, clearVisual), @@ -1984,8 +1989,7 @@ export class ProtspaceScatterplot extends LitElement { ${this.data ? html`
- ${this._getVisiblePointCount()} points - ${this._isZoomedIn ? html`· Zoomed in` : ''} + ${`${this._getVisiblePointCount()} points${this._isZoomedIn ? ' · Zoomed in' : ''}`}
` : ''} diff --git a/packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts index 1e59eafc..7ea9e4e4 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.zoom-indicator.test.ts @@ -26,10 +26,12 @@ import './scatter-plot'; type ZoomIndicatorInternals = HTMLElement & { data: VisualizationData; selectedAnnotation: string; + isUpdatePending: boolean; updateComplete: Promise; firstUpdated(): void; _interactionHost(): PlotInteractionHost; _renderPlot(): void; + _updateSelectionOverlays(): void; }; function makeData(): VisualizationData { @@ -77,36 +79,47 @@ describe('scatterplot zoom indicator (#343)', () => { await plot.updateComplete; const chip = plot.shadowRoot?.querySelector('[role="status"][aria-live="polite"]'); - expect(chip?.querySelector('.point-count')?.textContent).toBe('1 points'); - expect(chip?.querySelector('.zoom-indicator')?.textContent).toBe('· Zoomed in'); - expect(Array.from(chip?.children ?? []).map((child) => child.className)).toEqual([ - 'point-count', - 'zoom-indicator', - ]); + expect(chip?.textContent?.trim()).toBe('1 points · Zoomed in'); }); - it('schedules rendering only when the zoomed-in boundary changes', async () => { + it('schedules a Lit update only when the zoomed-in boundary changes', async () => { const plot = await makePlot(); const host = plot._interactionHost(); - const renderPlot = vi.spyOn(plot, '_renderPlot').mockImplementation(() => {}); host.onTransform(d3.zoomIdentity.scale(2)); + expect(plot.isUpdatePending).toBe(true); await plot.updateComplete; - expect(renderPlot).toHaveBeenCalledTimes(1); - renderPlot.mockClear(); host.onTransform(d3.zoomIdentity.scale(3)); - await plot.updateComplete; - expect(renderPlot).not.toHaveBeenCalled(); + expect(plot.isUpdatePending).toBe(false); host.onTransform(d3.zoomIdentity.translate(30, 20)); + expect(plot.isUpdatePending).toBe(true); await plot.updateComplete; - expect(renderPlot).toHaveBeenCalledTimes(1); - expect(plot.shadowRoot?.querySelector('.zoom-indicator')).toBeNull(); + expect(plot.shadowRoot?.querySelector('[role="status"]')?.textContent?.trim()).toBe('1 points'); - renderPlot.mockClear(); host.onTransform(d3.zoomIdentity.scale(0.5)); + expect(plot.isUpdatePending).toBe(false); + }); + + it('does not redraw plot content for a zoom-indicator-only update', async () => { + const plot = await makePlot(); + const renderPlot = vi.spyOn(plot, '_renderPlot').mockImplementation(() => {}); + const updateOverlays = vi.spyOn(plot, '_updateSelectionOverlays').mockImplementation(() => {}); + + plot._interactionHost().onTransform(d3.zoomIdentity.scale(2)); await plot.updateComplete; + expect(renderPlot).not.toHaveBeenCalled(); + expect(updateOverlays).not.toHaveBeenCalled(); + }); + + it('treats floating-point wheel residue as identity', async () => { + const plot = await makePlot(); + + plot._interactionHost().onTransform(d3.zoomIdentity.scale(1.0000000000000002)); + await plot.updateComplete; + + expect(plot.shadowRoot?.querySelector('[role="status"]')?.textContent?.trim()).toBe('1 points'); }); });