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 `
semantics (caption + column headers) for accessibility. + * - Spreadsheet clipboard paste: TSV (or ;-separated) payloads fill the grid + * starting at the focused cell, growing rows/columns as needed. `,` is + * reserved as a decimal separator (de/fr/ru/uk locales), never a field + * separator. + * - Column headers are editable identifiers, validated live. + * - Fully controlled and free of plot-specific imports so worksheets can host + * it later. + * + * @module components/plots/regression/DataTable + */ + +import { Plus, X } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { type ClipboardEvent, type KeyboardEvent, useRef, useState } from 'react'; +import { cn } from '@/lib/utils'; +import { Button } from '../../ui/button'; +import { Input } from '../../ui/input'; + +/** Valid column identifier (mirrors the math-engine tilde-model rule). */ +const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/; + +export interface DataTableValue { + columns: string[]; + rows: (number | null)[][]; +} + +export interface DataTableProps { + columns: string[]; + rows: (number | null)[][]; + onChange: (next: DataTableValue) => void; + className?: string; +} + +/** Generates a fresh column name (x2, y2, x3, …) not colliding with existing ones. */ +function nextColumnName(existing: readonly string[], index: number): string { + const prefix = index % 2 === 0 ? 'x' : 'y'; + for (let n = Math.floor(index / 2) + 1; ; n++) { + const candidate = `${prefix}${n}`; + if (!existing.includes(candidate)) return candidate; + } +} + +/** Matches a lone comma-decimal number, e.g. "1,5" or "-3,14" (de/fr/ru/uk locales). */ +const COMMA_DECIMAL_PATTERN = /^-?\d+,\d+$/; + +/** + * Parses one clipboard cell to a number or null. A bare comma decimal + * separator (no thousands grouping, no dot) is normalized to a dot first — + * Excel/LibreOffice in comma-decimal locales render 1.5 as "1,5". + */ +function parseCell(text: string): number | null { + const trimmed = text.trim(); + if (trimmed === '') return null; + const normalized = COMMA_DECIMAL_PATTERN.test(trimmed) ? trimmed.replace(',', '.') : trimmed; + const value = Number(normalized); + return Number.isFinite(value) ? value : null; +} + +/** + * Parses a spreadsheet clipboard payload into a cell matrix. + * Cells are tab-separated; when the payload has no tabs at all, `;` is + * accepted as a fallback field separator. `,` is deliberately NOT treated as + * a field separator: comma-decimal locale spreadsheets (de/fr/ru/uk) use `;` + * as the field separator for exactly this reason, reserving `,` for decimal + * numbers (see parseCell) — otherwise a single-column paste like "1,5\n2,5" + * would be silently split into two bogus columns. + */ +function parseClipboardMatrix(text: string): (number | null)[][] { + const lines = text.split(/\r?\n/); + while (lines.length > 0 && lines[lines.length - 1]?.trim() === '') lines.pop(); + const hasTab = text.includes('\t'); + return lines.map((line) => (hasTab ? line.split('\t') : line.split(';')).map(parseCell)); +} + +export function DataTable({ columns, rows, onChange, className }: DataTableProps) { + const t = useTranslations('plots.regression'); + const tableRef = useRef(null); + // Local text for the cell currently being edited, so intermediate states + // like "3." or "-" don't get normalized away mid-keystroke. + const [editing, setEditing] = useState<{ row: number; col: number; text: string } | null>(null); + + const columnInvalid = (index: number): boolean => { + const name = columns[index] ?? ''; + return !IDENTIFIER_PATTERN.test(name) || columns.indexOf(name) !== index; + }; + + const commitCell = (row: number, col: number, value: number | null) => { + const nextRows = rows.map((r, ri) => + ri === row ? r.map((c, ci) => (ci === col ? value : c)) : r, + ); + onChange({ columns, rows: nextRows }); + }; + + const renameColumn = (index: number, name: string) => { + onChange({ columns: columns.map((c, i) => (i === index ? name : c)), rows }); + }; + + const addRow = () => { + onChange({ columns, rows: [...rows, columns.map(() => null)] }); + }; + + const addColumn = () => { + const name = nextColumnName(columns, columns.length); + onChange({ columns: [...columns, name], rows: rows.map((r) => [...r, null]) }); + }; + + const removeRow = (index: number) => { + onChange({ columns, rows: rows.filter((_, i) => i !== index) }); + }; + + const focusCell = (row: number, col: number) => { + requestAnimationFrame(() => { + tableRef.current + ?.querySelector(`input[data-row="${row}"][data-col="${col}"]`) + ?.focus(); + }); + }; + + const handlePaste = (event: ClipboardEvent) => { + const text = event.clipboardData.getData('text/plain'); + if (!text.includes('\t') && !text.includes('\n')) return; // single value: default input behavior + event.preventDefault(); + + const matrix = parseClipboardMatrix(text); + if (matrix.length === 0) return; + + // Paste anchor: the focused cell, or the top-left corner. + const active = document.activeElement; + const startRow = active instanceof HTMLInputElement ? Number(active.dataset['row'] ?? 0) : 0; + const startCol = active instanceof HTMLInputElement ? Number(active.dataset['col'] ?? 0) : 0; + + const neededCols = startCol + Math.max(...matrix.map((r) => r.length)); + const nextColumns = [...columns]; + while (nextColumns.length < neededCols) { + nextColumns.push(nextColumnName(nextColumns, nextColumns.length)); + } + + const neededRows = startRow + matrix.length; + const nextRows = rows.map((r) => { + const padded = [...r]; + while (padded.length < nextColumns.length) padded.push(null); + return padded; + }); + while (nextRows.length < neededRows) { + nextRows.push(nextColumns.map(() => null)); + } + + matrix.forEach((matrixRow, ri) => { + matrixRow.forEach((value, ci) => { + const targetRow = nextRows[startRow + ri]; + if (targetRow) targetRow[startCol + ci] = value; + }); + }); + + setEditing(null); + onChange({ columns: nextColumns, rows: nextRows }); + }; + + const handleCellKeyDown = (event: KeyboardEvent, row: number, col: number) => { + if (event.key !== 'Enter') return; + event.preventDefault(); + setEditing(null); + if (row === rows.length - 1) { + onChange({ columns, rows: [...rows, columns.map(() => null)] }); + } + focusCell(row + 1, col); + }; + + return ( +
+
+
+ + + + {columns.map((name, ci) => ( + + ))} + + + + + {rows.map((row, ri) => ( + + {columns.map((_, ci) => { + const isEditing = editing?.row === ri && editing.col === ci; + const stored = row[ci]; + const display = isEditing ? editing.text : stored == null ? '' : String(stored); + return ( + + ); + })} + + + ))} + +
{t('dataTitle')}
+ renameColumn(ci, e.target.value)} + aria-label={t('columnName')} + aria-invalid={columnInvalid(ci)} + required + pattern="[A-Za-z][A-Za-z0-9_]*" + className={cn( + 'h-8 w-auto min-w-16 field-sizing-content text-sm font-semibold font-mono', + 'user-invalid:border-red-500 aria-invalid:border-red-500', + )} + /> + + {t('removeRow')} +
+ { + const text = e.target.value; + setEditing({ row: ri, col: ci, text }); + commitCell(ri, ci, parseCell(text)); + }} + onBlur={() => setEditing(null)} + onKeyDown={(e) => handleCellKeyDown(e, ri, ci)} + className="h-8 w-auto min-w-16 field-sizing-content text-sm font-mono tabular-nums" + /> + + +
+ + +
+ + +
+ + {columns.some((_, i) => columnInvalid(i)) && ( +

+ {t('invalidColumnName')} +

+ )} + +

{t('pasteHint')}

+ + ); +} diff --git a/apps/web/components/plots/regression/FitStatsPanel.tsx b/apps/web/components/plots/regression/FitStatsPanel.tsx new file mode 100644 index 00000000..eac6207b --- /dev/null +++ b/apps/web/components/plots/regression/FitStatsPanel.tsx @@ -0,0 +1,147 @@ +'use client'; + +/** + * Fit statistics panel: fitted parameters, R², RMSE, iterations, convergence + * status and warnings — plus the residual-segment toggle. + * + * Dataviz fundamentals: one panel, one axis of truth, every numeral + * tabular-nums. A non-converged or failed fit is ALWAYS visibly flagged — + * never a silent bad fit. + * + * @module components/plots/regression/FitStatsPanel + */ + +import type { FitResult, FitWarning } from '@nextcalc/math-engine/stats'; +import { AlertTriangle } from 'lucide-react'; +import { useFormatter, useTranslations } from 'next-intl'; +import { useId } from 'react'; +import { Badge } from '../../ui/badge'; +import { Label } from '../../ui/label'; +import { Switch } from '../../ui/switch'; + +export interface FitStatsPanelProps { + fit: FitResult | null; + showResiduals: boolean; + onToggleResiduals: (value: boolean) => void; +} + +/** Maps a fit failure status to its translation key. */ +const FAILURE_KEYS = { + 'invalid-model': 'status.invalidModel', + 'insufficient-data': 'status.insufficientData', + singular: 'status.singular', + diverged: 'status.diverged', +} as const; + +export function FitStatsPanel({ fit, showResiduals, onToggleResiduals }: FitStatsPanelProps) { + const t = useTranslations('plots.regression'); + const format = useFormatter(); + const toggleId = useId(); + + if (fit === null) return null; + + const warningText = (warning: FitWarning): string => { + switch (warning.code) { + case 'dropped-rows': + return t('warnings.droppedRows', { count: warning.count }); + case 'bound-hit': + return t('warnings.boundHit', { name: warning.name }); + case 'zero-variance': + return t('warnings.zeroVariance'); + } + }; + + if (!fit.ok) { + return ( +
+

+

+

{fit.message}

+
+ ); + } + + const number = (value: number) => format.number(value, { maximumSignificantDigits: 6 }); + + return ( +
+
+

{t('stats.title')}

+ {fit.status === 'converged' ? ( + + {t('status.converged')} + + ) : ( + + {t('status.maxIterations')} + + )} +
+ + + + + {Object.entries(fit.parameters).map(([name, value]) => ( + + + + + ))} + +
{t('stats.parameters')}
+ {name} + + {number(value)} + {fit.standardErrors?.[name] !== undefined && ( + + {' '} + ± {format.number(fit.standardErrors[name], { maximumSignificantDigits: 3 })} + + )} +
+ +
+
+
{t('stats.r2')}
+
+ {format.number(fit.r2, { minimumFractionDigits: 4, maximumFractionDigits: 4 })} +
+
+
+
{t('stats.rmse')}
+
{number(fit.rmse)}
+
+
+
{t('stats.iterations')}
+
{fit.iterations}
+
+
+ + {fit.warnings.length > 0 && ( + + )} + +
+ + +
+
+ ); +} diff --git a/apps/web/components/plots/regression/RegressionModelInput.tsx b/apps/web/components/plots/regression/RegressionModelInput.tsx new file mode 100644 index 00000000..aefaef45 --- /dev/null +++ b/apps/web/components/plots/regression/RegressionModelInput.tsx @@ -0,0 +1,108 @@ +'use client'; + +/** + * Tilde-syntax regression model input with canned-model chips. + * + * Deliberately NOT built on FunctionInput: its expression validator rejects + * the `~` separator. Validation feedback mirrors FunctionInput's visual + * language (CheckCircle2 / AlertCircle + text, aria-live). + * + * @module components/plots/regression/RegressionModelInput + */ + +import { buildCannedModel, type CannedModelKind } from '@nextcalc/math-engine/stats'; +import { AlertCircle, CheckCircle2 } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useId } from 'react'; +import { Input } from '../../ui/input'; +import { Label } from '../../ui/label'; + +const CANNED_KINDS: readonly CannedModelKind[] = [ + 'linear', + 'quadratic', + 'exponential', + 'logarithmic', + 'logistic', + 'sinusoidal', +]; + +export type ModelParseState = + | { ok: true; parameters: readonly string[] } + | { ok: false; message: string }; + +export interface RegressionModelInputProps { + model: string; + onModelChange: (value: string) => void; + /** First (x) and second (y) data columns used by the canned-model chips. */ + firstX: string; + firstY: string; + /** Live parse feedback; null when the input is empty. */ + parseState: ModelParseState | null; +} + +export function RegressionModelInput({ + model, + onModelChange, + firstX, + firstY, + parseState, +}: RegressionModelInputProps) { + const t = useTranslations('plots.regression'); + const inputId = useId(); + + return ( +
+
+ + onModelChange(e.target.value)} + placeholder={t('modelPlaceholder')} + className={`h-9 text-sm font-mono ${ + model.trim() && parseState && !parseState.ok + ? 'border-red-500 focus:border-red-500' + : '' + }`} + /> + {/* Live validation feedback (same visual language as FunctionInput). */} +
+ {model.trim() !== '' && parseState?.ok === true && ( + <> +
+
+ +
+

+ {t('cannedTitle')} +

+
+ {CANNED_KINDS.map((kind) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 74ff6504..fab882de 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1044,6 +1044,67 @@ "inflection": "Wendepunkte", "derivative": "Ableitung", "integral": "Integral", + "pageTitle": "Interactive Plots", + "subtitle": "GPU-accelerated time-frequency domain analysis with interactive visualizations", + "badge": { + "fps": "60+ FPS", + "interactive": "Interactive", + "responsive": "Responsive" + }, + "tab": { + "2dCartesian": "2D Cartesian", + "2dPolar": "2D Polar", + "2dParametric": "2D Parametric", + "3dSurface": "3D Surface", + "dataRegression": "Daten & Regression", + "2dRelation": "2D-Relationen" + }, + "regression": { + "title": "Daten & Regression", + "dataTitle": "Datentabelle", + "pasteHint": "Fügen Sie Daten aus einer Tabellenkalkulation (tabulatorgetrennt) direkt in die Tabelle ein.", + "addColumn": "Spalte hinzufügen", + "addRow": "Zeile hinzufügen", + "removeRow": "Zeile entfernen", + "columnName": "Spaltenname", + "invalidColumnName": "Spaltennamen müssen mit einem Buchstaben beginnen und eindeutig sein", + "modelLabel": "Regressionsmodell", + "modelPlaceholder": "z. B. y1 ~ a*exp(b*x1)", + "modelValid": "Parameter: {parameters}", + "cannedTitle": "Modellvorlagen", + "canned": { + "linear": "Linear", + "quadratic": "Quadratisch", + "exponential": "Exponentiell", + "logarithmic": "Logarithmisch", + "logistic": "Logistisch", + "sinusoidal": "Sinusförmig" + }, + "stats": { + "title": "Anpassungsstatistik", + "parameters": "Angepasste Parameter", + "r2": "R²", + "rmse": "RMSE", + "iterations": "Iterationen" + }, + "status": { + "converged": "Konvergiert", + "maxIterations": "Nicht konvergiert (Iterationslimit erreicht) – Anpassung mit Vorsicht interpretieren", + "invalidModel": "Ungültiges Modell", + "insufficientData": "Nicht genügend Datenpunkte für die Anzahl der Parameter", + "singular": "Die Parameter lassen sich aus diesen Daten nicht bestimmen", + "diverged": "Das Modell kann mit diesen Daten nicht ausgewertet werden" + }, + "residualsToggle": "Residuen anzeigen", + "dragHint": "Punkte ziehen, um sie zu verschieben – die Anpassung aktualisiert sich sofort. Hintergrund ziehen zum Verschieben, Scrollen zum Zoomen.", + "pointLabel": "Datenpunkt ({x}, {y})", + "plotTitle": "Daten & angepasste Kurve", + "warnings": { + "droppedRows": "{count} Zeilen mit fehlenden oder nicht numerischen Werten ignoriert", + "boundHit": "Parameter {name} hat seine Schranke erreicht", + "zeroVariance": "Die abhängige Spalte ist konstant – R² ist nicht aussagekräftig" + } + }, "relation": { "inputTitle": "Relationen", "addButton": "Relation hinzufügen", @@ -1065,20 +1126,6 @@ "presetsLabel": "Relations-Vorlagen", "plotTitle": "2D-Relationsdiagramm", "plotDescription": "Implizite Kurven und schattierte Ungleichungsbereiche. Ziehen zum Verschieben, Scrollen zum Zoomen." - }, - "pageTitle": "Interactive Plots", - "subtitle": "GPU-accelerated time-frequency domain analysis with interactive visualizations", - "badge": { - "fps": "60+ FPS", - "interactive": "Interactive", - "responsive": "Responsive" - }, - "tab": { - "2dCartesian": "2D Cartesian", - "2dPolar": "2D Polar", - "2dParametric": "2D Parametric", - "2dRelation": "2D-Relationen", - "3dSurface": "3D Surface" } }, "chaos": { diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 6f5ad85c..7939153f 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1078,6 +1078,67 @@ "inflection": "Inflection Points", "derivative": "Derivative", "integral": "Integral", + "pageTitle": "Interactive Plots", + "subtitle": "GPU-accelerated mathematical visualization with real-time interactions", + "badge": { + "fps": "60+ FPS", + "interactive": "Interactive", + "responsive": "Responsive" + }, + "tab": { + "2dCartesian": "2D Cartesian", + "2dPolar": "2D Polar", + "2dParametric": "2D Parametric", + "3dSurface": "3D Surface", + "dataRegression": "Data & Regression", + "2dRelation": "2D Relations" + }, + "regression": { + "title": "Data & Regression", + "dataTitle": "Data table", + "pasteHint": "Paste data from a spreadsheet (tab-separated) directly into the table.", + "addColumn": "Add column", + "addRow": "Add row", + "removeRow": "Remove row", + "columnName": "Column name", + "invalidColumnName": "Column names must start with a letter and be unique", + "modelLabel": "Regression model", + "modelPlaceholder": "e.g. y1 ~ a*exp(b*x1)", + "modelValid": "Parameters: {parameters}", + "cannedTitle": "Model templates", + "canned": { + "linear": "Linear", + "quadratic": "Quadratic", + "exponential": "Exponential", + "logarithmic": "Logarithmic", + "logistic": "Logistic", + "sinusoidal": "Sinusoidal" + }, + "stats": { + "title": "Fit statistics", + "parameters": "Fitted parameters", + "r2": "R²", + "rmse": "RMSE", + "iterations": "Iterations" + }, + "status": { + "converged": "Converged", + "maxIterations": "Did not converge (iteration limit reached) — treat the fit with caution", + "invalidModel": "Invalid model", + "insufficientData": "Not enough data points for the number of parameters", + "singular": "Parameters cannot be identified from this data", + "diverged": "The model cannot be evaluated on this data" + }, + "residualsToggle": "Show residuals", + "dragHint": "Drag points to move them; the fit updates live. Drag the background to pan, scroll to zoom.", + "pointLabel": "Data point ({x}, {y})", + "plotTitle": "Data & fitted curve", + "warnings": { + "droppedRows": "Ignored {count} rows with missing or non-numeric values", + "boundHit": "Parameter {name} hit its bound", + "zeroVariance": "The dependent column is constant — R² is not meaningful" + } + }, "relation": { "inputTitle": "Relations", "addButton": "Add Relation", @@ -1099,20 +1160,6 @@ "presetsLabel": "Relation Presets", "plotTitle": "2D Relations Plot", "plotDescription": "Implicit curves and shaded inequality regions. Drag to pan, scroll to zoom." - }, - "pageTitle": "Interactive Plots", - "subtitle": "GPU-accelerated mathematical visualization with real-time interactions", - "badge": { - "fps": "60+ FPS", - "interactive": "Interactive", - "responsive": "Responsive" - }, - "tab": { - "2dCartesian": "2D Cartesian", - "2dPolar": "2D Polar", - "2dParametric": "2D Parametric", - "2dRelation": "2D Relations", - "3dSurface": "3D Surface" } }, "chaos": { diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index f2150c4b..2a1ce7b0 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -1044,6 +1044,67 @@ "inflection": "Puntos de inflexión", "derivative": "Derivada", "integral": "Integral", + "pageTitle": "Gráficos interactivos", + "subtitle": "GPU-accelerated time-frequency domain analysis with interactive visualizations", + "badge": { + "fps": "60+ FPS", + "interactive": "Interactivo", + "responsive": "Responsivo" + }, + "tab": { + "2dCartesian": "2D Cartesiano", + "2dPolar": "2D Polar", + "2dParametric": "2D Paramétrico", + "3dSurface": "Superficie 3D", + "dataRegression": "Datos y regresión", + "2dRelation": "2D Relaciones" + }, + "regression": { + "title": "Datos y regresión", + "dataTitle": "Tabla de datos", + "pasteHint": "Pega datos de una hoja de cálculo (separados por tabulaciones) directamente en la tabla.", + "addColumn": "Añadir columna", + "addRow": "Añadir fila", + "removeRow": "Eliminar fila", + "columnName": "Nombre de columna", + "invalidColumnName": "Los nombres de columna deben empezar por una letra y ser únicos", + "modelLabel": "Modelo de regresión", + "modelPlaceholder": "p. ej. y1 ~ a*exp(b*x1)", + "modelValid": "Parámetros: {parameters}", + "cannedTitle": "Plantillas de modelos", + "canned": { + "linear": "Lineal", + "quadratic": "Cuadrática", + "exponential": "Exponencial", + "logarithmic": "Logarítmica", + "logistic": "Logística", + "sinusoidal": "Sinusoidal" + }, + "stats": { + "title": "Estadísticas del ajuste", + "parameters": "Parámetros ajustados", + "r2": "R²", + "rmse": "RMSE", + "iterations": "Iteraciones" + }, + "status": { + "converged": "Convergió", + "maxIterations": "No convergió (límite de iteraciones alcanzado): interprete el ajuste con cautela", + "invalidModel": "Modelo no válido", + "insufficientData": "No hay suficientes puntos de datos para el número de parámetros", + "singular": "Los parámetros no pueden identificarse a partir de estos datos", + "diverged": "El modelo no puede evaluarse con estos datos" + }, + "residualsToggle": "Mostrar residuos", + "dragHint": "Arrastra los puntos para moverlos; el ajuste se actualiza al instante. Arrastra el fondo para desplazarte y usa la rueda para hacer zoom.", + "pointLabel": "Punto de datos ({x}, {y})", + "plotTitle": "Datos y curva ajustada", + "warnings": { + "droppedRows": "Se ignoraron {count} filas con valores ausentes o no numéricos", + "boundHit": "El parámetro {name} alcanzó su límite", + "zeroVariance": "La columna dependiente es constante: R² no es significativo" + } + }, "relation": { "inputTitle": "Relaciones", "addButton": "Agregar relación", @@ -1065,20 +1126,6 @@ "presetsLabel": "Plantillas de relaciones", "plotTitle": "Gráfico de relaciones 2D", "plotDescription": "Curvas implícitas y regiones de desigualdad sombreadas. Arrastre para desplazar, desplácese para hacer zoom." - }, - "pageTitle": "Gráficos interactivos", - "subtitle": "GPU-accelerated time-frequency domain analysis with interactive visualizations", - "badge": { - "fps": "60+ FPS", - "interactive": "Interactivo", - "responsive": "Responsivo" - }, - "tab": { - "2dCartesian": "2D Cartesiano", - "2dPolar": "2D Polar", - "2dParametric": "2D Paramétrico", - "2dRelation": "2D Relaciones", - "3dSurface": "Superficie 3D" } }, "chaos": { diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 6bd689cc..4ea8fd21 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1077,6 +1077,67 @@ "inflection": "Points d'inflexion", "derivative": "Dérivée", "integral": "Intégrale", + "pageTitle": "Graphiques interactifs", + "subtitle": "Visualisation mathématique accélérée par GPU avec interactions en temps réel", + "badge": { + "fps": "60+ FPS", + "interactive": "Interactif", + "responsive": "Réactif" + }, + "tab": { + "2dCartesian": "2D Cartésien", + "2dPolar": "2D Polaire", + "2dParametric": "2D Paramétrique", + "3dSurface": "Surface 3D", + "dataRegression": "Données et régression", + "2dRelation": "2D Relations" + }, + "regression": { + "title": "Données et régression", + "dataTitle": "Tableau de données", + "pasteHint": "Collez des données d'un tableur (séparées par des tabulations) directement dans le tableau.", + "addColumn": "Ajouter une colonne", + "addRow": "Ajouter une ligne", + "removeRow": "Supprimer la ligne", + "columnName": "Nom de colonne", + "invalidColumnName": "Les noms de colonnes doivent commencer par une lettre et être uniques", + "modelLabel": "Modèle de régression", + "modelPlaceholder": "p. ex. y1 ~ a*exp(b*x1)", + "modelValid": "Paramètres : {parameters}", + "cannedTitle": "Modèles prédéfinis", + "canned": { + "linear": "Linéaire", + "quadratic": "Quadratique", + "exponential": "Exponentielle", + "logarithmic": "Logarithmique", + "logistic": "Logistique", + "sinusoidal": "Sinusoïdale" + }, + "stats": { + "title": "Statistiques d'ajustement", + "parameters": "Paramètres ajustés", + "r2": "R²", + "rmse": "RMSE", + "iterations": "Itérations" + }, + "status": { + "converged": "Convergence atteinte", + "maxIterations": "Pas de convergence (limite d'itérations atteinte) — interprétez l'ajustement avec prudence", + "invalidModel": "Modèle non valide", + "insufficientData": "Pas assez de points de données pour le nombre de paramètres", + "singular": "Les paramètres ne peuvent pas être identifiés à partir de ces données", + "diverged": "Le modèle ne peut pas être évalué sur ces données" + }, + "residualsToggle": "Afficher les résidus", + "dragHint": "Faites glisser les points pour les déplacer ; l'ajustement se met à jour instantanément. Faites glisser l'arrière-plan pour vous déplacer, molette pour zoomer.", + "pointLabel": "Point de données ({x}, {y})", + "plotTitle": "Données et courbe ajustée", + "warnings": { + "droppedRows": "{count} lignes avec des valeurs manquantes ou non numériques ont été ignorées", + "boundHit": "Le paramètre {name} a atteint sa borne", + "zeroVariance": "La colonne dépendante est constante — R² n'est pas significatif" + } + }, "relation": { "inputTitle": "Relations", "addButton": "Ajouter une relation", @@ -1098,20 +1159,6 @@ "presetsLabel": "Modèles de relations", "plotTitle": "Graphique de relations 2D", "plotDescription": "Courbes implicites et régions d’inégalité ombrées. Glissez pour déplacer, faites défiler pour zoomer." - }, - "pageTitle": "Graphiques interactifs", - "subtitle": "Visualisation mathématique accélérée par GPU avec interactions en temps réel", - "badge": { - "fps": "60+ FPS", - "interactive": "Interactif", - "responsive": "Réactif" - }, - "tab": { - "2dCartesian": "2D Cartésien", - "2dPolar": "2D Polaire", - "2dParametric": "2D Paramétrique", - "2dRelation": "2D Relations", - "3dSurface": "Surface 3D" } }, "chaos": { diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index bf5e3e8c..fbff9f85 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -1077,6 +1077,67 @@ "inflection": "変曲点", "derivative": "導関数", "integral": "積分", + "pageTitle": "インタラクティブプロット", + "subtitle": "GPUアクセラレーション付きリアルタイムインタラクティブ数学ビジュアライゼーション", + "badge": { + "fps": "60+ FPS", + "interactive": "インタラクティブ", + "responsive": "レスポンシブ" + }, + "tab": { + "2dCartesian": "2D デカルト座標", + "2dPolar": "2D 極座標", + "2dParametric": "2D パラメトリック", + "3dSurface": "3D 曲面", + "dataRegression": "データと回帰", + "2dRelation": "2D 関係式" + }, + "regression": { + "title": "データと回帰", + "dataTitle": "データテーブル", + "pasteHint": "スプレッドシートのデータ(タブ区切り)をそのまま表に貼り付けられます。", + "addColumn": "列を追加", + "addRow": "行を追加", + "removeRow": "行を削除", + "columnName": "列名", + "invalidColumnName": "列名は英字で始まり、重複しないようにしてください", + "modelLabel": "回帰モデル", + "modelPlaceholder": "例: y1 ~ a*exp(b*x1)", + "modelValid": "パラメータ: {parameters}", + "cannedTitle": "モデルテンプレート", + "canned": { + "linear": "線形", + "quadratic": "二次", + "exponential": "指数", + "logarithmic": "対数", + "logistic": "ロジスティック", + "sinusoidal": "正弦波" + }, + "stats": { + "title": "フィット統計", + "parameters": "推定パラメータ", + "r2": "R²", + "rmse": "RMSE", + "iterations": "反復回数" + }, + "status": { + "converged": "収束", + "maxIterations": "収束しませんでした(反復上限に到達)— フィット結果は慎重に扱ってください", + "invalidModel": "無効なモデル", + "insufficientData": "パラメータ数に対してデータ点が不足しています", + "singular": "このデータからパラメータを特定できません", + "diverged": "このデータではモデルを評価できません" + }, + "residualsToggle": "残差を表示", + "dragHint": "点をドラッグすると移動でき、フィットは即座に更新されます。背景をドラッグでパン、スクロールでズーム。", + "pointLabel": "データ点 ({x}, {y})", + "plotTitle": "データとフィット曲線", + "warnings": { + "droppedRows": "欠損値または数値でない値を含む {count} 行を無視しました", + "boundHit": "パラメータ {name} が境界に達しました", + "zeroVariance": "従属変数の列が一定のため、R² は意味を持ちません" + } + }, "relation": { "inputTitle": "関係式", "addButton": "関係式を追加", @@ -1098,20 +1159,6 @@ "presetsLabel": "関係式のプリセット", "plotTitle": "2D関係式プロット", "plotDescription": "陰関数曲線と塗りつぶされた不等式領域。ドラッグでパン、スクロールでズーム。" - }, - "pageTitle": "インタラクティブプロット", - "subtitle": "GPUアクセラレーション付きリアルタイムインタラクティブ数学ビジュアライゼーション", - "badge": { - "fps": "60+ FPS", - "interactive": "インタラクティブ", - "responsive": "レスポンシブ" - }, - "tab": { - "2dCartesian": "2D デカルト座標", - "2dPolar": "2D 極座標", - "2dParametric": "2D パラメトリック", - "2dRelation": "2D 関係式", - "3dSurface": "3D 曲面" } }, "chaos": { diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json index d27ab951..9c823a39 100644 --- a/apps/web/messages/ru.json +++ b/apps/web/messages/ru.json @@ -1044,6 +1044,67 @@ "inflection": "Точки перегиба", "derivative": "Производная", "integral": "Интеграл", + "pageTitle": "Интерактивные графики", + "subtitle": "Математическая визуализация с GPU-ускорением и взаимодействием в реальном времени", + "badge": { + "fps": "60+ кадров/с", + "interactive": "Интерактивные", + "responsive": "Адаптивные" + }, + "tab": { + "2dCartesian": "2D декартовы", + "2dPolar": "2D полярные", + "2dParametric": "2D параметрические", + "3dSurface": "3D поверхность", + "dataRegression": "Данные и регрессия", + "2dRelation": "2D соотношения" + }, + "regression": { + "title": "Данные и регрессия", + "dataTitle": "Таблица данных", + "pasteHint": "Вставьте данные из электронной таблицы (разделённые табуляцией) прямо в таблицу.", + "addColumn": "Добавить столбец", + "addRow": "Добавить строку", + "removeRow": "Удалить строку", + "columnName": "Имя столбца", + "invalidColumnName": "Имена столбцов должны начинаться с буквы и быть уникальными", + "modelLabel": "Модель регрессии", + "modelPlaceholder": "напр. y1 ~ a*exp(b*x1)", + "modelValid": "Параметры: {parameters}", + "cannedTitle": "Шаблоны моделей", + "canned": { + "linear": "Линейная", + "quadratic": "Квадратичная", + "exponential": "Экспоненциальная", + "logarithmic": "Логарифмическая", + "logistic": "Логистическая", + "sinusoidal": "Синусоидальная" + }, + "stats": { + "title": "Статистика подгонки", + "parameters": "Подобранные параметры", + "r2": "R²", + "rmse": "RMSE", + "iterations": "Итерации" + }, + "status": { + "converged": "Сошлась", + "maxIterations": "Не сошлась (достигнут предел итераций) — интерпретируйте подгонку с осторожностью", + "invalidModel": "Недопустимая модель", + "insufficientData": "Недостаточно точек данных для числа параметров", + "singular": "Параметры не определяются по этим данным", + "diverged": "Модель не вычисляется на этих данных" + }, + "residualsToggle": "Показать остатки", + "dragHint": "Перетаскивайте точки — подгонка обновляется сразу. Перетаскивайте фон для панорамирования, колесо — для масштаба.", + "pointLabel": "Точка данных ({x}, {y})", + "plotTitle": "Данные и подобранная кривая", + "warnings": { + "droppedRows": "Пропущено строк с отсутствующими или нечисловыми значениями: {count}", + "boundHit": "Параметр {name} достиг границы", + "zeroVariance": "Зависимый столбец постоянен — R² не информативен" + } + }, "relation": { "inputTitle": "Соотношения", "addButton": "Добавить соотношение", @@ -1065,20 +1126,6 @@ "presetsLabel": "Шаблоны соотношений", "plotTitle": "График 2D-соотношений", "plotDescription": "Неявные кривые и закрашенные области неравенств. Перетаскивайте для панорамирования, прокручивайте для масштабирования." - }, - "pageTitle": "Интерактивные графики", - "subtitle": "Математическая визуализация с GPU-ускорением и взаимодействием в реальном времени", - "badge": { - "fps": "60+ кадров/с", - "interactive": "Интерактивные", - "responsive": "Адаптивные" - }, - "tab": { - "2dCartesian": "2D декартовы", - "2dPolar": "2D полярные", - "2dParametric": "2D параметрические", - "2dRelation": "2D соотношения", - "3dSurface": "3D поверхность" } }, "chaos": { diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json index b9498a1d..fc82a883 100644 --- a/apps/web/messages/uk.json +++ b/apps/web/messages/uk.json @@ -1044,6 +1044,67 @@ "inflection": "Точки перегину", "derivative": "Похідна", "integral": "Інтеграл", + "pageTitle": "Interactive Plots", + "subtitle": "GPU-accelerated time-frequency domain analysis with interactive visualizations", + "badge": { + "fps": "60+ FPS", + "interactive": "Interactive", + "responsive": "Responsive" + }, + "tab": { + "2dCartesian": "2D Cartesian", + "2dPolar": "2D Polar", + "2dParametric": "2D Parametric", + "3dSurface": "3D Surface", + "dataRegression": "Дані та регресія", + "2dRelation": "2D співвідношення" + }, + "regression": { + "title": "Дані та регресія", + "dataTitle": "Таблиця даних", + "pasteHint": "Вставте дані з електронної таблиці (розділені табуляцією) безпосередньо в таблицю.", + "addColumn": "Додати стовпець", + "addRow": "Додати рядок", + "removeRow": "Видалити рядок", + "columnName": "Назва стовпця", + "invalidColumnName": "Назви стовпців мають починатися з літери та бути унікальними", + "modelLabel": "Модель регресії", + "modelPlaceholder": "напр. y1 ~ a*exp(b*x1)", + "modelValid": "Параметри: {parameters}", + "cannedTitle": "Шаблони моделей", + "canned": { + "linear": "Лінійна", + "quadratic": "Квадратична", + "exponential": "Експоненційна", + "logarithmic": "Логарифмічна", + "logistic": "Логістична", + "sinusoidal": "Синусоїдальна" + }, + "stats": { + "title": "Статистика підгонки", + "parameters": "Підібрані параметри", + "r2": "R²", + "rmse": "RMSE", + "iterations": "Ітерації" + }, + "status": { + "converged": "Зійшлася", + "maxIterations": "Не зійшлася (досягнуто ліміт ітерацій) — інтерпретуйте підгонку з обережністю", + "invalidModel": "Неприпустима модель", + "insufficientData": "Недостатньо точок даних для кількості параметрів", + "singular": "Параметри неможливо визначити з цих даних", + "diverged": "Модель не обчислюється на цих даних" + }, + "residualsToggle": "Показати залишки", + "dragHint": "Перетягуйте точки — підгонка оновлюється одразу. Перетягуйте фон для панорамування, коліщатко — для масштабу.", + "pointLabel": "Точка даних ({x}, {y})", + "plotTitle": "Дані та підібрана крива", + "warnings": { + "droppedRows": "Пропущено рядків із відсутніми або нечисловими значеннями: {count}", + "boundHit": "Параметр {name} досяг межі", + "zeroVariance": "Залежний стовпець сталий — R² не інформативний" + } + }, "relation": { "inputTitle": "Співвідношення", "addButton": "Додати співвідношення", @@ -1065,20 +1126,6 @@ "presetsLabel": "Шаблони співвідношень", "plotTitle": "Графік 2D-співвідношень", "plotDescription": "Неявні криві та зафарбовані області нерівностей. Перетягуйте для панорамування, прокручуйте для масштабування." - }, - "pageTitle": "Interactive Plots", - "subtitle": "GPU-accelerated time-frequency domain analysis with interactive visualizations", - "badge": { - "fps": "60+ FPS", - "interactive": "Interactive", - "responsive": "Responsive" - }, - "tab": { - "2dCartesian": "2D Cartesian", - "2dPolar": "2D Polar", - "2dParametric": "2D Parametric", - "2dRelation": "2D співвідношення", - "3dSurface": "3D Surface" } }, "chaos": { diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index 785e3bae..15d4e01c 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -1077,6 +1077,67 @@ "inflection": "拐点", "derivative": "导数", "integral": "积分", + "pageTitle": "交互式绘图", + "subtitle": "GPU 加速的数学可视化,支持实时交互", + "badge": { + "fps": "60+ FPS", + "interactive": "可交互", + "responsive": "响应式" + }, + "tab": { + "2dCartesian": "2D 直角坐标", + "2dPolar": "2D 极坐标", + "2dParametric": "2D 参数化", + "3dSurface": "3D 曲面", + "dataRegression": "数据与回归", + "2dRelation": "2D 关系式" + }, + "regression": { + "title": "数据与回归", + "dataTitle": "数据表", + "pasteHint": "可将电子表格中的数据(制表符分隔)直接粘贴到表格中。", + "addColumn": "添加列", + "addRow": "添加行", + "removeRow": "删除行", + "columnName": "列名", + "invalidColumnName": "列名必须以字母开头且不能重复", + "modelLabel": "回归模型", + "modelPlaceholder": "例如 y1 ~ a*exp(b*x1)", + "modelValid": "参数:{parameters}", + "cannedTitle": "模型模板", + "canned": { + "linear": "线性", + "quadratic": "二次", + "exponential": "指数", + "logarithmic": "对数", + "logistic": "逻辑斯谛", + "sinusoidal": "正弦" + }, + "stats": { + "title": "拟合统计", + "parameters": "拟合参数", + "r2": "R²", + "rmse": "RMSE", + "iterations": "迭代次数" + }, + "status": { + "converged": "已收敛", + "maxIterations": "未收敛(达到迭代上限)— 请谨慎对待该拟合", + "invalidModel": "模型无效", + "insufficientData": "数据点数量不足以拟合该参数个数", + "singular": "无法从这些数据中确定参数", + "diverged": "无法在这些数据上求值该模型" + }, + "residualsToggle": "显示残差", + "dragHint": "拖动数据点即可移动,拟合会即时更新。拖动背景平移,滚轮缩放。", + "pointLabel": "数据点 ({x}, {y})", + "plotTitle": "数据与拟合曲线", + "warnings": { + "droppedRows": "已忽略 {count} 行缺失或非数值数据", + "boundHit": "参数 {name} 已达到边界", + "zeroVariance": "因变量列为常数 — R² 无意义" + } + }, "relation": { "inputTitle": "关系式", "addButton": "添加关系式", @@ -1098,20 +1159,6 @@ "presetsLabel": "关系式预设", "plotTitle": "2D 关系式图", "plotDescription": "隐函数曲线和阴影不等式区域。拖动平移,滚动缩放。" - }, - "pageTitle": "交互式绘图", - "subtitle": "GPU 加速的数学可视化,支持实时交互", - "badge": { - "fps": "60+ FPS", - "interactive": "可交互", - "responsive": "响应式" - }, - "tab": { - "2dCartesian": "2D 直角坐标", - "2dPolar": "2D 极坐标", - "2dParametric": "2D 参数化", - "2dRelation": "2D 关系式", - "3dSurface": "3D 曲面" } }, "chaos": { diff --git a/packages/math-engine/src/stats/fit.test.ts b/packages/math-engine/src/stats/fit.test.ts new file mode 100644 index 00000000..4dd4810d --- /dev/null +++ b/packages/math-engine/src/stats/fit.test.ts @@ -0,0 +1,330 @@ +/** + * Unit tests for arbitrary-model tilde regression (Levenberg-Marquardt) + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { buildCannedModel, fitModel, parseTildeModel } from './fit'; +import { linearRegression } from './regression'; + +/** Asserts a fit succeeded and narrows the type. */ +function expectOk( + result: ReturnType, +): Extract, { ok: true }> { + if (!result.ok) { + throw new Error(`Expected successful fit, got ${result.status}: ${result.message}`); + } + return result; +} + +describe('parseTildeModel', () => { + const columns = ['x1', 'y1']; + + it('parses a model with parameters and regressors', () => { + const parsed = parseTildeModel('y1 ~ a*exp(b*x1)', columns); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.dependent).toBe('y1'); + expect(parsed.parameters).toEqual(['a', 'b']); + expect(parsed.regressors).toEqual(['x1']); + expect(parsed.rhsText).toBe('a*exp(b*x1)'); + } + }); + + it('rejects input without a tilde', () => { + const parsed = parseTildeModel('y1 = a*x1', columns); + expect(parsed).toMatchObject({ ok: false, code: 'no-tilde' }); + }); + + it('rejects input with multiple tildes', () => { + const parsed = parseTildeModel('y1 ~ a ~ b', columns); + expect(parsed).toMatchObject({ ok: false, code: 'multiple-tildes' }); + }); + + it('rejects a dependent that is not a data column', () => { + const parsed = parseTildeModel('z ~ a*x1', columns); + expect(parsed).toMatchObject({ ok: false, code: 'invalid-dependent' }); + }); + + it('rejects a dependent that is an expression', () => { + const parsed = parseTildeModel('2*y1 ~ a*x1', columns); + expect(parsed).toMatchObject({ ok: false, code: 'invalid-dependent' }); + }); + + it('rejects an unparseable right-hand side', () => { + const parsed = parseTildeModel('y1 ~ a*(x1', columns); + expect(parsed).toMatchObject({ ok: false, code: 'parse-error' }); + }); + + it('rejects a model with no free parameters', () => { + const parsed = parseTildeModel('y1 ~ x1', columns); + expect(parsed).toMatchObject({ ok: false, code: 'no-parameters' }); + }); + + it('does not treat built-in constants as parameters', () => { + const parsed = parseTildeModel('y1 ~ a*sin(pi*x1) + b*e', columns); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.parameters).toEqual(['a', 'b']); + } + }); + + it('rejects a model where a data column name collides with a built-in constant', () => { + // A column literally named "e" would otherwise be invisible to + // extractVariables (excluded as the constant Math.E) and silently never + // become a regressor — the column's actual data would never be used. + const parsed = parseTildeModel('y1 ~ m*e + b', ['e', 'y1']); + expect(parsed).toMatchObject({ ok: false, code: 'reserved-name-collision' }); + }); +}); + +describe('buildCannedModel', () => { + it('emits the exact model string for each family', () => { + expect(buildCannedModel('linear', 'x1', 'y1')).toBe('y1 ~ m*x1 + b'); + expect(buildCannedModel('quadratic', 'x1', 'y1')).toBe('y1 ~ a*x1^2 + b*x1 + c'); + expect(buildCannedModel('exponential', 'x1', 'y1')).toBe('y1 ~ a*exp(b*x1)'); + expect(buildCannedModel('logarithmic', 'x1', 'y1')).toBe('y1 ~ a + b*ln(x1)'); + expect(buildCannedModel('logistic', 'x1', 'y1')).toBe('y1 ~ L/(1 + exp(-k*(x1 - x0)))'); + expect(buildCannedModel('sinusoidal', 'x1', 'y1')).toBe('y1 ~ a*sin(b*x1 + c) + d'); + }); + + it('renames a colliding parameter letter instead of reusing the column name', () => { + // The y-column renamed to 'b' collides with the linear template's + // hardcoded intercept letter — parseTildeModel would otherwise silently + // reclassify 'b' as a regressor bound to the dependent column itself. + const model = buildCannedModel('linear', 'x1', 'b'); + expect(model).not.toBe('b ~ m*x1 + b'); + const parsed = parseTildeModel(model, ['x1', 'b']); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.dependent).toBe('b'); + expect(parsed.regressors).toEqual(['x1']); + expect(parsed.parameters).not.toContain('b'); + } + }); + + it('renames a colliding regressor letter (quadratic) instead of reusing the column name', () => { + // The x-column renamed to 'a' collides with the quadratic template's + // hardcoded leading-coefficient letter. + const model = buildCannedModel('quadratic', 'a', 'y1'); + const parsed = parseTildeModel(model, ['a', 'y1']); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.regressors).toEqual(['a']); + expect(parsed.parameters).toHaveLength(3); + } + }); +}); + +describe('fitModel — parameter recovery', () => { + it('recovers an exact linear fit y = 2x + 1', () => { + const x1 = [0, 1, 2, 3, 4, 5]; + const y1 = x1.map((x) => 2 * x + 1); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b')); + expect(fit.parameters['m']).toBeCloseTo(2, 8); + expect(fit.parameters['b']).toBeCloseTo(1, 8); + expect(fit.r2).toBeCloseTo(1, 8); + expect(fit.status).toBe('converged'); + }); + + it('recovers a quadratic y = 0.5x² − 3x + 2', () => { + const x1 = [-3, -2, -1, 0, 1, 2, 3, 4]; + const y1 = x1.map((x) => 0.5 * x ** 2 - 3 * x + 2); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ a*x1^2 + b*x1 + c')); + expect(fit.parameters['a']).toBeCloseTo(0.5, 6); + expect(fit.parameters['b']).toBeCloseTo(-3, 6); + expect(fit.parameters['c']).toBeCloseTo(2, 6); + expect(fit.status).toBe('converged'); + }); + + it('recovers an exponential y = 3·e^(0.5x)', () => { + const x1 = [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4]; + const y1 = x1.map((x) => 3 * Math.exp(0.5 * x)); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ a*exp(b*x1)')); + expect(fit.parameters['a']).toBeCloseTo(3, 6); + expect(fit.parameters['b']).toBeCloseTo(0.5, 6); + expect(fit.r2).toBeCloseTo(1, 8); + expect(fit.status).toBe('converged'); + }); + + it('recovers a logarithmic y = 1.5 + 2·ln(x)', () => { + const x1 = [0.5, 1, 2, 3, 5, 8, 13]; + const y1 = x1.map((x) => 1.5 + 2 * Math.log(x)); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ a + b*ln(x1)')); + expect(fit.parameters['a']).toBeCloseTo(1.5, 6); + expect(fit.parameters['b']).toBeCloseTo(2, 6); + expect(fit.status).toBe('converged'); + }); + + it('recovers a logistic L=10, k=1.2, x0=2', () => { + const x1 = Array.from({ length: 25 }, (_, i) => -4 + i * 0.5); + const y1 = x1.map((x) => 10 / (1 + Math.exp(-1.2 * (x - 2)))); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ L/(1 + exp(-k*(x1 - x0)))')); + expect(fit.parameters['L']).toBeCloseTo(10, 4); + expect(fit.parameters['k']).toBeCloseTo(1.2, 4); + expect(fit.parameters['x0']).toBeCloseTo(2, 4); + expect(fit.status).toBe('converged'); + }); + + it('recovers a sinusoid when given an initial guess', () => { + const x1 = Array.from({ length: 40 }, (_, i) => i * 0.25); + const y1 = x1.map((x) => 2.5 * Math.sin(1.5 * x + 0.4) + 1); + const fit = expectOk( + fitModel({ x1, y1 }, 'y1 ~ a*sin(b*x1 + c) + d', { + initialGuess: { a: 2, b: 1.4, c: 0.5, d: 0.8 }, + }), + ); + expect(fit.parameters['a']).toBeCloseTo(2.5, 4); + expect(fit.parameters['b']).toBeCloseTo(1.5, 4); + expect(fit.parameters['c']).toBeCloseTo(0.4, 4); + expect(fit.parameters['d']).toBeCloseTo(1, 4); + expect(fit.status).toBe('converged'); + }); + + it('matches linearRegression on the same data', () => { + const x1 = [1, 2, 3, 4, 5, 6]; + const y1 = [2.1, 3.9, 6.2, 7.8, 10.1, 11.7]; + const reference = linearRegression(x1, y1); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b')); + expect(fit.parameters['m']).toBeCloseTo(reference.slope, 6); + expect(fit.parameters['b']).toBeCloseTo(reference.intercept, 6); + expect(fit.r2).toBeCloseTo(reference.r2, 6); + }); +}); + +describe('fitModel — hygiene and honest failure', () => { + it('reports consistent residuals, predictions and rmse', () => { + const x1 = [1, 2, 3, 4, 5]; + const y1 = [1.9, 4.2, 5.8, 8.1, 9.9]; + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b')); + expect(fit.residuals).toHaveLength(5); + expect(fit.predicted).toHaveLength(5); + expect(fit.rmse).toBeGreaterThanOrEqual(0); + for (let i = 0; i < 5; i++) { + expect((fit.predicted[i] ?? Number.NaN) + (fit.residuals[i] ?? Number.NaN)).toBeCloseTo( + y1[i] ?? Number.NaN, + 10, + ); + } + }); + + it('drops non-finite rows and records a structured warning', () => { + const x1 = [0, 1, 2, Number.NaN, 4, 5]; + const y1 = [1, 3, 5, 7, Number.POSITIVE_INFINITY, 11]; + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b')); + expect(fit.residuals).toHaveLength(4); + expect(fit.warnings).toContainEqual({ code: 'dropped-rows', count: 2 }); + expect(fit.parameters['m']).toBeCloseTo(2, 6); + expect(fit.parameters['b']).toBeCloseTo(1, 6); + }); + + it('warns instead of silently truncating ragged (unequal-length) columns', () => { + // y1 has only 3 values against x1's 5 — the two extra x1 rows must be + // counted as dropped, not silently discarded with an empty warnings list. + const fit = expectOk(fitModel({ x1: [1, 2, 3, 4, 5], y1: [2, 4, 6] }, 'y1 ~ m*x1 + b')); + expect(fit.residuals).toHaveLength(3); + expect(fit.warnings).toContainEqual({ code: 'dropped-rows', count: 2 }); + }); + + it('fails with insufficient-data when rows do not exceed parameters', () => { + const fit = fitModel({ x1: [1, 2], y1: [2, 4] }, 'y1 ~ m*x1 + b'); + expect(fit).toMatchObject({ ok: false, status: 'insufficient-data' }); + }); + + it('fails with invalid-model for unparseable input', () => { + const fit = fitModel({ x1: [1, 2, 3], y1: [1, 2, 3] }, 'y1 a*x1'); + expect(fit).toMatchObject({ ok: false, status: 'invalid-model' }); + }); + + it('fails with diverged when the model is not evaluable at the initial guess', () => { + // ln(x1) is undefined for negative x — every row fails at any parameter value. + const fit = fitModel({ x1: [-5, -4, -3, -2], y1: [1, 2, 3, 4] }, 'y1 ~ a + b*ln(x1)'); + expect(fit).toMatchObject({ ok: false, status: 'diverged' }); + if (!fit.ok) { + expect(fit.message).toContain('row'); + } + }); + + it('never returns ok with non-finite parameters on degenerate data', () => { + const fit = fitModel({ x1: [5, 5, 5, 5, 5], y1: [1, 2, 3, 4, 5] }, 'y1 ~ m*x1 + b'); + if (fit.ok) { + for (const value of Object.values(fit.parameters)) { + expect(Number.isFinite(value)).toBe(true); + } + expect(Number.isFinite(fit.r2)).toBe(true); + expect(Number.isFinite(fit.rmse)).toBe(true); + } else { + expect(fit.status).toBe('singular'); + } + }); + + it('reports zero-variance r2 = 0 with a warning on constant y', () => { + const fit = expectOk(fitModel({ x1: [1, 2, 3, 4], y1: [7, 7, 7, 7] }, 'y1 ~ m*x1 + b')); + expect(fit.r2).toBe(0); + expect(fit.warnings).toContainEqual({ code: 'zero-variance' }); + }); + + it('clamps parameters to bounds and records a warning', () => { + const x1 = [0, 1, 2, 3, 4, 5]; + const y1 = x1.map((x) => 2 * x); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b', { bounds: { m: { max: 1 } } })); + expect(fit.parameters['m']).toBeLessThanOrEqual(1); + expect(fit.warnings).toContainEqual({ code: 'bound-hit', name: 'm' }); + }); + + it('predict closure matches predicted[] on training rows', () => { + const x1 = [0, 1, 2, 3, 4]; + const y1 = x1.map((x) => 3 * Math.exp(0.5 * x)); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ a*exp(b*x1)')); + x1.forEach((x, i) => { + expect(fit.predict({ x1: x })).toBeCloseTo(fit.predicted[i] ?? Number.NaN, 10); + }); + }); + + it('predict returns NaN outside the model domain instead of throwing', () => { + const x1 = [1, 2, 3, 4, 5]; + const y1 = x1.map((x) => 1 + 2 * Math.log(x)); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ a + b*ln(x1)')); + expect(Number.isNaN(fit.predict({ x1: -1 }))).toBe(true); + }); + + it('identifies a well-posed exact fit even at tiny x-magnitudes (e.g. SI meters)', () => { + // A perfectly determined line whose x-values happen to be tiny in + // magnitude (e.g. metres instead of micrometres) must not be + // misclassified as "singular" merely because of the unit choice. + const x1 = [1e-7, 2e-7, 3e-7, 4e-7, 5e-7]; + const y1 = x1.map((x) => 2e6 * x + 1); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b')); + expect(fit.parameters['m']).toBeCloseTo(2e6, -2); + expect(fit.parameters['b']).toBeCloseTo(1, 6); + expect(fit.r2).toBeCloseTo(1, 6); + }); + + it('reports standard errors when the fit is overdetermined', () => { + const x1 = [1, 2, 3, 4, 5, 6]; + const y1 = [2.1, 3.9, 6.2, 7.8, 10.1, 11.7]; + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b')); + expect(fit.standardErrors).toBeDefined(); + expect(fit.standardErrors?.['m']).toBeGreaterThan(0); + expect(fit.standardErrors?.['b']).toBeGreaterThan(0); + }); +}); + +describe('fitModel — property-based linear recovery', () => { + it('recovers arbitrary slope and intercept from noiseless data', () => { + const x1 = [-3, -1.5, 0, 1, 2.5, 4, 5.5, 7]; + fc.assert( + fc.property( + fc.double({ min: -10, max: 10, noNaN: true }), + fc.double({ min: -10, max: 10, noNaN: true }), + (slope, intercept) => { + const y1 = x1.map((x) => slope * x + intercept); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b')); + expect(fit.parameters['m']).toBeCloseTo(slope, 6); + expect(fit.parameters['b']).toBeCloseTo(intercept, 6); + }, + ), + { numRuns: 50 }, + ); + }); +}); diff --git a/packages/math-engine/src/stats/fit.ts b/packages/math-engine/src/stats/fit.ts new file mode 100644 index 00000000..0d5ecff6 --- /dev/null +++ b/packages/math-engine/src/stats/fit.ts @@ -0,0 +1,782 @@ +/** + * Arbitrary-Model Regression (Desmos-style tilde fitting) + * + * Fits user-supplied models of the form `y1 ~ a*exp(b*x1)` to columnar data + * using damped Levenberg-Marquardt with analytic Jacobians obtained from the + * symbolic differentiation engine. The tilde is split in this module — it is + * never fed to the expression parser (mathjs has no `~` operator). + * + * Honesty contract: a fit NEVER silently succeeds. `FitResult` is a + * discriminated union whose failure arm carries an explicit status + * ('invalid-model' | 'insufficient-data' | 'singular' | 'diverged'), and the + * success arm reports 'converged' vs 'max-iterations' plus structured + * warnings (dropped rows, parameters pinned at bounds, zero variance). + * + * @module stats/fit + */ + +import { solveLinearSystem } from '../matrix'; +import { + type EvaluationResult, + type ExpressionNode, + evaluate, + extractVariables, + isFunctionNode, + isSymbolNode, + parse, +} from '../parser'; +import { differentiate, simplifyDerivative } from '../symbolic/differentiate'; +import { mean, median } from './descriptive'; +import { linearRegression } from './regression'; + +/** Valid column / parameter identifier (also mirrored by the web data table). */ +const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/; + +/** Error codes produced by {@link parseTildeModel}. */ +export type TildeModelErrorCode = + | 'no-tilde' + | 'multiple-tildes' + | 'invalid-dependent' + | 'parse-error' + | 'no-parameters' + | 'reserved-name-collision'; + +/** Successfully parsed tilde model (the `ok: true` arm of {@link ParsedModel}). */ +export interface TildeModel { + /** Column name on the left of the tilde (the dependent variable). */ + readonly dependent: string; + /** Parsed AST of the right-hand side. */ + readonly rhsAst: ExpressionNode; + /** Trimmed right-hand-side source text. */ + readonly rhsText: string; + /** Free parameters to fit (symbols that are neither columns nor built-in constants), sorted. */ + readonly parameters: readonly string[]; + /** Data columns referenced on the right-hand side, sorted. */ + readonly regressors: readonly string[]; +} + +/** + * Result of parsing a tilde model expression. + * A discriminated union so UI callers can render error state without try/catch. + */ +export type ParsedModel = + | ({ readonly ok: true } & TildeModel) + | { readonly ok: false; readonly code: TildeModelErrorCode; readonly message: string }; + +/** All symbol names appearing in an AST, INCLUDING built-in constants + * (unlike {@link extractVariables}, which excludes them). Used to detect + * when a data column shares a name with a built-in constant. */ +function collectAllSymbolNames(node: ExpressionNode): Set { + const names = new Set(); + function traverse(n: ExpressionNode): void { + if (isSymbolNode(n) && n.name) { + names.add(n.name); + } else if (n.args) { + n.args.forEach(traverse); + } + } + traverse(node); + return names; +} + +/** + * Splits a tilde model like `y1 ~ a*exp(b*x1)` into dependent column, + * right-hand-side AST, fit parameters and regressors. + * + * @param input - Full model text containing exactly one `~` + * @param columns - Data column names; the LHS must be one of these + */ +export function parseTildeModel(input: string, columns: readonly string[]): ParsedModel { + const parts = input.split('~'); + if (parts.length === 1) { + return { + ok: false, + code: 'no-tilde', + message: 'Model must contain a "~" separating the dependent column from the formula', + }; + } + if (parts.length > 2) { + return { + ok: false, + code: 'multiple-tildes', + message: 'Model must contain exactly one "~"', + }; + } + + const dependent = (parts[0] ?? '').trim(); + const rhsText = (parts[1] ?? '').trim(); + + if (!IDENTIFIER_PATTERN.test(dependent) || !columns.includes(dependent)) { + return { + ok: false, + code: 'invalid-dependent', + message: `Left side of "~" must be a data column (one of: ${columns.join(', ')})`, + }; + } + + let rhsAst: ExpressionNode; + try { + rhsAst = parse(rhsText); + } catch (error) { + return { + ok: false, + code: 'parse-error', + message: error instanceof Error ? error.message : `Failed to parse formula: ${rhsText}`, + }; + } + + // extractVariables already excludes built-in constants (pi/π, e, tau/τ, i, phi/φ). + const symbols = extractVariables(rhsAst); + const columnSet = new Set(columns); + const parameters = [...symbols].filter((s) => !columnSet.has(s)).sort(); + const regressors = [...symbols].filter((s) => columnSet.has(s)).sort(); + + // A data column named e/pi/tau/i/phi is invisible to extractVariables (it + // is excluded there as a built-in constant), so it would silently never + // become a regressor and the constant's numeric value would be used in + // its place for every row. Detect the collision explicitly instead of + // letting the fit quietly ignore the column's actual data. + const allSymbols = collectAllSymbolNames(rhsAst); + const reservedCollisions = [...allSymbols] + .filter((s) => !symbols.has(s) && columnSet.has(s)) + .sort(); + if (reservedCollisions.length > 0) { + return { + ok: false, + code: 'reserved-name-collision', + message: `Column name "${reservedCollisions[0]}" collides with a built-in constant (e, pi, tau, i, phi) and cannot be used on the right-hand side of "~" — rename the column`, + }; + } + + if (parameters.length === 0) { + return { + ok: false, + code: 'no-parameters', + message: 'Formula has no free parameters to fit — every symbol is a data column', + }; + } + + return { ok: true, dependent, rhsAst, rhsText, parameters, regressors }; +} + +/** Canned model families with prebaked formulas and smart initial guesses. */ +export type CannedModelKind = + | 'linear' + | 'quadratic' + | 'exponential' + | 'logarithmic' + | 'logistic' + | 'sinusoidal'; + +/** Fallback letters tried, in order, when a canned model's hardcoded parameter + * letter collides with a data column name (e.g. the y-column renamed to 'b'). */ +const PARAM_LETTER_FALLBACKS: readonly string[] = [ + 'm', + 'a', + 'b', + 'c', + 'd', + 'k', + 'p', + 'q', + 'r', + 's', + 'u', + 'v', + 'w', +]; + +/** + * Resolves a canned model's hardcoded parameter letter to one that doesn't + * collide with a data column name or another parameter already placed in + * this formula — otherwise `parseTildeModel` would silently reclassify the + * colliding letter as a regressor instead of a fit parameter (see + * buildCannedModel's honesty w.r.t. column-name collisions). + */ +function resolveParamLetter( + preferred: string, + reserved: ReadonlySet, + used: Set, +): string { + if (!reserved.has(preferred) && !used.has(preferred)) { + used.add(preferred); + return preferred; + } + for (const candidate of PARAM_LETTER_FALLBACKS) { + if (!reserved.has(candidate) && !used.has(candidate)) { + used.add(candidate); + return candidate; + } + } + // Pathological: every fallback letter is also a column name. Suffix until unique. + let n = 2; + let candidate = `${preferred}${n}`; + while (reserved.has(candidate) || used.has(candidate)) { + n += 1; + candidate = `${preferred}${n}`; + } + used.add(candidate); + return candidate; +} + +/** + * Builds the model string for a canned model family over the given columns. + * Parameter letters are renamed away from `xColumn`/`yColumn` (and from each + * other) when they would otherwise collide with a data column name. + * + * @example buildCannedModel('exponential', 'x1', 'y1') // 'y1 ~ a*exp(b*x1)' + */ +export function buildCannedModel(kind: CannedModelKind, xColumn: string, yColumn: string): string { + const reserved = new Set([xColumn, yColumn]); + const used = new Set(); + const letter = (preferred: string): string => resolveParamLetter(preferred, reserved, used); + + switch (kind) { + case 'linear': { + const m = letter('m'); + const b = letter('b'); + return `${yColumn} ~ ${m}*${xColumn} + ${b}`; + } + case 'quadratic': { + const a = letter('a'); + const b = letter('b'); + const c = letter('c'); + return `${yColumn} ~ ${a}*${xColumn}^2 + ${b}*${xColumn} + ${c}`; + } + case 'exponential': { + const a = letter('a'); + const b = letter('b'); + return `${yColumn} ~ ${a}*exp(${b}*${xColumn})`; + } + case 'logarithmic': { + const a = letter('a'); + const b = letter('b'); + return `${yColumn} ~ ${a} + ${b}*ln(${xColumn})`; + } + case 'logistic': { + const l = letter('L'); + const k = letter('k'); + const x0 = letter('x0'); + return `${yColumn} ~ ${l}/(1 + exp(-${k}*(${xColumn} - ${x0})))`; + } + case 'sinusoidal': { + const a = letter('a'); + const b = letter('b'); + const c = letter('c'); + const d = letter('d'); + return `${yColumn} ~ ${a}*sin(${b}*${xColumn} + ${c}) + ${d}`; + } + } +} + +/** Optional per-parameter box constraints. */ +export interface ParameterBounds { + readonly min?: number; + readonly max?: number; +} + +/** Options for {@link fitModel}. */ +export interface FitOptions { + /** Maximum Levenberg-Marquardt iterations (default 100). */ + readonly maxIterations?: number; + /** Relative convergence tolerance on SSR decrease and step size (default 1e-10). */ + readonly tolerance?: number; + /** Initial LM damping factor λ (default 1e-3). */ + readonly lambdaInit?: number; + /** Per-parameter starting values; wins over the built-in smart seeds. */ + readonly initialGuess?: Readonly>; + /** Per-parameter box bounds; trial steps are clamped and a warning recorded. */ + readonly bounds?: Readonly>; +} + +/** + * Structured, translatable warning attached to a successful fit. + * (Structured rather than plain strings so UIs can localize with ICU args.) + */ +export type FitWarning = + | { readonly code: 'dropped-rows'; readonly count: number } + | { readonly code: 'bound-hit'; readonly name: string } + | { readonly code: 'zero-variance' }; + +/** Successful fit. */ +export interface FitSuccess { + readonly ok: true; + /** Fitted parameter values keyed by parameter name. */ + readonly parameters: Record; + /** Asymptotic standard errors (omitted when JᵀJ is singular or n ≤ p). */ + readonly standardErrors?: Record; + /** Coefficient of determination in the original data space. */ + readonly r2: number; + /** Root mean squared error √(SSR/n). */ + readonly rmse: number; + /** Per-row residuals yᵢ − ŷᵢ (rows with non-finite inputs are excluded). */ + readonly residuals: number[]; + /** Per-row model predictions ŷᵢ. */ + readonly predicted: number[]; + /** LM iterations executed. */ + readonly iterations: number; + /** 'converged' or 'max-iterations' — the latter is a visible caution, never silent. */ + readonly status: 'converged' | 'max-iterations'; + readonly warnings: FitWarning[]; + /** The parsed model that was fitted. */ + readonly model: TildeModel; + /** Evaluates the fitted model at arbitrary regressor values (NaN on domain errors). */ + readonly predict: (vars: Record) => number; +} + +/** Failed fit — always carries an explicit machine-readable status. */ +export interface FitFailure { + readonly ok: false; + readonly status: 'invalid-model' | 'insufficient-data' | 'singular' | 'diverged'; + readonly message: string; + readonly issues?: string[]; +} + +/** Discriminated fit result: check `.ok` before reading fit statistics. */ +export type FitResult = FitSuccess | FitFailure; + +/** + * Fallback solve for the damped normal equations: column-scales JᵀJ + * (Marquardt's original scaling) so `solveLinearSystem`'s absolute pivot + * threshold is scale-invariant, then un-scales the result. Used ONLY when + * the raw (unscaled) solve reports singular — well-conditioned fits keep + * their existing unscaled numeric path unchanged. Without this, well-posed + * data whose regressor has a tiny physical magnitude (e.g. micrometers + * stored as meters) can trip the solver's epsilon purely from unit choice, + * misreporting an identifiable fit as "singular". + */ +function solveScaledFallback( + jtj: readonly (readonly number[])[], + jtr: readonly number[], + lambda: number, +): number[] | null { + const p = jtr.length; + const scale = jtj.map((row, j) => { + const diag = row[j] ?? 0; + return diag > 0 ? Math.sqrt(diag) : 1; + }); + const scaledJtj = jtj.map((row, j) => row.map((v, k) => v / ((scale[j] ?? 1) * (scale[k] ?? 1)))); + const dampedScaled = scaledJtj.map((row, j) => row.map((v, k) => (j === k ? v + lambda : v))); + const scaledJtr = jtr.map((v, j) => v / (scale[j] ?? 1)); + const scaledDelta = solveLinearSystem(dampedScaled, scaledJtr); + if (scaledDelta === null) return null; + return Array.from({ length: p }, (_, j) => (scaledDelta[j] ?? 0) / (scale[j] ?? 1)); +} + +/** Coerces an evaluator result to a finite number, or null on any failure. */ +function toFiniteNumber(result: EvaluationResult): number | null { + if (!result.success) return null; + const raw = result.value; + const value = typeof raw === 'bigint' ? Number(raw) : raw; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +/** True when the AST contains a function call with the given name. */ +function containsFunction(node: ExpressionNode, fn: string): boolean { + if (isFunctionNode(node) && node.fn === fn) return true; + return node.args?.some((arg) => containsFunction(arg, fn)) ?? false; +} + +/** Largest absolute value in a non-empty array. */ +function maxAbs(values: readonly number[]): number { + let max = 0; + for (const v of values) { + const a = Math.abs(v); + if (a > max) max = a; + } + return max; +} + +/** + * Smart initial guesses keyed off the canned model shapes; every parameter + * defaults to 1 when no heuristic applies. + * + * Note: the sinusoidal frequency seed b = 2π/(xMax − xMin) is a heuristic — + * sinusoidal fits are strongly guess-sensitive; pass `initialGuess` when the + * frequency is roughly known. + */ +function defaultSeeds( + model: TildeModel, + x: readonly number[] | null, + y: readonly number[], +): Record { + const seeds: Record = {}; + for (const p of model.parameters) seeds[p] = 1; + const paramSet = new Set(model.parameters); + + if (paramSet.has('L') && paramSet.has('k') && paramSet.has('x0')) { + // Logistic L/(1 + exp(-k*(x - x0))) + seeds['L'] = 1.05 * Math.max(...y); + seeds['k'] = 1; + seeds['x0'] = x && x.length > 0 ? median([...x]) : 0; + } else if ( + paramSet.has('a') && + paramSet.has('b') && + paramSet.has('c') && + paramSet.has('d') && + containsFunction(model.rhsAst, 'sin') + ) { + // Sinusoidal a*sin(b*x + c) + d + const yMax = Math.max(...y); + const yMin = Math.min(...y); + seeds['a'] = (yMax - yMin) / 2 || 1; + seeds['d'] = mean([...y]); + seeds['c'] = 0; + if (x && x.length > 1) { + const xMax = Math.max(...x); + const xMin = Math.min(...x); + seeds['b'] = xMax > xMin ? (2 * Math.PI) / (xMax - xMin) : 1; + } + } else if (paramSet.has('a') && paramSet.has('b') && containsFunction(model.rhsAst, 'exp')) { + // Exponential-family a*exp(b*x): seed from log-linear regression when possible. + let seeded = false; + if (x && x.length >= 2 && y.every((v) => v > 0)) { + try { + const logFit = linearRegression([...x], y.map(Math.log)); + if (Number.isFinite(logFit.slope) && Number.isFinite(logFit.intercept)) { + seeds['a'] = Math.exp(logFit.intercept); + seeds['b'] = logFit.slope; + seeded = true; + } + } catch { + // degenerate x — fall through to the magnitude seed + } + } + if (!seeded) { + seeds['a'] = maxAbs(y) || 1; + seeds['b'] = 0.1; + } + } + + return seeds; +} + +/** One fully evaluated LM point: predictions, Jacobian and SSR. */ +interface EvalPoint { + readonly f: number[]; + readonly jacobian: number[][]; + readonly ssr: number; +} + +/** + * Fits `model` (tilde syntax) to columnar `data` with damped Levenberg-Marquardt. + * + * - Analytic Jacobians via symbolic differentiation (no finite differences). + * - Rows containing non-finite values in any used column are dropped (warned). + * - Trial steps are clamped to `options.bounds` (warned once per parameter). + * - Never returns a silent bad fit: failures carry an explicit status, and a + * non-converged success is flagged 'max-iterations'. + */ +export function fitModel( + data: Readonly>, + model: string, + options?: FitOptions, +): FitResult { + const columns = Object.keys(data); + const parsed = parseTildeModel(model, columns); + if (!parsed.ok) { + return { + ok: false, + status: 'invalid-model', + message: parsed.message, + issues: [parsed.code], + }; + } + + const maxIterations = options?.maxIterations ?? 100; + const tolerance = options?.tolerance ?? 1e-10; + let lambda = options?.lambdaInit ?? 1e-3; + + // Analytic Jacobian ASTs, computed once per parameter. + const parameters = parsed.parameters; + const p = parameters.length; + let jacobianAsts: ExpressionNode[]; + try { + jacobianAsts = parameters.map((name) => simplifyDerivative(differentiate(parsed.rhsAst, name))); + } catch (error) { + return { + ok: false, + status: 'invalid-model', + message: `Model is not differentiable with respect to its parameters: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + + // Assemble finite rows over the used columns. `rowCount` is the LONGEST + // used column (not the shortest): a ragged tail beyond a shorter column is + // missing data, not absent rows, and must fall into the same + // finite-row check below so it is counted in the 'dropped-rows' warning + // instead of being silently truncated away with no warning at all. + const usedColumns = [parsed.dependent, ...parsed.regressors]; + const rowCount = Math.max(...usedColumns.map((c) => data[c]?.length ?? 0)); + const rows: Record[] = []; + const y: number[] = []; + for (let i = 0; i < rowCount; i++) { + const row: Record = {}; + let finite = true; + for (const c of usedColumns) { + const v = data[c]?.[i]; + if (typeof v !== 'number' || !Number.isFinite(v)) { + finite = false; + break; + } + row[c] = v; + } + if (!finite) continue; + rows.push(row); + y.push(row[parsed.dependent] ?? Number.NaN); + } + + const warnings: FitWarning[] = []; + const dropped = rowCount - rows.length; + if (dropped > 0) { + warnings.push({ code: 'dropped-rows', count: dropped }); + } + + const n = rows.length; + if (n <= p) { + return { + ok: false, + status: 'insufficient-data', + message: `Need more than ${p} finite data rows to fit ${p} parameter${p === 1 ? '' : 's'} (got ${n})`, + }; + } + + // Initial parameter vector: user guess > smart seeds > 1, clamped into bounds. + const firstRegressor = parsed.regressors[0]; + const xForSeeds = + firstRegressor === undefined ? null : rows.map((row) => row[firstRegressor] ?? Number.NaN); + const seeds = defaultSeeds(parsed, xForSeeds, y); + const boundsFor = (name: string): ParameterBounds | undefined => options?.bounds?.[name]; + const clamp = (name: string, value: number): number => { + const b = boundsFor(name); + let v = value; + if (b?.min !== undefined && v < b.min) v = b.min; + if (b?.max !== undefined && v > b.max) v = b.max; + return v; + }; + let params = parameters.map((name) => + clamp(name, options?.initialGuess?.[name] ?? seeds[name] ?? 1), + ); + + const toParamRecord = (vec: readonly number[]): Record => { + const record: Record = {}; + parameters.forEach((name, j) => { + record[name] = vec[j] ?? Number.NaN; + }); + return record; + }; + + /** Evaluates model + Jacobian at a parameter vector; string on failure. */ + const evalPoint = (vec: readonly number[]): EvalPoint | string => { + const paramRecord = toParamRecord(vec); + const f: number[] = []; + const jacobian: number[][] = []; + let ssr = 0; + for (let i = 0; i < n; i++) { + const variables = { ...rows[i], ...paramRecord }; + const fi = toFiniteNumber(evaluate(parsed.rhsAst, { variables })); + if (fi === null) { + return `model evaluation failed at data row ${i + 1}`; + } + const grad: number[] = []; + for (let j = 0; j < p; j++) { + const ast = jacobianAsts[j]; + const gj = ast === undefined ? null : toFiniteNumber(evaluate(ast, { variables })); + if (gj === null) { + return `derivative with respect to "${parameters[j]}" failed at data row ${i + 1}`; + } + grad.push(gj); + } + f.push(fi); + jacobian.push(grad); + const r = (y[i] ?? Number.NaN) - fi; + ssr += r * r; + } + return { f, jacobian, ssr }; + }; + + let current = evalPoint(params); + if (typeof current === 'string') { + return { + ok: false, + status: 'diverged', + message: `Model could not be evaluated at the initial guess: ${current}`, + }; + } + + let iterations = 0; + let status: 'converged' | 'max-iterations' = 'max-iterations'; + let consecutiveSingular = 0; + const boundHits = new Set(); + + for (let iter = 1; iter <= maxIterations; iter++) { + iterations = iter; + + // Normal equations: (JᵀJ + λ·diag(JᵀJ))·δ = Jᵀr + const jtj: number[][] = Array.from({ length: p }, () => new Array(p).fill(0)); + const jtr = new Array(p).fill(0); + for (let i = 0; i < n; i++) { + const gradRow = current.jacobian[i]; + const fi = current.f[i]; + if (gradRow === undefined || fi === undefined) continue; + const ri = (y[i] ?? Number.NaN) - fi; + for (let j = 0; j < p; j++) { + const gj = gradRow[j] ?? 0; + jtr[j] = (jtr[j] ?? 0) + gj * ri; + const jtjRow = jtj[j]; + if (jtjRow === undefined) continue; + for (let k = j; k < p; k++) { + jtjRow[k] = (jtjRow[k] ?? 0) + gj * (gradRow[k] ?? 0); + } + } + } + // Mirror the upper triangle. + for (let j = 0; j < p; j++) { + for (let k = 0; k < j; k++) { + const upper = jtj[k]?.[j]; + const rowJ = jtj[j]; + if (rowJ !== undefined && upper !== undefined) rowJ[k] = upper; + } + } + + const damped = jtj.map((row, j) => + row.map((v, k) => (j === k ? v + lambda * Math.max(v, 1e-12) : v)), + ); + + const delta = solveLinearSystem(damped, jtr) ?? solveScaledFallback(jtj, jtr, lambda); + if (delta === null) { + consecutiveSingular += 1; + if (consecutiveSingular >= 5) { + return { + ok: false, + status: 'singular', + message: + 'Normal equations are singular — parameters are likely not identifiable from this data (e.g. all x values identical or redundant parameters)', + }; + } + lambda = Math.min(lambda * 8, 1e14); + continue; + } + consecutiveSingular = 0; + + // Trial step, clamped to bounds. + const trial = params.map((v, j) => { + const name = parameters[j]; + const stepped = v + (delta[j] ?? 0); + if (name === undefined) return stepped; + const clamped = clamp(name, stepped); + if (clamped !== stepped) boundHits.add(name); + return clamped; + }); + if (trial.some((v) => !Number.isFinite(v))) { + lambda = Math.min(lambda * 8, 1e14); + continue; + } + + const stepTiny = trial.every( + (v, j) => + Math.abs(v - (params[j] ?? 0)) <= tolerance * (Math.abs(params[j] ?? 0) + tolerance), + ); + + const trialPoint = evalPoint(trial); + if (typeof trialPoint === 'string') { + // Reject steps into non-evaluable territory; keep the last accepted point. + lambda = Math.min(lambda * 8, 1e14); + continue; + } + + if (trialPoint.ssr <= current.ssr) { + const ssrDrop = current.ssr - trialPoint.ssr; + params = trial; + current = trialPoint; + lambda = Math.max(lambda * 0.3, 1e-14); + if (ssrDrop <= tolerance * (current.ssr + tolerance) || stepTiny) { + status = 'converged'; + break; + } + } else { + lambda = Math.min(lambda * 8, 1e14); + if (stepTiny) { + // The proposed step is negligible and does not improve SSR: local minimum. + status = 'converged'; + break; + } + } + } + + // Fit statistics in the original data space. + const ssr = current.ssr; + const yMean = mean(y); + let tss = 0; + for (const yi of y) tss += (yi - yMean) ** 2; + let r2: number; + if (tss === 0) { + r2 = 0; + warnings.push({ code: 'zero-variance' }); + } else { + r2 = 1 - ssr / tss; + } + const rmse = Math.sqrt(ssr / n); + const predicted = [...current.f]; + const residuals = y.map((yi, i) => yi - (predicted[i] ?? Number.NaN)); + + for (const name of [...boundHits].sort()) { + warnings.push({ code: 'bound-hit', name }); + } + + // Asymptotic standard errors from (JᵀJ)⁻¹ · SSR/(n − p), when invertible. + let standardErrors: Record | undefined; + if (n > p) { + const jtj: number[][] = Array.from({ length: p }, () => new Array(p).fill(0)); + for (let i = 0; i < n; i++) { + const gradRow = current.jacobian[i]; + if (gradRow === undefined) continue; + for (let j = 0; j < p; j++) { + const jtjRow = jtj[j]; + if (jtjRow === undefined) continue; + for (let k = 0; k < p; k++) { + jtjRow[k] = (jtjRow[k] ?? 0) + (gradRow[j] ?? 0) * (gradRow[k] ?? 0); + } + } + } + const variance = ssr / (n - p); + const errors: Record = {}; + let invertible = true; + for (let j = 0; j < p; j++) { + const unit = new Array(p).fill(0); + unit[j] = 1; + const col = solveLinearSystem(jtj, unit); + const diag = col?.[j]; + if (col === null || diag === undefined || diag < 0) { + invertible = false; + break; + } + const name = parameters[j]; + if (name !== undefined) errors[name] = Math.sqrt(diag * variance); + } + if (invertible) standardErrors = errors; + } + + const finalParams = toParamRecord(params); + const predict = (vars: Record): number => { + const result = evaluate(parsed.rhsAst, { variables: { ...vars, ...finalParams } }); + return toFiniteNumber(result) ?? Number.NaN; + }; + + return { + ok: true, + parameters: finalParams, + ...(standardErrors !== undefined ? { standardErrors } : {}), + r2, + rmse, + residuals, + predicted, + iterations, + status, + warnings, + model: parsed, + predict, + }; +} diff --git a/packages/math-engine/src/stats/index.ts b/packages/math-engine/src/stats/index.ts index c5a79053..c23a8576 100644 --- a/packages/math-engine/src/stats/index.ts +++ b/packages/math-engine/src/stats/index.ts @@ -4,6 +4,7 @@ * Comprehensive statistical analysis toolkit including: * - Descriptive statistics (mean, median, mode, variance, standard deviation, etc.) * - Regression analysis (linear, polynomial, exponential) + * - Arbitrary-model tilde regression (Levenberg-Marquardt with analytic Jacobians) * - Correlation and covariance measures * * All functions are pure, immutable, and numerically stable. @@ -32,6 +33,22 @@ export { sum, variance, } from './descriptive'; +// Arbitrary-model tilde regression (nonlinear least squares) +export { + buildCannedModel, + type CannedModelKind, + type FitFailure, + type FitOptions, + type FitResult, + type FitSuccess, + type FitWarning, + fitModel, + type ParameterBounds, + type ParsedModel, + parseTildeModel, + type TildeModel, + type TildeModelErrorCode, +} from './fit'; // Regression analysis export { type ExponentialRegressionResult,