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`