From b93b657af05d951dbcf36179ba5d79bc3498dc04 Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:08:05 -0500 Subject: [PATCH 01/11] refactor(web): extract PDE initial conditions into lib/simulation Move the 11-preset InitialConditionType union and grid generator out of the PDE studio page into a pure shared module so the worksheet simulation cell can reuse the exact same preset library. No behavior change; the relocated numeric hot-path keeps its scoped biome noNonNullAssertion override (decision-#21a pattern). Co-Authored-By: Claude Fable 5 --- apps/web/app/[locale]/pde/page.tsx | 223 +---------------- apps/web/lib/simulation/initial-conditions.ts | 235 ++++++++++++++++++ biome.json | 1 + 3 files changed, 242 insertions(+), 217 deletions(-) create mode 100644 apps/web/lib/simulation/initial-conditions.ts 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/lib/simulation/initial-conditions.ts b/apps/web/lib/simulation/initial-conditions.ts new file mode 100644 index 00000000..57913983 --- /dev/null +++ b/apps/web/lib/simulation/initial-conditions.ts @@ -0,0 +1,235 @@ +/** + * Initial-condition generators for 2D PDE simulations. + * + * Extracted from app/[locale]/pde/page.tsx so both the PDE studio page and + * the worksheet simulation cell can share the exact same preset library. + * Pure functions — no 'use client' needed, safe to import from RSC and + * client components alike. + * + * @module lib/simulation/initial-conditions + */ + +/** The 11 built-in initial-condition presets. */ +export const INITIAL_CONDITION_TYPES = [ + 'center', + 'line', + 'corners', + 'ring', + 'cross', + 'gaussian', + 'random', + 'doubleGaussian', + 'sawtooth', + 'squarePulse', + 'sinc', +] as const; + +export type InitialConditionType = (typeof INITIAL_CONDITION_TYPES)[number]; + +/** Runtime guard for deserialized preset strings (worksheet cells from DB/JSON). */ +export function isInitialConditionType(value: string): value is InitialConditionType { + return (INITIAL_CONDITION_TYPES as readonly string[]).includes(value); +} + +/** + * Generate the initial temperature/displacement grid for a preset. + * + * Values are (mostly) in [0, 100]; `sinc` intentionally goes negative for + * wave-equation interest. The grid is row-major `gridSize × gridSize`. + */ +export function generateInitialCondition(type: InitialConditionType, gridSize: number): 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; +} diff --git a/biome.json b/biome.json index 3bd504a3..b6971033 100644 --- a/biome.json +++ b/biome.json @@ -112,6 +112,7 @@ "apps/web/components/calculator/eigen-panel.tsx", "apps/web/components/calculator/unit-converter.tsx", "apps/web/components/profile/level-utils.ts", + "apps/web/lib/simulation/initial-conditions.ts", "apps/web/app/**/pde/**", "apps/web/app/**/graphs-full/**", "apps/web/app/**/solver/**", From 8da8a1aeaaebd6d6425337a35aab9a275a8e8a64 Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:08:05 -0500 Subject: [PATCH 02/11] feat(web): simulation cell kind in worksheet store + SIM_REGISTRY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SimulationCell { sim, preset, params } — serializable presets/params ONLY (plain numbers/strings, never GPU buffers); runtime state stays component-local - SIM_REGISTRY param specs/presets for pde-heat/wave/laplace, lorenz, and direction-field (per-preset param specs + CPU f/g mirroring the WGSL evalSystem cases) - updateSimulation action (kind switch resets preset/params to registry defaults), importFromJSON accepts 'simulation' - hydrate({ fork }) detaches non-owner loads (worksheetId null, version 0) so edits create a new worksheet instead of 409-spamming - visibility metadata in store for the publish toggle Co-Authored-By: Claude Fable 5 --- apps/web/lib/simulation/registry.ts | 266 +++++++++++++++++++++++++ apps/web/lib/stores/worksheet-store.ts | 110 +++++++++- 2 files changed, 370 insertions(+), 6 deletions(-) create mode 100644 apps/web/lib/simulation/registry.ts diff --git a/apps/web/lib/simulation/registry.ts b/apps/web/lib/simulation/registry.ts new file mode 100644 index 00000000..6c1323aa --- /dev/null +++ b/apps/web/lib/simulation/registry.ts @@ -0,0 +1,266 @@ +/** + * Simulation Registry — the single source of truth for the worksheet + * "simulation" cell kind. + * + * Describes, for each simulation kind: + * - which parameter sliders to render (key, symbol, range, step, default) + * - which presets are available + * - i18n label keys (relative to the `worksheet.simulation` namespace) + * + * Everything in here is plain serializable data (numbers/strings) plus pure + * CPU-fallback functions for the direction field — NO GPU state. Worksheet + * cells persist only `{ sim, preset, params }` from this registry's domain. + * + * @module lib/simulation/registry + */ + +import type { FieldEquationType } from '@/app/[locale]/solver/ode/GpuDirectionField'; +import { INITIAL_CONDITION_TYPES } from './initial-conditions'; + +// --------------------------------------------------------------------------- +// Kinds +// --------------------------------------------------------------------------- + +export const SIMULATION_KINDS = [ + 'pde-heat', + 'pde-wave', + 'pde-laplace', + 'lorenz', + 'direction-field', +] as const; + +export type SimulationKind = (typeof SIMULATION_KINDS)[number]; + +/** Runtime guard for deserialized cell data (DB/JSON may carry unknown kinds). */ +export function isSimulationKind(value: string): value is SimulationKind { + return (SIMULATION_KINDS as readonly string[]).includes(value); +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Spec for a single parameter slider row. */ +export interface SimParamSpec { + /** Key inside SimulationCell.params */ + readonly key: string; + /** Short mathematical symbol shown in the row badge (language-neutral) */ + readonly symbol: string; + readonly min: number; + readonly max: number; + readonly step: number; + readonly default: number; + /** i18n key under `worksheet.simulation.params.*` */ + readonly labelKey: string; +} + +export interface SimRegistryEntry { + /** i18n key under `worksheet.simulation.*` */ + readonly labelKey: string; + readonly params: readonly SimParamSpec[]; + readonly presets: readonly string[]; +} + +/** Per-preset configuration for the direction-field simulation. */ +export interface DirectionFieldPreset { + readonly equationType: FieldEquationType; + readonly params: readonly SimParamSpec[]; + /** Square domain bounds for the field */ + readonly xMin: number; + readonly xMax: number; + readonly yMin: number; + readonly yMax: number; + /** CPU fallbacks mirroring the WGSL evalSystem cases in GpuDirectionField */ + readonly f: (x: number, y: number, params: Readonly>) => number; + readonly g: (x: number, y: number, params: Readonly>) => number; +} + +// --------------------------------------------------------------------------- +// Direction-field presets (mirror GpuDirectionField.tsx WGSL evalSystem) +// --------------------------------------------------------------------------- + +export const DIRECTION_FIELD_PRESETS: Record = { + 'lotka-volterra': { + equationType: 'lotka-volterra', + params: [ + { + key: 'param0', + symbol: 'a', + min: 0.1, + max: 3, + step: 0.05, + default: 1.5, + labelKey: 'preyGrowth', + }, + { + key: 'param1', + symbol: 'b', + min: 0.1, + max: 3, + step: 0.05, + default: 1.0, + labelKey: 'predatorDeath', + }, + ], + xMin: 0, + xMax: 4, + yMin: 0, + yMax: 4, + // dx/dt = a*x - x*y, dy/dt = x*y - b*y + f: (x, y, p) => (p['param0'] ?? 1.5) * x - x * y, + g: (x, y, p) => x * y - (p['param1'] ?? 1.0) * y, + }, + 'van-der-pol': { + equationType: 'van-der-pol', + params: [ + { key: 'param0', symbol: 'μ', min: 0.1, max: 4, step: 0.05, default: 1, labelKey: 'mu' }, + ], + xMin: -4, + xMax: 4, + yMin: -4, + yMax: 4, + // dx/dt = y, dy/dt = mu*(1 - x^2)*y - x + f: (_x, y) => y, + g: (x, y, p) => (p['param0'] ?? 1) * (1 - x * x) * y - x, + }, + 'stable-spiral': { + equationType: 'stable-spiral', + params: [ + { + key: 'param0', + symbol: 'α', + min: 0.01, + max: 1, + step: 0.01, + default: 0.1, + labelKey: 'spiralDamping', + }, + ], + xMin: -3, + xMax: 3, + yMin: -3, + yMax: 3, + // dx/dt = -alpha*x - y, dy/dt = x - alpha*y + f: (x, y, p) => -(p['param0'] ?? 0.1) * x - y, + g: (x, y, p) => x - (p['param0'] ?? 0.1) * y, + }, + pendulum: { + equationType: 'pendulum', + params: [ + { key: 'param0', symbol: 'γ', min: 0, max: 1, step: 0.01, default: 0.1, labelKey: 'damping' }, + ], + xMin: -6, + xMax: 6, + yMin: -4, + yMax: 4, + // dx/dt = y, dy/dt = -sin(x) - gamma*y + f: (_x, y) => y, + g: (x, y, p) => -Math.sin(x) - (p['param0'] ?? 0.1) * y, + }, +}; + +const DIRECTION_FIELD_PRESET_NAMES = Object.keys(DIRECTION_FIELD_PRESETS); +const DEFAULT_DIRECTION_FIELD_PRESET = 'van-der-pol'; + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +export const SIM_REGISTRY: Record = { + 'pde-heat': { + labelKey: 'heat', + params: [ + { + key: 'alpha', + symbol: 'α', + min: 0.01, + max: 0.25, + step: 0.005, + default: 0.1, + labelKey: 'alpha', + }, + ], + presets: INITIAL_CONDITION_TYPES, + }, + 'pde-wave': { + labelKey: 'wave', + params: [ + { key: 'c', symbol: 'c', min: 0.1, max: 2, step: 0.05, default: 1, labelKey: 'waveSpeed' }, + ], + presets: INITIAL_CONDITION_TYPES, + }, + 'pde-laplace': { + labelKey: 'laplace', + params: [ + { + key: 'alpha', + symbol: 'α', + min: 0.05, + max: 0.25, + step: 0.005, + default: 0.1, + labelKey: 'relaxation', + }, + ], + // Laplace relaxation is most interesting from localized boundary-ish sources + presets: ['center', 'ring', 'corners', 'cross', 'random'], + }, + lorenz: { + labelKey: 'lorenz', + params: [ + { key: 'sigma', symbol: 'σ', min: 0, max: 30, step: 0.1, default: 10, labelKey: 'sigma' }, + { key: 'rho', symbol: 'ρ', min: 0, max: 60, step: 0.1, default: 28, labelKey: 'rho' }, + { key: 'beta', symbol: 'β', min: 0, max: 10, step: 0.01, default: 8 / 3, labelKey: 'beta' }, + { + key: 'steps', + symbol: 'n', + min: 500, + max: 5000, + step: 100, + default: 2000, + labelKey: 'steps', + }, + ], + presets: ['classic'], + }, + 'direction-field': { + labelKey: 'directionField', + // Default preset's params — use getSimParams(kind, preset) for the live set + params: DIRECTION_FIELD_PRESETS[DEFAULT_DIRECTION_FIELD_PRESET]?.params ?? [], + presets: DIRECTION_FIELD_PRESET_NAMES, + }, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * The parameter slider specs for a kind, taking the active preset into + * account (direction-field presets each carry their own param specs). + */ +export function getSimParams(kind: SimulationKind, preset: string): readonly SimParamSpec[] { + if (kind === 'direction-field') { + return DIRECTION_FIELD_PRESETS[preset]?.params ?? SIM_REGISTRY[kind].params; + } + return SIM_REGISTRY[kind].params; +} + +/** Default preset name for a kind. */ +export function DEFAULT_PRESET(kind: SimulationKind): string { + if (kind === 'direction-field') return DEFAULT_DIRECTION_FIELD_PRESET; + return SIM_REGISTRY[kind].presets[0] ?? ''; +} + +/** + * Default `params` map for a kind (and optional preset), used by + * `createSimulationCell()` and when switching sim kind/preset. + */ +export function DEFAULT_PARAMS(kind: SimulationKind, preset?: string): Record { + const specs = getSimParams(kind, preset ?? DEFAULT_PRESET(kind)); + const params: Record = {}; + for (const spec of specs) { + params[spec.key] = spec.default; + } + return params; +} diff --git a/apps/web/lib/stores/worksheet-store.ts b/apps/web/lib/stores/worksheet-store.ts index 1370a48b..ecb9125e 100644 --- a/apps/web/lib/stores/worksheet-store.ts +++ b/apps/web/lib/stores/worksheet-store.ts @@ -9,18 +9,24 @@ import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import { useShallow } from 'zustand/react/shallow'; +import { DEFAULT_PARAMS, DEFAULT_PRESET, type SimulationKind } from '@/lib/simulation/registry'; import { generateId } from '@/lib/utils'; +export type { SimulationKind }; + // --------------------------------------------------------------------------- // Domain types // --------------------------------------------------------------------------- /** Discriminated union for cell kind */ -export type CellKind = 'math' | 'text' | 'plot'; +export type CellKind = 'math' | 'text' | 'plot' | 'simulation'; /** Evaluation state for math/plot cells */ export type EvalStatus = 'idle' | 'pending' | 'success' | 'error'; +/** Gallery visibility (mirrors the Prisma WorksheetVisibility enum) */ +export type WorksheetVisibility = 'PRIVATE' | 'UNLISTED' | 'PUBLIC'; + /** A single variable binding produced by a math cell */ export interface VariableBinding { name: string; @@ -69,7 +75,25 @@ export interface PlotCell extends BaseCellFields { errorMessage: string | null; } -export type WorksheetCell = MathCell | TextCell | PlotCell; +/** + * GPU/CPU simulation cell (PDE heatmap, Lorenz attractor, direction field). + * + * Serialization invariant: ONLY plain presets/params (strings + numbers) are + * stored — never GPU buffers, trajectories, or runtime state. Whether the + * simulation is currently running (plus fps/backend) lives in component + * state inside SimulationCellContent and is intentionally NOT persisted. + */ +export interface SimulationCell extends BaseCellFields { + kind: 'simulation'; + /** Which simulation to mount (see lib/simulation/registry) */ + sim: SimulationKind; + /** Preset name within the sim kind (initial condition / equation preset) */ + preset: string; + /** Slider-bound numeric parameters (α, σ, ρ, β, μ, …) */ + params: Record; +} + +export type WorksheetCell = MathCell | TextCell | PlotCell | SimulationCell; /** Serialisable worksheet document (for JSON export / localStorage) */ export interface WorksheetDocument { @@ -101,6 +125,10 @@ interface WorksheetStore { id: string, viewport: { xMin: number; xMax: number; yMin: number; yMax: number }, ) => void; + updateSimulation: ( + id: string, + patch: { sim?: SimulationKind; preset?: string; params?: Record }, + ) => void; // Evaluation (called by components, not by the store itself) setMathResult: (id: string, result: string, latex: string, variables: VariableBinding[]) => void; @@ -116,11 +144,22 @@ interface WorksheetStore { worksheetId: string | null; version: number; isDirty: boolean; + /** Server-side gallery visibility of the loaded worksheet */ + visibility: WorksheetVisibility; + setVisibility: (visibility: WorksheetVisibility) => void; hydrate: (data: { worksheetId: string; title: string; cells: WorksheetCell[]; version: number; + visibility?: WorksheetVisibility; + /** + * Fork-on-open: when true (viewer is not the owner), the worksheet is + * hydrated as a brand-new local draft (worksheetId null, version 0) so + * the viewer's edits autosave as their OWN new worksheet instead of + * conflict-spamming updates against the owner's row. + */ + fork?: boolean; }) => void; markClean: (version: number, worksheetId: string) => void; @@ -180,9 +219,24 @@ function createPlotCell(): PlotCell { }; } +function createSimulationCell(): SimulationCell { + const now = Date.now(); + const sim: SimulationKind = 'pde-heat'; + return { + id: generateId(), + kind: 'simulation', + sim, + preset: DEFAULT_PRESET(sim), + params: DEFAULT_PARAMS(sim), + createdAt: now, + updatedAt: now, + }; +} + function makeCell(kind: CellKind): WorksheetCell { if (kind === 'text') return createTextCell(); if (kind === 'plot') return createPlotCell(); + if (kind === 'simulation') return createSimulationCell(); return createMathCell(); } @@ -214,6 +268,7 @@ export const useWorksheetStore = create()( worksheetId: null, version: 0, isDirty: false, + visibility: 'PRIVATE', // --------------------------------------------------------------- // Cell CRUD @@ -336,6 +391,29 @@ export const useWorksheetStore = create()( }); }, + updateSimulation: (id, patch) => { + set((draft) => { + const cell = draft.worksheet.cells.find((c) => c.id === id); + if (cell?.kind !== 'simulation') return; + if (patch.sim !== undefined && patch.sim !== cell.sim) { + // Switching sim kind resets preset + params to the new kind's + // registry defaults (old params are meaningless for the new sim). + cell.sim = patch.sim; + cell.preset = DEFAULT_PRESET(patch.sim); + cell.params = DEFAULT_PARAMS(patch.sim); + } + if (patch.preset !== undefined) { + cell.preset = patch.preset; + } + if (patch.params !== undefined) { + cell.params = patch.params; + } + cell.updatedAt = Date.now(); + touchWorksheet(draft.worksheet); + draft.isDirty = true; + }); + }, + // --------------------------------------------------------------- // Evaluation state (set by components after async evaluation) // --------------------------------------------------------------- @@ -402,6 +480,7 @@ export const useWorksheetStore = create()( draft.worksheetId = null; draft.version = 0; draft.isDirty = false; + draft.visibility = 'PRIVATE'; }); }, @@ -432,7 +511,7 @@ export const useWorksheetStore = create()( importFromJSON: (json) => { try { const parsed = JSON.parse(json) as WorksheetDocument; - const validKinds = new Set(['math', 'text', 'plot']); + const validKinds = new Set(['math', 'text', 'plot', 'simulation']); // Validate structure and cell kinds if ( typeof parsed.id === 'string' && @@ -460,19 +539,33 @@ export const useWorksheetStore = create()( // --------------------------------------------------------------- hydrate: (data) => { set((draft) => { + const fork = data.fork === true; draft.worksheet = { - id: data.worksheetId, + // A fork is a brand-new local document — give it its own id so + // it never aliases the source worksheet. + id: fork ? generateId() : data.worksheetId, title: data.title, cells: data.cells, createdAt: Date.now(), updatedAt: Date.now(), }; - draft.worksheetId = data.worksheetId; - draft.version = data.version; + // Forks detach from the source row: the next dirty autosave hits + // saveWorksheet's create branch (worksheetId null) and produces a + // new worksheet owned by the viewer. + draft.worksheetId = fork ? null : data.worksheetId; + draft.version = fork ? 0 : data.version; + // A fork is a fresh private draft; otherwise mirror the server row. + draft.visibility = fork ? 'PRIVATE' : (data.visibility ?? 'PRIVATE'); draft.isDirty = false; }); }, + setVisibility: (visibility) => { + set((draft) => { + draft.visibility = visibility; + }); + }, + markClean: (version, worksheetId) => { set((draft) => { draft.version = version; @@ -488,6 +581,7 @@ export const useWorksheetStore = create()( worksheet: store.worksheet, worksheetId: store.worksheetId, version: store.version, + visibility: store.visibility, }), }, ), @@ -522,6 +616,7 @@ export const useWorksheetActions = () => updateTextContent: s.updateTextContent, updatePlotExpressions: s.updatePlotExpressions, updatePlotViewport: s.updatePlotViewport, + updateSimulation: s.updateSimulation, setMathResult: s.setMathResult, setMathError: s.setMathError, setMathPending: s.setMathPending, @@ -533,11 +628,14 @@ export const useWorksheetActions = () => importFromJSON: s.importFromJSON, hydrate: s.hydrate, markClean: s.markClean, + setVisibility: s.setVisibility, })), ); export const useWorksheetId = () => useWorksheetStore((s) => s.worksheetId); +export const useWorksheetVisibility = () => useWorksheetStore((s) => s.visibility); + export const useWorksheetVersion = () => useWorksheetStore((s) => s.version); export const useWorksheetDirty = () => useWorksheetStore((s) => s.isDirty); From 62b4754243302540b738e9f4bebc1b5835f966e8 Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:08:05 -0500 Subject: [PATCH 03/11] feat(web): optional params prop on GpuDirectionField Additive readonly [number, number] override for the built-in eqParams defaults so worksheet sliders can bind Lotka-Volterra a/b, Van der Pol mu, spiral alpha, pendulum gamma. 32-byte uniform layout unchanged (f32 offsets 6/7); effect deps use the destructured numbers to avoid tuple identity churn. Existing ode page call sites unaffected. Co-Authored-By: Claude Fable 5 --- .../[locale]/solver/ode/GpuDirectionField.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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, From 9b12f9dd471651b5e134118cf5919de44be81925 Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:08:27 -0500 Subject: [PATCH 04/11] feat(web): simulation worksheet cell mounting the GPU renderers SimulationCellContent mounts the three EXISTING renderers as-is: - pde-* -> WebGPUHeatmap (internal WebGPU compute solver + WebGL fallback), IC grids from the shared preset library, gpuSolverEnabled bound to local play/pause state - lorenz -> Lorenz3DRenderer fed by LorenzAttractor.simulate through useDeferredValue(params) so slider drags stay responsive - direction-field -> GpuDirectionField with the new params prop; mounted only while playing so GPU resources init/dispose cleanly detectBestBackend() drives the WebGL2 fallback banner and the no-GPU message card (lorenz + canvas2d). Cell wired into cell.tsx kindMeta + body (dynamic import) and all three add-cell surfaces; toolbar gains the Publish-to-Gallery toggle (useActionState). No manual memoization (React Compiler); strings via worksheet.simulation i18n namespace. Documented the deferred GIF/clip export follow-up design (canvas.captureStream + MediaRecorder WebM, zero deps). Co-Authored-By: Claude Fable 5 --- apps/web/components/worksheet/cell.tsx | 18 + .../components/worksheet/simulation-cell.tsx | 473 ++++++++++++++++++ .../components/worksheet/worksheet-editor.tsx | 96 +++- 3 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 apps/web/components/worksheet/simulation-cell.tsx 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.tsx b/apps/web/components/worksheet/simulation-cell.tsx new file mode 100644 index 00000000..c38c6d78 --- /dev/null +++ b/apps/web/components/worksheet/simulation-cell.tsx @@ -0,0 +1,473 @@ +'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, +} 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) ---- + const deferredParams = useDeferredValue(cell.params); + const lorenzData = cell.sim === 'lorenz' ? computeLorenzTrajectory(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 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 = cell.params[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, cell.params)} + g={(x, y) => dfPreset.g(x, y, cell.params)} + equationType={dfPreset.equationType} + params={[ + cell.params['param0'] ?? dfPreset.params[0]?.default ?? 0, + cell.params['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 + + + + {/* Mode + speed controls — only while animating */} + {anim && ( + <> + + + + )} + {/* Min bound (click to edit) */} - + {/* The range slider */}
@@ -296,7 +423,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 +459,7 @@ function SliderRow({ name, config, onValueChange, onMinChange, onMaxChange }: Sl
{/* Max bound (click to edit) */} - + {/* Current value readout */} {formatValue(clampedValue)} @@ -368,6 +500,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 +514,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 +583,111 @@ 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; + } + const span = cfg.max - cfg.min; + if (span <= 0) continue; + + const delta = (span / SWEEP_DURATION_MS) * dt * anim.speed * anim.direction; + let value = cfg.value + delta; + + if (anim.mode === 'loop') { + if (value >= cfg.max) value = cfg.min; + } else if (anim.mode === 'bounce') { + if (value >= cfg.max) { + value = cfg.max; + setAnims((prev) => { + const a = prev[name]; + return a ? { ...prev, [name]: { ...a, direction: -1 } } : prev; + }); + } else if (value <= cfg.min) { + value = cfg.min; + setAnims((prev) => { + const a = prev[name]; + return a ? { ...prev, [name]: { ...a, direction: 1 } } : prev; + }); + } + } else { + // 'once' — stop exactly at max and clear the animation entry + if (value >= cfg.max) { + value = cfg.max; + setAnims((prev) => { + const { [name]: _removed, ...rest } = prev; + return rest; + }); + } + } + + // Route through the existing value path so onChange keeps firing + handleValueChange(name, 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 +704,7 @@ export function VariableSliders({ expressions, onChange, className = '' }: Varia className, ].join(' ')} role="region" - aria-label="Variable parameter sliders" + aria-label={t('panelLabel')} > {/* Subtle gradient overlay */} )} From 6045b771925563ccb335e93b8a415ae8755d16af Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:16:30 -0500 Subject: [PATCH 07/11] feat(web): /gpu-lab public gallery over publicWorksheets + share codes - GPU_LAB_WORKSHEETS_QUERY typed document (content JSON included) + regenerated codegen artifacts; resolver already serves a 60s PUBLIC cache hint - RSC gallery page via the in-process SchemaLink client: filters nodes whose content contains a simulation cell, renders glass-morphism cards with sim-kind badges, author (UserSummary fragment unmask), views and relative updated time via next-intl getFormatter - loading.tsx skeleton; command-palette GPU Lab entry Co-Authored-By: Claude Fable 5 --- apps/web/app/[locale]/gpu-lab/loading.tsx | 16 ++ apps/web/app/[locale]/gpu-lab/page.tsx | 258 ++++++++++++++++++ .../web/components/layout/command-palette.tsx | 8 + apps/web/lib/graphql/generated/gql.ts | 6 + apps/web/lib/graphql/generated/graphql.ts | 9 + apps/web/lib/graphql/operations.ts | 26 ++ 6 files changed, 323 insertions(+) create mode 100644 apps/web/app/[locale]/gpu-lab/loading.tsx create mode 100644 apps/web/app/[locale]/gpu-lab/page.tsx 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..82149ecf --- /dev/null +++ b/apps/web/app/[locale]/gpu-lab/page.tsx @@ -0,0 +1,258 @@ +/** + * 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 { 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; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Narrow an unknown cell JSON blob to a simulation cell shape. */ +function isSimulationCellData(cell: unknown): cell is { kind: 'simulation'; sim: string } { + return ( + typeof cell === 'object' && + cell !== null && + 'kind' in cell && + cell.kind === 'simulation' && + 'sim' in cell && + typeof cell.sim === 'string' + ); +} + +/** Unique simulation kinds present in a worksheet's content JSON. */ +function simKindsOf(content: unknown): string[] { + const cells: readonly unknown[] = Array.isArray(content) ? content : []; + const kinds = new Set(); + for (const cell of cells) { + if (isSimulationCellData(cell)) { + kinds.add(cell.sim); + } + } + return [...kinds]; +} + +// --------------------------------------------------------------------------- +// 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; + viewsLabel: string; + updatedLabel: string; + openLabel: string; +} + +function GalleryCard({ + worksheet, + badges, + byLabel, + viewsLabel, + 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 = worksheets + .map((worksheet) => ({ worksheet, simKinds: simKindsOf(worksheet.content) })) + .filter(({ simKinds }) => simKinds.length > 0); + + return ( +
+ {/* Ambient background */} +
+ ); +} 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/lib/graphql/generated/gql.ts b/apps/web/lib/graphql/generated/gql.ts index e06cb507..b9b09a36 100644 --- a/apps/web/lib/graphql/generated/gql.ts +++ b/apps/web/lib/graphql/generated/gql.ts @@ -31,6 +31,7 @@ type Documents = { "\n query Worksheet($id: ID!) {\n worksheet(id: $id) {\n id\n title\n description\n content\n visibility\n views\n createdAt\n updatedAt\n user {\n ...UserSummary\n }\n folder {\n id\n name\n }\n shares {\n id\n sharedWith\n permission\n }\n }\n }\n": typeof types.WorksheetDocument, "\n query Worksheets(\n $limit: Int = 20\n $offset: Int = 0\n $visibility: WorksheetVisibility\n $userId: ID\n $folderId: ID\n $searchQuery: String\n ) {\n worksheets(\n limit: $limit\n offset: $offset\n visibility: $visibility\n userId: $userId\n folderId: $folderId\n searchQuery: $searchQuery\n ) {\n nodes {\n id\n title\n description\n visibility\n views\n createdAt\n updatedAt\n user {\n ...UserSummary\n }\n folder {\n id\n name\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n totalCount\n currentPage\n totalPages\n }\n }\n }\n": typeof types.WorksheetsDocument, "\n query PublicWorksheets(\n $limit: Int = 20\n $offset: Int = 0\n $searchQuery: String\n ) {\n publicWorksheets(\n limit: $limit\n offset: $offset\n searchQuery: $searchQuery\n ) {\n nodes {\n id\n title\n description\n views\n createdAt\n user {\n ...UserSummary\n }\n }\n pageInfo {\n hasNextPage\n totalCount\n currentPage\n totalPages\n }\n }\n }\n": typeof types.PublicWorksheetsDocument, + "\n query GpuLabWorksheets($limit: Int = 24, $offset: Int = 0) {\n publicWorksheets(limit: $limit, offset: $offset) {\n nodes {\n id\n title\n description\n views\n updatedAt\n content\n user {\n ...UserSummary\n }\n }\n pageInfo {\n totalCount\n }\n }\n }\n": typeof types.GpuLabWorksheetsDocument, "\n mutation CreateWorksheet($input: CreateWorksheetInput!) {\n createWorksheet(input: $input) {\n id\n title\n description\n visibility\n createdAt\n }\n }\n": typeof types.CreateWorksheetDocument, "\n mutation UpdateWorksheet($id: ID!, $input: UpdateWorksheetInput!) {\n updateWorksheet(id: $id, input: $input) {\n id\n title\n description\n content\n visibility\n updatedAt\n }\n }\n": typeof types.UpdateWorksheetDocument, "\n mutation DeleteWorksheet($id: ID!) {\n deleteWorksheet(id: $id)\n }\n": typeof types.DeleteWorksheetDocument, @@ -69,6 +70,7 @@ const documents: Documents = { "\n query Worksheet($id: ID!) {\n worksheet(id: $id) {\n id\n title\n description\n content\n visibility\n views\n createdAt\n updatedAt\n user {\n ...UserSummary\n }\n folder {\n id\n name\n }\n shares {\n id\n sharedWith\n permission\n }\n }\n }\n": types.WorksheetDocument, "\n query Worksheets(\n $limit: Int = 20\n $offset: Int = 0\n $visibility: WorksheetVisibility\n $userId: ID\n $folderId: ID\n $searchQuery: String\n ) {\n worksheets(\n limit: $limit\n offset: $offset\n visibility: $visibility\n userId: $userId\n folderId: $folderId\n searchQuery: $searchQuery\n ) {\n nodes {\n id\n title\n description\n visibility\n views\n createdAt\n updatedAt\n user {\n ...UserSummary\n }\n folder {\n id\n name\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n totalCount\n currentPage\n totalPages\n }\n }\n }\n": types.WorksheetsDocument, "\n query PublicWorksheets(\n $limit: Int = 20\n $offset: Int = 0\n $searchQuery: String\n ) {\n publicWorksheets(\n limit: $limit\n offset: $offset\n searchQuery: $searchQuery\n ) {\n nodes {\n id\n title\n description\n views\n createdAt\n user {\n ...UserSummary\n }\n }\n pageInfo {\n hasNextPage\n totalCount\n currentPage\n totalPages\n }\n }\n }\n": types.PublicWorksheetsDocument, + "\n query GpuLabWorksheets($limit: Int = 24, $offset: Int = 0) {\n publicWorksheets(limit: $limit, offset: $offset) {\n nodes {\n id\n title\n description\n views\n updatedAt\n content\n user {\n ...UserSummary\n }\n }\n pageInfo {\n totalCount\n }\n }\n }\n": types.GpuLabWorksheetsDocument, "\n mutation CreateWorksheet($input: CreateWorksheetInput!) {\n createWorksheet(input: $input) {\n id\n title\n description\n visibility\n createdAt\n }\n }\n": types.CreateWorksheetDocument, "\n mutation UpdateWorksheet($id: ID!, $input: UpdateWorksheetInput!) {\n updateWorksheet(id: $id, input: $input) {\n id\n title\n description\n content\n visibility\n updatedAt\n }\n }\n": types.UpdateWorksheetDocument, "\n mutation DeleteWorksheet($id: ID!) {\n deleteWorksheet(id: $id)\n }\n": types.DeleteWorksheetDocument, @@ -175,6 +177,10 @@ export function graphql(source: "\n query Worksheets(\n $limit: Int = 20\n * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n query PublicWorksheets(\n $limit: Int = 20\n $offset: Int = 0\n $searchQuery: String\n ) {\n publicWorksheets(\n limit: $limit\n offset: $offset\n searchQuery: $searchQuery\n ) {\n nodes {\n id\n title\n description\n views\n createdAt\n user {\n ...UserSummary\n }\n }\n pageInfo {\n hasNextPage\n totalCount\n currentPage\n totalPages\n }\n }\n }\n"): (typeof documents)["\n query PublicWorksheets(\n $limit: Int = 20\n $offset: Int = 0\n $searchQuery: String\n ) {\n publicWorksheets(\n limit: $limit\n offset: $offset\n searchQuery: $searchQuery\n ) {\n nodes {\n id\n title\n description\n views\n createdAt\n user {\n ...UserSummary\n }\n }\n pageInfo {\n hasNextPage\n totalCount\n currentPage\n totalPages\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query GpuLabWorksheets($limit: Int = 24, $offset: Int = 0) {\n publicWorksheets(limit: $limit, offset: $offset) {\n nodes {\n id\n title\n description\n views\n updatedAt\n content\n user {\n ...UserSummary\n }\n }\n pageInfo {\n totalCount\n }\n }\n }\n"): (typeof documents)["\n query GpuLabWorksheets($limit: Int = 24, $offset: Int = 0) {\n publicWorksheets(limit: $limit, offset: $offset) {\n nodes {\n id\n title\n description\n views\n updatedAt\n content\n user {\n ...UserSummary\n }\n }\n pageInfo {\n totalCount\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/apps/web/lib/graphql/generated/graphql.ts b/apps/web/lib/graphql/generated/graphql.ts index 2b8cb466..de0327ae 100644 --- a/apps/web/lib/graphql/generated/graphql.ts +++ b/apps/web/lib/graphql/generated/graphql.ts @@ -226,6 +226,14 @@ export type PublicWorksheetsQueryVariables = Exact<{ export type PublicWorksheetsQuery = { publicWorksheets: { nodes: Array<{ id: string, title: string, description: string | null, views: number, createdAt: string, user: { ' $fragmentRefs'?: { 'UserSummaryFragment': UserSummaryFragment } } }>, pageInfo: { hasNextPage: boolean, totalCount: number, currentPage: number, totalPages: number } } }; +export type GpuLabWorksheetsQueryVariables = Exact<{ + limit?: number | null | undefined; + offset?: number | null | undefined; +}>; + + +export type GpuLabWorksheetsQuery = { publicWorksheets: { nodes: Array<{ id: string, title: string, description: string | null, views: number, updatedAt: string, content: Record, user: { ' $fragmentRefs'?: { 'UserSummaryFragment': UserSummaryFragment } } }>, pageInfo: { totalCount: number } } }; + export type CreateWorksheetMutationVariables = Exact<{ input: CreateWorksheetInput; }>; @@ -373,6 +381,7 @@ export const UserDocument = {"kind":"Document","definitions":[{"kind":"Operation export const WorksheetDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Worksheet"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"worksheet"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"views"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserSummary"}}]}},{"kind":"Field","name":{"kind":"Name","value":"folder"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"shares"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"sharedWith"}},{"kind":"Field","name":{"kind":"Name","value":"permission"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserSummary"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"image"}}]}}]} as unknown as DocumentNode; export const WorksheetsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Worksheets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"20"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"visibility"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"WorksheetVisibility"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"folderId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchQuery"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"worksheets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}},{"kind":"Argument","name":{"kind":"Name","value":"visibility"},"value":{"kind":"Variable","name":{"kind":"Name","value":"visibility"}}},{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}},{"kind":"Argument","name":{"kind":"Name","value":"folderId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"folderId"}}},{"kind":"Argument","name":{"kind":"Name","value":"searchQuery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchQuery"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"views"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserSummary"}}]}},{"kind":"Field","name":{"kind":"Name","value":"folder"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"currentPage"}},{"kind":"Field","name":{"kind":"Name","value":"totalPages"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserSummary"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"image"}}]}}]} as unknown as DocumentNode; export const PublicWorksheetsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PublicWorksheets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"20"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchQuery"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicWorksheets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}},{"kind":"Argument","name":{"kind":"Name","value":"searchQuery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchQuery"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"views"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserSummary"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"currentPage"}},{"kind":"Field","name":{"kind":"Name","value":"totalPages"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserSummary"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"image"}}]}}]} as unknown as DocumentNode; +export const GpuLabWorksheetsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GpuLabWorksheets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"24"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicWorksheets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"views"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserSummary"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserSummary"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"image"}}]}}]} as unknown as DocumentNode; export const CreateWorksheetDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateWorksheet"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateWorksheetInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createWorksheet"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode; export const UpdateWorksheetDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorksheet"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWorksheetInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorksheet"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; export const DeleteWorksheetDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteWorksheet"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteWorksheet"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; diff --git a/apps/web/lib/graphql/operations.ts b/apps/web/lib/graphql/operations.ts index a312f187..25e1b2c3 100644 --- a/apps/web/lib/graphql/operations.ts +++ b/apps/web/lib/graphql/operations.ts @@ -192,6 +192,32 @@ export const PUBLIC_WORKSHEETS_QUERY = graphql(` } `); +/** + * GPU Lab gallery — public worksheets WITH content so the RSC page can + * filter for simulation cells and derive per-card sim badges. + * Served with a 60s PUBLIC cache hint by the publicWorksheets resolver. + */ +export const GPU_LAB_WORKSHEETS_QUERY = graphql(` + query GpuLabWorksheets($limit: Int = 24, $offset: Int = 0) { + publicWorksheets(limit: $limit, offset: $offset) { + nodes { + id + title + description + views + updatedAt + content + user { + ...UserSummary + } + } + pageInfo { + totalCount + } + } + } +`); + // ============================================================================ // WORKSHEET MUTATIONS // ============================================================================ From 669a3f1797b1f8e7db3bf70036f550835f04824e Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:21:15 -0500 Subject: [PATCH 08/11] i18n(web): GPU Lab + slider animation keys in all 8 locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 77 new keys (plots.sliders.*, worksheet.simulation.* incl. params/presets, worksheet.publish*, gpuLab.*, nav.gpuLab) with real translations in en/ru/es/uk/de/fr/ja/zh — correct plural categories per locale (ru/uk one/few/many, ja/zh other-only). No English placeholders; remaining identical strings are cognates/proper nouns (Sigma, Van der Pol, de/fr 'Simulation'/'Pause', fr min/max). Co-Authored-By: Claude Fable 5 --- apps/web/app/[locale]/gpu-lab/page.tsx | 15 +++-- apps/web/messages/de.json | 91 +++++++++++++++++++++++++- apps/web/messages/en.json | 91 +++++++++++++++++++++++++- apps/web/messages/es.json | 91 +++++++++++++++++++++++++- apps/web/messages/fr.json | 91 +++++++++++++++++++++++++- apps/web/messages/ja.json | 91 +++++++++++++++++++++++++- apps/web/messages/ru.json | 91 +++++++++++++++++++++++++- apps/web/messages/uk.json | 91 +++++++++++++++++++++++++- apps/web/messages/zh.json | 91 +++++++++++++++++++++++++- 9 files changed, 722 insertions(+), 21 deletions(-) diff --git a/apps/web/app/[locale]/gpu-lab/page.tsx b/apps/web/app/[locale]/gpu-lab/page.tsx index 82149ecf..330c56fd 100644 --- a/apps/web/app/[locale]/gpu-lab/page.tsx +++ b/apps/web/app/[locale]/gpu-lab/page.tsx @@ -106,7 +106,10 @@ interface GalleryCardProps { worksheet: GalleryWorksheet; badges: string[]; byLabel: (name: string) => string; - viewsLabel: 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; } @@ -115,7 +118,8 @@ function GalleryCard({ worksheet, badges, byLabel, - viewsLabel, + viewsCount, + viewsAria, updatedLabel, openLabel, }: GalleryCardProps) { @@ -164,9 +168,9 @@ function GalleryCard({
{byLabel(user.name ?? '—')} - + {updatedLabel} @@ -242,7 +246,8 @@ export default async function GpuLabPage({ params }: PageProps) { .filter(isSimulationKind) .map((kind) => tSim(SIM_REGISTRY[kind].labelKey))} byLabel={(name) => t('by', { name })} - viewsLabel={format.number(worksheet.views)} + viewsCount={format.number(worksheet.views)} + viewsAria={t('views', { count: worksheet.views })} updatedLabel={t('updated', { time: format.relativeTime(new Date(worksheet.updatedAt)), })} diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 605d49ee..679c8e08 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -59,7 +59,8 @@ "algorithmsMenu": "Algorithmen-Menü", "home": "NextCalc Pro - Startseite", "templates": "Vorlagen", - "templatesDescription": "Gebrauchsfertige Berechnungsvorlagen" + "templatesDescription": "Gebrauchsfertige Berechnungsvorlagen", + "gpuLab": "GPU Lab" }, "common": { "language": "Sprache", @@ -479,7 +480,61 @@ "cellType": { "code": "Code", "markdown": "Markdown" - } + }, + "simulation": { + "label": "Simulation", + "addCell": "Simulationszelle hinzufügen", + "heat": "Wärme", + "wave": "Welle", + "laplace": "Laplace", + "lorenz": "Lorenz", + "directionField": "Richtungsfeld", + "preset": "Voreinstellung", + "parameters": "Parameter", + "play": "Start", + "pause": "Pause", + "reset": "Zurücksetzen", + "dismiss": "Ausblenden", + "webgpuFallback": "WebGPU ist in diesem Browser nicht verfügbar — der WebGL-Fallback-Renderer wird verwendet.", + "noGpu": "Diese Simulation benötigt GPU-Beschleunigung (WebGPU oder WebGL 2), die in diesem Browser nicht verfügbar ist.", + "unknownKind": "Unbekannter Simulationstyp „{kind}“ — diese Zelle wurde in einer neueren NextCalc-Version erstellt.", + "params": { + "alpha": "Diffusivität α", + "waveSpeed": "Wellengeschwindigkeit c", + "relaxation": "Relaxation α", + "sigma": "Sigma σ", + "rho": "Rho ρ", + "beta": "Beta β", + "steps": "Trajektorienschritte", + "preyGrowth": "Beutewachstum a", + "predatorDeath": "Räubersterblichkeit b", + "mu": "Dämpfung μ", + "spiralDamping": "Spiraldämpfung α", + "damping": "Dämpfung γ" + }, + "presets": { + "center": "Zentrum", + "line": "Linie", + "corners": "Ecken", + "ring": "Ring", + "cross": "Kreuz", + "gaussian": "Gauß-Peaks", + "random": "Zufällig", + "doubleGaussian": "Doppelte Gauß-Kurve", + "sawtooth": "Sägezahn", + "squarePulse": "Rechteckimpuls", + "sinc": "Sinc", + "classic": "Klassisch", + "lotka-volterra": "Lotka-Volterra", + "van-der-pol": "Van der Pol", + "stable-spiral": "Stabile Spirale", + "pendulum": "Pendel" + } + }, + "publish": "Veröffentlichen", + "unpublish": "Veröffentlichung aufheben", + "published": "Veröffentlicht", + "publishHint": "Melde dich an und lass das Arbeitsblatt automatisch speichern, bevor du es in der GPU-Lab-Galerie veröffentlichst." }, "settings": { "title": "Einstellungen", @@ -1056,6 +1111,29 @@ "2dPolar": "2D Polar", "2dParametric": "2D Parametric", "3dSurface": "3D Surface" + }, + "sliders": { + "parameters": "Parameter", + "dragToAdjust": "zum Anpassen ziehen", + "min": "Min", + "max": "Max", + "value": "Wert", + "editRangeHint": "Klicke auf die Min/Max-Beschriftungen, um den Bereich zu ändern.", + "play": "Animation für {name} starten", + "pause": "Animation für {name} pausieren", + "animationMode": "Animationsmodus für {name}", + "modeLoop": "Schleife", + "modeBounce": "Hin und her", + "modeOnce": "Einmal", + "speed": "Animationsgeschwindigkeit für {name}: {speed}x", + "minFor": "Minimum für {name}", + "maxFor": "Maximum für {name}", + "editBound": "{label} bearbeiten (aktuell {value})", + "editBoundTitle": "Klicken, um {label} zu bearbeiten", + "sliderAria": "Parameter {name}, aktueller Wert {value}, Bereich {min} bis {max}", + "valueAria": "{name} ist gleich {value}", + "panelLabel": "Parameter-Schieberegler", + "detected": "{count, plural, one {# Parameter erkannt} other {# Parameter erkannt}}" } }, "chaos": { @@ -1463,5 +1541,14 @@ "complexNumbers": "Komplexe Zahlen", "complexNumbersSubtitle": "Argand-Diagramm & Darstellungen" } + }, + "gpuLab": { + "title": "GPU Lab", + "description": "Öffentliche Galerie interaktiver GPU-Simulationen: Wärme- und Wellengleichung, Lorenz-Attraktor und Richtungsfelder aus Arbeitsblättern der Community.", + "empty": "Noch keine veröffentlichten Simulationen. Füge einem Arbeitsblatt eine Simulationszelle hinzu und veröffentliche es.", + "openWorksheet": "Arbeitsblatt öffnen", + "by": "von {name}", + "views": "{count, plural, one {# Aufruf} other {# Aufrufe}}", + "updated": "Aktualisiert {time}" } } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a665df56..9ceb4a30 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -61,7 +61,8 @@ "templates": "Templates", "templatesDescription": "Ready-to-use calculation templates", "worksheets": "My Worksheets", - "worksheetsDescription": "View and manage your saved worksheets" + "worksheetsDescription": "View and manage your saved worksheets", + "gpuLab": "GPU Lab" }, "common": { "language": "Language", @@ -513,7 +514,61 @@ "cellType": { "code": "Code", "markdown": "Markdown" - } + }, + "simulation": { + "label": "Simulation", + "addCell": "Add simulation cell", + "heat": "Heat", + "wave": "Wave", + "laplace": "Laplace", + "lorenz": "Lorenz", + "directionField": "Direction field", + "preset": "Preset", + "parameters": "Parameters", + "play": "Play", + "pause": "Pause", + "reset": "Reset", + "dismiss": "Dismiss", + "webgpuFallback": "WebGPU isn't available in this browser — running on the WebGL fallback renderer.", + "noGpu": "This simulation needs GPU acceleration (WebGPU or WebGL 2), which isn't available in this browser.", + "unknownKind": "Unknown simulation type \"{kind}\" — this cell was created in a newer version of NextCalc.", + "params": { + "alpha": "Diffusivity α", + "waveSpeed": "Wave speed c", + "relaxation": "Relaxation α", + "sigma": "Sigma σ", + "rho": "Rho ρ", + "beta": "Beta β", + "steps": "Trajectory steps", + "preyGrowth": "Prey growth a", + "predatorDeath": "Predator death b", + "mu": "Damping μ", + "spiralDamping": "Spiral damping α", + "damping": "Damping γ" + }, + "presets": { + "center": "Center", + "line": "Line", + "corners": "Corners", + "ring": "Ring", + "cross": "Cross", + "gaussian": "Gaussians", + "random": "Random", + "doubleGaussian": "Double Gaussian", + "sawtooth": "Sawtooth", + "squarePulse": "Square pulse", + "sinc": "Sinc", + "classic": "Classic", + "lotka-volterra": "Lotka–Volterra", + "van-der-pol": "Van der Pol", + "stable-spiral": "Stable spiral", + "pendulum": "Pendulum" + } + }, + "publish": "Publish", + "unpublish": "Unpublish", + "published": "Published", + "publishHint": "Sign in and let the worksheet autosave before publishing to the GPU Lab gallery." }, "settings": { "title": "Settings", @@ -1090,6 +1145,29 @@ "2dPolar": "2D Polar", "2dParametric": "2D Parametric", "3dSurface": "3D Surface" + }, + "sliders": { + "parameters": "Parameters", + "dragToAdjust": "drag to adjust", + "min": "min", + "max": "max", + "value": "value", + "editRangeHint": "Click the min/max labels to edit the range.", + "play": "Play animation for {name}", + "pause": "Pause animation for {name}", + "animationMode": "Animation mode for {name}", + "modeLoop": "Loop", + "modeBounce": "Bounce", + "modeOnce": "Once", + "speed": "Animation speed for {name}: {speed}x", + "minFor": "minimum for {name}", + "maxFor": "maximum for {name}", + "editBound": "Edit {label} (currently {value})", + "editBoundTitle": "Click to edit {label}", + "sliderAria": "Parameter {name}, current value {value}, range {min} to {max}", + "valueAria": "{name} equals {value}", + "panelLabel": "Variable parameter sliders", + "detected": "{count, plural, one {# parameter detected} other {# parameters detected}}" } }, "chaos": { @@ -1499,5 +1577,14 @@ "complexNumbers": "Complex Numbers", "complexNumbersSubtitle": "Argand Diagram & Forms" } + }, + "gpuLab": { + "title": "GPU Lab", + "description": "A public gallery of interactive GPU simulations — heat and wave equations, the Lorenz attractor, and direction fields from community worksheets.", + "empty": "No published simulations yet. Add a simulation cell to a worksheet and publish it to share it here.", + "openWorksheet": "Open worksheet", + "by": "by {name}", + "views": "{count, plural, one {# view} other {# views}}", + "updated": "Updated {time}" } } diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 9bad412f..1c21ed27 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -59,7 +59,8 @@ "algorithmsMenu": "Menú de algoritmos", "home": "NextCalc Pro - Inicio", "templates": "Plantillas", - "templatesDescription": "Plantillas de cálculo listas para usar" + "templatesDescription": "Plantillas de cálculo listas para usar", + "gpuLab": "GPU Lab" }, "common": { "language": "Idioma", @@ -479,7 +480,61 @@ "cellType": { "code": "Código", "markdown": "Markdown" - } + }, + "simulation": { + "label": "Simulación", + "addCell": "Añadir celda de simulación", + "heat": "Calor", + "wave": "Onda", + "laplace": "Laplace", + "lorenz": "Lorenz", + "directionField": "Campo de direcciones", + "preset": "Preajuste", + "parameters": "Parámetros", + "play": "Reproducir", + "pause": "Pausa", + "reset": "Reiniciar", + "dismiss": "Descartar", + "webgpuFallback": "WebGPU no está disponible en este navegador; se usa el renderizador WebGL de respaldo.", + "noGpu": "Esta simulación requiere aceleración GPU (WebGPU o WebGL 2), no disponible en este navegador.", + "unknownKind": "Tipo de simulación desconocido «{kind}»: esta celda se creó en una versión más reciente de NextCalc.", + "params": { + "alpha": "Difusividad α", + "waveSpeed": "Velocidad de onda c", + "relaxation": "Relajación α", + "sigma": "Sigma σ", + "rho": "Rho ρ", + "beta": "Beta β", + "steps": "Pasos de la trayectoria", + "preyGrowth": "Crecimiento de presas a", + "predatorDeath": "Mortalidad de depredadores b", + "mu": "Amortiguamiento μ", + "spiralDamping": "Amortiguamiento de la espiral α", + "damping": "Amortiguamiento γ" + }, + "presets": { + "center": "Centro", + "line": "Línea", + "corners": "Esquinas", + "ring": "Anillo", + "cross": "Cruz", + "gaussian": "Gaussianas", + "random": "Aleatorio", + "doubleGaussian": "Gaussiana doble", + "sawtooth": "Diente de sierra", + "squarePulse": "Pulso cuadrado", + "sinc": "Sinc", + "classic": "Clásico", + "lotka-volterra": "Lotka-Volterra", + "van-der-pol": "Van der Pol", + "stable-spiral": "Espiral estable", + "pendulum": "Péndulo" + } + }, + "publish": "Publicar", + "unpublish": "Retirar publicación", + "published": "Publicado", + "publishHint": "Inicia sesión y deja que la hoja se guarde automáticamente antes de publicarla en la galería GPU Lab." }, "settings": { "title": "Configuración", @@ -1056,6 +1111,29 @@ "2dPolar": "2D Polar", "2dParametric": "2D Paramétrico", "3dSurface": "Superficie 3D" + }, + "sliders": { + "parameters": "Parámetros", + "dragToAdjust": "arrastra para ajustar", + "min": "mín", + "max": "máx", + "value": "valor", + "editRangeHint": "Haz clic en las etiquetas mín/máx para editar el rango.", + "play": "Reproducir animación de {name}", + "pause": "Pausar animación de {name}", + "animationMode": "Modo de animación de {name}", + "modeLoop": "Bucle", + "modeBounce": "Rebote", + "modeOnce": "Una vez", + "speed": "Velocidad de animación de {name}: {speed}x", + "minFor": "mínimo de {name}", + "maxFor": "máximo de {name}", + "editBound": "Editar {label} (actual: {value})", + "editBoundTitle": "Haz clic para editar {label}", + "sliderAria": "Parámetro {name}, valor actual {value}, rango de {min} a {max}", + "valueAria": "{name} es igual a {value}", + "panelLabel": "Controles deslizantes de parámetros", + "detected": "{count, plural, one {# parámetro detectado} other {# parámetros detectados}}" } }, "chaos": { @@ -1463,5 +1541,14 @@ "complexNumbers": "Números complejos", "complexNumbersSubtitle": "Diagrama de Argand y formas" } + }, + "gpuLab": { + "title": "GPU Lab", + "description": "Galería pública de simulaciones GPU interactivas: ecuaciones del calor y de ondas, el atractor de Lorenz y campos de direcciones de hojas de la comunidad.", + "empty": "Aún no hay simulaciones publicadas. Añade una celda de simulación a una hoja de trabajo y publícala.", + "openWorksheet": "Abrir hoja de trabajo", + "by": "por {name}", + "views": "{count, plural, one {# visita} other {# visitas}}", + "updated": "Actualizado {time}" } } diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 07f95f6f..59b0fdb7 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -61,7 +61,8 @@ "templates": "Modèles", "templatesDescription": "Modèles de calcul prêts à l'emploi", "worksheets": "Mes feuilles de calcul", - "worksheetsDescription": "Consulter et gérer vos feuilles de calcul enregistrées" + "worksheetsDescription": "Consulter et gérer vos feuilles de calcul enregistrées", + "gpuLab": "GPU Lab" }, "common": { "language": "Langue", @@ -512,7 +513,61 @@ "cellType": { "code": "Code", "markdown": "Markdown" - } + }, + "simulation": { + "label": "Simulation", + "addCell": "Ajouter une cellule de simulation", + "heat": "Chaleur", + "wave": "Onde", + "laplace": "Laplace", + "lorenz": "Lorenz", + "directionField": "Champ de directions", + "preset": "Préréglage", + "parameters": "Paramètres", + "play": "Lecture", + "pause": "Pause", + "reset": "Réinitialiser", + "dismiss": "Masquer", + "webgpuFallback": "WebGPU n'est pas disponible dans ce navigateur — le rendu WebGL de secours est utilisé.", + "noGpu": "Cette simulation nécessite l'accélération GPU (WebGPU ou WebGL 2), indisponible dans ce navigateur.", + "unknownKind": "Type de simulation inconnu « {kind} » — cette cellule a été créée dans une version plus récente de NextCalc.", + "params": { + "alpha": "Diffusivité α", + "waveSpeed": "Vitesse d'onde c", + "relaxation": "Relaxation α", + "sigma": "Sigma σ", + "rho": "Rhô ρ", + "beta": "Bêta β", + "steps": "Pas de la trajectoire", + "preyGrowth": "Croissance des proies a", + "predatorDeath": "Mortalité des prédateurs b", + "mu": "Amortissement μ", + "spiralDamping": "Amortissement de la spirale α", + "damping": "Amortissement γ" + }, + "presets": { + "center": "Centre", + "line": "Ligne", + "corners": "Coins", + "ring": "Anneau", + "cross": "Croix", + "gaussian": "Gaussiennes", + "random": "Aléatoire", + "doubleGaussian": "Double gaussienne", + "sawtooth": "Dents de scie", + "squarePulse": "Impulsion carrée", + "sinc": "Sinc", + "classic": "Classique", + "lotka-volterra": "Lotka-Volterra", + "van-der-pol": "Van der Pol", + "stable-spiral": "Spirale stable", + "pendulum": "Pendule" + } + }, + "publish": "Publier", + "unpublish": "Dépublier", + "published": "Publié", + "publishHint": "Connectez-vous et laissez la feuille s'enregistrer automatiquement avant de la publier dans la galerie GPU Lab." }, "settings": { "title": "Paramètres", @@ -1089,6 +1144,29 @@ "2dPolar": "2D Polaire", "2dParametric": "2D Paramétrique", "3dSurface": "Surface 3D" + }, + "sliders": { + "parameters": "Paramètres", + "dragToAdjust": "glisser pour ajuster", + "min": "min", + "max": "max", + "value": "valeur", + "editRangeHint": "Cliquez sur les libellés min/max pour modifier la plage.", + "play": "Lancer l'animation de {name}", + "pause": "Mettre en pause l'animation de {name}", + "animationMode": "Mode d'animation de {name}", + "modeLoop": "Boucle", + "modeBounce": "Aller-retour", + "modeOnce": "Une fois", + "speed": "Vitesse d'animation de {name} : {speed}x", + "minFor": "minimum de {name}", + "maxFor": "maximum de {name}", + "editBound": "Modifier {label} (actuellement {value})", + "editBoundTitle": "Cliquez pour modifier {label}", + "sliderAria": "Paramètre {name}, valeur actuelle {value}, plage de {min} à {max}", + "valueAria": "{name} vaut {value}", + "panelLabel": "Curseurs de paramètres", + "detected": "{count, plural, one {# paramètre détecté} other {# paramètres détectés}}" } }, "chaos": { @@ -1496,5 +1574,14 @@ "complexNumbers": "Nombres complexes", "complexNumbersSubtitle": "Diagramme d'Argand et formes" } + }, + "gpuLab": { + "title": "GPU Lab", + "description": "Galerie publique de simulations GPU interactives : équations de la chaleur et des ondes, attracteur de Lorenz et champs de directions issus des feuilles de la communauté.", + "empty": "Aucune simulation publiée pour le moment. Ajoutez une cellule de simulation à une feuille de calcul et publiez-la.", + "openWorksheet": "Ouvrir la feuille de calcul", + "by": "par {name}", + "views": "{count, plural, one {# vue} other {# vues}}", + "updated": "Mis à jour {time}" } } diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index 2420ac24..6782852f 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -61,7 +61,8 @@ "templates": "テンプレート", "templatesDescription": "すぐに使える計算テンプレート", "worksheets": "マイワークシート", - "worksheetsDescription": "保存済みワークシートの表示と管理" + "worksheetsDescription": "保存済みワークシートの表示と管理", + "gpuLab": "GPU Lab" }, "common": { "language": "言語", @@ -512,7 +513,61 @@ "cellType": { "code": "コード", "markdown": "Markdown" - } + }, + "simulation": { + "label": "シミュレーション", + "addCell": "シミュレーションセルを追加", + "heat": "熱", + "wave": "波動", + "laplace": "ラプラス", + "lorenz": "ローレンツ", + "directionField": "方向場", + "preset": "プリセット", + "parameters": "パラメータ", + "play": "再生", + "pause": "一時停止", + "reset": "リセット", + "dismiss": "閉じる", + "webgpuFallback": "このブラウザでは WebGPU を利用できないため、WebGL フォールバックレンダラーで実行しています。", + "noGpu": "このシミュレーションには GPU アクセラレーション(WebGPU または WebGL 2)が必要ですが、このブラウザでは利用できません。", + "unknownKind": "不明なシミュレーション種別「{kind}」— このセルは新しいバージョンの NextCalc で作成されました。", + "params": { + "alpha": "拡散係数 α", + "waveSpeed": "波の速度 c", + "relaxation": "緩和係数 α", + "sigma": "シグマ σ", + "rho": "ロー ρ", + "beta": "ベータ β", + "steps": "軌道ステップ数", + "preyGrowth": "被食者の増殖率 a", + "predatorDeath": "捕食者の死亡率 b", + "mu": "減衰 μ", + "spiralDamping": "螺旋の減衰 α", + "damping": "減衰 γ" + }, + "presets": { + "center": "中央", + "line": "直線", + "corners": "四隅", + "ring": "リング", + "cross": "十字", + "gaussian": "ガウス群", + "random": "ランダム", + "doubleGaussian": "二重ガウス", + "sawtooth": "のこぎり波", + "squarePulse": "矩形パルス", + "sinc": "Sinc", + "classic": "クラシック", + "lotka-volterra": "ロトカ・ヴォルテラ", + "van-der-pol": "ファン・デル・ポール", + "stable-spiral": "安定螺旋", + "pendulum": "振り子" + } + }, + "publish": "公開", + "unpublish": "非公開にする", + "published": "公開中", + "publishHint": "GPU Lab ギャラリーに公開するには、サインインしてワークシートの自動保存を待ってください。" }, "settings": { "title": "設定", @@ -1089,6 +1144,29 @@ "2dPolar": "2D 極座標", "2dParametric": "2D パラメトリック", "3dSurface": "3D 曲面" + }, + "sliders": { + "parameters": "パラメータ", + "dragToAdjust": "ドラッグで調整", + "min": "最小", + "max": "最大", + "value": "値", + "editRangeHint": "最小/最大のラベルをクリックすると範囲を編集できます。", + "play": "{name} のアニメーションを再生", + "pause": "{name} のアニメーションを一時停止", + "animationMode": "{name} のアニメーションモード", + "modeLoop": "ループ", + "modeBounce": "往復", + "modeOnce": "1回", + "speed": "{name} のアニメーション速度: {speed}x", + "minFor": "{name} の最小値", + "maxFor": "{name} の最大値", + "editBound": "{label}を編集(現在 {value})", + "editBoundTitle": "クリックして{label}を編集", + "sliderAria": "パラメータ {name}、現在の値 {value}、範囲 {min}〜{max}", + "valueAria": "{name} は {value}", + "panelLabel": "パラメータスライダー", + "detected": "{count, plural, other {# 個のパラメータを検出}}" } }, "chaos": { @@ -1496,5 +1574,14 @@ "complexNumbers": "複素数", "complexNumbersSubtitle": "アルガン図と表現形式" } + }, + "gpuLab": { + "title": "GPU Lab", + "description": "インタラクティブな GPU シミュレーションの公開ギャラリー。熱方程式・波動方程式、ローレンツアトラクタ、コミュニティのワークシートによる方向場を紹介します。", + "empty": "公開されたシミュレーションはまだありません。ワークシートにシミュレーションセルを追加して公開してみましょう。", + "openWorksheet": "ワークシートを開く", + "by": "作成者: {name}", + "views": "{count, plural, other {# 回表示}}", + "updated": "更新: {time}" } } diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json index 3b5e1f2e..ea63deee 100644 --- a/apps/web/messages/ru.json +++ b/apps/web/messages/ru.json @@ -59,7 +59,8 @@ "algorithmsMenu": "Меню алгоритмов", "home": "NextCalc Pro — Главная", "templates": "Шаблоны", - "templatesDescription": "Готовые шаблоны вычислений" + "templatesDescription": "Готовые шаблоны вычислений", + "gpuLab": "GPU Lab" }, "common": { "language": "Язык", @@ -479,7 +480,61 @@ "cellType": { "code": "Код", "markdown": "Markdown" - } + }, + "simulation": { + "label": "Симуляция", + "addCell": "Добавить ячейку симуляции", + "heat": "Тепло", + "wave": "Волна", + "laplace": "Лаплас", + "lorenz": "Лоренц", + "directionField": "Поле направлений", + "preset": "Пресет", + "parameters": "Параметры", + "play": "Пуск", + "pause": "Пауза", + "reset": "Сброс", + "dismiss": "Скрыть", + "webgpuFallback": "WebGPU недоступен в этом браузере — используется резервный рендерер WebGL.", + "noGpu": "Для этой симуляции нужно GPU-ускорение (WebGPU или WebGL 2), которое недоступно в этом браузере.", + "unknownKind": "Неизвестный тип симуляции «{kind}» — эта ячейка создана в более новой версии NextCalc.", + "params": { + "alpha": "Коэффициент диффузии α", + "waveSpeed": "Скорость волны c", + "relaxation": "Релаксация α", + "sigma": "Сигма σ", + "rho": "Ро ρ", + "beta": "Бета β", + "steps": "Шаги траектории", + "preyGrowth": "Рост жертв a", + "predatorDeath": "Гибель хищников b", + "mu": "Демпфирование μ", + "spiralDamping": "Затухание спирали α", + "damping": "Затухание γ" + }, + "presets": { + "center": "Центр", + "line": "Линия", + "corners": "Углы", + "ring": "Кольцо", + "cross": "Крест", + "gaussian": "Гауссианы", + "random": "Случайно", + "doubleGaussian": "Двойная гауссиана", + "sawtooth": "Пила", + "squarePulse": "Прямоугольный импульс", + "sinc": "Sinc", + "classic": "Классика", + "lotka-volterra": "Лотка–Вольтерра", + "van-der-pol": "Ван дер Поль", + "stable-spiral": "Устойчивая спираль", + "pendulum": "Маятник" + } + }, + "publish": "Опубликовать", + "unpublish": "Снять с публикации", + "published": "Опубликовано", + "publishHint": "Войдите и дождитесь автосохранения листа, чтобы опубликовать его в галерее GPU Lab." }, "settings": { "title": "Настройки", @@ -1056,6 +1111,29 @@ "2dPolar": "2D полярные", "2dParametric": "2D параметрические", "3dSurface": "3D поверхность" + }, + "sliders": { + "parameters": "Параметры", + "dragToAdjust": "перетащите для настройки", + "min": "мин", + "max": "макс", + "value": "значение", + "editRangeHint": "Нажмите на подписи мин/макс, чтобы изменить диапазон.", + "play": "Запустить анимацию {name}", + "pause": "Остановить анимацию {name}", + "animationMode": "Режим анимации {name}", + "modeLoop": "Цикл", + "modeBounce": "Туда-обратно", + "modeOnce": "Один раз", + "speed": "Скорость анимации {name}: {speed}x", + "minFor": "минимум для {name}", + "maxFor": "максимум для {name}", + "editBound": "Изменить {label} (сейчас {value})", + "editBoundTitle": "Нажмите, чтобы изменить {label}", + "sliderAria": "Параметр {name}, текущее значение {value}, диапазон от {min} до {max}", + "valueAria": "{name} равно {value}", + "panelLabel": "Ползунки параметров", + "detected": "{count, plural, one {обнаружен # параметр} few {обнаружено # параметра} many {обнаружено # параметров} other {обнаружено # параметра}}" } }, "chaos": { @@ -1463,5 +1541,14 @@ "complexNumbers": "Комплексные числа", "complexNumbersSubtitle": "Диаграмма Аргана и формы" } + }, + "gpuLab": { + "title": "GPU Lab", + "description": "Публичная галерея интерактивных GPU-симуляций: уравнения теплопроводности и волны, аттрактор Лоренца и поля направлений из рабочих листов сообщества.", + "empty": "Опубликованных симуляций пока нет. Добавьте ячейку симуляции в рабочий лист и опубликуйте его.", + "openWorksheet": "Открыть рабочий лист", + "by": "автор: {name}", + "views": "{count, plural, one {# просмотр} few {# просмотра} many {# просмотров} other {# просмотра}}", + "updated": "Обновлено {time}" } } diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json index 42bf1a9e..6ebf9107 100644 --- a/apps/web/messages/uk.json +++ b/apps/web/messages/uk.json @@ -59,7 +59,8 @@ "algorithmsMenu": "Меню алгоритмів", "home": "NextCalc Pro - Головна", "templates": "Шаблони", - "templatesDescription": "Готові шаблони обчислень" + "templatesDescription": "Готові шаблони обчислень", + "gpuLab": "GPU Lab" }, "common": { "language": "Мова", @@ -479,7 +480,61 @@ "cellType": { "code": "Код", "markdown": "Markdown" - } + }, + "simulation": { + "label": "Симуляція", + "addCell": "Додати комірку симуляції", + "heat": "Тепло", + "wave": "Хвиля", + "laplace": "Лаплас", + "lorenz": "Лоренц", + "directionField": "Поле напрямків", + "preset": "Пресет", + "parameters": "Параметри", + "play": "Пуск", + "pause": "Пауза", + "reset": "Скинути", + "dismiss": "Приховати", + "webgpuFallback": "WebGPU недоступний у цьому браузері — використовується резервний рендерер WebGL.", + "noGpu": "Для цієї симуляції потрібне GPU-прискорення (WebGPU або WebGL 2), яке недоступне в цьому браузері.", + "unknownKind": "Невідомий тип симуляції «{kind}» — цю комірку створено в новішій версії NextCalc.", + "params": { + "alpha": "Коефіцієнт дифузії α", + "waveSpeed": "Швидкість хвилі c", + "relaxation": "Релаксація α", + "sigma": "Сигма σ", + "rho": "Ро ρ", + "beta": "Бета β", + "steps": "Кроки траєкторії", + "preyGrowth": "Приріст жертв a", + "predatorDeath": "Загибель хижаків b", + "mu": "Демпфування μ", + "spiralDamping": "Згасання спіралі α", + "damping": "Згасання γ" + }, + "presets": { + "center": "Центр", + "line": "Лінія", + "corners": "Кути", + "ring": "Кільце", + "cross": "Хрест", + "gaussian": "Гауссіани", + "random": "Випадково", + "doubleGaussian": "Подвійна гауссіана", + "sawtooth": "Пилка", + "squarePulse": "Прямокутний імпульс", + "sinc": "Sinc", + "classic": "Класика", + "lotka-volterra": "Лотка–Вольтерра", + "van-der-pol": "Ван дер Поль", + "stable-spiral": "Стійка спіраль", + "pendulum": "Маятник" + } + }, + "publish": "Опублікувати", + "unpublish": "Зняти з публікації", + "published": "Опубліковано", + "publishHint": "Увійдіть і зачекайте на автозбереження аркуша, щоб опублікувати його в галереї GPU Lab." }, "settings": { "title": "Налаштування", @@ -1056,6 +1111,29 @@ "2dPolar": "2D Polar", "2dParametric": "2D Parametric", "3dSurface": "3D Surface" + }, + "sliders": { + "parameters": "Параметри", + "dragToAdjust": "перетягніть для налаштування", + "min": "мін", + "max": "макс", + "value": "значення", + "editRangeHint": "Натисніть на підписи мін/макс, щоб змінити діапазон.", + "play": "Запустити анімацію {name}", + "pause": "Призупинити анімацію {name}", + "animationMode": "Режим анімації {name}", + "modeLoop": "Цикл", + "modeBounce": "Туди-назад", + "modeOnce": "Один раз", + "speed": "Швидкість анімації {name}: {speed}x", + "minFor": "мінімум для {name}", + "maxFor": "максимум для {name}", + "editBound": "Змінити {label} (зараз {value})", + "editBoundTitle": "Натисніть, щоб змінити {label}", + "sliderAria": "Параметр {name}, поточне значення {value}, діапазон від {min} до {max}", + "valueAria": "{name} дорівнює {value}", + "panelLabel": "Повзунки параметрів", + "detected": "{count, plural, one {виявлено # параметр} few {виявлено # параметри} many {виявлено # параметрів} other {виявлено # параметра}}" } }, "chaos": { @@ -1463,5 +1541,14 @@ "complexNumbers": "Комплексні числа", "complexNumbersSubtitle": "Діаграма Арганда та форми" } + }, + "gpuLab": { + "title": "GPU Lab", + "description": "Публічна галерея інтерактивних GPU-симуляцій: рівняння теплопровідності та хвилі, атрактор Лоренца й поля напрямків із робочих аркушів спільноти.", + "empty": "Опублікованих симуляцій поки немає. Додайте комірку симуляції до робочого аркуша й опублікуйте його.", + "openWorksheet": "Відкрити робочий аркуш", + "by": "автор: {name}", + "views": "{count, plural, one {# перегляд} few {# перегляди} many {# переглядів} other {# перегляду}}", + "updated": "Оновлено {time}" } } diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index 2531a3af..25d0a30d 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -61,7 +61,8 @@ "templates": "模板", "templatesDescription": "即用型计算模板", "worksheets": "我的工作表", - "worksheetsDescription": "查看和管理您保存的工作表" + "worksheetsDescription": "查看和管理您保存的工作表", + "gpuLab": "GPU Lab" }, "common": { "language": "语言", @@ -512,7 +513,61 @@ "cellType": { "code": "代码", "markdown": "Markdown" - } + }, + "simulation": { + "label": "仿真", + "addCell": "添加仿真单元格", + "heat": "热传导", + "wave": "波动", + "laplace": "拉普拉斯", + "lorenz": "洛伦兹", + "directionField": "方向场", + "preset": "预设", + "parameters": "参数", + "play": "播放", + "pause": "暂停", + "reset": "重置", + "dismiss": "关闭", + "webgpuFallback": "此浏览器不支持 WebGPU,正在使用 WebGL 后备渲染器。", + "noGpu": "此仿真需要 GPU 加速(WebGPU 或 WebGL 2),但此浏览器不支持。", + "unknownKind": "未知的仿真类型“{kind}”——此单元格由更新版本的 NextCalc 创建。", + "params": { + "alpha": "扩散系数 α", + "waveSpeed": "波速 c", + "relaxation": "松弛系数 α", + "sigma": "西格玛 σ", + "rho": "洛 ρ", + "beta": "贝塔 β", + "steps": "轨迹步数", + "preyGrowth": "猎物增长率 a", + "predatorDeath": "捕食者死亡率 b", + "mu": "阻尼 μ", + "spiralDamping": "螺旋阻尼 α", + "damping": "阻尼 γ" + }, + "presets": { + "center": "中心", + "line": "直线", + "corners": "四角", + "ring": "圆环", + "cross": "十字", + "gaussian": "高斯峰", + "random": "随机", + "doubleGaussian": "双高斯", + "sawtooth": "锯齿波", + "squarePulse": "方波脉冲", + "sinc": "Sinc", + "classic": "经典", + "lotka-volterra": "洛特卡-沃尔泰拉", + "van-der-pol": "范德波尔", + "stable-spiral": "稳定螺旋", + "pendulum": "单摆" + } + }, + "publish": "发布", + "unpublish": "取消发布", + "published": "已发布", + "publishHint": "请先登录并等待工作表自动保存,然后才能发布到 GPU Lab 画廊。" }, "settings": { "title": "设置", @@ -1089,6 +1144,29 @@ "2dPolar": "2D 极坐标", "2dParametric": "2D 参数化", "3dSurface": "3D 曲面" + }, + "sliders": { + "parameters": "参数", + "dragToAdjust": "拖动调整", + "min": "最小", + "max": "最大", + "value": "数值", + "editRangeHint": "点击最小/最大标签可编辑范围。", + "play": "播放 {name} 的动画", + "pause": "暂停 {name} 的动画", + "animationMode": "{name} 的动画模式", + "modeLoop": "循环", + "modeBounce": "往返", + "modeOnce": "单次", + "speed": "{name} 的动画速度:{speed}x", + "minFor": "{name} 的最小值", + "maxFor": "{name} 的最大值", + "editBound": "编辑{label}(当前 {value})", + "editBoundTitle": "点击编辑{label}", + "sliderAria": "参数 {name},当前值 {value},范围 {min} 到 {max}", + "valueAria": "{name} 等于 {value}", + "panelLabel": "参数滑块", + "detected": "{count, plural, other {检测到 # 个参数}}" } }, "chaos": { @@ -1496,5 +1574,14 @@ "complexNumbers": "复数", "complexNumbersSubtitle": "Argand 图与各种形式" } + }, + "gpuLab": { + "title": "GPU Lab", + "description": "交互式 GPU 仿真的公共画廊:热方程与波动方程、洛伦兹吸引子以及来自社区工作表的方向场。", + "empty": "还没有已发布的仿真。请在工作表中添加仿真单元格并发布。", + "openWorksheet": "打开工作表", + "by": "作者:{name}", + "views": "{count, plural, other {# 次浏览}}", + "updated": "更新于 {time}" } } From 545cb5a7e3dbedb648f42eb7d24a0f9b454a43b4 Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:35:35 -0500 Subject: [PATCH 09/11] fix(web): gpu-lab view-count no longer misuses aria-label on a span The wrapping the Eye icon + view count had aria-label set, but a plain span's implicit "generic" role does not support ARIA naming (biome lint/a11y/useAriaPropsSupportedByRole). Move the accessible name into a visually-hidden sr-only sibling instead of naming the generic container, and mark the visible count aria-hidden so screen readers read the sr-only text exactly once. Co-Authored-By: Claude Fable 5 --- apps/web/app/[locale]/gpu-lab/page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/app/[locale]/gpu-lab/page.tsx b/apps/web/app/[locale]/gpu-lab/page.tsx index 330c56fd..ac553b88 100644 --- a/apps/web/app/[locale]/gpu-lab/page.tsx +++ b/apps/web/app/[locale]/gpu-lab/page.tsx @@ -168,9 +168,10 @@ function GalleryCard({
{byLabel(user.name ?? '—')} - + {updatedLabel} From ff1b0474008d3739e9bccbe2244301f9ead74562 Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:37:36 -0500 Subject: [PATCH 10/11] test(web): cover GPU Lab registry, simulation cell, slider animation, gallery filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills the test gap on the GPU Lab feature branch — all four Wave 1 review items get real vitest coverage instead of shipping untested: - lib/simulation/registry.ts: structural invariants over SIM_REGISTRY and DIRECTION_FIELD_PRESETS (min<=default<=max, presets/params resolve real en.json label keys, direction-field f/g never reference an undeclared param key, getSimParams/DEFAULT_PARAMS/DEFAULT_PRESET behavior). - components/worksheet/simulation-cell.tsx: renders the right sliders per SIM_REGISTRY entry, propagates a slider change through the real worksheet store into the mounted (mocked) GPU renderer, atomic preset+params reset on direction-field preset switch, hides the preset select for single-preset kinds, and the unknown-sim-kind fallback renders instead of crashing. GPU renderers, ui/slider, and detectBestBackend are mocked — jsdom has no WebGPU/WebGL. - components/plot/variable-sliders.tsx: extracted the rAF tick's per-frame math into a pure, exported `stepSliderValue()` (loop wrap / bounce-and-flip / once-and-finish) so the animation behavior is covered without driving a fake requestAnimationFrame loop through a real component. No behavior change — the component's tick effect event now just calls the extracted function. - app/[locale]/gpu-lab/page.tsx: extracted the "only show worksheets with a simulation cell" rule into lib/simulation/gallery.ts (isSimulationCellData / simKindsOf / filterGalleryWorksheets) so it's unit-testable without a database or RSC rendering. Co-Authored-By: Claude Fable 5 --- .../__tests__/lib/simulation/gallery.test.ts | 114 +++++++++ .../__tests__/lib/simulation/registry.test.ts | 164 +++++++++++++ apps/web/app/[locale]/gpu-lab/page.tsx | 33 +-- .../components/plot/variable-sliders.test.ts | 151 ++++++++++++ apps/web/components/plot/variable-sliders.tsx | 134 ++++++++--- .../worksheet/simulation-cell.test.tsx | 216 ++++++++++++++++++ apps/web/lib/simulation/gallery.ts | 52 +++++ 7 files changed, 797 insertions(+), 67 deletions(-) create mode 100644 apps/web/__tests__/lib/simulation/gallery.test.ts create mode 100644 apps/web/__tests__/lib/simulation/registry.test.ts create mode 100644 apps/web/components/plot/variable-sliders.test.ts create mode 100644 apps/web/components/worksheet/simulation-cell.test.tsx create mode 100644 apps/web/lib/simulation/gallery.ts 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..2f33916f --- /dev/null +++ b/apps/web/__tests__/lib/simulation/registry.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_PARAMS, + DEFAULT_PRESET, + DIRECTION_FIELD_PRESETS, + getSimParams, + isSimulationKind, + SIM_REGISTRY, + SIMULATION_KINDS, +} 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]); + }); + }); +}); diff --git a/apps/web/app/[locale]/gpu-lab/page.tsx b/apps/web/app/[locale]/gpu-lab/page.tsx index ac553b88..ba044526 100644 --- a/apps/web/app/[locale]/gpu-lab/page.tsx +++ b/apps/web/app/[locale]/gpu-lab/page.tsx @@ -22,6 +22,7 @@ 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'; // --------------------------------------------------------------------------- @@ -43,34 +44,6 @@ interface GalleryWorksheet { user: FragmentType; } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Narrow an unknown cell JSON blob to a simulation cell shape. */ -function isSimulationCellData(cell: unknown): cell is { kind: 'simulation'; sim: string } { - return ( - typeof cell === 'object' && - cell !== null && - 'kind' in cell && - cell.kind === 'simulation' && - 'sim' in cell && - typeof cell.sim === 'string' - ); -} - -/** Unique simulation kinds present in a worksheet's content JSON. */ -function simKindsOf(content: unknown): string[] { - const cells: readonly unknown[] = Array.isArray(content) ? content : []; - const kinds = new Set(); - for (const cell of cells) { - if (isSimulationCellData(cell)) { - kinds.add(cell.sim); - } - } - return [...kinds]; -} - // --------------------------------------------------------------------------- // Data fetching // --------------------------------------------------------------------------- @@ -194,9 +167,7 @@ export default async function GpuLabPage({ params }: PageProps) { ]); // Only worksheets that actually contain a simulation cell belong here - const galleryItems = worksheets - .map((worksheet) => ({ worksheet, simKinds: simKindsOf(worksheet.content) })) - .filter(({ simKinds }) => simKinds.length > 0); + const galleryItems = filterGalleryWorksheets(worksheets); return (
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 dd4891e5..09bf1c7a 100644 --- a/apps/web/components/plot/variable-sliders.tsx +++ b/apps/web/components/plot/variable-sliders.tsx @@ -81,10 +81,10 @@ const DEFAULT_MAX = 10; const DEFAULT_VALUE = 1; /** Time for one full min→max sweep at 1× speed. */ -const SWEEP_DURATION_MS = 4000; +export const SWEEP_DURATION_MS = 4000; /** Speed multipliers cycled by the speed button. */ -const SPEED_STEPS = [0.5, 1, 2, 4] as const; +export const SPEED_STEPS = [0.5, 1, 2, 4] as const; // --------------------------------------------------------------------------- // Types @@ -175,18 +175,86 @@ function detectParameters(expressions: string[]): string[] { } /** Next animation mode in the loop → bounce → once cycle. */ -function nextMode(mode: SliderAnimMode): SliderAnimMode { +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. */ -function nextSpeed(speed: number): number { +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 // --------------------------------------------------------------------------- @@ -633,41 +701,35 @@ export function VariableSliders({ expressions, onChange, className = '' }: Varia }); continue; } - const span = cfg.max - cfg.min; - if (span <= 0) continue; - - const delta = (span / SWEEP_DURATION_MS) * dt * anim.speed * anim.direction; - let value = cfg.value + delta; - - if (anim.mode === 'loop') { - if (value >= cfg.max) value = cfg.min; - } else if (anim.mode === 'bounce') { - if (value >= cfg.max) { - value = cfg.max; - setAnims((prev) => { - const a = prev[name]; - return a ? { ...prev, [name]: { ...a, direction: -1 } } : prev; - }); - } else if (value <= cfg.min) { - value = cfg.min; - setAnims((prev) => { - const a = prev[name]; - return a ? { ...prev, [name]: { ...a, direction: 1 } } : prev; - }); - } - } else { - // 'once' — stop exactly at max and clear the animation entry - if (value >= cfg.max) { - value = cfg.max; - setAnims((prev) => { - const { [name]: _removed, ...rest } = prev; - return rest; - }); - } + 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, value); + handleValueChange(name, result.value); } }); 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/lib/simulation/gallery.ts b/apps/web/lib/simulation/gallery.ts new file mode 100644 index 00000000..34a874e5 --- /dev/null +++ b/apps/web/lib/simulation/gallery.ts @@ -0,0 +1,52 @@ +/** + * GPU Lab gallery filtering. + * + * Extracted from app/[locale]/gpu-lab/page.tsx so the "only show worksheets + * that actually contain a simulation cell" rule is a plain, unit-testable + * function rather than logic inlined in the RSC page body. + * + * @module lib/simulation/gallery + */ + +/** Narrow an unknown cell JSON blob to a simulation cell shape. */ +export function isSimulationCellData(cell: unknown): cell is { kind: 'simulation'; sim: string } { + return ( + typeof cell === 'object' && + cell !== null && + 'kind' in cell && + cell.kind === 'simulation' && + 'sim' in cell && + typeof cell.sim === 'string' + ); +} + +/** Unique simulation kinds present in a worksheet's content JSON. */ +export function simKindsOf(content: unknown): string[] { + const cells: readonly unknown[] = Array.isArray(content) ? content : []; + const kinds = new Set(); + for (const cell of cells) { + if (isSimulationCellData(cell)) { + kinds.add(cell.sim); + } + } + return [...kinds]; +} + +/** A worksheet paired with the distinct simulation kinds it contains. */ +export interface GalleryItem { + worksheet: T; + simKinds: string[]; +} + +/** + * Only worksheets that contain at least one simulation cell belong in the + * public GPU Lab gallery — text-only or plot-only worksheets are filtered out + * even if they happen to be PUBLIC. + */ +export function filterGalleryWorksheets( + worksheets: readonly T[], +): GalleryItem[] { + return worksheets + .map((worksheet) => ({ worksheet, simKinds: simKindsOf(worksheet.content) })) + .filter((item) => item.simKinds.length > 0); +} From a9d274bdd0af8444f4e34a7e41b95daed17aaf84 Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:31:28 -0500 Subject: [PATCH 11/11] fix(gpu-lab): clamp stored simulation params to their spec bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worksheet content is untrusted input — forked/public gallery worksheets arrive as author-controlled JSON, and the sliders only clamp values written locally. A crafted lorenz `steps` (e.g. 1e9) previously drove an unbounded CPU trajectory loop on open; NaN/out-of-range params reached the GPU renderers unchecked. sanitizeSimParams() clamps every declared spec key to [min, max] and replaces non-finite values with the spec default before any compute or render. Co-Authored-By: Claude Fable 5 --- .../__tests__/lib/simulation/registry.test.ts | 51 +++++++++++++++++++ .../components/worksheet/simulation-cell.tsx | 23 ++++++--- apps/web/lib/simulation/registry.ts | 23 +++++++++ 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/apps/web/__tests__/lib/simulation/registry.test.ts b/apps/web/__tests__/lib/simulation/registry.test.ts index 2f33916f..d932ea9e 100644 --- a/apps/web/__tests__/lib/simulation/registry.test.ts +++ b/apps/web/__tests__/lib/simulation/registry.test.ts @@ -7,6 +7,7 @@ import { isSimulationKind, SIM_REGISTRY, SIMULATION_KINDS, + sanitizeSimParams, } from '@/lib/simulation/registry'; import en from '@/messages/en.json'; @@ -161,4 +162,54 @@ describe('SIM_REGISTRY', () => { 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/components/worksheet/simulation-cell.tsx b/apps/web/components/worksheet/simulation-cell.tsx index c38c6d78..5807fb20 100644 --- a/apps/web/components/worksheet/simulation-cell.tsx +++ b/apps/web/components/worksheet/simulation-cell.tsx @@ -60,6 +60,7 @@ import { 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'; @@ -211,8 +212,15 @@ export function SimulationCellContent({ cell, cellIndex }: SimulationCellContent } // ---- 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(deferredParams) : null; + const lorenzData = + cell.sim === 'lorenz' + ? computeLorenzTrajectory(sanitizeSimParams('lorenz', cell.preset, deferredParams)) + : null; // ---- Handlers (React Compiler ON — no manual memoization) ---- @@ -259,6 +267,7 @@ export function SimulationCellContent({ cell, cellIndex }: SimulationCellContent 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 = @@ -341,7 +350,7 @@ export function SimulationCellContent({ cell, cellIndex }: SimulationCellContent > {t('parameters')} {paramSpecs.map((spec) => { - const value = cell.params[spec.key] ?? spec.default; + const value = safeParams[spec.key] ?? spec.default; return (
)} @@ -440,12 +449,12 @@ export function SimulationCellContent({ cell, cellIndex }: SimulationCellContent xMax={dfPreset.xMax} yMin={dfPreset.yMin} yMax={dfPreset.yMax} - f={(x, y) => dfPreset.f(x, y, cell.params)} - g={(x, y) => dfPreset.g(x, y, cell.params)} + f={(x, y) => dfPreset.f(x, y, safeParams)} + g={(x, y) => dfPreset.g(x, y, safeParams)} equationType={dfPreset.equationType} params={[ - cell.params['param0'] ?? dfPreset.params[0]?.default ?? 0, - cell.params['param1'] ?? dfPreset.params[1]?.default ?? 0, + safeParams['param0'] ?? dfPreset.params[0]?.default ?? 0, + safeParams['param1'] ?? dfPreset.params[1]?.default ?? 0, ]} gridN={48} opacity={0.85} diff --git a/apps/web/lib/simulation/registry.ts b/apps/web/lib/simulation/registry.ts index 6c1323aa..c545aad9 100644 --- a/apps/web/lib/simulation/registry.ts +++ b/apps/web/lib/simulation/registry.ts @@ -246,6 +246,29 @@ export function getSimParams(kind: SimulationKind, preset: string): readonly Sim return SIM_REGISTRY[kind].params; } +/** + * Clamp a stored params map to its spec bounds. Worksheet content is + * untrusted input — forked/public gallery worksheets arrive from the server + * as author-controlled JSON, so out-of-range or non-finite values must never + * reach the renderers (e.g. a crafted `steps` would drive an unbounded + * CPU trajectory loop; the sliders only clamp values written locally). + */ +export function sanitizeSimParams( + kind: SimulationKind, + preset: string, + params: Readonly>, +): Record { + const safe: Record = {}; + for (const spec of getSimParams(kind, preset)) { + const raw = params[spec.key]; + safe[spec.key] = + typeof raw === 'number' && Number.isFinite(raw) + ? Math.min(spec.max, Math.max(spec.min, raw)) + : spec.default; + } + return safe; +} + /** Default preset name for a kind. */ export function DEFAULT_PRESET(kind: SimulationKind): string { if (kind === 'direction-field') return DEFAULT_DIRECTION_FIELD_PRESET;