diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..f77de87 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "extraKnownMarketplaces": { + "openphysics": { + "source": { + "source": "github", + "repo": "OpenPhysics/Baton" + } + } + }, + "enabledPlugins": { + "scenerystack@openphysics": true + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6f19ed0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,34 @@ +# Ensure consistent LF line endings for all text files +* text=auto eol=lf + +# Explicitly set common file types +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.mjs text eol=lf +*.json text eol=lf +*.css text eol=lf +*.html text eol=lf +*.md text eol=lf +*.svg text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + +# Git hooks (SceneryStack sims use .githooks/) +.githooks/* text eol=lf + +# Binary files (no conversion) +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.mp4 binary +*.webm binary +*.ogg binary +*.wav binary +*.woff binary +*.woff2 binary +*.ttf binary +*.wasm binary diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..8ad0e23 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,34 @@ +#!/bin/sh +# Pre-commit hook: auto-format and lint with Biome. +# Activated by: npm install (prepare script sets core.hooksPath) + +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0 + +if [ ! -x node_modules/.bin/biome ]; then + echo "pre-commit: skipping — run npm install first." + exit 0 +fi + +STAGED=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.(ts|js|json|html)$' || true) + +if [ -z "$STAGED" ]; then + exit 0 +fi + +echo "Running Biome fix on staged files..." + +npm run fix -- --no-errors-on-unmatched +STATUS=$? + +printf '%s\n' "$STAGED" | while IFS= read -r file; do + [ -n "$file" ] && git add -- "$file" +done + +if [ "$STATUS" -ne 0 ]; then + echo "" + echo "Biome reported errors that could not be auto-fixed." + echo "Please fix the issues above and try committing again." + exit 1 +fi + +exit 0 diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..0e57670 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,33 @@ +#!/bin/sh +# Pre-push hook: run lint and type-check before pushing. +# Activated by: npm install (prepare script sets core.hooksPath) + +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0 + +if [ ! -x node_modules/.bin/biome ]; then + echo "pre-push: node_modules missing — run npm install before pushing." + exit 1 +fi + +echo "Running Biome lint check..." +npm run lint +LINT_STATUS=$? + +if [ "$LINT_STATUS" -ne 0 ]; then + echo "" + echo "Lint errors found. Run 'npm run fix' to auto-fix, then commit and push again." + exit 1 +fi + +echo "Running TypeScript type check..." +npm run check +TSC_STATUS=$? + +if [ "$TSC_STATUS" -ne 0 ]; then + echo "" + echo "TypeScript errors found. Fix the errors above before pushing." + exit 1 +fi + +echo "All checks passed." +exit 0 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..feb0440 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default owners for OpenPhysics simulations +* @veillette diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c67ba08 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,43 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "automated" + commit-message: + prefix: "chore" + include: "scope" + # @types/node major versions track the Node.js runtime (see shared CI default). + # Bump SceneryStackTemplate and repos together when CI node-version changes. + ignore: + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + groups: + development-dependencies: + dependency-type: "development" + update-types: + - "minor" + - "patch" + production-dependencies: + dependency-type: "production" + update-types: + - "minor" + - "patch" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + labels: + - "ci/cd" + - "dependencies" + - "automated" + commit-message: + prefix: "ci" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..865de67 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + schedule: + - cron: '30 2 * * 1' + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Caller must grant the scopes the reusable workflows request (CodeQL needs +# security-events: write, dependency-review needs pull-requests: write). The repo +# default token is read-only, so without this block reusable calls fail at startup. +permissions: + contents: read + actions: read + security-events: write + pull-requests: write + +jobs: + ci: + uses: OpenPhysics/Baton/.github/workflows/ci.yml@main + + compliance: + uses: OpenPhysics/Baton/.github/workflows/shared-compliance-check.yml@main + + dependency-review: + if: github.event_name == 'pull_request' + uses: OpenPhysics/Baton/.github/workflows/shared-dependency-review.yml@main + + codeql: + uses: OpenPhysics/Baton/.github/workflows/shared-codeql.yml@main diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..910486b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,17 @@ +name: Deploy + +on: + push: + branches: [main] + +concurrency: + group: pages-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + uses: OpenPhysics/Baton/.github/workflows/deploy.yml@main + permissions: + contents: read + pages: write + id-token: write diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac34e85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dev-dist +dist-ssr +.vite +*.local +tsconfig.tsbuildinfo + +# Environment +.env +.env.local +.env.*.local + +# Test output +coverage/ +playwright-report/ +test-results/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +Thumbs.db +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Claude Code - local settings excluded, hooks tracked +.claude/settings.local.json + +# CI / local npm audit artifact (must not be linted) +audit.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bbb1b35 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,182 @@ +# CLAUDE.md — Heat Transfer + +Sim-specific context for AI assistants. General SceneryStack guidance: +[OpenPhysics/.github/CLAUDE.md](https://github.com/OpenPhysics/.github/blob/main/CLAUDE.md). + +## What this sim is + +Heat as a field, on a WebGPU field engine. The governing equation is + +``` +ρc_p ∂T/∂t + ρc_p (v · ∇T) = ∇ · (k ∇T) +``` + +with terms switched on screen by screen. Physics and numerics: +[`doc/model.md`](doc/model.md). Architecture and the reasoning behind it: +[`doc/implementation-notes.md`](doc/implementation-notes.md). **Read +implementation-notes before changing anything under `src/common/field/`.** + +## The one rule to keep + +> The temperature field is GPU-native data, not a Scenery-rendered image that +> happens to contain a field. + +Concretely, three invariants are worth defending: + +1. **Nothing outside `SimulationDomain` knows the grid size.** The `FieldEngine` + interface speaks unit-square coordinates and SI units, never cells. If you find + yourself passing an `i, j` across that boundary, something has gone wrong. +2. **One Scenery node for the field, at any resolution.** `FieldNode` is the whole + Scenery/WebGPU boundary. Do not add per-cell nodes. +3. **`kernels.ts` and the WGSL compute shaders are the same algorithm twice.** + Change one and you must change the other; the function names are deliberately + paired (`fetchCell`/`fetchScalar`, `bilinearSample`/`bilinearScalar`) so the + correspondence is greppable. The unit tests pin down `kernels.ts` and therefore + constrain the shaders. + +## Key files + +| File | Purpose | +|---|---| +| `src/HeatTransferColors.ts` | All `ProfileColorProperty` instances, including the field overlays | +| `src/HeatTransferConstants.ts` | Named numeric constants (layout px, physics SI units) | +| `src/HeatTransferNamespace.ts` | Namespace for color property names | +| `src/i18n/StringManager.ts` | Singleton localized string accessor | +| `src/preferences/` | Grid resolution, field-status readout, query parameters | +| **Field engine** | | +| `src/common/field/SimulationDomain.ts` | The only place that knows the grid size | +| `src/common/field/FieldEngine.ts` | The interface the model talks to | +| `src/common/field/FieldEngineBase.ts` | Sampling, strokes, materials — everything both backends share | +| `src/common/field/kernels.ts` | The reference numerics, in plain TypeScript | +| `src/common/field/ColorMap.ts` | One ramp, generated into both TS and WGSL | +| `src/common/field/VelocityPresets.ts` | Analytic divergence-free flows | +| `src/common/field/cpu/` | Reference backend + 2-D canvas renderer | +| `src/common/field/gpu/` | WebGPU backend, device acquisition, WGSL sources | +| `src/common/field/gpu/webgpu-globals.d.ts` | The flag namespaces TS 7's DOM lib omits | +| **Model / view** | | +| `src/common/model/FieldSimulationModel.ts` | The model every screen composes | +| `src/common/view/FieldNode.ts` | The Scenery/WebGPU boundary | +| `src/common/view/FieldScreenView.ts` | Shared layout and frame loop | +| `src/common/view/ControlFactory.ts` | The sim's themed control vocabulary | +| `src/common/HeatTransferScreenIcons.ts` | Programmatic screen icons | +| **Screens** | `src/{temperature,conduction,convection,combined,materials}/` | + +## Screens + +| Folder | Class prefix | Adds | +|---|---|---| +| `temperature/` | `Temperature` | The bare field: paint, look, probe | +| `conduction/` | `Conduction` | Materials, edges, flux arrows, cross-section graph | +| `convection/` | `Convection` | The velocity field and tracer particles | +| `combined/` | `HeatTransfer` | Transport balance and the Péclet number | +| `materials/` | `Materials` | A paintable material field and anisotropy | + +The combined screen's folder is `combined/` while its classes are `HeatTransfer*`, +so that `HeatTransferScreen` does not collide with the sim-level `HeatTransfer*` +files at `src/` root. + +## Things that will bite you + +- **Backend selection is asynchronous exactly once.** `initializeGpuContext()` runs + in `main.ts` before `sim.start()`. Everything after it is synchronous, because + SceneryStack builds a screen's model lazily and synchronously. Do not make + `createFieldEngine` async. +- **Explicit bind group layouts, never `layout: "auto"`.** 32-bit float textures + can only be bound as `unfilterable-float`; an inferred layout asks for a + filterable float and fails on most hardware. +- **Uniform block sizes are hand-computed.** The `*_PARAMS_BYTES` constants beside + each shader must match the WGSL struct's actual size under `vec4`'s 16-byte + alignment. Getting this wrong produces garbage, not an error. +- **`FieldScreenView` sets `pdomOrder` on a wrapper node.** `ScreenView` throws if + you set it on itself, and a node listed twice throws too — the probe checkbox is + already inside `layerPanel.checkboxes`. +- **The temperature ramp is not themed.** Overlay colours follow the colour profile + via `FieldRenderStyle`; the ramp itself must not, or the legend would lie. +- **Node 24 is the fleet version.** `npm install` on Node 22 warns about + `engines.node` but works; CI uses 24. + +## Accessibility + +The three required layers are wired up: PDOM names from the `a11y` string group, +a `*ScreenSummaryContent` per screen whose `currentDetailsContent` derives live +from the field's min/max temperature, and an explicit `pdomOrder` plus a +`*KeyboardHelpContent` per screen. + +The field itself needed something beyond the standard controls, since it is a +continuous canvas rather than a slider or a draggable object: it has a **paint +cursor** (arrow keys to move, shift for finer steps, space or enter to paint), +documented by `HeatBrushKeyboardHelpSection`. Keep that working when touching +`FieldNode`. + +Full convention: +[Baton/ACCESSIBILITY.md](https://github.com/OpenPhysics/Baton/blob/main/ACCESSIBILITY.md). + +## Compliance carve-outs + +- **`src/common/field/gpu/webgpu-globals.d.ts`** — an ambient declaration file + under `src/`. TypeScript 7's `lib.dom.d.ts` ships the WebGPU interfaces but not + the `GPUBufferUsage` / `GPUTextureUsage` / `GPUShaderStage` flag namespaces. + Declaring those here is cheaper and less fragile than adding `@webgpu/types`, + which would redeclare all 107 interfaces and collide with the built-in ones. + Delete it when a TypeScript release fills the gap — `npm run check` will say so. + Note it deliberately does *not* add a `getContext("webgpu")` overload: augmenting + `HTMLCanvasElement` puts the new overload ahead of the built-ins in resolution + order and breaks generic `getContext(id, ...args)` forwarding, including the + fleet's own `tests/setup.ts` canvas mock. `requestCanvasContext` in + `WebGpuSupport.ts` does that cast in one place instead. +- **`tsconfig.test.json` lists one extra path.** Ambient declarations are not + inherited through `extends`, and the tests import src modules that use the + WebGPU flag namespaces, so the test project includes + `src/common/field/gpu/webgpu-globals.d.ts` alongside `tests`. +- **`rgbToCss` in `ColorMap.ts`** — flagged by the compliance scan as a possible + hardcoded colour because it builds an `rgb(...)` string. It is a *format* helper, + not a palette: the components it is handed come either from the temperature ramp + (a quantitative encoding, deliberately not themed) or from a + `ProfileColorProperty` by way of `FieldRenderStyle`. Nothing in it chooses a + colour, and it is the single such helper in the sim. +- **No `src/common/TimeModel.ts`.** The template's composable timer does not fit: + the engine's simulated time is set by the stability-limited step, not by + accumulating `dt`, so `FieldSimulationModel` owns `isPlayingProperty` and + `timeSpeedProperty` directly and reads elapsed time from the engine. + +## Testing + +Fleet-standard Vitest layout, `happy-dom` environment, `tests/setup.ts` mocks +Canvas 2D and AudioContext. + +| Path | Covers | +|---|---| +| `tests/common/field/kernels.test.ts` | Energy conservation, stability bound, Fourier's law, harmonic-mean barriers, anisotropy, advective translation | +| `tests/common/field/SimulationDomain.test.ts` | Resolution independence | +| `tests/common/field/ColorMap.test.ts` | Ramp ordering, WGSL generation | +| `tests/common/field/VelocityPresets.test.ts` | Bounded, divergence-free flows | +| `tests/common/field/CpuFieldEngine.test.ts` | The `FieldEngine` interface end to end | +| `tests/common/model/FieldSimulationModel.test.ts` | Property wiring, reset, Péclet, CPU clamping | +| `tests/memory-leak.test.ts` | Engines and models collected after `dispose()` | + +WebGPU is unavailable under Vitest, so the suites cover the CPU backend. When +changing the shaders, verify them against the CPU backend **in a browser** — run +both engines from the same initial condition and compare mean temperature, which +should agree to ~6 significant figures. See implementation-notes §"The two +backends". + +## Commands + +```bash +npm run lint && npm run check && npm run build && npm test +``` + +| Command | Description | +|---|---| +| `npm start` / `npm run dev` | Vite dev server | +| `npm run build` | Type-check + production build | +| `npm run build:single` | Single-file build mode | +| `npm run check` | TypeScript (app, scripts, tests) | +| `npm run lint` / `npm run fix` | Biome check / auto-fix | +| `npm test` | Vitest unit tests | +| `npm run test:fuzz` | Playwright fuzz smoke | +| `npm run icons` | Regenerate PWA icons | + +Useful while developing: `?forceCpu=true` to compare backends on one machine, +`?resolution=large` to check the field engine at 1024², `?screens=N` to open one +screen directly. diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 0000000..6c59172 --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,12 @@ +# Credits — SceneryStackTemplate + +Reusable SceneryStack simulation template (one or N screens) with Vite, TypeScript, Biome, PWA support, and i18n scaffolding. + +## License + +GNU Affero General Public License v3.0 or later — see [org LICENSE](https://github.com/OpenPhysics/.github/blob/main/LICENSE). + +## Acknowledgments + +Built with [SceneryStack](https://scenerystack.org/) as part of the +[OpenPhysics](https://github.com/OpenPhysics) fleet. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4db7b80 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# Heat Transfer + +An interactive simulation of heat as a **field**, built on a WebGPU field engine. + +Temperature, heat flux, velocity, and material properties are all fields living in +GPU textures; compute shaders evolve them, render pipelines draw them, and +[SceneryStack](https://scenerystack.org/) provides the interface around them. The +same simulation runs as a 128 × 128 classroom demonstration or a 2048 × 2048 +high-resolution study — the resolution is a preference, not an assumption. + +Five screens build the subject up one field at a time: **Temperature** (T is a +field you can read at every point), **Conduction** (gradients drive flux, +q = −k∇T), **Convection** (a second field, v, carries heat with it), **Heat +Transfer** (both mechanisms, with the balance adjustable and the Péclet number +displayed), and **Materials** (k, ρ, and c_p become fields too). + +## Features + +- **GPU-native fields** — ping-pong `r32float` temperature textures evolved by + WGSL compute passes; the scene graph holds one node for the field at any grid size +- **Four grid resolutions** — 128², 512², 1024², 2048², selectable in Preferences + with no change to the model or the UI +- **A CPU reference backend** — the same physics in TypeScript, used automatically + where WebGPU is unavailable and as the oracle the WGSL shaders are written against +- **Five visualization layers** — colour-mapped temperature, antialiased isotherms, + heat-flux arrows, tracer particles, and gradient magnitude, each a render pass over + the same state rather than a separate simulation +- **Measurement tools** — a probe that samples the interpolated field, and a + draggable cross-section that graphs T(s) and q_s(s) together +- **Seven materials** spanning four decades of thermal diffusivity, paintable into + the plate to build composites, barriers, and anisotropic media +- **Full keyboard access** — including a paint cursor for the field itself — with + live screen-reader summaries of the plate's current temperature range +- English, Spanish, and French localization; default and projector colour profiles +- Progressive Web App (installable, offline-capable) + +## Quick Start + +```bash +npm install +npm run icons # generate PNG icons from public/icons/icon.svg +npm start # dev server → http://localhost:5173 +``` + +Useful query parameters: + +| Parameter | Effect | +|---|---| +| `?resolution=high` | Request a 512 × 512 grid (`classroom`, `high`, `large`, `extreme`) | +| `?forceCpu=true` | Skip WebGPU and run the CPU reference backend | +| `?showFieldStatus=false` | Hide the backend / grid-size readout under the field | +| `?screens=2` | Open a single screen directly | + +## Scripts + +| Command | Description | +|---|---| +| `npm start` / `npm run dev` | Start Vite dev server | +| `npm run build` | Type-check + production build → `dist/` | +| `npm run build:single` | Single self-contained `dist/index.html` | +| `npm run preview` | Preview the production build locally | +| `npm test` | Run Vitest unit tests (includes memory-leak suite) | +| `npm run test:fuzz` | Optional Playwright fuzz smoke (`?fuzz`, default 15s) | +| `npm run test:fuzz:quick` | Shorter fuzz smoke (10s) | +| `npm run check` | TypeScript type check (app, scripts, tests) | +| `npm run lint` | Biome lint check | +| `npm run format` | Auto-format all files | +| `npm run fix` | Lint + auto-fix | +| `npm run icons` | Regenerate PNG icons from `public/icons/icon.svg` | +| `npm run clean` | Remove `dist/` | + +## Tech Stack + +| Tool | Version | Purpose | +|---|---|---| +| [SceneryStack](https://scenerystack.org/) | ^3 | Simulation framework (PhET-derived) | +| WebGPU | — | Field storage, compute, and rendering | +| [Vite](https://vite.dev/) | ^8 | Build tool and dev server | +| [TypeScript](https://www.typescriptlang.org/) | ^7 | `erasableSyntaxOnly`, `verbatimModuleSyntax` | +| [Biome](https://biomejs.dev/) | ^2.5 | Linting and formatting | +| [Vitest](https://vitest.dev/) | ^4 | Unit tests (happy-dom) | +| [vite-plugin-pwa](https://vite-pwa-org.netlify.app/) | ^1 | PWA / offline / installable | + +The physics and numerics are documented in [`doc/model.md`](doc/model.md); the +architecture in [`doc/implementation-notes.md`](doc/implementation-notes.md). + +## License + +[AGPL-3.0-or-later](https://github.com/OpenPhysics/.github/blob/main/LICENSE), the +OpenPhysics organization default. + +## Contributing + +See [CONTRIBUTING.md](https://github.com/OpenPhysics/.github/blob/main/CONTRIBUTING.md) +in the OpenPhysics organization defaults. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3d1cb16 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Supported versions + +Security fixes are applied to the default branch (`main`) of active OpenPhysics +repositories listed in [structure/repos.json](https://github.com/OpenPhysics/Baton/blob/main/structure/repos.json). + +## Reporting a vulnerability + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, use [GitHub Security Advisories](https://github.com/advisories) on the +affected repository: + +1. Open the repository on GitHub. +2. Go to **Security** → **Report a vulnerability**. +3. Submit a private advisory with steps to reproduce and impact. + +If you cannot use GitHub Security Advisories for a given repository, open a +private report via the OpenPhysics organization contact channels. + +We aim to acknowledge reports within a reasonable timeframe and will coordinate +disclosure once a fix is available. diff --git a/Test b/Test deleted file mode 100644 index 9daeafb..0000000 --- a/Test +++ /dev/null @@ -1 +0,0 @@ -test diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..9877d90 --- /dev/null +++ b/biome.json @@ -0,0 +1,207 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.6/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true, + "defaultBranch": "main" + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "nursery": { + "useExplicitType": "warn" + }, + "correctness": { + "preset": "recommended", + "noUnusedImports": "error", + "noUnusedVariables": "error", + "noUnusedPrivateClassMembers": "error", + "noUndeclaredVariables": "error", + "noUnreachable": "error", + "noInvalidUseBeforeDeclaration": "error" + }, + "style": { + "preset": "recommended", + "noDefaultExport": "off", + "noParameterAssign": "error", + "useBlockStatements": "error", + "useCollapsedElseIf": "warn", + "useConsistentBuiltinInstantiation": "error", + "useDefaultParameterLast": "error", + "useExplicitLengthCheck": "warn", + "useForOf": "warn", + "useImportType": "error", + "useShorthandAssign": "warn", + "useShorthandFunctionType": "warn", + "useThrowNewError": "error", + "useThrowOnlyError": "error", + "noCommonJs": "error", + "noExportedImports": "warn", + "useNamingConvention": { + "level": "warn", + "options": { + "strictCase": false, + "conventions": [ + { + "selector": { + "kind": "variable" + }, + "formats": ["camelCase", "CONSTANT_CASE", "PascalCase"] + }, + { + "selector": { + "kind": "function" + }, + "formats": ["camelCase", "PascalCase"] + }, + { + "selector": { + "kind": "typeLike" + }, + "formats": ["PascalCase"] + }, + { + "selector": { + "kind": "enumMember" + }, + "formats": ["CONSTANT_CASE", "PascalCase"] + }, + { + "selector": { + "kind": "objectLiteralProperty" + }, + "formats": ["camelCase", "CONSTANT_CASE", "PascalCase"] + } + ] + } + }, + "useFilenamingConvention": { + "level": "warn", + "options": { + "requireAscii": true, + "strictCase": false, + "filenameCases": ["PascalCase", "camelCase", "kebab-case"] + } + }, + "useSpreadOverApply": "warn" + }, + "suspicious": { + "preset": "recommended", + "noConsole": "warn", + "noEmptyBlockStatements": "error", + "noExplicitAny": "error", + "useAwait": "error", + "useErrorMessage": "error", + "useGuardForIn": "error", + "useIsArray": "error", + "noIrregularWhitespace": "off", + "noShadow": "error", + "noEqualsToNull": "error", + "noReturnAssign": "error", + "useArraySortCompare": "error" + }, + "complexity": { + "preset": "recommended", + "useLiteralKeys": "off", + "noExcessiveCognitiveComplexity": { + "level": "warn", + "options": { + "maxAllowedComplexity": 25 + } + }, + "noForEach": "warn", + "noStaticOnlyClass": "error", + "noUselessStringConcat": "error", + "noUselessUndefinedInitialization": "error", + "noVoid": "error", + "useArrowFunction": "warn", + "useDateNow": "error", + "useFlatMap": "warn", + "useSimplifiedLogicExpression": "warn", + "useArrayFind": "warn" + }, + "performance": { + "preset": "recommended", + "noAccumulatingSpread": "warn", + "noBarrelFile": "off", + "noDelete": "warn", + "noReExportAll": "off" + }, + "a11y": { + "preset": "recommended" + }, + "security": { + "preset": "recommended", + "noGlobalEval": "error" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120 + }, + "files": { + "includes": ["**/*.ts", "**/*.js", "**/*.json", "**/*.html", "!dist", "!node_modules", "!.claude"] + }, + "overrides": [ + { + "includes": ["src/**"], + "linter": { + "rules": { + "nursery": { + "useExplicitType": "off" + }, + "suspicious": { + "noConsole": "error" + } + } + } + }, + { + "includes": ["scripts/**"], + "linter": { + "rules": { + "nursery": { + "useExplicitType": "off" + }, + "suspicious": { + "noConsole": "off" + }, + "style": { + "noCommonJs": "off" + } + } + } + }, + { + "includes": ["tests/**"], + "linter": { + "rules": { + "nursery": { + "useExplicitType": "off" + } + } + } + } + ], + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all", + "arrowParentheses": "always", + "bracketSameLine": false + } + } +} diff --git a/doc/implementation-notes.md b/doc/implementation-notes.md new file mode 100644 index 0000000..65a0b11 --- /dev/null +++ b/doc/implementation-notes.md @@ -0,0 +1,367 @@ +# Heat Transfer — Implementation Notes + +Architecture and the reasoning behind it. For the physics, see +[`model.md`](model.md). + +## The central idea + +**The temperature field is GPU-native data, not a Scenery-rendered image that +happens to contain a field.** + +Everything else follows from that. Fields are the primary objects: a scalar +temperature field, a vector velocity field, a material field. Compute shaders +evolve them, render pipelines draw them, and Scenery provides the pedagogical +interface around them — buttons, sliders, probes, graphs, labels, accessibility, +screen navigation. + +The practical consequence is that resolution is a parameter rather than an +assumption. A 128 × 128 classroom grid and a 2048 × 2048 grid are the same code +with a different number in one constructor. Nothing above `SimulationDomain` knows +how many cells there are, and the scene graph contains exactly **one node** for +the field at every resolution. + +``` + HEAT MODEL + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + Temperature Velocity Material + field field field + │ │ │ + └───────────────┼───────────────┘ + ▼ + WebGPU compute + (advect, diffuse, brush) + │ + ┌───────────┴───────────┐ + ▼ ▼ + Updated field Derived fields + │ ∇T, q, |∇T| + └───────────┬───────────┘ + ▼ + WebGPU rendering + (colour map, isotherms, arrows, tracers) + │ + ▼ + Scenery UI layer +``` + +## Source layout + +``` +src/ + init.ts assert.ts splash.ts brand.ts main.ts bootstrap chain (never reorder) + HeatTransferColors.ts ProfileColorProperty entries + HeatTransferConstants.ts every named numeric constant + HeatTransferNamespace.ts Namespace("heat-transfer") + i18n/ StringManager + en / es / fr + preferences/ resolution, field-status readout, query parameters + common/ + field/ ← the field engine; no Scenery anywhere below here + SimulationDomain.ts the only place that knows the grid size + FieldTypes.ts boundary conditions, presets, layers, materials + Materials.ts material presets + ColorMap.ts one ramp, generated into both TS and WGSL + VelocityPresets.ts analytic divergence-free flows + kernels.ts the reference numerics, in plain TypeScript + FieldEngine.ts the interface the model talks to + FieldEngineBase.ts everything both backends do identically + createFieldEngine.ts backend selection + cpu/ reference backend + 2-D canvas renderer + gpu/ WebGPU backend, device acquisition, WGSL + model/FieldSimulationModel.ts the model every screen composes + view/ the shared Scenery layer + temperature/ conduction/ convection/ combined/ materials/ + one folder per screen: Screen, model/, view/ +``` + +## The two backends + +`FieldEngine` is an interface, and there are two implementations: + +| | `WebGpuFieldEngine` | `CpuFieldEngine` | +|---|---|---| +| Storage | GPU textures | `Float32Array` | +| Evolution | WGSL compute passes | `kernels.ts` | +| Drawing | WGSL render passes | 2-D canvas API | +| Resolution | up to 2048² | capped at 128² | +| Role | the primary path | fallback, oracle, and test subject | + +The CPU backend exists for three reasons, in order of importance: + +1. **The simulation still runs where WebGPU does not** — older browsers, locked + down machines, software rendering. +2. **It is the executable specification of the physics.** The WGSL shaders are + written to reproduce `kernels.ts` statement for statement — the function names + are deliberately aligned (`fetchCell`/`fetchScalar`, + `bilinearSample`/`bilinearScalar`) so that a change to the physics has to be + made in the same two places. The unit tests pin `kernels.ts` down, and + therefore indirectly constrain the shaders. +3. **It makes the architectural claim falsifiable.** If the model can drive an + array-of-floats backend and a texture backend without noticing the difference, + then the field abstraction really is the interface, not the GPU. + +Point 2 was checked directly during development: both engines were run in a +browser from the same initial condition, and after equivalent numbers of steps +their mean temperatures agreed to six significant figures (296.16625 K vs +296.16618 K). Two independent implementations conserving the same energy to that +precision is the strongest available evidence that the shaders say what the +kernels say. + +### The division of labour + +One rule decides what lives where: + +> **The CPU authors, the GPU evolves.** + +- The **material field** is written by user actions (uniform selection, brush + strokes), so the CPU holds the authoritative copy and the backend uploads it. +- The **velocity field** is an analytic preset, computed on the CPU and uploaded. +- The **temperature field** is genuinely GPU-owned. It is the only thing that + needs to come back. + +That is why every read-out feature — probe, legend, cross-section graph, field +statistics — is implemented **once**, in `FieldEngineBase`, against a CPU mirror +of the temperature field. There is no duplicated sampling code, and the two +backends cannot disagree about what a probe reads. + +### Reading back from the GPU + +`copyTextureToBuffer` + `mapAsync` every four frames, at most one readback in +flight. A synchronous read would stall the pipeline every frame; a probe that is a +few tens of milliseconds behind is imperceptible. Brush strokes are applied to the +mirror *immediately* as well as being dispatched to the texture — with identical +arithmetic — so painting still feels instant. + +## The WebGPU backend + +### Resources + +| Resource | Format | Notes | +|---|---|---| +| `temperature[2]` | `r32float` | ping-pong pair; the only GPU-owned state | +| `velocity` | `rg32float` | CPU-authored, read-only on the GPU | +| `material` | `rgba32float` | `(k_x, k_y, ρc_p, unused)` | +| `particles` | storage buffer | `(u, v, life, seed)` per tracer | + +### A timestep + +``` + temperature A ──advect──▶ temperature B ──diffuse──▶ temperature A ──▶ … +``` + +All `substeps` iterations are recorded into a **single command buffer**, so a +frame is one submission no matter how many substeps it takes. Brush strokes are a +further pass over the same pair, which is why painting heat is a write into the +GPU texture rather than a CPU upload of the whole field. + +### Two non-obvious constraints + +**Explicit bind group layouts, not `layout: "auto"`.** Every field texture is a +32-bit float format, which can only be bound as `unfilterable-float`. An inferred +layout asks for a filterable float and fails validation on any device that does +not advertise the optional `float32-filterable` feature — that is, most of them. +For the same reason every fetch is a `textureLoad` and bilinear interpolation is +done by hand, in both the shaders and the kernels. + +**Uniform block layouts are hand-checked.** WGSL's alignment rules put `vec4` on +16-byte boundaries; the `*_PARAMS_BYTES` constants next to each shader record the +resulting sizes so the `DataView` writes and the struct declarations cannot drift +apart. + +### Isotherms without contour tracing + +The field shader draws contours analytically: + +```wgsl +let level = temperature / interval; +let distance = abs(fract(level - 0.5) - 0.5) / max(fwidth(level), 1e-5); +let line = 1.0 - smoothstep(0.0, 1.5, distance); +``` + +`fwidth` gives the screen-space rate of change of `T/interval`, so dividing the +distance to the nearest contour by it produces a line exactly one pixel wide at +any zoom and any grid resolution, antialiased, for free. The CPU renderer cannot +do this and uses marching squares instead, with per-cell level pruning so that +most cells test zero or one level rather than all twenty. + +### Flux arrows and tracers + +Both are **instanced draws with no vertex buffer**. The arrow vertex shader reads +the temperature and material textures directly, applies Fourier's law, and lays +out a nine-vertex arrow in clip space; an arrow below the noise floor collapses to +a degenerate triangle rather than branching. The tracer vertex shader reads the +same storage buffer the particle compute pass writes, so tracer positions never +make a round trip through the CPU. + +## Backend selection + +`initializeGpuContext` runs **once**, during startup, while the splash screen is +still up. It is the only asynchronous step in the whole simulation; after it +resolves, building a field engine is an ordinary synchronous constructor call and +a screen's model factory — which SceneryStack invokes lazily and synchronously — +can just call it. + +It checks three things, and any failure demotes the simulation to the CPU backend +before a student sees anything: + +1. **A device can be acquired.** No `navigator.gpu`, no adapter, a rejected + device request. +2. **Every shader compiles.** WGSL compilation is asynchronous and + `createShaderModule` never throws, so `getCompilationInfo()` is the only + reliable way to find out. A shader that fails on one driver would otherwise + surface as a silently black canvas. +3. **The canvas can be presented.** This one was found by running the simulation + rather than by reasoning about it. The field reaches the scene graph as a + Scenery `Image` wrapping the engine's canvas, so the browser has to be able to + `drawImage` a WebGPU-backed canvas into a 2-D one. That works on hardware — but + not on every software rasterizer. Some configurations happily create a device, + compile every shader, and run compute passes *correctly* while presenting + nothing a 2-D context can read. The symptom is a completely blank field with no + error anywhere, which is strictly worse than the CPU fallback. So the check + clears a 4 × 4 canvas to red, copies it, and looks. + +## The Scenery boundary + +`FieldNode` is the whole of it: one `Image` over the engine's canvas, plus the +input that turns pointer and keyboard gestures into brush strokes in unit-square +coordinates. + +Scenery's `Image` only ever advertises the Canvas and WebGL renderers for a canvas +source — never SVG, which would have to re-encode a data URL every frame — so +compositing costs one `drawImage` per frame regardless of grid size. + +The frame loop, in `FieldScreenView.step`: + +1. advance the model, which advances the fields on the GPU +2. run the visualization passes over the new state +3. `invalidateImage()` so Scenery repaints + +Step 2 reads the overlay colours out of `HeatTransferColors` each frame and hands +them to the engine as a `FieldRenderStyle`. That is how the WGSL render passes +follow the active colour profile — including Projector Mode — without knowing that +colour profiles exist. The temperature ramp itself is deliberately *not* themed: +it is a quantitative encoding, and a legend that changed with the theme would lie. + +### Coordinates + +Three systems, converted only in `SimulationDomain` and `FieldNode`: + +| | | +|---|---| +| grid `(i, j)` | integer cell indices | +| unit `(u, v)` | normalized `[0,1]²`, origin top-left, `v` down | +| model `(x, y)` | metres | + +The `FieldEngine` interface speaks **only** unit coordinates and physical units, +never cells. That is what makes it resolution-agnostic in a way the type system +enforces. + +## The model layer + +`FieldSimulationModel` owns one engine and the reactive state that drives it. It +never touches a texture, a shader, or a typed array — it hands the engine +parameters and strokes and asks for samples. + +Each screen composes it (composition, not inheritance) and adds nothing but a +configuration: + +```ts +this.field = new FieldSimulationModel({ + advectionEnabled: true, + boundaryCondition: BoundaryCondition.PERIODIC, + defaultLayers: { temperature: true, velocity: true, … }, + initialCondition: InitialCondition.HOT_SPOT, + initialFlowPreset: FlowPreset.CHANNEL, + resolution: preferences.resolutionProperty.value, + displaySize: FIELD_VIEW_SIZE * 2, + initiallyPlaying: true, +}); +``` + +Screens differ only in which Properties they expose and what that config enables. +Nothing about the physics or the substrate is per-screen. + +The one piece of genuinely screen-local state is the Heat Transfer screen's +transport balance, which lives on `TransportControlPanel` because it is a *view* +of the two multipliers the model actually holds. That is also why that screen +overrides `reset()`. + +## Visualization layers are not simulation options + +Every layer checkbox changes which render pass runs over the current GPU state and +nothing else. Turning on heat flux does not start computing heat flux — the +gradient was always there — it starts *drawing* it. + +This is why the Heat Transfer screen groups the layer checkboxes alone in their own +panel, well away from the transport control. A student who notices that toggling +four checkboxes never disturbs the field has understood something worth +understanding, and the UI should not blur it. + +## Accessibility + +The three required layers, per +[Baton/ACCESSIBILITY.md](https://github.com/OpenPhysics/Baton/blob/main/ACCESSIBILITY.md): + +1. **PDOM names.** Every interactive node carries an `accessibleName` from the + `a11y` string group. +2. **Screen summaries.** Each `ScreenView` registers a `*ScreenSummaryContent` + whose `currentDetailsContent` is a live `DerivedProperty` over the field's + coldest and hottest points — the non-visual counterpart of watching the colours + change. +3. **Keyboard.** A wrapper `Node` carries `pdomOrder` (field first, Reset All + last); the probe and cross-section handles use `KeyboardDragListener`. + +The field itself needed something the standard controls do not cover: it is not a +slider, a combo box, or a draggable object but a continuous canvas you deposit +into. So it has a **paint cursor** — arrow keys move it, shift moves it in finer +steps, space or enter paints — shown as a crosshair whenever the field has focus, +exactly as the hover ring is shown to a pointer user. `HeatBrushKeyboardHelpSection` +documents it in the keyboard-help dialog. + +## Testing + +WebGPU is not available under Vitest, so the suites exercise the CPU kernels and +the model layer — which is the point of having written the kernels as the +reference implementation. + +| Suite | Covers | +|---|---| +| `kernels.test.ts` | energy conservation under each boundary condition, no overshoot at the stability limit, the direction of heat flow, harmonic-mean barriers, anisotropic flux, advective translation | +| `SimulationDomain.test.ts` | resolution independence: the plate stays the same size as the grid refines | +| `ColorMap.test.ts` | monotone ordering, WGSL generation matching the TS sampler | +| `VelocityPresets.test.ts` | bounded magnitude, divergence-free flows | +| `CpuFieldEngine.test.ts` | the `FieldEngine` interface end to end — written so it would pass unchanged against the GPU backend | +| `FieldSimulationModel.test.ts` | Property wiring, reset, Péclet, CPU clamping | +| `memory-leak.test.ts` | engines and models are collected after `dispose()` | + +Two tests are worth calling out as things that caught real bugs: + +- *`carries heat downstream at the flow speed`* runs to a **simulated duration** + rather than a step count, and asserts the blob travelled `speed × time`. Written + the obvious way — a fixed number of steps — it passed for the wrong reason and + then failed once periodic boundaries were introduced, because the blob had gone + all the way round. +- The energy-conservation tests compare **relative** error. The totals are on the + order of 10⁷ J/m in Float32, so an absolute tolerance either fails on round-off + or is meaningless. + +## Where this goes next + +The field engine is not heat-specific. `SimulationDomain`, `FieldEngine`, +`ColorMap`, the ping-pong compute machinery, and the render passes are a general +GPU field framework that happens to implement heat transfer first. The same +infrastructure would carry electric potential, gravitational fields, wave +propagation, concentration fields, or a real fluid solver, with the physics +confined to `kernels.ts` and the compute shaders. + +Nearer-term work, in rough order of value: + +- **Buoyancy coupling** on the plume preset, so the flow is driven by the + temperature field instead of prescribed alongside it. +- **A GPU reduction** for field statistics and the arrow scale, removing the last + dependency of the render path on the CPU mirror. +- **Convective and radiative loss** as boundary options, which the boundary + machinery is already shaped to accept. +- **A full conductivity tensor** rather than a diagonal one, giving flux that + bends in a direction unrelated to either axis. diff --git a/doc/model.md b/doc/model.md new file mode 100644 index 0000000..00b7961 --- /dev/null +++ b/doc/model.md @@ -0,0 +1,250 @@ +# Heat Transfer — Model + +The physics, the numerics, and the choices made in turning one into the other. + +## 1. The governing equation + +Everything in this simulation is one equation with pieces switched on and off: + +``` + ∂T +ρc_p ── + ρc_p (v · ∇T) = ∇ · (k ∇T) + ∂t +``` + +| Screen | Terms active | What is a field | +|---|---|---| +| 1. Temperature | diffusion only, uniform `k` | `T(x, y)` | +| 2. Conduction | diffusion only, uniform `k` | `T`, `q = -k∇T` | +| 3. Convection | diffusion + advection | `T`, `v` | +| 4. Heat Transfer | diffusion + advection, balance adjustable | `T`, `q`, `v` | +| 5. Materials | diffusion, `k(x, y)`, `ρc_p(x, y)`, anisotropic | `T`, `q`, `k` | + +For a homogeneous, isotropic medium this reduces to the familiar form + +``` +∂T/∂t + v · ∇T = α ∇²T, α = k / (ρ c_p) +``` + +and Fourier's law `q = -k ∇T` is what the heat-flux layer draws. + +## 2. The domain + +A square plate, 10 cm on a side, discretized into `N × N` cells with +`dx = dy = 0.1 / N`. `N` is a preference — 128, 512, 1024, or 2048 — and nothing +in the model, the physics, or the UI reads it except `SimulationDomain`. The +physical extent is fixed as `N` changes, so refining the grid resolves more +structure in the same plate rather than simulating a different one. + +Thickness is not modelled: the plate is two-dimensional, and energy is quoted per +unit depth (J/m). + +## 3. Materials + +Room-temperature handbook values. The derived diffusivity `α = k / (ρ c_p)` spans +nearly four decades across the list, which is the point of having a list. + +| Material | k [W/m·K] | ρ [kg/m³] | c_p [J/kg·K] | α [m²/s] | +|---|---|---|---|---| +| Copper | 401 | 8960 | 385 | 1.16 × 10⁻⁴ | +| Aluminum | 237 | 2700 | 897 | 9.8 × 10⁻⁵ | +| Steel | 16 | 8000 | 500 | 4.0 × 10⁻⁶ | +| Glass | 1.0 | 2500 | 840 | 4.8 × 10⁻⁷ | +| Water | 0.6 | 1000 | 4182 | 1.4 × 10⁻⁷ | +| Wood | 0.15 | 700 | 1700 | 1.3 × 10⁻⁷ | +| Insulator (foam) | 0.03 | 30 | 1500 | 6.7 × 10⁻⁷ | + +Note that foam has the *lowest* conductivity but a *higher* diffusivity than wood, +because it stores almost no energy. The Material panel shows both numbers live so +that this is visible rather than surprising: `k` is what appears in Fourier's law, +`α` is what governs how fast the field changes, and they do not order materials +the same way. + +### Anisotropy + +The Materials screen can split the scalar `k` into a diagonal conductivity tensor + +``` +K = diag(k_x, k_y), k_x = k·r, k_y = k / r +``` + +where `r` is the anisotropy ratio. The geometric mean `√(k_x k_y) = k` is +preserved, so changing `r` redistributes the material's conductivity between the +axes without making it a different material. A hot spot then spreads into an +ellipse of aspect ratio `√(k_x/k_y) = r`, and the flux no longer points straight +down the temperature gradient. + +## 4. Discretization + +### Diffusion — conservative finite volume + +Heat entering a cell through its four faces: + +``` + 1 ⎡ ⎤ +T_ij ← T_ij + ── ⎢ (F_E − F_W)/dx + (F_S − F_N)/dy ⎥ · dt + ρc_p⎣ ⎦ + +F_E = k_{i+½,j} (T_{i+1,j} − T_{i,j}) / dx (and similarly for W, N, S) +``` + +Face conductivities use the **harmonic mean** of the two adjacent cells: + +``` +k_{i+½} = 2 k_i k_{i+1} / (k_i + k_{i+1}) +``` + +This is the series combination of thermal resistances, and it is what makes a +painted barrier behave like a barrier. An arithmetic mean would let a single cell +of foam between two cells of copper conduct at roughly half copper's rate; the +harmonic mean gives roughly twice foam's rate, which is the physical answer. The +`blocks heat with a strip of insulator` test in `tests/common/field/kernels.test.ts` +pins this down. + +### Advection — semi-Lagrangian + +Each cell traces its parcel backward along the velocity field and bilinearly +samples the incoming field there: + +``` +T_new(x) = T_old(x − v dt) +``` + +Unconditionally stable, so the time step is never limited by the flow, at the +cost of some numerical diffusion. That trade is right for a teaching simulation: +the alternative (an upwind or flux-limited scheme) buys sharpness at the price of +a step size that collapses when a student drags the speed slider up. + +Advection and diffusion are applied by operator splitting, in that order, within +each substep. + +### Boundary conditions + +| Condition | Meaning | Energy | +|---|---|---| +| Insulated | Zero normal gradient (adiabatic). Outward face conductivities are forced to zero. | Conserved exactly | +| Fixed | Edges held at ambient (Dirichlet). | Leaks to the surroundings | +| Periodic | The domain wraps. | Conserved exactly | + +Screens with a flow default to **periodic**, because with insulated edges a +uniform stream carries every warm parcel off the downstream side within a few +seconds of simulated time and leaves a blank plate. Periodic edges make the flow a +steady recirculation, so a painted spot keeps travelling. + +## 5. The time step + +This is the modelling decision most worth understanding, because it is why the +elapsed-time readout behaves the way it does. + +The explicit five-point Laplacian is stable while + +``` +α · dt · (1/dx² + 1/dy²) ≤ ½ +``` + +and the simulation always integrates at **40% of that limit**, with a further cap +from the advective Courant number `|v| dt / dx ≤ 1`. It does *not* pick a step to +match wall-clock time. Instead, each frame takes a fixed budget of substeps +(8 at normal speed, scaled by the frame's actual length). + +The consequence is that **simulated seconds per real second depend on the +material**. Glass permits a step ~250× larger than copper's, so a screen showing +glass advances ~250× more simulated time per frame. Both run at the same rate in +*diffusion times* — the dimensionless `Fo = αt/L²` that actually governs what the +field looks like — which is why copper and glass produce the same *sequence* of +pictures at very different clock readings. + +That is the honest behaviour, and the elapsed-time readout in the status line +reports it rather than hiding it. The alternative — fixing simulated seconds per +real second — would mean either an unstable scheme or a glass plate on which +nothing visibly happens for ten minutes. + +### Cost + +Substeps per frame is constant, so the per-frame cost is `O(N²)` in the grid +size and independent of the material. A 2048 × 2048 grid is 256× the work of the +classroom 128 × 128 grid, which is exactly why the field lives on the GPU. + +## 6. Velocity fields + +The flow is prescribed, not solved — these are analytic fields, not a +Navier-Stokes solution. Each preset returns a dimensionless direction field of +magnitude ≤ 1, which the engine multiplies by the requested speed, so the speed +control and the Péclet readout have one unambiguous scale. + +| Preset | Field | Note | +|---|---|---| +| Still | `v = 0` | | +| Uniform | `v = (U, 0)` | | +| Channel | `v_x = U(1 − y²/R²)` | Hagen-Poiseuille between no-slip walls | +| Vortex | Lamb-Oseen-like swirl about the centre | Zero at the core, decaying outward | +| Plume | `ψ = A sin(2πu) sin(πv)` | Two counter-rotating cells: rising in the middle, sinking at the walls | + +Vortex and plume are written from a stream function, and channel is +one-dimensional, so all three are divergence-free by construction. That matters: +a compressible flow would pile temperature up at convergence points, which would +look exactly like heating and would be entirely fictitious. The +`is divergence-free for every moving preset` test checks this numerically. + +The plume is the closest thing here to natural convection, but it is still +imposed — the temperature field does not drive it. Buoyancy coupling would be the +natural next step. + +## 7. The Péclet number + +The Heat Transfer screen's single control moves conductivity and flow speed in +opposite directions on a logarithmic scale, and reports + +``` +Pe = U L / α +``` + +where `L` is the plate width, `U` the peak flow speed, and `α` the area-weighted +mean diffusivity. Below `Pe ≈ 1` the field is shaped by conduction; above +`Pe ≈ 100` by the flow; in between both matter. The readout names the regime as +well as printing the number. + +The mapping is symmetric about the midpoint — + +| Balance | Conductivity | Flow | | +|---|---|---|---| +| 0.0 | × 1 | × 0.01 | conduction alone | +| 0.5 | × 0.1 | × 0.1 | comparable | +| 1.0 | × 0.01 | × 1 | flow alone | + +— which sweeps roughly four decades of `Pe` while keeping the frame cost flat: +reducing conductivity raises the stable time step by exactly the factor that +raising the flow speed lowers it. + +## 8. The heat brush + +A stroke pulls each cell inside a disc toward the brush temperature: + +``` +T ← T + (T_brush − T) · s · w(r), w(r) = (1 − r²/R²)² +``` + +The falloff `w` is a compactly supported bump: 1 at the centre, reaching 0 with +zero slope at the rim, so repeated strokes build a smooth blob rather than a stack +of hard discs. Because the update is a convex combination, repeated painting +*saturates* at `T_brush` and never overshoots — there is no way to paint a plate +to 10 000 °C by scribbling. + +The material brush is a hard assignment instead, since a half-copper-half-foam +cell is not a material. + +## 9. What is not modelled + +Worth being explicit about, since each is a place a student's intuition might +reasonably go: + +- **Radiation.** No `σT⁴` term. At the temperatures shown (−20 °C to 180 °C) + radiative loss is small next to conduction in a solid, but it is not zero. +- **Convective loss to the air.** The plate does not cool to its surroundings + unless the boundary condition is set to fixed. +- **Buoyancy.** The flow is prescribed; temperature does not drive it. The plume + preset looks like natural convection but is imposed. +- **Phase change.** Water stays water at 180 °C. +- **Temperature-dependent properties.** `k`, `ρ`, and `c_p` are constants. +- **The third dimension.** The plate is a 2-D slab with no through-thickness + gradient. diff --git a/doc/multi-screen.md b/doc/multi-screen.md new file mode 100644 index 0000000..b0571ae --- /dev/null +++ b/doc/multi-screen.md @@ -0,0 +1,443 @@ +# Multi-Screen Simulations + +This sim was scaffolded by `npm run scaffold-screens` into `src/temperature/`, `src/conduction/`, `src/convection/`, `src/combined/`, `src/materials/` (fleet naming: +kebab folders with no `-screen` suffix). This guide covers the architecture and how to add +another screen by hand. + +--- + +## Automated scaffold (preferred) + +Already run for this sim — the prototype folder is gone, so a second run exits early. +Kept as reference for the next sim: + +```sh +# One screen named after the sim (default when --screens is omitted) +npm run scaffold-screens + +# N screens — titles, or kebab:Title pairs +npm run scaffold-screens -- --screens Intro,Lab +npm run scaffold-screens -- --screens intro:Intro,"series-rlc:Series RLC" + +# Shared helpers under common/model/ (fleet style) +npm run scaffold-screens -- --screens Intro,Lab --shared-model +``` + +The scaffolder: + +1. Copies the `temperature/` prototype into `src//` per screen +2. Writes `src/common/{Prefix}ScreenIcons.ts` stubs and wires icons on each Screen +3. Updates `main.ts`, locale JSON (`screens` + nested `a11y`), and `StringManager` +4. Removes the prototype `temperature/` folder + +Then always: + +```sh +npm run fix # the emitted/renamed imports need Biome's organizeImports pass +npm run check +``` + +Independent models by default. Pass `--shared-model` to emit +`src/common/model/SharedModel.ts` and compose it into each screen model +(see [Shared model](#4--shared-model) below). + +--- + +## Architecture patterns + +### Single-screen (template default) + +``` +main.ts + └─ TemperatureScreen (Screen) + ├─ TemperatureModel owns all state + └─ TemperatureScreenView owns all visuals +``` + +### Multi-screen with independent state (simplest) + +Each screen is completely self-contained. Use this when screens have no shared +physical state — for instance an "Intro" that is purely explanatory and a "Lab" +with interactive controls. + +``` +main.ts + ├─ IntroScreen (Screen) + │ ├─ IntroModel + │ └─ IntroScreenView + └─ LabScreen (Screen) + ├─ LabModel + └─ LabScreenView +``` + +### Multi-screen with shared helpers (fleet style) + +Put reusable physics in `src/common/model/` under a **domain** name. Each screen +model composes its own instance — code is shared, live state usually is not: + +``` +common/model/RlcCircuitModel.ts (or SkyModel, TimeMaster, …) +main.ts + ├─ IntroScreen → IntroModel { circuit = new RlcCircuitModel() } + └─ LabScreen → LabModel { circuit = new RlcCircuitModel() } +``` + +### Multi-screen with one live shared instance (optional) + +When screens must mutate the **same** Properties, construct once in `main.ts` +and pass the instance into each Screen/Model. Prefer domain names in +`common/model/` — there is no `*RootModel` and no top-level `src/model/` +(see Baton CONVENTIONS). + +--- + +## Step-by-step: adding a second screen by hand + +Prefer `npm run scaffold-screens` when creating the sim. Use this section when +growing an already-scaffolded sim. + +### 1 — Add strings + +`src/i18n/strings_en.json` (and every other locale file): + +```json +{ + "title": "Friction", + "screens": { + "intro": "Intro", + "lab": "Lab" + } +} +``` + +**Important:** All locale files must define identical keys. TypeScript will error +at compile time if any key is missing (see the `satisfies` checks in +`StringManager.ts`). + +### 2 — Expose screen-name properties in StringManager + +```typescript +// src/i18n/StringManager.ts +public getScreenNames(): { + readonly introStringProperty: ReadOnlyProperty; + readonly labStringProperty: ReadOnlyProperty; +} { + return { + introStringProperty: stringProperties.screens.introStringProperty, + labStringProperty: stringProperties.screens.labStringProperty, + }; +} +``` + +### 3 — Create the second screen folder + +Mirror the structure of an existing screen package. Fleet convention: **kebab +folder names without a `-screen` suffix**: + +``` +src/ +├─ common/ +│ └─ FrictionScreenIcons.ts # createIntroIcon(), createLabIcon(), … +├─ intro/ +│ ├─ IntroScreen.ts +│ ├─ model/ +│ │ └─ IntroModel.ts +│ └─ view/ +│ ├─ IntroScreenView.ts +│ ├─ IntroScreenSummaryContent.ts +│ └─ IntroKeyboardHelpContent.ts +└─ lab/ + ├─ LabScreen.ts + ├─ model/ + │ └─ LabModel.ts + └─ view/ + ├─ LabScreenView.ts + ├─ LabScreenSummaryContent.ts + └─ LabKeyboardHelpContent.ts +``` + +Each screen file follows the same `Screen` pattern as the +template's `TemperatureScreen.ts`. Screen icons live in one shared module under +`src/common/` (see [Home screen icons](#home-screen-icons)) — do **not** put +a `*ScreenIcon.ts` next to each screen. + +### 4 — Shared model + +**Automated:** `npm run scaffold-screens -- --screens Intro,Lab --shared-model` +writes `src/common/model/SharedModel.ts` and has each screen model compose +`public readonly shared = new SharedModel()` (same pattern as ACPhasor's +`RlcCircuitModel` / RotatingSky's `SkyModel`). Rename `SharedModel` to a domain +noun when you know it. + +**Manual:** add a domain model under `common/model/` and compose it: + +```typescript +// src/common/model/FrictionSurface.ts +import { NumberProperty, StringProperty } from "scenerystack/axon"; + +export class FrictionSurface { + public readonly surfaceTypeProperty = new StringProperty("wood"); + public readonly normalForceProperty = new NumberProperty(10, { units: "N" }); + + public reset(): void { + this.surfaceTypeProperty.reset(); + this.normalForceProperty.reset(); + } +} +``` + +Per-screen models compose it (fleet default): + +```typescript +// src/intro/model/IntroModel.ts +import { FrictionSurface } from "../../common/model/FrictionSurface.js"; + +export class IntroModel implements TModel { + public readonly surface = new FrictionSurface(); + + public step(_dt: number): void { /* … */ } + public reset(): void { this.surface.reset(); } +} +``` + +### 5 — Register both screens in main.ts + +```typescript +// src/main.ts (inside onReadyToLaunch) + +const screens = [ + new IntroScreen({ + name: stringManager.getScreenNames().introStringProperty, + tandem: Tandem.ROOT.createTandem("introScreen"), + backgroundColorProperty: HeatTransferColors.backgroundColorProperty, + }), + new LabScreen({ + name: stringManager.getScreenNames().labStringProperty, + tandem: Tandem.ROOT.createTandem("labScreen"), + backgroundColorProperty: HeatTransferColors.backgroundColorProperty, + }), +]; + +const sim = new Sim(stringManager.getTitleStringProperty(), screens, { … }); +``` + +Screen models own their composed `common/model/` helpers; `main.ts` only builds the +`screens` array (unless you deliberately share one live instance — see above). + +--- + +## Screen options reference + +| Option | Type | Purpose | +|---|---|---| +| `name` | `ReadOnlyProperty` | Localizable tab label | +| `tandem` | `Tandem` | PhET-iO registration root | +| `backgroundColorProperty` | `TReadOnlyProperty` | Screen background | +| `createKeyboardHelpNode` | `() => Node` | Per-screen keyboard help | +| `homeScreenIcon` | `ScreenIcon` | Icon on the home screen | +| `navigationBarIcon` | `ScreenIcon` | Smaller icon in the nav bar | +| `maxDT` | `number` | Maximum allowed dt in seconds | +| `targetFrameRate` | `number` | Target FPS for `step()` | + +--- + +## Home screen icons + +Multi-screen sims show a home screen by default. Each screen needs a +`homeScreenIcon` and usually a `navigationBarIcon`, or SceneryStack falls +back to a generic placeholder. + +### Fleet convention + +Put **all** screen icons in one module: + +``` +src/common/HeatTransferScreenIcons.ts +``` + +Export one factory per screen named `create{Screen}Icon()`: + +| Screen | Factory | +|---|---| +| Intro | `createIntroIcon()` | +| Lab | `createLabIcon()` | +| … | `create…Icon()` | + +Wire both icons in each `*Screen.ts` constructor via `optionize` defaults +(same pattern as MotionsOfTheSun / TheRamp): + +```typescript +import { createIntroIcon } from "../common/FrictionScreenIcons.js"; + +optionize()( + { + backgroundColorProperty: FrictionColors.backgroundColorProperty, + createKeyboardHelpNode: () => new IntroKeyboardHelpContent(), + homeScreenIcon: createIntroIcon(), + navigationBarIcon: createIntroIcon(), + }, + options, +); +``` + +Do **not** use per-screen classes like `intro-screen/IntroScreenIcon.ts`. + +### Icon module skeleton + +Draw on the standard PhET **548 × 373** canvas with scenery primitives and +`*Colors` `ProfileColorProperty`s so icons follow default / projector mode: + +```typescript +/** + * FrictionScreenIcons.ts + * + * Programmatic home-screen / navigation-bar icons for each Friction screen. + * Drawn on the standard PhET 548 × 373 canvas using FrictionColors. + */ +import { Node, Rectangle } from "scenerystack/scenery"; +import { ScreenIcon } from "scenerystack/sim"; +import FrictionColors from "../FrictionColors.js"; + +const W = 548; +const H = 373; + +function background(): Rectangle { + return new Rectangle(0, 0, W, H, { fill: FrictionColors.backgroundColorProperty }); +} + +function iconFrom(content: Node): ScreenIcon { + return new ScreenIcon(content, { + maxIconWidthProportion: 1, + maxIconHeightProportion: 1, + fill: FrictionColors.backgroundColorProperty, + }); +} + +export function createIntroIcon(): ScreenIcon { + // Distinctive motif for the Intro screen (keep it readable at navbar size too). + return iconFrom( + new Node({ + children: [ + background(), + new Rectangle(180, 120, 188, 133, { + fill: FrictionColors.accentColorProperty, + cornerRadius: 12, + }), + ], + }), + ); +} + +export function createLabIcon(): ScreenIcon { + return iconFrom( + new Node({ + children: [ + background(), + // … Lab-specific motif … + ], + }), + ); +} +``` + +Each icon should be a miniature of what that screen is about so learners can +tell the screens apart on the home screen. + +--- + +## Accessibility across screens + +Each screen must have its own `ScreenSummaryContent` and `KeyboardHelpContent`. +The strings live under per-screen keys in the a11y block: + +```json +"a11y": { + "intro": { + "screenSummary": { … }, + "currentDetails": "…" + }, + "lab": { + "screenSummary": { … }, + "currentDetails": "…" + } +} +``` + +Expose them via separate methods in `StringManager`: + +```typescript +public getIntroA11yStrings() { return stringProperties.a11y.intro; } +public getLabA11yStrings() { return stringProperties.a11y.lab; } +``` + +--- + +## Using this template beyond a direct copy + +### GitHub template repository + +The repository is a GitHub **template**. Use the **"Use this template"** button +on GitHub to create a new repository, then: + +```sh +npm install +npm run rename -- --id my-sim --name "My Simulation" +npm run scaffold-screens -- --screens Intro,Lab # or omit --screens for one screen +npm run fix +npm run check +``` + +`rename` picks up `repository.url` from your `origin` remote; if you cloned the template +directly rather than using **Use this template**, set it in `package.json` by hand. + +### Baton `create-sim` (recommended for agents / fleet) + +From the OpenPhysics workspace: + +```sh +Baton/scripts/create-sim.sh \ + --repo MySim \ + --name "My Simulation" \ + --screens Intro,Lab \ + --shared-model \ + --onboard +``` + +Creates the GitHub repo from this template, clones it beside `Baton`, runs +rename + scaffold-screens + check. `--onboard` finishes catalog, screenshot, +WebP, Pages index, and the OpenPhysics README Layout row; add `--pr` to open +follow-up PRs. See [`Baton/doc/add-simulation.md`](https://github.com/OpenPhysics/Baton/blob/main/doc/add-simulation.md). + +### Monorepo / workspace setup + +For organisations building a suite of simulations, a pnpm/npm workspace lets +you share tooling while keeping each sim independent: + +``` +physics-sims/ +├─ package.json # workspace root (workspaces: ["sims/*"]) +├─ sims/ +│ ├─ friction/ # forked from this template +│ ├─ waves/ +│ └─ optics/ +└─ shared/ # optional: shared assets, design tokens +``` + +Each sim is still independently deployable; the workspace just gives you a +single `npm run build --workspaces` command to build all of them. + +### Git subtree for template updates + +To pull template improvements back into an existing fork: + +```sh +# One-time: add the template as a remote +git remote add template https://github.com/OpenPhysics/SceneryStackTemplate.git + +# Pull template changes into a branch for review +git fetch template +git merge template/main --allow-unrelated-histories --squash +``` + +Review the diff carefully — class-name changes in the template may conflict +with your sim-specific renames. diff --git a/index.html b/index.html new file mode 100644 index 0000000..df2273d --- /dev/null +++ b/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + Heat Transfer + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..dea4210 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,9090 @@ +{ + "name": "heat-transfer", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "heat-transfer", + "version": "0.0.0", + "license": "AGPL-3.0-or-later", + "dependencies": { + "scenerystack": "^3.0.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.6", + "@playwright/test": "^1.62.1", + "@types/node": "^24.13.3", + "happy-dom": "^20.11.1", + "jsdom": "^30.0.1", + "playwright": "^1.61.1", + "png-to-ico": "^3.0.2", + "sharp": "^0.35.3", + "tsx": "^4.23.1", + "typescript": "^7.0.2", + "vite": "^8.2.0", + "vite-plugin-pwa": "^1.3.0", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@apideck/better-ajv-errors": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", + "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsonpointer": "^5.0.1", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz", + "integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", + "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", + "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", + "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", + "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", + "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", + "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", + "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", + "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", + "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", + "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", + "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", + "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", + "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", + "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", + "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", + "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", + "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", + "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", + "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", + "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", + "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", + "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.7.tgz", + "integrity": "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", + "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", + "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", + "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", + "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", + "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.29.7", + "@babel/plugin-syntax-import-attributes": "^7.29.7", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.29.7", + "@babel/plugin-transform-async-generator-functions": "^7.29.7", + "@babel/plugin-transform-async-to-generator": "^7.29.7", + "@babel/plugin-transform-block-scoped-functions": "^7.29.7", + "@babel/plugin-transform-block-scoping": "^7.29.7", + "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-class-static-block": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", + "@babel/plugin-transform-computed-properties": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-dotall-regex": "^7.29.7", + "@babel/plugin-transform-duplicate-keys": "^7.29.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-dynamic-import": "^7.29.7", + "@babel/plugin-transform-explicit-resource-management": "^7.29.7", + "@babel/plugin-transform-exponentiation-operator": "^7.29.7", + "@babel/plugin-transform-export-namespace-from": "^7.29.7", + "@babel/plugin-transform-for-of": "^7.29.7", + "@babel/plugin-transform-function-name": "^7.29.7", + "@babel/plugin-transform-json-strings": "^7.29.7", + "@babel/plugin-transform-literals": "^7.29.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", + "@babel/plugin-transform-member-expression-literals": "^7.29.7", + "@babel/plugin-transform-modules-amd": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", + "@babel/plugin-transform-modules-umd": "^7.29.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-new-target": "^7.29.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", + "@babel/plugin-transform-numeric-separator": "^7.29.7", + "@babel/plugin-transform-object-rest-spread": "^7.29.7", + "@babel/plugin-transform-object-super": "^7.29.7", + "@babel/plugin-transform-optional-catch-binding": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/plugin-transform-private-methods": "^7.29.7", + "@babel/plugin-transform-private-property-in-object": "^7.29.7", + "@babel/plugin-transform-property-literals": "^7.29.7", + "@babel/plugin-transform-regenerator": "^7.29.7", + "@babel/plugin-transform-regexp-modifiers": "^7.29.7", + "@babel/plugin-transform-reserved-words": "^7.29.7", + "@babel/plugin-transform-shorthand-properties": "^7.29.7", + "@babel/plugin-transform-spread": "^7.29.7", + "@babel/plugin-transform-sticky-regex": "^7.29.7", + "@babel/plugin-transform-template-literals": "^7.29.7", + "@babel/plugin-transform-typeof-symbol": "^7.29.7", + "@babel/plugin-transform-unicode-escapes": "^7.29.7", + "@babel/plugin-transform-unicode-property-regex": "^7.29.7", + "@babel/plugin-transform-unicode-regex": "^7.29.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.6.tgz", + "integrity": "sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.6", + "@biomejs/cli-darwin-x64": "2.5.6", + "@biomejs/cli-linux-arm64": "2.5.6", + "@biomejs/cli-linux-arm64-musl": "2.5.6", + "@biomejs/cli-linux-x64": "2.5.6", + "@biomejs/cli-linux-x64-musl": "2.5.6", + "@biomejs/cli-win32-arm64": "2.5.6", + "@biomejs/cli-win32-x64": "2.5.6" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.6.tgz", + "integrity": "sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.6.tgz", + "integrity": "sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.6.tgz", + "integrity": "sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.6.tgz", + "integrity": "sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.6.tgz", + "integrity": "sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@fluent/bundle": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@fluent/bundle/-/bundle-0.18.0.tgz", + "integrity": "sha512-8Wfwu9q8F9g2FNnv82g6Ch/E1AW1wwljsUOolH5NEtdJdv0sZTuWvfCM7c3teB9dzNaJA8rn4khpidpozHWYEA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@fluent/syntax": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@fluent/syntax/-/syntax-0.19.0.tgz", + "integrity": "sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-babel": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz", + "integrity": "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@rollup/pluginutils": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", + "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^7.0.3", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { + "version": "3.0.0-pre1", + "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", + "integrity": "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ejs": "^3.1.10", + "json5": "^2.2.3", + "magic-string": "^0.30.21", + "string.prototype.matchall": "^4.0.12" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", + "integrity": "sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/big.js": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-6.2.2.tgz", + "integrity": "sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/bigjs" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eta": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", + "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/flatqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/flatqueue/-/flatqueue-2.0.3.tgz", + "integrity": "sha512-RZCWZNkmxzUOh8jqEcEGZCycb3B8KAfpPwg3H//cURasunYxsg1eIvE+QDSjX+ZPHTIVfINfK1aLTrVKKO0i4g==", + "license": "ISC", + "engines": { + "node": ">= 12.17.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/happy-dom": { + "version": "20.11.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", + "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha512-z/GDPjlRMNOa2XJiB4em8wJpuuBfrFOlYKTZxtpkdr1uPdibHI8rYA3MY0KDObpVyaes0e/aunid/t88ZI2EKA==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/himalaya": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/himalaya/-/himalaya-1.1.1.tgz", + "integrity": "sha512-mJLY5tErGWtsw8hO2fJ2vK4IpG6S1AIgVkduRo4FqFJhgI2H3XLzgemRemk45zcnFyxNNpOfrIDle2KcnJM0lA==", + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linebreak-ts": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/linebreak-ts/-/linebreak-ts-1.0.2.tgz", + "integrity": "sha512-/bIJBbvGJ2stDxhVkWVuwqir/xJY95xEdW00fUpnm5oV8LdKsiUpjW3SgszvNlG4fhpLoeqLa7OyglcNZUIuxw==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/paper": { + "version": "0.12.18", + "resolved": "https://registry.npmjs.org/paper/-/paper-0.12.18.tgz", + "integrity": "sha512-ZSLIEejQTJZuYHhSSqAf4jXOnii0kPhCJGAnYAANtdS72aNwXJ9cP95tZHgq1tnNpvEwgQhggy+4OarviqTCGw==", + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/png-to-ico": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/png-to-ico/-/png-to-ico-3.0.2.tgz", + "integrity": "sha512-36vvp3/YF7LPYkUEOj08/WgB1wI63qW391YfOhOWckgOtGkarr9+EhPBimcCmRP7fJaG4EaXQhTTaQ8qqdj8aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^25.5.0", + "minimist": "^1.2.8", + "pngjs": "^7.0.0" + }, + "bin": { + "png-to-ico": "bin/cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/png-to-ico/node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/png-to-ico/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rolldown": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" + } + }, + "node_modules/rollup": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scenerystack": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/scenerystack/-/scenerystack-3.0.0.tgz", + "integrity": "sha512-lQDR1EzfYHmmK0EbR1ZqkQ8ohRO8w4BfDFRJDefqhWSAbIxErR90xbjxNm5EGRaHZ6KD3G62sk0LUH8UhGendQ==", + "license": "MIT", + "dependencies": { + "@fluent/bundle": "^0.18.0", + "@fluent/syntax": "^0.19.0", + "base64-js": "^1.5.1", + "big.js": "^6.2.2", + "file-saver": "^2.0.5", + "flatqueue": "^2.0.3", + "he": "~1.1.0", + "himalaya": "^1.1.0", + "linebreak-ts": "~1.0.2", + "lodash": "~4.17.12", + "paper": "~0.12.17", + "seedrandom": "~2.4.2", + "text-encoder-lite": "~2.0.0", + "three": "^0.104.0" + }, + "bin": { + "scenerystack": "bin/scenerystack.js" + } + }, + "node_modules/seedrandom": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-2.4.4.tgz", + "integrity": "sha512-9A+PDmgm+2du77B5i0Ip2cxOqqHjgNxnBgglxLcX78A2D6c2rTo61z4jnVABpF4cKeDMDG+cmXXvdnqse2VqMA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz", + "integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smob": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", + "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/text-encoder-lite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/text-encoder-lite/-/text-encoder-lite-2.0.0.tgz", + "integrity": "sha512-bo08ND8LlBwPeU23EluRUcO3p2Rsb/eN5EIfOVqfRmblNDEVKK5IzM9Qfidvo+odT0hhV8mpXQcP/M5MMzABXw==" + }, + "node_modules/three": { + "version": "0.125.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.125.2.tgz", + "integrity": "sha512-7rIRO23jVKWcAPFdW/HREU2NZMGWPBZ4XwEMt0Ak0jwLUKVJhcKM55eCBWyGZq/KiQbeo1IeuAoo/9l2dzhTXA==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-pwa": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz", + "integrity": "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.6", + "pretty-bytes": "^6.1.1", + "tinyglobby": "^0.2.10", + "workbox-build": "^7.4.1", + "workbox-window": "^7.4.1" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vite-pwa/assets-generator": "^1.0.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "workbox-build": "^7.4.1", + "workbox-window": "^7.4.1" + }, + "peerDependenciesMeta": { + "@vite-pwa/assets-generator": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/workbox-background-sync": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz", + "integrity": "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz", + "integrity": "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-build": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.1.tgz", + "integrity": "sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.24.4", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "eta": "^4.5.1", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^11.0.1", + "pretty-bytes": "^5.3.0", + "rollup": "^4.53.3", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "7.4.1", + "workbox-broadcast-update": "7.4.1", + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-google-analytics": "7.4.1", + "workbox-navigation-preload": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-range-requests": "7.4.1", + "workbox-recipes": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1", + "workbox-streams": "7.4.1", + "workbox-sw": "7.4.1", + "workbox-window": "7.4.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/workbox-build/node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.1.tgz", + "integrity": "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-core": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz", + "integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-expiration": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.1.tgz", + "integrity": "sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-google-analytics": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz", + "integrity": "sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-background-sync": "7.4.1", + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-navigation-preload": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.1.tgz", + "integrity": "sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-precaching": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz", + "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-range-requests": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.1.tgz", + "integrity": "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-recipes": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.1.tgz", + "integrity": "sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-routing": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", + "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-strategies": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz", + "integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-streams": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.1.tgz", + "integrity": "sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1" + } + }, + "node_modules/workbox-sw": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.1.tgz", + "integrity": "sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-window": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.1.tgz", + "integrity": "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "7.4.1" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bb104fb --- /dev/null +++ b/package.json @@ -0,0 +1,68 @@ +{ + "name": "heat-transfer", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "A SceneryStack simulation: Heat Transfer.", + "license": "AGPL-3.0-or-later", + "repository": { + "type": "git", + "url": "https://github.com/OpenPhysics/HeatTransfer.git" + }, + "keywords": [ + "simulation", + "SceneryStack", + "interactive", + "physics", + "education", + "pwa" + ], + "scripts": { + "start": "vite", + "dev": "vite", + "build": "tsc && vite build", + "build:single": "tsc && vite build --mode single", + "preview": "vite preview", + "lint": "biome check .", + "format": "biome format --write .", + "fix": "biome check --write .", + "check": "tsc --noEmit && tsc -p tsconfig.scripts.json --noEmit && tsc -p tsconfig.test.json --noEmit", + "release": "npm run check && npm run lint && npm run build && npm version patch && git push && git push --tags", + "watch": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest", + "test:fuzz": "playwright test --project=chromium", + "test:fuzz:quick": "FUZZ_DURATION=10 playwright test --project=chromium", + "icons": "tsx scripts/generate-icons.ts", + "rename": "tsx scripts/rename-sim.ts", + "scaffold-screens": "tsx scripts/scaffold-screens.ts", + "clean": "rm -rf dist", + "prepare": "git rev-parse --is-inside-work-tree >/dev/null 2>&1 && git config core.hooksPath .githooks || true" + }, + "dependencies": { + "scenerystack": "^3.0.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.6", + "@playwright/test": "^1.62.1", + "@types/node": "^24.13.3", + "happy-dom": "^20.11.1", + "jsdom": "^30.0.1", + "playwright": "^1.61.1", + "png-to-ico": "^3.0.2", + "sharp": "^0.35.3", + "tsx": "^4.23.1", + "typescript": "^7.0.2", + "vite": "^8.2.0", + "vite-plugin-pwa": "^1.3.0", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=24" + }, + "overrides": { + "lodash": "^4.18.0", + "three": "^0.125.0", + "brace-expansion": "^5.0.8" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..66c95b7 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,38 @@ +/** + * Playwright configuration for optional fuzz testing (Template smoke). + */ + +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/fuzz", + timeout: 5 * 60 * 1000, + expect: { + timeout: 10_000, + }, + fullyParallel: false, + forbidOnly: !!process.env["CI"], + retries: 0, + workers: 1, + reporter: [["list"], ["html", { open: "never" }]], + use: { + baseURL: "http://localhost:5173", + trace: "retain-on-failure", + video: "retain-on-failure", + screenshot: "only-on-failure", + }, + webServer: { + command: "npm run start", + url: "http://localhost:5173", + reuseExistingServer: !process.env["CI"], + timeout: 120_000, + }, + projects: [ + { + name: "chromium", + use: { + browserName: "chromium", + }, + }, + ], +}); diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..38f5a14 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..c8e90e5 Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..f4eb1d1 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..dafee03 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/icon.svg b/public/icons/icon.svg new file mode 100644 index 0000000..07e1b68 --- /dev/null +++ b/public/icons/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/scripts/generate-icons.ts b/scripts/generate-icons.ts new file mode 100644 index 0000000..25cb167 --- /dev/null +++ b/scripts/generate-icons.ts @@ -0,0 +1,33 @@ +/** + * generate-icons.ts + * + * Rasterizes public/icons/icon.svg into the PNG icons and favicon.ico used by the PWA. + * Run with: npm run icons + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import pngToIco from "png-to-ico"; +import sharp from "sharp"; + +const here = dirname(fileURLToPath(import.meta.url)); +const publicDir = resolve(here, "..", "public"); +const svg = readFileSync(resolve(publicDir, "icons", "icon.svg")); + +const density = 512; + +const pngTargets = [ + { size: 180, file: "icons/apple-touch-icon.png" }, + { size: 192, file: "icons/icon-192.png" }, + { size: 512, file: "icons/icon-512.png" }, +]; + +for (const { size, file } of pngTargets) { + await sharp(svg, { density }).resize(size, size).png().toFile(resolve(publicDir, file)); +} + +const icoBuffers = await Promise.all( + [16, 32, 48, 64].map((size) => sharp(svg, { density }).resize(size, size).png().toBuffer()), +); +writeFileSync(resolve(publicDir, "favicon.ico"), await pngToIco(icoBuffers)); diff --git a/scripts/rename-sim.ts b/scripts/rename-sim.ts new file mode 100644 index 0000000..5022f4e --- /dev/null +++ b/scripts/rename-sim.ts @@ -0,0 +1,258 @@ +#!/usr/bin/env tsx +/** + * scripts/rename-sim.ts + * + * Renames the sim template for a new simulation at the **sim** level (package id and + * metadata, display name, Colors/Constants/Namespace/Panel/ButtonOptions/Preferences). + * Screen packages stay as `src/sim-screen/` with `Sim*` class names so + * `npm run scaffold-screens` can emit N fleet-named screen folders afterward. + * No `Sim*` identifier should survive both steps. + * + * Usage: + * npm run rename -- --id --name "" + * + * Examples: + * npm run rename -- --id friction --name "Friction" + * npm run rename -- --id wave-interference --name "Wave Interference" + * + * The class prefix is derived automatically: + * "Friction" → prefix "Friction" + * "Wave Interference" → prefix "WaveInterference" + * + * Override the prefix explicitly with --prefix: + * npm run rename -- --id my-sim --name "My Simulation" --prefix MySim + * + * After running: + * npm run scaffold-screens -- --screens Intro,Lab ← or omit for single screen + * npm run fix + * npm run check + */ + +import { execFileSync } from "node:child_process"; +import { readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +// ── Argument parsing ────────────────────────────────────────────────────────── + +function getArg(flag: string): string | undefined { + const i = process.argv.indexOf(flag); + return i !== -1 ? process.argv[i + 1] : undefined; +} + +const newId = getArg("--id"); +const newName = getArg("--name"); + +if (!(newId && newName)) { + console.error('Usage: npm run rename -- --id --name ""'); + console.error(""); + console.error("Examples:"); + console.error(' npm run rename -- --id friction --name "Friction"'); + console.error(' npm run rename -- --id wave-interference --name "Wave Interference"'); + process.exit(1); +} + +// PascalCase class prefix: "Wave Interference" → "WaveInterference" +const newPrefix = + getArg("--prefix") ?? + newName + .split(/\s+/) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(""); + +// camelCase prefix: "WaveInterference" → "waveInterference" +const newCamel = newPrefix.charAt(0).toLowerCase() + newPrefix.slice(1); + +// SCREAMING_SNAKE prefix: "WaveInterference" → "WAVE_INTERFERENCE" +const newSnake = newPrefix + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .toUpperCase(); + +const ROOT = resolve(process.cwd()); + +// ── Skip lists ──────────────────────────────────────────────────────────────── + +const SKIP_DIRS = new Set([".git", "node_modules", "dist", ".cache", ".vite", "scripts"]); +const TEXT_EXTS = new Set([".ts", ".js", ".json", ".html", ".md", ".css", ".svg", ".txt", ".webmanifest", ".toml"]); + +// ── Content replacements ────────────────────────────────────────────────────── +// Sim-level only. Screen classes (SimScreen, SimModel, …) and `sim-screen/` are +// left for scaffold-screens. Longer strings must come first to avoid partial matches. + +const REPLACEMENTS: ReadonlyArray<[string, string]> = [ + // Shared / preferences (not per-screen) + ["SimPreferencesModel", `${newPrefix}PreferencesModel`], + ["SimPreferencesNode", `${newPrefix}PreferencesNode`], + ["SimButtonOptions", `${newPrefix}ButtonOptions`], + ["SimControlPanel", `${newPrefix}ControlPanel`], + ["SimConstants", `${newPrefix}Constants`], + ["SimColors", `${newPrefix}Colors`], + ["SimNamespace", `${newPrefix}Namespace`], + ["SimPanel", `${newPrefix}Panel`], + // camelCase identifier + ["simQueryParameters", `${newCamel}QueryParameters`], + // SCREAMING_SNAKE identifier + ["SIM_COMBO_BOX_OPTIONS", `${newSnake}_COMBO_BOX_OPTIONS`], + // Display strings (all locales + PWA) + ["SceneryStack Template", newName], + ["Plantilla de Simulación", newName], + ["Modèle de Simulation", newName], + ["SimTemplate", newPrefix], + ["A SceneryStack simulation template for one or N screens.", `A SceneryStack simulation: ${newName}.`], + ["A SceneryStack simulation template for one or N screens", `A SceneryStack simulation: ${newName}`], + // Kebab package id + ["scenerystack-template", newId], +]; + +// ── Utilities ───────────────────────────────────────────────────────────────── + +function replaceAll(str: string, search: string, replacement: string): string { + return str.split(search).join(replacement); +} + +function applyReplacements(text: string): string { + let result = text; + for (const [search, replacement] of REPLACEMENTS) { + if (search !== replacement) { + result = replaceAll(result, search, replacement); + } + } + return result; +} + +function fileExtension(filename: string): string { + const dot = filename.lastIndexOf("."); + return dot !== -1 ? filename.slice(dot) : ""; +} + +/** `git@github.com:Org/Repo.git` / `https://github.com/Org/Repo` → `https://github.com/Org/Repo.git` */ +function normalizeRemoteUrl(url: string): string { + const https = url.replace(/^git@([^:]+):/, "https://$1/"); + return https.endsWith(".git") ? https : `${https}.git`; +} + +/** Origin remote of the current checkout, or undefined when there is none. */ +function originUrl(): string | undefined { + try { + const url = execFileSync("git", ["remote", "get-url", "origin"], { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return url === "" ? undefined : normalizeRemoteUrl(url); + } catch { + return undefined; + } +} + +// ── package.json fields that are not plain string substitutions ──────────────── + +/** + * `description`, `keywords`, and `repository.url` describe the template itself, so + * substitution cannot fix them. The repo URL comes from the origin remote — that is + * correct both for a "Use this template" clone and for Baton's create-sim.sh. + */ +function updatePackageJson(): void { + const path = join(ROOT, "package.json"); + const pkg = JSON.parse(readFileSync(path, "utf8")) as { + description?: string; + keywords?: string[]; + repository?: { type?: string; url?: string }; + }; + + pkg.description = `A SceneryStack simulation: ${newName}.`; + if (pkg.keywords) { + pkg.keywords = pkg.keywords.filter((k) => k !== "template"); + } + + const remote = originUrl(); + const isTemplateRemote = remote?.endsWith("/SceneryStackTemplate.git") ?? true; + if (remote && !isTemplateRemote && pkg.repository) { + pkg.repository.url = remote; + } + + writeFileSync(path, `${JSON.stringify(pkg, null, 2)}\n`, "utf8"); + console.log(" updated package.json (description, keywords, repository.url)"); + if (isTemplateRemote) { + console.warn(" note: origin still points at the template — set package.json repository.url by hand"); + } +} + +// ── Pass 1: update file contents ────────────────────────────────────────────── + +function processContents(dir: string): void { + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry)) { + continue; + } + const full = join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + processContents(full); + } else if (TEXT_EXTS.has(fileExtension(entry))) { + const original = readFileSync(full, "utf8"); + const transformed = applyReplacements(original); + if (transformed !== original) { + writeFileSync(full, transformed, "utf8"); + console.log(` updated ${full.slice(ROOT.length + 1)}`); + } + } + } +} + +// ── Pass 2: rename files and directories (children before parents) ──────────── + +interface RenameOp { + from: string; + to: string; +} + +function collectRenames(dir: string, renameOps: RenameOp[]): void { + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry)) { + continue; + } + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + collectRenames(full, renameOps); + } + const newEntry = applyReplacements(entry); + if (newEntry !== entry) { + renameOps.push({ from: full, to: join(dir, newEntry) }); + } + } +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +console.log("\nRenaming sim template →", newName); +console.log(` id: scenerystack-template → ${newId}`); +console.log(` name: SceneryStack Template → ${newName}`); +console.log(` prefix: Sim → ${newPrefix}`); +console.log(` camel: sim → ${newCamel}`); +console.log(` snake: SIM_ → ${newSnake}_`); +console.log(" (screen packages left as sim-screen/ for scaffold-screens)"); +console.log(""); + +console.log("Pass 1: updating file contents…"); +processContents(ROOT); + +console.log("\nPass 2: package.json metadata…"); +updatePackageJson(); + +console.log("\nPass 3: renaming files and directories…"); +const ops: RenameOp[] = []; +collectRenames(ROOT, ops); +for (const { from, to } of ops) { + renameSync(from, to); + console.log(` renamed ${from.slice(ROOT.length + 1)} → ${to.slice(ROOT.length + 1)}`); +} + +console.log("\nDone."); +console.log("\nNext steps:"); +console.log(" 1. npm run scaffold-screens -- --screens Intro,Lab"); +console.log(" (omit --screens to use one screen named after the sim)"); +console.log(" 2. npm run fix (renamed imports need Biome's organizeImports pass)"); +console.log(" 3. npm run check"); +console.log(" 4. git diff --stat"); +console.log(" 5. Update doc/implementation-notes.md for your simulation."); diff --git a/scripts/scaffold-screens.ts b/scripts/scaffold-screens.ts new file mode 100644 index 0000000..ad1169e --- /dev/null +++ b/scripts/scaffold-screens.ts @@ -0,0 +1,702 @@ +#!/usr/bin/env tsx +/** + * scripts/scaffold-screens.ts + * + * Emits N screen packages from the template's `src/sim-screen/` prototype + * (fleet folder naming: `src/intro/`, not `intro-screen/`), wires main.ts, + * StringManager, locale JSON, and a stub `{Prefix}ScreenIcons.ts` module, then + * repoints CLAUDE.md / README.md / doc/*.md at the emitted screens. + * + * Run after `npm run rename` (create-sim always does both). Safe on a pristine + * template too (prefix stays `Sim`). + * + * Usage: + * npm run scaffold-screens -- --screens Intro,Lab + * npm run scaffold-screens -- --screens intro:Intro,"series-rlc:Series RLC" + * npm run scaffold-screens -- --screens Friction + * npm run scaffold-screens # one screen from package.json display name / id + * + * Options: + * --screens Comma-separated titles, or kebab:Title pairs + * --prefix Override sim prefix (default: detect from *Colors.ts) + * --shared-model Emit src/common/model/SharedModel.ts; each screen model composes it + */ + +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, relative, resolve } from "node:path"; + +// ── Argument parsing ────────────────────────────────────────────────────────── + +function getArg(flag: string): string | undefined { + const i = process.argv.indexOf(flag); + return i !== -1 ? process.argv[i + 1] : undefined; +} + +function hasFlag(flag: string): boolean { + return process.argv.includes(flag); +} + +const ROOT = resolve(process.cwd()); +const SRC = join(ROOT, "src"); + +interface ScreenSpec { + /** JSON / StringProperty key (camelCase): intro, seriesRlc */ + key: string; + /** Folder name (kebab): intro, series-rlc */ + kebab: string; + /** Class prefix (Pascal): Intro, SeriesRlc */ + pascal: string; + /** Display title: Intro, Series RLC */ + title: string; + /** Tandem id: introScreen */ + tandem: string; +} + +function toKebab(input: string): string { + return input + .trim() + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/[^a-zA-Z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase(); +} + +function kebabToCamel(kebab: string): string { + return kebab.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase()); +} + +function kebabToPascal(kebab: string): string { + const camel = kebabToCamel(kebab); + return camel.charAt(0).toUpperCase() + camel.slice(1); +} + +function titleToPascal(title: string): string { + return title + .split(/\s+/) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join("") + .replace(/[^A-Za-z0-9]/g, ""); +} + +function parseScreens(raw: string | undefined): ScreenSpec[] { + if (!raw || raw.trim() === "") { + // Default: one screen from package name / README title leftovers + const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { + name?: string; + description?: string; + }; + const id = pkg.name && pkg.name !== "scenerystack-template" ? pkg.name : "sim"; + const titleGuess = + // Prefer vite/html title already rewritten by rename + (() => { + try { + const html = readFileSync(join(ROOT, "index.html"), "utf8"); + const m = html.match(/([^<]+)<\/title>/); + if (m?.[1] && m[1] !== "SceneryStack Template") { + return m[1].trim(); + } + } catch { + // ignore + } + return null; + })() ?? kebabToPascal(id); + return [specFromParts(toKebab(id), titleGuess)]; + } + + // Split on commas not inside quotes + const parts: string[] = []; + let cur = ""; + let inQuotes = false; + for (const ch of raw) { + if (ch === '"' && !inQuotes) { + inQuotes = true; + continue; + } + if (ch === '"' && inQuotes) { + inQuotes = false; + continue; + } + if (ch === "," && !inQuotes) { + if (cur.trim()) { + parts.push(cur.trim()); + } + cur = ""; + continue; + } + cur += ch; + } + if (cur.trim()) { + parts.push(cur.trim()); + } + + return parts.map((part) => { + const colon = part.indexOf(":"); + if (colon !== -1) { + const kebab = toKebab(part.slice(0, colon)); + const title = part.slice(colon + 1).trim(); + return specFromParts(kebab, title); + } + const title = part.trim(); + return specFromParts(toKebab(title), title); + }); +} + +function specFromParts(kebab: string, title: string): ScreenSpec { + if (!kebab) { + console.error("Invalid screen name (empty kebab)."); + process.exit(1); + } + const pascal = titleToPascal(title) || kebabToPascal(kebab); + const key = kebabToCamel(kebab); + return { + key, + kebab, + pascal, + title, + tandem: `${key}Screen`, + }; +} + +function detectPrefix(explicit: string | undefined): string { + if (explicit) { + return explicit; + } + const colors = readdirSync(SRC).filter((f) => f.endsWith("Colors.ts")); + const only = colors.length === 1 ? colors[0] : undefined; + if (only) { + return basename(only, "Colors.ts"); + } + if (existsSync(join(SRC, "SimColors.ts"))) { + return "Sim"; + } + console.error("Could not detect sim prefix from *Colors.ts; pass --prefix."); + process.exit(1); +} + +function replaceAll(str: string, search: string, replacement: string): string { + return str.split(search).join(replacement); +} + +function transformPrototype(text: string, screen: ScreenSpec, simPrefix: string): string { + // Longest class names first + let out = text; + const pairs: Array<[string, string]> = [ + ["SimScreenSummaryContent", `${screen.pascal}ScreenSummaryContent`], + ["SimKeyboardHelpContent", `${screen.pascal}KeyboardHelpContent`], + ["SimScreenView", `${screen.pascal}ScreenView`], + ["SimScreen", `${screen.pascal}Screen`], + ["SimModel", `${screen.pascal}Model`], + ["getA11yStrings()", `get${screen.pascal}A11yStrings()`], + ]; + for (const [from, to] of pairs) { + out = replaceAll(out, from, to); + } + // The prototype header tells the reader to run scaffold-screens; in emitted screens that + // advice is stale, so point at main.ts and the shared icon module instead. + out = out.replace( + / \* For multi-screen simulations, run `npm run scaffold-screens` \(preferred\) or\n \* duplicate this file \(e\.g\. IntroScreen\.ts, LabScreen\.ts\), add each screen to the\n \* screens array in src\/main\.ts, and put shared create\*Icon\(\) factories in\n \* src\/common\/\{SimName\}ScreenIcons\.ts \(see doc\/multi-screen\.md\)\.\n/, + ` * Registered in the screens array in src/main.ts. Its home-screen and navigation-bar\n * icons come from create${screen.pascal}Icon() in src/common/${simPrefix}ScreenIcons.ts\n * (see doc/multi-screen.md).\n`, + ); + + // Placeholder label: prefer screen title over leftover sim title + out = out.replace( + /new Text\("([^"]*)", \{/, + `new Text("${screen.title.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}", {`, + ); + + // Inject screen icons into Screen class defaults when transforming *Screen.ts + if (out.includes(`export class ${screen.pascal}Screen`) && !out.includes("homeScreenIcon")) { + const iconImport = `import { create${screen.pascal}Icon } from "../common/${simPrefix}ScreenIcons.js";\n`; + out = out.replace( + `import ${simPrefix}Colors from "../${simPrefix}Colors.js";\n`, + `import ${simPrefix}Colors from "../${simPrefix}Colors.js";\n${iconImport}`, + ); + // Also handle still-SimColors path if somehow unchanged + if (!out.includes(`${simPrefix}ScreenIcons`)) { + out = out.replace(/import (\w+)Colors from "\.\.\/\1Colors\.js";\n/, (m) => `${m}${iconImport}`); + } + out = out.replace( + `createKeyboardHelpNode: () => new ${screen.pascal}KeyboardHelpContent(),`, + [ + `createKeyboardHelpNode: () => new ${screen.pascal}KeyboardHelpContent(),`, + ` homeScreenIcon: create${screen.pascal}Icon(),`, + ` navigationBarIcon: create${screen.pascal}Icon(),`, + ].join("\n"), + ); + } + + return out; +} + +function findPrototypeDir(): string { + const preferred = join(SRC, "sim-screen"); + if (existsSync(preferred) && existsSync(join(preferred, "SimScreen.ts"))) { + return preferred; + } + // Legacy post-rename layout: {id}-screen/ + for (const entry of readdirSync(SRC)) { + const full = join(SRC, entry); + if (!(statSync(full).isDirectory() && entry.endsWith("-screen"))) { + continue; + } + const screens = readdirSync(full).filter((f) => f.endsWith("Screen.ts") && !f.includes("View")); + if (screens.length === 1) { + return full; + } + } + console.error("No prototype screen package found (expected src/sim-screen/)."); + process.exit(1); +} + +function walkFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...walkFiles(full)); + } else { + out.push(full); + } + } + return out; +} + +function emitScreenPackage(protoDir: string, screen: ScreenSpec, simPrefix: string): void { + const destRoot = join(SRC, screen.kebab); + if (existsSync(destRoot)) { + console.error(`Refusing to overwrite existing screen folder: src/${screen.kebab}/`); + process.exit(1); + } + + // Map prototype relative paths → dest paths with renamed filenames + const files = walkFiles(protoDir); + for (const from of files) { + const rel = relative(protoDir, from); + const destRel = rel + .split(/[/\\]/) + .map((seg) => { + let s = seg; + s = replaceAll(s, "SimScreenSummaryContent", `${screen.pascal}ScreenSummaryContent`); + s = replaceAll(s, "SimKeyboardHelpContent", `${screen.pascal}KeyboardHelpContent`); + s = replaceAll(s, "SimScreenView", `${screen.pascal}ScreenView`); + s = replaceAll(s, "SimScreen", `${screen.pascal}Screen`); + s = replaceAll(s, "SimModel", `${screen.pascal}Model`); + // Legacy renamed prototype files ({Prefix}Screen.ts) + if (simPrefix !== "Sim") { + s = replaceAll(s, `${simPrefix}ScreenSummaryContent`, `${screen.pascal}ScreenSummaryContent`); + s = replaceAll(s, `${simPrefix}KeyboardHelpContent`, `${screen.pascal}KeyboardHelpContent`); + s = replaceAll(s, `${simPrefix}ScreenView`, `${screen.pascal}ScreenView`); + s = replaceAll(s, `${simPrefix}Screen`, `${screen.pascal}Screen`); + s = replaceAll(s, `${simPrefix}Model`, `${screen.pascal}Model`); + } + return s; + }) + .join("/"); + + const to = join(destRoot, destRel); + mkdirSync(dirname(to), { recursive: true }); + let text = readFileSync(from, "utf8"); + + // If prototype was already renamed at class level, map Prefix* → screen* + if (simPrefix !== "Sim" && text.includes(`${simPrefix}Screen`)) { + text = replaceAll(text, `${simPrefix}ScreenSummaryContent`, "SimScreenSummaryContent"); + text = replaceAll(text, `${simPrefix}KeyboardHelpContent`, "SimKeyboardHelpContent"); + text = replaceAll(text, `${simPrefix}ScreenView`, "SimScreenView"); + text = replaceAll(text, `${simPrefix}Screen`, "SimScreen"); + text = replaceAll(text, `${simPrefix}Model`, "SimModel"); + } + + text = transformPrototype(text, screen, simPrefix); + writeFileSync(to, text, "utf8"); + console.log(` wrote ${relative(ROOT, to)}`); + } +} + +function writeScreenIcons(screens: ScreenSpec[], simPrefix: string): void { + const path = join(SRC, "common", `${simPrefix}ScreenIcons.ts`); + const exports = screens + .map( + (s) => ` +export function create${s.pascal}Icon(): ScreenIcon { + return iconFrom( + new Node({ + children: [background()], + }), + ); +}`, + ) + .join("\n"); + + const body = `/** + * ${simPrefix}ScreenIcons.ts + * + * Programmatic home-screen / navigation-bar icons for each screen. + * Drawn on the standard PhET 548 × 373 canvas using ${simPrefix}Colors. + * Replace the stub backgrounds with screen-specific motifs. + */ +import { Node, Rectangle } from "scenerystack/scenery"; +import { ScreenIcon } from "scenerystack/sim"; +import ${simPrefix}Colors from "../${simPrefix}Colors.js"; + +const W = 548; +const H = 373; + +function background(): Rectangle { + return new Rectangle(0, 0, W, H, { fill: ${simPrefix}Colors.backgroundColorProperty }); +} + +function iconFrom(content: Node): ScreenIcon { + return new ScreenIcon(content, { + maxIconWidthProportion: 1, + maxIconHeightProportion: 1, + fill: ${simPrefix}Colors.backgroundColorProperty, + }); +} +${exports} +`; + writeFileSync(path, body, "utf8"); + console.log(` wrote ${relative(ROOT, path)}`); +} + +function updateLocaleFiles(screens: ScreenSpec[]): void { + const localeFiles = ["strings_en.json", "strings_es.json", "strings_fr.json"]; + for (const file of localeFiles) { + const path = join(SRC, "i18n", file); + const json = JSON.parse(readFileSync(path, "utf8")) as { + title: string; + screens: Record<string, string>; + a11y: Record<string, unknown>; + preferences: unknown; + }; + + const flatA11y = json.a11y; + // Detect the shape structurally: the single-screen prototype has screenSummary at the + // top level, a scaffolded sim has it one level down under each screen key. Never infer + // from the requested screen keys — a screen named "Controls" collides with the + // prototype's a11y.controls block and would nest the wrong subtree. + const isFlat = Object.hasOwn(flatA11y, "screenSummary"); + const firstNestedKey = Object.keys(flatA11y)[0]; + const templateA11y = isFlat + ? flatA11y + : firstNestedKey !== undefined + ? (flatA11y[firstNestedKey] as Record<string, unknown>) + : flatA11y; + + const screensObj: Record<string, string> = {}; + const a11yObj: Record<string, unknown> = {}; + for (const s of screens) { + screensObj[s.key] = s.title; + // Keep English template copy for es/fr stubs (translator can refine later) + a11yObj[s.key] = structuredClone(templateA11y); + } + json.screens = screensObj; + json.a11y = a11yObj; + writeFileSync(path, `${JSON.stringify(json, null, 2)}\n`, "utf8"); + console.log(` updated ${relative(ROOT, path)}`); + } +} + +function updateStringManager(screens: ScreenSpec[]): void { + const path = join(SRC, "i18n", "StringManager.ts"); + let text = readFileSync(path, "utf8"); + + const nameFields = screens.map((s) => ` readonly ${s.key}StringProperty: ReadOnlyProperty<string>;`).join("\n"); + const nameReturns = screens + .map((s) => ` ${s.key}StringProperty: stringProperties.screens.${s.key}StringProperty,`) + .join("\n"); + + const a11yGetters = screens + .map( + (s) => ` /** Accessibility strings for the ${s.title} screen. */ + public get${s.pascal}A11yStrings() { + return stringProperties.a11y.${s.key}; + }`, + ) + .join("\n\n"); + + const methods = ` /** + * The simulation title shown in the navigation bar and browser tab. + * Updates automatically when the locale changes. + */ + public getTitleStringProperty(): ReadOnlyProperty<string> { + return stringProperties.titleStringProperty; + } + + /** + * Screen name StringProperties used when constructing Screen instances. + * Each property updates automatically when the locale changes. + */ + public getScreenNames(): { +${nameFields} + } { + return { +${nameReturns} + }; + } + +${a11yGetters} + + /** + * Simulation-specific preference labels shown in Preferences → Simulation. + */ + public getPreferences() { + return stringProperties.preferences; + } +} +`; + + // Replace from the first instance method through the end of the class. + const start = text.indexOf(" /**\n * The simulation title"); + const altStart = text.indexOf(" public getTitleStringProperty"); + const cut = start !== -1 ? start : altStart; + if (cut === -1) { + console.error("StringManager.ts: could not find getTitleStringProperty to rewrite."); + process.exit(1); + } + // Drop any leftover methods if title block was already removed (re-run / partial) + const classOpen = text.indexOf("export class StringManager"); + const getInstanceEnd = text.indexOf("return StringManager.instance;", classOpen); + const afterGetInstance = text.indexOf("}", getInstanceEnd); + const afterBlock = text.indexOf("}", afterGetInstance + 1); + // Prefer cutting at title JSDoc when present; else right after getInstance() + const rewriteFrom = cut !== -1 ? cut : afterBlock + 1; + const before = text.slice(0, rewriteFrom).replace(/\s+$/, "\n\n"); + text = `${before}${methods}`; + + writeFileSync(path, text, "utf8"); + console.log(` updated ${relative(ROOT, path)}`); +} + +function updateMain(screens: ScreenSpec[], simPrefix: string): void { + const path = join(SRC, "main.ts"); + let text = readFileSync(path, "utf8"); + + // Drop old screen imports (sim-screen or any ./…/…Screen.js) + text = text.replace(/^import \{ \w+Screen \} from "\.\/.+Screen\.js";\n/gm, ""); + text = text.replace(/^import \{ \w+RootModel \} from "\.\/model\/\w+RootModel\.js";\n/gm, ""); + text = text.replace(/^import \{ SharedModel \} from "\.\/common\/model\/SharedModel\.js";\n/gm, ""); + + const imports = screens + .map((s) => `import { ${s.pascal}Screen } from "./${s.kebab}/${s.pascal}Screen.js";`) + .join("\n"); + + // Insert after SimColors / *Colors import + if (/import \w+Colors from "\.\/\w+Colors\.js";\n/.test(text)) { + text = text.replace(/(import \w+Colors from "\.\/\w+Colors\.js";\n)/, `$1${imports}\n`); + } else { + text = text.replace(/(import "\.\/brand\.js";\n)/, `$1\n${imports}\n`); + } + + const screenEntries = screens + .map( + (s) => ` new ${s.pascal}Screen({ + name: stringManager.getScreenNames().${s.key}StringProperty, + tandem: Tandem.ROOT.createTandem("${s.tandem}"), + backgroundColorProperty: ${simPrefix}Colors.backgroundColorProperty, + }),`, + ) + .join("\n"); + + const screensBlock = ` const screens = [ +${screenEntries} + ];`; + + text = text.replace(/ {2}const screens = \[[\s\S]*?\];/, screensBlock); + + writeFileSync(path, text, "utf8"); + console.log(` updated ${relative(ROOT, path)}`); +} + +/** + * Docs shipped with the template point at the `sim-screen/` prototype and its `Sim*` + * classes, which no longer exist once screens are emitted. Rewrite those references to + * the first screen (as the fleet forks did by hand) so no `Sim*` name survives the fork. + */ +function updateDocs(screens: ScreenSpec[], simPrefix: string): void { + const first = screens[0]; + if (!first) { + return; + } + const docDir = join(ROOT, "doc"); + const paths = [join(ROOT, "CLAUDE.md"), join(ROOT, "README.md")]; + if (existsSync(docDir)) { + for (const entry of readdirSync(docDir)) { + if (entry.endsWith(".md")) { + paths.push(join(docDir, entry)); + } + } + } + + // Longest names first so shorter ones do not eat their prefixes. + const pairs: Array<[string, string]> = [ + ["src/sim-screen", `src/${first.kebab}`], + ["sim-screen/", `${first.kebab}/`], + ["SimScreenSummaryContent", `${first.pascal}ScreenSummaryContent`], + ["SimKeyboardHelpContent", `${first.pascal}KeyboardHelpContent`], + ["SimScreenView", `${first.pascal}ScreenView`], + ["SimScreenOptions", `${first.pascal}ScreenOptions`], + ["SimScreen", `${first.pascal}Screen`], + ["SimModel", `${first.pascal}Model`], + ["getA11yStrings()", `get${first.pascal}A11yStrings()`], + ["{SimName}ScreenIcons", `${simPrefix}ScreenIcons`], + ]; + + // Template-only prose in doc/multi-screen.md: token substitution alone would leave a + // fork claiming to ship an un-scaffolded prototype. + const screenList = screens.map((s) => `\`src/${s.kebab}/\``).join(", "); + const prose: Array<[string, string]> = [ + [ + "This template ships as a **single-screen** prototype (`src/sim-screen/`). New sims\nshould call `npm run scaffold-screens` (or `Baton/scripts/create-sim.sh`) so screen\nfolders use fleet naming (`src/intro/`, not `intro-screen/`). This guide covers the\narchitecture and how to extend an existing sim by hand.", + `This sim was scaffolded by \`npm run scaffold-screens\` into ${screenList} (fleet naming:\nkebab folders with no \`-screen\` suffix). This guide covers the architecture and how to add\nanother screen by hand.`, + ], + [ + "## Automated scaffold (preferred)\n\nAfter `npm run rename` (or via `create-sim.sh`):", + "## Automated scaffold (preferred)\n\nAlready run for this sim — the prototype folder is gone, so a second run exits early.\nKept as reference for the next sim:", + ], + ]; + + for (const path of paths) { + if (!existsSync(path)) { + continue; + } + const original = readFileSync(path, "utf8"); + let text = original; + if (basename(path) === "multi-screen.md") { + for (const [from, to] of prose) { + text = replaceAll(text, from, to); + } + } + for (const [from, to] of pairs) { + text = replaceAll(text, from, to); + } + if (text !== original) { + writeFileSync(path, text, "utf8"); + console.log(` updated ${relative(ROOT, path)}`); + } + } +} + +/** + * Fleet pattern (ACPhasor RlcCircuitModel, RotatingSky SkyModel, MotionsOfTheSun + * TimeMaster): domain helpers live under common/model/ and each screen model + * composes its own instance. Rename SharedModel to a domain noun when you know it. + */ +function writeSharedModel(): void { + const dir = join(SRC, "common", "model"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "SharedModel.ts"); + const body = `/** + * SharedModel.ts + * + * Stub for cross-screen physics/state helpers. Lives under common/model/ per + * Baton CONVENTIONS (no top-level src/model/). Fleet sims use domain names here + * (e.g. SkyModel, RlcCircuitModel, TimeMaster) — rename this file when the + * domain is clear. + * + * Each screen model typically owns its own instance (\`new SharedModel()\`), matching + * ACPhasor / RotatingSky. For a single live instance shared across screens, construct + * once in main.ts and pass it into each Screen/Model instead. + */ +import { BooleanProperty } from "scenerystack/axon"; + +export class SharedModel { + /** Example shared toggle — replace with real cross-screen Properties. */ + public readonly exampleEnabledProperty = new BooleanProperty(false); + + public reset(): void { + this.exampleEnabledProperty.reset(); + } +} +`; + writeFileSync(path, body, "utf8"); + console.log(` wrote ${relative(ROOT, path)}`); +} + +/** + * Compose SharedModel into each screen model (fleet style — not injected from main). + */ +function wireSharedModel(screens: ScreenSpec[]): void { + for (const screen of screens) { + const modelPath = join(SRC, screen.kebab, "model", `${screen.pascal}Model.ts`); + let model = readFileSync(modelPath, "utf8"); + if (model.includes("SharedModel")) { + continue; + } + + model = model.replace( + /import type \{ TModel \} from "scenerystack\/joist";\n/, + `import type { TModel } from "scenerystack/joist";\nimport { SharedModel } from "../../common/model/SharedModel.js";\n`, + ); + model = model.replace( + new RegExp(`export class ${screen.pascal}Model implements TModel \\{\\n`), + `export class ${screen.pascal}Model implements TModel {\n` + + ` /** Shared helpers — rename SharedModel to a domain type when known. */\n` + + ` public readonly shared = new SharedModel();\n\n`, + ); + if (model.includes("public reset(): void {") && !model.includes("this.shared.reset()")) { + model = model.replace(/public reset\(\): void \{\n/, "public reset(): void {\n this.shared.reset();\n"); + } + writeFileSync(modelPath, model, "utf8"); + console.log(` updated ${relative(ROOT, modelPath)}`); + } +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +function main(): void { + const screenList = parseScreens(getArg("--screens")); + const prefix = detectPrefix(getArg("--prefix")); + const sharedModel = hasFlag("--shared-model"); + const protoDir = findPrototypeDir(); + + // Validate unique kebabs / keys + const kebabs = new Set<string>(); + const keys = new Set<string>(); + for (const s of screenList) { + if (kebabs.has(s.kebab) || keys.has(s.key)) { + console.error(`Duplicate screen id: ${s.kebab} / ${s.key}`); + process.exit(1); + } + kebabs.add(s.kebab); + keys.add(s.key); + } + + console.log("\nScaffolding screens:"); + for (const s of screenList) { + console.log(` - ${s.title} → src/${s.kebab}/ (${s.pascal}*, key=${s.key})`); + } + console.log(` sim prefix: ${prefix}`); + console.log(` prototype: ${relative(ROOT, protoDir)}`); + console.log(` shared model (common/model): ${sharedModel ? "yes" : "no"}`); + console.log(""); + + for (const s of screenList) { + emitScreenPackage(protoDir, s, prefix); + } + + writeScreenIcons(screenList, prefix); + updateLocaleFiles(screenList); + updateStringManager(screenList); + if (sharedModel) { + writeSharedModel(); + wireSharedModel(screenList); + } + updateMain(screenList, prefix); + + console.log("\nRewriting docs that referenced the prototype…"); + updateDocs(screenList, prefix); + + console.log("\nRemoving prototype screen package…"); + rmSync(protoDir, { recursive: true, force: true }); + console.log(` removed ${relative(ROOT, protoDir)}`); + + console.log("\nDone."); + console.log("\nNext steps:"); + console.log(" 1. npm run fix (emitted imports need Biome's organizeImports pass)"); + console.log(" 2. npm run check"); + if (screenList.length > 1) { + console.log(` 3. docs now reference the ${screenList[0]?.title} screen only — tailor them for all screens`); + } +} + +main(); diff --git a/src/HeatTransferColors.ts b/src/HeatTransferColors.ts new file mode 100644 index 0000000..890d5f1 --- /dev/null +++ b/src/HeatTransferColors.ts @@ -0,0 +1,161 @@ +/** + * HeatTransferColors.ts + * + * Every dynamic colour in the simulation, as a `ProfileColorProperty` with a + * `default` (dark) and a `projector` (light) value. + * + * The field itself is *not* coloured from here — temperature is mapped through + * the ramp in `common/field/ColorMap.ts`, which is a quantitative encoding rather + * than a theme choice and must stay identical in both profiles or the legend + * would lie. What is here is everything drawn *over* the field (contours, arrows, + * tracers, tools) and the ordinary UI chrome. The overlay colours are handed to + * the field engine each frame as a `FieldRenderStyle`, so the WGSL render passes + * follow the active profile without knowing that profiles exist. + */ +import { ProfileColorProperty } from "scenerystack/scenery"; +import HeatTransferNamespace from "./HeatTransferNamespace.js"; + +const HeatTransferColors = { + /** + * Background colour for the simulation screens. + * Deep navy in default mode; white in projector mode. + */ + backgroundColorProperty: new ProfileColorProperty(HeatTransferNamespace, "background", { + default: "#12141f", + projector: "#ffffff", + }), + + /** + * Primary accent for highlights, selected items, and key UI elements. + * Warm amber in both profiles — it reads as "heat" and stays legible on either + * background. + */ + accentColorProperty: new ProfileColorProperty(HeatTransferNamespace, "accent", { + default: "#ffb347", + projector: "#b35c00", + }), + + /** Background fill for control panels and dialogs. */ + panelBackgroundColorProperty: new ProfileColorProperty(HeatTransferNamespace, "panelBackground", { + default: "#1b1f2e", + projector: "#f5f5f5", + }), + + /** Border / stroke colour for control panels and dialogs. */ + panelBorderColorProperty: new ProfileColorProperty(HeatTransferNamespace, "panelBorder", { + default: "#333a52", + projector: "#999999", + }), + + /** Text colour for labels, readouts, and general UI text on the panel fill. */ + textColorProperty: new ProfileColorProperty(HeatTransferNamespace, "text", { + default: "#e6e6ea", + projector: "#1a1a1a", + }), + + /** Secondary text: units, hints, and annotations. */ + secondaryTextColorProperty: new ProfileColorProperty(HeatTransferNamespace, "secondaryText", { + default: "#9aa0b4", + projector: "#5a5a5a", + }), + + // ── Field overlays ────────────────────────────────────────────────────────── + // Drawn on top of the temperature colour map, so these must contrast with the + // *ramp*, not with the page background. They change only slightly between + // profiles: the ramp is the same in both, so what works over it works over it. + + /** Isotherm contour lines. */ + isothermColorProperty: new ProfileColorProperty(HeatTransferNamespace, "isotherm", { + default: "#12141f", + projector: "#12141f", + }), + + /** Heat-flux arrows. */ + heatFluxColorProperty: new ProfileColorProperty(HeatTransferNamespace, "heatFlux", { + default: "#f2f2f5", + projector: "#111111", + }), + + /** Velocity tracer particles. */ + particleColorProperty: new ProfileColorProperty(HeatTransferNamespace, "particle", { + default: "#ffffff", + projector: "#1a1a1a", + }), + + /** Frame drawn around the field view. */ + fieldBorderColorProperty: new ProfileColorProperty(HeatTransferNamespace, "fieldBorder", { + default: "#4a5170", + projector: "#666666", + }), + + // ── Tools ─────────────────────────────────────────────────────────────────── + + /** Body of the temperature probe. */ + probeColorProperty: new ProfileColorProperty(HeatTransferNamespace, "probe", { + default: "#e6e6ea", + projector: "#1a1a1a", + }), + + /** The cross-section line and its handles. */ + crossSectionColorProperty: new ProfileColorProperty(HeatTransferNamespace, "crossSection", { + default: "#7fe3ff", + projector: "#005f87", + }), + + /** Ring showing where the brush will deposit. */ + brushOutlineColorProperty: new ProfileColorProperty(HeatTransferNamespace, "brushOutline", { + default: "#ffffff", + projector: "#222222", + }), + + // ── Graph ─────────────────────────────────────────────────────────────────── + + /** Plot background for the cross-section graph. */ + graphBackgroundColorProperty: new ProfileColorProperty(HeatTransferNamespace, "graphBackground", { + default: "#0d0f18", + projector: "#ffffff", + }), + + /** Axes and frame of the cross-section graph. */ + graphAxisColorProperty: new ProfileColorProperty(HeatTransferNamespace, "graphAxis", { + default: "#5a6280", + projector: "#777777", + }), + + /** The T(s) curve on the cross-section graph. */ + temperatureCurveColorProperty: new ProfileColorProperty(HeatTransferNamespace, "temperatureCurve", { + default: "#ffb347", + projector: "#c1440e", + }), + + /** The q_s(s) curve on the cross-section graph. */ + fluxCurveColorProperty: new ProfileColorProperty(HeatTransferNamespace, "fluxCurve", { + default: "#7fe3ff", + projector: "#005f87", + }), + + // ── Light control surfaces ────────────────────────────────────────────────── + // White chrome (combo boxes, flat push buttons, editable input fields) stays + // light in both profiles; its text stays dark. Identical values in both + // profiles, but defined here so every colour lives in one themeable place. + + /** Fill of light control surfaces: combo-box button/list, editable input fields. */ + controlSurfaceColorProperty: new ProfileColorProperty(HeatTransferNamespace, "controlSurface", { + default: "#ffffff", + projector: "#ffffff", + }), + + /** Fill of a disabled control surface (grayed-out editable input field). */ + controlSurfaceDisabledColorProperty: new ProfileColorProperty(HeatTransferNamespace, "controlSurfaceDisabled", { + default: "#cccccc", + projector: "#cccccc", + }), + + /** Text on light control surfaces: combo items, flat-button labels, preferences. */ + controlSurfaceTextColorProperty: new ProfileColorProperty(HeatTransferNamespace, "controlSurfaceText", { + default: "#1a1a1a", + projector: "#1a1a1a", + }), +}; + +export default HeatTransferColors; diff --git a/src/HeatTransferConstants.ts b/src/HeatTransferConstants.ts new file mode 100644 index 0000000..c4201dd --- /dev/null +++ b/src/HeatTransferConstants.ts @@ -0,0 +1,229 @@ +/** + * HeatTransferConstants.ts + * + * Every named numeric constant used across the simulation. + * + * Conventions + * ─────────── + * - Physics / model values use SI units; the unit is named in the comment or + * the identifier suffix (`_M`, `_S`, `_K`, `_W_PER_M_K`, …). + * - Layout / chrome values are in screen pixels of the 1024 x 618 layout bounds. + * - Colours live in HeatTransferColors.ts, not here. + */ + +import HeatTransferNamespace from "./HeatTransferNamespace.js"; + +// ── Layout / chrome (screen pixels) ─────────────────────────────────────────── + +/** Margin between the screen edge and edge-anchored controls (e.g. Reset All). */ +export const SCREEN_VIEW_MARGIN = 20; + +/** Corner radius shared by control panels and dialogs. */ +export const PANEL_CORNER_RADIUS = 6; + +/** Vertical spacing between stacked control panels. */ +export const PANEL_SPACING = 10; + +/** Side length of the square field view, in screen pixels. */ +export const FIELD_VIEW_SIZE = 480; + +/** Left edge of the field view. */ +export const FIELD_VIEW_LEFT = 25; + +/** Top edge of the field view. */ +export const FIELD_VIEW_TOP = 55; + +/** Left edge of the temperature legend, just right of the field. */ +export const LEGEND_LEFT = 517; + +/** Left edge of the first control column. */ +export const CONTROL_COLUMN_LEFT = 590; + +/** Left edge of the second control column. */ +export const CONTROL_COLUMN_RIGHT = 804; + +/** Minimum width of a control panel. Two columns of this fill the control area. */ +export const CONTROL_PANEL_WIDTH = 200; + +/** Full width of the control area, spanning both columns. */ +export const CONTROL_AREA_WIDTH = 414; + +/** Top of the full-width area under the control columns, used for the graph. */ +export const WIDE_AREA_TOP = 385; + +/** Font size for panel titles. */ +export const TITLE_FONT_SIZE = 15; + +/** Font size for ordinary control labels and readouts. */ +export const LABEL_FONT_SIZE = 13; + +/** Font size for small annotations (legend ticks, units). */ +export const SMALL_FONT_SIZE = 11; + +// ── Grid resolution ─────────────────────────────────────────────────────────── + +/** + * Named grid resolutions. The simulation is resolution-agnostic: these only + * choose how many cells the field engine allocates. The CPU fallback backend is + * clamped to {@link MAX_CPU_RESOLUTION} because it evolves the field on the main + * thread. + */ +export const RESOLUTION_PRESETS = { + classroom: 128, + high: 512, + large: 1024, + extreme: 2048, +} as const; + +export type ResolutionPresetId = keyof typeof RESOLUTION_PRESETS; + +/** Preset order for UI controls (coarse to fine). */ +export const RESOLUTION_PRESET_ORDER: readonly ResolutionPresetId[] = ["classroom", "high", "large", "extreme"]; + +/** The CPU fallback backend never allocates a grid finer than this. */ +export const MAX_CPU_RESOLUTION = 128; + +/** Resolution used when nothing else is specified. */ +export const DEFAULT_RESOLUTION: ResolutionPresetId = "classroom"; + +// ── Time integration ────────────────────────────────────────────────────────── + +/** + * Safety factor on the explicit-diffusion stability limit. The 2-D five-point + * Laplacian is stable for alpha*dt*(1/dx^2 + 1/dy^2) <= 1/2; staying at 40% of + * that keeps the scheme well away from the boundary on non-square cells. + */ +export const DIFFUSION_CFL = 0.4; + +/** + * Courant number for advection. Semi-Lagrangian backtracing is unconditionally + * stable, but accuracy degrades once a parcel jumps more than a cell or two per + * substep, so the substep is also capped by |v| dt / dx <= this. + */ +export const ADVECTION_CFL = 1.0; + +/** + * Diffusion substeps taken per animation frame at normal speed. The simulation + * always integrates at the stability-limited step, so this — not a wall-clock + * target — is what sets how fast the field evolves on screen. See doc/model.md. + */ +export const SUBSTEPS_PER_FRAME = 8; + +/** Multiplier applied to {@link SUBSTEPS_PER_FRAME} for TimeControlNode's slow speed. */ +export const SLOW_SPEED_FACTOR = 0.25; + +/** Largest frame delta the model will accept, in seconds (guards against tab-switch jumps). */ +export const MAX_FRAME_DT = 1 / 20; + +// ── Temperature ─────────────────────────────────────────────────────────────── + +/** Ambient / initial temperature of the plate, in kelvin (20 degrees Celsius). */ +export const AMBIENT_TEMPERATURE_K = 293.15; + +/** Coldest temperature the colour map resolves, in kelvin (-20 degrees Celsius). */ +export const MIN_TEMPERATURE_K = 253.15; + +/** Hottest temperature the colour map resolves, in kelvin (180 degrees Celsius). */ +export const MAX_TEMPERATURE_K = 453.15; + +/** Offset between kelvin and degrees Celsius. */ +export const KELVIN_TO_CELSIUS_OFFSET = 273.15; + +/** Temperature deposited by the heat brush at full strength, in kelvin. */ +export const HOT_BRUSH_TEMPERATURE_K = MAX_TEMPERATURE_K; + +/** Temperature deposited by the cool brush at full strength, in kelvin. */ +export const COOL_BRUSH_TEMPERATURE_K = MIN_TEMPERATURE_K; + +/** Spacing between isotherm contour lines, in kelvin. */ +export const ISOTHERM_INTERVAL_K = 10; + +// ── Brush ───────────────────────────────────────────────────────────────────── + +/** Brush radius as a fraction of the domain's shorter side. */ +export const DEFAULT_BRUSH_RADIUS_FRACTION = 0.08; + +/** Smallest selectable brush radius, as a fraction of the domain's shorter side. */ +export const MIN_BRUSH_RADIUS_FRACTION = 0.02; + +/** Largest selectable brush radius, as a fraction of the domain's shorter side. */ +export const MAX_BRUSH_RADIUS_FRACTION = 0.2; + +/** + * Fraction of the way to the brush temperature that a single application moves a + * cell at the brush centre. Repeated strokes saturate rather than overshoot. + */ +export const BRUSH_STRENGTH = 0.35; + +// ── Flow ────────────────────────────────────────────────────────────────────── + +/** Flow speed at the "fast" end of the speed control, in metres per second. */ +export const MAX_FLOW_SPEED = 0.01; + +/** Default flow speed, in metres per second. */ +export const DEFAULT_FLOW_SPEED = 0.002; + +// ── Visualization ───────────────────────────────────────────────────────────── + +/** Number of heat-flux arrows across the field, per axis. */ +export const FLUX_ARROW_COUNT = 20; + +/** Longest an arrow may be drawn, as a fraction of the field's on-screen size. */ +export const MAX_ARROW_LENGTH_FRACTION = 0.055; + +/** Number of tracer particles used to visualize the velocity field. */ +export const PARTICLE_COUNT = 1800; + +/** Tracer particle lifetime, in seconds, before it respawns at a random position. */ +export const PARTICLE_LIFETIME_S = 6; + +/** How many animation frames pass between GPU-to-CPU field readbacks. */ +export const READBACK_FRAME_INTERVAL = 4; + +/** Number of samples taken along the cross-section line. */ +export const CROSS_SECTION_SAMPLES = 128; + +HeatTransferNamespace.register("HeatTransferConstants", { + ADVECTION_CFL, + AMBIENT_TEMPERATURE_K, + BRUSH_STRENGTH, + CONTROL_AREA_WIDTH, + CONTROL_COLUMN_LEFT, + CONTROL_COLUMN_RIGHT, + CONTROL_PANEL_WIDTH, + COOL_BRUSH_TEMPERATURE_K, + CROSS_SECTION_SAMPLES, + DEFAULT_BRUSH_RADIUS_FRACTION, + DEFAULT_FLOW_SPEED, + DEFAULT_RESOLUTION, + DIFFUSION_CFL, + FIELD_VIEW_LEFT, + FIELD_VIEW_SIZE, + FIELD_VIEW_TOP, + FLUX_ARROW_COUNT, + HOT_BRUSH_TEMPERATURE_K, + ISOTHERM_INTERVAL_K, + KELVIN_TO_CELSIUS_OFFSET, + LABEL_FONT_SIZE, + LEGEND_LEFT, + MAX_ARROW_LENGTH_FRACTION, + MAX_BRUSH_RADIUS_FRACTION, + MAX_CPU_RESOLUTION, + MAX_FLOW_SPEED, + MAX_FRAME_DT, + MAX_TEMPERATURE_K, + MIN_BRUSH_RADIUS_FRACTION, + MIN_TEMPERATURE_K, + PANEL_CORNER_RADIUS, + PANEL_SPACING, + PARTICLE_COUNT, + PARTICLE_LIFETIME_S, + READBACK_FRAME_INTERVAL, + RESOLUTION_PRESETS, + SCREEN_VIEW_MARGIN, + SLOW_SPEED_FACTOR, + SMALL_FONT_SIZE, + SUBSTEPS_PER_FRAME, + TITLE_FONT_SIZE, + WIDE_AREA_TOP, +}); diff --git a/src/HeatTransferNamespace.ts b/src/HeatTransferNamespace.ts new file mode 100644 index 0000000..fca5c51 --- /dev/null +++ b/src/HeatTransferNamespace.ts @@ -0,0 +1,16 @@ +/** + * HeatTransferNamespace.ts + * + * The SceneryStack Namespace for this simulation. It is used as the first + * argument to ProfileColorProperty (so color names are scoped to this sim) + * and optionally for registering objects with the PhET-iO API. + * + * ── How to customize ───────────────────────────────────────────────────────── + * Change the string argument to match your simulation's identifier, using the + * same kebab-case name as in package.json and src/init.ts. + */ +import { Namespace } from "scenerystack/phet-core"; + +const HeatTransferNamespace = new Namespace("heat-transfer"); + +export default HeatTransferNamespace; diff --git a/src/assert.ts b/src/assert.ts new file mode 100644 index 0000000..a7c917c --- /dev/null +++ b/src/assert.ts @@ -0,0 +1,18 @@ +/** + * assert.ts + * + * Enables SceneryStack runtime assertions. + * + * Chain position: init.ts → [here] assert.ts → splash.ts → brand.ts + * + * Assertions help catch programming errors during development and are + * stripped from SceneryStack's production bundle. To disable assertions + * for your own assert() calls in production, comment out enableAssert(). + */ + +// init.ts must run before assertions are enabled (chain order enforced by import) +import "./init.js"; + +import { enableAssert } from "scenerystack/assert"; + +enableAssert(); diff --git a/src/brand.ts b/src/brand.ts new file mode 100644 index 0000000..11f3da3 --- /dev/null +++ b/src/brand.ts @@ -0,0 +1,51 @@ +/** + * brand.ts + * + * Registers the SceneryStack brand for this simulation. + * + * Chain position: init.ts → assert.ts → splash.ts → [here] brand.ts + * + * !! THIS FILE MUST BE THE FIRST IMPORT IN src/main.ts !! + * + * The brand object controls what appears in the About dialog (Help → About), + * including the logo, copyright notice, and navigation links. + * + * ── How to customize ───────────────────────────────────────────────────────── + * - Set `name` to your organization name (shown in About dialog) + * - Set `copyright` to your copyright string, e.g. "© 2025 My Organization" + * - Implement `getLinks(simName, locale)` to return About-dialog links + * - Replace logo data URIs with your own if desired + * + * ── Note on the import path ────────────────────────────────────────────────── + * src/main.ts imports this file as `"./brand.js"`. TypeScript (in bundler mode) + * resolves `.js` extensions to `.ts` source files automatically — no renaming + * or extra config is needed. + */ + +// splash.ts (and transitively assert.ts and init.ts) must run before brand registration +import "./splash.js"; + +import type { TBrand } from "scenerystack/brand"; +import { brand, madeWithSceneryStackOnDark, madeWithSceneryStackOnLight } from "scenerystack/brand"; + +const Brand: TBrand = { + // Must match the brand id passed to init() in src/init.ts + id: "made-with-scenerystack", + + // Your organization name, or null to use the SceneryStack default + name: null, + + // Copyright string shown in the About dialog, or null to omit + copyright: null, + + // Returns About-dialog footer links for the given sim name and locale. + // LinkObject shape: { textStringProperty, url } + getLinks: () => [], + + // Logos shown on dark and light backgrounds respectively. + // Replace with your own data URIs to use a custom logo. + logoOnBlackBackground: madeWithSceneryStackOnDark, + logoOnWhiteBackground: madeWithSceneryStackOnLight, +}; + +brand.register("Brand", Brand); diff --git a/src/combined/HeatTransferScreen.ts b/src/combined/HeatTransferScreen.ts new file mode 100644 index 0000000..f8616fc --- /dev/null +++ b/src/combined/HeatTransferScreen.ts @@ -0,0 +1,54 @@ +/** + * HeatTransferScreen.ts + * + * Screen 4. Wires the model and view factories together and passes screen-level + * options to `Screen`. + * + * The preferences model rides on the options bag because a screen's field engine + * has to know its grid resolution at construction time, and SceneryStack builds a + * screen's model lazily — the first time a student opens the screen. + * + * Registered in the screens array in src/main.ts. Its home-screen and + * navigation-bar icons come from createHeatTransferIcon() in + * src/common/HeatTransferScreenIcons.ts (see doc/multi-screen.md). + */ +import { type EmptySelfOptions, optionize } from "scenerystack/phet-core"; +import type { ScreenOptions } from "scenerystack/sim"; +import { Screen } from "scenerystack/sim"; +import type { Tandem } from "scenerystack/tandem"; +import { createHeatTransferIcon } from "../common/HeatTransferScreenIcons.js"; +import HeatTransferColors from "../HeatTransferColors.js"; +import type { HeatTransferPreferencesModel } from "../preferences/HeatTransferPreferencesModel.js"; +import { HeatTransferModel } from "./model/HeatTransferModel.js"; +import { HeatTransferKeyboardHelpContent } from "./view/HeatTransferKeyboardHelpContent.js"; +import { HeatTransferScreenView } from "./view/HeatTransferScreenView.js"; + +// Require tandem to be explicit — accidental omission would break PhET-iO. +export type HeatTransferScreenOptions = ScreenOptions & { + tandem: Tandem; + preferences: HeatTransferPreferencesModel; +}; + +export class HeatTransferScreen extends Screen<HeatTransferModel, HeatTransferScreenView> { + public constructor(options: HeatTransferScreenOptions) { + super( + // Model factory — called once when the screen is first shown + () => new HeatTransferModel(options.preferences), + // View factory — receives the model instance + (model) => + new HeatTransferScreenView(model, { + tandem: options.tandem.createTandem("view"), + showFieldStatusProperty: options.preferences.showFieldStatusProperty, + }), + optionize<HeatTransferScreenOptions, EmptySelfOptions, ScreenOptions>()( + { + backgroundColorProperty: HeatTransferColors.backgroundColorProperty, + createKeyboardHelpNode: () => new HeatTransferKeyboardHelpContent(), + homeScreenIcon: createHeatTransferIcon(), + navigationBarIcon: createHeatTransferIcon(), + }, + options, + ), + ); + } +} diff --git a/src/combined/model/HeatTransferModel.ts b/src/combined/model/HeatTransferModel.ts new file mode 100644 index 0000000..33a25b2 --- /dev/null +++ b/src/combined/model/HeatTransferModel.ts @@ -0,0 +1,59 @@ +/** + * HeatTransferModel.ts + * + * Screen 4: the laboratory. Both mechanisms run, and the balance between them is + * the control. + * + * Every layer is available here because the point of the screen is that they are + * all views of one state: the same texture drives the colour map, the contour + * pass, the flux arrows, and the gradient overlay, and the tracer particles ride + * the same velocity field the advection pass reads. Nothing a checkbox does can + * change the simulation. + */ +import type { TModel } from "scenerystack/joist"; +import { BoundaryCondition, FlowPreset, InitialCondition } from "../../common/field/FieldTypes.js"; +import { FieldSimulationModel } from "../../common/model/FieldSimulationModel.js"; +import { FIELD_VIEW_SIZE } from "../../HeatTransferConstants.js"; +import type { HeatTransferPreferencesModel } from "../../preferences/HeatTransferPreferencesModel.js"; + +/** Backing-canvas resolution multiplier, so the field is crisp on high-DPI displays. */ +const CANVAS_SCALE = 2; + +export class HeatTransferModel implements TModel { + public readonly field: FieldSimulationModel; + + public constructor(preferences: HeatTransferPreferencesModel) { + this.field = new FieldSimulationModel({ + advectionEnabled: true, + boundaryCondition: BoundaryCondition.PERIODIC, + defaultLayers: { + temperature: true, + isotherms: false, + heatFlux: true, + velocity: true, + gradient: false, + material: false, + }, + initialCondition: InitialCondition.HOT_SPOT, + initialFlowPreset: FlowPreset.CHANNEL, + resolution: preferences.resolutionProperty.value, + displaySize: FIELD_VIEW_SIZE * CANVAS_SCALE, + initiallyPlaying: true, + }); + + this.field.materialIdProperty.value = "steel"; + } + + public step(dt: number): void { + this.field.step(dt); + } + + public reset(): void { + this.field.reset(); + this.field.materialIdProperty.value = "steel"; + } + + public dispose(): void { + this.field.dispose(); + } +} diff --git a/src/combined/view/HeatTransferKeyboardHelpContent.ts b/src/combined/view/HeatTransferKeyboardHelpContent.ts new file mode 100644 index 0000000..113d0db --- /dev/null +++ b/src/combined/view/HeatTransferKeyboardHelpContent.ts @@ -0,0 +1,29 @@ +/** + * HeatTransferKeyboardHelpContent.ts + * + * Content for the keyboard-help dialog (the "?" button in the navigation bar). + * This screen paints on the field, drags the probe, and uses a slider and + * checkboxes, so the left column carries the sim-specific paint section plus the + * stock slider and drag sections. + */ + +import { + BasicActionsKeyboardHelpSection, + MoveDraggableItemsKeyboardHelpSection, + SliderControlsKeyboardHelpSection, + TwoColumnKeyboardHelpContent, +} from "scenerystack/scenery-phet"; +import { HeatBrushKeyboardHelpSection } from "../../common/view/HeatBrushKeyboardHelpSection.js"; + +export class HeatTransferKeyboardHelpContent extends TwoColumnKeyboardHelpContent { + public constructor() { + super( + [ + new HeatBrushKeyboardHelpSection(), + new MoveDraggableItemsKeyboardHelpSection(), + new SliderControlsKeyboardHelpSection(), + ], + [new BasicActionsKeyboardHelpSection({ withCheckboxContent: true })], + ); + } +} diff --git a/src/combined/view/HeatTransferScreenSummaryContent.ts b/src/combined/view/HeatTransferScreenSummaryContent.ts new file mode 100644 index 0000000..6d2bda0 --- /dev/null +++ b/src/combined/view/HeatTransferScreenSummaryContent.ts @@ -0,0 +1,29 @@ +/** + * HeatTransferScreenSummaryContent.ts + * + * The accessible screen summary for the Heat Transfer screen. `currentDetailsContent` + * is derived live from the field's coldest and hottest points, so re-reading the + * summary reports the present state of the plate rather than how it started. + */ +import { DerivedProperty, PatternStringProperty } from "scenerystack/axon"; +import { ScreenSummaryContent } from "scenerystack/sim"; +import { formatCelsiusRounded, formatPeclet } from "../../common/view/formatters.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { HeatTransferModel } from "../model/HeatTransferModel.js"; + +export class HeatTransferScreenSummaryContent extends ScreenSummaryContent { + public constructor(model: HeatTransferModel) { + const a11y = StringManager.getInstance().getHeatTransferA11yStrings(); + + super({ + playAreaContent: a11y.screenSummary.playAreaStringProperty, + controlAreaContent: a11y.screenSummary.controlAreaStringProperty, + currentDetailsContent: new PatternStringProperty(a11y.currentDetailsStringProperty, { + min: new DerivedProperty([model.field.minTemperatureProperty], (kelvin) => formatCelsiusRounded(kelvin)), + max: new DerivedProperty([model.field.maxTemperatureProperty], (kelvin) => formatCelsiusRounded(kelvin)), + peclet: new DerivedProperty([model.field.pecletNumberProperty], (peclet) => formatPeclet(peclet)), + }), + interactionHintContent: a11y.screenSummary.interactionHintStringProperty, + }); + } +} diff --git a/src/combined/view/HeatTransferScreenView.ts b/src/combined/view/HeatTransferScreenView.ts new file mode 100644 index 0000000..58a624b --- /dev/null +++ b/src/combined/view/HeatTransferScreenView.ts @@ -0,0 +1,88 @@ +/** + * HeatTransferScreenView.ts + * + * Screen 4's controls: the transport balance, the flow pattern, and every layer. + * + * This is the screen where the architecture becomes the pedagogy. The layer + * checkboxes are grouped alone in their own panel and the transport control sits + * apart from them, because they are categorically different: one changes the + * simulation, the others change only which render pass runs over its output. A + * student who notices that toggling four checkboxes never disturbs the field has + * understood something worth understanding. + */ + +import type { Node } from "scenerystack/scenery"; +import { BrushControlPanel } from "../../common/view/BrushControlPanel.js"; +import { themedCheckbox } from "../../common/view/ControlFactory.js"; +import { FieldScreenView, type FieldScreenViewOptions } from "../../common/view/FieldScreenView.js"; +import { FlowControlPanel } from "../../common/view/FlowControlPanel.js"; +import { LayerControlPanel } from "../../common/view/LayerControlPanel.js"; +import { TransportControlPanel } from "../../common/view/TransportControlPanel.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { HeatTransferModel } from "../model/HeatTransferModel.js"; +import { HeatTransferScreenSummaryContent } from "./HeatTransferScreenSummaryContent.js"; + +/** The view supplies the field's accessible name and summary itself. */ +export type HeatTransferScreenViewOptions = Omit< + FieldScreenViewOptions, + "fieldAccessibleName" | "fieldAccessibleHelpText" | "screenSummaryContent" +>; + +export class HeatTransferScreenView extends FieldScreenView { + private readonly transportPanel: TransportControlPanel; + + public constructor(model: HeatTransferModel, providedOptions: HeatTransferScreenViewOptions) { + const strings = StringManager.getInstance(); + const a11y = strings.getSharedA11yStrings(); + + super(model.field, { + ...providedOptions, + screenSummaryContent: new HeatTransferScreenSummaryContent(model), + fieldAccessibleName: a11y.controls.fieldStringProperty, + fieldAccessibleHelpText: a11y.controls.fieldHelpStringProperty, + }); + + const controls = strings.getControls(); + + // ── Left column: what the simulation does ───────────────────────────────── + + this.transportPanel = new TransportControlPanel(model.field); + this.leftColumn.addChild(this.transportPanel); + + const flowPanel = new FlowControlPanel(model.field, this.comboBoxLayer); + this.leftColumn.addChild(flowPanel); + + // ── Right column: what is drawn ─────────────────────────────────────────── + + const probeCheckbox = themedCheckbox( + model.field.probeVisibleProperty, + controls.showProbeStringProperty, + a11y.controls.probeHelpStringProperty, + ); + + const layerPanel = new LayerControlPanel( + model.field, + ["temperature", "isotherms", "heatFlux", "velocity", "gradient"], + [probeCheckbox], + ); + this.rightColumn.addChild(layerPanel); + + const brushPanel = new BrushControlPanel(model.field); + this.rightColumn.addChild(brushPanel); + + const screenControls: Node[] = [ + ...this.transportPanel.controls, + ...flowPanel.controls, + ...layerPanel.checkboxes, + ...brushPanel.controls, + ]; + this.finishLayout(screenControls); + } + + public override reset(): void { + super.reset(); + // The balance slider is view-owned state — the model only sees the two + // multipliers it derives — so it has to be reset here. + this.transportPanel.reset(); + } +} diff --git a/src/common/HeatTransferButtonOptions.ts b/src/common/HeatTransferButtonOptions.ts new file mode 100644 index 0000000..5d55bdd --- /dev/null +++ b/src/common/HeatTransferButtonOptions.ts @@ -0,0 +1,52 @@ +/** + * HeatTransferButtonOptions.ts + * + * Shared flat button appearance for the sim. Rectangular and round push buttons + * default to SceneryStack's 3-D appearance; pass these options (or spread them + * into nested button options) for a flat look everywhere. + */ + +import type { PlayPauseStepButtonGroupOptions, TimeControlNodeOptions } from "scenerystack/scenery-phet"; +import { ButtonNode, type ComboBoxOptions } from "scenerystack/sun"; +import HeatTransferColors from "../HeatTransferColors.js"; + +export const FLAT_BUTTON_APPEARANCE_OPTIONS = { + buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy, +} as const; + +/** Text on flat push buttons and combo-box items (always on a light control surface). */ +export const LIGHT_SURFACE_TEXT_FILL = HeatTransferColors.controlSurfaceTextColorProperty; + +/** + * Combo-box chrome for panels. Item labels must use {@link LIGHT_SURFACE_TEXT_FILL}, not + * {@link HeatTransferColors.textColorProperty} — that color is for labels on the dark panel fill. + */ +export const HEAT_TRANSFER_COMBO_BOX_OPTIONS = { + buttonFill: HeatTransferColors.controlSurfaceColorProperty, + listFill: HeatTransferColors.controlSurfaceColorProperty, + buttonStroke: HeatTransferColors.panelBorderColorProperty, + listStroke: HeatTransferColors.panelBorderColorProperty, +} satisfies Pick<ComboBoxOptions, "buttonFill" | "listFill" | "buttonStroke" | "listStroke">; + +/** Options for RectangularPushButton and NumberControl arrow buttons. */ +export const FLAT_RECTANGULAR_BUTTON_OPTIONS = FLAT_BUTTON_APPEARANCE_OPTIONS; + +/** Options for ResetAllButton (extends RoundPushButton). */ +export const FLAT_RESET_ALL_BUTTON_OPTIONS = FLAT_BUTTON_APPEARANCE_OPTIONS; + +/** Nested options for TimeControlNode play / pause / step round buttons. */ +export const FLAT_PLAY_PAUSE_STEP_BUTTON_OPTIONS = { + playPauseButtonOptions: FLAT_BUTTON_APPEARANCE_OPTIONS, + stepForwardButtonOptions: FLAT_BUTTON_APPEARANCE_OPTIONS, + stepBackwardButtonOptions: FLAT_BUTTON_APPEARANCE_OPTIONS, +} satisfies PlayPauseStepButtonGroupOptions; + +/** + * Speed radio labels for TimeControlNode. SceneryStack Text defaults to black, which + * is low-contrast on the sim's dark Default-mode panels. + */ +export const TIME_CONTROL_SPEED_RADIO_OPTIONS = { + speedRadioButtonGroupOptions: { + labelOptions: { fill: HeatTransferColors.textColorProperty }, + }, +} satisfies Pick<TimeControlNodeOptions, "speedRadioButtonGroupOptions">; diff --git a/src/common/HeatTransferPanel.ts b/src/common/HeatTransferPanel.ts new file mode 100644 index 0000000..edba53e --- /dev/null +++ b/src/common/HeatTransferPanel.ts @@ -0,0 +1,50 @@ +/** + * HeatTransferPanel.ts + * + * A pre-themed Panel that automatically uses HeatTransferColors for background and + * border. Use this for all control panels and info boxes in the sim so that + * default / projector mode switching is handled automatically. + * + * ── Basic usage ─────────────────────────────────────────────────────────────── + * + * import { HeatTransferPanel } from "../../common/HeatTransferPanel.js"; + * import { VBox, Text } from "scenerystack/scenery"; + * + * const content = new VBox({ + * children: [ new Text("label"), slider ], + * spacing: 8, + * }); + * const panel = new HeatTransferPanel(content); + * + * ── Overriding defaults ─────────────────────────────────────────────────────── + * + * // Wider margins, sharper corners, custom stroke + * const panel = new HeatTransferPanel(content, { xMargin: 20, cornerRadius: 0 }); + * + * // Transparent background (decorative border only) + * const panel = new HeatTransferPanel(content, { fill: "transparent" }); + */ + +import { type EmptySelfOptions, optionize } from "scenerystack/phet-core"; +import type { Node } from "scenerystack/scenery"; +import { Panel, type PanelOptions } from "scenerystack/sun"; +import HeatTransferColors from "../HeatTransferColors.js"; +import { PANEL_CORNER_RADIUS } from "../HeatTransferConstants.js"; + +export type HeatTransferPanelOptions = PanelOptions; + +export class HeatTransferPanel extends Panel { + public constructor(content: Node, providedOptions?: HeatTransferPanelOptions) { + const options = optionize<HeatTransferPanelOptions, EmptySelfOptions, PanelOptions>()( + { + fill: HeatTransferColors.panelBackgroundColorProperty, + stroke: HeatTransferColors.panelBorderColorProperty, + cornerRadius: PANEL_CORNER_RADIUS, + xMargin: 12, + yMargin: 10, + }, + providedOptions, + ); + super(content, options); + } +} diff --git a/src/common/HeatTransferScreenIcons.ts b/src/common/HeatTransferScreenIcons.ts new file mode 100644 index 0000000..37efee8 --- /dev/null +++ b/src/common/HeatTransferScreenIcons.ts @@ -0,0 +1,173 @@ +/** + * HeatTransferScreenIcons.ts + * + * Home-screen and navigation-bar icons, drawn programmatically on the standard + * PhET 548 x 373 canvas. + * + * Each icon is a miniature of what its screen actually shows, using the same + * colour ramp the field uses, so the home screen reads as a progression: a plain + * temperature field, then the same field with flux arrows, then with a flow, then + * with both, then with a material barrier through it. Nothing here imports the + * field engine — these are static gradients and shapes, cheap enough to build + * five of at startup. + */ +import { Shape } from "scenerystack/kite"; +import { Circle, LinearGradient, Node, Path, RadialGradient, Rectangle } from "scenerystack/scenery"; +import { ScreenIcon } from "scenerystack/sim"; +import HeatTransferColors from "../HeatTransferColors.js"; +import { rgbToCss, sampleColorMap } from "./field/ColorMap.js"; + +/** Icon canvas width. */ +const W = 548; + +/** Icon canvas height. */ +const H = 373; + +/** A colour from the temperature ramp, as a CSS string. */ +function rampColor(position: number): string { + return rgbToCss(sampleColorMap(position)); +} + +function background(): Rectangle { + return new Rectangle(0, 0, W, H, { fill: HeatTransferColors.backgroundColorProperty }); +} + +/** A hot blob at (x, y) fading to ambient — the motif every icon is built from. */ +function hotSpot(x: number, y: number, radius: number, peak = 1): Node { + const gradient = new RadialGradient(x, y, 0, x, y, radius) + .addColorStop(0, rampColor(peak)) + .addColorStop(0.45, rampColor(peak * 0.65)) + .addColorStop(1, rampColor(0.28)); + return new Circle(radius, { x, y, fill: gradient }); +} + +/** The field tile every icon sits on: a cool background with a warm corner. */ +function fieldTile(): Node { + const tile = new Rectangle(0, 0, W, H, { fill: rampColor(0.28) }); + return new Node({ children: [tile] }); +} + +function iconFrom(content: Node): ScreenIcon { + return new ScreenIcon(content, { + maxIconWidthProportion: 1, + maxIconHeightProportion: 1, + fill: HeatTransferColors.backgroundColorProperty, + }); +} + +/** Temperature: a single hot spot on a cool plate. */ +export function createTemperatureIcon(): ScreenIcon { + return iconFrom( + new Node({ + children: [background(), fieldTile(), hotSpot(W / 2, H / 2, 190)], + }), + ); +} + +/** Conduction: a hot spot with flux arrows radiating outward. */ +export function createConductionIcon(): ScreenIcon { + const arrows = new Shape(); + const centreX = W / 2; + const centreY = H / 2; + for (let n = 0; n < 8; n++) { + const angle = (n / 8) * 2 * Math.PI; + const inner = 95; + const outer = 165; + const x0 = centreX + Math.cos(angle) * inner; + const y0 = centreY + Math.sin(angle) * inner; + const x1 = centreX + Math.cos(angle) * outer; + const y1 = centreY + Math.sin(angle) * outer; + arrows.moveTo(x0, y0).lineTo(x1, y1); + // Arrowhead + const back = 22; + const spread = 0.4; + arrows + .moveTo(x1, y1) + .lineTo(x1 - Math.cos(angle - spread) * back, y1 - Math.sin(angle - spread) * back) + .moveTo(x1, y1) + .lineTo(x1 - Math.cos(angle + spread) * back, y1 - Math.sin(angle + spread) * back); + } + + return iconFrom( + new Node({ + children: [ + background(), + fieldTile(), + hotSpot(centreX, centreY, 150), + new Path(arrows, { stroke: HeatTransferColors.heatFluxColorProperty, lineWidth: 9, lineCap: "round" }), + ], + }), + ); +} + +/** Convection: a warm plume swept sideways, with tracer dots along the flow. */ +export function createConvectionIcon(): ScreenIcon { + const streak = new Rectangle(0, H * 0.32, W, H * 0.36, { + fill: new LinearGradient(0, 0, W, 0) + .addColorStop(0, rampColor(0.95)) + .addColorStop(0.55, rampColor(0.7)) + .addColorStop(1, rampColor(0.35)), + }); + + const tracers = new Node(); + for (let n = 0; n < 14; n++) { + tracers.addChild( + new Circle(7, { + x: 30 + n * 38, + y: n % 2 === 0 ? H * 0.2 : H * 0.8, + fill: HeatTransferColors.particleColorProperty, + opacity: 0.85, + }), + ); + } + + return iconFrom( + new Node({ + children: [background(), fieldTile(), streak, hotSpot(90, H / 2, 110), tracers], + }), + ); +} + +/** Heat Transfer: a hot spot both spreading and being swept downstream. */ +export function createHeatTransferIcon(): ScreenIcon { + const comet = new Shape() + .moveTo(130, H / 2 - 105) + .quadraticCurveTo(360, H / 2 - 55, W - 40, H / 2) + .quadraticCurveTo(360, H / 2 + 55, 130, H / 2 + 105) + .close(); + + return iconFrom( + new Node({ + children: [ + background(), + fieldTile(), + new Path(comet, { + fill: new LinearGradient(130, 0, W - 40, 0) + .addColorStop(0, rampColor(1)) + .addColorStop(0.5, rampColor(0.72)) + .addColorStop(1, rampColor(0.36)), + }), + hotSpot(150, H / 2, 95), + ], + }), + ); +} + +/** Materials: a hot half and a cool half separated by an insulating bar. */ +export function createMaterialsIcon(): ScreenIcon { + return iconFrom( + new Node({ + children: [ + background(), + fieldTile(), + hotSpot(W * 0.24, H / 2, 165), + new Rectangle(W * 0.45, 40, 62, H - 80, 8, 8, { + fill: HeatTransferColors.panelBackgroundColorProperty, + stroke: HeatTransferColors.fieldBorderColorProperty, + lineWidth: 5, + }), + hotSpot(W * 0.82, H / 2, 130, 0.42), + ], + }), + ); +} diff --git a/src/common/field/ColorMap.ts b/src/common/field/ColorMap.ts new file mode 100644 index 0000000..f2d426c --- /dev/null +++ b/src/common/field/ColorMap.ts @@ -0,0 +1,135 @@ +/** + * ColorMap.ts + * + * The temperature colour map, defined once as data so the CPU renderer and the + * WGSL fragment shader cannot drift apart: {@link colorMapWgsl} generates the + * shader function from the very same stop list that {@link sampleColorMap} + * interpolates. + * + * The ramp is a perceptually ordered cold-to-hot sequence — deep blue, blue, + * cyan, green, yellow, orange, red, white-hot — chosen so that (a) hue alone + * orders the values, and (b) lightness increases monotonically, which keeps the + * ordering legible when the sim is projected or viewed in greyscale. + */ + +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; + +/** One stop on the ramp: a normalized position and an sRGB colour in [0, 1]. */ +export type ColorStop = { + position: number; + red: number; + green: number; + blue: number; +}; + +/** The ramp, in increasing position order. Positions must span 0 to 1. */ +export const TEMPERATURE_COLOR_STOPS: readonly ColorStop[] = [ + { position: 0.0, red: 0.031, green: 0.09, blue: 0.353 }, + { position: 0.15, red: 0.075, green: 0.294, blue: 0.71 }, + { position: 0.3, red: 0.204, green: 0.647, blue: 0.859 }, + { position: 0.45, red: 0.361, green: 0.812, blue: 0.647 }, + { position: 0.58, red: 0.83, green: 0.882, blue: 0.318 }, + { position: 0.72, red: 0.976, green: 0.702, blue: 0.192 }, + { position: 0.86, red: 0.902, green: 0.318, blue: 0.145 }, + { position: 1.0, red: 0.996, green: 0.925, blue: 0.851 }, +]; + +/** An RGB triple in [0, 1]. */ +export type Rgb = { red: number; green: number; blue: number }; + +/** + * Samples the ramp at a normalized position. Values outside [0, 1] clamp to the + * end stops, so a field that runs off the legend still renders sensibly. + */ +export function sampleColorMap(position: number): Rgb { + const stops = TEMPERATURE_COLOR_STOPS; + const first = stops[0]; + const last = stops[stops.length - 1]; + if (!(first && last)) { + return { red: 0, green: 0, blue: 0 }; + } + if (!(position > first.position)) { + return { red: first.red, green: first.green, blue: first.blue }; + } + if (position >= last.position) { + return { red: last.red, green: last.green, blue: last.blue }; + } + + for (let i = 1; i < stops.length; i++) { + const hi = stops[i]; + const lo = stops[i - 1]; + if (!(hi && lo)) { + continue; + } + if (position <= hi.position) { + const span = hi.position - lo.position; + const t = span > 0 ? (position - lo.position) / span : 0; + return { + red: lo.red + (hi.red - lo.red) * t, + green: lo.green + (hi.green - lo.green) * t, + blue: lo.blue + (hi.blue - lo.blue) * t, + }; + } + } + return { red: last.red, green: last.green, blue: last.blue }; +} + +/** + * Formats an {@link Rgb} as a CSS colour string. + * + * A *format* helper, not a palette: the components it is handed always come from + * the ramp or from a `ProfileColorProperty` by way of `FieldRenderStyle`. Nothing + * here chooses a colour. + */ +export function rgbToCss(color: Rgb): string { + const to255 = (value: number): number => Math.round(Math.min(1, Math.max(0, value)) * 255); + return `rgb(${to255(color.red)}, ${to255(color.green)}, ${to255(color.blue)})`; +} + +/** + * WGSL source for `fn colorMap(position: f32) -> vec3<f32>`, generated from + * {@link TEMPERATURE_COLOR_STOPS}. Included by every render shader that paints + * the temperature field. + */ +export function colorMapWgsl(): string { + const count = TEMPERATURE_COLOR_STOPS.length; + const positions = TEMPERATURE_COLOR_STOPS.map((stop) => wgslFloat(stop.position)).join(", "); + const colors = TEMPERATURE_COLOR_STOPS.map( + (stop) => `vec3<f32>(${wgslFloat(stop.red)}, ${wgslFloat(stop.green)}, ${wgslFloat(stop.blue)})`, + ).join(",\n "); + + return ` +const COLOR_STOP_COUNT: u32 = ${count}u; +const COLOR_STOP_POSITIONS = array<f32, ${count}>(${positions}); +const COLOR_STOP_COLORS = array<vec3<f32>, ${count}>( + ${colors} +); + +fn colorMap(position: f32) -> vec3<f32> { + let p = clamp(position, 0.0, 1.0); + var positions = COLOR_STOP_POSITIONS; + var colors = COLOR_STOP_COLORS; + var result = colors[COLOR_STOP_COUNT - 1u]; + for (var i: u32 = 1u; i < COLOR_STOP_COUNT; i = i + 1u) { + if (p <= positions[i]) { + let lo = positions[i - 1u]; + let hi = positions[i]; + let span = max(hi - lo, 1e-6); + let t = clamp((p - lo) / span, 0.0, 1.0); + result = mix(colors[i - 1u], colors[i], t); + break; + } + } + return result; +} +`; +} + +/** Formats a number as a WGSL `f32` literal (WGSL requires a decimal point). */ +function wgslFloat(value: number): string { + return Number.isInteger(value) ? `${value}.0` : `${value}`; +} + +HeatTransferNamespace.register("ColorMap", { + TEMPERATURE_COLOR_STOPS, +}); diff --git a/src/common/field/FieldEngine.ts b/src/common/field/FieldEngine.ts new file mode 100644 index 0000000..158f801 --- /dev/null +++ b/src/common/field/FieldEngine.ts @@ -0,0 +1,137 @@ +/** + * FieldEngine.ts + * + * The contract between the simulation and whatever is actually evolving and + * drawing the fields. + * + * The model never touches a texture, a shader, or a typed array. It hands the + * engine parameters and strokes and asks for samples; the engine owns the field + * data and the canvas it paints into. Two backends implement this interface: + * + * WebGpuFieldEngine — fields live in GPU textures, compute shaders evolve them, + * render pipelines draw them. The primary path. + * CpuFieldEngine — the same semantics in TypeScript over Float32Arrays, + * drawn with the 2-D canvas API. The fallback, and the + * reference the unit tests check the physics against. + * + * Because the interface is expressed in unit-square coordinates and physical + * units — never in cells — nothing above it changes when the grid resolution + * does. + */ + +import type { Rgb } from "./ColorMap.js"; +import type { + BrushStroke, + CrossSectionSample, + FieldStatistics, + FlowPresetId, + InitialConditionId, + LayerVisibility, + MaterialProperties, + MaterialStroke, + TransportParameters, +} from "./FieldTypes.js"; +import type { SimulationDomain } from "./SimulationDomain.js"; + +/** Which substrate is evolving the fields. */ +export const FieldBackend = { + WEBGPU: "webgpu", + CPU: "cpu", +} as const; + +export type FieldBackendId = (typeof FieldBackend)[keyof typeof FieldBackend]; + +/** + * Colours and ranges the render passes need. Supplied by the view from + * HeatTransferColors so the overlays follow the active colour profile — the + * field renderer holds no palette of its own. + */ +export type FieldRenderStyle = { + /** Isotherm contour lines. */ + isotherm: Rgb; + /** Heat-flux arrows. */ + arrow: Rgb; + /** Velocity tracer particles. */ + particle: Rgb; + /** Temperature mapped to the bottom of the colour ramp, in kelvin. */ + minTemperature: number; + /** Temperature mapped to the top of the colour ramp, in kelvin. */ + maxTemperature: number; + /** Spacing between isotherms, in kelvin. */ + isothermInterval: number; +}; + +export type FieldEngineOptions = { + /** Edge length of the square backing canvas, in device pixels. */ + displaySize: number; +}; + +export interface FieldEngine { + /** The grid these fields live on. */ + readonly domain: SimulationDomain; + + /** Which backend this is. Surfaced in the UI so the substrate is never a mystery. */ + readonly backend: FieldBackendId; + + /** The canvas the engine renders into. The view wraps it in a Scenery Image. */ + readonly canvas: HTMLCanvasElement; + + /** Simulated time elapsed since the last reset, in seconds. */ + readonly simulatedTime: number; + + /** The stability-limited substep the engine is currently integrating at, in seconds. */ + readonly substepSize: number; + + // ── Authoring the fields ──────────────────────────────────────────────────── + + /** Replaces the material field with a single homogeneous material. */ + setMaterial(material: MaterialProperties): void; + + /** Paints a material disc into the material field. */ + paintMaterial(stroke: MaterialStroke): void; + + /** Replaces the velocity field with a preset scaled to `speed` metres per second. */ + setFlow(preset: FlowPresetId, speed: number): void; + + /** Paints a temperature disc into the temperature field. */ + paintTemperature(stroke: BrushStroke): void; + + /** Reseeds the temperature field and zeroes the clock. Materials and flow are untouched. */ + resetField(initial: InitialConditionId): void; + + // ── Evolving the fields ───────────────────────────────────────────────────── + + /** + * Advances the temperature field by `parameters.substeps` stability-limited + * substeps and returns the simulated time advanced, in seconds. + */ + step(parameters: TransportParameters): number; + + // ── Drawing the fields ────────────────────────────────────────────────────── + + /** Runs the enabled visualization passes over the current state. */ + render(layers: LayerVisibility, style: FieldRenderStyle): void; + + // ── Reading the fields ────────────────────────────────────────────────────── + + /** Bilinearly samples temperature at a unit-square point, in kelvin. */ + sampleTemperature(u: number, v: number): number; + + /** Samples the heat flux q = -k grad(T) at a unit-square point, in W/m^2. */ + sampleHeatFlux(u: number, v: number): { qx: number; qy: number }; + + /** Samples temperature, gradient, and flux along a line across the field. */ + sampleCrossSection(u0: number, v0: number, u1: number, v1: number, count: number): CrossSectionSample[]; + + /** Min / max / mean temperature over the whole field, in kelvin. */ + getStatistics(): FieldStatistics; + + /** The largest |v| currently in the velocity field, in m/s. Used for the Peclet readout. */ + getMaxSpeed(): number; + + /** The area-weighted mean thermal diffusivity, in m^2/s. Used for the Peclet readout. */ + getMeanDiffusivity(): number; + + /** Releases GPU resources, listeners, and buffers. */ + dispose(): void; +} diff --git a/src/common/field/FieldEngineBase.ts b/src/common/field/FieldEngineBase.ts new file mode 100644 index 0000000..adda489 --- /dev/null +++ b/src/common/field/FieldEngineBase.ts @@ -0,0 +1,416 @@ +/** + * FieldEngineBase.ts + * + * Everything both backends do identically. + * + * The split follows one rule: **the CPU authors, the GPU evolves.** The material + * and velocity fields are written by user actions and analytic presets, so the + * CPU always holds the authoritative copy and a backend merely uploads it. Only + * the temperature field is genuinely GPU-owned, so only it needs to be mirrored + * back — and every read-out feature (probe, legend, cross-section graph, + * statistics) is implemented once here against that mirror. + * + * That is what keeps the two backends honest with each other: sampling, material + * bookkeeping, stroke geometry, initial conditions, and the stability-limited + * time step are shared code, not duplicated code. + */ + +import { + ADVECTION_CFL, + AMBIENT_TEMPERATURE_K, + COOL_BRUSH_TEMPERATURE_K, + DIFFUSION_CFL, + HOT_BRUSH_TEMPERATURE_K, +} from "../../HeatTransferConstants.js"; +import type { FieldBackendId, FieldEngine, FieldEngineOptions } from "./FieldEngine.js"; +import { + BoundaryCondition, + type BoundaryConditionId, + type BrushStroke, + type CrossSectionSample, + conductivityX, + conductivityY, + type FieldStatistics, + type FlowPresetId, + InitialCondition, + type InitialConditionId, + type MaterialProperties, + type MaterialStroke, + maxDirectionalDiffusivity, + volumetricHeatCapacity, +} from "./FieldTypes.js"; +import { bilinearSample, type FieldGeometry, gradientAt, type MaterialArrays, stableTimeStep } from "./kernels.js"; +import { MATERIALS } from "./Materials.js"; +import type { SimulationDomain } from "./SimulationDomain.js"; +import { fillVelocityField } from "./VelocityPresets.js"; + +export abstract class FieldEngineBase implements FieldEngine { + public readonly domain: SimulationDomain; + public readonly canvas: HTMLCanvasElement; + + /** CPU mirror of the temperature field, in kelvin. Authoritative on CPU, refreshed on GPU. */ + protected readonly temperatureMirror: Float32Array; + + /** Per-cell material coefficients. Always CPU-authoritative; backends upload copies. */ + protected readonly material: MaterialArrays; + + /** Interleaved (vx, vy) velocity in m/s. Always CPU-authoritative. */ + protected readonly velocity: Float32Array; + + /** Grid geometry in the shape the kernels want. */ + protected readonly geometry: FieldGeometry; + + /** Largest directional diffusivity anywhere in the domain, m^2/s. */ + protected maxDiffusivity: number; + + /** Sum of directional diffusivities, for the area-weighted mean used by the Peclet readout. */ + private diffusivitySum: number; + + /** Largest |v| in the velocity field, m/s. */ + protected maxSpeed = 0; + + /** Simulated seconds since the last reset. */ + protected elapsedTime = 0; + + /** The substep used by the most recent step() call, in seconds. */ + protected currentSubstep = 0; + + /** Boundary condition in force, refreshed on every step() from the parameters. */ + protected boundary: BoundaryConditionId = BoundaryCondition.INSULATED; + + /** How the field was last seeded, so a material change can re-derive it if needed. */ + protected lastInitialCondition: InitialConditionId = InitialCondition.UNIFORM; + + public abstract readonly backend: FieldBackendId; + + protected constructor(domain: SimulationDomain, options: FieldEngineOptions) { + this.domain = domain; + this.geometry = { + gridWidth: domain.gridWidth, + gridHeight: domain.gridHeight, + dx: domain.dx, + dy: domain.dy, + }; + + const cells = domain.cellCount; + this.temperatureMirror = new Float32Array(cells); + this.material = { + conductivityX: new Float32Array(cells), + conductivityY: new Float32Array(cells), + volumetricHeatCapacity: new Float32Array(cells), + }; + this.velocity = new Float32Array(2 * cells); + + this.canvas = document.createElement("canvas"); + this.canvas.width = options.displaySize; + this.canvas.height = options.displaySize; + + const initial = MATERIALS.copper; + this.maxDiffusivity = maxDirectionalDiffusivity(initial); + this.diffusivitySum = this.maxDiffusivity * cells; + this.writeUniformMaterial(initial); + this.seedTemperature(InitialCondition.UNIFORM); + } + + // ── Read-only state ───────────────────────────────────────────────────────── + + public get simulatedTime(): number { + return this.elapsedTime; + } + + public get substepSize(): number { + return this.currentSubstep; + } + + /** The stability-limited substep for the current material and flow, in seconds. */ + protected computeSubstep(flowScale: number, diffusionScale: number, diffusionEnabled: boolean): number { + return stableTimeStep( + this.geometry, + diffusionEnabled ? this.maxDiffusivity * diffusionScale : 0, + this.maxSpeed * flowScale, + DIFFUSION_CFL, + ADVECTION_CFL, + ); + } + + // ── Authoring: material ───────────────────────────────────────────────────── + + public setMaterial(material: MaterialProperties): void { + this.writeUniformMaterial(material); + this.maxDiffusivity = maxDirectionalDiffusivity(material); + this.diffusivitySum = this.maxDiffusivity * this.domain.cellCount; + this.onMaterialChanged(); + } + + public paintMaterial(stroke: MaterialStroke): void { + const kx = conductivityX(stroke.material); + const ky = conductivityY(stroke.material); + const rhoCp = volumetricHeatCapacity(stroke.material); + const cellDiffusivity = Math.max(kx, ky) / rhoCp; + + this.forEachCellInStroke(stroke.u, stroke.v, stroke.radius, (index, weight) => { + // A material brush is hard-edged in the middle and feathered at the rim, so + // a composite has a clean interface but no single-pixel staircase. + if (weight <= 0) { + return; + } + const previous = (this.material.conductivityX[index] ?? 0) / (this.material.volumetricHeatCapacity[index] ?? 1); + const previousY = (this.material.conductivityY[index] ?? 0) / (this.material.volumetricHeatCapacity[index] ?? 1); + this.diffusivitySum += cellDiffusivity - Math.max(previous, previousY); + + this.material.conductivityX[index] = kx; + this.material.conductivityY[index] = ky; + this.material.volumetricHeatCapacity[index] = rhoCp; + }); + + this.maxDiffusivity = Math.max(this.maxDiffusivity, cellDiffusivity); + this.onMaterialChanged(); + } + + private writeUniformMaterial(material: MaterialProperties): void { + this.material.conductivityX.fill(conductivityX(material)); + this.material.conductivityY.fill(conductivityY(material)); + this.material.volumetricHeatCapacity.fill(volumetricHeatCapacity(material)); + } + + // ── Authoring: flow ───────────────────────────────────────────────────────── + + public setFlow(preset: FlowPresetId, speed: number): void { + fillVelocityField(this.velocity, this.domain.gridWidth, this.domain.gridHeight, preset, speed); + + let max = 0; + for (let index = 0; index < this.velocity.length; index += 2) { + const vx = this.velocity[index] ?? 0; + const vy = this.velocity[index + 1] ?? 0; + const magnitude = Math.hypot(vx, vy); + if (magnitude > max) { + max = magnitude; + } + } + this.maxSpeed = max; + this.onVelocityChanged(); + } + + // ── Authoring: temperature ────────────────────────────────────────────────── + + public paintTemperature(stroke: BrushStroke): void { + this.applyBrushToMirror(stroke); + this.onTemperaturePainted(stroke); + } + + /** Applies a brush stroke to the CPU mirror. Both backends do this; the GPU one also dispatches a shader. */ + protected applyBrushToMirror(stroke: BrushStroke): void { + this.forEachCellInStroke(stroke.u, stroke.v, stroke.radius, (index, weight) => { + const current = this.temperatureMirror[index] ?? AMBIENT_TEMPERATURE_K; + const amount = stroke.strength * weight; + this.temperatureMirror[index] = current + (stroke.temperature - current) * amount; + }); + } + + public resetField(initial: InitialConditionId): void { + this.seedTemperature(initial); + this.elapsedTime = 0; + this.onTemperatureReseeded(); + } + + /** Writes the chosen initial condition into the temperature mirror. */ + protected seedTemperature(initial: InitialConditionId): void { + this.lastInitialCondition = initial; + const { gridWidth, gridHeight } = this.domain; + + for (let j = 0; j < gridHeight; j++) { + const v = (j + 0.5) / gridHeight; + for (let i = 0; i < gridWidth; i++) { + const u = (i + 0.5) / gridWidth; + this.temperatureMirror[j * gridWidth + i] = initialTemperatureAt(initial, u, v); + } + } + } + + // ── Backend hooks ─────────────────────────────────────────────────────────── + + /** Called after the material field changes, so a backend can re-upload it. */ + protected abstract onMaterialChanged(): void; + + /** Called after the velocity field changes, so a backend can re-upload it. */ + protected abstract onVelocityChanged(): void; + + /** Called after a brush stroke has been applied to the mirror. */ + protected abstract onTemperaturePainted(stroke: BrushStroke): void; + + /** Called after the mirror has been reseeded, so a backend can upload it. */ + protected abstract onTemperatureReseeded(): void; + + // ── Reading ───────────────────────────────────────────────────────────────── + + public sampleTemperature(u: number, v: number): number { + return bilinearSample( + this.temperatureMirror, + this.geometry, + this.boundary, + this.domain.unitToGridX(u), + this.domain.unitToGridY(v), + AMBIENT_TEMPERATURE_K, + ); + } + + public sampleHeatFlux(u: number, v: number): { qx: number; qy: number } { + const { i, j } = this.domain.unitToCell(u, v); + const { gx, gy } = gradientAt(this.temperatureMirror, this.geometry, this.boundary, i, j, AMBIENT_TEMPERATURE_K); + const index = this.domain.index(i, j); + return { + qx: -(this.material.conductivityX[index] ?? 0) * gx, + qy: -(this.material.conductivityY[index] ?? 0) * gy, + }; + } + + public sampleCrossSection(u0: number, v0: number, u1: number, v1: number, count: number): CrossSectionSample[] { + const samples: CrossSectionSample[] = []; + if (count < 2) { + return samples; + } + + const dxMetres = (u1 - u0) * this.domain.physicalWidth; + const dyMetres = (v1 - v0) * this.domain.physicalHeight; + const length = Math.hypot(dxMetres, dyMetres); + if (length === 0) { + return samples; + } + const dirX = dxMetres / length; + const dirY = dyMetres / length; + const spacing = length / (count - 1); + + for (let n = 0; n < count; n++) { + const t = n / (count - 1); + const u = u0 + (u1 - u0) * t; + const v = v0 + (v1 - v0) * t; + const temperature = this.sampleTemperature(u, v); + + const { i, j } = this.domain.unitToCell(u, v); + const { gx, gy } = gradientAt(this.temperatureMirror, this.geometry, this.boundary, i, j, AMBIENT_TEMPERATURE_K); + // Directional derivative dT/ds and the flux component along the same line. + const gradient = gx * dirX + gy * dirY; + const index = this.domain.index(i, j); + const kAlongLine = + (this.material.conductivityX[index] ?? 0) * dirX * dirX + + (this.material.conductivityY[index] ?? 0) * dirY * dirY; + + samples.push({ + distance: n * spacing, + temperature, + gradient, + flux: -kAlongLine * gradient, + }); + } + + return samples; + } + + public getStatistics(): FieldStatistics { + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + let sum = 0; + const cells = this.domain.cellCount; + for (let index = 0; index < cells; index++) { + const value = this.temperatureMirror[index] ?? AMBIENT_TEMPERATURE_K; + sum += value; + if (value < min) { + min = value; + } + if (value > max) { + max = value; + } + } + return cells > 0 + ? { minTemperature: min, maxTemperature: max, meanTemperature: sum / cells } + : { minTemperature: 0, maxTemperature: 0, meanTemperature: 0 }; + } + + public getMaxSpeed(): number { + return this.maxSpeed; + } + + public getMeanDiffusivity(): number { + const cells = this.domain.cellCount; + return cells > 0 ? this.diffusivitySum / cells : 0; + } + + public abstract step(parameters: import("./FieldTypes.js").TransportParameters): number; + + public abstract render( + layers: import("./FieldTypes.js").LayerVisibility, + style: import("./FieldEngine.js").FieldRenderStyle, + ): void; + + public abstract dispose(): void; + + // ── Stroke geometry ───────────────────────────────────────────────────────── + + /** + * Visits every cell touched by a disc brush, with a smooth falloff weight. + * + * The weight is `1 - (r/R)^2` squared — a compactly supported bump that is 1 at + * the centre and reaches 0 with zero slope at the rim, so repeated strokes + * build up a smooth Gaussian-looking blob rather than a stack of hard discs. + */ + protected forEachCellInStroke( + u: number, + v: number, + radiusFraction: number, + visit: (index: number, weight: number) => void, + ): void { + const { gridWidth, gridHeight } = this.domain; + const radiusCells = radiusFraction * Math.min(gridWidth, gridHeight); + if (radiusCells <= 0) { + return; + } + + const centreX = u * gridWidth; + const centreY = v * gridHeight; + const minI = Math.max(0, Math.floor(centreX - radiusCells)); + const maxI = Math.min(gridWidth - 1, Math.ceil(centreX + radiusCells)); + const minJ = Math.max(0, Math.floor(centreY - radiusCells)); + const maxJ = Math.min(gridHeight - 1, Math.ceil(centreY + radiusCells)); + const radiusSquared = radiusCells * radiusCells; + + for (let j = minJ; j <= maxJ; j++) { + const dy = j + 0.5 - centreY; + for (let i = minI; i <= maxI; i++) { + const dx = i + 0.5 - centreX; + const normalized = (dx * dx + dy * dy) / radiusSquared; + if (normalized >= 1) { + continue; + } + const falloff = 1 - normalized; + visit(j * gridWidth + i, falloff * falloff); + } + } + } +} + +/** The temperature an initial condition prescribes at a unit-square point, in kelvin. */ +export function initialTemperatureAt(initial: InitialConditionId, u: number, v: number): number { + switch (initial) { + case InitialCondition.HOT_SPOT: { + const r2 = (u - 0.5) ** 2 + (v - 0.5) ** 2; + return AMBIENT_TEMPERATURE_K + (HOT_BRUSH_TEMPERATURE_K - AMBIENT_TEMPERATURE_K) * Math.exp(-r2 / 0.006); + } + + case InitialCondition.GRADIENT: + // Linear ramp: hot left wall, cold right wall — the textbook 1-D setup. + return HOT_BRUSH_TEMPERATURE_K + (COOL_BRUSH_TEMPERATURE_K - HOT_BRUSH_TEMPERATURE_K) * u; + + case InitialCondition.TWO_SPOTS: { + const hot = Math.exp(-((u - 0.28) ** 2 + (v - 0.5) ** 2) / 0.006); + const cold = Math.exp(-((u - 0.72) ** 2 + (v - 0.5) ** 2) / 0.006); + return ( + AMBIENT_TEMPERATURE_K + + (HOT_BRUSH_TEMPERATURE_K - AMBIENT_TEMPERATURE_K) * hot + + (COOL_BRUSH_TEMPERATURE_K - AMBIENT_TEMPERATURE_K) * cold + ); + } + + default: + return AMBIENT_TEMPERATURE_K; + } +} diff --git a/src/common/field/FieldTypes.ts b/src/common/field/FieldTypes.ts new file mode 100644 index 0000000..28da694 --- /dev/null +++ b/src/common/field/FieldTypes.ts @@ -0,0 +1,247 @@ +/** + * FieldTypes.ts + * + * The vocabulary shared by the model, the two field-engine backends, and the + * view. Everything here is plain data: no Scenery, no WebGPU, no axon. Both + * backends implement the same semantics against these types, which is what makes + * the CPU reference backend a usable oracle for the GPU one. + */ + +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; + +// ── Boundary conditions ─────────────────────────────────────────────────────── + +/** + * How the field behaves at the edge of the domain. + * + * - `insulated` — zero normal gradient (adiabatic). Energy is conserved. + * - `fixed` — edges are held at the ambient temperature (Dirichlet). + * - `periodic` — the domain wraps, so what leaves one side re-enters the other. + */ +export const BoundaryCondition = { + INSULATED: "insulated", + FIXED: "fixed", + PERIODIC: "periodic", +} as const; + +export type BoundaryConditionId = (typeof BoundaryCondition)[keyof typeof BoundaryCondition]; + +export const BOUNDARY_CONDITION_ORDER: readonly BoundaryConditionId[] = [ + BoundaryCondition.INSULATED, + BoundaryCondition.FIXED, + BoundaryCondition.PERIODIC, +]; + +// ── Flow presets ────────────────────────────────────────────────────────────── + +/** + * Prescribed velocity fields. None of these solve Navier-Stokes; they are + * analytic fields that give students a `v` to reason about. `plume` is the + * closest to natural convection: an upward jet up the middle with return flow + * down the sides. + */ +export const FlowPreset = { + NONE: "none", + UNIFORM: "uniform", + CHANNEL: "channel", + VORTEX: "vortex", + PLUME: "plume", +} as const; + +export type FlowPresetId = (typeof FlowPreset)[keyof typeof FlowPreset]; + +export const FLOW_PRESET_ORDER: readonly FlowPresetId[] = [ + FlowPreset.NONE, + FlowPreset.UNIFORM, + FlowPreset.CHANNEL, + FlowPreset.VORTEX, + FlowPreset.PLUME, +]; + +// ── Initial conditions ──────────────────────────────────────────────────────── + +/** How the temperature field is seeded on reset. */ +export const InitialCondition = { + /** Uniform ambient temperature — a blank canvas for the heat brush. */ + UNIFORM: "uniform", + /** A single hot Gaussian blob at the centre. */ + HOT_SPOT: "hotSpot", + /** Hot left edge, cold right edge — the classic 1-D conduction setup. */ + GRADIENT: "gradient", + /** Hot blob on the left, cold blob on the right. */ + TWO_SPOTS: "twoSpots", +} as const; + +export type InitialConditionId = (typeof InitialCondition)[keyof typeof InitialCondition]; + +// ── Brush ───────────────────────────────────────────────────────────────────── + +/** A single application of the heat brush, in unit-square coordinates. */ +export type BrushStroke = { + /** Horizontal position in [0, 1]. */ + u: number; + /** Vertical position in [0, 1]. */ + v: number; + /** Radius as a fraction of the domain's shorter side. */ + radius: number; + /** Temperature the brush pushes cells toward, in kelvin. */ + temperature: number; + /** How far toward `temperature` the centre of the brush moves the field, in [0, 1]. */ + strength: number; +}; + +/** A single application of the material brush, in unit-square coordinates. */ +export type MaterialStroke = { + u: number; + v: number; + radius: number; + /** Material painted inside the brush. */ + material: MaterialProperties; +}; + +// ── Materials ───────────────────────────────────────────────────────────────── + +/** + * The three properties that close the heat equation, plus an anisotropy ratio. + * + * The isotropic conductivity is `conductivity`; an anisotropic material scales + * it by `anisotropy` along x and by 1/`anisotropy` along y, so the geometric + * mean conductivity — and therefore the material's identity — is preserved while + * heat is free to travel more easily along one axis. An anisotropy of 1 is + * isotropic. + */ +export type MaterialProperties = { + /** Thermal conductivity k, in W/(m K). */ + conductivity: number; + /** Density rho, in kg/m^3. */ + density: number; + /** Specific heat capacity c_p, in J/(kg K). */ + specificHeat: number; + /** Ratio of k_x to k_y, as a multiplier on sqrt(k). 1 is isotropic. */ + anisotropy: number; +}; + +/** Thermal diffusivity alpha = k / (rho c_p), in m^2/s. */ +export function thermalDiffusivity(material: MaterialProperties): number { + return material.conductivity / (material.density * material.specificHeat); +} + +/** Volumetric heat capacity rho c_p, in J/(m^3 K). */ +export function volumetricHeatCapacity(material: MaterialProperties): number { + return material.density * material.specificHeat; +} + +/** Conductivity along x, in W/(m K). */ +export function conductivityX(material: MaterialProperties): number { + return material.conductivity * material.anisotropy; +} + +/** Conductivity along y, in W/(m K). */ +export function conductivityY(material: MaterialProperties): number { + return material.conductivity / material.anisotropy; +} + +/** + * The largest directional diffusivity, which is what limits the explicit time + * step. For an isotropic material this is just alpha. + */ +export function maxDirectionalDiffusivity(material: MaterialProperties): number { + const rhoCp = volumetricHeatCapacity(material); + return Math.max(conductivityX(material), conductivityY(material)) / rhoCp; +} + +// ── Transport parameters ────────────────────────────────────────────────────── + +/** + * Everything the integrator needs that is not stored per-cell. The model rebuilds + * this each frame from its Properties and hands it to the engine, so neither + * backend holds duplicated state that could drift out of sync with the UI. + */ +export type TransportParameters = { + /** Whether the advection term v . grad(T) is integrated at all. */ + advectionEnabled: boolean; + /** Whether the diffusion term alpha grad^2(T) is integrated at all. */ + diffusionEnabled: boolean; + /** Multiplier applied to the material's conductivity (the "diffusion" control). */ + diffusionScale: number; + /** Multiplier applied to the velocity field (the "flow speed" control). */ + flowScale: number; + /** Behaviour at the domain edge. */ + boundaryCondition: BoundaryConditionId; + /** Number of stability-limited substeps to take this frame. */ + substeps: number; +}; + +// ── Visualization layers ────────────────────────────────────────────────────── + +/** + * Which visualization passes run over the current field state. These are render + * options, never simulation options: toggling a layer changes nothing about the + * physics, which is exactly the distinction the Heat Transfer screen is meant to + * teach. + */ +export type LayerVisibility = { + /** The temperature colour map — the base layer. */ + temperature: boolean; + /** Isotherm contour lines at fixed temperature intervals. */ + isotherms: boolean; + /** Heat-flux arrows, q = -k grad(T). */ + heatFlux: boolean; + /** Tracer particles advected by the velocity field. */ + velocity: boolean; + /** |grad(T)| as a brightness overlay. */ + gradient: boolean; + /** Material regions tinted by conductivity. */ + material: boolean; +}; + +/** No layer visible except the temperature field. */ +export const TEMPERATURE_ONLY_LAYERS: LayerVisibility = { + temperature: true, + isotherms: false, + heatFlux: false, + velocity: false, + gradient: false, + material: false, +}; + +/** The layer ids in the order they are listed in the UI. */ +export const LAYER_ORDER: readonly (keyof LayerVisibility)[] = [ + "temperature", + "isotherms", + "heatFlux", + "velocity", + "gradient", + "material", +]; + +// ── Sampling ────────────────────────────────────────────────────────────────── + +/** One sample along a cross-section line. */ +export type CrossSectionSample = { + /** Arc length from the start of the line, in metres. */ + distance: number; + /** Temperature at this point, in kelvin. */ + temperature: number; + /** Directional derivative dT/ds along the line, in K/m. */ + gradient: number; + /** Heat flux along the line, q_s = -k dT/ds, in W/m^2. */ + flux: number; +}; + +/** Aggregate statistics over the whole temperature field. */ +export type FieldStatistics = { + minTemperature: number; + maxTemperature: number; + meanTemperature: number; +}; + +HeatTransferNamespace.register("FieldTypes", { + BOUNDARY_CONDITION_ORDER, + BoundaryCondition, + FLOW_PRESET_ORDER, + FlowPreset, + InitialCondition, + LAYER_ORDER, + TEMPERATURE_ONLY_LAYERS, +}); diff --git a/src/common/field/Materials.ts b/src/common/field/Materials.ts new file mode 100644 index 0000000..95e8d35 --- /dev/null +++ b/src/common/field/Materials.ts @@ -0,0 +1,79 @@ +/** + * Materials.ts + * + * Material presets. Values are room-temperature handbook figures; the derived + * thermal diffusivity alpha = k / (rho c_p) spans nearly four decades across this + * list, which is the point — copper and foam are the same equation with very + * different coefficients. + * + * material k [W/m K] rho [kg/m^3] c_p [J/kg K] alpha [m^2/s] + * copper 401 8960 385 1.16e-4 + * aluminum 237 2700 897 9.8e-5 + * steel 16 8000 500 4.0e-6 + * glass 1.0 2500 840 4.8e-7 + * water 0.6 1000 4182 1.4e-7 + * wood 0.15 700 1700 1.3e-7 + * insulator 0.03 30 1500 6.7e-7 + * + * `insulator` is rigid polyurethane foam: a *low conductivity* material whose + * diffusivity is nevertheless higher than wood's, because it stores almost no + * energy. That contrast is worth showing rather than smoothing over. + */ + +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import type { MaterialProperties } from "./FieldTypes.js"; + +export const MaterialId = { + COPPER: "copper", + ALUMINUM: "aluminum", + STEEL: "steel", + GLASS: "glass", + WATER: "water", + WOOD: "wood", + INSULATOR: "insulator", +} as const; + +export type MaterialIdValue = (typeof MaterialId)[keyof typeof MaterialId]; + +/** Isotropic presets, keyed by id. */ +export const MATERIALS: Record<MaterialIdValue, MaterialProperties> = { + copper: { conductivity: 401, density: 8960, specificHeat: 385, anisotropy: 1 }, + aluminum: { conductivity: 237, density: 2700, specificHeat: 897, anisotropy: 1 }, + steel: { conductivity: 16, density: 8000, specificHeat: 500, anisotropy: 1 }, + glass: { conductivity: 1.0, density: 2500, specificHeat: 840, anisotropy: 1 }, + water: { conductivity: 0.6, density: 1000, specificHeat: 4182, anisotropy: 1 }, + wood: { conductivity: 0.15, density: 700, specificHeat: 1700, anisotropy: 1 }, + insulator: { conductivity: 0.03, density: 30, specificHeat: 1500, anisotropy: 1 }, +}; + +/** Presentation order for combo boxes: most conductive first. */ +export const MATERIAL_ORDER: readonly MaterialIdValue[] = [ + MaterialId.COPPER, + MaterialId.ALUMINUM, + MaterialId.STEEL, + MaterialId.GLASS, + MaterialId.WATER, + MaterialId.WOOD, + MaterialId.INSULATOR, +]; + +/** The material a screen starts with. */ +export const DEFAULT_MATERIAL_ID: MaterialIdValue = MaterialId.COPPER; + +/** Lowest conductivity in the preset list, used to normalize the material tint. */ +export const MIN_PRESET_CONDUCTIVITY = MATERIALS.insulator.conductivity; + +/** Highest conductivity in the preset list, used to normalize the material tint. */ +export const MAX_PRESET_CONDUCTIVITY = MATERIALS.copper.conductivity; + +/** Returns a copy of a preset with its anisotropy ratio replaced. */ +export function withAnisotropy(material: MaterialProperties, anisotropy: number): MaterialProperties { + return { ...material, anisotropy }; +} + +HeatTransferNamespace.register("Materials", { + DEFAULT_MATERIAL_ID, + MATERIAL_ORDER, + MATERIALS, + MaterialId, +}); diff --git a/src/common/field/SimulationDomain.ts b/src/common/field/SimulationDomain.ts new file mode 100644 index 0000000..e16d220 --- /dev/null +++ b/src/common/field/SimulationDomain.ts @@ -0,0 +1,149 @@ +/** + * SimulationDomain.ts + * + * The discretized rectangle every field lives on. + * + * A domain is the *only* place that knows how many cells there are. Nothing else + * in the simulation — not the model, not the view, not a shader — hard-codes a + * grid size. Swapping a 128 x 128 classroom grid for a 1024 x 1024 one is a + * matter of constructing a different domain and rebuilding the engine's + * resources; the physics, the controls, and the rendering are unchanged. + * + * Coordinate systems + * ────────────────── + * grid (i, j) integer cell indices, i in [0, gridWidth), j in [0, gridHeight) + * unit (u, v) normalized [0, 1]^2, origin at the top-left of the field + * model (x, y) metres, origin at the top-left of the field + * + * `u` / `v` are what the view speaks (a pointer hit on the field canvas is + * trivially normalized), `x` / `y` are what the physics speaks. Everything else + * converts through this class. + */ + +import { RESOLUTION_PRESETS, type ResolutionPresetId } from "../../HeatTransferConstants.js"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; + +export type SimulationDomainOptions = { + /** Physical width of the domain in metres. */ + physicalWidth?: number; + /** Physical height of the domain in metres. */ + physicalHeight?: number; +}; + +export class SimulationDomain { + /** Number of cells along x. */ + public readonly gridWidth: number; + + /** Number of cells along y. */ + public readonly gridHeight: number; + + /** Physical width of the domain, in metres. */ + public readonly physicalWidth: number; + + /** Physical height of the domain, in metres. */ + public readonly physicalHeight: number; + + /** Cell size along x, in metres. */ + public readonly dx: number; + + /** Cell size along y, in metres. */ + public readonly dy: number; + + public constructor(gridWidth: number, gridHeight: number, options?: SimulationDomainOptions) { + if (!(Number.isInteger(gridWidth) && Number.isInteger(gridHeight)) || gridWidth < 2 || gridHeight < 2) { + throw new Error(`SimulationDomain requires integer grid dimensions >= 2, got ${gridWidth} x ${gridHeight}`); + } + + this.gridWidth = gridWidth; + this.gridHeight = gridHeight; + this.physicalWidth = options?.physicalWidth ?? DEFAULT_PHYSICAL_SIZE; + this.physicalHeight = options?.physicalHeight ?? DEFAULT_PHYSICAL_SIZE; + this.dx = this.physicalWidth / gridWidth; + this.dy = this.physicalHeight / gridHeight; + } + + /** Total number of cells. */ + public get cellCount(): number { + return this.gridWidth * this.gridHeight; + } + + /** The smaller of the two cell dimensions — the one that limits the explicit-diffusion time step. */ + public get minCellSize(): number { + return Math.min(this.dx, this.dy); + } + + /** + * The characteristic length used for dimensionless groups (Peclet, Fourier). + * The larger physical side, so a "cross the domain" journey is length 1. + */ + public get characteristicLength(): number { + return Math.max(this.physicalWidth, this.physicalHeight); + } + + /** Row-major index of cell (i, j). Callers must supply in-range indices. */ + public index(i: number, j: number): number { + return j * this.gridWidth + i; + } + + /** Row-major index of cell (i, j), with indices clamped into the grid. */ + public clampedIndex(i: number, j: number): number { + const ci = i < 0 ? 0 : i > this.gridWidth - 1 ? this.gridWidth - 1 : i; + const cj = j < 0 ? 0 : j > this.gridHeight - 1 ? this.gridHeight - 1 : j; + return cj * this.gridWidth + ci; + } + + /** Continuous grid coordinate (cell units, cell centres at i + 0.5) for a unit-square point. */ + public unitToGridX(u: number): number { + return u * this.gridWidth; + } + + /** @see unitToGridX */ + public unitToGridY(v: number): number { + return v * this.gridHeight; + } + + /** The cell containing a unit-square point, clamped to the grid. */ + public unitToCell(u: number, v: number): { i: number; j: number } { + const i = Math.floor(u * this.gridWidth); + const j = Math.floor(v * this.gridHeight); + return { + i: i < 0 ? 0 : i > this.gridWidth - 1 ? this.gridWidth - 1 : i, + j: j < 0 ? 0 : j > this.gridHeight - 1 ? this.gridHeight - 1 : j, + }; + } + + /** Model-space x (metres) of the centre of column i. */ + public cellCentreX(i: number): number { + return (i + 0.5) * this.dx; + } + + /** Model-space y (metres) of the centre of row j. */ + public cellCentreY(j: number): number { + return (j + 0.5) * this.dy; + } + + /** True when this domain has the same discretization as `other`. */ + public equals(other: SimulationDomain): boolean { + return ( + this.gridWidth === other.gridWidth && + this.gridHeight === other.gridHeight && + this.physicalWidth === other.physicalWidth && + this.physicalHeight === other.physicalHeight + ); + } + + public toString(): string { + return `${this.gridWidth}x${this.gridHeight} over ${this.physicalWidth}m x ${this.physicalHeight}m`; + } + + /** Builds a square domain at one of the named resolutions. */ + public static fromPreset(preset: ResolutionPresetId, options?: SimulationDomainOptions): SimulationDomain { + const cells = RESOLUTION_PRESETS[preset]; + return new SimulationDomain(cells, cells, options); + } +} + +/** Default physical extent of the (square) plate, in metres. 10 cm on a side. */ +export const DEFAULT_PHYSICAL_SIZE = 0.1; + +HeatTransferNamespace.register("SimulationDomain", SimulationDomain); diff --git a/src/common/field/VelocityPresets.ts b/src/common/field/VelocityPresets.ts new file mode 100644 index 0000000..c893d36 --- /dev/null +++ b/src/common/field/VelocityPresets.ts @@ -0,0 +1,103 @@ +/** + * VelocityPresets.ts + * + * Analytic velocity fields. Each preset returns a *direction* field whose + * magnitude never exceeds 1; the engine multiplies it by the requested speed in + * m/s. Keeping the presets dimensionless means the flow-speed control and the + * Peclet readout have one unambiguous scale to talk about, and it makes the + * advection CFL bound trivially `speed * dt / dx`. + * + * `channel`, `vortex`, and `plume` are all divergence-free by construction (the + * latter two are written from a stream function), so advecting a temperature + * field with them neither compresses nor rarefies it — heat is transported, not + * created. + * + * Coordinates are the unit square with v increasing *downward*, matching both + * the texture layout and Scenery's screen coordinates. "Up" is therefore + * negative v. + */ + +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import { FlowPreset, type FlowPresetId } from "./FieldTypes.js"; + +/** Radius of the vortex core, as a fraction of the domain's shorter side. */ +const VORTEX_CORE_RADIUS = 0.22; + +/** A dimensionless velocity direction with |v| <= 1. */ +export type UnitVelocity = { vx: number; vy: number }; + +/** + * Evaluates a preset at a point in the unit square. + * + * @param preset - which analytic field to evaluate + * @param u - horizontal position in [0, 1] + * @param v - vertical position in [0, 1], increasing downward + */ +export function evaluateFlowPreset(preset: FlowPresetId, u: number, v: number): UnitVelocity { + switch (preset) { + case FlowPreset.NONE: + return { vx: 0, vy: 0 }; + + case FlowPreset.UNIFORM: + // v = (U, 0): every parcel moves right at the same speed. + return { vx: 1, vy: 0 }; + + case FlowPreset.CHANNEL: { + // Hagen-Poiseuille profile between no-slip walls at v = 0 and v = 1: + // v_x(y) = U_max (1 - (y/R)^2) with y measured from the centreline. + const y = 2 * v - 1; + return { vx: 1 - y * y, vy: 0 }; + } + + case FlowPreset.VORTEX: { + // Lamb-Oseen-like swirl: solid-body rotation inside the core, decaying + // outside it, and exactly zero at the centre (no singularity to guard). + const dx = u - 0.5; + const dy = v - 0.5; + const rSquared = dx * dx + dy * dy; + const coreSquared = VORTEX_CORE_RADIUS * VORTEX_CORE_RADIUS; + // speed/r, so multiplying by the (-dy, dx) offset gives the tangential field + const omega = Math.exp(0.5 * (1 - rSquared / coreSquared)) / VORTEX_CORE_RADIUS; + return { vx: -dy * omega, vy: dx * omega }; + } + + case FlowPreset.PLUME: { + // Stream function psi = A sin(2 pi u) sin(pi v) with A = -1/(2 pi), giving + // v_x = d(psi)/dv, v_y = -d(psi)/du + // Two counter-rotating cells: rising in the middle, sinking at both walls. + const amplitude = -1 / (2 * Math.PI); + const vx = amplitude * Math.PI * Math.sin(2 * Math.PI * u) * Math.cos(Math.PI * v); + const vy = -amplitude * 2 * Math.PI * Math.cos(2 * Math.PI * u) * Math.sin(Math.PI * v); + return { vx, vy }; + } + + default: + return { vx: 0, vy: 0 }; + } +} + +/** + * Fills an interleaved (vx, vy) buffer with `preset` scaled to `speed` m/s. + * The buffer is laid out row-major with two floats per cell, matching the + * `rg32float` velocity texture the GPU backend uploads it into. + */ +export function fillVelocityField( + target: Float32Array, + gridWidth: number, + gridHeight: number, + preset: FlowPresetId, + speed: number, +): void { + for (let j = 0; j < gridHeight; j++) { + const v = (j + 0.5) / gridHeight; + for (let i = 0; i < gridWidth; i++) { + const u = (i + 0.5) / gridWidth; + const { vx, vy } = evaluateFlowPreset(preset, u, v); + const offset = 2 * (j * gridWidth + i); + target[offset] = vx * speed; + target[offset + 1] = vy * speed; + } + } +} + +HeatTransferNamespace.register("VelocityPresets", { evaluateFlowPreset }); diff --git a/src/common/field/cpu/CpuFieldEngine.ts b/src/common/field/cpu/CpuFieldEngine.ts new file mode 100644 index 0000000..9087b86 --- /dev/null +++ b/src/common/field/cpu/CpuFieldEngine.ts @@ -0,0 +1,182 @@ +/** + * CpuFieldEngine.ts + * + * The reference backend: the same field model, evolved with {@link kernels} and + * drawn with the 2-D canvas API. + * + * It exists for three reasons, in order of importance: + * + * 1. The simulation still runs where WebGPU does not — older browsers, locked + * down machines, software rendering. + * 2. It is the executable specification of the physics. The WGSL shaders are + * written to reproduce these kernels, and the unit tests pin the kernels + * down, so the test suite indirectly constrains the shaders too. + * 3. It makes the architectural claim falsifiable: if the model can drive both + * an array-of-floats backend and a texture backend without noticing, then + * the field abstraction really is the interface, not the GPU. + * + * It is deliberately capped at a coarse grid — see MAX_CPU_RESOLUTION — because + * it runs on the main thread. + */ + +import { AMBIENT_TEMPERATURE_K, PARTICLE_COUNT } from "../../../HeatTransferConstants.js"; +import { FieldBackend, type FieldBackendId, type FieldEngineOptions, type FieldRenderStyle } from "../FieldEngine.js"; +import { FieldEngineBase } from "../FieldEngineBase.js"; +import type { LayerVisibility, TransportParameters } from "../FieldTypes.js"; +import { advectStep, diffuseStep, type MaterialArrays } from "../kernels.js"; +import type { SimulationDomain } from "../SimulationDomain.js"; +import { CpuFieldRenderer } from "./CpuFieldRenderer.js"; +import { CpuParticleSystem } from "./CpuParticleSystem.js"; + +export class CpuFieldEngine extends FieldEngineBase { + public readonly backend: FieldBackendId = FieldBackend.CPU; + + /** The other half of the ping-pong. `temperatureMirror` is always the current field. */ + private readonly scratch: Float32Array; + + /** A second scratch buffer, so advection and diffusion can each write somewhere clean. */ + private readonly scratchB: Float32Array; + + private readonly particles: CpuParticleSystem; + private readonly renderer: CpuFieldRenderer; + + /** Lazily allocated copy of the material arrays with conductivity scaled. */ + private scaledMaterialCache: MaterialArrays | null = null; + + /** Flow scale from the last step, so the renderer draws particles at the right speed. */ + private lastFlowScale = 1; + + public constructor(domain: SimulationDomain, options: FieldEngineOptions) { + super(domain, options); + this.scratch = new Float32Array(domain.cellCount); + this.scratchB = new Float32Array(domain.cellCount); + this.particles = new CpuParticleSystem(domain, PARTICLE_COUNT); + this.renderer = new CpuFieldRenderer(domain, this.geometry, this.canvas); + } + + // ── Evolving ──────────────────────────────────────────────────────────────── + + public step(parameters: TransportParameters): number { + this.boundary = parameters.boundaryCondition; + this.lastFlowScale = parameters.flowScale; + + const substep = this.computeSubstep(parameters.flowScale, parameters.diffusionScale, parameters.diffusionEnabled); + this.currentSubstep = substep; + + const doAdvection = parameters.advectionEnabled && parameters.flowScale > 0 && this.maxSpeed > 0; + const doDiffusion = parameters.diffusionEnabled && parameters.diffusionScale > 0; + + let advanced = 0; + for (let n = 0; n < parameters.substeps; n++) { + // Operator splitting: transport first, then diffuse the transported field. + let current = this.temperatureMirror; + + if (doAdvection) { + advectStep( + current, + this.scratch, + this.velocity, + this.geometry, + this.boundary, + AMBIENT_TEMPERATURE_K, + substep, + parameters.flowScale, + ); + current = this.scratch; + } + + if (doDiffusion) { + const target = current === this.temperatureMirror ? this.scratch : this.scratchB; + diffuseStep( + current, + target, + this.geometry, + this.scaledMaterial(parameters.diffusionScale), + this.boundary, + AMBIENT_TEMPERATURE_K, + substep, + ); + current = target; + } + + if (current !== this.temperatureMirror) { + this.temperatureMirror.set(current); + } + + advanced += substep; + } + + if (doAdvection) { + this.particles.step(this.velocity, advanced, parameters.flowScale); + } + + this.elapsedTime += advanced; + return advanced; + } + + /** + * The material arrays with conductivity multiplied by the diffusion control. + * + * The scale is applied to k rather than to alpha so that rho c_p — and + * therefore the energy the field carries — is untouched: turning the + * "diffusion" slider changes how fast heat spreads, not how much there is. + */ + private scaledMaterial(scale: number): MaterialArrays { + if (scale === 1) { + return this.material; + } + const cells = this.domain.cellCount; + const cache: MaterialArrays = this.scaledMaterialCache ?? { + conductivityX: new Float32Array(cells), + conductivityY: new Float32Array(cells), + volumetricHeatCapacity: new Float32Array(cells), + }; + this.scaledMaterialCache = cache; + + for (let index = 0; index < cells; index++) { + cache.conductivityX[index] = (this.material.conductivityX[index] ?? 0) * scale; + cache.conductivityY[index] = (this.material.conductivityY[index] ?? 0) * scale; + cache.volumetricHeatCapacity[index] = this.material.volumetricHeatCapacity[index] ?? 1; + } + return cache; + } + + // ── Drawing ───────────────────────────────────────────────────────────────── + + public render(layers: LayerVisibility, style: FieldRenderStyle): void { + this.renderer.render(layers, style, { + temperature: this.temperatureMirror, + material: this.material, + velocity: this.velocity, + boundary: this.boundary, + particles: this.particles, + flowScale: this.lastFlowScale, + }); + } + + // ── Backend hooks ─────────────────────────────────────────────────────────── + + // The CPU backend keeps no second copy of anything, so these are all no-ops — + // the base class has already written straight into the arrays it evolves. + + protected onMaterialChanged(): void { + // Nothing to upload. + } + + protected onVelocityChanged(): void { + this.particles.reset(); + } + + protected onTemperaturePainted(): void { + // Already applied to the mirror, which is the field. + } + + protected onTemperatureReseeded(): void { + this.particles.reset(); + } + + public dispose(): void { + this.canvas.width = 0; + this.canvas.height = 0; + } +} diff --git a/src/common/field/cpu/CpuFieldRenderer.ts b/src/common/field/cpu/CpuFieldRenderer.ts new file mode 100644 index 0000000..ab53381 --- /dev/null +++ b/src/common/field/cpu/CpuFieldRenderer.ts @@ -0,0 +1,451 @@ +/** + * CpuFieldRenderer.ts + * + * The 2-D canvas equivalent of the WebGPU render passes. Same layer list, same + * order, same meaning: + * + * 1. temperature — colour-mapped scalar field, drawn as an ImageData at grid + * resolution and scaled up with the browser's own smoothing + * 2. material — conductivity tint, composited into the same ImageData + * 3. gradient — |grad T| as a brightness lift, also in the same ImageData + * 4. isotherms — marching-squares contours, drawn as vector paths so they + * stay crisp no matter how coarse the grid is + * 5. heat flux — arrows on a coarse lattice + * 6. velocity — tracer particles + * + * Passes 1-3 share one pixel loop because they all write colour; 4-6 are vector + * overlays drawn on top. + */ + +import { FLUX_ARROW_COUNT, ISOTHERM_INTERVAL_K, MAX_ARROW_LENGTH_FRACTION } from "../../../HeatTransferConstants.js"; +import { rgbToCss, sampleColorMap } from "../ColorMap.js"; +import type { FieldRenderStyle } from "../FieldEngine.js"; +import type { BoundaryConditionId, LayerVisibility } from "../FieldTypes.js"; +import { type FieldGeometry, gradientAt, type MaterialArrays } from "../kernels.js"; +import { MAX_PRESET_CONDUCTIVITY, MIN_PRESET_CONDUCTIVITY } from "../Materials.js"; +import type { SimulationDomain } from "../SimulationDomain.js"; +import type { CpuParticleSystem } from "./CpuParticleSystem.js"; + +/** Entries in the colour lookup table built once per style change. */ +const COLOR_LUT_SIZE = 256; + +/** How much of the way to white the gradient layer lifts a pixel at full strength. */ +const GRADIENT_LIFT = 0.55; + +/** Opacity of the material tint overlay. */ +const MATERIAL_TINT_ALPHA = 0.35; + +/** Radius of a tracer particle, in display pixels. */ +const PARTICLE_RADIUS = 1.6; + +/** The colour a pixel starts from when the temperature layer is off. */ +const BLANK_COLOR = { red: 15, green: 18, blue: 31 } as const; + +/** + * Grey level for a material of conductivity `k`, on a log scale across the preset + * range: bright for conductors, near-black for insulators. + */ +function materialTint(conductivity: number): number { + const logMin = Math.log(MIN_PRESET_CONDUCTIVITY); + const logSpan = Math.log(MAX_PRESET_CONDUCTIVITY) - logMin; + const k = Math.max(MIN_PRESET_CONDUCTIVITY, conductivity); + const t = Math.min(1, Math.max(0, (Math.log(k) - logMin) / logSpan)); + return 40 + 200 * t; +} + +export type CpuRenderInputs = { + temperature: Float32Array; + material: MaterialArrays; + velocity: Float32Array; + boundary: BoundaryConditionId; + particles: CpuParticleSystem; + flowScale: number; +}; + +export class CpuFieldRenderer { + private readonly domain: SimulationDomain; + private readonly geometry: FieldGeometry; + private readonly canvas: HTMLCanvasElement; + private readonly context: CanvasRenderingContext2D | null; + + /** Grid-resolution scratch canvas the field is painted into before upscaling. */ + private readonly gridCanvas: HTMLCanvasElement; + private readonly gridContext: CanvasRenderingContext2D | null; + private readonly gridImage: ImageData | null; + + /** Colour ramp flattened to a byte LUT, rebuilt when the style's range changes. */ + private readonly colorLut = new Uint8ClampedArray(COLOR_LUT_SIZE * 3); + + public constructor(domain: SimulationDomain, geometry: FieldGeometry, canvas: HTMLCanvasElement) { + this.domain = domain; + this.geometry = geometry; + this.canvas = canvas; + this.context = canvas.getContext("2d"); + + this.gridCanvas = document.createElement("canvas"); + this.gridCanvas.width = domain.gridWidth; + this.gridCanvas.height = domain.gridHeight; + this.gridContext = this.gridCanvas.getContext("2d"); + this.gridImage = this.gridContext?.createImageData(domain.gridWidth, domain.gridHeight) ?? null; + + this.buildColorLut(); + } + + private buildColorLut(): void { + for (let n = 0; n < COLOR_LUT_SIZE; n++) { + const { red, green, blue } = sampleColorMap(n / (COLOR_LUT_SIZE - 1)); + this.colorLut[3 * n] = red * 255; + this.colorLut[3 * n + 1] = green * 255; + this.colorLut[3 * n + 2] = blue * 255; + } + } + + public render(layers: LayerVisibility, style: FieldRenderStyle, inputs: CpuRenderInputs): void { + const context = this.context; + if (!context) { + return; + } + + const { width, height } = this.canvas; + context.clearRect(0, 0, width, height); + + this.paintField(layers, style, inputs); + + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + context.drawImage(this.gridCanvas, 0, 0, width, height); + + if (layers.isotherms) { + this.drawIsotherms(context, style, inputs); + } + if (layers.heatFlux) { + this.drawFluxArrows(context, style, inputs); + } + if (layers.velocity) { + this.drawParticles(context, style, inputs); + } + } + + // ── Pass 1-3: colour ──────────────────────────────────────────────────────── + + private paintField(layers: LayerVisibility, style: FieldRenderStyle, inputs: CpuRenderInputs): void { + const image = this.gridImage; + const gridContext = this.gridContext; + if (!(image && gridContext)) { + return; + } + + const { gridWidth, gridHeight } = this.domain; + const { temperature, material, boundary } = inputs; + const span = Math.max(1e-6, style.maxTemperature - style.minTemperature); + const pixels = image.data; + + // The gradient layer normalizes against the frame's own peak, so a nearly + // uniform field does not render as noise amplified to full brightness. + const gradientScale = layers.gradient ? this.gradientNormalization(inputs, style.minTemperature) : 0; + + for (let j = 0; j < gridHeight; j++) { + for (let i = 0; i < gridWidth; i++) { + const index = j * gridWidth + i; + const offset = 4 * index; + + const temperatureColor = layers.temperature + ? this.lookUpColor(((temperature[index] ?? style.minTemperature) - style.minTemperature) / span) + : BLANK_COLOR; + + let { red, green, blue } = temperatureColor; + + if (layers.material) { + const tint = materialTint(material.conductivityX[index] ?? MIN_PRESET_CONDUCTIVITY); + red = red * (1 - MATERIAL_TINT_ALPHA) + tint * MATERIAL_TINT_ALPHA; + green = green * (1 - MATERIAL_TINT_ALPHA) + tint * MATERIAL_TINT_ALPHA; + blue = blue * (1 - MATERIAL_TINT_ALPHA) + tint * MATERIAL_TINT_ALPHA; + } + + if (gradientScale > 0) { + const { gx, gy } = gradientAt(temperature, this.geometry, boundary, i, j, style.minTemperature); + const lift = Math.min(1, Math.hypot(gx, gy) * gradientScale) * GRADIENT_LIFT; + red += (255 - red) * lift; + green += (255 - green) * lift; + blue += (255 - blue) * lift; + } + + pixels[offset] = red; + pixels[offset + 1] = green; + pixels[offset + 2] = blue; + pixels[offset + 3] = 255; + } + } + + gridContext.putImageData(image, 0, 0); + } + + /** The colour ramp sampled through the byte LUT, as 0-255 components. */ + private lookUpColor(position: number): { red: number; green: number; blue: number } { + const lut = Math.min(COLOR_LUT_SIZE - 1, Math.max(0, Math.round(position * (COLOR_LUT_SIZE - 1)))); + return { + red: this.colorLut[3 * lut] ?? 0, + green: this.colorLut[3 * lut + 1] ?? 0, + blue: this.colorLut[3 * lut + 2] ?? 0, + }; + } + + /** + * `1 / peak |grad T|` over a quarter-density sample of the field, or 0 when the + * field is flat. Sampling every other cell in each direction is four times + * cheaper and cannot miss a peak by more than one cell's worth of curvature. + */ + private gradientNormalization(inputs: CpuRenderInputs, outsideValue: number): number { + const { gridWidth, gridHeight } = this.domain; + let peak = 0; + for (let j = 0; j < gridHeight; j += 2) { + for (let i = 0; i < gridWidth; i += 2) { + const { gx, gy } = gradientAt(inputs.temperature, this.geometry, inputs.boundary, i, j, outsideValue); + const magnitude = Math.hypot(gx, gy); + if (magnitude > peak) { + peak = magnitude; + } + } + } + return peak > 0 ? 1 / peak : 0; + } + + // ── Pass 4: isotherms ─────────────────────────────────────────────────────── + + /** + * Marching squares over the cell-centre lattice. + * + * Only the levels that actually cross a given quad are tested, which turns a + * levels x cells double loop into something close to a single pass over the + * cells: most quads in a smooth field span less than one contour interval. + */ + private drawIsotherms(context: CanvasRenderingContext2D, style: FieldRenderStyle, inputs: CpuRenderInputs): void { + const { gridWidth, gridHeight } = this.domain; + const temperature = inputs.temperature; + const interval = style.isothermInterval > 0 ? style.isothermInterval : ISOTHERM_INTERVAL_K; + const scaleX = this.canvas.width / gridWidth; + const scaleY = this.canvas.height / gridHeight; + + context.save(); + context.strokeStyle = rgbToCss(style.isotherm); + context.lineWidth = 1.25; + context.globalAlpha = 0.85; + context.beginPath(); + + for (let j = 0; j < gridHeight - 1; j++) { + for (let i = 0; i < gridWidth - 1; i++) { + const t00 = temperature[j * gridWidth + i] ?? 0; + const t10 = temperature[j * gridWidth + i + 1] ?? 0; + const t01 = temperature[(j + 1) * gridWidth + i] ?? 0; + const t11 = temperature[(j + 1) * gridWidth + i + 1] ?? 0; + + const lowest = Math.min(t00, t10, t01, t11); + const highest = Math.max(t00, t10, t01, t11); + const firstLevel = Math.ceil(lowest / interval); + const lastLevel = Math.floor(highest / interval); + + for (let level = firstLevel; level <= lastLevel; level++) { + const value = level * interval; + appendContourSegment(context, i, j, t00, t10, t01, t11, value, scaleX, scaleY); + } + } + } + + context.stroke(); + context.restore(); + } + + // ── Pass 5: heat flux ─────────────────────────────────────────────────────── + + private drawFluxArrows(context: CanvasRenderingContext2D, style: FieldRenderStyle, inputs: CpuRenderInputs): void { + const { gridWidth, gridHeight } = this.domain; + const { temperature, material, boundary } = inputs; + const width = this.canvas.width; + const height = this.canvas.height; + const maxLength = MAX_ARROW_LENGTH_FRACTION * width; + + // Two passes: find the largest flux on the lattice, then scale every arrow + // against it so the longest arrow is always exactly maxLength. + const samples: { x: number; y: number; qx: number; qy: number }[] = []; + let peak = 0; + + for (let row = 0; row < FLUX_ARROW_COUNT; row++) { + for (let column = 0; column < FLUX_ARROW_COUNT; column++) { + const u = (column + 0.5) / FLUX_ARROW_COUNT; + const v = (row + 0.5) / FLUX_ARROW_COUNT; + const i = Math.min(gridWidth - 1, Math.floor(u * gridWidth)); + const j = Math.min(gridHeight - 1, Math.floor(v * gridHeight)); + const { gx, gy } = gradientAt(temperature, this.geometry, boundary, i, j, style.minTemperature); + const index = j * gridWidth + i; + const qx = -(material.conductivityX[index] ?? 0) * gx; + const qy = -(material.conductivityY[index] ?? 0) * gy; + const magnitude = Math.hypot(qx, qy); + if (magnitude > peak) { + peak = magnitude; + } + samples.push({ x: u * width, y: v * height, qx, qy }); + } + } + + if (peak <= 0) { + return; + } + + context.save(); + context.strokeStyle = rgbToCss(style.arrow); + context.fillStyle = rgbToCss(style.arrow); + context.lineWidth = 1.4; + context.lineCap = "round"; + + for (const sample of samples) { + const magnitude = Math.hypot(sample.qx, sample.qy); + if (magnitude < peak * 0.02) { + continue; + } + // Square-root scaling keeps weak arrows visible without letting strong ones + // dominate — the field often spans two decades of |q|. + const length = maxLength * Math.sqrt(magnitude / peak); + const dirX = sample.qx / magnitude; + const dirY = sample.qy / magnitude; + drawArrow(context, sample.x, sample.y, dirX, dirY, length); + } + + context.restore(); + } + + // ── Pass 6: velocity ──────────────────────────────────────────────────────── + + private drawParticles(context: CanvasRenderingContext2D, style: FieldRenderStyle, inputs: CpuRenderInputs): void { + const { particles } = inputs; + const width = this.canvas.width; + const height = this.canvas.height; + + context.save(); + context.fillStyle = rgbToCss(style.particle); + for (let n = 0; n < particles.particleCount; n++) { + const u = particles.positions[2 * n] ?? 0; + const v = particles.positions[2 * n + 1] ?? 0; + context.globalAlpha = particles.opacityAt(n); + context.beginPath(); + context.arc(u * width, v * height, PARTICLE_RADIUS, 0, 2 * Math.PI); + context.fill(); + } + context.restore(); + } +} + +/** Linear interpolation of the crossing position between two corner values. */ +function crossing(a: number, b: number, value: number): number { + const span = b - a; + return span === 0 ? 0.5 : (value - a) / span; +} + +/** + * Appends the contour segment(s) for one marching-squares cell to the current path. + * + * Corners are the cell centres (i, j), (i+1, j), (i, j+1), (i+1, j+1); the + * segment endpoints are placed on the edges where the level is crossed. + */ +function appendContourSegment( + context: CanvasRenderingContext2D, + i: number, + j: number, + t00: number, + t10: number, + t01: number, + t11: number, + value: number, + scaleX: number, + scaleY: number, +): void { + const code = (t00 > value ? 1 : 0) | (t10 > value ? 2 : 0) | (t11 > value ? 4 : 0) | (t01 > value ? 8 : 0); + if (code === 0 || code === 15) { + return; + } + + // Edge crossing points, in cell-centre coordinates offset by (i, j). + const top = { x: i + crossing(t00, t10, value), y: j }; + const right = { x: i + 1, y: j + crossing(t10, t11, value) }; + const bottom = { x: i + crossing(t01, t11, value), y: j + 1 }; + const left = { x: i, y: j + crossing(t00, t01, value) }; + + const line = (a: { x: number; y: number }, b: { x: number; y: number }): void => { + context.moveTo((a.x + 0.5) * scaleX, (a.y + 0.5) * scaleY); + context.lineTo((b.x + 0.5) * scaleX, (b.y + 0.5) * scaleY); + }; + + switch (code) { + case 1: + case 14: + line(left, top); + break; + case 2: + case 13: + line(top, right); + break; + case 3: + case 12: + line(left, right); + break; + case 4: + case 11: + line(right, bottom); + break; + case 6: + case 9: + line(top, bottom); + break; + case 7: + case 8: + line(left, bottom); + break; + case 5: + // Saddle: two disjoint segments. + line(left, top); + line(right, bottom); + break; + case 10: + line(top, right); + line(left, bottom); + break; + default: + break; + } +} + +/** Draws a line-and-head arrow centred on (x, y) pointing along (dirX, dirY). */ +function drawArrow( + context: CanvasRenderingContext2D, + x: number, + y: number, + dirX: number, + dirY: number, + length: number, +): void { + const halfLength = length / 2; + const tailX = x - dirX * halfLength; + const tailY = y - dirY * halfLength; + const tipX = x + dirX * halfLength; + const tipY = y + dirY * halfLength; + + context.beginPath(); + context.moveTo(tailX, tailY); + context.lineTo(tipX, tipY); + context.stroke(); + + const headLength = Math.min(length * 0.45, 7); + const perpX = -dirY; + const perpY = dirX; + context.beginPath(); + context.moveTo(tipX, tipY); + context.lineTo( + tipX - dirX * headLength + perpX * headLength * 0.45, + tipY - dirY * headLength + perpY * headLength * 0.45, + ); + context.lineTo( + tipX - dirX * headLength - perpX * headLength * 0.45, + tipY - dirY * headLength - perpY * headLength * 0.45, + ); + context.closePath(); + context.fill(); +} diff --git a/src/common/field/cpu/CpuParticleSystem.ts b/src/common/field/cpu/CpuParticleSystem.ts new file mode 100644 index 0000000..726afda --- /dev/null +++ b/src/common/field/cpu/CpuParticleSystem.ts @@ -0,0 +1,107 @@ +/** + * CpuParticleSystem.ts + * + * Tracer particles for the velocity layer, advected on the CPU. + * + * Particles are pure visualization — they carry no heat and never feed back into + * the temperature field. Each one drifts with the local velocity, ages out after + * {@link PARTICLE_LIFETIME_S} of simulated time, and respawns at a fresh random + * position so a steady flow keeps a steady density of tracers instead of + * draining into stagnation points. + * + * The WebGPU backend does the same job in a compute shader over a storage + * buffer; the spawn and ageing rules are deliberately identical so the two + * backends look the same. + */ + +import { PARTICLE_LIFETIME_S } from "../../../HeatTransferConstants.js"; +import type { SimulationDomain } from "../SimulationDomain.js"; + +export class CpuParticleSystem { + /** Interleaved (u, v) positions in the unit square. */ + public readonly positions: Float32Array; + + /** Remaining life of each particle, in simulated seconds. */ + private readonly ages: Float32Array; + + private readonly count: number; + private readonly domain: SimulationDomain; + + public constructor(domain: SimulationDomain, count: number) { + this.domain = domain; + this.count = count; + this.positions = new Float32Array(2 * count); + this.ages = new Float32Array(count); + this.reset(); + } + + /** Scatters every particle and randomizes its remaining life. */ + public reset(): void { + for (let n = 0; n < this.count; n++) { + this.respawn(n, Math.random() * PARTICLE_LIFETIME_S); + } + } + + private respawn(n: number, life: number): void { + this.positions[2 * n] = Math.random(); + this.positions[2 * n + 1] = Math.random(); + this.ages[n] = life; + } + + /** + * Advances every particle by `dt` simulated seconds through the velocity field. + * + * @param velocity - interleaved (vx, vy) in m/s, one pair per cell + * @param dt - simulated time step, in seconds + * @param flowScale - multiplier matching the one the advection kernel used + */ + public step(velocity: Float32Array, dt: number, flowScale: number): void { + const { gridWidth, gridHeight, physicalWidth, physicalHeight } = this.domain; + + for (let n = 0; n < this.count; n++) { + const life = (this.ages[n] ?? 0) - dt; + if (life <= 0) { + this.respawn(n, PARTICLE_LIFETIME_S); + continue; + } + this.ages[n] = life; + + const u = this.positions[2 * n] ?? 0; + const v = this.positions[2 * n + 1] ?? 0; + + // Nearest-cell velocity lookup: particles are dense and small, so the extra + // smoothness of a bilinear fetch is not worth the cost here. + const i = Math.min(gridWidth - 1, Math.max(0, Math.floor(u * gridWidth))); + const j = Math.min(gridHeight - 1, Math.max(0, Math.floor(v * gridHeight))); + const cell = 2 * (j * gridWidth + i); + const vx = (velocity[cell] ?? 0) * flowScale; + const vy = (velocity[cell + 1] ?? 0) * flowScale; + + // Velocity is metres/second; positions are in the unit square. + // Wrap rather than respawn: a tracer that leaves one side re-enters the + // other, which is what makes a uniform flow read as a steady stream. (The + // advection kernel wraps too whenever the boundary is periodic, so the + // tracers and the temperature they mark stay together.) + const nextU = wrapUnit(u + (vx * dt) / physicalWidth); + const nextV = wrapUnit(v + (vy * dt) / physicalHeight); + this.positions[2 * n] = nextU; + this.positions[2 * n + 1] = nextV; + } + } + + /** Fraction of life remaining, used to fade particles in and out. */ + public opacityAt(n: number): number { + const life = (this.ages[n] ?? 0) / PARTICLE_LIFETIME_S; + // Triangular fade so particles neither pop in nor pop out. + return Math.min(1, 2 * Math.min(life, 1 - life) * 2); + } + + public get particleCount(): number { + return this.count; + } +} + +/** Wraps a unit-square coordinate into [0, 1). */ +function wrapUnit(value: number): number { + return value - Math.floor(value); +} diff --git a/src/common/field/createFieldEngine.ts b/src/common/field/createFieldEngine.ts new file mode 100644 index 0000000..e630247 --- /dev/null +++ b/src/common/field/createFieldEngine.ts @@ -0,0 +1,66 @@ +/** + * createFieldEngine.ts + * + * Builds a field engine for a screen. + * + * Synchronous by design: {@link initializeGpuContext} has already answered the + * only asynchronous question during startup, so a screen's model factory can + * construct its engine inline. The resolution is clamped to what the chosen + * backend can actually carry, so asking for a 2048 grid on the CPU fallback + * quietly yields a coarse grid rather than a frozen tab — and the caller is told + * what it actually got, so the UI can say so instead of silently lying about the + * resolution. + */ + +import { + DEFAULT_RESOLUTION, + MAX_CPU_RESOLUTION, + RESOLUTION_PRESETS, + type ResolutionPresetId, +} from "../../HeatTransferConstants.js"; +import { CpuFieldEngine } from "./cpu/CpuFieldEngine.js"; +import { FieldBackend, type FieldBackendId, type FieldEngine } from "./FieldEngine.js"; +import { getGpuContext } from "./gpu/GpuContext.js"; +import { WebGpuFieldEngine } from "./gpu/WebGpuFieldEngine.js"; +import { SimulationDomain } from "./SimulationDomain.js"; + +export type CreateFieldEngineOptions = { + /** Grid resolution the caller would like. May be reduced to fit the backend. */ + resolution?: ResolutionPresetId; + /** Edge length of the square backing canvas, in device pixels. */ + displaySize: number; +}; + +export type FieldEngineCreation = { + engine: FieldEngine; + backend: FieldBackendId; + /** Cells per side actually allocated, which may be fewer than requested. */ + effectiveResolution: number; + /** True when the resolution had to be reduced to fit the backend. */ + resolutionReduced: boolean; +}; + +export function createFieldEngine(options: CreateFieldEngineOptions): FieldEngineCreation { + const requestedCells = RESOLUTION_PRESETS[options.resolution ?? DEFAULT_RESOLUTION]; + const gpu = getGpuContext(); + + if (gpu) { + const cells = Math.min(requestedCells, gpu.maxGridSize); + const domain = new SimulationDomain(cells, cells); + return { + engine: new WebGpuFieldEngine(domain, { displaySize: options.displaySize }, gpu), + backend: FieldBackend.WEBGPU, + effectiveResolution: cells, + resolutionReduced: cells < requestedCells, + }; + } + + const cells = Math.min(requestedCells, MAX_CPU_RESOLUTION); + const domain = new SimulationDomain(cells, cells); + return { + engine: new CpuFieldEngine(domain, { displaySize: options.displaySize }), + backend: FieldBackend.CPU, + effectiveResolution: cells, + resolutionReduced: cells < requestedCells, + }; +} diff --git a/src/common/field/gpu/GpuContext.ts b/src/common/field/gpu/GpuContext.ts new file mode 100644 index 0000000..f76273b --- /dev/null +++ b/src/common/field/gpu/GpuContext.ts @@ -0,0 +1,184 @@ +/** + * GpuContext.ts + * + * One device for the whole simulation, acquired once before the first screen is + * built. + * + * Screens create their field engines lazily — SceneryStack only calls a screen's + * model factory when the student first opens it — and a model factory cannot + * await. So the single genuinely asynchronous step, "do we have a working GPU?", + * is hoisted out of the model layer and answered here during startup, while the + * splash screen is still up. After {@link initializeGpuContext} resolves, + * building a field engine is an ordinary synchronous constructor call. + * + * "Working" means more than "a device was returned". WGSL compilation is + * asynchronous and does *not* throw from `createShaderModule`, so a shader that + * fails to compile on some driver would otherwise surface as a silently black + * canvas. Every shader is therefore compiled and checked here, once, and any + * error demotes the whole simulation to the CPU backend before a student sees + * anything. + */ + +import { ADVECT_SHADER, BRUSH_SHADER, DIFFUSE_SHADER, PARTICLE_COMPUTE_SHADER } from "./shaders/compute.js"; +import { ARROW_RENDER_SHADER, FIELD_RENDER_SHADER, PARTICLE_RENDER_SHADER } from "./shaders/render.js"; +import { requestCanvasContext, requestWebGpuContext, type WebGpuContext } from "./WebGpuSupport.js"; + +/** Why the simulation is not using the GPU, when it is not. */ +export const GpuUnavailableReason = { + /** The browser has no `navigator.gpu`, or no adapter/device could be acquired. */ + NO_DEVICE: "noDevice", + /** A shader failed to compile on this device. */ + SHADER_ERROR: "shaderError", + /** The device works, but its canvas output cannot be composited into Scenery. */ + PRESENTATION: "presentation", + /** The user asked for the CPU backend explicitly. */ + FORCED: "forced", +} as const; + +export type GpuUnavailableReasonId = (typeof GpuUnavailableReason)[keyof typeof GpuUnavailableReason]; + +export type GpuInitializationResult = { + context: WebGpuContext | null; + reason: GpuUnavailableReasonId | null; + /** Compiler diagnostics, when a shader failed. Shown in the About/preferences UI, not thrown. */ + diagnostics: readonly string[]; +}; + +let cached: GpuInitializationResult | null = null; + +/** The device, if the simulation has one. */ +export function getGpuContext(): WebGpuContext | null { + return cached?.context ?? null; +} + +/** + * Acquires and validates the device. Safe to call more than once; the first + * result is cached and returned thereafter. + * + * @param forceCpu - skip WebGPU entirely, for the `forceCpu` query parameter + */ +export async function initializeGpuContext(forceCpu: boolean): Promise<GpuInitializationResult> { + if (cached) { + return cached; + } + + if (forceCpu) { + cached = { context: null, reason: GpuUnavailableReason.FORCED, diagnostics: [] }; + return cached; + } + + const context = await requestWebGpuContext(); + if (!context) { + cached = { context: null, reason: GpuUnavailableReason.NO_DEVICE, diagnostics: [] }; + return cached; + } + + const diagnostics = await compileAllShaders(context.device); + if (diagnostics.length > 0) { + context.device.destroy(); + cached = { context: null, reason: GpuUnavailableReason.SHADER_ERROR, diagnostics }; + return cached; + } + + if (!canPresentToCanvas(context.device, context.presentationFormat)) { + context.device.destroy(); + cached = { context: null, reason: GpuUnavailableReason.PRESENTATION, diagnostics: [] }; + return cached; + } + + cached = { context, reason: null, diagnostics: [] }; + return cached; +} + +/** Edge length of the throwaway canvas used by the presentation check. */ +const PRESENTATION_PROBE_SIZE = 4; + +/** + * Whether a WebGPU canvas's output can actually be composited into Scenery. + * + * The field reaches the scene graph as a Scenery `Image` wrapping the engine's + * canvas, which means the browser has to be able to `drawImage` a WebGPU-backed + * canvas into a 2-D one. That works on hardware, but not on every software + * rasterizer: some configurations happily create a device, compile every shader, + * and run compute passes correctly while presenting nothing a 2-D context can + * read. Without this check the symptom is a completely blank field with no error + * anywhere — strictly worse than the CPU fallback, which at least draws. + * + * So: clear a 4 x 4 canvas to an unmistakable colour, copy it, and look. If the + * pixel does not survive the trip, the device is unusable *for this simulation* + * however well it computes, and we take the CPU path. + */ +function canPresentToCanvas(device: GPUDevice, format: GPUTextureFormat): boolean { + try { + const source = document.createElement("canvas"); + source.width = PRESENTATION_PROBE_SIZE; + source.height = PRESENTATION_PROBE_SIZE; + const gpuContext = requestCanvasContext(source); + if (!gpuContext) { + return false; + } + gpuContext.configure({ device, format, alphaMode: "opaque" }); + + const encoder = device.createCommandEncoder({ label: "presentationProbe" }); + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: gpuContext.getCurrentTexture().createView(), + clearValue: { r: 1, g: 0, b: 0, a: 1 }, + loadOp: "clear", + storeOp: "store", + }, + ], + }); + pass.end(); + device.queue.submit([encoder.finish()]); + + const destination = document.createElement("canvas"); + destination.width = PRESENTATION_PROBE_SIZE; + destination.height = PRESENTATION_PROBE_SIZE; + const context2d = destination.getContext("2d", { willReadFrequently: true }); + if (!context2d) { + return false; + } + context2d.drawImage(source, 0, 0); + + const pixel = context2d.getImageData(1, 1, 1, 1).data; + // Red and opaque is what was cleared; anything else means it did not arrive. + return (pixel[0] ?? 0) > 200 && (pixel[3] ?? 0) > 200; + } catch { + return false; + } +} + +/** + * Compiles every shader and returns the error messages, if any. + * + * `getCompilationInfo()` is the only reliable way to see a WGSL error: shader + * module creation never rejects, and the resulting pipeline would just draw + * nothing. + */ +async function compileAllShaders(device: GPUDevice): Promise<string[]> { + const sources: Record<string, string> = { + advect: ADVECT_SHADER, + diffuse: DIFFUSE_SHADER, + brush: BRUSH_SHADER, + particleCompute: PARTICLE_COMPUTE_SHADER, + fieldRender: FIELD_RENDER_SHADER, + arrowRender: ARROW_RENDER_SHADER, + particleRender: PARTICLE_RENDER_SHADER, + }; + + const problems: string[] = []; + const checks = Object.entries(sources).map(async ([label, code]) => { + const module = device.createShaderModule({ label, code }); + const info = await module.getCompilationInfo(); + for (const message of info.messages) { + if (message.type === "error") { + problems.push(`${label}:${message.lineNum}:${message.linePos} ${message.message}`); + } + } + }); + + await Promise.all(checks); + return problems; +} diff --git a/src/common/field/gpu/WebGpuFieldEngine.ts b/src/common/field/gpu/WebGpuFieldEngine.ts new file mode 100644 index 0000000..daffd78 --- /dev/null +++ b/src/common/field/gpu/WebGpuFieldEngine.ts @@ -0,0 +1,837 @@ +/** + * WebGpuFieldEngine.ts + * + * The primary backend: the temperature field is a GPU texture, compute shaders + * evolve it, render pipelines draw it, and the CPU only ever authors inputs and + * reads back samples. + * + * Resources + * ───────── + * temperature[2] r32float ping-pong pair, the only GPU-owned state + * velocity rg32float CPU-authored from an analytic preset + * material rgba32float CPU-authored: (k_x, k_y, rho c_p, unused) + * particles storage buf (u, v, life, seed) per tracer + * + * A timestep is `substeps` iterations of advect → swap → diffuse → swap, all + * recorded into a single command buffer so the whole frame is one submission + * regardless of how many substeps it takes. Brush strokes are a further pass over + * the same pair. + * + * Reading back + * ──────────── + * The probe, legend, cross-section graph, and statistics all read the CPU mirror + * in `FieldEngineBase`, which is refreshed from the GPU every + * {@link READBACK_FRAME_INTERVAL} frames via `copyTextureToBuffer` + `mapAsync`. + * That is a deliberate trade: a synchronous read would stall the pipeline every + * frame, and a probe that is a few tens of milliseconds behind is not something a + * student can perceive. Brush strokes are applied to the mirror immediately as + * well as to the texture, so painting still feels instant. + */ + +import { + AMBIENT_TEMPERATURE_K, + FLUX_ARROW_COUNT, + MAX_ARROW_LENGTH_FRACTION, + PARTICLE_COUNT, + PARTICLE_LIFETIME_S, + READBACK_FRAME_INTERVAL, +} from "../../../HeatTransferConstants.js"; +import { FieldBackend, type FieldBackendId, type FieldEngineOptions, type FieldRenderStyle } from "../FieldEngine.js"; +import { FieldEngineBase } from "../FieldEngineBase.js"; +import { + BOUNDARY_CONDITION_ORDER, + type BrushStroke, + type LayerVisibility, + type TransportParameters, +} from "../FieldTypes.js"; +import { gradientAt } from "../kernels.js"; +import { MAX_PRESET_CONDUCTIVITY, MIN_PRESET_CONDUCTIVITY } from "../Materials.js"; +import type { SimulationDomain } from "../SimulationDomain.js"; +import { SIM_PARAMS_BYTES, WORKGROUP_SIZE } from "./shaders/common.js"; +import { + ADVECT_SHADER, + BRUSH_PARAMS_BYTES, + BRUSH_SHADER, + DIFFUSE_SHADER, + PARTICLE_COMPUTE_SHADER, + PARTICLE_PARAMS_BYTES, +} from "./shaders/compute.js"; +import { + ARROW_PARAMS_BYTES, + ARROW_RENDER_SHADER, + ARROW_VERTEX_COUNT, + FIELD_RENDER_SHADER, + LAYER_BIT, + PARTICLE_RENDER_PARAMS_BYTES, + PARTICLE_RENDER_SHADER, + PARTICLE_VERTEX_COUNT, + RENDER_PARAMS_BYTES, +} from "./shaders/render.js"; +import { requestCanvasContext, type WebGpuContext } from "./WebGpuSupport.js"; + +/** Index into the ping-pong pair. Typed as a literal union so tuple reads are exact. */ +type PingPongIndex = 0 | 1; + +/** Floats per particle in the storage buffer: (u, v, life, seed). */ +const PARTICLE_STRIDE = 4; + +/** Radius of a tracer particle in clip-space units. */ +const PARTICLE_CLIP_SIZE = 0.006; + +export class WebGpuFieldEngine extends FieldEngineBase { + public readonly backend: FieldBackendId = FieldBackend.WEBGPU; + + private readonly device: GPUDevice; + private readonly context: GPUCanvasContext; + + // ── Resources ─────────────────────────────────────────────────────────────── + + private readonly temperatureTextures: [GPUTexture, GPUTexture]; + private readonly temperatureViews: [GPUTextureView, GPUTextureView]; + private readonly velocityTexture: GPUTexture; + private readonly materialTexture: GPUTexture; + private readonly particleBuffer: GPUBuffer; + private readonly readbackBuffer: GPUBuffer; + + private readonly simParamsBuffer: GPUBuffer; + private readonly brushParamsBuffer: GPUBuffer; + private readonly particleParamsBuffer: GPUBuffer; + private readonly renderParamsBuffer: GPUBuffer; + private readonly arrowParamsBuffer: GPUBuffer; + private readonly particleRenderParamsBuffer: GPUBuffer; + + // ── Pipelines ─────────────────────────────────────────────────────────────── + + private readonly advectPipeline: GPUComputePipeline; + private readonly diffusePipeline: GPUComputePipeline; + private readonly brushPipeline: GPUComputePipeline; + private readonly particlePipeline: GPUComputePipeline; + private readonly fieldPipeline: GPURenderPipeline; + private readonly arrowPipeline: GPURenderPipeline; + private readonly particleRenderPipeline: GPURenderPipeline; + + // ── Bind groups (index 0 reads texture A, index 1 reads texture B) ────────── + + private readonly advectBindGroups: [GPUBindGroup, GPUBindGroup]; + private readonly diffuseBindGroups: [GPUBindGroup, GPUBindGroup]; + private readonly brushBindGroups: [GPUBindGroup, GPUBindGroup]; + private readonly particleBindGroup: GPUBindGroup; + private readonly fieldBindGroups: [GPUBindGroup, GPUBindGroup]; + private readonly arrowBindGroups: [GPUBindGroup, GPUBindGroup]; + private readonly particleRenderBindGroup: GPUBindGroup; + + /** Which of the ping-pong textures currently holds the field. */ + private current: PingPongIndex = 0; + + private frameCounter = 0; + private readbackInFlight = false; + private disposed = false; + + /** Scratch typed arrays for uniform writes, so stepping allocates nothing. */ + private readonly simParamsData = new ArrayBuffer(SIM_PARAMS_BYTES); + private readonly simParamsView = new DataView(this.simParamsData); + private readonly brushParamsData = new Float32Array(BRUSH_PARAMS_BYTES / 4); + private readonly particleParamsData = new ArrayBuffer(PARTICLE_PARAMS_BYTES); + private readonly particleParamsView = new DataView(this.particleParamsData); + private readonly renderParamsData = new ArrayBuffer(RENDER_PARAMS_BYTES); + private readonly renderParamsView = new DataView(this.renderParamsData); + private readonly arrowParamsData = new ArrayBuffer(ARROW_PARAMS_BYTES); + private readonly arrowParamsView = new DataView(this.arrowParamsData); + private readonly particleRenderParamsData = new Float32Array(PARTICLE_RENDER_PARAMS_BYTES / 4); + + public constructor(domain: SimulationDomain, options: FieldEngineOptions, gpu: WebGpuContext) { + super(domain, options); + + this.device = gpu.device; + const context = requestCanvasContext(this.canvas); + if (!context) { + throw new Error("WebGpuFieldEngine: canvas did not provide a webgpu context"); + } + this.context = context; + this.context.configure({ + device: gpu.device, + format: gpu.presentationFormat, + alphaMode: "premultiplied", + }); + + const { device } = this; + const { gridWidth, gridHeight } = domain; + const size = { width: gridWidth, height: gridHeight }; + + // ── Textures ────────────────────────────────────────────────────────────── + + const makeTemperature = (label: string): GPUTexture => + device.createTexture({ + label, + size, + format: "r32float", + usage: + GPUTextureUsage.STORAGE_BINDING | + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_SRC | + GPUTextureUsage.COPY_DST, + }); + + this.temperatureTextures = [makeTemperature("temperatureA"), makeTemperature("temperatureB")]; + this.temperatureViews = [this.temperatureTextures[0].createView(), this.temperatureTextures[1].createView()]; + + this.velocityTexture = device.createTexture({ + label: "velocity", + size, + format: "rg32float", + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + + this.materialTexture = device.createTexture({ + label: "material", + size, + format: "rgba32float", + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + + // ── Buffers ─────────────────────────────────────────────────────────────── + + this.particleBuffer = device.createBuffer({ + label: "particles", + size: PARTICLE_COUNT * PARTICLE_STRIDE * 4, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + }); + + this.readbackBuffer = device.createBuffer({ + label: "temperatureReadback", + size: gridWidth * gridHeight * 4, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + + const uniform = (label: string, bytes: number): GPUBuffer => + device.createBuffer({ label, size: bytes, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + + this.simParamsBuffer = uniform("simParams", SIM_PARAMS_BYTES); + this.brushParamsBuffer = uniform("brushParams", BRUSH_PARAMS_BYTES); + this.particleParamsBuffer = uniform("particleParams", PARTICLE_PARAMS_BYTES); + this.renderParamsBuffer = uniform("renderParams", RENDER_PARAMS_BYTES); + this.arrowParamsBuffer = uniform("arrowParams", ARROW_PARAMS_BYTES); + this.particleRenderParamsBuffer = uniform("particleRenderParams", PARTICLE_RENDER_PARAMS_BYTES); + + // ── Bind group layouts ──────────────────────────────────────────────────── + // + // Explicit rather than "auto": every field texture is a 32-bit float format, + // which can only be bound as `unfilterable-float`. An inferred layout would + // ask for a filterable float and fail validation on hardware that does not + // advertise the optional `float32-filterable` feature — that is, most of it. + + const computeTransportLayout = device.createBindGroupLayout({ + label: "transport", + entries: [ + { + binding: 0, + visibility: GPUShaderStage.COMPUTE, + texture: { sampleType: "unfilterable-float" }, + }, + { + binding: 1, + visibility: GPUShaderStage.COMPUTE, + storageTexture: { access: "write-only", format: "r32float" }, + }, + { + binding: 2, + visibility: GPUShaderStage.COMPUTE, + texture: { sampleType: "unfilterable-float" }, + }, + { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } }, + ], + }); + + const brushLayout = device.createBindGroupLayout({ + label: "brush", + entries: [ + { binding: 0, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: "unfilterable-float" } }, + { + binding: 1, + visibility: GPUShaderStage.COMPUTE, + storageTexture: { access: "write-only", format: "r32float" }, + }, + { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } }, + ], + }); + + const particleLayout = device.createBindGroupLayout({ + label: "particleCompute", + entries: [ + { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } }, + { binding: 1, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: "unfilterable-float" } }, + { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } }, + ], + }); + + const fieldLayout = device.createBindGroupLayout({ + label: "fieldRender", + entries: [ + { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } }, + { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } }, + { binding: 2, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, + ], + }); + + const arrowLayout = device.createBindGroupLayout({ + label: "arrowRender", + entries: [ + { binding: 0, visibility: GPUShaderStage.VERTEX, texture: { sampleType: "unfilterable-float" } }, + { binding: 1, visibility: GPUShaderStage.VERTEX, texture: { sampleType: "unfilterable-float" } }, + { + binding: 2, + visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + buffer: { type: "uniform" }, + }, + ], + }); + + const particleRenderLayout = device.createBindGroupLayout({ + label: "particleRender", + entries: [ + { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, + { + binding: 1, + visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + buffer: { type: "uniform" }, + }, + ], + }); + + // ── Pipelines ───────────────────────────────────────────────────────────── + + const computePipeline = (label: string, code: string, layout: GPUBindGroupLayout): GPUComputePipeline => + device.createComputePipeline({ + label, + layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }), + compute: { module: device.createShaderModule({ label, code }), entryPoint: "main" }, + }); + + this.advectPipeline = computePipeline("advect", ADVECT_SHADER, computeTransportLayout); + this.diffusePipeline = computePipeline("diffuse", DIFFUSE_SHADER, computeTransportLayout); + this.brushPipeline = computePipeline("brush", BRUSH_SHADER, brushLayout); + this.particlePipeline = computePipeline("particles", PARTICLE_COMPUTE_SHADER, particleLayout); + + const alphaBlend: GPUBlendState = { + color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" }, + alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" }, + }; + + const renderPipeline = ( + label: string, + code: string, + layout: GPUBindGroupLayout, + blend: GPUBlendState | undefined, + ): GPURenderPipeline => { + const module = device.createShaderModule({ label, code }); + return device.createRenderPipeline({ + label, + layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }), + vertex: { module, entryPoint: "vertexMain" }, + fragment: { + module, + entryPoint: "fragmentMain", + targets: [blend ? { format: gpu.presentationFormat, blend } : { format: gpu.presentationFormat }], + }, + primitive: { topology: "triangle-list" }, + }); + }; + + this.fieldPipeline = renderPipeline("fieldRender", FIELD_RENDER_SHADER, fieldLayout, undefined); + this.arrowPipeline = renderPipeline("arrowRender", ARROW_RENDER_SHADER, arrowLayout, alphaBlend); + this.particleRenderPipeline = renderPipeline( + "particleRender", + PARTICLE_RENDER_SHADER, + particleRenderLayout, + alphaBlend, + ); + + // ── Bind groups ─────────────────────────────────────────────────────────── + + const transportGroup = (read: PingPongIndex, auxiliary: GPUTexture): GPUBindGroup => + device.createBindGroup({ + layout: computeTransportLayout, + entries: [ + { binding: 0, resource: this.temperatureViews[read] }, + { binding: 1, resource: this.temperatureViews[read === 0 ? 1 : 0] }, + { binding: 2, resource: auxiliary.createView() }, + { binding: 3, resource: { buffer: this.simParamsBuffer } }, + ], + }); + + this.advectBindGroups = [transportGroup(0, this.velocityTexture), transportGroup(1, this.velocityTexture)]; + this.diffuseBindGroups = [transportGroup(0, this.materialTexture), transportGroup(1, this.materialTexture)]; + + const brushGroup = (read: PingPongIndex): GPUBindGroup => + device.createBindGroup({ + layout: brushLayout, + entries: [ + { binding: 0, resource: this.temperatureViews[read] }, + { binding: 1, resource: this.temperatureViews[read === 0 ? 1 : 0] }, + { binding: 2, resource: { buffer: this.brushParamsBuffer } }, + ], + }); + this.brushBindGroups = [brushGroup(0), brushGroup(1)]; + + this.particleBindGroup = device.createBindGroup({ + layout: particleLayout, + entries: [ + { binding: 0, resource: { buffer: this.particleBuffer } }, + { binding: 1, resource: this.velocityTexture.createView() }, + { binding: 2, resource: { buffer: this.particleParamsBuffer } }, + ], + }); + + const readGroup = (layout: GPUBindGroupLayout, read: PingPongIndex, buffer: GPUBuffer): GPUBindGroup => + device.createBindGroup({ + layout, + entries: [ + { binding: 0, resource: this.temperatureViews[read] }, + { binding: 1, resource: this.materialTexture.createView() }, + { binding: 2, resource: { buffer } }, + ], + }); + + this.fieldBindGroups = [ + readGroup(fieldLayout, 0, this.renderParamsBuffer), + readGroup(fieldLayout, 1, this.renderParamsBuffer), + ]; + this.arrowBindGroups = [ + readGroup(arrowLayout, 0, this.arrowParamsBuffer), + readGroup(arrowLayout, 1, this.arrowParamsBuffer), + ]; + + this.particleRenderBindGroup = device.createBindGroup({ + layout: particleRenderLayout, + entries: [ + { binding: 0, resource: { buffer: this.particleBuffer } }, + { binding: 1, resource: { buffer: this.particleRenderParamsBuffer } }, + ], + }); + + // ── Initial upload ──────────────────────────────────────────────────────── + + this.uploadMaterial(); + this.uploadVelocity(); + this.uploadTemperature(); + this.uploadParticles(); + } + + // ── Uploads ───────────────────────────────────────────────────────────────── + + private uploadTemperature(): void { + const { gridWidth, gridHeight } = this.domain; + this.device.queue.writeTexture( + { texture: this.temperatureTextures[this.current] }, + this.temperatureMirror, + { bytesPerRow: gridWidth * 4, rowsPerImage: gridHeight }, + { width: gridWidth, height: gridHeight }, + ); + } + + private uploadVelocity(): void { + const { gridWidth, gridHeight } = this.domain; + this.device.queue.writeTexture( + { texture: this.velocityTexture }, + this.velocity, + { bytesPerRow: gridWidth * 8, rowsPerImage: gridHeight }, + { width: gridWidth, height: gridHeight }, + ); + } + + private uploadMaterial(): void { + const { gridWidth, gridHeight, cellCount } = this.domain; + const packed = new Float32Array(cellCount * 4); + for (let index = 0; index < cellCount; index++) { + packed[4 * index] = this.material.conductivityX[index] ?? 0; + packed[4 * index + 1] = this.material.conductivityY[index] ?? 0; + packed[4 * index + 2] = this.material.volumetricHeatCapacity[index] ?? 1; + packed[4 * index + 3] = 0; + } + this.device.queue.writeTexture( + { texture: this.materialTexture }, + packed, + { bytesPerRow: gridWidth * 16, rowsPerImage: gridHeight }, + { width: gridWidth, height: gridHeight }, + ); + } + + private uploadParticles(): void { + const data = new Float32Array(PARTICLE_COUNT * PARTICLE_STRIDE); + for (let n = 0; n < PARTICLE_COUNT; n++) { + data[PARTICLE_STRIDE * n] = Math.random(); + data[PARTICLE_STRIDE * n + 1] = Math.random(); + data[PARTICLE_STRIDE * n + 2] = Math.random() * PARTICLE_LIFETIME_S; + data[PARTICLE_STRIDE * n + 3] = Math.random(); + } + this.device.queue.writeBuffer(this.particleBuffer, 0, data); + } + + // ── Backend hooks ─────────────────────────────────────────────────────────── + + protected onMaterialChanged(): void { + this.uploadMaterial(); + } + + protected onVelocityChanged(): void { + this.uploadVelocity(); + } + + protected onTemperaturePainted(stroke: BrushStroke): void { + // The mirror already has the stroke (the base class applied it); run the same + // arithmetic on the texture so the GPU state matches without a full upload. + const radiusCells = stroke.radius * Math.min(this.domain.gridWidth, this.domain.gridHeight); + this.brushParamsData[0] = stroke.u * this.domain.gridWidth; + this.brushParamsData[1] = stroke.v * this.domain.gridHeight; + this.brushParamsData[2] = radiusCells; + this.brushParamsData[3] = stroke.temperature; + this.brushParamsData[4] = stroke.strength; + this.device.queue.writeBuffer(this.brushParamsBuffer, 0, this.brushParamsData); + + const encoder = this.device.createCommandEncoder({ label: "brush" }); + const pass = encoder.beginComputePass(); + pass.setPipeline(this.brushPipeline); + pass.setBindGroup(0, this.brushBindGroups[this.current]); + pass.dispatchWorkgroups(this.workgroupsX(), this.workgroupsY()); + pass.end(); + this.device.queue.submit([encoder.finish()]); + this.current = this.current === 0 ? 1 : 0; + } + + protected onTemperatureReseeded(): void { + this.uploadTemperature(); + this.uploadParticles(); + } + + private workgroupsX(): number { + return Math.ceil(this.domain.gridWidth / WORKGROUP_SIZE); + } + + private workgroupsY(): number { + return Math.ceil(this.domain.gridHeight / WORKGROUP_SIZE); + } + + // ── Stepping ──────────────────────────────────────────────────────────────── + + public step(parameters: TransportParameters): number { + if (this.disposed) { + return 0; + } + + this.boundary = parameters.boundaryCondition; + const substep = this.computeSubstep(parameters.flowScale, parameters.diffusionScale, parameters.diffusionEnabled); + this.currentSubstep = substep; + + const doAdvection = parameters.advectionEnabled && parameters.flowScale > 0 && this.maxSpeed > 0; + const doDiffusion = parameters.diffusionEnabled && parameters.diffusionScale > 0; + if (!(doAdvection || doDiffusion)) { + return 0; + } + + this.writeSimParams(substep, parameters); + + const encoder = this.device.createCommandEncoder({ label: "step" }); + const pass = encoder.beginComputePass({ label: "transport" }); + const groupsX = this.workgroupsX(); + const groupsY = this.workgroupsY(); + + for (let n = 0; n < parameters.substeps; n++) { + if (doAdvection) { + pass.setPipeline(this.advectPipeline); + pass.setBindGroup(0, this.advectBindGroups[this.current]); + pass.dispatchWorkgroups(groupsX, groupsY); + this.current = this.current === 0 ? 1 : 0; + } + if (doDiffusion) { + pass.setPipeline(this.diffusePipeline); + pass.setBindGroup(0, this.diffuseBindGroups[this.current]); + pass.dispatchWorkgroups(groupsX, groupsY); + this.current = this.current === 0 ? 1 : 0; + } + } + pass.end(); + + const advanced = substep * parameters.substeps; + + if (doAdvection) { + this.writeParticleParams(advanced, parameters.flowScale); + const particlePass = encoder.beginComputePass({ label: "particles" }); + particlePass.setPipeline(this.particlePipeline); + particlePass.setBindGroup(0, this.particleBindGroup); + particlePass.dispatchWorkgroups(Math.ceil(PARTICLE_COUNT / 64)); + particlePass.end(); + } + + this.device.queue.submit([encoder.finish()]); + + this.elapsedTime += advanced; + this.frameCounter++; + if (this.frameCounter % READBACK_FRAME_INTERVAL === 0) { + this.requestReadback(); + } + + return advanced; + } + + private writeSimParams(substep: number, parameters: TransportParameters): void { + const view = this.simParamsView; + view.setUint32(0, this.domain.gridWidth, true); + view.setUint32(4, this.domain.gridHeight, true); + view.setFloat32(8, this.domain.dx, true); + view.setFloat32(12, this.domain.dy, true); + view.setFloat32(16, substep, true); + view.setFloat32(20, parameters.flowScale, true); + view.setFloat32(24, parameters.diffusionScale, true); + view.setUint32(28, BOUNDARY_CONDITION_ORDER.indexOf(parameters.boundaryCondition), true); + view.setFloat32(32, AMBIENT_TEMPERATURE_K, true); + this.device.queue.writeBuffer(this.simParamsBuffer, 0, this.simParamsData); + } + + private writeParticleParams(dt: number, flowScale: number): void { + const view = this.particleParamsView; + view.setUint32(0, this.domain.gridWidth, true); + view.setUint32(4, this.domain.gridHeight, true); + view.setFloat32(8, dt, true); + view.setFloat32(12, flowScale, true); + view.setFloat32(16, this.domain.physicalWidth, true); + view.setFloat32(20, this.domain.physicalHeight, true); + view.setFloat32(24, PARTICLE_LIFETIME_S, true); + view.setUint32(28, PARTICLE_COUNT, true); + this.device.queue.writeBuffer(this.particleParamsBuffer, 0, this.particleParamsData); + } + + // ── Readback ──────────────────────────────────────────────────────────────── + + /** + * Copies the current temperature texture into the mirror, asynchronously. + * + * At most one readback is in flight at a time; if the previous one has not + * resolved, this frame simply skips it. The mirror going a few frames stale is + * invisible in the UI, whereas blocking on `mapAsync` would not be. + */ + private requestReadback(): void { + if (this.readbackInFlight || this.disposed) { + return; + } + this.readbackInFlight = true; + + const { gridWidth, gridHeight } = this.domain; + const encoder = this.device.createCommandEncoder({ label: "readback" }); + encoder.copyTextureToBuffer( + { texture: this.temperatureTextures[this.current] }, + { buffer: this.readbackBuffer, bytesPerRow: gridWidth * 4, rowsPerImage: gridHeight }, + { width: gridWidth, height: gridHeight }, + ); + this.device.queue.submit([encoder.finish()]); + + this.readbackBuffer + .mapAsync(GPUMapMode.READ) + .then(() => { + if (this.disposed) { + return; + } + this.temperatureMirror.set(new Float32Array(this.readbackBuffer.getMappedRange())); + this.readbackBuffer.unmap(); + }) + .catch(() => { + // The device was lost or the buffer was destroyed mid-flight; the mirror + // simply keeps its previous contents. + }) + .finally(() => { + this.readbackInFlight = false; + }); + } + + // ── Rendering ─────────────────────────────────────────────────────────────── + + public render(layers: LayerVisibility, style: FieldRenderStyle): void { + if (this.disposed) { + return; + } + + this.writeRenderParams(layers, style); + + const encoder = this.device.createCommandEncoder({ label: "render" }); + const view = this.context.getCurrentTexture().createView(); + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view, + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + loadOp: "clear", + storeOp: "store", + }, + ], + }); + + pass.setPipeline(this.fieldPipeline); + pass.setBindGroup(0, this.fieldBindGroups[this.current]); + pass.draw(3); + + if (layers.heatFlux) { + this.writeArrowParams(style); + pass.setPipeline(this.arrowPipeline); + pass.setBindGroup(0, this.arrowBindGroups[this.current]); + pass.draw(ARROW_VERTEX_COUNT, FLUX_ARROW_COUNT * FLUX_ARROW_COUNT); + } + + if (layers.velocity) { + this.writeParticleRenderParams(style); + pass.setPipeline(this.particleRenderPipeline); + pass.setBindGroup(0, this.particleRenderBindGroup); + pass.draw(PARTICLE_VERTEX_COUNT, PARTICLE_COUNT); + } + + pass.end(); + this.device.queue.submit([encoder.finish()]); + } + + private writeRenderParams(layers: LayerVisibility, style: FieldRenderStyle): void { + let flags = 0; + if (layers.temperature) { + flags |= LAYER_BIT.TEMPERATURE; + } + if (layers.isotherms) { + flags |= LAYER_BIT.ISOTHERMS; + } + if (layers.gradient) { + flags |= LAYER_BIT.GRADIENT; + } + if (layers.material) { + flags |= LAYER_BIT.MATERIAL; + } + + const view = this.renderParamsView; + view.setUint32(0, this.domain.gridWidth, true); + view.setUint32(4, this.domain.gridHeight, true); + view.setFloat32(8, style.minTemperature, true); + view.setFloat32(12, style.maxTemperature, true); + view.setFloat32(16, style.isothermInterval, true); + view.setUint32(20, flags, true); + view.setFloat32(24, Math.log(MIN_PRESET_CONDUCTIVITY), true); + view.setFloat32(28, Math.log(MAX_PRESET_CONDUCTIVITY) - Math.log(MIN_PRESET_CONDUCTIVITY), true); + view.setFloat32(32, style.isotherm.red, true); + view.setFloat32(36, style.isotherm.green, true); + view.setFloat32(40, style.isotherm.blue, true); + view.setFloat32(44, 0.85, true); + view.setFloat32(48, layers.gradient ? this.peakGradientFromMirror() : 0, true); + view.setUint32(52, BOUNDARY_CONDITION_ORDER.indexOf(this.boundary), true); + view.setFloat32(56, AMBIENT_TEMPERATURE_K, true); + view.setFloat32(60, this.domain.dx, true); + this.device.queue.writeBuffer(this.renderParamsBuffer, 0, this.renderParamsData); + } + + private writeArrowParams(style: FieldRenderStyle): void { + const view = this.arrowParamsView; + view.setUint32(0, this.domain.gridWidth, true); + view.setUint32(4, this.domain.gridHeight, true); + view.setUint32(8, FLUX_ARROW_COUNT, true); + // Clip space spans 2 units across the canvas, so a fraction of the canvas + // width is twice that fraction in clip units. + view.setFloat32(12, MAX_ARROW_LENGTH_FRACTION * 2, true); + view.setFloat32(16, style.arrow.red, true); + view.setFloat32(20, style.arrow.green, true); + view.setFloat32(24, style.arrow.blue, true); + view.setFloat32(28, 1, true); + view.setFloat32(32, this.peakFluxFromMirror(), true); + view.setUint32(36, BOUNDARY_CONDITION_ORDER.indexOf(this.boundary), true); + view.setFloat32(40, AMBIENT_TEMPERATURE_K, true); + view.setFloat32(44, this.domain.dx, true); + this.device.queue.writeBuffer(this.arrowParamsBuffer, 0, this.arrowParamsData); + } + + private writeParticleRenderParams(style: FieldRenderStyle): void { + this.particleRenderParamsData[0] = style.particle.red; + this.particleRenderParamsData[1] = style.particle.green; + this.particleRenderParamsData[2] = style.particle.blue; + this.particleRenderParamsData[3] = 0.9; + this.particleRenderParamsData[4] = PARTICLE_CLIP_SIZE; + this.particleRenderParamsData[5] = PARTICLE_LIFETIME_S; + this.device.queue.writeBuffer(this.particleRenderParamsBuffer, 0, this.particleRenderParamsData); + } + + /** + * The largest |q| on the arrow lattice, taken from the CPU mirror. + * + * A GPU reduction would be more current, but it would also add a readback + * dependency to the render path for a value that only sets the arrow scale. + * 400 samples off a slightly stale mirror is the cheaper, steadier answer — and + * it is exactly what the CPU renderer computes, so the two look alike. + */ + private peakFluxFromMirror(): number { + const { gridWidth, gridHeight } = this.domain; + let peak = 0; + for (let row = 0; row < FLUX_ARROW_COUNT; row++) { + for (let column = 0; column < FLUX_ARROW_COUNT; column++) { + const i = Math.min(gridWidth - 1, Math.floor(((column + 0.5) / FLUX_ARROW_COUNT) * gridWidth)); + const j = Math.min(gridHeight - 1, Math.floor(((row + 0.5) / FLUX_ARROW_COUNT) * gridHeight)); + const { gx, gy } = gradientAt( + this.temperatureMirror, + this.geometry, + this.boundary, + i, + j, + AMBIENT_TEMPERATURE_K, + ); + const index = j * gridWidth + i; + const qx = -(this.material.conductivityX[index] ?? 0) * gx; + const qy = -(this.material.conductivityY[index] ?? 0) * gy; + const magnitude = Math.hypot(qx, qy); + if (magnitude > peak) { + peak = magnitude; + } + } + } + return peak; + } + + /** The largest |grad T| on a coarse sample of the mirror, for the gradient layer. */ + private peakGradientFromMirror(): number { + const { gridWidth, gridHeight } = this.domain; + const stride = Math.max(1, Math.floor(gridWidth / 128)); + let peak = 0; + for (let j = 0; j < gridHeight; j += stride) { + for (let i = 0; i < gridWidth; i += stride) { + const { gx, gy } = gradientAt( + this.temperatureMirror, + this.geometry, + this.boundary, + i, + j, + AMBIENT_TEMPERATURE_K, + ); + const magnitude = Math.hypot(gx, gy); + if (magnitude > peak) { + peak = magnitude; + } + } + } + return peak; + } + + public dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + + for (const texture of this.temperatureTextures) { + texture.destroy(); + } + this.velocityTexture.destroy(); + this.materialTexture.destroy(); + this.particleBuffer.destroy(); + this.readbackBuffer.destroy(); + for (const buffer of [ + this.simParamsBuffer, + this.brushParamsBuffer, + this.particleParamsBuffer, + this.renderParamsBuffer, + this.arrowParamsBuffer, + this.particleRenderParamsBuffer, + ]) { + buffer.destroy(); + } + this.context.unconfigure(); + } +} diff --git a/src/common/field/gpu/WebGpuSupport.ts b/src/common/field/gpu/WebGpuSupport.ts new file mode 100644 index 0000000..861d177 --- /dev/null +++ b/src/common/field/gpu/WebGpuSupport.ts @@ -0,0 +1,82 @@ +/** + * WebGpuSupport.ts + * + * Device acquisition, kept separate from the engine so that "can we run on the + * GPU?" is a question with one answer in one place. + * + * Nothing here throws: every failure mode — no `navigator.gpu`, no adapter, a + * device request that rejects, a device that is lost a second later — resolves to + * `null` so the caller can fall back to the CPU backend without a try/catch + * around half the application. + */ + +export type WebGpuContext = { + adapter: GPUAdapter; + device: GPUDevice; + /** The format the canvas should be configured with. */ + presentationFormat: GPUTextureFormat; + /** Largest square grid this device's limits allow. */ + maxGridSize: number; +}; + +/** + * `canvas.getContext("webgpu")`, typed. + * + * TypeScript 7's DOM lib has every WebGPU interface but no `"webgpu"` overload on + * `getContext`, and augmenting `HTMLCanvasElement` to add one would reorder + * overload resolution for every other caller. One cast, in one place, is the + * smaller cost. + */ +export function requestCanvasContext(canvas: HTMLCanvasElement): GPUCanvasContext | null { + return (canvas.getContext("webgpu") as unknown as GPUCanvasContext | null) ?? null; +} + +/** Whether this browser exposes the WebGPU entry point at all. */ +export function isWebGpuAvailable(): boolean { + return typeof navigator !== "undefined" && "gpu" in navigator && Boolean(navigator.gpu); +} + +/** + * Requests an adapter and device. + * + * @param onDeviceLost - called if the device is lost after a successful start, so + * the simulation can rebuild on the CPU backend rather than freezing. + */ +export async function requestWebGpuContext(onDeviceLost?: (reason: string) => void): Promise<WebGpuContext | null> { + if (!isWebGpuAvailable()) { + return null; + } + + try { + const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" }); + if (!adapter) { + return null; + } + + const device = await adapter.requestDevice({ + requiredLimits: { + maxTextureDimension2D: adapter.limits.maxTextureDimension2D, + maxStorageTexturesPerShaderStage: Math.min(4, adapter.limits.maxStorageTexturesPerShaderStage), + }, + }); + + if (onDeviceLost) { + device.lost.then((info) => { + // A destroyed device is an ordinary part of teardown, not a failure. + if (info.reason !== "destroyed") { + onDeviceLost(info.message || info.reason); + } + }); + } + + return { + adapter, + device, + presentationFormat: navigator.gpu.getPreferredCanvasFormat(), + maxGridSize: device.limits.maxTextureDimension2D, + }; + } catch { + // Adapter or device request failed (blocklisted driver, headless, …). + return null; + } +} diff --git a/src/common/field/gpu/shaders/common.ts b/src/common/field/gpu/shaders/common.ts new file mode 100644 index 0000000..57e13b4 --- /dev/null +++ b/src/common/field/gpu/shaders/common.ts @@ -0,0 +1,165 @@ +/** + * common.ts + * + * WGSL fragments shared by every compute and render pass. + * + * Everything here mirrors a function in `kernels.ts` one-for-one — `fetchScalar` + * is `fetchCell`, `bilinearScalar` is `bilinearSample`, `faceConductivity` is the + * harmonic mean. Keeping the names aligned is deliberate: when the physics + * changes, the two implementations have to change in the same places. + * + * All textures are float32 formats and are bound as `unfilterable-float`, so + * every fetch is a `textureLoad` and interpolation is done by hand. That avoids + * depending on the optional `float32-filterable` feature, which would make the + * simulation refuse to start on otherwise perfectly capable hardware. + */ + +/** Boundary condition ids, matching the order of `BOUNDARY_CONDITION_ORDER`. */ +export const WGSL_BOUNDARY_CONSTANTS = ` +const BOUNDARY_INSULATED: u32 = 0u; +const BOUNDARY_FIXED: u32 = 1u; +const BOUNDARY_PERIODIC: u32 = 2u; +`; + +/** The uniform block every compute pass reads. Must match `SIM_PARAMS_BYTES`. */ +export const WGSL_SIM_PARAMS = ` +struct SimParams { + gridSize: vec2<u32>, + cellSize: vec2<f32>, + dt: f32, + flowScale: f32, + diffusionScale: f32, + boundary: u32, + ambient: f32, + pad0: f32, + pad1: f32, + pad2: f32, +}; +`; + +/** Size of the `SimParams` uniform block, in bytes. */ +export const SIM_PARAMS_BYTES = 48; + +/** + * Boundary-aware index resolution. + * + * Returns the coordinate to sample, plus a flag saying "this is outside and the + * boundary condition wants the ambient value instead". Three boundary conditions + * with no ghost cells anywhere. + */ +export const WGSL_RESOLVE = ` +struct Resolved { + coord: vec2<i32>, + outside: bool, +}; + +fn resolveCoord(coord: vec2<i32>, size: vec2<i32>, boundary: u32) -> Resolved { + var out: Resolved; + out.coord = coord; + out.outside = false; + + if (coord.x < 0 || coord.x >= size.x || coord.y < 0 || coord.y >= size.y) { + if (boundary == BOUNDARY_FIXED) { + out.outside = true; + } else if (boundary == BOUNDARY_PERIODIC) { + out.coord = vec2<i32>( + ((coord.x % size.x) + size.x) % size.x, + ((coord.y % size.y) + size.y) % size.y, + ); + } else { + out.coord = clamp(coord, vec2<i32>(0, 0), size - vec2<i32>(1, 1)); + } + } + return out; +} +`; + +/** Scalar fetch and bilinear interpolation against a boundary condition. */ +export const WGSL_SAMPLE_SCALAR = ` +fn fetchScalar( + tex: texture_2d<f32>, + coord: vec2<i32>, + size: vec2<i32>, + boundary: u32, + ambient: f32, +) -> f32 { + let resolved = resolveCoord(coord, size, boundary); + if (resolved.outside) { + return ambient; + } + return textureLoad(tex, resolved.coord, 0).r; +} + +fn bilinearScalar( + tex: texture_2d<f32>, + gridPosition: vec2<f32>, + size: vec2<i32>, + boundary: u32, + ambient: f32, +) -> f32 { + let shifted = gridPosition - vec2<f32>(0.5, 0.5); + let base = floor(shifted); + let frac = shifted - base; + let corner = vec2<i32>(base); + + let c00 = fetchScalar(tex, corner, size, boundary, ambient); + let c10 = fetchScalar(tex, corner + vec2<i32>(1, 0), size, boundary, ambient); + let c01 = fetchScalar(tex, corner + vec2<i32>(0, 1), size, boundary, ambient); + let c11 = fetchScalar(tex, corner + vec2<i32>(1, 1), size, boundary, ambient); + + return mix(mix(c00, c10, frac.x), mix(c01, c11, frac.x), frac.y); +} +`; + +/** + * Material lookup. The material field always extends outward by clamping, + * independently of the temperature boundary condition — a plate does not stop + * being copper because the temperature wraps. + * + * Channels are (k_x, k_y, rho c_p, unused). + */ +export const WGSL_SAMPLE_MATERIAL = ` +fn fetchMaterial(tex: texture_2d<f32>, coord: vec2<i32>, size: vec2<i32>) -> vec4<f32> { + let clamped = clamp(coord, vec2<i32>(0, 0), size - vec2<i32>(1, 1)); + return textureLoad(tex, clamped, 0); +} + +fn faceConductivity(a: f32, b: f32) -> f32 { + let total = a + b; + if (total > 0.0) { + return 2.0 * a * b / total; + } + return 0.0; +} +`; + +/** Central-difference gradient of the temperature field, in K/m. */ +export const WGSL_GRADIENT = ` +fn temperatureGradient( + tex: texture_2d<f32>, + coord: vec2<i32>, + size: vec2<i32>, + cellSize: vec2<f32>, + boundary: u32, + ambient: f32, +) -> vec2<f32> { + let east = fetchScalar(tex, coord + vec2<i32>(1, 0), size, boundary, ambient); + let west = fetchScalar(tex, coord - vec2<i32>(1, 0), size, boundary, ambient); + let south = fetchScalar(tex, coord + vec2<i32>(0, 1), size, boundary, ambient); + let north = fetchScalar(tex, coord - vec2<i32>(0, 1), size, boundary, ambient); + return vec2<f32>((east - west) / (2.0 * cellSize.x), (south - north) / (2.0 * cellSize.y)); +} +`; + +/** Everything a compute pass needs, concatenated. */ +export const WGSL_COMPUTE_PRELUDE = [ + WGSL_BOUNDARY_CONSTANTS, + WGSL_SIM_PARAMS, + WGSL_RESOLVE, + WGSL_SAMPLE_SCALAR, + WGSL_SAMPLE_MATERIAL, + WGSL_GRADIENT, +].join("\n"); + +/** Workgroup edge length used by every 2-D compute pass. */ +export const WORKGROUP_SIZE = 8; diff --git a/src/common/field/gpu/shaders/compute.ts b/src/common/field/gpu/shaders/compute.ts new file mode 100644 index 0000000..4c703e8 --- /dev/null +++ b/src/common/field/gpu/shaders/compute.ts @@ -0,0 +1,231 @@ +/** + * compute.ts + * + * The compute passes that evolve the fields. One timestep is + * + * temperature --advect--> temperature' --diffuse--> temperature'' + * + * with a texture swap after each pass, exactly the ping-pong the prescription + * calls for and exactly the operator splitting `CpuFieldEngine.step` performs. + * Brush strokes are a fourth pass over the same pair, so painting heat is a + * write into the GPU temperature texture rather than a CPU upload. + */ + +import { WGSL_COMPUTE_PRELUDE, WORKGROUP_SIZE } from "./common.js"; + +/** + * Semi-Lagrangian advection: each cell traces its parcel back along the velocity + * field and bilinearly samples the incoming temperature there. + */ +export const ADVECT_SHADER = ` +${WGSL_COMPUTE_PRELUDE} + +@group(0) @binding(0) var sourceTexture: texture_2d<f32>; +@group(0) @binding(1) var destinationTexture: texture_storage_2d<r32float, write>; +@group(0) @binding(2) var velocityTexture: texture_2d<f32>; +@group(0) @binding(3) var<uniform> params: SimParams; + +@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) +fn main(@builtin(global_invocation_id) id: vec3<u32>) { + let size = vec2<i32>(params.gridSize); + let coord = vec2<i32>(i32(id.x), i32(id.y)); + if (coord.x >= size.x || coord.y >= size.y) { + return; + } + + let velocity = textureLoad(velocityTexture, coord, 0).xy * params.flowScale; + let departure = vec2<f32>(f32(coord.x) + 0.5, f32(coord.y) + 0.5) + - velocity * params.dt / params.cellSize; + + let value = bilinearScalar(sourceTexture, departure, size, params.boundary, params.ambient); + textureStore(destinationTexture, coord, vec4<f32>(value, 0.0, 0.0, 1.0)); +} +`; + +/** + * Conservative explicit diffusion with harmonic-mean face conductivities. + * + * On an insulated boundary the outward face conductivity is forced to zero, so + * no flux crosses it and the total energy in the domain is exactly conserved to + * floating-point round-off. + */ +export const DIFFUSE_SHADER = ` +${WGSL_COMPUTE_PRELUDE} + +@group(0) @binding(0) var sourceTexture: texture_2d<f32>; +@group(0) @binding(1) var destinationTexture: texture_storage_2d<r32float, write>; +@group(0) @binding(2) var materialTexture: texture_2d<f32>; +@group(0) @binding(3) var<uniform> params: SimParams; + +@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) +fn main(@builtin(global_invocation_id) id: vec3<u32>) { + let size = vec2<i32>(params.gridSize); + let coord = vec2<i32>(i32(id.x), i32(id.y)); + if (coord.x >= size.x || coord.y >= size.y) { + return; + } + + let centre = textureLoad(sourceTexture, coord, 0).r; + let here = fetchMaterial(materialTexture, coord, size); + let scale = params.diffusionScale; + let kxHere = here.x * scale; + let kyHere = here.y * scale; + + let insulated = params.boundary == BOUNDARY_INSULATED; + let atLeft = coord.x == 0; + let atRight = coord.x == size.x - 1; + let atTop = coord.y == 0; + let atBottom = coord.y == size.y - 1; + + var kWest = 0.0; + if (!(insulated && atLeft)) { + kWest = faceConductivity(kxHere, fetchMaterial(materialTexture, coord - vec2<i32>(1, 0), size).x * scale); + } + var kEast = 0.0; + if (!(insulated && atRight)) { + kEast = faceConductivity(kxHere, fetchMaterial(materialTexture, coord + vec2<i32>(1, 0), size).x * scale); + } + var kNorth = 0.0; + if (!(insulated && atTop)) { + kNorth = faceConductivity(kyHere, fetchMaterial(materialTexture, coord - vec2<i32>(0, 1), size).y * scale); + } + var kSouth = 0.0; + if (!(insulated && atBottom)) { + kSouth = faceConductivity(kyHere, fetchMaterial(materialTexture, coord + vec2<i32>(0, 1), size).y * scale); + } + + let west = fetchScalar(sourceTexture, coord - vec2<i32>(1, 0), size, params.boundary, params.ambient); + let east = fetchScalar(sourceTexture, coord + vec2<i32>(1, 0), size, params.boundary, params.ambient); + let north = fetchScalar(sourceTexture, coord - vec2<i32>(0, 1), size, params.boundary, params.ambient); + let south = fetchScalar(sourceTexture, coord + vec2<i32>(0, 1), size, params.boundary, params.ambient); + + let inverseSquares = vec2<f32>(1.0, 1.0) / (params.cellSize * params.cellSize); + let divergence = + (kEast * (east - centre) + kWest * (west - centre)) * inverseSquares.x + + (kSouth * (south - centre) + kNorth * (north - centre)) * inverseSquares.y; + + let volumetricHeatCapacity = max(here.z, 1.0); + let updated = centre + (params.dt / volumetricHeatCapacity) * divergence; + textureStore(destinationTexture, coord, vec4<f32>(updated, 0.0, 0.0, 1.0)); +} +`; + +/** Size of the `BrushParams` uniform block, in bytes. */ +export const BRUSH_PARAMS_BYTES = 32; + +/** + * The heat brush, as a pass over the whole field: cells outside the disc are + * copied through, cells inside are pulled toward the brush temperature by a + * smooth falloff. Identical arithmetic to `FieldEngineBase.applyBrushToMirror`, + * so the mirror the probe reads stays in step with the texture until the next + * readback anyway. + */ +export const BRUSH_SHADER = ` +struct BrushParams { + centre: vec2<f32>, + radius: f32, + temperature: f32, + strength: f32, + pad0: f32, + pad1: f32, + pad2: f32, +}; + +@group(0) @binding(0) var sourceTexture: texture_2d<f32>; +@group(0) @binding(1) var destinationTexture: texture_storage_2d<r32float, write>; +@group(0) @binding(2) var<uniform> brush: BrushParams; + +@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) +fn main(@builtin(global_invocation_id) id: vec3<u32>) { + let size = textureDimensions(sourceTexture); + if (id.x >= size.x || id.y >= size.y) { + return; + } + let coord = vec2<i32>(i32(id.x), i32(id.y)); + let current = textureLoad(sourceTexture, coord, 0).r; + + let offset = vec2<f32>(f32(coord.x) + 0.5, f32(coord.y) + 0.5) - brush.centre; + let normalized = dot(offset, offset) / max(brush.radius * brush.radius, 1e-6); + + var updated = current; + if (normalized < 1.0) { + let falloff = 1.0 - normalized; + let weight = falloff * falloff * brush.strength; + updated = current + (brush.temperature - current) * weight; + } + + textureStore(destinationTexture, coord, vec4<f32>(updated, 0.0, 0.0, 1.0)); +} +`; + +/** Size of the `ParticleParams` uniform block, in bytes. */ +export const PARTICLE_PARAMS_BYTES = 32; + +/** + * Tracer-particle advection. Particles live in a storage buffer as + * (u, v, life, seed) and are respawned from a hash when they age out or leave + * the domain, so the tracer density stays even in a flow with stagnation points. + */ +export const PARTICLE_COMPUTE_SHADER = ` +struct Particle { + position: vec2<f32>, + life: f32, + seed: f32, +}; + +struct ParticleParams { + gridSize: vec2<u32>, + dt: f32, + flowScale: f32, + physicalSize: vec2<f32>, + lifetime: f32, + count: u32, +}; + +@group(0) @binding(0) var<storage, read_write> particles: array<Particle>; +@group(0) @binding(1) var velocityTexture: texture_2d<f32>; +@group(0) @binding(2) var<uniform> params: ParticleParams; + +/** PCG-style integer hash, then scaled into [0, 1). */ +fn hashToUnit(input: u32) -> f32 { + var state = input * 747796405u + 2891336453u; + var word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u; + word = (word >> 22u) ^ word; + return f32(word) / 4294967296.0; +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) id: vec3<u32>) { + let index = id.x; + if (index >= params.count) { + return; + } + + var particle = particles[index]; + let seedBits = bitcast<u32>(particle.seed) ^ (index * 2654435761u); + + particle.life = particle.life - params.dt; + + let cell = vec2<i32>( + clamp(i32(particle.position.x * f32(params.gridSize.x)), 0, i32(params.gridSize.x) - 1), + clamp(i32(particle.position.y * f32(params.gridSize.y)), 0, i32(params.gridSize.y) - 1), + ); + let velocity = textureLoad(velocityTexture, cell, 0).xy * params.flowScale; + // Wrap rather than respawn on leaving: a tracer that exits one side re-enters + // the other, so a uniform flow reads as a steady stream instead of a static + // sprinkle of freshly spawned dots. + let advanced = particle.position + velocity * params.dt / params.physicalSize; + let next = advanced - floor(advanced); + + if (particle.life <= 0.0) { + let respawnSeed = seedBits ^ bitcast<u32>(particle.life) ^ 0x9e3779b9u; + particle.position = vec2<f32>(hashToUnit(respawnSeed), hashToUnit(respawnSeed ^ 0x85ebca6bu)); + particle.life = params.lifetime; + particle.seed = f32(respawnSeed & 0xffffu) / 65536.0; + } else { + particle.position = next; + } + + particles[index] = particle; +} +`; diff --git a/src/common/field/gpu/shaders/render.ts b/src/common/field/gpu/shaders/render.ts new file mode 100644 index 0000000..0e401f4 --- /dev/null +++ b/src/common/field/gpu/shaders/render.ts @@ -0,0 +1,319 @@ +/** + * render.ts + * + * The visualization passes. Each one reads the same GPU state and draws one + * layer over it — none of them can change the simulation, which is the property + * that lets the Heat Transfer screen present five checkboxes as "views of one + * thing" rather than five different sims. + * + * field — colour map, isotherms, |grad T|, and material tint in a single + * full-screen fragment shader + * arrows — instanced heat-flux arrows, geometry built in the vertex shader + * particles — instanced tracer quads read straight from the particle buffer + */ + +import { colorMapWgsl } from "../../ColorMap.js"; +import { WGSL_BOUNDARY_CONSTANTS, WGSL_GRADIENT, WGSL_RESOLVE, WGSL_SAMPLE_SCALAR } from "./common.js"; + +/** Size of the `RenderParams` uniform block, in bytes. */ +export const RENDER_PARAMS_BYTES = 64; + +/** Layer bits packed into `RenderParams.layerFlags`. */ +export const LAYER_BIT = { + TEMPERATURE: 1, + ISOTHERMS: 2, + GRADIENT: 4, + MATERIAL: 8, +} as const; + +/** + * Full-screen pass. + * + * The vertex stage emits one oversized triangle rather than a quad — fewer + * vertices, no seam down the diagonal. `uv` runs (0,0) at the top-left to (1,1) + * at the bottom-right so it matches the texture and the unit-square coordinates + * the rest of the simulation speaks. + * + * Isotherms are drawn analytically instead of by tracing contours: `fwidth` gives + * the screen-space rate of change of T/interval, so dividing the distance to the + * nearest contour by it yields a line exactly one pixel wide at any zoom, on any + * grid resolution, for free. + */ +export const FIELD_RENDER_SHADER = ` +${WGSL_BOUNDARY_CONSTANTS} +${WGSL_RESOLVE} +${WGSL_SAMPLE_SCALAR} +${WGSL_GRADIENT} +${colorMapWgsl()} + +struct RenderParams { + gridSize: vec2<u32>, + temperatureRange: vec2<f32>, + isothermInterval: f32, + layerFlags: u32, + logMinConductivity: f32, + logConductivitySpan: f32, + isothermColor: vec4<f32>, + peakGradient: f32, + boundary: u32, + ambient: f32, + // Single scalar rather than a vec2: the domain is always square, and the + // gradient overlay only needs the magnitude to be on the right scale. + cellSize: f32, +}; + +const LAYER_TEMPERATURE: u32 = 1u; +const LAYER_ISOTHERMS: u32 = 2u; +const LAYER_GRADIENT: u32 = 4u; +const LAYER_MATERIAL: u32 = 8u; + +const GRADIENT_LIFT: f32 = 0.55; +const MATERIAL_TINT_ALPHA: f32 = 0.35; + +@group(0) @binding(0) var temperatureTexture: texture_2d<f32>; +@group(0) @binding(1) var materialTexture: texture_2d<f32>; +@group(0) @binding(2) var<uniform> params: RenderParams; + +struct VertexOutput { + @builtin(position) position: vec4<f32>, + @location(0) uv: vec2<f32>, +}; + +@vertex +fn vertexMain(@builtin(vertex_index) index: u32) -> VertexOutput { + // One triangle covering the clip rectangle: (-1,-1), (3,-1), (-1,3). + var clip = array<vec2<f32>, 3>( + vec2<f32>(-1.0, -1.0), + vec2<f32>(3.0, -1.0), + vec2<f32>(-1.0, 3.0), + ); + let point = clip[index]; + + var out: VertexOutput; + out.position = vec4<f32>(point, 0.0, 1.0); + out.uv = vec2<f32>((point.x + 1.0) * 0.5, (1.0 - point.y) * 0.5); + return out; +} + +@fragment +fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> { + let size = vec2<i32>(params.gridSize); + let gridPosition = input.uv * vec2<f32>(params.gridSize); + let temperature = bilinearScalar(temperatureTexture, gridPosition, size, params.boundary, params.ambient); + + let span = max(params.temperatureRange.y - params.temperatureRange.x, 1e-6); + let normalized = (temperature - params.temperatureRange.x) / span; + + var color = vec3<f32>(0.06, 0.07, 0.12); + if ((params.layerFlags & LAYER_TEMPERATURE) != 0u) { + color = colorMap(normalized); + } + + let cell = clamp(vec2<i32>(gridPosition), vec2<i32>(0, 0), size - vec2<i32>(1, 1)); + + if ((params.layerFlags & LAYER_MATERIAL) != 0u) { + // Conductivity spans four decades across the preset list, so tint on a log + // scale: bright for conductors, near-black for insulators. + let conductivity = max(textureLoad(materialTexture, cell, 0).x, 1e-6); + let t = clamp((log(conductivity) - params.logMinConductivity) / max(params.logConductivitySpan, 1e-6), 0.0, 1.0); + let tint = vec3<f32>(0.157 + 0.784 * t); + color = mix(color, tint, MATERIAL_TINT_ALPHA); + } + + if ((params.layerFlags & LAYER_GRADIENT) != 0u && params.peakGradient > 0.0) { + let gradient = temperatureGradient( + temperatureTexture, cell, size, + vec2<f32>(params.cellSize, params.cellSize), + params.boundary, params.ambient, + ); + let lift = clamp(length(gradient) / params.peakGradient, 0.0, 1.0) * GRADIENT_LIFT; + color = mix(color, vec3<f32>(1.0), lift); + } + + if ((params.layerFlags & LAYER_ISOTHERMS) != 0u && params.isothermInterval > 0.0) { + let level = temperature / params.isothermInterval; + let width = fwidth(level); + let distance = abs(fract(level - 0.5) - 0.5) / max(width, 1e-5); + let line = 1.0 - smoothstep(0.0, 1.5, distance); + color = mix(color, params.isothermColor.rgb, line * params.isothermColor.a); + } + + return vec4<f32>(color, 1.0); +} +`; + +/** Size of the `ArrowParams` uniform block, in bytes. */ +export const ARROW_PARAMS_BYTES = 48; + +/** Vertices per arrow: six for the shaft quad, three for the head. */ +export const ARROW_VERTEX_COUNT = 9; + +/** + * Heat-flux arrows. + * + * One instance per lattice site. The vertex shader reads the temperature and + * material textures directly, applies Fourier's law, and lays out the arrow in + * clip space — no vertex buffer, no per-frame CPU geometry. An arrow shorter than + * the noise floor is collapsed to a degenerate triangle rather than branching, + * which keeps the pass uniform. + */ +export const ARROW_RENDER_SHADER = ` +${WGSL_BOUNDARY_CONSTANTS} +${WGSL_RESOLVE} +${WGSL_SAMPLE_SCALAR} +${WGSL_GRADIENT} + +struct ArrowParams { + gridSize: vec2<u32>, + arrowCount: u32, + maxLength: f32, + color: vec4<f32>, + peakFlux: f32, + boundary: u32, + ambient: f32, + cellSize: f32, +}; + +@group(0) @binding(0) var temperatureTexture: texture_2d<f32>; +@group(0) @binding(1) var materialTexture: texture_2d<f32>; +@group(0) @binding(2) var<uniform> params: ArrowParams; + +/** Arrow outline in a unit frame: +x is the flux direction, length 1, centred. */ +fn arrowVertex(index: u32) -> vec2<f32> { + var shape = array<vec2<f32>, 9>( + vec2<f32>(-0.5, -0.06), vec2<f32>(0.18, -0.06), vec2<f32>(0.18, 0.06), + vec2<f32>(-0.5, -0.06), vec2<f32>(0.18, 0.06), vec2<f32>(-0.5, 0.06), + vec2<f32>(0.18, -0.20), vec2<f32>(0.5, 0.0), vec2<f32>(0.18, 0.20), + ); + return shape[index]; +} + +struct ArrowOutput { + @builtin(position) position: vec4<f32>, + @location(0) shade: f32, +}; + +@vertex +fn vertexMain( + @builtin(vertex_index) vertexIndex: u32, + @builtin(instance_index) instanceIndex: u32, +) -> ArrowOutput { + let size = vec2<i32>(params.gridSize); + let column = instanceIndex % params.arrowCount; + let row = instanceIndex / params.arrowCount; + let uv = (vec2<f32>(f32(column), f32(row)) + vec2<f32>(0.5)) / f32(params.arrowCount); + + let cell = clamp( + vec2<i32>(uv * vec2<f32>(params.gridSize)), + vec2<i32>(0, 0), + size - vec2<i32>(1, 1), + ); + + let gradient = temperatureGradient( + temperatureTexture, cell, size, + vec2<f32>(params.cellSize, params.cellSize), + params.boundary, params.ambient, + ); + let material = textureLoad(materialTexture, cell, 0); + let flux = vec2<f32>(-material.x * gradient.x, -material.y * gradient.y); + let magnitude = length(flux); + + var out: ArrowOutput; + if (magnitude < params.peakFlux * 0.02 || params.peakFlux <= 0.0) { + // Degenerate: nothing to draw at this site. + out.position = vec4<f32>(0.0, 0.0, 2.0, 1.0); + out.shade = 0.0; + return out; + } + + // Square-root scaling: |q| routinely spans two decades, and a linear map would + // leave most of the lattice invisible. + let length2d = params.maxLength * sqrt(clamp(magnitude / params.peakFlux, 0.0, 1.0)); + let direction = flux / magnitude; + + let local = arrowVertex(vertexIndex) * length2d; + // Field v points down; clip-space y points up. + let axis = vec2<f32>(direction.x, -direction.y); + let perpendicular = vec2<f32>(-axis.y, axis.x); + let offset = axis * local.x + perpendicular * local.y; + + let centre = vec2<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0); + out.position = vec4<f32>(centre + offset, 0.0, 1.0); + out.shade = clamp(magnitude / params.peakFlux, 0.25, 1.0); + return out; +} + +@fragment +fn fragmentMain(input: ArrowOutput) -> @location(0) vec4<f32> { + return vec4<f32>(params.color.rgb, params.color.a * input.shade); +} +`; + +/** Size of the `ParticleRenderParams` uniform block, in bytes. */ +export const PARTICLE_RENDER_PARAMS_BYTES = 32; + +/** Vertices per tracer particle (two triangles). */ +export const PARTICLE_VERTEX_COUNT = 6; + +/** + * Tracer particles, one instanced quad each, read directly from the same storage + * buffer the particle compute pass writes — the positions never make a round trip + * through the CPU. + */ +export const PARTICLE_RENDER_SHADER = ` +struct Particle { + position: vec2<f32>, + life: f32, + seed: f32, +}; + +struct ParticleRenderParams { + color: vec4<f32>, + size: f32, + lifetime: f32, + pad0: f32, + pad1: f32, +}; + +@group(0) @binding(0) var<storage, read> particles: array<Particle>; +@group(0) @binding(1) var<uniform> params: ParticleRenderParams; + +struct ParticleOutput { + @builtin(position) position: vec4<f32>, + @location(0) offset: vec2<f32>, + @location(1) alpha: f32, +}; + +@vertex +fn vertexMain( + @builtin(vertex_index) vertexIndex: u32, + @builtin(instance_index) instanceIndex: u32, +) -> ParticleOutput { + var corners = array<vec2<f32>, 6>( + vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0), + vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, 1.0), vec2<f32>(-1.0, 1.0), + ); + let corner = corners[vertexIndex]; + let particle = particles[instanceIndex]; + + let centre = vec2<f32>(particle.position.x * 2.0 - 1.0, 1.0 - particle.position.y * 2.0); + + // Triangular fade over the particle's life so tracers neither pop in nor out. + let fraction = clamp(particle.life / max(params.lifetime, 1e-6), 0.0, 1.0); + let fade = clamp(4.0 * min(fraction, 1.0 - fraction), 0.0, 1.0); + + var out: ParticleOutput; + out.position = vec4<f32>(centre + corner * params.size, 0.0, 1.0); + out.offset = corner; + out.alpha = params.color.a * fade; + return out; +} + +@fragment +fn fragmentMain(input: ParticleOutput) -> @location(0) vec4<f32> { + // Round the quad off into a soft disc. + let radius = length(input.offset); + let coverage = 1.0 - smoothstep(0.6, 1.0, radius); + return vec4<f32>(params.color.rgb, input.alpha * coverage); +} +`; diff --git a/src/common/field/gpu/webgpu-globals.d.ts b/src/common/field/gpu/webgpu-globals.d.ts new file mode 100644 index 0000000..6aa11a2 --- /dev/null +++ b/src/common/field/gpu/webgpu-globals.d.ts @@ -0,0 +1,60 @@ +/** + * webgpu-globals.d.ts + * + * TypeScript 7's `lib.dom.d.ts` ships the WebGPU *interfaces* (GPUDevice, + * GPUTexture, GPUCanvasContext, …) but not the flag namespaces the API is + * configured with. Declaring those here is cheaper and less fragile than pulling + * in `@webgpu/types`, which would redeclare all 107 interfaces and collide with + * the built-in ones. + * + * The other gap — a `getContext("webgpu")` overload — is deliberately *not* + * patched here. Augmenting `HTMLCanvasElement` puts the new overload ahead of the + * built-ins in resolution order, which breaks any code that forwards + * `getContext(id, ...args)` generically (the fleet's `tests/setup.ts` canvas mock + * does exactly that). `requestCanvasContext` in WebGpuSupport.ts does the cast in + * one place instead. + * + * Nothing in this file describes behaviour — it is purely the missing half of an + * existing type declaration. If a future TypeScript release fills these in, this + * file can be deleted and `npm run check` will say so. + */ + +declare const GPUBufferUsage: { + readonly MAP_READ: 0x0001; + readonly MAP_WRITE: 0x0002; + readonly COPY_SRC: 0x0004; + readonly COPY_DST: 0x0008; + readonly INDEX: 0x0010; + readonly VERTEX: 0x0020; + readonly UNIFORM: 0x0040; + readonly STORAGE: 0x0080; + readonly INDIRECT: 0x0100; + readonly QUERY_RESOLVE: 0x0200; +}; + +declare const GPUTextureUsage: { + readonly COPY_SRC: 0x01; + readonly COPY_DST: 0x02; + readonly TEXTURE_BINDING: 0x04; + readonly STORAGE_BINDING: 0x08; + readonly RENDER_ATTACHMENT: 0x10; +}; + +declare const GPUShaderStage: { + readonly VERTEX: 0x1; + readonly FRAGMENT: 0x2; + readonly COMPUTE: 0x4; +}; + +declare const GPUMapMode: { + readonly READ: 0x0001; + readonly WRITE: 0x0002; +}; + +declare const GPUColorWrite: { + readonly RED: 0x1; + readonly GREEN: 0x2; + readonly BLUE: 0x4; + readonly ALPHA: 0x8; + readonly ALL: 0xf; +}; diff --git a/src/common/field/kernels.ts b/src/common/field/kernels.ts new file mode 100644 index 0000000..192e1fc --- /dev/null +++ b/src/common/field/kernels.ts @@ -0,0 +1,312 @@ +/** + * kernels.ts + * + * The reference implementation of the field update, in plain TypeScript over + * `Float32Array`s. These functions define the *semantics* the WGSL compute + * shaders reproduce: same discretization, same boundary handling, same order of + * operations. They are also what the unit tests exercise, since WebGPU is not + * available under Vitest. + * + * Discretization + * ────────────── + * Conservative finite volume on a uniform grid. For the general heterogeneous, + * anisotropic case + * + * rho c_p dT/dt = d/dx (k_x dT/dx) + d/dy (k_y dT/dy) + * + * face conductivities use the **harmonic mean** of the two adjacent cells, which + * is the correct series combination of thermal resistances — an insulating layer + * one cell thick actually blocks heat, instead of being averaged away. + * + * Advection is semi-Lagrangian: each cell traces its parcel backward along the + * velocity field and bilinearly samples the old field there. That is + * unconditionally stable (no advective step-size limit) and mass-preserving + * enough for a teaching sim, at the cost of some numerical diffusion. + */ + +import { BoundaryCondition, type BoundaryConditionId } from "./FieldTypes.js"; + +/** + * The per-cell material arrays plus grid geometry. Bundled so kernels take one + * descriptor instead of eight loose parameters. + */ +export type FieldGeometry = { + gridWidth: number; + gridHeight: number; + dx: number; + dy: number; +}; + +/** Per-cell material coefficients, row-major, one entry per cell. */ +export type MaterialArrays = { + /** Conductivity along x, W/(m K). */ + conductivityX: Float32Array; + /** Conductivity along y, W/(m K). */ + conductivityY: Float32Array; + /** Volumetric heat capacity rho c_p, J/(m^3 K). */ + volumetricHeatCapacity: Float32Array; +}; + +/** + * Reads a cell, resolving out-of-range indices according to the boundary + * condition. This single function is why the three boundary conditions need no + * ghost-cell bookkeeping anywhere else. + */ +export function fetchCell( + field: Float32Array, + geometry: FieldGeometry, + boundary: BoundaryConditionId, + i: number, + j: number, + outsideValue: number, +): number { + const { gridWidth, gridHeight } = geometry; + let ii = i; + let jj = j; + + if (ii < 0 || ii >= gridWidth || jj < 0 || jj >= gridHeight) { + if (boundary === BoundaryCondition.FIXED) { + return outsideValue; + } + if (boundary === BoundaryCondition.PERIODIC) { + ii = ((ii % gridWidth) + gridWidth) % gridWidth; + jj = ((jj % gridHeight) + gridHeight) % gridHeight; + } else { + // INSULATED: zero normal gradient, i.e. mirror the edge cell outward. + ii = ii < 0 ? 0 : ii >= gridWidth ? gridWidth - 1 : ii; + jj = jj < 0 ? 0 : jj >= gridHeight ? gridHeight - 1 : jj; + } + } + + return field[jj * gridWidth + ii] ?? outsideValue; +} + +/** Reads a material array with edge clamping (materials always extend outward). */ +function fetchClamped(field: Float32Array, geometry: FieldGeometry, i: number, j: number): number { + const { gridWidth, gridHeight } = geometry; + const ii = i < 0 ? 0 : i >= gridWidth ? gridWidth - 1 : i; + const jj = j < 0 ? 0 : j >= gridHeight ? gridHeight - 1 : j; + return field[jj * gridWidth + ii] ?? 0; +} + +/** Series (harmonic) combination of two face conductivities. */ +function faceConductivity(a: number, b: number): number { + const sum = a + b; + return sum > 0 ? (2 * a * b) / sum : 0; +} + +/** + * Bilinearly samples a field at continuous grid coordinates, where the centre of + * cell (i, j) sits at (i + 0.5, j + 0.5). + */ +export function bilinearSample( + field: Float32Array, + geometry: FieldGeometry, + boundary: BoundaryConditionId, + gx: number, + gy: number, + outsideValue: number, +): number { + const fx = gx - 0.5; + const fy = gy - 0.5; + const i0 = Math.floor(fx); + const j0 = Math.floor(fy); + const tx = fx - i0; + const ty = fy - j0; + + const c00 = fetchCell(field, geometry, boundary, i0, j0, outsideValue); + const c10 = fetchCell(field, geometry, boundary, i0 + 1, j0, outsideValue); + const c01 = fetchCell(field, geometry, boundary, i0, j0 + 1, outsideValue); + const c11 = fetchCell(field, geometry, boundary, i0 + 1, j0 + 1, outsideValue); + + const top = c00 + (c10 - c00) * tx; + const bottom = c01 + (c11 - c01) * tx; + return top + (bottom - top) * ty; +} + +/** + * One explicit diffusion substep, written into `output`. + * + * `input` and `output` must be different arrays — this is the CPU half of the + * same ping-pong the GPU backend does between two textures. + */ +export function diffuseStep( + input: Float32Array, + output: Float32Array, + geometry: FieldGeometry, + material: MaterialArrays, + boundary: BoundaryConditionId, + outsideValue: number, + dt: number, +): void { + const { gridWidth, gridHeight, dx, dy } = geometry; + const invDx2 = 1 / (dx * dx); + const invDy2 = 1 / (dy * dy); + const insulated = boundary === BoundaryCondition.INSULATED; + + for (let j = 0; j < gridHeight; j++) { + for (let i = 0; i < gridWidth; i++) { + const index = j * gridWidth + i; + const centre = input[index] ?? outsideValue; + + const kxHere = material.conductivityX[index] ?? 0; + const kyHere = material.conductivityY[index] ?? 0; + + // Face conductivities. On an insulated edge the outward face is closed, so + // its conductivity is zero and no flux crosses it. + const atLeftEdge = i === 0; + const atRightEdge = i === gridWidth - 1; + const atTopEdge = j === 0; + const atBottomEdge = j === gridHeight - 1; + + const kWest = + insulated && atLeftEdge + ? 0 + : faceConductivity(kxHere, fetchClamped(material.conductivityX, geometry, i - 1, j)); + const kEast = + insulated && atRightEdge + ? 0 + : faceConductivity(kxHere, fetchClamped(material.conductivityX, geometry, i + 1, j)); + const kNorth = + insulated && atTopEdge ? 0 : faceConductivity(kyHere, fetchClamped(material.conductivityY, geometry, i, j - 1)); + const kSouth = + insulated && atBottomEdge + ? 0 + : faceConductivity(kyHere, fetchClamped(material.conductivityY, geometry, i, j + 1)); + + const west = fetchCell(input, geometry, boundary, i - 1, j, outsideValue); + const east = fetchCell(input, geometry, boundary, i + 1, j, outsideValue); + const north = fetchCell(input, geometry, boundary, i, j - 1, outsideValue); + const south = fetchCell(input, geometry, boundary, i, j + 1, outsideValue); + + const divergence = + (kEast * (east - centre) + kWest * (west - centre)) * invDx2 + + (kSouth * (south - centre) + kNorth * (north - centre)) * invDy2; + + const rhoCp = material.volumetricHeatCapacity[index] ?? 1; + output[index] = centre + (dt / rhoCp) * divergence; + } + } +} + +/** + * One semi-Lagrangian advection substep, written into `output`. + * + * `velocity` is interleaved (vx, vy) in m/s, one pair per cell. + */ +export function advectStep( + input: Float32Array, + output: Float32Array, + velocity: Float32Array, + geometry: FieldGeometry, + boundary: BoundaryConditionId, + outsideValue: number, + dt: number, + flowScale: number, +): void { + const { gridWidth, gridHeight, dx, dy } = geometry; + + for (let j = 0; j < gridHeight; j++) { + for (let i = 0; i < gridWidth; i++) { + const index = j * gridWidth + i; + const vx = (velocity[2 * index] ?? 0) * flowScale; + const vy = (velocity[2 * index + 1] ?? 0) * flowScale; + + // Trace this cell's parcel backward one step and sample where it came from. + const gx = i + 0.5 - (vx * dt) / dx; + const gy = j + 0.5 - (vy * dt) / dy; + output[index] = bilinearSample(input, geometry, boundary, gx, gy, outsideValue); + } + } +} + +/** + * Central-difference temperature gradient at a cell, in K/m. + * One-sided at insulated edges, which is where the mirrored fetch lands. + */ +export function gradientAt( + field: Float32Array, + geometry: FieldGeometry, + boundary: BoundaryConditionId, + i: number, + j: number, + outsideValue: number, +): { gx: number; gy: number } { + const east = fetchCell(field, geometry, boundary, i + 1, j, outsideValue); + const west = fetchCell(field, geometry, boundary, i - 1, j, outsideValue); + const south = fetchCell(field, geometry, boundary, i, j + 1, outsideValue); + const north = fetchCell(field, geometry, boundary, i, j - 1, outsideValue); + return { + gx: (east - west) / (2 * geometry.dx), + gy: (south - north) / (2 * geometry.dy), + }; +} + +/** + * Fourier's law, q = -k grad(T), evaluated at a cell. Returns W/m^2. + * The two components use their own directional conductivities, so an anisotropic + * material bends the flux away from the steepest-descent direction — which is + * the whole point of the anisotropy control. + */ +export function heatFluxAt( + field: Float32Array, + geometry: FieldGeometry, + material: MaterialArrays, + boundary: BoundaryConditionId, + i: number, + j: number, + outsideValue: number, +): { qx: number; qy: number } { + const { gx, gy } = gradientAt(field, geometry, boundary, i, j, outsideValue); + const index = j * geometry.gridWidth + i; + return { + qx: -(material.conductivityX[index] ?? 0) * gx, + qy: -(material.conductivityY[index] ?? 0) * gy, + }; +} + +/** + * The largest stable explicit time step, in seconds. + * + * Diffusion: the five-point Laplacian is stable while + * `alpha dt (1/dx^2 + 1/dy^2) <= 1/2`. Advection is unconditionally stable under + * semi-Lagrangian backtracing but is held to a Courant number for accuracy. + * + * @param geometry - grid spacing + * @param maxDiffusivity - the largest directional alpha anywhere in the domain, m^2/s + * @param maxSpeed - the largest |v| anywhere in the domain, m/s + * @param diffusionCfl - safety factor in (0, 1] on the diffusion limit + * @param advectionCfl - Courant number cap on the advective step + */ +export function stableTimeStep( + geometry: FieldGeometry, + maxDiffusivity: number, + maxSpeed: number, + diffusionCfl: number, + advectionCfl: number, +): number { + const { dx, dy } = geometry; + const inverseSquares = 1 / (dx * dx) + 1 / (dy * dy); + + let dt = Number.POSITIVE_INFINITY; + if (maxDiffusivity > 0) { + dt = Math.min(dt, (diffusionCfl * 0.5) / (maxDiffusivity * inverseSquares)); + } + if (maxSpeed > 0) { + dt = Math.min(dt, (advectionCfl * Math.min(dx, dy)) / maxSpeed); + } + + // Nothing is moving and nothing is diffusing: any step is stable, but pick a + // finite one so the clock still advances. + return Number.isFinite(dt) ? dt : 1; +} + +/** Total thermal energy per unit depth, in J/m — the quantity insulated boundaries conserve. */ +export function totalEnergy(field: Float32Array, geometry: FieldGeometry, material: MaterialArrays): number { + const cellVolume = geometry.dx * geometry.dy; + let sum = 0; + for (let index = 0; index < field.length; index++) { + sum += (material.volumetricHeatCapacity[index] ?? 0) * (field[index] ?? 0) * cellVolume; + } + return sum; +} diff --git a/src/common/model/FieldSimulationModel.ts b/src/common/model/FieldSimulationModel.ts new file mode 100644 index 0000000..eaa5b61 --- /dev/null +++ b/src/common/model/FieldSimulationModel.ts @@ -0,0 +1,450 @@ +/** + * FieldSimulationModel.ts + * + * The model every screen composes. It owns one {@link FieldEngine} and the + * reactive state that drives it, and it knows nothing about how the fields are + * stored or drawn — a screen's controls bind to Properties here, and the + * parameters those Properties describe are handed to the engine once per frame. + * + * Screens differ only in which Properties they expose to the student and what + * their {@link FieldSimulationConfig} enables. Nothing about the physics or the + * substrate is per-screen. + * + * On stepping + * ─────────── + * The engine always integrates at its stability-limited substep, so this model + * does not pass it a wall-clock dt. What it passes is a *substep budget*, scaled + * by the frame's length and the speed control. The consequence is worth stating + * plainly, because it is a real modelling decision and not an accident: a screen + * showing copper advances far more simulated seconds per real second than one + * showing glass, since glass's tiny diffusivity permits a much larger stable + * step. Both run at the same rate in *diffusion times*, which is the quantity + * that governs what the field actually looks like. The elapsed-time readout tells + * the honest story. See doc/model.md. + */ + +import { BooleanProperty, DerivedProperty, NumberProperty, Property, type TReadOnlyProperty } from "scenerystack/axon"; +import { Vector2, Vector2Property } from "scenerystack/dot"; +import type { TModel } from "scenerystack/joist"; +import { TimeSpeed } from "scenerystack/scenery-phet"; +import type { ResolutionPresetId } from "../../HeatTransferConstants.js"; +import { + BRUSH_STRENGTH, + COOL_BRUSH_TEMPERATURE_K, + CROSS_SECTION_SAMPLES, + DEFAULT_BRUSH_RADIUS_FRACTION, + DEFAULT_FLOW_SPEED, + HOT_BRUSH_TEMPERATURE_K, + MAX_FRAME_DT, + READBACK_FRAME_INTERVAL, + SLOW_SPEED_FACTOR, + SUBSTEPS_PER_FRAME, +} from "../../HeatTransferConstants.js"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import { createFieldEngine } from "../field/createFieldEngine.js"; +import type { FieldBackendId, FieldEngine } from "../field/FieldEngine.js"; +import { + type BoundaryConditionId, + type CrossSectionSample, + FlowPreset, + type FlowPresetId, + type InitialConditionId, + type LayerVisibility, + type MaterialProperties, +} from "../field/FieldTypes.js"; +import { DEFAULT_MATERIAL_ID, MATERIALS, type MaterialIdValue, withAnisotropy } from "../field/Materials.js"; + +/** What a screen turns on. Everything not listed here is identical across screens. */ +export type FieldSimulationConfig = { + /** Whether the advection term is integrated. False on Temperature and Conduction. */ + advectionEnabled: boolean; + /** + * Behaviour at the plate edges. + * + * Screens with a flow use `periodic`: with insulated edges a uniform stream + * carries every warm parcel off the downstream side within a few seconds of + * simulated time and leaves a blank plate, which teaches nothing. Periodic + * edges make the flow a steady recirculation, so a painted spot keeps + * travelling and the student can watch it both move and spread. + */ + boundaryCondition: BoundaryConditionId; + /** Which layers are visible when the screen opens. */ + defaultLayers: LayerVisibility; + /** How the temperature field is seeded on load and on Reset All. */ + initialCondition: InitialConditionId; + /** The flow preset selected on load. */ + initialFlowPreset: FlowPresetId; + /** Grid resolution to request. */ + resolution: ResolutionPresetId; + /** Edge length of the field canvas in device pixels. */ + displaySize: number; + /** Whether the screen starts running. */ + initiallyPlaying: boolean; +}; + +/** What the heat brush is currently depositing. */ +export const BrushMode = { + HEAT: "heat", + COOL: "cool", + MATERIAL: "material", +} as const; + +export type BrushModeId = (typeof BrushMode)[keyof typeof BrushMode]; + +export class FieldSimulationModel implements TModel { + public readonly config: FieldSimulationConfig; + + /** The substrate. Screens read `engine.canvas` and call `engine.render`; nothing else touches it. */ + public readonly engine: FieldEngine; + + /** Which backend was selected, for the status readout. */ + public readonly backend: FieldBackendId; + + /** Cells per side actually allocated, which may be coarser than requested. */ + public readonly effectiveResolution: number; + + /** True when the requested resolution had to be reduced to fit the backend. */ + public readonly resolutionReduced: boolean; + + // ── Clock ─────────────────────────────────────────────────────────────────── + + public readonly isPlayingProperty: BooleanProperty; + public readonly timeSpeedProperty: Property<TimeSpeed>; + + /** Simulated seconds since the last reset. */ + public readonly elapsedTimeProperty: NumberProperty; + + // ── Material ──────────────────────────────────────────────────────────────── + + public readonly materialIdProperty: Property<MaterialIdValue>; + + /** Ratio of k_x to k_y. 1 is isotropic; only the Materials screen exposes it. */ + public readonly anisotropyProperty: NumberProperty; + + /** Multiplier on conductivity — the "diffusion" control. */ + public readonly diffusionScaleProperty: NumberProperty; + + public readonly boundaryConditionProperty: Property<BoundaryConditionId>; + + // ── Flow ──────────────────────────────────────────────────────────────────── + + public readonly flowPresetProperty: Property<FlowPresetId>; + + /** Peak flow speed, in metres per second. */ + public readonly flowSpeedProperty: NumberProperty; + + /** Multiplier on the velocity field — the "flow speed" control on the combined screen. */ + public readonly flowScaleProperty: NumberProperty; + + // ── Visualization layers ──────────────────────────────────────────────────── + + public readonly temperatureLayerProperty: BooleanProperty; + public readonly isothermLayerProperty: BooleanProperty; + public readonly heatFluxLayerProperty: BooleanProperty; + public readonly velocityLayerProperty: BooleanProperty; + public readonly gradientLayerProperty: BooleanProperty; + public readonly materialLayerProperty: BooleanProperty; + + // ── Tools ─────────────────────────────────────────────────────────────────── + + public readonly brushModeProperty: Property<BrushModeId>; + + /** Brush radius as a fraction of the domain's shorter side. */ + public readonly brushRadiusProperty: NumberProperty; + + /** Which material the material brush paints. */ + public readonly paintMaterialIdProperty: Property<MaterialIdValue>; + + /** Probe position in the unit square. */ + public readonly probePositionProperty: Vector2Property; + + /** Temperature under the probe, in kelvin. */ + public readonly probeTemperatureProperty: NumberProperty; + + public readonly probeVisibleProperty: BooleanProperty; + + /** Endpoints of the cross-section line, in the unit square. */ + public readonly crossSectionStartProperty: Vector2Property; + public readonly crossSectionEndProperty: Vector2Property; + public readonly crossSectionVisibleProperty: BooleanProperty; + public readonly crossSectionSamplesProperty: Property<CrossSectionSample[]>; + + // ── Readouts ──────────────────────────────────────────────────────────────── + + public readonly minTemperatureProperty: NumberProperty; + public readonly maxTemperatureProperty: NumberProperty; + public readonly meanTemperatureProperty: NumberProperty; + + /** + * Peclet number Pe = U L / alpha: the ratio of advective to diffusive + * transport. Below 1 the field is shaped by conduction, above ~100 by the flow. + */ + public readonly pecletNumberProperty: TReadOnlyProperty<number>; + + private frameCounter = 0; + + public constructor(config: FieldSimulationConfig) { + this.config = config; + + const creation = createFieldEngine({ + resolution: config.resolution, + displaySize: config.displaySize, + }); + this.engine = creation.engine; + this.backend = creation.backend; + this.effectiveResolution = creation.effectiveResolution; + this.resolutionReduced = creation.resolutionReduced; + + this.isPlayingProperty = new BooleanProperty(config.initiallyPlaying); + this.timeSpeedProperty = new Property<TimeSpeed>(TimeSpeed.NORMAL); + this.elapsedTimeProperty = new NumberProperty(0, { units: "s" }); + + this.materialIdProperty = new Property<MaterialIdValue>(DEFAULT_MATERIAL_ID); + this.anisotropyProperty = new NumberProperty(1); + this.diffusionScaleProperty = new NumberProperty(1); + this.boundaryConditionProperty = new Property<BoundaryConditionId>(config.boundaryCondition); + + this.flowPresetProperty = new Property<FlowPresetId>(config.initialFlowPreset); + this.flowSpeedProperty = new NumberProperty(DEFAULT_FLOW_SPEED, { units: "m/s" }); + this.flowScaleProperty = new NumberProperty(1); + + const layers = config.defaultLayers; + this.temperatureLayerProperty = new BooleanProperty(layers.temperature); + this.isothermLayerProperty = new BooleanProperty(layers.isotherms); + this.heatFluxLayerProperty = new BooleanProperty(layers.heatFlux); + this.velocityLayerProperty = new BooleanProperty(layers.velocity); + this.gradientLayerProperty = new BooleanProperty(layers.gradient); + this.materialLayerProperty = new BooleanProperty(layers.material); + + this.brushModeProperty = new Property<BrushModeId>(BrushMode.HEAT); + this.brushRadiusProperty = new NumberProperty(DEFAULT_BRUSH_RADIUS_FRACTION); + this.paintMaterialIdProperty = new Property<MaterialIdValue>("insulator"); + + this.probePositionProperty = new Vector2Property(new Vector2(0.5, 0.5)); + this.probeTemperatureProperty = new NumberProperty(this.engine.sampleTemperature(0.5, 0.5), { units: "K" }); + this.probeVisibleProperty = new BooleanProperty(false); + + this.crossSectionStartProperty = new Vector2Property(new Vector2(0.1, 0.5)); + this.crossSectionEndProperty = new Vector2Property(new Vector2(0.9, 0.5)); + this.crossSectionVisibleProperty = new BooleanProperty(false); + this.crossSectionSamplesProperty = new Property<CrossSectionSample[]>([]); + + const statistics = this.engine.getStatistics(); + this.minTemperatureProperty = new NumberProperty(statistics.minTemperature, { units: "K" }); + this.maxTemperatureProperty = new NumberProperty(statistics.maxTemperature, { units: "K" }); + this.meanTemperatureProperty = new NumberProperty(statistics.meanTemperature, { units: "K" }); + + this.pecletNumberProperty = new DerivedProperty( + [this.flowSpeedProperty, this.flowScaleProperty, this.diffusionScaleProperty, this.flowPresetProperty], + (speed, flowScale, diffusionScale, preset) => { + if (preset === FlowPreset.NONE) { + return 0; + } + const diffusivity = this.engine.getMeanDiffusivity() * diffusionScale; + if (diffusivity <= 0) { + return Number.POSITIVE_INFINITY; + } + return (speed * flowScale * this.engine.domain.characteristicLength) / diffusivity; + }, + ); + + // ── Wire the Properties to the engine ───────────────────────────────────── + + this.materialIdProperty.link(() => { + this.pushMaterial(); + }); + this.anisotropyProperty.link(() => { + this.pushMaterial(); + }); + + const pushFlow = (): void => { + this.engine.setFlow(this.flowPresetProperty.value, this.flowSpeedProperty.value); + }; + this.flowPresetProperty.link(pushFlow); + this.flowSpeedProperty.link(pushFlow); + + this.probePositionProperty.link((position) => { + this.probeTemperatureProperty.value = this.engine.sampleTemperature(position.x, position.y); + }); + + const updateCrossSection = (): void => { + this.refreshCrossSection(); + }; + this.crossSectionStartProperty.link(updateCrossSection); + this.crossSectionEndProperty.link(updateCrossSection); + + this.engine.resetField(config.initialCondition); + this.refreshReadouts(); + } + + // ── Derived views of state ────────────────────────────────────────────────── + + /** The material the plate is made of, including its anisotropy ratio. */ + public get material(): MaterialProperties { + return withAnisotropy(MATERIALS[this.materialIdProperty.value], this.anisotropyProperty.value); + } + + /** A snapshot of which layers are on, for the engine's render call. */ + public getLayerVisibility(): LayerVisibility { + return { + temperature: this.temperatureLayerProperty.value, + isotherms: this.isothermLayerProperty.value, + heatFlux: this.heatFluxLayerProperty.value, + velocity: this.velocityLayerProperty.value, + gradient: this.gradientLayerProperty.value, + material: this.materialLayerProperty.value, + }; + } + + // ── Interaction ───────────────────────────────────────────────────────────── + + /** + * Applies the active brush at a point in the unit square. + * + * One entry point for pointer drags, keyboard activation, and touch alike, so + * every input route deposits exactly the same stroke. + */ + public paintAt(u: number, v: number): void { + const mode = this.brushModeProperty.value; + const radius = this.brushRadiusProperty.value; + + if (mode === BrushMode.MATERIAL) { + this.engine.paintMaterial({ + u, + v, + radius, + material: withAnisotropy(MATERIALS[this.paintMaterialIdProperty.value], this.anisotropyProperty.value), + }); + return; + } + + this.engine.paintTemperature({ + u, + v, + radius, + temperature: mode === BrushMode.HEAT ? HOT_BRUSH_TEMPERATURE_K : COOL_BRUSH_TEMPERATURE_K, + strength: BRUSH_STRENGTH, + }); + this.refreshProbe(); + } + + /** Re-reads the cross-section line from the engine. */ + public refreshCrossSection(): void { + const start = this.crossSectionStartProperty.value; + const end = this.crossSectionEndProperty.value; + this.crossSectionSamplesProperty.value = this.engine.sampleCrossSection( + start.x, + start.y, + end.x, + end.y, + CROSS_SECTION_SAMPLES, + ); + } + + private refreshProbe(): void { + const position = this.probePositionProperty.value; + this.probeTemperatureProperty.value = this.engine.sampleTemperature(position.x, position.y); + } + + private pushMaterial(): void { + this.engine.setMaterial(this.material); + } + + // ── Stepping ──────────────────────────────────────────────────────────────── + + public step(dt: number): void { + if (!this.isPlayingProperty.value) { + return; + } + this.advance(dt); + } + + /** Advances one frame's worth of simulation regardless of the play/pause state. */ + public stepOnce(): void { + this.advance(1 / 60); + } + + private advance(dt: number): void { + const clamped = Math.min(Math.max(dt, 0), MAX_FRAME_DT); + const speedFactor = this.timeSpeedProperty.value === TimeSpeed.SLOW ? SLOW_SPEED_FACTOR : 1; + + // Scale the substep budget by how long the frame actually was, so the + // simulation runs at the same rate on a 30 Hz display as on a 120 Hz one. + const budget = Math.round(SUBSTEPS_PER_FRAME * speedFactor * clamped * 60); + const substeps = Math.min(Math.max(budget, 1), SUBSTEPS_PER_FRAME * 4); + + this.engine.step({ + advectionEnabled: this.config.advectionEnabled && this.flowPresetProperty.value !== FlowPreset.NONE, + diffusionEnabled: true, + diffusionScale: this.diffusionScaleProperty.value, + flowScale: this.flowScaleProperty.value, + boundaryCondition: this.boundaryConditionProperty.value, + substeps, + }); + + this.elapsedTimeProperty.value = this.engine.simulatedTime; + + // Statistics and the cross-section scan the whole field, and the GPU mirror + // only refreshes every few frames anyway, so there is nothing to gain from + // recomputing them more often than the data changes. + this.frameCounter++; + if (this.frameCounter % READBACK_FRAME_INTERVAL === 0) { + this.refreshReadouts(); + } + } + + private refreshReadouts(): void { + const statistics = this.engine.getStatistics(); + this.minTemperatureProperty.value = statistics.minTemperature; + this.maxTemperatureProperty.value = statistics.maxTemperature; + this.meanTemperatureProperty.value = statistics.meanTemperature; + this.refreshProbe(); + if (this.crossSectionVisibleProperty.value) { + this.refreshCrossSection(); + } + } + + // ── Reset ─────────────────────────────────────────────────────────────────── + + public reset(): void { + this.isPlayingProperty.reset(); + this.timeSpeedProperty.reset(); + this.materialIdProperty.reset(); + this.anisotropyProperty.reset(); + this.diffusionScaleProperty.reset(); + this.boundaryConditionProperty.reset(); + this.flowPresetProperty.reset(); + this.flowSpeedProperty.reset(); + this.flowScaleProperty.reset(); + + this.temperatureLayerProperty.reset(); + this.isothermLayerProperty.reset(); + this.heatFluxLayerProperty.reset(); + this.velocityLayerProperty.reset(); + this.gradientLayerProperty.reset(); + this.materialLayerProperty.reset(); + + this.brushModeProperty.reset(); + this.brushRadiusProperty.reset(); + this.paintMaterialIdProperty.reset(); + this.probePositionProperty.reset(); + this.probeVisibleProperty.reset(); + this.crossSectionStartProperty.reset(); + this.crossSectionEndProperty.reset(); + this.crossSectionVisibleProperty.reset(); + + // The material Property links above have already restored the homogeneous + // material, so reseeding the temperature is all that is left. + this.engine.resetField(this.config.initialCondition); + this.elapsedTimeProperty.reset(); + this.frameCounter = 0; + this.refreshReadouts(); + } + + public dispose(): void { + this.pecletNumberProperty.dispose(); + this.engine.dispose(); + } +} + +HeatTransferNamespace.register("FieldSimulationModel", FieldSimulationModel); diff --git a/src/common/view/BrushControlPanel.ts b/src/common/view/BrushControlPanel.ts new file mode 100644 index 0000000..7425db6 --- /dev/null +++ b/src/common/view/BrushControlPanel.ts @@ -0,0 +1,109 @@ +/** + * BrushControlPanel.ts + * + * What the brush deposits, and how big it is. + * + * The heat/cool pair is a radio group rather than a toggle so that both states + * are visible at once — a student choosing between heating and cooling should not + * have to press a button to discover what the other option was. The size slider + * is in fractions of the plate, not cells, so it means the same thing at every + * grid resolution. + */ + +import { Range } from "scenerystack/dot"; +import { type Node, Text, VBox } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; +import { RectangularRadioButtonGroup, type RectangularRadioButtonGroupItem } from "scenerystack/sun"; +import HeatTransferColors from "../../HeatTransferColors.js"; +import { LABEL_FONT_SIZE, MAX_BRUSH_RADIUS_FRACTION, MIN_BRUSH_RADIUS_FRACTION } from "../../HeatTransferConstants.js"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import { HeatTransferPanel, type HeatTransferPanelOptions } from "../HeatTransferPanel.js"; +import { BrushMode, type BrushModeId, type FieldSimulationModel } from "../model/FieldSimulationModel.js"; +import { labelledSlider, panelTitle } from "./ControlFactory.js"; + +export class BrushControlPanel extends HeatTransferPanel { + /** The interactive children, in traversal order, for the ScreenView's `pdomOrder`. */ + public readonly controls: readonly Node[]; + + /** + * @param model - the screen's field model + * @param includeMaterialMode - add a third mode that paints material instead of + * temperature; only the Materials screen has a material field worth painting + * @param providedOptions - ordinary Panel options + */ + public constructor( + model: FieldSimulationModel, + includeMaterialMode = false, + providedOptions?: HeatTransferPanelOptions, + ) { + const strings = StringManager.getInstance(); + const controls = strings.getControls(); + const a11y = strings.getSharedA11yStrings(); + + const modeItems: RectangularRadioButtonGroupItem<BrushModeId>[] = [ + { + value: BrushMode.HEAT, + createNode: () => radioLabel(controls.heatStringProperty), + options: { accessibleName: controls.heatStringProperty }, + }, + { + value: BrushMode.COOL, + createNode: () => radioLabel(controls.coolStringProperty), + options: { accessibleName: controls.coolStringProperty }, + }, + ]; + if (includeMaterialMode) { + modeItems.push({ + value: BrushMode.MATERIAL, + createNode: () => radioLabel(controls.paintMaterialStringProperty), + options: { accessibleName: controls.paintMaterialStringProperty }, + }); + } + + const modeGroup = new RectangularRadioButtonGroup<BrushModeId>(model.brushModeProperty, modeItems, { + orientation: "horizontal", + spacing: 8, + accessibleName: controls.brushStringProperty, + accessibleHelpText: a11y.controls.brushModeStringProperty, + radioButtonOptions: { + baseColor: HeatTransferColors.controlSurfaceColorProperty, + xMargin: includeMaterialMode ? 8 : 16, + yMargin: 6, + buttonAppearanceStrategyOptions: { + selectedStroke: HeatTransferColors.accentColorProperty, + selectedLineWidth: 3, + }, + }, + }); + + const size = labelledSlider({ + label: controls.brushSizeStringProperty, + property: model.brushRadiusProperty, + range: new Range(MIN_BRUSH_RADIUS_FRACTION, MAX_BRUSH_RADIUS_FRACTION), + accessibleName: controls.brushSizeStringProperty, + accessibleHelpText: a11y.controls.brushSizeStringProperty, + }); + + super( + new VBox({ + align: "left", + spacing: 9, + children: [panelTitle(controls.brushStringProperty), modeGroup, size.node], + }), + providedOptions, + ); + + this.controls = [modeGroup, size.slider]; + } +} + +/** A radio-button label, drawn on the white control surface. */ +function radioLabel(label: Parameters<typeof panelTitle>[0]): Text { + return new Text(label, { + font: new PhetFont({ size: LABEL_FONT_SIZE, weight: "bold" }), + fill: HeatTransferColors.controlSurfaceTextColorProperty, + }); +} + +HeatTransferNamespace.register("BrushControlPanel", BrushControlPanel); diff --git a/src/common/view/ControlFactory.ts b/src/common/view/ControlFactory.ts new file mode 100644 index 0000000..aa214c7 --- /dev/null +++ b/src/common/view/ControlFactory.ts @@ -0,0 +1,178 @@ +/** + * ControlFactory.ts + * + * The sim's small vocabulary of themed controls. + * + * Five screens share one control language: the same title weight, the same + * slider track, the same combo-box chrome, the same label colour on the panel + * fill versus on the white control surface. Centralizing that here means a screen + * file reads as a list of *what* it offers rather than a wall of styling, and it + * makes the whole set retheme with one edit. + * + * Colour rule worth remembering: text drawn on a panel uses + * `textColorProperty`, text drawn on a combo box or a flat button uses + * `controlSurfaceTextColorProperty`. The surfaces stay white in both profiles, so + * their text must stay dark in both. + */ + +import type { PhetioProperty, TReadOnlyProperty } from "scenerystack/axon"; +import { Dimension2, type Range } from "scenerystack/dot"; +import { Node, Text, VBox } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; +import { Checkbox, ComboBox, HSlider } from "scenerystack/sun"; +import HeatTransferColors from "../../HeatTransferColors.js"; +import { CONTROL_PANEL_WIDTH, LABEL_FONT_SIZE, SMALL_FONT_SIZE, TITLE_FONT_SIZE } from "../../HeatTransferConstants.js"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import { HEAT_TRANSFER_COMBO_BOX_OPTIONS } from "../HeatTransferButtonOptions.js"; + +/** Usable content width inside a control panel. */ +export const CONTENT_WIDTH = CONTROL_PANEL_WIDTH - 30; + +/** Slider track dimensions shared by every slider in the sim. */ +const SLIDER_TRACK = new Dimension2(CONTENT_WIDTH - 10, 4); + +/** A panel heading. */ +export function panelTitle(label: TReadOnlyProperty<string>): Text { + return new Text(label, { + font: new PhetFont({ size: TITLE_FONT_SIZE, weight: "bold" }), + fill: HeatTransferColors.textColorProperty, + maxWidth: CONTENT_WIDTH, + }); +} + +/** An ordinary control label on the panel fill. */ +export function panelLabel(label: TReadOnlyProperty<string>): Text { + return new Text(label, { + font: new PhetFont(LABEL_FONT_SIZE), + fill: HeatTransferColors.textColorProperty, + maxWidth: CONTENT_WIDTH, + }); +} + +/** A secondary readout: units, derived quantities, status. */ +export function panelReadout(label: TReadOnlyProperty<string>): Text { + return new Text(label, { + font: new PhetFont(SMALL_FONT_SIZE), + fill: HeatTransferColors.secondaryTextColorProperty, + maxWidth: CONTENT_WIDTH, + }); +} + +/** A combo-box item label, drawn on the white control surface. */ +export function comboBoxLabel(label: TReadOnlyProperty<string>): Text { + return new Text(label, { + font: new PhetFont(LABEL_FONT_SIZE), + fill: HeatTransferColors.controlSurfaceTextColorProperty, + maxWidth: CONTENT_WIDTH - 40, + }); +} + +export type LabelledSliderOptions = { + /** Label above the track. Omit when the panel title already names the control. */ + label?: TReadOnlyProperty<string>; + property: PhetioProperty<number>; + range: Range; + accessibleName: TReadOnlyProperty<string>; + accessibleHelpText?: TReadOnlyProperty<string>; + /** Optional end labels drawn under the track, e.g. "Diffusion" and "Advection". */ + endLabels?: { left: TReadOnlyProperty<string>; right: TReadOnlyProperty<string> }; + /** Optional live readout drawn under the slider. */ + readout?: TReadOnlyProperty<string>; +}; + +/** + * A slider with a label above it and, optionally, end labels and a readout below. + * + * Returned as a `VBox` rather than a `NumberControl` because most of this sim's + * sliders control a quantity whose raw number means nothing to a student — brush + * radius as a fraction of the plate, a dimensionless transport balance — so the + * label and an interpreted readout carry the meaning instead of a spinner. + */ +export function labelledSlider(options: LabelledSliderOptions): { node: VBox; slider: HSlider } { + const slider = new HSlider(options.property, options.range, { + trackSize: SLIDER_TRACK, + trackFillEnabled: HeatTransferColors.panelBorderColorProperty, + thumbFill: HeatTransferColors.accentColorProperty, + thumbSize: new Dimension2(13, 24), + accessibleName: options.accessibleName, + ...(options.accessibleHelpText && { accessibleHelpText: options.accessibleHelpText }), + }); + + const children: Node[] = options.label ? [panelLabel(options.label), slider] : [slider]; + + if (options.endLabels) { + children.push( + new VBox({ + align: "center", + children: [endLabelRow(options.endLabels.left, options.endLabels.right)], + }), + ); + } + if (options.readout) { + children.push(panelReadout(options.readout)); + } + + return { + node: new VBox({ align: "left", spacing: 5, children }), + slider, + }; +} + +/** The two end labels under a slider, pushed to the track's ends. */ +function endLabelRow(left: TReadOnlyProperty<string>, right: TReadOnlyProperty<string>): Node { + const leftText = panelReadout(left); + const rightText = panelReadout(right); + const row = new Node({ children: [leftText, rightText] }); + leftText.left = 0; + rightText.right = SLIDER_TRACK.width; + return row; +} + +/** A checkbox with a panel-fill label. */ +export function themedCheckbox( + property: PhetioProperty<boolean>, + label: TReadOnlyProperty<string>, + accessibleHelpText?: TReadOnlyProperty<string>, +): Checkbox { + return new Checkbox(property, panelLabel(label), { + checkboxColor: HeatTransferColors.textColorProperty, + checkboxColorBackground: HeatTransferColors.panelBackgroundColorProperty, + spacing: 8, + accessibleName: label, + ...(accessibleHelpText && { accessibleHelpText }), + }); +} + +/** + * A themed combo box over a list of ids. + * + * `listParent` must be a node high in the scene graph — usually the ScreenView's + * combo-box layer — so the popup list is not clipped by the panel it lives in. + */ +export function themedComboBox<T>( + property: PhetioProperty<T>, + values: readonly T[], + labelFor: (value: T) => TReadOnlyProperty<string>, + listParent: Node, + accessibleName: TReadOnlyProperty<string>, + accessibleHelpText?: TReadOnlyProperty<string>, +): ComboBox<T> { + return new ComboBox( + property, + values.map((value) => ({ + value, + createNode: () => comboBoxLabel(labelFor(value)), + accessibleName: labelFor(value), + })), + listParent, + { + ...HEAT_TRANSFER_COMBO_BOX_OPTIONS, + xMargin: 10, + yMargin: 5, + accessibleName, + ...(accessibleHelpText && { accessibleHelpText }), + }, + ); +} + +HeatTransferNamespace.register("ControlFactory", { labelledSlider, panelTitle }); diff --git a/src/common/view/CrossSectionGraphNode.ts b/src/common/view/CrossSectionGraphNode.ts new file mode 100644 index 0000000..5b620ea --- /dev/null +++ b/src/common/view/CrossSectionGraphNode.ts @@ -0,0 +1,201 @@ +/** + * CrossSectionGraphNode.ts + * + * T(s) — and optionally q_s(s) — along the cross-section line. + * + * Two curves on one set of axes, with two different vertical scales, is usually a + * bad idea. Here it earns its place: the whole point is that the flux curve is + * the *negative slope* of the temperature curve times k, so seeing q peak exactly + * where T is steepest, and cross zero exactly where T turns over, is the + * demonstration. The temperature axis is fixed to the legend's range so the curve + * can be read against the colours on the field; the flux axis autoscales, since + * its magnitude changes by decades with the material. + */ + +import type { TReadOnlyProperty } from "scenerystack/axon"; +import { Shape } from "scenerystack/kite"; +import { Node, type NodeOptions, Path, Rectangle, Text } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; +import HeatTransferColors from "../../HeatTransferColors.js"; +import { + KELVIN_TO_CELSIUS_OFFSET, + MAX_TEMPERATURE_K, + MIN_TEMPERATURE_K, + SMALL_FONT_SIZE, +} from "../../HeatTransferConstants.js"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { CrossSectionSample } from "../field/FieldTypes.js"; +import type { FieldSimulationModel } from "../model/FieldSimulationModel.js"; +import { panelReadout } from "./ControlFactory.js"; + +/** Padding inside the plot frame, in view coordinates. */ +const PLOT_MARGIN = { left: 34, right: 12, top: 10, bottom: 22 }; + +/** Number of horizontal gridlines. */ +const GRIDLINE_COUNT = 4; + +export type CrossSectionGraphNodeOptions = NodeOptions & { + width: number; + height: number; + /** Whether to draw the flux curve alongside the temperature curve. */ + showFluxProperty?: TReadOnlyProperty<boolean>; +}; + +export class CrossSectionGraphNode extends Node { + private readonly temperatureCurve: Path; + private readonly fluxCurve: Path; + private readonly plotWidth: number; + private readonly plotHeight: number; + + public constructor(model: FieldSimulationModel, providedOptions: CrossSectionGraphNodeOptions) { + super(); + + const strings = StringManager.getInstance(); + const graph = strings.getGraph(); + + const { width, height } = providedOptions; + this.plotWidth = width - PLOT_MARGIN.left - PLOT_MARGIN.right; + this.plotHeight = height - PLOT_MARGIN.top - PLOT_MARGIN.bottom; + + // ── Frame ───────────────────────────────────────────────────────────────── + + const background = new Rectangle(0, 0, width, height, 4, 4, { + fill: HeatTransferColors.graphBackgroundColorProperty, + stroke: HeatTransferColors.panelBorderColorProperty, + }); + this.addChild(background); + + const plot = new Node({ x: PLOT_MARGIN.left, y: PLOT_MARGIN.top }); + this.addChild(plot); + + const gridShape = new Shape(); + for (let n = 0; n <= GRIDLINE_COUNT; n++) { + const y = (this.plotHeight * n) / GRIDLINE_COUNT; + gridShape.moveTo(0, y).lineTo(this.plotWidth, y); + } + plot.addChild( + new Path(gridShape, { + stroke: HeatTransferColors.graphAxisColorProperty, + lineWidth: 0.5, + opacity: 0.5, + }), + ); + + // Temperature axis ticks, in degrees Celsius, matching the legend's range. + for (let n = 0; n <= GRIDLINE_COUNT; n++) { + const fraction = n / GRIDLINE_COUNT; + const kelvin = MIN_TEMPERATURE_K + (1 - fraction) * (MAX_TEMPERATURE_K - MIN_TEMPERATURE_K); + plot.addChild( + new Text(`${Math.round(kelvin - KELVIN_TO_CELSIUS_OFFSET)}`, { + font: new PhetFont(SMALL_FONT_SIZE - 1), + fill: HeatTransferColors.graphAxisColorProperty, + right: -5, + centerY: (this.plotHeight * n) / GRIDLINE_COUNT, + }), + ); + } + + // ── Curves ──────────────────────────────────────────────────────────────── + + this.fluxCurve = new Path(null, { + stroke: HeatTransferColors.fluxCurveColorProperty, + lineWidth: 1.5, + lineDash: [4, 3], + }); + this.temperatureCurve = new Path(null, { + stroke: HeatTransferColors.temperatureCurveColorProperty, + lineWidth: 2, + }); + plot.addChild(this.fluxCurve); + plot.addChild(this.temperatureCurve); + + // ── Labels ──────────────────────────────────────────────────────────────── + + const title = panelReadout(graph.titleStringProperty); + title.left = PLOT_MARGIN.left; + title.bottom = -3; + this.addChild(title); + + const xAxisLabel = panelReadout(graph.distanceAxisStringProperty); + xAxisLabel.centerX = PLOT_MARGIN.left + this.plotWidth / 2; + xAxisLabel.top = height - PLOT_MARGIN.bottom + 4; + this.addChild(xAxisLabel); + + const yAxisLabel = new Text(graph.temperatureAxisStringProperty, { + font: new PhetFont(SMALL_FONT_SIZE - 1), + fill: HeatTransferColors.temperatureCurveColorProperty, + rotation: -Math.PI / 2, + right: 10, + centerY: PLOT_MARGIN.top + this.plotHeight / 2, + }); + this.addChild(yAxisLabel); + + // ── Data ────────────────────────────────────────────────────────────────── + + const showFluxProperty = providedOptions.showFluxProperty; + model.crossSectionSamplesProperty.link((samples) => { + this.updateCurves(samples, showFluxProperty?.value ?? false); + }); + showFluxProperty?.link((show) => { + this.updateCurves(model.crossSectionSamplesProperty.value, show); + }); + + this.mutate(providedOptions); + } + + private updateCurves(samples: readonly CrossSectionSample[], showFlux: boolean): void { + if (samples.length < 2) { + this.temperatureCurve.shape = null; + this.fluxCurve.shape = null; + return; + } + + const lastSample = samples[samples.length - 1]; + const totalDistance = lastSample ? lastSample.distance : 1; + const xOf = (distance: number): number => (totalDistance > 0 ? (distance / totalDistance) * this.plotWidth : 0); + + // Temperature: fixed scale, matching the legend. + const temperatureSpan = MAX_TEMPERATURE_K - MIN_TEMPERATURE_K; + const temperatureShape = new Shape(); + samples.forEach((sample, index) => { + const fraction = (sample.temperature - MIN_TEMPERATURE_K) / temperatureSpan; + const y = this.plotHeight * (1 - Math.min(1, Math.max(0, fraction))); + if (index === 0) { + temperatureShape.moveTo(xOf(sample.distance), y); + } else { + temperatureShape.lineTo(xOf(sample.distance), y); + } + }); + this.temperatureCurve.shape = temperatureShape; + + if (!showFlux) { + this.fluxCurve.shape = null; + return; + } + + // Flux: autoscaled and centred, so zero flux sits on the middle gridline and + // the sign of q is readable directly. + let peak = 0; + for (const sample of samples) { + peak = Math.max(peak, Math.abs(sample.flux)); + } + if (peak <= 0) { + this.fluxCurve.shape = null; + return; + } + + const fluxShape = new Shape(); + samples.forEach((sample, index) => { + const y = this.plotHeight * (0.5 - (sample.flux / peak) * 0.45); + if (index === 0) { + fluxShape.moveTo(xOf(sample.distance), y); + } else { + fluxShape.lineTo(xOf(sample.distance), y); + } + }); + this.fluxCurve.shape = fluxShape; + } +} + +HeatTransferNamespace.register("CrossSectionGraphNode", CrossSectionGraphNode); diff --git a/src/common/view/CrossSectionToolNode.ts b/src/common/view/CrossSectionToolNode.ts new file mode 100644 index 0000000..7adc632 --- /dev/null +++ b/src/common/view/CrossSectionToolNode.ts @@ -0,0 +1,122 @@ +/** + * CrossSectionToolNode.ts + * + * The line the student drags across the field, and its two handles. + * + * This is the bridge between the picture and the mathematics: the field is a + * function of two variables, and restricting it to a line turns it into a + * function of one, which is the form every textbook derivation is written in. + * Dragging the line and watching T(s) change shape alongside it is the whole + * point of the tool, so the graph updates continuously rather than on release. + */ + +import { Vector2 } from "scenerystack/dot"; +import { Shape } from "scenerystack/kite"; +import { Circle, DragListener, KeyboardDragListener, Node, type NodeOptions, Path } from "scenerystack/scenery"; +import HeatTransferColors from "../../HeatTransferColors.js"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { FieldSimulationModel } from "../model/FieldSimulationModel.js"; +import type { FieldNode } from "./FieldNode.js"; + +/** Radius of a drag handle, in view coordinates. */ +const HANDLE_RADIUS = 8; + +/** How far one keyboard drag step moves a handle, as a fraction of the field. */ +const KEYBOARD_SCALE = 0.5; + +export class CrossSectionToolNode extends Node { + /** The two handles, in traversal order. */ + public readonly handles: readonly Node[]; + + public constructor(model: FieldSimulationModel, fieldNode: FieldNode, providedOptions?: NodeOptions) { + super(); + + const strings = StringManager.getInstance(); + const a11y = strings.getSharedA11yStrings(); + + const line = new Path(null, { + stroke: HeatTransferColors.crossSectionColorProperty, + lineWidth: 2.5, + lineDash: [7, 4], + pickable: false, + }); + this.addChild(line); + + const makeHandle = ( + positionProperty: typeof model.crossSectionStartProperty, + accessibleName: typeof a11y.controls.crossSectionStartStringProperty, + ): Node => { + const handle = new Circle(HANDLE_RADIUS, { + fill: HeatTransferColors.crossSectionColorProperty, + stroke: HeatTransferColors.brushOutlineColorProperty, + lineWidth: 1.5, + cursor: "pointer", + tagName: "div", + focusable: true, + accessibleName, + }); + + const clampToField = (u: number, v: number): Vector2 => + new Vector2(Math.min(1, Math.max(0, u)), Math.min(1, Math.max(0, v))); + + handle.addInputListener( + new DragListener({ + drag: (event) => { + const local = fieldNode.globalToLocalPoint(event.pointer.point); + positionProperty.value = clampToField(local.x / fieldNode.viewSize, local.y / fieldNode.viewSize); + }, + }), + ); + + handle.addInputListener( + new KeyboardDragListener({ + dragSpeed: 300, + shiftDragSpeed: 80, + drag: (_event, listener) => { + const delta = listener.modelDelta; + const current = positionProperty.value; + positionProperty.value = clampToField( + current.x + (delta.x / fieldNode.viewSize) * KEYBOARD_SCALE, + current.y + (delta.y / fieldNode.viewSize) * KEYBOARD_SCALE, + ); + }, + }), + ); + + positionProperty.link((position) => { + handle.translation = fieldNode.unitToLocal(position.x, position.y); + }); + + return handle; + }; + + const startHandle = makeHandle(model.crossSectionStartProperty, a11y.controls.crossSectionStartStringProperty); + const endHandle = makeHandle(model.crossSectionEndProperty, a11y.controls.crossSectionEndStringProperty); + this.addChild(startHandle); + this.addChild(endHandle); + this.handles = [startHandle, endHandle]; + + const redraw = (): void => { + const start = fieldNode.unitToLocal( + model.crossSectionStartProperty.value.x, + model.crossSectionStartProperty.value.y, + ); + const end = fieldNode.unitToLocal(model.crossSectionEndProperty.value.x, model.crossSectionEndProperty.value.y); + line.shape = new Shape().moveTo(start.x, start.y).lineTo(end.x, end.y); + }; + model.crossSectionStartProperty.link(redraw); + model.crossSectionEndProperty.link(redraw); + + model.crossSectionVisibleProperty.link((visible) => { + this.visible = visible; + if (visible) { + model.refreshCrossSection(); + } + }); + + this.mutate(providedOptions ?? {}); + } +} + +HeatTransferNamespace.register("CrossSectionToolNode", CrossSectionToolNode); diff --git a/src/common/view/FieldNode.ts b/src/common/view/FieldNode.ts new file mode 100644 index 0000000..715fe35 --- /dev/null +++ b/src/common/view/FieldNode.ts @@ -0,0 +1,279 @@ +/** + * FieldNode.ts + * + * The field's window into the scene graph. + * + * This is the whole of the Scenery/WebGPU boundary: a single `Image` wrapping the + * canvas the field engine renders into, plus the input that turns pointer and + * keyboard gestures into brush strokes in unit-square coordinates. There is one + * node here no matter how many cells the grid has — a 1024 x 1024 field is the + * same one node as a 128 x 128 one, which is the entire reason the field is a GPU + * texture rather than a lattice of Rectangles. + * + * Scenery's `Image` only ever advertises the Canvas and WebGL renderers for a + * canvas source (never SVG, which would have to re-encode a data URL every + * frame), so compositing the engine's output costs one `drawImage` per frame. + * + * Keyboard parity + * ─────────────── + * Painting must not require a pointer. When the field has focus, the arrow keys + * move a visible paint cursor and space or enter deposits a stroke there; holding + * shift moves the cursor in finer increments. The cursor is the keyboard's + * equivalent of the hover ring, so both routes show the student the same thing + * before they commit to it. + */ + +import { DerivedProperty, type TReadOnlyProperty } from "scenerystack/axon"; +import { Vector2 } from "scenerystack/dot"; +import { Shape } from "scenerystack/kite"; +import { + Circle, + DragListener, + Image, + KeyboardListener, + Node, + type NodeOptions, + Path, + Rectangle, +} from "scenerystack/scenery"; +import HeatTransferColors from "../../HeatTransferColors.js"; +import { FIELD_VIEW_SIZE } from "../../HeatTransferConstants.js"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import type { FieldSimulationModel } from "../model/FieldSimulationModel.js"; + +/** Fraction of the field the keyboard cursor moves per arrow-key press. */ +const KEYBOARD_STEP = 0.05; + +/** Finer keyboard step, used while shift is held. */ +const KEYBOARD_FINE_STEP = 0.01; + +export type FieldNodeOptions = NodeOptions & { + /** Accessible name for the field itself. */ + accessibleName: TReadOnlyProperty<string>; + /** Accessible help text describing how to paint. */ + accessibleHelpText: TReadOnlyProperty<string>; + /** Whether the brush is available on this screen. */ + paintingEnabled?: boolean; +}; + +export class FieldNode extends Node { + /** Side length of the field on screen, in view coordinates. */ + public readonly viewSize: number; + + private readonly model: FieldSimulationModel; + private readonly fieldImage: Image; + + /** Ring showing where a pointer stroke would land. */ + private readonly hoverRing: Circle; + + /** Cross-hair showing where a keyboard stroke would land. */ + private readonly keyboardCursor: Path; + + /** Keyboard paint position, in the unit square. */ + private keyboardPosition = new Vector2(0.5, 0.5); + + public constructor(model: FieldSimulationModel, providedOptions: FieldNodeOptions) { + super(); + + this.model = model; + this.viewSize = FIELD_VIEW_SIZE; + const paintingEnabled = providedOptions.paintingEnabled ?? true; + + // ── The field ───────────────────────────────────────────────────────────── + // One Image over the engine's canvas, scaled from its device-pixel backing + // size to the view's layout size. + + const canvas = model.engine.canvas; + this.fieldImage = new Image(canvas, { + scale: this.viewSize / canvas.width, + }); + this.addChild(this.fieldImage); + + const border = new Rectangle(0, 0, this.viewSize, this.viewSize, { + stroke: HeatTransferColors.fieldBorderColorProperty, + lineWidth: 1.5, + pickable: false, + }); + this.addChild(border); + + // ── Brush affordances ───────────────────────────────────────────────────── + + this.hoverRing = new Circle(this.brushRadiusInView(), { + stroke: HeatTransferColors.brushOutlineColorProperty, + lineWidth: 1.5, + lineDash: [4, 3], + visible: false, + pickable: false, + }); + this.addChild(this.hoverRing); + + this.keyboardCursor = new Path(null, { + stroke: HeatTransferColors.brushOutlineColorProperty, + lineWidth: 2, + visible: false, + pickable: false, + }); + this.addChild(this.keyboardCursor); + + model.brushRadiusProperty.link(() => { + this.hoverRing.radius = this.brushRadiusInView(); + this.updateKeyboardCursor(); + }); + + // ── Input ───────────────────────────────────────────────────────────────── + + if (paintingEnabled) { + this.cursor = "crosshair"; + this.addInputListener( + new DragListener({ + press: (event) => { + this.paintAtGlobalPoint(event.pointer.point); + }, + drag: (event) => { + this.paintAtGlobalPoint(event.pointer.point); + }, + }), + ); + + this.addInputListener({ + enter: (event) => { + this.hoverRing.visible = true; + this.moveHoverRing(event.pointer.point); + }, + move: (event) => { + this.moveHoverRing(event.pointer.point); + }, + exit: () => { + this.hoverRing.visible = false; + }, + }); + + this.addInputListener({ + focus: () => { + this.keyboardCursor.visible = true; + this.updateKeyboardCursor(); + }, + blur: () => { + this.keyboardCursor.visible = false; + }, + }); + + this.addInputListener( + new KeyboardListener({ + keys: [ + "arrowLeft", + "arrowRight", + "arrowUp", + "arrowDown", + "shift+arrowLeft", + "shift+arrowRight", + "shift+arrowUp", + "shift+arrowDown", + "space", + "enter", + ], + fire: (_event, keysPressed) => { + this.handleKey(keysPressed); + }, + }), + ); + } + + this.mutate({ + tagName: "div", + focusable: paintingEnabled, + ...providedOptions, + }); + } + + /** Pushes the latest engine output to the display. Called once per frame by the ScreenView. */ + public updateImage(): void { + this.fieldImage.invalidateImage(); + } + + // ── Coordinate conversion ─────────────────────────────────────────────────── + + /** Converts a global point to unit-square field coordinates, clamped to the field. */ + private globalToUnit(globalPoint: Vector2): Vector2 { + const local = this.globalToLocalPoint(globalPoint); + return new Vector2( + Math.min(1, Math.max(0, local.x / this.viewSize)), + Math.min(1, Math.max(0, local.y / this.viewSize)), + ); + } + + /** Converts unit-square field coordinates to this node's local frame. */ + public unitToLocal(u: number, v: number): Vector2 { + return new Vector2(u * this.viewSize, v * this.viewSize); + } + + private brushRadiusInView(): number { + return this.model.brushRadiusProperty.value * this.viewSize; + } + + // ── Painting ──────────────────────────────────────────────────────────────── + + private paintAtGlobalPoint(globalPoint: Vector2): void { + const unit = this.globalToUnit(globalPoint); + this.model.paintAt(unit.x, unit.y); + this.moveHoverRing(globalPoint); + } + + private moveHoverRing(globalPoint: Vector2): void { + const unit = this.globalToUnit(globalPoint); + this.hoverRing.translation = this.unitToLocal(unit.x, unit.y); + } + + private handleKey(keysPressed: string): void { + if (keysPressed === "space" || keysPressed === "enter") { + this.model.paintAt(this.keyboardPosition.x, this.keyboardPosition.y); + return; + } + + const step = keysPressed.startsWith("shift+") ? KEYBOARD_FINE_STEP : KEYBOARD_STEP; + const key = keysPressed.replace("shift+", ""); + const delta = + key === "arrowLeft" + ? new Vector2(-step, 0) + : key === "arrowRight" + ? new Vector2(step, 0) + : key === "arrowUp" + ? new Vector2(0, -step) + : new Vector2(0, step); + + this.keyboardPosition = new Vector2( + Math.min(1, Math.max(0, this.keyboardPosition.x + delta.x)), + Math.min(1, Math.max(0, this.keyboardPosition.y + delta.y)), + ); + this.updateKeyboardCursor(); + } + + private updateKeyboardCursor(): void { + const centre = this.unitToLocal(this.keyboardPosition.x, this.keyboardPosition.y); + const radius = this.brushRadiusInView(); + const arm = Math.max(8, radius); + + this.keyboardCursor.shape = new Shape() + .moveTo(centre.x - arm, centre.y) + .lineTo(centre.x + arm, centre.y) + .moveTo(centre.x, centre.y - arm) + .lineTo(centre.x, centre.y + arm) + .moveTo(centre.x + radius, centre.y) + .arc(centre.x, centre.y, radius, 0, 2 * Math.PI); + } + + /** + * A live description of the field for the screen summary: how hot the hottest + * and coldest parts currently are. + */ + public static createStateDescription( + model: FieldSimulationModel, + pattern: (minCelsius: number, maxCelsius: number) => string, + ): TReadOnlyProperty<string> { + return new DerivedProperty([model.minTemperatureProperty, model.maxTemperatureProperty], (min, max) => + pattern(min, max), + ); + } +} + +HeatTransferNamespace.register("FieldNode", FieldNode); diff --git a/src/common/view/FieldScreenView.ts b/src/common/view/FieldScreenView.ts new file mode 100644 index 0000000..7d970dc --- /dev/null +++ b/src/common/view/FieldScreenView.ts @@ -0,0 +1,308 @@ +/** + * FieldScreenView.ts + * + * The layout and frame loop every screen shares. + * + * All five screens are the same picture — a square field, a legend beside it, two + * columns of controls, a clock underneath — differing only in which panels appear + * and which layers are on. Putting that skeleton here means a screen file is a + * short list of what it offers, and it guarantees that a control means the same + * thing and sits in the same place wherever a student meets it. + * + * The frame loop + * ────────────── + * `step(dt)` does three things in a fixed order: + * + * 1. advance the model, which advances the fields on the GPU + * 2. run the visualization passes over the new state + * 3. tell Scenery the canvas changed + * + * Step 2 reads the overlay colours out of `HeatTransferColors` each frame, which + * is what lets the WGSL render passes follow the active colour profile without + * knowing profiles exist. + */ + +import type { TReadOnlyProperty } from "scenerystack/axon"; +import type { Color } from "scenerystack/scenery"; +import { Node, Rectangle, Text, VBox } from "scenerystack/scenery"; +import { PhetFont, ResetAllButton, TimeControlNode } from "scenerystack/scenery-phet"; +import { ScreenView, type ScreenViewOptions } from "scenerystack/sim"; +import { RectangularPushButton } from "scenerystack/sun"; +import HeatTransferColors from "../../HeatTransferColors.js"; +import { + CONTROL_COLUMN_LEFT, + CONTROL_COLUMN_RIGHT, + FIELD_VIEW_LEFT, + FIELD_VIEW_SIZE, + FIELD_VIEW_TOP, + ISOTHERM_INTERVAL_K, + LABEL_FONT_SIZE, + LEGEND_LEFT, + MAX_TEMPERATURE_K, + MIN_TEMPERATURE_K, + PANEL_SPACING, + SCREEN_VIEW_MARGIN, + WIDE_AREA_TOP, +} from "../../HeatTransferConstants.js"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { Rgb } from "../field/ColorMap.js"; +import type { FieldRenderStyle } from "../field/FieldEngine.js"; +import { + FLAT_PLAY_PAUSE_STEP_BUTTON_OPTIONS, + FLAT_RECTANGULAR_BUTTON_OPTIONS, + FLAT_RESET_ALL_BUTTON_OPTIONS, + TIME_CONTROL_SPEED_RADIO_OPTIONS, +} from "../HeatTransferButtonOptions.js"; +import type { FieldSimulationModel } from "../model/FieldSimulationModel.js"; +import { CrossSectionToolNode } from "./CrossSectionToolNode.js"; +import { FieldNode } from "./FieldNode.js"; +import { FieldStatusNode } from "./FieldStatusNode.js"; +import { ProbeNode } from "./ProbeNode.js"; +import { TemperatureLegendNode } from "./TemperatureLegendNode.js"; + +export type FieldScreenViewOptions = ScreenViewOptions & { + /** Accessible name for the field itself. */ + fieldAccessibleName: TReadOnlyProperty<string>; + /** Accessible help text for the field. */ + fieldAccessibleHelpText: TReadOnlyProperty<string>; + /** Whether the heat/material brush is available on this screen. */ + paintingEnabled?: boolean; + /** Whether the probe tool is created. */ + probeEnabled?: boolean; + /** Whether the cross-section tool is created. */ + crossSectionEnabled?: boolean; + /** Whether the field-engine status line is shown, from Preferences. */ + showFieldStatusProperty: TReadOnlyProperty<boolean>; +}; + +export abstract class FieldScreenView extends ScreenView { + protected readonly model: FieldSimulationModel; + protected readonly fieldNode: FieldNode; + + /** Parent for combo-box popup lists, kept above every panel. */ + protected readonly comboBoxLayer: Node; + + /** Left control column. Subclasses push panels into it before calling `finishLayout`. */ + protected readonly leftColumn: VBox; + + /** Right control column. */ + protected readonly rightColumn: VBox; + + /** Full-width area under both columns, for anything too wide for one column. */ + protected readonly wideArea: VBox; + + protected readonly probeNode: ProbeNode | null; + protected readonly crossSectionNode: CrossSectionToolNode | null; + + private readonly resetAllButton: ResetAllButton; + private readonly clearFieldButton: RectangularPushButton; + private readonly timeControlNode: TimeControlNode; + + protected constructor(model: FieldSimulationModel, providedOptions: FieldScreenViewOptions) { + super(providedOptions); + + this.model = model; + const strings = StringManager.getInstance(); + const controls = strings.getControls(); + const a11y = strings.getSharedA11yStrings(); + + // ── Background ──────────────────────────────────────────────────────────── + + this.addChild( + new Rectangle(0, 0, this.layoutBounds.width, this.layoutBounds.height, { + fill: HeatTransferColors.backgroundColorProperty, + pickable: false, + }), + ); + + // ── The field ───────────────────────────────────────────────────────────── + + this.fieldNode = new FieldNode(model, { + x: FIELD_VIEW_LEFT, + y: FIELD_VIEW_TOP, + accessibleName: providedOptions.fieldAccessibleName, + accessibleHelpText: providedOptions.fieldAccessibleHelpText, + paintingEnabled: providedOptions.paintingEnabled ?? true, + }); + this.addChild(this.fieldNode); + + // ── Tools, drawn in the field's coordinate frame ────────────────────────── + + this.probeNode = (providedOptions.probeEnabled ?? true) ? new ProbeNode(model, this.fieldNode) : null; + if (this.probeNode) { + this.fieldNode.addChild(this.probeNode); + } + + this.crossSectionNode = providedOptions.crossSectionEnabled + ? new CrossSectionToolNode(model, this.fieldNode) + : null; + if (this.crossSectionNode) { + this.fieldNode.addChild(this.crossSectionNode); + } + + // ── Legend ──────────────────────────────────────────────────────────────── + + this.addChild( + new TemperatureLegendNode({ + x: LEGEND_LEFT, + y: FIELD_VIEW_TOP, + barHeight: FIELD_VIEW_SIZE, + minTemperatureProperty: model.minTemperatureProperty, + maxTemperatureProperty: model.maxTemperatureProperty, + }), + ); + + // ── Status line ─────────────────────────────────────────────────────────── + + const statusNode = new FieldStatusNode(model, { + left: FIELD_VIEW_LEFT, + top: FIELD_VIEW_TOP + FIELD_VIEW_SIZE + 8, + visibleProperty: providedOptions.showFieldStatusProperty, + }); + this.addChild(statusNode); + + // ── Control columns ─────────────────────────────────────────────────────── + + this.leftColumn = new VBox({ + align: "left", + spacing: PANEL_SPACING, + x: CONTROL_COLUMN_LEFT, + y: FIELD_VIEW_TOP, + }); + this.rightColumn = new VBox({ + align: "left", + spacing: PANEL_SPACING, + x: CONTROL_COLUMN_RIGHT, + y: FIELD_VIEW_TOP, + }); + this.wideArea = new VBox({ + align: "left", + spacing: PANEL_SPACING, + x: CONTROL_COLUMN_LEFT, + y: WIDE_AREA_TOP, + }); + this.addChild(this.leftColumn); + this.addChild(this.rightColumn); + this.addChild(this.wideArea); + + // ── Clock and buttons ───────────────────────────────────────────────────── + + this.timeControlNode = new TimeControlNode(model.isPlayingProperty, { + timeSpeedProperty: model.timeSpeedProperty, + playPauseStepButtonOptions: { + ...FLAT_PLAY_PAUSE_STEP_BUTTON_OPTIONS, + stepForwardButtonOptions: { + ...FLAT_PLAY_PAUSE_STEP_BUTTON_OPTIONS.stepForwardButtonOptions, + listener: () => { + model.stepOnce(); + }, + }, + }, + ...TIME_CONTROL_SPEED_RADIO_OPTIONS, + centerX: FIELD_VIEW_LEFT + FIELD_VIEW_SIZE / 2, + top: FIELD_VIEW_TOP + FIELD_VIEW_SIZE + 30, + }); + this.addChild(this.timeControlNode); + + this.clearFieldButton = new RectangularPushButton({ + ...FLAT_RECTANGULAR_BUTTON_OPTIONS, + content: new Text(controls.clearFieldStringProperty, { + font: new PhetFont(LABEL_FONT_SIZE), + fill: HeatTransferColors.controlSurfaceTextColorProperty, + maxWidth: 120, + }), + baseColor: HeatTransferColors.controlSurfaceColorProperty, + listener: () => { + model.engine.resetField(model.config.initialCondition); + }, + accessibleName: controls.clearFieldStringProperty, + accessibleHelpText: a11y.controls.clearFieldStringProperty, + left: LEGEND_LEFT, + centerY: this.timeControlNode.centerY, + }); + this.addChild(this.clearFieldButton); + + this.resetAllButton = new ResetAllButton({ + ...FLAT_RESET_ALL_BUTTON_OPTIONS, + listener: () => { + model.reset(); + this.reset(); + }, + right: this.layoutBounds.maxX - SCREEN_VIEW_MARGIN, + bottom: this.layoutBounds.maxY - SCREEN_VIEW_MARGIN, + }); + this.addChild(this.resetAllButton); + + // Combo-box popups must sit above every panel, so their parent is added last. + this.comboBoxLayer = new Node(); + this.addChild(this.comboBoxLayer); + } + + /** + * Sets the keyboard traversal order. Subclasses call this at the end of their + * constructor with their own interactive nodes; the field comes first and Reset + * All comes last, always. + * + * `ScreenView` throws if `pdomOrder` is set on itself, so the order lives on a + * lightweight wrapper node. + */ + protected finishLayout(screenControls: readonly Node[]): void { + const tools: Node[] = []; + if (this.probeNode) { + tools.push(this.probeNode); + } + if (this.crossSectionNode) { + tools.push(...this.crossSectionNode.handles); + } + + this.addChild( + new Node({ + pdomOrder: [ + this.fieldNode, + ...tools, + ...screenControls, + this.timeControlNode, + this.clearFieldButton, + this.resetAllButton, + ], + }), + ); + } + + /** Resets view-side state. Subclasses override and call `super.reset()`. */ + public reset(): void { + // The model owns all persistent state; nothing view-side survives a reset. + } + + /** + * Advance, draw, present. + * + * `step` is called even while the sim is paused, so the render passes still run + * and a layer toggled off-clock takes effect immediately. + */ + public override step(dt: number): void { + this.model.step(dt); + this.model.engine.render(this.model.getLayerVisibility(), this.renderStyle()); + this.fieldNode.updateImage(); + } + + /** Reads the overlay colours out of the active colour profile. */ + private renderStyle(): FieldRenderStyle { + return { + isotherm: toRgb(HeatTransferColors.isothermColorProperty.value), + arrow: toRgb(HeatTransferColors.heatFluxColorProperty.value), + particle: toRgb(HeatTransferColors.particleColorProperty.value), + minTemperature: MIN_TEMPERATURE_K, + maxTemperature: MAX_TEMPERATURE_K, + isothermInterval: ISOTHERM_INTERVAL_K, + }; + } +} + +/** Converts a Scenery `Color` to the 0-1 triple the field engine expects. */ +function toRgb(color: Color): Rgb { + return { red: color.red / 255, green: color.green / 255, blue: color.blue / 255 }; +} + +HeatTransferNamespace.register("FieldScreenView", FieldScreenView); diff --git a/src/common/view/FieldStatusNode.ts b/src/common/view/FieldStatusNode.ts new file mode 100644 index 0000000..4004416 --- /dev/null +++ b/src/common/view/FieldStatusNode.ts @@ -0,0 +1,58 @@ +/** + * FieldStatusNode.ts + * + * A one-line readout of what is actually running: which backend, how many cells, + * and how much simulated time has passed. + * + * This is shown by default rather than hidden behind a debug flag, because in + * this simulation the substrate is part of the subject. A student who switches + * from a 128 x 128 classroom grid to 1024 x 1024 and sees finer structure appear + * has learned something about discretization; one who does not know the grid + * changed has only seen the picture get prettier. It can be turned off in + * Preferences for a cleaner screen. + */ + +import { DerivedProperty, PatternStringProperty } from "scenerystack/axon"; +import { HBox, type NodeOptions, Text } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; +import HeatTransferColors from "../../HeatTransferColors.js"; +import { SMALL_FONT_SIZE } from "../../HeatTransferConstants.js"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import { FieldBackend } from "../field/FieldEngine.js"; +import type { FieldSimulationModel } from "../model/FieldSimulationModel.js"; +import { formatElapsed } from "./formatters.js"; + +export class FieldStatusNode extends HBox { + public constructor(model: FieldSimulationModel, providedOptions?: NodeOptions) { + const strings = StringManager.getInstance(); + const readouts = strings.getReadouts(); + const a11y = strings.getSharedA11yStrings(); + + const backendLabel = + model.backend === FieldBackend.WEBGPU ? readouts.backendWebgpuStringProperty : readouts.backendCpuStringProperty; + + const gridLabel = new PatternStringProperty(readouts.gridStringProperty, { + width: model.effectiveResolution, + height: model.effectiveResolution, + }); + + const elapsedLabel = new PatternStringProperty(readouts.elapsedStringProperty, { + value: new DerivedProperty([model.elapsedTimeProperty], (seconds) => formatElapsed(seconds)), + }); + + const style = { + font: new PhetFont(SMALL_FONT_SIZE), + fill: HeatTransferColors.secondaryTextColorProperty, + }; + + super({ + spacing: 14, + children: [new Text(backendLabel, style), new Text(gridLabel, style), new Text(elapsedLabel, style)], + accessibleParagraph: a11y.controls.fieldStatusStringProperty, + ...providedOptions, + }); + } +} + +HeatTransferNamespace.register("FieldStatusNode", FieldStatusNode); diff --git a/src/common/view/FlowControlPanel.ts b/src/common/view/FlowControlPanel.ts new file mode 100644 index 0000000..a696437 --- /dev/null +++ b/src/common/view/FlowControlPanel.ts @@ -0,0 +1,72 @@ +/** + * FlowControlPanel.ts + * + * The velocity field: which pattern, and how fast. + * + * All the presets except "still" are divergence-free, so choosing one never + * changes how much heat is present — only where it goes. The speed slider scales + * the whole field at once, which is what makes the Peclet readout on the Heat + * Transfer screen a single meaningful number rather than a position-dependent one. + */ + +import { DerivedProperty, PatternStringProperty } from "scenerystack/axon"; +import { Range } from "scenerystack/dot"; +import { type Node, VBox } from "scenerystack/scenery"; +import { MAX_FLOW_SPEED } from "../../HeatTransferConstants.js"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import { FLOW_PRESET_ORDER, type FlowPresetId } from "../field/FieldTypes.js"; +import { HeatTransferPanel, type HeatTransferPanelOptions } from "../HeatTransferPanel.js"; +import type { FieldSimulationModel } from "../model/FieldSimulationModel.js"; +import { labelledSlider, panelTitle, themedComboBox } from "./ControlFactory.js"; +import { formatSpeed } from "./formatters.js"; + +/** Slowest selectable flow, in metres per second. Zero would make the layer pointless. */ +const MIN_FLOW_SPEED = 0.0002; + +export class FlowControlPanel extends HeatTransferPanel { + public readonly controls: readonly Node[]; + + public constructor(model: FieldSimulationModel, listParent: Node, providedOptions?: HeatTransferPanelOptions) { + const strings = StringManager.getInstance(); + const controls = strings.getControls(); + const readouts = strings.getReadouts(); + const flowNames = strings.getFlowNames(); + const a11y = strings.getSharedA11yStrings(); + + const presetComboBox = themedComboBox<FlowPresetId>( + model.flowPresetProperty, + FLOW_PRESET_ORDER, + (id) => flowNames[`${id}StringProperty`], + listParent, + controls.flowPatternStringProperty, + a11y.controls.flowPatternStringProperty, + ); + + const speedReadout = new PatternStringProperty(readouts.speedStringProperty, { + value: new DerivedProperty([model.flowSpeedProperty], (metresPerSecond) => formatSpeed(metresPerSecond)), + }); + + const speedControl = labelledSlider({ + label: controls.flowSpeedStringProperty, + property: model.flowSpeedProperty, + range: new Range(MIN_FLOW_SPEED, MAX_FLOW_SPEED), + accessibleName: controls.flowSpeedStringProperty, + accessibleHelpText: a11y.controls.flowSpeedStringProperty, + readout: speedReadout, + }); + + super( + new VBox({ + align: "left", + spacing: 9, + children: [panelTitle(controls.flowStringProperty), presetComboBox, speedControl.node], + }), + providedOptions, + ); + + this.controls = [presetComboBox, speedControl.slider]; + } +} + +HeatTransferNamespace.register("FlowControlPanel", FlowControlPanel); diff --git a/src/common/view/HeatBrushKeyboardHelpSection.ts b/src/common/view/HeatBrushKeyboardHelpSection.ts new file mode 100644 index 0000000..6fc6925 --- /dev/null +++ b/src/common/view/HeatBrushKeyboardHelpSection.ts @@ -0,0 +1,33 @@ +/** + * HeatBrushKeyboardHelpSection.ts + * + * The keyboard-help entry for painting on the field. + * + * The field is the one interactive surface in this simulation that has no + * standard PhET analogue — it is not a slider, a combo box, or a draggable + * object, but a continuous canvas you deposit into. So it needs its own help + * section describing the paint cursor, which is the keyboard's stand-in for a + * pointer position. Every other interaction in the sim is a standard control and + * is covered by the stock sections. + */ + +import { KeyboardHelpIconFactory, KeyboardHelpSection, KeyboardHelpSectionRow } from "scenerystack/scenery-phet"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import { StringManager } from "../../i18n/StringManager.js"; + +export class HeatBrushKeyboardHelpSection extends KeyboardHelpSection { + public constructor() { + const help = StringManager.getInstance().getKeyboardHelp(); + + super(help.titleStringProperty, [ + KeyboardHelpSectionRow.labelWithIcon(help.moveCursorStringProperty, KeyboardHelpIconFactory.arrowKeysRowIcon()), + KeyboardHelpSectionRow.labelWithIcon( + help.moveCursorSlowerStringProperty, + KeyboardHelpIconFactory.shiftPlusIcon(KeyboardHelpIconFactory.arrowKeysRowIcon()), + ), + KeyboardHelpSectionRow.labelWithIcon(help.paintStringProperty, KeyboardHelpIconFactory.spaceOrEnter()), + ]); + } +} + +HeatTransferNamespace.register("HeatBrushKeyboardHelpSection", HeatBrushKeyboardHelpSection); diff --git a/src/common/view/LayerControlPanel.ts b/src/common/view/LayerControlPanel.ts new file mode 100644 index 0000000..38bd542 --- /dev/null +++ b/src/common/view/LayerControlPanel.ts @@ -0,0 +1,98 @@ +/** + * LayerControlPanel.ts + * + * Checkboxes for the visualization layers. + * + * The panel deliberately reads as a list of *views* rather than a list of + * options: every checkbox here changes which render pass runs over the current + * GPU state and nothing else. Turning on heat flux does not start computing heat + * flux — the gradient was always there — it starts drawing it. That is the + * distinction the Heat Transfer screen is built to teach, so the UI should not + * blur it by mixing a simulation setting into this group. + */ + +import type { BooleanProperty } from "scenerystack/axon"; +import { Text, VBox } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; +import { Checkbox } from "scenerystack/sun"; +import HeatTransferColors from "../../HeatTransferColors.js"; +import { CONTROL_PANEL_WIDTH, LABEL_FONT_SIZE, TITLE_FONT_SIZE } from "../../HeatTransferConstants.js"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import { HeatTransferPanel, type HeatTransferPanelOptions } from "../HeatTransferPanel.js"; +import type { FieldSimulationModel } from "../model/FieldSimulationModel.js"; + +/** Which layer checkboxes a screen shows, in display order. */ +export type LayerControlId = "temperature" | "isotherms" | "heatFlux" | "velocity" | "gradient" | "material"; + +export class LayerControlPanel extends HeatTransferPanel { + /** The checkboxes, in display order, so the ScreenView can put them in `pdomOrder`. */ + public readonly checkboxes: Checkbox[]; + + /** + * @param model - the screen's field model + * @param layers - which checkboxes to include; screens show only the layers they are about + * @param extras - further checkboxes appended below the layers (the probe toggle, + * which is a view option too and does not deserve a panel of its own) + * @param providedOptions - ordinary Panel options + */ + public constructor( + model: FieldSimulationModel, + layers: readonly LayerControlId[], + extras: readonly Checkbox[] = [], + providedOptions?: HeatTransferPanelOptions, + ) { + const strings = StringManager.getInstance(); + const controls = strings.getControls(); + const a11y = strings.getSharedA11yStrings(); + + const sources: Record< + LayerControlId, + { property: BooleanProperty; label: typeof controls.temperatureLayerStringProperty } + > = { + temperature: { property: model.temperatureLayerProperty, label: controls.temperatureLayerStringProperty }, + isotherms: { property: model.isothermLayerProperty, label: controls.isothermLayerStringProperty }, + heatFlux: { property: model.heatFluxLayerProperty, label: controls.heatFluxLayerStringProperty }, + velocity: { property: model.velocityLayerProperty, label: controls.velocityLayerStringProperty }, + gradient: { property: model.gradientLayerProperty, label: controls.gradientLayerStringProperty }, + material: { property: model.materialLayerProperty, label: controls.materialLayerStringProperty }, + }; + + const checkboxes = layers.map((id) => { + const source = sources[id]; + return new Checkbox( + source.property, + new Text(source.label, { + font: new PhetFont(LABEL_FONT_SIZE), + fill: HeatTransferColors.textColorProperty, + maxWidth: CONTROL_PANEL_WIDTH - 60, + }), + { + checkboxColor: HeatTransferColors.textColorProperty, + checkboxColorBackground: HeatTransferColors.panelBackgroundColorProperty, + spacing: 8, + accessibleName: source.label, + }, + ); + }); + + const title = new Text(controls.fieldLayersStringProperty, { + font: new PhetFont({ size: TITLE_FONT_SIZE, weight: "bold" }), + fill: HeatTransferColors.textColorProperty, + maxWidth: CONTROL_PANEL_WIDTH - 30, + }); + + const content = new VBox({ + align: "left", + spacing: 7, + children: [title, ...checkboxes, ...extras], + accessibleHeading: controls.fieldLayersStringProperty, + accessibleHelpText: a11y.controls.layersStringProperty, + }); + + super(content, providedOptions); + this.checkboxes = [...checkboxes, ...extras]; + } +} + +HeatTransferNamespace.register("LayerControlPanel", LayerControlPanel); diff --git a/src/common/view/MaterialControlPanel.ts b/src/common/view/MaterialControlPanel.ts new file mode 100644 index 0000000..91f3290 --- /dev/null +++ b/src/common/view/MaterialControlPanel.ts @@ -0,0 +1,100 @@ +/** + * MaterialControlPanel.ts + * + * Choosing the plate's material, and seeing what that choice means. + * + * The preset combo box is the primary control, and the two readouts underneath + * are the reason: `k` is the number in Fourier's law, `alpha = k / (rho c_p)` is + * the number that governs how fast the field actually changes, and they do *not* + * order materials the same way. Foam has the lowest conductivity in the list but a + * higher diffusivity than wood. Showing both, live, is what makes that visible + * instead of surprising. + * + * Optionally the panel also carries the edge-condition control, since "what is + * the plate made of" and "what happens at its edges" are the two things that + * close the heat equation. + */ + +import { DerivedProperty, PatternStringProperty } from "scenerystack/axon"; +import { type Node, VBox } from "scenerystack/scenery"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import { BOUNDARY_CONDITION_ORDER, type BoundaryConditionId, thermalDiffusivity } from "../field/FieldTypes.js"; +import { MATERIAL_ORDER, MATERIALS, type MaterialIdValue, withAnisotropy } from "../field/Materials.js"; +import { HeatTransferPanel, type HeatTransferPanelOptions } from "../HeatTransferPanel.js"; +import type { FieldSimulationModel } from "../model/FieldSimulationModel.js"; +import { panelReadout, panelTitle, themedComboBox } from "./ControlFactory.js"; +import { formatConductivity, formatDiffusivity } from "./formatters.js"; + +export type MaterialControlPanelOptions = HeatTransferPanelOptions; + +export class MaterialControlPanel extends HeatTransferPanel { + public readonly controls: readonly Node[]; + + /** + * @param model - the screen's field model + * @param listParent - a node high in the scene graph for the combo-box popups + * @param includeEdges - also show the boundary-condition combo box + * @param providedOptions - ordinary Panel options + */ + public constructor( + model: FieldSimulationModel, + listParent: Node, + includeEdges: boolean, + providedOptions?: MaterialControlPanelOptions, + ) { + const strings = StringManager.getInstance(); + const controls = strings.getControls(); + const readouts = strings.getReadouts(); + const materialNames = strings.getMaterialNames(); + const edgeNames = strings.getEdgeNames(); + const a11y = strings.getSharedA11yStrings(); + + const materialComboBox = themedComboBox<MaterialIdValue>( + model.materialIdProperty, + MATERIAL_ORDER, + (id) => materialNames[`${id}StringProperty`], + listParent, + controls.materialStringProperty, + a11y.controls.materialStringProperty, + ); + + // Both readouts follow the anisotropy control too, because an anisotropic + // material has a different diffusivity along each axis and the readout should + // not quietly keep showing the isotropic value. + const conductivityText = new PatternStringProperty(readouts.conductivityStringProperty, { + value: new DerivedProperty([model.materialIdProperty], (id) => formatConductivity(MATERIALS[id].conductivity)), + }); + const diffusivityText = new PatternStringProperty(readouts.diffusivityStringProperty, { + value: new DerivedProperty([model.materialIdProperty, model.anisotropyProperty], (id, anisotropy) => + formatDiffusivity(thermalDiffusivity(withAnisotropy(MATERIALS[id], anisotropy))), + ), + }); + + const children: Node[] = [ + panelTitle(controls.materialStringProperty), + materialComboBox, + panelReadout(conductivityText), + panelReadout(diffusivityText), + ]; + const interactive: Node[] = [materialComboBox]; + + if (includeEdges) { + const edgeComboBox = themedComboBox<BoundaryConditionId>( + model.boundaryConditionProperty, + BOUNDARY_CONDITION_ORDER, + (id) => edgeNames[`${id}StringProperty`], + listParent, + controls.edgesStringProperty, + a11y.controls.edgesStringProperty, + ); + children.push(panelTitle(controls.edgesStringProperty), edgeComboBox); + interactive.push(edgeComboBox); + } + + super(new VBox({ align: "left", spacing: 8, children }), providedOptions); + this.controls = interactive; + } +} + +HeatTransferNamespace.register("MaterialControlPanel", MaterialControlPanel); diff --git a/src/common/view/ProbeNode.ts b/src/common/view/ProbeNode.ts new file mode 100644 index 0000000..0e5cec0 --- /dev/null +++ b/src/common/view/ProbeNode.ts @@ -0,0 +1,141 @@ +/** + * ProbeNode.ts + * + * A draggable point sampler with a live temperature readout. + * + * The lesson this tool carries is that *every* point of the surface has a + * temperature — the colour is not decoration over a few hot spots, it is a + * function defined everywhere. So the readout stays visible while the probe + * moves, updates continuously as the field evolves under a stationary probe, and + * never snaps to a cell: it reads the bilinearly interpolated value, the same one + * the renderer shades with. + * + * On the WebGPU backend the sample comes from the CPU mirror of the temperature + * texture, which is refreshed every few frames. A student cannot perceive the + * lag; a pipeline stall every frame would be very perceptible indeed. + */ + +import { DerivedProperty, PatternStringProperty } from "scenerystack/axon"; +import { Vector2 } from "scenerystack/dot"; +import { Shape } from "scenerystack/kite"; +import { Circle, DragListener, KeyboardDragListener, Node, type NodeOptions, Path, Text } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; +import HeatTransferColors from "../../HeatTransferColors.js"; +import { LABEL_FONT_SIZE } from "../../HeatTransferConstants.js"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { FieldSimulationModel } from "../model/FieldSimulationModel.js"; +import type { FieldNode } from "./FieldNode.js"; +import { formatCelsius } from "./formatters.js"; + +/** Radius of the probe's ring, in view coordinates. */ +const RING_RADIUS = 9; + +/** Height of the readout bubble. */ +const BUBBLE_HEIGHT = 26; + +/** Fraction of the field a keyboard arrow press moves the probe. */ +const KEYBOARD_STEP = 0.02; + +export class ProbeNode extends Node { + public constructor(model: FieldSimulationModel, fieldNode: FieldNode, providedOptions?: NodeOptions) { + super(); + + const strings = StringManager.getInstance(); + const a11y = strings.getSharedA11yStrings(); + + // ── Readout bubble ──────────────────────────────────────────────────────── + + const readoutText = new PatternStringProperty(strings.getReadouts().celsiusStringProperty, { + value: new DerivedProperty([model.probeTemperatureProperty], (kelvin) => formatCelsius(kelvin)), + }); + + const label = new Text(readoutText, { + font: new PhetFont({ size: LABEL_FONT_SIZE, weight: "bold" }), + fill: HeatTransferColors.controlSurfaceTextColorProperty, + }); + + const bubble = new Path(null, { + fill: HeatTransferColors.controlSurfaceColorProperty, + stroke: HeatTransferColors.probeColorProperty, + lineWidth: 1.5, + }); + + // The bubble is redrawn rather than scaled so its corner radius stays constant + // as the number inside changes width. + label.boundsProperty.link(() => { + const width = Math.max(56, label.width + 16); + bubble.shape = Shape.roundRectangle(-width / 2, -BUBBLE_HEIGHT - RING_RADIUS - 6, width, BUBBLE_HEIGHT, 5, 5); + label.centerX = 0; + label.centerY = -BUBBLE_HEIGHT / 2 - RING_RADIUS - 6; + }); + + // ── Crosshair ───────────────────────────────────────────────────────────── + + const ring = new Circle(RING_RADIUS, { + stroke: HeatTransferColors.probeColorProperty, + lineWidth: 2.5, + }); + const crosshair = new Path( + new Shape() + .moveTo(-RING_RADIUS - 5, 0) + .lineTo(RING_RADIUS + 5, 0) + .moveTo(0, -RING_RADIUS - 5) + .lineTo(0, RING_RADIUS + 5), + { stroke: HeatTransferColors.probeColorProperty, lineWidth: 1.5 }, + ); + + this.children = [bubble, label, ring, crosshair]; + + // ── Position ────────────────────────────────────────────────────────────── + + model.probePositionProperty.link((position) => { + this.translation = fieldNode.unitToLocal(position.x, position.y); + }); + + const moveTo = (localPoint: Vector2): void => { + model.probePositionProperty.value = new Vector2( + Math.min(1, Math.max(0, localPoint.x / fieldNode.viewSize)), + Math.min(1, Math.max(0, localPoint.y / fieldNode.viewSize)), + ); + }; + + this.addInputListener( + new DragListener({ + drag: (event) => { + moveTo(fieldNode.globalToLocalPoint(event.pointer.point)); + }, + }), + ); + + this.addInputListener( + new KeyboardDragListener({ + dragSpeed: 300, + shiftDragSpeed: 80, + drag: (_event, listener) => { + const delta = listener.modelDelta; + const current = model.probePositionProperty.value; + model.probePositionProperty.value = new Vector2( + Math.min(1, Math.max(0, current.x + (delta.x / fieldNode.viewSize) * KEYBOARD_STEP * 50)), + Math.min(1, Math.max(0, current.y + (delta.y / fieldNode.viewSize) * KEYBOARD_STEP * 50)), + ); + }, + }), + ); + + this.mutate({ + cursor: "pointer", + tagName: "div", + focusable: true, + accessibleName: a11y.controls.probeStringProperty, + accessibleHelpText: a11y.controls.probeHelpStringProperty, + ...providedOptions, + }); + + model.probeVisibleProperty.link((visible) => { + this.visible = visible; + }); + } +} + +HeatTransferNamespace.register("ProbeNode", ProbeNode); diff --git a/src/common/view/TemperatureLegendNode.ts b/src/common/view/TemperatureLegendNode.ts new file mode 100644 index 0000000..52723a3 --- /dev/null +++ b/src/common/view/TemperatureLegendNode.ts @@ -0,0 +1,132 @@ +/** + * TemperatureLegendNode.ts + * + * The key that makes the colour map quantitative. + * + * The bar is drawn from the same {@link TEMPERATURE_COLOR_STOPS} the field + * renderer uses, so it cannot disagree with the field: adding a stop changes both + * at once. Live markers track the current coldest and hottest points in the + * field, which turns the legend into a readout as well as a key — a student can + * see the range narrow as the plate equilibrates without reading a number. + */ + +import { DerivedProperty, type TReadOnlyProperty } from "scenerystack/axon"; +import { Shape } from "scenerystack/kite"; +import { LinearGradient, Node, type NodeOptions, Path, Rectangle, Text } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; +import HeatTransferColors from "../../HeatTransferColors.js"; +import { + KELVIN_TO_CELSIUS_OFFSET, + MAX_TEMPERATURE_K, + MIN_TEMPERATURE_K, + SMALL_FONT_SIZE, +} from "../../HeatTransferConstants.js"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import { rgbToCss, TEMPERATURE_COLOR_STOPS } from "../field/ColorMap.js"; + +/** Width of the colour bar, in view coordinates. */ +const BAR_WIDTH = 22; + +/** Number of labelled ticks along the bar, including both ends. */ +const TICK_COUNT = 5; + +export type TemperatureLegendNodeOptions = NodeOptions & { + /** Height of the colour bar, in view coordinates. */ + barHeight: number; + /** Coldest point in the field, in kelvin. */ + minTemperatureProperty: TReadOnlyProperty<number>; + /** Hottest point in the field, in kelvin. */ + maxTemperatureProperty: TReadOnlyProperty<number>; +}; + +export class TemperatureLegendNode extends Node { + public constructor(providedOptions: TemperatureLegendNodeOptions) { + super(); + + const { barHeight } = providedOptions; + const strings = StringManager.getInstance(); + + // ── Colour bar ──────────────────────────────────────────────────────────── + // Hot at the top, so "up" means "hotter" as it does on a thermometer. The + // gradient runs from y = barHeight (cold) to y = 0 (hot). + + const gradient = new LinearGradient(0, barHeight, 0, 0); + for (const stop of TEMPERATURE_COLOR_STOPS) { + gradient.addColorStop(stop.position, rgbToCss(stop)); + } + + const bar = new Rectangle(0, 0, BAR_WIDTH, barHeight, { + fill: gradient, + stroke: HeatTransferColors.fieldBorderColorProperty, + lineWidth: 1, + }); + this.addChild(bar); + + // ── Ticks ───────────────────────────────────────────────────────────────── + + const tickShape = new Shape(); + for (let n = 0; n < TICK_COUNT; n++) { + const fraction = n / (TICK_COUNT - 1); + const y = barHeight * (1 - fraction); + tickShape.moveTo(BAR_WIDTH, y).lineTo(BAR_WIDTH + 5, y); + + const kelvin = MIN_TEMPERATURE_K + fraction * (MAX_TEMPERATURE_K - MIN_TEMPERATURE_K); + const label = new Text(`${Math.round(kelvin - KELVIN_TO_CELSIUS_OFFSET)}`, { + font: new PhetFont(SMALL_FONT_SIZE), + fill: HeatTransferColors.secondaryTextColorProperty, + left: BAR_WIDTH + 8, + centerY: y, + }); + this.addChild(label); + } + this.addChild( + new Path(tickShape, { + stroke: HeatTransferColors.fieldBorderColorProperty, + lineWidth: 1, + }), + ); + + // ── Title and unit ──────────────────────────────────────────────────────── + + const title = new Text(strings.getLegend().titleStringProperty, { + font: new PhetFont({ size: SMALL_FONT_SIZE, weight: "bold" }), + fill: HeatTransferColors.textColorProperty, + maxWidth: 90, + centerX: BAR_WIDTH / 2, + bottom: -6, + }); + this.addChild(title); + + const unit = new Text("°C", { + font: new PhetFont(SMALL_FONT_SIZE), + fill: HeatTransferColors.secondaryTextColorProperty, + centerX: BAR_WIDTH / 2, + top: barHeight + 5, + }); + this.addChild(unit); + + // ── Live range markers ──────────────────────────────────────────────────── + + const marker = (temperatureProperty: TReadOnlyProperty<number>): Node => { + const shape = new Shape().moveTo(-7, 0).lineTo(0, -4).lineTo(0, 4).close(); + const node = new Path(shape, { fill: HeatTransferColors.textColorProperty }); + const positionProperty = new DerivedProperty([temperatureProperty], (kelvin) => { + const fraction = (kelvin - MIN_TEMPERATURE_K) / (MAX_TEMPERATURE_K - MIN_TEMPERATURE_K); + return barHeight * (1 - Math.min(1, Math.max(0, fraction))); + }); + positionProperty.link((y) => { + node.centerY = y; + node.right = 0; + }); + return node; + }; + + this.addChild(marker(providedOptions.minTemperatureProperty)); + this.addChild(marker(providedOptions.maxTemperatureProperty)); + + this.mutate(providedOptions); + } +} + +HeatTransferNamespace.register("TemperatureLegendNode", TemperatureLegendNode); diff --git a/src/common/view/TransportControlPanel.ts b/src/common/view/TransportControlPanel.ts new file mode 100644 index 0000000..eea3583 --- /dev/null +++ b/src/common/view/TransportControlPanel.ts @@ -0,0 +1,121 @@ +/** + * TransportControlPanel.ts + * + * The Heat Transfer screen's main control: one slider from + * "diffusion dominated" to "advection dominated". + * + * Physically there are two independent knobs — the conductivity multiplier and + * the flow multiplier — and the honest quantity relating them is the Peclet + * number, Pe = U L / alpha. A student who has just met both mechanisms is not yet + * ready to be handed two sliders and asked to reason about their ratio, so this + * control moves both at once, in opposite directions, on a logarithmic scale, and + * reports the Pe that results. + * + * The mapping is deliberately symmetric about the midpoint: + * + * balance 0.0 → conductivity x 1, flow x 0.01 (conduction alone) + * balance 0.5 → conductivity x 0.1, flow x 0.1 (comparable) + * balance 1.0 → conductivity x 0.01, flow x 1 (flow alone) + * + * so sliding it sweeps roughly four decades of Pe while keeping the frame cost + * flat — reducing conductivity raises the stable time step by exactly the factor + * that raising the flow speed lowers it. + */ + +import { DerivedProperty, NumberProperty, PatternStringProperty } from "scenerystack/axon"; +import { Range } from "scenerystack/dot"; +import { type Node, VBox } from "scenerystack/scenery"; +import HeatTransferNamespace from "../../HeatTransferNamespace.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import { HeatTransferPanel, type HeatTransferPanelOptions } from "../HeatTransferPanel.js"; +import type { FieldSimulationModel } from "../model/FieldSimulationModel.js"; +import { labelledSlider, panelReadout, panelTitle } from "./ControlFactory.js"; +import { formatPeclet } from "./formatters.js"; + +/** Decades the balance slider sweeps in each direction. */ +const DECADES = 2; + +/** Peclet numbers below this read as conduction-dominated. */ +const CONDUCTION_THRESHOLD = 1; + +/** Peclet numbers above this read as flow-dominated. */ +const ADVECTION_THRESHOLD = 100; + +export class TransportControlPanel extends HeatTransferPanel { + public readonly controls: readonly Node[]; + + /** 0 is pure diffusion, 1 is pure advection. Owned here because only this screen has it. */ + public readonly balanceProperty: NumberProperty; + + public constructor(model: FieldSimulationModel, providedOptions?: HeatTransferPanelOptions) { + const strings = StringManager.getInstance(); + const controls = strings.getControls(); + const readouts = strings.getReadouts(); + const a11y = strings.getSharedA11yStrings(); + + const balanceProperty = new NumberProperty(0.5); + + const balance = labelledSlider({ + property: balanceProperty, + range: new Range(0, 1), + accessibleName: controls.transportRegimeStringProperty, + accessibleHelpText: a11y.controls.transportRegimeStringProperty, + endLabels: { + left: controls.diffusionDominatedStringProperty, + right: controls.advectionDominatedStringProperty, + }, + }); + + const pecletText = new PatternStringProperty(readouts.pecletStringProperty, { + value: new DerivedProperty([model.pecletNumberProperty], (peclet) => formatPeclet(peclet)), + }); + + // A number is not an interpretation. Name the regime as well as reporting it. + const regimeText = new DerivedProperty( + [ + model.pecletNumberProperty, + readouts.conductionDominatesStringProperty, + readouts.flowDominatesStringProperty, + readouts.comparableStringProperty, + ], + (peclet, conduction, flow, comparable) => { + if (peclet < CONDUCTION_THRESHOLD) { + return conduction; + } + if (peclet > ADVECTION_THRESHOLD) { + return flow; + } + return comparable; + }, + ); + + super( + new VBox({ + align: "left", + spacing: 8, + children: [ + panelTitle(controls.transportRegimeStringProperty), + balance.node, + panelReadout(pecletText), + panelReadout(regimeText), + ], + }), + providedOptions, + ); + + this.balanceProperty = balanceProperty; + this.controls = [balance.slider]; + + // Drive both physical multipliers from the single balance value. + balanceProperty.link((balanceValue) => { + model.diffusionScaleProperty.value = 10 ** (-DECADES * balanceValue); + model.flowScaleProperty.value = 10 ** (DECADES * (balanceValue - 1)); + }); + } + + public reset(): void { + this.balanceProperty.reset(); + } +} + +HeatTransferNamespace.register("TransportControlPanel", TransportControlPanel); diff --git a/src/common/view/formatters.ts b/src/common/view/formatters.ts new file mode 100644 index 0000000..2656506 --- /dev/null +++ b/src/common/view/formatters.ts @@ -0,0 +1,110 @@ +/** + * formatters.ts + * + * Turning physical quantities into short strings a student can read at a glance. + * + * Every quantity in this simulation spans several decades — conductivity runs + * from 0.03 to 401, diffusivity from 1e-7 to 1e-4, the Peclet number from 0 to + * thousands — so a fixed number of decimal places is wrong for most of the range. + * These helpers choose a representation that keeps two or three significant + * figures without ever printing something like `0.000000143`. + * + * The unit text itself lives in the locale files; these produce only the numeric + * part, which is then substituted into a translated pattern. + */ + +import { KELVIN_TO_CELSIUS_OFFSET } from "../../HeatTransferConstants.js"; + +/** Degrees Celsius, to one decimal place. */ +export function formatCelsius(kelvin: number): string { + return (kelvin - KELVIN_TO_CELSIUS_OFFSET).toFixed(1); +} + +/** Degrees Celsius, rounded to a whole number, for summaries and axis ticks. */ +export function formatCelsiusRounded(kelvin: number): string { + return Math.round(kelvin - KELVIN_TO_CELSIUS_OFFSET).toString(); +} + +/** Thermal conductivity in W/(m K): whole numbers for metals, decimals below 10. */ +export function formatConductivity(conductivity: number): string { + if (conductivity >= 10) { + return Math.round(conductivity).toString(); + } + if (conductivity >= 0.1) { + return conductivity.toFixed(2); + } + return conductivity.toFixed(3); +} + +/** Thermal diffusivity in m^2/s, always in scientific notation with two figures. */ +export function formatDiffusivity(diffusivity: number): string { + if (diffusivity <= 0) { + return "0"; + } + const exponent = Math.floor(Math.log10(diffusivity)); + const mantissa = diffusivity / 10 ** exponent; + return `${mantissa.toFixed(1)} × 10${superscript(exponent)}`; +} + +/** Speed in millimetres per second, since the flow speeds here are a few mm/s. */ +export function formatSpeed(metresPerSecond: number): string { + return (metresPerSecond * 1000).toFixed(1); +} + +/** + * The Peclet number. Below 10 it is worth seeing the decimal; above that the + * order of magnitude is the whole message. + */ +export function formatPeclet(peclet: number): string { + if (!Number.isFinite(peclet)) { + return "∞"; + } + if (peclet < 10) { + return peclet.toFixed(1); + } + if (peclet < 1000) { + return Math.round(peclet).toString(); + } + return `${Math.round(peclet / 100) / 10}k`; +} + +/** Elapsed simulated time. Seconds below a minute, then minutes and seconds. */ +export function formatElapsed(seconds: number): string { + if (seconds < 60) { + return seconds.toFixed(1); + } + const minutes = Math.floor(seconds / 60); + return `${minutes}:${Math.floor(seconds % 60) + .toString() + .padStart(2, "0")}`; +} + +/** Heat flux in kW/m^2, to two significant-ish figures. */ +export function formatFlux(wattsPerSquareMetre: number): string { + const kilowatts = wattsPerSquareMetre / 1000; + const magnitude = Math.abs(kilowatts); + if (magnitude >= 100) { + return Math.round(kilowatts).toString(); + } + if (magnitude >= 1) { + return kilowatts.toFixed(1); + } + return kilowatts.toFixed(2); +} + +/** Distance along the cross-section, in millimetres. */ +export function formatMillimetres(metres: number): string { + return (metres * 1000).toFixed(0); +} + +/** Renders an integer exponent with Unicode superscript digits. */ +function superscript(exponent: number): string { + const digits = "⁰¹²³⁴⁵⁶⁷⁸⁹"; + const sign = exponent < 0 ? "⁻" : ""; + const body = Math.abs(exponent) + .toString() + .split("") + .map((digit) => digits[Number(digit)] ?? digit) + .join(""); + return sign + body; +} diff --git a/src/conduction/ConductionScreen.ts b/src/conduction/ConductionScreen.ts new file mode 100644 index 0000000..c412652 --- /dev/null +++ b/src/conduction/ConductionScreen.ts @@ -0,0 +1,54 @@ +/** + * ConductionScreen.ts + * + * Screen 2. Wires the model and view factories together and passes screen-level + * options to `Screen`. + * + * The preferences model rides on the options bag because a screen's field engine + * has to know its grid resolution at construction time, and SceneryStack builds a + * screen's model lazily — the first time a student opens the screen. + * + * Registered in the screens array in src/main.ts. Its home-screen and + * navigation-bar icons come from createConductionIcon() in + * src/common/HeatTransferScreenIcons.ts (see doc/multi-screen.md). + */ +import { type EmptySelfOptions, optionize } from "scenerystack/phet-core"; +import type { ScreenOptions } from "scenerystack/sim"; +import { Screen } from "scenerystack/sim"; +import type { Tandem } from "scenerystack/tandem"; +import { createConductionIcon } from "../common/HeatTransferScreenIcons.js"; +import HeatTransferColors from "../HeatTransferColors.js"; +import type { HeatTransferPreferencesModel } from "../preferences/HeatTransferPreferencesModel.js"; +import { ConductionModel } from "./model/ConductionModel.js"; +import { ConductionKeyboardHelpContent } from "./view/ConductionKeyboardHelpContent.js"; +import { ConductionScreenView } from "./view/ConductionScreenView.js"; + +// Require tandem to be explicit — accidental omission would break PhET-iO. +export type ConductionScreenOptions = ScreenOptions & { + tandem: Tandem; + preferences: HeatTransferPreferencesModel; +}; + +export class ConductionScreen extends Screen<ConductionModel, ConductionScreenView> { + public constructor(options: ConductionScreenOptions) { + super( + // Model factory — called once when the screen is first shown + () => new ConductionModel(options.preferences), + // View factory — receives the model instance + (model) => + new ConductionScreenView(model, { + tandem: options.tandem.createTandem("view"), + showFieldStatusProperty: options.preferences.showFieldStatusProperty, + }), + optionize<ConductionScreenOptions, EmptySelfOptions, ScreenOptions>()( + { + backgroundColorProperty: HeatTransferColors.backgroundColorProperty, + createKeyboardHelpNode: () => new ConductionKeyboardHelpContent(), + homeScreenIcon: createConductionIcon(), + navigationBarIcon: createConductionIcon(), + }, + options, + ), + ); + } +} diff --git a/src/conduction/model/ConductionModel.ts b/src/conduction/model/ConductionModel.ts new file mode 100644 index 0000000..d36f766 --- /dev/null +++ b/src/conduction/model/ConductionModel.ts @@ -0,0 +1,57 @@ +/** + * ConductionModel.ts + * + * Screen 2: temperature differences drive heat flow. + * + * Still no flow, but now the material matters and the derived fields are on + * display. Everything the flux and gradient layers show is computed from the same + * temperature texture the colour map reads — turning them on adds render passes, + * not physics. The default material is copper because its diffusivity makes the + * response fast enough to see, and the interesting comparison is switching to + * glass and watching almost nothing happen. + */ +import type { TModel } from "scenerystack/joist"; +import { BoundaryCondition, FlowPreset, InitialCondition } from "../../common/field/FieldTypes.js"; +import { FieldSimulationModel } from "../../common/model/FieldSimulationModel.js"; +import { FIELD_VIEW_SIZE } from "../../HeatTransferConstants.js"; +import type { HeatTransferPreferencesModel } from "../../preferences/HeatTransferPreferencesModel.js"; + +/** Backing-canvas resolution multiplier, so the field is crisp on high-DPI displays. */ +const CANVAS_SCALE = 2; + +export class ConductionModel implements TModel { + public readonly field: FieldSimulationModel; + + public constructor(preferences: HeatTransferPreferencesModel) { + this.field = new FieldSimulationModel({ + advectionEnabled: false, + boundaryCondition: BoundaryCondition.INSULATED, + defaultLayers: { + temperature: true, + isotherms: false, + heatFlux: true, + velocity: false, + gradient: false, + material: false, + }, + // Two spots so there is a gradient to look at the moment the screen opens. + initialCondition: InitialCondition.TWO_SPOTS, + initialFlowPreset: FlowPreset.NONE, + resolution: preferences.resolutionProperty.value, + displaySize: FIELD_VIEW_SIZE * CANVAS_SCALE, + initiallyPlaying: true, + }); + } + + public step(dt: number): void { + this.field.step(dt); + } + + public reset(): void { + this.field.reset(); + } + + public dispose(): void { + this.field.dispose(); + } +} diff --git a/src/conduction/view/ConductionKeyboardHelpContent.ts b/src/conduction/view/ConductionKeyboardHelpContent.ts new file mode 100644 index 0000000..64146c4 --- /dev/null +++ b/src/conduction/view/ConductionKeyboardHelpContent.ts @@ -0,0 +1,29 @@ +/** + * ConductionKeyboardHelpContent.ts + * + * Content for the keyboard-help dialog (the "?" button in the navigation bar). + * This screen paints on the field, drags the probe, and uses a slider and + * checkboxes, so the left column carries the sim-specific paint section plus the + * stock slider and drag sections. + */ + +import { + BasicActionsKeyboardHelpSection, + MoveDraggableItemsKeyboardHelpSection, + SliderControlsKeyboardHelpSection, + TwoColumnKeyboardHelpContent, +} from "scenerystack/scenery-phet"; +import { HeatBrushKeyboardHelpSection } from "../../common/view/HeatBrushKeyboardHelpSection.js"; + +export class ConductionKeyboardHelpContent extends TwoColumnKeyboardHelpContent { + public constructor() { + super( + [ + new HeatBrushKeyboardHelpSection(), + new MoveDraggableItemsKeyboardHelpSection(), + new SliderControlsKeyboardHelpSection(), + ], + [new BasicActionsKeyboardHelpSection({ withCheckboxContent: true })], + ); + } +} diff --git a/src/conduction/view/ConductionScreenSummaryContent.ts b/src/conduction/view/ConductionScreenSummaryContent.ts new file mode 100644 index 0000000..4f904dd --- /dev/null +++ b/src/conduction/view/ConductionScreenSummaryContent.ts @@ -0,0 +1,28 @@ +/** + * ConductionScreenSummaryContent.ts + * + * The accessible screen summary for the Conduction screen. `currentDetailsContent` + * is derived live from the field's coldest and hottest points, so re-reading the + * summary reports the present state of the plate rather than how it started. + */ +import { DerivedProperty, PatternStringProperty } from "scenerystack/axon"; +import { ScreenSummaryContent } from "scenerystack/sim"; +import { formatCelsiusRounded } from "../../common/view/formatters.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { ConductionModel } from "../model/ConductionModel.js"; + +export class ConductionScreenSummaryContent extends ScreenSummaryContent { + public constructor(model: ConductionModel) { + const a11y = StringManager.getInstance().getConductionA11yStrings(); + + super({ + playAreaContent: a11y.screenSummary.playAreaStringProperty, + controlAreaContent: a11y.screenSummary.controlAreaStringProperty, + currentDetailsContent: new PatternStringProperty(a11y.currentDetailsStringProperty, { + min: new DerivedProperty([model.field.minTemperatureProperty], (kelvin) => formatCelsiusRounded(kelvin)), + max: new DerivedProperty([model.field.maxTemperatureProperty], (kelvin) => formatCelsiusRounded(kelvin)), + }), + interactionHintContent: a11y.screenSummary.interactionHintStringProperty, + }); + } +} diff --git a/src/conduction/view/ConductionScreenView.ts b/src/conduction/view/ConductionScreenView.ts new file mode 100644 index 0000000..506e333 --- /dev/null +++ b/src/conduction/view/ConductionScreenView.ts @@ -0,0 +1,114 @@ +/** + * ConductionScreenView.ts + * + * Screen 2's controls: material, edges, layers, and the cross-section tool. + * + * The cross-section is the centrepiece. A student who has only seen the colour + * field has seen T(x, y); dragging a line across it and watching T(s) appear — + * with q_s(s) peaking exactly where T(s) is steepest — is the moment Fourier's law + * stops being a formula. The graph therefore gets the full width under the + * control columns rather than being squeezed into one. + */ + +import { type Node, VBox } from "scenerystack/scenery"; +import { HeatTransferPanel } from "../../common/HeatTransferPanel.js"; +import { BrushControlPanel } from "../../common/view/BrushControlPanel.js"; +import { panelTitle, themedCheckbox } from "../../common/view/ControlFactory.js"; +import { CrossSectionGraphNode } from "../../common/view/CrossSectionGraphNode.js"; +import { FieldScreenView, type FieldScreenViewOptions } from "../../common/view/FieldScreenView.js"; +import { LayerControlPanel } from "../../common/view/LayerControlPanel.js"; +import { MaterialControlPanel } from "../../common/view/MaterialControlPanel.js"; +import { CONTROL_AREA_WIDTH } from "../../HeatTransferConstants.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { ConductionModel } from "../model/ConductionModel.js"; +import { ConductionScreenSummaryContent } from "./ConductionScreenSummaryContent.js"; + +/** Height of the cross-section graph, in view coordinates. */ +const GRAPH_HEIGHT = 150; + +/** The view supplies the field's accessible name and summary itself. */ +export type ConductionScreenViewOptions = Omit< + FieldScreenViewOptions, + "fieldAccessibleName" | "fieldAccessibleHelpText" | "screenSummaryContent" | "crossSectionEnabled" +>; + +export class ConductionScreenView extends FieldScreenView { + public constructor(model: ConductionModel, providedOptions: ConductionScreenViewOptions) { + const strings = StringManager.getInstance(); + const a11y = strings.getSharedA11yStrings(); + + super(model.field, { + ...providedOptions, + screenSummaryContent: new ConductionScreenSummaryContent(model), + fieldAccessibleName: a11y.controls.fieldStringProperty, + fieldAccessibleHelpText: a11y.controls.fieldHelpStringProperty, + crossSectionEnabled: true, + }); + + const controls = strings.getControls(); + const graphStrings = strings.getGraph(); + const screenA11y = strings.getConductionA11yStrings(); + + // ── Left column: material and edges ─────────────────────────────────────── + + const materialPanel = new MaterialControlPanel(model.field, this.comboBoxLayer, true); + this.leftColumn.addChild(materialPanel); + + const brushPanel = new BrushControlPanel(model.field); + this.leftColumn.addChild(brushPanel); + + // ── Right column: layers and tools ──────────────────────────────────────── + + const layerPanel = new LayerControlPanel(model.field, ["temperature", "isotherms", "heatFlux", "gradient"]); + this.rightColumn.addChild(layerPanel); + + const probeCheckbox = themedCheckbox( + model.field.probeVisibleProperty, + controls.showProbeStringProperty, + a11y.controls.probeHelpStringProperty, + ); + const crossSectionCheckbox = themedCheckbox( + model.field.crossSectionVisibleProperty, + controls.showCrossSectionStringProperty, + screenA11y.controls.crossSectionStringProperty, + ); + const showFluxCheckbox = themedCheckbox(model.field.heatFluxLayerProperty, graphStrings.showFluxStringProperty); + + const toolPanel = new HeatTransferPanel( + new VBox({ + align: "left", + spacing: 7, + children: [ + panelTitle(controls.showCrossSectionStringProperty), + probeCheckbox, + crossSectionCheckbox, + showFluxCheckbox, + ], + }), + ); + this.rightColumn.addChild(toolPanel); + + // ── Full width: the graph ───────────────────────────────────────────────── + + const graph = new CrossSectionGraphNode(model.field, { + width: CONTROL_AREA_WIDTH, + height: GRAPH_HEIGHT, + showFluxProperty: model.field.heatFluxLayerProperty, + visibleProperty: model.field.crossSectionVisibleProperty, + }); + this.wideArea.addChild(graph); + + const screenControls: Node[] = [ + ...materialPanel.controls, + ...brushPanel.controls, + ...layerPanel.checkboxes, + probeCheckbox, + crossSectionCheckbox, + showFluxCheckbox, + ]; + this.finishLayout(screenControls); + + // The cross-section starts visible: this screen is largely about it. + model.field.crossSectionVisibleProperty.value = true; + } +} diff --git a/src/convection/ConvectionScreen.ts b/src/convection/ConvectionScreen.ts new file mode 100644 index 0000000..c2ba137 --- /dev/null +++ b/src/convection/ConvectionScreen.ts @@ -0,0 +1,54 @@ +/** + * ConvectionScreen.ts + * + * Screen 3. Wires the model and view factories together and passes screen-level + * options to `Screen`. + * + * The preferences model rides on the options bag because a screen's field engine + * has to know its grid resolution at construction time, and SceneryStack builds a + * screen's model lazily — the first time a student opens the screen. + * + * Registered in the screens array in src/main.ts. Its home-screen and + * navigation-bar icons come from createConvectionIcon() in + * src/common/HeatTransferScreenIcons.ts (see doc/multi-screen.md). + */ +import { type EmptySelfOptions, optionize } from "scenerystack/phet-core"; +import type { ScreenOptions } from "scenerystack/sim"; +import { Screen } from "scenerystack/sim"; +import type { Tandem } from "scenerystack/tandem"; +import { createConvectionIcon } from "../common/HeatTransferScreenIcons.js"; +import HeatTransferColors from "../HeatTransferColors.js"; +import type { HeatTransferPreferencesModel } from "../preferences/HeatTransferPreferencesModel.js"; +import { ConvectionModel } from "./model/ConvectionModel.js"; +import { ConvectionKeyboardHelpContent } from "./view/ConvectionKeyboardHelpContent.js"; +import { ConvectionScreenView } from "./view/ConvectionScreenView.js"; + +// Require tandem to be explicit — accidental omission would break PhET-iO. +export type ConvectionScreenOptions = ScreenOptions & { + tandem: Tandem; + preferences: HeatTransferPreferencesModel; +}; + +export class ConvectionScreen extends Screen<ConvectionModel, ConvectionScreenView> { + public constructor(options: ConvectionScreenOptions) { + super( + // Model factory — called once when the screen is first shown + () => new ConvectionModel(options.preferences), + // View factory — receives the model instance + (model) => + new ConvectionScreenView(model, { + tandem: options.tandem.createTandem("view"), + showFieldStatusProperty: options.preferences.showFieldStatusProperty, + }), + optionize<ConvectionScreenOptions, EmptySelfOptions, ScreenOptions>()( + { + backgroundColorProperty: HeatTransferColors.backgroundColorProperty, + createKeyboardHelpNode: () => new ConvectionKeyboardHelpContent(), + homeScreenIcon: createConvectionIcon(), + navigationBarIcon: createConvectionIcon(), + }, + options, + ), + ); + } +} diff --git a/src/convection/model/ConvectionModel.ts b/src/convection/model/ConvectionModel.ts new file mode 100644 index 0000000..6e131ee --- /dev/null +++ b/src/convection/model/ConvectionModel.ts @@ -0,0 +1,61 @@ +/** + * ConvectionModel.ts + * + * Screen 3: a second field enters the picture. + * + * The velocity field is prescribed rather than solved — these are analytic, + * divergence-free patterns, not a Navier-Stokes solution — which keeps the + * lesson on transport rather than on fluid dynamics. Diffusion still runs + * underneath, so a hot spot both moves and spreads, and telling those two effects + * apart is exactly what this screen is for. + */ +import type { TModel } from "scenerystack/joist"; +import { BoundaryCondition, FlowPreset, InitialCondition } from "../../common/field/FieldTypes.js"; +import { FieldSimulationModel } from "../../common/model/FieldSimulationModel.js"; +import { FIELD_VIEW_SIZE } from "../../HeatTransferConstants.js"; +import type { HeatTransferPreferencesModel } from "../../preferences/HeatTransferPreferencesModel.js"; + +/** Backing-canvas resolution multiplier, so the field is crisp on high-DPI displays. */ +const CANVAS_SCALE = 2; + +export class ConvectionModel implements TModel { + public readonly field: FieldSimulationModel; + + public constructor(preferences: HeatTransferPreferencesModel) { + this.field = new FieldSimulationModel({ + advectionEnabled: true, + boundaryCondition: BoundaryCondition.PERIODIC, + defaultLayers: { + temperature: true, + isotherms: false, + heatFlux: false, + velocity: true, + gradient: false, + material: false, + }, + initialCondition: InitialCondition.HOT_SPOT, + initialFlowPreset: FlowPreset.UNIFORM, + resolution: preferences.resolutionProperty.value, + displaySize: FIELD_VIEW_SIZE * CANVAS_SCALE, + initiallyPlaying: true, + }); + + // Steel rather than copper: a lower diffusivity lets advection win at + // achievable flow speeds, so the transport is visible instead of being + // smeared out by conduction before it can travel. + this.field.materialIdProperty.value = "steel"; + } + + public step(dt: number): void { + this.field.step(dt); + } + + public reset(): void { + this.field.reset(); + this.field.materialIdProperty.value = "steel"; + } + + public dispose(): void { + this.field.dispose(); + } +} diff --git a/src/convection/view/ConvectionKeyboardHelpContent.ts b/src/convection/view/ConvectionKeyboardHelpContent.ts new file mode 100644 index 0000000..9fb33bb --- /dev/null +++ b/src/convection/view/ConvectionKeyboardHelpContent.ts @@ -0,0 +1,29 @@ +/** + * ConvectionKeyboardHelpContent.ts + * + * Content for the keyboard-help dialog (the "?" button in the navigation bar). + * This screen paints on the field, drags the probe, and uses a slider and + * checkboxes, so the left column carries the sim-specific paint section plus the + * stock slider and drag sections. + */ + +import { + BasicActionsKeyboardHelpSection, + MoveDraggableItemsKeyboardHelpSection, + SliderControlsKeyboardHelpSection, + TwoColumnKeyboardHelpContent, +} from "scenerystack/scenery-phet"; +import { HeatBrushKeyboardHelpSection } from "../../common/view/HeatBrushKeyboardHelpSection.js"; + +export class ConvectionKeyboardHelpContent extends TwoColumnKeyboardHelpContent { + public constructor() { + super( + [ + new HeatBrushKeyboardHelpSection(), + new MoveDraggableItemsKeyboardHelpSection(), + new SliderControlsKeyboardHelpSection(), + ], + [new BasicActionsKeyboardHelpSection({ withCheckboxContent: true })], + ); + } +} diff --git a/src/convection/view/ConvectionScreenSummaryContent.ts b/src/convection/view/ConvectionScreenSummaryContent.ts new file mode 100644 index 0000000..f201955 --- /dev/null +++ b/src/convection/view/ConvectionScreenSummaryContent.ts @@ -0,0 +1,28 @@ +/** + * ConvectionScreenSummaryContent.ts + * + * The accessible screen summary for the Convection screen. `currentDetailsContent` + * is derived live from the field's coldest and hottest points, so re-reading the + * summary reports the present state of the plate rather than how it started. + */ +import { DerivedProperty, PatternStringProperty } from "scenerystack/axon"; +import { ScreenSummaryContent } from "scenerystack/sim"; +import { formatCelsiusRounded } from "../../common/view/formatters.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { ConvectionModel } from "../model/ConvectionModel.js"; + +export class ConvectionScreenSummaryContent extends ScreenSummaryContent { + public constructor(model: ConvectionModel) { + const a11y = StringManager.getInstance().getConvectionA11yStrings(); + + super({ + playAreaContent: a11y.screenSummary.playAreaStringProperty, + controlAreaContent: a11y.screenSummary.controlAreaStringProperty, + currentDetailsContent: new PatternStringProperty(a11y.currentDetailsStringProperty, { + min: new DerivedProperty([model.field.minTemperatureProperty], (kelvin) => formatCelsiusRounded(kelvin)), + max: new DerivedProperty([model.field.maxTemperatureProperty], (kelvin) => formatCelsiusRounded(kelvin)), + }), + interactionHintContent: a11y.screenSummary.interactionHintStringProperty, + }); + } +} diff --git a/src/convection/view/ConvectionScreenView.ts b/src/convection/view/ConvectionScreenView.ts new file mode 100644 index 0000000..3be595b --- /dev/null +++ b/src/convection/view/ConvectionScreenView.ts @@ -0,0 +1,78 @@ +/** + * ConvectionScreenView.ts + * + * Screen 3's controls: the flow pattern, its speed, and the layers that make + * transport visible. + * + * The velocity layer is on by default, because the tracer particles are what + * distinguish this screen from the last one: without them a swept hot spot looks + * like a hot spot that happens to be moving, and with them it is obvious that the + * *material* is moving and carrying its heat along. + */ + +import type { Node } from "scenerystack/scenery"; +import { BrushControlPanel } from "../../common/view/BrushControlPanel.js"; +import { themedCheckbox } from "../../common/view/ControlFactory.js"; +import { FieldScreenView, type FieldScreenViewOptions } from "../../common/view/FieldScreenView.js"; +import { FlowControlPanel } from "../../common/view/FlowControlPanel.js"; +import { LayerControlPanel } from "../../common/view/LayerControlPanel.js"; +import { MaterialControlPanel } from "../../common/view/MaterialControlPanel.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { ConvectionModel } from "../model/ConvectionModel.js"; +import { ConvectionScreenSummaryContent } from "./ConvectionScreenSummaryContent.js"; + +/** The view supplies the field's accessible name and summary itself. */ +export type ConvectionScreenViewOptions = Omit< + FieldScreenViewOptions, + "fieldAccessibleName" | "fieldAccessibleHelpText" | "screenSummaryContent" +>; + +export class ConvectionScreenView extends FieldScreenView { + public constructor(model: ConvectionModel, providedOptions: ConvectionScreenViewOptions) { + const strings = StringManager.getInstance(); + const a11y = strings.getSharedA11yStrings(); + + super(model.field, { + ...providedOptions, + screenSummaryContent: new ConvectionScreenSummaryContent(model), + fieldAccessibleName: a11y.controls.fieldStringProperty, + fieldAccessibleHelpText: a11y.controls.fieldHelpStringProperty, + }); + + const controls = strings.getControls(); + + // ── Left column: the flow ───────────────────────────────────────────────── + + const flowPanel = new FlowControlPanel(model.field, this.comboBoxLayer); + this.leftColumn.addChild(flowPanel); + + const materialPanel = new MaterialControlPanel(model.field, this.comboBoxLayer, false); + this.leftColumn.addChild(materialPanel); + + // ── Right column: brush, layers, probe ──────────────────────────────────── + + const brushPanel = new BrushControlPanel(model.field); + this.rightColumn.addChild(brushPanel); + + const probeCheckbox = themedCheckbox( + model.field.probeVisibleProperty, + controls.showProbeStringProperty, + a11y.controls.probeHelpStringProperty, + ); + + const layerPanel = new LayerControlPanel( + model.field, + ["temperature", "velocity", "isotherms", "heatFlux"], + [probeCheckbox], + ); + this.rightColumn.addChild(layerPanel); + + const screenControls: Node[] = [ + ...flowPanel.controls, + ...materialPanel.controls, + ...brushPanel.controls, + ...layerPanel.checkboxes, + ]; + this.finishLayout(screenControls); + } +} diff --git a/src/i18n/StringManager.ts b/src/i18n/StringManager.ts new file mode 100644 index 0000000..ca5a140 --- /dev/null +++ b/src/i18n/StringManager.ts @@ -0,0 +1,171 @@ +/** + * StringManager.ts + * + * Centralizes all localized string access for the simulation. + * + * Strings are loaded from JSON files per locale and wrapped in reactive + * Property objects by SceneryStack. When the user switches language in the + * Preferences dialog, all StringProperties update automatically. + * + * ── How to add a locale ─────────────────────────────────────────────────────── + * 1. Create src/i18n/strings_XX.json with the same keys as strings_en.json + * 2. Import it below and add `XX: stringsXX` to the locale map + * 3. Add "XX" to `availableLocales` in src/init.ts + * + * ── How to add a string ─────────────────────────────────────────────────────── + * 1. Add the key + English value to strings_en.json + * 2. Add the same key + translated value to ALL other locale files + * (TypeScript will show an error here if any locale is missing a key) + * 3. Expose the new StringProperty via a new getter method below + */ + +import type { ReadOnlyProperty } from "scenerystack/axon"; +import { LocalizedString } from "scenerystack/chipper"; +import stringsEn from "./strings_en.json"; +import stringsEs from "./strings_es.json"; +import stringsFr from "./strings_fr.json"; + +// ── Compile-time key-parity check ───────────────────────────────────────────── +// English is the canonical shape; every other locale must match it exactly. +// TypeScript errors here if any locale file is missing (or adds) a key relative to +// English. Add one `satisfies` line per new locale so the check stays exhaustive. +// biome-ignore lint/complexity/noVoid: intentional compile-time type assertion +void (stringsFr satisfies typeof stringsEn); +// biome-ignore lint/complexity/noVoid: intentional compile-time type assertion +void (stringsEn satisfies typeof stringsFr); +// biome-ignore lint/complexity/noVoid: intentional compile-time type assertion +void (stringsEs satisfies typeof stringsEn); +// biome-ignore lint/complexity/noVoid: intentional compile-time type assertion +void (stringsEn satisfies typeof stringsEs); + +// ── Build the reactive string property tree ─────────────────────────────────── +const stringProperties = LocalizedString.getNestedStringProperties({ + en: stringsEn, + fr: stringsFr, + es: stringsEs, +}); + +/** + * StringManager is a singleton that provides typed access to all localized + * strings. Use `StringManager.getInstance()` everywhere — never construct it + * directly. + */ +export class StringManager { + private static instance: StringManager | null = null; + + private constructor() { + // Private — obtain via getInstance() + } + + public static getInstance(): StringManager { + if (StringManager.instance === null) { + StringManager.instance = new StringManager(); + } + return StringManager.instance; + } + + /** + * The simulation title shown in the navigation bar and browser tab. + * Updates automatically when the locale changes. + */ + public getTitleStringProperty(): ReadOnlyProperty<string> { + return stringProperties.titleStringProperty; + } + + /** + * Screen name StringProperties used when constructing Screen instances. + * Each property updates automatically when the locale changes. + */ + public getScreenNames(): { + readonly temperatureStringProperty: ReadOnlyProperty<string>; + readonly conductionStringProperty: ReadOnlyProperty<string>; + readonly convectionStringProperty: ReadOnlyProperty<string>; + readonly combinedStringProperty: ReadOnlyProperty<string>; + readonly materialsStringProperty: ReadOnlyProperty<string>; + } { + return { + temperatureStringProperty: stringProperties.screens.temperatureStringProperty, + conductionStringProperty: stringProperties.screens.conductionStringProperty, + convectionStringProperty: stringProperties.screens.convectionStringProperty, + combinedStringProperty: stringProperties.screens.combinedStringProperty, + materialsStringProperty: stringProperties.screens.materialsStringProperty, + }; + } + + /** Labels for the control panels: layers, brush, material, flow, transport, edges. */ + public getControls() { + return stringProperties.controls; + } + + /** Display names of the material presets, keyed by material id. */ + public getMaterialNames() { + return stringProperties.materials; + } + + /** Display names of the flow presets, keyed by preset id. */ + public getFlowNames() { + return stringProperties.flows; + } + + /** Display names of the boundary conditions, keyed by condition id. */ + public getEdgeNames() { + return stringProperties.edges; + } + + /** Patterns for numeric readouts. Each carries its own units. */ + public getReadouts() { + return stringProperties.readouts; + } + + /** Labels for the cross-section graph. */ + public getGraph() { + return stringProperties.graph; + } + + /** Labels for the temperature legend. */ + public getLegend() { + return stringProperties.legend; + } + + /** Labels for the sim-specific keyboard-help section. */ + public getKeyboardHelp() { + return stringProperties.keyboardHelp; + } + + /** Accessibility strings shared by every screen (control names and help text). */ + public getSharedA11yStrings() { + return stringProperties.a11y.shared; + } + + /** Accessibility strings for the Temperature screen. */ + public getTemperatureA11yStrings() { + return stringProperties.a11y.temperature; + } + + /** Accessibility strings for the Conduction screen. */ + public getConductionA11yStrings() { + return stringProperties.a11y.conduction; + } + + /** Accessibility strings for the Convection screen. */ + public getConvectionA11yStrings() { + return stringProperties.a11y.convection; + } + + /** Accessibility strings for the Heat Transfer screen. */ + public getHeatTransferA11yStrings() { + return stringProperties.a11y.combined; + } + + /** Accessibility strings for the Materials screen. */ + public getMaterialsA11yStrings() { + return stringProperties.a11y.materials; + } + + /** + * Simulation-specific preference labels shown in Preferences → Simulation. + */ + public getPreferences() { + return stringProperties.preferences; + } +} diff --git a/src/i18n/strings_en.json b/src/i18n/strings_en.json new file mode 100644 index 0000000..d217a1e --- /dev/null +++ b/src/i18n/strings_en.json @@ -0,0 +1,179 @@ +{ + "title": "Heat Transfer", + "screens": { + "temperature": "Temperature", + "conduction": "Conduction", + "convection": "Convection", + "combined": "Heat Transfer", + "materials": "Materials" + }, + "controls": { + "fieldLayers": "Field Layers", + "temperatureLayer": "Temperature", + "isothermLayer": "Isotherms", + "heatFluxLayer": "Heat Flux", + "velocityLayer": "Velocity", + "gradientLayer": "Gradient", + "materialLayer": "Material", + "brush": "Brush", + "heat": "Heat", + "cool": "Cool", + "brushSize": "Brush Size", + "material": "Material", + "anisotropy": "Anisotropy", + "flow": "Flow", + "flowPattern": "Pattern", + "flowSpeed": "Flow Speed", + "transportRegime": "Transport", + "diffusionDominated": "Diffusion", + "advectionDominated": "Advection", + "edges": "Edges", + "showProbe": "Probe", + "showCrossSection": "Cross-section", + "paintMaterial": "Paint", + "clearField": "Clear Field" + }, + "materials": { + "copper": "Copper", + "aluminum": "Aluminum", + "steel": "Steel", + "glass": "Glass", + "water": "Water", + "wood": "Wood", + "insulator": "Insulator" + }, + "flows": { + "none": "Still", + "uniform": "Uniform", + "channel": "Channel", + "vortex": "Vortex", + "plume": "Plume" + }, + "edges": { + "insulated": "Insulated", + "fixed": "Fixed", + "periodic": "Periodic" + }, + "readouts": { + "celsius": "{{value}} °C", + "conductivity": "k = {{value}} W/m·K", + "ratio": "kx : ky = {{value}}", + "diffusivity": "α = {{value}} m²/s", + "speed": "{{value}} mm/s", + "elapsed": "t = {{value}} s", + "peclet": "Pe = {{value}}", + "grid": "{{width}} × {{height}} cells", + "backendWebgpu": "WebGPU", + "backendCpu": "CPU fallback", + "range": "{{min}} to {{max}} °C", + "conductionDominates": "Conduction dominates", + "flowDominates": "Flow dominates", + "comparable": "Comparable" + }, + "graph": { + "title": "Along the cross-section", + "distanceAxis": "Distance (mm)", + "temperatureAxis": "T (°C)", + "fluxAxis": "q (kW/m²)", + "showFlux": "Show flux" + }, + "legend": { + "title": "Temperature" + }, + "keyboardHelp": { + "title": "Paint on the field", + "moveCursor": "Move the paint cursor", + "moveCursorSlower": "Move the cursor in smaller steps", + "paint": "Paint at the cursor" + }, + "preferences": { + "title": "Simulation", + "resolution": "Grid Resolution", + "resolutionHelp": "Finer grids resolve more structure and need more GPU work. Takes effect when a screen is reloaded.", + "resolutionClassroom": "128 × 128 (classroom)", + "resolutionHigh": "512 × 512 (high)", + "resolutionLarge": "1024 × 1024 (large)", + "resolutionExtreme": "2048 × 2048 (extreme)", + "showFieldStatus": "Show field engine status" + }, + "a11y": { + "shared": { + "controls": { + "field": "Temperature field", + "fieldHelp": "Move the paint cursor with the arrow keys, hold shift for finer steps, then press space or enter to paint.", + "fieldReadOnlyHelp": "The temperature field. Use the controls to change what is shown.", + "brushMode": "What the brush deposits", + "brushSize": "Brush size", + "material": "Plate material", + "paintMaterial": "Material the brush paints", + "anisotropy": "Ratio of conductivity along x to conductivity along y", + "flowPattern": "Velocity field pattern", + "flowSpeed": "Peak flow speed", + "transportRegime": "Balance between diffusion and advection", + "edges": "Behaviour of the field at the edges of the plate", + "layers": "Which field layers are drawn", + "probe": "Temperature probe", + "probeHelp": "Drag the probe over the field to read the temperature at a point.", + "crossSectionStart": "Start of the cross-section line", + "crossSectionEnd": "End of the cross-section line", + "clearField": "Return the temperature field to its starting state without changing the other controls", + "fieldStatus": "Field engine status" + } + }, + "temperature": { + "screenSummary": { + "playArea": "A square plate whose colour shows its temperature at every point. Painting with the heat or cool brush changes the temperature where you paint.", + "controlArea": "Controls choose whether the brush heats or cools, set its size, turn isotherm lines on and off, and reset the plate.", + "interactionHint": "Paint on the plate to make hot and cold regions, then read the temperature with the probe." + }, + "currentDetails": "The plate ranges from {{min}} to {{max}} degrees Celsius.", + "controls": { + "isotherms": "Draw lines joining points at the same temperature" + } + }, + "conduction": { + "screenSummary": { + "playArea": "A plate of a chosen material, with its temperature shown as colour and the heat flux shown as arrows pointing from hot toward cold.", + "controlArea": "Controls choose the material, set the behaviour of the plate edges, select which layers are drawn, and place a cross-section line.", + "interactionHint": "Paint a hot spot, then watch the arrows and see how much faster heat spreads through copper than through glass." + }, + "currentDetails": "The plate ranges from {{min}} to {{max}} degrees Celsius.", + "controls": { + "crossSection": "Show a line across the plate and graph the temperature along it" + } + }, + "convection": { + "screenSummary": { + "playArea": "A plate carrying both a temperature field and a velocity field. Tracer particles drift with the flow, carrying warm and cool regions with them.", + "controlArea": "Controls choose the flow pattern, set the flow speed, and select which layers are drawn.", + "interactionHint": "Paint a hot spot into the flow and watch it be carried along as well as spreading out." + }, + "currentDetails": "The plate ranges from {{min}} to {{max}} degrees Celsius.", + "controls": { + "flowPreset": "Choose the shape of the velocity field" + } + }, + "combined": { + "screenSummary": { + "playArea": "A plate where heat both diffuses through the material and is carried by a flow. The balance between the two is adjustable.", + "controlArea": "A transport control shifts between diffusion-dominated and advection-dominated behaviour, with the resulting Péclet number displayed. Layer checkboxes choose what is drawn.", + "interactionHint": "Move the transport control and watch the same hot spot change from spreading evenly to being swept downstream." + }, + "currentDetails": "The plate ranges from {{min}} to {{max}} degrees Celsius. The Péclet number is {{peclet}}.", + "controls": { + "transport": "Shift between diffusion-dominated and advection-dominated transport" + } + }, + "materials": { + "screenSummary": { + "playArea": "A plate whose material can differ from place to place. Painting material into the plate builds composites, barriers, and channels for heat.", + "controlArea": "Controls choose which material the brush paints, how anisotropic it is, and which layers are drawn.", + "interactionHint": "Paint a strip of insulator across the plate, then paint a hot spot on one side and watch heat go around the barrier." + }, + "currentDetails": "The plate ranges from {{min}} to {{max}} degrees Celsius.", + "controls": { + "anisotropyHelp": "Values above one let heat travel more easily left and right; values below one favour up and down" + } + } + } +} diff --git a/src/i18n/strings_es.json b/src/i18n/strings_es.json new file mode 100644 index 0000000..f2a3fb9 --- /dev/null +++ b/src/i18n/strings_es.json @@ -0,0 +1,179 @@ +{ + "title": "Transferencia de calor", + "screens": { + "temperature": "Temperatura", + "conduction": "Conducción", + "convection": "Convección", + "combined": "Transferencia de calor", + "materials": "Materiales" + }, + "controls": { + "fieldLayers": "Capas del campo", + "temperatureLayer": "Temperatura", + "isothermLayer": "Isotermas", + "heatFluxLayer": "Flujo de calor", + "velocityLayer": "Velocidad", + "gradientLayer": "Gradiente", + "materialLayer": "Material", + "brush": "Pincel", + "heat": "Calentar", + "cool": "Enfriar", + "brushSize": "Tamaño del pincel", + "material": "Material", + "anisotropy": "Anisotropía", + "flow": "Flujo", + "flowPattern": "Patrón", + "flowSpeed": "Velocidad del flujo", + "transportRegime": "Transporte", + "diffusionDominated": "Difusión", + "advectionDominated": "Advección", + "edges": "Bordes", + "showProbe": "Sonda", + "showCrossSection": "Sección transversal", + "paintMaterial": "Pintar", + "clearField": "Limpiar campo" + }, + "materials": { + "copper": "Cobre", + "aluminum": "Aluminio", + "steel": "Acero", + "glass": "Vidrio", + "water": "Agua", + "wood": "Madera", + "insulator": "Aislante" + }, + "flows": { + "none": "En reposo", + "uniform": "Uniforme", + "channel": "Canal", + "vortex": "Vórtice", + "plume": "Pluma térmica" + }, + "edges": { + "insulated": "Aislados", + "fixed": "Fijos", + "periodic": "Periódicos" + }, + "readouts": { + "celsius": "{{value}} °C", + "conductivity": "k = {{value}} W/m·K", + "ratio": "kx : ky = {{value}}", + "diffusivity": "α = {{value}} m²/s", + "speed": "{{value}} mm/s", + "elapsed": "t = {{value}} s", + "peclet": "Pe = {{value}}", + "grid": "{{width}} × {{height}} celdas", + "backendWebgpu": "WebGPU", + "backendCpu": "CPU (alternativa)", + "range": "de {{min}} a {{max}} °C", + "conductionDominates": "Domina la conducción", + "flowDominates": "Domina el flujo", + "comparable": "Comparables" + }, + "graph": { + "title": "A lo largo de la sección", + "distanceAxis": "Distancia (mm)", + "temperatureAxis": "T (°C)", + "fluxAxis": "q (kW/m²)", + "showFlux": "Mostrar flujo" + }, + "legend": { + "title": "Temperatura" + }, + "keyboardHelp": { + "title": "Pintar sobre el campo", + "moveCursor": "Mover el cursor de pintado", + "moveCursorSlower": "Mover el cursor en pasos más pequeños", + "paint": "Pintar en el cursor" + }, + "preferences": { + "title": "Simulación", + "resolution": "Resolución de la malla", + "resolutionHelp": "Las mallas más finas resuelven más estructura y exigen más trabajo a la GPU. Se aplica al recargar una pantalla.", + "resolutionClassroom": "128 × 128 (aula)", + "resolutionHigh": "512 × 512 (alta)", + "resolutionLarge": "1024 × 1024 (grande)", + "resolutionExtreme": "2048 × 2048 (extrema)", + "showFieldStatus": "Mostrar el estado del motor de campos" + }, + "a11y": { + "shared": { + "controls": { + "field": "Campo de temperatura", + "fieldHelp": "Mueve el cursor de pintado con las teclas de flecha, mantén mayúsculas para pasos más finos y pulsa espacio o intro para pintar.", + "fieldReadOnlyHelp": "El campo de temperatura. Usa los controles para cambiar lo que se muestra.", + "brushMode": "Lo que deposita el pincel", + "brushSize": "Tamaño del pincel", + "material": "Material de la placa", + "paintMaterial": "Material que pinta el pincel", + "anisotropy": "Relación entre la conductividad en x y la conductividad en y", + "flowPattern": "Patrón del campo de velocidad", + "flowSpeed": "Velocidad máxima del flujo", + "transportRegime": "Equilibrio entre difusión y advección", + "edges": "Comportamiento del campo en los bordes de la placa", + "layers": "Qué capas del campo se dibujan", + "probe": "Sonda de temperatura", + "probeHelp": "Arrastra la sonda sobre el campo para leer la temperatura en un punto.", + "crossSectionStart": "Inicio de la línea de sección transversal", + "crossSectionEnd": "Final de la línea de sección transversal", + "clearField": "Devuelve el campo de temperatura a su estado inicial sin cambiar los demás controles", + "fieldStatus": "Estado del motor de campos" + } + }, + "temperature": { + "screenSummary": { + "playArea": "Una placa cuadrada cuyo color muestra su temperatura en cada punto. Pintar con el pincel de calor o de frío cambia la temperatura donde pintas.", + "controlArea": "Los controles eligen si el pincel calienta o enfría, ajustan su tamaño, activan y desactivan las isotermas y reinician la placa.", + "interactionHint": "Pinta sobre la placa para crear zonas calientes y frías, y luego lee la temperatura con la sonda." + }, + "currentDetails": "La placa va de {{min}} a {{max}} grados Celsius.", + "controls": { + "isotherms": "Dibuja líneas que unen los puntos que están a la misma temperatura" + } + }, + "conduction": { + "screenSummary": { + "playArea": "Una placa de un material elegido, con su temperatura en color y el flujo de calor en flechas que apuntan de lo caliente hacia lo frío.", + "controlArea": "Los controles eligen el material, fijan el comportamiento de los bordes, seleccionan las capas dibujadas y colocan una línea de sección transversal.", + "interactionHint": "Pinta un punto caliente y observa las flechas: el calor se extiende mucho más rápido por el cobre que por el vidrio." + }, + "currentDetails": "La placa va de {{min}} a {{max}} grados Celsius.", + "controls": { + "crossSection": "Muestra una línea que cruza la placa y representa la temperatura a lo largo de ella" + } + }, + "convection": { + "screenSummary": { + "playArea": "Una placa con un campo de temperatura y un campo de velocidad. Las partículas trazadoras se mueven con el flujo y arrastran consigo las zonas cálidas y frías.", + "controlArea": "Los controles eligen el patrón del flujo, ajustan su velocidad y seleccionan las capas dibujadas.", + "interactionHint": "Pinta un punto caliente dentro del flujo y observa cómo es arrastrado además de extenderse." + }, + "currentDetails": "La placa va de {{min}} a {{max}} grados Celsius.", + "controls": { + "flowPreset": "Elige la forma del campo de velocidad" + } + }, + "combined": { + "screenSummary": { + "playArea": "Una placa en la que el calor se difunde por el material y además es arrastrado por un flujo. El equilibrio entre ambos es ajustable.", + "controlArea": "Un control de transporte desplaza el comportamiento entre el dominio de la difusión y el de la advección, mostrando el número de Péclet resultante. Las casillas de capas eligen qué se dibuja.", + "interactionHint": "Mueve el control de transporte y observa cómo el mismo punto caliente pasa de extenderse por igual a ser arrastrado corriente abajo." + }, + "currentDetails": "La placa va de {{min}} a {{max}} grados Celsius. El número de Péclet es {{peclet}}.", + "controls": { + "transport": "Desplaza el transporte entre el dominio de la difusión y el de la advección" + } + }, + "materials": { + "screenSummary": { + "playArea": "Una placa cuyo material puede variar de un lugar a otro. Pintar material sobre la placa construye compuestos, barreras y canales para el calor.", + "controlArea": "Los controles eligen qué material pinta el pincel, cuán anisótropo es y qué capas se dibujan.", + "interactionHint": "Pinta una franja de aislante que cruce la placa, pinta después un punto caliente a un lado y observa cómo el calor rodea la barrera." + }, + "currentDetails": "La placa va de {{min}} a {{max}} grados Celsius.", + "controls": { + "anisotropyHelp": "Los valores mayores que uno facilitan el paso del calor a izquierda y derecha; los menores que uno favorecen arriba y abajo" + } + } + } +} diff --git a/src/i18n/strings_fr.json b/src/i18n/strings_fr.json new file mode 100644 index 0000000..91503d3 --- /dev/null +++ b/src/i18n/strings_fr.json @@ -0,0 +1,179 @@ +{ + "title": "Transfert de chaleur", + "screens": { + "temperature": "Température", + "conduction": "Conduction", + "convection": "Convection", + "combined": "Transfert de chaleur", + "materials": "Matériaux" + }, + "controls": { + "fieldLayers": "Couches du champ", + "temperatureLayer": "Température", + "isothermLayer": "Isothermes", + "heatFluxLayer": "Flux de chaleur", + "velocityLayer": "Vitesse", + "gradientLayer": "Gradient", + "materialLayer": "Matériau", + "brush": "Pinceau", + "heat": "Chauffer", + "cool": "Refroidir", + "brushSize": "Taille du pinceau", + "material": "Matériau", + "anisotropy": "Anisotropie", + "flow": "Écoulement", + "flowPattern": "Motif", + "flowSpeed": "Vitesse d'écoulement", + "transportRegime": "Transport", + "diffusionDominated": "Diffusion", + "advectionDominated": "Advection", + "edges": "Bords", + "showProbe": "Sonde", + "showCrossSection": "Coupe", + "paintMaterial": "Peindre", + "clearField": "Effacer le champ" + }, + "materials": { + "copper": "Cuivre", + "aluminum": "Aluminium", + "steel": "Acier", + "glass": "Verre", + "water": "Eau", + "wood": "Bois", + "insulator": "Isolant" + }, + "flows": { + "none": "Au repos", + "uniform": "Uniforme", + "channel": "Canal", + "vortex": "Tourbillon", + "plume": "Panache" + }, + "edges": { + "insulated": "Isolés", + "fixed": "Fixes", + "periodic": "Périodiques" + }, + "readouts": { + "celsius": "{{value}} °C", + "conductivity": "k = {{value}} W/m·K", + "ratio": "kx : ky = {{value}}", + "diffusivity": "α = {{value}} m²/s", + "speed": "{{value}} mm/s", + "elapsed": "t = {{value}} s", + "peclet": "Pe = {{value}}", + "grid": "{{width}} × {{height}} cellules", + "backendWebgpu": "WebGPU", + "backendCpu": "CPU (secours)", + "range": "de {{min}} à {{max}} °C", + "conductionDominates": "La conduction domine", + "flowDominates": "L'écoulement domine", + "comparable": "Comparables" + }, + "graph": { + "title": "Le long de la coupe", + "distanceAxis": "Distance (mm)", + "temperatureAxis": "T (°C)", + "fluxAxis": "q (kW/m²)", + "showFlux": "Afficher le flux" + }, + "legend": { + "title": "Température" + }, + "keyboardHelp": { + "title": "Peindre sur le champ", + "moveCursor": "Déplacer le curseur de peinture", + "moveCursorSlower": "Déplacer le curseur par pas plus fins", + "paint": "Peindre au curseur" + }, + "preferences": { + "title": "Simulation", + "resolution": "Résolution de la grille", + "resolutionHelp": "Les grilles plus fines révèlent davantage de structure et sollicitent plus le GPU. L'effet s'applique au rechargement d'un écran.", + "resolutionClassroom": "128 × 128 (classe)", + "resolutionHigh": "512 × 512 (élevée)", + "resolutionLarge": "1024 × 1024 (grande)", + "resolutionExtreme": "2048 × 2048 (extrême)", + "showFieldStatus": "Afficher l'état du moteur de champs" + }, + "a11y": { + "shared": { + "controls": { + "field": "Champ de température", + "fieldHelp": "Déplacez le curseur de peinture avec les touches fléchées, maintenez la touche majuscule pour des pas plus fins, puis appuyez sur espace ou entrée pour peindre.", + "fieldReadOnlyHelp": "Le champ de température. Utilisez les commandes pour changer ce qui est affiché.", + "brushMode": "Ce que dépose le pinceau", + "brushSize": "Taille du pinceau", + "material": "Matériau de la plaque", + "paintMaterial": "Matériau peint par le pinceau", + "anisotropy": "Rapport entre la conductivité selon x et la conductivité selon y", + "flowPattern": "Motif du champ de vitesse", + "flowSpeed": "Vitesse maximale de l'écoulement", + "transportRegime": "Équilibre entre diffusion et advection", + "edges": "Comportement du champ aux bords de la plaque", + "layers": "Couches du champ à dessiner", + "probe": "Sonde de température", + "probeHelp": "Faites glisser la sonde sur le champ pour lire la température en un point.", + "crossSectionStart": "Début de la ligne de coupe", + "crossSectionEnd": "Fin de la ligne de coupe", + "clearField": "Ramène le champ de température à son état initial sans modifier les autres commandes", + "fieldStatus": "État du moteur de champs" + } + }, + "temperature": { + "screenSummary": { + "playArea": "Une plaque carrée dont la couleur indique la température en chaque point. Peindre avec le pinceau chaud ou froid modifie la température à l'endroit peint.", + "controlArea": "Les commandes choisissent si le pinceau chauffe ou refroidit, règlent sa taille, activent ou désactivent les isothermes et réinitialisent la plaque.", + "interactionHint": "Peignez sur la plaque pour créer des zones chaudes et froides, puis lisez la température avec la sonde." + }, + "currentDetails": "La plaque va de {{min}} à {{max}} degrés Celsius.", + "controls": { + "isotherms": "Trace des lignes reliant les points à la même température" + } + }, + "conduction": { + "screenSummary": { + "playArea": "Une plaque d'un matériau choisi, sa température en couleur et le flux de chaleur en flèches allant du chaud vers le froid.", + "controlArea": "Les commandes choisissent le matériau, fixent le comportement des bords, sélectionnent les couches dessinées et placent une ligne de coupe.", + "interactionHint": "Peignez un point chaud, puis regardez les flèches : la chaleur se propage bien plus vite dans le cuivre que dans le verre." + }, + "currentDetails": "La plaque va de {{min}} à {{max}} degrés Celsius.", + "controls": { + "crossSection": "Affiche une ligne traversant la plaque et trace la température le long de celle-ci" + } + }, + "convection": { + "screenSummary": { + "playArea": "Une plaque portant à la fois un champ de température et un champ de vitesse. Des particules traceuses dérivent avec l'écoulement et emportent les zones chaudes et froides.", + "controlArea": "Les commandes choisissent le motif de l'écoulement, règlent sa vitesse et sélectionnent les couches dessinées.", + "interactionHint": "Peignez un point chaud dans l'écoulement et observez-le être emporté en même temps qu'il s'étale." + }, + "currentDetails": "La plaque va de {{min}} à {{max}} degrés Celsius.", + "controls": { + "flowPreset": "Choisit la forme du champ de vitesse" + } + }, + "combined": { + "screenSummary": { + "playArea": "Une plaque où la chaleur diffuse dans le matériau et se trouve en même temps emportée par un écoulement. L'équilibre entre les deux est réglable.", + "controlArea": "Une commande de transport déplace le comportement entre dominante diffusive et dominante advective, en affichant le nombre de Péclet correspondant. Les cases à cocher choisissent les couches dessinées.", + "interactionHint": "Déplacez la commande de transport et observez le même point chaud passer d'un étalement régulier à un entraînement vers l'aval." + }, + "currentDetails": "La plaque va de {{min}} à {{max}} degrés Celsius. Le nombre de Péclet vaut {{peclet}}.", + "controls": { + "transport": "Déplace le transport entre dominante diffusive et dominante advective" + } + }, + "materials": { + "screenSummary": { + "playArea": "Une plaque dont le matériau peut varier d'un endroit à l'autre. Peindre du matériau construit des composites, des barrières et des canaux pour la chaleur.", + "controlArea": "Les commandes choisissent le matériau peint par le pinceau, son anisotropie et les couches dessinées.", + "interactionHint": "Peignez une bande d'isolant en travers de la plaque, peignez ensuite un point chaud d'un côté et observez la chaleur contourner la barrière." + }, + "currentDetails": "La plaque va de {{min}} à {{max}} degrés Celsius.", + "controls": { + "anisotropyHelp": "Au-dessus de un, la chaleur circule plus facilement vers la gauche et la droite ; en dessous de un, vers le haut et le bas" + } + } + } +} diff --git a/src/init.ts b/src/init.ts new file mode 100644 index 0000000..5697e56 --- /dev/null +++ b/src/init.ts @@ -0,0 +1,46 @@ +/** + * init.ts + * + * Initializes SceneryStack with simulation metadata. + * + * IMPORTANT: This file is the START of the EXECUTION chain (deepest import runs first): + * init.ts → assert.ts → splash.ts → brand.ts → main.ts + * + * Import nesting is the reverse (main → brand → splash → assert → init). + * brand.js must be the first import in main.ts so this file runs before any other + * SceneryStack module is imported. + * + * ── How to customize ───────────────────────────────────────────────────────── + * 1. Change `name` to match your package.json "name" field (kebab-case) + * 2. Change `version` to match your package.json "version" field + * 3. Update `availableLocales` when you add new translation files + */ +import { init, madeWithSceneryStackSplashDataURI } from "scenerystack/init"; + +init({ + // Internal identifier used by SceneryStack for URL parameters and phetmarks. + // Use kebab-case matching the package.json "name" field. + name: "heat-transfer", + + // Displayed in the About dialog (Help menu → About). + version: "0.0.0", + + // Must match the id registered in src/brand.ts. + brand: "made-with-scenerystack", + + // Default locale (ISO-639-1, optionally with ISO-3166-1 country code, e.g. "en_US"). + locale: "en", + + // All supported locales — must match the locale keys in src/i18n/StringManager.ts. + availableLocales: ["en", "es", "fr"], + + // Splash screen shown while the simulation loads. + splashDataURI: madeWithSceneryStackSplashDataURI, + + // Allow the user to switch locale at runtime via the Preferences dialog. + allowLocaleSwitching: true, + + // Enables the "Projector Mode" color profile alongside the default dark theme. + // Required when supportsProjectorMode: true is used in PreferencesModel (src/main.ts). + colorProfiles: ["default", "projector"], +}); diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..f5f2bca --- /dev/null +++ b/src/main.ts @@ -0,0 +1,124 @@ +/** + * main.ts + * + * Entry point. Initializes SceneryStack, acquires the GPU device, creates the + * screens, and starts the main event loop. + * + * !! CRITICAL IMPORT ORDER !! + * brand.js MUST be the first import. Each module imports the next, so the import + * nesting is + * + * main → brand → splash → assert → init + * + * and therefore the actual EXECUTION order (deepest import runs first) is the + * reverse: + * + * init → assert → splash → brand → main + * + * SceneryStack requires this exact load order. Never reorder these imports. + * + * ── Why there is an await here ──────────────────────────────────────────────── + * The field engines are built when a screen's model is constructed, which + * SceneryStack does lazily and synchronously. Acquiring a WebGPU adapter, device, + * and validated shaders is unavoidably asynchronous, so it happens once here — + * while the splash screen is still up — and the answer is cached. Every screen + * afterwards asks a synchronous question and gets a synchronous answer. If the + * device cannot be had, or a shader fails to compile on this driver, the engines + * silently use the CPU reference backend instead and the status line under the + * field says so. + */ + +// brand.js MUST be first; importing it runs the whole chain (init→assert→splash→brand) before main. +import "./brand.js"; + +import { onReadyToLaunch, PreferencesModel, Sim } from "scenerystack/sim"; +import { Tandem } from "scenerystack/tandem"; +import { HeatTransferScreen } from "./combined/HeatTransferScreen.js"; +import { initializeGpuContext } from "./common/field/gpu/GpuContext.js"; +import { ConductionScreen } from "./conduction/ConductionScreen.js"; +import { ConvectionScreen } from "./convection/ConvectionScreen.js"; +import HeatTransferColors from "./HeatTransferColors.js"; +import { StringManager } from "./i18n/StringManager.js"; +import { MaterialsScreen } from "./materials/MaterialsScreen.js"; +import { HeatTransferPreferencesModel } from "./preferences/HeatTransferPreferencesModel.js"; +import { HeatTransferPreferencesNode } from "./preferences/HeatTransferPreferencesNode.js"; +import heatTransferQueryParameters from "./preferences/heatTransferQueryParameters.js"; +import { TemperatureScreen } from "./temperature/TemperatureScreen.js"; + +onReadyToLaunch(() => { + const stringManager = StringManager.getInstance(); + const preferences = new HeatTransferPreferencesModel(Tandem.ROOT.createTandem("preferences")); + + const launch = (): void => { + const screenNames = stringManager.getScreenNames(); + + const screens = [ + new TemperatureScreen({ + name: screenNames.temperatureStringProperty, + tandem: Tandem.ROOT.createTandem("temperatureScreen"), + backgroundColorProperty: HeatTransferColors.backgroundColorProperty, + preferences, + }), + new ConductionScreen({ + name: screenNames.conductionStringProperty, + tandem: Tandem.ROOT.createTandem("conductionScreen"), + backgroundColorProperty: HeatTransferColors.backgroundColorProperty, + preferences, + }), + new ConvectionScreen({ + name: screenNames.convectionStringProperty, + tandem: Tandem.ROOT.createTandem("convectionScreen"), + backgroundColorProperty: HeatTransferColors.backgroundColorProperty, + preferences, + }), + new HeatTransferScreen({ + name: screenNames.combinedStringProperty, + tandem: Tandem.ROOT.createTandem("combinedScreen"), + backgroundColorProperty: HeatTransferColors.backgroundColorProperty, + preferences, + }), + new MaterialsScreen({ + name: screenNames.materialsStringProperty, + tandem: Tandem.ROOT.createTandem("materialsScreen"), + backgroundColorProperty: HeatTransferColors.backgroundColorProperty, + preferences, + }), + ]; + + const sim = new Sim(stringManager.getTitleStringProperty(), screens, { + preferencesModel: new PreferencesModel({ + visualOptions: { + // Adds a "Projector Mode" toggle in Preferences → Visual + supportsProjectorMode: true, + // Enables keyboard-navigation highlight outlines + supportsInteractiveHighlights: true, + }, + simulationOptions: { + customPreferences: [ + { + createContent: (tandem: Tandem) => new HeatTransferPreferencesNode(preferences, tandem), + }, + ], + }, + localizationOptions: { + // Adds a language picker in Preferences → Language + supportsDynamicLocale: true, + }, + }), + + credits: { + leadDesign: "", + softwareDevelopment: "", + team: "", + qualityAssurance: "", + }, + }); + + sim.start(); + }; + + // A rejected device request is already folded into the resolved value, so this + // never rejects; the `catch` is belt and braces so a launch failure can never be + // caused by the GPU probe itself. + initializeGpuContext(heatTransferQueryParameters.forceCpu).then(launch, launch); +}); diff --git a/src/materials/MaterialsScreen.ts b/src/materials/MaterialsScreen.ts new file mode 100644 index 0000000..f6e02da --- /dev/null +++ b/src/materials/MaterialsScreen.ts @@ -0,0 +1,54 @@ +/** + * MaterialsScreen.ts + * + * Screen 5. Wires the model and view factories together and passes screen-level + * options to `Screen`. + * + * The preferences model rides on the options bag because a screen's field engine + * has to know its grid resolution at construction time, and SceneryStack builds a + * screen's model lazily — the first time a student opens the screen. + * + * Registered in the screens array in src/main.ts. Its home-screen and + * navigation-bar icons come from createMaterialsIcon() in + * src/common/HeatTransferScreenIcons.ts (see doc/multi-screen.md). + */ +import { type EmptySelfOptions, optionize } from "scenerystack/phet-core"; +import type { ScreenOptions } from "scenerystack/sim"; +import { Screen } from "scenerystack/sim"; +import type { Tandem } from "scenerystack/tandem"; +import { createMaterialsIcon } from "../common/HeatTransferScreenIcons.js"; +import HeatTransferColors from "../HeatTransferColors.js"; +import type { HeatTransferPreferencesModel } from "../preferences/HeatTransferPreferencesModel.js"; +import { MaterialsModel } from "./model/MaterialsModel.js"; +import { MaterialsKeyboardHelpContent } from "./view/MaterialsKeyboardHelpContent.js"; +import { MaterialsScreenView } from "./view/MaterialsScreenView.js"; + +// Require tandem to be explicit — accidental omission would break PhET-iO. +export type MaterialsScreenOptions = ScreenOptions & { + tandem: Tandem; + preferences: HeatTransferPreferencesModel; +}; + +export class MaterialsScreen extends Screen<MaterialsModel, MaterialsScreenView> { + public constructor(options: MaterialsScreenOptions) { + super( + // Model factory — called once when the screen is first shown + () => new MaterialsModel(options.preferences), + // View factory — receives the model instance + (model) => + new MaterialsScreenView(model, { + tandem: options.tandem.createTandem("view"), + showFieldStatusProperty: options.preferences.showFieldStatusProperty, + }), + optionize<MaterialsScreenOptions, EmptySelfOptions, ScreenOptions>()( + { + backgroundColorProperty: HeatTransferColors.backgroundColorProperty, + createKeyboardHelpNode: () => new MaterialsKeyboardHelpContent(), + homeScreenIcon: createMaterialsIcon(), + navigationBarIcon: createMaterialsIcon(), + }, + options, + ), + ); + } +} diff --git a/src/materials/model/MaterialsModel.ts b/src/materials/model/MaterialsModel.ts new file mode 100644 index 0000000..2840768 --- /dev/null +++ b/src/materials/model/MaterialsModel.ts @@ -0,0 +1,65 @@ +/** + * MaterialsModel.ts + * + * Screen 5: the material becomes a field too. + * + * Up to here `k`, `rho`, and `c_p` have been three numbers. Here they are three + * more textures, painted the same way temperature is painted, and the governing + * equation becomes the general one: + * + * rho c_p dT/dt = div(k grad T) + * + * The face conductivities in the diffusion kernel are harmonic means, so a + * one-cell strip of foam really does act as a thermal resistance in series rather + * than being averaged into its neighbours. That is what makes a painted barrier + * behave like a barrier. + */ +import type { TModel } from "scenerystack/joist"; +import { BoundaryCondition, FlowPreset, InitialCondition } from "../../common/field/FieldTypes.js"; +import { BrushMode, FieldSimulationModel } from "../../common/model/FieldSimulationModel.js"; +import { FIELD_VIEW_SIZE } from "../../HeatTransferConstants.js"; +import type { HeatTransferPreferencesModel } from "../../preferences/HeatTransferPreferencesModel.js"; + +/** Backing-canvas resolution multiplier, so the field is crisp on high-DPI displays. */ +const CANVAS_SCALE = 2; + +export class MaterialsModel implements TModel { + public readonly field: FieldSimulationModel; + + public constructor(preferences: HeatTransferPreferencesModel) { + this.field = new FieldSimulationModel({ + advectionEnabled: false, + boundaryCondition: BoundaryCondition.INSULATED, + defaultLayers: { + temperature: true, + isotherms: false, + heatFlux: true, + velocity: false, + gradient: false, + material: true, + }, + initialCondition: InitialCondition.UNIFORM, + initialFlowPreset: FlowPreset.NONE, + resolution: preferences.resolutionProperty.value, + displaySize: FIELD_VIEW_SIZE * CANVAS_SCALE, + initiallyPlaying: true, + }); + + // This screen opens in material-painting mode: building the medium is the + // first thing to do, and heating it only means something afterwards. + this.field.brushModeProperty.value = BrushMode.MATERIAL; + } + + public step(dt: number): void { + this.field.step(dt); + } + + public reset(): void { + this.field.reset(); + this.field.brushModeProperty.value = BrushMode.MATERIAL; + } + + public dispose(): void { + this.field.dispose(); + } +} diff --git a/src/materials/view/MaterialsKeyboardHelpContent.ts b/src/materials/view/MaterialsKeyboardHelpContent.ts new file mode 100644 index 0000000..f57c632 --- /dev/null +++ b/src/materials/view/MaterialsKeyboardHelpContent.ts @@ -0,0 +1,29 @@ +/** + * MaterialsKeyboardHelpContent.ts + * + * Content for the keyboard-help dialog (the "?" button in the navigation bar). + * This screen paints on the field, drags the probe, and uses a slider and + * checkboxes, so the left column carries the sim-specific paint section plus the + * stock slider and drag sections. + */ + +import { + BasicActionsKeyboardHelpSection, + MoveDraggableItemsKeyboardHelpSection, + SliderControlsKeyboardHelpSection, + TwoColumnKeyboardHelpContent, +} from "scenerystack/scenery-phet"; +import { HeatBrushKeyboardHelpSection } from "../../common/view/HeatBrushKeyboardHelpSection.js"; + +export class MaterialsKeyboardHelpContent extends TwoColumnKeyboardHelpContent { + public constructor() { + super( + [ + new HeatBrushKeyboardHelpSection(), + new MoveDraggableItemsKeyboardHelpSection(), + new SliderControlsKeyboardHelpSection(), + ], + [new BasicActionsKeyboardHelpSection({ withCheckboxContent: true })], + ); + } +} diff --git a/src/materials/view/MaterialsScreenSummaryContent.ts b/src/materials/view/MaterialsScreenSummaryContent.ts new file mode 100644 index 0000000..c3b6a06 --- /dev/null +++ b/src/materials/view/MaterialsScreenSummaryContent.ts @@ -0,0 +1,28 @@ +/** + * MaterialsScreenSummaryContent.ts + * + * The accessible screen summary for the Materials screen. `currentDetailsContent` + * is derived live from the field's coldest and hottest points, so re-reading the + * summary reports the present state of the plate rather than how it started. + */ +import { DerivedProperty, PatternStringProperty } from "scenerystack/axon"; +import { ScreenSummaryContent } from "scenerystack/sim"; +import { formatCelsiusRounded } from "../../common/view/formatters.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { MaterialsModel } from "../model/MaterialsModel.js"; + +export class MaterialsScreenSummaryContent extends ScreenSummaryContent { + public constructor(model: MaterialsModel) { + const a11y = StringManager.getInstance().getMaterialsA11yStrings(); + + super({ + playAreaContent: a11y.screenSummary.playAreaStringProperty, + controlAreaContent: a11y.screenSummary.controlAreaStringProperty, + currentDetailsContent: new PatternStringProperty(a11y.currentDetailsStringProperty, { + min: new DerivedProperty([model.field.minTemperatureProperty], (kelvin) => formatCelsiusRounded(kelvin)), + max: new DerivedProperty([model.field.maxTemperatureProperty], (kelvin) => formatCelsiusRounded(kelvin)), + }), + interactionHintContent: a11y.screenSummary.interactionHintStringProperty, + }); + } +} diff --git a/src/materials/view/MaterialsScreenView.ts b/src/materials/view/MaterialsScreenView.ts new file mode 100644 index 0000000..29a2a41 --- /dev/null +++ b/src/materials/view/MaterialsScreenView.ts @@ -0,0 +1,112 @@ +/** + * MaterialsScreenView.ts + * + * Screen 5's controls: which material the brush paints, how anisotropic it is, + * and the layers that make a heterogeneous medium legible. + * + * The brush panel here gains a third mode — paint material — and the material + * layer is on by default so the student can see what they have built even where + * the plate is at ambient temperature everywhere. The anisotropy slider is the + * one genuinely advanced control in the simulation: it splits the scalar `k` into + * `k_x` and `k_y` about a fixed geometric mean, so a hot spot spreads into an + * ellipse instead of a circle without the material becoming a different material. + */ + +import { DerivedProperty, PatternStringProperty } from "scenerystack/axon"; +import { Range } from "scenerystack/dot"; +import { type Node, VBox } from "scenerystack/scenery"; +import { MATERIAL_ORDER, type MaterialIdValue } from "../../common/field/Materials.js"; +import { HeatTransferPanel } from "../../common/HeatTransferPanel.js"; +import { BrushControlPanel } from "../../common/view/BrushControlPanel.js"; +import { labelledSlider, panelTitle, themedCheckbox, themedComboBox } from "../../common/view/ControlFactory.js"; +import { FieldScreenView, type FieldScreenViewOptions } from "../../common/view/FieldScreenView.js"; +import { LayerControlPanel } from "../../common/view/LayerControlPanel.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { MaterialsModel } from "../model/MaterialsModel.js"; +import { MaterialsScreenSummaryContent } from "./MaterialsScreenSummaryContent.js"; + +/** + * Anisotropy range. The value multiplies k_x and divides k_y, so 4 means heat + * moves sixteen times more readily along x than along y. + */ +const ANISOTROPY_RANGE = new Range(0.25, 4); + +/** The view supplies the field's accessible name and summary itself. */ +export type MaterialsScreenViewOptions = Omit< + FieldScreenViewOptions, + "fieldAccessibleName" | "fieldAccessibleHelpText" | "screenSummaryContent" +>; + +export class MaterialsScreenView extends FieldScreenView { + public constructor(model: MaterialsModel, providedOptions: MaterialsScreenViewOptions) { + const strings = StringManager.getInstance(); + const a11y = strings.getSharedA11yStrings(); + + super(model.field, { + ...providedOptions, + screenSummaryContent: new MaterialsScreenSummaryContent(model), + fieldAccessibleName: a11y.controls.fieldStringProperty, + fieldAccessibleHelpText: a11y.controls.fieldHelpStringProperty, + }); + + const controls = strings.getControls(); + const materialNames = strings.getMaterialNames(); + const screenA11y = strings.getMaterialsA11yStrings(); + + // ── Left column: what the brush paints ──────────────────────────────────── + + const paintComboBox = themedComboBox<MaterialIdValue>( + model.field.paintMaterialIdProperty, + MATERIAL_ORDER, + (id) => materialNames[`${id}StringProperty`], + this.comboBoxLayer, + controls.paintMaterialStringProperty, + a11y.controls.paintMaterialStringProperty, + ); + + const anisotropyReadout = new PatternStringProperty(strings.getReadouts().ratioStringProperty, { + value: new DerivedProperty([model.field.anisotropyProperty], (ratio) => + ratio >= 1 ? `${ratio.toFixed(2)} : 1` : `1 : ${(1 / ratio).toFixed(2)}`, + ), + }); + + const anisotropy = labelledSlider({ + label: controls.anisotropyStringProperty, + property: model.field.anisotropyProperty, + range: ANISOTROPY_RANGE, + accessibleName: controls.anisotropyStringProperty, + accessibleHelpText: screenA11y.controls.anisotropyHelpStringProperty, + readout: anisotropyReadout, + }); + + const paintPanel = new HeatTransferPanel( + new VBox({ + align: "left", + spacing: 8, + children: [panelTitle(controls.paintMaterialStringProperty), paintComboBox, anisotropy.node], + }), + ); + this.leftColumn.addChild(paintPanel); + + const brushPanel = new BrushControlPanel(model.field, true); + this.leftColumn.addChild(brushPanel); + + // ── Right column: what is drawn ─────────────────────────────────────────── + + const probeCheckbox = themedCheckbox( + model.field.probeVisibleProperty, + controls.showProbeStringProperty, + a11y.controls.probeHelpStringProperty, + ); + + const layerPanel = new LayerControlPanel( + model.field, + ["temperature", "material", "isotherms", "heatFlux", "gradient"], + [probeCheckbox], + ); + this.rightColumn.addChild(layerPanel); + + const screenControls: Node[] = [paintComboBox, anisotropy.slider, ...brushPanel.controls, ...layerPanel.checkboxes]; + this.finishLayout(screenControls); + } +} diff --git a/src/preferences/HeatTransferPreferencesModel.ts b/src/preferences/HeatTransferPreferencesModel.ts new file mode 100644 index 0000000..4751012 --- /dev/null +++ b/src/preferences/HeatTransferPreferencesModel.ts @@ -0,0 +1,56 @@ +/** + * HeatTransferPreferencesModel.ts + * + * Simulation-specific preferences, shown in Preferences → Simulation. Initial + * values come from the matching query parameters. + * + * Resolution is a preference rather than an in-screen control on purpose: it + * changes how much GPU memory the fields occupy and how much work a frame is, so + * it belongs with the other machine-shaped settings and not next to the physics + * controls. A screen reads it when its model is built, so changing it takes + * effect on the next screen load rather than reallocating textures underneath a + * running simulation. + */ + +import { BooleanProperty, StringUnionProperty } from "scenerystack/axon"; +import type { Tandem } from "scenerystack/tandem"; +import { RESOLUTION_PRESET_ORDER, type ResolutionPresetId } from "../HeatTransferConstants.js"; +import HeatTransferNamespace from "../HeatTransferNamespace.js"; +import heatTransferQueryParameters from "./heatTransferQueryParameters.js"; + +export class HeatTransferPreferencesModel { + /** + * Grid resolution the field engines request when a screen's model is built. + * + * A `StringUnionProperty` rather than a plain `Property<ResolutionPresetId>`: + * it carries the valid-values list and the PhET-iO value type that a + * tandem-registered Property requires, and it asserts on a bad value instead of + * letting a typo reach `RESOLUTION_PRESETS` and produce `undefined` cells. + */ + public readonly resolutionProperty: StringUnionProperty<ResolutionPresetId>; + + /** Whether the backend and grid-size readout is shown under the field. */ + public readonly showFieldStatusProperty: BooleanProperty; + + public constructor(tandem?: Tandem) { + this.resolutionProperty = new StringUnionProperty<ResolutionPresetId>( + heatTransferQueryParameters.resolution as ResolutionPresetId, + { + validValues: RESOLUTION_PRESET_ORDER, + ...(tandem && { tandem: tandem.createTandem("resolutionProperty") }), + }, + ); + + this.showFieldStatusProperty = new BooleanProperty( + heatTransferQueryParameters.showFieldStatus, + tandem ? { tandem: tandem.createTandem("showFieldStatusProperty") } : undefined, + ); + } + + public reset(): void { + this.resolutionProperty.reset(); + this.showFieldStatusProperty.reset(); + } +} + +HeatTransferNamespace.register("HeatTransferPreferencesModel", HeatTransferPreferencesModel); diff --git a/src/preferences/HeatTransferPreferencesNode.ts b/src/preferences/HeatTransferPreferencesNode.ts new file mode 100644 index 0000000..7cef396 --- /dev/null +++ b/src/preferences/HeatTransferPreferencesNode.ts @@ -0,0 +1,95 @@ +/** + * HeatTransferPreferencesNode.ts + * + * Custom preferences UI shown in Preferences → Simulation. + * + * The Preferences dialog is always white, so text here uses the light + * control-surface colours rather than `textColorProperty`, which is near-white in + * the default profile and would be invisible. + */ + +import { Text, VBox } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; +import { AquaRadioButtonGroup, Checkbox } from "scenerystack/sun"; +import type { Tandem } from "scenerystack/tandem"; +import HeatTransferColors from "../HeatTransferColors.js"; +import { RESOLUTION_PRESET_ORDER, type ResolutionPresetId } from "../HeatTransferConstants.js"; +import HeatTransferNamespace from "../HeatTransferNamespace.js"; +import { StringManager } from "../i18n/StringManager.js"; +import type { HeatTransferPreferencesModel } from "./HeatTransferPreferencesModel.js"; + +/** Preference-label font size. */ +const LABEL_SIZE = 14; + +export class HeatTransferPreferencesNode extends VBox { + public constructor(preferencesModel: HeatTransferPreferencesModel, tandem?: Tandem) { + const strings = StringManager.getInstance(); + const preferenceStrings = strings.getPreferences(); + + const dialogText = (): { font: PhetFont; fill: typeof HeatTransferColors.controlSurfaceTextColorProperty } => ({ + font: new PhetFont(LABEL_SIZE), + fill: HeatTransferColors.controlSurfaceTextColorProperty, + }); + + const header = new Text(preferenceStrings.titleStringProperty, { + font: new PhetFont({ size: 18, weight: "bold" }), + fill: HeatTransferColors.controlSurfaceTextColorProperty, + }); + + const resolutionHeader = new Text(preferenceStrings.resolutionStringProperty, { + font: new PhetFont({ size: LABEL_SIZE, weight: "bold" }), + fill: HeatTransferColors.controlSurfaceTextColorProperty, + }); + + const resolutionLabels: Record<ResolutionPresetId, typeof preferenceStrings.resolutionClassroomStringProperty> = { + classroom: preferenceStrings.resolutionClassroomStringProperty, + high: preferenceStrings.resolutionHighStringProperty, + large: preferenceStrings.resolutionLargeStringProperty, + extreme: preferenceStrings.resolutionExtremeStringProperty, + }; + + const resolutionRadioButtons = new AquaRadioButtonGroup( + preferencesModel.resolutionProperty, + RESOLUTION_PRESET_ORDER.map((preset) => ({ + value: preset, + createNode: () => new Text(resolutionLabels[preset], dialogText()), + options: { accessibleName: resolutionLabels[preset] }, + })), + { + orientation: "vertical", + align: "left", + spacing: 6, + radioButtonOptions: { radius: 7 }, + accessibleName: preferenceStrings.resolutionStringProperty, + accessibleHelpText: preferenceStrings.resolutionHelpStringProperty, + ...(tandem && { tandem: tandem.createTandem("resolutionRadioButtonGroup") }), + }, + ); + + const resolutionHelp = new Text(preferenceStrings.resolutionHelpStringProperty, { + font: new PhetFont(11), + fill: HeatTransferColors.controlSurfaceTextColorProperty, + maxWidth: 340, + }); + + const statusCheckbox = new Checkbox( + preferencesModel.showFieldStatusProperty, + new Text(preferenceStrings.showFieldStatusStringProperty, dialogText()), + { + checkboxColor: HeatTransferColors.controlSurfaceTextColorProperty, + checkboxColorBackground: HeatTransferColors.controlSurfaceColorProperty, + spacing: 8, + accessibleName: preferenceStrings.showFieldStatusStringProperty, + ...(tandem && { tandem: tandem.createTandem("showFieldStatusCheckbox") }), + }, + ); + + super({ + align: "left", + spacing: 12, + children: [header, resolutionHeader, resolutionRadioButtons, resolutionHelp, statusCheckbox], + }); + } +} + +HeatTransferNamespace.register("HeatTransferPreferencesNode", HeatTransferPreferencesNode); diff --git a/src/preferences/heatTransferQueryParameters.ts b/src/preferences/heatTransferQueryParameters.ts new file mode 100644 index 0000000..5d29d6c --- /dev/null +++ b/src/preferences/heatTransferQueryParameters.ts @@ -0,0 +1,60 @@ +/** + * heatTransferQueryParameters.ts + * + * Sim-specific startup query parameters. + * + * The two interesting ones exist because the field engine's substrate is a + * genuine variable, not an implementation detail: `resolution` chooses how many + * cells the fields have, and `forceCpu` selects the fallback backend even where + * WebGPU works. Together they make it possible to compare the two backends and + * four grid sizes on the same machine, which is how the CPU reference stays + * honest about the GPU path. + * + * Usage: append e.g. `?resolution=high&forceCpu=true` to the sim URL. + */ + +import { logGlobal } from "scenerystack/phet-core"; +import { QueryStringMachine } from "scenerystack/query-string-machine"; +import { DEFAULT_RESOLUTION, RESOLUTION_PRESET_ORDER } from "../HeatTransferConstants.js"; +import HeatTransferNamespace from "../HeatTransferNamespace.js"; + +const heatTransferQueryParameters = QueryStringMachine.getAll({ + /** + * Grid resolution preset. Coarser presets run everywhere; finer ones need a + * capable GPU and are clamped to what the backend can allocate. + */ + resolution: { + type: "string", + defaultValue: DEFAULT_RESOLUTION, + validValues: RESOLUTION_PRESET_ORDER, + public: true, + }, + + /** + * Skip WebGPU and run the CPU reference backend. Useful for comparing the two + * implementations, and for reproducing what a student without WebGPU sees. + */ + forceCpu: { + type: "boolean", + defaultValue: false, + public: true, + }, + + /** + * Show the field-engine status line (backend and grid size) under the field. + * On by default: which substrate is running is part of what this simulation is + * about, not a debugging detail. + */ + showFieldStatus: { + type: "boolean", + defaultValue: true, + public: true, + }, +}); + +HeatTransferNamespace.register("heatTransferQueryParameters", heatTransferQueryParameters); + +// Log query parameters (for the console / PhET-iO). +logGlobal("phet.chipper.queryParameters"); + +export default heatTransferQueryParameters; diff --git a/src/splash.ts b/src/splash.ts new file mode 100644 index 0000000..c298002 --- /dev/null +++ b/src/splash.ts @@ -0,0 +1,13 @@ +/** + * splash.ts + * + * Shows the SceneryStack splash screen while the simulation loads. + * + * Chain position: init.ts → assert.ts → [here] splash.ts → brand.ts + */ + +// assert.ts (and transitively init.ts) must run before the splash screen +import "./assert.js"; + +// Side-effect import: renders the splash screen immediately +import "scenerystack/splash"; diff --git a/src/temperature/TemperatureScreen.ts b/src/temperature/TemperatureScreen.ts new file mode 100644 index 0000000..dd05cab --- /dev/null +++ b/src/temperature/TemperatureScreen.ts @@ -0,0 +1,54 @@ +/** + * TemperatureScreen.ts + * + * Screen 1. Wires the model and view factories together and passes screen-level + * options to `Screen`. + * + * The preferences model rides on the options bag because a screen's field engine + * has to know its grid resolution at construction time, and SceneryStack builds a + * screen's model lazily — the first time a student opens the screen. + * + * Registered in the screens array in src/main.ts. Its home-screen and + * navigation-bar icons come from createTemperatureIcon() in + * src/common/HeatTransferScreenIcons.ts (see doc/multi-screen.md). + */ +import { type EmptySelfOptions, optionize } from "scenerystack/phet-core"; +import type { ScreenOptions } from "scenerystack/sim"; +import { Screen } from "scenerystack/sim"; +import type { Tandem } from "scenerystack/tandem"; +import { createTemperatureIcon } from "../common/HeatTransferScreenIcons.js"; +import HeatTransferColors from "../HeatTransferColors.js"; +import type { HeatTransferPreferencesModel } from "../preferences/HeatTransferPreferencesModel.js"; +import { TemperatureModel } from "./model/TemperatureModel.js"; +import { TemperatureKeyboardHelpContent } from "./view/TemperatureKeyboardHelpContent.js"; +import { TemperatureScreenView } from "./view/TemperatureScreenView.js"; + +// Require tandem to be explicit — accidental omission would break PhET-iO. +export type TemperatureScreenOptions = ScreenOptions & { + tandem: Tandem; + preferences: HeatTransferPreferencesModel; +}; + +export class TemperatureScreen extends Screen<TemperatureModel, TemperatureScreenView> { + public constructor(options: TemperatureScreenOptions) { + super( + // Model factory — called once when the screen is first shown + () => new TemperatureModel(options.preferences), + // View factory — receives the model instance + (model) => + new TemperatureScreenView(model, { + tandem: options.tandem.createTandem("view"), + showFieldStatusProperty: options.preferences.showFieldStatusProperty, + }), + optionize<TemperatureScreenOptions, EmptySelfOptions, ScreenOptions>()( + { + backgroundColorProperty: HeatTransferColors.backgroundColorProperty, + createKeyboardHelpNode: () => new TemperatureKeyboardHelpContent(), + homeScreenIcon: createTemperatureIcon(), + navigationBarIcon: createTemperatureIcon(), + }, + options, + ), + ); + } +} diff --git a/src/temperature/model/TemperatureModel.ts b/src/temperature/model/TemperatureModel.ts new file mode 100644 index 0000000..edf6217 --- /dev/null +++ b/src/temperature/model/TemperatureModel.ts @@ -0,0 +1,54 @@ +/** + * TemperatureModel.ts + * + * Screen 1: temperature as a field. + * + * The physics here is deliberately as thin as it can be while still being real. + * Diffusion runs — a painted hot spot does soften over time, because a plate that + * held a razor-edged blob forever would teach the wrong thing — but there is no + * flow, no material choice, and no flux visualization to interpret. What the + * student is meant to take away is only this: every point of the surface has a + * temperature, and it is a number you can read. + */ +import type { TModel } from "scenerystack/joist"; +import { + BoundaryCondition, + FlowPreset, + InitialCondition, + TEMPERATURE_ONLY_LAYERS, +} from "../../common/field/FieldTypes.js"; +import { FieldSimulationModel } from "../../common/model/FieldSimulationModel.js"; +import { FIELD_VIEW_SIZE } from "../../HeatTransferConstants.js"; +import type { HeatTransferPreferencesModel } from "../../preferences/HeatTransferPreferencesModel.js"; + +/** Backing-canvas resolution multiplier, so the field is crisp on high-DPI displays. */ +const CANVAS_SCALE = 2; + +export class TemperatureModel implements TModel { + public readonly field: FieldSimulationModel; + + public constructor(preferences: HeatTransferPreferencesModel) { + this.field = new FieldSimulationModel({ + advectionEnabled: false, + boundaryCondition: BoundaryCondition.INSULATED, + defaultLayers: TEMPERATURE_ONLY_LAYERS, + initialCondition: InitialCondition.UNIFORM, + initialFlowPreset: FlowPreset.NONE, + resolution: preferences.resolutionProperty.value, + displaySize: FIELD_VIEW_SIZE * CANVAS_SCALE, + initiallyPlaying: true, + }); + } + + public step(dt: number): void { + this.field.step(dt); + } + + public reset(): void { + this.field.reset(); + } + + public dispose(): void { + this.field.dispose(); + } +} diff --git a/src/temperature/view/TemperatureKeyboardHelpContent.ts b/src/temperature/view/TemperatureKeyboardHelpContent.ts new file mode 100644 index 0000000..aba3305 --- /dev/null +++ b/src/temperature/view/TemperatureKeyboardHelpContent.ts @@ -0,0 +1,29 @@ +/** + * TemperatureKeyboardHelpContent.ts + * + * Content for the keyboard-help dialog (the "?" button in the navigation bar). + * This screen paints on the field, drags the probe, and uses a slider and + * checkboxes, so the left column carries the sim-specific paint section plus the + * stock slider and drag sections. + */ + +import { + BasicActionsKeyboardHelpSection, + MoveDraggableItemsKeyboardHelpSection, + SliderControlsKeyboardHelpSection, + TwoColumnKeyboardHelpContent, +} from "scenerystack/scenery-phet"; +import { HeatBrushKeyboardHelpSection } from "../../common/view/HeatBrushKeyboardHelpSection.js"; + +export class TemperatureKeyboardHelpContent extends TwoColumnKeyboardHelpContent { + public constructor() { + super( + [ + new HeatBrushKeyboardHelpSection(), + new MoveDraggableItemsKeyboardHelpSection(), + new SliderControlsKeyboardHelpSection(), + ], + [new BasicActionsKeyboardHelpSection({ withCheckboxContent: true })], + ); + } +} diff --git a/src/temperature/view/TemperatureScreenSummaryContent.ts b/src/temperature/view/TemperatureScreenSummaryContent.ts new file mode 100644 index 0000000..799f141 --- /dev/null +++ b/src/temperature/view/TemperatureScreenSummaryContent.ts @@ -0,0 +1,33 @@ +/** + * TemperatureScreenSummaryContent.ts + * + * The accessible screen summary for the Temperature screen. + * + * `currentDetailsContent` is a live `DerivedProperty` over the field's coldest + * and hottest points, so a screen-reader user re-reading the summary gets the + * present state of the plate rather than a description of how it started. That is + * the non-visual counterpart of watching the colours change, and it is the reason + * the model keeps min/max as Properties rather than computing them only for the + * legend. + */ +import { DerivedProperty, PatternStringProperty } from "scenerystack/axon"; +import { ScreenSummaryContent } from "scenerystack/sim"; +import { formatCelsiusRounded } from "../../common/view/formatters.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { TemperatureModel } from "../model/TemperatureModel.js"; + +export class TemperatureScreenSummaryContent extends ScreenSummaryContent { + public constructor(model: TemperatureModel) { + const a11y = StringManager.getInstance().getTemperatureA11yStrings(); + + super({ + playAreaContent: a11y.screenSummary.playAreaStringProperty, + controlAreaContent: a11y.screenSummary.controlAreaStringProperty, + currentDetailsContent: new PatternStringProperty(a11y.currentDetailsStringProperty, { + min: new DerivedProperty([model.field.minTemperatureProperty], (kelvin) => formatCelsiusRounded(kelvin)), + max: new DerivedProperty([model.field.maxTemperatureProperty], (kelvin) => formatCelsiusRounded(kelvin)), + }), + interactionHintContent: a11y.screenSummary.interactionHintStringProperty, + }); + } +} diff --git a/src/temperature/view/TemperatureScreenView.ts b/src/temperature/view/TemperatureScreenView.ts new file mode 100644 index 0000000..5e4a632 --- /dev/null +++ b/src/temperature/view/TemperatureScreenView.ts @@ -0,0 +1,76 @@ +/** + * TemperatureScreenView.ts + * + * Screen 1's controls: a brush, an isotherm toggle, and a probe. + * + * Everything that could distract from "temperature is a field" has been left + * out. There is no material choice, no flux, no flow — just paint, look, and + * measure. The isotherm checkbox is the one optional layer, because contours are + * the first hint that the colour field has structure worth naming. + */ + +import { type Node, VBox } from "scenerystack/scenery"; +import { HeatTransferPanel } from "../../common/HeatTransferPanel.js"; +import { BrushControlPanel } from "../../common/view/BrushControlPanel.js"; +import { panelTitle, themedCheckbox } from "../../common/view/ControlFactory.js"; +import { FieldScreenView, type FieldScreenViewOptions } from "../../common/view/FieldScreenView.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { TemperatureModel } from "../model/TemperatureModel.js"; +import { TemperatureScreenSummaryContent } from "./TemperatureScreenSummaryContent.js"; + +/** The view supplies the field's accessible name and summary itself. */ +export type TemperatureScreenViewOptions = Omit< + FieldScreenViewOptions, + "fieldAccessibleName" | "fieldAccessibleHelpText" | "screenSummaryContent" +>; + +export class TemperatureScreenView extends FieldScreenView { + public constructor(model: TemperatureModel, providedOptions: TemperatureScreenViewOptions) { + const strings = StringManager.getInstance(); + const a11y = strings.getSharedA11yStrings(); + + super(model.field, { + ...providedOptions, + screenSummaryContent: new TemperatureScreenSummaryContent(model), + fieldAccessibleName: a11y.controls.fieldStringProperty, + fieldAccessibleHelpText: a11y.controls.fieldHelpStringProperty, + }); + + const controls = strings.getControls(); + const screenA11y = strings.getTemperatureA11yStrings(); + + // ── Brush ───────────────────────────────────────────────────────────────── + + const brushPanel = new BrushControlPanel(model.field); + this.leftColumn.addChild(brushPanel); + + // ── Layers and tools ────────────────────────────────────────────────────── + + const isothermCheckbox = themedCheckbox( + model.field.isothermLayerProperty, + controls.isothermLayerStringProperty, + screenA11y.controls.isothermsStringProperty, + ); + const probeCheckbox = themedCheckbox( + model.field.probeVisibleProperty, + controls.showProbeStringProperty, + a11y.controls.probeHelpStringProperty, + ); + + const viewPanel = new HeatTransferPanel( + new VBox({ + align: "left", + spacing: 7, + children: [panelTitle(controls.fieldLayersStringProperty), isothermCheckbox, probeCheckbox], + }), + ); + this.rightColumn.addChild(viewPanel); + + const screenControls: Node[] = [...brushPanel.controls, isothermCheckbox, probeCheckbox]; + this.finishLayout(screenControls); + + // The probe starts hidden so the plate is unobstructed; showing it is the + // student's first deliberate act of measurement. + model.field.probeVisibleProperty.value = false; + } +} diff --git a/tests/common/field/ColorMap.test.ts b/tests/common/field/ColorMap.test.ts new file mode 100644 index 0000000..fc3a2ee --- /dev/null +++ b/tests/common/field/ColorMap.test.ts @@ -0,0 +1,96 @@ +/** + * ColorMap.test.ts + * + * The colour ramp is a quantitative encoding, not decoration: the legend and the + * field are drawn from the same stop list, so these tests guard the properties + * that make the legend readable — monotone ordering, no gaps, and a WGSL + * generator that agrees with the TypeScript sampler. + */ + +import { describe, expect, it } from "vitest"; +import { colorMapWgsl, sampleColorMap, TEMPERATURE_COLOR_STOPS } from "../../../src/common/field/ColorMap.js"; + +describe("TEMPERATURE_COLOR_STOPS", () => { + it("spans the full normalized range", () => { + expect(TEMPERATURE_COLOR_STOPS[0]?.position).toBe(0); + expect(TEMPERATURE_COLOR_STOPS[TEMPERATURE_COLOR_STOPS.length - 1]?.position).toBe(1); + }); + + it("is strictly increasing in position", () => { + for (let n = 1; n < TEMPERATURE_COLOR_STOPS.length; n++) { + const previous = TEMPERATURE_COLOR_STOPS[n - 1]; + const current = TEMPERATURE_COLOR_STOPS[n]; + expect(current?.position ?? 0).toBeGreaterThan(previous?.position ?? 0); + } + }); + + it("increases in luminance from cold to hot", () => { + // Lightness ordering is what keeps the ramp legible in greyscale and under a + // projector, where hue alone can wash out. + const luminance = (position: number): number => { + const { red, green, blue } = sampleColorMap(position); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; + }; + expect(luminance(1)).toBeGreaterThan(luminance(0.5)); + expect(luminance(0.5)).toBeGreaterThan(luminance(0)); + }); +}); + +describe("sampleColorMap", () => { + it("returns the end stops exactly at the ends", () => { + const first = TEMPERATURE_COLOR_STOPS[0]; + const last = TEMPERATURE_COLOR_STOPS[TEMPERATURE_COLOR_STOPS.length - 1]; + expect(sampleColorMap(0)).toEqual({ red: first?.red, green: first?.green, blue: first?.blue }); + expect(sampleColorMap(1)).toEqual({ red: last?.red, green: last?.green, blue: last?.blue }); + }); + + it("clamps outside the range instead of extrapolating", () => { + expect(sampleColorMap(-5)).toEqual(sampleColorMap(0)); + expect(sampleColorMap(5)).toEqual(sampleColorMap(1)); + }); + + it("interpolates linearly between adjacent stops", () => { + const lo = TEMPERATURE_COLOR_STOPS[0]; + const hi = TEMPERATURE_COLOR_STOPS[1]; + if (!(lo && hi)) { + throw new Error("ramp needs at least two stops"); + } + const middle = (lo.position + hi.position) / 2; + expect(sampleColorMap(middle).red).toBeCloseTo((lo.red + hi.red) / 2, 10); + }); + + it("stays inside the unit colour cube everywhere", () => { + for (let n = 0; n <= 100; n++) { + const { red, green, blue } = sampleColorMap(n / 100); + for (const channel of [red, green, blue]) { + expect(channel).toBeGreaterThanOrEqual(0); + expect(channel).toBeLessThanOrEqual(1); + } + } + }); +}); + +describe("colorMapWgsl", () => { + it("declares one entry per stop", () => { + const source = colorMapWgsl(); + expect(source).toContain(`COLOR_STOP_COUNT: u32 = ${TEMPERATURE_COLOR_STOPS.length}u`); + expect(source.match(/vec3<f32>\(/g)?.length).toBe(TEMPERATURE_COLOR_STOPS.length); + }); + + it("writes every literal with a decimal point, as WGSL requires", () => { + // `1` is an i32 literal in WGSL; `1.0` is the f32 the array needs. + for (const literal of colorMapWgsl() + .match(/array<f32, \d+>\(([^)]*)\)/)?.[1] + ?.split(",") ?? []) { + expect(literal.trim()).toMatch(/\./); + } + }); + + it("carries the same stop positions the sampler uses", () => { + const source = colorMapWgsl(); + for (const stop of TEMPERATURE_COLOR_STOPS) { + const literal = Number.isInteger(stop.position) ? `${stop.position}.0` : `${stop.position}`; + expect(source).toContain(literal); + } + }); +}); diff --git a/tests/common/field/CpuFieldEngine.test.ts b/tests/common/field/CpuFieldEngine.test.ts new file mode 100644 index 0000000..25af139 --- /dev/null +++ b/tests/common/field/CpuFieldEngine.test.ts @@ -0,0 +1,248 @@ +/** + * CpuFieldEngine.test.ts + * + * The engine as the model sees it: unit-square coordinates in, physical + * quantities out, with no mention of cells anywhere. These tests are written + * entirely against the `FieldEngine` interface, so they would pass unchanged + * against the WebGPU backend in an environment that had one. + */ + +import { describe, expect, it } from "vitest"; +import { CpuFieldEngine } from "../../../src/common/field/cpu/CpuFieldEngine.js"; +import { FieldBackend } from "../../../src/common/field/FieldEngine.js"; +import { + BoundaryCondition, + FlowPreset, + InitialCondition, + type TransportParameters, +} from "../../../src/common/field/FieldTypes.js"; +import { MATERIALS } from "../../../src/common/field/Materials.js"; +import { SimulationDomain } from "../../../src/common/field/SimulationDomain.js"; +import { + AMBIENT_TEMPERATURE_K, + FIELD_VIEW_SIZE, + KELVIN_TO_CELSIUS_OFFSET, +} from "../../../src/HeatTransferConstants.js"; + +function makeEngine(cells = 32): CpuFieldEngine { + return new CpuFieldEngine(new SimulationDomain(cells, cells), { displaySize: FIELD_VIEW_SIZE }); +} + +const diffusionOnly: TransportParameters = { + advectionEnabled: false, + diffusionEnabled: true, + diffusionScale: 1, + flowScale: 1, + boundaryCondition: BoundaryCondition.INSULATED, + substeps: 8, +}; + +/** The horizontal position of the warmest point along the middle row, in [0, 1]. */ +function warmestAlongCentreRow(engine: CpuFieldEngine): number { + const samples = 200; + let bestU = 0; + let best = Number.NEGATIVE_INFINITY; + for (let n = 0; n <= samples; n++) { + const u = n / samples; + const temperature = engine.sampleTemperature(u, 0.5); + if (temperature > best) { + best = temperature; + bestU = u; + } + } + return bestU; +} + +describe("CpuFieldEngine", () => { + it("reports itself as the CPU backend and owns a canvas", () => { + const engine = makeEngine(); + expect(engine.backend).toBe(FieldBackend.CPU); + expect(engine.canvas.width).toBe(FIELD_VIEW_SIZE); + engine.dispose(); + }); + + it("seeds a uniform field at ambient", () => { + const engine = makeEngine(); + engine.resetField(InitialCondition.UNIFORM); + expect(engine.sampleTemperature(0.5, 0.5)).toBeCloseTo(AMBIENT_TEMPERATURE_K, 4); + const statistics = engine.getStatistics(); + expect(statistics.maxTemperature - statistics.minTemperature).toBeCloseTo(0, 4); + engine.dispose(); + }); + + it("seeds a hot spot at the centre and leaves the corners cool", () => { + const engine = makeEngine(); + engine.resetField(InitialCondition.HOT_SPOT); + expect(engine.sampleTemperature(0.5, 0.5)).toBeGreaterThan(engine.sampleTemperature(0.05, 0.05)); + engine.dispose(); + }); + + it("paints heat where asked and nowhere else", () => { + const engine = makeEngine(); + engine.resetField(InitialCondition.UNIFORM); + const far = engine.sampleTemperature(0.9, 0.9); + + engine.paintTemperature({ u: 0.25, v: 0.25, radius: 0.1, temperature: 450, strength: 0.5 }); + + expect(engine.sampleTemperature(0.25, 0.25)).toBeGreaterThan(AMBIENT_TEMPERATURE_K + 50); + expect(engine.sampleTemperature(0.9, 0.9)).toBeCloseTo(far, 6); + engine.dispose(); + }); + + it("saturates rather than overshooting when a stroke is repeated", () => { + const engine = makeEngine(); + engine.resetField(InitialCondition.UNIFORM); + const stroke = { u: 0.5, v: 0.5, radius: 0.15, temperature: 450, strength: 0.5 }; + for (let n = 0; n < 40; n++) { + engine.paintTemperature(stroke); + } + expect(engine.sampleTemperature(0.5, 0.5)).toBeLessThanOrEqual(450 + 1e-6); + expect(engine.sampleTemperature(0.5, 0.5)).toBeGreaterThan(440); + engine.dispose(); + }); + + it("advances simulated time and relaxes a painted spot", () => { + const engine = makeEngine(); + engine.resetField(InitialCondition.UNIFORM); + engine.setMaterial(MATERIALS.copper); + engine.paintTemperature({ u: 0.5, v: 0.5, radius: 0.12, temperature: 450, strength: 1 }); + + const peakBefore = engine.getStatistics().maxTemperature; + let advanced = 0; + for (let n = 0; n < 30; n++) { + advanced += engine.step(diffusionOnly); + } + + expect(advanced).toBeGreaterThan(0); + expect(engine.simulatedTime).toBeCloseTo(advanced, 6); + expect(engine.getStatistics().maxTemperature).toBeLessThan(peakBefore); + engine.dispose(); + }); + + it("zeroes the clock on reset", () => { + const engine = makeEngine(); + engine.step(diffusionOnly); + expect(engine.simulatedTime).toBeGreaterThan(0); + engine.resetField(InitialCondition.UNIFORM); + expect(engine.simulatedTime).toBe(0); + engine.dispose(); + }); + + it("takes a smaller stability-limited step for a more diffusive material", () => { + const engine = makeEngine(); + engine.setMaterial(MATERIALS.glass); + engine.step(diffusionOnly); + const glassStep = engine.substepSize; + + engine.setMaterial(MATERIALS.copper); + engine.step(diffusionOnly); + const copperStep = engine.substepSize; + + expect(copperStep).toBeLessThan(glassStep); + engine.dispose(); + }); + + it("points the sampled flux from hot toward cold", () => { + const engine = makeEngine(); + engine.resetField(InitialCondition.GRADIENT); // hot left, cold right + const { qx, qy } = engine.sampleHeatFlux(0.5, 0.5); + expect(qx).toBeGreaterThan(0); + expect(Math.abs(qy)).toBeLessThan(Math.abs(qx)); + engine.dispose(); + }); + + it("samples a cross-section whose flux is minus k times its own gradient", () => { + const engine = makeEngine(); + engine.resetField(InitialCondition.GRADIENT); + engine.setMaterial(MATERIALS.copper); + + const samples = engine.sampleCrossSection(0.05, 0.5, 0.95, 0.5, 32); + expect(samples).toHaveLength(32); + expect(samples[0]?.distance).toBe(0); + expect(samples[31]?.distance).toBeGreaterThan(0); + + for (const sample of samples) { + expect(sample.flux).toBeCloseTo(-MATERIALS.copper.conductivity * sample.gradient, 0); + } + // Hot left, cold right: temperature falls along the line. + expect(samples[31]?.temperature ?? 0).toBeLessThan(samples[0]?.temperature ?? 0); + engine.dispose(); + }); + + it("returns no cross-section for a degenerate line", () => { + const engine = makeEngine(); + expect(engine.sampleCrossSection(0.5, 0.5, 0.5, 0.5, 32)).toHaveLength(0); + expect(engine.sampleCrossSection(0.1, 0.5, 0.9, 0.5, 1)).toHaveLength(0); + engine.dispose(); + }); + + it("reports the peak speed of the flow preset it was given", () => { + const engine = makeEngine(); + expect(engine.getMaxSpeed()).toBe(0); + engine.setFlow(FlowPreset.UNIFORM, 0.005); + expect(engine.getMaxSpeed()).toBeCloseTo(0.005, 9); + engine.setFlow(FlowPreset.NONE, 0.005); + expect(engine.getMaxSpeed()).toBe(0); + engine.dispose(); + }); + + it("carries heat downstream at the flow speed when advection is on", () => { + const engine = makeEngine(48); + engine.resetField(InitialCondition.UNIFORM); + engine.setMaterial(MATERIALS.steel); + + const speed = 0.004; + engine.setFlow(FlowPreset.UNIFORM, speed); + engine.paintTemperature({ u: 0.25, v: 0.5, radius: 0.08, temperature: 450, strength: 1 }); + expect(warmestAlongCentreRow(engine)).toBeCloseTo(0.25, 1); + + const parameters: TransportParameters = { + ...diffusionOnly, + advectionEnabled: true, + boundaryCondition: BoundaryCondition.PERIODIC, + substeps: 16, + }; + + // Run to a *simulated* duration rather than a step count: the step size is + // set by the stability limit, so a fixed number of steps would carry the + // blob a distance that depends on the material and the grid — and with + // periodic edges, far enough eventually means all the way back round. + const targetSeconds = 5; + while (engine.simulatedTime < targetSeconds) { + engine.step(parameters); + } + + const expectedTravel = (speed * engine.simulatedTime) / engine.domain.physicalWidth; + expect(warmestAlongCentreRow(engine)).toBeCloseTo(0.25 + expectedTravel, 1); + engine.dispose(); + }); + + it("lets a painted insulator slow the spread of heat", () => { + const build = (withBarrier: boolean): number => { + const engine = makeEngine(48); + engine.resetField(InitialCondition.UNIFORM); + engine.setMaterial(MATERIALS.copper); + if (withBarrier) { + for (let n = 0; n <= 20; n++) { + engine.paintMaterial({ u: 0.5, v: n / 20, radius: 0.05, material: MATERIALS.insulator }); + } + } + engine.paintTemperature({ u: 0.25, v: 0.5, radius: 0.1, temperature: 450, strength: 1 }); + for (let n = 0; n < 60; n++) { + engine.step(diffusionOnly); + } + const beyond = engine.sampleTemperature(0.8, 0.5) - AMBIENT_TEMPERATURE_K; + engine.dispose(); + return beyond; + }; + + expect(build(true)).toBeLessThan(build(false)); + }); + + it("keeps degrees Celsius and kelvin consistent at the ambient point", () => { + const engine = makeEngine(); + engine.resetField(InitialCondition.UNIFORM); + expect(engine.sampleTemperature(0.5, 0.5) - KELVIN_TO_CELSIUS_OFFSET).toBeCloseTo(20, 1); + engine.dispose(); + }); +}); diff --git a/tests/common/field/SimulationDomain.test.ts b/tests/common/field/SimulationDomain.test.ts new file mode 100644 index 0000000..2f97801 --- /dev/null +++ b/tests/common/field/SimulationDomain.test.ts @@ -0,0 +1,81 @@ +/** + * SimulationDomain.test.ts + * + * The domain is the one place that knows the grid size, so these tests are + * really about the claim the architecture rests on: that resolution is a + * parameter and nothing above the domain depends on its value. + */ + +import { describe, expect, it } from "vitest"; +import { DEFAULT_PHYSICAL_SIZE, SimulationDomain } from "../../../src/common/field/SimulationDomain.js"; +import { RESOLUTION_PRESETS } from "../../../src/HeatTransferConstants.js"; + +describe("SimulationDomain", () => { + it("derives the cell size from the physical extent", () => { + const domain = new SimulationDomain(128, 128); + expect(domain.dx).toBeCloseTo(DEFAULT_PHYSICAL_SIZE / 128, 12); + expect(domain.dy).toBeCloseTo(DEFAULT_PHYSICAL_SIZE / 128, 12); + expect(domain.cellCount).toBe(128 * 128); + }); + + it("keeps the physical extent fixed as the resolution changes", () => { + // This is the property that makes the resolution preference safe: refining + // the grid must not silently change the size of the plate being simulated. + for (const cells of Object.values(RESOLUTION_PRESETS)) { + const domain = new SimulationDomain(cells, cells); + expect(domain.physicalWidth).toBe(DEFAULT_PHYSICAL_SIZE); + expect(domain.dx * domain.gridWidth).toBeCloseTo(DEFAULT_PHYSICAL_SIZE, 12); + } + }); + + it("halves the cell size when the resolution doubles", () => { + const coarse = new SimulationDomain(128, 128); + const fine = new SimulationDomain(256, 256); + expect(coarse.dx / fine.dx).toBeCloseTo(2, 12); + }); + + it("indexes row-major", () => { + const domain = new SimulationDomain(8, 4); + expect(domain.index(0, 0)).toBe(0); + expect(domain.index(7, 0)).toBe(7); + expect(domain.index(0, 1)).toBe(8); + expect(domain.index(3, 2)).toBe(19); + }); + + it("clamps out-of-range indices", () => { + const domain = new SimulationDomain(8, 4); + expect(domain.clampedIndex(-5, -5)).toBe(domain.index(0, 0)); + expect(domain.clampedIndex(99, 99)).toBe(domain.index(7, 3)); + }); + + it("maps unit coordinates onto cells, clamped at the edges", () => { + const domain = new SimulationDomain(10, 10); + expect(domain.unitToCell(0, 0)).toEqual({ i: 0, j: 0 }); + expect(domain.unitToCell(0.55, 0.35)).toEqual({ i: 5, j: 3 }); + // Exactly 1 would land one cell past the end without the clamp. + expect(domain.unitToCell(1, 1)).toEqual({ i: 9, j: 9 }); + expect(domain.unitToCell(-0.5, 2)).toEqual({ i: 0, j: 9 }); + }); + + it("places cell centres half a cell in from the index", () => { + const domain = new SimulationDomain(10, 10); + expect(domain.cellCentreX(0)).toBeCloseTo(0.5 * domain.dx, 12); + expect(domain.cellCentreY(9)).toBeCloseTo(9.5 * domain.dy, 12); + }); + + it("builds square domains from the named presets", () => { + const domain = SimulationDomain.fromPreset("classroom"); + expect(domain.gridWidth).toBe(RESOLUTION_PRESETS.classroom); + expect(domain.gridHeight).toBe(RESOLUTION_PRESETS.classroom); + }); + + it("rejects degenerate grids", () => { + expect(() => new SimulationDomain(1, 8)).toThrow(); + expect(() => new SimulationDomain(8.5, 8)).toThrow(); + }); + + it("compares equal only when the discretization matches", () => { + expect(new SimulationDomain(64, 64).equals(new SimulationDomain(64, 64))).toBe(true); + expect(new SimulationDomain(64, 64).equals(new SimulationDomain(64, 32))).toBe(false); + }); +}); diff --git a/tests/common/field/VelocityPresets.test.ts b/tests/common/field/VelocityPresets.test.ts new file mode 100644 index 0000000..a9323f3 --- /dev/null +++ b/tests/common/field/VelocityPresets.test.ts @@ -0,0 +1,103 @@ +/** + * VelocityPresets.test.ts + * + * The flow presets are prescribed analytic fields, so what matters is that they + * are *dimensionless* (so the speed control has one meaning), *bounded* (so the + * advective CFL bound holds), and *divergence-free* (so advecting temperature + * transports heat rather than creating or destroying it). + */ + +import { describe, expect, it } from "vitest"; +import { FLOW_PRESET_ORDER, FlowPreset } from "../../../src/common/field/FieldTypes.js"; +import { evaluateFlowPreset, fillVelocityField } from "../../../src/common/field/VelocityPresets.js"; + +/** Central-difference divergence of a preset, in unit-square coordinates. */ +function divergenceAt(preset: (typeof FLOW_PRESET_ORDER)[number], u: number, v: number): number { + const h = 1e-4; + const right = evaluateFlowPreset(preset, u + h, v).vx; + const left = evaluateFlowPreset(preset, u - h, v).vx; + const down = evaluateFlowPreset(preset, u, v + h).vy; + const up = evaluateFlowPreset(preset, u, v - h).vy; + return (right - left) / (2 * h) + (down - up) / (2 * h); +} + +describe("evaluateFlowPreset", () => { + it("never exceeds unit magnitude, so `speed` is the peak speed", () => { + for (const preset of FLOW_PRESET_ORDER) { + for (let j = 0; j <= 40; j++) { + for (let i = 0; i <= 40; i++) { + const { vx, vy } = evaluateFlowPreset(preset, i / 40, j / 40); + expect(Math.hypot(vx, vy)).toBeLessThanOrEqual(1.0000001); + } + } + } + }); + + it("is divergence-free for every moving preset", () => { + // A compressible flow would pile temperature up at convergence points, which + // would look exactly like heating and would be entirely fictitious. + for (const preset of FLOW_PRESET_ORDER) { + for (const [u, v] of [ + [0.3, 0.3], + [0.5, 0.5], + [0.7, 0.4], + [0.25, 0.8], + ]) { + expect(Math.abs(divergenceAt(preset, u ?? 0, v ?? 0))).toBeLessThan(1e-3); + } + } + }); + + it("is exactly zero when still", () => { + expect(evaluateFlowPreset(FlowPreset.NONE, 0.5, 0.5)).toEqual({ vx: 0, vy: 0 }); + }); + + it("makes uniform flow point right everywhere", () => { + expect(evaluateFlowPreset(FlowPreset.UNIFORM, 0.1, 0.9)).toEqual({ vx: 1, vy: 0 }); + }); + + it("gives channel flow a no-slip wall and a peak on the centreline", () => { + expect(evaluateFlowPreset(FlowPreset.CHANNEL, 0.5, 0).vx).toBeCloseTo(0, 10); + expect(evaluateFlowPreset(FlowPreset.CHANNEL, 0.5, 1).vx).toBeCloseTo(0, 10); + expect(evaluateFlowPreset(FlowPreset.CHANNEL, 0.5, 0.5).vx).toBeCloseTo(1, 10); + }); + + it("gives the vortex a stationary centre and opposite tangents across it", () => { + expect(evaluateFlowPreset(FlowPreset.VORTEX, 0.5, 0.5)).toEqual({ vx: -0, vy: 0 }); + const above = evaluateFlowPreset(FlowPreset.VORTEX, 0.5, 0.35); + const below = evaluateFlowPreset(FlowPreset.VORTEX, 0.5, 0.65); + expect(Math.sign(above.vx)).toBe(-Math.sign(below.vx)); + }); + + it("makes the plume rise in the middle and sink at the walls", () => { + // v increases downward, so rising is negative vy. + expect(evaluateFlowPreset(FlowPreset.PLUME, 0.5, 0.5).vy).toBeLessThan(0); + expect(evaluateFlowPreset(FlowPreset.PLUME, 0.02, 0.5).vy).toBeGreaterThan(0); + expect(evaluateFlowPreset(FlowPreset.PLUME, 0.98, 0.5).vy).toBeGreaterThan(0); + }); +}); + +describe("fillVelocityField", () => { + it("writes interleaved (vx, vy) scaled by the requested speed", () => { + const width = 8; + const height = 8; + const buffer = new Float32Array(2 * width * height); + const speed = 0.004; + fillVelocityField(buffer, width, height, FlowPreset.UNIFORM, speed); + + for (let index = 0; index < width * height; index++) { + expect(buffer[2 * index]).toBeCloseTo(speed, 9); + expect(buffer[2 * index + 1]).toBeCloseTo(0, 9); + } + }); + + it("scales linearly with speed", () => { + const single = new Float32Array(2 * 16); + const double = new Float32Array(2 * 16); + fillVelocityField(single, 4, 4, FlowPreset.VORTEX, 0.001); + fillVelocityField(double, 4, 4, FlowPreset.VORTEX, 0.002); + for (let index = 0; index < single.length; index++) { + expect(double[index]).toBeCloseTo(2 * (single[index] ?? 0), 9); + } + }); +}); diff --git a/tests/common/field/kernels.test.ts b/tests/common/field/kernels.test.ts new file mode 100644 index 0000000..585a506 --- /dev/null +++ b/tests/common/field/kernels.test.ts @@ -0,0 +1,355 @@ +/** + * kernels.test.ts + * + * The physics, tested where it is testable. + * + * WebGPU is not available under Vitest, so these tests exercise the CPU kernels — + * which is the point of having written them as the reference implementation. The + * WGSL shaders reproduce these functions statement for statement, so an invariant + * pinned down here is an invariant the GPU path is written against. (The two + * implementations were also checked against each other numerically in a real + * browser during development; see doc/implementation-notes.md.) + * + * What is asserted here is behaviour that would be wrong in an obvious, + * physically meaningful way if the discretization drifted: energy conservation, + * the direction of heat flow, the effect of an insulating barrier, and the + * stability bound the whole time-stepping scheme rests on. + */ + +import { beforeEach, describe, expect, it } from "vitest"; +import { + BoundaryCondition, + conductivityX, + conductivityY, + type MaterialProperties, + volumetricHeatCapacity, +} from "../../../src/common/field/FieldTypes.js"; +import { + advectStep, + bilinearSample, + diffuseStep, + type FieldGeometry, + fetchCell, + gradientAt, + heatFluxAt, + type MaterialArrays, + stableTimeStep, + totalEnergy, +} from "../../../src/common/field/kernels.js"; +import { MATERIALS } from "../../../src/common/field/Materials.js"; + +const GRID = 24; +const AMBIENT = 293.15; + +const geometry: FieldGeometry = { + gridWidth: GRID, + gridHeight: GRID, + dx: 0.1 / GRID, + dy: 0.1 / GRID, +}; + +/** A uniform material field. */ +function uniformMaterial(material: MaterialProperties): MaterialArrays { + const cells = GRID * GRID; + const arrays: MaterialArrays = { + conductivityX: new Float32Array(cells), + conductivityY: new Float32Array(cells), + volumetricHeatCapacity: new Float32Array(cells), + }; + arrays.conductivityX.fill(conductivityX(material)); + arrays.conductivityY.fill(conductivityY(material)); + arrays.volumetricHeatCapacity.fill(volumetricHeatCapacity(material)); + return arrays; +} + +/** A field at ambient everywhere except one hot cell. */ +function hotSpotField(i: number, j: number, temperature: number): Float32Array { + const field = new Float32Array(GRID * GRID).fill(AMBIENT); + field[j * GRID + i] = temperature; + return field; +} + +/** Runs `steps` diffusion substeps, ping-ponging between two buffers. */ +function diffuse( + field: Float32Array, + material: MaterialArrays, + boundary: typeof BoundaryCondition.INSULATED | typeof BoundaryCondition.FIXED | typeof BoundaryCondition.PERIODIC, + dt: number, + steps: number, +): Float32Array { + let current: Float32Array = field; + let other: Float32Array = new Float32Array(field.length); + for (let n = 0; n < steps; n++) { + diffuseStep(current, other, geometry, material, boundary, AMBIENT, dt); + const swap = current; + current = other; + other = swap; + } + return current; +} + +/** Fractional difference between two values, for comparing large sums. */ +function relativeError(actual: number, expected: number): number { + return Math.abs(actual - expected) / Math.abs(expected); +} + +describe("stableTimeStep", () => { + it("satisfies the explicit-diffusion stability bound", () => { + const alpha = 1.16e-4; + const dt = stableTimeStep(geometry, alpha, 0, 0.4, 1); + // The five-point Laplacian is stable while alpha dt (1/dx^2 + 1/dy^2) <= 1/2. + const criterion = alpha * dt * (1 / geometry.dx ** 2 + 1 / geometry.dy ** 2); + expect(criterion).toBeLessThanOrEqual(0.5); + expect(criterion).toBeCloseTo(0.2, 10); + }); + + it("shrinks in proportion to the diffusivity", () => { + const fast = stableTimeStep(geometry, 1e-4, 0, 0.4, 1); + const slow = stableTimeStep(geometry, 1e-6, 0, 0.4, 1); + expect(slow / fast).toBeCloseTo(100, 6); + }); + + it("is also bounded by the advective Courant number", () => { + const speed = 0.05; + const dt = stableTimeStep(geometry, 1e-9, speed, 0.4, 1); + expect(speed * dt).toBeLessThanOrEqual(geometry.dx * 1.0000001); + }); + + it("returns a finite step when nothing is diffusing or moving", () => { + expect(Number.isFinite(stableTimeStep(geometry, 0, 0, 0.4, 1))).toBe(true); + }); +}); + +describe("fetchCell boundary handling", () => { + let field: Float32Array; + + beforeEach(() => { + field = new Float32Array(GRID * GRID); + for (let index = 0; index < field.length; index++) { + field[index] = index; + } + }); + + it("mirrors the edge cell outward when insulated", () => { + expect(fetchCell(field, geometry, BoundaryCondition.INSULATED, -1, 5, AMBIENT)).toBe( + fetchCell(field, geometry, BoundaryCondition.INSULATED, 0, 5, AMBIENT), + ); + }); + + it("returns the outside value when fixed", () => { + expect(fetchCell(field, geometry, BoundaryCondition.FIXED, -1, 5, AMBIENT)).toBe(AMBIENT); + }); + + it("wraps when periodic", () => { + expect(fetchCell(field, geometry, BoundaryCondition.PERIODIC, -1, 5, AMBIENT)).toBe( + fetchCell(field, geometry, BoundaryCondition.PERIODIC, GRID - 1, 5, AMBIENT), + ); + expect(fetchCell(field, geometry, BoundaryCondition.PERIODIC, GRID, 5, AMBIENT)).toBe( + fetchCell(field, geometry, BoundaryCondition.PERIODIC, 0, 5, AMBIENT), + ); + }); +}); + +describe("bilinearSample", () => { + it("reproduces the cell value exactly at a cell centre", () => { + const field = hotSpotField(7, 9, 400); + const value = bilinearSample(field, geometry, BoundaryCondition.INSULATED, 7.5, 9.5, AMBIENT); + expect(value).toBeCloseTo(400, 4); + }); + + it("averages the two neighbours exactly halfway between them", () => { + const field = new Float32Array(GRID * GRID).fill(AMBIENT); + field[9 * GRID + 7] = 400; + field[9 * GRID + 8] = 300; + const value = bilinearSample(field, geometry, BoundaryCondition.INSULATED, 8.0, 9.5, AMBIENT); + expect(value).toBeCloseTo(350, 4); + }); +}); + +describe("diffuseStep", () => { + it("conserves total energy with insulated boundaries", () => { + const material = uniformMaterial(MATERIALS.copper); + const field = hotSpotField(12, 12, 450); + const before = totalEnergy(field, geometry, material); + + const dt = stableTimeStep(geometry, 1.16e-4, 0, 0.4, 1); + const after = diffuse(field, material, BoundaryCondition.INSULATED, dt, 200); + + // An adiabatic box neither gains nor loses heat. The comparison has to be + // relative: the total is on the order of 10^7 J/m, and the fields are + // Float32, so a few parts in 10^8 of drift over 200 steps is round-off, not + // a leak. A real conservation bug shows up orders of magnitude above this. + expect(relativeError(totalEnergy(after, geometry, material), before)).toBeLessThan(1e-6); + }); + + it("conserves total energy with periodic boundaries", () => { + const material = uniformMaterial(MATERIALS.copper); + const field = hotSpotField(3, 3, 450); + const before = totalEnergy(field, geometry, material); + + const dt = stableTimeStep(geometry, 1.16e-4, 0, 0.4, 1); + const after = diffuse(field, material, BoundaryCondition.PERIODIC, dt, 200); + + expect(relativeError(totalEnergy(after, geometry, material), before)).toBeLessThan(1e-6); + }); + + it("loses energy to fixed boundaries held at ambient", () => { + const material = uniformMaterial(MATERIALS.copper); + const field = hotSpotField(12, 12, 450); + const before = totalEnergy(field, geometry, material); + + const dt = stableTimeStep(geometry, 1.16e-4, 0, 0.4, 1); + const after = diffuse(field, material, BoundaryCondition.FIXED, dt, 400); + + expect(totalEnergy(after, geometry, material)).toBeLessThan(before); + }); + + it("relaxes toward a uniform field", () => { + const material = uniformMaterial(MATERIALS.copper); + const field = hotSpotField(12, 12, 450); + const dt = stableTimeStep(geometry, 1.16e-4, 0, 0.4, 1); + const after = diffuse(field, material, BoundaryCondition.INSULATED, dt, 4000); + + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + for (const value of after) { + min = Math.min(min, value); + max = Math.max(max, value); + } + expect(max - min).toBeLessThan(1); + }); + + it("stays bounded by its own extremes (no overshoot at the stability limit)", () => { + const material = uniformMaterial(MATERIALS.copper); + const field = hotSpotField(12, 12, 450); + const dt = stableTimeStep(geometry, 1.16e-4, 0, 0.4, 1); + const after = diffuse(field, material, BoundaryCondition.INSULATED, dt, 500); + + for (const value of after) { + expect(value).toBeGreaterThanOrEqual(AMBIENT - 1e-6); + expect(value).toBeLessThanOrEqual(450 + 1e-6); + } + }); + + it("spreads faster through copper than through glass", () => { + const dt = stableTimeStep(geometry, 1.16e-4, 0, 0.4, 1); + const spread = (material: MaterialProperties): number => { + const arrays = uniformMaterial(material); + const after = diffuse(hotSpotField(12, 12, 450), arrays, BoundaryCondition.INSULATED, dt, 50); + // How much the peak has dropped is a direct measure of how far heat moved. + return 450 - (after[12 * GRID + 12] ?? 0); + }; + expect(spread(MATERIALS.copper)).toBeGreaterThan(spread(MATERIALS.glass)); + }); + + it("blocks heat with a strip of insulator across the domain", () => { + // Copper everywhere, except one column of foam splitting the plate in two. + const material = uniformMaterial(MATERIALS.copper); + const barrier = MATERIALS.insulator; + const barrierColumn = 12; + for (let j = 0; j < GRID; j++) { + const index = j * GRID + barrierColumn; + material.conductivityX[index] = conductivityX(barrier); + material.conductivityY[index] = conductivityY(barrier); + material.volumetricHeatCapacity[index] = volumetricHeatCapacity(barrier); + } + + const dt = stableTimeStep(geometry, 1.16e-4, 0, 0.4, 1); + const withBarrier = diffuse(hotSpotField(6, 12, 450), material, BoundaryCondition.INSULATED, dt, 400); + const withoutBarrier = diffuse( + hotSpotField(6, 12, 450), + uniformMaterial(MATERIALS.copper), + BoundaryCondition.INSULATED, + dt, + 400, + ); + + // Well past the barrier, the shielded plate must be cooler than the open one. + const probe = 12 * GRID + 20; + expect((withBarrier[probe] ?? 0) - AMBIENT).toBeLessThan((withoutBarrier[probe] ?? 0) - AMBIENT); + }); +}); + +describe("advectStep", () => { + it("translates a blob downstream without changing its peak much", () => { + const field = new Float32Array(GRID * GRID).fill(AMBIENT); + // A smooth bump, so bilinear interpolation has something to interpolate. + for (let j = 0; j < GRID; j++) { + for (let i = 0; i < GRID; i++) { + const r2 = (i - 6) ** 2 + (j - 12) ** 2; + field[j * GRID + i] = AMBIENT + 150 * Math.exp(-r2 / 8); + } + } + + const speed = 0.002; + const velocity = new Float32Array(2 * GRID * GRID); + for (let index = 0; index < GRID * GRID; index++) { + velocity[2 * index] = speed; + } + + // Move the blob exactly six cells to the right. + const dt = (6 * geometry.dx) / speed; + const steps = 12; + let current: Float32Array = field; + let other: Float32Array = new Float32Array(field.length); + for (let n = 0; n < steps; n++) { + advectStep(current, other, velocity, geometry, BoundaryCondition.PERIODIC, AMBIENT, dt / steps, 1); + const swap = current; + current = other; + other = swap; + } + + const peakIndex = current.indexOf(Math.max(...current)); + expect(peakIndex % GRID).toBe(12); + expect(Math.floor(peakIndex / GRID)).toBe(12); + }); + + it("leaves the field alone when the velocity is zero", () => { + const field = hotSpotField(8, 8, 400); + const output = new Float32Array(field.length); + const velocity = new Float32Array(2 * GRID * GRID); + advectStep(field, output, velocity, geometry, BoundaryCondition.INSULATED, AMBIENT, 0.5, 1); + expect(Array.from(output)).toEqual(Array.from(field)); + }); +}); + +describe("gradientAt and heatFluxAt", () => { + it("points the gradient uphill and the flux downhill", () => { + const field = new Float32Array(GRID * GRID).fill(AMBIENT); + // A linear ramp increasing to the right. + for (let j = 0; j < GRID; j++) { + for (let i = 0; i < GRID; i++) { + field[j * GRID + i] = AMBIENT + i * 10; + } + } + + const { gx, gy } = gradientAt(field, geometry, BoundaryCondition.INSULATED, 12, 12, AMBIENT); + expect(gx).toBeGreaterThan(0); + expect(gy).toBeCloseTo(0, 6); + expect(gx).toBeCloseTo(10 / geometry.dx, 3); + + const material = uniformMaterial(MATERIALS.copper); + const { qx, qy } = heatFluxAt(field, geometry, material, BoundaryCondition.INSULATED, 12, 12, AMBIENT); + // Fourier's law: q = -k grad(T), so heat flows toward the cold side. + expect(qx).toBeLessThan(0); + expect(qy).toBeCloseTo(0, 6); + expect(qx).toBeCloseTo(-MATERIALS.copper.conductivity * gx, 0); + }); + + it("bends the flux away from the gradient in an anisotropic material", () => { + const field = new Float32Array(GRID * GRID).fill(AMBIENT); + // A ramp along the diagonal, so grad(T) points at 45 degrees. + for (let j = 0; j < GRID; j++) { + for (let i = 0; i < GRID; i++) { + field[j * GRID + i] = AMBIENT + (i + j) * 10; + } + } + + const anisotropic = uniformMaterial({ ...MATERIALS.copper, anisotropy: 4 }); + const { qx, qy } = heatFluxAt(field, geometry, anisotropic, BoundaryCondition.INSULATED, 12, 12, AMBIENT); + + // k_x is sixteen times k_y, so the flux leans far harder along x than the + // 45-degree gradient alone would give. + expect(Math.abs(qx / qy)).toBeCloseTo(16, 1); + }); +}); diff --git a/tests/common/model/FieldSimulationModel.test.ts b/tests/common/model/FieldSimulationModel.test.ts new file mode 100644 index 0000000..a54494e --- /dev/null +++ b/tests/common/model/FieldSimulationModel.test.ts @@ -0,0 +1,227 @@ +/** + * FieldSimulationModel.test.ts + * + * The model layer: reactive state wired to an engine. Under Vitest there is no + * WebGPU device, so `createFieldEngine` selects the CPU backend — which is the + * fallback path a student without WebGPU gets, and therefore worth having + * covered by the suite in its own right. + */ + +import { TimeSpeed } from "scenerystack/scenery-phet"; +import { describe, expect, it } from "vitest"; +import { FieldBackend } from "../../../src/common/field/FieldEngine.js"; +import { + BoundaryCondition, + FlowPreset, + InitialCondition, + TEMPERATURE_ONLY_LAYERS, +} from "../../../src/common/field/FieldTypes.js"; +import { MATERIALS } from "../../../src/common/field/Materials.js"; +import { + BrushMode, + type FieldSimulationConfig, + FieldSimulationModel, +} from "../../../src/common/model/FieldSimulationModel.js"; +import { AMBIENT_TEMPERATURE_K, FIELD_VIEW_SIZE, MAX_CPU_RESOLUTION } from "../../../src/HeatTransferConstants.js"; + +function makeConfig(overrides?: Partial<FieldSimulationConfig>): FieldSimulationConfig { + return { + advectionEnabled: false, + boundaryCondition: BoundaryCondition.INSULATED, + defaultLayers: TEMPERATURE_ONLY_LAYERS, + initialCondition: InitialCondition.UNIFORM, + initialFlowPreset: FlowPreset.NONE, + resolution: "classroom", + displaySize: FIELD_VIEW_SIZE, + initiallyPlaying: true, + ...overrides, + }; +} + +describe("FieldSimulationModel", () => { + it("falls back to the CPU backend when there is no GPU device", () => { + const model = new FieldSimulationModel(makeConfig()); + expect(model.backend).toBe(FieldBackend.CPU); + model.dispose(); + }); + + it("clamps the CPU backend to a grid it can carry on the main thread", () => { + const model = new FieldSimulationModel(makeConfig({ resolution: "extreme" })); + expect(model.effectiveResolution).toBeLessThanOrEqual(MAX_CPU_RESOLUTION); + expect(model.resolutionReduced).toBe(true); + model.dispose(); + }); + + it("does not report a reduction when the request already fits", () => { + const model = new FieldSimulationModel(makeConfig({ resolution: "classroom" })); + expect(model.resolutionReduced).toBe(false); + model.dispose(); + }); + + it("stays still while paused and advances while playing", () => { + const model = new FieldSimulationModel(makeConfig({ initiallyPlaying: false })); + model.paintAt(0.5, 0.5); + model.step(1 / 60); + expect(model.elapsedTimeProperty.value).toBe(0); + + model.isPlayingProperty.value = true; + model.step(1 / 60); + expect(model.elapsedTimeProperty.value).toBeGreaterThan(0); + model.dispose(); + }); + + it("advances on a single step even while paused", () => { + const model = new FieldSimulationModel(makeConfig({ initiallyPlaying: false })); + model.stepOnce(); + expect(model.elapsedTimeProperty.value).toBeGreaterThan(0); + model.dispose(); + }); + + it("advances less per frame on the slow speed setting", () => { + const fast = new FieldSimulationModel(makeConfig()); + const slow = new FieldSimulationModel(makeConfig()); + slow.timeSpeedProperty.value = TimeSpeed.SLOW; + + for (let n = 0; n < 10; n++) { + fast.step(1 / 60); + slow.step(1 / 60); + } + expect(slow.elapsedTimeProperty.value).toBeLessThan(fast.elapsedTimeProperty.value); + fast.dispose(); + slow.dispose(); + }); + + it("pushes a material change through to the engine", () => { + const model = new FieldSimulationModel(makeConfig()); + model.materialIdProperty.value = "glass"; + model.step(1 / 60); + const glassStep = model.engine.substepSize; + + model.materialIdProperty.value = "copper"; + model.step(1 / 60); + expect(model.engine.substepSize).toBeLessThan(glassStep); + model.dispose(); + }); + + it("splits conductivity about the geometric mean when made anisotropic", () => { + const model = new FieldSimulationModel(makeConfig()); + model.materialIdProperty.value = "copper"; + model.anisotropyProperty.value = 4; + + const material = model.material; + expect(material.anisotropy).toBe(4); + // The identity of the material is preserved: sqrt(k_x k_y) is still k. + expect(Math.sqrt(material.conductivity * 4 * (material.conductivity / 4))).toBeCloseTo( + MATERIALS.copper.conductivity, + 6, + ); + model.dispose(); + }); + + it("pushes a flow change through to the engine", () => { + const model = new FieldSimulationModel(makeConfig({ advectionEnabled: true })); + expect(model.engine.getMaxSpeed()).toBe(0); + model.flowPresetProperty.value = FlowPreset.UNIFORM; + expect(model.engine.getMaxSpeed()).toBeCloseTo(model.flowSpeedProperty.value, 9); + model.dispose(); + }); + + it("paints heat, cool, and material through one entry point", () => { + const model = new FieldSimulationModel(makeConfig()); + + model.brushModeProperty.value = BrushMode.HEAT; + model.paintAt(0.3, 0.3); + expect(model.engine.sampleTemperature(0.3, 0.3)).toBeGreaterThan(AMBIENT_TEMPERATURE_K); + + model.brushModeProperty.value = BrushMode.COOL; + model.paintAt(0.7, 0.7); + expect(model.engine.sampleTemperature(0.7, 0.7)).toBeLessThan(AMBIENT_TEMPERATURE_K); + + // A material stroke must not disturb the temperature it is painted over. + const before = model.engine.sampleTemperature(0.3, 0.3); + model.brushModeProperty.value = BrushMode.MATERIAL; + model.paintAt(0.3, 0.3); + expect(model.engine.sampleTemperature(0.3, 0.3)).toBeCloseTo(before, 6); + model.dispose(); + }); + + it("tracks the probe temperature as the probe moves", () => { + const model = new FieldSimulationModel(makeConfig()); + model.paintAt(0.2, 0.2); + + model.probePositionProperty.value = model.probePositionProperty.value.copy().setXY(0.2, 0.2); + const hot = model.probeTemperatureProperty.value; + model.probePositionProperty.value = model.probePositionProperty.value.copy().setXY(0.85, 0.85); + expect(model.probeTemperatureProperty.value).toBeLessThan(hot); + model.dispose(); + }); + + it("reports a Peclet number that rises with flow and falls with diffusion", () => { + const model = new FieldSimulationModel(makeConfig({ advectionEnabled: true })); + expect(model.pecletNumberProperty.value).toBe(0); // still + + model.flowPresetProperty.value = FlowPreset.UNIFORM; + const base = model.pecletNumberProperty.value; + expect(base).toBeGreaterThan(0); + + model.flowScaleProperty.value = 2; + expect(model.pecletNumberProperty.value).toBeCloseTo(2 * base, 6); + + model.diffusionScaleProperty.value = 0.5; + expect(model.pecletNumberProperty.value).toBeCloseTo(4 * base, 6); + model.dispose(); + }); + + it("snapshots exactly the layers whose Properties are true", () => { + const model = new FieldSimulationModel(makeConfig()); + model.isothermLayerProperty.value = true; + model.heatFluxLayerProperty.value = true; + expect(model.getLayerVisibility()).toEqual({ + temperature: true, + isotherms: true, + heatFlux: true, + velocity: false, + gradient: false, + material: false, + }); + model.dispose(); + }); + + it("returns everything to its starting state on reset", () => { + const model = new FieldSimulationModel(makeConfig({ advectionEnabled: true })); + + model.materialIdProperty.value = "wood"; + model.anisotropyProperty.value = 3; + model.flowPresetProperty.value = FlowPreset.VORTEX; + model.isothermLayerProperty.value = true; + model.brushModeProperty.value = BrushMode.COOL; + model.paintAt(0.5, 0.5); + model.step(1 / 60); + + model.reset(); + + expect(model.materialIdProperty.value).toBe("copper"); + expect(model.anisotropyProperty.value).toBe(1); + expect(model.flowPresetProperty.value).toBe(FlowPreset.NONE); + expect(model.isothermLayerProperty.value).toBe(false); + expect(model.brushModeProperty.value).toBe(BrushMode.HEAT); + expect(model.elapsedTimeProperty.value).toBe(0); + expect(model.engine.sampleTemperature(0.5, 0.5)).toBeCloseTo(AMBIENT_TEMPERATURE_K, 3); + model.dispose(); + }); + + it("does not advect when the screen has advection turned off", () => { + const model = new FieldSimulationModel(makeConfig({ advectionEnabled: false })); + model.flowPresetProperty.value = FlowPreset.UNIFORM; + model.paintAt(0.25, 0.5); + const painted = model.engine.sampleTemperature(0.25, 0.5); + + for (let n = 0; n < 20; n++) { + model.step(1 / 60); + } + // Heat diffuses outward but the peak stays put. + expect(model.engine.sampleTemperature(0.25, 0.5)).toBeLessThan(painted); + expect(model.engine.sampleTemperature(0.25, 0.5)).toBeGreaterThan(model.engine.sampleTemperature(0.6, 0.5)); + model.dispose(); + }); +}); diff --git a/tests/fuzz/fuzz.spec.ts b/tests/fuzz/fuzz.spec.ts new file mode 100644 index 0000000..098c1c3 --- /dev/null +++ b/tests/fuzz/fuzz.spec.ts @@ -0,0 +1,78 @@ +/** + * Optional Playwright fuzz smoke for the SceneryStack template. + * + * Usage: + * npm run test:fuzz + * npm run test:fuzz:quick + * FUZZ_SEED=12345 npm run test:fuzz + */ + +import { expect, test } from "@playwright/test"; + +const FUZZ_DURATION: number = parseInt(process.env["FUZZ_DURATION"] || "15", 10) * 1000; +const FUZZ_SEED: string = process.env["FUZZ_SEED"] || Math.floor(Math.random() * 1_000_000).toString(); +const FUZZ_RATE: string = process.env["FUZZ_RATE"] || "100"; +const FUZZ_POINTERS: string = process.env["FUZZ_POINTERS"] || "1"; + +interface ConsoleMessage { + type: string; + text: string; + location: string; + timestamp: number; +} + +test.describe("Fuzz Testing", () => { + test("should run without console errors", async ({ page }) => { + const errors: ConsoleMessage[] = []; + const assertions: ConsoleMessage[] = []; + const startTime = Date.now(); + + const fuzzUrl = `/?fuzz&randomSeed=${FUZZ_SEED}&fuzzRate=${FUZZ_RATE}&fuzzPointers=${FUZZ_POINTERS}`; + + page.on("console", (msg) => { + const type = msg.type(); + const text = msg.text(); + const location = msg.location(); + const timestamp = Date.now() - startTime; + const message: ConsoleMessage = { + type, + text, + location: `${location.url}:${location.lineNumber}:${location.columnNumber}`, + timestamp, + }; + if (type === "error") { + errors.push(message); + } else if (text.includes("Assertion failed") || text.includes("AssertionError")) { + assertions.push(message); + } + }); + + page.on("pageerror", (error) => { + errors.push({ + type: "pageerror", + text: error.message, + location: error.stack || "unknown", + timestamp: Date.now() - startTime, + }); + }); + + await page.goto(fuzzUrl); + await page.waitForSelector("#sim", { timeout: 30_000 }); + + const checkInterval = 2000; + let elapsed = 0; + while (elapsed < FUZZ_DURATION) { + const waitTime = Math.min(checkInterval, FUZZ_DURATION - elapsed); + await page.waitForTimeout(waitTime); + elapsed += waitTime; + try { + await page.evaluate(() => window.document.hasFocus); + } catch { + break; + } + } + + expect(errors.length, `Found ${errors.length} console errors`).toBe(0); + expect(assertions.length, `Found ${assertions.length} assertion failures`).toBe(0); + }); +}); diff --git a/tests/memory-leak.test.ts b/tests/memory-leak.test.ts new file mode 100644 index 0000000..f0ea6ad --- /dev/null +++ b/tests/memory-leak.test.ts @@ -0,0 +1,120 @@ +/** + * Fleet-standard memory-leak regression suite (SceneryStackTemplate / QubitSketch pattern). + * + * Creates a disposable model object inside a function boundary, disposes it, forces + * garbage collection via global.gc (--expose-gc in vitest.config.ts), then asserts via + * WeakRef that the object was collected. V8 requires a function boundary (not merely + * a block scope) so local strong references die when the helper returns. + */ + +import { describe, expect, it } from "vitest"; +import { CpuFieldEngine } from "../src/common/field/cpu/CpuFieldEngine.js"; +import { + BoundaryCondition, + FlowPreset, + InitialCondition, + TEMPERATURE_ONLY_LAYERS, +} from "../src/common/field/FieldTypes.js"; +import { SimulationDomain } from "../src/common/field/SimulationDomain.js"; +import { FieldSimulationModel } from "../src/common/model/FieldSimulationModel.js"; +import { FIELD_VIEW_SIZE } from "../src/HeatTransferConstants.js"; + +/** + * Force garbage collection with multiple passes. When `earlyExitRef` is supplied + * the loop bails as soon as the object is confirmed collected. The setTimeout(0) + * yield after a live deref() avoids the WeakRef macrotask-liveness pin. + */ +async function forceGC(earlyExitRef?: WeakRef<object>): Promise<void> { + for (let i = 0; i < 15; i++) { + globalThis.gc?.(); + await new Promise<void>((r) => setTimeout(r, 50)); + if (earlyExitRef !== undefined && earlyExitRef.deref() === undefined) { + return; + } + if (earlyExitRef !== undefined) { + await new Promise<void>((r) => setTimeout(r, 0)); + } + } +} + +/** + * A field engine holds several megabytes of typed arrays and a canvas, so it is + * the object in this simulation whose leaking would hurt most: a student moving + * between five screens would accumulate five engines' worth of field storage. + */ +function createAndDisposeFieldEngine(): WeakRef<object> { + const engine = new CpuFieldEngine(new SimulationDomain(64, 64), { displaySize: FIELD_VIEW_SIZE }); + engine.resetField(InitialCondition.HOT_SPOT); + engine.step({ + advectionEnabled: false, + diffusionEnabled: true, + diffusionScale: 1, + flowScale: 1, + boundaryCondition: BoundaryCondition.INSULATED, + substeps: 4, + }); + const ref = new WeakRef<object>(engine); + engine.dispose(); + return ref; +} + +/** The whole model graph: Properties, DerivedProperties, links, and an engine. */ +function createAndDisposeFieldSimulationModel(): WeakRef<object> { + const model = new FieldSimulationModel({ + advectionEnabled: true, + boundaryCondition: BoundaryCondition.PERIODIC, + defaultLayers: TEMPERATURE_ONLY_LAYERS, + initialCondition: InitialCondition.HOT_SPOT, + initialFlowPreset: FlowPreset.UNIFORM, + resolution: "classroom", + displaySize: FIELD_VIEW_SIZE, + initiallyPlaying: true, + }); + model.paintAt(0.5, 0.5); + model.step(1 / 60); + const ref = new WeakRef<object>(model); + model.dispose(); + return ref; +} + +describe("Memory leak regression", () => { + it("global.gc is available (--expose-gc)", () => { + expect(globalThis.gc).toBeDefined(); + }); + + it("sanity: plain object is collected", async () => { + const ref = (() => new WeakRef({ hello: "world" }))(); + await forceGC(ref); + expect(ref.deref()).toBeUndefined(); + }); + + it("CpuFieldEngine is collected after dispose", async () => { + const ref = createAndDisposeFieldEngine(); + await forceGC(ref); + expect(ref.deref()).toBeUndefined(); + }); + + it("FieldSimulationModel is collected after dispose", async () => { + const ref = createAndDisposeFieldSimulationModel(); + await forceGC(ref); + expect(ref.deref()).toBeUndefined(); + }); + + it("engine double dispose() does not throw", () => { + const engine = new CpuFieldEngine(new SimulationDomain(32, 32), { displaySize: FIELD_VIEW_SIZE }); + engine.dispose(); + expect(() => engine.dispose()).not.toThrow(); + }); + + it("repeated create/dispose cycles leave no survivors", async () => { + // Ten engines at 64 x 64 is a few megabytes of field storage; the same cycle + // at a large resolution is what a student browsing between screens does. + const refs: WeakRef<object>[] = []; + for (let i = 0; i < 10; i++) { + refs.push(createAndDisposeFieldEngine()); + } + await forceGC(); + const survivors = refs.filter((r) => r.deref() !== undefined).length; + expect(survivors).toBe(0); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..595a0c0 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,168 @@ +/** + * Vitest setup file — runs before every test file. + * + * SceneryStack requires a Canvas 2D context and an AudioContext at import time. + * happy-dom does not provide working versions, so we patch in minimal mocks + * before any scenerystack code loads, then call init() once for the suite. + * + * This is the canonical test setup for OpenPhysics sims — copy it as-is when + * forking the template, changing only the `name` passed to init() below. + */ + +// ── shared no-op helpers ───────────────────────────────────────────────────── +const noop: () => void = () => { + /* no-op */ +}; +const noopReturn: (val: unknown) => () => unknown = (val: unknown) => (): unknown => val; + +// ── Canvas 2D mock ─────────────────────────────────────────────────────────── +function createMockContext2D(): CanvasRenderingContext2D { + const ctx: Record<string, unknown> = { + canvas: { width: 1, height: 1 }, + save: noop, + restore: noop, + scale: noop, + rotate: noop, + translate: noop, + transform: noop, + setTransform: noop, + getTransform: noopReturn({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }), + resetTransform: noop, + globalAlpha: 1, + globalCompositeOperation: "source-over", + fillStyle: "#000", + strokeStyle: "#000", + lineWidth: 1, + lineCap: "butt", + lineJoin: "miter", + miterLimit: 10, + lineDashOffset: 0, + font: "10px sans-serif", + textAlign: "start", + textBaseline: "alphabetic", + direction: "ltr", + shadowBlur: 0, + shadowColor: "rgba(0,0,0,0)", + shadowOffsetX: 0, + shadowOffsetY: 0, + imageSmoothingEnabled: true, + clearRect: noop, + fillRect: noop, + strokeRect: noop, + fillText: noop, + strokeText: noop, + measureText: () => ({ + width: 0, + actualBoundingBoxAscent: 0, + actualBoundingBoxDescent: 0, + fontBoundingBoxAscent: 0, + fontBoundingBoxDescent: 0, + actualBoundingBoxLeft: 0, + actualBoundingBoxRight: 0, + emHeightAscent: 0, + emHeightDescent: 0, + }), + beginPath: noop, + closePath: noop, + moveTo: noop, + lineTo: noop, + bezierCurveTo: noop, + quadraticCurveTo: noop, + arc: noop, + arcTo: noop, + ellipse: noop, + rect: noop, + fill: noop, + stroke: noop, + clip: noop, + isPointInPath: noopReturn(false), + isPointInStroke: noopReturn(false), + getLineDash: noopReturn([]), + setLineDash: noop, + createLinearGradient: () => ({ addColorStop: noop }), + createRadialGradient: () => ({ addColorStop: noop }), + createPattern: noopReturn(null), + createImageData: (w: number, h: number) => ({ width: w, height: h, data: new Uint8ClampedArray(w * h * 4) }), + getImageData: (_x: number, _y: number, w: number, h: number) => ({ + width: w, + height: h, + data: new Uint8ClampedArray(w * h * 4), + }), + putImageData: noop, + drawImage: noop, + }; + return ctx as unknown as CanvasRenderingContext2D; +} + +// ── Web Audio mock ─────────────────────────────────────────────────────────── +class MockAudioContext { + readonly sampleRate = 44100; + readonly state = "running" as AudioContextState; + readonly destination = {} as AudioDestinationNode; + createGain(): GainNode { + return { + gain: { value: 1, setValueAtTime: noop, linearRampToValueAtTime: noop }, + connect: noop, + disconnect: noop, + } as unknown as GainNode; + } + createBufferSource(): AudioBufferSourceNode { + return { + buffer: null, + connect: noop, + disconnect: noop, + start: noop, + stop: noop, + playbackRate: { value: 1 }, + } as unknown as AudioBufferSourceNode; + } + createOscillator(): OscillatorNode { + return { connect: noop, start: noop, stop: noop, frequency: { value: 440 } } as unknown as OscillatorNode; + } + createDynamicsCompressor(): DynamicsCompressorNode { + return { connect: noop, disconnect: noop } as unknown as DynamicsCompressorNode; + } + decodeAudioData(_data: ArrayBuffer): Promise<AudioBuffer> { + return Promise.resolve({ + length: 0, + duration: 0, + sampleRate: 44100, + numberOfChannels: 1, + getChannelData: () => new Float32Array(0), + } as unknown as AudioBuffer); + } + close(): Promise<void> { + return Promise.resolve(); + } + resume(): Promise<void> { + return Promise.resolve(); + } +} +(globalThis as Record<string, unknown>)["AudioContext"] = MockAudioContext; +(globalThis as Record<string, unknown>)["webkitAudioContext"] = MockAudioContext; + +// ── patch getContext("2d") before any scenerystack import ──────────────────── +const origGetContext: typeof HTMLCanvasElement.prototype.getContext = HTMLCanvasElement.prototype.getContext; +HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement, contextId: string, ...args: unknown[]) { + if (contextId === "2d") { + const ctx = createMockContext2D(); + (ctx as unknown as Record<string, unknown>)["canvas"] = this; + return ctx as unknown as ReturnType<typeof origGetContext>; + } + return origGetContext.call(this, contextId, ...args) as ReturnType<typeof origGetContext>; +} as typeof origGetContext; + +// ── SceneryStack init ──────────────────────────────────────────────────────── +import { init, madeWithSceneryStackSplashDataURI } from "scenerystack/init"; + +init({ + // Change to match your package.json "name" when forking the template. + name: "heat-transfer", + version: "1.0.0-test", + brand: "made-with-scenerystack", + locale: "en", + availableLocales: ["en"], + splashDataURI: madeWithSceneryStackSplashDataURI, + allowLocaleSwitching: false, + colorProfiles: ["default"], +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2eb7d1b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2024", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "module": "ESNext", + "resolveJsonModule": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "exactOptionalPropertyTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noUncheckedSideEffectImports": true, + "erasableSyntaxOnly": true, + "types": ["vite/client", "vite-plugin-pwa/client"] + }, + "include": ["src"] +} diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 0000000..6e0601b --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["scripts", "*.config.ts"] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..ad4fcad --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vite/client", "vitest/globals"] + }, + "// webgpu-globals.d.ts": "Ambient declarations are not inherited through `extends`, and the tests import src modules that use the WebGPU flag namespaces. Listing the one declaration file keeps this project type-checking the same source the app project does, without re-checking all of src.", + "include": ["tests", "src/common/field/gpu/webgpu-globals.d.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..6f1f779 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,193 @@ +import type { Plugin, Rollup } from "vite"; +import { defineConfig } from "vite"; +import { VitePWA } from "vite-plugin-pwa"; + +/** + * Security headers required for: + * - COOP/COEP: SharedArrayBuffer support + * - CSP: restrict resource loading to same-origin + known blob/data exceptions + * - X-Content-Type-Options: prevent MIME sniffing + * - X-Frame-Options: prevent clickjacking (belt-and-suspenders alongside frame-ancestors) + */ +const securityHeaders: Record<string, string> = { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + "Content-Security-Policy": [ + "default-src 'self'", + // 'unsafe-eval' is required for SceneryStack query parameter parsing + "script-src 'self' 'unsafe-eval'", + "worker-src blob: 'self'", + // Inline styles are set via element.style / cssText throughout the UI layer + "style-src 'self' 'unsafe-inline'", + // data: for icons + "img-src 'self' data:", + "media-src 'self' blob:", + // blob: for fetch inside workers + "connect-src 'self' blob:", + "font-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "frame-ancestors 'none'", + ].join("; "), + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", +}; + +/** Escape a string for literal use inside a `RegExp`. */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Decode a Rollup asset source (string or bytes) to text. */ +function assetSourceToText(source: string | Uint8Array): string { + return typeof source === "string" ? source : Buffer.from(source).toString("utf8"); +} + +/** + * Return `html` with the tag that references `fileName` replaced by an inline + * `<script>`/`<style>`, or `null` when this asset is not referenced. + * + * The replacement is a function (never a string) so `$` sequences in the JS/CSS + * are not interpreted as `String.prototype.replace` special patterns. + */ +function inlineAsset(html: string, fileName: string, item: Rollup.OutputChunk | Rollup.OutputAsset): string | null { + const ref = escapeRegExp(fileName); + + if (item.type === "chunk") { + const scriptTag = new RegExp(`<script[^>]*\\bsrc="[^"]*${ref}"[^>]*></script>`); + if (!scriptTag.test(html)) { + return null; + } + // Escape `</script>` so an inlined occurrence cannot close the tag early. + const code = item.code.replace(/<\/script>/g, "<\\/script>"); + return html.replace(scriptTag, () => `<script type="module">${code}</script>`); + } + + if (fileName.endsWith(".css")) { + const linkTag = new RegExp(`<link[^>]*\\bhref="[^"]*${ref}"[^>]*>`); + if (!linkTag.test(html)) { + return null; + } + const css = assetSourceToText(item.source); + return html.replace(linkTag, () => `<style>${css}</style>`); + } + + return null; +} + +/** + * Dependency-free single-file plugin. After the bundle is generated, splice every + * JS chunk and CSS asset that `index.html` references directly into the HTML as + * inline tags, drop those now-orphaned files, and strip external icon links so the + * result has no outbound references — `dist/index.html` is the entire build. + * + * Safe because the production bundle is self-contained: no web workers, no .wasm, + * no `import.meta.url`, no runtime fetches of local files. + */ +function inlineSingleFile(): Plugin { + return { + name: "inline-single-file", + enforce: "post", + generateBundle(_options: Rollup.NormalizedOutputOptions, bundle: Rollup.OutputBundle): void { + for (const htmlName of Object.keys(bundle)) { + const htmlAsset = bundle[htmlName]; + if (!htmlName.endsWith(".html") || htmlAsset?.type !== "asset" || typeof htmlAsset.source !== "string") { + continue; + } + + let html = htmlAsset.source; + for (const fileName of Object.keys(bundle)) { + const item = bundle[fileName]; + if (!item) { + continue; + } + const inlined = inlineAsset(html, fileName, item); + if (inlined !== null) { + html = inlined; + delete bundle[fileName]; + } + } + + // Drop external favicon/touch-icon links — public/ is not emitted in single mode. + htmlAsset.source = html.replace(/\s*<link[^>]*\brel="(?:icon|apple-touch-icon)"[^>]*>/g, ""); + } + }, + }; +} + +// https://vite.dev/config/ +export default defineConfig(({ mode }) => { + // `vite build --mode single` produces a single self-contained dist/index.html. + const single = mode === "single"; + + return { + // So the build can be served from an arbitrary path + base: "./", + build: { + // Requires Vite 8+ / esbuild ≥0.24. Run `npm ci` if build errors on ES2024. + target: "es2024", + // SceneryStack bundles exceed Vite's default 500 kB chunk warning. + chunkSizeWarningLimit: 5000, + ...(single && { + // Inline every imported asset as a base64 data URI instead of emitting files. + assetsInlineLimit: 100_000_000, + // Emit one CSS file (no per-chunk split) so there is a single tag to inline. + cssCodeSplit: false, + // Skip copying public/ (favicon, icons) — nothing external should remain. + copyPublicDir: false, + rollupOptions: { + // Collapse dynamic imports into the single entry chunk. + output: { inlineDynamicImports: true }, + }, + }), + }, + server: { + headers: securityHeaders, + }, + preview: { + headers: securityHeaders, + }, + plugins: single + ? [inlineSingleFile()] + : [ + VitePWA({ + registerType: "autoUpdate", + includeAssets: ["favicon.ico", "icons/apple-touch-icon.png"], + manifest: { + name: "Heat Transfer", + // biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys + short_name: "HeatTransfer", + description: "A SceneryStack simulation: Heat Transfer", + // biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys + theme_color: "#1a1a2e", + // biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys + background_color: "#000000", + display: "standalone", + orientation: "landscape", + icons: [ + { + src: "icons/icon-192.png", + sizes: "192x192", + type: "image/png", + }, + { + src: "icons/icon-512.png", + sizes: "512x512", + type: "image/png", + }, + { + src: "icons/icon.svg", + sizes: "any", + type: "image/svg+xml", + purpose: "maskable", + }, + ], + }, + workbox: { + maximumFileSizeToCacheInBytes: 12 * 1024 * 1024, + globPatterns: ["**/*.{js,css,html,svg,png,woff2}"], + }, + }), + ], + }; +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7a44eed --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // happy-dom gives a lightweight DOM so SceneryStack code can import. + environment: "happy-dom", + setupFiles: ["./tests/setup.ts"], + include: ["tests/**/*.test.ts"], + // --expose-gc lets us call global.gc() to force garbage collection + execArgv: ["--expose-gc"], + testTimeout: 30_000, + }, +});