diff --git a/apps/web/__tests__/lib/simulation/gallery.test.ts b/apps/web/__tests__/lib/simulation/gallery.test.ts new file mode 100644 index 00000000..e142877e --- /dev/null +++ b/apps/web/__tests__/lib/simulation/gallery.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { + filterGalleryWorksheets, + isSimulationCellData, + simKindsOf, +} from '@/lib/simulation/gallery'; + +interface Worksheet { + id: string; + content: unknown; +} + +describe('isSimulationCellData', () => { + it('accepts a simulation cell shape', () => { + expect(isSimulationCellData({ kind: 'simulation', sim: 'lorenz' })).toBe(true); + }); + + it('rejects non-simulation cell kinds', () => { + expect(isSimulationCellData({ kind: 'math', input: '2+2' })).toBe(false); + expect(isSimulationCellData({ kind: 'text', content: 'hi' })).toBe(false); + }); + + it('rejects malformed / non-object values', () => { + expect(isSimulationCellData(null)).toBe(false); + expect(isSimulationCellData(undefined)).toBe(false); + expect(isSimulationCellData('simulation')).toBe(false); + expect(isSimulationCellData(42)).toBe(false); + expect(isSimulationCellData({ kind: 'simulation' })).toBe(false); // missing sim + expect(isSimulationCellData({ sim: 'lorenz' })).toBe(false); // missing kind + expect(isSimulationCellData({ kind: 'simulation', sim: 5 })).toBe(false); // sim not a string + }); +}); + +describe('simKindsOf', () => { + it('collects unique simulation kinds from a cell array', () => { + const content = [ + { kind: 'math', input: 'x=1' }, + { kind: 'simulation', sim: 'pde-heat' }, + { kind: 'simulation', sim: 'lorenz' }, + { kind: 'simulation', sim: 'pde-heat' }, // duplicate + ]; + expect(simKindsOf(content).sort()).toEqual(['lorenz', 'pde-heat']); + }); + + it('returns an empty array for a worksheet with no simulation cells', () => { + const content = [ + { kind: 'math', input: 'x=1' }, + { kind: 'text', content: 'notes' }, + ]; + expect(simKindsOf(content)).toEqual([]); + }); + + it('returns an empty array for malformed content (not an array)', () => { + expect(simKindsOf(null)).toEqual([]); + expect(simKindsOf(undefined)).toEqual([]); + expect(simKindsOf('not-an-array')).toEqual([]); + expect(simKindsOf({ kind: 'simulation', sim: 'lorenz' })).toEqual([]); + }); + + it('skips malformed cells within an otherwise valid array', () => { + const content = [null, 42, { kind: 'simulation', sim: 'lorenz' }, { notACell: true }]; + expect(simKindsOf(content)).toEqual(['lorenz']); + }); +}); + +describe('filterGalleryWorksheets', () => { + it('keeps only worksheets containing at least one simulation cell', () => { + const worksheets: Worksheet[] = [ + { id: 'text-only', content: [{ kind: 'text', content: 'hi' }] }, + { id: 'has-sim', content: [{ kind: 'simulation', sim: 'lorenz' }] }, + { id: 'empty', content: [] }, + { + id: 'multi-sim', + content: [ + { kind: 'simulation', sim: 'pde-wave' }, + { kind: 'simulation', sim: 'direction-field' }, + ], + }, + ]; + + const result = filterGalleryWorksheets(worksheets); + + expect(result.map((item) => item.worksheet.id)).toEqual(['has-sim', 'multi-sim']); + }); + + it('attaches the distinct simKinds alongside each surviving worksheet', () => { + const worksheets: Worksheet[] = [ + { + id: 'multi-sim', + content: [ + { kind: 'simulation', sim: 'pde-wave' }, + { kind: 'simulation', sim: 'direction-field' }, + ], + }, + ]; + + const result = filterGalleryWorksheets(worksheets); + + expect(result).toHaveLength(1); + expect(result[0]?.simKinds.sort()).toEqual(['direction-field', 'pde-wave']); + }); + + it('returns an empty array when nothing qualifies', () => { + const worksheets: Worksheet[] = [ + { id: 'a', content: [{ kind: 'text', content: 'hi' }] }, + { id: 'b', content: [] }, + ]; + expect(filterGalleryWorksheets(worksheets)).toEqual([]); + }); + + it('returns an empty array for an empty worksheet list', () => { + expect(filterGalleryWorksheets([])).toEqual([]); + }); +}); diff --git a/apps/web/__tests__/lib/simulation/registry.test.ts b/apps/web/__tests__/lib/simulation/registry.test.ts new file mode 100644 index 00000000..d932ea9e --- /dev/null +++ b/apps/web/__tests__/lib/simulation/registry.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_PARAMS, + DEFAULT_PRESET, + DIRECTION_FIELD_PRESETS, + getSimParams, + isSimulationKind, + SIM_REGISTRY, + SIMULATION_KINDS, + sanitizeSimParams, +} from '@/lib/simulation/registry'; +import en from '@/messages/en.json'; + +/** Resolve a dotted key path against the (typed) en.json import at runtime. */ +function hasStringAt(obj: unknown, path: readonly string[]): boolean { + let cur: unknown = obj; + for (const key of path) { + if (typeof cur !== 'object' || cur === null || !(key in cur)) return false; + cur = (cur as Record)[key]; + } + return typeof cur === 'string'; +} + +/** + * Extract the string param keys a direction-field f/g closure reads via + * `p['paramN']` — used to assert presets never read an undeclared param key. + */ +function referencedParamKeys(fn: (...args: never[]) => number): string[] { + const matches = fn.toString().matchAll(/p\[['"](\w+)['"]\]/g); + return [...matches].map((m) => m[1] as string); +} + +describe('SIM_REGISTRY', () => { + it('declares exactly the kinds in SIMULATION_KINDS', () => { + expect(Object.keys(SIM_REGISTRY).sort()).toEqual([...SIMULATION_KINDS].sort()); + }); + + describe.each(SIMULATION_KINDS)('%s', (kind) => { + it('has a label key that resolves in en.json under worksheet.simulation', () => { + const entry = SIM_REGISTRY[kind]; + expect(hasStringAt(en, ['worksheet', 'simulation', entry.labelKey])).toBe(true); + }); + + it('declares at least one parameter spec', () => { + expect(SIM_REGISTRY[kind].params.length).toBeGreaterThan(0); + }); + + it('every param spec satisfies min <= default <= max and min < max', () => { + for (const spec of SIM_REGISTRY[kind].params) { + expect(spec.min).toBeLessThan(spec.max); + expect(spec.min).toBeLessThanOrEqual(spec.default); + expect(spec.default).toBeLessThanOrEqual(spec.max); + } + }); + + it('every param spec has a label key that resolves under worksheet.simulation.params', () => { + for (const spec of SIM_REGISTRY[kind].params) { + expect(hasStringAt(en, ['worksheet', 'simulation', 'params', spec.labelKey])).toBe(true); + } + }); + + it('declares at least one preset, each with a resolvable label key', () => { + const presets = SIM_REGISTRY[kind].presets; + expect(presets.length).toBeGreaterThan(0); + for (const preset of presets) { + expect(hasStringAt(en, ['worksheet', 'simulation', 'presets', preset])).toBe(true); + } + }); + + it('DEFAULT_PARAMS produces exactly the declared keys, each within [min, max]', () => { + const preset = DEFAULT_PRESET(kind); + const specs = getSimParams(kind, preset); + const params = DEFAULT_PARAMS(kind, preset); + + expect(Object.keys(params).sort()).toEqual(specs.map((s) => s.key).sort()); + for (const spec of specs) { + const value = params[spec.key]; + expect(value).toBeGreaterThanOrEqual(spec.min); + expect(value).toBeLessThanOrEqual(spec.max); + } + }); + }); + + describe('direction-field presets', () => { + it('every preset name declared on the kind maps to a DIRECTION_FIELD_PRESETS entry', () => { + for (const preset of SIM_REGISTRY['direction-field'].presets) { + expect(DIRECTION_FIELD_PRESETS[preset]).toBeDefined(); + } + }); + + it('every preset param spec satisfies min <= default <= max', () => { + for (const preset of Object.values(DIRECTION_FIELD_PRESETS)) { + expect(preset.params.length).toBeGreaterThan(0); + for (const spec of preset.params) { + expect(spec.min).toBeLessThan(spec.max); + expect(spec.min).toBeLessThanOrEqual(spec.default); + expect(spec.default).toBeLessThanOrEqual(spec.max); + } + } + }); + + it('every preset declares a non-degenerate domain', () => { + for (const preset of Object.values(DIRECTION_FIELD_PRESETS)) { + expect(preset.xMin).toBeLessThan(preset.xMax); + expect(preset.yMin).toBeLessThan(preset.yMax); + } + }); + + it('f/g only ever read param keys declared on that preset', () => { + for (const [name, preset] of Object.entries(DIRECTION_FIELD_PRESETS)) { + const declared = new Set(preset.params.map((s) => s.key)); + const used = new Set([...referencedParamKeys(preset.f), ...referencedParamKeys(preset.g)]); + for (const key of used) { + expect(declared.has(key), `${name} references undeclared param "${key}"`).toBe(true); + } + } + }); + + it('f/g return finite numbers for default params at the domain midpoint', () => { + for (const preset of Object.values(DIRECTION_FIELD_PRESETS)) { + const params: Record = {}; + for (const spec of preset.params) params[spec.key] = spec.default; + const midX = (preset.xMin + preset.xMax) / 2; + const midY = (preset.yMin + preset.yMax) / 2; + expect(Number.isFinite(preset.f(midX, midY, params))).toBe(true); + expect(Number.isFinite(preset.g(midX, midY, params))).toBe(true); + } + }); + + it('getSimParams returns the active preset params, not the registry default entry', () => { + const pendulumParams = getSimParams('direction-field', 'pendulum'); + expect(pendulumParams).toBe(DIRECTION_FIELD_PRESETS['pendulum']?.params); + expect(pendulumParams).not.toBe(SIM_REGISTRY['direction-field'].params); + }); + + it('falls back to the registry default params for an unrecognized preset name', () => { + const params = getSimParams('direction-field', 'not-a-real-preset'); + expect(params).toBe(SIM_REGISTRY['direction-field'].params); + }); + }); + + describe('isSimulationKind', () => { + it('accepts every declared kind', () => { + for (const kind of SIMULATION_KINDS) { + expect(isSimulationKind(kind)).toBe(true); + } + }); + + it('rejects strings that are not declared kinds', () => { + expect(isSimulationKind('not-a-kind')).toBe(false); + expect(isSimulationKind('')).toBe(false); + }); + }); + + describe('DEFAULT_PRESET', () => { + it('returns van-der-pol for direction-field', () => { + expect(DEFAULT_PRESET('direction-field')).toBe('van-der-pol'); + }); + + it('returns the first registry preset for non-direction-field kinds', () => { + expect(DEFAULT_PRESET('lorenz')).toBe(SIM_REGISTRY['lorenz'].presets[0]); + expect(DEFAULT_PRESET('pde-heat')).toBe(SIM_REGISTRY['pde-heat'].presets[0]); + }); + }); + + describe('sanitizeSimParams', () => { + // Worksheet content is untrusted: forked/public gallery worksheets are + // author-controlled JSON, so stored params must be clamped before any + // compute (a crafted lorenz `steps` would otherwise drive an unbounded + // CPU trajectory loop — the sliders only clamp locally-written values). + it('clamps out-of-range values to the spec bounds', () => { + const safe = sanitizeSimParams('lorenz', 'classic', { + sigma: -100, + rho: 1e9, + beta: 2, + steps: 1e9, + }); + expect(safe['sigma']).toBe(0); + expect(safe['rho']).toBe(60); + expect(safe['beta']).toBe(2); + expect(safe['steps']).toBe(5000); + }); + + it('replaces non-finite and non-numeric values with the spec default', () => { + const safe = sanitizeSimParams('lorenz', 'classic', { + sigma: Number.NaN, + rho: Number.POSITIVE_INFINITY, + steps: 'evil' as unknown as number, + }); + expect(safe['sigma']).toBe(10); + expect(safe['rho']).toBe(28); + expect(safe['beta']).toBe(8 / 3); + expect(safe['steps']).toBe(2000); + }); + + it('drops unknown keys and fills every declared spec key', () => { + const safe = sanitizeSimParams('pde-heat', 'center', { __proto__evil: 1 }); + expect(Object.keys(safe).sort()).toEqual( + getSimParams('pde-heat', 'center') + .map((s) => s.key) + .sort(), + ); + }); + + it('uses the active direction-field preset spec, not the registry default', () => { + const specs = getSimParams('direction-field', 'van-der-pol'); + const firstKey = specs[0]?.key; + if (!firstKey) throw new Error('van-der-pol preset declares no params'); + const safe = sanitizeSimParams('direction-field', 'van-der-pol', { + [firstKey]: Number.MAX_VALUE, + }); + expect(safe[firstKey]).toBe(specs[0]?.max); + }); + }); +}); diff --git a/apps/web/app/[locale]/gpu-lab/loading.tsx b/apps/web/app/[locale]/gpu-lab/loading.tsx new file mode 100644 index 00000000..dd7e5d2c --- /dev/null +++ b/apps/web/app/[locale]/gpu-lab/loading.tsx @@ -0,0 +1,16 @@ +export default function Loading() { + return ( +
+
+
+
+
+
+
+ {[0, 1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+
+ ); +} diff --git a/apps/web/app/[locale]/gpu-lab/page.tsx b/apps/web/app/[locale]/gpu-lab/page.tsx new file mode 100644 index 00000000..ba044526 --- /dev/null +++ b/apps/web/app/[locale]/gpu-lab/page.tsx @@ -0,0 +1,235 @@ +/** + * GPU Lab — public gallery of worksheets containing simulation cells. + * + * Server component: fetches public worksheets (with content JSON) through the + * in-process RSC Apollo client (SchemaLink — no HTTP self-fetch), filters for + * worksheets that contain at least one simulation cell, and renders a card + * grid with per-card simulation badges. + * + * Caching: no 'use cache' (cacheComponents intentionally not adopted); the + * publicWorksheets resolver sets a 60s PUBLIC cache hint and the + * setWorksheetVisibility action revalidates this path on publish/unpublish. + * + * Route: /[locale]/gpu-lab + */ + +import { Eye, Zap } from 'lucide-react'; +import type { Metadata } from 'next'; +import { getFormatter, getTranslations } from 'next-intl/server'; +import { Link } from '@/i18n/navigation'; +import type { FragmentType } from '@/lib/graphql/generated'; +import { useFragment } from '@/lib/graphql/generated'; +import { UserSummaryFragmentDoc } from '@/lib/graphql/generated/graphql'; +import { GPU_LAB_WORKSHEETS_QUERY, type USER_SUMMARY_FRAGMENT } from '@/lib/graphql/operations'; +import { query } from '@/lib/graphql/rsc-client'; +import { filterGalleryWorksheets } from '@/lib/simulation/gallery'; +import { isSimulationKind, SIM_REGISTRY } from '@/lib/simulation/registry'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface PageProps { + params: Promise<{ locale: string }>; +} + +interface GalleryWorksheet { + id: string; + title: string; + description: string | null; + views: number; + updatedAt: string; + content: unknown; + /** Masked — unwrap with useFragment(UserSummaryFragmentDoc, user) */ + user: FragmentType; +} + +// --------------------------------------------------------------------------- +// Data fetching +// --------------------------------------------------------------------------- + +async function getGalleryWorksheets(): Promise { + try { + const { data } = await query({ query: GPU_LAB_WORKSHEETS_QUERY }); + return data?.publicWorksheets.nodes ?? []; + } catch { + // Database unreachable — render the empty state rather than erroring + return []; + } +} + +// --------------------------------------------------------------------------- +// Metadata +// --------------------------------------------------------------------------- + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: 'gpuLab' }); + return { + title: t('title'), + description: t('description'), + }; +} + +// --------------------------------------------------------------------------- +// Gallery card +// --------------------------------------------------------------------------- + +interface GalleryCardProps { + worksheet: GalleryWorksheet; + badges: string[]; + byLabel: (name: string) => string; + /** Formatted view count for display (locale number formatting) */ + viewsCount: string; + /** Full accessible label, e.g. "12 views" with locale plural rules */ + viewsAria: string; + updatedLabel: string; + openLabel: string; +} + +function GalleryCard({ + worksheet, + badges, + byLabel, + viewsCount, + viewsAria, + updatedLabel, + openLabel, +}: GalleryCardProps) { + // Not a hook — the codegen client preset's fragment unmasking function. + const user = useFragment(UserSummaryFragmentDoc, worksheet.user); + + return ( + + {/* Sim-kind badges */} +
+ {badges.map((badge) => ( + + + ))} +
+ + {/* Title + description */} +
+

+ {worksheet.title} +

+ {worksheet.description && ( +

{worksheet.description}

+ )} +
+ + {/* Footer: author · views · updated */} +
+ {byLabel(user.name ?? '—')} + + + + {updatedLabel} + +
+ + ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export default async function GpuLabPage({ params }: PageProps) { + const { locale } = await params; + const [t, tSim, format, worksheets] = await Promise.all([ + getTranslations({ locale, namespace: 'gpuLab' }), + getTranslations({ locale, namespace: 'worksheet.simulation' }), + getFormatter({ locale }), + getGalleryWorksheets(), + ]); + + // Only worksheets that actually contain a simulation cell belong here + const galleryItems = filterGalleryWorksheets(worksheets); + + return ( +
+ {/* Ambient background */} +
+ ); +} diff --git a/apps/web/app/[locale]/pde/page.tsx b/apps/web/app/[locale]/pde/page.tsx index fccfefa3..66cb39d1 100644 --- a/apps/web/app/[locale]/pde/page.tsx +++ b/apps/web/app/[locale]/pde/page.tsx @@ -26,20 +26,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Label } from '@/components/ui/label'; import { Slider } from '@/components/ui/slider'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + generateInitialCondition, + type InitialConditionType, +} from '@/lib/simulation/initial-conditions'; type EquationType = 'heat' | 'wave'; -type InitialConditionType = - | 'center' - | 'line' - | 'corners' - | 'ring' - | 'cross' - | 'gaussian' - | 'random' - | 'doubleGaussian' - | 'sawtooth' - | 'squarePulse' - | 'sinc'; // ============================================================================ // HIGH-PERFORMANCE PDE SOLVERS FOR REAL-TIME VISUALIZATION @@ -220,209 +212,6 @@ export default function PDESolverPage() { // Refs const animationFrameRef = useRef(null); - /** - * Generate initial conditions for various scenarios - */ - const initializeCondition = useCallback( - (type: InitialConditionType): number[][] => { - const grid: number[][] = Array(gridSize) - .fill(0) - .map(() => Array(gridSize).fill(0)); - - switch (type) { - case 'center': { - // Gaussian hot spot in the center — scaled to grid size for visible diffusion - const center = Math.floor(gridSize / 2); - const sigma = gridSize / 8; // Wider peak for better visualisation - for (let i = 0; i < gridSize; i++) { - for (let j = 0; j < gridSize; j++) { - const dx = i - center; - const dy = j - center; - grid[i]![j] = 100 * Math.exp(-(dx * dx + dy * dy) / (2 * sigma * sigma)); - } - } - break; - } - - case 'line': { - // Hot line across the middle - const mid = Math.floor(gridSize / 2); - for (let j = 0; j < gridSize; j++) { - grid[mid]![j] = 100; - if (mid > 0) grid[mid - 1]![j] = 50; - if (mid < gridSize - 1) grid[mid + 1]![j] = 50; - } - break; - } - - case 'corners': { - // Hot corners - const cornerSize = 5; - for (let i = 0; i < cornerSize; i++) { - for (let j = 0; j < cornerSize; j++) { - const intensity = 100 * (1 - Math.sqrt(i * i + j * j) / (cornerSize * Math.sqrt(2))); - grid[i]![j] = Math.max(0, intensity); - grid[i]![gridSize - 1 - j] = Math.max(0, intensity); - grid[gridSize - 1 - i]![j] = Math.max(0, intensity); - grid[gridSize - 1 - i]![gridSize - 1 - j] = Math.max(0, intensity); - } - } - break; - } - - case 'ring': { - // Ring pattern — width scales with grid for consistent aesthetics - const ringCenter = Math.floor(gridSize / 2); - const ringRadius = Math.floor(gridSize / 3); - const ringWidth = Math.max(3, Math.floor(gridSize / 16)); - for (let i = 0; i < gridSize; i++) { - for (let j = 0; j < gridSize; j++) { - const dx = i - ringCenter; - const dy = j - ringCenter; - const dist = Math.sqrt(dx * dx + dy * dy); - const proximity = Math.abs(dist - ringRadius); - if (proximity < ringWidth) { - grid[i]![j] = 100 * (1 - proximity / ringWidth); - } - } - } - break; - } - - case 'cross': { - // Cross pattern - const crossMid = Math.floor(gridSize / 2); - const crossWidth = 3; - for (let i = 0; i < gridSize; i++) { - for (let j = crossMid - crossWidth; j <= crossMid + crossWidth; j++) { - if (j >= 0 && j < gridSize) { - grid[i]![j] = 100 * (1 - Math.abs(j - crossMid) / crossWidth); - } - } - } - for (let j = 0; j < gridSize; j++) { - for (let i = crossMid - crossWidth; i <= crossMid + crossWidth; i++) { - if (i >= 0 && i < gridSize) { - grid[i]![j] = Math.max( - grid[i]![j] ?? 0, - 100 * (1 - Math.abs(i - crossMid) / crossWidth), - ); - } - } - } - break; - } - - case 'gaussian': { - // Multiple Gaussian peaks - const peaks = [ - { x: gridSize / 4, y: gridSize / 4 }, - { x: (3 * gridSize) / 4, y: gridSize / 4 }, - { x: gridSize / 2, y: (3 * gridSize) / 4 }, - ]; - const sigma = gridSize / 10; - for (let i = 0; i < gridSize; i++) { - for (let j = 0; j < gridSize; j++) { - let value = 0; - for (const peak of peaks) { - const dx = i - peak.x; - const dy = j - peak.y; - const r2 = dx * dx + dy * dy; - value += 100 * Math.exp(-r2 / (2 * sigma * sigma)); - } - grid[i]![j] = Math.min(100, value); - } - } - break; - } - - case 'random': - // Random hot spots - for (let k = 0; k < 10; k++) { - const x = Math.floor(Math.random() * (gridSize - 10)) + 5; - const y = Math.floor(Math.random() * (gridSize - 10)) + 5; - const size = 3; - for (let i = x - size; i <= x + size; i++) { - for (let j = y - size; j <= y + size; j++) { - if (i >= 0 && i < gridSize && j >= 0 && j < gridSize) { - const dist = Math.sqrt((i - x) ** 2 + (j - y) ** 2); - grid[i]![j] = Math.max(grid[i]![j] ?? 0, 100 * (1 - dist / size)); - } - } - } - } - break; - - case 'doubleGaussian': { - // Two Gaussian peaks: f(x) = exp(-(x-0.3)^2/0.01) + exp(-(x-0.7)^2/0.01) - // Applied as separable product f(x)*f(y) for a 2D pattern - const dgFn = (t: number): number => - Math.exp(-((t - 0.3) ** 2) / 0.01) + Math.exp(-((t - 0.7) ** 2) / 0.01); - for (let i = 0; i < gridSize; i++) { - const nx = i / (gridSize - 1); - for (let j = 0; j < gridSize; j++) { - const ny = j / (gridSize - 1); - grid[i]![j] = 100 * dgFn(nx) * dgFn(ny); - } - } - break; - } - - case 'sawtooth': { - // Sawtooth: f(x) = x - floor(x), with 3 repeats across the domain - // Applied as separable product for 2D - const stFn = (t: number): number => { - const scaled = t * 3; - return scaled - Math.floor(scaled); - }; - for (let i = 0; i < gridSize; i++) { - const nx = i / (gridSize - 1); - for (let j = 0; j < gridSize; j++) { - const ny = j / (gridSize - 1); - grid[i]![j] = 100 * stFn(nx) * stFn(ny); - } - } - break; - } - - case 'squarePulse': { - // Square pulse: f(x) = 1 if 0.3 < x < 0.7, else 0 - // Applied as separable product for a 2D box - const spFn = (t: number): number => (t > 0.3 && t < 0.7 ? 1 : 0); - for (let i = 0; i < gridSize; i++) { - const nx = i / (gridSize - 1); - for (let j = 0; j < gridSize; j++) { - const ny = j / (gridSize - 1); - grid[i]![j] = 100 * spFn(nx) * spFn(ny); - } - } - break; - } - - case 'sinc': { - // Sinc function: f(x) = sin(t)/t where t = (x-0.5)*20, f(0.5) = 1 - // Applied as separable product for 2D - const sincFn = (x: number): number => { - const t = (x - 0.5) * 20; - return t === 0 ? 1 : Math.sin(t) / t; - }; - for (let i = 0; i < gridSize; i++) { - const nx = i / (gridSize - 1); - for (let j = 0; j < gridSize; j++) { - const ny = j / (gridSize - 1); - // sinc can be negative; scale so max is 100, keeping sign for wave equation interest - grid[i]![j] = 100 * sincFn(nx) * sincFn(ny); - } - } - break; - } - } - - return grid; - }, - [gridSize], - ); - /** * Solve PDE with performance tracking */ @@ -481,8 +270,8 @@ export default function PDESolverPage() { * which left the heatmap blank when other parameters changed. */ useEffect(() => { - solvePDE(initializeCondition(initialCondition)); - }, [solvePDE, initializeCondition, initialCondition]); + solvePDE(generateInitialCondition(initialCondition, gridSize)); + }, [solvePDE, initialCondition, gridSize]); /** * High-performance animation loop diff --git a/apps/web/app/[locale]/solver/ode/GpuDirectionField.tsx b/apps/web/app/[locale]/solver/ode/GpuDirectionField.tsx index ca1c8abc..8e480866 100644 --- a/apps/web/app/[locale]/solver/ode/GpuDirectionField.tsx +++ b/apps/web/app/[locale]/solver/ode/GpuDirectionField.tsx @@ -69,6 +69,13 @@ export interface GpuDirectionFieldProps { readonly g: (x: number, y: number, t: number) => number; /** Which built-in equation the GPU shader should use. */ readonly equationType: FieldEquationType; + /** + * Optional equation parameters (param0, param1) overriding the built-in + * defaults for the selected equation type — e.g. worksheet sliders binding + * Lotka-Volterra a/b, Van der Pol μ, spiral α, or pendulum γ. + * Uniform layout is unchanged (f32 offsets 6/7 of the 32-byte block). + */ + readonly params?: readonly [number, number]; /** Grid resolution per axis. Default 64 → 64×64 = 4096 arrows. */ readonly gridN?: number; /** Opacity of the direction field layer [0, 1]. Default 0.4. */ @@ -650,10 +657,15 @@ export function GpuDirectionField({ f, g, equationType, + params, gridN = 64, opacity = 0.4, visible = true, }: GpuDirectionFieldProps) { + // Destructured so the render effect depends on the numbers, not on the + // (potentially per-render) tuple identity. + const paramOverride0 = params?.[0]; + const paramOverride1 = params?.[1]; const canvasRef = useRef(null); const gpuRef = useRef(null); const gpuActiveRef = useRef(false); @@ -722,7 +734,9 @@ export function GpuDirectionField({ } = r; const eqInt = eqTypeToInt(equationType); - const [param0, param1] = eqParams(equationType); + const [defaultParam0, defaultParam1] = eqParams(equationType); + const param0 = paramOverride0 ?? defaultParam0; + const param1 = paramOverride1 ?? defaultParam1; // Write compute uniforms writeComputeUniforms( @@ -786,6 +800,8 @@ export function GpuDirectionField({ yMin, yMax, equationType, + paramOverride0, + paramOverride1, gridN, opacity, size, diff --git a/apps/web/app/[locale]/worksheet/client-wrapper.tsx b/apps/web/app/[locale]/worksheet/client-wrapper.tsx index bc1f2f6c..d10dd12d 100644 --- a/apps/web/app/[locale]/worksheet/client-wrapper.tsx +++ b/apps/web/app/[locale]/worksheet/client-wrapper.tsx @@ -58,15 +58,20 @@ export function WorksheetClientWrapper({ } }, [initialWorksheetId, loadAction]); - // Hydrate store when load completes + // Hydrate store when load completes. + // Fork-on-open: non-owners get a detached local draft (worksheetId null, + // version 0) so their edits autosave as a NEW worksheet they own instead of + // 409-conflicting against the owner's row. useEffect(() => { if (loadState.success && loadState.data) { - const { worksheetId, title, cells, version } = loadState.data; + const { worksheetId, title, cells, version, isOwner, visibility } = loadState.data; store.getState().hydrate({ worksheetId, title, cells: cells as WorksheetCell[], version, + visibility, + ...(isOwner ? {} : { fork: true }), }); } }, [loadState]); diff --git a/apps/web/app/actions/worksheet.ts b/apps/web/app/actions/worksheet.ts index f76894f1..681e54db 100644 --- a/apps/web/app/actions/worksheet.ts +++ b/apps/web/app/actions/worksheet.ts @@ -15,6 +15,7 @@ import { DeleteWorksheetSchema, LoadWorksheetSchema, SaveWorksheetSchema, + SetWorksheetVisibilitySchema, } from '@/lib/validations/learning'; import type { ActionResult } from './problems'; @@ -134,6 +135,10 @@ export interface LoadWorksheetResult { title: string; cells: unknown; version: number; + /** True when the requesting session owns this worksheet (fork-on-open cue) */ + isOwner: boolean; + /** Current gallery visibility (seeds the Publish toggle state) */ + visibility: 'PRIVATE' | 'UNLISTED' | 'PUBLIC'; } export async function loadWorksheet( @@ -183,6 +188,8 @@ export async function loadWorksheet( title: worksheet.title, cells: worksheet.content, version: worksheet.version, + isOwner: userId !== undefined && worksheet.userId === userId, + visibility: worksheet.visibility, }, }; } catch (error) { @@ -240,3 +247,62 @@ export async function deleteWorksheet( return { success: false, error: 'Failed to delete worksheet' }; } } + +// --------------------------------------------------------------------------- +// setWorksheetVisibility (publish / unpublish to the GPU Lab gallery) +// --------------------------------------------------------------------------- + +export interface SetWorksheetVisibilityResult { + worksheetId: string; + visibility: 'PUBLIC' | 'PRIVATE'; +} + +export async function setWorksheetVisibility( + _prevState: ActionResult, + formData: FormData, +): Promise> { + try { + const raw = Object.fromEntries(formData.entries()); + const data = SetWorksheetVisibilitySchema.parse(raw); + + const session = await auth(); + if (!session?.user?.id) { + return { success: false, error: 'Sign in to publish worksheets' }; + } + + // Atomic ownership check in the WHERE clause (same shape as saveWorksheet) + try { + const updated = await prisma.worksheet.update({ + where: { + id: data.worksheetId, + userId: session.user.id, + deletedAt: null, + }, + data: { visibility: data.visibility }, + select: { id: true }, + }); + + // The public gallery lists PUBLIC worksheets — refresh it. + revalidatePath('/[locale]/gpu-lab', 'page'); + + return { + success: true, + data: { worksheetId: updated.id, visibility: data.visibility }, + }; + } catch (e) { + // Prisma P2025: record not found (wrong owner or deleted) + if ( + typeof e === 'object' && + e !== null && + 'code' in e && + (e as { code: string }).code === 'P2025' + ) { + return { success: false, error: 'Worksheet not found' }; + } + throw e; + } + } catch (error) { + console.error('setWorksheetVisibility error:', error); + return { success: false, error: 'Failed to update visibility' }; + } +} diff --git a/apps/web/components/layout/command-palette.tsx b/apps/web/components/layout/command-palette.tsx index f6369d6d..72438159 100644 --- a/apps/web/components/layout/command-palette.tsx +++ b/apps/web/components/layout/command-palette.tsx @@ -161,6 +161,14 @@ const PAGE_COMMANDS = [ keywords: ['pde', '3d', 'heat', 'wave', 'isosurface', 'marching', 'cubes', 'voxel', 'three'], href: '/pde/3d', }, + { + id: 'page-gpu-lab', + label: 'GPU Lab', + description: 'Public gallery of shared GPU simulations', + icon: Zap, + keywords: ['gpu', 'simulation', 'gallery', 'webgpu', 'lab', 'lorenz', 'heatmap', 'worksheet'], + href: '/gpu-lab', + }, { id: 'page-ode', label: 'ODE Solver', diff --git a/apps/web/components/plot/variable-sliders.test.ts b/apps/web/components/plot/variable-sliders.test.ts new file mode 100644 index 00000000..4ca1a472 --- /dev/null +++ b/apps/web/components/plot/variable-sliders.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import { + nextMode, + nextSpeed, + SPEED_STEPS, + SWEEP_DURATION_MS, + stepSliderValue, +} from './variable-sliders'; + +describe('nextMode', () => { + it('cycles loop -> bounce -> once -> loop', () => { + expect(nextMode('loop')).toBe('bounce'); + expect(nextMode('bounce')).toBe('once'); + expect(nextMode('once')).toBe('loop'); + }); +}); + +describe('nextSpeed', () => { + it('cycles 0.5 -> 1 -> 2 -> 4 -> 0.5', () => { + expect(nextSpeed(0.5)).toBe(1); + expect(nextSpeed(1)).toBe(2); + expect(nextSpeed(2)).toBe(4); + expect(nextSpeed(4)).toBe(0.5); + }); + + it('falls back to the first speed step for a speed outside SPEED_STEPS', () => { + // indexOf(123) === -1 -> (-1 + 1) % 4 === 0 -> SPEED_STEPS[0] + expect(nextSpeed(123)).toBe(SPEED_STEPS[0]); + }); + + it('SPEED_STEPS is the documented 0.5/1/2/4 progression', () => { + expect(SPEED_STEPS).toEqual([0.5, 1, 2, 4]); + }); +}); + +describe('stepSliderValue', () => { + const base = { min: 0, max: 10, speed: 1, direction: 1 as const }; + + it('advances by (span / SWEEP_DURATION_MS) * dt * speed * direction', () => { + const result = stepSliderValue({ ...base, value: 0, mode: 'loop', dt: 400 }); + // span=10, dt=400ms, speed=1 -> delta = 10/4000 * 400 = 1 + expect(result.value).toBeCloseTo(1, 10); + expect(result.direction).toBe(1); + expect(result.finished).toBe(false); + }); + + it('scales linearly with speed', () => { + const at1x = stepSliderValue({ ...base, value: 0, mode: 'loop', dt: 400, speed: 1 }); + const at4x = stepSliderValue({ ...base, value: 0, mode: 'loop', dt: 400, speed: 4 }); + expect(at4x.value).toBeCloseTo(at1x.value * 4, 10); + }); + + describe('loop mode', () => { + it('wraps back to min once value reaches max', () => { + const result = stepSliderValue({ + ...base, + value: 9.5, + mode: 'loop', + dt: SWEEP_DURATION_MS, // a full sweep worth of dt guarantees overshoot + }); + expect(result.value).toBe(base.min); + expect(result.direction).toBe(1); + expect(result.finished).toBe(false); + }); + + it('never reports finished', () => { + const result = stepSliderValue({ ...base, value: 9.99, mode: 'loop', dt: 1000 }); + expect(result.finished).toBe(false); + }); + }); + + describe('bounce mode', () => { + it('clamps at max and flips direction to -1', () => { + const result = stepSliderValue({ + ...base, + value: 9.5, + mode: 'bounce', + direction: 1, + dt: 1000, + }); + expect(result.value).toBe(base.max); + expect(result.direction).toBe(-1); + expect(result.finished).toBe(false); + }); + + it('clamps at min and flips direction to +1 when travelling backwards', () => { + const result = stepSliderValue({ + ...base, + value: 0.5, + mode: 'bounce', + direction: -1, + dt: 1000, + }); + expect(result.value).toBe(base.min); + expect(result.direction).toBe(1); + expect(result.finished).toBe(false); + }); + + it('does not flip direction mid-sweep', () => { + const result = stepSliderValue({ + ...base, + value: 5, + mode: 'bounce', + direction: 1, + dt: 10, + }); + expect(result.direction).toBe(1); + expect(result.value).toBeGreaterThan(5); + expect(result.value).toBeLessThan(base.max); + }); + }); + + describe('once mode', () => { + it('clamps at max and reports finished', () => { + const result = stepSliderValue({ + ...base, + value: 9.5, + mode: 'once', + dt: 1000, + }); + expect(result.value).toBe(base.max); + expect(result.finished).toBe(true); + }); + + it('is not finished mid-sweep', () => { + const result = stepSliderValue({ + ...base, + value: 5, + mode: 'once', + dt: 10, + }); + expect(result.finished).toBe(false); + expect(result.value).toBeGreaterThan(5); + }); + }); + + it('a full SWEEP_DURATION_MS-length dt at 1x sweeps exactly one full span', () => { + const result = stepSliderValue({ + min: -5, + max: 5, + speed: 1, + direction: 1, + value: -5, + mode: 'bounce', + dt: SWEEP_DURATION_MS, + }); + // -5 + (10/4000)*4000*1 = 5 exactly -> clamps at max, flips direction + expect(result.value).toBe(5); + expect(result.direction).toBe(-1); + }); +}); diff --git a/apps/web/components/plot/variable-sliders.tsx b/apps/web/components/plot/variable-sliders.tsx index 627b0be6..09bf1c7a 100644 --- a/apps/web/components/plot/variable-sliders.tsx +++ b/apps/web/components/plot/variable-sliders.tsx @@ -11,6 +11,11 @@ * - Per-slider min/max editing via inline click-to-edit number inputs * - Step size auto-chosen from range (1/200 of span, clamped to 4 d.p.) * - Real-time value display (4 significant figures) + * - Desmos-style play-button animation per slider (loop / bounce / once + * modes, 0.5–4× speed) driven by ONE component-level rAF loop whose tick + * is a React 19.3 useEffectEvent (reads latest state, no re-subscribes) + * - prefers-reduced-motion respected: nothing auto-plays, and flipping the + * OS setting on mid-animation pauses every slider * - Keyboard navigation (arrow keys, Home/End) * - Fully ARIA-labelled range inputs for screen readers * - Framer Motion entrance animation @@ -20,10 +25,21 @@ */ import { extractVariables } from '@nextcalc/math-engine'; -import { ChevronDown, ChevronUp, SlidersHorizontal } from 'lucide-react'; +import { + ArrowLeftRight, + ChevronDown, + ChevronUp, + MoveRight, + Pause, + Play, + Repeat, + SlidersHorizontal, +} from 'lucide-react'; import { AnimatePresence, m } from 'motion/react'; +import { useTranslations } from 'next-intl'; import type { ChangeEvent, KeyboardEvent } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'; +import { useReducedMotion } from '@/lib/hooks/use-reduced-motion'; // --------------------------------------------------------------------------- // Constants @@ -64,6 +80,12 @@ const DEFAULT_MIN = -10; const DEFAULT_MAX = 10; const DEFAULT_VALUE = 1; +/** Time for one full min→max sweep at 1× speed. */ +export const SWEEP_DURATION_MS = 4000; + +/** Speed multipliers cycled by the speed button. */ +export const SPEED_STEPS = [0.5, 1, 2, 4] as const; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -80,6 +102,16 @@ export interface SliderConfig { /** Map from parameter name to its current slider configuration. */ export type SliderValues = Record; +/** Animation playback mode for a single slider. */ +export type SliderAnimMode = 'loop' | 'bounce' | 'once'; + +/** Runtime animation state for a single slider — local UI state only. */ +interface SliderAnim { + mode: SliderAnimMode; + speed: number; + direction: 1 | -1; +} + export interface VariableSlidersProps { /** * One or more expression strings to scan for free parameters. @@ -142,6 +174,87 @@ function detectParameters(expressions: string[]): string[] { return Array.from(seen).sort(); } +/** Next animation mode in the loop → bounce → once cycle. */ +export function nextMode(mode: SliderAnimMode): SliderAnimMode { + if (mode === 'loop') return 'bounce'; + if (mode === 'bounce') return 'once'; + return 'loop'; +} + +/** Next speed multiplier in the 0.5× → 1× → 2× → 4× cycle. */ +export function nextSpeed(speed: number): number { + const idx = (SPEED_STEPS as readonly number[]).indexOf(speed); + return SPEED_STEPS[(idx + 1) % SPEED_STEPS.length] ?? 1; +} + +/** Inputs for a single rAF-driven animation step (one slider, one frame). */ +export interface SliderStepInput { + value: number; + min: number; + max: number; + mode: SliderAnimMode; + speed: number; + direction: 1 | -1; + /** Elapsed time since the previous frame, in milliseconds. */ + dt: number; +} + +export interface SliderStepResult { + /** Next slider value, already clamped to [min, max]. */ + value: number; + /** Next travel direction (only ever flips in 'bounce' mode). */ + direction: 1 | -1; + /** True once a 'once'-mode sweep has reached max — caller should stop it. */ + finished: boolean; +} + +/** + * Pure, framework-free step function for the Desmos-style slider animation. + * Advances `value` by `(span / SWEEP_DURATION_MS) * dt * speed` in `direction`, + * then applies the mode's boundary behavior: + * - loop: wraps back to `min` at `max` + * - bounce: clamps at the bound and flips `direction` + * - once: clamps at `max` and reports `finished: true` + * + * Callers are expected to have already verified `max > min` (a degenerate + * span is a caller-level no-op, not a step to compute). + */ +export function stepSliderValue({ + value, + min, + max, + mode, + speed, + direction, + dt, +}: SliderStepInput): SliderStepResult { + const span = max - min; + const delta = (span / SWEEP_DURATION_MS) * dt * speed * direction; + let next = value + delta; + let nextDirection = direction; + let finished = false; + + if (mode === 'loop') { + if (next >= max) next = min; + } else if (mode === 'bounce') { + if (next >= max) { + next = max; + nextDirection = -1; + } else if (next <= min) { + next = min; + nextDirection = 1; + } + } else { + // 'once' — stop exactly at max + if (next >= max) { + next = max; + finished = true; + } + } + + return { value: next, direction: nextDirection, finished }; +} + // --------------------------------------------------------------------------- // Sub-component: inline editable min/max label // --------------------------------------------------------------------------- @@ -153,6 +266,7 @@ interface EditableBoundProps { } function EditableBound({ value, label, onChange }: EditableBoundProps) { + const t = useTranslations('plots.sliders'); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(''); const inputRef = useRef(null); @@ -206,8 +320,8 @@ function EditableBound({ value, label, onChange }: EditableBoundProps) { + + {/* Mode + speed controls — only while animating */} + {anim && ( + <> + + + + )} + {/* Min bound (click to edit) */} - + {/* The range slider */}
@@ -296,7 +491,12 @@ function SliderRow({ name, config, onValueChange, onMinChange, onMaxChange }: Sl step={step} value={clampedValue} onChange={handleChange} - aria-label={`Parameter ${name}, current value ${formatValue(clampedValue)}, range ${min} to ${max}`} + aria-label={t('sliderAria', { + name, + value: formatValue(clampedValue), + min, + max, + })} aria-valuemin={min} aria-valuemax={max} aria-valuenow={clampedValue} @@ -327,7 +527,7 @@ function SliderRow({ name, config, onValueChange, onMinChange, onMaxChange }: Sl
{/* Max bound (click to edit) */} - + {/* Current value readout */} {formatValue(clampedValue)} @@ -368,6 +568,8 @@ function SliderRow({ name, config, onValueChange, onMinChange, onMaxChange }: Sl * ``` */ export function VariableSliders({ expressions, onChange, className = '' }: VariableSlidersProps) { + const t = useTranslations('plots.sliders'); + // Detect free parameter names from all expressions const paramNames = useMemo(() => detectParameters(expressions), [expressions]); @@ -380,9 +582,23 @@ export function VariableSliders({ expressions, onChange, className = '' }: Varia return init; }); + // Per-slider animation state — local UI state, NOT part of the + // SliderConfig/onChange contract. Entry present == slider is playing. + const [anims, setAnims] = useState>({}); + // Collapsed state for the panel const [collapsed, setCollapsed] = useState(false); + const reducedMotion = useReducedMotion(); + + // Respect prefers-reduced-motion: nothing here ever auto-plays, and if the + // user enables reduce-motion while sliders are animating, pause them all. + useEffect(() => { + if (reducedMotion) { + setAnims({}); + } + }, [reducedMotion]); + // When detected parameter list changes: // - Add entries for newly appeared parameters // - Remove entries for parameters that vanished @@ -435,6 +651,105 @@ export function VariableSliders({ expressions, onChange, className = '' }: Varia }); }, []); + // ---- Animation controls (React Compiler ON — plain handlers) ---- + + const togglePlay = (name: string) => { + if (anims[name]) { + setAnims((prev) => { + const { [name]: _removed, ...rest } = prev; + return rest; + }); + return; + } + // A finished 'once' run restarts from min for an obvious visual cue + const cfg = sliders[name]; + if (cfg && cfg.value >= cfg.max) { + handleValueChange(name, cfg.min); + } + setAnims((prev) => ({ ...prev, [name]: { mode: 'loop', speed: 1, direction: 1 } })); + }; + + const cycleMode = (name: string) => { + setAnims((prev) => { + const anim = prev[name]; + if (!anim) return prev; + return { ...prev, [name]: { ...anim, mode: nextMode(anim.mode), direction: 1 } }; + }); + }; + + const cycleSpeed = (name: string) => { + setAnims((prev) => { + const anim = prev[name]; + if (!anim) return prev; + return { ...prev, [name]: { ...anim, speed: nextSpeed(anim.speed) } }; + }); + }; + + // ---- Single component-level rAF driver ---- + // + // The tick is a React 19.3 Effect Event: it reads the LATEST sliders/anims + // without being reactive, so the driver effect below only re-subscribes + // when animation starts/stops — never per frame or per value change. + const tick = useEffectEvent((dt: number) => { + for (const [name, anim] of Object.entries(anims)) { + const cfg = sliders[name]; + if (!cfg) { + // Parameter vanished (expression edited) — drop its animation + setAnims((prev) => { + const { [name]: _removed, ...rest } = prev; + return rest; + }); + continue; + } + if (cfg.max - cfg.min <= 0) continue; + + const result = stepSliderValue({ + value: cfg.value, + min: cfg.min, + max: cfg.max, + mode: anim.mode, + speed: anim.speed, + direction: anim.direction, + dt, + }); + + if (result.direction !== anim.direction) { + setAnims((prev) => { + const a = prev[name]; + return a ? { ...prev, [name]: { ...a, direction: result.direction } } : prev; + }); + } + + if (result.finished) { + // 'once' sweep completed — clear the animation entry + setAnims((prev) => { + const { [name]: _removed, ...rest } = prev; + return rest; + }); + } + + // Route through the existing value path so onChange keeps firing + handleValueChange(name, result.value); + } + }); + + const hasAnims = Object.keys(anims).length > 0; + + useEffect(() => { + if (!hasAnims) return; + let rafId = 0; + let last = performance.now(); + const frame = (now: number) => { + // Clamp dt so a backgrounded tab doesn't teleport sliders on return + const dt = Math.min(now - last, 100); + last = now; + tick(dt); + rafId = requestAnimationFrame(frame); + }; + rafId = requestAnimationFrame(frame); + return () => cancelAnimationFrame(rafId); + }, [hasAnims]); + // Don't render anything when there are no free parameters if (paramNames.length === 0) return null; @@ -451,7 +766,7 @@ export function VariableSliders({ expressions, onChange, className = '' }: Varia className, ].join(' ')} role="region" - aria-label="Variable parameter sliders" + aria-label={t('panelLabel')} > {/* Subtle gradient overlay */} )} diff --git a/apps/web/components/worksheet/cell.tsx b/apps/web/components/worksheet/cell.tsx index 3fddafa1..bb4dbc83 100644 --- a/apps/web/components/worksheet/cell.tsx +++ b/apps/web/components/worksheet/cell.tsx @@ -34,6 +34,7 @@ import { Play, Trash2, TrendingUp, + Zap, } from 'lucide-react'; import { AnimatePresence, m } from 'motion/react'; import dynamic from 'next/dynamic'; @@ -63,6 +64,7 @@ import { type WorksheetCell as WorksheetCellType, } from '@/lib/stores/worksheet-store'; import { cn } from '@/lib/utils'; +import type { SimulationCellContentProps } from './simulation-cell'; // --------------------------------------------------------------------------- // Dynamic import for Plot2D to avoid SSR issues with WebGL @@ -80,6 +82,20 @@ const Plot2D = dynamic( }, ); +// Simulation cell content (GPU renderers) — client-only, loaded on demand +const SimulationCellContent = dynamic( + () => import('./simulation-cell').then((m) => ({ default: m.SimulationCellContent })), + { + ssr: false, + loading: () => ( +
+
+ ), + }, +); + // --------------------------------------------------------------------------- // Helpers: expression -> LaTeX conversion // --------------------------------------------------------------------------- @@ -119,6 +135,7 @@ const kindMeta = { math: { icon: Code2, label: 'Math', color: 'text-blue-400' }, text: { icon: AlignLeft, label: 'Text', color: 'text-emerald-400' }, plot: { icon: TrendingUp, label: 'Plot', color: 'text-purple-400' }, + simulation: { icon: Zap, label: 'Simulation', color: 'text-cyan-400' }, } as const satisfies Record< string, { icon: ComponentType<{ className?: string }>; label: string; color: string } @@ -995,6 +1012,7 @@ export const WorksheetCell = memo(function WorksheetCell({ )} {cell.kind === 'text' && } {cell.kind === 'plot' && } + {cell.kind === 'simulation' && }
); diff --git a/apps/web/components/worksheet/simulation-cell.test.tsx b/apps/web/components/worksheet/simulation-cell.test.tsx new file mode 100644 index 00000000..e1c0fb46 --- /dev/null +++ b/apps/web/components/worksheet/simulation-cell.test.tsx @@ -0,0 +1,216 @@ +/** + * Component tests for SimulationCellContent (GPU Lab worksheet cell). + * + * jsdom/happy-dom has no WebGPU/WebGL, so the three dynamically-imported GPU + * renderers are mocked with lightweight stand-ins that surface their props as + * data attributes/text — enough to assert param propagation without ever + * touching a real GPU context. `@/components/ui/slider` (Radix) is likewise + * swapped for a plain `` so slider interaction can be + * driven with a simple `fireEvent.change`. + */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SimulationCell } from '@/lib/stores/worksheet-store'; +import { useWorksheetStore } from '@/lib/stores/worksheet-store'; +import { SimulationCellContent } from './simulation-cell'; + +vi.mock('@nextcalc/plot-engine', () => ({ + detectBestBackend: vi.fn(async () => 'webgpu'), +})); + +vi.mock('@/components/plots/webgpu-heatmap', () => ({ + WebGPUHeatmap: (props: { + equationType: string; + solverAlpha: number; + gpuSolverEnabled: boolean; + }) => ( +
+ ), +})); + +vi.mock('@/components/chaos/lorenz-3d-renderer', () => ({ + Lorenz3DRenderer: (props: { data: unknown[] }) => ( +
+ ), +})); + +vi.mock('@/app/[locale]/solver/ode/GpuDirectionField', () => ({ + GpuDirectionField: (props: { equationType: string; params: number[] }) => ( +
+ ), +})); + +vi.mock('@/components/ui/slider', () => ({ + Slider: ({ + value, + min, + max, + step, + onValueChange, + 'aria-label': ariaLabel, + }: { + value: number[]; + min: number; + max: number; + step: number; + onValueChange: (values: number[]) => void; + 'aria-label'?: string; + }) => ( + onValueChange([Number(e.target.value)])} + /> + ), +})); + +function getStore() { + return useWorksheetStore.getState(); +} + +/** Add a fresh simulation cell to the real store and return its current data. */ +function addSimulationCell(): SimulationCell { + const id = getStore().addCell('simulation'); + const cell = getStore().worksheet.cells.find((c) => c.id === id); + if (cell?.kind !== 'simulation') throw new Error('expected a simulation cell'); + return cell; +} + +/** Re-read the (possibly updated) cell from the store by id. */ +function readCell(id: string): SimulationCell { + const cell = getStore().worksheet.cells.find((c) => c.id === id); + if (cell?.kind !== 'simulation') throw new Error('expected a simulation cell'); + return cell; +} + +describe('SimulationCellContent', () => { + beforeEach(() => { + getStore().resetWorksheet(); + }); + + afterEach(() => { + getStore().resetWorksheet(); + }); + + it('renders one slider per param spec declared in SIM_REGISTRY for the cell kind', async () => { + const cell = addSimulationCell(); // default kind: pde-heat, 1 param (alpha) + render(); + + await waitFor(() => { + expect(screen.getByRole('slider', { name: 'params.alpha' })).toBeInTheDocument(); + }); + expect(screen.getAllByRole('slider')).toHaveLength(1); + }); + + it('renders one slider per lorenz param (sigma, rho, beta, steps)', async () => { + const cell = addSimulationCell(); + getStore().updateSimulation(cell.id, { sim: 'lorenz' }); + const lorenzCell = readCell(cell.id); + + render(); + + await waitFor(() => { + expect(screen.getAllByRole('slider')).toHaveLength(4); + }); + expect(screen.getByRole('slider', { name: 'params.sigma' })).toBeInTheDocument(); + expect(screen.getByRole('slider', { name: 'params.rho' })).toBeInTheDocument(); + expect(screen.getByRole('slider', { name: 'params.beta' })).toBeInTheDocument(); + expect(screen.getByRole('slider', { name: 'params.steps' })).toBeInTheDocument(); + }); + + it('propagates a slider change into the worksheet store and the mounted renderer', async () => { + const cell = addSimulationCell(); // pde-heat / alpha, default 0.1 + const { rerender } = render(); + + const slider = await screen.findByRole('slider', { name: 'params.alpha' }); + fireEvent.change(slider, { target: { value: '0.2' } }); + + // Store was updated with the new param value + await waitFor(() => { + expect(readCell(cell.id).params['alpha']).toBeCloseTo(0.2, 5); + }); + + // Re-render with the fresh cell (mirrors how the real worksheet subscribes + // to the store and passes an updated cell prop down) and confirm the + // mounted renderer receives the propagated value. + rerender(); + await waitFor(() => { + const heatmap = screen.getByTestId('webgpu-heatmap'); + expect(heatmap.getAttribute('data-solver-alpha')).toBe('0.2'); + }); + }); + + it('resets params to the new preset defaults when switching a direction-field preset', async () => { + // Mirrors handlePresetChange in simulation-cell.tsx: preset + params are + // patched atomically in one updateSimulation call so a preset switch + // never leaves stale params from the previous preset's param0/param1. + const cell = addSimulationCell(); + getStore().updateSimulation(cell.id, { sim: 'direction-field' }); // -> van-der-pol, param0=1 + expect(readCell(cell.id).params['param0']).toBeCloseTo(1, 5); + + getStore().updateSimulation(cell.id, { + preset: 'pendulum', + params: { param0: 0.1 }, // DEFAULT_PARAMS('direction-field', 'pendulum') + }); + const afterPendulum = readCell(cell.id); + expect(afterPendulum.preset).toBe('pendulum'); + expect(afterPendulum.params['param0']).toBeCloseTo(0.1, 5); + + getStore().updateSimulation(cell.id, { + preset: 'van-der-pol', + params: { param0: 1 }, // DEFAULT_PARAMS('direction-field', 'van-der-pol') + }); + const afterVdp = readCell(cell.id); + expect(afterVdp.preset).toBe('van-der-pol'); + expect(afterVdp.params['param0']).toBeCloseTo(1, 5); + }); + + it('does not render a preset select when the kind has only one preset (lorenz)', async () => { + const cell = addSimulationCell(); + getStore().updateSimulation(cell.id, { sim: 'lorenz' }); + const lorenzCell = readCell(cell.id); + + render(); + + await waitFor(() => { + expect(screen.getAllByRole('slider')).toHaveLength(4); + }); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + + it('renders a graceful fallback for an unrecognized sim kind instead of crashing', async () => { + const cell = addSimulationCell(); + const corrupted = { + ...cell, + // Simulate DB/JSON data written by a future NextCalc version + sim: 'quantum-foam' as unknown as SimulationCell['sim'], + } satisfies SimulationCell; + + render(); + + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('unknownKind'); + expect(alert).toHaveTextContent('quantum-foam'); + // No sliders / renderer mounted for unknown kinds + expect(screen.queryByRole('slider')).not.toBeInTheDocument(); + expect(screen.queryByTestId('webgpu-heatmap')).not.toBeInTheDocument(); + + // Let the (mocked, async) backend-detection effect settle before the test + // exits, so its setState doesn't land after unmount/cleanup. + await waitFor(() => {}); + }); +}); diff --git a/apps/web/components/worksheet/simulation-cell.tsx b/apps/web/components/worksheet/simulation-cell.tsx new file mode 100644 index 00000000..5807fb20 --- /dev/null +++ b/apps/web/components/worksheet/simulation-cell.tsx @@ -0,0 +1,482 @@ +'use client'; + +/** + * SimulationCellContent — GPU Lab worksheet cell. + * + * Mounts the existing GPU renderers as slider-driven, shareable worksheet + * cells: + * - pde-heat / pde-wave / pde-laplace → WebGPUHeatmap (internal WebGPU + * compute solver, automatic WebGL fallback), fed by the shared + * initial-condition preset library + * - lorenz → Lorenz3DRenderer (three/webgpu + TSL), fed by + * LorenzAttractor.simulate with useDeferredValue on params so slider + * drags stay responsive + * - direction-field → GpuDirectionField with the new `params` prop binding + * worksheet sliders to the WGSL uniform buffer + * + * Serialization invariant: the cell persists ONLY `{ sim, preset, params }` + * (plain strings/numbers) through the worksheet store → autosave → share + * pipeline. Runtime state (running, backend, generated grids/trajectories, + * GPU buffers) is component-local and never serialized. + * + * GIF/clip export — deliberately deferred follow-up (Wave 1 decision): + * The chosen design is a zero-dependency, fully client-side recorder: + * `canvas.captureStream(30)` on the renderer canvas piped into a + * `MediaRecorder` with `video/webm` (VP9 where supported), producing a + * downloadable WebM clip with NO export-service round-trip. It is deferred + * because the record/trim/download UI plus cross-renderer canvas plumbing + * (three's canvas vs raw WebGPU canvas vs 2D overlay composition) is its own + * UI/QA surface, and the alternative — export-service (Cloudflare Worker) + * frame-upload GIF encoding — is disproportionately heavy for Wave 1 + * (modern-pdf-lib WASM in workerd is already known-blocked upstream). + */ + +import { LorenzAttractor } from '@nextcalc/math-engine/chaos'; +import { detectBestBackend, type RenderBackend } from '@nextcalc/plot-engine'; +import { AlertCircle, Info, Loader2, Pause, Play, RotateCcw, X, Zap } from 'lucide-react'; +import dynamic from 'next/dynamic'; +import { useTranslations } from 'next-intl'; +import { useDeferredValue, useEffect, useState } from 'react'; +import type { PdeEquationType } from '@/components/plots/webgpu-heatmap'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Slider } from '@/components/ui/slider'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + generateInitialCondition, + isInitialConditionType, +} from '@/lib/simulation/initial-conditions'; +import { + DEFAULT_PARAMS, + DIRECTION_FIELD_PRESETS, + getSimParams, + isSimulationKind, + SIM_REGISTRY, + SIMULATION_KINDS, + type SimulationKind, + sanitizeSimParams, +} from '@/lib/simulation/registry'; +import { type SimulationCell, useWorksheetActions } from '@/lib/stores/worksheet-store'; +import { cn } from '@/lib/utils'; + +// --------------------------------------------------------------------------- +// Dynamic renderer imports — keep GPU code out of the worksheet bundle until +// a simulation cell actually mounts. All three are client-only (ssr: false). +// --------------------------------------------------------------------------- + +function RendererLoading() { + return ( +
+
+ ); +} + +const WebGPUHeatmap = dynamic( + () => import('@/components/plots/webgpu-heatmap').then((m) => ({ default: m.WebGPUHeatmap })), + { ssr: false, loading: () => }, +); + +const Lorenz3DRenderer = dynamic( + () => + import('@/components/chaos/lorenz-3d-renderer').then((m) => ({ + default: m.Lorenz3DRenderer, + })), + { ssr: false, loading: () => }, +); + +const GpuDirectionField = dynamic( + () => + import('@/app/[locale]/solver/ode/GpuDirectionField').then((m) => ({ + default: m.GpuDirectionField, + })), + { ssr: false, loading: () => }, +); + +// --------------------------------------------------------------------------- +// Constants + pure helpers +// --------------------------------------------------------------------------- + +/** Grid resolution for worksheet PDE sims — small enough for many cells. */ +const SIM_GRID_SIZE = 96; + +/** Direction-field canvas size (matches the h-[340px] mount area). */ +const FIELD_SIZE = 340; + +const PDE_EQUATION = { + 'pde-heat': 'heat', + 'pde-wave': 'wave', + 'pde-laplace': 'laplace', +} as const satisfies Partial>; + +type PdeSimKind = keyof typeof PDE_EQUATION; + +function isPdeSim(sim: SimulationKind): sim is PdeSimKind { + return sim === 'pde-heat' || sim === 'pde-wave' || sim === 'pde-laplace'; +} + +/** Point shape consumed by Lorenz3DRenderer (structural match). */ +interface LorenzPoint { + x: number; + y: number; + z: number; +} + +/** + * Compute a Lorenz trajectory from serialized cell params. + * Pure CPU compute via math-engine — the renderer receives plain data. + */ +function computeLorenzTrajectory(params: Readonly>): LorenzPoint[] { + const lorenz = new LorenzAttractor( + params['sigma'] ?? 10, + params['rho'] ?? 28, + params['beta'] ?? 8 / 3, + ); + const steps = Math.round(params['steps'] ?? 2000); + return [...lorenz.simulate(steps, 0.01, { x: 1, y: 1, z: 1 })]; +} + +/** Generate the (static) initial-condition grid for a PDE sim cell. */ +function computeIcGrid(sim: SimulationKind, preset: string): number[][] { + if (!isPdeSim(sim)) return []; + const ic = isInitialConditionType(preset) ? preset : 'center'; + return generateInitialCondition(ic, SIM_GRID_SIZE); +} + +/** Value range for the heatmap color scale. `sinc` and wave go negative. */ +function gridValueRange(grid: number[][]): { min: number; max: number } { + let min = 0; + let max = 100; + for (const row of grid) { + for (const v of row) { + if (v < min) min = v; + if (v > max) max = v; + } + } + return { min, max }; +} + +function formatParamValue(v: number): string { + if (!Number.isFinite(v)) return String(v); + return parseFloat(v.toPrecision(4)).toString(); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export interface SimulationCellContentProps { + cell: SimulationCell; + cellIndex: number; +} + +export function SimulationCellContent({ cell, cellIndex }: SimulationCellContentProps) { + const t = useTranslations('worksheet.simulation'); + const { updateSimulation } = useWorksheetActions(); + + // Runtime state — intentionally component-local, NEVER serialized. + const [running, setRunning] = useState(false); + const [dataVersion, setDataVersion] = useState(0); + const [backend, setBackend] = useState(null); + const [bannerDismissed, setBannerDismissed] = useState(false); + + // ---- Capability probe (single WebGPU-first check via plot-engine) ---- + useEffect(() => { + let cancelled = false; + detectBestBackend().then((b) => { + if (!cancelled) setBackend(b); + }); + return () => { + cancelled = true; + }; + }, []); + + // ---- Initial-condition grid, regenerated when sim/preset/reset changes ---- + // "Adjust state during render" idiom: derive the grid from a key so preset + // switches, sim switches, external hydration, and Reset (dataVersion bump) + // all regenerate it without effects. + const icKey = `${cell.sim}:${cell.preset}:${dataVersion}`; + const [icState, setIcState] = useState(() => ({ + key: icKey, + grid: computeIcGrid(cell.sim, cell.preset), + })); + if (icState.key !== icKey) { + setIcState({ key: icKey, grid: computeIcGrid(cell.sim, cell.preset) }); + } + + // ---- Lorenz trajectory (deferred so slider drags stay responsive) ---- + // Stored params are clamped to their spec bounds before any compute: + // worksheet content is untrusted (forked gallery worksheets are + // author-controlled JSON), and e.g. a crafted `steps` value would + // otherwise drive an arbitrarily long CPU trajectory loop. + const deferredParams = useDeferredValue(cell.params); + const lorenzData = + cell.sim === 'lorenz' + ? computeLorenzTrajectory(sanitizeSimParams('lorenz', cell.preset, deferredParams)) + : null; + + // ---- Handlers (React Compiler ON — no manual memoization) ---- + + const handleSimChange = (value: string) => { + if (!isSimulationKind(value) || value === cell.sim) return; + setRunning(false); + setDataVersion(0); + updateSimulation(cell.id, { sim: value }); + }; + + const handlePresetChange = (preset: string) => { + setRunning(false); + updateSimulation(cell.id, { + preset, + // Direction-field presets carry their own param specs — atomically + // reset params to the new preset's defaults in the same patch. + ...(cell.sim === 'direction-field' ? { params: DEFAULT_PARAMS(cell.sim, preset) } : {}), + }); + }; + + const handleParamChange = (key: string, value: number) => { + updateSimulation(cell.id, { params: { ...cell.params, [key]: value } }); + }; + + const handleReset = () => { + setRunning(false); + setDataVersion((v) => v + 1); + }; + + const cellLabel = `${t('label')} ${cellIndex + 1}`; + + // ---- Unknown sim kind (forward compatibility with future cell data) ---- + if (!isSimulationKind(cell.sim)) { + return ( +
+
+ ); + } + + const registry = SIM_REGISTRY[cell.sim]; + const paramSpecs = getSimParams(cell.sim, cell.preset); + const safeParams = sanitizeSimParams(cell.sim, cell.preset, cell.params); + const presets = registry.presets; + const noGpuForLorenz = cell.sim === 'lorenz' && backend === 'canvas2d'; + const dfPreset = + cell.sim === 'direction-field' + ? (DIRECTION_FIELD_PRESETS[cell.preset] ?? DIRECTION_FIELD_PRESETS['van-der-pol']) + : undefined; + const showPlayPause = isPdeSim(cell.sim) || cell.sim === 'direction-field'; + const showReset = isPdeSim(cell.sim); + const valueRange = isPdeSim(cell.sim) ? gridValueRange(icState.grid) : null; + + return ( +
+ {/* (1) Sim-kind segmented control */} + + + {SIMULATION_KINDS.map((kind) => ( + + {t(SIM_REGISTRY[kind].labelKey)} + + ))} + + + +
+ {/* (2) Preset select */} + {presets.length > 1 && ( + + )} + + {/* (4) Play/Pause + Reset — `running` is runtime-only, never saved */} + {showPlayPause && ( + + )} + {showReset && ( + + )} +
+ + {/* (3) Parameter sliders — these ARE the worksheet-bound parameters */} +
+ {t('parameters')} + {paramSpecs.map((spec) => { + const value = safeParams[spec.key] ?? spec.default; + return ( +
+ + { + const next = values[0]; + if (next !== undefined) handleParamChange(spec.key, next); + }} + aria-label={t(`params.${spec.labelKey}`)} + className="flex-1" + /> + +
+ ); + })} +
+ + {/* WebGL2 fallback info banner (dismissible) */} + {backend === 'webgl2' && !bannerDismissed && !noGpuForLorenz && ( +
+
+ )} + + {/* (5) Renderer mount area */} +
+ {isPdeSim(cell.sim) && valueRange && ( +
+ +
+ )} + + {cell.sim === 'lorenz' && + (noGpuForLorenz ? ( +
+
+ ) : lorenzData ? ( + + ) : ( + + ))} + + {cell.sim === 'direction-field' && + dfPreset && + (running ? ( +
+ dfPreset.f(x, y, safeParams)} + g={(x, y) => dfPreset.g(x, y, safeParams)} + equationType={dfPreset.equationType} + params={[ + safeParams['param0'] ?? dfPreset.params[0]?.default ?? 0, + safeParams['param1'] ?? dfPreset.params[1]?.default ?? 0, + ]} + gridN={48} + opacity={0.85} + visible + /> +
+ ) : ( + + ))} +
+
+ ); +} diff --git a/apps/web/components/worksheet/worksheet-editor.tsx b/apps/web/components/worksheet/worksheet-editor.tsx index 7568e233..04184929 100644 --- a/apps/web/components/worksheet/worksheet-editor.tsx +++ b/apps/web/components/worksheet/worksheet-editor.tsx @@ -26,15 +26,29 @@ import { ChevronRight, Code2, Download, + Globe, Plus, RotateCcw, Save, TrendingUp, Upload, Variable, + Zap, } from 'lucide-react'; import { AnimatePresence, m } from 'motion/react'; -import { type ChangeEvent, type ReactNode, useCallback, useId, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { + type ChangeEvent, + type ReactNode, + useActionState, + useCallback, + useEffect, + useId, + useRef, + useState, +} from 'react'; +import type { ActionResult } from '@/app/actions/problems'; +import { type SetWorksheetVisibilityResult, setWorksheetVisibility } from '@/app/actions/worksheet'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { @@ -42,13 +56,74 @@ import { type MathCell, useWorksheetActions, useWorksheetCells, + useWorksheetId, useWorksheetTitle, + useWorksheetVisibility, type WorksheetCell, } from '@/lib/stores/worksheet-store'; import { cn } from '@/lib/utils'; import { WorksheetCell as CellComponent } from './cell'; import { CollabBar } from './collab-bar'; +// --------------------------------------------------------------------------- +// Publish-to-gallery toggle (GPU Lab) +// --------------------------------------------------------------------------- + +const initialVisibilityState: ActionResult = { success: false }; + +/** + * Publishes the current worksheet to the public GPU Lab gallery (visibility + * PUBLIC) or takes it private again. Disabled until the worksheet has been + * autosaved to the database (worksheetId present — requires sign-in). + */ +function PublishButton() { + const t = useTranslations('worksheet'); + const worksheetId = useWorksheetId(); + const visibility = useWorksheetVisibility(); + const { setVisibility } = useWorksheetActions(); + const [state, formAction, isPending] = useActionState( + setWorksheetVisibility, + initialVisibilityState, + ); + + // Mirror the server-confirmed visibility into the store + useEffect(() => { + if (state.success && state.data) { + setVisibility(state.data.visibility); + } + }, [state, setVisibility]); + + const isPublished = visibility === 'PUBLIC'; + const disabled = worksheetId === null || isPending; + + const handleToggle = () => { + if (!worksheetId) return; + const fd = new FormData(); + fd.set('worksheetId', worksheetId); + fd.set('visibility', isPublished ? 'PRIVATE' : 'PUBLIC'); + formAction(fd); + }; + + return ( + + ); +} + // --------------------------------------------------------------------------- // Toolbar — title + global actions // --------------------------------------------------------------------------- @@ -149,6 +224,8 @@ function Toolbar({ titleInputId }: ToolbarProps) { Export + +