diff --git a/apps/web/__tests__/accessibility/data-regression.a11y.test.tsx b/apps/web/__tests__/accessibility/data-regression.a11y.test.tsx
new file mode 100644
index 00000000..fba4a718
--- /dev/null
+++ b/apps/web/__tests__/accessibility/data-regression.a11y.test.tsx
@@ -0,0 +1,110 @@
+/**
+ * Accessibility tests for the Data & Regression tab (jest-axe).
+ *
+ * Guard: native
semantics with caption/headers, labelled inputs and
+ * an accessibly-named residual toggle — verified with axe, not just biome.
+ *
+ * The global vitest.setup ResizeObserver mock is a no-op, so DataPointsOverlay
+ * (rendered directly inside this tab) never leaves its {width:0,height:0}
+ * early-return branch under jsdom — the accessible, keyboard-operable
+ * draggable points (``) would
+ * never actually be mounted, so no test in this suite would ever notice a
+ * regression to that markup. The "fitted state" test below stubs a firing
+ * ResizeObserver so the points actually mount and asserts on them directly
+ * (role/tabindex/accessible name); full axe coverage of that exact markup
+ * lives in DataPointsOverlay.test.tsx (see the note on that test for why
+ * axe() isn't also run here).
+ */
+
+import { fireEvent, render, screen } from '@testing-library/react';
+import { axe } from 'jest-axe';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { DataRegressionTab } from '@/components/plots';
+
+vi.mock('@/components/plots/Plot2D', () => ({
+ Plot2D: () => ,
+}));
+
+/** A ResizeObserver stub that immediately fires its callback with a fixed,
+ * non-zero content rect — unlike the global no-op mock in vitest.setup.ts. */
+class FiringResizeObserver {
+ #callback: ResizeObserverCallback;
+
+ constructor(callback: ResizeObserverCallback) {
+ this.#callback = callback;
+ }
+
+ observe(target: Element) {
+ this.#callback(
+ [{ target, contentRect: { width: 600, height: 400 } } as unknown as ResizeObserverEntry],
+ this as unknown as ResizeObserver,
+ );
+ }
+
+ unobserve() {}
+ disconnect() {}
+}
+
+describe('Data & Regression accessibility (WCAG 2.2)', () => {
+ it('empty tab has no axe violations', async () => {
+ const { container } = render();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ describe('with draggable data points mounted', () => {
+ beforeEach(() => {
+ vi.stubGlobal('ResizeObserver', FiringResizeObserver);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('fitted state mounts keyboard-operable draggable points with accessible names', async () => {
+ render();
+
+ const table = screen.getByRole('table', { name: 'dataTitle' });
+ fireEvent.paste(table, {
+ clipboardData: { getData: () => '1\t3\n2\t5\n3\t7\n4\t9' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'canned.linear' }));
+ await screen.findByText('status.converged');
+
+ // Residual toggle is a labelled switch.
+ expect(screen.getByRole('switch', { name: 'residualsToggle' })).toBeInTheDocument();
+
+ // The draggable data points actually mounted (regression guard for the
+ // ResizeObserver-mock blind spot described above) and are keyboard
+ // operable. (The global next-intl mock returns raw `key:{"args":...}`
+ // strings, not the formatted `pointLabel` sentence.)
+ const points = screen.getAllByRole('button', { name: /^pointLabel:/ });
+ expect(points.length).toBeGreaterThan(0);
+ for (const point of points) {
+ expect(point).toHaveAttribute('tabindex', '0');
+ expect(point).toHaveAccessibleName();
+ }
+
+ // NOTE: axe() is intentionally not run in this scenario. The global
+ // next-intl mock renders interpolated translations as literal
+ // `key:{"x":"1",...}` strings, and axe-core's selector generator
+ // crashes under happy-dom when an aria-label contains embedded double
+ // quotes (a test-environment interaction bug, not a real accessibility
+ // defect — real translated point labels never contain quote
+ // characters). The interactive markup itself IS exercised by axe in
+ // DataPointsOverlay.test.tsx, which supplies plain-text labels.
+ });
+ });
+
+ it('data table uses native semantics: caption and column headers', () => {
+ render();
+
+ const table = screen.getByRole('table', { name: 'dataTitle' });
+ expect(table.querySelector('caption')).not.toBeNull();
+ expect(screen.getAllByRole('columnheader').length).toBeGreaterThanOrEqual(2);
+ // Every data cell input is labelled.
+ for (const input of screen.getAllByRole('textbox')) {
+ expect(input).toHaveAccessibleName();
+ }
+ });
+});
diff --git a/apps/web/app/[locale]/plot/page.tsx b/apps/web/app/[locale]/plot/page.tsx
index a82f88d6..b86c0d54 100644
--- a/apps/web/app/[locale]/plot/page.tsx
+++ b/apps/web/app/[locale]/plot/page.tsx
@@ -31,6 +31,7 @@ import { VariableSliders } from '@/components/plot/variable-sliders';
import {
type AnalysisFunction,
buildRelationConfig,
+ DataRegressionTab,
type FunctionDefinition,
FunctionInput,
Plot2D,
@@ -918,6 +919,12 @@ export default function PlotsExamplesPage() {
>
{t('tab.3dSurface')}
+
+ {t('tab.dataRegression')}
+
@@ -1408,6 +1415,13 @@ const config = {
+
+ {/* ----------------------------------------------------------------
+ DATA & REGRESSION TAB
+ ---------------------------------------------------------------- */}
+
+
+
{/* Feature cards */}
diff --git a/apps/web/components/plots/Plot2D.tsx b/apps/web/components/plots/Plot2D.tsx
index 8b52dc07..aee5f918 100644
--- a/apps/web/components/plots/Plot2D.tsx
+++ b/apps/web/components/plots/Plot2D.tsx
@@ -43,6 +43,15 @@ export interface Plot2DProps {
onCanvasReady?: (canvas: HTMLCanvasElement) => void;
/** When true, renders the annotation toolbar and overlay. Defaults to false. */
enableAnnotations?: boolean;
+ /**
+ * Controlled-viewport mode: when true, external changes to `config.viewport`
+ * are pushed into the interaction controller so the next drag continues from
+ * the new viewport instead of snapping back to the controller's stale seed.
+ * Use when the parent feeds `onViewportChange` back into `config.viewport`
+ * (e.g. the Data & Regression tab's auto-fit). Defaults to false, preserving
+ * the legacy contract where `config.viewport` is only the initial view.
+ */
+ syncViewportToConfig?: boolean;
}
/**
@@ -57,6 +66,7 @@ export function Plot2D({
onViewportChange,
onCanvasReady,
enableAnnotations = false,
+ syncViewportToConfig = false,
}: Plot2DProps) {
const canvasRef = useRef(null);
const containerRef = useRef(null);
@@ -382,6 +392,26 @@ export function Plot2D({
// config re-render) and read config via the stable ref inside the handler.
}, [isReady, enableInteractions, config.type]);
+ // Controlled-viewport mode: push external config.viewport changes into the
+ // controller. Value-compared, so the echo from the controller's own events
+ // (viewport state fed back into config by the parent) terminates immediately.
+ useEffect(() => {
+ if (!syncViewportToConfig) return;
+ if (config.type !== '2d-cartesian' && config.type !== '2d-parametric') return;
+ const controller = controllerRef.current;
+ if (!controller) return;
+ const target = config.viewport;
+ const current = controller.getViewport();
+ if (
+ current.xMin !== target.xMin ||
+ current.xMax !== target.xMax ||
+ current.yMin !== target.yMin ||
+ current.yMax !== target.yMax
+ ) {
+ controller.setViewport(target);
+ }
+ }, [syncViewportToConfig, config]);
+
// --- Fix 1: Debounced auto-refresh when config changes ---
// Use a ref to hold the pending timer so we can clear it on cleanup.
const debounceTimerRef = useRef | null>(null);
diff --git a/apps/web/components/plots/index.ts b/apps/web/components/plots/index.ts
index 5876d723..c564b869 100644
--- a/apps/web/components/plots/index.ts
+++ b/apps/web/components/plots/index.ts
@@ -35,6 +35,19 @@ export type { PolarAxisLabelsProps } from './PolarAxisLabels';
export { PolarAxisLabels } from './PolarAxisLabels';
export type { RelationDefinition, RelationInputProps } from './RelationInput';
export { RELATION_COLORS, RelationInput } from './RelationInput';
+export type {
+ DataPointsOverlayProps,
+ OverlayPoint,
+ OverlayViewport,
+} from './regression/DataPointsOverlay';
+export { DataPointsOverlay } from './regression/DataPointsOverlay';
+export { DataRegressionTab } from './regression/DataRegressionTab';
+export type { DataTableProps, DataTableValue } from './regression/DataTable';
+export { DataTable } from './regression/DataTable';
+export type { FitStatsPanelProps } from './regression/FitStatsPanel';
+export { FitStatsPanel } from './regression/FitStatsPanel';
+export type { ModelParseState, RegressionModelInputProps } from './regression/RegressionModelInput';
+export { RegressionModelInput } from './regression/RegressionModelInput';
export type { BuildRelationConfigOptions } from './relation-config';
export { buildRelationConfig, buildRelationEntries } from './relation-config';
export type { SurfaceEditor3DProps } from './SurfaceEditor3D';
diff --git a/apps/web/components/plots/regression/DataPointsOverlay.test.tsx b/apps/web/components/plots/regression/DataPointsOverlay.test.tsx
new file mode 100644
index 00000000..4d15f5ab
--- /dev/null
+++ b/apps/web/components/plots/regression/DataPointsOverlay.test.tsx
@@ -0,0 +1,172 @@
+/**
+ * Tests for the draggable/keyboard-operable data points overlay.
+ *
+ * The global vitest.setup ResizeObserver mock is a no-op (`observe(){}`
+ * never invokes its callback), so under jsdom/happy-dom the component's
+ * `size` state never leaves {0,0} and it permanently takes its early-return
+ * branch — a bare `