diff --git a/.claude/launch.json b/.claude/launch.json index 64fb03b..1a0f1f4 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,12 @@ "runtimeExecutable": "python3", "runtimeArgs": ["main.py"], "port": 4173 + }, + { + "name": "visuallm-web", + "runtimeExecutable": "python3", + "runtimeArgs": ["-m", "http.server", "4179", "--directory", "web"], + "port": 4179 } ] } diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..686fa2c --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Copy this file to ".env" (same folder) and fill in your key(s). +# The launcher (launch.py / VisualLM.command / desktop.py) loads it automatically. +# Format is one KEY=VALUE per line. Anything already set in your shell wins. + +# Best-quality animations (recommended). Get a key at https://console.anthropic.com +ANTHROPIC_API_KEY=sk-ant-... + +# Optional alternatives / fallbacks (used only if Anthropic isn't set): +# OPENAI_API_KEY=sk-... +# GEMINI_API_KEY=... + +# No cloud key? VisualLM still runs entirely on its built-in pure-code library — +# 3D chemistry, balanced reactions, the step-by-step solver, and the interactive +# demos all work offline. A key only adds AI generation for free-form prompts. + +# Optional: pin the port (default 4173). +# VISUALLM_PORT=4173 + +# Optional: require a shared access code to generate (for a published instance). +# VISUALLM_ACCESS_CODE=letmein diff --git a/.gitignore b/.gitignore index 18df511..f2e57f6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,16 @@ __pycache__/ .env .venv/ venv/ + +# macOS app build artifacts (run ./make_app.sh to regenerate) +VisualLM.app/ +VisualLM.iconset/ +VisualLM.png +VisualLM.icns +build/ +dist/ +*.spec.bak + +# Runtime user/AI DLC packs (not source) +data/dlc_custom/ +web/data/dlc_custom/ diff --git a/Dockerfile b/Dockerfile index 6b1deb6..7091589 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,12 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY main.py scene_library.py scene_library_generated.json validate_scene.js \ +# Server, the libraries (STEM scenes + curriculum demos + chemistry), the +# generated data (*_generated.json — scene, demo, physics, chemistry), the +# validator, and the UI. The glob keeps new generated data files shipping +# automatically. +COPY main.py scene_library.py demo_library.py chemistry.py validate_scene.js \ + *_generated.json \ index.html app.js sandbox-worker.js styles.css ./ # Cloud platforms inject PORT; main.py binds 0.0.0.0 automatically when set. diff --git a/README.md b/README.md index 88074dc..6189cc4 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,36 @@ or 3D explanation**. Instead of picking from a handful of fixed demos, the AI *writes the animation itself* — generating JavaScript that runs in a locked-down sandbox in your browser — so it can visualize essentially any topic. +## Two editions + +VisualLM ships in two flavors of the same project: + +| | **Browser edition** (`web/`) | **Desktop / server edition** (repo root) | +|---|---|---| +| Runs | 100% in the browser — **no server, no install, no API key** | Local Python server (or packaged desktop app) | +| Host | **GitHub Pages** (static) — instant, free, shareable link | Your machine, or any server host (Render, etc.) | +| Chemistry: 3D molecules + equation balancing | ✅ | ✅ | +| 333 interactive curriculum demos + 50 curated scenes | ✅ | ✅ | +| Free-form AI generation (type *any* idea) | ❌ (needs a model + key) | ✅ (Claude ▸ ChatGPT ▸ Gemini) | +| AI tutor chat | ❌ | ✅ | + +The browser edition is a faithful client-side port: the chemistry engine and the +demo/scene matcher are reimplemented in JavaScript (`web/js/`) with **verified +parity** against the Python — same molecules, same exact-integer balancing, same +scene routing. It's the fastest way to share VisualLM (just a link); the desktop +edition adds the open-ended AI generation that needs server-side keys. + +- **Browser edition (live):** https://maxpeng59.github.io/VisualLM/ +- **Desktop app:** `python3 launch.py` (browser app-window) or `python3 desktop.py` + (native window), or build a standalone app with `./build_app.sh`. +- **Server deploy (full app):** see [Deploy](#deploy) below (Render blueprint). + ## How it works ``` prompt ──▶ /api/visualize ──▶ AI writes scene code ──▶ sandbox renders it │ │ - (Claude ▸ ChatGPT ▸ Gemini ▸ Ollama) │ + (Claude ▸ ChatGPT ▸ Gemini) │ error? └─▶ /api/repair ──┐ │ fixed code ◀───────────────┘ @@ -97,25 +121,50 @@ Generation tries providers in this order — the first one with a key wins: | **Claude** | `ANTHROPIC_API_KEY` (+ `pip install anthropic`) | Primary generator — best quality | | **ChatGPT** | `OPENAI_API_KEY` | Cloud fallback generator | | **Gemini** | `GEMINI_API_KEY` | Cloud fallback generator | -| **Ollama** | run `ollama serve` locally | Offline fallback + default tutor | -All cloud providers are called over plain REST — no extra SDKs required. +All cloud providers are called over plain REST — no extra SDKs required. **The AI +relies only on code — there is no local model app.** With **no key at all**, +VisualLM still runs entirely on its built-in pure-code library: 3D chemistry, +balanced reactions, the step-by-step solver, and every interactive demo work +offline. A key only adds free-form "type any idea" generation. -## Run locally +## Run it (as an app) -```bash -# 1. (Recommended) enable at least one cloud generator -pip install -r requirements.txt # only needed for Claude -export ANTHROPIC_API_KEY=sk-ant-... # and/or OPENAI_API_KEY / GEMINI_API_KEY +The easy way — after a one-time setup, **no terminal needed**: -# 2. (Optional) local fallback + tutor: run Ollama with a model -ollama pull qwen2.5:7b +1. **Install dependencies (once).** Run **`./install.sh`** (macOS/Linux) or + **`install.bat`** (Windows). It creates a `.venv` and downloads everything — + the Claude client and the native-window backend (pywebview). Then, optionally, + add a key to `.env`: `ANTHROPIC_API_KEY=sk-ant-...`. **No key? Skip it** — + chemistry, reactions, the solver, and the demos all run offline; a key only + adds free-form AI generation. +2. **Launch it.** + - **macOS:** double-click **`VisualLM.command`** in Finder (first time: right-click → Open to clear the macOS warning). + - **Any OS:** `python3 launch.py` -# 3. Start the app -python3 main.py -``` + The launcher loads `.env`, starts the server on a free port, waits until it's + healthy, and opens VisualLM in its own app-style window. Keep that window open; + Ctrl+C (or closing it) stops everything. + +**Prefer a true native window** (no browser chrome at all)? `pip install pywebview` +then `python3 desktop.py`. -Then open . +**Want a real, self-contained app** (bundles Python — no terminal, no repo needed +to run)? `./build_app.sh` produces **`dist/VisualLM.app`** via PyInstaller; move it +to /Applications and double-click. It runs the server in-process (`app_main.py`) +and serves bundled assets. For Claude inside the bundle, `pip install anthropic` +before building; for a native window, `pip install pywebview` before building. +The packaged app reads a `.env` placed next to `VisualLM.app` or in `~/.visuallm/`. + +### Plain manual start + +Equivalent to what the launcher does, if you'd rather drive it yourself: + +```bash +./install.sh # one-time: creates .venv + installs deps +export ANTHROPIC_API_KEY=sk-ant-... # optional — and/or OPENAI_API_KEY / GEMINI_API_KEY +python3 main.py # then open http://127.0.0.1:4173 +``` ## Deploy to the web @@ -134,6 +183,23 @@ binds `0.0.0.0` automatically when it's set. strangers can't burn your API credits. Per-IP rate limiting is on by default (`VISUALLM_RATE_LIMIT`, 10/min via render.yaml). +It works with **no API key** — all curriculum demos and STEM library scenes are +served from the bundled libraries (no model calls), so the site is fully useful +out of the box. A key only enables free-form "type any idea" generation. + +### Custom domain (e.g. www.VisualLM.com) + +The app is origin-agnostic (all requests are relative paths), so a custom domain +needs only DNS — no code changes: + +1. Own the domain (buy `VisualLM.com` from any registrar if you don't). +2. In your Render service → **Settings → Custom Domains** → add `www.visuallm.com` + (and `visuallm.com`). Render shows the exact DNS records to create. +3. At your registrar's DNS panel, add what Render gives you — typically: + - `www` → **CNAME** → `your-app.onrender.com` + - root `@` → Render's **A record** (or an ALIAS/ANAME → `your-app.onrender.com`) +4. Wait for DNS to propagate (minutes–hours); Render auto-provisions free HTTPS. + ### Any other Docker host (Fly.io, Railway, Cloud Run, a VPS…) ```bash @@ -153,9 +219,12 @@ HTTP round-trips against every endpoint (with stubbed generators). ## Files -- `main.py` — web server, the four AI bridges (Claude/OpenAI/Gemini/Ollama), - the validate→auto-fix→repair pipeline, library retrieval + cache, rate - limiting, and the system prompt that defines the rendering contract. +- `main.py` — web server, the three cloud AI bridges (Claude/OpenAI/Gemini — + no local model app), the validate→auto-fix→repair pipeline, library retrieval + + cache, rate limiting, and the system prompt that defines the rendering + contract. +- `install.sh` / `install.bat` — one-shot dependency installers (create a + `.venv`, download the Claude client + the pywebview native-window backend). - `sandbox-worker.js` — the sandboxed Web Worker that runs generated code on an OffscreenCanvas, the `H` helper library, and the software 3D pipeline. - `validate_scene.js` — headless server-side scene validator (hardened Node @@ -165,6 +234,12 @@ HTTP round-trips against every endpoint (with stubbed generators). - `app.js` — orchestration: sandbox runner, generate→run→repair loop, orbit controls, playback, tutor chat, resources, status. - `index.html` / `styles.css` — app structure and visual design. +- `launch.py` / `VisualLM.command` / `desktop.py` — run VisualLM as an app: + the one-click launcher (loads `.env`, starts the server on a free port, opens + an app-style window), the macOS double-click wrapper, and the optional + native-window version (pywebview). `.env.example` is the key template. +- `stem-viz-plugin/` — the scene-generation capability packaged as a portable + Claude skill + plugin (drop into any AI); see its own README. - `Dockerfile` / `render.yaml` — production deployment (Docker image bundles Node for the validator). - `tests/` — stdlib-only test suite (covers the validator, auto-fixer, @@ -188,7 +263,6 @@ When `VISUALLM_ACCESS_CODE` is set, all POST/DELETE endpoints require the - `ANTHROPIC_API_KEY` / `ANTHROPIC_MODEL` (default `claude-opus-4-8`) - `OPENAI_API_KEY` / `OPENAI_MODEL` (default `gpt-4o`) / `OPENAI_BASE_URL` - `GEMINI_API_KEY` / `GEMINI_MODEL` (default `gemini-2.0-flash`) / `GEMINI_BASE_URL` -- `OLLAMA_URL` (default `http://127.0.0.1:11434`) / `OLLAMA_MODEL` - `VISUALLM_ACCESS_CODE` — require a shared code for generation (recommended on public deployments). - `VISUALLM_RATE_LIMIT` — requests/min per IP (default 20; 0 disables). diff --git a/USER_EVALUATION.md b/USER_EVALUATION.md new file mode 100644 index 0000000..541adec --- /dev/null +++ b/USER_EVALUATION.md @@ -0,0 +1,133 @@ +# VisualLM — User Evaluation Form + +> A hands-on evaluation done by using the app as a student would, then filling out +> this form. Each finding is grounded in something actually typed/clicked, with a +> concrete fix and a code reference where one applies. + +--- + +## 1. Session details + +| Field | Value | +|---|---| +| Tester role | Student (first-time-ish user) | +| Edition tested | Browser edition (client-side, no server/key) | +| URL / build | `http://localhost:4179` (web/, `parameterized-demos` branch) | +| Viewports | Desktop (1280×800) + Mobile (375×812) | +| Date | 2026-06-26 | +| Console errors seen | None | + +## 2. What I actually tested + +`2H2 + O2 -> 2H2O` (balance) · `x^2 - 5x + 6 = 0` (quadratic) · +`triangle with a=5, b=7, C=40 degrees` (solver) · `benzene` (3D molecule) · +"Surprise me" · `how does a transformer neural network work` (out-of-scope) · +"Solve it step by step" button · mobile layout. + +## 3. Overall impression + +**★★★☆☆ — Strong, correct, instant content; weak bridge from a user's words to it.** + +The deterministic engine (triangle solver, molecules, balancing, slider demos) is +genuinely good and fast. The weak link is routing: a plain typed equation or a +casual question frequently lands on a confidently-wrong demo, and one headline +feature ("step-by-step solver") promises more than the browser edition delivers. + +## 4. What works well (keep it) + +- [x] **3D molecules** (`benzene`) — ball-and-stick, CPK colors, double bonds as + parallel sticks, drag-to-orbit / scroll-zoom. Excellent. +- [x] **Triangle solver** — auto-advancing "Slide 2 of 4", Law of Cosines worked + through *your* numbers, triangle drawn to scale. Best feature in the app. +- [x] **Chemistry balancing** — the atom-balance tally (H 4=4 ✓, O 2=2) makes + conservation visible. +- [x] **Instant render + live sliders**, clean dark/light theming, zero console + errors, and a populated demo on landing. + +## 5. Findings by severity + +### P0 — Topic routing confidently serves the wrong demo + +The matcher is keyword-only, with no sense of equation *structure* or common-word +stopwords. The 2.0 "serve confidently" threshold lets false positives through with +**no "closest match" caveat**. + +| Typed | Got | Cause | +|---|---|---| +| `x^2 - 5x + 6 = 0` | **0/0 limit by factoring** (Calculus, score 3) | "quadratic" never spelled out; correct demos (Quadratic formula, Completing the square) sit at 2.5 and lose | +| `how does a transformer neural network work` | **Work done by a force** (score 2) | everyday word "work" | +| `explain how vaccines work` | **Work done by a force** (score 2) | same | +| `power dynamics in a relationship` | **Electrical power P=V·I** (score 2) | "power" | + +Verified via the scorer: adding the literal word "quadratic" lifts the right demo +from 2.5 → 4.5. The content exists; the engine just can't read a typed equation's shape. + +**Fixes (in order of payoff):** +1. **Structural recognition of typed equations** — you already do this for triangles + in `web/js/solver.js`. Extend it: degree-2 polynomial `= 0` → quadratic; + `ax+b=0` → linear; a single unknown with `=` → equation-solve. Route those + *before* keyword scoring. +2. **Stopword list** in `web/js/matching.js` so "work", "power", "field", + "function", "how", "of", "real" don't carry full topic weight. +3. **Calibrate confidence + a "did you mean…?" affordance.** On a marginal/tied top + score, offer the runner-up as a chip instead of silently committing. Today the + honest "free-form AI is in the desktop app" help card almost never fires, because + nearly any English sentence keyword-matches *something*. + +### P0 — "Step-by-step solver" over-promises in the browser edition + +Header advertises "…**step-by-step solver**…" and the most prominent tutor CTA is +**"Solve it step by step."** But in the browser edition: + +- The local solver (`web/js/solver.js`) only handles **triangles**. +- The button just **fills the chat box** (no submit, no scroll), and that box is + below the fold — so to the user *nothing happens*. (`web/app.js:1018`) +- If they then click "Ask", `/api/chat` **always** returns "the AI tutor runs in + the desktop app." (`web/js/browser-backend.js:67`) + +So the headline CTA is a dead end for anything but triangles. + +**Fix:** relabel honestly ("Triangle solver" / "Worked solutions"), or have the +button run the local solver for supported inputs and **disable/relabel** itself +when no solver applies. + +### P1 — Mobile buries the primary action + +At 375px the stack is Explanation → Canvas → Prompt. Measured positions: canvas at +**864px**, prompt box + Visualize at **~1580–1800px** — two-plus screens below the +fold, behind a wall of text about the default scene. A first-time mobile user can't +find where to type. + +**Fix:** on mobile, reorder to **Prompt → Canvas → Explanation**. + +### P1 — Two tutor markdown bugs mangle the text + +- **"left"/"right" deleted from prose.** `web/app.js:93` + `s.replace(/\b(left|right)\b/g, "")` is meant to strip LaTeX `\left`/`\right` but + nukes the English words. "Reactants **(left)** … products **(right)**" rendered as + "Reactants **()** … **()**". Fix: require the backslash — `/\\(left|right)\b/g`. +- **Multiplication `*` rendered as italics.** `web/app.js:109`'s emphasis regex + matches `* m *`, so `gamma * m * c^2` renders with "m" italicized and the asterisks + eaten. Per CommonMark, `* x *` (spaces inside) isn't emphasis — tighten the regex + to not match space-flanked `*`. + +### P2 — Polish + +- **Overlapping badges** on the 3D-molecule canvas: the green status pill collides + with the "drag to orbit · scroll to zoom · double-click to reset" hint at bottom-left. +- **Desktop dead space**: the asymmetric grid leaves a tall empty area below the + short right column; aligning panel heights (or moving the tutor up) tightens it. + +## 6. Recommended next steps + +| Priority | Item | Effort | Risk | +|---|---|---|---| +| 1 | Structural equation recognition + stopwords (P0 routing) | Medium | Low–med | +| 2 | Honest labeling / wiring of the "step-by-step solver" (P0) | Small | Low | +| 3 | Mobile reorder Prompt → Canvas → Explanation (P1) | Small | Low | +| 4 | Two tutor regex fixes (P1) | Tiny | Low | +| 5 | Badge overlap + desktop dead space (P2) | Tiny | Low | + +**Biggest lever:** P0 #1. The single highest-impact change is teaching the router +to read a typed equation's structure, because that's the input a student is most +likely to paste. diff --git a/USER_EVALUATION_2_middle_school.md b/USER_EVALUATION_2_middle_school.md new file mode 100644 index 0000000..892fc3f --- /dev/null +++ b/USER_EVALUATION_2_middle_school.md @@ -0,0 +1,127 @@ +# VisualLM — User Evaluation Form (Persona 2) + +> Second hands-on evaluation, from a **different and younger** persona than +> `USER_EVALUATION.md`. Same method: actually type what this student would type, +> watch where it routes, judge it through her eyes. + +--- + +## 1. Session details + +| Field | Value | +|---|---| +| Tester persona | **Maya, age 12, 7th grade** — doing science + pre-algebra homework | +| How she types | Plain-English questions, often "what is…/why do…", occasional typo; **no math notation** | +| What she wants | A quick, clear picture and a simple explanation; gives up at walls of text | +| Edition tested | Browser edition (`http://localhost:4179`, `parameterized-demos`) | +| Date | 2026-06-26 | + +## 2. What I actually typed (12 real 7th-grade prompts) + +| Prompt | What she got | Verdict | +|---|---|---| +| what is photosynthesis | Dead-end help card | ✗ no biology | +| what is a cell | Dead-end help card | ✗ no biology | +| how do magnets work | Dead-end help card | ✗ | +| why do we have seasons | Dead-end help card | ✗ no earth science | +| the water cycle | Dead-end help card | ✗ no earth science | +| 7 times 8 | Dead-end help card | ✗ no basic arithmetic | +| why is the sky blue | Dead-end help card | ✗ | +| parts of a plant cell | Dead-end help card | ✗ | +| how does the heart pump blood | Dead-end help card | ✗ | +| area of a circle | **Rectangle: Area = L·W** | ✗ wrong shape | +| how to add fractions 1/2 + 1/4 | **Add rational expressions a/b + c/d** (Algebra 2) | ~ right idea, wrong grade | +| what is gravity | **Inverse-Square Gravity & the Potential Well** | ~ real, but college-level | + +**8 of 12 dead-ended. The other 4 were wrong, mis-graded, or over her head.** Zero +clean, age-appropriate hits. + +## 3. Overall impression + +**★★☆☆☆ for a middle-schooler.** The app is clearly built for high-school-and-up +STEM. A 12-year-old's curriculum — life science, earth science, simple physical +science, pre-algebra arithmetic — is almost entirely outside it, and the content she +*can* reach is written above her reading level. The interactivity is appealing; the +content scope and tone are not for her. + +## 4. What still works for her (be fair) + +- [x] When she reaches the **fraction demo**, the bar / area-model visual ("cut each + fraction into finer pieces until denominators match") is genuinely + age-appropriate and well explained. +- [x] **Instant, colorful, interactive** — kids respond to drag-the-slider. +- [x] "what is gravity" at least produced a real moving visualization (just the wrong + altitude for her). + +## 5. Findings by severity + +### P0 — Reading level / grade band is fixed and too high +The same explanation is served to everyone; there's no notion of audience. "what is +gravity" returns *"the gravitational potential Φ = −GM/r drawn as a 3D funnel-shaped +well with a test mass orbiting on its wall… g(r) = GM/r²."* A 12-year-old wanted +"gravity is the pull that brings things down to Earth." Dense multi-clause paragraphs +are a wall for her. + +**Fix:** add a **grade-band / "explain it simpler ↔ deeper" control** that swaps the +explanation text (and biases demo selection toward the grade-appropriate version). +Cheapest first step: a reading-level toggle that rewrites the explanation; later, +let grade preference break ties in the matcher. + +### P0 — Tie-breaking serves the abstract demo over the exact-topic one +"area of a circle" → **Rectangle** (Area = L·W). The literally-perfect demo +**"Circle: area = πr², circumference = 2πr" (Geometry)** exists but **tied at score 2** +and lost the tie-break. Same shape of bug as Persona 1's quadratic→calculus case. + +**Fix:** break ties by **exact topic/title phrase match** — an "area of a circle" query +must let the *Circle area* demo beat a generic *Rectangle* demo. Also bias by grade +band (a 7th-grade "fractions" query should prefer the arithmetic demo over Algebra 2 +"rational expressions"). + +### P0 — Demos ignore the numbers the student typed +She types `1/2 + 1/4`; the fraction demo opens at **default sliders**, not +a=1, b=2, c=1, d=4. She has to re-enter her own problem by hand. (Contrast: the +**triangle solver reads her numbers** — which is exactly why it feels great.) For a +younger student, "show MY problem" is most of the value. + +**Fix:** parse numbers out of the prompt and **seed the demo's sliders** from them, +the way `solver.js` already seeds the triangle. Big perceived-quality win across all +ages, biggest for this one. + +### P1 — Coverage scope excludes the middle-school curriculum +No biology, no earth science, simple physical science only in advanced form, and no +elementary arithmetic. This is a **product-scope decision**, not a bug — but if +younger students are in scope, the library needs life-science / earth-science / +physical-science / pre-algebra demos. If they're *not* in scope, the app should say so +kindly instead of dead-ending (see next). + +### P1 — The dead-end help card is written for adults +What she sees after a dead-end: *"Free-form AI generation runs in the desktop app… +curriculum demos… curated scenes… no server,"* then it suggests she try +*"quadratic vertex / projectile motion / CH4."* Every word and every example is above +her. It reads like an error for power users. + +**Fix:** plain-language, kid-friendly copy with age-appropriate example chips, and +**don't push high-school topics** at someone who asked about photosynthesis. If the +topic genuinely isn't covered, say "I can't draw that one yet" simply. + +## 6. Recommended next steps + +| Priority | Item | Effort | Note | +|---|---|---|---| +| 1 | Seed demo sliders from numbers in the prompt | Small | Helps every persona | +| 2 | Tie-break by exact topic/title phrase (+ grade bias) | Small | Fixes circle→rectangle & fractions→Algebra 2 | +| 3 | Reading-level / "simpler ↔ deeper" toggle on explanations | Medium | Core of the age problem | +| 4 | Plain-language, age-aware help card | Small | Stop dead-ending kids with jargon | +| 5 | Decide middle-school coverage (scope) | Large | Product call: add life/earth science or set expectations | + +**Biggest lever for this persona:** items 1–3. Reading "MY numbers" back to me, at +*my* level, in *plain words* is the whole difference between "this is for me" and +"this is for the big kids." + +## 7. Cross-persona note + +The relevance/tie-break weakness shows up for **both** personas — the older student's +`x²−5x+6=0` → calculus limit, and the younger student's `area of a circle` → rectangle +are the *same* underlying bug (an exact-topic demo losing a tie to a generic one). +Fixing tie-breaking + number-seeding + a grade toggle would lift the experience for +the whole age range at once. diff --git a/VisualLM.command b/VisualLM.command new file mode 100755 index 0000000..13aac0b --- /dev/null +++ b/VisualLM.command @@ -0,0 +1,5 @@ +#!/bin/bash +# Double-click this file in Finder to launch VisualLM like an app. +# (macOS may ask once to confirm running it — right-click → Open the first time.) +cd "$(dirname "$0")" || exit 1 +exec python3 launch.py diff --git a/VisualLM.spec b/VisualLM.spec new file mode 100644 index 0000000..d479821 --- /dev/null +++ b/VisualLM.spec @@ -0,0 +1,74 @@ +# -*- mode: python ; coding: utf-8 -*- +# PyInstaller spec — freezes VisualLM into a self-contained, double-click macOS app. +# +# ./build_app.sh # regenerates the icon, then runs `pyinstaller VisualLM.spec` +# open dist/VisualLM.app +# +# The frozen app runs the server in-process (app_main.py) and serves the bundled +# static assets via main.py's BASE_DIR -> sys._MEIPASS. It opens a native +# pywebview window if pywebview was importable at build time, else an app-style +# browser window. To include Claude, `pip install anthropic` before building; +# otherwise the bundle uses OpenAI / Gemini, or the built-in pure-code library. +from pathlib import Path + +# Static + data files the server reads at runtime, placed at the bundle root. +_ASSETS = [ + "index.html", "app.js", "sandbox-worker.js", "styles.css", + "validate_scene.js", "scene_library.py", "scene_library_generated.json", +] +datas = [(a, ".") for a in _ASSETS if Path(a).exists()] + +# pywebview is optional: include its backend only if it's installed, so the +# build works without it (app_main.py falls back to a browser window). +hiddenimports = ["main", "launch"] +try: + import webview # noqa: F401 + hiddenimports.append("webview") +except ImportError: + pass + +icon = "VisualLM.icns" if Path("VisualLM.icns").exists() else None + +a = Analysis( + ["app_main.py"], + pathex=["."], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + runtime_hooks=[], + excludes=[], + noarchive=False, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="VisualLM", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, # windowed app — no terminal + argv_emulation=False, + icon=icon, +) +coll = COLLECT(exe, a.binaries, a.datas, strip=False, upx=False, name="VisualLM") + +app = BUNDLE( + coll, + name="VisualLM.app", + icon=icon, + bundle_identifier="com.visuallm.app", + info_plist={ + "CFBundleName": "VisualLM", + "CFBundleDisplayName": "VisualLM", + "CFBundleShortVersionString": "1.0", + "CFBundleVersion": "1.0", + "NSHighResolutionCapable": True, + "LSMinimumSystemVersion": "10.13", + }, +) diff --git a/ap_physics_chemistry_generated.json b/ap_physics_chemistry_generated.json new file mode 100644 index 0000000..2e06f0f --- /dev/null +++ b/ap_physics_chemistry_generated.json @@ -0,0 +1,7968 @@ +[ + { + "id": "apphys-velocity-time-graph", + "area": "AP Physics", + "topic": "Velocity-time graphs", + "title": "Velocity-time graph: area under v(t) = displacement, slope = a", + "equation": "v(t) = v0 + a*t, displacement = area = v0*t + 1/2 a t^2", + "keywords": [ + "velocity time graph", + "v-t graph", + "area under curve", + "displacement", + "slope acceleration", + "kinematics", + "constant acceleration", + "ap physics", + "v = v0 + at", + "integral of velocity" + ], + "explanation": "On a velocity-time graph the SLOPE equals acceleration and the AREA between the line and the t-axis equals displacement. With v(t) = v0 + a*t the shaded region is a trapezoid, so the displacement is v0*t + 1/2 a t^2 (positive area above the axis adds, area below subtracts). AP free-response problems constantly ask students to read acceleration as a slope and displacement as an area, so this links the graph to the kinematics equations. The orange shaded area grows as the sweep time advances and the readout reports the accumulated displacement in meters.", + "bullets": [ + "Slope of the v-t line IS the acceleration a (here v0 = 2 m/s fixed).", + "Shaded area between the line and the t-axis equals displacement.", + "Area above the axis is positive displacement; below would be negative.", + "Sweeping marker shows displacement = v0*t + 1/2 a t^2 accumulating." + ], + "params": [ + { + "name": "a", + "label": "acceleration a (m/s^2)", + "min": -2, + "max": 3, + "step": 0.5, + "value": 1.5 + } + ], + "code": "H.background();\nconst a = P.a;\nconst v = H.plot2d({ xMin: 0, xMax: 8, yMin: -10, yMax: 20 });\nv.grid(); v.axes();\nconst v0 = 2;\nconst vAt = x => v0 + a * x;\nconst tc = (t % 8);\nconst pts = [[0, 0]];\nfor (let i = 0; i <= 40; i++) {\n const x = (tc) * (i / 40);\n pts.push([x, vAt(x)]);\n}\npts.push([tc, 0]);\nv.path(pts, { color: H.colors.accent2, fill: \"rgba(244,162,89,0.25)\", close: true });\nv.fn(vAt, { color: H.colors.accent, width: 3 });\nconst disp = v0 * tc + 0.5 * a * tc * tc;\nv.dot(tc, vAt(tc), { r: 6, fill: H.colors.warn });\nH.text(\"Velocity-time graph: area = displacement\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"v(t) = v0 + a*t (v0 = 2 m/s)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = \" + a.toFixed(1) + \" m/s^2\", 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"t = \" + tc.toFixed(1) + \" s v = \" + vAt(tc).toFixed(1) + \" m/s\", 24, 96, { color: H.colors.accent, size: 13 });\nH.text(\"displacement = area = \" + disp.toFixed(1) + \" m\", 24, 118, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apphys-kinematics-constant-a", + "area": "AP Physics", + "topic": "Constant-acceleration kinematics", + "title": "Constant-acceleration kinematics: x = x0 + v0 t + 1/2 a t^2", + "equation": "x(t) = x0 + v0*t + 1/2 a*t^2, v(t) = v0 + a*t", + "keywords": [ + "kinematics", + "constant acceleration", + "x = x0 + v0t + 1/2at^2", + "position time", + "suvat", + "equations of motion", + "ap physics", + "displacement", + "velocity", + "particle motion" + ], + "explanation": "For motion with constant acceleration the position is x(t) = x0 + v0*t + 1/2 a*t^2 and the velocity is v(t) = v0 + a*t. The position function is a parabola in time whose curvature is set by a, while v0 is the initial slope; a velocity arrow on the particle shows v reversing sign when v0 and a have opposite signs. This is one of the core AP kinematics equations and shows up in nearly every mechanics problem. The dot rides a real track with numbered position ticks and the readout reports x and v at each instant.", + "bullets": [ + "Position is a parabola in t; its curvature is set by acceleration a.", + "Velocity arrow = v0 + a*t; it can reverse when a opposes v0.", + "x0 = 0 here, so x is the displacement from the start.", + "Track ticks are real meters; the dot's x matches the lower graph." + ], + "params": [ + { + "name": "v0", + "label": "initial velocity v0 (m/s)", + "min": -10, + "max": 12, + "step": 1, + "value": 5 + }, + { + "name": "a", + "label": "acceleration a (m/s^2)", + "min": -4, + "max": 4, + "step": 0.5, + "value": 1.5 + } + ], + "code": "H.background();\nconst v0 = P.v0, a = P.a;\nconst x0 = 0;\nconst period = 6;\nconst tc = (t % period);\nconst xt = x0 + v0 * tc + 0.5 * a * tc * tc;\nconst vt = v0 + a * tc;\nconst xMin = -20, xMax = 60;\nconst px = H.map(Math.max(xMin, Math.min(xMax, xt)), xMin, xMax, 80, H.W - 40);\nconst trackY = 320;\nH.line(80, trackY, H.W - 40, trackY, { color: H.colors.grid, width: 2 });\nfor (let xm = xMin; xm <= xMax; xm += 10) {\n const tx = H.map(xm, xMin, xMax, 80, H.W - 40);\n H.line(tx, trackY - 6, tx, trackY + 6, { color: H.colors.axis, width: 1 });\n H.text(xm + \"\", tx, trackY + 22, { color: H.colors.sub, size: 11, align: \"center\" });\n}\nH.circle(px, trackY - 18, 14, { fill: H.colors.accent });\nconst vScale = 3;\nH.arrow(px, trackY - 18, px + vt * vScale, trackY - 18, { color: H.colors.warn, width: 3 });\nconst view = H.plot2d({ xMin: 0, xMax: period, yMin: -20, yMax: 60, box: { x: 90, y: 380, w: H.W - 140, h: 150 } });\nview.grid(); view.axes();\nview.fn(s => x0 + v0 * s + 0.5 * a * s * s, { color: H.colors.accent2, width: 2 });\nview.dot(tc, xt, { r: 5, fill: H.colors.warn });\nH.text(\"Constant acceleration: x = x0 + v0*t + 1/2 a t^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"x0 = 0 m, v0 = \" + v0.toFixed(1) + \" m/s, a = \" + a.toFixed(1) + \" m/s^2\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"t = \" + tc.toFixed(1) + \" s\", 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"x = \" + xt.toFixed(1) + \" m v = \" + vt.toFixed(1) + \" m/s\", 24, 96, { color: H.colors.accent, size: 13 });" + }, + { + "id": "apphys-free-fall", + "area": "AP Physics", + "topic": "Free fall (g)", + "title": "Free fall: y = h0 - 1/2 g t^2, v = -g t (g = 9.8 m/s^2)", + "equation": "y(t) = h0 - 1/2 g t^2, v(t) = -g t, g = 9.8 m/s^2", + "keywords": [ + "free fall", + "gravity", + "g = 9.8", + "dropped object", + "y = h0 - 1/2 g t^2", + "acceleration due to gravity", + "ap physics", + "kinematics", + "fall time", + "terminal", + "released from rest" + ], + "explanation": "An object released from rest near Earth's surface falls with constant downward acceleration g = 9.8 m/s^2, so its height is y(t) = h0 - 1/2 g t^2 and its velocity is v(t) = -g t (negative meaning downward). The time to reach the ground is t = sqrt(2 h0 / g), found by setting y = 0. AP problems use this to connect drop height, fall time, and impact speed, and to emphasize that mass does not affect the motion (air resistance neglected). The ball accelerates visibly, the downward velocity arrow lengthens, and the readouts show y shrinking and v growing more negative.", + "bullets": [ + "Acceleration is constant g = 9.8 m/s^2, independent of mass.", + "Velocity v = -g t grows linearly; the arrow lengthens downward.", + "Fall time t = sqrt(2 h0 / g) is shown for the chosen height.", + "Height drops as a parabola y = h0 - 1/2 g t^2, slow then fast." + ], + "params": [ + { + "name": "h0", + "label": "drop height h0 (m)", + "min": 1, + "max": 45, + "step": 1, + "value": 20 + } + ], + "code": "H.background();\nconst g = 9.8;\nconst h0 = P.h0;\nconst tFall = Math.sqrt(2 * h0 / g);\nconst period = tFall + 1.2;\nconst tc = (t % period);\nconst tf = Math.min(tc, tFall);\nconst y = h0 - 0.5 * g * tf * tf;\nconst vy = -g * tf;\nconst groundY = 500, topY = 80;\nconst yMax = Math.max(h0, 1);\nconst py = H.map(Math.max(0, y), 0, yMax, groundY, topY);\nconst ballX = H.W / 2;\nH.line(120, groundY, H.W - 120, groundY, { color: H.colors.grid, width: 3 });\nH.text(\"ground\", H.W - 120, groundY + 18, { color: H.colors.sub, size: 11, align: \"right\" });\nfor (let i = 0; i <= 4; i++) {\n const hv = yMax * i / 4;\n const ty = H.map(hv, 0, yMax, groundY, topY);\n H.line(115, ty, 125, ty, { color: H.colors.axis, width: 1 });\n H.text(hv.toFixed(0) + \" m\", 108, ty, { color: H.colors.sub, size: 11, align: \"right\", baseline: \"middle\" });\n}\nH.line(ballX, topY, ballX, groundY, { color: H.colors.grid, width: 1, dash: [4, 4] });\nH.circle(ballX, py, 16, { fill: H.colors.accent });\nH.arrow(ballX, py, ballX, py - vy * 4, { color: H.colors.warn, width: 3 });\nH.text(\"Free fall: y = h0 - 1/2 g t^2, v = -g t\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"g = 9.8 m/s^2, released from rest\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"h0 = \" + h0.toFixed(1) + \" m t_fall = \" + tFall.toFixed(2) + \" s\", 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"t = \" + tf.toFixed(2) + \" s\", 24, 96, { color: H.colors.sub, size: 13 });\nH.text(\"y = \" + Math.max(0, y).toFixed(1) + \" m v = \" + vy.toFixed(1) + \" m/s\", 24, 118, { color: H.colors.accent, size: 13 });" + }, + { + "id": "apphys-projectile", + "area": "AP Physics", + "topic": "Projectile motion", + "title": "Projectile motion: range R = v^2 sin(2a)/g, max height H = v^2 sin^2 a /(2g)", + "equation": "x = v*cos(a)*t, y = v*sin(a)*t - 1/2 g t^2, R = v^2 sin(2a)/g", + "keywords": [ + "projectile motion", + "range", + "max height", + "launch angle", + "parabola", + "v^2 sin(2theta)/g", + "horizontal vertical components", + "ap physics", + "trajectory", + "time of flight", + "kinematics 2d" + ], + "explanation": "Projectile motion splits into independent horizontal (constant velocity) and vertical (constant -g acceleration) parts: x = v cos(a) t and y = v sin(a) t - 1/2 g t^2. The trajectory is a parabola; the time of flight is 2 v sin(a)/g, the range is R = v^2 sin(2a)/g (maximized at 45 degrees), and the peak height is v^2 sin^2(a)/(2g). AP problems hinge on treating the two directions separately and recognizing complementary angles give the same range. The dot traces the real parabola while the live readout reports range, max height, and flight time for the chosen speed and angle.", + "bullets": [ + "Horizontal motion is constant velocity; vertical has accel -g.", + "Range R = v^2 sin(2a)/g is largest at a launch angle of 45 deg.", + "Max height H = v^2 sin^2(a)/(2g) occurs at the green apex.", + "Complementary angles (e.g. 30 and 60 deg) give the same range." + ], + "params": [ + { + "name": "speed", + "label": "launch speed v (m/s)", + "min": 5, + "max": 30, + "step": 1, + "value": 18 + }, + { + "name": "angle", + "label": "launch angle a (deg)", + "min": 0, + "max": 90, + "step": 5, + "value": 45 + } + ], + "code": "H.background();\nconst g = 9.8;\nconst speed = P.speed;\nconst angDeg = P.angle;\nconst ang = angDeg * Math.PI / 180;\nconst vx = speed * Math.cos(ang);\nconst vy = speed * Math.sin(ang);\nconst tFlight = Math.max(0.001, 2 * vy / g);\nconst range = vx * tFlight;\nconst maxH = (vy * vy) / (2 * g);\nconst xMax = Math.max(range * 1.1, 1);\nconst yMax = Math.max(maxH * 1.3, 1);\nconst view = H.plot2d({ xMin: 0, xMax: xMax, yMin: 0, yMax: yMax });\nview.grid(); view.axes();\nconst pts = [];\nfor (let i = 0; i <= 50; i++) {\n const tt = tFlight * i / 50;\n pts.push([vx * tt, vy * tt - 0.5 * g * tt * tt]);\n}\nview.path(pts, { color: H.colors.accent, width: 3 });\nconst tc = (t % (tFlight + 0.6));\nconst tp = Math.min(tc, tFlight);\nconst xp = vx * tp;\nconst yp = Math.max(0, vy * tp - 0.5 * g * tp * tp);\nview.dot(xp, yp, { r: 7, fill: H.colors.warn });\nview.dot(range / 2, maxH, { r: 5, fill: H.colors.good });\nview.text(\"apex\", range / 2, maxH + yMax * 0.05, { color: H.colors.good, size: 11, align: \"center\" });\nH.text(\"Projectile: range R = v^2 sin(2a)/g\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"g = 9.8 m/s^2, launch from ground\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"v = \" + speed.toFixed(1) + \" m/s angle = \" + angDeg.toFixed(0) + \" deg\", 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"R = \" + range.toFixed(1) + \" m H = \" + maxH.toFixed(1) + \" m t_flight = \" + tFlight.toFixed(2) + \" s\", 24, 96, { color: H.colors.accent, size: 13 });" + }, + { + "id": "apphys-relative-velocity", + "area": "AP Physics", + "topic": "Relative velocity", + "title": "Relative velocity: v_resultant = v_boat + v_river (tip-to-tail vectors)", + "equation": "v_resultant = sqrt(v_boat^2 + v_river^2), theta = atan2(v_boat, v_river)", + "keywords": [ + "relative velocity", + "boat in river", + "vector addition", + "resultant velocity", + "crossing river", + "current", + "tip to tail", + "ap physics", + "pythagorean", + "frame of reference", + "downstream drift" + ], + "explanation": "Velocities add as vectors: a boat pointed straight across a river with speed v_boat (relative to the water) plus a downstream current v_river gives a resultant velocity relative to the ground found by tip-to-tail addition. Because the two are perpendicular here, the resultant magnitude is sqrt(v_boat^2 + v_river^2) and it points at angle theta = atan2(v_boat, v_river) measured from the downstream direction, so the boat is carried downstream while crossing. AP problems use this to compute crossing time (set only by the across-component) and downstream drift. The diagram shows the blue across-vector, the green current added tip-to-tail, and the orange resultant, with a dot drifting along the true ground-frame path.", + "bullets": [ + "Velocities are vectors: v_boat + v_river adds tip-to-tail.", + "Perpendicular case: |v| = sqrt(v_boat^2 + v_river^2) (Pythagoras).", + "Resultant angle theta = atan2(v_boat, v_river) from downstream.", + "Across-speed sets crossing time; current sets downstream drift." + ], + "params": [ + { + "name": "vboat", + "label": "boat speed across v_boat (m/s)", + "min": 0, + "max": 6, + "step": 0.5, + "value": 3 + }, + { + "name": "vriver", + "label": "river current v_river (m/s)", + "min": 0, + "max": 6, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst vBoat = P.vboat;\nconst vRiver = P.vriver;\nconst Rx = vRiver;\nconst Ry = vBoat;\nconst Rmag = Math.sqrt(Rx * Rx + Ry * Ry);\nconst ang = Math.atan2(Ry, Rx) * 180 / Math.PI;\nconst lim = Math.max(vBoat, vRiver, 1) * 1.4;\nconst view = H.plot2d({ xMin: -lim, xMax: lim, yMin: -0.3 * lim, yMax: lim * 1.2 });\nview.grid(); view.axes();\nview.arrow(0, 0, 0, vBoat, { color: H.colors.accent, width: 3 });\nview.text(\"boat \" + vBoat.toFixed(1), 0.05 * lim, vBoat * 0.6, { color: H.colors.accent, size: 12 });\nview.arrow(0, vBoat, vRiver, vBoat, { color: H.colors.good, width: 3 });\nview.text(\"river \" + vRiver.toFixed(1), vRiver * 0.5, vBoat + 0.05 * lim, { color: H.colors.good, size: 12 });\nview.arrow(0, 0, Rx, Ry, { color: H.colors.warn, width: 4 });\nconst period = 6;\nconst s = (t % period) / period;\nview.dot(Rx * s, Ry * s, { r: 7, fill: H.colors.violet });\nH.text(\"Relative velocity: v_boat + v_river = v_resultant\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Vectors add tip-to-tail (boat across + current downstream)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"v_boat = \" + vBoat.toFixed(1) + \" m/s v_river = \" + vRiver.toFixed(1) + \" m/s\", 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"|v| = \" + Rmag.toFixed(2) + \" m/s theta = \" + ang.toFixed(1) + \" deg from downstream\", 24, 96, { color: H.colors.warn, size: 13 });" + }, + { + "id": "apphys-newton-second-law", + "area": "AP Physics", + "topic": "Newton second law F=ma", + "title": "Newton's 2nd law: a = F / m", + "equation": "a = F / m (F = m*a)", + "keywords": [ + "newton's second law", + "f equals ma", + "f=ma", + "net force", + "acceleration", + "mass", + "force", + "inertia", + "dynamics", + "ap physics" + ], + "explanation": "Newton's second law states that the net force on an object equals its mass times its acceleration, F = m*a, so the acceleration is a = F/m in the same direction as the net force. For a fixed force, doubling the mass halves the acceleration (an inverse relationship), which is exactly what the live a = F/m readout shows as you drag the sliders. Units: F in newtons (kg·m/s^2), m in kilograms, a in m/s^2. On the AP exam this is the workhorse for translating a free-body diagram into the equation sum(F) = m*a; the animation drives the orange force arrow and the green acceleration arrow together to show they always point the same way.", + "bullets": [ + "a = F/m: acceleration is proportional to force and inversely proportional to mass.", + "The orange F arrow and green a arrow always point the same direction.", + "Increase m with F fixed and the live a readout drops — heavier is harder to accelerate.", + "Units check: N = kg·m/s^2, so F/m gives m/s^2." + ], + "params": [ + { + "name": "F", + "label": "applied force F (N)", + "min": 0, + "max": 20, + "step": 1, + "value": 10 + }, + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 10, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst W = H.W;\nconst F = P.F, m = Math.max(0.1, P.m);\nconst a = F / m;\nconst groundY = 360;\nH.line(40, groundY, W - 40, groundY, { color: H.colors.grid, width: 2 });\nconst cx = W / 2;\nconst span = 220;\nconst xpos = cx + span * Math.sin(t * 0.9) * H.clamp(a / 12, 0.05, 1);\nconst bw = 90, bh = 64;\nconst bx = xpos - bw / 2, by = groundY - bh;\nH.rect(bx, by, bw, bh, { fill: H.colors.panel, stroke: H.colors.accent, width: 2, radius: 6 });\nH.text(m.toFixed(1) + \" kg\", xpos, by + bh / 2, { color: H.colors.ink, size: 14, align: \"center\", baseline: \"middle\" });\nconst dir = Math.cos(t * 0.9) >= 0 ? 1 : -1;\nconst alen = H.clamp(Math.abs(F) * 6, 12, 180) * Math.sign(F || 1) * dir;\nH.arrow(xpos, by + bh / 2, xpos + alen, by + bh / 2, { color: H.colors.accent2, width: 4 });\nH.text(\"F\", xpos + alen + 8 * Math.sign(alen || 1), by + bh / 2 - 12, { color: H.colors.accent2, size: 14, align: \"center\" });\nconst aarr = H.clamp(a * 12, 8, 200) * dir;\nH.arrow(xpos, by - 18, xpos + aarr, by - 18, { color: H.colors.good, width: 3 });\nH.text(\"a\", xpos + aarr + 8 * Math.sign(aarr || 1), by - 30, { color: H.colors.good, size: 13, align: \"center\" });\nH.text(\"Newton's 2nd law: F = m a\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Same force on a heavier block gives a smaller acceleration.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"F = \" + F.toFixed(1) + \" N\", 24, 84, { color: H.colors.accent2, size: 14 });\nH.text(\"m = \" + m.toFixed(1) + \" kg\", 24, 104, { color: H.colors.sub, size: 14 });\nH.text(\"a = F/m = \" + a.toFixed(2) + \" m/s^2\", 24, 124, { color: H.colors.good, size: 14 });" + }, + { + "id": "apphys-free-body-diagram", + "area": "AP Physics", + "topic": "Free-body diagram & net force", + "title": "Free-body diagram: net F = F_app - f, with N = W", + "equation": "sum(F_y)=0 -> N = W = m*g ; sum(F_x) = F_app - f = net", + "keywords": [ + "free body diagram", + "fbd", + "net force", + "normal force", + "weight", + "applied force", + "friction", + "force balance", + "sum of forces", + "ap physics" + ], + "explanation": "A free-body diagram isolates one object and draws every force as a vector from its center: weight W = m*g down, the normal force N up, the applied force F horizontal, and friction f opposing relative motion. On a level surface the vertical forces balance (N = W), so the net force is purely horizontal: net = F_app - f. The animation scales each arrow to its magnitude in newtons and reports the live net force, switching between a static label (friction matches the applied force) and moving once F_app exceeds the friction limit. On the AP exam this decomposition into sum(F_x) and sum(F_y) is the required first step before applying sum(F) = m*a.", + "bullets": [ + "Four forces from the center: weight (down), normal (up), applied (horizontal), friction (opposing).", + "Vertical balance gives N = W = m*g on a level surface.", + "Net horizontal force = F_app - f drives the block; the readout shows its value.", + "While static, friction grows to match F_app so the net force stays zero." + ], + "params": [ + { + "name": "Fapp", + "label": "applied force F_app (N)", + "min": 0, + "max": 40, + "step": 1, + "value": 15 + } + ], + "code": "H.background();\nconst W = H.W;\nconst m = 5, g = 9.8, mu = 0.4;\nconst Fapp = P.Fapp;\nconst Wt = m * g;\nconst N = Wt;\nconst fricMax = mu * N;\nconst moving = Math.abs(Fapp) > fricMax;\nconst fric = moving ? -Math.sign(Fapp) * fricMax : -Fapp;\nconst net = Fapp + fric;\nconst groundY = 380;\nH.line(40, groundY, W - 40, groundY, { color: H.colors.grid, width: 2 });\nconst cx = W / 2;\nconst sway = (net / 30) * 40 * Math.sin(t * 1.2);\nconst bx = cx + H.clamp(sway, -120, 120);\nconst by = groundY;\nconst s = 70;\nH.rect(bx - s / 2, by - s, s, s, { fill: H.colors.panel, stroke: H.colors.accent, width: 2, radius: 6 });\nconst ccx = bx, ccy = by - s / 2;\nH.circle(ccx, ccy, 4, { fill: H.colors.ink });\nconst sc = 2.2;\nH.arrow(ccx, ccy, ccx, ccy + Wt * sc * 0.5, { color: H.colors.warn, width: 4 });\nH.text(\"W=\" + Wt.toFixed(0) + \"N\", ccx + 8, ccy + Wt * sc * 0.5, { color: H.colors.warn, size: 12 });\nH.arrow(ccx, ccy, ccx, ccy - N * sc * 0.5, { color: H.colors.good, width: 4 });\nH.text(\"N=\" + N.toFixed(0) + \"N\", ccx + 8, ccy - N * sc * 0.5, { color: H.colors.good, size: 12 });\nH.arrow(ccx, ccy, ccx + Fapp * sc, ccy, { color: H.colors.accent2, width: 4 });\nH.text(\"F=\" + Fapp.toFixed(0) + \"N\", ccx + Fapp * sc + 6 * Math.sign(Fapp || 1), ccy - 12, { color: H.colors.accent2, size: 12, align: \"center\" });\nH.arrow(ccx, ccy + 16, ccx + fric * sc, ccy + 16, { color: H.colors.violet, width: 4 });\nH.text(\"f=\" + Math.abs(fric).toFixed(0) + \"N\", ccx + fric * sc + 6 * Math.sign(fric || -1), ccy + 34, { color: H.colors.violet, size: 12, align: \"center\" });\nH.text(\"Free-body diagram: net F = F_app - f\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Up/down forces balance; horizontal net force = applied minus friction.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"net = \" + net.toFixed(1) + \" N (\" + (moving ? \"moving\" : \"static\") + \")\", 24, 84, { color: H.colors.accent, size: 14 });" + }, + { + "id": "apphys-friction", + "area": "AP Physics", + "topic": "Static vs kinetic friction", + "title": "Static vs kinetic friction: f_s <= mu_s*N, f_k = mu_k*N", + "equation": "f_s <= mu_s*N (no slip); f_k = mu_k*N (sliding)", + "keywords": [ + "static friction", + "kinetic friction", + "coefficient of friction", + "mu_s", + "mu_k", + "threshold", + "normal force", + "slipping", + "ap physics", + "friction force" + ], + "explanation": "Static friction is a self-adjusting force that matches the applied force up to a maximum f_s,max = mu_s*N, so the block stays at rest until the applied force exceeds that threshold. Once it slips, friction drops to the constant kinetic value f_k = mu_k*N (with mu_k < mu_s), and the leftover net force F_app - f_k produces acceleration a = (F_app - f_k)/m. The bar at the bottom marks the mu_s*N threshold and the orange dot tracks the applied force, so you see exactly when motion begins. AP problems hinge on this distinction: use the inequality for static cases and the equality for sliding, and remember the force drops at the moment of breakaway.", + "bullets": [ + "Static friction self-adjusts up to f_s,max = mu_s*N — below it, the block stays put.", + "Cross the threshold and friction falls to the constant kinetic value f_k = mu_k*N.", + "The bottom bar shows mu_s*N; the orange dot is the current applied force.", + "Once sliding, net = F_app - f_k gives a = net/m (live readout)." + ], + "params": [ + { + "name": "Fapp", + "label": "applied force F_app (N)", + "min": 0, + "max": 40, + "step": 1, + "value": 12 + } + ], + "code": "H.background();\nconst W = H.W;\nconst m = 4, g = 9.8, mus = 0.5, muk = 0.3;\nconst N = m * g;\nconst fsMax = mus * N;\nconst fk = muk * N;\nconst Fapp = P.Fapp;\nconst moving = Fapp > fsMax;\nconst fric = moving ? fk : Math.min(Fapp, fsMax);\nconst net = Fapp - fric;\nconst a = moving ? net / m : 0;\nconst groundY = 360;\nH.line(40, groundY, W - 40, groundY, { color: H.colors.grid, width: 2 });\nfor (let i = 0; i < 24; i++) {\n const gx = 50 + i * 35;\n H.line(gx, groundY, gx - 10, groundY + 10, { color: H.colors.grid, width: 1 });\n}\nconst cx = W / 2;\nconst slide = moving ? 120 * Math.sin(t * 1.4) : 0;\nconst bx = cx + H.clamp(slide, -150, 150);\nconst s = 72;\nH.rect(bx - s / 2, groundY - s, s, s, { fill: H.colors.panel, stroke: moving ? H.colors.good : H.colors.accent, width: 2, radius: 6 });\nconst ccy = groundY - s / 2;\nconst sc = 3.0;\nH.arrow(bx, ccy, bx + Fapp * sc, ccy, { color: H.colors.accent2, width: 4 });\nH.text(\"F_app\", bx + Fapp * sc + 8, ccy - 10, { color: H.colors.accent2, size: 12 });\nH.arrow(bx, ccy + 18, bx - fric * sc, ccy + 18, { color: H.colors.violet, width: 4 });\nH.text(\"f\", bx - fric * sc - 12, ccy + 30, { color: H.colors.violet, size: 12 });\nconst barX = 24, barY = 430, barW = W - 48;\nH.line(barX, barY, barX + barW, barY, { color: H.colors.grid, width: 2 });\nH.text(\"0\", barX, barY + 20, { color: H.colors.sub, size: 11 });\nconst fsX = barX + barW * H.clamp(fsMax / 40, 0, 1);\nH.line(fsX, barY - 8, fsX, barY + 8, { color: H.colors.warn, width: 2 });\nH.text(\"μs·N=\" + fsMax.toFixed(1) + \"N\", fsX, barY - 14, { color: H.colors.warn, size: 11, align: \"center\" });\nconst fX = barX + barW * H.clamp(Fapp / 40, 0, 1);\nH.circle(fX, barY, 6, { fill: H.colors.accent2 });\nH.text(\"Static vs kinetic friction: f_s ≤ μs·N, f_k = μk·N\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Block stays put until F exceeds μs·N, then kinetic friction μk·N acts.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"F = \" + Fapp.toFixed(1) + \" N -> \" + (moving ? \"MOVING, a=\" + a.toFixed(2) + \" m/s^2\" : \"STATIC, f=\" + fric.toFixed(1) + \" N\"), 24, 84, { color: moving ? H.colors.good : H.colors.accent, size: 14 });" + }, + { + "id": "apphys-inclined-plane", + "area": "AP Physics", + "topic": "Block on an incline", + "title": "Block on an incline: mg sin(theta) down-slope, mg cos(theta) into surface", + "equation": "F_parallel = m*g*sin(theta) ; F_perp = N = m*g*cos(theta)", + "keywords": [ + "inclined plane", + "incline", + "ramp", + "components of gravity", + "mg sin theta", + "mg cos theta", + "normal force", + "tilted axes", + "ap physics", + "slope" + ], + "explanation": "On a ramp the smart move is to tilt the axes so one points down the slope and one points into the surface, then split gravity mg into those two directions. The component along the slope is mg*sin(theta) (this drives the block down), and the component into the surface is mg*cos(theta), which the normal force balances so N = mg*cos(theta). As theta increases the down-slope pull grows and the normal force shrinks — at theta = 0 it is all normal force, and near 90 degrees it is nearly all down-slope. The animation draws mg straight down and its two tilted components with live newton values, exactly the decomposition the AP exam expects before writing sum(F) = m*a along the incline.", + "bullets": [ + "Tilt the axes to the slope: gravity splits into mg*sin(theta) and mg*cos(theta).", + "mg*sin(theta) points down the slope; mg*cos(theta) presses into the surface.", + "Normal force balances the perpendicular part: N = mg*cos(theta).", + "Steeper theta means more down-slope pull and less normal force (live N readout)." + ], + "params": [ + { + "name": "theta", + "label": "incline angle theta (deg)", + "min": 0, + "max": 80, + "step": 1, + "value": 30 + } + ], + "code": "H.background();\nconst deg = H.clamp(P.theta, 0, 80);\nconst th = deg * Math.PI / 180;\nconst m = 4, g = 9.8;\nconst Wt = m * g;\nconst par = Wt * Math.sin(th);\nconst perp = Wt * Math.cos(th);\nconst ax = 120, ay = 420;\nconst baseLen = 520;\nconst topX = ax + baseLen * Math.cos(th);\nconst topY = ay - baseLen * Math.sin(th);\nH.path([[ax, ay], [ax + baseLen, ay], [topX, topY]], { color: H.colors.grid, width: 2, fill: H.colors.panel, close: true });\nH.line(ax, ay, topX, topY, { color: H.colors.accent, width: 3 });\nconst f = 0.25 + 0.5 * (0.5 + 0.5 * Math.sin(t * 0.8));\nconst px = ax + baseLen * f * Math.cos(th);\nconst py = ay - baseLen * f * Math.sin(th);\nconst ux = Math.cos(th), uy = -Math.sin(th);\nconst nx = Math.sin(th), ny = Math.cos(th);\nconst s = 50;\nconst bcx = px - nx * (s / 2 - 10);\nconst bcy = py - ny * (s / 2 - 10);\nH.rect(bcx - s / 2, bcy - s / 2, s, s, { fill: H.colors.panel, stroke: H.colors.accent2, width: 2, radius: 5 });\nconst sc = 3.2;\nH.arrow(bcx, bcy, bcx, bcy + Wt * sc * 0.5, { color: H.colors.warn, width: 4 });\nH.text(\"mg\", bcx + 6, bcy + Wt * sc * 0.5, { color: H.colors.warn, size: 12 });\nH.arrow(bcx, bcy, bcx - ux * par * sc, bcy - uy * par * sc, { color: H.colors.good, width: 4 });\nH.text(\"mg sinθ\", bcx - ux * par * sc - 30, bcy - uy * par * sc, { color: H.colors.good, size: 12 });\nH.arrow(bcx, bcy, bcx + nx * perp * sc, bcy + ny * perp * sc, { color: H.colors.violet, width: 4 });\nH.text(\"mg cosθ\", bcx + nx * perp * sc + 6, bcy + ny * perp * sc, { color: H.colors.violet, size: 12 });\nH.text(deg.toFixed(0) + \"°\", ax + 70, ay - 12, { color: H.colors.sub, size: 13 });\nH.text(\"Block on an incline: components of mg\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Gravity splits into mg·sinθ down the slope and mg·cosθ into the surface.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"θ=\" + deg.toFixed(0) + \"° mg·sinθ=\" + par.toFixed(1) + \"N mg·cosθ=\" + perp.toFixed(1) + \"N\", 24, 84, { color: H.colors.accent, size: 14 });" + }, + { + "id": "apphys-atwood", + "area": "AP Physics", + "topic": "Atwood machine / tension", + "title": "Atwood machine: a = (m2-m1)g/(m1+m2), T = 2 m1 m2 g/(m1+m2)", + "equation": "a = (m2 - m1)*g/(m1 + m2) ; T = 2*m1*m2*g/(m1 + m2)", + "keywords": [ + "atwood machine", + "tension", + "pulley", + "connected masses", + "two masses", + "rope", + "acceleration", + "newton's second law", + "ap physics", + "ideal pulley" + ], + "explanation": "An Atwood machine connects two masses over an ideal (massless, frictionless) pulley with one inextensible rope, so both masses share the same acceleration magnitude and the same tension. Writing sum(F)=m*a for each mass and eliminating T gives a = (m2 - m1)*g/(m1 + m2): the heavier mass accelerates down, the lighter one up, and equal masses give a = 0. Substituting back yields T = 2*m1*m2*g/(m1 + m2), a value that lies between the two weights (m1*g < T < m2*g when m2 > m1). The animation shows the equal upward tension arrows on both blocks and reports live a and T as you change the masses — a classic AP free-response setup for systems of connected objects.", + "bullets": [ + "One rope, one ideal pulley: both masses share the same |a| and the same tension T.", + "a = (m2 - m1)g/(m1 + m2): heavier side falls, equal masses give a = 0.", + "T = 2 m1 m2 g/(m1 + m2) lies between the two weights — equal arrows on both blocks.", + "Per-mass sum(F)=m*a eliminated to one equation: the standard AP connected-bodies method." + ], + "params": [ + { + "name": "m1", + "label": "left mass m1 (kg)", + "min": 0.5, + "max": 10, + "step": 0.5, + "value": 3 + }, + { + "name": "m2", + "label": "right mass m2 (kg)", + "min": 0.5, + "max": 10, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\nconst W = H.W;\nconst g = 9.8;\nconst m1 = Math.max(0.1, P.m1);\nconst m2 = Math.max(0.1, P.m2);\nconst a = (m2 - m1) * g / (m1 + m2);\nconst T = 2 * m1 * m2 * g / (m1 + m2);\nconst pcx = W / 2, pcy = 110, pr = 40;\nH.circle(pcx, pcy, pr, { fill: H.colors.panel, stroke: H.colors.accent, width: 3 });\nH.circle(pcx, pcy, 5, { fill: H.colors.ink });\nconst lx = pcx - pr, rx = pcx + pr;\nconst amp = 70;\nconst disp = amp * Math.sin(t * 1.1) * Math.sign(a || 1);\nconst y1 = 250 - disp;\nconst y2 = 250 + disp;\nH.line(lx, pcy, lx, y1, { color: H.colors.sub, width: 2 });\nH.line(rx, pcy, rx, y2, { color: H.colors.sub, width: 2 });\nfunction box(x, y, mass, col) {\n const s = 30 + 26 * H.clamp(mass / 10, 0, 1);\n H.rect(x - s / 2, y, s, s, { fill: H.colors.panel, stroke: col, width: 2, radius: 5 });\n H.text(mass.toFixed(1) + \"kg\", x, y + s / 2, { color: H.colors.ink, size: 12, align: \"center\", baseline: \"middle\" });\n}\nbox(lx, y1, m1, H.colors.accent2);\nbox(rx, y2, m2, H.colors.good);\nconst sc = 1.4;\nH.arrow(lx, y1, lx, y1 - H.clamp(T * sc, 14, 90), { color: H.colors.violet, width: 3 });\nH.arrow(rx, y2, rx, y2 - H.clamp(T * sc, 14, 90), { color: H.colors.violet, width: 3 });\nH.text(\"T\", lx - 16, y1 - 30, { color: H.colors.violet, size: 12 });\nH.text(\"T\", rx + 6, y2 - 30, { color: H.colors.violet, size: 12 });\nH.text(\"Atwood machine: a = (m2-m1)g/(m1+m2), T = 2 m1 m2 g/(m1+m2)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Unequal masses share one rope; heavier side falls, tension is equal throughout.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = \" + a.toFixed(2) + \" m/s^2 T = \" + T.toFixed(1) + \" N\", 24, 84, { color: H.colors.accent, size: 14 });\nH.text((m2 > m1 ? \"m2 falls\" : m2 < m1 ? \"m1 falls\" : \"balanced\"), 24, 104, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apphys-elevator-normal", + "area": "AP Physics", + "topic": "Apparent weight in an elevator", + "title": "Apparent weight in an elevator: N = m(g + a)", + "equation": "N = m*(g + a)", + "keywords": [ + "apparent weight", + "elevator problem", + "normal force", + "n = m(g+a)", + "scale reading", + "newton second law", + "free body diagram", + "weightlessness", + "ap physics", + "acceleration", + "fnet = ma" + ], + "explanation": "A scale reads the NORMAL force N it pushes up with, not the true weight mg. Applying Newton's second law to a rider in an elevator (taking up as positive), N - mg = ma, so N = m(g + a). When the elevator accelerates upward (a > 0) you feel heavier (N > mg); accelerating downward (a < 0) you feel lighter, and in free fall (a = -g) N = 0 and you are 'weightless.' On the AP exam this is the classic free-body / Newton's-second-law setup: the green arrow is the scale's normal force, the pink arrow is the constant true weight mg, and the readout N/mg shows how apparent weight scales with acceleration.", + "bullets": [ + "The scale reads N (green up arrow), the support force — not mg.", + "Newton's 2nd law: N - mg = ma, so N = m(g + a).", + "a > 0 (up): feel heavier, N > mg; a < 0 (down): feel lighter.", + "a = -g gives N = 0: free fall, apparent weightlessness." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 20, + "max": 120, + "step": 5, + "value": 70 + }, + { + "name": "a", + "label": "accel a (m/s^2)", + "min": -9.8, + "max": 9.8, + "step": 0.2, + "value": 3 + } + ], + "code": "H.background();\nconst g = 9.8, m = P.m, a = P.a;\nconst N = m * (g + a);\nconst w = m * g;\nH.text(\"Apparent weight: N = m(g + a)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Scale reads the NORMAL force, not the true weight mg\", 24, 52, { color: H.colors.sub, size: 13 });\nconst cx = H.W * 0.5;\nconst cy0 = H.H * 0.5;\nconst bob = 16 * Math.sin(t * 1.2) * (a >= 0 ? 1 : -1);\nconst cy = cy0 + bob;\nconst carW = 150, carH = 230;\nconst left = cx - carW / 2, top = cy - carH / 2;\nH.rect(left, top, carW, carH, { stroke: H.colors.axis, width: 3, radius: 8 });\nconst bx = cx - 34, by = top + carH - 70;\nH.rect(bx, by, 68, 60, { fill: H.colors.accent, stroke: H.colors.ink, width: 2, radius: 6 });\nH.text(\"m\", cx, by + 34, { color: H.colors.bg, size: 18, weight: 700, align: \"center\", baseline: \"middle\" });\nH.line(cx, by + 60, cx, top + carH, { color: H.colors.axis, width: 4 });\nconst wScale = w * 0.18 + 8;\nH.arrow(cx, by, cx, by - Math.min(wScale, 90), { color: H.colors.warn, width: 4 });\nH.text(\"mg = \" + w.toFixed(0) + \" N\", cx - 80, by - 24, { color: H.colors.warn, size: 13, align: \"right\" });\nconst nScale = N * 0.18 + 8;\nH.arrow(cx, by + 60, cx, by + 60 - Math.min(nScale, 110), { color: H.colors.good, width: 4 });\nH.text(\"N = \" + N.toFixed(0) + \" N\", cx + 80, by + 30, { color: H.colors.good, size: 13 });\nconst aLabel = a > 0.05 ? \"accel UP\" : a < -0.05 ? \"accel DOWN\" : \"no accel\";\nH.text(\"a = \" + a.toFixed(1) + \" m/s^2 (\" + aLabel + \")\", 24, 76, { color: H.colors.accent2, size: 13 });\nH.text(\"ratio N/mg = \" + (N / w).toFixed(2), 24, 96, { color: H.colors.violet, size: 13 });\nH.arrow(left - 40, cy, left - 40, cy + (a >= 0 ? -40 : 40), { color: H.colors.accent2, width: 3 });\nH.text(\"a\", left - 56, cy, { color: H.colors.accent2, size: 13, baseline: \"middle\" });" + }, + { + "id": "apphys-uniform-circular", + "area": "AP Physics", + "topic": "Uniform circular motion", + "title": "Uniform circular motion: a = v^2 / r", + "equation": "a_c = v^2 / r", + "keywords": [ + "uniform circular motion", + "centripetal acceleration", + "a = v^2/r", + "tangential velocity", + "angular speed", + "omega = v/r", + "radial acceleration", + "ap physics", + "circular motion", + "velocity direction" + ], + "explanation": "In uniform circular motion the speed v is constant but the velocity VECTOR continuously changes direction, so there is an acceleration. The velocity is always tangent to the circle (blue arrow); the acceleration points toward the center (pink arrow) and has magnitude a_c = v^2/r, with angular speed omega = v/r. Because nothing speeds the object up, there is no tangential acceleration — the net acceleration is purely radial (centripetal). On the AP exam, recognizing that 'constant speed' still means 'accelerating, directed inward' is the key conceptual hurdle this animation makes visible.", + "bullets": [ + "Velocity (blue) is always tangent to the circle.", + "Acceleration (pink) always points to the center.", + "Magnitude a_c = v^2/r; angular speed omega = v/r.", + "Constant speed, changing direction = nonzero acceleration." + ], + "params": [ + { + "name": "v", + "label": "speed v (m/s)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst r = 2.0;\nconst v = Math.max(P.v, 0.1);\nconst omega = v / r;\nconst ac = v * v / r;\nH.text(\"Uniform circular motion: a = v^2 / r\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Speed is constant; velocity turns, so accel points to the center\", 24, 52, { color: H.colors.sub, size: 13 });\nconst cx = H.W * 0.5, cy = H.H * 0.55;\nconst R = 150;\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 2 });\nH.circle(cx, cy, 4, { fill: H.colors.sub });\nconst ang = omega * t;\nconst px = cx + R * Math.cos(ang);\nconst py = cy - R * Math.sin(ang);\nH.line(cx, cy, px, py, { color: H.colors.axis, width: 1, dash: [4, 4] });\nconst vx = -Math.sin(ang), vy = -Math.cos(ang);\nconst vLen = 70;\nH.arrow(px, py, px + vx * vLen, py + vy * vLen, { color: H.colors.accent, width: 4 });\nH.text(\"v\", px + vx * vLen + 6, py + vy * vLen, { color: H.colors.accent, size: 13, baseline: \"middle\" });\nconst aLen = 30 + ac * 6;\nconst ax = (cx - px), ay = (cy - py);\nconst aMag = Math.sqrt(ax * ax + ay * ay) || 1;\nH.arrow(px, py, px + ax / aMag * Math.min(aLen, R - 10), py + ay / aMag * Math.min(aLen, R - 10), { color: H.colors.warn, width: 4 });\nH.text(\"a\", px + ax / aMag * 40, py + ay / aMag * 40, { color: H.colors.warn, size: 13, baseline: \"middle\" });\nH.circle(px, py, 9, { fill: H.colors.accent2, stroke: H.colors.ink, width: 2 });\nH.text(\"v = \" + v.toFixed(1) + \" m/s r = \" + r.toFixed(1) + \" m\", 24, 76, { color: H.colors.accent, size: 13 });\nH.text(\"omega = v/r = \" + omega.toFixed(2) + \" rad/s\", 24, 96, { color: H.colors.violet, size: 13 });\nH.text(\"a_c = v^2/r = \" + ac.toFixed(2) + \" m/s^2\", 24, 116, { color: H.colors.warn, size: 13 });\nH.legend([{ label: \"velocity (tangent)\", color: H.colors.accent }, { label: \"accel (centripetal)\", color: H.colors.warn }], H.W - 230, 80);" + }, + { + "id": "apphys-centripetal-force", + "area": "AP Physics", + "topic": "Centripetal force", + "title": "Centripetal force: Fc = m v^2 / r", + "equation": "F_c = m*v^2 / r", + "keywords": [ + "centripetal force", + "fc = mv^2/r", + "net inward force", + "circular motion force", + "tension", + "friction", + "newton second law circular", + "ap physics", + "radial force", + "mass speed radius" + ], + "explanation": "Centripetal force is the NET inward force required to keep a mass moving in a circle: Fc = m v^2 / r, always directed toward the center. It is not a new kind of force — it is supplied by tension, friction, gravity, or a normal force depending on the situation. The dependence is linear in mass m and inverse in radius r, but QUADRATIC in speed: doubling v quadruples the required force. On the AP exam you set the real force (e.g. tension or friction) equal to m v^2 / r; the pink inward arrow here grows and shrinks live as you change m, v, and r.", + "bullets": [ + "Fc is the NET force, always pointing to the center (pink arrow).", + "Fc = m v^2 / r: linear in m, inverse in r, quadratic in v.", + "Supplied by tension, friction, gravity, or normal force.", + "Double the speed -> 4x the force needed to keep the curve." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "v", + "label": "speed v (m/s)", + "min": 1, + "max": 12, + "step": 0.5, + "value": 5 + }, + { + "name": "r", + "label": "radius r (m)", + "min": 0.5, + "max": 6, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst m = Math.max(P.m, 0.1);\nconst v = Math.max(P.v, 0.1);\nconst r = Math.max(P.r, 0.3);\nconst Fc = m * v * v / r;\nconst omega = v / r;\nH.text(\"Centripetal force: Fc = m v^2 / r\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Net inward force keeps the mass on its circular path\", 24, 52, { color: H.colors.sub, size: 13 });\nconst cx = H.W * 0.52, cy = H.H * 0.56;\nconst R = 150;\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 2 });\nH.circle(cx, cy, 4, { fill: H.colors.sub });\nconst ang = omega * t;\nconst px = cx + R * Math.cos(ang);\nconst py = cy - R * Math.sin(ang);\nH.line(cx, cy, px, py, { color: H.colors.axis, width: 1, dash: [4, 4] });\nconst ax = (cx - px), ay = (cy - py);\nconst aMag = Math.sqrt(ax * ax + ay * ay) || 1;\nconst fLen = Math.min(40 + Fc * 1.5, R - 14);\nH.arrow(px, py, px + ax / aMag * fLen, py + ay / aMag * fLen, { color: H.colors.warn, width: 5 });\nH.text(\"Fc\", px + ax / aMag * (fLen + 16), py + ay / aMag * (fLen + 16), { color: H.colors.warn, size: 13, align: \"center\", baseline: \"middle\" });\nconst rDot = 8 + m * 2.5;\nH.circle(px, py, Math.min(rDot, 22), { fill: H.colors.accent2, stroke: H.colors.ink, width: 2 });\nH.text(\"m = \" + m.toFixed(1) + \" kg\", 24, 76, { color: H.colors.accent2, size: 13 });\nH.text(\"v = \" + v.toFixed(1) + \" m/s r = \" + r.toFixed(1) + \" m\", 24, 96, { color: H.colors.accent, size: 13 });\nH.text(\"Fc = m v^2 / r = \" + Fc.toFixed(1) + \" N\", 24, 120, { color: H.colors.warn, size: 15, weight: 700 });\nH.text(\"(doubling v quadruples Fc)\", 24, 140, { color: H.colors.sub, size: 12 });" + }, + { + "id": "apphys-banked-curve", + "area": "AP Physics", + "topic": "Banked curve", + "title": "Banked curve design speed: v = sqrt(g r tan(theta))", + "equation": "v = sqrt(g*r*tan(theta))", + "keywords": [ + "banked curve", + "banked turn", + "design speed", + "v = sqrt(g r tan theta)", + "frictionless bank", + "normal force component", + "centripetal", + "incline", + "ap physics", + "road banking", + "race track" + ], + "explanation": "On a frictionless banked curve, only the normal force N and gravity mg act on the car. The normal force tilts inward, so its horizontal component N sin(theta) provides the centripetal force while its vertical component N cos(theta) balances mg. Eliminating N gives the design (or 'ideal') speed v = sqrt(g r tan(theta)) — the one speed at which NO friction is needed. Below it the car tends to slide down the bank, above it outward; on the AP exam you derive this by writing N cos(theta) = mg and N sin(theta) = m v^2 / r and dividing. The animation shows N (green) perpendicular to the road and mg (pink) straight down as you change the bank angle.", + "bullets": [ + "Frictionless bank: only N (perpendicular to road) and mg act.", + "N sin(theta) supplies centripetal force; N cos(theta) balances mg.", + "Design speed v = sqrt(g r tan(theta)) needs no friction.", + "Steeper bank (larger theta) -> higher safe design speed." + ], + "params": [ + { + "name": "theta", + "label": "bank angle theta (deg)", + "min": 0, + "max": 45, + "step": 1, + "value": 20 + } + ], + "code": "H.background();\nconst g = 9.8, r = 50;\nconst theta = P.theta * Math.PI / 180;\nconst vDesign = Math.sqrt(g * r * Math.tan(theta));\nH.text(\"Banked curve (no friction): v = sqrt(g r tan(theta))\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"At the design speed, the normal force alone supplies the centripetal force\", 24, 52, { color: H.colors.sub, size: 13 });\nconst baseY = H.H * 0.74;\nconst apexX = H.W * 0.30;\nconst len = 320;\nconst ex = apexX + len * Math.cos(theta);\nconst ey = baseY - len * Math.sin(theta);\nH.line(apexX, baseY, apexX + len, baseY, { color: H.colors.grid, width: 2, dash: [5, 5] });\nH.path([[apexX, baseY], [ex, ey], [ex, baseY]], { color: H.colors.panel, fill: H.colors.panel, close: true, width: 1 });\nH.path([[apexX, baseY], [ex, ey]], { color: H.colors.axis, width: 4 });\nconst slide = (Math.sin(t * 0.7) * 0.5 + 0.5);\nconst fx = apexX + len * 0.35 + slide * len * 0.3;\nconst fy = baseY - (fx - apexX) * Math.tan(theta);\nconst carW = 56, carH = 26;\nconst ca = Math.cos(theta), sa = Math.sin(theta);\nconst corners = [[-carW/2,-carH],[carW/2,-carH],[carW/2,0],[-carW/2,0]].map(p => [fx + p[0]*ca + p[1]*sa, fy + (-p[0]*sa + p[1]*ca)]);\nH.path(corners, { color: H.colors.ink, fill: H.colors.accent2, close: true, width: 2 });\nconst Nlen = 70;\nH.arrow(fx, fy - carH * 0.4, fx + sa * Nlen, fy - ca * Nlen, { color: H.colors.good, width: 4 });\nH.text(\"N\", fx + sa * Nlen + 4, fy - ca * Nlen, { color: H.colors.good, size: 13, baseline: \"middle\" });\nH.arrow(fx, fy - carH * 0.4, fx, fy - carH * 0.4 + 55, { color: H.colors.warn, width: 4 });\nH.text(\"mg\", fx + 6, fy - carH * 0.4 + 55, { color: H.colors.warn, size: 13 });\nH.arrow(fx, fy - carH * 0.4, fx - 60, fy - carH * 0.4, { color: H.colors.violet, width: 3 });\nH.text(\"toward center\", fx - 64, fy - carH * 0.4 - 8, { color: H.colors.violet, size: 11, align: \"right\" });\nH.text(\"theta = \" + (P.theta).toFixed(0) + \" deg\", 24, 80, { color: H.colors.accent, size: 13 });\nH.text(\"r = \" + r.toFixed(0) + \" m, g = 9.8 m/s^2\", 24, 100, { color: H.colors.sub, size: 13 });\nH.text(\"design speed v = \" + vDesign.toFixed(1) + \" m/s (\" + (vDesign * 3.6).toFixed(0) + \" km/h)\", 24, 124, { color: H.colors.good, size: 15, weight: 700 });\nH.legend([{ label: \"Normal N\", color: H.colors.good }, { label: \"Weight mg\", color: H.colors.warn }], H.W - 180, 80);" + }, + { + "id": "apphys-gravitation", + "area": "AP Physics", + "topic": "Newton law of gravitation", + "title": "Newton's law of gravitation: F = G m1 m2 / r^2", + "equation": "F = G*m1*m2 / r^2", + "keywords": [ + "newton gravitation", + "law of universal gravitation", + "f = g m1 m2 / r^2", + "inverse square law", + "gravitational force", + "big g", + "newtons third law", + "ap physics", + "separation distance", + "attraction" + ], + "explanation": "Newton's law of universal gravitation says every pair of masses attracts along the line joining them with force F = G m1 m2 / r^2, where G = 6.674e-11 N m^2/kg^2. The forces on the two bodies are equal in magnitude and opposite in direction (Newton's third law), regardless of how different the masses are. The defining feature is the INVERSE-SQUARE dependence: triple the separation r and the force drops to one-ninth. On the AP exam you use this for orbits, g = GM/r^2 at a surface, and comparing forces at different distances; the graph here shows the 1/r^2 curve with a live dot tracking the force as you slide the separation r.", + "bullets": [ + "Force acts along the line joining the masses, always attractive.", + "Equal and opposite on both bodies (Newton's 3rd law).", + "Inverse-square: 3x the distance -> 1/9 the force.", + "Live dot rides the 1/r^2 curve as separation r changes." + ], + "params": [ + { + "name": "r", + "label": "separation r (10^6 m)", + "min": 2, + "max": 50, + "step": 1, + "value": 10 + } + ], + "code": "H.background();\nconst G = 6.674e-11;\nconst m1 = 5.0e24, m2 = 7.0e22;\nconst r = Math.max(P.r, 1) * 1e6;\nconst F = G * m1 * m2 / (r * r);\nH.text(\"Newton's gravitation: F = G m1 m2 / r^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Equal and opposite attraction; force falls off as 1 / r^2\", 24, 52, { color: H.colors.sub, size: 13 });\nconst v = H.plot2d({ xMin: 1, xMax: 50, yMin: 0, yMax: 1.0 });\nv.grid(); v.axes();\nconst Fat = (rm) => G * m1 * m2 / ((rm * 1e6) * (rm * 1e6)) / 1e10;\nv.fn(x => Fat(x), { color: H.colors.accent, width: 3 });\nconst rm = P.r;\nv.dot(rm, Fat(rm), { r: 7, fill: H.colors.warn });\nv.text(\"r = \" + rm.toFixed(0) + \" Mm\", rm + 1, Fat(rm) + 0.04);\nH.text(\"F = \" + (F).toExponential(2) + \" N\", 24, 78, { color: H.colors.warn, size: 15, weight: 700 });\nH.text(\"m1 = 5.0e24 kg, m2 = 7.0e22 kg\", 24, 100, { color: H.colors.sub, size: 12 });\nH.text(\"force vs separation (x10^10 N)\", 24, H.H - 18, { color: H.colors.sub, size: 12 });\nconst sep = 60 + rm * 4;\nconst aX = H.W * 0.62, aY = H.H * 0.30;\nconst bX = Math.min(aX + sep, H.W - 60);\nH.circle(aX, aY, 16, { fill: H.colors.accent, stroke: H.colors.ink, width: 2 });\nH.circle(bX, aY, 9, { fill: H.colors.accent2, stroke: H.colors.ink, width: 2 });\nconst pull = 18 + Math.min(F * 1e9, 30);\nH.arrow(aX + 20, aY, aX + 20 + pull, aY, { color: H.colors.warn, width: 3 });\nH.arrow(bX - 14, aY, bX - 14 - pull, aY, { color: H.colors.warn, width: 3 });\nH.text(\"F\", aX + 24 + pull, aY - 10, { color: H.colors.warn, size: 12 });\nH.text(\"F\", bX - 24 - pull, aY - 10, { color: H.colors.warn, size: 12, align: \"right\" });\nconst wob = 4 * Math.sin(t * 2);\nH.line(aX, aY + 26, bX, aY + 26 + wob * 0, { color: H.colors.grid, width: 1, dash: [4,4] });\nH.text(\"r\", (aX + bX) / 2, aY + 40, { color: H.colors.sub, size: 12, align: \"center\" });" + }, + { + "id": "apphys-orbital-velocity", + "area": "AP Physics", + "topic": "Orbital velocity", + "title": "Orbital speed: v = sqrt(G*M / r)", + "equation": "v = sqrt(G*M / r)", + "keywords": [ + "orbital velocity", + "orbital speed", + "circular orbit", + "v = sqrt(GM/r)", + "satellite speed", + "gravitational force", + "centripetal force", + "newton gravity", + "keplerian orbit", + "ap physics", + "orbit radius" + ], + "explanation": "For a circular orbit, gravity supplies the centripetal force: G*M*m/r^2 = m*v^2/r, which solves to v = sqrt(G*M/r). The orbital speed depends only on the central mass M and the radius r, NOT on the satellite's mass, and a larger orbit means a SLOWER satellite (v falls off as 1/sqrt(r)). On the AP exam this is the standard setup-and-cancel derivation, and the velocity vector is always tangent to the orbit (perpendicular to the radius). The animation shows a satellite circling Earth: increase r and it moves to a wider, slower orbit while the live readout updates v in km/s.", + "bullets": [ + "Gravity is the centripetal force: G*M*m/r^2 = m*v^2/r.", + "Set them equal and m cancels — speed is independent of satellite mass.", + "v shrinks as 1/sqrt(r): bigger orbits are slower.", + "The velocity arrow is tangent, perpendicular to the radius." + ], + "params": [ + { + "name": "r", + "label": "orbit radius r (Earth radii)", + "min": 1, + "max": 8, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -7, yMax: 7 });\nv.grid(); v.axes();\nconst G = 6.674e-11;\nconst M = 5.972e24; // Earth mass (kg)\nconst rScale = Math.max(0.5, P.r); // orbit radius in Earth radii\nconst Re = 6.371e6; // Earth radius (m)\nconst r = rScale * Re; // physical orbit radius (m)\nconst vOrb = Math.sqrt(G * M / r); // orbital speed (m/s)\n// Earth at origin\nv.circle(0, 0, 14, { fill: H.colors.accent, stroke: H.colors.ink, width: 1 });\n// orbit path radius kept on screen\nconst Rdisp = Math.min(6, rScale);\nconst orbit = [];\nfor (let i = 0; i <= 80; i++) { const a = (i / 80) * Math.PI * 2; orbit.push([Rdisp * Math.cos(a), Rdisp * Math.sin(a)]); }\nv.path(orbit, { color: H.colors.grid, width: 1.5, dash: [4, 4] });\n// satellite: angular speed scaled so faster (closer) orbits look faster\nconst omega = 1.2 / Math.sqrt(rScale);\nconst ang = omega * t;\nconst sx = Rdisp * Math.cos(ang), sy = Rdisp * Math.sin(ang);\nv.dot(sx, sy, { r: 7, fill: H.colors.good });\n// velocity vector (tangent to orbit)\nv.arrow(sx, sy, sx - 2 * Math.sin(ang), sy + 2 * Math.cos(ang), { color: H.colors.warn, width: 3 });\nH.text(\"Orbital speed: v = sqrt(G M / r)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"r = \" + rScale.toFixed(1) + \" R_Earth = \" + (r / 1e6).toFixed(2) + \" x10^6 m\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"v = \" + (vOrb / 1000).toFixed(2) + \" km/s\", 24, 74, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"Earth\", color: H.colors.accent }, { label: \"satellite\", color: H.colors.good }, { label: \"velocity v\", color: H.colors.warn }], H.W - 180, 28);" + }, + { + "id": "apphys-keplers-third", + "area": "AP Physics", + "topic": "Kepler third law", + "title": "Kepler's 3rd law: T^2 = a^3 (yr, AU)", + "equation": "T^2 = a^3", + "keywords": [ + "kepler third law", + "keplers third law", + "T squared proportional a cubed", + "T^2 = a^3", + "semi-major axis", + "orbital period", + "planetary motion", + "period radius relationship", + "ap physics", + "astronomical unit", + "orbit period" + ], + "explanation": "Kepler's third law states the square of a planet's orbital period is proportional to the cube of its semi-major axis: T^2 = k*a^3. In solar-system units (T in years, a in AU) the constant k = 1, so T = a^(3/2). This follows from Newtonian gravity (k = 4*pi^2/(G*M)), and the AP exam often asks you to compare two orbits using the ratio (T1/T2)^2 = (a1/a2)^3. The animation plots T = a^1.5: drag the semi-major axis and watch the period grow faster than linearly, with the T^2 and a^3 readouts staying equal.", + "bullets": [ + "T^2 = a^3 in years and AU — the proportionality constant is 1.", + "Equivalently T = a^(3/2): the curve steepens as a grows.", + "Doubling a multiplies the period by 2^1.5 ≈ 2.83.", + "From Newton's gravity, k = 4*pi^2/(G*M)." + ], + "params": [ + { + "name": "a", + "label": "semi-major axis a (AU)", + "min": 0.3, + "max": 5, + "step": 0.1, + "value": 1.5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 6, yMin: 0, yMax: 16 });\nv.grid(); v.axes();\n// Kepler's third law: T^2 = k * a^3 (for solar system, k=1 yr^2/AU^3)\nconst a = Math.max(0.2, P.a); // semi-major axis (AU)\nconst T = Math.pow(a, 1.5); // period (yr), since T^2 = a^3\n// plot T vs a (curve) and mark the planet\nv.fn(x => Math.pow(x, 1.5), { color: H.colors.accent, width: 3 });\nv.dot(a, T, { r: 7, fill: H.colors.warn });\n// guide lines to axes\nv.line(a, 0, a, T, { color: H.colors.grid, width: 1, dash: [3, 3] });\nv.line(0, T, a, T, { color: H.colors.grid, width: 1, dash: [3, 3] });\nv.text(\"a = \" + a.toFixed(2) + \" AU\", a + 0.1, T * 0.5, { color: H.colors.sub, size: 12 });\nv.text(\"T = \" + T.toFixed(2) + \" yr\", a * 0.45, T + 0.6, { color: H.colors.sub, size: 12 });\n// animated sweep dot riding the curve to show T grows faster than a\nconst xs = 0.3 + 2.7 * (0.5 + 0.5 * Math.sin(t * 0.7));\nv.dot(xs, Math.pow(xs, 1.5), { r: 5, fill: H.colors.good });\nH.text(\"Kepler's 3rd law: T^2 = a^3\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"period grows as a^(3/2) (T in yr, a in AU)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"T^2 = \" + (T * T).toFixed(2) + \" a^3 = \" + Math.pow(a, 3).toFixed(2), 24, 74, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"T = a^1.5\", color: H.colors.accent }, { label: \"your planet\", color: H.colors.warn }], H.W - 180, 28);" + }, + { + "id": "apphys-gravitational-pe", + "area": "AP Physics", + "topic": "Gravitational potential energy", + "title": "Gravitational PE near surface: Ug = m*g*h", + "equation": "Ug = m*g*h", + "keywords": [ + "gravitational potential energy", + "Ug = mgh", + "potential energy", + "mgh", + "height energy", + "gravity energy", + "reference level", + "conservation of energy", + "ap physics", + "joules", + "g = 9.8" + ], + "explanation": "Near Earth's surface gravity is nearly constant, so the gravitational potential energy is Ug = m*g*h, where h is the height above a chosen reference level. Only CHANGES in Ug are physically meaningful, so you can place h = 0 wherever is convenient; here it's the ground. The relationship is linear — double the height and you double the energy — and on the AP exam Ug is paired with kinetic energy through conservation of mechanical energy. The animation bobs a 2 kg mass up and down while a dot rides the straight Ug = m*g*h line, with the live energy readout in joules.", + "bullets": [ + "Ug = m*g*h is linear in height — the graph is a straight line.", + "Only the change in Ug matters; the h = 0 reference is your choice.", + "g = 9.8 m/s^2 is treated as constant near the surface.", + "Lift the mass and gained Ug equals the work done against gravity." + ], + "params": [ + { + "name": "h", + "label": "max height h (m)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: 0, yMax: 50 });\nv.grid(); v.axes();\nconst g = 9.8; // m/s^2\nconst m = 2; // assume 2 kg mass\nconst hMax = Math.max(0.5, P.h); // chosen height (m)\n// Ug = m g h is linear in h: draw the line\nv.fn(x => m * g * x, { color: H.colors.accent, width: 3 });\n// object bobs between ground and hMax to show U rising with height\nconst h = hMax * (0.5 - 0.5 * Math.cos(t * 0.9)); // 0 .. hMax\nconst U = m * g * h;\n// vertical position marker on a \"pole\" at x=1\nv.line(1, 0, 1, hMax, { color: H.colors.grid, width: 2 });\nv.dot(1, h, { r: 8, fill: H.colors.warn });\nv.text(\"m = 2 kg\", 1.3, h, { color: H.colors.sub, size: 12 });\n// point on the U(h) line\nv.dot(h, U, { r: 6, fill: H.colors.good });\nv.line(h, 0, h, U, { color: H.colors.grid, width: 1, dash: [3, 3] });\nH.text(\"Gravitational PE (near surface): Ug = m g h\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"g = 9.8 m/s^2, m = 2 kg (Ug measured from the ground, h = 0)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"h = \" + h.toFixed(2) + \" m Ug = \" + U.toFixed(1) + \" J\", 24, 74, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"Ug = mgh line\", color: H.colors.accent }, { label: \"mass\", color: H.colors.warn }, { label: \"Ug now\", color: H.colors.good }], H.W - 190, 28);" + }, + { + "id": "apphys-work-constant-force", + "area": "AP Physics", + "topic": "Work by a constant force", + "title": "Work by a constant force: W = F*d*cos(theta)", + "equation": "W = F*d*cos(theta)", + "keywords": [ + "work", + "work done by force", + "W = Fd cos theta", + "constant force", + "dot product", + "force angle", + "negative work", + "scalar product", + "ap physics", + "joules", + "displacement" + ], + "explanation": "Work done by a constant force is W = F*d*cos(theta), the product of the displacement with the COMPONENT of force along that displacement. Only F*cos(theta) does work, so a force perpendicular to the motion (theta = 90 degrees, cos = 0) does zero work, and a force with a backward component (theta > 90 degrees) does NEGATIVE work. Work is a scalar measured in joules, and on the AP exam it is the dot product F·d. The animation slides a block through a fixed displacement while you set the force magnitude and its angle, showing the F*cos(theta) component and the live W readout.", + "bullets": [ + "Only the force component along d, F*cos(theta), does work.", + "theta = 90 degrees gives cos = 0, so perpendicular forces do zero work.", + "theta > 90 degrees makes cos negative, so the work is negative.", + "Work is a scalar (the dot product F·d), in joules." + ], + "params": [ + { + "name": "F", + "label": "force F (N)", + "min": 0, + "max": 50, + "step": 1, + "value": 20 + }, + { + "name": "theta", + "label": "angle θ (deg)", + "min": 0, + "max": 180, + "step": 5, + "value": 30 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: -3, yMax: 5 });\nv.grid(); v.axes();\nconst F = Math.max(0, P.F); // applied force (N)\nconst ang = (P.theta) * Math.PI / 180; // angle above horizontal\nconst d = 8; // displacement (m)\nconst W = F * d * Math.cos(ang); // work done by the force\n// block slides along the floor, displacement d, looping\nconst yFloor = 0;\nv.line(0, yFloor, 10, yFloor, { color: H.colors.axis, width: 2 });\nconst xc = 0.5 + d * (0.5 - 0.5 * Math.cos(t * 0.8)); // 0.5 .. 8.5\nv.rect(xc - 0.6, yFloor, 1.2, 0.9, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\n// force vector at angle theta from block\nconst fLen = Math.min(4, F * 0.06);\nconst fx = xc + fLen * Math.cos(ang), fy = yFloor + 0.45 + fLen * Math.sin(ang);\nv.arrow(xc, yFloor + 0.45, fx, fy, { color: H.colors.warn, width: 4 });\n// horizontal component (the part that does work)\nv.arrow(xc, yFloor + 0.45, xc + fLen * Math.cos(ang), yFloor + 0.45, { color: H.colors.good, width: 2, dash: [4, 3] });\nv.text(\"F cosθ\", fx + 0.2, yFloor + 0.6, { color: H.colors.sub, size: 12 });\n// displacement arrow\nv.arrow(0.5, yFloor + 1.8, 8.5, yFloor + 1.8, { color: H.colors.violet, width: 2 });\nv.text(\"d = 8 m\", 4, yFloor + 2.2, { color: H.colors.sub, size: 12 });\nH.text(\"Work by a constant force: W = F d cosθ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"F = \" + F.toFixed(1) + \" N, θ = \" + P.theta.toFixed(0) + \"°, d = 8 m\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"W = \" + F.toFixed(1) + \" x 8 x cos(\" + P.theta.toFixed(0) + \"°) = \" + W.toFixed(1) + \" J\", 24, 74, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"F (applied)\", color: H.colors.warn }, { label: \"F cosθ\", color: H.colors.good }, { label: \"d\", color: H.colors.violet }], H.W - 175, 28);" + }, + { + "id": "apphys-work-energy-theorem", + "area": "AP Physics", + "topic": "Work-energy theorem", + "title": "Work-energy theorem: ΔKE = W_net", + "equation": "KE_f - KE_0 = W_net", + "keywords": [ + "work energy theorem", + "change in kinetic energy", + "delta KE = Wnet", + "net work", + "kinetic energy", + "KE = half m v squared", + "work done", + "speeding up slowing down", + "ap physics", + "joules", + "velocity" + ], + "explanation": "The work-energy theorem says the NET work done on an object equals its change in kinetic energy: W_net = KE_f - KE_0 = (1/2)m*v_f^2 - (1/2)m*v_0^2. Positive net work speeds the object up; negative net work (like friction) slows it down. It bundles all forces into one scalar equation, so on the AP exam it is often faster than kinematics for finding a final speed. The animation pushes a 2 kg cart that starts with KE_0 = 16 J: set the net work and watch the velocity arrow and the final-KE bar change, with v_f = sqrt(2*KE_f/m) read out live.", + "bullets": [ + "W_net = KE_f - KE_0 — net work equals the change in kinetic energy.", + "Positive W_net speeds it up; negative W_net (friction) slows it down.", + "KE = (1/2)m*v^2, so v_f = sqrt(2*KE_f/m).", + "If W_net would drive KE below 0 the object simply stops (v = 0)." + ], + "params": [ + { + "name": "W", + "label": "net work W_net (J)", + "min": -16, + "max": 40, + "step": 2, + "value": 20 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: -2, yMax: 6 });\nv.grid(); v.axes();\nconst m = 2; // mass (kg)\nconst Wnet = P.W; // net work done (J), can be + or -\nconst KE0 = 16; // initial KE (J), v0 = 4 m/s\nconst KEf = Math.max(0, KE0 + Wnet); // work-energy theorem: KEf = KE0 + Wnet\nconst v0 = Math.sqrt(2 * KE0 / m);\nconst vf = Math.sqrt(2 * KEf / m);\n// animate cart speeding up / slowing down along track over one push\nconst tc = (t * 0.5) % 1; // 0..1 fraction of the push\nconst KEnow = KE0 + Wnet * tc;\nconst KEsafe = Math.max(0, KEnow);\nconst vNow = Math.sqrt(2 * KEsafe / m);\nconst xc = 0.5 + 8 * tc;\nv.line(0, 0, 10, 0, { color: H.colors.axis, width: 2 });\nv.rect(xc - 0.5, 0, 1, 0.8, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\n// velocity arrow (length ~ vNow)\nv.arrow(xc, 0.4, xc + Math.min(3, vNow * 0.45), 0.4, { color: H.colors.good, width: 3 });\n// KE bars: initial and final\nv.rect(2, -1.8, 1.2, KE0 * 0.18, { fill: H.colors.grid });\nv.text(\"KE0\", 2, -1.95, { color: H.colors.sub, size: 11 });\nv.rect(6.5, -1.8, 1.2, KEf * 0.18, { fill: H.colors.warn });\nv.text(\"KEf\", 6.5, -1.95, { color: H.colors.sub, size: 11 });\nH.text(\"Work-energy theorem: ΔKE = W_net\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"m = 2 kg, KE0 = 16 J (v0 = 4 m/s); W_net = \" + Wnet.toFixed(1) + \" J\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"KEf = KE0 + W_net = \" + KEf.toFixed(1) + \" J -> vf = \" + vf.toFixed(2) + \" m/s\", 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"v = \" + vNow.toFixed(2) + \" m/s\", 24, 94, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"cart velocity\", color: H.colors.good }, { label: \"KE final\", color: H.colors.warn }], H.W - 175, 28);" + }, + { + "id": "apphys-kinetic-energy", + "area": "AP Physics", + "topic": "Kinetic energy", + "title": "Kinetic energy: KE = 1/2 m v^2", + "equation": "KE = 0.5 * m * v^2", + "keywords": [ + "kinetic energy", + "ke", + "1/2 m v^2", + "half m v squared", + "energy of motion", + "joules", + "mass and speed", + "work energy theorem", + "ap physics", + "speed squared" + ], + "explanation": "Kinetic energy is the energy an object has because of its motion, KE = 1/2 m v^2, measured in joules. The defining feature for the AP exam is that KE depends on the SQUARE of speed: doubling v quadruples KE, while doubling m only doubles it. The animation shows a block sliding back and forth while a dot rides the KE = 1/2 m v^2 parabola, so the readout climbs much faster than the speed does. This quadratic dependence is exactly why the work-energy theorem (W_net = delta KE) and stopping-distance problems behave the way they do.", + "bullets": [ + "KE rises with the SQUARE of speed — the dot races up the parabola as v grows.", + "Doubling speed gives 4x the kinetic energy; doubling mass gives only 2x.", + "Units are joules (kg*m^2/s^2); KE is always >= 0.", + "The block's live speed and KE readout track the same v on the curve." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 1, + "max": 20, + "step": 1, + "value": 5 + }, + { + "name": "v", + "label": "max speed v (m/s)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 8 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 12, yMin: 0, yMax: 1200 });\nv.grid(); v.axes();\nconst m = P.m, vmax = P.v;\nv.fn(x => 0.5 * m * x * x, { color: H.colors.accent, width: 3 });\nconst sp = vmax * (0.5 + 0.5 * Math.sin(t * 0.8));\nconst KE = 0.5 * m * sp * sp;\nv.dot(sp, KE, { r: 7, fill: H.colors.warn });\nconst px = 60 + (H.W - 120) * (0.5 + 0.5 * Math.sin(t * 0.8));\nH.line(40, 110, H.W - 40, 110, { color: H.colors.grid, width: 2 });\nH.rect(px - 16, 92, 32, 22, { fill: H.colors.accent2, radius: 4 });\nH.arrow(px + 16, 103, px + 16 + Math.min(60, sp * 6), 103, { color: H.colors.good, width: 3 });\nH.text(\"Kinetic energy: KE = 1/2 m v^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"KE grows with the SQUARE of speed\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"v = \" + sp.toFixed(1) + \" m/s\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"KE = \" + KE.toFixed(0) + \" J\", 24, 96, { color: H.colors.good, size: 13 });" + }, + { + "id": "apphys-hookes-law", + "area": "AP Physics", + "topic": "Hooke law & spring PE", + "title": "Hooke's law: F = k x, PE = 1/2 k x^2", + "equation": "F = k*x, PE = 0.5*k*x^2", + "keywords": [ + "hooke's law", + "spring force", + "f = kx", + "spring constant", + "elastic potential energy", + "pe = 1/2 k x^2", + "restoring force", + "stiffness", + "extension", + "ap physics" + ], + "explanation": "Hooke's law says an ideal spring exerts a restoring force F = k x proportional to its displacement x from equilibrium, where k (N/m) is the spring constant; the force points back toward equilibrium. The elastic potential energy stored is the area under that F-vs-x line, PE = 1/2 k x^2, which grows quadratically. On the AP exam this pairing is key: F is linear in x (used in SHM, F = -k x) while stored energy is quadratic in x. The animation stretches a spring while showing the restoring-force arrow grow linearly and a dot climbing the 1/2 k x^2 energy parabola.", + "bullets": [ + "Restoring force F = k x is LINEAR — the arrow length scales with stretch x.", + "Stored energy PE = 1/2 k x^2 is the triangular area under the F-x line.", + "Stiffer springs (larger k) give both more force and more stored energy.", + "A negative-x compression stores the same energy as the equal +x stretch." + ], + "params": [ + { + "name": "k", + "label": "spring constant k (N/m)", + "min": 2, + "max": 40, + "step": 1, + "value": 20 + }, + { + "name": "x", + "label": "max stretch x (m)", + "min": 0.5, + "max": 4, + "step": 0.25, + "value": 3 + } + ], + "code": "H.background();\nconst k = P.k;\nconst x = P.x * (0.5 + 0.5 * Math.sin(t * 0.9));\nconst F = k * x;\nconst PE = 0.5 * k * x * x;\nconst wallX = 80, restLen = 220, cy = 150;\nconst px = wallX + restLen + x * 20;\nH.line(wallX, cy - 40, wallX, cy + 40, { color: H.colors.ink, width: 4 });\nconst coils = 12, pts = [];\nfor (let i = 0; i <= coils * 2; i++) {\n const fx = wallX + (px - 40 - wallX) * (i / (coils * 2));\n const fy = cy + ((i % 2 === 0) ? 0 : (i % 4 === 1 ? -16 : 16));\n pts.push([fx, fy]);\n}\nH.path(pts, { color: H.colors.accent, width: 3 });\nH.rect(px - 40, cy - 26, 40, 52, { fill: H.colors.accent2, radius: 4 });\nH.arrow(px - 20, cy, px - 20 - Math.max(8, F * 4), cy, { color: H.colors.warn, width: 3 });\nconst v = H.plot2d({ xMin: 0, xMax: 5, yMin: 0, yMax: 250 });\nv.grid(); v.axes();\nv.fn(s => 0.5 * k * s * s, { color: H.colors.good, width: 3 });\nv.dot(Math.abs(x), PE, { r: 6, fill: H.colors.warn });\nH.text(\"Hooke's law: F = k x, PE = 1/2 k x^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Restoring force grows linearly; PE grows as x^2\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"x = \" + x.toFixed(2) + \" m F = \" + F.toFixed(1) + \" N\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"PE = \" + PE.toFixed(1) + \" J\", 24, 96, { color: H.colors.good, size: 13 });" + }, + { + "id": "apphys-energy-conservation", + "area": "AP Physics", + "topic": "Conservation of mechanical energy", + "title": "Conservation of energy: KE + PE = constant", + "equation": "KE + PE = E_total = m*g*H (constant)", + "keywords": [ + "conservation of energy", + "mechanical energy", + "ke + pe", + "potential energy", + "kinetic energy", + "mgh", + "frictionless", + "energy bars", + "ap physics", + "total energy" + ], + "explanation": "With no friction or air resistance, the total mechanical energy E = KE + PE stays constant; as an object descends, gravitational PE = m g h converts into KE = 1/2 m v^2. The animation drops a ball down a frictionless ramp: the violet PE bar shrinks while the orange KE bar grows by exactly the same amount, and the green total bar never changes. On the AP exam this lets you find speed from height alone (1/2 v^2 = g(H - h)) without ever using kinematics or time. The fixed-total dashed line is the visual signature graders want students to recognize.", + "bullets": [ + "The green total-energy bar stays fixed while PE and KE trade off.", + "At the top: all PE, zero KE; at the bottom: all KE, zero PE.", + "Speed comes from the drop: v = sqrt(2 g (H - h)) — mass cancels out.", + "Friction would lower the total bar; here it stays on the dashed line." + ], + "params": [ + { + "name": "h", + "label": "release height H (m)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\nconst Htop = P.h;\nconst g = 9.8, m = 1;\nconst frac = 0.5 + 0.5 * Math.cos(t * 1.2);\nconst h = Htop * frac;\nconst Etot = m * g * Htop;\nconst PE = m * g * h;\nconst KE = Etot - PE;\nconst speed = Math.sqrt(2 * g * Math.max(0, KE) / m);\nconst x0 = 80, y0 = 320, x1 = 320, y1 = 120;\nH.line(x0, y0, x1, y1, { color: H.colors.grid, width: 3 });\nH.line(x1, y1, x1, y0, { color: H.colors.grid, width: 1, dash: [4, 4] });\nconst bx = x0 + (x1 - x0) * frac;\nconst by = y0 + (y1 - y0) * frac;\nH.circle(bx, by, 12, { fill: H.colors.accent2 });\nH.text(\"h = \" + h.toFixed(2) + \" m\", x1 + 10, y1, { color: H.colors.sub, size: 12 });\nconst bx0 = 480, bw = 70, base = 340, maxh = 220, scale = maxh / Etot;\nH.rect(bx0, base - PE * scale, bw, PE * scale, { fill: H.colors.violet });\nH.rect(bx0 + 100, base - KE * scale, bw, KE * scale, { fill: H.colors.warn });\nH.rect(bx0 + 200, base - Etot * scale, bw, Etot * scale, { fill: H.colors.good });\nH.line(bx0 - 10, base - Etot * scale, bx0 + 290, base - Etot * scale, { color: H.colors.good, width: 1, dash: [5, 5] });\nH.text(\"PE\", bx0 + 24, base + 16, { color: H.colors.sub, size: 12 });\nH.text(\"KE\", bx0 + 124, base + 16, { color: H.colors.sub, size: 12 });\nH.text(\"E\", bx0 + 230, base + 16, { color: H.colors.sub, size: 12 });\nH.text(\"Conservation: KE + PE = constant\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"As the ball drops, PE converts to KE; total E stays fixed\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"PE = \" + PE.toFixed(1) + \" J KE = \" + KE.toFixed(1) + \" J v = \" + speed.toFixed(1) + \" m/s\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"E = \" + Etot.toFixed(1) + \" J (fixed)\", 24, 96, { color: H.colors.good, size: 13 });" + }, + { + "id": "apphys-power", + "area": "AP Physics", + "topic": "Power", + "title": "Power: P = W / t = F v", + "equation": "P = W / t = F * v", + "keywords": [ + "power", + "p = w/t", + "work over time", + "watts", + "rate of work", + "p = fv", + "force times velocity", + "joules per second", + "horsepower", + "ap physics" + ], + "explanation": "Power is the rate at which work is done, P = W / t, measured in watts (1 W = 1 J/s). The same average power can come from a small force over a long time or a large force over a short time, since for the same total work P is inversely proportional to t. The equivalent instantaneous form P = F v shows power as force times velocity, which is why a car needs more power to maintain speed against larger drag. The animation fills a work bar over a time interval and rides a dot along the P = W/t curve, with the live watt readout and the matching F-at-v form.", + "bullets": [ + "The work bar fills over the interval; P is that work divided by the time.", + "P = W/t is a 1/t curve — halving the time doubles the power for the same W.", + "The equivalent form P = F v gives the force needed at a given speed.", + "Watts = joules per second; the readout updates as work accumulates." + ], + "params": [ + { + "name": "work", + "label": "work W (J)", + "min": 10, + "max": 500, + "step": 10, + "value": 200 + }, + { + "name": "time", + "label": "time t (s)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\nconst Wk = P.work;\nconst time = Math.max(0.1, P.time);\nconst Pavg = Wk / time;\nconst phase = (t * 0.5) % 1;\nconst wDone = Wk * phase;\nconst tNow = time * phase;\nconst bx = 60, by = 110, bw = H.W - 120, bh = 26;\nH.rect(bx, by, bw, bh, { stroke: H.colors.grid, width: 2, radius: 4 });\nH.rect(bx, by, bw * phase, bh, { fill: H.colors.accent, radius: 4 });\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: 0, yMax: Math.max(50, Pavg * 1.5 + 10) });\nv.grid(); v.axes();\nv.fn(tt => Wk / Math.max(0.1, tt), { color: H.colors.violet, width: 3 });\nv.dot(time, Pavg, { r: 7, fill: H.colors.warn });\nconst refV = 2;\nconst Fequiv = Pavg / refV;\nH.text(\"Power: P = W / t = F v\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Power is the RATE of doing work (watts = J/s)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"W = \" + wDone.toFixed(0) + \" / \" + Wk.toFixed(0) + \" J t = \" + tNow.toFixed(2) + \" s\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"P = \" + Pavg.toFixed(1) + \" W (= F v: F = \" + Fequiv.toFixed(1) + \" N at v = \" + refV + \" m/s)\", 24, 96, { color: H.colors.good, size: 13 });" + }, + { + "id": "apphys-roller-coaster", + "area": "AP Physics", + "topic": "Energy on a track", + "title": "Energy on a track: 1/2 v^2 = g(H - h)", + "equation": "0.5*v^2 = g*(H - h), v = sqrt(2 g (H - h))", + "keywords": [ + "roller coaster", + "energy on a track", + "pe to ke", + "conservation of energy", + "mgh", + "1/2 m v^2", + "speed from height", + "frictionless track", + "hill", + "ap physics" + ], + "explanation": "On a frictionless track, total mechanical energy is conserved, so a cart's speed depends only on how far below the highest point it currently sits: 1/2 v^2 = g(H - h), giving v = sqrt(2 g (H - h)). The cart is fastest at the lowest point of the hill and momentarily slowest at the crests, regardless of the track's shape between them. The animation runs a cart over a hill profile with a velocity arrow and side-by-side PE/KE bars (per kilogram) that swap as it rises and falls. This is the classic AP setup for finding speeds at any point on a coaster or pendulum without using forces or time.", + "bullets": [ + "Speed is set by the DROP from the top: v = sqrt(2 g (H - h)).", + "Fastest in the valley, slowest at the crest — independent of mass.", + "The PE and KE bars trade height while their sum stays constant.", + "Path shape between two heights doesn't matter, only the height change." + ], + "params": [ + { + "name": "start", + "label": "top height H (m)", + "min": 2, + "max": 15, + "step": 1, + "value": 8 + } + ], + "code": "H.background();\nconst g = 9.8;\nconst startH = P.start;\nconst gx0 = 60, gx1 = H.W - 60, gtop = 110, gbot = 360;\nfunction hillY(s){ return startH * (0.55 + 0.45 * Math.cos(s * Math.PI * 2.0)); }\nconst Hmax = startH;\nconst pts = [];\nconst N = 60;\nfor (let i = 0; i <= N; i++){\n const s = i / N;\n const hm = hillY(s);\n const px = gx0 + (gx1 - gx0) * s;\n const py = gbot - (gbot - gtop) * (hm / Math.max(0.01, Hmax));\n pts.push([px, py]);\n}\nH.path(pts, { color: H.colors.grid, width: 4 });\nconst s = 0.5 + 0.5 * Math.sin(t * 0.7);\nconst hm = hillY(s);\nconst cpx = gx0 + (gx1 - gx0) * s;\nconst cpy = gbot - (gbot - gtop) * (hm / Math.max(0.01, Hmax));\nconst Etot = g * Hmax;\nconst PE = g * hm;\nconst KE = Math.max(0, Etot - PE);\nconst speed = Math.sqrt(2 * KE);\nH.circle(cpx, cpy - 10, 11, { fill: H.colors.accent2 });\nH.arrow(cpx, cpy - 10, cpx + Math.min(70, speed * 5 + 2), cpy - 10, { color: H.colors.warn, width: 3 });\nconst bx0 = gx1 - 120, base = 470, scale = 90 / Math.max(0.01, Etot);\nH.rect(bx0, base - PE * scale, 30, PE * scale, { fill: H.colors.violet });\nH.rect(bx0 + 45, base - KE * scale, 30, KE * scale, { fill: H.colors.warn });\nH.text(\"PE\", bx0, base + 14, { color: H.colors.sub, size: 11 });\nH.text(\"KE\", bx0 + 45, base + 14, { color: H.colors.sub, size: 11 });\nH.text(\"Energy on a track: 1/2 v^2 = g(H - h)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"On a frictionless hill, speed is set by how far you've dropped\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"h = \" + hm.toFixed(1) + \" m PE = \" + PE.toFixed(1) + \" J/kg KE = \" + KE.toFixed(1) + \" J/kg\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"v = \" + speed.toFixed(1) + \" m/s\", 24, 96, { color: H.colors.good, size: 13 });" + }, + { + "id": "apphys-impulse-momentum", + "area": "AP Physics", + "topic": "Impulse-momentum theorem", + "title": "Impulse-momentum theorem: J = F*dt = dp", + "equation": "J = F*dt = dp = m*dv", + "keywords": [ + "impulse", + "impulse momentum theorem", + "J = F dt", + "change in momentum", + "force times time", + "area under force time graph", + "Ns", + "momentum", + "ap physics", + "newtons second law", + "dp = F dt" + ], + "explanation": "Impulse J is the product of a (constant) force F and the time interval dt over which it acts, J = F*dt, and it equals the change in momentum dp = m*dv. On a force-vs-time graph the impulse is the AREA under the curve, which AP problems often ask you to read off geometrically. Because dp = J, the same impulse produces a larger speed change for a smaller mass (dv = J/m). The animation shades the growing area under the constant force line while a cart of fixed mass m gains the resulting velocity, with units N, s, N*s, and m/s tracked live.", + "bullets": [ + "Impulse J = F*dt is the AREA under the force-time graph (shaded sweep).", + "Impulse equals the change in momentum: J = dp = m*dv.", + "For fixed J, a smaller mass gains more velocity: dv = J/m.", + "Units: force N, time s, impulse N*s = kg*m/s, equal to dp." + ], + "params": [ + { + "name": "F", + "label": "force F (N)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 6 + }, + { + "name": "dt", + "label": "time dt (s)", + "min": 0.5, + "max": 4, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst F = P.F, dt = P.dt, m = 2;\nconst J = F * dt;\nconst dv = J / m;\nH.text(\"Impulse-Momentum Theorem: J = F*dt = dp\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Constant force F over time dt gives impulse J = change in momentum dp\", 24, 52, { color: H.colors.sub, size: 13 });\nconst v = H.plot2d({ xMin: 0, xMax: 5, yMin: 0, yMax: 12, box: { x: 70, y: 90, w: 360, h: 300 } });\nv.grid(); v.axes();\nv.line(0, F, dt, F, { color: H.colors.accent, width: 3 });\nv.line(dt, 0, dt, F, { color: H.colors.accent, width: 1, dash: [4, 4] });\nconst sweep = dt * (0.5 + 0.5 * Math.sin(t * 0.8));\nv.path([[0, 0], [0, F], [sweep, F], [sweep, 0]], { color: H.colors.accent2, fill: H.colors.accent2, close: true });\nv.text(\"F = \" + F.toFixed(1) + \" N\", dt * 0.4, F + 1, { color: H.colors.ink, size: 12 });\nv.text(\"t (s)\", 4.4, 0.6, { color: H.colors.sub, size: 12 });\nv.text(\"F (N)\", 0.2, 11.4, { color: H.colors.sub, size: 12 });\nconst px0 = 500, py = 230, track = 360;\nH.line(px0, py + 30, px0 + track, py + 30, { color: H.colors.grid, width: 2 });\nconst period = 4;\nconst ph = (t % period) / period;\nconst v_now = dv * ph;\nconst x_now = px0 + 0.5 * (track * 0.6) * ph * ph + 20;\nH.rect(x_now, py, 44, 30, { fill: H.colors.violet, stroke: H.colors.ink, width: 2, radius: 4 });\nH.arrow(x_now + 44, py + 15, x_now + 44 + Math.min(60, 8 + v_now * 6), py + 15, { color: H.colors.warn, width: 3 });\nH.text(\"cart m = \" + m.toFixed(0) + \" kg\", px0, py - 24, { color: H.colors.sub, size: 13 });\nH.text(\"J = F*dt = \" + J.toFixed(2) + \" N*s\", px0, py + 80, { color: H.colors.accent, size: 14, weight: 700 });\nH.text(\"dp = J => dv = J/m = \" + dv.toFixed(2) + \" m/s\", px0, py + 102, { color: H.colors.good, size: 13 });\nH.text(\"v(now) = \" + v_now.toFixed(2) + \" m/s\", px0, py + 124, { color: H.colors.violet, size: 13 });" + }, + { + "id": "apphys-momentum-conservation", + "area": "AP Physics", + "topic": "Conservation of momentum", + "title": "Conservation of momentum: m1*u1 + m2*u2 = m1*v1 + m2*v2", + "equation": "m1*u1 + m2*u2 = m1*v1 + m2*v2", + "keywords": [ + "conservation of momentum", + "total momentum", + "m1u1 + m2u2", + "isolated system", + "no external force", + "carts collision", + "center of mass velocity", + "ap physics", + "momentum before equals after", + "newtons third law" + ], + "explanation": "When no external force acts on a system, its total momentum p = sum of m*v is conserved, so m1*u1 + m2*u2 (before) equals m1*v1 + m2*v2 (after). This follows from Newton's third law: the equal-and-opposite internal forces deliver equal-and-opposite impulses that cancel. The velocity of the center of mass, v_cm = p_total/(m1+m2), is unchanged by the collision and is a useful invariant on the AP exam. The animation shows two carts approaching, colliding, and separating; the running readout confirms p_total stays fixed and shows v_cm.", + "bullets": [ + "With no external force, total p = m1*v1 + m2*v2 is conserved.", + "Internal forces are equal and opposite (Newton's 3rd law), so impulses cancel.", + "Signs matter: rightward is +, leftward is - (note u2 is negative here).", + "v_cm = p_total/(m1+m2) is unchanged before and after." + ], + "params": [ + { + "name": "r", + "label": "mass ratio m2/m1", + "min": 0.5, + "max": 4, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst r = P.r;\nconst m1 = 1.0, m2 = m1 * r;\nconst u1 = 3.0, u2 = -2.0;\nconst ptot = m1 * u1 + m2 * u2;\nconst vcm = ptot / (m1 + m2);\nH.text(\"Conservation of Momentum: m1*u1 + m2*u2 = m1*v1 + m2*v2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"No external force => total momentum p is the same before and after the collision\", 24, 52, { color: H.colors.sub, size: 13 });\nconst cx = 450, trackY = 250, halfW = 380;\nH.line(cx - halfW, trackY + 28, cx + halfW, trackY + 28, { color: H.colors.grid, width: 2 });\nconst period = 6;\nconst ph = (t % period) / period;\nconst collide = 0.5;\nlet x1, x2, sv1, sv2;\nif (ph < collide) {\n const f = ph / collide;\n x1 = cx - 240 + u1 * 26 * f;\n x2 = cx + 240 + u2 * 26 * f;\n sv1 = u1; sv2 = u2;\n} else {\n const f = (ph - collide) / (1 - collide);\n const v1 = (m1 - m2) / (m1 + m2) * u1 + (2 * m2) / (m1 + m2) * u2;\n const v2 = (2 * m1) / (m1 + m2) * u1 + (m2 - m1) / (m1 + m2) * u2;\n const cx1 = cx - 240 + u1 * 26 * collide;\n const cx2 = cx + 240 + u2 * 26 * collide;\n x1 = cx1 + v1 * 26 * f;\n x2 = cx2 + v2 * 26 * f;\n sv1 = v1; sv2 = v2;\n}\nconst w1 = 36 + 10 * m1, w2 = 36 + 10 * m2;\nH.rect(x1 - w1 / 2, trackY - 4, w1, 32, { fill: H.colors.accent, stroke: H.colors.ink, width: 2, radius: 4 });\nH.rect(x2 - w2 / 2, trackY - 4, w2, 32, { fill: H.colors.violet, stroke: H.colors.ink, width: 2, radius: 4 });\nH.arrow(x1, trackY + 12, x1 + sv1 * 14, trackY + 12, { color: H.colors.warn, width: 3 });\nH.arrow(x2, trackY + 12, x2 + sv2 * 14, trackY + 12, { color: H.colors.warn, width: 3 });\nH.text(\"m1 = \" + m1.toFixed(1) + \" kg\", x1 - w1 / 2, trackY - 14, { color: H.colors.accent, size: 12 });\nH.text(\"m2 = \" + m2.toFixed(1) + \" kg\", x2 - w2 / 2, trackY - 14, { color: H.colors.violet, size: 12 });\nconst phase = ph < collide ? \"BEFORE\" : \"AFTER\";\nH.text(\"Phase: \" + phase, 24, 360, { color: H.colors.good, size: 14, weight: 700 });\nH.text(\"u1 = +\" + u1.toFixed(1) + \" m/s, u2 = \" + u2.toFixed(1) + \" m/s\", 24, 384, { color: H.colors.sub, size: 13 });\nH.text(\"p_total = m1*u1 + m2*u2 = \" + ptot.toFixed(2) + \" kg*m/s (conserved)\", 24, 408, { color: H.colors.accent, size: 14, weight: 700 });\nH.text(\"v_cm = p/(m1+m2) = \" + vcm.toFixed(2) + \" m/s\", 24, 432, { color: H.colors.violet, size: 13 });" + }, + { + "id": "apphys-elastic-collision", + "area": "AP Physics", + "topic": "Elastic collision (1D)", + "title": "Elastic collision (1D): v1 = ((m1-m2)u1 + 2 m2 u2)/(m1+m2)", + "equation": "v1 = ((m1-m2)*u1 + 2*m2*u2)/(m1+m2); v2 = ((m2-m1)*u2 + 2*m1*u1)/(m1+m2)", + "keywords": [ + "elastic collision", + "kinetic energy conserved", + "1d collision", + "final velocities", + "equal mass swap", + "relative velocity reverses", + "momentum and energy", + "ap physics", + "head on collision", + "billiard balls" + ], + "explanation": "In a 1D elastic collision BOTH momentum and kinetic energy are conserved, which fixes the final velocities to v1 = ((m1-m2)u1 + 2 m2 u2)/(m1+m2) and v2 = ((m2-m1)u2 + 2 m1 u1)/(m1+m2). A hallmark the AP exam tests is that the relative velocity reverses: v2 - v1 = -(u2 - u1). For equal masses the two objects simply exchange velocities. The animation runs the carts through the collision and shows that both total p and total KE are numerically unchanged before and after.", + "bullets": [ + "Elastic = both momentum AND kinetic energy conserved.", + "Final speeds use the standard formulas shown in the readout.", + "Relative velocity reverses: v2 - v1 = -(u2 - u1).", + "Equal masses (m1 = m2) simply swap their velocities." + ], + "params": [ + { + "name": "m1", + "label": "mass m1 (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "m2", + "label": "mass m2 (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst m1 = P.m1, m2 = P.m2;\nconst u1 = 4.0, u2 = -1.0;\nconst s = m1 + m2;\nconst v1 = ((m1 - m2) * u1 + 2 * m2 * u2) / s;\nconst v2 = ((m2 - m1) * u2 + 2 * m1 * u1) / s;\nconst KEi = 0.5 * m1 * u1 * u1 + 0.5 * m2 * u2 * u2;\nconst KEf = 0.5 * m1 * v1 * v1 + 0.5 * m2 * v2 * v2;\nH.text(\"Elastic Collision (1D): KE and p both conserved\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"v1 = ((m1-m2)u1 + 2 m2 u2)/(m1+m2), v2 = ((m2-m1)u2 + 2 m1 u1)/(m1+m2)\", 24, 52, { color: H.colors.sub, size: 13 });\nconst cx = 450, trackY = 240, halfW = 380;\nH.line(cx - halfW, trackY + 30, cx + halfW, trackY + 30, { color: H.colors.grid, width: 2 });\nconst period = 6;\nconst ph = (t % period) / period;\nconst collide = 0.5;\nlet x1, x2, sv1, sv2;\nconst startSep = 230;\nif (ph < collide) {\n const f = ph / collide;\n x1 = cx - startSep + u1 * 22 * f;\n x2 = cx + startSep + u2 * 22 * f;\n sv1 = u1; sv2 = u2;\n} else {\n const f = (ph - collide) / (1 - collide);\n const cx1 = cx - startSep + u1 * 22 * collide;\n const cx2 = cx + startSep + u2 * 22 * collide;\n x1 = cx1 + v1 * 22 * f;\n x2 = cx2 + v2 * 22 * f;\n sv1 = v1; sv2 = v2;\n}\nconst w1 = 30 + 9 * m1, w2 = 30 + 9 * m2;\nH.rect(x1 - w1 / 2, trackY, w1, 32, { fill: H.colors.accent, stroke: H.colors.ink, width: 2, radius: 4 });\nH.rect(x2 - w2 / 2, trackY, w2, 32, { fill: H.colors.violet, stroke: H.colors.ink, width: 2, radius: 4 });\nH.arrow(x1, trackY + 16, x1 + sv1 * 16, trackY + 16, { color: H.colors.warn, width: 3 });\nH.arrow(x2, trackY + 16, x2 + sv2 * 16, trackY + 16, { color: H.colors.warn, width: 3 });\nH.text(\"m1 = \" + m1.toFixed(1) + \" kg\", x1 - w1 / 2, trackY - 10, { color: H.colors.accent, size: 12 });\nH.text(\"m2 = \" + m2.toFixed(1) + \" kg\", x2 - w2 / 2, trackY - 10, { color: H.colors.violet, size: 12 });\nconst phase = ph < collide ? \"BEFORE u1=+\" + u1.toFixed(1) + \", u2=\" + u2.toFixed(1) : \"AFTER v1=\" + v1.toFixed(2) + \", v2=\" + v2.toFixed(2);\nH.text(phase + \" m/s\", 24, 350, { color: H.colors.good, size: 14, weight: 700 });\nH.text(\"v1 = \" + v1.toFixed(2) + \" m/s, v2 = \" + v2.toFixed(2) + \" m/s\", 24, 376, { color: H.colors.accent, size: 13 });\nH.text(\"p: \" + (m1*u1+m2*u2).toFixed(2) + \" = \" + (m1*v1+m2*v2).toFixed(2) + \" kg*m/s (conserved)\", 24, 400, { color: H.colors.sub, size: 13 });\nH.text(\"KE: \" + KEi.toFixed(2) + \" J -> \" + KEf.toFixed(2) + \" J (conserved)\", 24, 424, { color: H.colors.good, size: 13, weight: 700 });" + }, + { + "id": "apphys-inelastic-collision", + "area": "AP Physics", + "topic": "Perfectly inelastic collision", + "title": "Perfectly inelastic collision: v_f = (m1*u1 + m2*u2)/(m1+m2)", + "equation": "v_f = (m1*u1 + m2*u2)/(m1+m2); KE_lost = KE_i - KE_f", + "keywords": [ + "perfectly inelastic collision", + "stick together", + "common velocity", + "kinetic energy lost", + "momentum conserved", + "ballistic pendulum", + "energy to heat", + "ap physics", + "combined mass", + "sticky collision" + ], + "explanation": "In a perfectly inelastic collision the objects stick and move off with one common velocity v_f = (m1*u1 + m2*u2)/(m1+m2), found from momentum conservation alone. Momentum is still conserved, but kinetic energy is NOT: some KE is converted to heat and deformation, the maximum possible loss for a given p. The AP exam pairs this with the ballistic pendulum, where you use momentum for the sticking step and then energy for the swing. The animation shows mass m1 striking a stationary m2, the two merging, and a live tally of the KE lost.", + "bullets": [ + "Objects stick: one common final velocity v_f from momentum alone.", + "Momentum is conserved; kinetic energy is NOT (lost to heat/deformation).", + "KE loss is the maximum allowed for the given total momentum.", + "Readout shows v_f, conserved p, and the percent of KE lost." + ], + "params": [ + { + "name": "m1", + "label": "mass m1 (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "m2", + "label": "mass m2 (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "u1", + "label": "speed u1 (m/s)", + "min": 1, + "max": 6, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst m1 = P.m1, m2 = P.m2, u1 = P.u1;\nconst u2 = 0.0;\nconst s = m1 + m2;\nconst vf = (m1 * u1 + m2 * u2) / s;\nconst KEi = 0.5 * m1 * u1 * u1 + 0.5 * m2 * u2 * u2;\nconst KEf = 0.5 * s * vf * vf;\nconst lost = KEi - KEf;\nconst lostPct = KEi > 0.0001 ? 100 * lost / KEi : 0;\nH.text(\"Perfectly Inelastic Collision: they stick, move together\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"v_f = (m1*u1 + m2*u2)/(m1+m2); p conserved but KE is LOST to heat/deformation\", 24, 52, { color: H.colors.sub, size: 13 });\nconst cx = 430, trackY = 235, halfW = 380;\nH.line(cx - halfW, trackY + 32, cx + halfW, trackY + 32, { color: H.colors.grid, width: 2 });\nconst period = 6;\nconst ph = (t % period) / period;\nconst collide = 0.5;\nconst w1 = 30 + 9 * m1, w2 = 30 + 9 * m2;\nlet x1, x2, sv;\nconst startSep = 200;\nif (ph < collide) {\n const f = ph / collide;\n x1 = cx - startSep + u1 * 24 * f;\n x2 = cx + 60;\n sv = u1;\n} else {\n const f = (ph - collide) / (1 - collide);\n const cx1 = cx - startSep + u1 * 24 * collide;\n x1 = cx1 + vf * 24 * f;\n x2 = x1 + w1 / 2 + w2 / 2;\n sv = vf;\n}\nH.rect(x1 - w1 / 2, trackY, w1, 34, { fill: H.colors.accent, stroke: H.colors.ink, width: 2, radius: 4 });\nH.rect(x2 - w2 / 2, trackY, w2, 34, { fill: H.colors.violet, stroke: H.colors.ink, width: 2, radius: 4 });\nH.arrow((x1 + x2) / 2, trackY + 17, (x1 + x2) / 2 + sv * 16, trackY + 17, { color: H.colors.warn, width: 3 });\nH.text(\"m1 = \" + m1.toFixed(1), x1 - w1 / 2, trackY - 10, { color: H.colors.accent, size: 12 });\nH.text(\"m2 = \" + m2.toFixed(1), x2 - w2 / 2, trackY - 10, { color: H.colors.violet, size: 12 });\nconst phase = ph < collide ? \"BEFORE (m1 moving at u1)\" : \"AFTER (stuck together at v_f)\";\nH.text(phase, 24, 348, { color: H.colors.good, size: 14, weight: 700 });\nH.text(\"u1 = \" + u1.toFixed(1) + \" m/s, u2 = 0 => v_f = \" + vf.toFixed(2) + \" m/s\", 24, 374, { color: H.colors.accent, size: 13 });\nH.text(\"p: \" + (m1*u1).toFixed(2) + \" = \" + (s*vf).toFixed(2) + \" kg*m/s (conserved)\", 24, 398, { color: H.colors.sub, size: 13 });\nH.text(\"KE: \" + KEi.toFixed(2) + \" -> \" + KEf.toFixed(2) + \" J lost \" + lost.toFixed(2) + \" J (\" + lostPct.toFixed(0) + \"%)\", 24, 422, { color: H.colors.warn, size: 13, weight: 700 });" + }, + { + "id": "apphys-center-of-mass", + "area": "AP Physics", + "topic": "Center of mass", + "title": "Center of mass: x_cm = (m1*x1 + m2*x2)/(m1+m2)", + "equation": "x_cm = (m1*x1 + m2*x2)/(m1+m2)", + "keywords": [ + "center of mass", + "weighted average position", + "x_cm", + "balance point", + "two particle system", + "m1 x1 plus m2 x2", + "centroid", + "ap physics", + "moment", + "cm formula" + ], + "explanation": "The center of mass of a system is the mass-weighted average of the particle positions, x_cm = (m1*x1 + m2*x2)/(m1+m2), and it sits closer to the heavier mass. It is the balance point: the moments about it cancel, m1*(x1 - x_cm) = -m2*(x2 - x_cm). On the AP exam the center of mass moves as if all the mass and the net external force acted there, so for an isolated system it travels at constant velocity. The animation lets you drag the masses and positions and watches the green pivot track the weighted average.", + "bullets": [ + "x_cm is the mass-weighted average position, not the midpoint.", + "It lies closer to the heavier mass (try increasing m1 or m2).", + "It is the balance point: m1*(x1 - x_cm) = -m2*(x2 - x_cm).", + "For an isolated system the center of mass moves at constant velocity." + ], + "params": [ + { + "name": "m1", + "label": "mass m1 (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "m2", + "label": "mass m2 (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 1 + }, + { + "name": "x1", + "label": "position x1 (m)", + "min": -9, + "max": 0, + "step": 0.5, + "value": -6 + }, + { + "name": "x2", + "label": "position x2 (m)", + "min": 0, + "max": 9, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\nconst m1 = P.m1, m2 = P.m2, x1 = P.x1, x2 = P.x2;\nconst s = m1 + m2;\nconst xcm = (m1 * x1 + m2 * x2) / s;\nH.text(\"Center of Mass: x_cm = (m1*x1 + m2*x2)/(m1+m2)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"The balance point: a weighted average of positions, pulled toward the heavier mass\", 24, 52, { color: H.colors.sub, size: 13 });\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -3, yMax: 3, box: { x: 60, y: 110, w: 780, h: 230 } });\nv.grid(); v.axes();\nv.line(-10, 0, 10, 0, { color: H.colors.grid, width: 2 });\nconst r1 = 10 + 4 * m1, r2 = 10 + 4 * m2;\nconst bob = 0.18 * Math.sin(t * 1.4);\nv.circle(x1, bob, r1, { fill: H.colors.accent, stroke: H.colors.ink, width: 2 });\nv.circle(x2, bob, r2, { fill: H.colors.violet, stroke: H.colors.ink, width: 2 });\nv.text(\"m1=\" + m1.toFixed(1) + \"kg\", x1, -1.0, { color: H.colors.accent, size: 12 });\nv.text(\"m2=\" + m2.toFixed(1) + \"kg\", x2, -1.0, { color: H.colors.violet, size: 12 });\nv.text(\"x1=\" + x1.toFixed(1) + \"m\", x1, 1.2, { color: H.colors.sub, size: 11 });\nv.text(\"x2=\" + x2.toFixed(1) + \"m\", x2, 1.2, { color: H.colors.sub, size: 11 });\nconst tri = 0.35 + 0.06 * Math.sin(t * 1.4);\nv.path([[xcm - 0.5, -tri - 0.55], [xcm + 0.5, -tri - 0.55], [xcm, -0.15]], { color: H.colors.good, fill: H.colors.good, close: true });\nv.line(xcm, -3, xcm, 3, { color: H.colors.good, width: 2, dash: [5, 5] });\nv.dot(xcm, 0, { r: 6, fill: H.colors.good });\nH.text(\"x_cm = (m1*x1 + m2*x2)/(m1+m2) = \" + xcm.toFixed(2) + \" m\", 24, 380, { color: H.colors.good, size: 15, weight: 700 });\nH.text(\"m1*(x1 - x_cm) = \" + (m1 * (x1 - xcm)).toFixed(2) + \" = -m2*(x2 - x_cm) = \" + (-m2 * (x2 - xcm)).toFixed(2) + \" (torques balance)\", 24, 406, { color: H.colors.sub, size: 13 });\nH.text(\"Total mass M = \" + s.toFixed(1) + \" kg\", 24, 430, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apphys-angular-kinematics", + "area": "AP Physics", + "topic": "Angular kinematics", + "title": "Angular kinematics: theta = w0*t + 1/2*alpha*t^2", + "equation": "theta = w0*t + (1/2)*alpha*t^2 ; omega = w0 + alpha*t", + "keywords": [ + "angular kinematics", + "rotational kinematics", + "angular acceleration", + "angular velocity", + "theta = w0 t + 1/2 alpha t^2", + "omega = w0 + alpha t", + "rotating disk", + "constant angular acceleration", + "rad/s", + "ap physics" + ], + "explanation": "For constant angular acceleration alpha, the rotational equations mirror the linear ones: omega = omega0 + alpha*t and theta = omega0*t + (1/2)*alpha*t^2, with theta in radians, omega in rad/s, and alpha in rad/s^2. The angle-vs-time curve is a parabola while the angular-velocity-vs-time curve is a straight line of slope alpha. On the AP exam you read alpha off the slope of an omega(t) graph or the curvature of a theta(t) graph, and you must keep angles in radians to connect to arc length s = r*theta and tangential speed v = r*omega. The sweeping spoke on the disk turns through exactly theta(t) while the two graph dots ride the parabola (theta) and line (omega).", + "bullets": [ + "omega(t) is a straight line; its slope IS alpha (rad/s^2).", + "theta(t) is a parabola — curvature set by alpha, intercept slope by w0.", + "The disk's spoke angle equals theta(t) at every instant.", + "Keep angles in radians to use s = r*theta and v = r*omega." + ], + "params": [ + { + "name": "alpha", + "label": "angular accel alpha (rad/s^2)", + "min": -1, + "max": 3, + "step": 0.25, + "value": 1 + }, + { + "name": "w0", + "label": "initial omega w0 (rad/s)", + "min": -2, + "max": 4, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 6, yMin: -4, yMax: 20 });\nv.grid(); v.axes();\nconst alpha = P.alpha, w0 = P.w0;\nconst tt = (t % 6);\nconst theta = w0 * tt + 0.5 * alpha * tt * tt;\nconst omega = w0 + alpha * tt;\nv.fn(x => w0 * x + 0.5 * alpha * x * x, { color: H.colors.accent, width: 3 });\nv.fn(x => w0 + alpha * x, { color: H.colors.accent2, width: 2 });\nv.dot(tt, theta, { r: 6, fill: H.colors.accent });\nv.dot(tt, omega, { r: 5, fill: H.colors.accent2 });\nconst cx = 720, cy = 150, R = 70;\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 2 });\nH.circle(cx, cy, 4, { fill: H.colors.sub });\nconst px = cx + R * Math.cos(theta), py = cy + R * Math.sin(theta);\nH.line(cx, cy, px, py, { color: H.colors.good, width: 3 });\nH.circle(px, py, 7, { fill: H.colors.good });\nH.legend([{label:\"theta (rad)\",color:H.colors.accent},{label:\"omega (rad/s)\",color:H.colors.accent2}], 600, 250);\nH.text(\"Angular kinematics: theta = w0*t + 1/2*alpha*t^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Constant alpha; omega = w0 + alpha*t (a sweeping spoke)\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"t = \" + tt.toFixed(2) + \" s theta = \" + theta.toFixed(2) + \" rad omega = \" + omega.toFixed(2) + \" rad/s\", 24, 72, { color: H.colors.yellow, size: 13 });" + }, + { + "id": "apphys-torque", + "area": "AP Physics", + "topic": "Torque", + "title": "Torque: tau = r F sin(theta)", + "equation": "tau = r * F * sin(theta)", + "keywords": [ + "torque", + "tau = r F sin theta", + "lever arm", + "moment arm", + "perpendicular force component", + "rotational force", + "pivot", + "newton meter", + "rotation", + "ap physics" + ], + "explanation": "Torque is the rotational analog of force: tau = r*F*sin(theta), where r is the distance from the pivot to where the force acts, F is the force magnitude, and theta is the angle between the position vector r and the force F. Only the component of force perpendicular to the lever, F*sin(theta), produces a turn — a force aimed straight along the lever (theta = 0) gives zero torque, while a perpendicular push (theta = 90 deg) is maximally effective. Equivalently, tau = F times the moment arm r*sin(theta). On the AP exam, net torque sets angular acceleration through tau_net = I*alpha, and equilibrium requires the torques to balance; the green dashed segment shows the active F*sin(theta) component as you change the angle.", + "bullets": [ + "tau is maximum at theta = 90 deg and zero at theta = 0 (force along the lever).", + "Green dashed segment = the perpendicular component F sin(theta) that twists.", + "Longer lever r or larger F both raise tau proportionally.", + "Units: N*m; tau sets rotation via tau_net = I*alpha." + ], + "params": [ + { + "name": "F", + "label": "force F (N)", + "min": 1, + "max": 12, + "step": 0.5, + "value": 8 + }, + { + "name": "r", + "label": "lever arm r (m)", + "min": 0.5, + "max": 4, + "step": 0.25, + "value": 3 + }, + { + "name": "theta", + "label": "angle theta (deg)", + "min": 0, + "max": 90, + "step": 5, + "value": 60 + } + ], + "code": "H.background();\nconst F = P.F, r = P.r, deg = P.theta;\nconst ang = deg * Math.PI / 180;\nconst tau = r * F * Math.sin(ang);\nconst px = 280, py = 320;\nconst pxScale = 26;\nconst ex = px + r * pxScale, ey = py;\nH.line(px, py, ex, ey, { color: H.colors.sub, width: 6 });\nH.circle(px, py, 9, { fill: H.colors.ink });\nH.circle(ex, ey, 6, { fill: H.colors.grid });\nconst fScale = 8;\nconst fang = ang;\nconst fx = ex + F * fScale * Math.cos(fang);\nconst fy = ey - F * fScale * Math.sin(fang);\nH.arrow(ex, ey, fx, fy, { color: H.colors.accent2, width: 3 });\nconst perpY = ey - F * fScale * Math.sin(fang);\nH.line(ex, ey, ex, perpY, { color: H.colors.good, width: 2, dash: [5,4] });\nH.text(\"F sin(theta)\", ex + 8, (ey + perpY) / 2, { color: H.colors.good, size: 12 });\nconst spin = (t % 3) / 3 * Math.PI * (tau >= 0 ? 1 : -1);\nconst arcR = 40;\nconst a0 = Math.PI * 0.15;\nconst arx = px + arcR * Math.cos(a0 + spin);\nconst ary = py - arcR * Math.sin(a0 + spin);\nH.circle(arx, ary, 6, { fill: H.colors.violet });\nH.text(\"r = \" + r.toFixed(2) + \" m\", (px + ex) / 2 - 20, py + 22, { color: H.colors.sub, size: 12 });\nH.text(\"Torque: tau = r F sin(theta)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Only the perpendicular force component F sin(theta) twists the lever\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"F = \" + F.toFixed(1) + \" N r = \" + r.toFixed(2) + \" m theta = \" + deg.toFixed(0) + \" deg tau = \" + tau.toFixed(2) + \" N*m\", 24, 72, { color: H.colors.yellow, size: 13 });" + }, + { + "id": "apphys-rotational-inertia", + "area": "AP Physics", + "topic": "Rotational inertia", + "title": "Rotational inertia: I = sum(m r^2), alpha = tau / I", + "equation": "I = sum(m*r^2) ; alpha = tau / I", + "keywords": [ + "rotational inertia", + "moment of inertia", + "I = sum m r^2", + "mass distribution", + "alpha = tau / I", + "newton second law rotation", + "point masses", + "angular acceleration", + "kg m^2", + "ap physics" + ], + "explanation": "Rotational inertia I = sum(m*r^2) measures how hard it is to angularly accelerate an object; it depends not just on mass but on how far that mass sits from the axis (the r^2 makes distance dominate). For two point masses on a light rod, I = 2*m*d^2, so doubling the radius quadruples I. Newton's second law for rotation, tau_net = I*alpha, then says that for a FIXED torque, moving mass outward raises I and shrinks the resulting angular acceleration alpha. On the AP exam this is why a hoop accelerates more slowly than a disk of equal mass, and why pulling mass toward the axis speeds up a spin; here the same orange torque arrow yields a visibly slower disk when you slide the masses out.", + "bullets": [ + "I = sum(m r^2): distance from the axis matters as the square.", + "Same torque arrow, but larger I means smaller alpha = tau/I.", + "Two masses on a rod: I = 2 m d^2 (quadruples if d doubles).", + "This is tau_net = I*alpha — the rotational Newton's 2nd law." + ], + "params": [ + { + "name": "d", + "label": "mass radius d (m)", + "min": 0.2, + "max": 1, + "step": 0.1, + "value": 0.6 + }, + { + "name": "m", + "label": "each mass m (kg)", + "min": 1, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "tau", + "label": "applied torque tau (N*m)", + "min": 1, + "max": 8, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst d = P.d, m = P.m, tau = P.tau;\nconst I = 2 * m * d * d;\nconst alpha = I > 1e-6 ? tau / I : 0;\nconst cx = 260, cy = 300, R = 120;\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 2 });\nH.circle(cx, cy, 5, { fill: H.colors.sub });\nconst tt = (t % 5);\nconst theta = 0.5 * alpha * tt * tt;\nconst omega = alpha * tt;\nconst rPix = 30 + d * 90;\nconst m1x = cx + rPix * Math.cos(theta), m1y = cy + rPix * Math.sin(theta);\nconst m2x = cx - rPix * Math.cos(theta), m2y = cy - rPix * Math.sin(theta);\nH.line(m1x, m1y, m2x, m2y, { color: H.colors.sub, width: 3 });\nconst mr = 6 + m * 4;\nH.circle(m1x, m1y, mr, { fill: H.colors.accent });\nH.circle(m2x, m2y, mr, { fill: H.colors.accent });\nconst txp = cx + R * Math.cos(theta + Math.PI / 2);\nconst typ = cy + R * Math.sin(theta + Math.PI / 2);\nconst ttx = txp + 34 * Math.cos(theta);\nconst tty = typ + 34 * Math.sin(theta);\nH.arrow(txp, typ, ttx, tty, { color: H.colors.accent2, width: 3 });\nH.text(\"same tau\", txp + 6, typ - 8, { color: H.colors.accent2, size: 12 });\nH.text(\"Rotational inertia: I = sum(m r^2), alpha = tau / I\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Slide the masses out: bigger I, so the SAME torque gives a smaller alpha\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"d = \" + d.toFixed(2) + \" m m = \" + m.toFixed(1) + \" kg I = \" + I.toFixed(2) + \" kg*m^2\", 24, 72, { color: H.colors.yellow, size: 13 });\nH.text(\"alpha = tau/I = \" + alpha.toFixed(2) + \" rad/s^2 omega = \" + omega.toFixed(2) + \" rad/s\", 24, 92, { color: H.colors.good, size: 13 });" + }, + { + "id": "apphys-angular-momentum", + "area": "AP Physics", + "topic": "Conservation of angular momentum", + "title": "Angular momentum: L = I omega = constant", + "equation": "L = I*omega = constant (no external torque)", + "keywords": [ + "angular momentum", + "conservation of angular momentum", + "L = I omega", + "spinning skater", + "pull arms in", + "no external torque", + "moment of inertia", + "spin up", + "kg m^2 / s", + "ap physics" + ], + "explanation": "Angular momentum L = I*omega is conserved whenever the net external torque is zero, because tau_ext = dL/dt. A spinning skater has no external torque about the vertical axis, so when they pull their arms in they shrink their moment of inertia I = Icore + 2*m*r^2, and omega must rise to keep L = I*omega fixed. The skater's spin literally speeds up even though no one pushes them — internal forces redistribute mass but cannot change L. On the AP exam this appears as collapsing-orbit, merging-disk, or person-on-a-turntable problems where you set I1*omega1 = I2*omega2; note that rotational kinetic energy (1/2)*I*omega^2 actually INCREASES because the skater does work pulling the masses inward.", + "bullets": [ + "No external torque means L = I omega stays constant.", + "Pull arms in: r down, I = Icore + 2 m r^2 down, so omega up.", + "Set I1 omega1 = I2 omega2 on the AP exam (L conserved).", + "KE_rot = 1/2 I omega^2 rises — the skater does work pulling in." + ], + "params": [ + { + "name": "r", + "label": "arm radius r (m)", + "min": 0.1, + "max": 1.2, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst r = P.r;\nconst mArm = 4;\nconst Icore = 3;\nconst L = 12;\nconst I = Icore + 2 * mArm * r * r;\nconst omega = L / I;\nconst cx = 300, cy = 300;\nconst theta = (omega * t) % (Math.PI * 2);\nH.circle(cx, cy, 26, { fill: H.colors.panel, stroke: H.colors.sub, width: 2 });\nH.circle(cx, cy - 36, 16, { fill: H.colors.panel, stroke: H.colors.sub, width: 2 });\nconst rPix = 26 + r * 70;\nconst ax1 = cx + rPix * Math.cos(theta), ay1 = cy + rPix * Math.sin(theta);\nconst ax2 = cx - rPix * Math.cos(theta), ay2 = cy - rPix * Math.sin(theta);\nH.line(cx, cy, ax1, ay1, { color: H.colors.sub, width: 4 });\nH.line(cx, cy, ax2, ay2, { color: H.colors.sub, width: 4 });\nH.circle(ax1, ay1, 10, { fill: H.colors.accent });\nH.circle(ax2, ay2, 10, { fill: H.colors.accent });\nconst gx = 640, gy = 300, gR = 80;\nH.circle(gx, gy, gR, { stroke: H.colors.grid, width: 2 });\nconst gpx = gx + gR * Math.cos(theta), gpy = gy + gR * Math.sin(theta);\nH.line(gx, gy, gpx, gpy, { color: H.colors.good, width: 3 });\nH.text(\"spin rate\", gx - 28, gy + gR + 20, { color: H.colors.sub, size: 12 });\nH.text(\"Angular momentum: L = I omega = constant\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"No external torque: pull arms in (small r) -> I drops -> omega rises\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + r.toFixed(2) + \" m I = \" + I.toFixed(2) + \" kg*m^2 L = \" + L.toFixed(0) + \" kg*m^2/s\", 24, 72, { color: H.colors.yellow, size: 13 });\nH.text(\"omega = L/I = \" + omega.toFixed(2) + \" rad/s\", 24, 92, { color: H.colors.good, size: 13 });" + }, + { + "id": "apphys-rolling", + "area": "AP Physics", + "topic": "Rolling without slipping", + "title": "Rolling without slipping: v = omega r", + "equation": "v = omega * r (contact point at rest)", + "keywords": [ + "rolling without slipping", + "v = omega r", + "rolling motion", + "contact point", + "instantaneous axis", + "wheel", + "tangential speed", + "no slip condition", + "top of wheel 2v", + "ap physics" + ], + "explanation": "When a wheel rolls without slipping, the no-slip condition v = omega*r links the center's translational speed v to the spin rate omega and radius r, and likewise a = alpha*r for accelerations. The key insight is that the contact point with the ground is instantaneously at rest — its translational velocity v exactly cancels the rotational velocity omega*r, so the wheel rotates momentarily about that point. As a result the top of the wheel moves at 2v (translation plus rotation add), while the center moves at v. On the AP exam this condition couples rotational and linear equations for a rolling object and is what makes static friction (which does no work) the force that lets a rolling object accelerate; the green contact dot stays put while the orange spoke shows omega = v/r.", + "bullets": [ + "No-slip condition: v = omega r (and a = alpha r).", + "Green contact point is instantaneously at REST — zero velocity.", + "Center moves at v; the top of the wheel moves at 2v.", + "Static friction (no work) enforces rolling; couples spin to motion." + ], + "params": [ + { + "name": "v", + "label": "center speed v (m/s)", + "min": 0.5, + "max": 6, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = P.v;\nconst r = 1.0;\nconst omega = v / r;\nconst Rpix = 70;\nconst groundY = 380;\nH.line(60, groundY, 840, groundY, { color: H.colors.sub, width: 3 });\nconst trackW = 700, x0 = 110;\nconst pxPerM = 60;\nconst dist = (v * t) * pxPerM;\nconst cx = x0 + (dist % trackW);\nconst cy = groundY - Rpix;\nconst ang = (v * t) / r;\nH.circle(cx, cy, Rpix, { stroke: H.colors.accent, width: 3 });\nH.circle(cx, cy, 4, { fill: H.colors.ink });\nconst mx = cx + Rpix * Math.cos(ang), my = cy + Rpix * Math.sin(ang);\nH.line(cx, cy, mx, my, { color: H.colors.accent2, width: 3 });\nH.circle(mx, my, 6, { fill: H.colors.accent2 });\nconst conx = cx, cony = cy + Rpix;\nH.circle(conx, cony, 7, { fill: H.colors.good });\nH.text(\"contact pt: v=0\", conx + 10, cony - 4, { color: H.colors.good, size: 12 });\nH.arrow(cx, cy, cx + Math.min(v, 8) * 12, cy, { color: H.colors.violet, width: 3 });\nH.text(\"v\", cx + Math.min(v, 8) * 12 + 6, cy - 4, { color: H.colors.violet, size: 13 });\nH.arrow(cx, cy - Rpix, cx + Math.min(v, 8) * 24, cy - Rpix, { color: H.colors.yellow, width: 2 });\nH.text(\"top: 2v\", cx + 8, cy - Rpix - 10, { color: H.colors.yellow, size: 12 });\nH.text(\"Rolling without slipping: v = omega r\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Contact point is momentarily at rest; top of wheel moves at 2v\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"v = \" + v.toFixed(1) + \" m/s r = \" + r.toFixed(1) + \" m omega = v/r = \" + omega.toFixed(2) + \" rad/s\", 24, 72, { color: H.colors.yellow, size: 13 });" + }, + { + "id": "apphys-shm-spring", + "area": "AP Physics", + "topic": "SHM: mass on a spring", + "title": "Mass on a spring: x = A cos(ωt), ω = √(k/m)", + "equation": "x = A*cos(omega*t), omega = sqrt(k/m), T = 2*pi*sqrt(m/k)", + "keywords": [ + "simple harmonic motion", + "shm", + "mass on a spring", + "hooke's law", + "spring constant", + "angular frequency", + "period of oscillation", + "x = a cos omega t", + "omega sqrt k over m", + "ap physics" + ], + "explanation": "A mass on an ideal spring obeys Hooke's law F = -kx, giving SHM with angular frequency ω = √(k/m), so a stiffer spring (larger k) or a smaller mass speeds up the oscillation while amplitude does NOT change the period. The displacement is x = A·cos(ωt) and the period is T = 2π√(m/k), independent of A. On the AP exam you are expected to read ω, T, and f off this relationship and recognize that mass and stiffness — not amplitude — set the period. The animation shows the block oscillating about x = 0 while the position-vs-time graph traces x = A·cos(ωt) with the live ω and T.", + "bullets": [ + "The block oscillates about x = 0; restoring force F = -kx points back to equilibrium.", + "ω = √(k/m): stiffer k or lighter m raises ω and shortens the period T.", + "Period T = 2π√(m/k) is independent of amplitude A.", + "The x–t graph is a pure cosine; the dot's phase matches the block's position." + ], + "params": [ + { + "name": "k", + "label": "spring constant k (N/m)", + "min": 5, + "max": 80, + "step": 1, + "value": 20 + }, + { + "name": "m", + "label": "mass m (kg)", + "min": 0.2, + "max": 5, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst k = P.k, m = P.m;\nconst w = Math.sqrt(k / Math.max(m, 0.01));\nconst A = 1.2;\nconst T = (2 * Math.PI) / Math.max(w, 0.01);\nconst x = A * Math.cos(w * t);\nH.text(\"Mass on a spring: x = A cos(ωt), ω = √(k/m)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Stiffer k or lighter m -> larger ω -> faster oscillation\", 24, 52, { color: H.colors.sub, size: 13 });\nconst wallX = 120, eqX = 520, scale = 150;\nconst blockX = eqX + x * scale;\nH.line(wallX, 120, wallX, 360, { color: H.colors.axis, width: 4 });\nconst coils = 12;\nconst pts = [];\nfor (let i = 0; i <= coils; i++) {\n const f = i / coils;\n const sx = wallX + f * (blockX - wallX);\n const sy = 240 + (i === 0 || i === coils ? 0 : (i % 2 === 0 ? -18 : 18));\n pts.push([sx, sy]);\n}\nH.path(pts, { color: H.colors.accent, width: 2 });\nH.rect(blockX - 28, 212, 56, 56, { fill: H.colors.violet, stroke: H.colors.ink, width: 2, radius: 6 });\nH.line(eqX, 110, eqX, 380, { color: H.colors.grid, width: 1, dash: [4, 4] });\nH.text(\"x = 0\", eqX - 18, 100, { color: H.colors.sub, size: 12 });\nconst v = H.plot2d({ xMin: 0, xMax: 2 * T, yMin: -A * 1.3, yMax: A * 1.3 });\nv.axes();\nv.fn(tt => A * Math.cos(w * tt), { color: H.colors.accent2, width: 2 });\nv.dot((t % (2 * T)), x, { r: 5, fill: H.colors.warn });\nH.text(\"ω = \" + w.toFixed(2) + \" rad/s\", 24, 76, { color: H.colors.sub, size: 13 });\nH.text(\"T = \" + T.toFixed(2) + \" s x = \" + x.toFixed(2) + \" m\", 24, 96, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apphys-pendulum", + "area": "AP Physics", + "topic": "Simple pendulum", + "title": "Simple pendulum: T = 2π √(L/g)", + "equation": "T = 2*pi*sqrt(L/g), omega = sqrt(g/L)", + "keywords": [ + "simple pendulum", + "period of a pendulum", + "t = 2 pi sqrt l over g", + "small angle approximation", + "restoring force gravity", + "oscillation", + "length dependence", + "pendulum frequency", + "ap physics", + "shm" + ], + "explanation": "For small angles a pendulum is SHM with restoring torque from gravity, giving period T = 2π√(L/g) that depends ONLY on length L and g — not on the bob's mass or (for small swings) the amplitude. Quadrupling L doubles T, and on the Moon (smaller g) the period lengthens. The AP exam frequently tests that mass cancels out and that the small-angle approximation sin θ ≈ θ is what makes the motion sinusoidal. The animation swings the bob about the vertical equilibrium with θ(t) = θ₀cos(ωt) and reports the live period.", + "bullets": [ + "Restoring force is the tangential component of gravity, mg·sin θ ≈ mg·θ for small θ.", + "T = 2π√(L/g): longer string -> longer period; mass does not appear.", + "Quadrupling the length L doubles the period T.", + "The bob traces a sinusoid about vertical; the dashed line marks equilibrium." + ], + "params": [ + { + "name": "L", + "label": "length L (m)", + "min": 0.2, + "max": 4, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst g = 9.8;\nconst L = Math.max(P.L, 0.1);\nconst T = 2 * Math.PI * Math.sqrt(L / g);\nconst w = (2 * Math.PI) / T;\nconst A = 0.35;\nconst theta = A * Math.cos(w * t);\nH.text(\"Simple pendulum: T = 2π √(L/g)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Period depends on length, not mass or amplitude (small angle)\", 24, 52, { color: H.colors.sub, size: 13 });\nconst px = 450, py = 110;\nconst Lmax = 4.0;\nconst lenPx = 80 + (L / Lmax) * 320;\nconst bx = px + lenPx * Math.sin(theta);\nconst by = py + lenPx * Math.cos(theta);\nH.line(px, py, px, py + lenPx + 20, { color: H.colors.grid, width: 1, dash: [4, 4] });\nH.circle(px, py, 6, { fill: H.colors.axis });\nH.line(px, py, bx, by, { color: H.colors.sub, width: 2 });\nH.circle(bx, by, 22, { fill: H.colors.accent, stroke: H.colors.ink, width: 2 });\nconst arcPts = [];\nfor (let i = 0; i <= 20; i++) {\n const a = -A + (2 * A) * (i / 20);\n arcPts.push([px + lenPx * Math.sin(a), py + lenPx * Math.cos(a)]);\n}\nH.path(arcPts, { color: H.colors.violet, width: 1, dash: [3, 3] });\nH.text(\"θ(t)\", bx + 24, by, { color: H.colors.sub, size: 12 });\nH.text(\"L = \" + L.toFixed(2) + \" m\", 24, 76, { color: H.colors.sub, size: 13 });\nH.text(\"T = \" + T.toFixed(2) + \" s θ = \" + (theta * 180 / Math.PI).toFixed(1) + \"°\", 24, 96, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apphys-shm-energy", + "area": "AP Physics", + "topic": "SHM energy exchange", + "title": "SHM energy: E = ½kA² = KE + PE", + "equation": "E = (1/2)*k*A^2 = (1/2)*m*v^2 + (1/2)*k*x^2", + "keywords": [ + "shm energy", + "kinetic energy", + "potential energy", + "energy conservation", + "half k a squared", + "half m v squared", + "half k x squared", + "energy exchange oscillator", + "ap physics", + "simple harmonic motion" + ], + "explanation": "In SHM the total mechanical energy E = ½kA² is constant and continuously trades between elastic PE = ½kx² and KE = ½mv². At the turning points (x = ±A) the mass is momentarily at rest so all energy is PE; at x = 0 the speed is maximum so all energy is KE. The AP exam asks students to read these two parabolas and confirm KE + PE = E at every instant. The animation shows the violet PE parabola and the orange KE curve summing to the flat total-energy line as the dots slide along with the oscillation.", + "bullets": [ + "Total energy E = ½kA² is set by amplitude and stays constant all cycle.", + "PE = ½kx² is a parabola, maximum at the turning points x = ±A.", + "KE = ½mv² is maximum at x = 0 where the speed is largest.", + "At every x the two dots add to the dashed total-energy line." + ], + "params": [ + { + "name": "A", + "label": "amplitude A (m)", + "min": 0.2, + "max": 2, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst A = Math.max(P.A, 0.1);\nconst k = 20;\nconst m = 1;\nconst w = Math.sqrt(k / m);\nconst T = (2 * Math.PI) / w;\nconst x = A * Math.cos(w * t);\nconst v = -A * w * Math.sin(w * t);\nconst E = 0.5 * k * A * A;\nconst PE = 0.5 * k * x * x;\nconst KE = 0.5 * m * v * v;\nH.text(\"SHM energy: E = ½kA² = KE + PE (constant)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"KE peaks at x = 0; PE peaks at x = ±A; their sum stays fixed\", 24, 52, { color: H.colors.sub, size: 13 });\nconst view = H.plot2d({ xMin: -A * 1.2, xMax: A * 1.2, yMin: 0, yMax: E * 1.2 });\nview.axes();\nview.fn(xx => 0.5 * k * xx * xx, { color: H.colors.violet, width: 2 });\nview.fn(xx => E - 0.5 * k * xx * xx, { color: H.colors.accent2, width: 2 });\nview.line(-A * 1.2, E, A * 1.2, E, { color: H.colors.sub, width: 1, dash: [4, 4] });\nview.dot(x, PE, { r: 6, fill: H.colors.violet });\nview.dot(x, KE, { r: 6, fill: H.colors.accent2 });\nview.text(\"PE\", -A * 0.9, 0.5 * k * (A * 0.9) * (A * 0.9), { color: H.colors.violet, size: 12 });\nview.text(\"KE\", 0, E * 0.5, { color: H.colors.accent2, size: 12 });\nH.legend([{ label: \"PE = ½kx²\", color: H.colors.violet }, { label: \"KE = ½mv²\", color: H.colors.accent2 }, { label: \"E total\", color: H.colors.sub }], 24, 470);\nH.text(\"x = \" + x.toFixed(2) + \" m v = \" + v.toFixed(2) + \" m/s\", 24, 76, { color: H.colors.sub, size: 13 });\nH.text(\"KE = \" + KE.toFixed(2) + \" J PE = \" + PE.toFixed(2) + \" J E = \" + E.toFixed(2) + \" J\", 24, 96, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apphys-wave-speed", + "area": "AP Physics", + "topic": "Wave on a string v=fλ", + "title": "Wave on a string: v = f λ", + "equation": "v = f * lambda", + "keywords": [ + "wave speed", + "v = f lambda", + "frequency", + "wavelength", + "traveling wave", + "wave on a string", + "transverse wave", + "period frequency relation", + "ap physics", + "mechanical wave" + ], + "explanation": "Every periodic wave satisfies v = fλ: in one period T = 1/f the pattern advances exactly one wavelength λ, so the wave travels at speed v = fλ. For a wave on a string the speed is fixed by the medium (tension and linear density), so raising f forces λ to shrink and vice versa, but here the sliders set f and λ to show how their product gives v. The AP exam tests recognizing that v depends on the medium while f is set by the source. The animation shows the snapshot y = A·sin(kx − ωt) moving right, with a marked crest and the wavelength bracket.", + "bullets": [ + "The disturbance moves right at v = fλ while the string elements move only up/down.", + "One full wavelength λ passes a point in one period T = 1/f.", + "The violet bracket spans exactly one wavelength λ.", + "The orange crest marker advances to the right at the wave speed v." + ], + "params": [ + { + "name": "f", + "label": "frequency f (Hz)", + "min": 0.2, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "lam", + "label": "wavelength λ (m)", + "min": 0.4, + "max": 3, + "step": 0.1, + "value": 1.5 + } + ], + "code": "H.background();\nconst f = Math.max(P.f, 0.1);\nconst lam = Math.max(P.lam, 0.2);\nconst v = f * lam;\nconst A = 0.7;\nconst kw = (2 * Math.PI) / lam;\nconst w = 2 * Math.PI * f;\nH.text(\"Wave on a string: v = f λ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Speed = frequency × wavelength; the crest travels right at v\", 24, 52, { color: H.colors.sub, size: 13 });\nconst xMax = 4;\nconst view = H.plot2d({ xMin: 0, xMax: xMax, yMin: -1.2, yMax: 1.2 });\nview.axes();\nview.fn(x => A * Math.sin(kw * x - w * t), { color: H.colors.accent, width: 2 });\nlet xc = (Math.PI / 2 + w * t) / kw;\nxc = xc % lam;\nview.dot(xc, A, { r: 6, fill: H.colors.warn });\nview.line(0, -1.05, lam, -1.05, { color: H.colors.violet, width: 2 });\nview.text(\"λ = \" + lam.toFixed(2) + \" m\", lam * 0.5, -1.18, { color: H.colors.violet, size: 12 });\nH.text(\"f = \" + f.toFixed(2) + \" Hz λ = \" + lam.toFixed(2) + \" m\", 24, 76, { color: H.colors.sub, size: 13 });\nH.text(\"v = f λ = \" + v.toFixed(2) + \" m/s\", 24, 96, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apphys-superposition", + "area": "AP Physics", + "topic": "Superposition / interference", + "title": "Superposition: y = y₁ + y₂, amplitude 2A|cos(φ/2)|", + "equation": "y = y1 + y2, A_res = 2*A*|cos(phi/2)|", + "keywords": [ + "superposition", + "interference", + "constructive interference", + "destructive interference", + "phase difference", + "wave addition", + "y1 plus y2", + "two waves", + "path difference", + "ap physics" + ], + "explanation": "The superposition principle says overlapping waves add point-by-point: y = y₁ + y₂. For two equal-amplitude waves differing by phase φ, the resultant amplitude is 2A|cos(φ/2)| — maximum 2A when φ = 0 (constructive) and zero when φ = π (destructive). On the AP exam a phase difference of φ = 2π·(Δpath/λ) links to path difference, and integer-vs-half-integer multiples of λ select constructive vs destructive interference. The animation overlays the two source waves and their bright sum, with dashed lines marking the live resultant amplitude as the phase slider sweeps from in-phase to out-of-phase.", + "bullets": [ + "Waves add algebraically: the bright curve is y₁ + y₂ at every point.", + "φ = 0 gives constructive interference, resultant amplitude 2A.", + "φ = π gives destructive interference, resultant amplitude ≈ 0.", + "Resultant amplitude follows 2A|cos(φ/2)| (the dashed envelope)." + ], + "params": [ + { + "name": "phi", + "label": "phase difference φ (rad)", + "min": 0, + "max": 6.28, + "step": 0.05, + "value": 0 + } + ], + "code": "H.background();\nconst phi = P.phi;\nconst A = 0.6;\nconst k = 2 * Math.PI / 2;\nconst w = 2 * Math.PI * 0.5;\nH.text(\"Superposition: y = y₁ + y₂\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Phase φ = 0 -> constructive (2A); φ = π -> destructive (≈0)\", 24, 52, { color: H.colors.sub, size: 13 });\nconst view = H.plot2d({ xMin: 0, xMax: 4, yMin: -1.4, yMax: 1.4 });\nview.axes();\nconst y1 = x => A * Math.sin(k * x - w * t);\nconst y2 = x => A * Math.sin(k * x - w * t + phi);\nview.fn(y1, { color: H.colors.sub, width: 1 });\nview.fn(y2, { color: H.colors.violet, width: 1 });\nview.fn(x => y1(x) + y2(x), { color: H.colors.accent, width: 3 });\nconst Ares = 2 * A * Math.abs(Math.cos(phi / 2));\nview.line(0, Ares, 4, Ares, { color: H.colors.warn, width: 1, dash: [4, 4] });\nview.line(0, -Ares, 4, -Ares, { color: H.colors.warn, width: 1, dash: [4, 4] });\nconst xc = (w * t / k) % 4;\nview.dot(xc, y1(xc) + y2(xc), { r: 6, fill: H.colors.warn });\nH.legend([{ label: \"y1\", color: H.colors.sub }, { label: \"y2 (shifted)\", color: H.colors.violet }, { label: \"y1+y2\", color: H.colors.accent }], 24, 470);\nH.text(\"φ = \" + phi.toFixed(2) + \" rad (\" + (phi * 180 / Math.PI).toFixed(0) + \"°)\", 24, 76, { color: H.colors.sub, size: 13 });\nH.text(\"Resultant amplitude = 2A|cos(φ/2)| = \" + Ares.toFixed(2) + \" m\", 24, 96, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apphys-standing-waves", + "area": "AP Physics", + "topic": "Standing waves on a string", + "title": "Standing waves: L = n*lambda/2, y = A sin(kx) cos(wt)", + "equation": "L = n*lambda/2, lambda_n = 2L/n, f_n = n*v/(2L)", + "keywords": [ + "standing waves", + "standing wave on a string", + "nodes and antinodes", + "harmonics", + "fundamental frequency", + "resonance", + "normal modes", + "lambda = 2L/n", + "string fixed both ends", + "ap physics", + "wavelength harmonic" + ], + "explanation": "A string fixed at both ends supports only standing waves whose half-wavelengths fit exactly in the length L, giving the quantization L = n*lambda/2, so lambda_n = 2L/n and f_n = n*v/(2L). Nodes are points of zero displacement (always n+1 of them, including the two fixed ends) and antinodes are points of maximum oscillation (n of them). The waveform y = A sin(kx) cos(wt) shows every point sharing the same time factor cos(wt) — the pattern stays put and only its amplitude breathes, which is the signature of a standing wave versus a traveling one. On the AP exam you must count nodes/antinodes for a given harmonic and use f_n = n*f_1 to relate overtones to the fundamental.", + "bullets": [ + "Orange dots are nodes (zero motion); there are always n+1 of them.", + "Violet dots are antinodes (max swing); there are n of them.", + "Dashed curves are the +/- A sin(kx) envelope the string never exceeds.", + "Whole pattern shares cos(wt): it pulses in place, it does not travel." + ], + "params": [ + { + "name": "n", + "label": "harmonic n", + "min": 1, + "max": 6, + "step": 1, + "value": 3 + } + ], + "code": "H.background();\nconst n = Math.max(1, Math.round(P.n));\nconst L = 1.0;\nconst A = 0.8;\nconst v = H.plot2d({ xMin: 0, xMax: L, yMin: -1.2, yMax: 1.2 });\nv.grid(); v.axes();\nconst k = n * Math.PI / L;\nconst omega = 2 * Math.PI * 1.0;\nconst env = Math.cos(omega * t);\nconst pts = [];\nfor (let i = 0; i <= 200; i++) {\n const x = L * i / 200;\n pts.push([x, A * Math.sin(k * x) * env]);\n}\nv.path(pts, { color: H.colors.accent, width: 3 });\nconst up = [], dn = [];\nfor (let i = 0; i <= 200; i++) {\n const x = L * i / 200;\n up.push([x, A * Math.sin(k * x)]);\n dn.push([x, -A * Math.sin(k * x)]);\n}\nv.path(up, { color: H.colors.sub, width: 1, dash: [4, 4] });\nv.path(dn, { color: H.colors.sub, width: 1, dash: [4, 4] });\nfor (let m = 0; m <= n; m++) {\n const xn = m * L / n;\n v.circle(xn, 0, 5, { fill: H.colors.warn });\n}\nfor (let m = 0; m < n; m++) {\n const xa = (m + 0.5) * L / n;\n v.dot(xa, A * Math.sin(k * xa) * env, { r: 5, fill: H.colors.violet });\n}\nconst lam = 2 * L / n;\nH.text(\"Standing wave: y = A sin(kx) cos(wt), L = n*lambda/2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Harmonic n = \" + n + \" (n nodes shown in orange, antinodes in violet)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"lambda = 2L/n = \" + lam.toFixed(2) + \" m nodes = \" + (n + 1) + \" antinodes = \" + n, 24, 74, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apphys-beats", + "area": "AP Physics", + "topic": "Beats", + "title": "Beats: f_beat = |f1 - f2|", + "equation": "f_beat = |f1 - f2|, y = sin(2*pi*f1*t) + sin(2*pi*f2*t)", + "keywords": [ + "beats", + "beat frequency", + "f beat = |f1 - f2|", + "interference", + "superposition", + "amplitude modulation", + "throbbing tone", + "tuning", + "two close frequencies", + "ap physics", + "envelope" + ], + "explanation": "When two tones of nearly equal frequency overlap, superposition makes their sum swell and fade: the loudness throbs at the beat frequency f_beat = |f1 - f2|. The product-to-sum identity rewrites the sum as a fast carrier at the average frequency (f1+f2)/2 multiplied by a slow envelope cos(2*pi*(df/2)*t); because loudness peaks at both the positive and negative humps of that envelope, the audible beat rate is the full |f1-f2|, not half of it. Musicians use this to tune — a slowing beat means the two pitches are converging. On the AP exam you compute f_beat from two given frequencies and identify the violet envelope as the modulation, not a separate wave.", + "bullets": [ + "Blue curve is the literal sum of the two sine tones (superposition).", + "Violet dashed curve is the +/- 2cos(2*pi*(df/2)*t) amplitude envelope.", + "One full loud-soft cycle per 1/|f1-f2| seconds = the beat period.", + "Smaller df -> slower throb -> the tones are closer to in tune." + ], + "params": [ + { + "name": "df", + "label": "freq diff |f1-f2| (Hz)", + "min": 0.5, + "max": 6, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst f1 = 10;\nconst df = Math.max(0.5, P.df);\nconst f2 = f1 + df;\nconst tspan = 2.0;\nconst v = H.plot2d({ xMin: 0, xMax: tspan, yMin: -2.3, yMax: 2.3 });\nv.grid(); v.axes();\nconst shift = (t * 0.3) % tspan;\nconst pts = [], envU = [], envD = [];\nfor (let i = 0; i <= 300; i++) {\n const x = tspan * i / 300;\n const tt = x + shift;\n const y = Math.sin(2 * Math.PI * f1 * tt) + Math.sin(2 * Math.PI * f2 * tt);\n pts.push([x, y]);\n const e = 2 * Math.cos(2 * Math.PI * (df / 2) * tt);\n envU.push([x, e]);\n envD.push([x, -e]);\n}\nv.path(envU, { color: H.colors.violet, width: 1.5, dash: [5, 4] });\nv.path(envD, { color: H.colors.violet, width: 1.5, dash: [5, 4] });\nv.path(pts, { color: H.colors.accent, width: 2 });\nconst fbeat = df;\nconst Tbeat = 1 / fbeat;\nH.text(\"Beats: f_beat = |f1 - f2|\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Two close tones interfere; loudness throbs at the beat frequency\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"f1 = \" + f1.toFixed(1) + \" Hz f2 = \" + f2.toFixed(1) + \" Hz f_beat = \" + fbeat.toFixed(1) + \" Hz T_beat = \" + Tbeat.toFixed(2) + \" s\", 24, 74, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"sum signal\", color: H.colors.accent }, { label: \"beat envelope\", color: H.colors.violet }], 24, 96);" + }, + { + "id": "apphys-doppler", + "area": "AP Physics", + "topic": "Doppler effect", + "title": "Doppler effect: f' = f0 * v / (v -/+ v_s)", + "equation": "f' = f0 * v / (v - v_s) toward, f' = f0 * v / (v + v_s) away", + "keywords": [ + "doppler effect", + "doppler shift", + "moving source", + "wavefronts bunching", + "frequency shift", + "f' = f0 v/(v - vs)", + "blueshift redshift sound", + "siren pitch", + "approaching receding", + "ap physics", + "wave speed" + ], + "explanation": "A source moving through the medium emits each successive wavefront from a position farther along its path, so the fronts pile up ahead of it and spread out behind it. Ahead of the source the wavelength shrinks and the observed frequency rises, f' = f0*v/(v - v_s); behind it the wavelength stretches and f' = f0*v/(v + v_s), where v is the wave (sound) speed and v_s the source speed. The pitch heard is constant while approaching and constant while receding — it does not glide; the drop happens at the instant the source passes you. On the AP exam you pick the minus sign for the toward case (higher f) and the plus sign for the away case (lower f), and note that v_s -> v makes the toward frequency diverge (the basis of a shock front).", + "bullets": [ + "Circles are wavefronts emitted from where the source WAS, not where it is.", + "Fronts crowd ahead (right): shorter lambda, higher f for the green observer.", + "Fronts spread behind (left): longer lambda, lower f for the violet observer.", + "Toward uses (v - v_s), away uses (v + v_s); larger v_s = bigger shift." + ], + "params": [ + { + "name": "vs", + "label": "source speed v_s (as fraction of v)", + "min": 0, + "max": 0.85, + "step": 0.05, + "value": 0.5 + } + ], + "code": "H.background();\nconst W = H.W, Ht = H.H;\nconst cy = Ht * 0.56;\nconst cx0 = W * 0.5;\nconst Mach = Math.max(0, Math.min(0.85, P.vs));\nconst cpx = 120;\nconst vspx = Mach * cpx;\nconst period = 8.0;\nconst travel = (t % period) / period;\nconst startX = cx0 - 300, endX = cx0 + 300;\nconst sx = startX + (endX - startX) * travel;\nconst Te = 0.55;\nconst tEmitStart = t - 9 * Te;\nfor (let e = 0; e < 14; e++) {\n const te = tEmitStart + e * Te;\n if (te < 0) continue;\n const age = t - te;\n if (age <= 0) continue;\n const trav_e = ((te % period) / period);\n const ex = startX + (endX - startX) * trav_e;\n const r = cpx * age;\n if (r < 1 || r > 700) continue;\n const circ = [];\n for (let a = 0; a <= 48; a++) {\n const th = 2 * Math.PI * a / 48;\n circ.push([ex + r * Math.cos(th), cy + r * Math.sin(th)]);\n }\n H.path(circ, { color: H.colors.grid, width: 1.5 });\n}\nH.circle(sx, cy, 8, { fill: H.colors.warn });\nH.arrow(sx, cy, sx + 46, cy, { color: H.colors.ink, width: 2 });\nH.circle(endX + 40, cy, 7, { fill: H.colors.good });\nH.text(\"ahead: higher f\", endX - 60, cy - 22, { color: H.colors.good, size: 12 });\nH.circle(startX - 40, cy, 7, { fill: H.colors.violet });\nH.text(\"behind: lower f\", startX - 30, cy - 22, { color: H.colors.violet, size: 12 });\nconst f0 = 500;\nconst fTow = f0 / (1 - Mach);\nconst fAway = f0 / (1 + Mach);\nH.text(\"Doppler effect: f' = f0 * v / (v -/+ v_s)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Fronts bunch ahead of the moving source (higher f), spread behind (lower f)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"v_s = \" + Mach.toFixed(2) + \" v f0 = \" + f0 + \" Hz f_ahead = \" + fTow.toFixed(0) + \" Hz f_behind = \" + fAway.toFixed(0) + \" Hz\", 24, 74, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apphys-sound-intensity", + "area": "AP Physics", + "topic": "Sound intensity level (dB)", + "title": "Sound level: beta = 10 log10(I/I0), I = P/(4 pi r^2)", + "equation": "I = P_acoustic / (4*pi*r^2), beta = 10*log10(I / I0), I0 = 1e-12 W/m^2", + "keywords": [ + "sound intensity", + "decibel", + "sound intensity level", + "beta = 10 log(I/I0)", + "inverse square law", + "intensity vs distance", + "loudness", + "I0 = 1e-12", + "6 dB per doubling", + "ap physics", + "power spreads over sphere" + ], + "explanation": "A point source radiates power equally over an expanding sphere, so intensity falls as the inverse square of distance, I = P/(4*pi*r^2). The sound intensity level is the logarithmic measure beta = 10*log10(I/I0) in decibels, referenced to the threshold of hearing I0 = 1e-12 W/m^2. Because the scale is logarithmic, doubling the distance quarters the intensity but lowers beta by only about 6 dB (10*log10(4) = 6.0), and a 10x intensity change is exactly 10 dB. On the AP exam you combine the inverse-square drop in I with the log formula for beta, and recognize that equal dB steps correspond to equal RATIOS of intensity, not equal differences.", + "bullets": [ + "Expanding ring shows power thinning over a growing sphere (inverse square).", + "The orange dot tracks beta(r) on the curve as you change distance r.", + "Doubling r cuts I by 4x but drops beta only ~6 dB (log scale).", + "beta is referenced to I0 = 1e-12 W/m^2, the hearing threshold." + ], + "params": [ + { + "name": "r", + "label": "distance r (m)", + "min": 0.5, + "max": 20, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst W = H.W, Ht = H.H;\nconst Pw = 0.5;\nconst I0 = 1e-12;\nconst r = Math.max(0.5, P.r);\nconst I = Pw / (4 * Math.PI * r * r);\nconst beta = 10 * Math.log10(I / I0);\nconst sx = 150, sy = Ht * 0.55;\nconst pulse = (t % 1.2) / 1.2;\nconst rpx = 30 + pulse * 230;\nconst circ = [];\nfor (let a = 0; a <= 48; a++) {\n const th = 2 * Math.PI * a / 48;\n circ.push([sx + rpx * Math.cos(th), sy + rpx * Math.sin(th)]);\n}\nH.path(circ, { color: H.colors.grid, width: 2 });\nH.circle(sx, sy, 9, { fill: H.colors.warn });\nconst lx = sx + Math.min(300, 18 * r);\nH.circle(lx, sy, 7, { fill: H.colors.good });\nH.line(sx, sy, lx, sy, { color: H.colors.sub, width: 1, dash: [4, 4] });\nH.text(\"r = \" + r.toFixed(1) + \" m\", (sx + lx) / 2 - 24, sy - 14, { color: H.colors.sub, size: 12 });\nconst v = H.plot2d({ xMin: 0.5, xMax: 20, yMin: 30, yMax: 120, box: { x: 430, y: 90, w: 430, h: 380 } });\nv.grid(); v.axes();\nv.fn(x => 10 * Math.log10((Pw / (4 * Math.PI * x * x)) / I0), { color: H.colors.accent, width: 3 });\nv.dot(r, beta, { r: 7, fill: H.colors.warn });\nv.text(\"beta(r)\", 14, 115, { color: H.colors.accent, size: 12 });\nH.text(\"Sound level: beta = 10 log10(I / I0), I = P / (4 pi r^2)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Doubling r drops I by 4x (inverse square) and beta by ~6 dB\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"I = \" + I.toExponential(2) + \" W/m^2 beta = \" + beta.toFixed(1) + \" dB\", 24, 74, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apphys-pressure-depth", + "area": "AP Physics", + "topic": "Pressure with depth", + "title": "Hydrostatic pressure: P = P0 + rho*g*h", + "equation": "P = P0 + rho*g*h (P0 = atmospheric, rho*g*h = gauge pressure)", + "keywords": [ + "pressure with depth", + "hydrostatic pressure", + "P = P0 + rho g h", + "fluid pressure", + "gauge pressure", + "absolute pressure", + "density rho g h", + "fluids", + "diver pressure", + "ap physics", + "pascal pressure" + ], + "explanation": "In a static fluid the pressure increases linearly with depth because each layer must support the weight of all the fluid above it: P = P0 + rho*g*h, where P0 is the pressure at the surface (atmospheric, ~101 kPa for an open container), rho is the fluid density, g is gravity, and h is the depth. The term rho*g*h is the gauge pressure — the amount ABOVE atmospheric — so absolute pressure is P0 plus the gauge term. Pressure depends only on depth h, not on the shape or total amount of the container (the hydrostatic paradox), and it acts equally in all directions. On the AP exam you add P0 for absolute pressure, drop it when only gauge pressure is asked, and use rho_water = 1000 kg/m^3 with the linear P-vs-h relationship shown by the straight line.", + "bullets": [ + "Pressure grows LINEARLY with depth h — the right-hand graph is a straight line.", + "rho*g*h is gauge pressure (above atmospheric); add P0 for absolute.", + "Depends only on h, not the container's shape or width.", + "The orange diver marker reads P at its current depth in kPa." + ], + "params": [ + { + "name": "h", + "label": "depth h (m)", + "min": 0, + "max": 20, + "step": 0.5, + "value": 8 + } + ], + "code": "H.background();\nconst W = H.W, Ht = H.H;\nconst P0 = 101325;\nconst rho = 1000;\nconst g = 9.8;\nconst hmax = 20;\nconst h = Math.max(0, P.h);\nconst topY = 90, botY = 500;\nconst colX = 90, colW = 260;\nH.rect(colX, topY, colW, botY - topY, { fill: H.colors.panel, stroke: H.colors.sub, width: 1 });\nH.line(colX, topY, colX + colW, topY, { color: H.colors.accent2, width: 2 });\nH.text(\"surface (P0)\", colX, topY - 8, { color: H.colors.accent2, size: 12 });\nconst yOf = d => topY + (botY - topY) * (d / hmax);\nconst bob = 0.4 * Math.sin(t * 1.5);\nconst dy = yOf(Math.min(hmax, h)) + bob * 8;\nH.circle(colX + colW / 2, dy, 9, { fill: H.colors.warn });\nH.line(colX + colW / 2, topY, colX + colW / 2, dy, { color: H.colors.sub, width: 1, dash: [4, 4] });\nH.text(\"h = \" + h.toFixed(1) + \" m\", colX + colW / 2 + 12, dy - 10, { color: H.colors.ink, size: 13 });\nconst Ph = P0 + rho * g * h;\nconst Pgauge = rho * g * h;\nconst v = H.plot2d({ xMin: 0, xMax: hmax, yMin: 0, yMax: P0 + rho * g * hmax, box: { x: 440, y: 90, w: 420, h: 380 } });\nv.grid(); v.axes();\nv.fn(x => P0 + rho * g * x, { color: H.colors.accent, width: 3 });\nv.dot(h, Ph, { r: 7, fill: H.colors.warn });\nv.text(\"P(h)\", hmax * 0.55, P0 + rho * g * hmax * 0.55, { color: H.colors.accent, size: 12 });\nH.text(\"Hydrostatic pressure: P = P0 + rho g h\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Pressure rises linearly with depth; rho g h is the gauge (extra) pressure\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"P0 = \" + (P0 / 1000).toFixed(1) + \" kPa gauge = \" + (Pgauge / 1000).toFixed(1) + \" kPa P = \" + (Ph / 1000).toFixed(1) + \" kPa\", 24, 74, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apphys-buoyancy", + "area": "AP Physics", + "topic": "Buoyancy / Archimedes", + "title": "Buoyancy: Fb = rho_fluid * V_sub * g", + "equation": "Fb = rho_fluid * V_submerged * g", + "keywords": [ + "buoyancy", + "archimedes principle", + "buoyant force", + "fb = rho v g", + "displaced fluid", + "floating", + "submerged fraction", + "density", + "apparent weight", + "ap physics", + "fluids", + "sink or float" + ], + "explanation": "Archimedes' principle says the upward buoyant force equals the weight of the displaced fluid: Fb = rho_fluid * V_submerged * g. Only the SUBMERGED volume displaces fluid, so the buoyant force scales with the submerged fraction the slider sets and with the fluid density rho_fluid. An object floats in equilibrium when this buoyant force balances its weight (rho_object * V * g), which fixes the submerged fraction as rho_object/rho_fluid. On the AP exam you compare Fb to weight to decide float/sink and read the apparent weight as weight minus Fb; denser fluids (slider up) lift more, so less of the block needs to be under to balance.", + "bullets": [ + "Fb depends only on the SUBMERGED volume (highlighted blue), not the part above water.", + "Raising fluid density rho increases Fb for the same submerged fraction.", + "Floating equilibrium: submerged fraction = rho_object / rho_fluid.", + "The green arrow Fb pushes up through the submerged region; readout updates in newtons." + ], + "params": [ + { + "name": "rho", + "label": "fluid density rho (kg/m^3)", + "min": 500, + "max": 2000, + "step": 50, + "value": 1000 + }, + { + "name": "frac", + "label": "submerged fraction", + "min": 0, + "max": 1, + "step": 0.05, + "value": 0.6 + } + ], + "code": "H.background();\nconst rho = P.rho, f = Math.min(1, Math.max(0, P.frac)), g = 9.8;\nH.text(\"Buoyancy: Fb = rho_fluid * V_sub * g\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"rho = \" + rho.toFixed(0) + \" kg/m^3, submerged fraction = \" + (f*100).toFixed(0) + \"%\", 24, 52, { color: H.colors.sub, size: 13 });\n// fluid region\nconst surfaceY = 220, floorY = 500, left = 120, right = 600;\nH.rect(left, surfaceY, right-left, floorY-surfaceY, { fill: \"rgba(70,140,220,0.18)\" });\nH.line(left, surfaceY, right, surfaceY, { color: H.colors.accent, width: 2 });\nH.text(\"water surface\", right+8, surfaceY, { color: H.colors.sub, size: 11, baseline: \"middle\" });\n// block: total height 120, width 120\nconst bw = 120, bh = 120, bx = 300;\n// block top oscillates a touch but submerged fraction fixed by slider\nconst wob = 3 * Math.sin(t * 1.2);\nconst subH = f * bh;\nconst topY = surfaceY - (bh - subH) + wob; // top above surface by emerged part\nH.rect(bx, topY, bw, bh, { fill: \"rgba(180,130,80,0.85)\", stroke: H.colors.ink, width: 2 });\n// submerged portion highlight\nconst subTop = Math.max(topY, surfaceY);\nH.rect(bx, subTop, bw, (topY+bh)-subTop, { fill: \"rgba(70,140,220,0.35)\" });\n// buoyant force arrow up, weight arrow down (illustrative; assume V=1 m^3 scale)\nconst V = 1.0;\nconst Fb = rho * (V * f) * g;\nconst cx = bx + bw/2;\nH.arrow(cx, subTop + (topY+bh-subTop)/2, cx, subTop + (topY+bh-subTop)/2 - 70, { color: H.colors.good, width: 4 });\nH.text(\"Fb\", cx + 8, subTop + (topY+bh-subTop)/2 - 50, { color: H.colors.good, size: 13 });\nH.text(\"Fb = \" + Fb.toFixed(0) + \" N (V=1 m^3)\", 24, 80, { color: H.colors.good, size: 13 });\nH.legend([{label:\"buoyant force Fb\", color:H.colors.good}], 620, 240);\n" + }, + { + "id": "apphys-continuity", + "area": "AP Physics", + "topic": "Continuity equation", + "title": "Continuity: A1*v1 = A2*v2", + "equation": "A1 * v1 = A2 * v2 (Q = A*v = const)", + "keywords": [ + "continuity equation", + "a1 v1 = a2 v2", + "volume flow rate", + "incompressible flow", + "conservation of mass", + "fluid speed", + "pipe narrowing", + "flow rate q", + "cross-sectional area", + "ap physics", + "fluids", + "streamline" + ], + "explanation": "For an incompressible fluid the volume flow rate Q = A*v is the same through every cross-section, so A1*v1 = A2*v2. Where the pipe narrows (smaller A) the fluid must speed up, and where it widens it slows down, inversely with area. The slider shrinks the throat area A2 while Q is held fixed, so v2 = Q/A2 climbs as the pipe constricts, shown by the faster orange particles in the narrow section. On the AP exam this is the mass-conservation step you apply before Bernoulli, and it explains why a thumb over a hose makes the stream shoot faster.", + "bullets": [ + "Q = A*v stays constant: the same volume passes every second through any slice.", + "Narrow the throat (A2 down) and v2 = Q/A2 rises inversely with area.", + "Particles in the narrow region (orange) move faster than in the wide region.", + "Live readouts show v1 and v2 changing while Q is held fixed." + ], + "params": [ + { + "name": "A1", + "label": "wide area A1 (m^2)", + "min": 0.5, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "A2", + "label": "throat area A2 (m^2)", + "min": 0.1, + "max": 1.5, + "step": 0.05, + "value": 0.4 + }, + { + "name": "Q", + "label": "flow rate Q (m^3/s)", + "min": 0.5, + "max": 4, + "step": 0.25, + "value": 2 + } + ], + "code": "H.background();\nconst A1 = P.A1, A2 = Math.max(0.05, P.A2), Q = P.Q; // Q = volume flow rate m^3/s\nconst v1 = Q / A1, v2 = Q / A2;\nH.text(\"Continuity: A1*v1 = A2*v2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Incompressible flow: same volume per second through every cross-section\", 24, 52, { color: H.colors.sub, size: 13 });\n// pipe geometry: width maps to area; centerline at cy\nconst cy = 320, xJoin = 450;\nconst scale = 90; // px per m^2 of area for half-height\nconst h1 = Math.min(140, A1 * scale), h2 = Math.min(140, A2 * scale);\n// pipe walls\nH.path([[80, cy-h1],[xJoin, cy-h1],[820, cy-h2]], { color: H.colors.sub, width: 3 });\nH.path([[80, cy+h1],[xJoin, cy+h1],[820, cy+h2]], { color: H.colors.sub, width: 3 });\n// fluid fill\nH.path([[80,cy-h1],[xJoin,cy-h1],[820,cy-h2],[820,cy+h2],[xJoin,cy+h1],[80,cy+h1]], { fill: \"rgba(70,140,220,0.18)\", close:true });\n// particles advect: speed proportional to local v\nconst nP = 7;\nfor (let i=0;ixJoin moves faster. Use piecewise phase.\n const period = 4.0;\n const ph = ((t*0.6 + i*0.13) % 1);\n // map phase to x across pipe; faster in region 2 by compressing time there\n let x, hLoc;\n // simple: x sweeps 80..820, but we color speed\n x = 80 + ph*740;\n hLoc = x < xJoin ? h1 + (h2-h1)*((x-80)/(xJoin-80))*0 : h2; // approx\n const hh = x < xJoin ? h1 : h2;\n const py = cy + lane*2*(hh-10);\n const fast = x >= xJoin;\n H.circle(x, py, 5, { fill: fast ? H.colors.warn : H.colors.accent2 });\n}\nH.text(\"A1 = \" + A1.toFixed(2) + \" m^2 -> v1 = \" + v1.toFixed(2) + \" m/s\", 90, cy - h1 - 20, { color: H.colors.accent2, size: 13 });\nH.text(\"A2 = \" + A2.toFixed(2) + \" m^2 -> v2 = \" + v2.toFixed(2) + \" m/s\", 560, cy - h2 - 20, { color: H.colors.warn, size: 13 });\nH.text(\"Q = \" + Q.toFixed(2) + \" m^3/s (same both sides)\", 24, 80, { color: H.colors.good, size: 13 });\n" + }, + { + "id": "apphys-bernoulli", + "area": "AP Physics", + "topic": "Bernoulli equation", + "title": "Bernoulli: P + 1/2 rho v^2 + rho g h = const", + "equation": "P1 + 1/2 rho v1^2 + rho g h1 = P2 + 1/2 rho v2^2 + rho g h2", + "keywords": [ + "bernoulli equation", + "p + half rho v squared + rho g h", + "fluid pressure", + "energy conservation fluid", + "venturi", + "faster flow lower pressure", + "dynamic pressure", + "static pressure", + "streamline", + "ap physics", + "fluids", + "lift" + ], + "explanation": "Bernoulli's equation is energy conservation per unit volume along a streamline: static pressure P, dynamic pressure 1/2 rho v^2, and gravitational term rho g h add to a constant. Where the fluid speeds up (or rises), pressure must drop to keep the sum fixed, which is why a Venturi throat has lower pressure than the wide inlet. Here continuity (A1 v1 = A2 v2) sets the throat speed from the area slider, and raising the throat height h2 further lowers its pressure through the rho g h term. On the AP exam you pair Bernoulli with continuity to solve for an unknown pressure or speed, and it explains airplane lift and atomizers.", + "bullets": [ + "The three terms P, 1/2 rho v^2, and rho g h trade off so their sum is constant.", + "Shrinking the throat raises v2 (continuity), which drops P2 below P1.", + "Raising the throat height h2 lowers P2 further via the rho g h term.", + "Pressure bars compare inlet vs throat; readouts give v and P in kPa." + ], + "params": [ + { + "name": "A2", + "label": "throat area A2 (m^2)", + "min": 0.01, + "max": 0.06, + "step": 0.005, + "value": 0.02 + }, + { + "name": "Q", + "label": "flow rate Q (m^3/s)", + "min": 0.05, + "max": 0.4, + "step": 0.05, + "value": 0.2 + }, + { + "name": "h2", + "label": "throat height h2 (m)", + "min": 0, + "max": 3, + "step": 0.25, + "value": 1 + } + ], + "code": "H.background();\nconst rho = 1000; // water kg/m^3\nconst A1 = 0.06, A2 = Math.max(0.01, P.A2); // throat area slider, m^2\nconst Q = P.Q; // m^3/s\nconst g = 9.8;\nconst h2 = P.h2; // height of throat relative to inlet, m\nconst v1 = Q / A1, v2 = Q / A2;\n// Bernoulli: P1 + 0.5 rho v1^2 + rho g h1 = P2 + 0.5 rho v2^2 + rho g h2\nconst h1 = 0, P1 = 120000; // Pa gauge at inlet\nconst P2 = P1 + 0.5*rho*v1*v1 + rho*g*h1 - 0.5*rho*v2*v2 - rho*g*h2;\nH.text(\"Bernoulli: P + 1/2 rho v^2 + rho g h = const\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Faster/higher flow -> lower pressure (energy per volume conserved)\", 24, 52, { color: H.colors.sub, size: 13 });\nconst cy1 = 360, xJoin = 430;\nconst cy2 = cy1 - h2*40; // higher throat drawn higher\nconst sc = 600;\nconst r1 = Math.min(80, A1*sc), r2 = Math.min(80, A2*sc);\n// pipe walls\nH.path([[80, cy1-r1],[xJoin, cy1-r1],[xJoin+10, cy2-r2],[820, cy2-r2]], { color: H.colors.sub, width: 3 });\nH.path([[80, cy1+r1],[xJoin, cy1+r1],[xJoin+10, cy2+r2],[820, cy2+r2]], { color: H.colors.sub, width: 3 });\n// moving particle\nconst ph = (t*0.5)%1;\nconst px = 80 + ph*740;\nconst inThroat = px > xJoin;\nconst py = (inThroat ? cy2 : cy1) + 6*Math.sin(t*3);\nH.circle(px, py, 6, { fill: inThroat ? H.colors.warn : H.colors.accent2 });\n// pressure bars\nH.rect(120, 470, 40, -Math.max(2,P1/3000), { fill: H.colors.accent2 });\nH.rect(640, 470, 40, -Math.max(2,P2/3000), { fill: H.colors.warn });\nH.text(\"inlet\", 118, 488, { color: H.colors.sub, size: 11 });\nH.text(\"throat\", 636, 488, { color: H.colors.sub, size: 11 });\nH.text(\"v1 = \" + v1.toFixed(2) + \" m/s P1 = \" + (P1/1000).toFixed(1) + \" kPa\", 24, 78, { color: H.colors.accent2, size: 13 });\nH.text(\"v2 = \" + v2.toFixed(2) + \" m/s P2 = \" + (P2/1000).toFixed(1) + \" kPa\", 24, 96, { color: H.colors.warn, size: 13 });\nH.text(\"throat A2 = \" + A2.toFixed(3) + \" m^2, h2 = \" + h2.toFixed(1) + \" m\", 24, 114, { color: H.colors.sub, size: 12 });\n" + }, + { + "id": "apphys-thermal-expansion", + "area": "AP Physics", + "topic": "Thermal expansion", + "title": "Linear thermal expansion: dL = alpha * L0 * dT", + "equation": "Delta L = alpha * L0 * Delta T", + "keywords": [ + "thermal expansion", + "linear expansion", + "delta l = alpha l0 delta t", + "coefficient of thermal expansion", + "alpha", + "heating a rod", + "expansion joint", + "bimetallic strip", + "temperature change", + "ap physics", + "thermodynamics", + "length change" + ], + "explanation": "Heating a solid lengthens it by Delta L = alpha * L0 * Delta T, where alpha is the linear expansion coefficient (typically ~1e-5 /K for metals). The change is proportional to the original length L0 and to the temperature change Delta T, so long rods and big temperature swings give the largest expansion. A negative Delta T (cooling) gives contraction. On the AP exam this explains expansion gaps in bridges and rails, the bending of bimetallic strips, and why thermal stress builds when a constrained rod is heated; the readout here shows the actual Delta L in millimeters (the on-screen stretch is exaggerated so it is visible).", + "bullets": [ + "Delta L grows linearly with both L0 and Delta T (and with the material's alpha).", + "alpha for metals is tiny (~1e-5 /K), so real Delta L is mm-scale; the picture is exaggerated.", + "Cooling (Delta T < 0) reverses the sign and the rod contracts.", + "The dashed line marks L0; the orange arrow and readout track Delta L." + ], + "params": [ + { + "name": "alpha", + "label": "coeff alpha (1e-6 /K)", + "min": 1, + "max": 30, + "step": 1, + "value": 12 + }, + { + "name": "L0", + "label": "length L0 (m)", + "min": 0.5, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "dT", + "label": "temp change dT (K)", + "min": -100, + "max": 200, + "step": 10, + "value": 100 + } + ], + "code": "H.background();\nconst alpha = P.alpha * 1e-6; // per K, slider in units of 1e-6\nconst L0 = P.L0; // m\nconst dT = P.dT; // K\nconst dL = alpha * L0 * dT;\nconst Lnew = L0 + dL;\nH.text(\"Thermal expansion: dL = alpha * L0 * dT\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Heating lengthens a rod in proportion to its length and to dT\", 24, 52, { color: H.colors.sub, size: 13 });\n// animate dT sweeping toward set value for motion, but readout uses slider value\nconst sweep = 0.5 + 0.5*Math.sin(t*0.8); // 0..1\nconst dLanim = dL * sweep;\nconst x0 = 100, y = 300, pxPerM = 300;\nconst baseLen = L0 * pxPerM;\n// exaggerate expansion visually so it's visible\nconst exag = 4000;\nconst drawLen = baseLen + dLanim*exag;\n// rod\nconst hot = sweep; // color toward warm\nH.rect(x0, y-18, Math.min(700, drawLen), 36, { fill: \"rgba(220,\" + Math.round(120-80*hot) + \",60,0.85)\", stroke: H.colors.ink, width: 2 });\n// original length marker\nH.line(x0+baseLen, y-40, x0+baseLen, y+40, { color: H.colors.sub, width: 1, dash:[4,4] });\nH.text(\"L0\", x0+baseLen-10, y-48, { color: H.colors.sub, size: 12 });\n// current end\nH.line(x0+Math.min(700,drawLen), y-50, x0+Math.min(700,drawLen), y+50, { color: H.colors.warn, width: 2 });\nH.arrow(x0+baseLen, y-60, x0+Math.min(700,drawLen), y-60, { color: H.colors.warn, width: 3 });\nH.text(\"dL (x\" + exag + \" shown)\", x0+baseLen, y-66, { color: H.colors.warn, size: 11 });\n// thermometer\nconst tx = 760;\nH.rect(tx, 120, 24, 240, { stroke: H.colors.sub, width: 2 });\nconst fill = 240*sweep;\nH.rect(tx, 360-fill, 24, fill, { fill: \"rgba(220,60,60,0.8)\" });\nH.text(\"alpha = \" + P.alpha.toFixed(1) + \"e-6 /K\", 24, 80, { color: H.colors.sub, size: 13 });\nH.text(\"L0 = \" + L0.toFixed(2) + \" m, dT = \" + dT.toFixed(0) + \" K\", 24, 98, { color: H.colors.sub, size: 13 });\nH.text(\"dL = \" + (dL*1000).toFixed(3) + \" mm, L = \" + Lnew.toFixed(5) + \" m\", 24, 116, { color: H.colors.good, size: 13 });\n" + }, + { + "id": "apphys-ideal-gas", + "area": "AP Physics", + "topic": "Ideal gas law PV=nRT", + "title": "Ideal gas law: P*V = n*R*T", + "equation": "P = n * R * T / V (P*V = n*R*T)", + "keywords": [ + "ideal gas law", + "pv = nrt", + "pressure volume temperature", + "gas constant r", + "moles", + "kinetic theory", + "boyle's law", + "charles's law", + "gay-lussac", + "ap physics", + "thermodynamics", + "particle collisions" + ], + "explanation": "The ideal gas law P*V = n*R*T ties pressure, volume, temperature, and amount together with R = 8.314 J/(mol*K). Fixing any two of n, V, T determines the third; here you set n, V, and T and the pressure P = nRT/V is computed and shown on the gauge. Pressure rises if you compress (smaller V), heat (larger T), or add gas (larger n). Microscopically, P comes from particles striking the walls: the animation speeds up the particles as T rises (mean speed ~ sqrt(T)) and adds more particles as n grows, the kinetic-theory picture the AP exam expects you to connect to Boyle's, Charles's, and Gay-Lussac's laws.", + "bullets": [ + "P = nRT/V: compress, heat, or add moles and pressure climbs.", + "Particle speed scales with sqrt(T); more particles appear as n increases.", + "Box width scales with V, so the same n,T at larger V gives lower P.", + "Gauge and readout report P in kPa and atm from the live n, V, T." + ], + "params": [ + { + "name": "n", + "label": "amount n (mol)", + "min": 0.5, + "max": 3, + "step": 0.25, + "value": 1 + }, + { + "name": "V", + "label": "volume V (m^3)", + "min": 0.01, + "max": 0.05, + "step": 0.005, + "value": 0.025 + }, + { + "name": "T", + "label": "temperature T (K)", + "min": 200, + "max": 600, + "step": 25, + "value": 300 + } + ], + "code": "H.background();\nconst R = 8.314;\nconst n = P.n; // mol\nconst T = P.T; // K\nconst V = Math.max(0.001, P.V); // m^3\n// solve for the third quantity: pressure\nconst Pgas = n * R * T / V; // Pa\nH.text(\"Ideal gas law: P*V = n*R*T\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Set n, V, T -> pressure P is determined\", 24, 52, { color: H.colors.sub, size: 13 });\n// container box scaled by V\nconst boxX = 80, boxY = 360;\nconst boxW = Math.min(420, 120 + V*8000);\nconst boxH = 220;\nH.rect(boxX, boxY-boxH, boxW, boxH, { stroke: H.colors.sub, width: 3 });\n// gas particles: count ~ n, speed ~ sqrt(T)\nconst nPart = Math.min(40, Math.max(4, Math.round(n*12)));\nconst speed = 40 * Math.sqrt(T/300);\nfor (let i=0;i P = nRT/V = \" + Pkpa.toFixed(1) + \" kPa = \" + (Pgas/101325).toFixed(2) + \" atm\", 24, 116, { color: H.colors.good, size: 13 });\n" + }, + { + "id": "apphys-kinetic-theory", + "area": "AP Physics", + "topic": "Kinetic theory / rms speed", + "title": "Kinetic theory: v_rms = sqrt(3kT/m)", + "equation": "v_rms = sqrt(3*k*T/m), (1/2)m*v_rms^2 = (3/2)kT", + "keywords": [ + "kinetic theory", + "rms speed", + "root mean square speed", + "v_rms = sqrt(3kt/m)", + "maxwell-boltzmann", + "average kinetic energy", + "temperature and speed", + "ideal gas molecules", + "boltzmann constant", + "ap physics" + ], + "explanation": "Kinetic theory links the microscopic motion of gas molecules to the macroscopic temperature: the average translational kinetic energy of a molecule is (3/2)kT, so the root-mean-square speed is v_rms = sqrt(3kT/m). Because v_rms grows as the square root of T, doubling the absolute temperature multiplies speeds by only sqrt(2), and lighter molecules (smaller m) move faster at the same T. The animation plots the Maxwell-Boltzmann speed distribution for N2; raising T shifts the whole curve to the right and broadens it, and the dashed line marks v_rms. On the AP exam this appears as ranking speeds of different gases at one temperature, or computing the factor by which v_rms changes when T changes — remember T must be in kelvin.", + "bullets": [ + "Average KE per molecule is (3/2)kT — it depends ONLY on temperature.", + "v_rms = sqrt(3kT/m): square-root, not linear, in T (always use kelvin).", + "At equal T, lighter molecules have larger v_rms (smaller m in the denominator).", + "Raising T shifts and widens the speed distribution; the dashed line tracks v_rms." + ], + "params": [ + { + "name": "T", + "label": "temperature T (K)", + "min": 50, + "max": 1000, + "step": 10, + "value": 300 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 1200, yMin: 0, yMax: 1.0 });\nv.grid(); v.axes();\nconst T = Math.max(P.T, 1);\nconst k = 1.380649e-23;\nconst m = 4.65e-26; // N2 molecule mass (kg)\nconst vrms = Math.sqrt(3 * k * T / m);\nconst a = m / (2 * k * T);\nconst peak = Math.sqrt(2 / a);\nconst fmax = 4 * Math.PI * Math.pow(a / Math.PI, 1.5) * peak * peak * Math.exp(-a * peak * peak);\nv.fn(s => {\n const f = 4 * Math.PI * Math.pow(a / Math.PI, 1.5) * s * s * Math.exp(-a * s * s);\n return f / (fmax * 1.05);\n}, { color: H.colors.accent, width: 3 });\nv.line(vrms, 0, vrms, 0.95, { color: H.colors.warn, width: 2, dash: [6, 5] });\nv.text(\"v_rms\", vrms, 0.99, { color: H.colors.warn, size: 12 });\nconst ph = (t * vrms / 600) % 1;\nconst sx = ph * 1100;\nconst sf = 4 * Math.PI * Math.pow(a / Math.PI, 1.5) * sx * sx * Math.exp(-a * sx * sx);\nv.dot(sx, sf / (fmax * 1.05), { r: 6, fill: H.colors.accent2 });\nH.text(\"Kinetic theory: v_rms = sqrt(3kT/m)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Speed distribution of N2 gas; raise T to shift it right.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"T = \" + T.toFixed(0) + \" K v_rms = \" + vrms.toFixed(0) + \" m/s\", 24, 76, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apphys-specific-heat", + "area": "AP Physics", + "topic": "Heat & specific heat", + "title": "Specific heat: Q = m c ΔT", + "equation": "Q = m * c * ΔT", + "keywords": [ + "specific heat", + "q = mc delta t", + "heat capacity", + "calorimetry", + "temperature change", + "joules of heat", + "specific heat of water", + "thermal energy", + "heating", + "ap physics" + ], + "explanation": "When no phase change occurs, the heat Q needed to change a substance's temperature is Q = mcΔT, where c is the specific heat (4186 J/kg·K for water). Q is proportional to both the mass and the temperature change, so heating twice the mass, or by twice the ΔT, needs twice the heat. A negative ΔT means heat is removed (the object cools). The animation fills a heating bar over each cycle and tallies the heat delivered; the totals readout shows the full Q for the chosen m and ΔT. On the AP exam this is the core calorimetry relation — often combined as Q_lost = Q_gained when two bodies reach thermal equilibrium.", + "bullets": [ + "Q = mcΔT applies only while temperature is changing (no melting/boiling).", + "Q scales linearly with BOTH mass m and temperature change ΔT.", + "c for water is 4186 J/kg·K — large, so water resists temperature change.", + "Negative ΔT → negative Q: heat is being removed and the sample cools." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "dT", + "label": "temp change ΔT (K)", + "min": -50, + "max": 100, + "step": 5, + "value": 40 + } + ], + "code": "H.background();\nconst m = Math.max(P.m, 0);\nconst dT = P.dT;\nconst c = 4186; // water, J/(kg*K)\nconst Q = m * c * dT;\nconst barX = 120, barY = 200, barW = 660, barH = 60;\nH.rect(barX, barY, barW, barH, { stroke: H.colors.axis, width: 2, radius: 8 });\nconst frac = (t % 4) / 4;\nconst hot = dT >= 0;\nconst fillW = barW * frac * (hot ? 1 : 0.6);\nH.rect(barX, barY, Math.max(fillW, 0.001), barH, { fill: hot ? H.colors.accent2 : H.colors.accent, radius: 8 });\nH.text(\"Q = m c ΔT\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Heat to change a mass of water by ΔT (c = 4186 J/kg·K).\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"m = \" + m.toFixed(1) + \" kg ΔT = \" + dT.toFixed(0) + \" K\", 24, 76, { color: H.colors.sub, size: 13 });\nconst Qpartial = Q * frac;\nH.text(\"Q delivered = \" + (Qpartial / 1000).toFixed(1) + \" kJ\", barX, barY - 18, { color: H.colors.good, size: 14, weight: 700 });\nH.text(\"Total Q = \" + (Q / 1000).toFixed(1) + \" kJ\", barX, barY + barH + 30, { color: H.colors.ink, size: 15, weight: 700 });\nconst thx = barX + barW + 40;\nH.line(thx, barY - 10, thx, barY + barH + 10, { color: H.colors.sub, width: 3 });\nconst merc = barY + barH + 10 - (barH + 20) * frac;\nH.circle(thx, merc, 7, { fill: H.colors.warn });" + }, + { + "id": "apphys-latent-heat", + "area": "AP Physics", + "topic": "Latent heat / heating curve", + "title": "Latent heat: Q = mL (plateaus on the heating curve)", + "equation": "Q = m*L (phase change), Q = m*c*ΔT (single phase)", + "keywords": [ + "latent heat", + "heat of fusion", + "heat of vaporization", + "q = ml", + "heating curve", + "phase change", + "melting and boiling", + "plateau temperature", + "specific heat", + "ap physics" + ], + "explanation": "A heating curve plots temperature against heat added. Within a single phase the slope follows Q = mcΔT, but during a phase change the temperature stays constant (a plateau) while heat goes entirely into breaking bonds: Q = mL_f to melt and Q = mL_v to boil. For water L_f = 334 kJ/kg and L_v = 2256 kJ/kg, so the boiling plateau is much longer than the melting one. The animation follows 1 kg of water from −20 °C upward; the two flat steps are melting at 0 °C and boiling at 100 °C, and the marker tracks T for the heat you add. On the AP exam, multi-step problems require summing Q across each ramp and plateau separately — forgetting the latent-heat terms is the classic mistake.", + "bullets": [ + "Flat segments = phase changes: temperature is fixed while Q = mL is absorbed.", + "Sloped segments use Q = mcΔT; slope is steeper where c is smaller.", + "Boiling plateau (L_v = 2256 kJ/kg) is far longer than melting (L_f = 334 kJ/kg).", + "Total heat = sum of every ramp AND every plateau in the path." + ], + "params": [ + { + "name": "Q", + "label": "heat added Q (kJ)", + "min": 0, + "max": 3000, + "step": 25, + "value": 800 + } + ], + "code": "H.background();\nconst c_ice = 2090, c_water = 4186, c_steam = 2010;\nconst Lf = 334000, Lv = 2256000;\nconst mass = 1;\nconst e1 = c_ice * mass * 20;\nconst e2 = Lf * mass;\nconst e3 = c_water * mass * 100;\nconst e4 = Lv * mass;\nconst e5 = c_steam * mass * 30;\nconst tot = e1 + e2 + e3 + e4 + e5;\nfunction tempAt(q) {\n if (q <= e1) return -20 + (q / e1) * 20;\n q -= e1;\n if (q <= e2) return 0;\n q -= e2;\n if (q <= e3) return (q / e3) * 100;\n q -= e3;\n if (q <= e4) return 100;\n q -= e4;\n return 100 + Math.min(q / e5, 1) * 30;\n}\nconst xMaxkJ = tot / 1000;\nconst v = H.plot2d({ xMin: 0, xMax: xMaxkJ, yMin: -30, yMax: 140 });\nv.grid(); v.axes();\nconst pts = [];\nfor (let i = 0; i <= 200; i++) {\n const q = (i / 200) * tot;\n pts.push([q / 1000, tempAt(q)]);\n}\nv.path(pts, { color: H.colors.accent, width: 3 });\nconst qAdded = Math.min(Math.max(P.Q, 0), xMaxkJ) * 1000;\nconst sweep = (0.5 + 0.5 * Math.sin(t * 0.7)) * qAdded;\nv.dot(sweep / 1000, tempAt(sweep), { r: 6, fill: H.colors.accent2 });\nv.dot(qAdded / 1000, tempAt(qAdded), { r: 7, fill: H.colors.warn });\nH.text(\"Heating curve: plateaus at phase changes (Q = mL)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"1 kg water; flat steps = melting (L_f) and boiling (L_v).\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Q = \" + P.Q.toFixed(0) + \" kJ T = \" + tempAt(qAdded).toFixed(0) + \" °C\", 24, 76, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apphys-first-law-thermo", + "area": "AP Physics", + "topic": "First law of thermodynamics", + "title": "First law: ΔU = Q − W", + "equation": "ΔU = Q − W (Q = heat added to gas, W = work done BY gas)", + "keywords": [ + "first law of thermodynamics", + "delta u = q - w", + "internal energy", + "heat and work", + "work done by gas", + "conservation of energy", + "thermodynamic system", + "sign convention", + "isothermal adiabatic", + "ap physics" + ], + "explanation": "The first law of thermodynamics is energy conservation for a gas: ΔU = Q − W, where Q is heat added to the gas and W is the work done BY the gas. Heat in raises internal energy, while work done by the gas (expansion) lowers it. Internal energy U depends only on temperature, so for an ideal gas a positive ΔU means the gas warms. Special cases follow directly: isothermal → ΔU = 0 so Q = W; adiabatic → Q = 0 so ΔU = −W; isochoric → W = 0 so ΔU = Q. The animation shows three bars — heat in, work out, and the resulting ΔU — so you can see how the signs combine. Watch the AP sign convention: the equation ΔU = Q − W assumes W is work done by the gas (some texts write ΔU = Q + W for work done on the gas).", + "bullets": [ + "ΔU = Q − W is just energy conservation for the gas.", + "Q > 0 adds energy; W > 0 (gas expands, work BY gas) removes it.", + "ΔU > 0 ⇒ internal energy and temperature rise; ΔU < 0 ⇒ gas cools.", + "Isothermal: ΔU = 0 → Q = W; Adiabatic: Q = 0 → ΔU = −W; Isochoric: W = 0 → ΔU = Q." + ], + "params": [ + { + "name": "Q", + "label": "heat in Q (J)", + "min": -150, + "max": 150, + "step": 10, + "value": 100 + }, + { + "name": "W", + "label": "work by gas W (J)", + "min": -150, + "max": 150, + "step": 10, + "value": 60 + } + ], + "code": "H.background();\nconst Q = P.Q;\nconst Wby = P.W;\nconst dU = Q - Wby;\nconst baseY = 420;\nconst cx = H.W / 2;\nconst scale = 1.2;\nfunction bar(x, label, val, col) {\n const bw = 90;\n const len = val * scale;\n const grow = Math.min(t / 1.5, 1);\n const gl = len * grow;\n const top = gl >= 0 ? baseY - gl : baseY;\n H.rect(x - bw / 2, top, bw, Math.abs(gl) + 0.001, { fill: col, radius: 6 });\n H.text(label, x, baseY + 24, { color: H.colors.sub, size: 13, align: \"center\" });\n H.text(val.toFixed(0) + \" J\", x, (gl >= 0 ? top - 10 : baseY + Math.abs(gl) + 18), { color: H.colors.ink, size: 13, align: \"center\", weight: 700 });\n}\nH.line(120, baseY, 780, baseY, { color: H.colors.axis, width: 2 });\nbar(240, \"Q (heat in)\", Q, H.colors.warn);\nbar(450, \"W (work by gas)\", Wby, H.colors.accent2);\nbar(660, \"ΔU = Q - W\", dU, dU >= 0 ? H.colors.good : H.colors.violet);\nH.text(\"First law: ΔU = Q − W\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Q adds energy; work done BY the gas removes it.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Q = \" + Q.toFixed(0) + \" J W = \" + Wby.toFixed(0) + \" J ΔU = \" + dU.toFixed(0) + \" J\", 24, 76, { color: H.colors.sub, size: 13 });\nH.text(dU >= 0 ? \"Internal energy rises → gas warms\" : \"Internal energy falls → gas cools\", cx, 470, { color: H.colors.sub, size: 13, align: \"center\" });" + }, + { + "id": "apphys-pv-diagram", + "area": "AP Physics", + "topic": "PV diagram & work", + "title": "PV diagram: W = ∫P dV (area under the curve)", + "equation": "W = ∫ P dV (isothermal: W = nRT*ln(V2/V1))", + "keywords": [ + "pv diagram", + "work done by gas", + "area under the curve", + "w = integral p dv", + "isothermal process", + "pressure volume graph", + "expansion compression", + "thermodynamic work", + "p = nrt/v", + "ap physics" + ], + "explanation": "On a pressure-volume (PV) diagram, the work done BY a gas equals the area under the process curve: W = ∫P dV. Expansion (volume increasing) gives positive work, compression gives negative work, and a constant-volume step does zero work. For an isothermal process P = nRT/V, so the curve is a hyperbola and W = nRT·ln(V2/V1). The animation shades the area under an isothermal curve as the gas expands from V1 to the volume V2 you choose, with a live work readout. On the AP exam you are routinely asked to read off or compute the area for each leg of a cycle and to recognize that the net work of a closed cycle equals the enclosed loop area.", + "bullets": [ + "Work done BY the gas = area under the P–V curve, W = ∫P dV.", + "Expansion (V increases) → W > 0; compression → W < 0; constant V → W = 0.", + "Isothermal curve is a hyperbola P = nRT/V; W = nRT·ln(V2/V1).", + "For a closed cycle, net work equals the area enclosed by the loop." + ], + "params": [ + { + "name": "V", + "label": "final volume V2", + "min": 1.5, + "max": 5, + "step": 0.25, + "value": 3 + } + ], + "code": "H.background();\nconst nRT = 2000;\nconst V1 = 1.0;\nconst V2 = Math.max(P.V, V1 + 0.1);\nconst v = H.plot2d({ xMin: 0, xMax: 6, yMin: 0, yMax: 2200 });\nv.grid(); v.axes();\nconst Pf = V => nRT / Math.max(V, 0.05);\nv.fn(Pf, { color: H.colors.accent, width: 3 });\nconst sweep = V1 + (V2 - V1) * (0.5 + 0.5 * Math.sin(t * 0.6));\nconst N = 40;\nfor (let i = 0; i < N; i++) {\n const Va = V1 + (sweep - V1) * (i / N);\n v.line(Va, 0, Va, Pf(Va), { color: H.colors.accent2, width: 2 });\n}\nv.dot(V1, Pf(V1), { r: 6, fill: H.colors.good });\nv.dot(V2, Pf(V2), { r: 6, fill: H.colors.warn });\nv.dot(sweep, Pf(sweep), { r: 6, fill: H.colors.violet });\nconst Wfull = nRT * Math.log(V2 / V1);\nconst Wsweep = nRT * Math.log(sweep / V1);\nH.text(\"PV diagram: W = ∫P dV (area under curve)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Isothermal expansion; shaded area is work done by the gas.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"V1 = \" + V1.toFixed(1) + \" V2 = \" + V2.toFixed(1) + \" W = \" + Wfull.toFixed(0) + \" J\", 24, 76, { color: H.colors.sub, size: 13 });\nv.text(\"W ≈ \" + Wsweep.toFixed(0) + \" J\", sweep, Pf(sweep) + 200, { color: H.colors.violet, size: 12 });" + }, + { + "id": "apphys-heat-engine", + "area": "AP Physics", + "topic": "Heat-engine efficiency", + "title": "Heat-engine efficiency: e = 1 - Qc/Qh = W/Qh", + "equation": "e = 1 - Qc/Qh = W/Qh, W = Qh - Qc", + "keywords": [ + "heat engine", + "thermal efficiency", + "e = 1 - qc/qh", + "first law", + "work done by engine", + "qh qc", + "hot cold reservoir", + "thermodynamics", + "carnot cycle", + "ap physics", + "ap physics 2", + "energy flow" + ], + "explanation": "A cyclic heat engine absorbs heat Qh from a hot reservoir, dumps Qc to a cold reservoir, and outputs net work W = Qh - Qc (energy conservation, since the gas returns to its starting state so Delta U = 0 per cycle). Efficiency is e = W/Qh = 1 - Qc/Qh, always between 0 and 1 because some heat must be exhausted (second law). The animation shows Qh flowing in from the hot reservoir, W tapped off as useful work, and Qc rejected to the cold reservoir, with the efficiency bar filling to e. On the AP exam you compute W and e from a Qh, Qc table or a PV-cycle and contrast actual efficiency with the Carnot limit.", + "bullets": [ + "Per cycle Delta U = 0, so W = Qh - Qc exactly (first law).", + "e = W/Qh = 1 - Qc/Qh; it can never reach 1 because Qc > 0.", + "More rejected heat Qc means lower efficiency for the same Qh.", + "The flowing dots show energy IN (Qh), OUT as work (W), and exhausted (Qc)." + ], + "params": [ + { + "name": "Qh", + "label": "heat in Qh (J)", + "min": 100, + "max": 1000, + "step": 10, + "value": 600 + }, + { + "name": "Qc", + "label": "heat out Qc (J)", + "min": 50, + "max": 900, + "step": 10, + "value": 400 + } + ], + "code": "H.background();\nconst Qh = Math.max(Math.abs(P.Qh), 1);\nconst Qc = Math.min(Math.abs(P.Qc), Qh);\nconst W = Qh - Qc;\nconst e = 1 - Qc / Qh;\nH.text(\"Heat-engine efficiency: e = 1 - Qc/Qh = W/Qh\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"A cyclic engine takes in Qh, dumps Qc, does work W = Qh - Qc\", 24, 52, { color: H.colors.sub, size: 13 });\nconst cx = 450, cy = 300, R = 70;\nconst ph = (t * 0.8) % H.TAU;\nconst eng = Math.abs(Math.sin(ph));\nH.rect(cx - R, cy - R, 2 * R, 2 * R, { fill: H.colors.panel, stroke: H.colors.axis, width: 2, radius: 10 });\nH.text(\"ENGINE\", cx, cy - 8, { color: H.colors.ink, size: 14, align: \"center\", weight: 700 });\nH.text(\"W = \" + W.toFixed(0) + \" J\", cx, cy + 14, { color: H.colors.good, size: 13, align: \"center\" });\nconst hotY = 110, coldY = 490;\nH.rect(cx - 120, hotY - 22, 240, 30, { fill: \"#3a1f1f\", stroke: H.colors.warn, width: 2, radius: 6 });\nH.text(\"HOT reservoir Th\", cx, hotY - 4, { color: H.colors.warn, size: 13, align: \"center\", weight: 700 });\nH.rect(cx - 120, coldY - 8, 240, 30, { fill: \"#1f2c3a\", stroke: H.colors.accent, width: 2, radius: 6 });\nH.text(\"COLD reservoir Tc\", cx, coldY + 14, { color: H.colors.accent, size: 13, align: \"center\", weight: 700 });\n// Qh in (animated flow downward)\nconst fy1 = hotY + 8 + ((t * 60) % (cy - R - hotY - 8));\nH.arrow(cx, hotY + 8, cx, cy - R, { color: H.colors.warn, width: 3 });\nH.circle(cx, fy1, 5, { fill: H.colors.warn });\nH.text(\"Qh = \" + Qh.toFixed(0) + \" J\", cx - 150, (hotY + cy) / 2, { color: H.colors.warn, size: 13, align: \"right\" });\n// Qc out\nconst fy2 = cy + R + ((t * 60) % (coldY - 8 - cy - R));\nH.arrow(cx, cy + R, cx, coldY - 8, { color: H.colors.accent, width: 3 });\nH.circle(cx, fy2, 5, { fill: H.colors.accent });\nH.text(\"Qc = \" + Qc.toFixed(0) + \" J\", cx + 150, (coldY + cy) / 2, { color: H.colors.accent, size: 13, align: \"left\" });\n// W out arrow to side\nconst wx = cx + R + 6 + eng * 40;\nH.arrow(cx + R, cy, cx + R + 90, cy, { color: H.colors.good, width: 3 });\nH.circle(wx, cy, 5, { fill: H.colors.good });\n// efficiency bar\nconst bx = 80, by = 470, bw = 200, bh = 22;\nH.rect(bx, by, bw, bh, { stroke: H.colors.axis, width: 1, radius: 4 });\nH.rect(bx, by, bw * e, bh, { fill: H.colors.good, radius: 4 });\nH.text(\"e = \" + (100 * e).toFixed(1) + \" %\", bx, by - 8, { color: H.colors.good, size: 14, weight: 700 });\n" + }, + { + "id": "apphys-carnot", + "area": "AP Physics", + "topic": "Carnot efficiency", + "title": "Carnot efficiency: e = 1 - Tc/Th", + "equation": "e_Carnot = 1 - Tc/Th (Tc, Th in kelvin)", + "keywords": [ + "carnot efficiency", + "e = 1 - tc/th", + "maximum efficiency", + "reversible engine", + "kelvin temperature", + "second law", + "carnot cycle", + "absolute temperature", + "ideal engine", + "thermodynamics", + "ap physics", + "ap physics 2" + ], + "explanation": "The Carnot efficiency e = 1 - Tc/Th is the maximum efficiency ANY engine running between a cold reservoir at Tc and a hot reservoir at Th can reach; only a reversible (Carnot) cycle achieves it. Temperatures MUST be absolute (kelvin) — using Celsius gives a wrong answer, a classic AP trap. Raising Th or lowering Tc increases e, but e = 1 is impossible because that needs Tc = 0 K. The curve plots e versus Th for a fixed Tc, and the orange dot sweeps along it so you see efficiency climb as the hot reservoir gets hotter.", + "bullets": [ + "Use KELVIN: Tc/Th must be a ratio of absolute temperatures.", + "e rises as Th increases or Tc decreases; the curve approaches 1 but never reaches it.", + "No real engine beats the Carnot limit for the same Tc, Th (second law).", + "The sweeping dot reads off e at each operating Th." + ], + "params": [ + { + "name": "Tc", + "label": "cold temp Tc (K)", + "min": 100, + "max": 500, + "step": 10, + "value": 300 + }, + { + "name": "Th", + "label": "hot temp Th (K)", + "min": 300, + "max": 1000, + "step": 10, + "value": 600 + } + ], + "code": "H.background();\nconst Th = Math.max(P.Th, 1);\nconst Tc = Math.max(Math.min(P.Tc, Th), 0);\nconst e = 1 - Tc / Th;\nH.text(\"Carnot efficiency: e = 1 - Tc/Th\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Maximum possible efficiency of any engine between Tc and Th (absolute temps, in K)\", 24, 52, { color: H.colors.sub, size: 13 });\nconst v = H.plot2d({ xMin: 0, xMax: 1000, yMin: 0, yMax: 1, pad: 56 });\nv.grid(); v.axes();\n// e vs Th for fixed Tc\nv.fn(x => (x > Tc ? 1 - Tc / x : NaN), { color: H.colors.accent, width: 3 });\n// sweeping operating point in Th\nconst Thsweep = Tc + 50 + (Th - Tc - 50 > 0 ? (Th - Tc - 50) : 0) * (0.5 + 0.5 * Math.sin(t * 0.7));\nconst esweep = 1 - Tc / Thsweep;\nv.dot(Thsweep, esweep, { r: 7, fill: H.colors.warn });\nv.line(Thsweep, 0, Thsweep, esweep, { color: H.colors.warn, width: 1, dash: [4, 4] });\nv.dot(Th, e, { r: 6, fill: H.colors.good });\nv.text(\"Tc = \" + Tc.toFixed(0) + \" K (fixed)\", 120, 0.12, { color: H.colors.sub, size: 12 });\nH.text(\"x-axis: Th (K) y-axis: efficiency e\", 24, 78, { color: H.colors.sub, size: 12 });\nH.text(\"e = 1 - \" + Tc.toFixed(0) + \"/\" + Th.toFixed(0) + \" = \" + (100 * e).toFixed(1) + \" %\", 24, 104, { color: H.colors.good, size: 14, weight: 700 });\nH.text(\"sweep Th = \" + Thsweep.toFixed(0) + \" K -> e = \" + (100 * esweep).toFixed(1) + \" %\", 24, 126, { color: H.colors.warn, size: 13 });\n" + }, + { + "id": "apphys-coulombs-law", + "area": "AP Physics", + "topic": "Coulomb law", + "title": "Coulomb's law: F = k q1 q2 / r^2", + "equation": "F = k * q1 * q2 / r^2, k = 8.99e9 N*m^2/C^2", + "keywords": [ + "coulomb's law", + "f = k q1 q2 / r^2", + "electrostatic force", + "like charges repel", + "opposite charges attract", + "inverse square law", + "coulomb constant", + "point charges", + "electric force", + "newton's third law", + "ap physics", + "ap physics 2" + ], + "explanation": "Coulomb's law gives the electrostatic force between two point charges: F = k*q1*q2/r^2 with k = 8.99e9 N*m^2/C^2. The force is along the line joining the charges, repulsive when q1*q2 > 0 (like signs) and attractive when q1*q2 < 0 (opposite signs); the two forces are equal and opposite by Newton's third law. Because of the 1/r^2 dependence, doubling r cuts the force to one quarter — a frequent AP calculation. The animation shows the two charges, color-coded by sign, with equal-and-opposite force arrows that flip direction as you change the signs and a live F readout that scales with the charges and distance.", + "bullets": [ + "F is along the line of centers; same sign -> repel, opposite -> attract.", + "The forces on the two charges are EQUAL and OPPOSITE (Newton's third law).", + "Inverse-square: double r and F drops to 1/4; halve r and F goes up 4x.", + "Sign of q1*q2 sets the arrow direction; magnitude sets the F readout (in N)." + ], + "params": [ + { + "name": "q1", + "label": "charge q1 (uC)", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "q2", + "label": "charge q2 (uC)", + "min": -5, + "max": 5, + "step": 0.5, + "value": -2 + }, + { + "name": "r", + "label": "separation r (m)", + "min": 0.2, + "max": 5, + "step": 0.1, + "value": 1.5 + } + ], + "code": "H.background();\nconst k = 8.99e9;\nconst q1 = P.q1; // microcoulombs\nconst q2 = P.q2;\nconst r = Math.max(P.r, 0.1); // meters\n// Force magnitude in newtons: q in microC -> 1e-6\nconst F = k * Math.abs(q1 * q2) * 1e-12 / (r * r);\nconst repel = q1 * q2 > 0;\nH.text(\"Coulomb's law: F = k q1 q2 / r^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Like charges repel, opposite attract; F falls off as 1/r^2 (k = 8.99e9 N m^2/C^2)\", 24, 52, { color: H.colors.sub, size: 13 });\nconst cy = 300;\n// map r (0.1..5 m) to pixel separation\nconst sepMax = 320;\nconst rNorm = Math.min(r / 5, 1);\nconst sep = 60 + sepMax * rNorm;\nconst cx = 450;\n// small oscillation showing the push/pull tendency\nconst osc = 8 * Math.sin(t * 1.6);\nconst x1 = cx - sep / 2 - (repel ? -osc : osc);\nconst x2 = cx + sep / 2 + (repel ? -osc : osc);\n// charge 1\nconst c1 = q1 >= 0 ? H.colors.warn : H.colors.accent;\nconst c2 = q2 >= 0 ? H.colors.warn : H.colors.accent;\nH.circle(x1, cy, 22, { fill: c1, stroke: H.colors.ink, width: 2 });\nH.text((q1 >= 0 ? \"+\" : \"-\"), x1, cy, { color: \"#0e1525\", size: 22, align: \"center\", baseline: \"middle\", weight: 700 });\nH.text(\"q1 = \" + q1.toFixed(1) + \" uC\", x1, cy - 36, { color: c1, size: 13, align: \"center\" });\nH.circle(x2, cy, 22, { fill: c2, stroke: H.colors.ink, width: 2 });\nH.text((q2 >= 0 ? \"+\" : \"-\"), x2, cy, { color: \"#0e1525\", size: 22, align: \"center\", baseline: \"middle\", weight: 700 });\nH.text(\"q2 = \" + q2.toFixed(1) + \" uC\", x2, cy - 36, { color: c2, size: 13, align: \"center\" });\n// force arrows on each charge (Newton's third law: equal & opposite)\nconst aL = 50;\nif (repel) {\n H.arrow(x1, cy + 40, x1 - aL, cy + 40, { color: H.colors.good, width: 3 });\n H.arrow(x2, cy + 40, x2 + aL, cy + 40, { color: H.colors.good, width: 3 });\n H.text(\"REPEL\", cx, cy + 70, { color: H.colors.good, size: 13, align: \"center\", weight: 700 });\n} else {\n H.arrow(x1, cy + 40, x1 + aL, cy + 40, { color: H.colors.violet, width: 3 });\n H.arrow(x2, cy + 40, x2 - aL, cy + 40, { color: H.colors.violet, width: 3 });\n H.text(\"ATTRACT\", cx, cy + 70, { color: H.colors.violet, size: 13, align: \"center\", weight: 700 });\n}\n// distance marker\nH.line(x1, cy - 60, x2, cy - 60, { color: H.colors.sub, width: 1, dash: [4, 4] });\nH.text(\"r = \" + r.toFixed(2) + \" m\", cx, cy - 68, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(\"F = \" + F.toExponential(2) + \" N (on each charge)\", 24, 96, { color: H.colors.good, size: 14, weight: 700 });\n" + }, + { + "id": "apphys-electric-field-point", + "area": "AP Physics", + "topic": "Electric field of a point charge", + "title": "Field of a point charge: E = k q / r^2", + "equation": "E = k * q / r^2, k = 8.99e9 N*m^2/C^2 (E in N/C)", + "keywords": [ + "electric field", + "e = kq/r^2", + "point charge field", + "field direction", + "field arrows", + "n/c newtons per coulomb", + "inverse square", + "radial field", + "positive negative charge", + "ap physics", + "ap physics 2", + "test charge" + ], + "explanation": "A point charge q sets up a radial electric field of magnitude E = k*q/r^2 (N/C, k = 8.99e9). The field points radially OUTWARD from a positive charge and radially INWARD toward a negative charge — this is the direction of the force a positive test charge would feel. E falls off as 1/r^2, so the arrows shrink rapidly with distance, exactly as the animation shows; reversing the sign of q flips every arrow. On the AP exam you use E = kq/r^2 to find field strength and then F = qE to get the force on a charge placed in that field.", + "bullets": [ + "Arrows point AWAY from +q and TOWARD -q (direction a + test charge is pushed).", + "Magnitude E = kq/r^2 weakens as 1/r^2 — outer arrows are much shorter.", + "Flip the sign of q and the whole radial field reverses direction.", + "The orbiting test point reads its r and the resulting E in N/C." + ], + "params": [ + { + "name": "q", + "label": "charge q (uC)", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst k = 8.99e9;\nconst q = P.q; // microcoulombs, can be negative\nconst cx = 450, cy = 290;\nH.text(\"Electric field of a point charge: E = k q / r^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Field points AWAY from + charge, TOWARD - charge; E weakens as 1/r^2 (k = 8.99e9)\", 24, 52, { color: H.colors.sub, size: 13 });\nconst pos = q >= 0;\nconst cc = pos ? H.colors.warn : H.colors.accent;\n// radial field arrows at several radii and angles\nconst radii = [60, 110, 165, 225];\nconst nAng = 12;\nfor (let i = 0; i < nAng; i++) {\n const a = (i / nAng) * H.TAU;\n const dx = Math.cos(a), dy = Math.sin(a);\n for (let j = 0; j < radii.length; j++) {\n const rp = radii[j];\n // arrow length scales like 1/r^2 (normalized), with a floor for visibility\n const len = 10 + 900 / (rp * 0.45);\n const L = Math.min(len, 34);\n const bx = cx + dx * rp, by = cy + dy * rp;\n const sgn = pos ? 1 : -1;\n H.arrow(bx, by, bx + dx * L * sgn, by + dy * L * sgn, { color: cc, width: 2 });\n }\n}\n// animated test point riding outward/inward showing E magnitude\nconst ra = 70 + 130 * (0.5 + 0.5 * Math.sin(t * 0.7));\nconst ang = 0.5 * t;\nconst tx = cx + Math.cos(ang) * ra, ty = cy + Math.sin(ang) * ra;\nH.circle(tx, ty, 6, { fill: H.colors.good, stroke: H.colors.ink, width: 1 });\n// physical r in meters: map 70px..200px to 0.1..2 m\nconst rPx = ra;\nconst rM = 0.1 + (rPx - 70) / 130 * 1.9;\nconst E = k * Math.abs(q) * 1e-6 / (rM * rM);\n// central charge\nH.circle(cx, cy, 20, { fill: cc, stroke: H.colors.ink, width: 2 });\nH.text(pos ? \"+\" : \"-\", cx, cy, { color: \"#0e1525\", size: 22, align: \"center\", baseline: \"middle\", weight: 700 });\nH.text(\"q = \" + q.toFixed(1) + \" uC\", cx, cy - 32, { color: cc, size: 13, align: \"center\" });\nH.line(cx, cy, tx, ty, { color: H.colors.sub, width: 1, dash: [3, 3] });\nH.text(\"test charge: r = \" + rM.toFixed(2) + \" m\", 24, 96, { color: H.colors.good, size: 13 });\nH.text(\"E = \" + E.toExponential(2) + \" N/C\", 24, 118, { color: H.colors.good, size: 14, weight: 700 });\n" + }, + { + "id": "apphys-field-lines-dipole", + "area": "AP Physics", + "topic": "Field lines of a dipole", + "title": "Electric dipole field lines: + to - field map", + "equation": "E = sum of k*q_i / r_i^2 (vector); lines go from +q to -q", + "keywords": [ + "electric dipole", + "field lines", + "dipole field map", + "lines from + to -", + "field line density", + "superposition of fields", + "electric field pattern", + "equal opposite charges", + "field direction", + "ap physics", + "ap physics 2", + "streamlines" + ], + "explanation": "An electric dipole is a pair of equal and opposite charges; its total field is the vector sum (superposition) of the two point-charge fields. Field lines start on the positive charge and end on the negative charge, never cross, and are tangent to E everywhere; where they bunch together the field is strong, where they spread apart it is weak. The lines run nearly straight between the charges (fields add there) and curve outward to loop around. The animation traces these streamlines and sends markers from +q to -q to show field direction; adjusting the separation reshapes the pattern, illustrating why a dipole's far field is weaker than a single charge's.", + "bullets": [ + "Lines originate on +q and terminate on -q; they never cross each other.", + "Line tangent gives field direction; line density gives field strength.", + "Between the charges the two fields add, so lines are dense and nearly straight.", + "Marker dots flow + -> - to make the field direction visible." + ], + "params": [ + { + "name": "d", + "label": "half-separation d (units)", + "min": 0.4, + "max": 2.5, + "step": 0.1, + "value": 1.2 + } + ], + "code": "H.background();\nconst d = Math.max(P.d, 0.2);\nconst cx = 450, cy = 290;\nconst scale = 70;\nH.text(\"Field lines of an electric dipole\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Lines leave + and enter -; they never cross. Density shows field strength.\", 24, 52, { color: H.colors.sub, size: 13 });\nconst xp = cx - d * scale, xm = cx + d * scale;\nconst yp = cy, ym = cy;\nfunction field(px, py) {\n let ex = 0, ey = 0;\n let rx = px - xp, ry = py - yp, r2 = rx * rx + ry * ry + 25, r = Math.sqrt(r2);\n ex += rx / (r2 * r); ey += ry / (r2 * r);\n rx = px - xm; ry = py - ym; r2 = rx * rx + ry * ry + 25; r = Math.sqrt(r2);\n ex -= rx / (r2 * r); ey -= ry / (r2 * r);\n return [ex, ey];\n}\nconst nLines = 12, startR = 16;\nfor (let i = 0; i < nLines; i++) {\n const a0 = (i / nLines) * H.TAU + 0.0001;\n let px = xp + Math.cos(a0) * startR;\n let py = yp + Math.sin(a0) * startR;\n const pts = [[px, py]];\n const step = 5;\n for (let s = 0; s < 220; s++) {\n const f = field(px, py);\n const mag = Math.hypot(f[0], f[1]);\n if (mag < 1e-12) break;\n px += (f[0] / mag) * step;\n py += (f[1] / mag) * step;\n pts.push([px, py]);\n if (Math.hypot(px - xm, py - ym) < startR) break;\n if (px < -50 || px > H.W + 50 || py < -50 || py > H.H + 50) break;\n }\n H.path(pts, { color: H.colors.accent, width: 1.5 });\n const m = Math.floor(((t * 0.5 + i / nLines) % 1) * (pts.length - 1));\n if (pts[m]) H.circle(pts[m][0], pts[m][1], 3, { fill: H.colors.yellow });\n}\nH.circle(xp, yp, 16, { fill: H.colors.warn, stroke: H.colors.ink, width: 2 });\nH.text(\"+\", xp, yp, { color: \"#0e1525\", size: 20, align: \"center\", baseline: \"middle\", weight: 700 });\nH.circle(xm, ym, 16, { fill: \"#4a90d9\", stroke: H.colors.ink, width: 2 });\nH.text(\"-\", xm, ym, { color: \"#0e1525\", size: 22, align: \"center\", baseline: \"middle\", weight: 700 });\nH.text(\"separation 2d = \" + (2 * d).toFixed(2) + \" units\", 24, 96, { color: H.colors.good, size: 14, weight: 700 });\nH.text(\"yellow dots trace +q -> -q along the field lines\", 24, 118, { color: H.colors.sub, size: 12 });\n" + }, + { + "id": "apphys-gauss-law", + "area": "AP Physics", + "topic": "Gauss law (flux)", + "title": "Gauss's law: Φ = q_enc / ε₀", + "equation": "Phi = q_enc / epsilon_0", + "keywords": [ + "gauss law", + "gauss's law", + "electric flux", + "flux", + "q_enc", + "enclosed charge", + "epsilon naught", + "gaussian surface", + "closed surface", + "field lines", + "ap physics", + "coulomb" + ], + "explanation": "Gauss's law states that the net electric flux through any closed (Gaussian) surface equals the charge enclosed divided by the permittivity of free space, Φ = q_enc/ε₀, with ε₀ = 8.85e-12 C²/(N·m²). The flux depends ONLY on the enclosed charge — not on the surface's size or shape, and charges outside contribute zero net flux. Positive enclosed charge gives outward flux (field lines exit), negative gives inward flux. On the AP exam, Gauss's law is used to find E for symmetric charge distributions (sphere, line, plane); the animation shows that growing or shrinking the Gaussian sphere never changes Φ, which is fixed by q_enc.", + "bullets": [ + "Φ counts net field lines through the purple Gaussian surface.", + "Φ = q_enc/ε₀ is fixed by enclosed charge — surface radius pulses but Φ is unchanged.", + "Sign of q flips the arrows: + charge → outward flux, − charge → inward.", + "ε₀ = 8.85×10⁻¹² C²/(N·m²) sets the proportionality." + ], + "params": [ + { + "name": "q", + "label": "enclosed charge q (C)", + "min": -5e-09, + "max": 5e-09, + "step": 5e-10, + "value": 3e-09 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -3, xMax: 3, yMin: -3, yMax: 3 });\nv.grid(); v.axes();\nconst eps0 = 8.854e-12;\nconst q = P.q;\nconst flux = q / eps0;\nconst R = 1.6 + 0.25 * Math.sin(t * 0.9);\nv.circle(0, 0, R * (v.box.w) / 6, { stroke: H.colors.violet, width: 2, fill: \"rgba(196,167,255,0.08)\" });\nv.dot(0, 0, { r: 9, fill: q >= 0 ? H.colors.warn : H.colors.accent });\nv.text((q >= 0 ? \"+\" : \"\") + (q * 1e9).toFixed(1) + \" nC\", 0.18, 0.42, { color: H.colors.ink, size: 13 });\nconst n = 12;\nfor (let i = 0; i < n; i++) {\n const a = (i / n) * Math.PI * 2;\n const phase = (t * 0.6) % 1;\n const r0 = 0.35;\n const r1 = R + 0.9;\n const dir = q >= 0 ? 1 : -1;\n const ax = Math.cos(a), ay = Math.sin(a);\n const tail = r0 + (r1 - r0) * (phase);\n const head = r0 + (r1 - r0) * Math.min(1, phase + 0.18);\n if (dir > 0) v.arrow(ax * tail, ay * tail, ax * head, ay * head, { color: H.colors.accent2, width: 2 });\n else v.arrow(ax * head, ay * head, ax * tail, ay * tail, { color: H.colors.accent2, width: 2 });\n}\nH.text(\"Gauss's law: Φ = q_enc / ε₀\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Flux through a closed surface depends ONLY on enclosed charge\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"q_enc = \" + (q * 1e9).toFixed(1) + \" nC\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"Φ = q/ε₀ = \" + flux.toExponential(2) + \" N·m²/C\", 24, 98, { color: H.colors.good, size: 13 });" + }, + { + "id": "apphys-electric-potential", + "area": "AP Physics", + "topic": "Electric potential", + "title": "Electric potential of a point charge: V = k q / r", + "equation": "V = k*q / r", + "keywords": [ + "electric potential", + "potential", + "V = kq/r", + "point charge potential", + "voltage", + "scalar potential", + "coulomb constant", + "volts", + "kq over r", + "ap physics", + "potential vs distance", + "k" + ], + "explanation": "The electric potential a distance r from a point charge is V = kq/r, where k = 8.99e9 N·m²/C². Unlike the field, potential is a SCALAR: its sign follows the sign of q (positive charge → positive V, negative charge → negative V), and it falls off as 1/r (more gently than the 1/r² field). V is the work per unit charge needed to bring a test charge from infinity, where V = 0. On the AP exam this curve underlies energy problems (U = qV) and the relation E = −dV/dr; the animation sweeps a probe along r so you watch V climb steeply near the charge and flatten far away.", + "bullets": [ + "V is a scalar that falls off as 1/r — steep near the charge, flat far out.", + "Sign of V follows the sign of q (no direction, just magnitude and sign).", + "The dashed drop shows V at the probe's current distance r.", + "Reference V = 0 is at r → ∞; k = 8.99×10⁹ N·m²/C²." + ], + "params": [ + { + "name": "q", + "label": "charge q (C)", + "min": -5e-09, + "max": 5e-09, + "step": 5e-10, + "value": 3e-09 + } + ], + "code": "H.background();\nconst k = 8.99e9;\nconst q = P.q;\nconst v = H.plot2d({ xMin: 0, xMax: 5, yMin: -3, yMax: 3 });\nv.grid(); v.axes();\nconst Vof = r => (k * q) / Math.max(r, 1e-9);\nv.fn(r => (r > 0.05 ? Vof(r) / 1000 : NaN), { color: H.colors.accent, width: 3 });\nconst rp = 0.4 + 2.2 * (0.5 + 0.5 * Math.sin(t * 0.7));\nconst Vp = Vof(rp);\nv.dot(rp, Vp / 1000, { r: 7, fill: H.colors.accent2 });\nv.line(rp, 0, rp, Vp / 1000, { color: H.colors.sub, width: 1, dash: [4, 4] });\nH.text(\"Electric potential: V = k q / r\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"V is a scalar; sign follows q. y-axis in kilovolts (kV)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"q = \" + (q * 1e9).toFixed(1) + \" nC\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + rp.toFixed(2) + \" m\", 24, 98, { color: H.colors.sub, size: 13 });\nH.text(\"V = \" + (Vp / 1000).toFixed(2) + \" kV\", 24, 118, { color: H.colors.good, size: 13 });" + }, + { + "id": "apphys-equipotential", + "area": "AP Physics", + "topic": "Equipotential lines", + "title": "Equipotentials of a point charge: V = k q / r", + "equation": "V = k*q / r (equipotential where r is constant)", + "keywords": [ + "equipotential", + "equipotential lines", + "equipotential surfaces", + "perpendicular to field", + "V = kq/r", + "no work along equipotential", + "field lines", + "contour", + "ap physics", + "potential map", + "perpendicular", + "scalar" + ], + "explanation": "An equipotential is a set of points at the same potential V; for a point charge V = kq/r, so equipotentials are concentric spheres (circles in cross-section) of constant r. The electric field is always PERPENDICULAR to equipotentials and points toward decreasing potential (down the steepest drop). Because V is constant along an equipotential, NO work is done moving a charge along one (W = qΔV = 0). On the AP exam, students sketch equipotentials orthogonal to field lines and read closer spacing as a stronger field; the animation shows purple equipotential circles and orange field arrows crossing them at right angles.", + "bullets": [ + "Purple circles are equipotentials — every point on one has the same V.", + "Orange field arrows cross every equipotential at 90° (E ⊥ equipotential).", + "Moving a charge along an equipotential does zero work (ΔV = 0).", + "Tighter spacing means a larger |E|; sign of q reverses the arrows." + ], + "params": [ + { + "name": "q", + "label": "charge q (C)", + "min": -5e-09, + "max": 5e-09, + "step": 5e-10, + "value": 3e-09 + } + ], + "code": "H.background();\nconst k = 8.99e9;\nconst q = P.q;\nconst v = H.plot2d({ xMin: -4, xMax: 4, yMin: -3, yMax: 3 });\nv.grid(); v.axes();\nconst cx = 0, cy = 0;\nconst baseV = k * Math.abs(q);\nfor (let i = 0; i < 4; i++) {\n const r = 0.6 + i * 0.7;\n const Vr = baseV / r * (q >= 0 ? 1 : -1);\n const px = v.X(cx + r) - v.X(cx);\n v.circle(cx, cy, Math.abs(px), { stroke: H.colors.violet, width: 1.5 });\n v.text((Vr / 1000).toFixed(1) + \" kV\", cx + r * 0.7, cy + r * 0.7, { color: H.colors.sub, size: 11 });\n}\nv.dot(cx, cy, { r: 8, fill: q >= 0 ? H.colors.warn : H.colors.accent });\nconst n = 8;\nfor (let i = 0; i < n; i++) {\n const a = (i / n) * Math.PI * 2 + 0.0;\n const ph = (t * 0.5) % 1;\n const r0 = 0.5, r1 = 3.1;\n const rr = r0 + (r1 - r0) * ph;\n const rr2 = Math.min(r1, rr + 0.4);\n const ax = Math.cos(a), ay = Math.sin(a);\n const dir = q >= 0 ? 1 : -1;\n if (dir > 0) v.arrow(ax * rr, ay * rr, ax * rr2, ay * rr2, { color: H.colors.accent2, width: 1.5 });\n else v.arrow(ax * rr2, ay * rr2, ax * rr, ay * rr, { color: H.colors.accent2, width: 1.5 });\n}\nH.text(\"Equipotential lines: V = k q / r\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Field (orange) is ⊥ to equipotentials (purple); no work along a line\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"q = \" + (q * 1e9).toFixed(1) + \" nC\", 24, 78, { color: H.colors.good, size: 13 });" + }, + { + "id": "apphys-capacitance", + "area": "AP Physics", + "topic": "Parallel-plate capacitor", + "title": "Parallel-plate capacitor: C = ε₀ A / d", + "equation": "C = epsilon_0 * A / d", + "keywords": [ + "capacitance", + "parallel plate capacitor", + "C = epsilon0 A / d", + "plate area", + "plate separation", + "gap", + "epsilon naught", + "farad", + "picofarad", + "ap physics", + "capacitor geometry", + "dielectric" + ], + "explanation": "For a parallel-plate capacitor with vacuum between the plates, C = ε₀A/d, where A is the plate area, d the gap, and ε₀ = 8.85e-12 F/m. Capacitance is purely geometric: it rises with larger plate area (more charge stored per volt) and falls with a wider gap. The uniform field between the plates is E = V/d, and the charge stored is Q = CV. On the AP exam, students predict how C changes when plates are moved apart or area is changed (and how a dielectric multiplies C by κ); the animation lets you grow the plates and widen the gap and reads out C in picofarads.", + "bullets": [ + "C scales directly with plate area A — taller plates on screen mean larger C.", + "C scales inversely with gap d — widening the gap lowers C.", + "Field between plates is uniform (E = V/d); yellow charges drift across it.", + "C is geometry-only: ε₀ = 8.85×10⁻¹² F/m; a dielectric would multiply by κ." + ], + "params": [ + { + "name": "A", + "label": "plate area A (cm²)", + "min": 1, + "max": 100, + "step": 1, + "value": 50 + }, + { + "name": "d", + "label": "gap d (mm)", + "min": 0.2, + "max": 10, + "step": 0.2, + "value": 2 + } + ], + "code": "H.background();\nconst eps0 = 8.854e-12;\nconst A = P.A;\nconst d = P.d;\nconst Acm = Math.max(A, 0.1);\nconst dmm = Math.max(d, 0.1);\nconst Am2 = Acm * 1e-4;\nconst dm = dmm * 1e-3;\nconst C = eps0 * Am2 / dm;\nconst cx = H.W * 0.5, cy = H.H * 0.55;\nconst plateH = 60 + 120 * (Acm / 100);\nconst ph = Math.min(plateH, 260);\nconst gapPx = 30 + 120 * (dmm / 10);\nconst gp = Math.min(gapPx, 220);\nconst lx = cx - gp / 2, rx = cx + gp / 2;\nconst top = cy - ph / 2;\nH.rect(lx - 8, top, 8, ph, { fill: H.colors.accent });\nH.rect(rx, top, 8, ph, { fill: H.colors.warn });\nH.text(\"+\", lx - 22, cy, { color: H.colors.accent, size: 20, align: \"center\", baseline: \"middle\" });\nH.text(\"–\", rx + 22, cy, { color: H.colors.warn, size: 20, align: \"center\", baseline: \"middle\" });\nconst nl = 5;\nfor (let i = 0; i < nl; i++) {\n const y = top + ph * (i + 0.5) / nl;\n H.line(lx, y, rx, y, { color: H.colors.grid, width: 1 });\n const ph2 = (t * 0.6 + i * 0.13) % 1;\n const x = lx + (rx - lx) * ph2;\n H.circle(x, y, 3, { fill: H.colors.yellow });\n}\nH.line(lx, top - 14, rx, top - 14, { color: H.colors.sub, width: 1, dash: [3, 3] });\nH.text(\"d = \" + dmm.toFixed(2) + \" mm\", cx, top - 22, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(\"Parallel-plate capacitor: C = ε₀ A / d\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Bigger plates raise C; wider gap lowers C\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"A = \" + Acm.toFixed(1) + \" cm² d = \" + dmm.toFixed(2) + \" mm\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"C = \" + (C * 1e12).toFixed(2) + \" pF\", 24, 98, { color: H.colors.good, size: 13 });" + }, + { + "id": "apphys-capacitor-energy", + "area": "AP Physics", + "topic": "Energy in a capacitor", + "title": "Energy stored in a capacitor: U = ½ C V²", + "equation": "U = 0.5 * C * V^2", + "keywords": [ + "capacitor energy", + "energy stored", + "U = 1/2 C V^2", + "half C V squared", + "Q-V triangle", + "work to charge", + "joule", + "microjoule", + "ap physics", + "energy density", + "QV/2", + "stored energy" + ], + "explanation": "The energy stored in a charged capacitor is U = ½CV² = ½QV = Q²/(2C), since charging it requires work as each added charge is pushed against the rising voltage. On a charge-versus-voltage graph, Q = CV is a straight line of slope C, and the stored energy is the TRIANGULAR area beneath it (½ base × height = ½VQ). Energy grows with the square of voltage, so doubling V quadruples U. On the AP exam this appears in RC and energy-conservation problems; the animation charges the capacitor over time, shading the growing Q–V triangle while reporting the instantaneous and full-charge energies.", + "bullets": [ + "U is the area of the Q–V triangle = ½QV = ½CV².", + "Because U ∝ V², doubling the voltage quadruples the stored energy.", + "The orange dot/shading grows as the capacitor charges up over time.", + "Equivalent forms: U = ½CV² = ½QV = Q²/(2C), with Q = CV." + ], + "params": [ + { + "name": "C", + "label": "capacitance C (µF)", + "min": 0.5, + "max": 10, + "step": 0.5, + "value": 4 + }, + { + "name": "V", + "label": "voltage V (V)", + "min": 1, + "max": 12, + "step": 0.5, + "value": 9 + } + ], + "code": "H.background();\nconst C = P.C;\nconst V = P.V;\nconst Cf = Math.max(C, 0.01) * 1e-6;\nconst U = 0.5 * Cf * V * V;\nconst ph = (t * 0.4) % 1;\nconst frac = ph;\nconst vNow = V * frac;\nconst uNow = 0.5 * Cf * vNow * vNow;\nconst v2 = H.plot2d({ xMin: 0, xMax: 12, yMin: 0, yMax: 12 });\nv2.grid(); v2.axes();\nconst Qfull = Cf * V * 1e6;\nv2.fn(x => (x <= V ? Cf * x * 1e6 : NaN), { color: H.colors.accent, width: 3 });\nconst qNow = Cf * vNow * 1e6;\nv2.path([[0, 0], [vNow, qNow], [vNow, 0]], { color: H.colors.accent2, width: 1, fill: \"rgba(244,162,89,0.25)\", close: true });\nv2.dot(vNow, qNow, { r: 7, fill: H.colors.accent2 });\nv2.text(\"U = area\", vNow * 0.45, qNow * 0.4, { color: H.colors.sub, size: 11 });\nH.text(\"Energy in a capacitor: U = ½ C V²\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Stored energy is the area of the Q–V triangle (½QV)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"C = \" + C.toFixed(1) + \" µF V = \" + V.toFixed(1) + \" V\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"charging: V = \" + vNow.toFixed(1) + \" V → U = \" + (uNow * 1e6).toFixed(2) + \" µJ\", 24, 98, { color: H.colors.warn, size: 13 });\nH.text(\"full U = ½CV² = \" + (U * 1e6).toFixed(2) + \" µJ\", 24, 118, { color: H.colors.good, size: 13 });" + }, + { + "id": "apphys-ohms-law", + "area": "AP Physics", + "topic": "Ohm law V=IR", + "title": "Ohm's law: I = V / R", + "equation": "I = V / R", + "keywords": [ + "ohm's law", + "ohms law", + "v=ir", + "i=v/r", + "current voltage resistance", + "resistance", + "resistor", + "circuits", + "ap physics", + "ap physics 2", + "ohm", + "linear circuit" + ], + "explanation": "For an ohmic resistor the current is directly proportional to the voltage across it, I = V/R, so a plot of I versus V is a straight line through the origin whose slope is 1/R. A larger resistance R produces a gentler slope (less current for the same voltage), while a smaller R gives a steeper line. On the AP exam you must read this slope off an I-V graph to extract R, recognize that doubling V doubles I (ohmic behavior), and keep units consistent (volts, amps, ohms). The orange dot sweeps along the line as the source voltage oscillates, and the pink dot marks your chosen (V, I) operating point.", + "bullets": [ + "The I-V line is straight through the origin; its slope equals 1/R.", + "Larger R = gentler slope = less current at the same voltage.", + "Doubling V doubles I — the signature of an ohmic resistor.", + "Pink dot is your operating point (V, I); orange dot sweeps the line." + ], + "params": [ + { + "name": "V", + "label": "voltage V (V)", + "min": 0, + "max": 10, + "step": 0.5, + "value": 6 + }, + { + "name": "R", + "label": "resistance R (Ohm)", + "min": 0.5, + "max": 10, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst V = P.V, R = Math.max(0.001, P.R);\nconst I = V / R;\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: 0, yMax: 24 });\nv.grid(); v.axes();\nv.fn(x => x / R, { color: H.colors.accent, width: 3 });\nconst Vsweep = 5 + 5 * Math.sin(t * 0.7);\nv.dot(Vsweep, Vsweep / R, { r: 6, fill: H.colors.accent2 });\nv.dot(V, I, { r: 7, fill: H.colors.warn });\nv.text(\"slope = 1/R\", 6.4, 6.4 / R + 1.5, { color: H.colors.sub });\nH.text(\"Ohm's law: I = V / R\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Current vs voltage; line slope is 1/R\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"V = \" + V.toFixed(1) + \" V\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"R = \" + R.toFixed(1) + \" Ohm\", 24, 96, { color: H.colors.sub, size: 13 });\nH.text(\"I = V/R = \" + I.toFixed(2) + \" A\", 24, 114, { color: H.colors.good, size: 13 });\n" + }, + { + "id": "apphys-resistors-series", + "area": "AP Physics", + "topic": "Resistors in series", + "title": "Resistors in series: Req = R1 + R2", + "equation": "Req = R1 + R2", + "keywords": [ + "resistors in series", + "series resistance", + "req=r1+r2", + "equivalent resistance", + "series circuit", + "same current", + "voltage divider", + "kirchhoff", + "ap physics", + "circuits", + "add resistors" + ], + "explanation": "Resistors in series carry the same current I because there is only one path for charge, so their resistances simply add: Req = R1 + R2. The total resistance is always larger than either individual resistor, and the source voltage divides across them in proportion to their resistances (V1 = IR1, V2 = IR2), which is the voltage-divider idea tested on the AP exam. Because the current is common, the larger resistor drops the larger voltage. The animation pushes a single electron through both resistors in turn to show that one current flows through the entire chain, and the readout reports Req, I, and the two voltage drops for a 12 V source.", + "bullets": [ + "One path means the SAME current I flows through both resistors.", + "Resistances add directly: Req = R1 + R2 (always bigger than each).", + "Source voltage splits as V1 = IR1 and V2 = IR2 (voltage divider).", + "The single electron threads R1 then R2 — one shared current." + ], + "params": [ + { + "name": "R1", + "label": "R1 (Ohm)", + "min": 1, + "max": 20, + "step": 1, + "value": 4 + }, + { + "name": "R2", + "label": "R2 (Ohm)", + "min": 1, + "max": 20, + "step": 1, + "value": 8 + } + ], + "code": "H.background();\nconst R1 = P.R1, R2 = P.R2;\nconst Req = R1 + R2;\nconst I = 12 / Math.max(0.001, Req);\nconst V1 = I * R1, V2 = I * R2;\nconst y = 200;\nconst x0 = 120, x3 = 780;\nconst xa = x0 + (x3 - x0) * 0.34, xb = x0 + (x3 - x0) * 0.66;\nH.line(x0, y, xa, y, { color: H.colors.axis, width: 2 });\nH.line(xb, y, x3, y, { color: H.colors.axis, width: 2 });\nH.rect(xa, y - 18, xb - xa, 36, { stroke: H.colors.accent, width: 2, fill: H.colors.panel });\nconst xm = (xa + xb) / 2;\nH.rect(xa, y - 18, (xm - xa), 36, { fill: \"rgba(124,196,255,0.18)\" });\nH.rect(xm, y - 18, (xb - xm), 36, { fill: \"rgba(244,162,89,0.18)\" });\nH.text(\"R1 = \" + R1.toFixed(0), (xa + xm) / 2, y - 30, { color: H.colors.accent, size: 12, align: \"center\" });\nH.text(\"R2 = \" + R2.toFixed(0), (xm + xb) / 2, y - 30, { color: H.colors.accent2, size: 12, align: \"center\" });\nconst phase = (t * 0.4) % 1;\nconst ex = x0 + (x3 - x0) * phase;\nH.circle(ex, y, 6, { fill: H.colors.good });\nH.text(\"e-\", ex, y - 14, { color: H.colors.good, size: 11, align: \"center\" });\nH.text(\"Resistors in series: Req = R1 + R2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Same current I everywhere; voltages add\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Req = \" + Req.toFixed(1) + \" Ohm\", 24, 360, { color: H.colors.good, size: 14 });\nH.text(\"I = 12V / Req = \" + I.toFixed(2) + \" A\", 24, 382, { color: H.colors.sub, size: 13 });\nH.text(\"V1 = \" + V1.toFixed(1) + \" V, V2 = \" + V2.toFixed(1) + \" V\", 24, 404, { color: H.colors.sub, size: 13 });\n" + }, + { + "id": "apphys-resistors-parallel", + "area": "AP Physics", + "topic": "Resistors in parallel", + "title": "Resistors in parallel: 1/Req = 1/R1 + 1/R2", + "equation": "1/Req = 1/R1 + 1/R2", + "keywords": [ + "resistors in parallel", + "parallel resistance", + "reciprocal resistance", + "1/req", + "equivalent resistance", + "parallel circuit", + "same voltage", + "current divider", + "kirchhoff", + "ap physics", + "circuits" + ], + "explanation": "Resistors in parallel share the same two nodes, so each has the full source voltage across it; the reciprocals of resistance add: 1/Req = 1/R1 + 1/R2, giving Req = R1*R2/(R1+R2) for two resistors. The equivalent resistance is always SMALLER than the smallest branch because adding paths gives charge more ways to flow. Each branch current is I = V/R, and the branch currents add to the total (Kirchhoff's junction rule), so the lower-resistance branch carries more current — the current-divider concept tested on the AP exam. The readout shows the two branch currents and confirms Itot = I1 + I2 for a 12 V source.", + "bullets": [ + "Both resistors share the SAME voltage (here 12 V across each).", + "Reciprocals add: 1/Req = 1/R1 + 1/R2; Req < smaller branch.", + "Branch currents I1 = V/R1, I2 = V/R2 add to the total.", + "Lower resistance carries more current (current divider)." + ], + "params": [ + { + "name": "R1", + "label": "R1 (Ohm)", + "min": 1, + "max": 20, + "step": 1, + "value": 4 + }, + { + "name": "R2", + "label": "R2 (Ohm)", + "min": 1, + "max": 20, + "step": 1, + "value": 6 + } + ], + "code": "H.background();\nconst R1 = Math.max(0.001, P.R1), R2 = Math.max(0.001, P.R2);\nconst Req = 1 / (1 / R1 + 1 / R2);\nconst Vs = 12;\nconst Itot = Vs / Req, I1 = Vs / R1, I2 = Vs / R2;\nconst xL = 220, xR = 620, yT = 150, yB = 320;\nH.line(xL, yT, xR, yT, { color: H.colors.axis, width: 2 });\nH.line(xL, yB, xR, yB, { color: H.colors.axis, width: 2 });\nH.line(xL, yT, xL, yB, { color: H.colors.axis, width: 2 });\nH.line(xR, yT, xR, yB, { color: H.colors.axis, width: 2 });\nconst ym = (yT + yB) / 2;\nH.rect(380 - 18, yT + 18, 36, ym - yT - 36, { stroke: H.colors.accent, width: 2, fill: H.colors.panel });\nH.rect(380 - 18, ym + 18, 36, yB - ym - 36, { stroke: H.colors.accent2, width: 2, fill: H.colors.panel });\nH.text(\"R1 = \" + R1.toFixed(0), 420, yT + (ym - yT) / 2, { color: H.colors.accent, size: 12 });\nH.text(\"R2 = \" + R2.toFixed(0), 420, ym + (yB - ym) / 2, { color: H.colors.accent2, size: 12 });\nconst ph = (t * 0.5) % 1;\nH.circle(xL + (xR - xL) * ph, yT, 5, { fill: H.colors.good });\nH.text(\"Resistors in parallel: 1/Req = 1/R1 + 1/R2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Same voltage across each; currents add\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Req = \" + Req.toFixed(2) + \" Ohm (< both)\", 24, 400, { color: H.colors.good, size: 14 });\nH.text(\"I1 = \" + I1.toFixed(2) + \" A, I2 = \" + I2.toFixed(2) + \" A\", 24, 422, { color: H.colors.sub, size: 13 });\nH.text(\"Itot = I1 + I2 = \" + Itot.toFixed(2) + \" A\", 24, 444, { color: H.colors.sub, size: 13 });\n" + }, + { + "id": "apphys-power-dissipation", + "area": "AP Physics", + "topic": "Power in a resistor", + "title": "Power dissipated: P = I^2 R = V^2 / R", + "equation": "P = I^2 * R = V^2 / R = I * V", + "keywords": [ + "power dissipation", + "power in a resistor", + "p=i^2r", + "i squared r", + "v squared over r", + "joule heating", + "electrical power", + "watts", + "ap physics", + "circuits", + "power formula", + "heat" + ], + "explanation": "A resistor converts electrical energy to heat at a rate P = IV, which (using V = IR) can be written P = I^2 R or P = V^2/R. Because power goes as the SQUARE of the current, doubling the current quadruples the power dissipated — the P-versus-I curve is a parabola opening upward, not a line. The form you choose on the AP exam depends on what is held fixed: at fixed current the larger resistor dissipates more (P = I^2 R), but at fixed voltage the SMALLER resistor dissipates more (P = V^2/R). The orange dot sweeps along the I^2 R parabola while the pink dot marks your chosen operating point, with the live readout giving P in watts.", + "bullets": [ + "Three equivalent forms: P = IV = I^2 R = V^2 / R.", + "P-vs-I is a parabola: double I quadruples the power.", + "Fixed current: bigger R dissipates more (P = I^2 R).", + "Fixed voltage: smaller R dissipates more (P = V^2 / R)." + ], + "params": [ + { + "name": "I", + "label": "current I (A)", + "min": 0, + "max": 5, + "step": 0.25, + "value": 2 + }, + { + "name": "R", + "label": "resistance R (Ohm)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\nconst I = P.I, R = Math.max(0.001, P.R);\nconst P_ = I * I * R;\nconst V = I * R;\nconst v = H.plot2d({ xMin: 0, xMax: 5, yMin: 0, yMax: 60 });\nv.grid(); v.axes();\nv.fn(x => x * x * R, { color: H.colors.accent, width: 3 });\nconst Isweep = 2.5 + 2.4 * Math.sin(t * 0.7);\nv.dot(Isweep, Isweep * Isweep * R, { r: 6, fill: H.colors.accent2 });\nv.dot(I, P_, { r: 7, fill: H.colors.warn });\nv.text(\"P grows as I^2\", 1.0, 50, { color: H.colors.sub });\nH.text(\"Power dissipated: P = I^2 R = V^2 / R\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Heat in a resistor scales with current squared\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"I = \" + I.toFixed(2) + \" A, R = \" + R.toFixed(1) + \" Ohm\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"V = IR = \" + V.toFixed(1) + \" V\", 24, 96, { color: H.colors.sub, size: 13 });\nH.text(\"P = I^2 R = \" + P_.toFixed(1) + \" W\", 24, 114, { color: H.colors.good, size: 14 });\n" + }, + { + "id": "apphys-kirchhoff", + "area": "AP Physics", + "topic": "Kirchhoff laws", + "title": "Kirchhoff's laws: sum I_in = sum I_out, sum V_loop = 0", + "equation": "sum(I_in) = sum(I_out); sum(V_loop) = 0", + "keywords": [ + "kirchhoff's laws", + "kirchhoff", + "junction rule", + "kcl", + "loop rule", + "kvl", + "conservation of charge", + "conservation of energy", + "node", + "ap physics", + "circuits", + "sum of currents" + ], + "explanation": "Kirchhoff's junction rule (KCL) states that the total current entering any node equals the total current leaving it, sum(I_in) = sum(I_out), because charge is conserved and cannot pile up at a junction. Kirchhoff's loop rule (KVL) states that the sum of voltage changes around any closed loop is zero, sum(V_loop) = 0, because the electric potential is single-valued and energy is conserved — a rise across an EMF is balanced by drops across resistors. On the AP exam you apply KCL at nodes and KVL around loops (watching signs: a drop IR is negative when traversed in the current direction) to solve for unknown branch currents. The animation shows two currents merging into a node where I3 = I1 + I2, with charge flowing in and back out so nothing accumulates.", + "bullets": [ + "Junction rule (KCL): currents in equal currents out — charge conserved.", + "At the node shown, I3 = I1 + I2 so I1 + I2 - I3 = 0.", + "Loop rule (KVL): voltage gains and drops sum to zero — energy conserved.", + "Sign care: an IR drop is negative when traversed along the current." + ], + "params": [ + { + "name": "I1", + "label": "branch current I1 (A)", + "min": 0, + "max": 5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst I1 = P.I1;\nconst I2 = 2.0;\nconst I3 = I1 + I2;\nconst jx = 300, jy = 240;\nH.arrow(120, 180, jx, jy, { color: H.colors.accent, width: 3 });\nH.arrow(120, 320, jx, jy, { color: H.colors.accent2, width: 3 });\nH.arrow(jx, jy, 560, 240, { color: H.colors.good, width: 3 });\nH.circle(jx, jy, 6, { fill: H.colors.ink });\nH.text(\"I1 = \" + I1.toFixed(1) + \" A\", 150, 165, { color: H.colors.accent, size: 13 });\nH.text(\"I2 = \" + I2.toFixed(1) + \" A\", 150, 335, { color: H.colors.accent2, size: 13 });\nH.text(\"I3 = I1 + I2 = \" + I3.toFixed(1) + \" A\", 430, 222, { color: H.colors.good, size: 13 });\nconst ph = (t * 0.5) % 1;\nH.circle(120 + (jx - 120) * ph, 180 + (jy - 180) * ph, 5, { fill: H.colors.accent });\nH.circle(jx + (560 - jx) * ph, jy, 5, { fill: H.colors.good });\nH.text(\"Kirchhoff's laws: sum I_in = sum I_out, sum V_loop = 0\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Junction (KCL): charge conserved; Loop (KVL): energy conserved\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"KCL at node: I1 + I2 - I3 = \" + (I1 + I2 - I3).toFixed(1) + \" A (= 0)\", 24, 420, { color: H.colors.good, size: 14 });\nH.text(\"KVL: EMF - I*R1 - I*R2 = 0 around any loop\", 24, 442, { color: H.colors.sub, size: 13 });\n" + }, + { + "id": "apphys-rc-circuit", + "area": "AP Physics", + "topic": "RC charging", + "title": "RC charging: V(t) = V0 (1 - e^(-t/RC))", + "equation": "V(t) = V0 * (1 - e^(-t/(R*C))), tau = R*C", + "keywords": [ + "rc circuit", + "capacitor charging", + "time constant", + "tau", + "exponential charging", + "rc charging", + "resistor capacitor", + "ap physics", + "e^-t/rc", + "63 percent", + "charging curve", + "circuit" + ], + "explanation": "When a capacitor charges through a resistor from a constant source V0, the capacitor voltage rises as V(t) = V0(1 - e^(-t/RC)). The product tau = RC is the time constant: after one tau the capacitor reaches about 63% of V0, and after ~5 tau it is essentially fully charged. Increasing either R or C slows the charging because tau grows, so the same curve stretches out in time. On the AP exam you must recognize the exponential approach to a horizontal asymptote at V0, read tau off the graph, and know the charging current decays as I(t) = (V0/R)e^(-t/RC). The orange dot rides the rising curve while the dashed line marks t = tau.", + "bullets": [ + "After one time constant tau = RC the voltage reaches ~63% of V0.", + "Larger R or C means larger tau -> slower charging (curve stretches).", + "The curve approaches V0 asymptotically; ~5 tau is 'fully charged'.", + "Charging current does the opposite: it decays as e^(-t/RC)." + ], + "params": [ + { + "name": "R", + "label": "resistance R (ohm)", + "min": 0.5, + "max": 10, + "step": 0.5, + "value": 4 + }, + { + "name": "C", + "label": "capacitance C (F)", + "min": 0.05, + "max": 1, + "step": 0.05, + "value": 0.25 + } + ], + "code": "H.background();\nconst R = Math.max(P.R, 0.1), C = Math.max(P.C, 0.01);\nconst V0 = 9;\nconst tau = R * C;\nconst Tmax = Math.max(5 * tau, 0.5);\nconst v = H.plot2d({ xMin: 0, xMax: Tmax, yMin: 0, yMax: V0 * 1.15 });\nv.grid(); v.axes();\nv.fn(x => V0 * (1 - Math.exp(-x / tau)), { color: H.colors.accent, width: 3 });\nv.line(tau, 0, tau, V0, { color: H.colors.violet, width: 1.5, dash: [5, 5] });\nv.text(\"tau\", tau, V0 * 0.1, { color: H.colors.violet, size: 12 });\nconst tt = (t % (Tmax)) ;\nconst Vt = V0 * (1 - Math.exp(-tt / tau));\nv.dot(tt, Vt, { r: 7, fill: H.colors.accent2 });\nH.text(\"RC charging: V(t) = V0 (1 - e^(-t/RC))\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"tau = RC = \" + tau.toFixed(2) + \" s (63% at t=tau)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"V = \" + Vt.toFixed(2) + \" V t = \" + tt.toFixed(2) + \" s\", 24, 74, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apphys-emf-internal-r", + "area": "AP Physics", + "topic": "EMF & internal resistance", + "title": "Terminal voltage: V = emf - I r", + "equation": "V = emf - I*r, with I = emf / (R + r)", + "keywords": [ + "emf", + "internal resistance", + "terminal voltage", + "electromotive force", + "battery internal resistance", + "v = emf - ir", + "load resistance", + "ap physics", + "real battery", + "cell", + "circuit", + "voltage drop" + ], + "explanation": "A real battery is modeled as an ideal emf source in series with a small internal resistance r. The current it drives into a load R is I = emf/(R + r), and the voltage measured across its terminals is V = emf - I r, which is always less than the emf whenever current flows. As the load resistance R increases, the current drops, the internal drop I r shrinks, and the terminal voltage rises toward the full emf; in the open-circuit limit (R -> infinity) V = emf. On the AP exam this explains why a battery's measured voltage sags under heavy load (small R, large I) and why a graph of V vs R rises and saturates at emf. The orange probe sweeps R along the curve while the pink dot marks your chosen load.", + "bullets": [ + "Terminal voltage V = emf - I r is always below emf when I > 0.", + "Current I = emf/(R + r): smaller load R draws more current.", + "Large load R -> small I -> V climbs toward the full emf.", + "Open circuit (R -> infinity) gives V = emf; short circuit maximizes I r drop." + ], + "params": [ + { + "name": "R", + "label": "load resistance R (ohm)", + "min": 0.1, + "max": 20, + "step": 0.1, + "value": 4 + } + ], + "code": "H.background();\nconst emf = 12;\nconst r = 1.5;\nconst Rload = Math.max(P.R, 0.01);\nconst I = emf / (Rload + r);\nconst Vterm = emf - I * r;\nconst v = H.plot2d({ xMin: 0, xMax: 20, yMin: 0, yMax: emf * 1.1 });\nv.grid(); v.axes();\nv.fn(R => emf * R / (R + r), { color: H.colors.accent, width: 3 });\nv.line(0, emf, 20, emf, { color: H.colors.violet, width: 1.5, dash: [5, 5] });\nv.text(\"emf = 12 V\", 10, emf - 0.6, { color: H.colors.violet, size: 12 });\nv.dot(Rload, Vterm, { r: 7, fill: H.colors.warn });\nconst Rs = 10 + 9.5 * Math.sin(t * 0.6);\nconst Vs = emf * Rs / (Rs + r);\nv.dot(Rs, Vs, { r: 6, fill: H.colors.accent2 });\nH.text(\"Terminal voltage: V = emf - I r\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"emf = 12 V, internal r = 1.5 ohm (orange probe sweeps R; V rises toward emf)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"R = \" + Rload.toFixed(1) + \" ohm I = \" + I.toFixed(2) + \" A V = \" + Vterm.toFixed(2) + \" V\", 24, 74, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apphys-field-of-wire", + "area": "AP Physics", + "topic": "Magnetic field of a wire", + "title": "Field of a long wire: B = mu0 I / (2 pi r)", + "equation": "B = mu0 * I / (2 * pi * r)", + "keywords": [ + "magnetic field", + "long straight wire", + "ampere's law", + "b = mu0 i / 2 pi r", + "right hand rule", + "field lines", + "current", + "ap physics", + "magnetism", + "circular field", + "mu0", + "permeability" + ], + "explanation": "A long straight wire carrying current I sets up a magnetic field whose lines are concentric circles around the wire, with magnitude B = mu0 I / (2 pi r) that falls off as 1/r with distance. The direction follows the right-hand rule: point your thumb along the conventional current (here, out of the page, shown as a dot) and your fingers curl in the direction of B. Doubling the current doubles B, while doubling the distance halves it. On the AP exam this result comes from Ampere's law and is the building block for fields of loops and solenoids and for the force between parallel wires. The arrows circulate to show the field direction, and the readout gives B at a fixed radius.", + "bullets": [ + "Field lines are concentric circles centered on the wire.", + "B = mu0 I / (2 pi r): proportional to I, falls off as 1/r.", + "Right-hand rule: thumb along I (out of page), fingers curl with B.", + "mu0 = 4 pi x 1e-7 T m/A is the permeability of free space." + ], + "params": [ + { + "name": "I", + "label": "current I (A)", + "min": 0, + "max": 20, + "step": 0.5, + "value": 10 + } + ], + "code": "H.background();\nconst cx = H.W / 2, cy = H.H / 2;\nconst I = P.I;\nconst mu0 = 4 * Math.PI * 1e-7;\nconst TAU = Math.PI * 2;\nH.text(\"Magnetic field of a wire: B = mu0 I / (2 pi r)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Current out of page (dot); B circles the wire, weakening as 1/r\", 24, 52, { color: H.colors.sub, size: 13 });\nH.circle(cx, cy, 9, { fill: H.colors.warn, stroke: H.colors.ink, width: 2 });\nH.circle(cx, cy, 3, { fill: H.colors.ink });\nfor (let k = 1; k <= 4; k++) {\n const rpx = 45 * k;\n const pts = [];\n for (let a = 0; a <= 40; a++) {\n const ang = TAU * a / 40;\n pts.push([cx + rpx * Math.cos(ang), cy + rpx * Math.sin(ang)]);\n }\n H.path(pts, { color: H.colors.grid, width: 1 });\n const ang = (t * 0.8 + k * 0.4) % TAU;\n const px = cx + rpx * Math.cos(ang), py = cy + rpx * Math.sin(ang);\n const tx = -Math.sin(ang), ty = Math.cos(ang);\n H.arrow(px, py, px + tx * 26, py + ty * 26, { color: H.colors.accent, width: 2 });\n}\nconst rprobe = 0.10;\nconst Bprobe = mu0 * Math.max(I, 0) / (TAU * rprobe);\nH.text(\"at r = \" + (rprobe * 100).toFixed(0) + \" cm: B = \" + (Bprobe * 1e6).toFixed(2) + \" uT\", 24, 74, { color: H.colors.accent2, size: 13 });\nH.text(\"I = \" + I.toFixed(1) + \" A (right-hand rule: thumb = I, fingers = B)\", 24, 96, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apphys-force-moving-charge", + "area": "AP Physics", + "topic": "Force on a moving charge", + "title": "Force on a moving charge: F = q v B", + "equation": "F = q*v*B (v perpendicular to B), r = m*v/(q*B)", + "keywords": [ + "magnetic force", + "moving charge", + "lorentz force", + "f = qvb", + "circular motion", + "centripetal force", + "radius of motion", + "ap physics", + "charged particle", + "magnetic field", + "cyclotron", + "q v b" + ], + "explanation": "A charge q moving with speed v perpendicular to a magnetic field B feels a force F = qvB directed perpendicular to both v and B (the full Lorentz force F = qv x B). Because the force is always perpendicular to the velocity, it does no work and acts as a centripetal force, bending the charge into a circle of radius r = mv/(qB). Increasing v widens the circle (and raises F), while a stronger B tightens it; the period T = 2 pi m/(qB) is independent of speed. On the AP exam, set qvB = mv^2/r to derive r, and use the right-hand rule (then reverse for a negative charge) to get the direction. The animation shows v tangent to the path and F always pointing to the center.", + "bullets": [ + "F = qvB is perpendicular to v, so it does no work — only turns the charge.", + "Perpendicular magnetic force is centripetal: qvB = mv^2/r.", + "Orbit radius r = mv/(qB): faster -> bigger circle, stronger B -> tighter.", + "Direction from right-hand rule (reverse for negative charge)." + ], + "params": [ + { + "name": "v", + "label": "speed v (x1e6 m/s)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "B", + "label": "field B (T)", + "min": 0.05, + "max": 1, + "step": 0.05, + "value": 0.5 + } + ], + "code": "H.background();\nconst cx = H.W / 2, cy = H.H / 2;\nconst vmag = Math.max(P.v, 0.1);\nconst B = Math.max(P.B, 0.01);\nconst q = 1.6e-19;\nconst m = 9.11e-31;\nconst F = q * vmag * B;\nconst rad = m * vmag / (q * B);\nconst Rpx = 150;\nconst ang = (t * 1.2) % (Math.PI * 2);\nconst px = cx + Rpx * Math.cos(ang), py = cy + Rpx * Math.sin(ang);\nH.text(\"Force on a moving charge: F = q v B\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"B out of page; magnetic force is centripetal -> circular motion\", 24, 52, { color: H.colors.sub, size: 13 });\nconst circ = [];\nfor (let a = 0; a <= 60; a++) { const an = Math.PI * 2 * a / 60; circ.push([cx + Rpx * Math.cos(an), cy + Rpx * Math.sin(an)]); }\nH.path(circ, { color: H.colors.grid, width: 1.5, dash: [6, 6] });\nconst vx = -Math.sin(ang), vy = Math.cos(ang);\nH.arrow(px, py, px + vx * 50, py + vy * 50, { color: H.colors.good, width: 2.5 });\nconst fx = (cx - px), fy = (cy - py);\nconst fl = Math.hypot(fx, fy) || 1;\nH.arrow(px, py, px + fx / fl * 45, py + fy / fl * 45, { color: H.colors.warn, width: 2.5 });\nH.circle(px, py, 9, { fill: H.colors.accent2, stroke: H.colors.ink, width: 2 });\nH.legend([{ label: \"v (velocity)\", color: H.colors.good }, { label: \"F (toward center)\", color: H.colors.warn }], 24, H.H - 60);\nH.text(\"v = \" + vmag.toFixed(1) + \" x1e6 m/s B = \" + B.toFixed(2) + \" T\", 24, 74, { color: H.colors.accent2, size: 13 });\nH.text(\"F = q v B = \" + (q * vmag * 1e6 * B * 1e15).toFixed(2) + \" x1e-15 N r = mv/qB\", 24, 96, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apphys-force-on-wire", + "area": "AP Physics", + "topic": "Force on a current-carrying wire", + "title": "Force on a current-carrying wire: F = B I L", + "equation": "F = B*I*L (I perpendicular to B), F = I L x B", + "keywords": [ + "magnetic force on wire", + "current carrying wire", + "f = bil", + "motor effect", + "f = il x b", + "force on conductor", + "ap physics", + "magnetic field", + "current", + "right hand rule", + "wire in field", + "motor" + ], + "explanation": "A straight wire of length L carrying current I in a magnetic field B experiences a force F = BIL when the current is perpendicular to the field (in general F = IL x B, so F = BIL sin(theta)). The force is mutually perpendicular to both the current and the field, given by the right-hand rule, and it scales linearly with all three of I, B, and L. This 'motor effect' is what spins electric motors and underlies the definition of the ampere via the force between parallel wires. On the AP exam, watch for the sin(theta) factor when the wire is not perpendicular to B, and reverse the force direction if the current direction flips. Here B points into the page (x symbols), the current flows up the wire, and the red arrow grows with F = BIL.", + "bullets": [ + "F = BIL when I is perpendicular to B (general: F = BIL sin theta).", + "Force is perpendicular to both the wire and the field (right-hand rule).", + "F scales linearly with current I, field B, and length L.", + "Reverse I or B and the force flips direction — the basis of motors." + ], + "params": [ + { + "name": "I", + "label": "current I (A)", + "min": 0, + "max": 10, + "step": 0.5, + "value": 5 + }, + { + "name": "B", + "label": "field B (T)", + "min": 0, + "max": 2, + "step": 0.1, + "value": 0.8 + }, + { + "name": "L", + "label": "length L (m)", + "min": 0.1, + "max": 1.5, + "step": 0.1, + "value": 0.6 + } + ], + "code": "H.background();\nconst I = Math.max(P.I, 0);\nconst B = Math.max(P.B, 0);\nconst L = Math.max(P.L, 0.01);\nconst F = B * I * L;\nconst cx = H.W / 2, cy = H.H / 2 + 10;\nH.text(\"Force on a current-carrying wire: F = B I L\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"B into page (x); wire carries I; F = B I L pushes the wire (F = I L x B)\", 24, 52, { color: H.colors.sub, size: 13 });\nfor (let gx = 120; gx <= H.W - 120; gx += 60) {\n for (let gy = 110; gy <= H.H - 70; gy += 55) {\n H.text(\"x\", gx, gy, { color: H.colors.grid, size: 12, align: \"center\" });\n }\n}\nconst wlen = 80 + L * 120;\nconst y0 = cy - wlen / 2, y1 = cy + wlen / 2;\nH.line(cx, y0, cx, y1, { color: H.colors.accent, width: 4 });\nconst cdot = (t * 1.5) % 1;\nconst cy2 = y1 - cdot * wlen;\nH.circle(cx, cy2, 5, { fill: H.colors.yellow });\nH.text(\"I\", cx + 12, y0 - 8, { color: H.colors.accent, size: 13 });\nconst Fscale = 12;\nconst fx = Math.min(F * Fscale, 260);\nH.arrow(cx, cy, cx + 30 + fx, cy, { color: H.colors.warn, width: 3 });\nH.text(\"F\", cx + 40 + fx, cy - 10, { color: H.colors.warn, size: 14, weight: 700 });\nH.text(\"I = \" + I.toFixed(1) + \" A B = \" + B.toFixed(2) + \" T L = \" + L.toFixed(2) + \" m\", 24, 74, { color: H.colors.accent2, size: 13 });\nH.text(\"F = B I L = \" + F.toFixed(2) + \" N\", 24, 96, { color: H.colors.warn, size: 14, weight: 700 });" + }, + { + "id": "apphys-solenoid", + "area": "AP Physics", + "topic": "Magnetic field of a solenoid", + "title": "Solenoid field: B = mu0 * n * I", + "equation": "B = mu0 * n * I, n = N/L", + "keywords": [ + "solenoid", + "magnetic field of a solenoid", + "b = mu0 n i", + "ampere's law", + "turns per meter", + "n = N/L", + "uniform magnetic field", + "electromagnet", + "permeability of free space", + "ap physics", + "magnetism", + "coil" + ], + "explanation": "Inside a long, tightly wound solenoid the magnetic field is nearly uniform and given by B = mu0*n*I, where n = N/L is the number of turns per unit length and mu0 = 4*pi*1e-7 T*m/A. The field is independent of the solenoid's radius and is essentially zero outside, which is exactly the result Ampere's law gives for an ideal solenoid. On the AP exam you most often double B by doubling either the current I or the turn density n; the loops shown carry current into the page on top and out on the bottom, producing the uniform rightward interior field whose marker arrows speed up as B grows.", + "bullets": [ + "Interior field B = mu0*n*I is uniform and points along the axis (rightward arrows).", + "n = N/L is turns PER METRE, not total turns N — packing loops tighter raises B.", + "Field is independent of radius and ~0 outside the windings.", + "The current dots (out) and crosses (in) on opposite sides set the field direction by the right-hand rule." + ], + "params": [ + { + "name": "I", + "label": "current I (A)", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "N", + "label": "turns N (over 0.2 m)", + "min": 4, + "max": 40, + "step": 1, + "value": 20 + } + ], + "code": "H.background();\nconst I = P.I, N = P.N;\nconst mu0 = 1.25663706e-6;\nconst Lm = 0.2;\nconst n = N / Lm;\nconst B = mu0 * n * I;\nH.text(\"Solenoid field: B = mu0 * n * I (uniform inside)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"n = N/L = turns per metre; field is uniform inside, ~0 outside\", 24, 52, { color: H.colors.sub, size: 13 });\nconst cx = H.W / 2, cy = H.H / 2 + 10;\nconst halfL = 280, rad = 90;\nconst turns = Math.max(4, Math.min(40, Math.round(N)));\nH.rect(cx - halfL, cy - rad, 2 * halfL, 2 * rad, { stroke: H.colors.grid, width: 1, radius: 6 });\nfor (let k = 0; k < turns; k++) {\n const x = cx - halfL + (2 * halfL) * (k + 0.5) / turns;\n H.circle(x, cy - rad, 7, { stroke: H.colors.accent2, width: 2 });\n H.circle(x, cy + rad, 7, { stroke: H.colors.accent2, width: 2 });\n // current direction dots/crosses\n H.text(I >= 0 ? \"x\" : \".\", x, cy - rad, { color: H.colors.accent2, size: 9, align: \"center\", baseline: \"middle\" });\n H.text(I >= 0 ? \".\" : \"x\", x, cy + rad, { color: H.colors.accent2, size: 9, align: \"center\", baseline: \"middle\" });\n}\n// uniform interior field lines: arrows pointing right (or left if I<0), brightness from B\nconst dir = I >= 0 ? 1 : -1;\nconst nlines = 5;\nfor (let j = 0; j < nlines; j++) {\n const y = cy - rad + 2 * rad * (j + 0.5) / nlines;\n // animated marker sliding along the line, wrapped, speed scales with B\n const span = 2 * halfL - 40;\n const ph = ((t * (0.3 + Math.min(2, B * 4e5)) + j * 0.2) % 1);\n const mx = cx - halfL + 20 + ph * span;\n H.line(cx - halfL + 20, y, cx + halfL - 20, y, { color: H.colors.grid, width: 1 });\n H.arrow(mx - dir * 14, y, mx + dir * 14, y, { color: H.colors.accent, width: 2 });\n}\n// B magnitude bar\nconst Bbar = Math.min(1, B / (mu0 * (40 / Lm) * 10));\nH.rect(cx - halfL, cy + rad + 24, (2 * halfL) * Bbar, 12, { fill: H.colors.good });\nH.text(\"B = \" + (B * 1000).toFixed(3) + \" mT (n = \" + n.toFixed(0) + \" /m, I = \" + I.toFixed(1) + \" A)\", 24, 76, { color: H.colors.sub, size: 13 });\n" + }, + { + "id": "apphys-velocity-selector", + "area": "AP Physics", + "topic": "Velocity selector", + "title": "Velocity selector: v = E / B", + "equation": "qE = qvB => v = E / B", + "keywords": [ + "velocity selector", + "v = E/B", + "crossed fields", + "electric and magnetic force balance", + "mass spectrometer", + "wien filter", + "lorentz force", + "qe = qvb", + "undeflected charge", + "ap physics", + "e/m experiment", + "crossed e and b" + ], + "explanation": "A velocity selector uses perpendicular (crossed) E and B fields so that the electric force qE and the magnetic force qvB act in opposite directions on a charge. Only particles whose speed satisfies qE = qvB, i.e. v = E/B, feel zero net force and travel straight through; slower particles are bent one way (electric force wins) and faster ones the other (magnetic force wins). The selected speed v = E/B is independent of the charge and mass of the particle, which is why this device feeds a single clean speed into a mass spectrometer. On the AP exam, watch that the two forces must be antiparallel for any balance to exist; the animation shows the test charge deflecting up when too fast and down when too slow, going straight only at v = E/B.", + "bullets": [ + "Balance condition qE = qvB gives the selected speed v = E/B (charge cancels).", + "E pushes the +charge down (qE); B into the page pushes the rightward +charge up (qvB) — opposite directions.", + "Too slow: electric force wins, deflects down. Too fast: magnetic force wins, deflects up.", + "Selected speed depends ONLY on E and B, not on q or m — key for mass spectrometry." + ], + "params": [ + { + "name": "E", + "label": "E field E (N/C)", + "min": 0.5, + "max": 8, + "step": 0.5, + "value": 4 + }, + { + "name": "B", + "label": "B field B (T)", + "min": 0.2, + "max": 4, + "step": 0.2, + "value": 2 + } + ], + "code": "H.background();\nconst E = P.E, B = P.B;\nconst vsel = B !== 0 ? E / B : 0;\nH.text(\"Velocity selector: balance when v = E / B\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"E field (down) gives qE; B field (into page) gives qvB; they cancel only at v = E/B\", 24, 52, { color: H.colors.sub, size: 13 });\nconst x0 = 120, x1 = H.W - 120, ymid = H.H / 2 + 10;\nconst halfH = 120;\n// the selector chamber\nH.rect(x0, ymid - halfH, x1 - x0, 2 * halfH, { stroke: H.colors.grid, width: 1, radius: 6 });\n// E field arrows (pointing down) and B field crosses (into page)\nfor (let i = 0; i <= 8; i++) {\n const x = x0 + (x1 - x0) * i / 8;\n H.arrow(x, ymid - halfH + 8, x, ymid - halfH + 38, { color: H.colors.accent2, width: 1.5 });\n H.text(\"x\", x, ymid + halfH - 16, { color: H.colors.violet, size: 11, align: \"center\", baseline: \"middle\" });\n}\nH.text(\"E\", x0 + 14, ymid - halfH + 24, { color: H.colors.accent2, size: 12 });\nH.text(\"B (into page)\", x1 - 70, ymid + halfH - 14, { color: H.colors.violet, size: 11 });\n// a test charge q>0 moving right at speed v; its vertical deflection ~ (qE - qvB)\n// pick a probe speed that sweeps so the student sees too-slow / matched / too-fast\nconst vmax = 2 * Math.max(vsel, 1);\nconst v = 0.5 * vmax * (1 + Math.sin(t * 0.7));\n// net upward accel sign: electric force down (=-), magnetic for +q moving right in B into page is up (=+)\n// net vertical = (qvB - qE); positive => deflect up\nconst net = (v * B - E);\nconst scale = halfH / (E + Math.abs(vmax * B) + 1e-6);\nconst span = x1 - x0 - 60;\nconst px = x0 + 30 + ((t * 0.25) % 1) * span;\nconst ph = ((t * 0.25) % 1);\nconst py = ymid - net * scale * ph; // deflection accumulates across the chamber\nH.line(x0 + 30, ymid, x1 - 30, ymid, { color: H.colors.grid, width: 1, dash: [4, 4] });\nH.circle(px, py, 8, { fill: H.colors.good });\nH.arrow(px, py, px + 28, py, { color: H.colors.accent, width: 2 });\n// force arrows on the charge\nH.arrow(px, py, px, py + Math.min(40, E * scale * 0.6 + 8), { color: H.colors.accent2, width: 2 });\nH.arrow(px, py, px, py - Math.min(40, v * B * scale * 0.6 + 8), { color: H.colors.violet, width: 2 });\nconst status = Math.abs(net) < 0.05 * (E + 1) ? \"balanced -> passes straight\" : (net > 0 ? \"v too fast -> deflects up\" : \"v too slow -> deflects down\");\nH.text(\"v_select = E/B = \" + vsel.toFixed(2) + \" m/s (probe v = \" + v.toFixed(2) + \") \" + status, 24, 76, { color: H.colors.sub, size: 13 });\n" + }, + { + "id": "apphys-magnetic-flux", + "area": "AP Physics", + "topic": "Magnetic flux", + "title": "Magnetic flux: Phi = B*A*cos(theta)", + "equation": "Phi = B * A * cos(theta)", + "keywords": [ + "magnetic flux", + "phi = b a cos theta", + "weber", + "flux through a loop", + "area vector", + "normal to the loop", + "field times area", + "ap physics", + "induction setup", + "flux linkage", + "b dot a", + "tilted loop" + ], + "explanation": "Magnetic flux Phi measures how much magnetic field passes through a loop: Phi = B*A*cos(theta), where theta is the angle between B and the loop's NORMAL (not the loop's plane), and the SI unit is the weber (1 Wb = 1 T*m^2). Flux is maximum (Phi = BA) when the loop faces the field head-on (theta = 0), zero when the loop is edge-on (theta = 90 deg), and reverses sign when the loop flips past 90 deg. On the AP exam, flux is the quantity whose CHANGE drives induction (Faraday's law), so getting theta measured from the normal is essential. The animation shows the loop foreshortening from a full circle (face-on) to a thin line (edge-on) as you turn theta, with the flux gauge tracking B*A*cos(theta).", + "bullets": [ + "theta is measured from the loop's NORMAL to B — face-on is theta = 0, not 90.", + "Phi is maximum BA at theta = 0 and exactly zero at theta = 90 deg (edge-on).", + "Doubling B or doubling area A doubles the flux; both enter linearly.", + "Units: Phi in webers (Wb) = T*m^2 — this is the quantity Faraday's law differentiates." + ], + "params": [ + { + "name": "B", + "label": "field B (T)", + "min": -1, + "max": 1, + "step": 0.1, + "value": 0.6 + }, + { + "name": "A", + "label": "area A (m^2)", + "min": 0.1, + "max": 3, + "step": 0.1, + "value": 1.5 + }, + { + "name": "angle", + "label": "angle theta (deg)", + "min": 0, + "max": 180, + "step": 5, + "value": 30 + } + ], + "code": "H.background();\nconst B = P.B, A = P.A, ang = P.angle * Math.PI / 180;\nconst flux = B * A * Math.cos(ang);\nH.text(\"Magnetic flux: Phi = B * A * cos(theta)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"theta is between B and the loop's NORMAL; max flux face-on, zero edge-on\", 24, 52, { color: H.colors.sub, size: 13 });\nconst cx = H.W / 2 - 60, cy = H.H / 2 + 20;\nconst span = 360;\nfor (let j = 0; j < 6; j++) {\n const y = cy - 130 + 260 * j / 5;\n H.line(cx - 200, y, cx + 200, y, { color: H.colors.grid, width: 1 });\n const ph = ((t * (0.3 + Math.min(1.5, Math.abs(B) * 0.8)) + j * 0.15) % 1);\n const mx = cx - 200 + ph * span;\n const dir = B >= 0 ? 1 : -1;\n H.arrow(mx - dir * 12, y, mx + dir * 12, y, { color: H.colors.accent, width: 2 });\n}\nH.text(\"B field\", cx - 195, cy - 145, { color: H.colors.accent, size: 12 });\n// loop seen from above: a circle of radius rr whose plane normal makes angle theta with B.\n// Project to an ellipse: full height rr, width rr*cos(theta) (face-on theta=0 -> circle; edge-on -> line).\nconst rr = Math.min(120, 30 + Math.sqrt(Math.max(0.01, A)) * 26);\nconst pts = [];\nfor (let i = 0; i <= 40; i++) {\n const a = i / 40 * Math.PI * 2;\n pts.push([cx + rr * Math.cos(a) * Math.cos(ang), cy + rr * Math.sin(a)]);\n}\nH.path(pts, { color: H.colors.good, width: 3, close: true });\n// normal vector from loop center, in the horizontal plane at angle theta from B (horizontal)\nconst nx = Math.cos(ang), ny = Math.sin(ang);\nconst nlen = rr + 34;\nH.arrow(cx, cy, cx + nx * nlen, cy + ny * nlen, { color: H.colors.violet, width: 2 });\nH.text(\"normal n\", cx + nx * nlen + 4, cy + ny * nlen, { color: H.colors.violet, size: 11 });\n// flux gauge on right\nconst gx = H.W - 70, gy0 = cy - 130, gh = 260, cymid = gy0 + gh / 2;\nH.rect(gx, gy0, 30, gh, { stroke: H.colors.grid, width: 1 });\nconst fmax = Math.max(0.01, Math.abs(B) * A);\nconst frac = fmax > 0 ? flux / fmax : 0;\nconst barH = Math.abs(frac) * (gh / 2);\nH.rect(gx, frac >= 0 ? cymid - barH : cymid, 30, barH, { fill: frac >= 0 ? H.colors.good : H.colors.warn });\nH.line(gx - 6, cymid, gx + 36, cymid, { color: H.colors.axis, width: 1 });\nH.text(\"Phi\", gx + 4, gy0 - 8, { color: H.colors.sub, size: 12 });\nH.text(\"Phi = \" + flux.toFixed(3) + \" Wb (B=\" + B.toFixed(2) + \" T, A=\" + A.toFixed(2) + \" m^2, theta=\" + P.angle.toFixed(0) + \" deg)\", 24, 76, { color: H.colors.sub, size: 13 });\n" + }, + { + "id": "apphys-faradays-law", + "area": "AP Physics", + "topic": "Faraday law of induction", + "title": "Faraday's law: EMF = -N*dPhi/dt", + "equation": "EMF = -N * dPhi/dt", + "keywords": [ + "faraday's law", + "emf = -n dphi/dt", + "electromagnetic induction", + "induced emf", + "rate of change of flux", + "changing magnetic flux", + "n turns", + "induced voltage", + "ap physics", + "minus sign lenz", + "coil emf", + "flux slope" + ], + "explanation": "Faraday's law says a changing magnetic flux induces an EMF: EMF = -N*dPhi/dt, where N is the number of turns and dPhi/dt is the rate of change of flux through one loop. The size of the induced EMF depends only on how FAST the flux changes, not on the flux value itself — a steeper Phi-vs-t slope means a larger voltage, and a constant flux gives zero EMF. The minus sign is Lenz's law: the induced EMF opposes the change that created it. On the AP exam you read dPhi/dt as the slope of a flux-vs-time graph; here the left graph shows Phi(t) with slope dPhi/dt and the right loop drives a current (and bulb brightness) whose magnitude tracks |EMF| = N*|dPhi/dt|.", + "bullets": [ + "EMF depends on the SLOPE dPhi/dt of the flux graph, not on Phi itself.", + "N turns multiply the EMF: EMF = -N*dPhi/dt, so more loops = more voltage.", + "Constant flux (zero slope) gives zero induced EMF — the bulb goes dark.", + "The minus sign encodes Lenz's law: the induced current opposes the flux change." + ], + "params": [ + { + "name": "rate", + "label": "flux rate dPhi/dt (Wb/s)", + "min": -1.5, + "max": 1.5, + "step": 0.1, + "value": 0.8 + }, + { + "name": "N", + "label": "turns N", + "min": 1, + "max": 50, + "step": 1, + "value": 10 + } + ], + "code": "H.background();\nconst rate = P.rate; // dPhi/dt in Wb/s (the controllable slope)\nconst Nturns = P.N;\nconst emf = -Nturns * rate; // EMF = -N dPhi/dt\nH.text(\"Faraday's law: EMF = -N * dPhi/dt\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"A changing flux induces an EMF; steeper flux slope -> bigger induced voltage\", 24, 52, { color: H.colors.sub, size: 13 });\n// LEFT: graph of Phi(t) as a moving line with the chosen slope, oscillating offset so it stays in frame\nconst v = H.plot2d({ xMin: 0, xMax: 4, yMin: -2, yMax: 2, box: { x: 60, y: 90, w: H.W * 0.52, h: H.H - 170 } });\nv.grid(); v.axes();\n// flux ramps with slope=rate; we draw a triangle-wave-ish flux that sweeps so the dot moves and loops\nconst period = 4;\nconst tt = t % period;\n// Phi(t) = rate*(t - 2) clamped into view, plus we sweep a probe dot along it\nv.fn(x => Math.max(-2, Math.min(2, rate * (x - 2))), { color: H.colors.accent, width: 3 });\nconst px = tt;\nconst py = Math.max(-2, Math.min(2, rate * (px - 2)));\nv.dot(px, py, { r: 7, fill: H.colors.accent2 });\nv.text(\"Phi(t), slope = dPhi/dt\", 0.2, 1.7, { color: H.colors.accent, size: 12 });\nv.text(\"t (s)\", 3.5, -0.25, { color: H.colors.sub, size: 11 });\n// RIGHT: induced-EMF bar + a simple loop with a glowing bulb whose brightness ~ |EMF|\nconst bx = H.W * 0.62, by = 100, bw = H.W - bx - 50, bh = H.H - 190;\nH.rect(bx, by, bw, bh, { stroke: H.colors.grid, width: 1, radius: 8 });\nH.text(\"induced loop\", bx + 10, by - 8, { color: H.colors.sub, size: 12 });\nconst lcx = bx + bw / 2, lcy = by + bh / 2;\n// loop\nH.circle(lcx, lcy, 52, { stroke: H.colors.violet, width: 3 });\n// current direction depends on sign of emf; animate a charge dot around the loop, speed ~ |emf|\nconst speed = Math.min(4, Math.abs(emf) * 0.8 + 0.2);\nconst dirSign = emf >= 0 ? 1 : -1;\nconst ca = dirSign * t * speed;\nH.circle(lcx + 52 * Math.cos(ca), lcy + 52 * Math.sin(ca), 7, { fill: H.colors.good });\n// bulb brightness\nconst bright = Math.min(1, Math.abs(emf) / (Math.abs(Nturns) * 2 + 0.5));\nH.circle(lcx, lcy, 14 + 10 * bright, { fill: H.colors.yellow, stroke: H.colors.axis, width: 1 });\nH.text(\"EMF bar:\", bx + 10, by + bh + 22, { color: H.colors.sub, size: 12 });\nconst ebarMax = Math.abs(Nturns) * 2 + 0.5;\nconst ebar = Math.min(1, Math.abs(emf) / ebarMax) * (bw - 90);\nH.rect(bx + 80, by + bh + 12, ebar, 12, { fill: emf >= 0 ? H.colors.good : H.colors.warn });\nH.text(\"EMF = -N*dPhi/dt = \" + emf.toFixed(2) + \" V (N=\" + Nturns.toFixed(0) + \", dPhi/dt=\" + rate.toFixed(2) + \" Wb/s)\", 24, 76, { color: H.colors.sub, size: 13 });\n" + }, + { + "id": "apphys-lenzs-law", + "area": "AP Physics", + "topic": "Lenz law", + "title": "Lenz's law: induced current opposes dPhi/dt", + "equation": "induced current opposes the change in flux (sign of -dPhi/dt)", + "keywords": [ + "lenz's law", + "induced current opposes change", + "magnet and coil", + "repel approaching magnet", + "conservation of energy induction", + "right-hand rule coil", + "induced pole", + "faraday lenz", + "ap physics", + "eddy effect", + "direction of induced current", + "push back" + ], + "explanation": "Lenz's law gives the DIRECTION of an induced current: it always flows so its own magnetic field OPPOSES the change in flux that produced it. When a magnet approaches a coil the flux is rising, so the coil's near face becomes the SAME pole and repels the magnet; when the magnet recedes the flux falls, so the near face becomes the OPPOSITE pole and attracts it back. This 'opposition' is required by conservation of energy — if the coil helped the motion you'd get free kinetic energy. On the AP exam you use Lenz's law to pick the sign in EMF = -dPhi/dt and to predict the induced-current direction; the animation shows the magnet oscillating, the coil's near-face pole flipping between N and S, the circulating induced current reversing, and the Lenz force always fighting the magnet's motion.", + "bullets": [ + "Induced current opposes the CHANGE in flux, not the flux itself.", + "Approaching magnet (flux rising) -> coil's near face becomes the SAME pole -> repels it.", + "Receding magnet (flux falling) -> near face becomes OPPOSITE pole -> attracts it.", + "The opposing force is required by energy conservation — it's the source of the minus sign in Faraday's law." + ], + "params": [ + { + "name": "speed", + "label": "magnet speed (m/s)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "pole", + "label": "facing pole (+1=N, -1=S)", + "min": -1, + "max": 1, + "step": 2, + "value": 1 + } + ], + "code": "H.background();\nconst speed = P.speed; // magnet oscillation speed scale (m/s)\nconst pole = P.pole; // +1 = N pole facing coil, -1 = S pole facing coil\nH.text(\"Lenz's law: induced current opposes the CHANGE in flux\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Approaching N pole -> coil presents its N face and repels the magnet\", 24, 52, { color: H.colors.sub, size: 13 });\nconst coilX = H.W * 0.62, cy = H.H / 2 + 10;\nconst coilH = 150;\nfor (let k = 0; k < 7; k++) {\n const y = cy - coilH / 2 + coilH * k / 6;\n H.circle(coilX, y, 46, { stroke: H.colors.grid, width: 1 });\n}\nH.rect(coilX - 50, cy - coilH / 2 - 10, 100, coilH + 20, { stroke: H.colors.violet, width: 2, radius: 8 });\nH.text(\"coil\", coilX - 14, cy - coilH / 2 - 18, { color: H.colors.violet, size: 12 });\n// magnet oscillates toward/away so it stays in frame; its instantaneous velocity drives the physics\nconst w = 0.4 + Math.abs(speed) * 0.25;\nconst amp = 150;\nconst mx = (coilX - 250) + amp * (1 + Math.sin(t * w)) / 2;\nconst vinst = amp * w * Math.cos(t * w) / 2; // +x = toward coil\nconst approaching = vinst > 0;\nconst mw = 90, mh = 46;\nconst nFacingCoil = pole >= 0; // N pole on the coil (right) side?\nH.rect(mx - mw / 2, cy - mh / 2, mw / 2, mh, { fill: nFacingCoil ? H.colors.warn : H.colors.accent });\nH.rect(mx, cy - mh / 2, mw / 2, mh, { fill: nFacingCoil ? H.colors.accent : H.colors.warn });\nH.text(nFacingCoil ? \"S\" : \"N\", mx - mw / 4, cy, { color: H.colors.ink, size: 14, align: \"center\", baseline: \"middle\", weight: 700 });\nH.text(nFacingCoil ? \"N\" : \"S\", mx + mw / 4, cy, { color: H.colors.ink, size: 14, align: \"center\", baseline: \"middle\", weight: 700 });\nH.arrow(mx, cy - mh / 2 - 16, mx + (vinst >= 0 ? 36 : -36), cy - mh / 2 - 16, { color: H.colors.yellow, width: 2 });\nH.text(\"v\", mx + (vinst >= 0 ? 40 : -50), cy - mh / 2 - 16, { color: H.colors.yellow, size: 12, baseline: \"middle\" });\n// Sign of dPhi/dt: facing-pole base sign s, times +1 if approaching (|flux| growing) else -1.\nconst s = nFacingCoil ? 1 : -1;\nconst dPhi = s * (approaching ? 1 : -1);\n// Coil opposes: near face repels an approaching magnet (same pole), attracts a receding one.\nconst nearFaceN = approaching ? nFacingCoil : !nFacingCoil;\nH.text(\"coil near face: \" + (nearFaceN ? \"N\" : \"S\"), coilX - 36, cy + coilH / 2 + 24, { color: nearFaceN ? H.colors.warn : H.colors.accent, size: 13 });\n// induced current dot circulating; direction flips with sign of dPhi/dt, speed ~ |induced I|\nconst Iind = Math.min(4, Math.abs(speed) * 0.9 + 0.3);\nconst ca = (dPhi > 0 ? 1 : -1) * t * Iind;\nH.circle(coilX + 46 * Math.cos(ca), cy + 46 * Math.sin(ca), 7, { fill: H.colors.good });\n// Lenz force on magnet: pushes it away when approaching, pulls it back when receding\nconst fdir = approaching ? -1 : 1;\nH.arrow(mx + mw / 2 + 6, cy, mx + mw / 2 + 6 + fdir * 34, cy, { color: H.colors.accent2, width: 2 });\nH.text(\"F_coil\", mx + mw / 2 + 10, cy - 14, { color: H.colors.accent2, size: 11 });\nconst motion = approaching ? \"approaching -> flux rising -> coil REPELS\" : \"receding -> flux falling -> coil ATTRACTS\";\nH.text(motion + \" (|I_induced| ~ \" + Iind.toFixed(1) + \", speed=\" + speed.toFixed(1) + \" m/s)\", 24, 76, { color: H.colors.sub, size: 13 });\n" + }, + { + "id": "apphys-motional-emf", + "area": "AP Physics", + "topic": "Motional EMF", + "title": "Motional EMF: EMF = B L v", + "equation": "EMF = B * L * v", + "keywords": [ + "motional emf", + "blv", + "induced emf", + "faraday law", + "magnetic flux", + "conducting rod on rails", + "electromagnetic induction", + "induced voltage", + "ap physics", + "flux change", + "moving rod magnetic field" + ], + "explanation": "When a conducting rod of length L slides at speed v along rails through a uniform magnetic field B (perpendicular to the plane), the change in enclosed flux Phi = B*A induces an EMF of magnitude EMF = B*L*v. This is a special case of Faraday's law, EMF = -dPhi/dt, because the swept area changes at rate L*v. On the AP exam you use the right-hand rule (or F = qv x B on charge carriers) to find polarity, and Lenz's law to argue the induced current opposes the motion, producing a retarding force F = B*I*L. The animation sweeps the rod back and forth and shows the live EMF in volts as B, L, and v change.", + "bullets": [ + "EMF magnitude is the product B*L*v — linear in each factor.", + "The induced EMF comes from the changing enclosed area (dPhi/dt = B*L*v).", + "Right-hand rule sets the polarity; Lenz's law makes the current oppose the motion.", + "B is into the page (x symbols); v (green) is the rod's velocity along the rails." + ], + "params": [ + { + "name": "B", + "label": "field B (T)", + "min": 0.1, + "max": 2, + "step": 0.1, + "value": 0.5 + }, + { + "name": "L", + "label": "rod length L (m)", + "min": 0.2, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "v", + "label": "speed v (m/s)", + "min": 0.5, + "max": 8, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst B = P.B, L = P.L, v = P.v;\nH.text(\"Motional EMF: EMF = B L v\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"A rod sliding on rails through B sweeps area, inducing an EMF\", 24, 52, { color: H.colors.sub, size: 13 });\nconst railL = 240, railR = 760, railTop = 140, railBot = 140 + 160;\nfor (let i = 0; i < 8; i++) {\n for (let j = 0; j < 4; j++) {\n const fx = railL + 30 + i * 64;\n const fy = railTop + 22 + j * 40;\n H.circle(fx, fy, 2, { fill: H.colors.violet });\n H.text(\"x\", fx - 4, fy + 26, { color: H.colors.grid, size: 11 });\n }\n}\nH.text(\"B into page\", railL, railTop - 14, { color: H.colors.violet, size: 12 });\nH.line(railL, railTop, railR, railTop, { color: H.colors.axis, width: 3 });\nH.line(railL, railBot, railR, railBot, { color: H.colors.axis, width: 3 });\nconst span = (railR - 60) - (railL + 60);\nconst xr = (railL + 60) + (span * 0.5) * (1 + Math.sin(t * 0.8));\nH.line(xr, railTop - 6, xr, railBot + 6, { color: H.colors.accent2, width: 5 });\nconst dir = Math.cos(t * 0.8) >= 0 ? 1 : -1;\nH.arrow(xr, (railTop + railBot) / 2, xr + dir * 46, (railTop + railBot) / 2, { color: H.colors.good, width: 3 });\nH.text(\"v\", xr + dir * 30, (railTop + railBot) / 2 - 12, { color: H.colors.good, size: 13 });\nconst emf = B * L * v;\nH.rect(railL - 36, railTop, 36, railBot - railTop, { stroke: H.colors.sub, width: 2, radius: 4 });\nH.text(\"EMF\", railL - 60, (railTop + railBot) / 2, { color: H.colors.sub, size: 12, align: \"right\" });\nH.text(\"EMF = B*L*v = \" + emf.toFixed(2) + \" V\", 24, 80, { color: H.colors.accent, size: 14 });\nH.text(\"B = \" + B.toFixed(2) + \" T L = \" + L.toFixed(2) + \" m v = \" + v.toFixed(2) + \" m/s\", 24, 100, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"B field (into page)\", color: H.colors.violet }, { label: \"rod velocity v\", color: H.colors.good }], 560, 470);" + }, + { + "id": "apphys-ac-generator", + "area": "AP Physics", + "topic": "AC generator", + "title": "AC generator: EMF = e0 sin(w t)", + "equation": "EMF = e0 * sin(w * t), e0 = N*B*A*w", + "keywords": [ + "ac generator", + "alternating current", + "emf sinusoidal", + "peak emf", + "e0 sin wt", + "faraday law", + "rotating coil", + "angular frequency", + "ap physics", + "induced emf", + "electromagnetic induction" + ], + "explanation": "A coil of N turns and area A rotating at angular frequency w in a uniform field B has flux Phi = N*B*A*cos(w*t), so by Faraday's law the induced EMF = -dPhi/dt = N*B*A*w*sin(w*t) = e0*sin(w*t), where the peak EMF is e0 = N*B*A*w. The EMF is maximum when the coil's plane is parallel to B (flux changing fastest) and zero when the plane is perpendicular to B (flux maximal, momentarily not changing). On the AP exam, raising w both increases the frequency AND the peak amplitude (e0 is proportional to w). The animation rotates the coil edge-on while the right panel traces the sinusoidal output and a probe rides the wave.", + "bullets": [ + "EMF is a sine wave; its peak e0 = N*B*A*w grows with rotation rate w.", + "EMF is largest when flux changes fastest (coil plane parallel to B).", + "EMF is zero when the flux is maximal (coil plane perpendicular to B).", + "Increasing w raises BOTH the frequency and the amplitude of the output." + ], + "params": [ + { + "name": "w", + "label": "angular freq w (rad/s)", + "min": 0.5, + "max": 4, + "step": 0.25, + "value": 1.5 + }, + { + "name": "e0", + "label": "peak EMF e0 (V)", + "min": 0.2, + "max": 1.1, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst w = P.w, e0 = P.e0;\nH.text(\"AC generator: EMF = e0 sin(w t)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"A coil rotating in B gives a sinusoidal EMF; e0 = N*B*A*w is the peak\", 24, 52, { color: H.colors.sub, size: 13 });\nconst cx = 180, cy = 300, R = 90;\nH.text(\"B field\", cx - 70, cy - 130, { color: H.colors.violet, size: 12 });\nfor (let i = -2; i <= 2; i++) {\n H.line(cx + i * 30, cy - 120, cx + i * 30, cy + 120, { color: H.colors.grid, width: 1 });\n}\nconst ang = w * t;\nconst ex = Math.cos(ang) * R, ey = Math.sin(ang) * R * 0.45;\nH.path([[cx - ex, cy - ey], [cx + ex, cy + ey]], { color: H.colors.accent2, width: 5 });\nH.circle(cx - ex, cy - ey, 6, { fill: H.colors.good });\nH.circle(cx + ex, cy + ey, 6, { fill: H.colors.warn });\nH.text(\"coil\", cx - 18, cy + 140, { color: H.colors.sub, size: 12 });\nconst vv = H.plot2d({ xMin: 0, xMax: 8, yMin: -1.2, yMax: 1.2, box: { x: 420, y: 120, w: 440, h: 320 } });\nvv.grid(); vv.axes();\nvv.fn(x => e0 * Math.sin(w * x), { color: H.colors.accent, width: 3 });\nconst tw = t % 8;\nvv.dot(tw, e0 * Math.sin(w * tw), { r: 6, fill: H.colors.warn });\nconst emf = e0 * Math.sin(ang);\nH.text(\"EMF = \" + emf.toFixed(2) + \" V\", 24, 80, { color: H.colors.accent, size: 14 });\nH.text(\"e0 = \" + e0.toFixed(2) + \" V (peak) w = \" + w.toFixed(2) + \" rad/s wt = \" + ang.toFixed(2) + \" rad\", 24, 100, { color: H.colors.sub, size: 13 });\nvv.text(\"e0 sin(w t)\", 0.4, e0 * 0.9, { color: H.colors.accent, size: 12 });" + }, + { + "id": "apphys-reflection-mirror", + "area": "AP Physics", + "topic": "Concave mirror image", + "title": "Concave mirror: 1/f = 1/do + 1/di", + "equation": "1/f = 1/do + 1/di, m = -di/do", + "keywords": [ + "concave mirror", + "mirror equation", + "1/f=1/do+1/di", + "focal length", + "image distance", + "magnification", + "real image", + "virtual image", + "ap physics", + "ray optics", + "inverted image" + ], + "explanation": "For a concave (converging) mirror the mirror equation 1/f = 1/do + 1/di relates the focal length f, object distance do, and image distance di, with magnification m = -di/do. Using the convention that distances in front of the mirror are positive: when do > f the image is real (di > 0), inverted (m < 0), and on the same side; when do < f the image is virtual (di < 0), upright (m > 0), and enlarged. At do = f the rays leave parallel and no image forms (di -> infinity). The animation slides the object along the axis and recomputes di and m live, drawing the image arrow up or down to show upright vs. inverted.", + "bullets": [ + "Object beyond f (do > f): real, inverted image (di > 0, m < 0).", + "Object inside f (do < f): virtual, upright, enlarged image (di < 0, m > 0).", + "At do = f the image is at infinity — reflected rays are parallel.", + "Magnification m = -di/do gives both the size and orientation of the image." + ], + "params": [ + { + "name": "f", + "label": "focal length f (cm)", + "min": 4, + "max": 18, + "step": 1, + "value": 10 + }, + { + "name": "do", + "label": "object dist do (cm)", + "min": 3, + "max": 40, + "step": 1, + "value": 24 + } + ], + "code": "H.background();\nconst f = P.f, doSlider = P.do;\nH.text(\"Concave mirror: 1/f = 1/do + 1/di\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Image distance di and magnification m = -di/do vary with object distance do\", 24, 52, { color: H.colors.sub, size: 13 });\nconst xV = 720, yAx = 320;\nH.line(120, yAx, 820, yAx, { color: H.colors.axis, width: 1 });\nconst mpts = [];\nfor (let i = -90; i <= 90; i += 6) {\n const a = i * Math.PI / 180;\n mpts.push([xV - (1 - Math.cos(a)) * 40, yAx + Math.sin(a) * 110]);\n}\nH.path(mpts, { color: H.colors.sub, width: 3 });\nconst scale = 9;\nconst doVal = Math.max(2, doSlider + 6 * Math.sin(t * 0.5));\nconst xObj = xV - doVal * scale;\nconst hObj = 60;\nH.arrow(xObj, yAx, xObj, yAx - hObj, { color: H.colors.good, width: 3 });\nH.text(\"object\", xObj - 16, yAx + 18, { color: H.colors.good, size: 12 });\nconst xF = xV - f * scale;\nH.circle(xF, yAx, 4, { fill: H.colors.yellow });\nH.text(\"F\", xF - 3, yAx + 18, { color: H.colors.yellow, size: 12 });\nlet diStr, mStr;\nconst denom = (1 / f) - (1 / doVal);\nif (Math.abs(denom) < 1e-4) {\n diStr = \"di -> infinity (object at F)\";\n H.text(\"rays emerge parallel: no image forms\", 24, 124, { color: H.colors.warn, size: 13 });\n} else {\n const di = 1 / denom;\n const m = -di / doVal;\n const xImg = xV - di * scale;\n const hImg = m * hObj;\n const xImgC = Math.max(130, Math.min(810, xImg));\n H.arrow(xImgC, yAx, xImgC, yAx - hImg, { color: H.colors.warn, width: 3 });\n H.text(di > 0 ? \"real image\" : \"virtual image\", xImgC - 20, yAx + 34, { color: H.colors.warn, size: 12 });\n diStr = \"di = \" + di.toFixed(1) + \" cm\";\n mStr = \"m = \" + m.toFixed(2);\n H.text(\"m = -di/do = \" + m.toFixed(2) + (m < 0 ? \" (inverted)\" : \" (upright)\"), 24, 124, { color: H.colors.accent2, size: 13 });\n}\nH.text(diStr, 24, 80, { color: H.colors.accent, size: 14 });\nH.text(\"f = \" + f.toFixed(1) + \" cm do = \" + doVal.toFixed(1) + \" cm\", 24, 102, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"object\", color: H.colors.good }, { label: \"image\", color: H.colors.warn }], 120, 470);" + }, + { + "id": "apphys-snells-law", + "area": "AP Physics", + "topic": "Refraction / Snell law", + "title": "Snell's law: n1 sin(th1) = n2 sin(th2)", + "equation": "n1 * sin(th1) = n2 * sin(th2)", + "keywords": [ + "snell law", + "refraction", + "index of refraction", + "bending of light", + "n1 sin theta1", + "angle of refraction", + "optics", + "normal line", + "ap physics", + "light bends", + "critical angle" + ], + "explanation": "Snell's law n1*sin(th1) = n2*sin(th2) governs how a light ray bends as it crosses a boundary between media of refractive indices n1 and n2, with angles measured from the normal. Going into a denser (higher n) medium the ray bends toward the normal (th2 < th1); into a rarer medium it bends away. The product n*sin(theta) is invariant across the interface, which is the quantity students track on the AP exam. When n1 > n2 and the required sin(th2) exceeds 1 there is no refracted ray — total internal reflection occurs. The animation sweeps the incidence angle and draws the refracted (or reflected) ray while reporting both angles and the conserved n*sin(theta).", + "bullets": [ + "Angles are measured from the NORMAL, not from the surface.", + "Into a denser medium (n2 > n1) the ray bends toward the normal (th2 < th1).", + "The product n*sin(theta) is conserved across the boundary.", + "If (n1/n2)*sin(th1) > 1 there is no refracted ray: total internal reflection." + ], + "params": [ + { + "name": "th1", + "label": "incidence th1 (deg)", + "min": 5, + "max": 80, + "step": 1, + "value": 40 + }, + { + "name": "n1", + "label": "index n1", + "min": 1, + "max": 2.4, + "step": 0.05, + "value": 1 + }, + { + "name": "n2", + "label": "index n2", + "min": 1, + "max": 2.4, + "step": 0.05, + "value": 1.5 + } + ], + "code": "H.background();\nconst th1deg = P.th1, n1 = P.n1, n2 = P.n2;\nH.text(\"Snell's law: n1 sin(th1) = n2 sin(th2)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Light bends toward the normal when entering a denser (higher n) medium\", 24, 52, { color: H.colors.sub, size: 13 });\nconst ix = 450, iy = 300;\nH.line(120, iy, 820, iy, { color: H.colors.axis, width: 2 });\nH.rect(120, iy, 700, 180, { fill: H.colors.panel });\nH.text(\"medium 1 n1 = \" + n1.toFixed(2), 140, iy - 14, { color: H.colors.sub, size: 12 });\nH.text(\"medium 2 n2 = \" + n2.toFixed(2), 140, iy + 24, { color: H.colors.sub, size: 12 });\nH.line(ix, iy - 160, ix, iy + 160, { color: H.colors.grid, width: 1, dash: [6, 6] });\nH.text(\"normal\", ix + 6, iy - 150, { color: H.colors.grid, size: 11 });\nconst th1 = (Math.max(5, Math.min(80, th1deg + 18 * Math.sin(t * 0.5)))) * Math.PI / 180;\nconst Lr = 180;\nconst ax = ix - Lr * Math.sin(th1), ay = iy - Lr * Math.cos(th1);\nH.arrow(ax, ay, ix, iy, { color: H.colors.accent, width: 3 });\nH.text(\"incident\", ax - 10, ay - 8, { color: H.colors.accent, size: 12 });\nconst sinth2 = (n1 / n2) * Math.sin(th1);\nlet th2deg;\nif (sinth2 > 1) {\n const rx = ix + Lr * Math.sin(th1), ry = iy - Lr * Math.cos(th1);\n H.arrow(ix, iy, rx, ry, { color: H.colors.warn, width: 3 });\n H.text(\"TOTAL INTERNAL REFLECTION\", ix - 40, iy - 170, { color: H.colors.warn, size: 12 });\n th2deg = NaN;\n H.text(\"sin(th2) = (n1/n2) sin(th1) > 1 -> no refraction\", 24, 124, { color: H.colors.warn, size: 13 });\n} else {\n const th2 = Math.asin(sinth2);\n const bx = ix + Lr * Math.sin(th2), by = iy + Lr * Math.cos(th2);\n H.arrow(ix, iy, bx, by, { color: H.colors.good, width: 3 });\n H.text(\"refracted\", bx + 4, by + 6, { color: H.colors.good, size: 12 });\n th2deg = th2 * 180 / Math.PI;\n H.text(\"th2 = \" + th2deg.toFixed(1) + \" deg\", 24, 124, { color: H.colors.good, size: 13 });\n}\nH.text(\"th1 = \" + (th1 * 180 / Math.PI).toFixed(1) + \" deg\", 24, 80, { color: H.colors.accent, size: 14 });\nH.text(\"n1 sin th1 = \" + (n1 * Math.sin(th1)).toFixed(3) + \" (invariant across interface)\", 24, 102, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apphys-total-internal-reflection", + "area": "AP Physics", + "topic": "Total internal reflection", + "title": "Total internal reflection: sin(thc) = n2/n1", + "equation": "sin(thc) = n2 / n1 (requires n1 > n2)", + "keywords": [ + "total internal reflection", + "critical angle", + "tir", + "sin thc = n2/n1", + "fiber optic", + "refraction", + "optics", + "dense to rare", + "ap physics", + "snell law", + "critical angle formula" + ], + "explanation": "Total internal reflection happens when light travels from a denser medium to a rarer one (n1 > n2) and strikes the boundary at an angle beyond the critical angle thc, defined by sin(thc) = n2/n1. At th1 = thc the refracted ray grazes along the surface (th2 = 90 deg); for any th1 > thc no refraction is possible and 100% of the light reflects back into the dense medium. This is the principle behind optical fibers and prisms on the AP exam, where you set sin(th2) = 1 in Snell's law to derive the critical angle. The animation sweeps the incidence angle past thc, switching between a transmitted ray and a fully reflected one while showing the critical angle and angle live.", + "bullets": [ + "TIR needs a dense-to-rare boundary: n1 > n2 (otherwise no critical angle).", + "At the critical angle the refracted ray bends to 90 deg (grazes the surface).", + "Beyond thc, light is reflected with 100% of its energy (yellow dashed = thc).", + "Derive thc by setting th2 = 90 deg in Snell's law: sin(thc) = n2/n1." + ], + "params": [ + { + "name": "th1", + "label": "incidence th1 (deg)", + "min": 5, + "max": 85, + "step": 1, + "value": 50 + }, + { + "name": "n1", + "label": "dense index n1", + "min": 1.2, + "max": 2.4, + "step": 0.05, + "value": 1.5 + }, + { + "name": "n2", + "label": "rare index n2", + "min": 1, + "max": 1.6, + "step": 0.05, + "value": 1 + } + ], + "code": "H.background();\nconst th1deg = P.th1, n1 = P.n1, n2 = P.n2;\nH.text(\"Total internal reflection: sin(thc) = n2/n1\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"From dense to rare (n1>n2), beyond the critical angle thc light fully reflects\", 24, 52, { color: H.colors.sub, size: 13 });\nconst ix = 450, iy = 280;\nH.line(120, iy, 820, iy, { color: H.colors.axis, width: 2 });\nH.rect(120, iy, 700, 200, { fill: H.colors.panel });\nH.text(\"dense n1 = \" + n1.toFixed(2), 140, iy + 24, { color: H.colors.sub, size: 12 });\nH.text(\"rare n2 = \" + n2.toFixed(2), 140, iy - 14, { color: H.colors.sub, size: 12 });\nH.line(ix, iy - 150, ix, iy + 170, { color: H.colors.grid, width: 1, dash: [6, 6] });\nconst ratio = n2 / n1;\nlet thc = ratio < 1 ? Math.asin(ratio) : Math.PI / 2;\nconst thcDeg = thc * 180 / Math.PI;\nconst Lc = 175;\nconst cxp = ix + Lc * Math.sin(thc), cyp = iy + Lc * Math.cos(thc);\nH.line(ix, iy, cxp, cyp, { color: H.colors.yellow, width: 1, dash: [4, 4] });\nH.text(\"thc\", cxp + 4, cyp, { color: H.colors.yellow, size: 11 });\nconst th1 = (Math.max(5, Math.min(85, th1deg + 22 * Math.sin(t * 0.5)))) * Math.PI / 180;\nconst Lr = 175;\nconst ax = ix - Lr * Math.sin(th1), ay = iy + Lr * Math.cos(th1);\nH.arrow(ax, ay, ix, iy, { color: H.colors.accent, width: 3 });\nH.text(\"incident\", ax - 10, ay + 14, { color: H.colors.accent, size: 12 });\nconst sinth2 = (n1 / n2) * Math.sin(th1);\nlet status;\nif (sinth2 >= 1) {\n const rx = ix + Lr * Math.sin(th1), ry = iy + Lr * Math.cos(th1);\n H.arrow(ix, iy, rx, ry, { color: H.colors.warn, width: 4 });\n H.text(\"reflected (100%)\", rx + 4, ry, { color: H.colors.warn, size: 12 });\n status = \"th1 >= thc -> TOTAL INTERNAL REFLECTION\";\n H.text(status, 24, 124, { color: H.colors.warn, size: 13 });\n} else {\n const th2 = Math.asin(sinth2);\n const bx = ix + Lr * Math.sin(th2), by = iy - Lr * Math.cos(th2);\n H.arrow(ix, iy, bx, by, { color: H.colors.good, width: 3 });\n H.text(\"refracted out\", bx + 4, by, { color: H.colors.good, size: 12 });\n const rx = ix + Lr * 0.5 * Math.sin(th1), ry = iy + Lr * 0.5 * Math.cos(th1);\n H.line(ix, iy, rx, ry, { color: H.colors.warn, width: 1, dash: [3, 3] });\n status = \"th1 < thc -> refracts (partial reflection)\";\n H.text(status, 24, 124, { color: H.colors.good, size: 13 });\n}\nH.text(\"thc = \" + thcDeg.toFixed(1) + \" deg\", 24, 80, { color: H.colors.yellow, size: 14 });\nH.text(\"th1 = \" + (th1 * 180 / Math.PI).toFixed(1) + \" deg sin(thc) = n2/n1 = \" + ratio.toFixed(3), 24, 102, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apphys-thin-lens", + "area": "AP Physics", + "topic": "Thin lens image", + "title": "Thin lens equation: 1/f = 1/do + 1/di", + "equation": "1/f = 1/do + 1/di , m = -di/do", + "keywords": [ + "thin lens", + "lens equation", + "1/f = 1/do + 1/di", + "image distance", + "magnification", + "converging lens", + "real image", + "virtual image", + "ray diagram", + "focal length", + "optics", + "ap physics" + ], + "explanation": "For a thin lens the object distance do, image distance di, and focal length f obey 1/f = 1/do + 1/di, and the magnification is m = -di/do. With a converging lens (f > 0): when do > f the image is real (di > 0), inverted (m < 0), and forms on the far side; when do < f the image is virtual (di < 0), upright, and enlarged. The ray diagram uses the parallel ray (refracts through the far focal point) and the chief ray (straight through the center) to locate the image tip. On the AP exam, sign conventions are the trap: di > 0 means real/inverted, di < 0 means virtual/upright, and as do approaches f the image races to infinity.", + "bullets": [ + "The two yellow F dots mark the focal points at distance |f| from the lens.", + "Parallel ray bends through the far focus; center ray goes straight — they cross at the image tip.", + "Real image (di>0) appears inverted on the opposite side; virtual (di<0) stays upright on the object side.", + "Live readout shows di and m = -di/do; m<0 means inverted, |m|>1 means enlarged." + ], + "params": [ + { + "name": "dov", + "label": "object distance do (cm)", + "min": 2, + "max": 40, + "step": 0.5, + "value": 18 + }, + { + "name": "f", + "label": "focal length f (cm)", + "min": 4, + "max": 24, + "step": 0.5, + "value": 10 + } + ], + "code": "H.background();\nconst W = H.W, Hh = H.H;\nconst f = P.f;\nconst dov = Math.max(P.dov, 0.2);\nconst cx = W * 0.5, ay = Hh * 0.52;\nconst ppu = 9;\nH.line(40, ay, W - 40, ay, { color: H.colors.axis, width: 1 });\nconst lensH = 150;\nH.line(cx, ay - lensH/2, cx, ay + lensH/2, { color: H.colors.accent, width: 3 });\nH.path([[cx-8, ay-lensH/2+10],[cx, ay-lensH/2],[cx+8, ay-lensH/2+10]], { color: H.colors.accent, width: 2 });\nH.path([[cx-8, ay+lensH/2-10],[cx, ay+lensH/2],[cx+8, ay+lensH/2-10]], { color: H.colors.accent, width: 2 });\nconst fx = Math.abs(f) * ppu;\nH.circle(cx - fx, ay, 3, { fill: H.colors.yellow });\nH.circle(cx + fx, ay, 3, { fill: H.colors.yellow });\nH.text(\"F\", cx - fx, ay + 16, { color: H.colors.sub, size: 11, align: \"center\" });\nH.text(\"F\", cx + fx, ay + 16, { color: H.colors.sub, size: 11, align: \"center\" });\nconst hoBase = 6;\nconst ho = hoBase * (0.7 + 0.3 * Math.sin(t * 1.2));\nconst ox = cx - dov * ppu;\nconst otop = ay - ho * ppu;\nH.arrow(ox, ay, ox, otop, { color: H.colors.good, width: 3 });\nlet di, hi, valid = true;\nconst inv = 1/f - 1/dov;\nif (Math.abs(inv) < 1e-4) { valid = false; di = Infinity; hi = 0; }\nelse { di = 1/inv; hi = -di/dov * ho; }\nconst ix = cx + di * ppu;\nconst itop = ay - hi * ppu;\nH.line(ox, otop, cx, otop, { color: H.colors.accent2, width: 1.5 });\nif (valid && isFinite(ix)) {\n H.line(cx, otop, ix, itop, { color: H.colors.accent2, width: 1.5 });\n H.line(ox, otop, ix, itop, { color: H.colors.violet, width: 1.5 });\n}\nif (valid && isFinite(ix) && Math.abs(ix - cx) < W) {\n H.arrow(ix, ay, ix, itop, { color: H.colors.warn, width: 3 });\n}\nconst m = valid ? -di/dov : 0;\nH.text(\"Thin lens: 1/f = 1/do + 1/di\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"do = \" + dov.toFixed(1) + \" cm, f = \" + f.toFixed(1) + \" cm\", 24, 52, { color: H.colors.sub, size: 13 });\nif (valid && isFinite(di)) {\n H.text(\"di = \" + di.toFixed(1) + \" cm m = \" + m.toFixed(2) + (di>0?\" (real)\":\" (virtual)\"), 24, 72, { color: H.colors.yellow, size: 13 });\n} else {\n H.text(\"Object at focal point: image at infinity\", 24, 72, { color: H.colors.warn, size: 13 });\n}" + }, + { + "id": "apphys-double-slit", + "area": "AP Physics", + "topic": "Double-slit interference", + "title": "Double-slit maxima: d sinθ = mλ", + "equation": "d sinθ = mλ (bright fringes, m = 0, ±1, ±2, ...)", + "keywords": [ + "double slit", + "interference", + "d sin theta = m lambda", + "bright fringe", + "young's experiment", + "constructive interference", + "path difference", + "wavelength", + "fringe order", + "diffraction", + "two slit", + "ap physics" + ], + "explanation": "In Young's double-slit experiment two coherent slits separated by d produce bright fringes (constructive interference) wherever the path difference equals a whole number of wavelengths: d sinθ = mλ, with m the order. The on-screen intensity follows I = I0 cos²(πd sinθ/λ), peaking at each order. Smaller slit spacing d or longer wavelength λ spreads the fringes to larger angles; larger d packs them closer together. On the AP exam you typically solve for λ, d, or θ from this condition, or use the small-angle form y = mλL/d for fringe positions on a distant screen.", + "bullets": [ + "Intensity peaks (bright fringes) sit exactly where d sinθ = mλ — the dashed yellow lines.", + "Curve is I = I0 cos²(πd sinθ/λ); minima fall halfway between orders.", + "Increasing λ or decreasing d pushes the fringes to larger angles — they spread out.", + "Readout gives the first-order angle θ(m=1) and the live I/I0 at the swept point." + ], + "params": [ + { + "name": "d", + "label": "slit spacing d (µm)", + "min": 5, + "max": 60, + "step": 1, + "value": 20 + }, + { + "name": "lambda", + "label": "wavelength λ (nm)", + "min": 380, + "max": 750, + "step": 10, + "value": 550 + } + ], + "code": "H.background();\nconst W = H.W, Hh = H.H;\nconst d = P.d;\nconst lam = P.lambda;\nconst dm = Math.max(d, 0.5) * 1e-6;\nconst lamm = Math.max(lam, 100) * 1e-9;\nconst v = H.plot2d({ xMin: -3, xMax: 3, yMin: 0, yMax: 1.25 });\nv.grid(); v.axes();\nconst I = (thetaDeg) => {\n const th = thetaDeg * Math.PI / 180;\n const phi = Math.PI * dm * Math.sin(th) / lamm;\n return Math.cos(phi) * Math.cos(phi);\n};\nv.fn(I, { color: H.colors.accent, width: 2.5 });\nfor (let m = -5; m <= 5; m++) {\n const s = m * lamm / dm;\n if (Math.abs(s) <= 1) {\n const thDeg = Math.asin(s) * 180 / Math.PI;\n if (thDeg >= -3 && thDeg <= 3) {\n v.line(thDeg, 0, thDeg, 1, { color: H.colors.yellow, width: 1, dash: [4,4] });\n v.text(\"m=\" + m, thDeg, 1.12, { color: H.colors.sub, size: 10, align: \"center\" });\n }\n }\n};\nconst sweep = 2.6 * Math.sin(t * 0.7);\nv.dot(sweep, I(sweep), { r: 6, fill: H.colors.warn });\nH.text(\"Double slit: d sinθ = mλ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"d = \" + d.toFixed(1) + \" µm, λ = \" + lam.toFixed(0) + \" nm\", 24, 52, { color: H.colors.sub, size: 13 });\nconst s1 = lamm / dm;\nconst th1 = Math.abs(s1) <= 1 ? (Math.asin(s1) * 180 / Math.PI) : NaN;\nH.text(\"θ(m=1) = \" + (isFinite(th1) ? th1.toFixed(2) + \"°\" : \"none\") + \" I/I0 = \" + I(sweep).toFixed(2), 24, 72, { color: H.colors.yellow, size: 13 });" + }, + { + "id": "apphys-diffraction-grating", + "area": "AP Physics", + "topic": "Diffraction grating", + "title": "Diffraction grating: d sinθ = mλ", + "equation": "d sinθ = mλ , d = 1 / (lines per unit length)", + "keywords": [ + "diffraction grating", + "grating equation", + "d sin theta = m lambda", + "lines per mm", + "grating spacing", + "diffraction order", + "spectrum", + "maxima angle", + "interference", + "spectroscopy", + "wavelength", + "ap physics" + ], + "explanation": "A diffraction grating with N lines per millimeter has slit spacing d = 1/N, so its principal maxima obey d sinθ = mλ — the same condition as two slits, but the many slits make each order sharp and bright. More lines/mm means a smaller d, which throws each order to a larger angle and spreads the spectrum; eventually high orders disappear once mλ/d exceeds 1 (sinθ can't exceed 1). The animation fans out the visible orders for 550 nm light and labels each angle. On the AP exam this is used to find wavelength from a measured angle, or to count how many orders a grating can produce.", + "bullets": [ + "Each diffracted ray is an order m where d sinθ = mλ; m=0 is the straight-through beam.", + "More lines/mm shrinks d, so the same order m appears at a larger angle θ.", + "Orders exist only while mλ/d ≤ 1 — the readout shows the highest visible order.", + "Spacing d (nm) and first-order angle θ(m=1) update live as you change lines/mm." + ], + "params": [ + { + "name": "lines", + "label": "grating density (lines/mm)", + "min": 100, + "max": 1200, + "step": 50, + "value": 600 + } + ], + "code": "H.background();\nconst W = H.W, Hh = H.H;\nconst linespermm = Math.max(P.lines, 50);\nconst lam = 550e-9;\nconst d = 1 / (linespermm * 1000);\nconst cx = W * 0.5, sy = Hh * 0.30;\nconst R = 230;\nH.line(cx, sy + 6, cx, sy + 90, { color: H.colors.accent, width: 4 });\nH.text(\"grating\", cx + 8, sy + 50, { color: H.colors.sub, size: 11 });\nH.arrow(cx - 160, sy + 48, cx - 6, sy + 48, { color: H.colors.good, width: 3 });\nlet maxOrder = 0;\nfor (let m = 0; m <= 6; m++) {\n const s = m * lam / d;\n if (Math.abs(s) <= 1) maxOrder = m;\n}\nconst apex = { x: cx, y: sy + 48 };\nfor (let m = -maxOrder; m <= maxOrder; m++) {\n const s = m * lam / d;\n if (Math.abs(s) > 1) continue;\n const th = Math.asin(s);\n const ex = apex.x + R * Math.cos(th);\n const ey = apex.y + R * Math.sin(th);\n const shimmer = 0.6 + 0.4 * Math.abs(Math.sin(t * 1.5 + m));\n const col = m === 0 ? H.colors.ink : H.colors.violet;\n H.line(apex.x, apex.y, ex, ey, { color: col, width: m === 0 ? 2.5 : 1.8 });\n const thDeg = th * 180 / Math.PI;\n H.text(\"m=\" + m + \" (\" + thDeg.toFixed(1) + \"°)\", ex + (m<0?-50:6), ey, { color: H.colors.sub, size: 10 });\n};\nH.text(\"Diffraction grating: d sinθ = mλ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(linespermm.toFixed(0) + \" lines/mm → d = \" + (d*1e9).toFixed(0) + \" nm, λ = 550 nm\", 24, 52, { color: H.colors.sub, size: 13 });\nconst th1 = lam/d <= 1 ? Math.asin(lam/d)*180/Math.PI : NaN;\nH.text(\"θ(m=1) = \" + (isFinite(th1)? th1.toFixed(1)+\"°\":\"none\") + \" visible orders: ±\" + maxOrder, 24, 72, { color: H.colors.yellow, size: 13 });" + }, + { + "id": "apphys-photoelectric", + "area": "AP Physics", + "topic": "Photoelectric effect", + "title": "Photoelectric effect: KEmax = hf − φ", + "equation": "KEmax = hf - φ , threshold f0 = φ/h", + "keywords": [ + "photoelectric effect", + "kemax = hf - phi", + "work function", + "threshold frequency", + "stopping potential", + "einstein photoelectric", + "planck constant", + "photon", + "quantum", + "metal surface", + "electron emission", + "ap physics" + ], + "explanation": "Einstein's photoelectric equation says a single photon of energy hf ejects an electron only if its energy exceeds the metal's work function φ; the surplus becomes the electron's maximum kinetic energy: KEmax = hf − φ. Below the threshold frequency f0 = φ/h no electrons are emitted no matter how intense the light — emission depends on frequency, not brightness. Plotting KEmax versus f gives a straight line of slope h (Planck's constant) with x-intercept f0 and y-intercept −φ. On the AP exam this graph, the threshold condition, and the link to stopping potential (eV_stop = KEmax) are the classic test points.", + "bullets": [ + "KEmax vs f is a straight line of slope h; the dashed line marks threshold f0 = φ/h.", + "Below f0 the physical KE is zero — frequency, not intensity, controls emission.", + "The y-intercept of the line sits at −φ (pink dot): bigger work function shifts it down.", + "Green sweep dot means electrons escape (KE>0); pink means below threshold, no emission." + ], + "params": [ + { + "name": "fHz", + "label": "frequency f (×10¹⁴ Hz)", + "min": 1, + "max": 20, + "step": 0.5, + "value": 10 + }, + { + "name": "phi", + "label": "work function φ (eV)", + "min": 1, + "max": 5, + "step": 0.1, + "value": 2.3 + } + ], + "code": "H.background();\nconst W = H.W, Hh = H.H;\nconst f = Math.max(P.fHz, 0.1);\nconst phi = Math.max(P.phi, 0.1);\nconst h_eV = 4.1357e-15;\nconst fHz = f * 1e14;\nconst KE = h_eV * fHz - phi;\nconst f0 = phi / h_eV;\nconst f0u = f0 / 1e14;\nconst v = H.plot2d({ xMin: 0, xMax: 25, yMin: -1, yMax: 6 });\nv.grid(); v.axes();\nconst keOf = (fu) => h_eV * (fu*1e14) - phi;\nv.fn(fu => keOf(fu), { color: H.colors.accent, width: 2.5 });\nif (f0u >= 0 && f0u <= 25) {\n v.line(f0u, -1, f0u, 6, { color: H.colors.yellow, width: 1, dash: [4,4] });\n v.text(\"f₀=\" + f0u.toFixed(1), f0u, 5.6, { color: H.colors.sub, size: 10, align: \"center\" });\n}\nv.dot(0, -phi, { r: 4, fill: H.colors.warn });\nv.text(\"-φ\", 0.6, -phi, { color: H.colors.sub, size: 11 });\nconst fAnim = f + 1.5 * Math.sin(t * 0.8);\nconst keA = keOf(fAnim);\nv.dot(Math.max(fAnim,0), keA, { r: 6, fill: keA > 0 ? H.colors.good : H.colors.warn });\nH.text(\"Photoelectric: KEmax = hf − φ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f = \" + f.toFixed(1) + \"×10¹⁴ Hz, φ = \" + phi.toFixed(2) + \" eV\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"KEmax = \" + (KE > 0 ? KE.toFixed(2) + \" eV (emission)\" : \"0 — below threshold (f₀=\" + f0u.toFixed(1) + \"×10¹⁴ Hz)\"), 24, 72, { color: H.colors.yellow, size: 13 });" + }, + { + "id": "apphys-photon-energy", + "area": "AP Physics", + "topic": "Photon energy E=hf", + "title": "Photon energy: E = hf = hc/λ", + "equation": "E = h f = h c / λ", + "keywords": [ + "photon energy", + "e = hf", + "e = hc/lambda", + "planck constant", + "wavelength frequency relation", + "quantum of light", + "electromagnetic wave", + "energy of photon", + "ev and joules", + "frequency", + "photon", + "ap physics" + ], + "explanation": "A photon carries energy proportional to its frequency: E = hf, where h is Planck's constant. Since c = fλ for light, this is equivalently E = hc/λ — so higher frequency (shorter wavelength) means a more energetic photon. The animation shows the EM wave packing in more cycles (shorter λ) as f rises, while the lower graph traces the straight-line E ∝ f relationship. On the AP exam you convert between f, λ, and E and switch units between joules and electron-volts (1 eV = 1.602×10⁻¹⁹ J), often to compare photon energy against an atomic transition or work function.", + "bullets": [ + "E = hf: energy rises linearly with frequency — the slope of the lower line is h.", + "Equivalently E = hc/λ, so shorter wavelength = higher energy (the wave crowds tighter).", + "Wave readout shrinks λ and the moving dot climbs the E–f line as f increases.", + "Energy shown in both eV and joules — the AP exam tests both unit forms." + ], + "params": [ + { + "name": "fHz", + "label": "frequency f (×10¹⁴ Hz)", + "min": 1, + "max": 20, + "step": 0.5, + "value": 6 + } + ], + "code": "H.background();\nconst W = H.W, Hh = H.H;\nconst f = Math.max(P.fHz, 0.1);\nconst h = 6.626e-34;\nconst h_eV = 4.1357e-15;\nconst c = 3.0e8;\nconst fHz = f * 1e14;\nconst E_J = h * fHz;\nconst E_eV = h_eV * fHz;\nconst lam = c / fHz;\nconst lam_nm = lam * 1e9;\nconst ay = Hh * 0.30;\nconst amp = 42;\nconst cycles = f * 0.9 + 0.5;\nconst x0 = 60, x1 = W - 60;\nconst pts = [];\nfor (let i = 0; i <= 160; i++) {\n const x = x0 + (x1 - x0) * (i / 160);\n const ph = (i/160) * cycles * 2 * Math.PI - t * 3.0;\n pts.push([x, ay + amp * Math.sin(ph)]);\n}\nH.path(pts, { color: H.colors.accent, width: 2.5 });\nH.line(x0, ay, x1, ay, { color: H.colors.axis, width: 1, dash: [3,5] });\nH.text(\"E grows with f (λ shrinks)\", x0, ay - amp - 12, { color: H.colors.sub, size: 12 });\nconst v = H.plot2d({ xMin: 0, xMax: 25, yMin: 0, yMax: 11, box: { x: 60, y: Hh*0.52, w: W-120, h: Hh*0.40 } });\nv.grid(); v.axes();\nv.fn(fu => h_eV * (fu*1e14), { color: H.colors.violet, width: 2.5 });\nconst fAnim = f + 1.0 * Math.sin(t * 0.8);\nv.dot(Math.max(fAnim,0), h_eV*(Math.max(fAnim,0)*1e14), { r: 6, fill: H.colors.warn });\nH.text(\"Photon energy: E = hf = hc/λ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f = \" + f.toFixed(1) + \"×10¹⁴ Hz λ = \" + lam_nm.toFixed(0) + \" nm\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"E = \" + E_eV.toFixed(2) + \" eV = \" + E_J.toExponential(2) + \" J\", 24, 72, { color: H.colors.yellow, size: 13 });" + }, + { + "id": "apphys-de-broglie", + "area": "AP Physics", + "topic": "de Broglie wavelength", + "title": "de Broglie wavelength: lambda = h / p", + "equation": "lambda = h / p", + "keywords": [ + "de broglie wavelength", + "matter waves", + "lambda = h/p", + "plancks constant", + "momentum wavelength", + "wave particle duality", + "electron wavelength", + "quantum", + "ap physics", + "modern physics", + "h/p", + "particle wavelength" + ], + "explanation": "Every moving particle has a wavelength lambda = h/p, where h = 6.626e-34 J*s is Planck's constant and p is the particle's momentum. Because lambda is inversely proportional to p, a faster (or more massive) particle has a SHORTER wavelength, which is why de Broglie waves only matter for tiny momenta like electrons. On the AP exam you compute lambda from p (or from kinetic energy via p = sqrt(2mK)) and use it to explain electron diffraction and the wave nature of matter. The animation shows a traveling matter wave whose spacing tightens as you raise the momentum slider, with the live wavelength readout in nm.", + "bullets": [ + "lambda = h/p: wavelength is INVERSELY proportional to momentum.", + "Raising the p slider shortens the wave's spacing on screen.", + "Only tiny momenta (electrons) give measurable, atomic-scale wavelengths.", + "p = 5e-25 kg m/s gives lambda ~ 1.3 nm, near atomic spacing." + ], + "params": [ + { + "name": "p", + "label": "momentum p (x10^-25 kg m/s)", + "min": 0.2, + "max": 10, + "step": 0.2, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -1.4, yMax: 1.4 });\nv.grid(); v.axes();\nconst p = Math.max(P.p, 0.01);\nconst h = 6.626e-34;\nconst lam = h / (p * 1e-25);\nconst lamNm = lam * 1e9;\nconst k = 2 * Math.PI / Math.max(lamNm, 0.001);\nconst scaleK = k * 0.5;\nv.fn(x => Math.sin(scaleK * (x + 0.5) - 2.2 * t), { color: H.colors.accent, width: 3 });\nconst px = 5 + 4 * Math.sin(t * 0.7);\nv.dot(px, Math.sin(scaleK * (px + 0.5) - 2.2 * t), { r: 6, fill: H.colors.accent2 });\nH.text(\"de Broglie: lambda = h / p\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Matter waves: faster particle -> shorter wavelength\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"p = \" + p.toFixed(2) + \" x10^-25 kg m/s\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"lambda = \" + lamNm.toFixed(2) + \" nm\", 24, 98, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apphys-energy-levels", + "area": "AP Physics", + "topic": "Atomic energy levels", + "title": "Hydrogen levels: E_n = -13.6/n^2 eV, photon E = E_i - E_f", + "equation": "E_photon = E_i - E_f, E_n = -13.6 / n^2 eV", + "keywords": [ + "atomic energy levels", + "hydrogen spectrum", + "bohr model", + "spectral lines", + "photon energy", + "emission absorption", + "e_n = -13.6/n^2", + "quantized energy", + "ap physics", + "modern physics", + "e_i - e_f", + "quantum jump" + ], + "explanation": "In the Bohr model of hydrogen the allowed energies are quantized as E_n = -13.6/n^2 eV, so they are negative (bound) and crowd together as n grows. When an electron drops from a higher level n_i to a lower level n_f it EMITS a photon of energy E_photon = E_i - E_f = |dE|; the reverse jump absorbs one. AP problems ask you to find the photon energy of a given transition and convert to wavelength with lambda(nm) = 1240/E(eV). The animation shows the level ladder, an arrow for the chosen transition, and an emitted photon wave-packet plus the live photon energy and wavelength.", + "bullets": [ + "E_n = -13.6/n^2 eV: levels are negative and bunch up near n high.", + "A downward jump (n_i > n_f) EMITS a photon; upward absorbs.", + "E_photon = |E_i - E_f|; bigger gaps give higher-energy photons.", + "lambda(nm) = 1240 / E(eV) gives the spectral line wavelength." + ], + "params": [ + { + "name": "ni", + "label": "initial level n_i", + "min": 1, + "max": 5, + "step": 1, + "value": 3 + }, + { + "name": "nf", + "label": "final level n_f", + "min": 1, + "max": 5, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst W = H.W, Ht = H.H;\nconst ni = Math.round(P.ni);\nconst nf = Math.round(P.nf);\nconst E = n => -13.6 / (n * n);\nconst nMax = 5;\nconst topY = 90, botY = Ht - 70;\nconst yOf = n => {\n const En = E(n);\n return botY + (En - E(1)) / (0 - E(1)) * (topY - botY);\n};\nH.text(\"Hydrogen energy levels: E_n = -13.6 / n^2 eV\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Photon energy = E_i - E_f for the marked transition\", 24, 52, { color: H.colors.sub, size: 13 });\nfor (let n = 1; n <= nMax; n++) {\n const y = yOf(n);\n H.line(120, y, 380, y, { color: H.colors.grid, width: 2 });\n H.text(\"n=\" + n, 90, y, { color: H.colors.sub, size: 12, align: \"right\", baseline: \"middle\" });\n H.text(E(n).toFixed(2) + \" eV\", 392, y, { color: H.colors.sub, size: 11, baseline: \"middle\" });\n}\nconst yi = yOf(ni), yf = yOf(nf);\nconst dE = E(ni) - E(nf);\nconst ax = 250;\nH.arrow(ax, yi, ax, yf, { color: dE > 0 ? H.colors.warn : H.colors.accent, width: 3 });\nconst emit = ni > nf;\nconst photonE = Math.abs(dE);\nconst lamNm = photonE > 0.001 ? 1239.84 / photonE : 0;\nif (emit && photonE > 0.001) {\n const frac = (t % 1.5) / 1.5;\n const wx = 470 + frac * 340;\n const wy = (yi + yf) / 2;\n for (let i = 0; i < 60; i++) {\n const xx = wx - i * 4;\n if (xx < 460) break;\n const yy = wy + 16 * Math.sin(i * 0.5 + t * 6);\n H.circle(xx, yy, 1.6, { fill: H.colors.yellow });\n }\n H.circle(wx, wy, 5, { fill: H.colors.yellow });\n}\nH.text((emit ? \"Emission\" : \"Absorption\") + \": \" + ni + \" -> \" + nf, 470, 90, { color: H.colors.ink, size: 14, weight: 700 });\nH.text(\"E_photon = |E_i - E_f| = \" + photonE.toFixed(2) + \" eV\", 470, 116, { color: H.colors.accent2, size: 13 });\nH.text(\"lambda = \" + (lamNm > 0 ? lamNm.toFixed(0) : \"-\") + \" nm\", 470, 138, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apphys-half-life", + "area": "AP Physics", + "topic": "Radioactive decay / half-life", + "title": "Radioactive decay: N = N0 (1/2)^(t/T)", + "equation": "N = N0 * (1/2)^(t/T)", + "keywords": [ + "radioactive decay", + "half life", + "exponential decay", + "n = n0 (1/2)^(t/T)", + "nuclear physics", + "decay curve", + "number of nuclei", + "carbon dating", + "ap physics", + "modern physics", + "activity", + "halving" + ], + "explanation": "Radioactive nuclei decay exponentially: after each half-life T the number of remaining (un-decayed) nuclei is halved, so N = N0 (1/2)^(t/T). The graph is a decreasing exponential that never quite reaches zero; after 1 T half remain, after 2 T a quarter, after 3 T an eighth, and so on. AP problems give you N0 and T and ask for N (or the remaining fraction) at some time, or they read off how many half-lives have elapsed. The animation sweeps a probe along the decay curve while marking each successive half-life on the axis, with a live count of the surviving nuclei.", + "bullets": [ + "Each half-life T halves the survivors: 1 -> 1/2 -> 1/4 -> 1/8.", + "The curve is exponential decay, approaching but never hitting 0.", + "Larger T (slider) makes the sample decay more slowly.", + "Violet dots mark t = T, 2T, 3T... at fractions 1/2, 1/4, 1/8." + ], + "params": [ + { + "name": "T", + "label": "half-life T (s)", + "min": 0.5, + "max": 10, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst T = Math.max(P.T, 0.1);\nconst N0 = 1000;\nconst tMaxPlot = 5 * T;\nconst tcur = (t * 0.6) % tMaxPlot;\nconst Ncur = N0 * Math.pow(0.5, tcur / T);\nconst v = H.plot2d({ xMin: 0, xMax: tMaxPlot, yMin: 0, yMax: N0 * 1.08 });\nv.grid(); v.axes();\nv.fn(x => N0 * Math.pow(0.5, x / T), { color: H.colors.accent, width: 3 });\nfor (let k = 1; k <= 5; k++) {\n const xk = k * T;\n if (xk <= tMaxPlot) {\n const yk = N0 * Math.pow(0.5, k);\n v.line(xk, 0, xk, yk, { color: H.colors.grid, width: 1, dash: [4, 4] });\n v.dot(xk, yk, { r: 4, fill: H.colors.violet });\n }\n}\nv.dot(tcur, Ncur, { r: 7, fill: H.colors.accent2 });\nH.text(\"Radioactive decay: N = N0 (1/2)^(t/T)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Each half-life T halves the remaining nuclei\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"T = \" + T.toFixed(1) + \" s t = \" + tcur.toFixed(1) + \" s\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"N = \" + Math.round(Ncur) + \" of \" + N0 + \" nuclei\", 24, 98, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apphys-mass-energy", + "area": "AP Physics", + "topic": "Mass-energy E=mc^2", + "title": "Mass-energy equivalence: E = m c^2", + "equation": "E = (dm) * c^2", + "keywords": [ + "mass energy equivalence", + "e=mc^2", + "mass defect", + "binding energy", + "nuclear energy", + "einstein", + "rest energy", + "energy released", + "ap physics", + "modern physics", + "mc squared", + "931.5 mev" + ], + "explanation": "Einstein's E = mc^2 says mass and energy are interchangeable, with c = 3.00e8 m/s as the conversion factor. In nuclear reactions a small mass defect dm (the missing mass relative to the separate particles) is converted into a large energy release E = dm*c^2, which is why nuclear processes liberate so much more energy than chemical ones. A convenient AP shortcut is 1 atomic mass unit = 931.5 MeV/c^2, so a mass defect of 0.1 u releases about 93 MeV. The animation pulses a mass and emits energy outward, showing the released energy in both joules and MeV as you vary the mass defect.", + "bullets": [ + "E = dm*c^2: a tiny mass defect yields a huge energy because c^2 is enormous.", + "dm is the mass lost (defect) between initial and final particles.", + "1 u = 931.5 MeV/c^2 converts mass units to MeV directly.", + "More mass defect (slider) -> more emitted energy on screen." + ], + "params": [ + { + "name": "dm", + "label": "mass defect dm (u)", + "min": 0, + "max": 0.3, + "step": 0.005, + "value": 0.1 + } + ], + "code": "H.background();\nconst dm = Math.max(P.dm, 0);\nconst c = 2.998e8;\nconst E = dm * 1.6605e-27 * c * c;\nconst EMeV = E / 1.602e-13;\nH.text(\"Mass-energy equivalence: E = m c^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"A mass defect dm releases energy E = dm c^2\", 24, 52, { color: H.colors.sub, size: 13 });\nconst cx = 220, cy = 300;\nconst pulse = 1 + 0.12 * Math.sin(t * 3);\nH.circle(cx, cy, 46 * pulse, { fill: H.colors.panel, stroke: H.colors.accent, width: 2 });\nH.text(\"dm\", cx, cy - 8, { color: H.colors.ink, size: 16, weight: 700, align: \"center\", baseline: \"middle\" });\nH.text(dm.toFixed(3) + \" u\", cx, cy + 14, { color: H.colors.sub, size: 12, align: \"center\", baseline: \"middle\" });\nconst nRays = 12;\nfor (let i = 0; i < nRays; i++) {\n const ang = (i / nRays) * Math.PI * 2;\n const frac = ((t * 0.8 + i / nRays) % 1);\n const r0 = 50, r1 = 230;\n const rr = r0 + frac * (r1 - r0);\n const xx = cx + Math.cos(ang) * rr;\n const yy = cy + Math.sin(ang) * rr;\n const a = dm > 0 ? (1 - frac) : 0;\n if (a > 0.02) H.circle(xx, yy, 3 + 3 * a, { fill: H.colors.yellow });\n}\nH.rect(520, 170, 340, 150, { fill: H.colors.panel, stroke: H.colors.grid, width: 1, radius: 8 });\nH.text(\"Energy released\", 540, 200, { color: H.colors.sub, size: 13 });\nH.text(\"E = \" + E.toExponential(2) + \" J\", 540, 234, { color: H.colors.accent2, size: 16, weight: 700 });\nH.text(\"E = \" + EMeV.toFixed(1) + \" MeV\", 540, 264, { color: H.colors.good, size: 15, weight: 700 });\nH.text(\"(1 u = 931.5 MeV/c^2)\", 540, 296, { color: H.colors.sub, size: 12 });" + }, + { + "id": "apphys-vectors-components", + "area": "AP Physics", + "topic": "Vector components", + "title": "Vector components: Ax = A cos(theta), Ay = A sin(theta)", + "equation": "Ax = A*cos(theta), Ay = A*sin(theta)", + "keywords": [ + "vector components", + "resolving vectors", + "ax = a cos theta", + "ay = a sin theta", + "x and y components", + "trig vectors", + "magnitude and direction", + "component method", + "ap physics", + "kinematics", + "right triangle", + "projection" + ], + "explanation": "Any vector of magnitude A pointing at angle theta from the +x axis splits into perpendicular components Ax = A cos(theta) along x and Ay = A sin(theta) along y. These components form the legs of a right triangle whose hypotenuse is the vector, so A = sqrt(Ax^2 + Ay^2) and theta = atan2(Ay, Ax). AP problems use this constantly to add forces, decompose weight on inclines, and analyze projectile velocity. The animation draws the vector with its dashed x and y component legs and shows the live Ax and Ay values as you change the magnitude and angle.", + "bullets": [ + "Ax = A cos(theta) is the horizontal leg; Ay = A sin(theta) the vertical leg.", + "The vector is the hypotenuse: A = sqrt(Ax^2 + Ay^2).", + "At theta = 90 deg Ax = 0 and Ay = A (all vertical).", + "Components let you add vectors axis-by-axis on the AP exam." + ], + "params": [ + { + "name": "A", + "label": "magnitude A", + "min": 1, + "max": 10, + "step": 0.5, + "value": 7 + }, + { + "name": "ang", + "label": "angle theta (deg)", + "min": 0, + "max": 360, + "step": 5, + "value": 35 + } + ], + "code": "H.background();\nconst A = P.A;\nconst deg = P.ang;\nconst rad = deg * Math.PI / 180;\nconst v = H.plot2d({ xMin: -11, xMax: 11, yMin: -11, yMax: 11 });\nv.grid(); v.axes();\nconst aShow = rad;\nconst Ax = A * Math.cos(aShow);\nconst Ay = A * Math.sin(aShow);\nconst tipScale = 0.6 + 0.4 * (0.5 + 0.5 * Math.sin(t * 0.9));\nconst ex = Ax * tipScale, ey = Ay * tipScale;\nv.line(0, 0, ex, 0, { color: H.colors.accent, width: 2, dash: [5, 4] });\nv.line(ex, 0, ex, ey, { color: H.colors.good, width: 2, dash: [5, 4] });\nv.arrow(0, 0, ex, ey, { color: H.colors.accent2, width: 3 });\nv.dot(ex, ey, { r: 5, fill: H.colors.accent2 });\nv.text(\"Ax\", ex / 2, -0.9, { color: H.colors.accent, size: 12 });\nv.text(\"Ay\", ex + 0.5, ey / 2, { color: H.colors.good, size: 12 });\nH.text(\"Vector components: Ax = A cos(theta), Ay = A sin(theta)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Resolve a vector onto x and y axes\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"A = \" + A.toFixed(1) + \" theta = \" + deg.toFixed(0) + \" deg\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"Ax = \" + Ax.toFixed(2) + \" Ay = \" + Ay.toFixed(2), 24, 98, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apphys-terminal-velocity", + "area": "AP Physics", + "topic": "Terminal velocity", + "title": "Terminal velocity: mg = b*v^2, v_t = sqrt(mg/b)", + "equation": "m*g - b*v^2 = m*a; at terminal v: b*v_t^2 = m*g", + "keywords": [ + "terminal velocity", + "drag force", + "air resistance", + "quadratic drag", + "free fall with drag", + "net force zero", + "b v squared", + "equilibrium speed", + "falling object", + "ap physics", + "newton second law", + "limiting velocity" + ], + "explanation": "A falling object speeds up, but the drag force (modeled here as b*v^2, opposite the motion) grows with speed. The net force is mg - b*v^2, so acceleration shrinks as v rises; when drag exactly equals weight the net force is zero and the object stops accelerating, moving at the constant terminal velocity v_t = sqrt(mg/b). On the AP exam this is the classic 'when does a = 0?' scenario: set the up and down forces equal, and recognize the velocity curve approaches its asymptote without ever exceeding it. The v-t graph rising to a horizontal asymptote, with the down weight arrow and growing up drag arrow, shows exactly that force balance.", + "bullets": [ + "Net force mg - b*v^2 drives the motion; a -> 0 as v -> v_t.", + "At terminal velocity the up drag (green) exactly cancels the down weight (orange).", + "The v-t curve approaches v_t asymptotically — it never overshoots.", + "Heavier mass means larger v_t since v_t = sqrt(mg/b)." + ], + "params": [ + { + "name": "vt", + "label": "terminal speed v_t (m/s)", + "min": 4, + "max": 40, + "step": 1, + "value": 20 + }, + { + "name": "m", + "label": "mass m (kg)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst vt = P.vt, m = P.m;\nconst g = 9.8;\nconst W = m * g;\nconst b = vt > 0.5 ? W / (vt * vt) : W / 0.25;\nH.text(\"Terminal velocity: drag grows until F_drag = mg\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Object falls; quadratic drag b*v^2 rises until it cancels weight, then v is constant\", 24, 52, { color: H.colors.sub, size: 13 });\nconst tau = vt / g;\nconst cyc = (t % 8);\nconst v = vt * (1 - Math.exp(-cyc / Math.max(tau, 0.05)));\nconst Fd = b * v * v;\nconst Fnet = W - Fd;\nconst px = 250;\nconst groundY = H.H - 60;\nconst topY = 110;\nconst drop = (cyc / 8);\nconst ballY = topY + drop * (groundY - topY - 40);\nH.line(120, groundY, H.W - 60, groundY, { color: H.colors.grid, width: 2 });\nH.circle(px, ballY, 18, { fill: H.colors.accent2, stroke: H.colors.ink, width: 2 });\nconst wScale = 1.4;\nH.arrow(px, ballY + 18, px, ballY + 18 + W * wScale, { color: H.colors.warn, width: 4 });\nH.text(\"mg = \" + W.toFixed(1) + \" N\", px + 12, ballY + 18 + W * wScale, { color: H.colors.warn, size: 12 });\nH.arrow(px, ballY - 18, px, ballY - 18 - Fd * wScale, { color: H.colors.good, width: 4 });\nH.text(\"F_drag = \" + Fd.toFixed(1) + \" N\", px + 12, ballY - 18 - Fd * wScale * 0.5, { color: H.colors.good, size: 12 });\nconst view = H.plot2d({ xMin: 0, xMax: 8, yMin: 0, yMax: vt * 1.25 + 1 });\nview.grid(); view.axes();\nview.fn(x => vt * (1 - Math.exp(-x / Math.max(tau, 0.05))), { color: H.colors.accent, width: 3 });\nview.line(0, vt, 8, vt, { color: H.colors.violet, width: 2, dash: [6, 4] });\nview.text(\"v_t = \" + vt.toFixed(1) + \" m/s\", 0.3, vt, { color: H.colors.violet, size: 12 });\nview.dot(cyc, v, { r: 6, fill: H.colors.accent2 });\nH.text(\"v = \" + v.toFixed(2) + \" m/s F_net = \" + Fnet.toFixed(1) + \" N\", 24, 78, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"weight mg (down)\", color: H.colors.warn }, { label: \"drag b v^2 (up)\", color: H.colors.good }], 24, 96);" + }, + { + "id": "apphys-spring-pendulum-compare", + "area": "AP Physics", + "topic": "Period vs parameters", + "title": "Period: spring T = 2pi*sqrt(m/k) vs pendulum T = 2pi*sqrt(L/g)", + "equation": "T_spring = 2*pi*sqrt(m/k); T_pendulum = 2*pi*sqrt(L/g)", + "keywords": [ + "period", + "simple harmonic motion", + "shm", + "mass spring period", + "pendulum period", + "2 pi sqrt m over k", + "2 pi sqrt l over g", + "spring constant", + "pendulum length", + "frequency", + "ap physics", + "oscillation" + ], + "explanation": "Both systems undergo simple harmonic motion, but their periods depend on different things. A mass on a spring has T = 2pi*sqrt(m/k): adding mass lengthens the period, while a stiffer spring (larger k) shortens it. A simple pendulum (small-angle) has T = 2pi*sqrt(L/g): longer length lengthens the period, and crucially the period does NOT depend on the bob's mass. A frequent AP exam trap is assuming mass changes a pendulum's period — it does not. The two oscillators animate side by side, each labeled with its live period so you can watch how m, k, and L scale T by a square-root.", + "bullets": [ + "Spring: T grows with sqrt(m), shrinks with 1/sqrt(k).", + "Pendulum: T grows with sqrt(L) and is independent of bob mass.", + "Both are SHM — sinusoidal position with restoring force proportional to displacement.", + "Quadrupling m (or L) only doubles the period, since T ~ sqrt of the parameter." + ], + "params": [ + { + "name": "m", + "label": "spring mass m (kg)", + "min": 0.5, + "max": 4, + "step": 0.25, + "value": 1 + }, + { + "name": "k", + "label": "spring constant k (N/m)", + "min": 2, + "max": 40, + "step": 1, + "value": 20 + }, + { + "name": "L", + "label": "pendulum length L (m)", + "min": 0.25, + "max": 4, + "step": 0.25, + "value": 1 + } + ], + "code": "H.background();\nconst m = P.m, k = P.k, L = P.L;\nconst g = 9.8;\nconst Ts = 2 * Math.PI * Math.sqrt(m / Math.max(k, 0.1));\nconst Tp = 2 * Math.PI * Math.sqrt(Math.max(L, 0.01) / g);\nH.text(\"Period: spring T = 2pi*sqrt(m/k) vs pendulum T = 2pi*sqrt(L/g)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"Spring period depends on mass & stiffness; pendulum on length & g (NOT on mass)\", 24, 52, { color: H.colors.sub, size: 13 });\nconst sx = 200;\nconst sTop = 110;\nconst sAmp = 40;\nconst sPhase = Math.sin(2 * Math.PI * t / Ts);\nconst sEq = sTop + 120;\nconst sy = sEq + sAmp * sPhase;\nH.line(sx - 50, sTop, sx + 50, sTop, { color: H.colors.grid, width: 3 });\nconst coils = 8;\nconst spr = [];\nfor (let i = 0; i <= coils * 4; i++) {\n const f = i / (coils * 4);\n const yy = sTop + f * (sy - sTop);\n const xx = sx + (i % 2 === 0 ? -14 : 14) * (i > 0 && i < coils * 4 ? 1 : 0);\n spr.push([xx, yy]);\n}\nH.path(spr, { color: H.colors.accent, width: 2 });\nH.circle(sx, sy + 18, 18, { fill: H.colors.accent2, stroke: H.colors.ink, width: 2 });\nH.text(\"SPRING\", sx - 28, sTop - 16, { color: H.colors.ink, size: 13, weight: 700 });\nH.text(\"T = \" + Ts.toFixed(2) + \" s\", sx - 36, sy + 60, { color: H.colors.accent2, size: 13 });\nconst px = 650;\nconst pTop = 110;\nconst pLpx = 130;\nconst ampA = 0.5;\nconst ang = ampA * Math.sin(2 * Math.PI * t / Tp);\nconst bobX = px + pLpx * Math.sin(ang);\nconst bobY = pTop + pLpx * Math.cos(ang);\nH.line(px - 60, pTop, px + 60, pTop, { color: H.colors.grid, width: 3 });\nH.line(px, pTop, bobX, bobY, { color: H.colors.violet, width: 2 });\nH.circle(bobX, bobY, 18, { fill: H.colors.yellow, stroke: H.colors.ink, width: 2 });\nH.text(\"PENDULUM\", px - 36, pTop - 16, { color: H.colors.ink, size: 13, weight: 700 });\nH.text(\"T = \" + Tp.toFixed(2) + \" s\", px - 36, bobY + 50, { color: H.colors.yellow, size: 13 });\nH.text(\"m = \" + m.toFixed(2) + \" kg k = \" + k.toFixed(1) + \" N/m L = \" + L.toFixed(2) + \" m\", 24, 78, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"spring T=2pi*sqrt(m/k)\", color: H.colors.accent }, { label: \"pendulum T=2pi*sqrt(L/g)\", color: H.colors.violet }], 24, H.H - 40);" + }, + { + "id": "apchem-pes", + "area": "AP Chemistry", + "topic": "Photoelectron spectroscopy (PES)", + "title": "PES: peak height = # electrons in a subshell; x = binding energy", + "equation": "binding energy ~ Zeff; peak height = number of electrons in subshell", + "keywords": [ + "photoelectron spectroscopy", + "pes", + "binding energy", + "subshell", + "1s 2s 2p", + "peak height electrons", + "effective nuclear charge", + "zeff", + "ap chemistry", + "electron configuration", + "ionization" + ], + "explanation": "A PES spectrum plots binding energy on the x-axis (increasing to the LEFT) versus relative number of electrons (peak height). Each subshell (1s, 2s, 2p, ...) gives one peak whose HEIGHT equals the number of electrons it holds (max 2 for s, 6 for p), and whose POSITION reflects how tightly those electrons are held: core electrons (1s) sit far left at very high binding energy because they feel nearly the full nuclear charge, while valence electrons sit at low binding energy. As Z increases across a period the binding energy of every subshell rises because effective nuclear charge increases. On the AP exam students read a spectrum to deduce the element's configuration and identify which subshell each peak represents.", + "bullets": [ + "Peak HEIGHT = number of electrons (1s peak height 2, full 2p height 6).", + "Binding energy increases to the LEFT; core 1s is always far left.", + "Higher Z raises every peak's binding energy (greater Zeff).", + "The dashed beam scans the spectrum; its readout is the binding energy probed." + ], + "params": [ + { + "name": "Z", + "label": "atomic number Z", + "min": 1, + "max": 10, + "step": 1, + "value": 8 + } + ], + "code": "H.background();\nconst Z = Math.max(1, Math.min(10, Math.round(P.Z)));\n// AP PES: x-axis = binding energy (increasing to the LEFT), peak height = # electrons.\nconst order = [\n {name:\"1s\", cap:2, base:100},\n {name:\"2s\", cap:2, base:12},\n {name:\"2p\", cap:6, base:8}\n];\nlet remaining = Z;\nconst peaks = [];\nfor (const sh of order){\n if (remaining <= 0) break;\n const e = Math.min(sh.cap, remaining);\n remaining -= e;\n const be = sh.base * Math.pow(Z/ (sh.name===\"1s\"?2: (sh.name===\"2s\"?4:5)), 0.9) + sh.base*0.2;\n peaks.push({name: sh.name, n: e, be: be});\n}\nlet maxBE = 1;\nfor (const p of peaks) maxBE = Math.max(maxBE, p.be);\nconst v = H.plot2d({ xMin: 0, xMax: maxBE*1.15, yMin: 0, yMax: 7 });\nv.grid(); v.axes();\nconst sweep = (Math.sin(t*0.8)*0.5+0.5);\nfor (const p of peaks){\n const xpos = maxBE*1.15 - p.be;\n v.line(xpos, 0, xpos, p.n, { color: H.colors.accent, width: 10 });\n v.text(p.name, xpos, p.n + 0.4, { color: H.colors.ink, size: 12 });\n v.text(p.n + \" e-\", xpos, p.n + 0.9, { color: H.colors.sub, size: 11 });\n}\nconst scanX = maxBE*1.15 * sweep;\nv.line(scanX, 0, scanX, 7, { color: H.colors.warn, width: 2, dash:[4,4] });\nconst scanBE = (maxBE*1.15 - scanX);\nH.text(\"Photoelectron Spectroscopy: peak height = # electrons\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Element Z = \" + Z + \" (binding energy increases to the LEFT)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"scan binding E = \" + scanBE.toFixed(1) + \" (rel.)\", 24, 74, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apchem-coulomb-attraction", + "area": "AP Chemistry", + "topic": "Coulomb attraction in atoms", + "title": "Coulomb attraction: F = k Z / r^2 (more protons pull harder)", + "equation": "F = k * Z / r^2", + "keywords": [ + "coulomb attraction", + "coulombic", + "f = k z / r^2", + "nuclear charge", + "effective nuclear charge", + "zeff", + "shell distance", + "inverse square", + "ap chemistry", + "electron proton attraction", + "periodic trends cause" + ], + "explanation": "The force holding an electron to the nucleus is Coulombic: F is proportional to the nuclear charge Z and inversely proportional to the square of the distance r, F = k*Z/r^2 (attractive, so directed inward). Increasing the number of protons (Z) strengthens the pull, while placing the electron in a higher shell (larger n, larger r) weakens it as 1/r^2. This single relationship is the CAUSE behind every periodic trend the AP exam tests: atomic radius, ionization energy, and electronegativity all follow from how strongly the nucleus attracts the outer electrons. The green arrow shows the attractive force pointing toward the nucleus; its length grows with Z and shrinks as the shell n increases.", + "bullets": [ + "Force is ATTRACTIVE: arrow points from electron toward the +Z nucleus.", + "F is proportional to Z (more protons = stronger pull).", + "F falls off as 1/r^2, so an outer shell (large n) is held far more weakly.", + "This is the root cause of radius, ionization energy, and EN trends." + ], + "params": [ + { + "name": "Z", + "label": "nuclear charge Z (protons)", + "min": 1, + "max": 10, + "step": 1, + "value": 6 + }, + { + "name": "n", + "label": "shell number n", + "min": 1, + "max": 4, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst Z = Math.max(1, Math.min(10, Math.round(P.Z)));\nconst n = Math.max(1, Math.min(4, Math.round(P.n)));\nconst cx = H.W*0.62, cy = H.H*0.52;\nconst rScale = 55;\nH.circle(cx, cy, 14, { fill: H.colors.warn });\nH.text(\"+\" + Z, cx, cy, { color: H.colors.bg, size: 13, weight: 700, align:\"center\", baseline:\"middle\" });\nfor (let s = 1; s <= n; s++){\n const r = s * rScale;\n H.circle(cx, cy, r, { stroke: H.colors.grid, width: 1 });\n}\nconst r = n * rScale;\nconst ang = t * 1.2;\nconst ex = cx + r * Math.cos(ang);\nconst ey = cy + r * Math.sin(ang);\nH.circle(ex, ey, 8, { fill: H.colors.accent });\nH.text(\"e-\", ex, ey, { color: H.colors.bg, size: 10, align:\"center\", baseline:\"middle\" });\nconst rPm = n;\nconst F = Z / (rPm*rPm);\nconst arrowLen = Math.min(r*0.85, 30 + F*40);\nconst ux = (cx-ex)/r, uy = (cy-ey)/r;\nH.arrow(ex, ey, ex + ux*arrowLen, ey + uy*arrowLen, { color: H.colors.good, width: 3 });\nH.text(\"Coulomb attraction: F = k Z / r^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Nuclear charge Z = \" + Z + \", shell n = \" + n, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"F (attraction) = \" + F.toFixed(2) + \" (rel., larger = stronger pull)\", 24, 74, { color: H.colors.good, size: 13 });\nH.text(\"More protons -> stronger pull; farther shell -> weaker pull\", 24, 96, { color: H.colors.sub, size: 12 });" + }, + { + "id": "apchem-atomic-radius-trend", + "area": "AP Chemistry", + "topic": "Atomic radius periodic trend", + "title": "Atomic radius: decreases across a period, increases down a group", + "equation": "radius down (more shells, larger n); radius up across (larger Zeff)", + "keywords": [ + "atomic radius", + "periodic trend", + "radius decreases across", + "radius increases down", + "effective nuclear charge", + "zeff", + "shells", + "shielding", + "ap chemistry", + "picometers", + "size of atom" + ], + "explanation": "Atomic radius DECREASES left-to-right across a period because protons are added to the same valence shell, raising the effective nuclear charge (Zeff) and pulling the electron cloud in tighter. It INCREASES down a group because each new period adds a higher principal energy level (larger n), and the added inner shells shield the valence electrons. Both behaviors trace back to Coulomb's law (F proportional to Zeff/r^2): more pull shrinks the atom, more distance/shielding enlarges it. On the AP exam students rank atoms by size and justify the order with Zeff and number of shells; the breathing cloud here shrinks as you move across and swells as you move down.", + "bullets": [ + "Across a period: Zeff rises, same shell -> cloud contracts (smaller r).", + "Down a group: a new shell (higher n) is added -> larger atom.", + "Shielding by inner electrons offsets nuclear charge going down.", + "Radius is reported in picometers; the readout updates with both sliders." + ], + "params": [ + { + "name": "across", + "label": "position across period (1=left)", + "min": 1, + "max": 8, + "step": 1, + "value": 3 + }, + { + "name": "down", + "label": "depth down group (1=top)", + "min": 1, + "max": 4, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst across = Math.max(1, Math.min(8, Math.round(P.across)));\nconst down = Math.max(1, Math.min(4, Math.round(P.down)));\nconst radius = 60 + (down-1)*55 - (across-1)*8;\nconst rPx = Math.max(12, radius*0.55);\nconst cx = H.W*0.68, cy = H.H*0.55;\nH.circle(cx, cy, 8, { fill: H.colors.warn });\nconst pulse = rPx * (1 + 0.04*Math.sin(t*2));\nH.circle(cx, cy, pulse, { stroke: H.colors.accent, width: 2 });\nH.circle(cx, cy, pulse, { fill: \"rgba(124,196,255,0.10)\" });\nconst ang = t*1.0;\nH.circle(cx + pulse*Math.cos(ang), cy + pulse*Math.sin(ang), 5, { fill: H.colors.accent2 });\nH.arrow(60, 120, 200, 120, { color: H.colors.sub, width: 2 });\nH.text(\"across period -> radius DECREASES\", 60, 110, { color: H.colors.sub, size: 12 });\nH.arrow(60, 150, 60, 260, { color: H.colors.sub, width: 2 });\nH.text(\"down group: radius INCREASES\", 70, 260, { color: H.colors.sub, size: 12 });\nH.text(\"Atomic Radius Trend\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"period pos = \" + across + \", group depth = \" + down, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"radius approx \" + Math.round(radius) + \" pm\", 24, 74, { color: H.colors.accent, size: 13 });\nH.text(\"Across: Zeff up, same shell -> tighter. Down: new shell -> bigger.\", 24, 470, { color: H.colors.sub, size: 12 });" + }, + { + "id": "apchem-ionization-energy", + "area": "AP Chemistry", + "topic": "Ionization energy trend", + "title": "Ionization energy: increases across a period, decreases down a group", + "equation": "IE1 = energy to remove the outermost electron; up across, down down-group", + "keywords": [ + "ionization energy", + "first ionization energy", + "ie1", + "increases across", + "decreases down", + "effective nuclear charge", + "zeff", + "ap chemistry", + "kj/mol", + "remove electron", + "periodic trend" + ], + "explanation": "First ionization energy (IE1) is the energy needed to remove the most loosely held electron from a gaseous atom, X(g) -> X+(g) + e-, and it is always positive (endothermic). IE INCREASES across a period because rising effective nuclear charge holds the valence electrons more tightly, and DECREASES down a group because the outer electron sits in a higher shell, farther from and more shielded from the nucleus. Small dips occur (e.g. removing a paired p-electron or the first p vs full s), but the AP-level trend is up-and-to-the-right. The bars show IE1 across a period with the selected element highlighted, and an electron is animated being ejected upward from that atom.", + "bullets": [ + "IE1 is the energy to remove ONE electron: X(g) -> X+(g) + e- (endothermic).", + "Across a period: higher Zeff -> electron held tighter -> IE rises.", + "Down a group: outer e- farther/shielded -> easier to remove -> IE falls.", + "The ejected electron animates off the highlighted bar; readout in kJ/mol." + ], + "params": [ + { + "name": "across", + "label": "position across period (1=left)", + "min": 1, + "max": 8, + "step": 1, + "value": 4 + }, + { + "name": "down", + "label": "depth down group (1=top)", + "min": 1, + "max": 4, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst across = Math.max(1, Math.min(8, Math.round(P.across)));\nconst down = Math.max(1, Math.min(4, Math.round(P.down)));\nconst IE = 500 + (across-1)*230 - (down-1)*180;\nconst IEc = Math.max(380, IE);\nconst v = H.plot2d({ xMin: 0, xMax: 9, yMin: 0, yMax: 2400 });\nv.grid(); v.axes();\nfor (let a = 1; a <= 8; a++){\n const val = 500 + (a-1)*230 - (down-1)*180;\n const valc = Math.max(380, val);\n const isCur = (a === across);\n v.line(a, 0, a, valc, { color: isCur ? H.colors.accent2 : H.colors.grid, width: isCur?14:9 });\n}\nconst top = IEc;\nconst ejFrac = (t % 2)/2;\nconst ex = across;\nconst ey = top + ejFrac*600;\nv.circle(ex, ey, 6, { fill: H.colors.warn });\nv.text(\"e- ejected\", ex+0.2, ey, { color: H.colors.warn, size: 11 });\nH.text(\"Ionization Energy Trend: IE = energy to remove an e-\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"period pos = \" + across + \", group depth = \" + down, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"IE1 approx \" + Math.round(IEc) + \" kJ/mol\", 24, 74, { color: H.colors.accent2, size: 13 });\nH.text(\"Across: higher Zeff holds e- tighter -> IE up. Down: farther e- -> IE down.\", 24, 96, { color: H.colors.sub, size: 12 });" + }, + { + "id": "apchem-electronegativity", + "area": "AP Chemistry", + "topic": "Electronegativity trend", + "title": "Electronegativity: increases toward fluorine (F = 3.98, the max)", + "equation": "EN rises up and across toward F; the shared electron shifts to higher-EN atom", + "keywords": [ + "electronegativity", + "pauling scale", + "increases toward fluorine", + "f = 3.98", + "polar bond", + "shared electrons", + "periodic trend", + "ap chemistry", + "partial charge", + "dipole", + "effective nuclear charge" + ], + "explanation": "Electronegativity (EN) measures how strongly an atom in a BOND attracts the shared bonding electrons; on the Pauling scale fluorine is the most electronegative element at 3.98. EN INCREASES up a group and across a period, so it peaks at the top-right (fluorine), for the same Coulombic reason as the other trends: higher effective nuclear charge and a smaller radius pull bonding electrons harder. A large EN difference between two bonded atoms makes the bond polar, shifting the shared pair toward the more electronegative atom and creating partial charges; on the AP exam this drives bond polarity, dipoles, and ranking bonds from nonpolar to ionic. Here the shared electron is tugged toward F more strongly when the partner atom X has low EN.", + "bullets": [ + "EN = pull on SHARED bonding electrons; fluorine is the max at 3.98.", + "Increases up and across -> highest at top-right (F).", + "Big EN difference -> polar bond; electron sits nearer the high-EN atom.", + "The shared electron's position shows the bias toward fluorine." + ], + "params": [ + { + "name": "across", + "label": "position across period (1=left)", + "min": 1, + "max": 8, + "step": 1, + "value": 2 + }, + { + "name": "down", + "label": "depth down group (1=top)", + "min": 1, + "max": 4, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst across = Math.max(1, Math.min(8, Math.round(P.across)));\nconst down = Math.max(1, Math.min(4, Math.round(P.down)));\nlet EN = 0.9 + (across-1)*0.42 - (down-1)*0.30;\nEN = Math.max(0.7, Math.min(3.98, EN));\nconst cx = H.W*0.6, cy = H.H*0.52;\nH.circle(cx-90, cy, 22, { fill: H.colors.panel, stroke: H.colors.sub, width:2 });\nH.text(\"X\", cx-90, cy, { color: H.colors.ink, size:14, align:\"center\", baseline:\"middle\", weight:700 });\nH.circle(cx+90, cy, 22, { fill: H.colors.panel, stroke: H.colors.good, width:2 });\nH.text(\"F\", cx+90, cy, { color: H.colors.good, size:14, align:\"center\", baseline:\"middle\", weight:700 });\nH.line(cx-68, cy, cx+68, cy, { color: H.colors.grid, width: 2 });\nconst ENf = 3.98;\nconst bias = (ENf - EN)/4;\nconst ePos = cx - 50 + bias*100 + 6*Math.sin(t*2);\nH.circle(ePos, cy, 7, { fill: H.colors.accent });\nH.text(\"shared e-\", ePos, cy-22, { color: H.colors.accent, size: 11, align:\"center\" });\nH.text(\"Electronegativity Trend: pull on shared electrons\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"period pos = \" + across + \", group depth = \" + down, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"EN(X) approx \" + EN.toFixed(2) + \" (Pauling; F = 3.98 is max)\", 24, 74, { color: H.colors.accent, size: 13 });\nH.text(\"Increases toward fluorine (up + across); decreases down a group.\", 24, 96, { color: H.colors.sub, size: 12 });" + }, + { + "id": "apchem-electron-config", + "area": "AP Chemistry", + "topic": "Electron configuration / orbital filling", + "title": "Aufbau electron configuration: 1s 2s 2p 3s 3p 4s 3d ...", + "equation": "fill subshells in increasing energy; capacities 2(s),6(p),10(d)", + "keywords": [ + "electron configuration", + "aufbau principle", + "orbital filling order", + "subshell capacity", + "1s 2s 2p", + "ground state configuration", + "atomic number", + "pauli exclusion", + "hund's rule", + "ap chemistry", + "periodic table", + "quantum numbers" + ], + "explanation": "Electrons occupy subshells from lowest to highest energy (the Aufbau order 1s, 2s, 2p, 3s, 3p, 4s, 3d, 4p, ...), filling each subshell to its capacity (s holds 2, p holds 6, d holds 10) before the next begins. Notably 4s fills before 3d because its energy is slightly lower in the neutral atom, an exam favorite. As you raise the atomic number Z, electrons are placed one at a time into the next available slot, and the bracketed string shows the resulting ground-state configuration. On the AP exam you use these configurations to predict valence electrons, ions, paramagnetism, and periodic trends.", + "bullets": [ + "Boxes are drawn in true energy order, so 4s sits before 3d.", + "Yellow dots are electrons; capacity is 2/6/10 for s/p/d.", + "The animation places electrons one by one as Z increases.", + "The bracket [...] is the live ground-state configuration." + ], + "params": [ + { + "name": "Z", + "label": "atomic number Z", + "min": 1, + "max": 36, + "step": 1, + "value": 17 + } + ], + "code": "H.background();\nH.text(\"Aufbau filling: 1s 2s 2p 3s 3p 4s 3d ...\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst Z = Math.max(1, Math.round(P.Z));\nconst subs = [\n {n:1,l:\"s\",cap:2},{n:2,l:\"s\",cap:2},{n:2,l:\"p\",cap:6},\n {n:3,l:\"s\",cap:2},{n:3,l:\"p\",cap:6},{n:4,l:\"s\",cap:2},\n {n:3,l:\"d\",cap:10},{n:4,l:\"p\",cap:6},{n:5,l:\"s\",cap:2},\n {n:4,l:\"d\",cap:10},{n:5,l:\"p\",cap:6}\n];\nlet rem = Z;\nconst filled = subs.map(s => { const e = Math.min(s.cap, Math.max(0,rem)); rem -= e; return e; });\nconst reveal = (t * 4) % (Z + 6);\nlet cfg = \"\";\nfor (let i = 0; i < subs.length; i++) {\n if (filled[i] > 0) cfg += subs[i].n + subs[i].l + \"^\" + filled[i] + \" \";\n}\nH.text(\"Z = \" + Z + \" [\" + cfg.trim() + \"]\", 24, 52, { color: H.colors.sub, size: 13, maxWidth: H.W - 48 });\nconst x0 = 70, top = 110, rowH = 64, perRow = 4, boxW = 150, boxH = 50;\nlet count = 0;\nfor (let i = 0; i < subs.length; i++) {\n const col = i % perRow, row = Math.floor(i / perRow);\n const bx = x0 + col * (boxW + 30);\n const by = top + row * rowH;\n const active = filled[i] > 0;\n H.rect(bx, by, boxW, boxH, { stroke: active ? H.colors.accent : H.colors.grid, width: active ? 2 : 1, radius: 6 });\n H.text(subs[i].n + subs[i].l, bx + 10, by + 30, { color: active ? H.colors.ink : H.colors.sub, size: 16, weight: 700 });\n for (let e = 0; e < subs[i].cap; e++) {\n const ex = bx + 46 + (e % 5) * 18;\n const ey = by + 16 + Math.floor(e / 5) * 20;\n const here = (count + e) < reveal;\n const present = e < filled[i];\n H.circle(ex, ey, 5, { fill: present && here ? H.colors.yellow : H.colors.panel, stroke: H.colors.grid, width: 1 });\n }\n count += filled[i];\n}\nconst shown = Math.min(Z, Math.floor(reveal));\nH.text(\"electrons placed: \" + shown + \" / \" + Z, 24, H.H - 24, { color: H.colors.good, size: 13 });" + }, + { + "id": "apchem-emission-spectrum", + "area": "AP Chemistry", + "topic": "Atomic emission spectrum", + "title": "Hydrogen emission: E = 13.6(1/nf^2 - 1/ni^2) eV, lambda = hc/E", + "equation": "E_photon = 13.6*(1/nf^2 - 1/ni^2) eV ; lambda = 1239.84/E nm", + "keywords": [ + "emission spectrum", + "hydrogen spectrum", + "balmer series", + "photon energy", + "wavelength", + "rydberg", + "electron transition", + "spectral lines", + "delta e", + "ap chemistry", + "quantized energy", + "line spectrum" + ], + "explanation": "When an excited electron drops from a higher level ni to a lower level nf, the atom emits a photon whose energy equals the difference in level energies: E = 13.6*(1/nf^2 - 1/ni^2) eV, which is positive for emission. That photon's wavelength is lambda = hc/E with hc = 1239.84 eV*nm, so a larger energy gap gives a shorter (bluer) wavelength. Transitions ending at nf=2 (the Balmer series) land in the visible band and produce hydrogen's characteristic colored lines, while nf=1 (Lyman) lands in the UV. On the AP exam these discrete lines are the evidence for quantized energy levels, and you convert between level numbers, energy, and wavelength.", + "bullets": [ + "The electron drops from ni down to nf; arrow shows the jump.", + "Photon energy is the gap between levels (positive for emission).", + "lambda = 1239.84/E nm: bigger gap -> shorter, bluer wavelength.", + "The colored tick on the spectrum is the emitted line's position." + ], + "params": [ + { + "name": "ni", + "label": "initial level ni", + "min": 2, + "max": 6, + "step": 1, + "value": 3 + }, + { + "name": "nf", + "label": "final level nf", + "min": 1, + "max": 5, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nH.text(\"Emission line: E = 13.6(1/nf^2 - 1/ni^2) eV\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst ni = Math.max(2, Math.round(P.ni));\nconst nf = Math.max(1, Math.min(ni - 1, Math.round(P.nf)));\nconst RY = 13.6;\nconst dE = RY * (1 / (nf * nf) - 1 / (ni * ni));\nconst hc = 1239.84;\nconst lam = dE > 0 ? hc / dE : 0;\nfunction wlColor(nm) {\n if (nm < 380) return H.colors.violet;\n if (nm < 440) return \"#7a00ff\";\n if (nm < 490) return \"#0040ff\";\n if (nm < 510) return \"#00c8a0\";\n if (nm < 580) return \"#67e8b0\";\n if (nm < 645) return \"#ffae00\";\n if (nm < 750) return \"#ff2b2b\";\n return \"#8a0000\";\n}\nconst col = lam >= 380 && lam <= 750 ? wlColor(lam) : H.colors.sub;\nH.text(\"ni=\" + ni + \" -> nf=\" + nf + \" dE=\" + dE.toFixed(2) + \" eV lambda=\" + lam.toFixed(0) + \" nm\", 24, 52, { color: H.colors.sub, size: 13 });\nconst lx = 90, lyTop = 110, lyBot = H.H - 70;\nH.text(\"energy levels\", lx - 20, 92, { color: H.colors.sub, size: 12 });\nfor (let n = 1; n <= 6; n++) {\n const En = -RY / (n * n);\n const y = H.map(En, -RY, 0, lyBot, lyTop);\n const on = (n === ni || n === nf);\n H.line(lx, y, lx + 150, y, { color: on ? H.colors.accent : H.colors.grid, width: on ? 2.5 : 1 });\n H.text(\"n=\" + n, lx + 156, y + 4, { color: on ? H.colors.ink : H.colors.sub, size: 12 });\n}\nconst yi = H.map(-RY / (ni * ni), -RY, 0, lyBot, lyTop);\nconst yf = H.map(-RY / (nf * nf), -RY, 0, lyBot, lyTop);\nconst ph = (t % 2) / 2;\nconst ey = yi + (yf - yi) * H.ease(ph);\nH.circle(lx + 75, ey, 7, { fill: H.colors.yellow, stroke: H.colors.ink, width: 1 });\nH.arrow(lx + 75, yi, lx + 75, yf, { color: H.colors.warn, width: 1.5 });\nconst sx = 360, sw = H.W - sx - 40, sy = 140, sh = 240;\nH.rect(sx, sy, sw, sh, { stroke: H.colors.grid, width: 1, radius: 6 });\nH.text(\"visible spectrum 380-750 nm\", sx, sy - 10, { color: H.colors.sub, size: 12 });\nfor (let i = 0; i <= 20; i++) {\n const nm = 380 + (750 - 380) * i / 20;\n const px = sx + sw * i / 20;\n H.line(px, sy + sh - 18, px, sy + sh, { color: wlColor(nm), width: 2 });\n}\nif (lam >= 380 && lam <= 750) {\n const px = sx + sw * (lam - 380) / (750 - 380);\n const pulse = 2 + 1.5 * Math.sin(t * 6);\n H.line(px, sy + 10, px, sy + sh - 20, { color: col, width: pulse });\n H.text(lam.toFixed(0) + \" nm\", px - 20, sy + 4, { color: col, size: 12, weight: 700 });\n} else {\n H.text(dE > RY ? \"UV (Lyman)\" : \"IR\", sx + 20, sy + sh / 2, { color: H.colors.sub, size: 14 });\n}\nH.text(\"photon: E = \" + dE.toFixed(2) + \" eV\", sx, sy + sh + 28, { color: H.colors.good, size: 13 });" + }, + { + "id": "apchem-bohr-model", + "area": "AP Chemistry", + "topic": "Bohr model energy levels", + "title": "Bohr model: En = -13.6 / n^2 eV", + "equation": "En = -13.6 / n^2 eV ; r_n ~ n^2 * a0", + "keywords": [ + "bohr model", + "energy levels", + "quantized orbits", + "en = -13.6/n^2", + "hydrogen atom", + "principal quantum number", + "electron orbit", + "ionization energy", + "ground state", + "ap chemistry", + "atomic radius", + "rydberg energy" + ], + "explanation": "In the Bohr model of hydrogen the electron occupies quantized circular orbits with energy En = -13.6/n^2 eV; the energy is negative (bound) and rises toward 0 as n grows, so the levels crowd together near the top. The orbit radius scales as n^2, so n=2 is four times larger than n=1. The energy needed to remove the electron from level n (ionization to E=0) is +13.6/n^2 eV, which for the ground state n=1 is 13.6 eV. On the AP exam this model justifies discrete energy levels and connects level spacing to the emission and absorption lines an atom can produce.", + "bullets": [ + "Levels follow En = -13.6/n^2 eV: negative and bunched near E=0.", + "Orbit radius grows as n^2, so outer orbits are much larger.", + "The orbiting electron sits on the selected level n.", + "Ionizing from level n needs +13.6/n^2 eV up to E = 0." + ], + "params": [ + { + "name": "n", + "label": "level n", + "min": 1, + "max": 5, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nH.text(\"Bohr model: En = -13.6 / n^2 eV\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst n = Math.max(1, Math.min(5, Math.round(P.n)));\nconst RY = 13.6;\nconst En = -RY / (n * n);\nH.text(\"current level n=\" + n + \" En = \" + En.toFixed(2) + \" eV radius ~ n^2 a0\", 24, 52, { color: H.colors.sub, size: 13 });\nconst cx = 280, cy = 300;\nH.circle(cx, cy, 12, { fill: H.colors.warn, stroke: H.colors.ink, width: 1 });\nH.text(\"+\", cx - 4, cy + 5, { color: H.colors.bg, size: 16, weight: 700 });\nconst rMax = 230;\nfor (let k = 1; k <= 5; k++) {\n const r = rMax * (k * k) / 25;\n const on = k === n;\n H.circle(cx, cy, r, { stroke: on ? H.colors.accent : H.colors.grid, width: on ? 2.5 : 1 });\n H.text(\"n=\" + k, cx + r - 10, cy - 6, { color: on ? H.colors.ink : H.colors.sub, size: 11 });\n}\nconst rn = rMax * (n * n) / 25;\nconst ang = t * (3 / n);\nconst ex = cx + rn * Math.cos(ang);\nconst ey = cy + rn * Math.sin(ang);\nH.circle(ex, ey, 7, { fill: H.colors.yellow, stroke: H.colors.ink, width: 1 });\nconst lx = 640, lyTop = 110, lyBot = H.H - 70;\nH.text(\"energy (eV)\", lx, 92, { color: H.colors.sub, size: 12 });\nfor (let k = 1; k <= 5; k++) {\n const Ek = -RY / (k * k);\n const y = H.map(Ek, -RY, 0, lyBot, lyTop);\n const on = k === n;\n H.line(lx, y, lx + 170, y, { color: on ? H.colors.accent : H.colors.grid, width: on ? 2.5 : 1 });\n H.text(Ek.toFixed(2), lx + 176, y + 4, { color: on ? H.colors.ink : H.colors.sub, size: 11 });\n}\nH.line(lx, lyTop - 6, lx, lyBot, { color: H.colors.axis, width: 1 });\nH.text(\"n=infinity, E=0 (ionized)\", lx, lyTop - 2, { color: H.colors.sub, size: 10 });\nconst ay = H.map(En, -RY, 0, lyBot, lyTop);\nH.circle(lx + 85 + 4 * Math.sin(t * 2), ay, 6, { fill: H.colors.yellow });\nH.text(\"E\" + n + \" = \" + En.toFixed(2) + \" eV\", 24, H.H - 24, { color: H.colors.good, size: 13 });" + }, + { + "id": "apchem-lewis-formal-charge", + "area": "AP Chemistry", + "topic": "Lewis structures & formal charge", + "title": "Formal charge: FC = V - (lone electrons) - (bonds)", + "equation": "FC = (valence e-) - (nonbonding e-) - (number of bonds)", + "keywords": [ + "formal charge", + "lewis structure", + "resonance", + "best lewis structure", + "fc = v - lone - bonds", + "octet rule", + "expanded octet", + "so2", + "double bond", + "ap chemistry", + "bonding pairs", + "most favorable structure" + ], + "explanation": "Formal charge tracks how an atom's electron count in a Lewis structure compares to its neutral free-atom valence: FC = V - (nonbonding electrons) - (number of bonds, counting each shared pair once). The most favorable Lewis structure is the one that makes formal charges as close to zero as possible, with any negative charge on the most electronegative atom. Here SO2 is drawn with 0, 1, or 2 S=O double bonds: each additional double bond pulls a terminal O's formal charge from -1 toward 0 and reduces the charge separation. On the AP exam you compute formal charges to rank resonance structures and to justify when sulfur uses an expanded octet.", + "bullets": [ + "FC = V - lone electrons - bonds, evaluated per atom on screen.", + "A single-bonded O is -1; a double-bonded O is 0.", + "Adding S=O double bonds lowers the total charge separation.", + "The pulsing ring flags the atom with the largest |FC|." + ], + "params": [ + { + "name": "doubleBonds", + "label": "S=O double bonds", + "min": 0, + "max": 2, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nH.text(\"Formal charge: FC = V - (lone e) - (bonds)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst dbl = Math.max(0, Math.min(2, Math.round(P.doubleBonds)));\nconst Sbonds = 2 + dbl;\nconst SloneE = Math.max(0, 6 - Sbonds);\nconst FC_S = 6 - SloneE - Sbonds;\nfunction FC_O(isDouble) { return isDouble ? (6 - 4 - 2) : (6 - 6 - 1); }\nconst o1Double = dbl >= 1;\nconst o2Double = dbl >= 2;\nconst fcO1 = FC_O(o1Double);\nconst fcO2 = FC_O(o2Double);\nH.text(\"SO2 with \" + dbl + \" S=O double bond(s) |sum FC| should be minimal\", 24, 52, { color: H.colors.sub, size: 13 });\nconst Sx = 450, Sy = 300;\nconst ang = 120 * Math.PI / 180;\nconst L = 150;\nconst o1x = Sx - L * Math.sin(ang / 2), o1y = Sy + L * Math.cos(ang / 2);\nconst o2x = Sx + L * Math.sin(ang / 2), o2y = Sy + L * Math.cos(ang / 2);\nfunction bond(ax, ay, bx, by, isDouble) {\n const dx = bx - ax, dy = by - ay, len = Math.hypot(dx, dy);\n const nx = -dy / len, ny = dx / len;\n if (isDouble) {\n H.line(ax + nx * 5, ay + ny * 5, bx + nx * 5, by + ny * 5, { color: H.colors.sub, width: 2 });\n H.line(ax - nx * 5, ay - ny * 5, bx - nx * 5, by - ny * 5, { color: H.colors.sub, width: 2 });\n } else {\n H.line(ax, ay, bx, by, { color: H.colors.sub, width: 2 });\n }\n}\nbond(Sx, Sy, o1x, o1y, o1Double);\nbond(Sx, Sy, o2x, o2y, o2Double);\nfunction atom(x, y, sym, fc, color) {\n H.circle(x, y, 26, { fill: H.colors.panel, stroke: color, width: 2 });\n H.text(sym, x - 7, y + 6, { color: H.colors.ink, size: 18, weight: 700 });\n const fcStr = (fc > 0 ? \"+\" + fc : \"\" + fc);\n const fcCol = fc === 0 ? H.colors.good : (fc > 0 ? H.colors.warn : H.colors.accent);\n H.text(\"FC \" + fcStr, x - 18, y - 34, { color: fcCol, size: 13, weight: 700 });\n}\nconst maxFC = Math.max(Math.abs(FC_S), Math.abs(fcO1), Math.abs(fcO2));\nconst pr = 30 + 4 * Math.sin(t * 4);\nlet hx = Sx, hy = Sy;\nif (Math.abs(fcO1) === maxFC && maxFC > 0) { hx = o1x; hy = o1y; }\nelse if (Math.abs(fcO2) === maxFC && maxFC > 0) { hx = o2x; hy = o2y; }\nif (maxFC > 0) H.circle(hx, hy, pr, { stroke: H.colors.yellow, width: 2 });\natom(Sx, Sy, \"S\", FC_S, H.colors.violet);\natom(o1x, o1y, \"O\", fcO1, H.colors.accent2);\natom(o2x, o2y, \"O\", fcO2, H.colors.accent2);\nconst sumAbs = Math.abs(FC_S) + Math.abs(fcO1) + Math.abs(fcO2);\nH.text(\"S: FC=\" + (FC_S>0?\"+\":\"\") + FC_S + \" O: \" + (fcO1>0?\"+\":\"\")+fcO1 + \" / \" + (fcO2>0?\"+\":\"\")+fcO2, 24, H.H - 46, { color: H.colors.sub, size: 13 });\nH.text(\"sum of |FC| = \" + sumAbs + \" (lower = more favorable structure)\", 24, H.H - 24, { color: sumAbs === 0 ? H.colors.good : H.colors.warn, size: 13 });" + }, + { + "id": "apchem-vsepr", + "area": "AP Chemistry", + "topic": "VSEPR geometry", + "title": "VSEPR: electron domains set shape (linear 180 ... octahedral 90)", + "equation": "molecular shape = f(bonding pairs, lone pairs); angle from e-domain geometry", + "keywords": [ + "vsepr", + "molecular geometry", + "electron domains", + "bond angle", + "lone pairs", + "tetrahedral", + "trigonal bipyramidal", + "octahedral", + "bent", + "linear", + "ap chemistry", + "molecular shape" + ], + "explanation": "VSEPR theory says electron domains (bonding pairs plus lone pairs) around a central atom repel and arrange themselves as far apart as possible, which fixes the electron-domain geometry and its ideal angle: 2 domains -> linear 180, 3 -> trigonal planar 120, 4 -> tetrahedral 109.5, 5 -> trigonal bipyramidal, 6 -> octahedral 90. The molecular shape is named from only the bonded atoms, so replacing bonds with lone pairs turns tetrahedral into trigonal pyramidal then bent. Lone pairs occupy more space than bonding pairs, so they slightly compress the remaining bond angles (e.g. water's 104.5 vs 109.5). On the AP exam you count domains from the Lewis structure, name the shape, and predict polarity from that geometry.", + "bullets": [ + "Total domains set the e-geometry and its ideal angle (180...90).", + "Violet = central atom, orange = bonded atoms, pink = lone pairs.", + "Shape name uses only bonded atoms, so lone pairs change it.", + "Lone pairs repel more strongly and compress bond angles." + ], + "params": [ + { + "name": "domains", + "label": "electron domains", + "min": 2, + "max": 6, + "step": 1, + "value": 4 + }, + { + "name": "lone", + "label": "lone pairs", + "min": 0, + "max": 3, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nH.text(\"VSEPR: electron domains -> shape & bond angle\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst dom = Math.max(2, Math.min(6, Math.round(P.domains)));\nconst lone = Math.max(0, Math.min(dom - 1, Math.round(P.lone)));\nconst bonds = dom - lone;\nconst info = {\n 2: { name: \"linear\", base: 180, eg: \"linear\" },\n 3: { name: \"trigonal planar\", base: 120, eg: \"trigonal planar\" },\n 4: { name: \"tetrahedral\", base: 109.5, eg: \"tetrahedral\" },\n 5: { name: \"trigonal bipyramidal\", base: 90, eg: \"trig. bipyramidal\" },\n 6: { name: \"octahedral\", base: 90, eg: \"octahedral\" }\n};\nfunction shapeName(b, l) {\n const key = b + \"_\" + l;\n const map = {\n \"2_0\": \"linear (180)\",\n \"3_0\": \"trigonal planar (120)\", \"2_1\": \"bent (<120)\",\n \"4_0\": \"tetrahedral (109.5)\", \"3_1\": \"trigonal pyramidal (~107)\", \"2_2\": \"bent (~104.5)\",\n \"5_0\": \"trigonal bipyramidal\", \"4_1\": \"seesaw\", \"3_2\": \"T-shaped\", \"2_3\": \"linear\",\n \"6_0\": \"octahedral (90)\", \"5_1\": \"square pyramidal\", \"4_2\": \"square planar (90)\",\n \"3_3\": \"T-shaped\", \"2_4\": \"linear\"\n };\n return map[key] || (b + \" bonds, \" + l + \" lone\");\n}\nconst inf = info[dom];\nconst sname = shapeName(bonds, lone);\nH.text(\"domains=\" + dom + \" bonds=\" + bonds + \" lone=\" + lone + \" e-geometry: \" + inf.eg + \" shape: \" + sname, 24, 52, { color: H.colors.sub, size: 13 });\nconst cam = H.cam3d({ scale: 70, dist: 8, cy: 320 });\ncam.yaw = 0.5 * t;\ncam.pitch = -0.45;\nfunction dirs(d) {\n const D = [];\n if (d === 2) return [[0,0,1],[0,0,-1]];\n if (d === 3) { for (let i=0;i<3;i++){ const a=i*2*Math.PI/3; D.push([Math.cos(a),Math.sin(a),0]); } return D; }\n if (d === 4) return [[1,1,1],[1,-1,-1],[-1,1,-1],[-1,-1,1]].map(v=>{const n=Math.sqrt(3);return [v[0]/n,v[1]/n,v[2]/n];});\n if (d === 5) return [[0,0,1],[0,0,-1],[1,0,0],[-0.5,Math.sin(2*Math.PI/3),0],[-0.5,-Math.sin(2*Math.PI/3),0]];\n if (d === 6) return [[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1]];\n return [[0,0,1]];\n}\nconst V = dirs(dom);\nconst C = [0, 0, 0];\ncam.sphere(C, 0.32, { color: H.colors.violet });\nfor (let i = 0; i < V.length; i++) {\n const tip = [V[i][0] * 1.4, V[i][1] * 1.4, V[i][2] * 1.4];\n const isBond = i < bonds;\n if (isBond) {\n cam.line(C, tip, { color: H.colors.sub, width: 2 });\n cam.sphere(tip, 0.22, { color: H.colors.accent2 });\n } else {\n const lp = [V[i][0] * 0.9, V[i][1] * 0.9, V[i][2] * 0.9];\n cam.sphere(lp, 0.16, { color: H.colors.warn });\n }\n}\nH.legend([{label:\"central atom\", color:H.colors.violet},{label:\"bonded atom\", color:H.colors.accent2},{label:\"lone pair\", color:H.colors.warn}], 24, H.H - 70);\nH.text(\"ideal e-domain angle: \" + inf.base + \" deg (lone pairs compress bond angles)\", 24, H.H - 24, { color: H.colors.good, size: 13 });" + }, + { + "id": "apchem-bond-polarity", + "area": "AP Chemistry", + "topic": "Bond polarity / dipole", + "title": "Bond polarity: ΔEN sets δ+/δ− and dipole μ", + "equation": "ΔEN = EN(A) − EN(B); μ points from δ+ toward δ−", + "keywords": [ + "bond polarity", + "electronegativity difference", + "dipole", + "delta EN", + "partial charges", + "polar covalent", + "nonpolar covalent", + "ionic bond", + "dipole moment", + "ap chemistry", + "electronegativity", + "mu" + ], + "explanation": "When two bonded atoms differ in electronegativity, the more electronegative atom pulls the shared bonding pair toward itself, gaining a partial negative charge (δ−) while its partner becomes δ+. The size of that shift scales with the electronegativity difference ΔEN: ΔEN < 0.4 is essentially nonpolar covalent, roughly 0.4–1.7 is polar covalent, and a very large ΔEN (greater than about 1.7–2.0) means electrons are transferred and the bond is ionic. The bond dipole μ is a vector that points from the δ+ atom toward the δ− atom (the crossed-arrow convention). On the AP exam this underlies predicting bond and molecular polarity, ranking dipole strength, and explaining intermolecular forces; the animation shows the shared electron pair drifting toward the δ− atom and the dipole arrow growing as ΔEN increases.", + "bullets": [ + "The more electronegative atom (A) becomes δ−; its partner becomes δ+.", + "Larger ΔEN → bigger charge separation → the shared e⁻ pair (green dot) sits closer to A.", + "Dipole arrow μ points from δ+ to δ−; it appears once the bond is polar (ΔEN ≥ 0.4).", + "ΔEN thresholds: <0.4 nonpolar, 0.4–1.7 polar covalent, >1.7 ionic." + ], + "params": [ + { + "name": "dEN", + "label": "ΔEN (electronegativity diff)", + "min": 0, + "max": 3.3, + "step": 0.1, + "value": 0.9 + } + ], + "code": "H.background();\nconst dEN = P.dEN;\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -3, yMax: 3 });\nv.grid(); v.axes();\n// Two atoms: A (left, more electronegative as dEN grows), B (right)\nconst ax = -2, bx = 2, ay = 0, by = 0;\n// vibration along bond axis\nconst vib = 0.12 * Math.sin(t * 2.2);\nconst axx = ax - vib, bxx = bx + vib;\n// classify\nlet kind = \"nonpolar covalent\";\nif (dEN > 1.7) kind = \"ionic\";\nelse if (dEN >= 0.4) kind = \"polar covalent\";\n// bond line\nv.line(axx, ay, bxx, by, { color: H.colors.grid, width: 6 });\n// partial charge magnitude grows with dEN (cap visual)\nconst frac = Math.min(dEN / 3.3, 1);\n// atom A pulls electrons -> delta minus ; B -> delta plus\nconst rA = 0.55 + 0.25 * frac;\nconst rB = 0.55 - 0.0 * frac;\nv.circle(axx, ay, 30, { fill: H.colors.accent, stroke: H.colors.ink, width: 2 });\nv.circle(bxx, by, 28, { fill: H.colors.accent2, stroke: H.colors.ink, width: 2 });\nv.text(\"A (EN+)\", axx, ay - 1.3, { color: H.colors.sub, size: 12, align: \"center\" });\nv.text(\"B\", bxx, by - 1.3, { color: H.colors.sub, size: 12, align: \"center\" });\n// partial charge labels\nv.text(\"δ−\", axx, ay + 0.0, { color: H.colors.ink, size: 16, align: \"center\" });\nv.text(\"δ+\", bxx, by + 0.0, { color: H.colors.ink, size: 16, align: \"center\" });\n// dipole arrow points from + to - (crossed arrow convention): from B(+) to A(-)\nif (dEN >= 0.4) {\n v.arrow(bxx - 0.2, -1.7, axx + 0.2, -1.7, { color: H.colors.warn, width: 3 });\n v.text(\"μ dipole → toward δ−\", 0, -2.3, { color: H.colors.warn, size: 12, align: \"center\" });\n}\n// electron cloud shift dot oscillating, biased toward A\nconst shift = -frac * 1.0 + 0.3 * Math.sin(t * 1.5) * (1 - frac);\nv.dot(shift, 0.9, { r: 7, fill: H.colors.good });\nv.text(\"shared e⁻ pair\", shift, 1.5, { color: H.colors.good, size: 11, align: \"center\" });\nH.text(\"Bond polarity: ΔEN sets δ+ / δ− and the dipole\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"More electronegative atom pulls bonding electrons → δ−\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"ΔEN = \" + dEN.toFixed(2) + \" → \" + kind, 24, 74, { color: H.colors.accent, size: 14, weight: 700 });" + }, + { + "id": "apchem-bond-energy-length", + "area": "AP Chemistry", + "topic": "Bond order vs length & energy", + "title": "Bond order ↑ → shorter, stronger bond (C–C/C=C/C≡C)", + "equation": "higher bond order ⇒ shorter bond length and larger bond energy", + "keywords": [ + "bond order", + "bond length", + "bond energy", + "single double triple bond", + "bond strength", + "shorter stronger", + "carbon-carbon bond", + "sigma pi bonds", + "ap chemistry", + "pm picometers", + "kj/mol" + ], + "explanation": "Bond order is the number of shared electron pairs between two atoms: 1 for a single bond, 2 for a double, 3 for a triple. As bond order rises, more electron density is concentrated between the nuclei, pulling them closer together, so bond length DECREASES, while it takes more energy to break the bond, so bond energy INCREASES. For carbon–carbon bonds this is quantitative: C–C is ~154 pm / 348 kJ·mol⁻¹, C=C is ~134 pm / 614 kJ·mol⁻¹, and C≡C is ~120 pm / 839 kJ·mol⁻¹. AP questions use this to rank bond length/strength, justify reactivity, and tie into Lewis structures and σ/π bonding; the animation shows the two carbons drawn closer with each added bond line while the energy bar climbs.", + "bullets": [ + "Bond order = number of shared pairs: single (1), double (2), triple (3).", + "Higher bond order pulls nuclei closer → bond LENGTH shrinks (154→134→120 pm).", + "Higher bond order takes more energy to break → bond ENERGY rises (348→614→839 kJ/mol).", + "The stiffer (higher-order) bond vibrates with smaller amplitude in the animation." + ], + "params": [ + { + "name": "bo", + "label": "bond order (1=single,2=double,3=triple)", + "min": 1, + "max": 3, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\n// bond order: 1, 2, or 3 (single/double/triple)\nlet bo = Math.round(P.bo);\nif (bo < 1) bo = 1; if (bo > 3) bo = 3;\n// representative C–C data: length (pm) and energy (kJ/mol)\nconst lenTab = { 1: 154, 2: 134, 3: 120 };\nconst enTab = { 1: 348, 2: 614, 3: 839 };\nconst len = lenTab[bo], en = enTab[bo];\nconst name = { 1: \"C–C single\", 2: \"C=C double\", 3: \"C≡C triple\" }[bo];\n// scene: draw two carbon atoms separated by length; bond drawn as bo lines\nconst cx = H.W / 2, cy = 230;\n// map length pm -> pixels (shorter = closer). 120..154 pm -> 200..130 px sep\nconst sep = H.map(len, 120, 154, 150, 220);\nconst vib = 4 * Math.sin(t * (1 + bo)) * (1 / bo); // stiffer (higher bo) vibrates less\nconst x1 = cx - sep / 2 - vib, x2 = cx + sep / 2 + vib;\n// bond lines (bo of them, stacked)\nfor (let i = 0; i < bo; i++) {\n const off = (i - (bo - 1) / 2) * 12;\n H.line(x1, cy + off, x2, cy + off, { color: H.colors.accent, width: 4 });\n}\nH.circle(x1, cy, 34, { fill: H.colors.panel, stroke: H.colors.ink, width: 2 });\nH.circle(x2, cy, 34, { fill: H.colors.panel, stroke: H.colors.ink, width: 2 });\nH.text(\"C\", x1, cy + 1, { color: H.colors.ink, size: 20, align: \"center\", baseline: \"middle\", weight: 700 });\nH.text(\"C\", x2, cy + 1, { color: H.colors.ink, size: 20, align: \"center\", baseline: \"middle\", weight: 700 });\n// length annotation\nH.line(x1, cy + 70, x2, cy + 70, { color: H.colors.sub, width: 1 });\nH.text(len + \" pm\", cx, cy + 88, { color: H.colors.sub, size: 13, align: \"center\" });\n// energy bar (animated fill / pulse)\nconst barX = 560, barY0 = 380, barW = 60;\nconst maxEn = 900;\nconst fullH = 260;\nconst h = (en / maxEn) * fullH;\nconst grow = 0.92 + 0.08 * (0.5 + 0.5 * Math.sin(t * 2));\nH.rect(barX, barY0 - fullH, barW, fullH, { stroke: H.colors.grid, width: 1 });\nH.rect(barX, barY0 - h * grow, barW, h * grow, { fill: H.colors.warn });\nH.text(\"bond energy\", barX + barW / 2, barY0 + 20, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(en + \" kJ/mol\", barX + barW / 2, barY0 - h - 10, { color: H.colors.warn, size: 13, align: \"center\", weight: 700 });\nH.text(\"Bond order ↑ → shorter & stronger bond\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"More shared pairs pull nuclei closer; harder to break\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(name + \": length \" + len + \" pm, energy \" + en + \" kJ/mol\", 24, 74, { color: H.colors.accent, size: 14, weight: 700 });" + }, + { + "id": "apchem-hybridization", + "area": "AP Chemistry", + "topic": "Hybridization (sp, sp2, sp3)", + "title": "Hybridization: sp (180°), sp² (120°), sp³ (109.5°)", + "equation": "steric number = # σ bonds + lone pairs → sp/sp²/sp³ geometry & angle", + "keywords": [ + "hybridization", + "sp sp2 sp3", + "bond angle", + "tetrahedral", + "trigonal planar", + "linear", + "orbital geometry", + "steric number", + "vsepr", + "ap chemistry", + "hybrid orbitals", + "109.5 120 180" + ], + "explanation": "A central atom mixes its valence s and p atomic orbitals into equivalent hybrid orbitals to point its bonds (and lone pairs) as far apart as possible. Mixing 1 s + 1 p gives two sp hybrids (linear, 180°); 1 s + 2 p gives three sp² hybrids (trigonal planar, 120°); 1 s + 3 p gives four sp³ hybrids (tetrahedral, 109.5°). The number of hybrids equals the steric number = (σ bonds + lone pairs), which is exactly what VSEPR counts. On the AP exam you determine hybridization from a Lewis structure, then read off geometry and approximate bond angle; the animation shows the hybrid orbitals fanning out into the correct geometry with a probe tracing the characteristic angle.", + "bullets": [ + "Number of hybrid orbitals = steric number = σ bonds + lone pairs.", + "sp = 2 hybrids, linear, 180°; sp² = 3 hybrids, trigonal planar, 120°; sp³ = 4 hybrids, tetrahedral, 109.5°.", + "More p-character (sp→sp²→sp³) spreads the orbitals to ever-smaller angles.", + "The green probe sweeps out the characteristic bond angle for the selected hybridization." + ], + "params": [ + { + "name": "hyb", + "label": "hybridization (1=sp, 2=sp², 3=sp³)", + "min": 1, + "max": 3, + "step": 1, + "value": 3 + } + ], + "code": "H.background();\n// hybridization index: 1 -> sp, 2 -> sp2, 3 -> sp3\nlet h = Math.round(P.hyb);\nif (h < 1) h = 1; if (h > 3) h = 3;\nconst data = {\n 1: { name: \"sp\", angle: 180, geom: \"linear\", n: 2, dirs: [0, 180], scount: \"1 s + 1 p\" },\n 2: { name: \"sp²\", angle: 120, geom: \"trigonal planar\", n: 3, dirs: [90, 210, 330], scount: \"1 s + 2 p\" },\n 3: { name: \"sp³\", angle: 109.5, geom: \"tetrahedral\", n: 4, dirs: [-60, 60, 150, 235], scount: \"1 s + 3 p\" }\n};\nconst d = data[h];\nconst cx = H.W / 2 - 80, cy = 300;\nconst R = 130;\n// central atom\nH.circle(cx, cy, 26, { fill: H.colors.accent, stroke: H.colors.ink, width: 2 });\nH.text(\"C\", cx, cy + 1, { color: H.colors.bg, size: 18, align: \"center\", baseline: \"middle\", weight: 700 });\n// breathing animation of lobes\nconst breathe = 1 + 0.06 * Math.sin(t * 2.5);\n// draw hybrid orbital lobes / bonds in correct geometry (2D projection)\nfor (let i = 0; i < d.n; i++) {\n const a = d.dirs[i] * Math.PI / 180;\n const ex = cx + Math.cos(a) * R * breathe;\n const ey = cy - Math.sin(a) * R * breathe;\n H.line(cx, cy, ex, ey, { color: H.colors.accent2, width: 4 });\n H.circle(ex, ey, 16, { fill: H.colors.violet, stroke: H.colors.ink, width: 1.5 });\n}\n// sweeping angle probe dot tracing the characteristic bond angle\nconst sweep = d.angle;\nconst phase = 0.5 + 0.5 * Math.sin(t * 1.2);\nconst aDeg = phase * sweep;\nconst ar = 55;\nconst px = cx + Math.cos(aDeg * Math.PI / 180) * ar;\nconst py = cy - Math.sin(aDeg * Math.PI / 180) * ar;\nH.circle(px, py, 6, { fill: H.colors.good });\nH.text(aDeg.toFixed(0) + \"°\", px + 12, py, { color: H.colors.good, size: 12, baseline: \"middle\" });\nH.text(\"Hybridization: \" + d.name + \" orbitals\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Mix \" + d.scount + \" → \" + d.n + \" equivalent \" + d.name + \" hybrids\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(d.name + \": \" + d.geom + \", bond angle \" + d.angle + \"°\", 24, 74, { color: H.colors.accent, size: 14, weight: 700 });\n// side legend\nH.text(\"steric number = \" + d.n, H.W - 210, 120, { color: H.colors.sub, size: 13 });\nH.text(\"angle ≈ \" + d.angle + \"°\", H.W - 210, 144, { color: H.colors.violet, size: 13 });" + }, + { + "id": "apchem-imf", + "area": "AP Chemistry", + "topic": "Intermolecular forces", + "title": "IMF strength → boiling point: LDF < dipole–dipole < H-bond", + "equation": "stronger intermolecular force ⇒ more energy to separate ⇒ higher boiling point", + "keywords": [ + "intermolecular forces", + "imf", + "london dispersion", + "ldf", + "dipole-dipole", + "hydrogen bonding", + "h-bond", + "boiling point", + "van der waals", + "ap chemistry", + "polarity", + "melting point" + ], + "explanation": "Intermolecular forces (IMFs) are attractions BETWEEN molecules, and their strength controls physical properties like boiling point: the stronger the IMF, the more kinetic energy (higher temperature) is needed to pull molecules apart, so the boiling point is higher. In rough order of increasing strength they are London dispersion forces (LDF, induced temporary dipoles, present in ALL molecules and growing with molar mass/polarizability), permanent dipole–dipole forces (between polar molecules), and hydrogen bonding (a especially strong dipole interaction when H is bonded to N, O, or F). AP free-response questions ask you to identify the dominant IMF and use it to rank or explain boiling points, solubility, and vapor pressure; the animation shows two molecules held more tightly with smaller thermal wobble as the IMF type is strengthened, while the boiling-point bar rises.", + "bullets": [ + "IMFs act BETWEEN molecules; bonds act within — IMFs set boiling/melting point.", + "Strength order: LDF (weakest) < dipole–dipole < hydrogen bond (strongest).", + "Hydrogen bonding requires H bonded directly to N, O, or F (here, H₂O, bp 100 °C).", + "Stronger IMF → smaller thermal wobble and a taller boiling-point bar." + ], + "params": [ + { + "name": "imf", + "label": "IMF type (1=LDF, 2=dipole, 3=H-bond)", + "min": 1, + "max": 3, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\n// IMF type: 1 -> LDF (London dispersion), 2 -> dipole-dipole, 3 -> hydrogen bond\nlet k = Math.round(P.imf);\nif (k < 1) k = 1; if (k > 3) k = 3;\nconst data = {\n 1: { name: \"London dispersion (LDF)\", strength: 1, ex: \"Cl₂ bp ≈ −34 °C\", color: H.colors.accent },\n 2: { name: \"Dipole–dipole\", strength: 2, ex: \"H₂S bp ≈ −60 °C\", color: H.colors.violet },\n 3: { name: \"Hydrogen bond\", strength: 3, ex: \"H₂O bp = 100 °C\", color: H.colors.warn }\n};\nconst d = data[k];\n// two molecules vibrating; closer & tighter coupling for stronger IMF\nconst cx = H.W / 2, cy = 250;\nconst baseSep = 220 - d.strength * 30;\n// stronger IMF -> smaller thermal wobble (more held together)\nconst amp = 30 / d.strength;\nconst wob = amp * Math.sin(t * 2.0);\nconst x1 = cx - baseSep / 2 - wob, x2 = cx + baseSep / 2 + wob;\n// molecule 1\nH.circle(x1, cy, 30, { fill: H.colors.panel, stroke: d.color, width: 3 });\nH.circle(x2, cy, 30, { fill: H.colors.panel, stroke: d.color, width: 3 });\n// partial charges shown for dipole / H-bond\nif (k >= 2) {\n H.text(\"δ+\", x1 - 6, cy, { color: H.colors.good, size: 13, align: \"center\", baseline: \"middle\" });\n H.text(\"δ−\", x1 + 14, cy, { color: H.colors.warn, size: 13, align: \"center\", baseline: \"middle\" });\n H.text(\"δ+\", x2 - 6, cy, { color: H.colors.good, size: 13, align: \"center\", baseline: \"middle\" });\n H.text(\"δ−\", x2 + 14, cy, { color: H.colors.warn, size: 13, align: \"center\", baseline: \"middle\" });\n}\n// the IMF link between them (dashed), thickness ~ strength\nconst dash = k === 3 ? [6, 5] : [4, 6];\nfor (let i = 0; i < d.strength; i++) {\n const off = (i - (d.strength - 1) / 2) * 7;\n H.line(x1 + 30, cy + off, x2 - 30, cy + off, { color: d.color, width: 2, dash: dash });\n}\nH.text(\"attraction\", cx, cy - 24, { color: d.color, size: 12, align: \"center\" });\n// boiling-point thermometer bar (relative), animated pulse\nconst bpRel = d.strength / 3; // 0..1 schematic\nconst barX = 120, barTop = 360, barBot = 470, barW = 50;\nH.rect(barX, barTop, barW, barBot - barTop, { stroke: H.colors.grid, width: 1 });\nconst fill = (barBot - barTop) * bpRel;\nconst puls = 0.95 + 0.05 * Math.sin(t * 3);\nH.rect(barX, barBot - fill * puls, barW, fill * puls, { fill: d.color });\nH.text(\"boiling point\", barX + barW / 2, barBot + 18, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(\"Intermolecular forces set boiling point\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Stronger IMF → more energy to separate molecules → higher b.p.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(d.name + \" | \" + d.ex, 24, 74, { color: d.color, size: 14, weight: 700 });\n// ranking legend\nH.legend([\n { label: \"LDF (weakest)\", color: H.colors.accent },\n { label: \"dipole–dipole\", color: H.colors.violet },\n { label: \"H-bond (strongest)\", color: H.colors.warn }\n], H.W - 230, 110);" + }, + { + "id": "apchem-lattice-energy", + "area": "AP Chemistry", + "topic": "Lattice energy (Coulomb)", + "title": "Lattice energy: U ∝ q₁q₂ / r", + "equation": "U ∝ (q1 * q2) / r", + "keywords": [ + "lattice energy", + "coulomb's law", + "ionic charge", + "ionic radius", + "coulombic attraction", + "born-lande", + "ionic bond strength", + "melting point ionic", + "ap chemistry", + "q1q2/r", + "electrostatic" + ], + "explanation": "Lattice energy is the energy released when gaseous ions combine into an ionic solid, and its magnitude follows the Coulomb relationship U ∝ q₁q₂ / r, where q₁ and q₂ are the ion charges and r is the distance between ion centers (the sum of the ionic radii). Two trends fall out: increasing the ion charges raises U sharply (MgO with 2+/2− is far more than NaCl with 1+/1−), and decreasing the ionic radius (smaller ions, smaller r) also raises U because of the inverse-r dependence. Larger lattice energy means a more strongly held lattice, which on the AP exam is used to explain higher melting points, greater hardness, and lower solubility of ionic compounds. The animation plots U(r) ∝ q₁q₂/r, sweeps a probe along it to show the 1/r falloff, and shrinks the ion pair as r decreases.", + "bullets": [ + "U ∝ q₁q₂/r: bigger charges and smaller ions both INCREASE lattice energy.", + "Charge dominates — doubling both charges (1→2) quadruples q₁q₂ and U.", + "The 1/r curve is steep: shrinking r raises U more and more rapidly (violet marker).", + "Higher lattice energy → higher melting point and harder, less-soluble ionic solid." + ], + "params": [ + { + "name": "q1", + "label": "cation charge q₁ (+)", + "min": 1, + "max": 3, + "step": 1, + "value": 1 + }, + { + "name": "q2", + "label": "anion charge q₂ (−)", + "min": 1, + "max": 3, + "step": 1, + "value": 1 + }, + { + "name": "r", + "label": "ion separation r (nm)", + "min": 0.15, + "max": 0.7, + "step": 0.01, + "value": 0.28 + } + ], + "code": "H.background();\n// q1, q2 = ionic charge magnitudes (cation +, anion -); r = ionic separation (radius sum) in arbitrary units\nlet q1 = P.q1, q2 = P.q2, r = P.r;\nif (!(r > 0.1)) r = 0.1; // guard divide-by-zero / tiny r\n// Lattice energy magnitude ∝ |q1*q2| / r (Born-Landé / Coulomb proportionality)\n// scale to plausible kJ/mol: NaCl q=1,1 r~0.28nm -> ~787. Use constant tuned to that.\nconst Kconst = 787 * 0.28; // gives U=787 at q1=q2=1, r=0.28\nconst U = Kconst * (q1 * q2) / r;\nconst v = H.plot2d({ xMin: 0.15, xMax: 0.7, yMin: 0, yMax: 5000 });\nv.grid(); v.axes();\n// curve U(r) for current charges\nv.fn(x => Kconst * (q1 * q2) / Math.max(x, 0.05), { color: H.colors.accent, width: 3 });\n// moving probe sweeping r to show 1/r falloff\nconst rs = 0.25 + 0.2 * (0.5 + 0.5 * Math.sin(t * 0.8));\nconst Us = Kconst * (q1 * q2) / rs;\nv.dot(rs, Math.min(Us, 5000), { r: 7, fill: H.colors.warn });\nv.text(\"U(r) ∝ q₁q₂/r\", 0.42, 4200, { color: H.colors.accent, size: 13 });\n// marker at the chosen r\nv.line(r, 0, r, Math.min(U, 5000), { color: H.colors.violet, width: 1.5, dash: [5, 5] });\nv.dot(r, Math.min(U, 5000), { r: 6, fill: H.colors.violet });\n// small ion-pair inset, vibrating\nconst ix = 720, iy = 130;\nconst vib = 3 * Math.sin(t * 2);\nconst sep = H.map(r, 0.15, 0.7, 50, 120);\nH.circle(ix - sep / 2 - vib, iy, 18, { fill: H.colors.good, stroke: H.colors.ink, width: 1.5 });\nH.circle(ix + sep / 2 + vib, iy, 22, { fill: H.colors.warn, stroke: H.colors.ink, width: 1.5 });\nH.text(\"+\" + q1, ix - sep / 2 - vib, iy, { color: H.colors.bg, size: 13, align: \"center\", baseline: \"middle\", weight: 700 });\nH.text(\"−\" + q2, ix + sep / 2 + vib, iy, { color: H.colors.bg, size: 13, align: \"center\", baseline: \"middle\", weight: 700 });\nH.text(\"r\", ix, iy + 34, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(\"Lattice energy: U ∝ q₁q₂ / r\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Higher charges and smaller ions → much stronger ionic lattice\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"U ≈ \" + U.toFixed(0) + \" kJ/mol (q₁=\" + q1 + \", q₂=\" + q2 + \", r=\" + r.toFixed(2) + \" nm)\", 24, 74, { color: H.colors.warn, size: 14, weight: 700 });" + }, + { + "id": "apchem-mole-concept", + "area": "AP Chemistry", + "topic": "Mole & molar mass", + "title": "Mole concept: n = mass / M, N = n * NA", + "equation": "n = mass / M ; N = n * NA", + "keywords": [ + "mole", + "molar mass", + "avogadro number", + "n = mass / M", + "moles to grams", + "grams to moles", + "particles", + "6.022e23", + "stoichiometry", + "ap chemistry", + "ap chem" + ], + "explanation": "The mole is chemistry's counting unit: one mole is 6.022 x 10^23 particles (Avogadro's number, NA). To go from a measured mass in grams to a count of particles you make two conversions: divide grams by the molar mass M (g/mol) to get moles n, then multiply by NA to get the number of particles. Units cancel cleanly (g / (g/mol) = mol, then mol x 1/mol = particles), which is exactly the dimensional-analysis chain AP graders look for. The three animated bars show mass converting down to moles via /M and then up to particle count via xNA.", + "bullets": [ + "n = mass / M: grams divided by molar mass (g/mol) gives moles.", + "N = n * NA: moles times 6.022e23 /mol gives particle count.", + "Molar mass M is the bridge between lab grams and counted moles.", + "Watch the three bars: the same sample shown as g, mol, and particles." + ], + "params": [ + { + "name": "grams", + "label": "mass (g)", + "min": 0, + "max": 100, + "step": 1, + "value": 18 + }, + { + "name": "M", + "label": "molar mass M (g/mol)", + "min": 1, + "max": 200, + "step": 1, + "value": 18 + } + ], + "code": "H.background();\nconst M = Math.max(0.01, P.M);\nconst grams = Math.max(0, P.grams);\nconst NA = 6.022e23;\nconst n = grams / M;\nconst particles = n * NA;\nH.text(\"Mole concept: n = mass / M, N = n * NA\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Grams convert to moles via molar mass M, then to particles via Avogadro's number.\", 24, 52, { color: H.colors.sub, size: 13 });\n\nconst pulse = 0.5 + 0.5 * Math.sin(t * 1.2);\nconst bx = 60, bw = 560, bh = 36, gap = 70;\nconst rows = [\n { y: 110, label: \"mass = \" + grams.toFixed(1) + \" g\", frac: Math.min(1, grams / 100), col: H.colors.accent },\n { y: 110 + gap, label: \"n = \" + n.toFixed(3) + \" mol\", frac: Math.min(1, n / 5), col: H.colors.good },\n { y: 110 + 2 * gap, label: \"N = \" + particles.toExponential(2) + \" particles\", frac: Math.min(1, particles / (5 * NA)), col: H.colors.violet }\n];\nfor (let i = 0; i < rows.length; i++) {\n const r = rows[i];\n H.rect(bx, r.y, bw, bh, { stroke: H.colors.grid, width: 1, radius: 6 });\n const w = Math.max(0, r.frac * bw);\n H.rect(bx, r.y, w, bh, { fill: r.col, radius: 6 });\n H.text(r.label, bx, r.y - 8, { color: H.colors.ink, size: 13, weight: 700 });\n H.circle(bx + w * (0.6 + 0.4 * pulse), r.y + bh / 2, 6, { fill: H.colors.yellow });\n if (i < rows.length - 1) H.arrow(bx + bw / 2, r.y + bh + 6, bx + bw / 2, r.y + gap - 14, { color: H.colors.sub, width: 2 });\n}\nH.text(\"/ M\", bx + bw / 2 + 10, 110 + bh + 28, { color: H.colors.sub, size: 12 });\nH.text(\"x Na\", bx + bw / 2 + 10, 110 + gap + bh + 28, { color: H.colors.sub, size: 12 });\nH.text(\"M = \" + M.toFixed(1) + \" g/mol\", 660, 128, { color: H.colors.accent2, size: 14, weight: 700 });\nH.text(\"n = \" + n.toFixed(3) + \" mol\", 660, 198, { color: H.colors.good, size: 14, weight: 700 });\nH.text(\"Na = 6.022e23 /mol\", 660, 268, { color: H.colors.violet, size: 13 });" + }, + { + "id": "apchem-empirical-formula", + "area": "AP Chemistry", + "topic": "Empirical vs molecular formula", + "title": "Empirical formula: moles / smallest -> whole-number ratio", + "equation": "ratio_i = (mass%_i / M_i) / min(moles)", + "keywords": [ + "empirical formula", + "molecular formula", + "mass percent", + "percent composition", + "mole ratio", + "smallest whole number ratio", + "combustion analysis", + "percent to formula", + "ap chemistry", + "ap chem" + ], + "explanation": "An empirical formula is the simplest whole-number ratio of atoms in a compound. Starting from mass percents, you assume a 100 g sample so each percent equals grams, divide each element's grams by its molar mass to get moles, then divide every mole value by the smallest of them to expose the ratio. Rounding those ratios to whole numbers gives the empirical formula; the molecular formula is then a whole-number multiple of it (found from the actual molar mass). This CxHyOz demo highlights each element's percent, moles, and scaled ratio, the exact workflow AP free-response problems reward.", + "bullets": [ + "Assume a 100 g sample so mass% reads directly as grams.", + "Divide grams by molar mass to get moles of each element.", + "Divide all mole values by the smallest to get the ratio.", + "Round ratios to whole numbers: that IS the empirical formula." + ], + "params": [ + { + "name": "pctC", + "label": "mass % C", + "min": 0, + "max": 100, + "step": 1, + "value": 40 + }, + { + "name": "pctH", + "label": "mass % H", + "min": 0, + "max": 100, + "step": 1, + "value": 7 + } + ], + "code": "H.background();\nlet pC = Math.max(0, Math.min(100, P.pctC));\nlet pH = Math.max(0, Math.min(100, P.pctH));\nif (pC + pH > 100) { const s = 100 / (pC + pH); pC *= s; pH *= s; }\nconst pO = Math.max(0, 100 - pC - pH);\nconst MC = 12.011, MH = 1.008, MO = 15.999;\nconst nC = pC / MC, nH = pH / MH, nO = pO / MO;\nconst mins = Math.min(nC > 0 ? nC : Infinity, nH > 0 ? nH : Infinity, nO > 0 ? nO : Infinity);\nconst base = isFinite(mins) && mins > 0 ? mins : 1;\nconst rC = nC / base, rH = nH / base, rO = nO / base;\nH.text(\"Empirical formula: divide moles by the smallest\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Take a 100 g sample so mass% = grams; convert to moles, then scale to smallest whole ratio.\", 24, 52, { color: H.colors.sub, size: 13 });\n\nconst elems = [\n { sym: \"C\", pct: pC, n: nC, r: rC, col: H.colors.accent },\n { sym: \"H\", pct: pH, n: nH, r: rH, col: H.colors.good },\n { sym: \"O\", pct: pO, n: nO, r: rO, col: H.colors.violet }\n];\nconst hi = Math.floor((t * 0.6) % 3);\nconst cx = [200, 430, 660];\nfor (let i = 0; i < 3; i++) {\n const e = elems[i];\n const x = cx[i];\n H.circle(x, 200, 46, { fill: e.col, stroke: H.colors.ink, width: i === hi ? 3 : 1 });\n H.text(e.sym, x, 200, { color: H.colors.bg, size: 26, weight: 700, align: \"center\", baseline: \"middle\" });\n H.text(e.pct.toFixed(1) + \" %\", x, 270, { color: H.colors.sub, size: 13, align: \"center\" });\n H.text(\"n = \" + e.n.toFixed(3) + \" mol\", x, 292, { color: e.col, size: 13, align: \"center\", weight: 700 });\n H.text(\"ratio = \" + e.r.toFixed(2), x, 314, { color: H.colors.ink, size: 14, align: \"center\", weight: 700 });\n}\nconst sub = v => { const r = Math.round(v); return r <= 1 ? \"\" : String(r); };\nconst formula = \"C\" + sub(rC) + \"H\" + sub(rH) + \"O\" + sub(rO);\nH.text(\"Empirical formula = \" + formula, 24, 380, { color: H.colors.accent2, size: 18, weight: 700 });\nH.text(\"(ratios rounded to nearest whole number)\", 24, 404, { color: H.colors.sub, size: 12 });" + }, + { + "id": "apchem-limiting-reactant", + "area": "AP Chemistry", + "topic": "Limiting reactant", + "title": "Limiting reactant: smallest extent caps product (N2 + 3H2 -> 2NH3)", + "equation": "extent = min(n_N2/1, n_H2/3) ; n_NH3 = 2*extent", + "keywords": [ + "limiting reactant", + "limiting reagent", + "excess reactant", + "stoichiometry", + "mole ratio", + "extent of reaction", + "haber process", + "theoretical product", + "ap chemistry", + "ap chem" + ], + "explanation": "When reactants are not supplied in the exact stoichiometric ratio, one runs out first and limits how much product forms. To find it, divide each reactant's moles by its coefficient (here 1 for N2, 3 for H2); the smallest quotient is the reaction extent, and the reactant giving it is the limiting reactant. Product is then coefficient x extent (2 x extent for NH3), and the other reactant is left in excess. The animated bars deplete the reactants and build NH3 in real time, with a clear callout of which species limits the yield, exactly how AP stoichiometry problems are scored.", + "bullets": [ + "Divide each reactant's moles by its coefficient (1 for N2, 3 for H2).", + "The SMALLEST quotient is the reaction extent and names the limiter.", + "Product NH3 = 2 x extent; the other reactant remains in excess.", + "Bars show reactants depleting and NH3 building as the reaction runs." + ], + "params": [ + { + "name": "molN2", + "label": "moles N2", + "min": 0, + "max": 10, + "step": 0.5, + "value": 3 + }, + { + "name": "molH2", + "label": "moles H2", + "min": 0, + "max": 30, + "step": 0.5, + "value": 6 + } + ], + "code": "H.background();\nconst nN2 = Math.max(0, P.molN2);\nconst nH2 = Math.max(0, P.molH2);\nconst extN2 = nN2 / 1;\nconst extH2 = nH2 / 3;\nconst ext = Math.min(extN2, extH2);\nconst limiting = extN2 <= extH2 ? \"N2\" : \"H2\";\nconst usedN2 = ext * 1, usedH2 = ext * 3;\nconst leftN2 = nN2 - usedN2, leftH2 = nH2 - usedH2;\nconst prodNH3 = ext * 2;\nH.text(\"Limiting reactant: N2 + 3 H2 -> 2 NH3\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Whichever reactant gives the SMALLER reaction extent runs out first and caps the product.\", 24, 52, { color: H.colors.sub, size: 13 });\n\nconst prog = 0.5 - 0.5 * Math.cos(t * 1.0);\nconst bx = 80, bw = 520, bh = 30;\nconst scaleMax = Math.max(nN2, nH2, prodNH3, 0.001);\nfunction row(y, label, val, col) {\n H.rect(bx, y, bw, bh, { stroke: H.colors.grid, width: 1, radius: 6 });\n const w = bw * Math.min(1, val / scaleMax);\n H.rect(bx, y, Math.max(0, w), bh, { fill: col, radius: 6 });\n H.text(label, bx, y - 7, { color: H.colors.ink, size: 12, weight: 700 });\n}\nconst curN2 = nN2 - usedN2 * prog;\nconst curH2 = nH2 - usedH2 * prog;\nconst curNH3 = prodNH3 * prog;\nrow(95, \"N2 remaining = \" + curN2.toFixed(2) + \" mol\", curN2, H.colors.accent);\nrow(150, \"H2 remaining = \" + curH2.toFixed(2) + \" mol\", curH2, H.colors.good);\nrow(205, \"NH3 formed = \" + curNH3.toFixed(2) + \" mol\", curNH3, H.colors.violet);\n\nH.text(\"Limiting reactant: \" + limiting, 24, 300, { color: H.colors.warn, size: 16, weight: 700 });\nH.text(\"Reaction extent = \" + ext.toFixed(3) + \" mol rxn\", 24, 326, { color: H.colors.sub, size: 13 });\nH.text(\"Max NH3 = \" + prodNH3.toFixed(3) + \" mol\", 24, 348, { color: H.colors.violet, size: 14, weight: 700 });\nH.text(\"Excess left: N2 \" + leftN2.toFixed(2) + \" mol, H2 \" + leftH2.toFixed(2) + \" mol\", 24, 372, { color: H.colors.sub, size: 13 });\nH.legend([\n { label: \"N2\", color: H.colors.accent },\n { label: \"H2\", color: H.colors.good },\n { label: \"NH3\", color: H.colors.violet }\n], 640, 100);" + }, + { + "id": "apchem-percent-yield", + "area": "AP Chemistry", + "topic": "Percent yield", + "title": "Percent yield = (actual / theoretical) x 100%", + "equation": "% yield = (actual / theoretical) * 100", + "keywords": [ + "percent yield", + "actual yield", + "theoretical yield", + "reaction efficiency", + "stoichiometry", + "yield calculation", + "lab recovery", + "side reactions", + "ap chemistry", + "ap chem" + ], + "explanation": "Theoretical yield is the maximum product predicted by stoichiometry from the limiting reactant; actual yield is what you really recover in the lab. Percent yield = (actual / theoretical) x 100% measures efficiency and is normally below 100% because of side reactions, incomplete reactions, and losses during transfer and purification. A value above 100% signals an impure or wet product (or measurement error), not extra mass appearing. The animated beaker fills the actual amount against the dashed theoretical-max line while the gauge reports the live percent, mirroring AP lab and free-response questions.", + "bullets": [ + "Theoretical yield is the stoichiometric maximum from the limiting reactant.", + "Actual yield is the measured mass you recover in the lab.", + "% yield = actual/theoretical x 100; real reactions fall short of 100%.", + "Beaker level = actual; dashed line = theoretical max; gauge shows % live." + ], + "params": [ + { + "name": "theoretical", + "label": "theoretical yield (g)", + "min": 1, + "max": 50, + "step": 1, + "value": 20 + }, + { + "name": "actual", + "label": "actual yield (g)", + "min": 0, + "max": 50, + "step": 1, + "value": 15 + } + ], + "code": "H.background();\nconst theo = Math.max(0.001, P.theoretical);\nconst actual = Math.max(0, P.actual);\nconst pct = (actual / theo) * 100;\nconst shown = Math.min(actual, theo);\nH.text(\"Percent yield = (actual / theoretical) x 100%\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Compares what you really recovered to the stoichiometric maximum; in practice it is below 100%.\", 24, 52, { color: H.colors.sub, size: 13 });\n\nconst bx = 120, by = 110, bw = 150, bh = 240;\nH.rect(bx, by, bw, bh, { stroke: H.colors.grid, width: 2, radius: 4 });\nconst settle = 1 - Math.exp(-t * 1.2);\nconst fillFrac = Math.min(1, shown / theo) * (0.96 + 0.04 * Math.sin(t * 2.5)) * settle;\nconst fillH = bh * Math.max(0, Math.min(1, fillFrac));\nH.rect(bx + 2, by + bh - fillH, bw - 4, fillH, { fill: H.colors.accent });\nH.line(bx - 14, by, bx + bw + 14, by, { color: H.colors.warn, width: 2, dash: [6, 4] });\nH.text(\"theoretical max\", bx + bw + 18, by + 4, { color: H.colors.warn, size: 12 });\nconst ly = by + bh - bh * Math.min(1, shown / theo);\nH.line(bx - 14, ly, bx + bw + 14, ly, { color: H.colors.good, width: 2 });\nH.text(\"actual\", bx + bw + 18, ly + 4, { color: H.colors.good, size: 12 });\n\nH.text(\"Theoretical = \" + theo.toFixed(2) + \" g\", 480, 150, { color: H.colors.warn, size: 14, weight: 700 });\nH.text(\"Actual = \" + actual.toFixed(2) + \" g\", 480, 184, { color: H.colors.good, size: 14, weight: 700 });\nconst pcol = pct > 100 ? H.colors.warn : H.colors.accent2;\nH.text(\"Percent yield = \" + pct.toFixed(1) + \" %\", 480, 230, { color: pcol, size: 20, weight: 700 });\nif (pct > 100) H.text(\"(>100% means impure product or error)\", 480, 256, { color: H.colors.warn, size: 12 });\nelse H.text(\"lost to side reactions, transfer, purification\", 480, 256, { color: H.colors.sub, size: 12 });\n\nconst gx = 480, gy = 300, gw = 320, gh = 26;\nH.rect(gx, gy, gw, gh, { stroke: H.colors.grid, width: 1, radius: 6 });\nconst gwid = gw * Math.min(1, pct / 100) * settle;\nH.rect(gx, gy, Math.max(0, gwid), gh, { fill: H.colors.accent2, radius: 6 });\nH.text(\"0%\", gx, gy + gh + 16, { color: H.colors.sub, size: 11 });\nH.text(\"100%\", gx + gw - 26, gy + gh + 16, { color: H.colors.sub, size: 11 });" + }, + { + "id": "apchem-redox-half", + "area": "AP Chemistry", + "topic": "Balancing redox (half-reactions)", + "title": "Redox half-reactions: electrons lost = electrons gained", + "equation": "M -> M^z+ + z e- ; X^z+ + z e- -> X", + "keywords": [ + "redox", + "half reaction", + "oxidation", + "reduction", + "electrons transferred", + "oxidation state", + "balancing redox", + "electron transfer", + "oil rig", + "ap chemistry", + "ap chem" + ], + "explanation": "A redox reaction splits into two half-reactions: oxidation, where a species loses electrons and its oxidation number rises, and reduction, where a species gains electrons and its number falls (OIL RIG: Oxidation Is Loss, Reduction Is Gain). To balance, the electrons released by oxidation must exactly equal the electrons consumed by reduction, so when the half-reactions are added the electrons cancel. Here the slider sets z, the electrons transferred per atom (the size of the oxidation-state change), and z electrons animate along the wire from the oxidation electrode to the reduction electrode. This electron-bookkeeping is the core skill for AP redox and electrochemistry questions.", + "bullets": [ + "Oxidation (left): M loses z electrons, oxidation number 0 -> +z.", + "Reduction (right): X^z+ gains z electrons, +z -> 0.", + "Electrons lost must equal electrons gained so they cancel when added.", + "z moving electrons on the wire = electrons transferred per atom." + ], + "params": [ + { + "name": "z", + "label": "electrons transferred z", + "min": 1, + "max": 4, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst z = Math.max(1, Math.round(P.z));\nH.text(\"Redox half-reactions: balance the electrons transferred\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Oxidation loses e- (number goes up); reduction gains e-; the two e- counts must match.\", 24, 52, { color: H.colors.sub, size: 13 });\n\nconst oxX = 180, redX = 720;\nH.rect(oxX - 50, 120, 100, 220, { fill: H.colors.panel, stroke: H.colors.accent, width: 2, radius: 6 });\nH.rect(redX - 50, 120, 100, 220, { fill: H.colors.panel, stroke: H.colors.good, width: 2, radius: 6 });\nH.text(\"OXIDATION\", oxX, 108, { color: H.colors.accent, size: 13, weight: 700, align: \"center\" });\nH.text(\"REDUCTION\", redX, 108, { color: H.colors.good, size: 13, weight: 700, align: \"center\" });\nH.text(\"M -> M(\" + z + \"+) + \" + z + \" e-\", oxX, 360, { color: H.colors.accent, size: 14, weight: 700, align: \"center\" });\nH.text(\"X(\" + z + \"+) + \" + z + \" e- -> X\", redX, 360, { color: H.colors.good, size: 14, weight: 700, align: \"center\" });\n\nconst wireY = 150;\nH.line(oxX, 120, oxX, wireY, { color: H.colors.sub, width: 2 });\nH.line(redX, 120, redX, wireY, { color: H.colors.sub, width: 2 });\nH.line(oxX, wireY, redX, wireY, { color: H.colors.sub, width: 2 });\nH.arrow(oxX + 200, wireY, oxX + 260, wireY, { color: H.colors.yellow, width: 2 });\n\nfor (let i = 0; i < z; i++) {\n const phase = (t * 0.5 + i / z) % 1;\n const ex = oxX + (redX - oxX) * phase;\n H.circle(ex, wireY, 7, { fill: H.colors.yellow, stroke: H.colors.ink, width: 1 });\n H.text(\"-\", ex, wireY, { color: H.colors.bg, size: 11, weight: 700, align: \"center\", baseline: \"middle\" });\n}\n\nH.text(\"Oxidation state of M: 0 -> +\" + z, 24, 410, { color: H.colors.accent, size: 13 });\nH.text(\"Oxidation state of X: +\" + z + \" -> 0\", 24, 432, { color: H.colors.good, size: 13 });\nH.text(\"Electrons transferred = \" + z + \" per atom\", 24, 462, { color: H.colors.accent2, size: 16, weight: 700 });\nH.text(\"Net: M + X(\" + z + \"+) -> M(\" + z + \"+) + X (e- cancel)\", 24, 486, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"electron (e-)\", color: H.colors.yellow }], 600, 420);" + }, + { + "id": "apchem-net-ionic", + "area": "AP Chemistry", + "topic": "Net ionic equations", + "title": "Net ionic equation: Ag+(aq) + Cl-(aq) -> AgCl(s)", + "equation": "Ag+(aq) + Cl-(aq) -> AgCl(s) (spectators Na+, NO3- cancel)", + "keywords": [ + "net ionic equation", + "spectator ions", + "complete ionic equation", + "molecular equation", + "precipitation reaction", + "agcl", + "silver chloride", + "double replacement", + "ap chemistry", + "dissociation" + ], + "explanation": "A net ionic equation strips a reaction down to only the species that actually change. You start from the molecular equation (AgNO3 + NaCl -> AgCl + NaNO3), write every strong electrolyte as its separated aqueous ions (the complete ionic equation), then cancel the ions that appear unchanged on both sides — the spectator ions Na+ and NO3-. What remains, Ag+(aq) + Cl-(aq) -> AgCl(s), is the net ionic equation. On the AP exam you must produce balanced net ionic equations for precipitation, acid-base, and redox reactions and justify why a species is a spectator; charge and mass must both balance.", + "bullets": [ + "Only ions that change phase or bond stay: Ag+ and Cl- form solid AgCl.", + "Na+ and NO3- are spectators — present before and after, so they cancel.", + "The arrows show reacting ions converging to form the precipitate.", + "A correct net ionic equation balances both atoms and total charge." + ], + "params": [ + { + "name": "reveal", + "label": "remove spectators (0 = ionic, 1 = net)", + "min": 0, + "max": 1, + "step": 0.05, + "value": 0.6 + } + ], + "code": "H.background();\nH.text(\"Net ionic equation: AgNO3 + NaCl -> AgCl(s)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Drop spectator ions to reveal the net ionic equation\", 24, 52, { color: H.colors.sub, size: 13 });\nconst reveal = Math.max(0, Math.min(1, P.reveal));\nconst breathe = 0.5 + 0.5 * Math.sin(t * 1.6);\nconst cx = H.W / 2;\nconst ions = [\n { name: \"Ag+\", spec: false, col: H.colors.accent2 },\n { name: \"NO3-\", spec: true, col: H.colors.sub },\n { name: \"Na+\", spec: true, col: H.colors.sub },\n { name: \"Cl-\", spec: false, col: H.colors.good }\n];\nconst baseY = 150;\nconst slotW = 160;\nconst startX = cx - slotW * 1.5;\nfor (let i = 0; i < ions.length; i++) {\n const io = ions[i];\n const x = startX + i * slotW;\n const dy = io.spec ? reveal * 130 : 6 * Math.sin(t * 2 + i);\n const r = 27;\n H.circle(x, baseY + dy, r, { fill: io.col, stroke: H.colors.ink, width: 2 });\n H.text(io.name, x, baseY + dy, { color: H.colors.bg, size: 14, weight: 700, align: \"center\", baseline: \"middle\" });\n if (io.spec) H.text(\"spectator\", x, baseY + dy + 46, { color: H.colors.warn, size: 11, align: \"center\" });\n else H.text(\"reacts\", x, baseY - 46, { color: H.colors.good, size: 11, align: \"center\" });\n}\nconst py = 370;\nconst glow = 1 + 0.15 * breathe;\nH.rect(cx - 95, py - 36, 190, 72, { fill: H.colors.panel, stroke: H.colors.accent, width: 2, radius: 10 });\nH.text(\"AgCl (s)\", cx, py - 4, { color: H.colors.ink, size: 20 * glow, weight: 700, align: \"center\", baseline: \"middle\" });\nH.text(\"precipitate forms\", cx, py + 22, { color: H.colors.accent, size: 12, align: \"center\", baseline: \"middle\" });\nH.arrow(startX, baseY + 40, cx - 34, py - 42, { color: H.colors.accent2, width: 2 });\nH.arrow(startX + 3 * slotW, baseY + 40, cx + 34, py - 42, { color: H.colors.good, width: 2 });\nH.text(\"Net ionic: Ag+(aq) + Cl-(aq) -> AgCl(s)\", 24, 502, { color: H.colors.accent, size: 15, weight: 700 });\nH.text(\"spectators removed: \" + (reveal * 100).toFixed(0) + \"% (Na+ and NO3- stay in solution)\", 24, 526, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apchem-solubility-rules", + "area": "AP Chemistry", + "topic": "Precipitation & solubility", + "title": "Solubility rules: soluble (aq) vs precipitate (s)", + "equation": "ion pair -> soluble (aq) OR insoluble (s) precipitate", + "keywords": [ + "solubility rules", + "precipitation reaction", + "precipitate", + "soluble salt", + "insoluble", + "nitrates soluble", + "silver halide", + "sulfate", + "ap chemistry", + "double replacement" + ], + "explanation": "Solubility rules predict whether a combination of a cation and anion stays dissolved as aqueous ions or drops out as an ionic solid (a precipitate). Key rules tested on the AP exam: all Group 1 (Na+, K+), ammonium (NH4+), and nitrate (NO3-) salts are soluble; most chlorides/bromides/iodides are soluble EXCEPT with Ag+, Pb2+, Hg2 2+; most sulfates are soluble EXCEPT with Ba2+, Pb2+, Ca2+, Sr2+; most carbonates, phosphates, hydroxides, and sulfides are insoluble. Here the beaker shows free drifting ions when the pair is soluble, or particles settling to the bottom when a precipitate forms.", + "bullets": [ + "Soluble pairs (NaCl, KNO3) stay dissolved — ions drift freely in solution.", + "Ag+ halides (AgCl) and Ba2+ sulfate (BaSO4) are classic insoluble precipitates.", + "When insoluble, the solid settles to the bottom of the beaker over time.", + "The on-screen rule tells you WHY: nitrate always soluble, sulfides usually not." + ], + "params": [ + { + "name": "pair", + "label": "ion pair (index 0-7)", + "min": 0, + "max": 7, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nH.text(\"Solubility rules: soluble (aq) vs precipitate (s)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Slide to pick a cation-anion pair; rules decide if a solid drops out\", 24, 52, { color: H.colors.sub, size: 13 });\nconst pairs = [\n { f: \"NaCl\", sol: true, why: \"Group 1 salts always soluble\" },\n { f: \"KNO3\", sol: true, why: \"all nitrates soluble\" },\n { f: \"AgCl\", sol: false, why: \"Ag+ halides insoluble\" },\n { f: \"BaSO4\", sol: false, why: \"Ba2+ sulfate insoluble\" },\n { f: \"PbI2\", sol: false, why: \"Pb2+ halides insoluble\" },\n { f: \"CuS\", sol: false, why: \"most sulfides insoluble\" },\n { f: \"CaCl2\", sol: true, why: \"most chlorides soluble\" },\n { f: \"NH4Cl\", sol: true, why: \"ammonium salts soluble\" }\n];\nconst n = pairs.length;\nlet idx = Math.round(P.pair);\nif (!(idx >= 0)) idx = 0;\nif (idx > n - 1) idx = n - 1;\nconst cur = pairs[idx];\nconst cx = H.W / 2;\nconst bx = cx - 120, by = 110, bw = 240, bh = 300;\nH.rect(bx, by, bw, bh, { stroke: H.colors.grid, width: 3, radius: 8 });\nH.line(bx, by + 70, bx + bw, by + 70, { color: H.colors.accent, width: 1, dash: [5,5] });\nH.text(\"solution\", bx + bw - 8, by + 60, { color: H.colors.sub, size: 11, align: \"right\" });\nif (cur.sol) {\n for (let i = 0; i < 14; i++) {\n const ph = i * 0.9;\n const x = bx + 30 + ((i * 53) % (bw - 60)) + 14 * Math.sin(t * 1.2 + ph);\n const y = by + 95 + ((i * 71) % (bh - 130)) + 12 * Math.cos(t * 1.1 + ph);\n const pos = i % 2 === 0;\n H.circle(x, y, 7, { fill: pos ? H.colors.accent2 : H.colors.good });\n }\n H.text(cur.f + \" (aq)\", cx, by + bh + 34, { color: H.colors.good, size: 22, weight: 700, align: \"center\" });\n H.text(\"dissolves -> free ions\", cx, by + bh + 60, { color: H.colors.sub, size: 13, align: \"center\" });\n} else {\n const settle = Math.min(1, (t % 4) / 2.2);\n for (let i = 0; i < 24; i++) {\n const col = i % 6;\n const row = Math.floor(i / 6);\n const x = bx + 40 + col * 30 + (1 - settle) * 6 * Math.sin(t * 3 + i);\n const yTarget = by + bh - 16 - row * 16;\n const yFall = by + 100 + (yTarget - (by + 100)) * settle;\n H.circle(x, yFall, 8, { fill: H.colors.warn, stroke: H.colors.ink, width: 1 });\n }\n H.text(cur.f + \" (s)\", cx, by + bh + 34, { color: H.colors.warn, size: 22, weight: 700, align: \"center\" });\n H.text(\"precipitate settles out\", cx, by + bh + 60, { color: H.colors.sub, size: 13, align: \"center\" });\n}\nH.text(\"Rule: \" + cur.why, 24, 506, { color: H.colors.ink, size: 14, weight: 700 });\nH.text(\"pair \" + (idx + 1) + \" of \" + n + \": \" + cur.f + \" -> \" + (cur.sol ? \"SOLUBLE (aq)\" : \"INSOLUBLE (s)\"), 24, 530, { color: cur.sol ? H.colors.good : H.colors.warn, size: 13 });" + }, + { + "id": "apchem-ideal-gas", + "area": "AP Chemistry", + "topic": "Ideal gas law PV=nRT", + "title": "Ideal gas law: PV = nRT -> P = nRT/V", + "equation": "P = n*R*T / V, R = 0.0821 L*atm/(mol*K)", + "keywords": [ + "ideal gas law", + "pv=nrt", + "gas pressure", + "moles", + "kelvin temperature", + "volume", + "gas constant", + "piston", + "ap chemistry", + "kinetic molecular theory" + ], + "explanation": "The ideal gas law PV = nRT links the four state variables of a gas through the constant R = 0.0821 L*atm/(mol*K). Solved for pressure, P = nRT/V: pressure rises if you add moles (n) or raise the absolute (Kelvin) temperature, and falls if you let the gas expand to a larger volume V. The animation shows particles bouncing inside a piston — adding moles puts more particles in, raising T speeds them up, and increasing V drops the piston and lowers the gauge. On the AP exam you must use Kelvin (never Celsius), pick R to match your pressure/volume units, and reason qualitatively about how each variable responds when another is held fixed.", + "bullets": [ + "P = nRT/V: pressure is directly proportional to n and T, inversely to V.", + "Particle count tracks moles n; particle speed tracks sqrt(T) (faster = hotter).", + "Expanding the volume drops the piston and lowers the pressure gauge.", + "Temperature MUST be in Kelvin; R = 0.0821 L*atm/(mol*K) for these units." + ], + "params": [ + { + "name": "n", + "label": "moles n (mol)", + "min": 0.1, + "max": 5, + "step": 0.1, + "value": 1 + }, + { + "name": "T", + "label": "temperature T (K)", + "min": 100, + "max": 800, + "step": 10, + "value": 300 + }, + { + "name": "V", + "label": "volume V (L)", + "min": 0.5, + "max": 10, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nH.text(\"Ideal gas law: P = nRT / V\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Set n, T, V; pressure follows. Watch particles in a movable piston.\", 24, 52, { color: H.colors.sub, size: 13 });\nconst R = 0.08206;\nconst n = Math.max(0.1, P.n);\nconst T = Math.max(50, P.T);\nconst V = Math.max(0.5, P.V);\nconst Pres = (n * R * T) / V;\nconst cylX = H.W / 2 - 110, cylTop = 110, cylW = 220, cylMaxH = 320;\nconst Vmax = 10;\nconst colH = Math.max(40, Math.min(cylMaxH, (V / Vmax) * cylMaxH));\nconst gasTop = cylTop + (cylMaxH - colH);\nH.line(cylX, cylTop - 10, cylX, cylTop + cylMaxH, { color: H.colors.grid, width: 3 });\nH.line(cylX + cylW, cylTop - 10, cylX + cylW, cylTop + cylMaxH, { color: H.colors.grid, width: 3 });\nH.line(cylX, cylTop + cylMaxH, cylX + cylW, cylTop + cylMaxH, { color: H.colors.grid, width: 3 });\nH.rect(cylX - 6, gasTop - 16, cylW + 12, 16, { fill: H.colors.panel, stroke: H.colors.accent, width: 2, radius: 3 });\nH.text(\"piston\", cylX + cylW + 16, gasTop - 8, { color: H.colors.sub, size: 11, baseline: \"middle\" });\nconst np = Math.max(4, Math.min(40, Math.round(n * 14)));\nconst speed = 0.4 + Math.sqrt(T) / 18;\nfor (let i = 0; i < np; i++) {\n const ph = i * 1.7;\n const ax = (cylW - 30) / 2;\n const ay = (colH - 30) / 2;\n const x = cylX + 15 + ax + ax * Math.sin(t * speed + ph);\n const y = gasTop + 15 + ay + ay * Math.cos(t * speed * 1.13 + ph * 1.3);\n H.circle(x, y, 5, { fill: H.colors.accent2 });\n}\nconst gx = cylX + cylW + 70, gy = cylTop, gh = cylMaxH;\nH.rect(gx, gy, 22, gh, { stroke: H.colors.grid, width: 2, radius: 4 });\nconst pFrac = Math.max(0, Math.min(1, Pres / 50));\nH.rect(gx + 2, gy + gh - pFrac * (gh - 4), 18, pFrac * (gh - 4), { fill: H.colors.warn, radius: 3 });\nH.text(\"P\", gx + 11, gy - 10, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(\"P = nRT/V = \" + Pres.toFixed(2) + \" atm\", 24, 500, { color: H.colors.warn, size: 15, weight: 700 });\nH.text(\"n = \" + n.toFixed(2) + \" mol T = \" + T.toFixed(0) + \" K V = \" + V.toFixed(2) + \" L R = 0.0821 L*atm/mol*K\", 24, 526, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apchem-partial-pressures", + "area": "AP Chemistry", + "topic": "Dalton partial pressures", + "title": "Dalton's law: P_i = X_i * P_total", + "equation": "P_i = X_i * P_total, X_i = n_i / n_total, sum(P_i) = P_total", + "keywords": [ + "dalton's law", + "partial pressure", + "mole fraction", + "total pressure", + "gas mixture", + "p_total", + "x_i", + "ideal gas mixture", + "ap chemistry", + "collecting gas over water" + ], + "explanation": "Dalton's law states that in a mixture of non-reacting ideal gases each component exerts the pressure it would alone, and the total pressure is the sum of these partial pressures: P_total = sum(P_i). The partial pressure of gas i is its mole fraction times the total: P_i = X_i * P_total, where X_i = n_i / n_total and the mole fractions sum to 1. The stacked bar shows each gas's partial pressure summing exactly to P_total, while the mixture box shows particle counts proportional to each mole fraction. On the AP exam this appears in gas-collection-over-water problems (subtract the water vapor pressure) and in relating mole ratios to pressure ratios.", + "bullets": [ + "X_i = n_i / n_total; the three mole fractions always sum to 1.", + "P_i = X_i * P_total — the gas with the most moles gets the biggest slice.", + "The stacked partial-pressure bar adds up to exactly P_total.", + "Particle counts in the box are proportional to each gas's mole fraction." + ], + "params": [ + { + "name": "Ptot", + "label": "total pressure P_total (atm)", + "min": 0.5, + "max": 5, + "step": 0.1, + "value": 2 + }, + { + "name": "nA", + "label": "moles N2 n_A", + "min": 0, + "max": 6, + "step": 0.5, + "value": 3 + }, + { + "name": "nB", + "label": "moles O2 n_B", + "min": 0, + "max": 6, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nH.text(\"Dalton's law: P_i = X_i * P_total\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Each gas contributes pressure in proportion to its mole fraction\", 24, 52, { color: H.colors.sub, size: 13 });\nconst Ptot = Math.max(0.2, P.Ptot);\nlet a = Math.max(0, P.nA);\nlet b = Math.max(0, P.nB);\nlet c = 1;\nlet sum = a + b + c;\nif (sum <= 0) { a = 1; b = 1; c = 1; sum = 3; }\nconst gases = [\n { name: \"N2\", n: a, col: H.colors.accent },\n { name: \"O2\", n: b, col: H.colors.good },\n { name: \"Ar\", n: c, col: H.colors.violet }\n];\nconst bx = 60, by = 100, bw = 360, bh = 330;\nH.rect(bx, by, bw, bh, { stroke: H.colors.grid, width: 3, radius: 8 });\nH.text(\"gas mixture\", bx + 8, by + 16, { color: H.colors.sub, size: 11 });\nlet total = 0;\nfor (const g of gases) total += g.n;\nlet drawn = 0;\nfor (let gi = 0; gi < gases.length; gi++) {\n const g = gases[gi];\n const count = Math.max(1, Math.round((g.n / total) * 36));\n for (let i = 0; i < count; i++) {\n const ph = (drawn + i) * 1.3 + gi;\n const ax = (bw - 36) / 2, ay = (bh - 36) / 2;\n const x = bx + 18 + ax + ax * Math.sin(t * 1.1 + ph);\n const y = by + 18 + ay + ay * Math.cos(t * 1.27 + ph * 1.1);\n H.circle(x, y, 5, { fill: g.col });\n }\n drawn += count;\n}\nconst sx = 500, sy = 120, sw = 90, sh = 300;\nH.line(sx - 10, sy + sh, sx + sw + 60, sy + sh, { color: H.colors.grid, width: 2 });\nH.text(\"partial P (atm)\", sx, sy - 14, { color: H.colors.sub, size: 12 });\nlet yCursor = sy + sh;\nfor (let gi = 0; gi < gases.length; gi++) {\n const g = gases[gi];\n const X = g.n / total;\n const Pi = X * Ptot;\n const segH = (Pi / Math.max(0.001, Ptot)) * sh;\n const top = yCursor - segH;\n H.rect(sx, top, sw, segH, { fill: g.col, stroke: H.colors.ink, width: 1 });\n H.text(g.name + \": \" + Pi.toFixed(2), sx + sw + 8, (top + yCursor) / 2, { color: g.col, size: 12, baseline: \"middle\" });\n H.text(\"X=\" + X.toFixed(2), sx + sw / 2, top + segH / 2, { color: H.colors.bg, size: 11, align: \"center\", baseline: \"middle\", weight: 700 });\n yCursor = top;\n}\nH.text(\"P_total = \" + Ptot.toFixed(2) + \" atm\", 24, 500, { color: H.colors.ink, size: 15, weight: 700 });\nH.text(\"sum of X = 1.00 -> sum of P_i = P_total (Dalton)\", 24, 526, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apchem-grahams-law", + "area": "AP Chemistry", + "topic": "Graham law of effusion", + "title": "Graham's law: rate(1)/rate(2) = sqrt(M2/M1)", + "equation": "rate proportional to 1/sqrt(M); rate_A/rate_B = sqrt(M_B/M_A)", + "keywords": [ + "graham's law", + "effusion", + "diffusion", + "molar mass", + "rate of effusion", + "rms speed", + "kinetic molecular theory", + "lighter gas faster", + "ap chemistry", + "1 over sqrt M" + ], + "explanation": "Graham's law says the rate at which a gas effuses (escapes through a tiny pinhole) is inversely proportional to the square root of its molar mass: rate is proportional to 1/sqrt(M). Comparing two gases, rate_A/rate_B = sqrt(M_B/M_A). This follows from kinetic molecular theory: at the same temperature all gases have the same average kinetic energy, so a lighter molecule must move faster (v_rms proportional to 1/sqrt(M)) and reaches the hole more often. The animation races a test gas against a helium reference (M = 4); lower molar mass means faster jiggling and a faster escaping stream. On the AP exam you use this to find an unknown molar mass from a measured rate ratio.", + "bullets": [ + "rate is proportional to 1/sqrt(M): quadruple the mass, halve the effusion rate.", + "Lighter molecules (small M) jiggle faster and effuse out the pinhole quicker.", + "The ratio rate_test/rate_He = sqrt(4/M) is shown live as M changes.", + "Same temperature => same average KE, so speed scales as 1/sqrt(M)." + ], + "params": [ + { + "name": "M", + "label": "molar mass M (g/mol)", + "min": 2, + "max": 300, + "step": 2, + "value": 32 + } + ], + "code": "H.background();\nH.text(\"Graham's law of effusion: rate is proportional to 1 / sqrt(M)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Lighter gases effuse faster; rate ratio = sqrt(M_ref / M)\", 24, 52, { color: H.colors.sub, size: 13 });\nconst M = Math.max(2, P.M);\nconst Mref = 4;\nconst rTest = 1 / Math.sqrt(M);\nconst rRef = 1 / Math.sqrt(Mref);\nconst ratio = Math.sqrt(Mref / M);\nfunction chamber(cy, label, rate, col) {\n const bx = 70, bw = 230, bh = 150;\n const by = cy - bh / 2;\n H.rect(bx, by, bw, bh, { stroke: H.colors.grid, width: 3, radius: 8 });\n H.line(bx + bw, by + bh / 2 - 10, bx + bw, by + bh / 2 + 10, { color: H.colors.bg, width: 6 });\n H.text(label, bx + 8, by + 18, { color: col, size: 13, weight: 700 });\n for (let i = 0; i < 10; i++) {\n const ph = i * 1.4;\n const x = bx + 25 + ((i * 41) % (bw - 50)) + 8 * Math.sin(t * (2 + rate * 30) + ph);\n const y = by + 25 + ((i * 53) % (bh - 50)) + 8 * Math.cos(t * (2 + rate * 30) + ph);\n H.circle(x, y, 6, { fill: col });\n }\n const span = 260;\n for (let kk = 0; kk < 5; kk++) {\n const prog = ((t * rate * 22 + kk * 0.2) % 1);\n const ex = bx + bw + prog * span;\n const ey = cy + 6 * Math.sin(prog * 6 + kk);\n H.circle(ex, ey, 5, { fill: col });\n }\n}\nchamber(150, \"Test gas M = \" + M.toFixed(0) + \" g/mol\", rTest, H.colors.accent2);\nchamber(360, \"He reference M = 4 g/mol\", rRef, H.colors.good);\nH.arrow(320, 150, 420, 150, { color: H.colors.sub, width: 1 });\nH.arrow(320, 360, 420, 360, { color: H.colors.sub, width: 1 });\nH.text(\"effuses\", 430, 150, { color: H.colors.sub, size: 11, baseline: \"middle\" });\nH.text(\"effuses\", 430, 360, { color: H.colors.sub, size: 11, baseline: \"middle\" });\nH.text(\"rate_test / rate_He = sqrt(4 / \" + M.toFixed(0) + \") = \" + ratio.toFixed(3), 24, 502, { color: H.colors.accent2, size: 15, weight: 700 });\nH.text(ratio < 1 ? \"heavier -> effuses slower than He\" : (ratio > 1 ? \"lighter -> effuses faster than He\" : \"same rate as He\"), 24, 526, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apchem-kmt", + "area": "AP Chemistry", + "topic": "Kinetic molecular theory", + "title": "Maxwell-Boltzmann distribution: f(v) ~ v^2 exp(-Mv^2 / 2RT)", + "equation": "f(v) = 4*pi*(M/(2*pi*R*T))^1.5 * v^2 * exp(-M*v^2/(2*R*T))", + "keywords": [ + "kinetic molecular theory", + "kmt", + "maxwell boltzmann", + "speed distribution", + "molecular speed", + "most probable speed", + "rms speed", + "temperature", + "average kinetic energy", + "ap chemistry", + "ideal gas" + ], + "explanation": "Kinetic molecular theory models a gas as many tiny particles in constant random motion whose AVERAGE kinetic energy is set only by absolute temperature (KE_avg = (3/2)RT per mole). The Maxwell-Boltzmann curve gives the fraction of molecules at each speed: it rises as v^2, peaks at the most-probable speed v_mp = sqrt(2RT/M), then falls off exponentially. Raising T shifts the whole curve to higher speeds and flattens/broadens it (the area stays fixed at 1), which is why reaction rates climb with temperature. On the AP exam you must rank v_mp < v_avg < v_rms, know that lighter gases (smaller M) sit farther right, and read a higher, broader curve as a higher temperature.", + "bullets": [ + "Curve rises as v^2 then decays exponentially; peak = most-probable speed v_mp.", + "Higher T shifts the peak right and broadens/flattens the curve.", + "Speed ordering is always v_mp < v_avg < v_rms (all marked with values).", + "Total area (probability) stays 1 — broadening means lowering the peak." + ], + "params": [ + { + "name": "T", + "label": "temperature T (K)", + "min": 100, + "max": 1200, + "step": 25, + "value": 300 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 2500, yMin: 0, yMax: 0.0022 });\nv.grid(); v.axes();\nconst T = P.T;\nconst M = 0.032;\nconst R = 8.314;\nconst a = M / (2 * R * T);\nconst f = s => 4 * Math.PI * Math.pow(a / Math.PI, 1.5) * s * s * Math.exp(-a * s * s);\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst vmp = Math.sqrt(2 * R * T / M);\nconst vrms = Math.sqrt(3 * R * T / M);\nconst vavg = Math.sqrt(8 * R * T / (Math.PI * M));\nv.line(vmp, 0, vmp, f(vmp), { color: H.colors.warn, width: 2, dash: [5, 4] });\nv.dot(vmp, f(vmp), { r: 6, fill: H.colors.warn });\nconst probe = vmp * (0.4 + 1.3 * (0.5 + 0.5 * Math.sin(t * 0.7)));\nv.dot(probe, f(probe), { r: 6, fill: H.colors.accent2 });\nH.text(\"Maxwell-Boltzmann: f(v) ~ v^2 exp(-Mv^2 / 2RT)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"O2 gas at T = \" + T.toFixed(0) + \" K (higher T -> broader, flatter, shifted right)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"v_mp = \" + vmp.toFixed(0) + \" m/s\", 24, 80, { color: H.colors.warn, size: 13 });\nH.text(\"v_avg = \" + vavg.toFixed(0) + \" m/s\", 24, 100, { color: H.colors.sub, size: 13 });\nH.text(\"v_rms = \" + vrms.toFixed(0) + \" m/s\", 24, 120, { color: H.colors.accent, size: 13 });\nH.text(\"probe v = \" + probe.toFixed(0) + \" m/s\", 24, 140, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apchem-real-gases", + "area": "AP Chemistry", + "topic": "Real gases (deviation)", + "title": "Real-gas compressibility: Z = PV / nRT (van der Waals)", + "equation": "Z = PV/(nRT); (P + a*n^2/V^2)(V - n*b) = nRT", + "keywords": [ + "real gas", + "ideal gas deviation", + "van der waals", + "compressibility factor", + "z factor", + "intermolecular forces", + "molecular volume", + "high pressure", + "low temperature", + "ap chemistry", + "non ideal gas", + "pv=nrt" + ], + "explanation": "The ideal gas law PV = nRT assumes point particles with no intermolecular forces, so the compressibility factor Z = PV/nRT equals 1 for an ideal gas. Real gases deviate: the van der Waals 'a' term (attractions) pulls Z below 1 at moderate pressure, while the 'b' term (finite molecular volume) pushes Z above 1 at very high pressure. Deviations are largest at HIGH pressure and LOW temperature, where molecules are forced close together and move slowly enough for attractions to matter. On the AP exam, expect to predict whether a gas behaves more ideally (raise T, lower P) or less ideally, and to explain Z<1 vs Z>1 using attractions vs particle volume.", + "bullets": [ + "Z = 1 (dashed line) is the ideal-gas baseline; the curve is real CO2.", + "Attractions (a) dip Z below 1 at moderate P; particle volume (b) lifts Z above 1.", + "Deviation grows with pressure — the moving dot tracks Z up the curve.", + "Lowering T (slider) deepens the dip: cold, crowded gas is least ideal." + ], + "params": [ + { + "name": "P", + "label": "pressure P (atm)", + "min": 1, + "max": 300, + "step": 5, + "value": 100 + }, + { + "name": "T", + "label": "temperature T (K)", + "min": 220, + "max": 500, + "step": 10, + "value": 300 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 300, yMin: 0.4, yMax: 1.6 });\nv.grid(); v.axes();\nconst Pset = P.P;\nconst Tset = P.T;\nconst a = 1.36;\nconst b = 0.0318;\nconst R = 0.08206;\nv.fn(x => 1, { color: H.colors.grid, width: 1.5, dash: [6, 5] });\nconst Zfn = (Pr, Tk) => {\n let Vm = R * Tk / Pr + b;\n for (let i = 0; i < 40; i++) {\n const denom = Pr + a / (Vm * Vm);\n if (denom <= 0) break;\n Vm = R * Tk / denom + b;\n }\n return Pr * Vm / (R * Tk);\n};\nv.fn(x => Zfn(Math.max(x, 0.5), Tset), { color: H.colors.accent, width: 3 });\nconst Znow = Zfn(Math.max(Pset, 0.5), Tset);\nv.dot(Pset, Znow, { r: 7, fill: H.colors.accent2 });\nconst sweepP = 20 + 130 * (1 + Math.sin(t * 0.6));\nv.dot(sweepP, Zfn(Math.max(sweepP, 0.5), Tset), { r: 5, fill: H.colors.warn });\nH.text(\"Real gas compressibility: Z = PV / nRT\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"CO2 (van der Waals). Ideal gas: Z = 1. High P / low T -> deviation\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"P = \" + Pset.toFixed(0) + \" atm, T = \" + Tset.toFixed(0) + \" K\", 24, 80, { color: H.colors.sub, size: 13 });\nH.text(\"Z = \" + Znow.toFixed(3) + (Znow < 1 ? \" (attractions dominate, Z<1)\" : \" (volume dominates, Z>1)\"), 24, 100, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apchem-gas-stoichiometry", + "area": "AP Chemistry", + "topic": "Gas stoichiometry (molar volume)", + "title": "Molar volume at STP: V = n x 22.4 L/mol", + "equation": "V = n * 22.4 L/mol (at STP, 0 C and 1 atm)", + "keywords": [ + "gas stoichiometry", + "molar volume", + "22.4", + "stp", + "standard temperature pressure", + "moles of gas", + "avogadro law", + "ideal gas", + "liters per mole", + "ap chemistry", + "gas volume", + "mole ratio" + ], + "explanation": "At standard temperature and pressure (STP: 0 C = 273.15 K and 1 atm), one mole of ANY ideal gas occupies the same molar volume, 22.4 L/mol, a direct consequence of Avogadro's law that equal volumes contain equal numbers of molecules. So the volume of a gas at STP is simply V = n x 22.4 L, a straight line through the origin. In gas stoichiometry you first use the balanced equation's mole ratio to find moles of gas, then multiply by 22.4 L/mol for the volume at STP. On the AP exam, remember 22.4 L/mol applies only at STP — away from STP you must use PV = nRT instead.", + "bullets": [ + "V vs n is a straight line through the origin with slope 22.4 L/mol.", + "Holds for any ideal gas at STP (0 C, 1 atm) — Avogadro's law.", + "The shaded box height = computed volume V for the chosen moles n.", + "Each mole = 6.022 x 10^23 molecules (shown in the live readout)." + ], + "params": [ + { + "name": "n", + "label": "amount n (mol)", + "min": 0, + "max": 5, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\nconst n = P.n;\nconst Vm = 22.4;\nconst V = n * Vm;\nconst v = H.plot2d({ xMin: 0, xMax: 5, yMin: 0, yMax: 112 });\nv.grid(); v.axes();\nv.fn(x => x * Vm, { color: H.colors.accent, width: 3 });\nv.rect(0, 0, Math.max(n, 0.001), V, { fill: \"rgba(96,165,250,0.18)\", stroke: H.colors.accent, width: 2 });\nv.dot(n, V, { r: 7, fill: H.colors.accent2 });\nconst pulse = 0.5 + 0.5 * Math.sin(t * 1.4);\nv.circle(n, V, 8 + 6 * pulse, { stroke: H.colors.warn, width: 2 });\nH.text(\"Molar volume at STP: V = n x 22.4 L/mol\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"At STP (0 C, 1 atm) one mole of ANY ideal gas occupies 22.4 L\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"n = \" + n.toFixed(2) + \" mol\", 24, 80, { color: H.colors.sub, size: 13 });\nH.text(\"V = \" + V.toFixed(1) + \" L\", 24, 100, { color: H.colors.accent2, size: 14, weight: 700 });\nH.text(\"molecules ~ \" + (n * 6.022).toFixed(2) + \" x 10^23\", 24, 122, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apchem-enthalpy-reaction", + "area": "AP Chemistry", + "topic": "Enthalpy of reaction", + "title": "Reaction energy diagram: dH = H_products - H_reactants", + "equation": "dH = H_products - H_reactants; dH<0 exothermic, dH>0 endothermic", + "keywords": [ + "enthalpy", + "enthalpy of reaction", + "delta h", + "exothermic", + "endothermic", + "energy diagram", + "reaction coordinate", + "activation energy", + "heat of reaction", + "ap chemistry", + "thermochemistry", + "potential energy diagram" + ], + "explanation": "Enthalpy change dH = H_products - H_reactants is the heat absorbed or released by a reaction at constant pressure. If products sit LOWER than reactants, energy is released to the surroundings and dH < 0 (exothermic); if products sit HIGHER, energy is absorbed and dH > 0 (endothermic). The hump in the reaction-coordinate diagram is the activation energy Ea, the minimum energy needed to reach the transition state; Ea controls rate but does NOT change dH. On the AP exam you read sign and magnitude of dH straight off such a diagram and recall that dH depends only on initial and final states (Hess's law), not on the path or the barrier height.", + "bullets": [ + "dH is the vertical gap from reactants to products (arrow + live value).", + "Products below reactants = exothermic (dH<0); above = endothermic (dH>0).", + "The peak is the activation energy Ea — it sets the rate, not dH.", + "The orange marker traces the path; energy is a state function (Hess's law)." + ], + "params": [ + { + "name": "dH", + "label": "enthalpy drop -dH (kJ): + exo, - endo", + "min": -160, + "max": 220, + "step": 10, + "value": 120 + } + ], + "code": "H.background();\nconst W = H.W, Ht = H.H;\nconst x0 = 120, x1 = 780;\nconst yBase = 380;\nconst Ea = 140;\nconst dH = P.dH;\nconst Ereact = 0;\nconst Eprod = -dH;\nconst yOf = e => yBase - e;\nconst exo = dH > 0;\nH.text(\"Reaction energy diagram: dH = H_products - H_reactants\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(exo ? \"Exothermic: releases heat, dH < 0, products LOWER\" : (dH < 0 ? \"Endothermic: absorbs heat, dH > 0, products HIGHER\" : \"Thermoneutral: dH = 0\"), 24, 52, { color: H.colors.sub, size: 13 });\nconst xPeak = (x0 + x1) / 2;\nconst yPeak = yOf(Ea);\nconst pts = [];\nfor (let i = 0; i <= 60; i++) {\n const f = i / 60;\n const xx = x0 + (x1 - x0) * f;\n let yy;\n if (f < 0.5) {\n const g = f / 0.5;\n const e = Ereact + (Ea - Ereact) * (g * g * (3 - 2 * g));\n yy = yOf(e);\n } else {\n const g = (f - 0.5) / 0.5;\n const e = Ea + (Eprod - Ea) * (g * g * (3 - 2 * g));\n yy = yOf(e);\n }\n pts.push([xx, yy]);\n}\nH.path(pts, { color: H.colors.accent, width: 3 });\nH.line(x0, yOf(Ereact), x0 + 70, yOf(Ereact), { color: H.colors.sub, width: 2 });\nH.line(x1 - 70, yOf(Eprod), x1, yOf(Eprod), { color: H.colors.sub, width: 2 });\nH.text(\"reactants\", x0, yOf(Ereact) - 10, { color: H.colors.sub, size: 12 });\nH.text(\"products\", x1 - 70, yOf(Eprod) + (Eprod < Ereact ? 18 : -10), { color: H.colors.sub, size: 12 });\nH.line(x0 - 30, yBase, x0 - 30, yBase - 320, { color: H.colors.axis, width: 2 });\nH.text(\"Energy\", x0 - 60, yBase - 330, { color: H.colors.sub, size: 12 });\nconst arrowCol = exo ? H.colors.good : H.colors.warn;\nH.arrow(x1 - 35, yOf(Ereact), x1 - 35, yOf(Eprod), { color: arrowCol, width: 2 });\nH.text(\"dH = \" + (Eprod - Ereact).toFixed(0) + \" kJ\", x1 - 30, (yOf(Ereact) + yOf(Eprod)) / 2, { color: arrowCol, size: 13, weight: 700 });\nconst prog = 0.5 + 0.5 * Math.sin(t * 0.8);\nconst idx = Math.min(60, Math.floor(prog * 60));\nH.circle(pts[idx][0], pts[idx][1], 8, { fill: H.colors.accent2 });\nH.text(\"Ea(fwd) = \" + Ea + \" kJ\", xPeak - 50, yPeak - 16, { color: H.colors.violet, size: 12 });" + }, + { + "id": "apchem-calorimetry", + "area": "AP Chemistry", + "topic": "Calorimetry q=mcΔT", + "title": "Calorimetry: q = m c dT", + "equation": "q = m * c * dT (c_water = 4.18 J/(g*K))", + "keywords": [ + "calorimetry", + "q=mcdeltat", + "specific heat", + "heat capacity", + "heat transfer", + "temperature change", + "joules", + "q = mc delta t", + "ap chemistry", + "thermochemistry", + "coffee cup calorimeter", + "water" + ], + "explanation": "Calorimetry measures heat flow q from the temperature change of a known mass: q = m*c*dT, where c is the specific heat capacity (4.18 J/(g*K) for liquid water) and dT = T_final - T_initial. A positive q (dT>0) means the substance absorbed heat; a negative q (dT<0) means it released heat. In a coffee-cup calorimeter, heat released by a reaction is gained by the water, so q_reaction = -q_water, the basis for finding enthalpies experimentally. On the AP exam you must track signs carefully and use the right c, watching units (J vs kJ, g vs kg).", + "bullets": [ + "q scales with BOTH mass m and temperature change dT (and with c).", + "Sign of q follows dT: warming absorbs heat (q>0), cooling releases (q<0).", + "The curve is T(t) relaxing toward T_final; the dot rides it as it animates.", + "Live readout converts q from J to kJ — watch units on the AP exam." + ], + "params": [ + { + "name": "m", + "label": "mass m (g)", + "min": 10, + "max": 500, + "step": 10, + "value": 100 + }, + { + "name": "dT", + "label": "temp change dT (K)", + "min": -40, + "max": 60, + "step": 1, + "value": 25 + } + ], + "code": "H.background();\nconst m = P.m;\nconst dT = P.dT;\nconst c = 4.18;\nconst q = m * c * dT;\nconst v = H.plot2d({ xMin: 0, xMax: 60, yMin: 0, yMax: 100 });\nv.grid(); v.axes();\nconst Ti = 20;\nconst heating = dT >= 0;\nconst tau = 18;\nconst Tfinal = Ti + dT;\nconst Tof = tt => Ti + dT * (1 - Math.exp(-tt / tau));\nv.fn(x => Tof(x), { color: heating ? H.colors.warn : H.colors.accent, width: 3 });\nv.line(0, Ti, 60, Ti, { color: H.colors.grid, width: 1.5, dash: [5, 4] });\nv.line(0, Tfinal, 60, Tfinal, { color: H.colors.sub, width: 1.5, dash: [5, 4] });\nconst tt = (t * 6) % 60;\nv.dot(tt, Tof(tt), { r: 7, fill: H.colors.accent2 });\nH.text(\"Calorimetry: q = m c dT\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Water, c = 4.18 J/(g K). q>0 absorbs heat, q<0 releases heat\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"m = \" + m.toFixed(0) + \" g, dT = \" + dT.toFixed(0) + \" K, c = 4.18 J/(g K)\", 24, 80, { color: H.colors.sub, size: 13 });\nH.text(\"q = \" + q.toFixed(0) + \" J = \" + (q / 1000).toFixed(2) + \" kJ\" + (heating ? \" (absorbed)\" : \" (released)\"), 24, 102, { color: H.colors.accent2, size: 14, weight: 700 });" + }, + { + "id": "apchem-hess-law", + "area": "AP Chemistry", + "topic": "Hess law", + "title": "Hess's law: ΔH_total = ΔH1 + ΔH2", + "equation": "ΔH_total = ΔH1 + ΔH2", + "keywords": [ + "hess law", + "hess's law", + "enthalpy", + "delta h", + "state function", + "reaction enthalpy", + "energy diagram", + "thermochemistry", + "ap chemistry", + "ap chem", + "summing enthalpies", + "reaction coordinate" + ], + "explanation": "Hess's law follows from enthalpy being a STATE FUNCTION: ΔH for an overall reaction depends only on the initial reactants and final products, not the path taken, so the enthalpies of any sequence of steps add to the same total. The diagram tracks energy along a reaction coordinate from reactants (0) up or down through an intermediate by ΔH1, then to products by ΔH2; the products' level above the reactants IS ΔH_total = ΔH1 + ΔH2. On the AP exam you apply this by adding/reversing/scaling given thermochemical equations (reversing flips the sign of ΔH, scaling multiplies it) so they sum to the target reaction. A negative total means the products sit below the reactants (exothermic); positive means above (endothermic).", + "bullets": [ + "Each plateau is an energy level; the moving dot rides the path R → I → P.", + "ΔH adds step-by-step because enthalpy is path-independent (a state function).", + "Reversing a step flips the sign of its ΔH; scaling multiplies it.", + "Products below reactants ⇒ ΔH_total < 0 (exothermic); above ⇒ endothermic." + ], + "params": [ + { + "name": "dH1", + "label": "ΔH₁ step 1 (kJ)", + "min": -200, + "max": 200, + "step": 10, + "value": -120 + }, + { + "name": "dH2", + "label": "ΔH₂ step 2 (kJ)", + "min": -200, + "max": 200, + "step": 10, + "value": -80 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: -260, yMax: 120 });\nv.grid(); v.axes();\nconst d1 = P.dH1, d2 = P.dH2;\nconst total = d1 + d2;\nconst yR = 0;\nconst yI = d1;\nconst yP = d1 + d2;\nconst xR = 1.5, xI = 5, xP = 8.5;\nv.line(xR - 0.6, yR, xR + 0.6, yR, { color: H.colors.ink, width: 4 });\nv.line(xI - 0.6, yI, xI + 0.6, yI, { color: H.colors.violet, width: 4 });\nv.line(xP - 0.6, yP, xP + 0.6, yP, { color: H.colors.good, width: 4 });\nv.line(xR + 0.6, yR, xI - 0.6, yI, { color: H.colors.accent, width: 2, dash: [5,4] });\nv.line(xI + 0.6, yI, xP - 0.6, yP, { color: H.colors.accent2, width: 2, dash: [5,4] });\nconst phase = (t * 0.5) % 2;\nlet mx, my;\nif (phase < 1) { mx = xR + (xI - xR) * phase; my = yR + (yI - yR) * phase; }\nelse { const u = phase - 1; mx = xI + (xP - xI) * u; my = yI + (yP - yI) * u; }\nv.dot(mx, my, { r: 7, fill: H.colors.yellow });\nv.text(\"Reactants\", xR, yR + 18);\nv.text(\"Intermediate\", xI, yI + (yI >= 0 ? 18 : -10));\nv.text(\"Products\", xP, yP + (yP >= 0 ? 18 : -10));\nH.text(\"Hess's law: ΔH_total = ΔH1 + ΔH2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Enthalpy is a state function — path-independent\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"ΔH1 = \" + d1.toFixed(0) + \" kJ\", 24, 80, { color: H.colors.violet, size: 13 });\nH.text(\"ΔH2 = \" + d2.toFixed(0) + \" kJ\", 24, 98, { color: H.colors.accent2, size: 13 });\nH.text(\"ΔH_total = \" + total.toFixed(0) + \" kJ\", 24, 118, { color: H.colors.good, size: 14, weight: 700 });" + }, + { + "id": "apchem-bond-enthalpy", + "area": "AP Chemistry", + "topic": "Enthalpy from bond energies", + "title": "Bond enthalpy: ΔH = Σ(bonds broken) − Σ(bonds formed)", + "equation": "ΔH = Σ(bonds broken) − Σ(bonds formed)", + "keywords": [ + "bond enthalpy", + "bond energy", + "enthalpy from bonds", + "bonds broken", + "bonds formed", + "delta h", + "exothermic", + "endothermic", + "thermochemistry", + "ap chemistry", + "ap chem", + "average bond energy" + ], + "explanation": "Estimating ΔH from bond energies uses the rule ΔH = Σ(bond energies of bonds BROKEN in reactants) − Σ(bond energies of bonds FORMED in products). Breaking bonds always REQUIRES energy (positive contribution) and forming bonds always RELEASES energy, so the sign convention follows directly. If more energy is released forming product bonds than is spent breaking reactant bonds, ΔH < 0 and the reaction is exothermic; the reverse gives an endothermic reaction. On the AP exam bond energies are average values, so this gives an estimate of ΔH; the animation contrasts the two summed quantities as bars and reports the signed difference.", + "bullets": [ + "Left bar = total energy to BREAK reactant bonds (positive, energy in).", + "Right bar = total energy RELEASED forming product bonds (energy out).", + "ΔH = broken − formed; the flowing packet shows net energy exchange.", + "broken > formed ⇒ ΔH > 0 (endothermic); broken < formed ⇒ exothermic." + ], + "params": [ + { + "name": "broken", + "label": "Σ bonds broken (kJ)", + "min": 200, + "max": 2000, + "step": 50, + "value": 1200 + }, + { + "name": "formed", + "label": "Σ bonds formed (kJ)", + "min": 200, + "max": 2000, + "step": 50, + "value": 1400 + } + ], + "code": "H.background();\nconst broken = P.broken;\nconst formed = P.formed;\nconst dH = broken - formed;\nconst W = H.W, Ht = H.H;\nconst baseY = Ht - 90;\nconst x1 = 220, x2 = 520, bw = 110;\nconst maxE = Math.max(broken, formed, 1);\nconst scale = 300 / maxE;\nconst hB = broken * scale;\nconst hF = formed * scale;\nH.rect(x1, baseY - hB, bw, hB, { fill: H.colors.warn, radius: 6 });\nH.rect(x2, baseY - hF, bw, hF, { fill: H.colors.good, radius: 6 });\nH.line(120, baseY, W - 120, baseY, { color: H.colors.axis, width: 2 });\nH.text(\"bonds BROKEN (in)\", x1 + bw/2, baseY + 22, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(\"bonds FORMED (out)\", x2 + bw/2, baseY + 22, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(broken.toFixed(0) + \" kJ\", x1 + bw/2, baseY - hB - 10, { color: H.colors.ink, size: 13, align: \"center\" });\nH.text(formed.toFixed(0) + \" kJ\", x2 + bw/2, baseY - hF - 10, { color: H.colors.ink, size: 13, align: \"center\" });\nconst swing = 0.5 + 0.5 * Math.sin(t * 1.2);\nconst px = (x1 + bw) + ((x2) - (x1 + bw)) * swing;\nconst py = baseY - 150 + 20 * Math.sin(t * 2);\nH.circle(px, py, 8, { fill: H.colors.yellow });\nconst exo = dH < 0;\nH.text(\"Bond enthalpy: ΔH = Σ(broken) − Σ(formed)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Breaking bonds costs energy (+); forming bonds releases it (−)\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"ΔH = \" + broken.toFixed(0) + \" − \" + formed.toFixed(0) + \" = \" + dH.toFixed(0) + \" kJ\", 24, 82, { color: exo ? H.colors.good : H.colors.warn, size: 15, weight: 700 });\nH.text(exo ? \"ΔH < 0 → EXOTHERMIC\" : \"ΔH > 0 → ENDOTHERMIC\", 24, 104, { color: exo ? H.colors.good : H.colors.warn, size: 13 });" + }, + { + "id": "apchem-heating-curve", + "area": "AP Chemistry", + "topic": "Heating/cooling curve", + "title": "Heating curve: q = m·c·ΔT (slopes), q = m·L (plateaus)", + "equation": "q = m*c*ΔT (warming) ; q = m*L (phase change, T constant)", + "keywords": [ + "heating curve", + "cooling curve", + "phase change", + "latent heat", + "heat of fusion", + "heat of vaporization", + "specific heat", + "melting", + "boiling", + "plateau", + "thermochemistry", + "ap chemistry", + "ap chem", + "q = mc delta t" + ], + "explanation": "A heating curve plots temperature versus heat added. On the SLOPED segments a single phase warms and q = m·c·ΔT, so temperature rises (the slope's steepness reflects 1/(m·c)). On the FLAT plateaus the substance changes phase at constant temperature — added heat goes into latent heat q = m·L (fusion at the melting point, vaporization at the boiling point) breaking intermolecular forces rather than raising T. For water the vaporization plateau is much longer than the fusion plateau because the heat of vaporization is far larger. AP questions test reading which segment is which, why temperature stays constant during a phase change, and computing total q by summing the q·=·mcΔT and q·=·mL pieces.", + "bullets": [ + "Sloped parts: one phase warms, q = m·c·ΔT, temperature climbs.", + "Flat parts: phase change at constant T, q = m·L (latent heat).", + "Boiling plateau ≫ melting plateau because Lvap ≫ Lfus for water.", + "The yellow probe and phase label show where the energy is going." + ], + "params": [ + { + "name": "heat", + "label": "start heat position (a.u.)", + "min": 0, + "max": 100, + "step": 5, + "value": 20 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 100, yMin: -40, yMax: 140 });\nv.grid(); v.axes();\nconst q = P.heat;\nconst s1 = 12;\nconst s2 = 26;\nconst s3 = 22;\nconst s4 = 60;\nconst s5 = 12;\nconst tempAt = (x) => {\n let h = x, T;\n if (h <= s1) T = -40 + (40 / s1) * h;\n else if (h <= s1 + s2) T = 0;\n else if (h <= s1 + s2 + s3) T = (100 / s3) * (h - s1 - s2);\n else if (h <= s1 + s2 + s3 + s4) T = 100;\n else T = 100 + (40 / s5) * (h - s1 - s2 - s3 - s4);\n return T;\n};\nconst pts = [];\nfor (let i = 0; i <= 120; i++) { const x = i * (132 / 120); pts.push([x, tempAt(x)]); }\nv.path(pts, { color: H.colors.accent, width: 3 });\nv.line(0, 0, 100, 0, { color: H.colors.violet, width: 1, dash: [4,4] });\nv.line(0, 100, 100, 100, { color: H.colors.warn, width: 1, dash: [4,4] });\nv.text(\"melt 0°C\", 2, 8);\nv.text(\"boil 100°C\", 2, 108);\nconst x = ((q * 0.5 + t * 8) % 132);\nconst T = tempAt(x);\nv.dot(x, T, { r: 7, fill: H.colors.yellow });\nlet phase;\nif (x <= s1) phase = \"solid (ice) warming\";\nelse if (x <= s1 + s2) phase = \"MELTING (latent heat)\";\nelse if (x <= s1 + s2 + s3) phase = \"liquid water warming\";\nelse if (x <= s1 + s2 + s3 + s4) phase = \"BOILING (latent heat)\";\nelse phase = \"steam warming\";\nH.text(\"Heating curve: q = m·c·ΔT (slopes) and q = m·L (plateaus)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"On plateaus, heat drives phase change at constant T\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"T = \" + T.toFixed(0) + \" °C\", 24, 80, { color: H.colors.yellow, size: 14, weight: 700 });\nH.text(phase, 24, 100, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apchem-molarity-dilution", + "area": "AP Chemistry", + "topic": "Molarity & dilution", + "title": "Molarity & dilution: M = n/V, M1V1 = M2V2", + "equation": "M = n/V ; M1*V1 = M2*V2", + "keywords": [ + "molarity", + "dilution", + "concentration", + "moles per liter", + "m1v1 m2v2", + "n over v", + "solution", + "solute", + "solvent", + "stoichiometry", + "ap chemistry", + "ap chem", + "mol/L" + ], + "explanation": "Molarity is concentration in moles of solute per liter of solution, M = n/V. Diluting a solution adds solvent, which increases V while the MOLES OF SOLUTE stay constant — so the concentration drops. Setting moles before = moles after gives the dilution equation M₁V₁ = M₂V₂, valid for any consistent volume units. The animation shows a concentrated beaker (V₁) emptied into a larger volume (V₂ = 2.00 L): particle density and tint drop as M falls from M₁ to M₂, while the solute count (moles) is conserved. AP problems use M = n/V to find concentration and M₁V₁ = M₂V₂ to find how much stock or solvent is needed to reach a target molarity.", + "bullets": [ + "M = n/V: more solute (n) raises M, more solution volume (V) lowers it.", + "Dilution adds solvent — V grows, M shrinks, moles of solute unchanged.", + "M₁V₁ = M₂V₂ comes straight from 'moles before = moles after'.", + "Bobbing particle count tracks concentration; tint fades on dilution." + ], + "params": [ + { + "name": "moles", + "label": "moles solute n (mol)", + "min": 0.1, + "max": 4, + "step": 0.1, + "value": 1.5 + }, + { + "name": "volume", + "label": "solution volume V₁ (L)", + "min": 0.25, + "max": 4, + "step": 0.25, + "value": 1 + } + ], + "code": "H.background();\nconst n = P.moles;\nconst Vol = P.volume;\nconst Vsafe = Math.max(Vol, 0.001);\nconst M1 = n / Vsafe;\nconst V1 = Vsafe;\nconst V2 = 2.0;\nconst M2 = (M1 * V1) / V2;\nconst W = H.W, Ht = H.H;\nfunction beaker(cx, by, w, h, conc, label, volStr, mStr) {\n H.rect(cx - w/2, by - h, w, h, { stroke: H.colors.axis, width: 2, radius: 4 });\n const cClamped = Math.min(conc, 4) / 4;\n const fillH = h * 0.8;\n H.rect(cx - w/2 + 3, by - fillH, w - 6, fillH - 3, { fill: H.hsl(210, 80, 35 + 30 * cClamped, 0.55) });\n const cnt = Math.max(2, Math.min(14, Math.round(conc * 4)));\n for (let i = 0; i < cnt; i++) {\n const px = cx - w/2 + 10 + ((i * 53) % (w - 20));\n const py = by - 12 - ((i * 37) % (fillH - 24)) + 4 * Math.sin(t * 2 + i);\n H.circle(px, py, 3.5, { fill: H.colors.yellow });\n }\n H.text(label, cx, by + 20, { color: H.colors.ink, size: 13, align: \"center\", weight: 700 });\n H.text(volStr, cx, by + 38, { color: H.colors.sub, size: 12, align: \"center\" });\n H.text(mStr, cx, by - h - 12, { color: H.colors.accent, size: 13, align: \"center\", weight: 700 });\n}\nconst by = Ht - 110;\nbeaker(260, by, 130, 200, M1, \"Concentrated\", \"V₁ = \" + V1.toFixed(2) + \" L\", \"M₁ = \" + M1.toFixed(2) + \" M\");\nbeaker(620, by, 180, 200, M2, \"Diluted\", \"V₂ = \" + V2.toFixed(2) + \" L\", \"M₂ = \" + M2.toFixed(2) + \" M\");\nH.arrow(335, by - 100, 525, by - 100, { color: H.colors.accent2, width: 2 });\nconst fx = 335 + (525 - 335) * (0.5 + 0.5 * Math.sin(t * 1.5));\nH.circle(fx, by - 100, 5, { fill: H.colors.accent2 });\nH.text(\"add solvent\", 430, by - 112, { color: H.colors.sub, size: 11, align: \"center\" });\nH.text(\"Molarity & dilution: M = n/V , M₁V₁ = M₂V₂\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Adding solvent raises V, lowers M; moles of solute are conserved\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"M₁ = \" + n.toFixed(2) + \" mol / \" + V1.toFixed(2) + \" L = \" + M1.toFixed(2) + \" M\", 24, 80, { color: H.colors.accent, size: 13 });\nH.text(\"M₂ = M₁V₁/V₂ = \" + M2.toFixed(2) + \" M (moles = \" + n.toFixed(2) + \" conserved)\", 24, 100, { color: H.colors.good, size: 13 });" + }, + { + "id": "apchem-beers-law", + "area": "AP Chemistry", + "topic": "Beer law (absorbance)", + "title": "Beer–Lambert law: A = ε·b·c", + "equation": "A = epsilon * b * c ; T = 10^(-A)", + "keywords": [ + "beer law", + "beer-lambert law", + "absorbance", + "spectrophotometry", + "molar absorptivity", + "path length", + "concentration", + "transmittance", + "epsilon b c", + "calibration curve", + "ap chemistry", + "ap chem", + "colorimetry" + ], + "explanation": "The Beer–Lambert law states A = ε·b·c, where A is absorbance, ε is the molar absorptivity (a constant for a species at a given wavelength, in L·mol⁻¹·cm⁻¹), b is the path length through the sample (cm), and c is the concentration (mol/L). Because A is directly proportional to c, a plot of absorbance versus concentration is a straight line through the origin with slope ε·b — the basis of a spectrophotometric calibration curve used to find an unknown concentration. Absorbance relates to transmittance by A = −log₁₀(T), so as A rises the fraction of light passing through (T) falls and the solution looks darker. On the AP exam students use the linear A–c relationship to interpolate an unknown's concentration and reason about how changing b or c changes A.", + "bullets": [ + "A = ε·b·c — absorbance is linear in concentration (line through origin).", + "Slope of the A-vs-c line is ε·b; the dot sweeps along that line.", + "Doubling path length b or concentration c doubles the absorbance.", + "T = 10^(−A): higher A means less light transmitted — darker cuvette." + ], + "params": [ + { + "name": "conc", + "label": "concentration c (mol/L)", + "min": 0, + "max": 0.02, + "step": 0.001, + "value": 0.008 + }, + { + "name": "path", + "label": "path length b (cm)", + "min": 0.5, + "max": 2, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst c = P.conc;\nconst b = P.path;\nconst eps = 200;\nconst A = eps * b * c;\nconst Ttrans = Math.pow(10, -A);\nconst v = H.plot2d({ xMin: 0, xMax: 0.02, yMin: 0, yMax: 4 });\nv.grid(); v.axes();\nconst slope = eps * b;\nv.fn(x => slope * x, { color: H.colors.accent, width: 3 });\nconst cc = Math.min(0.02, Math.max(0, c * (0.6 + 0.4 * Math.sin(t * 1.0))));\nconst Ac = slope * cc;\nv.dot(cc, Math.min(Ac, 4), { r: 7, fill: H.colors.yellow });\nv.text(\"slope = ε·b\", 0.001, 3.5);\nconst cx = 760, cy = 300, cw = 70, ch = 150;\nH.rect(cx - cw/2, cy - ch/2, cw, ch, { stroke: H.colors.axis, width: 2 });\nconst shade = Math.min(1, A / 4);\nH.rect(cx - cw/2 + 2, cy - ch/2 + 2, cw - 4, ch - 4, { fill: H.hsl(280, 70, 50, 0.2 + 0.7 * shade) });\nH.line(cx - cw, cy, cx - cw/2, cy, { color: H.colors.yellow, width: 3 });\nH.line(cx + cw/2, cy, cx + cw, cy, { color: H.colors.yellow, width: Math.max(1, 3 * Ttrans) });\nconst ph = ((t * 0.4) % 1);\nH.circle(cx - cw + (cw/2) * ph, cy, 4, { fill: H.colors.yellow });\nif (Ttrans > 0.05) H.circle(cx + cw/2 + (cw/2) * ph, cy, 4 * Ttrans + 1, { fill: H.colors.yellow });\nH.text(\"I₀\", cx - cw - 4, cy - 12, { color: H.colors.sub, size: 12, align: \"right\" });\nH.text(\"I\", cx + cw + 4, cy - 12, { color: H.colors.sub, size: 12 });\nH.text(\"Beer–Lambert law: A = ε·b·c\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Absorbance is linear in concentration — basis of spectrophotometry\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"ε = \" + eps + \" L·mol⁻¹·cm⁻¹, b = \" + b.toFixed(2) + \" cm, c = \" + c.toFixed(4) + \" M\", 24, 80, { color: H.colors.accent, size: 13 });\nH.text(\"A = \" + A.toFixed(2) + \" → T = 10^(−A) = \" + (Ttrans * 100).toFixed(1) + \"%\", 24, 100, { color: H.colors.good, size: 13 });" + }, + { + "id": "apchem-colligative", + "area": "AP Chemistry", + "topic": "Colligative properties", + "title": "Colligative properties: dTf = -i*Kf*m, dTb = +i*Kb*m", + "equation": "dTf = -i*Kf*m and dTb = +i*Kb*m", + "keywords": [ + "colligative properties", + "freezing point depression", + "boiling point elevation", + "molality", + "kf", + "kb", + "van't hoff factor", + "delta tf", + "ap chemistry", + "solutions", + "antifreeze" + ], + "explanation": "Colligative properties depend only on the NUMBER of dissolved particles, not their identity, so they scale with molality m (mol solute per kg solvent). Freezing point is depressed by dTf = -i*Kf*m (negative: the solution freezes lower) and boiling point is elevated by dTb = +i*Kb*m (positive), where the van't Hoff factor i counts particles per formula unit (i=1 for a nonelectrolyte like sugar, ~2 for NaCl). For water Kf = 1.86 C/m and Kb = 0.512 C/m. The animation plots both straight lines through the origin and rides a dot up each line as molality changes, showing the AP exam point that more solute means a larger shift and that depression is always bigger than elevation for the same m.", + "bullets": [ + "Both shifts are LINEAR in molality and pass through the origin.", + "Freezing point goes DOWN (dTf < 0); boiling point goes UP (dTb > 0).", + "Kf(1.86) > Kb(0.512), so freezing depression exceeds boiling elevation.", + "Multiply by i (van't Hoff factor) for ionic solutes: NaCl gives i ~ 2." + ], + "params": [ + { + "name": "m", + "label": "max molality m (mol/kg)", + "min": 0.5, + "max": 4, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 4, yMin: -8, yMax: 4 });\nv.grid(); v.axes();\nconst Kf = 1.86, Kb = 0.512, iVH = 1;\nconst mMax = P.m;\nconst m = mMax * (0.5 + 0.5 * Math.sin(t * 0.7) * Math.sin(t * 0.7));\nconst dTf = -iVH * Kf * m;\nconst dTb = iVH * Kb * m;\nv.fn(x => -iVH * Kf * x, { color: H.colors.warn, width: 3 });\nv.fn(x => iVH * Kb * x, { color: H.colors.accent, width: 3 });\nv.dot(m, dTf, { r: 7, fill: H.colors.warn });\nv.dot(m, dTb, { r: 7, fill: H.colors.accent });\nv.line(m, -8, m, 4, { color: H.colors.grid, width: 1, dash: [4, 4] });\nH.text(\"Colligative: dTf = -Kf*m, dTb = +Kb*m\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Kf(H2O)=1.86, Kb(H2O)=0.512 C/m - more solute = bigger shift\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"m = \" + m.toFixed(2) + \" mol/kg\", 24, 80, { color: H.colors.sub, size: 13 });\nH.text(\"dTf = \" + dTf.toFixed(2) + \" C (Tf falls)\", 24, 98, { color: H.colors.warn, size: 13 });\nH.text(\"dTb = +\" + dTb.toFixed(2) + \" C (Tb rises)\", 24, 116, { color: H.colors.accent, size: 13 });\nH.legend([{ label: \"freezing pt depression\", color: H.colors.warn }, { label: \"boiling pt elevation\", color: H.colors.accent }], 560, 30);" + }, + { + "id": "apchem-rate-law", + "area": "AP Chemistry", + "topic": "Reaction rate & rate law", + "title": "Rate law: rate = k[A]^m [B]^n", + "equation": "rate = k * [A]^m * [B]^n", + "keywords": [ + "rate law", + "reaction rate", + "rate constant", + "order of reaction", + "k", + "concentration", + "overall order", + "ap chemistry", + "kinetics", + "rate equation", + "initial rates" + ], + "explanation": "The differential rate law gives the instantaneous rate as rate = k[A]^m[B]^n, where the orders m and n are found EXPERIMENTALLY (from initial-rate data), not from the balanced coefficients. Doubling [A] multiplies the rate by 2^m, so a first-order reactant doubles the rate while a second-order one quadruples it; a zero-order reactant (exponent 0) does not affect the rate at all. The overall order is m+n and sets the units of k. The animation oscillates [A] and shows how the rate curve and live readout respond to the chosen orders, the key skill the AP exam tests with initial-rates tables.", + "bullets": [ + "Rate depends on concentration through experimentally determined orders m, n.", + "Doubling [A] scales the rate by 2^m (1st order: x2, 2nd order: x4).", + "Overall order = m + n; a zero-order reactant drops out of the curve.", + "k is the rate constant - it changes only with temperature, not concentration." + ], + "params": [ + { + "name": "A", + "label": "[A] (M)", + "min": 0.2, + "max": 2, + "step": 0.1, + "value": 1.5 + }, + { + "name": "B", + "label": "[B] (M)", + "min": 0.2, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "m", + "label": "order in A m", + "min": 0, + "max": 2, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 2, yMin: 0, yMax: 4 });\nv.grid(); v.axes();\nconst k = 1.5;\nconst A0 = P.A, B0 = P.B, m = P.m, n = 1;\nconst A = A0 * (0.55 + 0.45 * Math.sin(t * 0.8));\nconst rate = k * Math.pow(Math.max(A, 0), m) * Math.pow(Math.max(B0, 0), n);\nv.fn(a => k * Math.pow(Math.max(a, 0), m) * Math.pow(Math.max(B0, 0), n), { color: H.colors.accent, width: 3 });\nv.dot(A, rate, { r: 7, fill: H.colors.accent2 });\nv.line(A, 0, A, rate, { color: H.colors.grid, width: 1, dash: [4, 4] });\nH.text(\"Rate law: rate = k[A]^m [B]^n\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"k = \" + k.toFixed(1) + \", order in A = \" + m + \", order in B = \" + n, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"[A] = \" + A.toFixed(2) + \" M, [B] = \" + B0.toFixed(2) + \" M\", 24, 80, { color: H.colors.sub, size: 13 });\nH.text(\"rate = \" + rate.toFixed(3) + \" M/s\", 24, 98, { color: H.colors.accent2, size: 13 });\nH.text(\"overall order = \" + (m + n), 24, 116, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apchem-reaction-order", + "area": "AP Chemistry", + "topic": "Order from concentration graphs", + "title": "Order from graphs: linear plot reveals order (0: [A], 1: ln[A], 2: 1/[A])", + "equation": "0th: [A] vs t linear | 1st: ln[A] vs t linear | 2nd: 1/[A] vs t linear", + "keywords": [ + "reaction order", + "integrated rate law", + "linear plot", + "ln[a] vs t", + "1/[a] vs t", + "graphical method", + "determine order", + "ap chemistry", + "kinetics", + "slope", + "straight line" + ], + "explanation": "On the AP exam you identify reaction order by which transformed concentration-vs-time plot is a STRAIGHT LINE. Zero order gives a linear [A] vs t (slope -k); first order gives a linear ln[A] vs t (slope -k); second order gives a linear 1/[A] vs t (slope +k). The animation transforms the same underlying decay according to the selected order so that the matching axis comes out perfectly straight, while a sweeping dot traces the line. This is the diagnostic students memorize: pick the plot that linearizes the data, read order from which axis it is, and get k from the slope.", + "bullets": [ + "Zero order: [A] vs t is the straight line, slope = -k.", + "First order: ln[A] vs t is straight, slope = -k.", + "Second order: 1/[A] vs t is straight, slope = +k.", + "Order is read from WHICH transform straightens the data, then k from the slope." + ], + "params": [ + { + "name": "order", + "label": "reaction order (0,1,2)", + "min": 0, + "max": 2, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst ord = Math.round(P.order);\nconst k = 0.4, A0 = 2.0;\nconst tEnd = 5;\nconst v = H.plot2d({ xMin: 0, xMax: tEnd, yMin: -1, yMax: 4 });\nv.grid(); v.axes();\nfunction conc(tt) {\n if (ord === 0) return Math.max(A0 - k * tt, 0.001);\n if (ord === 2) return 1 / (1 / A0 + k * tt);\n return A0 * Math.exp(-k * tt);\n}\nconst yLabel = ord === 0 ? \"[A]\" : ord === 1 ? \"ln[A]\" : \"1/[A]\";\nfunction transform(tt) {\n const c = conc(tt);\n if (ord === 0) return c;\n if (ord === 1) return Math.log(c);\n return 1 / c;\n}\nv.fn(x => transform(x), { color: H.colors.good, width: 3 });\nconst sweep = (t % 5) / 5 * tEnd;\nv.dot(sweep, transform(sweep), { r: 7, fill: H.colors.accent2 });\nH.text(\"Find order: which plot is a straight line?\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Order \" + ord + \" is LINEAR when you plot \" + yLabel + \" vs t\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"0th: [A] linear | 1st: ln[A] linear | 2nd: 1/[A] linear\", 24, 80, { color: H.colors.sub, size: 13 });\nH.text(\"plotting \" + yLabel + \" vs t, slope = \" + (ord === 0 ? \"-k\" : ord === 1 ? \"-k\" : \"+k\"), 24, 98, { color: H.colors.good, size: 13 });\nH.text(\"t = \" + sweep.toFixed(2) + \" s, \" + yLabel + \" = \" + transform(sweep).toFixed(2), 24, 116, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apchem-integrated-rate", + "area": "AP Chemistry", + "topic": "Integrated rate laws", + "title": "Integrated rate laws: [A] vs t for 0th/1st/2nd order", + "equation": "0th: [A]=[A]0-kt | 1st: [A]=[A]0*e^(-kt) | 2nd: 1/[A]=1/[A]0+kt", + "keywords": [ + "integrated rate law", + "concentration vs time", + "zero order", + "first order", + "second order", + "exponential decay", + "ap chemistry", + "kinetics", + "[a] vs t", + "rate constant", + "decay curve" + ], + "explanation": "The integrated rate laws give [A] as a function of time for each order: zero order falls in a straight line [A]=[A]0-kt, first order decays exponentially [A]=[A]0*e^(-kt), and second order follows 1/[A]=1/[A]0+kt, producing a curve that starts steep then trails off slowly. The animation draws the actual [A] vs t curve for the chosen order and sweeps a dot along it so students see the SHAPE differences: a constant-slope drop, a half-life-paced exponential, and a long second-order tail. Recognizing these shapes and matching them to the linearized plots is exactly how AP free-response kinetics questions are scored.", + "bullets": [ + "Zero order: straight-line drop, constant slope -k until [A] hits 0.", + "First order: smooth exponential decay with a constant half-life.", + "Second order: steep early drop then a long, slow tail.", + "Same [A]0 and k, but the curve shape alone reveals the order." + ], + "params": [ + { + "name": "order", + "label": "reaction order (0,1,2)", + "min": 0, + "max": 2, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst ord = Math.round(P.order);\nconst k = 0.5, A0 = 2.0, tEnd = 6;\nconst v = H.plot2d({ xMin: 0, xMax: tEnd, yMin: 0, yMax: 2.2 });\nv.grid(); v.axes();\nfunction conc(tt) {\n if (ord === 0) return Math.max(A0 - k * tt, 0);\n if (ord === 2) return 1 / (1 / A0 + k * tt);\n return A0 * Math.exp(-k * tt);\n}\nv.fn(x => conc(x), { color: H.colors.accent, width: 3 });\nconst sweep = (t % 6) / 6 * tEnd;\nv.dot(sweep, conc(sweep), { r: 7, fill: H.colors.accent2 });\nv.line(sweep, 0, sweep, conc(sweep), { color: H.colors.grid, width: 1, dash: [4, 4] });\nconst eqn = ord === 0 ? \"[A] = [A]0 - k t\" : ord === 1 ? \"[A] = [A]0 e^(-k t)\" : \"1/[A] = 1/[A]0 + k t\";\nH.text(\"Integrated rate laws: [A] vs t\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"order \" + ord + \": \" + eqn + \" (k=0.5, [A]0=2.0 M)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"0th = straight drop | 1st = exponential | 2nd = slow tail\", 24, 80, { color: H.colors.sub, size: 13 });\nH.text(\"t = \" + sweep.toFixed(2) + \" s, [A] = \" + conc(sweep).toFixed(3) + \" M\", 24, 98, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apchem-first-order-halflife", + "area": "AP Chemistry", + "topic": "First-order half-life", + "title": "First-order half-life: t(1/2) = ln2 / k", + "equation": "t(1/2) = ln(2) / k = 0.693 / k", + "keywords": [ + "half-life", + "first order", + "ln2 over k", + "radioactive decay", + "exponential decay", + "rate constant", + "ap chemistry", + "kinetics", + "t half", + "0.693", + "constant half-life" + ], + "explanation": "For a first-order reaction the half-life t(1/2) = ln2 / k = 0.693/k is CONSTANT - it does not depend on the starting concentration, which is the signature property AP exams test. After each half-life the amount left is cut in half (to 1/2, then 1/4, then 1/8 of [A]0), so the decay curve [A]=[A]0*e^(-kt) crosses evenly spaced ticks. The animation sweeps a dot down the exponential and marks successive half-lives; increasing k shortens t(1/2) (faster decay) but the spacing between ticks stays uniform. This is the same math as radioactive decay.", + "bullets": [ + "First-order half-life t(1/2)=0.693/k is INDEPENDENT of starting amount.", + "Pink ticks fall at equal time spacing - successive half-lives.", + "Each half-life leaves 1/2 the previous amount: 1/2, 1/4, 1/8 ...", + "Larger k means a shorter half-life and a faster exponential drop." + ], + "params": [ + { + "name": "k", + "label": "rate constant k (1/s)", + "min": 0.1, + "max": 2, + "step": 0.05, + "value": 0.5 + } + ], + "code": "H.background();\nconst k = P.k;\nconst halfLife = Math.log(2) / Math.max(k, 0.001);\nconst A0 = 1.0;\nconst tEnd = Math.max(4 * halfLife, 1);\nconst v = H.plot2d({ xMin: 0, xMax: tEnd, yMin: 0, yMax: 1.1 });\nv.grid(); v.axes();\nfunction conc(tt) { return A0 * Math.exp(-k * tt); }\nv.fn(x => conc(x), { color: H.colors.violet, width: 3 });\nfor (let i = 1; i <= 3; i++) {\n const th = i * halfLife;\n if (th <= tEnd) {\n v.line(th, 0, th, conc(th), { color: H.colors.grid, width: 1, dash: [4, 4] });\n v.dot(th, conc(th), { r: 5, fill: H.colors.warn });\n }\n}\nconst sweep = (t % 6) / 6 * tEnd;\nv.dot(sweep, conc(sweep), { r: 7, fill: H.colors.accent2 });\nconst frac = conc(sweep) / A0;\nH.text(\"First-order half-life: t(1/2) = ln2 / k\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"k = \" + k.toFixed(2) + \" 1/s => t(1/2) = \" + halfLife.toFixed(2) + \" s (constant!)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"pink ticks = successive half-lives (1, 1/2, 1/4, 1/8 of [A]0)\", 24, 80, { color: H.colors.warn, size: 13 });\nH.text(\"t = \" + sweep.toFixed(2) + \" s, [A]/[A]0 = \" + frac.toFixed(3), 24, 98, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apchem-arrhenius", + "area": "AP Chemistry", + "topic": "Arrhenius / activation energy", + "title": "Arrhenius equation: k = A e^(-Ea/RT)", + "equation": "k = A * exp(-Ea / (R*T))", + "keywords": [ + "arrhenius equation", + "activation energy", + "rate constant", + "k = a e^-ea/rt", + "temperature dependence of rate", + "ea", + "pre-exponential factor", + "frequency factor", + "kinetics", + "ap chemistry", + "exponential", + "reaction rate" + ], + "explanation": "The Arrhenius equation links the rate constant k to temperature T and activation energy Ea: k = A e^(-Ea/RT), where A is the frequency (pre-exponential) factor and R = 8.314 J/mol·K. Because Ea sits in a negative exponent divided by RT, raising T or lowering Ea makes the exponent less negative and increases k exponentially — a small temperature rise can double a rate. On the AP exam this appears as the linear form ln k = ln A - (Ea/R)(1/T), whose slope -Ea/R lets you extract Ea from a plot of ln k vs 1/T. The curve and the moving probe show k climbing steeply as T increases for the fixed Ea you set.", + "bullets": [ + "k rises EXPONENTIALLY with T because Ea/RT shrinks as T grows.", + "Larger Ea (taller barrier) means a smaller k at any given T.", + "Linear form ln k = ln A - (Ea/R)(1/T): slope = -Ea/R.", + "The dashed line marks your set T; the orange dot sweeps T over the curve." + ], + "params": [ + { + "name": "Ea", + "label": "activation energy Ea (kJ/mol)", + "min": 20, + "max": 150, + "step": 5, + "value": 75 + }, + { + "name": "T", + "label": "temperature T (K)", + "min": 250, + "max": 700, + "step": 10, + "value": 400 + } + ], + "code": "H.background();\nconst Ea = P.Ea;\nconst T = Math.max(1, P.T);\nconst R = 8.314;\nconst A = 1e13;\nconst v = H.plot2d({ xMin: 200, xMax: 800, yMin: 0, yMax: 1 });\nv.grid(); v.axes();\nconst kmax = A * Math.exp(-(Ea * 1000) / (R * 800));\nconst kOf = TT => A * Math.exp(-(Ea * 1000) / (R * Math.max(1, TT))) / (kmax || 1);\nv.fn(TT => kOf(TT), { color: H.colors.accent, width: 3 });\nconst Tsweep = 300 + 250 * (0.5 + 0.5 * Math.sin(t * 0.6));\nv.dot(Tsweep, kOf(Tsweep), { r: 6, fill: H.colors.accent2 });\nconst ksweep = A * Math.exp(-(Ea * 1000) / (R * Tsweep));\nconst kNow = A * Math.exp(-(Ea * 1000) / (R * T));\nv.line(T, 0, T, kOf(T), { color: H.colors.warn, width: 2, dash: [5, 5] });\nv.text(\"T set\", T, kOf(T) + 0.06, { color: H.colors.warn, size: 12 });\nH.text(\"Arrhenius: k = A e^(-Ea/RT)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Higher T or lower Ea -> larger rate constant k\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Ea = \" + Ea.toFixed(0) + \" kJ/mol T = \" + Tsweep.toFixed(0) + \" K\", 24, 76, { color: H.colors.sub, size: 13 });\nH.text(\"k(sweep) = \" + ksweep.toExponential(2) + \" s^-1\", 24, 96, { color: H.colors.accent2, size: 13 });\nH.text(\"k(T set) = \" + kNow.toExponential(2) + \" s^-1\", 24, 116, { color: H.colors.warn, size: 13 });" + }, + { + "id": "apchem-reaction-mechanism", + "area": "AP Chemistry", + "topic": "Mechanisms & rate-determining step", + "title": "Rate-determining step: rate set by the slow step", + "equation": "rate ~ k_slow * [reactants of slow step]", + "keywords": [ + "reaction mechanism", + "rate determining step", + "rate-limiting step", + "slow step", + "elementary step", + "intermediate", + "overall rate", + "rate law from mechanism", + "kinetics", + "ap chemistry", + "molecularity", + "rds" + ], + "explanation": "A reaction mechanism is a sequence of elementary steps whose sum is the overall reaction; the rate law is governed by the slowest (rate-determining) step, since traffic can move no faster than its narrowest point. For an elementary step the rate law comes directly from its molecularity (e.g. A + B -> I gives rate = k1[A][B]). Species like I that are made then consumed are intermediates and do not appear in the overall equation. On the AP exam you identify the slow step, write its rate law, and substitute for any intermediate using a fast pre-equilibrium. Here two marbles travel each step; the faster step's marble cycles quickly, but overall throughput is capped by whichever k is smaller.", + "bullets": [ + "The SLOWEST elementary step is rate-determining for the whole reaction.", + "Rate law of an elementary step comes from its molecularity (not the overall equation).", + "I is an intermediate: produced in step 1, consumed in step 2, absent overall.", + "Whichever k is smaller (highlighted) sets rate ~ k_slow times its reactants." + ], + "params": [ + { + "name": "k1", + "label": "step-1 rate const k1 (/s)", + "min": 0.1, + "max": 3, + "step": 0.1, + "value": 0.4 + }, + { + "name": "k2", + "label": "step-2 rate const k2 (/s)", + "min": 0.1, + "max": 3, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\nconst k1 = Math.max(0.05, P.k1);\nconst k2 = Math.max(0.05, P.k2);\nconst W = H.W, Hh = H.H;\nconst slow = Math.min(k1, k2);\nconst cx = W / 2;\nH.text(\"Mechanism: the SLOW step is rate-determining\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Overall rate is set by the highest-barrier (slowest) elementary step\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Step 1: A + B -> I (k1 = \" + k1.toFixed(2) + \" /s)\", 24, 78, { color: H.colors.accent, size: 13 });\nH.text(\"Step 2: I + B -> P (k2 = \" + k2.toFixed(2) + \" /s)\", 24, 98, { color: H.colors.accent2, size: 13 });\nconst slowLabel = (k1 <= k2) ? \"Step 1 is slow -> rate = k1[A][B]\" : \"Step 2 is slow -> rate = k2[I][B]\";\nH.text(slowLabel, 24, 122, { color: H.colors.warn, size: 14, weight: 700 });\nH.text(\"Overall rate ~ \" + slow.toFixed(2) + \" /s\", 24, 144, { color: H.colors.good, size: 13 });\nconst y1 = 230, y2 = 360;\nconst xA = 90, xI = cx, xP = W - 90;\nH.line(xA, y1, xP, y1, { color: H.colors.grid, width: 2 });\nH.line(xA, y2, xP, y2, { color: H.colors.grid, width: 2 });\nH.circle(xA, y1, 22, { fill: H.colors.panel, stroke: H.colors.accent, width: 2 });\nH.text(\"A+B\", xA, y1, { color: H.colors.ink, size: 12, align: \"center\", baseline: \"middle\" });\nH.circle(xI, y1, 22, { fill: H.colors.panel, stroke: H.colors.violet, width: 2 });\nH.text(\"I\", xI, y1, { color: H.colors.ink, size: 13, align: \"center\", baseline: \"middle\" });\nH.circle(xP, y2, 22, { fill: H.colors.panel, stroke: H.colors.good, width: 2 });\nH.text(\"P\", xP, y2, { color: H.colors.ink, size: 13, align: \"center\", baseline: \"middle\" });\nH.circle(xI, y2, 22, { fill: H.colors.panel, stroke: H.colors.violet, width: 2 });\nH.text(\"I+B\", xI, y2, { color: H.colors.ink, size: 11, align: \"center\", baseline: \"middle\" });\nconst p1 = (t * k1 * 0.5) % 1;\nconst m1x = xA + (xI - xA) * p1;\nH.circle(m1x, y1, 9, { fill: H.colors.accent });\nH.arrow(xA + 24, y1, xI - 24, y1, { color: H.colors.accent, width: 2 });\nconst p2 = (t * k2 * 0.5) % 1;\nconst m2x = xI + (xP - xI) * p2;\nH.circle(m2x, y2, 9, { fill: H.colors.accent2 });\nH.arrow(xI + 24, y2, xP - 24, y2, { color: H.colors.accent2, width: 2 });\nH.text(\"faster step moves more, but throughput is capped by the slow step\", 24, Hh - 24, { color: H.colors.sub, size: 12 });" + }, + { + "id": "apchem-catalyst", + "area": "AP Chemistry", + "topic": "Catalyst / energy diagram", + "title": "Catalysis: lowers Ea, leaves dH unchanged", + "equation": "Ea(cat) < Ea(uncat), dH unchanged", + "keywords": [ + "catalyst", + "catalysis", + "activation energy", + "energy diagram", + "reaction coordinate", + "lowers ea", + "enthalpy unchanged", + "alternative pathway", + "transition state", + "kinetics", + "ap chemistry", + "potential energy diagram" + ], + "explanation": "A catalyst provides an alternative reaction pathway with a lower activation energy Ea, speeding up both forward and reverse reactions, but it does NOT change the energies of the reactants or products, so the overall enthalpy change dH is identical. On a reaction-coordinate (potential-energy) diagram the catalyzed curve has a lower peak while the left (reactant) and right (product) plateaus stay put. Because Ea is lower, a larger fraction of collisions clear the barrier (e^(-Ea/RT) is larger), raising the rate without altering the equilibrium position or thermodynamics. AP free-response often asks you to draw the lowered hump and to state explicitly that dH is unchanged.", + "bullets": [ + "Catalyzed path (green) has a LOWER peak: smaller Ea, faster rate.", + "Reactant and product levels are unchanged, so dH is identical.", + "Lower Ea means a bigger reacting fraction e^(-Ea/RT) at the same T.", + "Sign of dH (down = exothermic, up = endothermic) is set by your slider." + ], + "params": [ + { + "name": "Ea", + "label": "uncatalyzed Ea (kJ/mol)", + "min": 40, + "max": 180, + "step": 10, + "value": 120 + }, + { + "name": "drop", + "label": "Ea lowered by catalyst (kJ/mol)", + "min": 10, + "max": 120, + "step": 10, + "value": 60 + }, + { + "name": "dH", + "label": "reaction dH (kJ/mol)", + "min": -100, + "max": 100, + "step": 10, + "value": -40 + } + ], + "code": "H.background();\nconst Ea = Math.max(10, P.Ea);\nconst drop = Math.min(P.drop, Ea - 5);\nconst dH = P.dH;\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: -120, yMax: 200 });\nv.grid(); v.axes();\nconst Rlevel = 0;\nconst Plevel = dH;\nfunction profile(x, barrier) {\n const base = Rlevel + (Plevel - Rlevel) * Math.min(1, Math.max(0, (x - 1) / 8));\n const bump = barrier * Math.exp(-Math.pow((x - 5) / 1.6, 2));\n return base + bump;\n}\nv.fn(x => profile(x, Ea), { color: H.colors.accent, width: 3 });\nv.fn(x => profile(x, Ea - drop), { color: H.colors.good, width: 3 });\nv.line(0.3, Rlevel, 1.2, Rlevel, { color: H.colors.sub, width: 2 });\nv.line(8.8, Plevel, 9.7, Plevel, { color: H.colors.sub, width: 2 });\nv.text(\"reactants\", 1.0, Rlevel + 14, { color: H.colors.sub, size: 12 });\nv.text(\"products\", 8.4, Plevel + 14, { color: H.colors.sub, size: 12 });\nconst ph = (t * 0.4) % 1;\nconst mx = 1 + ph * 8;\nv.dot(mx, profile(mx, Ea - drop), { r: 7, fill: H.colors.accent2 });\nH.text(\"Catalysis: lowers Ea, same dH\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"A catalyst opens a lower-barrier path; reactant/product energies unchanged\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Ea (uncat) = \" + Ea.toFixed(0) + \" kJ/mol\", 24, 78, { color: H.colors.accent, size: 13 });\nH.text(\"Ea (cat) = \" + (Ea - drop).toFixed(0) + \" kJ/mol\", 24, 98, { color: H.colors.good, size: 13 });\nH.text(\"dH = \" + dH.toFixed(0) + \" kJ/mol (\" + (dH < 0 ? \"exothermic\" : \"endothermic\") + \")\", 24, 118, { color: H.colors.accent2, size: 13 });\nH.legend([{label:\"uncatalyzed\", color:H.colors.accent},{label:\"catalyzed\", color:H.colors.good}], H.W - 220, 70);" + }, + { + "id": "apchem-collision-theory", + "area": "AP Chemistry", + "topic": "Collision theory", + "title": "Collision theory: fraction with E > Ea ~ e^(-Ea/RT)", + "equation": "fraction(E > Ea) ~ exp(-Ea / (R*T))", + "keywords": [ + "collision theory", + "maxwell-boltzmann distribution", + "fraction with energy above ea", + "activation energy", + "effective collision", + "orientation factor", + "temperature and rate", + "boltzmann factor", + "kinetics", + "ap chemistry", + "energy distribution", + "reaction rate" + ], + "explanation": "Collision theory says a reaction occurs only when particles collide with at least the activation energy Ea AND a suitable orientation; the Maxwell–Boltzmann curve shows how molecular kinetic energies are distributed at a given temperature. The shaded area to the right of Ea is the fraction of collisions energetic enough to react, well-approximated by the Boltzmann factor e^(-Ea/RT). Raising T broadens and flattens the distribution, shifting more area past Ea, which is why warmer reactions go faster. On the AP exam you must interpret these curves: higher T -> larger high-energy tail -> more effective collisions -> faster rate. The bouncing probe reacts (turns green) only when its energy exceeds Ea.", + "bullets": [ + "Curve is the Maxwell-Boltzmann energy distribution at temperature T.", + "Shaded tail past Ea = fraction of collisions able to react.", + "Fraction ~ e^(-Ea/RT): rises sharply as T increases.", + "Probe turns green only when its energy clears the Ea threshold." + ], + "params": [ + { + "name": "T", + "label": "temperature T (K)", + "min": 100, + "max": 900, + "step": 25, + "value": 400 + }, + { + "name": "Ea", + "label": "activation energy Ea (kJ/mol)", + "min": 2, + "max": 20, + "step": 1, + "value": 10 + } + ], + "code": "H.background();\nconst T = Math.max(50, P.T);\nconst Ea = Math.max(1, P.Ea);\nconst kT = 8.314e-3 * T;\nconst v = H.plot2d({ xMin: 0, xMax: 25, yMin: 0, yMax: 0.5 });\nv.grid(); v.axes();\nfunction f(E) {\n if (E < 0) return 0;\n return (2 / Math.sqrt(Math.PI)) * Math.pow(kT, -1.5) * Math.sqrt(E) * Math.exp(-E / kT);\n}\nv.fn(E => f(E), { color: H.colors.accent, width: 3 });\nfor (let E = Ea; E <= 25; E += 0.4) {\n const yv = f(E);\n if (yv > 0.001) v.line(E, 0, E, yv, { color: H.colors.warn, width: 1 });\n}\nv.line(Ea, 0, Ea, 0.5, { color: H.colors.violet, width: 2, dash: [5, 5] });\nv.text(\"Ea\", Ea + 0.3, 0.46, { color: H.colors.violet, size: 13 });\nconst frac = Math.exp(-Ea / kT);\nconst Emol = 12.5 + 11 * Math.sin(t * 1.1);\nconst reacts = Emol > Ea;\nv.dot(Math.max(0.2, Emol), f(Math.max(0.2, Emol)) + 0.02, { r: 7, fill: reacts ? H.colors.good : H.colors.sub });\nH.text(\"Collision theory: fraction with E > Ea\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Only collisions with energy >= Ea (and right orientation) react\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"T = \" + T.toFixed(0) + \" K Ea = \" + Ea.toFixed(1) + \" kJ/mol\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"fraction E>Ea ~ e^(-Ea/RT) = \" + frac.toExponential(2), 24, 98, { color: H.colors.warn, size: 13 });\nH.text(reacts ? \"this collision REACTS (E > Ea)\" : \"this collision bounces (E < Ea)\", 24, 118, { color: reacts ? H.colors.good : H.colors.sub, size: 13 });" + }, + { + "id": "apchem-keq", + "area": "AP Chemistry", + "topic": "Equilibrium constant Keq", + "title": "Equilibrium constant: K = [C] / ([A][B])", + "equation": "K = [C] / ([A] * [B])", + "keywords": [ + "equilibrium constant", + "keq", + "law of mass action", + "products over reactants", + "reaction quotient", + "k = products/reactants", + "concentration equilibrium", + "k > 1 products favored", + "ap chemistry", + "equilibrium", + "kc", + "le chatelier" + ], + "explanation": "At equilibrium the concentrations of products and reactants are related by the equilibrium constant K, written as products over reactants each raised to its stoichiometric coefficient; for A + B <-> C this is K = [C]/([A][B]). K > 1 means the equilibrium lies toward products, K < 1 means it favors reactants, and K depends only on temperature, not on the starting amounts. Comparing the reaction quotient Q to K tells you which way a reaction shifts (Q < K shifts right toward products). On the AP exam you build K expressions from balanced equations, exclude pure solids and liquids, and use ICE tables to solve for unknown concentrations. The bars show each species' concentration and the live readout recomputes K as you change them.", + "bullets": [ + "K = [products]/[reactants], each to its coefficient: here [C]/([A][B]).", + "K > 1 favors products; K < 1 favors reactants (shown live).", + "K depends only on temperature, not on initial concentrations.", + "Bars are [A], [B], [C]; the readout recomputes K from them each frame." + ], + "params": [ + { + "name": "A", + "label": "[A] (M)", + "min": 0.1, + "max": 5, + "step": 0.1, + "value": 1 + }, + { + "name": "B", + "label": "[B] (M)", + "min": 0.1, + "max": 5, + "step": 0.1, + "value": 1 + }, + { + "name": "C", + "label": "[C] (M)", + "min": 0.1, + "max": 5, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\nconst A = Math.max(0.01, P.A);\nconst B = Math.max(0.01, P.B);\nconst C = Math.max(0.01, P.C);\nconst denom = A * B;\nconst K = C / (denom || 1);\nconst W = H.W, Hh = H.H;\nH.text(\"Equilibrium constant: K = [C] / ([A][B])\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"A + B <-> C K compares product to reactants at equilibrium\", 24, 52, { color: H.colors.sub, size: 13 });\nconst baseY = 420, maxBarH = 280, maxConc = 5;\nconst species = [\n { name: \"[A]\", val: A, color: H.colors.accent },\n { name: \"[B]\", val: B, color: H.colors.accent2 },\n { name: \"[C]\", val: C, color: H.colors.good }\n];\nconst bw = 100, gap = 70, x0 = 150;\nfor (let i = 0; i < species.length; i++) {\n const s = species[i];\n const bx = x0 + i * (bw + gap);\n const settle = 0.85 + 0.15 * Math.sin(t * 0.9 + i);\n const h = Math.min(maxBarH, (s.val / maxConc) * maxBarH) * settle;\n H.rect(bx, baseY - h, bw, h, { fill: s.color, radius: 4 });\n H.text(s.name, bx + bw / 2, baseY + 18, { color: H.colors.ink, size: 13, align: \"center\" });\n H.text(s.val.toFixed(2) + \" M\", bx + bw / 2, baseY - h - 10, { color: H.colors.sub, size: 12, align: \"center\" });\n}\nH.line(x0 - 30, baseY, x0 + 3 * (bw + gap), baseY, { color: H.colors.axis, width: 2 });\nconst lean = K > 1.0001 ? \"products favored (K > 1)\" : (K < 0.9999 ? \"reactants favored (K < 1)\" : \"balanced (K ~ 1)\");\nH.text(\"K = \" + C.toFixed(2) + \" / (\" + A.toFixed(2) + \" * \" + B.toFixed(2) + \") = \" + K.toFixed(2) + \" M^-1\", 24, 96, { color: H.colors.good, size: 14, weight: 700 });\nH.text(lean, 24, 118, { color: H.colors.warn, size: 13 });" + }, + { + "id": "apchem-q-vs-k", + "area": "AP Chemistry", + "topic": "Reaction quotient Q vs K", + "title": "Reaction quotient: compare Q to K", + "equation": "Q vs K: QK reverse, Q=K equilibrium", + "keywords": [ + "reaction quotient", + "q vs k", + "equilibrium", + "qc", + "kc", + "shift direction", + "forward reverse", + "ice", + "equilibrium constant", + "ap chemistry", + "ap chem", + "predicting shift" + ], + "explanation": "The reaction quotient Q has the same mass-action form as the equilibrium constant K but uses the current (not equilibrium) concentrations or pressures. Comparing Q to K predicts which way a reaction must proceed to reach equilibrium: if Q < K there are too few products so the reaction shifts forward (toward products); if Q > K there are too many products so it shifts reverse; if Q = K the system is already at equilibrium and there is no net change. On the AP exam you compute Q from given starting amounts and compare it to K to state the shift direction and justify it. The animation places Q on a log number line and slides it toward the fixed K marker, with an arrow showing the net direction.", + "bullets": [ + "Q has the SAME expression as K but uses current concentrations/pressures.", + "Q < K: too few products, net shift FORWARD (toward products).", + "Q > K: too many products, net shift REVERSE (toward reactants).", + "The Q marker eases toward the fixed green K line — equilibrium is Q = K." + ], + "params": [ + { + "name": "Q", + "label": "reaction quotient Q", + "min": 0.001, + "max": 1000, + "step": 0.001, + "value": 0.1 + }, + { + "name": "K", + "label": "equilibrium constant K", + "min": 0.001, + "max": 1000, + "step": 0.001, + "value": 1 + } + ], + "code": "H.background();\nconst Q = Math.max(P.Q, 0.0001);\nconst K = Math.max(P.K, 0.0001);\nconst x0 = 120, x1 = H.W - 120, ymid = 300;\nH.line(x0, ymid, x1, ymid, {color:H.colors.axis, width:3});\nconst lo = -3, hi = 3;\nfunction px(val){ const lg = Math.log(val)/Math.LN10; return x0 + (Math.max(lo,Math.min(hi,lg)) - lo)/(hi-lo)*(x1-x0); }\nfor (let e=lo; e<=hi; e++){ const xx = x0 + (e-lo)/(hi-lo)*(x1-x0); H.line(xx, ymid-8, xx, ymid+8, {color:H.colors.grid, width:1}); H.text(\"1e\"+e, xx, ymid+24, {color:H.colors.sub, size:11, align:\"center\"}); }\nconst xK = px(K);\nH.line(xK, ymid-60, xK, ymid+40, {color:H.colors.good, width:2, dash:[5,4]});\nH.text(\"K = \"+K.toFixed(3), xK, ymid-70, {color:H.colors.good, size:13, align:\"center\", weight:700});\nconst frac = 0.5 - 0.5*Math.cos(t*0.8);\nconst lgQ = Math.log(Q)/Math.LN10, lgK = Math.log(K)/Math.LN10;\nconst lgNow = lgQ + (lgK - lgQ)*frac;\nconst valNow = Math.pow(10, lgNow);\nconst xQ = px(valNow);\nH.circle(xQ, ymid, 11, {fill:H.colors.accent, stroke:H.colors.ink, width:2});\nH.text(\"Q\", xQ, ymid-22, {color:H.colors.accent, size:14, align:\"center\", weight:700});\nlet dir, col;\nif (Q < K*0.999){ dir=\"forward (-> products)\"; col=H.colors.accent2; }\nelse if (Q > K*1.001){ dir=\"reverse (-> reactants)\"; col=H.colors.warn; }\nelse { dir=\"at equilibrium\"; col=H.colors.good; }\nif (Math.abs(Q-K) > 1e-6) H.arrow(xQ, ymid-95, xK, ymid-95, {color:col, width:3});\nH.text(\"Q = \"+Q.toFixed(3)+\" K = \"+K.toFixed(3), 24, 64, {color:H.colors.sub, size:13});\nH.text(\"shift \"+dir, 24, 84, {color:col, size:14, weight:700});\nH.text(\"Reaction quotient Q vs K\", 24, 30, {color:H.colors.ink, size:18, weight:700});\nH.text(\"QK shifts reverse; Q=K equilibrium\", 24, 48, {color:H.colors.sub, size:13});" + }, + { + "id": "apchem-le-chatelier", + "area": "AP Chemistry", + "topic": "Le Chatelier principle", + "title": "Le Chatelier: a stress shifts equilibrium to oppose it", + "equation": "stress -> system shifts to partially counteract the change", + "keywords": [ + "le chatelier", + "le chatelier's principle", + "equilibrium shift", + "stress", + "add reactant", + "remove product", + "pressure change", + "temperature change", + "exothermic", + "haber process", + "ap chemistry", + "ap chem" + ], + "explanation": "Le Chatelier's principle states that when a system at equilibrium is disturbed, it shifts in the direction that partially counteracts the disturbance. For N2 + 3H2 <-> 2NH3 (forward exothermic), adding a reactant or increasing pressure (which favors the side with fewer moles of gas, here 2 vs 4) shifts the equilibrium forward; adding product NH3 shifts it reverse; and raising temperature drives an exothermic reaction backward because heat behaves like a product. On the AP exam you must name the shift direction AND justify it with the specific stress (concentration, volume/pressure, or temperature) — noting that only temperature actually changes K. The animation shows reactant and product pools rebalancing with an arrow giving the net shift.", + "bullets": [ + "Add a reactant or compress (raise pressure): shifts FORWARD toward fewer gas moles (2 NH3).", + "Add a product (NH3): shifts REVERSE toward reactants.", + "Raising T on an exothermic reaction shifts REVERSE — heat acts as a product and K decreases.", + "Only a temperature change alters K; concentration/pressure stresses just re-shift the same K." + ], + "params": [ + { + "name": "stress", + "label": "stress (-2 add reactant ... +2 raise T)", + "min": -2, + "max": 2, + "step": 1, + "value": -2 + } + ], + "code": "H.background();\nconst s = P.stress;\nlet label, dir, col;\nif (s <= -1.5){ label=\"Add reactant (N2/H2)\"; dir=1; }\nelse if (s <= -0.5){ label=\"Increase pressure (compress)\"; dir=1; }\nelse if (s < 0.5){ label=\"No stress (at equilibrium)\"; dir=0; }\nelse if (s < 1.5){ label=\"Add product (NH3)\"; dir=-1; }\nelse { label=\"Increase temperature\"; dir=-1; }\ncol = dir>0 ? H.colors.accent2 : dir<0 ? H.colors.warn : H.colors.good;\nconst flow = (0.5 - 0.5*Math.cos(t*1.0));\nconst baseR = 50, baseP = 50;\nconst shift = dir * 18 * flow;\nconst rH = baseR - shift, pH = baseP + shift;\nconst bx=180, by=440, bw=120;\nH.rect(bx, by-rH*2.6, bw, rH*2.6, {fill:H.colors.accent, radius:4});\nH.text(\"reactants\", bx+bw/2, by+20, {color:H.colors.sub, size:13, align:\"center\"});\nH.text(\"N2 + 3H2\", bx+bw/2, by+38, {color:H.colors.sub, size:12, align:\"center\"});\nconst px2=600;\nH.rect(px2, by-pH*2.6, bw, pH*2.6, {fill:H.colors.violet, radius:4});\nH.text(\"products\", px2+bw/2, by+20, {color:H.colors.sub, size:13, align:\"center\"});\nH.text(\"2NH3\", px2+bw/2, by+38, {color:H.colors.sub, size:12, align:\"center\"});\nconst ay=250;\nif (dir!==0){\n const a1 = dir>0 ? bx+bw+20 : px2-20;\n const a2 = dir>0 ? px2-20 : bx+bw+20;\n H.arrow(a1, ay, a2, ay, {color:col, width:4});\n H.text(dir>0?\"shift FORWARD (->)\":\"shift REVERSE (<-)\", (bx+px2+bw)/2, ay-18, {color:col, size:14, align:\"center\", weight:700});\n} else {\n H.text(\"balanced (no net shift)\", (bx+px2+bw)/2, ay-18, {color:col, size:14, align:\"center\", weight:700});\n}\nH.text(\"N2 + 3H2 <-> 2NH3 (forward exothermic)\", 24, 64, {color:H.colors.sub, size:13});\nH.text(\"Stress: \"+label, 24, 84, {color:col, size:14, weight:700});\nH.text(\"Le Chatelier's principle\", 24, 30, {color:H.colors.ink, size:18, weight:700});\nH.text(\"A stress shifts equilibrium to partially oppose the change\", 24, 48, {color:H.colors.sub, size:13});" + }, + { + "id": "apchem-ice-table", + "area": "AP Chemistry", + "topic": "ICE table equilibrium", + "title": "ICE table: Kc = [C]/([A][B]) solved for x", + "equation": "Kc = x / (C0 - x)^2 for A + B <-> C", + "keywords": [ + "ice table", + "initial change equilibrium", + "equilibrium concentration", + "solve for x", + "kc", + "quadratic equilibrium", + "equilibrium amounts", + "ap chemistry", + "ap chem", + "change in concentration", + "equilibrium expression" + ], + "explanation": "An ICE table tracks Initial, Change, and Equilibrium amounts for each species, using a single unknown x for the extent of reaction set by the stoichiometry. For A + B <-> C starting from equal concentrations C0 with no product, the changes are -x, -x, +x, so at equilibrium [A]=[B]=C0-x and [C]=x. Substituting into Kc = [C]/([A][B]) gives Kc = x/(C0-x)^2, a quadratic you solve and then keep the physically valid root (0 <= x < C0). On the AP exam you set up the ICE table, plug equilibrium expressions into the K expression, solve for x (sometimes with the small-x approximation), and report the equilibrium concentrations. The bars animate from initial amounts to their equilibrium values.", + "bullets": [ + "Initial: [A]=[B]=C0, [C]=0; Change: -x, -x, +x; Equilibrium: C0-x, C0-x, x.", + "Substitute the E row into Kc = [C]/([A][B]) to get Kc = x/(C0-x)^2.", + "Solve the quadratic and keep the root with 0 <= x < C0 (concentrations stay positive).", + "The dashed line marks x_eq; the violet [C] bar rises to it as A and B fall." + ], + "params": [ + { + "name": "C0", + "label": "initial [A]=[B] C0 (M)", + "min": 0.1, + "max": 2, + "step": 0.05, + "value": 1 + } + ], + "code": "H.background();\nconst C0 = Math.max(P.C0, 0.0001);\nconst Kc = 2.0;\nconst a = Kc, b = -(2*C0*Kc + 1), c = Kc*C0*C0;\nconst disc = Math.max(b*b - 4*a*c, 0);\nlet x = (-b - Math.sqrt(disc))/(2*a);\nx = Math.max(0, Math.min(x, C0*0.999));\nconst prog = 0.5 - 0.5*Math.cos(t*0.7);\nconst xt = x*prog;\nconst A = C0 - xt, B = C0 - xt, Cc = xt;\nconst v = H.plot2d({xMin:0, xMax:3, yMin:0, yMax: C0*1.15+0.05});\nv.grid(); v.axes();\nfunction bar(cx, h, col, lab){\n const w=0.5;\n v.path([[cx-w/2,0],[cx-w/2,h],[cx+w/2,h],[cx+w/2,0]], {color:col, width:2, fill:col, close:true});\n v.text(lab, cx, h+C0*0.06+0.02, {color:H.colors.ink, size:12, align:\"center\"});\n}\nbar(0.5, A, H.colors.accent, \"[A]=\"+A.toFixed(3));\nbar(1.5, B, H.colors.good, \"[B]=\"+B.toFixed(3));\nbar(2.5, Cc, H.colors.violet, \"[C]=\"+Cc.toFixed(3));\nv.line(0, x, 3, x, {color:H.colors.warn, width:1, dash:[4,4]});\nv.text(\"x_eq=\"+x.toFixed(3), 2.5, x-0.04, {color:H.colors.warn, size:11, align:\"center\"});\nH.text(\"A + B <-> C Kc = \"+Kc.toFixed(1)+\" [A]0=[B]0=\"+C0.toFixed(2)+\" M\", 24, 64, {color:H.colors.sub, size:13});\nH.text(\"Eq: [A]=[B]=C0-x, [C]=x, x=\"+x.toFixed(3)+\" M\", 24, 84, {color:H.colors.good, size:13, weight:700});\nH.text(\"ICE table: solve for x\", 24, 30, {color:H.colors.ink, size:18, weight:700});\nH.text(\"Initial-Change-Equilibrium; Kc = [C]/([A][B]) sets x\", 24, 48, {color:H.colors.sub, size:13});" + }, + { + "id": "apchem-ksp", + "area": "AP Chemistry", + "topic": "Solubility product Ksp", + "title": "Solubility product: Ksp = 4 s^3 for a 1:2 salt", + "equation": "Ksp = [M2+][X-]^2 = (s)(2s)^2 = 4 s^3", + "keywords": [ + "ksp", + "solubility product", + "molar solubility", + "saturated solution", + "slightly soluble salt", + "dissolution equilibrium", + "ksp expression", + "pksp", + "ap chemistry", + "ap chem", + "precipitate" + ], + "explanation": "For a slightly soluble salt the solubility product Ksp is the equilibrium constant for the dissolution of the solid into its ions, with the pure solid omitted from the expression. For a 1:2 salt MX2(s) <-> M2+ + 2X-, if s is the molar solubility then [M2+]=s and [X-]=2s, so Ksp = (s)(2s)^2 = 4s^3 and therefore s = (Ksp/4)^(1/3). On the AP exam you write the correct Ksp expression from the dissolution equation (watch the stoichiometric coefficients, which become exponents), then relate Ksp to molar solubility s. The curve plots log s versus pKsp, and the pulsing dot marks the solubility for the chosen Ksp.", + "bullets": [ + "Ksp is the dissolution equilibrium constant; the pure solid is left OUT of the expression.", + "Coefficients become exponents: [X-] is squared because 2 X- form per formula unit.", + "For MX2: Ksp = (s)(2s)^2 = 4 s^3, so s = (Ksp/4)^(1/3).", + "Smaller Ksp (larger pKsp) means a less soluble salt — s drops along the curve." + ], + "params": [ + { + "name": "pKsp", + "label": "pKsp (Ksp = 10^-pKsp)", + "min": 4, + "max": 14, + "step": 0.5, + "value": 11 + } + ], + "code": "H.background();\nconst pKsp = P.pKsp;\nconst Ksp = Math.pow(10, -pKsp);\nconst s = Math.cbrt(Ksp/4);\nconst Mc = s, Xc = 2*s;\nconst v = H.plot2d({xMin:4, xMax:14, yMin:-7, yMax:0});\nv.grid(); v.axes();\nv.fn(pk => (-pk - Math.log(4)/Math.LN10)/3, {color:H.colors.accent, width:3});\nconst logS = (-pKsp - Math.log(4)/Math.LN10)/3;\nconst pulse = 4 + 2*Math.abs(Math.sin(t*1.2));\nv.dot(pKsp, logS, {r:pulse, fill:H.colors.warn});\nv.text(\"log s = \"+logS.toFixed(2), pKsp, logS+0.4, {color:H.colors.warn, size:11, align:\"center\"});\nv.text(\"pKsp\", 13.2, -6.6, {color:H.colors.sub, size:12, align:\"center\"});\nv.text(\"log10 s\", 4.6, -0.3, {color:H.colors.sub, size:12, align:\"center\"});\nH.text(\"MX2(s) <-> M2+ + 2X- Ksp = 4 s^3\", 24, 64, {color:H.colors.sub, size:13});\nH.text(\"Ksp = \"+Ksp.toExponential(2)+\" s = \"+s.toExponential(2)+\" M\", 24, 84, {color:H.colors.good, size:13, weight:700});\nH.text(\"[M2+]=\"+Mc.toExponential(2)+\" M [X-]=\"+Xc.toExponential(2)+\" M\", 24, 102, {color:H.colors.sub, size:12});\nH.text(\"Solubility product Ksp\", 24, 30, {color:H.colors.ink, size:18, weight:700});\nH.text(\"Molar solubility s from Ksp for a 1:2 salt\", 24, 48, {color:H.colors.sub, size:13});" + }, + { + "id": "apchem-common-ion", + "area": "AP Chemistry", + "topic": "Common-ion effect", + "title": "Common-ion effect: s = Ksp / [Cl-] for AgCl", + "equation": "s(s + C) = Ksp, so s ~ Ksp / [Cl-] when [Cl-] is large", + "keywords": [ + "common ion effect", + "common-ion", + "solubility suppression", + "ksp", + "agcl", + "added chloride", + "le chatelier solubility", + "molar solubility", + "ap chemistry", + "ap chem", + "saturated solution" + ], + "explanation": "The common-ion effect is Le Chatelier applied to a solubility equilibrium: adding an ion already present in the salt drives the dissolution equilibrium back toward the solid, lowering the salt's solubility. For AgCl(s) <-> Ag+ + Cl- with Ksp = 1.8e-10, dissolving in a solution that already contains [Cl-]=C gives [Ag+]=s and [Cl-]=s+C, so s(s+C)=Ksp; when C is much larger than s this reduces to s = Ksp/C. On the AP exam you set up this modified ICE expression, often using the approximation s+C ~ C, and show that solubility drops sharply as the common ion is added. The curve plots log s versus added [Cl-], and the dashed line marks the pure-water solubility sqrt(Ksp) for comparison.", + "bullets": [ + "Adding a shared ion (Cl-) shifts AgCl(s) <-> Ag+ + Cl- LEFT, lowering solubility.", + "Exact: s(s + C) = Ksp; for large added C, s ~ Ksp/[Cl-].", + "Pure-water solubility is s0 = sqrt(Ksp); the dashed line shows it for reference.", + "The readout reports how many times solubility is suppressed versus pure water." + ], + "params": [ + { + "name": "Cl", + "label": "added [Cl-] (M)", + "min": 0, + "max": 0.1, + "step": 0.005, + "value": 0.05 + } + ], + "code": "H.background();\nconst Ksp = 1.8e-10;\nconst C = Math.max(P.Cl, 0);\nconst s = (-C + Math.sqrt(C*C + 4*Ksp))/2;\nconst s0 = Math.sqrt(Ksp);\nconst v = H.plot2d({xMin:0, xMax:0.1, yMin:-11, yMax:-4});\nv.grid(); v.axes();\nv.fn(cc => { const ss = (-cc + Math.sqrt(cc*cc + 4*Ksp))/2; return Math.log(Math.max(ss,1e-30))/Math.LN10; }, {color:H.colors.accent, width:3});\nv.line(0, Math.log(s0)/Math.LN10, 0.1, Math.log(s0)/Math.LN10, {color:H.colors.sub, width:1, dash:[4,4]});\nv.text(\"pure water s0=\"+s0.toExponential(1), 0.05, Math.log(s0)/Math.LN10+0.3, {color:H.colors.sub, size:11, align:\"center\"});\nconst sweep = (0.5 - 0.5*Math.cos(t*0.7));\nconst Cn = C*sweep;\nconst sn = (-Cn + Math.sqrt(Cn*Cn + 4*Ksp))/2;\nconst logSn = Math.log(Math.max(sn,1e-30))/Math.LN10;\nconst pulse = 5 + 2*Math.abs(Math.sin(t*1.5));\nv.dot(Cn, logSn, {r:pulse, fill:H.colors.warn});\nv.text(\"added Cl- (M)\", 0.05, -10.6, {color:H.colors.sub, size:12, align:\"center\"});\nv.text(\"log10 s\", 0.006, -4.3, {color:H.colors.sub, size:12, align:\"center\"});\nH.text(\"AgCl(s) <-> Ag+ + Cl- Ksp = 1.8e-10\", 24, 64, {color:H.colors.sub, size:13});\nH.text(\"Added [Cl-]=\"+C.toFixed(4)+\" M -> s = \"+s.toExponential(2)+\" M\", 24, 84, {color:H.colors.good, size:13, weight:700});\nconst ratio = s0/Math.max(s,1e-30);\nH.text(\"solubility suppressed \"+ratio.toFixed(0)+\"x vs pure water\", 24, 102, {color:H.colors.sub, size:12});\nH.text(\"Common-ion effect\", 24, 30, {color:H.colors.ink, size:18, weight:700});\nH.text(\"Adding Cl- shifts equilibrium left; s = Ksp/[Cl-]\", 24, 48, {color:H.colors.sub, size:13});" + }, + { + "id": "apchem-ph-poh", + "area": "AP Chemistry", + "topic": "pH and pOH", + "title": "pH and pOH: pH = -log[H+], pH + pOH = 14", + "equation": "pH = -log10([H+]); pH + pOH = 14", + "keywords": [ + "ph", + "poh", + "ph and poh", + "-log[h+]", + "hydrogen ion concentration", + "ph + poh = 14", + "acidic basic neutral", + "logarithmic scale", + "kw", + "ap chemistry", + "ap chem acid base" + ], + "explanation": "pH is defined as the negative base-10 log of the hydrogen-ion concentration, pH = -log[H+], so each pH unit is a 10-fold change in [H+]. Because water self-ionizes with Kw = [H+][OH-] = 1.0e-14 at 25 C, taking -log of both sides gives pH + pOH = 14. A solution is acidic when pH < 7, neutral at pH = 7, and basic when pH > 7. On the AP exam you must move fluently between [H+], pH, pOH, and [OH-]; the animation plots the line pH + pOH = 14 and marks your point as the slider changes [H+].", + "bullets": [ + "pH = -log[H+]: each unit is a factor of 10 in [H+].", + "The violet line is pH + pOH = 14 (Kw at 25 C).", + "The blue point shows your current (pH, pOH) from the slider.", + "pH < 7 acidic, = 7 neutral, > 7 basic." + ], + "params": [ + { + "name": "Hplus", + "label": "[H+] (M)", + "min": 1e-07, + "max": 1, + "step": 1e-07, + "value": 0.001 + } + ], + "code": "H.background();\nconst w = H.W;\nconst Hp = Math.max(1e-14, P.Hplus);\nconst pH = -Math.log10(Hp);\nconst pOH = 14 - pH;\nconst pHc = Math.max(0, Math.min(14, pH));\nconst v = H.plot2d({ xMin: 0, xMax: 14, yMin: 0, yMax: 14 });\nv.grid(); v.axes();\nv.fn(x => 14 - x, { color: H.colors.violet, width: 2 });\nconst sweep = 7 + 6.5 * Math.sin(t * 0.5);\nv.dot(pHc, pOH < 0 ? 0 : (pOH > 14 ? 14 : pOH), { r: 8, fill: H.colors.accent });\nv.dot(sweep, 14 - sweep, { r: 5, fill: H.colors.yellow });\nv.text(\"pH\", 12.5, 1.0, { color: H.colors.sub, size: 12 });\nv.text(\"pOH\", 1.0, 12.5, { color: H.colors.sub, size: 12 });\nH.text(\"pH = -log[H+], pH + pOH = 14\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"[H+] = \" + Hp.toExponential(2) + \" M\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"pH = \" + pH.toFixed(2) + \" pOH = \" + pOH.toFixed(2), 24, 74, { color: H.colors.good, size: 13 });\nconst tag = pH < 6.99 ? \"acidic\" : (pH > 7.01 ? \"basic\" : \"neutral\");\nH.text(\"solution is \" + tag, 24, 96, { color: H.colors.accent2, size: 13 });\nH.legend([{ label: \"pH + pOH = 14\", color: H.colors.violet }, { label: \"current point\", color: H.colors.accent }], w - 220, 28);\n" + }, + { + "id": "apchem-strong-acid-base", + "area": "AP Chemistry", + "topic": "Strong acid/base pH", + "title": "Strong acid/base pH: pH = -log(C) or pH = 14 + log(C)", + "equation": "strong acid: pH = -log(C); strong base: pH = 14 + log(C)", + "keywords": [ + "strong acid", + "strong base", + "ph from concentration", + "-log concentration", + "complete dissociation", + "hcl", + "naoh", + "poh = -log[oh-]", + "ap chemistry", + "ap chem ph", + "strong electrolyte" + ], + "explanation": "A strong acid dissociates completely, so [H+] equals the acid's molarity and pH = -log(C). A strong base such as NaOH gives [OH-] = C, so pOH = -log(C) and pH = 14 - pOH = 14 + log(C). There is no equilibrium step because dissociation is essentially 100 percent. The animation lets you toggle acid vs base and slide the concentration; the point rides the curve pH = -log(C) for acids or pH = 14 - (-log C) for bases, with pC = -log[conc] on the x-axis.", + "bullets": [ + "Strong acids/bases dissociate completely: [H+] or [OH-] = C.", + "Acid: pH = -log(C); base: pH = 14 + log(C).", + "The orange curve is pH vs pC = -log[conc].", + "The blue point shows your concentration's pH live." + ], + "params": [ + { + "name": "conc", + "label": "concentration C (M)", + "min": 0.0001, + "max": 1, + "step": 0.0001, + "value": 0.01 + }, + { + "name": "base", + "label": "0 = acid, 1 = base", + "min": 0, + "max": 1, + "step": 1, + "value": 0 + } + ], + "code": "H.background();\nconst w = H.W;\nconst c = Math.max(1e-7, P.conc);\nconst isBase = P.base >= 0.5;\nlet pH;\nif (isBase) { pH = 14 + Math.log10(c); } else { pH = -Math.log10(c); }\nconst pHc = Math.max(0, Math.min(14, pH));\nconst v = H.plot2d({ xMin: 0, xMax: 7, yMin: 0, yMax: 14 });\nv.grid(); v.axes();\nv.fn(x => isBase ? 14 - x : x, { color: H.colors.accent2, width: 2 });\nconst pc = -Math.log10(c);\nconst pcc = Math.max(0, Math.min(7, pc));\nconst sw = 3.5 + 3.3 * Math.sin(t * 0.5);\nv.dot(sw, isBase ? 14 - sw : sw, { r: 5, fill: H.colors.yellow });\nv.dot(pcc, pHc, { r: 8, fill: H.colors.accent });\nv.text(\"pC = -log[conc]\", 3.5, 1.2, { color: H.colors.sub, size: 12 });\nH.text((isBase ? \"Strong base: pH = 14 + log[OH-]\" : \"Strong acid: pH = -log[H+]\"), 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"concentration = \" + c.toExponential(2) + \" M\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"pH = \" + pH.toFixed(2), 24, 74, { color: H.colors.good, size: 13 });\nH.legend([{ label: isBase ? \"pH = 14 - pC\" : \"pH = pC\", color: H.colors.accent2 }, { label: \"this solution\", color: H.colors.accent }], w - 200, 28);\n" + }, + { + "id": "apchem-weak-acid-ka", + "area": "AP Chemistry", + "topic": "Weak acid Ka", + "title": "Weak acid Ka: Ka = x^2/(C - x), x = [H+]", + "equation": "Ka = x^2 / (C - x), where x = [H+]", + "keywords": [ + "weak acid", + "ka", + "acid dissociation constant", + "percent ionization", + "ice table", + "x^2/(c-x)", + "equilibrium", + "ph of weak acid", + "ap chemistry", + "ap chem equilibrium", + "partial dissociation" + ], + "explanation": "A weak acid only partially ionizes: HA <=> H+ + A-, with Ka = [H+][A-]/[HA]. Letting x = [H+] formed, the ICE table gives Ka = x^2/(C - x); solving the exact quadratic x^2 + Ka*x - Ka*C = 0 avoids the small-x approximation error. Then pH = -log(x) and percent ionization = (x/C) x 100. The bars show HA remaining (large) versus the small amounts of H+ and A- formed, and the AP exam rewards recognizing that smaller Ka or higher C means a smaller fraction ionized.", + "bullets": [ + "Weak acids partially ionize: HA <=> H+ + A-.", + "Ka = x^2/(C - x) with x = [H+]; solved exactly as a quadratic.", + "Tall blue bar = HA left; short bars = H+ and A- formed.", + "% ionization = (x/C) x 100 rises as Ka rises or C falls." + ], + "params": [ + { + "name": "Ka", + "label": "Ka", + "min": 1e-06, + "max": 0.01, + "step": 1e-06, + "value": 1.8e-05 + }, + { + "name": "conc", + "label": "initial conc C (M)", + "min": 0.001, + "max": 1, + "step": 0.001, + "value": 0.1 + } + ], + "code": "H.background();\nconst w = H.W;\nconst Ka = Math.max(1e-10, P.Ka);\nconst c = Math.max(1e-4, P.conc);\nconst x = (-Ka + Math.sqrt(Ka * Ka + 4 * Ka * c)) / 2;\nconst pH = -Math.log10(Math.max(1e-14, x));\nconst pct = (x / c) * 100;\nconst grow = 0.5 + 0.5 * Math.sin(t * 0.7);\nconst baseY = 470, maxH = 320;\nconst HA = c - x, prod = x;\nconst scaleV = maxH / c;\nconst bx1 = 200, bx2 = 420, bx3 = 640, bw = 120;\nconst hHA = HA * scaleV, hPr = prod * scaleV;\nH.rect(bx1, baseY - hHA, bw, hHA, { fill: H.colors.accent, radius: 4 });\nH.rect(bx2, baseY - hPr * grow, bw, hPr * grow, { fill: H.colors.warn, radius: 4 });\nH.rect(bx3, baseY - hPr * grow, bw, hPr * grow, { fill: H.colors.good, radius: 4 });\nH.line(140, baseY, 800, baseY, { color: H.colors.axis, width: 2 });\nH.text(\"HA\", bx1 + bw / 2 - 10, baseY + 20, { color: H.colors.sub, size: 13 });\nH.text(\"H+\", bx2 + bw / 2 - 10, baseY + 20, { color: H.colors.sub, size: 13 });\nH.text(\"A-\", bx3 + bw / 2 - 8, baseY + 20, { color: H.colors.sub, size: 13 });\nH.text(\"Weak acid: Ka = x^2/(C - x), x = [H+]\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Ka = \" + Ka.toExponential(2) + \" C = \" + c.toFixed(3) + \" M\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"[H+] = \" + x.toExponential(2) + \" M pH = \" + pH.toFixed(2), 24, 74, { color: H.colors.good, size: 13 });\nH.text(\"% ionization = \" + pct.toFixed(2) + \" %\", 24, 96, { color: H.colors.accent2, size: 13 });\nH.legend([{ label: \"HA remaining\", color: H.colors.accent }, { label: \"H+ formed\", color: H.colors.warn }, { label: \"A- formed\", color: H.colors.good }], w - 200, 28);\n" + }, + { + "id": "apchem-buffer", + "area": "AP Chemistry", + "topic": "Buffer / Henderson-Hasselbalch", + "title": "Buffer: pH = pKa + log([A-]/[HA])", + "equation": "pH = pKa + log10([A-]/[HA])", + "keywords": [ + "buffer", + "henderson-hasselbalch", + "pka", + "conjugate base", + "[a-]/[ha]", + "buffer ratio", + "weak acid buffer", + "ph of buffer", + "half equivalence", + "ap chemistry", + "ap chem buffers" + ], + "explanation": "A buffer of a weak acid HA and its conjugate base A- resists pH change; the Henderson-Hasselbalch equation pH = pKa + log([A-]/[HA]) follows from rearranging the Ka expression. When [A-] = [HA] the log term is zero and pH = pKa, which is exactly the half-equivalence point of a titration. Buffers work best within a 10:1 to 1:10 ratio, roughly pKa +/- 1. The animation plots pH against log([A-]/[HA]) as a straight line through (0, pKa) and marks your chosen ratio.", + "bullets": [ + "Henderson-Hasselbalch: pH = pKa + log([A-]/[HA]).", + "At ratio = 1, log term = 0, so pH = pKa (violet dot).", + "Effective buffering holds for ratios about 1/10 to 10.", + "More conjugate base raises pH; more weak acid lowers it." + ], + "params": [ + { + "name": "pKa", + "label": "pKa", + "min": 2, + "max": 12, + "step": 0.1, + "value": 4.74 + }, + { + "name": "ratio", + "label": "[A-]/[HA] ratio", + "min": 0.05, + "max": 20, + "step": 0.05, + "value": 1 + } + ], + "code": "H.background();\nconst w = H.W;\nconst pKa = Math.max(0, Math.min(14, P.pKa));\nconst ratio = Math.max(0.01, P.ratio);\nconst pH = pKa + Math.log10(ratio);\nconst pHc = Math.max(0, Math.min(14, pH));\nconst v = H.plot2d({ xMin: -2, xMax: 2, yMin: pKa - 3, yMax: pKa + 3 });\nv.grid(); v.axes();\nv.fn(lr => pKa + lr, { color: H.colors.accent2, width: 2 });\nv.line(-1, pKa - 1, -1, pKa + 3, { color: H.colors.grid, width: 1, dash: [3, 3] });\nv.line(1, pKa - 3, 1, pKa + 1, { color: H.colors.grid, width: 1, dash: [3, 3] });\nconst lr = Math.log10(ratio);\nconst lrc = Math.max(-2, Math.min(2, lr));\nconst sw = 1.6 * Math.sin(t * 0.5);\nv.dot(sw, pKa + sw, { r: 5, fill: H.colors.yellow });\nv.dot(lrc, pHc, { r: 8, fill: H.colors.accent });\nv.dot(0, pKa, { r: 5, fill: H.colors.violet });\nv.text(\"pH = pKa\", -1.9, pKa + 0.3, { color: H.colors.violet, size: 11 });\nv.text(\"log([A-]/[HA])\", 0.5, pKa - 2.6, { color: H.colors.sub, size: 11 });\nH.text(\"Buffer: pH = pKa + log([A-]/[HA])\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"pKa = \" + pKa.toFixed(2) + \" [A-]/[HA] = \" + ratio.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"pH = \" + pH.toFixed(2), 24, 74, { color: H.colors.good, size: 13 });\nconst note = (ratio >= 0.1 && ratio <= 10) ? \"within buffer range (1/10 to 10)\" : \"outside ideal buffer range\";\nH.text(note, 24, 96, { color: H.colors.accent2, size: 13 });\nH.legend([{ label: \"pH vs ratio\", color: H.colors.accent2 }, { label: \"this buffer\", color: H.colors.accent }], w - 200, 28);\n" + }, + { + "id": "apchem-titration-strong", + "area": "AP Chemistry", + "topic": "Titration curve (strong-strong)", + "title": "Strong-strong titration: equivalence at pH 7", + "equation": "moles base = Cb*Vb; pH from excess H+ or OH- in total volume", + "keywords": [ + "titration", + "titration curve", + "strong acid strong base", + "equivalence point", + "ph 7", + "moles acid moles base", + "veq", + "neutralization", + "ap chemistry", + "ap chem titration", + "burette volume" + ], + "explanation": "Titrating a strong acid with a strong base, moles of base added (Cb*Vb) neutralize moles of acid (Ca*Va) one-to-one. Before equivalence the pH comes from leftover H+ in the total volume; after equivalence it comes from excess OH-. At the equivalence point all acid is neutralized and only water and a neutral salt remain, so pH = 7 (at 25 C). The curve is nearly flat then jumps sharply through pH 7 at Veq = Ca*Va/Cb; the slider moves the volume of base added and the readout tracks pH.", + "bullets": [ + "1:1 neutralization: Cb*Vb neutralizes Ca*Va.", + "Before Veq: pH set by excess H+ in total volume.", + "After Veq: pH set by excess OH-; sharp jump at Veq.", + "Equivalence of strong-strong is exactly pH 7 (violet dot)." + ], + "params": [ + { + "name": "Ca", + "label": "acid conc Ca (M)", + "min": 0.01, + "max": 0.5, + "step": 0.01, + "value": 0.1 + }, + { + "name": "Cb", + "label": "base conc Cb (M)", + "min": 0.01, + "max": 0.5, + "step": 0.01, + "value": 0.1 + }, + { + "name": "Vb", + "label": "base added Vb (mL)", + "min": 0, + "max": 50, + "step": 0.5, + "value": 25 + } + ], + "code": "H.background();\nconst w = H.W;\nconst Va = 25, Ca = Math.max(0.001, P.Ca), Cb = Math.max(0.001, P.Cb);\nconst Veq = Ca * Va / Cb;\nconst Vb = Math.max(0, Math.min(2 * Veq + 1e-9, P.Vb));\nfunction pHat(vb) {\n const nA = Ca * Va / 1000, nB = Cb * vb / 1000;\n const Vtot = (Va + vb) / 1000;\n if (Vtot <= 0) return 7;\n if (Math.abs(nA - nB) < 1e-12) return 7;\n if (nB < nA) { const Hp = (nA - nB) / Vtot; return -Math.log10(Math.max(1e-14, Hp)); }\n const OH = (nB - nA) / Vtot; return 14 + Math.log10(Math.max(1e-14, OH));\n}\nconst xMax = Math.max(2 * Veq, 1);\nconst v = H.plot2d({ xMin: 0, xMax: xMax, yMin: 0, yMax: 14 });\nv.grid(); v.axes();\nv.fn(vb => Math.max(0, Math.min(14, pHat(vb))), { color: H.colors.accent, width: 3 });\nv.line(Veq, 0, Veq, 14, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.dot(Veq, 7, { r: 6, fill: H.colors.violet });\nconst sw = (xMax / 2) * (1 + Math.sin(t * 0.5));\nv.dot(sw, Math.max(0, Math.min(14, pHat(sw))), { r: 5, fill: H.colors.yellow });\nconst pHnow = pHat(Vb);\nv.dot(Vb, Math.max(0, Math.min(14, pHnow)), { r: 8, fill: H.colors.accent2 });\nv.text(\"equiv.\", Veq, 1.0, { color: H.colors.violet, size: 11 });\nH.text(\"Strong-strong titration: equivalence at pH 7\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Va=25 mL Ca=\" + Ca.toFixed(3) + \" M Cb=\" + Cb.toFixed(3) + \" M Veq=\" + Veq.toFixed(1) + \" mL\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Vb added = \" + Vb.toFixed(1) + \" mL pH = \" + pHnow.toFixed(2), 24, 74, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"titration curve\", color: H.colors.accent }, { label: \"equivalence\", color: H.colors.violet }], w - 200, 28);\n" + }, + { + "id": "apchem-titration-weak", + "area": "AP Chemistry", + "topic": "Titration curve (weak-strong)", + "title": "Weak acid + strong base titration: at half-equivalence pH = pKa", + "equation": "pH = pKa + log([A-]/[HA]); at half-eq pH = pKa", + "keywords": [ + "titration curve", + "weak acid strong base", + "pH = pKa", + "half equivalence point", + "henderson hasselbalch", + "buffer region", + "equivalence point", + "ph pka", + "acetic acid naoh", + "ap chemistry", + "titration" + ], + "explanation": "When a weak acid (CH3COOH, Ka = 1.8e-5) is titrated with strong base, the curve has a gentle buffer region where the Henderson-Hasselbalch equation governs pH = pKa + log([A-]/[HA]). At the half-equivalence point exactly half the acid is converted to its conjugate base, so [A-] = [HA] and pH = pKa = 4.74 (violet marker) — the standard AP way to read pKa off a curve. The equivalence point (orange) lands ABOVE pH 7 because the solution is the conjugate base A-, which hydrolyzes; this is a key AP distinction from strong-strong titrations that finish at pH 7. The green probe sweeps the volume so you watch the buffer plateau, the steep jump, and the basic endpoint in order.", + "bullets": [ + "Buffer region is flat: small pH change as base is added (Henderson-Hasselbalch).", + "At half-equivalence [A-]=[HA], so pH = pKa = 4.74 (violet point).", + "Equivalence pH > 7 because the product A- is a weak base that hydrolyzes.", + "Steepest slope (the jump) sits right at the equivalence volume (orange line)." + ], + "params": [ + { + "name": "Vmax", + "label": "max V(NaOH) added (mL)", + "min": 28, + "max": 50, + "step": 1, + "value": 40 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 50, yMin: 0, yMax: 14 });\nv.grid(); v.axes();\nconst Ca = 0.1, Cb = 0.1, Va = 25, Ka = 1.8e-5, pKa = -Math.log10(Ka), Kw = 1e-14;\nfunction pH(Vb) {\n const na = Ca * Va / 1000, nb = Cb * Vb / 1000, Vt = (Va + Vb) / 1000;\n if (Vt <= 0) return 7;\n if (nb < na - 1e-12) {\n const HA = (na - nb) / Vt, Am = nb / Vt;\n if (Am <= 1e-12) return -Math.log10(Math.sqrt(Ka * HA));\n return pKa + Math.log10(Am / HA);\n } else if (Math.abs(nb - na) <= 1e-12) {\n const Am = na / Vt;\n return 14 + Math.log10(Math.sqrt((Kw / Ka) * Am));\n } else {\n return 14 + Math.log10((nb - na) / Vt);\n }\n}\nv.fn(x => pH(x), { color: H.colors.accent, width: 3 });\nconst Veq = Ca * Va / Cb;\nconst Vhalf = Veq / 2;\nv.line(Vhalf, 0, Vhalf, 14, { color: H.colors.violet, width: 1.5, dash: [5,4] });\nv.dot(Vhalf, pKa, { r: 6, fill: H.colors.violet });\nv.text(\"half-eq: pH=pKa=\" + pKa.toFixed(2), Vhalf + 1, pKa + 1.4, { color: H.colors.violet, size: 12 });\nv.line(Veq, 0, Veq, 14, { color: H.colors.warn, width: 1.5, dash: [5,4] });\nv.dot(Veq, pH(Veq), { r: 6, fill: H.colors.warn });\nv.text(\"equiv pt\", Veq + 1, 2, { color: H.colors.warn, size: 12 });\nconst Vmax = (P.Vmax !== undefined ? P.Vmax : 40);\nconst Vb = 0.5 * Vmax * (1 - Math.cos(t * 0.6));\nconst cur = pH(Vb);\nv.dot(Vb, cur, { r: 7, fill: H.colors.good });\nv.line(Vb, 0, Vb, cur, { color: H.colors.good, width: 1, dash: [3,3] });\nH.text(\"Weak acid + strong base titration\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"CH3COOH (0.1 M, 25 mL) titrated with NaOH (0.1 M)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"V(NaOH) = \" + Vb.toFixed(1) + \" mL pH = \" + cur.toFixed(2), 24, 74, { color: H.colors.good, size: 13 });" + }, + { + "id": "apchem-polyprotic", + "area": "AP Chemistry", + "topic": "Polyprotic acid titration", + "title": "Diprotic acid titration: two equivalence points, V(eq2) = 2*V(eq1)", + "equation": "pH = pKa1 + log([HA-]/[H2A]) then pH = pKa2 + log([A2-]/[HA-]); Veq2 = 2*Veq1", + "keywords": [ + "polyprotic acid", + "diprotic titration", + "two equivalence points", + "pka1 pka2", + "stepwise dissociation", + "buffer regions", + "carbonic acid", + "titration curve", + "ap chemistry", + "h2a titration", + "two protons" + ], + "explanation": "A diprotic acid H2A loses its protons in two stages, giving a titration curve with two buffer regions and two equivalence points. The first equivalence (orange) consumes one mole of base per mole of acid; the second arrives at exactly twice that volume because the second proton needs the same amount of base again (Veq2 = 2*Veq1). Each buffer region is centered on its own pKa: at the first half-equivalence pH = pKa1, at the second half-equivalence pH = pKa2 (violet markers), which is the AP method for reading both Ka values from one curve. Because pKa2 >> pKa1, the second proton is far weaker and its jump is smaller; the green probe sweeps the volume to trace H2A -> HA- -> A2-.", + "bullets": [ + "Two equivalence points: the second sits at exactly 2x the first volume.", + "First half-equivalence gives pH = pKa1; second gives pH = pKa2 (violet dots).", + "Two separate buffer plateaus, one centered on each pKa.", + "Second proton is weaker (pKa2 > pKa1), so its jump is smaller." + ], + "params": [ + { + "name": "pKa1", + "label": "pKa1 of first proton", + "min": 2, + "max": 4, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 60, yMin: 0, yMax: 14 });\nv.grid(); v.axes();\nconst Ca = 0.1, Va = 25, Cb = 0.1;\nconst pKa1 = (P.pKa1 !== undefined ? P.pKa1 : 3.0);\nconst pKa2 = pKa1 + 5;\nconst Veq1 = Ca * Va / Cb;\nconst Veq2 = 2 * Veq1;\nfunction pH(Vb) {\n if (Vb <= 0) return 0.5 * (pKa1 - Math.log10(Ca));\n if (Vb < Veq1) return pKa1 + Math.log10(Vb / (Veq1 - Vb));\n if (Math.abs(Vb - Veq1) < 1e-9) return 0.5 * (pKa1 + pKa2);\n if (Vb < Veq2) return pKa2 + Math.log10((Vb - Veq1) / (Veq2 - Vb));\n if (Math.abs(Vb - Veq2) < 1e-9) return 0.5 * (14 + pKa2);\n const nb = Cb * (Vb - Veq2) / 1000, Vt = (Va + Vb) / 1000;\n return 14 + Math.log10(nb / Vt);\n}\nv.fn(x => { const y = pH(x); return (isFinite(y) ? Math.max(0.2, Math.min(13.8, y)) : NaN); }, { color: H.colors.accent, width: 3 });\nv.dot(Veq1 / 2, pKa1, { r: 6, fill: H.colors.violet });\nv.text(\"pH=pKa1=\" + pKa1.toFixed(1), Veq1/2 + 1, pKa1 - 1, { color: H.colors.violet, size: 11 });\nv.dot(Veq1 + Veq1 / 2, pKa2, { r: 6, fill: H.colors.violet });\nv.text(\"pH=pKa2=\" + pKa2.toFixed(1), Veq1*1.5 + 1, pKa2 - 1, { color: H.colors.violet, size: 11 });\nv.line(Veq1, 0, Veq1, 14, { color: H.colors.warn, width: 1.5, dash: [5,4] });\nv.text(\"eq pt 1\", Veq1 + 0.5, 1.2, { color: H.colors.warn, size: 11 });\nv.line(Veq2, 0, Veq2, 14, { color: H.colors.warn, width: 1.5, dash: [5,4] });\nv.text(\"eq pt 2\", Veq2 + 0.5, 1.2, { color: H.colors.warn, size: 11 });\nconst Vb = 30 * (1 - Math.cos(t * 0.55));\nconst cur = pH(Vb);\nconst cy = isFinite(cur) ? Math.max(0.2, Math.min(13.8, cur)) : 7;\nv.dot(Vb, cy, { r: 7, fill: H.colors.good });\nH.text(\"Diprotic acid titration: two equivalence points\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"H2A (0.1 M, 25 mL) + NaOH (0.1 M): two pKa, two jumps\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"V = \" + Vb.toFixed(1) + \" mL pH = \" + cy.toFixed(2), 24, 74, { color: H.colors.good, size: 13 });" + }, + { + "id": "apchem-salt-hydrolysis", + "area": "AP Chemistry", + "topic": "Salt hydrolysis", + "title": "Salt hydrolysis: conjugate of the WEAK partner sets acidic/basic/neutral", + "equation": "NH4+ + H2O -> NH3 + H3O+ (acidic); A- + H2O -> HA + OH- (basic)", + "keywords": [ + "salt hydrolysis", + "acidic basic neutral salt", + "spectator ion", + "conjugate acid", + "conjugate base", + "nh4cl acidic", + "sodium acetate basic", + "ph of salt solution", + "ap chemistry", + "weak base hydrolysis", + "salt of weak acid" + ], + "explanation": "Whether a salt solution is acidic, basic, or neutral depends on which of its ions is the conjugate of a WEAK acid or base — that ion hydrolyzes water; ions from strong acids/bases (Na+, Cl-) are spectators. NaCl (strong+strong) leaves both ions inert, so pH = 7 (NEUTRAL). NH4Cl is the salt of a weak base, so NH4+ donates a proton (NH4+ + H2O -> NH3 + H3O+) making it ACIDIC; CH3COONa is the salt of a weak acid, so CH3COO- + H2O -> CH3COOH + OH- makes it BASIC. For NH4CH3COO both ions react, and because Ka(NH4+) ~ Kb(CH3COO-) the result is roughly neutral — a classic AP comparison that hinges on comparing Ka and Kb. The bobbing marker shows where each salt lands on the pH scale.", + "bullets": [ + "Ions from strong acids/bases (Na+, Cl-) are spectators -> no effect on pH.", + "NH4+ is a weak acid: NH4+ + H2O -> NH3 + H3O+, so NH4Cl is acidic.", + "CH3COO- is a weak base: hydrolyzes to give OH-, so CH3COONa is basic.", + "When both ions react, compare Ka vs Kb (NH4CH3COO ~ neutral)." + ], + "params": [ + { + "name": "salt", + "label": "salt: 0=NaCl 1=NH4Cl 2=CH3COONa 3=NH4OAc", + "min": 0, + "max": 3, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst sel = (P.salt !== undefined ? P.salt : 1);\nconst idx = Math.max(0, Math.min(3, Math.round(sel)));\nconst salts = [\n { name: \"NaCl\", cation: \"Na+ (spectator)\", anion: \"Cl- (spectator)\", pH: 7.0, type: \"NEUTRAL\", note: \"strong-acid + strong-base salt\" },\n { name: \"NH4Cl\", cation: \"NH4+ (weak acid)\", anion: \"Cl- (spectator)\", pH: 5.1, type: \"ACIDIC\", note: \"NH4+ + H2O -> NH3 + H3O+\" },\n { name: \"CH3COONa\", cation: \"Na+ (spectator)\", anion: \"CH3COO- (weak base)\", pH: 8.9, type: \"BASIC\", note: \"CH3COO- + H2O -> CH3COOH + OH-\" },\n { name: \"NH4CH3COO\", cation: \"NH4+ (weak acid)\", anion: \"CH3COO- (weak base)\", pH: 7.0, type: \"~NEUTRAL\", note: \"Ka(NH4+) ~ Kb(CH3COO-)\" }\n];\nconst s = salts[idx];\nconst x0 = 90, x1 = H.W - 90, ybar = 230, h = 26;\nfor (let i = 0; i < 14; i++) {\n const fx = x0 + (x1 - x0) * (i / 14);\n const fw = (x1 - x0) / 14;\n const col = i < 7 ? H.colors.warn : (i === 7 ? H.colors.good : H.colors.accent);\n H.rect(fx, ybar, fw, h, { fill: col, stroke: H.colors.grid, width: 0.5 });\n}\nfor (let i = 0; i <= 14; i += 2) {\n const fx = x0 + (x1 - x0) * (i / 14);\n H.text(String(i), fx, ybar + h + 16, { color: H.colors.sub, size: 11, align: \"center\" });\n}\nH.text(\"acidic\", x0, ybar - 10, { color: H.colors.warn, size: 12 });\nH.text(\"neutral\", (x0 + x1) / 2, ybar - 10, { color: H.colors.good, size: 12, align: \"center\" });\nH.text(\"basic\", x1, ybar - 10, { color: H.colors.accent, size: 12, align: \"right\" });\nconst px = x0 + (x1 - x0) * (s.pH / 14);\nconst bob = 6 * Math.sin(t * 2.0);\nH.path([[px, ybar - 30 + bob], [px - 8, ybar - 44 + bob], [px + 8, ybar - 44 + bob]], { fill: H.colors.yellow, close: true });\nH.circle(px, ybar + h / 2, 9, { fill: H.colors.yellow, stroke: H.colors.ink, width: 1.5 });\nH.text(\"Salt hydrolysis: which ion reacts with water?\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Conjugate of a WEAK partner hydrolyzes; strong-strong = spectators\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Salt: \" + s.name + \" -> \" + s.type + \" pH = \" + s.pH.toFixed(1), 24, 90, { color: H.colors.good, size: 15, weight: 700 });\nH.text(\"cation: \" + s.cation, 24, 120, { color: H.colors.sub, size: 13 });\nH.text(\"anion: \" + s.anion, 24, 142, { color: H.colors.sub, size: 13 });\nH.text(s.note, 24, 170, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apchem-indicators", + "area": "AP Chemistry", + "topic": "Acid-base indicators", + "title": "Acid-base indicators: color flips over pH = pKa(In) +- 1", + "equation": "[In-]/[HIn] = 10^(pH - pKa); midpoint color at pH = pKa", + "keywords": [ + "acid base indicator", + "indicator color change", + "ph = pka indicator", + "phenolphthalein", + "endpoint", + "transition range", + "hin in-", + "indicator equilibrium", + "ap chemistry", + "titration indicator", + "color change ph" + ], + "explanation": "An indicator is itself a weak acid HIn whose protonated form (HIn) and deprotonated form (In-) have different colors. The ratio [In-]/[HIn] = 10^(pH - pKa), so the visible color is set by how far pH sits from the indicator's pKa: at pH = pKa the two forms are equal (the midpoint color), and the eye sees a full flip across roughly pKa +- 1, the indicator's transition range. A good titration indicator is chosen so this range straddles the equivalence-point pH — the AP exam pairs phenolphthalein (pKa ~ 9) with basic endpoints and methyl orange (pKa ~ 3.5) with acidic ones. The beaker color blends as pH sweeps, and the violet band marks the pKa +- 1 window on the pH ruler.", + "bullets": [ + "Indicator is a weak acid: HIn (one color) <-> In- (another color).", + "[In-]/[HIn] = 10^(pH - pKa): color is set by pH relative to pKa.", + "Visible color change spans the transition range pKa +- 1 (violet band).", + "Pick an indicator whose pKa is near the equivalence-point pH." + ], + "params": [ + { + "name": "pKaInd", + "label": "indicator pKa(In)", + "min": 3, + "max": 10, + "step": 0.5, + "value": 8 + } + ], + "code": "H.background();\nconst pKa = (P.pKaInd !== undefined ? P.pKaInd : 8.0);\nconst pH = 7 + 6.8 * Math.sin(t * 0.5);\nconst r = Math.pow(10, pH - pKa);\nconst fracBase = r / (1 + r);\nconst hue = 50 + fracBase * 270;\nconst col = H.hsl(hue, 70, 55);\nconst bx = H.W/2 - 70, by = 150, bw = 140, bh = 200;\nH.rect(bx, by, bw, bh, { fill: col, stroke: H.colors.ink, width: 2, radius: 8 });\nconst my = by + 14 + 3 * Math.sin(t * 3);\nH.line(bx + 6, my, bx + bw - 6, my, { color: H.colors.ink, width: 1, dash: [4,4] });\nconst rx0 = 80, rx1 = H.W - 80, ry = 410;\nH.line(rx0, ry, rx1, ry, { color: H.colors.axis, width: 2 });\nfor (let i = 0; i <= 14; i += 2) {\n const fx = rx0 + (rx1 - rx0) * (i / 14);\n H.line(fx, ry - 5, fx, ry + 5, { color: H.colors.axis, width: 1 });\n H.text(String(i), fx, ry + 20, { color: H.colors.sub, size: 11, align: \"center\" });\n}\nconst tw0 = rx0 + (rx1 - rx0) * ((pKa - 1) / 14);\nconst tw1 = rx0 + (rx1 - rx0) * ((pKa + 1) / 14);\nH.rect(tw0, ry - 14, Math.max(2, tw1 - tw0), 28, { fill: H.colors.violet });\nH.text(\"transition pKa+-1\", (tw0+tw1)/2, ry - 22, { color: H.colors.violet, size: 11, align: \"center\" });\nconst px = rx0 + (rx1 - rx0) * (Math.max(0, Math.min(14, pH)) / 14);\nH.circle(px, ry, 7, { fill: H.colors.yellow, stroke: H.colors.ink, width: 1.5 });\nH.text(\"Acid-base indicators: color flips near pH = pKa(In)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"[In-]/[HIn] = 10^(pH - pKa); midpoint color at pH = pKa\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"pH = \" + pH.toFixed(2) + \" pKa = \" + pKa.toFixed(1) + \" base form = \" + (fracBase*100).toFixed(0) + \"%\", 24, 74, { color: H.colors.good, size: 13 });" + }, + { + "id": "apchem-entropy", + "area": "AP Chemistry", + "topic": "Entropy ΔS", + "title": "Entropy: S(gas) > S(liquid) > S(solid); melting/boiling give ΔS > 0", + "equation": "DeltaS = S(final) - S(initial); S(gas) > S(liquid) > S(solid)", + "keywords": [ + "entropy", + "delta s", + "disorder", + "microstates", + "gas liquid solid entropy", + "second law", + "phase change entropy", + "molar entropy", + "ap chemistry", + "spontaneity", + "delta s sign", + "thermodynamics" + ], + "explanation": "Entropy S measures the number of accessible microstates — the ways particles can spread their positions and energy — so it rises sharply with disorder: S(gas) >> S(liquid) > S(solid). The bar chart uses water as a benchmark (ice ~41, liquid ~70, steam ~189 J/mol-K) to show this ordering, while the box of jittering particles ramps up in spread and speed from solid to gas. Because the final state has more microstates, melting and boiling have DeltaS > 0; freezing and condensing have DeltaS < 0. On the AP exam this drives sign predictions for DeltaS_rxn (e.g. forming a gas, increasing moles of gas, or dissolving) and feeds into DeltaG = DeltaH - T*DeltaS for spontaneity.", + "bullets": [ + "S increases with the number of accessible microstates (disorder).", + "Ordering: S(gas) >> S(liquid) > S(solid) — bar heights and particle motion show it.", + "Melting and boiling: DeltaS > 0; freezing and condensing: DeltaS < 0.", + "More moles of gas in products => DeltaS_rxn > 0 (exam sign rule)." + ], + "params": [ + { + "name": "phase", + "label": "phase: 0=solid 1=liquid 2=gas", + "min": 0, + "max": 2, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst sel = (P.phase !== undefined ? P.phase : 2);\nconst idx = Math.max(0, Math.min(2, Math.round(sel)));\nconst phases = [\n { name: \"SOLID\", S: 41, n: 16, spread: 0.10, speed: 0.4 },\n { name: \"LIQUID\", S: 70, n: 16, spread: 0.45, speed: 1.0 },\n { name: \"GAS\", S: 189, n: 16, spread: 1.00, speed: 2.4 }\n];\nconst ph = phases[idx];\nconst bx = H.W - 300, by = 120, bw = 240, bh = 240;\nH.rect(bx, by, bw, bh, { fill: H.colors.panel, stroke: H.colors.axis, width: 2, radius: 6 });\nfor (let i = 0; i < ph.n; i++) {\n const gx = i % 4, gy = Math.floor(i / 4);\n const baseX = bx + 36 + gx * 56;\n const baseY = by + 36 + gy * 56;\n const amp = ph.spread * 24;\n const px = baseX + amp * Math.sin(t * ph.speed + i * 1.7);\n const py = baseY + amp * Math.cos(t * ph.speed * 1.1 + i * 2.3);\n H.circle(Math.max(bx+10, Math.min(bx+bw-10, px)), Math.max(by+10, Math.min(by+bh-10, py)), 8, { fill: H.colors.accent, stroke: H.colors.ink, width: 1 });\n}\nH.text(ph.name, bx + bw/2, by - 12, { color: H.colors.yellow, size: 15, align: \"center\", weight: 700 });\nconst cx = 80, baseY = 380, barW = 50, gap = 30, scale = 1.6;\nfor (let i = 0; i < 3; i++) {\n const p = phases[i];\n const hgt = p.S * scale;\n const x = cx + i * (barW + gap);\n const grow = (i === idx) ? (0.5 + 0.5 * (0.5 + 0.5 * Math.sin(t * 1.5))) : 1;\n const hh = hgt * (i === idx ? grow : 1);\n H.rect(x, baseY - hh, barW, hh, { fill: i === idx ? H.colors.good : H.colors.grid, stroke: H.colors.axis, width: 1 });\n H.text(p.name, x + barW/2, baseY + 16, { color: H.colors.sub, size: 11, align: \"center\" });\n H.text(p.S + \"\", x + barW/2, baseY - hgt - 8, { color: H.colors.sub, size: 11, align: \"center\" });\n}\nH.text(\"S (J/mol-K)\", cx - 6, baseY - 300, { color: H.colors.sub, size: 11 });\nH.line(cx - 10, baseY, cx + 3*(barW+gap), baseY, { color: H.colors.axis, width: 1.5 });\nH.text(\"Entropy DeltaS: S(gas) > S(liquid) > S(solid)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"More accessible microstates -> higher S; melting & boiling have DeltaS > 0\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Phase: \" + ph.name + \" molar S ~ \" + ph.S + \" J/mol-K\", 24, 78, { color: H.colors.good, size: 14, weight: 700 });\nH.text(\"solid->liquid->gas: DeltaS > 0 (more disorder); reverse: DeltaS < 0\", 24, 100, { color: H.colors.accent2, size: 12 });" + }, + { + "id": "apchem-gibbs", + "area": "AP Chemistry", + "topic": "Gibbs free energy ΔG", + "title": "Gibbs free energy: dG = dH - T*dS", + "equation": "dG = dH - T*dS", + "keywords": [ + "gibbs free energy", + "delta g", + "dg = dh - tds", + "spontaneity", + "enthalpy", + "entropy", + "thermodynamics", + "free energy", + "spontaneous reaction", + "ap chemistry", + "ap chem" + ], + "explanation": "Gibbs free energy combines enthalpy and entropy into one criterion for spontaneity at constant temperature and pressure: dG = dH - T*dS. A reaction is thermodynamically spontaneous (product-favored) when dG < 0, at equilibrium when dG = 0, and non-spontaneous when dG > 0. Watch the units: dH is in kJ/mol but dS is usually tabulated in J/(mol K), so dS must be divided by 1000 before multiplying by T (in kelvin). On the AP exam you compute dG from dH and dS and read off the sign; the graph here plots dG as a straight line in T and the sweeping marker turns green the instant dG dips below zero.", + "bullets": [ + "dG < 0 spontaneous, dG = 0 equilibrium, dG > 0 non-spontaneous.", + "Unit trap: convert dS from J/(mol K) to kJ before T*dS.", + "The dG-vs-T line has slope -dS/1000 and intercept dH.", + "Sweeping dot turns green (spont.) or pink (non-spont.) live." + ], + "params": [ + { + "name": "dH", + "label": "enthalpy dH (kJ/mol)", + "min": -200, + "max": 200, + "step": 5, + "value": -100 + }, + { + "name": "dS", + "label": "entropy dS (J/mol·K)", + "min": -300, + "max": 300, + "step": 10, + "value": 150 + }, + { + "name": "T", + "label": "temperature T (K)", + "min": 0, + "max": 1000, + "step": 10, + "value": 298 + } + ], + "code": "H.background();\nconst dH = P.dH, dS = P.dS, T = Math.max(0, P.T);\n// dH in kJ/mol, dS in J/(mol K) -> convert dS to kJ for dG in kJ/mol\nconst dG = dH - T * (dS / 1000);\nconst v = H.plot2d({ xMin: 0, xMax: 1000, yMin: -250, yMax: 250 });\nv.grid(); v.axes();\n// dG(T') as a line across the temperature axis: dG = dH - T'*(dS/1000)\nv.fn(Tx => dH - Tx * (dS / 1000), { color: H.colors.accent, width: 3 });\n// zero line (spontaneity boundary)\nv.line(0, 0, 1000, 0, { color: H.colors.axis, width: 1, dash: [5, 5] });\n// sweeping temperature marker\nconst Tm = 500 + 480 * Math.sin(t * 0.5);\nconst dGm = dH - Tm * (dS / 1000);\nv.line(Tm, -250, Tm, 250, { color: H.colors.sub, width: 1, dash: [3, 3] });\nv.dot(Tm, dGm, { r: 7, fill: dGm < 0 ? H.colors.good : H.colors.warn });\nv.text(dGm < 0 ? \"spontaneous\" : \"non-spont.\", Tm, dGm + (dGm < 0 ? -25 : 25));\nH.text(\"Gibbs free energy: dG = dH - T*dS\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"x = T (K), y = dG (kJ/mol); dG<0 -> spontaneous\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"dH = \" + dH.toFixed(0) + \" kJ/mol\", 24, 80, { color: H.colors.sub, size: 13 });\nH.text(\"dS = \" + dS.toFixed(0) + \" J/(mol K)\", 24, 98, { color: H.colors.sub, size: 13 });\nH.text(\"at T = \" + Tm.toFixed(0) + \" K: dG = \" + dGm.toFixed(1) + \" kJ/mol\", 24, 116, { color: dGm < 0 ? H.colors.good : H.colors.warn, size: 14, weight: 700 });" + }, + { + "id": "apchem-gibbs-temperature", + "area": "AP Chemistry", + "topic": "Spontaneity vs temperature", + "title": "Spontaneity vs T: dG flips sign at T* = dH/dS", + "equation": "T* = dH / dS (where dG = 0)", + "keywords": [ + "spontaneity vs temperature", + "crossover temperature", + "dg sign change", + "delta g temperature", + "dh/ds", + "temperature dependence", + "spontaneous at high temperature", + "gibbs", + "thermodynamics", + "ap chemistry", + "ap chem" + ], + "explanation": "Whether a reaction is spontaneous can depend on temperature, because the T*dS term in dG = dH - T*dS grows with T. When dH and dS have the SAME sign, dG passes through zero at the crossover temperature T* = dH/dS, and the reaction switches spontaneity above versus below T*. If dH < 0 and dS > 0 it is spontaneous at all T; if dH > 0 and dS < 0 it is never spontaneous. On the AP exam, setting dG = 0 and solving for T to find this crossover is a classic prompt; here the violet line marks T* = dH/dS and the sweeping dot flips from green to pink as it crosses it.", + "bullets": [ + "Set dG = 0 to get crossover T* = dH/dS (mind the J→kJ units).", + "dH<0, dS>0: spontaneous at ALL temperatures.", + "dH>0, dS<0: spontaneous at NO temperature (no crossover).", + "Same-sign dH and dS: spontaneity switches at T*." + ], + "params": [ + { + "name": "dH", + "label": "enthalpy dH (kJ/mol)", + "min": -200, + "max": 200, + "step": 5, + "value": 80 + }, + { + "name": "dS", + "label": "entropy dS (J/mol·K)", + "min": -300, + "max": 300, + "step": 10, + "value": 160 + }, + { + "name": "T", + "label": "temperature T (K)", + "min": 0, + "max": 1200, + "step": 10, + "value": 300 + } + ], + "code": "H.background();\nconst dH = P.dH, dS = P.dS;\n// crossover temperature where dG = 0: T* = dH/dS (in K), dH kJ, dS J/(molK)\n// T* = (dH*1000)/dS\nconst Tstar = Math.abs(dS) < 1e-6 ? Infinity : (dH * 1000) / dS;\nconst v = H.plot2d({ xMin: 0, xMax: 1200, yMin: -200, yMax: 200 });\nv.grid(); v.axes();\nv.fn(Tx => dH - Tx * (dS / 1000), { color: H.colors.accent, width: 3 });\nv.line(0, 0, 1200, 0, { color: H.colors.axis, width: 1, dash: [5, 5] });\n// crossover marker if within range and finite & positive\nif (isFinite(Tstar) && Tstar > 0 && Tstar < 1200) {\n v.line(Tstar, -200, Tstar, 200, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\n v.dot(Tstar, 0, { r: 6, fill: H.colors.violet });\n v.text(\"T* = \" + Tstar.toFixed(0) + \" K\", Tstar, 175);\n}\n// user-driven temperature point\nconst T = Math.max(0, Math.min(1200, P.T));\nconst dGT = dH - T * (dS / 1000);\n// animated sweeping probe to satisfy motion + show sign flip\nconst Tm = 600 + 560 * Math.sin(t * 0.4);\nconst dGm = dH - Tm * (dS / 1000);\nv.line(Tm, -200, Tm, 200, { color: H.colors.sub, width: 1, dash: [2, 4] });\nv.dot(Tm, dGm, { r: 7, fill: dGm < 0 ? H.colors.good : H.colors.warn });\nH.text(\"Spontaneity vs T: dG flips sign at T* = dH/dS\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"dG = dH - T*dS; dG<0 spontaneous, dG>0 not\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"dH = \" + dH.toFixed(0) + \" kJ/mol, dS = \" + dS.toFixed(0) + \" J/(mol K)\", 24, 80, { color: H.colors.sub, size: 13 });\nH.text(isFinite(Tstar) && Tstar > 0 ? (\"crossover T* = \" + Tstar.toFixed(0) + \" K\") : \"no sign change (signs agree)\", 24, 98, { color: H.colors.violet, size: 13 });\nH.text(\"sweep T = \" + Tm.toFixed(0) + \" K: dG = \" + dGm.toFixed(1) + \" kJ/mol (\" + (dGm < 0 ? \"spont\" : \"non-spont\") + \")\", 24, 116, { color: dGm < 0 ? H.colors.good : H.colors.warn, size: 14, weight: 700 });" + }, + { + "id": "apchem-gibbs-k", + "area": "AP Chemistry", + "topic": "ΔG and K", + "title": "Free energy and equilibrium: dG = -R*T*ln(K)", + "equation": "dG = -R*T*ln(K)", + "keywords": [ + "delta g and k", + "dg = -rt lnk", + "free energy equilibrium constant", + "thermodynamics equilibrium", + "gibbs and k", + "ln k", + "product favored", + "reactant favored", + "equilibrium constant", + "ap chemistry", + "ap chem" + ], + "explanation": "The standard free energy change is tied directly to the equilibrium constant by dG° = -R*T*ln(K), where R = 8.314 J/(mol K) and T is in kelvin. A negative dG° forces ln K positive, so K > 1 and products are favored; a positive dG° gives K < 1 (reactants favored); and dG° = 0 means K = 1 at equilibrium. Because the relationship is logarithmic, even modest changes in dG° swing K by orders of magnitude. On the AP exam you rearrange to K = exp(-dG°/RT), keeping dG° in J (not kJ) to match R; the graph plots ln K versus dG° as a straight line through the origin.", + "bullets": [ + "dG° < 0 → K > 1 (products favored); dG° > 0 → K < 1.", + "dG° = 0 → K = 1, the reaction sits at equilibrium.", + "Unit match: dG° in J/mol with R = 8.314 J/(mol K).", + "Logarithmic link: small dG° shifts move K by powers of 10." + ], + "params": [ + { + "name": "dG", + "label": "free energy dG° (kJ/mol)", + "min": -50, + "max": 50, + "step": 1, + "value": -20 + } + ], + "code": "H.background();\nconst R = 8.314; // J/(mol K)\nconst T = 298; // standard temperature, K\nconst dG = P.dG; // kJ/mol\n// dG = -RT ln K -> ln K = -dG*1000 / (R*T); K = exp(...)\nconst lnK = -(dG * 1000) / (R * T);\nconst K = Math.exp(lnK);\n// Plot dG (kJ/mol, x) vs ln K (y): ln K = -dG*1000/(R T) -> straight line\nconst v = H.plot2d({ xMin: -60, xMax: 60, yMin: -25, yMax: 25 });\nv.grid(); v.axes();\nv.fn(g => -(g * 1000) / (R * T), { color: H.colors.accent, width: 3 });\nv.line(-60, 0, 60, 0, { color: H.colors.axis, width: 1, dash: [5, 5] });\nv.line(0, -25, 0, 25, { color: H.colors.axis, width: 1, dash: [5, 5] });\n// equilibrium point at dG = 0 -> K = 1 -> ln K = 0\nv.dot(0, 0, { r: 5, fill: H.colors.violet });\nv.text(\"dG=0, K=1\", 0, -3 + 0);\n// animated probe sweeping dG, capped to frame\nconst gm = 50 * Math.sin(t * 0.5);\nconst ym = Math.max(-25, Math.min(25, -(gm * 1000) / (R * T)));\nv.line(gm, -25, gm, 25, { color: H.colors.sub, width: 1, dash: [3, 3] });\nv.dot(gm, ym, { r: 7, fill: gm < 0 ? H.colors.good : H.colors.warn });\nH.text(\"dG and K: dG = -R*T*ln(K)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"x = dG (kJ/mol), y = ln K; T = 298 K, R = 8.314 J/(mol K)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"slider dG = \" + dG.toFixed(1) + \" kJ/mol\", 24, 80, { color: H.colors.sub, size: 13 });\nconst Kstr = (K >= 1e4 || K <= 1e-4) ? K.toExponential(2) : K.toFixed(3);\nH.text(\"ln K = \" + lnK.toFixed(2) + \" -> K = \" + Kstr, 24, 98, { color: dG < 0 ? H.colors.good : H.colors.warn, size: 14, weight: 700 });\nH.text(dG < 0 ? \"dG<0 -> K>1: products favored\" : (dG > 0 ? \"dG>0 -> K<1: reactants favored\" : \"dG=0 -> K=1: at equilibrium\"), 24, 116, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apchem-galvanic-cell", + "area": "AP Chemistry", + "topic": "Galvanic cell", + "title": "Galvanic cell: e- flow anode -> cathode (oxidation/reduction)", + "equation": "anode: oxidation (-); cathode: reduction (+); e- : anode -> cathode", + "keywords": [ + "galvanic cell", + "voltaic cell", + "electrochemical cell", + "electron flow", + "anode cathode", + "salt bridge", + "oxidation reduction", + "redox", + "half cell", + "electrochemistry", + "ap chemistry", + "ap chem" + ], + "explanation": "In a galvanic (voltaic) cell a spontaneous redox reaction (dG < 0, E°cell > 0) pushes electrons through an external wire from the anode to the cathode. Oxidation happens at the anode (electrons released, M → Mⁿ⁺ + n e⁻), reduction at the cathode (Mⁿ⁺ + n e⁻ → M); in a galvanic cell the anode is the negative terminal and the cathode is positive. The salt bridge completes the circuit by letting ions migrate — cations toward the cathode, anions toward the anode — so neither beaker builds up net charge. On the AP exam you must label anode/cathode, state oxidation vs reduction, and give the directions of both electron flow (in the wire) and ion flow (in the bridge), all of which this animation shows live.", + "bullets": [ + "Yellow electrons travel anode → cathode through the external wire.", + "Anode = oxidation (− terminal); cathode = reduction (+ terminal).", + "Salt bridge: cations move to cathode, anions to anode.", + "Mnemonic: AN-OX, RED-CAT (anode oxidation, reduction cathode)." + ], + "params": [ + { + "name": "rate", + "label": "relative current (×)", + "min": 0.2, + "max": 3, + "step": 0.2, + "value": 1 + } + ], + "code": "H.background();\nconst W = H.W, Hh = H.H;\n// slider drives \"load\" / current rate -> animation speed of electrons\nconst rate = Math.max(0.2, P.rate);\n// Beakers\nconst anX = 200, caX = 700, beY = 320, beW = 150, beH = 170;\nH.rect(anX - beW/2, beY, beW, beH, { stroke: H.colors.grid, width: 2, fill: H.colors.panel });\nH.rect(caX - beW/2, beY, beW, beH, { stroke: H.colors.grid, width: 2, fill: H.colors.panel });\n// Electrodes (vertical bars dipping into solution)\nH.rect(anX - 8, 240, 16, 180, { fill: H.colors.sub });\nH.rect(caX - 8, 240, 16, 180, { fill: H.colors.accent2 });\n// External wire path: top of anode -> up -> across -> down to cathode\nconst wireY = 150;\nH.line(anX, 240, anX, wireY, { color: H.colors.ink, width: 2 });\nH.line(anX, wireY, caX, wireY, { color: H.colors.ink, width: 2 });\nH.line(caX, wireY, caX, 240, { color: H.colors.ink, width: 2 });\n// Electrons travel anode -> cathode along the wire (external circuit)\nconst N = 6;\nfor (let i = 0; i < N; i++) {\n const ph = ((t * rate * 0.25) + i / N) % 1;\n // parametrize along the 3-segment wire\n let ex, ey;\n const segUp = 90 / 280, segAcross = 500 / 280; // weights not needed; use length fractions\n // total length: up(90)+across(500)+down(90)=680\n const d = ph * 680;\n if (d < 90) { ex = anX; ey = 240 - d; }\n else if (d < 590) { ex = anX + (d - 90); ey = wireY; }\n else { ex = caX; ey = wireY + (d - 590); }\n H.circle(ex, ey, 5, { fill: H.colors.yellow });\n}\n// e- direction label + arrow\nH.arrow(380, wireY, 470, wireY, { color: H.colors.yellow, width: 2 });\nH.text(\"e-\", 420, wireY - 12, { color: H.colors.yellow, size: 13, align: \"center\" });\n// Salt bridge connecting the two solutions (inverted U)\nconst sbY = 280;\nH.path([[anX, sbY], [anX, sbY - 30], [caX, sbY - 30], [caX, sbY]], { color: H.colors.violet, width: 8 });\nH.text(\"salt bridge\", (anX + caX) / 2, sbY - 42, { color: H.colors.violet, size: 12, align: \"center\" });\n// ions in salt bridge: cations -> cathode, anions -> anode\nconst ip = (t * rate * 0.3) % 1;\nH.circle(anX + (caX - anX) * ip, sbY - 30, 4, { fill: H.colors.accent }); // cation -> cathode\nH.circle(caX - (caX - anX) * ip, sbY - 30, 4, { fill: H.colors.warn }); // anion -> anode\n// Labels\nH.text(\"Galvanic cell: e- flow anode -> cathode through wire\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Anode = oxidation (-); Cathode = reduction (+); salt bridge keeps charge balance\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"ANODE (-)\", anX, 470, { color: H.colors.sub, size: 13, align: \"center\", weight: 700 });\nH.text(\"oxidation: M -> M^n+ + n e-\", anX, 488, { color: H.colors.sub, size: 11, align: \"center\" });\nH.text(\"CATHODE (+)\", caX, 470, { color: H.colors.accent2, size: 13, align: \"center\", weight: 700 });\nH.text(\"reduction: M^n+ + n e- -> M\", caX, 488, { color: H.colors.accent2, size: 11, align: \"center\" });\nH.text(\"relative current (slider) = \" + rate.toFixed(1) + \"x\", 24, 76, { color: H.colors.yellow, size: 13 });" + }, + { + "id": "apchem-cell-potential", + "area": "AP Chemistry", + "topic": "Standard cell potential", + "title": "Standard cell potential: E_cell = E_cathode - E_anode", + "equation": "E°cell = E°cathode - E°anode", + "keywords": [ + "standard cell potential", + "ecell", + "e cathode minus e anode", + "reduction potential", + "standard reduction potential", + "electrochemistry", + "galvanic spontaneous", + "emf", + "cell voltage", + "half cell potential", + "ap chemistry", + "ap chem" + ], + "explanation": "The standard cell potential is the difference of the two standard REDUCTION potentials: E°cell = E°cathode - E°anode, where both values come straight from the reduction-potential table (do not flip the sign of the anode's tabulated value — the subtraction already accounts for it being oxidized). The half-reaction with the more positive reduction potential acts as the cathode, which makes E°cell positive for a galvanic cell. A positive E°cell means a spontaneous reaction (dG° = -nFE°cell < 0); a negative E°cell describes a non-spontaneous, electrolytic cell. On the AP exam you pick cathode/anode by which potential is larger and compute the difference, exactly as the green/pink bar here shows the gap E°cathode - E°anode.", + "bullets": [ + "E°cell = E°cathode - E°anode, both as table reduction potentials.", + "More positive reduction potential becomes the cathode.", + "E°cell > 0 → galvanic & spontaneous (dG° = -nFE°cell < 0).", + "Violet arrow spans the cathode-minus-anode gap = E°cell." + ], + "params": [ + { + "name": "Ecat", + "label": "cathode E°red (V)", + "min": -1.5, + "max": 1.5, + "step": 0.05, + "value": 0.34 + }, + { + "name": "Ean", + "label": "anode E°red (V)", + "min": -1.5, + "max": 1.5, + "step": 0.05, + "value": -0.76 + } + ], + "code": "H.background();\nconst Ecat = P.Ecat, Ean = P.Ean; // standard reduction potentials, V\nconst Ecell = Ecat - Ean;\nconst v = H.plot2d({ xMin: -1, xMax: 3, yMin: -2, yMax: 2 });\nv.grid(); v.axes();\n// zero (SHE) reference line\nv.line(-1, 0, 3, 0, { color: H.colors.axis, width: 1, dash: [5, 5] });\nv.text(\"SHE = 0 V\", -0.6, 0.12);\n// cathode level (x=1) and anode level (x=2) as horizontal markers\nv.line(0.6, Ecat, 1.4, Ecat, { color: H.colors.accent2, width: 4 });\nv.line(1.6, Ean, 2.4, Ean, { color: H.colors.sub, width: 4 });\nv.text(\"cathode E = \" + Ecat.toFixed(2) + \" V\", 1.0, Ecat + (Ecat >= 0 ? 0.18 : -0.22));\nv.text(\"anode E = \" + Ean.toFixed(2) + \" V\", 2.0, Ean + (Ean >= 0 ? 0.18 : -0.22));\n// animated bar showing Ecell = difference, drawn as a growing/pulsing vertical span at x=2.7\nconst grow = 0.5 + 0.5 * Math.sin(t * 1.2); // 0..1 pulse, loops\nconst ytop = Math.max(Ecat, Ean), ybot = Math.min(Ecat, Ean);\nconst yfill = ybot + (ytop - ybot) * grow;\nv.line(2.7, ybot, 2.7, yfill, { color: Ecell >= 0 ? H.colors.good : H.colors.warn, width: 8 });\nv.dot(2.7, yfill, { r: 6, fill: Ecell >= 0 ? H.colors.good : H.colors.warn });\nv.text(\"dE\", 2.85, (Ecat + Ean) / 2);\n// arrow from anode level to cathode level showing the gap\nv.arrow(1.5, Ean, 1.5, Ecat, { color: H.colors.violet, width: 2 });\nH.text(\"Standard cell potential: E_cell = E_cathode - E_anode\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Both are reduction potentials; the more positive one is the cathode\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"E_cat = \" + Ecat.toFixed(2) + \" V, E_an = \" + Ean.toFixed(2) + \" V\", 24, 80, { color: H.colors.sub, size: 13 });\nH.text(\"E_cell = \" + Ecat.toFixed(2) + \" - (\" + Ean.toFixed(2) + \") = \" + Ecell.toFixed(2) + \" V\", 24, 98, { color: Ecell >= 0 ? H.colors.good : H.colors.warn, size: 14, weight: 700 });\nH.text(Ecell > 0 ? \"E_cell > 0 -> galvanic (spontaneous, dG<0)\" : (Ecell < 0 ? \"E_cell < 0 -> electrolytic (non-spontaneous)\" : \"E_cell = 0 -> at equilibrium\"), 24, 116, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apchem-nernst", + "area": "AP Chemistry", + "topic": "Nernst equation", + "title": "Nernst equation: E = E0 - (RT/nF) ln Q", + "equation": "E = E0 - (R*T / (n*F)) * ln(Q)", + "keywords": [ + "nernst equation", + "cell potential", + "electrochemistry", + "reaction quotient", + "Q", + "E0", + "standard potential", + "concentration cell", + "RT/nF", + "daniell cell", + "ap chemistry", + "galvanic cell", + "ln Q", + "log Q" + ], + "explanation": "The Nernst equation corrects the standard cell potential E0 for non-standard concentrations: E = E0 - (RT/nF) ln Q, where Q is the reaction quotient and n is the moles of electrons transferred. At 298 K the factor RT/F = 0.0257 V, so a tenfold change in Q (one unit of log10 Q) shifts E by about 0.0257/n volts. When Q < 1 (more reactant), E rises above E0; when Q > 1 (products accumulate), E falls, and at equilibrium Q = K so E = 0. On the AP exam students use this to predict how diluting or concentrating an ion changes a galvanic cell's voltage, shown here as the straight E-vs-log10 Q line for the Daniell cell (E0 = +1.10 V, n = 2).", + "bullets": [ + "Line is E vs log10 Q with slope -(RT/nF)*ln10 = -0.0257/n V per decade.", + "Q = [Zn2+]/[Cu2+]; raising product ion (Zn2+) lowers E, raising reactant ion (Cu2+) raises E.", + "Pink dot marks the cell's actual (log Q, E) from the concentration sliders.", + "At Q = 1 (log Q = 0) the cell sits exactly at E0 = +1.10 V." + ], + "params": [ + { + "name": "Cprod", + "label": "[Zn2+] product (M)", + "min": 0.001, + "max": 2, + "step": 0.001, + "value": 1 + }, + { + "name": "Creact", + "label": "[Cu2+] reactant (M)", + "min": 0.001, + "max": 2, + "step": 0.001, + "value": 1 + } + ], + "code": "H.background();\n// Nernst: E = E0 - (RT/nF) ln Q, here Q = [products]/[reactants] for a\n// concentration cell-style readout. Daniell cell Zn|Zn2+||Cu2+|Cu, E0=+1.10 V.\nconst E0 = 1.10;\nconst n = 2; // electrons transferred\nconst RTF = 0.02569; // RT/F at 298 K in volts\nconst Cprod = Math.max(0.001, P.Cprod); // [Zn2+] (product side)\nconst Creact = Math.max(0.001, P.Creact); // [Cu2+] (reactant side)\n// Q = [Zn2+]/[Cu2+]; reaction quotient\nconst v = H.plot2d({ xMin: -4, xMax: 3, yMin: 0.9, yMax: 1.3 });\nv.grid(); v.axes();\n// E as a function of log10(Q): straight line. x-axis = log10 Q.\nconst slope = -(RTF / n) * Math.LN10; // dE/d(log10 Q)\nv.fn(x => E0 + slope * x, { color: H.colors.accent, width: 3 });\n// live point: current Q from sliders, but let the marker breathe along the line\nconst Qnow = Cprod / Creact;\nconst logQ = Math.log10(Qnow);\nconst Enow = E0 - (RTF / n) * Math.log(Qnow);\n// animate a probe sweeping the line so MOVE rule holds\nconst sweep = -1 + 2.5 * Math.sin(t * 0.7);\nconst Esweep = E0 + slope * sweep;\nv.dot(sweep, Esweep, { r: 5, fill: H.colors.sub });\nv.dot(logQ, Enow, { r: 7, fill: H.colors.warn });\nv.text(\"E0 = +1.10 V\", -3.8, 1.27);\nH.text(\"Nernst: E = E0 - (RT/nF) ln Q\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Daniell cell Zn|Zn2+||Cu2+|Cu, n=2, T=298 K; x-axis = log10 Q\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Q = [Zn2+]/[Cu2+] = \" + Qnow.toFixed(3) + \" E = \" + Enow.toFixed(3) + \" V\", 24, 74, { color: H.colors.warn, size: 13 });" + }, + { + "id": "apchem-electrolysis", + "area": "AP Chemistry", + "topic": "Electrolysis / Faraday", + "title": "Faraday's law: n = I*t / (n_e*F)", + "equation": "moles_metal = (I * t) / (n_e * F)", + "keywords": [ + "electrolysis", + "faraday's law", + "faraday constant", + "current", + "charge", + "coulombs", + "moles of electrons", + "electroplating", + "I*t/nF", + "copper deposition", + "ap chemistry", + "electrolytic cell", + "mass deposited", + "amperes" + ], + "explanation": "In electrolysis the charge passed, Q = I*t (amperes times seconds = coulombs), determines how much substance is reduced or oxidized. Dividing by Faraday's constant F = 96485 C per mole of electrons gives moles of electrons, and dividing again by n_e (electrons per ion) gives moles of metal: n = I*t/(n_e*F). For Cu2+ + 2e- -> Cu, n_e = 2 and the molar mass is 63.55 g/mol, so mass deposited grows linearly with both current and time. AP free-response problems chain these conversions (current and time -> coulombs -> mol e- -> mol metal -> grams), exactly the straight mass-vs-time line traced here as the run proceeds.", + "bullets": [ + "Charge Q = I*t in coulombs is the bridge from electrical to chemical amounts.", + "Each Cu requires 2 electrons, so divide moles of e- by n_e = 2.", + "Deposited mass rises linearly with time; doubling current doubles the slope.", + "Live readout chains Q -> mol e- -> mol Cu -> grams as the marker climbs." + ], + "params": [ + { + "name": "I", + "label": "current I (A)", + "min": 0, + "max": 5, + "step": 0.1, + "value": 2 + }, + { + "name": "tmax", + "label": "run time t (s)", + "min": 1, + "max": 3600, + "step": 1, + "value": 1800 + } + ], + "code": "H.background();\n// Faraday electrolysis: moles deposited = I*t / (n*F).\n// Example: Cu2+ + 2e- -> Cu, n=2, M(Cu)=63.55 g/mol.\nconst F = 96485; // C/mol e-\nconst n = 2; // electrons per Cu atom\nconst M = 63.55; // g/mol Cu\nconst I = Math.max(0, P.I); // current in A\nconst tmax = Math.max(1, P.tmax); // total run time in s\n// animate elapsed time looping 0..tmax\nconst elapsed = (t % 6) / 6 * tmax;\nconst charge = I * elapsed; // Q = I t\nconst molEl = charge / F; // moles of electrons\nconst molCu = molEl / n; // moles of Cu\nconst massCu = molCu * M; // grams deposited\n// growing bar of deposited mass; full bar = mass at tmax\nconst massFull = (I * tmax / F) / n * M;\nconst v = H.plot2d({ xMin: 0, xMax: Math.max(tmax, 1), yMin: 0, yMax: Math.max(massFull * 1.2, 0.01) });\nv.grid(); v.axes();\n// curve mass(t') = I t' /(nF) * M -> straight line through origin\nv.fn(x => (I * x / F) / n * M, { color: H.colors.accent, width: 3 });\nv.dot(elapsed, massCu, { r: 7, fill: H.colors.warn });\nv.line(elapsed, 0, elapsed, massCu, { color: H.colors.sub, width: 1, dash: [3, 3] });\nv.text(\"Cu2+ + 2e- -> Cu\", 0.2 * Math.max(tmax,1), Math.max(massFull * 1.1, 0.009));\nH.text(\"Faraday: n_Cu = I*t / (n*F)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"I=\" + I.toFixed(1) + \" A, t up to \" + tmax.toFixed(0) + \" s; F=96485 C/mol, n=2\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"t=\" + elapsed.toFixed(0) + \" s Q=\" + charge.toFixed(0) + \" C mol Cu=\" + molCu.toFixed(4) + \" mass=\" + massCu.toFixed(3) + \" g\", 24, 74, { color: H.colors.warn, size: 13 });" + }, + { + "id": "apchem-oxidation-states", + "area": "AP Chemistry", + "topic": "Oxidation states", + "title": "Oxidation numbers: sum = overall charge", + "equation": "sum(n_atom * ox_atom) = overall charge", + "keywords": [ + "oxidation states", + "oxidation number", + "redox", + "assigning oxidation numbers", + "oxidation rules", + "sulfur oxidation state", + "charge balance", + "h is +1", + "o is -2", + "ap chemistry", + "redox reactions", + "oxidizing agent", + "h2so4", + "so2" + ], + "explanation": "Oxidation numbers are assigned by rules: oxygen is usually -2, hydrogen bonded to nonmetals is +1, and the sum of all oxidation numbers must equal the species' overall charge (zero for a neutral compound). The unknown atom's oxidation number is then forced algebraically; for sulfur it ranges from -2 in H2S, to 0 in elemental S8, to +4 in SO2, up to +6 in SO3 and H2SO4. AP exams use these to identify what is oxidized vs reduced and to balance redox half-reactions. Here the slider steps through a series of sulfur compounds and the pulsing circle shows S's computed oxidation number while the oxygens and hydrogens hold their rule-based values.", + "bullets": [ + "Rule values O = -2 and H = +1 are fixed; the highlighted atom is solved for.", + "For a neutral compound all oxidation numbers must sum to 0.", + "Sulfur spans -2 (H2S), 0 (S8), +4 (SO2), +6 (SO3, H2SO4) across the slider.", + "The pulsing ring marks the atom whose number you must calculate." + ], + "params": [ + { + "name": "compound", + "label": "compound (0-4: H2S,S8,SO2,SO3,H2SO4)", + "min": 0, + "max": 4, + "step": 1, + "value": 4 + } + ], + "code": "H.background();\n// Oxidation states: rules (O = -2, H = +1) force the central atom's number.\n// Slider picks a sulfur compound; oxidation number of S is computed so the\n// sum of oxidation numbers = overall charge (here neutral, sum = 0).\n// Compounds: H2S, S8, SO2, SO3, H2SO4 with S oxidation states -2,0,+4,+6,+6.\nconst comps = [\n { f: \"H2S\", parts: [[\"H\", 2, +1], [\"S\", 1, null]], charge: 0 },\n { f: \"S8\", parts: [[\"S\", 8, null]], charge: 0 },\n { f: \"SO2\", parts: [[\"O\", 2, -2], [\"S\", 1, null]], charge: 0 },\n { f: \"SO3\", parts: [[\"O\", 3, -2], [\"S\", 1, null]], charge: 0 },\n { f: \"H2SO4\", parts: [[\"H\", 2, +1], [\"O\", 4, -2], [\"S\", 1, null]], charge: 0 }\n];\nconst idx = Math.max(0, Math.min(comps.length - 1, Math.round(P.compound)));\nconst c = comps[idx];\n// solve for the unknown (S) oxidation number\nlet known = 0, sCount = 1;\nfor (const p of c.parts) { if (p[2] !== null) known += p[1] * p[2]; else sCount = p[1]; }\nconst oxS = (c.charge - known) / sCount;\n// layout boxes for each element, animate a highlight that sweeps the unknown\nconst cx = H.W / 2, cy = H.H / 2;\nH.rect(cx - 220, cy - 70, 440, 140, { fill: H.colors.panel, stroke: H.colors.grid, width: 1, radius: 10 });\nlet x = cx - 170;\nfor (const p of c.parts) {\n const val = p[2] !== null ? p[2] : oxS;\n const col = p[2] === null ? H.colors.warn : H.colors.accent;\n const pulse = p[2] === null ? (0.5 + 0.5 * Math.sin(t * 3)) : 1;\n H.circle(x, cy + 8, 26, { fill: H.colors.bg, stroke: col, width: 2 + 2 * pulse });\n H.text(p[0] + (p[1] > 1 ? p[1] : \"\"), x, cy + 8, { color: H.colors.ink, size: 20, weight: 700, align: \"center\", baseline: \"middle\" });\n const sign = val >= 0 ? \"+\" : \"\";\n H.text(sign + val, x, cy - 40, { color: col, size: 16, weight: 700, align: \"center\" });\n x += 110;\n}\nH.text(\"Oxidation numbers: sum = overall charge\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Rules: O = -2, H = +1; solve the rest. Compound: \" + c.f, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"S in \" + c.f + \" = \" + (oxS >= 0 ? \"+\" : \"\") + oxS + \" (sum of all = \" + c.charge + \")\", 24, 74, { color: H.colors.warn, size: 13 });" + }, + { + "id": "apchem-percent-composition", + "area": "AP Chemistry", + "topic": "Percent composition", + "title": "Percent composition: %X = n_X*A_X / M * 100", + "equation": "percent_X = (n_X * A_X) / M_molar * 100", + "keywords": [ + "percent composition", + "mass percent", + "molar mass", + "atomic mass", + "empirical formula", + "mass fraction", + "percent by mass", + "n_X*A_X/M", + "composition by mass", + "ap chemistry", + "stoichiometry", + "molecular formula", + "carbon hydrogen oxygen", + "gram per mole" + ], + "explanation": "Percent composition gives the mass fraction of each element: %X = (number of X atoms times its atomic mass) / molar mass times 100. The three percentages must sum to 100% because every gram of the compound belongs to one of the elements. Here a CH2O-style family C(a)H(2a)O(c) is built from the slider subscripts, the molar mass M is the sum of all atomic-mass contributions, and the stacked bar's widths are proportional to %C, %H, and %O. AP students use this both to verify a proposed formula and, run in reverse with the empirical-formula method, to deduce a formula from measured mass percents.", + "bullets": [ + "Each element's bar width = its mass contribution n_X*A_X divided by molar mass M.", + "Percentages always sum to 100% (shown in the readout).", + "Heavier or more-numerous atoms claim a larger share of the mass.", + "Changing the C or O subscript shifts M and re-partitions the whole bar." + ], + "params": [ + { + "name": "a", + "label": "carbon count a", + "min": 1, + "max": 12, + "step": 1, + "value": 6 + }, + { + "name": "c", + "label": "oxygen count c", + "min": 0, + "max": 12, + "step": 1, + "value": 6 + } + ], + "code": "H.background();\n// Percent composition: mass% of element X = (n_X * A_X) / M_molar * 100%.\n// Compound family C_a H_b O_c (e.g. glucose-like). Sliders set subscripts a,c\n// (carbon and oxygen counts); hydrogen fixed at 2*a (a CH2O-style unit).\nconst Ac = 12.011, Ah = 1.008, Ao = 15.999; // atomic masses g/mol\nconst a = Math.max(1, Math.round(P.a)); // # carbon\nconst c = Math.max(0, Math.round(P.c)); // # oxygen\nconst b = 2 * a; // # hydrogen (CH2O repeat)\nconst mC = a * Ac, mH = b * Ah, mO = c * Ao;\nconst M = Math.max(1e-6, mC + mH + mO); // molar mass\nconst pC = mC / M * 100, pH = mH / M * 100, pO = mO / M * 100;\n// animated stacked bar: a sweeping marker rides the cumulative boundaries\nconst x0 = 120, x1 = H.W - 60, y = H.H / 2, bh = 70;\nconst segs = [\n { p: pC, col: H.colors.accent, lab: \"C\" },\n { p: pH, col: H.colors.good, lab: \"H\" },\n { p: pO, col: H.colors.warn, lab: \"O\" }\n];\nlet cx = x0;\nfor (const s of segs) {\n const w = (s.p / 100) * (x1 - x0);\n H.rect(cx, y - bh / 2, Math.max(0, w), bh, { fill: s.col, stroke: H.colors.bg, width: 1 });\n if (w > 24) H.text(s.lab + \" \" + s.p.toFixed(1) + \"%\", cx + w / 2, y, { color: H.colors.bg, size: 13, weight: 700, align: \"center\", baseline: \"middle\" });\n cx += w;\n}\n// sweeping probe along the bar\nconst px = x0 + (0.5 + 0.5 * Math.sin(t * 0.8)) * (x1 - x0);\nH.line(px, y - bh / 2 - 14, px, y + bh / 2 + 14, { color: H.colors.ink, width: 2 });\nconst formula = \"C\" + a + \"H\" + b + (c > 0 ? \"O\" + c : \"\");\nH.text(\"Percent composition: %X = n_X * A_X / M * 100\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Formula \" + formula + \", M = \" + M.toFixed(2) + \" g/mol\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"%C=\" + pC.toFixed(1) + \" %H=\" + pH.toFixed(1) + \" %O=\" + pO.toFixed(1) + \" (sum=\" + (pC + pH + pO).toFixed(1) + \")\", 24, 74, { color: H.colors.warn, size: 13 });" + }, + { + "id": "apchem-stoichiometry-ratio", + "area": "AP Chemistry", + "topic": "Mole ratio stoichiometry", + "title": "Mole ratio: n_product = n_reactant * (coef_p / coef_r)", + "equation": "n_NH3 = n_H2 * (coef_NH3 / coef_H2)", + "keywords": [ + "stoichiometry", + "mole ratio", + "balanced equation", + "limiting reactant", + "coefficients", + "mole to mole", + "haber process", + "ammonia synthesis", + "reaction extent", + "product yield", + "ap chemistry", + "n2 + h2", + "mole mole conversion", + "theoretical yield" + ], + "explanation": "The coefficients of a balanced equation give the mole ratio that converts moles of one species into another: for N2 + b H2 -> 2 NH3, the moles of NH3 formed equal moles of H2 consumed times (2/b). With N2 in excess, H2 is the limiting reactant, so the maximum NH3 is set entirely by the H2 supply and ratio. As the reaction extent runs from 0 to 1, H2 falls linearly while NH3 rises linearly, meeting the stoichiometric ratio at completion. AP problems test exactly this mole-to-mole step (and recognizing the limiting reactant), visualized here as the crossing H2-remaining and NH3-formed lines.", + "bullets": [ + "NH3 made = H2 used times the coefficient ratio 2/b from the balanced equation.", + "H2 is limiting (N2 in excess), so it caps the product amount.", + "As reaction extent goes 0->1, H2 drops and NH3 climbs linearly.", + "Changing coefficient b rebalances the equation and rescales the product line." + ], + "params": [ + { + "name": "b", + "label": "H2 coefficient b", + "min": 1, + "max": 6, + "step": 1, + "value": 3 + }, + { + "name": "molH2", + "label": "moles H2 supplied", + "min": 0, + "max": 10, + "step": 0.1, + "value": 6 + } + ], + "code": "H.background();\n// Mole-ratio stoichiometry: for a A + b B -> products, the moles of product\n// from the limiting reactant follow the balanced coefficients.\n// Reaction: N2 + 3 H2 -> 2 NH3. Slider sets the H2 coefficient b and the\n// supplied moles of H2; N2 assumed in excess so H2 is limiting.\nconst a = 1; // N2 coefficient (fixed)\nconst b = Math.max(1, Math.round(P.b)); // H2 coefficient\nconst prodCoef = 2; // NH3 coefficient (fixed)\nconst molH2 = Math.max(0, P.molH2); // supplied moles of H2\n// product moles = molH2 * (prodCoef / b)\nconst molNH3full = molH2 * (prodCoef / b);\n// animate the reaction filling up over a loop\nconst prog = (t % 5) / 5; // 0..1 extent of reaction\nconst molNH3 = molNH3full * prog;\nconst H2used = molH2 * prog;\nconst v = H.plot2d({ xMin: 0, xMax: 1, yMin: 0, yMax: Math.max(molH2, molNH3full, 0.01) * 1.2 });\nv.grid(); v.axes();\n// H2 consumed (down) and NH3 formed (up) vs reaction extent\nv.fn(x => molH2 * (1 - x), { color: H.colors.warn, width: 3 }); // H2 remaining\nv.fn(x => molNH3full * x, { color: H.colors.good, width: 3 }); // NH3 formed\nv.dot(prog, molH2 - H2used, { r: 6, fill: H.colors.warn });\nv.dot(prog, molNH3, { r: 6, fill: H.colors.good });\nv.text(\"N2 + \" + b + \" H2 -> 2 NH3\", 0.05, Math.max(molH2, molNH3full, 0.01) * 1.12);\nH.legend([{ label: \"H2 left\", color: H.colors.warn }, { label: \"NH3 made\", color: H.colors.good }], H.W - 170, 70);\nH.text(\"Mole ratio: n_NH3 = n_H2 * (2 / \" + b + \")\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Limiting H2 = \" + molH2.toFixed(2) + \" mol; x-axis = reaction extent (0..1)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"extent=\" + prog.toFixed(2) + \" NH3=\" + molNH3.toFixed(2) + \" mol (max \" + molNH3full.toFixed(2) + \" mol)\", 24, 74, { color: H.colors.warn, size: 13 });" + }, + { + "id": "apchem-acid-strength", + "area": "AP Chemistry", + "topic": "Acid strength & Ka", + "title": "Acid strength: Ka and pH of a weak acid (Ka = [H+][A-]/[HA])", + "equation": "Ka = [H+][A-]/[HA]; for 0.10 M HA: [H+] = (-Ka + sqrt(Ka^2 + 4*Ka*C))/2, pH = -log[H+]", + "keywords": [ + "acid strength", + "ka", + "pka", + "weak acid", + "strong acid", + "ph", + "dissociation", + "acid ionization", + "ka equilibrium", + "ap chemistry" + ], + "explanation": "The acid-dissociation constant Ka measures how far a weak acid HA ionizes into H+ and A-: a larger Ka means more dissociation, a stronger acid, and a lower pH. For a 0.10 M solution the exact [H+] is the positive root of Ka = x^2/(C - x), i.e. x = (-Ka + sqrt(Ka^2 + 4*Ka*C))/2, and pH = -log[H+]; pKa = -log Ka, so a smaller pKa is a stronger acid. On the AP exam you compare relative acid strengths from Ka/pKa values, set up an ICE table for the weak-acid equilibrium, and recognize that strong acids dissociate essentially completely while weak acids do not. The animation plots pH versus pKa for a fixed 0.10 M acid and marks where this acid sits as you change Ka.", + "bullets": [ + "Larger Ka = stronger acid = more H+ produced = lower pH.", + "pKa = -log Ka, so a smaller pKa marks a stronger acid.", + "The exact [H+] solves Ka = x^2/(C - x), not the shortcut x = sqrt(Ka*C).", + "The marked point shows this acid's pH on the pKa axis for 0.10 M." + ], + "params": [ + { + "name": "Ka", + "label": "Ka (acid dissociation const)", + "min": 1e-07, + "max": 1, + "step": 1e-07, + "value": 1.8e-05 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 14, yMin: 0, yMax: 1, pad: 60 });\nv.grid(); v.axes();\nconst Ka = Math.max(1e-12, P.Ka);\nconst C = 0.10;\nconst Hp = (-Ka + Math.sqrt(Ka * Ka + 4 * Ka * C)) / 2;\nconst pH = -Math.log(Math.max(1e-14, Hp)) / Math.LN10;\nconst pKa = -Math.log(Ka) / Math.LN10;\nv.fn(x => {\n const k = Math.pow(10, -x);\n const h = (-k + Math.sqrt(k * k + 4 * k * C)) / 2;\n const ph = -Math.log(Math.max(1e-14, h)) / Math.LN10;\n return ph / 14;\n}, { color: H.colors.grid, width: 2 });\nconst sweep = pKa + 6 * Math.sin(t * 0.6);\nv.dot(sweep, 0.5, { r: 4, fill: H.colors.sub });\nv.dot(pKa, pH / 14, { r: 8, fill: H.colors.accent });\nv.line(pKa, 0, pKa, pH / 14, { color: H.colors.accent2, width: 2, dash: [4, 4] });\nv.text(\"pKa = \" + pKa.toFixed(2), pKa, pH / 14 + 0.08);\nH.text(\"Acid strength: Ka -> pH of 0.10 M HA\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Larger Ka = stronger acid = more dissociation = lower pH\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Ka = \" + Ka.toExponential(2) + \" pKa = \" + pKa.toFixed(2), 24, 74, { color: H.colors.accent, size: 14 });\nH.text(\"[H+] = \" + Hp.toExponential(2) + \" M pH = \" + pH.toFixed(2), 24, 94, { color: H.colors.accent2, size: 14 });" + }, + { + "id": "apchem-equilibrium-pressure", + "area": "AP Chemistry", + "topic": "Kp vs Kc", + "title": "Gas equilibrium: Kp = Kc (RT)^dn", + "equation": "Kp = Kc * (R*T)^dn, dn = (moles gas products) - (moles gas reactants), R = 0.08206 L*atm/(mol*K)", + "keywords": [ + "kp", + "kc", + "kp vs kc", + "equilibrium constant", + "delta n", + "gas equilibrium", + "rt to the delta n", + "pressure equilibrium", + "ideal gas equilibrium", + "ap chemistry" + ], + "explanation": "For a gas-phase equilibrium, the pressure constant Kp and the concentration constant Kc are related by Kp = Kc*(RT)^dn, where dn is the change in moles of gas (products minus reactants) and R = 0.08206 L*atm/(mol*K). When dn = 0 the two are equal; when dn > 0 Kp exceeds Kc, and when dn < 0 Kp is smaller, with the gap widening as temperature rises. On the AP exam you must use this conversion correctly, count only gaseous species when finding dn, and keep R and the units (atm, L, K) consistent. The animation sweeps T and plots Kp = Kc*(RT)^dn so you see how the choice of dn bends the curve up, flat, or down.", + "bullets": [ + "dn counts gas moles only: products minus reactants.", + "dn = 0 makes Kp = Kc exactly, independent of temperature.", + "dn > 0 gives Kp > Kc; dn < 0 gives Kp < Kc.", + "Use R = 0.08206 L*atm/(mol*K) so Kp comes out in atm units." + ], + "params": [ + { + "name": "dn", + "label": "dn (change in moles gas)", + "min": -2, + "max": 2, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst dn = P.dn;\nconst T = 298 + 200 * Math.sin(t * 0.5);\nconst R = 0.08206;\nconst Kc = 0.50;\nconst RT = R * T;\nconst Kp = Kc * Math.pow(RT, dn);\nconst v = H.plot2d({ xMin: 250, xMax: 700, yMin: 0, yMax: Math.max(2, Kc * Math.pow(R * 700, Math.max(0, dn)) * 1.2), pad: 64 });\nv.grid(); v.axes();\nv.fn(x => Kc * Math.pow(R * x, dn), { color: H.colors.accent, width: 3 });\nv.dot(T, Kp, { r: 8, fill: H.colors.accent2 });\nv.line(T, 0, T, Kp, { color: H.colors.sub, width: 1, dash: [4, 4] });\nH.text(\"Kp = Kc (RT)^dn\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"dn = moles gas products - reactants; R = 0.08206 L atm/mol K\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"dn = \" + dn.toFixed(0) + \" T = \" + T.toFixed(0) + \" K\", 24, 74, { color: H.colors.accent, size: 14 });\nH.text(\"Kc = \" + Kc.toFixed(2) + \" Kp = \" + Kp.toFixed(3), 24, 94, { color: H.colors.accent2, size: 14 });\nH.text(dn === 0 ? \"dn = 0 -> Kp = Kc\" : (dn > 0 ? \"dn > 0 -> Kp > Kc\" : \"dn < 0 -> Kp < Kc\"), 24, 114, { color: H.colors.good, size: 13 });" + }, + { + "id": "apchem-reaction-energy-profile", + "area": "AP Chemistry", + "topic": "Energy profile of a reaction", + "title": "Reaction energy profile: Ea barrier and dH", + "equation": "dH = E(products) - E(reactants); Ea(reverse) = Ea(forward) - dH", + "keywords": [ + "energy profile", + "reaction coordinate diagram", + "activation energy", + "ea", + "transition state", + "enthalpy change", + "delta h", + "exothermic", + "potential energy diagram", + "ap chemistry" + ], + "explanation": "A reaction-coordinate diagram tracks potential energy as reactants climb over a transition state (the peak) to become products. The activation energy Ea is the height of the barrier from reactants to the transition state, while dH = E(products) - E(reactants) is negative for this exothermic reaction, so products sit lower than reactants. The reverse barrier is Ea(reverse) = Ea(forward) - dH, which is larger than the forward barrier when dH is negative. On the AP exam you read Ea and dH off such a diagram, connect a larger Ea to a slower rate, and remember that a catalyst lowers Ea without changing dH. The animation marches a marker over the barrier and labels Ea, dH, and the reverse barrier.", + "bullets": [ + "Ea is the climb from reactants up to the transition state (TS).", + "dH = products - reactants; here it is negative (exothermic).", + "Reverse barrier Ea(rev) = Ea(fwd) - dH, larger when dH < 0.", + "A catalyst would lower the peak (Ea) but leave dH unchanged." + ], + "params": [ + { + "name": "Ea", + "label": "Ea forward (kJ/mol)", + "min": 5, + "max": 150, + "step": 5, + "value": 75 + } + ], + "code": "H.background();\nconst W = H.W, Hh = H.H;\nconst Ea = P.Ea;\nconst dH = -40;\nconst x0 = 90, x1 = W - 90;\nconst baseY = Hh - 120;\nconst kjToPx = 1.6;\nconst yReact = baseY;\nconst yProd = baseY + dH * kjToPx;\nconst yPeak = baseY - Ea * kjToPx;\nconst pts = [];\nconst N = 120;\nfor (let i = 0; i <= N; i++) {\n const f = i / N;\n const px = x0 + f * (x1 - x0);\n let py;\n if (f < 0.2) py = yReact;\n else if (f > 0.8) py = yProd;\n else {\n const u = (f - 0.2) / 0.6;\n const bump = Math.sin(Math.PI * u);\n const baseLine = yReact + (yProd - yReact) * u;\n py = baseLine - (baseLine - yPeak) * bump;\n }\n pts.push([px, py]);\n}\nH.path(pts, { color: H.colors.accent, width: 3 });\nH.line(x0, 50, x0, Hh - 60, { color: H.colors.axis, width: 2 });\nH.line(x0, Hh - 60, x1, Hh - 60, { color: H.colors.axis, width: 2 });\nH.text(\"energy (kJ/mol)\", x0 + 6, 46, { color: H.colors.sub, size: 12 });\nH.text(\"reaction coordinate\", x1 - 10, Hh - 40, { color: H.colors.sub, size: 12, align: \"right\" });\nconst f = 0.2 + 0.6 * (0.5 - 0.5 * Math.cos(t * 1.2));\nconst idx = Math.min(N, Math.max(0, Math.round(f * N)));\nconst mk = pts[idx];\nH.circle(mk[0], mk[1], 9, { fill: H.colors.warn, stroke: H.colors.ink, width: 1 });\nH.line(x0, yReact, x0 + 70, yReact, { color: H.colors.grid, width: 1, dash: [4, 4] });\nH.line(x1 - 70, yProd, x1, yProd, { color: H.colors.grid, width: 1, dash: [4, 4] });\nH.line(x0, yPeak, x1, yPeak, { color: H.colors.grid, width: 1, dash: [3, 5] });\nH.text(\"reactants\", x0 + 10, yReact - 10, { color: H.colors.good, size: 12 });\nH.text(\"TS\", (x0 + x1) / 2, yPeak - 12, { color: H.colors.accent2, size: 12, align: \"center\" });\nH.text(\"products\", x1 - 10, yProd - 10, { color: H.colors.violet, size: 12, align: \"right\" });\nH.text(\"Energy profile: Ea and dH\", 24, 28, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Ea = barrier height; dH = products - reactants\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"Ea(fwd) = \" + Ea.toFixed(0) + \" kJ/mol dH = \" + dH.toFixed(0) + \" kJ/mol (exothermic)\", 24, 72, { color: H.colors.accent, size: 13 });\nH.text(\"Ea(rev) = \" + (Ea - dH).toFixed(0) + \" kJ/mol\", 24, 92, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "apchem-molar-mass-gas", + "area": "AP Chemistry", + "topic": "Molar mass of a gas", + "title": "Molar mass of a gas: M = mRT / PV", + "equation": "PV = nRT, n = PV/(R*T), M = m/n = m*R*T/(P*V), R = 0.08206 L*atm/(mol*K)", + "keywords": [ + "molar mass of a gas", + "ideal gas law", + "pv = nrt", + "m = mrt/pv", + "moles of gas", + "gas density", + "molar mass from gas", + "ideal gas", + "stoichiometry", + "ap chemistry" + ], + "explanation": "The ideal gas law PV = nRT lets you count moles of a gas from measured P, V, and T as n = PV/(RT); dividing the weighed mass m by n gives the molar mass M = m/n = mRT/(PV). With R = 0.08206 L*atm/(mol*K), pressure in atm, volume in L, and temperature in K, M comes out in g/mol. On the AP exam this is the standard way to identify an unknown gas or vapor: raising T or V (at fixed mass and P) lowers n and raises the computed M, while raising P or mass raises n's effect accordingly. The animation shows gas particles in a fixed box with the count tied to n, and reports n and M live as you adjust P, V, T, and mass.", + "bullets": [ + "n = PV/(RT) converts a measured P, V, T into moles of gas.", + "M = m/n = mRT/(PV); divide the weighed mass by those moles.", + "Keep units consistent: atm, L, K with R = 0.08206 for g/mol.", + "Particle count tracks n: raising T or V at fixed P lowers n." + ], + "params": [ + { + "name": "P", + "label": "P (atm)", + "min": 0.2, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "V", + "label": "V (L)", + "min": 0.2, + "max": 5, + "step": 0.1, + "value": 1 + }, + { + "name": "T", + "label": "T (K)", + "min": 100, + "max": 600, + "step": 5, + "value": 300 + }, + { + "name": "mass", + "label": "mass (g)", + "min": 0.1, + "max": 5, + "step": 0.1, + "value": 1.2 + } + ], + "code": "H.background();\nconst Pp = Math.max(0.1, P.P);\nconst Vv = Math.max(0.1, P.V);\nconst Tt = Math.max(1, P.T);\nconst mm = Math.max(0.01, P.mass);\nconst R = 0.08206;\nconst n = (Pp * Vv) / (R * Tt);\nconst M = mm / Math.max(1e-9, n);\nconst bx = 480, by = 110, bw = 360, bh = 360;\nH.rect(bx, by, bw, bh, { stroke: H.colors.axis, width: 2 });\nconst Np = Math.max(4, Math.min(60, Math.round(n * 30)));\nfor (let i = 0; i < Np; i++) {\n const ph = i * 1.7;\n const sx = bx + 20 + ((i * 53) % (bw - 40));\n const sy = by + 20 + ((i * 97) % (bh - 40));\n const px = sx + (bw - 80) * 0.4 * Math.sin(t * (1 + (i % 5) * 0.2) + ph);\n const py = sy + (bh - 80) * 0.3 * Math.cos(t * (1.2 + (i % 4) * 0.25) + ph * 1.3);\n H.circle(Math.max(bx + 6, Math.min(bx + bw - 6, px)), Math.max(by + 6, Math.min(by + bh - 6, py)), 5, { fill: H.colors.accent2 });\n}\nH.text(\"ideal gas: n = PV/RT\", bx + bw / 2, by - 14, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(\"Molar mass of a gas: M = mRT / PV\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Weigh a gas sample, measure P, V, T -> find M\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"P = \" + Pp.toFixed(2) + \" atm\", 24, 84, { color: H.colors.accent, size: 14 });\nH.text(\"V = \" + Vv.toFixed(2) + \" L\", 24, 108, { color: H.colors.accent, size: 14 });\nH.text(\"T = \" + Tt.toFixed(0) + \" K\", 24, 132, { color: H.colors.accent, size: 14 });\nH.text(\"mass = \" + mm.toFixed(2) + \" g\", 24, 156, { color: H.colors.accent, size: 14 });\nH.text(\"n = PV/RT = \" + n.toFixed(4) + \" mol\", 24, 196, { color: H.colors.good, size: 15 });\nH.text(\"M = \" + M.toFixed(1) + \" g/mol\", 24, 222, { color: H.colors.warn, size: 16, weight: 700 });" + }, + { + "id": "apchem-spectrophotometry-curve", + "area": "AP Chemistry", + "topic": "Calibration curve", + "title": "Beer's law calibration: A = eps*b*c, read c = A/slope", + "equation": "A = eps*b*c (Beer-Lambert); unknown c = A(unknown)/slope, slope = eps*b", + "keywords": [ + "calibration curve", + "beer's law", + "beer lambert law", + "spectrophotometry", + "absorbance", + "concentration", + "epsilon", + "a = ebc", + "molar absorptivity", + "ap chemistry" + ], + "explanation": "Beer's law A = eps*b*c says absorbance is directly proportional to concentration, where eps is molar absorptivity and b is the path length, so a plot of A versus c for standard solutions is a straight line through the origin with slope eps*b. To find an unknown you measure its absorbance, then read across to the calibration line and down to get c = A/slope. On the AP exam you build the line from standards, confirm it passes through the origin, and divide an unknown's absorbance by the slope to get its concentration. The animation draws the calibration line through the standard points and traces the read-off from the unknown's absorbance down to its concentration.", + "bullets": [ + "A = eps*b*c: absorbance rises linearly with concentration.", + "The calibration line passes through the origin; slope = eps*b.", + "Read an unknown by going from its A across to the line, then down.", + "Unknown concentration c = A(unknown) / slope." + ], + "params": [ + { + "name": "Au", + "label": "A unknown (absorbance)", + "min": 0.05, + "max": 0.95, + "step": 0.01, + "value": 0.4 + } + ], + "code": "H.background();\nconst slope = 2.0;\nconst Amax = 1.0;\nconst v = H.plot2d({ xMin: 0, xMax: 0.5, yMin: 0, yMax: Amax, pad: 60 });\nv.grid(); v.axes();\nv.fn(x => slope * x, { color: H.colors.accent, width: 3 });\nconst stds = [0.05, 0.1, 0.2, 0.3, 0.4];\nfor (let i = 0; i < stds.length; i++) v.dot(stds[i], slope * stds[i], { r: 5, fill: H.colors.sub });\nlet Au = Math.min(Amax, Math.max(0, P.Au));\nAu = Math.min(slope * 0.5, Math.max(0.001, Au + 0.03 * Math.sin(t * 0.8)));\nconst cUnk = Au / slope;\nv.line(0, Au, cUnk, Au, { color: H.colors.accent2, width: 2, dash: [5, 4] });\nv.line(cUnk, Au, cUnk, 0, { color: H.colors.accent2, width: 2, dash: [5, 4] });\nv.dot(cUnk, Au, { r: 8, fill: H.colors.warn });\nv.text(\"unknown\", cUnk, Au + 0.05);\nH.text(\"Calibration curve: A = eps b c (Beer's law)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Absorbance is linear in concentration; read c off the line\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"slope (eps*b) = \" + slope.toFixed(2) + \" /M\", 24, 74, { color: H.colors.accent, size: 14 });\nH.text(\"A(unknown) = \" + Au.toFixed(3), 24, 94, { color: H.colors.accent2, size: 14 });\nH.text(\"c = A/slope = \" + cUnk.toFixed(4) + \" mol/L\", 24, 114, { color: H.colors.warn, size: 15, weight: 700 });" + }, + { + "id": "apchem-bond-dipole-molecule", + "area": "AP Chemistry", + "topic": "Net molecular dipole", + "title": "Net dipole = vector sum of bond dipoles", + "equation": "mu_net = vector sum of bond dipole vectors", + "keywords": [ + "molecular dipole", + "net dipole", + "bond dipole", + "polar molecule", + "nonpolar molecule", + "molecular polarity", + "vector sum", + "electronegativity", + "symmetry", + "vsepr", + "ap chemistry", + "ap chem" + ], + "explanation": "A molecule's overall polarity is the VECTOR sum of its individual bond dipoles, which point from the less electronegative atom toward the more electronegative one. When the molecular geometry is symmetric (e.g. linear AX2 at 180 degrees, like CO2), equal and opposite bond dipoles cancel and the molecule is nonpolar even though the bonds are polar; bending the geometry (like the ~104.5 degree bend in water) leaves a nonzero net dipole and a polar molecule. On the AP exam you must justify polarity from geometry, not just from bond electronegativity. The animation rotates the bond angle: the orange bond-dipole arrows stay fixed in size, but their vector sum (green) collapses to zero at 180 degrees and grows as the molecule bends.", + "bullets": [ + "Orange arrows are the two bond dipoles, point toward the more electronegative atoms.", + "At 180 degrees the bond dipoles are opposite and cancel -> NONPOLAR (like CO2).", + "Bending the angle leaves a green resultant arrow -> POLAR (like H2O).", + "Polarity depends on GEOMETRY/symmetry, not on bond polarity alone." + ], + "params": [ + { + "name": "bond", + "label": "bond angle (deg)", + "min": 60, + "max": 180, + "step": 1, + "value": 104 + } + ], + "code": "H.background();\nconst ang = (P.bond * Math.PI / 180);\nconst cx = H.W * 0.5, cy = H.H * 0.55, L = 130;\nconst dipMag = 1.0;\nconst half = ang / 2;\nconst ax = -Math.sin(half), ay = Math.cos(half);\nconst bx = Math.sin(half), by = Math.cos(half);\nconst netx = dipMag * (ax + bx);\nconst nety = dipMag * (ay + by);\nconst net = Math.sqrt(netx * netx + nety * nety);\nconst wob = 1 + 0.12 * Math.sin(t * 1.5);\nconst cAx = cx + L * ax, cAy = cy - L * ay;\nconst cBx = cx + L * bx, cBy = cy - L * by;\nH.line(cx, cy, cAx, cAy, { color: H.colors.grid, width: 3 });\nH.line(cx, cy, cBx, cBy, { color: H.colors.grid, width: 3 });\nH.circle(cx, cy, 18, { fill: H.colors.accent, stroke: H.colors.ink, width: 2 });\nH.text(\"X\", cx, cy, { color: H.colors.bg, size: 14, align: \"center\", baseline: \"middle\", weight: 700 });\nH.circle(cAx, cAy, 14, { fill: H.colors.violet });\nH.circle(cBx, cBy, 14, { fill: H.colors.violet });\nconst s = 70 * wob;\nH.arrow(cAx, cAy, cAx - s * ax, cAy + s * ay, { color: H.colors.warn, width: 3 });\nH.arrow(cBx, cBy, cBx - s * bx, cBy + s * by, { color: H.colors.warn, width: 3 });\nconst ns = net * 70 * wob;\nconst polar = net > 0.05;\nif (polar) {\n H.arrow(cx, cy, cx - ns * (netx / (net || 1)), cy + ns * (nety / (net || 1)), { color: H.colors.good, width: 5 });\n}\nH.text(\"Net molecular dipole = vector sum of bond dipoles\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Bonds add as VECTORS; symmetry can make them cancel.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"bond angle = \" + P.bond.toFixed(0) + \" deg\", 24, 90, { color: H.colors.sub, size: 13 });\nH.text(\"|net dipole| = \" + net.toFixed(2) + \" -> \" + (polar ? \"POLAR\" : \"NONPOLAR\"), 24, 110, { color: polar ? H.colors.good : H.colors.sub, size: 13 });\nH.legend([{ label: \"bond dipole\", color: H.colors.warn }, { label: \"net dipole\", color: H.colors.good }], 24, 130);" + }, + { + "id": "apchem-effusion-animation", + "area": "AP Chemistry", + "topic": "Effusion rates", + "title": "Graham's law: rate_A/rate_B = sqrt(M_B/M_A)", + "equation": "rate_A / rate_B = sqrt(M_B / M_A)", + "keywords": [ + "effusion", + "graham's law", + "grahams law", + "effusion rate", + "molar mass", + "rms speed", + "kinetic molecular theory", + "gas escape", + "root mean square", + "lighter gas faster", + "ap chemistry", + "ap chem" + ], + "explanation": "At a given temperature all gases have the same average kinetic energy, so a lighter molecule must move faster: v_rms = sqrt(3RT/M). Because effusion rate is proportional to molecular speed, Graham's law gives rate_A/rate_B = sqrt(M_B/M_A) -- the rates scale with the INVERSE square root of molar mass. Helium (M=4) effuses through a pinhole faster than a heavier gas; quadrupling the molar mass only halves the rate. On the AP exam this appears as ratio calculations and as the explanation for why low-mass gases leak or diffuse fastest. The animation shows He molecules on the left reaching the pinhole more often than the heavier gas on the right, with the live ratio updating as you change the heavy gas's molar mass.", + "bullets": [ + "Equal temperature -> equal average KE, so lighter = faster (v_rms = sqrt(3RT/M)).", + "Effusion rate scales as 1/sqrt(M): rate_A/rate_B = sqrt(M_B/M_A).", + "Left He molecules hit the pinhole more frequently than the heavier gas.", + "Quadrupling molar mass only halves the effusion rate (square-root law)." + ], + "params": [ + { + "name": "mB", + "label": "heavy gas molar mass M_B (g/mol)", + "min": 2, + "max": 146, + "step": 1, + "value": 44 + } + ], + "code": "H.background();\nconst mA = 4;\nconst mB = Math.max(P.mB, 1);\nconst ratio = Math.sqrt(mB / mA);\nconst cx = H.W * 0.5;\nH.line(cx, 80, cx, H.H - 80, { color: H.colors.grid, width: 4 });\nH.circle(cx, H.H * 0.5, 9, { fill: H.colors.bg, stroke: H.colors.panel, width: 0 });\nH.rect(cx - 4, H.H * 0.5 - 18, 8, 36, { fill: H.colors.bg });\nH.text(\"light gas (He, M=4)\", H.W * 0.25, 60, { color: H.colors.accent, size: 13, align: \"center\" });\nH.text(\"heavy gas (M=\" + mB.toFixed(0) + \")\", H.W * 0.75, 60, { color: H.colors.violet, size: 13, align: \"center\" });\nconst nP = 7;\nfor (let i = 0; i < nP; i++) {\n const x = cx - 60 - 80 * Math.abs(Math.sin((t * 1.0 + i) * 0.9 + i));\n const y = 110 + (H.H - 220) * ((i + 0.5) / nP);\n H.circle(Math.max(40, x), y, 7, { fill: H.colors.accent });\n}\nfor (let i = 0; i < nP; i++) {\n const x = cx + 60 + 80 * Math.abs(Math.sin((t * (1 / ratio)) * 0.9 + i * 1.3));\n const y = 110 + (H.H - 220) * ((i + 0.5) / nP);\n H.circle(Math.min(H.W - 40, x), y, 7, { fill: H.colors.violet });\n}\nH.text(\"Graham's law: rate_A / rate_B = sqrt(M_B / M_A)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Lighter molecules move faster, so they effuse faster.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"rate(He) / rate(heavy) = sqrt(\" + mB.toFixed(0) + \"/4) = \" + ratio.toFixed(2) + \"x\", 24, H.H - 50, { color: H.colors.good, size: 14 });\nH.text(\"He effuses \" + ratio.toFixed(2) + \" times faster\", 24, H.H - 30, { color: H.colors.accent, size: 13 });" + }, + { + "id": "apchem-equilibrium-shift-T", + "area": "AP Chemistry", + "topic": "Temperature & equilibrium", + "title": "van 't Hoff: T shifts K (endo vs exo)", + "equation": "ln(K2/K1) = -(dH/R)*(1/T2 - 1/T1)", + "keywords": [ + "le chatelier", + "equilibrium shift", + "temperature equilibrium", + "van't hoff", + "endothermic", + "exothermic", + "equilibrium constant k", + "heat as reactant", + "heat as product", + "delta h", + "ap chemistry", + "ap chem" + ], + "explanation": "Only temperature changes the value of the equilibrium constant K (adding catalyst, pressure, or reactants does not). Treat heat as a reagent: for an ENDOTHERMIC reaction heat is a reactant, so raising T shifts toward products and K increases; for an EXOTHERMIC reaction heat is a product, so raising T shifts toward reactants and K decreases. Quantitatively the van 't Hoff equation ln(K2/K1) = -(dH/R)(1/T2 - 1/T1) ties the direction of the shift to the sign of dH. On the AP exam, justify the direction with Le Chatelier and recognize that K is temperature-dependent. The animation recomputes K(T) and redraws the product/reactant split as you change T and toggle endo/exo.", + "bullets": [ + "Temperature is the ONLY factor that changes the numerical value of K.", + "Endothermic: heat is a reactant, so heating raises K (more products).", + "Exothermic: heat is a product, so heating lowers K (more reactants).", + "Bar split and molecule counts show the product fraction K/(1+K) shift." + ], + "params": [ + { + "name": "T", + "label": "temperature T (K)", + "min": 200, + "max": 800, + "step": 10, + "value": 400 + }, + { + "name": "endo", + "label": "endothermic? (1=yes, 0=exo)", + "min": 0, + "max": 1, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst endo = P.endo > 0.5;\nconst T = Math.max(P.T, 100);\nconst Tref = 400;\nconst dH = endo ? 60000 : -60000;\nconst R = 8.314;\nconst Kref = 1.0;\nconst lnK = Math.log(Kref) - (dH / R) * (1 / T - 1 / Tref);\nconst K = Math.exp(lnK);\nconst frac = K / (1 + K);\nH.text(\"Le Chatelier + van 't Hoff: T shifts K\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Endothermic: heating raises K. Exothermic: heating lowers K.\", 24, 52, { color: H.colors.sub, size: 13 });\nconst bx = 120, by = 110, bw = H.W - 240, bh = 40;\nH.rect(bx, by, bw, bh, { stroke: H.colors.grid, width: 2 });\nconst split = bx + bw * Math.min(0.97, Math.max(0.03, frac));\nH.rect(bx, by, split - bx, bh, { fill: H.colors.accent });\nH.rect(split, by, bx + bw - split, bh, { fill: H.colors.warn });\nH.text(\"products\", bx + (split - bx) / 2, by + bh / 2, { color: H.colors.bg, size: 12, align: \"center\", baseline: \"middle\", weight: 700 });\nH.text(\"reactants\", split + (bx + bw - split) / 2, by + bh / 2, { color: H.colors.bg, size: 12, align: \"center\", baseline: \"middle\", weight: 700 });\nconst pulse = 1 + 0.1 * Math.sin(t * 2);\nconst yA = by + bh + 30;\nconst nMol = 8;\nfor (let i = 0; i < nMol; i++) {\n const onProd = (i / nMol) < frac;\n const baseX = onProd ? (bx + 30 + (i * 37) % ((split - bx) || 1)) : (split + 20 + (i * 31) % ((bx + bw - split) || 1));\n const x = Math.min(bx + bw - 10, Math.max(bx + 10, baseX + 8 * Math.sin(t * 1.3 + i)));\n const y = yA + 18 * Math.sin(t * 1.1 + i * 0.7) + (i % 2) * 24;\n H.circle(x, y, 6 * pulse, { fill: onProd ? H.colors.accent : H.colors.warn });\n}\nH.text(\"type: \" + (endo ? \"ENDOTHERMIC (heat is a reactant)\" : \"EXOTHERMIC (heat is a product)\"), 24, H.H - 90, { color: H.colors.violet, size: 13 });\nH.text(\"T = \" + T.toFixed(0) + \" K\", 24, H.H - 66, { color: H.colors.sub, size: 13 });\nH.text(\"K(T) = \" + K.toFixed(2) + \" (K=1 at 400 K)\", 24, H.H - 44, { color: H.colors.good, size: 14 });\nH.text(\"product fraction = \" + (frac * 100).toFixed(0) + \"%\", 24, H.H - 22, { color: H.colors.accent, size: 13 });" + }, + { + "id": "apchem-acid-base-strength-pH", + "area": "AP Chemistry", + "topic": "Strong vs weak at same concentration", + "title": "Strong vs weak acid pH: weak gives higher pH", + "equation": "strong: pH = -log C ; weak: [H+] = (-Ka + sqrt(Ka^2 + 4*Ka*C))/2", + "keywords": [ + "strong acid", + "weak acid", + "ph comparison", + "acid strength", + "ka", + "percent ionization", + "-log concentration", + "equilibrium acid", + "ice table", + "weak vs strong", + "ap chemistry", + "ap chem" + ], + "explanation": "At the SAME concentration a strong acid and a weak acid give different pH because they ionize to different extents. A strong acid (HCl) ionizes completely, so [H+] = C and pH = -log C; a weak acid only partially ionizes, governed by Ka = [H+][A-]/[HA], so it produces less H+ and therefore a HIGHER pH. Solving the equilibrium gives [H+] = (-Ka + sqrt(Ka^2 + 4*Ka*C))/2. A larger pKa (weaker acid) means less ionization and a pH closer to neutral. On the AP exam this distinguishes 'strong/weak' from 'concentrated/dilute' and underlies titration and percent-ionization problems. The bars show both pH values side by side as you vary concentration and pKa.", + "bullets": [ + "Strong acid ionizes 100%: pH = -log C exactly.", + "Weak acid only partly ionizes via Ka, so it gives LESS H+.", + "Same concentration -> the weak acid always has the HIGHER pH.", + "Strength (Ka/pKa) is NOT the same as concentration (C)." + ], + "params": [ + { + "name": "C", + "label": "concentration C (M)", + "min": 0.001, + "max": 1, + "step": 0.001, + "value": 0.1 + }, + { + "name": "pKa", + "label": "weak acid pKa", + "min": 0, + "max": 12, + "step": 0.1, + "value": 4.7 + } + ], + "code": "H.background();\nconst C = Math.max(P.C, 0.001);\nconst pKa = Math.max(P.pKa, 0);\nconst Ka = Math.pow(10, -pKa);\nconst pCl = -Math.log10(C);\nconst hWeak = (-Ka + Math.sqrt(Ka * Ka + 4 * Ka * C)) / 2;\nconst pWeak = -Math.log10(Math.max(hWeak, 1e-14));\nconst sweep = 0.5 + 0.5 * Math.sin(t * 0.8);\nH.text(\"Strong vs weak acid at the same concentration\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Strong fully ionizes (pH=-log C); weak only partly, so higher pH.\", 24, 52, { color: H.colors.sub, size: 13 });\nfunction bar(x, label, pH, col) {\n const bx = x, by = H.H - 90, bw = 110, maxH = H.H - 220;\n const h = Math.max(4, (pH / 14) * maxH);\n H.rect(bx, by - h, bw, h, { fill: col });\n H.text(label, bx + bw / 2, by + 20, { color: H.colors.sub, size: 13, align: \"center\" });\n H.text(\"pH \" + pH.toFixed(2), bx + bw / 2, by - h - 12, { color: col, size: 14, align: \"center\", weight: 700 });\n}\nbar(H.W * 0.28 - 55, \"strong (HCl)\", pCl, H.colors.warn);\nbar(H.W * 0.62 - 55, \"weak (Ka)\", pWeak, H.colors.accent);\nH.line(H.W - 60, 90, H.W - 60, H.H - 90, { color: H.colors.grid, width: 2 });\nfor (let i = 0; i <= 14; i += 2) {\n const yy = (H.H - 90) - (i / 14) * (H.H - 220);\n H.text(String(i), H.W - 40, yy, { color: H.colors.sub, size: 11, baseline: \"middle\" });\n}\nconst ny = (H.H - 90) - (7 / 14) * (H.H - 220);\nH.line(H.W * 0.18, ny, H.W - 60, ny, { color: H.colors.grid, width: 1, dash: [4, 4] });\nH.text(\"neutral pH 7\", H.W * 0.18, ny - 8, { color: H.colors.sub, size: 11 });\nH.circle(H.W * 0.28, (H.H - 90) - (pCl / 14) * (H.H - 220) * sweep, 5, { fill: H.colors.warn });\nH.text(\"C = \" + C.toFixed(3) + \" M, pKa = \" + pKa.toFixed(1), 24, H.H - 50, { color: H.colors.violet, size: 13 });\nH.text(\"dpH (weak - strong) = +\" + (pWeak - pCl).toFixed(2), 24, H.H - 28, { color: H.colors.good, size: 14 });" + }, + { + "id": "apchem-redox-electron-transfer", + "area": "AP Chemistry", + "topic": "Electron transfer in redox", + "title": "Redox electron transfer: LEO says GER", + "equation": "oxidation: lose n e- ; reduction: gain n e-", + "keywords": [ + "redox", + "oxidation", + "reduction", + "electron transfer", + "leo ger", + "oil rig", + "oxidation state", + "half reaction", + "reducing agent", + "oxidizing agent", + "n electrons", + "ap chemistry", + "ap chem" + ], + "explanation": "A redox reaction is a transfer of electrons: the species that is OXIDIZED loses electrons (its oxidation number rises) and the species that is REDUCED gains them (its oxidation number falls) -- LEO says GER (Lose Electrons = Oxidation, Gain Electrons = Reduction). In Zn + Cu2+ -> Zn2+ + Cu, zinc gives up 2 electrons (oxidation half: Zn -> Zn2+ + 2e-) and Cu2+ accepts them (reduction half: Cu2+ + 2e- -> Cu); the electrons lost must exactly equal the electrons gained, which is how you balance half-reactions. On the AP exam you identify the oxidizing/reducing agents and balance n electrons between halves. The animation sends n electrons from the oxidized atom to the reduced atom, with n set by the slider.", + "bullets": [ + "Oxidation = LOSE electrons (oxidation number increases) -- left atom.", + "Reduction = GAIN electrons (oxidation number decreases) -- right atom.", + "Electrons lost MUST equal electrons gained (balance the n e-).", + "Yellow e- particles flow Zn -> Cu2+ across the bridge each loop." + ], + "params": [ + { + "name": "n", + "label": "electrons transferred n", + "min": 1, + "max": 4, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst nE = Math.max(1, Math.min(4, Math.round(P.n)));\nconst lx = H.W * 0.25, rx = H.W * 0.75, cy = H.H * 0.5;\nH.text(\"Redox: electron transfer (LEO says GER)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Oxidation = LOSE e- (left); Reduction = GAIN e- (right).\", 24, 52, { color: H.colors.sub, size: 13 });\nH.circle(lx, cy, 46, { fill: H.colors.warn, stroke: H.colors.ink, width: 2 });\nH.text(\"Zn\", lx, cy - 6, { color: H.colors.bg, size: 18, align: \"center\", baseline: \"middle\", weight: 700 });\nH.text(\"oxidized\", lx, cy + 70, { color: H.colors.warn, size: 13, align: \"center\" });\nH.text(\"loses e-\", lx, cy + 88, { color: H.colors.sub, size: 12, align: \"center\" });\nH.circle(rx, cy, 46, { fill: H.colors.accent, stroke: H.colors.ink, width: 2 });\nH.text(\"Cu2+\", rx, cy - 6, { color: H.colors.bg, size: 17, align: \"center\", baseline: \"middle\", weight: 700 });\nH.text(\"reduced\", rx, cy + 70, { color: H.colors.accent, size: 13, align: \"center\" });\nH.text(\"gains e-\", rx, cy + 88, { color: H.colors.sub, size: 12, align: \"center\" });\nH.arrow(lx + 50, cy - 60, rx - 50, cy - 60, { color: H.colors.grid, width: 2 });\nH.text(\"e- flow\", (lx + rx) / 2, cy - 72, { color: H.colors.sub, size: 12, align: \"center\" });\nfor (let i = 0; i < nE; i++) {\n const ph = (t * 0.5 + i / nE) % 1;\n const x = lx + 50 + (rx - lx - 100) * ph;\n const y = cy - 60 - 22 * Math.sin(Math.PI * ph);\n H.circle(x, y, 8, { fill: H.colors.yellow, stroke: H.colors.ink, width: 1 });\n H.text(\"-\", x, y, { color: H.colors.ink, size: 12, align: \"center\", baseline: \"middle\", weight: 700 });\n}\nH.text(\"Zn -> Zn2+ + \" + nE + \" e- (oxidation half)\", 24, H.H - 70, { color: H.colors.warn, size: 13 });\nH.text(\"Cu2+ + \" + nE + \" e- -> Cu (reduction half)\", 24, H.H - 48, { color: H.colors.accent, size: 13 });\nH.text(\"electrons transferred, n = \" + nE, 24, H.H - 26, { color: H.colors.good, size: 14 });" + }, + { + "id": "apchem-molarity-titration-calc", + "area": "AP Chemistry", + "topic": "Titration stoichiometry", + "title": "Titration stoichiometry: M_titrant * V_eq = n_analyte", + "equation": "M_titrant * V_eq = n_analyte ; n_analyte = C_a * V_a", + "keywords": [ + "titration", + "titration stoichiometry", + "equivalence point", + "molarity", + "moles analyte", + "strong acid strong base", + "mole ratio", + "MV = moles", + "ph curve", + "ap chemistry", + "ap chem", + "neutralization" + ], + "explanation": "In an acid-base titration you add titrant of known molarity M until it exactly consumes the analyte at the equivalence point, where moles of titrant equal moles of analyte (for a 1:1 reaction). The moles of analyte are fixed by C_a*V_a, so the equivalence volume is V_eq = n_analyte / M_titrant. For a strong acid titrated with a strong base the pH is near 0-2 before equivalence, jumps sharply through pH 7 at equivalence, and approaches the titrant's pH afterward. On the AP exam you compute n_analyte from the sharp jump, then use the 1:1 (or stoichiometric) mole ratio to get an unknown concentration; watch units (mL vs L) and the reaction ratio.", + "bullets": [ + "n(analyte) = C_a*V_a is fixed; the orange probe sweeps delivered volume.", + "Equivalence (pink dot) sits where M_titrant*V_eq = n_analyte, here pH 7.", + "The steep vertical jump marks where moles base = moles acid.", + "Larger titrant M shifts V_eq smaller: same moles need less volume." + ], + "params": [ + { + "name": "M", + "label": "titrant molarity M (mol/L)", + "min": 0.05, + "max": 0.5, + "step": 0.05, + "value": 0.1 + }, + { + "name": "V", + "label": "delivered volume V (mL)", + "min": 0, + "max": 50, + "step": 1, + "value": 25 + } + ], + "code": "H.background();\n// Titration of a strong acid analyte with a strong base titrant.\n// Analyte: fixed Va mL of Ca mol/L acid. Titrant: P.M mol/L base, P.V mL delivered.\nconst Ca = 0.100; // analyte conc (mol/L)\nconst Va = 25.0; // analyte volume (mL)\nconst M = Math.max(P.M, 0.01); // titrant molarity (guard >0)\nconst nA = Ca * Va / 1000; // moles of analyte H+ (mol)\nconst Veq = nA / M * 1000; // equivalence volume (mL)\nconst V = Math.min(P.V, 50); // delivered titrant volume (mL)\nconst nB = M * V / 1000; // moles base added (mol)\nfunction pHat(vol){\n const nb = M * vol / 1000;\n const diff = nA - nb;\n const Vtot = (Va + vol) / 1000;\n if (Math.abs(diff) < 1e-12) return 7;\n if (diff > 0){ const Hc = diff / Vtot; return -Math.log10(Math.min(Math.max(Hc,1e-14),1)); }\n const OHc = (-diff) / Vtot; const pOH = -Math.log10(Math.min(Math.max(OHc,1e-14),1)); return 14 - pOH;\n}\nconst v = H.plot2d({ xMin: 0, xMax: 50, yMin: 0, yMax: 14 });\nv.grid(); v.axes();\nv.fn(x => pHat(x), { color: H.colors.accent, width: 3 });\n// equivalence point marker\nv.line(Veq, 0, Veq, 14, { color: H.colors.sub, width: 1, dash: [4,4] });\nv.dot(Veq, 7, { r: 6, fill: H.colors.warn });\n// moving burette tip: sweeps 0..min(2*Veq,50) so it always animates & loops\nconst vmax = Math.min(2 * Veq, 50);\nconst sweep = (0.5 - 0.5 * Math.cos(t * 0.7)) * vmax;\nv.dot(sweep, pHat(sweep), { r: 6, fill: H.colors.accent2 });\n// user's delivered-volume readout dot\nv.dot(V, pHat(V), { r: 5, fill: H.colors.good });\nH.text(\"Titration: M_titrant * V_eq = n_analyte\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"strong acid (\" + Ca.toFixed(2) + \" M, \" + Va.toFixed(0) + \" mL) titrated with strong base\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"n(analyte) = \" + (nA*1000).toFixed(2) + \" mmol\", 24, 470, { color: H.colors.sub, size: 13 });\nH.text(\"V_eq = n/M = \" + Veq.toFixed(1) + \" mL\", 24, 490, { color: H.colors.sub, size: 13 });\nH.text(\"at V = \" + V.toFixed(1) + \" mL -> pH = \" + pHat(V).toFixed(2), 24, 510, { color: H.colors.good, size: 13 });" + }, + { + "id": "apchem-vapor-pressure", + "area": "AP Chemistry", + "topic": "Vapor pressure & temperature", + "title": "Vapor pressure: ln P = -(dHvap/R)(1/T) + C", + "equation": "ln(P) = -(dHvap/R)*(1/T) + C", + "keywords": [ + "vapor pressure", + "clausius clapeyron", + "boiling point", + "enthalpy of vaporization", + "dhvap", + "temperature dependence", + "ln p vs 1/t", + "intermolecular forces", + "ap chemistry", + "ap chem", + "volatility", + "exponential" + ], + "explanation": "Vapor pressure is the pressure of vapor in equilibrium with its liquid; it rises steeply with temperature because more molecules have enough kinetic energy to escape the surface. The Clausius-Clapeyron equation, ln P = -(dHvap/R)(1/T) + C, makes ln P linear in 1/T with slope -dHvap/R, so a larger enthalpy of vaporization (stronger intermolecular forces) gives a steeper line and lower volatility. A liquid boils when its vapor pressure equals the external pressure, so the normal boiling point is where P reaches 1 atm. On the AP exam you use the two-point form ln(P2/P1) = -(dHvap/R)(1/T2 - 1/T1) to find dHvap, a boiling point, or a vapor pressure, and you connect steeper curves to stronger IMFs.", + "bullets": [ + "P climbs exponentially with T (not linearly) per Clausius-Clapeyron.", + "The dashed line is 1 atm; the curve crosses it at the boiling point.", + "Slope of ln P vs 1/T is -dHvap/R: stronger IMFs = steeper, less volatile.", + "Orange probe sweeps T so you see how fast P accelerates near boiling." + ], + "params": [ + { + "name": "T", + "label": "temperature T (K)", + "min": 273, + "max": 393, + "step": 1, + "value": 333 + } + ], + "code": "H.background();\n// Clausius-Clapeyron: ln(P) = -dHvap/R * (1/T) + C. Water-like values.\nconst dH = 40700; // enthalpy of vaporization (J/mol), ~water\nconst R = 8.314; // J/(mol K)\nconst Tb = 373.15; // normal boiling point (K), P = 101.3 kPa there\nconst P0 = 101.3; // kPa at Tb\n// C from boundary: ln(P0) = -dH/R / Tb + C\nconst Cc = Math.log(P0) + dH / R / Tb;\nfunction Pvap(Tk){ return Math.exp(-dH / R / Math.max(Tk,1) + Cc); } // kPa\nconst Tmin = 273, Tmax = 393;\nconst v = H.plot2d({ xMin: Tmin, xMax: Tmax, yMin: 0, yMax: 200 });\nv.grid(); v.axes();\nv.fn(Tk => Math.min(Pvap(Tk), 200), { color: H.colors.accent, width: 3 });\n// 1 atm reference line\nv.line(Tmin, P0, Tmax, P0, { color: H.colors.sub, width: 1, dash: [5,5] });\nv.text(\"1 atm (101.3 kPa)\", Tmin + 4, P0 + 9, { color: H.colors.sub, size: 12 });\n// user slider T\nconst Tuser = Math.max(Math.min(P.T, Tmax), Tmin);\nconst Pu = Pvap(Tuser);\nv.dot(Tuser, Math.min(Pu, 200), { r: 6, fill: H.colors.good });\n// animated probe sweeping the temperature range (loops)\nconst Ta = Tmin + (0.5 - 0.5 * Math.cos(t * 0.6)) * (Tmax - Tmin);\nv.dot(Ta, Math.min(Pvap(Ta), 200), { r: 6, fill: H.colors.accent2 });\nH.text(\"Vapor pressure: ln P = -(dHvap/R)(1/T) + C\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Clausius-Clapeyron: P rises steeply (exponentially) with T\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"dHvap = 40.7 kJ/mol, boils when P = external P\", 24, 470, { color: H.colors.sub, size: 13 });\nH.text(\"T = \" + Tuser.toFixed(0) + \" K (\" + (Tuser-273.15).toFixed(0) + \" C)\", 24, 490, { color: H.colors.good, size: 13 });\nH.text(\"P_vap = \" + Pu.toFixed(1) + \" kPa\", 24, 510, { color: H.colors.good, size: 13 });" + }, + { + "id": "apchem-raoults-law", + "area": "AP Chemistry", + "topic": "Raoult law", + "title": "Raoult's law: P_soln = X_solvent * P_pure", + "equation": "P_solution = X_solvent * P_pure_solvent", + "keywords": [ + "raoult's law", + "raoult law", + "vapor pressure lowering", + "colligative property", + "mole fraction", + "nonvolatile solute", + "ideal solution", + "p solution", + "ap chemistry", + "ap chem", + "solvent", + "solute" + ], + "explanation": "Raoult's law states that the vapor pressure of an ideal solution equals the mole fraction of the solvent times the vapor pressure of the pure solvent: P_soln = X_solvent * P_pure. Adding a nonvolatile solute dilutes the solvent at the surface, so fewer solvent molecules escape and the vapor pressure drops in direct proportion to X_solvent. The vapor-pressure lowering is therefore dP = X_solute * P_pure, a colligative property that depends only on the number of dissolved particles, not their identity. On the AP exam you use this to find a solution's vapor pressure, to relate it to boiling-point elevation, or to back out a mole fraction; remember to count ions (van't Hoff factor) for ionic solutes.", + "bullets": [ + "P_soln vs X_solvent is a straight line from (0,0) to (1, P_pure).", + "Pure solvent (X = 1) gives full P_pure; the dashed line marks it.", + "Vapor-pressure lowering = X_solute * P_pure (the gap below the dashed line).", + "More solute lowers X_solvent and pulls P_soln down proportionally." + ], + "params": [ + { + "name": "X", + "label": "mole fraction solvent X_solvent", + "min": 0, + "max": 1, + "step": 0.05, + "value": 0.8 + } + ], + "code": "H.background();\n// Raoult's law (nonvolatile solute): P_solution = X_solvent * P_pure\nconst Ppure = 23.8; // vapor pressure of pure solvent (kPa), water at 25 C\nconst v = H.plot2d({ xMin: 0, xMax: 1, yMin: 0, yMax: 26 });\nv.grid(); v.axes();\n// Psoln vs Xsolvent is a straight line from (0,0) to (1, Ppure)\nv.fn(x => x * Ppure, { color: H.colors.accent, width: 3 });\nv.line(0, Ppure, 1, Ppure, { color: H.colors.sub, width: 1, dash: [5,5] });\nv.text(\"P_pure = \" + Ppure.toFixed(1), 0.02, Ppure + 1.2, { color: H.colors.sub, size: 12 });\n// user slider: mole fraction of solvent\nconst X = Math.max(Math.min(P.X, 1), 0);\nconst Psoln = X * Ppure;\nconst lowering = Ppure - Psoln; // = X_solute * P_pure\nv.dot(X, Psoln, { r: 6, fill: H.colors.good });\nv.line(X, 0, X, Psoln, { color: H.colors.good, width: 1, dash: [3,3] });\n// animated probe sweeping X (loops, stays in 0..1)\nconst Xa = 0.5 - 0.5 * Math.cos(t * 0.6);\nv.dot(Xa, Xa * Ppure, { r: 6, fill: H.colors.accent2 });\nH.text(\"Raoult's law: P_soln = X_solvent * P_pure\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Nonvolatile solute lowers vapor pressure linearly\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"X_solvent = \" + X.toFixed(2) + \" -> P_soln = \" + Psoln.toFixed(2) + \" kPa\", 24, 470, { color: H.colors.good, size: 13 });\nH.text(\"vapor-pressure lowering = X_solute * P_pure = \" + lowering.toFixed(2) + \" kPa\", 24, 490, { color: H.colors.sub, size: 13 });\nH.text(\"X_solute = \" + (1 - X).toFixed(2), 24, 510, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apchem-percent-ionization", + "area": "AP Chemistry", + "topic": "Percent ionization vs dilution", + "title": "Percent ionization: %ion = ([H+]/C)*100 rises on dilution", + "equation": "x^2/(C - x) = Ka ; %ionization = (x/C)*100", + "keywords": [ + "percent ionization", + "weak acid", + "dilution", + "ostwald dilution law", + "ka", + "equilibrium", + "degree of ionization", + "ice table", + "ap chemistry", + "ap chem", + "acid strength", + "ph" + ], + "explanation": "For a weak acid HA, the percent ionization is (x/C)*100 where x = [H+] comes from the equilibrium x^2/(C - x) = Ka. As the acid is diluted (C decreases), the equilibrium shifts toward more ionization to partially offset the dilution, so the FRACTION ionized rises even though the absolute [H+] falls. This is Ostwald's dilution law: at high dilution percent ionization can approach 100% while pH still drifts upward toward 7. On the AP exam you set up an ICE table, often use the x << C approximation x = sqrt(Ka*C), and explain why a more dilute weak acid is 'more ionized' yet less acidic; this demo plots percent ionized against log(concentration).", + "bullets": [ + "x-axis is log10(C): moving left (dilution) raises % ionized.", + "%ion = (x/C)*100 with x from x^2/(C-x) = Ka (no approximation here).", + "Counterintuitive: dilution raises % ionized but [H+] and acidity drop.", + "Orange probe sweeps concentration to trace Ostwald's dilution law." + ], + "params": [ + { + "name": "C", + "label": "acid concentration C (mol/L)", + "min": 1e-05, + "max": 1, + "step": 0.001, + "value": 0.1 + } + ], + "code": "H.background();\n// Weak acid: % ionization = ([H+]/C)*100. As C decreases (dilution), % rises (Ostwald).\n// Exact: x^2/(C-x)=Ka => x=(-Ka+sqrt(Ka^2+4 Ka C))/2 ; %ion = x/C*100\nconst Ka = 1.8e-5; // acetic acid\nfunction pctIon(C){\n const Cc = Math.max(C, 1e-6);\n const x = (-Ka + Math.sqrt(Ka*Ka + 4*Ka*Cc)) / 2;\n return Math.min(x / Cc * 100, 100);\n}\n// plot % ionization vs concentration on log axis (use log10 C as x)\nconst lcMin = -5, lcMax = 0; // C from 1e-5 to 1 mol/L\nconst v = H.plot2d({ xMin: lcMin, xMax: lcMax, yMin: 0, yMax: 100 });\nv.grid(); v.axes();\nv.fn(lc => pctIon(Math.pow(10, lc)), { color: H.colors.accent, width: 3 });\n// user slider: concentration C (mol/L)\nconst C = Math.max(Math.min(P.C, 1), 1e-5);\nconst lcU = Math.log10(C);\nconst pu = pctIon(C);\nv.dot(lcU, pu, { r: 6, fill: H.colors.good });\nv.line(lcU, 0, lcU, pu, { color: H.colors.good, width: 1, dash: [3,3] });\n// animated probe sweeping concentration (loops within range)\nconst lca = lcMin + (0.5 - 0.5 * Math.cos(t * 0.6)) * (lcMax - lcMin);\nv.dot(lca, pctIon(Math.pow(10, lca)), { r: 6, fill: H.colors.accent2 });\nH.text(\"Percent ionization rises on dilution (Ostwald)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"x-axis = log10(C); more dilute (left) -> larger % ionized\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"Ka (acetic) = 1.8e-5\", 24, 470, { color: H.colors.sub, size: 13 });\nH.text(\"C = \" + C.toExponential(1) + \" M -> % ionized = \" + pu.toFixed(2) + \" %\", 24, 490, { color: H.colors.good, size: 13 });\nH.text(\"[H+] = \" + (pu/100*C).toExponential(2) + \" M, pH = \" + (-Math.log10(Math.max(pu/100*C,1e-14))).toFixed(2), 24, 510, { color: H.colors.sub, size: 13 });" + }, + { + "id": "apchem-buffer-capacity", + "area": "AP Chemistry", + "topic": "Buffer capacity", + "title": "Buffer capacity: pH = pKa + log([A-]/[HA])", + "equation": "pH = pKa + log10([A-]/[HA])", + "keywords": [ + "buffer", + "buffer capacity", + "henderson hasselbalch", + "pka", + "weak acid conjugate base", + "resist ph change", + "added acid base", + "ph", + "ap chemistry", + "ap chem", + "equivalence", + "strong base" + ], + "explanation": "A buffer is a mixture of a weak acid HA and its conjugate base A- that resists pH change, described by the Henderson-Hasselbalch equation pH = pKa + log([A-]/[HA]). Adding strong base converts HA into A- (and strong acid does the reverse), so the ratio [A-]/[HA] changes only modestly and the pH barely moves while reserves remain. Buffer capacity is largest when [A-] = [HA] (pH = pKa) and when the absolute amounts of both are large; once one component is nearly exhausted the pH jumps sharply, just as it does for the same addition to pure water. On the AP exam you compute the new ratio after a neutralization, predict the small pH shift, and choose a buffer whose pKa is near the target pH.", + "bullets": [ + "Blue buffer curve stays nearly flat; red pure-water curve swings wildly.", + "pH = pKa when [A-] = [HA] (dashed line) — maximum buffering here.", + "Adding base raises [A-]/[HA] only slightly, so pH barely moves.", + "Near the ends reserves run out and the buffer pH jumps — capacity exceeded." + ], + "params": [ + { + "name": "b", + "label": "strong base added (mol; - = acid)", + "min": -0.5, + "max": 0.5, + "step": 0.05, + "value": 0.1 + } + ], + "code": "H.background();\n// Buffer: Henderson-Hasselbalch. pH = pKa + log([A-]/[HA]).\n// Start with equal moles HA and A- (1 L). Add strong base 'b' mol (converts HA->A-)\n// or strong acid (b<0, converts A-->HA). Compare to adding same to pure water.\nconst pKa = 4.74; // acetic / acetate buffer\nconst n0 = 0.50; // initial moles HA = moles A- (in 1 L)\nfunction pHbuf(b){\n // b = mol strong base added (negative = strong acid)\n const HA = n0 - b;\n const A = n0 + b;\n if (HA <= 1e-6) return 7 + 6; // past capacity (base side) -> jumps up\n if (A <= 1e-6) return 1; // past capacity (acid side) -> jumps down\n return pKa + Math.log10(A / HA);\n}\nfunction pHwater(b){\n // adding b mol strong base (or acid) to 1 L pure water\n if (b > 0){ const OH = b; const pOH = -Math.log10(Math.max(OH,1e-14)); return 14 - pOH; }\n if (b < 0){ const Hc = -b; return -Math.log10(Math.max(Hc,1e-14)); }\n return 7;\n}\nconst bMin = -0.5, bMax = 0.5;\nconst v = H.plot2d({ xMin: bMin, xMax: bMax, yMin: 0, yMax: 14 });\nv.grid(); v.axes();\nv.fn(b => Math.max(Math.min(pHwater(b),14),0), { color: H.colors.warn, width: 2 });\nv.fn(b => Math.max(Math.min(pHbuf(b),14),0), { color: H.colors.accent, width: 3 });\nv.line(bMin, pKa, bMax, pKa, { color: H.colors.sub, width: 1, dash: [5,5] });\nv.text(\"pKa = \" + pKa.toFixed(2), bMin + 0.02, pKa + 0.7, { color: H.colors.sub, size: 12 });\nH.legend([{label:\"buffer\", color:H.colors.accent},{label:\"pure water\", color:H.colors.warn}], 660, 70);\n// user slider: added strong base (mol); negative = added acid\nconst b = Math.max(Math.min(P.b, bMax), bMin);\nv.dot(b, Math.max(Math.min(pHbuf(b),14),0), { r: 6, fill: H.colors.good });\n// animated probe sweeping additions (loops within capacity-ish range)\nconst ba = (bMax * 0.85) * Math.sin(t * 0.6);\nv.dot(ba, Math.max(Math.min(pHbuf(ba),14),0), { r: 6, fill: H.colors.accent2 });\nH.text(\"Buffer capacity: pH = pKa + log([A-]/[HA])\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Add acid/base: buffer pH barely moves until reserves run out\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"HA0 = A-0 = \" + n0.toFixed(2) + \" mol in 1 L\", 24, 470, { color: H.colors.sub, size: 13 });\nH.text(\"add base = \" + b.toFixed(2) + \" mol -> buffer pH = \" + pHbuf(b).toFixed(2), 24, 490, { color: H.colors.good, size: 13 });\nH.text(\"same to pure water -> pH = \" + pHwater(b).toFixed(2) + \" (delta = \" + Math.abs(pHbuf(b)-pHwater(b)).toFixed(1) + \")\", 24, 510, { color: H.colors.sub, size: 13 });" + } +] \ No newline at end of file diff --git a/app.js b/app.js index 8284129..b25fcd8 100644 --- a/app.js +++ b/app.js @@ -18,7 +18,7 @@ /* Tutor text rendering */ /* ============================================================== */ // - // The tutor (Ollama or Claude) sometimes ignores the "plain text" rule and + // The tutor (Claude/OpenAI/Gemini) sometimes ignores the "plain text" rule and // emits Markdown + LaTeX. Rather than rely on prompt discipline, we clean // it up here: // 1. HTML-escape first, so any literal <, >, & from the model stays text. @@ -54,26 +54,48 @@ s = s.replace(/(? a/b (parenthesize compound numerator/denominator). - s = s.replace(/\\frac\s*\{([^{}]+)\}\s*\{([^{}]+)\}/g, (_, a, b) => { + // Degree: \circ and ^\circ -> ° (do this BEFORE the greek fallback, which + // would otherwise turn \circ into the literal word "circ", e.g. 58^circ). + s = s.replace(/\^?\s*\\circ\b/g, "°"); + s = s.replace(/\\degrees?\b/g, "°"); + + // LaTeX spacing macros (\, \; \: \! \quad \qquad and backslash-space) -> a + // single space. The greek fallback ignores these (\, isn't a letter run), + // so without this they survive as literal "\," junk. + s = s.replace(/\\(?:quad|qquad)\b/g, " "); + s = s.replace(/\\[,;:!> ]/g, " "); + + // \mathrm/\mathbf/\operatorname{...} -> just the inner text. + s = s.replace(/\\(?:mathrm|mathbf|mathit|operatorname)\s*\{([^{}]*)\}/g, "$1"); + + // \frac{a}{b} -> a/b. Tolerate doubled braces {{...}} that local models + // sometimes emit, and parenthesize a compound numerator/denominator. + s = s.replace(/\\frac\s*\{+([^{}]+)\}+\s*\{+([^{}]+)\}+/g, (_, a, b) => { + a = a.trim(); + b = b.trim(); const parenA = /[+\-]/.test(a) ? `(${a})` : a; const parenB = /[+\-]/.test(b) ? `(${b})` : b; return `${parenA}/${parenB}`; }); // \sqrt{a} -> sqrt(a) - s = s.replace(/\\sqrt\s*\{([^{}]+)\}/g, "sqrt($1)"); + s = s.replace(/\\sqrt\s*\{+([^{}]+)\}+/g, "sqrt($1)"); // \text{a} -> a s = s.replace(/\\text\s*\{([^{}]*)\}/g, "$1"); - // Subscripts / superscripts: x_{n} -> x_n, x^{2} -> x^2 - s = s.replace(/_\{([^{}]+)\}/g, "_$1"); - s = s.replace(/\^\{([^{}]+)\}/g, "^$1"); - + // Subscripts / superscripts: x_{n} -> x_n, x^{2} -> x^2 (doubled braces too) + s = s.replace(/_\{+([^{}]+)\}+/g, "_$1"); + s = s.replace(/\^\{+([^{}]+)\}+/g, "^$1"); + + // LaTeX sizing markers (\left( \right) \big …) — strip the command but keep + // the bracket. Must run BEFORE the generic \word converter below, which would + // otherwise turn "\left" into the English word "left" and then we'd wrongly + // delete real "left"/"right" words from prose ("Reactants (left) …"). + s = s.replace(/\\(left|right|bigg?|Bigg?)\b/g, ""); // Greek + common symbols / function names. s = s.replace(/\\([A-Za-z]+)/g, (_, name) => Object.prototype.hasOwnProperty.call(GREEK, name) ? GREEK[name] : name ); - // Stray "left"/"right" sizing markers — leave the inner brackets. - s = s.replace(/\b(left|right)\b/g, ""); + // Any lone backslash left before whitespace/punctuation is LaTeX residue. + s = s.replace(/\\(?=[\s.,;:)\]}])/g, ""); // Tidy spaces. return s.replace(/[ \t]+/g, " ").replace(/ ?\n ?/g, "\n"); } @@ -87,7 +109,9 @@ // 3. Markdown: code, bold, italics, headers. s = s.replace(/`([^`\n]+?)`/g, "$1"); s = s.replace(/\*\*([^*\n][^*]*?)\*\*/g, "$1"); - s = s.replace(/(?$1"); + // Italics: require non-space just inside the asterisks (CommonMark) so a + // multiplication like "gamma * m * c^2" is NOT treated as emphasis. + s = s.replace(/(?$1"); s = s.replace(/^#{1,6}\s+(.+)$/gm, "$1"); // 4. Lists, line by line. Group consecutive same-type items, even when @@ -229,6 +253,11 @@ /* AI-generated animation code in isolation. */ /* ============================================================== */ + // The app theme ("light"|"dark") lives on ; the canvas worker + // needs it too so the animation follows the toggle. Default to dark. + const currentTheme = () => + document.documentElement.dataset.theme === "light" ? "light" : "dark"; + class SandboxRunner { constructor(frame) { this.frame = frame; @@ -253,6 +282,15 @@ }); }); this._wireOrbit(); + + // Stop rendering while the tab is hidden — no point animating a canvas + // nobody can see, and it frees the CPU/battery. Don't suspend mid-load + // (a pending run relies on the worker's heartbeat to settle). + document.addEventListener("visibilitychange", () => { + if (!this.worker) return; + if (document.hidden && this.pending && !this.pending.settled) return; + this.worker.postMessage({ type: "visible", value: !document.hidden }); + }); } /* Drag-to-orbit + scroll-to-zoom + double-click-to-reset for 3D scenes. @@ -326,7 +364,10 @@ return { width: Math.max(320, Math.round(rect.width)), height: Math.max(240, Math.round(rect.height)), - dpr: Math.min(2, window.devicePixelRatio || 1), + // Cap at 1.5 rather than 2: on a Retina display dpr=2 means 4x the + // pixels to fill every frame, which dominates the cost of fill-heavy + // 3D scenes. 1.5 still looks crisp and roughly halves the fill work. + dpr: Math.min(1.5, window.devicePixelRatio || 1), }; } @@ -341,12 +382,12 @@ } const canvas = this._newCanvas(); const offscreen = canvas.transferControlToOffscreen(); - const worker = new Worker("./sandbox-worker.js?v=16"); + const worker = new Worker("./sandbox-worker.js?v=24"); this.worker = worker; worker.onmessage = (e) => this._onMessage(e.data || {}); const d = this._dims(); worker.postMessage( - { type: "init", canvas: offscreen, width: d.width, height: d.height, dpr: d.dpr }, + { type: "init", canvas: offscreen, width: d.width, height: d.height, dpr: d.dpr, theme: currentTheme() }, [offscreen] ); } @@ -395,8 +436,9 @@ else p.reject(err); } - /* Load code and resolve once it renders a frame; reject on error/hang. */ - async run(code) { + /* Load code and resolve once it renders a frame; reject on error/hang. + * `params` seeds the demo `P` global (editable live via setParams). */ + async run(code, params) { await this._whenReady(); if (this.pending && !this.pending.settled) this._settle(false, new Error("superseded")); @@ -408,7 +450,7 @@ this._spawn(); // kill the frozen worker and start a fresh one this._settle(false, new Error("The animation hung (possible infinite loop).")); }, 3000); - this.worker.postMessage({ type: "run", code, resetTime: true }); + this.worker.postMessage({ type: "run", code, params: params || {}, resetTime: true, theme: currentTheme() }); if (this.paused) this.worker.postMessage({ type: "pause" }); this.worker.postMessage({ type: "speed", value: this.speed }); }); @@ -426,6 +468,14 @@ this.speed = value; if (this.worker) this.worker.postMessage({ type: "speed", value }); } + /* Live-update demo parameters without recompiling the scene. */ + setParams(values) { + if (this.worker) this.worker.postMessage({ type: "params", values: values || {} }); + } + /* Push a light/dark theme switch to the running scene (live, no recompile). */ + setTheme(theme) { + if (this.worker) this.worker.postMessage({ type: "theme", theme }); + } _resize() { if (!this.worker || !this.ready) return; const d = this._dims(); @@ -521,6 +571,9 @@ el.equation.textContent = scene.equation || "No single equation — concept scene"; el.summary.textContent = scene.summary; el.tag.textContent = scene.tag; + // The help card is self-explanatory on the canvas — hide the overlay pills + // (tag + status) so they don't clutter it with redundant chrome. + if (el.frame) el.frame.classList.toggle("bare", !!scene.browser_help); // 3D scenes are orbitable — surface that, since nothing else hints at it. if (el.orbitHint) el.orbitHint.hidden = scene.dimension !== "3D"; @@ -588,8 +641,9 @@ claude: "Claude", openai: "ChatGPT", gemini: "Gemini", - ollama: "local model", library: "curated library", + chemistry: "chemistry engine", + solver: "step-by-step solver", fallback: "fallback", }; function engineName(engine) { @@ -649,7 +703,7 @@ el.topic.textContent = "Generation failed"; el.summary.textContent = err.message; // Re-check which backend is alive — the failure often means the badge - // is now stale (Ollama died, Claude key expired, server restarted, etc.). + // is now stale (Claude key expired, server restarted, etc.). refreshStatus(); } finally { stopElapsed(); @@ -666,6 +720,7 @@ updatePanels(scene); } setConfidence(message, "warn"); + renderDemoControls(null); try { await runner.run(CLIENT_FALLBACK_CODE); } catch (fallbackErr) { @@ -673,6 +728,76 @@ } } + /* ============================================================== */ + /* Interactive demo parameter controls ("fill in the values") */ + /* ============================================================== */ + + function demoParams(scene) { + const out = {}; + ((scene && scene.params) || []).forEach((p) => { + out[p.name] = p.value; + }); + return out; + } + + function fmtNum(v) { + return String(Math.round(v * 100) / 100); + } + + // Build the live slider panel for a demo scene. A non-demo scene (no params) + // hides the panel. Moving a slider updates the worker's `P` global live — + // no recompile — so the graph responds as you drag. + function renderDemoControls(scene) { + let dc = document.getElementById("demoControls"); + if (!dc) { + dc = document.createElement("div"); + dc.id = "demoControls"; + dc.className = "demo-controls"; + el.frame.insertAdjacentElement("afterend", dc); + } + const params = (scene && scene.params) || []; + if (!params.length) { + dc.hidden = true; + dc.innerHTML = ""; + return; + } + dc.hidden = false; + dc.innerHTML = ""; + const head = document.createElement("div"); + head.className = "demo-controls-head"; + head.textContent = "Adjust the values — the visualization updates live"; + dc.appendChild(head); + const grid = document.createElement("div"); + grid.className = "demo-params"; + dc.appendChild(grid); + params.forEach((p) => { + const row = document.createElement("div"); + row.className = "demo-param"; + const name = document.createElement("span"); + name.className = "demo-param-name"; + name.textContent = p.label || p.name; + const slider = document.createElement("input"); + slider.type = "range"; + slider.min = p.min; + slider.max = p.max; + slider.step = p.step; + slider.value = p.value; + slider.setAttribute("aria-label", p.label || p.name); + const val = document.createElement("span"); + val.className = "demo-param-val"; + val.textContent = fmtNum(p.value); + slider.addEventListener("input", () => { + const v = parseFloat(slider.value); + val.textContent = fmtNum(v); + runner.setParams({ [p.name]: v }); + }); + row.appendChild(name); + row.appendChild(slider); + row.appendChild(val); + grid.appendChild(row); + }); + } + async function runSceneWithRepair(scene, prompt) { let current = scene; let prevError = null; @@ -688,9 +813,10 @@ : `Repair ${attempt}/${MAX_REPAIRS}: trying the fixed code…`, "pending" ); - await runner.run(current.code); + await runner.run(current.code, demoParams(current)); state.scene = current; updatePanels(current); + renderDemoControls(current); if (current.is_fallback) { // Server gave us a guaranteed-renderable placeholder because the real // generator produced blank code three times. Surface it clearly — @@ -710,6 +836,27 @@ "Regenerate or rephrase for a better scene.", "warn", ); + } else if (current.from_solver) { + // Worked solution: animated step-by-step slides for a specific problem. + setConfidence( + "Worked solution • step-by-step slides — solved with your own numbers", + "ok", + ); + } else if (current.from_chemistry) { + // Chemistry: a 3D molecular structure (orbit it) or a balanced + // reaction. Known-correct — parsed and computed server-side. + setConfidence( + current.chem_kind === "balance" + ? "Chemistry • balanced equation — reactants → products, atoms conserved" + : "Chemistry • 3D molecular structure — drag to orbit, scroll to zoom", + "ok", + ); + } else if (current.from_demo) { + // Interactive curriculum demo — drag the sliders to explore. + setConfidence( + `${current.area || "Interactive"} demo • drag the sliders to explore`, + "ok", + ); } else if (current.from_library) { // Instant, hand-verified scene from the curated STEM corpus. setConfidence( @@ -843,24 +990,74 @@ role: "assistant", content: `This is "${scene.title}". ${scene.summary} ` + - "Ask me anything about what you're seeing, the math behind it, or for a practice problem.", + "Ask me anything — or ask me to solve a problem step by step and I'll " + + "show what you need, each step, and where it applies.", }, ]; renderChat(); } + // A persistent quick-action that puts the tutor into step-by-step solve mode. + // Filling the box (rather than auto-sending) lets the student append their + // own numbers first, e.g. "...for v0 = 20 m/s and angle = 30 degrees". + const SOLVE_PROMPT = "Solve this step by step: show what's needed, each step, and where it applies."; + + function addSuggestionChip(text, opts) { + const btn = document.createElement("button"); + btn.className = "suggestion-chip" + (opts && opts.solve ? " solve" : ""); + btn.type = "button"; + btn.textContent = text; + btn.addEventListener("click", () => { + const value = (opts && opts.value) || text; + // Scene-explanation chips send immediately — one click, one answer — so + // Quick Questions actually *do* something. The solver chip instead FILLS + // the box so the student can append their own numbers before pressing Ask. + if (opts && opts.send) { + sendChat(value); + return; + } + el.chatInput.value = value; + // Scroll the chat into view FIRST — the tutor sits below the fold, so + // without this the box fills silently and it looks like nothing happened. + el.chatInput.scrollIntoView({ behavior: "smooth", block: "center" }); + el.chatInput.focus({ preventScroll: true }); + // Put the caret at the end so the student can keep typing their specifics. + const v = el.chatInput.value; + el.chatInput.setSelectionRange(v.length, v.length); + }); + el.chatSuggestions.appendChild(btn); + } + function renderSuggestions(scene) { el.chatSuggestions.innerHTML = ""; - (scene.student_prompts || []).slice(0, 4).forEach((prompt) => { - const btn = document.createElement("button"); - btn.className = "suggestion-chip"; - btn.type = "button"; - btn.textContent = prompt; - btn.addEventListener("click", () => { - el.chatInput.value = prompt; - el.chatInput.focus(); + const hasExplanation = + scene && !scene.browser_help && (scene.summary || (scene.bullets || []).length); + // Scene-aware questions answered on one click. In the browser edition these + // are answered offline from the scene's own explanation; the desktop app + // routes them to the real AI tutor. + if (hasExplanation) { + addSuggestionChip("Explain what I'm seeing", { + send: true, + value: "Explain what I'm seeing in this visualization.", }); - el.chatSuggestions.appendChild(btn); + if (scene.params && scene.params.length) { + addSuggestionChip("What do the sliders change?", { + send: true, + value: "What do the sliders/controls change?", + }); + } + if (scene.equation) { + addSuggestionChip("What does the equation mean?", { + send: true, + value: "What does the equation on screen mean?", + }); + } + } + // The step-by-step solver — fills the box so numbers can be appended first. + addSuggestionChip("Solve it step by step", { solve: true, value: SOLVE_PROMPT }); + // Any scene-provided follow-ups (desktop scenes may include these). + (scene.student_prompts || []).slice(0, 3).forEach((prompt) => { + addSuggestionChip(prompt); }); } @@ -924,15 +1121,13 @@ el.statusBadge.textContent = "Gemini online"; el.statusBadge.className = "status-pill ok"; el.modelLabel.textContent = "Generator: " + (health.gemini.model || "gemini"); - } else if (gen === "ollama") { - el.statusBadge.textContent = "Local model"; - el.statusBadge.className = "status-pill ok"; - el.modelLabel.textContent = "Generator: " + (health.ollama.selected_model || "ollama"); } else { - el.statusBadge.textContent = "No generator"; - el.statusBadge.className = "status-pill error"; + // No cloud key — the AI relies only on code, so the app still runs the + // built-in pure-code library (demos, chemistry, the step-by-step solver). + el.statusBadge.textContent = "Code-only"; + el.statusBadge.className = "status-pill"; el.modelLabel.textContent = - "Set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY or start Ollama"; + "Demos, chemistry & solver run offline. Set ANTHROPIC_API_KEY for AI generation."; } } catch (err) { el.statusBadge.textContent = "Server offline"; @@ -1281,6 +1476,7 @@ const current = document.documentElement.dataset.theme || "dark"; const next = current === "dark" ? "light" : "dark"; document.documentElement.dataset.theme = next; + runner.setTheme(next); syncThemeToggleLabel(); try { localStorage.setItem(THEME_KEY, next); @@ -1290,12 +1486,488 @@ }); } + /* ============================================================== */ + /* Resizable AI Tutor — drag the handle to stretch / shrink it. */ + /* The top workspace self-adapts (it's a flex:1 region), and the */ + /* chosen height is persisted across sessions. Setting a CSS var */ + /* never triggers layout reflow events, so there is no resize */ + /* feedback loop / stack overflow here. */ + /* ============================================================== */ + + (function setupTutorResizer() { + const resizer = $("appResizer"); + if (!resizer) return; + const root = document.documentElement; + const KEY = "visuallm-tutor-h"; + const MIN = 150; + const RESERVE = 300; // px always kept for the top workspace + chrome + const maxH = () => Math.max(MIN, window.innerHeight - RESERVE); + const clampH = (h) => Math.min(maxH(), Math.max(MIN, h)); + const apply = (h) => root.style.setProperty("--tutor-h", clampH(h) + "px"); + const current = () => + parseFloat(getComputedStyle(root).getPropertyValue("--tutor-h")) || + Math.min(maxH(), 300); + const save = () => { + try { + localStorage.setItem(KEY, String(Math.round(current()))); + } catch (e) { + /* private mode — non-fatal */ + } + }; + + // Re-clamp any restored value to the live viewport (the head bootstrap set + // it pre-paint to avoid a flash). + const saved = parseFloat(localStorage.getItem(KEY)); + if (!isNaN(saved)) apply(saved); + + let dragging = false; + resizer.addEventListener("pointerdown", (e) => { + dragging = true; + resizer.classList.add("dragging"); + document.body.style.userSelect = "none"; + try { + resizer.setPointerCapture(e.pointerId); + } catch (_) {} + e.preventDefault(); + }); + resizer.addEventListener("pointermove", (e) => { + if (!dragging) return; + // The tutor's bottom sits ~14px above the viewport bottom (its margin); + // its top tracks the pointer. So height = (viewport bottom) − pointerY. + apply(window.innerHeight - e.clientY - 14); + }); + const stop = (e) => { + if (!dragging) return; + dragging = false; + resizer.classList.remove("dragging"); + document.body.style.userSelect = ""; + try { + resizer.releasePointerCapture(e.pointerId); + } catch (_) {} + save(); + }; + resizer.addEventListener("pointerup", stop); + resizer.addEventListener("pointercancel", stop); + + // Keyboard a11y: arrows nudge the size when the handle is focused. + resizer.addEventListener("keydown", (e) => { + const step = e.shiftKey ? 48 : 16; + if (e.key === "ArrowUp") { + apply(current() + step); + save(); + e.preventDefault(); + } else if (e.key === "ArrowDown") { + apply(current() - step); + save(); + e.preventDefault(); + } + }); + + // Keep the tutor within bounds when the window itself is resized. + window.addEventListener("resize", () => apply(current())); + })(); + + /* ============================================================== */ + /* Resizable top columns — drag the vertical handles to change the */ + /* WIDTH of the Explanation / canvas / Prompt cards (height stays */ + /* fixed by the app-shell). Widths persist across sessions. */ + /* ============================================================== */ + + (function setupColumnResizers() { + const left = document.querySelector(".left-panel"); + const right = document.querySelector(".right-panel"); + if (!left || !right) return; + const root = document.documentElement; + const clampL = (w) => Math.min(460, Math.max(200, w)); // matches CSS clamp() + const clampR = (w) => Math.min(420, Math.max(190, w)); + + const restore = (key, prop, clamp) => { + try { + const v = parseFloat(localStorage.getItem(key)); + if (!isNaN(v)) root.style.setProperty(prop, clamp(v) + "px"); + } catch (e) {} + }; + restore("visuallm-col-left", "--col-left", clampL); + restore("visuallm-col-right", "--col-right", clampR); + + function wire(handle, side) { + if (!handle) return; + const prop = side === "left" ? "--col-left" : "--col-right"; + const key = side === "left" ? "visuallm-col-left" : "visuallm-col-right"; + const clamp = side === "left" ? clampL : clampR; + const width = (clientX) => + side === "left" + ? clamp(clientX - left.getBoundingClientRect().left) + : clamp(right.getBoundingClientRect().right - clientX); + const save = () => { + try { + localStorage.setItem( + key, + String(Math.round(parseFloat(getComputedStyle(root).getPropertyValue(prop)) || 280)), + ); + } catch (e) {} + }; + let dragging = false; + handle.addEventListener("pointerdown", (e) => { + dragging = true; + handle.classList.add("dragging"); + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; + try { + handle.setPointerCapture(e.pointerId); + } catch (_) {} + e.preventDefault(); + }); + handle.addEventListener("pointermove", (e) => { + if (!dragging) return; + root.style.setProperty(prop, width(e.clientX) + "px"); + }); + const stop = (e) => { + if (!dragging) return; + dragging = false; + handle.classList.remove("dragging"); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + try { + handle.releasePointerCapture(e.pointerId); + } catch (_) {} + save(); + }; + handle.addEventListener("pointerup", stop); + handle.addEventListener("pointercancel", stop); + // Keyboard a11y: arrows nudge the width. For the left handle, Right grows + // the left card; for the right handle, Left grows the right card. + handle.addEventListener("keydown", (e) => { + const step = e.shiftKey ? 40 : 12; + const cur = parseFloat(getComputedStyle(root).getPropertyValue(prop)) || 280; + let next = null; + if (e.key === "ArrowLeft") next = side === "left" ? cur - step : cur + step; + else if (e.key === "ArrowRight") next = side === "left" ? cur + step : cur - step; + if (next === null) return; + root.style.setProperty(prop, clamp(next) + "px"); + save(); + e.preventDefault(); + }); + } + wire($("colResizerLeft"), "left"); + wire($("colResizerRight"), "right"); + })(); + + /* ============================================================== */ + /* Study Packs (DLC) — browse official/custom packs, launch a */ + /* demo or experiment, import a .dlc.json. Backend-agnostic: talks */ + /* to /api/packs, /api/pack, /api/demo, /api/dlc/import — the */ + /* browser shim answers offline; the desktop server answers too. */ + /* ============================================================== */ + + // Run a fully-formed scene (from a pack) through the normal render+repair + // path so it reuses all the panel / tutor / demo-control wiring. + async function launchScene(scene, label) { + if (state.busy) return; + setBusyVisual(true, "Loading…"); + if (runner.paused) { + runner.resume(); + el.playPause.textContent = "Pause"; + } + try { + await runSceneWithRepair(scene, label || scene.title || ""); + if (el.frame && window.matchMedia("(max-width: 900px)").matches) + el.frame.scrollIntoView({ behavior: "smooth", block: "center" }); + } catch (err) { + setConfidence("Could not load this demo: " + err.message, "warn"); + } finally { + setBusyVisual(false); + } + } + + const dlc = { + catalog: $("dlcCatalog"), + catalogSection: $("dlcCatalogSection"), + detailSection: $("dlcDetailSection"), + detail: $("dlcDetail"), + back: $("dlcBack"), + drop: $("dlcDropZone"), + file: $("dlcFileInput"), + msg: $("dlcImportMsg"), + analyze: $("dlcAnalyze"), + uploadBlock: $("resourceUploadBlock"), + }; + + // On a real backend (desktop), the AI-analysis panel becomes an actual + // generator; in the browser it stays an explanatory note. + function renderAnalyzeForm() { + if (!dlc.analyze) return; + dlc.analyze.innerHTML = + '

Paste notes or slide text — or a link — and the AI builds a tailored demo pack (one Claude call per topic). It installs here and downloads as a shareable .dlc.json.

' + + '' + + '' + + '' + + ''; + const materialEl = $("dlcMaterial"); + const nameEl = $("dlcPackName"); + const genBtn = $("dlcGenerate"); + const amsg = $("dlcAnalyzeMsg"); + const say = (t, err) => { + if (!amsg) return; + amsg.hidden = false; + amsg.textContent = t; + amsg.classList.toggle("error", !!err); + }; + genBtn.addEventListener("click", async () => { + const raw = (materialEl.value || "").trim(); + if (!raw) return say("Paste some material or a link first.", true); + const body = { name: (nameEl.value || "").trim() }; + if (/^https?:\/\//i.test(raw)) body.link = raw; + else body.material = raw; + genBtn.disabled = true; + say("Generating… the AI is building a demo per topic. This can take a minute."); + try { + const res = await postJSON("/api/analyze", body, { retry: false }); + say("Built “" + (res.pack ? res.pack.name : "pack") + "” — installed and downloaded."); + if (res.dlc) downloadDlcZip(res.dlc, res.dlc.id); + dlcShowCatalog(); + dlcLoadCatalog(); + } catch (e) { + say("Analysis failed: " + e.message, true); + } finally { + genBtn.disabled = false; + } + }); + } + + function dlcMsg(text, isError) { + if (!dlc.msg) return; + dlc.msg.hidden = false; + dlc.msg.textContent = text; + dlc.msg.classList.toggle("error", !!isError); + } + function dlcShowCatalog() { + if (dlc.detailSection) dlc.detailSection.hidden = true; + if (dlc.catalogSection) dlc.catalogSection.hidden = false; + } + + async function dlcLoadCatalog() { + if (!dlc.catalog) return; + try { + const data = await fetch("/api/packs").then((r) => r.json()); + renderPackCards(data.packs || []); + } catch (e) { + dlc.catalog.innerHTML = '

Packs unavailable.

'; + } + } + + function renderPackCards(packs) { + dlc.catalog.innerHTML = ""; + if (!packs.length) { + dlc.catalog.innerHTML = '

No packs yet — import one below.

'; + return; + } + packs.forEach((p) => { + const card = document.createElement("button"); + card.type = "button"; + card.className = "dlc-card" + (p.source === "official" ? "" : " custom"); + card.innerHTML = + '' + escapeHtml(p.icon || "📦") + "" + + '' + escapeHtml(p.name) + "" + + '' + p.demoCount + " demos" + + (p.experimentCount ? " · " + p.experimentCount + " labs" : "") + + (p.source !== "official" ? " · custom" : "") + "" + + '' + escapeHtml(p.description || "") + ""; + card.addEventListener("click", () => dlcOpenPack(p.id)); + dlc.catalog.appendChild(card); + }); + } + + async function dlcOpenPack(id) { + try { + const pack = await postJSON("/api/pack", { id }); + renderPackDetail(pack); + dlc.catalogSection.hidden = true; + dlc.detailSection.hidden = false; + dlc.detailSection.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } catch (e) { + dlcMsg("Could not open pack: " + e.message, true); + } + } + + function dlcDemoRow(d) { + const b = document.createElement("button"); + b.type = "button"; + b.className = "dlc-demo"; + b.innerHTML = + "" + escapeHtml(d.title) + "" + + (d.equation ? "" + escapeHtml(d.equation) + "" : ""); + if (d.blurb) b.title = d.blurb; + b.addEventListener("click", async () => { + const scene = await postJSON("/api/demo", { id: d.id }).catch(() => null); + if (scene && !scene.error) { + activateTab("explanation"); + launchScene(scene, d.title); + } else { + dlcMsg("Could not load “" + d.title + "”.", true); + } + }); + return b; + } + + function renderPackDetail(pack) { + dlc.detail.innerHTML = ""; + const head = document.createElement("div"); + head.className = "dlc-detail-head"; + head.innerHTML = + '' + escapeHtml(pack.icon || "📦") + "" + + "
" + escapeHtml(pack.name) + "" + + ' ' + + (pack.source !== "official" + ? ' ' + : "") + + '

' + escapeHtml(pack.description || "") + "

"; + dlc.detail.appendChild(head); + const dl = head.querySelector(".dlc-dl"); + if (dl) dl.addEventListener("click", () => dlcExport(pack.id, pack.name)); + const rm = head.querySelector(".dlc-remove"); + if (rm) + rm.addEventListener("click", async () => { + await postJSON("/api/dlc/remove", { id: pack.id }).catch(() => {}); + dlcShowCatalog(); + dlcLoadCatalog(); + }); + + if (pack.experiments && pack.experiments.length) { + const h = document.createElement("h4"); + h.className = "dlc-group-title"; + h.textContent = "🧪 Experiments"; + dlc.detail.appendChild(h); + pack.experiments.forEach((e) => dlc.detail.appendChild(dlcDemoRow(e))); + } + (pack.sections || []).forEach((sec) => { + const h = document.createElement("h4"); + h.className = "dlc-group-title"; + h.textContent = sec.area; + dlc.detail.appendChild(h); + sec.topics.forEach((tp) => tp.demos.forEach((d) => dlc.detail.appendChild(dlcDemoRow(d)))); + }); + } + + function downloadBytes(bytes, filename, mime) { + const blob = new Blob([bytes], { type: mime || "application/octet-stream" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + const dlcSlug = (s) => (String(s || "pack").replace(/[^a-z0-9-]+/gi, "-").toLowerCase() || "pack"); + const dlcSafe = (s) => String(s || "item").replace(/[^a-z0-9_-]+/gi, "-"); + + // A DLC is packaged as a browsable folder inside a .zip: manifest.json + // (metadata) + demos/.json + experiments/.json. Reassembled on import. + function dlcToFiles(dlc) { + const manifest = Object.assign({}, dlc); + delete manifest.demos; + delete manifest.experiments; + manifest._layout = "folder/1"; + const files = [{ name: "manifest.json", text: JSON.stringify(manifest, null, 2) }]; + (dlc.demos || []).forEach((d) => + files.push({ name: "demos/" + dlcSafe(d.id) + ".json", text: JSON.stringify(d, null, 2) }), + ); + (dlc.experiments || []).forEach((e) => + files.push({ name: "experiments/" + dlcSafe(e.id) + ".json", text: JSON.stringify(e, null, 2) }), + ); + return files; + } + function filesToDlc(files) { + const byName = {}; + files.forEach((f) => (byName[f.name] = f.text)); + if (!byName["manifest.json"]) throw new Error("no manifest.json inside the .zip"); + const dlc = JSON.parse(byName["manifest.json"]); + delete dlc._layout; + const demos = []; + const experiments = []; + files.forEach((f) => { + if (/^demos\/.+\.json$/i.test(f.name)) demos.push(JSON.parse(f.text)); + else if (/^experiments\/.+\.json$/i.test(f.name)) experiments.push(JSON.parse(f.text)); + }); + if (demos.length) dlc.demos = demos; + if (experiments.length) dlc.experiments = experiments; + return dlc; + } + function downloadDlcZip(dlc, id) { + if (!window.VisualLMZip) return dlcMsg("Zip support didn't load — reload the page.", true); + downloadBytes(window.VisualLMZip.zip(dlcToFiles(dlc)), dlcSlug(id) + ".zip", "application/zip"); + } + async function dlcExport(id, name) { + try { + const res = await postJSON("/api/dlc/export", { id }); + downloadDlcZip(res.dlc, id || name); + } catch (e) { + dlcMsg("Download failed: " + e.message, true); + } + } + + async function dlcImportFile(file) { + if (!file) return; + try { + let obj; + if (/\.zip$/i.test(file.name)) { + if (!window.VisualLMZip) throw new Error("zip support didn't load — reload the page"); + obj = filesToDlc(window.VisualLMZip.unzip(new Uint8Array(await file.arrayBuffer()))); + } else { + obj = JSON.parse(await file.text()); + } + const res = await postJSON("/api/dlc/import", { dlc: obj }); + dlcMsg("Imported “" + (res.pack ? res.pack.name : obj.name || "pack") + "”.", false); + dlcShowCatalog(); + dlcLoadCatalog(); + } catch (e) { + dlcMsg("Import failed: " + e.message, true); + } + } + + function initDLC() { + if (!dlc.catalog) return; + if (dlc.back) dlc.back.addEventListener("click", dlcShowCatalog); + if (dlc.file) + dlc.file.addEventListener("change", (e) => + dlcImportFile(e.target.files && e.target.files[0]), + ); + if (dlc.drop) { + ["dragover", "dragenter"].forEach((ev) => + dlc.drop.addEventListener(ev, (e) => { + e.preventDefault(); + dlc.drop.classList.add("dragover"); + }), + ); + ["dragleave", "drop"].forEach((ev) => + dlc.drop.addEventListener(ev, () => dlc.drop.classList.remove("dragover")), + ); + dlc.drop.addEventListener("drop", (e) => { + e.preventDefault(); + dlcImportFile(e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]); + }); + } + // Reveal the desktop analysis uploader only when a real backend is present. + fetch("/api/health") + .then((r) => r.json()) + .then((h) => { + const isServer = h.generator && h.generator !== "browser"; + if (dlc.uploadBlock) dlc.uploadBlock.hidden = !isServer; + if (isServer) renderAnalyzeForm(); + }) + .catch(() => {}); + dlcLoadCatalog(); + } + /* ============================================================== */ /* Boot */ /* ============================================================== */ refreshStatus(); fetchResources(); + initDLC(); state.chat = [ { role: "assistant", diff --git a/app_main.py b/app_main.py new file mode 100755 index 0000000..01330cc --- /dev/null +++ b/app_main.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""VisualLM native-app entry point — runs the server IN-PROCESS and opens a window. + +This is what PyInstaller freezes into VisualLM.app, and it also works unfrozen +(`python3 app_main.py`). Unlike launch.py / desktop.py — which spawn `python +main.py` as a subprocess for the dev workflow — this imports the server and runs +it in a background thread, because a frozen app's `sys.executable` is the app +binary, not a Python interpreter, so spawning a subprocess can't work once bundled. + +Flags: + --no-window start the server only (print the URL, keep serving) + --smoke start, confirm /api/health, stop, exit 0 (for testing) +""" +from __future__ import annotations + +import os +import sys +import threading +from http.server import ThreadingHTTPServer +from pathlib import Path + +import launch # reuse load_dotenv / free_port / wait_healthy / open_app_window + +HERE = Path(__file__).resolve().parent + + +def _env_files() -> list[Path]: + """Where to look for a .env. When frozen (inside VisualLM.app) __file__ is + in the bundle, so also check next to the .app and a per-user config dir — + that's where a user can drop ANTHROPIC_API_KEY=... for the packaged app.""" + if getattr(sys, "frozen", False): + out: list[Path] = [] + exe = Path(sys.executable).resolve() + # .../VisualLM.app/Contents/MacOS/VisualLM -> the folder holding the .app + if len(exe.parents) >= 4: + out.append(exe.parents[3] / ".env") + out.append(Path.home() / ".visuallm" / ".env") + return out + return [HERE / ".env"] + + +def main() -> int: + for env_file in _env_files(): + launch.load_dotenv(env_file) + forced = os.environ.get("VISUALLM_PORT") + port = int(forced) if forced else launch.free_port(4173) + os.environ["VISUALLM_PORT"] = str(port) + os.environ.setdefault("VISUALLM_HOST", "127.0.0.1") + url = f"http://127.0.0.1:{port}" + + # Importing the server module is side-effect-free (its server start is under + # an `if __name__ == "__main__"` guard); we drive it ourselves below. + import main as server_mod + + httpd = ThreadingHTTPServer(("127.0.0.1", port), server_mod.VisualLMHandler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + + smoke = "--smoke" in sys.argv + no_window = smoke or "--no-window" in sys.argv + try: + if not launch.wait_healthy(f"{url}/api/health"): + print("✗ Server did not become healthy.") + return 1 + print(f"✓ VisualLM is up on {url}") + if smoke: + # Also confirm static assets serve — the real test that bundled data + # files (index.html etc.) resolve via BASE_DIR when frozen. + import urllib.request + try: + with urllib.request.urlopen(url + "/", timeout=3) as r: + served = r.status == 200 and b"VisualLM" in r.read() + except Exception: # noqa: BLE001 + served = False + print("✓ static assets served (index.html)" if served else "✗ static assets NOT served") + print("✓ Smoke check passed." if served else "✗ Smoke check FAILED.") + return 0 if served else 1 + if no_window: + print(f" Open {url} in your browser. Ctrl+C to stop.") + _block() + return 0 + try: + import webview # pywebview → true native window + except ImportError: + print("• pywebview not installed — opening an app-style browser window.") + print(" (pip install pywebview for a true native window.)") + launch.open_app_window(url) + _block() + return 0 + webview.create_window("VisualLM", url, width=1280, height=820, min_size=(900, 600)) + webview.start() # blocks on the main thread until the window closes + return 0 + finally: + httpd.shutdown() + + +def _block() -> None: + try: + threading.Event().wait() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build_app.sh b/build_app.sh new file mode 100755 index 0000000..f7e8757 --- /dev/null +++ b/build_app.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Build a SELF-CONTAINED, double-click VisualLM.app with PyInstaller. +# +# ./build_app.sh +# open dist/VisualLM.app +# +# Unlike make_app.sh (a lightweight wrapper that needs the repo + system Python), +# this produces a standalone bundle that includes Python and all dependencies. +# To bundle Claude support, `pip install anthropic` first; otherwise the app +# uses OpenAI / Gemini, or the built-in pure-code library (no local model app). +# For a true native window, `pip install pywebview` +# before building; otherwise it opens an app-style browser window. +# +# macOS only (uses sips/iconutil for the icon). Output: dist/VisualLM.app +set -euo pipefail +cd "$(dirname "$0")" + +echo "• Ensuring PyInstaller is available …" +python3 -c "import PyInstaller" 2>/dev/null || python3 -m pip install --disable-pip-version-check pyinstaller + +echo "• Generating icon …" +python3 make_icon.py VisualLM.png >/dev/null +ICONSET="VisualLM.iconset"; rm -rf "$ICONSET"; mkdir "$ICONSET" +gen() { sips -z "$2" "$2" VisualLM.png --out "$ICONSET/$1" >/dev/null; } +gen icon_16x16.png 16; gen icon_16x16@2x.png 32 +gen icon_32x32.png 32; gen icon_32x32@2x.png 64 +gen icon_128x128.png 128; gen icon_128x128@2x.png 256 +gen icon_256x256.png 256; gen icon_256x256@2x.png 512 +gen icon_512x512.png 512; gen icon_512x512@2x.png 1024 +iconutil -c icns "$ICONSET" -o VisualLM.icns +rm -rf "$ICONSET" VisualLM.png + +echo "• Freezing app with PyInstaller …" +python3 -m PyInstaller --noconfirm --clean VisualLM.spec + +rm -f VisualLM.icns +echo "✓ Built dist/VisualLM.app" +echo " Verify: dist/VisualLM.app/Contents/MacOS/VisualLM --smoke" +echo " Run: open dist/VisualLM.app" diff --git a/chemistry.py b/chemistry.py new file mode 100644 index 0000000..38079bc --- /dev/null +++ b/chemistry.py @@ -0,0 +1,1020 @@ +"""Chemistry visualization subsystem for VisualLM. + +Two capabilities, both fired from `chemistry_scene(prompt)`: + + 1. MOLECULE — a chemical formula or a known molecule name ("CH4", "benzene", + "H2O") renders a 3D ball-and-stick structure. Geometry comes from one of: + - a curated/validated library (`MOLECULES` + chemistry_molecules_generated.json), + - a VSEPR generator for single-centre hydride/halide molecules (CH4, NH3, + H2O, SF6, PCl5, ...), computed from valence electrons + electron-domain + geometry. Correct angles, no hand coordinates needed. + + 2. BALANCE — a reaction ("C3H8 + O2 -> CO2 + H2O") is balanced exactly (integer + coefficients via the rational null space of the element-composition matrix) + and rendered as reactant cards → balanced product cards, with a per-element + conservation tally proving both sides match. + +Pure standard library — no numpy/scipy. Coordinates are in Ångström internally +and normalized to a fixed on-screen size at render time, so every molecule frames +consistently. The emitted `code` is a bare `scene(ctx, t)` body using the worker's +`H`/`cam3d` helpers; it carries its own data as JS literals (no `P` params). +""" + +from __future__ import annotations + +import json +import math +import os +import re +from fractions import Fraction + +# --------------------------------------------------------------------------- # +# Element data +# --------------------------------------------------------------------------- # + +# Every real element symbol — used only to decide whether a token is a valid +# chemical formula (so we don't hijack a math/physics prompt). Curated visual +# data (color/radius) lives in ELEMENT_DATA below; anything here but not there +# renders with a neutral default. +PERIODIC = { + "H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne", + "Na", "Mg", "Al", "Si", "P", "S", "Cl", "Ar", + "K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu", "Zn", + "Ga", "Ge", "As", "Se", "Br", "Kr", + "Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd", + "In", "Sn", "Sb", "Te", "I", "Xe", + "Cs", "Ba", "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd", "Tb", "Dy", + "Ho", "Er", "Tm", "Yb", "Lu", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", + "Au", "Hg", "Tl", "Pb", "Bi", "Po", "At", "Rn", + "Fr", "Ra", "Ac", "Th", "Pa", "U", "Np", "Pu", +} + +# CPK-ish colors (tuned for a dark canvas), covalent radii (Å), and a label +# text color (light symbol on dark atoms, dark symbol on light atoms). +# (color, covalent_radius_Å, valence_electrons_or_None) +_E = { + "H": ("#f5f7ff", 0.31, 1), + "He": ("#d9ffff", 0.28, 8), + "Li": ("#cc80ff", 1.28, 1), + "Be": ("#c2ff00", 0.96, 2), + "B": ("#ffb5b5", 0.84, 3), + "C": ("#4a4f5c", 0.76, 4), + "N": ("#5a7bff", 0.71, 5), + "O": ("#ff4d4d", 0.66, 6), + "F": ("#7fe06a", 0.57, 7), + "Ne": ("#b3e3f5", 0.58, 8), + "Na": ("#ab5cf2", 1.66, 1), + "Mg": ("#8aff00", 1.41, 2), + "Al": ("#bfa6a6", 1.21, 3), + "Si": ("#f0c8a0", 1.11, 4), + "P": ("#ff8000", 1.07, 5), + "S": ("#ffe23d", 1.05, 6), + "Cl": ("#3df04a", 1.02, 7), + "Ar": ("#80d1e3", 1.06, 8), + "K": ("#8f40d4", 2.03, 1), + "Ca": ("#3dff00", 1.76, 2), + "Mn": ("#9c7ac7", 1.39, None), + "Fe": ("#e06633", 1.32, None), + "Co": ("#f090a0", 1.26, None), + "Ni": ("#50d050", 1.24, None), + "Cu": ("#c88033", 1.32, None), + "Zn": ("#7d80b0", 1.22, None), + "Br": ("#c44d3a", 1.20, 7), + "Se": ("#ffa100", 1.20, 6), + "As": ("#bd80e3", 1.19, 5), + "I": ("#a04dd6", 1.39, 7), + "Xe": ("#66c6cf", 1.40, 8), + "Ag": ("#c0c0c8", 1.45, None), + "Au": ("#ffd123", 1.36, None), + "Pb": ("#575961", 1.46, 4), + "Sn": ("#9e9eae", 1.39, 4), + "Te": ("#d47a00", 1.38, 6), + "Sb": ("#9e63b5", 1.39, 5), + "B ": ("#ffb5b5", 0.84, 3), +} + +_DEFAULT_ELEMENT = ("#ff80c0", 1.40, None) # neutral pink for uncolored elements + + +def element_color(sym: str) -> str: + return _E.get(sym, _DEFAULT_ELEMENT)[0] + + +def covalent_radius(sym: str) -> float: + return _E.get(sym, _DEFAULT_ELEMENT)[1] + + +def valence_electrons(sym: str): + return _E.get(sym, _DEFAULT_ELEMENT)[2] + + +def _luminance(hex_color: str) -> float: + h = hex_color.lstrip("#") + if len(h) != 6: + return 0.5 + try: + r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4)) + except ValueError: + return 0.5 + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def _label_color(hex_color: str) -> str: + return "#0c1020" if _luminance(hex_color) > 0.55 else "#f3f6ff" + + +# --------------------------------------------------------------------------- # +# Formula parsing +# --------------------------------------------------------------------------- # + +_TERMINALS = {"H", "F", "Cl", "Br", "I"} +_FORMULA_TOKEN_RE = re.compile(r"^[A-Za-z0-9()]+[+-]?$") + + +def parse_formula(formula: str) -> dict | None: + """'C6H12O6' -> {'C':6,'H':12,'O':6}; supports parentheses ('Ca(OH)2'). + + Returns None if the string isn't a valid neutral formula over real elements. + A trailing charge ('SO4^2-', 'NH4+') is tolerated and ignored. + """ + if not formula: + return None + s = formula.strip() + # Drop a trailing ionic charge. With a caret ('SO4^2-'), the charge is + # everything after it. Without ('NH4+'), only a bare trailing sign is the + # charge — any digits before it are subscripts, so don't eat them. + if "^" in s: + s = s.split("^", 1)[0] + else: + s = re.sub(r"[+-]$", "", s) + if not s or not re.match(r"^[A-Za-z0-9()]+$", s): + return None + + counts: dict[str, int] = {} + stack: list[dict[str, int]] = [counts] + i, n = 0, len(s) + while i < n: + ch = s[i] + if ch == "(": + new: dict[str, int] = {} + stack.append(new) + i += 1 + elif ch == ")": + i += 1 + j = i + while j < n and s[j].isdigit(): + j += 1 + mult = int(s[i:j]) if j > i else 1 + i = j + if len(stack) < 2: + return None + group = stack.pop() + for el, c in group.items(): + stack[-1][el] = stack[-1].get(el, 0) + c * mult + elif ch.isupper(): + j = i + 1 + if j < n and s[j].islower(): + j += 1 + sym = s[i:j] + if sym not in PERIODIC: + return None + i = j + j = i + while j < n and s[j].isdigit(): + j += 1 + cnt = int(s[i:j]) if j > i else 1 + i = j + stack[-1][sym] = stack[-1].get(sym, 0) + cnt + else: + return None + if len(stack) != 1: + return None + counts = {k: v for k, v in counts.items() if v > 0} + return counts or None + + +def formula_atom_count(formula: str) -> int: + counts = parse_formula(formula) + return sum(counts.values()) if counts else 0 + + +_SUBSCRIPTS = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉") + + +def pretty_formula(formula: str) -> str: + """'C6H12O6' -> 'C₆H₁₂O₆' (subscript the digits) for display strings.""" + return re.sub(r"\d+", lambda m: m.group(0).translate(_SUBSCRIPTS), formula) + + +# --------------------------------------------------------------------------- # +# Equation parsing + balancing +# --------------------------------------------------------------------------- # + +_ARROW_RE = re.compile(r"\s*(=+>|-+>|→|⟶|⇌|=)\s*") + + +def parse_equation(text: str): + """'2H2 + O2 -> 2H2O' -> (reactants, products) as [(coeff, formula), ...] + with any existing coefficients stripped to 1 (we re-balance). Returns None + if the text isn't a two-sided reaction with valid species on both sides.""" + if not text: + return None + parts = _ARROW_RE.split(text.strip(), maxsplit=1) + if len(parts) < 3: + return None + left, right = parts[0], parts[-1] + + def _species(side: str): + out = [] + for chunk in side.split("+"): + chunk = chunk.strip() + if not chunk: + continue + # Drop non-species reaction terms sometimes written in equations. + if chunk.lower() in {"heat", "energy", "light", "δ", "delta"}: + continue + # Strip a leading stoichiometric coefficient ("2H2O" / "2 H2O"). + m = re.match(r"^(\d+)\s*([A-Za-z(].*)$", chunk) + formula = m.group(2).strip() if m else chunk + if parse_formula(formula) is None: + return None + out.append(formula) + return out or None + + r = _species(left) + p = _species(right) + if not r or not p: + return None + return r, p + + +def _rref(matrix: list[list[Fraction]]): + """Reduced row echelon form (in place copy) -> (rref, pivot_columns).""" + M = [row[:] for row in matrix] + rows = len(M) + cols = len(M[0]) if rows else 0 + pivots = [] + r = 0 + for c in range(cols): + pivot = next((i for i in range(r, rows) if M[i][c] != 0), None) + if pivot is None: + continue + M[r], M[pivot] = M[pivot], M[r] + inv = M[r][c] + M[r] = [x / inv for x in M[r]] + for i in range(rows): + if i != r and M[i][c] != 0: + f = M[i][c] + M[i] = [a - f * b for a, b in zip(M[i], M[r])] + pivots.append(c) + r += 1 + if r == rows: + break + return M, pivots + + +def balance_equation(reactants: list[str], products: list[str]): + """Return integer coefficients [r1.., p1..] balancing the reaction, or None. + + Sets up element conservation A·x = 0 (reactants +, products −), finds the + 1-D rational null space, and scales to the smallest positive integers. + None if it can't be balanced uniquely (no solution / multiple independent + reactions / a non-positive coefficient).""" + species = reactants + products + n = len(species) + if n < 2: + return None + comps = [parse_formula(f) for f in species] + if any(c is None for c in comps): + return None + elements = sorted({e for c in comps for e in c}) + # Element-composition matrix: row per element, col per species. + A: list[list[Fraction]] = [] + for el in elements: + row = [] + for idx, c in enumerate(comps): + val = c.get(el, 0) + row.append(Fraction(val if idx < len(reactants) else -val)) + A.append(row) + + rref, pivots = _rref(A) + free = [c for c in range(n) if c not in pivots] + # A unique balance needs exactly one free variable (1-D null space). + if len(free) != 1: + return None + fcol = free[0] + x = [Fraction(0)] * n + x[fcol] = Fraction(1) + for ri, pc in enumerate(pivots): + # pivot var = -sum(coeff*free) ; only the single free col contributes. + x[pc] = -rref[ri][fcol] + + denom_lcm = 1 + for v in x: + denom_lcm = denom_lcm * v.denominator // math.gcd(denom_lcm, v.denominator) + ints = [int(v * denom_lcm) for v in x] + g = 0 + for v in ints: + g = math.gcd(g, abs(v)) + if g == 0: + return None + ints = [v // g for v in ints] + if all(v <= 0 for v in ints): + ints = [-v for v in ints] + if any(v <= 0 for v in ints): + return None + return ints + + +# --------------------------------------------------------------------------- # +# VSEPR geometry (single-centre hydride / halide molecules) +# --------------------------------------------------------------------------- # + +def _u(v): + n = math.sqrt(sum(c * c for c in v)) or 1.0 + return [c / n for c in v] + + +def _tetra(): + return [_u(v) for v in ([1, 1, 1], [1, -1, -1], [-1, 1, -1], [-1, -1, 1])] + + +def _trig_planar(): + return [[math.cos(a), math.sin(a), 0.0] + for a in (0.0, 2 * math.pi / 3, 4 * math.pi / 3)] + + +def _tbp(): + eq = [[math.cos(a), 0.0, math.sin(a)] + for a in (0.0, 2 * math.pi / 3, 4 * math.pi / 3)] + return [[0, 1, 0], [0, -1, 0]] + eq # 2 axial + 3 equatorial + + +def _octa(): + return [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]] + + +# (steric_number, lone_pairs) -> bonding direction unit vectors. Lone pairs are +# placed in the positions VSEPR predicts; only the bonding directions remain. +def _geometry(sn: int, lp: int): + if sn == 2: + return [[1, 0, 0], [-1, 0, 0]], "linear" + if sn == 3: + tri = _trig_planar() + if lp == 0: + return tri, "trigonal planar" + if lp == 1: + return [tri[1], tri[2]], "bent" + if sn == 4: + tet = _tetra() + if lp == 0: + return tet, "tetrahedral" + if lp == 1: + return tet[1:], "trigonal pyramidal" + if lp == 2: + return [tet[1], tet[2]], "bent" + if sn == 5: + t = _tbp() # [ax+, ax-, eq0, eq1, eq2] + if lp == 0: + return t, "trigonal bipyramidal" + if lp == 1: + return [t[0], t[1], t[3], t[4]], "seesaw" + if lp == 2: + return [t[0], t[1], t[2]], "T-shaped" + if lp == 3: + return [t[0], t[1]], "linear" + if sn == 6: + o = _octa() + if lp == 0: + return o, "octahedral" + if lp == 1: + return o[:5], "square pyramidal" + if lp == 2: + return [o[0], o[1], o[4], o[5]], "square planar" + return None, None + + +def vsepr_molecule(formula: str): + """Build a {atoms, bonds, shape} for a single-centre hydride/halide molecule, + or None when the formula isn't of that form (then library/generative handles + it). Central atom = the one element that is not H or a halogen, count 1.""" + counts = parse_formula(formula) + if not counts: + return None + non_terminal = [e for e in counts if e not in _TERMINALS] + if len(non_terminal) != 1 or counts[non_terminal[0]] != 1: + return None + central = non_terminal[0] + ve = valence_electrons(central) + if ve is None: + return None + terminals: list[str] = [] + for el, c in counts.items(): + if el != central: + terminals.extend([el] * c) + n = len(terminals) + if n < 2: + return None + lone2 = ve - n + if lone2 < 0 or lone2 % 2 != 0: + return None + lp = lone2 // 2 + sn = n + lp + dirs, shape = _geometry(sn, lp) + if dirs is None or len(dirs) != n: + return None + + # Heavier terminals first so they land on the more spread-out positions + # (purely cosmetic; chemistry is symmetric for identical terminals). + terminals.sort(key=lambda e: -covalent_radius(e)) + atoms = [{"e": central, "p": [0.0, 0.0, 0.0]}] + bonds = [] + rc = covalent_radius(central) + for k, d in enumerate(dirs): + el = terminals[k] + length = rc + covalent_radius(el) + d = _u(d) + atoms.append({"e": el, "p": [d[0] * length, d[1] * length, d[2] * length]}) + bonds.append([0, k + 1, 1]) + return {"atoms": atoms, "bonds": bonds, "shape": shape} + + +# --------------------------------------------------------------------------- # +# Canonical formula (Hill system) — order-independent library keys +# --------------------------------------------------------------------------- # + +_HILL_ORDER = ["C", "H"] + + +def _canonical_formula(counts: dict) -> str: + """Hill system: C first, H second, then the rest alphabetical.""" + parts = [] + for el in _HILL_ORDER: + if el in counts: + parts.append(el + (str(counts[el]) if counts[el] > 1 else "")) + for el in sorted(k for k in counts if k not in _HILL_ORDER): + parts.append(el + (str(counts[el]) if counts[el] > 1 else "")) + return "".join(parts) + + +def _canon(formula: str) -> str: + counts = parse_formula(formula) + return _canonical_formula(counts) if counts else formula + + +def _shape_name(formula: str, shape) -> str: + return f"{pretty_formula(formula)} — {shape}" if shape else pretty_formula(formula) + + +# --------------------------------------------------------------------------- # +# Curated molecule library (correct geometry for non-VSEPR / named molecules) +# --------------------------------------------------------------------------- # + +def _diatomic(a: str, b: str, order: int = 1, length: float | None = None): + if length is None: + length = covalent_radius(a) + covalent_radius(b) + return { + "atoms": [{"e": a, "p": [-length / 2, 0, 0]}, + {"e": b, "p": [length / 2, 0, 0]}], + "bonds": [[0, 1, order]], + } + + +def _co2(): + d = 1.16 + return { + "atoms": [{"e": "C", "p": [0, 0, 0]}, + {"e": "O", "p": [-d, 0, 0]}, + {"e": "O", "p": [d, 0, 0]}], + "bonds": [[0, 1, 2], [0, 2, 2]], + } + + +def _benzene(): + R = 1.39 + Rh = R + 1.09 # C–H ~1.09 Å outward + atoms, bonds = [], [] + for i in range(6): + a = i * math.pi / 3 + atoms.append({"e": "C", "p": [R * math.cos(a), R * math.sin(a), 0]}) + for i in range(6): + a = i * math.pi / 3 + atoms.append({"e": "H", "p": [Rh * math.cos(a), Rh * math.sin(a), 0]}) + for i in range(6): + bonds.append([i, (i + 1) % 6, 2 if i % 2 == 0 else 1]) # kekulé + bonds.append([i, i + 6, 1]) + return {"atoms": atoms, "bonds": bonds} + + +def _bent_triatomic(center, outer, angle_deg, bond_len, order): + half = math.radians(angle_deg) / 2 + dx = bond_len * math.sin(half) + dy = bond_len * math.cos(half) + return { + "atoms": [{"e": center, "p": [0, 0, 0]}, + {"e": outer, "p": [-dx, -dy, 0]}, + {"e": outer, "p": [dx, -dy, 0]}], + "bonds": [[0, 1, order], [0, 2, order]], + } + + +# Seed library — correct geometry, computed (not hand-typed) where possible. The +# workflow-generated chemistry_molecules_generated.json is merged on top of this. +# Keys here are human-readable formulas; they're re-keyed by canonical (Hill) +# formula at registration so lookup is order-independent. +def _seed_library() -> dict: + return { + "H2": {**_diatomic("H", "H", 1, 0.74), "name": "Hydrogen (H₂)", + "names": ["hydrogen", "dihydrogen"]}, + "O2": {**_diatomic("O", "O", 2, 1.21), "name": "Oxygen (O₂)", + "names": ["oxygen", "dioxygen"]}, + "N2": {**_diatomic("N", "N", 3, 1.10), "name": "Nitrogen (N₂)", + "names": ["nitrogen", "dinitrogen"]}, + "Cl2": {**_diatomic("Cl", "Cl", 1, 1.99), "name": "Chlorine (Cl₂)", + "names": ["chlorine"]}, + "HCl": {**_diatomic("H", "Cl", 1, 1.27), "name": "Hydrogen chloride (HCl)", + "names": ["hydrogen chloride", "hydrochloric acid"]}, + "HF": {**_diatomic("H", "F", 1, 0.92), "name": "Hydrogen fluoride (HF)", + "names": ["hydrogen fluoride"]}, + "CO": {**_diatomic("C", "O", 3, 1.13), "name": "Carbon monoxide (CO)", + "names": ["carbon monoxide"]}, + "NaCl": {**_diatomic("Na", "Cl", 1, 2.36), "name": "Sodium chloride (NaCl)", + "names": ["sodium chloride", "table salt", "halite"]}, + "CO2": {**_co2(), "name": "Carbon dioxide (CO₂)", + "names": ["carbon dioxide", "dry ice"]}, + "SO2": {**_bent_triatomic("S", "O", 119.0, 1.43, 2), "name": "Sulfur dioxide (SO₂)", + "names": ["sulfur dioxide", "sulphur dioxide"]}, + "O3": {**_bent_triatomic("O", "O", 117.0, 1.28, 1), "name": "Ozone (O₃)", + "names": ["ozone"]}, + "C6H6": {**_benzene(), "name": "Benzene (C₆H₆)", + "names": ["benzene"]}, + } + + +# Common molecule names → formula, so a bare "methane" / "ammonia" resolves +# (via the library or the VSEPR generator) even when not in the curated library. +_COMMON_NAMES = { + "methane": "CH4", "ammonia": "NH3", "water": "H2O", + "carbon dioxide": "CO2", "carbon monoxide": "CO", "benzene": "C6H6", + "ozone": "O3", "sulfur dioxide": "SO2", "sulphur dioxide": "SO2", + "sulfur hexafluoride": "SF6", "phosphorus pentachloride": "PCl5", + "boron trifluoride": "BF3", "hydrogen sulfide": "H2S", + "methanol": "CH4O", "ethanol": "C2H6O", "glucose": "C6H12O6", + "acetic acid": "C2H4O2", "ethane": "C2H6", "ethene": "C2H4", + "ethyne": "C2H2", "acetylene": "C2H2", "ethylene": "C2H4", + "hydrogen peroxide": "H2O2", "ammonium": "NH4", + "silane": "SiH4", "phosphine": "PH3", "sodium chloride": "NaCl", + "table salt": "NaCl", +} + +# Ambiguous everyday/physics words — only treat as a molecule with an explicit +# structure cue, so we don't hijack "water waves" or "oxygen tank". +_AMBIGUOUS_NAMES = {"water", "oxygen", "nitrogen", "hydrogen", "salt", "ozone"} + +MOLECULES: dict = {} +_NAME_INDEX: dict = {} + + +def _register(disp_formula: str, mol: dict): + canon = _canon(disp_formula) + mol = dict(mol) + mol.setdefault("formula", disp_formula) + MOLECULES[canon] = mol + + +def _index_names(): + _NAME_INDEX.clear() + for canon, mol in MOLECULES.items(): + _NAME_INDEX[canon.lower()] = canon + disp = mol.get("formula", canon) + _NAME_INDEX[disp.lower()] = canon + for nm in mol.get("names", []): + _NAME_INDEX[str(nm).strip().lower()] = canon + + +def _load_generated(path: str = "chemistry_molecules_generated.json"): + """Merge a workflow-generated, chemically-verified molecule library.""" + MOLECULES.clear() + for disp, mol in _seed_library().items(): + _register(disp, mol) + full = path if os.path.isabs(path) else os.path.join(os.path.dirname(__file__), path) + try: + with open(full, "r", encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + _index_names() + return + items = data.get("molecules", data) if isinstance(data, dict) else data + if isinstance(items, list): + for mol in items: + f = mol.get("formula") + if f and isinstance(mol.get("atoms"), list) and mol.get("bonds") is not None: + _register(f, mol) + _index_names() + + +_load_generated() + + +# --------------------------------------------------------------------------- # +# Molecule lookup +# --------------------------------------------------------------------------- # + +def _normalize_geometry(mol: dict) -> dict: + """Center the molecule and scale to a fixed bounding radius so every + structure frames the same on screen. Attaches per-atom color/radius/label.""" + atoms = mol["atoms"] + cx = sum(a["p"][0] for a in atoms) / len(atoms) + cy = sum(a["p"][1] for a in atoms) / len(atoms) + cz = sum(a["p"][2] for a in atoms) / len(atoms) + maxr = 0.0 + for a in atoms: + p = a["p"] + d = math.sqrt((p[0] - cx) ** 2 + (p[1] - cy) ** 2 + (p[2] - cz) ** 2) + maxr = max(maxr, d) + k = (3.0 / maxr) if maxr > 1e-6 else 1.0 + out_atoms = [] + for a in atoms: + p = a["p"] + col = element_color(a["e"]) + out_atoms.append({ + "e": a["e"], + "p": [round((p[0] - cx) * k, 4), round((p[1] - cy) * k, 4), + round((p[2] - cz) * k, 4)], + "c": col, + "lc": _label_color(col), + "r": round(max(0.28, covalent_radius(a["e"]) * 0.42) * k, 4), + }) + bonds = [[int(b[0]), int(b[1]), int(b[2]) if len(b) > 2 else 1] + for b in mol["bonds"]] + return {"atoms": out_atoms, "bonds": bonds} + + +def _library_geo(canon: str, display_override: str | None = None): + mol = MOLECULES[canon] + geo = _normalize_geometry(mol) + geo["formula"] = display_override or mol.get("formula", canon) + geo["name"] = mol.get("name", pretty_formula(geo["formula"])) + geo["shape"] = mol.get("shape", "") + return geo + + +def lookup_molecule(query: str): + """Resolve a formula or name to a normalized renderable molecule, or None. + + Tries the curated library first (by canonical formula then name), then the + VSEPR generator for single-centre hydride/halide formulas.""" + if not query: + return None + q = query.strip() + counts = parse_formula(q) + if counts: + canon = _canonical_formula(counts) + if canon in MOLECULES: + return _library_geo(canon) + v = vsepr_molecule(q) + if v: + geo = _normalize_geometry(v) + geo["formula"] = q # show the formula as the user typed it + geo["name"] = _shape_name(q, v.get("shape")) + geo["shape"] = v.get("shape", "") + return geo + return None + key = q.lower() + if key in _NAME_INDEX: + return _library_geo(_NAME_INDEX[key]) + if key in _COMMON_NAMES: + return lookup_molecule(_COMMON_NAMES[key]) + return None + + +# --------------------------------------------------------------------------- # +# Detection +# --------------------------------------------------------------------------- # + +_CHEM_KEYWORDS = re.compile( + r"\b(molecul\w*|compound|lewis|vsepr|ball[- ]and[- ]stick|3d\s+structure|" + r"structure of|shape of|geometry of|bond(s|ing)?|chemical structure)\b", + re.I, +) +_BALANCE_KEYWORDS = re.compile(r"\b(balanc\w+|stoichiometr\w+|reaction|reactants?|products?)\b", re.I) + + +def _looks_like_formula(token: str) -> bool: + counts = parse_formula(token) + if not counts: + return False + total = sum(counts.values()) + if total < 2: + return False + has_digit = bool(re.search(r"\d", token)) + multi_element = len(counts) >= 2 + return has_digit or multi_element + + +def _find_formula(prompt: str): + """Pull the most formula-like token from a prompt, or None.""" + best = None + for raw in re.split(r"[\s,;:]+", prompt.strip()): + tok = raw.strip().strip(".?!)()\"'") + if not tok or not _FORMULA_TOKEN_RE.match(tok): + continue + if _looks_like_formula(tok): + # Prefer the longer / more specific token. + if best is None or len(tok) > len(best): + best = tok + return best + + +_COMMAND_PREFIX = re.compile( + r"^\s*(please\s+)?(balance|solve|complete|finish)\b" + r"(\s+(the|this))?(\s+(equation|reaction|chemical\s+equation))?\s*[:\-]?\s*", + re.I, +) + + +def _bare_name_triggers(): + """Multi-letter molecule names safe to fire on without a structure cue.""" + names = set(k for k in _NAME_INDEX if k.isalpha() and len(k) >= 4) + names |= set(k for k in _COMMON_NAMES if all(p.isalpha() for p in k.split()) and len(k) >= 4) + return names - _AMBIGUOUS_NAMES + + +def detect_chemistry(prompt: str): + """Classify a prompt -> ('balance', (reactants, products)) | + ('molecule', query) | None. Conservative: only fires on an unambiguous + chemical formula, a reaction, or a recognized molecule name.""" + if not prompt: + return None + text = prompt.strip() + # Drop a leading command phrase ("balance the equation: ...") so the reaction + # parser sees the species, not the verb. + reaction_text = _COMMAND_PREFIX.sub("", text) + + # Reaction first — an arrow with valid species on both sides is unambiguous. + eq = parse_equation(reaction_text) + if eq is not None: + return ("balance", eq) + + # Bare formula (or formula + words): "CH4", "draw C6H12O6", "H2O structure". + formula = _find_formula(text) + if formula is not None: + return ("molecule", formula) + + low = text.lower() + # A recognized molecule name. Unambiguous names ("benzene", "methane") fire + # on their own; everyday/physics words ("water", "oxygen") need a structure + # cue so we don't hijack "water waves" or "liquid oxygen". + if _CHEM_KEYWORDS.search(text): + candidates = set(_NAME_INDEX) | set(_COMMON_NAMES) # incl. ambiguous + else: + candidates = _bare_name_triggers() + # Match the longest name first ("carbon dioxide" before "carbon monoxide"). + for name in sorted(candidates, key=len, reverse=True): + if len(name) >= 4 and re.search(r"\b" + re.escape(name) + r"\b", low): + return ("molecule", name) + return None + + +# --------------------------------------------------------------------------- # +# Scene code builders (emit a bare scene(ctx, t) body) +# --------------------------------------------------------------------------- # + +def _molecule_code(mol: dict) -> str: + data = json.dumps({"atoms": mol["atoms"], "bonds": mol["bonds"], + "name": mol["name"], "formula": pretty_formula(mol["formula"])}, + ensure_ascii=False) + return ( + "H.background();\n" + f"const MOL = {data};\n" + "const w = H.W, hgt = H.H;\n" + "const cam = H.cam3d({ scale: 46, dist: 15, pitch: -0.16, cy: hgt * 0.54 });\n" + "cam.yaw = 0.45 * t;\n" + "const proj = MOL.atoms.map(function (a) { return cam.project(a.p); });\n" + "function bond(i, j, order) {\n" + " const p1 = proj[i], p2 = proj[j];\n" + " let dx = p2.x - p1.x, dy = p2.y - p1.y;\n" + " const len = Math.sqrt(dx * dx + dy * dy) || 1;\n" + " const ux = -dy / len, uy = dx / len;\n" + " const gap = 3.4;\n" + " const offs = order >= 3 ? [-gap * 1.5, 0, gap * 1.5]\n" + " : order === 2 ? [-gap, gap] : [0];\n" + " for (let k = 0; k < offs.length; k++) {\n" + " const o = offs[k];\n" + " H.line(p1.x + ux * o, p1.y + uy * o, p2.x + ux * o, p2.y + uy * o,\n" + " { color: H.colors.sub, width: 3 });\n" + " }\n" + "}\n" + "for (let b = 0; b < MOL.bonds.length; b++) {\n" + " bond(MOL.bonds[b][0], MOL.bonds[b][1], MOL.bonds[b][2]);\n" + "}\n" + "const order = MOL.atoms.map(function (a, idx) {\n" + " return { idx: idx, depth: proj[idx].depth };\n" + "}).sort(function (u, v) { return v.depth - u.depth; });\n" + "for (let s = 0; s < order.length; s++) {\n" + " const a = MOL.atoms[order[s].idx];\n" + " cam.sphere(a.p, a.r, { color: a.c, stroke: 'rgba(6,8,18,0.45)', width: 1 });\n" + " const q = proj[order[s].idx];\n" + " if (a.e !== 'H') H.text(a.e, q.x, q.y,\n" + " { color: a.lc, size: 12, weight: 700, align: 'center', baseline: 'middle' });\n" + "}\n" + "H.text(MOL.name, 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\n" + "H.text(MOL.formula + ' · drag to orbit, scroll to zoom', 24, 52,\n" + " { color: H.colors.sub, size: 13 });\n" + "H.legend(MOL.atoms.filter(function (a, i, arr) {\n" + " return arr.findIndex(function (b) { return b.e === a.e; }) === i;\n" + "}).map(function (a) { return { label: a.e, color: a.c }; }), 24, hgt - 28);\n" + ) + + +def _element_chip_color(el: str) -> str: + return element_color(el) + + +def _balance_code(reactants, products, coeffs) -> str: + nr = len(reactants) + rc = coeffs[:nr] + pc = coeffs[nr:] + comps = [parse_formula(f) for f in reactants + products] + elements = sorted({e for c in comps for e in c}) + tally = [] + for el in elements: + left = sum(rc[i] * comps[i].get(el, 0) for i in range(nr)) + right = sum(pc[i] * comps[nr + i].get(el, 0) for i in range(len(products))) + tally.append({"el": el, "left": left, "right": right, "color": element_color(el)}) + + def _side(formulas, cs): + return [{"coef": cs[i], "formula": pretty_formula(formulas[i])} + for i in range(len(formulas))] + + def _eqstr(formulas, cs): + toks = [] + for i, f in enumerate(formulas): + pre = (str(cs[i]) + " ") if cs[i] != 1 else "" + toks.append(pre + pretty_formula(f)) + return " + ".join(toks) + + balanced = _eqstr(reactants, rc) + " → " + _eqstr(products, pc) + data = json.dumps({ + "reactants": _side(reactants, rc), + "products": _side(products, pc), + "tally": tally, + "balanced": balanced, + }, ensure_ascii=False) + + return ( + "H.background();\n" + f"const EQ = {data};\n" + "const w = H.W, hgt = H.H;\n" + "H.text('Balancing the equation', 24, 30,\n" + " { color: H.colors.ink, size: 18, weight: 700 });\n" + "H.text('Coefficients chosen so every element is conserved (same count on both sides).',\n" + " 24, 52, { color: H.colors.sub, size: 13 });\n" + "// Reactant cards (left) -> arrow -> product cards (right). The layout\n" + "// scales to the canvas width so even a 6-species reaction fits.\n" + "const nL = EQ.reactants.length, nR = EQ.products.length;\n" + "const nCards = nL + nR;\n" + "const margin = 34, arrowGap = 64;\n" + "const gapX = Math.min(124, (w - 2 * margin - arrowGap) / Math.max(1, nCards));\n" + "const cw = Math.min(100, gapX * 0.82), ch = 50;\n" + "const fs = Math.max(11, Math.min(20, cw * 0.21));\n" + "const midY = hgt * 0.40;\n" + "function card(cx, item, accent) {\n" + " H.rect(cx - cw / 2, midY - ch / 2, cw, ch,\n" + " { fill: 'rgba(124,196,255,0.08)', stroke: accent, width: 1.5, radius: 10 });\n" + " H.text(item.formula, cx, midY + 3,\n" + " { color: H.colors.ink, size: fs, weight: 700, align: 'center', baseline: 'middle' });\n" + " if (item.coef !== 1) {\n" + " H.circle(cx - cw / 2, midY - ch / 2, 13, { fill: accent });\n" + " H.text(String(item.coef), cx - cw / 2, midY - ch / 2 + 1,\n" + " { color: '#0c1020', size: 13, weight: 700, align: 'center', baseline: 'middle' });\n" + " }\n" + "}\n" + "const totalW = nCards * gapX + arrowGap;\n" + "const start = w / 2 - totalW / 2 + gapX / 2;\n" + "for (let i = 0; i < nL; i++) {\n" + " const cx = start + i * gapX;\n" + " card(cx, EQ.reactants[i], H.colors.accent);\n" + " if (i < nL - 1) H.text('+', cx + gapX / 2, midY,\n" + " { color: H.colors.sub, size: 20, align: 'center', baseline: 'middle' });\n" + "}\n" + "const px0 = start + nL * gapX + arrowGap;\n" + "const ax0 = start + (nL - 1) * gapX + cw / 2 + 6, ax1 = px0 - cw / 2 - 6;\n" + "H.arrow(ax0, midY, ax1, midY, { color: H.colors.good, width: 3 });\n" + "// A glow travels along the arrow to show the reaction proceeding.\n" + "const gx = ax0 + (ax1 - ax0) * (0.5 + 0.5 * Math.sin(t * 1.6));\n" + "H.circle(gx, midY, 4, { fill: H.colors.yellow });\n" + "for (let i = 0; i < nR; i++) {\n" + " const cx = px0 + i * gapX;\n" + " card(cx, EQ.products[i], H.colors.good);\n" + " if (i < nR - 1) H.text('+', cx + gapX / 2, midY,\n" + " { color: H.colors.sub, size: 20, align: 'center', baseline: 'middle' });\n" + "}\n" + "// Balanced equation, one clean line.\n" + "H.text(EQ.balanced, w / 2, hgt * 0.62,\n" + " { color: H.colors.accent, size: 18, weight: 700, align: 'center', baseline: 'middle' });\n" + "// Per-element conservation tally, revealed one row at a time.\n" + "const rows = EQ.tally.length;\n" + "const ty0 = hgt * 0.72, rowH = 24;\n" + "const tx = Math.max(40, w / 2 - 160);\n" + "const reveal = Math.floor((t * 1.1) % (rows + 2));\n" + "H.text('Atom balance', tx, ty0 - 22,\n" + " { color: H.colors.sub, size: 12, weight: 700 });\n" + "for (let i = 0; i < rows; i++) {\n" + " const r = EQ.tally[i];\n" + " const yy = ty0 + i * rowH;\n" + " const on = i <= reveal;\n" + " H.circle(tx, yy, 7, { fill: on ? r.color : H.colors.grid });\n" + " H.text(r.el, tx, yy + 1,\n" + " { color: '#0c1020', size: 10, weight: 700, align: 'center', baseline: 'middle' });\n" + " H.text(r.left + ' = ' + r.right + (on ? ' ✓' : ''), tx + 18, yy + 1,\n" + " { color: on ? H.colors.good : H.colors.sub, size: 14, baseline: 'middle' });\n" + "}\n" + ) + + +# --------------------------------------------------------------------------- # +# Public entry point +# --------------------------------------------------------------------------- # + +def chemistry_scene(prompt: str): + """Return a scene-content dict (title/tag/dimension/equation/summary/bullets/ + student_prompts/code/kind) for a chemistry prompt, or None to fall through to + the normal demo/library/generative path. `code` is RAW — the caller should + run it through sanitize_code (mirrors the demo/library shapers).""" + hit = detect_chemistry(prompt) + if hit is None: + return None + kind, payload = hit + + if kind == "molecule": + mol = lookup_molecule(payload) + if mol is None: + return None + shape = mol.get("shape", "") + summary = ( + f"3D ball-and-stick structure of {mol['name']}" + + (f", a {shape} molecule." if shape else ".") + + " Atoms are colored by element (CPK); sticks are bonds. Drag to orbit." + ) + bullets = [ + "Each ball is an atom (colored by element); each stick is a bond.", + "Double and triple bonds are drawn as 2 or 3 parallel sticks.", + (f"The arrangement is {shape} — set by how electron pairs spread out (VSEPR)." + if shape else "The 3D arrangement reflects how the atoms bond in space."), + ] + return { + "title": mol["name"], + "tag": "Chemistry", + "dimension": "3D", + "equation": mol["formula"], + "summary": summary, + "bullets": bullets, + "student_prompts": [ + f"Why does {mol['formula']} take this shape?", + "What are the bond angles in this molecule?", + "How do lone pairs change the geometry?", + ], + "code": _molecule_code(mol), + "kind": "molecule", + } + + if kind == "balance": + reactants, products = payload + coeffs = balance_equation(reactants, products) + if coeffs is None: + return None + nr = len(reactants) + + def _eq(formulas, cs): + toks = [] + for i, f in enumerate(formulas): + pre = (str(cs[i]) + " ") if cs[i] != 1 else "" + toks.append(pre + f) + return " + ".join(toks) + + balanced_plain = _eq(reactants, coeffs[:nr]) + " -> " + _eq(products, coeffs[nr:]) + return { + "title": "Balanced chemical equation", + "tag": "Chemistry", + "dimension": "2D", + "equation": balanced_plain, + "summary": ( + "The reaction balanced so atoms of every element are conserved. " + "Reactants (left) combine in the shown ratio to give the products (right); " + "the tally proves each element's count matches on both sides." + ), + "bullets": [ + "Coefficients are the smallest whole numbers that conserve every atom.", + "Atoms are never created or destroyed — only rearranged (conservation of mass).", + "The badge on each card is how many of that molecule the balance requires.", + ], + "student_prompts": [ + "How do you find balancing coefficients systematically?", + "Why must the atom counts match on both sides?", + "What is the mole ratio between the reactants here?", + ], + "code": _balance_code(reactants, products, coeffs), + "kind": "balance", + } + return None diff --git a/chemistry_molecules_generated.json b/chemistry_molecules_generated.json new file mode 100644 index 0000000..bb8eb55 --- /dev/null +++ b/chemistry_molecules_generated.json @@ -0,0 +1,6097 @@ +{ + "molecules": [ + { + "formula": "CH4O", + "name": "Methanol (CH₃OH)", + "names": [ + "methanol", + "methyl alcohol", + "wood alcohol" + ], + "shape": "tetrahedral C, bent at O", + "atoms": [ + { + "e": "C", + "p": [ + -0.382, + 0, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.048, + 0, + 0 + ] + }, + { + "e": "H", + "p": [ + -0.764, + 1.028, + 0 + ] + }, + { + "e": "H", + "p": [ + -0.764, + -0.514, + 0.89 + ] + }, + { + "e": "H", + "p": [ + -0.764, + -0.514, + -0.89 + ] + }, + { + "e": "H", + "p": [ + 1.345, + 0.922, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 1, + 5, + 1 + ] + ] + }, + { + "formula": "C2H6O", + "name": "Ethanol (C₂H₅OH)", + "names": [ + "ethanol", + "ethyl alcohol" + ], + "shape": "bent chain, two tetrahedral C", + "atoms": [ + { + "e": "C", + "p": [ + -1.165, + -0.215, + 0 + ] + }, + { + "e": "C", + "p": [ + 0.291, + 0.265, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.13, + -0.872, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.834, + 0.648, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.366, + -0.822, + 0.89 + ] + }, + { + "e": "H", + "p": [ + -1.366, + -0.822, + -0.89 + ] + }, + { + "e": "H", + "p": [ + 0.485, + 0.89, + 0.89 + ] + }, + { + "e": "H", + "p": [ + 0.485, + 0.89, + -0.89 + ] + }, + { + "e": "H", + "p": [ + 2.045, + -0.56, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 1, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 0, + 5, + 1 + ], + [ + 1, + 6, + 1 + ], + [ + 1, + 7, + 1 + ], + [ + 2, + 8, + 1 + ] + ] + }, + { + "formula": "C2H6", + "name": "Ethane (C₂H₆)", + "names": [ + "ethane" + ], + "shape": "staggered, two tetrahedral C", + "atoms": [ + { + "e": "C", + "p": [ + 0, + 0, + 0.77 + ] + }, + { + "e": "C", + "p": [ + 0, + 0, + -0.77 + ] + }, + { + "e": "H", + "p": [ + 1.028, + 0, + 1.157 + ] + }, + { + "e": "H", + "p": [ + -0.514, + 0.89, + 1.157 + ] + }, + { + "e": "H", + "p": [ + -0.514, + -0.89, + 1.157 + ] + }, + { + "e": "H", + "p": [ + -1.028, + 0, + -1.157 + ] + }, + { + "e": "H", + "p": [ + 0.514, + -0.89, + -1.157 + ] + }, + { + "e": "H", + "p": [ + 0.514, + 0.89, + -1.157 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 1, + 5, + 1 + ], + [ + 1, + 6, + 1 + ], + [ + 1, + 7, + 1 + ] + ] + }, + { + "formula": "C2H4", + "name": "Ethene (C₂H₄)", + "names": [ + "ethene", + "ethylene" + ], + "shape": "trigonal planar, C=C double bond", + "atoms": [ + { + "e": "C", + "p": [ + 0, + 0.67, + 0 + ] + }, + { + "e": "C", + "p": [ + 0, + -0.67, + 0 + ] + }, + { + "e": "H", + "p": [ + 0.927, + 1.235, + 0 + ] + }, + { + "e": "H", + "p": [ + -0.927, + 1.235, + 0 + ] + }, + { + "e": "H", + "p": [ + 0.927, + -1.235, + 0 + ] + }, + { + "e": "H", + "p": [ + -0.927, + -1.235, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 1, + 4, + 1 + ], + [ + 1, + 5, + 1 + ] + ] + }, + { + "formula": "C2H2", + "name": "Ethyne (C₂H₂)", + "names": [ + "ethyne", + "acetylene" + ], + "shape": "linear", + "atoms": [ + { + "e": "C", + "p": [ + -0.6, + 0, + 0 + ] + }, + { + "e": "C", + "p": [ + 0.6, + 0, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.69, + 0, + 0 + ] + }, + { + "e": "H", + "p": [ + 1.69, + 0, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 3 + ], + [ + 0, + 2, + 1 + ], + [ + 1, + 3, + 1 + ] + ] + }, + { + "formula": "C3H8", + "name": "Propane (C₃H₈)", + "names": [ + "propane" + ], + "shape": "bent chain", + "atoms": [ + { + "e": "C", + "p": [ + -1.26, + -0.262, + 0 + ] + }, + { + "e": "C", + "p": [ + 0, + 0.602, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.26, + -0.262, + 0 + ] + }, + { + "e": "H", + "p": [ + -2.15, + 0.378, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.3, + -0.905, + 0.885 + ] + }, + { + "e": "H", + "p": [ + -1.3, + -0.905, + -0.885 + ] + }, + { + "e": "H", + "p": [ + 0, + 1.26, + 0.88 + ] + }, + { + "e": "H", + "p": [ + 0, + 1.26, + -0.88 + ] + }, + { + "e": "H", + "p": [ + 2.15, + 0.378, + 0 + ] + }, + { + "e": "H", + "p": [ + 1.3, + -0.905, + 0.885 + ] + }, + { + "e": "H", + "p": [ + 1.3, + -0.905, + -0.885 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 1, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 0, + 5, + 1 + ], + [ + 1, + 6, + 1 + ], + [ + 1, + 7, + 1 + ], + [ + 2, + 8, + 1 + ], + [ + 2, + 9, + 1 + ], + [ + 2, + 10, + 1 + ] + ] + }, + { + "formula": "C3H6", + "name": "Propene (C₃H₆)", + "names": [ + "propene", + "propylene" + ], + "shape": "bent chain", + "atoms": [ + { + "e": "C", + "p": [ + -1.23, + 0.23, + 0 + ] + }, + { + "e": "C", + "p": [ + 0, + -0.43, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.29, + 0.14, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.37, + 1.31, + 0 + ] + }, + { + "e": "H", + "p": [ + -2.13, + -0.38, + 0 + ] + }, + { + "e": "H", + "p": [ + 0, + -1.52, + 0 + ] + }, + { + "e": "H", + "p": [ + 1.88, + -0.205, + 0.86 + ] + }, + { + "e": "H", + "p": [ + 1.88, + -0.205, + -0.86 + ] + }, + { + "e": "H", + "p": [ + 1.23, + 1.23, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 1, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 1, + 5, + 1 + ], + [ + 2, + 6, + 1 + ], + [ + 2, + 7, + 1 + ], + [ + 2, + 8, + 1 + ] + ] + }, + { + "formula": "C4H10", + "name": "Butane (C₄H₁₀)", + "names": [ + "butane", + "n-butane" + ], + "shape": "anti zigzag chain", + "atoms": [ + { + "e": "C", + "p": [ + -1.89, + -0.262, + 0 + ] + }, + { + "e": "C", + "p": [ + -0.63, + 0.602, + 0 + ] + }, + { + "e": "C", + "p": [ + 0.63, + -0.262, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.89, + 0.602, + 0 + ] + }, + { + "e": "H", + "p": [ + -2.78, + 0.378, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.93, + -0.905, + 0.885 + ] + }, + { + "e": "H", + "p": [ + -1.93, + -0.905, + -0.885 + ] + }, + { + "e": "H", + "p": [ + -0.63, + 1.26, + 0.88 + ] + }, + { + "e": "H", + "p": [ + -0.63, + 1.26, + -0.88 + ] + }, + { + "e": "H", + "p": [ + 0.63, + -0.92, + 0.88 + ] + }, + { + "e": "H", + "p": [ + 0.63, + -0.92, + -0.88 + ] + }, + { + "e": "H", + "p": [ + 2.78, + -0.038, + 0 + ] + }, + { + "e": "H", + "p": [ + 1.93, + 1.245, + 0.885 + ] + }, + { + "e": "H", + "p": [ + 1.93, + 1.245, + -0.885 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 1, + 2, + 1 + ], + [ + 2, + 3, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 0, + 5, + 1 + ], + [ + 0, + 6, + 1 + ], + [ + 1, + 7, + 1 + ], + [ + 1, + 8, + 1 + ], + [ + 2, + 9, + 1 + ], + [ + 2, + 10, + 1 + ], + [ + 3, + 11, + 1 + ], + [ + 3, + 12, + 1 + ], + [ + 3, + 13, + 1 + ] + ] + }, + { + "formula": "C5H12", + "name": "Pentane (C₅H₁₂)", + "names": [ + "pentane" + ], + "shape": "bent chain", + "atoms": [ + { + "e": "C", + "p": [ + -2.55, + -0.35, + 0 + ] + }, + { + "e": "C", + "p": [ + -1.28, + 0.51, + 0 + ] + }, + { + "e": "C", + "p": [ + 0, + -0.35, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.28, + 0.51, + 0 + ] + }, + { + "e": "C", + "p": [ + 2.55, + -0.35, + 0 + ] + }, + { + "e": "H", + "p": [ + -3.44, + 0.29, + 0 + ] + }, + { + "e": "H", + "p": [ + -2.59, + -0.99, + 0.88 + ] + }, + { + "e": "H", + "p": [ + -2.59, + -0.99, + -0.88 + ] + }, + { + "e": "H", + "p": [ + -1.24, + 1.15, + 0.88 + ] + }, + { + "e": "H", + "p": [ + -1.24, + 1.15, + -0.88 + ] + }, + { + "e": "H", + "p": [ + 0, + -0.99, + 0.88 + ] + }, + { + "e": "H", + "p": [ + 0, + -0.99, + -0.88 + ] + }, + { + "e": "H", + "p": [ + 1.24, + 1.15, + 0.88 + ] + }, + { + "e": "H", + "p": [ + 1.24, + 1.15, + -0.88 + ] + }, + { + "e": "H", + "p": [ + 3.44, + 0.29, + 0 + ] + }, + { + "e": "H", + "p": [ + 2.59, + -0.99, + 0.88 + ] + }, + { + "e": "H", + "p": [ + 2.59, + -0.99, + -0.88 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 1, + 2, + 1 + ], + [ + 2, + 3, + 1 + ], + [ + 3, + 4, + 1 + ], + [ + 0, + 5, + 1 + ], + [ + 0, + 6, + 1 + ], + [ + 0, + 7, + 1 + ], + [ + 1, + 8, + 1 + ], + [ + 1, + 9, + 1 + ], + [ + 2, + 10, + 1 + ], + [ + 2, + 11, + 1 + ], + [ + 3, + 12, + 1 + ], + [ + 3, + 13, + 1 + ], + [ + 4, + 14, + 1 + ], + [ + 4, + 15, + 1 + ], + [ + 4, + 16, + 1 + ] + ] + }, + { + "formula": "C6H14", + "name": "Hexane (C₆H₁₄)", + "names": [ + "hexane" + ], + "shape": "bent chain", + "atoms": [ + { + "e": "C", + "p": [ + -3.19, + -0.35, + 0 + ] + }, + { + "e": "C", + "p": [ + -1.91, + 0.51, + 0 + ] + }, + { + "e": "C", + "p": [ + -0.64, + -0.35, + 0 + ] + }, + { + "e": "C", + "p": [ + 0.64, + 0.51, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.91, + -0.35, + 0 + ] + }, + { + "e": "C", + "p": [ + 3.19, + 0.51, + 0 + ] + }, + { + "e": "H", + "p": [ + -4.08, + 0.29, + 0 + ] + }, + { + "e": "H", + "p": [ + -3.23, + -0.99, + 0.88 + ] + }, + { + "e": "H", + "p": [ + -3.23, + -0.99, + -0.88 + ] + }, + { + "e": "H", + "p": [ + -1.87, + 1.15, + 0.88 + ] + }, + { + "e": "H", + "p": [ + -1.87, + 1.15, + -0.88 + ] + }, + { + "e": "H", + "p": [ + -0.68, + -0.99, + 0.88 + ] + }, + { + "e": "H", + "p": [ + -0.68, + -0.99, + -0.88 + ] + }, + { + "e": "H", + "p": [ + 0.68, + 1.15, + 0.88 + ] + }, + { + "e": "H", + "p": [ + 0.68, + 1.15, + -0.88 + ] + }, + { + "e": "H", + "p": [ + 1.87, + -0.99, + 0.88 + ] + }, + { + "e": "H", + "p": [ + 1.87, + -0.99, + -0.88 + ] + }, + { + "e": "H", + "p": [ + 4.08, + -0.13, + 0 + ] + }, + { + "e": "H", + "p": [ + 3.23, + 1.15, + 0.88 + ] + }, + { + "e": "H", + "p": [ + 3.23, + 1.15, + -0.88 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 1, + 2, + 1 + ], + [ + 2, + 3, + 1 + ], + [ + 3, + 4, + 1 + ], + [ + 4, + 5, + 1 + ], + [ + 0, + 6, + 1 + ], + [ + 0, + 7, + 1 + ], + [ + 0, + 8, + 1 + ], + [ + 1, + 9, + 1 + ], + [ + 1, + 10, + 1 + ], + [ + 2, + 11, + 1 + ], + [ + 2, + 12, + 1 + ], + [ + 3, + 13, + 1 + ], + [ + 3, + 14, + 1 + ], + [ + 4, + 15, + 1 + ], + [ + 4, + 16, + 1 + ], + [ + 5, + 17, + 1 + ], + [ + 5, + 18, + 1 + ], + [ + 5, + 19, + 1 + ] + ] + }, + { + "formula": "C6H12", + "name": "Cyclohexane (C₆H₁₂)", + "names": [ + "cyclohexane" + ], + "shape": "chair ring", + "atoms": [ + { + "e": "C", + "p": [ + 1.261, + 0.728, + 0.25 + ] + }, + { + "e": "C", + "p": [ + 1.261, + -0.728, + -0.25 + ] + }, + { + "e": "C", + "p": [ + 0, + -1.456, + 0.25 + ] + }, + { + "e": "C", + "p": [ + -1.261, + -0.728, + -0.25 + ] + }, + { + "e": "C", + "p": [ + -1.261, + 0.728, + 0.25 + ] + }, + { + "e": "C", + "p": [ + 0, + 1.456, + -0.25 + ] + }, + { + "e": "H", + "p": [ + 2.158, + 1.245, + -0.115 + ] + }, + { + "e": "H", + "p": [ + 1.286, + 0.742, + 1.348 + ] + }, + { + "e": "H", + "p": [ + 2.158, + -1.245, + 0.115 + ] + }, + { + "e": "H", + "p": [ + 1.286, + -0.742, + -1.348 + ] + }, + { + "e": "H", + "p": [ + 0, + -2.49, + -0.115 + ] + }, + { + "e": "H", + "p": [ + 0, + -1.484, + 1.348 + ] + }, + { + "e": "H", + "p": [ + -2.158, + -1.245, + 0.115 + ] + }, + { + "e": "H", + "p": [ + -1.286, + -0.742, + -1.348 + ] + }, + { + "e": "H", + "p": [ + -2.158, + 1.245, + -0.115 + ] + }, + { + "e": "H", + "p": [ + -1.286, + 0.742, + 1.348 + ] + }, + { + "e": "H", + "p": [ + 0, + 2.49, + 0.115 + ] + }, + { + "e": "H", + "p": [ + 0, + 1.484, + -1.348 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 1, + 2, + 1 + ], + [ + 2, + 3, + 1 + ], + [ + 3, + 4, + 1 + ], + [ + 4, + 5, + 1 + ], + [ + 5, + 0, + 1 + ], + [ + 0, + 6, + 1 + ], + [ + 0, + 7, + 1 + ], + [ + 1, + 8, + 1 + ], + [ + 1, + 9, + 1 + ], + [ + 2, + 10, + 1 + ], + [ + 2, + 11, + 1 + ], + [ + 3, + 12, + 1 + ], + [ + 3, + 13, + 1 + ], + [ + 4, + 14, + 1 + ], + [ + 4, + 15, + 1 + ], + [ + 5, + 16, + 1 + ], + [ + 5, + 17, + 1 + ] + ] + }, + { + "formula": "CH2O", + "name": "Formaldehyde (CH₂O)", + "names": [ + "formaldehyde", + "methanal" + ], + "shape": "trigonal planar", + "atoms": [ + { + "e": "C", + "p": [ + 0, + -0.6, + 0 + ] + }, + { + "e": "O", + "p": [ + 0, + 0.61, + 0 + ] + }, + { + "e": "H", + "p": [ + 0.94, + -1.14, + 0 + ] + }, + { + "e": "H", + "p": [ + -0.94, + -1.14, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ] + ] + }, + { + "formula": "C2H4O", + "name": "Acetaldehyde (CH₃CHO)", + "names": [ + "acetaldehyde", + "ethanal" + ], + "shape": "bent chain (sp3 methyl + sp2 carbonyl)", + "atoms": [ + { + "e": "C", + "p": [ + -0.76, + 0.03, + 0 + ] + }, + { + "e": "C", + "p": [ + 0.73, + -0.19, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.33, + -1.244, + 0 + ] + }, + { + "e": "H", + "p": [ + 1.245, + 0.789, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.246, + -0.47, + 0.844 + ] + }, + { + "e": "H", + "p": [ + -1.246, + -0.47, + -0.844 + ] + }, + { + "e": "H", + "p": [ + -1.001, + 1.097, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 1, + 2, + 2 + ], + [ + 1, + 3, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 0, + 5, + 1 + ], + [ + 0, + 6, + 1 + ] + ] + }, + { + "formula": "C3H6O", + "name": "Acetone (CH₃COCH₃)", + "names": [ + "acetone", + "propanone" + ], + "shape": "trigonal planar carbonyl, two tetrahedral methyls", + "atoms": [ + { + "e": "C", + "p": [ + 0, + 0.52, + 0 + ] + }, + { + "e": "O", + "p": [ + 0, + 1.73, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.286, + -0.27, + 0 + ] + }, + { + "e": "C", + "p": [ + -1.286, + -0.27, + 0 + ] + }, + { + "e": "H", + "p": [ + 2.131, + 0.42, + 0 + ] + }, + { + "e": "H", + "p": [ + 1.34, + -0.91, + 0.884 + ] + }, + { + "e": "H", + "p": [ + 1.34, + -0.91, + -0.884 + ] + }, + { + "e": "H", + "p": [ + -2.131, + 0.42, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.34, + -0.91, + 0.884 + ] + }, + { + "e": "H", + "p": [ + -1.34, + -0.91, + -0.884 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 2, + 4, + 1 + ], + [ + 2, + 5, + 1 + ], + [ + 2, + 6, + 1 + ], + [ + 3, + 7, + 1 + ], + [ + 3, + 8, + 1 + ], + [ + 3, + 9, + 1 + ] + ] + }, + { + "formula": "CH2O2", + "name": "Formic acid (HCOOH)", + "names": [ + "formic acid", + "methanoic acid" + ], + "shape": "planar carboxyl", + "atoms": [ + { + "e": "C", + "p": [ + 0, + 0.42, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.15, + 0.76, + 0 + ] + }, + { + "e": "O", + "p": [ + -0.96, + 1.36, + 0 + ] + }, + { + "e": "H", + "p": [ + -0.29, + -0.64, + 0 + ] + }, + { + "e": "H", + "p": [ + -0.56, + 2.214, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 2, + 4, + 1 + ] + ] + }, + { + "formula": "C2H4O2", + "name": "Acetic acid (CH₃COOH)", + "names": [ + "acetic acid", + "ethanoic acid" + ], + "shape": "planar carboxyl + tetrahedral methyl", + "atoms": [ + { + "e": "C", + "p": [ + 0.42, + 0, + 0 + ] + }, + { + "e": "C", + "p": [ + -1.08, + 0.14, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.14, + 0.985, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.01, + -1.205, + 0 + ] + }, + { + "e": "H", + "p": [ + 1.974, + -1.155, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.43, + -0.452, + 0.852 + ] + }, + { + "e": "H", + "p": [ + -1.43, + -0.452, + -0.852 + ] + }, + { + "e": "H", + "p": [ + -1.439, + 1.171, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 2 + ], + [ + 0, + 3, + 1 + ], + [ + 3, + 4, + 1 + ], + [ + 1, + 5, + 1 + ], + [ + 1, + 6, + 1 + ], + [ + 1, + 7, + 1 + ] + ] + }, + { + "formula": "C2H6O2", + "name": "Ethylene glycol (C₂H₆O₂)", + "names": [ + "ethylene glycol", + "ethane-1,2-diol" + ], + "shape": "bent chain", + "atoms": [ + { + "e": "C", + "p": [ + -0.76, + -0.15, + 0 + ] + }, + { + "e": "C", + "p": [ + 0.76, + 0.15, + 0 + ] + }, + { + "e": "O", + "p": [ + -1.43, + 1.03, + -0.42 + ] + }, + { + "e": "O", + "p": [ + 1.43, + -1.03, + 0.42 + ] + }, + { + "e": "H", + "p": [ + -1.18, + -0.43, + 0.975 + ] + }, + { + "e": "H", + "p": [ + -0.93, + -1.01, + -0.665 + ] + }, + { + "e": "H", + "p": [ + 1.18, + 0.43, + -0.975 + ] + }, + { + "e": "H", + "p": [ + 0.93, + 1.01, + 0.665 + ] + }, + { + "e": "H", + "p": [ + -2.36, + 0.82, + -0.3 + ] + }, + { + "e": "H", + "p": [ + 2.36, + -0.82, + 0.3 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 1 + ], + [ + 1, + 3, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 0, + 5, + 1 + ], + [ + 1, + 6, + 1 + ], + [ + 1, + 7, + 1 + ], + [ + 2, + 8, + 1 + ], + [ + 3, + 9, + 1 + ] + ] + }, + { + "formula": "C3H8O", + "name": "Isopropanol (C₃H₇OH)", + "names": [ + "isopropanol", + "isopropyl alcohol", + "propan-2-ol" + ], + "shape": "tetrahedral", + "atoms": [ + { + "e": "C", + "p": [ + 0, + 0.35, + 0 + ] + }, + { + "e": "C", + "p": [ + -1.27, + -0.48, + 0.08 + ] + }, + { + "e": "C", + "p": [ + 1.27, + -0.48, + -0.08 + ] + }, + { + "e": "O", + "p": [ + 0.02, + 1.23, + 1.13 + ] + }, + { + "e": "H", + "p": [ + 0.02, + 0.99, + -0.895 + ] + }, + { + "e": "H", + "p": [ + -1.31, + -1.15, + -0.79 + ] + }, + { + "e": "H", + "p": [ + -2.16, + 0.16, + 0.08 + ] + }, + { + "e": "H", + "p": [ + -1.31, + -1.09, + 0.99 + ] + }, + { + "e": "H", + "p": [ + 1.31, + -1.09, + 0.83 + ] + }, + { + "e": "H", + "p": [ + 2.16, + 0.16, + -0.08 + ] + }, + { + "e": "H", + "p": [ + 1.31, + -1.15, + -0.95 + ] + }, + { + "e": "H", + "p": [ + 0.83, + 1.74, + 1.08 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 1, + 5, + 1 + ], + [ + 1, + 6, + 1 + ], + [ + 1, + 7, + 1 + ], + [ + 2, + 8, + 1 + ], + [ + 2, + 9, + 1 + ], + [ + 2, + 10, + 1 + ], + [ + 3, + 11, + 1 + ] + ] + }, + { + "formula": "C3H8O3", + "name": "Glycerol (C₃H₈O₃)", + "names": [ + "glycerol", + "glycerine", + "propane-1,2,3-triol" + ], + "shape": "bent chain", + "atoms": [ + { + "e": "C", + "p": [ + -1.27, + 0.1, + -0.1 + ] + }, + { + "e": "C", + "p": [ + 0, + -0.7, + 0.15 + ] + }, + { + "e": "C", + "p": [ + 1.27, + 0.1, + -0.1 + ] + }, + { + "e": "O", + "p": [ + -2.4, + -0.7, + 0.18 + ] + }, + { + "e": "O", + "p": [ + 0, + -1.95, + -0.52 + ] + }, + { + "e": "O", + "p": [ + 2.4, + -0.7, + 0.18 + ] + }, + { + "e": "H", + "p": [ + -1.32, + 1.04, + 0.47 + ] + }, + { + "e": "H", + "p": [ + -1.31, + 0.37, + -1.17 + ] + }, + { + "e": "H", + "p": [ + 0.01, + -0.91, + 1.23 + ] + }, + { + "e": "H", + "p": [ + 1.32, + 1.04, + 0.47 + ] + }, + { + "e": "H", + "p": [ + 1.31, + 0.37, + -1.17 + ] + }, + { + "e": "H", + "p": [ + -3.18, + -0.18, + 0 + ] + }, + { + "e": "H", + "p": [ + 0.8, + -2.41, + -0.27 + ] + }, + { + "e": "H", + "p": [ + 3.18, + -0.18, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 1, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 1, + 4, + 1 + ], + [ + 2, + 5, + 1 + ], + [ + 0, + 6, + 1 + ], + [ + 0, + 7, + 1 + ], + [ + 1, + 8, + 1 + ], + [ + 2, + 9, + 1 + ], + [ + 2, + 10, + 1 + ], + [ + 3, + 11, + 1 + ], + [ + 4, + 12, + 1 + ], + [ + 5, + 13, + 1 + ] + ] + }, + { + "formula": "C6H12O6", + "name": "Glucose (C₆H₁₂O₆)", + "names": [ + "glucose", + "dextrose" + ], + "shape": "aromatic ring", + "atoms": [ + { + "e": "O", + "p": [ + 1.3, + 0.5, + 0.3 + ] + }, + { + "e": "C", + "p": [ + 1.32, + -0.55, + -0.66 + ] + }, + { + "e": "C", + "p": [ + 0.1, + -1.46, + -0.5 + ] + }, + { + "e": "C", + "p": [ + -1.18, + -0.66, + -0.78 + ] + }, + { + "e": "C", + "p": [ + -1.2, + 0.55, + 0.15 + ] + }, + { + "e": "C", + "p": [ + 0.1, + 1.33, + -0.05 + ] + }, + { + "e": "C", + "p": [ + 0.18, + 2.55, + 0.85 + ] + }, + { + "e": "O", + "p": [ + 2.5, + -1.28, + -0.45 + ] + }, + { + "e": "O", + "p": [ + 0.13, + -2.55, + -1.41 + ] + }, + { + "e": "O", + "p": [ + -2.32, + -1.49, + -0.55 + ] + }, + { + "e": "O", + "p": [ + -2.35, + 1.35, + -0.06 + ] + }, + { + "e": "O", + "p": [ + 1.43, + 3.21, + 0.69 + ] + }, + { + "e": "H", + "p": [ + 1.31, + -0.18, + -1.7 + ] + }, + { + "e": "H", + "p": [ + 0.06, + -1.86, + 0.53 + ] + }, + { + "e": "H", + "p": [ + -1.22, + -0.33, + -1.83 + ] + }, + { + "e": "H", + "p": [ + -1.21, + 0.22, + 1.21 + ] + }, + { + "e": "H", + "p": [ + 0.13, + 1.69, + -1.1 + ] + }, + { + "e": "H", + "p": [ + -0.65, + 3.23, + 0.59 + ] + }, + { + "e": "H", + "p": [ + 0.05, + 2.27, + 1.91 + ] + }, + { + "e": "H", + "p": [ + 2.49, + -1.95, + 0.25 + ] + }, + { + "e": "H", + "p": [ + 0.93, + -3.08, + -1.28 + ] + }, + { + "e": "H", + "p": [ + -3.13, + -0.99, + -0.77 + ] + }, + { + "e": "H", + "p": [ + -3.13, + 0.79, + -0.22 + ] + }, + { + "e": "H", + "p": [ + 1.42, + 3.97, + 1.3 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 1, + 2, + 1 + ], + [ + 2, + 3, + 1 + ], + [ + 3, + 4, + 1 + ], + [ + 4, + 5, + 1 + ], + [ + 5, + 0, + 1 + ], + [ + 5, + 6, + 1 + ], + [ + 6, + 11, + 1 + ], + [ + 1, + 7, + 1 + ], + [ + 2, + 8, + 1 + ], + [ + 3, + 9, + 1 + ], + [ + 4, + 10, + 1 + ], + [ + 1, + 12, + 1 + ], + [ + 2, + 13, + 1 + ], + [ + 3, + 14, + 1 + ], + [ + 4, + 15, + 1 + ], + [ + 5, + 16, + 1 + ], + [ + 6, + 17, + 1 + ], + [ + 6, + 18, + 1 + ], + [ + 7, + 19, + 1 + ], + [ + 8, + 20, + 1 + ], + [ + 9, + 21, + 1 + ], + [ + 10, + 22, + 1 + ], + [ + 11, + 23, + 1 + ] + ] + }, + { + "formula": "C6H6O", + "name": "Phenol (C₆H₅OH)", + "names": [ + "phenol" + ], + "shape": "aromatic ring", + "atoms": [ + { + "e": "C", + "p": [ + 0, + 1.397, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.21, + 0.698, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.21, + -0.698, + 0 + ] + }, + { + "e": "C", + "p": [ + 0, + -1.397, + 0 + ] + }, + { + "e": "C", + "p": [ + -1.21, + -0.698, + 0 + ] + }, + { + "e": "C", + "p": [ + -1.21, + 0.698, + 0 + ] + }, + { + "e": "O", + "p": [ + 0, + 2.787, + 0 + ] + }, + { + "e": "H", + "p": [ + 0.812, + 3.137, + 0 + ] + }, + { + "e": "H", + "p": [ + 2.154, + 1.243, + 0 + ] + }, + { + "e": "H", + "p": [ + 2.154, + -1.243, + 0 + ] + }, + { + "e": "H", + "p": [ + 0, + -2.487, + 0 + ] + }, + { + "e": "H", + "p": [ + -2.154, + -1.243, + 0 + ] + }, + { + "e": "H", + "p": [ + -2.154, + 1.243, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 1, + 2, + 1 + ], + [ + 2, + 3, + 2 + ], + [ + 3, + 4, + 1 + ], + [ + 4, + 5, + 2 + ], + [ + 5, + 0, + 1 + ], + [ + 0, + 6, + 1 + ], + [ + 6, + 7, + 1 + ], + [ + 1, + 8, + 1 + ], + [ + 2, + 9, + 1 + ], + [ + 3, + 10, + 1 + ], + [ + 4, + 11, + 1 + ], + [ + 5, + 12, + 1 + ] + ] + }, + { + "formula": "C7H8", + "name": "Toluene (C₆H₅CH₃)", + "names": [ + "toluene", + "methylbenzene" + ], + "shape": "aromatic ring", + "atoms": [ + { + "e": "C", + "p": [ + 0, + 1.397, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.21, + 0.698, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.21, + -0.698, + 0 + ] + }, + { + "e": "C", + "p": [ + 0, + -1.397, + 0 + ] + }, + { + "e": "C", + "p": [ + -1.21, + -0.698, + 0 + ] + }, + { + "e": "C", + "p": [ + -1.21, + 0.698, + 0 + ] + }, + { + "e": "C", + "p": [ + 0, + 2.907, + 0 + ] + }, + { + "e": "H", + "p": [ + 1.027, + 3.288, + 0 + ] + }, + { + "e": "H", + "p": [ + -0.513, + 3.288, + 0.889 + ] + }, + { + "e": "H", + "p": [ + -0.513, + 3.288, + -0.889 + ] + }, + { + "e": "H", + "p": [ + 2.154, + 1.243, + 0 + ] + }, + { + "e": "H", + "p": [ + 2.154, + -1.243, + 0 + ] + }, + { + "e": "H", + "p": [ + 0, + -2.487, + 0 + ] + }, + { + "e": "H", + "p": [ + -2.154, + -1.243, + 0 + ] + }, + { + "e": "H", + "p": [ + -2.154, + 1.243, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 1, + 2, + 1 + ], + [ + 2, + 3, + 2 + ], + [ + 3, + 4, + 1 + ], + [ + 4, + 5, + 2 + ], + [ + 5, + 0, + 1 + ], + [ + 0, + 6, + 1 + ], + [ + 6, + 7, + 1 + ], + [ + 6, + 8, + 1 + ], + [ + 6, + 9, + 1 + ], + [ + 1, + 10, + 1 + ], + [ + 2, + 11, + 1 + ], + [ + 3, + 12, + 1 + ], + [ + 4, + 13, + 1 + ], + [ + 5, + 14, + 1 + ] + ] + }, + { + "formula": "C6H7N", + "name": "Aniline (C₆H₅NH₂)", + "names": [ + "aniline", + "aminobenzene" + ], + "shape": "aromatic ring", + "atoms": [ + { + "e": "C", + "p": [ + 0, + 1.397, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.21, + 0.698, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.21, + -0.698, + 0 + ] + }, + { + "e": "C", + "p": [ + 0, + -1.397, + 0 + ] + }, + { + "e": "C", + "p": [ + -1.21, + -0.698, + 0 + ] + }, + { + "e": "C", + "p": [ + -1.21, + 0.698, + 0 + ] + }, + { + "e": "N", + "p": [ + 0, + 2.797, + 0 + ] + }, + { + "e": "H", + "p": [ + 0.823, + 3.26, + 0.18 + ] + }, + { + "e": "H", + "p": [ + -0.823, + 3.26, + 0.18 + ] + }, + { + "e": "H", + "p": [ + 2.154, + 1.243, + 0 + ] + }, + { + "e": "H", + "p": [ + 2.154, + -1.243, + 0 + ] + }, + { + "e": "H", + "p": [ + 0, + -2.487, + 0 + ] + }, + { + "e": "H", + "p": [ + -2.154, + -1.243, + 0 + ] + }, + { + "e": "H", + "p": [ + -2.154, + 1.243, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 1, + 2, + 1 + ], + [ + 2, + 3, + 2 + ], + [ + 3, + 4, + 1 + ], + [ + 4, + 5, + 2 + ], + [ + 5, + 0, + 1 + ], + [ + 0, + 6, + 1 + ], + [ + 6, + 7, + 1 + ], + [ + 6, + 8, + 1 + ], + [ + 1, + 9, + 1 + ], + [ + 2, + 10, + 1 + ], + [ + 3, + 11, + 1 + ], + [ + 4, + 12, + 1 + ], + [ + 5, + 13, + 1 + ] + ] + }, + { + "formula": "C10H8", + "name": "Naphthalene (C₁₀H₈)", + "names": [ + "naphthalene" + ], + "shape": "fused aromatic rings", + "atoms": [ + { + "e": "C", + "p": [ + 0, + 0.715, + 0 + ] + }, + { + "e": "C", + "p": [ + 0, + -0.715, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.241, + 1.402, + 0 + ] + }, + { + "e": "C", + "p": [ + 2.432, + 0.71, + 0 + ] + }, + { + "e": "C", + "p": [ + 2.432, + -0.71, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.241, + -1.402, + 0 + ] + }, + { + "e": "C", + "p": [ + -1.241, + 1.402, + 0 + ] + }, + { + "e": "C", + "p": [ + -2.432, + 0.71, + 0 + ] + }, + { + "e": "C", + "p": [ + -2.432, + -0.71, + 0 + ] + }, + { + "e": "C", + "p": [ + -1.241, + -1.402, + 0 + ] + }, + { + "e": "H", + "p": [ + 1.247, + 2.492, + 0 + ] + }, + { + "e": "H", + "p": [ + 3.376, + 1.254, + 0 + ] + }, + { + "e": "H", + "p": [ + 3.376, + -1.254, + 0 + ] + }, + { + "e": "H", + "p": [ + 1.247, + -2.492, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.247, + 2.492, + 0 + ] + }, + { + "e": "H", + "p": [ + -3.376, + 1.254, + 0 + ] + }, + { + "e": "H", + "p": [ + -3.376, + -1.254, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.247, + -2.492, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 2 + ], + [ + 2, + 3, + 1 + ], + [ + 3, + 4, + 2 + ], + [ + 4, + 5, + 1 + ], + [ + 5, + 1, + 2 + ], + [ + 0, + 6, + 1 + ], + [ + 6, + 7, + 2 + ], + [ + 7, + 8, + 1 + ], + [ + 8, + 9, + 2 + ], + [ + 9, + 1, + 1 + ], + [ + 2, + 10, + 1 + ], + [ + 3, + 11, + 1 + ], + [ + 4, + 12, + 1 + ], + [ + 5, + 13, + 1 + ], + [ + 6, + 14, + 1 + ], + [ + 7, + 15, + 1 + ], + [ + 8, + 16, + 1 + ], + [ + 9, + 17, + 1 + ] + ] + }, + { + "formula": "CH5N", + "name": "Methylamine (CH₃NH₂)", + "names": [ + "methylamine" + ], + "shape": "tetrahedral C, pyramidal N", + "atoms": [ + { + "e": "C", + "p": [ + -0.745, + 0, + 0 + ] + }, + { + "e": "N", + "p": [ + 0.745, + 0, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.14, + 1.013, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.14, + -0.507, + 0.877 + ] + }, + { + "e": "H", + "p": [ + -1.14, + -0.507, + -0.877 + ] + }, + { + "e": "H", + "p": [ + 1.075, + -0.473, + 0.819 + ] + }, + { + "e": "H", + "p": [ + 1.075, + -0.473, + -0.819 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 1, + 5, + 1 + ], + [ + 1, + 6, + 1 + ] + ] + }, + { + "formula": "C2H3N", + "name": "Acetonitrile (CH₃CN)", + "names": [ + "acetonitrile" + ], + "shape": "linear nitrile, tetrahedral methyl", + "atoms": [ + { + "e": "C", + "p": [ + -1.46, + 0, + 0 + ] + }, + { + "e": "C", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "N", + "p": [ + 1.157, + 0, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.85, + 1.02, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.85, + -0.51, + 0.883 + ] + }, + { + "e": "H", + "p": [ + -1.85, + -0.51, + -0.883 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 1, + 2, + 3 + ], + [ + 0, + 3, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 0, + 5, + 1 + ] + ] + }, + { + "formula": "CH4N2O", + "name": "Urea (CO(NH₂)₂)", + "names": [ + "urea", + "carbamide" + ], + "shape": "trigonal planar carbonyl, two NH2", + "atoms": [ + { + "e": "C", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "O", + "p": [ + 0, + 1.21, + 0 + ] + }, + { + "e": "N", + "p": [ + 1.16, + -0.7, + 0 + ] + }, + { + "e": "N", + "p": [ + -1.16, + -0.7, + 0 + ] + }, + { + "e": "H", + "p": [ + 2.03, + -0.19, + 0 + ] + }, + { + "e": "H", + "p": [ + 1.16, + -1.71, + 0 + ] + }, + { + "e": "H", + "p": [ + -2.03, + -0.19, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.16, + -1.71, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 2, + 4, + 1 + ], + [ + 2, + 5, + 1 + ], + [ + 3, + 6, + 1 + ], + [ + 3, + 7, + 1 + ] + ] + }, + { + "formula": "C2H5NO2", + "name": "Glycine (NH₂CH₂COOH)", + "names": [ + "glycine" + ], + "shape": "bent chain amino acid", + "atoms": [ + { + "e": "N", + "p": [ + -1.87, + 0.3, + 0.1 + ] + }, + { + "e": "C", + "p": [ + -0.65, + -0.49, + -0.15 + ] + }, + { + "e": "C", + "p": [ + 0.61, + 0.33, + 0.02 + ] + }, + { + "e": "O", + "p": [ + 0.56, + 1.53, + 0.15 + ] + }, + { + "e": "O", + "p": [ + 1.77, + -0.35, + 0.02 + ] + }, + { + "e": "H", + "p": [ + -2.66, + -0.25, + -0.2 + ] + }, + { + "e": "H", + "p": [ + -1.9, + 1.15, + -0.45 + ] + }, + { + "e": "H", + "p": [ + -0.66, + -0.91, + -1.165 + ] + }, + { + "e": "H", + "p": [ + -0.64, + -1.33, + 0.555 + ] + }, + { + "e": "H", + "p": [ + 2.54, + 0.23, + 0.13 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 1, + 2, + 1 + ], + [ + 2, + 3, + 2 + ], + [ + 2, + 4, + 1 + ], + [ + 0, + 5, + 1 + ], + [ + 0, + 6, + 1 + ], + [ + 1, + 7, + 1 + ], + [ + 1, + 8, + 1 + ], + [ + 4, + 9, + 1 + ] + ] + }, + { + "formula": "CH3NO2", + "name": "Nitromethane (CH₃NO₂)", + "names": [ + "nitromethane" + ], + "shape": "tetrahedral C, trigonal planar N", + "atoms": [ + { + "e": "C", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "N", + "p": [ + 0, + 1.49, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.067, + 2.097, + 0 + ] + }, + { + "e": "O", + "p": [ + -1.067, + 2.097, + 0 + ] + }, + { + "e": "H", + "p": [ + 1.028, + -0.363, + 0 + ] + }, + { + "e": "H", + "p": [ + -0.514, + -0.363, + 0.89 + ] + }, + { + "e": "H", + "p": [ + -0.514, + -0.363, + -0.89 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 1, + 2, + 2 + ], + [ + 1, + 3, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 0, + 5, + 1 + ], + [ + 0, + 6, + 1 + ] + ] + }, + { + "formula": "C2H2O4", + "name": "Oxalic acid (HOOC-COOH)", + "names": [ + "oxalic acid", + "ethanedioic acid" + ], + "shape": "planar, two carboxyl groups", + "atoms": [ + { + "e": "C", + "p": [ + 0.77, + 0, + 0 + ] + }, + { + "e": "C", + "p": [ + -0.77, + 0, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.46, + 1.04, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.38, + -1.19, + 0 + ] + }, + { + "e": "O", + "p": [ + -1.46, + -1.04, + 0 + ] + }, + { + "e": "O", + "p": [ + -1.38, + 1.19, + 0 + ] + }, + { + "e": "H", + "p": [ + 2.34, + -1.13, + 0 + ] + }, + { + "e": "H", + "p": [ + -2.34, + 1.13, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 2 + ], + [ + 0, + 3, + 1 + ], + [ + 1, + 4, + 2 + ], + [ + 1, + 5, + 1 + ], + [ + 3, + 6, + 1 + ], + [ + 5, + 7, + 1 + ] + ] + }, + { + "formula": "C4H10O", + "name": "Diethyl ether (C₂H₅OC₂H₅)", + "names": [ + "diethyl ether", + "ether" + ], + "shape": "bent at O", + "atoms": [ + { + "e": "O", + "p": [ + 0, + 0.33, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.166, + -0.47, + 0 + ] + }, + { + "e": "C", + "p": [ + -1.166, + -0.47, + 0 + ] + }, + { + "e": "C", + "p": [ + 2.42, + 0.385, + 0 + ] + }, + { + "e": "C", + "p": [ + -2.42, + 0.385, + 0 + ] + }, + { + "e": "H", + "p": [ + 1.135, + -1.12, + 0.885 + ] + }, + { + "e": "H", + "p": [ + 1.135, + -1.12, + -0.885 + ] + }, + { + "e": "H", + "p": [ + -1.135, + -1.12, + 0.885 + ] + }, + { + "e": "H", + "p": [ + -1.135, + -1.12, + -0.885 + ] + }, + { + "e": "H", + "p": [ + 3.318, + -0.243, + 0 + ] + }, + { + "e": "H", + "p": [ + 2.47, + 1.025, + 0.885 + ] + }, + { + "e": "H", + "p": [ + 2.47, + 1.025, + -0.885 + ] + }, + { + "e": "H", + "p": [ + -3.318, + -0.243, + 0 + ] + }, + { + "e": "H", + "p": [ + -2.47, + 1.025, + 0.885 + ] + }, + { + "e": "H", + "p": [ + -2.47, + 1.025, + -0.885 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 1 + ], + [ + 1, + 3, + 1 + ], + [ + 2, + 4, + 1 + ], + [ + 1, + 5, + 1 + ], + [ + 1, + 6, + 1 + ], + [ + 2, + 7, + 1 + ], + [ + 2, + 8, + 1 + ], + [ + 3, + 9, + 1 + ], + [ + 3, + 10, + 1 + ], + [ + 3, + 11, + 1 + ], + [ + 4, + 12, + 1 + ], + [ + 4, + 13, + 1 + ], + [ + 4, + 14, + 1 + ] + ] + }, + { + "formula": "C2H6S", + "name": "Dimethyl sulfide (CH₃SCH₃)", + "names": [ + "dimethyl sulfide" + ], + "shape": "bent at S", + "atoms": [ + { + "e": "S", + "p": [ + 0, + 0.45, + 0 + ] + }, + { + "e": "C", + "p": [ + 1.452, + -0.61, + 0 + ] + }, + { + "e": "C", + "p": [ + -1.452, + -0.61, + 0 + ] + }, + { + "e": "H", + "p": [ + 2.31, + 0.062, + 0 + ] + }, + { + "e": "H", + "p": [ + 1.493, + -1.25, + 0.885 + ] + }, + { + "e": "H", + "p": [ + 1.493, + -1.25, + -0.885 + ] + }, + { + "e": "H", + "p": [ + -2.31, + 0.062, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.493, + -1.25, + 0.885 + ] + }, + { + "e": "H", + "p": [ + -1.493, + -1.25, + -0.885 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 1 + ], + [ + 1, + 3, + 1 + ], + [ + 1, + 4, + 1 + ], + [ + 1, + 5, + 1 + ], + [ + 2, + 6, + 1 + ], + [ + 2, + 7, + 1 + ], + [ + 2, + 8, + 1 + ] + ] + }, + { + "formula": "H2O2", + "name": "Hydrogen peroxide (H₂O₂)", + "names": [ + "hydrogen peroxide" + ], + "shape": "open-book (skew chain)", + "atoms": [ + { + "e": "O", + "p": [ + 0, + 0.735, + -0.05 + ] + }, + { + "e": "O", + "p": [ + 0, + -0.735, + -0.05 + ] + }, + { + "e": "H", + "p": [ + 0.839, + 0.96, + 0.42 + ] + }, + { + "e": "H", + "p": [ + -0.839, + -0.96, + 0.42 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 1 + ], + [ + 1, + 3, + 1 + ] + ] + }, + { + "formula": "N2H4", + "name": "Hydrazine (N₂H₄)", + "names": [ + "hydrazine" + ], + "shape": "gauche, two pyramidal N", + "atoms": [ + { + "e": "N", + "p": [ + 0, + 0.727, + -0.115 + ] + }, + { + "e": "N", + "p": [ + 0, + -0.727, + -0.115 + ] + }, + { + "e": "H", + "p": [ + 0.453, + 1.044, + 0.74 + ] + }, + { + "e": "H", + "p": [ + -0.905, + 1.164, + -0.025 + ] + }, + { + "e": "H", + "p": [ + -0.453, + -1.044, + 0.74 + ] + }, + { + "e": "H", + "p": [ + 0.905, + -1.164, + -0.025 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 1, + 4, + 1 + ], + [ + 1, + 5, + 1 + ] + ] + }, + { + "formula": "HCN", + "name": "Hydrogen cyanide (HCN)", + "names": [ + "hydrogen cyanide", + "prussic acid" + ], + "shape": "linear", + "atoms": [ + { + "e": "H", + "p": [ + 0, + 0, + -1.665 + ] + }, + { + "e": "C", + "p": [ + 0, + 0, + -0.575 + ] + }, + { + "e": "N", + "p": [ + 0, + 0, + 0.625 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 1, + 2, + 3 + ] + ] + }, + { + "formula": "CS2", + "name": "Carbon disulfide (CS₂)", + "names": [ + "carbon disulfide" + ], + "shape": "linear", + "atoms": [ + { + "e": "C", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "S", + "p": [ + 0, + 0, + 1.555 + ] + }, + { + "e": "S", + "p": [ + 0, + 0, + -1.555 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 0, + 2, + 2 + ] + ] + }, + { + "formula": "N2O", + "name": "Nitrous oxide (N₂O)", + "names": [ + "nitrous oxide", + "laughing gas" + ], + "shape": "linear", + "atoms": [ + { + "e": "N", + "p": [ + -1.135, + 0, + 0 + ] + }, + { + "e": "N", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.186, + 0, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 3 + ], + [ + 1, + 2, + 1 + ] + ] + }, + { + "formula": "NO2", + "name": "Nitrogen dioxide (NO₂)", + "names": [ + "nitrogen dioxide" + ], + "shape": "bent", + "atoms": [ + { + "e": "N", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.099, + 0.498, + 0 + ] + }, + { + "e": "O", + "p": [ + -1.099, + 0.498, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 0, + 2, + 1 + ] + ] + }, + { + "formula": "N2O4", + "name": "Dinitrogen tetroxide (N₂O₄)", + "names": [ + "dinitrogen tetroxide" + ], + "shape": "planar", + "atoms": [ + { + "e": "N", + "p": [ + -0.89, + 0, + 0 + ] + }, + { + "e": "N", + "p": [ + 0.89, + 0, + 0 + ] + }, + { + "e": "O", + "p": [ + -1.49, + 1.06, + 0 + ] + }, + { + "e": "O", + "p": [ + -1.49, + -1.06, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.49, + 1.06, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.49, + -1.06, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 2 + ], + [ + 0, + 3, + 1 + ], + [ + 1, + 4, + 2 + ], + [ + 1, + 5, + 1 + ] + ] + }, + { + "formula": "HNO3", + "name": "Nitric acid (HNO₃)", + "names": [ + "nitric acid" + ], + "shape": "trigonal planar", + "atoms": [ + { + "e": "N", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.046, + 0.604, + 0 + ] + }, + { + "e": "O", + "p": [ + 0, + -1.21, + 0 + ] + }, + { + "e": "O", + "p": [ + -1.205, + 0.696, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.86, + -0.02, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 2 + ], + [ + 0, + 3, + 1 + ], + [ + 3, + 4, + 1 + ] + ] + }, + { + "formula": "H2SO4", + "name": "Sulfuric acid (H₂SO₄)", + "names": [ + "sulfuric acid" + ], + "shape": "tetrahedral", + "atoms": [ + { + "e": "S", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "O", + "p": [ + 0, + 1.245, + 0.88 + ] + }, + { + "e": "O", + "p": [ + 0, + -1.245, + 0.88 + ] + }, + { + "e": "O", + "p": [ + 1.31, + 0, + -0.927 + ] + }, + { + "e": "O", + "p": [ + -1.31, + 0, + -0.927 + ] + }, + { + "e": "H", + "p": [ + 1.886, + 0, + -0.171 + ] + }, + { + "e": "H", + "p": [ + -1.886, + 0, + -0.171 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 0, + 2, + 2 + ], + [ + 0, + 3, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 3, + 5, + 1 + ], + [ + 4, + 6, + 1 + ] + ] + }, + { + "formula": "H3PO4", + "name": "Phosphoric acid (H₃PO₄)", + "names": [ + "phosphoric acid" + ], + "shape": "tetrahedral", + "atoms": [ + { + "e": "P", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "O", + "p": [ + 0, + 0, + 1.48 + ] + }, + { + "e": "O", + "p": [ + 1.453, + 0, + -0.52 + ] + }, + { + "e": "O", + "p": [ + -0.726, + 1.258, + -0.52 + ] + }, + { + "e": "O", + "p": [ + -0.726, + -1.258, + -0.52 + ] + }, + { + "e": "H", + "p": [ + 1.948, + -0.64, + 0.02 + ] + }, + { + "e": "H", + "p": [ + -1.633, + 1.13, + -0.18 + ] + }, + { + "e": "H", + "p": [ + -0.315, + -1.95, + -0.18 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 2, + 5, + 1 + ], + [ + 3, + 6, + 1 + ], + [ + 4, + 7, + 1 + ] + ] + }, + { + "formula": "H2CO3", + "name": "Carbonic acid (H₂CO₃)", + "names": [ + "carbonic acid" + ], + "shape": "trigonal planar", + "atoms": [ + { + "e": "C", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "O", + "p": [ + 0, + 1.21, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.238, + -0.715, + 0 + ] + }, + { + "e": "O", + "p": [ + -1.238, + -0.715, + 0 + ] + }, + { + "e": "H", + "p": [ + 1.193, + -1.683, + 0 + ] + }, + { + "e": "H", + "p": [ + -1.193, + -1.683, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 2, + 4, + 1 + ], + [ + 3, + 5, + 1 + ] + ] + }, + { + "formula": "SO3", + "name": "Sulfur trioxide (SO₃)", + "names": [ + "sulfur trioxide" + ], + "shape": "trigonal planar", + "atoms": [ + { + "e": "S", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "O", + "p": [ + 0, + 1.42, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.23, + -0.71, + 0 + ] + }, + { + "e": "O", + "p": [ + -1.23, + -0.71, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 0, + 2, + 2 + ], + [ + 0, + 3, + 2 + ] + ] + }, + { + "formula": "CH3Cl", + "name": "Chloromethane (CH₃Cl)", + "names": [ + "chloromethane", + "methyl chloride" + ], + "shape": "tetrahedral", + "atoms": [ + { + "e": "C", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "Cl", + "p": [ + 0, + 0, + 1.781 + ] + }, + { + "e": "H", + "p": [ + 1.028, + 0, + -0.363 + ] + }, + { + "e": "H", + "p": [ + -0.514, + 0.89, + -0.363 + ] + }, + { + "e": "H", + "p": [ + -0.514, + -0.89, + -0.363 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 0, + 4, + 1 + ] + ] + }, + { + "formula": "NO3", + "name": "Nitrate ion (NO₃⁻)", + "names": [ + "nitrate" + ], + "shape": "trigonal planar", + "atoms": [ + { + "e": "N", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "O", + "p": [ + 0, + 1.25, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.0825, + -0.625, + 0 + ] + }, + { + "e": "O", + "p": [ + -1.0825, + -0.625, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ] + ] + }, + { + "formula": "SO4", + "name": "Sulfate ion (SO₄²⁻)", + "names": [ + "sulfate" + ], + "shape": "tetrahedral", + "atoms": [ + { + "e": "S", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "O", + "p": [ + 0.853, + 0.853, + 0.853 + ] + }, + { + "e": "O", + "p": [ + -0.853, + -0.853, + 0.853 + ] + }, + { + "e": "O", + "p": [ + -0.853, + 0.853, + -0.853 + ] + }, + { + "e": "O", + "p": [ + 0.853, + -0.853, + -0.853 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 0, + 4, + 1 + ] + ] + }, + { + "formula": "CO3", + "name": "Carbonate ion (CO₃²⁻)", + "names": [ + "carbonate" + ], + "shape": "trigonal planar", + "atoms": [ + { + "e": "C", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "O", + "p": [ + 0, + 1.29, + 0 + ] + }, + { + "e": "O", + "p": [ + 1.117, + -0.645, + 0 + ] + }, + { + "e": "O", + "p": [ + -1.117, + -0.645, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 2 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ] + ] + }, + { + "formula": "PO4", + "name": "Phosphate ion (PO₄³⁻)", + "names": [ + "phosphate" + ], + "shape": "tetrahedral", + "atoms": [ + { + "e": "P", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "O", + "p": [ + 0.892, + 0.892, + 0.892 + ] + }, + { + "e": "O", + "p": [ + -0.892, + -0.892, + 0.892 + ] + }, + { + "e": "O", + "p": [ + -0.892, + 0.892, + -0.892 + ] + }, + { + "e": "O", + "p": [ + 0.892, + -0.892, + -0.892 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 2 + ], + [ + 0, + 4, + 1 + ] + ] + }, + { + "formula": "NH4", + "name": "Ammonium ion (NH₄⁺)", + "names": [ + "ammonium" + ], + "shape": "tetrahedral", + "atoms": [ + { + "e": "N", + "p": [ + 0, + 0, + 0 + ] + }, + { + "e": "H", + "p": [ + 0.583, + 0.583, + 0.583 + ] + }, + { + "e": "H", + "p": [ + -0.583, + -0.583, + 0.583 + ] + }, + { + "e": "H", + "p": [ + -0.583, + 0.583, + -0.583 + ] + }, + { + "e": "H", + "p": [ + 0.583, + -0.583, + -0.583 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 2, + 1 + ], + [ + 0, + 3, + 1 + ], + [ + 0, + 4, + 1 + ] + ] + }, + { + "formula": "OH", + "name": "Hydroxide ion (OH⁻)", + "names": [ + "hydroxide" + ], + "shape": "diatomic", + "atoms": [ + { + "e": "O", + "p": [ + -0.485, + 0, + 0 + ] + }, + { + "e": "H", + "p": [ + 0.485, + 0, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 1 + ] + ] + }, + { + "formula": "CN", + "name": "Cyanide ion (CN⁻)", + "names": [ + "cyanide" + ], + "shape": "linear diatomic", + "atoms": [ + { + "e": "C", + "p": [ + -0.58, + 0, + 0 + ] + }, + { + "e": "N", + "p": [ + 0.58, + 0, + 0 + ] + } + ], + "bonds": [ + [ + 0, + 1, + 3 + ] + ] + } + ] +} \ No newline at end of file diff --git a/data/dlc/chem-lab.dlc.json b/data/dlc/chem-lab.dlc.json new file mode 100644 index 0000000..0e04fea --- /dev/null +++ b/data/dlc/chem-lab.dlc.json @@ -0,0 +1,41 @@ +{ + "format": "visuallm-dlc/1", + "id": "chem-lab", + "name": "Chemistry Lab", + "description": "AP Chemistry — 85 demos on bonding, thermodynamics, kinetics, and equilibrium, plus an ideal-gas lab. (Molecules & balancing work from the prompt box.)", + "category": "chem", + "icon": "🧪", + "source": "official", + "version": "1.0", + "areas": [ + "AP Chemistry" + ], + "experiments": [ + { + "id": "exp-ideal-gas-isotherm", + "title": "Ideal Gas Isotherm", + "blurb": "PV = nRT. Change moles and temperature and watch the pressure-volume curve breathe.", + "area": "AP Chemistry", + "topic": "Experiments", + "params": [ + { + "name": "n", + "label": "moles n", + "min": 0.2, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "T", + "label": "temperature T (K)", + "min": 100, + "max": 600, + "step": 10, + "value": 300 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0.2, xMax: 10, yMin: 0, yMax: 12 });\nv.grid(); v.axes();\nconst n = P.n, T = P.T, R = 0.0821, scale = 0.5;\nconst Pv = (V) => (V <= 0.05 ? 0 : (n * R * T * scale) / V);\nv.fn(Pv, { color: H.colors.accent, width: 3 });\nconst Vs = 2 + 3.5 * (0.5 + 0.5 * Math.sin(t * 0.5));\nv.dot(Vs, Pv(Vs), { r: 7, fill: H.colors.warn });\nH.text('Ideal gas isotherm: P = nRT / V', 16, 24, { color: H.colors.ink, size: 18, weight: 700 });\nH.text('n=' + n.toFixed(2) + ' mol T=' + T.toFixed(0) + ' K (x = V, y = P)', 16, 48, { color: H.colors.sub, size: 13 });" + } + ] +} \ No newline at end of file diff --git a/data/dlc/index.json b/data/dlc/index.json new file mode 100644 index 0000000..9df6919 --- /dev/null +++ b/data/dlc/index.json @@ -0,0 +1,5 @@ +[ + "math-studio.dlc.json", + "physics-lab.dlc.json", + "chem-lab.dlc.json" +] \ No newline at end of file diff --git a/data/dlc/math-studio.dlc.json b/data/dlc/math-studio.dlc.json new file mode 100644 index 0000000..b4f017d --- /dev/null +++ b/data/dlc/math-studio.dlc.json @@ -0,0 +1,62 @@ +{ + "format": "visuallm-dlc/1", + "id": "math-studio", + "name": "Math Studio", + "description": "Algebra 1 & 2, Geometry, Precalculus, and Calculus — 299 interactive demos, plus a function-transformer lab.", + "category": "math", + "icon": "📐", + "source": "official", + "version": "1.0", + "areas": [ + "Algebra 1", + "Algebra 2", + "Geometry", + "Geometry & Trig", + "Precalculus", + "Calculus" + ], + "experiments": [ + { + "id": "exp-function-transformer", + "title": "Function Transformer", + "blurb": "Slide a, b, c, d and watch how each one stretches, squeezes, shifts, and lifts a sine wave.", + "area": "Precalculus", + "topic": "Experiments", + "params": [ + { + "name": "a", + "label": "amplitude a", + "min": -3, + "max": 3, + "step": 0.1, + "value": 2 + }, + { + "name": "b", + "label": "frequency b", + "min": 0.2, + "max": 4, + "step": 0.1, + "value": 1 + }, + { + "name": "c", + "label": "phase c", + "min": -3.14, + "max": 3.14, + "step": 0.1, + "value": 0 + }, + { + "name": "d", + "label": "midline d", + "min": -4, + "max": 4, + "step": 0.1, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6.5, xMax: 6.5, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst a = P.a, b = (Math.abs(P.b) < 0.05 ? 0.05 : P.b), c = P.c, d = P.d;\nconst f = (x) => a * Math.sin(b * x + c) + d;\nv.line(-6.5, d, 6.5, d, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst xs = 6 * Math.sin(t * 0.6);\nv.dot(xs, f(xs), { r: 6, fill: H.colors.accent2 });\nH.text('Transform: y = a\\u00b7sin(b\\u00b7x + c) + d', 16, 24, { color: H.colors.ink, size: 18, weight: 700 });\nH.text('a=' + a.toFixed(1) + ' b=' + b.toFixed(1) + ' c=' + c.toFixed(1) + ' d=' + d.toFixed(1), 16, 48, { color: H.colors.sub, size: 13 });" + } + ] +} \ No newline at end of file diff --git a/data/dlc/physics-lab.dlc.json b/data/dlc/physics-lab.dlc.json new file mode 100644 index 0000000..9c0c476 --- /dev/null +++ b/data/dlc/physics-lab.dlc.json @@ -0,0 +1,58 @@ +{ + "format": "visuallm-dlc/1", + "id": "physics-lab", + "name": "Physics Lab", + "description": "Mechanics, waves, and electromagnetism — 218 demos across core and AP Physics, plus a wave-interference lab.", + "category": "physics", + "icon": "🔬", + "source": "official", + "version": "1.0", + "areas": [ + "Physics", + "AP Physics" + ], + "experiments": [ + { + "id": "exp-wave-superposition", + "title": "Wave Superposition", + "blurb": "Add two travelling waves and see interference — tune amplitudes and wavenumbers to build beats and standing patterns.", + "area": "Physics", + "topic": "Experiments", + "params": [ + { + "name": "a1", + "label": "amplitude A₁", + "min": 0, + "max": 2, + "step": 0.1, + "value": 1.2 + }, + { + "name": "k1", + "label": "wavenumber k₁", + "min": 0.5, + "max": 4, + "step": 0.1, + "value": 1 + }, + { + "name": "a2", + "label": "amplitude A₂", + "min": 0, + "max": 2, + "step": 0.1, + "value": 0.8 + }, + { + "name": "k2", + "label": "wavenumber k₂", + "min": 0.5, + "max": 4, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6.5, xMax: 6.5, yMin: -4.5, yMax: 4.5 });\nv.grid(); v.axes();\nconst a1 = P.a1, k1 = P.k1, a2 = P.a2, k2 = P.k2;\nconst w1 = (x) => a1 * Math.sin(k1 * x - t);\nconst w2 = (x) => a2 * Math.sin(k2 * x - t);\nv.fn(w1, { color: H.colors.good, width: 1.5 });\nv.fn(w2, { color: H.colors.violet, width: 1.5 });\nv.fn((x) => w1(x) + w2(x), { color: H.colors.accent, width: 3 });\nH.text('Superposition: y = A\\u2081sin(k\\u2081x\\u2212t) + A\\u2082sin(k\\u2082x\\u2212t)', 16, 24, { color: H.colors.ink, size: 15, weight: 700 });\nH.text('A1=' + a1.toFixed(1) + ' k1=' + k1.toFixed(1) + ' A2=' + a2.toFixed(1) + ' k2=' + k2.toFixed(1), 16, 48, { color: H.colors.sub, size: 13 });" + } + ] +} \ No newline at end of file diff --git a/demo_library.py b/demo_library.py new file mode 100644 index 0000000..7898557 --- /dev/null +++ b/demo_library.py @@ -0,0 +1,554 @@ +"""Curated, parameterized DEMO library for VisualLM (Algebra 1 -> Precalculus). + +The "interactive textbook" path: instead of generating code per prompt, the +server classifies the prompt to a topic (demo_match in main.py), shows a +hand-built demo, lets the student edit its values live (the `params` become the +`P` global the scene reads each frame), and explains the idea/theorem. + +Each entry: + id - stable identifier + area - curriculum band (Algebra 1 / Algebra 2 / Geometry & Trig / Precalculus) + topic - short topic name + title - scene title + equation - the central relationship, plain text + keywords - synonym-rich terms used to classify a prompt to this demo + explanation - the idea/theorem, written to BUILD UNDERSTANDING (not just state + an answer): what each parameter does and why. + bullets - 3 short teaching points + params - editable controls: {name, label, min, max, step, value}; the + scene reads them as P., updated live by the UI sliders + code - scene body (uses P., t, and the H helper API) + +Every `code` follows the H API contract and is checked by validate_scene.js +(which supplies a permissive `P`). Keep them animated (read `t`) and labeled. +""" +from __future__ import annotations + +_BASE: list[dict] = [ + { + "id": "linear-slope-intercept", + "area": "Algebra 1", + "topic": "Linear functions", + "title": "Slope–intercept form: y = mx + b", + "equation": "y = m·x + b", + "keywords": [ + "slope", "intercept", "slope intercept", "linear", "linear function", + "line", "y=mx+b", "mx+b", "rise over run", "gradient", "straight line", + ], + "explanation": ( + "A line's slope m is its steepness — the rise over the run. Increase m " + "and the line tilts up faster; make it negative and the line goes " + "downhill. The intercept b slides the whole line up or down, and is " + "exactly where it crosses the y-axis (x = 0). Watch the rise/run " + "triangle change as you drag m." + ), + "bullets": [ + "m = rise / run: the change in y for a 1-unit change in x.", + "b is the y-value where the line crosses the y-axis (x = 0).", + "Two points, or a point and a slope, fully determine a line.", + ], + "params": [ + {"name": "m", "label": "slope m", "min": -4, "max": 4, "step": 0.1, "value": 1}, + {"name": "b", "label": "y-intercept b", "min": -6, "max": 6, "step": 0.5, "value": 1}, + ], + "code": r""" +H.background(); +const v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 }); +v.grid(); v.axes(); +const m = P.m, b = P.b; +v.fn(x => m * x + b, { color: H.colors.accent, width: 3 }); +v.dot(0, b, { r: 6, fill: H.colors.warn }); +v.line(0, b, 2, b, { color: H.colors.good, width: 2, dash: [5, 5] }); +v.line(2, b, 2, m * 2 + b, { color: H.colors.violet, width: 2, dash: [5, 5] }); +const xs = 6 * Math.sin(t * 0.6); +v.dot(xs, m * xs + b, { r: 6, fill: H.colors.accent2 }); +H.text("y = m·x + b", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("m = " + m.toFixed(2) + " b = " + b.toFixed(2), 24, 52, { color: H.colors.sub, size: 14 }); +H.legend([{ label: "rise", color: H.colors.violet }, { label: "run", color: H.colors.good }], H.W - 150, 28); +""".strip(), + }, + { + "id": "systems-linear", + "area": "Algebra 1", + "topic": "Systems of equations", + "title": "System of two linear equations", + "equation": "y = m1·x + b1, y = m2·x + b2", + "keywords": [ + "system", "systems of equations", "two equations", "intersection", + "simultaneous", "solve system", "elimination", "substitution", + "point of intersection", "two lines", + ], + "explanation": ( + "The solution of a system is the point that lies on BOTH lines at once " + "— where they cross. Slide the slopes and intercepts: when the lines " + "have different slopes there's exactly one crossing; give them the same " + "slope and they're parallel (no solution) or identical (infinitely " + "many). The pulsing dot marks the solution." + ), + "bullets": [ + "A solution satisfies every equation at the same time.", + "Different slopes -> one intersection; equal slopes -> parallel or identical.", + "Graphing finds it visually; elimination/substitution find it exactly.", + ], + "params": [ + {"name": "m1", "label": "slope 1", "min": -4, "max": 4, "step": 0.1, "value": 1}, + {"name": "b1", "label": "intercept 1", "min": -6, "max": 6, "step": 0.5, "value": 2}, + {"name": "m2", "label": "slope 2", "min": -4, "max": 4, "step": 0.1, "value": -1}, + {"name": "b2", "label": "intercept 2", "min": -6, "max": 6, "step": 0.5, "value": -1}, + ], + "code": r""" +H.background(); +const v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 }); +v.grid(); v.axes(); +const m1 = P.m1, b1 = P.b1, m2 = P.m2, b2 = P.b2; +v.fn(x => m1 * x + b1, { color: H.colors.accent, width: 3 }); +v.fn(x => m2 * x + b2, { color: H.colors.accent2, width: 3 }); +H.text("System: two lines", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +if (Math.abs(m1 - m2) > 1e-6) { + const xi = (b2 - b1) / (m1 - m2), yi = m1 * xi + b1; + H.circle(v.X(xi), v.Y(yi), 6 + Math.sin(t * 3), { fill: H.colors.warn }); + H.text("solution (" + xi.toFixed(2) + ", " + yi.toFixed(2) + ")", 24, 52, { color: H.colors.good, size: 14 }); +} else { + H.text(Math.abs(b1 - b2) < 1e-6 ? "same line — infinitely many solutions" : "parallel lines — no solution", 24, 52, { color: H.colors.warn, size: 14 }); +} +H.legend([{ label: "line 1", color: H.colors.accent }, { label: "line 2", color: H.colors.accent2 }], H.W - 150, 28); +""".strip(), + }, + { + "id": "abs-value-transform", + "area": "Algebra 1", + "topic": "Absolute value", + "title": "Absolute value: y = a|x − h| + k", + "equation": "y = a·|x − h| + k", + "keywords": [ + "absolute value", "abs", "modulus", "v shape", "vertex", "piecewise", + "|x|", "absolute value function", "translation", + ], + "explanation": ( + "The graph of |x| is a V with its corner at the origin. h slides the " + "corner left/right, k slides it up/down, and a controls how steep the " + "arms are (negative a flips the V upside-down). The corner sits exactly " + "at the vertex (h, k) — drag the sliders and watch it move." + ), + "bullets": [ + "|x − h| shifts the corner to x = h; + k shifts it up by k.", + "a stretches the arms (|a| > 1 steeper); a < 0 opens it downward.", + "The vertex (h, k) is the minimum (or maximum if a < 0).", + ], + "params": [ + {"name": "a", "label": "steepness a", "min": -3, "max": 3, "step": 0.1, "value": 1}, + {"name": "h", "label": "shift right h", "min": -5, "max": 5, "step": 0.5, "value": 0}, + {"name": "k", "label": "shift up k", "min": -3, "max": 6, "step": 0.5, "value": 0}, + ], + "code": r""" +H.background(); +const v = H.plot2d({ xMin: -8, xMax: 8, yMin: -4, yMax: 10 }); +v.grid(); v.axes(); +const a = P.a, h = P.h, k = P.k; +v.fn(x => a * Math.abs(x - h) + k, { color: H.colors.accent, width: 3 }); +v.dot(h, k, { r: 7, fill: H.colors.warn }); +const xs = h + 5 * Math.sin(t * 0.7); +v.dot(xs, a * Math.abs(xs - h) + k, { r: 6, fill: H.colors.accent2 }); +H.text("y = a|x − h| + k", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("vertex (" + h.toFixed(1) + ", " + k.toFixed(1) + ") slope ±" + Math.abs(a).toFixed(1), 24, 52, { color: H.colors.sub, size: 14 }); +""".strip(), + }, + { + "id": "exponential-growth-decay", + "area": "Algebra 1", + "topic": "Exponential functions", + "title": "Exponential growth & decay: y = a·bˣ", + "equation": "y = a · b^x", + "keywords": [ + "exponential", "growth", "decay", "compound", "b^x", "exponential function", + "half life", "doubling", "exponential growth", "exponential decay", + ], + "explanation": ( + "Exponential change multiplies by the same factor b every step (unlike " + "linear change, which adds). When b > 1 the curve shoots up (growth); " + "when 0 < b < 1 it decays toward zero. a is the starting value at x = 0. " + "Notice how a small change in b dramatically changes how fast it rises." + ), + "bullets": [ + "Each unit of x multiplies y by b (constant ratio, not constant difference).", + "b > 1 grows; 0 < b < 1 decays; a is the value at x = 0.", + "Growth eventually outpaces ANY straight line.", + ], + "params": [ + {"name": "a", "label": "start a", "min": 0.2, "max": 6, "step": 0.2, "value": 1}, + {"name": "b", "label": "base b", "min": 0.2, "max": 2.5, "step": 0.05, "value": 1.5}, + ], + "code": r""" +H.background(); +const v = H.plot2d({ xMin: -1, xMax: 8, yMin: -1, yMax: 12 }); +v.grid(); v.axes(); +const a = P.a, b = Math.max(0.05, P.b); +v.fn(x => a * Math.pow(b, x), { color: H.colors.accent, width: 3 }); +const xs = (t % 8); +v.dot(xs, a * Math.pow(b, xs), { r: 6, fill: H.colors.warn }); +H.text("y = a · bˣ", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("a = " + a.toFixed(2) + " b = " + b.toFixed(2) + (b > 1 ? " (growth)" : " (decay)"), 24, 52, { color: H.colors.sub, size: 14 }); +""".strip(), + }, + { + "id": "quadratic-vertex", + "area": "Algebra 2", + "topic": "Quadratic functions", + "title": "Quadratic (vertex form): y = a(x − h)² + k", + "equation": "y = a(x − h)² + k", + "keywords": [ + "quadratic", "parabola", "vertex", "vertex form", "axis of symmetry", + "completing the square", "x squared", "quadratic function", + ], + "explanation": ( + "Every parabola is a stretched, shifted version of y = x². The vertex " + "(h, k) is its turning point, and the parabola is mirror-symmetric " + "across the vertical line x = h. a sets how narrow it is and which way " + "it opens (a < 0 opens down). Vertex form lets you read the vertex off " + "directly — no algebra needed." + ), + "bullets": [ + "Vertex (h, k) is the turning point; x = h is the axis of symmetry.", + "|a| > 1 narrows the parabola; a < 0 opens it downward.", + "Vertex form is y = x² shifted by (h, k) and scaled by a.", + ], + "params": [ + {"name": "a", "label": "shape a", "min": -3, "max": 3, "step": 0.1, "value": 1}, + {"name": "h", "label": "vertex x h", "min": -5, "max": 5, "step": 0.5, "value": 0}, + {"name": "k", "label": "vertex y k", "min": -3, "max": 8, "step": 0.5, "value": 0}, + ], + "code": r""" +H.background(); +const v = H.plot2d({ xMin: -8, xMax: 8, yMin: -4, yMax: 12 }); +v.grid(); v.axes(); +const a = P.a, h = P.h, k = P.k; +v.fn(x => a * (x - h) * (x - h) + k, { color: H.colors.accent, width: 3 }); +v.line(h, -4, h, 12, { color: H.colors.violet, width: 1.5, dash: [4, 4] }); +v.dot(h, k, { r: 7, fill: H.colors.warn }); +const xs = h + 4 * Math.sin(t * 0.7); +v.dot(xs, a * (xs - h) * (xs - h) + k, { r: 6, fill: H.colors.accent2 }); +H.text("y = a(x − h)² + k", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("vertex (" + h.toFixed(1) + ", " + k.toFixed(1) + ") a = " + a.toFixed(2), 24, 52, { color: H.colors.sub, size: 14 }); +""".strip(), + }, + { + "id": "quadratic-discriminant", + "area": "Algebra 2", + "topic": "Quadratic formula", + "title": "Roots & the discriminant: y = ax² + bx + c", + "equation": "x = (−b ± √(b² − 4ac)) / 2a", + "keywords": [ + "quadratic formula", "discriminant", "roots", "zeros", "x intercepts", + "ax2+bx+c", "factoring", "solutions", "real roots", "b^2-4ac", + "quadratic", + ], + "explanation": ( + "The roots are where the parabola crosses the x-axis (y = 0). The " + "discriminant b² − 4ac (the part under the square root) tells you how " + "many real roots there are BEFORE you solve: positive -> two, zero -> " + "one (the vertex touches the axis), negative -> none (it floats above " + "or below). Watch the green roots appear and merge as you change c." + ), + "bullets": [ + "Roots are the x-intercepts: where ax² + bx + c = 0.", + "Discriminant b² − 4ac: >0 two roots, =0 one, <0 none (real).", + "The two roots are symmetric about the axis x = −b/2a.", + ], + "params": [ + {"name": "a", "label": "a", "min": -2, "max": 2, "step": 0.1, "value": 1}, + {"name": "b", "label": "b", "min": -6, "max": 6, "step": 0.5, "value": 0}, + {"name": "c", "label": "c", "min": -8, "max": 8, "step": 0.5, "value": -4}, + ], + "code": r""" +H.background(); +const v = H.plot2d({ xMin: -8, xMax: 8, yMin: -10, yMax: 10 }); +v.grid(); v.axes(); +const a = P.a, b = P.b, c = P.c; +v.fn(x => a * x * x + b * x + c, { color: H.colors.accent, width: 3 }); +const disc = b * b - 4 * a * c; +if (disc >= 0 && Math.abs(a) > 1e-6) { + const r1 = (-b + Math.sqrt(disc)) / (2 * a), r2 = (-b - Math.sqrt(disc)) / (2 * a); + v.dot(r1, 0, { r: 6, fill: H.colors.good }); + v.dot(r2, 0, { r: 6, fill: H.colors.good }); +} +const xs = 5 * Math.sin(t * 0.6); +v.dot(xs, a * xs * xs + b * xs + c, { r: 5, fill: H.colors.accent2 }); +H.text("y = ax² + bx + c", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +const verdict = disc > 1e-9 ? " → 2 real roots" : Math.abs(disc) <= 1e-9 ? " → 1 real root" : " → no real roots"; +H.text("b² − 4ac = " + disc.toFixed(2) + verdict, 24, 52, { color: H.colors.sub, size: 13 }); +""".strip(), + }, + { + "id": "polynomial-end-behavior", + "area": "Algebra 2", + "topic": "Polynomials", + "title": "Power functions & end behavior: y = a·xⁿ", + "equation": "y = a · x^n", + "keywords": [ + "polynomial", "end behavior", "degree", "power function", "x^n", + "even odd degree", "leading coefficient", "cubic", "quartic", + ], + "explanation": ( + "The highest-power term controls what a polynomial does at the far left " + "and right. Even degree makes both ends go the same way (both up, or " + "both down if a < 0); odd degree sends the ends in opposite directions. " + "The sign of the leading coefficient a flips them. Step n through whole " + "numbers to feel the even/odd pattern." + ), + "bullets": [ + "Even n: both ends point the same way; odd n: opposite ends.", + "a > 0 lifts the right end; a < 0 flips the whole picture.", + "Only the leading term matters for the far-left/far-right behavior.", + ], + "params": [ + {"name": "a", "label": "leading coef a", "min": -2, "max": 2, "step": 0.1, "value": 1}, + {"name": "n", "label": "degree n", "min": 1, "max": 5, "step": 1, "value": 3}, + ], + "code": r""" +H.background(); +const v = H.plot2d({ xMin: -3, xMax: 3, yMin: -10, yMax: 10 }); +v.grid(); v.axes(); +const a = P.a, n = Math.max(1, Math.round(P.n)); +v.fn(x => a * Math.pow(x, n), { color: H.colors.accent, width: 3 }); +const xs = 2.5 * Math.sin(t * 0.6); +v.dot(xs, a * Math.pow(xs, n), { r: 6, fill: H.colors.warn }); +const even = n % 2 === 0; +H.text("y = a · xⁿ (end behavior)", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("a = " + a.toFixed(1) + " n = " + n + (even ? " even: ends match" : " odd: ends oppose"), 24, 52, { color: H.colors.sub, size: 13 }); +""".strip(), + }, + { + "id": "log-exp-inverse", + "area": "Algebra 2", + "topic": "Logarithms", + "title": "Logarithm as the inverse of bˣ", + "equation": "y = b^x ⇔ y = log_b(x)", + "keywords": [ + "logarithm", "log", "inverse function", "exponential inverse", "log base", + "ln", "natural log", "reflection", "logarithmic", + ], + "explanation": ( + "A logarithm undoes an exponential: log_b(x) answers 'b to what power " + "gives x?'. Because they're inverses, their graphs are mirror images " + "across the line y = x — every (x, y) on bˣ becomes (y, x) on log_b. " + "That's why the log grows so slowly: it's the exponential turned on its " + "side." + ), + "bullets": [ + "log_b(x) is the exponent: b^(log_b x) = x.", + "bˣ and log_b(x) are reflections across y = x (inverse functions).", + "The log is only defined for x > 0 and rises ever more slowly.", + ], + "params": [ + {"name": "b", "label": "base b", "min": 1.2, "max": 4, "step": 0.1, "value": 2}, + ], + "code": r""" +H.background(); +const v = H.plot2d({ xMin: -6, xMax: 6, yMin: -6, yMax: 6 }); +v.grid(); v.axes(); +const b = Math.min(5, Math.max(1.2, P.b)); +v.line(-6, -6, 6, 6, { color: H.colors.violet, width: 1.5, dash: [5, 5] }); +v.fn(x => Math.pow(b, x), { color: H.colors.accent, width: 3 }); +v.fn(x => (x > 0 ? Math.log(x) / Math.log(b) : NaN), { color: H.colors.accent2, width: 3 }); +const xs = 2.4 * Math.sin(t * 0.6); +v.dot(xs, Math.pow(b, xs), { r: 5, fill: H.colors.warn }); +H.text("y = bˣ and its inverse y = log_b(x)", 24, 30, { color: H.colors.ink, size: 17, weight: 700 }); +H.text("base b = " + b.toFixed(2) + " — mirror images across y = x", 24, 52, { color: H.colors.sub, size: 13 }); +H.legend([{ label: "bˣ", color: H.colors.accent }, { label: "log_b x", color: H.colors.accent2 }, { label: "y = x", color: H.colors.violet }], H.W - 150, 28); +""".strip(), + }, + { + "id": "function-transformations", + "area": "Algebra 2", + "topic": "Transformations", + "title": "Transformations: y = a·f(b(x − h)) + k", + "equation": "y = a · f(b(x − h)) + k", + "keywords": [ + "transformation", "shift", "stretch", "translate", "reflect", "compress", + "parent function", "horizontal vertical shift", "scaling", "transformations", + ], + "explanation": ( + "Every function family shares the same four moves applied to a parent " + "f(x). k shifts it up/down and h shifts it left/right (note: x − h moves " + "RIGHT by h). a stretches it vertically (and flips it if negative); b " + "stretches it horizontally — and bigger b actually SQUEEZES it. Compare " + "the faint parent curve to the bold transformed one." + ), + "bullets": [ + "Outside the function (a, k): vertical stretch and shift.", + "Inside (b, h): horizontal — and they work 'backwards' (x − h moves right).", + "Negative a or b reflects across the x- or y-axis.", + ], + "params": [ + {"name": "a", "label": "vert stretch a", "min": -3, "max": 3, "step": 0.1, "value": 1}, + {"name": "b", "label": "horiz squeeze b", "min": 0.2, "max": 3, "step": 0.1, "value": 1}, + {"name": "h", "label": "shift right h", "min": -4, "max": 4, "step": 0.5, "value": 0}, + {"name": "k", "label": "shift up k", "min": -4, "max": 6, "step": 0.5, "value": 0}, + ], + "code": r""" +H.background(); +const v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 10 }); +v.grid(); v.axes(); +const a = P.a, b = Math.max(0.1, P.b), h = P.h, k = P.k; +const base = (x) => Math.sin(x); +v.fn(base, { color: H.colors.sub, width: 1.5 }); +v.fn(x => a * base(b * (x - h)) + k, { color: H.colors.accent, width: 3 }); +const xs = 6 * Math.sin(t * 0.6); +v.dot(xs, a * base(b * (xs - h)) + k, { r: 6, fill: H.colors.warn }); +H.text("y = a · f(b(x − h)) + k", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("a=" + a.toFixed(1) + " b=" + b.toFixed(1) + " h=" + h.toFixed(1) + " k=" + k.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 }); +H.legend([{ label: "parent f(x)=sin x", color: H.colors.sub }, { label: "transformed", color: H.colors.accent }], H.W - 230, 28); +""".strip(), + }, + { + "id": "unit-circle", + "area": "Geometry & Trig", + "topic": "Unit circle", + "title": "Unit circle: (cos θ, sin θ)", + "equation": "(x, y) = (cos θ, sin θ)", + "keywords": [ + "unit circle", "cosine", "sine", "trig", "angle", "radians", "degrees", + "cos sin", "reference angle", "trigonometry", + ], + "explanation": ( + "Spin the angle θ and the point rides a circle of radius 1. Its shadow " + "on the x-axis IS cos θ and its height above the x-axis IS sin θ — that's " + "the definition of cosine and sine. The dashed legs show exactly those " + "two lengths. One full trip around is 360° (2π radians)." + ), + "bullets": [ + "cos θ is the x-coordinate; sin θ is the y-coordinate, on radius 1.", + "cos²θ + sin²θ = 1 — it's the Pythagorean theorem on the circle.", + "360° = 2π radians; the signs flip by quadrant.", + ], + "params": [ + {"name": "deg", "label": "angle θ (degrees)", "min": 0, "max": 360, "step": 1, "value": 45}, + ], + "code": r""" +H.background(); +const cx = H.W * 0.5, cy = H.H * 0.52, R = Math.min(H.W, H.H) * 0.3; +const ang = P.deg * Math.PI / 180; +H.circle(cx, cy, R, { stroke: H.colors.grid, width: 1.5 }); +H.line(cx - R - 12, cy, cx + R + 12, cy, { color: H.colors.axis, width: 1 }); +H.line(cx, cy - R - 12, cx, cy + R + 12, { color: H.colors.axis, width: 1 }); +const px = cx + R * Math.cos(ang), py = cy - R * Math.sin(ang); +H.line(cx, cy, px, py, { color: H.colors.accent2, width: 2 }); +H.line(px, py, px, cy, { color: H.colors.good, width: 2, dash: [4, 4] }); +H.line(px, py, cx, py, { color: H.colors.accent, width: 2, dash: [4, 4] }); +H.circle(px, py, 6 + Math.sin(t * 3), { fill: H.colors.warn }); +H.text("Unit circle: (cos θ, sin θ)", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("θ = " + P.deg.toFixed(0) + "° cos θ = " + Math.cos(ang).toFixed(2) + " sin θ = " + Math.sin(ang).toFixed(2), 24, 52, { color: H.colors.sub, size: 13 }); +H.legend([{ label: "cos θ", color: H.colors.accent }, { label: "sin θ", color: H.colors.good }], H.W - 150, 28); +""".strip(), + }, + { + "id": "sine-wave", + "area": "Precalculus", + "topic": "Sinusoids", + "title": "Sine wave: y = A·sin(B(x − C)) + D", + "equation": "y = A·sin(B(x − C)) + D", + "keywords": [ + "sine", "sinusoid", "amplitude", "period", "phase shift", "frequency", + "trig graph", "cosine wave", "midline", "sine function", "wave", + ], + "explanation": ( + "Four knobs shape every sinusoid. A is the amplitude (how tall above and " + "below the midline). B sets the frequency — the period is 2π/B, so " + "bigger B means tighter waves. C is the phase shift (slides the wave " + "left/right) and D is the midline (slides it up/down). Watch the dot " + "ride the wave as you tune them." + ), + "bullets": [ + "Amplitude A = half the peak-to-trough height.", + "Period = 2π / B (bigger B -> shorter period).", + "C shifts horizontally; D is the midline the wave oscillates about.", + ], + "params": [ + {"name": "A", "label": "amplitude A", "min": 0.2, "max": 4, "step": 0.1, "value": 2}, + {"name": "B", "label": "frequency B", "min": 0.2, "max": 4, "step": 0.1, "value": 1}, + {"name": "C", "label": "phase shift C", "min": -3, "max": 3, "step": 0.1, "value": 0}, + {"name": "D", "label": "midline D", "min": -3, "max": 3, "step": 0.5, "value": 0}, + ], + "code": r""" +H.background(); +const v = H.plot2d({ xMin: -Math.PI, xMax: 3 * Math.PI, yMin: -6, yMax: 6 }); +v.grid(); v.axes(); +const A = P.A, B = Math.max(0.1, P.B), C = P.C, D = P.D; +v.line(-Math.PI, D, 3 * Math.PI, D, { color: H.colors.violet, width: 1.5, dash: [5, 5] }); +v.fn(x => A * Math.sin(B * (x - C)) + D, { color: H.colors.accent, width: 3 }); +const xs = -Math.PI + ((t * 0.8) % (4 * Math.PI)); +v.dot(xs, A * Math.sin(B * (xs - C)) + D, { r: 6, fill: H.colors.warn }); +H.text("y = A·sin(B(x − C)) + D", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("A = " + A.toFixed(1) + " period = " + (2 * Math.PI / B).toFixed(2) + " C = " + C.toFixed(1) + " D = " + D.toFixed(1), 24, 52, { color: H.colors.sub, size: 12 }); +""".strip(), + }, + { + "id": "ellipse", + "area": "Precalculus", + "topic": "Conic sections", + "title": "Ellipse: x²/a² + y²/b² = 1", + "equation": "x²/a² + y²/b² = 1", + "keywords": [ + "ellipse", "conic", "conic section", "foci", "major axis", "minor axis", + "oval", "eccentricity", "semi axis", + ], + "explanation": ( + "An ellipse is the set of points whose distances to two fixed foci add " + "to a constant. a and b are the semi-axes (half-widths) along x and y; " + "when a = b you get a circle. The foci sit on the longer axis at " + "distance c = √|a² − b²| from the center — the more a and b differ, the " + "more stretched (eccentric) the ellipse." + ), + "bullets": [ + "a and b are the semi-axis lengths along x and y; a = b is a circle.", + "Foci lie on the major axis at c = √|a² − b²| from the center.", + "Sum of distances to the two foci is constant for every point.", + ], + "params": [ + {"name": "a", "label": "semi-axis a", "min": 0.5, "max": 7, "step": 0.5, "value": 5}, + {"name": "b", "label": "semi-axis b", "min": 0.5, "max": 5, "step": 0.5, "value": 3}, + ], + "code": r""" +H.background(); +const v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 6 }); +v.grid(); v.axes(); +const a = Math.max(0.5, P.a), b = Math.max(0.5, P.b); +const pts = []; +for (let i = 0; i <= 90; i++) { const th = i / 90 * H.TAU; pts.push([a * Math.cos(th), b * Math.sin(th)]); } +v.path(pts, { color: H.colors.accent, width: 3, close: true }); +const c = Math.sqrt(Math.abs(a * a - b * b)); +if (a >= b) { v.dot(c, 0, { r: 5, fill: H.colors.warn }); v.dot(-c, 0, { r: 5, fill: H.colors.warn }); } +else { v.dot(0, c, { r: 5, fill: H.colors.warn }); v.dot(0, -c, { r: 5, fill: H.colors.warn }); } +const th = t * 0.8; +v.dot(a * Math.cos(th), b * Math.sin(th), { r: 6, fill: H.colors.accent2 }); +H.text("Ellipse: x²/a² + y²/b² = 1", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("a = " + a.toFixed(1) + " b = " + b.toFixed(1) + " foci at c = " + c.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 }); +""".strip(), + }, +] + +# Demos authored + validated by the algebra-precalc-demos workflow (one per +# Algebra 1 / Algebra 2 / Precalculus topic), re-checked through +# validate_scene.js so every code runs, paints on-screen, animates, and is +# labeled. Stored as data in demo_library_generated.json and merged here. Kept +# separate from _BASE so the hand-verified baseline is always present. +import json as _json +from pathlib import Path as _Path + +def _load_generated(filename: str) -> list[dict]: + try: + data = _json.loads((_Path(__file__).resolve().parent / filename).read_text(encoding="utf-8")) + return data if isinstance(data, list) else [] + except (OSError, ValueError): + return [] + + +# Generated curriculum demos: math (Algebra 1 → Precalculus) + physics + +# geometry & front-half calculus (limits, derivatives, applications). +_GENERATED: list[dict] = _load_generated("demo_library_generated.json") +_PHYSICS: list[dict] = _load_generated("physics_library_generated.json") +_GEO_CALC: list[dict] = _load_generated("geometry_calculus_generated.json") +_AP: list[dict] = _load_generated("ap_physics_chemistry_generated.json") + +# Hand-written demos first so they win id/keyword ties over generated ones. +DEMO_LIBRARY: list[dict] = _BASE + _GENERATED + _PHYSICS + _GEO_CALC + _AP diff --git a/demo_library_generated.json b/demo_library_generated.json new file mode 100644 index 0000000..85961a2 --- /dev/null +++ b/demo_library_generated.json @@ -0,0 +1,10215 @@ +[ + { + "id": "a1-absolute-value-equations", + "area": "Algebra 1", + "topic": "Absolute value equations", + "title": "Absolute value equation: |x − h| = d", + "equation": "|x - h| = d", + "keywords": [ + "absolute value equation", + "absolute value", + "abs", + "|x|", + "distance", + "two solutions", + "solve absolute value", + "modulus", + "|x-h|=d", + "number line", + "split into two", + "plus or minus" + ], + "explanation": "Absolute value measures DISTANCE, so |x − h| = d asks: which points sit exactly d away from h on the number line? Slide h to move the center, and slide d to set the distance — there are always two answers, h − d and h + d, one on each side. The sweeping probe dot lights up red exactly when its distance from h equals d. When d shrinks to 0 the two answers merge into one (x = h); a negative distance is impossible, so there'd be no solution.", + "bullets": [ + "|x − h| is the distance between x and h, never negative.", + "|x − h| = d splits into x − h = d OR x − h = −d, giving x = h ± d.", + "d > 0 → two solutions; d = 0 → one (x = h); d < 0 → none." + ], + "params": [ + { + "name": "h", + "label": "center h", + "min": -6, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "d", + "label": "distance d", + "min": 0, + "max": 6, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\n// Absolute value equation: |x - h| = d (distance from h equals d)\n// Solutions are the two points h - d and h + d on the number line.\nconst h = P.h, d = Math.abs(P.d);\nH.text(\"Absolute value equation: |x − h| = d\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst nSols = d > 1e-9 ? 2 : (Math.abs(d) <= 1e-9 ? 1 : 0);\nH.text(\"|x − \" + h.toFixed(1) + \"| = \" + d.toFixed(1) + \" means: x is distance \" + d.toFixed(1) + \" from \" + h.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nconst W = H.W, Ht = H.H;\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -2, yMax: 2, box: { x: 50, y: Ht * 0.5, w: W - 100, h: 90 } });\nv.line(-10, 0, 10, 0, { color: H.colors.axis, width: 2 });\nfor (let i = -10; i <= 10; i++) {\n v.line(i, -0.22, i, 0.22, { color: H.colors.grid, width: 1 });\n if (i % 2 === 0) v.text(String(i), i, -0.95, { color: H.colors.sub, size: 11, align: \"center\" });\n}\n// center h\nv.dot(h, 0, { r: 5, fill: H.colors.violet });\nv.text(\"h = \" + h.toFixed(1), h, 1.5, { color: H.colors.violet, size: 12, align: \"center\" });\nconst x1 = h - d, x2 = h + d;\n// distance brackets from h out to each solution\nv.line(h, 0.55, x1, 0.55, { color: H.colors.accent, width: 3 });\nv.line(h, -0.55, x2, -0.55, { color: H.colors.accent2, width: 3 });\nv.text(\"dist \" + d.toFixed(1), (h + x1) / 2, 1.05, { color: H.colors.accent, size: 11, align: \"center\" });\nv.text(\"dist \" + d.toFixed(1), (h + x2) / 2, -1.25, { color: H.colors.accent2, size: 11, align: \"center\" });\n// solution dots\nv.dot(x1, 0, { r: 7, fill: H.colors.good });\nv.dot(x2, 0, { r: 7, fill: H.colors.good });\nv.text(\"x = \" + x1.toFixed(1), x1, 1.5, { color: H.colors.good, size: 12, align: \"center\" });\nv.text(\"x = \" + x2.toFixed(1), x2, 1.5, { color: H.colors.good, size: 12, align: \"center\" });\n// animated probe: a dot sweeping the line; its bar grows = |x - h|, glows when it equals d\nconst xp = h + (d + 3) * Math.sin(t * 0.8);\nconst dist = Math.abs(xp - h);\nconst hit = Math.abs(dist - d) < 0.12;\nv.dot(xp, 0, { r: hit ? 8 + Math.sin(t * 8) : 5, fill: hit ? H.colors.warn : H.colors.accent });\nH.text(\"|x − h| sweeps: current |x − \" + h.toFixed(1) + \"| = \" + dist.toFixed(2) + (hit ? \" = d ✓ solution!\" : \"\"), 24, Ht * 0.5 - 22, { color: hit ? H.colors.warn : H.colors.sub, size: 13 });\nH.text(nSols === 2 ? \"two solutions\" : nSols === 1 ? \"one solution (x = h)\" : \"no solution (d < 0)\", 24, Ht * 0.5 + 90, { color: H.colors.good, size: 13, weight: 700 });" + }, + { + "id": "a1-absolute-value-inequalities", + "area": "Algebra 1", + "topic": "Absolute value inequalities", + "title": "Absolute value inequality: |x − h| < d", + "equation": "|x - h| < d (or > d)", + "keywords": [ + "absolute value inequality", + "absolute value", + "abs inequality", + "|x|", + "less than", + "greater than", + "and or", + "between", + "outside", + "compound inequality", + "|x-h| d means the distance is large → the OUTSIDE rays: x < h − d OR x > h + d.", + "The crossing points x = h ± d are the boundaries you read off the graph." + ], + "params": [ + { + "name": "h", + "label": "center h", + "min": -5, + "max": 5, + "step": 0.5, + "value": 1 + }, + { + "name": "d", + "label": "threshold d", + "min": 0, + "max": 7, + "step": 0.5, + "value": 3 + }, + { + "name": "gt", + "label": "0 = less than, 1 = greater", + "min": 0, + "max": 1, + "step": 1, + "value": 0 + } + ], + "code": "H.background();\n// Absolute value inequality: |x - h| < d (or > d, toggle with `gt`).\n// Graph y = |x - h| and the line y = d; the solution is where the V is below\n// (or above) the line. \"less than\" -> the BETWEEN band; \"greater\" -> OUTSIDE.\nconst h = P.h, d = Math.abs(P.d), gt = P.gt >= 0.5;\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -1, yMax: 9 });\nv.grid(); v.axes();\nconst x1 = h - d, x2 = h + d;\n// draw solution band on the axis (y just above 0)\nif (!gt) {\n v.line(x1, 0.18, x2, 0.18, { color: H.colors.good, width: 8 });\n} else {\n v.line(-10, 0.18, x1, 0.18, { color: H.colors.good, width: 8 });\n v.line(x2, 0.18, 10, 0.18, { color: H.colors.good, width: 8 });\n}\n// the V and the threshold line\nv.line(-10, d, 10, d, { color: H.colors.violet, width: 2, dash: [6, 6] });\nv.fn(x => Math.abs(x - h), { color: H.colors.accent, width: 3 });\nv.dot(h, 0, { r: 6, fill: H.colors.accent2 });\n// boundary points where V crosses the line\nv.dot(x1, d, { r: 6, fill: H.colors.warn });\nv.dot(x2, d, { r: 6, fill: H.colors.warn });\nv.text(\"x = \" + x1.toFixed(1), x1, d + 0.7, { color: H.colors.warn, size: 11, align: \"center\" });\nv.text(\"x = \" + x2.toFixed(1), x2, d + 0.7, { color: H.colors.warn, size: 11, align: \"center\" });\n// animated probe walking the V; turns green inside the solution set\nconst xp = h + (d + 4) * Math.sin(t * 0.7);\nconst yp = Math.abs(xp - h);\nconst inSol = gt ? (yp > d) : (yp < d);\nv.dot(xp, yp, { r: 6 + (inSol ? Math.sin(t * 5) : 0), fill: inSol ? H.colors.good : H.colors.sub });\nv.line(xp, 0, xp, yp, { color: inSol ? H.colors.good : H.colors.sub, width: 1.5, dash: [3, 3] });\nH.text(\"Absolute value inequality: |x − h| \" + (gt ? \">\" : \"<\") + \" d\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst setText = gt ? (\"x < \" + x1.toFixed(1) + \" or x > \" + x2.toFixed(1) + \" (outside)\")\n : (x1.toFixed(1) + \" < x < \" + x2.toFixed(1) + \" (between)\");\nH.text(\"h = \" + h.toFixed(1) + \" d = \" + d.toFixed(1) + \" → \" + setText, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"probe x = \" + xp.toFixed(2) + \": |x − h| = \" + yp.toFixed(2) + (inSol ? \" ✓ in solution\" : \" ✗ not\"), 24, 76, { color: inSol ? H.colors.good : H.colors.sub, size: 13 });\nH.legend([{ label: \"y = |x − h|\", color: H.colors.accent }, { label: \"y = d\", color: H.colors.violet }, { label: \"solution set\", color: H.colors.good }], H.W - 190, 100);" + }, + { + "id": "a1-combine-like-terms", + "area": "Algebra 1", + "topic": "Combine like terms", + "title": "Combine like terms: ax + c + bx + d", + "equation": "a*x + c + b*x + d = (a+b)*x + (c+d)", + "keywords": [ + "combine like terms", + "like terms", + "simplify expression", + "coefficients", + "constants", + "ax + bx", + "add coefficients", + "collect terms", + "simplify", + "(a+b)x", + "x terms and constants", + "grouping like terms" + ], + "explanation": "Like terms share the same variable part, so x-terms only combine with x-terms and plain numbers only combine with other numbers. The colored tiles model this: tall accent bars are x-tiles and small orange squares are units, and the gather animation slides each kind into a single group. Add the coefficients a + b for the x-tiles and c + d for the units to read off the simplified (a+b)x + (c+d).", + "bullets": [ + "Like terms have identical variable parts; only those can be added.", + "Add only the coefficients — the variable x is unchanged.", + "x-tiles never merge with unit tiles; they stay separate groups." + ], + "params": [ + { + "name": "a", + "label": "x-terms a", + "min": 0, + "max": 5, + "step": 1, + "value": 2 + }, + { + "name": "b", + "label": "x-terms b", + "min": 0, + "max": 5, + "step": 1, + "value": 3 + }, + { + "name": "c", + "label": "units c", + "min": 0, + "max": 5, + "step": 1, + "value": 1 + }, + { + "name": "d", + "label": "units d", + "min": 0, + "max": 5, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst w = H.W, hh = H.H;\nconst a = Math.round(P.a), b = Math.round(P.b), c = Math.round(P.c), d = Math.round(P.d);\n// Combine like terms in a x + c + b x + d -> (a+b) x + (c+d)\nconst xc = a + b, kc = c + d;\nH.text(\"Combine like terms: ax + c + bx + d\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Only matching kinds add: x-tiles join x-tiles, units join units.\", 24, 54, { color: H.colors.sub, size: 13 });\n// original expression with x-terms in accent, constants in accent2\nH.text(a + \"x\", 60, 96, { color: H.colors.accent, size: 22, weight: 700 });\nH.text(\"+ \" + c, 110, 96, { color: H.colors.accent2, size: 22, weight: 700 });\nH.text(\"+ \" + b + \"x\", 168, 96, { color: H.colors.accent, size: 22, weight: 700 });\nH.text(\"+ \" + d, 234, 96, { color: H.colors.accent2, size: 22, weight: 700 });\n// tile model: each x-tile a tall bar, each unit a small square. animate a gather.\nconst gather = (Math.sin(t * 0.8) + 1) / 2; // 0..1 pulse: spread <-> grouped\nconst baseY = 250, tileW = 26, gap = 6;\n// x tiles (a then b) slide together\nlet xi = 0;\nconst total = a + b;\nfor (let i = 0; i < total; i++) {\n const spreadX = 70 + i * (tileW + gap) + (i >= a ? 60 : 0); // gap between the two groups when spread\n const groupX = 70 + i * (tileW + gap);\n const tx = H.lerp(spreadX, groupX, gather);\n H.rect(tx, baseY - 70, tileW, 70, { fill: H.colors.accent, stroke: H.colors.bg, width: 1, radius: 3 });\n xi = tx + tileW;\n}\nH.text(\"x-tiles\", 70, baseY + 18, { color: H.colors.accent, size: 13 });\nH.text(\"count = \" + xc, 70, baseY + 36, { color: H.colors.sub, size: 12 });\n// unit tiles on the right\nconst ux0 = w * 0.6;\nconst totalU = c + d;\nfor (let i = 0; i < totalU; i++) {\n const spreadX = ux0 + i * (tileW + gap) + (i >= c ? 50 : 0);\n const groupX = ux0 + i * (tileW + gap);\n const tx = H.lerp(spreadX, groupX, gather);\n H.rect(tx, baseY - 26, tileW, 26, { fill: H.colors.accent2, stroke: H.colors.bg, width: 1, radius: 3 });\n}\nH.text(\"unit tiles\", ux0, baseY + 18, { color: H.colors.accent2, size: 13 });\nH.text(\"count = \" + kc, ux0, baseY + 36, { color: H.colors.sub, size: 12 });\n// result\nH.rect(50, baseY + 60, w - 100, 56, { fill: \"#13243f\", stroke: H.colors.accent, width: 2, radius: 10 });\nH.text(\"= \" + xc + \"x + \" + kc, 70, baseY + 96, { color: H.colors.good, size: 24, weight: 700 });\nH.text(\"(\" + a + \"+\" + b + \")x = \" + xc + \"x , (\" + c + \"+\" + d + \") = \" + kc, 24, hh - 22, { color: H.colors.sub, size: 13 });" + }, + { + "id": "a1-compound-inequalities", + "area": "Algebra 1", + "topic": "Compound inequalities", + "title": "Compound: lo < x < hi (AND / OR)", + "equation": "lo < x < hi (AND) or x < lo OR x > hi", + "keywords": [ + "compound inequality", + "and or inequality", + "intersection union", + "between", + "number line", + "double inequality", + "lo < x < hi", + "conjunction disjunction", + "solution set", + "shade the region", + "inequalities", + "interval" + ], + "explanation": "A compound inequality joins two simple ones. With AND (the 'between' case) the solution is the OVERLAP of the two pieces — the green band from lo to hi. With OR the solution is the UNION — both outer rays, everything outside the band. Flip the mode slider to switch between AND and OR, slide the two bounds, and watch the test point turn green only when it lands in the current solution set.", + "bullets": [ + "AND keeps the OVERLAP of both pieces: the band lo < x < hi.", + "OR keeps the UNION of both pieces: the two rays outside lo and hi.", + "The same two bounds give opposite shaded regions for AND vs OR." + ], + "params": [ + { + "name": "lo", + "label": "lower bound lo", + "min": -8, + "max": 8, + "step": 0.5, + "value": -3 + }, + { + "name": "hi", + "label": "upper bound hi", + "min": -8, + "max": 8, + "step": 0.5, + "value": 4 + }, + { + "name": "mode", + "label": "0 = AND 1 = OR", + "min": 0, + "max": 1, + "step": 1, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -3, yMax: 3 });\nv.grid({ stepY: 100 }); v.axes({ stepY: 100 });\nlet lo = P.lo, hi = P.hi;\nif (lo > hi) { const tmp = lo; lo = hi; hi = tmp; }\nconst isAnd = P.mode < 0.5;\nif (isAnd) {\n v.line(lo, 0, hi, 0, { color: H.colors.good, width: 6 });\n} else {\n v.line(-10, 0, lo, 0, { color: H.colors.good, width: 6 });\n v.line(hi, 0, 10, 0, { color: H.colors.good, width: 6 });\n}\nv.circle(lo, 0, 8, { stroke: H.colors.warn, width: 3, fill: H.colors.bg });\nv.circle(hi, 0, 8, { stroke: H.colors.warn, width: 3, fill: H.colors.bg });\nv.text(\"lo\", lo - 0.2, -0.9, { color: H.colors.warn, size: 12 });\nv.text(\"hi\", hi - 0.2, -0.9, { color: H.colors.warn, size: 12 });\nconst xs = 9 * Math.sin(t * 0.6);\nconst inBand = xs > lo && xs < hi;\nconst ok = isAnd ? inBand : !inBand;\nv.dot(xs, 0, { r: 7, fill: ok ? H.colors.good : H.colors.sub });\nH.text(\"Compound: \" + (isAnd ? \"lo < x < hi (AND / between)\" : \"x < lo OR x > hi (outside)\"), 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"lo = \" + lo.toFixed(1) + \" hi = \" + hi.toFixed(1) + \" test x = \" + xs.toFixed(2) + \" -> \" + (ok ? \"in the solution\" : \"not a solution\"), 24, 52, { color: H.colors.sub, size: 12 });" + }, + { + "id": "a1-consecutive-integers", + "area": "Algebra 1", + "topic": "Consecutive integer word problems", + "title": "Consecutive integers: n, n+1, n+2, ...", + "equation": "sum = n + (n+d) + (n+2d) + ... = count*n + d*count*(count-1)/2", + "keywords": [ + "consecutive integers", + "consecutive integer word problem", + "consecutive even", + "consecutive odd", + "n n+1 n+2", + "sum of consecutive integers", + "find the integers", + "three consecutive numbers", + "number line", + "translate to equation", + "integer word problem", + "sequence of integers" + ], + "explanation": "Consecutive-integer problems hinge on naming the first number n and writing every other number relative to it. With a gap of 1 you get n, n+1, n+2; a gap of 2 gives consecutive even or odd numbers. The dots light up one at a time on the number line so you can watch the sum build, and the bottom line shows how that sum collapses into count*n + (a fixed offset) — the single equation you would solve for n. Change the first value, gap, or count and see how the target sum responds.", + "bullets": [ + "Name the first integer n; every later one is n + (gap)*(its position).", + "Their sum simplifies to count*n + a constant, giving one equation in n.", + "Gap 1 = consecutive integers; gap 2 = consecutive even OR odd integers." + ], + "params": [ + { + "name": "first", + "label": "first integer n", + "min": -8, + "max": 20, + "step": 1, + "value": 5 + }, + { + "name": "gap", + "label": "gap between terms", + "min": 1, + "max": 3, + "step": 1, + "value": 1 + }, + { + "name": "count", + "label": "how many terms", + "min": 2, + "max": 5, + "step": 1, + "value": 3 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst count = Math.max(2, Math.round(P.count));\nconst step = Math.max(1, Math.round(P.gap));\nconst first = Math.round(P.first);\nconst y0 = h * 0.56;\nconst xL = 70, xR = w - 60;\nH.line(xL, y0, xR, y0, { color: H.colors.axis, width: 2 });\nconst lo = first - 1, hi = first + step * (count - 1) + 1;\nconst sx = (n) => xL + (n - lo) / Math.max(1e-6, (hi - lo)) * (xR - xL);\nfor (let n = lo; n <= hi; n++) {\n H.line(sx(n), y0 - 5, sx(n), y0 + 5, { color: H.colors.grid, width: 1 });\n H.text(String(n), sx(n), y0 + 22, { color: H.colors.sub, size: 11, align: \"center\" });\n}\nlet sum = 0;\nconst active = Math.floor((t * 1.2) % (count + 1));\nfor (let i = 0; i < count; i++) {\n const n = first + step * i;\n sum += n;\n const on = i <= active;\n H.circle(sx(n), y0, on ? 11 : 8, { fill: on ? H.colors.accent : H.colors.panel, stroke: H.colors.accent2, width: 2 });\n H.text(i === 0 ? \"n\" : \"n+\" + (step * i), sx(n), y0 - 24, { color: on ? H.colors.ink : H.colors.sub, size: 13, align: \"center\" });\n if (i < count - 1) {\n const mx = (sx(n) + sx(n + step)) / 2;\n H.text(\"+\" + step, mx, y0 - 8, { color: H.colors.good, size: 11, align: \"center\" });\n }\n}\nH.text(\"Consecutive integers: n, n+\" + step + \", n+\" + (2 * step) + \", ... (\" + count + \" terms)\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"first n = \" + first + \" gap = \" + step + \" count = \" + count + \" -> sum = \" + sum, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Sum = \" + count + \"n + \" + (step * count * (count - 1) / 2) + \" = \" + count + \"(\" + first + \") + \" + (step * count * (count - 1) / 2) + \" = \" + sum, 24, h - 26, { color: H.colors.good, size: 13 });" + }, + { + "id": "a1-coordinate-plane-basics", + "area": "Algebra 1", + "topic": "Coordinate plane basics", + "title": "Plotting a point: (a, b)", + "equation": "point = (a, b) → x = a, y = b", + "keywords": [ + "coordinate plane", + "ordered pair", + "plot a point", + "x coordinate", + "y coordinate", + "quadrant", + "axes", + "origin", + "(x,y)", + "cartesian", + "graphing points", + "x and y" + ], + "explanation": "An ordered pair (a, b) is a set of directions from the origin: first go a to the RIGHT along the x-axis, then b UP along the y-axis. The animation walks that path one leg at a time so you see the x-move and the y-move separately before they meet at the point. The sign of each number decides direction — right/left for a, up/down for b — and together they place the point in one of the four quadrants, shown live in the readout.", + "bullets": [ + "The first number is x (left/right); the second is y (up/down). Order matters: (a,b) ≠ (b,a).", + "Start at the origin (0,0); move a along x, then b along y.", + "Signs pick the quadrant: (+,+) Q1, (−,+) Q2, (−,−) Q3, (+,−) Q4." + ], + "params": [ + { + "name": "a", + "label": "x-coordinate a", + "min": -7, + "max": 7, + "step": 1, + "value": 4 + }, + { + "name": "b", + "label": "y-coordinate b", + "min": -7, + "max": 7, + "step": 1, + "value": 3 + } + ], + "code": "H.background();\n// Coordinate plane basics: an ordered pair (a, b). Walk RIGHT a along x, then\n// UP b along y to reach the point. Show which quadrant it lands in.\nconst a = P.a, b = P.b;\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\n// quadrant labels\nv.text(\"Q1\", 6.5, 6.5, { color: H.colors.sub, size: 12, align: \"center\" });\nv.text(\"Q2\", -6.5, 6.5, { color: H.colors.sub, size: 12, align: \"center\" });\nv.text(\"Q3\", -6.5, -6.5, { color: H.colors.sub, size: 12, align: \"center\" });\nv.text(\"Q4\", 6.5, -6.5, { color: H.colors.sub, size: 12, align: \"center\" });\n// animate the \"walk\": phase 0->1 moves along x, 1->2 moves up along y, then loops\nconst ph = (t % 4) / 2; // 0..2 over 4s\nconst fx = H.clamp(ph, 0, 1);\nconst fy = H.clamp(ph - 1, 0, 1);\nconst wx = a * fx;\nconst wy = b * fy;\n// x-run leg (origin to (wx,0))\nv.arrow(0, 0, wx, 0, { color: H.colors.accent, width: 3 });\n// y-run leg (from (a,0) up to (a, wy))\nif (fx >= 0.999) v.arrow(a, 0, a, wy, { color: H.colors.good, width: 3 });\n// dashed guide to the final point\nv.line(a, 0, a, b, { color: H.colors.grid, width: 1.5, dash: [4, 4] });\nv.line(0, b, a, b, { color: H.colors.grid, width: 1.5, dash: [4, 4] });\n// moving traveler dot\nv.dot(fx < 1 ? wx : a, fx < 1 ? 0 : wy, { r: 6, fill: H.colors.warn });\n// final point marker (pulsing)\nv.dot(a, b, { r: 6 + Math.sin(t * 3), fill: H.colors.accent2 });\nv.text(\"(\" + a.toFixed(1) + \", \" + b.toFixed(1) + \")\", a, b + 0.8, { color: H.colors.accent2, size: 13, align: \"center\" });\nconst quad = (a > 0 && b > 0) ? \"Quadrant I\" : (a < 0 && b > 0) ? \"Quadrant II\"\n : (a < 0 && b < 0) ? \"Quadrant III\" : (a > 0 && b < 0) ? \"Quadrant IV\"\n : (a === 0 && b === 0) ? \"the origin\" : \"on an axis\";\nH.text(\"Coordinate plane: the point (a, b)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a = \" + a.toFixed(1) + \" (right/left) b = \" + b.toFixed(1) + \" (up/down) → \" + quad, 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"x-run a\", color: H.colors.accent }, { label: \"y-rise b\", color: H.colors.good }], H.W - 160, 100);" + }, + { + "id": "a1-distributive-property", + "area": "Algebra 1", + "topic": "Use the distributive property", + "title": "Distributive property: a(b + c) = ab + ac", + "equation": "a*(b + c) = a*b + a*c", + "keywords": [ + "distributive property", + "distribute", + "distributing", + "a(b+c)", + "ab+ac", + "expand", + "factor out", + "multiply through", + "area model", + "distributive law", + "times a sum" + ], + "explanation": "A rectangle of width a and height (b + c) has area a(b + c). Split the height into a b-piece and a c-piece and you get two smaller rectangles of area a*b and a*c that together fill the SAME rectangle — that is why a(b + c) = ab + ac. Slide a to scale the width and slide b and c to set how the height splits; the sweeping highlight steps between the two pieces so you see each product the distribution creates.", + "bullets": [ + "a(b + c) means a copies of the whole sum b + c.", + "Distributing multiplies a by EACH term: a*b and a*c, then add.", + "The area model shows the two products tile the same rectangle as the whole." + ], + "params": [ + { + "name": "a", + "label": "factor a", + "min": 1, + "max": 6, + "step": 0.5, + "value": 3 + }, + { + "name": "b", + "label": "first term b", + "min": 1, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "c", + "label": "second term c", + "min": 1, + "max": 5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: 0, yMax: 8 });\nv.grid(); v.axes();\nconst a = P.a, b = P.b, c = P.c;\n// Area model: a rectangle of width a and height (b + c), split into two pieces.\nconst widthA = Math.max(0.2, Math.abs(a));\nconst hB = Math.max(0, b), hC = Math.max(0, c);\n// pulse a sweeping highlight across the two sub-rectangles\nconst phase = (t * 0.5) % 2;\nconst lit1 = phase < 1, lit2 = !lit1;\nv.rect(0.5, 0.5, widthA, hB, { fill: lit1 ? H.colors.accent : H.colors.panel, stroke: H.colors.ink, width: 2 });\nv.rect(0.5, 0.5 + hB, widthA, hC, { fill: lit2 ? H.colors.accent2 : H.colors.panel, stroke: H.colors.ink, width: 2 });\nv.text(\"a·b = \" + (a * b).toFixed(1), 0.5 + widthA / 2, 0.5 + hB / 2, { color: H.colors.ink, size: 14, align: \"center\", baseline: \"middle\" });\nv.text(\"a·c = \" + (a * c).toFixed(1), 0.5 + widthA / 2, 0.5 + hB + hC / 2, { color: H.colors.ink, size: 14, align: \"center\", baseline: \"middle\" });\nv.line(0.5, 0.5, 0.5, 0.5 + hB + hC, { color: H.colors.good, width: 2 });\nv.text(\"height b+c = \" + (b + c).toFixed(1), 0.5 + widthA + 0.2, 0.5 + (hB + hC) / 2, { color: H.colors.good, size: 12, align: \"left\", baseline: \"middle\" });\nH.text(\"Distributive property: a(b + c) = ab + ac\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a=\" + a.toFixed(1) + \" b=\" + b.toFixed(1) + \" c=\" + c.toFixed(1) + \" → \" + a.toFixed(1) + \"·\" + (b + c).toFixed(1) + \" = \" + (a * b).toFixed(1) + \" + \" + (a * c).toFixed(1) + \" = \" + (a * (b + c)).toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"a·b\", color: H.colors.accent }, { label: \"a·c\", color: H.colors.accent2 }], H.W - 150, 28);" + }, + { + "id": "a1-domain-and-range", + "area": "Algebra 1", + "topic": "Domain and range from graphs/tables", + "title": "Domain & range: x-values in, y-values out", + "equation": "domain = all valid x ; range = all resulting y", + "keywords": [ + "domain", + "range", + "domain and range", + "from a graph", + "x values", + "y values", + "interval", + "input values", + "output values", + "domain from graph", + "range from graph", + "interval notation" + ], + "explanation": "The domain is the set of x-values the graph actually uses (read left-to-right along the x-axis); the range is the set of y-values it actually reaches (read bottom-to-top along the y-axis). The green bar on the x-axis shows the domain you set with the endpoint sliders, and the violet bar on the y-axis shows the range the curve sweeps out. Watch the tracer dot crawl across the domain while its shadow on each axis fills in exactly those input and output sets.", + "bullets": [ + "Domain = the x-values the graph covers; read it along the x-axis (green).", + "Range = the y-values the graph reaches; read it along the y-axis (violet).", + "The dashed walls mark where the graph starts and stops in x." + ], + "params": [ + { + "name": "xlo", + "label": "domain start x", + "min": -7, + "max": 0, + "step": 0.5, + "value": -6 + }, + { + "name": "xhi", + "label": "domain end x", + "min": 0, + "max": 7, + "step": 0.5, + "value": 6 + }, + { + "name": "amp", + "label": "amplitude (sets range)", + "min": 1, + "max": 6, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst lo = Math.min(P.xlo, P.xhi), hi = Math.max(P.xlo, P.xhi);\nconst amp = P.amp;\nconst f = x => amp * Math.sin(x);\nconst pts = [];\nconst N = 80;\nlet yMinR = Infinity, yMaxR = -Infinity;\nfor (let i = 0; i <= N; i++) { const x = lo + (hi - lo) * i / N; const y = f(x); pts.push([x, y]); if (y < yMinR) yMinR = y; if (y > yMaxR) yMaxR = y; }\nv.path(pts, { color: H.colors.accent, width: 3 });\nv.line(lo, -8, lo, 8, { color: H.colors.good, width: 1.5, dash: [4, 4] });\nv.line(hi, -8, hi, 8, { color: H.colors.good, width: 1.5, dash: [4, 4] });\nv.line(lo, 0, hi, 0, { color: H.colors.good, width: 4 });\nv.line(0, yMinR, 0, yMaxR, { color: H.colors.violet, width: 4 });\nv.dot(lo, f(lo), { r: 5, fill: H.colors.accent });\nv.dot(hi, f(hi), { r: 5, fill: H.colors.accent });\nconst frac = (Math.sin(t * 0.6) + 1) / 2;\nconst xs = lo + (hi - lo) * frac;\nv.dot(xs, f(xs), { r: 7, fill: H.colors.warn });\nv.dot(xs, 0, { r: 4, fill: H.colors.good });\nv.dot(0, f(xs), { r: 4, fill: H.colors.violet });\nH.text(\"Domain & range from a graph\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"domain x in [\" + lo.toFixed(1) + \", \" + hi.toFixed(1) + \"] range y in [\" + yMinR.toFixed(1) + \", \" + yMaxR.toFixed(1) + \"]\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"domain (x-values)\", color: H.colors.good }, { label: \"range (y-values)\", color: H.colors.violet }], H.W - 200, 28);\n" + }, + { + "id": "a1-equations-fractions-decimals", + "area": "Algebra 1", + "topic": "Equations with fractions/decimals", + "title": "Clear fractions: x/p + 1/q = r", + "equation": "x/p + 1/q = r -> (L/p)*x + L/q = L*r, L = lcm(p, q)", + "keywords": [ + "equations with fractions", + "fractions and decimals", + "clear the fractions", + "lcd", + "least common denominator", + "multiply both sides", + "x/p + 1/q = r", + "denominators", + "lcm", + "solve fractional equation", + "eliminate fractions" + ], + "explanation": "Fractions are easier to solve once they are gone. Multiply BOTH sides by the least common denominator L and every fraction turns into a whole number — and because you multiply both sides equally, the scale stays balanced. The two bars are the left and right sides: equal length means a true equation. The sliding ×L badge reminds you the same multiplier hits both sides. Step through to clear the fractions, then read off x.", + "bullets": [ + "The LCD L = lcm(p, q) is the smallest number every denominator divides.", + "Multiply BOTH sides by L to clear all fractions at once.", + "Balanced before = balanced after; only the numbers got simpler." + ], + "params": [ + { + "name": "p", + "label": "denominator p", + "min": 2, + "max": 8, + "step": 1, + "value": 4 + }, + { + "name": "q", + "label": "denominator q", + "min": 2, + "max": 8, + "step": 1, + "value": 6 + }, + { + "name": "r", + "label": "right side r", + "min": 0.5, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "step", + "label": "solving step", + "min": 0, + "max": 2, + "step": 1, + "value": 0 + } + ], + "code": "H.background();\nconst p = (Math.abs(Math.round(P.p)) < 1) ? 1 : Math.round(P.p), q = (Math.abs(Math.round(P.q)) < 1) ? 1 : Math.round(P.q);\nconst r = P.r;\nconst step = Math.max(0, Math.min(2, Math.round(P.step)));\nfunction gcd(m, n){ m = Math.abs(m); n = Math.abs(n); while(n){ const tmp = n; n = m % n; m = tmp; } return m || 1; }\nconst L = Math.abs(p * q) / gcd(p, q);\nconst x = (r - 1 / q) * p; // solve x/p + 1/q = r\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -1, yMax: 5 });\nv.grid({ stepY: 1 }); v.axes({ ticks: false });\nconst breathe = 0.85 + 0.15 * Math.sin(t * 1.6);\n// LHS and RHS drawn as equal-length bars: a balanced scale. Multiplying by L\n// scales BOTH equally, so they stay balanced — but now the numbers are whole.\nconst lhs = step === 0 ? (x / p + 1 / q) : (L * x / p + L / q);\nconst rhs = step === 0 ? r : (L * r);\nconst yL = 3, yR = 1.3;\nv.rect(0, yL, Math.max(0.02, lhs) * breathe, 0.7, { fill: H.colors.accent + \"55\", stroke: H.colors.accent, width: 2 });\nv.rect(0, yR, Math.max(0.02, rhs) * breathe, 0.7, { fill: H.colors.good + \"55\", stroke: H.colors.good, width: 2 });\nv.text(\"LHS = \" + lhs.toFixed(2), 0.2, yL + 0.35, { color: H.colors.ink, size: 13 });\nv.text(\"RHS = \" + rhs.toFixed(2), 0.2, yR + 0.35, { color: H.colors.ink, size: 13 });\nH.text(\"Clear fractions: x/p + 1/q = r\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nconst msg = step === 0 ? (\"LCD L = \" + L + \". Both bars equal → balanced.\") : step === 1 ? (\"×L on BOTH sides keeps balance: (L/p)·x + L/q = L·r\") : (\"now whole numbers → x = \" + x.toFixed(2));\nH.text(\"p=\" + p + \" q=\" + q + \" r=\" + r.toFixed(1) + \" L=\" + L + \" step \" + step + \"/2 \" + msg, 24, 52, { color: H.colors.sub, size: 12 });\nif (step >= 2) { v.dot(0, -0.4, { r: 6 + Math.sin(t * 3), fill: H.colors.good }); v.text(\"x = \" + x.toFixed(2), 0.3, -0.4, { color: H.colors.good, size: 14 }); }\n// a \"×L\" badge slides between the two bars to show the same multiplier hits both\nconst sy = H.map(0.5 + 0.5 * Math.sin(t * 1.5), 0, 1, v.Y(yR + 0.35), v.Y(yL + 0.35));\nconst bx = v.X(Math.max(lhs, rhs) * breathe) + 28;\nH.circle(bx, sy, 14, { fill: H.colors.violet });\nH.text(\"×L\", bx - 10, sy + 4, { color: H.colors.bg, size: 12, weight: 700 });" + }, + { + "id": "a1-equations-with-parentheses", + "area": "Algebra 1", + "topic": "Equations with parentheses", + "title": "Parentheses: a(x + b) = c", + "equation": "a*(x + b) = c -> a*x + a*b = c -> x = c/a - b", + "keywords": [ + "equations with parentheses", + "distributive property", + "distribute", + "a(x+b)=c", + "remove parentheses", + "expand brackets", + "area model", + "solve for x", + "multiply through", + "grouping" + ], + "explanation": "A coefficient outside a parenthesis multiplies EVERYTHING inside it. The area model makes this visible: a rectangle of height a and width (x + b) has total area a(x + b), and splitting the width into x and b splits the area into a·x plus a·b — that IS the distributive property. Step through to distribute, then undo the multiplication and addition to isolate x. The blue and orange blocks are the two products you get.", + "bullets": [ + "a(x + b) means a*x + a*b — multiply a by every term inside.", + "The rectangle's area splits the same way the algebra does.", + "After distributing, solve it like any two-step equation." + ], + "params": [ + { + "name": "a", + "label": "outside factor a", + "min": 1, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "b", + "label": "inside add b", + "min": 0, + "max": 4, + "step": 0.5, + "value": 3 + }, + { + "name": "c", + "label": "result c", + "min": 1, + "max": 20, + "step": 1, + "value": 14 + }, + { + "name": "step", + "label": "solving step", + "min": 0, + "max": 2, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst a = (Math.abs(P.a) < 1e-9) ? 1 : P.a, b = P.b, c = P.c;\nconst step = Math.max(0, Math.min(2, Math.round(P.step)));\nconst x = c / a - b; // from a(x+b)=c -> x = c/a - b\nconst v = H.plot2d({ xMin: -1, xMax: 10, yMin: -1, yMax: 6 });\nv.grid({ stepX: 1, stepY: 1 }); v.axes({ ticks: false });\n// AREA MODEL: a rectangle of height |a|, split into width x and width b.\n// Area = a*(x+b) = a*x + a*b = the distributed form.\nconst xw = Math.max(0.4, 3 + 1.2 * Math.sin(t * 0.8)); // animated x-width (visual breathing)\nconst ah = Math.max(0.4, Math.abs(a));\nconst bw = Math.max(0, b);\n// left block: a * x\nv.rect(0, 0, xw, ah, { fill: H.colors.accent + \"55\", stroke: H.colors.accent, width: 2 });\nv.text(\"a·x\", xw / 2 - 0.3, ah / 2, { color: H.colors.ink, size: 14 });\n// right block: a * b (revealed when distributing, step>=1)\nif (step >= 1) {\n v.rect(xw, 0, bw, ah, { fill: H.colors.accent2 + \"55\", stroke: H.colors.accent2, width: 2 });\n v.text(\"a·b\", xw + bw / 2 - 0.3, ah / 2, { color: H.colors.ink, size: 13 });\n}\n// width / height brackets\nv.line(0, ah + 0.25, xw, ah + 0.25, { color: H.colors.accent, width: 2 });\nv.text(\"x\", xw / 2 - 0.1, ah + 0.7, { color: H.colors.accent, size: 13 });\nif (step >= 1) { v.line(xw, ah + 0.25, xw + bw, ah + 0.25, { color: H.colors.accent2, width: 2 }); v.text(\"b\", xw + bw / 2 - 0.1, ah + 0.7, { color: H.colors.accent2, size: 13 }); }\nv.line(-0.25, 0, -0.25, ah, { color: H.colors.violet, width: 2 });\nv.text(\"a\", -0.7, ah / 2, { color: H.colors.violet, size: 13 });\nH.text(\"Equations with parentheses: a(x + b) = c\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nconst msg = step === 0 ? \"the box has total area a(x+b)\" : step === 1 ? \"distribute: a·x + a·b = c\" : (\"undo: x = c/a − b = \" + x.toFixed(2));\nH.text(\"a=\" + a.toFixed(1) + \" b=\" + b.toFixed(1) + \" c=\" + c.toFixed(1) + \" step \" + step + \"/2 \" + msg, 24, 52, { color: H.colors.sub, size: 13 });\nif (step >= 2) { v.dot(x, -0.5, { r: 6 + Math.sin(t * 3), fill: H.colors.good }); v.text(\"x = \" + x.toFixed(2), x + 0.2, -0.5, { color: H.colors.good, size: 13 }); }\nH.legend([{ label: \"a·x\", color: H.colors.accent }, { label: \"a·b\", color: H.colors.accent2 }], H.W - 150, 28);" + }, + { + "id": "a1-evaluate-expressions", + "area": "Algebra 1", + "topic": "Evaluate algebraic expressions", + "title": "Evaluate: a·x + b at x", + "equation": "value = a * x + b", + "keywords": [ + "evaluate", + "evaluate expressions", + "substitute", + "substitution", + "plug in", + "a*x+b", + "find the value", + "evaluate algebraic expression", + "value of expression", + "replace variable", + "ax+b" + ], + "explanation": "To evaluate an algebraic expression you substitute a number for the variable and then simplify using order of operations. Slide x to choose the input and a, b to reshape the expression a·x + b; the worked steps show the substitution and the green dot lands the result on the number line. The orange dot sweeps to remind you that the same expression gives a different value at every x.", + "bullets": [ + "Substitute the value in for the variable, then simplify.", + "Do the multiplication a·x before adding b (order of operations).", + "The same expression yields a different value for each x you plug in." + ], + "params": [ + { + "name": "a", + "label": "coefficient a", + "min": -4, + "max": 4, + "step": 1, + "value": 2 + }, + { + "name": "x", + "label": "value of x", + "min": -6, + "max": 6, + "step": 1, + "value": 3 + }, + { + "name": "b", + "label": "constant b", + "min": -8, + "max": 8, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst w = H.W, hh = H.H;\nconst xv = P.x, a = P.a, b = P.b;\n// Evaluate a*x + b by substitution, shown as a tape/bar model + number line.\nH.text(\"Evaluate a·x + b at x = \" + xv.toFixed(0), 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Substitute the value of x, then simplify with order of operations.\", 24, 54, { color: H.colors.sub, size: 13 });\nconst val = a * xv + b;\n// substitution line with a pulsing highlight on x\nconst sy = 96;\nH.text(\"a·x + b\", 60, sy, { color: H.colors.ink, size: 20, weight: 700 });\nH.text(\"= \" + a.toFixed(0) + \"·(\" + xv.toFixed(0) + \") + \" + b.toFixed(0), 60, sy + 34, { color: H.colors.accent, size: 18 });\nH.text(\"= \" + (a * xv).toFixed(0) + \" + \" + b.toFixed(0) + \" = \" + val.toFixed(0), 60, sy + 68, { color: H.colors.good, size: 20, weight: 700 });\nH.circle(150 + 6 * Math.sin(t * 3), sy - 6, 5, { fill: H.colors.warn });\n// number line showing the result land\nconst v = H.plot2d({ xMin: -20, xMax: 20, yMin: -1, yMax: 1, pad: 50 });\nv.line(-20, 0, 20, 0, { color: H.colors.axis, width: 2 });\nfor (let k = -20; k <= 20; k += 5) { v.line(k, -0.12, k, 0.12, { color: H.colors.grid, width: 1.5 }); v.text(String(k), k, -0.4, { color: H.colors.sub, size: 11, align: \"center\" }); }\nconst clamped = Math.max(-20, Math.min(20, val));\nv.dot(clamped, 0, { r: 8, fill: H.colors.good });\nv.text(\"a·x+b = \" + val.toFixed(0), clamped, 0.55, { color: H.colors.good, size: 13, align: \"center\" });\n// animated dot sweeping x through the line to show the expression as a function\nconst sweep = 18 * Math.sin(t * 0.6);\nv.dot(sweep, 0, { r: 5, fill: H.colors.accent2 });\nH.text(\"a = \" + a.toFixed(1) + \" x = \" + xv.toFixed(1) + \" b = \" + b.toFixed(1) + \" → value = \" + val.toFixed(1), 24, hh - 22, { color: H.colors.sub, size: 14 });" + }, + { + "id": "a1-exponent-rules", + "area": "Algebra 1", + "topic": "Exponent rules", + "title": "Product rule: a^m · a^n = a^(m+n)", + "equation": "a^m * a^n = a^(m+n)", + "keywords": [ + "exponent rules", + "exponents", + "product rule", + "powers", + "a^m * a^n", + "add exponents", + "laws of exponents", + "multiplying powers", + "exponent law", + "same base", + "power rule", + "index laws" + ], + "explanation": "An exponent just counts repeated factors: a^m is m copies of a multiplied together. When you multiply a^m by a^n you simply pool both piles of factors, so the total count is m + n — that is why the exponents ADD instead of multiplying. Slide m and n and watch the boxes (each box is one factor of a); the moving highlight counts them one at a time so you see m + n directly.", + "bullets": [ + "a^m means a multiplied by itself m times (m factors).", + "Same base: multiply -> ADD the exponents (pool the factors).", + "a^0 = 1 because zero factors of a leaves the multiplicative identity." + ], + "params": [ + { + "name": "base", + "label": "base a", + "min": 2, + "max": 5, + "step": 1, + "value": 2 + }, + { + "name": "m", + "label": "exponent m", + "min": 0, + "max": 5, + "step": 1, + "value": 3 + }, + { + "name": "n", + "label": "exponent n", + "min": 0, + "max": 5, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst a = Math.max(2, Math.round(P.base));\nconst m = Math.max(0, Math.round(P.m));\nconst n = Math.max(0, Math.round(P.n));\nconst total = m + n;\nconst cell = 26, gap = 6;\nconst baseX = 60, baseY = h * 0.5;\nH.text(\"Product rule: a^m · a^n = a^(m+n)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a = \" + a + \", \" + a + \"^\" + m + \" · \" + a + \"^\" + n + \" = \" + a + \"^\" + total + \" = \" + Math.pow(a, total), 24, 52, { color: H.colors.sub, size: 13 });\n// sweep highlights one factor box at a time, looping over all total boxes\nconst sweep = total > 0 ? Math.floor((t * 1.5) % total) : -1;\nfunction drawGroup(x0, count, col, lbl) {\n for (let i = 0; i < count; i++) {\n const gx = x0 + i * (cell + gap);\n const isLit = (lbl === \"left\" && i === sweep) || (lbl === \"right\" && (i + m) === sweep);\n H.rect(gx, baseY, cell, cell, { fill: isLit ? H.colors.warn : col, stroke: H.colors.ink, width: 1.5, radius: 4 });\n H.text(\"a\", gx + cell * 0.5, baseY + cell * 0.5 + 5, { color: H.colors.bg, size: 14, weight: 700, align: \"center\" });\n }\n return x0 + count * (cell + gap);\n}\nconst midX = drawGroup(baseX, m, H.colors.accent, \"left\");\nH.text(\"×\", midX + 2, baseY + cell * 0.5 + 6, { color: H.colors.ink, size: 20, weight: 700 });\ndrawGroup(midX + 22, n, H.colors.accent2, \"right\");\nH.text(a + \"^\" + m + \" = \" + m + \" factors of a\", baseX, baseY - 18, { color: H.colors.accent, size: 13 });\nH.text(a + \"^\" + n + \" = \" + n + \" factors\", midX + 22, baseY - 18, { color: H.colors.accent2, size: 13 });\nH.text(\"Counting factors: \" + m + \" + \" + n + \" = \" + total + \" → exponents ADD\", baseX, baseY + 70, { color: H.colors.good, size: 14, weight: 600 });\nH.legend([{ label: \"a^m factors\", color: H.colors.accent }, { label: \"a^n factors\", color: H.colors.accent2 }], w - 170, 30);" + }, + { + "id": "a1-factoring-gcf-trinomials-special", + "area": "Algebra 1", + "topic": "Factoring GCF, trinomials, and special products", + "title": "Factoring trinomials: x^2 + bx + c = (x+p)(x+q)", + "equation": "x^2 + b*x + c = (x + p)(x + q), where p + q = b and p*q = c", + "keywords": [ + "factoring", + "factor trinomials", + "gcf", + "greatest common factor", + "special products", + "difference of squares", + "product sum method", + "(x+p)(x+q)", + "quadratic factoring", + "sum and product", + "factor pairs", + "reverse foil" + ], + "explanation": "Factoring x^2 + bx + c reverses the box method: you hunt for two numbers p and q that MULTIPLY to c and ADD to b. The animated marker scans candidate pairs along the number line and turns green when a pair finally hits the target product — that search IS the method. The rectangle on the right shows the same idea as area: (x+p)(x+q) builds the trinomial back up. Drag p and q to choose the roots and watch b = p+q and c = p·q update.", + "bullets": [ + "First always pull out the GCF; here factor x^2 + bx + c with leading 1.", + "Find p, q with p·q = c (product) and p + q = b (sum).", + "Special case: c<0 with b=0 is a difference of squares (x+p)(x−p)." + ], + "params": [ + { + "name": "p", + "label": "first number p", + "min": -6, + "max": 6, + "step": 1, + "value": 2 + }, + { + "name": "q", + "label": "second number q", + "min": -6, + "max": 6, + "step": 1, + "value": 3 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst p = Math.round(P.p), q = Math.round(P.q);\n// trinomial x^2 + b x + c with roots -p, -q -> factors (x+p)(x+q)\nconst b = p + q, c = p * q;\nH.text(\"Factoring: x² + bx + c = (x + p)(x + q)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst bs = (b >= 0 ? \"+ \" + b : \"- \" + Math.abs(b));\nconst cs = (c >= 0 ? \"+ \" + c : \"- \" + Math.abs(c));\nH.text(\"x² \" + bs + \"x \" + cs + \" = (x \" + (p >= 0 ? \"+ \" + p : \"- \" + Math.abs(p)) + \")(x \" + (q >= 0 ? \"+ \" + q : \"- \" + Math.abs(q)) + \")\", 24, 52, { color: H.colors.sub, size: 14 });\n// Goal box: find two numbers that MULTIPLY to c and ADD to b\nH.text(\"Need two numbers that × = \" + c + \" and + = \" + b, 60, 96, { color: H.colors.good, size: 15, weight: 600 });\n// animated search: step a candidate i from -range..range, show i and (b-i)\nconst range = 9;\nconst span = 2 * range + 1;\nconst i = Math.round((t * 1.5) % span) - range;\nconst j = b - i;\nconst prod = i * j;\nconst hit = (prod === c);\nconst lineY = h * 0.42;\nconst x0 = 70, x1 = w - 70;\nH.line(x0, lineY, x1, lineY, { color: H.colors.axis, width: 2 });\nfor (let k = -range; k <= range; k++) {\n const px = H.map(k, -range, range, x0, x1);\n H.line(px, lineY - 6, px, lineY + 6, { color: H.colors.grid, width: 1 });\n if (k % 3 === 0) H.text(k + \"\", px, lineY + 22, { color: H.colors.sub, size: 10, align: \"center\" });\n}\nconst ix = H.map(H.clamp(i, -range, range), -range, range, x0, x1);\nH.circle(ix, lineY, 8 + Math.sin(t * 5) * 2, { fill: hit ? H.colors.good : H.colors.warn, stroke: H.colors.bg, width: 2 });\nH.text(\"try p = \" + i + \", q = \" + j + \" → p·q = \" + prod + (hit ? \" ✓ matches c!\" : \" (need \" + c + \")\"), 60, h * 0.42 + 50, { color: hit ? H.colors.good : H.colors.warn, size: 14, weight: 600 });\n// area rectangle model for the factored form (x+p)(x+q)\nconst rx = 70, ry = h * 0.62, ux = 120, uc = 26;\nconst pw = ux + Math.max(0, p) * uc, qh = ux * 0.55 + Math.max(0, q) * uc * 0.55;\nH.rect(rx, ry, pw, qh, { stroke: H.colors.accent, width: 2, fill: \"rgba(124,196,255,0.10)\", radius: 4 });\nH.line(rx + ux, ry, rx + ux, ry + qh, { color: H.colors.accent2, width: 1.5, dash: [4, 4] });\nH.line(rx, ry + ux * 0.55, rx + pw, ry + ux * 0.55, { color: H.colors.accent2, width: 1.5, dash: [4, 4] });\nH.text(\"x\", rx + ux * 0.5, ry - 8, { color: H.colors.accent, size: 13, align: \"center\" });\nH.text(\"+\" + p, rx + ux + (pw - ux) * 0.5, ry - 8, { color: H.colors.accent, size: 13, align: \"center\" });\nH.text(\"(x+p)(x+q) = area\", rx, ry + qh + 22, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"candidate\", color: H.colors.warn }, { label: \"match\", color: H.colors.good }], w - 150, 96);" + }, + { + "id": "a1-fraction-decimal-coefficients", + "area": "Algebra 1", + "topic": "Fraction/decimal coefficients", + "title": "Fractional slope: y = (p/q)·x + b", + "equation": "y = (p/q)*x + b", + "keywords": [ + "fraction coefficient", + "decimal coefficient", + "fractional slope", + "rise over run", + "p/q", + "slope as a fraction", + "rational coefficient", + "y=(p/q)x", + "decimal slope", + "fraction of x", + "coefficient" + ], + "explanation": "A fractional coefficient p/q is a rise-over-run recipe: every time x moves q to the right, y moves p up. The green run and violet rise build a staircase up the line, so a slope like 2/3 visibly means 'go 3 across, 2 up' — much smaller than 2. Slide p and q to reshape the fraction (try a decimal-looking 1/2 vs a steep 5/2) and watch the staircase and the slope readout change together; b just lifts the whole line.", + "bullets": [ + "A coefficient p/q is the slope: rise p for every run q.", + "Bigger numerator p (or smaller q) makes the line steeper.", + "The staircase shows q across then p up, repeated up the line." + ], + "params": [ + { + "name": "p", + "label": "rise (numerator) p", + "min": 1, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "q", + "label": "run (denominator) q", + "min": 1, + "max": 4, + "step": 0.5, + "value": 3 + }, + { + "name": "b", + "label": "intercept b", + "min": -1, + "max": 4, + "step": 0.5, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 9, yMin: -1, yMax: 7 });\nv.grid(); v.axes();\nconst p = P.p, q = Math.abs(P.q) < 1e-6 ? 1 : P.q;\nconst m = p / q; // fractional/decimal coefficient\nconst b = P.b;\nv.fn(x => m * x + b, { color: H.colors.accent, width: 3 });\n// staircase: from x=0 step \"q to the right, p up\", repeated\nconst startX = 0, startY = b;\nv.dot(startX, startY, { r: 6, fill: H.colors.good });\nconst steps = 3;\nfor (let s = 0; s < steps; s++) {\n const x0 = startX + s * q, y0 = startY + s * p;\n // run (horizontal) then rise (vertical)\n v.line(x0, y0, x0 + q, y0, { color: H.colors.good, width: 2, dash: [5, 4] });\n v.line(x0 + q, y0, x0 + q, y0 + p, { color: H.colors.violet, width: 2, dash: [5, 4] });\n}\n// animated dot riding the line, bounded loop\nconst xs = 4 + 4 * Math.sin(t * 0.7);\nv.dot(xs, m * xs + b, { r: 6, fill: H.colors.accent2 });\nv.text(\"run q = \" + q.toFixed(1), startX + q / 2, startY - 0.45, { color: H.colors.good, size: 12, align: \"center\", baseline: \"middle\" });\nv.text(\"rise p = \" + p.toFixed(1), startX + q + 0.25, startY + p / 2, { color: H.colors.violet, size: 12, align: \"left\", baseline: \"middle\" });\nH.text(\"Fraction/decimal slope: y = (p/q)·x + b\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"p = \" + p.toFixed(1) + \" q = \" + q.toFixed(1) + \" → slope = \" + m.toFixed(3) + \" b = \" + b.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"run q\", color: H.colors.good }, { label: \"rise p\", color: H.colors.violet }], H.W - 150, 28);" + }, + { + "id": "a1-function-notation", + "area": "Algebra 1", + "topic": "Function notation", + "title": "Function notation: f(x) = a*x^2 + b*x + c", + "equation": "f(x) = a*x^2 + b*x + c", + "keywords": [ + "function notation", + "f of x", + "f(x)", + "evaluate a function", + "plug in", + "substitute", + "input output", + "function value", + "evaluating functions", + "name of function", + "find f(3)" + ], + "explanation": "Function notation f(x) is a machine: you feed in an input x and it returns one output f(x). The green dashed line drops from the input on the x-axis up to the curve; the violet dashed line carries that height back to the y-axis as the output. The a, b, c sliders reshape the rule itself, and the readout writes out the full substitution f(x) = a(x)^2 + b(x) + c so you see exactly what 'plug in x' means.", + "bullets": [ + "f(x) names the OUTPUT you get after substituting the input x into the rule.", + "Each input x gives exactly one output f(x) (that's what makes it a function).", + "Changing a, b, or c changes the rule, so the same input gives a different output." + ], + "params": [ + { + "name": "a", + "label": "a", + "min": -1, + "max": 1, + "step": 0.1, + "value": 0.5 + }, + { + "name": "b", + "label": "b", + "min": -3, + "max": 3, + "step": 0.5, + "value": -1 + }, + { + "name": "c", + "label": "c", + "min": -2, + "max": 8, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -4, yMax: 12 });\nv.grid(); v.axes();\nconst a = P.a, b = P.b, c = P.c;\nconst f = x => a * x * x + b * x + c;\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst xin = 4 * Math.sin(t * 0.6);\nconst yout = f(xin);\nv.line(xin, 0, xin, yout, { color: H.colors.good, width: 2, dash: [5, 5] });\nv.line(0, yout, xin, yout, { color: H.colors.violet, width: 2, dash: [5, 5] });\nv.dot(xin, 0, { r: 5, fill: H.colors.good });\nv.dot(0, yout, { r: 5, fill: H.colors.violet });\nv.dot(xin, yout, { r: 7, fill: H.colors.warn });\nH.text(\"Function notation: f(x) = a x^2 + b x + c\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f(\" + xin.toFixed(2) + \") = \" + a.toFixed(1) + \"(\" + xin.toFixed(2) + \")^2 + \" + b.toFixed(1) + \"(\" + xin.toFixed(2) + \") + \" + c.toFixed(1) + \" = \" + yout.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"input x\", color: H.colors.good }, { label: \"output f(x)\", color: H.colors.violet }], H.W - 170, 28);" + }, + { + "id": "a1-geometry-formula-word-problems", + "area": "Algebra 1", + "topic": "Geometry formula word problems", + "title": "Rectangle: Area = L*W, Perimeter = 2(L+W)", + "equation": "Area = L * W, Perimeter = 2*(L + W)", + "keywords": [ + "geometry word problem", + "area", + "perimeter", + "rectangle", + "length and width", + "formula", + "dimensions", + "area of a rectangle", + "perimeter formula", + "l times w", + "2(l+w)", + "geometry formula" + ], + "explanation": "Most geometry word problems are really just plugging numbers into a formula. Drag the length and width sliders to reshape the rectangle: its area (the shaded inside) grows as length*width, while its perimeter (the distance the dot walks all the way around) grows as 2*(length+width). Notice that doubling one side doubles the area but only adds to the perimeter — that's why the two answers behave so differently.", + "bullets": [ + "Area = L*W counts the squares INSIDE; perimeter = 2(L+W) measures the border AROUND.", + "The walking dot traces exactly what 'perimeter' means: once around the whole edge.", + "Read the problem, match it to the right formula, then substitute the given numbers." + ], + "params": [ + { + "name": "length", + "label": "length L", + "min": 1, + "max": 12, + "step": 0.5, + "value": 8 + }, + { + "name": "width", + "label": "width W", + "min": 1, + "max": 8, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 13, yMin: -1, yMax: 9 });\nv.grid(); v.axes();\nconst L = Math.max(0.1, P.length), W = Math.max(0.1, P.width);\nv.rect(0, 0, L, W, { stroke: H.colors.accent, width: 3, fill: \"rgba(124,196,255,0.12)\" });\nconst per = 2 * (L + W);\nconst s = (t * 1.4) % per;\nlet px, py;\nif (s < L) { px = s; py = 0; }\nelse if (s < L + W) { px = L; py = s - L; }\nelse if (s < 2 * L + W) { px = L - (s - L - W); py = W; }\nelse { px = 0; py = W - (s - 2 * L - W); }\nv.dot(px, py, { r: 7, fill: H.colors.warn });\nv.text(\"length = \" + L.toFixed(1), L / 2 - 1.6, -0.4, { color: H.colors.accent, size: 13 });\nv.text(\"width = \" + W.toFixed(1), L + 0.3, W / 2, { color: H.colors.good, size: 13 });\nconst area = L * W;\nH.text(\"Rectangle: Area = L*W, Perimeter = 2(L+W)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"Area = \" + area.toFixed(2) + \" sq units Perimeter = \" + per.toFixed(2) + \" units\", 24, 52, { color: H.colors.sub, size: 13 });" + }, + { + "id": "a1-inequalities-variables-both-sides", + "area": "Algebra 1", + "topic": "Inequalities with variables on both sides", + "title": "Both sides: a1*x + b1 > a2*x + b2", + "equation": "a1*x + b1 > a2*x + b2", + "keywords": [ + "variables on both sides", + "inequality both sides", + "collect like terms", + "two lines", + "intersection", + "which side is higher", + "a1 x + b1 > a2 x + b2", + "compare two expressions", + "solve inequality", + "crossing point", + "inequalities" + ], + "explanation": "When x appears on BOTH sides, graph each side as its own line and ask: for which x is the left line ABOVE the right one? They cross exactly where the two sides are equal, and that crossing x is the boundary of your answer. The sweeping dot rides the left line and turns green wherever left > right, so you watch the solution region appear; collecting the x-terms algebraically gives that same boundary without graphing.", + "bullets": [ + "Each side of the inequality is its own line — the solution compares their heights.", + "The crossing point is where the two sides are EQUAL: the boundary of the answer.", + "Equal slopes mean the lines never cross: no solution, or all x (always true)." + ], + "params": [ + { + "name": "a1", + "label": "left slope a1", + "min": -3, + "max": 3, + "step": 0.1, + "value": 2 + }, + { + "name": "b1", + "label": "left const b1", + "min": -6, + "max": 6, + "step": 0.5, + "value": 1 + }, + { + "name": "a2", + "label": "right slope a2", + "min": -3, + "max": 3, + "step": 0.1, + "value": -1 + }, + { + "name": "b2", + "label": "right const b2", + "min": -6, + "max": 6, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst a1 = P.a1, b1 = P.b1, a2 = P.a2, b2 = P.b2;\nv.fn(x => a1 * x + b1, { color: H.colors.accent, width: 3 });\nv.fn(x => a2 * x + b2, { color: H.colors.accent2, width: 3 });\nlet bound = null;\nif (Math.abs(a1 - a2) > 1e-6) {\n bound = (b2 - b1) / (a1 - a2);\n const yb = a1 * bound + b1;\n if (bound > -8 && bound < 8) {\n v.line(bound, -8, bound, 8, { color: H.colors.warn, width: 1.5, dash: [4, 4] });\n v.dot(bound, yb, { r: 6, fill: H.colors.warn });\n }\n}\nconst xs = 6 * Math.sin(t * 0.5);\nconst y1 = a1 * xs + b1, y2 = a2 * xs + b2;\nconst ok = y1 > y2;\nv.dot(xs, y1, { r: 7, fill: ok ? H.colors.good : H.colors.sub });\nv.dot(xs, y2, { r: 5, fill: H.colors.sub });\nH.text(\"Both sides: a1*x + b1 > a2*x + b2\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nconst dir = a1 > a2 ? \"x > \" : \"x < \";\nconst msg = bound === null ? \"lines parallel - no crossing\" : dir + bound.toFixed(2);\nH.text(\"collect x on one side -> \" + msg + \" (test x=\" + xs.toFixed(1) + \": left \" + (ok ? \">\" : \"<=\") + \" right)\", 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"left side\", color: H.colors.accent }, { label: \"right side\", color: H.colors.accent2 }], H.W - 160, 28);" + }, + { + "id": "a1-inequality-word-problems", + "area": "Algebra 1", + "topic": "Inequality word problems", + "title": "Budget inequality: fixed + cost·n ≤ budget", + "equation": "fixed + cost*n <= budget", + "keywords": [ + "inequality word problem", + "inequality", + "word problem", + "at most", + "no more than", + "budget", + "how many", + "less than or equal", + "fixed cost", + "per item", + "spending", + "afford" + ], + "explanation": "Most inequality word problems hide the same shape: a one-time fixed cost plus a per-item cost can't exceed your budget. Slide cost up and the line of solutions shrinks (each item eats more of the budget); raise the fixed cost and you can afford fewer items even before you start. The green bar on the number line is every whole number of items you CAN buy, and the closed dot marks the most you can afford — watch the spend gauge fill as the shopper dot walks toward the boundary.", + "bullets": [ + "Translate the words: a fixed cost plus cost·n must stay at or under the budget.", + "Solve for n: n ≤ (budget − fixed) / cost — divide last, keep the ≤ direction.", + "A real count is a whole number, so round the boundary DOWN to the largest n that fits." + ], + "params": [ + { + "name": "budget", + "label": "budget $", + "min": 10, + "max": 120, + "step": 5, + "value": 100 + }, + { + "name": "fixed", + "label": "fixed cost $", + "min": 0, + "max": 40, + "step": 5, + "value": 20 + }, + { + "name": "cost", + "label": "cost / item $", + "min": 1, + "max": 20, + "step": 0.5, + "value": 8 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n// Word problem: budget B, fixed up-front cost F, per-item cost C.\n// \"How many items n can you buy?\" -> F + C*n <= B\nconst budget = P.budget, cost = Math.max(0.5, P.cost), fixed = P.fixed;\nconst nMax = Math.max(0, (budget - fixed) / cost);\nconst nMaxInt = Math.max(0, Math.floor(nMax + 1e-9));\nH.text(\"Inequality word problem: fixed + cost·n ≤ budget\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"budget = $\" + budget.toFixed(0) + \" fixed = $\" + fixed.toFixed(0) + \" cost = $\" + cost.toFixed(1) + \"/item\", 24, 52, { color: H.colors.sub, size: 13 });\nconst nLineMax = 20;\nconst v = H.plot2d({ xMin: -1, xMax: nLineMax, yMin: -2, yMax: 2, box: { x: 50, y: h * 0.52, w: w - 100, h: 80 } });\nv.line(0, 0, nLineMax, 0, { color: H.colors.axis, width: 2 });\nfor (let i = 0; i <= nLineMax; i++) {\n v.line(i, -0.25, i, 0.25, { color: H.colors.grid, width: 1 });\n if (i % 2 === 0) v.text(String(i), i, -0.95, { color: H.colors.sub, size: 11, align: \"center\" });\n}\nconst nBound = Math.min(nMaxInt, nLineMax);\nv.line(0, 0, nBound, 0, { color: H.colors.good, width: 7 });\nv.dot(nBound, 0, { r: 7, fill: H.colors.warn });\nv.text(\"n ≤ \" + nMaxInt, nBound, 1.4, { color: H.colors.warn, size: 13, align: \"center\" });\nconst span = Math.max(1, nMaxInt);\nconst phase = (Math.sin(t * 0.9) * 0.5 + 0.5);\nconst nNow = phase * span;\nconst spend = fixed + cost * nNow;\nconst ok = spend <= budget + 1e-9;\nv.dot(Math.min(nNow, nLineMax), 0, { r: 6 + Math.sin(t * 4), fill: ok ? H.colors.accent : H.colors.warn });\nconst bx = w - 240, by = 80, bw = 200, bh = 16;\nH.rect(bx, by, bw, bh, { stroke: H.colors.axis, width: 1.5, radius: 4 });\nconst frac = budget > 0 ? H.clamp(spend / budget, 0, 1) : 0;\nH.rect(bx, by, bw * frac, bh, { fill: ok ? H.colors.good : H.colors.warn, radius: 4 });\nH.text(\"buy n = \" + nNow.toFixed(1) + \" → spend $\" + spend.toFixed(0) + \" / $\" + budget.toFixed(0), bx, by - 8, { color: H.colors.sub, size: 12 });\nH.text(\"most whole items you can afford: n = \" + nMaxInt, 24, h * 0.52 - 22, { color: H.colors.good, size: 14, weight: 700 });" + }, + { + "id": "a1-integer-operations", + "area": "Algebra 1", + "topic": "Integer operations in algebra", + "title": "Integer addition: a + b on the number line", + "equation": "a + b (move right if b > 0, left if b < 0)", + "keywords": [ + "integer operations", + "adding integers", + "negative numbers", + "number line", + "signed numbers", + "a+b", + "subtracting integers", + "positive and negative", + "add and subtract", + "integer addition", + "sign rules" + ], + "explanation": "Adding integers is a walk on the number line: start at a, then take b steps — to the RIGHT when b is positive and to the LEFT when b is negative. The arrow shows the direction and length of the move, and the moving dot animates the walk from start to the sum so you feel why two negatives drive you further left while opposite signs cancel. Slide a to choose the start and slide b to set how far and which way you step.", + "bullets": [ + "a + b starts at a and steps b units along the number line.", + "b > 0 moves right (gets bigger); b < 0 moves left (gets smaller).", + "Opposite signs partly cancel; same signs push further the same way." + ], + "params": [ + { + "name": "a", + "label": "start a", + "min": -9, + "max": 9, + "step": 1, + "value": -3 + }, + { + "name": "b", + "label": "add b", + "min": -9, + "max": 9, + "step": 1, + "value": 5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -2, yMax: 2 });\nv.grid();\nconst a = P.a, b = P.b;\n// Number line for integer addition: start at a, then step by b (sign matters).\nconst sum = a + b;\n// draw a thick number line\nv.line(-10, 0, 10, 0, { color: H.colors.axis, width: 2 });\nfor (let k = -10; k <= 10; k += 2) {\n v.line(k, -0.18, k, 0.18, { color: H.colors.grid, width: 1.5 });\n v.text(String(k), k, -0.55, { color: H.colors.sub, size: 11, align: \"center\", baseline: \"middle\" });\n}\n// start marker at a\nv.dot(a, 0, { r: 6, fill: H.colors.good });\nv.text(\"start a\", a, 0.7, { color: H.colors.good, size: 12, align: \"center\", baseline: \"middle\" });\n// animated walking dot from a toward a+b\nconst prog = (Math.sin(t * 1.1) * 0.5 + 0.5); // 0..1 looping\nconst cur = a + b * prog;\nconst dir = b >= 0 ? 1 : -1;\nv.arrow(a, 0.35, a + b, 0.35, { color: dir > 0 ? H.colors.accent : H.colors.warn, width: 2.5 });\nv.dot(cur, 0, { r: 7, fill: H.colors.accent2 });\nv.text((dir > 0 ? \"+\" : \"−\") + Math.abs(b).toFixed(0) + \" steps\", a + b / 2, 1.1, { color: dir > 0 ? H.colors.accent : H.colors.warn, size: 12, align: \"center\", baseline: \"middle\" });\nv.dot(sum, 0, { r: 6, fill: H.colors.warn });\nv.text(\"sum\", sum, -1.0, { color: H.colors.warn, size: 12, align: \"center\", baseline: \"middle\" });\nH.text(\"Integer operations: a + b on the number line\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a = \" + a.toFixed(0) + \" b = \" + b.toFixed(0) + \" → a + b = \" + sum.toFixed(0) + \" (\" + (b >= 0 ? \"move right\" : \"move left\") + \")\", 24, 52, { color: H.colors.sub, size: 13 });" + }, + { + "id": "a1-intro-quadratics", + "area": "Algebra 1", + "topic": "Intro quadratics: graphing, factoring, square-root solving", + "title": "Quadratic: y = a(x − r1)(x − r2)", + "equation": "y = a(x - r1)(x - r2) = a*x^2 - a(r1+r2)*x + a*r1*r2", + "keywords": [ + "quadratics", + "parabola", + "graphing quadratics", + "factoring quadratics", + "roots", + "x intercepts", + "zeros", + "square root solving", + "axis of symmetry", + "vertex", + "factored form", + "intro quadratics" + ], + "explanation": "A parabola crosses the x-axis at its roots r1 and r2 — the same values that make each factor (x − r) equal zero, which is why factoring SOLVES a quadratic. The two green dots are those roots; halfway between them sits the axis of symmetry and the vertex, since a parabola is mirror-symmetric. Drag the roots to factor different quadratics and watch the standard-form coefficients update; when both roots are equal you get a perfect square that square-root solving handles directly.", + "bullets": [ + "Roots are the x-intercepts: where each factor (x − r) = 0.", + "Axis of symmetry x = (r1+r2)/2; the vertex sits on it.", + "Equal roots -> a perfect square; solve by taking ±square root." + ], + "params": [ + { + "name": "a", + "label": "shape a", + "min": -2, + "max": 2, + "step": 0.1, + "value": 0.5 + }, + { + "name": "r1", + "label": "root r1", + "min": -6, + "max": 6, + "step": 0.5, + "value": -3 + }, + { + "name": "r2", + "label": "root r2", + "min": -6, + "max": 6, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -10, yMax: 10 });\nv.grid(); v.axes();\nconst a = (Math.abs(P.a) < 0.05 ? 0.05 : P.a);\nconst r1 = P.r1, r2 = P.r2;\n// factored form y = a(x-r1)(x-r2); expand to standard form\nconst bb = -a * (r1 + r2);\nconst cc = a * r1 * r2;\nconst f = (x) => a * (x - r1) * (x - r2);\nv.fn(f, { color: H.colors.accent, width: 3 });\n// roots (x-intercepts) — where it crosses y=0\nv.dot(r1, 0, { r: 7, fill: H.colors.good });\nv.dot(r2, 0, { r: 7, fill: H.colors.good });\n// vertex / axis of symmetry at midpoint of roots\nconst xv = (r1 + r2) / 2, yv = f(xv);\nv.line(xv, -10, xv, 10, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.dot(xv, yv, { r: 6, fill: H.colors.warn });\n// animated dot sweeping the curve, bounded & looping\nconst xs = xv + 5 * Math.sin(t * 0.7);\nv.dot(xs, f(xs), { r: 6, fill: H.colors.accent2 });\nH.text(\"Quadratic: y = a(x − r₁)(x − r₂)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst bs = (bb >= 0 ? \"+ \" + bb.toFixed(1) : \"− \" + Math.abs(bb).toFixed(1));\nconst cs = (cc >= 0 ? \"+ \" + cc.toFixed(1) : \"− \" + Math.abs(cc).toFixed(1));\nH.text(\"= \" + a.toFixed(1) + \"x² \" + bs + \"x \" + cs + \" roots: x = \" + r1.toFixed(1) + \", \" + r2.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"axis x = \" + xv.toFixed(1) + \" vertex (\" + xv.toFixed(1) + \", \" + yv.toFixed(1) + \")\", 24, 74, { color: H.colors.violet, size: 12 });\nH.legend([{ label: \"roots (factors)\", color: H.colors.good }, { label: \"vertex\", color: H.colors.warn }, { label: \"axis of symmetry\", color: H.colors.violet }], H.W - 200, 30);" + }, + { + "id": "a1-line-from-graph-table", + "area": "Algebra 1", + "topic": "Write line equations from graphs/tables", + "title": "From a table/graph: y = m·x + b", + "equation": "y = m*x + b", + "keywords": [ + "write equation from graph", + "write equation from table", + "line from table", + "line from graph", + "find slope and intercept", + "rate of change from table", + "read a line", + "y=mx+b from data", + "constant rate", + "table of values" + ], + "explanation": "A table or graph hands you a line one row at a time. The b slider sets the starting value (where x = 0, the red dot), and the m slider sets how much y jumps for each step of +1 in x. The green highlight steps through consecutive rows so you can literally watch y change by exactly m every time x goes up by one — that constant jump IS the slope.", + "bullets": [ + "b is the y-value at x = 0: the first row or the y-intercept.", + "Each +1 step in x changes y by m, the constant rate of change.", + "Read b from x = 0, read m from any +1 step, then write y = mx + b." + ], + "params": [ + { + "name": "m", + "label": "rate / slope m", + "min": -3, + "max": 3, + "step": 0.5, + "value": 1.5 + }, + { + "name": "b", + "label": "start value b", + "min": -1, + "max": 6, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 9, yMin: -2, yMax: 10 });\nv.grid(); v.axes();\nconst m = P.m, b = P.b;\nconst f = x => m * x + b;\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.dot(0, b, { r: 6, fill: H.colors.warn });\nv.text(\"y-int b = \" + b.toFixed(1), 0.2, b + 0.7, { color: H.colors.warn, size: 12 });\nconst cols = 5;\nfor (let i = 0; i < cols; i++) {\n v.dot(i, f(i), { r: 4, fill: H.colors.sub });\n}\nconst step = Math.floor(t % cols);\nconst xa = step, xb = step + 1;\nv.dot(xa, f(xa), { r: 7, fill: H.colors.good });\nv.dot(xb, f(xb), { r: 7, fill: H.colors.good });\nv.line(xa, f(xa), xb, f(xa), { color: H.colors.good, width: 2, dash: [4, 4] });\nv.line(xb, f(xa), xb, f(xb), { color: H.colors.violet, width: 2, dash: [4, 4] });\nv.text(\"+1\", (xa + xb) / 2 - 0.2, f(xa) - 0.4, { color: H.colors.good, size: 12 });\nv.text(\"+\" + m.toFixed(1), xb + 0.2, (f(xa) + f(xb)) / 2, { color: H.colors.violet, size: 12 });\nH.text(\"Read a line from a table/graph: y = m·x + b\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"each +1 in x adds m = \" + m.toFixed(1) + \" to y; start b = \" + b.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"row: x=\" + xa + \" → y=\" + f(xa).toFixed(1) + \" x=\" + xb + \" → y=\" + f(xb).toFixed(1), 24, 74, { color: H.colors.good, size: 13 });" + }, + { + "id": "a1-line-from-two-points", + "area": "Algebra 1", + "topic": "Write line equations from points", + "title": "Line through two points", + "equation": "m = (y2 - y1)/(x2 - x1), y = m*x + b", + "keywords": [ + "line through two points", + "equation from two points", + "two point form", + "slope from two points", + "find the equation given points", + "rise over run", + "slope formula", + "write line from points", + "(x1,y1) and (x2,y2)", + "two points" + ], + "explanation": "Two points are all you need to pin down a line. Drag the two red points; the dashed triangle between them shows rise = y2 − y1 and run = x2 − x1, and their ratio is the slope m. Once m is known, the line must pass back through a point, which fixes b — the live readout assembles y = mx + b for you, and even catches the vertical case where the run is zero.", + "bullets": [ + "Slope m = (y2 − y1)/(x2 − x1): rise over run between the two points.", + "Plug one point into y = mx + b to solve for the intercept b.", + "Equal x-values (run = 0) make a vertical line x = x1 with undefined slope." + ], + "params": [ + { + "name": "x1", + "label": "point 1 x", + "min": -6, + "max": 6, + "step": 1, + "value": -3 + }, + { + "name": "y1", + "label": "point 1 y", + "min": -6, + "max": 6, + "step": 1, + "value": -1 + }, + { + "name": "x2", + "label": "point 2 x", + "min": -6, + "max": 6, + "step": 1, + "value": 2 + }, + { + "name": "y2", + "label": "point 2 y", + "min": -6, + "max": 6, + "step": 1, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst x1 = P.x1, y1 = P.y1, x2 = P.x2, y2 = P.y2;\nconst dx = x2 - x1, dy = y2 - y1;\nconst denom = Math.abs(dx) < 1e-6 ? 1e-6 : dx;\nconst m = dy / denom;\nconst b = y1 - m * x1;\nconst f = x => m * x + b;\nif (Math.abs(dx) > 1e-6) {\n v.fn(f, { color: H.colors.accent, width: 3 });\n} else {\n v.line(x1, -8, x1, 8, { color: H.colors.accent, width: 3 });\n}\nv.dot(x1, y1, { r: 7, fill: H.colors.warn });\nv.dot(x2, y2, { r: 7, fill: H.colors.warn });\nv.text(\"(\" + x1.toFixed(0) + \", \" + y1.toFixed(0) + \")\", x1 + 0.3, y1 - 0.6, { color: H.colors.warn, size: 12 });\nv.text(\"(\" + x2.toFixed(0) + \", \" + y2.toFixed(0) + \")\", x2 + 0.3, y2 - 0.6, { color: H.colors.warn, size: 12 });\nv.line(x1, y1, x2, y1, { color: H.colors.good, width: 2, dash: [5, 5] });\nv.line(x2, y1, x2, y2, { color: H.colors.violet, width: 2, dash: [5, 5] });\nv.text(\"run = \" + dx.toFixed(1), (x1 + x2) / 2 - 0.6, y1 - 0.5, { color: H.colors.good, size: 12 });\nv.text(\"rise = \" + dy.toFixed(1), x2 + 0.3, (y1 + y2) / 2, { color: H.colors.violet, size: 12 });\nconst xs = (x1 + x2) / 2 + 4 * Math.sin(t * 0.7);\nconst ys = Math.abs(dx) > 1e-6 ? f(xs) : 0;\nif (Math.abs(dx) > 1e-6) v.dot(xs, ys, { r: 6, fill: H.colors.accent2 });\nH.text(\"Line through two points\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"m = rise/run = \" + dy.toFixed(1) + \"/\" + dx.toFixed(1) + \" = \" + (Math.abs(dx) > 1e-6 ? m.toFixed(2) : \"∞\"), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(Math.abs(dx) > 1e-6 ? (\"y = \" + m.toFixed(2) + \"x + \" + b.toFixed(2)) : (\"vertical line x = \" + x1.toFixed(1)), 24, 74, { color: H.colors.good, size: 14 });" + }, + { + "id": "a1-line-of-best-fit", + "area": "Algebra 1", + "topic": "Linear modeling and line of best fit", + "title": "Line of best fit: y = m*x + b", + "equation": "y = m*x + b (minimize sum of (y - (m*x + b))^2)", + "keywords": [ + "line of best fit", + "best fit line", + "linear regression", + "linear model", + "trend line", + "scatter plot", + "residuals", + "least squares", + "correlation", + "modeling data", + "predict", + "regression line" + ], + "explanation": "Real data never lands perfectly on a line, so we look for the line that comes CLOSEST to all the points. Each red dashed segment is a residual: the gap between a data point and the line's prediction at that x. Drag the slope m and intercept b to swing the line through the cloud and watch the 'sum of squared error' shrink. The best-fit line is the setting that makes that total as small as possible.", + "bullets": [ + "A residual is the vertical gap between a data point and the line.", + "Best fit = the m and b that make the sum of squared residuals smallest.", + "Use the model y = m x + b to predict y for an x not in the data." + ], + "params": [ + { + "name": "m", + "label": "slope m", + "min": -1, + "max": 3, + "step": 0.05, + "value": 1 + }, + { + "name": "b", + "label": "intercept b", + "min": -3, + "max": 5, + "step": 0.25, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -1, yMax: 11 });\nv.grid(); v.axes();\nconst m = P.m, b = P.b;\nconst data = [[1, 2], [2, 2.6], [3, 4.1], [4, 4.4], [5, 6.0], [6, 6.3], [7, 8.1], [8, 8.4], [9, 9.6]];\nv.fn(x => m * x + b, { color: H.colors.accent, width: 3 });\nlet sse = 0;\nfor (let i = 0; i < data.length; i++) {\n const px = data[i][0], py = data[i][1];\n const yhat = m * px + b;\n v.line(px, py, px, yhat, { color: H.colors.warn, width: 1.5, dash: [3, 3] });\n v.dot(px, py, { r: 5, fill: H.colors.good });\n sse += (py - yhat) * (py - yhat);\n}\nconst k = Math.floor((t * 0.7) % data.length);\nconst hx = data[k][0], hy = data[k][1];\nv.circle(hx, hy, 9 + 2 * Math.sin(t * 3), { stroke: H.colors.yellow, width: 2 });\nH.text(\"Line of best fit: y = m x + b\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"m = \" + m.toFixed(2) + \" b = \" + b.toFixed(2) + \" sum of squared error = \" + sse.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"data points\", color: H.colors.good }, { label: \"residuals\", color: H.colors.warn }, { label: \"model line\", color: H.colors.accent }], H.W - 180, 28);" + }, + { + "id": "a1-literal-equations", + "area": "Algebra 1", + "topic": "Literal equations", + "title": "Literal equations: A = L*w solved for L", + "equation": "A = L * w -> L = A / w", + "keywords": [ + "literal equations", + "solve for a variable", + "solving for a letter", + "rearrange", + "isolate variable", + "formula with letters", + "a = l*w", + "l = a/w", + "area formula", + "solve for l", + "solve literal equation", + "rectangle area" + ], + "explanation": "A literal equation has letters instead of numbers, and 'solving' it means isolating one letter in terms of the others. Here the rectangle's area A = L*w; dividing both sides by w isolates the length, giving L = A/w. Drag the area A and the width w: the rectangle keeps the same area but reshapes, and the violet side L = A/w grows exactly when w shrinks. That inverse relationship IS what solving for L reveals.", + "bullets": [ + "Solving for a letter = isolate it using inverse operations on BOTH sides.", + "A = L*w divided by w gives L = A/w (undo the multiplication by w).", + "Same area, smaller width forces a longer length: L and w trade off." + ], + "params": [ + { + "name": "area", + "label": "area A", + "min": 4, + "max": 40, + "step": 1, + "value": 24 + }, + { + "name": "width", + "label": "width w", + "min": 1, + "max": 10, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst A = Math.max(1, P.area), w = Math.max(0.5, P.width);\nconst len = A / w;\nconst top = Math.max(11, Math.min(40, len + 2));\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -1, yMax: top });\nv.grid(); v.axes();\nv.rect(0, 0, w, len, { fill: \"rgba(124,196,255,0.18)\", stroke: H.colors.accent, width: 2 });\nv.line(0, 0, w, 0, { color: H.colors.good, width: 4 });\nv.line(0, 0, 0, len, { color: H.colors.violet, width: 4 });\nv.dot(w, len, { r: 6, fill: H.colors.warn });\nconst s = 0.5 + 0.5 * Math.sin(t);\nv.dot(w * s, len * s, { r: 5, fill: H.colors.accent2 });\nv.text(\"w = \" + w.toFixed(1), w / 2, -0.5, { color: H.colors.good, size: 13, align: \"center\" });\nv.text(\"L = A/w = \" + len.toFixed(2), w + 0.3, len / 2, { color: H.colors.violet, size: 13 });\nH.text(\"Literal equation: A = L*w solved for L -> L = A / w\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"A = \" + A.toFixed(1) + \" w = \" + w.toFixed(1) + \" L = A/w = \" + len.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"width w\", color: H.colors.good }, { label: \"length L\", color: H.colors.violet }], H.W - 150, 28);" + }, + { + "id": "a1-mixture-money", + "area": "Algebra 1", + "topic": "Mixture/money word problems", + "title": "Mixture problems: A*a% + B*b% = (A+B)*mix%", + "equation": "volA*concA + volB*concB = (volA + volB) * mixConc", + "keywords": [ + "mixture problem", + "mixture word problem", + "money word problem", + "concentration", + "percent solution", + "mixing solutions", + "weighted average", + "alloy mixture", + "acid solution", + "solute", + "a*p1 + b*p2 = (a+b)*p", + "coin mixture" + ], + "explanation": "Mixture (and money) problems are weighted-average problems: the amount of 'pure stuff' is conserved when you combine. Beaker A holds volA liters at concA%, beaker B holds volB liters at a fixed 50%, and the darker orange band in each beaker shows the actual solute. Pour them together and the solute adds up: volA*concA + volB*concB = total*mix%. The resulting concentration always lands BETWEEN the two inputs, pulled toward whichever volume is larger — that is the heart of every mixture equation you set up.", + "bullets": [ + "Conserve the pure part: solute_A + solute_B = solute_in_mixture.", + "The mix percent is a weighted average, pulled toward the larger volume.", + "Same idea for money: (count)(value) terms add to a total value." + ], + "params": [ + { + "name": "volA", + "label": "volume A (L)", + "min": 0, + "max": 12, + "step": 0.5, + "value": 4 + }, + { + "name": "volB", + "label": "volume B (L)", + "min": 0, + "max": 12, + "step": 0.5, + "value": 6 + }, + { + "name": "concA", + "label": "concentration A (%)", + "min": 0, + "max": 100, + "step": 5, + "value": 10 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst va = Math.max(0, P.volA);\nconst vb = Math.max(0, P.volB);\nconst ca = H.clamp(P.concA, 0, 100) / 100;\nconst cb = 0.50;\nconst total = Math.max(1e-6, va + vb);\nconst mixC = (va * ca + vb * cb) / total;\nconst baseY = h * 0.80, maxH = h * 0.46, maxV = 12;\nfunction beaker(cx, vol, conc, label, col) {\n const bw = 70;\n const bh = (Math.min(vol, maxV) / maxV) * maxH;\n H.rect(cx - bw / 2, baseY - maxH, bw, maxH, { stroke: H.colors.grid, width: 1.5 });\n H.rect(cx - bw / 2, baseY - bh, bw, bh, { fill: col, radius: 2 });\n const ph = bh * conc;\n H.rect(cx - bw / 2, baseY - ph, bw, ph, { fill: H.colors.accent2, radius: 2 });\n H.text(label, cx, baseY + 20, { color: H.colors.ink, size: 13, align: \"center\", weight: 700 });\n H.text(vol.toFixed(1) + \" L @ \" + (conc * 100).toFixed(0) + \"%\", cx, baseY + 38, { color: H.colors.sub, size: 11, align: \"center\" });\n}\nconst pulse = 0.5 + 0.5 * Math.sin(t * 2);\nbeaker(w * 0.22, va, ca, \"A\", H.colors.accent);\nbeaker(w * 0.50, vb, cb, \"B\", H.colors.violet);\nbeaker(w * 0.80, total, mixC, \"Mix\", H.hsl(180, 50, 45 + 8 * pulse));\nH.arrow(w * 0.30, baseY - maxH * 0.5, w * 0.40, baseY - maxH * 0.5, { color: H.colors.good, width: 2 });\nH.arrow(w * 0.60, baseY - maxH * 0.5, w * 0.70, baseY - maxH * 0.5, { color: H.colors.good, width: 2 });\nH.text(\"Mixture: A_vol*A% + B_vol*B% = (A_vol+B_vol)*Mix%\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(va.toFixed(1) + \"(\" + (ca * 100).toFixed(0) + \"%) + \" + vb.toFixed(1) + \"(50%) = \" + total.toFixed(1) + \" L @ \" + (mixC * 100).toFixed(1) + \"%\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"pure solute\", color: H.colors.accent2 }], H.W - 160, 28);" + }, + { + "id": "a1-multi-step-equations", + "area": "Algebra 1", + "topic": "Multi-step equations", + "title": "Multi-step: a·x + b = c", + "equation": "a*x + b = c -> x = (c - b) / a", + "keywords": [ + "multi-step equations", + "multi step equation", + "two step equation", + "solve for x", + "inverse operations", + "undo operations", + "ax+b=c", + "isolate the variable", + "balance both sides", + "solving equations", + "linear equation" + ], + "explanation": "Solving a multi-step equation means peeling away the operations around x in REVERSE order. The 'step' slider walks you through it: first you undo the +b by subtracting b from BOTH sides, then you undo the ·a by dividing both sides by a. The a, b, and c sliders set the equation, and the readout shows the running solution and a check that plugging x back in really gives c.", + "bullets": [ + "Work backwards: undo + or - first, then undo * or /.", + "Whatever you do to one side you must do to the other.", + "Check by substituting x back in: a*x + b should equal c." + ], + "params": [ + { + "name": "a", + "label": "coefficient a", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "b", + "label": "added b", + "min": -10, + "max": 10, + "step": 1, + "value": 5 + }, + { + "name": "c", + "label": "result c", + "min": -10, + "max": 20, + "step": 1, + "value": 14 + }, + { + "name": "step", + "label": "solving step", + "min": 0, + "max": 2, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst a = (Math.abs(P.a) < 1e-9) ? 1 : P.a, b = P.b, c = P.c;\nconst step = Math.max(0, Math.min(2, Math.round(P.step)));\nconst x = (c - b) / a;\nconst w = H.W, h = H.H;\n// three equation lines, revealed by step, with a sweeping highlight\nconst lines = [\n { lhs: \"a·x + b = c\", sub: \"start: \" + a.toFixed(1) + \"·x + \" + b.toFixed(1) + \" = \" + c.toFixed(1) },\n { lhs: \"a·x = c − b\", sub: \"subtract \" + b.toFixed(1) + \" from both sides → \" + a.toFixed(1) + \"·x = \" + (c - b).toFixed(2) },\n { lhs: \"x = (c − b) / a\", sub: \"divide both sides by \" + a.toFixed(1) + \" → x = \" + x.toFixed(2) }\n];\nconst y0 = 120, dy = 110;\nfor (let i = 0; i <= step; i++) {\n const yy = y0 + i * dy;\n const active = (i === step);\n const glow = active ? (4 + 3 * Math.sin(t * 3)) : 0;\n H.rect(60, yy - 34, w - 120, 64, { fill: active ? H.colors.panel : \"#11192e\", stroke: active ? H.colors.accent : H.colors.grid, width: 1.5 + glow * 0.2, radius: 10 });\n H.text(lines[i].lhs, 84, yy - 2, { color: H.colors.ink, size: 22, weight: 700 });\n H.text(lines[i].sub, 84, yy + 22, { color: active ? H.colors.good : H.colors.sub, size: 13 });\n if (i > 0) {\n const ax = w * 0.5;\n H.arrow(ax, yy - dy + 30, ax, yy - 34, { color: H.colors.accent2, width: 2, head: 8 });\n }\n}\n// animated check: plug x back in, dot slides to balance\nconst px = 60 + (w - 120) * (0.5 + 0.45 * Math.sin(t * 1.2));\nH.circle(px, h - 36, 6, { fill: H.colors.warn });\nH.text(\"Solve a multi-step equation by UNDOING operations\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a=\" + a.toFixed(1) + \" b=\" + b.toFixed(1) + \" c=\" + c.toFixed(1) + \" step \" + step + \"/2 solution x = \" + x.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"check: a·x + b = \" + (a * x + b).toFixed(2) + \" = c ✓\", 60, h - 30, { color: H.colors.good, size: 13 });" + }, + { + "id": "a1-multi-step-inequalities", + "area": "Algebra 1", + "topic": "Multi-step inequalities", + "title": "Multi-step inequality: a*x + b < c", + "equation": "a*x + b < c => x < (c - b)/a (flips if a < 0)", + "keywords": [ + "multi-step inequality", + "inequality", + "solve for x", + "distribute then solve", + "flip the sign", + "negative coefficient", + "a x + b < c", + "threshold line", + "boundary", + "graph an inequality", + "inequalities", + "two step inequality" + ], + "explanation": "To solve a*x + b < c you peel off b, then divide by a — two steps. Graphically, you plot the line y = a*x + b and the dashed threshold y = c; the solution is the x-values where the line dips below the threshold, and they meet at the boundary x = (c-b)/a. The crucial idea: when a is negative the line slopes downhill, so the '<' direction reverses — dividing an inequality by a negative number FLIPS the sign.", + "bullets": [ + "Isolate x in steps: subtract b, then divide by a.", + "The solution is where the line crosses the threshold y = c (the boundary x-value).", + "Dividing both sides by a negative a REVERSES the inequality direction." + ], + "params": [ + { + "name": "a", + "label": "slope a", + "min": -3, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "b", + "label": "add b", + "min": -6, + "max": 6, + "step": 0.5, + "value": 1 + }, + { + "name": "c", + "label": "threshold c", + "min": -6, + "max": 6, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst a = (Math.abs(P.a) < 0.1 ? 1 : P.a), b = P.b, c = P.c;\nv.fn(x => a * x + b, { color: H.colors.accent, width: 3 });\nv.line(-8, c, 8, c, { color: H.colors.violet, width: 2, dash: [6, 6] });\nconst bound = (c - b) / a;\nif (bound > -8 && bound < 8) {\n v.line(bound, -8, bound, 8, { color: H.colors.warn, width: 1.5, dash: [4, 4] });\n v.dot(bound, c, { r: 6, fill: H.colors.warn });\n}\nconst xs = 6 * Math.sin(t * 0.5);\nconst yv = a * xs + b;\nconst ok = yv < c;\nv.dot(xs, yv, { r: 7, fill: ok ? H.colors.good : H.colors.sub });\nconst dir = a > 0 ? \"<\" : \">\";\nH.text(\"Multi-step: a*x + b < c => x \" + dir + \" (c-b)/a\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"a=\" + a.toFixed(1) + \" b=\" + b.toFixed(1) + \" c=\" + c.toFixed(1) + \" -> x \" + dir + \" \" + bound.toFixed(2) + (a < 0 ? \" (a<0 flips the sign!)\" : \"\"), 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"y = a*x + b\", color: H.colors.accent }, { label: \"threshold c\", color: H.colors.violet }], H.W - 170, 28);" + }, + { + "id": "a1-no-solution-infinite-solution", + "area": "Algebra 1", + "topic": "No-solution and infinite-solution equations", + "title": "Special cases: a·x + b = c·x + d", + "equation": "a*x + b = c*x + d : one / none / infinite", + "keywords": [ + "no solution", + "infinite solutions", + "infinitely many solutions", + "identity equation", + "special cases", + "parallel lines", + "same line", + "contradiction", + "all real numbers", + "0=0", + "no solution vs infinite", + "linear equation cases" + ], + "explanation": "When you solve a x + b = c x + d, three things can happen — and graphing both sides as lines shows why. If the slopes differ the lines cross once: one solution. If the slopes match but the intercepts differ, the lines are parallel and never meet: no solution. If both slope and intercept match, the lines are literally the same line, so every x works: infinitely many solutions. Flip the mode slider to see all three; the violet probe shows the gap between the sides.", + "bullets": [ + "Different slopes -> lines cross once -> exactly one solution.", + "Same slope, different intercept -> parallel -> no solution.", + "Same slope AND intercept -> one line -> infinitely many solutions." + ], + "params": [ + { + "name": "mode", + "label": "case: 0 one / 1 none / 2 infinite", + "min": 0, + "max": 2, + "step": 1, + "value": 0 + }, + { + "name": "b", + "label": "left intercept b", + "min": -5, + "max": 5, + "step": 0.5, + "value": 1 + }, + { + "name": "gap", + "label": "intercept gap", + "min": -4, + "max": 4, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\n// a x + b = c x + d. We let the user pick slope-gap and intercept-gap to land\n// in each regime. mode: 0 one solution, 1 no solution, 2 infinite.\nconst mode = Math.max(0, Math.min(2, Math.round(P.mode)));\nconst b = P.b, gap = P.gap;\n// build the two sides so the chosen regime is guaranteed:\nlet a, c, d;\nif (mode === 0) { a = 2; c = -1; d = b + gap; } // different slopes -> one solution\nelse if (mode === 1) { a = 1; c = 1; d = b + Math.max(0.5, Math.abs(gap) + 0.5); } // parallel, b != d\nelse { a = 1; c = 1; d = b; } // identical line\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nv.fn(x => a * x + b, { color: H.colors.accent, width: 3 });\nv.fn(x => c * x + d, { color: H.colors.accent2, width: (mode === 2 ? 6 : 3), dash: (mode === 2 ? [2, 6] : null) });\nH.text(\"No-solution vs infinite-solution: a·x + b = c·x + d\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nlet label;\nif (mode === 0) {\n const xs = (d - b) / (a - c), ys = a * xs + b;\n v.dot(xs, ys, { r: 7 + Math.sin(t * 3), fill: H.colors.good });\n v.text(\"one x\", xs + 0.3, ys + 0.6, { color: H.colors.good, size: 13 });\n label = \"different slopes → lines CROSS once → exactly one solution x = \" + xs.toFixed(2);\n} else if (mode === 1) {\n label = \"same slope, different intercept → PARALLEL → no solution (a·x+b never equals c·x+d)\";\n} else {\n label = \"same slope AND intercept → SAME line → every x works → infinitely many solutions\";\n}\nH.text(label, 24, 52, { color: (mode === 0 ? H.colors.sub : H.colors.warn), size: 12 });\n// probe dot sweeping x, drawing the vertical gap between the two sides;\n// the gap shrinks to 0 only where a solution exists.\nconst xp = 6 * Math.sin(t * 0.6);\nconst yl = a * xp + b, yr = c * xp + d;\nv.dot(xp, yl, { r: 5, fill: H.colors.accent });\nv.dot(xp, yr, { r: 5, fill: H.colors.accent2 });\nv.line(xp, yl, xp, yr, { color: H.colors.violet, width: 1.5, dash: [3, 3] });\nv.text(\"gap = \" + Math.abs(yl - yr).toFixed(2), xp + 0.2, (yl + yr) / 2, { color: H.colors.violet, size: 12 });\nH.legend([{ label: \"left side\", color: H.colors.accent }, { label: \"right side\", color: H.colors.accent2 }], H.W - 170, 28);" + }, + { + "id": "a1-one-step-equations", + "area": "Algebra 1", + "topic": "One-step equations", + "title": "One-step equation: a*x = c", + "equation": "a * x = c -> x = c / a", + "keywords": [ + "one step equation", + "one-step equation", + "solve for x", + "inverse operation", + "divide both sides", + "isolate the variable", + "ax = c", + "balance", + "undo", + "equation", + "linear equation", + "solving equations" + ], + "explanation": "A one-step equation hides x behind a single operation; you undo it with the inverse to isolate x. Here a multiplies x, so you DIVIDE both sides by a to get x = c/a — the green dot on the number line. The pink dot sweeps from 0 toward that solution so you can watch the value the equation is asking for; change a or c and the solution slides to a new spot.", + "bullets": [ + "Undo the operation on x with its inverse to isolate the variable.", + "a*x = c is solved by dividing both sides by a: x = c/a.", + "Whatever you do to one side you must do to the other to stay balanced." + ], + "params": [ + { + "name": "a", + "label": "coefficient a", + "min": 1, + "max": 6, + "step": 1, + "value": 3 + }, + { + "name": "c", + "label": "right side c", + "min": -12, + "max": 12, + "step": 1, + "value": 6 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst a = (Math.abs(P.a) < 1e-6 ? 1 : P.a);\nconst c = P.c;\nconst sol = H.clamp(c / a, -12, 12);\nconst xMin = -12, xMax = 12;\nconst px = (xv) => 70 + (xv - xMin) / (xMax - xMin) * (w - 140);\nconst ny = h * 0.55;\nH.line(px(xMin), ny, px(xMax), ny, { color: H.colors.axis, width: 2 });\nfor (let g = Math.ceil(xMin); g <= xMax; g++) {\n const on = g % 2 === 0;\n H.line(px(g), ny - (on ? 7 : 4), px(g), ny + (on ? 7 : 4), { color: H.colors.grid, width: 1 });\n if (on) H.text(String(g), px(g), ny + 22, { color: H.colors.sub, size: 11, align: \"center\" });\n}\nconst sweep = 0.5 + 0.5 * Math.sin(t * 0.8);\nconst cur = sol * sweep;\nH.circle(px(cur), ny, 9, { fill: H.colors.warn });\nH.line(px(cur), ny - 40, px(cur), ny - 12, { color: H.colors.warn, width: 2, dash: [4, 4] });\nH.text(\"x = \" + cur.toFixed(2), px(cur), ny - 48, { color: H.colors.warn, size: 13, align: \"center\", weight: 700 });\nH.circle(px(sol), ny, 5, { fill: H.colors.good });\nH.text(\"solution \" + sol.toFixed(2), px(sol), ny + 44, { color: H.colors.good, size: 12, align: \"center\" });\nH.text(\"One-step equation: a * x = c\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(a.toFixed(1) + \" * x = \" + c.toFixed(1) + \" -> divide both sides by \" + a.toFixed(1) + \" -> x = \" + (c / a).toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });" + }, + { + "id": "a1-one-step-inequalities", + "area": "Algebra 1", + "topic": "One-step inequalities", + "title": "One-step inequality: x + b > c", + "equation": "x + b > c => x > c - b", + "keywords": [ + "one-step inequality", + "inequality", + "solve inequality", + "number line", + "greater than", + "less than", + "open circle", + "solution set", + "x + b > c", + "subtract from both sides", + "shade the ray", + "inequalities" + ], + "explanation": "A one-step inequality is solved with a single inverse move — here, subtracting b from both sides turns x + b > c into x > c - b. The open circle marks the boundary c - b (open because '>' does not include it), and the green ray shades every x that works. The sweeping test point turns green exactly when it lands in that solution region, so you can SEE that 'x > boundary' really is the answer.", + "bullets": [ + "Undo whatever is attached to x; here subtracting b isolates x in one step.", + "An open circle means the boundary is NOT included (strict > or <).", + "Every point on the green ray makes the original inequality true — test one to check." + ], + "params": [ + { + "name": "b", + "label": "add to x b", + "min": -6, + "max": 6, + "step": 0.5, + "value": 3 + }, + { + "name": "c", + "label": "right side c", + "min": -6, + "max": 6, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -3, yMax: 3 });\nv.grid({ stepY: 100 }); v.axes({ stepY: 100 });\nconst b = P.b, c = P.c;\nconst bound = c - b;\nv.line(bound, 0, 10, 0, { color: H.colors.good, width: 6 });\nv.circle(bound, 0, 8, { stroke: H.colors.warn, width: 3, fill: H.colors.bg });\nconst xs = 9 * Math.sin(t * 0.6);\nconst ok = xs > bound;\nv.dot(xs, 0, { r: 7, fill: ok ? H.colors.good : H.colors.sub });\nv.text(\"x\", xs - 0.2, 0.8, { color: ok ? H.colors.good : H.colors.sub, size: 13 });\nv.text(\"x > \" + bound.toFixed(1), bound + 0.3, -0.9, { color: H.colors.good, size: 13 });\nH.text(\"One step: x + b > c => x > c - b\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"b = \" + b.toFixed(1) + \" c = \" + c.toFixed(1) + \" -> x > \" + bound.toFixed(2) + \" (test x = \" + xs.toFixed(2) + \": \" + (ok ? \"TRUE\" : \"false\") + \")\", 24, 52, { color: H.colors.sub, size: 12 });" + }, + { + "id": "a1-order-of-operations", + "area": "Algebra 1", + "topic": "Order of operations", + "title": "Order of operations: a + b × c", + "equation": "a + b * c (multiply before add)", + "keywords": [ + "order of operations", + "pemdas", + "bodmas", + "gemdas", + "parentheses first", + "multiply before add", + "a + b * c", + "grouping", + "left to right", + "operation precedence", + "evaluate expression" + ], + "explanation": "PEMDAS says multiplication happens before addition, so a + b×c means a + (b×c), not (a+b)×c. Move the a, b, and c sliders and step through the worked solution: the highlighted box shows which operation is done first, and the right-hand column shows the wrong answer you get if you ignore the rule. The bottom readout always reports the correct value a + b×c.", + "bullets": [ + "Multiplication and division come before addition and subtraction.", + "Grouping with parentheses can override the default order.", + "a + b×c ≠ (a+b)×c whenever b×c and (a+b)×c differ." + ], + "params": [ + { + "name": "a", + "label": "a", + "min": 0, + "max": 9, + "step": 1, + "value": 2 + }, + { + "name": "b", + "label": "b", + "min": 0, + "max": 9, + "step": 1, + "value": 3 + }, + { + "name": "c", + "label": "c", + "min": 0, + "max": 9, + "step": 1, + "value": 4 + } + ], + "code": "H.background();\nconst w = H.W, hh = H.H;\nconst a = Math.round(P.a), b = Math.round(P.b), c = Math.round(P.c);\n// PEMDAS worked example: a + b * c vs (a + b) * c\nconst step = Math.floor((t % 6) / 2); // 0,1,2 stepped reveal\nH.text(\"Order of operations: a + b × c\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Multiply BEFORE you add — grouping changes the answer.\", 24, 54, { color: H.colors.sub, size: 13 });\n// Left column: correct PEMDAS path\nconst bx = 60, by = 120, rowH = 56;\nH.text(\"a + b × c\", bx, by, { color: H.colors.ink, size: 20, weight: 700 });\nif (step >= 1) {\n H.text(\"= a + (\" + b + \" × \" + c + \")\", bx, by + rowH, { color: H.colors.accent, size: 18 });\n H.rect(bx + 38, by + rowH - 22, 86, 30, { stroke: H.colors.warn, width: 2, radius: 6 });\n}\nif (step >= 2) {\n H.text(\"= \" + a + \" + \" + (b * c), bx, by + rowH * 2, { color: H.colors.accent, size: 18 });\n H.text(\"= \" + (a + b * c), bx, by + rowH * 3, { color: H.colors.good, size: 22, weight: 700 });\n}\n// Right column: the WRONG left-to-right path, for contrast\nconst rx = w * 0.56;\nH.text(\"(forcing left-to-right is WRONG)\", rx, by - 26, { color: H.colors.sub, size: 12 });\nH.text(\"a + b × c\", rx, by, { color: H.colors.ink, size: 20, weight: 700 });\nconst wrongVal = (a + b) * c;\nconst rel = (a + b * c) === wrongVal ? \"= \" : \"≠ \";\nH.text(rel + \"(\" + a + \" + \" + b + \") × \" + c + \" = \" + wrongVal, rx, by + rowH, { color: H.colors.warn, size: 18 });\n// animated pointer that sweeps to the operation done first\nconst px = bx + 70 + 8 * Math.sin(t * 3);\nH.circle(px, by + rowH + 4 + (step >= 1 ? 0 : 30), 6, { fill: H.colors.warn });\nH.text(\"a = \" + a + \" b = \" + b + \" c = \" + c + \" → a + b×c = \" + (a + b * c), 24, hh - 28, { color: H.colors.sub, size: 14 });" + }, + { + "id": "a1-parallel-lines", + "area": "Algebra 1", + "topic": "Parallel line equations", + "title": "Parallel lines: same slope m", + "equation": "y = m*x + b1, y = m*x + b2 (same m)", + "keywords": [ + "parallel lines", + "parallel line equation", + "same slope", + "equal slopes", + "parallel slope", + "write parallel line", + "lines that never meet", + "two parallel lines", + "slope of parallel lines", + "y=mx+b parallel" + ], + "explanation": "Two lines are parallel exactly when they share the same slope m but have different y-intercepts. One m slider drives BOTH lines, so they tilt together and stay forever the same distance apart; the b1 and b2 sliders slide each line up or down independently. The two matching slope triangles travel in lockstep, making it obvious the lines rise at the identical rate and therefore never cross.", + "bullets": [ + "Parallel means identical slope m; only the intercepts differ.", + "Different intercepts (b1 ≠ b2) keep the lines apart so they never intersect.", + "Set b1 = b2 and the 'parallel' lines collapse into a single line." + ], + "params": [ + { + "name": "m", + "label": "shared slope m", + "min": -4, + "max": 4, + "step": 0.1, + "value": 1 + }, + { + "name": "b1", + "label": "intercept b1", + "min": -6, + "max": 6, + "step": 0.5, + "value": 3 + }, + { + "name": "b2", + "label": "intercept b2", + "min": -6, + "max": 6, + "step": 0.5, + "value": -2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst m = P.m, b1 = P.b1, b2 = P.b2;\nconst f1 = x => m * x + b1;\nconst f2 = x => m * x + b2;\nv.fn(f1, { color: H.colors.accent, width: 3 });\nv.fn(f2, { color: H.colors.accent2, width: 3 });\nv.dot(0, b1, { r: 6, fill: H.colors.accent });\nv.dot(0, b2, { r: 6, fill: H.colors.accent2 });\nconst xs = -4 + ((t * 0.8) % 8);\nv.line(xs, f1(xs), xs + 1, f1(xs), { color: H.colors.good, width: 2 });\nv.line(xs + 1, f1(xs), xs + 1, f1(xs + 1), { color: H.colors.violet, width: 2 });\nv.line(xs, f2(xs), xs + 1, f2(xs), { color: H.colors.good, width: 2 });\nv.line(xs + 1, f2(xs), xs + 1, f2(xs + 1), { color: H.colors.violet, width: 2 });\nv.dot(xs, f1(xs), { r: 5, fill: H.colors.accent });\nv.dot(xs, f2(xs), { r: 5, fill: H.colors.accent2 });\nconst gap = Math.abs(b2 - b1);\nH.text(\"Parallel lines: same slope m\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"slope m = \" + m.toFixed(2) + \" (identical) → lines never meet\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"y = \" + m.toFixed(2) + \"x + \" + b1.toFixed(1) + \" y = \" + m.toFixed(2) + \"x + \" + b2.toFixed(1) + (gap < 1e-6 ? \" (same line!)\" : \"\"), 24, 74, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"line 1 (b=\" + b1.toFixed(1) + \")\", color: H.colors.accent }, { label: \"line 2 (b=\" + b2.toFixed(1) + \")\", color: H.colors.accent2 }], H.W - 200, 28);" + }, + { + "id": "a1-percent-change-markup-discount", + "area": "Algebra 1", + "topic": "Percent change, markup, discount", + "title": "Percent change: new = base * (1 + p/100)", + "equation": "new = base * (1 + p/100)", + "keywords": [ + "percent change", + "percent increase", + "percent decrease", + "markup", + "discount", + "sale price", + "percentage", + "tax", + "tip", + "new price", + "percent of change", + "p percent" + ], + "explanation": "A percent change scales the original amount by the factor (1 + p/100): a positive p marks the value UP, a negative p applies a discount. The top blue bar is the base; the lower bar grows or shrinks to the new amount, and the dashed line marks where the original ended so you can see the change. Slide p and watch the new bar pass or fall short of that line, with the actual dollar change printed above.", + "bullets": [ + "Markup of p% multiplies by (1 + p/100); a discount uses a negative p.", + "The change equals base * p/100 — the gap between the two bars.", + "Two back-to-back changes multiply factors, they do NOT simply add." + ], + "params": [ + { + "name": "base", + "label": "original amount", + "min": 10, + "max": 100, + "step": 5, + "value": 50 + }, + { + "name": "pct", + "label": "percent change p (%)", + "min": -80, + "max": 80, + "step": 5, + "value": 20 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst base = Math.max(1, P.base);\nconst pct = P.pct;\nconst factor = 1 + pct / 100;\nconst result = base * factor;\nconst maxVal = Math.max(base, result, 1);\nconst x0 = 90, barW = w - 180;\nconst yOrig = h * 0.40, yNew = h * 0.62, bh = h * 0.13;\nconst wOrig = barW * (base / maxVal);\nconst grow = 0.5 + 0.5 * Math.sin(t * 0.8);\nconst wNew = barW * (result / maxVal) * (0.15 + 0.85 * grow);\nH.rect(x0, yOrig, wOrig, bh, { fill: H.colors.accent, radius: 6 });\nH.text(\"original \" + base.toFixed(0), x0 + 8, yOrig + bh * 0.62, { color: H.colors.bg, size: 14, weight: 700 });\nconst upColor = pct >= 0 ? H.colors.good : H.colors.warn;\nH.rect(x0, yNew, wNew, bh, { fill: upColor, radius: 6 });\nH.text(\"new \" + result.toFixed(2), x0 + 8, yNew + bh * 0.62, { color: H.colors.bg, size: 14, weight: 700 });\nH.line(x0 + wOrig, yOrig, x0 + wOrig, yNew + bh, { color: H.colors.violet, width: 2, dash: [5, 5] });\nH.text(\"Percent change, markup, discount\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst word = pct >= 0 ? \"markup / increase\" : \"discount / decrease\";\nH.text(\"new = base * (1 + p/100) p = \" + pct.toFixed(0) + \"% (\" + word + \")\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"change = \" + (result - base).toFixed(2), x0, h * 0.30, { color: upColor, size: 14, weight: 700 });" + }, + { + "id": "a1-perpendicular-line-equations", + "area": "Algebra 1", + "topic": "Perpendicular line equations", + "title": "Perpendicular lines: m2 = -1 / m1", + "equation": "y = m1*x + b and y = (-1/m1)*x + b", + "keywords": [ + "perpendicular", + "perpendicular lines", + "perpendicular line equation", + "negative reciprocal", + "opposite reciprocal", + "slope of perpendicular", + "right angle", + "90 degrees", + "m1 m2 = -1", + "perpendicular slope", + "normal line" + ], + "explanation": "Two lines meet at a right angle exactly when their slopes are negative reciprocals: flip one slope over and change its sign. The m slider sets the first line's steepness; the second line is drawn automatically with slope -1/m, so they always cross at 90 degrees. Watch the little corner square stay a perfect right angle no matter how you tilt m, and notice the product m1 * m2 in the readout pins to -1.", + "bullets": [ + "Perpendicular slopes are negative reciprocals: m2 = -1 / m1.", + "The slopes always multiply to -1 (m1 * m2 = -1).", + "A horizontal line (m = 0) is perpendicular to a vertical line (undefined slope)." + ], + "params": [ + { + "name": "m", + "label": "slope of line 1 m1", + "min": -4, + "max": 4, + "step": 0.1, + "value": 1 + }, + { + "name": "b", + "label": "shared intercept b", + "min": -6, + "max": 6, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst m = P.m, b = P.b;\nconst mp = Math.abs(m) > 1e-6 ? -1 / m : 1e6;\nv.fn(x => m * x + b, { color: H.colors.accent, width: 3 });\nv.fn(x => mp * x + b, { color: H.colors.accent2, width: 3 });\nv.dot(0, b, { r: 6, fill: H.colors.warn });\nconst s = 0.7;\nconst u1x = 1 / Math.sqrt(1 + m * m), u1y = m / Math.sqrt(1 + m * m);\nconst u2x = 1 / Math.sqrt(1 + mp * mp), u2y = mp / Math.sqrt(1 + mp * mp);\nv.path([[u1x * s, b + u1y * s], [u1x * s + u2x * s, b + u1y * s + u2y * s], [u2x * s, b + u2y * s]], { color: H.colors.good, width: 2 });\nconst xs = 5 * Math.sin(t * 0.6);\nv.dot(xs, m * xs + b, { r: 6, fill: H.colors.accent });\nv.dot(xs, mp * xs + b, { r: 6, fill: H.colors.accent2 });\nH.text(\"Perpendicular lines: m2 = -1 / m1\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"m1 = \" + m.toFixed(2) + \" m2 = \" + mp.toFixed(2) + \" m1 * m2 = \" + (m * mp).toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"y = m1 x + b\", color: H.colors.accent }, { label: \"y = (-1/m1) x + b\", color: H.colors.accent2 }], H.W - 200, 28);" + }, + { + "id": "a1-point-slope-form", + "area": "Algebra 1", + "topic": "Point-slope form", + "title": "Point-slope form: y − y1 = m(x − x1)", + "equation": "y - y1 = m*(x - x1)", + "keywords": [ + "point slope", + "point-slope form", + "point slope form", + "y-y1=m(x-x1)", + "line through a point", + "slope and a point", + "write equation from point and slope", + "linear equation", + "slope", + "point on a line" + ], + "explanation": "Point-slope form builds a line straight from one anchor point (x1, y1) and a slope m. The (x1, y1) sliders drag the red anchor anywhere on the grid; the slope slider tilts the line about that anchor like a see-saw. The dashed rise/run triangle on the moving orange dot shows m = rise over run holding at every position, which is exactly why m(x − x1) measures how far y has climbed from y1.", + "bullets": [ + "The line is pinned to the point (x1, y1); changing m rotates it about that point.", + "m(x − x1) is the rise accumulated as x moves away from x1.", + "Any point on the line plus its slope gives you the equation immediately." + ], + "params": [ + { + "name": "m", + "label": "slope m", + "min": -4, + "max": 4, + "step": 0.1, + "value": 1 + }, + { + "name": "x1", + "label": "point x1", + "min": -6, + "max": 6, + "step": 0.5, + "value": -2 + }, + { + "name": "y1", + "label": "point y1", + "min": -6, + "max": 6, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst m = P.m, x1 = P.x1, y1 = P.y1;\nconst f = x => m * (x - x1) + y1;\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.dot(x1, y1, { r: 7, fill: H.colors.warn });\nv.text(\"(x1, y1)\", x1 + 0.3, y1 + 1.1, { color: H.colors.warn, size: 13 });\nconst xs = x1 + 4 * Math.sin(t * 0.7);\nconst ys = f(xs);\nv.line(x1, y1, xs, y1, { color: H.colors.good, width: 2, dash: [5, 5] });\nv.line(xs, y1, xs, ys, { color: H.colors.violet, width: 2, dash: [5, 5] });\nv.dot(xs, ys, { r: 6, fill: H.colors.accent2 });\nconst run = xs - x1, rise = ys - y1;\nv.text(\"run = \" + run.toFixed(1), (x1 + xs) / 2 - 0.6, y1 - 0.4, { color: H.colors.good, size: 12 });\nv.text(\"rise = \" + rise.toFixed(1), xs + 0.3, (y1 + ys) / 2, { color: H.colors.violet, size: 12 });\nH.text(\"Point-slope: y − y1 = m(x − x1)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"through (\" + x1.toFixed(1) + \", \" + y1.toFixed(1) + \") slope m = \" + m.toFixed(2), 24, 52, { color: H.colors.sub, size: 14 });\nH.legend([{ label: \"rise\", color: H.colors.violet }, { label: \"run\", color: H.colors.good }], H.W - 150, 28);" + }, + { + "id": "a1-polynomial-add-multiply", + "area": "Algebra 1", + "topic": "Polynomial addition and multiplication", + "title": "Box method: (ax + b)(cx + d)", + "equation": "(a*x + b)(c*x + d) = a*c*x^2 + (a*d + b*c)*x + b*d", + "keywords": [ + "polynomial multiplication", + "foil", + "box method", + "area model", + "distributing", + "binomial", + "polynomial addition", + "combine like terms", + "multiply binomials", + "ax+b", + "expand", + "distributive property" + ], + "explanation": "Multiplying two binomials means multiplying every part of one by every part of the other — exactly the area of a rectangle split into four pieces. Each box is one product (a piece of the total area), and the highlight sweeps through them so you see all four. The two middle boxes are both x-terms, so they ADD into a single (ad + bc)x — that combining of like terms is the whole point. Drag a, b, c, d and watch every box and the final trinomial update.", + "bullets": [ + "Every term in one factor multiplies every term in the other (FOIL).", + "The four boxes are areas; their sum is the product.", + "The two x-terms are like terms and add: ad·x + bc·x = (ad+bc)x." + ], + "params": [ + { + "name": "a", + "label": "a (x coef, left)", + "min": 1, + "max": 5, + "step": 1, + "value": 2 + }, + { + "name": "b", + "label": "b (const, left)", + "min": -4, + "max": 5, + "step": 1, + "value": 3 + }, + { + "name": "c", + "label": "c (x coef, right)", + "min": 1, + "max": 5, + "step": 1, + "value": 1 + }, + { + "name": "d", + "label": "d (const, right)", + "min": -4, + "max": 5, + "step": 1, + "value": 4 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst a = Math.round(P.a), b = Math.round(P.b), c = Math.round(P.c), d = Math.round(P.d);\n// product (ax+b)(cx+d)\nconst t2 = a * c, t1 = a * d + b * c, t0 = b * d;\nH.text(\"Box method: (a·x + b)(c·x + d)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"(\" + a + \"x + \" + b + \")(\" + c + \"x + \" + d + \") = \" + t2 + \"x² + \" + t1 + \"x + \" + t0, 24, 52, { color: H.colors.sub, size: 14 });\n// 2x2 area grid; columns = (ax, b), rows = (cx, d)\nconst gx = 70, gy = 90, cw = 150, ch = 95;\nconst cells = [\n { r: 0, col: 0, txt: (a * c) + \"x²\", fill: H.colors.accent },\n { r: 0, col: 1, txt: (b * c) + \"x\", fill: H.colors.good },\n { r: 1, col: 0, txt: (a * d) + \"x\", fill: H.colors.good },\n { r: 1, col: 1, txt: t0 + \"\", fill: H.colors.accent2 },\n];\nconst lit = Math.floor((t * 1.2) % 4);\ncells.forEach((cl, i) => {\n const x = gx + cl.col * cw, y = gy + cl.r * ch;\n H.rect(x, y, cw, ch, { fill: i === lit ? H.colors.warn : cl.fill, stroke: H.colors.ink, width: 2, radius: 6 });\n H.text(cl.txt, x + cw * 0.5, y + ch * 0.5 + 7, { color: H.colors.bg, size: 22, weight: 700, align: \"center\" });\n});\n// column / row headers\nH.text(a + \"x\", gx + cw * 0.5, gy - 12, { color: H.colors.accent, size: 16, weight: 700, align: \"center\" });\nH.text(\"+\" + b, gx + cw * 1.5, gy - 12, { color: H.colors.accent, size: 16, weight: 700, align: \"center\" });\nH.text(c + \"x\", gx - 22, gy + ch * 0.5 + 6, { color: H.colors.accent2, size: 16, weight: 700, align: \"right\" });\nH.text(\"+\" + d, gx - 22, gy + ch * 1.5 + 6, { color: H.colors.accent2, size: 16, weight: 700, align: \"right\" });\n// combine like terms readout (the two x-terms add)\nH.text(\"Like terms add: \" + (b * c) + \"x + \" + (a * d) + \"x = \" + t1 + \"x\", gx, gy + 2 * ch + 34, { color: H.colors.good, size: 14, weight: 600 });\nH.text(\"Each box = area of one piece; sum the boxes = the product.\", gx, gy + 2 * ch + 58, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"x² term\", color: H.colors.accent }, { label: \"x terms\", color: H.colors.good }, { label: \"constant\", color: H.colors.accent2 }], w - 150, 90);" + }, + { + "id": "a1-proportions", + "area": "Algebra 1", + "topic": "Proportions", + "title": "Proportion: a/b = c/d", + "equation": "a / b = c / d (so d = b * c / a)", + "keywords": [ + "proportion", + "proportions", + "ratio", + "ratios", + "cross multiply", + "cross multiplication", + "equivalent ratios", + "a/b = c/d", + "unit rate", + "solve for d", + "scale", + "equal ratios" + ], + "explanation": "A proportion says two ratios are equal, so every matching pair (input, output) lands on the SAME straight line through the origin whose slope is the shared ratio b/a. The pink point is your known pair (a, b); slide a or b to set the ratio, and the line tilts. The green point shows the fourth value d = (b/a)*c that keeps c/d equal to a/b, which is exactly what cross-multiplying finds.", + "bullets": [ + "Equal ratios all sit on one line through the origin; its slope IS the ratio.", + "Cross-multiplying a/b = c/d gives a*d = b*c, so d = b*c/a.", + "Scaling both parts of a ratio by the same number leaves it unchanged." + ], + "params": [ + { + "name": "a", + "label": "a (top of ratio 1)", + "min": 1, + "max": 9, + "step": 1, + "value": 2 + }, + { + "name": "b", + "label": "b (bottom of ratio 1)", + "min": 1, + "max": 9, + "step": 1, + "value": 3 + }, + { + "name": "c", + "label": "c (top of ratio 2)", + "min": 1, + "max": 9, + "step": 1, + "value": 6 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: 0, yMax: 10 });\nv.grid(); v.axes();\nconst a = Math.max(0.1, P.a), b = Math.max(0.1, P.b), c = Math.max(0.1, P.c);\nconst k = b / a;\nconst d = k * c;\nv.fn(x => k * x, { color: H.colors.accent, width: 3 });\nv.dot(a, b, { r: 7, fill: H.colors.warn });\nv.dot(c, d, { r: 7, fill: H.colors.good });\nv.line(a, 0, a, b, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.line(0, b, a, b, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.line(c, 0, c, d, { color: H.colors.accent2, width: 1.5, dash: [4, 4] });\nv.line(0, d, c, d, { color: H.colors.accent2, width: 1.5, dash: [4, 4] });\nconst xs = 1 + 4 * (0.5 + 0.5 * Math.sin(t * 0.7));\nv.dot(xs, k * xs, { r: 6, fill: H.colors.yellow });\nH.text(\"Proportion: a / b = c / d\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(a.toFixed(1) + \" / \" + b.toFixed(1) + \" = \" + c.toFixed(1) + \" / \" + d.toFixed(2) + \" (ratio = \" + k.toFixed(2) + \")\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"known (a,b)\", color: H.colors.warn }, { label: \"solve d\", color: H.colors.good }], H.W - 170, 28);" + }, + { + "id": "a1-rate-time-distance", + "area": "Algebra 1", + "topic": "Rate-time-distance word problems", + "title": "Rate-time-distance: d = r * t", + "equation": "d = r * t", + "keywords": [ + "rate time distance", + "distance rate time", + "d = rt", + "d = r*t", + "speed", + "uniform motion", + "how far", + "miles per hour", + "travel word problem", + "average speed", + "two trains", + "distance formula" + ], + "explanation": "Every uniform-motion problem rests on d = r*t: distance equals rate times time. The car loops across the road so you can see the trip repeat, and at any moment its position is rate*time-so-far. On the graph below, distance versus time is a straight line whose STEEPNESS is the rate — a faster rate tilts the line up more sharply and the same trip covers more distance. Adjust the rate and time and watch both the total distance and the slope respond.", + "bullets": [ + "d = r*t: distance is rate multiplied by elapsed time.", + "On a distance-vs-time graph the SLOPE is the speed (rate).", + "Hold time fixed and double the rate to double the distance covered." + ], + "params": [ + { + "name": "rate", + "label": "rate r (mph)", + "min": 10, + "max": 75, + "step": 5, + "value": 55 + }, + { + "name": "time", + "label": "time t (hours)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 8 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst r = Math.max(1, P.rate);\nconst T = Math.max(0.5, P.time);\nconst dist = r * T;\nconst roadY = h * 0.28;\nconst xL = 60, xR = w - 60;\nH.line(xL, roadY, xR, roadY, { color: H.colors.axis, width: 3 });\nH.circle(xL, roadY, 6, { fill: H.colors.good });\nH.circle(xR, roadY, 6, { fill: H.colors.warn });\nH.text(\"start\", xL, roadY - 14, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(dist.toFixed(0) + \" mi\", xR, roadY - 14, { color: H.colors.sub, size: 12, align: \"center\" });\nconst frac = (t / T) % 1;\nconst carX = xL + frac * (xR - xL);\nH.rect(carX - 12, roadY - 9, 24, 12, { fill: H.colors.accent, radius: 3 });\nH.circle(carX - 6, roadY + 5, 3, { fill: H.colors.ink });\nH.circle(carX + 6, roadY + 5, 3, { fill: H.colors.ink });\nH.text((frac * dist).toFixed(0) + \" mi\", carX, roadY - 18, { color: H.colors.accent2, size: 11, align: \"center\" });\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: 0, yMax: 800, box: { x: 60, y: h * 0.44, w: w - 120, h: h * 0.46 } });\nv.grid(); v.axes();\nv.fn(x => r * x, { color: H.colors.accent, width: 3 });\nv.dot(frac * T, r * (frac * T), { r: 6, fill: H.colors.accent2 });\nv.dot(T, dist, { r: 6, fill: H.colors.warn });\nH.text(\"Distance = rate x time: d = r * t\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"r = \" + r.toFixed(0) + \" mph t = \" + T.toFixed(1) + \" h -> d = r*t = \" + dist.toFixed(0) + \" mi\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"d = r*t (slope = rate)\", color: H.colors.accent }], H.W - 230, h * 0.44 + 4);" + }, + { + "id": "a1-ratios-unit-rates", + "area": "Algebra 1", + "topic": "Ratios and unit rates", + "title": "Ratios & unit rate: y = (a/b)·x", + "equation": "y = (a/b)*x (unit rate = a/b per 1)", + "keywords": [ + "ratios", + "unit rate", + "rate", + "proportion", + "proportional", + "a to b", + "a:b", + "per one", + "constant of proportionality", + "miles per hour", + "price per unit", + "rate of change" + ], + "explanation": "A ratio a : b means for every b of one quantity you get a of the other, and every equivalent ratio lies on the SAME straight ray through the origin. The unit rate is what you get for exactly 1 — the height of the line at x = 1, drawn here as the green-then-violet step. Slide a and b to change the ratio and watch the ray tilt and the unit rate update; the dot riding the ray shows that doubling x always doubles y because the rate is constant.", + "bullets": [ + "A ratio a : b graphs as a line through the origin: y = (a/b)·x.", + "The unit rate a/b is the y-value at x = 1 — the 'per one' amount.", + "Equivalent ratios (2:4, 3:6, ...) all sit on the same ray." + ], + "params": [ + { + "name": "a", + "label": "y-amount a", + "min": 1, + "max": 10, + "step": 0.5, + "value": 6 + }, + { + "name": "b", + "label": "x-amount b", + "min": 1, + "max": 6, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 8, yMin: 0, yMax: 12 });\nv.grid(); v.axes();\nconst a = P.a, b = Math.abs(P.b) < 1e-6 ? 1 : P.b; // ratio a : b (b units of x -> a of y)\nconst rate = a / b; // unit rate: y per 1 x\n// proportional line through origin\nv.fn(x => rate * x, { color: H.colors.accent, width: 3 });\n// mark the given ratio point (b, a)\nv.dot(b, a, { r: 6, fill: H.colors.accent2 });\nv.text(\"ratio \" + a.toFixed(1) + \" : \" + b.toFixed(1), b + 0.15, a, { color: H.colors.accent2, size: 12, align: \"left\", baseline: \"middle\" });\n// unit-rate staircase at x = 1\nv.line(0, 0, 1, 0, { color: H.colors.good, width: 2, dash: [5, 4] });\nv.line(1, 0, 1, rate, { color: H.colors.violet, width: 2, dash: [5, 4] });\nv.dot(1, rate, { r: 6, fill: H.colors.warn });\nv.text(\"1\", 0.5, -0.55, { color: H.colors.good, size: 12, align: \"center\", baseline: \"middle\" });\nv.text(\"rate = \" + rate.toFixed(2), 1.15, rate / 2, { color: H.colors.violet, size: 12, align: \"left\", baseline: \"middle\" });\n// animated dot riding the ray, bounded loop\nconst xs = 3.5 + 3.5 * Math.sin(t * 0.7);\nv.dot(xs, rate * xs, { r: 6, fill: H.colors.warn });\nH.text(\"Ratios & unit rate: y = (a/b)·x\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a = \" + a.toFixed(1) + \" b = \" + b.toFixed(1) + \" → unit rate = \" + rate.toFixed(2) + \" per 1\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"1 unit\", color: H.colors.good }, { label: \"rate\", color: H.colors.violet }], H.W - 160, 28);" + }, + { + "id": "a1-real-number-properties", + "area": "Algebra 1", + "topic": "Real-number properties", + "title": "Commutative property: a + b = b + a", + "equation": "a + b = b + a", + "keywords": [ + "real number properties", + "commutative property", + "associative property", + "order of operations does not matter", + "a+b=b+a", + "properties of addition", + "reorder terms", + "identity property", + "regroup", + "number properties", + "commute" + ], + "explanation": "Two tape bars hold the same pieces a and b in different orders. The top bar is a then b; the bottom bar swaps them to b then a — yet both reach the exact same right edge, which is why a + b = b + a (the commutative property). The dashed green line marks that shared total and the twin sweeping dots prove both rows fill to the same length. Slide a and b to change the pieces, and use the step slider to flip the order so you can watch the total stay put.", + "bullets": [ + "Commutative: swapping the order of addends never changes the sum.", + "The two tapes hold the same pieces, so they end at the same total.", + "Properties like this let you reorder and regroup to compute easily." + ], + "params": [ + { + "name": "a", + "label": "piece a", + "min": 1, + "max": 6, + "step": 0.5, + "value": 4 + }, + { + "name": "b", + "label": "piece b", + "min": 1, + "max": 6, + "step": 0.5, + "value": 3 + }, + { + "name": "step", + "label": "order: a+b / b+a", + "min": 0, + "max": 1, + "step": 1, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 12, yMin: 0, yMax: 6 });\nv.grid();\nconst a = Math.abs(P.a), b = Math.abs(P.b);\nconst step = Math.round(P.step) % 2; // 0 = commutative, 1 = associative-ish reorder\nconst total = a + b;\n// Two tape rows; row 1 = a then b, row 2 = b then a. Same total length.\n// animate a sweep marker proving both totals reach the same point.\nconst sweep = (Math.sin(t * 0.9) * 0.5 + 0.5) * total;\n// row 1 (y=4): a (accent) then b (accent2)\nv.rect(0.5, 3.6, a, 0.9, { fill: H.colors.accent, stroke: H.colors.ink, width: 1.5 });\nv.rect(0.5 + a, 3.6, b, 0.9, { fill: H.colors.accent2, stroke: H.colors.ink, width: 1.5 });\nv.text(\"a + b\", 0.5 + total / 2, 4.05, { color: H.colors.bg, size: 13, align: \"center\", baseline: \"middle\", weight: 700 });\n// row 2 (y=2): b then a (order swapped)\nconst firstB = step === 0 ? b : a;\nconst firstColor = step === 0 ? H.colors.accent2 : H.colors.accent;\nconst secondLen = step === 0 ? a : b;\nconst secondColor = step === 0 ? H.colors.accent : H.colors.accent2;\nv.rect(0.5, 1.6, firstB, 0.9, { fill: firstColor, stroke: H.colors.ink, width: 1.5 });\nv.rect(0.5 + firstB, 1.6, secondLen, 0.9, { fill: secondColor, stroke: H.colors.ink, width: 1.5 });\nv.text(step === 0 ? \"b + a\" : \"a + b\", 0.5 + total / 2, 2.05, { color: H.colors.bg, size: 13, align: \"center\", baseline: \"middle\", weight: 700 });\n// the equality marker: same right edge\nv.line(0.5 + total, 1.2, 0.5 + total, 4.9, { color: H.colors.good, width: 2, dash: [5, 4] });\nv.text(\"= \" + total.toFixed(1), 0.5 + total + 0.2, 3.0, { color: H.colors.good, size: 13, align: \"left\", baseline: \"middle\" });\n// sweeping proof dot along both rows\nv.dot(0.5 + sweep, 4.05, { r: 5, fill: H.colors.warn });\nv.dot(0.5 + sweep, 2.05, { r: 5, fill: H.colors.warn });\nH.text(step === 0 ? \"Commutative: a + b = b + a\" : \"Same total, regrouped — order/grouping never change the sum\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"a = \" + a.toFixed(1) + \" b = \" + b.toFixed(1) + \" → a + b = b + a = \" + total.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"a\", color: H.colors.accent }, { label: \"b\", color: H.colors.accent2 }], H.W - 130, 28);" + }, + { + "id": "a1-rearranging-formulas", + "area": "Algebra 1", + "topic": "Rearranging formulas", + "title": "Rearranging formulas: F = (9/5)C + 32", + "equation": "F = (9/5)*C + 32 <-> C = (5/9)*(F - 32)", + "keywords": [ + "rearranging formulas", + "rearrange formula", + "solve for variable", + "celsius to fahrenheit", + "f = 9/5 c + 32", + "c = 5/9 (f-32)", + "inverse formula", + "temperature conversion", + "isolate variable", + "change subject of formula", + "convert formula", + "undo operations" + ], + "explanation": "Rearranging a formula means undoing its operations in reverse order to isolate a different variable. The line F = (9/5)C + 32 turns any Celsius reading into Fahrenheit; solving it backwards (subtract 32, then multiply by 5/9) gives C = (5/9)(F - 32). Slide the Celsius value: read UP the violet line to get F, then read ACROSS the green line back to C. The same point on the line serves both directions, which is exactly why one formula and its rearrangement describe the same relationship.", + "bullets": [ + "To rearrange, undo operations in reverse: subtract 32 first, then divide by 9/5.", + "The forward and inverse formulas are the SAME line read two different ways.", + "Multiplying by 9/5 vs 5/9 are inverse steps that cancel each other out." + ], + "params": [ + { + "name": "celsius", + "label": "Celsius C", + "min": -40, + "max": 100, + "step": 1, + "value": 20 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -50, xMax: 110, yMin: -60, yMax: 230 });\nv.grid(); v.axes();\nconst C = P.celsius;\nconst fOf = (c) => c * 9 / 5 + 32;\nv.fn(c => fOf(c), { color: H.colors.accent, width: 3 });\nconst F = fOf(C);\nv.line(C, -60, C, F, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.line(-50, F, C, F, { color: H.colors.good, width: 1.5, dash: [4, 4] });\nv.dot(C, F, { r: 7, fill: H.colors.warn });\nconst sweep = 80 * Math.sin(t * 0.5) + 20;\nv.dot(sweep, fOf(sweep), { r: 5, fill: H.colors.accent2 });\nH.text(\"Rearrange: F = (9/5)C + 32 <-> C = (5/9)(F - 32)\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"read up: C = \" + C.toFixed(1) + \" deg -> F = \" + F.toFixed(1) + \" deg | read across: F = \" + F.toFixed(1) + \" -> C = \" + ((F - 32) * 5 / 9).toFixed(1), 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"C in (up)\", color: H.colors.violet }, { label: \"F out (across)\", color: H.colors.good }], H.W - 180, 28);" + }, + { + "id": "a1-scale-factors-similar-figures", + "area": "Algebra 1", + "topic": "Scale factors and similar figures", + "title": "Scale factor: new = k * original", + "equation": "new length = k * original length (area scales by k^2)", + "keywords": [ + "scale factor", + "similar figures", + "similar triangles", + "dilation", + "enlargement", + "reduction", + "proportional", + "corresponding sides", + "ratio of sides", + "scale drawing", + "k times", + "similarity" + ], + "explanation": "Similar figures have the same shape but a different size: every length of the original is multiplied by the SAME scale factor k. The blue triangle is the original; the orange triangle is its dilation by k, so corresponding sides stay in the ratio k (the pulsing corners mark a matching pair). Notice the readout: lengths grow by k but the AREA grows by k squared, which is why doubling a figure quadruples its area.", + "bullets": [ + "Every corresponding length is multiplied by the scale factor k.", + "k > 1 enlarges, k < 1 shrinks, k = 1 is congruent (same size).", + "Lengths scale by k but area scales by k^2 (and volume by k^3)." + ], + "params": [ + { + "name": "w", + "label": "base width", + "min": 1, + "max": 4, + "step": 0.5, + "value": 3 + }, + { + "name": "h", + "label": "base height", + "min": 1, + "max": 3, + "step": 0.5, + "value": 2 + }, + { + "name": "k", + "label": "scale factor k", + "min": 0.5, + "max": 3, + "step": 0.25, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 20, yMin: -1, yMax: 13 });\nv.grid(); v.axes();\nconst bw = Math.max(0.5, P.w), bh = Math.max(0.5, P.h);\nconst k = Math.max(0.1, P.k);\nconst o = [[0, 0], [bw, 0], [0, bh]];\nv.path(o.concat([o[0]]), { color: H.colors.accent, width: 3, close: true });\nv.dot(0, 0, { r: 4, fill: H.colors.accent });\nconst ox = bw + 1.5;\nconst s = [[ox, 0], [ox + bw * k, 0], [ox, bh * k]];\nv.path(s.concat([s[0]]), { color: H.colors.accent2, width: 3, close: true });\nv.dot(ox, 0, { r: 4, fill: H.colors.accent2 });\nconst ph = 0.5 + 0.5 * Math.sin(t * 1.2);\nv.dot(bw, 0, { r: 4 + 3 * ph, fill: H.colors.warn });\nv.dot(ox + bw * k, 0, { r: 4 + 3 * ph, fill: H.colors.warn });\nH.text(\"Scale factor k: new = k * original\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"k = \" + k.toFixed(2) + \" base \" + bw.toFixed(1) + \"x\" + bh.toFixed(1) + \" -> \" + (bw * k).toFixed(2) + \"x\" + (bh * k).toFixed(2) + \" area x\" + (k * k).toFixed(2), 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"original\", color: H.colors.accent }, { label: \"scaled (k)\", color: H.colors.accent2 }], H.W - 160, 28);" + }, + { + "id": "a1-scientific-notation", + "area": "Algebra 1", + "topic": "Scientific notation", + "title": "Scientific notation: value = m × 10^n", + "equation": "value = m * 10^n", + "keywords": [ + "scientific notation", + "powers of ten", + "m times 10 to the n", + "standard form", + "mantissa", + "exponent", + "move the decimal", + "very large numbers", + "very small numbers", + "10^n", + "decimal point", + "magnitude" + ], + "explanation": "Scientific notation splits a number into a mantissa m (between 1 and 10) and a power of ten that fixes its size. The exponent n is a shortcut for moving the decimal point: positive n slides it right (bigger), negative n slides it left (smaller). Drag n and watch the marker hop along the powers-of-ten number line while the full decimal value rewrites itself, so you see that 10^n only changes the SCALE, never the digits in m.", + "bullets": [ + "m holds the significant digits; 10^n sets the magnitude.", + "n > 0 moves the decimal right (large); n < 0 moves it left (small).", + "Each step of n is exactly one factor of 10 — one decimal place." + ], + "params": [ + { + "name": "mantissa", + "label": "mantissa m", + "min": 1, + "max": 9.9, + "step": 0.1, + "value": 4.2 + }, + { + "name": "exp", + "label": "exponent n", + "min": -6, + "max": 9, + "step": 1, + "value": 3 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst mant = P.mantissa;\nconst exp = Math.round(P.exp);\nconst value = mant * Math.pow(10, exp);\nH.text(\"Scientific notation: value = m × 10^n\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"m = \" + mant.toFixed(2) + \", n = \" + exp + \" → \" + mant.toFixed(2) + \" × 10^\" + exp, 24, 52, { color: H.colors.sub, size: 13 });\n// Build the expanded decimal string by literally shifting the point n places.\nconst sign = value < 0 ? \"-\" : \"\";\nconst av = Math.abs(value);\nlet expanded;\nif (av === 0) {\n expanded = \"0\";\n} else if (av >= 1e-6 && av < 1e12) {\n // round to kill float fuzz, then trim trailing zeros\n let s = av.toFixed(Math.max(0, 6 - exp));\n if (s.indexOf(\".\") >= 0) s = s.replace(/0+$/, \"\").replace(/\\.$/, \"\");\n expanded = sign + s;\n} else {\n expanded = sign + av.toExponential(3);\n}\n// big readout of the actual number, centered\nH.text(\"= \" + expanded, w * 0.5, h * 0.32, { color: H.colors.good, size: 30, weight: 700, align: \"center\" });\n// number line of powers of ten with a sliding marker that pulses on 10^n\nconst lineY = h * 0.62, x0 = 70, x1 = w - 70;\nH.line(x0, lineY, x1, lineY, { color: H.colors.axis, width: 2 });\nconst lo = -6, hi = 9;\nfor (let p = lo; p <= hi; p++) {\n const px = H.map(p, lo, hi, x0, x1);\n H.line(px, lineY - 7, px, lineY + 7, { color: H.colors.grid, width: 1.5 });\n H.text(\"10^\" + p, px, lineY + 26, { color: p === exp ? H.colors.warn : H.colors.sub, size: 11, align: \"center\" });\n}\nconst mx = H.map(H.clamp(exp, lo, hi), lo, hi, x0, x1);\nconst pulse = 7 + 2.5 * Math.sin(t * 4);\nH.circle(mx, lineY, pulse, { fill: H.colors.warn, stroke: H.colors.bg, width: 2 });\nH.arrow(mx, lineY - 40, mx, lineY - 12, { color: H.colors.warn, width: 2 });\n// animated \"place value\" sweep: a dot travels along the digit positions\nconst places = Math.abs(exp) + 1;\nconst slide = (Math.sin(t * 1.2) * 0.5 + 0.5);\nconst sx = H.lerp(w * 0.5 - 80, w * 0.5 + 80, slide);\nH.text(exp >= 0 ? \"× 10^\" + exp + \" → move point \" + exp + \" places RIGHT\" : \"× 10^\" + exp + \" → move point \" + (-exp) + \" places LEFT\", w * 0.5, h * 0.42, { color: H.colors.accent, size: 14, align: \"center\" });\nH.circle(sx, h * 0.42 + 14, 4, { fill: H.colors.accent2 });\nH.legend([{ label: \"exponent n\", color: H.colors.warn }, { label: \"decimal value\", color: H.colors.good }], 24, h - 60);" + }, + { + "id": "a1-slope-from-a-graph", + "area": "Algebra 1", + "topic": "Slope from a graph", + "title": "Slope from two points: m = rise / run", + "equation": "m = (y2 - y1) / (x2 - x1) = rise / run", + "keywords": [ + "slope from a graph", + "slope", + "rise over run", + "rise run", + "two points", + "steepness", + "gradient", + "m=(y2-y1)/(x2-x1)", + "delta y over delta x", + "graph a line", + "steep", + "undefined slope" + ], + "explanation": "To read slope off a graph, pick two points on the line and build the right triangle between them: the horizontal leg is the run (change in x) and the vertical leg is the rise (change in y). Slope m = rise / run, so steeper lines have a bigger rise per unit of run. Drag the two points: lift the right one and the rise grows (steeper); when the run shrinks to zero the line is vertical and the slope is undefined because you can't divide by 0.", + "bullets": [ + "Slope = rise / run = (y2 − y1) / (x2 − x1) between any two points on the line.", + "Up-to-the-right is positive slope; down-to-the-right is negative.", + "Run = 0 (a vertical line) makes slope undefined; rise = 0 makes slope 0 (horizontal)." + ], + "params": [ + { + "name": "x1", + "label": "point 1 x", + "min": -7, + "max": 7, + "step": 1, + "value": -3 + }, + { + "name": "y1", + "label": "point 1 y", + "min": -7, + "max": 7, + "step": 1, + "value": -2 + }, + { + "name": "x2", + "label": "point 2 x", + "min": -7, + "max": 7, + "step": 1, + "value": 4 + }, + { + "name": "y2", + "label": "point 2 y", + "min": -7, + "max": 7, + "step": 1, + "value": 3 + } + ], + "code": "H.background();\n// Slope from a graph: pick two points (x1,y1) and (x2,y2) on a line.\n// slope m = rise / run = (y2 - y1) / (x2 - x1). Show the rise/run triangle.\nconst x1 = P.x1, y1 = P.y1, x2 = P.x2, y2 = P.y2;\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst run = x2 - x1, rise = y2 - y1;\nconst denomOk = Math.abs(run) > 1e-9;\nconst m = denomOk ? rise / run : NaN;\n// the full line through the two points\nif (denomOk) v.fn(x => y1 + m * (x - x1), { color: H.colors.accent, width: 3 });\nelse v.line(x1, -8, x1, 8, { color: H.colors.accent, width: 3 }); // vertical: undefined slope\n// rise/run right triangle: horizontal leg then vertical leg\nv.line(x1, y1, x2, y1, { color: H.colors.good, width: 3 }); // run\nv.line(x2, y1, x2, y2, { color: H.colors.violet, width: 3 }); // rise\nv.text(\"run = \" + run.toFixed(1), (x1 + x2) / 2, y1 - 0.6, { color: H.colors.good, size: 12, align: \"center\" });\nv.text(\"rise = \" + rise.toFixed(1), x2 + 0.4, (y1 + y2) / 2, { color: H.colors.violet, size: 12 });\n// the two chosen points\nv.dot(x1, y1, { r: 6, fill: H.colors.accent2 });\nv.dot(x2, y2, { r: 6, fill: H.colors.accent2 });\nv.text(\"(\" + x1.toFixed(1) + \", \" + y1.toFixed(1) + \")\", x1, y1 + 0.8, { color: H.colors.sub, size: 11, align: \"center\" });\nv.text(\"(\" + x2.toFixed(1) + \", \" + y2.toFixed(1) + \")\", x2, y2 + 0.8, { color: H.colors.sub, size: 11, align: \"center\" });\n// animated dot riding the line between the two points (loops back and forth)\nconst fr = Math.sin(t * 0.8) * 0.5 + 0.5; // 0..1\nconst xr = x1 + run * fr;\nconst yr = denomOk ? (y1 + m * (xr - x1)) : (y1 + rise * fr);\nv.dot(xr, yr, { r: 6 + Math.sin(t * 4) * 0.5, fill: H.colors.warn });\nH.text(\"Slope from a graph: m = rise / run\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst mText = denomOk ? (\"m = \" + rise.toFixed(1) + \" / \" + run.toFixed(1) + \" = \" + m.toFixed(2)) : \"run = 0 → slope undefined (vertical)\";\nH.text(mText, 24, 52, { color: denomOk ? H.colors.sub : H.colors.warn, size: 14 });\nH.legend([{ label: \"run (Δx)\", color: H.colors.good }, { label: \"rise (Δy)\", color: H.colors.violet }, { label: \"the line\", color: H.colors.accent }], H.W - 170, 80);" + }, + { + "id": "a1-slope-from-a-table", + "area": "Algebra 1", + "topic": "Slope from a table", + "title": "Slope from a table: m = Δy / Δx", + "equation": "m = (change in y) / (change in x)", + "keywords": [ + "slope from table", + "table", + "rate of change", + "delta y over delta x", + "change in y over change in x", + "constant difference", + "linear table", + "find slope from a table", + "x y table", + "step in x step in y", + "rise over run table", + "consecutive rows" + ], + "explanation": "When a line is given as a table of (x, y) pairs, the slope is the change in y divided by the change in x between any two rows. The sliders build the table from a steady rule: each row steps x by Δx and y climbs by m·Δx. Watch the highlight sweep from row to row -- the green Δx and violet Δy change rows but their ratio (the slope) stays exactly the same, which is what makes the table linear.", + "bullets": [ + "Slope = Δy / Δx between any two rows of the table.", + "In a linear table every equal step in x gives the SAME step in y.", + "Pick any pair of rows -- the slope you compute is identical." + ], + "params": [ + { + "name": "x0", + "label": "first x", + "min": -4, + "max": 2, + "step": 0.5, + "value": -2 + }, + { + "name": "dx", + "label": "step in x Δx", + "min": 0.5, + "max": 3, + "step": 0.5, + "value": 1 + }, + { + "name": "m", + "label": "slope m", + "min": -3, + "max": 3, + "step": 0.5, + "value": 2 + }, + { + "name": "b", + "label": "start value b", + "min": -4, + "max": 4, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst x0 = P.x0, dx = Math.max(0.5, P.dx), b = P.b, m = P.m;\nconst rows = 5;\nconst xs = [], ys = [];\nfor (let i = 0; i < rows; i++) { const xv = x0 + i * dx; xs.push(xv); ys.push(m * xv + b); }\nconst hi = Math.min(rows - 2, Math.floor(t % (rows - 1)));\nconst hiNext = hi + 1;\nconst tx = 40, ty = 110, rh = 46, cw = 110;\nH.rect(tx, ty - rh, cw * 2, rh, { fill: H.colors.panel, stroke: H.colors.grid, width: 1 });\nH.text(\"x\", tx + cw * 0.5, ty - rh * 0.35, { color: H.colors.ink, size: 16, weight: 700, align: \"center\" });\nH.text(\"y\", tx + cw * 1.5, ty - rh * 0.35, { color: H.colors.ink, size: 16, weight: 700, align: \"center\" });\nfor (let i = 0; i < rows; i++) {\n const yy = ty + i * rh;\n const on = (i === hi || i === hiNext);\n H.rect(tx, yy, cw * 2, rh, { fill: on ? \"#23304f\" : H.colors.bg, stroke: H.colors.grid, width: 1 });\n H.text(xs[i].toFixed(1), tx + cw * 0.5, yy + rh * 0.62, { color: H.colors.accent, size: 15, align: \"center\" });\n H.text(ys[i].toFixed(1), tx + cw * 1.5, yy + rh * 0.62, { color: H.colors.accent2, size: 15, align: \"center\" });\n}\nH.arrow(tx + cw * 2 + 14, ty + hi * rh + rh * 0.5, tx + cw * 2 + 14, ty + hiNext * rh + rh * 0.5, { color: H.colors.good, width: 2, head: 7 });\nH.text(\"Δx = \" + dx.toFixed(1), tx + cw * 2 + 26, ty + hi * rh + rh * 0.55, { color: H.colors.good, size: 13 });\nH.text(\"Δy = \" + (ys[hiNext] - ys[hi]).toFixed(1), tx + cw * 2 + 26, ty + hi * rh + rh * 1.0, { color: H.colors.violet, size: 13 });\nH.text(\"Slope from a table\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"slope m = Δy / Δx = \" + ((ys[hiNext] - ys[hi]) / dx).toFixed(2) + \" (same for every row)\", 24, 52, { color: H.colors.sub, size: 13 });" + }, + { + "id": "a1-slope-from-an-equation", + "area": "Algebra 1", + "topic": "Slope from an equation", + "title": "Slope from an equation: y = mx + b", + "equation": "y = m*x + b", + "keywords": [ + "slope from equation", + "y=mx+b", + "coefficient of x", + "slope from y=mx+b", + "read the slope", + "linear equation slope", + "rise over run", + "steepness", + "gradient", + "identify slope", + "mx+b", + "slope coefficient" + ], + "explanation": "In the form y = mx + b, the slope is simply the number multiplying x -- you can read it straight off the equation with no graph needed. Slide m and the line tilts; the green run of 1 and violet rise of m form a triangle that proves the slope. The b slider slides the line up and down but never changes how steep it is, so the slope readout only follows m.", + "bullets": [ + "In y = mx + b the slope is m, the coefficient of x.", + "Stepping 1 to the right makes the line rise by exactly m.", + "Changing b moves the line up/down but leaves the slope unchanged." + ], + "params": [ + { + "name": "m", + "label": "slope m", + "min": -4, + "max": 4, + "step": 0.1, + "value": 1.5 + }, + { + "name": "b", + "label": "y-intercept b", + "min": -6, + "max": 6, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst m = P.m, b = P.b;\nv.fn(x => m * x + b, { color: H.colors.accent, width: 3 });\nv.line(0, b, 1, b, { color: H.colors.good, width: 2.5 });\nv.line(1, b, 1, b + m, { color: H.colors.violet, width: 2.5 });\nv.text(\"run = 1\", 0.5, b - 0.5, { color: H.colors.good, size: 12, align: \"center\" });\nv.text(\"rise = \" + m.toFixed(1), 1.25, b + m / 2, { color: H.colors.violet, size: 12 });\nv.dot(0, b, { r: 6, fill: H.colors.warn });\nconst xs = 6 * Math.sin(t * 0.6);\nv.dot(xs, m * xs + b, { r: 6, fill: H.colors.accent2 });\nH.text(\"Slope from an equation\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"y = \" + m.toFixed(2) + \"x + \" + b.toFixed(2) + \" -> slope m = \" + m.toFixed(2) + \" (coef of x)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"rise = m\", color: H.colors.violet }, { label: \"run = 1\", color: H.colors.good }], H.W - 160, 28);" + }, + { + "id": "a1-slope-from-two-points", + "area": "Algebra 1", + "topic": "Slope from two points", + "title": "Slope from two points: m = (y2 - y1)/(x2 - x1)", + "equation": "m = (y2 - y1) / (x2 - x1)", + "keywords": [ + "slope", + "two points", + "slope formula", + "rise over run", + "rise run", + "change in y over change in x", + "gradient", + "steepness", + "m = (y2-y1)/(x2-x1)", + "find slope from points", + "line through two points", + "delta y over delta x" + ], + "explanation": "Slope measures how steep a line is: how much y changes for each step in x. Drag the two points and watch the green run (horizontal change x2 - x1) and the violet rise (vertical change y2 - y1) update. The slope m is just rise divided by run, and a moving dot travels the line so you can see it stay constant the whole way.", + "bullets": [ + "Slope m = rise / run = (y2 - y1) / (x2 - x1).", + "The run is the horizontal change; the rise is the vertical change.", + "A vertical line (x1 = x2) has run 0, so its slope is undefined." + ], + "params": [ + { + "name": "x1", + "label": "point 1 x1", + "min": -7, + "max": 0, + "step": 0.5, + "value": -4 + }, + { + "name": "y1", + "label": "point 1 y1", + "min": -7, + "max": 7, + "step": 0.5, + "value": -2 + }, + { + "name": "x2", + "label": "point 2 x2", + "min": 0.5, + "max": 7, + "step": 0.5, + "value": 4 + }, + { + "name": "y2", + "label": "point 2 y2", + "min": -7, + "max": 7, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst x1 = P.x1, y1 = P.y1, x2 = P.x2, y2 = P.y2;\nconst dx = x2 - x1, dy = y2 - y1;\nconst m = Math.abs(dx) > 1e-9 ? dy / dx : NaN;\nif (Number.isFinite(m)) {\n v.fn(x => m * (x - x1) + y1, { color: H.colors.accent, width: 3 });\n}\nv.line(x1, y1, x2, y1, { color: H.colors.good, width: 2, dash: [5, 5] });\nv.line(x2, y1, x2, y2, { color: H.colors.violet, width: 2, dash: [5, 5] });\nv.text(\"run = \" + dx.toFixed(1), (x1 + x2) / 2, y1 - 0.6, { color: H.colors.good, size: 12, align: \"center\" });\nv.text(\"rise = \" + dy.toFixed(1), x2 + 0.3, (y1 + y2) / 2, { color: H.colors.violet, size: 12 });\nv.dot(x1, y1, { r: 6, fill: H.colors.accent2 });\nv.dot(x2, y2, { r: 6, fill: H.colors.accent2 });\nconst s = (Math.sin(t * 0.8) + 1) / 2;\nconst px = x1 + s * dx, py = y1 + s * dy;\nif (Number.isFinite(m)) v.dot(px, py, { r: 6, fill: H.colors.warn });\nH.text(\"Slope from two points\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"(\" + x1.toFixed(1) + \", \" + y1.toFixed(1) + \") to (\" + x2.toFixed(1) + \", \" + y2.toFixed(1) + \") m = \" + (Number.isFinite(m) ? m.toFixed(2) : \"undefined (vertical)\"), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"rise\", color: H.colors.violet }, { label: \"run\", color: H.colors.good }], H.W - 150, 28);" + }, + { + "id": "a1-slope-intercept-form", + "area": "Algebra 1", + "topic": "Slope-intercept form", + "title": "Slope-intercept form: y = mx + b", + "equation": "y = m*x + b", + "keywords": [ + "slope intercept form", + "y=mx+b", + "slope intercept", + "mx+b", + "graph a line", + "slope and intercept", + "linear function", + "straight line", + "m and b", + "write equation of a line", + "rise over run", + "y intercept" + ], + "explanation": "Slope-intercept form packs everything you need to graph a line into two numbers. b tells you where to START -- the point where the line crosses the y-axis (the pink dot). m tells you which way to GO -- for every run to the right the line rises by m, shown by the green/violet triangle. Slide m to tilt the line and b to lift the whole thing up or down.", + "bullets": [ + "b is the y-intercept: the line's height where x = 0.", + "m is the slope: rise over run, the tilt of the line.", + "Start at (0, b), then use the slope to step out the rest of the line." + ], + "params": [ + { + "name": "m", + "label": "slope m", + "min": -4, + "max": 4, + "step": 0.1, + "value": 1 + }, + { + "name": "b", + "label": "y-intercept b", + "min": -6, + "max": 6, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst m = P.m, b = P.b;\nv.fn(x => m * x + b, { color: H.colors.accent, width: 3 });\nv.dot(0, b, { r: 7, fill: H.colors.warn });\nv.text(\"b = \" + b.toFixed(1), 0.3, b + 0.7, { color: H.colors.warn, size: 12 });\nv.line(0, b, 2, b, { color: H.colors.good, width: 2.5, dash: [5, 5] });\nv.line(2, b, 2, m * 2 + b, { color: H.colors.violet, width: 2.5, dash: [5, 5] });\nv.text(\"run = 2\", 1, b - 0.5, { color: H.colors.good, size: 12, align: \"center\" });\nv.text(\"rise = \" + (2 * m).toFixed(1), 2.25, b + m, { color: H.colors.violet, size: 12 });\nconst xs = 6 * Math.sin(t * 0.6);\nv.dot(xs, m * xs + b, { r: 6, fill: H.colors.accent2 });\nH.text(\"Slope-intercept form: y = mx + b\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"m = \" + m.toFixed(2) + \" (steepness) b = \" + b.toFixed(2) + \" (y-axis crossing)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"rise = m·run\", color: H.colors.violet }, { label: \"run\", color: H.colors.good }], H.W - 175, 28);" + }, + { + "id": "a1-standard-form-line", + "area": "Algebra 1", + "topic": "Standard form of a line", + "title": "Standard form: A·x + B·y = C", + "equation": "A*x + B*y = C", + "keywords": [ + "standard form", + "standard form of a line", + "ax+by=c", + "general form", + "x-intercept", + "y-intercept", + "intercepts of a line", + "linear equation standard form", + "convert to standard form", + "slope from standard form" + ], + "explanation": "Standard form Ax + By = C hides the slope but makes the intercepts easy. Setting y = 0 gives the x-intercept at C/A (green), and setting x = 0 gives the y-intercept at C/B (red) — slide A, B, C and watch both intercept dots slide along the axes. The slope is always −A/B, shown live, so you can read steepness right off the coefficients.", + "bullets": [ + "x-intercept is C/A (let y = 0); y-intercept is C/B (let x = 0).", + "The slope of Ax + By = C is always −A/B.", + "Plotting the two intercepts and connecting them draws the whole line." + ], + "params": [ + { + "name": "A", + "label": "A", + "min": -5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "B", + "label": "B", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "C", + "label": "C", + "min": -8, + "max": 8, + "step": 1, + "value": 6 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst A = P.A, B = P.B, C = P.C;\nconst safeB = Math.abs(B) < 1e-6 ? 1e-6 : B;\nconst f = x => (C - A * x) / safeB;\nif (Math.abs(B) > 1e-6) {\n v.fn(f, { color: H.colors.accent, width: 3 });\n} else if (Math.abs(A) > 1e-6) {\n v.line(C / A, -8, C / A, 8, { color: H.colors.accent, width: 3 });\n}\nif (Math.abs(A) > 1e-6) {\n const xi = C / A;\n v.dot(xi, 0, { r: 6, fill: H.colors.good });\n v.text(\"x-int (\" + xi.toFixed(1) + \", 0)\", xi + 0.2, -0.6, { color: H.colors.good, size: 12 });\n}\nif (Math.abs(B) > 1e-6) {\n const yi = C / B;\n v.dot(0, yi, { r: 6, fill: H.colors.warn });\n v.text(\"y-int (0, \" + yi.toFixed(1) + \")\", 0.3, yi + 0.5, { color: H.colors.warn, size: 12 });\n}\nconst xs = 6 * Math.sin(t * 0.6);\nconst ys = Math.abs(B) > 1e-6 ? f(xs) : 0;\nif (Math.abs(B) > 1e-6) v.dot(xs, ys, { r: 6, fill: H.colors.accent2 });\nconst slope = Math.abs(B) > 1e-6 ? (-A / B) : Infinity;\nH.text(\"Standard form: A·x + B·y = C\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"A=\" + A.toFixed(1) + \" B=\" + B.toFixed(1) + \" C=\" + C.toFixed(1) + \" slope = −A/B = \" + (isFinite(slope) ? slope.toFixed(2) : \"∞\"), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"x-intercept\", color: H.colors.good }, { label: \"y-intercept\", color: H.colors.warn }], H.W - 180, 28);" + }, + { + "id": "a1-systems-by-graphing", + "area": "Algebra 1", + "topic": "Systems by graphing", + "title": "Systems by graphing: the crossing point", + "equation": "y = m1*x + b1 , y = m2*x + b2", + "keywords": [ + "systems by graphing", + "solve by graphing", + "system of equations", + "point of intersection", + "where lines cross", + "graphing method", + "two lines", + "intersection point", + "no solution", + "infinitely many solutions", + "simultaneous equations" + ], + "explanation": "To solve a system by graphing, draw both lines and find where they cross: that single point is the (x, y) that makes BOTH equations true at once. The tracer dots ride each line, and the dashed guides drop from the crossing to show its coordinates. Give the lines different slopes for exactly one solution; equal slopes make them parallel (no solution) or the same line (infinitely many solutions).", + "bullets": [ + "The solution is the point that lies on both lines at the same time.", + "Different slopes -> exactly one crossing; the dashed lines read off its coordinates.", + "Equal slopes -> parallel (no solution) or identical (infinitely many)." + ], + "params": [ + { + "name": "m1", + "label": "slope 1 m1", + "min": -4, + "max": 4, + "step": 0.1, + "value": 1 + }, + { + "name": "b1", + "label": "intercept 1 b1", + "min": -6, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "m2", + "label": "slope 2 m2", + "min": -4, + "max": 4, + "step": 0.1, + "value": -1 + }, + { + "name": "b2", + "label": "intercept 2 b2", + "min": -6, + "max": 6, + "step": 0.5, + "value": -1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst m1 = P.m1, b1 = P.b1, m2 = P.m2, b2 = P.b2;\nv.fn(x => m1 * x + b1, { color: H.colors.accent, width: 3 });\nv.fn(x => m2 * x + b2, { color: H.colors.accent2, width: 3 });\nH.text(\"Solve a system by graphing\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst par = Math.abs(m1 - m2) < 1e-6;\nif (!par) {\n const xi = (b2 - b1) / (m1 - m2), yi = m1 * xi + b1;\n const xt = -7 + ((t * 1.5) % 14);\n v.dot(xt, m1 * xt + b1, { r: 5, fill: H.colors.accent });\n v.dot(xt, m2 * xt + b2, { r: 5, fill: H.colors.accent2 });\n v.line(xi, -8, xi, yi, { color: H.colors.good, width: 1.5, dash: [4, 4] });\n v.line(-8, yi, xi, yi, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\n v.circle(xi, yi, 7 + 2 * Math.sin(t * 3), { stroke: H.colors.warn, width: 2.5 });\n v.dot(xi, yi, { r: 5, fill: H.colors.warn });\n H.text(\"they cross at (\" + xi.toFixed(2) + \", \" + yi.toFixed(2) + \") <- the solution\", 24, 52, { color: H.colors.good, size: 13 });\n} else {\n const sameLine = Math.abs(b1 - b2) < 1e-6;\n const xt = -7 + ((t * 1.5) % 14);\n v.dot(xt, m1 * xt + b1, { r: 5, fill: H.colors.accent });\n H.text(sameLine ? \"same line -> infinitely many solutions\" : \"parallel lines -> no solution\", 24, 52, { color: H.colors.warn, size: 13 });\n}\nH.legend([{ label: \"y = m1 x + b1\", color: H.colors.accent }, { label: \"y = m2 x + b2\", color: H.colors.accent2 }], H.W - 170, 28);" + }, + { + "id": "a1-systems-elimination", + "area": "Algebra 1", + "topic": "Systems by elimination", + "title": "Elimination: subtract to cancel y", + "equation": "a1*x + y = c1 and a2*x + y = c2 -> (a1 - a2)*x = c1 - c2", + "keywords": [ + "elimination", + "systems by elimination", + "linear combination", + "add equations", + "subtract equations", + "cancel variable", + "eliminate", + "system of equations", + "addition method", + "combine equations", + "solve system", + "two equations" + ], + "explanation": "Elimination lines the equations up so that adding or subtracting them makes one variable vanish. Here both equations share a +y term, so subtracting equation 2 from equation 1 cancels y and leaves (a1 − a2)x = c1 − c2 — one equation, one unknown. The violet segment is the y-gap between the lines at a swept x; it shrinks to zero exactly at the solution, which is what 'eliminating y' means geometrically.", + "bullets": [ + "Add or subtract the equations so one variable's terms cancel.", + "Scale an equation first if needed to make matching coefficients.", + "Solve the one-variable result, then substitute back for the other." + ], + "params": [ + { + "name": "a1", + "label": "eq1 x-coef a1", + "min": -3, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "c1", + "label": "eq1 constant c1", + "min": -6, + "max": 6, + "step": 0.5, + "value": 4 + }, + { + "name": "a2", + "label": "eq2 x-coef a2", + "min": -3, + "max": 3, + "step": 0.1, + "value": -1 + }, + { + "name": "c2", + "label": "eq2 constant c2", + "min": -6, + "max": 6, + "step": 0.5, + "value": -2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\n// System: a1*x + y = c1 and a2*x + y = c2 (both already isolating y for the plot)\nconst a1 = P.a1, c1 = P.c1, a2 = P.a2, c2 = P.c2;\n// y = c1 - a1*x and y = c2 - a2*x\nv.fn(x => c1 - a1 * x, { color: H.colors.accent, width: 3 });\nv.fn(x => c2 - a2 * x, { color: H.colors.accent2, width: 3 });\nH.text(\"Elimination: subtract equations to cancel y\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\n// Subtracting eq2 from eq1 cancels the +y term: (a1-a2)x = c1 - c2.\nif (Math.abs(a1 - a2) > 1e-6) {\n const xi = (c1 - c2) / (a1 - a2), yi = c1 - a1 * xi;\n // animate the \"subtraction\" as a shrinking gap between the two lines' y at a swept x\n const xs = 6 * Math.sin(t * 0.7);\n const y1 = c1 - a1 * xs, y2 = c2 - a2 * xs;\n v.line(xs, y1, xs, y2, { color: H.colors.violet, width: 2 });\n v.dot(xs, y1, { r: 4, fill: H.colors.accent });\n v.dot(xs, y2, { r: 4, fill: H.colors.accent2 });\n H.circle(v.X(xi), v.Y(yi), 6 + 1.5 * Math.sin(t * 3), { fill: H.colors.warn });\n H.text(\"(a1 − a2)x = c1 − c2 → x = \" + xi.toFixed(2) + \", y = \" + yi.toFixed(2), 24, 52, { color: H.colors.good, size: 13 });\n} else {\n H.text(\"a1 = a2: y-terms cancel AND x-terms cancel — no unique x\", 24, 52, { color: H.colors.warn, size: 13 });\n}\nH.legend([{ label: \"eq 1\", color: H.colors.accent }, { label: \"eq 2\", color: H.colors.accent2 }, { label: \"gap (subtract)\", color: H.colors.violet }], H.W - 175, 28);" + }, + { + "id": "a1-systems-inequalities", + "area": "Algebra 1", + "topic": "Systems of inequalities", + "title": "Feasible region: y ≥ m1·x+b1 and y ≤ m2·x+b2", + "equation": "y >= m1*x + b1 and y <= m2*x + b2", + "keywords": [ + "systems of inequalities", + "inequalities", + "feasible region", + "shaded region", + "linear inequalities", + "half plane", + "overlap", + "solution region", + "graph inequalities", + "boundary line", + "satisfies both", + "test point" + ], + "explanation": "Each linear inequality keeps HALF the plane — everything above (or below) its boundary line. The solution of the system is the overlap where BOTH are satisfied, shown by the green shading found by testing a grid of points. The roaming dot turns green only when it lands inside that overlap, which is exactly how you check a candidate point: plug it into every inequality and see if all of them hold.", + "bullets": [ + "Graph each boundary line, then shade the side that satisfies its inequality.", + "The solution set is the region where ALL shaded half-planes overlap.", + "Test any point: it's a solution only if it satisfies every inequality." + ], + "params": [ + { + "name": "m1", + "label": "lower slope m1", + "min": -3, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "b1", + "label": "lower intercept b1", + "min": -5, + "max": 5, + "step": 0.5, + "value": -2 + }, + { + "name": "m2", + "label": "upper slope m2", + "min": -3, + "max": 3, + "step": 0.1, + "value": -1 + }, + { + "name": "b2", + "label": "upper intercept b2", + "min": -5, + "max": 5, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\n// Two inequalities: y >= m1*x + b1 AND y <= m2*x + b2\nconst m1 = P.m1, b1 = P.b1, m2 = P.m2, b2 = P.b2;\n// Shade the feasible region by sampling a grid of test points.\nconst step = 0.7;\nfor (let x = -8; x <= 8; x += step) {\n for (let y = -8; y <= 8; y += step) {\n const ok1 = y >= m1 * x + b1;\n const ok2 = y <= m2 * x + b2;\n if (ok1 && ok2) {\n // pulse the overlap so the region \"breathes\"\n const r = 2.3 + 0.8 * Math.sin(t * 2 + x + y);\n H.circle(v.X(x), v.Y(y), r, { fill: \"rgba(103,232,176,0.45)\" });\n }\n }\n}\n// boundary lines\nv.fn(x => m1 * x + b1, { color: H.colors.accent, width: 2.5 });\nv.fn(x => m2 * x + b2, { color: H.colors.accent2, width: 2.5 });\n// a roaming test point that lights up when it's INSIDE the solution set\nconst tx = 6 * Math.sin(t * 0.6), ty = 5 * Math.sin(t * 0.9 + 1);\nconst inside = (ty >= m1 * tx + b1) && (ty <= m2 * tx + b2);\nv.dot(tx, ty, { r: 6, fill: inside ? H.colors.good : H.colors.warn });\nH.text(\"System of inequalities: shaded = satisfies BOTH\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"test (\" + tx.toFixed(1) + \", \" + ty.toFixed(1) + \") is \" + (inside ? \"INSIDE (a solution)\" : \"outside\"), 24, 52, { color: inside ? H.colors.good : H.colors.warn, size: 13 });\nH.legend([{ label: \"y ≥ m1·x+b1\", color: H.colors.accent }, { label: \"y ≤ m2·x+b2\", color: H.colors.accent2 }], H.W - 185, 28);" + }, + { + "id": "a1-systems-no-infinite-solutions", + "area": "Algebra 1", + "topic": "Systems with no solution/infinite solutions", + "title": "Same slope: no solution vs. infinite", + "equation": "y = m*x + 1 and y = m*x + (1 + d)", + "keywords": [ + "no solution", + "infinite solutions", + "infinitely many solutions", + "parallel lines", + "same line", + "consistent", + "inconsistent", + "dependent system", + "independent", + "equal slopes", + "coincident lines", + "system of equations" + ], + "explanation": "When two lines share the SAME slope they never cross at an angle, so the system can't have exactly one solution. Slide d to move the second line: at d = 0 the lines lie on top of each other (the same line — every point solves both, infinitely many solutions); at any other d they stay perfectly parallel and the violet gap |d| never closes, so there is no solution at all. The slope m just tilts both together without changing this.", + "bullets": [ + "Equal slopes -> the lines are parallel (no solution) or identical (infinite).", + "Same line = same slope AND same intercept: every point works.", + "Algebraically, you get 0 = nonzero (no solution) or 0 = 0 (all x)." + ], + "params": [ + { + "name": "m", + "label": "shared slope m", + "min": -3, + "max": 3, + "step": 0.1, + "value": 0.5 + }, + { + "name": "d", + "label": "line-2 gap d", + "min": -5, + "max": 5, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\n// Same slope m for both lines; gap d slides line 2 vertically.\nconst m = P.m, d = P.d;\nconst b1 = 1; // line 1 fixed intercept\nconst b2 = 1 + d; // line 2 intercept; d = 0 -> identical, d != 0 -> parallel\nv.fn(x => m * x + b1, { color: H.colors.accent, width: 3 });\nv.fn(x => m * x + b2, { color: H.colors.accent2, width: 3 });\nH.text(\"Same slope: parallel (no solution) or identical (infinite)\", 24, 30, { color: H.colors.ink, size: 15, weight: 700 });\n// animate a dot riding line 1; its vertical distance to line 2 is constant = |d|\nconst xs = 6 * Math.sin(t * 0.7);\nv.dot(xs, m * xs + b1, { r: 5, fill: H.colors.accent });\nv.line(xs, m * xs + b1, xs, m * xs + b2, { color: H.colors.violet, width: 2, dash: [4, 4] });\nif (Math.abs(d) < 1e-6) {\n H.text(\"d = 0 → SAME line: every point works (infinitely many)\", 24, 52, { color: H.colors.good, size: 13 });\n} else {\n H.text(\"d = \" + d.toFixed(1) + \" → parallel, gap stays \" + Math.abs(d).toFixed(1) + \": NO solution\", 24, 52, { color: H.colors.warn, size: 13 });\n}\nH.legend([{ label: \"line 1\", color: H.colors.accent }, { label: \"line 2\", color: H.colors.accent2 }, { label: \"gap = |d|\", color: H.colors.violet }], H.W - 165, 28);" + }, + { + "id": "a1-systems-substitution", + "area": "Algebra 1", + "topic": "Systems by substitution", + "title": "Substitution: put y = m·x + b into eq 2", + "equation": "y = m*x + b and y = -x + c -> m*x + b = -x + c", + "keywords": [ + "substitution", + "systems by substitution", + "solve by substitution", + "system of equations", + "substitute", + "plug in", + "two equations", + "isolate y", + "back substitute", + "y=mx+b", + "intersection", + "solve system" + ], + "explanation": "Substitution works because one equation already tells you what y EQUALS, so you can replace y in the other equation and get a single equation in x alone. Slide m and b to reshape the first line and c to slide the second; the violet drop shows the x you solve for, and the pulsing dot is where that x makes both equations true at once. The whole point: trade two unknowns for one by swapping y for its expression.", + "bullets": [ + "Solve one equation for a variable, then substitute it into the other.", + "That leaves ONE equation in one unknown — solve it, then back-substitute.", + "The solution is the single point lying on both lines simultaneously." + ], + "params": [ + { + "name": "m", + "label": "slope m", + "min": -3, + "max": 3, + "step": 0.1, + "value": 1.5 + }, + { + "name": "b", + "label": "intercept b", + "min": -5, + "max": 5, + "step": 0.5, + "value": -1 + }, + { + "name": "c", + "label": "eq2 constant c", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst m = P.m, b = P.b, c = P.c;\n// Line 1 (already solved for y): y = m*x + b\nv.fn(x => m * x + b, { color: H.colors.accent, width: 3 });\n// Line 2 is the horizontal \"what we substitute INTO\": x = c (a vertical line),\n// but to keep it a real system we use the second equation y = -x + c here.\nv.fn(x => -x + c, { color: H.colors.accent2, width: 3 });\nH.text(\"Substitution: put y = m·x + b into the 2nd equation\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nif (Math.abs(m + 1) > 1e-6) {\n // solve m*x + b = -x + c -> x = (c - b)/(m + 1)\n const xi = (c - b) / (m + 1), yi = m * xi + b;\n // animated vertical \"drop\" showing the substituted x-value being found\n const sweep = xi * (0.5 + 0.5 * Math.sin(t * 1.2));\n v.line(sweep, 0, sweep, m * sweep + b, { color: H.colors.violet, width: 2, dash: [4, 4] });\n v.dot(sweep, 0, { r: 5, fill: H.colors.violet });\n H.circle(v.X(xi), v.Y(yi), 6 + 1.5 * Math.sin(t * 3), { fill: H.colors.warn });\n v.line(xi, 0, xi, yi, { color: H.colors.good, width: 2, dash: [3, 3] });\n H.text(\"solve for x: x = (c − b)/(m + 1) = \" + xi.toFixed(2) + \", y = \" + yi.toFixed(2), 24, 52, { color: H.colors.good, size: 13 });\n} else {\n H.text(\"m = −1: lines parallel — substitution gives no x\", 24, 52, { color: H.colors.warn, size: 13 });\n}\nH.legend([{ label: \"y = m·x + b\", color: H.colors.accent }, { label: \"y = −x + c\", color: H.colors.accent2 }], H.W - 160, 28);" + }, + { + "id": "a1-systems-word-problems", + "area": "Algebra 1", + "topic": "Systems word problems", + "title": "Tickets: x + y = total, a·x + y = money", + "equation": "x + y = total and aprice*x + y = money", + "keywords": [ + "word problem", + "systems word problems", + "mixture problem", + "tickets", + "two variables", + "set up a system", + "translate", + "real world system", + "money and count", + "how many of each", + "modeling", + "system of equations" + ], + "explanation": "Word problems become systems when two facts each tie the SAME two unknowns together. Here x is adult tickets and y is child tickets: one equation counts tickets (x + y = total) and the other counts money (a·x + y = money, with child price 1). Subtracting them eliminates y and gives (a − 1)x = money − total, so the adult price slider directly controls the answer. The blue dot sweeps through 'guess-and-check' combinations while the pulsing dot marks the real solution.", + "bullets": [ + "Name each unknown, then write one equation per fact in the problem.", + "Both equations must use the same variables to form a solvable system.", + "Solve by substitution or elimination; check the answer fits BOTH facts." + ], + "params": [ + { + "name": "total", + "label": "total tickets", + "min": 4, + "max": 12, + "step": 1, + "value": 10 + }, + { + "name": "aprice", + "label": "adult price", + "min": 2, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "money", + "label": "total money", + "min": 10, + "max": 40, + "step": 1, + "value": 18 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 12, yMin: 0, yMax: 12 });\nv.grid(); v.axes();\n// Word problem: adult tickets (x) + child tickets (y).\n// total tickets: x + y = T\n// total money: a*x + y = M (adult price a, child price 1)\nconst T = P.total, a = P.aprice, M = P.money;\n// y = T - x and y = M - a*x\nv.fn(x => T - x, { color: H.colors.accent, width: 3 });\nv.fn(x => M - a * x, { color: H.colors.accent2, width: 3 });\nH.text(\"Word problem: x + y = total, a·x + y = money\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\n// subtract: (a-1)x = M - T -> x adults, y children\nif (Math.abs(a - 1) > 1e-6) {\n const xi = (M - T) / (a - 1), yi = T - xi;\n const pulse = 6 + 1.5 * Math.sin(t * 3);\n if (xi >= -1 && xi <= 13 && yi >= -1 && yi <= 13) {\n H.circle(v.X(xi), v.Y(yi), pulse, { fill: H.colors.warn });\n v.line(xi, 0, xi, yi, { color: H.colors.good, width: 1.5, dash: [3, 3] });\n v.line(0, yi, xi, yi, { color: H.colors.good, width: 1.5, dash: [3, 3] });\n }\n // animated marker sweeping line 1 to show \"trying combinations\"\n const xs = 6 + 5.5 * Math.sin(t * 0.6);\n v.dot(xs, T - xs, { r: 5, fill: H.colors.accent });\n H.text(\"x = \" + xi.toFixed(1) + \" adults, y = \" + yi.toFixed(1) + \" children\", 24, 52, { color: H.colors.good, size: 13 });\n} else {\n H.text(\"adult price = 1: both equations identical — underdetermined\", 24, 52, { color: H.colors.warn, size: 13 });\n}\nH.legend([{ label: \"tickets: x+y=T\", color: H.colors.accent }, { label: \"money: a·x+y=M\", color: H.colors.accent2 }], H.W - 195, 28);" + }, + { + "id": "a1-translate-sentences-equations", + "area": "Algebra 1", + "topic": "Translate verbal sentences into equations/inequalities", + "title": "Sentences to equations & inequalities", + "equation": "a * x {=, >=, <=, >} b", + "keywords": [ + "translate sentence", + "equation", + "inequality", + "is equal to", + "at least", + "at most", + "more than", + "word problem to equation", + "relationship words", + "verbal sentence", + "is greater than", + "write an inequality" + ], + "explanation": "A full sentence becomes an equation or inequality once you find the relationship word: 'is' means =, 'at least' means ≥, 'at most' means ≤, 'more than' means >. Use the relation slider to swap the verb and a, b to set the numbers; the arrow turns the sentence into a·x (relation) b and the number line shades every x that makes it true. An open circle marks a strict 'more than' boundary that is not included.", + "bullets": [ + "Find the verb: 'is' -> =, 'at least' -> ≥, 'at most' -> ≤, 'more than' -> >.", + "Solving a·x (rel) b divides by a to get x (rel) b/a.", + "Strict inequalities use an open endpoint; ≤ and ≥ use a closed one." + ], + "params": [ + { + "name": "a", + "label": "coefficient a", + "min": 1, + "max": 6, + "step": 1, + "value": 3 + }, + { + "name": "b", + "label": "number b", + "min": -9, + "max": 9, + "step": 1, + "value": 6 + }, + { + "name": "rel", + "label": "relation (0-3)", + "min": 0, + "max": 3, + "step": 1, + "value": 0 + } + ], + "code": "H.background();\nconst w = H.W, hh = H.H;\nconst a = Math.round(P.a), b = Math.round(P.b);\nconst rel = Math.round(P.rel) % 4; // is / at least / at most / more than\nconst q = String.fromCharCode(34);\n// \"three times a number, , b\" -> 3x b\nconst relWords = [\"is equal to\", \"is at least\", \"is at most\", \"is more than\"];\nconst relSym = [\"=\", \">=\", \"<=\", \">\"];\nconst sentence = q + a + \" times a number \" + relWords[rel] + \" \" + b + q;\nconst algebra = a + \" * x \" + relSym[rel] + \" \" + b;\nH.text(\"Translate a sentence to an equation / inequality\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Relationship words pick the symbol: 'is' -> = , 'at least' -> >= , 'at most' -> <= .\", 24, 54, { color: H.colors.sub, size: 12 });\nH.rect(50, 92, w - 100, 50, { fill: H.colors.panel, stroke: H.colors.grid, width: 1, radius: 10 });\nH.text(sentence, 68, 123, { color: H.colors.ink, size: 18, weight: 600 });\nconst ay = 158 + 5 * Math.sin(t * 2.5);\nH.arrow(w * 0.5, 146, w * 0.5, ay + 10, { color: H.colors.accent2, width: 3 });\nH.rect(50, 188, w - 100, 50, { fill: \"#13243f\", stroke: H.colors.accent, width: 2, radius: 10 });\nH.text(algebra, 68, 220, { color: H.colors.good, size: 24, weight: 700 });\n// number line: solution of a*x b -> x b/a (a assumed > 0)\nconst aa = a === 0 ? 1 : a;\nconst bound = b / aa;\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -1, yMax: 1, pad: 50 });\nv.line(-10, 0, 10, 0, { color: H.colors.axis, width: 2 });\nfor (let k = -10; k <= 10; k += 2) { v.line(k, -0.12, k, 0.12, { color: H.colors.grid, width: 1.5 }); v.text(String(k), k, -0.45, { color: H.colors.sub, size: 11, align: \"center\" }); }\nconst cb = Math.max(-10, Math.min(10, bound));\n// shade the solution region (skip for strict equality)\nif (rel !== 0) {\n const goRight = rel === 1 || rel === 3; // at least / more than\n const x2 = goRight ? 10 : -10;\n v.line(cb, 0.18, x2, 0.18, { color: H.colors.good, width: 5 });\n}\nconst open = rel === 3; // strict -> open circle\nv.dot(cb, 0, { r: 7, fill: open ? H.colors.bg : H.colors.warn, stroke: H.colors.warn });\nv.text(\"x \" + relSym[rel] + \" \" + bound.toFixed(2), cb, 0.6, { color: H.colors.warn, size: 13, align: \"center\" });\n// animated test point checking the inequality\nconst tx = 8 * Math.sin(t * 0.6);\nconst holds = rel === 0 ? Math.abs(aa * tx - b) < 0.3 : rel === 1 ? aa * tx >= b : rel === 2 ? aa * tx <= b : aa * tx > b;\nv.dot(tx, -0.0, { r: 5, fill: holds ? H.colors.good : H.colors.sub });\nH.text(\"a = \" + a + \" b = \" + b + \" -> \" + algebra + \" (x \" + relSym[rel] + \" \" + bound.toFixed(2) + \")\", 24, hh - 22, { color: H.colors.sub, size: 13 });" + }, + { + "id": "a1-translate-verbal-expressions", + "area": "Algebra 1", + "topic": "Translate verbal expressions into algebra", + "title": "Words to algebra: keyword picks the operation", + "equation": "a number = x ; keyword -> +, -, *, /", + "keywords": [ + "translate", + "verbal expression", + "words to algebra", + "into algebra", + "more than", + "less than", + "product", + "quotient", + "phrase to expression", + "keyword operation", + "write an expression", + "a number means x" + ], + "explanation": "Word phrases become algebra by turning 'a number' into the variable x and letting the keyword decide the operation. Use the phrase slider to flip between four common templates and the n slider to set the number; the arrow links the English to the resulting expression and the keyword map highlights which operation word is in play. Notice 'less than' reverses the order: 'n less than a number' is x − n, not n − x.", + "bullets": [ + "'A number' is the variable; 'more/less/product/quotient' picks the operation.", + "'n less than x' means x − n — the order flips.", + "Same number, different keyword, completely different expression." + ], + "params": [ + { + "name": "n", + "label": "number n", + "min": 1, + "max": 12, + "step": 1, + "value": 5 + }, + { + "name": "phrase", + "label": "phrase (0-3)", + "min": 0, + "max": 3, + "step": 1, + "value": 0 + } + ], + "code": "H.background();\nconst w = H.W, hh = H.H;\nconst n = Math.round(P.n);\nconst phrase = Math.round(P.phrase) % 4; // pick which verbal phrase to translate\nconst q = String.fromCharCode(34); // double-quote char for the English phrase\n// Phrase bank: words -> algebra, with the operation keyword highlighted.\nconst bank = [\n { words: q + n + \" more than a number\" + q, expr: \"x + \" + n, op: \"more than -> +\" },\n { words: q + n + \" less than a number\" + q, expr: \"x - \" + n, op: \"less than -> -\" },\n { words: q + \"the product of \" + n + \" and a number\" + q, expr: n + \" * x\", op: \"product -> *\" },\n { words: q + \"a number divided by \" + n + q, expr: \"x / \" + n, op: \"divided by -> /\" }\n];\nconst cur = bank[phrase];\nH.text(\"Translate words to algebra\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"'a number' becomes the variable x; the keyword picks the operation.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.rect(50, 100, w - 100, 54, { fill: H.colors.panel, stroke: H.colors.grid, width: 1, radius: 10 });\nH.text(cur.words, 70, 134, { color: H.colors.ink, size: 20, weight: 600 });\nconst ay = 175 + 6 * Math.sin(t * 2.5);\nH.arrow(w * 0.5, 160, w * 0.5, ay + 14, { color: H.colors.accent2, width: 3 });\nH.text(cur.op, w * 0.5 + 24, ay + 6, { color: H.colors.warn, size: 14 });\nH.rect(50, 210, w - 100, 60, { fill: \"#13243f\", stroke: H.colors.accent, width: 2, radius: 10 });\nH.text(cur.expr, 70, 250, { color: H.colors.good, size: 26, weight: 700 });\nconst keys = [\"more than -> +\", \"less than -> -\", \"product -> *\", \"quotient -> /\"];\nfor (let i = 0; i < keys.length; i++) {\n const yy = 320 + i * 26;\n const on = i === phrase;\n H.circle(64, yy - 4, 5, { fill: on ? H.colors.warn : H.colors.grid });\n H.text(keys[i], 80, yy, { color: on ? H.colors.ink : H.colors.sub, size: 14, weight: on ? 700 : 400 });\n}\nH.text(\"n = \" + n + \" phrase #\" + (phrase + 1) + \" -> \" + cur.expr, 24, hh - 22, { color: H.colors.sub, size: 14 });" + }, + { + "id": "a1-two-step-equations", + "area": "Algebra 1", + "topic": "Two-step equations", + "title": "Two-step equation: a*x + b = c", + "equation": "a * x + b = c -> x = (c - b) / a", + "keywords": [ + "two step equation", + "two-step equation", + "solve for x", + "ax + b = c", + "isolate variable", + "inverse operations", + "subtract then divide", + "linear equation", + "intersection", + "undo addition", + "balance equation", + "solving equations" + ], + "explanation": "A two-step equation needs two inverse moves done in the right order: first undo the +b (subtract b from both sides), then undo the multiply by a (divide by a). Graphically, the answer is where the line y = a*x + b meets the horizontal target line y = c — the pulsing point. The dashed drop shows the solution x = (c - b)/a, and the caption steps through subtract-then-divide so you see why that order works.", + "bullets": [ + "Two steps: subtract b first, then divide by a (undo in reverse order).", + "The solution is where y = a*x + b crosses the line y = c.", + "Each move is applied to BOTH sides so the equation stays balanced." + ], + "params": [ + { + "name": "a", + "label": "coefficient a", + "min": -4, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "b", + "label": "constant b", + "min": -6, + "max": 6, + "step": 1, + "value": 1 + }, + { + "name": "c", + "label": "right side c", + "min": -8, + "max": 8, + "step": 1, + "value": 5 + } + ], + "code": "H.background();\nconst a = P.a, b = P.b, c = P.c;\nconst degenerate = Math.abs(a) < 1e-6; // a = 0 -> not a real two-step equation\nconst solRaw = degenerate ? 0 : (c - b) / a; // true solution x (when it exists)\n// Make the window always contain the solution AND the y=c line, so the\n// intersection dot, its drop-line, and the readout always agree on-screen.\nconst span = Math.max(10, Math.abs(solRaw) + 2, Math.abs(c) + 2, Math.abs(b) + 2);\nconst v = H.plot2d({ xMin: -span, xMax: span, yMin: -span, yMax: span });\nv.grid(); v.axes();\n// line y = a x + b (if a = 0 this is the horizontal line y = b)\nv.fn(x => a * x + b, { color: H.colors.accent, width: 3 });\n// target horizontal line y = c\nv.line(-span, c, span, c, { color: H.colors.accent2, width: 2, dash: [6, 5] });\nconst phase = Math.floor((t % 9) / 3);\nif (!degenerate) {\n // intersection = the solution x (guaranteed on-screen by the adaptive span)\n v.dot(solRaw, c, { r: 6 + Math.sin(t * 3), fill: H.colors.warn });\n v.line(solRaw, -span, solRaw, c, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\n v.text(\"x = \" + solRaw.toFixed(2), solRaw + span * 0.03, c - span * 0.05, { color: H.colors.warn, size: 13, weight: 700 });\n // moving dot riding the line toward the solution\n const xs = solRaw * (0.5 + 0.5 * Math.sin(t * 0.8));\n v.dot(xs, a * xs + b, { r: 5, fill: H.colors.good });\n}\nconst steps = degenerate\n ? [\n \"0·x + \" + b.toFixed(1) + \" = \" + c.toFixed(1),\n \"no x term left: \" + b.toFixed(1) + \" = \" + c.toFixed(1),\n Math.abs(b - c) < 1e-6 ? \"true for every x (identity)\" : \"false -> no solution\"\n ]\n : [\n a.toFixed(1) + \"x + \" + b.toFixed(1) + \" = \" + c.toFixed(1),\n \"subtract \" + b.toFixed(1) + \": \" + a.toFixed(1) + \"x = \" + (c - b).toFixed(1),\n \"divide by \" + a.toFixed(1) + \": x = \" + solRaw.toFixed(2)\n ];\nH.text(\"Two-step equation: a*x + b = c\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"step \" + (phase + 1) + \" / 3: \" + steps[phase], 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"y = a x + b\", color: H.colors.accent }, { label: \"y = c\", color: H.colors.accent2 }], H.W - 170, 28);" + }, + { + "id": "a1-variables-both-sides", + "area": "Algebra 1", + "topic": "Equations with variables on both sides", + "title": "Both sides: a·x + b = c·x + d", + "equation": "a*x + b = c*x + d -> x = (d - b) / (a - c)", + "keywords": [ + "variables on both sides", + "x on both sides", + "ax+b=cx+d", + "collect like terms", + "solve for x", + "linear equation", + "intersection of two lines", + "move variables", + "balance equation", + "two expressions equal" + ], + "explanation": "Treat each side of the equation as its own line: y = a·x + b and y = c·x + d. The solution is the x where the two lines cross, because that is the one input that makes both sides equal. Slide a, b, c, d and watch the crossing move; the dashed probe shows the gap between the sides shrinking to zero exactly at the solution. Equal slopes give parallel lines (no solution) or the same line (infinitely many).", + "bullets": [ + "Each side is a line; the solution is where they intersect.", + "Collect x on one side: (a-c)*x = d-b, then divide by (a-c).", + "Equal slopes -> parallel (no solution) or identical (infinitely many)." + ], + "params": [ + { + "name": "a", + "label": "left slope a", + "min": -4, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "b", + "label": "left constant b", + "min": -6, + "max": 6, + "step": 0.5, + "value": 1 + }, + { + "name": "c", + "label": "right slope c", + "min": -4, + "max": 4, + "step": 0.5, + "value": -1 + }, + { + "name": "d", + "label": "right constant d", + "min": -6, + "max": 6, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst a = P.a, b = P.b, c = P.c, d = P.d;\nconst parallel = Math.abs(a - c) <= 1e-6;\nconst xs = parallel ? 0 : (d - b) / (a - c);\nconst ys = a * xs + b;\n// Adapt the window so the intersection (the whole point of the demo) stays\n// visible even for shallow slope differences that push it far out.\nconst span = Math.max(8, Math.abs(xs) + 2, Math.abs(ys) + 2);\nconst v = H.plot2d({ xMin: -span, xMax: span, yMin: -span, yMax: span });\nv.grid(); v.axes();\n// left side y = a x + b, right side y = c x + d; solution where they meet\nv.fn(x => a * x + b, { color: H.colors.accent, width: 3 });\nv.fn(x => c * x + d, { color: H.colors.accent2, width: 3 });\nH.text(\"Variables on both sides: a·x + b = c·x + d\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nif (!parallel) {\n v.line(xs, -span, xs, ys, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\n v.dot(xs, ys, { r: 7 + Math.sin(t * 3), fill: H.colors.good });\n v.text(\"x = \" + xs.toFixed(2), xs + span * 0.03, ys + span * 0.08, { color: H.colors.good, size: 13 });\n H.text(\"collect x on one side: (a−c)·x = d−b → x = \" + xs.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\n} else {\n H.text(Math.abs(b - d) < 1e-6 ? \"same line — every x works (infinite solutions)\" : \"parallel — no x makes them equal (no solution)\", 24, 52, { color: H.colors.warn, size: 13 });\n}\n// moving probe dot that sweeps along x, showing both sides' heights.\n// Bound the sweep so BOTH heights stay inside the window at every frame.\nconst maxSlope = Math.max(Math.abs(a), Math.abs(c), 1e-6);\nconst xpAmp = Math.max(1, (span - Math.max(Math.abs(b), Math.abs(d)) - 1) / maxSlope);\nconst xp = Math.min(span - 2, xpAmp) * Math.sin(t * 0.6);\nv.dot(xp, a * xp + b, { r: 5, fill: H.colors.accent });\nv.dot(xp, c * xp + d, { r: 5, fill: H.colors.accent2 });\nv.line(xp, a * xp + b, xp, c * xp + d, { color: H.colors.sub, width: 1, dash: [3, 3] });\nH.legend([{ label: \"left a·x+b\", color: H.colors.accent }, { label: \"right c·x+d\", color: H.colors.accent2 }], H.W - 180, 28);" + }, + { + "id": "a1-x-and-y-intercepts", + "area": "Algebra 1", + "topic": "x-intercepts and y-intercepts", + "title": "Intercepts: where a line crosses the axes", + "equation": "y-int: x = 0 -> y = b; x-int: y = 0 -> x = -b/m", + "keywords": [ + "intercept", + "x-intercept", + "y-intercept", + "x intercepts and y intercepts", + "where line crosses axis", + "set x=0", + "set y=0", + "axis crossing", + "zero of a line", + "root", + "crosses the x axis", + "crosses the y axis" + ], + "explanation": "An intercept is where a graph crosses an axis. To find the y-intercept set x = 0 -- on a line that gives y = b (the green point). To find the x-intercept set y = 0 and solve -- that gives x = -b/m (the pink point). Slide m and b and watch both crossings move; when the line is horizontal (m = 0) it never crosses the x-axis, so that intercept disappears.", + "bullets": [ + "y-intercept: plug in x = 0; for y = mx + b that is (0, b).", + "x-intercept: plug in y = 0 and solve, giving x = -b/m.", + "A horizontal line (m = 0) has no x-intercept unless it IS the x-axis." + ], + "params": [ + { + "name": "m", + "label": "slope m", + "min": -4, + "max": 4, + "step": 0.1, + "value": 1 + }, + { + "name": "b", + "label": "y-intercept b", + "min": -6, + "max": 6, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst m = P.m, b = P.b;\nconst xint = Math.abs(m) > 1e-9 ? -b / m : NaN;\n// Window includes the y-intercept (0,b) and the x-intercept (xint,0). Capped so\n// a near-horizontal line doesn't shrink the picture to nothing; the line is\n// drawn by fn so it stays visible regardless.\nconst span = Math.min(40, Math.max(8, (Number.isFinite(xint) ? Math.abs(xint) : 0) + 1.5, Math.abs(b) + 3));\nconst v = H.plot2d({ xMin: -span, xMax: span, yMin: -span, yMax: span });\nv.grid(); v.axes();\nv.fn(x => m * x + b, { color: H.colors.accent, width: 3 });\nv.dot(0, b, { r: 7, fill: H.colors.good });\nv.text(\"y-int (0, \" + b.toFixed(1) + \")\", span * 0.04, b + span * 0.08, { color: H.colors.good, size: 12 });\nif (Number.isFinite(xint)) {\n // keep the marker on-screen even past the cap; label always shows true value\n const xMark = H.clamp(xint, -span + 0.5, span - 0.5);\n v.dot(xMark, 0, { r: 7, fill: H.colors.warn });\n v.text(\"x-int (\" + xint.toFixed(1) + \", 0)\", xMark + span * 0.04, -span * 0.09, { color: H.colors.warn, size: 12 });\n}\n// probe sweep bounded so the moving point's height stays inside the window\nconst xsAmp = Math.min(span - 2, Math.max(0.3, (span - Math.abs(b) - 1) / Math.max(Math.abs(m), 1e-6)));\nconst xs = xsAmp * Math.sin(t * 0.6);\nv.dot(xs, m * xs + b, { r: 5, fill: H.colors.accent2 });\nH.text(\"x- and y-intercepts\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"y-int: set x=0 -> y=\" + b.toFixed(1) + \" x-int: set y=0 -> x=\" + (Number.isFinite(xint) ? xint.toFixed(1) : \"none (horizontal)\"), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"y-intercept\", color: H.colors.good }, { label: \"x-intercept\", color: H.colors.warn }], H.W - 180, 28);" + }, + { + "id": "a2-absolute-value-equations", + "area": "Algebra 2", + "topic": "Absolute value equations", + "title": "Solve |x − h| = d (two cases)", + "equation": "|x - h| = d => x - h = +d or x - h = -d => x = h ± d", + "keywords": [ + "absolute value equation", + "solve absolute value", + "|x-h|=d", + "two solutions", + "two cases", + "plus or minus", + "x = h plus or minus d", + "distance interpretation", + "isolate absolute value", + "no solution absolute value", + "extraneous", + "set equal to d" + ], + "explanation": "Solving |x − h| = d means: which x values sit exactly distance d from h? Graphically, you intersect the V graph y = |x − h| with the horizontal line y = d — the green dots are the solutions. That's why there are TWO: x − h can equal +d or −d, giving x = h ± d. Drag d below zero and the line drops under the V: no intersection means no solution (a distance can't be negative).", + "bullets": [ + "Isolate the absolute value, then split into two cases: x − h = +d and x − h = −d.", + "Solutions are x = h ± d — symmetric about x = h, the V's corner.", + "If d < 0 there is NO solution; if d = 0 the two solutions merge into one." + ], + "params": [ + { + "name": "h", + "label": "center h", + "min": -5, + "max": 5, + "step": 0.5, + "value": 1 + }, + { + "name": "d", + "label": "right side d", + "min": -1, + "max": 6, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst h = P.h, d = P.d;\n// Window must contain the solutions x = h ± d so both markers stay visible.\nconst xspan = Math.max(8, Math.abs(h) + Math.max(0, d) + 1.5);\nconst ymax = Math.max(10, d + 2);\nconst v = H.plot2d({ xMin: -xspan, xMax: xspan, yMin: -2, yMax: ymax });\nv.grid(); v.axes();\nconst f = x => Math.abs(x - h);\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(-xspan, d, xspan, d, { color: H.colors.accent2, width: 2.5 });\nv.dot(h, 0, { r: 5, fill: H.colors.warn });\nif (d >= 0) {\n const s1 = h + d, s2 = h - d;\n v.dot(s1, d, { r: 7, fill: H.colors.good });\n v.dot(s2, d, { r: 7, fill: H.colors.good });\n v.line(s1, 0, s1, d, { color: H.colors.violet, width: 1.2, dash: [3, 3] });\n v.line(s2, 0, s2, d, { color: H.colors.violet, width: 1.2, dash: [3, 3] });\n}\nconst pulse = 0.5 + 0.5 * Math.sin(t * 2.5);\nconst xs = h + Math.max(0, d) * Math.sin(t * 0.8);\nv.dot(xs, f(xs), { r: 4 + 2 * pulse, fill: H.colors.warn });\nH.text(\"Solve |x − h| = d\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst sol = d > 1e-9 ? \"x = \" + (h + d).toFixed(2) + \" or x = \" + (h - d).toFixed(2) : d < -1e-9 ? \"no solution (d < 0)\" : \"x = \" + h.toFixed(2) + \" (one solution)\";\nH.text(sol + \" → x − h = ±d\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"|x−h|\", color: H.colors.accent }, { label: \"y = d\", color: H.colors.accent2 }, { label: \"solutions\", color: H.colors.good }], H.W - 160, 28);" + }, + { + "id": "a2-absolute-value-functions", + "area": "Algebra 2", + "topic": "Absolute value functions", + "title": "Absolute value: y = a|x − h| + k", + "equation": "y = a*|x - h| + k (the line a(x-h)+k folded up at the vertex)", + "keywords": [ + "absolute value function", + "absolute value graph", + "v shape", + "vertex form absolute value", + "y = a|x-h|+k", + "|x|", + "modulus graph", + "fold the line", + "opens up down", + "transformations absolute value", + "corner point", + "arms of the v" + ], + "explanation": "The graph of y = a|x − h| + k is a V whose corner sits at the vertex (h, k). The dashed gray line is the plain line a(x − h) + k; the absolute value FOLDS the part below the vertex upward, mirroring it to make the second arm — so both arms have slope ±a. Slide h and k to move the corner, and a to set steepness (negative a flips the V to open downward).", + "bullets": [ + "The vertex (h, k) is the corner — the minimum if a > 0, the maximum if a < 0.", + "Both arms have slope ±a; the absolute value reflects one half of the line.", + "h shifts the corner left/right (x − h moves RIGHT by h); k shifts it up/down." + ], + "params": [ + { + "name": "a", + "label": "steepness a", + "min": -3, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "h", + "label": "shift right h", + "min": -5, + "max": 5, + "step": 0.5, + "value": 0 + }, + { + "name": "k", + "label": "shift up k", + "min": -3, + "max": 6, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 10 });\nv.grid(); v.axes();\nconst a = P.a, h = P.h, k = P.k;\nconst inside = x => a * (x - h) + k;\nconst f = x => a * Math.abs(x - h) + k;\nv.fn(inside, { color: H.colors.sub, width: 1.5, dash: [5, 5] });\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(h, -6, h, 10, { color: H.colors.violet, width: 1.2, dash: [4, 4] });\nv.dot(h, k, { r: 7, fill: H.colors.warn });\nconst x0 = h + 5 * Math.sin(t * 0.7);\nv.dot(x0, f(x0), { r: 6, fill: H.colors.good });\nH.text(\"y = a·|x − h| + k\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"vertex (\" + h.toFixed(1) + \", \" + k.toFixed(1) + \") arms slope ±\" + Math.abs(a).toFixed(2) + (a < 0 ? \" (opens down)\" : \"\"), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"a(x−h)+k (line)\", color: H.colors.sub }, { label: \"|·| folds it up\", color: H.colors.accent }], H.W - 190, 28);" + }, + { + "id": "a2-absolute-value-inequalities", + "area": "Algebra 2", + "topic": "Absolute value inequalities", + "title": "Absolute value inequality: |x − c| ≤ k", + "equation": "|x − c| <= k means c − k <= x <= c + k", + "keywords": [ + "absolute value inequality", + "absolute value inequalities", + "abs inequality", + "|x-c|<=k", + "distance", + "tolerance", + "compound inequality", + "and or", + "solution interval", + "number line", + "between", + "within k of c" + ], + "explanation": "An absolute value measures DISTANCE from a center c, so |x − c| ≤ k asks 'which x are within k of c?'. The answer is the band c − k ≤ x ≤ c + k — every point on the number line whose distance to c is at most k. Slide c to move the center and k to widen or narrow the band; the test point turns green when it lands inside and pink when it falls outside, exactly where the V dips below the dashed level line.", + "bullets": [ + "|x − c| is the distance from x to the center c on the number line.", + "|x − c| ≤ k is the closed band c − k ≤ x ≤ c + k (an AND/'between').", + "Flip to ≥ and you'd get the OUTSIDE two rays instead (an OR)." + ], + "params": [ + { + "name": "c", + "label": "center c", + "min": -6, + "max": 6, + "step": 0.5, + "value": 0 + }, + { + "name": "k", + "label": "tolerance k", + "min": 0.5, + "max": 6, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst c = P.c, k = Math.max(0, P.k);\n// Window must hold the band [c-k, c+k] AND the test point's full sweep, which\n// reaches c ± (k+3); also tall enough for f at the sweep's far point.\nconst reach = k + 3;\nconst xspan = Math.max(10, Math.abs(c) + reach + 1);\nconst ymax = Math.max(10, reach + 2);\nconst v = H.plot2d({ xMin: -xspan, xMax: xspan, yMin: -2, yMax: ymax });\nv.grid(); v.axes();\nconst f = (x) => Math.abs(x - c);\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(-xspan, k, xspan, k, { color: H.colors.violet, width: 2, dash: [6, 5] });\nconst lo = c - k, hi = c + k;\n// shade the solution interval |x - c| <= k on the x-axis\nv.line(lo, 0, hi, 0, { color: H.colors.good, width: 8 });\nv.dot(lo, 0, { r: 6, fill: H.colors.warn });\nv.dot(hi, 0, { r: 6, fill: H.colors.warn });\nv.dot(c, 0, { r: 5, fill: H.colors.accent2 });\n// animated test point sweeping across the number line\nconst xs = c + reach * Math.sin(t * 0.7);\nconst inside = Math.abs(xs - c) <= k;\nv.dot(xs, f(xs), { r: 6, fill: inside ? H.colors.good : H.colors.warn });\nv.line(xs, 0, xs, f(xs), { color: inside ? H.colors.good : H.colors.warn, width: 1.5, dash: [3, 3] });\nv.dot(xs, 0, { r: 5, fill: inside ? H.colors.good : H.colors.warn });\nH.text(\"|x − \" + c.toFixed(1) + \"| ≤ \" + k.toFixed(1), 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"solution: \" + lo.toFixed(1) + \" ≤ x ≤ \" + hi.toFixed(1) + \" test x = \" + xs.toFixed(1) + (inside ? \" ✓ inside\" : \" ✗ outside\"), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"|x − c|\", color: H.colors.accent }, { label: \"level k\", color: H.colors.violet }, { label: \"solution band\", color: H.colors.good }], H.W - 175, 28);" + }, + { + "id": "a2-adding-subtracting-rational-expressions", + "area": "Algebra 2", + "topic": "Adding/subtracting rational expressions", + "title": "Add rational expressions: a/b + c/d", + "equation": "a/b + c/d = (a*d + c*b)/(b*d)", + "keywords": [ + "adding rational expressions", + "subtracting rational expressions", + "add fractions", + "common denominator", + "least common denominator", + "lcd", + "unlike denominators", + "combine fractions", + "a/b + c/d", + "rational expression", + "rescale fractions", + "like denominators" + ], + "explanation": "You can only add fractions once their denominators match. Slide a, b, c, d to set the two fractions; the bars below rescale BOTH to the common denominator b*d, then stack the highlighted pieces to form the sum. Watch each fraction get cut into finer pieces (same total amount, more slices) until both share the same slice size and the tops simply add.", + "bullets": [ + "Unlike denominators can't be added directly — first rescale to a common one.", + "Multiplying top and bottom by the same factor keeps a fraction's value unchanged.", + "Once denominators match, add only the numerators: a*d + c*b over b*d." + ], + "params": [ + { + "name": "a", + "label": "top of 1st a", + "min": 1, + "max": 6, + "step": 1, + "value": 1 + }, + { + "name": "b", + "label": "bottom of 1st b", + "min": 1, + "max": 5, + "step": 1, + "value": 2 + }, + { + "name": "c", + "label": "top of 2nd c", + "min": 1, + "max": 6, + "step": 1, + "value": 1 + }, + { + "name": "d", + "label": "bottom of 2nd d", + "min": 1, + "max": 5, + "step": 1, + "value": 3 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst a = Math.round(P.a), b = Math.round(P.b), c = Math.round(P.c), d = Math.round(P.d);\n// Common denominator method for a/b + c/d, shown as scaled bar models.\nconst bd = (b === 0 ? 1 : b), dd = (d === 0 ? 1 : d);\nconst lcd = Math.abs(bd * dd) || 1;\nconst num = a * dd + c * bd;\nH.text(\"Add fractions: a/b + c/d\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Rescale BOTH to the common denominator b*d, then add the tops.\", 24, 52, { color: H.colors.sub, size: 13 });\n// Step reveal driven by t: 0 = show pieces, 1 = rescale, 2 = sum.\nconst step = Math.floor((t % 9) / 3);\nconst barX = 90, barW = w - 180, rowH = 46;\n// One whole = the bar OUTLINE = `den` equal pieces. Lit pieces = `val`\n// (may exceed `den` for an improper fraction, so the fill runs past one whole).\n// A single shared piece-width keeps \"a piece\" the same physical size in every\n// row and keeps the widest fill inside barW.\nconst maxVal = Math.max(a, c, a * dd, c * bd, Math.abs(num), lcd, 1);\nconst unit = barW / maxVal; // px per single piece (1/den of a whole)\nfunction bar(y, label, val, den, col, lit) {\n H.text(label, 24, y + rowH * 0.62, { color: H.colors.sub, size: 14 });\n const n = Math.max(1, Math.abs(Math.round(den)));\n const lo = Math.max(0, Math.round(val));\n const hh = rowH * 0.7;\n // outline marks the \"one whole\" = n pieces\n H.rect(barX, y, n * unit, hh, { stroke: H.colors.grid, width: 1.5, radius: 6 });\n for (let i = 0; i < lo; i++) {\n H.rect(barX + i * unit + 2, y + 2, unit - 4, hh - 4, { fill: col, radius: 3 });\n }\n // empty pieces inside the whole (when proper fraction)\n for (let i = lo; i < n; i++) {\n H.rect(barX + i * unit + 2, y + 2, unit - 4, hh - 4, { fill: H.colors.panel, radius: 3 });\n }\n if (lit) {\n const sx = barX + ((t * 0.6) % 1) * barW;\n H.line(sx, y - 4, sx, y + hh + 4, { color: H.colors.warn, width: 2 });\n }\n}\nlet y0 = 92;\nbar(y0, (a) + \"/\" + bd, a, bd, H.colors.accent, true);\nbar(y0 + rowH + 14, (c) + \"/\" + dd, c, dd, H.colors.accent2, true);\nif (step >= 1) {\n bar(y0 + 2 * (rowH + 14), (a * dd) + \"/\" + lcd, a * dd, lcd, H.colors.accent, false);\n bar(y0 + 3 * (rowH + 14), (c * bd) + \"/\" + lcd, c * bd, lcd, H.colors.accent2, false);\n}\nif (step >= 2) {\n bar(y0 + 4 * (rowH + 14), num + \"/\" + lcd, Math.abs(num), lcd, H.colors.good, false);\n}\nconst stepName = step === 0 ? \"1) two unlike fractions\" : step === 1 ? \"2) rescale to /\" + lcd : \"3) add tops: \" + (a * dd) + \" + \" + (c * bd);\nH.text(stepName, 24, h - 44, { color: H.colors.violet, size: 14, weight: 700 });\nH.text(a + \"/\" + bd + \" + \" + c + \"/\" + dd + \" = \" + num + \"/\" + lcd, 24, h - 22, { color: H.colors.ink, size: 15, weight: 700 });" + }, + { + "id": "a2-arithmetic-sequences", + "area": "Algebra 2", + "topic": "Arithmetic sequences", + "title": "Arithmetic sequence: a_n = a_1 + (n-1)d", + "equation": "a_n = a_1 + (n - 1)*d", + "keywords": [ + "arithmetic sequence", + "common difference", + "nth term", + "a_n", + "a1", + "arithmetic progression", + "linear sequence", + "term formula", + "sequences", + "add same amount", + "explicit formula" + ], + "explanation": "An arithmetic sequence adds the SAME amount d every step, so the terms march along a straight line. The a_1 slider sets where the first dot lands and d sets the constant jump from each term to the next. Watch the green segment between dots: its rise is always exactly d, which is why the whole sequence lies on one line.", + "bullets": [ + "Each term equals the previous one plus the common difference d.", + "a_1 is the starting value; d is the constant step (the slope of the line).", + "The dots are evenly spaced, so a_n = a_1 + (n-1)d for any n." + ], + "params": [ + { + "name": "a1", + "label": "first term a_1", + "min": -8, + "max": 8, + "step": 0.5, + "value": 2 + }, + { + "name": "d", + "label": "common difference d", + "min": -4, + "max": 4, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 11, yMin: -10, yMax: 30 });\nv.grid(); v.axes();\nconst a1 = P.a1, d = P.d;\nconst an = (n) => a1 + (n - 1) * d;\nv.line(0, a1 - d, 11, a1 - d + 11 * d, { color: H.colors.violet, width: 1.5, dash: [5, 5] });\nfor (let n = 1; n <= 10; n++) {\n v.dot(n, an(n), { r: 5, fill: H.colors.accent });\n if (n >= 2) v.line(n - 1, an(n - 1), n, an(n), { color: H.colors.good, width: 2 });\n}\nconst k = 1 + Math.floor((t * 0.8) % 10);\nv.circle(k, an(k), 8 + 2 * Math.sin(t * 4), { fill: H.colors.warn });\nv.text(\"+\" + d.toFixed(1), 5.5, an(5) + d / 2 + 2, { color: H.colors.good, size: 13 });\nH.text(\"Arithmetic: a_n = a_1 + (n-1)d\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a_1 = \" + a1.toFixed(1) + \" d = \" + d.toFixed(1) + \" a_\" + k + \" = \" + an(k).toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"term a_n\", color: H.colors.accent }, { label: \"common difference d\", color: H.colors.good }], H.W - 220, 28);" + }, + { + "id": "a2-arithmetic-series", + "area": "Algebra 2", + "topic": "Arithmetic series", + "title": "Arithmetic series: S_n = n(a_1 + a_n)/2", + "equation": "S_n = n*(a_1 + a_n)/2", + "keywords": [ + "arithmetic series", + "sum of arithmetic sequence", + "partial sum", + "s_n", + "sum formula", + "gauss sum", + "adding terms", + "running total", + "series", + "average times count", + "sum a_1 to a_n" + ], + "explanation": "A series is the running total of a sequence's terms. Each bar is one term a_n; the lit bars are the ones already added into the sum, and you can watch the running total grow as the highlight sweeps across. The slick formula S_n = n(a_1+a_n)/2 just says: the sum equals the average of the first and last term times how many terms there are.", + "bullets": [ + "S_n adds up the first n terms of an arithmetic sequence.", + "Pairing the first and last term gives the average (a_1+a_n)/2.", + "Multiply that average by the count n to get the total instantly." + ], + "params": [ + { + "name": "a1", + "label": "first term a_1", + "min": -6, + "max": 10, + "step": 0.5, + "value": 2 + }, + { + "name": "d", + "label": "common difference d", + "min": -2, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "n", + "label": "how many terms n", + "min": 2, + "max": 10, + "step": 1, + "value": 6 + } + ], + "code": "H.background();\nconst a1 = P.a1, d = P.d, nMax = Math.max(2, Math.round(P.n));\nconst v = H.plot2d({ xMin: 0, xMax: nMax + 1, yMin: 0, yMax: Math.max(5, a1 + (nMax - 1) * d) * 1.15 });\nv.grid(); v.axes();\nconst an = (n) => a1 + (n - 1) * d;\nconst shown = 1 + Math.floor((t * 0.9) % nMax);\nlet sum = 0;\nfor (let n = 1; n <= nMax; n++) {\n const h = Math.max(0, an(n));\n const lit = n <= shown;\n v.rect(n - 0.4, 0, 0.8, h, { fill: lit ? H.colors.accent : H.colors.panel, stroke: H.colors.accent, width: 1.5 });\n if (lit) sum += an(n);\n}\nconst formula = nMax * (a1 + an(nMax)) / 2;\nv.line(0.6, a1, nMax + 0.4, an(nMax), { color: H.colors.violet, width: 2, dash: [5, 5] });\nv.circle(shown, Math.max(0, an(shown)) / 2, 6 + 2 * Math.sin(t * 4), { fill: H.colors.warn });\nH.text(\"Arithmetic series: S_n = n(a_1 + a_n)/2\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"adding \" + shown + \" terms: running sum = \" + sum.toFixed(1) + \" full S_\" + nMax + \" = \" + formula.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"counted term\", color: H.colors.accent }, { label: \"not yet added\", color: H.colors.panel }], H.W - 200, 28);" + }, + { + "id": "a2-completing-the-square", + "area": "Algebra 2", + "topic": "Completing the square", + "title": "Completing the square: x² + bx + c = (x + b/2)² + (c − (b/2)²)", + "equation": "x^2 + bx + c = (x + b/2)^2 + (c − (b/2)^2)", + "keywords": [ + "completing the square", + "complete the square", + "perfect square trinomial", + "(b/2)^2", + "half the b coefficient square it", + "x^2+bx+c", + "rewrite as a square", + "vertex from completing the square", + "add and subtract", + "area model square" + ], + "explanation": "Completing the square rebuilds x² + bx + c into a perfect square plus a leftover constant. You take HALF of b, square it to get (b/2)², and that piece is exactly the area needed to finish a square of side x + b/2 — the pulsing green square shows that area filling in. Adding (b/2)² makes the perfect square, so you subtract it back as c − (b/2)², which lands the vertex at x = −b/2 (the dashed axis). Slide b and c and watch the vertex move to (−b/2, c − (b/2)²).", + "bullets": [ + "Take half of b, square it: (b/2)² is the area that completes the square.", + "x² + bx + c = (x + b/2)² + (c − (b/2)²) — same expression, regrouped.", + "This exposes the vertex (−b/2, c − (b/2)²) with no graphing." + ], + "params": [ + { + "name": "b", + "label": "b coefficient", + "min": -6, + "max": 6, + "step": 0.5, + "value": 4 + }, + { + "name": "c", + "label": "constant c", + "min": -4, + "max": 8, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 12 });\nv.grid(); v.axes();\nconst b = P.b, c = P.c;\n// completing the square on x^2 + bx + c (leading coef 1 to keep the area model clean)\nconst f = (x) => x * x + b * x + c;\nv.fn(f, { color: H.colors.accent, width: 3 });\n// x^2 + bx + c = (x + b/2)^2 + (c - (b/2)^2)\nconst half = b / 2;\nconst h = -half; // vertex x\nconst k = c - half * half; // vertex y\nv.line(h, -6, h, 12, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.dot(h, k, { r: 7, fill: H.colors.warn });\n// animated \"completion\" bar: the (b/2)^2 we add then subtract, pulsing\nconst grow = 0.5 + 0.5 * Math.sin(t * 1.2);\nconst added = half * half * grow;\n// draw the area-model square of side |b/2| sitting near the vertex\nconst side = Math.abs(half);\nif (side > 0.05) {\n v.rect(h - side / 2, k, side, side * grow, { fill: H.colors.good, stroke: H.colors.good, width: 1 });\n}\nconst xs = h + 5 * Math.sin(t * 0.7);\nv.dot(xs, f(xs), { r: 6, fill: H.colors.accent2 });\nH.text(\"x² + bx + c = (x + b/2)² + (c − (b/2)²)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"b/2 = \" + half.toFixed(2) + \" (b/2)² = \" + (half * half).toFixed(2) + \" vertex (\" + h.toFixed(2) + \", \" + k.toFixed(2) + \")\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"axis x=−b/2\", color: H.colors.violet }, { label: \"vertex\", color: H.colors.warn }, { label: \"(b/2)² square\", color: H.colors.good }], H.W - 195, 28);" + }, + { + "id": "a2-complex-conjugates-division", + "area": "Algebra 2", + "topic": "Complex conjugates and division", + "title": "Conjugate: z·conj(z) = a² + b² (real)", + "equation": "conj(a + b·i) = a - b·i, z · conj(z) = a^2 + b^2", + "keywords": [ + "complex conjugate", + "conjugate", + "dividing complex numbers", + "complex division", + "a - bi", + "reflection across real axis", + "magnitude", + "modulus", + "rationalize denominator", + "z times z bar", + "absolute value of complex number", + "conjugate" + ], + "explanation": "The conjugate of z = a + bi flips the sign of the imaginary part, which mirrors the point across the real axis. The key trick for division is that z times its conjugate is a^2 + b^2 — a real number with no i — so multiplying top and bottom of a fraction by the denominator's conjugate clears the i out of the bottom. The grey circle of radius |z| shows that z and its conjugate are the same distance from the origin.", + "bullets": [ + "The conjugate a - bi is z reflected across the real (horizontal) axis.", + "z · conj(z) = a² + b² is always real — that's why it clears the denominator.", + "To divide, multiply top and bottom by the conjugate of the denominator." + ], + "params": [ + { + "name": "a", + "label": "real part a", + "min": -6, + "max": 6, + "step": 0.5, + "value": 3 + }, + { + "name": "b", + "label": "imag part b", + "min": -4, + "max": 4, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -7, xMax: 7, yMin: -5, yMax: 5 });\nv.grid(); v.axes();\nconst a = P.a, b = P.b;\nv.arrow(0, 0, a, b, { color: H.colors.accent, width: 2.5 });\nv.arrow(0, 0, a, -b, { color: H.colors.accent2, width: 2.5 });\nv.line(a, b, a, -b, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.dot(a, b, { r: 5, fill: H.colors.accent });\nv.dot(a, -b, { r: 5, fill: H.colors.accent2 });\nconst mag = Math.sqrt(a * a + b * b);\nconst cpts = [];\nfor (let i = 0; i <= 80; i++) { const th = i / 80 * H.TAU; cpts.push([mag * Math.cos(th), mag * Math.sin(th)]); }\nv.path(cpts, { color: H.colors.grid, width: 1.2 });\nconst th = t * 0.9;\nv.dot(mag * Math.cos(th), mag * Math.sin(th), { r: 6, fill: H.colors.warn });\nH.text(\"Conjugate & division: z = a + bi\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nconst sgn = (x) => (x >= 0 ? \"+\" : \"-\");\nconst m2 = (a * a + b * b);\nH.text(\"conj z = \" + a.toFixed(1) + sgn(-b) + Math.abs(b).toFixed(1) + \"i z*conj = a^2+b^2 = \" + m2.toFixed(1) + \" (real)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"z\", color: H.colors.accent }, { label: \"conj z\", color: H.colors.accent2 }], H.W - 150, 28);" + }, + { + "id": "a2-complex-number-operations", + "area": "Algebra 2", + "topic": "Complex number operations", + "title": "Adding complex numbers: (a+bi) + (c+di)", + "equation": "(a + b·i) + (c + d·i) = (a + c) + (b + d)·i", + "keywords": [ + "complex number operations", + "adding complex numbers", + "complex addition", + "a+bi", + "real and imaginary parts", + "complex plane", + "vector addition", + "parallelogram rule", + "combine real parts", + "combine imaginary parts", + "subtract complex numbers", + "complex" + ], + "explanation": "A complex number a + bi is a point (a, b) in the plane, so adding two of them is just adding their arrow tips: the real parts add and the imaginary parts add separately. The dashed lines complete the parallelogram, and the green arrow is the sum z1 + z2 reached tip-to-tail. Drag a, b, c, d to move each arrow and watch the sum land at (a+c, b+d).", + "bullets": [ + "Add real parts to real, imaginary parts to imaginary — never mix them.", + "Each complex number is a point/arrow in the plane (real, imaginary).", + "The sum is the parallelogram (tip-to-tail) diagonal of the two arrows." + ], + "params": [ + { + "name": "a", + "label": "Re z1 a", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "b", + "label": "Im z1 b", + "min": -4, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "c", + "label": "Re z2 c", + "min": -5, + "max": 5, + "step": 0.5, + "value": -2 + }, + { + "name": "d", + "label": "Im z2 d", + "min": -4, + "max": 4, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst a = P.a, b = P.b, c = P.c, d = P.d;\nconst sx = a + c, sy = b + d;\nv.arrow(0, 0, a, b, { color: H.colors.accent, width: 2.5 });\nv.arrow(0, 0, c, d, { color: H.colors.accent2, width: 2.5 });\nv.line(a, b, sx, sy, { color: H.colors.accent2, width: 1.5, dash: [4, 4] });\nv.line(c, d, sx, sy, { color: H.colors.accent, width: 1.5, dash: [4, 4] });\nv.arrow(0, 0, sx, sy, { color: H.colors.good, width: 3 });\nconst f = 0.5 + 0.5 * Math.sin(t * 1.2);\nv.dot(sx * f, sy * f, { r: 6, fill: H.colors.warn });\nv.dot(a, b, { r: 4, fill: H.colors.accent });\nv.dot(c, d, { r: 4, fill: H.colors.accent2 });\nv.dot(sx, sy, { r: 5, fill: H.colors.good });\nH.text(\"Adding complex numbers = adding vectors\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nconst sign = (x) => (x >= 0 ? \"+\" : \"-\");\nH.text(\"(\" + a.toFixed(1) + sign(b) + Math.abs(b).toFixed(1) + \"i) + (\" + c.toFixed(1) + sign(d) + Math.abs(d).toFixed(1) + \"i) = \" + sx.toFixed(1) + sign(sy) + Math.abs(sy).toFixed(1) + \"i\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"z1\", color: H.colors.accent }, { label: \"z2\", color: H.colors.accent2 }, { label: \"z1 + z2\", color: H.colors.good }], H.W - 150, 28);" + }, + { + "id": "a2-complex-roots-quadratic", + "area": "Algebra 2", + "topic": "Complex roots of quadratics", + "title": "Complex roots: x = (-b +/- sqrt(b^2-4ac)) / 2a", + "equation": "x = (-b +/- sqrt(b^2 - 4*a*c)) / (2*a)", + "keywords": [ + "complex roots", + "imaginary roots", + "complex solutions", + "conjugate pair", + "a plus bi", + "negative discriminant", + "imaginary numbers", + "no real roots", + "quadratic formula", + "complex plane", + "i sqrt", + "complex conjugate" + ], + "explanation": "When the discriminant goes negative, the square root pulls in i and the two roots leave the real number line entirely. This demo plots the roots on the COMPLEX plane (horizontal = real part, vertical = imaginary part) instead of on a parabola. Slide c up past the vertex and watch the two real roots slide together, collide, and then split vertically into a conjugate pair a +/- bi that is always mirror-symmetric across the real axis. The real part stays fixed at -b/2a no matter what.", + "bullets": [ + "A negative discriminant makes sqrt(D) imaginary, so the roots become a +/- bi.", + "Complex roots ALWAYS come in conjugate pairs, mirrored across the real axis.", + "The shared real part is -b/2a; the imaginary part is sqrt(-D)/2a." + ], + "params": [ + { + "name": "a", + "label": "a", + "min": -2, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "b", + "label": "b", + "min": -6, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "c", + "label": "c", + "min": -8, + "max": 8, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst cx = w * 0.62, cy = h * 0.54, sc = Math.min(w, h) * 0.12;\nconst a = Math.abs(P.a) < 0.15 ? 0.15 : P.a;\nconst b = P.b, c = P.c;\nconst disc = b * b - 4 * a * c;\nconst re = -b / (2 * a);\nH.line(cx - sc * 5, cy, cx + sc * 5, cy, { color: H.colors.axis, width: 1.2 });\nH.line(cx, cy - sc * 4, cx, cy + sc * 4, { color: H.colors.axis, width: 1.2 });\nH.text(\"Re\", cx + sc * 5 + 6, cy + 4, { color: H.colors.sub, size: 12 });\nH.text(\"Im\", cx + 6, cy - sc * 4 - 4, { color: H.colors.sub, size: 12 });\nlet im = 0, label;\nif (disc >= 0) {\n const r = Math.sqrt(disc) / (2 * a);\n const x1 = re + r, x2 = re - r;\n H.circle(cx + x1 * sc, cy, 7, { fill: H.colors.good });\n H.circle(cx + x2 * sc, cy, 7, { fill: H.colors.good });\n label = \"D ≥ 0: roots are REAL (on the Re axis)\";\n} else {\n im = Math.sqrt(-disc) / (2 * a);\n const pulse = 6 + 1.5 * Math.sin(t * 3);\n H.circle(cx + re * sc, cy - im * sc, pulse, { fill: H.colors.warn });\n H.circle(cx + re * sc, cy + im * sc, pulse, { fill: H.colors.accent2 });\n H.line(cx + re * sc, cy - im * sc, cx + re * sc, cy + im * sc, { color: H.colors.violet, width: 1.4, dash: [4, 4] });\n label = \"D < 0: complex CONJUGATE pair a ± bi\";\n}\nconst ang = t * 0.8;\nH.circle(cx + (re + 0.4 * Math.cos(ang)) * sc, cy - (im + 0.4 * Math.sin(ang)) * sc, 4, { fill: H.colors.yellow });\nH.text(\"Complex roots of ax² + bx + c\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"D=\" + disc.toFixed(2) + \" Re=\" + re.toFixed(2) + \" Im=±\" + im.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(label, 24, 74, { color: disc < 0 ? H.colors.warn : H.colors.good, size: 13, weight: 600 });" + }, + { + "id": "a2-compound-interest", + "area": "Algebra 2", + "topic": "Compound interest", + "title": "Compound interest: A = P*(1 + r/n)^(n*t)", + "equation": "A = P * (1 + r/n)^(n*t)", + "keywords": [ + "compound interest", + "interest", + "principal", + "annual rate", + "compounding periods", + "a=p(1+r/n)^(nt)", + "savings", + "investment growth", + "compound vs simple", + "compounded monthly", + "interest formula" + ], + "explanation": "Compound interest pays interest on your interest: each period you multiply the balance by (1 + r/n), so growth feeds on itself rather than adding a flat amount. P is the starting principal, r the annual rate, and n how many times per year it compounds — raising n splits the year into more, smaller multiplications, which grows slightly faster. The y-axis shows the balance as a MULTIPLE of P; compare the bold compound curve (which bends upward) to the faint straight 'simple interest' line to see why compounding pulls ahead, while the readout prints the real dollar balance at the swept year t.", + "bullets": [ + "Interest is added to the balance, then the NEXT interest is figured on the bigger balance.", + "More compounding periods n (monthly vs yearly) grows the balance a bit faster.", + "Compound growth curves upward and beats the straight line of simple interest over time." + ], + "params": [ + { + "name": "principal", + "label": "principal P ($)", + "min": 100, + "max": 2000, + "step": 100, + "value": 1000 + }, + { + "name": "rate", + "label": "annual rate r", + "min": 0, + "max": 0.2, + "step": 0.01, + "value": 0.05 + }, + { + "name": "n", + "label": "compounds / year n", + "min": 1, + "max": 12, + "step": 1, + "value": 12 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 20, yMin: 0, yMax: 4 });\nv.grid(); v.axes();\nconst Pr = Math.max(0.1, P.principal), r = Math.max(0, P.rate), n = Math.max(1, Math.round(P.n));\nconst base = Pr;\nconst f = yr => Pr * Math.pow(1 + r / n, n * yr);\nconst g = yr => f(yr) / base;\nv.fn(g, { color: H.colors.accent, width: 3 });\nv.fn(yr => (Pr * (1 + r * yr)) / base, { color: H.colors.sub, width: 2, steps: 60 });\nv.dot(0, 1, { r: 6, fill: H.colors.warn });\nconst yr = (t % 20);\nconst bal = f(yr);\nv.dot(yr, Math.min(8, g(yr)), { r: 6, fill: H.colors.accent2 });\nH.text(\"A = P*(1 + r/n)^(n*t)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"P = $\" + Pr.toFixed(0) + \" r = \" + (r * 100).toFixed(0) + \"% n = \" + n + \"/yr at t = \" + yr.toFixed(1) + \" yr: A = $\" + bal.toFixed(0), 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"compound\", color: H.colors.accent }, { label: \"simple interest\", color: H.colors.sub }], H.W - 190, 28);" + }, + { + "id": "a2-conditional-binomial-probability", + "area": "Algebra 2", + "topic": "Conditional and binomial probability", + "title": "Binomial: P(X=k) = C(n,k)·p^k·(1−p)^(n−k)", + "equation": "P(X=k) = C(n,k) * p^k * (1-p)^(n-k), mean mu = n*p, P(X=k | X<=k) = P(X=k) / sum_{j<=k} P(X=j)", + "keywords": [ + "binomial", + "binomial probability", + "conditional probability", + "probability distribution", + "bernoulli trials", + "n choose k", + "success probability", + "p^k", + "expected value", + "mean np", + "pmf", + "given that" + ], + "explanation": "A binomial experiment is n independent yes/no trials, each a success with probability p; the bars show the chance of getting exactly k successes. The n and p sliders reshape the whole distribution — raising p slides the peak right, and the mean always lands at μ = np (the purple line). The sweeping bar reads out P(X=k) and a conditional probability P(X=k | X≤k), which renormalizes by only the outcomes still possible once you know X≤k — that's exactly what 'given that' does: shrink the sample space, then re-divide.", + "bullets": [ + "P(X=k) = C(n,k)·p^k·(1−p)^(n−k): choose which k trials succeed, times their probability.", + "The distribution is centered at the mean μ = np.", + "Conditional P(A|B) = P(A and B) / P(B): restrict to B, then renormalize." + ], + "params": [ + { + "name": "n", + "label": "trials n", + "min": 1, + "max": 20, + "step": 1, + "value": 10 + }, + { + "name": "p", + "label": "success prob p", + "min": 0.05, + "max": 0.95, + "step": 0.05, + "value": 0.5 + } + ], + "code": "H.background();\nconst w = H.W, hh = H.H;\nconst n = Math.max(1, Math.round(P.n));\nconst p = Math.min(0.99, Math.max(0.01, P.p));\nfunction fact(x) { let f = 1; for (let i = 2; i <= x; i++) f *= i; return f; }\nfunction C(nn, kk) { return fact(nn) / (fact(kk) * fact(nn - kk)); }\nconst probs = [];\nlet pmax = 0;\nfor (let k = 0; k <= n; k++) { const pr = C(n, k) * Math.pow(p, k) * Math.pow(1 - p, n - k); probs.push(pr); if (pr > pmax) pmax = pr; }\nH.text(\"Binomial: P(X=k) = C(n,k)·p^k·(1−p)^(n−k)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"n = \" + n + \" trials p = \" + p.toFixed(2) + \" mean μ = np = \" + (n * p).toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nconst baseY = hh - 90, plotH = hh - 200;\nconst bw = Math.min(60, (w - 120) / (n + 1));\nconst x0 = (w - bw * (n + 1)) / 2;\nconst litK = Math.floor((t * 0.8) % (n + 1));\nlet cum = 0;\nfor (let k = 0; k <= n; k++) {\n const bx = x0 + k * bw, bh = (pmax > 0 ? probs[k] / pmax : 0) * plotH;\n const lit = k === litK;\n H.rect(bx + 4, baseY - bh, bw - 8, bh, { fill: lit ? H.colors.warn : H.colors.accent, stroke: H.colors.bg, width: 1, radius: 3 });\n H.text(String(k), bx + bw * 0.5, baseY + 16, { color: H.colors.sub, size: 11, align: \"center\" });\n if (lit) H.text(probs[k].toFixed(3), bx + bw * 0.5, baseY - bh - 8, { color: H.colors.warn, size: 12, weight: 700, align: \"center\" });\n if (k <= litK) cum += probs[k];\n}\nH.line(x0, baseY, x0 + bw * (n + 1), baseY, { color: H.colors.axis, width: 1.5 });\nconst muX = x0 + (n * p + 0.5) * bw;\nH.line(muX, baseY - plotH, muX, baseY, { color: H.colors.violet, width: 1.5, dash: [5, 4] });\nH.text(\"μ\", muX, baseY - plotH - 6, { color: H.colors.violet, size: 13, weight: 700, align: \"center\" });\nH.text(\"P(X = \" + litK + \") = \" + probs[litK].toFixed(3) + \" conditional: P(X = \" + litK + \" | X ≤ \" + litK + \") = \" + (cum > 0 ? probs[litK] / cum : 0).toFixed(3), 24, hh - 30, { color: H.colors.good, size: 13, weight: 600 });\nH.legend([{ label: \"P(X=k)\", color: H.colors.accent }, { label: \"current k\", color: H.colors.warn }, { label: \"mean\", color: H.colors.violet }], w - 180, 28);" + }, + { + "id": "a2-conics-circle", + "area": "Algebra 2", + "topic": "Conics: circles", + "title": "Circle: (x - h)^2 + (y - k)^2 = r^2", + "equation": "(x - h)^2 + (y - k)^2 = r^2", + "keywords": [ + "circle", + "conic", + "conic section", + "center radius form", + "x-h squared", + "standard form circle", + "radius", + "center", + "equation of a circle", + "circles", + "h k r", + "distance from center" + ], + "explanation": "A circle is every point at the same distance r from a center (h, k) -- the equation just says 'distance from (h,k) squared equals r squared'. The h and k sliders slide the whole circle around without changing its size, and r grows or shrinks it about that center. The green dashed line is the radius: watch its tip ride the circle while staying exactly length r from the center.", + "bullets": [ + "(h, k) is the center; r is the radius -- both read straight off standard form.", + "Changing h or k translates the circle; changing r resizes it about the center.", + "Every point on the circle sits at distance exactly r from (h, k)." + ], + "params": [ + { + "name": "h", + "label": "center x h", + "min": -6, + "max": 6, + "step": 0.5, + "value": 1 + }, + { + "name": "k", + "label": "center y k", + "min": -5, + "max": 5, + "step": 0.5, + "value": 0 + }, + { + "name": "r", + "label": "radius r", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst h = P.h, k = P.k, r = Math.max(0.3, P.r);\nconst pts = [];\nfor (let i = 0; i <= 96; i++) {\n const th = i / 96 * H.TAU;\n pts.push([h + r * Math.cos(th), k + r * Math.sin(th)]);\n}\nv.path(pts, { color: H.colors.accent, width: 3, close: true });\nv.dot(h, k, { r: 6, fill: H.colors.warn });\nconst ang = t * 0.8;\nconst px = h + r * Math.cos(ang), py = k + r * Math.sin(ang);\nv.line(h, k, px, py, { color: H.colors.good, width: 2, dash: [4, 4] });\nv.dot(px, py, { r: 6, fill: H.colors.accent2 });\nv.text(\"(h,k)\", h + 0.3, k + 0.6, { color: H.colors.warn, size: 12 });\nv.text(\"r\", h + 0.5 * r * Math.cos(ang) + 0.3, k + 0.5 * r * Math.sin(ang), { color: H.colors.good, size: 13 });\nH.text(\"Circle: (x - h)^2 + (y - k)^2 = r^2\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"center (\" + h.toFixed(1) + \", \" + k.toFixed(1) + \") radius r = \" + r.toFixed(1) + \" point on circle (\" + px.toFixed(1) + \", \" + py.toFixed(1) + \")\",\n 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"center (h,k)\", color: H.colors.warn }, { label: \"radius r\", color: H.colors.good }, { label: \"point on circle\", color: H.colors.accent2 }], H.W - 175, 28);" + }, + { + "id": "a2-conics-ellipses-hyperbolas", + "area": "Algebra 2", + "topic": "Conics: ellipses and hyperbolas", + "title": "Ellipse & hyperbola: x²/a² ± y²/b² = 1", + "equation": "ellipse x^2/a^2 + y^2/b^2 = 1 (sum of focal distances = 2a); hyperbola x^2/a^2 - y^2/b^2 = 1 (difference = 2a)", + "keywords": [ + "ellipse", + "hyperbola", + "conic", + "conic section", + "foci", + "focal radii", + "major axis", + "minor axis", + "asymptote", + "eccentricity", + "x^2/a^2+y^2/b^2", + "sum of distances", + "difference of distances" + ], + "explanation": "Both of these conics are defined by their two foci. Flip the kind slider to compare them: an ellipse is every point whose distances to the two foci ADD to a constant 2a, while a hyperbola is every point whose distances SUBTRACT to a constant 2a. a and b stretch the curve along x and y; for the ellipse c = √|a²−b²| and for the hyperbola c = √(a²+b²), so the foci pull farther apart as the curve stretches. Watch the green and purple focal radii change while their sum (or difference) stays locked.", + "bullets": [ + "Ellipse: distances to the two foci ADD to 2a (a closed oval).", + "Hyperbola: distances to the two foci SUBTRACT to 2a (two opening branches).", + "Foci sit at c from the center: ellipse c = √|a²−b²|, hyperbola c = √(a²+b²)." + ], + "params": [ + { + "name": "kind", + "label": "0 ellipse / 1 hyperbola", + "min": 0, + "max": 1, + "step": 1, + "value": 0 + }, + { + "name": "a", + "label": "semi-axis a", + "min": 1, + "max": 7, + "step": 0.5, + "value": 5 + }, + { + "name": "b", + "label": "semi-axis b", + "min": 1, + "max": 5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -7, yMax: 7 });\nv.grid(); v.axes();\nconst a = Math.max(1, P.a), b = Math.max(1, P.b);\nconst hyper = P.kind >= 0.5;\nlet pts = [];\nif (!hyper) {\n for (let i = 0; i <= 120; i++) { const th = i / 120 * H.TAU; pts.push([a * Math.cos(th), b * Math.sin(th)]); }\n v.path(pts, { color: H.colors.accent, width: 3, close: true });\n} else {\n const right = [], left = [];\n for (let i = -60; i <= 60; i++) { const u = i / 22; right.push([a * Math.cosh(u), b * Math.sinh(u)]); left.push([-a * Math.cosh(u), b * Math.sinh(u)]); }\n v.path(right, { color: H.colors.accent, width: 3 });\n v.path(left, { color: H.colors.accent, width: 3 });\n v.line(-10, -10 * b / a, 10, 10 * b / a, { color: H.colors.grid, width: 1, dash: [3, 3] });\n v.line(-10, 10 * b / a, 10, -10 * b / a, { color: H.colors.grid, width: 1, dash: [3, 3] });\n}\nconst c = hyper ? Math.sqrt(a * a + b * b) : Math.sqrt(Math.abs(a * a - b * b));\nconst onMajor = hyper || a >= b;\nconst F1 = onMajor ? [c, 0] : [0, c], F2 = onMajor ? [-c, 0] : [0, -c];\nv.dot(F1[0], F1[1], { r: 6, fill: H.colors.warn });\nv.dot(F2[0], F2[1], { r: 6, fill: H.colors.warn });\nlet px, py;\nif (!hyper) { const th = t * 0.7; px = a * Math.cos(th); py = b * Math.sin(th); }\nelse { const u = 1.4 * Math.sin(t * 0.7); px = a * Math.cosh(u); py = b * Math.sinh(u); }\nv.dot(px, py, { r: 6, fill: H.colors.accent2 });\nv.line(px, py, F1[0], F1[1], { color: H.colors.good, width: 1.5 });\nv.line(px, py, F2[0], F2[1], { color: H.colors.violet, width: 1.5 });\nconst d1 = Math.hypot(px - F1[0], py - F1[1]), d2 = Math.hypot(px - F2[0], py - F2[1]);\nconst semiMajor = hyper ? a : Math.max(a, b);\nH.text(hyper ? \"Hyperbola: x²/a² − y²/b² = 1\" : \"Ellipse: x²/a² + y²/b² = 1\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text((hyper ? (\"|d1 − d2| = \" + Math.abs(d1 - d2).toFixed(2) + \" = 2·semi-major = \" + (2 * semiMajor).toFixed(2)) : (\"d1 + d2 = \" + (d1 + d2).toFixed(2) + \" = 2·semi-major = \" + (2 * semiMajor).toFixed(2))) + \" c = \" + c.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"foci\", color: H.colors.warn }, { label: \"d1\", color: H.colors.good }, { label: \"d2\", color: H.colors.violet }], H.W - 170, 28);" + }, + { + "id": "a2-conics-parabolas", + "area": "Algebra 2", + "topic": "Conics: parabolas", + "title": "Parabola: y = a(x − h)² + k", + "equation": "y = a(x - h)^2 + k, focus at y = k + 1/(4a), directrix y = k - 1/(4a)", + "keywords": [ + "parabola", + "conic", + "conic section", + "focus", + "directrix", + "vertex", + "axis of symmetry", + "y=a(x-h)^2+k", + "focal length", + "quadratic curve", + "vertex form", + "open up down" + ], + "explanation": "A parabola is every point that is equally far from a single focus point and a straight directrix line. The vertex (h, k) sits exactly halfway between them, and a controls how tightly the curve wraps the focus: the focal distance is 1/(4a), so a small a flings the focus far away and opens a wide bowl. Watch the orange point ride the curve — its green leg (to the focus) and orange leg (down to the directrix) stay equal length the whole way.", + "bullets": [ + "Vertex (h, k) is the turning point; the line x = h is the axis of symmetry.", + "Focus is 1/(4a) above the vertex; the directrix is the same distance below.", + "Defining property: every point is equidistant from the focus and the directrix." + ], + "params": [ + { + "name": "a", + "label": "shape a", + "min": -2, + "max": 2, + "step": 0.1, + "value": 0.5 + }, + { + "name": "h", + "label": "vertex x h", + "min": -5, + "max": 5, + "step": 0.5, + "value": 0 + }, + { + "name": "k", + "label": "vertex y k", + "min": -2, + "max": 8, + "step": 0.5, + "value": -2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -4, yMax: 12 });\nv.grid(); v.axes();\nconst a = (Math.abs(P.a) < 0.04 ? 0.04 : P.a), h = P.h, k = P.k;\nconst p = 1 / (4 * a);\nconst f = (x) => a * (x - h) * (x - h) + k;\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(h, -4, h, 12, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.dot(h, k, { r: 7, fill: H.colors.warn });\nv.dot(h, k + p, { r: 6, fill: H.colors.good });\nv.line(h - 7.5, k - p, h + 7.5, k - p, { color: H.colors.accent2, width: 2, dash: [6, 4] });\nconst xs = h + 5 * Math.sin(t * 0.7);\nconst ys = f(xs);\nv.dot(xs, ys, { r: 6, fill: H.colors.accent2 });\nv.line(xs, ys, h, k + p, { color: H.colors.good, width: 1.5 });\nv.line(xs, ys, xs, k - p, { color: H.colors.accent2, width: 1.5 });\nconst dF = Math.hypot(xs - h, ys - (k + p)), dD = Math.abs(ys - (k - p));\nH.text(\"Parabola: y = a(x − h)² + k\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"vertex (\" + h.toFixed(1) + \", \" + k.toFixed(1) + \") to focus = \" + dF.toFixed(2) + \" = to directrix = \" + dD.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"focus\", color: H.colors.good }, { label: \"directrix\", color: H.colors.accent2 }, { label: \"axis x=h\", color: H.colors.violet }], H.W - 210, 28);" + }, + { + "id": "a2-converting-quadratic-forms", + "area": "Algebra 2", + "topic": "Converting between quadratic forms", + "title": "Standard → vertex → factored: y = ax² + bx + c", + "equation": "h = −b/2a, k = c − b^2/4a, roots = (−b ± √(b^2 − 4ac))/2a", + "keywords": [ + "converting quadratic forms", + "convert between forms", + "standard to vertex", + "vertex to factored", + "h=-b/2a", + "rewrite quadratic", + "change form", + "ax^2+bx+c to vertex", + "discriminant", + "find vertex from standard", + "find roots" + ], + "explanation": "Start from standard form y = ax² + bx + c and convert without changing the graph. The vertex x is always h = −b/2a (and k = f(h)), which is why the dashed axis of symmetry tracks b and a. To reach factored form you need the roots, and the discriminant b² − 4ac decides whether they exist: ≥ 0 gives the two green crossings from the quadratic formula, < 0 means it can't factor over the reals. Adjust a, b, c and watch all three forms describe the same single curve.", + "bullets": [ + "Vertex from standard form: h = −b/2a, then k = f(h).", + "Roots (factored form) come from (−b ± √(b² − 4ac))/2a.", + "Discriminant b² − 4ac < 0 means no real factoring — vertex form still works." + ], + "params": [ + { + "name": "a", + "label": "a", + "min": -2, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "b", + "label": "b", + "min": -6, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "c", + "label": "c", + "min": -6, + "max": 6, + "step": 0.5, + "value": -3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 12 });\nv.grid(); v.axes();\nconst a = Math.abs(P.a) < 0.1 ? 0.1 : P.a, b = P.b, c = P.c;\n// start from standard form y = ax^2 + bx + c\nconst f = (x) => a * x * x + b * x + c;\nv.fn(f, { color: H.colors.accent, width: 3 });\n// convert to vertex form: h = -b/2a, k = f(h)\nconst h = -b / (2 * a);\nconst k = f(h);\nv.line(h, -6, h, 12, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.dot(h, k, { r: 7, fill: H.colors.warn });\n// convert to factored form via the discriminant: real roots when disc >= 0\nconst disc = b * b - 4 * a * c;\nif (disc >= 0) {\n const r1 = (-b + Math.sqrt(disc)) / (2 * a);\n const r2 = (-b - Math.sqrt(disc)) / (2 * a);\n v.dot(r1, 0, { r: 6, fill: H.colors.good });\n v.dot(r2, 0, { r: 6, fill: H.colors.good });\n}\nv.dot(0, c, { r: 6, fill: H.colors.accent2 });\nconst xs = h + 5 * Math.sin(t * 0.7);\nv.dot(xs, f(xs), { r: 6, fill: H.colors.accent2 });\nH.text(\"standard → vertex → factored\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst rootTxt = disc >= 0 ? \"factored: roots \" + ((-b + Math.sqrt(disc)) / (2 * a)).toFixed(1) + \", \" + ((-b - Math.sqrt(disc)) / (2 * a)).toFixed(1) : \"factored: no real roots (disc<0)\";\nH.text(\"vertex (\" + h.toFixed(1) + \", \" + k.toFixed(1) + \") h=−b/2a \" + rootTxt, 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"vertex h=−b/2a\", color: H.colors.warn }, { label: \"roots\", color: H.colors.good }, { label: \"y-int c\", color: H.colors.accent2 }], H.W - 185, 28);" + }, + { + "id": "a2-determinants", + "area": "Algebra 2", + "topic": "Determinants", + "title": "Determinant as signed area: det = ad - bc", + "equation": "det[[a,b],[c,d]] = a*d - b*c", + "keywords": [ + "determinant", + "det", + "ad minus bc", + "signed area", + "2x2 determinant", + "area of parallelogram", + "singular matrix", + "invertible", + "cross product", + "matrices", + "det equals zero", + "scaling factor" + ], + "explanation": "The determinant of a 2x2 matrix is the SIGNED area of the parallelogram built from its two column vectors. The a,c slider set the first column vector and b,d set the second; the shaded region is exactly that parallelogram. When the two columns line up (point the same direction) the parallelogram flattens to zero area, det = 0, and the matrix has no inverse. A negative determinant means the columns are in clockwise (flipped) order.", + "bullets": [ + "det = ad - bc is the signed area spanned by the two column vectors.", + "det = 0 means the columns are parallel and the matrix is NOT invertible.", + "The sign of det records orientation: + for counterclockwise, - for flipped." + ], + "params": [ + { + "name": "a", + "label": "col1 x a", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "c", + "label": "col1 y c", + "min": -5, + "max": 5, + "step": 0.5, + "value": 1 + }, + { + "name": "b", + "label": "col2 x b", + "min": -5, + "max": 5, + "step": 0.5, + "value": 1 + }, + { + "name": "d", + "label": "col2 y d", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst a = P.a, b = P.b, c = P.c, d = P.d;\nconst u = [a, c];\nconst ww = [b, d];\nconst det = a * d - b * c;\nconst para = [[0, 0], [u[0], u[1]], [u[0] + ww[0], u[1] + ww[1]], [ww[0], ww[1]]];\nv.path(para, { color: det >= 0 ? H.colors.good : H.colors.warn, width: 2, fill: det >= 0 ? \"rgba(103,232,176,0.18)\" : \"rgba(255,138,160,0.18)\", close: true });\nv.arrow(0, 0, u[0], u[1], { color: H.colors.accent, width: 3 });\nv.arrow(0, 0, ww[0], ww[1], { color: H.colors.accent2, width: 3 });\nconst loop = (t * 0.5) % 4;\nconst seg = Math.floor(loop), fr = loop - seg;\nconst p0 = para[seg], p1 = para[(seg + 1) % 4];\nconst tx = p0[0] + (p1[0] - p0[0]) * fr, ty = p0[1] + (p1[1] - p0[1]) * fr;\nv.dot(tx, ty, { r: 6, fill: H.colors.yellow });\nv.text(\"col 1\", u[0], u[1], { color: H.colors.accent, size: 12 });\nv.text(\"col 2\", ww[0], ww[1], { color: H.colors.accent2, size: 12 });\nH.text(\"Determinant = signed area: det = ad - bc\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"det = (\" + a.toFixed(1) + \")(\" + d.toFixed(1) + \") - (\" + b.toFixed(1) + \")(\" + c.toFixed(1) + \") = \" + det.toFixed(2) + (Math.abs(det) < 0.05 ? \" (collapsed: not invertible)\" : \"\"),\n 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"col 1 (a,c)\", color: H.colors.accent }, { label: \"col 2 (b,d)\", color: H.colors.accent2 }, { label: det >= 0 ? \"area > 0\" : \"area < 0\", color: det >= 0 ? H.colors.good : H.colors.warn }], H.W - 160, 28);" + }, + { + "id": "a2-discriminant", + "area": "Algebra 2", + "topic": "Discriminant", + "title": "Discriminant: D = b^2 - 4ac", + "equation": "D = b^2 - 4*a*c", + "keywords": [ + "discriminant", + "b^2-4ac", + "b squared minus 4ac", + "number of real roots", + "how many solutions", + "quadratic", + "real roots", + "nature of roots", + "two one no roots", + "ax^2+bx+c", + "under the square root", + "delta" + ], + "explanation": "Before you ever solve a quadratic, the single number D = b^2 - 4ac tells you how many times the parabola will cross the x-axis. Slide a, b, and c and watch the sign of D flip: when D is positive two green roots appear, when D is exactly zero the curve just kisses the axis at one point, and when D is negative the parabola lifts entirely off the axis so there are no real roots. The dashed violet line is the axis of symmetry x = -b/2a, the midpoint the roots are always balanced around.", + "bullets": [ + "D = b^2 - 4ac is the quantity under the square root in the quadratic formula.", + "D > 0: two real roots; D = 0: one (a double root); D < 0: none (complex).", + "The roots sit symmetrically about the axis x = -b/2a; D measures how far apart." + ], + "params": [ + { + "name": "a", + "label": "a (opens up/down)", + "min": -2, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "b", + "label": "b (slant)", + "min": -6, + "max": 6, + "step": 0.5, + "value": 0 + }, + { + "name": "c", + "label": "c (raises/lowers)", + "min": -8, + "max": 8, + "step": 0.5, + "value": -4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -10, yMax: 10 });\nv.grid(); v.axes();\nconst a = Math.abs(P.a) < 0.15 ? 0.15 : P.a;\nconst b = P.b, c = P.c;\nconst f = x => a * x * x + b * x + c;\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst disc = b * b - 4 * a * c;\nconst ax = -b / (2 * a);\nv.line(ax, -10, ax, 10, { color: H.colors.violet, width: 1.2, dash: [4, 4] });\nv.dot(ax, f(ax), { r: 5, fill: H.colors.violet });\nlet verdict, vcol;\nif (disc > 1e-9) {\n const r1 = (-b + Math.sqrt(disc)) / (2 * a), r2 = (-b - Math.sqrt(disc)) / (2 * a);\n v.dot(r1, 0, { r: 6, fill: H.colors.good });\n v.dot(r2, 0, { r: 6, fill: H.colors.good });\n verdict = \"> 0 -> 2 real roots (crosses twice)\"; vcol = H.colors.good;\n} else if (disc > -1e-9) {\n v.dot(ax, 0, { r: 7, fill: H.colors.warn });\n verdict = \"= 0 -> 1 real root (just touches)\"; vcol = H.colors.yellow;\n} else {\n verdict = \"< 0 -> no real roots (never crosses)\"; vcol = H.colors.warn;\n}\nconst xs = ax + 4 * Math.sin(t * 0.7);\nv.dot(xs, f(xs), { r: 5, fill: H.colors.accent2 });\nH.text(\"Discriminant: D = b² − 4ac\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a=\" + a.toFixed(1) + \" b=\" + b.toFixed(1) + \" c=\" + c.toFixed(1) + \" D=\" + disc.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"D \" + verdict, 24, 74, { color: vcol, size: 13, weight: 600 });" + }, + { + "id": "a2-domain-range-interval", + "area": "Algebra 2", + "topic": "Domain/range in interval notation", + "title": "Domain & range: y = sqrt(x - h) + k", + "equation": "y = sqrt(x - h) + k, domain [h, +inf), range [k, +inf)", + "keywords": [ + "domain", + "range", + "interval notation", + "domain and range", + "input output values", + "bracket notation", + "infinity", + "set of x values", + "set of y values", + "square root domain", + "restricted domain" + ], + "explanation": "Domain is every x the function will accept; range is every y it can produce. For a square-root graph the curve cannot start until x reaches h (you can't take the root of a negative), so the orange bracket on the x-axis opens at h. The lowest the curve ever gets is its starting height k, so the violet bracket on the y-axis opens at k. Slide h and k and watch both brackets and the interval readout slide with the corner.", + "bullets": [ + "Domain reads along the x-axis; range reads along the y-axis.", + "[ means the endpoint is INCLUDED; +inf always gets a round ) (never reached).", + "The corner point (h, k) is exactly where both intervals begin." + ], + "params": [ + { + "name": "h", + "label": "start x h", + "min": -6, + "max": 6, + "step": 0.5, + "value": -1 + }, + { + "name": "k", + "label": "start y k", + "min": -1, + "max": 6, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -2, yMax: 10 });\nv.grid(); v.axes();\nconst k = P.k, h = P.h;\nconst f = (x) => (x >= h ? Math.sqrt(x - h) + k : NaN);\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.dot(h, k, { r: 6, fill: H.colors.good });\nconst span = 6;\nconst xs = h + (span * ((t * 0.8) % 1));\nconst ys = f(xs);\nif (Number.isFinite(ys) && ys >= -2 && ys <= 10) v.dot(xs, ys, { r: 6, fill: H.colors.warn });\nv.line(h, -1.4, 8, -1.4, { color: H.colors.accent2, width: 5 });\nv.text(\"[\", h, -1.4, { color: H.colors.accent2, size: 22, weight: 700, baseline: \"middle\", align: \"center\" });\nv.line(-7.4, k, -7.4, 10, { color: H.colors.violet, width: 5 });\nv.text(\"[\", -7.4, k, { color: H.colors.violet, size: 22, weight: 700, baseline: \"middle\", align: \"center\" });\nH.text(\"Domain & range: y = sqrt(x - h) + k\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"domain [\" + h.toFixed(1) + \", +inf) range [\" + k.toFixed(1) + \", +inf)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"domain (x)\", color: H.colors.accent2 }, { label: \"range (y)\", color: H.colors.violet }], H.W - 170, 28);" + }, + { + "id": "a2-exponential-decay", + "area": "Algebra 2", + "topic": "Exponential decay", + "title": "Exponential decay: y = a*(1 - r)^x", + "equation": "y = a * (1 - r)^x", + "keywords": [ + "exponential decay", + "decay rate", + "percent decrease", + "half life", + "y=a(1-r)^x", + "decay factor", + "radioactive decay", + "depreciation", + "exponential", + "decreasing exponential", + "shrinking by percent" + ], + "explanation": "Exponential decay multiplies by a factor (1 - r) that is LESS than 1 every step, so the quantity keeps shrinking by the same percent and approaches zero without ever touching it. a is the starting amount at x = 0 (pink dot) and r is the decay rate — a bigger r means a smaller surviving factor, so the curve plunges faster. The violet dashed line marks the HALF-LIFE log(0.5)/log(1-r): the time to fall to half, which then repeats over and over (half, then a quarter, then an eighth).", + "bullets": [ + "Each step multiplies y by the decay factor 1 - r (a number between 0 and 1).", + "The half-life is the constant time to drop to half — it stays the same all the way down.", + "The curve nears zero asymptotically but never reaches it." + ], + "params": [ + { + "name": "a", + "label": "start a", + "min": 1, + "max": 10, + "step": 0.5, + "value": 8 + }, + { + "name": "r", + "label": "decay rate r", + "min": 0.05, + "max": 0.9, + "step": 0.05, + "value": 0.3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 12, yMin: -1, yMax: 12 });\nv.grid(); v.axes();\nconst a = Math.max(0.5, P.a), r = H.clamp(P.r, 0, 0.95);\nconst f = x => a * Math.pow(1 - r, x);\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.dot(0, a, { r: 6, fill: H.colors.warn });\nconst half = r > 0 ? Math.log(0.5) / Math.log(1 - r) : Infinity;\nif (Number.isFinite(half) && half <= 12) {\n v.line(half, 0, half, a / 2, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\n v.dot(half, a / 2, { r: 6, fill: H.colors.good });\n}\nconst xs = (t % 12);\nv.dot(xs, f(xs), { r: 6, fill: H.colors.accent2 });\nH.text(\"y = a*(1 - r)^x\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"start a = \" + a.toFixed(1) + \" decay r = \" + (r * 100).toFixed(0) + \"% half-life = \" + (Number.isFinite(half) ? half.toFixed(1) : \"inf\"), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"decay curve\", color: H.colors.accent }, { label: \"half-life\", color: H.colors.good }], H.W - 170, 28);" + }, + { + "id": "a2-exponential-equations", + "area": "Algebra 2", + "topic": "Exponential equations", + "title": "Exponential equation: b^x = C", + "equation": "b^x = C => x = log_b(C)", + "keywords": [ + "exponential equation", + "solve b^x = c", + "exponential equations", + "take the log", + "solve for exponent", + "b to the x equals", + "logarithm to solve", + "growth equation", + "x = log_b(c)", + "isolate exponent", + "solving exponentials" + ], + "explanation": "To solve b^x = C you find the exponent x that lifts the base b up to the target value C. The horizontal purple line is y = C and the blue curve is y = b^x; their crossing point is the solution, and dropping straight down gives x = log_b(C). The yellow dot sweeps back and forth along the curve so you can watch b^x rise and fall past the target, showing there is exactly one x that hits C.", + "bullets": [ + "b^x = C is solved by taking a log of both sides: x = log_b(C).", + "Graphically the answer is where y = b^x meets the horizontal line y = C.", + "C must be positive — an exponential b^x with b>0 is always above the x-axis." + ], + "params": [ + { + "name": "b", + "label": "base b", + "min": 1.3, + "max": 3, + "step": 0.1, + "value": 2 + }, + { + "name": "C", + "label": "target C", + "min": 0.5, + "max": 10, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 7, yMin: -2, yMax: 12 });\nv.grid(); v.axes();\nconst b = Math.min(3, Math.max(1.3, P.b));\nconst C = Math.max(0.5, P.C);\nv.fn(x => Math.pow(b, x), { color: H.colors.accent, width: 3 });\nv.line(-1, C, 7, C, { color: H.colors.violet, width: 2, dash: [6, 4] });\nconst sol = Math.log(C) / Math.log(b);\nif (sol >= -1 && sol <= 7) {\n v.dot(sol, C, { r: 7, fill: H.colors.warn });\n v.line(sol, -2, sol, C, { color: H.colors.good, width: 1.5, dash: [4, 4] });\n}\nconst xs = 3 + 3 * Math.sin(t * 0.6);\nconst ys = Math.pow(b, xs);\nv.dot(xs, ys, { r: 5, fill: H.colors.yellow });\nv.text(\"b^x = \" + ys.toFixed(1), xs + 0.15, Math.min(11, ys) + 0.4, { color: H.colors.sub, size: 11 });\nH.text(\"Exponential equation: b^x = C\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"solve \" + b.toFixed(1) + \"^x = \" + C.toFixed(1) + \" -> x = log_\" + b.toFixed(1) + \"(\" + C.toFixed(1) + \") = \" + sol.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"y = b^x\", color: H.colors.accent }, { label: \"y = C\", color: H.colors.violet }], H.W - 150, 28);" + }, + { + "id": "a2-exponential-growth", + "area": "Algebra 2", + "topic": "Exponential growth", + "title": "Exponential growth: y = a*(1 + r)^x", + "equation": "y = a * (1 + r)^x", + "keywords": [ + "exponential growth", + "growth rate", + "percent increase", + "compounding", + "doubling time", + "y=a(1+r)^x", + "growth factor", + "population growth", + "exponential", + "increasing exponential", + "constant percent growth" + ], + "explanation": "Exponential growth multiplies by the SAME factor (1 + r) every step, so a steady percent gain snowballs instead of adding a fixed amount like a line does. a is the starting amount at x = 0 (the pink dot) and r is the growth rate as a decimal — bump r from 0.05 to 0.10 and the curve doesn't just rise a little faster, it bends upward dramatically. The readout shows the doubling time log(2)/log(1+r): the quantity keeps doubling over equal-length intervals, which is the signature of exponential growth.", + "bullets": [ + "Each unit of x multiplies y by the growth factor 1 + r (constant ratio, not constant difference).", + "a is the value at x = 0; r is the per-step percent growth as a decimal.", + "Equal time intervals produce equal DOUBLINGS — growth accelerates as it goes." + ], + "params": [ + { + "name": "a", + "label": "start a", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 1 + }, + { + "name": "r", + "label": "growth rate r", + "min": 0, + "max": 1, + "step": 0.05, + "value": 0.3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: -1, yMax: 14 });\nv.grid(); v.axes();\nconst a = Math.max(0.1, P.a), r = Math.max(0, P.r);\nconst f = x => a * Math.pow(1 + r, x);\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.dot(0, a, { r: 6, fill: H.colors.warn });\nconst xs = (t % 10);\nv.dot(xs, Math.min(20, f(xs)), { r: 6, fill: H.colors.accent2 });\nconst doub = r > 0 ? Math.log(2) / Math.log(1 + r) : Infinity;\nH.text(\"y = a*(1 + r)^x\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"start a = \" + a.toFixed(1) + \" rate r = \" + (r * 100).toFixed(0) + \"% doubling time = \" + (Number.isFinite(doub) ? doub.toFixed(1) : \"inf\"), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"growth curve\", color: H.colors.accent }, { label: \"x = 0 -> a\", color: H.colors.warn }], H.W - 180, 28);" + }, + { + "id": "a2-exponential-logarithmic-graphs", + "area": "Algebra 2", + "topic": "Exponential/logarithmic graphs", + "title": "Exp & log graphs: y = b^x + k and its inverse", + "equation": "y = b^x + k reflects across y = x into y = log_b(x - k)", + "keywords": [ + "exponential graph", + "logarithmic graph", + "exp and log graphs", + "asymptote", + "horizontal asymptote", + "vertical asymptote", + "inverse functions", + "reflection across y=x", + "b^x graph", + "log graph shape", + "domain and range" + ], + "explanation": "An exponential and its matching logarithm are mirror images across the line y = x, which is why one races upward while the other crawls sideways. The base slider b sets how steeply y = b^x climbs, and k shifts the exponential up so its horizontal asymptote sits at y = k — which becomes the VERTICAL asymptote x = k of the orange log. Watch the two dots: a point (x, b^x+k) on the curve and its swapped partner (b^x+k, x) on the inverse always sit on opposite sides of y = x.", + "bullets": [ + "b^x has a horizontal asymptote; its inverse log has a vertical asymptote.", + "Reflecting across y = x swaps x and y — domain and range trade places.", + "Adding k raises the exponential's asymptote to y = k (and the log's to x = k)." + ], + "params": [ + { + "name": "b", + "label": "base b", + "min": 1.3, + "max": 4, + "step": 0.1, + "value": 2 + }, + { + "name": "k", + "label": "vertical shift k", + "min": -3, + "max": 3, + "step": 0.5, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst b = Math.min(4, Math.max(1.3, P.b));\nconst k = P.k;\nv.line(-6, -6, 6, 6, { color: H.colors.violet, width: 1.2, dash: [5, 5] });\nv.fn(x => Math.pow(b, x) + k, { color: H.colors.accent, width: 3 });\nv.fn(x => (x - k > 0 ? Math.log(x - k) / Math.log(b) : NaN), { color: H.colors.accent2, width: 3 });\nv.line(-6, k, 6, k, { color: H.colors.good, width: 1, dash: [3, 3] });\nv.line(k, -6, k, 6, { color: H.colors.good, width: 1, dash: [3, 3] });\nconst xs = 2.2 * Math.sin(t * 0.6);\nconst ye = Math.pow(b, xs) + k;\nv.dot(xs, ye, { r: 6, fill: H.colors.warn });\nif (ye > -6 && ye < 6) v.dot(ye, xs, { r: 6, fill: H.colors.yellow });\nH.text(\"Exponential & log graphs (mirror over y=x)\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"y = \" + b.toFixed(1) + \"^x + \" + k.toFixed(1) + \" (asymptote y=\" + k.toFixed(1) + \") inverse: y = log_\" + b.toFixed(1) + \"(x-\" + k.toFixed(1) + \")\", 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"b^x + k\", color: H.colors.accent }, { label: \"log_b(x-k)\", color: H.colors.accent2 }, { label: \"y = x\", color: H.colors.violet }], H.W - 165, 28);" + }, + { + "id": "a2-extraneous-radical-solutions", + "area": "Algebra 2", + "topic": "Extraneous solutions in radical equations", + "title": "Extraneous solutions: sqrt(x + a) = x + b", + "equation": "sqrt(x + a) = x + b", + "keywords": [ + "extraneous solution", + "extraneous root", + "radical equation", + "square root equation", + "squaring both sides", + "check solutions", + "sqrt(x+a)=x+b", + "false solution", + "radical", + "solving radicals", + "no solution radical" + ], + "explanation": "To solve a radical equation you square both sides, but squaring can INVENT solutions that the original equation never had: the square root only ever returns a value that is zero or positive, so any algebraic root where the right side x + b is negative cannot match it. Drag a (shifts the curve sideways) and b (tilts/raises the line): the algebra finds where the squared equations meet, then this demo CHECKS each candidate against the real square-root curve. Green dots are true solutions that lie on both graphs; pink dots are extraneous — produced by squaring but not actually on sqrt(x + a).", + "bullets": [ + "Squaring both sides can create roots the original equation never had.", + "sqrt(...) is never negative, so any candidate where x + b < 0 is extraneous.", + "Always substitute candidates back into the ORIGINAL equation to keep only the true ones." + ], + "params": [ + { + "name": "a", + "label": "inside shift a", + "min": -2, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "b", + "label": "line shift b", + "min": -4, + "max": 3, + "step": 0.5, + "value": -1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -3, xMax: 9, yMin: -3, yMax: 9 });\nv.grid(); v.axes();\nconst a = P.a, b = P.b;\nv.fn(x => (x + a >= 0 ? Math.sqrt(x + a) : NaN), { color: H.colors.accent, width: 3 });\nv.fn(x => x + b, { color: H.colors.accent2, width: 3 });\nconst A = 1, B = 2 * b - 1, C = b * b - a;\nconst disc = B * B - 4 * A * C;\nlet roots = [];\nif (disc >= 0) {\n roots.push((-B + Math.sqrt(disc)) / (2 * A));\n roots.push((-B - Math.sqrt(disc)) / (2 * A));\n}\nlet realCount = 0;\nroots.forEach(r => {\n const lhsOk = (r + a >= 0) && Math.abs(Math.sqrt(Math.max(0, r + a)) - (r + b)) < 1e-6;\n if (lhsOk) { v.dot(r, r + b, { r: 7, fill: H.colors.good }); realCount++; }\n else if (Number.isFinite(r)) { v.dot(r, r + b, { r: 7, fill: H.colors.warn }); }\n});\nconst xs = 3 + 3 * Math.sin(t * 0.7);\nif (xs + a >= 0) v.dot(xs, Math.sqrt(xs + a), { r: 5, fill: H.colors.violet });\nH.text(\"sqrt(x + a) = x + b\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a = \" + a.toFixed(1) + \" b = \" + b.toFixed(1) + \" true solutions: \" + realCount, 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"sqrt(x+a)\", color: H.colors.accent }, { label: \"x+b\", color: H.colors.accent2 }, { label: \"true root\", color: H.colors.good }, { label: \"extraneous\", color: H.colors.warn }], H.W - 170, 28);" + }, + { + "id": "a2-extraneous-solutions", + "area": "Algebra 2", + "topic": "Extraneous solutions in rational equations", + "title": "Extraneous solutions: x/(x − h) = c/(x − h)", + "equation": "x/(x - h) = c/(x - h) => x = c (rejected if c = h)", + "keywords": [ + "extraneous solution", + "extraneous root", + "rational equation", + "reject solution", + "excluded value", + "domain restriction", + "denominator zero", + "check solutions", + "false solution", + "multiply by denominator", + "undefined", + "rational" + ], + "explanation": "Multiplying both sides of x/(x − h) = c/(x − h) by (x − h) gives the candidate x = c — but that step is only legal where x − h ≠ 0. Slide c: the green dot is a genuine solution while c stays away from the excluded value h (the dashed line). Drag c right onto h and the candidate lands exactly on the forbidden vertical line, where the equation is undefined — that candidate is EXTRANEOUS and must be thrown out.", + "bullets": [ + "Multiplying away a denominator can invent solutions the original didn't allow.", + "Any x that makes a denominator zero is excluded — here that's x = h.", + "Always check candidates: reject any that hit an excluded value (extraneous)." + ], + "params": [ + { + "name": "h", + "label": "excluded value h", + "min": -5, + "max": 5, + "step": 0.5, + "value": 1 + }, + { + "name": "c", + "label": "candidate c", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\n// Equation: x/(x - h) = c/(x - h). Multiply by (x - h): x = c.\n// The candidate x = c is EXTRANEOUS when c = h (it makes the denominator 0).\nconst h = P.h, c = P.c;\n// excluded value: x = h (denominator zero) — draw the forbidden line\nv.line(h, -8, h, 8, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nH.text(\"x = h excluded\", v.X(h) + 6, v.Y(7.2), { color: H.colors.violet, size: 12 });\n// both sides as functions (equal everywhere they're defined except their domain)\nv.fn(x => (Math.abs(x - h) < 0.05 ? NaN : x / (x - h)), { color: H.colors.accent, width: 3 });\nv.fn(x => (Math.abs(x - h) < 0.05 ? NaN : c / (x - h)), { color: H.colors.accent2, width: 2.4, dash: [6, 5] });\nH.text(\"Extraneous solutions: x/(x-h) = c/(x-h)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\n// candidate from the algebra is x = c; valid only if c != h\nconst extraneous = Math.abs(c - h) < 1e-6;\nconst yc = (Math.abs(c - h) < 0.05) ? NaN : c / (c - h);\nif (!extraneous && c >= -8 && c <= 8 && Number.isFinite(yc) && yc >= -8 && yc <= 8) {\n H.circle(v.X(c), v.Y(yc), 6 + 1.5 * Math.sin(t * 3), { fill: H.colors.good });\n H.text(\"x = c = \" + c.toFixed(1) + \" is a REAL solution\", 24, 52, { color: H.colors.good, size: 14 });\n} else {\n // mark the excluded point with a pulsing red marker on the asymptote\n const yy = v.Y(2 * Math.sin(t * 1.2));\n H.circle(v.X(h), yy, 7, { stroke: H.colors.warn, width: 2.5 });\n H.text(\"x = c = \" + c.toFixed(1) + \" EQUALS h → EXTRANEOUS (rejected)\", 24, 52, { color: H.colors.warn, size: 13, weight: 700 });\n}\nH.text(\"h = \" + h.toFixed(1) + \" c = \" + c.toFixed(1) + \" (slide c onto h to break it)\", 24, 74, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"x/(x-h)\", color: H.colors.accent }, { label: \"c/(x-h)\", color: H.colors.accent2 }, { label: \"valid sol.\", color: H.colors.good }], H.W - 160, 28);" + }, + { + "id": "a2-factor-theorem", + "area": "Algebra 2", + "topic": "Factor theorem", + "title": "Factor theorem: (x − c) is a factor ⇔ f(c) = 0", + "equation": "(x - c) is a factor of f(x) <=> f(c) = 0", + "keywords": [ + "factor theorem", + "factor", + "is x minus c a factor", + "root", + "zero of polynomial", + "f(c)=0", + "x intercept", + "factored form", + "x - c", + "test a factor", + "polynomial factor", + "remainder zero" + ], + "explanation": "The factor theorem is the remainder theorem's punchline: (x − c) divides f(x) evenly exactly when f(c) = 0. Drag the probe c left and right — when its value f(c) lands ON the x-axis the marker turns green, meaning (x − c) is a genuine factor; anywhere else it's red and there's a leftover remainder. The two green dots are the real roots r₁, r₂, so the probe only goes green when it sits on one of them.", + "bullets": [ + "(x − c) is a factor of f exactly when f(c) = 0 (the remainder is zero).", + "Every real root r gives a factor (x − r); the graph crosses there.", + "If f(c) ≠ 0 the probe is off the axis — (x − c) is not a factor." + ], + "params": [ + { + "name": "a", + "label": "leading a", + "min": -2, + "max": 2, + "step": 0.5, + "value": 1 + }, + { + "name": "r1", + "label": "root r₁", + "min": -5, + "max": 5, + "step": 0.5, + "value": -2 + }, + { + "name": "r2", + "label": "root r₂", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "probe", + "label": "probe c", + "min": -6, + "max": 6, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst r1 = P.r1, r2 = P.r2, a = P.a, probe = P.probe;\nconst f = x => a * (x - r1) * (x - r2);\nconst v = H.plot2d({ xMin: -7, xMax: 7, yMin: -10, yMax: 10 });\nv.grid(); v.axes();\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.dot(r1, 0, { r: 6, fill: H.colors.good });\nv.dot(r2, 0, { r: 6, fill: H.colors.good });\nconst fp = f(probe);\nconst onAxis = Math.abs(fp) < 1e-6;\nv.line(probe, 0, probe, fp, { color: onAxis ? H.colors.good : H.colors.warn, width: 2, dash: [4, 4] });\nv.dot(probe, fp, { r: 7, fill: onAxis ? H.colors.good : H.colors.warn });\nconst xs = 5 * Math.sin(t * 0.7);\nv.dot(xs, f(xs), { r: 5, fill: H.colors.accent2 });\nH.text(\"Factor Theorem: (x − c) is a factor ⇔ f(c) = 0\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"probe c = \" + probe.toFixed(1) + \" f(c) = \" + fp.toFixed(2) + (onAxis ? \" → (x−c) IS a factor\" : \" → not a factor\"), 24, 52, { color: onAxis ? H.colors.good : H.colors.sub, size: 13 });\nH.legend([{ label: \"roots (f=0)\", color: H.colors.good }, { label: \"probe f(c)\", color: H.colors.warn }], H.W - 175, 28);" + }, + { + "id": "a2-finding-polynomial-zeros", + "area": "Algebra 2", + "topic": "Finding polynomial zeros", + "title": "Finding zeros: f(x) = a(x−r₁)(x−r₂)(x−r₃)", + "equation": "f(x) = a*(x - r1)*(x - r2)*(x - r3)", + "keywords": [ + "zeros", + "roots", + "x intercepts", + "find zeros", + "solve polynomial", + "factored form", + "where f equals zero", + "cubic roots", + "real roots", + "solutions", + "zero product property", + "polynomial zeros" + ], + "explanation": "A polynomial's zeros are the x-values where its graph crosses the x-axis (where f(x) = 0). In factored form f(x) = a(x − r₁)(x − r₂)(x − r₃), the zeros are sitting right inside the parentheses — slide r₁, r₂, r₃ and the three colored crossings move with them. The dashed vertical sweep glides across the window so you can watch the curve dip to exactly zero each time it passes a root.", + "bullets": [ + "Set each factor to zero: x − rᵢ = 0 gives the zero x = rᵢ.", + "A degree-n polynomial has at most n real zeros (here up to 3).", + "In factored form the zeros are read off directly — no solving needed." + ], + "params": [ + { + "name": "a", + "label": "leading a", + "min": -1.5, + "max": 1.5, + "step": 0.1, + "value": 0.4 + }, + { + "name": "r1", + "label": "zero r₁", + "min": -6, + "max": 6, + "step": 0.5, + "value": -4 + }, + { + "name": "r2", + "label": "zero r₂", + "min": -6, + "max": 6, + "step": 0.5, + "value": 0 + }, + { + "name": "r3", + "label": "zero r₃", + "min": -6, + "max": 6, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst r1 = P.r1, r2 = P.r2, r3 = P.r3, a = P.a;\nconst f = x => a * (x - r1) * (x - r2) * (x - r3);\nconst v = H.plot2d({ xMin: -7, xMax: 7, yMin: -12, yMax: 12 });\nv.grid(); v.axes();\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.dot(r1, 0, { r: 6, fill: H.colors.good });\nv.dot(r2, 0, { r: 6, fill: H.colors.warn });\nv.dot(r3, 0, { r: 6, fill: H.colors.violet });\nconst xs = 6 * Math.sin(t * 0.6);\nv.dot(xs, f(xs), { r: 6, fill: H.colors.accent2 });\nconst sweep = -6 + ((t * 1.4) % 12);\nconst fy = f(sweep);\nv.line(sweep, 0, sweep, fy, { color: H.colors.sub, width: 1, dash: [3, 3] });\nH.text(\"Finding zeros: f(x) = a(x−r₁)(x−r₂)(x−r₃)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"zeros at x = \" + r1.toFixed(1) + \", \" + r2.toFixed(1) + \", \" + r3.toFixed(1) + \" (each crosses the x-axis)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"r₁\", color: H.colors.good }, { label: \"r₂\", color: H.colors.warn }, { label: \"r₃\", color: H.colors.violet }], H.W - 130, 28);" + }, + { + "id": "a2-function-composition", + "area": "Algebra 2", + "topic": "Function composition", + "title": "Composition: (f ∘ g)(x) = f(g(x))", + "equation": "(f ∘ g)(x) = f(g(x)), g(x) = a*x + b, f(u) = u^2 + c", + "keywords": [ + "function composition", + "composite function", + "compose", + "f of g", + "f(g(x))", + "fog", + "f o g", + "inner function", + "outer function", + "chaining functions", + "nested functions", + "plug in" + ], + "explanation": "Composition feeds one function's output into another: first g acts on x, then f acts on that result. The orange line is g(x) = a*x + b (the inner function); the blue curve is the composite f(g(x)). Follow the moving point: the violet drop shows g(x), then the green drop lifts it to f(g(x)) — order matters, because g runs first.", + "bullets": [ + "Work inside-out: compute the inner g(x) first, then feed it to the outer f.", + "(f ∘ g)(x) is usually NOT the same as (g ∘ f)(x) — order changes the result.", + "The output of g becomes the input of f, so g's range must fit f's domain." + ], + "params": [ + { + "name": "a", + "label": "inner slope a", + "min": -3, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "b", + "label": "inner shift b", + "min": -4, + "max": 4, + "step": 0.5, + "value": 1 + }, + { + "name": "c", + "label": "outer shift c", + "min": -4, + "max": 6, + "step": 0.5, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -6, yMax: 10 });\nv.grid(); v.axes();\nconst a = P.a, b = P.b, c = P.c;\nconst g = x => a * x + b;\nconst f = u => u * u + c;\nconst fog = x => f(g(x));\nv.fn(g, { color: H.colors.accent2, width: 2 });\nv.fn(fog, { color: H.colors.accent, width: 3 });\nconst x0 = 3.5 * Math.sin(t * 0.7);\nconst gx = g(x0);\nconst y0 = f(gx);\nv.line(x0, 0, x0, gx, { color: H.colors.violet, width: 2, dash: [4, 4] });\nv.line(x0, gx, x0, y0, { color: H.colors.good, width: 2, dash: [4, 4] });\nv.dot(x0, 0, { r: 5, fill: H.colors.sub });\nv.dot(x0, gx, { r: 6, fill: H.colors.accent2 });\nv.dot(x0, y0, { r: 6, fill: H.colors.warn });\nH.text(\"(f ∘ g)(x) = f(g(x))\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"x = \" + x0.toFixed(2) + \" g(x) = \" + gx.toFixed(2) + \" f(g(x)) = \" + y0.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"g(x)=ax+b\", color: H.colors.accent2 }, { label: \"f(g(x))\", color: H.colors.accent }], H.W - 170, 28);" + }, + { + "id": "a2-function-notation-evaluation", + "area": "Algebra 2", + "topic": "Function notation and evaluation", + "title": "Evaluating f(x): f(x) = a x^2 + b x + c", + "equation": "f(x) = a*x^2 + b*x + c", + "keywords": [ + "function notation", + "evaluate", + "evaluation", + "f of x", + "plug in", + "substitute", + "input output", + "f(2)", + "find f(x)", + "function machine", + "evaluating functions" + ], + "explanation": "f(x) is just a name for a rule: 'whatever you put in for x, do these operations.' Slide 'input' to choose an x, then the green dashed line goes UP from that input to the curve and the orange dashed line goes ACROSS to read off the output f(x). The readout shows the full substitution so you can see the number replace every x. The violet dot keeps sweeping to remind you f works for every input, not just this one.", + "bullets": [ + "f(2) means substitute 2 for EVERY x, then simplify to one number.", + "The input is an x-value; the output f(input) is the matching y on the curve.", + "Same rule, different inputs, different outputs: that is what a function does." + ], + "params": [ + { + "name": "a", + "label": "a", + "min": -2, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "b", + "label": "b", + "min": -4, + "max": 4, + "step": 0.5, + "value": 0 + }, + { + "name": "c", + "label": "c", + "min": -4, + "max": 6, + "step": 0.5, + "value": 1 + }, + { + "name": "input", + "label": "input x", + "min": -4, + "max": 4, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -4, yMax: 16 });\nv.grid(); v.axes();\nconst a = P.a, b = P.b, c = P.c;\nconst f = (x) => a * x * x + b * x + c;\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst inp = P.input;\nconst out = f(inp);\nv.line(inp, 0, inp, out, { color: H.colors.good, width: 2, dash: [5, 5] });\nv.line(0, out, inp, out, { color: H.colors.accent2, width: 2, dash: [5, 5] });\nv.dot(inp, out, { r: 7, fill: H.colors.warn });\nv.dot(inp, 0, { r: 5, fill: H.colors.good });\nv.dot(0, out, { r: 5, fill: H.colors.accent2 });\nconst sweep = 4 * Math.sin(t * 0.7);\nconst sy = f(sweep);\nif (Number.isFinite(sy) && sy >= -4 && sy <= 16) v.dot(sweep, sy, { r: 5, fill: H.colors.violet });\nH.text(\"Function notation: f(x) = a x^2 + b x + c\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"f(\" + inp.toFixed(1) + \") = \" + a.toFixed(1) + \"(\" + inp.toFixed(1) + \")^2 + \" + b.toFixed(1) + \"(\" + inp.toFixed(1) + \") + \" + c.toFixed(1) + \" = \" + out.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"input x\", color: H.colors.good }, { label: \"output f(x)\", color: H.colors.accent2 }], H.W - 180, 28);" + }, + { + "id": "a2-function-operations", + "area": "Algebra 2", + "topic": "Function operations", + "title": "Combining functions: (f op g)(x)", + "equation": "(f+g), (f-g), (f*g), (f/g) all evaluated at x", + "keywords": [ + "function operations", + "combine functions", + "add functions", + "subtract functions", + "multiply functions", + "divide functions", + "f plus g", + "f times g", + "sum of functions", + "quotient of functions", + "(f+g)(x)" + ], + "explanation": "You can combine two functions point by point: at each x, grab f(x) and g(x) and add, subtract, multiply, or divide those two numbers. Slide 'op' to switch operation and watch the bold blue result curve change while the faint f and g curves stay put. The two small dots show f(x) and g(x) at the swept point, and the pink dot shows their combination, so you can see the result is built from the parents. For (f/g) the curve breaks wherever g(x) = 0, because you can't divide by zero.", + "bullets": [ + "Combine at each x: (f+g)(x) = f(x) + g(x), and similarly for -, *, /.", + "The result is a brand-new function built from the two parent functions.", + "(f/g)(x) is undefined wherever g(x) = 0, leaving gaps in that graph." + ], + "params": [ + { + "name": "m", + "label": "f slope m", + "min": -3, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "b", + "label": "f intercept b", + "min": -4, + "max": 4, + "step": 0.5, + "value": 1 + }, + { + "name": "op", + "label": "op: 1+ 2- 3* 4/", + "min": 1, + "max": 4, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst m = P.m, b = P.b;\nconst f = (x) => m * x + b;\nconst g = (x) => Math.sin(x) + 1;\nconst op = Math.round(P.op);\nconst opNames = [\"(f+g)(x)\", \"(f-g)(x)\", \"(f*g)(x)\", \"(f/g)(x)\"];\nconst combine = (x) => {\n if (op === 1) return f(x) + g(x);\n if (op === 2) return f(x) - g(x);\n if (op === 3) return f(x) * g(x);\n const d = g(x);\n return Math.abs(d) > 1e-3 ? f(x) / d : NaN;\n};\nconst idx = Math.max(0, Math.min(3, op - 1));\nv.fn(f, { color: H.colors.sub, width: 1.5 });\nv.fn(g, { color: H.colors.violet, width: 1.5 });\nv.fn(combine, { color: H.colors.accent, width: 3 });\nconst xs = 5 * Math.sin(t * 0.6);\nconst yc = combine(xs);\nif (Number.isFinite(yc) && yc >= -8 && yc <= 8) v.dot(xs, yc, { r: 6, fill: H.colors.warn });\nv.dot(xs, f(xs), { r: 4, fill: H.colors.sub });\nv.dot(xs, g(xs), { r: 4, fill: H.colors.violet });\nH.text(\"Function operations: \" + opNames[idx], 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"f(x)=\" + m.toFixed(1) + \"x+\" + b.toFixed(1) + \", g(x)=sin x + 1 at x=\" + xs.toFixed(2) + \": \" + (Number.isFinite(yc) ? yc.toFixed(2) : \"undef\"), 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"f\", color: H.colors.sub }, { label: \"g\", color: H.colors.violet }, { label: opNames[idx], color: H.colors.accent }], H.W - 160, 28);" + }, + { + "id": "a2-function-transformations", + "area": "Algebra 2", + "topic": "Function transformations", + "title": "Transformations: y = a(x - h)^2 + k", + "equation": "y = a(x - h)^2 + k", + "keywords": [ + "function transformation", + "transformations", + "shift", + "translate", + "stretch", + "reflect", + "vertical shift", + "horizontal shift", + "vertex form", + "h and k", + "compress", + "y=a(x-h)^2+k" + ], + "explanation": "Take the parent graph (the faint gray curve) and apply the four classic moves at once. h slides it left/right (note x - h moves it RIGHT by h), k slides it up/down, and a stretches it vertically and flips it upside down when negative. The pink dot rides the transformed curve and the warm dot marks the vertex (h, k) so you can see exactly where the parent's anchor point landed after the moves.", + "bullets": [ + "Outside changes (a, k) act vertically; inside changes (h) act horizontally.", + "Inside moves run 'backwards': x - h shifts RIGHT, x + h shifts LEFT.", + "a < 0 reflects the graph across the x-axis; |a| > 1 makes it steeper." + ], + "params": [ + { + "name": "a", + "label": "stretch a", + "min": -3, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "h", + "label": "shift right h", + "min": -5, + "max": 5, + "step": 0.5, + "value": -2 + }, + { + "name": "k", + "label": "shift up k", + "min": -4, + "max": 8, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 10 });\nv.grid(); v.axes();\nconst a = P.a, h = P.h, k = P.k;\nconst parent = (x) => x * x;\nv.fn(parent, { color: H.colors.sub, width: 1.5 });\nconst g = (x) => a * parent(x - h) + k;\nv.fn(g, { color: H.colors.accent, width: 3 });\nv.line(h, -6, h, 10, { color: H.colors.violet, width: 1.2, dash: [4, 4] });\nv.dot(h, k, { r: 7, fill: H.colors.warn });\nconst xs = h + 3 * Math.sin(t * 0.7);\nconst ys = g(xs);\nif (Number.isFinite(ys) && ys >= -6 && ys <= 10) v.dot(xs, ys, { r: 6, fill: H.colors.accent2 });\nH.text(\"y = a(x - h)^2 + k\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a=\" + a.toFixed(1) + \" h=\" + h.toFixed(1) + \" k=\" + k.toFixed(1) + \" vertex (\" + h.toFixed(1) + \", \" + k.toFixed(1) + \")\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"parent x^2\", color: H.colors.sub }, { label: \"transformed\", color: H.colors.accent }], H.W - 200, 28);" + }, + { + "id": "a2-geometric-sequences", + "area": "Algebra 2", + "topic": "Geometric sequences", + "title": "Geometric sequence: a_n = a_1 * r^(n-1)", + "equation": "a_n = a_1 * r^(n - 1)", + "keywords": [ + "geometric sequence", + "common ratio", + "nth term geometric", + "a_n", + "r^(n-1)", + "geometric progression", + "multiply each step", + "exponential sequence", + "sequences", + "constant ratio", + "doubling halving" + ], + "explanation": "A geometric sequence MULTIPLIES by the same ratio r every step instead of adding. a_1 sets the first dot and r is the factor between consecutive terms, so the dots curve away exponentially. When |r|>1 the terms explode upward; when |r|<1 they shrink toward zero, and a negative r makes them flip sign and zig-zag.", + "bullets": [ + "Each term is the previous term times the common ratio r.", + "|r| > 1 grows the sequence; 0 < |r| < 1 shrinks it toward 0.", + "a_n = a_1 * r^(n-1): r is multiplied (n-1) times from the start." + ], + "params": [ + { + "name": "a1", + "label": "first term a_1", + "min": -6, + "max": 6, + "step": 0.5, + "value": 1 + }, + { + "name": "r", + "label": "common ratio r", + "min": -2, + "max": 2, + "step": 0.05, + "value": 1.4 + } + ], + "code": "H.background();\nconst a1 = P.a1, r = P.r;\nconst an = (n) => a1 * Math.pow(r, n - 1);\nlet yHi = 2;\nfor (let n = 1; n <= 8; n++) yHi = Math.max(yHi, Math.abs(an(n)));\nconst v = H.plot2d({ xMin: 0, xMax: 9, yMin: -yHi * 1.1, yMax: yHi * 1.15 });\nv.grid(); v.axes();\nfor (let n = 1; n <= 8; n++) {\n v.dot(n, an(n), { r: 5, fill: H.colors.accent });\n if (n >= 2) v.line(n - 1, an(n - 1), n, an(n), { color: H.colors.good, width: 2 });\n}\nconst k = 1 + Math.floor((t * 0.8) % 7);\nv.circle(k + 1, an(k + 1), 8 + 2 * Math.sin(t * 4), { fill: H.colors.warn });\nv.text(\"x\" + r.toFixed(2), k + 0.4, (an(k) + an(k + 1)) / 2, { color: H.colors.good, size: 12 });\nH.text(\"Geometric: a_n = a_1 * r^(n-1)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst tag = Math.abs(r) > 1 ? \" (|r|>1: grows)\" : Math.abs(r) < 1 ? \" (|r|<1: shrinks)\" : \" (constant)\";\nH.text(\"a_1 = \" + a1.toFixed(1) + \" r = \" + r.toFixed(2) + tag, 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"term a_n\", color: H.colors.accent }, { label: \"x r each step\", color: H.colors.good }], H.W - 200, 28);" + }, + { + "id": "a2-geometric-series", + "area": "Algebra 2", + "topic": "Geometric series", + "title": "Geometric series: S_n = a_1(1 - r^n)/(1 - r)", + "equation": "S_n = a_1*(1 - r^n)/(1 - r)", + "keywords": [ + "geometric series", + "sum of geometric sequence", + "common ratio", + "partial sum geometric", + "s_n", + "infinite geometric series", + "converges", + "a_1/(1-r)", + "series sum formula", + "diverges", + "sum of powers" + ], + "explanation": "This bars-chart shows the PARTIAL SUMS S_n: each bar is the total after adding n geometric terms, and the highlight sweeps to show the sum building up. When |r|<1 the bars creep toward a ceiling — the dashed line a_1/(1-r), the infinite-sum limit — because each new term is too small to matter. When |r|>=1 the terms don't shrink, so the sum runs away and never settles.", + "bullets": [ + "S_n = a_1(1 - r^n)/(1 - r) totals the first n geometric terms.", + "If |r| < 1 the partial sums converge to a_1/(1 - r).", + "If |r| >= 1 the terms don't shrink, so the series diverges." + ], + "params": [ + { + "name": "a1", + "label": "first term a_1", + "min": -6, + "max": 8, + "step": 0.5, + "value": 4 + }, + { + "name": "r", + "label": "common ratio r", + "min": -0.95, + "max": 1.5, + "step": 0.05, + "value": 0.5 + }, + { + "name": "n", + "label": "how many terms n", + "min": 2, + "max": 12, + "step": 1, + "value": 8 + } + ], + "code": "H.background();\nconst a1 = P.a1, r = P.r, nMax = Math.max(2, Math.round(P.n));\nconst partial = (m) => Math.abs(r - 1) < 1e-9 ? a1 * m : a1 * (1 - Math.pow(r, m)) / (1 - r);\nlet yHi = 1;\nfor (let m = 1; m <= nMax; m++) yHi = Math.max(yHi, Math.abs(partial(m)));\nconst v = H.plot2d({ xMin: 0, xMax: nMax + 1, yMin: Math.min(0, -yHi * 0.2), yMax: yHi * 1.2 });\nv.grid(); v.axes();\nconst shown = 1 + Math.floor((t * 0.9) % nMax);\nfor (let m = 1; m <= nMax; m++) {\n const s = partial(m);\n const lit = m <= shown;\n v.rect(m - 0.4, 0, 0.8, s, { fill: lit ? H.colors.accent : H.colors.panel, stroke: H.colors.accent, width: 1.5 });\n}\nlet limTxt = \"\";\nif (Math.abs(r) < 1) {\n const lim = a1 / (1 - r);\n v.line(0, lim, nMax + 1, lim, { color: H.colors.violet, width: 2, dash: [6, 5] });\n limTxt = \" -> converges to a_1/(1-r) = \" + lim.toFixed(2);\n} else {\n limTxt = \" |r|>=1: diverges\";\n}\nv.circle(shown, partial(shown) / 2, 6 + 2 * Math.sin(t * 4), { fill: H.colors.warn });\nH.text(\"Geometric series: S_n = a_1(1 - r^n)/(1 - r)\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"S_\" + shown + \" = \" + partial(shown).toFixed(2) + limTxt, 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"partial sum S_n\", color: H.colors.accent }, { label: \"limit a_1/(1-r)\", color: H.colors.violet }], H.W - 210, 28);" + }, + { + "id": "a2-holes-and-vertical-asymptotes", + "area": "Algebra 2", + "topic": "Holes and vertical asymptotes", + "title": "Holes vs. asymptotes: (x − hole)/((x − hole)(x − va))", + "equation": "f(x) = (x - hole)/((x - hole)(x - va)) = 1/(x - va), hole at x = hole", + "keywords": [ + "hole", + "removable discontinuity", + "vertical asymptote", + "rational function", + "cancel common factor", + "simplify rational function", + "excluded value", + "graph rational function", + "1/(x-a)", + "factor and cancel", + "asymptote vs hole", + "rational" + ], + "explanation": "Both a hole and a vertical asymptote come from a zero denominator, but they behave very differently. A factor that CANCELS from top and bottom (here x − hole) leaves a removable HOLE — an open circle the graph skips over. A factor left only in the denominator (x − va) creates a true vertical ASYMPTOTE the curve races toward but never reaches. Slide the two and watch the open hole move along the curve while the dashed asymptote stays put.", + "bullets": [ + "A factor common to numerator and denominator cancels, leaving a hole.", + "A denominator-only factor gives a vertical asymptote the graph never touches.", + "The hole sits ON the simplified curve at the cancelled x-value, just excluded." + ], + "params": [ + { + "name": "hole", + "label": "hole at x =", + "min": -6, + "max": 6, + "step": 0.5, + "value": -2 + }, + { + "name": "va", + "label": "asymptote at x =", + "min": -6, + "max": 6, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\n// f(x) = (x - hole) / ((x - hole)(x - va)) = 1/(x - va), with a HOLE at x = hole.\n// Cancelling (x - hole) leaves 1/(x - va): a vertical asymptote at x = va,\n// but x = hole is still excluded from the domain -> a removable hole.\nconst hole = P.hole, va = P.va;\n// vertical asymptote line at x = va\nv.line(va, -8, va, 8, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nH.text(\"vertical asymptote x = \" + va.toFixed(1), v.X(va) + 6, v.Y(7.4), { color: H.colors.violet, size: 12 });\n// simplified curve 1/(x - va)\nv.fn(x => (Math.abs(x - va) < 0.04 ? NaN : 1 / (x - va)), { color: H.colors.accent, width: 3 });\nH.text(\"Holes vs. vertical asymptotes\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Cancelled factor (x-hole) → HOLE. Leftover (x-va) → ASYMPTOTE.\", 24, 52, { color: H.colors.sub, size: 13 });\n// open-circle HOLE at (hole, 1/(hole - va)) — only if defined and on-screen\nconst sameAsVA = Math.abs(hole - va) < 1e-6;\nconst yh = 1 / (hole - va);\nif (!sameAsVA && hole >= -8 && hole <= 8 && Number.isFinite(yh) && yh >= -8 && yh <= 8) {\n const pulse = 5.5 + 1.5 * Math.sin(t * 3);\n H.circle(v.X(hole), v.Y(yh), pulse, { stroke: H.colors.warn, width: 2.5, fill: H.colors.bg });\n H.text(\"hole (\" + hole.toFixed(1) + \", \" + yh.toFixed(2) + \")\", v.X(hole) + 8, v.Y(yh) - 8, { color: H.colors.warn, size: 12 });\n} else {\n H.text(\"hole coincides with asymptote — slide them apart\", 24, 74, { color: H.colors.warn, size: 12 });\n}\n// a probe dot sweeping the curve (bounded, looping) to keep it animated\nconst xp = va + 3 + 2.5 * Math.sin(t * 0.8);\nconst yp = 1 / (xp - va);\nif (Number.isFinite(yp) && yp >= -8 && yp <= 8 && xp >= -8 && xp <= 8) v.dot(xp, yp, { r: 5, fill: H.colors.good });\nH.legend([{ label: \"y = 1/(x-va)\", color: H.colors.accent }, { label: \"hole\", color: H.colors.warn }, { label: \"asymptote\", color: H.colors.violet }], H.W - 170, 28);" + }, + { + "id": "a2-horizontal-and-slant-asymptotes", + "area": "Algebra 2", + "topic": "Horizontal and slant asymptotes", + "title": "End behavior: horizontal vs. slant asymptote", + "equation": "top deg = bottom deg -> y = a (horizontal); top deg = bottom deg + 1 -> slant line", + "keywords": [ + "horizontal asymptote", + "slant asymptote", + "oblique asymptote", + "end behavior", + "degree of numerator", + "degree of denominator", + "leading coefficients", + "polynomial division", + "rational function asymptote", + "compare degrees", + "long run behavior", + "rational" + ], + "explanation": "Far from the origin, a rational function's shape is decided by the degrees of its top and bottom. Switch the top-degree slider: when top and bottom degrees are equal the graph levels off to a HORIZONTAL line y = a (the ratio of leading coefficients); when the top is one degree higher it instead hugs a tilted SLANT line found by dividing. The red dot sweeps far out along x so you can watch the curve snuggle up to its dashed asymptote.", + "bullets": [ + "Equal degrees → horizontal asymptote at y = (leading coefficient ratio).", + "Top degree one higher → slant asymptote from polynomial division.", + "The vertical asymptote at x = d is separate; it's about zeros of the bottom." + ], + "params": [ + { + "name": "a", + "label": "leading coef a", + "min": -3, + "max": 3, + "step": 0.5, + "value": 1 + }, + { + "name": "b", + "label": "next coef b", + "min": -4, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "d", + "label": "vert. asym. x = d", + "min": -4, + "max": 4, + "step": 0.5, + "value": 1 + }, + { + "name": "topdeg", + "label": "top degree (1 or 2)", + "min": 1, + "max": 2, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -10, yMax: 10 });\nv.grid(); v.axes();\n// Denominator is (x - d), degree 1. The numerator's degree decides the end model:\n// topdeg = 1: (a*x + b)/(x - d) -> HORIZONTAL asymptote y = a\n// topdeg = 2: (a*x^2 + b*x)/(x - d) -> SLANT asymptote y = a*x + (b + a*d)\nconst a = P.a, b = P.b, d = P.d, topdeg = Math.round(P.topdeg) >= 2 ? 2 : 1;\nconst num = (x) => (topdeg === 2 ? a * x * x + b * x : a * x + b);\nconst f = (x) => { const den = x - d; return Math.abs(den) < 0.04 ? NaN : num(x) / den; };\n// vertical asymptote (always, at x = d)\nv.line(d, -10, d, 10, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.fn(f, { color: H.colors.accent, width: 3 });\nH.text(\"Horizontal vs. slant asymptotes\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nlet asyText;\nif (topdeg === 1) {\n // horizontal asymptote y = a\n v.line(-10, a, 10, a, { color: H.colors.accent2, width: 2.4, dash: [7, 6] });\n asyText = \"deg top = deg bottom → HORIZONTAL y = \" + a.toFixed(1);\n} else {\n // slant asymptote from division: y = a*x + (b + a*d)\n const m = a, k = b + a * d;\n v.fn(x => m * x + k, { color: H.colors.accent2, width: 2.4, dash: [7, 6] });\n asyText = \"deg top = deg bottom + 1 → SLANT y = \" + m.toFixed(1) + \"x + \" + k.toFixed(1);\n}\nH.text(asyText, 24, 52, { color: H.colors.good, size: 13, weight: 700 });\n// probe dot that sweeps far out so you SEE the curve hug the asymptote\nconst xp = 9.2 * Math.sin(t * 0.5);\nconst yp = f(xp);\nif (Number.isFinite(yp) && yp >= -10 && yp <= 10) v.dot(xp, yp, { r: 5, fill: H.colors.warn });\nH.text(\"a = \" + a.toFixed(1) + \" b = \" + b.toFixed(1) + \" d = \" + d.toFixed(1) + \" top degree = \" + topdeg, 24, 74, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"f(x)\", color: H.colors.accent }, { label: \"asymptote\", color: H.colors.accent2 }], H.W - 150, 28);" + }, + { + "id": "a2-imaginary-unit-powers-of-i", + "area": "Algebra 2", + "topic": "Imaginary unit and powers of i", + "title": "Powers of i: i^n cycles 1, i, -1, -i", + "equation": "i^2 = -1, i^n = i^(n mod 4)", + "keywords": [ + "imaginary unit", + "powers of i", + "i squared equals negative one", + "i^2 = -1", + "imaginary number", + "complex plane", + "cycle of i", + "i to the n", + "i^n", + "1 i -1 -i", + "rotation by 90 degrees", + "imaginary" + ], + "explanation": "Multiplying by i is a quarter-turn (90 degrees) around the origin in the complex plane, so the powers of i march around a circle: 1 -> i -> -1 -> -i -> back to 1. That is why the powers repeat every 4: i^n only depends on n mod 4. Slide n and watch the green ring jump to the matching axis direction while the orange hand keeps rotating to show the spin between whole powers.", + "bullets": [ + "Multiplying by i rotates a number 90 degrees counterclockwise.", + "The powers of i repeat with period 4: i^n = i^(n mod 4).", + "Remainders 0,1,2,3 give 1, i, -1, -i respectively." + ], + "params": [ + { + "name": "n", + "label": "exponent n", + "min": 0, + "max": 12, + "step": 1, + "value": 7 + } + ], + "code": "H.background();\nconst n = Math.max(0, Math.round(P.n));\nconst cx = H.W * 0.52, cy = H.H * 0.54, R = Math.min(H.W, H.H) * 0.30;\nH.line(cx - R - 30, cy, cx + R + 30, cy, { color: H.colors.axis, width: 1.2 });\nH.line(cx, cy - R - 30, cx, cy + R + 30, { color: H.colors.axis, width: 1.2 });\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 1.5 });\nH.text(\"real\", cx + R + 6, cy - 6, { color: H.colors.sub, size: 12 });\nH.text(\"imag\", cx + 6, cy - R - 10, { color: H.colors.sub, size: 12 });\nconst pts = [[1,0,\"1\"],[0,1,\"i\"],[-1,0,\"-1\"],[0,-1,\"-i\"]];\nfor (let k = 0; k < 4; k++) {\n const px = cx + R * pts[k][0], py = cy - R * pts[k][1];\n H.circle(px, py, 5, { fill: H.colors.grid, stroke: H.colors.sub, width: 1 });\n H.text(pts[k][2], px + 8 * pts[k][0] + (pts[k][0] === 0 ? -4 : 4), py - 8 * pts[k][1] - 2, { color: H.colors.sub, size: 13 });\n}\nconst sweep = n + (t % 4);\nconst ang = sweep * Math.PI / 2;\nconst hx = cx + R * Math.cos(ang), hy = cy - R * Math.sin(ang);\nH.line(cx, cy, hx, hy, { color: H.colors.accent2, width: 2 });\nH.circle(hx, hy, 7 + Math.sin(t * 4), { fill: H.colors.warn });\nconst rem = n % 4;\nconst vals = [[\"1\",\"real 1\"],[\"i\",\"up the imag axis\"],[\"-1\",\"real -1\"],[\"-i\",\"down the imag axis\"]];\nconst rx = cx + R * Math.cos(rem * Math.PI / 2), ry = cy - R * Math.sin(rem * Math.PI / 2);\nH.circle(rx, ry, 9, { stroke: H.colors.good, width: 3 });\nH.text(\"Powers of i cycle every 4\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"i^\" + n + \" = i^(\" + n + \" mod 4) = i^\" + rem + \" = \" + vals[rem][0] + \" (\" + vals[rem][1] + \")\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"i^n result\", color: H.colors.good }, { label: \"rotating\", color: H.colors.warn }], H.W - 160, 28);" + }, + { + "id": "a2-inverse-functions", + "area": "Algebra 2", + "topic": "Inverse functions", + "title": "Inverse: f(x) = m·x + b and f⁻¹(x)", + "equation": "y = m*x + b <=> x = (y - b)/m, reflect across y = x", + "keywords": [ + "inverse function", + "inverse", + "f inverse", + "f^-1", + "undo a function", + "swap x and y", + "reflection across y=x", + "one to one", + "solve for x", + "y = x line", + "invertible", + "horizontal line test" + ], + "explanation": "An inverse function undoes the original: whatever f does to x, f⁻¹ reverses it. Geometrically that means swapping every (x, y) into (y, x), which reflects the graph across the dashed line y = x. Slide m and b and watch both lines pivot — the green tie-line shows a point and its mirror image always landing symmetrically across y = x.", + "bullets": [ + "To find an inverse, swap x and y, then solve for y.", + "f and f⁻¹ are mirror images across the line y = x.", + "Only one-to-one functions have an inverse (each output from one input)." + ], + "params": [ + { + "name": "m", + "label": "slope m", + "min": -3, + "max": 3, + "step": 0.1, + "value": 2 + }, + { + "name": "b", + "label": "intercept b", + "min": -5, + "max": 5, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst m = Math.abs(P.m) < 0.2 ? 0.2 : P.m;\nconst b = P.b;\nconst f = x => m * x + b;\nconst finv = y => (y - b) / m;\nv.line(-8, -8, 8, 8, { color: H.colors.violet, width: 1.5, dash: [5, 5] });\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.fn(finv, { color: H.colors.accent2, width: 3 });\nconst x0 = 5 * Math.sin(t * 0.6);\nconst y0 = f(x0);\nv.dot(x0, y0, { r: 6, fill: H.colors.accent });\nv.dot(y0, x0, { r: 6, fill: H.colors.accent2 });\nv.line(x0, y0, y0, x0, { color: H.colors.good, width: 1.5, dash: [3, 3] });\nH.text(\"Inverse: f(x) = m·x + b, swap (x, y)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"point (\" + x0.toFixed(2) + \", \" + y0.toFixed(2) + \") ↔ (\" + y0.toFixed(2) + \", \" + x0.toFixed(2) + \")\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"f(x)\", color: H.colors.accent }, { label: \"f⁻¹(x)\", color: H.colors.accent2 }, { label: \"y = x\", color: H.colors.violet }], H.W - 150, 28);" + }, + { + "id": "a2-linear-quadratic-systems", + "area": "Algebra 2", + "topic": "Linear-quadratic systems", + "title": "Linear-quadratic system: parabola meets line", + "equation": "y = a*x^2 + c and y = m*x + k", + "keywords": [ + "linear quadratic system", + "line and parabola", + "system with a quadratic", + "intersection of line and parabola", + "substitution", + "where they cross", + "two equations one quadratic", + "tangent line parabola", + "0 1 2 solutions", + "solve system", + "nonlinear system", + "points of intersection" + ], + "explanation": "A linear-quadratic system asks where a straight line meets a parabola, and unlike two lines they can meet twice, once, or not at all. Setting the equations equal collapses the whole problem to a single quadratic, so the discriminant of THAT quadratic counts the solutions. Slide the line's slope m and intercept k: lift it clear of the parabola and the green intersection dots vanish; lower it to graze the curve and the two dots merge into one yellow tangent point. The crossing points are exactly the (x, y) pairs that satisfy both equations at once.", + "bullets": [ + "Substituting the line into the parabola gives one quadratic to solve.", + "That quadratic's discriminant decides 2, 1, or 0 intersection points.", + "Each intersection is an (x, y) pair lying on BOTH the line and the parabola." + ], + "params": [ + { + "name": "a", + "label": "parabola a", + "min": -2, + "max": 2, + "step": 0.1, + "value": 0.5 + }, + { + "name": "c", + "label": "parabola c", + "min": -6, + "max": 6, + "step": 0.5, + "value": -2 + }, + { + "name": "m", + "label": "line slope m", + "min": -4, + "max": 4, + "step": 0.1, + "value": 1 + }, + { + "name": "k", + "label": "line intercept k", + "min": -6, + "max": 8, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 10 });\nv.grid(); v.axes();\nconst a = Math.abs(P.a) < 0.15 ? 0.15 : P.a;\nconst c = P.c, m = P.m, k = P.k;\nconst par = x => a * x * x + c;\nconst lin = x => m * x + k;\nv.fn(par, { color: H.colors.accent, width: 3 });\nv.fn(lin, { color: H.colors.accent2, width: 3 });\nconst A = a, B = -m, C = c - k;\nconst disc = B * B - 4 * A * C;\nlet msg, mcol;\nif (disc > 1e-9) {\n const x1 = (-B - Math.sqrt(disc)) / (2 * A), x2 = (-B + Math.sqrt(disc)) / (2 * A);\n v.dot(x1, lin(x1), { r: 7, fill: H.colors.good });\n v.dot(x2, lin(x2), { r: 7, fill: H.colors.good });\n msg = \"2 solutions: x = \" + Math.min(x1, x2).toFixed(2) + \" , \" + Math.max(x1, x2).toFixed(2);\n mcol = H.colors.good;\n} else if (disc > -1e-9) {\n const x1 = -B / (2 * A);\n v.dot(x1, lin(x1), { r: 8, fill: H.colors.yellow });\n msg = \"1 solution (tangent line): x = \" + x1.toFixed(2);\n mcol = H.colors.yellow;\n} else {\n msg = \"no real solution: line misses the parabola\";\n mcol = H.colors.warn;\n}\nconst xs = 5 * Math.sin(t * 0.6);\nv.dot(xs, par(xs), { r: 5, fill: H.colors.accent });\nv.dot(xs, lin(xs), { r: 5, fill: H.colors.accent2 });\nH.text(\"Linear–quadratic system\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"y = \" + a.toFixed(1) + \"x² + \" + c.toFixed(1) + \" and y = \" + m.toFixed(1) + \"x + \" + k.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(msg, 24, 74, { color: mcol, size: 13, weight: 600 });\nH.legend([{ label: \"parabola\", color: H.colors.accent }, { label: \"line\", color: H.colors.accent2 }], H.W - 150, 28);" + }, + { + "id": "a2-logarithm-definition-conversion", + "area": "Algebra 2", + "topic": "Logarithm definition and conversion", + "title": "Logarithm definition: log_b(x) = y means b^y = x", + "equation": "y = log_b(x) <=> b^y = x", + "keywords": [ + "logarithm", + "log", + "logarithm definition", + "log base b", + "exponential form", + "logarithmic form", + "convert log to exponential", + "b to what power", + "log_b(x)=y", + "inverse of exponential", + "evaluate log" + ], + "explanation": "A logarithm is just an exponent in disguise: log_b(x) asks 'b raised to WHAT power gives x?'. The base slider b sets which exponential you are inverting, and the x slider picks the input whose exponent you want. The red dot sits on the log curve at (x, log_b x) while the dashed legs drop to the axes, and the orange exponential is its mirror across y=x, so you can literally read the conversion b^y = x off the picture.", + "bullets": [ + "log_b(x) = y is the SAME statement as b^y = x — two forms of one fact.", + "The base b must be positive and not 1; x must be positive (only x>0 is plotted).", + "log_b and b^x are inverses: reflect one across y=x to get the other." + ], + "params": [ + { + "name": "b", + "label": "base b", + "min": 1.3, + "max": 4, + "step": 0.1, + "value": 2 + }, + { + "name": "x", + "label": "input x", + "min": 0.5, + "max": 8, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 9, yMin: -3, yMax: 9 });\nv.grid(); v.axes();\nconst b = Math.min(4, Math.max(1.3, P.b));\nconst xexp = Math.max(0.1, P.x);\nv.fn(x => Math.pow(b, x), { color: H.colors.accent, width: 2.5 });\nv.fn(x => (x > 0 ? Math.log(x) / Math.log(b) : NaN), { color: H.colors.accent2, width: 3 });\nv.line(0, 0, 9, 9, { color: H.colors.violet, width: 1.2, dash: [5, 5] });\nconst y = Math.log(xexp) / Math.log(b);\nv.dot(xexp, y, { r: 7, fill: H.colors.warn });\nv.line(xexp, 0, xexp, y, { color: H.colors.good, width: 1.5, dash: [4, 4] });\nv.line(0, y, xexp, y, { color: H.colors.good, width: 1.5, dash: [4, 4] });\nconst e = 2.6 * (0.5 + 0.5 * Math.sin(t * 0.7));\nv.dot(e, Math.pow(b, e), { r: 6, fill: H.colors.yellow });\nv.text(\"(x=\" + e.toFixed(2) + \", y=\" + Math.pow(b, e).toFixed(1) + \")\", 0.3, 8.4, { color: H.colors.sub, size: 12 });\nH.text(\"log_b(x): b to what power gives x?\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"log_\" + b.toFixed(1) + \"(\" + xexp.toFixed(1) + \") = \" + y.toFixed(2) + \" means \" + b.toFixed(1) + \"^\" + y.toFixed(2) + \" = \" + xexp.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"b^x\", color: H.colors.accent }, { label: \"log_b x\", color: H.colors.accent2 }, { label: \"y = x\", color: H.colors.violet }], H.W - 150, 28);" + }, + { + "id": "a2-logarithm-laws", + "area": "Algebra 2", + "topic": "Logarithm laws", + "title": "Product law: log(M*N) = log M + log N", + "equation": "log_b(M*N) = log_b(M) + log_b(N)", + "keywords": [ + "logarithm laws", + "log rules", + "log laws", + "product rule", + "log of a product", + "log(mn)", + "add logs", + "logarithm properties", + "expand logs", + "combine logs", + "log_b(m*n)" + ], + "explanation": "Logs convert multiplication into addition, because a log IS an exponent and you add exponents when you multiply. Slide M and N to set the two factors: the blue bar is log M and the orange bar is log N stacked on top of it, and where the second bar ends — the pulsing marker — is log(M*N). The number line below measures these exponent-lengths, so log M + log N lands exactly on log(MN) every time.", + "bullets": [ + "Multiplying inside a log turns into ADDING the logs: log(MN) = log M + log N.", + "It works because logs are exponents and multiplying powers adds exponents.", + "The same idea gives log(M/N) = log M - log N and log(M^p) = p*log M." + ], + "params": [ + { + "name": "b", + "label": "base b", + "min": 2, + "max": 5, + "step": 1, + "value": 2 + }, + { + "name": "M", + "label": "factor M", + "min": 1, + "max": 16, + "step": 1, + "value": 4 + }, + { + "name": "N", + "label": "factor N", + "min": 1, + "max": 16, + "step": 1, + "value": 8 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst b = Math.min(5, Math.max(2, P.b));\nconst M = Math.max(1, P.M), N = Math.max(1, P.N);\nconst lm = Math.log(M) / Math.log(b);\nconst ln = Math.log(N) / Math.log(b);\nconst lmn = lm + ln;\nconst baseY = h * 0.5;\nconst x0 = 70, span = w - 160;\nconst maxL = Math.max(lmn, 1) * 1.15;\nconst px = u => x0 + (u / maxL) * span;\nH.line(x0, baseY, x0 + span, baseY, { color: H.colors.axis, width: 2 });\nfor (let k = 0; k <= Math.ceil(maxL); k++) {\n H.line(px(k), baseY - 5, px(k), baseY + 5, { color: H.colors.grid, width: 1 });\n H.text(String(k), px(k) - 3, baseY + 22, { color: H.colors.sub, size: 11 });\n}\nconst grow = 0.5 + 0.5 * Math.sin(t * 0.8);\nH.rect(x0, baseY - 40, px(lm) - x0, 22, { fill: H.colors.accent, radius: 4 });\nH.rect(px(lm), baseY - 40, (px(lm + ln) - px(lm)) * grow, 22, { fill: H.colors.accent2, radius: 4 });\nH.text(\"log M = \" + lm.toFixed(2), x0 + 6, baseY - 24, { color: H.colors.ink, size: 12 });\nH.text(\"+ log N\", px(lm) + 6, baseY - 24, { color: H.colors.ink, size: 12 });\nH.circle(px(lmn), baseY, 6 + 1.5 * Math.sin(t * 3), { fill: H.colors.warn });\nH.text(\"log(MN) = \" + lmn.toFixed(2), px(lmn) - 30, baseY + 44, { color: H.colors.good, size: 13 });\nH.text(\"Log laws: multiply -> add exponents\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"log_\" + b.toFixed(0) + \"(\" + M.toFixed(0) + \"*\" + N.toFixed(0) + \") = log \" + M.toFixed(0) + \" + log \" + N.toFixed(0) + \" = \" + lm.toFixed(2) + \" + \" + ln.toFixed(2) + \" = \" + lmn.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"log M\", color: H.colors.accent }, { label: \"log N\", color: H.colors.accent2 }], H.W - 140, 28);" + }, + { + "id": "a2-logarithmic-equations", + "area": "Algebra 2", + "topic": "Logarithmic equations", + "title": "Logarithmic equation: log_b(x) = C", + "equation": "log_b(x) = C => x = b^C", + "keywords": [ + "logarithmic equation", + "solve log_b(x) = c", + "logarithmic equations", + "exponentiate both sides", + "solve for x in a log", + "undo a logarithm", + "log equation", + "x = b^c", + "rewrite in exponential form", + "solving logs", + "log(x) equals" + ], + "explanation": "To solve log_b(x) = C you undo the log by raising the base to each side: x = b^C. The blue curve is y = log_b(x) and the purple line is the target y = C; where they cross is the solution x, found by dropping straight down to the x-axis. The yellow dot rides the slow-growing log curve so you can see how far right you must go for the log to reach the value C.", + "bullets": [ + "log_b(x) = C is undone by exponentiating: x = b^C.", + "Graphically x is where the curve y = log_b(x) meets the line y = C.", + "Always check the answer is positive — a log is only defined for x > 0." + ], + "params": [ + { + "name": "b", + "label": "base b", + "min": 1.5, + "max": 4, + "step": 0.1, + "value": 2 + }, + { + "name": "C", + "label": "target C", + "min": -2, + "max": 3, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 9, yMin: -3, yMax: 4 });\nv.grid(); v.axes();\nconst b = Math.min(4, Math.max(1.5, P.b));\nconst C = P.C;\nv.fn(x => (x > 0 ? Math.log(x) / Math.log(b) : NaN), { color: H.colors.accent, width: 3 });\nv.line(-1, C, 9, C, { color: H.colors.violet, width: 2, dash: [6, 4] });\nconst sol = Math.pow(b, C);\nif (sol >= -1 && sol <= 9) {\n v.dot(sol, C, { r: 7, fill: H.colors.warn });\n v.line(sol, -3, sol, C, { color: H.colors.good, width: 1.5, dash: [4, 4] });\n}\nconst xs = 4.5 + 4 * Math.sin(t * 0.6);\nconst ys = xs > 0 ? Math.log(xs) / Math.log(b) : 0;\nv.dot(xs, ys, { r: 5, fill: H.colors.yellow });\nv.text(\"log_b(x) = \" + ys.toFixed(2), xs + 0.15, ys + 0.45, { color: H.colors.sub, size: 11 });\nH.text(\"Logarithmic equation: log_b(x) = C\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"solve log_\" + b.toFixed(1) + \"(x) = \" + C.toFixed(1) + \" -> x = \" + b.toFixed(1) + \"^\" + C.toFixed(1) + \" = \" + sol.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"y = log_b(x)\", color: H.colors.accent }, { label: \"y = C\", color: H.colors.violet }], H.W - 170, 28);" + }, + { + "id": "a2-matrix-add-scalar", + "area": "Algebra 2", + "topic": "Matrix addition and scalar multiplication", + "title": "Matrix add & scale: (A+B)ij = aij + bij, (sA)ij = s*aij", + "equation": "(A + B)_ij = a_ij + b_ij (s*A)_ij = s * a_ij", + "keywords": [ + "matrix addition", + "scalar multiplication", + "add matrices", + "matrix sum", + "scale matrix", + "entry by entry", + "elementwise", + "matrices", + "matrix arithmetic", + "componentwise", + "a+b", + "s times a" + ], + "explanation": "Adding two matrices just means adding the numbers that sit in the SAME position, one cell at a time, so A and B must have the same shape. Scalar multiplication is even simpler: multiply EVERY entry by the same number s. The demo alternates between the two operations; the a and b sliders move the top-left entries so you watch one cell's result change, and the s slider rescales the whole matrix.", + "bullets": [ + "Add matrices position-by-position: (A+B)ij = aij + bij (same shape required).", + "Scalar multiply touches every entry: (s*A)ij = s * aij.", + "Both keep the matrix's shape; only the numbers inside change." + ], + "params": [ + { + "name": "a", + "label": "A entry a11", + "min": -5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "b", + "label": "B entry b11", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "s", + "label": "scalar s", + "min": -3, + "max": 3, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst w = H.W, hh = H.H;\nconst s = P.s;\nconst a11 = P.a, b11 = P.b;\nconst A = [[a11, 2], [-1, 3]];\nconst B = [[b11, 1], [4, -2]];\nconst phase = (t * 0.4) % 3;\nconst showSum = phase < 1.5;\nconst Csum = [[A[0][0] + B[0][0], A[0][1] + B[0][1]], [A[1][0] + B[1][0], A[1][1] + B[1][1]]];\nconst Cscl = [[s * A[0][0], s * A[0][1]], [s * A[1][0], s * A[1][1]]];\nfunction drawMat(M, x, y, label, hot) {\n const cw = 46, ch = 34;\n H.rect(x - 8, y - 24, cw * 2 + 16, ch * 2 + 8, { stroke: H.colors.grid, width: 2, radius: 8 });\n H.text(label, x - 6, y - 32, { color: H.colors.ink, size: 15, weight: 700 });\n for (let r = 0; r < 2; r++) {\n for (let c = 0; c < 2; c++) {\n const cx = x + c * cw, cy = y + r * ch;\n const isHot = hot && r === 0 && c === 0;\n H.text(M[r][c].toFixed(1), cx + 14, cy + 6, { color: isHot ? H.colors.warn : H.colors.sub, size: 16, align: \"center\" });\n }\n }\n}\nconst baseY = hh * 0.42;\ndrawMat(A, w * 0.07, baseY, \"A\", true);\nH.text(showSum ? \"+\" : \"*\", w * 0.30, baseY + 18, { color: H.colors.accent2, size: 26, weight: 700 });\nif (showSum) {\n drawMat(B, w * 0.35, baseY, \"B\", true);\n} else {\n H.text(s.toFixed(1), w * 0.355, baseY + 18, { color: H.colors.good, size: 22, weight: 700 });\n}\nH.text(\"=\", w * 0.62, baseY + 18, { color: H.colors.ink, size: 26, weight: 700 });\nconst pulse = 6 + 4 * Math.abs(Math.sin(t * 2));\nH.circle(w * 0.70 + 30, baseY + 8, pulse, { stroke: H.colors.warn, width: 2 });\ndrawMat(showSum ? Csum : Cscl, w * 0.70, baseY, showSum ? \"A + B\" : (s.toFixed(1) + \"*A\"), true);\nH.text(\"Matrix add & scalar multiply\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(showSum\n ? \"Add entry-by-entry: a11+b11 = \" + A[0][0].toFixed(1) + \" + \" + B[0][0].toFixed(1) + \" = \" + Csum[0][0].toFixed(1)\n : \"Scale every entry by \" + s.toFixed(1) + \": \" + s.toFixed(1) + \"*\" + A[0][0].toFixed(1) + \" = \" + Cscl[0][0].toFixed(1),\n 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: showSum ? \"sum (A+B)\" : \"scaled (s*A)\", color: H.colors.warn }], w - 170, 28);" + }, + { + "id": "a2-matrix-inverse-systems", + "area": "Algebra 2", + "topic": "Matrix inverses and solving systems", + "title": "Solve A x = b with the inverse: x = A^-1 b", + "equation": "x = A^-1 b, A^-1 = (1/det) * [[d,-b],[-c,a]]", + "keywords": [ + "matrix inverse", + "inverse matrix", + "solving systems", + "a inverse b", + "x equals a inverse b", + "system of equations", + "ad minus bc", + "invertible", + "matrices", + "linear system", + "two equations two unknowns", + "1 over determinant" + ], + "explanation": "A square system A x = b can be solved by multiplying both sides by the inverse: x = A^-1 b. Each row of the matrix is one linear equation, so the demo draws them as two lines; the inverse formula lands the solution exactly where the lines cross (the pulsing dot). The a,b,c,d sliders change the matrix A; when the determinant hits zero the inverse blows up (you divide by zero), the lines go parallel, and there is no unique solution.", + "bullets": [ + "x = A^-1 b uses the inverse to undo A, just like dividing in regular algebra.", + "For a 2x2: A^-1 = (1/det) [[d,-b],[-c,a]] -- it needs det not equal to 0.", + "det = 0 means no inverse: the two equation-lines are parallel (no unique answer)." + ], + "params": [ + { + "name": "a", + "label": "a (row1 x)", + "min": -4, + "max": 4, + "step": 0.5, + "value": 1 + }, + { + "name": "b", + "label": "b (row1 y)", + "min": -4, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "c", + "label": "c (row2 x)", + "min": -4, + "max": 4, + "step": 0.5, + "value": 3 + }, + { + "name": "d", + "label": "d (row2 y)", + "min": -4, + "max": 4, + "step": 0.5, + "value": -1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst a = P.a, b = P.b, c = P.c, d = P.d;\nconst e = 4, f = 2;\nconst det = a * d - b * c;\nv.fn(x => Math.abs(b) > 1e-9 ? (e - a * x) / b : NaN, { color: H.colors.accent, width: 3 });\nv.fn(x => Math.abs(d) > 1e-9 ? (f - c * x) / d : NaN, { color: H.colors.accent2, width: 3 });\nH.text(\"Solve A x = b with the inverse: x = A^-1 b\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nif (Math.abs(det) > 1e-6) {\n const sx = (d * e - b * f) / det;\n const sy = (-c * e + a * f) / det;\n v.dot(sx, sy, { r: 7 + 2 * Math.abs(Math.sin(t * 3)), fill: H.colors.warn });\n v.text(\"solution\", sx + 0.3, sy + 0.6, { color: H.colors.good, size: 12 });\n H.text(\"det = \" + det.toFixed(2) + \" -> x = (\" + sx.toFixed(2) + \", \" + sy.toFixed(2) + \")\", 24, 52, { color: H.colors.sub, size: 13 });\n} else {\n H.circle(H.W * 0.5, H.H * 0.5, 16 + 6 * Math.abs(Math.sin(t * 2)), { stroke: H.colors.warn, width: 2 });\n H.text(\"det = 0 -> A has NO inverse (lines parallel / no unique solution)\", 24, 52, { color: H.colors.warn, size: 13 });\n}\nH.legend([{ label: \"row 1\", color: H.colors.accent }, { label: \"row 2\", color: H.colors.accent2 }, { label: \"x = A^-1 b\", color: H.colors.warn }], H.W - 160, 28);" + }, + { + "id": "a2-matrix-multiplication", + "area": "Algebra 2", + "topic": "Matrix multiplication", + "title": "Matrix multiply: (AB)ij = row i of A . column j of B", + "equation": "(A*B)_ij = a_i1*b_1j + a_i2*b_2j", + "keywords": [ + "matrix multiplication", + "multiply matrices", + "matrix product", + "row times column", + "dot product", + "ab matrix", + "matrix multiply", + "matrices", + "row by column", + "product of matrices", + "inner product", + "ai1 b1j" + ], + "explanation": "Each entry of the product A*B is a dot product: take row i of A, line it up with column j of B, multiply matching numbers, and add. The demo sweeps through all four result cells in turn, lighting up the exact row of A and column of B that build the cell circled in green. The a, b, c, d sliders change entries of A and B so you can watch a result entry recompute live.", + "bullets": [ + "Entry (i,j) of A*B is row i of A dotted with column j of B.", + "Pair up matching terms, multiply, then sum: ai1*b1j + ai2*b2j.", + "Order matters: A*B is generally NOT equal to B*A." + ], + "params": [ + { + "name": "a", + "label": "A entry a11", + "min": -4, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "b", + "label": "A entry a12", + "min": -4, + "max": 4, + "step": 0.5, + "value": 1 + }, + { + "name": "c", + "label": "B entry b11", + "min": -4, + "max": 4, + "step": 0.5, + "value": 3 + }, + { + "name": "d", + "label": "B entry b22", + "min": -4, + "max": 4, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst w = H.W, hh = H.H;\nconst a = P.a, b = P.b, c = P.c, d = P.d;\nconst A = [[a, b], [1, 2]];\nconst B = [[c, 0], [1, d]];\nconst C = [\n [A[0][0] * B[0][0] + A[0][1] * B[1][0], A[0][0] * B[0][1] + A[0][1] * B[1][1]],\n [A[1][0] * B[0][0] + A[1][1] * B[1][0], A[1][0] * B[0][1] + A[1][1] * B[1][1]]\n];\nconst cell = Math.floor((t / 0.9) % 4);\nconst hr = cell < 2 ? 0 : 1;\nconst hc = cell % 2;\nconst cw = 44, ch = 34;\nfunction drawMat(M, x, y, label, hiRow, hiCol) {\n H.rect(x - 8, y - 24, cw * 2 + 16, ch * 2 + 8, { stroke: H.colors.grid, width: 2, radius: 8 });\n H.text(label, x - 6, y - 32, { color: H.colors.ink, size: 15, weight: 700 });\n for (let r = 0; r < 2; r++) for (let cc = 0; cc < 2; cc++) {\n const lit = (hiRow === r && hiCol === -1) || (hiCol === cc && hiRow === -1) || (hiRow === r && hiCol === cc);\n H.text(M[r][cc].toFixed(1), x + cc * cw + 14, y + r * ch + 6, { color: lit ? H.colors.warn : H.colors.sub, size: 16, align: \"center\" });\n }\n}\nconst baseY = hh * 0.40;\ndrawMat(A, w * 0.06, baseY, \"A (row)\", hr, -1);\ndrawMat(B, w * 0.34, baseY, \"B (col)\", -1, hc);\nH.text(\"=\", w * 0.60, baseY + 18, { color: H.colors.ink, size: 26, weight: 700 });\ndrawMat(C, w * 0.68, baseY, \"A*B\", hr, hc);\nconst px = w * 0.68 + hc * cw + 14, py = baseY + hr * ch + 6;\nH.circle(px, py - 5, 12 + 3 * Math.sin(t * 4), { stroke: H.colors.good, width: 2 });\nH.text(\"Matrix multiplication: row dot column\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst prod = A[hr][0] * B[0][hc] + A[hr][1] * B[1][hc];\nH.text(\"C[\" + (hr + 1) + \"][\" + (hc + 1) + \"] = \" + A[hr][0].toFixed(1) + \"*\" + B[0][hc].toFixed(1) + \" + \" + A[hr][1].toFixed(1) + \"*\" + B[1][hc].toFixed(1) + \" = \" + prod.toFixed(1),\n 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"row of A\", color: H.colors.warn }, { label: \"active cell\", color: H.colors.good }], w - 160, 28);" + }, + { + "id": "a2-multiplicity-of-roots", + "area": "Algebra 2", + "topic": "Multiplicity of roots", + "title": "Multiplicity: f(x) = a(x − r)^m", + "equation": "f(x) = a*(x - r)^m", + "keywords": [ + "multiplicity", + "repeated root", + "double root", + "triple root", + "bounce", + "touch and turn", + "cross the axis", + "even multiplicity", + "odd multiplicity", + "power of factor", + "flatten", + "(x-r)^m" + ], + "explanation": "A root's multiplicity is how many times its factor (x − r) is repeated, and it controls how the graph behaves there. Step m up and down: with odd multiplicity the curve crosses the axis (steeply for m = 1, with a flattening S-bend for m = 3, 5), while even multiplicity makes it just touch and bounce back without crossing. Slide r to move the root and a to flip or stretch the curve — the bounce-versus-cross behavior at the root stays tied to whether m is even or odd.", + "bullets": [ + "Multiplicity m = how many times the factor (x − r) appears.", + "Odd m: the graph crosses the x-axis; even m: it touches and turns around.", + "Higher m flattens the curve more near the root before it leaves." + ], + "params": [ + { + "name": "a", + "label": "leading a", + "min": -2, + "max": 2, + "step": 0.5, + "value": 0.5 + }, + { + "name": "root", + "label": "root r", + "min": -4, + "max": 4, + "step": 0.5, + "value": 0 + }, + { + "name": "m", + "label": "multiplicity m", + "min": 1, + "max": 5, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst root = P.root, m = Math.max(1, Math.round(P.m));\nconst a = Math.abs(P.a) < 0.05 ? (P.a < 0 ? -0.5 : 0.5) : P.a;\nconst f = x => a * Math.pow(x - root, m);\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -10, yMax: 10 });\nv.grid(); v.axes();\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.dot(root, 0, { r: 8, fill: H.colors.warn });\nconst xs = root + 3 * Math.sin(t * 0.8);\nv.dot(H.clamp(xs, -6, 6), H.clamp(f(xs), -10, 10), { r: 6, fill: H.colors.accent2 });\nconst even = m % 2 === 0;\nconst behavior = m === 1 ? \"crosses straight\" : even ? \"touches & turns (bounce)\" : \"flattens, then crosses\";\nH.text(\"Multiplicity: f(x) = a(x − r)^m\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"r = \" + root.toFixed(1) + \" m = \" + m + \" → \" + behavior, 24, 52, { color: even ? H.colors.good : H.colors.sub, size: 14 });\nH.legend([{ label: \"root x = r\", color: H.colors.warn }, { label: \"f(x)\", color: H.colors.accent }], H.W - 165, 28);" + }, + { + "id": "a2-multiplying-dividing-rational-expressions", + "area": "Algebra 2", + "topic": "Multiplying/dividing rational expressions", + "title": "Multiply & divide fractions: a/b × c/d (or ÷)", + "equation": "a/b * c/d = (a*c)/(b*d); a/b ÷ c/d = a/b * d/c", + "keywords": [ + "multiplying rational expressions", + "dividing rational expressions", + "multiply fractions", + "divide fractions", + "keep change flip", + "reciprocal", + "flip and multiply", + "multiply numerators denominators", + "reduce fraction", + "rational expression product", + "quotient of fractions", + "simplify product" + ], + "explanation": "Multiplying two fractions multiplies the tops together and the bottoms together — then you reduce. Dividing is the same move with one extra step: flip the second fraction (use its reciprocal) and multiply. This stepped worked example reveals the rule in three stages driven by time: see the problem, then the keep-change-flip rewrite (for division), then the combined and reduced result. Slide the four numbers and the operation toggle to test the rule on any case; the highlight ring sweeps the fraction in play.", + "bullets": [ + "Multiply: numerator × numerator, denominator × denominator, then reduce.", + "Divide: keep the first, flip the second (reciprocal), then multiply.", + "Always reduce by the gcd of the new top and bottom at the end." + ], + "params": [ + { + "name": "a", + "label": "top 1 a", + "min": 1, + "max": 12, + "step": 1, + "value": 2 + }, + { + "name": "b", + "label": "bottom 1 b", + "min": 1, + "max": 12, + "step": 1, + "value": 3 + }, + { + "name": "c", + "label": "top 2 c", + "min": 1, + "max": 12, + "step": 1, + "value": 4 + }, + { + "name": "d", + "label": "bottom 2 d", + "min": 1, + "max": 12, + "step": 1, + "value": 6 + }, + { + "name": "op", + "label": "0 = × 1 = ÷", + "min": 0, + "max": 1, + "step": 1, + "value": 0 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst a = Math.round(P.a), b = Math.round(P.b), c = Math.round(P.c), d = Math.round(P.d);\nconst isDiv = P.op >= 0.5;\nconst step = Math.floor((t % 9) / 3);\nH.text((isDiv ? \"Dividing\" : \"Multiplying\") + \" rational expressions\", 24, 34, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"step \" + (step + 1) + \" of 3 — \" + (isDiv ? \"flip 2nd, then multiply & cancel\" : \"multiply tops, multiply bottoms, then cancel\"), 24, 56, { color: H.colors.sub, size: 13 });\nconst B = b === 0 ? 1 : b, D = d === 0 ? 1 : d, C0 = c === 0 ? 1 : c;\nfunction frac(cx, cy, num, den, col) {\n const bw = 60;\n H.line(cx - bw / 2, cy, cx + bw / 2, cy, { color: H.colors.ink, width: 2 });\n H.text(String(num), cx, cy - 10, { color: col, size: 22, weight: 700, align: \"center\" });\n H.text(String(den), cx, cy + 26, { color: col, size: 22, weight: 700, align: \"center\" });\n}\nfunction op(cx, cy, s) { H.text(s, cx, cy + 8, { color: H.colors.violet, size: 24, weight: 700, align: \"center\" }); }\nconst cy = h * 0.42;\nconst pulse = 0.5 + 0.5 * Math.sin(t * 4);\nfrac(w * 0.22, cy, a, B, H.colors.accent);\nop(w * 0.36, cy, isDiv ? \"÷\" : \"×\");\nfrac(w * 0.50, cy, c, D, H.colors.accent2);\nif (step >= 1) {\n op(w * 0.64, cy, \"=\");\n frac(w * 0.78, cy, a, B, H.colors.accent);\n op(w * 0.86, cy, \"×\");\n frac(w * 0.95, cy, isDiv ? D : c, isDiv ? C0 : D, H.colors.good);\n}\nconst topN = isDiv ? a * D : a * c;\nconst botN = isDiv ? B * C0 : B * D;\nconst g = (function gcd(x, y) { x = Math.abs(x); y = Math.abs(y); while (y) { [x, y] = [y, x % y]; } return x || 1; })(topN, botN);\nif (step >= 2) {\n H.text(\"combine:\", 24, h * 0.66, { color: H.colors.sub, size: 14 });\n frac(w * 0.30, h * 0.72, topN, botN, H.colors.warn);\n op(w * 0.44, h * 0.72, \"=\");\n frac(w * 0.56, h * 0.72, topN / g, botN / g, H.colors.good);\n H.text(\"(divide top & bottom by \" + g + \")\", w * 0.66, h * 0.72 + 6, { color: H.colors.sub, size: 13 });\n}\nconst hx = H.lerp(w * 0.22, w * 0.5, (Math.sin(t * 0.8) + 1) / 2);\nH.circle(hx, cy + 8, 30 + 4 * pulse, { stroke: H.colors.yellow, width: 2 });" + }, + { + "id": "a2-normal-distribution-regression", + "area": "Algebra 2", + "topic": "Normal distribution and regression", + "title": "Normal curve: f(x) = (1/(σ√2π))·e^(−(x−μ)²/2σ²)", + "equation": "f(x) = (1/(sigma*sqrt(2*pi))) * e^(-(x-mu)^2 / (2*sigma^2)), z = (x - mu)/sigma", + "keywords": [ + "normal distribution", + "bell curve", + "gaussian", + "standard deviation", + "mean mu sigma", + "z-score", + "68 95 99.7 rule", + "empirical rule", + "standardize", + "regression", + "data spread", + "probability density" + ], + "explanation": "The normal (bell) curve models data that clusters around an average. The μ slider slides the whole bell left or right to the mean, while σ controls the spread — a small σ gives a tall, narrow peak and a large σ flattens it out, but the total area always stays 1. The green ±1σ band holds about 68% of the data (the empirical rule), and the moving probe reports its z-score (x−μ)/σ — how many standard deviations from the mean it sits — which is the same standardizing step used to compare points and interpret a regression's residuals.", + "bullets": [ + "μ locates the center of the bell; σ sets how wide and tall the spread is.", + "About 68% of data lies within ±1σ, 95% within ±2σ (the empirical rule).", + "The z-score z = (x−μ)/σ rescales any value to standard-deviation units from the mean." + ], + "params": [ + { + "name": "mu", + "label": "mean μ", + "min": -6, + "max": 6, + "step": 0.5, + "value": 0 + }, + { + "name": "sigma", + "label": "std dev σ", + "min": 1, + "max": 4, + "step": 0.25, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -0.05, yMax: 0.55 });\nv.grid(); v.axes();\nconst mu = P.mu, sd = Math.max(1, P.sigma);\nconst pdf = (x) => Math.exp(-0.5 * ((x - mu) / sd) * ((x - mu) / sd)) / (sd * Math.sqrt(2 * Math.PI));\nv.fn(pdf, { color: H.colors.accent, width: 3 });\nv.line(mu, 0, mu, pdf(mu), { color: H.colors.violet, width: 1.5, dash: [5, 4] });\nfor (let x = mu - sd; x <= mu + sd; x += sd / 12) {\n v.line(x, 0, x, pdf(x), { color: H.colors.good, width: 2 });\n}\nv.dot(mu - sd, pdf(mu - sd), { r: 5, fill: H.colors.good });\nv.dot(mu + sd, pdf(mu + sd), { r: 5, fill: H.colors.good });\nconst xp = H.clamp(mu + 3 * sd * Math.sin(t * 0.6), -10, 10);\nconst z = (xp - mu) / sd;\nv.dot(xp, pdf(xp), { r: 6, fill: H.colors.warn });\nv.line(xp, 0, xp, pdf(xp), { color: H.colors.warn, width: 1.5 });\nH.text(\"Normal: f(x) = (1/(σ√2π))·e^(−(x−μ)²/2σ²)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"μ = \" + mu.toFixed(1) + \" σ = \" + sd.toFixed(1) + \" green band = ±1σ ≈ 68% of data\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"probe x = \" + xp.toFixed(2) + \" z-score = (x−μ)/σ = \" + z.toFixed(2), 24, 74, { color: H.colors.warn, size: 13, weight: 600 });\nH.legend([{ label: \"bell curve\", color: H.colors.accent }, { label: \"±1σ (68%)\", color: H.colors.good }, { label: \"mean μ\", color: H.colors.violet }], H.W - 190, 28);" + }, + { + "id": "a2-parent-functions", + "area": "Algebra 2", + "topic": "Parent functions", + "title": "Parent functions: the six basic graphs", + "equation": "y = x, x^2, x^3, |x|, sqrt(x), 1/x", + "keywords": [ + "parent function", + "parent functions", + "basic functions", + "function family", + "toolkit functions", + "linear", + "quadratic", + "cubic", + "absolute value", + "square root", + "reciprocal", + "y=x^2" + ], + "explanation": "Every function you meet is a member of one of a few basic families, and the simplest member of each family is its parent function. Slide 'family' to flip between the six core shapes and watch how each one curves, opens, or breaks differently. The green dot marks the key anchor point (the origin for most, the corner for |x|, the start for sqrt) and the pink dot sweeps along so you can feel the shape's growth.", + "bullets": [ + "Each family has ONE parent graph; transformations move and stretch it.", + "Shape clues: x^2 is a U, x^3 is an S, |x| is a V, 1/x has two branches.", + "sqrt(x) and 1/x are restricted: sqrt needs x>=0, and 1/x skips x=0." + ], + "params": [ + { + "name": "fam", + "label": "family (1-6)", + "min": 1, + "max": 6, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst which = Math.round(P.fam);\nconst names = [\"y = x\", \"y = x^2\", \"y = x^3\", \"y = |x|\", \"y = sqrt(x)\", \"y = 1/x\"];\nconst fns = [\n (x) => x,\n (x) => x * x,\n (x) => x * x * x,\n (x) => Math.abs(x),\n (x) => (x >= 0 ? Math.sqrt(x) : NaN),\n (x) => (Math.abs(x) > 1e-6 ? 1 / x : NaN),\n];\nconst idx = Math.max(0, Math.min(5, which - 1));\nconst f = fns[idx];\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst xs = 5 * Math.sin(t * 0.6);\nlet ys = f(xs);\nlet onCurve = Number.isFinite(ys) && ys >= -6 && ys <= 6;\nif (onCurve) {\n v.dot(xs, ys, { r: 7, fill: H.colors.warn });\n} else {\n ys = NaN;\n}\nconst y0 = Number.isFinite(f(0)) ? f(0) : (idx === 5 ? 6 : 0);\nv.dot(0, H.clamp(y0, -6, 6), { r: 5, fill: H.colors.good });\nH.text(\"Parent functions: \" + names[idx], 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"family #\" + (idx + 1) + \" x = \" + xs.toFixed(2) + \" y = \" + (Number.isFinite(ys) ? ys.toFixed(2) : \"off-screen\"), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: names[idx], color: H.colors.accent }], H.W - 150, 28);" + }, + { + "id": "a2-permutations-combinations", + "area": "Algebra 2", + "topic": "Permutations and combinations", + "title": "Counting: P(n,r) = n!/(n−r)! and C(n,r) = n!/(r!(n−r)!)", + "equation": "P(n,r) = n! / (n-r)!, C(n,r) = P(n,r) / r! = n! / (r!*(n-r)!)", + "keywords": [ + "permutation", + "combination", + "counting", + "factorial", + "npr", + "ncr", + "n choose r", + "order matters", + "arrangements", + "selections", + "fundamental counting principle", + "binomial coefficient" + ], + "explanation": "Counting r picks from n items works slot by slot: the first slot has n choices, the next n−1, and so on — multiply them and you get the ordered count P(n,r). The animated pointer fills the r slots, and each box shows it has one fewer choice than the last because you can't reuse an item. Flip the kind slider to combinations: order no longer matters, so you divide by r! to collapse all the rearrangements of the same r picks into one group.", + "bullets": [ + "Each slot has one fewer choice (no repeats): n × (n−1) × … gives P(n,r).", + "Permutations count ordered lineups; combinations count unordered groups.", + "C(n,r) = P(n,r) ÷ r! removes the r! ways to reorder the same chosen items." + ], + "params": [ + { + "name": "n", + "label": "items n", + "min": 2, + "max": 10, + "step": 1, + "value": 5 + }, + { + "name": "r", + "label": "pick r", + "min": 1, + "max": 6, + "step": 1, + "value": 3 + }, + { + "name": "kind", + "label": "0 perm / 1 comb", + "min": 0, + "max": 1, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst w = H.W, hh = H.H;\nconst n = Math.max(1, Math.round(P.n));\nconst r = Math.max(1, Math.min(Math.round(P.r), n));\nconst isComb = P.kind >= 0.5;\nfunction fact(x) { let p = 1; for (let i = 2; i <= x; i++) p *= i; return p; }\nconst nPr = fact(n) / fact(n - r);\nconst nCr = nPr / fact(r);\nH.text(isComb ? \"Combinations: C(n,r) = n! / (r!·(n−r)!)\" : \"Permutations: P(n,r) = n! / (n−r)!\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"n = \" + n + \" r = \" + r + (isComb ? \" order does NOT matter\" : \" order matters\"), 24, 52, { color: H.colors.sub, size: 13 });\nconst slotW = 54, slotH = 54, gap = 14;\nconst totalW = r * slotW + (r - 1) * gap;\nconst sx = Math.max(40, (w - totalW) / 2), sy = 110;\nconst step = Math.floor((t * 0.9) % (r + 1));\nfor (let i = 0; i < r; i++) {\n const x = sx + i * (slotW + gap);\n const choices = n - i;\n const filled = i < step;\n H.rect(x, sy, slotW, slotH, { fill: filled ? H.colors.accent : H.colors.panel, stroke: i === step ? H.colors.warn : H.colors.axis, width: i === step ? 3 : 1.5, radius: 8 });\n H.text(String(choices), x + slotW * 0.5, sy + slotH * 0.5 + 8, { color: filled ? H.colors.bg : H.colors.ink, size: 22, weight: 700, align: \"center\" });\n H.text(\"slot \" + (i + 1), x + slotW * 0.5, sy - 10, { color: H.colors.sub, size: 11, align: \"center\" });\n if (i < r - 1) H.text(\"×\", x + slotW + gap * 0.5, sy + slotH * 0.5 + 7, { color: H.colors.ink, size: 20, weight: 700, align: \"center\" });\n}\nH.text(\"Each slot: one FEWER choice than the last (no repeats).\", sx, sy + slotH + 30, { color: H.colors.sub, size: 12 });\nH.text(\"P(n,r) = \" + nPr + \" ordered lineups\", sx, sy + slotH + 58, { color: H.colors.accent, size: 15, weight: 600 });\nif (isComb) {\n H.text(\"÷ r! = ÷\" + fact(r) + \" (drop the \" + fact(r) + \" orderings of the SAME r picks)\", sx, sy + slotH + 84, { color: H.colors.violet, size: 13 });\n H.text(\"C(n,r) = \" + nCr + \" unordered groups\", sx, sy + slotH + 110, { color: H.colors.good, size: 16, weight: 700 });\n}\nH.legend([{ label: \"filled slot\", color: H.colors.accent }, { label: \"current\", color: H.colors.warn }], w - 170, 28);" + }, + { + "id": "a2-piecewise-functions", + "area": "Algebra 2", + "topic": "Piecewise functions", + "title": "Piecewise: f(x) = left if x= c", + "keywords": [ + "piecewise function", + "piecewise", + "split function", + "branches", + "breakpoint", + "domain pieces", + "different rules", + "step function", + "boundary point", + "if x less than", + "two formulas", + "conditional function" + ], + "explanation": "A piecewise function uses different rules on different parts of the domain, switching at a breakpoint x = c (the violet line). Below c the blue left piece (slope m1) applies; at and above c the orange right piece (slope m2) takes over. The two pieces are built to meet at the corner, so the green tracer slides smoothly across the join — change m2 to see the graph bend at the breakpoint.", + "bullets": [ + "Each piece owns a part of the x-axis; the breakpoint c decides which rule applies.", + "Always check which interval an input falls in BEFORE plugging it in.", + "Pieces can meet (continuous) or jump (a gap) at the boundary." + ], + "params": [ + { + "name": "c", + "label": "breakpoint c", + "min": -5, + "max": 5, + "step": 0.5, + "value": 0 + }, + { + "name": "m1", + "label": "left slope m1", + "min": -3, + "max": 3, + "step": 0.1, + "value": -1 + }, + { + "name": "m2", + "label": "right slope m2", + "min": -3, + "max": 3, + "step": 0.1, + "value": 1.5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 10 });\nv.grid(); v.axes();\nconst c = P.c, m1 = P.m1, m2 = P.m2;\nconst left = x => m1 * x + 2;\nconst right = x => m2 * (x - c) + (m1 * c + 2);\nconst piece = x => (x < c ? left(x) : right(x));\nv.fn(x => (x <= c ? left(x) : NaN), { color: H.colors.accent, width: 3 });\nv.fn(x => (x >= c ? right(x) : NaN), { color: H.colors.accent2, width: 3 });\nv.line(c, -6, c, 10, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nconst yb = left(c);\nv.dot(c, H.clamp(yb, -6, 10), { r: 6, fill: H.colors.warn });\nconst x0 = 6 * Math.sin(t * 0.6);\nv.dot(H.clamp(x0, -8, 8), H.clamp(piece(x0), -6, 10), { r: 6, fill: H.colors.good });\nH.text(\"Piecewise: f(x) = left if x 0 lifts the right end up; a < 0 reflects the whole graph.", + "Only the leading term a·x^n matters for the far-left/far-right ends." + ], + "params": [ + { + "name": "a", + "label": "leading coef a", + "min": -2, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "n", + "label": "degree n", + "min": 1, + "max": 6, + "step": 1, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -3, xMax: 3, yMin: -12, yMax: 12 });\nv.grid(); v.axes();\nconst a = Math.abs(P.a) < 0.05 ? (P.a < 0 ? -0.1 : 0.1) : P.a;\nconst n = Math.max(1, Math.round(P.n));\nconst f = x => a * Math.pow(x, n);\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst even = n % 2 === 0;\nconst leftUp = even ? (a > 0) : (a < 0);\nconst rightUp = a > 0;\nv.dot(-3, H.clamp(f(-3), -12, 12), { r: 7, fill: H.colors.violet });\nv.dot(3, H.clamp(f(3), -12, 12), { r: 7, fill: H.colors.accent2 });\nconst arrL = leftUp ? \"↑\" : \"↓\";\nconst arrR = rightUp ? \"↑\" : \"↓\";\nv.text(\"x→ -∞ \" + arrL, -2.9, leftUp ? 10.5 : -10.5, { color: H.colors.violet, size: 15, weight: 700 });\nv.text(arrR + \" x→ +∞\", 1.4, rightUp ? 10.5 : -10.5, { color: H.colors.accent2, size: 15, weight: 700 });\nconst xs = 2.7 * Math.sin(t * 0.6);\nv.dot(xs, H.clamp(f(xs), -12, 12), { r: 6, fill: H.colors.warn });\nH.text(\"y = a · xⁿ (end behavior)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a = \" + a.toFixed(1) + \" n = \" + n + (even ? \" even: ends MATCH\" : \" odd: ends OPPOSE\"), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"left end\", color: H.colors.violet }, { label: \"right end\", color: H.colors.accent2 }], H.W - 150, 28);" + }, + { + "id": "a2-polynomial-graphing", + "area": "Algebra 2", + "topic": "Polynomial graphing", + "title": "Graphing from roots: y = a(x−r1)(x−r2)(x−r3)", + "equation": "y = a * (x - r1) * (x - r2) * (x - r3)", + "keywords": [ + "polynomial graphing", + "graph a polynomial", + "roots", + "zeros", + "x intercepts", + "factored form", + "sign of polynomial", + "turning points", + "cubic graph", + "sketch polynomial", + "factored polynomial", + "positive negative regions" + ], + "explanation": "A polynomial written in factored form wears its roots on its sleeve: it crosses the x-axis exactly where each factor is zero. Drag r1, r2, r3 and the green root dots slide along the axis, dragging the curve with them. Between consecutive roots the graph stays entirely above or below the axis — it can only change sign by passing THROUGH a root. The leading coefficient a tilts and stretches the whole shape and decides the end behavior.", + "bullets": [ + "Each factor (x − r) gives an x-intercept at x = r.", + "The curve only changes sign by crossing a root — so it alternates above/below between roots.", + "a stretches the graph and sets which way the ends point." + ], + "params": [ + { + "name": "a", + "label": "leading coef a", + "min": -2, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "r1", + "label": "root r1", + "min": -4, + "max": 4, + "step": 0.5, + "value": -3 + }, + { + "name": "r2", + "label": "root r2", + "min": -4, + "max": 4, + "step": 0.5, + "value": 0 + }, + { + "name": "r3", + "label": "root r3", + "min": -4, + "max": 4, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -10, yMax: 10 });\nv.grid(); v.axes();\nconst a = (Math.abs(P.a) < 0.05 ? 0.05 : P.a) * 0.5;\nconst r1 = P.r1, r2 = P.r2, r3 = P.r3;\nconst f = x => a * (x - r1) * (x - r2) * (x - r3);\nv.fn(f, { color: H.colors.accent, width: 3 });\n[r1, r2, r3].forEach(r => v.dot(r, 0, { r: 7, fill: H.colors.good }));\nconst xs = 4.6 * Math.sin(t * 0.55);\nconst ys = H.clamp(f(xs), -10, 10);\nv.line(xs, 0, xs, ys, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.dot(xs, ys, { r: 6, fill: H.colors.warn });\nconst sign = f(xs) >= 0 ? \"above axis (y > 0)\" : \"below axis (y < 0)\";\nH.text(\"y = a(x − r1)(x − r2)(x − r3)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"roots \" + r1.toFixed(1) + \", \" + r2.toFixed(1) + \", \" + r3.toFixed(1) + \" — sweep is \" + sign, 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"roots (y=0)\", color: H.colors.good }, { label: \"sweep\", color: H.colors.warn }], H.W - 160, 28);" + }, + { + "id": "a2-polynomial-inequalities", + "area": "Algebra 2", + "topic": "Polynomial inequalities", + "title": "Sign chart: solve a(x−r1)(x−r2)(x−r3) > 0", + "equation": "a * (x - r1) * (x - r2) * (x - r3) > 0 (or < 0)", + "keywords": [ + "polynomial inequality", + "polynomial inequalities", + "sign chart", + "sign analysis", + "test point", + "greater than zero", + "less than zero", + "solution set on number line", + "where is positive", + "where is negative", + "factored inequality", + "intervals" + ], + "explanation": "Solving a polynomial inequality is just reading off WHERE the graph is above (or below) the x-axis. The roots split the number line into intervals, and inside each interval the polynomial keeps one sign — so you only need to test a single point per interval. The green band along the axis marks every x that satisfies the chosen inequality; flip the direction slider to swap > 0 and < 0. Watch the moving test point report f(x) and whether it lands inside the solution set.", + "bullets": [ + "Roots cut the number line into intervals of constant sign.", + "Pick the intervals where the graph is on the correct side of the axis.", + "One test point per interval is enough — the sign can't change without a root." + ], + "params": [ + { + "name": "r1", + "label": "root r1", + "min": -4, + "max": 4, + "step": 0.5, + "value": -3 + }, + { + "name": "r2", + "label": "root r2", + "min": -4, + "max": 4, + "step": 0.5, + "value": 0 + }, + { + "name": "r3", + "label": "root r3", + "min": -4, + "max": 4, + "step": 0.5, + "value": 3 + }, + { + "name": "dir", + "label": "0 = (<0) 1 = (>0)", + "min": 0, + "max": 1, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst r1 = Math.min(P.r1, P.r2), r2 = Math.max(P.r1, P.r2), r3 = P.r3;\nconst f = x => 0.4 * (x - r1) * (x - r2) * (x - r3);\nconst wantPos = P.dir >= 0.5;\nconst N = 200;\nfor (let i = 0; i < N; i++) {\n const x = H.lerp(-5, 5, i / N);\n const y = f(x);\n const inSol = wantPos ? (y > 0) : (y < 0);\n if (inSol) v.line(x, 0, x, -0.55, { color: H.colors.good, width: 3 });\n}\nv.fn(f, { color: H.colors.accent, width: 3 });\n[r1, r2, r3].forEach(r => v.dot(r, 0, { r: 6, fill: H.colors.warn }));\nconst xs = 4.7 * Math.sin(t * 0.5);\nconst ys = H.clamp(f(xs), -8, 8);\nv.dot(xs, ys, { r: 6, fill: H.colors.violet });\nv.dot(xs, 0, { r: 5, fill: H.colors.violet });\nconst here = f(xs);\nconst pass = wantPos ? (here > 0) : (here < 0);\nH.text(\"Solve a(x−r1)(x−r2)(x−r3) \" + (wantPos ? \"> 0\" : \"< 0\"), 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"test x = \" + xs.toFixed(2) + \": f(x) = \" + here.toFixed(2) + \" → \" + (pass ? \"IN solution\" : \"out\"), 24, 52, { color: pass ? H.colors.good : H.colors.sub, size: 13 });\nH.legend([{ label: \"solution set\", color: H.colors.good }, { label: \"roots\", color: H.colors.warn }], H.W - 160, 28);" + }, + { + "id": "a2-polynomial-long-division", + "area": "Algebra 2", + "topic": "Polynomial long division", + "title": "Long division: (x²+bx+c) ÷ (x−r)", + "equation": "x^2 + b·x + c = (x - r)·(quotient) + remainder, remainder = N(r)", + "keywords": [ + "polynomial long division", + "long division", + "divide polynomials", + "quotient and remainder", + "dividend divisor", + "remainder theorem", + "synthetic division", + "divide by x minus r", + "step by step division", + "factor", + "polynomial" + ], + "explanation": "Long division of polynomials works just like number long division: divide the leading terms, multiply back, subtract, and bring down — repeat until the degree drops below the divisor. Step the slider to reveal one stage at a time and watch the highlighted line pulse. The graph on the right confirms the Remainder Theorem: the remainder equals N(r), the value of the dividend at x = r, so a zero remainder means (x − r) is a factor.", + "bullets": [ + "Each step: leading ÷ leading → multiply the divisor → subtract → bring down.", + "Stop when the remainder's degree is below the divisor's degree.", + "Remainder Theorem: dividing by (x − r) leaves remainder N(r); zero ⇒ (x − r) is a factor." + ], + "params": [ + { + "name": "b", + "label": "dividend x coef b", + "min": -6, + "max": 6, + "step": 0.5, + "value": 1 + }, + { + "name": "c", + "label": "dividend const c", + "min": -8, + "max": 8, + "step": 0.5, + "value": -6 + }, + { + "name": "r", + "label": "divisor root r", + "min": -4, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "step", + "label": "reveal step", + "min": 0, + "max": 5, + "step": 1, + "value": 5 + } + ], + "code": "H.background();\nconst b = P.b, c = P.c, r = P.r, step = Math.max(0, Math.round(P.step));\nconst q1 = 1;\nconst q0 = b + r;\nconst rem = c + r * (b + r);\nconst w = H.W, h = H.H;\nconst sg = (x) => (x >= 0 ? \"+\" : \"-\");\nlet y = 96;\nconst L = 28;\nH.text(\"Polynomial long division\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"(x^2 \" + sg(b) + \" \" + Math.abs(b).toFixed(1) + \"x \" + sg(c) + \" \" + Math.abs(c).toFixed(1) + \") / (x \" + sg(-r) + \" \" + Math.abs(r).toFixed(1) + \")\", 24, 54, { color: H.colors.sub, size: 13 });\nconst lines = [\n \"1) x^2 / x = x -> first quotient term\",\n \"2) x*(x \" + sg(-r) + \" \" + Math.abs(r).toFixed(1) + \") = x^2 \" + sg(-r) + \" \" + Math.abs(r).toFixed(1) + \"x subtract\",\n \"3) bring down: (\" + (b + r).toFixed(1) + \")x \" + sg(c) + \" \" + Math.abs(c).toFixed(1),\n \"4) (\" + (b + r).toFixed(1) + \")x / x = \" + (b + r).toFixed(1) + \" -> next term\",\n \"5) remainder = \" + rem.toFixed(1),\n];\nconst lit = step % (lines.length + 1);\nfor (let i = 0; i < lines.length; i++) {\n const shown = i < lit;\n const pulsing = (i === lit - 1);\n const col = shown ? (pulsing ? H.colors.warn : H.colors.ink) : H.colors.grid;\n if (pulsing) H.rect(L - 6, y - 14, 360, 20, { fill: H.hsl(40, 80, 60, 0.12 + 0.06 * Math.sin(t * 4)) });\n H.text(lines[i], L, y, { color: col, size: 13 });\n y += L;\n}\nH.rect(L - 6, y + 2, 360, 30, { stroke: H.colors.good, width: 1.5, radius: 6 });\nH.text(\"quotient: x \" + sg(q0) + \" \" + Math.abs(q0).toFixed(1) + \" remainder: \" + rem.toFixed(1), L, y + 22, { color: H.colors.good, size: 13, weight: 700 });\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -12, yMax: 12, box: { x: w * 0.56, y: 80, w: w * 0.40, h: h - 150 } });\nv.grid(); v.axes();\nconst N = (x) => x * x + b * x + c;\nv.fn(N, { color: H.colors.accent, width: 2.5 });\nv.dot(r, rem, { r: 6 + Math.sin(t * 3), fill: H.colors.warn });\nv.line(r, 0, r, rem, { color: H.colors.violet, width: 1, dash: [3, 3] });\nH.text(\"N(r) = remainder = \" + rem.toFixed(1), w * 0.56, 70, { color: H.colors.sub, size: 12 });" + }, + { + "id": "a2-polynomial-operations", + "area": "Algebra 2", + "topic": "Polynomial operations", + "title": "Adding polynomials: combine like terms", + "equation": "p(x) + q(x) = (a1+a2)·x^2 + (b1+b2)·x", + "keywords": [ + "polynomial operations", + "adding polynomials", + "combine like terms", + "add subtract polynomials", + "polynomial addition", + "like terms", + "coefficients", + "p(x) + q(x)", + "degree", + "sum of polynomials", + "quadratic", + "polynomial" + ], + "explanation": "Adding polynomials means adding the coefficients of matching powers of x — the x^2 terms combine, the x terms combine, and so on, because only like terms can merge. The green curve p+q is exactly the blue and orange curves stacked: at any x, its height is p(x) + q(x). Slide the four coefficients and watch the moving dot show the two heights summing to the green one.", + "bullets": [ + "Only like terms combine: x² with x², x with x, constants with constants.", + "Add the coefficients of each power; the degree stays the same (or drops if they cancel).", + "At every x the sum curve's height equals p(x) + q(x)." + ], + "params": [ + { + "name": "a1", + "label": "p: x² coef a1", + "min": -2, + "max": 2, + "step": 0.25, + "value": 1 + }, + { + "name": "b1", + "label": "p: x coef b1", + "min": -4, + "max": 4, + "step": 0.5, + "value": 1 + }, + { + "name": "a2", + "label": "q: x² coef a2", + "min": -2, + "max": 2, + "step": 0.25, + "value": -0.5 + }, + { + "name": "b2", + "label": "q: x coef b2", + "min": -4, + "max": 4, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -4, xMax: 4, yMin: -8, yMax: 12 });\nv.grid(); v.axes();\nconst a1 = P.a1, b1 = P.b1, a2 = P.a2, b2 = P.b2;\nconst p = (x) => a1 * x * x + b1 * x;\nconst q = (x) => a2 * x * x + b2 * x;\nconst s = (x) => (a1 + a2) * x * x + (b1 + b2) * x;\nv.fn(p, { color: H.colors.accent, width: 2 });\nv.fn(q, { color: H.colors.accent2, width: 2 });\nv.fn(s, { color: H.colors.good, width: 3 });\nconst xs = 3 * Math.sin(t * 0.7);\nconst py = p(xs), qy = q(xs), syv = s(xs);\nv.dot(xs, py, { r: 5, fill: H.colors.accent });\nv.dot(xs, qy, { r: 5, fill: H.colors.accent2 });\nv.dot(xs, syv, { r: 6, fill: H.colors.warn });\nv.line(xs, 0, xs, syv, { color: H.colors.violet, width: 1, dash: [3, 3] });\nH.text(\"Adding polynomials: combine like terms\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nconst sg = (x) => (x >= 0 ? \"+\" : \"-\");\nH.text(\"(\" + (a1 + a2).toFixed(1) + \")x^2 \" + sg(b1 + b2) + \" \" + Math.abs(b1 + b2).toFixed(1) + \"x at x=\" + xs.toFixed(1) + \": \" + py.toFixed(1) + \" + \" + qy.toFixed(1) + \" = \" + syv.toFixed(1), 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"p(x)\", color: H.colors.accent }, { label: \"q(x)\", color: H.colors.accent2 }, { label: \"p+q\", color: H.colors.good }], H.W - 150, 28);" + }, + { + "id": "a2-quadratic-forms", + "area": "Algebra 2", + "topic": "Quadratic forms: standard, vertex, factored", + "title": "Three forms of one parabola: y = a(x − r1)(x − r2)", + "equation": "y = a(x − r1)(x − r2) = a(x − h)^2 + k = ax^2 + bx + c", + "keywords": [ + "quadratic forms", + "standard form", + "vertex form", + "factored form", + "intercept form", + "three forms of a quadratic", + "roots", + "vertex", + "y intercept", + "a(x-r1)(x-r2)", + "ax^2+bx+c", + "parabola forms" + ], + "explanation": "The very same parabola can be written three ways, and each form hands you a different feature for free. Factored form a(x − r1)(x − r2) shows the ROOTS where it crosses the x-axis. The vertex sits exactly midway between the roots at h = (r1 + r2)/2, and plugging x = 0 gives the y-intercept c of standard form. Drag the two roots and a: the green root dots, the pink vertex, and the orange y-intercept all update on the one curve, so you SEE why the forms describe identical graphs.", + "bullets": [ + "Factored a(x − r1)(x − r2): roots r1, r2 are read directly.", + "Vertex sits at h = (r1 + r2)/2 — the axis of symmetry between the roots.", + "Standard form's c = a·r1·r2 is the y-value where x = 0." + ], + "params": [ + { + "name": "a", + "label": "shape a", + "min": -2, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "r1", + "label": "root r1", + "min": -6, + "max": 6, + "step": 0.5, + "value": -3 + }, + { + "name": "r2", + "label": "root r2", + "min": -6, + "max": 6, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 12 });\nv.grid(); v.axes();\nconst a = Math.abs(P.a) < 0.1 ? 0.1 : P.a, r1 = P.r1, r2 = P.r2;\n// factored form a(x - r1)(x - r2): same parabola read three ways\nconst f = (x) => a * (x - r1) * (x - r2);\nv.fn(f, { color: H.colors.accent, width: 3 });\n// derived vertex (axis of symmetry midway between the roots)\nconst h = (r1 + r2) / 2;\nconst k = f(h);\nv.line(h, -6, h, 12, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.dot(h, k, { r: 7, fill: H.colors.warn });\n// roots (factored form reads them straight off)\nv.dot(r1, 0, { r: 6, fill: H.colors.good });\nv.dot(r2, 0, { r: 6, fill: H.colors.good });\n// standard-form y-intercept c = a*r1*r2 (factored form reads it at x=0)\nconst c = f(0);\nv.dot(0, c, { r: 6, fill: H.colors.accent2 });\n// animated point riding the curve\nconst xs = h + 5 * Math.sin(t * 0.7);\nv.dot(xs, f(xs), { r: 6, fill: H.colors.accent2 });\nH.text(\"Three forms of ONE parabola\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"standard a=\" + a.toFixed(1) + \" c=\" + c.toFixed(1) + \" vertex (\" + h.toFixed(1) + \", \" + k.toFixed(1) + \") factored roots \" + r1.toFixed(1) + \", \" + r2.toFixed(1), 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"roots (factored)\", color: H.colors.good }, { label: \"vertex (vertex form)\", color: H.colors.warn }, { label: \"y-int c (standard)\", color: H.colors.accent2 }], H.W - 215, 28);" + }, + { + "id": "a2-quadratic-formula", + "area": "Algebra 2", + "topic": "Quadratic formula", + "title": "Quadratic formula: x = (−b ± √(b² − 4ac)) / 2a", + "equation": "x = (−b ± √(b^2 − 4ac)) / 2a", + "keywords": [ + "quadratic formula", + "x equals minus b plus or minus", + "(-b±√(b^2-4ac))/2a", + "discriminant", + "roots", + "zeros", + "solve quadratic", + "ax^2+bx+c=0", + "two solutions", + "plus or minus", + "axis of symmetry" + ], + "explanation": "The quadratic formula is built around a center and a spread. The −b/2a term is the axis of symmetry — the x where the parabola turns — and the ±√(b² − 4ac)/2a term is how far the two roots sit on EITHER side of it. The animation slides markers out from that center to the roots, so you see the ± as a symmetric step. The discriminant b² − 4ac under the root decides the count: positive gives two crossings, zero gives one (the vertex kisses the axis), negative gives none.", + "bullets": [ + "−b/2a is the axis of symmetry; roots are symmetric about it.", + "± √(b² − 4ac)/2a is the equal distance out to each root.", + "Discriminant b² − 4ac: >0 two roots, =0 one, <0 none (real)." + ], + "params": [ + { + "name": "a", + "label": "a", + "min": -2, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "b", + "label": "b", + "min": -6, + "max": 6, + "step": 0.5, + "value": -1 + }, + { + "name": "c", + "label": "c", + "min": -8, + "max": 8, + "step": 0.5, + "value": -6 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -10, yMax: 10 });\nv.grid(); v.axes();\nconst a = Math.abs(P.a) < 0.1 ? 0.1 : P.a, b = P.b, c = P.c;\nconst f = (x) => a * x * x + b * x + c;\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst disc = b * b - 4 * a * c;\nconst axis = -b / (2 * a);\nv.line(axis, -10, axis, 10, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nif (disc >= 0) {\n const root = Math.sqrt(disc) / (2 * a);\n const r1 = axis + root, r2 = axis - root;\n v.dot(r1, 0, { r: 6, fill: H.colors.good });\n v.dot(r2, 0, { r: 6, fill: H.colors.good });\n // animate a marker sliding from the axis OUT to each root by ±√disc/2a\n const swing = (0.5 + 0.5 * Math.sin(t * 1.1));\n v.dot(axis + root * swing, 0, { r: 5, fill: H.colors.warn });\n v.dot(axis - root * swing, 0, { r: 5, fill: H.colors.warn });\n}\nconst xs = axis + 5 * Math.sin(t * 0.7);\nv.dot(xs, f(xs), { r: 6, fill: H.colors.accent2 });\nH.text(\"x = (−b ± √(b² − 4ac)) / 2a\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst verdict = disc > 1e-9 ? \"2 real roots\" : Math.abs(disc) <= 1e-9 ? \"1 (double) root\" : \"no real roots\";\nH.text(\"−b/2a = \" + axis.toFixed(2) + \" b²−4ac = \" + disc.toFixed(2) + \" → \" + verdict, 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"axis −b/2a\", color: H.colors.violet }, { label: \"roots\", color: H.colors.good }], H.W - 155, 28);" + }, + { + "id": "a2-quadratic-inequalities", + "area": "Algebra 2", + "topic": "Quadratic inequalities", + "title": "Quadratic inequality: ax^2 + bx + c < 0", + "equation": "a*x^2 + b*x + c < 0", + "keywords": [ + "quadratic inequality", + "ax^2+bx+c<0", + "less than zero", + "below the x axis", + "solution interval", + "sign chart", + "where parabola is negative", + "test point", + "between the roots", + "quadratic", + "inequality solution", + "number line" + ], + "explanation": "Solving a quadratic inequality is really just asking WHERE the parabola is below (or above) the x-axis. Here the shaded red band marks every x where ax^2 + bx + c < 0, and the green dots are the boundary roots that bracket it. A test point slides back and forth turning red whenever it dips below the axis, showing how a single test value tells you which interval to keep. Flip a negative and the parabola opens downward, so the 'below zero' region jumps to the outside instead of between the roots.", + "bullets": [ + "The solution set is the x-values where the curve is on the correct side of the axis.", + "The roots are the boundaries; '<' uses open intervals, '<=' includes them.", + "A single test point in each region tells you whether that whole interval works." + ], + "params": [ + { + "name": "a", + "label": "a (up/down)", + "min": -2, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "b", + "label": "b", + "min": -6, + "max": 6, + "step": 0.5, + "value": 0 + }, + { + "name": "c", + "label": "c", + "min": -8, + "max": 8, + "step": 0.5, + "value": -4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 10 });\nv.grid(); v.axes();\nconst a = Math.abs(P.a) < 0.15 ? 0.15 : P.a;\nconst b = P.b, c = P.c;\nconst f = x => a * x * x + b * x + c;\nconst disc = b * b - 4 * a * c;\nif (disc > 0) {\n const r1 = (-b - Math.sqrt(disc)) / (2 * a), r2 = (-b + Math.sqrt(disc)) / (2 * a);\n const lo = Math.min(r1, r2), hi = Math.max(r1, r2);\n if (a > 0) {\n // opens up: f<0 strictly BETWEEN the roots\n for (let x = lo; x <= hi; x += (hi - lo) / 60) {\n v.line(x, 0, x, f(x), { color: H.colors.warn, width: 2 });\n }\n H.text(\"solution of ax² + bx + c < 0: \" + lo.toFixed(2) + \" < x < \" + hi.toFixed(2), 24, 74, { color: H.colors.warn, size: 13, weight: 600 });\n } else {\n // opens down: f<0 OUTSIDE the roots (xhi)\n for (let x = -8; x <= lo; x += (lo - (-8)) / 40 || 1) {\n v.line(x, 0, x, f(x), { color: H.colors.warn, width: 2 });\n }\n for (let x = hi; x <= 8; x += (8 - hi) / 40 || 1) {\n v.line(x, 0, x, f(x), { color: H.colors.warn, width: 2 });\n }\n H.text(\"solution of ax² + bx + c < 0: x < \" + lo.toFixed(2) + \" or x > \" + hi.toFixed(2), 24, 74, { color: H.colors.warn, size: 13, weight: 600 });\n }\n v.dot(lo, 0, { r: 6, fill: H.colors.good });\n v.dot(hi, 0, { r: 6, fill: H.colors.good });\n} else {\n // no real roots: sign is constant = sign(a)\n if (a < 0) {\n for (let x = -8; x <= 8; x += 16 / 80) {\n v.line(x, 0, x, f(x), { color: H.colors.warn, width: 2 });\n }\n }\n H.text(a > 0 ? \"no real roots: ax²+bx+c < 0 has NO solution\" : \"no real roots: ax²+bx+c < 0 for ALL x\", 24, 74, { color: H.colors.warn, size: 13, weight: 600 });\n}\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst xs = -6 + ((t * 1.6) % 12);\nconst inside = f(xs) < 0;\nv.dot(xs, f(xs), { r: 6, fill: inside ? H.colors.warn : H.colors.accent2 });\nv.dot(xs, 0, { r: 4, fill: inside ? H.colors.warn : H.colors.sub });\nH.text(\"Quadratic inequality: ax² + bx + c < 0\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a=\" + a.toFixed(1) + \" b=\" + b.toFixed(1) + \" c=\" + c.toFixed(1) + \" test x=\" + xs.toFixed(2) + \" -> f=\" + f(xs).toFixed(2) + (inside ? \" (TRUE)\" : \" (false)\"), 24, 52, { color: H.colors.sub, size: 12 });" + }, + { + "id": "a2-quadratic-modeling", + "area": "Algebra 2", + "topic": "Quadratic modeling", + "title": "Quadratic model: h(t) = -1/2 g t^2 + v0 t + h0", + "equation": "h(t) = -0.5*g*t^2 + v0*t + h0", + "keywords": [ + "quadratic modeling", + "projectile motion", + "thrown object height", + "max height", + "word problem parabola", + "h(t)", + "vertex peak", + "when does it land", + "real world quadratic", + "height vs time", + "gravity model", + "launch height" + ], + "explanation": "Real-world problems like a tossed ball turn into quadratics because gravity makes height a parabola in time. Here h(t) = -1/2 g t^2 + v0 t + h0 maps time on the x-axis to height on the y-axis, and a dot rides the arc on a loop. The vertex (violet) is the PEAK height and the time it occurs is v0/g, while the green dot is where the object lands (h = 0). Slide the launch speed v0, starting height h0, and gravity g to see how the peak rises and the landing time shifts -- the same algebra behind 'how high' and 'when does it hit the ground' questions.", + "bullets": [ + "Constant gravity makes height a downward parabola in time t.", + "The vertex t = v0/g gives the maximum height; it is the axis of symmetry.", + "The positive root of h(t) = 0 is the landing time -- solve with the quadratic formula." + ], + "params": [ + { + "name": "v0", + "label": "launch speed v0", + "min": 2, + "max": 20, + "step": 0.5, + "value": 12 + }, + { + "name": "h0", + "label": "start height h0", + "min": 0, + "max": 8, + "step": 0.5, + "value": 2 + }, + { + "name": "g", + "label": "gravity g", + "min": 2, + "max": 12, + "step": 0.5, + "value": 9.8 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 7, yMin: -1, yMax: 22 });\nv.grid(); v.axes();\nconst v0 = P.v0, h0 = P.h0, g = P.g;\nconst f = x => -0.5 * g * x * x + v0 * x + h0;\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst tpk = g > 1e-6 ? v0 / g : 0;\nconst hpk = f(tpk);\nif (tpk >= 0 && tpk <= 7) {\n v.line(tpk, -1, tpk, hpk, { color: H.colors.violet, width: 1.2, dash: [4, 4] });\n v.dot(tpk, hpk, { r: 6, fill: H.colors.warn });\n}\nconst disc = v0 * v0 + 2 * g * h0;\nlet land = 0;\nif (g > 1e-6 && disc >= 0) {\n land = (v0 + Math.sqrt(disc)) / g;\n if (land >= 0 && land <= 7) v.dot(land, 0, { r: 6, fill: H.colors.good });\n}\nconst span = Math.max(0.5, Math.min(7, land || tpk * 2 || 5));\nconst xs = (t * 0.9) % span;\nv.dot(xs, Math.max(0, f(xs)), { r: 7, fill: H.colors.accent2 });\nH.text(\"Quadratic model: h(t) = −½g·t² + v₀·t + h₀\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"v₀=\" + v0.toFixed(1) + \" h₀=\" + h0.toFixed(1) + \" g=\" + g.toFixed(1) + \" peak \" + hpk.toFixed(1) + \" at t=\" + tpk.toFixed(2) + \"s\", 24, 52, { color: H.colors.sub, size: 12 });\nH.text(\"now: t=\" + xs.toFixed(2) + \"s height=\" + Math.max(0, f(xs)).toFixed(2) + (land ? \" lands at t=\" + land.toFixed(2) + \"s\" : \"\"), 24, 74, { color: H.colors.good, size: 12 });" + }, + { + "id": "a2-radical-equations", + "area": "Algebra 2", + "topic": "Radical equations", + "title": "Radical equation: √(x − h) + c = L", + "equation": "sqrt(x - h) + c = L -> x = h + (L - c)^2", + "keywords": [ + "radical equation", + "solve radical equation", + "square root equation", + "extraneous solution", + "extraneous root", + "isolate the radical", + "square both sides", + "sqrt(x-h)+c=l", + "graphical solution", + "intersection method", + "no solution radical" + ], + "explanation": "Solving a radical equation graphically means finding where the square-root curve meets the level line y = L — that x is the solution. Isolate the radical and square: √(x−h) = L−c forces x = h + (L−c)². Slide the level L below c and watch the line drop beneath the curve's lowest point (its value c): no intersection exists, so squaring would invent an extraneous root that fails the original equation. That is exactly why you must check answers in radical equations.", + "bullets": [ + "The curve starts at its minimum value c (at x = h) and only rises — it can never reach a level below c.", + "Squaring both sides gives x = h + (L−c)², but it's only valid when L ≥ c.", + "If L < c the lines never cross: any algebraic answer is extraneous and must be rejected." + ], + "params": [ + { + "name": "h", + "label": "shift right h", + "min": -2, + "max": 6, + "step": 0.5, + "value": 1 + }, + { + "name": "c", + "label": "vertical shift c", + "min": -3, + "max": 4, + "step": 0.5, + "value": -1 + }, + { + "name": "L", + "label": "level L", + "min": -3, + "max": 6, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -2, xMax: 12, yMin: -4, yMax: 8 });\nv.grid(); v.axes();\nconst h = P.h, c = P.c, L = P.L;\nconst f = (x) => (x < h ? NaN : Math.sqrt(x - h) + c);\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(-2, L, 12, L, { color: H.colors.violet, width: 2, dash: [5, 5] });\nconst rhs = L - c;\nconst hasSol = rhs >= 0;\nconst xsol = h + rhs * rhs;\nif (hasSol && xsol <= 12) {\n const pulse = 6 + 2 * Math.sin(t * 3);\n v.circle(xsol, L, pulse, { fill: H.colors.good });\n v.line(xsol, -4, xsol, L, { color: H.colors.good, width: 1.2, dash: [3, 3] });\n}\nconst xs = h + ((t * 1.5) % 10);\nconst ys = f(xs);\nif (isFinite(ys) && ys < 8) v.dot(xs, ys, { r: 6, fill: H.colors.warn });\nH.text(\"√(x − h) + c = L\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst msg = hasSol ? (\"x = h + (L−c)² = \" + xsol.toFixed(2)) : \"L < c → NO solution (extraneous)\";\nH.text(\"h = \" + h.toFixed(1) + \" c = \" + c.toFixed(1) + \" L = \" + L.toFixed(1) + \" \" + msg, 24, 52, { color: hasSol ? H.colors.good : H.colors.warn, size: 13 });\nH.legend([{ label: \"√(x−h)+c\", color: H.colors.accent }, { label: \"y = L\", color: H.colors.violet }], H.W - 160, 28);" + }, + { + "id": "a2-radical-function-graphing", + "area": "Algebra 2", + "topic": "Radical function graphing", + "title": "Square-root function: y = a*sqrt(x - h) + k", + "equation": "y = a * sqrt(x - h) + k", + "keywords": [ + "radical function", + "square root function", + "graphing radicals", + "sqrt graph", + "y=a sqrt(x-h)+k", + "domain restriction", + "endpoint", + "translation", + "half parabola", + "root function", + "transform square root" + ], + "explanation": "The graph of sqrt(x) is half of a sideways parabola that starts at the origin and rises ever more slowly. h slides that starting endpoint left/right (and sets the domain: x must be at least h, since you can't take the square root of a negative), k slides it up/down, and a stretches the curve vertically — a negative a flips it to open downward instead of upward. The pink dot marks the endpoint (h, k) where the curve begins; watch the orange dot ride along the curve and notice how its climb slows as x grows.", + "bullets": [ + "The curve STARTS at the endpoint (h, k); its domain is x >= h.", + "a stretches it vertically; a < 0 reflects it so the curve heads downward.", + "Unlike a line, a radical rises fast at first then flattens — never a straight slope." + ], + "params": [ + { + "name": "a", + "label": "stretch a", + "min": -3, + "max": 3, + "step": 0.5, + "value": 2 + }, + { + "name": "h", + "label": "start x h", + "min": -6, + "max": 6, + "step": 0.5, + "value": -2 + }, + { + "name": "k", + "label": "start y k", + "min": -4, + "max": 4, + "step": 0.5, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 8 });\nv.grid(); v.axes();\nconst a = P.a, h = P.h, k = P.k;\nv.fn(x => (x - h >= 0 ? a * Math.sqrt(x - h) + k : NaN), { color: H.colors.accent, width: 3 });\nv.dot(h, k, { r: 7, fill: H.colors.warn });\nconst u = 3 + 3 * Math.sin(t * 0.6);\nconst xs = h + u;\nv.dot(xs, a * Math.sqrt(u) + k, { r: 6, fill: H.colors.accent2 });\nH.text(\"y = a*sqrt(x - h) + k\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"start (\" + h.toFixed(1) + \", \" + k.toFixed(1) + \") a = \" + a.toFixed(1) + (a >= 0 ? \" (opens up-right)\" : \" (opens down-right)\"), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"radical curve\", color: H.colors.accent }, { label: \"endpoint (h,k)\", color: H.colors.warn }], H.W - 190, 28);" + }, + { + "id": "a2-radical-simplification", + "area": "Algebra 2", + "topic": "Radical simplification", + "title": "Simplify √n by pulling out perfect squares", + "equation": "sqrt(n) = sqrt(k^2 * m) = k * sqrt(m)", + "keywords": [ + "radical simplification", + "simplify radical", + "simplify square root", + "perfect square factor", + "sqrt", + "square root", + "radicand", + "factor out", + "k root m", + "simplest radical form", + "prime factorization radical" + ], + "explanation": "A square root simplifies when its radicand hides a perfect-square factor. Slide n and the demo finds the largest k with k² dividing n, so √n = √(k²·m) = k√m. The thick bar shows the true length √n on the axis, while the growing green square (side k) is the perfect-square block being pulled OUT of the radical as the whole-number coefficient. The leftover m is what stays trapped under the root.", + "bullets": [ + "√(k²·m) splits as √(k²)·√m = k·√m — a perfect-square factor escapes as a whole number.", + "Always pull out the LARGEST square factor, or you'll have to simplify again.", + "If no square factor above 1 divides n (n is square-free), √n is already in simplest form." + ], + "params": [ + { + "name": "n", + "label": "radicand n", + "min": 1, + "max": 200, + "step": 1, + "value": 72 + } + ], + "code": "H.background();\nconst n = Math.max(1, Math.round(P.n));\nlet sq = 1, k = 1;\nfor (let i = 1; i * i <= n; i++) { if (n % (i * i) === 0) { sq = i * i; k = i; } }\nconst rem = n / sq;\nconst v = H.plot2d({ xMin: 0, xMax: 14, yMin: -1, yMax: 9 });\nv.grid(); v.axes();\nconst root = Math.sqrt(n);\nv.line(0, 0, Math.min(root, 14), 0, { color: H.colors.accent, width: 5 });\nconst pulse = 6 + 2 * Math.sin(t * 3);\nv.circle(Math.min(root, 14), 0, pulse, { fill: H.colors.warn });\nconst grow = 0.5 + 0.5 * Math.sin(t * 0.8);\nv.rect(0.4, 2.5, k * grow * 0.9 + 0.2, k * grow * 0.9 + 0.2, { stroke: H.colors.good, fill: \"rgba(103,232,176,0.18)\", width: 2 });\nv.text(\"k = \" + k, 0.6, 2.0, { color: H.colors.good, size: 14 });\nH.text(\"√\" + n + \" = \" + (k > 1 ? k + \"√\" + rem : \"√\" + rem), 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"√\" + n + \" = √(\" + sq + \"·\" + rem + \") = √\" + sq + \"·√\" + rem + \" = \" + k + \"·√\" + rem + \" ≈ \" + root.toFixed(3), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"length √\" + n, color: H.colors.accent }, { label: \"pulled-out k=\" + k, color: H.colors.good }], H.W - 180, 28);" + }, + { + "id": "a2-rational-equations", + "area": "Algebra 2", + "topic": "Rational equations", + "title": "Solve a rational equation: a/(x − h) = k", + "equation": "a/(x - h) = k => x = h + a/k", + "keywords": [ + "rational equation", + "solve rational equation", + "a/(x-h)=k", + "equation with fractions", + "clear denominators", + "cross multiply", + "reciprocal function", + "one over x", + "solve for x", + "intersection", + "graphical solution", + "rational" + ], + "explanation": "A rational equation is solved where its two sides are equal — graphically, where the curve y = a/(x − h) crosses the horizontal line y = k. Slide a and h to reshape and shift the curve, and slide k to raise or lower the target line; the pulsing green dot marks the x that satisfies the equation. Notice the dashed line at x = h: the curve can never touch it, which is why k = 0 has no solution.", + "bullets": [ + "The solution is the x-value where the curve meets the line y = k.", + "Algebraically: multiply both sides by (x − h) to get x = h + a/k.", + "If k = 0 the horizontal line is the curve's own asymptote, so there is no solution." + ], + "params": [ + { + "name": "a", + "label": "numerator a", + "min": -6, + "max": 6, + "step": 0.5, + "value": 4 + }, + { + "name": "h", + "label": "shift h", + "min": -5, + "max": 5, + "step": 0.5, + "value": 1 + }, + { + "name": "k", + "label": "target k", + "min": -5, + "max": 5, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\n// Solve a/(x - h) = k by graphing both sides and finding the crossing.\nconst a = P.a, h = P.h, k = P.k;\n// vertical asymptote at x = h\nv.line(h, -8, h, 8, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\n// left and right branches of y = a/(x - h)\nv.fn(x => (Math.abs(x - h) < 0.04 ? NaN : a / (x - h)), { color: H.colors.accent, width: 3 });\n// the right-hand side y = k\nv.line(-8, k, 8, k, { color: H.colors.accent2, width: 2.5 });\n// solution: a/(x-h) = k => x = h + a/k (if k != 0)\nH.text(\"Solve a/(x - h) = k\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nif (Math.abs(k) > 1e-6) {\n const xs = h + a / k;\n if (xs >= -8 && xs <= 8) {\n H.circle(v.X(xs), v.Y(k), 6 + 1.5 * Math.sin(t * 3), { fill: H.colors.good });\n v.line(xs, 0, xs, k, { color: H.colors.good, width: 1.5, dash: [3, 3] });\n }\n H.text(\"x = h + a/k = \" + xs.toFixed(2), 24, 52, { color: H.colors.good, size: 14 });\n} else {\n H.text(\"k = 0: the curve never equals 0 (no solution)\", 24, 52, { color: H.colors.warn, size: 13 });\n}\n// moving probe dot riding the curve to keep it animated and bounded\nconst xp = h + 4.5 * Math.sin(t * 0.7) + (Math.sin(t * 0.7) >= 0 ? 0.4 : -0.4);\nconst yp = a / (xp - h);\nif (Number.isFinite(yp) && yp >= -8 && yp <= 8) v.dot(xp, yp, { r: 5, fill: H.colors.warn });\nH.text(\"a = \" + a.toFixed(1) + \" h = \" + h.toFixed(1) + \" k = \" + k.toFixed(1), 24, 74, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"a/(x-h)\", color: H.colors.accent }, { label: \"y = k\", color: H.colors.accent2 }, { label: \"solution\", color: H.colors.good }], H.W - 160, 28);" + }, + { + "id": "a2-rational-exponents", + "area": "Algebra 2", + "topic": "Rational exponents", + "title": "Rational exponent: y = x^(p/q) = (q√x)^p", + "equation": "x^(p/q) = (qth root of x)^p", + "keywords": [ + "rational exponents", + "fractional exponent", + "x^(p/q)", + "nth root as exponent", + "radical to exponent", + "power to a fraction", + "q-th root", + "exponent fraction", + "x^(1/2) square root", + "convert radical exponent" + ], + "explanation": "A fractional exponent is just a root and a power combined: x^(p/q) means take the q-th root of x, then raise it to the p power. The denominator q is the root (q = 2 gives a square root, q = 3 a cube root), and the numerator p is the ordinary power. Slide p and q and watch the curve bend: bigger p/q grows faster than the dashed y = x line, smaller p/q (a root-heavy exponent) grows slower. The dropping dot reads off (x, x^(p/q)) live.", + "bullets": [ + "x^(p/q) = (q√x)^p: denominator picks the root, numerator picks the power.", + "Exponent > 1 (p > q) bends above y = x; exponent < 1 (p < q) bends below it.", + "x^(1/2) is √x and x^(1/3) is the cube root — roots are just exponents with numerator 1." + ], + "params": [ + { + "name": "p", + "label": "numerator p (power)", + "min": 1, + "max": 6, + "step": 1, + "value": 3 + }, + { + "name": "q", + "label": "denominator q (root)", + "min": 1, + "max": 6, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -0.5, xMax: 8, yMin: -0.5, yMax: 8 });\nv.grid(); v.axes();\nconst p = Math.max(1, Math.round(P.p));\nconst q = Math.max(1, Math.round(P.q));\nconst e = p / q;\nconst f = (x) => (x < 0 ? NaN : Math.pow(x, e));\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(0, 0, 8, 8, { color: H.colors.violet, width: 1.2, dash: [4, 4] });\nconst xs = 0.2 + 3.7 * (0.5 + 0.5 * Math.sin(t * 0.7));\nconst ys = f(xs);\nif (isFinite(ys) && ys < 8) {\n v.dot(xs, ys, { r: 6, fill: H.colors.warn });\n v.line(xs, 0, xs, ys, { color: H.colors.good, width: 1.5, dash: [3, 3] });\n v.line(0, ys, xs, ys, { color: H.colors.accent2, width: 1.5, dash: [3, 3] });\n}\nH.text(\"y = x^(\" + p + \"/\" + q + \") = (\" + q + \"√x)^\" + p, 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"p = \" + p + \" q = \" + q + \" exponent = \" + e.toFixed(3) + \" at x = \" + xs.toFixed(2) + \", y = \" + (isFinite(ys) ? ys.toFixed(3) : \"—\"), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"y = x^(p/q)\", color: H.colors.accent }, { label: \"y = x\", color: H.colors.violet }], H.W - 160, 28);" + }, + { + "id": "a2-rational-function-graphing", + "area": "Algebra 2", + "topic": "Rational function graphing", + "title": "Rational graph: y = a/(x − h) + k", + "equation": "y = a / (x - h) + k", + "keywords": [ + "rational function", + "rational graph", + "asymptote", + "vertical asymptote", + "horizontal asymptote", + "hyperbola", + "1/x", + "reciprocal function", + "a/(x-h)+k", + "graphing rational", + "discontinuity" + ], + "explanation": "Every basic rational graph is the curve 1/x stretched and shifted. h slides the vertical asymptote (where the denominator hits zero and y blows up) left or right, while k slides the whole curve up to set the horizontal asymptote y = k that the branches flatten toward. a stretches the branches and, when negative, flips them into the opposite quadrants. Watch the two dashed asymptote lines move with the sliders and notice the curve can never touch them.", + "bullets": [ + "Vertical asymptote at x = h: the denominator is zero, so y is undefined there.", + "Horizontal asymptote at y = k: the value a/(x−h) shrinks to 0 far out, leaving y → k.", + "Sign of a sets which pair of opposite quadrants (around the asymptote crossing) the branches sit in." + ], + "params": [ + { + "name": "a", + "label": "stretch a", + "min": -6, + "max": 6, + "step": 0.5, + "value": 4 + }, + { + "name": "h", + "label": "vert asymptote h", + "min": -6, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "k", + "label": "horiz asymptote k", + "min": -5, + "max": 5, + "step": 0.5, + "value": -1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst a = P.a, h = P.h, k = P.k;\nv.line(h, -8, h, 8, { color: H.colors.warn, width: 1.5, dash: [5, 5] });\nv.line(-8, k, 8, k, { color: H.colors.violet, width: 1.5, dash: [5, 5] });\nconst f = (x) => { const d = x - h; return Math.abs(d) < 1e-4 ? NaN : a / d + k; };\nv.fn(x => (x < h ? f(x) : NaN), { color: H.colors.accent, width: 3 });\nv.fn(x => (x > h ? f(x) : NaN), { color: H.colors.accent, width: 3 });\nconst xs = h + (2.5 + 1.5 * Math.sin(t * 0.9)) * (Math.cos(t * 0.4) >= 0 ? 1 : -1);\nconst ys = f(xs);\nif (isFinite(ys) && ys > -8 && ys < 8) v.dot(xs, ys, { r: 6, fill: H.colors.warn });\nH.text(\"y = a / (x − h) + k\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"vert asymptote x = \" + h.toFixed(1) + \" horiz asymptote y = \" + k.toFixed(1) + \" a = \" + a.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"x = h (V.A.)\", color: H.colors.warn }, { label: \"y = k (H.A.)\", color: H.colors.violet }], H.W - 160, 28);" + }, + { + "id": "a2-rational-inequalities", + "area": "Algebra 2", + "topic": "Rational inequalities", + "title": "Rational inequality: (x−p)/(x−q) ≥ L", + "equation": "(x - p) / (x - q) >= L", + "keywords": [ + "rational inequality", + "rational inequalities", + "sign chart", + "sign analysis", + "critical values", + "test intervals", + "greater than or equal", + "solve inequality", + "(x-p)/(x-q)", + "number line solution", + "undefined point", + "zero of numerator" + ], + "explanation": "Solving a rational inequality means finding where the curve sits on or above the level line y = L. The two special x-values matter: the numerator's zero at x = p (where the value is 0) and the denominator's zero at x = q (where the function is undefined and can flip sign). Slide p, q, and L and watch the sweeping dot turn green exactly on the x-intervals that satisfy the inequality — those intervals are your solution set, and x = q is always excluded.", + "bullets": [ + "Mark the numerator zero (x = p) and the denominator zero (x = q): the value can only change sign at these.", + "Between consecutive critical values the sign is constant, so test one point per interval.", + "x = q is never part of the solution — the expression is undefined there even when the rest of the interval works." + ], + "params": [ + { + "name": "p", + "label": "numerator zero p", + "min": -6, + "max": 6, + "step": 0.5, + "value": -3 + }, + { + "name": "q", + "label": "undefined at q", + "min": -6, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "L", + "label": "level L", + "min": -3, + "max": 3, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst p = P.p, q = P.q, L = P.L;\nconst lo = Math.min(p, q), hi = Math.max(p, q);\nconst f = (x) => { const d = x - q; return Math.abs(d) < 1e-4 ? NaN : (x - p) / d; };\nv.line(-8, L, 8, L, { color: H.colors.violet, width: 1.5, dash: [5, 5] });\nv.line(q, -6, q, 6, { color: H.colors.warn, width: 1.5, dash: [5, 5] });\nv.fn(x => (x < q ? f(x) : NaN), { color: H.colors.accent, width: 3 });\nv.fn(x => (x > q ? f(x) : NaN), { color: H.colors.accent, width: 3 });\nv.dot(p, 0, { r: 6, fill: H.colors.good });\nconst xs = -7 + ((t * 1.6) % 14);\nconst ys = f(xs);\nconst sat = isFinite(ys) && ys >= L;\nif (isFinite(ys) && ys > -6 && ys < 6) v.dot(xs, ys, { r: 6, fill: sat ? H.colors.good : H.colors.warn });\nH.text(\"(x − p) / (x − q) ≥ L\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"zero at x = \" + p.toFixed(1) + \" undefined at x = \" + q.toFixed(1) + \" sweep \" + (sat ? \"SATISFIES\" : \"fails\"), 24, 52, { color: sat ? H.colors.good : H.colors.sub, size: 13 });\nH.legend([{ label: \"y = L level\", color: H.colors.violet }, { label: \"zero (x=p)\", color: H.colors.good }, { label: \"undefined (x=q)\", color: H.colors.warn }], H.W - 180, 28);" + }, + { + "id": "a2-recursive-formulas", + "area": "Algebra 2", + "topic": "Recursive formulas", + "title": "Recursive formula: a_n = b*a_(n-1) + c", + "equation": "a_n = b*a_(n-1) + c, a_0 given", + "keywords": [ + "recursive formula", + "recursion", + "recurrence relation", + "a_n in terms of a_n-1", + "seed value", + "previous term", + "next term", + "initial term", + "step by step", + "build sequence", + "define by recurrence" + ], + "explanation": "A recursive formula defines each term FROM the one before it: feed a term in, multiply by b, add c, and out comes the next term. The violet seed a_0 is where everything starts; the green arrows show each term being fed into the rule to produce the next, revealed one step at a time. Notice b=1 gives an arithmetic sequence (just adding c) and c=0 gives a geometric one (just multiplying by b).", + "bullets": [ + "You need a seed (a_0) plus the rule to generate every later term.", + "Each arrow applies the same rule: multiply by b, then add c.", + "b=1 makes it arithmetic; c=0 makes it geometric — recursion covers both." + ], + "params": [ + { + "name": "a0", + "label": "seed a_0", + "min": -6, + "max": 8, + "step": 0.5, + "value": 1 + }, + { + "name": "b", + "label": "multiplier b", + "min": -1.5, + "max": 2, + "step": 0.1, + "value": 1.5 + }, + { + "name": "c", + "label": "add each step c", + "min": -5, + "max": 5, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst a0 = P.a0, b = P.b, c = P.c;\nconst N = 9;\nconst seq = [a0];\nfor (let n = 1; n < N; n++) seq.push(b * seq[n - 1] + c);\nlet yLo = 0, yHi = 1;\nfor (let n = 0; n < N; n++) { yLo = Math.min(yLo, seq[n]); yHi = Math.max(yHi, seq[n]); }\nconst pad = (yHi - yLo) * 0.15 + 1;\nconst v = H.plot2d({ xMin: -0.5, xMax: N - 0.5, yMin: yLo - pad, yMax: yHi + pad });\nv.grid(); v.axes();\nconst reveal = Math.min(N - 1, Math.floor((t * 0.7) % (N + 2)));\nfor (let n = 0; n <= reveal; n++) {\n if (n >= 1) v.arrow(n - 1, seq[n - 1], n, seq[n], { color: H.colors.good, width: 1.8, head: 7 });\n v.dot(n, seq[n], { r: 5, fill: n === 0 ? H.colors.violet : H.colors.accent });\n}\nv.circle(reveal, seq[reveal], 8 + 2 * Math.sin(t * 4), { fill: H.colors.warn });\nH.text(\"Recursive: a_n = b*a_(n-1) + c\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst prev = reveal === 0 ? a0 : seq[reveal - 1];\nconst step = reveal === 0 ? (\"seed a_0 = \" + a0.toFixed(1)) : (\"a_\" + reveal + \" = \" + b.toFixed(1) + \"*\" + prev.toFixed(1) + \" + \" + c.toFixed(1) + \" = \" + seq[reveal].toFixed(1));\nH.text(step, 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"seed a_0\", color: H.colors.violet }, { label: \"computed a_n\", color: H.colors.accent }], H.W - 200, 28);" + }, + { + "id": "a2-remainder-theorem", + "area": "Algebra 2", + "topic": "Remainder theorem", + "title": "Remainder theorem: remainder = f(k)", + "equation": "remainder of f(x) / (x - k) = f(k)", + "keywords": [ + "remainder theorem", + "f of k", + "evaluate polynomial", + "remainder", + "divide by x minus k", + "plug in", + "polynomial value", + "f(k)", + "synthetic substitution", + "x - k", + "function value", + "remainder equals f(k)" + ], + "explanation": "The remainder theorem says you don't have to do the whole division to find the remainder of f(x) ÷ (x − k): just evaluate f(k). Slide k and the pink dot rides the curve to the height f(k) — that exact height IS the remainder. Change the coefficients a, b, c and the parabola reshapes, but the dot always sits at f(k), so the dashed line over to the y-axis shows the remainder value directly.", + "bullets": [ + "The remainder of f(x) ÷ (x − k) is simply f(k) — one evaluation, no division.", + "Geometrically, f(k) is the height of the curve directly above x = k.", + "If f(k) = 0 the remainder is zero, so (x − k) divides f exactly." + ], + "params": [ + { + "name": "a", + "label": "coef a (x²)", + "min": -2, + "max": 2, + "step": 0.5, + "value": 1 + }, + { + "name": "b", + "label": "coef b (x)", + "min": -6, + "max": 6, + "step": 0.5, + "value": -1 + }, + { + "name": "c", + "label": "coef c (1)", + "min": -8, + "max": 8, + "step": 0.5, + "value": -2 + }, + { + "name": "k", + "label": "test value k", + "min": -5, + "max": 5, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst a = P.a, b = P.b, c = P.c, k = P.k;\nconst f = x => a * x * x + b * x + c;\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -12, yMax: 12 });\nv.grid(); v.axes();\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(k, -12, k, 12, { color: H.colors.violet, width: 1.5, dash: [5, 5] });\nconst fk = f(k);\nv.dot(k, fk, { r: 7, fill: H.colors.warn });\nv.line(-6, fk, k, fk, { color: H.colors.good, width: 1.5, dash: [4, 4] });\nconst xs = k + 3 * Math.sin(t * 0.8);\nv.dot(xs, f(xs), { r: 6, fill: H.colors.accent2 });\nH.text(\"Remainder Theorem: remainder of f(x)÷(x−k) = f(k)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"k = \" + k.toFixed(1) + \" f(k) = remainder = \" + fk.toFixed(2), 24, 52, { color: H.colors.sub, size: 14 });\nH.legend([{ label: \"f(x)\", color: H.colors.accent }, { label: \"x = k\", color: H.colors.violet }, { label: \"f(k) = remainder\", color: H.colors.warn }], H.W - 200, 28);" + }, + { + "id": "a2-simplifying-rational-expressions", + "area": "Algebra 2", + "topic": "Simplifying rational expressions", + "title": "Cancel a factor → a hole: (x−p)(x−q)/((x−p)(x−r))", + "equation": "(x - p)(x - q) / ((x - p)(x - r)) = (x - q)/(x - r), x ≠ p", + "keywords": [ + "simplifying rational expressions", + "simplify rational expression", + "cancel common factors", + "removable hole", + "hole in graph", + "reduce fraction", + "vertical asymptote", + "domain restriction", + "rational function", + "factor and cancel", + "common factor", + "point of discontinuity" + ], + "explanation": "Simplifying a rational expression means cancelling factors the top and bottom share — but cancelling a factor leaves a HOLE in the graph, not a clean disappearance. Here (x−p) appears on both sides, so it cancels to give (x−q)/(x−r); yet x = p is still forbidden, marked by the open circle. The factor (x−r) that survives in the denominator becomes a vertical asymptote (dashed line) the curve can never touch. Slide p, q, r to watch the hole and the asymptote move independently.", + "bullets": [ + "A factor common to top and bottom cancels — but its x-value stays excluded (a hole).", + "Leftover denominator factors give vertical asymptotes the graph approaches but never reaches.", + "Simplified form has the SAME graph except for the hole — the domain restriction survives." + ], + "params": [ + { + "name": "p", + "label": "hole p", + "min": -4, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "q", + "label": "zero q", + "min": -4, + "max": 4, + "step": 0.5, + "value": -2 + }, + { + "name": "r", + "label": "asymptote r", + "min": -4, + "max": 4, + "step": 0.5, + "value": -3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst p = P.p, q = P.q, r = P.r;\nconst simp = x => (x - q) / (x - r);\nconst holeY = (p - q) / (p - r);\nv.line(r, -6, r, 6, { color: H.colors.warn, width: 1.5, dash: [5, 5] });\nv.fn(x => (Math.abs(x - r) < 0.04 ? NaN : simp(x)), { color: H.colors.accent, width: 3 });\nif (Math.abs(p - r) > 1e-6 && Math.abs(holeY) < 50) {\n v.circle(p, holeY, 6, { stroke: H.colors.violet, width: 2.5 });\n}\nconst xs = r + 3.5 * Math.sin(t * 0.6) + (Math.sin(t * 0.6) >= 0 ? 0.6 : -0.6);\nconst ys = H.clamp(simp(xs), -6, 6);\nif (Number.isFinite(ys)) v.dot(xs, ys, { r: 6, fill: H.colors.warn });\nH.text(\"(x−p)(x−q) / (x−p)(x−r) = (x−q)/(x−r)\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"(x−p) cancels → HOLE at x=\" + p.toFixed(1) + \" asymptote at x=\" + r.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"hole (removed)\", color: H.colors.violet }, { label: \"asymptote x=r\", color: H.colors.warn }], H.W - 190, 28);" + }, + { + "id": "a2-synthetic-division", + "area": "Algebra 2", + "topic": "Synthetic division", + "title": "Synthetic division: (ax² + bx + c) ÷ (x − r)", + "equation": "ax^2 + bx + c = (x - r)*(quotient) + remainder", + "keywords": [ + "synthetic division", + "divide polynomial", + "polynomial division", + "bring down", + "divisor x minus r", + "quotient", + "remainder", + "long division shortcut", + "x - r", + "depressed polynomial", + "coefficients", + "ax^2+bx+c" + ], + "explanation": "Synthetic division is a fast bookkeeping shortcut for dividing a polynomial by (x − r): write the coefficients in a row, bring the first one straight down, multiply it by r and add into the next column, and repeat. Slide r and the coefficients and watch each column update: the first numbers are the quotient's coefficients and the last number is the remainder. The pulsing ring and arrows step through 'bring down, multiply by r, add' so you can see where every number comes from.", + "bullets": [ + "List coefficients; bring the first down, then multiply-by-r-and-add across.", + "The leading results are the quotient; the final number is the remainder.", + "Dividing by (x − r) means you plug in +r (not −r) on the left." + ], + "params": [ + { + "name": "a", + "label": "coef a (x²)", + "min": -4, + "max": 4, + "step": 1, + "value": 1 + }, + { + "name": "b", + "label": "coef b (x)", + "min": -8, + "max": 8, + "step": 1, + "value": -5 + }, + { + "name": "c", + "label": "coef c (1)", + "min": -12, + "max": 12, + "step": 1, + "value": 6 + }, + { + "name": "r", + "label": "divisor r", + "min": -4, + "max": 4, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst a = P.a, b = P.b, c = P.c, r = P.r;\nconst coeffs = [a, b, c];\nconst x0 = 150, dx = 170, rowY = 150, gap = 64;\nH.text(\"Synthetic division: divide ax² + bx + c by (x − r)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"r = \" + r.toFixed(1) + \" coeffs [\" + a.toFixed(1) + \", \" + b.toFixed(1) + \", \" + c.toFixed(1) + \"]\", 24, 52, { color: H.colors.sub, size: 13 });\nH.rect(70, rowY - 28, 36, gap * 2 + 24, { stroke: H.colors.accent2, width: 2, radius: 4 });\nH.text(r.toFixed(1), 76, rowY + gap, { color: H.colors.accent2, size: 15, weight: 700 });\nconst step = Math.floor((t % 6) / 2);\nconst out = [];\nfor (let i = 0; i < coeffs.length; i++) {\n const cx = x0 + i * dx;\n const mul = i === 0 ? 0 : out[i - 1] * r;\n out.push(coeffs[i] + mul);\n const active = i <= step;\n H.text(coeffs[i].toFixed(1), cx, rowY, { color: H.colors.ink, size: 16, weight: 600 });\n if (i > 0) {\n H.text((mul >= 0 ? \"+\" : \"\") + mul.toFixed(1), cx, rowY + gap, { color: active ? H.colors.accent : H.colors.grid, size: 15 });\n H.arrow(cx - dx + 16, rowY + 16, cx - 10, rowY + gap - 10, { color: active ? H.colors.violet : H.colors.grid, width: 2 });\n }\n H.text(out[i].toFixed(1), cx, rowY + 2 * gap, { color: active ? H.colors.good : H.colors.grid, size: 18, weight: 700 });\n}\nH.line(x0 - 34, rowY + gap + 22, x0 + (coeffs.length - 1) * dx + 36, rowY + gap + 22, { color: H.colors.axis, width: 1.5 });\nH.text(\"quotient: \" + out[0].toFixed(1) + \" x + \" + out[1].toFixed(1) + \" remainder: \" + out[2].toFixed(1), 24, h - 38, { color: H.colors.good, size: 15, weight: 600 });\nconst pulse = x0 + step * dx;\nH.circle(pulse, rowY + 2 * gap - 6, 18 + 4 * Math.sin(t * 4), { stroke: H.colors.warn, width: 2 });" + }, + { + "id": "pc-advanced-domain-and-range", + "area": "Precalculus", + "topic": "Advanced domain and range", + "title": "Domain & range: y = sqrt(x - k) + c", + "equation": "y = sqrt(x - k) + c", + "keywords": [ + "domain", + "range", + "advanced domain and range", + "square root function", + "sqrt", + "domain restriction", + "range floor", + "valid inputs", + "valid outputs", + "interval notation", + "x >= k", + "y >= c" + ], + "explanation": "A square root only accepts inputs that keep the inside non-negative, so the graph starts abruptly at the vertical dashed line x = k - that boundary IS the left edge of the domain. From there the curve only rises, so its lowest output is c, making the horizontal dashed line y = c the floor of the range. Slide k to drag the whole domain left or right, and slide c to lift the range floor up or down; the moving dot only ever lives in the allowed region.", + "bullets": [ + "Domain is every x that keeps x - k >= 0, i.e. x >= k (the vertical edge).", + "Range is every reachable y; here the curve bottoms out at c, so y >= c.", + "The corner point (k, c) marks both the domain edge and the range floor." + ], + "params": [ + { + "name": "k", + "label": "domain edge k", + "min": -5, + "max": 5, + "step": 0.5, + "value": -2 + }, + { + "name": "c", + "label": "range floor c", + "min": -1, + "max": 6, + "step": 0.5, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -2, yMax: 10 });\nv.grid(); v.axes();\nconst k = P.k, c = P.c;\nconst inside = (x) => x - k;\nconst f = (x) => { const u = inside(x); return u >= 0 ? Math.sqrt(u) + c : NaN; };\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst xLo = k;\nv.line(xLo, -2, xLo, 10, { color: H.colors.violet, width: 1.5, dash: [5, 5] });\nv.dot(xLo, c, { r: 6, fill: H.colors.warn });\nv.line(-8, c, 8, c, { color: H.colors.good, width: 1.5, dash: [4, 4] });\nconst xs = k + 6 * (0.5 + 0.5 * Math.sin(t * 0.7));\nv.dot(xs, f(xs), { r: 6, fill: H.colors.accent2 });\nH.text(\"y = sqrt(x - k) + c\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"domain: x >= \" + k.toFixed(1) + \" range: y >= \" + c.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"x = k (domain edge)\", color: H.colors.violet }, { label: \"y = c (range floor)\", color: H.colors.good }], H.W - 220, 28);" + }, + { + "id": "pc-advanced-function-transformations", + "area": "Precalculus", + "topic": "Advanced function transformations", + "title": "Transform: y = a*f(b(x - h)) + k", + "equation": "y = a * f(b(x - h)) + k", + "keywords": [ + "function transformations", + "advanced function transformations", + "transform", + "horizontal stretch", + "vertical stretch", + "reflection", + "shift", + "translate", + "compress", + "parent function", + "a f(b(x-h))+k", + "scaling" + ], + "explanation": "Every transformed graph is the same parent f(x) = |x| run through four moves at once. Outside the function, a stretches it vertically (and flips it if negative) and k slides it up by k. Inside, h slides it RIGHT by h and b scales horizontally - but bigger b actually SQUEEZES the graph because it speeds up the input. Compare the faint dashed parent V to the bold transformed one, and watch the vertex (h, k) track your sliders.", + "bullets": [ + "Outer a, k act vertically: a stretches/reflects, k shifts up.", + "Inner b, h act horizontally and 'backwards': x - h moves RIGHT, larger b squeezes.", + "The vertex lands at (h, k) no matter how a and b are set." + ], + "params": [ + { + "name": "a", + "label": "vert stretch a", + "min": -3, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "b", + "label": "horiz squeeze b", + "min": 0.2, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "h", + "label": "shift right h", + "min": -4, + "max": 4, + "step": 0.5, + "value": 0 + }, + { + "name": "k", + "label": "shift up k", + "min": -4, + "max": 6, + "step": 0.5, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 10 });\nv.grid(); v.axes();\nconst a = P.a, b = Math.abs(P.b) < 0.1 ? 0.1 : P.b, h = P.h, k = P.k;\nconst parent = (x) => Math.abs(x);\nconst g = (x) => a * parent(b * (x - h)) + k;\nv.fn(parent, { color: H.colors.sub, width: 1.5, dash: [4, 4] });\nv.fn(g, { color: H.colors.accent, width: 3 });\nv.dot(h, k, { r: 7, fill: H.colors.warn });\nv.line(h, -6, h, 10, { color: H.colors.violet, width: 1, dash: [3, 5] });\nconst xs = h + 5 * Math.sin(t * 0.7);\nv.dot(xs, g(xs), { r: 6, fill: H.colors.accent2 });\nH.text(\"y = a * f(b(x - h)) + k\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a=\" + a.toFixed(1) + \" b=\" + b.toFixed(1) + \" h=\" + h.toFixed(1) + \" k=\" + k.toFixed(1) + \" vertex (\" + h.toFixed(1) + \", \" + k.toFixed(1) + \")\", 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"parent f(x)=|x|\", color: H.colors.sub }, { label: \"transformed\", color: H.colors.accent }], H.W - 210, 28);" + }, + { + "id": "pc-algebraic-limits", + "area": "Precalculus", + "topic": "Algebraic limits", + "title": "Algebraic limit: lim (x->a) (x^2 - a^2)/(x - a)", + "equation": "lim (x -> a) (x^2 - a^2)/(x - a) = x + a = 2a", + "keywords": [ + "algebraic limit", + "evaluate limit", + "factor and cancel", + "indeterminate form", + "zero over zero", + "0/0", + "removable discontinuity", + "limit by factoring", + "simplify limit", + "direct substitution", + "difference of squares limit" + ], + "explanation": "Plugging x = a into (x^2 - a^2)/(x - a) gives 0/0 — undefined, but NOT a dead end. Factor the top as (x-a)(x+a) and cancel the (x-a) to get x + a, which is defined everywhere; the limit is just its value 2a. The dashed line is that cancelled form x + a, and the curve sits exactly on it except for the single hole at x = a. Slide a and watch both the hole and the limit value 2a move together as the probe approaches.", + "bullets": [ + "0/0 is an indeterminate form: it means simplify, not that the limit fails.", + "Factoring exposes a common (x - a) factor that cancels the trouble.", + "After cancelling, direct substitution gives the limit: here lim = a + a = 2a." + ], + "params": [ + { + "name": "a", + "label": "point a", + "min": 1, + "max": 4, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst a = P.a;\nconst v = H.plot2d({ xMin: -2, xMax: 6, yMin: -2, yMax: 12 });\nv.grid(); v.axes();\n// f(x) = (x^2 - a^2)/(x - a) = x + a for x != a. Hole at x=a, height 2a.\nconst L = 2 * a;\nfunction f(x){ const den = x - a; return Math.abs(den) < 1e-6 ? NaN : (x * x - a * a) / den; }\nv.fn(x => f(x), { color: H.colors.accent, width: 3 });\n// The simplified line x + a (faint) — shows WHY the limit is 2a.\nv.fn(x => x + a, { color: H.colors.sub, width: 1.5, dash: [6, 6] });\nv.line(a, -2, a, 12, { color: H.colors.violet, width: 1, dash: [4, 4] });\nv.line(-2, L, 6, L, { color: H.colors.violet, width: 1, dash: [4, 4] });\nv.circle(a, L, 6, { stroke: H.colors.warn, width: 2.5, fill: H.colors.bg });\n// Probe sliding toward x=a; the y-value never reaches but approaches 2a.\nconst d = 1.6 * (0.5 + 0.5 * Math.cos(t * 1.1));\nconst xp = a + (Math.sin(t * 0.7) >= 0 ? d : -d);\nconst yp = f(xp);\nif (Number.isFinite(yp)){\n v.dot(xp, yp, { r: 6, fill: H.colors.good });\n v.line(xp, yp, a, L, { color: H.colors.good, width: 1, dash: [3, 3] });\n}\nH.text(\"Algebraic limit: lim (x->a) (x^2 - a^2)/(x - a)\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"factor & cancel: (x-a)(x+a)/(x-a) = x + a -> limit = \" + L.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = \" + a.toFixed(1) + \" x = \" + xp.toFixed(3) + \" f(x) = \" + (Number.isFinite(yp) ? yp.toFixed(3) : \"0/0\"), 24, 74, { color: H.colors.good, size: 12 });\nH.legend([{ label: \"(x^2-a^2)/(x-a)\", color: H.colors.accent }, { label: \"x + a (cancelled)\", color: H.colors.sub }], H.W - 200, 28);" + }, + { + "id": "pc-ambiguous-ssa-case", + "area": "Precalculus", + "topic": "Ambiguous SSA case", + "title": "Ambiguous SSA case: 0, 1, or 2 triangles", + "equation": "h = b * sin(A); triangles: 0 if a=b, 2 if h=b: one.", + "The opposite side 'swings' like a hinge, which is why two closures can exist." + ], + "params": [ + { + "name": "A", + "label": "angle A (deg)", + "min": 15, + "max": 75, + "step": 1, + "value": 35 + }, + { + "name": "b", + "label": "side b (adjacent)", + "min": 3, + "max": 8, + "step": 0.5, + "value": 6 + }, + { + "name": "a", + "label": "side a (opposite)", + "min": 1, + "max": 8, + "step": 0.25, + "value": 4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -1, yMax: 8 });\nv.grid(); v.axes();\nconst A = Math.max(5, Math.min(170, P.A)) * Math.PI / 180;\nconst b = Math.max(1, P.b);\nconst a = Math.max(0.2, P.a);\nconst h = b * Math.sin(A);\nconst Av = [0, 0];\nconst Cv = [b * Math.cos(A), b * Math.sin(A)];\nv.line(0, 0, 11, 0, { color: H.colors.axis, width: 2 });\nv.line(Av[0], Av[1], Cv[0], Cv[1], { color: H.colors.accent, width: 3 });\nv.dot(Cv[0], Cv[1], { r: 5, fill: H.colors.warn });\nv.text(\"C\", Cv[0] + 0.1, Cv[1] + 0.4, { color: H.colors.ink, size: 14 });\nv.text(\"A\", Av[0] - 0.4, -0.4, { color: H.colors.ink, size: 14 });\nv.text(\"b\", (Cv[0]) / 2 - 0.3, Cv[1] / 2 + 0.2, { color: H.colors.accent, size: 13 });\nv.line(Cv[0], 0, Cv[0], Cv[1], { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.text(\"h=\" + h.toFixed(2), Cv[0] + 0.15, Cv[1] / 2, { color: H.colors.violet, size: 12 });\nlet nsol = 0;\nconst xs = [];\nif (a >= b) { xs.push(Cv[0] + Math.sqrt(Math.max(0, a * a - h * h))); nsol = 1; }\nelse if (Math.abs(a - h) < 1e-6) { xs.push(Cv[0]); nsol = 1; }\nelse if (a > h) { const d = Math.sqrt(a * a - h * h); xs.push(Cv[0] + d); xs.push(Cv[0] - d); nsol = 2; }\nfor (let i = 0; i < xs.length; i++) {\n if (xs[i] >= 0) { v.line(Cv[0], Cv[1], xs[i], 0, { color: H.colors.good, width: 2 }); v.dot(xs[i], 0, { r: 5, fill: H.colors.good }); }\n}\nconst pts = [];\nfor (let i = 0; i <= 40; i++) { const th = Math.PI * i / 40; pts.push([Cv[0] + a * Math.cos(th), Cv[1] + a * Math.sin(th)]); }\nv.path(pts.map(p => [p[0], Math.max(0, p[1])]), { color: H.colors.accent2, width: 1.5, dash: [3, 3] });\nconst swp = Cv[0] + a * Math.cos(Math.PI * (0.5 + 0.5 * Math.sin(t)));\nv.dot(swp, Math.max(0, Cv[1] + a * Math.sin(Math.PI * (0.5 + 0.5 * Math.sin(t)))), { r: 5, fill: H.colors.warn });\nH.text(\"Ambiguous case (SSA): given A, b, a\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nconst verdict = nsol === 0 ? \"no triangle (a < h)\" : nsol === 2 ? \"TWO triangles (h < a < b)\" : a >= b ? \"one triangle (a >= b)\" : \"one right triangle (a = h)\";\nH.text(\"h = b·sin A = \" + h.toFixed(2) + \" a = \" + a.toFixed(2) + \" -> \" + verdict, 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"side b\", color: H.colors.accent }, { label: \"swing of a\", color: H.colors.accent2 }, { label: \"valid triangles\", color: H.colors.good }], H.W - 175, 28);" + }, + { + "id": "pc-amplitude-and-period", + "area": "Precalculus", + "topic": "Amplitude and period", + "title": "Amplitude & period: y = A·sin(B·x)", + "equation": "y = A * sin(B*x), period = 2*pi / B", + "keywords": [ + "amplitude", + "period", + "amplitude and period", + "sine amplitude", + "2pi/b", + "frequency", + "peak to trough", + "sinusoid", + "wave height", + "stretch sine", + "trig graph", + "midline" + ], + "explanation": "Two knobs control the size and pacing of a wave. The amplitude A is how far the curve reaches above and below the midline — the dashed green lines mark the peaks at +A and -A. The frequency B controls the period, the horizontal length of one full cycle, which is exactly 2pi/B: increase B and the waves bunch up (shorter period). The purple bracket measures one complete period so you can see it shrink and grow.", + "bullets": [ + "Amplitude A = half the peak-to-trough height; it stretches the wave vertically.", + "Period = 2pi / B — bigger B packs more cycles into the same width.", + "A changes how TALL the wave is; B changes how OFTEN it repeats — independent controls." + ], + "params": [ + { + "name": "A", + "label": "amplitude A", + "min": 0.5, + "max": 5, + "step": 0.1, + "value": 3 + }, + { + "name": "B", + "label": "frequency B", + "min": 0.5, + "max": 4, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -Math.PI, xMax: 3 * Math.PI, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst A = P.A, B = Math.max(0.1, P.B);\nconst per = 2 * Math.PI / B;\nv.line(-Math.PI, A, 3 * Math.PI, A, { color: H.colors.good, width: 1.4, dash: [5, 5] });\nv.line(-Math.PI, -A, 3 * Math.PI, -A, { color: H.colors.good, width: 1.4, dash: [5, 5] });\nv.fn(x => A * Math.sin(B * x), { color: H.colors.accent, width: 3 });\nconst x0 = 0, x1 = per;\nif (x1 <= 3 * Math.PI) {\n v.line(x0, -5.4, x1, -5.4, { color: H.colors.violet, width: 2 });\n v.line(x0, -5.7, x0, -5.1, { color: H.colors.violet, width: 2 });\n v.line(x1, -5.7, x1, -5.1, { color: H.colors.violet, width: 2 });\n v.text(\"one period\", (x0 + x1) / 2, -4.7, { color: H.colors.violet, size: 12, align: \"center\" });\n}\nconst xs = -Math.PI + ((t * 0.8) % (4 * Math.PI));\nv.dot(xs, A * Math.sin(B * xs), { r: 6, fill: H.colors.warn });\nH.text(\"y = A · sin(B·x)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"amplitude A = \" + A.toFixed(2) + \" period = 2pi/B = \" + per.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"wave\", color: H.colors.accent }, { label: \"peaks ±A\", color: H.colors.good }], H.W - 160, 28);" + }, + { + "id": "pc-angle-between-vectors-projection", + "area": "Precalculus", + "topic": "Angle between vectors and projections", + "title": "Angle & projection: cos(theta) = (a . b)/(|a||b|)", + "equation": "cos(theta) = (a . b) / (|a| * |b|); proj_b a = ((a . b)/|b|^2) * b", + "keywords": [ + "angle between vectors", + "dot product", + "projection", + "vector projection", + "scalar projection", + "a dot b", + "cosine of angle", + "orthogonal", + "component", + "proj", + "vectors", + "perpendicular" + ], + "explanation": "The dot product packs the angle between two vectors into one number: a . b = |a||b|cos(theta), so dividing by the two lengths recovers cos(theta) directly. Drag the components of a and b to change their directions, and watch the projection of a onto b (green) — it is the shadow a casts along b, and the dashed line from a's tip to that shadow is always perpendicular to b. When the vectors point the same way the dot product (and projection) are largest; at 90 degrees they vanish.", + "bullets": [ + "a . b = |a||b|cos(theta): the dot product is positive for acute, zero for right, negative for obtuse angles.", + "The projection of a onto b is a's shadow along b; the leftover piece is perpendicular to b.", + "Length of proj_b a = (a . b)/|b|; it shrinks to 0 exactly when the vectors are orthogonal." + ], + "params": [ + { + "name": "ax", + "label": "a x-component", + "min": -6, + "max": 6, + "step": 0.5, + "value": 3 + }, + { + "name": "ay", + "label": "a y-component", + "min": -5, + "max": 5, + "step": 0.5, + "value": 4 + }, + { + "name": "bx", + "label": "b x-component", + "min": -6, + "max": 6, + "step": 0.5, + "value": 5 + }, + { + "name": "by", + "label": "b y-component", + "min": -5, + "max": 5, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst ax = P.ax, ay = P.ay, bx = P.bx, by = P.by;\n// vector a sweeps slowly so the angle is alive, b is fixed by sliders\nconst phase = 0.5 * Math.sin(t * 0.6);\nconst ca = Math.cos(phase), sa = Math.sin(phase);\nconst a2x = ax * ca - ay * sa, a2y = ax * sa + ay * ca;\nv.arrow(0, 0, a2x, a2y, { color: H.colors.accent, width: 3 });\nv.arrow(0, 0, bx, by, { color: H.colors.accent2, width: 3 });\nconst dot = a2x * bx + a2y * by;\nconst ma = Math.hypot(a2x, a2y), mb = Math.hypot(bx, by);\nconst denom = Math.max(1e-6, ma * mb);\nconst cosang = Math.max(-1, Math.min(1, dot / denom));\nconst ang = Math.acos(cosang);\n// projection of a onto b: scalar (dot)/(|b|^2) * b\nconst k = dot / Math.max(1e-6, mb * mb);\nconst px = k * bx, py = k * by;\nv.line(a2x, a2y, px, py, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.arrow(0, 0, px, py, { color: H.colors.good, width: 3 });\nv.dot(px, py, { r: 5, fill: H.colors.warn });\nH.text(\"Angle between vectors and projection\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"cos(theta) = (a . b)/(|a||b|) = \" + cosang.toFixed(2) + \" theta = \" + (ang * 180 / Math.PI).toFixed(1) + \" deg\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a.b = \" + dot.toFixed(2) + \" proj_b a length = \" + (k * mb).toFixed(2), 24, 72, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"a\", color: H.colors.accent }, { label: \"b\", color: H.colors.accent2 }, { label: \"proj of a on b\", color: H.colors.good }], H.W - 200, 28);" + }, + { + "id": "pc-arc-length", + "area": "Precalculus", + "topic": "Arc length", + "title": "Arc length: s = r · θ", + "equation": "s = r * theta (theta in radians)", + "keywords": [ + "arc length", + "s = r theta", + "length of an arc", + "circular arc", + "radius times angle", + "arc formula", + "central angle", + "radians arc", + "circle arc length", + "rtheta" + ], + "explanation": "An arc is the curved piece of a circle's edge cut off by a central angle. The 'radius' slider sets how big the circle is, and the 'angle' slider sets how much of the way around the arc reaches. The formula s = r·θ says the arc length is just the radius scaled by the angle — but only when θ is in RADIANS, because a radian is defined so that one radian on radius 1 traces exactly one unit of arc.", + "bullets": [ + "s = r · θ: double the radius OR double the angle, and the arc doubles.", + "θ must be in radians — that's the whole reason radians exist.", + "When θ = 2π (a full turn), s = 2πr, which is just the circumference." + ], + "params": [ + { + "name": "r", + "label": "radius r", + "min": 0.5, + "max": 3, + "step": 0.1, + "value": 2 + }, + { + "name": "deg", + "label": "central angle (degrees)", + "min": 10, + "max": 350, + "step": 1, + "value": 120 + } + ], + "code": "H.background();\nconst w = H.W, hh = H.H;\nconst cx = w * 0.42, cy = hh * 0.55;\nconst r = Math.max(0.2, P.r);\nconst maxAng = Math.max(0.1, P.deg) * Math.PI / 180;\nconst Rpix = Math.min(w, hh) * 0.10 * r;\nconst sweep = (Math.sin(t * 0.5 - Math.PI / 2) + 1) / 2;\nconst ang = maxAng * sweep;\nH.circle(cx, cy, Rpix, { stroke: H.colors.grid, width: 1.5 });\nH.line(cx - Rpix - 14, cy, cx + Rpix + 14, cy, { color: H.colors.axis, width: 1 });\nH.line(cx, cy - Rpix - 14, cx, cy + Rpix + 14, { color: H.colors.axis, width: 1 });\nconst arc = [];\nconst steps = 80;\nfor (let i = 0; i <= steps; i++) {\n const aa = ang * (i / steps);\n arc.push([cx + Rpix * Math.cos(aa), cy - Rpix * Math.sin(aa)]);\n}\nif (arc.length >= 2) H.path(arc, { color: H.colors.accent2, width: 5 });\nconst px = cx + Rpix * Math.cos(ang), py = cy - Rpix * Math.sin(ang);\nH.line(cx, cy, cx + Rpix, cy, { color: H.colors.sub, width: 2 });\nH.line(cx, cy, px, py, { color: H.colors.accent, width: 2.5 });\nH.circle(px, py, 6, { fill: H.colors.warn });\nconst s = r * ang;\nH.text(\"Arc length: s = r · θ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"r = \" + r.toFixed(1) + \" θ = \" + ang.toFixed(2) + \" rad → s = \" + s.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"(θ MUST be in radians)\", 24, 74, { color: H.colors.warn, size: 12 });\nH.text(\"s = \" + s.toFixed(2), cx + Rpix * 0.7 * Math.cos(ang / 2) + 6, cy - Rpix * 0.7 * Math.sin(ang / 2), { color: H.colors.accent2, size: 13, weight: 700 });\nH.legend([{ label: \"radius r\", color: H.colors.accent }, { label: \"arc s = rθ\", color: H.colors.accent2 }], H.W - 170, 28);" + }, + { + "id": "pc-arithmetic-geometric-sequences", + "area": "Precalculus", + "topic": "Arithmetic and geometric sequences", + "title": "Sequences: a_n = a1 + (n−1)d vs a1·r^(n−1)", + "equation": "a_n = a1 + (n-1)*d OR a_n = a1 * r^(n-1)", + "keywords": [ + "arithmetic sequence", + "geometric sequence", + "common difference", + "common ratio", + "nth term", + "a_n", + "sequences", + "recursive", + "explicit formula", + "term", + "progression" + ], + "explanation": "An arithmetic sequence ADDS the same common difference d each step, so its terms march along a straight line; a geometric sequence MULTIPLIES by the same ratio r, so its terms curve like an exponential. Flip the mode slider to switch between the two rules using the same a1, and the dots rearrange from a line into a curve. The pulsing dot walks through the terms while the readout prints the current a_n so you can connect the formula to the picture.", + "bullets": [ + "Arithmetic: each term ADDS d → equally spaced, straight-line growth.", + "Geometric: each term MULTIPLIES by r → curved, exponential growth/decay.", + "Both have a closed nth-term formula, so you can jump straight to any term." + ], + "params": [ + { + "name": "a1", + "label": "first term a1", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 1 + }, + { + "name": "d", + "label": "difference d", + "min": -2, + "max": 3, + "step": 0.5, + "value": 1 + }, + { + "name": "r", + "label": "ratio r", + "min": 0.5, + "max": 1.6, + "step": 0.05, + "value": 1.3 + }, + { + "name": "mode", + "label": "0=arith 1=geom", + "min": 0, + "max": 1, + "step": 1, + "value": 0 + } + ], + "code": "H.background();\nconst a1 = P.a1, d = P.d, r = P.r, mode = P.mode;\nconst geometric = mode >= 0.5;\nconst N = 10;\nconst term = (n) => geometric ? a1 * Math.pow(r, n - 1) : a1 + (n - 1) * d;\n// Fit y-range to the ACTUAL terms so drawn dots equal the printed values\n// (no clamp mismatch between the readout and the plotted point).\nlet lo = Infinity, hi = -Infinity;\nfor (let n = 1; n <= N; n++) {\n const y = term(n);\n if (Number.isFinite(y)) { if (y < lo) lo = y; if (y > hi) hi = y; }\n}\nif (!Number.isFinite(lo)) { lo = 0; hi = 1; }\nif (hi - lo < 1) { hi = lo + 1; }\nconst padY = (hi - lo) * 0.12 + 0.5;\nconst v = H.plot2d({ xMin: 0, xMax: 11, yMin: lo - padY, yMax: hi + padY });\nv.grid(); v.axes();\nconst pts = [];\nfor (let n = 1; n <= N; n++) {\n let y = term(n);\n if (!Number.isFinite(y)) y = 0;\n pts.push([n, y]);\n}\nv.path(pts, { color: H.colors.accent, width: 2, dash: [5, 5] });\nfor (let n = 1; n <= N; n++) {\n v.dot(pts[n - 1][0], pts[n - 1][1], { r: 5, fill: H.colors.accent });\n}\nconst k = 1 + Math.floor((t * 1.2) % N);\nv.dot(pts[k - 1][0], pts[k - 1][1], { r: 8 + 2 * Math.sin(t * 4), fill: H.colors.warn });\nconst curVal = pts[k - 1][1];\nv.line(k, lo - padY, k, pts[k - 1][1], { color: H.colors.violet, width: 1.5, dash: [3, 3] });\nH.text(geometric ? \"Geometric: a_n = a1 · r^(n−1)\" : \"Arithmetic: a_n = a1 + (n−1)·d\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text((geometric ? \"a1 = \" + a1.toFixed(1) + \" ratio r = \" + r.toFixed(2) : \"a1 = \" + a1.toFixed(1) + \" difference d = \" + d.toFixed(2)) + \" a_\" + k + \" = \" + curVal.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(geometric ? \"each term MULTIPLIES by r\" : \"each term ADDS d\", 24, H.H - 16, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"term a_n\", color: H.colors.accent }, { label: \"current term\", color: H.colors.warn }], H.W - 170, 28);" + }, + { + "id": "pc-basic-trig-equations", + "area": "Precalculus", + "topic": "Basic trig equations", + "title": "Basic trig equation: sin(x) = k", + "equation": "sin(x) = k", + "keywords": [ + "trig equation", + "basic trig equation", + "solve sin x = k", + "sin(x)=k", + "trigonometric equation", + "solve for x", + "asin", + "arcsine", + "two solutions", + "intersection", + "solve trig" + ], + "explanation": "Solving sin(x) = k means finding every angle whose sine equals the target value k. Slide k to move the dashed horizontal line up or down: each place it crosses the blue sine curve is a solution. On one period [0, 2π) there are two such crossings (marked green) — one from asin(k) and its mirror π − asin(k) — and the pink dot sweeping the curve shows exactly when sin(x) hits the line.", + "bullets": [ + "A solution is any x where the sine curve meets the horizontal line y = k.", + "On [0, 2π) there are usually TWO solutions: asin(k) and π − asin(k).", + "Sine repeats every 2π, so add multiples of 2π for all solutions." + ], + "params": [ + { + "name": "k", + "label": "target value k", + "min": -1, + "max": 1, + "step": 0.05, + "value": 0.5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 2 * Math.PI, yMin: -1.6, yMax: 1.6 });\nv.grid(); v.axes();\nconst k = Math.max(-1, Math.min(1, P.k)); // target value, clamp to [-1,1]\n// y = sin(x) curve\nv.fn(x => Math.sin(x), { color: H.colors.accent, width: 3 });\n// horizontal target line y = k\nv.line(0, k, 2 * Math.PI, k, { color: H.colors.accent2, width: 2, dash: [6, 5] });\n// the two principal solutions of sin(x) = k on [0, 2π)\nconst base = Math.asin(k); // in [-π/2, π/2]\nlet s1 = (base + 2 * Math.PI) % (2 * Math.PI);\nlet s2 = (Math.PI - base + 2 * Math.PI) % (2 * Math.PI);\nv.dot(s1, k, { r: 6, fill: H.colors.good });\nv.dot(s2, k, { r: 6, fill: H.colors.good });\n// a dot sweeping the sine curve so you see when it crosses the line\nconst xs = (t * 0.9) % (2 * Math.PI);\nv.dot(xs, Math.sin(xs), { r: 6, fill: H.colors.warn });\nH.text(\"Solve sin(x) = k\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"k = \" + k.toFixed(2) + \" solutions x ≈ \" + s1.toFixed(2) + \", \" + s2.toFixed(2) + \" rad\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"y = sin x\", color: H.colors.accent }, { label: \"y = k\", color: H.colors.accent2 }, { label: \"solutions\", color: H.colors.good }], H.W - 160, 28);" + }, + { + "id": "pc-binomial-theorem", + "area": "Precalculus", + "topic": "Binomial theorem", + "title": "Binomial theorem: (x+y)^n = sum C(n,k) x^(n-k) y^k", + "equation": "(x + y)^n = sum_{k=0..n} C(n,k) * x^(n-k) * y^k", + "keywords": [ + "binomial theorem", + "binomial expansion", + "pascal triangle", + "combinations", + "n choose k", + "c(n,k)", + "(x+y)^n", + "binomial coefficient", + "expand binomial", + "power of a binomial", + "binomial series" + ], + "explanation": "Expanding (x+y)^n produces n+1 terms, and term k is C(n,k)*x^(n-k)*y^k. The n slider sets the power, and x and y set the two values being raised; each bar's height shows how big that term is, so you SEE which terms dominate. The sweep walks through the terms one at a time and the running sum at the bottom climbs to the full value (x+y)^n, showing the expansion really does reconstruct the original.", + "bullets": [ + "There are n+1 terms; term k has coefficient C(n,k) = n!/(k!(n-k)!).", + "Powers of x count down (n,n-1,..,0) while powers of y count up (0,1,..,n).", + "The binomial coefficients are exactly row n of Pascal's triangle." + ], + "params": [ + { + "name": "n", + "label": "power n", + "min": 1, + "max": 6, + "step": 1, + "value": 4 + }, + { + "name": "x", + "label": "x value", + "min": 0.5, + "max": 3, + "step": 0.1, + "value": 1.5 + }, + { + "name": "y", + "label": "y value", + "min": 0.5, + "max": 3, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst n = Math.max(1, Math.round(P.n));\nconst xv = P.x, yv = P.y;\nconst w = H.W, hgt = H.H;\nfunction fact(k){ let r = 1; for (let i = 2; i <= k; i++) r *= i; return r; }\nfunction comb(nn, kk){ return fact(nn) / (fact(kk) * fact(nn - kk)); }\nH.text(\"Binomial theorem: (x + y)^n = sum C(n,k) x^(n-k) y^k\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"n = \" + n + \" x = \" + xv.toFixed(1) + \" y = \" + yv.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\n// Sweep which term is highlighted with t (loops through the n+1 terms).\nconst active = Math.floor(t * 0.8) % (n + 1);\nconst x0 = 60, baseY = hgt - 70;\nconst colW = Math.min(120, (w - 120) / (n + 1));\nlet total = 0, partial = 0;\nfor (let k = 0; k <= n; k++){\n const c = comb(n, k);\n const term = c * Math.pow(xv, n - k) * Math.pow(yv, k);\n total += term;\n}\nfor (let k = 0; k <= n; k++){\n const c = comb(n, k);\n const term = c * Math.pow(xv, n - k) * Math.pow(yv, k);\n const frac = total !== 0 ? Math.abs(term) / Math.abs(total) : 0;\n const barH = 20 + 140 * Math.min(1, frac);\n const cx = x0 + k * colW;\n const on = k === active;\n if (k <= active) partial += term;\n H.rect(cx, baseY - barH, colW - 12, barH, { fill: on ? H.colors.accent : H.colors.panel, stroke: H.colors.grid, width: 1.5, radius: 4 });\n H.text(\"C(\" + n + \",\" + k + \")=\" + c, cx, baseY - barH - 18, { color: on ? H.colors.ink : H.colors.sub, size: 12, weight: on ? 700 : 500 });\n H.text(\"x^\" + (n - k) + \" y^\" + k, cx, baseY + 18, { color: H.colors.sub, size: 11 });\n H.text(term.toFixed(1), cx, baseY - barH + 14, { color: H.colors.ink, size: 11 });\n}\nH.line(x0 - 6, baseY, x0 + (n + 1) * colW, baseY, { color: H.colors.axis, width: 1.5 });\nH.text(\"running sum of terms 0..\" + active + \" = \" + partial.toFixed(2) + \" (full = \" + total.toFixed(2) + \")\", 24, hgt - 24, { color: H.colors.good, size: 13 });" + }, + { + "id": "pc-complex-mult-div-polar", + "area": "Precalculus", + "topic": "Complex multiplication/division in polar form", + "title": "Multiply in polar form: moduli multiply, angles add", + "equation": "z1*z2 = (r1*r2)*(cos(theta1+theta2) + i*sin(theta1+theta2))", + "keywords": [ + "complex multiplication", + "complex division", + "polar multiplication", + "moduli multiply", + "angles add", + "multiply complex polar", + "divide complex polar", + "product of complex numbers", + "argument adds", + "modulus multiplies", + "de moivre", + "rotate and scale" + ], + "explanation": "Multiplying complex numbers is just a stretch-and-spin. In polar form the rule is dead simple: the moduli MULTIPLY (r1·r2) and the arguments ADD (θ1 + θ2) — so z2 acts on z1 by scaling it by r2 and rotating it by θ2. Slide the two moduli and angles and watch the red product arrow grow and swing to its new angle; the violet dot animates the rotation from z1 toward the product. (Division flips this: divide the moduli, subtract the angles.)", + "bullets": [ + "z1·z2: multiply the moduli (r1·r2), add the arguments (θ1 + θ2).", + "z1/z2: divide the moduli (r1/r2), subtract the arguments (θ1 − θ2).", + "Multiplying by a unit-length number (r = 1) is a pure rotation." + ], + "params": [ + { + "name": "r1", + "label": "modulus r1", + "min": 0.5, + "max": 2.5, + "step": 0.1, + "value": 2 + }, + { + "name": "d1", + "label": "angle θ1 (deg)", + "min": 0, + "max": 180, + "step": 1, + "value": 30 + }, + { + "name": "r2", + "label": "modulus r2", + "min": 0.5, + "max": 2.5, + "step": 0.1, + "value": 2 + }, + { + "name": "d2", + "label": "angle θ2 (deg)", + "min": 0, + "max": 180, + "step": 1, + "value": 45 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst r1 = P.r1, d1 = P.d1, r2 = P.r2, d2 = P.d2;\nconst t1 = d1 * Math.PI / 180, t2 = d2 * Math.PI / 180;\nconst z1x = r1 * Math.cos(t1), z1y = r1 * Math.sin(t1);\nconst z2x = r2 * Math.cos(t2), z2y = r2 * Math.sin(t2);\nconst rp = r1 * r2, tp = t1 + t2;\nconst rpDraw = Math.min(rp, 5.6);\nconst px = rpDraw * Math.cos(tp), py = rpDraw * Math.sin(tp);\nv.arrow(0, 0, z1x, z1y, { color: H.colors.accent, width: 2 });\nv.arrow(0, 0, z2x, z2y, { color: H.colors.good, width: 2 });\nv.arrow(0, 0, px, py, { color: H.colors.warn, width: 2.5 });\nif (rp > 5.6) v.dot(px, py, { r: 5, fill: H.colors.warn });\nconst frac = 0.5 + 0.5 * Math.sin(t * 1.0);\nconst ta = t1 + frac * t2;\nconst ra = Math.min(r1 * (1 + frac * (r2 - 1)), 5.6);\nv.dot(ra * Math.cos(ta), ra * Math.sin(ta), { r: 6, fill: H.colors.violet });\nH.text(\"Multiply: moduli multiply, angles ADD\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"z1·z2: r = \" + r1.toFixed(1) + \"·\" + r2.toFixed(1) + \" = \" + rp.toFixed(2) + \" θ = \" + d1.toFixed(0) + \"°+\" + d2.toFixed(0) + \"° = \" + (d1 + d2).toFixed(0) + \"°\", 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"z1\", color: H.colors.accent }, { label: \"z2\", color: H.colors.good }, { label: \"z1·z2\", color: H.colors.warn }], H.W - 130, 28);" + }, + { + "id": "pc-complex-nth-roots", + "area": "Precalculus", + "topic": "Complex nth roots", + "title": "n-th roots: z^(1/n) = r^(1/n)·cis((θ+2πk)/n)", + "equation": "z^(1/n) = r^(1/n)·(cos((theta + 2*pi*k)/n) + i·sin((theta + 2*pi*k)/n)), k = 0..n-1", + "keywords": [ + "complex nth roots", + "nth roots", + "roots of complex number", + "roots of unity", + "cube roots", + "fourth roots", + "de moivre roots", + "polar roots", + "equally spaced roots", + "(theta+2pi k)/n", + "complex root", + "n roots" + ], + "explanation": "Every nonzero complex number has exactly n different n-th roots, and they all share the SAME modulus r^(1/n) — so they sit on one circle. Their angles start at theta/n and then step around by 2π/n each, which is why the roots are perfectly evenly spaced like spokes on a wheel. Slide n to add or remove spokes, slide θ to rotate the whole pattern, and slide r to grow or shrink the circle. The highlighted spoke cycles through the roots one by one.", + "bullets": [ + "All n roots have modulus r^(1/n) — they lie on a single circle.", + "Adding 2πk before dividing by n spaces the roots 360°/n apart.", + "Roots of unity (z=1) are this pattern starting at angle 0." + ], + "params": [ + { + "name": "mod", + "label": "modulus r", + "min": 0.3, + "max": 4, + "step": 0.1, + "value": 2 + }, + { + "name": "arg", + "label": "angle θ (deg)", + "min": 0, + "max": 360, + "step": 1, + "value": 60 + }, + { + "name": "n", + "label": "root count n", + "min": 2, + "max": 8, + "step": 1, + "value": 5 + } + ], + "code": "H.background();\nconst cx = H.W * 0.5, cy = H.H * 0.54, S = Math.min(H.W, H.H) * 0.30;\nconst mod = Math.max(0.2, P.mod), arg = P.arg * Math.PI / 180, n = Math.max(2, Math.round(P.n));\nconst root = Math.pow(mod, 1 / n);\nconst reach = Math.min(1, root / 2.2);\nconst Rpix = S;\nH.circle(cx, cy, Rpix * reach, { stroke: H.colors.grid, width: 1.5 });\nH.line(cx - S - 16, cy, cx + S + 16, cy, { color: H.colors.axis, width: 1 });\nH.line(cx, cy - S - 16, cx, cy + S + 16, { color: H.colors.axis, width: 1 });\nconst highlight = Math.floor(t * 0.8) % n;\nfor (let k = 0; k < n; k++) {\n const ak = (arg + 2 * Math.PI * k) / n;\n const px = cx + Rpix * reach * Math.cos(ak), py = cy - Rpix * reach * Math.sin(ak);\n const isHi = k === highlight;\n H.line(cx, cy, px, py, { color: isHi ? H.colors.accent2 : H.colors.accent, width: isHi ? 3 : 1.5 });\n H.circle(px, py, isHi ? 7 + Math.sin(t * 3) : 5, { fill: isHi ? H.colors.warn : H.colors.good });\n}\nH.text(\"n-th roots: z^(1/n) = r^(1/n)·cis((θ+2πk)/n)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"r = \" + mod.toFixed(2) + \" θ = \" + P.arg.toFixed(0) + \"° n = \" + n, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(n + \" roots, spaced \" + (360 / n).toFixed(0) + \"° apart, modulus \" + root.toFixed(2), 24, 72, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"highlighted root\", color: H.colors.warn }, { label: \"the n roots\", color: H.colors.good }], H.W - 200, 28);" + }, + { + "id": "pc-complex-polar-form", + "area": "Precalculus", + "topic": "Complex numbers in polar form", + "title": "Polar form: z = r(cos θ + i·sin θ)", + "equation": "z = r*(cos(theta) + i*sin(theta))", + "keywords": [ + "complex number", + "polar form", + "complex polar form", + "modulus", + "argument", + "r cis theta", + "trigonometric form", + "modulus argument", + "complex plane", + "argand diagram", + "magnitude and angle", + "cos plus i sin" + ], + "explanation": "Every complex number is an arrow in the plane (real part across, imaginary part up). Its polar form describes that arrow by its LENGTH r = |z| (the modulus) and its DIRECTION θ = arg z (the argument): z = r(cos θ + i·sin θ). Slide r to stretch the arrow along its ray and slide θ to rotate it; the readout shows the matching a + bi form, with a = r·cos θ and b = r·sin θ.", + "bullets": [ + "|z| = r is the arrow's length; arg z = θ is its angle from the real axis.", + "z = r(cos θ + i·sin θ) converts to a + bi via a = r·cos θ, b = r·sin θ.", + "Polar form makes rotation and scaling of complex numbers obvious." + ], + "params": [ + { + "name": "r", + "label": "modulus r = |z|", + "min": 0.5, + "max": 5, + "step": 0.1, + "value": 3.5 + }, + { + "name": "deg", + "label": "argument θ (degrees)", + "min": 0, + "max": 360, + "step": 1, + "value": 40 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst r = P.r, deg = P.deg;\nconst th = deg * Math.PI / 180;\nconst x = r * Math.cos(th), y = r * Math.sin(th);\nconst ring = [];\nfor (let i = 0; i <= 80; i++) { const a = i / 80 * H.TAU; ring.push([r * Math.cos(a), r * Math.sin(a)]); }\nv.path(ring, { color: H.colors.grid, width: 1 });\nv.arrow(0, 0, x, y, { color: H.colors.accent2, width: 2.5 });\nconst arc = [];\nfor (let i = 0; i <= 40; i++) { const a = th * i / 40; arc.push([1.4 * Math.cos(a), 1.4 * Math.sin(a)]); }\nv.path(arc, { color: H.colors.good, width: 2 });\nconst rr = r * (0.6 + 0.4 * (0.5 + 0.5 * Math.sin(t * 1.3)));\nv.dot(rr * Math.cos(th), rr * Math.sin(th), { r: 6, fill: H.colors.violet });\nv.dot(x, y, { r: 6 + Math.sin(t * 3), fill: H.colors.warn });\nH.text(\"Complex in polar form: z = r(cos θ + i·sin θ)\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"|z| = r = \" + r.toFixed(1) + \" arg θ = \" + deg.toFixed(0) + \"° => z = \" + x.toFixed(2) + \" + \" + y.toFixed(2) + \"i\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"z (modulus r)\", color: H.colors.accent2 }, { label: \"argument θ\", color: H.colors.good }], H.W - 175, 28);" + }, + { + "id": "pc-composite-functions", + "area": "Precalculus", + "topic": "Composite functions", + "title": "Composite: (f o g)(x) = (m*x + b)^2", + "equation": "(f o g)(x) = f(g(x)) = (m*x + b)^2", + "keywords": [ + "composite functions", + "composition", + "f of g", + "f(g(x))", + "fog", + "inner function", + "outer function", + "chain of functions", + "plug in", + "compose", + "nested functions" + ], + "explanation": "A composite feeds x through the inner function g first, then hands that output to the outer function f. Here g(x) = m*x + b is a line and f(u) = u^2 squares whatever it receives. Follow the moving x along the axis: the orange dashed jump shows g(x) (the inner result), then the green dashed jump squares it to land on the bold composite curve f(g(x)). Change m and b to reshape the inner line and watch the whole composite stretch and shift in response.", + "bullets": [ + "Work inside-out: compute g(x) first, then apply f to that result.", + "g(x)=m*x+b is the inner step; squaring it gives the bold composite parabola.", + "Order matters: f(g(x)) is generally NOT the same as g(f(x))." + ], + "params": [ + { + "name": "m", + "label": "inner slope m", + "min": -2, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "b", + "label": "inner shift b", + "min": -3, + "max": 3, + "step": 0.5, + "value": 0 + } + ], + "code": "H.background();\nconst m = P.m, b = P.b;\nconst g = (x) => m * x + b;\nconst f = (u) => u * u;\nconst comp = (x) => f(g(x));\n// Fit the plot so the composite curve and tracer stay on-screen for ALL m,b.\nconst xR = 6;\n// composite peak over [-xR,xR] occurs at an endpoint (parabola in x)\nconst yTop = Math.max(comp(-xR), comp(xR), 1);\nconst yMax = Math.min(Math.max(10, Math.ceil(yTop * 1.05)), 120);\n// inner line g(x) over [-xR,xR]; its min for the visible window\nconst gLo = Math.min(g(-xR), g(xR));\nconst yMin = Math.min(-6, Math.floor(gLo * 1.05));\nconst v = H.plot2d({ xMin: -xR, xMax: xR, yMin: yMin, yMax: yMax });\nv.grid(); v.axes();\nv.fn(g, { color: H.colors.accent2, width: 2 });\nv.fn(comp, { color: H.colors.accent, width: 3 });\nconst x0 = (xR * 0.66) * Math.sin(t * 0.6);\nconst inner = g(x0);\nconst outer = f(inner);\nv.dot(x0, 0, { r: 5, fill: H.colors.violet });\nv.line(x0, 0, x0, inner, { color: H.colors.accent2, width: 1.5, dash: [4, 4] });\nv.dot(x0, inner, { r: 6, fill: H.colors.accent2 });\nv.line(x0, inner, x0, outer, { color: H.colors.good, width: 1.5, dash: [4, 4] });\nv.dot(x0, outer, { r: 6, fill: H.colors.warn });\nH.text(\"(f o g)(x) = f(g(x)) = (m*x + b)^2\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"x=\" + x0.toFixed(2) + \" -> g(x)=\" + inner.toFixed(2) + \" -> f(g(x))=\" + outer.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"inner g(x)=m*x+b\", color: H.colors.accent2 }, { label: \"composite f(g(x))\", color: H.colors.accent }], H.W - 240, 28);" + }, + { + "id": "pc-conics-circle", + "area": "Precalculus", + "topic": "Conics: circles", + "title": "Circle: (x − h)² + (y − k)² = r²", + "equation": "(x - h)^2 + (y - k)^2 = r^2", + "keywords": [ + "circle", + "conic", + "conic section", + "circle equation", + "center radius", + "standard form circle", + "(x-h)^2+(y-k)^2=r^2", + "radius", + "center", + "distance from center", + "equation of a circle" + ], + "explanation": "A circle is every point that sits exactly r away from a fixed center (h, k) — the equation is just the distance formula set equal to r and squared. Slide h and k to drag the whole circle around the plane without changing its size, and slide r to grow or shrink it about that center. The rotating spoke shows the radius staying the same length no matter which direction it points, which is the entire definition of a circle.", + "bullets": [ + "(h, k) is the center; r is the constant distance to every point.", + "h and k translate the circle; only r changes its size.", + "The equation is the distance formula: √((x−h)²+(y−k)²) = r, squared." + ], + "params": [ + { + "name": "h", + "label": "center x h", + "min": -6, + "max": 6, + "step": 0.5, + "value": 0 + }, + { + "name": "k", + "label": "center y k", + "min": -4, + "max": 4, + "step": 0.5, + "value": 0 + }, + { + "name": "r", + "label": "radius r", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -7, yMax: 7 });\nv.grid(); v.axes();\nconst h = P.h, k = P.k, r = Math.max(0.3, P.r);\nconst pts = [];\nfor (let i = 0; i <= 100; i++) { const a = i / 100 * H.TAU; pts.push([h + r * Math.cos(a), k + r * Math.sin(a)]); }\nv.path(pts, { color: H.colors.accent, width: 3, close: true });\nv.dot(h, k, { r: 6, fill: H.colors.warn });\nconst a = t * 0.8;\nconst px = h + r * Math.cos(a), py = k + r * Math.sin(a);\nv.line(h, k, px, py, { color: H.colors.good, width: 2 });\nv.dot(px, py, { r: 6, fill: H.colors.accent2 });\nv.text(\"r\", h + r * 0.5 * Math.cos(a), k + r * 0.5 * Math.sin(a) + 0.4, { color: H.colors.good, size: 13 });\nH.text(\"Circle: (x − h)² + (y − k)² = r²\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"center (\" + h.toFixed(1) + \", \" + k.toFixed(1) + \") radius r = \" + r.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"every point is exactly r from the center\", 24, 72, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"center\", color: H.colors.warn }, { label: \"radius\", color: H.colors.good }], H.W - 150, 28);" + }, + { + "id": "pc-conics-ellipse-focal", + "area": "Precalculus", + "topic": "Conics: ellipses", + "title": "Ellipse: (x−h)²/a² + (y−k)²/b² = 1", + "equation": "(x - h)^2/a^2 + (y - k)^2/b^2 = 1", + "keywords": [ + "ellipse", + "conic", + "conic section", + "foci", + "focal sum", + "major axis", + "minor axis", + "semi axis", + "eccentricity", + "oval", + "(x-h)^2/a^2+(y-k)^2/b^2=1", + "ellipse equation" + ], + "explanation": "An ellipse is every point whose two distances to the two foci ADD UP to a constant (equal to the long axis length). a and b are the semi-axes; the foci sit at distance c = √|a²−b²| from the center on the longer axis. Slide a and b to stretch the oval — when a = b the foci merge and you get a circle — and slide h, k to move it. Watch the two colored focal radii: their lengths trade off as the point travels, but their SUM never changes.", + "bullets": [ + "a and b are the semi-axis lengths; a = b collapses it to a circle.", + "Foci lie at c = √|a² − b²| from the center along the major axis.", + "For every point, distance-to-focus-1 + distance-to-focus-2 is constant." + ], + "params": [ + { + "name": "h", + "label": "center x h", + "min": -5, + "max": 5, + "step": 0.5, + "value": 0 + }, + { + "name": "k", + "label": "center y k", + "min": -3, + "max": 3, + "step": 0.5, + "value": 0 + }, + { + "name": "a", + "label": "semi-axis a", + "min": 1, + "max": 8, + "step": 0.5, + "value": 6 + }, + { + "name": "b", + "label": "semi-axis b", + "min": 1, + "max": 6, + "step": 0.5, + "value": 3.5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -7, yMax: 7 });\nv.grid(); v.axes();\nconst h = P.h, k = P.k, a = Math.max(0.5, P.a), b = Math.max(0.5, P.b);\nconst pts = [];\nfor (let i = 0; i <= 100; i++) { const th = i / 100 * H.TAU; pts.push([h + a * Math.cos(th), k + b * Math.sin(th)]); }\nv.path(pts, { color: H.colors.accent, width: 3, close: true });\nv.dot(h, k, { r: 5, fill: H.colors.sub });\nconst c = Math.sqrt(Math.abs(a * a - b * b));\nlet f1, f2;\nif (a >= b) { f1 = [h - c, k]; f2 = [h + c, k]; } else { f1 = [h, k - c]; f2 = [h, k + c]; }\nv.dot(f1[0], f1[1], { r: 6, fill: H.colors.warn });\nv.dot(f2[0], f2[1], { r: 6, fill: H.colors.warn });\nconst th = t * 0.8;\nconst px = h + a * Math.cos(th), py = k + b * Math.sin(th);\nv.dot(px, py, { r: 6, fill: H.colors.good });\nv.line(f1[0], f1[1], px, py, { color: H.colors.accent2, width: 2 });\nv.line(f2[0], f2[1], px, py, { color: H.colors.violet, width: 2 });\nconst d1 = Math.hypot(px - f1[0], py - f1[1]), d2 = Math.hypot(px - f2[0], py - f2[1]);\nH.text(\"Ellipse: (x−h)²/a² + (y−k)²/b² = 1\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"a = \" + a.toFixed(1) + \" b = \" + b.toFixed(1) + \" c = \" + c.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"d₁ + d₂ = \" + (d1 + d2).toFixed(2) + \" (always = 2·\" + Math.max(a, b).toFixed(1) + \")\", 24, 72, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"foci\", color: H.colors.warn }, { label: \"d₁\", color: H.colors.accent2 }, { label: \"d₂\", color: H.colors.violet }], H.W - 150, 28);" + }, + { + "id": "pc-conics-hyperbolas", + "area": "Precalculus", + "topic": "Conics: hyperbolas", + "title": "Hyperbola: x²/a² − y²/b² = 1", + "equation": "x^2/a^2 - y^2/b^2 = 1", + "keywords": [ + "hyperbola", + "hyperbolas", + "conic", + "conic section", + "asymptote", + "asymptotes", + "foci", + "vertices", + "transverse axis", + "x^2/a^2 - y^2/b^2 = 1", + "two branches", + "center" + ], + "explanation": "A hyperbola has two mirror branches that open left and right, hugging a pair of straight asymptotes with slope ±b/a. The vertices sit at (±a, 0) where the branches turn around, and the foci sit farther out at (±c, 0) with c = sqrt(a² + b²). Increase a to push the vertices apart; increase b to steepen the asymptotes and flare the branches open.", + "bullets": [ + "Vertices are at (±a, 0); the branches open along the x-axis.", + "The branches approach the asymptotes y = ±(b/a)x but never touch them.", + "Foci are at (±c, 0) with c² = a² + b² (note the PLUS, unlike an ellipse)." + ], + "params": [ + { + "name": "a", + "label": "semi-transverse a", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "b", + "label": "semi-conjugate b", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -7, yMax: 7 });\nv.grid(); v.axes();\nconst a = Math.max(0.5, P.a), b = Math.max(0.5, P.b);\nconst xLim = 9.5;\nconst right = [], left = [];\nfor (let i = 0; i <= 80; i++) {\n const u = -2.2 + (4.4 * i) / 80;\n const x = a * Math.cosh(u), y = b * Math.sinh(u);\n if (x <= xLim && Math.abs(y) <= 6.8) right.push([x, y]);\n}\nfor (let i = 0; i <= 80; i++) {\n const u = -2.2 + (4.4 * i) / 80;\n const x = -a * Math.cosh(u), y = b * Math.sinh(u);\n if (x >= -xLim && Math.abs(y) <= 6.8) left.push([x, y]);\n}\nv.path(right, { color: H.colors.accent, width: 3 });\nv.path(left, { color: H.colors.accent, width: 3 });\nv.line(-xLim, -(b / a) * xLim, xLim, (b / a) * xLim, { color: H.colors.violet, width: 1.5, dash: [6, 6] });\nv.line(-xLim, (b / a) * xLim, xLim, -(b / a) * xLim, { color: H.colors.violet, width: 1.5, dash: [6, 6] });\nconst c = Math.sqrt(a * a + b * b);\nv.dot(c, 0, { r: 6, fill: H.colors.warn });\nv.dot(-c, 0, { r: 6, fill: H.colors.warn });\nv.dot(a, 0, { r: 5, fill: H.colors.good });\nv.dot(-a, 0, { r: 5, fill: H.colors.good });\nconst u = 1.8 * Math.sin(t * 0.7);\nv.dot(a * Math.cosh(u), b * Math.sinh(u), { r: 6, fill: H.colors.accent2 });\nH.text(\"Hyperbola: x²/a² − y²/b² = 1\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a = \" + a.toFixed(1) + \" b = \" + b.toFixed(1) + \" foci c = ±\" + c.toFixed(2) + \" asymptote slope ±\" + (b / a).toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"vertex (±a,0)\", color: H.colors.good }, { label: \"focus (±c,0)\", color: H.colors.warn }, { label: \"asymptotes\", color: H.colors.violet }], H.W - 200, 28);" + }, + { + "id": "pc-conics-parabola", + "area": "Precalculus", + "topic": "Conics: parabolas", + "title": "Parabola: (x − h)² = 4p(y − k)", + "equation": "(x - h)^2 = 4*p*(y - k)", + "keywords": [ + "parabola", + "conic", + "conic section", + "focus", + "directrix", + "vertex", + "(x-h)^2=4p(y-k)", + "focal length", + "parabola equation", + "focus directrix", + "axis of symmetry", + "reflector" + ], + "explanation": "A parabola is every point that is the SAME distance from a fixed focus as from a fixed line called the directrix. The focal length p is how far the focus sits above the vertex (and the directrix the same distance below). Slide p to open the parabola wide (large p) or pinch it narrow (small p), and slide h, k to move the vertex. The two dashed segments from the moving point are always equal — that equality IS the parabola.", + "bullets": [ + "Vertex (h, k) sits midway between the focus and the directrix.", + "Focus is p above the vertex; directrix is the line y = k − p.", + "Larger |p| opens the parabola wider; the sign flips it up or down." + ], + "params": [ + { + "name": "h", + "label": "vertex x h", + "min": -5, + "max": 5, + "step": 0.5, + "value": 0 + }, + { + "name": "k", + "label": "vertex y k", + "min": -1, + "max": 5, + "step": 0.5, + "value": 1 + }, + { + "name": "p", + "label": "focal length p", + "min": 0.3, + "max": 3, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -3, yMax: 11 });\nv.grid(); v.axes();\nconst h = P.h, k = P.k, p = Math.abs(P.p) < 0.2 ? 0.2 : P.p;\nconst a = 1 / (4 * p);\nv.fn(x => a * (x - h) * (x - h) + k, { color: H.colors.accent, width: 3 });\nv.dot(h, k, { r: 6, fill: H.colors.warn });\nconst fy = k + p;\nv.dot(h, fy, { r: 6, fill: H.colors.accent2 });\nv.line(-8, k - p, 8, k - p, { color: H.colors.violet, width: 2, dash: [6, 6] });\nconst xs = h + 4 * Math.sin(t * 0.7);\nconst ys = a * (xs - h) * (xs - h) + k;\nv.dot(xs, ys, { r: 6, fill: H.colors.good });\nv.line(xs, ys, h, fy, { color: H.colors.accent2, width: 1.5, dash: [3, 3] });\nv.line(xs, ys, xs, k - p, { color: H.colors.violet, width: 1.5, dash: [3, 3] });\nconst dFocus = Math.hypot(xs - h, ys - fy);\nH.text(\"Parabola: (x − h)² = 4p(y − k)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"vertex (\" + h.toFixed(1) + \", \" + k.toFixed(1) + \") p = \" + p.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"dist to focus = dist to directrix = \" + dFocus.toFixed(2), 24, 72, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"focus\", color: H.colors.accent2 }, { label: \"directrix\", color: H.colors.violet }, { label: \"point\", color: H.colors.good }], H.W - 170, 28);" + }, + { + "id": "pc-continuity", + "area": "Precalculus", + "topic": "Continuity", + "title": "Continuity at x = a: lim f(x) = f(a)", + "equation": "f continuous at a <=> lim (x->a) f(x) = f(a)", + "keywords": [ + "continuity", + "continuous function", + "discontinuity", + "jump discontinuity", + "removable discontinuity", + "hole", + "limit equals value", + "piecewise continuity", + "is f continuous", + "break in graph", + "continuous at a point" + ], + "explanation": "A function is continuous at a when you can draw through x = a without lifting your pen: the left limit, the right limit, and the actual value f(a) all agree. The jump slider pulls the right branch up or down so the two one-sided limits disagree (a jump discontinuity), and the hole slider removes the filled value f(a) entirely (a removable discontinuity / hole). Set jump = 0 and hole = off and the yellow tracer glides smoothly across; otherwise it leaps, and the readout names exactly which condition failed.", + "bullets": [ + "Continuity needs three things: lim from the left = lim from the right = f(a).", + "Jump discontinuity: the one-sided limits exist but don't match.", + "Removable discontinuity (hole): the limit exists but f(a) is missing or wrong." + ], + "params": [ + { + "name": "a", + "label": "test point a", + "min": -3, + "max": 3, + "step": 0.5, + "value": 1 + }, + { + "name": "jump", + "label": "jump size", + "min": -3, + "max": 3, + "step": 0.5, + "value": 2 + }, + { + "name": "hole", + "label": "hole (0=no,1=yes)", + "min": 0, + "max": 1, + "step": 1, + "value": 0 + } + ], + "code": "H.background();\nconst a = P.a; // the point we test continuity at\nconst jump = P.jump; // size of the jump in the function value at x=a\nconst hole = P.hole; // >=0.5 = leave a hole (f(a) undefined), else filled\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -4, yMax: 6 });\nv.grid(); v.axes();\n// Left branch approaches L; right branch starts at L+jump.\nconst L = 1;\nfunction left(x){ return L + 0.5 * (x - a); }\nfunction right(x){ return L + jump + 0.5 * (x - a); }\nv.fn(x => (x < a ? left(x) : NaN), { color: H.colors.accent, width: 3 });\nv.fn(x => (x > a ? right(x) : NaN), { color: H.colors.accent2, width: 3 });\nv.line(a, -4, a, 6, { color: H.colors.violet, width: 1, dash: [4, 4] });\nconst continuous = Math.abs(jump) < 0.05 && hole < 0.5;\n// Open circles mark the one-sided limits; a filled dot marks f(a) if defined.\nv.circle(a, L, 5.5, { stroke: H.colors.good, width: 2, fill: H.colors.bg });\nv.circle(a, L + jump, 5.5, { stroke: H.colors.accent2, width: 2, fill: H.colors.bg });\nif (hole < 0.5) v.dot(a, L + jump, { r: 6, fill: continuous ? H.colors.good : H.colors.warn });\n// Animate a tracer crossing x=a so a jump shows as a sudden change in height.\nconst xs = 4 * Math.sin(t * 0.7);\nconst ys = xs < a ? left(xs) : right(xs);\nif (Number.isFinite(ys)) v.dot(xs, ys, { r: 5, fill: H.colors.yellow });\nH.text(\"Continuity at x = a: lim f = f(a)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"a = \" + a.toFixed(1) + \" jump = \" + jump.toFixed(2) + \" hole = \" + (hole >= 0.5 ? \"yes\" : \"no\"), 24, 52, { color: H.colors.sub, size: 13 });\nconst reason = hole >= 0.5 ? \"f(a) undefined (hole)\" : Math.abs(jump) >= 0.05 ? \"left limit != right limit (jump)\" : \"left=right=f(a): continuous\";\nH.text((continuous ? \"CONTINUOUS — \" : \"DISCONTINUOUS — \") + reason, 24, 74, { color: continuous ? H.colors.good : H.colors.warn, size: 13, weight: 700 });\nH.legend([{ label: \"left branch\", color: H.colors.accent }, { label: \"right branch\", color: H.colors.accent2 }], H.W - 170, 28);" + }, + { + "id": "pc-de-moivres-theorem", + "area": "Precalculus", + "topic": "De Moivre's theorem", + "title": "De Moivre: (r·cis θ)^n = r^n·cis(nθ)", + "equation": "(r·(cos theta + i·sin theta))^n = r^n·(cos(n·theta) + i·sin(n·theta))", + "keywords": [ + "de moivre", + "de moivre's theorem", + "demoivre", + "complex power", + "polar form", + "cis", + "modulus argument", + "power of complex number", + "r cis theta", + "raising to a power", + "rotate and scale", + "complex exponent" + ], + "explanation": "Raising a complex number to the n-th power is just rotate-and-scale done n times. Each power MULTIPLIES the angle by n and RAISES the modulus to the n-th power, so the arms fan out by equal angle steps while the lengths grow (or shrink) geometrically. Slide theta to set the rotation per step, slide n to take more steps, and slide r to see lengths explode when r>1 or collapse toward 0 when r<1. The bold arm and pulsing dot show where z^n lands.", + "bullets": [ + "Multiplying complex numbers ADDS their angles and MULTIPLIES their moduli.", + "So z^n has angle n·theta and modulus r^n — one rule for every power.", + "It turns messy binomial expansion into a single rotate-and-scale step." + ], + "params": [ + { + "name": "r", + "label": "modulus r", + "min": 0.5, + "max": 1.5, + "step": 0.05, + "value": 0.95 + }, + { + "name": "theta", + "label": "angle θ (deg)", + "min": 0, + "max": 120, + "step": 1, + "value": 35 + }, + { + "name": "n", + "label": "power n", + "min": 1, + "max": 8, + "step": 1, + "value": 5 + } + ], + "code": "H.background();\nconst cx = H.W * 0.5, cy = H.H * 0.54, R = Math.min(H.W, H.H) * 0.32;\nconst r = Math.max(0.2, P.r), th0 = P.theta * Math.PI / 180, n = Math.max(1, Math.round(P.n));\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 1.5 });\nH.line(cx - R - 14, cy, cx + R + 14, cy, { color: H.colors.axis, width: 1 });\nH.line(cx, cy - R - 14, cx, cy + R + 14, { color: H.colors.axis, width: 1 });\nconst sweep = (t * 0.5) % 1;\nfor (let k = 1; k <= n; k++) {\n const ang = k * th0;\n const rho = Math.pow(r, k);\n const reach = Math.min(1, rho);\n const px = cx + R * reach * Math.cos(ang), py = cy - R * reach * Math.sin(ang);\n H.line(cx, cy, px, py, { color: k === n ? H.colors.accent2 : H.colors.accent, width: k === n ? 3 : 1.5 });\n H.circle(px, py, k === n ? 6 : 4, { fill: k === n ? H.colors.warn : H.colors.accent });\n}\nconst an = th0 + sweep * th0;\nconst rn = Math.min(1, Math.pow(r, 1 + sweep));\nH.circle(cx + R * rn * Math.cos(an), cy - R * rn * Math.sin(an), 5 + Math.sin(t * 3), { fill: H.colors.good });\nconst finalAng = n * th0, finalR = Math.pow(r, n);\nH.text(\"De Moivre: (r·cis θ)^n = r^n·cis(nθ)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"r = \" + r.toFixed(2) + \" θ = \" + P.theta.toFixed(0) + \"° n = \" + n, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"z^n: modulus = \" + finalR.toFixed(2) + \" angle = \" + ((finalAng * 180 / Math.PI) % 360).toFixed(0) + \"°\", 24, 72, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"z^n\", color: H.colors.warn }, { label: \"powers z^k\", color: H.colors.accent }], H.W - 170, 28);" + }, + { + "id": "pc-degrees-and-radians", + "area": "Precalculus", + "topic": "Degrees and radians", + "title": "Degrees and radians: 180° = π rad", + "equation": "radians = degrees * pi / 180", + "keywords": [ + "degrees and radians", + "convert degrees to radians", + "radian measure", + "180 degrees equals pi", + "angle conversion", + "pi over 180", + "degree radian", + "arc measure", + "full turn 2pi", + "angle units" + ], + "explanation": "Degrees and radians are two ways to name the same angle. The 'angle' slider sets how far the radius arm sweeps; the orange arc shows the angle opening up. Radians measure that angle by the arc it cuts on a circle of radius 1, which is why a full turn is 2π (about 6.28) instead of 360. The readout shows the same angle written both ways, plus what fraction of a full turn it is.", + "bullets": [ + "Multiply degrees by π/180 to get radians; multiply radians by 180/π to go back.", + "180° = π rad, 360° = 2π rad: a half turn is π, a full turn is 2π.", + "Radians are the angle's arc length on a unit circle — a pure ratio, no units." + ], + "params": [ + { + "name": "deg", + "label": "angle θ (degrees)", + "min": 1, + "max": 360, + "step": 1, + "value": 90 + } + ], + "code": "H.background();\nconst w = H.W, hh = H.H;\nconst cx = w * 0.5, cy = hh * 0.54, R = Math.min(w, hh) * 0.32;\nconst maxDeg = Math.max(1, P.deg);\nconst sweep = (Math.sin(t * 0.5 - Math.PI / 2) + 1) / 2;\nconst deg = maxDeg * sweep;\nconst rad = deg * Math.PI / 180;\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 1.5 });\nH.line(cx - R - 16, cy, cx + R + 16, cy, { color: H.colors.axis, width: 1 });\nH.line(cx, cy - R - 16, cx, cy + R + 16, { color: H.colors.axis, width: 1 });\nconst arc = [];\nconst steps = 64;\nfor (let i = 0; i <= steps; i++) {\n const aa = rad * (i / steps);\n arc.push([cx + (R * 0.42) * Math.cos(aa), cy - (R * 0.42) * Math.sin(aa)]);\n}\nif (arc.length >= 2) H.path(arc, { color: H.colors.accent2, width: 4 });\nconst px = cx + R * Math.cos(rad), py = cy - R * Math.sin(rad);\nH.line(cx, cy, cx + R, cy, { color: H.colors.sub, width: 1.5, dash: [4, 4] });\nH.line(cx, cy, px, py, { color: H.colors.accent, width: 2.5 });\nH.circle(px, py, 6, { fill: H.colors.warn });\nH.text(\"Degrees and radians\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"θ = \" + deg.toFixed(1) + \"° = \" + rad.toFixed(3) + \" rad (180° = π rad)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text((deg / 360).toFixed(2) + \" turn → \" + (rad / Math.PI).toFixed(3) + \"·π rad\", cx - 90, cy + R + 40, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"radius arm\", color: H.colors.accent }, { label: \"swept angle\", color: H.colors.accent2 }], H.W - 180, 28);" + }, + { + "id": "pc-difference-quotient", + "area": "Precalculus", + "topic": "Difference quotient", + "title": "Difference quotient: [f(a+h) − f(a)] / h", + "equation": "slope = (f(a + h) − f(a)) / h", + "keywords": [ + "difference quotient", + "secant line", + "average rate of change", + "slope of secant", + "f(a+h)-f(a)/h", + "rise over run", + "derivative definition", + "limit h to 0", + "instantaneous rate", + "average slope" + ], + "explanation": "The difference quotient is the slope of the secant line through two points on a curve: (a, f(a)) and (a+h, f(a+h)). The 'a' slider slides the first point along the curve, and the 'h' slider sets how far apart the second point is. Watch the rise (green-to-violet steps) over the run (h) — as the gap h breathes smaller, the secant tilts toward the tangent, which is exactly how the derivative is born.", + "bullets": [ + "It's just rise / run between two points: a measured slope, not a single number.", + "Bigger h = points far apart (rough average slope); smaller h closes in on the tangent.", + "Let h shrink toward 0 and the secant becomes the derivative f'(a)." + ], + "params": [ + { + "name": "a", + "label": "base point a", + "min": 0, + "max": 5, + "step": 0.1, + "value": 1.5 + }, + { + "name": "h", + "label": "gap h", + "min": 0.2, + "max": 4, + "step": 0.1, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 7, yMin: -1, yMax: 9 });\nv.grid(); v.axes();\nconst a = P.a, h0 = Math.max(0.05, P.h);\nconst f = (x) => 0.4 * x * x - 1.2 * x + 2;\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst breathe = (Math.sin(t * 0.8) + 1) / 2;\nconst h = 0.05 + (h0 - 0.05) * breathe;\nconst x1 = a, x2 = a + h;\nconst y1 = f(x1), y2 = f(x2);\nconst slope = (y2 - y1) / h;\nv.dot(x1, y1, { r: 6, fill: H.colors.warn });\nv.dot(x2, y2, { r: 6, fill: H.colors.good });\nv.line(x1, y1, x2, y2, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nconst sx0 = x1 - 2, sx1 = x2 + 2;\nv.line(sx0, y1 + slope * (sx0 - x1), sx1, y1 + slope * (sx1 - x1), { color: H.colors.accent2, width: 2.5 });\nv.line(x1, y1, x2, y1, { color: H.colors.good, width: 2 });\nv.line(x2, y1, x2, y2, { color: H.colors.violet, width: 2 });\nH.text(\"Difference quotient: secant slope\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"[f(a+h) − f(a)] / h = \" + slope.toFixed(2) + \" a = \" + a.toFixed(1) + \" h = \" + h.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"run = h\", color: H.colors.good }, { label: \"rise = f(a+h)−f(a)\", color: H.colors.violet }, { label: \"secant\", color: H.colors.accent2 }], H.W - 230, 28);" + }, + { + "id": "pc-dot-product", + "area": "Precalculus", + "topic": "Dot product", + "title": "Dot product: a·b = |a||b|cos(theta)", + "equation": "a.b = ax*bx + ay*by = |a|*|b|*cos(theta)", + "keywords": [ + "dot product", + "scalar product", + "a dot b", + "cosine of angle", + "projection", + "perpendicular vectors", + "angle between vectors", + "inner product", + "ax bx + ay by", + "orthogonal", + "work as dot product", + "component projection" + ], + "explanation": "The dot product measures how much two vectors point the same way. Numerically it is ax·bx + ay·by, and geometrically it equals |a||b|cos(theta), so its sign tells you the angle: positive means an acute angle, zero means perpendicular, negative means obtuse. The purple bar shows the projection of b onto a (its length is a·b divided by |a|) — rotate b with the angle slider and watch the dot product swing through zero exactly when b is perpendicular to a.", + "bullets": [ + "a·b = ax·bx + ay·by, and equally |a||b|cos(theta) — two views of the same number.", + "Sign tells the geometry: + acute, 0 perpendicular, − obtuse.", + "a·b equals |a| times the signed projection of b onto a (the purple bar)." + ], + "params": [ + { + "name": "ax", + "label": "a: x-component", + "min": -8, + "max": 8, + "step": 0.5, + "value": 6 + }, + { + "name": "ay", + "label": "a: y-component", + "min": -6, + "max": 6, + "step": 0.5, + "value": 1 + }, + { + "name": "bmag", + "label": "|b| length", + "min": 1, + "max": 8, + "step": 0.5, + "value": 5 + }, + { + "name": "bangle", + "label": "b angle (deg)", + "min": 0, + "max": 360, + "step": 1, + "value": 70 + } + ], + "code": "H.background();\nconst view = H.plot2d({ xMin: -10, xMax: 10, yMin: -7, yMax: 7 });\nview.grid(); view.axes();\nconst ax = P.ax, ay = P.ay;\nconst bMag = P.bmag, bAng = P.bangle * Math.PI / 180;\nconst bx = bMag * Math.cos(bAng), by = bMag * Math.sin(bAng);\nconst dot = ax * bx + ay * by;\nconst aMag = Math.sqrt(ax * ax + ay * ay) || 1e-6;\nconst proj = dot / aMag;\nconst ux = ax / aMag, uy = ay / aMag;\nconst px = proj * ux, py = proj * uy;\nview.arrow(0, 0, ax, ay, { color: H.colors.good, width: 3 });\nview.arrow(0, 0, bx, by, { color: H.colors.accent2, width: 3 });\nview.line(bx, by, px, py, { color: H.colors.sub, width: 1.5, dash: [4, 4] });\nview.line(0, 0, px, py, { color: H.colors.violet, width: 5 });\nconst k = 0.5 + 0.5 * Math.sin(t * 1.4);\nview.dot(px * k, py * k, { r: 6, fill: H.colors.warn });\nlet theta = Math.acos(H.clamp(dot / (aMag * (bMag || 1e-6)), -1, 1)) * 180 / Math.PI;\nview.text(\"a\", ax / 2, ay / 2 + 0.5, { color: H.colors.good, size: 13 });\nview.text(\"b\", bx / 2, by / 2 + 0.5, { color: H.colors.accent2, size: 13 });\nview.text(\"proj\", px / 2, py / 2 - 0.5, { color: H.colors.violet, size: 12 });\nH.text(\"Dot product: a·b = |a||b|cos(theta) = ax·bx + ay·by\", 24, 30, { color: H.colors.ink, size: 15, weight: 700 });\nconst sign = dot > 0.01 ? \"(> 0: angle < 90°)\" : dot < -0.01 ? \"(< 0: angle > 90°)\" : \"(= 0: perpendicular)\";\nH.text(\"a·b = \" + dot.toFixed(2) + \" theta = \" + theta.toFixed(0) + \"° \" + sign, 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"a\", color: H.colors.good }, { label: \"b\", color: H.colors.accent2 }, { label: \"b onto a\", color: H.colors.violet }], H.W - 150, 28);" + }, + { + "id": "pc-double-half-angle-formulas", + "area": "Precalculus", + "topic": "Double-angle and half-angle formulas", + "title": "Double angle: sin(2θ) = 2 sinθ cosθ", + "equation": "sin(2*theta) = 2*sin(theta)*cos(theta)", + "keywords": [ + "double angle", + "half angle", + "double angle formula", + "sin 2theta", + "sin(2x)", + "2 sin cos", + "cos 2theta", + "double-angle identity", + "trig identity", + "angle formulas", + "sin2x=2sinxcosx" + ], + "explanation": "The double-angle formula says sin(2theta) equals 2*sin(theta)*cos(theta) — one angle's sine and cosine multiplied together build the sine of twice the angle. The blue curve is sin(theta) and the orange curve is sin(2theta), which oscillates twice as fast. Move the angle slider to set a starting angle; the violet marker sweeps nearby so you can watch the pink dot (the value 2 sinθ cosθ) land exactly on the orange sin(2θ) curve at every angle.", + "bullets": [ + "sin(2θ) = 2 sinθ cosθ: products of the single angle build the doubled angle.", + "The doubled-angle wave completes two cycles while sinθ completes one.", + "Half-angle reverses this: sin²(θ/2) = (1 − cosθ)/2." + ], + "params": [ + { + "name": "deg", + "label": "angle θ (degrees)", + "min": 0, + "max": 360, + "step": 1, + "value": 60 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 360, yMin: -2.2, yMax: 2.2 });\nv.grid(); v.axes();\nconst deg = P.deg;\nconst a = deg * Math.PI / 180;\n// f(theta) = sin(theta) in blue\nv.fn(x => Math.sin(x * Math.PI / 180), { color: H.colors.accent, width: 2.5 });\n// g(theta) = sin(2 theta) the double-angle wave in orange\nv.fn(x => Math.sin(2 * x * Math.PI / 180), { color: H.colors.accent2, width: 2.5 });\n// sweep a vertical marker line through the angle, looping\nconst sweep = (P.deg + 60 * Math.sin(t * 0.7));\nconst sd = ((sweep % 360) + 360) % 360;\nconst sr = sd * Math.PI / 180;\nv.line(sd, -2.2, sd, 2.2, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nconst s = Math.sin(sr), c = Math.cos(sr);\nconst sin2 = 2 * s * c; // sin(2θ) = 2 sinθ cosθ\nv.dot(sd, s, { r: 6, fill: H.colors.accent });\nv.dot(sd, sin2, { r: 6, fill: H.colors.warn });\nH.text(\"Double angle: sin(2θ) = 2 sinθ cosθ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"θ = \" + sd.toFixed(0) + \"° sinθ = \" + s.toFixed(2) + \" 2 sinθ cosθ = \" + sin2.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"sinθ\", color: H.colors.accent }, { label: \"sin 2θ\", color: H.colors.accent2 }], H.W - 150, 28);" + }, + { + "id": "pc-eliminating-the-parameter", + "area": "Precalculus", + "topic": "Eliminating the parameter", + "title": "Eliminate the parameter: x = h + s, y = k + a s^2", + "equation": "x = h + s, y = k + a*s^2 => y = a*(x - h)^2 + k", + "keywords": [ + "eliminating the parameter", + "eliminate the parameter", + "rectangular form", + "cartesian equation", + "convert parametric", + "solve for t", + "remove parameter", + "parametric to rectangular", + "implicit equation", + "parametric" + ], + "explanation": "To eliminate the parameter you solve one equation for s and substitute into the other, turning a pair of parametric equations into a single x-y (Cartesian) equation. Here x = h + s solves to s = x - h, and plugging into y = k + a s^2 gives the parabola y = a(x - h)^2 + k. The blue parametric trace and the green Cartesian curve land on top of each other — proof they are the same set of points. Slide h, k, and a to see the readout's equation update while both curves stay matched.", + "bullets": [ + "Solve the simpler equation for the parameter, then substitute into the other.", + "x = h + s gives s = x - h, so y = k + a s^2 becomes y = a(x - h)^2 + k.", + "The parametric trace and the rectangular graph are identical — same points, two descriptions." + ], + "params": [ + { + "name": "a", + "label": "stretch a", + "min": -1.5, + "max": 1.5, + "step": 0.1, + "value": 0.5 + }, + { + "name": "h", + "label": "shift right h", + "min": -3, + "max": 3, + "step": 0.5, + "value": 0 + }, + { + "name": "k", + "label": "shift up k", + "min": -2, + "max": 5, + "step": 0.5, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -7, xMax: 7, yMin: -4, yMax: 8 });\nv.grid(); v.axes();\nconst h = P.h, k = P.k, a = P.a;\n// Parametric: x = h + s, y = k + a*s^2. Eliminate s = x - h => y = k + a(x-h)^2\nconst X = (s) => h + s;\nconst Y = (s) => k + a * s * s;\nconst pts = [];\nfor (let i = 0; i <= 200; i++) { const s = -4 + 8 * i / 200; pts.push([X(s), Y(s)]); }\nv.path(pts, { color: H.colors.accent, width: 2.5 });\n// the SAME curve as a Cartesian function (drawn faintly on top to show they match)\nv.fn(x => k + a * (x - h) * (x - h), { color: H.colors.good, width: 1.5 });\n// moving point parameterized by s, looping back and forth\nconst s = 3.5 * Math.sin(t * 0.7);\nconst cx = X(s), cy = Y(s);\nv.line(cx, -4, cx, cy, { color: H.colors.violet, width: 1.2, dash: [3, 3] });\nv.dot(cx, cy, { r: 6, fill: H.colors.warn });\nv.dot(cx, -4, { r: 4, fill: H.colors.accent2 });\nH.text(\"Eliminate the parameter\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"x = h + s, y = k + a s^2 -> s = x - h\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"y = \" + a.toFixed(1) + \"(x - \" + h.toFixed(1) + \")^2 + \" + k.toFixed(1) + \" (s = \" + s.toFixed(2) + \")\", 24, 72, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"parametric trace\", color: H.colors.accent }, { label: \"y = f(x) match\", color: H.colors.good }], H.W - 220, 28);" + }, + { + "id": "pc-even-odd-functions-and-symmetry", + "area": "Precalculus", + "topic": "Even/odd functions and symmetry", + "title": "Even/odd test: f(x) vs f(-x)", + "equation": "even: f(-x) = f(x); odd: f(-x) = -f(x)", + "keywords": [ + "even odd functions", + "even and odd functions", + "symmetry", + "f(-x)", + "y-axis symmetry", + "origin symmetry", + "even function", + "odd function", + "neither", + "reflection symmetry", + "rotational symmetry" + ], + "explanation": "To classify a function you compare its value at x with its value at the mirrored input -x. The pink dot rides (x, f(x)) and the orange dot rides (-x, f(-x)); the dashed line connects this matched pair. If the two heights are always equal the function is EVEN (mirror-symmetric across the y-axis); if they are always opposite it is ODD (the graph maps onto itself under a 180 degree turn about the origin). Turn on only the x^2 coefficient for an even graph, only x or x^3 for odd, and mix them to see 'neither'.", + "bullets": [ + "Even: f(-x) = f(x) for all x - the graph is a mirror image across the y-axis.", + "Odd: f(-x) = -f(x) - the graph looks the same after a 180 degree spin about the origin.", + "Mixing even and odd terms (like x^2 with x) gives a graph that is neither." + ], + "params": [ + { + "name": "c3", + "label": "x^3 coef (odd)", + "min": -1, + "max": 1, + "step": 0.1, + "value": 0.2 + }, + { + "name": "c2", + "label": "x^2 coef (even)", + "min": -1, + "max": 1, + "step": 0.1, + "value": 0 + }, + { + "name": "c1", + "label": "x coef (odd)", + "min": -3, + "max": 3, + "step": 0.5, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst c2 = P.c2, c3 = P.c3, c1 = P.c1;\nconst f = (x) => c3 * x * x * x + c2 * x * x + c1 * x;\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst xs = 4 * Math.abs(Math.sin(t * 0.6));\nconst yR = f(xs), yL = f(-xs);\nv.dot(xs, yR, { r: 6, fill: H.colors.warn });\nv.dot(-xs, yL, { r: 6, fill: H.colors.accent2 });\nv.line(xs, yR, -xs, yL, { color: H.colors.sub, width: 1, dash: [3, 4] });\nconst isEven = Math.abs(c3) < 1e-9 && Math.abs(c1) < 1e-9 && Math.abs(c2) > 1e-9;\nconst isOdd = Math.abs(c2) < 1e-9 && (Math.abs(c3) > 1e-9 || Math.abs(c1) > 1e-9);\nlet verdict = \"neither\";\nif (isEven) verdict = \"EVEN: f(-x) = f(x) (mirror over y-axis)\";\nelse if (isOdd) verdict = \"ODD: f(-x) = -f(x) (180 deg about origin)\";\nH.text(\"Test symmetry: compare f(x) and f(-x)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"x=\" + xs.toFixed(2) + \" f(x)=\" + yR.toFixed(2) + \" f(-x)=\" + yL.toFixed(2) + \" -> \" + verdict, 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"(x, f(x))\", color: H.colors.warn }, { label: \"(-x, f(-x))\", color: H.colors.accent2 }], H.W - 170, 28);" + }, + { + "id": "pc-exact-trig-values", + "area": "Precalculus", + "topic": "Exact trig values", + "title": "Exact trig values: (cos θ, sin θ)", + "equation": "cos 30 = √3/2, sin 30 = 1/2, cos 45 = sin 45 = √2/2, cos 60 = 1/2, sin 60 = √3/2", + "keywords": [ + "exact trig values", + "exact values", + "unit circle values", + "special angles", + "30 45 60", + "sqrt2 over 2", + "sqrt3 over 2", + "cos 30", + "sin 45", + "trig table", + "radians degrees", + "common angles" + ], + "explanation": "The 'exact values' are just the coordinates of special points on the unit circle, where cos θ is the x-coordinate and sin θ is the y-coordinate. The pick slider steps you through the 16 common angles (0°, 30°, 45°, 60°, 90°, ...); for each, the dashed blue and green legs show exactly cos θ and sin θ, and the readout prints them as clean radicals like √3/2 instead of a rounded decimal. Watching the same three numbers (1/2, √2/2, √3/2) reappear with different signs is what makes the table memorable.", + "bullets": [ + "cos θ is the x-coordinate and sin θ the y-coordinate of the point on the unit circle.", + "Only three magnitudes occur for the special angles: 1/2, √2/2, √3/2 (plus 0 and 1).", + "The quadrant decides the signs; the reference angle decides the magnitude." + ], + "params": [ + { + "name": "pick", + "label": "special angle (index)", + "min": 0, + "max": 15, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst cx = H.W * 0.5, cy = H.H * 0.54, R = Math.min(H.W, H.H) * 0.32;\nconst specials = [0, 30, 45, 60, 90, 120, 135, 150, 180, 210, 225, 240, 270, 300, 315, 330];\nconst pick = Math.max(0, Math.min(specials.length - 1, Math.round(P.pick)));\nconst deg = specials[pick];\nconst ang = deg * Math.PI / 180;\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 1.5 });\nH.line(cx - R - 14, cy, cx + R + 14, cy, { color: H.colors.axis, width: 1 });\nH.line(cx, cy - R - 14, cx, cy + R + 14, { color: H.colors.axis, width: 1 });\nfor (let i = 0; i < specials.length; i++) {\n const a = specials[i] * Math.PI / 180;\n H.circle(cx + R * Math.cos(a), cy - R * Math.sin(a), 3, { fill: H.colors.grid });\n}\nconst px = cx + R * Math.cos(ang), py = cy - R * Math.sin(ang);\nH.line(cx, cy, px, py, { color: H.colors.accent2, width: 2.5 });\nH.line(px, py, px, cy, { color: H.colors.good, width: 2, dash: [4, 4] });\nH.line(px, py, cx, py, { color: H.colors.accent, width: 2, dash: [4, 4] });\nconst pulse = 6 + 1.5 * Math.sin(t * 3);\nH.circle(px, py, pulse, { fill: H.colors.warn });\n// snap tiny floating-point dust (e.g. cos 270° = -1.8e-16) to a clean 0 so\n// the radical lookup below matches \"0.0000\" instead of falling to \"-0.000\".\nconst snap = (v) => (Math.abs(v) < 1e-12 ? 0 : v);\nconst co = snap(Math.cos(ang)), si = snap(Math.sin(ang));\nconst sName = (val) => {\n const key = (val + 0).toFixed(4);\n const m = { \"0.0000\": \"0\", \"0.5000\": \"1/2\", \"-0.5000\": \"-1/2\", \"0.7071\": \"√2/2\", \"-0.7071\": \"-√2/2\", \"0.8660\": \"√3/2\", \"-0.8660\": \"-√3/2\", \"1.0000\": \"1\", \"-1.0000\": \"-1\" };\n return m[key] || val.toFixed(3);\n};\nH.text(\"Exact trig values on the unit circle\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"θ = \" + deg + \"° cos θ = \" + sName(co) + \" sin θ = \" + sName(si), 24, 52, { color: H.colors.sub, size: 14 });\nH.text(\"(\" + sName(co) + \", \" + sName(si) + \")\", px + 10, py - 8, { color: H.colors.warn, size: 13 });\nH.legend([{ label: \"cos θ (x)\", color: H.colors.accent }, { label: \"sin θ (y)\", color: H.colors.good }], H.W - 160, 28);" + }, + { + "id": "pc-exponential-logarithmic-modeling", + "area": "Precalculus", + "topic": "Exponential/logarithmic modeling", + "title": "Growth model: y = A·e^(r·x)", + "equation": "y = A * e^(r*x)", + "keywords": [ + "exponential modeling", + "logarithmic modeling", + "growth rate", + "decay", + "continuous growth", + "half life", + "doubling time", + "a e^(rx)", + "solve for time", + "logarithm", + "compound", + "model" + ], + "explanation": "Continuous growth multiplies by a fixed percentage every unit of time, giving y = A·e^(r·x) where A is the starting amount and r is the rate. Drag r to see how a few percent reshapes the whole curve — positive r grows, negative r decays. The real power move is running the model backwards: to find WHEN the quantity reaches a target, you take a logarithm, x = ln(target/A)/r, marked by the green point where the curve meets the dashed target line. The violet dot sweeps forward in time so you can watch the value climb toward it.", + "bullets": [ + "A is the value at x = 0; r is the continuous rate (here shown in % per unit).", + "r > 0 grows, r < 0 decays — small changes in r compound into big differences.", + "To solve for time, invert with a log: x = ln(target / A) / r." + ], + "params": [ + { + "name": "A", + "label": "start A", + "min": 0.5, + "max": 8, + "step": 0.5, + "value": 2 + }, + { + "name": "r", + "label": "rate % r", + "min": -30, + "max": 30, + "step": 1, + "value": 15 + }, + { + "name": "target", + "label": "target value", + "min": 1, + "max": 12, + "step": 0.5, + "value": 8 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 12, yMin: 0, yMax: 12 });\nv.grid(); v.axes();\nconst A = P.A, r = P.r;\nconst f = x => A * Math.exp(r * x / 100);\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst target = P.target;\nv.line(0, target, 12, target, { color: H.colors.warn, width: 1.5, dash: [5, 5] });\nlet tstar = NaN;\nif (target > 0 && A > 0 && Math.abs(r) > 1e-6 && (target / A) > 0) {\n tstar = 100 * Math.log(target / A) / r;\n}\nif (Number.isFinite(tstar) && tstar >= 0 && tstar <= 12) {\n v.dot(tstar, target, { r: 6, fill: H.colors.good });\n}\nconst xs = (t * 1.0) % 12;\nv.dot(xs, f(xs), { r: 6, fill: H.colors.violet });\nH.text(\"y = A · e^(r·x) (r as %/unit)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"A = \" + A.toFixed(1) + \" r = \" + r.toFixed(0) + \"% value@x = \" + f(xs).toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(Number.isFinite(tstar) ? \"reach \" + target.toFixed(1) + \" when x = log: \" + tstar.toFixed(2) : \"target not reachable\", 24, 74, { color: H.colors.good, size: 13 });" + }, + { + "id": "pc-finite-infinite-series", + "area": "Precalculus", + "topic": "Finite and infinite series", + "title": "Series: partial sums and S_∞ = a/(1−r)", + "equation": "S_N = a*(1 - r^N)/(1 - r), S_inf = a/(1 - r) when |r| < 1", + "keywords": [ + "series", + "partial sum", + "infinite series", + "geometric series", + "convergence", + "sum to infinity", + "finite series", + "s_n", + "a/(1-r)", + "diverge", + "converge", + "summation" + ], + "explanation": "A series adds up the terms of a sequence; the partial sum S_N is the running total after N terms, drawn here as a climbing staircase. For a geometric series with |r| < 1 the terms shrink fast enough that the staircase levels off at the limit S_∞ = a/(1−r) (the dashed line). Push |r| toward 1 and the steps barely shrink, so the staircase keeps rising — the infinite sum diverges. Slide N to see how many terms it takes to get close to the limit.", + "bullets": [ + "A finite series stops at N terms: S_N = a(1 − r^N)/(1 − r).", + "If |r| < 1, partial sums converge to S_∞ = a/(1 − r).", + "If |r| ≥ 1 the terms don't shrink, so the sum has no finite value." + ], + "params": [ + { + "name": "a", + "label": "first term a", + "min": 1, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "r", + "label": "ratio r", + "min": -0.9, + "max": 0.9, + "step": 0.05, + "value": 0.5 + }, + { + "name": "N", + "label": "terms N", + "min": 1, + "max": 12, + "step": 1, + "value": 8 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 13, yMin: 0, yMax: 10 });\nv.grid(); v.axes();\nconst a = P.a, r = H.clamp(P.r, -0.95, 0.95), N = Math.round(H.clamp(P.N, 1, 12));\nconst term = (n) => a * Math.pow(r, n - 1);\nconst partial = (k) => Math.abs(1 - r) < 1e-9 ? a * k : a * (1 - Math.pow(r, k)) / (1 - r);\nconst Sinf = Math.abs(r) < 1 ? a / (1 - r) : NaN;\nif (Number.isFinite(Sinf)) {\n v.line(0, H.clamp(Sinf, 0, 9.8), 13, H.clamp(Sinf, 0, 9.8), { color: H.colors.good, width: 2, dash: [6, 6] });\n}\nconst stair = [[0, 0]];\nfor (let k = 1; k <= N; k++) {\n const y = H.clamp(partial(k), 0, 9.8);\n stair.push([k, H.clamp(partial(k - 1), 0, 9.8)]);\n stair.push([k, y]);\n}\nv.path(stair, { color: H.colors.accent, width: 2.5 });\nfor (let k = 1; k <= N; k++) {\n v.dot(k, H.clamp(partial(k), 0, 9.8), { r: 4, fill: H.colors.accent });\n}\nconst kk = 1 + Math.floor((t * 1.0) % N);\nv.dot(kk, H.clamp(partial(kk), 0, 9.8), { r: 8, fill: H.colors.warn });\nH.text(\"Series: S_N = a + a·r + ... + a·r^(N−1)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst tail = Number.isFinite(Sinf) ? \" S_∞ = a/(1−r) = \" + Sinf.toFixed(2) : \" |r| ≥ 1 → diverges\";\nH.text(\"a = \" + a.toFixed(1) + \" r = \" + r.toFixed(2) + \" S_\" + kk + \" = \" + partial(kk).toFixed(3) + tail, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(Number.isFinite(Sinf) ? \"partial sums climb the staircase toward the dashed limit\" : \"terms don't shrink → the staircase runs off, no sum\", 24, H.H - 16, { color: H.colors.good, size: 12 });\nH.legend([{ label: \"partial sum S_k\", color: H.colors.accent }, { label: \"limit S_∞\", color: H.colors.good }], H.W - 200, 28);" + }, + { + "id": "pc-identify-conic-from-equation", + "area": "Precalculus", + "topic": "Identifying conics from equations", + "title": "Which conic? A·x² + C·y² = 4", + "equation": "A*x^2 + C*y^2 = 4", + "keywords": [ + "identify conic", + "identifying conics", + "classify conic", + "conic from equation", + "circle ellipse parabola hyperbola", + "which conic", + "discriminant", + "general conic equation", + "ax^2 + cy^2", + "recognize conic", + "same sign opposite sign" + ], + "explanation": "You can name a conic from the signs of the squared-term coefficients before graphing it. With A·x² + C·y² = 4: if A and C are equal you get a circle, if they share a sign (but differ) an ellipse, if their signs are opposite a hyperbola, and if one of them is zero a parabola. Slide A and C across zero and watch the same equation morph from oval to open branches — the verdict updates live and is color-matched to the drawn curve.", + "bullets": [ + "Same sign on x² and y² → ellipse (equal coefficients → circle).", + "Opposite signs → hyperbola; one coefficient zero → parabola.", + "The product A·C is the quick test: positive, negative, or zero." + ], + "params": [ + { + "name": "A", + "label": "x² coefficient A", + "min": -2, + "max": 2, + "step": 0.5, + "value": 1 + }, + { + "name": "C", + "label": "y² coefficient C", + "min": -2, + "max": 2, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst A = P.A, C = P.C;\nconst k = 4;\nlet kind, col;\nif (Math.abs(A) < 1e-6 && Math.abs(C) < 1e-6) { kind = \"degenerate (no x², y²)\"; col = H.colors.sub; }\nelse if (Math.abs(A) < 1e-6 || Math.abs(C) < 1e-6) { kind = \"Parallel lines (one square missing)\"; col = H.colors.good; }\nelse if (A * C < 0) { kind = \"Hyperbola\"; col = H.colors.warn; }\nelse if (Math.abs(A - C) < 1e-6) { kind = \"Circle\"; col = H.colors.yellow; }\nelse { kind = \"Ellipse\"; col = H.colors.accent; }\n// Solve A·x² + C·y² = 4 honestly for each case.\nif (Math.abs(C) > 1e-6 && Math.abs(A) > 1e-6) {\n // both squared terms present: ellipse / circle / hyperbola — y = ±√((k - A x²)/C)\n const pts = [], pts2 = [];\n for (let i = 0; i <= 240; i++) {\n const x = -8 + (16 * i) / 240;\n const rhs = (k - A * x * x) / C;\n if (rhs >= 0) pts.push([x, Math.sqrt(rhs)]);\n }\n for (let i = 240; i >= 0; i--) {\n const x = -8 + (16 * i) / 240;\n const rhs = (k - A * x * x) / C;\n if (rhs >= 0) pts2.push([x, -Math.sqrt(rhs)]);\n }\n if (pts.length) v.path(pts, { color: col, width: 3 });\n if (pts2.length) v.path(pts2, { color: col, width: 3 });\n} else if (Math.abs(C) > 1e-6) {\n // A = 0: C·y² = 4 → y = ±√(4/C): two horizontal lines (or empty if C<0)\n const q = k / C;\n if (q >= 0) {\n const yv = Math.sqrt(q);\n v.line(-8, yv, 8, yv, { color: col, width: 3 });\n v.line(-8, -yv, 8, -yv, { color: col, width: 3 });\n }\n} else if (Math.abs(A) > 1e-6) {\n // C = 0: A·x² = 4 → x = ±√(4/A): two vertical lines (or empty if A<0)\n const q = k / A;\n if (q >= 0) {\n const xv = Math.sqrt(q);\n v.line(xv, -6, xv, 6, { color: col, width: 3 });\n v.line(-xv, -6, -xv, 6, { color: col, width: 3 });\n }\n}\n// tracer point that genuinely satisfies the equation\nconst xs = 6 * Math.sin(t * 0.6);\nlet ys = 0, show = false;\nif (Math.abs(C) > 1e-6) { const rhs = (k - A * xs * xs) / C; if (rhs >= 0) { ys = Math.sqrt(rhs); show = true; } }\nif (show) v.dot(xs, H.clamp(ys, -5.5, 5.5), { r: 6, fill: H.colors.accent2 });\nH.text(\"A·x² + C·y² = 4 → what conic?\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"A = \" + A.toFixed(1) + \" C = \" + C.toFixed(1) + \" A·C = \" + (A * C).toFixed(2) + \" → \" + kind, 24, 52, { color: col, size: 14, weight: 700 });\nH.text(\"equal & same sign → circle · same sign → ellipse · opposite → hyperbola · one is 0 → two parallel lines\", 24, H.H - 16, { color: H.colors.sub, size: 12 });" + }, + { + "id": "pc-inverse-functions-with-restrictions", + "area": "Precalculus", + "topic": "Inverse functions with restrictions", + "title": "Restricted inverse: y = (x - h)^2, x >= h", + "equation": "f(x) = (x - h)^2 for x >= h, f^-1(x) = sqrt(x) + h", + "keywords": [ + "inverse functions with restrictions", + "inverse function", + "restricted domain", + "one to one", + "reflection over y=x", + "horizontal line test", + "invertible", + "undo function", + "square root inverse", + "mirror y=x" + ], + "explanation": "A full parabola fails the horizontal line test, so it has no inverse - two x's share each y. By restricting the domain to x >= h (right of the green line) we keep only the rising half, which is one-to-one and CAN be inverted. The inverse is the mirror image across the dashed line y = x: every point (x, y) on f reflects to (y, x) on f-inverse. Drag h to slide both the restriction and its mirrored inverse together.", + "bullets": [ + "Restricting to x >= h makes f one-to-one, so an inverse function exists.", + "f and its inverse are reflections of each other across the line y = x.", + "Swapping a point's coordinates (x, y) -> (y, x) lands you on the inverse." + ], + "params": [ + { + "name": "h", + "label": "restrict at h", + "min": 0, + "max": 4, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -2, xMax: 10, yMin: -2, yMax: 10 });\nv.grid(); v.axes();\nconst h = P.h;\nconst f = (x) => (x >= h ? (x - h) * (x - h) : NaN);\nconst finv = (x) => (x >= 0 ? Math.sqrt(x) + h : NaN);\nv.line(-2, -2, 10, 10, { color: H.colors.violet, width: 1.5, dash: [5, 5] });\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.fn(finv, { color: H.colors.accent2, width: 3 });\nv.line(h, -2, h, 10, { color: H.colors.good, width: 1, dash: [3, 5] });\nconst s = 0.5 + 0.5 * Math.sin(t * 0.7);\nconst xs = h + 4 * s;\nconst ys = f(xs);\nv.dot(xs, ys, { r: 6, fill: H.colors.warn });\nv.dot(ys, xs, { r: 6, fill: H.colors.warn });\nv.line(xs, ys, ys, xs, { color: H.colors.sub, width: 1, dash: [2, 4] });\nH.text(\"Restrict x >= h, then invert: y = (x-h)^2\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"domain restricted to x >= \" + h.toFixed(1) + \" so the inverse is a function (mirror over y = x)\", 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"f (restricted)\", color: H.colors.accent }, { label: \"f inverse\", color: H.colors.accent2 }, { label: \"y = x\", color: H.colors.violet }], H.W - 170, 28);" + }, + { + "id": "pc-inverse-trig-equations", + "area": "Precalculus", + "topic": "Inverse trig equations", + "title": "Inverse trig: solve sin(x) = k", + "equation": "sin(x) = k -> x = asin(k) + 2*pi*n and x = pi - asin(k) + 2*pi*n", + "keywords": [ + "inverse trig", + "inverse trig equations", + "arcsin", + "asin", + "solve sin x = k", + "trig equation", + "general solution", + "principal value", + "reference angle", + "sine equation", + "solve for x trig", + "inverse sine" + ], + "explanation": "The calculator's asin(k) gives only ONE angle (the principal value between -pi/2 and pi/2), but sin(x) = k has infinitely many solutions because the sine curve crosses the line y = k over and over. Slide k up and down to move the horizontal line; every place it cuts the sine curve inside your chosen window [lo, hi] is a valid solution. That is why you must add the second branch pi - asin(k) and then the +2*pi copies, instead of trusting one button press.", + "bullets": [ + "asin(k) returns just the principal value; sin(x)=k has infinitely many solutions.", + "Each crossing of the line y=k with y=sin x in your window is one solution.", + "The two families are asin(k)+2*pi*n and pi-asin(k)+2*pi*n." + ], + "params": [ + { + "name": "k", + "label": "target k", + "min": -1, + "max": 1, + "step": 0.05, + "value": 0.5 + }, + { + "name": "lo", + "label": "window low", + "min": -1, + "max": 2, + "step": 0.5, + "value": 0 + }, + { + "name": "hi", + "label": "window high", + "min": 3, + "max": 7, + "step": 0.5, + "value": 7 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 7, yMin: -1.4, yMax: 1.4 });\nv.grid(); v.axes();\nconst k = P.k, lo = P.lo, hi = P.hi;\nconst kk = Math.max(-1, Math.min(1, k));\nv.fn(x => Math.sin(x), { color: H.colors.accent, width: 3 });\nv.line(-1, kk, 7, kk, { color: H.colors.violet, width: 2, dash: [6, 6] });\nconst a = Math.asin(kk);\nconst sols = [a, Math.PI - a, a + 2 * Math.PI, 3 * Math.PI - a];\nlet count = 0;\nfor (let i = 0; i < sols.length; i++) {\n const xs = sols[i];\n if (xs >= lo && xs <= hi && xs >= -1 && xs <= 7) {\n v.dot(xs, kk, { r: 6, fill: H.colors.good });\n count++;\n }\n}\nconst sweep = lo + ((t * 0.7) % Math.max(0.001, (hi - lo)));\nv.dot(sweep, Math.sin(sweep), { r: 6, fill: H.colors.warn });\nv.line(lo, -1.4, lo, 1.4, { color: H.colors.sub, width: 1, dash: [3, 3] });\nv.line(hi, -1.4, hi, 1.4, { color: H.colors.sub, width: 1, dash: [3, 3] });\nH.text(\"Solve sin(x) = k on [lo, hi]\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"k = \" + kk.toFixed(2) + \" principal asin = \" + a.toFixed(2) + \" rad solutions in window: \" + count, 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"y = sin x\", color: H.colors.accent }, { label: \"y = k\", color: H.colors.violet }, { label: \"solutions\", color: H.colors.good }], H.W - 160, 28);" + }, + { + "id": "pc-inverse-trig-functions", + "area": "Precalculus", + "topic": "Inverse trig functions", + "title": "Inverse trig: y = arcsin(x)", + "equation": "y = arcsin(x), -pi/2 <= y <= pi/2", + "keywords": [ + "inverse trig", + "arcsin", + "arcsine", + "inverse sine", + "asin", + "inverse trig functions", + "principal branch", + "restricted domain", + "reflection across y=x", + "range of arcsin", + "inverse function", + "sin inverse" + ], + "explanation": "Sine isn't one-to-one, so to invert it we keep only its principal branch — the slice of sin(x) on [−π/2, π/2] (blue). Reflecting that branch across the dashed line y = x produces arcsin (orange), which answers 'what angle in [−π/2, π/2] has this sine?'. Slide x to pick an input in [−1, 1]: the orange dot is (x, arcsin x) and its blue mirror is (arcsin x, x) — the green dashed link shows they are the same point with coordinates swapped, which is exactly what an inverse does.", + "bullets": [ + "arcsin is sine reflected across y = x — inputs and outputs swap roles.", + "Sine must be restricted to [−π/2, π/2] first so the inverse is single-valued.", + "Domain of arcsin is [−1, 1]; its range (the principal branch) is [−π/2, π/2]." + ], + "params": [ + { + "name": "x", + "label": "input x", + "min": -1, + "max": 1, + "step": 0.05, + "value": 0.5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -2.2, xMax: 2.2, yMin: -2.2, yMax: 2.2 });\nv.grid(); v.axes();\nconst xq = Math.max(-1, Math.min(1, P.x)); // query value in [-1, 1]\n// mirror line y = x\nv.line(-2.2, -2.2, 2.2, 2.2, { color: H.colors.violet, width: 1.4, dash: [5, 5] });\n// sin restricted to its principal domain [-π/2, π/2]\nconst hp = Math.PI / 2;\nconst sinPts = [];\nfor (let i = 0; i <= 80; i++) { const x = -hp + (2 * hp) * i / 80; sinPts.push([x, Math.sin(x)]); }\nv.path(sinPts, { color: H.colors.accent, width: 2.6 });\n// arcsin = reflection of that branch across y = x\nconst asinPts = [];\nfor (let i = 0; i <= 80; i++) { const x = -1 + 2 * i / 80; asinPts.push([x, Math.asin(x)]); }\nv.path(asinPts, { color: H.colors.accent2, width: 2.6 });\n// the inverse pairing: (xq, arcsin xq) on the orange curve, mirror (arcsin xq, xq) on blue\nconst y = Math.asin(xq);\nv.dot(xq, y, { r: 6, fill: H.colors.accent2 });\nv.dot(y, xq, { r: 6, fill: H.colors.accent });\nv.line(xq, y, y, xq, { color: H.colors.good, width: 1.4, dash: [3, 3] });\n// a sweeping query point riding the input axis, looping in [-1, 1]\nconst xs = Math.sin(t * 0.7);\nv.dot(xs, Math.asin(xs), { r: 5, fill: H.colors.warn });\nH.text(\"Inverse trig: y = arcsin(x)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"x = \" + xq.toFixed(2) + \" arcsin x = \" + y.toFixed(2) + \" rad (range −π/2…π/2)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"sin (restricted)\", color: H.colors.accent }, { label: \"arcsin\", color: H.colors.accent2 }, { label: \"y = x\", color: H.colors.violet }], H.W - 200, 28);" + }, + { + "id": "pc-law-of-cosines", + "area": "Precalculus", + "topic": "Law of cosines", + "title": "Law of Cosines: c^2 = a^2 + b^2 - 2ab*cos(C)", + "equation": "c^2 = a^2 + b^2 - 2*a*b*cos(C)", + "keywords": [ + "law of cosines", + "cosine rule", + "c squared", + "sas", + "sss", + "find third side", + "included angle", + "generalized pythagorean", + "oblique triangle", + "solve triangle two sides angle", + "minus 2ab cos c" + ], + "explanation": "The Law of Cosines finds the third side when you know two sides and the angle squeezed between them (SAS). Drag sides a and b and the included angle C: the triangle is rebuilt and side c is recomputed from the formula. The crucial insight is the -2ab*cos(C) correction term -- when C = 90 degrees its cosine is zero and the whole thing collapses back to the Pythagorean theorem, so this formula is just Pythagoras generalized to any angle.", + "bullets": [ + "Use it for SAS (two sides + included angle) or SSS (all three sides).", + "The angle C must be the one BETWEEN the two known sides a and b.", + "At C = 90 deg, cos C = 0 and it becomes c^2 = a^2 + b^2." + ], + "params": [ + { + "name": "a", + "label": "side a", + "min": 1, + "max": 7, + "step": 0.5, + "value": 5 + }, + { + "name": "b", + "label": "side b", + "min": 1, + "max": 6, + "step": 0.5, + "value": 4 + }, + { + "name": "C", + "label": "included angle C (deg)", + "min": 20, + "max": 160, + "step": 1, + "value": 60 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -1, yMax: 8 });\nv.grid(); v.axes();\nconst a = Math.max(1, P.a);\nconst b = Math.max(1, P.b);\nconst Cdeg = Math.max(5, Math.min(170, P.C));\nconst C = Cdeg * Math.PI / 180;\nconst cc = Math.sqrt(Math.max(0, a * a + b * b - 2 * a * b * Math.cos(C)));\nconst Cv = [0, 0];\nconst Bv = [a, 0];\nconst Av = [b * Math.cos(C), b * Math.sin(C)];\nv.path([Av, Bv, Cv], { color: H.colors.accent, width: 3, close: true });\nv.dot(Av[0], Av[1], { r: 5, fill: H.colors.warn });\nv.dot(Bv[0], Bv[1], { r: 5, fill: H.colors.warn });\nv.dot(Cv[0], Cv[1], { r: 5, fill: H.colors.warn });\nv.text(\"C\", Cv[0] - 0.4, -0.3, { color: H.colors.ink, size: 14 });\nv.text(\"A\", Av[0] - 0.2, Av[1] + 0.4, { color: H.colors.ink, size: 14 });\nv.text(\"B\", Bv[0] + 0.2, -0.3, { color: H.colors.ink, size: 14 });\nv.text(\"a\", a / 2, -0.4, { color: H.colors.accent2, size: 13 });\nv.text(\"b\", (Av[0]) / 2 - 0.3, Av[1] / 2 + 0.1, { color: H.colors.accent2, size: 13 });\nv.text(\"c\", (Av[0] + Bv[0]) / 2 + 0.2, Av[1] / 2, { color: H.colors.good, size: 13 });\nv.line(Av[0], Av[1], Bv[0], Bv[1], { color: H.colors.good, width: 3 });\nconst arcR = 0.9;\nconst pts = [];\nconst a0 = Math.atan2(Av[1], Av[0]);\nfor (let i = 0; i <= 30; i++) { const th = (a0) * i / 30; pts.push([arcR * Math.cos(th), arcR * Math.sin(th)]); }\nv.path(pts, { color: H.colors.violet, width: 2 });\nconst swp = a0 * (0.5 + 0.5 * Math.sin(t));\nv.dot(arcR * Math.cos(swp), arcR * Math.sin(swp), { r: 5, fill: H.colors.violet });\nH.text(\"Law of Cosines: c² = a² + b² − 2ab·cos C\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"a=\" + a.toFixed(2) + \" b=\" + b.toFixed(2) + \" C=\" + Cdeg.toFixed(0) + \"° -> c = \" + cc.toFixed(3), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"(C = 90° gives the Pythagorean theorem)\", 24, 74, { color: H.colors.violet, size: 12 });" + }, + { + "id": "pc-law-of-sines", + "area": "Precalculus", + "topic": "Law of sines", + "title": "Law of Sines: a/sin A = b/sin B = c/sin C", + "equation": "a / sin(A) = b / sin(B) = c / sin(C)", + "keywords": [ + "law of sines", + "sine rule", + "a over sin a", + "solve triangle", + "aas", + "asa", + "oblique triangle", + "trigonometry triangle", + "ratio of side to sine", + "find missing side angle", + "non right triangle" + ], + "explanation": "In ANY triangle each side and the angle facing it share one common ratio: side divided by the sine of the opposite angle. Drag angles A and B (which fixes C, since the three add to 180 degrees) and the one known side a; the triangle is rebuilt and the other two sides are computed straight from that ratio. The point is that the side-to-sine ratio printed at the bottom is identical for all three pairs, which is what lets you find a missing side or angle.", + "bullets": [ + "Each side pairs with the angle directly across from it.", + "Side/sin(opposite angle) is the SAME value for all three pairs.", + "Use it when you know two angles and a side (AAS/ASA), or SSA." + ], + "params": [ + { + "name": "A", + "label": "angle A (deg)", + "min": 20, + "max": 120, + "step": 1, + "value": 50 + }, + { + "name": "B", + "label": "angle B (deg)", + "min": 20, + "max": 120, + "step": 1, + "value": 60 + }, + { + "name": "a", + "label": "side a", + "min": 2, + "max": 7, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -1, yMax: 8 });\nv.grid(); v.axes();\nconst A = Math.max(10, P.A) * Math.PI / 180;\nconst B = Math.max(10, P.B) * Math.PI / 180;\nconst a = Math.max(1, P.a);\nconst C = Math.max(0.05, Math.PI - A - B);\nconst ratio = a / Math.sin(A);\nconst b = ratio * Math.sin(B);\nconst c = ratio * Math.sin(C);\nconst Av = [0, 0];\nconst Bv = [c, 0];\nconst Cv = [b * Math.cos(A), b * Math.sin(A)];\nv.path([Av, Bv, Cv], { color: H.colors.accent, width: 3, close: true });\nv.dot(Av[0], Av[1], { r: 5, fill: H.colors.warn });\nv.dot(Bv[0], Bv[1], { r: 5, fill: H.colors.warn });\nv.dot(Cv[0], Cv[1], { r: 5, fill: H.colors.warn });\nv.text(\"A\", Av[0] - 0.4, Av[1] - 0.3, { color: H.colors.ink, size: 14 });\nv.text(\"B\", Bv[0] + 0.2, Bv[1] - 0.3, { color: H.colors.ink, size: 14 });\nv.text(\"C\", Cv[0] + 0.1, Cv[1] + 0.4, { color: H.colors.ink, size: 14 });\nv.text(\"a\", (Bv[0] + Cv[0]) / 2 + 0.2, (Bv[1] + Cv[1]) / 2, { color: H.colors.accent2, size: 13 });\nv.text(\"b\", (Av[0] + Cv[0]) / 2 - 0.4, (Av[1] + Cv[1]) / 2, { color: H.colors.accent2, size: 13 });\nv.text(\"c\", (Av[0] + Bv[0]) / 2, Av[1] - 0.4, { color: H.colors.accent2, size: 13 });\nconst sweep = 0.5 + 0.5 * Math.sin(t);\nv.dot(Av[0] + (Cv[0] - Av[0]) * sweep, Av[1] + (Cv[1] - Av[1]) * sweep, { r: 6, fill: H.colors.good });\nH.text(\"Law of Sines: a/sin A = b/sin B = c/sin C\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"A=\" + (A * 180 / Math.PI).toFixed(0) + \" B=\" + (B * 180 / Math.PI).toFixed(0) + \" C=\" + (C * 180 / Math.PI).toFixed(0) + \" a=\" + a.toFixed(2) + \" b=\" + b.toFixed(2) + \" c=\" + c.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"common ratio = \" + ratio.toFixed(2), 24, 74, { color: H.colors.good, size: 13 });" + }, + { + "id": "pc-limits-graphs-tables", + "area": "Precalculus", + "topic": "Limits from graphs and tables", + "title": "Limit from a graph: lim (x->a) f(x) = L", + "equation": "lim (x -> a) f(x) = L", + "keywords": [ + "limit", + "limits", + "limit from graph", + "limit from table", + "approaching", + "one sided limit", + "left limit", + "right limit", + "removable discontinuity", + "hole in graph", + "lim x to a", + "value the function approaches" + ], + "explanation": "A limit asks: as x gets arbitrarily close to a (without necessarily equaling a), what single height L does f(x) head toward? The a slider moves the test point, L sets the height the curve approaches, and gap sets how far out the two probe points start before they slide inward. Watch the left (green) and right (orange) probes close in on the open hole: their f-values both squeeze toward L, which is the limit even though the function itself has a hole there.", + "bullets": [ + "The limit is the height the curve approaches, NOT necessarily f(a) (here there's a hole).", + "Both one-sided limits must agree on the same L for the two-sided limit to exist.", + "A table that lists f(x) for x ever closer to a converges to the same L the graph shows." + ], + "params": [ + { + "name": "a", + "label": "approach point a", + "min": -3, + "max": 3, + "step": 0.5, + "value": 1 + }, + { + "name": "L", + "label": "limit value L", + "min": 1, + "max": 6, + "step": 0.5, + "value": 3 + }, + { + "name": "gap", + "label": "start distance", + "min": 0.5, + "max": 3, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\nconst a = P.a, L = P.L, gap = P.gap;\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -2, yMax: 8 });\nv.grid(); v.axes();\n// A function approaching height L at x=a from both sides, but with a hole\n// (removable point) at x=a. We use a smooth curve that passes through (a, L).\nfunction f(x){ return L + 0.6 * (x - a) + 0.25 * (x - a) * (x - a); }\nv.fn(x => (Math.abs(x - a) < 0.04 ? NaN : f(x)), { color: H.colors.accent, width: 3 });\n// Dashed guide lines to the limit value.\nv.line(-6, L, 6, L, { color: H.colors.violet, width: 1, dash: [4, 4] });\nv.line(a, -2, a, 8, { color: H.colors.violet, width: 1, dash: [4, 4] });\n// Open hole at (a, L): the limit, not the (possibly undefined) value.\nv.circle(a, L, 6, { stroke: H.colors.warn, width: 2.5, fill: H.colors.bg });\n// Animate two probe points sliding IN toward x=a from both sides.\nconst d = gap * (0.5 + 0.5 * Math.cos(t * 1.2)); // oscillates between ~0 and gap\nconst xl = a - d, xr = a + d;\nv.dot(xl, f(xl), { r: 6, fill: H.colors.good });\nv.dot(xr, f(xr), { r: 6, fill: H.colors.accent2 });\nv.line(xl, f(xl), xl, L, { color: H.colors.good, width: 1, dash: [3, 3] });\nv.line(xr, f(xr), xr, L, { color: H.colors.accent2, width: 1, dash: [3, 3] });\nH.text(\"Limit from a graph: lim (x->a) f(x) = L\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"a = \" + a.toFixed(1) + \" L = \" + L.toFixed(1) + \" |x-a| = \" + d.toFixed(3), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"f(\" + xl.toFixed(2) + \")=\" + f(xl).toFixed(3) + \" f(\" + xr.toFixed(2) + \")=\" + f(xr).toFixed(3), 24, 74, { color: H.colors.good, size: 12 });\nH.legend([{ label: \"from left\", color: H.colors.good }, { label: \"from right\", color: H.colors.accent2 }, { label: \"L (limit)\", color: H.colors.violet }], H.W - 150, 28);" + }, + { + "id": "pc-nonlinear-inequalities", + "area": "Precalculus", + "topic": "Nonlinear inequalities", + "title": "Solve (x − r1)(x − r2) ≤ c", + "equation": "(x - r1)*(x - r2) <= c", + "keywords": [ + "nonlinear inequality", + "quadratic inequality", + "polynomial inequality", + "solution set", + "interval", + "sign chart", + "solve inequality", + "less than", + "shaded region", + "test points", + "where below" + ], + "explanation": "Solving a nonlinear inequality means finding every x where the curve sits at or below a cutoff height c. The shaded blue columns mark exactly that solution set — drag c up or down (the dashed line) and watch the band of valid x widen or vanish. Moving the roots r1, r2 reshapes the parabola, which slides the endpoints of the interval where it dips under the line. The traveling dot turns green only while it is inside the solution set, so you can feel the boundary as it passes through.", + "bullets": [ + "The solution is the set of x where the curve lies at/below the line y = c.", + "Boundaries occur where (x − r1)(x − r2) = c — solve that equation for the endpoints.", + "Raising c grows the interval; if the parabola never reaches c, there is no solution." + ], + "params": [ + { + "name": "r1", + "label": "root r1", + "min": -5, + "max": 5, + "step": 0.5, + "value": -2 + }, + { + "name": "r2", + "label": "root r2", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "c", + "label": "cutoff c", + "min": -6, + "max": 8, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -8, yMax: 10 });\nv.grid(); v.axes();\nconst r1 = Math.min(P.r1, P.r2), r2 = Math.max(P.r1, P.r2);\nconst c = P.c;\nconst f = x => (x - r1) * (x - r2);\nfor (let i = 0; i <= 60; i++) {\n const x = -6 + i * 12 / 60;\n const y = f(x);\n if (y <= c) {\n v.line(x, -8, x, 10, { color: \"rgba(124,196,255,0.18)\", width: 3 });\n }\n}\nv.line(-6, c, 6, c, { color: H.colors.warn, width: 1.5, dash: [5, 5] });\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst xs = 4.5 * Math.sin(t * 0.6);\nconst inSet = f(xs) <= c;\nv.dot(xs, f(xs), { r: 6, fill: inSet ? H.colors.good : H.colors.violet });\nH.text(\"solve (x − r1)(x − r2) ≤ c\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst disc = (r1 + r2) * (r1 + r2) - 4 * (r1 * r2 - c);\nlet band = \"no x satisfies it\";\nif (disc >= 0) {\n const lo = ((r1 + r2) - Math.sqrt(disc)) / 2, hi = ((r1 + r2) + Math.sqrt(disc)) / 2;\n band = \"solution: \" + lo.toFixed(2) + \" ≤ x ≤ \" + hi.toFixed(2);\n}\nH.text(\"cutoff c = \" + c.toFixed(1) + \" \" + band, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(inSet ? \"dot: IN the solution set\" : \"dot: outside\", 24, 74, { color: inSet ? H.colors.good : H.colors.violet, size: 13 });" + }, + { + "id": "pc-parametric-equations", + "area": "Precalculus", + "topic": "Parametric equations", + "title": "Parametric curve: x = a cos(p s), y = b sin(q s)", + "equation": "x = a * cos(p * s), y = b * sin(q * s)", + "keywords": [ + "parametric equations", + "parameter", + "parametric curve", + "x of t", + "y of t", + "lissajous", + "trace a curve", + "parametrize", + "param", + "x(s) y(s)", + "parametric" + ], + "explanation": "A parametric curve uses a single parameter s to drive BOTH coordinates at once: as s ticks forward, the point (x(s), y(s)) traces a path that a plain y = f(x) graph often cannot. a and b stretch the curve horizontally and vertically; the whole-number sliders p and q set how many times x and y oscillate per loop, which controls how many lobes the figure has. The dashed lines show the current x and y being read off separately, and the moving dot is the point you get by combining them.", + "bullets": [ + "One parameter s feeds two equations, so x and y move together as s advances.", + "a and b scale the curve; the ratio of p to q decides the loop/lobe pattern.", + "Parametric form can draw paths (loops, figure-eights) that fail the vertical-line test." + ], + "params": [ + { + "name": "a", + "label": "x amplitude a", + "min": 1, + "max": 6, + "step": 0.5, + "value": 5 + }, + { + "name": "b", + "label": "y amplitude b", + "min": 1, + "max": 4, + "step": 0.5, + "value": 4 + }, + { + "name": "p", + "label": "x frequency p", + "min": 1, + "max": 5, + "step": 1, + "value": 1 + }, + { + "name": "q", + "label": "y frequency q", + "min": 1, + "max": 5, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -7, xMax: 7, yMin: -5, yMax: 5 });\nv.grid(); v.axes();\nconst a = Math.max(0.5, P.a), b = Math.max(0.5, P.b), p = Math.max(1, Math.round(P.p)), q = Math.max(1, Math.round(P.q));\n// Lissajous-style parametric curve x = a cos(p s), y = b sin(q s)\nconst X = (s) => a * Math.cos(p * s);\nconst Y = (s) => b * Math.sin(q * s);\nconst pts = [];\nfor (let i = 0; i <= 240; i++) { const s = i / 240 * H.TAU; pts.push([X(s), Y(s)]); }\nv.path(pts, { color: H.colors.accent, width: 2.5 });\n// moving point traces the path as parameter s advances and loops\nconst s = (t * 0.8) % H.TAU;\nconst cx = X(s), cy = Y(s);\nv.line(cx, 0, cx, cy, { color: H.colors.good, width: 1.5, dash: [3, 3] });\nv.line(0, cy, cx, cy, { color: H.colors.accent2, width: 1.5, dash: [3, 3] });\nv.dot(cx, cy, { r: 6, fill: H.colors.warn });\nH.text(\"Parametric: x = a cos(p s), y = b sin(q s)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"s = \" + s.toFixed(2) + \" x = \" + cx.toFixed(2) + \" y = \" + cy.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"one parameter s drives BOTH coordinates\", 24, 72, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"x(s)\", color: H.colors.good }, { label: \"y(s)\", color: H.colors.accent2 }], H.W - 150, 28);" + }, + { + "id": "pc-parametric-motion", + "area": "Precalculus", + "topic": "Parametric motion problems", + "title": "Projectile motion: x = v cos(theta) t, y = v sin(theta) t - g t^2/2", + "equation": "x = v*cos(theta)*t, y = v*sin(theta)*t - (1/2)*g*t^2", + "keywords": [ + "parametric motion", + "projectile motion", + "position over time", + "trajectory", + "launch angle", + "range", + "horizontal vertical motion", + "velocity components", + "time as parameter", + "kinematics", + "motion problem" + ], + "explanation": "Motion problems use TIME as the parameter: the horizontal position grows steadily at v cos(theta) while the vertical position rises and falls under gravity. Split the launch speed into components — slide the angle and watch how a steeper angle trades horizontal range for height. The violet arrow is the live velocity vector (it tips downward as gravity slows the rise and speeds the fall), and the green dot marks where the path lands when y returns to 0. Change speed and g to see the whole arc reshape.", + "bullets": [ + "Time t is the parameter; x and y are separate functions of the same t.", + "Horizontal motion is constant (v cos theta); vertical motion is accelerated by gravity.", + "Flight time is 2 v sin(theta)/g, and range = v cos(theta) times that flight time." + ], + "params": [ + { + "name": "speed", + "label": "launch speed v", + "min": 6, + "max": 12, + "step": 0.5, + "value": 12 + }, + { + "name": "angle", + "label": "launch angle (deg)", + "min": 25, + "max": 75, + "step": 1, + "value": 55 + }, + { + "name": "g", + "label": "gravity g", + "min": 8, + "max": 13, + "step": 0.5, + "value": 9.8 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 19, yMin: -1, yMax: 9 });\nv.grid(); v.axes();\nconst spd = Math.max(1, P.speed), deg = P.angle, g = Math.max(1, P.g);\nconst ang = deg * Math.PI / 180;\nconst vx = spd * Math.cos(ang), vy = spd * Math.sin(ang);\n// projectile: x(tau) = vx*tau, y(tau) = vy*tau - 0.5 g tau^2\nconst tflight = Math.max(0.1, 2 * vy / g);\nconst X = (tau) => vx * tau;\nconst Y = (tau) => vy * tau - 0.5 * g * tau * tau;\nconst pts = [];\nfor (let i = 0; i <= 120; i++) { const tau = tflight * i / 120; pts.push([X(tau), Math.max(0, Y(tau))]); }\nv.path(pts, { color: H.colors.accent, width: 2.5 });\n// the ball flies, then the flight loops\nconst tau = (t * 1.2) % tflight;\nconst bx = X(tau), by = Math.max(0, Y(tau));\nv.arrow(bx, by, bx + vx * 0.25, by + (vy - g * tau) * 0.25, { color: H.colors.violet, width: 2 });\nv.dot(bx, by, { r: 7, fill: H.colors.warn });\nconst range = vx * tflight;\nv.dot(range, 0, { r: 5, fill: H.colors.good });\nH.text(\"Parametric motion: position over time\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"x = v*cos(theta)*t, y = v*sin(theta)*t - 0.5 g t^2\", 24, 52, { color: H.colors.sub, size: 12 });\nH.text(\"t = \" + tau.toFixed(2) + \"s x = \" + bx.toFixed(1) + \" y = \" + by.toFixed(1) + \" range = \" + range.toFixed(1), 24, 72, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"path\", color: H.colors.accent }, { label: \"velocity\", color: H.colors.violet }, { label: \"landing\", color: H.colors.good }], H.W - 180, 28);" + }, + { + "id": "pc-phase-and-vertical-shift", + "area": "Precalculus", + "topic": "Phase shift and vertical shift", + "title": "Shifts: y = sin(x − C) + D", + "equation": "y = sin(x - C) + D", + "keywords": [ + "phase shift", + "vertical shift", + "horizontal shift", + "midline", + "sine shift", + "translate sine", + "x minus c", + "shift right", + "shift up", + "sinusoid translation", + "trig graph", + "y = sin(x-c)+d" + ], + "explanation": "Shifts slide a wave without changing its shape. The phase shift C moves the whole curve horizontally — sin(x − C) shifts RIGHT by C (subtracting moves it the positive direction), shown by the orange arrow. The vertical shift D raises or lowers the entire wave; D is the midline (dashed purple line) that the wave now oscillates around. Compare the faint parent sin x to the bold shifted curve to see both moves at once.", + "bullets": [ + "x − C shifts the graph RIGHT by C (it works 'backwards' from what the sign suggests).", + "D is the midline: + D lifts the whole wave up by D, the level it oscillates about.", + "Shifts move the wave but never change its amplitude or period." + ], + "params": [ + { + "name": "C", + "label": "phase shift C", + "min": -3, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "D", + "label": "vertical shift D", + "min": -4, + "max": 4, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -Math.PI, xMax: 3 * Math.PI, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst C = P.C, D = P.D;\nconst base = (x) => Math.sin(x);\nconst shifted = (x) => Math.sin(x - C) + D;\nv.fn(base, { color: H.colors.sub, width: 1.6 });\nv.line(-Math.PI, D, 3 * Math.PI, D, { color: H.colors.violet, width: 1.4, dash: [5, 5] });\nv.fn(shifted, { color: H.colors.accent, width: 3 });\nv.dot(C, D, { r: 6, fill: H.colors.good });\nv.arrow(0, D, C, D, { color: H.colors.accent2, width: 2 });\nconst xs = -Math.PI + ((t * 0.8) % (4 * Math.PI));\nv.dot(xs, shifted(xs), { r: 6, fill: H.colors.warn });\nH.text(\"y = sin(x − C) + D\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"phase shift C = \" + C.toFixed(2) + \" (right) vertical shift D = \" + D.toFixed(2) + \" (midline)\", 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"shifted\", color: H.colors.accent }, { label: \"parent sin x\", color: H.colors.sub }, { label: \"midline y=D\", color: H.colors.violet }], H.W - 175, 28);" + }, + { + "id": "pc-polar-coordinate-plotting", + "area": "Precalculus", + "topic": "Polar coordinate plotting", + "title": "Polar plot: r = a cos(k theta) + b", + "equation": "r = a*cos(k*theta) + b", + "keywords": [ + "polar coordinates", + "polar plotting", + "r theta", + "polar curve", + "rose curve", + "limacon", + "cardioid", + "radius angle", + "polar graph", + "r = f(theta)", + "polar" + ], + "explanation": "In polar coordinates a point is given by an angle theta and a distance r from the origin, so a curve is r = f(theta) instead of y = f(x). The sweeping green ray shows the current angle, and r tells how far out along that ray the curve sits — as theta runs around the circle the radius pulses, tracing roses and limaçons. The whole-number slider k sets how many petals/lobes appear, a sets their reach, and b lifts the radius so the curve can avoid (or pass through) the origin.", + "bullets": [ + "A polar point is (r, theta): distance from the origin at a given angle.", + "k controls the number of lobes; with b = 0 an odd k gives k petals, an even k gives 2k.", + "b shifts the radius, turning a rose into a limaçon (inner loop, dimple, or cardioid)." + ], + "params": [ + { + "name": "a", + "label": "amplitude a", + "min": 0.5, + "max": 4, + "step": 0.5, + "value": 3 + }, + { + "name": "k", + "label": "lobes k", + "min": 1, + "max": 6, + "step": 1, + "value": 3 + }, + { + "name": "b", + "label": "offset b", + "min": 0, + "max": 3, + "step": 0.5, + "value": 0 + } + ], + "code": "H.background();\nconst cx = H.W * 0.5, cy = H.H * 0.54, R = Math.min(H.W, H.H) * 0.32;\nconst a = Math.max(0.2, P.a), k = Math.max(1, Math.round(P.k)), b = P.b;\n// polar curve r(theta) = a*cos(k*theta) + b (rose / limaçon family)\nconst rOf = (th) => a * Math.cos(k * th) + b;\n// scale so the largest radius fits the circle\nconst rMax = Math.max(0.5, a + Math.abs(b));\nconst sc = R / rMax;\n// faint polar grid rings + axes\nfor (let ring = 1; ring <= 3; ring++) H.circle(cx, cy, R * ring / 3, { stroke: H.colors.grid, width: 1 });\nH.line(cx - R - 14, cy, cx + R + 14, cy, { color: H.colors.axis, width: 1 });\nH.line(cx, cy - R - 14, cx, cy + R + 14, { color: H.colors.axis, width: 1 });\n// draw the curve\nconst pts = [];\nfor (let i = 0; i <= 360; i++) {\n const th = i / 360 * H.TAU;\n const r = rOf(th);\n pts.push([cx + r * sc * Math.cos(th), cy - r * sc * Math.sin(th)]);\n}\nH.path(pts, { color: H.colors.accent, width: 2.5 });\n// sweeping radial line + point at the current angle, looping\nconst th = (t * 0.7) % H.TAU;\nconst r = rOf(th);\nconst px = cx + r * sc * Math.cos(th), py = cy - r * sc * Math.sin(th);\nH.line(cx, cy, px, py, { color: H.colors.good, width: 2 });\nH.circle(px, py, 6, { fill: H.colors.warn });\nH.text(\"Polar plot: r = a cos(k theta) + b\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"theta = \" + (th * 180 / Math.PI).toFixed(0) + \" deg r = \" + r.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = \" + a.toFixed(1) + \" k = \" + k + \" petals/lobes b = \" + b.toFixed(1), 24, 72, { color: H.colors.sub, size: 13 });" + }, + { + "id": "pc-polar-equations-graphs", + "area": "Precalculus", + "topic": "Polar equations and graphs", + "title": "Polar rose: r = a·cos(k·θ)", + "equation": "r = a*cos(k*theta)", + "keywords": [ + "polar equation", + "polar graph", + "rose curve", + "polar rose", + "r = a cos", + "petals", + "polar plot", + "graphing polar", + "r as function of theta", + "polar function", + "rose petals", + "cos k theta" + ], + "explanation": "A polar equation gives the distance r for every direction θ, and the curve is the trail the point leaves as θ sweeps from 0 to 2π. For r = a·cos(kθ) you get a flower: a controls how long the petals reach, and k controls how MANY there are — odd k gives k petals, even k gives 2k. Watch the sweeping radius shrink to zero and flip sign, which is how each petal is drawn.", + "bullets": [ + "A polar curve plots r against the direction θ, not against x.", + "a is the petal length; k sets the petal count (odd k → k, even k → 2k).", + "When r goes negative the point is plotted in the opposite direction." + ], + "params": [ + { + "name": "a", + "label": "petal length a", + "min": 1, + "max": 5, + "step": 0.1, + "value": 4 + }, + { + "name": "k", + "label": "petal count k", + "min": 1, + "max": 6, + "step": 1, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst a = P.a, k = Math.max(1, Math.round(P.k));\nconst pts = [];\nconst N = 480;\nfor (let i = 0; i <= N; i++) {\n const th = i / N * H.TAU;\n const r = a * Math.cos(k * th);\n pts.push([r * Math.cos(th), r * Math.sin(th)]);\n}\nv.path(pts, { color: H.colors.accent, width: 2.5 });\nconst ph = (t * 0.6) % H.TAU;\nconst rr = a * Math.cos(k * ph);\nv.line(0, 0, rr * Math.cos(ph), rr * Math.sin(ph), { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.dot(rr * Math.cos(ph), rr * Math.sin(ph), { r: 6, fill: H.colors.warn });\nconst petals = (k % 2 === 1) ? k : 2 * k;\nH.text(\"Polar graph: r = a·cos(k·θ)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a = \" + a.toFixed(1) + \" k = \" + k + \" -> \" + petals + \" petals (θ = \" + (ph * 180 / Math.PI).toFixed(0) + \"°, r = \" + rr.toFixed(2) + \")\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"rose curve\", color: H.colors.accent }, { label: \"sweeping radius\", color: H.colors.violet }], H.W - 175, 28);" + }, + { + "id": "pc-polar-intersections", + "area": "Precalculus", + "topic": "Polar intersections", + "title": "Polar intersections: a = b(1 + cos θ)", + "equation": "a = b*(1 + cos(theta))", + "keywords": [ + "polar intersection", + "polar intersections", + "intersection of polar curves", + "where polar curves cross", + "circle and cardioid", + "solve polar equations", + "common points polar", + "polar systems", + "set r equal", + "polar crossing points", + "two polar curves", + "cardioid circle intersection" + ], + "explanation": "Two polar curves cross where they hit the SAME point — same direction θ AND same distance r. To find those points you set the two r-expressions equal: here a = b(1 + cos θ), which solves to cos θ = a/b − 1. Slide the circle radius a and the cardioid scale b and watch the pink intersection dots appear, merge, or vanish as that equation gains or loses solutions; the sweeping ray compares both r-values at one direction.", + "bullets": [ + "Curves intersect where both r AND θ match — set the r-formulas equal.", + "a = b(1 + cos θ) ⇒ cos θ = a/b − 1; only |a/b − 1| ≤ 1 gives crossings.", + "By symmetry the solutions come in ± pairs around the polar axis." + ], + "params": [ + { + "name": "a", + "label": "circle radius a", + "min": 0.5, + "max": 4, + "step": 0.1, + "value": 2 + }, + { + "name": "b", + "label": "cardioid scale b", + "min": 0.5, + "max": 2.5, + "step": 0.1, + "value": 1.5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -5, yMax: 5 });\nv.grid(); v.axes();\nconst a = P.a, b = P.b;\nconst circle = [], curve = [];\nconst N = 360;\nfor (let i = 0; i <= N; i++) {\n const th = i / N * H.TAU;\n const r1 = a;\n const r2 = b * (1 + Math.cos(th));\n circle.push([r1 * Math.cos(th), r1 * Math.sin(th)]);\n curve.push([r2 * Math.cos(th), r2 * Math.sin(th)]);\n}\nv.path(circle, { color: H.colors.accent, width: 2.5 });\nv.path(curve, { color: H.colors.accent2, width: 2.5 });\nlet count = 0;\nconst c = a / Math.max(1e-6, b) - 1;\nif (c >= -1 && c <= 1) {\n const th0 = Math.acos(c);\n [th0, -th0].forEach(th => {\n v.dot(a * Math.cos(th), a * Math.sin(th), { r: 6 + Math.sin(t * 3), fill: H.colors.warn });\n count++;\n });\n}\nconst ph = (t * 0.5) % H.TAU;\nv.line(0, 0, a * Math.cos(ph), a * Math.sin(ph), { color: H.colors.good, width: 1.5, dash: [4, 4] });\nv.dot(a * Math.cos(ph), a * Math.sin(ph), { r: 5, fill: H.colors.good });\nv.dot(b * (1 + Math.cos(ph)) * Math.cos(ph), b * (1 + Math.cos(ph)) * Math.sin(ph), { r: 5, fill: H.colors.violet });\nH.text(\"Polar intersections: set a = b(1 + cos θ)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"circle r = \" + a.toFixed(1) + \" curve r = \" + b.toFixed(1) + \"(1+cos θ) -> \" + count + \" intersection(s)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"circle r = a\", color: H.colors.accent }, { label: \"r = b(1+cos θ)\", color: H.colors.accent2 }], H.W - 185, 28);" + }, + { + "id": "pc-polar-rectangular-conversion", + "area": "Precalculus", + "topic": "Polar-rectangular conversion", + "title": "Polar to rectangular: x = r·cos θ, y = r·sin θ", + "equation": "x = r*cos(theta), y = r*sin(theta)", + "keywords": [ + "polar", + "rectangular", + "polar to rectangular", + "polar coordinates", + "convert coordinates", + "r cos theta", + "r sin theta", + "cartesian", + "x = r cos", + "y = r sin", + "polar conversion", + "coordinate conversion" + ], + "explanation": "A polar point is named by how FAR out it is (r) and which DIRECTION it points (theta). To get its everyday x,y address, drop a right triangle from the point: the horizontal leg is x = r·cos θ and the vertical leg is y = r·sin θ. Slide r to push the point in or out along its ray, and slide θ to swing the whole ray around — the dashed legs are exactly the x and y you read off.", + "bullets": [ + "r is the distance from the origin; θ is the direction (angle).", + "x = r·cos θ and y = r·sin θ are the two legs of a right triangle.", + "Negative r points the opposite way; θ can be given in degrees or radians." + ], + "params": [ + { + "name": "r", + "label": "radius r", + "min": -5, + "max": 5, + "step": 0.1, + "value": 4 + }, + { + "name": "deg", + "label": "angle θ (degrees)", + "min": 0, + "max": 360, + "step": 1, + "value": 35 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst r = P.r, deg = P.deg;\nconst th = deg * Math.PI / 180;\nconst x = r * Math.cos(th), y = r * Math.sin(th);\nconst ring = [];\nfor (let i = 0; i <= 80; i++) { const a = i / 80 * H.TAU; ring.push([Math.abs(r) * Math.cos(a), Math.abs(r) * Math.sin(a)]); }\nv.path(ring, { color: H.colors.grid, width: 1 });\nv.line(0, 0, x, y, { color: H.colors.accent2, width: 2.5 });\nv.line(x, 0, x, y, { color: H.colors.good, width: 2, dash: [4, 4] });\nv.line(0, 0, x, 0, { color: H.colors.accent, width: 2, dash: [4, 4] });\nconst sweep = th + 0.5 * Math.sin(t * 1.2);\nv.dot(r * Math.cos(sweep), r * Math.sin(sweep), { r: 6, fill: H.colors.violet });\nv.dot(x, y, { r: 6 + Math.sin(t * 3), fill: H.colors.warn });\nH.text(\"Polar -> Rectangular: x = r·cos θ, y = r·sin θ\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"r = \" + r.toFixed(1) + \" θ = \" + deg.toFixed(0) + \"° => x = \" + x.toFixed(2) + \", y = \" + y.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"r (radius)\", color: H.colors.accent2 }, { label: \"x = r·cos θ\", color: H.colors.accent }, { label: \"y = r·sin θ\", color: H.colors.good }], H.W - 160, 28);" + }, + { + "id": "pc-polynomial-end-behavior", + "area": "Precalculus", + "topic": "Polynomial end behavior", + "title": "End behavior of y = a·xⁿ", + "equation": "y = a * x^n", + "keywords": [ + "end behavior", + "polynomial", + "degree", + "leading coefficient", + "even odd degree", + "power function", + "x^n", + "tails", + "far left far right", + "leading term", + "ends of graph" + ], + "explanation": "Far from the origin a polynomial is ruled entirely by its highest-power term, so y = a·xⁿ captures the whole story of its tails. Step the degree n through whole numbers: even n sends both arms the same direction, while odd n makes the arms point opposite ways. The sign of the leading coefficient a then decides which way is up — flip a negative and the whole picture turns over. The violet arrows mark which way each end heads.", + "bullets": [ + "Even degree: both ends agree (up–up or down–down). Odd degree: ends oppose.", + "a > 0 lifts the right end; a < 0 flips both ends.", + "Only the leading term matters for the far-left and far-right behavior." + ], + "params": [ + { + "name": "a", + "label": "leading coef a", + "min": -2, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "n", + "label": "degree n", + "min": 1, + "max": 6, + "step": 1, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -3, xMax: 3, yMin: -12, yMax: 12 });\nv.grid(); v.axes();\nconst a = (Math.abs(P.a) < 0.1 ? 0.1 : P.a);\nconst n = Math.min(6, Math.max(1, Math.round(P.n)));\nconst f = x => a * Math.pow(x, n);\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst xs = 2.6 * Math.sin(t * 0.6);\nv.dot(xs, f(xs), { r: 6, fill: H.colors.warn });\nconst even = (n % 2 === 0);\nconst leftUp = even ? (a > 0) : (a < 0);\nconst rightUp = (a > 0);\nv.arrow(-2.6, even ? (leftUp ? 9 : -9) : (leftUp ? 9 : -9), -2.85, even ? (leftUp ? 11 : -11) : (leftUp ? 11 : -11), { color: H.colors.violet, width: 2 });\nv.arrow(2.6, rightUp ? 9 : -9, 2.85, rightUp ? 11 : -11, { color: H.colors.violet, width: 2 });\nH.text(\"y = a · xⁿ (end behavior)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a = \" + a.toFixed(1) + \" n = \" + n + (even ? \" even: ends agree\" : \" odd: ends oppose\"), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"left \" + (leftUp ? \"↑\" : \"↓\") + \" right \" + (rightUp ? \"↑\" : \"↓\"), 24, 74, { color: H.colors.violet, size: 13 });" + }, + { + "id": "pc-polynomial-zeros-factorization", + "area": "Precalculus", + "topic": "Polynomial zeros and factorization", + "title": "Factored form: y = a(x − r1)(x − r2)(x − r3)", + "equation": "y = a*(x - r1)*(x - r2)*(x - r3)", + "keywords": [ + "polynomial zeros", + "factorization", + "roots", + "x-intercepts", + "factored form", + "cubic", + "factoring polynomials", + "zero product", + "real roots", + "a(x-r1)(x-r2)(x-r3)", + "solve polynomial" + ], + "explanation": "A polynomial in factored form wears its zeros on its sleeve: each factor (x − r) is zero exactly when x = r, so the curve crosses the x-axis at every root you set. Drag r1, r2, r3 and watch the green crossing points slide along the axis — the shape bends to pass through all three. The leading coefficient a stretches the curve vertically and flips it upside down when negative, but it never moves the zeros.", + "bullets": [ + "Each factor (x − r) makes the curve hit zero at x = r (zero-product property).", + "The roots r1, r2, r3 are precisely the x-intercepts you can read off directly.", + "a scales and (if negative) flips the curve but leaves every zero in place." + ], + "params": [ + { + "name": "a", + "label": "leading coef a", + "min": -2, + "max": 2, + "step": 0.1, + "value": 0.4 + }, + { + "name": "r1", + "label": "zero r1", + "min": -5, + "max": 5, + "step": 0.5, + "value": -3 + }, + { + "name": "r2", + "label": "zero r2", + "min": -5, + "max": 5, + "step": 0.5, + "value": 0 + }, + { + "name": "r3", + "label": "zero r3", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -10, yMax: 10 });\nv.grid(); v.axes();\nconst r1 = P.r1, r2 = P.r2, r3 = P.r3, a = (Math.abs(P.a) < 0.1 ? 0.1 : P.a);\nconst f = x => a * (x - r1) * (x - r2) * (x - r3);\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.dot(r1, 0, { r: 6, fill: H.colors.good });\nv.dot(r2, 0, { r: 6, fill: H.colors.good });\nv.dot(r3, 0, { r: 6, fill: H.colors.good });\nconst xs = 5 * Math.sin(t * 0.6);\nv.dot(xs, f(xs), { r: 6, fill: H.colors.warn });\nH.text(\"y = a(x − r1)(x − r2)(x − r3)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"zeros at x = \" + r1.toFixed(1) + \", \" + r2.toFixed(1) + \", \" + r3.toFixed(1) + \" a = \" + a.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"curve\", color: H.colors.accent }, { label: \"zeros\", color: H.colors.good }], H.W - 160, 28);" + }, + { + "id": "pc-proving-trig-identities", + "area": "Precalculus", + "topic": "Proving trig identities", + "title": "Proving identities: tan x + cot x = sec x csc x", + "equation": "tan x + cot x = sec x csc x", + "keywords": [ + "proving trig identities", + "prove identity", + "verify trig identity", + "tan + cot = sec csc", + "trig proof", + "left side right side", + "transform one side", + "common denominator trig", + "establish identity", + "trigonometric proof", + "qed trig" + ], + "explanation": "To PROVE an identity you don't plug in numbers — you transform one side until it literally becomes the other, using known identities. Step through the proof with the slider: rewrite tan and cot as sin/cos ratios, combine over a common denominator, apply sin^2+cos^2=1, then split back into sec and csc. Both sides are graphed (thick green = right side, thin orange = left side) and they coincide everywhere, while the moving dot shows the two sides always agree numerically.", + "bullets": [ + "A proof transforms ONE side step by step into the other — not numeric testing.", + "Key move: rewrite everything in sin/cos, combine, then use sin^2+cos^2=1.", + "The curves overlapping is confirmation, but the algebra is what proves it." + ], + "params": [ + { + "name": "step", + "label": "proof step", + "min": 0, + "max": 4, + "step": 1, + "value": 0 + } + ], + "code": "H.background();\nconst eps = 1e-4;\nconst v = H.plot2d({ xMin: 0.12, xMax: Math.PI - 0.12, yMin: -1, yMax: 9, box: { x: 56, y: 150, w: H.W * 0.52 - 70, h: H.H - 210 } });\nv.grid(); v.axes();\nconst lhs = (x) => { const s = Math.sin(x), c = Math.cos(x); if (Math.abs(s) < eps || Math.abs(c) < eps) return NaN; return s / c + c / s; };\nconst rhs = (x) => { const s = Math.sin(x), c = Math.cos(x); if (Math.abs(s) < eps || Math.abs(c) < eps) return NaN; return (1 / c) * (1 / s); };\nv.fn(rhs, { color: H.colors.good, width: 7 });\nv.fn(lhs, { color: H.colors.accent2, width: 2.5 });\nconst xs = 0.25 + (Math.sin(t * 0.5) * 0.5 + 0.5) * (Math.PI - 0.5);\nconst yl = lhs(xs);\nif (Number.isFinite(yl)) v.dot(xs, yl, { r: 6, fill: H.colors.warn });\nconst steps = [\n \"Goal: tan x + cot x = sec x csc x\",\n \"1. tan x + cot x = sin/cos + cos/sin\",\n \"2. = (sin^2 + cos^2) / (sin cos)\",\n \"3. = 1 / (sin cos) [Pythagorean]\",\n \"4. = (1/cos)(1/sin) = sec x csc x QED\",\n];\nconst cur = Math.max(0, Math.min(steps.length - 1, Math.round(P.step)));\nconst pulse = 0.5 + 0.5 * Math.sin(t * 4);\nH.text(\"Proving trig identities\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Transform the LEFT side step by step until it equals the RIGHT.\", 24, 52, { color: H.colors.sub, size: 13 });\nconst tx = H.W * 0.56, ty = 130, lh = 30;\nfor (let i = 0; i < steps.length; i++) {\n const on = i <= cur;\n let col;\n if (i === cur) col = H.colors.warn;\n else if (on) col = H.colors.ink;\n else col = H.colors.grid;\n H.text(steps[i], tx, ty + i * lh, { color: col, size: i === 0 ? 15 : 14, weight: i === 0 ? 700 : 500 });\n}\nH.circle(tx - 14, ty + cur * lh - 5, 4 + 2 * pulse, { fill: H.colors.warn });\nconst yv = Number.isFinite(yl) ? yl.toFixed(2) : \"undef\";\nH.text(\"check at x = \" + xs.toFixed(2) + \": both sides = \" + yv, 24, 100, { color: H.colors.warn, size: 13 });\nH.legend([{ label: \"right side\", color: H.colors.good }, { label: \"left side\", color: H.colors.accent2 }], H.W - 150, 88);" + }, + { + "id": "pc-pythagorean-trig-identities", + "area": "Precalculus", + "topic": "Pythagorean trig identities", + "title": "Pythagorean identity: sin^2 + cos^2 = 1", + "equation": "sin^2 theta + cos^2 theta = 1", + "keywords": [ + "pythagorean identity", + "sin^2 + cos^2 = 1", + "trig pythagorean", + "1 + tan^2 = sec^2", + "1 + cot^2 = csc^2", + "trig identities", + "unit circle triangle", + "sin squared cos squared", + "fundamental identity", + "pythagorean trig" + ], + "explanation": "The radius of the unit circle is 1, and the cos-leg and sin-leg form a right triangle with that radius as hypotenuse — so the Pythagorean theorem says cos^2 + sin^2 = 1, exactly. The stacked bar makes it visible: the blue cos^2 piece and green sin^2 piece always fill the bar to length 1, no matter the angle. The k slider scales the whole identity, showing the companion forms k + k*tan^2 = k*sec^2 (divide sin^2+cos^2=1 by cos^2 to get 1 + tan^2 = sec^2).", + "bullets": [ + "cos and sin are the legs of a right triangle with hypotenuse 1.", + "So sin^2 + cos^2 = 1 holds for EVERY angle (the bar always fills to 1).", + "Divide by cos^2 for 1 + tan^2 = sec^2; by sin^2 for 1 + cot^2 = csc^2." + ], + "params": [ + { + "name": "k", + "label": "scale unit k", + "min": 0.5, + "max": 4, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst cx = H.W * 0.32, cy = H.H * 0.56, R = Math.min(H.W, H.H) * 0.30;\nconst ang = t * 0.6;\nconst c = Math.cos(ang), s = Math.sin(ang);\nconst k = Math.max(0.1, P.k);\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 1.5 });\nH.line(cx - R - 14, cy, cx + R + 14, cy, { color: H.colors.axis, width: 1 });\nH.line(cx, cy - R - 14, cx, cy + R + 14, { color: H.colors.axis, width: 1 });\nconst px = cx + R * c, py = cy - R * s;\nH.line(cx, cy, px, py, { color: H.colors.violet, width: 2 });\nH.line(cx, cy, px, cy, { color: H.colors.accent, width: 3 });\nH.line(px, cy, px, py, { color: H.colors.good, width: 3 });\nH.circle(px, py, 5, { fill: H.colors.warn });\nH.text(\"Pythagorean identity: sin^2 + cos^2 = 1\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"The right triangle has legs cos and sin and hypotenuse 1.\", 24, 52, { color: H.colors.sub, size: 13 });\nconst c2 = c * c, s2 = s * s;\nconst bx = H.W * 0.62, bw = H.W * 0.30, by = H.H * 0.34, bh = 34;\nH.text(\"cos^2 + sin^2 stacks to exactly 1:\", bx, by - 16, { color: H.colors.sub, size: 13 });\nH.rect(bx, by, bw * c2, bh, { fill: H.colors.accent });\nH.rect(bx + bw * c2, by, bw * s2, bh, { fill: H.colors.good });\nH.rect(bx, by, bw, bh, { stroke: H.colors.ink, width: 2 });\nH.text(\"cos^2 = \" + c2.toFixed(2), bx, by + bh + 22, { color: H.colors.accent, size: 14 });\nH.text(\"sin^2 = \" + s2.toFixed(2), bx, by + bh + 44, { color: H.colors.good, size: 14 });\nH.text(\"sum = \" + (c2 + s2).toFixed(2), bx, by + bh + 66, { color: H.colors.ink, size: 15, weight: 700 });\nconst variant = k * (1 + s2 / Math.max(0.01, c2));\nH.text(\"Scale the unit (k = \" + k.toFixed(2) + \"): k + k*tan^2 = k*sec^2 = \" + variant.toFixed(2), 24, H.H - 24, { color: H.colors.violet, size: 13 });\nH.legend([{ label: \"cos leg\", color: H.colors.accent }, { label: \"sin leg\", color: H.colors.good }, { label: \"hyp = 1\", color: H.colors.violet }], 24, 88);" + }, + { + "id": "pc-rational-graphs-asymptotes", + "area": "Precalculus", + "topic": "Rational graphs and asymptotes", + "title": "Rational graph: y = k/(x − p) + q", + "equation": "y = k / (x - p) + q", + "keywords": [ + "rational function", + "asymptote", + "vertical asymptote", + "horizontal asymptote", + "hyperbola", + "rational graph", + "1/x", + "k/(x-p)+q", + "blow up", + "approach", + "discontinuity" + ], + "explanation": "This is the parent reciprocal 1/x stretched and slid around. The vertical asymptote sits at x = p, where the denominator hits zero and the curve shoots off to ±infinity — drag p and the red dashed wall moves with it. The horizontal asymptote is y = q, the height the curve flattens toward far left and right; k stretches the branches and, when negative, swaps which corners they live in. The traveling dot rides one branch so you can watch it hug both asymptotes without ever touching them.", + "bullets": [ + "Vertical asymptote at x = p: the denominator is zero there, so y blows up.", + "Horizontal asymptote at y = q: the curve levels off toward q at the far ends.", + "k scales the branches; k < 0 reflects them into the opposite quadrants." + ], + "params": [ + { + "name": "p", + "label": "vert asym p", + "min": -5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "q", + "label": "horiz asym q", + "min": -5, + "max": 5, + "step": 0.5, + "value": -1 + }, + { + "name": "k", + "label": "stretch k", + "min": -8, + "max": 8, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst p = P.p, q = P.q, k = P.k;\nconst f = x => k / (x - p) + q;\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(p, -8, p, 8, { color: H.colors.warn, width: 1.5, dash: [5, 5] });\nv.line(-8, q, 8, q, { color: H.colors.good, width: 1.5, dash: [5, 5] });\nconst X = 7.5, Y = 7.5, ak = Math.max(0.5, Math.abs(k));\nconst roomUp = Y - q, roomDown = q + Y;\nconst room = (k >= 0 ? roomUp : roomDown);\nlet dMin = Math.max(0.5, ak / Math.max(0.8, room - 0.3));\nlet dMax = Math.max(dMin + 0.2, Math.min(X - p, 5));\nlet xs;\nif (p + dMin > X) {\n const room2 = (k >= 0 ? roomDown : roomUp);\n let dl = Math.max(0.5, ak / Math.max(0.8, room2 - 0.3));\n let dr = Math.max(dl + 0.2, Math.min(p + X, 5));\n const c = (dl + dr) / 2, amp = (dr - dl) / 2;\n xs = p - (c + amp * Math.sin(t * 0.8));\n} else {\n const c = (dMin + dMax) / 2, amp = (dMax - dMin) / 2;\n xs = p + (c + amp * Math.sin(t * 0.8));\n}\nconst yd = f(xs);\nv.dot(xs, yd, { r: 6, fill: H.colors.violet });\nv.text(\"(\" + xs.toFixed(1) + \", \" + yd.toFixed(1) + \")\", xs + 0.2, yd + 0.5, { color: H.colors.violet, size: 12 });\nH.text(\"y = k / (x − p) + q\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"vert asym x = \" + p.toFixed(1) + \" horiz asym y = \" + q.toFixed(1) + \" k = \" + k.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"x = p\", color: H.colors.warn }, { label: \"y = q\", color: H.colors.good }], H.W - 150, 28);" + }, + { + "id": "pc-real-world-trig", + "area": "Precalculus", + "topic": "Real-world trig applications", + "title": "Angle of elevation: height = d · tan(theta)", + "equation": "height = d * tan(theta)", + "keywords": [ + "trig applications", + "angle of elevation", + "tangent", + "height of building", + "right triangle", + "real world trig", + "line of sight", + "tan theta", + "indirect measurement", + "surveying", + "depression", + "trigonometry word problem" + ], + "explanation": "Stand a distance d from a tall object and look up at angle theta to its top. Those two facts pin down the height exactly: tan(theta) is the rise over run of your line of sight, so height = d * tan(theta). Slide theta to steepen your gaze (height shoots up as theta nears 90°) and slide d to back away; the dashed line of sight and the orange height bar update together.", + "bullets": [ + "tan(theta) = opposite/adjacent = height/distance, so height = d * tan(theta).", + "A bigger angle of elevation or a larger distance both raise the computed height.", + "This is how surveyors find heights they cannot reach: measure one angle and one distance." + ], + "params": [ + { + "name": "angle", + "label": "elevation theta (deg)", + "min": 5, + "max": 80, + "step": 1, + "value": 35 + }, + { + "name": "dist", + "label": "distance d (m)", + "min": 10, + "max": 100, + "step": 1, + "value": 40 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst ang = Math.min(85, Math.max(1, P.angle)) * Math.PI / 180;\nconst dist = Math.max(1, P.dist);\nconst height = dist * Math.tan(ang);\nconst gx = w * 0.20, gy = h * 0.84;\nconst sx = (w * 0.60) / 100;\nconst objX = Math.min(w - 60, gx + dist * sx);\nconst topY = Math.max(70, gy - Math.min(height, 100) * sx);\nH.line(gx - 30, gy, w - 20, gy, { color: H.colors.axis, width: 2 });\nH.line(objX, gy, objX, topY, { color: H.colors.accent2, width: 5 });\nH.line(gx, gy, objX, topY, { color: H.colors.accent, width: 2, dash: [6, 5] });\nH.line(gx, gy, objX, gy, { color: H.colors.good, width: 2, dash: [4, 4] });\nconst sweep = 0.5 + 0.5 * Math.sin(t * 1.2);\nH.circle(gx + (objX - gx) * sweep, gy + (topY - gy) * sweep, 5 + Math.sin(t * 3), { fill: H.colors.warn });\nH.circle(gx, gy, 6, { fill: H.colors.violet });\nH.text(\"observer\", gx - 24, gy + 20, { color: H.colors.sub, size: 12 });\nH.text(\"height\", objX + 8, (topY + gy) / 2, { color: H.colors.accent2, size: 12 });\nH.text(\"theta = \" + P.angle.toFixed(0) + \"°\", gx + 36, gy - 10, { color: H.colors.accent, size: 12 });\nH.text(\"Real-world trig: height = d · tan(theta)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"angle theta = \" + P.angle.toFixed(0) + \"° distance d = \" + dist.toFixed(0) + \" m -> height = \" + height.toFixed(1) + \" m\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"line of sight\", color: H.colors.accent }, { label: \"ground distance d\", color: H.colors.good }, { label: \"height\", color: H.colors.accent2 }], w - 200, 28);" + }, + { + "id": "pc-reciprocal-quotient-identities", + "area": "Precalculus", + "topic": "Reciprocal and quotient identities", + "title": "Reciprocal & quotient identities: tan = sin/cos, sec = 1/cos", + "equation": "tan = sin/cos, cot = cos/sin, sec = 1/cos, csc = 1/sin", + "keywords": [ + "reciprocal identities", + "quotient identities", + "tan sin cos", + "secant cosecant cotangent", + "sec csc cot", + "tan = sin/cos", + "1/cos", + "trig identities", + "reciprocal trig", + "six trig functions", + "tangent cotangent" + ], + "explanation": "Only sine and cosine are 'primary' — the other four trig functions are built from them. Drag the angle and watch the x-leg (cos) and y-leg (sin) of the unit-circle triangle change; tan is just their ratio sin/cos, cot is the flipped ratio cos/sin, and sec and csc are the reciprocals 1/cos and 1/sin. When a denominator hits zero the function is 'undef', which is exactly where tan, cot, sec, or csc has no value.", + "bullets": [ + "Quotient: tan = sin/cos and cot = cos/sin (one is the other flipped).", + "Reciprocal: sec = 1/cos, csc = 1/sin, cot = 1/tan.", + "A zero denominator (cos = 0 or sin = 0) makes that function undefined." + ], + "params": [ + { + "name": "deg", + "label": "angle θ (degrees)", + "min": 1, + "max": 359, + "step": 1, + "value": 40 + } + ], + "code": "H.background();\nconst cx = H.W * 0.36, cy = H.H * 0.56, R = Math.min(H.W, H.H) * 0.32;\nconst ang = P.deg * Math.PI / 180;\nconst c = Math.cos(ang), s = Math.sin(ang);\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 1.5 });\nH.line(cx - R - 16, cy, cx + R + 16, cy, { color: H.colors.axis, width: 1 });\nH.line(cx, cy - R - 16, cx, cy + R + 16, { color: H.colors.axis, width: 1 });\nconst px = cx + R * c, py = cy - R * s;\nH.line(cx, cy, px, py, { color: H.colors.violet, width: 2 });\nH.line(px, py, px, cy, { color: H.colors.good, width: 2.5 });\nH.line(cx, cy, px, cy, { color: H.colors.accent, width: 2.5 });\nH.circle(px, py, 5 + Math.sin(t * 3), { fill: H.colors.warn });\nconst eps = 1e-4;\nconst safe = (num, den) => Math.abs(den) < eps ? NaN : num / den;\nconst tan = safe(s, c), cot = safe(c, s), sec = safe(1, c), csc = safe(1, s);\nconst fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : \"undef\";\nH.text(\"Reciprocal & quotient identities\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"From cos = \" + c.toFixed(2) + \" (blue) and sin = \" + s.toFixed(2) + \" (green), everything else follows.\", 24, 52, { color: H.colors.sub, size: 13 });\nconst bx = H.W * 0.7, by = 90, lh = 30;\nH.text(\"tan = sin/cos = \" + fmt(tan), bx, by, { color: H.colors.accent2, size: 15 });\nH.text(\"cot = cos/sin = \" + fmt(cot), bx, by + lh, { color: H.colors.accent2, size: 15 });\nH.text(\"sec = 1/cos = \" + fmt(sec), bx, by + 2 * lh, { color: H.colors.good, size: 15 });\nH.text(\"csc = 1/sin = \" + fmt(csc), bx, by + 3 * lh, { color: H.colors.good, size: 15 });\nH.legend([{ label: \"cos (x-leg)\", color: H.colors.accent }, { label: \"sin (y-leg)\", color: H.colors.good }, { label: \"radius = 1\", color: H.colors.violet }], 24, 88);" + }, + { + "id": "pc-reciprocal-trig-graphs", + "area": "Precalculus", + "topic": "Secant, cosecant, cotangent graphs", + "title": "Reciprocal trig: sec, csc, cot", + "equation": "sec(x)=1/cos(x), csc(x)=1/sin(x), cot(x)=cos(x)/sin(x)", + "keywords": [ + "secant", + "cosecant", + "cotangent", + "sec graph", + "csc graph", + "cot graph", + "reciprocal trig", + "1/cos", + "1/sin", + "reciprocal identities", + "asymptote", + "trig graph" + ], + "explanation": "Each of these is the reciprocal of a basic trig function, so it blows up to infinity exactly where its partner crosses zero. Watch the faint partner curve (cos for sec, sin for csc and cot): wherever it touches the x-axis you get a vertical asymptote, and wherever it peaks at ±1 the reciprocal just touches ±A. Step the function slider to compare all three; the A slider stretches the curve vertically.", + "bullets": [ + "sec, csc, cot have vertical asymptotes where cos, sin, sin (respectively) equal zero.", + "Where the partner curve hits its max ±1, the reciprocal touches ±A — its closest approach.", + "sec and csc never take values between -A and A; cot, like tan, sweeps the whole range." + ], + "params": [ + { + "name": "fn", + "label": "function (1 sec, 2 csc, 3 cot)", + "min": 1, + "max": 3, + "step": 1, + "value": 1 + }, + { + "name": "A", + "label": "vertical stretch A", + "min": 0.5, + "max": 3, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -2 * Math.PI, xMax: 2 * Math.PI, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst which = Math.round(P.fn);\nconst A = P.A;\nlet label, recip, base, baseLabel, baseColor;\nif (which <= 1) {\n label = \"sec(x) = 1 / cos(x)\"; baseLabel = \"cos x\"; baseColor = H.colors.violet;\n recip = (x) => Math.cos(x); base = (x) => Math.cos(x);\n} else if (which === 2) {\n label = \"csc(x) = 1 / sin(x)\"; baseLabel = \"sin x\"; baseColor = H.colors.violet;\n recip = (x) => Math.sin(x); base = (x) => Math.sin(x);\n} else {\n label = \"cot(x) = cos(x) / sin(x)\"; baseLabel = \"sin x\"; baseColor = H.colors.violet;\n recip = (x) => Math.sin(x); base = (x) => Math.sin(x);\n}\nv.fn(base, { color: baseColor, width: 1.6 });\nv.fn(x => {\n const d = recip(x);\n if (Math.abs(d) < 0.04) return NaN;\n return which === 3 ? A * Math.cos(x) / d : A / d;\n}, { color: H.colors.accent, width: 3 });\nconst xs = 2 * Math.PI * 0.9 * Math.sin(t * 0.5);\nconst dd = recip(xs);\nlet ys = Math.abs(dd) < 0.04 ? NaN : (which === 3 ? A * Math.cos(xs) / dd : A / dd);\nif (Number.isFinite(ys) && ys >= -6 && ys <= 6) v.dot(xs, ys, { r: 6, fill: H.colors.warn });\nH.text(\"y = A · \" + label, 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"A = \" + A.toFixed(2) + \" (blow-ups where \" + baseLabel + \" = 0)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: label.split(\" \")[0], color: H.colors.accent }, { label: baseLabel, color: baseColor }], H.W - 160, 28);" + }, + { + "id": "pc-reference-coterminal-angles", + "area": "Precalculus", + "topic": "Reference angles and coterminal angles", + "title": "Coterminal & reference angles: θ + 360°·k", + "equation": "coterminal: θ + 360*k ; reference: acute angle to the x-axis", + "keywords": [ + "reference angle", + "coterminal angles", + "coterminal", + "add 360", + "terminal side", + "acute angle to x-axis", + "standard position", + "find reference angle", + "same terminal side", + "angles in standard position", + "360k" + ], + "explanation": "Two angles are coterminal when they land on the same terminal side — you get them by adding or subtracting full turns, θ + 360°·k. The k slider spins the ray k extra times around (watch the violet sweep) yet the terminal ray ends in exactly the same place, and the readout shows θ and θ+360k giving the same direction. The reference angle (green) is the acute angle between that terminal side and the x-axis; it's what carries the magnitude of every trig value, while the quadrant supplies the sign.", + "bullets": [ + "Coterminal angles differ by a whole number of full turns: θ + 360°·k.", + "All coterminal angles share one terminal side, so they share every trig value.", + "The reference angle is the acute angle to the x-axis; it sets the magnitude." + ], + "params": [ + { + "name": "deg", + "label": "angle θ (degrees)", + "min": 0, + "max": 360, + "step": 5, + "value": 210 + }, + { + "name": "k", + "label": "full turns k", + "min": -2, + "max": 2, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst cx = H.W * 0.5, cy = H.H * 0.54, R = Math.min(H.W, H.H) * 0.3;\nconst baseDeg = ((P.deg % 360) + 360) % 360;\nconst k = Math.round(P.k);\nconst totalDeg = baseDeg + 360 * k;\nconst ang = baseDeg * Math.PI / 180;\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 1.5 });\nH.line(cx - R - 14, cy, cx + R + 14, cy, { color: H.colors.axis, width: 1 });\nH.line(cx, cy - R - 14, cx, cy + R + 14, { color: H.colors.axis, width: 1 });\nconst px = cx + R * Math.cos(ang), py = cy - R * Math.sin(ang);\nconst totalRad = totalDeg * Math.PI / 180;\nconst frac = (t * 0.18) % 1;\nconst showAng = totalRad * frac;\nconst sx = cx + R * Math.cos(showAng), sy = cy - R * Math.sin(showAng);\nconst sweep = [];\nconst N = 96;\nfor (let i = 0; i <= N; i++) { const a = showAng * (i / N); sweep.push([cx + R * 0.6 * Math.cos(a), cy - R * 0.6 * Math.sin(a)]); }\nH.path(sweep, { color: H.colors.violet, width: 2 });\nH.line(cx, cy, px, py, { color: H.colors.accent2, width: 2.5 });\nH.circle(sx, sy, 6, { fill: H.colors.warn });\nlet ref = baseDeg;\nif (baseDeg <= 90) ref = baseDeg;\nelse if (baseDeg <= 180) ref = 180 - baseDeg;\nelse if (baseDeg <= 270) ref = baseDeg - 180;\nelse ref = 360 - baseDeg;\nconst refRad = ref * Math.PI / 180;\nconst refPts = [];\nconst baseDir = (Math.cos(ang) >= 0) ? 0 : Math.PI;\nconst yDir = (Math.sin(ang) >= 0) ? 1 : -1;\nconst inward = (Math.cos(ang) >= 0) ? 1 : -1;\nfor (let i = 0; i <= 30; i++) { const a = refRad * (i / 30) * yDir * inward; refPts.push([cx + R * 0.4 * Math.cos(baseDir + a), cy - R * 0.4 * Math.sin(baseDir + a)]); }\nH.path(refPts, { color: H.colors.good, width: 2 });\nH.text(\"Coterminal & reference angles\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"θ = \" + baseDeg.toFixed(0) + \"° + 360°·\" + k + \" = \" + totalDeg.toFixed(0) + \"° (same terminal side)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"reference angle = \" + ref.toFixed(0) + \"°\", 24, 74, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"terminal side\", color: H.colors.accent2 }, { label: \"sweep \" + totalDeg.toFixed(0) + \"°\", color: H.colors.violet }, { label: \"ref angle\", color: H.colors.good }], H.W - 175, 28);" + }, + { + "id": "pc-right-triangle-trig", + "area": "Precalculus", + "topic": "Right-triangle trigonometry", + "title": "Right-triangle trig: SOH-CAH-TOA", + "equation": "sin θ = opp/hyp, cos θ = adj/hyp, tan θ = opp/adj", + "keywords": [ + "right triangle trigonometry", + "soh cah toa", + "opposite adjacent hypotenuse", + "sine cosine tangent ratio", + "right triangle", + "trig ratios", + "solve a triangle", + "angle of elevation", + "sohcahtoa", + "trigonometry triangle" + ], + "explanation": "In a right triangle the three trig ratios are fixed once you know the angle θ — they don't depend on how big the triangle is. The angle slider tilts the hypotenuse, and the legs labeled opp (green) and adj (blue) update so that opp/hyp is always sin θ and adj/hyp is always cos θ. The hyp slider scales the whole triangle: notice the side lengths change but the printed sin, cos, and tan ratios stay the same — that constancy is the whole point of SOH-CAH-TOA.", + "bullets": [ + "SOH-CAH-TOA: Sin = Opp/Hyp, Cos = Adj/Hyp, Tan = Opp/Adj.", + "The ratios depend only on the angle, not on the triangle's size.", + "Scale the hypotenuse and the sides grow, but sin/cos/tan are unchanged." + ], + "params": [ + { + "name": "angle", + "label": "angle θ (degrees)", + "min": 5, + "max": 85, + "step": 1, + "value": 37 + }, + { + "name": "hyp", + "label": "hypotenuse length", + "min": 1, + "max": 9, + "step": 0.5, + "value": 6 + } + ], + "code": "H.background();\nconst deg = Math.max(5, Math.min(85, P.angle));\nconst hyp = Math.max(1, P.hyp);\nconst ang = deg * Math.PI / 180;\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -1, yMax: 10 });\nv.grid(); v.axes();\nconst opp = hyp * Math.sin(ang);\nconst adj = hyp * Math.cos(ang);\nconst Ax = 0, Ay = 0, Bx = adj, By = 0, Cx = adj, Cy = opp;\nv.path([[Ax, Ay], [Bx, By], [Cx, Cy]], { color: H.colors.accent, width: 3, close: true });\nv.line(Bx, By, Bx, 0.5, { color: H.colors.sub, width: 1 });\nv.line(Bx - 0.5, 0.5, Bx, 0.5, { color: H.colors.sub, width: 1 });\nconst sweep = (Math.sin(t * 1.2) * 0.5 + 0.5);\nconst apts = [];\nfor (let i = 0; i <= 24; i++) { const a = ang * (i / 24) * sweep; apts.push([0.9 * Math.cos(a), 0.9 * Math.sin(a)]); }\nv.path(apts, { color: H.colors.warn, width: 2 });\nv.dot(adj * (0.5 + 0.5 * Math.sin(t)), opp * (0.5 + 0.5 * Math.sin(t)), { r: 5, fill: H.colors.yellow });\nv.text(\"θ\", 1.2, 0.45, { color: H.colors.warn, size: 14 });\nv.text(\"opp = \" + opp.toFixed(2), adj + 0.2, opp / 2, { color: H.colors.good, size: 13 });\nv.text(\"adj = \" + adj.toFixed(2), adj / 2, -0.5, { color: H.colors.accent, size: 13 });\nv.text(\"hyp = \" + hyp.toFixed(2), adj / 2 - 0.6, opp / 2 + 0.4, { color: H.colors.accent2, size: 13 });\nH.text(\"Right-triangle trig: SOH-CAH-TOA\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"θ=\" + deg.toFixed(0) + \"° sin=\" + Math.sin(ang).toFixed(3) + \" cos=\" + Math.cos(ang).toFixed(3) + \" tan=\" + Math.tan(ang).toFixed(3), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"opp\", color: H.colors.good }, { label: \"adj\", color: H.colors.accent }, { label: \"hyp\", color: H.colors.accent2 }], H.W - 130, 28);" + }, + { + "id": "pc-sector-area", + "area": "Precalculus", + "topic": "Sector area", + "title": "Sector area: A = ½ · r² · θ", + "equation": "A = 0.5 * r^2 * theta (theta in radians)", + "keywords": [ + "sector area", + "area of a sector", + "pie slice area", + "half r squared theta", + "circular sector", + "wedge area", + "central angle area", + "fraction of circle", + "r^2 theta over 2", + "sector of a circle" + ], + "explanation": "A sector is a 'pie slice' of a circle — bounded by two radii and the arc between them. The 'radius' slider sizes the whole circle and the 'angle' slider opens the slice wider. The formula A = ½r²θ comes from taking the fraction θ/(2π) of the full circle area πr². The readout shows the slice's area and what percent of the whole pie it covers, so you can feel the angle and radius each pull the area up.", + "bullets": [ + "A = ½ · r² · θ with θ in radians: it's the angle's share of the full circle πr².", + "Area grows with the SQUARE of the radius, but only linearly with the angle.", + "A full slice (θ = 2π) gives A = πr², the area of the whole circle." + ], + "params": [ + { + "name": "r", + "label": "radius r", + "min": 0.5, + "max": 3, + "step": 0.1, + "value": 2 + }, + { + "name": "deg", + "label": "central angle (degrees)", + "min": 10, + "max": 350, + "step": 1, + "value": 90 + } + ], + "code": "H.background();\nconst w = H.W, hh = H.H;\nconst cx = w * 0.42, cy = hh * 0.55;\nconst r = Math.max(0.2, P.r);\nconst maxAng = Math.max(0.1, P.deg) * Math.PI / 180;\nconst Rpix = Math.min(w, hh) * 0.11 * r;\nconst sweep = (Math.sin(t * 0.5 - Math.PI / 2) + 1) / 2;\nconst ang = maxAng * sweep;\nH.circle(cx, cy, Rpix, { stroke: H.colors.grid, width: 1.5 });\nH.line(cx - Rpix - 14, cy, cx + Rpix + 14, cy, { color: H.colors.axis, width: 1 });\nH.line(cx, cy - Rpix - 14, cx, cy + Rpix + 14, { color: H.colors.axis, width: 1 });\nconst wedge = [[cx, cy]];\nconst steps = 80;\nfor (let i = 0; i <= steps; i++) {\n const aa = ang * (i / steps);\n wedge.push([cx + Rpix * Math.cos(aa), cy - Rpix * Math.sin(aa)]);\n}\nif (wedge.length >= 3) H.path(wedge, { color: H.colors.accent2, width: 2, fill: \"rgba(244,162,89,0.30)\", close: true });\nconst px = cx + Rpix * Math.cos(ang), py = cy - Rpix * Math.sin(ang);\nH.line(cx, cy, cx + Rpix, cy, { color: H.colors.accent, width: 2.5 });\nH.line(cx, cy, px, py, { color: H.colors.accent, width: 2.5 });\nH.circle(px, py, 6, { fill: H.colors.warn });\nconst area = 0.5 * r * r * ang;\nconst full = Math.PI * r * r;\nH.text(\"Sector area: A = ½ · r² · θ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"r = \" + r.toFixed(1) + \" θ = \" + ang.toFixed(2) + \" rad → A = \" + area.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"that's \" + (full > 0 ? (100 * area / full).toFixed(0) : \"0\") + \"% of the full circle (πr² = \" + full.toFixed(1) + \")\", 24, 74, { color: H.colors.good, size: 12 });\nH.legend([{ label: \"radius r\", color: H.colors.accent }, { label: \"sector A\", color: H.colors.accent2 }], H.W - 170, 28);" + }, + { + "id": "pc-sigma-notation", + "area": "Precalculus", + "topic": "Sigma notation", + "title": "Sigma notation: Σ from k=lo to hi of c·k", + "equation": "sum_{k=lo}^{hi} c*k", + "keywords": [ + "sigma notation", + "summation notation", + "summation", + "index of summation", + "lower bound", + "upper bound", + "summand", + "capital sigma", + "sum k from", + "series notation", + "expand the sum" + ], + "explanation": "Sigma notation is a compact instruction: plug each integer k from the lower bound up to the upper bound into the summand, then add the results. Each bar here is one term c·k, and the animation lights them up left to right while the running total grows — so you literally watch the sum being built. Change the lower and upper bounds to add or drop terms, or change c to rescale every term at once.", + "bullets": [ + "The index k steps through every integer from the lower to the upper bound.", + "The expression after Σ (here c·k) is evaluated once per k, then all are added.", + "Widening the bounds adds more terms; the coefficient c scales every term." + ], + "params": [ + { + "name": "lo", + "label": "lower bound k=", + "min": 1, + "max": 6, + "step": 1, + "value": 1 + }, + { + "name": "hi", + "label": "upper bound", + "min": 1, + "max": 10, + "step": 1, + "value": 5 + }, + { + "name": "c", + "label": "coefficient c", + "min": 0.5, + "max": 2, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst lo = Math.round(H.clamp(P.lo, 1, 6));\nconst hiRaw = Math.round(H.clamp(P.hi, 1, 10));\nconst hi = Math.max(lo, hiRaw);\nconst c = P.c;\nconst summand = (k) => c * k;\nconst count = hi - lo + 1;\nconst revealed = lo + Math.floor((t * 1.1) % (count + 1));\nconst v = H.plot2d({ xMin: lo - 1, xMax: hi + 1, yMin: 0, yMax: Math.max(4, c * hi + 2) });\nv.grid(); v.axes();\nlet running = 0;\nfor (let k = lo; k <= hi; k++) {\n const h = summand(k);\n const on = k < revealed;\n v.rect(k - 0.4, 0, 0.8, Math.max(0, h), { fill: on ? H.colors.accent : H.colors.panel, stroke: H.colors.axis, width: 1 });\n if (on) running += h;\n v.text(String(k), k, -0.0001, { color: H.colors.sub, size: 12, align: \"center\", baseline: \"top\" });\n}\nconst cur = Math.min(revealed, hi);\nv.dot(cur, Math.max(0.2, summand(cur)) + 0.4, { r: 6 + Math.sin(t * 4), fill: H.colors.warn });\nconst total = (function () { let s = 0; for (let k = lo; k <= hi; k++) s += summand(k); return s; })();\nH.text(\"Σ from k=\" + lo + \" to \" + hi + \" of \" + c.toFixed(1) + \"·k\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"each bar is one term c·k; the running total adds them left to right\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"running sum so far = \" + running.toFixed(1) + \" full sum = \" + total.toFixed(1), 24, H.H - 16, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"added term\", color: H.colors.accent }, { label: \"now adding\", color: H.colors.warn }], H.W - 170, 28);" + }, + { + "id": "pc-simplifying-trig-expressions", + "area": "Precalculus", + "topic": "Simplifying trig expressions", + "title": "Simplifying trig: messy expression = clean curve", + "equation": "(1 - cos^2 x)/sin x = sin x", + "keywords": [ + "simplifying trig expressions", + "simplify trig", + "trig simplification", + "1 - cos^2", + "sin x sec x = tan x", + "sec^2 - tan^2 = 1", + "fundamental identities", + "reduce trig expression", + "trig algebra", + "verify graphically", + "simplify trigonometric" + ], + "explanation": "A complicated trig expression and its simplified form are the SAME function — so their graphs land exactly on top of each other. The thick green curve is the simplified answer; the thin orange curve is the original messy expression, and they trace identical paths. The two dots ride along at the same height for every x, which is the visual proof that the simplification is correct. Switch the slider to try other classic simplifications.", + "bullets": [ + "A correct simplification produces the identical graph (curves coincide).", + "(1-cos^2 x)/sin x = sin^2 x / sin x = sin x using the Pythagorean identity.", + "If the original and simplified values ever differed, the dots would split apart." + ], + "params": [ + { + "name": "expr", + "label": "example (1,2,3)", + "min": 1, + "max": 3, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0.05, xMax: 2 * Math.PI - 0.05, yMin: -2.2, yMax: 2.2 });\nv.grid(); v.axes();\nconst which = Math.round(P.expr);\nconst eps = 1e-4;\nconst sec = (x) => { const c = Math.cos(x); return Math.abs(c) < eps ? NaN : 1 / c; };\nconst tan = (x) => { const c = Math.cos(x); return Math.abs(c) < eps ? NaN : Math.sin(x) / c; };\nlet orig, simp, origLbl, simpLbl;\nif (which <= 1) {\n orig = (x) => { const s = Math.sin(x); return Math.abs(s) < eps ? NaN : (1 - Math.cos(x) * Math.cos(x)) / s; };\n simp = (x) => Math.sin(x);\n origLbl = \"(1 - cos^2 x) / sin x\"; simpLbl = \"sin x\";\n} else if (which === 2) {\n orig = (x) => { const c = Math.cos(x); return Math.abs(c) < eps ? NaN : Math.sin(x) * sec(x); };\n simp = (x) => tan(x);\n origLbl = \"sin x * sec x\"; simpLbl = \"tan x\";\n} else {\n orig = (x) => sec(x) * sec(x) - tan(x) * tan(x);\n simp = (x) => 1;\n origLbl = \"sec^2 x - tan^2 x\"; simpLbl = \"1\";\n}\nv.fn(simp, { color: H.colors.good, width: 7 });\nv.fn(orig, { color: H.colors.accent2, width: 2.5 });\nconst xs = 0.3 + (Math.sin(t * 0.5) * 0.5 + 0.5) * (2 * Math.PI - 0.6);\nconst yo = orig(xs), yc = simp(xs);\nif (Number.isFinite(yo)) v.dot(xs, yo, { r: 6, fill: H.colors.warn });\nif (Number.isFinite(yc)) v.dot(xs, yc, { r: 6, fill: H.colors.violet });\nH.text(\"Simplifying trig expressions\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(origLbl + \" = \" + simpLbl + \" (same curve, so the identity holds)\", 24, 52, { color: H.colors.sub, size: 13 });\nconst ov = Number.isFinite(yo) ? yo.toFixed(2) : \"undef\";\nconst cv = Number.isFinite(yc) ? yc.toFixed(2) : \"undef\";\nH.text(\"at x = \" + xs.toFixed(2) + \": original = \" + ov + \" simplified = \" + cv, 24, 76, { color: H.colors.warn, size: 13 });\nH.legend([{ label: \"simplified\", color: H.colors.good }, { label: \"original\", color: H.colors.accent2 }], H.W - 160, 88);" + }, + { + "id": "pc-sine-cosine-graphs", + "area": "Precalculus", + "topic": "Sine and cosine graphs", + "title": "Sine and cosine graphs: y = A·sin x, y = A·cos x", + "equation": "y = A*sin(x) and y = A*cos(x)", + "keywords": [ + "sine graph", + "cosine graph", + "sine and cosine graphs", + "y = sin x", + "y = cos x", + "amplitude", + "period 2pi", + "trig graph", + "graphing sine", + "cosine curve", + "sin cos wave", + "phase shift quarter period" + ], + "explanation": "Sine and cosine are the same wave shifted by a quarter period: cos x is just sin x started a quarter-turn earlier, which is why cos starts at its peak and sin starts at 0. The amp slider sets the amplitude A — the height of the peaks above and below the midline — and the readout prints the live y-values as the sweeping dot rides the curve. Use the which slider to show sine only, cosine only, or both together so you can line up their peaks and zeros.", + "bullets": [ + "Both have period 2π and oscillate between +A and −A about the midline y = 0.", + "cos x = sin(x + π/2): cosine is sine shifted left by a quarter period.", + "Amplitude A scales the height; the zeros of one fall at the peaks of the other." + ], + "params": [ + { + "name": "amp", + "label": "amplitude A", + "min": 0.2, + "max": 2.5, + "step": 0.1, + "value": 2 + }, + { + "name": "which", + "label": "1=sin 2=cos 3=both", + "min": 1, + "max": 3, + "step": 1, + "value": 3 + } + ], + "code": "H.background();\nconst A = Math.max(0.2, P.amp);\nconst which = Math.round(P.which);\nconst v = H.plot2d({ xMin: 0, xMax: 2 * Math.PI, yMin: -3, yMax: 3 });\nv.grid(); v.axes();\nv.line(0, 0, 2 * Math.PI, 0, { color: H.colors.violet, width: 1, dash: [5, 5] });\nconst showSin = which !== 2;\nconst showCos = which !== 1;\nif (showSin) v.fn(x => A * Math.sin(x), { color: H.colors.accent, width: 3 });\nif (showCos) v.fn(x => A * Math.cos(x), { color: H.colors.good, width: 3 });\nconst xs = (t * 0.9) % (2 * Math.PI);\nif (showSin) v.dot(xs, A * Math.sin(xs), { r: 6, fill: H.colors.warn });\nif (showCos) v.dot(xs, A * Math.cos(xs), { r: 6, fill: H.colors.accent2 });\nv.line(xs, -3, xs, 3, { color: H.colors.sub, width: 1, dash: [3, 4] });\nconst lbl = which === 1 ? \"y = A·sin(x)\" : which === 2 ? \"y = A·cos(x)\" : \"y = A·sin(x) and y = A·cos(x)\";\nH.text(\"Sine and cosine graphs\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(lbl + \" A = \" + A.toFixed(1) + \" x = \" + xs.toFixed(2) + \" rad\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"sin(x) = \" + (A * Math.sin(xs)).toFixed(2) + \" cos(x) = \" + (A * Math.cos(xs)).toFixed(2), 24, 74, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"sin\", color: H.colors.accent }, { label: \"cos\", color: H.colors.good }], H.W - 130, 28);" + }, + { + "id": "pc-sinusoidal-modeling", + "area": "Precalculus", + "topic": "Sinusoidal modeling", + "title": "Sinusoidal model: fit a wave to data", + "equation": "temp(h) = mid + amp * cos((2*pi/per) * (h - peak))", + "keywords": [ + "sinusoidal modeling", + "model with sine", + "fit a sinusoid", + "real world trig", + "daily temperature model", + "periodic data", + "amplitude midline period", + "cosine model", + "data fitting", + "tides ferris wheel", + "sinusoidal regression" + ], + "explanation": "Real periodic data — like temperature over a day — can be modeled by a sinusoid you tune by hand. The green dots are the measured readings; slide the four controls until the blue cosine rides through them. The midline is the average value, amplitude is half the high-to-low swing, the period is how long before the pattern repeats (24 h here), and peak is the time the curve reaches its maximum. The sweeping dot reads off the model's prediction at each hour.", + "bullets": [ + "midline = average of the high and low; amplitude = half their difference.", + "period is the time for one full cycle; peak sets WHEN the maximum occurs.", + "A good model is the curve that threads through all the data points at once." + ], + "params": [ + { + "name": "amp", + "label": "amplitude (deg)", + "min": 1, + "max": 7, + "step": 0.5, + "value": 4 + }, + { + "name": "per", + "label": "period (hours)", + "min": 4, + "max": 24, + "step": 1, + "value": 12 + }, + { + "name": "peak", + "label": "peak hour", + "min": 0, + "max": 24, + "step": 1, + "value": 15 + }, + { + "name": "mid", + "label": "midline (deg)", + "min": 2, + "max": 14, + "step": 0.5, + "value": 9 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 24, yMin: 0, yMax: 16 });\nv.grid(); v.axes();\nconst amp = P.amp, per = Math.max(1, P.per), peak = P.peak, mid = P.mid;\nconst B = 2 * Math.PI / per;\nconst model = (x) => mid + amp * Math.cos(B * (x - peak));\nconst DMID = 9, DAMP = 4, DPER = 24, DPEAK = 15;\nconst DB = 2 * Math.PI / DPER;\nconst data = (x) => DMID + DAMP * Math.cos(DB * (x - DPEAK));\nv.line(0, mid, 24, mid, { color: H.colors.violet, width: 1.4, dash: [5, 5] });\nfor (let hr = 0; hr <= 24; hr += 2) {\n v.dot(hr, data(hr), { r: 4, fill: H.colors.good });\n}\nv.fn(model, { color: H.colors.accent, width: 3 });\nconst sx = 24 * ((t * 0.12) % 1);\nv.dot(sx, model(sx), { r: 6, fill: H.colors.warn });\nv.line(sx, 0, sx, model(sx), { color: H.colors.warn, width: 1, dash: [3, 3] });\nlet sse = 0;\nfor (let hr = 0; hr <= 24; hr += 2) { const e = model(hr) - data(hr); sse += e * e; }\nH.text(\"temp(h) = mid + amp·cos(2pi/per (h − peak))\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"amp=\" + amp.toFixed(1) + \" per=\" + per.toFixed(1) + \"h peak@h=\" + peak.toFixed(1) + \" mid=\" + mid.toFixed(1) + \" now=\" + model(sx).toFixed(1), 24, 52, { color: H.colors.sub, size: 12 });\nH.text(\"fit error (lower = better): \" + sse.toFixed(1) + \" — tune all four sliders to thread the dots\", 24, 74, { color: H.colors.good, size: 12 });\nH.legend([{ label: \"fitted model\", color: H.colors.accent }, { label: \"data points\", color: H.colors.good }], H.W - 175, 28);" + }, + { + "id": "pc-sum-difference-formulas", + "area": "Precalculus", + "topic": "Sum and difference formulas", + "title": "Difference formula: cos(A - B) = cosA cosB + sinA sinB", + "equation": "cos(A - B) = cosA cosB + sinA sinB", + "keywords": [ + "sum and difference formulas", + "cos(a-b)", + "cos(a+b)", + "angle sum formula", + "angle difference formula", + "cosa cosb + sina sinb", + "sin(a+b)", + "addition formula trig", + "dot product unit vectors", + "compound angle", + "trig sum formula" + ], + "explanation": "The difference formula isn't arbitrary — cos(A - B) is the cosine of the angle BETWEEN two unit arrows pointing at A and B, which equals their dot product cosA*cosB + sinA*sinB. Sweep arrow A around with time and fix arrow B with the slider, and watch the right-side sum (the dot product) stay exactly equal to the left-side cos(A - B) for every position. When A and B coincide the angle between them is 0 and cos(A - B) = 1; when they are perpendicular it drops to 0.", + "bullets": [ + "cos(A - B) measures the angle BETWEEN the two unit arrows.", + "That angle's cosine equals the dot product cosA cosB + sinA sinB.", + "Left side and right side stay equal for every A and B (that's the identity)." + ], + "params": [ + { + "name": "B", + "label": "angle B (degrees)", + "min": 0, + "max": 360, + "step": 1, + "value": 60 + } + ], + "code": "H.background();\nconst cx = H.W * 0.34, cy = H.H * 0.56, R = Math.min(H.W, H.H) * 0.30;\nconst A = t * 0.5;\nconst B = P.B * Math.PI / 180;\nconst cA = Math.cos(A), sA = Math.sin(A), cB = Math.cos(B), sB = Math.sin(B);\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 1.5 });\nH.line(cx - R - 14, cy, cx + R + 14, cy, { color: H.colors.axis, width: 1 });\nH.line(cx, cy - R - 14, cx, cy + R + 14, { color: H.colors.axis, width: 1 });\nconst ax = cx + R * cA, ay = cy - R * sA;\nconst bx = cx + R * cB, by = cy - R * sB;\nH.line(cx, cy, ax, ay, { color: H.colors.accent, width: 2.5 });\nH.line(cx, cy, bx, by, { color: H.colors.accent2, width: 2.5 });\nconst m = 26;\nH.path([[cx + m, cy], [cx + m * Math.cos(B), cy - m * Math.sin(B)]], { color: H.colors.violet, width: 2 });\nH.circle(ax, ay, 5, { fill: H.colors.accent });\nH.circle(bx, by, 5, { fill: H.colors.accent2 });\nH.text(\"A\", ax + 8, ay, { color: H.colors.accent, size: 13 });\nH.text(\"B\", bx + 8, by + 4, { color: H.colors.accent2, size: 13 });\nH.text(\"Sum & difference: cos(A - B) = cosA cosB + sinA sinB\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"cos(A - B) is the cosine of the angle BETWEEN the two unit arrows.\", 24, 52, { color: H.colors.sub, size: 13 });\nconst lhs = Math.cos(A - B);\nconst rhs = cA * cB + sA * sB;\nconst tx = H.W * 0.66, ty = 130, lh = 28;\nH.text(\"A = \" + (A % (2 * Math.PI) * 180 / Math.PI).toFixed(0) + \" deg\", tx, ty, { color: H.colors.accent, size: 14 });\nH.text(\"B = \" + P.B.toFixed(0) + \" deg\", tx, ty + lh, { color: H.colors.accent2, size: 14 });\nH.text(\"cosA cosB = \" + (cA * cB).toFixed(2), tx, ty + 2.4 * lh, { color: H.colors.sub, size: 13 });\nH.text(\"sinA sinB = \" + (sA * sB).toFixed(2), tx, ty + 3.4 * lh, { color: H.colors.sub, size: 13 });\nH.text(\"sum (right side) = \" + rhs.toFixed(3), tx, ty + 4.6 * lh, { color: H.colors.good, size: 14 });\nH.text(\"cos(A - B) (left) = \" + lhs.toFixed(3), tx, ty + 5.6 * lh, { color: H.colors.good, size: 14 });\nH.text(\"they match for every A and B\", tx, ty + 6.8 * lh, { color: H.colors.violet, size: 13 });\nH.legend([{ label: \"arrow at A\", color: H.colors.accent }, { label: \"arrow at B\", color: H.colors.accent2 }], 24, 88);" + }, + { + "id": "pc-tangent-area", + "area": "Precalculus", + "topic": "Tangent-line and area-under-curve concepts", + "title": "Tangent slope & area: secant -> f'(a), rectangles -> integral", + "equation": "slope = lim (h->0) [f(a+h)-f(a)]/h ; area = sum f(x)*dx", + "keywords": [ + "tangent line", + "secant line", + "slope of tangent", + "derivative as limit", + "instantaneous rate of change", + "area under curve", + "riemann sum", + "rectangles", + "approximate area", + "definite integral", + "limit of difference quotient" + ], + "explanation": "The two great ideas of calculus, side by side on f(x) = x^2/2. In tangent mode (mode slider low) a secant line through (a, f(a)) and a nearby point swings as the gap h shrinks toward 0, and its slope homes in on the true tangent slope f'(a) = a. In area mode (mode high) the region from 0 to a is filled with N rectangles whose midpoint heights estimate the area; raise N and the Riemann sum tightens onto the exact area. Move a to pick the point/width and watch each readout converge.", + "bullets": [ + "Tangent slope = limit of secant slopes [f(a+h)-f(a)]/h as h -> 0.", + "Area under a curve = limit of a sum of skinny rectangles f(x)*dx.", + "More rectangles (bigger N) make the Riemann sum approach the exact integral." + ], + "params": [ + { + "name": "mode", + "label": "0=tangent 1=area", + "min": 0, + "max": 1, + "step": 1, + "value": 0 + }, + { + "name": "a", + "label": "point / width a", + "min": 1, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "N", + "label": "rectangles N", + "min": 1, + "max": 20, + "step": 1, + "value": 6 + } + ], + "code": "H.background();\nconst a = P.a; // point where we take the tangent\nconst N = Math.max(1, Math.round(P.N)); // number of area rectangles\nconst mode = P.mode; // <0.5 = tangent (secant->tangent), else area\nconst v = H.plot2d({ xMin: -1, xMax: 5, yMin: -1, yMax: 9 });\nv.grid(); v.axes();\nfunction f(x){ return 0.5 * x * x; } // f(x) = x^2/2\nfunction fp(x){ return x; } // f'(x) = x\nv.fn(f, { color: H.colors.accent, width: 3 });\nif (mode < 0.5){\n // TANGENT: a secant from (a, f(a)) to (a+h, f(a+h)); h shrinks to 0 with t.\n const h = 1.8 * (0.5 + 0.5 * Math.cos(t * 1.1)) + 0.02;\n const x1 = a, x2 = a + h;\n const slope = (f(x2) - f(x1)) / h;\n v.line(x1 - 3, f(x1) - 3 * slope, x2 + 3, f(x2) + 3 * slope, { color: H.colors.accent2, width: 2 });\n v.dot(x1, f(x1), { r: 6, fill: H.colors.warn });\n v.dot(x2, f(x2), { r: 6, fill: H.colors.good });\n H.text(\"Tangent line: slope = lim (h->0) [f(a+h)-f(a)]/h = f'(a)\", 24, 30, { color: H.colors.ink, size: 15, weight: 700 });\n H.text(\"a = \" + a.toFixed(1) + \" h = \" + h.toFixed(3) + \" secant slope = \" + slope.toFixed(3) + \" -> f'(a) = \" + fp(a).toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\n H.legend([{ label: \"f(x) = x^2/2\", color: H.colors.accent }, { label: \"secant -> tangent\", color: H.colors.accent2 }], H.W - 200, 28);\n} else {\n // AREA: N Riemann rectangles from 0 to a; a sweep highlights one at a time.\n const x0 = 0, x1 = Math.max(0.5, a);\n const dx = (x1 - x0) / N;\n const active = Math.floor(t * 1.2) % N;\n let area = 0;\n for (let i = 0; i < N; i++){\n const xl = x0 + i * dx;\n const xm = xl + dx * 0.5;\n const hh = f(xm);\n area += hh * dx;\n v.rect(xl, 0, dx, hh, { fill: i === active ? H.colors.accent2 : \"rgba(124,196,255,0.25)\", stroke: H.colors.accent, width: 1 });\n }\n const exact = (x1 * x1 * x1) / 6; // integral of x^2/2 from 0 to x1\n H.text(\"Area under the curve: sum of rectangles -> integral\", 24, 30, { color: H.colors.ink, size: 15, weight: 700 });\n H.text(\"from 0 to \" + x1.toFixed(1) + \" N = \" + N + \" Riemann sum = \" + area.toFixed(3) + \" exact = \" + exact.toFixed(3), 24, 52, { color: H.colors.sub, size: 13 });\n H.legend([{ label: \"f(x) = x^2/2\", color: H.colors.accent }, { label: \"rectangles\", color: H.colors.accent2 }], H.W - 180, 28);\n}" + }, + { + "id": "pc-tangent-graph", + "area": "Precalculus", + "topic": "Tangent graph", + "title": "Tangent: y = a·tan(b·x)", + "equation": "y = a * tan(b*x)", + "keywords": [ + "tangent", + "tan graph", + "tangent function", + "tan(x)", + "asymptote", + "vertical asymptote", + "period of tangent", + "tan period", + "pi/b", + "trig graph", + "y=a tan bx", + "undefined where cosine is zero" + ], + "explanation": "Unlike sine and cosine, tangent shoots off to infinity wherever cos(b·x) = 0 — those are its vertical asymptotes (the dashed red lines). Between every pair of asymptotes the curve climbs from minus infinity up to plus infinity. The slider b squeezes the whole pattern: the period is pi/b, NOT 2pi/b, so tangent repeats twice as often as sine for the same b. The slider a stretches it vertically.", + "bullets": [ + "Tangent has vertical asymptotes wherever cos(b·x) = 0 (denominator is zero).", + "Period of tan is pi/b — half the period of sin or cos.", + "a is a vertical stretch; tangent has no amplitude because it is unbounded." + ], + "params": [ + { + "name": "a", + "label": "vertical stretch a", + "min": 0.2, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "b", + "label": "frequency b", + "min": 0.2, + "max": 3, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -2 * Math.PI, xMax: 2 * Math.PI, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst a = P.a, b = Math.max(0.2, P.b);\nconst per = Math.PI / b;\nfor (let k = -3; k <= 3; k++) {\n const xa = (k + 0.5) * per;\n if (xa > -2 * Math.PI - 0.01 && xa < 2 * Math.PI + 0.01) {\n v.line(xa, -6, xa, 6, { color: H.colors.warn, width: 1.5, dash: [4, 4] });\n }\n}\nv.fn(x => {\n const c = Math.cos(b * x);\n if (Math.abs(c) < 0.04) return NaN;\n return a * Math.sin(b * x) / c;\n}, { color: H.colors.accent, width: 3 });\nconst xs = 2 * Math.PI * 0.9 * Math.sin(t * 0.5);\nconst cc = Math.cos(b * xs);\nlet ys = Math.abs(cc) < 0.04 ? NaN : a * Math.sin(b * xs) / cc;\nif (Number.isFinite(ys) && ys >= -6 && ys <= 6) v.dot(xs, ys, { r: 6, fill: H.colors.warn });\nH.text(\"y = a · tan(b·x)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a = \" + a.toFixed(2) + \" b = \" + b.toFixed(2) + \" period = \" + per.toFixed(2) + \" (= pi/b)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"tan curve\", color: H.colors.accent }, { label: \"asymptotes\", color: H.colors.warn }], H.W - 170, 28);" + }, + { + "id": "pc-triangle-area-sine", + "area": "Precalculus", + "topic": "Triangle area with sine formula", + "title": "Triangle area: Area = (1/2)ab*sin(C)", + "equation": "Area = (1/2) * a * b * sin(C)", + "keywords": [ + "triangle area", + "area sine formula", + "one half ab sin c", + "sas area", + "area of triangle two sides angle", + "included angle area", + "half base times height", + "trig area", + "sine area formula", + "area without height" + ], + "explanation": "You can find a triangle's area from two sides and the angle between them, with no height measurement needed. The reason is that the height drawn from the far vertex equals h = b*sin(C), so the usual (1/2)*base*height becomes (1/2)*a*(b*sin C). Slide the angle C and watch the dashed height -- and the shaded area -- grow to a maximum exactly at C = 90 degrees, where sin C = 1, then shrink again as the triangle flattens.", + "bullets": [ + "Area = (1/2)*a*b*sin(C); C is the angle between sides a and b.", + "It works because the height equals b*sin(C), recovering (1/2)*base*height.", + "Area peaks at C = 90 deg (sin C = 1) and tends to 0 as C -> 0 or 180 deg." + ], + "params": [ + { + "name": "a", + "label": "side a", + "min": 1, + "max": 8, + "step": 0.5, + "value": 6 + }, + { + "name": "b", + "label": "side b", + "min": 1, + "max": 6, + "step": 0.5, + "value": 4 + }, + { + "name": "C", + "label": "angle C (deg)", + "min": 10, + "max": 170, + "step": 1, + "value": 70 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -7, xMax: 9, yMin: -1.5, yMax: 7 });\nv.grid(); v.axes();\nconst a = Math.max(1, P.a);\nconst b = Math.max(1, P.b);\nconst Cdeg = Math.max(1, Math.min(179, P.C));\nconst C = Cdeg * Math.PI / 180;\nconst area = 0.5 * a * b * Math.sin(C);\nconst Cv = [0, 0];\nconst Bv = [a, 0];\nconst Av = [b * Math.cos(C), b * Math.sin(C)];\nv.path([Av, Bv, Cv], { color: H.colors.accent, width: 3, close: true, fill: H.hsl(150, 60, 45, 0.18) });\nv.dot(Cv[0], Cv[1], { r: 5, fill: H.colors.warn });\nv.dot(Bv[0], Bv[1], { r: 5, fill: H.colors.warn });\nv.dot(Av[0], Av[1], { r: 5, fill: H.colors.warn });\nv.text(\"C\", -0.5, -0.4, { color: H.colors.ink, size: 14 });\nv.text(\"a\", a / 2, -0.5, { color: H.colors.accent2, size: 13 });\nv.text(\"b\", Av[0] / 2 - 0.3, Av[1] / 2 + 0.1, { color: H.colors.accent2, size: 13 });\nconst hgt = b * Math.sin(C);\nv.line(Av[0], Av[1], Av[0], 0, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.text(\"h = b·sin C\", Av[0] + 0.15, hgt / 2, { color: H.colors.violet, size: 12 });\nconst swpAng = C * (0.5 + 0.5 * Math.sin(t * 0.9));\nconst rr = Math.min(a, b) * 0.45;\nconst pts = [];\nfor (let i = 0; i <= 30; i++) { const th = C * i / 30; pts.push([rr * Math.cos(th), rr * Math.sin(th)]); }\nv.path(pts, { color: H.colors.good, width: 2 });\nv.dot(rr * Math.cos(swpAng), rr * Math.sin(swpAng), { r: 5, fill: H.colors.good });\nH.text(\"Area = ½ · a · b · sin C\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a=\" + a.toFixed(2) + \" b=\" + b.toFixed(2) + \" C=\" + Cdeg.toFixed(0) + \"° -> Area = \" + area.toFixed(3), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Area is largest at C = 90° (sin C = 1).\", 24, 74, { color: H.colors.good, size: 12 });" + }, + { + "id": "pc-trig-equations-restricted-interval", + "area": "Precalculus", + "topic": "Trig equations on restricted intervals", + "title": "Restricted interval: cos(x) = k on 0 ≤ x ≤ b", + "equation": "cos(x) = k, 0 <= x <= b", + "keywords": [ + "restricted interval", + "trig equation interval", + "cos x = k", + "solve on interval", + "0 to 2pi", + "domain restriction", + "principal solutions", + "keep solutions in range", + "bounded interval", + "acos", + "solutions in [0,2pi)" + ], + "explanation": "Often you only want solutions inside a specific window, not all of them. The shaded blue band is the allowed interval [0, b] — slide b to widen or shrink it. The equation cos(x) = k has principal solutions acos(k) and 2π − acos(k); a solution stays GREEN only while it sits inside the band and turns gray the moment b shrinks past it. The pink dot sweeps only across the allowed window so the motion itself feels 'restricted'.", + "bullets": [ + "First find ALL solutions, then keep only those inside the interval [0, b].", + "cos(x) = k gives acos(k) and 2π − acos(k) on one period.", + "Shrinking the interval can drop a valid solution — moving b shows this live." + ], + "params": [ + { + "name": "k", + "label": "target value k", + "min": -1, + "max": 1, + "step": 0.05, + "value": 0.4 + }, + { + "name": "hi", + "label": "interval upper bound b", + "min": 0.5, + "max": 6.28, + "step": 0.05, + "value": 6.28 + } + ], + "code": "H.background();\nconst xLo = 0, xHi = 2 * Math.PI;\nconst v = H.plot2d({ xMin: xLo, xMax: xHi, yMin: -1.6, yMax: 1.6 });\nv.grid(); v.axes();\nconst k = Math.max(-1, Math.min(1, P.k)); // target value\nconst hi = Math.max(0.2, Math.min(2 * Math.PI, P.hi)); // interval upper bound\n// shade the allowed interval [0, hi] as a translucent band\nv.rect(0, -1.6, hi, 3.2, { fill: \"rgba(124,196,255,0.12)\" });\nv.fn(x => Math.cos(x), { color: H.colors.accent, width: 3 });\nv.line(xLo, k, xHi, k, { color: H.colors.accent2, width: 2, dash: [6, 5] });\n// cos(x) = k principal solutions on [0, 2π): a and 2π - a\nconst a = Math.acos(k); // [0, π]\nconst sols = [a, 2 * Math.PI - a];\nfor (let i = 0; i < sols.length; i++) {\n const xv = sols[i];\n const inside = xv >= 0 && xv <= hi;\n v.dot(xv, k, { r: 6, fill: inside ? H.colors.good : H.colors.sub });\n}\nconst kept = sols.filter(x => x >= 0 && x <= hi);\n// sweeping dot, but only travels across the allowed window so motion reads \"restricted\"\nconst xs = (t * 0.8) % hi;\nv.dot(xs, Math.cos(xs), { r: 6, fill: H.colors.warn });\n// upper boundary marker\nv.line(hi, -1.6, hi, 1.6, { color: H.colors.violet, width: 1.5 });\nH.text(\"Solve cos(x) = k on 0 ≤ x ≤ b\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"k = \" + k.toFixed(2) + \" b = \" + hi.toFixed(2) + \" kept: \" + (kept.length ? kept.map(x => x.toFixed(2)).join(\", \") : \"none\"), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"in interval\", color: H.colors.good }, { label: \"rejected\", color: H.colors.sub }], H.W - 160, 28);" + }, + { + "id": "pc-trig-equations-using-identities", + "area": "Precalculus", + "topic": "Trig equations using identities", + "title": "Use an identity: sin 2x = c·cos x", + "equation": "sin(2x) = c*cos(x) -> cos(x)*(2*sin(x) - c) = 0", + "keywords": [ + "trig equation identity", + "solve using identities", + "sin 2x = c cos x", + "double angle identity", + "factor trig equation", + "factoring", + "cos x (2 sin x - c)", + "identity substitution", + "solve trig with identity", + "zero product", + "trigonometric identity" + ], + "explanation": "A mixed equation like sin(2x) = c·cos(x) looks hard until you apply an identity. Rewrite sin(2x) as 2 sinx cosx, move everything to one side, and FACTOR: cos(x)·(2 sin x − c) = 0. Now the zero-product rule splits it into two easy equations — cos x = 0 (always) and sin x = c/2 (only when |c/2| ≤ 1). Slide c and watch the green roots: the two from cos x = 0 stay fixed while the sin x = c/2 pair appears and slides, and the violet difference curve crosses zero exactly at every root.", + "bullets": [ + "Replace sin 2x with 2 sinx cosx, then factor out the common cos x.", + "Zero-product rule: cos x = 0 OR 2 sin x − c = 0 (so sin x = c/2).", + "The sin x = c/2 roots only exist when |c/2| ≤ 1; cos x = 0 roots are always there." + ], + "params": [ + { + "name": "c", + "label": "coefficient c", + "min": -2, + "max": 2, + "step": 0.05, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 2 * Math.PI, yMin: -2.6, yMax: 2.6 });\nv.grid(); v.axes();\nconst c = P.c; // coefficient: solve sin(2x) = c*cos(x)\n// Identity: sin(2x) = 2 sinx cosx, so sin(2x) - c cosx = cosx(2 sinx - c).\n// Roots: cosx = 0 OR sinx = c/2.\nconst lhs = x => Math.sin(2 * x);\nconst rhs = x => c * Math.cos(x);\nv.fn(lhs, { color: H.colors.accent, width: 2.6 });\nv.fn(rhs, { color: H.colors.accent2, width: 2.6 });\n// difference curve whose zeros ARE the solutions\nv.fn(x => Math.sin(2 * x) - c * Math.cos(x), { color: H.colors.violet, width: 1.6 });\n// mark the factored roots on [0, 2π)\nconst roots = [Math.PI / 2, 3 * Math.PI / 2]; // cosx = 0 always\nconst r = c / 2;\nif (Math.abs(r) <= 1) {\n const b = Math.asin(r);\n roots.push((b + 2 * Math.PI) % (2 * Math.PI));\n roots.push((Math.PI - b + 2 * Math.PI) % (2 * Math.PI));\n}\nfor (let i = 0; i < roots.length; i++) v.dot(roots[i], 0, { r: 6, fill: H.colors.good });\n// sweeping intersection-finder dot tracing the difference curve\nconst xs = (t * 0.8) % (2 * Math.PI);\nv.dot(xs, Math.sin(2 * xs) - c * Math.cos(xs), { r: 6, fill: H.colors.warn });\nH.text(\"sin 2x = c·cos x → cos x (2 sin x − c) = 0\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"c = \" + c.toFixed(2) + \" roots: cos x = 0 or sin x = c/2 = \" + r.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"sin 2x\", color: H.colors.accent }, { label: \"c·cos x\", color: H.colors.accent2 }, { label: \"difference\", color: H.colors.violet }], H.W - 160, 28);" + }, + { + "id": "pc-trig-signs-quadrants", + "area": "Precalculus", + "topic": "Trig signs in all quadrants", + "title": "Trig signs by quadrant (ASTC)", + "equation": "Q I: all +, Q II: sin +, Q III: tan +, Q IV: cos +", + "keywords": [ + "trig signs", + "signs in all quadrants", + "astc", + "all students take calculus", + "quadrant signs", + "sin cos tan positive negative", + "cast rule", + "which quadrant", + "sign of sine cosine", + "positive negative trig" + ], + "explanation": "The sign of each trig value is just the sign of a coordinate: cos θ follows the x-coordinate and sin θ follows the y-coordinate, while tan θ = sin/cos. The deg slider sweeps the ray around all four quadrants; the dashed blue (x) and green (y) legs flip sign as the point crosses an axis, and the readout shows the resulting +/− pattern. That pattern is the ASTC rule — All positive in QI, then only Sin, then only Tan, then only Cos as you go counterclockwise.", + "bullets": [ + "cos θ has the sign of x; sin θ has the sign of y; tan θ = sin θ / cos θ.", + "ASTC: QI all +, QII sin +, QIII tan +, QIV cos + (counterclockwise).", + "Crossing an axis flips exactly one coordinate, flipping the matching ratios." + ], + "params": [ + { + "name": "deg", + "label": "angle θ (degrees)", + "min": 0, + "max": 360, + "step": 2, + "value": 200 + } + ], + "code": "H.background();\nconst cx = H.W * 0.5, cy = H.H * 0.54, R = Math.min(H.W, H.H) * 0.3;\nconst deg = ((P.deg % 360) + 360) % 360;\nconst ang = deg * Math.PI / 180;\nH.rect(cx, cy - R - 16, R + 16, R + 16, { fill: \"rgba(124,196,255,0.07)\" });\nH.rect(cx - R - 16, cy - R - 16, R + 16, R + 16, { fill: \"rgba(103,232,176,0.07)\" });\nH.rect(cx - R - 16, cy, R + 16, R + 16, { fill: \"rgba(244,162,89,0.07)\" });\nH.rect(cx, cy, R + 16, R + 16, { fill: \"rgba(255,138,160,0.07)\" });\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 1.5 });\nH.line(cx - R - 16, cy, cx + R + 16, cy, { color: H.colors.axis, width: 1 });\nH.line(cx, cy - R - 16, cx, cy + R + 16, { color: H.colors.axis, width: 1 });\nH.text(\"I: All +\", cx + R * 0.3, cy - R * 0.55, { color: H.colors.accent, size: 13 });\nH.text(\"II: Sin +\", cx - R * 0.85, cy - R * 0.55, { color: H.colors.good, size: 13 });\nH.text(\"III: Tan +\", cx - R * 0.85, cy + R * 0.6, { color: H.colors.accent2, size: 13 });\nH.text(\"IV: Cos +\", cx + R * 0.3, cy + R * 0.6, { color: H.colors.warn, size: 13 });\nconst px = cx + R * Math.cos(ang), py = cy - R * Math.sin(ang);\nH.line(cx, cy, px, py, { color: H.colors.violet, width: 2.5 });\nH.circle(px, py, 6 + Math.sin(t * 3), { fill: H.colors.warn });\nH.line(px, py, px, cy, { color: H.colors.good, width: 2, dash: [4, 4] });\nH.line(px, py, cx, py, { color: H.colors.accent, width: 2, dash: [4, 4] });\nconst eps = 1e-9;\nconst snap = (x) => Math.abs(x) < eps ? 0 : x;\nconst co = snap(Math.cos(ang)), si = snap(Math.sin(ang));\nconst onAxis = (co === 0 || si === 0);\nconst ta = co === 0 ? NaN : si / co;\nconst q = onAxis ? \"on axis\" : (deg < 90 ? \"I\" : deg < 180 ? \"II\" : deg < 270 ? \"III\" : \"IV\");\nconst sg = (x) => x > 0 ? \"+\" : x < 0 ? \"−\" : \"0\";\nconst sgT = co === 0 ? \"undef\" : sg(ta);\nH.text(\"Trig signs by quadrant (ASTC)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"θ = \" + deg.toFixed(0) + \"° → Quadrant \" + q, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"sin \" + sg(si) + \" cos \" + sg(co) + \" tan \" + sgT, 24, 74, { color: H.colors.violet, size: 13 });\nH.legend([{ label: \"cos θ (x)\", color: H.colors.accent }, { label: \"sin θ (y)\", color: H.colors.good }], H.W - 160, 28);" + }, + { + "id": "pc-unit-circle-coordinates", + "area": "Precalculus", + "topic": "Unit circle coordinates", + "title": "Unit circle coordinates: (x, y) = (cos θ, sin θ)", + "equation": "(x, y) = (cos theta, sin theta)", + "keywords": [ + "unit circle coordinates", + "cos sin point", + "x equals cos theta", + "y equals sin theta", + "point on unit circle", + "coordinates from angle", + "trig coordinates", + "circle radius 1", + "reference angle", + "cos2 plus sin2 equals 1" + ], + "explanation": "On a circle of radius 1 centered at the origin, every angle θ lands on a single point whose coordinates ARE (cos θ, sin θ). The slider sets the angle; the blue leg is the x-coordinate (cos θ) and the green leg is the y-coordinate (sin θ). Because the radius is exactly 1, the Pythagorean theorem becomes cos²θ + sin²θ = 1 — the point can never leave the circle, no matter the angle.", + "bullets": [ + "The x-coordinate of the point is cos θ; the y-coordinate is sin θ.", + "Signs flip by quadrant: cos is the horizontal reach, sin is the vertical reach.", + "cos²θ + sin²θ = 1 always — it's the Pythagorean theorem on radius 1." + ], + "params": [ + { + "name": "deg", + "label": "angle θ (degrees)", + "min": 0, + "max": 360, + "step": 1, + "value": 60 + } + ], + "code": "H.background();\nconst w = H.W, hh = H.H;\nconst cx = w * 0.5, cy = hh * 0.54, R = Math.min(w, hh) * 0.32;\nconst maxDeg = Math.max(1, P.deg);\nconst sweep = (Math.sin(t * 0.4 - Math.PI / 2) + 1) / 2;\nconst deg = maxDeg * sweep;\nconst ang = deg * Math.PI / 180;\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 1.5 });\nH.line(cx - R - 16, cy, cx + R + 16, cy, { color: H.colors.axis, width: 1 });\nH.line(cx, cy - R - 16, cx, cy + R + 16, { color: H.colors.axis, width: 1 });\nconst cosv = Math.cos(ang), sinv = Math.sin(ang);\nconst px = cx + R * cosv, py = cy - R * sinv;\nH.line(cx, cy, px, py, { color: H.colors.violet, width: 2 });\nH.line(px, py, px, cy, { color: H.colors.good, width: 2.5, dash: [4, 4] });\nH.line(cx, cy, px, cy, { color: H.colors.accent, width: 2.5 });\nH.circle(px, py, 7, { fill: H.colors.warn });\nH.text(\"x = cos θ\", px + (cosv >= 0 ? 8 : -70), cy + (sinv >= 0 ? 18 : -8), { color: H.colors.accent, size: 12, weight: 700 });\nH.text(\"y = sin θ\", px + (cosv >= 0 ? 10 : -78), py + 4, { color: H.colors.good, size: 12, weight: 700 });\nH.text(\"Unit circle coordinates: (cos θ, sin θ)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"θ = \" + deg.toFixed(0) + \"° (x, y) = (\" + cosv.toFixed(2) + \", \" + sinv.toFixed(2) + \")\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"cos²θ + sin²θ = \" + (cosv * cosv + sinv * sinv).toFixed(2) + \" (always 1, radius = 1)\", 24, 74, { color: H.colors.good, size: 12 });\nH.legend([{ label: \"cos θ (x)\", color: H.colors.accent }, { label: \"sin θ (y)\", color: H.colors.good }], H.W - 160, 28);" + }, + { + "id": "pc-vector-components", + "area": "Precalculus", + "topic": "Vector components", + "title": "Vector components: vx = |v|·cos(theta), vy = |v|·sin(theta)", + "equation": "vx = |v|*cos(theta), vy = |v|*sin(theta)", + "keywords": [ + "vector components", + "horizontal component", + "vertical component", + "resolve a vector", + "vx vy", + "cosine sine components", + "magnitude angle to components", + "polar to rectangular", + "component form", + "x and y components", + "decompose vector" + ], + "explanation": "Any vector can be split into a horizontal piece vx and a vertical piece vy that, placed tip-to-tail, rebuild it exactly. Because the vector is the hypotenuse of a right triangle, vx = |v|·cos(theta) (the adjacent side) and vy = |v|·sin(theta) (the opposite side). Drag the magnitude to lengthen the arrow and the angle to rotate it; the green and purple legs stretch and shrink as the components change.", + "bullets": [ + "vx is the shadow on the x-axis (|v|·cos theta); vy is the shadow on the y-axis (|v|·sin theta).", + "The vector, vx, and vy form a right triangle: vx and vy are the legs, v is the hypotenuse.", + "At theta = 0 the vector is all horizontal; at 90° it is all vertical." + ], + "params": [ + { + "name": "mag", + "label": "magnitude |v|", + "min": 1, + "max": 9, + "step": 0.5, + "value": 6 + }, + { + "name": "angle", + "label": "angle theta (deg)", + "min": 0, + "max": 360, + "step": 1, + "value": 35 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -10, yMax: 10 });\nv.grid(); v.axes();\nconst mag = P.mag, ang = P.angle * Math.PI / 180;\nconst vx = mag * Math.cos(ang), vy = mag * Math.sin(ang);\nv.line(0, 0, vx, 0, { color: H.colors.good, width: 3 });\nv.line(vx, 0, vx, vy, { color: H.colors.violet, width: 3 });\nv.arrow(0, 0, vx, vy, { color: H.colors.accent, width: 3 });\nconst k = 0.5 + 0.5 * Math.sin(t * 1.3);\nv.dot(vx * k, vy * k, { r: 6, fill: H.colors.warn });\nv.text(\"vx = \" + vx.toFixed(1), vx / 2 - 0.5, -0.8, { color: H.colors.good, size: 13 });\nv.text(\"vy = \" + vy.toFixed(1), vx + 0.3, vy / 2, { color: H.colors.violet, size: 13 });\nv.text(\"v\", vx / 2, vy / 2 + 0.8, { color: H.colors.accent, size: 14 });\nH.text(\"Vector components: vx = |v|·cos(theta), vy = |v|·sin(theta)\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"|v| = \" + mag.toFixed(1) + \" theta = \" + P.angle.toFixed(0) + \"° -> (vx, vy) = (\" + vx.toFixed(2) + \", \" + vy.toFixed(2) + \")\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"v\", color: H.colors.accent }, { label: \"vx\", color: H.colors.good }, { label: \"vy\", color: H.colors.violet }], H.W - 130, 28);" + }, + { + "id": "pc-vector-magnitude-direction", + "area": "Precalculus", + "topic": "Vector magnitude and direction", + "title": "Magnitude & direction: |v| = sqrt(vx² + vy²)", + "equation": "|v| = sqrt(vx^2 + vy^2), theta = atan2(vy, vx)", + "keywords": [ + "vector magnitude", + "vector direction", + "length of a vector", + "magnitude formula", + "direction angle", + "atan2", + "pythagorean vector", + "rectangular to polar", + "norm of a vector", + "magnitude and angle", + "find the angle of a vector" + ], + "explanation": "Given a vector's components, you can recover how long it is and which way it points. Its length |v| is just the Pythagorean theorem on the legs vx and vy, and its direction is the angle theta = atan2(vy, vx) measured from the positive x-axis. Drag vx and vy to reshape the arrow: the magnitude readout grows with the hypotenuse and the pink arc tracks the direction angle.", + "bullets": [ + "|v| = sqrt(vx² + vy²): the magnitude is the hypotenuse of the component triangle.", + "theta = atan2(vy, vx) gives the direction, automatically picking the correct quadrant.", + "Components and (magnitude, direction) are two equivalent ways to name the same vector." + ], + "params": [ + { + "name": "vx", + "label": "x-component vx", + "min": -9, + "max": 9, + "step": 0.5, + "value": 6 + }, + { + "name": "vy", + "label": "y-component vy", + "min": -6, + "max": 6, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -7, yMax: 7 });\nv.grid(); v.axes();\nconst vx = P.vx, vy = P.vy;\nconst mag = Math.sqrt(vx * vx + vy * vy);\nlet dir = Math.atan2(vy, vx) * 180 / Math.PI;\nif (dir < 0) dir += 360;\nv.line(0, 0, vx, 0, { color: H.colors.good, width: 2, dash: [4, 4] });\nv.line(vx, 0, vx, vy, { color: H.colors.violet, width: 2, dash: [4, 4] });\nv.arrow(0, 0, vx, vy, { color: H.colors.accent, width: 3 });\nconst aMax = Math.atan2(vy, vx);\nconst steps = 24;\nconst arc = [];\nfor (let i = 0; i <= steps; i++) { const a = aMax * (i / steps); arc.push([1.6 * Math.cos(a), 1.6 * Math.sin(a)]); }\nv.path(arc, { color: H.colors.warn, width: 2 });\nconst k = 0.5 + 0.5 * Math.sin(t * 1.3);\nv.dot(vx * k, vy * k, { r: 6, fill: H.colors.warn });\nv.text(\"|v| = \" + mag.toFixed(2), vx / 2 + 0.3, vy / 2 + 0.5, { color: H.colors.accent, size: 13 });\nv.text(\"theta = \" + dir.toFixed(0) + \"°\", 1.9, 0.8, { color: H.colors.warn, size: 12 });\nH.text(\"Magnitude & direction: |v| = sqrt(vx² + vy²), theta = atan2(vy, vx)\", 24, 30, { color: H.colors.ink, size: 15, weight: 700 });\nH.text(\"v = (\" + vx.toFixed(1) + \", \" + vy.toFixed(1) + \") -> |v| = \" + mag.toFixed(2) + \" direction = \" + dir.toFixed(1) + \"°\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"v\", color: H.colors.accent }, { label: \"angle theta\", color: H.colors.warn }], H.W - 140, 28);" + }, + { + "id": "pc-vector-operations", + "area": "Precalculus", + "topic": "Vector addition/subtraction/scalar multiplication", + "title": "Vector ops: a + b, a - b, s·a", + "equation": "a + b = (ax+bx, ay+by); a - b = (ax-bx, ay-by); s*a = (s*ax, s*ay)", + "keywords": [ + "vector addition", + "vector subtraction", + "scalar multiplication", + "tip to tail", + "resultant vector", + "add vectors", + "subtract vectors", + "scale a vector", + "parallelogram rule", + "component wise", + "combining vectors", + "vector arithmetic" + ], + "explanation": "The same three operations work component by component. Set the operation slider: addition places b tip-to-tail after a so the resultant a + b reaches b's new tip; subtraction adds the reversed b; scalar multiplication stretches a by the factor s (negative s flips it around). Drag a's and b's components and watch the blue resultant arrow rebuild itself from the dashed construction.", + "bullets": [ + "Add or subtract by combining matching components: a ± b = (ax ± bx, ay ± by).", + "Geometrically, addition is tip-to-tail; the resultant runs from the first tail to the last tip.", + "s·a scales the length by |s| and reverses direction when s is negative." + ], + "params": [ + { + "name": "ax", + "label": "a: x-component", + "min": -6, + "max": 6, + "step": 0.5, + "value": 3 + }, + { + "name": "ay", + "label": "a: y-component", + "min": -5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "bx", + "label": "b: x / scalar s", + "min": -5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "op", + "label": "0=add 1=sub 2=scale", + "min": 0, + "max": 2, + "step": 1, + "value": 0 + } + ], + "code": "H.background();\nconst ax = P.ax, ay = P.ay, bx = P.bx, by = 0;\nconst s = P.bx;\nconst op = Math.round(P.op);\nlet rx, ry, label, opName;\nif (op <= 0) { rx = ax + bx; ry = ay + by; label = \"a + b\"; opName = \"addition\"; }\nelse if (op === 1) { rx = ax - bx; ry = ay - by; label = \"a - b\"; opName = \"subtraction\"; }\nelse { rx = s * ax; ry = s * ay; label = s.toFixed(1) + \"·a\"; opName = \"scalar multiple\"; }\n// Adaptive view: make sure every drawn vector tip fits on-screen at all settings.\nconst xsAll = [ax, bx, rx, ax + bx, ax - bx, 0];\nconst ysAll = [ay, by, ry, ay + by, ay - by, 0];\nlet mx = 1, my = 1;\nfor (let i = 0; i < xsAll.length; i++) mx = Math.max(mx, Math.abs(xsAll[i]));\nfor (let i = 0; i < ysAll.length; i++) my = Math.max(my, Math.abs(ysAll[i]));\nconst xR = mx * 1.25, yR = my * 1.25;\nconst view = H.plot2d({ xMin: -xR, xMax: xR, yMin: -yR, yMax: yR });\nview.grid(); view.axes();\nview.arrow(0, 0, ax, ay, { color: H.colors.good, width: 3 });\nif (op < 2) {\n if (op === 0) view.arrow(ax, ay, ax + bx, ay + by, { color: H.colors.violet, width: 2, dash: [5, 4] });\n else view.arrow(ax, ay, ax - bx, ay - by, { color: H.colors.violet, width: 2, dash: [5, 4] });\n view.arrow(0, 0, bx, by, { color: H.colors.accent2, width: 3 });\n}\nview.arrow(0, 0, rx, ry, { color: H.colors.accent, width: 4 });\nconst k = 0.5 + 0.5 * Math.sin(t * 1.3);\nview.dot(rx * k, ry * k, { r: 6, fill: H.colors.warn });\nview.text(\"a\", ax / 2, ay / 2 + yR * 0.05, { color: H.colors.good, size: 13 });\nview.text(label, rx / 2 + xR * 0.03, ry / 2 - yR * 0.05, { color: H.colors.accent, size: 13 });\nH.text(\"Vector \" + opName + \": tip-to-tail / scaling\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(op < 2 ? (\"a = (\" + ax.toFixed(1) + \", \" + ay.toFixed(1) + \") b = (\" + bx.toFixed(1) + \", \" + by.toFixed(1) + \") -> \" + label + \" = (\" + rx.toFixed(1) + \", \" + ry.toFixed(1) + \")\") : (\"a = (\" + ax.toFixed(1) + \", \" + ay.toFixed(1) + \") s = \" + s.toFixed(1) + \" -> \" + label + \" = (\" + rx.toFixed(1) + \", \" + ry.toFixed(1) + \")\"), 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"a\", color: H.colors.good }, { label: \"b\", color: H.colors.accent2 }, { label: \"result\", color: H.colors.accent }], H.W - 140, 28);" + } +] \ No newline at end of file diff --git a/desktop.py b/desktop.py new file mode 100755 index 0000000..9cb549f --- /dev/null +++ b/desktop.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Run VisualLM as a NATIVE desktop window (no browser chrome at all). + +This is the most "real app" option. It needs one extra package: + + pip install pywebview + python3 desktop.py + +It starts the local server in the background and opens VisualLM in a native OS +window (WKWebView on macOS, WebView2 on Windows, GTK/Qt on Linux). API keys via +.env work exactly as in launch.py. If you don't want the extra dependency, use +`python3 launch.py` instead — it opens an app-style browser window. +""" +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import launch # reuse .env loading, free_port, and the health check + +HERE = Path(__file__).resolve().parent + + +def main() -> int: + try: + import webview # pywebview + except ImportError: + print("This needs pywebview: pip install pywebview") + print("(or just run: python3 launch.py — opens an app-style browser window)") + return 1 + + launch.load_dotenv(HERE / ".env") + forced = os.environ.get("VISUALLM_PORT") + port = int(forced) if forced else launch.free_port(4173) + os.environ["VISUALLM_PORT"] = str(port) + os.environ.setdefault("VISUALLM_HOST", "127.0.0.1") + url = f"http://127.0.0.1:{port}" + + server = subprocess.Popen([sys.executable, str(HERE / "main.py")], cwd=str(HERE)) + try: + if not launch.wait_healthy(f"{url}/api/health"): + print("✗ The server did not start — see output above.") + return 1 + webview.create_window("VisualLM", url, width=1280, height=820, min_size=(900, 600)) + webview.start() # blocks until the window closes (must run on main thread) + finally: + if server.poll() is None: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dlc.py b/dlc.py new file mode 100644 index 0000000..6a3d64f --- /dev/null +++ b/dlc.py @@ -0,0 +1,179 @@ +"""DLC (Downloadable Content) pack engine — desktop side. + +Mirrors web/js/browser-backend.js: a DLC is a pack of demos (+ experiments). +Official packs (data/dlc/*.dlc.json) are lightweight area-filter manifests +resolved against the built-in demo library; custom / AI / user packs embed full +demo objects and live as files under data/dlc_custom/. A pack's `code` only ever +runs in the sandboxed worker, exactly like any other demo. + +Pure/self-contained: callers pass a `library_index` (a list of light demo dicts +with id/area/topic/title/equation) so this module never imports the library. +""" +from __future__ import annotations + +import json +import os +import re + +_HERE = os.path.dirname(os.path.abspath(__file__)) +DLC_DIR = os.path.join(_HERE, "data", "dlc") # official packs (read-only) +CUSTOM_DIR = os.path.join(_HERE, "data", "dlc_custom") # imported / AI packs + + +def _read_json(path: str): + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: # noqa: BLE001 — missing/corrupt file is non-fatal + return None + + +def official_packs() -> list[dict]: + index = _read_json(os.path.join(DLC_DIR, "index.json")) or [] + out = [] + for fname in index: + p = _read_json(os.path.join(DLC_DIR, fname)) + if isinstance(p, dict): + p["source"] = "official" + out.append(p) + return out + + +def custom_packs() -> list[dict]: + out = [] + if os.path.isdir(CUSTOM_DIR): + for fname in sorted(os.listdir(CUSTOM_DIR)): + if fname.endswith(".json"): + p = _read_json(os.path.join(CUSTOM_DIR, fname)) + if isinstance(p, dict): + p["source"] = p.get("source", "user") + out.append(p) + return out + + +def all_packs() -> list[dict]: + return official_packs() + custom_packs() + + +def find_pack(pack_id: str) -> dict | None: + for p in all_packs(): + if p.get("id") == pack_id: + return p + return None + + +def find_embedded(demo_id: str) -> dict | None: + """A demo/experiment object embedded in any pack (custom or official).""" + for p in custom_packs() + official_packs(): + for d in (p.get("demos") or []) + (p.get("experiments") or []): + if d.get("id") == demo_id: + return d + return None + + +def validate(obj) -> list[str]: + """Return a list of validation errors ([] == valid).""" + errors: list[str] = [] + if not isinstance(obj, dict): + return ["Not a JSON object."] + if str(obj.get("format", "")).split("/")[0] != "visuallm-dlc": + errors.append('Missing or bad `format` (expected "visuallm-dlc/1").') + if not obj.get("id") or not isinstance(obj.get("id"), str): + errors.append("Missing `id`.") + if not obj.get("name") or not isinstance(obj.get("name"), str): + errors.append("Missing `name`.") + demos = obj.get("demos") or [] + exps = obj.get("experiments") or [] + areas = obj.get("areas") or [] + if not demos and not exps and not areas: + errors.append("Pack is empty (no demos, experiments, or areas).") + for i, d in enumerate(list(demos) + list(exps)): + if not isinstance(d, dict): + errors.append(f"Item {i} is not an object.") + continue + if not d.get("id"): + errors.append(f"Item {i} is missing `id`.") + if not str(d.get("code", "")).strip(): + errors.append(f"Item {d.get('id', i)} is missing animation `code`.") + return errors + + +def pack_meta(p: dict, library_index: list[dict]) -> dict: + areas = p.get("areas") or [] + area_count = sum(1 for d in library_index if d.get("area") in areas) if areas else 0 + return { + "id": p.get("id"), + "name": p.get("name") or p.get("id"), + "description": p.get("description", ""), + "category": p.get("category", "custom"), + "icon": p.get("icon") or ("📦" if p.get("source") == "official" else "🧩"), + "source": p.get("source", "user"), + "demoCount": area_count + len(p.get("demos") or []), + "experimentCount": len(p.get("experiments") or []), + } + + +def pack_catalog(p: dict, library_index: list[dict]) -> dict: + groups: dict[str, dict[str, list]] = {} + + def push(area, topic, d): + area = area or "General" + topic = topic or "Demos" + groups.setdefault(area, {}).setdefault(topic, []).append(d) + + areas = p.get("areas") or [] + if areas: + for d in library_index: + if d.get("area") in areas: + push(d.get("area"), d.get("topic"), + {"id": d.get("id"), "title": d.get("title"), "equation": d.get("equation", "")}) + for d in (p.get("demos") or []): + push(d.get("area"), d.get("topic"), + {"id": d.get("id"), "title": d.get("title"), "equation": d.get("equation", "")}) + + sections = [] + for area in sorted(groups): + topics = [{"topic": t, "demos": groups[area][t]} for t in sorted(groups[area])] + sections.append({"area": area, "topics": topics}) + experiments = [ + {"id": e.get("id"), "title": e.get("title"), "blurb": e.get("blurb", "")} + for e in (p.get("experiments") or []) + ] + return { + "id": p.get("id"), + "name": p.get("name"), + "description": p.get("description", ""), + "icon": p.get("icon", "📦"), + "source": p.get("source", "official"), + "sections": sections, + "experiments": experiments, + } + + +def _slug(s: str) -> str: + return re.sub(r"[^a-z0-9-]+", "-", (s or "pack").lower()).strip("-") or "pack" + + +def import_pack(obj: dict) -> dict: + """Persist an imported/generated pack as a file. Assumes validate() passed.""" + os.makedirs(CUSTOM_DIR, exist_ok=True) + obj = dict(obj) + obj["source"] = obj.get("source") if obj.get("source") in ("ai", "user") else "user" + with open(os.path.join(CUSTOM_DIR, _slug(obj.get("id")) + ".json"), "w", encoding="utf-8") as f: + json.dump(obj, f) + return obj + + +def remove_pack(pack_id: str) -> bool: + path = os.path.join(CUSTOM_DIR, _slug(pack_id) + ".json") + if os.path.exists(path): + os.remove(path) + return True + # Fall back to a scan (id may differ from the slugged filename). + if os.path.isdir(CUSTOM_DIR): + for fname in os.listdir(CUSTOM_DIR): + p = _read_json(os.path.join(CUSTOM_DIR, fname)) + if isinstance(p, dict) and p.get("id") == pack_id: + os.remove(os.path.join(CUSTOM_DIR, fname)) + return True + return False diff --git a/geometry_calculus_generated.json b/geometry_calculus_generated.json new file mode 100644 index 0000000..c465de8 --- /dev/null +++ b/geometry_calculus_generated.json @@ -0,0 +1,3515 @@ +[ + { + "id": "geo-angle-types", + "area": "Geometry", + "topic": "Angle types", + "title": "Angle types: acute < 90, right = 90, obtuse > 90, straight = 180", + "equation": "acute < 90 < obtuse < 180 = straight (right = 90)", + "keywords": [ + "angle types", + "acute angle", + "right angle", + "obtuse angle", + "straight angle", + "classify angles", + "degrees", + "measure of an angle", + "reflex", + "protractor", + "angle classification" + ], + "explanation": "An angle is named purely by how open it is: acute angles measure less than 90 degrees, a right angle is exactly 90, obtuse angles fall between 90 and 180, and a straight angle is a full 180 (the two rays point in opposite directions). The yellow arc shows the opening between a fixed base ray and the orange rotating ray, and as you drag the slider the live label snaps to the correct category at the boundary values. The classification depends only on the measure, never on the size of the rays or how the angle is positioned, which is why moving the ray smoothly walks the label through acute, right, obtuse, and straight.", + "bullets": [ + "The yellow arc measures the opening between the two rays in degrees.", + "Acute < 90: the rotating ray stays inside a right angle.", + "Right = 90 and straight = 180 are the two named boundary cases.", + "Classification depends only on the measure, not the rays' length." + ], + "params": [ + { + "name": "ang", + "label": "angle (deg)", + "min": 0, + "max": 180, + "step": 1, + "value": 50 + } + ], + "code": "H.background();\nconst cx = H.W * 0.42, cy = H.H * 0.62, R = 150;\nconst a = Math.max(0, Math.min(180, P.ang));\nconst wob = 6 * Math.sin(t * 1.2);\nconst ang = Math.max(0, Math.min(180, a + wob));\nconst rad = ang * Math.PI / 180;\nH.text(\"Angle types: classify by measure\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Drag the ray; an angle is acute (<90), right (=90), obtuse (>90) or straight (=180).\", 24, 52, { color: H.colors.sub, size: 13 });\nH.line(cx, cy, cx + R, cy, { color: H.colors.axis, width: 3 });\nconst ex = cx + R * Math.cos(rad), ey = cy - R * Math.sin(rad);\nH.arrow(cx, cy, ex, ey, { color: H.colors.accent, width: 3 });\nconst arc = [];\nfor (let i = 0; i <= 40; i++) {\n const th = rad * i / 40;\n arc.push([cx + 52 * Math.cos(th), cy - 52 * Math.sin(th)]);\n}\nH.path(arc, { color: H.colors.yellow, width: 3 });\nlet label = \"acute\", col = H.colors.good;\nif (Math.abs(ang - 90) < 0.75) { label = \"right\"; col = H.colors.accent2; }\nelse if (ang > 90.75 && ang < 179.25) { label = \"obtuse\"; col = H.colors.violet; }\nelse if (ang >= 179.25) { label = \"straight\"; col = H.colors.warn; }\nelse if (ang <= 0.75) { label = \"zero\"; col = H.colors.sub; }\nH.circle(cx, cy, 5, { fill: H.colors.ink });\nH.text(ang.toFixed(0) + \" deg\", cx + 70 * Math.cos(rad / 2), cy - 70 * Math.sin(rad / 2), { color: H.colors.ink, size: 15, weight: 700 });\nH.text(label.toUpperCase(), 24, 84, { color: col, size: 16, weight: 700 });\nH.text(\"measure = \" + ang.toFixed(1) + \" deg\", 24, 108, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"acute <90\", color: H.colors.good }, { label: \"right =90\", color: H.colors.accent2 }, { label: \"obtuse >90\", color: H.colors.violet }, { label: \"straight =180\", color: H.colors.warn }], H.W - 200, 90);" + }, + { + "id": "geo-complementary-supplementary", + "area": "Geometry", + "topic": "Complementary & supplementary angles", + "title": "Complementary & supplementary: a + (90-a) = 90, a + (180-a) = 180", + "equation": "complement = 90 - a, supplement = 180 - a", + "keywords": [ + "complementary angles", + "supplementary angles", + "complement", + "supplement", + "add to 90", + "add to 180", + "adjacent angles", + "right angle", + "straight line", + "linear pair", + "angle pair" + ], + "explanation": "Two angles are complementary when they add to 90 degrees and supplementary when they add to 180, so once you know one angle the other is forced to be 90 - a or 180 - a. On the left, the blue and green arcs are adjacent pieces of a right angle, so they must total 90; on the right, the blue and purple arcs are adjacent pieces along a straight line, so they must total 180. As the slider changes a, each partner angle updates to keep the running sum exactly 90 (left) or 180 (right), which is why a larger angle always leaves a smaller complement or supplement.", + "bullets": [ + "Left: a and 90-a are adjacent pieces of a right angle (sum 90).", + "Right: a and 180-a are adjacent pieces of a straight line (sum 180).", + "Knowing one angle fixes its partner: subtract from 90 or 180.", + "Increasing a shrinks both the complement and the supplement." + ], + "params": [ + { + "name": "a", + "label": "angle a (deg)", + "min": 1, + "max": 89, + "step": 1, + "value": 35 + } + ], + "code": "H.background();\nconst a = Math.max(1, Math.min(89, P.a + 4 * Math.sin(t * 0.9)));\nconst aRad = a * Math.PI / 180;\nH.text(\"Complementary & supplementary angles\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Two angles are complementary if they sum to 90, supplementary if they sum to 180.\", 24, 52, { color: H.colors.sub, size: 13 });\nconst lx = H.W * 0.27, ly = H.H * 0.78, R = 150;\nH.line(lx, ly, lx + R, ly, { color: H.colors.axis, width: 3 });\nH.line(lx, ly, lx, ly - R, { color: H.colors.axis, width: 3 });\nconst ex = lx + R * Math.cos(aRad), ey = ly - R * Math.sin(aRad);\nH.line(lx, ly, ex, ey, { color: H.colors.ink, width: 2 });\nconst arcA = [], arcB = [];\nfor (let i = 0; i <= 30; i++) { const th = aRad * i / 30; arcA.push([lx + 46 * Math.cos(th), ly - 46 * Math.sin(th)]); }\nfor (let i = 0; i <= 30; i++) { const th = aRad + (Math.PI / 2 - aRad) * i / 30; arcB.push([lx + 64 * Math.cos(th), ly - 64 * Math.sin(th)]); }\nH.path(arcA, { color: H.colors.accent, width: 3 });\nH.path(arcB, { color: H.colors.good, width: 3 });\nH.circle(lx, ly, 5, { fill: H.colors.ink });\nH.text(\"a = \" + a.toFixed(0), lx + 56 * Math.cos(aRad / 2), ly - 56 * Math.sin(aRad / 2), { color: H.colors.accent, size: 13, weight: 700 });\nH.text(\"90-a = \" + (90 - a).toFixed(0), lx + 86 * Math.cos((aRad + Math.PI / 2) / 2), ly - 86 * Math.sin((aRad + Math.PI / 2) / 2), { color: H.colors.good, size: 13, weight: 700 });\nH.text(\"Complementary: a + (90-a) = 90\", lx - 60, ly + 36, { color: H.colors.sub, size: 13 });\nconst rx = H.W * 0.74, ry = H.H * 0.78;\nH.line(rx - R, ry, rx + R, ry, { color: H.colors.axis, width: 3 });\nconst sx = rx + R * Math.cos(aRad), sy = ry - R * Math.sin(aRad);\nH.line(rx, ry, sx, ry - R * Math.sin(aRad) - 0, { color: H.colors.ink, width: 2 });\nH.line(rx, ry, sx, sy, { color: H.colors.ink, width: 2 });\nconst arcC = [], arcD = [];\nfor (let i = 0; i <= 30; i++) { const th = aRad * i / 30; arcC.push([rx + 46 * Math.cos(th), ry - 46 * Math.sin(th)]); }\nfor (let i = 0; i <= 30; i++) { const th = aRad + (Math.PI - aRad) * i / 30; arcD.push([rx + 64 * Math.cos(th), ry - 64 * Math.sin(th)]); }\nH.path(arcC, { color: H.colors.accent, width: 3 });\nH.path(arcD, { color: H.colors.violet, width: 3 });\nH.circle(rx, ry, 5, { fill: H.colors.ink });\nH.text(\"a = \" + a.toFixed(0), rx + 56 * Math.cos(aRad / 2), ry - 56 * Math.sin(aRad / 2), { color: H.colors.accent, size: 13, weight: 700 });\nH.text(\"180-a = \" + (180 - a).toFixed(0), rx + 86 * Math.cos((aRad + Math.PI) / 2), ry - 86 * Math.sin((aRad + Math.PI) / 2), { color: H.colors.violet, size: 13, weight: 700 });\nH.text(\"Supplementary: a + (180-a) = 180\", rx - 90, ry + 36, { color: H.colors.sub, size: 13 });" + }, + { + "id": "geo-vertical-angles", + "area": "Geometry", + "topic": "Vertical angles", + "title": "Vertical angles are equal; each linear pair sums to 180", + "equation": "vertical angles equal; a + b = 180", + "keywords": [ + "vertical angles", + "opposite angles", + "crossing lines", + "intersecting lines", + "linear pair", + "vertically opposite", + "equal angles", + "supplementary", + "x crossing", + "angle relationships" + ], + "explanation": "When two straight lines cross they create four angles in two pairs: the angles directly across from each other (vertical, or vertically opposite, angles) are always equal, while any two angles that sit next to each other along one line form a linear pair that sums to 180. The two blue arcs mark one pair of equal vertical angles (both labelled a) and the green arc marks the adjacent angle b; rotating the slider changes a but the opposite angle stays locked equal to it. They must be equal because each of the two blue angles is the supplement of the same green angle, so a = 180 - b on both sides, forcing the opposite angles to match.", + "bullets": [ + "Crossing lines make two pairs of equal vertical (opposite) angles.", + "Both blue arcs are labelled a and stay equal as the lines rotate.", + "Adjacent angles a and b form a linear pair that sums to 180.", + "Vertical angles are equal because both are supplements of the same b." + ], + "params": [ + { + "name": "rot", + "label": "rotation (deg)", + "min": 10, + "max": 170, + "step": 1, + "value": 55 + } + ], + "code": "H.background();\nconst cx = H.W / 2, cy = H.H * 0.56, R = 210;\nconst rot = (P.rot + 18 * Math.sin(t * 0.5)) * Math.PI / 180;\nH.text(\"Vertical angles are equal\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Two crossing lines make two pairs of equal vertical (opposite) angles; each linear pair sums to 180.\", 24, 52, { color: H.colors.sub, size: 13 });\nconst d2x = R * Math.cos(rot), d2y = R * Math.sin(rot);\nH.line(cx - R, cy, cx + R, cy, { color: H.colors.axis, width: 3 });\nH.line(cx - d2x, cy - d2y, cx + d2x, cy + d2y, { color: H.colors.axis, width: 3 });\nlet a = ((rot * 180 / Math.PI) % 180 + 180) % 180;\nconst b = 180 - a;\nfunction arc(start, end, r, col) {\n const pts = [];\n for (let i = 0; i <= 30; i++) { const th = start + (end - start) * i / 30; pts.push([cx + r * Math.cos(th), cy - r * Math.sin(th)]); }\n H.path(pts, { color: col, width: 4 });\n}\narc(0, -rot, 55, H.colors.accent);\narc(Math.PI, Math.PI - rot, 55, H.colors.accent);\narc(0, Math.PI - rot, 80, H.colors.good);\nH.circle(cx, cy, 5, { fill: H.colors.ink });\nH.text(\"a = \" + a.toFixed(0), cx + 95 * Math.cos(-rot / 2), cy - 95 * Math.sin(-rot / 2), { color: H.colors.accent, size: 14, weight: 700 });\nH.text(\"a = \" + a.toFixed(0), cx + 95 * Math.cos(Math.PI - rot / 2), cy - 95 * Math.sin(Math.PI - rot / 2), { color: H.colors.accent, size: 14, weight: 700 });\nH.text(\"b = \" + b.toFixed(0), cx + 115 * Math.cos((Math.PI - rot) / 2), cy - 115 * Math.sin((Math.PI - rot) / 2), { color: H.colors.good, size: 14, weight: 700 });\nH.text(\"vertical pair a = a = \" + a.toFixed(0) + \" deg\", 24, 84, { color: H.colors.accent, size: 14, weight: 700 });\nH.text(\"linear pair a + b = \" + a.toFixed(0) + \" + \" + b.toFixed(0) + \" = 180 deg\", 24, 106, { color: H.colors.good, size: 14, weight: 700 });" + }, + { + "id": "geo-parallel-transversal", + "area": "Geometry", + "topic": "Parallel lines cut by a transversal", + "title": "Parallel lines + transversal: corresponding & alternate angles equal", + "equation": "corresponding = equal, alternate interior = equal, co-interior = 180", + "keywords": [ + "parallel lines", + "transversal", + "corresponding angles", + "alternate interior angles", + "co-interior angles", + "same-side angles", + "consecutive interior", + "f angles", + "z angles", + "c angles", + "angle relationships", + "equal angles" + ], + "explanation": "When a transversal crosses two parallel lines it makes the same set of angles at both crossing points, which is what locks the angle relationships together. Corresponding angles (same position at each intersection) are equal, alternate-interior angles (opposite sides of the transversal, between the parallels) are equal, and co-interior / same-side angles are supplementary, summing to 180. The two green arcs show a corresponding pair that stay equal as you change the transversal's slope, and the yellow arc shows the alternate-interior angle that also equals a; the relationships hold only because the lines are parallel, so the crossing pattern is identical at both points.", + "bullets": [ + "The transversal cuts both parallels at the same angle pattern.", + "Green arcs: corresponding angles, equal at both crossings.", + "Yellow arc: alternate-interior angle, also equal to a.", + "Co-interior (same-side) angles are supplementary: a + (180-a) = 180." + ], + "params": [ + { + "name": "ang", + "label": "transversal (deg)", + "min": 25, + "max": 75, + "step": 1, + "value": 55 + } + ], + "code": "H.background();\nconst y1 = H.H * 0.38, y2 = H.H * 0.72, cx = H.W / 2;\nconst ang = Math.max(25, Math.min(75, P.ang + 6 * Math.sin(t * 0.6)));\nconst aRad = ang * Math.PI / 180;\nH.text(\"Parallel lines cut by a transversal\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Corresponding & alternate-interior angles are EQUAL; co-interior (same-side) angles are SUPPLEMENTARY.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.line(60, y1, H.W - 60, y1, { color: H.colors.accent, width: 3 });\nH.line(60, y2, H.W - 60, y2, { color: H.colors.accent, width: 3 });\nconst dx = Math.cos(aRad), dy = -Math.sin(aRad);\nconst top = [cx + dx * 220, y1 + dy * 220];\nconst bot = [cx - dx * 220, y1 - dy * 220];\nH.line(top[0], top[1], bot[0], bot[1], { color: H.colors.violet, width: 3 });\nconst ix1 = cx, iy1 = y1;\nconst s = (y2 - y1) / dy;\nconst ix2 = cx + s * dx, iy2 = y2;\nH.circle(ix1, iy1, 5, { fill: H.colors.ink });\nH.circle(ix2, iy2, 5, { fill: H.colors.ink });\nconst a = ang, sup = 180 - ang;\nfunction arc(px, py, start, end, r, col) {\n const pts = [];\n for (let i = 0; i <= 24; i++) { const th = start + (end - start) * i / 24; pts.push([px + r * Math.cos(th), py - r * Math.sin(th)]); }\n H.path(pts, { color: col, width: 4 });\n}\narc(ix1, iy1, 0, aRad, 40, H.colors.good);\narc(ix2, iy2, 0, aRad, 40, H.colors.good);\narc(ix2, iy2, Math.PI, Math.PI + aRad, 40, H.colors.yellow);\nH.text(a.toFixed(0), ix1 + 52 * Math.cos(aRad / 2), iy1 - 52 * Math.sin(aRad / 2), { color: H.colors.good, size: 14, weight: 700 });\nH.text(a.toFixed(0), ix2 + 52 * Math.cos(aRad / 2), iy2 - 52 * Math.sin(aRad / 2), { color: H.colors.good, size: 14, weight: 700 });\nH.text(a.toFixed(0), ix2 - 52 * Math.cos(aRad / 2), iy2 + 52 * Math.sin(aRad / 2), { color: H.colors.yellow, size: 14, weight: 700 });\nH.text(\"corresponding = \" + a.toFixed(0) + \" deg (equal)\", 24, 84, { color: H.colors.good, size: 13, weight: 700 });\nH.text(\"alternate interior = \" + a.toFixed(0) + \" deg (equal)\", 24, 104, { color: H.colors.yellow, size: 13, weight: 700 });\nH.text(\"co-interior = \" + a.toFixed(0) + \" + \" + sup.toFixed(0) + \" = 180 deg\", 24, 124, { color: H.colors.warn, size: 13, weight: 700 });" + }, + { + "id": "geo-triangle-angle-sum", + "area": "Geometry", + "topic": "Triangle angle sum = 180", + "title": "Triangle angle sum: A + B + C = 180", + "equation": "A + B + C = 180", + "keywords": [ + "triangle angle sum", + "angles of a triangle", + "sum of angles", + "180 degrees", + "interior angles", + "third angle", + "A plus B plus C", + "straight line", + "angle tiling", + "triangle" + ], + "explanation": "The three interior angles of any triangle always add to 180 degrees, so choosing two of them completely determines the third as C = 180 - A - B. As you move the two sliders the triangle reshapes (it is rescaled to stay in frame) and the bottom strip lays the three angles edge to edge, where they tile exactly half a turn, the same as a straight line. This works because drawing a line through one vertex parallel to the opposite side splits the straight angle there into copies of the other two angles, so the three interior angles must together make 180.", + "bullets": [ + "Pick A and B; the third angle is forced to C = 180 - A - B.", + "The colored strip lays A, B, C edge to edge to fill a straight line.", + "Any valid pair of angles still leaves a positive third angle.", + "The sum is always 180 no matter the triangle's shape or size." + ], + "params": [ + { + "name": "A", + "label": "angle A (deg)", + "min": 15, + "max": 140, + "step": 1, + "value": 60 + }, + { + "name": "B", + "label": "angle B (deg)", + "min": 15, + "max": 140, + "step": 1, + "value": 70 + } + ], + "code": "H.background();\nlet A = Math.max(15, Math.min(140, P.A));\nlet B = Math.max(15, Math.min(140, P.B));\nA = A + 4 * Math.sin(t * 0.7);\nB = B + 4 * Math.cos(t * 0.9);\nA = Math.max(10, Math.min(150, A));\nB = Math.max(10, Math.min(160 - A, B));\nconst C = 180 - A - B;\nH.text(\"Triangle angle sum = 180\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Pick two angles; the third is forced to 180-A-B. The three angles tile a straight line.\", 24, 52, { color: H.colors.sub, size: 13 });\nconst aR = A * Math.PI / 180, bR = B * Math.PI / 180;\nconst tA = Math.tan(aR), tB = Math.tan(bR);\nconst ux = (tB) / (tA + tB);\nconst uy = tA * ux;\nconst maxW = 360, maxH = 230;\nconst scale = Math.min(maxW / 1, maxH / Math.max(0.001, uy));\nconst w = scale, h = uy * scale;\nconst Bx = H.W / 2 - w / 2, By = H.H * 0.74;\nconst Cx = Bx + w, Cy = By;\nconst Ax = Bx + ux * w, Ay = By - h;\nH.path([[Bx, By], [Cx, Cy], [Ax, Ay]], { color: H.colors.accent, width: 3, close: true, fill: \"rgba(124,196,255,0.10)\" });\nH.circle(Ax, Ay, 5, { fill: H.colors.ink });\nH.circle(Bx, By, 5, { fill: H.colors.ink });\nH.circle(Cx, Cy, 5, { fill: H.colors.ink });\nfunction arc(px, py, start, end, r, col) {\n const pts = [];\n for (let i = 0; i <= 24; i++) { const th = start + (end - start) * i / 24; pts.push([px + r * Math.cos(th), py - r * Math.sin(th)]); }\n H.path(pts, { color: col, width: 4 });\n}\nconst angBA = Math.atan2(By - Ay, Ax - Bx);\narc(Bx, By, 0, angBA, 30, H.colors.good);\nconst angCA = Math.atan2(Cy - Ay, Ax - Cx);\narc(Cx, Cy, angCA, Math.PI, 30, H.colors.violet);\nconst angAB = Math.atan2(Ay - By, Bx - Ax);\nconst angAC = Math.atan2(Ay - Cy, Cx - Ax);\narc(Ax, Ay, angAB, angAC, 30, H.colors.warn);\nH.text(\"A=\" + A.toFixed(0), Bx + 42 * Math.cos(angBA / 2), By - 42 * Math.sin(angBA / 2), { color: H.colors.good, size: 14, weight: 700 });\nH.text(\"B=\" + B.toFixed(0), Cx + 48 * Math.cos((angCA + Math.PI) / 2), Cy - 48 * Math.sin((angCA + Math.PI) / 2), { color: H.colors.violet, size: 14, weight: 700 });\nH.text(\"C=\" + C.toFixed(0), Ax - 14, Ay + 28, { color: H.colors.warn, size: 14, weight: 700 });\nconst sx = 80, sy = H.H - 24, sw = H.W - 160;\nH.line(sx, sy, sx + sw, sy, { color: H.colors.axis, width: 2 });\nconst wA = sw * A / 180, wB = sw * B / 180, wC = sw * C / 180;\nH.rect(sx, sy - 14, wA, 12, { fill: \"rgba(103,232,176,0.6)\" });\nH.rect(sx + wA, sy - 14, wB, 12, { fill: \"rgba(196,167,255,0.6)\" });\nH.rect(sx + wA + wB, sy - 14, wC, 12, { fill: \"rgba(255,138,160,0.6)\" });\nH.text(\"the three angles laid edge to edge fill exactly half a turn (a straight line)\", sx, sy - 22, { color: H.colors.sub, size: 12 });\nH.text(\"A + B + C = \" + A.toFixed(0) + \" + \" + B.toFixed(0) + \" + \" + C.toFixed(0) + \" = 180 deg\", 24, 84, { color: H.colors.sub, size: 14, weight: 700 });" + }, + { + "id": "geo-exterior-angle", + "area": "Geometry", + "topic": "Exterior angle theorem", + "title": "Exterior angle theorem: ext = remote interior 1 + remote interior 2", + "equation": "exterior angle = sum of the two remote interior angles", + "keywords": [ + "exterior angle", + "exterior angle theorem", + "remote interior angles", + "triangle angles", + "angle sum", + "interior angles", + "triangle exterior angle", + "geometry angles", + "linear pair", + "remote angles" + ], + "explanation": "Extending one side of a triangle creates an exterior angle, and this theorem says it equals the sum of the two interior angles at the far (non-adjacent) vertices. It holds because the exterior angle and its adjacent interior angle form a straight line (they sum to 180°), while the three interior angles also sum to 180° — so the exterior angle must equal what's left after removing the adjacent interior angle, namely the other two. Dragging the interior angle at B with the slider re-shapes the triangle, and the yellow exterior angle at C tracks it exactly: ext = A + 55° at every setting, which the live readout confirms.", + "bullets": [ + "The dashed line extends a side to form the exterior angle at C.", + "Exterior angle + adjacent interior angle = 180° (straight line).", + "The yellow arc equals the two remote interior angles ADDED.", + "Change the slider: ext always equals A + 55°, never just one of them." + ], + "params": [ + { + "name": "A", + "label": "interior angle at B A (°)", + "min": 20, + "max": 110, + "step": 1, + "value": 50 + } + ], + "code": "H.background();\nconst cx = H.W * 0.42, cy = H.H * 0.62;\nconst A = P.A; // interior angle at vertex B (degrees), slider\nconst Bx = cx - 200, By = cy;\nconst Cx = cx + 200, Cy = cy;\nconst angB = A * Math.PI / 180;\nconst angCdeg = 55; // fixed interior angle at C (adjacent to the exterior angle)\nconst angC = angCdeg * Math.PI / 180;\nconst denom = Math.sin(angB + angC);\nconst safe = Math.abs(denom) < 1e-4 ? 1e-4 : denom;\nconst BC = (Cx - Bx);\nconst dist = BC * Math.sin(angC) / safe; // length B->apex\nconst Ax = Bx + dist * Math.cos(angB);\nconst Ay = By - dist * Math.sin(angB);\nconst angAdeg = 180 - A - angCdeg; // remote interior angle at apex\nconst extDeg = angAdeg + A; // exterior at C = sum of remote interiors (apex + B) = 180 - 55 = 125\n// draw triangle\nH.path([[Bx,By],[Cx,Cy],[Ax,Ay]], { color: H.colors.accent, width: 3, close: true });\n// extend base beyond C to show exterior angle\nconst Ex = Cx + 160;\nH.line(Bx, By, Ex, By, { color: H.colors.grid, width: 2, dash: [6,5] });\nH.line(Cx, Cy, Ex, By, { color: H.colors.grid, width: 1 });\n// exterior angle at C is between CA and the extension CE (toward +x)\nconst baseAng = 0; // extension direction\nconst caAng = Math.atan2(Ay - Cy, Ax - Cx); // direction C->A\n// animated sweeping radius inside exterior angle\nconst sweep = (Math.sin(t * 0.8) * 0.5 + 0.5);\nconst arcAng = baseAng + (caAng - baseAng) * sweep;\nconst rr = 46;\nH.line(Cx, Cy, Cx + rr * Math.cos(arcAng), Cy + rr * Math.sin(arcAng), { color: H.colors.yellow, width: 2 });\n// arc for exterior angle\nconst arcPts = [];\nfor (let i = 0; i <= 24; i++) {\n const aa = baseAng + (caAng - baseAng) * (i / 24);\n arcPts.push([Cx + 38 * Math.cos(aa), Cy + 38 * Math.sin(aa)]);\n}\nH.path(arcPts, { color: H.colors.yellow, width: 2 });\n// label vertices\nH.text(\"B\", Bx - 14, By + 6, { color: H.colors.sub, size: 14 });\nH.text(\"C\", Cx + 4, Cy + 18, { color: H.colors.sub, size: 14 });\nH.text(\"apex\", Ax - 14, Ay - 10, { color: H.colors.sub, size: 13 });\nH.text(\"ext\", Cx + 50, Cy - 26, { color: H.colors.yellow, size: 13 });\n// remote interior angle markers (the two FAR vertices: B and apex)\nH.text(A.toFixed(0) + \"°\", Bx + 24, By - 10, { color: H.colors.accent2, size: 13 });\nH.text(angAdeg.toFixed(0) + \"°\", Ax + 6, Ay + 18, { color: H.colors.accent2, size: 13 });\n// adjacent interior angle at C (NOT remote)\nH.text(\"55° (adjacent)\", Cx - 70, Cy - 12, { color: H.colors.sub, size: 11 });\n// title + caption\nH.text(\"Exterior angle = sum of remote interior angles\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"ext angle at C equals the two far interior angles (B + apex)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"ext = \" + extDeg.toFixed(0) + \"° = \" + A.toFixed(0) + \"° + \" + angAdeg.toFixed(0) + \"°\", 24, 76, { color: H.colors.yellow, size: 13 });" + }, + { + "id": "geo-pythagorean", + "area": "Geometry", + "topic": "Pythagorean theorem", + "title": "Pythagorean theorem: a^2 + b^2 = c^2", + "equation": "a^2 + b^2 = c^2", + "keywords": [ + "pythagorean theorem", + "right triangle", + "hypotenuse", + "a squared plus b squared", + "legs", + "a^2 + b^2 = c^2", + "right angle", + "square on the sides", + "pythagoras", + "find hypotenuse" + ], + "explanation": "For a right triangle, the square built on the hypotenuse has exactly the same area as the two squares built on the legs combined: a² + b² = c². The drawing makes the algebra visible — the blue square (area a²) and orange square (area b²) literally hold the same total area as the green square (area c²) on the slanted hypotenuse. Sliding a and b reshapes the triangle and re-sizes all three squares, while the readout recomputes c = √(a² + b²) so you can watch the areas stay balanced for every right triangle.", + "bullets": [ + "Blue and orange squares sit on the legs; green sits on the hypotenuse.", + "The two leg-square areas (a², b²) always add to the hypotenuse area c².", + "c = √(a² + b²) is just this area equation solved for the long side.", + "The right-angle marker shows the theorem needs a 90° corner." + ], + "params": [ + { + "name": "a", + "label": "leg a", + "min": 1, + "max": 8, + "step": 0.5, + "value": 3 + }, + { + "name": "b", + "label": "leg b", + "min": 1, + "max": 8, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst a = P.a; // leg a\nconst b = P.b; // leg b\nconst c = Math.sqrt(a * a + b * b);\n// Right angle at origin O. Scale so squares fit.\nconst maxSide = Math.max(a, b, 1);\nconst s = 150 / maxSide; // px per unit\nconst Ox = H.W * 0.42, Oy = H.H * 0.66; // right-angle corner\n// place leg a horizontal (to the right), leg b vertical (up)\nconst Px = Ox + a * s, Py = Oy; // end of leg a\nconst Qx = Ox, Qy = Oy - b * s; // end of leg b\n// triangle\nH.path([[Ox,Oy],[Px,Py],[Qx,Qy]], { color: H.colors.ink, width: 3, close: true });\n// right-angle marker\nconst m = 12;\nH.path([[Ox+m,Oy],[Ox+m,Oy-m],[Ox,Oy-m]], { color: H.colors.sub, width: 1.5 });\n// square on leg a (below the horizontal leg)\nH.path([[Ox,Oy],[Px,Py],[Px,Py + a*s],[Ox,Oy + a*s]], { color: H.colors.accent, width: 2, fill: \"rgba(124,196,255,0.18)\", close: true });\n// square on leg b (to the left of vertical leg)\nH.path([[Ox,Oy],[Qx,Qy],[Qx - b*s,Qy],[Ox - b*s,Oy]], { color: H.colors.accent2, width: 2, fill: \"rgba(244,162,89,0.18)\", close: true });\n// square on hypotenuse: outward normal\nconst hx = Qx - Px, hy = Qy - Py; // vector P->Q (hypotenuse)\nconst len = Math.sqrt(hx*hx + hy*hy) || 1;\n// outward normal (rotate hypotenuse vector -90 to push away from triangle)\nconst nx = hy / len, ny = -hx / len;\nconst H1x = Px + nx * c * s, H1y = Py + ny * c * s;\nconst H2x = Qx + nx * c * s, H2y = Qy + ny * c * s;\nH.path([[Px,Py],[Qx,Qy],[H2x,H2y],[H1x,H1y]], { color: H.colors.good, width: 2, fill: \"rgba(103,232,176,0.18)\", close: true });\n// animated pulse dot traveling along hypotenuse\nconst u = (Math.sin(t * 0.9) * 0.5 + 0.5);\nconst dx = Px + (Qx - Px) * u, dy = Py + (Qy - Py) * u;\nH.circle(dx, dy, 6, { fill: H.colors.warn });\n// labels\nH.text(\"a²\", (Ox + Px)/2 - 8, Oy + a*s*0.55, { color: H.colors.accent, size: 13 });\nH.text(\"b²\", Ox - b*s*0.55 - 6, (Oy + Qy)/2, { color: H.colors.accent2, size: 13 });\nH.text(\"c²\", (H1x + H2x)/2 - 8, (H1y + H2y)/2, { color: H.colors.good, size: 13 });\nH.text(\"a = \" + a.toFixed(1), (Ox+Px)/2 - 14, Oy - 8, { color: H.colors.sub, size: 12 });\nH.text(\"b = \" + b.toFixed(1), Ox + 6, (Oy+Qy)/2, { color: H.colors.sub, size: 12 });\n// title\nH.text(\"Pythagorean theorem: a² + b² = c²\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"the two leg squares' areas add up to the hypotenuse square\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"c = √(\" + (a*a).toFixed(1) + \" + \" + (b*b).toFixed(1) + \") = \" + c.toFixed(2), 24, 76, { color: H.colors.good, size: 13 });" + }, + { + "id": "geo-special-45-45-90", + "area": "Geometry", + "topic": "45-45-90 right triangle", + "title": "45-45-90 triangle: hypotenuse = leg * sqrt(2)", + "equation": "hypotenuse = leg * sqrt(2)", + "keywords": [ + "45-45-90", + "isosceles right triangle", + "special right triangle", + "leg times sqrt 2", + "hypotenuse", + "1 1 sqrt2 ratio", + "45 degree triangle", + "right triangle ratios", + "diagonal of square", + "sqrt2" + ], + "explanation": "A 45-45-90 triangle is the right triangle you get by cutting a square along its diagonal: the two acute angles are equal (45° each), so the two legs are equal too. Because the legs are equal, the Pythagorean theorem gives hypotenuse = √(leg² + leg²) = leg·√2, which is why the sides always sit in the ratio 1 : 1 : √2. Moving the leg slider rescales the whole triangle, and the readout shows the hypotenuse staying exactly leg·1.414 — the √2 factor never changes, only the size does.", + "bullets": [ + "Equal 45° angles force the two legs to be equal in length.", + "Hypotenuse = leg·√2 comes straight from a² + a² = c².", + "Sides keep the fixed ratio 1 : 1 : √2 at any size.", + "It is exactly half of a square split along its diagonal." + ], + "params": [ + { + "name": "leg", + "label": "leg length leg", + "min": 1, + "max": 6, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst leg = P.leg; // leg length, slider\nconst hyp = leg * Math.SQRT2;\nconst maxv = Math.max(hyp, 1);\nconst s = 230 / maxv; // px per unit so it always fits\nconst Ox = H.W * 0.40, Oy = H.H * 0.70; // right-angle corner\nconst Px = Ox + leg * s, Py = Oy; // horizontal leg\nconst Qx = Ox, Qy = Oy - leg * s; // vertical leg\n// triangle\nH.path([[Ox,Oy],[Px,Py],[Qx,Qy]], { color: H.colors.accent, width: 3, fill: \"rgba(124,196,255,0.12)\", close: true });\n// right-angle marker\nconst m = 13;\nH.path([[Ox+m,Oy],[Ox+m,Oy-m],[Ox,Oy-m]], { color: H.colors.sub, width: 1.5 });\n// 45 arcs at P and Q\nconst arc = (cx, cy, a0, a1, r, col) => {\n const pts = [];\n for (let i = 0; i <= 16; i++) { const a = a0 + (a1 - a0) * i / 16; pts.push([cx + r*Math.cos(a), cy + r*Math.sin(a)]); }\n H.path(pts, { color: col, width: 2 });\n};\narc(Px, Py, Math.PI, Math.PI + Math.PI/4, 30, H.colors.yellow); // at P\narc(Qx, Qy, Math.PI/4, Math.PI/2, 30, H.colors.yellow); // at Q (approx)\n// animated dot along hypotenuse\nconst u = (Math.sin(t * 0.9) * 0.5 + 0.5);\nconst dx = Px + (Qx - Px) * u, dy = Py + (Qy - Py) * u;\nH.circle(dx, dy, 6, { fill: H.colors.warn });\n// labels\nH.text(\"45°\", Px - 40, Py - 8, { color: H.colors.yellow, size: 13 });\nH.text(\"45°\", Qx + 8, Qy + 36, { color: H.colors.yellow, size: 13 });\nH.text(\"90°\", Ox + 16, Oy - 16, { color: H.colors.sub, size: 12 });\nH.text(\"leg = \" + leg.toFixed(2), (Ox+Px)/2 - 18, Oy + 20, { color: H.colors.accent, size: 13 });\nH.text(\"leg = \" + leg.toFixed(2), Ox - 70, (Oy+Qy)/2, { color: H.colors.accent, size: 13 });\nH.text(\"hyp = leg·√2\", (Px+Qx)/2 + 6, (Py+Qy)/2 - 8, { color: H.colors.good, size: 13 });\n// ratio bar\nH.text(\"ratio 1 : 1 : √2\", H.W - 220, H.H - 40, { color: H.colors.sub, size: 14 });\n// title\nH.text(\"45-45-90 triangle: hyp = leg·√2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"equal legs meet at a right angle; sides scale as 1 : 1 : √2\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"hyp = \" + leg.toFixed(2) + \" · 1.414 = \" + hyp.toFixed(2), 24, 76, { color: H.colors.good, size: 13 });" + }, + { + "id": "geo-special-30-60-90", + "area": "Geometry", + "topic": "30-60-90 right triangle", + "title": "30-60-90 triangle: sides in ratio 1 : sqrt(3) : 2", + "equation": "short : long : hypotenuse = 1 : sqrt(3) : 2", + "keywords": [ + "30-60-90", + "special right triangle", + "1 sqrt3 2 ratio", + "short leg", + "long leg", + "hypotenuse twice short leg", + "30 degree triangle", + "60 degree triangle", + "right triangle ratios", + "half equilateral triangle" + ], + "explanation": "A 30-60-90 triangle is half of an equilateral triangle sliced down its height, which fixes its side lengths in the ratio 1 : √3 : 2. The shortest side sits opposite the 30° angle; the hypotenuse opposite the 90° angle is always exactly twice that short leg, and the remaining long leg (opposite 60°) is the short leg times √3 — these follow from the Pythagorean theorem once you know hyp = 2·short. Dragging the short-leg slider rescales the triangle while the readout shows the sides locked at short : short·√3 : short·2, so the proportions hold at every size.", + "bullets": [ + "Short leg sits opposite the 30° angle (the smallest side).", + "Hypotenuse is always exactly twice the short leg.", + "Long leg (opposite 60°) equals short leg · √3.", + "It is exactly half of an equilateral triangle." + ], + "params": [ + { + "name": "sh", + "label": "short leg s", + "min": 1, + "max": 5, + "step": 0.5, + "value": 2.5 + } + ], + "code": "H.background();\nconst sh = P.sh; // short leg (opposite 30 deg), slider\nconst longLeg = sh * Math.sqrt(3); // opposite 60 deg\nconst hyp = sh * 2;\nconst maxv = Math.max(hyp, longLeg, 1);\nconst s = 210 / maxv;\n// right angle at O; short leg vertical (up), long leg horizontal (right)\nconst Ox = H.W * 0.36, Oy = H.H * 0.74;\nconst Px = Ox + longLeg * s, Py = Oy; // end of long leg\nconst Qx = Ox, Qy = Oy - sh * s; // end of short leg (up)\n// 30 is opposite short leg -> at vertex P; 60 opposite long leg -> at vertex Q\n// triangle\nH.path([[Ox,Oy],[Px,Py],[Qx,Qy]], { color: H.colors.violet, width: 3, fill: \"rgba(196,167,255,0.12)\", close: true });\n// right-angle marker\nconst m = 13;\nH.path([[Ox+m,Oy],[Ox+m,Oy-m],[Ox,Oy-m]], { color: H.colors.sub, width: 1.5 });\n// angle arcs\nconst arc = (cx, cy, a0, a1, r, col) => {\n const pts = [];\n for (let i = 0; i <= 16; i++) { const a = a0 + (a1 - a0) * i / 16; pts.push([cx + r*Math.cos(a), cy + r*Math.sin(a)]); }\n H.path(pts, { color: col, width: 2 });\n};\narc(Px, Py, Math.PI, Math.atan2(Qy - Py, Qx - Px), 34, H.colors.yellow); // 30 at P\narc(Qx, Qy, Math.atan2(Py - Qy, Px - Qx), Math.PI/2, 30, H.colors.yellow); // 60 at Q\n// animated dot along hypotenuse\nconst u = (Math.sin(t * 0.9) * 0.5 + 0.5);\nconst dx = Px + (Qx - Px) * u, dy = Py + (Qy - Py) * u;\nH.circle(dx, dy, 6, { fill: H.colors.warn });\n// labels\nH.text(\"30°\", Px - 46, Py - 6, { color: H.colors.yellow, size: 13 });\nH.text(\"60°\", Qx + 8, Qy + 30, { color: H.colors.yellow, size: 13 });\nH.text(\"short = \" + sh.toFixed(2), Ox - 12, (Oy+Qy)/2, { color: H.colors.accent, size: 12, align: \"right\" });\nH.text(\"long = \" + longLeg.toFixed(2), (Ox+Px)/2 - 24, Oy + 20, { color: H.colors.accent2, size: 12 });\nH.text(\"hyp = \" + hyp.toFixed(2), (Px+Qx)/2 + 8, (Py+Qy)/2 - 8, { color: H.colors.good, size: 12 });\nH.text(\"ratio 1 : √3 : 2\", H.W - 220, H.H - 40, { color: H.colors.sub, size: 14 });\n// title\nH.text(\"30-60-90 triangle: 1 : √3 : 2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"short leg opposite 30°; sides scale as 1 : √3 : 2\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(sh.toFixed(2) + \" : \" + longLeg.toFixed(2) + \" : \" + hyp.toFixed(2), 24, 76, { color: H.colors.good, size: 13 });" + }, + { + "id": "geo-triangle-similarity", + "area": "Geometry", + "topic": "Similar triangles (AA) & scale factor", + "title": "Similar triangles (AA): sides x k, area x k^2", + "equation": "sides scale by k, areas scale by k^2", + "keywords": [ + "similar triangles", + "AA similarity", + "angle angle", + "scale factor", + "proportional sides", + "area ratio k squared", + "dilation", + "enlargement", + "corresponding sides", + "ratio of areas" + ], + "explanation": "By the Angle-Angle (AA) test, if two triangles share two pairs of equal angles they must be similar — the same shape at a different size — because the third angles match automatically and equal angles force the sides into proportion. The single scale factor k links them: every corresponding side of the larger triangle is k times the matching side of the smaller one. Area, being a product of two lengths, scales by k², so dragging the slider stretches the sides linearly while the area readout grows as the square — at k = 2 the sides double but the area quadruples.", + "bullets": [ + "AA: two equal angle pairs are enough to guarantee similarity.", + "Every pair of corresponding sides is multiplied by the same factor k.", + "Area scales by k², not k, because area depends on two lengths.", + "The dashed links connect matching vertices of the two similar triangles." + ], + "params": [ + { + "name": "k", + "label": "scale factor k", + "min": 0.5, + "max": 3, + "step": 0.25, + "value": 1.5 + } + ], + "code": "H.background();\nconst k = P.k; // scale factor, slider\n// base triangle shape (unit), drawn small (left) and scaled (right)\n// triangle vertices relative to a corner, in px (base shape)\nconst base = [[0,0],[120,0],[40,-90]]; // a generic triangle\n// small (reference) triangle at left\nconst Sx = 90, Sy = H.H * 0.66;\nconst sm = base.map(p => [Sx + p[0], Sy + p[1]]);\nH.path(sm, { color: H.colors.accent, width: 3, fill: \"rgba(124,196,255,0.14)\", close: true });\n// scaled triangle at right (scaled by k about its own corner)\nconst drawK = k;\nconst Bx = H.W * 0.50, By = H.H * 0.78;\nconst big = base.map(p => [Bx + p[0]*drawK, By + p[1]*drawK]);\nH.path(big, { color: H.colors.accent2, width: 3, fill: \"rgba(244,162,89,0.12)\", close: true });\n// connect corresponding vertices to show similarity (animated dashed)\nconst phase = (Math.sin(t*0.7)*0.5+0.5);\nfor (let i = 0; i < 3; i++) {\n const aPt = sm[i], bPt = big[i];\n const mx = aPt[0] + (bPt[0]-aPt[0])*phase, my = aPt[1] + (bPt[1]-aPt[1])*phase;\n H.line(aPt[0], aPt[1], bPt[0], bPt[1], { color: H.colors.grid, width: 1, dash: [5,5] });\n H.circle(mx, my, 4, { fill: H.colors.warn });\n}\n// equal-angle ticks (just labels) at one matching vertex\nH.text(\"equal angles (AA)\", Sx - 10, Sy + 34, { color: H.colors.violet, size: 13 });\n// side length readouts\nH.text(\"side = 1\", Sx + 40, Sy + 18, { color: H.colors.accent, size: 12 });\nH.text(\"side = \" + k.toFixed(2), Bx + 40*drawK, By + 18, { color: H.colors.accent2, size: 12 });\n// area relationship\nH.text(\"scale factor k = \" + k.toFixed(2), H.W - 260, H.H - 64, { color: H.colors.sub, size: 14 });\nH.text(\"sides × k, areas × k²\", H.W - 260, H.H - 42, { color: H.colors.good, size: 14 });\nH.text(\"area ratio = \" + (k*k).toFixed(2), H.W - 260, H.H - 20, { color: H.colors.good, size: 13 });\n// title\nH.text(\"Similar triangles (AA): sides × k, area × k²\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"equal angles force the same shape; one scale factor sets every side\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"k = \" + k.toFixed(2) + \" → every side ×\" + k.toFixed(2) + \", area ×\" + (k*k).toFixed(2), 24, 76, { color: H.colors.good, size: 13 });" + }, + { + "id": "geo-triangle-inequality", + "area": "Geometry", + "topic": "Triangle inequality", + "title": "Triangle inequality: |a - b| < c < a + b", + "equation": "|a - b| < c < a + b", + "keywords": [ + "triangle inequality", + "third side", + "sum of two sides", + "can a triangle exist", + "valid triangle", + "side lengths", + "a+b>c", + "degenerate triangle", + "triangle sides rule" + ], + "explanation": "For three lengths to close into a triangle, the third side c must be longer than the difference of the other two and shorter than their sum: |a - b| < c < a + b. This holds because the shortest path between two points is the straight segment, so going from one base endpoint to the apex and back can never be shorter than the base (giving the upper bound) nor can the base be shorter than the difference of the two slanted sides (the lower bound). The green band on the number line marks that valid window for fixed a and b, and the moving dot is c sweeping in and out of it; when the dot is inside, the triangle closes, and when it leaves, the two sides collapse into a flat hinge that cannot meet.", + "bullets": [ + "The green band on the number line is the only window where c yields a real triangle.", + "Lower bound |a-b|: the two sides barely differ enough to span c.", + "Upper bound a+b: at c = a+b the triangle flattens into a straight line.", + "Outside the window the two sides become a hinge that cannot close." + ], + "params": [ + { + "name": "a", + "label": "side a", + "min": 1, + "max": 10, + "step": 0.5, + "value": 5 + }, + { + "name": "b", + "label": "side b", + "min": 1, + "max": 10, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst a = P.a, b = P.b;\nconst lo = Math.abs(a - b), hi = a + b;\nconst mid = (lo + hi) / 2, amp = hi * 0.7;\nlet c = mid + amp * Math.sin(t * 0.7);\nif (c < 0.05) c = 0.05;\nconst valid = c > lo + 1e-9 && c < hi - 1e-9;\nconst cx = H.W / 2, baseY = 360;\nconst scale = 300 / Math.max(hi, 4);\nconst half = (c * scale) / 2;\nconst Lx = cx - half, Rx = cx + half;\nlet apex = null;\nif (valid) {\n const x = (c * c + a * a - b * b) / (2 * c);\n const h2 = a * a - x * x;\n if (h2 > 0) {\n const h = Math.sqrt(h2);\n apex = { x: Lx + x * scale, y: baseY - h * scale };\n }\n}\nH.text(\"Triangle inequality: |a - b| < c < a + b\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Two sides force the third into a window; outside it, the triangle cannot close.\", 24, 52, { color: H.colors.sub, size: 13 });\nconst nlY = 150, nlX0 = 60, nlX1 = H.W - 60;\nconst nmax = hi * 1.25 + 1;\nconst NX = vv => nlX0 + (vv / nmax) * (nlX1 - nlX0);\nH.line(nlX0, nlY, nlX1, nlY, { color: H.colors.grid, width: 2 });\nH.rect(NX(lo), nlY - 8, NX(hi) - NX(lo), 16, { fill: \"rgba(63,185,80,0.35)\", stroke: H.colors.good, width: 1, radius: 3 });\nH.line(NX(lo), nlY - 16, NX(lo), nlY + 16, { color: H.colors.warn, width: 2 });\nH.line(NX(hi), nlY - 16, NX(hi), nlY + 16, { color: H.colors.warn, width: 2 });\nH.text(\"|a-b| = \" + lo.toFixed(2), NX(lo), nlY + 32, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(\"a+b = \" + hi.toFixed(2), NX(hi), nlY + 32, { color: H.colors.sub, size: 12, align: \"center\" });\nH.circle(NX(c), nlY, 7, { fill: valid ? H.colors.accent : H.colors.warn });\nH.text(\"c = \" + c.toFixed(2), NX(c), nlY - 24, { color: valid ? H.colors.accent : H.colors.warn, size: 13, align: \"center\", weight: 700 });\nH.line(Lx, baseY, Rx, baseY, { color: H.colors.accent, width: 3 });\nH.text(\"c\", (Lx + Rx) / 2, baseY + 22, { color: H.colors.accent, size: 14, align: \"center\" });\nif (apex) {\n H.path([[Lx, baseY], [apex.x, apex.y], [Rx, baseY]], { color: H.colors.ink, width: 3, fill: \"rgba(110,168,254,0.12)\", close: true });\n H.text(\"a = \" + a.toFixed(1), (Lx + apex.x) / 2 - 18, (baseY + apex.y) / 2, { color: H.colors.violet, size: 13 });\n H.text(\"b = \" + b.toFixed(1), (Rx + apex.x) / 2 + 4, (baseY + apex.y) / 2, { color: H.colors.violet, size: 13 });\n} else {\n const ax = Lx + a * scale, bx = Rx - b * scale;\n H.line(Lx, baseY, ax, baseY - 1, { color: H.colors.violet, width: 3 });\n H.line(Rx, baseY, bx, baseY - 1, { color: H.colors.violet, width: 3 });\n H.text(\"a\", (Lx + ax) / 2, baseY - 12, { color: H.colors.violet, size: 13, align: \"center\" });\n H.text(\"b\", (Rx + bx) / 2, baseY - 12, { color: H.colors.violet, size: 13, align: \"center\" });\n}\nH.text(valid ? \"VALID triangle\" : \"NOT a triangle\", H.W / 2, 470, { color: valid ? H.colors.good : H.colors.warn, size: 16, align: \"center\", weight: 700 });" + }, + { + "id": "geo-sohcahtoa", + "area": "Geometry", + "topic": "Right-triangle trig (SOHCAHTOA)", + "title": "SOHCAHTOA: sin=opp/hyp, cos=adj/hyp, tan=opp/adj", + "equation": "sin = opp/hyp, cos = adj/hyp, tan = opp/adj", + "keywords": [ + "sohcahtoa", + "right triangle trig", + "sine cosine tangent", + "opposite adjacent hypotenuse", + "trig ratios", + "sin opp hyp", + "cos adj hyp", + "tan opp adj", + "right angle trigonometry" + ], + "explanation": "In a right triangle the three primary trig ratios are fixed functions of one acute angle theta: sine is the opposite leg over the hypotenuse, cosine is the adjacent leg over the hypotenuse, and tangent is opposite over adjacent. They depend only on the angle, not the triangle's size, because any two right triangles with the same theta are similar, so their corresponding side ratios are identical. As you change theta the violet hypotenuse stays the same length while the green opposite leg and blue adjacent leg trade size, and the readout shows sin growing toward 1 and cos shrinking toward 0 as theta approaches 90 degrees.", + "bullets": [ + "The right-angle box marks the 90 degree corner; theta is the labeled acute angle.", + "sin = green opposite / violet hypotenuse, rising as theta grows.", + "cos = blue adjacent / violet hypotenuse, falling as theta grows.", + "tan = opposite / adjacent, blowing up as theta nears 90 degrees." + ], + "params": [ + { + "name": "theta", + "label": "angle theta (deg)", + "min": 10, + "max": 80, + "step": 1, + "value": 35 + } + ], + "code": "H.background();\nconst base = P.theta;\nconst th = (base + 10 * Math.sin(t * 0.8)) * Math.PI / 180;\nconst thC = Math.max(0.05, Math.min(th, 1.52));\nconst deg = thC * 180 / Math.PI;\nconst hyp = 320;\nconst adj = hyp * Math.cos(thC);\nconst opp = hyp * Math.sin(thC);\nconst Ax = 130, Ay = 430;\nconst Bx = Ax + adj, By = Ay;\nconst Cx = Bx, Cy = By - opp;\nH.text(\"SOHCAHTOA: sin=opp/hyp, cos=adj/hyp, tan=opp/adj\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Theta sets the shape; the three ratios depend ONLY on the angle, not the size.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.path([[Ax, Ay], [Bx, By], [Cx, Cy]], { color: H.colors.ink, width: 3, fill: \"rgba(110,168,254,0.10)\", close: true });\nH.line(Ax, Ay, Bx, By, { color: H.colors.accent, width: 4 });\nH.line(Bx, By, Cx, Cy, { color: H.colors.good, width: 4 });\nH.line(Ax, Ay, Cx, Cy, { color: H.colors.violet, width: 4 });\nconst s = 16;\nH.rect(Bx - s, By - s, s, s, { stroke: H.colors.sub, width: 1.5 });\nconst r = 46;\nconst pts = [];\nfor (let i = 0; i <= 16; i++) { const a = -thC * i / 16; pts.push([Ax + r * Math.cos(a), Ay + r * Math.sin(a)]); }\nH.path(pts, { color: H.colors.yellow, width: 2 });\nH.text(\"theta = \" + deg.toFixed(1) + \" deg\", Ax + 52, Ay - 14, { color: H.colors.yellow, size: 13, weight: 700 });\nH.text(\"adj\", (Ax + Bx) / 2, Ay + 22, { color: H.colors.accent, size: 13, align: \"center\" });\nH.text(\"opp\", Bx + 22, (By + Cy) / 2, { color: H.colors.good, size: 13 });\nH.text(\"hyp\", (Ax + Cx) / 2 - 24, (Ay + Cy) / 2 - 8, { color: H.colors.violet, size: 13 });\nconst sin = Math.sin(thC), cos = Math.cos(thC), tan = Math.tan(thC);\nH.rect(560, 360, 310, 120, { fill: H.colors.panel, stroke: H.colors.grid, width: 1, radius: 8 });\nH.text(\"sin = opp/hyp = \" + sin.toFixed(3), 578, 392, { color: H.colors.good, size: 14 });\nH.text(\"cos = adj/hyp = \" + cos.toFixed(3), 578, 420, { color: H.colors.accent, size: 14 });\nH.text(\"tan = opp/adj = \" + tan.toFixed(3), 578, 448, { color: H.colors.yellow, size: 14 });" + }, + { + "id": "geo-law-of-sines", + "area": "Geometry", + "topic": "Law of sines", + "title": "Law of sines: a/sinA = b/sinB = c/sinC", + "equation": "a/sinA = b/sinB = c/sinC", + "keywords": [ + "law of sines", + "sine rule", + "a over sin a", + "ratio of side to sine", + "oblique triangle", + "non-right triangle", + "opposite angle", + "circumdiameter", + "solve triangle" + ], + "explanation": "In any triangle each side divided by the sine of the angle opposite it gives the same constant, so a/sinA = b/sinB = c/sinC. This holds because that common ratio equals the diameter of the triangle's circumscribed circle, so a larger angle always sits across from a proportionally longer side. As the slider changes angle A the triangle reshapes, the side a opposite it grows or shrinks, yet the three computed ratios in the panel stay locked to the same value, demonstrating that the relationship is exact for every shape.", + "bullets": [ + "Each colored side is paired with the angle of the same name opposite it.", + "A larger angle forces a longer side across from it.", + "The three side-over-sine ratios in the panel are always equal.", + "That shared ratio equals the circumscribed circle's diameter." + ], + "params": [ + { + "name": "A", + "label": "angle A (deg)", + "min": 20, + "max": 120, + "step": 1, + "value": 70 + } + ], + "code": "H.background();\nconst baseA = P.A;\nlet Adeg = baseA + 12 * Math.sin(t * 0.6);\nAdeg = Math.max(20, Math.min(Adeg, 120));\nlet Bdeg = 50;\nlet Cdeg = 180 - Adeg - Bdeg;\nif (Cdeg < 15) { Cdeg = 15; Bdeg = 180 - Adeg - Cdeg; }\nconst A = Adeg * Math.PI / 180, B = Bdeg * Math.PI / 180, C = Cdeg * Math.PI / 180;\nconst ax = 180, ay = 420, c = 360;\nconst bx = ax + c, by = ay;\nconst k = c / Math.sin(C);\nconst aLen = k * Math.sin(A);\nconst bLen = k * Math.sin(B);\nconst Cx = ax + bLen * Math.cos(A);\nconst Cy = ay - bLen * Math.sin(A);\nH.text(\"Law of sines: a/sinA = b/sinB = c/sinC\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Each side over the sine of its opposite angle gives the SAME ratio (the circumdiameter).\", 24, 52, { color: H.colors.sub, size: 13 });\nH.path([[ax, ay], [bx, by], [Cx, Cy]], { color: H.colors.ink, width: 3, fill: \"rgba(110,168,254,0.10)\", close: true });\nH.line(ax, ay, bx, by, { color: H.colors.accent, width: 4 });\nH.line(bx, by, Cx, Cy, { color: H.colors.good, width: 4 });\nH.line(ax, ay, Cx, Cy, { color: H.colors.violet, width: 4 });\nH.text(\"A\", ax - 18, ay + 6, { color: H.colors.yellow, size: 15, weight: 700 });\nH.text(\"B\", bx + 8, by + 6, { color: H.colors.yellow, size: 15, weight: 700 });\nH.text(\"C\", Cx, Cy - 14, { color: H.colors.yellow, size: 15, weight: 700, align: \"center\" });\nH.text(\"c\", (ax + bx) / 2, ay + 22, { color: H.colors.accent, size: 13, align: \"center\" });\nH.text(\"a\", (bx + Cx) / 2 + 10, (by + Cy) / 2, { color: H.colors.good, size: 13 });\nH.text(\"b\", (ax + Cx) / 2 - 18, (ay + Cy) / 2, { color: H.colors.violet, size: 13 });\nconst ra = aLen / Math.sin(A), rb = bLen / Math.sin(B), rc = c / Math.sin(C);\nH.rect(560, 80, 320, 132, { fill: H.colors.panel, stroke: H.colors.grid, width: 1, radius: 8 });\nH.text(\"a/sinA = \" + ra.toFixed(1), 578, 112, { color: H.colors.good, size: 14 });\nH.text(\"b/sinB = \" + rb.toFixed(1), 578, 140, { color: H.colors.violet, size: 14 });\nH.text(\"c/sinC = \" + rc.toFixed(1), 578, 168, { color: H.colors.accent, size: 14 });\nH.text(\"all equal -> \" + rc.toFixed(1) + \" px\", 578, 196, { color: H.colors.sub, size: 13, weight: 700 });\nH.text(\"A=\" + Adeg.toFixed(0) + \" B=\" + Bdeg.toFixed(0) + \" C=\" + Cdeg.toFixed(0), 24, 76, { color: H.colors.sub, size: 13 });" + }, + { + "id": "geo-law-of-cosines", + "area": "Geometry", + "topic": "Law of cosines", + "title": "Law of cosines: c^2 = a^2 + b^2 - 2ab*cos(C)", + "equation": "c^2 = a^2 + b^2 - 2ab*cos(C)", + "keywords": [ + "law of cosines", + "cosine rule", + "c squared", + "included angle", + "generalized pythagorean", + "sas triangle", + "find third side", + "oblique triangle", + "2ab cos c" + ], + "explanation": "The law of cosines finds the side opposite a known angle from the other two sides: c^2 = a^2 + b^2 - 2ab*cosC, where C is the angle between sides a and b. It is Pythagoras plus a correction term: when C = 90 degrees cosC is 0 and it reduces to a^2 + b^2 = c^2, while a smaller angle pulls the endpoints together (positive -2ab*cosC reduction) and a larger angle pushes them apart. As the slider opens and closes the included angle C, the violet side c stretches and shrinks, and the panel shows each term so you can watch the -2ab*cosC correction flip sign as C passes 90 degrees.", + "bullets": [ + "C is the included angle between the two fixed sides a and b.", + "At C = 90 degrees cosC = 0, recovering the Pythagorean theorem.", + "Acute C shortens c; obtuse C lengthens it past the right-angle value.", + "The panel breaks out a^2+b^2 and the -2ab*cosC correction live." + ], + "params": [ + { + "name": "C", + "label": "included angle C (deg)", + "min": 10, + "max": 170, + "step": 1, + "value": 60 + } + ], + "code": "H.background();\nconst aLen = 4, bLen = 3;\nconst baseG = P.C;\nlet g = baseG + 25 * Math.sin(t * 0.5);\ng = Math.max(5, Math.min(g, 175));\nconst gr = g * Math.PI / 180;\nconst c2 = aLen * aLen + bLen * bLen - 2 * aLen * bLen * Math.cos(gr);\nconst cLen = Math.sqrt(Math.max(c2, 0));\nconst scale = 70;\nconst Cx = 300, Cy = 380;\nconst Ax = Cx + bLen * scale, Ay = Cy;\nconst Bx = Cx + aLen * scale * Math.cos(gr);\nconst By = Cy - aLen * scale * Math.sin(gr);\nH.text(\"Law of cosines: c^2 = a^2 + b^2 - 2ab*cos(C)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Generalizes Pythagoras; the -2ab*cosC term corrects for the angle between a and b.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.path([[Cx, Cy], [Ax, Ay], [Bx, By]], { color: H.colors.ink, width: 3, fill: \"rgba(110,168,254,0.10)\", close: true });\nH.line(Cx, Cy, Ax, Ay, { color: H.colors.accent, width: 4 });\nH.line(Cx, Cy, Bx, By, { color: H.colors.good, width: 4 });\nH.line(Ax, Ay, Bx, By, { color: H.colors.violet, width: 4 });\nconst r = 44; const pts = [];\nfor (let i = 0; i <= 18; i++) { const ang = -gr * i / 18; pts.push([Cx + r * Math.cos(ang), Cy + r * Math.sin(ang)]); }\nH.path(pts, { color: H.colors.yellow, width: 2 });\nH.text(\"C\", Cx - 18, Cy + 18, { color: H.colors.yellow, size: 15, weight: 700 });\nH.text(\"A\", Ax + 8, Ay + 6, { color: H.colors.sub, size: 14 });\nH.text(\"B\", Bx, By - 12, { color: H.colors.sub, size: 14, align: \"center\" });\nH.text(\"b = \" + bLen.toFixed(1), (Cx + Ax) / 2, Cy + 22, { color: H.colors.accent, size: 13, align: \"center\" });\nH.text(\"a = \" + aLen.toFixed(1), (Cx + Bx) / 2 - 28, (Cy + By) / 2, { color: H.colors.good, size: 13 });\nH.text(\"c = \" + cLen.toFixed(2), (Ax + Bx) / 2 + 8, (Ay + By) / 2, { color: H.colors.violet, size: 13, weight: 700 });\nH.rect(560, 380, 320, 132, { fill: H.colors.panel, stroke: H.colors.grid, width: 1, radius: 8 });\nH.text(\"C = \" + g.toFixed(1) + \" deg, cos C = \" + Math.cos(gr).toFixed(3), 578, 410, { color: H.colors.yellow, size: 13 });\nH.text(\"a^2 + b^2 = \" + (aLen * aLen + bLen * bLen).toFixed(1), 578, 438, { color: H.colors.sub, size: 14 });\nH.text(\"- 2ab*cosC = \" + (-2 * aLen * bLen * Math.cos(gr)).toFixed(2), 578, 466, { color: H.colors.sub, size: 14 });\nH.text(\"c^2 = \" + c2.toFixed(2) + \" -> c = \" + cLen.toFixed(2), 578, 494, { color: H.colors.violet, size: 14, weight: 700 });" + }, + { + "id": "geo-triangle-area", + "area": "Geometry", + "topic": "Triangle area = half base times height", + "title": "Triangle area = (1/2) * base * height", + "equation": "Area = (1/2) * base * height", + "keywords": [ + "triangle area", + "half base times height", + "area of triangle", + "base and height", + "shear invariance", + "equal area triangles", + "constant area", + "perpendicular height", + "cavalieri" + ], + "explanation": "The area of a triangle is half the product of any base and the perpendicular height to the opposite vertex: Area = (1/2)*base*height. The striking consequence is that area depends only on those two numbers, not on where the apex sits horizontally: sliding the apex left or right along a line parallel to the base keeps both the base and the perpendicular height fixed, so the area is unchanged even though the triangle looks very different. The animation shears the apex back and forth between the two dashed parallel lines while the green vertical height stays constant, and the readout confirms the area never moves off its computed value.", + "bullets": [ + "Area uses the base and the PERPENDICULAR height, shown as the green dashed segment.", + "The two dashed parallels keep the apex's height fixed as it slides.", + "Shearing the apex sideways changes the shape but not base, height, or area.", + "The readout stays pinned to (1/2)*base*height throughout the sweep." + ], + "params": [ + { + "name": "base", + "label": "base b", + "min": 1, + "max": 7, + "step": 0.5, + "value": 4 + }, + { + "name": "height", + "label": "height h", + "min": 1, + "max": 5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst baseLen = P.base, height = P.height;\nconst scale = 60;\nconst bx0 = 200, by0 = 440;\nconst bx1 = bx0 + baseLen * scale;\nconst shear = (baseLen * scale * 0.9) * Math.sin(t * 0.7);\nconst apexX = (bx0 + bx1) / 2 + shear;\nconst apexY = by0 - height * scale;\nconst area = 0.5 * baseLen * height;\nH.text(\"Triangle area = (1/2) * base * height\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Sliding the apex sideways keeps base AND height fixed, so the area never changes.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.line(120, apexY, H.W - 120, apexY, { color: H.colors.grid, width: 1.5, dash: [6, 6] });\nH.line(120, by0, H.W - 120, by0, { color: H.colors.grid, width: 1.5, dash: [6, 6] });\nH.path([[bx0, by0], [bx1, by0], [apexX, apexY]], { color: H.colors.ink, width: 3, fill: \"rgba(110,168,254,0.16)\", close: true });\nH.line(bx0, by0, bx1, by0, { color: H.colors.accent, width: 5 });\nH.line(apexX, apexY, apexX, by0, { color: H.colors.good, width: 3, dash: [4, 4] });\nH.rect(apexX, by0 - 14, 14, 14, { stroke: H.colors.good, width: 1.5 });\nH.circle(apexX, apexY, 6, { fill: H.colors.warn });\nH.text(\"base = \" + baseLen.toFixed(1), (bx0 + bx1) / 2, by0 + 26, { color: H.colors.accent, size: 14, align: \"center\" });\nH.text(\"height = \" + height.toFixed(1), apexX + 12, (apexY + by0) / 2, { color: H.colors.good, size: 13 });\nH.text(\"apex slides ->\", apexX, apexY - 16, { color: H.colors.warn, size: 12, align: \"center\" });\nH.rect(560, 90, 320, 96, { fill: H.colors.panel, stroke: H.colors.grid, width: 1, radius: 8 });\nH.text(\"area = (1/2)(\" + baseLen.toFixed(1) + \")(\" + height.toFixed(1) + \")\", 578, 124, { color: H.colors.sub, size: 14 });\nH.text(\"area = \" + area.toFixed(2) + \" (constant)\", 578, 156, { color: H.colors.violet, size: 16, weight: 700 });" + }, + { + "id": "geo-area-parallelogram", + "area": "Geometry", + "topic": "Area of a parallelogram = b*h", + "title": "Area of a parallelogram: A = b * h", + "equation": "A = b * h", + "keywords": [ + "parallelogram", + "area of parallelogram", + "base times height", + "b*h", + "shear", + "slant", + "rectangle area", + "perpendicular height", + "geometry area", + "quadrilateral" + ], + "explanation": "A parallelogram's area equals its base times its perpendicular height, the same formula as a rectangle. This holds because shearing the top edge sideways slices a triangle off one end and slides it to the other, rearranging the shape into a rectangle of identical base and height without adding or removing any area. The slant slider rocks the top edge back and forth: the figure leans more or less, yet b, the perpendicular height h (the green dashed drop, not the slanted side), and therefore the area never change.", + "bullets": [ + "Area uses the PERPENDICULAR height h, not the length of the slanted side.", + "Shearing the top edge keeps base b and height h fixed, so area is constant.", + "Cut a triangle off one side and slide it over to form a rectangle of equal area.", + "The live readout shows A = b * h staying the same as the slant changes." + ], + "params": [ + { + "name": "b", + "label": "base b", + "min": 60, + "max": 280, + "step": 10, + "value": 200 + }, + { + "name": "h", + "label": "height h", + "min": 40, + "max": 200, + "step": 10, + "value": 130 + }, + { + "name": "slant", + "label": "slant amount", + "min": 0, + "max": 160, + "step": 10, + "value": 90 + } + ], + "code": "H.background();\nconst b = P.b, h = P.h, slant = P.slant;\nconst cx = H.W/2, baseY = H.H*0.72;\nconst x0 = cx - b/2;\nconst sweep = (slant) * (0.5 + 0.5*Math.sin(t*0.7));\nH.text(\"Area of a parallelogram = b * h\", 24, 30, {color:H.colors.ink, size:18, weight:700});\nH.text(\"Shearing slides the top edge but never changes the area.\", 24, 52, {color:H.colors.sub, size:13});\nconst TL = [x0 + sweep, baseY - h];\nconst TR = [x0 + b + sweep, baseY - h];\nconst BL = [x0, baseY];\nconst BR = [x0 + b, baseY];\nH.path([BL, BR, TR, TL], {close:true, fill:\"rgba(124,196,255,0.18)\", color:H.colors.accent, width:2.5});\nH.line(BL[0], baseY+14, BR[0], baseY+14, {color:H.colors.sub, width:1.5});\nH.text(\"b = \" + b.toFixed(0), (BL[0]+BR[0])/2, baseY+30, {color:H.colors.sub, size:13, align:\"center\"});\nH.line(BR[0]+22, baseY, BR[0]+22, baseY-h, {color:H.colors.good, width:1.5, dash:[5,4]});\nH.text(\"h = \" + h.toFixed(0), BR[0]+30, baseY-h/2, {color:H.colors.good, size:13, baseline:\"middle\"});\nconst dropX = TL[0];\nH.line(dropX, baseY-h, dropX, baseY, {color:H.colors.warn, width:1.5, dash:[4,4]});\nconst area = b*h;\nH.text(\"Area = b * h = \" + b.toFixed(0) + \" * \" + h.toFixed(0) + \" = \" + area.toFixed(0), 24, 76, {color:H.colors.accent2, size:14});" + }, + { + "id": "geo-area-trapezoid", + "area": "Geometry", + "topic": "Area of a trapezoid", + "title": "Area of a trapezoid: A = (1/2)(b1 + b2) h", + "equation": "A = (1/2)(b1 + b2) * h", + "keywords": [ + "trapezoid", + "trapezium", + "area of trapezoid", + "parallel bases", + "average of bases", + "half sum of bases", + "b1 b2", + "midsegment", + "geometry area", + "quadrilateral" + ], + "explanation": "A trapezoid has two parallel sides, the bases b1 and b2, and its area is the average of those bases times the perpendicular height. Intuitively, the midline halfway up has length (b1 + b2)/2, so the trapezoid covers the same area as a rectangle of that average width and height h; equivalently, two copies of the trapezoid join into a parallelogram of base b1 + b2. The b1 and b2 sliders stretch the bottom and top edges independently while the warn-colored dashed line shows the average base, and the h slider sets the perpendicular height that scales the whole area.", + "bullets": [ + "Area = average of the two parallel bases, (b1 + b2)/2, times the height h.", + "The dashed midline length equals that average base.", + "Two flipped copies of a trapezoid tile a parallelogram of base b1 + b2.", + "If b1 equals b2 the formula collapses to the parallelogram area b * h." + ], + "params": [ + { + "name": "b1", + "label": "bottom base b1", + "min": 60, + "max": 300, + "step": 10, + "value": 240 + }, + { + "name": "b2", + "label": "top base b2", + "min": 40, + "max": 240, + "step": 10, + "value": 120 + }, + { + "name": "h", + "label": "height h", + "min": 40, + "max": 180, + "step": 10, + "value": 120 + } + ], + "code": "H.background();\nconst b1 = P.b1, b2 = P.b2, h = P.h;\nconst cx = H.W/2, baseY = H.H*0.72;\nH.text(\"Area of a trapezoid = (1/2)(b1 + b2) h\", 24, 30, {color:H.colors.ink, size:18, weight:700});\nH.text(\"Average the two parallel bases, then multiply by the height.\", 24, 52, {color:H.colors.sub, size:13});\nconst BL = [cx - b1/2, baseY];\nconst BR = [cx + b1/2, baseY];\nconst TL = [cx - b2/2, baseY - h];\nconst TR = [cx + b2/2, baseY - h];\nH.path([BL, BR, TR, TL], {close:true, fill:\"rgba(124,196,255,0.18)\", color:H.colors.accent, width:2.5});\nH.line(BL[0], baseY+14, BR[0], baseY+14, {color:H.colors.sub, width:1.5});\nH.text(\"b1 = \" + b1.toFixed(0), cx, baseY+30, {color:H.colors.sub, size:13, align:\"center\"});\nH.line(TL[0], baseY-h-14, TR[0], baseY-h-14, {color:H.colors.violet, width:1.5});\nH.text(\"b2 = \" + b2.toFixed(0), cx, baseY-h-28, {color:H.colors.violet, size:13, align:\"center\"});\nH.line(BR[0]+24, baseY, BR[0]+24, baseY-h, {color:H.colors.good, width:1.5, dash:[5,4]});\nH.text(\"h = \" + h.toFixed(0), BR[0]+32, baseY-h/2, {color:H.colors.good, size:13, baseline:\"middle\"});\nconst avg = (b1+b2)/2;\nconst area = avg*h;\nconst mid = baseY - h*(0.4+0.15*Math.sin(t*0.6));\nH.line(cx-avg/2, mid, cx+avg/2, mid, {color:H.colors.warn, width:2, dash:[4,3]});\nH.text(\"avg base = \" + avg.toFixed(1), 24, 76, {color:H.colors.warn, size:13});\nH.text(\"Area = (1/2)(\" + b1.toFixed(0) + \" + \" + b2.toFixed(0) + \") * \" + h.toFixed(0) + \" = \" + area.toFixed(0), 24, 96, {color:H.colors.accent2, size:14});" + }, + { + "id": "geo-polygon-angle-sum", + "area": "Geometry", + "topic": "Polygon interior angle sum = (n-2)*180", + "title": "Polygon interior angle sum: (n - 2) * 180", + "equation": "interior angle sum = (n - 2) * 180", + "keywords": [ + "interior angle sum", + "polygon angles", + "n-2 times 180", + "regular polygon", + "triangulation", + "pentagon angles", + "hexagon angles", + "n-gon", + "each interior angle", + "geometry angles" + ], + "explanation": "The interior angles of any n-sided polygon add up to (n - 2) * 180 degrees. The reason is triangulation: drawing every diagonal from a single vertex splits the polygon into exactly n - 2 triangles, and since each triangle's angles sum to 180, the whole polygon sums to (n - 2) * 180. The n slider rebuilds a regular n-gon and shows those dashed fan diagonals; because the polygon is regular, dividing the total by n gives each equal interior angle, shown on the highlighted vertex that hops around the shape.", + "bullets": [ + "Diagonals from one vertex cut an n-gon into exactly n - 2 triangles.", + "Each triangle contributes 180 degrees, so the sum is (n - 2) * 180.", + "For a REGULAR n-gon every interior angle equals the sum divided by n.", + "More sides means a larger total and each angle nears (but never reaches) 180 degrees." + ], + "params": [ + { + "name": "n", + "label": "sides n", + "min": 3, + "max": 10, + "step": 1, + "value": 6 + } + ], + "code": "H.background();\nconst n = Math.max(3, Math.round(P.n));\nconst cx = H.W/2, cy = H.H*0.56, R = 150;\nconst rot = -Math.PI/2 + 0.15*Math.sin(t*0.4);\nconst pts = [];\nfor (let i = 0; i < n; i++){\n const a = rot + i*2*Math.PI/n;\n pts.push([cx + R*Math.cos(a), cy + R*Math.sin(a)]);\n}\nconst sum = (n-2)*180;\nconst each = sum/n;\nH.text(\"Interior angle sum = (n - 2) * 180\", 24, 30, {color:H.colors.ink, size:18, weight:700});\nH.text(\"Each diagonal from one vertex cuts the n-gon into n - 2 triangles.\", 24, 52, {color:H.colors.sub, size:13});\nH.path(pts, {close:true, fill:\"rgba(124,196,255,0.14)\", color:H.colors.accent, width:2.5});\nfor (let i = 2; i < n-1; i++){\n H.line(pts[0][0], pts[0][1], pts[i][0], pts[i][1], {color:H.colors.violet, width:1, dash:[4,4]});\n}\nconst hi = Math.floor(t*0.8) % n;\nH.circle(pts[hi][0], pts[hi][1], 6, {fill:H.colors.warn, stroke:H.colors.bg, width:2});\nH.text(each.toFixed(0) + \"°\", pts[hi][0], pts[hi][1]-14, {color:H.colors.warn, size:12, align:\"center\"});\nH.text(\"n = \" + n + \" triangles = \" + (n-2), 24, 76, {color:H.colors.good, size:13});\nH.text(\"sum = (\" + n + \" - 2) * 180 = \" + sum + \"°\", 24, 96, {color:H.colors.accent2, size:14});\nH.text(\"each interior angle = \" + each.toFixed(1) + \"°\", 24, 116, {color:H.colors.violet, size:13});" + }, + { + "id": "geo-polygon-exterior", + "area": "Geometry", + "topic": "Exterior angles of a polygon sum to 360", + "title": "Exterior angle sum of a polygon = 360", + "equation": "exterior angle sum = 360", + "keywords": [ + "exterior angles", + "exterior angle sum", + "360 degrees", + "polygon turning", + "full turn", + "regular polygon", + "turn angle", + "n-gon exterior", + "each exterior angle", + "geometry angles" + ], + "explanation": "The exterior angles of any convex polygon always add up to 360 degrees, no matter how many sides it has. The reason is turning: if you walk all the way around the boundary, at each vertex you turn by that vertex's exterior angle, and arriving back at the start facing your original direction means you have turned through exactly one full revolution. The n slider changes how many sides share that single turn, so for a regular polygon each exterior angle is 360/n, and the orange wedges on the dial accumulate one full turn as the animation steps from vertex to vertex.", + "bullets": [ + "Walking the boundary once turns you through exactly one full 360-degree turn.", + "That turn is split among the vertices, so all exterior angles sum to 360.", + "For a REGULAR n-gon each exterior angle equals 360/n.", + "The sum is 360 for every n, so more sides means smaller individual turns." + ], + "params": [ + { + "name": "n", + "label": "sides n", + "min": 3, + "max": 10, + "step": 1, + "value": 5 + } + ], + "code": "H.background();\nconst n = Math.max(3, Math.round(P.n));\nconst cx = H.W*0.40, cy = H.H*0.56, R = 130;\nconst rot = -Math.PI/2;\nconst pts = [];\nfor (let i = 0; i < n; i++){\n const a = rot + i*2*Math.PI/n;\n pts.push([cx + R*Math.cos(a), cy + R*Math.sin(a)]);\n}\nconst ext = 360/n;\nH.text(\"Exterior angles of any polygon sum to 360°\", 24, 30, {color:H.colors.ink, size:18, weight:700});\nH.text(\"Walking the whole boundary turns you through exactly one full turn.\", 24, 52, {color:H.colors.sub, size:13});\nH.path(pts, {close:true, fill:\"rgba(124,196,255,0.10)\", color:H.colors.accent, width:2.5});\nfor (let i = 0; i < n; i++){\n const A = pts[i], B = pts[(i+1)%n];\n const dx = B[0]-A[0], dy = B[1]-A[1];\n const L = Math.hypot(dx,dy) || 1;\n const ux = dx/L, uy = dy/L;\n H.arrow(B[0]-ux*22, B[1]-uy*22, B[0]+ux*26, B[1]+uy*26, {color:H.colors.accent2, width:2});\n}\nconst dialX = H.W*0.82, dialY = H.H*0.56, dR = 90;\nH.circle(dialX, dialY, dR, {stroke:H.colors.grid, width:1.5});\nconst k = Math.floor((t*0.7) % n);\nconst turned = (k+1)*ext;\nfor (let i = 0; i <= k; i++){\n const a0 = -Math.PI/2 + i*ext*Math.PI/180;\n const a1 = -Math.PI/2 + (i+1)*ext*Math.PI/180;\n const wedge = [[dialX,dialY]];\n for (let s = 0; s <= 10; s++){\n const a = a0 + (a1-a0)*s/10;\n wedge.push([dialX + dR*Math.cos(a), dialY + dR*Math.sin(a)]);\n }\n H.path(wedge, {close:true, fill:\"rgba(244,162,89,0.22)\", color:H.colors.accent2, width:1});\n}\nH.text(\"each exterior = 360 / \" + n + \" = \" + ext.toFixed(1) + \"°\", 24, 76, {color:H.colors.accent2, size:13});\nH.text(\"turned so far = \" + turned.toFixed(0) + \"° of 360°\", 24, 96, {color:H.colors.good, size:13});\nH.text(\"full turn\", dialX, dialY+dR+20, {color:H.colors.sub, size:12, align:\"center\"});" + }, + { + "id": "geo-circle-area-circumference", + "area": "Geometry", + "topic": "Circle: area pi r^2 and circumference 2 pi r", + "title": "Circle: area = pi r^2, circumference = 2 pi r", + "equation": "A = pi r^2, C = 2 pi r", + "keywords": [ + "circle area", + "circumference", + "pi r squared", + "2 pi r", + "radius", + "pi", + "rolling circle", + "perimeter of circle", + "area of circle", + "geometry circle" + ], + "explanation": "A circle of radius r has circumference C = 2 pi r and area A = pi r squared. The circumference is what unrolls: as the circle makes one complete rotation it rolls out a straight track exactly 2 pi r long, which is why pi is defined as the ratio of circumference to diameter. The r slider scales both quantities at once, but notice they grow differently, the circumference grows in proportion to r while the area grows with r squared, so doubling the radius doubles the perimeter but quadruples the area; the rolling animation lays the circumference flat as the orange segment to make the 2 pi r length concrete.", + "bullets": [ + "Circumference C = 2 pi r is the straight track laid down by one full roll.", + "Area A = pi r^2 grows with the SQUARE of the radius, not linearly.", + "Double r and C doubles but A quadruples, the live readouts show this.", + "pi is exactly the ratio of any circle's circumference to its diameter." + ], + "params": [ + { + "name": "r", + "label": "radius r", + "min": 1, + "max": 8, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst r = Math.max(0.1, P.r);\nconst maxR = 8;\nconst scale = Math.min(20, (H.W - 140) / (2*Math.PI*maxR));\nconst Rpx = r*scale;\nconst cy = H.H*0.40;\nconst startX = 80;\nconst trackY = cy + Rpx + 40;\nconst circ = 2*Math.PI*r;\nconst area = Math.PI*r*r;\nH.text(\"Circle: area = pi r^2, circumference = 2 pi r\", 24, 30, {color:H.colors.ink, size:18, weight:700});\nH.text(\"One full roll lays the circumference flat as a straight segment.\", 24, 52, {color:H.colors.sub, size:13});\nconst phase = (t*0.25) % 1;\nconst rollDist = phase * 2*Math.PI*Rpx;\nconst cx = startX + Rpx + rollDist;\nH.line(startX, trackY, startX + 2*Math.PI*Rpx, trackY, {color:H.colors.grid, width:2});\nH.text(\"circumference = 2 pi r\", startX, trackY+18, {color:H.colors.sub, size:12});\nH.line(startX, trackY, startX + rollDist, trackY, {color:H.colors.accent2, width:3});\nH.circle(cx, cy, Rpx, {fill:\"rgba(124,196,255,0.16)\", stroke:H.colors.accent, width:2.5});\nH.circle(cx, cy, 3, {fill:H.colors.ink});\nconst ang = -Math.PI/2 + rollDist/Rpx;\nH.line(cx, cy, cx + Rpx*Math.cos(ang), cy + Rpx*Math.sin(ang), {color:H.colors.good, width:2});\nH.circle(cx + Rpx*Math.cos(ang), cy + Rpx*Math.sin(ang), 4, {fill:H.colors.warn});\nH.text(\"r = \" + r.toFixed(2), 24, 80, {color:H.colors.good, size:14});\nH.text(\"circumference = 2 pi r = \" + circ.toFixed(2), 24, 100, {color:H.colors.accent2, size:14});\nH.text(\"area = pi r^2 = \" + area.toFixed(2), 24, 120, {color:H.colors.violet, size:14});" + }, + { + "id": "geo-central-inscribed", + "area": "Geometry", + "topic": "Inscribed angle = half the central angle", + "title": "Inscribed angle theorem: inscribed = central / 2", + "equation": "inscribed angle = (1/2) * central angle", + "keywords": [ + "inscribed angle", + "central angle", + "inscribed angle theorem", + "subtended arc", + "circle angles", + "half the central angle", + "arc", + "circle theorem", + "vertex on circle", + "same arc" + ], + "explanation": "Both the central angle (vertex at the center) and the inscribed angle (vertex on the circle) open onto the SAME highlighted arc, yet the inscribed angle is always exactly half the central one. This holds because of the isosceles triangles formed by equal radii: splitting the inscribed angle through the center shows each piece equals half of the corresponding central piece. The slider sets the subtended arc, so you can watch both angles grow together while their 2-to-1 ratio stays locked; meanwhile the orange vertex slides along the far (major) arc, and the inscribed reading never changes — every viewing point on that arc sees the same angle.", + "bullets": [ + "Central angle (blue) and inscribed angle (orange) face the SAME yellow arc.", + "The inscribed reading is exactly half the central reading at every slider value.", + "Move the slider: both angles scale, but the 1/2 ratio is invariant.", + "The orange vertex drifts along the arc yet the inscribed angle stays constant." + ], + "params": [ + { + "name": "arc", + "label": "subtended arc (deg)", + "min": 20, + "max": 180, + "step": 5, + "value": 100 + } + ], + "code": "H.background();\nconst cx = H.W*0.5, cy = H.H*0.55, R = 150;\n// Arc subtended: from B (fixed) to C (controlled by slider P.arc, in degrees)\nconst aB = Math.PI*0.95; // fixed left endpoint\nconst aC = aB - (P.arc*Math.PI/180); // C swings clockwise by arc degrees\nconst Bx = cx + R*Math.cos(aB), By = cy + R*Math.sin(aB);\nconst Cx = cx + R*Math.cos(aC), Cy = cy + R*Math.sin(aC);\n// Inscribed vertex P rides along the MAJOR arc (the part not between B,C); animate with t\nconst span = (Math.PI*2) - (aB - aC); // major-arc angular length\nconst aP = aC - 0.15 - (0.7 + 0.25*Math.sin(t*0.7)) * (span - 0.3);\nconst Px = cx + R*Math.cos(aP), Py = cy + R*Math.sin(aP);\n// circle + center\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 2 });\nH.circle(cx, cy, 4, { fill: H.colors.sub });\n// highlight subtended minor arc B->C\nconst arcPts = [];\nfor (let i=0;i<=40;i++){ const a = aB + (aC-aB)*i/40; arcPts.push([cx+R*Math.cos(a), cy+R*Math.sin(a)]); }\nH.path(arcPts, { color: H.colors.yellow, width: 6 });\n// central angle (vertex = center)\nH.line(cx, cy, Bx, By, { color: H.colors.accent, width: 2.5 });\nH.line(cx, cy, Cx, Cy, { color: H.colors.accent, width: 2.5 });\n// inscribed angle (vertex = P)\nH.line(Px, Py, Bx, By, { color: H.colors.accent2, width: 2.5 });\nH.line(Px, Py, Cx, Cy, { color: H.colors.accent2, width: 2.5 });\nH.circle(Bx, By, 5, { fill: H.colors.yellow });\nH.circle(Cx, Cy, 5, { fill: H.colors.yellow });\nH.circle(Px, Py, 6, { fill: H.colors.accent2 });\nconst central = P.arc; // degrees\nconst inscribed = central/2;\nH.text(\"Inscribed angle = half the central angle\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Same arc (yellow): blue central is twice the orange inscribed.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"central = \" + central.toFixed(0) + \" deg\", 24, 80, { color: H.colors.accent, size: 14 });\nH.text(\"inscribed = \" + inscribed.toFixed(1) + \" deg (constant as the vertex moves)\", 24, 100, { color: H.colors.accent2, size: 14 });" + }, + { + "id": "geo-arc-sector", + "area": "Geometry", + "topic": "Arc length (r theta) and sector area (half r^2 theta)", + "title": "Arc length and sector area: s = r*theta, A = (1/2)r^2*theta", + "equation": "s = r * theta, A = (1/2) * r^2 * theta", + "keywords": [ + "arc length", + "sector area", + "radian measure", + "r theta", + "half r squared theta", + "circle sector", + "central angle", + "radians", + "pie slice", + "fraction of circle" + ], + "explanation": "A sector is a pie slice cut by a central angle theta (measured in radians). The arc length is s = r*theta because radian measure literally defines theta as arc-over-radius, so the arc is theta radii laid end to end. The area is A = (1/2)r^2*theta — it is the fraction theta/(2pi) of the whole disk pi*r^2, which simplifies exactly to half r-squared theta. The slider changes the radius and the angle; the yellow arc edge tracks s while the shaded blue fan sweeps out the area, and both readouts update live so you can confirm that doubling theta doubles both quantities and that growing r stretches the arc linearly but the area quadratically.", + "bullets": [ + "theta is in RADIANS, which is why s = r*theta has no extra constant.", + "Yellow = arc length s; the shaded blue fan = sector area A.", + "Sector area is the slice fraction theta/(2pi) of pi*r^2 = (1/2)r^2*theta.", + "Increase r: the arc grows linearly but the area grows with r^2." + ], + "params": [ + { + "name": "r", + "label": "radius r", + "min": 1, + "max": 9, + "step": 0.5, + "value": 5 + }, + { + "name": "theta", + "label": "angle theta (rad)", + "min": 0.3, + "max": 5.5, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\nconst cx = H.W*0.42, cy = H.H*0.56;\nconst r = P.r; // radius (data units), scaled to pixels\nconst theta = P.theta; // radians\nconst scale = 28; // px per unit\nconst R = r*scale;\n// animated sweep angle from 0 to theta to \"fill\" the sector\nconst fill = (0.5 + 0.5*Math.sin(t*0.6)); // 0..1\nconst sweep = theta*fill;\nconst a0 = -0.3; // starting angle\n// full circle outline\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 2 });\nH.circle(cx, cy, 4, { fill: H.colors.sub });\n// shaded sector (fan) up to swept angle\nconst fan = [[cx,cy]];\nfor (let i=0;i<=48;i++){ const a = a0 + sweep*i/48; fan.push([cx+R*Math.cos(a), cy+R*Math.sin(a)]); }\nfan.push([cx,cy]);\nH.path(fan, { color: H.colors.accent, width: 1.5, fill: \"rgba(124,196,255,0.28)\", close: true });\n// two radii at full theta\nconst eAx = cx+R*Math.cos(a0), eAy = cy+R*Math.sin(a0);\nconst eBx = cx+R*Math.cos(a0+theta), eBy = cy+R*Math.sin(a0+theta);\nH.line(cx, cy, eAx, eAy, { color: H.colors.accent2, width: 2.5 });\nH.line(cx, cy, eBx, eBy, { color: H.colors.accent2, width: 2.5 });\n// highlight the swept arc edge (arc length)\nconst arc = [];\nfor (let i=0;i<=48;i++){ const a = a0 + sweep*i/48; arc.push([cx+R*Math.cos(a), cy+R*Math.sin(a)]); }\nH.path(arc, { color: H.colors.yellow, width: 5 });\n// radius label\nH.text(\"r=\" + r.toFixed(1), cx + R*0.5*Math.cos(a0)+4, cy + R*0.5*Math.sin(a0)+14, { color: H.colors.accent2, size: 13 });\nconst arcLen = r*theta;\nconst sectorArea = 0.5*r*r*theta;\nH.text(\"Arc length = r*theta, Sector area = (1/2)r^2*theta\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"theta in radians; yellow = arc, blue = swept sector area.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"theta = \" + theta.toFixed(2) + \" rad\", 24, 80, { color: H.colors.sub, size: 14 });\nH.text(\"arc length = \" + arcLen.toFixed(2), 24, 100, { color: H.colors.yellow, size: 14 });\nH.text(\"sector area = \" + sectorArea.toFixed(2), 24, 120, { color: H.colors.accent, size: 14 });" + }, + { + "id": "geo-tangent-radius", + "area": "Geometry", + "topic": "A tangent is perpendicular to the radius", + "title": "Tangent-radius: tangent is perpendicular to the radius (90 deg)", + "equation": "radius . tangent = 0 -> angle = 90 deg", + "keywords": [ + "tangent line", + "radius", + "perpendicular", + "tangent to circle", + "right angle", + "point of tangency", + "90 degrees", + "circle tangent", + "tangent radius theorem", + "normal line" + ], + "explanation": "At the single point where a tangent line touches a circle, that line is perpendicular to the radius drawn to the touch point. The reason is that the radius is the SHORTEST distance from the center to the tangent line, and the shortest segment from a point to a line is always the perpendicular one — any other point on the tangent lies outside the circle, hence farther away. The slider moves the touch point around the circle; the radius (blue) and the tangent (orange) rotate together, and the green right-angle square confirms they always meet at exactly 90 degrees no matter where the contact point sits.", + "bullets": [ + "The tangent touches the circle at exactly one point (the touch point).", + "Blue radius to that point is perpendicular to the orange tangent line.", + "It is the shortest center-to-line distance, which is always perpendicular.", + "Drag the slider: the right angle holds for every position on the circle." + ], + "params": [ + { + "name": "ang", + "label": "touch point (deg)", + "min": 0, + "max": 360, + "step": 5, + "value": 40 + } + ], + "code": "H.background();\nconst cx = H.W*0.5, cy = H.H*0.55, R = 150;\n// point on circle controlled by slider P.ang (degrees), gently nudged by t\nconst a = P.ang*Math.PI/180 + 0.15*Math.sin(t*0.7);\nconst Tx = cx + R*Math.cos(a), Ty = cy + R*Math.sin(a);\n// tangent direction is perpendicular to radius: (-sin, cos)\nconst tx = -Math.sin(a), ty = Math.cos(a);\nconst L = 150;\nconst T1x = Tx - tx*L, T1y = Ty - ty*L;\nconst T2x = Tx + tx*L, T2y = Ty + ty*L;\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 2 });\nH.circle(cx, cy, 4, { fill: H.colors.sub });\n// radius\nH.line(cx, cy, Tx, Ty, { color: H.colors.accent, width: 2.5 });\n// tangent line\nH.line(T1x, T1y, T2x, T2y, { color: H.colors.accent2, width: 3 });\n// right-angle square at T\nconst s = 16;\nconst rx = -Math.cos(a), ry = -Math.sin(a); // inward radial dir\nconst c1 = [Tx + rx*s, Ty + ry*s];\nconst c2 = [c1[0] + tx*s, c1[1] + ty*s];\nconst c3 = [Tx + tx*s, Ty + ty*s];\nH.path([[Tx,Ty], c3, c2, c1], { color: H.colors.good, width: 2, close: true });\nH.circle(Tx, Ty, 6, { fill: H.colors.yellow });\nH.text(\"Tangent is perpendicular to the radius\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"The radius (blue) meets the tangent (orange) at exactly 90 deg.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"touch point angle = \" + (((P.ang%360)+360)%360).toFixed(0) + \" deg\", 24, 80, { color: H.colors.yellow, size: 14 });\nH.text(\"radius . tangent = 0 -> angle = 90 deg\", 24, 100, { color: H.colors.good, size: 14 });" + }, + { + "id": "geo-chord-bisector", + "area": "Geometry", + "topic": "A radius perpendicular to a chord bisects it", + "title": "Perpendicular radius bisects a chord (equal halves)", + "equation": "left half = right half = sqrt(r^2 - d^2)", + "keywords": [ + "chord", + "perpendicular bisector", + "radius bisects chord", + "circle chord", + "midpoint of chord", + "equal halves", + "perpendicular from center", + "chord distance", + "right angle", + "bisect" + ], + "explanation": "When a radius (or any line through the center) meets a chord at a right angle, it cuts the chord into two equal halves. This follows from congruent right triangles: drop the radius to the chord, and the two triangles formed share that radius, share the full circle radius as their hypotenuses, and each has a right angle — so by Hypotenuse-Leg they are congruent, forcing the two half-chords to match. The slider sets the chord's distance d from the center; as d grows the chord shrinks toward a point, and each half always equals sqrt(r^2 - d^2), shown live while the green tick marks confirm the two halves stay identical.", + "bullets": [ + "The center-to-chord segment meets the chord at a right angle (green marker).", + "It lands exactly on the midpoint: the two green half-chords are equal.", + "Congruent right triangles (shared radius + hypotenuse) force the split.", + "Half-length = sqrt(r^2 - d^2): larger distance d gives a shorter chord." + ], + "params": [ + { + "name": "d", + "label": "chord distance d (px)", + "min": -150, + "max": 150, + "step": 5, + "value": 60 + } + ], + "code": "H.background();\nconst cx = H.W*0.5, cy = H.H*0.52, R = 160;\nlet d = P.d + 6*Math.sin(t*0.6);\nconst maxd = R - 4;\nif (d > maxd) d = maxd; if (d < -maxd) d = -maxd;\nconst half = Math.sqrt(Math.max(0, R*R - d*d));\nconst Mx = cx, My = cy + d;\nconst Lx = cx - half, Ly = cy + d;\nconst Rx = cx + half, Ry = cy + d;\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 2 });\nH.circle(cx, cy, 4, { fill: H.colors.sub });\nH.line(Lx, Ly, Rx, Ry, { color: H.colors.accent2, width: 3 });\nH.circle(Lx, Ly, 5, { fill: H.colors.yellow });\nH.circle(Rx, Ry, 5, { fill: H.colors.yellow });\nH.line(cx, cy, Mx, My, { color: H.colors.accent, width: 2.5 });\nH.circle(Mx, My, 5, { fill: H.colors.good });\nconst s = 14;\nconst sgn = d >= 0 ? 1 : -1;\nH.path([[Mx - s, My - sgn*s],[Mx - s, My],[Mx, My]], { color: H.colors.good, width: 2 });\nfunction tick(ax,ay,bx,by){ const mx=(ax+bx)/2, my=(ay+by)/2; H.line(mx, my-7, mx, my+7, { color: H.colors.good, width: 2 }); }\ntick(Lx,Ly,Mx,My); tick(Mx,My,Rx,Ry);\nH.text(\"A perpendicular radius bisects the chord\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Blue radius hits the chord at 90 deg; the two green halves are equal.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"half length = \" + half.toFixed(0) + \" px (left = right)\", 24, 80, { color: H.colors.good, size: 14 });\nH.text(\"distance from center d = \" + d.toFixed(0) + \" px\", 24, 100, { color: H.colors.accent, size: 14 });" + }, + { + "id": "geo-power-of-point", + "area": "Geometry", + "topic": "Intersecting chords: a*b = c*d", + "title": "Intersecting chords: a*b = c*d (power of a point)", + "equation": "a * b = c * d", + "keywords": [ + "intersecting chords", + "power of a point", + "chord segments", + "a b = c d", + "two chords", + "product of segments", + "circle chords", + "crossing chords theorem", + "segment products", + "chord intersection" + ], + "explanation": "When two chords cross inside a circle, each chord is split into two pieces, and the product of one chord's two pieces equals the product of the other chord's two pieces: a*b = c*d. This is the power of a point: the crossing creates two similar triangles (matching inscribed angles on the same arcs), and equal ratios of corresponding sides rearrange exactly into a*b = c*d. The slider slides the intersection point around inside the circle while the two products are computed live; even as the four individual segment lengths change wildly, the blue product and the orange product stay equal, which is the whole content of the theorem.", + "bullets": [ + "Each chord is cut into two segments at the crossing point (yellow).", + "Blue chord's a*b always equals orange chord's c*d.", + "Similar triangles from equal inscribed angles give a/c = d/b.", + "Slide the crossing: individual pieces change, the two products stay equal." + ], + "params": [ + { + "name": "px", + "label": "crossing x-offset (px)", + "min": -120, + "max": 120, + "step": 5, + "value": 30 + } + ], + "code": "H.background();\nconst cx = H.W*0.5, cy = H.H*0.55, R = 160;\n// Intersection point X inside circle, controlled by slider P.px (horizontal offset), animated vertically by t\nconst px = cx + P.px;\nconst py = cy + 40*Math.sin(t*0.5);\n// keep inside circle\nlet dx = px - cx, dy = py - cy;\nlet dist = Math.hypot(dx, dy);\nlet ix = px, iy = py;\nif (dist > R - 20){ const k = (R-20)/dist; ix = cx + dx*k; iy = cy + dy*k; dist = R-20; }\n// Chord 1: direction angle phi1 (fixed-ish), find both circle intersections through (ix,iy)\nfunction chordEnds(ix, iy, ang){\n const ux = Math.cos(ang), uy = Math.sin(ang);\n // (ix + s*ux - cx)^2 + (iy + s*uy - cy)^2 = R^2\n const fx = ix - cx, fy = iy - cy;\n const b = 2*(fx*ux + fy*uy);\n const c = fx*fx + fy*fy - R*R;\n const disc = Math.max(0, b*b - 4*c);\n const sq = Math.sqrt(disc);\n const s1 = (-b + sq)/2, s2 = (-b - sq)/2;\n return [[ix+s1*ux, iy+s1*uy, s1],[ix+s2*ux, iy+s2*uy, s2]];\n}\nconst ang1 = 0.45;\nconst ang2 = 0.45 + Math.PI/2 + 0.25*Math.sin(t*0.4);\nconst ch1 = chordEnds(ix, iy, ang1);\nconst ch2 = chordEnds(ix, iy, ang2);\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 2 });\n// chords\nH.line(ch1[0][0], ch1[0][1], ch1[1][0], ch1[1][1], { color: H.colors.accent, width: 2.5 });\nH.line(ch2[0][0], ch2[0][1], ch2[1][0], ch2[1][1], { color: H.colors.accent2, width: 2.5 });\n// segment lengths\nconst a = Math.abs(ch1[0][2]), b = Math.abs(ch1[1][2]);\nconst c = Math.abs(ch2[0][2]), dd = Math.abs(ch2[1][2]);\n// endpoints + intersection\nfor (const e of [ch1[0],ch1[1]]) H.circle(e[0], e[1], 4, { fill: H.colors.accent });\nfor (const e of [ch2[0],ch2[1]]) H.circle(e[0], e[1], 4, { fill: H.colors.accent2 });\nH.circle(ix, iy, 6, { fill: H.colors.yellow });\nH.text(\"Intersecting chords: a*b = c*d\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"At the crossing, the two segment products are always equal.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a*b = \" + (a*b).toFixed(0) + \" (blue chord)\", 24, 80, { color: H.colors.accent, size: 14 });\nH.text(\"c*d = \" + (c*dd).toFixed(0) + \" (orange chord)\", 24, 100, { color: H.colors.accent2, size: 14 });" + }, + { + "id": "geo-distance-formula", + "area": "Geometry", + "topic": "Distance formula", + "title": "Distance formula: d = sqrt(dx^2 + dy^2)", + "equation": "d = sqrt((x2 - x1)^2 + (y2 - y1)^2)", + "keywords": [ + "distance formula", + "distance between two points", + "length of segment", + "pythagorean distance", + "sqrt dx2 dy2", + "hypotenuse", + "coordinate geometry", + "dx dy", + "magnitude", + "how far apart" + ], + "explanation": "The distance between two points is just the length of the straight segment joining them, and you find it by building a right triangle on the grid: the horizontal leg is dx = x2 - x1, the vertical leg is dy = y2 - y1, and the segment itself is the hypotenuse. Because the legs meet at a right angle, the Pythagorean theorem gives d = sqrt(dx^2 + dy^2). Squaring removes the sign, so it never matters which point you call first. As you drag the endpoints the blue and green legs resize, the yellow probe sweeps along the pink hypotenuse, and the live readout recomputes d from the two leg lengths.", + "bullets": [ + "The two legs (blue dx, green dy) and the pink hypotenuse form a right triangle.", + "d = sqrt(dx^2 + dy^2) is the Pythagorean theorem applied to those legs.", + "Squaring the differences makes the order of the points irrelevant.", + "The yellow probe rides the hypotenuse; the readout shows d updating live." + ], + "params": [ + { + "name": "x1", + "label": "point 1 x (x1)", + "min": -7, + "max": 7, + "step": 0.5, + "value": -4 + }, + { + "name": "y1", + "label": "point 1 y (y1)", + "min": -5, + "max": 5, + "step": 0.5, + "value": -2 + }, + { + "name": "x2", + "label": "point 2 x (x2)", + "min": -7, + "max": 7, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst x1 = P.x1, y1 = P.y1, x2 = P.x2, y2 = P.y2;\nconst dx = x2 - x1, dy = y2 - y1;\nconst d = Math.sqrt(dx * dx + dy * dy);\nv.line(x1, y1, x2, y1, { color: H.colors.accent, width: 2.5 });\nv.line(x2, y1, x2, y2, { color: H.colors.good, width: 2.5 });\nconst frac = 0.5 + 0.5 * Math.sin(t * 0.8);\nconst hx = x1 + dx * frac, hy = y1 + dy * frac;\nv.line(x1, y1, x2, y2, { color: H.colors.warn, width: 3 });\nv.dot(hx, hy, { r: 6, fill: H.colors.yellow });\nv.dot(x1, y1, { r: 6, fill: H.colors.accent2 });\nv.dot(x2, y2, { r: 6, fill: H.colors.violet });\nv.text(\"|dx| = \" + Math.abs(dx).toFixed(1), (x1 + x2) / 2, y1 - 0.5, { color: H.colors.accent, size: 12, align: \"center\" });\nv.text(\"|dy| = \" + Math.abs(dy).toFixed(1), x2 + 0.3, (y1 + y2) / 2, { color: H.colors.good, size: 12 });\nH.text(\"Distance formula: d = sqrt(dx^2 + dy^2)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"(\" + x1.toFixed(1) + \", \" + y1.toFixed(1) + \") to (\" + x2.toFixed(1) + \", \" + y2.toFixed(1) + \")\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"d = sqrt(\" + dx.toFixed(1) + \"^2 + \" + dy.toFixed(1) + \"^2) = \" + d.toFixed(2), 24, 72, { color: H.colors.warn, size: 13 });" + }, + { + "id": "geo-midpoint", + "area": "Geometry", + "topic": "Midpoint formula", + "title": "Midpoint formula: M = ((x1+x2)/2, (y1+y2)/2)", + "equation": "M = ((x1 + x2)/2, (y1 + y2)/2)", + "keywords": [ + "midpoint formula", + "midpoint of a segment", + "average of coordinates", + "center of segment", + "halfway point", + "coordinate geometry", + "bisect segment", + "mean of points", + "x1 x2 over 2", + "find the middle" + ], + "explanation": "The midpoint of a segment is the point exactly halfway between the two endpoints, and you get it by averaging the coordinates separately: the x of the midpoint is the average of the two x's and the y is the average of the two y's. This works because averaging two numbers lands you precisely between them, so doing it in each direction lands you in the geometric center. The dashed green guides drop from the midpoint to the axes to show those two averages directly. As you drag the endpoints the yellow probe oscillates along the segment while the pink M stays pinned at the true center, and the readout shows the averaged coordinates.", + "bullets": [ + "M is the average of the endpoints, taken one coordinate at a time.", + "The dashed green guides show mx and my read off the axes.", + "The midpoint always lies on the segment, dead center.", + "The yellow probe sweeps the segment; pink M holds the middle." + ], + "params": [ + { + "name": "x1", + "label": "point 1 x (x1)", + "min": -7, + "max": 7, + "step": 0.5, + "value": -4 + }, + { + "name": "y1", + "label": "point 1 y (y1)", + "min": -5, + "max": 5, + "step": 0.5, + "value": -2 + }, + { + "name": "x2", + "label": "point 2 x (x2)", + "min": -7, + "max": 7, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst x1 = P.x1, y1 = P.y1, x2 = P.x2, y2 = P.y2;\nconst mx = (x1 + x2) / 2, my = (y1 + y2) / 2;\nv.line(x1, y1, x2, y2, { color: H.colors.accent, width: 3 });\nconst frac = 0.5 + 0.5 * Math.sin(t * 0.9);\nconst px = x1 + (x2 - x1) * frac, py = y1 + (y2 - y1) * frac;\nv.dot(px, py, { r: 5, fill: H.colors.yellow });\nv.line(mx, 0, mx, my, { color: H.colors.good, width: 1.5, dash: [4, 4] });\nv.line(0, my, mx, my, { color: H.colors.good, width: 1.5, dash: [4, 4] });\nv.dot(x1, y1, { r: 6, fill: H.colors.accent2 });\nv.dot(x2, y2, { r: 6, fill: H.colors.violet });\nv.dot(mx, my, { r: 7, fill: H.colors.warn });\nv.text(\"M\", mx + 0.3, my + 0.3, { color: H.colors.warn, size: 13 });\nH.text(\"Midpoint: M = ((x1+x2)/2, (y1+y2)/2)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"(\" + x1.toFixed(1) + \", \" + y1.toFixed(1) + \") and (\" + x2.toFixed(1) + \", \" + y2.toFixed(1) + \")\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"M = (\" + mx.toFixed(2) + \", \" + my.toFixed(2) + \")\", 24, 72, { color: H.colors.warn, size: 13 });" + }, + { + "id": "geo-slope", + "area": "Geometry", + "topic": "Slope, parallel & perpendicular", + "title": "Slope m = rise/run; perpendicular slope = -1/m", + "equation": "m = (y2 - y1)/(x2 - x1), m_perp = -1/m", + "keywords": [ + "slope", + "rise over run", + "rise run", + "gradient of a line", + "negative reciprocal", + "perpendicular slope", + "parallel lines slope", + "steepness", + "delta y over delta x", + "line slope formula" + ], + "explanation": "Slope measures steepness as rise over run: the vertical change divided by the horizontal change between two points on the line. The green and yellow legs show that rise and run explicitly, and their ratio is the same no matter which two points you pick, which is why a line has a single slope. Two lines are parallel when they share that slope, and perpendicular when one slope is the negative reciprocal of the other, m_perp = -1/m, so the violet dashed line always crosses the blue line at a right angle. Dragging the points changes rise and run live; the code guards the vertical case (run = 0) where the slope is undefined and the perpendicular becomes horizontal.", + "bullets": [ + "Slope = rise/run: green run leg over yellow rise leg.", + "The blue line's slope is constant for every pair of its points.", + "Perpendicular slope is the negative reciprocal: m_perp = -1/m.", + "A vertical line has undefined slope; its perpendicular is horizontal." + ], + "params": [ + { + "name": "x1", + "label": "point 1 x (x1)", + "min": -7, + "max": 7, + "step": 0.5, + "value": -4 + }, + { + "name": "y1", + "label": "point 1 y (y1)", + "min": -5, + "max": 5, + "step": 0.5, + "value": -2 + }, + { + "name": "x2", + "label": "point 2 x (x2)", + "min": -7, + "max": 7, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst x1 = P.x1, y1 = P.y1, x2 = P.x2, y2 = P.y2;\nconst run = x2 - x1, rise = y2 - y1;\nconst eps = 1e-9;\nconst m = Math.abs(run) < eps ? Infinity : rise / run;\nif (Math.abs(run) < eps) {\n v.line(x1, -6, x1, 6, { color: H.colors.accent, width: 3 });\n} else {\n v.line(-8, y1 + m * (-8 - x1), 8, y1 + m * (8 - x1), { color: H.colors.accent, width: 3 });\n}\nv.line(x1, y1, x2, y1, { color: H.colors.good, width: 2.5 });\nv.line(x2, y1, x2, y2, { color: H.colors.yellow, width: 2.5 });\nconst mx = (x1 + x2) / 2, my = (y1 + y2) / 2;\nif (Math.abs(run) < eps) {\n v.line(-8, my, 8, my, { color: H.colors.violet, width: 2.5, dash: [6, 4] });\n} else if (Math.abs(rise) < eps) {\n v.line(mx, -6, mx, 6, { color: H.colors.violet, width: 2.5, dash: [6, 4] });\n} else {\n const mp = -1 / m;\n v.line(-8, my + mp * (-8 - mx), 8, my + mp * (8 - mx), { color: H.colors.violet, width: 2.5, dash: [6, 4] });\n}\nconst u = mx + 3 * Math.sin(t * 0.7);\nconst pu = Math.abs(run) < eps ? my + 3 * Math.sin(t * 0.7) : y1 + m * (u - x1);\nconst pxv = Math.abs(run) < eps ? x1 : u;\nv.dot(pxv, pu, { r: 5, fill: H.colors.warn });\nv.dot(x1, y1, { r: 6, fill: H.colors.accent2 });\nv.dot(x2, y2, { r: 6, fill: H.colors.violet });\nH.text(\"Slope m = rise/run; perp = -1/m\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"rise = \" + rise.toFixed(1) + \", run = \" + run.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"m = \" + (Math.abs(run) < eps ? \"undefined (vertical)\" : m.toFixed(2)) + \" perp = \" + (Math.abs(run) < eps ? \"0\" : (Math.abs(rise) < eps ? \"undefined\" : (-1 / m).toFixed(2))), 24, 72, { color: H.colors.violet, size: 13 });" + }, + { + "id": "geo-translation", + "area": "Geometry", + "topic": "Translation (slide)", + "title": "Translation: (x, y) -> (x + dx, y + dy)", + "equation": "(x, y) -> (x + dx, y + dy)", + "keywords": [ + "translation", + "slide transformation", + "shift a shape", + "rigid motion", + "translation vector", + "add dx dy", + "move shape", + "preimage and image", + "congruent transformation", + "glide" + ], + "explanation": "A translation slides every point of a shape by the same fixed amount: dx to the right and dy up. Because every vertex moves by the identical translation vector, the image is congruent to the preimage with the same size, shape, and orientation, just relocated. The green arrow on one vertex shows that shared vector, and the yellow dashed outline animates the slide from the blue preimage to the pink image. Adjusting dx and dy moves the whole figure together; nothing stretches or rotates, which is what makes a translation a rigid motion.", + "bullets": [ + "Every point shifts by the same vector (dx, dy) — drawn in green.", + "Image (pink) is congruent to the preimage (blue): same size and shape.", + "Orientation is preserved; the shape does not flip or turn.", + "The yellow dashed shape animates the slide partway across." + ], + "params": [ + { + "name": "dx", + "label": "shift x (dx)", + "min": -6, + "max": 6, + "step": 0.5, + "value": 4 + }, + { + "name": "dy", + "label": "shift y (dy)", + "min": -5, + "max": 5, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -7, yMax: 7 });\nv.grid(); v.axes();\nconst dx = P.dx, dy = P.dy;\nconst shape = [[-3, 0], [-1, 0], [-2, 2]];\nv.path(shape.concat([shape[0]]), { color: H.colors.accent, width: 2.5, fill: \"rgba(124,196,255,0.18)\" });\nconst frac = 0.5 + 0.5 * Math.sin(t * 0.8);\nconst moving = shape.map(p => [p[0] + dx * frac, p[1] + dy * frac]);\nv.path(moving.concat([moving[0]]), { color: H.colors.yellow, width: 2, dash: [5, 4] });\nconst image = shape.map(p => [p[0] + dx, p[1] + dy]);\nv.path(image.concat([image[0]]), { color: H.colors.warn, width: 2.5, fill: \"rgba(255,138,160,0.18)\" });\nv.arrow(shape[2][0], shape[2][1], image[2][0], image[2][1], { color: H.colors.good, width: 2 });\nv.dot(shape[2][0], shape[2][1], { r: 5, fill: H.colors.accent2 });\nv.dot(image[2][0], image[2][1], { r: 5, fill: H.colors.violet });\nH.text(\"Translation: (x, y) -> (x + dx, y + dy)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"every point shifts by the same vector\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"dx = \" + dx.toFixed(1) + \", dy = \" + dy.toFixed(1), 24, 72, { color: H.colors.good, size: 13 });" + }, + { + "id": "geo-reflection", + "area": "Geometry", + "topic": "Reflection (flip across a line)", + "title": "Reflection: flip across the y-axis or y = x", + "equation": "y-axis: (x, y) -> (-x, y); y = x: (x, y) -> (y, x)", + "keywords": [ + "reflection", + "flip a shape", + "mirror image", + "reflect across y-axis", + "reflect across y=x", + "line of reflection", + "mirror line", + "negate x", + "swap x and y", + "rigid motion" + ], + "explanation": "A reflection flips a shape across a mirror line so the image is the same distance from the line as the original but on the opposite side. Across the y-axis each point (x, y) becomes (-x, y) — only the sign of x flips — while across the line y = x each point becomes (y, x), swapping the two coordinates. The segment joining any point to its image crosses the mirror at a right angle and is bisected by it, which is exactly what 'mirror image' means. The slider switches the mirror line, the yellow dashed shape animates the flip, and the readout shows one vertex mapping to its reflected coordinates.", + "bullets": [ + "Reflection reverses orientation: the image is a mirror, not a slide.", + "Across the y-axis only x changes sign: (x, y) -> (-x, y).", + "Across y = x the coordinates swap: (x, y) -> (y, x).", + "The mirror line perpendicularly bisects each point-to-image segment." + ], + "params": [ + { + "name": "mode", + "label": "mirror: 0 = y-axis, 2 = y=x (mode)", + "min": 0, + "max": 2, + "step": 2, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -7, yMax: 7 });\nv.grid(); v.axes();\nconst mode = P.mode;\nconst useYx = mode >= 1;\nif (useYx) {\n v.line(-7, -7, 7, 7, { color: H.colors.sub, width: 1.8, dash: [6, 5] });\n v.text(\"y = x\", 5.2, 6, { color: H.colors.sub, size: 12 });\n} else {\n v.line(0, -7, 0, 7, { color: H.colors.sub, width: 1.8, dash: [6, 5] });\n v.text(\"y-axis\", 0.4, 6, { color: H.colors.sub, size: 12 });\n}\nconst shape = [[2, 1], [5, 1], [3, 4]];\nfunction reflect(p) { return useYx ? [p[1], p[0]] : [-p[0], p[1]]; }\nv.path(shape.concat([shape[0]]), { color: H.colors.accent, width: 2.5, fill: \"rgba(124,196,255,0.18)\" });\nconst frac = 0.5 + 0.5 * Math.sin(t * 0.8);\nconst moving = shape.map(p => { const r = reflect(p); return [p[0] + (r[0] - p[0]) * frac, p[1] + (r[1] - p[1]) * frac]; });\nv.path(moving.concat([moving[0]]), { color: H.colors.yellow, width: 2, dash: [5, 4] });\nconst image = shape.map(reflect);\nv.path(image.concat([image[0]]), { color: H.colors.warn, width: 2.5, fill: \"rgba(255,138,160,0.18)\" });\nv.line(shape[0][0], shape[0][1], image[0][0], image[0][1], { color: H.colors.good, width: 1.5 });\nv.dot(shape[0][0], shape[0][1], { r: 5, fill: H.colors.accent2 });\nv.dot(image[0][0], image[0][1], { r: 5, fill: H.colors.violet });\nH.text(\"Reflection: flip across a mirror line\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(useYx ? \"across y = x: (x, y) -> (y, x)\" : \"across y-axis: (x, y) -> (-x, y)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"(\" + shape[0][0].toFixed(1) + \", \" + shape[0][1].toFixed(1) + \") -> (\" + image[0][0].toFixed(1) + \", \" + image[0][1].toFixed(1) + \")\", 24, 72, { color: H.colors.warn, size: 13 });" + }, + { + "id": "geo-rotation", + "area": "Geometry", + "topic": "Rotation about a point", + "title": "Rotation about the origin by angle theta", + "equation": "x' = x cos(theta) - y sin(theta), y' = x sin(theta) + y cos(theta)", + "keywords": [ + "rotation", + "rotate", + "rotation about a point", + "angle of rotation", + "center of rotation", + "transformation", + "rigid motion", + "isometry", + "theta", + "turn", + "rotate about origin", + "preserves distance" + ], + "explanation": "A rotation turns every point of a shape around a fixed center (here the origin O) by the same angle theta, so the figure swings rigidly without changing size or shape. It holds because each point keeps its exact distance r to the center while its angle to O increases by theta — the dashed radius and the solid radius have equal length r, only their direction differs. The angle slider sets the total turn, and the animation sweeps theta from 0 up to that value (the orange arc traces a tracked corner's circular path) so you can watch the blue image rotate off the dashed original. Because distances are preserved, rotation is an isometry: corresponding side lengths and interior angles are identical before and after.", + "bullets": [ + "Every point stays the SAME distance r from the center O.", + "The angle each point makes with O increases by theta.", + "The orange arc traces one corner's circular path of radius r.", + "Lengths and angles are preserved — rotation is a rigid motion." + ], + "params": [ + { + "name": "angle", + "label": "angle theta (deg)", + "min": -180, + "max": 180, + "step": 5, + "value": 120 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -4, yMax: 4 });\nv.grid(); v.axes();\nconst ang = (P.angle * Math.PI) / 180;\nconst sweep = ang * (0.5 + 0.5 * Math.sin(t * 0.7));\nconst c = Math.cos(sweep), s = Math.sin(sweep);\nconst base = [[1, 0.5], [3, 0.5], [3, 2], [1.5, 2.2]];\nconst rot = base.map(([x, y]) => [x * c - y * s, x * s + y * c]);\nv.path([...base, base[0]], { color: H.colors.sub, width: 2, dash: [4, 4] });\nv.path([...rot, rot[0]], { color: H.colors.accent, width: 3, fill: \"rgba(124,196,255,0.18)\", close: true });\nv.dot(0, 0, { r: 5, fill: H.colors.warn });\nconst p0 = base[1], pr = rot[1];\nv.line(0, 0, p0[0], p0[1], { color: H.colors.sub, width: 1.5, dash: [3, 3] });\nv.line(0, 0, pr[0], pr[1], { color: H.colors.accent2, width: 1.5 });\nv.dot(pr[0], pr[1], { r: 5, fill: H.colors.accent2 });\nconst r0 = Math.hypot(p0[0], p0[1]);\nconst ra = Math.atan2(p0[1], p0[0]);\nconst arc = [];\nfor (let i = 0; i <= 24; i++) { const a = ra + (sweep * i) / 24; arc.push([r0 * Math.cos(a), r0 * Math.sin(a)]); }\nv.path(arc, { color: H.colors.accent2, width: 1.5 });\nH.text(\"Rotation about the origin\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"every point keeps its distance to O; angle to O grows by theta\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"theta = \" + ((sweep * 180) / Math.PI).toFixed(0) + \" deg r = \" + r0.toFixed(2), 24, 74, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "geo-dilation", + "area": "Geometry", + "topic": "Dilation (scale from a center)", + "title": "Dilation from a center: lengths x k, area x k^2", + "equation": "P' = C + k(P - C); area scales by k^2", + "keywords": [ + "dilation", + "scale factor", + "scaling", + "center of dilation", + "similar figures", + "similarity transformation", + "enlargement", + "reduction", + "k", + "resize", + "proportional", + "area scale k^2" + ], + "explanation": "A dilation pushes every point P directly away from (or toward) a fixed center C, moving it to k times its original distance from C, which produces a similar figure with the same shape but a different size. It holds because all displacement rays emanate from the single center C (the dashed lines), so every length is multiplied by the same factor k — and since area depends on two perpendicular lengths, the area multiplies by k times k, i.e. k^2. The k slider sets the target scale, and the animation breathes the image between the original (k=1) and the chosen k so you can see lengths grow linearly while the colored area grows quadratically. When k>1 the image enlarges, when 0 [cx + k * (x - cx), cy + k * (y - cy)]);\nv.dot(cx, cy, { r: 5, fill: H.colors.warn });\nv.path([...base, base[0]], { color: H.colors.sub, width: 2, dash: [4, 4] });\nv.path([...img, img[0]], { color: H.colors.accent, width: 3, fill: \"rgba(124,196,255,0.18)\", close: true });\nfor (let i = 0; i < base.length; i++) {\n v.line(cx, cy, img[i][0], img[i][1], { color: H.colors.accent2, width: 1, dash: [2, 3] });\n}\nconst a0 = 1.5 * 1.2;\nconst a1 = a0 * k * k;\nH.text(\"Dilation from a center\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"each point moves to k times its distance from the center C\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"k = \" + k.toFixed(2) + \" lengths x\" + k.toFixed(2) + \" area x\" + (k * k).toFixed(2), 24, 74, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "geo-prism-cylinder-volume", + "area": "Geometry", + "topic": "Volume & surface area of a cylinder", + "title": "Cylinder: V = pi r^2 h, SA = 2 pi r^2 + 2 pi r h", + "equation": "V = pi r^2 h, SA = 2 pi r^2 + 2 pi r h", + "keywords": [ + "cylinder", + "volume of a cylinder", + "surface area", + "pi r squared h", + "lateral area", + "base circle", + "radius", + "height", + "3d solid", + "prism volume", + "2 pi r h", + "circular base" + ], + "explanation": "A cylinder is a stack of identical circular discs, so its volume is simply the base circle's area pi r^2 multiplied by the height h. Its surface area has two parts: the two circular caps (each pi r^2) plus the curved side, which unrolls into a flat rectangle of width 2 pi r (the circle's circumference) and height h, giving the lateral area 2 pi r h. The r and h sliders reshape the solid in 3D as it slowly rotates, and the live readout shows how V grows with the square of r but only linearly with h, while the orange radius and dashed axis mark the two measurements that drive every formula.", + "bullets": [ + "Volume = base area pi r^2 stacked through height h.", + "Doubling r quadruples the volume; doubling h only doubles it.", + "The curved side unrolls to a 2 pi r by h rectangle.", + "Surface area = two caps (2 pi r^2) + lateral side (2 pi r h)." + ], + "params": [ + { + "name": "r", + "label": "radius r", + "min": 0.5, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "h", + "label": "height h", + "min": 1, + "max": 8, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst r = P.r, h = P.h;\nconst cam = H.cam3d({ scale: 34, dist: 11, pitch: -0.45, cy: 320 });\ncam.yaw = 0.4 * t;\ncam.grid(6, 1);\nconst N = 40;\nfunction ring(y) { const pts = []; for (let i = 0; i <= N; i++) { const a = (i / N) * Math.PI * 2; pts.push([r * Math.cos(a), y, r * Math.sin(a)]); } return pts; }\nconst yB = -h / 2, yT = h / 2;\ncam.path(ring(yB), { color: H.colors.accent, width: 2 });\ncam.poly(ring(yT), { fill: \"rgba(124,196,255,0.22)\", stroke: H.colors.accent, width: 2 });\nfor (let i = 0; i < N; i += 5) { const a = (i / N) * Math.PI * 2; cam.line([r * Math.cos(a), yB, r * Math.sin(a)], [r * Math.cos(a), yT, r * Math.sin(a)], { color: \"rgba(124,196,255,0.5)\", width: 1 }); }\ncam.line([0, yB, 0], [0, yT, 0], { color: H.colors.warn, width: 2, dash: [4, 4] });\ncam.line([0, yB, 0], [r, yB, 0], { color: H.colors.accent2, width: 2 });\nconst V = Math.PI * r * r * h;\nconst SA = 2 * Math.PI * r * r + 2 * Math.PI * r * h;\nH.text(\"Cylinder: V = pi r^2 h\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"volume = base circle area times height; side unrolls to a 2 pi r by h rectangle\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + r.toFixed(1) + \" h = \" + h.toFixed(1) + \" V = \" + V.toFixed(1) + \" SA = \" + SA.toFixed(1), 24, 74, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "geo-cone-pyramid-volume", + "area": "Geometry", + "topic": "Cone/pyramid volume = one third base times height", + "title": "Cone volume = (1/3) pi r^2 h (one third of the cylinder)", + "equation": "V_cone = (1/3) pi r^2 h", + "keywords": [ + "cone", + "cone volume", + "pyramid volume", + "one third base times height", + "1/3 b h", + "apex", + "slant", + "cylinder comparison", + "radius", + "height", + "base area", + "third of a cylinder" + ], + "explanation": "A cone (like any pyramid) holds exactly one third of the prism or cylinder that shares its base and height, so its volume is (1/3) times the base area pi r^2 times the height h. The reason is that as you rise from base to apex the cross-section shrinks linearly, and integrating those shrinking discs leaves one third of the full straight-walled solid — three identical cones would refill the cylinder. The faint outline is that enclosing cylinder, the orange shape is the cone tapering to its apex, and the r and h sliders rescale both together so the readout always shows the cone's volume equal to one third of the cylinder's pi r^2 h.", + "bullets": [ + "A cone fills exactly 1/3 of the cylinder with the same base and height.", + "Cross-sections shrink linearly from the base up to the apex.", + "Same rule for any pyramid: V = (1/3) (base area)(height).", + "The faint outline is the enclosing cylinder for comparison." + ], + "params": [ + { + "name": "r", + "label": "radius r", + "min": 0.5, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "h", + "label": "height h", + "min": 1, + "max": 8, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst r = P.r, h = P.h;\nconst cam = H.cam3d({ scale: 34, dist: 11, pitch: -0.45, cy: 320 });\ncam.yaw = 0.4 * t;\ncam.grid(6, 1);\nconst N = 40;\nfunction ring(y, rad) { const pts = []; for (let i = 0; i <= N; i++) { const a = (i / N) * Math.PI * 2; pts.push([rad * Math.cos(a), y, rad * Math.sin(a)]); } return pts; }\nconst yB = -h / 2, yT = h / 2;\ncam.path(ring(yB, r), { color: H.colors.grid, width: 1 });\ncam.path(ring(yT, r), { color: H.colors.grid, width: 1 });\nfor (let i = 0; i < N; i += 10) { const a = (i / N) * Math.PI * 2; cam.line([r * Math.cos(a), yB, r * Math.sin(a)], [r * Math.cos(a), yT, r * Math.sin(a)], { color: H.colors.grid, width: 1 }); }\nconst apex = [0, yT, 0];\ncam.poly(ring(yB, r), { fill: \"rgba(244,162,89,0.22)\", stroke: H.colors.accent2, width: 2 });\nfor (let i = 0; i < N; i += 4) { const a = (i / N) * Math.PI * 2; cam.line([r * Math.cos(a), yB, r * Math.sin(a)], apex, { color: \"rgba(244,162,89,0.6)\", width: 1 }); }\ncam.line([0, yB, 0], apex, { color: H.colors.warn, width: 2, dash: [4, 4] });\ncam.line([0, yB, 0], [r, yB, 0], { color: H.colors.accent, width: 2 });\nconst Vcone = (1 / 3) * Math.PI * r * r * h;\nconst Vcyl = Math.PI * r * r * h;\nH.text(\"Cone volume = (1/3) pi r^2 h\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"the cone fills exactly one third of the enclosing cylinder (faint outline)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + r.toFixed(1) + \" h = \" + h.toFixed(1) + \" cone V = \" + Vcone.toFixed(1) + \" = (1/3)(\" + Vcyl.toFixed(1) + \")\", 24, 74, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "geo-sphere-volume", + "area": "Geometry", + "topic": "Sphere: volume 4/3 pi r^3, surface 4 pi r^2", + "title": "Sphere: V = (4/3) pi r^3, SA = 4 pi r^2", + "equation": "V = (4/3) pi r^3, SA = 4 pi r^2", + "keywords": [ + "sphere", + "sphere volume", + "four thirds pi r cubed", + "surface area of a sphere", + "4 pi r squared", + "ball", + "radius", + "3d solid", + "r cubed", + "round solid", + "great circle", + "sphere formula" + ], + "explanation": "A sphere is the set of all points a fixed radius r from a center, and its volume (4/3) pi r^3 grows with the cube of r while its surface area 4 pi r^2 grows with the square. This square-cube split is why a small change in r changes the surface modestly but the volume dramatically — the surface is exactly four times the area of a great-circle cross-section (pi r^2). The r slider resizes the shaded ball as it slowly rotates; the latitude and longitude wireframe lets you read its roundness, the orange segment marks the radius, and the readout updates V and SA so you can see the cubic versus quadratic growth side by side.", + "bullets": [ + "A sphere is every point at distance r from one center.", + "Volume scales with r^3 — very sensitive to the radius.", + "Surface area scales with r^2 = four great-circle areas (4 pi r^2).", + "The wireframe shows latitude/longitude lines on the round surface." + ], + "params": [ + { + "name": "r", + "label": "radius r", + "min": 0.5, + "max": 3, + "step": 0.25, + "value": 2 + } + ], + "code": "H.background();\nconst r = P.r;\nconst cam = H.cam3d({ scale: 40, dist: 11, pitch: -0.35, cy: 300 });\ncam.yaw = 0.4 * t;\ncam.grid(6, 1);\ncam.sphere([0, 0, 0], r, { color: H.colors.accent });\nconst N = 32;\nfor (let m = 1; m <= 3; m++) {\n const phi = (m / 4) * Math.PI - Math.PI / 2;\n const yy = r * Math.sin(phi), rr = r * Math.cos(phi);\n const pts = [];\n for (let i = 0; i <= N; i++) { const a = (i / N) * Math.PI * 2; pts.push([rr * Math.cos(a), yy, rr * Math.sin(a)]); }\n cam.path(pts, { color: \"rgba(255,255,255,0.28)\", width: 1 });\n}\nfor (let m = 0; m < 4; m++) {\n const lon = (m / 4) * Math.PI;\n const pts = [];\n for (let i = 0; i <= N; i++) { const a = (i / N) * Math.PI * 2; pts.push([r * Math.cos(a) * Math.cos(lon), r * Math.sin(a), r * Math.cos(a) * Math.sin(lon)]); }\n cam.path(pts, { color: \"rgba(255,255,255,0.18)\", width: 1 });\n}\ncam.line([0, 0, 0], [r, 0, 0], { color: H.colors.accent2, width: 2 });\nconst V = (4 / 3) * Math.PI * r * r * r;\nconst SA = 4 * Math.PI * r * r;\nH.text(\"Sphere: V = (4/3) pi r^3\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"volume grows with the cube of r; surface area 4 pi r^2 grows with the square\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + r.toFixed(1) + \" V = \" + V.toFixed(1) + \" SA = \" + SA.toFixed(1), 24, 74, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "geo-heron", + "area": "Geometry", + "topic": "Herons formula for triangle area", + "title": "Heron's formula: Area = sqrt(s(s-a)(s-b)(s-c))", + "equation": "Area = sqrt(s(s-a)(s-b)(s-c)), s = (a+b+c)/2", + "keywords": [ + "herons formula", + "heron formula", + "triangle area", + "area of a triangle", + "semiperimeter", + "three sides", + "sss area", + "s(s-a)(s-b)(s-c)", + "triangle sides", + "area without height" + ], + "explanation": "Heron's formula finds a triangle's area from its three side lengths alone, with no need for a height or angle. You first compute the semiperimeter s = (a+b+c)/2, then Area = sqrt(s(s-a)(s-b)(s-c)); each factor (s-a), (s-b), (s-c) is positive exactly when the three sides can actually close into a triangle (the triangle inequality), so the product under the root is non-negative for any real triangle. The sliders set a, b, and c, and the code keeps them valid (the longest side stays shorter than the sum of the other two); watch the live Area readout and the pulsing centroid marker change as you reshape the triangle.", + "bullets": [ + "Only the three side lengths are needed -- no height or angle.", + "s is the semiperimeter: half of the perimeter a+b+c.", + "Each factor s-a, s-b, s-c is positive only for a valid triangle.", + "Drag a, b, c and the Area readout updates instantly." + ], + "params": [ + { + "name": "a", + "label": "side a", + "min": 1, + "max": 8, + "step": 0.5, + "value": 5 + }, + { + "name": "b", + "label": "side b", + "min": 1, + "max": 8, + "step": 0.5, + "value": 6 + }, + { + "name": "c", + "label": "side c", + "min": 1, + "max": 8, + "step": 0.5, + "value": 7 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -1, yMax: 8 });\nv.grid(); v.axes();\nlet a = P.a, b = P.b, c = P.c;\nconst m = Math.max(a, b, c);\nif (m >= a + b + c - m) { const fix = (a + b + c - m) * 0.98; if (a === m) a = fix; else if (b === m) b = fix; else c = fix; }\nconst s = (a + b + c) / 2;\nconst prod = s * (s - a) * (s - b) * (s - c);\nconst area = prod > 0 ? Math.sqrt(prod) : 0;\nconst Ax = 1, Ay = 1, Bx = 1 + c, By = 1;\nconst cosA = (b * b + c * c - a * a) / (2 * b * c);\nconst ang = Math.acos(Math.max(-1, Math.min(1, cosA)));\nconst Cx = Ax + b * Math.cos(ang), Cy = Ay + b * Math.sin(ang);\nv.path([[Ax, Ay], [Bx, By], [Cx, Cy]], { color: H.colors.accent, width: 3, close: true, fill: H.colors.accent });\nv.text(\"a\", (Bx + Cx) / 2 + 0.2, (By + Cy) / 2, { color: H.colors.sub, size: 13 });\nv.text(\"b\", (Ax + Cx) / 2 - 0.4, (Ay + Cy) / 2, { color: H.colors.sub, size: 13 });\nv.text(\"c\", (Ax + Bx) / 2, Ay - 0.4, { color: H.colors.sub, size: 13 });\nconst pulse = 0.5 + 0.5 * Math.sin(t * 1.2);\nconst Gx = (Ax + Bx + Cx) / 3, Gy = (Ay + By + Cy) / 3;\nv.circle(Gx, Gy, 6 + 4 * pulse, { stroke: H.colors.warn, width: 2 });\nv.dot(Ax, Ay, { r: 4, fill: H.colors.ink });\nv.dot(Bx, By, { r: 4, fill: H.colors.ink });\nv.dot(Cx, Cy, { r: 4, fill: H.colors.ink });\nH.text(\"Heron's formula: Area = sqrt(s(s-a)(s-b)(s-c))\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"s = (a+b+c)/2 = \" + s.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a=\" + a.toFixed(2) + \" b=\" + b.toFixed(2) + \" c=\" + c.toFixed(2) + \" Area = \" + area.toFixed(3), 24, 72, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "geo-regular-polygon-area", + "area": "Geometry", + "topic": "Regular polygon area = half apothem times perimeter", + "title": "Regular polygon area = (1/2) * apothem * perimeter", + "equation": "Area = (1/2) * a * P, P = n * s", + "keywords": [ + "regular polygon area", + "apothem", + "perimeter", + "polygon area formula", + "half apothem perimeter", + "n-gon", + "hexagon area", + "pentagon area", + "number of sides", + "area of polygon" + ], + "explanation": "A regular polygon (all sides and angles equal) has area equal to half its apothem times its perimeter: Area = (1/2) * a * P. The reason is that lines from the center to each vertex split the polygon into n identical triangles, each with base s (one side) and height a (the apothem, the perpendicular distance from center to a side); summing (1/2) * s * a over all n triangles gives (1/2) * a * (n*s) = (1/2) * a * P. The slider sets the number of sides n: as n grows the highlighted side shrinks, the apothem lengthens toward the radius, and the shape -- and the Area readout -- approach those of a circle.", + "bullets": [ + "Spokes from the center cut the polygon into n equal triangles.", + "Each triangle has base = one side s and height = apothem a.", + "Summing them gives Area = (1/2) * a * P with perimeter P = n*s.", + "As n rises the polygon approaches a circle of radius R." + ], + "params": [ + { + "name": "n", + "label": "sides n", + "min": 3, + "max": 16, + "step": 1, + "value": 6 + } + ], + "code": "H.background();\nconst cx = H.W / 2, cy = H.H / 2 + 20;\nconst R = 170;\nlet n = Math.round(P.n);\nif (n < 3) n = 3;\nif (n > 16) n = 16;\nconst pts = [];\nconst rot = -Math.PI / 2 + 0.15 * Math.sin(t * 0.5);\nfor (let i = 0; i < n; i++) {\n const ang = rot + i * 2 * Math.PI / n;\n pts.push([cx + R * Math.cos(ang), cy + R * Math.sin(ang)]);\n}\nH.path(pts, { color: H.colors.accent, width: 3, close: true, fill: H.colors.accent });\nconst apothem = R * Math.cos(Math.PI / n);\nconst side = 2 * R * Math.sin(Math.PI / n);\nconst perim = n * side;\nconst area = 0.5 * apothem * perim;\nconst k = Math.floor((t * 0.4) % n);\nconst p1 = pts[k], p2 = pts[(k + 1) % n];\nconst mx = (p1[0] + p2[0]) / 2, my = (p1[1] + p2[1]) / 2;\nH.line(p1[0], p1[1], p2[0], p2[1], { color: H.colors.warn, width: 4 });\nH.line(cx, cy, mx, my, { color: H.colors.accent2, width: 3, dash: [6, 4] });\nH.circle(cx, cy, 4, { fill: H.colors.ink });\nH.text(\"apothem a\", (cx + mx) / 2 + 6, (cy + my) / 2 - 6, { color: H.colors.accent2, size: 12 });\nH.text(\"side s\", mx + 6, my + 6, { color: H.colors.warn, size: 12 });\nH.text(\"Regular polygon area = (1/2) a P\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"n = \" + n + \" sides a = \" + apothem.toFixed(1) + \" P = n s = \" + perim.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Area = (1/2)(\" + apothem.toFixed(1) + \")(\" + perim.toFixed(1) + \") = \" + area.toFixed(0) + \" px^2\", 24, 72, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "geo-angle-bisector", + "area": "Geometry", + "topic": "Angle bisector", + "title": "Angle bisector: splits an angle into two equal halves", + "equation": "each half = (full angle) / 2", + "keywords": [ + "angle bisector", + "bisect an angle", + "equal halves", + "halve an angle", + "ray", + "vertex", + "bisector ray", + "angle splitting", + "degrees", + "half angle" + ], + "explanation": "The bisector of an angle is the ray from the vertex that divides the angle into two equal parts, so each smaller angle measures exactly half of the original. It holds by definition and by symmetry: reflecting one side of the angle across the bisector maps it onto the other side, which is only possible if the two halves are congruent. The slider sets the full opening angle; the dashed orange ray is the bisector, and the two growing arcs (drawn by a moving probe) sweep out the two halves, which the readout confirms are always equal -- each is the slider value divided by two.", + "bullets": [ + "The bisector starts at the vertex and lies inside the angle.", + "It creates two congruent angles, each half the original.", + "Reflecting across the bisector swaps the two rays (symmetry).", + "Change the angle -- each half stays exactly half the total." + ], + "params": [ + { + "name": "angle", + "label": "full angle (deg)", + "min": 10, + "max": 170, + "step": 5, + "value": 100 + } + ], + "code": "H.background();\nconst vx = 180, vy = H.H / 2 + 60;\nconst L = 380;\nlet deg = P.angle;\nif (deg < 10) deg = 10;\nif (deg > 170) deg = 170;\nconst ang = deg * Math.PI / 180;\nconst a0 = -0.1;\nconst a1 = a0 - ang;\nconst r1x = vx + L * Math.cos(a0), r1y = vy + L * Math.sin(a0);\nconst r2x = vx + L * Math.cos(a1), r2y = vy + L * Math.sin(a1);\nH.line(vx, vy, r1x, r1y, { color: H.colors.accent, width: 3 });\nH.line(vx, vy, r2x, r2y, { color: H.colors.accent, width: 3 });\nconst half = a0 - ang / 2;\nconst bx = vx + L * Math.cos(half), by = vy + L * Math.sin(half);\nH.line(vx, vy, bx, by, { color: H.colors.warn, width: 3, dash: [7, 5] });\nconst probe = 0.5 + 0.5 * Math.sin(t * 1.3);\nconst ar = 120;\nconst upper = [];\nconst lower = [];\nfor (let i = 0; i <= 24; i++) {\n const f = i / 24;\n upper.push([vx + ar * Math.cos(a0 - (ang / 2) * f * probe), vy + ar * Math.sin(a0 - (ang / 2) * f * probe)]);\n lower.push([vx + ar * Math.cos(half - (ang / 2) * f * probe), vy + ar * Math.sin(half - (ang / 2) * f * probe)]);\n}\nH.path(upper, { color: H.colors.accent2, width: 3 });\nH.path(lower, { color: H.colors.violet, width: 3 });\nH.circle(vx, vy, 4, { fill: H.colors.ink });\nH.text(\"ray\", r1x - 30, r1y - 8, { color: H.colors.sub, size: 12 });\nH.text(\"bisector\", bx - 50, by + 16, { color: H.colors.warn, size: 12 });\nH.text(\"Angle bisector splits an angle into two equal halves\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"full angle = \" + deg.toFixed(0) + \" deg\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"each half = \" + (deg / 2).toFixed(1) + \" deg (\" + (deg / 2).toFixed(1) + \" = \" + (deg / 2).toFixed(1) + \")\", 24, 72, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "geo-perpendicular-bisector", + "area": "Geometry", + "topic": "Perpendicular bisector & equidistance", + "title": "Perpendicular bisector: PA = PB for every point P on it", + "equation": "P on perp bisector of AB <=> PA = PB", + "keywords": [ + "perpendicular bisector", + "equidistant", + "equidistance", + "midpoint", + "perpendicular", + "right angle", + "locus", + "equal distance", + "segment bisector", + "PA = PB" + ], + "explanation": "The perpendicular bisector of segment AB is the line that passes through the midpoint M of AB and meets it at a right angle. Its defining property is equidistance: a point P lies on this line if and only if it is the same distance from A as from B (PA = PB). This holds because the perpendicular bisector is the line of symmetry of A and B -- reflecting across it swaps A and B, so any point on the line keeps the same distance to both. The slider and the gliding green point P move along the bisector, and the two dashed measuring lines PA and PB always report equal lengths.", + "bullets": [ + "The line passes through the midpoint M of AB at 90 degrees.", + "Any point P on it satisfies PA = PB exactly.", + "It is the set (locus) of all points equidistant from A and B.", + "Slide P along the line -- the two distances stay equal." + ], + "params": [ + { + "name": "s", + "label": "point along bisector s", + "min": -3, + "max": 3, + "step": 0.25, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -5, yMax: 5 });\nv.grid(); v.axes();\nconst Ax = -4, Ay = -1.5, Bx = 4, By = 2;\nconst Mx = (Ax + Bx) / 2, My = (Ay + By) / 2;\nv.line(Ax, Ay, Bx, By, { color: H.colors.sub, width: 2 });\nv.dot(Ax, Ay, { r: 6, fill: H.colors.accent });\nv.dot(Bx, By, { r: 6, fill: H.colors.accent });\nv.text(\"A\", Ax - 0.5, Ay - 0.4, { color: H.colors.accent, size: 13 });\nv.text(\"B\", Bx + 0.3, By + 0.4, { color: H.colors.accent, size: 13 });\nconst dx = Bx - Ax, dy = By - Ay;\nconst len = Math.hypot(dx, dy) || 1;\nconst px = -dy / len, py = dx / len;\nconst ext = 6;\nv.line(Mx - px * ext, My - py * ext, Mx + px * ext, My + py * ext, { color: H.colors.warn, width: 3 });\nv.dot(Mx, My, { r: 5, fill: H.colors.warn });\nlet s = P.s;\nif (s < -3) s = -3;\nif (s > 3) s = 3;\nconst tt = s + 2.5 * Math.sin(t * 0.8);\nconst Px = Mx + px * tt, Py = My + py * tt;\nv.line(Px, Py, Ax, Ay, { color: H.colors.accent2, width: 2, dash: [5, 4] });\nv.line(Px, Py, Bx, By, { color: H.colors.violet, width: 2, dash: [5, 4] });\nv.dot(Px, Py, { r: 6, fill: H.colors.good });\nconst dA = Math.hypot(Px - Ax, Py - Ay);\nconst dB = Math.hypot(Px - Bx, Py - By);\nH.text(\"Perpendicular bisector: every point is equidistant from A and B\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"midpoint M = (\" + Mx.toFixed(1) + \", \" + My.toFixed(1) + \"), line meets AB at 90 deg\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"PA = \" + dA.toFixed(2) + \" PB = \" + dB.toFixed(2) + \" (equal)\", 24, 72, { color: H.colors.good, size: 13 });" + }, + { + "id": "geo-circle-equation", + "area": "Geometry", + "topic": "Equation of a circle", + "title": "Equation of a circle: (x-h)^2 + (y-k)^2 = r^2", + "equation": "(x - h)^2 + (y - k)^2 = r^2", + "keywords": [ + "equation of a circle", + "circle equation", + "center radius form", + "standard form circle", + "(x-h)^2+(y-k)^2=r^2", + "center", + "radius", + "distance formula", + "graphing a circle", + "conic circle" + ], + "explanation": "A circle is the set of all points at a fixed distance r (the radius) from a fixed center (h, k); writing that distance condition with the distance formula and squaring both sides gives the standard equation (x-h)^2 + (y-k)^2 = r^2. The sliders h and k move the center horizontally and vertically, while r sets the radius, so changing them translates the circle and scales its size without altering its round shape. The orange dot sweeps around the circle and its connecting spoke shows the radius r staying constant -- exactly the distance the equation forces every point on the curve to keep from the center.", + "bullets": [ + "Every point on the circle is distance r from the center (h, k).", + "h shifts the center left/right, k shifts it up/down.", + "r sets the size; r^2 is the constant on the right side.", + "The sweeping point keeps its radius r fixed all the way around." + ], + "params": [ + { + "name": "h", + "label": "center x h", + "min": -6, + "max": 6, + "step": 0.5, + "value": 1 + }, + { + "name": "k", + "label": "center y k", + "min": -4, + "max": 4, + "step": 0.5, + "value": 0 + }, + { + "name": "r", + "label": "radius r", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -7, yMax: 7 });\nv.grid(); v.axes();\nconst h = P.h, k = P.k;\nlet r = P.r;\nif (r < 0.5) r = 0.5;\nconst pts = [];\nfor (let i = 0; i <= 64; i++) {\n const a = i / 64 * 2 * Math.PI;\n pts.push([h + r * Math.cos(a), k + r * Math.sin(a)]);\n}\nv.path(pts, { color: H.colors.accent, width: 3, close: true });\nv.dot(h, k, { r: 5, fill: H.colors.warn });\nv.text(\"(h, k)\", h + 0.3, k - 0.4, { color: H.colors.warn, size: 12 });\nconst ang = t * 0.9;\nconst Px = h + r * Math.cos(ang), Py = k + r * Math.sin(ang);\nv.line(h, k, Px, Py, { color: H.colors.accent2, width: 2 });\nv.dot(Px, Py, { r: 6, fill: H.colors.good });\nv.text(\"r\", h + 0.5 * r * Math.cos(ang) + 0.2, k + 0.5 * r * Math.sin(ang) - 0.2, { color: H.colors.accent2, size: 12 });\nH.text(\"Equation of a circle: (x-h)^2 + (y-k)^2 = r^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"center (h, k) = (\" + h.toFixed(1) + \", \" + k.toFixed(1) + \"), radius r = \" + r.toFixed(1), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"(x - \" + h.toFixed(1) + \")^2 + (y - \" + k.toFixed(1) + \")^2 = \" + (r * r).toFixed(2), 24, 72, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "geo-shoelace-area", + "area": "Geometry", + "topic": "Triangle area from coordinates (shoelace)", + "title": "Shoelace formula: area = |x_A(y_B-y_C)+x_B(y_C-y_A)+x_C(y_A-y_B)| / 2", + "equation": "area = |x_A(y_B - y_C) + x_B(y_C - y_A) + x_C(y_A - y_B)| / 2", + "keywords": [ + "shoelace formula", + "triangle area", + "area from coordinates", + "cross product area", + "signed area", + "vertices", + "determinant area", + "coordinate geometry", + "polygon area", + "surveyor's formula" + ], + "explanation": "The shoelace formula gets a triangle's area straight from its vertex coordinates, no base or height needed. It works because each term x_A(y_B - y_C) is twice the signed area of a sub-triangle the origin makes with an edge; summing them around the triangle cancels the outside pieces and leaves exactly twice the enclosed area, so you halve the total. The sign of the un-absolute-valued sum tells you the winding direction (positive = counterclockwise), which is why we take the absolute value for area. Dragging vertex C changes both the shape and the readout: the signed sum and the area update together, and the area collapses toward 0 as C lines up with edge AB (a degenerate, zero-area triangle).", + "bullets": [ + "Area comes directly from coordinates — no need to measure a base or height.", + "The signed sum's SIGN encodes orientation; |...| makes it a positive area.", + "When C lands on line AB the triangle is degenerate and area -> 0.", + "The yellow pulse marks the centroid, the average of the three vertices." + ], + "params": [ + { + "name": "cx", + "label": "C x-coord x_C", + "min": 0, + "max": 10, + "step": 0.5, + "value": 4 + }, + { + "name": "cy", + "label": "C y-coord y_C", + "min": 0, + "max": 8, + "step": 0.5, + "value": 7 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -1, yMax: 9 });\nv.grid(); v.axes();\nconst Ax = 1, Ay = 1;\nconst Bx = 9, By = 2;\nconst Cx = P.cx, Cy = P.cy;\nconst area = Math.abs(Ax*(By-Cy) + Bx*(Cy-Ay) + Cx*(Ay-By)) / 2;\nconst signed = (Ax*(By-Cy) + Bx*(Cy-Ay) + Cx*(Ay-By)) / 2;\nv.path([[Ax,Ay],[Bx,By],[Cx,Cy]], { color: H.colors.accent, width: 3, fill: \"rgba(124,196,255,0.18)\", close: true });\nv.dot(Ax, Ay, { r: 6, fill: H.colors.sub });\nv.dot(Bx, By, { r: 6, fill: H.colors.sub });\nv.dot(Cx, Cy, { r: 7, fill: H.colors.accent2 });\nv.text(\"A\", Ax - 0.4, Ay - 0.4, { color: H.colors.sub, size: 12 });\nv.text(\"B\", Bx + 0.2, By - 0.4, { color: H.colors.sub, size: 12 });\nv.text(\"C\", Cx + 0.2, Cy + 0.4, { color: H.colors.accent2, size: 12 });\nconst pulse = 0.5 + 0.5 * Math.sin(t * 1.5);\nconst gx = (Ax + Bx + Cx) / 3, gy = (Ay + By + Cy) / 3;\nv.circle(gx, gy, 4 + 6 * pulse, { stroke: H.colors.yellow, width: 1.5 });\nH.text(\"Shoelace: area = |x_A(y_B-y_C)+x_B(y_C-y_A)+x_C(y_A-y_B)| / 2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Drag vertex C; the signed sum halves to the area.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"signed sum/2 = \" + signed.toFixed(2) + \" area = \" + area.toFixed(2), 24, 76, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "geo-scale-factor-3d", + "area": "Geometry", + "topic": "Scale factor: length k, area k^2, volume k^3", + "title": "Scale factor: length x k, area x k^2, volume x k^3", + "equation": "length' = k*length, area' = k^2*area, volume' = k^3*volume", + "keywords": [ + "scale factor", + "similar solids", + "length area volume scaling", + "k squared", + "k cubed", + "enlargement", + "dilation", + "ratio of areas", + "ratio of volumes", + "cube scaling" + ], + "explanation": "When you enlarge a shape by a linear scale factor k, every length scales by k, but area scales by k^2 and volume by k^3 — because area is built from two length dimensions and volume from three. The rotating pair of cubes makes this visceral: doubling k (length x2) makes each face four times as big and the whole cube eight times as big in volume. This is why a model at half scale uses a quarter of the surface paint but only an eighth of the material. The readout updates the three multipliers live, so you can watch the volume multiplier shoot up far faster than the length one as you raise k.", + "bullets": [ + "One scale factor k controls all three — but with powers 1, 2, 3.", + "Area grows as k^2 (two length dimensions multiplied).", + "Volume grows as k^3 (three length dimensions multiplied).", + "Doubling k makes area x4 and volume x8 — growth accelerates." + ], + "params": [ + { + "name": "k", + "label": "scale factor k", + "min": 0.5, + "max": 2.5, + "step": 0.1, + "value": 1.5 + } + ], + "code": "H.background();\nconst cam = H.cam3d({ scale: 70, dist: 9, pitch: -0.5, cy: 300 });\ncam.yaw = 0.4 * t;\ncam.axes(4);\nconst k = P.k;\nfunction cubeEdges(s, ox) {\n const c = [\n [ox,0,0],[ox+s,0,0],[ox+s,s,0],[ox,s,0],\n [ox,0,s],[ox+s,0,s],[ox+s,s,s],[ox,s,s]\n ];\n return c;\n}\nfunction drawCube(s, ox, col, w) {\n const c = cubeEdges(s, ox);\n const E = [[0,1],[1,2],[2,3],[3,0],[4,5],[5,6],[6,7],[7,4],[0,4],[1,5],[2,6],[3,7]];\n for (let i = 0; i < E.length; i++) cam.line(c[E[i][0]], c[E[i][1]], { color: col, width: w });\n}\ndrawCube(1, -2.4, H.colors.sub, 1.5);\ndrawCube(k, 0.6, H.colors.accent, 2.5);\nconst L = k, A = k*k, Vv = k*k*k;\nH.text(\"Scale factor k: length x k, area x k^2, volume x k^3\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Left: unit cube. Right: scaled by k. Watch how fast volume grows.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"k = \" + k.toFixed(2) + \" length x\" + L.toFixed(2) + \" area x\" + A.toFixed(2) + \" volume x\" + Vv.toFixed(2), 24, 76, { color: H.colors.accent2, size: 13 });\nH.legend([{label:\"unit cube\", color:H.colors.sub},{label:\"scaled (k)\", color:H.colors.accent}], 24, 96);" + }, + { + "id": "geo-tangent-chord-angle", + "area": "Geometry", + "topic": "Tangent-chord angle = half intercepted arc", + "title": "Tangent-chord angle = half the intercepted arc", + "equation": "tangent-chord angle = (1/2) * intercepted arc", + "keywords": [ + "tangent chord angle", + "intercepted arc", + "circle theorems", + "tangent line", + "chord", + "inscribed angle", + "half the arc", + "tangent-secant angle", + "arc measure", + "circle geometry" + ], + "explanation": "The angle between a tangent line and a chord drawn from the point of tangency equals half the arc that the chord cuts off on that side. It holds because it is the limiting case of the inscribed-angle theorem: as the second endpoint of an inscribed angle slides toward the tangent point, that inscribed angle (already half its arc) becomes the tangent-chord angle, keeping the half-the-arc rule. Here T is the tangent point at the bottom of the circle and the dashed line is the tangent there; the yellow arc is the intercepted arc and the blue chord runs to its far end. As you grow the arc with the slider, both the highlighted arc and the readout's angle change, and the angle stays exactly half the arc's degree measure.", + "bullets": [ + "Measure the angle between the tangent (dashed) and the chord (blue) at T.", + "The yellow arc is the one the chord intercepts on the tangent's side.", + "Angle is always half that arc — the readout shows arc and half-arc.", + "It's the limiting case of the inscribed-angle theorem at the tangent point." + ], + "params": [ + { + "name": "arc", + "label": "intercepted arc (deg)", + "min": 20, + "max": 160, + "step": 5, + "value": 80 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -4, yMax: 4 });\nv.grid(); v.axes();\nconst R = 3;\nv.circle(0, 0, 0, {});\nconst cpts = [];\nfor (let i = 0; i <= 60; i++) { const a = i/60*Math.PI*2; cpts.push([R*Math.cos(a), R*Math.sin(a)]); }\nv.path(cpts, { color: H.colors.grid, width: 2 });\nconst Tx = 0, Ty = -R;\nconst arcDeg = P.arc;\nconst arc = arcDeg * Math.PI / 180;\nconst ang = -Math.PI/2 + arc;\nconst Ex = R * Math.cos(ang), Ey = R * Math.sin(ang);\nv.line(-4, Ty, 4, Ty, { color: H.colors.sub, width: 2, dash: [5,4] });\nv.line(Tx, Ty, Ex, Ey, { color: H.colors.accent, width: 3 });\nv.dot(Tx, Ty, { r: 6, fill: H.colors.accent2 });\nv.dot(Ex, Ey, { r: 6, fill: H.colors.accent });\nv.dot(0, 0, { r: 4, fill: H.colors.sub });\nconst apts = [];\nfor (let i = 0; i <= 30; i++) { const a = -Math.PI/2 + arc*i/30; apts.push([R*Math.cos(a), R*Math.sin(a)]); }\nv.path(apts, { color: H.colors.yellow, width: 4 });\nconst pulse = 0.5 + 0.5*Math.sin(t*1.6);\nv.dot(R*Math.cos(-Math.PI/2 + arc*pulse), R*Math.sin(-Math.PI/2 + arc*pulse), { r: 4, fill: H.colors.yellow });\nconst tcAngle = arcDeg / 2;\nH.text(\"Tangent-chord angle = half the intercepted arc\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Yellow = intercepted arc. The angle at T (tangent vs chord) is half of it.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"arc = \" + arcDeg.toFixed(0) + \" deg tangent-chord angle = \" + tcAngle.toFixed(1) + \" deg\", 24, 76, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "geo-semicircle-right-angle", + "area": "Geometry", + "topic": "Angle in a semicircle is 90", + "title": "Thales' theorem: angle in a semicircle is 90 deg", + "equation": "if AB is a diameter and P is on the circle, then angle APB = 90 deg", + "keywords": [ + "thales theorem", + "angle in a semicircle", + "right angle", + "diameter", + "inscribed angle", + "semicircle", + "circle theorems", + "90 degrees", + "inscribed in circle", + "right triangle in circle" + ], + "explanation": "Thales' theorem says that if you pick any point P on a circle and connect it to the two ends of a diameter, the angle at P is always exactly 90 degrees. It follows from the inscribed-angle theorem: an inscribed angle is half its intercepted arc, and the diameter cuts off a 180-degree semicircular arc, so the angle is half of 180, namely 90. As the slider slides P around the upper arc, the two colored legs of triangle APB swing wildly, yet the green right-angle marker and the live readout stay pinned at 90 degrees — the right angle is invariant. This is why a triangle inscribed in a circle with one side as the diameter is guaranteed to be right-angled.", + "bullets": [ + "AB is the diameter; P is any point on the circle.", + "Angle APB stays 90 deg no matter where P slides — the readout confirms it.", + "It's the inscribed-angle theorem: half of the 180 deg semicircle arc.", + "Hence any diameter forces an inscribed right triangle." + ], + "params": [ + { + "name": "pos", + "label": "position of P (deg)", + "min": 10, + "max": 170, + "step": 5, + "value": 60 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -4.5, yMax: 4.5 });\nv.grid(); v.axes();\nconst R = 3.5;\nconst Ax = -R, Ay = 0, Bx = R, By = 0;\nconst cpts = [];\nfor (let i = 0; i <= 64; i++) { const a = i/64*Math.PI*2; cpts.push([R*Math.cos(a), R*Math.sin(a)]); }\nv.path(cpts, { color: H.colors.grid, width: 2 });\nconst base = P.pos * Math.PI / 180;\nconst ang = base + 0.25 * Math.sin(t * 0.8);\nconst clamped = Math.max(0.08, Math.min(Math.PI - 0.08, ang));\nconst Px = R * Math.cos(clamped), Py = R * Math.abs(Math.sin(clamped));\nv.line(Ax, Ay, Bx, By, { color: H.colors.sub, width: 2 });\nv.line(Ax, Ay, Px, Py, { color: H.colors.accent, width: 3 });\nv.line(Bx, By, Px, Py, { color: H.colors.accent2, width: 3 });\nv.dot(Ax, Ay, { r: 6, fill: H.colors.sub });\nv.dot(Bx, By, { r: 6, fill: H.colors.sub });\nv.dot(0, 0, { r: 4, fill: H.colors.sub });\nv.dot(Px, Py, { r: 7, fill: H.colors.yellow });\nv.text(\"A\", Ax - 0.5, Ay - 0.3, { color: H.colors.sub, size: 12 });\nv.text(\"B\", Bx + 0.2, By - 0.3, { color: H.colors.sub, size: 12 });\nv.text(\"P\", Px + 0.2, Py + 0.3, { color: H.colors.yellow, size: 12 });\nconst ux = (Ax - Px), uy = (Ay - Py); const ul = Math.hypot(ux, uy) || 1;\nconst wx = (Bx - Px), wy = (By - Py); const wl = Math.hypot(wx, wy) || 1;\nconst s = 0.45;\nconst c1 = [Px + ux/ul*s, Py + uy/ul*s];\nconst c2 = [Px + wx/wl*s, Py + wy/wl*s];\nconst c3 = [c1[0] + wx/wl*s, c1[1] + wy/wl*s];\nv.path([c1, c3, c2], { color: H.colors.good, width: 2 });\nconst dot = ux*wx + uy*wy;\nconst angP = Math.acos(Math.max(-1, Math.min(1, dot/(ul*wl)))) * 180/Math.PI;\nH.text(\"Thales: angle in a semicircle is 90 deg\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"AB is the diameter. Wherever P sits on the arc, angle APB is a right angle.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"angle APB = \" + angP.toFixed(1) + \" deg (always 90)\", 24, 76, { color: H.colors.good, size: 13 });" + }, + { + "id": "calc-limit-intuition", + "area": "Calculus", + "topic": "Limit: approaching a value", + "title": "Limit: f(x) -> L as x -> a", + "equation": "lim (x -> a) f(x) = L", + "keywords": [ + "limit", + "approaching a value", + "limit of a function", + "limit intuition", + "removable discontinuity", + "hole in graph", + "x approaches a", + "limit notation", + "left and right approach", + "tends to", + "value the curve aims at" + ], + "explanation": "A limit answers a single question: as the input x slides toward a chosen point a, what y-value is the output f(x) heading for? Crucially this is about the value the curve AIMS at near a, not the value AT a — so the function can have a hole exactly at x = a and the limit still exists. Here f(x) = (x^2 - a^2)/(x - a), which equals x + a everywhere except at x = a, where it is 0/0 and undefined; the open circle marks that hole at height L = 2a. The green probe sweeps in from the left and the orange probe sweeps in from the right, and as the gap to a shrinks toward zero both outputs close in on the same number L, which is exactly what 'the limit equals L' means. To apply this, you check whether both sides funnel toward one common height; if they do, that shared height is the limit even when direct substitution gives 0/0, which is precisely why algebraic simplification (cancelling the (x - a) factor) recovers the answer.", + "bullets": [ + "The limit is the y-value the curve AIMS at near x = a, not the value at a.", + "The open circle is a hole: f is undefined at x = a, yet the limit still exists.", + "Green (left) and orange (right) probes both close in on the same L = 2a.", + "Simplifying (x^2-a^2)/(x-a) to x+a removes the 0/0 and reveals L." + ], + "params": [ + { + "name": "a", + "label": "approach point a", + "min": 0, + "max": 6, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -2, xMax: 8, yMin: -2, yMax: 8 });\nv.grid(); v.axes();\nconst a = P.a;\nconst L = 2 * a;\nconst f = x => Math.abs(x - a) < 1e-6 ? NaN : (x * x - a * a) / (x - a);\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.circle(a, L, 6, { fill: H.colors.bg, stroke: H.colors.warn, width: 2.5 });\nv.line(v.xMin, L, a, L, { color: H.colors.sub, width: 1, dash: [5, 5] });\nv.line(a, v.yMin, a, L, { color: H.colors.sub, width: 1, dash: [5, 5] });\nconst gap = 2.2 * (0.5 + 0.5 * Math.cos(t * 0.9));\nconst dl = Math.max(gap, 0.02);\nconst xL = a - dl, xR = a + dl;\nv.dot(xL, f(xL), { r: 6, fill: H.colors.good });\nv.dot(xR, f(xR), { r: 6, fill: H.colors.accent2 });\nv.dot(a, L, { r: 4, fill: H.colors.warn });\nH.text(\"Limit: f(x) -> L as x -> a\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Both probes head to L even though x = a is a hole\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = \" + a.toFixed(2) + \" L = \" + L.toFixed(2), 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"left x=\" + xL.toFixed(2) + \" -> \" + f(xL).toFixed(2), 24, 94, { color: H.colors.good, size: 13 });\nH.text(\"right x=\" + xR.toFixed(2) + \" -> \" + f(xR).toFixed(2), 24, 112, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "calc-one-sided-limits", + "area": "Calculus", + "topic": "One-sided limits", + "title": "One-sided limits: left-limit vs right-limit at a jump", + "equation": "lim (x -> a-) f(x) vs lim (x -> a+) f(x)", + "keywords": [ + "one-sided limits", + "left-hand limit", + "right-hand limit", + "jump discontinuity", + "piecewise function", + "limit from the left", + "limit from the right", + "limit does not exist", + "DNE", + "approaching from one side", + "step function" + ], + "explanation": "A one-sided limit restricts the approach to a single direction: the left-hand limit (written x -> a-) watches the outputs as x creeps up to a using only inputs below a, while the right-hand limit (x -> a+) uses only inputs above a. Here the function is piecewise — it follows g(x) = x for x below a (green) and g(x) = x + jump for x above a (orange) — so the green probe climbs to height a while the orange probe descends to height a + jump. The two-sided limit exists only when these two one-sided values agree; the closed dot shows the left side's destination and the open dot shows the right side's, and the vertical gap between them is the jump. When you set jump = 0 the open and closed dots merge, the two sides match, and the ordinary limit exists; with any nonzero jump the directions disagree, so lim x->a f(x) does not exist even though both one-sided limits are perfectly well defined. This is the standard test you apply at any jump or piecewise boundary: compute each side separately and compare.", + "bullets": [ + "Left-limit (green) uses only x < a; right-limit (orange) uses only x > a.", + "The two sides land on different heights — that vertical gap is the jump.", + "The two-sided limit exists ONLY when left-limit = right-limit.", + "Set jump = 0 and the sides agree: the dots merge and the limit exists." + ], + "params": [ + { + "name": "a", + "label": "approach point a", + "min": 0, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "jump", + "label": "jump size (R - L)", + "min": -3, + "max": 3, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -2, xMax: 8, yMin: -4, yMax: 8 });\nv.grid(); v.axes();\nconst a = P.a;\nconst jump = P.jump;\nconst Lm = a;\nconst Lp = a + jump;\nconst g = x => x < a ? x : x + jump;\nv.fn(x => x <= a ? x : NaN, { color: H.colors.good, width: 3 });\nv.fn(x => x > a ? x + jump : NaN, { color: H.colors.accent2, width: 3 });\nv.circle(a, Lm, 6, { fill: H.colors.good, stroke: H.colors.bg, width: 2 });\nv.circle(a, Lp, 6, { fill: H.colors.bg, stroke: H.colors.accent2, width: 2.5 });\nv.line(v.xMin, Lm, a, Lm, { color: H.colors.good, width: 1, dash: [5,5] });\nv.line(a, Lp, v.xMax, Lp, { color: H.colors.accent2, width: 1, dash: [5,5] });\nv.line(a, v.yMin, a, v.yMax, { color: H.colors.sub, width: 1, dash: [3,4] });\nconst gap = 2.4 * (0.5 + 0.5 * Math.cos(t * 0.9));\nconst dl = Math.max(gap, 0.03);\nconst xL = a - dl, xR = a + dl;\nv.dot(xL, g(xL), { r: 6, fill: H.colors.good });\nv.dot(xR, g(xR), { r: 6, fill: H.colors.accent2 });\nH.text(\"One-sided limits: left vs right\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"The two sides aim at different heights -> the limit does not exist\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = \" + a.toFixed(2), 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"left limit = \" + Lm.toFixed(2), 24, 94, { color: H.colors.good, size: 13 });\nH.text(\"right limit = \" + Lp.toFixed(2) + (Math.abs(jump) < 1e-6 ? \" (equal -> limit exists)\" : \" (differ)\"), 24, 112, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "calc-limit-laws", + "area": "Calculus", + "topic": "Limit laws (sum, product, quotient)", + "title": "Limit laws: lim(f op g) = (lim f) op (lim g)", + "equation": "lim(f+g)=Lf+Lg, lim(f*g)=Lf*Lg, lim(f/g)=Lf/Lg (Lg != 0)", + "keywords": [ + "limit laws", + "sum rule for limits", + "product rule for limits", + "quotient rule for limits", + "algebra of limits", + "limit of a sum", + "limit of a product", + "limit of a quotient", + "combining limits", + "limit properties", + "split the limit" + ], + "explanation": "The limit laws say that limits respect arithmetic: the limit of a sum is the sum of the limits, the limit of a product is the product of the limits, and the limit of a quotient is the quotient of the limits provided the denominator's limit is not zero. The intuition is that if f settles to a stable number Lf and g settles to Lg as x -> a, then any fixed combination of two settling quantities settles to that same combination of the settled values. On screen the green line f and violet line g are simple, well-behaved functions; the blue curve is their combination (sum, product, or quotient, chosen by the slider), and as the probe sweeps toward a you can read the green and violet outputs converging to Lf and Lg while the blue/orange output converges to Lf op Lg. The pink target dot marks (a, Lf op Lg), confirming that combining the functions first and then taking the limit gives the very same height as taking each limit first and then combining. To apply this you break a complicated limit into pieces, take the limit of each simple piece by inspection, and reassemble — the only caveat being the quotient rule, which fails when the denominator's limit is zero.", + "bullets": [ + "Limits distribute over +, x, and / — combine the pieces' limits.", + "Green f and violet g each settle to Lf and Lg as x -> a.", + "Blue combination converges to Lf op Lg (the pink target dot).", + "Quotient law needs lim g != 0, or the combined limit is undefined." + ], + "params": [ + { + "name": "a", + "label": "approach point a", + "min": 0, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "op", + "label": "law: 1=sum 2=product 3=quotient", + "min": 1, + "max": 3, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 7, yMin: -4, yMax: 10 });\nv.grid(); v.axes();\nconst a = P.a;\nconst op = P.op;\nconst f = x => 0.6 * x + 1;\nconst gg = x => -0.4 * x + 4;\nconst Lf = f(a), Lg = gg(a);\nlet combo, Lcombo, name, sym;\nif (op <= 1.34) { combo = x => f(x) + gg(x); Lcombo = Lf + Lg; name = \"sum\"; sym = \"+\"; }\nelse if (op <= 2.34) { combo = x => f(x) * gg(x); Lcombo = Lf * Lg; name = \"product\"; sym = \"x\"; }\nelse { combo = x => Math.abs(gg(x)) < 1e-6 ? NaN : f(x) / gg(x); Lcombo = Math.abs(Lg) < 1e-6 ? NaN : Lf / Lg; name = \"quotient\"; sym = \"/\"; }\nv.fn(f, { color: H.colors.good, width: 2 });\nv.fn(gg, { color: H.colors.violet, width: 2 });\nv.fn(combo, { color: H.colors.accent, width: 3 });\nv.line(a, v.yMin, a, v.yMax, { color: H.colors.sub, width: 1, dash: [3,4] });\nconst gap = 2.6 * (0.5 + 0.5 * Math.cos(t * 0.9));\nconst x = a - Math.max(gap, 0.03) * Math.sign(Math.cos(t * 0.45) || 1);\nv.dot(x, f(x), { r: 5, fill: H.colors.good });\nv.dot(x, gg(x), { r: 5, fill: H.colors.violet });\nv.dot(x, Number.isFinite(combo(x)) ? combo(x) : 0, { r: 6, fill: H.colors.accent2 });\nv.dot(a, Number.isFinite(Lcombo) ? Lcombo : 0, { r: 4, fill: H.colors.warn });\nH.text(\"Limit laws: lim(f \" + sym + \" g) = (lim f) \" + sym + \" (lim g)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Combine first or take limits first -> same answer (\" + name + \")\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = \" + a.toFixed(2) + \" lim f = \" + Lf.toFixed(2) + \" lim g = \" + Lg.toFixed(2), 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"lim(f \" + sym + \" g) = \" + (Number.isFinite(Lcombo) ? Lcombo.toFixed(2) : \"undefined (g->0)\"), 24, 94, { color: H.colors.accent, size: 13 });" + }, + { + "id": "calc-limit-substitution", + "area": "Calculus", + "topic": "Limits by direct substitution (continuous)", + "title": "Direct substitution: lim x->a f(x) = f(a) for continuous f", + "equation": "lim (x -> a) f(x) = f(a)", + "keywords": [ + "direct substitution", + "limits by substitution", + "continuous function", + "continuity", + "plug in a", + "lim equals f(a)", + "evaluating limits", + "no hole", + "polynomial limit", + "substitution method", + "continuous limit" + ], + "explanation": "For a continuous function, evaluating a limit is as easy as plugging the point in: lim x->a f(x) is just f(a). The reason is the definition of continuity itself — a function is continuous at a exactly when the value the curve aims at as x -> a equals the value the curve actually takes at a, so there is no hole, jump, or break to worry about. Here f(x) = 0.4x^2 - 0.5x + 1 is a polynomial, and polynomials are continuous everywhere, so the green probe approaching from the left and the orange probe approaching from the right both glide smoothly onto the solid pink dot at (a, f(a)) with nothing missing. The live readout shows that the left and right probe outputs and f(a) all converge to one shared value, confirming the limit. In practice this is the FIRST thing you try on any limit: substitute a, and if you get an ordinary finite number (no 0/0 or division by zero), that number IS the limit — you only reach for factoring, conjugates, or one-sided analysis when substitution produces an indeterminate or undefined form.", + "bullets": [ + "If f is continuous at a, the limit is simply f(a) — just substitute.", + "Polynomials are continuous everywhere, so the curve has no hole.", + "The dot is FILLED at (a, f(a)): the value equals the limit.", + "Try substitution first; only if you get 0/0 do you need more work." + ], + "params": [ + { + "name": "a", + "label": "approach point a", + "min": -4, + "max": 4, + "step": 0.5, + "value": 1.5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -3, yMax: 9 });\nv.grid(); v.axes();\nconst a = P.a;\nconst f = x => 0.4 * x * x - 0.5 * x + 1;\nconst fa = f(a);\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(a, v.yMin, a, fa, { color: H.colors.sub, width: 1, dash: [5,5] });\nv.line(v.xMin, fa, a, fa, { color: H.colors.sub, width: 1, dash: [5,5] });\nv.circle(a, fa, 7, { fill: H.colors.warn, stroke: H.colors.bg, width: 2 });\nconst gap = 3.0 * (0.5 + 0.5 * Math.cos(t * 0.9));\nconst dl = Math.max(gap, 0.03);\nconst xL = a - dl, xR = a + dl;\nv.dot(xL, f(xL), { r: 6, fill: H.colors.good });\nv.dot(xR, f(xR), { r: 6, fill: H.colors.accent2 });\nH.text(\"Direct substitution: lim x->a f(x) = f(a)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f is continuous, so the curve has no hole - just plug in a\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = \" + a.toFixed(2) + \" f(a) = \" + fa.toFixed(2), 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"left f(\" + xL.toFixed(2) + \") = \" + f(xL).toFixed(2), 24, 94, { color: H.colors.good, size: 13 });\nH.text(\"right f(\" + xR.toFixed(2) + \") = \" + f(xR).toFixed(2), 24, 112, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "calc-limit-factoring", + "area": "Calculus", + "topic": "0/0 limits by factoring", + "title": "0/0 limit by factoring: (x^2 - c^2)/(x - c) -> x + c", + "equation": "lim (x->c) (x^2 - c^2)/(x - c) = 2c", + "keywords": [ + "removable discontinuity", + "factoring limits", + "0/0 indeterminate", + "hole in graph", + "cancel common factor", + "difference of squares", + "limit by factoring", + "evaluate limit", + "indeterminate form", + "rational function limit" + ], + "explanation": "Plugging x = c straight into (x^2 - c^2)/(x - c) gives 0/0, which is indeterminate — it does not mean the limit fails, only that the formula is hiding a removable hole at x = c. The fix is algebraic: factor the numerator as a difference of squares, (x - c)(x + c), and cancel the shared (x - c), leaving the simple line y = x + c everywhere except the single missing point. Because the cancelled function is continuous, the limit is just its value there, x + c = 2c, even though the original is literally undefined at x = c. On screen the blue curve is the original quotient and the hollow ring marks the removable hole at (c, 2c); the orange probe slides along and a dashed line ties it to that hole, while the readout reports the probe's value approaching 2c as it nears x = c. Use this whenever direct substitution yields 0/0 and the numerator shares a factor with the denominator — factor, cancel, then substitute.", + "bullets": [ + "Direct substitution gives 0/0 — an indeterminate form, not a final answer.", + "Factor x^2 - c^2 = (x - c)(x + c), then cancel the (x - c) in the denominator.", + "The graph is the line y = x + c with one point punched out at x = c.", + "The hole's height 2c IS the limit; the probe's value approaches it from both sides." + ], + "params": [ + { + "name": "c", + "label": "hole location c", + "min": -3, + "max": 3, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -4, yMax: 10 });\nv.grid(); v.axes();\nconst c = P.c;\nconst f = x => (Math.abs(x - c) < 1e-6 ? NaN : (x * x - c * c) / (x - c));\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.circle(c, 2 * c, 7, { stroke: H.colors.warn, width: 2.5 });\nconst u = c + 2.5 * Math.sin(t * 0.7);\nconst fu = (Math.abs(u - c) < 1e-6 ? NaN : (u * u - c * c) / (u - c));\nif (isFinite(fu)) {\n v.dot(u, fu, { r: 6, fill: H.colors.accent2 });\n v.line(u, fu, c, 2 * c, { color: H.colors.sub, width: 1, dash: [4, 4] });\n}\nH.text(\"0/0 limit by factoring: (x^2 - c^2)/(x - c) -> x + c\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Hole at x = c is removable; cancel (x - c) to get x + c\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"limit as x -> \" + c.toFixed(1) + \" is \" + (2 * c).toFixed(2) + \" (probe f = \" + (isFinite(fu) ? fu.toFixed(2) : \"—\") + \")\", 24, 76, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "calc-limit-conjugate", + "area": "Calculus", + "topic": "Limits with the conjugate trick", + "title": "Conjugate trick: (sqrt(x+c) - sqrt(c))/x -> 1/(2*sqrt(c))", + "equation": "lim (x->0) (sqrt(x+c) - sqrt(c))/x = 1/(2*sqrt(c))", + "keywords": [ + "conjugate trick", + "rationalize the numerator", + "square root limit", + "0/0 indeterminate", + "limit of radical", + "multiply by conjugate", + "difference of square roots", + "removable hole", + "evaluate limit", + "irrational limit" + ], + "explanation": "Substituting x = 0 into (sqrt(x+c) - sqrt(c))/x gives 0/0, because both the radical difference on top and the x on the bottom vanish — direct substitution stalls. The conjugate trick rescues it: multiply top and bottom by the conjugate sqrt(x+c) + sqrt(c), which turns the numerator into a difference of squares (x+c) - c = x, and that x cancels the troublesome denominator. What remains, 1/(sqrt(x+c) + sqrt(c)), is continuous at x = 0, so the limit is simply 1/(2*sqrt(c)). On screen the blue curve is the original quotient with its hole at x = 0, the green dashed curve is the cancelled equivalent 1/(sqrt(x+c)+sqrt(c)) that fills that hole, the violet line marks the limit height, and the hollow ring sits exactly at (0, 1/(2*sqrt(c))) where the orange probe is heading. Reach for the conjugate whenever a 0/0 limit involves a difference (or sum) of square roots — rationalizing converts the radicals into something that cancels.", + "bullets": [ + "Substitution gives 0/0 because both the root difference and x go to 0.", + "Multiply by the conjugate sqrt(x+c)+sqrt(c) to clear the radical on top.", + "The numerator collapses to x, which cancels the denominator's x.", + "The leftover 1/(sqrt(x+c)+sqrt(c)) is continuous, so the limit is 1/(2*sqrt(c))." + ], + "params": [ + { + "name": "c", + "label": "shift c", + "min": 0.25, + "max": 6, + "step": 0.25, + "value": 4 + } + ], + "code": "H.background();\nconst c = Math.max(P.c, 0.25);\nconst L = 1 / (2 * Math.sqrt(c));\nconst yC = L;\nconst v = H.plot2d({ xMin: -2, xMax: 4, yMin: yC - 0.6, yMax: yC + 0.6 });\nv.grid(); v.axes();\nconst f = x => {\n if (x + c < 0) return NaN;\n if (Math.abs(x) < 1e-6) return NaN;\n return (Math.sqrt(x + c) - Math.sqrt(c)) / x;\n};\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.fn(x => 1 / (Math.sqrt(x + c) + Math.sqrt(c)), { color: H.colors.good, width: 1.5, dash: [5, 4] });\nv.circle(0, L, 7, { stroke: H.colors.warn, width: 2.5 });\nconst u = 1.6 * Math.sin(t * 0.7);\nconst fu = f(u);\nif (isFinite(fu)) v.dot(u, fu, { r: 6, fill: H.colors.accent2 });\nv.line(-2, L, 4, L, { color: H.colors.violet, width: 1, dash: [3, 5] });\nH.text(\"Conjugate trick: (sqrt(x+c) - sqrt(c)) / x\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Multiply by conjugate -> 1 / (sqrt(x+c) + sqrt(c)); hole fills at x=0\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"limit as x -> 0 = 1/(2*sqrt(c)) = \" + L.toFixed(3) + \" (probe = \" + (isFinite(fu) ? fu.toFixed(3) : \"—\") + \")\", 24, 76, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "calc-squeeze-theorem", + "area": "Calculus", + "topic": "Squeeze theorem", + "title": "Squeeze theorem: -x^2 <= x^2 sin(1/x) <= x^2 -> 0", + "equation": "lim (x->0) x^2 sin(1/x) = 0", + "keywords": [ + "squeeze theorem", + "sandwich theorem", + "pinching theorem", + "x^2 sin(1/x)", + "bounded oscillation", + "limit forced to zero", + "trapped function", + "upper and lower bound", + "oscillating limit", + "does not exist sin(1/x)" + ], + "explanation": "The function x^2 sin(1/x) oscillates infinitely fast as x approaches 0 because sin(1/x) never settles, so you cannot find the limit by watching the wiggle directly — yet the limit exists and equals 0. The squeeze (sandwich) theorem works by control from outside: since sin(1/x) always stays between -1 and 1, multiplying by the non-negative x^2 traps the whole function between -x^2 and x^2. As x -> 0 both of those parabola bounds are pulled to 0, and a function pinned between two things that meet at 0 has no room left — it is forced to 0 as well. On screen the two warm parabolas y = x^2 and y = -x^2 form the envelope, the blue curve is x^2 sin(1/x) rattling between them, the green dot marks the proven limit at the origin, and a violet segment shows the shrinking gap that imprisons the orange probe. Apply the squeeze whenever a factor is bounded (like sin, cos) and the rest goes to zero: bound it, send the bounds to the same limit, and the trapped function inherits it.", + "bullets": [ + "sin(1/x) oscillates wildly but is always between -1 and 1.", + "Multiplying by x^2 traps the function inside -x^2 <= f <= x^2.", + "Both bounding parabolas collapse to 0 at the origin.", + "Pinned between them, x^2 sin(1/x) is forced to the limit 0." + ], + "params": [ + { + "name": "A", + "label": "window half-width A", + "min": 0.4, + "max": 2, + "step": 0.2, + "value": 1 + } + ], + "code": "H.background();\nconst A = Math.max(P.A, 0.4);\nconst v = H.plot2d({ xMin: -A, xMax: A, yMin: -A * A * 1.2 - 0.02, yMax: A * A * 1.2 + 0.02 });\nv.grid(); v.axes();\nconst g = x => (Math.abs(x) < 1e-7 ? 0 : x * x * Math.sin(1 / x));\nv.fn(x => x * x, { color: H.colors.warn, width: 2 });\nv.fn(x => -x * x, { color: H.colors.warn, width: 2 });\nv.fn(g, { color: H.colors.accent, width: 2.5 });\nv.dot(0, 0, { r: 6, fill: H.colors.good });\nconst u = (A * 0.92) * Math.sin(t * 0.6);\nconst gu = g(u);\nv.dot(u, gu, { r: 6, fill: H.colors.accent2 });\nv.line(u, -u * u, u, u * u, { color: H.colors.violet, width: 1, dash: [4, 4] });\nH.legend([\n { label: \"y = x^2 (upper)\", color: H.colors.warn },\n { label: \"y = -x^2 (lower)\", color: H.colors.warn },\n { label: \"x^2 sin(1/x)\", color: H.colors.accent }\n], H.W - 200, 96);\nH.text(\"Squeeze theorem: -x^2 <= x^2 sin(1/x) <= x^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Both bounds -> 0 at x=0, so the trapped wiggle is forced to 0\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"at x = \" + u.toFixed(3) + \": f = \" + gu.toFixed(4) + \", bound x^2 = \" + (u * u).toFixed(4), 24, 76, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "calc-limits-at-infinity", + "area": "Calculus", + "topic": "Limits at infinity (horizontal asymptotes)", + "title": "Limits at infinity: y = (a x^2 + b x)/(x^2 + 1) -> a", + "equation": "lim (x->+/-inf) (a x^2 + b x)/(x^2 + 1) = a", + "keywords": [ + "limit at infinity", + "horizontal asymptote", + "end behavior", + "ratio of leading coefficients", + "rational function", + "degree of numerator denominator", + "y equals a asymptote", + "limits to infinity", + "leading term", + "long run behavior" + ], + "explanation": "A limit at infinity asks where a function levels off as x runs far out toward +inf or -inf, and for a rational function that long-run behavior is decided entirely by the highest-power terms — the lower-power and constant terms become negligible by comparison. Here both numerator a x^2 + b x and denominator x^2 + 1 are degree 2, so dividing top and bottom by x^2 turns the function into (a + b/x)/(1 + 1/x^2); as x grows the b/x and 1/x^2 pieces shrink to 0, leaving exactly a. That is the rule of thumb made rigorous: when numerator and denominator share the same degree, the horizontal asymptote is the ratio of the leading coefficients, a/1 = a. On screen the blue curve flattens toward the green dashed line y = a on both ends while the orange probe sweeps left and right to show the approach, and the readout tracks how the probe value closes in on a far from the origin. Use this to find horizontal asymptotes: compare degrees — equal degrees give the leading-coefficient ratio, a bigger denominator degree gives 0, and a bigger numerator degree gives no horizontal asymptote.", + "bullets": [ + "Far from 0, only the highest-power terms matter; the rest fade away.", + "Same degree top and bottom: asymptote = ratio of leading coefficients = a.", + "b shifts the curve in the middle but never changes the end behavior.", + "The probe sweeping outward shows y closing in on the green line y = a." + ], + "params": [ + { + "name": "a", + "label": "leading coeff a", + "min": -3, + "max": 3, + "step": 0.5, + "value": 2 + }, + { + "name": "b", + "label": "middle term b", + "min": -6, + "max": 6, + "step": 1, + "value": -4 + } + ], + "code": "H.background();\nconst a = P.a, b = P.b;\nconst v = H.plot2d({ xMin: -20, xMax: 20, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst f = x => {\n const den = x * x + 1;\n return (a * x * x + b * x) / den;\n};\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(-20, a, 20, a, { color: H.colors.good, width: 2, dash: [6, 5] });\nconst u = 14 * Math.sin(t * 0.5);\nconst fu = f(u);\nif (isFinite(fu)) {\n v.dot(u, Math.max(-6, Math.min(6, fu)), { r: 6, fill: H.colors.accent2 });\n}\nH.text(\"Limits at infinity: y = (a x^2 + b x)/(x^2 + 1)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Equal top/bottom degree -> asymptote = ratio of leading coeffs = a\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"horizontal asymptote y = \" + a.toFixed(2) + \" (probe at x=\" + u.toFixed(1) + \": y=\" + (isFinite(fu) ? fu.toFixed(3) : \"—\") + \")\", 24, 76, { color: H.colors.good, size: 13 });" + }, + { + "id": "calc-infinite-limits", + "area": "Calculus", + "topic": "Infinite limits (vertical asymptotes)", + "title": "Infinite limit: 1/(x - a)^2 -> +infinity", + "equation": "lim_{x->a} 1/(x - a)^2 = +infinity", + "keywords": [ + "infinite limit", + "vertical asymptote", + "limit does not exist", + "1/(x-a)^2", + "blows up", + "diverges", + "unbounded", + "denominator goes to zero", + "reciprocal squared", + "limit at asymptote" + ], + "explanation": "An infinite limit describes a function that grows without bound as the input nears a forbidden point, which is exactly what 1/(x - a)^2 does at x = a: intuitively, you are dividing 1 by a number that is shrinking toward 0, so the quotient explodes. The mechanism is the squared denominator (x - a)^2, which is always positive and approaches 0 from above whether x comes from the left or the right, so the function rises to +infinity on BOTH sides and the dashed vertical line x = a is a two-sided vertical asymptote. We say the limit 'is +infinity' as shorthand for unbounded growth, even though strictly the limit does not exist as a finite number. The orange probe slides toward the asymptote and the readout shows x - a shrinking while 1/(x-a)^2 races upward; the a slider moves the whole asymptote, letting you confirm the blow-up happens wherever the denominator hits zero. In practice you spot such asymptotes by finding inputs that zero a denominator (and don't cancel), then checking the sign of the denominator to decide the direction of divergence.", + "bullets": [ + "Near x = a the denominator (x-a)^2 -> 0, so 1/(x-a)^2 grows without bound.", + "Because the denominator is SQUARED it is always positive: divergence is +infinity on both sides.", + "The dashed line x = a is a vertical asymptote the curve never crosses.", + "Readout: as x-a shrinks the value 1/(x-a)^2 climbs toward infinity." + ], + "params": [ + { + "name": "a", + "label": "asymptote a", + "min": -4, + "max": 4, + "step": 0.5, + "value": 0 + } + ], + "code": "H.background();\nconst a = P.a;\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -1, yMax: 12 });\nv.grid(); v.axes();\nv.fn(x => { const d = (x - a) * (x - a); return d < 1e-4 ? NaN : 1 / d; }, { color: H.colors.accent, width: 3 });\nv.line(a, -1, a, 12, { color: H.colors.warn, width: 2, dash: [6, 5] });\nv.text(\"x = \" + a.toFixed(1), a, 11.4, { color: H.colors.warn, size: 12 });\nconst dx = 0.25 + 2.0 * (0.5 + 0.5 * Math.cos(t * 0.7));\nconst xp = a + dx;\nconst yp = 1 / (dx * dx);\nconst ypc = Math.min(yp, 12);\nv.dot(xp, ypc, { r: 6, fill: H.colors.accent2 });\nv.line(xp, 0, xp, ypc, { color: H.colors.sub, width: 1, dash: [3, 3] });\nH.text(\"Infinite limit: 1/(x - a)^2 -> +infinity\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"As x approaches a, the denominator -> 0 and the value blows up\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"x - a = \" + dx.toFixed(2) + \" 1/(x-a)^2 = \" + yp.toFixed(1), 24, 74, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "calc-continuity", + "area": "Calculus", + "topic": "Continuity & types of discontinuity", + "title": "Continuity & the three discontinuity types (hole, jump, infinite)", + "equation": "continuous at c iff lim_{x->c} f(x) = f(c)", + "keywords": [ + "continuity", + "discontinuity", + "removable discontinuity", + "hole in graph", + "jump discontinuity", + "infinite discontinuity", + "left and right limit", + "limit equals value", + "piecewise", + "asymptote discontinuity", + "continuous function" + ], + "explanation": "A function is continuous at a point c when you can draw through it without lifting the pen, captured formally by lim_{x->c} f(x) = f(c): the limit must exist AND equal the actual function value. This demo slides between the three ways that equation can fail. A REMOVABLE discontinuity (a hole) has a perfectly good two-sided limit but the point is missing or misplaced, as in (x^2-1)/(x-1), which equals x+1 everywhere except x=1 where it is 0/0; you could 'fill the hole' and restore continuity. A JUMP discontinuity has finite left and right limits that disagree, so no single limit exists and the graph leaps. An INFINITE discontinuity is a vertical asymptote where the function diverges, so the limit is not finite at all. To classify a discontinuity you compute the one-sided limits: equal and finite -> removable; finite but unequal -> jump; unbounded -> infinite. The violet probe sweeps across x = 1 each loop so you can watch the function value behave (or misbehave) at the trouble spot; switch the slider to compare all three side by side.", + "bullets": [ + "Continuous at c means lim f(x) as x->c exists AND equals f(c).", + "Removable: limit exists but the point is a hole -- it can be patched.", + "Jump: left and right limits are finite but unequal, so no limit exists.", + "Infinite: the function diverges to +/- infinity at a vertical asymptote." + ], + "params": [ + { + "name": "kind", + "label": "type (1 hole, 2 jump, 3 infinite)", + "min": 1, + "max": 3, + "step": 1, + "value": 1 + } + ], + "code": "H.background();\nconst sel = Math.round(P.kind);\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -2, yMax: 8 });\nv.grid(); v.axes();\nconst c = 1;\nlet title, caption, val;\nif (sel <= 1) {\n title = \"Removable discontinuity (a hole)\";\n caption = \"f(x) = (x^2 - 1)/(x - 1) = x + 1 except at x=1 -- a single point is missing\";\n v.fn(x => Math.abs(x - c) < 0.02 ? NaN : x + 1, { color: H.colors.accent, width: 3 });\n v.circle(c, c + 1, 7, { stroke: H.colors.warn, width: 2, fill: H.colors.bg });\n val = \"limit = \" + (c + 1).toFixed(0) + \", but f(1) is undefined\";\n} else if (sel === 2) {\n title = \"Jump discontinuity\";\n caption = \"Left and right limits exist but disagree -- the graph leaps at x=1\";\n v.fn(x => x < c ? x + 1 : NaN, { color: H.colors.accent, width: 3 });\n v.fn(x => x >= c ? x + 4 : NaN, { color: H.colors.accent2, width: 3 });\n v.dot(c, c + 1, { r: 6, fill: H.colors.bg });\n v.circle(c, c + 1, 6, { stroke: H.colors.accent, width: 2, fill: H.colors.bg });\n v.dot(c, c + 4, { r: 6, fill: H.colors.accent2 });\n val = \"left limit = 2, right limit = 5 (jump = 3)\";\n} else {\n title = \"Infinite discontinuity (asymptote)\";\n caption = \"The function diverges near x=1 -- no limit exists there\";\n v.fn(x => { const d = x - c; return Math.abs(d) < 0.05 ? NaN : Math.min(Math.max(1 / d + 3, -2), 8); }, { color: H.colors.accent, width: 3 });\n v.line(c, -2, c, 8, { color: H.colors.warn, width: 2, dash: [6, 5] });\n val = \"limit -> +/- infinity\";\n}\nconst px = c + 1.6 * Math.sin(t * 0.8);\nv.line(px, -2, px, 8, { color: H.colors.sub, width: 1, dash: [3, 3] });\nv.dot(px, -1.6, { r: 4, fill: H.colors.violet });\nH.text(title, 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(caption, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(val, 24, 74, { color: H.colors.accent2, size: 13 });\nH.text(\"probe x = \" + px.toFixed(2), 24, 94, { color: H.colors.violet, size: 12 });" + }, + { + "id": "calc-ivt", + "area": "Calculus", + "topic": "Intermediate value theorem", + "title": "Intermediate Value Theorem: every N between f(a) and f(b) is hit", + "equation": "f continuous on [a,b], N between f(a),f(b) => exists c in (a,b) with f(c)=N", + "keywords": [ + "intermediate value theorem", + "ivt", + "continuous function", + "exists c", + "f(c)=N", + "bisection", + "root finding", + "sign change", + "closed interval", + "guaranteed value", + "existence theorem" + ], + "explanation": "The Intermediate Value Theorem is an existence guarantee: if f is continuous on a closed interval [a,b], then it must take on EVERY value N between f(a) and f(b) somewhere inside the interval. The intuition is that a continuous curve cannot teleport, so to get from height f(a) to height f(b) it has to pass through all the heights in between at least once. The mechanism is continuity plus the closed interval -- there are no gaps or jumps for the curve to skip a value through, which is why the dashed target line y = N always meets the curve. In the demo the horizontal target N slides up and down between the two endpoint heights f(a) and f(b), and a bisection search (repeatedly halving the interval toward the sign of f(mid) - N) locates a green crossing point c where f(c) = N exactly. You apply IVT most often to prove a root exists: if f(a) and f(b) have opposite signs then N = 0 lies between them, so the theorem guarantees a zero in (a,b) -- the foundation of the bisection method shown here. Note it only promises existence, not uniqueness or a formula for c.", + "bullets": [ + "Continuous f on [a,b] hits every height N between f(a) and f(b).", + "A continuous curve can't skip values, so the target line y=N must cross it.", + "The green dot c is found by bisection -- halving toward where f equals N.", + "Special case N=0 with a sign change proves a root exists in (a,b)." + ], + "params": [ + { + "name": "speed", + "label": "target sweep speed (x rate)", + "min": 0.2, + "max": 1.5, + "step": 0.1, + "value": 0.6 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 5, yMin: -3, yMax: 7 });\nv.grid(); v.axes();\nconst a = 0, b = 4;\nconst f = x => 0.4 * Math.sin(1.1 * x) * x + 0.6 * x - 1.2;\nconst fa = f(a), fb = f(b);\nconst lo = Math.min(fa, fb), hi = Math.max(fa, fb);\nconst N = lo + (hi - lo) * (0.5 + 0.45 * Math.sin(t * P.speed));\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.dot(a, fa, { r: 6, fill: H.colors.sub });\nv.dot(b, fb, { r: 6, fill: H.colors.sub });\nv.text(\"f(a)=\" + fa.toFixed(2), a + 0.05, fa - 0.5, { color: H.colors.sub, size: 11 });\nv.text(\"f(b)=\" + fb.toFixed(2), b - 1.4, fb + 0.5, { color: H.colors.sub, size: 11 });\nv.line(a, N, b, N, { color: H.colors.warn, width: 2, dash: [6, 5] });\nv.text(\"N = \" + N.toFixed(2), 4.05, N, { color: H.colors.warn, size: 12 });\nlet c = NaN;\nlet loX = a, hiX = b;\nconst incr = fb > fa;\nfor (let i = 0; i < 40; i++) {\n const mid = 0.5 * (loX + hiX);\n const fm = f(mid);\n if ((incr && fm < N) || (!incr && fm > N)) loX = mid; else hiX = mid;\n c = mid;\n}\nv.line(c, -3, c, N, { color: H.colors.good, width: 1, dash: [3, 3] });\nv.dot(c, N, { r: 7, fill: H.colors.good });\nv.text(\"c = \" + c.toFixed(2), c + 0.1, N - 0.6, { color: H.colors.good, size: 12 });\nH.text(\"Intermediate Value Theorem\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f continuous on [a,b]: every N between f(a) and f(b) is hit at some c\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"target N = \" + N.toFixed(2) + \" -> found c = \" + c.toFixed(2) + \" with f(c)=N\", 24, 74, { color: H.colors.good, size: 13 });" + }, + { + "id": "calc-special-limit-sinx", + "area": "Calculus", + "topic": "Special limit sin(x)/x toward 1", + "title": "Special limit: sin(x)/x -> 1 as x -> 0", + "equation": "lim_{x->0} sin(x)/x = 1", + "keywords": [ + "sin x over x", + "sinx/x limit", + "special trig limit", + "limit equals 1", + "indeterminate 0/0", + "small angle approximation", + "squeeze theorem", + "removable hole", + "fundamental trig limit", + "derivative of sine" + ], + "explanation": "The limit of sin(x)/x as x approaches 0 is the fundamental trig limit that equals exactly 1, even though plugging in x = 0 gives the indeterminate form 0/0. Intuitively, for very small angles (in radians) the sine of an angle is nearly the angle itself, so their ratio squeezes toward 1; this is the small-angle approximation sin(x) ~ x made precise. The mechanism is the Squeeze Theorem: a geometric argument with the unit circle bounds cos(x) <= sin(x)/x <= 1, and since cos(x) -> 1 as x -> 0, the trapped ratio is forced to 1 as well. On screen the function is graphed as a smooth even curve with a single OPEN hole at x = 0 (it is genuinely undefined there) yet the limit, the dashed green line y = 1, is approached identically from both the left and the right. The orange probe oscillates toward 0 and the live readout shows sin(x)/x creeping toward 1.0000 as x shrinks. This limit is the engine behind the derivative of sine -- (d/dx) sin x = cos x relies on exactly this result -- and it is why radians, not degrees, are the natural unit in calculus.", + "bullets": [ + "sin(x)/x is undefined at 0 (form 0/0) yet its limit is exactly 1.", + "Both one-sided limits agree, so the two-sided limit exists and equals 1.", + "Squeeze theorem: cos(x) <= sin(x)/x <= 1, and cos(x) -> 1.", + "This limit underlies (d/dx) sin x = cos x and requires radians." + ], + "params": [ + { + "name": "reach", + "label": "probe reach (max |x|)", + "min": 0.5, + "max": 6, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -0.4, yMax: 1.4 });\nv.grid(); v.axes();\nconst f = x => Math.abs(x) < 1e-6 ? 1 : Math.sin(x) / x;\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(-8, 1, 8, 1, { color: H.colors.good, width: 1.5, dash: [5, 5] });\nv.text(\"y = 1\", 6.6, 1.12, { color: H.colors.good, size: 12 });\nv.circle(0, 1, 6, { stroke: H.colors.warn, width: 2, fill: H.colors.bg });\nconst amp = P.reach;\nconst x = amp * Math.sin(t * 0.9);\nconst y = f(x);\nv.line(x, 0, x, y, { color: H.colors.sub, width: 1, dash: [3, 3] });\nv.dot(x, y, { r: 6, fill: H.colors.accent2 });\nv.text(\"x\", x + 0.15, 0.08, { color: H.colors.sub, size: 11 });\nH.text(\"Special limit: sin(x)/x -> 1 as x -> 0\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Undefined at 0 (a hole), yet both sides squeeze toward 1\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"x = \" + x.toFixed(3) + \" sin(x)/x = \" + y.toFixed(4), 24, 74, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "calc-derivative-tangent", + "area": "Calculus", + "topic": "Derivative = slope of the tangent line", + "title": "Derivative = slope of the tangent line: f'(a) = slope at x = a", + "equation": "f'(a) = slope of the tangent to y = f(x) at x = a", + "keywords": [ + "derivative", + "tangent line", + "slope", + "secant line", + "instantaneous slope", + "f prime", + "rate of change", + "slope of curve", + "calculus", + "limit", + "tangent slope", + "derivative meaning" + ], + "explanation": "The derivative f'(a) is the slope of the line that just grazes the curve at the single point (a, f(a)) — it measures how steeply the function is climbing at that exact spot. You can't read a slope off one point alone, so we start with a secant line through two points, (a, f(a)) and a nearby (a+h, f(a+h)); the dashed secant has slope (f(a+h)-f(a))/h, an average rate over the gap. As the orange point slides back toward the warm point and h shrinks, that secant pivots and snaps onto the green tangent line, and its slope converges to the exact value f'(a). To apply it you pick the point a (the slider), watch the secant collapse, and read the live secant slope approaching f'(a): this is the geometric heart of every derivative — the value you'd get from the limit, here for f(x) = 0.35x^2 - 0.6x + 1.2 whose true slope is f'(a) = 0.7a - 0.6.", + "bullets": [ + "The warm dot sits at (a, f(a)); slide a to move where you measure the slope.", + "The dashed SECANT through two points has an average slope (f(a+h)-f(a))/h.", + "As h shrinks, the secant pivots onto the green TANGENT — the secant slope -> f'(a).", + "The readout shows secant slope closing in on the exact derivative f'(a) = 0.7a - 0.6." + ], + "params": [ + { + "name": "a", + "label": "point a", + "min": -0.5, + "max": 4.5, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 7, yMin: -1, yMax: 9 });\nv.grid(); v.axes();\nconst f = x => 0.35 * x * x - 0.6 * x + 1.2;\nconst fp = x => 0.7 * x - 0.6;\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst a = P.a;\nconst h = 2.2 * Math.abs(Math.sin(t * 0.7)) + 0.0001;\nconst fa = f(a), fah = f(a + h);\nconst msec = (fah - fa) / h;\nconst mtan = fp(a);\nv.line(a - 2, fa - 2 * msec, a + 4, fa + 4 * msec, { color: H.colors.sub, width: 2, dash: [6, 5] });\nv.line(a - 2, fa - 2 * mtan, a + 4, fa + 4 * mtan, { color: H.colors.good, width: 2 });\nv.dot(a, fa, { r: 7, fill: H.colors.warn });\nv.dot(a + h, fah, { r: 6, fill: H.colors.accent2 });\nH.text(\"Derivative = slope of the tangent line\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"secant (dashed) collapses to the tangent (green) as h -> 0\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = \" + a.toFixed(2) + \" f'(a) = \" + mtan.toFixed(2), 24, 76, { color: H.colors.accent, size: 13 });\nH.text(\"secant slope = \" + msec.toFixed(2) + \" (h = \" + h.toFixed(2) + \")\", 24, 96, { color: H.colors.sub, size: 13 });" + }, + { + "id": "calc-derivative-limit-def", + "area": "Calculus", + "topic": "Limit definition of the derivative", + "title": "Limit definition of the derivative: f'(a) = lim_{h->0} (f(a+h)-f(a))/h", + "equation": "f'(a) = lim_{h->0} ( f(a+h) - f(a) ) / h", + "keywords": [ + "limit definition of derivative", + "difference quotient", + "f(a+h)-f(a)/h", + "first principles", + "derivative from limit", + "rise over run", + "secant slope", + "limit", + "h approaches zero", + "calculus", + "instantaneous rate", + "derivative definition" + ], + "explanation": "The limit definition turns the intuitive 'slope at a point' into something you can actually compute: f'(a) = lim_{h->0} (f(a+h)-f(a))/h. The fraction (f(a+h)-f(a))/h is the difference quotient — literally rise over run between (a, f(a)) and (a+h, f(a+h)), the average rate of change across a step of width h, shown here by the dashed run and rise legs. A single point gives 0/0, which is undefined, so we never set h exactly to zero; instead we let h shrink and track the limiting value the quotient approaches. In the animation h sweeps down toward 0 and the violet secant flattens onto the true tangent direction, while the readout shows the difference quotient converging to the exact derivative f'(a) = 0.8a - 0.5. To apply it you compute (f(a+h)-f(a))/h symbolically, simplify to cancel the h in the denominator, then take h->0 — that algebraic limit is the mechanism behind every differentiation rule.", + "bullets": [ + "The dashed legs show the run (h) and the rise f(a+h)-f(a) of the difference quotient.", + "Plugging h = 0 directly gives 0/0 — undefined — so we take a LIMIT instead.", + "As h -> 0 the violet secant flattens to the tangent; its slope settles on f'(a).", + "The readout shows (f(a+h)-f(a))/h converging to the exact f'(a) = 0.8a - 0.5." + ], + "params": [ + { + "name": "a", + "label": "point a", + "min": 0, + "max": 4, + "step": 0.1, + "value": 1.5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 6, yMin: -1, yMax: 8 });\nv.grid(); v.axes();\nconst f = x => 0.4 * x * x - 0.5 * x + 1.5;\nconst fp = x => 0.8 * x - 0.5;\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst a = P.a;\nconst h = 2.0 * (0.5 + 0.5 * Math.cos(t * 0.6)) + 0.02;\nconst fa = f(a), fah = f(a + h);\nconst slope = (fah - fa) / h;\nconst exact = fp(a);\nv.line(a - 1.5, fa - 1.5 * slope, a + 4, fa + 4 * slope, { color: H.colors.violet, width: 2 });\nv.line(a, fa, a + h, fa, { color: H.colors.sub, width: 1.5, dash: [4, 4] });\nv.line(a + h, fa, a + h, fah, { color: H.colors.sub, width: 1.5, dash: [4, 4] });\nv.dot(a, fa, { r: 7, fill: H.colors.warn });\nv.dot(a + h, fah, { r: 6, fill: H.colors.accent2 });\nv.text(\"h\", a + h * 0.5, fa - 0.45, { color: H.colors.sub, size: 12 });\nH.text(\"Limit definition of the derivative\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f'(a) = lim_{h->0} ( f(a+h) - f(a) ) / h\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"(f(a+h) - f(a))/h = \" + slope.toFixed(3) + \" -> f'(a) = \" + exact.toFixed(3), 24, 76, { color: H.colors.violet, size: 13 });\nH.text(\"h = \" + h.toFixed(3), 24, 96, { color: H.colors.sub, size: 13 });" + }, + { + "id": "calc-rate-of-change", + "area": "Calculus", + "topic": "Derivative as instantaneous rate of change", + "title": "Derivative as instantaneous rate of change: v = ds/dt", + "equation": "velocity v(t) = ds/dt = slope of the position graph", + "keywords": [ + "instantaneous rate of change", + "velocity", + "ds/dt", + "position graph", + "derivative as rate", + "speed", + "slope of position", + "motion", + "calculus", + "instantaneous velocity", + "rate of change", + "tangent slope velocity" + ], + "explanation": "A derivative is the instantaneous rate of change of one quantity with respect to another, and motion is the cleanest example: if s(t) is position, then its derivative ds/dt is velocity. Average velocity over an interval is just total displacement divided by elapsed time — the slope of a line joining two points on the position graph — but the instantaneous velocity at one instant is the slope of the tangent at that single point, the limit of those averages as the interval shrinks to zero. In the animation the warm dot is a moving 'now' that slides along the curved position graph, and the green tangent line riding with it has a slope equal to the live velocity readout: where the curve is steep the object is moving fast, where it flattens the velocity drops toward zero. To apply this you read or compute the slope of the position-vs-time curve at the instant you care about; the same idea generalizes to any rate — current as dQ/dt, growth as dP/dt — wherever you ask 'how fast is this changing right now?'", + "bullets": [ + "The accent curve is position s vs time; its STEEPNESS at any instant is the speed.", + "The green tangent's slope equals the instantaneous velocity at that moment.", + "Steep curve = fast motion; a flat spot means the velocity is near zero.", + "Average velocity is a secant slope; instantaneous velocity is the tangent slope (the limit)." + ], + "params": [ + { + "name": "A", + "label": "motion scale A", + "min": 1, + "max": 4, + "step": 0.25, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 6, yMin: -1, yMax: 11 });\nv.grid(); v.axes();\nconst A = P.A;\nconst s = x => A * (x - 0.5 * Math.sin(2 * x)) * 0.5 + 0.5;\nconst sp = x => A * (1 - Math.cos(2 * x)) * 0.5;\nv.fn(s, { color: H.colors.accent, width: 3 });\nconst a = 3 + 2.6 * Math.sin(t * 0.5);\nconst pos = s(a), vel = sp(a);\nv.line(a - 1.4, pos - 1.4 * vel, a + 1.4, pos + 1.4 * vel, { color: H.colors.good, width: 2.5 });\nv.dot(a, pos, { r: 7, fill: H.colors.warn });\nH.text(\"Derivative as instantaneous rate of change\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"position s(time); tangent slope = velocity v = ds/dt\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"time = \" + a.toFixed(2) + \" position = \" + pos.toFixed(2), 24, 76, { color: H.colors.sub, size: 13 });\nH.text(\"instantaneous velocity = \" + vel.toFixed(2), 24, 96, { color: H.colors.good, size: 13 });" + }, + { + "id": "calc-power-rule", + "area": "Calculus", + "topic": "Power rule: d/dx x^n = n x^(n-1)", + "title": "Power rule: d/dx x^n = n x^(n-1)", + "equation": "d/dx x^n = n * x^(n-1)", + "keywords": [ + "power rule", + "derivative of x^n", + "n x^(n-1)", + "differentiation rule", + "polynomial derivative", + "exponent rule", + "derivative power", + "calculus", + "slope of x^n", + "differentiate", + "monomial derivative", + "derivative shortcut" + ], + "explanation": "The power rule is the workhorse shortcut for differentiating powers: the derivative of x^n is n*x^(n-1) — bring the exponent down as a multiplier, then drop the exponent by one. It comes straight from the limit definition: expanding (x+h)^n with the binomial theorem, every term keeps at least one factor of h except n*x^(n-1)*h, so after dividing by h and letting h->0 only n*x^(n-1) survives. The animation plots f(x) = x^n as the solid accent curve and its derivative f'(x) = n*x^(n-1) as the dashed violet curve, while a green tangent rides a probe that sweeps across x; you can see the tangent's slope at any x exactly matches the height of the dashed derivative curve there. Increase n with the slider and watch the derivative gain a power and steepen, or set n = 1 and the derivative flattens to the constant 1 (slope of a line). To apply it you differentiate each power term separately — x^5 -> 5x^4, x^(1/2) -> (1/2)x^(-1/2) — which makes the power rule the backbone of differentiating any polynomial.", + "bullets": [ + "Solid accent curve is f(x) = x^n; dashed violet curve is its derivative n*x^(n-1).", + "The green tangent's slope at the probe equals the violet curve's height there.", + "Bring the exponent down as a factor, then subtract one from the exponent.", + "Raise n and the derivative gains a power and steepens; at n = 1 it flattens to the constant 1." + ], + "params": [ + { + "name": "n", + "label": "exponent n", + "min": 0, + "max": 5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -2.4, xMax: 2.4, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst n = P.n;\nconst f = x => Math.pow(x, n);\nconst fp = x => n * Math.pow(x, n - 1);\nconst safe = g => (x => { const y = g(x); return (typeof y === \"number\" && isFinite(y)) ? y : NaN; });\nv.fn(safe(f), { color: H.colors.accent, width: 3 });\nv.fn(safe(fp), { color: H.colors.violet, width: 2.5, dash: [7, 5] });\nconst x0 = 1.9 * Math.sin(t * 0.6);\nconst y0 = f(x0), m = fp(x0);\nif (isFinite(y0)) {\n v.line(x0 - 1, y0 - 1 * m, x0 + 1, y0 + 1 * m, { color: H.colors.good, width: 2 });\n v.dot(x0, y0, { r: 6, fill: H.colors.warn });\n}\nif (isFinite(m)) v.dot(x0, m, { r: 6, fill: H.colors.violet });\nH.legend([{ label: \"f(x) = x^n\", color: H.colors.accent }, { label: \"f'(x) = n x^(n-1)\", color: H.colors.violet }], 24, 116);\nH.text(\"Power rule: d/dx x^n = n x^(n-1)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"solid = f, dashed = its derivative; green = tangent at the probe\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"n = \" + n.toFixed(2) + \" x = \" + x0.toFixed(2), 24, 76, { color: H.colors.accent, size: 13 });\nH.text(\"f'(x) = \" + (isFinite(m) ? m.toFixed(2) : \"n/a\"), 24, 96, { color: H.colors.violet, size: 13 });" + }, + { + "id": "calc-sum-constant-rule", + "area": "Calculus", + "topic": "Constant-multiple & sum rules", + "title": "Sum & constant-multiple rules: d/dx[a*x^2 + b*x] = 2a*x + b", + "equation": "d/dx[a*f(x) + b*g(x)] = a*f'(x) + b*g'(x)", + "keywords": [ + "sum rule", + "constant multiple rule", + "derivative of a sum", + "linearity of the derivative", + "term by term differentiation", + "coefficient", + "polynomial derivative", + "power rule", + "a*x^2 + b*x", + "differentiation rules", + "slope of a sum" + ], + "explanation": "The sum and constant-multiple rules say the derivative is LINEAR: you can differentiate a combination one term at a time and pull each coefficient out front, so d/dx[a*x^2 + b*x] is just a*(2x) + b*(1) = 2a*x + b. Intuitively this works because the slope of a sum of curves at a point is the sum of the individual slopes — stacking two motions adds their rates of change — and scaling a function by a constant scales its rate of change by that same constant. In the animation the blue curve is f(x) = a*x^2 + b*x and the dashed orange line is its derivative f'(x) = 2a*x + b, a straight line whose height at each x equals the slope of the blue curve directly above it. The green tangent rides along f as the probe point sweeps left and right; watch its steepness match the orange line's value exactly. To apply the rule, break any polynomial or linear combination into terms, differentiate each with the power rule, multiply by its coefficient, and add — the sliders a and b let you confirm that changing one coefficient only rescales that term's contribution to the slope.", + "bullets": [ + "The derivative distributes over the sum: each term is handled separately.", + "Constants a and b factor straight out — they just rescale each term's slope.", + "The dashed orange line f'=2a*x+b gives the slope of the blue curve at every x.", + "The green tangent's steepness always equals the orange line's height at the probe." + ], + "params": [ + { + "name": "a", + "label": "coefficient a (of x^2)", + "min": -2, + "max": 2, + "step": 0.25, + "value": 1 + }, + { + "name": "b", + "label": "coefficient b (of x)", + "min": -4, + "max": 4, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -4, xMax: 4, yMin: -8, yMax: 16 });\nv.grid(); v.axes();\nconst a = P.a, b = P.b;\n// f(x) = a*x^2 + b*x -> f'(x) = 2a*x + b\nconst f = x => a * x * x + b * x;\nconst df = x => 2 * a * x + b;\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.fn(df, { color: H.colors.accent2, width: 2.5, dash: [6, 5] });\nconst x0 = 2.6 * Math.sin(t * 0.6);\nconst slope = df(x0);\nconst y0 = f(x0);\n// tangent segment on f\nconst dx = 1.3;\nv.line(x0 - dx, y0 - slope * dx, x0 + dx, y0 + slope * dx, { color: H.colors.good, width: 2.5 });\nv.dot(x0, y0, { r: 7, fill: H.colors.accent });\nv.dot(x0, slope, { r: 6, fill: H.colors.accent2 });\nH.text(\"Sum & constant-multiple rules: d/dx[a*x^2 + b*x] = 2a*x + b\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"the derivative of a sum is the sum of derivatives, each scaled by its coefficient\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"x = \" + x0.toFixed(2) + \" f'(x) = 2(\" + a.toFixed(1) + \")x + \" + b.toFixed(1) + \" = \" + slope.toFixed(2), 24, 76, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"f(x) = a*x^2 + b*x\", color: H.colors.accent }, { label: \"f'(x) = 2a*x + b\", color: H.colors.accent2 }, { label: \"tangent (slope = f'(x))\", color: H.colors.good }], 24, 96);" + }, + { + "id": "calc-product-rule", + "area": "Calculus", + "topic": "Product rule", + "title": "Product rule: (u*v)' = u'v + u*v'", + "equation": "(u*v)' = u'*v + u*v'", + "keywords": [ + "product rule", + "derivative of a product", + "u prime v plus u v prime", + "differentiating products", + "uv rule", + "x sin x derivative", + "two factors", + "leibniz product rule", + "product of functions", + "differentiation rules" + ], + "explanation": "The product rule handles the derivative of a product of two functions: (u*v)' = u'v + u*v', NOT u'*v' — that common guess is wrong because both factors change at once. The intuition is to think of u and v as the side lengths of a rectangle whose area is u*v; nudging x changes both sides, and the area grows by one strip from u changing (that's u'*v) plus another strip from v changing (that's u*v'), with the tiny corner being negligible. In the animation u = x is the dashed violet line and v = sin(kx) is the dashed yellow wave; their product f = x*sin(kx) is the solid blue curve and its derivative f' = sin(kx) + x*k*cos(kx) is the solid orange curve. The green tangent rides along f as the probe sweeps, and the readout splits f' into its two pieces u'v and u*v' so you can see them add up. To apply it, label your two factors u and v, differentiate each separately, then form u'v + u*v'; the slider k stretches the wave's frequency, which scales the v' term and visibly steepens the orange derivative.", + "bullets": [ + "(u*v)' is u'v + u*v', never the product of the two derivatives.", + "Each term keeps one factor un-differentiated — they take turns changing.", + "Blue f = x*sin(kx) is the product; orange is its derivative f'.", + "The readout shows u'v and u*v' separately so you watch them sum to f'." + ], + "params": [ + { + "name": "k", + "label": "frequency k of v = sin(kx)", + "min": 0.5, + "max": 3, + "step": 0.25, + "value": 1.5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -3.2, xMax: 3.2, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst k = P.k; // frequency of v(x) = sin(k x)\n// u(x) = x, v(x) = sin(k x)\n// f = u*v = x*sin(k x)\n// f' = u'v + u v' = 1*sin(k x) + x*k*cos(k x)\nconst uF = x => x;\nconst vF = x => Math.sin(k * x);\nconst f = x => uF(x) * vF(x);\nconst df = x => vF(x) + uF(x) * k * Math.cos(k * x);\nv.fn(uF, { color: H.colors.violet, width: 1.8, dash: [4, 5] });\nv.fn(vF, { color: H.colors.yellow, width: 1.8, dash: [4, 5] });\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.fn(df, { color: H.colors.accent2, width: 2.5 });\nconst x0 = 2.8 * Math.sin(t * 0.5);\nconst y0 = f(x0);\nconst m = df(x0);\nconst dx = 0.9;\nv.line(x0 - dx, y0 - m * dx, x0 + dx, y0 + m * dx, { color: H.colors.good, width: 2.5 });\nv.dot(x0, y0, { r: 7, fill: H.colors.accent });\nH.text(\"Product rule: (u*v)' = u'v + u v'\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"here u = x and v = sin(kx), so f = x*sin(kx) and f' = sin(kx) + x*k*cos(kx)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"x = \" + x0.toFixed(2) + \" u'v = \" + vF(x0).toFixed(2) + \" u v' = \" + (uF(x0) * k * Math.cos(k * x0)).toFixed(2) + \" f' = \" + m.toFixed(2), 24, 76, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"u = x\", color: H.colors.violet }, { label: \"v = sin(kx)\", color: H.colors.yellow }, { label: \"f = u*v\", color: H.colors.accent }, { label: \"f' = u'v+u v'\", color: H.colors.accent2 }], 24, 96);" + }, + { + "id": "calc-quotient-rule", + "area": "Calculus", + "topic": "Quotient rule", + "title": "Quotient rule: (u/v)' = (u'v - u*v') / v^2", + "equation": "(u/v)' = (u'*v - u*v') / v^2", + "keywords": [ + "quotient rule", + "derivative of a quotient", + "u over v derivative", + "low d high minus high d low", + "fraction rule", + "differentiating ratios", + "x/(x^2+c) derivative", + "division rule", + "denominator squared", + "differentiation rules" + ], + "explanation": "The quotient rule gives the derivative of a ratio: (u/v)' = (u'v - u*v') / v^2, where the numerator subtracts (unlike the product rule's plus) and everything is divided by the denominator squared. The order and the minus sign matter — a handy mnemonic is 'low d-high minus high d-low, over low-squared' — and the subtraction appears because increasing the denominator v actually shrinks the fraction, so v's rate of change enters with a negative sign. In the animation u = x and v = x^2 + c, so f = x/(x^2 + c) is the blue curve and its derivative simplifies to f' = (c - x^2)/(x^2 + c)^2, drawn dashed in orange; the green tangent rides along f as the probe sweeps and the readout shows f(x) and f'(x) updating live. The c slider shifts the denominator, and when c is negative the denominator can hit zero, producing vertical asymptotes — there the function and its slope are undefined, which is exactly why the rule requires v ≠ 0. To apply it, identify the top u and bottom v, differentiate each, plug into (u'v - u*v')/v^2, and simplify; the v^2 in the bottom is what makes quotient derivatives die off far from the origin, as the flattening orange curve shows.", + "bullets": [ + "Numerator SUBTRACTS: u'v - u*v', and the order cannot be swapped.", + "Everything sits over v^2 — the denominator squared.", + "Blue f = x/(x^2+c); dashed orange is f' = (c - x^2)/(x^2+c)^2.", + "Negative c creates asymptotes where v = 0 and the derivative is undefined." + ], + "params": [ + { + "name": "c", + "label": "denominator shift c (in x^2 + c)", + "min": -4, + "max": 4, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst c = P.c; // shift in denominator v(x) = x^2 + c\n// u(x) = x, v(x) = x^2 + c\n// f = u/v = x/(x^2 + c)\n// f' = (u'v - u v')/v^2 = ((x^2+c) - x*2x)/(x^2+c)^2 = (c - x^2)/(x^2+c)^2\nconst denom = x => x * x + c;\nconst safe = x => {\n const d = denom(x);\n return Math.abs(d) < 1e-4 ? NaN : d;\n};\nconst f = x => x / safe(x);\nconst df = x => {\n const d = safe(x);\n return isFinite(d) ? (c - x * x) / (d * d) : NaN;\n};\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.fn(df, { color: H.colors.accent2, width: 2.5, dash: [6, 5] });\nconst x0 = 4 * Math.sin(t * 0.5);\nconst d0 = safe(x0);\nlet y0 = NaN, m = NaN;\nif (isFinite(d0)) { y0 = x0 / d0; m = (c - x0 * x0) / (d0 * d0); }\nif (isFinite(y0) && isFinite(m)) {\n const dx = 1.1;\n v.line(x0 - dx, y0 - m * dx, x0 + dx, y0 + m * dx, { color: H.colors.good, width: 2.5 });\n v.dot(x0, y0, { r: 7, fill: H.colors.accent });\n}\nH.text(\"Quotient rule: (u/v)' = (u'v - u v') / v^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"here u = x, v = x^2 + c, so f = x/(x^2+c) and f' = (c - x^2)/(x^2+c)^2\", 24, 52, { color: H.colors.sub, size: 13 });\nconst mTxt = isFinite(m) ? m.toFixed(3) : \"undef\";\nconst yTxt = isFinite(y0) ? y0.toFixed(2) : \"undef\";\nH.text(\"x = \" + x0.toFixed(2) + \" f(x) = \" + yTxt + \" f'(x) = \" + mTxt, 24, 76, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"f = u/v\", color: H.colors.accent }, { label: \"f' (quotient rule)\", color: H.colors.accent2 }, { label: \"tangent\", color: H.colors.good }], 24, 96);" + }, + { + "id": "calc-chain-rule", + "area": "Calculus", + "topic": "Chain rule", + "title": "Chain rule: d/dx F(g(x)) = F'(g(x)) * g'(x)", + "equation": "d/dx F(g(x)) = F'(g(x)) * g'(x)", + "keywords": [ + "chain rule", + "composite function derivative", + "outer times inner derivative", + "f of g of x", + "derivative of sin(kx)", + "nested functions", + "inner function", + "outer function", + "substitution u = g(x)", + "differentiation rules" + ], + "explanation": "The chain rule differentiates a COMPOSITION F(g(x)) — a function inside another function — by multiplying the outer derivative (evaluated at the inner function) by the inner derivative: F'(g(x)) * g'(x). The intuition is rates multiplying through a pipeline: if the inner stage g changes x at rate g', and the outer stage F responds to its input at rate F', then the overall response chains those two rates together, much like gears where a fast inner gear multiplies the outer gear's effect. In the animation the outer is F(u) = sin(u) and the inner is g(x) = k*x, so f(x) = sin(kx) is the blue curve and its derivative f' = cos(kx)*k is the dashed orange curve; the green tangent rides along f as the probe sweeps, and the readout shows g(x), the outer slope F'(g) = cos(kx), the inner slope g' = k, and their product f'. The k slider controls the inner function's rate: larger k both squeezes the wave horizontally AND amplifies the derivative, because g' = k enters as a multiplying factor — that amplification is the whole point of the rule. To apply it, peel the function into outer and inner layers, differentiate the outer leaving the inner untouched, then multiply by the inner's derivative; for deeper nests you repeat the peeling outward to inward.", + "bullets": [ + "Differentiate the OUTER function but keep the inner argument intact.", + "Then MULTIPLY by the inner function's derivative g'(x).", + "Blue f = sin(kx); dashed orange is f' = k*cos(kx).", + "Bigger k squeezes the wave and scales the derivative by g' = k." + ], + "params": [ + { + "name": "k", + "label": "inner slope k (in g(x)=k*x)", + "min": 0.5, + "max": 4, + "step": 0.25, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -3.2, xMax: 3.2, yMin: -1.6, yMax: 1.6 });\nv.grid(); v.axes();\nconst k = P.k; // inner-function parameter: g(x) = k*x\n// outer F(u) = sin(u), inner g(x) = k*x\n// f(x) = sin(k*x)\n// f'(x) = F'(g) * g'(x) = cos(k*x) * k\nconst inner = x => k * x;\nconst f = x => Math.sin(inner(x));\nconst df = x => Math.cos(inner(x)) * k;\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.fn(df, { color: H.colors.accent2, width: 2.5, dash: [6, 5] });\nconst x0 = 2.8 * Math.sin(t * 0.5);\nconst u0 = inner(x0);\nconst y0 = f(x0);\nconst m = df(x0);\nconst dx = 0.7;\nv.line(x0 - dx, y0 - m * dx, x0 + dx, y0 + m * dx, { color: H.colors.good, width: 2.5 });\nv.dot(x0, y0, { r: 7, fill: H.colors.accent });\nH.text(\"Chain rule: d/dx F(g(x)) = F'(g(x)) * g'(x)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"outer F(u)=sin(u), inner g(x)=k*x, so f=sin(kx) and f'=cos(kx)*k\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"x = \" + x0.toFixed(2) + \" g(x) = \" + u0.toFixed(2) + \" F'(g) = \" + Math.cos(u0).toFixed(2) + \" g' = \" + k.toFixed(1) + \" f' = \" + m.toFixed(2), 24, 76, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"f = sin(kx)\", color: H.colors.accent }, { label: \"f' = k*cos(kx)\", color: H.colors.accent2 }, { label: \"tangent (slope = f')\", color: H.colors.good }], 24, 96);" + }, + { + "id": "calc-deriv-sin-cos", + "area": "Calculus", + "topic": "Derivatives of sin and cos", + "title": "Derivative of sine: d/dx sin x = cos x", + "equation": "d/dx sin(x) = cos(x)", + "keywords": [ + "derivative of sine", + "derivative of cosine", + "d/dx sin x", + "cos x", + "trig derivatives", + "sin cos derivative", + "slope of sine", + "rate of change of sin", + "calculus trigonometry", + "tangent to sine curve" + ], + "explanation": "The derivative of a function measures its instantaneous slope, and for sine that slope is exactly cosine: wherever sin x is climbing fastest (at x = 0) cos x is at its peak of 1, and wherever sin x flattens at its crest cos x crosses zero. This works because sin and cos are a quarter-period apart, so differentiating shifts the wave left by 90 degrees and that shifted sine IS cosine. In the animation the green tangent line rides the blue sine curve and its tilt is read off and plotted as the orange dot on the cosine curve, so you literally watch slope-of-sin become height-of-cos. The phase slider shifts both waves together, demonstrating that the slope rule holds no matter where the wave starts. Use this when differentiating trig expressions, and remember the companion fact d/dx cos x = -sin x, which is the same picture shifted one more quarter-turn.", + "bullets": [ + "The tangent's slope on the blue sine curve equals the height of the orange cosine curve.", + "sin peaks where cos = 0 (flat slope); sin crosses zero where cos = +/-1 (steepest).", + "Differentiating shifts a sine wave a quarter period to the left.", + "The phase slider moves both curves so you can confirm the slope rule everywhere." + ], + "params": [ + { + "name": "phase", + "label": "phase shift phi", + "min": -3.14, + "max": 3.14, + "step": 0.1, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6.5, xMax: 6.5, yMin: -2.2, yMax: 2.2 });\nv.grid(); v.axes();\nconst ph = P.phase;\nv.fn(x => Math.sin(x + ph), { color: H.colors.accent, width: 3 });\nv.fn(x => Math.cos(x + ph), { color: H.colors.accent2, width: 2.5, dash: [6, 5] });\nconst x0 = 4 * Math.sin(t * 0.5);\nconst y0 = Math.sin(x0 + ph);\nconst m = Math.cos(x0 + ph);\nv.line(x0 - 1.4, y0 - 1.4 * m, x0 + 1.4, y0 + 1.4 * m, { color: H.colors.good, width: 2 });\nv.dot(x0, y0, { r: 6, fill: H.colors.accent });\nv.dot(x0, m, { r: 6, fill: H.colors.accent2 });\nH.text(\"d/dx sin x = cos x\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"the slope of sin equals the height of cos\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"slope at x=\" + x0.toFixed(2) + \" is \" + m.toFixed(2), 24, 76, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"sin\", color: H.colors.accent }, { label: \"cos = derivative\", color: H.colors.accent2 }, { label: \"tangent\", color: H.colors.good }], 24, 96);" + }, + { + "id": "calc-deriv-exp", + "area": "Calculus", + "topic": "Derivative of e^x equals itself", + "title": "Derivative of e^x: d/dx e^x = e^x", + "equation": "d/dx e^x = e^x", + "keywords": [ + "derivative of e^x", + "exponential derivative", + "e to the x", + "self derivative", + "d/dx exp", + "slope equals height", + "natural exponential", + "euler number e", + "exponential growth rate", + "tangent to exponential" + ], + "explanation": "The natural exponential e^x is the unique function (up to a constant multiple) whose derivative equals itself, meaning at every point the slope of the curve is numerically equal to its height above the axis. Intuitively, e^x models growth whose rate is always proportional to the current amount, so the taller the curve the steeper it climbs, which is exactly what makes exponential growth accelerate. The number e is defined precisely so that this proportionality constant is 1 rather than some other factor, which is why no extra multiplier appears. In the animation the violet drop-line marks the height e^x at the moving point while the green tangent line shows the slope there, and the readout confirms the two numbers stay identical as the point sweeps left and right. Apply this whenever you differentiate exponentials: d/dx e^x = e^x directly, and by the chain rule d/dx e^(kx) = k*e^(kx); the scale slider shows that any constant multiple a*e^x also reproduces itself under differentiation.", + "bullets": [ + "At every x the tangent's slope (green) equals the curve's height (violet drop-line).", + "e is chosen so the proportionality constant is exactly 1 - no extra factor.", + "Exponential growth accelerates because a taller curve is automatically steeper.", + "Scaling by a constant a keeps the self-derivative property: d/dx (a*e^x) = a*e^x." + ], + "params": [ + { + "name": "scale", + "label": "scale a", + "min": 0.2, + "max": 2, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -3, xMax: 3, yMin: -1, yMax: 12 });\nv.grid(); v.axes();\nconst k = P.scale;\nv.fn(x => k * Math.exp(x), { color: H.colors.accent, width: 3 });\nconst x0 = 1.7 * Math.sin(t * 0.5);\nconst y0 = k * Math.exp(x0);\nconst m = y0;\nv.line(x0 - 1.2, y0 - 1.2 * m, x0 + 1.2, y0 + 1.2 * m, { color: H.colors.good, width: 2 });\nv.line(x0, 0, x0, y0, { color: H.colors.violet, width: 2, dash: [5, 4] });\nv.dot(x0, y0, { r: 6, fill: H.colors.accent });\nv.text(\"height = \" + y0.toFixed(2), x0 + 0.12, y0 * 0.5, { color: H.colors.violet, size: 12 });\nH.text(\"d/dx e^x = e^x\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"at every point the tangent's slope equals the curve's height\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"x=\" + x0.toFixed(2) + \": height=\" + y0.toFixed(2) + \", slope=\" + m.toFixed(2), 24, 76, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"e^x\", color: H.colors.accent }, { label: \"tangent (slope=height)\", color: H.colors.good }], 24, 96);" + }, + { + "id": "calc-deriv-ln", + "area": "Calculus", + "topic": "Derivative of ln x = 1/x", + "title": "Derivative of natural log: d/dx ln x = 1/x", + "equation": "d/dx ln(x) = 1/x", + "keywords": [ + "derivative of ln x", + "derivative of natural log", + "1/x", + "d/dx ln", + "logarithm derivative", + "slope of ln", + "natural logarithm", + "reciprocal derivative", + "tangent to log curve", + "log calculus" + ], + "explanation": "The natural logarithm ln x grows without bound but ever more slowly, and its derivative captures that slowdown exactly: the slope at x is 1/x, so it is steep near x = 0 and flattens toward zero as x increases. This follows because ln x is the inverse of e^x, and the inverse-function rule turns the exponential's self-derivative into the reciprocal 1/x; equivalently, ln is defined as the area under 1/x from 1 to x, so by the Fundamental Theorem of Calculus its derivative is the integrand 1/x. In the animation the green tangent line rides the blue ln curve as the point sweeps right, and you can watch the tangent visibly flatten while the readout shows 1/x shrinking. Because the domain of ln is x > 0, the curve and the point stay on the positive side and the slope 1/x is always positive. Apply this whenever you differentiate logarithms or use logarithmic differentiation, and pair it with the chain-rule form d/dx ln(g(x)) = g'(x)/g(x).", + "bullets": [ + "The tangent's slope at x is exactly 1/x - steep near 0, flat for large x.", + "ln x is the inverse of e^x, so its slope is the reciprocal of the height.", + "ln is the area under 1/x, so by the FTC its derivative is the integrand 1/x.", + "The domain is x > 0, so the slope 1/x is always positive." + ], + "params": [ + { + "name": "x0", + "label": "point x", + "min": 0.5, + "max": 7, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -0.5, xMax: 8, yMin: -3, yMax: 4 });\nv.grid(); v.axes();\nv.fn(x => (x > 0 ? Math.log(x) : NaN), { color: H.colors.accent, width: 3 });\nconst xc = Math.max(0.2, Math.min(7.6, P.x0 + 1.2 * Math.sin(t * 0.6)));\nconst y0 = Math.log(xc);\nconst m = 1 / xc;\nv.line(xc - 2, y0 - 2 * m, xc + 2, y0 + 2 * m, { color: H.colors.good, width: 2 });\nv.dot(xc, y0, { r: 6, fill: H.colors.accent });\nH.text(\"d/dx ln x = 1/x\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"the tangent gets flatter as x grows, since 1/x shrinks\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"x=\" + xc.toFixed(2) + \": slope = 1/x = \" + m.toFixed(3), 24, 76, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"ln x\", color: H.colors.accent }, { label: \"tangent, slope 1/x\", color: H.colors.good }], 24, 96);" + }, + { + "id": "calc-implicit-diff", + "area": "Calculus", + "topic": "Implicit differentiation", + "title": "Implicit differentiation: x^2 + y^2 = r^2 gives dy/dx = -x/y", + "equation": "x^2 + y^2 = r^2 => dy/dx = -x/y", + "keywords": [ + "implicit differentiation", + "dy/dx", + "circle derivative", + "x^2+y^2=r^2", + "-x/y", + "differentiate implicitly", + "tangent to circle", + "related rates", + "chain rule y", + "slope on a circle" + ], + "explanation": "Implicit differentiation lets you find a slope dy/dx even when y is not solved for explicitly, by differentiating both sides of an equation and treating y as a function of x. For the circle x^2 + y^2 = r^2, differentiating gives 2x + 2y*(dy/dx) = 0, and solving yields dy/dx = -x/y; the key step is that d/dx of y^2 is 2y*(dy/dx) by the chain rule, since y depends on x. Geometrically this slope is always perpendicular to the radius, which the animation makes visible: the violet line is the radius from the center to the moving point, and the green tangent meets it at a right angle as the point travels around the circle. The readout shows the point's coordinates and the computed slope -x/y, and you can see the tangent go vertical at the top and bottom where y = 0 makes the formula blow up (the code guards that case). The radius slider rescales the circle while the same -x/y rule holds, and this technique generalizes to any implicit curve and underlies related-rates problems.", + "bullets": [ + "Differentiate both sides: 2x + 2y*(dy/dx) = 0, so dy/dx = -x/y.", + "d/dx(y^2) = 2y*(dy/dx) by the chain rule because y depends on x.", + "The green tangent is always perpendicular to the violet radius.", + "At y = 0 (left/right of the circle) the tangent is vertical and -x/y is undefined." + ], + "params": [ + { + "name": "r", + "label": "radius r", + "min": 1, + "max": 3.5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -4, yMax: 4 });\nv.grid(); v.axes();\nconst r = P.r;\nv.fn(x => (r * r - x * x >= 0 ? Math.sqrt(r * r - x * x) : NaN), { color: H.colors.accent, width: 2.5 });\nv.fn(x => (r * r - x * x >= 0 ? -Math.sqrt(r * r - x * x) : NaN), { color: H.colors.accent, width: 2.5 });\nconst a = t * 0.5;\nconst px = r * Math.cos(a);\nconst py = r * Math.sin(a);\nconst m = Math.abs(py) < 1e-4 ? null : -px / py;\nv.line(0, 0, px, py, { color: H.colors.violet, width: 1.6, dash: [5, 4] });\nif (m !== null) {\n v.line(px - 1.8, py - 1.8 * m, px + 1.8, py + 1.8 * m, { color: H.colors.good, width: 2 });\n}\nv.dot(px, py, { r: 6, fill: H.colors.accent2 });\nH.text(\"x^2 + y^2 = r^2 -> dy/dx = -x/y\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"differentiate both sides; the tangent is perpendicular to the radius\", 24, 52, { color: H.colors.sub, size: 13 });\nconst slopeTxt = m === null ? \"vertical (y=0)\" : m.toFixed(2);\nH.text(\"point (\" + px.toFixed(2) + \", \" + py.toFixed(2) + \"): dy/dx = \" + slopeTxt, 24, 76, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"circle\", color: H.colors.accent }, { label: \"radius\", color: H.colors.violet }, { label: \"tangent -x/y\", color: H.colors.good }], 24, 96);" + }, + { + "id": "calc-higher-order", + "area": "Calculus", + "topic": "Higher-order derivatives (f, f prime, f double prime)", + "title": "Higher-order derivatives: f, f', f''", + "equation": "f''(x) = d/dx [ f'(x) ]", + "keywords": [ + "higher order derivatives", + "second derivative", + "f prime", + "f double prime", + "concavity", + "rate of change of slope", + "derivative of the derivative", + "f''", + "acceleration", + "inflection point" + ], + "explanation": "A derivative measures the slope of a curve, and a higher-order derivative just repeats that operation: f' is the slope of f, and f'' is the slope of f' — the rate at which the slope itself is changing. Intuitively, if f is position, f' is velocity and f'' is acceleration; mechanically, you differentiate, then differentiate the result again. In the animation a vertical probe sweeps across all three graphs at once: the accent curve is f, the orange curve is f', and the dashed violet curve is f''. Watch how wherever f peaks (slope zero), the f' curve crosses zero, and wherever f bends, f'' is large — this is how f'' encodes concavity. You apply this when finding inflection points (f'' = 0) or classifying maxima and minima with the second-derivative test: f'' < 0 means concave-down (a max), f'' > 0 means concave-up (a min).", + "bullets": [ + "The orange f' curve crosses zero exactly where f has a peak or valley.", + "f'' (dashed violet) measures concavity: positive = cupped up, negative = cupped down.", + "The sweeping vertical line ties all three values together at one x.", + "Live readouts show f, f', and f'' at the same point each frame." + ], + "params": [ + { + "name": "a", + "label": "linear tilt a", + "min": -1.5, + "max": 1.5, + "step": 0.25, + "value": 0.4 + }, + { + "name": "b", + "label": "frequency b", + "min": 0.5, + "max": 2.5, + "step": 0.25, + "value": 1.5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -3.2, xMax: 3.2, yMin: -4, yMax: 4 });\nv.grid(); v.axes();\nconst a = P.a, b = P.b;\nfunction f(x){ return Math.sin(b * x) + a * x; }\nfunction f1(x){ return b * Math.cos(b * x) + a; }\nfunction f2(x){ return -b * b * Math.sin(b * x); }\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.fn(f1, { color: H.colors.accent2, width: 2 });\nv.fn(f2, { color: H.colors.violet, width: 2, dash: [5,4] });\nconst x = 2.6 * Math.sin(t * 0.6);\nv.dot(x, f(x), { r: 6, fill: H.colors.accent });\nv.dot(x, f1(x), { r: 5, fill: H.colors.accent2 });\nv.dot(x, f2(x), { r: 5, fill: H.colors.violet });\nv.line(x, -4, x, 4, { color: H.colors.grid, width: 1, dash: [3,3] });\nH.text(\"Higher-order derivatives: f, f', f''\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f' = slope of f; f'' = slope of f' (concavity)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"x = \" + x.toFixed(2) + \" f = \" + f(x).toFixed(2) + \" f' = \" + f1(x).toFixed(2) + \" f'' = \" + f2(x).toFixed(2), 24, 74, { color: H.colors.sub, size: 13 });\nH.legend([{label:\"f\",color:H.colors.accent},{label:\"f'\",color:H.colors.accent2},{label:\"f''\",color:H.colors.violet}], 24, 92);" + }, + { + "id": "calc-deriv-inverse", + "area": "Calculus", + "topic": "Derivative of an inverse function (arctan)", + "title": "Derivative of an inverse: d/dx arctan(x) = 1/(1+x^2)", + "equation": "d/dx arctan(x) = 1 / (1 + x^2)", + "keywords": [ + "derivative of inverse function", + "arctan derivative", + "inverse trig derivative", + "1/(1+x^2)", + "d/dx arctan", + "inverse function rule", + "reciprocal of slope", + "tan inverse", + "arctangent", + "inverse tangent" + ], + "explanation": "An inverse function reflects the original across the line y = x, and that reflection swaps the roles of run and rise — so the slope of the inverse is the reciprocal of the original's slope. For y = arctan(x), the inverse of tan, this rule gives the clean closed form d/dx arctan(x) = 1/(1 + x^2): the derivative is always positive (arctan is increasing) and shrinks toward zero as |x| grows, which is why arctan flattens out toward its horizontal asymptotes y = ±pi/2 (drawn as the dashed guides). In the animation the accent curve is arctan(kx), the orange curve below it is its derivative 1/(1+...), and the short warm segment is the actual tangent line riding along arctan as the probe sweeps left and right. Notice the tangent is steepest near x = 0 (where the derivative peaks at k) and nearly flat far out, exactly matching the orange derivative curve. You apply this whenever you differentiate inverse-trig expressions or use the inverse-function rule (f^-1)'(y) = 1/f'(x) to get a slope without ever solving for the inverse explicitly.", + "bullets": [ + "The derivative 1/(1+x^2) is always positive, so arctan never decreases.", + "Slope is largest at x = 0 and decays toward 0 — the curve flattens to ±pi/2.", + "The warm tangent segment's steepness tracks the orange derivative curve.", + "Inverse-function rule: the inverse's slope is the reciprocal of the original's." + ], + "params": [ + { + "name": "k", + "label": "input scale k", + "min": 0.5, + "max": 3, + "step": 0.25, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -2.2, yMax: 2.2 });\nv.grid(); v.axes();\nconst k = (P.k && P.k !== 0) ? P.k : 1;\nfunction f(x){ return Math.atan(k * x); }\nfunction fp(x){ return k / (1 + k * k * x * x); }\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.fn(fp, { color: H.colors.accent2, width: 2 });\nv.line(-6, Math.PI/2, 6, Math.PI/2, { color: H.colors.grid, width: 1, dash: [4,4] });\nv.line(-6, -Math.PI/2, 6, -Math.PI/2, { color: H.colors.grid, width: 1, dash: [4,4] });\nv.text(\"y = pi/2\", 3.6, Math.PI/2 + 0.18, { color: H.colors.sub, size: 11 });\nconst x = 5 * Math.sin(t * 0.5);\nconst m = fp(x);\nv.dot(x, f(x), { r: 6, fill: H.colors.accent });\nv.dot(x, m, { r: 5, fill: H.colors.accent2 });\nconst dx = 1.4;\nv.line(x - dx, f(x) - m * dx, x + dx, f(x) + m * dx, { color: H.colors.warn, width: 2 });\nH.text(\"d/dx arctan(x) = 1/(1+x^2)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Slope of the inverse from the original's slope\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"x = \" + x.toFixed(2) + \" arctan = \" + f(x).toFixed(2) + \" slope = \" + m.toFixed(3), 24, 74, { color: H.colors.sub, size: 13 });\nH.legend([{label:\"arctan(kx)\",color:H.colors.accent},{label:\"derivative\",color:H.colors.accent2},{label:\"tangent\",color:H.colors.warn}], 24, 92);" + }, + { + "id": "calc-tangent-line-eq", + "area": "Calculus", + "topic": "Tangent line equation", + "title": "Tangent line equation: y = f(a) + f'(a)(x - a)", + "equation": "y = f(a) + f'(a)(x - a)", + "keywords": [ + "tangent line", + "tangent line equation", + "point slope tangent", + "f(a) + f'(a)(x-a)", + "slope of tangent", + "derivative as slope", + "line touching curve", + "instantaneous slope", + "tangent at a point", + "local linear" + ], + "explanation": "The tangent line at x = a is the straight line that just grazes the curve there, matching both its height f(a) and its instantaneous slope f'(a). It is really point-slope form built from calculus: start at the point (a, f(a)), then rise at exactly the curve's slope f'(a), giving y = f(a) + f'(a)(x - a). In the animation you slide the contact point a back and forth; the orange tangent line pivots so it always touches the accent curve at the moving dot (a, f(a)) and leans at the curve's local steepness. Watch how the line tilts upward where the curve climbs and downward where it falls, and how it momentarily flattens at the curve's peaks and valleys where f'(a) = 0. You use this equation constantly: to approximate a curve near a point, to find where a curve has a horizontal tangent, and as the foundation of linear approximation and Newton's method.", + "bullets": [ + "The line passes through (a, f(a)) — it must touch the curve there.", + "Its slope equals f'(a), the curve's instantaneous rate of change at a.", + "Where the curve peaks, the tangent goes flat (slope f'(a) = 0).", + "The printed equation updates live as you drag the contact point a." + ], + "params": [ + { + "name": "c", + "label": "wave amount c", + "min": -2, + "max": 2, + "step": 0.25, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -4, xMax: 4, yMin: -3, yMax: 7 });\nv.grid(); v.axes();\nconst c = P.c;\nfunction f(x){ return 0.4 * x * x + c * Math.sin(x); }\nfunction fp(x){ return 0.8 * x + c * Math.cos(x); }\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst a = 3 * Math.sin(t * 0.5);\nconst fa = f(a), m = fp(a);\nfunction tan(x){ return fa + m * (x - a); }\nv.fn(tan, { color: H.colors.warn, width: 2 });\nv.dot(a, fa, { r: 7, fill: H.colors.accent2 });\nv.line(a, -3, a, fa, { color: H.colors.grid, width: 1, dash: [3,3] });\nconst sgn = m >= 0 ? \"+ \" : \"- \";\nH.text(\"Tangent line: y = f(a) + f'(a)(x - a)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"The line touches f at x = a with slope f'(a)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = \" + a.toFixed(2) + \" f(a) = \" + fa.toFixed(2) + \" slope f'(a) = \" + m.toFixed(2), 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"y = \" + fa.toFixed(2) + \" \" + sgn + Math.abs(m).toFixed(2) + \"(x - \" + a.toFixed(2) + \")\", 24, 96, { color: H.colors.warn, size: 13 });\nH.legend([{label:\"f(x)\",color:H.colors.accent},{label:\"tangent at a\",color:H.colors.warn},{label:\"point (a, f(a))\",color:H.colors.accent2}], 24, 116);" + }, + { + "id": "calc-linear-approximation", + "area": "Calculus", + "topic": "Linear approximation / differentials", + "title": "Linear approximation: f(x) ~ f(a) + f'(a)(x - a)", + "equation": "f(x) ~ f(a) + f'(a)(x - a)", + "keywords": [ + "linear approximation", + "linearization", + "differentials", + "tangent line approximation", + "local linearity", + "f(a) + f'(a)(x-a)", + "approximate sqrt", + "small change dy = f'(x) dx", + "estimate function value", + "first order approximation" + ], + "explanation": "Linear approximation uses the tangent line as a cheap stand-in for a curve near a chosen base point a, because any smooth curve looks almost straight when you zoom in close enough — this is local linearity. Mechanically you replace f(x) with its linearization L(x) = f(a) + f'(a)(x - a); in differential language, a small input change dx produces an estimated output change dy = f'(a) dx. The animation shows f(x) = sqrt(x) in accent, the orange tangent at the base point a, and a sweeping probe whose violet bar is the gap between the true curve value and the line — the approximation error. Notice the bar is invisibly small right at a and grows as the probe moves away, exactly because the error scales with (x - a)^2: the farther from a, the worse the estimate. You apply this to estimate values like sqrt(4.1) ~ 2 + (1/4)(0.1) by hand, to propagate measurement error through a formula, and as the first step of more accurate Taylor approximations.", + "bullets": [ + "Near the base point a the tangent line L(x) hugs the curve closely.", + "The violet bar is the error |f(x) - L(x)| — it grows as x leaves a.", + "Error scales like (x - a)^2, so doubling the distance roughly quadruples it.", + "Differentials: a small dx gives estimated change dy = f'(a) dx along the line." + ], + "params": [ + { + "name": "a", + "label": "base point a", + "min": 0.25, + "max": 7, + "step": 0.25, + "value": 4 + } + ], + "code": "H.background();\nconst a = P.a;\nconst v = H.plot2d({ xMin: -1, xMax: 9, yMin: -1, yMax: 4 });\nv.grid(); v.axes();\nfunction f(x){ return x > 0 ? Math.sqrt(x) : 0; }\nfunction fp(x){ return x > 0 ? 0.5 / Math.sqrt(x) : 0; }\nconst aa = a > 0.25 ? a : 0.25;\nconst fa = f(aa), m = fp(aa);\nfunction L(x){ return fa + m * (x - aa); }\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.fn(L, { color: H.colors.warn, width: 2 });\nv.dot(aa, fa, { r: 7, fill: H.colors.accent2 });\nconst x = aa + 2.4 * Math.sin(t * 0.7);\nconst xc = x > 0 ? x : 0;\nconst ex = f(xc), ap = L(xc);\nv.line(xc, ap, xc, ex, { color: H.colors.violet, width: 3 });\nv.dot(xc, ex, { r: 5, fill: H.colors.accent });\nv.dot(xc, ap, { r: 5, fill: H.colors.warn });\nv.line(xc, -1, xc, Math.min(ex, ap), { color: H.colors.grid, width: 1, dash: [3,3] });\nconst err = Math.abs(ex - ap);\nH.text(\"Linear approximation: f(x) ~ f(a) + f'(a)(x - a)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Near a the tangent line stands in for the curve\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = \" + aa.toFixed(2) + \" x = \" + xc.toFixed(2) + \" approx = \" + ap.toFixed(3) + \" true = \" + ex.toFixed(3), 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"error = \" + err.toFixed(4) + \" (grows with |x - a|)\", 24, 96, { color: H.colors.violet, size: 13 });\nH.legend([{label:\"f(x)=sqrt(x)\",color:H.colors.accent},{label:\"tangent L(x)\",color:H.colors.warn},{label:\"error\",color:H.colors.violet}], 24, 116);" + }, + { + "id": "calc-related-rates", + "area": "Calculus", + "topic": "Related rates (expanding circle)", + "title": "Related rates: dA/dt = 2*pi*r*(dr/dt)", + "equation": "dA/dt = 2 * pi * r * (dr/dt)", + "keywords": [ + "related rates", + "expanding circle", + "dadt", + "drdt", + "area rate of change", + "2 pi r", + "chain rule", + "implicit differentiation", + "growing circle", + "rate of area", + "circle area derivative", + "ripple" + ], + "explanation": "Related rates link how fast one quantity changes to how fast another changes when both depend on time. Here the circle's area A = pi*r^2 depends on its radius r, and r itself grows with time, so differentiating with respect to t and applying the chain rule gives dA/dt = 2*pi*r*(dr/dt) -- the derivative of pi*r^2 is 2*pi*r, times the rate dr/dt at which r is changing. The key insight the animation shows is that even when dr/dt (the green arrow on the edge) is held steady, dA/dt grows because it is multiplied by r: a large circle gains far more area per unit of radius than a small one, since the new area is added all along a longer perimeter (2*pi*r). To apply this you identify the relating equation (A = pi*r^2), differentiate both sides in t, then substitute the known instantaneous values of r and dr/dt. Watch the radius pulse out and back: the readout A = pi*r^2 and dA/dt update together, and dA/dt is largest exactly when the circle is biggest.", + "bullets": [ + "A = pi*r^2, so differentiating in time gives dA/dt = 2*pi*r*(dr/dt).", + "The chain rule supplies the dr/dt factor because r depends on t.", + "Same dr/dt yields a bigger dA/dt when r is larger -- area grows along the full perimeter 2*pi*r.", + "The green edge arrow is dr/dt; the live readouts show A and dA/dt changing together." + ], + "params": [ + { + "name": "drdt", + "label": "radius rate dr/dt", + "min": 0, + "max": 2, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -7, xMax: 7, yMin: -4.5, yMax: 4.5 });\nv.grid(); v.axes();\nconst drdt = P.drdt;\nconst rMin = 0.4;\nconst rMax = 3.4;\nconst period = 5;\nconst phase = (t % period) / period;\nconst r = rMin + (rMax - rMin) * phase;\nconst dr = drdt;\nconst dA = 2 * Math.PI * r * dr;\nconst A = Math.PI * r * r;\nconst circ = [];\nfor (let i = 0; i <= 80; i++) {\n const th = (i / 80) * H.TAU;\n circ.push([r * Math.cos(th), r * Math.sin(th)]);\n}\nv.path(circ, { color: H.colors.accent, width: 2.6, fill: \"rgba(91,140,255,0.18)\", close: true });\nv.line(0, 0, r, 0, { color: H.colors.accent2, width: 2.4 });\nv.text(\"r\", r / 2, 0.4, { color: H.colors.accent2, size: 14 });\nv.dot(0, 0, { r: 4, fill: H.colors.ink });\nconst tip = r + dr * 1.6;\nv.arrow(r, 0, tip, 0, { color: H.colors.good, width: 2.4 });\nH.text(\"Related rates: dA/dt = 2 pi r (dr/dt)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"radius grows at a steady rate; area grows faster as r gets bigger\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + r.toFixed(2) + \" dr/dt = \" + dr.toFixed(2) + \" (steady)\", 24, 80, { color: H.colors.sub, size: 13 });\nH.text(\"A = pi r^2 = \" + A.toFixed(2) + \" dA/dt = \" + dA.toFixed(2), 24, 100, { color: H.colors.good, size: 13 });" + }, + { + "id": "calc-critical-points", + "area": "Calculus", + "topic": "Critical points where f prime = 0", + "title": "Critical points: where f'(x) = 0", + "equation": "f'(x) = 0 => horizontal tangent", + "keywords": [ + "critical points", + "critical numbers", + "f prime equals zero", + "horizontal tangent", + "stationary points", + "derivative zero", + "turning points", + "cubic critical points", + "tangent slope zero", + "extrema candidates", + "f'(x)=0" + ], + "explanation": "A critical point is an x-value where the derivative f'(x) equals zero (or fails to exist) -- intuitively, where the curve momentarily stops climbing or falling and its tangent line is perfectly horizontal. Mechanically you find them by setting f'(x) = 0 and solving: here f(x) = x^3 - a*x has f'(x) = 3x^2 - a, so the critical numbers are x = +/- sqrt(a/3), which exist only when a > 0; when a <= 0 the derivative 3x^2 - a is never zero and the curve has no horizontal tangents at all. The dashed purple curve is f'(x): its x-intercepts sit directly below the orange critical points on the blue curve f(x), which is the visual statement that f'=0 there. These points are the *candidates* for local maxima and minima -- you locate them first, then a further test (sign of f' or f'') decides which is which. The orange probe sweeps across and shows f'(x) shrinking toward 0 as it nears a critical point, where the green horizontal-tangent segment appears.", + "bullets": [ + "Critical points are x where f'(x) = 0, marked by the orange dots.", + "At each one the tangent is horizontal -- the green segment has slope 0.", + "f'(x) = 3x^2 - a: real roots x = +/- sqrt(a/3) only when a > 0.", + "The dashed f'(x) crosses zero exactly beneath each critical point of f." + ], + "params": [ + { + "name": "a", + "label": "coefficient a", + "min": -3, + "max": 6, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -3.5, xMax: 3.5, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst a = P.a;\nconst f = x => x * x * x - a * x;\nconst fp = x => 3 * x * x - a;\nv.fn(f, { color: H.colors.accent, width: 2.8 });\nv.fn(fp, { color: H.colors.violet, width: 1.6, dash: [5, 4] });\nconst roots = [];\nif (a > 0) {\n const xr = Math.sqrt(a / 3);\n roots.push(-xr, xr);\n}\nfor (const xc of roots) {\n v.dot(xc, f(xc), { r: 7, fill: H.colors.warn });\n v.line(xc - 1, f(xc), xc + 1, f(xc), { color: H.colors.good, width: 2 });\n}\nconst xp = 2.6 * Math.sin(t * 0.7);\nv.dot(xp, f(xp), { r: 5, fill: H.colors.accent2 });\nv.dot(xp, fp(xp), { r: 4, fill: H.colors.violet });\nH.text(\"Critical points: where f'(x) = 0\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"blue f(x), dashed purple f'(x); horizontal tangents at the orange dots\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"probe x = \" + xp.toFixed(2) + \" f'(x) = \" + fp(xp).toFixed(2), 24, 80, { color: H.colors.sub, size: 13 });\nH.text(\"critical x: \" + (roots.length ? roots.map(r => r.toFixed(2)).join(\", \") : \"none (f' never 0)\"), 24, 100, { color: H.colors.warn, size: 13 });\nH.legend([{ label: \"f(x)\", color: H.colors.accent }, { label: \"f'(x)\", color: H.colors.violet }], 24, 124);" + }, + { + "id": "calc-increasing-decreasing", + "area": "Calculus", + "topic": "Increasing/decreasing from the sign of f prime", + "title": "Increasing / decreasing from the sign of f'(x)", + "equation": "f' > 0 => rising, f' < 0 => falling", + "keywords": [ + "increasing decreasing", + "sign of derivative", + "monotonic", + "rising falling", + "f prime positive negative", + "interval of increase", + "interval of decrease", + "slope sign", + "monotonicity", + "first derivative sign", + "where function increases" + ], + "explanation": "A function is increasing on an interval exactly where its derivative f'(x) is positive and decreasing where f'(x) is negative -- the derivative measures slope, and a positive slope means the curve is climbing, a negative slope means it is dropping. Mechanically you compute f'(x), find where it equals zero (the orange boundary points), and then test the sign of f' on each resulting interval; here f(x) = x^3 - a*x gives f'(x) = 3x^2 - a, which is negative between the two critical points and positive outside them when a > 0. The animation shades the plane: green vertical strips mark where f' > 0 (rising) and red strips where f' < 0 (falling), so the curve visibly goes up through every green band and down through every red one. The yellow tangent on the moving probe confirms it -- its slope is positive over green, negative over red, and flat at the boundaries. To apply this for sketching or optimization, you build a sign chart of f' and read off the increasing/decreasing intervals directly from it.", + "bullets": [ + "Green strips: f'(x) > 0, the curve is rising there.", + "Red strips: f'(x) < 0, the curve is falling there.", + "Boundaries (orange) are critical points where f'(x) = 0.", + "The yellow tangent's slope matches the strip color under the moving probe." + ], + "params": [ + { + "name": "a", + "label": "coefficient a", + "min": -3, + "max": 6, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -3.5, xMax: 3.5, yMin: -5, yMax: 5 });\nv.grid(); v.axes();\nconst a = P.a;\nconst f = x => x * x * x - a * x;\nconst fp = x => 3 * x * x - a;\nconst N = 90;\nconst dx = (v.xMax - v.xMin) / N;\nfor (let i = 0; i < N; i++) {\n const xL = v.xMin + i * dx;\n const xM = xL + dx / 2;\n const rising = fp(xM) > 0;\n const col = rising ? \"rgba(74,200,140,0.16)\" : \"rgba(232,110,110,0.16)\";\n v.rect(xL, v.yMin, dx, v.yMax - v.yMin, { fill: col });\n}\nv.fn(f, { color: H.colors.accent, width: 3 });\nif (a > 0) {\n const xr = Math.sqrt(a / 3);\n v.dot(-xr, f(-xr), { r: 6, fill: H.colors.warn });\n v.dot(xr, f(xr), { r: 6, fill: H.colors.warn });\n}\nconst xp = 2.8 * Math.sin(t * 0.7);\nconst slope = fp(xp);\nv.dot(xp, f(xp), { r: 6, fill: H.colors.accent2 });\nconst dxs = 0.9;\nv.line(xp - dxs, f(xp) - slope * dxs, xp + dxs, f(xp) + slope * dxs, { color: H.colors.yellow, width: 2.2 });\nH.text(\"Increasing / decreasing from sign of f'(x)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"green strips: f' > 0 (rising) red strips: f' < 0 (falling)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"probe x = \" + xp.toFixed(2) + \" f'(x) = \" + slope.toFixed(2) + \" -> \" + (slope >= 0 ? \"increasing\" : \"decreasing\"), 24, 80, { color: slope >= 0 ? H.colors.good : H.colors.warn, size: 13 });" + }, + { + "id": "calc-first-derivative-test", + "area": "Calculus", + "topic": "First derivative test (local max/min)", + "title": "First derivative test: local max vs local min", + "equation": "f' : + to - => local max; - to + => local min", + "keywords": [ + "first derivative test", + "local maximum", + "local minimum", + "sign change of derivative", + "relative extrema", + "f prime sign change", + "turning point classification", + "local extrema", + "max min test", + "plus to minus", + "minus to plus" + ], + "explanation": "The first derivative test classifies a critical point by watching how the sign of f'(x) changes as x passes through it: if f' switches from positive to negative the function was rising then falling, so that point is a local maximum; if f' switches from negative to positive the function was falling then rising, giving a local minimum. The intuition is simply that a hilltop has an up-slope on its left and a down-slope on its right, while a valley is the reverse. Mechanically you first find the critical numbers by solving f'(x) = 0 -- here f(x) = x^3 - a*x has f'(x) = 3x^2 - a with critical points x = +/- sqrt(a/3) when a > 0 -- then check the sign of f' just to each side of every critical point. In the animation the moving probe carries a yellow tangent whose slope (shown as f'(x) with its sign in the readout) flips from + to - as it crosses the orange local-max dot and from - to + as it crosses the green local-min dot. You apply this test whenever you need to identify and label extrema -- for curve sketching or to solve optimization problems -- because it works even when the second derivative is awkward or zero.", + "bullets": [ + "f' changes + to - at a local max (orange dot at the hilltop).", + "f' changes - to + at a local min (green dot at the valley).", + "Critical points come from f'(x) = 3x^2 - a = 0, i.e. x = +/- sqrt(a/3).", + "The probe's yellow tangent and the signed f'(x) readout flip across each extremum." + ], + "params": [ + { + "name": "a", + "label": "coefficient a", + "min": -3, + "max": 6, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -3.5, xMax: 3.5, yMin: -5, yMax: 5 });\nv.grid(); v.axes();\nconst a = P.a;\nconst f = x => x * x * x - a * x;\nconst fp = x => 3 * x * x - a;\nv.fn(f, { color: H.colors.accent, width: 3 });\nlet xMaxc = null, xMinc = null;\nif (a > 0) {\n const xr = Math.sqrt(a / 3);\n xMaxc = -xr; xMinc = xr;\n v.dot(xMaxc, f(xMaxc), { r: 7, fill: H.colors.warn });\n v.text(\"local max (f': + to -)\", xMaxc - 0.2, f(xMaxc) + 0.7, { color: H.colors.warn, size: 12, align: \"center\" });\n v.dot(xMinc, f(xMinc), { r: 7, fill: H.colors.good });\n v.text(\"local min (f': - to +)\", xMinc + 0.2, f(xMinc) - 0.7, { color: H.colors.good, size: 12, align: \"center\" });\n}\nconst xp = 2.8 * Math.sin(t * 0.7);\nconst sp = fp(xp);\nv.dot(xp, f(xp), { r: 5, fill: H.colors.accent2 });\nv.line(xp - 0.8, f(xp) - sp * 0.8, xp + 0.8, f(xp) + sp * 0.8, { color: H.colors.yellow, width: 2.2 });\nH.text(\"First derivative test: local max vs local min\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f' switches + to - at a max, - to + at a min\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"probe x = \" + xp.toFixed(2) + \" f'(x) = \" + sp.toFixed(2) + \" (\" + (sp >= 0 ? \"+\" : \"-\") + \")\", 24, 80, { color: H.colors.sub, size: 13 });\nH.text(a > 0 ? (\"max at x=\" + xMaxc.toFixed(2) + \", min at x=\" + xMinc.toFixed(2)) : \"no critical points (a <= 0)\", 24, 100, { color: H.colors.ink, size: 13 });" + }, + { + "id": "calc-concavity", + "area": "Calculus", + "topic": "Concavity and the second derivative", + "title": "Concavity from f''(x): concave up vs concave down", + "equation": "f''(x) > 0 -> concave up; f''(x) < 0 -> concave down", + "keywords": [ + "concavity", + "second derivative", + "concave up", + "concave down", + "cup and cap", + "f double prime", + "curvature", + "tangent below curve", + "tangent above curve", + "convex" + ], + "explanation": "Concavity describes which way a curve bends, and the second derivative f''(x) is exactly the sign that measures it: where f'' > 0 the slope f' is increasing, so the curve cups upward and every tangent line lies BELOW it (concave up), while where f'' < 0 the slope is decreasing and the curve caps downward with tangents lying above it (concave down). Mechanically you compute f''(x) and read only its sign, region by region. Here the curve is f(x) = a·x^3 + b·x, whose f'' = 6a·x is negative left of the origin and positive to the right; the green band shades the concave-up region and the pink band the concave-down region. To apply it, watch the yellow probe sweep across: the violet tangent it carries swings from lying above the curve (cap) to lying below it (cup), and the live readout reports f''(x) and the verdict at the probe so you can connect a number's sign to a visible bend.", + "bullets": [ + "f'' > 0 means slope is increasing -> curve cups upward (tangent below).", + "f'' < 0 means slope is decreasing -> curve caps downward (tangent above).", + "Green band = concave up region; pink band = concave down region.", + "The violet tangent flips from above to below as the probe crosses x = 0." + ], + "params": [ + { + "name": "a", + "label": "cubic coeff a", + "min": -1.5, + "max": 1.5, + "step": 0.1, + "value": 0.5 + }, + { + "name": "b", + "label": "linear coeff b", + "min": -4, + "max": 4, + "step": 0.5, + "value": -2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -4, xMax: 4, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst a = P.a, b = P.b;\nconst f = x => a * x * x * x + b * x;\nconst fpp = x => 6 * a * x;\nconst N = 60;\nfor (let i = 0; i < N; i++) {\n const x0 = -4 + (8 * i) / N;\n const x1 = -4 + (8 * (i + 1)) / N;\n const xm = (x0 + x1) / 2;\n const up = fpp(xm) > 0;\n const col = up ? H.colors.good : H.colors.warn;\n v.rect(x0, 8, x1 - x0, 16, { fill: col + \"22\" });\n}\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst xp = 3 * Math.sin(t * 0.7);\nconst yp = f(xp);\nv.dot(xp, yp, { r: 6, fill: H.colors.yellow });\nconst fp = x => 3 * a * x * x + b;\nconst m = fp(xp);\nv.line(xp - 1.3, yp - 1.3 * m, xp + 1.3, yp + 1.3 * m, { color: H.colors.violet, width: 2, dash: [5, 4] });\nconst cc = fpp(xp);\nconst label = cc > 0 ? \"concave UP (cup)\" : cc < 0 ? \"concave DOWN (cap)\" : \"inflection\";\nH.text(\"Concavity & f''(x)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"green band: f'' > 0 | pink band: f'' < 0\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"at x = \" + xp.toFixed(2) + \": f'' = \" + cc.toFixed(2) + \" -> \" + label, 24, 74, { color: H.colors.yellow, size: 13 });\nH.legend([{ label: \"f(x)\", color: H.colors.accent }, { label: \"tangent\", color: H.colors.violet }], 24, 96);" + }, + { + "id": "calc-inflection", + "area": "Calculus", + "topic": "Inflection points (f double prime changes sign)", + "title": "Inflection point: where f''(x) changes sign", + "equation": "f''(c) = 0 and f'' changes sign at c -> inflection at x = c", + "keywords": [ + "inflection point", + "concavity change", + "f double prime changes sign", + "second derivative zero", + "point of inflection", + "concave up to down", + "curvature flip", + "bend changes", + "f''=0", + "inflection" + ], + "explanation": "An inflection point is where a curve switches its bend direction — from concave up to concave down or vice versa — and that switch happens exactly where the second derivative f'' changes sign. The mechanism has two parts: f''(c) = 0 (or is undefined) is only a CANDIDATE; you must then check that f'' actually changes sign across c, because a zero of f'' alone (like x^4 at 0) need not be an inflection. Here the curve is f(x) = a·(x − c)^3 + 2·(x − c), so f'' = 6a·(x − c) is zero only at x = c and genuinely flips sign there, marked by the violet dot and dashed vertical line; the green and pink bands show the concave-up and concave-down regions meeting at that line. To apply it, drag c to relocate the flip and watch the yellow probe: as it sweeps through x = c the readout shows f''(x) passing through zero and reversing sign, confirming the concavity actually changes rather than merely touching zero.", + "bullets": [ + "Inflection = the point where concavity switches direction.", + "f''(c) = 0 is only a candidate; f'' must CHANGE sign across c.", + "The violet dot sits where green (cup) meets pink (cap) regions.", + "Slide c to move the flip; the probe's f'' reverses sign there." + ], + "params": [ + { + "name": "a", + "label": "steepness a", + "min": -1, + "max": 1, + "step": 0.1, + "value": 0.3 + }, + { + "name": "c", + "label": "inflection x c", + "min": -3, + "max": 3, + "step": 0.5, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -10, yMax: 10 });\nv.grid(); v.axes();\nconst a = P.a, c = P.c;\nconst s = 2;\nconst f = x => a * Math.pow(x - c, 3) + s * (x - c);\nconst fpp = x => 6 * a * (x - c);\nconst N = 60;\nfor (let i = 0; i < N; i++) {\n const x0 = -5 + (10 * i) / N;\n const x1 = -5 + (10 * (i + 1)) / N;\n const xm = (x0 + x1) / 2;\n const col = fpp(xm) > 0 ? H.colors.good : H.colors.warn;\n v.rect(x0, 10, x1 - x0, 20, { fill: col + \"1e\" });\n}\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst yi = f(c);\nv.line(c, -10, c, 10, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.dot(c, yi, { r: 8, fill: H.colors.violet });\nconst xp = c + 3 * Math.sin(t * 0.8);\nconst yp = f(xp);\nv.dot(xp, yp, { r: 6, fill: H.colors.yellow });\nconst cc = fpp(xp);\nconst side = cc > 0 ? \"concave up\" : cc < 0 ? \"concave down\" : \"flip!\";\nH.text(\"Inflection point: f'' changes sign\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"violet dot at x = \" + c.toFixed(2) + \": concavity flips here\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"probe x = \" + xp.toFixed(2) + \": f'' = \" + cc.toFixed(2) + \" (\" + side + \")\", 24, 74, { color: H.colors.yellow, size: 13 });" + }, + { + "id": "calc-second-derivative-test", + "area": "Calculus", + "topic": "Second derivative test", + "title": "Second derivative test: classify a critical point by sign of f''", + "equation": "f'(c) = 0: f''(c) > 0 -> local min; f''(c) < 0 -> local max", + "keywords": [ + "second derivative test", + "classify critical point", + "local min", + "local max", + "f''(c)", + "concavity test", + "critical point", + "relative extremum", + "f'=0", + "min or max" + ], + "explanation": "Once you have a critical point c where f'(c) = 0, the second derivative test classifies it in one step using the sign of f''(c): if f''(c) > 0 the curve is concave up there so the critical point sits at the bottom of a cup — a local minimum — and if f''(c) < 0 the curve is concave down so it sits at the top of a cap — a local maximum. The reason it works is that concavity tells you which way the curve opens around the horizontal tangent: a cup holds a low point, a cap holds a high point. If f''(c) = 0 the test is inconclusive (the curve could still go either way) and you must fall back to the first-derivative sign test. Here f(x) = a·(x − c)^2 has its only critical point at x = c, drawn as the violet dot with a dashed horizontal tangent; f'' = 2a is constant, so its sign is fixed by the slider a. Watch the yellow probe's tangent slope f'(x) shrink to zero as it reaches the critical point, while the readout reports f'' and the resulting min/max verdict.", + "bullets": [ + "Use only at a critical point where f'(c) = 0 (flat tangent).", + "f''(c) > 0 -> concave up -> local MINIMUM (bottom of a cup).", + "f''(c) < 0 -> concave down -> local MAXIMUM (top of a cap).", + "f''(c) = 0 is inconclusive — switch to the first-derivative test." + ], + "params": [ + { + "name": "a", + "label": "opening a (sign sets min/max)", + "min": -3, + "max": 3, + "step": 0.5, + "value": 1 + }, + { + "name": "c", + "label": "critical x c", + "min": -3, + "max": 3, + "step": 0.5, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -4, xMax: 4, yMin: -8, yMax: 8 });\nv.grid(); v.axes();\nconst a = P.a, c = P.c;\nconst f = x => a * (x - c) * (x - c);\nconst fp = x => 2 * a * (x - c);\nconst fpp = 2 * a;\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst yc = f(c);\nv.dot(c, yc, { r: 8, fill: H.colors.violet });\nv.line(c - 1.4, yc, c + 1.4, yc, { color: H.colors.violet, width: 2, dash: [5, 4] });\nconst xp = c + 2.5 * Math.sin(t * 0.8);\nconst yp = f(xp);\nv.dot(xp, yp, { r: 6, fill: H.colors.yellow });\nconst m = fp(xp);\nv.line(xp - 1, yp - m, xp + 1, yp + m, { color: H.colors.yellow, width: 1.5 });\nlet verdict;\nif (fpp > 0) verdict = \"f'' > 0 -> local MIN\";\nelse if (fpp < 0) verdict = \"f'' < 0 -> local MAX\";\nelse verdict = \"f'' = 0 -> test inconclusive\";\nH.text(\"Second Derivative Test\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"critical pt x = \" + c.toFixed(2) + \" (f' = 0); classify by sign of f''\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"f'' = \" + fpp.toFixed(2) + \" \" + verdict, 24, 74, { color: fpp > 0 ? H.colors.good : fpp < 0 ? H.colors.warn : H.colors.sub, size: 13 });\nH.text(\"probe slope f'(x) = \" + m.toFixed(2), 24, 96, { color: H.colors.yellow, size: 12 });" + }, + { + "id": "calc-absolute-extrema", + "area": "Calculus", + "topic": "Absolute extrema on a closed interval", + "title": "Absolute extrema on [a, b]: compare critical points and endpoints", + "equation": "absolute max/min = largest/smallest of f at {critical points in (a,b)} U {a, b}", + "keywords": [ + "absolute extrema", + "global maximum", + "global minimum", + "closed interval method", + "candidate test", + "extreme value theorem", + "endpoints", + "critical points", + "absolute max", + "absolute min on interval" + ], + "explanation": "On a closed interval [a, b] a continuous function is guaranteed (by the Extreme Value Theorem) to attain both an absolute maximum and an absolute minimum, and the Closed Interval Method finds them with a short finite checklist: the only places a global extreme can occur are interior critical points (where f' = 0 or is undefined) and the two endpoints. The mechanism is simply to evaluate f at every candidate in that finite set and pick the largest value as the absolute max and the smallest as the absolute min — no further sign analysis is needed. Here f(x) = x^3 − 3x has critical points at x = −1 and x = +1 (violet dots whenever they fall inside the window), the gray dots mark the endpoints you set with the sliders, and the green and pink rings flag the winning absolute max and min among them. To apply it, slide the endpoints a and b: the candidate set updates, and the highlighted extrema can jump from an interior critical point to an endpoint, showing why endpoints must always be checked even though they are not critical points.", + "bullets": [ + "EVT guarantees a max and min exist on any closed interval [a, b].", + "Candidates = interior critical points (violet) PLUS endpoints (gray).", + "Evaluate f at every candidate; biggest = abs max, smallest = abs min.", + "Endpoints can win even though f' is not zero there — always check them." + ], + "params": [ + { + "name": "a", + "label": "left endpoint a", + "min": -4.5, + "max": 3.5, + "step": 0.5, + "value": -2.5 + }, + { + "name": "b", + "label": "right endpoint b", + "min": -3.5, + "max": 4.5, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -10, yMax: 14 });\nv.grid(); v.axes();\nconst f = x => x * x * x - 3 * x;\nconst crit = [-1, 1];\nlet lo = Math.min(P.a, P.b), hi = Math.max(P.a, P.b);\nif (hi - lo < 0.5) hi = lo + 0.5;\nlo = Math.max(-4.5, Math.min(lo, 3.5));\nhi = Math.min(4.5, Math.max(hi, lo + 0.5));\nv.rect(lo, 14, hi - lo, 24, { fill: H.colors.accent + \"18\" });\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst cands = [{ x: lo, kind: \"endpoint\" }, { x: hi, kind: \"endpoint\" }];\nfor (const xc of crit) if (xc > lo && xc < hi) cands.push({ x: xc, kind: \"critical\" });\nlet mn = cands[0], mx = cands[0];\nfor (const cd of cands) {\n if (f(cd.x) < f(mn.x)) mn = cd;\n if (f(cd.x) > f(mx.x)) mx = cd;\n}\nv.line(lo, -10, lo, 14, { color: H.colors.sub, width: 1, dash: [3, 4] });\nv.line(hi, -10, hi, 14, { color: H.colors.sub, width: 1, dash: [3, 4] });\nfor (const cd of cands) {\n v.dot(cd.x, f(cd.x), { r: 5, fill: cd.kind === \"critical\" ? H.colors.violet : H.colors.sub });\n}\nv.circle(mx.x, f(mx.x), 9, { stroke: H.colors.good, width: 2.5 });\nv.circle(mn.x, f(mn.x), 9, { stroke: H.colors.warn, width: 2.5 });\nconst xs = lo + (hi - lo) * (0.5 + 0.5 * Math.sin(t * 0.9));\nv.dot(xs, f(xs), { r: 6, fill: H.colors.yellow });\nv.line(xs, -10, xs, f(xs), { color: H.colors.yellow, width: 1, dash: [2, 3] });\nH.text(\"Absolute extrema on [a, b]\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"compare f at endpoints (gray) AND interior critical pts (violet)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"[a,b] = [\" + lo.toFixed(2) + \", \" + hi.toFixed(2) + \"]\", 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"max = \" + f(mx.x).toFixed(2) + \" at x=\" + mx.x.toFixed(2) + \" (\" + mx.kind + \")\", 24, 96, { color: H.colors.good, size: 13 });\nH.text(\"min = \" + f(mn.x).toFixed(2) + \" at x=\" + mn.x.toFixed(2) + \" (\" + mn.kind + \")\", 24, 116, { color: H.colors.warn, size: 13 });" + }, + { + "id": "calc-optimization", + "area": "Calculus", + "topic": "Optimization (max area, fixed perimeter)", + "title": "Optimization: maximize area w*(P/2 - w) for fixed perimeter", + "equation": "A(w) = w*(P/2 - w), A'(w)=0 at w = P/4 (a square)", + "keywords": [ + "optimization", + "max area", + "maximum area", + "fixed perimeter", + "rectangle area", + "critical point", + "derivative zero", + "max min problem", + "applied optimization", + "square is optimal", + "calculus optimization" + ], + "explanation": "An optimization problem turns a real constraint into a single-variable function and then finds where its derivative is zero. Here the perimeter P is fixed, so the two sides obey 2w + 2h = P, meaning h = P/2 - w; substituting gives the area as one function A(w) = w*(P/2 - w), a downward parabola. The mechanism is that A'(w) = P/2 - 2w, which is zero exactly at w = P/4 — and since the parabola opens downward, that critical point is the global maximum, where the rectangle becomes a square. The orange probe sweeps the width back and forth and reads off its area, climbing toward the green optimum dot and falling away on either side; the dashed green guide marks the peak at w = P/4. To apply this pattern to any constrained problem: write the quantity you want as one variable using the constraint, set the derivative to zero, and confirm it is a max with the second derivative or the shape of the curve.", + "bullets": [ + "The constraint 2w+2h=P lets you write area as ONE variable: A(w)=w*(P/2 - w).", + "A'(w)=P/2-2w=0 gives w=P/4 — the orange probe peaks at the green dot.", + "The downward parabola means that critical point is a maximum, not a minimum.", + "Among fixed-perimeter rectangles, the square encloses the most area." + ], + "params": [ + { + "name": "w", + "label": "width w", + "min": 0.5, + "max": 9.5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst Pm = 20;\nconst w = Math.max(0.001, Math.min(Pm/2 - 0.001, P.w));\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: 0, yMax: 30 });\nv.grid(); v.axes();\nconst area = x => x * (Pm/2 - x);\nv.fn(area, { color: H.colors.accent, width: 3 });\nconst opt = Pm/4;\nv.line(opt, 0, opt, area(opt), { color: H.colors.grid, width: 1.5, dash: [4,4] });\nv.dot(opt, area(opt), { r: 6, fill: H.colors.good });\nconst sweep = (Pm/2) * (0.5 + 0.49 * Math.sin(t * 0.7));\nconst wv = Math.max(0.001, Math.min(Pm/2 - 0.001, sweep));\nv.dot(wv, area(wv), { r: 7, fill: H.colors.accent2 });\nv.line(wv, 0, wv, area(wv), { color: H.colors.accent2, width: 1, dash:[3,3] });\nconst h = Pm/2 - w;\nconst A = w * h;\nH.text(\"Max area for fixed perimeter\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Perimeter \" + Pm + \" fixed: area = w*(P/2 - w) peaks at the square\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"w = \" + w.toFixed(2) + \", h = \" + h.toFixed(2) + \", area = \" + A.toFixed(2), 24, 76, { color: H.colors.accent2, size: 13 });\nH.text(\"optimum: square \" + opt.toFixed(1) + \" x \" + opt.toFixed(1) + \" -> area \" + area(opt).toFixed(1), 24, 96, { color: H.colors.good, size: 13 });" + }, + { + "id": "calc-mvt", + "area": "Calculus", + "topic": "Mean value theorem", + "title": "Mean Value Theorem: f'(c) = (f(b)-f(a))/(b-a)", + "equation": "f'(c) = (f(b) - f(a)) / (b - a) for some c in (a, b)", + "keywords": [ + "mean value theorem", + "mvt", + "secant slope", + "tangent parallel to secant", + "average rate of change", + "instantaneous rate", + "f prime of c", + "c in a b", + "calculus theorem", + "lagrange theorem" + ], + "explanation": "The Mean Value Theorem says that for a function that is continuous on [a,b] and differentiable inside, there is at least one point c where the instantaneous slope equals the average slope across the whole interval. Intuitively, if you drive from a to b at an average speed, at some instant your speedometer must read exactly that average — the curve can't beat its own average everywhere. The mechanism is that the secant slope (f(b)-f(a))/(b-a) is a fixed number, and as you slide a tangent line along a smooth curve its slope varies continuously, so it must pass through that value somewhere; for this parabola that c is the midpoint of a and b. In the animation the purple line is the secant joining the endpoints, the orange dashed line is a moving tangent whose slope you can read changing, and the green dot marks c where the dashed tangent becomes exactly parallel to the secant. To apply MVT, check the hypotheses (continuous and differentiable), compute the average rate, and then you are guaranteed a matching instantaneous rate — the basis for error bounds, the FTC, and uniqueness arguments. The slider k changes the curvature so you can watch c stay at the midpoint while the slopes rescale.", + "bullets": [ + "Purple secant slope = average rate of change (f(b)-f(a))/(b-a).", + "The orange tangent sweeps; its slope is a live readout that changes with t.", + "At the green dot c the tangent is PARALLEL to the secant — slopes match.", + "Hypotheses matter: needs continuity on [a,b] and differentiability on (a,b)." + ], + "params": [ + { + "name": "k", + "label": "curvature k", + "min": 0.05, + "max": 0.4, + "step": 0.01, + "value": 0.18 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 7, yMin: -2, yMax: 10 });\nv.grid(); v.axes();\nconst k = P.k;\nconst f = x => k * x * x - 0.3 * x + 1.5;\nconst fp = x => 2 * k * x - 0.3;\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst a = 0.5, b = 6;\nconst slope = (f(b) - f(a)) / (b - a);\nv.line(a, f(a), b, f(b), { color: H.colors.violet, width: 2.5 });\nv.dot(a, f(a), { r: 5, fill: H.colors.sub });\nv.dot(b, f(b), { r: 5, fill: H.colors.sub });\nconst c = (a + b) / 2;\nconst sweep = a + 0.2 + (b - a - 0.4) * (0.5 + 0.5 * Math.sin(t * 0.7));\nconst ts = f(sweep) - fp(sweep) * sweep;\nv.line(a, fp(sweep) * a + ts, b, fp(sweep) * b + ts, { color: H.colors.accent2, width: 1.6, dash:[4,4] });\nv.dot(sweep, f(sweep), { r: 5, fill: H.colors.accent2 });\nv.line(c, -2, c, f(c), { color: H.colors.grid, width:1, dash:[3,3] });\nv.dot(c, f(c), { r: 7, fill: H.colors.good });\nv.text(\"c\", c, f(c) + 0.9, { color: H.colors.good, size: 13, align:\"center\" });\nH.text(\"Mean Value Theorem\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Secant slope (a..b) = tangent slope at some c in (a,b)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"secant slope = \" + slope.toFixed(2) + \" ; c = \" + c.toFixed(2) + \" where f'(c) = \" + fp(c).toFixed(2), 24, 76, { color: H.colors.good, size: 13 });\nH.text(\"probe f'(x) = \" + fp(sweep).toFixed(2) + \" at x = \" + sweep.toFixed(2), 24, 96, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "calc-rolles", + "area": "Calculus", + "topic": "Rolles theorem", + "title": "Rolle's Theorem: f(a)=f(b) implies f'(c)=0 for some c", + "equation": "if f(a) = f(b) then f'(c) = 0 for some c in (a, b)", + "keywords": [ + "rolles theorem", + "rolle theorem", + "horizontal tangent", + "f prime equals zero", + "equal endpoints", + "critical point between", + "turning point", + "calculus theorem", + "f a equals f b", + "stationary point" + ], + "explanation": "Rolle's Theorem is the special case of the Mean Value Theorem where the two endpoints have the same height, so the average slope across the interval is zero. Intuitively, if a smooth curve leaves and returns to the same height, it must turn around somewhere — at that turning point the tangent is flat. The mechanism: continuity guarantees the function attains a maximum and minimum on [a,b]; since the endpoints are equal, at least one of those extremes lies strictly inside the interval, and at an interior extreme of a differentiable function the derivative is zero. In the animation the dashed purple line shows f(a)=f(b) at the same level, the orange dashed tangent sweeps along the curve with its slope shown live, and the green dot at c is where the tangent becomes perfectly horizontal (the solid green level line), confirming f'(c)=0. To apply it, verify continuity, differentiability, and equal endpoint values, then you are guaranteed an interior stationary point — this is the engine behind the Mean Value Theorem and many root-counting arguments. The slider k opens or flips the parabola while c stays pinned at the midpoint where the slope is zero.", + "bullets": [ + "Equal endpoints f(a)=f(b) make the average slope exactly zero.", + "A smooth curve that returns to its start height must turn around inside.", + "The green dot c is where the orange tangent goes flat — f'(c)=0.", + "Rolle is MVT with f(a)=f(b); it powers the proof of MVT itself." + ], + "params": [ + { + "name": "k", + "label": "shape k", + "min": -1.5, + "max": 1.5, + "step": 0.25, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 7, yMin: -2, yMax: 8 });\nv.grid(); v.axes();\nconst a = 1, b = 5;\nconst k = P.k;\nconst L = 3;\nconst f = x => k * (x - a) * (x - b) + L;\nconst fp = x => k * (2 * x - a - b);\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(a, f(a), b, f(b), { color: H.colors.violet, width: 2, dash:[5,5] });\nv.dot(a, f(a), { r: 5, fill: H.colors.sub });\nv.dot(b, f(b), { r: 5, fill: H.colors.sub });\nv.text(\"f(a)=f(b)=\" + L.toFixed(1), a, L + 0.8, { color: H.colors.violet, size: 12 });\nconst c = (a + b) / 2;\nconst sweep = a + 0.2 + (b - a - 0.4) * (0.5 + 0.5 * Math.sin(t * 0.7));\nconst ts = f(sweep) - fp(sweep) * sweep;\nv.line(a, fp(sweep) * a + ts, b, fp(sweep) * b + ts, { color: H.colors.accent2, width: 1.6, dash:[4,4] });\nv.dot(sweep, f(sweep), { r: 5, fill: H.colors.accent2 });\nv.line(c, -2, c, f(c), { color: H.colors.grid, width:1, dash:[3,3] });\nv.line(a, f(c), b, f(c), { color: H.colors.good, width: 2 });\nv.dot(c, f(c), { r: 7, fill: H.colors.good });\nv.text(\"c, slope 0\", c, f(c) + (k>=0? -0.9: 0.9), { color: H.colors.good, size: 13, align:\"center\" });\nH.text(\"Rolle's Theorem\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"If f(a)=f(b), some c in (a,b) has f'(c)=0 (horizontal tangent)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"c = \" + c.toFixed(2) + \" ; f'(c) = \" + fp(c).toFixed(2), 24, 76, { color: H.colors.good, size: 13 });\nH.text(\"probe f'(x) = \" + fp(sweep).toFixed(2) + \" at x = \" + sweep.toFixed(2), 24, 96, { color: H.colors.accent2, size: 13 });" + }, + { + "id": "calc-lhopital", + "area": "Calculus", + "topic": "L Hopital rule for 0/0", + "title": "L'Hopital's Rule: lim 0/0 = lim f'(x)/g'(x)", + "equation": "lim sin(a*x)/x = lim (a*cos(a*x))/1 = a as x -> 0", + "keywords": [ + "lhopital", + "l hopital", + "lhopitals rule", + "0/0 indeterminate", + "indeterminate form", + "limit of ratio", + "ratio of derivatives", + "removable hole", + "limit sin x over x", + "calculus limits" + ], + "explanation": "L'Hopital's Rule rescues limits that plug in to the meaningless form 0/0, where both numerator and denominator vanish at the same point. The intuition is that near such a point each function is well-approximated by its tangent line, so the ratio is dominated by their slopes — the ratio of the functions tends to the ratio of their derivatives. The mechanism: if f(x0)=g(x0)=0, then f(x)/g(x) = (f(x)-f(x0))/(g(x)-g(x0)), and dividing top and bottom by (x-x0) turns each into a difference quotient that converges to f'(x0) and g'(x0); hence the limit equals f'(x0)/g'(x0) when that exists. The animation uses sin(a*x)/x, which is 0/0 at x=0: the orange probe slides in from both sides reading the ratio, the warn-colored open circle marks that the function is literally undefined at the hole, and the green dashed level is the true limit a = a*cos(0)/1 that the curve fills in continuously. To apply it, first confirm the form is genuinely 0/0 (or infinity/infinity), differentiate top and bottom separately — not as a quotient — and take the new limit, repeating if it is still indeterminate. The slider a changes the limiting value so you can see the curve and its filled-in hole both shift to a.", + "bullets": [ + "Direct substitution gives 0/0 — undefined, shown by the warn open circle.", + "Replace the ratio of functions with the ratio of their DERIVATIVES.", + "sin(a*x)/x -> a*cos(a*x)/1 -> a as x->0, the green dashed level.", + "The orange probe approaches from both sides and lands on that limit." + ], + "params": [ + { + "name": "a", + "label": "coefficient a", + "min": -3, + "max": 3, + "step": 0.5, + "value": 1.5 + } + ], + "code": "H.background();\nconst a = (P.a === 0 ? 0.001 : P.a);\nconst yLo = Math.min(-1, a - 1.5);\nconst yHi = Math.max(2, a + 1.5);\nconst v = H.plot2d({ xMin: -3, xMax: 3, yMin: yLo, yMax: yHi });\nv.grid(); v.axes();\nconst r = x => (Math.abs(x) < 1e-4 ? a * Math.cos(a * x) : Math.sin(a * x) / x);\nv.fn(r, { color: H.colors.accent, width: 3 });\nv.line(-3, a, 3, a, { color: H.colors.good, width: 1.5, dash:[5,5] });\nv.text(\"limit = a = \" + a.toFixed(2), 0.2, a + (a>=0?0.25:-0.25), { color: H.colors.good, size: 12 });\nconst x = 2.2 * Math.cos(t * 0.9);\nconst xs = (Math.abs(x) < 1e-4 ? 1e-4 : x);\nconst num = Math.sin(a * xs);\nconst den = xs;\nv.dot(xs, num/den, { r: 7, fill: H.colors.accent2 });\nv.line(xs, 0, xs, num/den, { color: H.colors.accent2, width:1, dash:[3,3] });\nv.circle(0, a, 5, { fill: H.colors.bg, stroke: H.colors.warn, width: 2 });\nH.text(\"L'Hopital's Rule for 0/0\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"sin(a*x)/x is 0/0 at x=0; limit = a*cos(0)/1 = a\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"at x=\" + xs.toFixed(3) + \": sin(a*x)=\" + num.toFixed(3) + \" , x=\" + den.toFixed(3) + \" , ratio=\" + (num/den).toFixed(3), 24, 76, { color: H.colors.accent2, size: 13 });\nH.text(\"derivatives: a*cos(a*x) / 1 -> a as x->0\", 24, 96, { color: H.colors.good, size: 13 });" + }, + { + "id": "calc-curve-sketching", + "area": "Calculus", + "topic": "Curve sketching with f, f prime, f double prime", + "title": "Curve sketching: slope from f ', concavity from f ''", + "equation": "f(x) = x^3 - a*x, f '(x) = 3x^2 - a, f ''(x) = 6x", + "keywords": [ + "curve sketching", + "first derivative test", + "second derivative test", + "concavity", + "inflection point", + "increasing decreasing", + "critical point", + "f prime f double prime", + "shape of a graph", + "concave up concave down" + ], + "explanation": "Curve sketching means reading the graph of f off its derivatives instead of plotting a hundred points: the sign of the first derivative f ' tells you where f is rising or falling, and the sign of the second derivative f '' tells you which way f curves. Here f(x) = x^3 - a*x is drawn in teal, its slope function f '(x) = 3x^2 - a in orange, and its concavity function f ''(x) = 6x in violet, all on one axis so you can compare signs vertically. The yellow tangent line riding the teal curve has slope exactly equal to the orange curve's height at that x — when orange crosses zero the tangent goes flat (a critical point), and when violet crosses zero (at x = 0) the curve switches from concave-down to concave-up, an inflection point. The slider a slides the two roots of f ' apart or together: increasing a widens the hump-and-valley, and when a drops to 0 the critical points merge and f becomes monotonic. To sketch any function by hand you apply exactly this logic — find where f ' = 0 to locate peaks and valleys, check the sign of f '' there to classify them, and mark f '' = 0 as inflection points — and the moving probe lets you watch those rules light up as x sweeps left to right.", + "bullets": [ + "Where the orange f ' is positive, the teal f rises; where it is negative, f falls.", + "f ' = 0 marks critical points: the yellow tangent goes flat there.", + "The violet f '' sign gives concavity; f '' = 0 at x = 0 is the inflection point.", + "Slider a pushes the two critical points apart; at a = 0 the curve becomes monotonic." + ], + "params": [ + { + "name": "a", + "label": "linear coeff a", + "min": 0, + "max": 6, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst a = P.a;\nconst f = x => x*x*x - a*x;\nconst fp = x => 3*x*x - a;\nconst fpp = x => 6*x;\nconst sweep = 3.2 * Math.sin(t * 0.5);\nconst v = H.plot2d({ xMin: -3.4, xMax: 3.4, yMin: -9, yMax: 9, box: { x: 60, y: 40, w: H.W - 320, h: H.H - 80 } });\nv.grid(); v.axes();\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.fn(fp, { color: H.colors.accent2, width: 2 });\nv.fn(fpp, { color: H.colors.violet, width: 2 });\nconst y0 = f(sweep), s0 = fp(sweep), c0 = fpp(sweep);\nv.dot(sweep, y0, { r: 7, fill: H.colors.accent });\nconst dx = 1.1;\nv.line(sweep - dx, y0 - s0*dx, sweep + dx, y0 + s0*dx, { color: H.colors.yellow, width: 2 });\nv.dot(sweep, fp(sweep), { r: 5, fill: H.colors.accent2 });\nv.dot(sweep, fpp(sweep), { r: 5, fill: H.colors.violet });\nH.legend([\n { label: \"f (curve)\", color: H.colors.accent },\n { label: \"f ' (slope)\", color: H.colors.accent2 },\n { label: \"f '' (concavity)\", color: H.colors.violet }\n], H.W - 250, 60);\nH.text(\"Curve sketching: f, f ', f ''\", 24, 28, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f(x) = x^3 - a*x\", 24, 50, { color: H.colors.sub, size: 13 });\nconst px = H.W - 250;\nH.text(\"at x = \" + sweep.toFixed(2), px, 130, { color: H.colors.sub, size: 13 });\nH.text(\"f ' = \" + s0.toFixed(2) + (s0 > 0 ? \" rising\" : s0 < 0 ? \" falling\" : \" flat\"), px, 152, { color: H.colors.accent2, size: 13 });\nH.text(\"f '' = \" + c0.toFixed(2) + (c0 > 0 ? \" concave up\" : c0 < 0 ? \" concave down\" : \" inflection\"), px, 174, { color: H.colors.violet, size: 13 });" + }, + { + "id": "calc-newtons-method", + "area": "Calculus", + "topic": "Newtons method (root finding)", + "title": "Newton's method: x_{n+1} = x_n - f(x_n)/f '(x_n)", + "equation": "x_{n+1} = x_n - f(x_n) / f '(x_n)", + "keywords": [ + "newtons method", + "newton raphson", + "root finding", + "tangent line method", + "numerical root", + "iteration", + "approximate solution", + "zero of a function", + "fixed point", + "convergence" + ], + "explanation": "Newton's method is a fast way to numerically hunt for a root of f (a place where f(x) = 0) when you cannot solve f(x) = 0 by algebra. The idea is to replace the curve near your current guess with its tangent line — a straight line is trivial to solve — and take where that tangent crosses the x-axis as your improved guess; doing the algebra on the tangent gives the update x_{n+1} = x_n - f(x_n)/f '(x_n). In the animation the teal curve is f(x) = x^3 - a*x - 2, each yellow segment is a tangent slid down to the axis, the dashed grey drop shows the value f(x_n) being corrected, and the green ringed dot is the latest root estimate; the live readout reports x and f(x) shrinking toward zero as the steps accumulate. Because the curve hugs its tangent ever more closely as you near a simple root, the error roughly squares each step — convergence is quadratic, so a couple of iterations usually nail several digits, which is exactly why calculators and solvers use this method under the hood. The cautions to remember when you apply it are visible in the geometry: if f '(x_n) is near zero the tangent is nearly horizontal and the next guess can fly far away, and a poor starting point can converge to a different root than you wanted — so the slider a, which reshapes the curve and moves its roots, lets you see the estimate settle on whichever root the starting tangent happens to point at.", + "bullets": [ + "Each yellow tangent replaces the curve with a line, then is solved for its x-intercept.", + "The new guess is that intercept: x_{n+1} = x_n - f(x_n)/f '(x_n).", + "The green dot and readout show x and f(x) collapsing toward the true root.", + "Near a simple root the error squares each step (quadratic convergence)." + ], + "params": [ + { + "name": "a", + "label": "linear coeff a", + "min": 0, + "max": 5, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst a = P.a;\nconst f = x => x*x*x - a*x - 2;\nconst fp = x => 3*x*x - a;\nconst v = H.plot2d({ xMin: -3, xMax: 4, yMin: -8, yMax: 12 });\nv.grid(); v.axes();\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst x0 = -2.4;\nconst xs = [x0];\nfor (let i = 0; i < 6; i++) {\n const xc = xs[xs.length - 1];\n const d = fp(xc);\n const nx = Math.abs(d) < 1e-6 ? xc : xc - f(xc) / d;\n xs.push(Math.max(-3, Math.min(4, nx)));\n}\nconst steps = Math.min(xs.length - 1, 1 + Math.floor((t % 8) / 1.4));\nfor (let i = 0; i < steps; i++) {\n const xc = xs[i], yc = f(xc), nx = xs[i + 1];\n v.dot(xc, yc, { r: 4, fill: H.colors.accent });\n v.line(xc, yc, nx, 0, { color: H.colors.yellow, width: 2 });\n v.line(xc, 0, xc, yc, { color: H.colors.grid, width: 1, dash: [4, 4] });\n}\nconst cur = xs[steps];\nv.dot(cur, 0, { r: 7, fill: H.colors.good });\nv.circle(cur, 0, 11, { stroke: H.colors.good, width: 2 });\nH.text(\"Newton's method: x_{n+1} = x_n - f(x_n)/f '(x_n)\", 24, 28, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Follow each tangent down to the x-axis to refine the root\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"step \" + steps + \" x = \" + cur.toFixed(5), 24, 74, { color: H.colors.good, size: 13 });\nH.text(\"f(x) = \" + f(cur).toFixed(5), 24, 94, { color: H.colors.sub, size: 13 });" + }, + { + "id": "calc-position-velocity-accel", + "area": "Calculus", + "topic": "Position toward velocity toward acceleration", + "title": "Position -> Velocity -> Acceleration: each is the derivative of the one above", + "equation": "v(t) = x'(t), a(t) = v'(t) = x''(t)", + "keywords": [ + "position velocity acceleration", + "derivative of position", + "rate of change", + "kinematics", + "motion graphs", + "velocity is slope", + "second derivative", + "instantaneous velocity", + "displacement", + "simple harmonic motion" + ], + "explanation": "These three stacked graphs show the central chain of single-variable calculus applied to motion: velocity is the derivative of position and acceleration is the derivative of velocity, so each graph is literally the slope-function of the one above it. The teal x(t) = A*sin(w*t) gives the object's location, the orange v(t) = A*w*cos(w*t) gives how fast and in which direction it is moving, and the violet a(t) = -A*w^2*sin(w*t) gives how the velocity itself is changing; a shared vertical time-cursor and a dot on each curve let you read all three values at the same instant t. The key thing to watch is the alignment of zeros and extremes: where x(t) reaches a peak the object is momentarily stopped, so v(t) crosses zero there, and where v(t) peaks the object is moving fastest, so a(t) crosses zero — each curve's slope is the next curve's height. On the right, a single particle moves up and down its track at position x(t) with an orange velocity arrow whose length and direction match v(t), making the abstract derivative concrete as real movement. You apply this whenever you have motion data: differentiate a position record to get velocity and again to get acceleration (and integrate to go the other way), and the sliders A (amplitude) and w (angular frequency) let you confirm the pattern — raising w steepens x, which scales v by w and a by w^2, exactly as differentiation predicts.", + "bullets": [ + "v(t) is the slope of x(t); a(t) is the slope of v(t) — read each graph from the one above.", + "Where x(t) peaks the object is momentarily at rest, so v(t) = 0 there.", + "The moving particle's arrow length and direction equal the current velocity v(t).", + "Raising w scales velocity by w and acceleration by w^2, the signature of differentiation." + ], + "params": [ + { + "name": "A", + "label": "amplitude A", + "min": 0.5, + "max": 3, + "step": 0.5, + "value": 2 + }, + { + "name": "w", + "label": "angular freq w", + "min": 0.2, + "max": 4, + "step": 0.2, + "value": 1 + } + ], + "code": "H.background();\nconst A = P.A, w = P.w;\nconst pos = tt => A * Math.sin(w * tt);\nconst vel = tt => A * w * Math.cos(w * tt);\nconst acc = tt => -A * w * w * Math.sin(w * tt);\nconst tMax = 6.2832 / Math.max(w, 0.2);\nconst now = (t * 0.6) % tMax;\nconst colW = H.W - 200;\nconst ph = (H.H - 70) / 3;\nconst ymax = A * Math.max(1, w, w * w) * 1.15 + 0.1;\nconst panel = (yTop, fn, col, label) => {\n const view = H.plot2d({ xMin: 0, xMax: tMax, yMin: -ymax, yMax: ymax,\n box: { x: 60, y: yTop, w: colW, h: ph - 18 } });\n view.grid(); view.axes();\n view.fn(fn, { color: col, width: 3 });\n view.dot(now, fn(now), { r: 6, fill: col });\n view.line(now, -ymax, now, ymax, { color: H.colors.grid, width: 1, dash: [3, 3] });\n H.text(label + \" = \" + fn(now).toFixed(2), 70, yTop + 4, { color: col, size: 13, weight: 700 });\n};\npanel(54, pos, H.colors.accent, \"x(t)\");\npanel(54 + ph, vel, H.colors.accent2, \"v(t)\");\npanel(54 + 2 * ph, acc, H.colors.violet, \"a(t)\");\nconst px = H.W - 130, mid = H.H / 2;\nH.line(px, 70, px, H.H - 16, { color: H.colors.grid, width: 1 });\nconst scale = ph * 0.7 / ymax;\nconst py = mid - pos(now) * scale;\nH.circle(px, py, 9, { fill: H.colors.accent });\nH.arrow(px, py, px, py - vel(now) * scale, { color: H.colors.accent2, width: 3 });\nH.text(\"particle\", px - 20, 54, { color: H.colors.sub, size: 12 });\nH.text(\"Position -> Velocity -> Acceleration\", 24, 28, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"t = \" + now.toFixed(2) + \"s v is slope of x, a is slope of v\", 320, 28, { color: H.colors.sub, size: 13 });" + } +] \ No newline at end of file diff --git a/index.html b/index.html index a6ff235..c28611d 100644 --- a/index.html +++ b/index.html @@ -17,7 +17,16 @@ response headers for that), so the worker can still use `new Function` to compile generated code. --> - + + VisualLM @@ -25,10 +34,10 @@ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" /> - + @@ -158,36 +189,83 @@

Try an example

+ + + + + +
@@ -199,6 +277,16 @@

Reference material

+ + + + + +
@@ -258,6 +356,7 @@

Quick questions

- + + diff --git a/install.bat b/install.bat new file mode 100644 index 0000000..62e1564 --- /dev/null +++ b/install.bat @@ -0,0 +1,85 @@ +@echo off +REM =========================================================================== +REM VisualLM - one-shot dependency installer (Windows) +REM +REM install.bat +REM +REM Creates an isolated virtual environment (.venv) and downloads everything +REM VisualLM needs to run as a desktop app: +REM +REM * anthropic - the Claude API client that powers the best animations. +REM VisualLM relies ONLY on code: with a key it calls Claude, +REM without one it uses the built-in pure-code library +REM (interactive demos + 3D chemistry + step-by-step solver). +REM No local model app (Ollama etc.) is required or used. +REM * pywebview - opens VisualLM in a true native OS window (WebView2). +REM Optional: if it fails to install, the app opens an +REM app-style browser window instead. +REM +REM The server itself is pure Python standard library - nothing else needed. +REM +REM After it finishes: +REM .venv\Scripts\activate +REM python desktop.py +REM =========================================================================== +setlocal +cd /d "%~dp0" + +REM --- 1. Find a Python 3 interpreter ---------------------------------------- +set "PY=" +where py >nul 2>&1 && set "PY=py -3" +if not defined PY ( + where python >nul 2>&1 && set "PY=python" +) +if not defined PY ( + echo ! Python 3.9+ was not found on your PATH. + echo Install it from https://www.python.org/downloads/ ^(check "Add to PATH"^) + echo and re-run install.bat + exit /b 1 +) +echo - Using Python: %PY% + +REM --- 2. Create / reuse a virtual environment ------------------------------- +if not exist ".venv" ( + echo - Creating virtual environment ^(.venv^) ... + %PY% -m venv .venv || (echo ! Failed to create venv & exit /b 1) +) else ( + echo - Reusing existing virtual environment ^(.venv^) +) +set "VENV_PY=.venv\Scripts\python.exe" + +echo - Upgrading pip ... +"%VENV_PY%" -m pip install --quiet --upgrade pip + +REM --- 3. Core dependency: the Claude client --------------------------------- +echo - Installing core dependencies ^(anthropic^) ... +"%VENV_PY%" -m pip install --quiet -r requirements.txt || (echo ! Failed to install core deps & exit /b 1) +echo + Core dependencies installed. + +REM --- 4. Optional: native-window backend ------------------------------------ +echo - Installing the native-window backend ^(pywebview^) ... +"%VENV_PY%" -m pip install --quiet pywebview && ( + echo + pywebview installed - desktop.py will open a true native window. +) || ( + echo ! pywebview could not be installed ^(that's OK^). + echo VisualLM will open an app-style browser window instead. +) + +REM NOTE: we deliberately do NOT create a .env. With no key, VisualLM runs cleanly +REM in code-only mode. When you want AI generation, copy .env.example to .env and +REM add a real key (a placeholder key would make the app try - and fail - Claude). + +echo. +echo -------------------------------------------------------------------------- +echo + VisualLM is ready. +echo. +echo Run the desktop app: +echo .venv\Scripts\activate +echo python desktop.py +echo. +echo No API key? It still runs - you get the built-in pure-code library +echo (interactive demos, 3D chemistry, step-by-step solver), all offline. +echo For AI animations: copy .env.example .env then add your real +echo ANTHROPIC_API_KEY=sk-ant-... to .env +echo -------------------------------------------------------------------------- +endlocal diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..9255fb4 --- /dev/null +++ b/install.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# ============================================================================ +# VisualLM — one-shot dependency installer (macOS / Linux) +# +# ./install.sh +# +# Creates an isolated virtual environment (.venv) and downloads everything +# VisualLM needs to run as a desktop app: +# +# • anthropic — the Claude API client that powers the best animations. +# VisualLM relies ONLY on code: with a key it calls Claude, +# without one it uses the built-in pure-code library +# (interactive demos + chemistry + step-by-step solver). +# No local model app (Ollama etc.) is required or used. +# • pywebview — opens VisualLM in a true native OS window. Optional: if it +# fails to build, the app still opens an app-style browser +# window, so the install continues either way. +# +# The server itself is pure Python standard library — nothing else needed. +# +# After it finishes: +# source .venv/bin/activate +# python3 desktop.py # native window (or ./VisualLM.command) +# ============================================================================ +set -euo pipefail +cd "$(dirname "$0")" + +say() { printf '\033[1m• %s\033[0m\n' "$*"; } +ok() { printf '\033[32m✓ %s\033[0m\n' "$*"; } +warn(){ printf '\033[33m! %s\033[0m\n' "$*"; } + +# --- 1. Find a Python 3 interpreter ----------------------------------------- +PY="" +for cand in python3 python; do + if command -v "$cand" >/dev/null 2>&1; then + if "$cand" -c 'import sys; raise SystemExit(0 if sys.version_info[:2] >= (3, 9) else 1)' 2>/dev/null; then + PY="$cand"; break + fi + fi +done +if [ -z "$PY" ]; then + warn "Python 3.9+ was not found on your PATH." + echo " Install it from https://www.python.org/downloads/ (or 'brew install python')" + echo " and re-run ./install.sh" + exit 1 +fi +ok "Using $("$PY" --version 2>&1) at $(command -v "$PY")" + +# --- 2. Create / reuse a virtual environment -------------------------------- +if [ ! -d ".venv" ]; then + say "Creating virtual environment (.venv) …" + "$PY" -m venv .venv +else + say "Reusing existing virtual environment (.venv)" +fi +# shellcheck disable=SC1091 +source .venv/bin/activate +VENV_PY="$(command -v python)" + +say "Upgrading pip …" +"$VENV_PY" -m pip install --quiet --upgrade pip + +# --- 3. Core dependency: the Claude client ---------------------------------- +say "Installing core dependencies (anthropic) …" +"$VENV_PY" -m pip install --quiet -r requirements.txt +ok "Core dependencies installed." + +# --- 4. Optional: native-window backend ------------------------------------- +say "Installing the native-window backend (pywebview) …" +if "$VENV_PY" -m pip install --quiet pywebview; then + ok "pywebview installed — desktop.py will open a true native window." +else + warn "pywebview could not be installed (that's OK)." + echo " VisualLM will open an app-style browser window instead." +fi + +# NOTE: we deliberately do NOT create a .env. With no key, VisualLM runs cleanly +# in code-only mode. When you want AI generation, copy .env.example to .env and +# add a real key (a placeholder key would make the app try — and fail — Claude). + +cat <<'DONE' + +────────────────────────────────────────────────────────────────────────── +✓ VisualLM is ready. + + Run the desktop app: + source .venv/bin/activate + python3 desktop.py # native window + # …or: ./VisualLM.command (double-click in Finder on macOS) + # …or: python3 launch.py (app-style browser window) + + No API key? It still runs — you'll get the built-in pure-code library + (interactive demos, 3D chemistry, and the step-by-step solver), all offline. + For AI-generated animations: cp .env.example .env then put your real + ANTHROPIC_API_KEY=sk-ant-... in .env +────────────────────────────────────────────────────────────────────────── +DONE diff --git a/launch.py b/launch.py new file mode 100755 index 0000000..7887503 --- /dev/null +++ b/launch.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""One-click launcher for VisualLM. + +Run it: + python3 launch.py # start the server + open the app in a window + ./VisualLM.command # macOS: just double-click this in Finder + +It loads API keys from a `.env` file next to this script (if present), starts the +local server (main.py), waits until it's healthy, then opens the UI — preferring +a chrome-less "app window" (Chrome / Edge / Brave --app mode) and falling back to +your default browser. Leave the launcher running; Ctrl+C stops everything. + +Flags: + --no-browser start the server but don't open a window + --smoke start, confirm healthy, stop, exit 0 (used for testing) +""" +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +import webbrowser +from pathlib import Path + +HERE = Path(__file__).resolve().parent + + +def load_dotenv(path: Path) -> None: + """Load simple KEY=VALUE lines from `.env` into the environment. + + Does NOT override variables already set in the real environment, so an + explicit `export ANTHROPIC_API_KEY=...` still wins over the file. + """ + if not path.exists(): + return + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + os.environ.setdefault(key.strip(), val.strip().strip('"').strip("'")) + + +def free_port(preferred: int) -> int: + """Return `preferred` if it's bindable, otherwise an OS-chosen free port.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("127.0.0.1", preferred)) + return preferred + except OSError: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def wait_healthy(url: str, timeout: float = 25.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=1.5) as r: + if r.status == 200: + return True + except (urllib.error.URLError, OSError): + pass + time.sleep(0.25) + return False + + +def open_app_window(url: str) -> None: + """Open `url` in a chrome-less app window if a Chromium browser is present, + else fall back to the default browser.""" + candidates = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + shutil.which("google-chrome"), + shutil.which("google-chrome-stable"), + shutil.which("chromium"), + shutil.which("chromium-browser"), + shutil.which("microsoft-edge"), + shutil.which("brave-browser"), + ] + for c in candidates: + if c and Path(c).exists(): + try: + subprocess.Popen( + [c, f"--app={url}", "--new-window"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return + except OSError: + pass + webbrowser.open(url) + + +def main() -> int: + load_dotenv(HERE / ".env") + + forced = os.environ.get("VISUALLM_PORT") + port = int(forced) if forced else free_port(4173) + os.environ["VISUALLM_PORT"] = str(port) + os.environ.setdefault("VISUALLM_HOST", "127.0.0.1") + url = f"http://127.0.0.1:{port}" + no_browser = "--no-browser" in sys.argv or "--smoke" in sys.argv + smoke = "--smoke" in sys.argv + + if not any(os.environ.get(k) for k in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY")): + print("• No cloud API key found — running in code-only mode. Chemistry,") + print(" reactions, the step-by-step solver, and the demo library all work") + print(" offline. For AI generation of free-form prompts, put") + print(" ANTHROPIC_API_KEY=sk-ant-... in a .env file next to launch.py") + print(" (copy .env.example to .env). See README for details.\n") + + print(f"• Starting VisualLM on {url} …") + server = subprocess.Popen([sys.executable, str(HERE / "main.py")], cwd=str(HERE)) + try: + if not wait_healthy(f"{url}/api/health"): + print("✗ The server did not become healthy in time — see output above.") + return 1 + print("✓ VisualLM is up.") + if smoke: + print("✓ Smoke check passed.") + return 0 + if no_browser: + print(f" Open {url} in your browser.") + else: + print("• Opening the app window …") + open_app_window(url) + print("\nVisualLM is running. Keep this window open. Press Ctrl+C to stop.\n") + server.wait() + except KeyboardInterrupt: + print("\n• Stopping VisualLM …") + finally: + if server.poll() is None: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/main.py b/main.py index 70ec7b7..3954424 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ import re import shutil import subprocess +import sys import textwrap import threading import time @@ -21,7 +22,16 @@ from pathlib import Path -BASE_DIR = Path(__file__).resolve().parent +# When bundled by PyInstaller, the static assets + validate_scene.js live in the +# unpacked bundle dir (sys._MEIPASS), not beside a source file. Harmless when +# not frozen (falls back to this file's directory). _MEIPASS is injected by the +# PyInstaller bootloader at runtime and isn't in the type stubs, so read it via +# getattr to keep static type-checkers (Pylance/Pyright) quiet. +_meipass = getattr(sys, "_MEIPASS", None) +if getattr(sys, "frozen", False) and _meipass: + BASE_DIR = Path(_meipass) +else: + BASE_DIR = Path(__file__).resolve().parent # Hard caps for the in-memory resource store (single-user local app). MAX_RESOURCES = 12 @@ -35,15 +45,6 @@ # thread on `rfile.read(huge_n)` (which would either OOM or wait forever). MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024 -# --- Ollama (local) is used for the tutor chat and as an offline fallback. --- -OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434").rstrip("/") -PREFERRED_MODELS = ( - "qwen2.5:7b", - "llama3.2", - "llama3.1:8b", - "phi4-mini", -) - # --- Claude (cloud) is the primary brain for generating animation code. --- ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-opus-4-8") # Generation depth/latency lever. Opus 4.8 effort levels: low|medium|high|xhigh|max. @@ -70,8 +71,8 @@ # Abuse protection (the app may be exposed to the public internet) # # ===================================================================== # # -# Every /api POST burns either local compute (Ollama) or the operator's paid -# API credits (Claude/OpenAI/Gemini), so when published we (a) rate-limit per +# Every /api POST can burn the operator's paid API credits (Claude/OpenAI/ +# Gemini), so when published we (a) rate-limit per # client IP with a sliding 60 s window and (b) optionally require a shared # access code (VISUALLM_ACCESS_CODE) so only people you invite can generate. @@ -154,60 +155,12 @@ def send_json(handler: SimpleHTTPRequestHandler, status: HTTPStatus, payload: di pass -# ===================================================================== # -# Ollama bridge # -# ===================================================================== # - - -def ollama_request(path: str, payload: dict | None = None, timeout: float = 60.0) -> dict: - body = None if payload is None else json.dumps(payload).encode("utf-8") - request = urllib.request.Request( - f"{OLLAMA_URL}{path}", - data=body, - headers={"Content-Type": "application/json"}, - method="POST" if body is not None else "GET", - ) - try: - with urllib.request.urlopen(request, timeout=timeout) as response: - return json.loads(response.read().decode("utf-8")) - except urllib.error.HTTPError as error: - details = error.read().decode("utf-8", errors="replace").strip() - raise RuntimeError(details or f"Ollama returned HTTP {error.code}.") from error - except urllib.error.URLError as error: - reason = getattr(error, "reason", error) - raise RuntimeError(f"Could not reach Ollama at {OLLAMA_URL}. {reason}") from error - - -def fetch_ollama_models() -> list[dict]: - response = ollama_request("/api/tags", timeout=10.0) - return response.get("models", []) - - -def choose_ollama_model(models: list[dict]) -> str: - configured = os.environ.get("OLLAMA_MODEL", "").strip() - if configured: - return configured - available = [m.get("name", "") for m in models] - for candidate in PREFERRED_MODELS: - if candidate in available: - return candidate - return available[0] if available else PREFERRED_MODELS[0] - - -def ollama_available() -> tuple[bool, str | None]: - try: - fetch_ollama_models() - return True, None - except RuntimeError as error: - return False, str(error) - - # ===================================================================== # # Resource store (uploaded reference material) # # ===================================================================== # # # Single-user, in-memory. Files are kept as text so both the generator -# (Claude) and the tutor (Ollama/Claude) can ground their output in them. +# (Claude) and the tutor (Claude/OpenAI/Gemini) can ground their output in them. # Binary files are rejected; users upload notes, problem sets, lecture # excerpts, code, datasets — anything text-ish. @@ -420,6 +373,13 @@ def claude_available() -> dict: the EXPLANATION: sweep the highlight through the parts, pulse the region being discussed, orbit the camera, or step through stages with `const phase = Math.floor(t % 9 / 3);`. + - KEEP MOVING SUBJECTS IN FRAME. Motion must LOOP, never drift away. Do NOT + write `x = speed * t` (the object sails off-screen and never comes back). + Instead oscillate — `x = A * Math.sin(t)` — or wrap — `x = (t * v) % span` + — or reset a phase with `t % period`. A car passing an observer should + loop back and pass again; a wave should keep propagating across the SAME + visible window. If after a few seconds your main subject would leave the + canvas, the scene is rejected. ### Rule 2 — every scene MUST BE LABELED with real values @@ -518,6 +478,26 @@ def claude_available() -> dict: ## Style and pedagogy + ### Teach the mechanism — do not just solve their problem + + The point of every scene is to help the learner UNDERSTAND, not to be an + answer key. If the prompt is really a specific problem ("a ball thrown at + 22 m/s at 58 deg, find the range"), do NOT just compute the number and show + it as the answer — that does the student's work for them. Instead reveal the + MECHANISM that produces it: the parabola forming, the velocity components, + why it peaks where it does, so the learner can see the structure and reason + to the result themselves. Prefer the general relationship over a single + instance — SWEEP the parameter (let the point `a`, the angle, or the + harmonic count vary with `t`) so they watch how it behaves across cases, not + only at their one value. Make labels EXPLAIN ("slope = f'(a): watch it flip + sign at the peak"), not just state a result; use the bullets and + student_prompts to provoke prediction, not to spoon-feed the solution. + + This is NOT a license to be vague. Scenes stay concrete, runnable, and + labeled, and a live readout of a CHANGING quantity is good teaching — it + makes the relationship visible. The line: illuminate the mechanism (teach) + vs. hand over the one specific answer to their assigned problem (solve). + - Teach the idea. Label axes, key points, and quantities with `H.text`. - Animate the MECHANISM (a moving particle, sweeping angle, growing sum, propagating wave, rotating object), not just a static picture. @@ -762,9 +742,9 @@ def repair_with_claude(prompt: str, code: str, error: str) -> dict: return claude_call_scene([{"type": "text", "text": user_text}]) -# --- Ollama fallback generation (best-effort; lower quality than Claude). --- +# --- Shared strict-JSON scene prompt for the non-Claude cloud generators. --- -OLLAMA_SCENE_PROMPT = ( +JSON_SCENE_PROMPT = ( SCENE_SYSTEM_PROMPT + "\n\nReturn STRICT JSON only with keys: title, tag, dimension, equation, " "summary, bullets (array of 3 strings), student_prompts (array of 3 strings), " @@ -783,8 +763,8 @@ def _extract_json_object(raw_text: str) -> dict: # Brace-balanced walk: find each complete top-level {...} segment and try # to parse it. Handles "prose {a} more {b}" (find+rfind would have sliced # both objects into one unparseable blob) and braces inside JSON string - # values. Raises json.JSONDecodeError so callers (e.g. generate_with_ollama) - # can decide whether to retry. + # values. Raises json.JSONDecodeError so callers (e.g. the OpenAI/Gemini + # generators) can decide whether to retry. depth = 0 start = -1 in_str = False @@ -818,71 +798,6 @@ def _extract_json_object(raw_text: str) -> dict: raise json.JSONDecodeError("No parseable JSON object in model output", text, 0) -def generate_with_ollama(prompt: str, preferred_mode: str, fix: dict | None = None) -> dict: - models = fetch_ollama_models() - model_name = choose_ollama_model(models) - if fix: - user = ( - "Your animation code threw an error. Fix it and return the full scene " - "as strict JSON.\n\nRequest:\n" + prompt + "\n\nError:\n" + fix["error"] - + "\n\nHow to fix it:\n" + _repair_hint(fix["error"]) - + "\n\nBroken code:\n" + fix["code"] - ) - else: - user = ( - "Create a STEM visualization for this request:\n\n" - + prompt - + "\n\n" - + _mode_hint(preferred_mode) - ) - resources = resources_context_block() - if resources: - user += "\n\n" + resources - payload = { - "model": model_name, - "stream": False, - "format": "json", - "messages": [ - {"role": "system", "content": OLLAMA_SCENE_PROMPT}, - {"role": "user", "content": user}, - ], - "options": {"temperature": 0.2}, - # Tell Ollama to keep the model resident for a while after the - # call returns. The next visualize/repair won't pay the cold-load. - "keep_alive": "30m", - } - # Retry once on JSON parse failure: `format: json` produces valid JSON - # most of the time, but sampling occasionally emits unterminated strings - # / unescaped quotes that break json.loads. A single re-sample at the - # same temperature usually succeeds and is much cheaper than surfacing - # a hard "Could not generate" to the user. - last_parse_err: json.JSONDecodeError | None = None - for attempt in range(2): - response = ollama_request( - "/api/chat", - payload=payload, - # Generous timeout: a cold load of a 7B model + a complex JSON-mode - # generation can take well over 2 minutes the first time. Subsequent - # calls run in 10-30s. - timeout=420.0, - ) - content = response.get("message", {}).get("content") - if not isinstance(content, str): - raise RuntimeError("Ollama returned no visualization.") - try: - plan = _extract_json_object(content) - except json.JSONDecodeError as err: - last_parse_err = err - continue - plan["model"] = model_name - plan["engine"] = "ollama" - return plan - # Both attempts produced unparseable JSON — surface the latest parse error. - raise RuntimeError( - f"Ollama produced invalid JSON twice in a row: {last_parse_err}" - ) - - # ===================================================================== # # OpenAI / Gemini bridges (optional cloud fallbacks) # # ===================================================================== # @@ -1003,7 +918,7 @@ def _scene_user_text(prompt: str, preferred_mode: str, fix: dict | None) -> str: def generate_with_openai(prompt: str, preferred_mode: str, fix: dict | None = None) -> dict: raw = _openai_chat( - OLLAMA_SCENE_PROMPT, + JSON_SCENE_PROMPT, [{"role": "user", "content": _scene_user_text(prompt, preferred_mode, fix)}], json_mode=True, ) @@ -1015,7 +930,7 @@ def generate_with_openai(prompt: str, preferred_mode: str, fix: dict | None = No def generate_with_gemini(prompt: str, preferred_mode: str, fix: dict | None = None) -> dict: raw = _gemini_generate( - OLLAMA_SCENE_PROMPT, + JSON_SCENE_PROMPT, [{"role": "user", "content": _scene_user_text(prompt, preferred_mode, fix)}], json_mode=True, ) @@ -1078,25 +993,42 @@ def _apply_outside_strings(code: str, transform) -> str: return "".join(out) -def _autofix_math(segment: str) -> str: - segment = _MATH_FN_RE.sub(r"Math.\1\2", segment) - segment = _BARE_PI_RE.sub("Math.PI", segment) - segment = _BARE_TAU_RE.sub("H.TAU", segment) +def _autofix_math(segment: str, declared: frozenset = frozenset()) -> str: + # Never rewrite a name the scene declares itself: a local `const PI = ...` + # must not become `const Math.PI = ...` (a syntax error), and a local + # `function log(){}` must not be rewritten to `Math.log`. + def _fn(m): + name = m.group(1) + return m.group(0) if name in declared else "Math." + name + m.group(2) + + segment = _MATH_FN_RE.sub(_fn, segment) + if "PI" not in declared: + segment = _BARE_PI_RE.sub("Math.PI", segment) + if "TAU" not in declared: + segment = _BARE_TAU_RE.sub("H.TAU", segment) return segment +# Names the scene declares for itself — excluded from the bare-Math rewrite so +# we never corrupt an alias like `const PI = Math.PI` or a local `function sin`. +_DECLARED_RE = re.compile(r"\b(?:const|let|var|function)\s+([A-Za-z_$][\w$]*)") + + def autofix_code(code: str) -> str: """Mechanically fix high-confidence errors without a model round-trip. Currently: prefix bare Math functions/constants (the dominant "X is not defined" cause). Applied outside strings/comments so labels and - comments are never corrupted. Idempotent — running it on already-correct - code is a no-op. + comments are never corrupted, and never to a name the scene declares itself + (so a local `const PI`/`const TAU`/`function log` is left intact rather than + rewritten into invalid syntax or the wrong call). Idempotent — running it on + already-correct code is a no-op. """ if not code: return code try: - return _apply_outside_strings(code, _autofix_math) + declared = frozenset(_DECLARED_RE.findall(code)) + return _apply_outside_strings(code, lambda seg: _autofix_math(seg, declared)) except re.error: return code @@ -1132,11 +1064,16 @@ def headless_validate(code: str, timeout: float = 6.0) -> dict | None: validator is unavailable or itself failed (so callers skip the gate rather than wrongly reject a scene). """ - if not code or not code.strip() or not node_validator_available(): + # Rebind to a local so the guard narrows it to `str` for the type checker: + # Pylance/pyright can't carry the non-None proof across the separate + # node_validator_available() function (it's a module global). This inline + # check is De Morgan-equivalent to `not node_validator_available()`. + node_bin = _NODE_BIN + if not code or not code.strip() or not node_bin or not _VALIDATOR_PATH.exists(): return None try: proc = subprocess.run( - [_NODE_BIN, str(_VALIDATOR_PATH)], + [node_bin, str(_VALIDATOR_PATH)], input=code.encode("utf-8"), stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -1174,14 +1111,69 @@ def _rewrite_declared_names(span: str) -> str: const W = H.W, H = H.H; (the multi-decl that causes TDZ) const W = H.W,\n H = H.H; (multi-line) - A binding identifier is one that appears either: - - right after `const|let|var ` (the first declarator), or - - right after a comma at the top level of the statement. + A binding is a reserved name at bracket depth 0 that is either the first + declarator (right after const/let/var) or follows a top-level comma. This is + DEPTH-AWARE on purpose: a reserved name used as a VALUE inside ()/[]/{} — + `H.lerp(a, b, t)`, `[x, t]`, `H.map(x, 0, 10, t)` — is NOT a declarator and + must be left alone, or it becomes an undefined reference (`_t`). The old + comma-matching regex renamed those and silently broke very common scenes. """ - pattern = re.compile( - r"(\b(?:const|let|var)\s+|,\s*)(" + "|".join(_RESERVED_BINDINGS) + r")\b" - ) - return pattern.sub(lambda m: f"{m.group(1)}_{m.group(2)}", span) + m = re.match(r"\s*(?:const|let|var)\b", span) + if not m: + return span + reserved = set(_RESERVED_BINDINGS) + out = [span[: m.end()]] + i, n = m.end(), len(span) + depth = 0 + quote = None # active string/template delimiter, or None + expect = True # the next depth-0 identifier is a declarator binding + while i < n: + ch = span[i] + if quote is not None: + out.append(ch) + if ch == "\\" and i + 1 < n: + out.append(span[i + 1]) + i += 2 + continue + if ch == quote: + quote = None + i += 1 + continue + if ch in "\"'`": + quote = ch + out.append(ch) + i += 1 + continue + if ch in "([{": + depth += 1 + expect = False + out.append(ch) + i += 1 + continue + if ch in ")]}": + depth -= 1 + out.append(ch) + i += 1 + continue + if ch == "," and depth == 0: + expect = True + out.append(ch) + i += 1 + continue + if expect and depth == 0 and (ch.isalpha() or ch in "_$"): + j = i + while j < n and (span[j].isalnum() or span[j] in "_$"): + j += 1 + ident = span[i:j] + out.append("_" + ident if ident in reserved else ident) + expect = False + i = j + continue + if not ch.isspace(): + expect = False + out.append(ch) + i += 1 + return "".join(out) def sanitize_code(code: object) -> str: @@ -1389,11 +1381,15 @@ def evaluate_scene(code: str) -> dict: return { "fatal": True, "error": ( - "Everything was drawn OFF-SCREEN — you mixed coordinate spaces. " - "H.line/H.circle/H.text take PIXEL coords (0..H.W, 0..H.H). The " - "plot2d view's methods (v.line/v.text/v.dot/v.path/v.circle) take " - "DATA coords and map them for you — do NOT wrap their arguments in " - "v.X()/v.Y(). Pick one space per call and keep points on-canvas." + "The content ended up OFF-SCREEN. Two common causes: (1) mixing " + "coordinate spaces — H.line/H.circle/H.text take PIXEL coords " + "(0..H.W, 0..H.H), while the plot2d view methods " + "(v.line/v.text/v.dot/v.path) take DATA coords and map them for " + "you, so never wrap their args in v.X()/v.Y(); (2) UNBOUNDED " + "motion that drifts away — e.g. pos = t*4 sails off the edge. " + "Make motion LOOP: use t % period, Math.sin(t), or keep the " + "moving subject within the visible range. Keep the main content " + "on-canvas the whole time." ), "problems": [], } @@ -1586,6 +1582,59 @@ def _cloud_generators() -> list[tuple[str, object]]: except Exception: # noqa: BLE001 — library is optional; never block startup SCENE_LIBRARY = [] +# Parameterized "interactive textbook" demos (Algebra 1 -> Precalculus). When a +# prompt classifies to a curriculum topic, we show one of these — the student +# edits its values live (the `params`) and reads the explanation — instead of +# generating code. This is the retrieval-first, fill-in-the-values path. +try: + from demo_library import DEMO_LIBRARY # type: ignore +except Exception: # noqa: BLE001 — optional; never block startup + DEMO_LIBRARY = [] + +# Study Packs (DLC): browse/launch curated + custom demo packs, and generate a +# custom pack from uploaded material (AI analysis). See dlc.py. +try: + import dlc as _dlc # type: ignore +except Exception: # noqa: BLE001 + _dlc = None + +_DLC_INDEX_CACHE: "list[dict] | None" = None + + +def _library_index() -> list[dict]: + """A light index (id/area/topic/title/equation) for DLC pack resolution.""" + global _DLC_INDEX_CACHE + if _DLC_INDEX_CACHE is None: + _DLC_INDEX_CACHE = [ + { + "id": d.get("id"), + "area": d.get("area"), + "topic": d.get("topic"), + "title": d.get("title"), + "equation": d.get("equation", ""), + } + for d in DEMO_LIBRARY + ] + return _DLC_INDEX_CACHE + +# Chemistry: a chemical formula renders a 3D molecular structure; a reaction is +# balanced and shown as reactants -> products with conservation. Known-correct +# (parsed/computed server-side), so it short-circuits like the demo/library path. +try: + from chemistry import chemistry_scene as _chemistry_scene # type: ignore +except Exception: # noqa: BLE001 — optional; never block startup + def _chemistry_scene(_prompt): # type: ignore + return None + +# Worked-solution slides: a SPECIFIC problem (real numbers + labels, e.g. a +# triangle with given sides/angles) renders an animated step-by-step solution +# using the student's own numbers — distinct from the topic demos. +try: + from solver import solver_scene as _solver_scene # type: ignore +except Exception: # noqa: BLE001 — optional; never block startup + def _solver_scene(_prompt): # type: ignore + return None + _scene_cache_lock = threading.Lock() _scene_cache: dict[tuple, dict] = {} _SCENE_CACHE_MAX = 256 @@ -1622,8 +1671,64 @@ def _tokenize(s: str) -> set: "draw plot with for as its it this that what why over time using see".split() ) +# --- Equation-form matching ------------------------------------------------ +# A bare equation ("E=mc^2", "F=ma", "PV=nRT") tokenizes to junk ({e,mc,2}) that +# misses keyword/title matching entirely. So we ALSO match on a normalized +# equation signature: pull the equation token out of the prompt, normalize it +# (keeping '=' so the match is anchored and false-positive-resistant), and +# compare it to each demo's normalized equation / title / equation-keywords. +_EQ_SUB = str.maketrans({"²": "2", "³": "3", "⁴": "4", "λ": "l", "·": "", "×": "", "−": "-", "–": "-"}) + + +def _norm_eq(s: str) -> str: + s = (s or "").lower().translate(_EQ_SUB) + return re.sub(r"[^a-z0-9=+/]", "", s) + -def _scene_score(sc: dict, ptext: str, ptoks: set, mode: str) -> float: +def _prompt_equation(prompt: str) -> str: + """Extract + normalize the equation token from a prompt, or '' if none. + 'show me E=mc^2' -> 'e=mc2'; 'F = ma' -> 'f=ma'.""" + if not prompt or "=" not in prompt: + return "" + tight = re.sub(r"\s*([=+\-*/^·×])\s*", r"\1", prompt) + cands = [tok for tok in tight.split() if "=" in tok] + if not cands: + return "" + return _norm_eq(max(cands, key=len)) + + +def _sort_runs(s: str) -> str: + """Sort each run of >=2 letters so a product is order-independent: + 'mc2' and 'cm2' both -> 'cm2' (handles E=CM^2 written for E=mc^2).""" + return re.sub(r"[a-z]{2,}", lambda m: "".join(sorted(m.group(0))), s) + + +def _equation_boost(sc: dict, peq: str) -> float: + """Score boost if the prompt's equation matches this scene's equation form.""" + if not peq or len(peq) < 4: + return 0.0 + sigs = [_norm_eq(sc.get("equation", "")), _norm_eq(sc.get("title", ""))] + sigs += [_norm_eq(k) for k in sc.get("keywords", []) if "=" in str(k)] + sigs = [s for s in sigs if s] + boost = 0.0 + for sig in sigs: + if peq == sig: + return 6.0 + if peq in sig or (len(sig) >= 4 and sig in peq): + boost = max(boost, 5.0) + if boost == 0.0: + # Fall back to order-independent (commutative-product) matching. + peqs = _sort_runs(peq) + for sig in sigs: + ss = _sort_runs(sig) + if peqs == ss: + return 5.0 + if (len(peqs) >= 4 and peqs in ss) or (len(ss) >= 4 and ss in peqs): + boost = max(boost, 4.0) + return boost + + +def _scene_score(sc: dict, ptext: str, ptoks: set, mode: str, peq: str = "") -> float: score = 0.0 for kw in sc.get("keywords", []): k = kw.lower().strip() @@ -1639,19 +1744,41 @@ def _scene_score(sc: dict, ptext: str, ptoks: set, mode: str) -> float: tag_toks = _tokenize(sc.get("tag", "")) - _MATCH_STOPWORDS score += 1.0 * len(title_toks & ptoks) score += 0.5 * len(tag_toks & ptoks) + # A bare/embedded equation matches the scene's equation form directly. + score += _equation_boost(sc, peq) # Respect an explicit 2D/3D preference. if mode in ("2d", "3d") and sc.get("dimension", "").lower() != mode: score -= 1.5 return score +# Everyday words that are ALSO demo keywords. A long natural-language prompt +# ("how do vaccines work", "power dynamics in a relationship") whose only match +# comes from one of these shouldn't confidently serve a wrong demo. +_WEAK_TOPIC_WORDS = frozenset( + "work power field function real point line value model rate range table mean series root".split() +) + + +def _weak_prose_match(prompt: str, sc: dict, score: float) -> bool: + """True if a borderline match for a multi-word sentence rests ONLY on common + everyday words — in which case we'd rather show the honest 'no clear topic' + fallback than a confidently-wrong demo.""" + if score > 2.0 or len((prompt or "").split()) < 4: + return False + ptext = " " + re.sub(r"\s+", " ", (prompt or "").lower()) + " " + ptoks2 = (_tokenize(prompt) - _MATCH_STOPWORDS) - _WEAK_TOPIC_WORDS + return _scene_score(sc, ptext, ptoks2, "auto", _prompt_equation(prompt)) < 2.0 + + def _library_scored(prompt: str, mode: str) -> list[tuple[dict, float]]: """All library scenes scored against the prompt, best first (score > 0).""" if not SCENE_LIBRARY: return [] ptext = " " + re.sub(r"\s+", " ", (prompt or "").lower()) + " " ptoks = _tokenize(prompt) - _MATCH_STOPWORDS - scored = [(sc, _scene_score(sc, ptext, ptoks, mode)) for sc in SCENE_LIBRARY] + peq = _prompt_equation(prompt) + scored = [(sc, _scene_score(sc, ptext, ptoks, mode, peq)) for sc in SCENE_LIBRARY] scored = [t for t in scored if t[1] > 0] scored.sort(key=lambda t: t[1], reverse=True) return scored @@ -1684,6 +1811,109 @@ def _has_cloud() -> bool: return bool(_cloud_generators()) +# --- Parameterized demo classification (the "fill in the values" path) ------- + +def _demo_scored(prompt: str) -> list[tuple[dict, float]]: + """All demos scored against the prompt, best first (score > 0). Reuses the + library scorer; mode is 'auto' so a 2D demo isn't penalized by a 3D hint.""" + if not DEMO_LIBRARY: + return [] + ptext = " " + re.sub(r"\s+", " ", (prompt or "").lower()) + " " + ptoks = _tokenize(prompt) - _MATCH_STOPWORDS + peq = _prompt_equation(prompt) + scored = [(d, _scene_score(d, ptext, ptoks, "auto", peq)) for d in DEMO_LIBRARY] + scored = [t for t in scored if t[1] > 0] + scored.sort(key=lambda t: t[1], reverse=True) + return scored + + +def demo_match(prompt: str) -> tuple[dict | None, float]: + """Classify a prompt to its best curriculum demo (None = no clear topic).""" + scored = _demo_scored(prompt) + return scored[0] if scored else (None, 0.0) + + +def _demo_scene(demo: dict, prompt: str) -> dict: + """Shape a demo into a scene response carrying its editable `params` and the + teaching `explanation` the UI renders alongside the live graph.""" + expl = demo.get("explanation", "") + bullets = [str(b) for b in demo.get("bullets", [])][:4] or [ + "Drag the sliders on the right and watch the graph respond.", + "The picture updates live as you change each value.", + ] + return { + "title": demo.get("title", "Interactive demo"), + "tag": demo.get("area", "STEM"), + "dimension": "3D" if str(demo.get("dimension", "")).lower().startswith("3") else "2D", + "equation": demo.get("equation", ""), + "summary": expl, + "bullets": bullets, + "student_prompts": [str(p) for p in demo.get("student_prompts", [])][:4] or [ + "Why does changing this value have that effect on the graph?", + "What happens at the extreme settings?", + "How does the picture connect back to the formula?", + ], + "code": sanitize_code(demo.get("code", "")), + "params": [dict(p) for p in demo.get("params", [])], + "explanation": expl, + "topic": demo.get("topic", ""), + "area": demo.get("area", ""), + "demo_id": demo.get("id", ""), + "model": "demo", + "engine": "demo", + "prompt": prompt, + "from_demo": True, + } + + +def _chemistry_response(sc: dict, prompt: str) -> dict: + """Shape a chemistry scene-content dict into a full scene response. `code` + is sanitized here (mirrors _demo_scene / _library_scene).""" + summary = sc.get("summary", "") + return { + "title": sc.get("title", "Chemistry"), + "tag": sc.get("tag", "Chemistry"), + "dimension": "3D" if str(sc.get("dimension", "")).lower().startswith("3") else "2D", + "equation": sc.get("equation", ""), + "summary": summary, + "bullets": [str(b) for b in sc.get("bullets", [])][:4], + "student_prompts": [str(p) for p in sc.get("student_prompts", [])][:4], + "code": sanitize_code(sc.get("code", "")), + "explanation": summary, + "model": "chemistry", + "engine": "chemistry", + "prompt": prompt, + "from_chemistry": True, + "chem_kind": sc.get("kind", ""), + } + + +def _solver_response(sc: dict, prompt: str) -> dict: + """Shape a worked-solution (step-by-step slides) scene into a full response.""" + summary = sc.get("summary", "") + return { + "title": sc.get("title", "Worked solution"), + "tag": sc.get("tag", "Worked solution"), + "dimension": "2D", + "equation": sc.get("equation", ""), + "summary": summary, + "bullets": [str(b) for b in sc.get("bullets", [])][:4], + "student_prompts": [str(p) for p in sc.get("student_prompts", [])][:4], + "code": sanitize_code(sc.get("code", "")), + "explanation": summary, + "model": "solver", + "engine": "solver", + "prompt": prompt, + "from_solver": True, + "solver_kind": sc.get("kind", ""), + } + + +# Demo fires on a clear topic hit (a couple of keyword/title matches). Below +# this, fall through to the scene library / generative path. +_DEMO_THRESHOLD = 2.0 + + def plan_visualization(prompt: str, preferred_mode: str) -> dict: # 1. Exact-prompt cache — instant repeat for an already-validated scene. cached = scene_cache_get(prompt, preferred_mode) @@ -1691,6 +1921,37 @@ def plan_visualization(prompt: str, preferred_mode: str) -> dict: cached["cached"] = True return cached + # 1.4 Chemistry — a chemical formula renders a 3D molecular structure; a + # reaction is balanced and shown as reactants -> products. This is a + # strong, specific signal (an arrow or a real formula), so it fires + # before the curriculum demo / library so "CH4" or "2H2+O2->2H2O" is + # never mis-routed to a math demo. + chem = _chemistry_scene(prompt) + if chem is not None: + result = _chemistry_response(chem, prompt) + scene_cache_put(prompt, preferred_mode, result) + return result + + # 1.45 Worked solution — a SPECIFIC numeric problem (e.g. a triangle with + # given sides/angles) renders animated step-by-step slides that solve it + # WITH the student's numbers. Fires before the topic demo so a concrete + # problem gets a worked solution, while "law of sines" still teaches the + # general method. Requires real numbers + labels, so topics don't trigger it. + solv = _solver_scene(prompt) + if solv is not None: + result = _solver_response(solv, prompt) + scene_cache_put(prompt, preferred_mode, result) + return result + + # 1.5 Curriculum demo — the interactive "fill in the values" path. If the + # prompt clearly names an Algebra-1..Precalc topic, show its parameterized + # demo (editable + explained) rather than generating code. Takes priority + # over the static library so an interactive version wins when both exist. + demo, demo_score = demo_match(prompt) + if (demo is not None and demo_score >= _DEMO_THRESHOLD + and not _weak_prose_match(prompt, demo, demo_score)): + return _demo_scene(demo, prompt) + # 2. Strong curated match — instant, known-correct. The bar is high when a # cloud model is available (the user likely wants a custom take), and # lower when we'd otherwise lean on the slow/weak local model. A @@ -1706,7 +1967,8 @@ def plan_visualization(prompt: str, preferred_mode: str) -> dict: # firing on an ambiguous near-tie between unrelated scenes. Base scenes are # ordered first, so they win ties. confident = lib_score >= 5.0 or (lib_score - runner_up) >= 1.5 - if lib_scene is not None and lib_score >= strong_threshold and confident: + if (lib_scene is not None and lib_score >= strong_threshold and confident + and not _weak_prose_match(prompt, lib_scene, lib_score)): result = _library_scene(lib_scene, prompt) scene_cache_put(prompt, preferred_mode, result) return result @@ -1720,18 +1982,6 @@ def plan_visualization(prompt: str, preferred_mode: str) -> dict: return scene except Exception as error: # noqa: BLE001 errors.append(f"{label}: {error}") - try: - scene = _try_generate( - lambda p, m: generate_with_ollama(p, m), - prompt, - preferred_mode, - "Ollama", - max_attempts=3, - ) - scene_cache_put(prompt, preferred_mode, scene) - return scene - except Exception as error: # noqa: BLE001 - errors.append(f"Ollama: {error}") # 4. Every generator failed. A RELEVANT curated scene beats a dead canvas — # but a wrong-topic one is worse than an honest placeholder, so require a @@ -1765,17 +2015,29 @@ def _is_hard(e: str) -> bool: "enable a stronger model (ANTHROPIC_API_KEY / OPENAI_API_KEY / " "GEMINI_API_KEY) for more reliable results.", ) + # No cloud key configured at all (errors is empty): the app relies only on + # code. Chemistry, reactions, the solver, and the demo library are handled + # above with no model — only a genuinely novel free-form prompt reaches here. + # Show the friendly placeholder (not a hard error) and point the user at what + # works offline plus how to enable AI generation. + if not errors: + return _fallback_scene( + prompt, + "No AI model is configured, so free-form prompts can't be generated. " + "Try a formula (CH4), a reaction (2H2 + O2 -> 2H2O), or a topic " + "(projectile motion) — these run with no key. For custom prompts, set " + "ANTHROPIC_API_KEY (and `pip install anthropic`) to enable Claude.", + ) if any("timed out" in e.lower() or "timeout" in e.lower() for e in errors): hint = ( - "The local model probably hit a cold-start timeout. Try again — " - "the model is loaded now and the next call will be much faster." + "The model request timed out. Try again, or simplify the prompt — " + "complex scenes take longer to generate." ) - elif any("connection" in e.lower() or "could not reach" in e.lower() for e in errors): - hint = "Start Ollama (`ollama serve`) or set ANTHROPIC_API_KEY for Claude." else: hint = ( - "Set ANTHROPIC_API_KEY (and `pip install anthropic`), " - "OPENAI_API_KEY, or GEMINI_API_KEY — or run Ollama locally." + "Set ANTHROPIC_API_KEY (and `pip install anthropic`) for Claude, or " + "OPENAI_API_KEY / GEMINI_API_KEY. With no key, VisualLM still runs the " + "built-in pure-code library (demos, chemistry, step-by-step solver)." ) raise RuntimeError(f"Generator failed ({detail}). {hint}") @@ -1803,18 +2065,11 @@ def repair_visualization(prompt: str, code: str, error: str, where: str = "") -> ) except Exception as e: # noqa: BLE001 errors.append(f"{label}: {e}") - try: - return normalize_scene( - generate_with_ollama(prompt, "auto", fix={"code": code, "error": error_ctx}), - prompt, - ) - except Exception as e: # noqa: BLE001 - errors.append(f"Ollama: {e}") raise RuntimeError("Repair failed. " + " | ".join(errors)) # ===================================================================== # -# Tutor chat (Ollama primary, Claude fallback) # +# Tutor chat (Claude / OpenAI / Gemini, cloud only) # # ===================================================================== # @@ -1844,6 +2099,45 @@ def build_tutor_system_prompt(viz: dict) -> str: - Short step-by-step reasoning for math/physics. - End with one short follow-up question or practice suggestion. + SOLVING A PROBLEM — when the student asks you to solve, find, calculate, + derive, or "how do I do this", do NOT just give the final answer. Walk + through the METHOD so they could repeat it themselves, using exactly + these labeled sections (each label on its own line, plain text). + + Assume the student is a TOTAL BEGINNER who may be anxious about math. + Make it dumb-proof: + - Define every term the first time you use it, in plain everyday words + (e.g. "the coefficient — that's just the number multiplying x"). + - NEVER skip a step. Show every single arithmetic and algebra move on + its own line. Do not jump from 2x + 6 = 10 to x = 2; show "subtract 6 + from both sides: 2x = 4", then "divide both sides by 2: x = 2". + - For every move, say WHY it is allowed in one short clause ("to undo + the + 6 we do the opposite, subtract 6", "we can divide both sides by + the same number and keep them equal"). + - Short sentences. No jargon without an immediate plain-words gloss. No + step should make the student think "wait, how did you get that?". + + Goal: one line naming what we solve for — its symbol and unit. + + What you need: the relationship(s)/formula(s) that apply, plus every + known quantity. For each known, give symbol = value (with unit) and say + WHERE it comes from: stated in the problem, a known constant, or read + off the animation on screen. Call out anything still unknown. + + Steps: numbered. Each step does ONE small thing — name it, say WHY, + substitute the specific values RIGHT THERE, do the single arithmetic + move, and show the intermediate result with units. If a line of algebra + has two moves, split it into two steps. When a step corresponds to + something on screen, point to it (e.g. "this is the slope of the tangent + line you see sweeping the curve"). + + Answer: the final result with units, then a one-line plain-words sanity + check (does the sign/size make sense?). + + If the student gave no numbers, solve it symbolically and show exactly + where each quantity would be plugged in. Keep every step tiny; still end + with one short follow-up question. + Output format — STRICT. The chat window shows plain text only. - NO LaTeX of any kind. Do not write \\(...\\), \\[...\\], $...$, \\frac{{a}}{{b}}, \\sqrt{{x}}, \\sum, \\int, \\alpha, \\theta, \\pi, @@ -1878,30 +2172,6 @@ def build_tutor_system_prompt(viz: dict) -> str: return base -def chat_with_ollama(question: str, viz: dict, history: list[dict]) -> dict: - models = fetch_ollama_models() - model_name = choose_ollama_model(models) - messages = [{"role": "system", "content": build_tutor_system_prompt(viz)}] - messages.extend(history) - messages.append({"role": "user", "content": question}) - response = ollama_request( - "/api/chat", - payload={ - "model": model_name, - "stream": False, - "messages": messages, - "options": {"temperature": 0.3}, - "keep_alive": "30m", - }, - timeout=240.0, - ) - raw = response.get("message", {}).get("content") - answer = raw.strip() if isinstance(raw, str) else "" - if not answer: - raise RuntimeError("Ollama returned an empty response.") - return {"answer": answer, "model": model_name, "engine": "ollama"} - - def chat_with_claude(question: str, viz: dict, history: list[dict]) -> dict: client = anthropic_client() if client is None: @@ -1914,9 +2184,19 @@ def chat_with_claude(question: str, viz: dict, history: list[dict]) -> dict: while trimmed and trimmed[0].get("role") != "user": trimmed.pop(0) messages = trimmed + [{"role": "user", "content": question}] + # Adaptive thinking lets the tutor REASON through a "how do I solve this" + # question before answering (better step-by-step math correctness) while + # staying fast on simple "what does this mean" questions — Claude decides how + # much to think per turn. Thinking tokens count against max_tokens, so the cap + # is raised well above the old 2000 to leave room for thinking + a thorough, + # beginner-proof explanation. medium effort balances interactive latency + # against correctness. We only read the text block (thinking blocks, if any, + # are skipped by the `type == "text"` filter below). response = client.messages.create( model=ANTHROPIC_MODEL, - max_tokens=2000, + max_tokens=8000, + thinking={"type": "adaptive"}, + output_config={"effort": "medium"}, system=build_tutor_system_prompt(viz), messages=messages, ) @@ -1928,14 +2208,6 @@ def chat_with_claude(question: str, viz: dict, history: list[dict]) -> dict: def tutor_chat(question: str, viz: dict, history: list[dict]) -> dict: errors = [] - # Previously did ollama_available() (probe /api/tags) THEN chat_with_ollama() - # (which also fetches /api/tags) — same duplicate-call issue as Bug #45. - # Trying chat_with_ollama directly fails just as fast on a refused - # connection because fetch_ollama_models has its own 10 s timeout. - try: - return chat_with_ollama(question, viz, history) - except Exception as e: # noqa: BLE001 - errors.append(f"Ollama: {e}") if claude_available()["available"]: try: return chat_with_claude(question, viz, history) @@ -1965,8 +2237,8 @@ def tutor_chat(question: str, viz: dict, history: list[dict]) -> dict: # No backend is configured. Give the same actionable hint that # plan_visualization gives, otherwise the user sees a dead end. raise RuntimeError( - "No tutor backend is available. Start Ollama (`ollama serve`) " - "or set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY." + "No tutor backend is available. Set ANTHROPIC_API_KEY " + "/ OPENAI_API_KEY / GEMINI_API_KEY to enable the AI tutor." ) raise RuntimeError("Tutor unavailable. " + " | ".join(errors)) @@ -1977,20 +2249,10 @@ def tutor_chat(question: str, viz: dict, history: list[dict]) -> dict: def get_health() -> dict: - # Single Ollama probe — was making 2 HTTP calls per request: ollama_available() - # calls fetch_ollama_models() internally, then we called it AGAIN to populate - # the model list. With Bug #19's refresh-on-error this fired often enough to - # matter. Now we fetch once and derive availability from success/failure. - ollama_status = {"available": False, "url": OLLAMA_URL, "error": None} - try: - models = fetch_ollama_models() - ollama_status["available"] = True - ollama_status["selected_model"] = choose_ollama_model(models) - ollama_status["models"] = [m.get("name") for m in models] - except RuntimeError as error: - ollama_status["error"] = str(error) - # claude_available() does no I/O (env var + import probe) but was still - # called twice — once for the dict, once for the generator switch. + # The AI relies only on code: a cloud key enables Claude/OpenAI/Gemini, and + # with no key the app serves the built-in pure-code library (demos, chemistry, + # the step-by-step solver). No local model app (Ollama) is probed or required, + # so /api/health does no network I/O — just an env-var + import probe. claude_info = claude_available() openai_info = openai_available() gemini_info = gemini_available() @@ -2000,12 +2262,9 @@ def get_health() -> dict: generator = "openai" elif gemini_info["available"]: generator = "gemini" - elif ollama_status["available"]: - generator = "ollama" else: generator = "none" return { - "ollama": ollama_status, "claude": claude_info, "openai": openai_info, "gemini": gemini_info, @@ -2017,11 +2276,125 @@ def get_health() -> dict: } +def _extract_json(text: str): + """Pull the first {...} JSON object out of a possibly fenced model reply.""" + match = re.search(r"\{.*\}", text or "", re.S) + if not match: + return None + try: + return json.loads(match.group(0)) + except Exception: # noqa: BLE001 + return None + + +def _fetch_link_text(url: str) -> str: + """Fetch a user-supplied http(s) link and return its text (desktop only).""" + if not re.match(r"^https?://", url): + raise ValueError("Links must start with http:// or https://.") + try: + request = urllib.request.Request(url, headers={"User-Agent": "VisualLM/1.0"}) + with urllib.request.urlopen(request, timeout=12) as response: # noqa: S310 + raw = response.read(500_000) # 500KB cap + except Exception as err: # noqa: BLE001 + raise ValueError(f"Couldn't fetch that link: {err}") + text = raw.decode("utf-8", "replace") + text = re.sub(r"(?is)<(script|style)[^>]*>.*?", " ", text) + text = re.sub(r"(?s)<[^>]+>", " ", text) + return re.sub(r"\s+", " ", text).strip() + + +def analyze_to_dlc(payload: dict) -> dict: + """AI analysis: turn uploaded material (text or a link) into a custom DLC of + generated demos. Reuses the normal Claude generation path per topic.""" + client = anthropic_client() + if client is None: + raise RuntimeError( + "AI analysis needs Claude — set ANTHROPIC_API_KEY (and `pip install anthropic`)." + ) + material = str(payload.get("material") or "").strip() + link = str(payload.get("link") or "").strip() + name = (str(payload.get("name") or "Custom Pack").strip() or "Custom Pack")[:80] + if link and not material: + material = _fetch_link_text(link) + if not material: + raise ValueError("Provide some material (paste text or a link) to analyze.") + material = material[:12_000] + max_demos = max(1, min(8, int(payload.get("max_demos") or 6))) + + # 1) Ask Claude for concrete, visualizable topics from the material. + system = ( + "You are building an interactive-demo pack from teaching material. " + "Identify the most useful STEM concepts to visualize as interactive, " + "slider-driven demos. Respond with ONLY JSON of the form " + '{"name": "short pack name", "topics": [{"title": "...", "prompt": "..."}]}. ' + f"Give at most {max_demos} topics. Each `prompt` is a short instruction to " + "generate ONE demo, e.g. 'projectile motion with adjustable launch angle " + "and speed'." + ) + response = client.messages.create( + model=ANTHROPIC_MODEL, + max_tokens=2000, + thinking={"type": "adaptive"}, + output_config={"effort": "low"}, + system=system, + messages=[{"role": "user", "content": "Material:\n\n" + material}], + ) + text = next((b.text for b in response.content if b.type == "text"), "").strip() + plan = _extract_json(text) or {} + topics = plan.get("topics") or [] + if plan.get("name"): + name = str(plan["name"])[:80] + if not topics: + raise ValueError("Couldn't find teachable concepts in that material.") + + slug = _dlc._slug(name) if _dlc else "custom-pack" + + # 2) Generate a demo per topic (same generator the prompt box uses). + demos: list[dict] = [] + for i, topic in enumerate(topics[:max_demos]): + prompt = str(topic.get("prompt") or topic.get("title") or "").strip() + if not prompt: + continue + try: + scene = generate_with_claude(prompt, "auto") + except Exception: # noqa: BLE001 — skip a failed topic, keep the rest + continue + code = sanitize_code(scene.get("code", "")) + if not code.strip(): + continue + demos.append({ + "id": f"{slug}-{i + 1}", + "area": "Custom", + "topic": str(topic.get("title") or "Generated"), + "title": str(topic.get("title") or scene.get("title") or prompt)[:80], + "equation": scene.get("equation", ""), + "code": code, + "params": [dict(p) for p in scene.get("params", [])], + "explanation": scene.get("summary", "") or scene.get("explanation", ""), + "bullets": [str(b) for b in scene.get("bullets", [])][:4], + }) + if not demos: + raise RuntimeError("The generator didn't produce any usable demos. Try different material.") + + return { + "format": "visuallm-dlc/1", + "id": f"{slug}-ai", + "name": name, + "description": f"AI-generated from your material — {len(demos)} demos.", + "category": "custom", + "icon": "✨", + "source": "ai", + "version": "1.0", + "demos": demos, + "experiments": [], + } + + class VisualLMHandler(SimpleHTTPRequestHandler): # Cap per-request socket reads so a malicious / hung client that declares # Content-Length: N but only sends part of it can't pin a worker thread # forever on rfile.read(N). 30 s is far more than any legitimate body - # (the slow path is a Claude/Ollama call, which happens server-side after + # (the slow path is a cloud model call, which happens server-side after # the body is fully read). Without this, ThreadingHTTPServer's default # socket timeout is None — the thread hangs until TCP keepalive expires. timeout = 30 @@ -2054,6 +2427,15 @@ def do_GET(self) -> None: if parsed.path == "/api/resources": send_json(self, HTTPStatus.OK, {"resources": list_resources()}) return + if parsed.path == "/api/packs": + if _dlc is None: + send_json(self, HTTPStatus.OK, {"packs": []}) + return + idx = _library_index() + packs = [_dlc.pack_meta(p, idx) for p in _dlc.official_packs()] + packs += [_dlc.pack_meta(p, idx) for p in _dlc.custom_packs()] + send_json(self, HTTPStatus.OK, {"packs": packs}) + return if parsed.path == "/": self.path = "/index.html" super().do_GET() @@ -2091,7 +2473,12 @@ def client_ip(self) -> str: def do_POST(self) -> None: parsed = urllib.parse.urlparse(self.path) - routes = {"/api/visualize", "/api/repair", "/api/chat", "/api/resources"} + routes = { + "/api/visualize", "/api/repair", "/api/chat", "/api/resources", + "/api/pack", "/api/demo", + "/api/dlc/import", "/api/dlc/remove", "/api/dlc/export", + "/api/analyze", + } if parsed.path not in routes: send_json(self, HTTPStatus.NOT_FOUND, {"error": "Unknown API route."}) return @@ -2133,6 +2520,18 @@ def do_POST(self) -> None: self._handle_repair(payload) elif parsed.path == "/api/resources": self._handle_resource_upload(payload) + elif parsed.path == "/api/pack": + self._handle_pack(payload) + elif parsed.path == "/api/demo": + self._handle_demo(payload) + elif parsed.path == "/api/dlc/import": + self._handle_dlc_import(payload) + elif parsed.path == "/api/dlc/remove": + self._handle_dlc_remove(payload) + elif parsed.path == "/api/dlc/export": + self._handle_dlc_export(payload) + elif parsed.path == "/api/analyze": + self._handle_analyze(payload) else: self._handle_chat(payload) except Exception: # noqa: BLE001 @@ -2155,6 +2554,62 @@ def _handle_resource_upload(self, payload: dict) -> None: {"resource": entry, "resources": list_resources()}, ) + # ---- Study Packs (DLC) ---- + def _handle_pack(self, payload: dict) -> None: + pack = _dlc.find_pack(payload.get("id")) if _dlc else None + if not pack: + send_json(self, HTTPStatus.NOT_FOUND, {"error": "Pack not found."}) + return + send_json(self, HTTPStatus.OK, _dlc.pack_catalog(pack, _library_index())) + + def _handle_demo(self, payload: dict) -> None: + demo_id = payload.get("id") + demo = next((d for d in DEMO_LIBRARY if d.get("id") == demo_id), None) + if demo is None and _dlc is not None: + demo = _dlc.find_embedded(demo_id) + if demo is None: + send_json(self, HTTPStatus.NOT_FOUND, {"error": "Demo not found."}) + return + send_json(self, HTTPStatus.OK, _demo_scene(demo, demo.get("title", ""))) + + def _handle_dlc_import(self, payload: dict) -> None: + obj = payload.get("dlc") + errors = _dlc.validate(obj) if _dlc else ["DLC engine unavailable."] + if errors: + send_json( + self, + HTTPStatus.BAD_REQUEST, + {"error": "Invalid pack — " + " ".join(errors[:3]), "errors": errors}, + ) + return + saved = _dlc.import_pack(obj) + send_json(self, HTTPStatus.OK, {"ok": True, "pack": _dlc.pack_meta(saved, _library_index())}) + + def _handle_dlc_remove(self, payload: dict) -> None: + ok = _dlc.remove_pack(payload.get("id")) if _dlc else False + send_json(self, HTTPStatus.OK, {"ok": bool(ok)}) + + def _handle_dlc_export(self, payload: dict) -> None: + pack = _dlc.find_pack(payload.get("id")) if _dlc else None + if not pack: + send_json(self, HTTPStatus.NOT_FOUND, {"error": "Pack not found."}) + return + send_json(self, HTTPStatus.OK, {"dlc": pack}) + + def _handle_analyze(self, payload: dict) -> None: + try: + dlc_obj = analyze_to_dlc(payload) + except ValueError as err: + send_json(self, HTTPStatus.BAD_REQUEST, {"error": str(err)}) + return + except RuntimeError as err: + send_json(self, HTTPStatus.SERVICE_UNAVAILABLE, {"error": str(err)}) + return + if _dlc is not None: + _dlc.import_pack(dlc_obj) + meta = _dlc.pack_meta(dlc_obj, _library_index()) if _dlc else None + send_json(self, HTTPStatus.OK, {"dlc": dlc_obj, "pack": meta}) + def _handle_visualize(self, payload: dict) -> None: prompt = payload.get("prompt", "") mode = payload.get("preferred_mode", "auto") diff --git a/make_app.sh b/make_app.sh new file mode 100755 index 0000000..5a060a2 --- /dev/null +++ b/make_app.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Build VisualLM.app — a real, double-clickable macOS app for VisualLM. +# +# ./make_app.sh +# +# It generates an icon, then assembles a .app bundle that (when double-clicked) +# starts the local server and opens VisualLM in a native window if pywebview is +# installed (`pip install pywebview`), otherwise an app-style browser window. +# +# The bundle lives next to this script and resolves the repo by its own +# location, so keep VisualLM.app inside the repo folder. It's a build artifact +# (git-ignored); re-run this script to rebuild. macOS only (needs iconutil/sips). +set -euo pipefail +cd "$(dirname "$0")" + +APP="VisualLM.app" +PYBIN="$(command -v python3 || true)" +[ -n "$PYBIN" ] || { echo "python3 not found on PATH"; exit 1; } + +echo "• Generating icon …" +python3 make_icon.py VisualLM.png >/dev/null +ICONSET="VisualLM.iconset"; rm -rf "$ICONSET"; mkdir "$ICONSET" +# Exact names iconutil expects (logical size + @2x retina variant). +gen() { sips -z "$2" "$2" VisualLM.png --out "$ICONSET/$1" >/dev/null; } +gen icon_16x16.png 16 +gen icon_16x16@2x.png 32 +gen icon_32x32.png 32 +gen icon_32x32@2x.png 64 +gen icon_128x128.png 128 +gen icon_128x128@2x.png 256 +gen icon_256x256.png 256 +gen icon_256x256@2x.png 512 +gen icon_512x512.png 512 +gen icon_512x512@2x.png 1024 +iconutil -c icns "$ICONSET" -o VisualLM.icns + +echo "• Assembling $APP …" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp VisualLM.icns "$APP/Contents/Resources/VisualLM.icns" + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleNameVisualLM + CFBundleDisplayNameVisualLM + CFBundleIdentifiercom.visuallm.app + CFBundleVersion1.0 + CFBundleShortVersionString1.0 + CFBundlePackageTypeAPPL + CFBundleExecutableVisualLM + CFBundleIconFileVisualLM + NSHighResolutionCapable + LSMinimumSystemVersion10.13 + + +PLIST + +# The launcher. PYBIN is baked at build time because a Finder-launched app gets +# a minimal PATH that usually won't include a pyenv/framework python3. +cat > "$APP/Contents/MacOS/VisualLM" <> "\$LOG" +# Native window via pywebview if available; otherwise an app-style browser window. +"\$PY" desktop.py >> "\$LOG" 2>&1 || "\$PY" launch.py >> "\$LOG" 2>&1 +LAUNCH +chmod +x "$APP/Contents/MacOS/VisualLM" + +rm -rf "$ICONSET" VisualLM.png VisualLM.icns +touch "$APP" # nudge Finder to refresh the icon + +echo "✓ Built $APP" +echo " Double-click it in Finder. First launch: right-click → Open (clears the" +echo " macOS 'unidentified developer' warning once). For a true native window," +echo " 'pip install pywebview' first; otherwise it opens an app-style window." diff --git a/make_icon.py b/make_icon.py new file mode 100755 index 0000000..110bd12 --- /dev/null +++ b/make_icon.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Generate the VisualLM app icon (1024x1024 PNG) with only the standard library. + +Design echoes the app's "breathing circle" motif: a dark gradient rounded square +with two concentric accent rings and a bright center dot. Output: VisualLM.png +(make_app.sh downsamples it into an .iconset and runs iconutil -> .icns). + +Usage: python3 make_icon.py [out.png] +""" +from __future__ import annotations + +import math +import struct +import sys +import zlib + +SIZE = 1024 + + +def _hex(h: str) -> tuple[int, int, int]: + h = h.lstrip("#") + return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + + +BG_TOP, BG_BOT = _hex("#16203a"), _hex("#0b1120") +ACCENT, ACCENT2, INK = _hex("#7cc4ff"), _hex("#f4a259"), _hex("#eef2ff") + + +def _lerp(a: float, b: float, t: float) -> float: + return a + (b - a) * t + + +def _png(width: int, height: int, raw: bytes) -> bytes: + def chunk(typ: bytes, data: bytes) -> bytes: + body = typ + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) # 8-bit RGBA + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"") + ) + + +def build() -> bytes: + half = SIZE / 2.0 + cr = SIZE * 0.235 # corner radius + flat = half - cr # half-extent of the straight edges + r1, w1 = SIZE * 0.300, SIZE * 0.024 # outer ring radius / half-width + r2, w2 = SIZE * 0.200, SIZE * 0.020 # inner ring + rdot = SIZE * 0.072 # center dot radius + rows = bytearray() + for y in range(SIZE): + rows.append(0) # PNG filter byte (None) per scanline + ty = y / (SIZE - 1) + bg = ( + _lerp(BG_TOP[0], BG_BOT[0], ty), + _lerp(BG_TOP[1], BG_BOT[1], ty), + _lerp(BG_TOP[2], BG_BOT[2], ty), + ) + dyc = y - half + for x in range(SIZE): + # Rounded-square mask (hard edge; sips downscaling anti-aliases it). + ex = abs(x - half) - flat + ey = abs(dyc) - flat + ex = ex if ex > 0 else 0.0 + ey = ey if ey > 0 else 0.0 + if ex * ex + ey * ey > cr * cr: + rows += b"\x00\x00\x00\x00" + continue + r, g, b = bg + d = math.hypot(x - half, dyc) + a1 = 1.0 - abs(d - r1) / w1 + if a1 > 0: + r = _lerp(r, ACCENT[0], a1); g = _lerp(g, ACCENT[1], a1); b = _lerp(b, ACCENT[2], a1) + a2 = 1.0 - abs(d - r2) / w2 + if a2 > 0: + r = _lerp(r, ACCENT2[0], a2); g = _lerp(g, ACCENT2[1], a2); b = _lerp(b, ACCENT2[2], a2) + if d < rdot: + r, g, b = INK + rows += bytes((int(r), int(g), int(b), 255)) + return _png(SIZE, SIZE, bytes(rows)) + + +def main() -> int: + out = sys.argv[1] if len(sys.argv) > 1 else "VisualLM.png" + with open(out, "wb") as f: + f.write(build()) + print(f"wrote {out} ({SIZE}x{SIZE})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/physics_library_generated.json b/physics_library_generated.json new file mode 100644 index 0000000..1db36db --- /dev/null +++ b/physics_library_generated.json @@ -0,0 +1,6006 @@ +[ + { + "id": "ph-special-relativity-time-dilation", + "area": "Physics", + "topic": "Special relativity: time dilation", + "title": "Time dilation: Delta t = gamma * Delta tau", + "equation": "Delta t = gamma * Delta tau, gamma = 1 / sqrt(1 - (v/c)^2)", + "keywords": [ + "time dilation", + "special relativity", + "lorentz factor", + "gamma", + "moving clocks", + "proper time", + "beta v over c", + "light clock", + "relativistic time", + "delta t = gamma delta tau", + "twins", + "einstein" + ], + "explanation": "A moving clock ticks slower than one at rest with you. The slider beta = v/c sets how fast the light-clock flies; the Lorentz factor gamma = 1/sqrt(1 - beta^2) grows past 1 as beta nears 1, so each tick that takes Delta tau on the moving clock takes Delta t = gamma * Delta tau on yours. Watch the bouncing photon: as you raise beta the clock drifts faster across the screen AND its bounce visibly slows, and the readout shows Delta t stretching above Delta tau.", + "bullets": [ + "gamma = 1/sqrt(1 - (v/c)^2) is always >= 1, so moving clocks never run fast.", + "Delta tau is the proper time on the moving clock; Delta t is the longer time you measure.", + "At beta = 0.87 gamma ~ 2: the clock ages half as fast as yours." + ], + "params": [ + { + "name": "beta", + "label": "speed beta = v/c", + "min": 0, + "max": 0.99, + "step": 0.01, + "value": 0.8 + }, + { + "name": "dtau", + "label": "proper tick Delta tau (s)", + "min": 0.1, + "max": 2, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst w = H.W;\nconst beta = Math.min(0.99, Math.max(0, P.beta));\nconst gamma = 1 / Math.sqrt(Math.max(1e-6, 1 - beta * beta));\nconst dtau = Math.max(0.1, P.dtau);\nconst dt = gamma * dtau;\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: -1, yMax: 6 });\nv.grid(); v.axes();\n// Moving light-clock loops across the window; faster beta => faster horizontal drift.\nconst cx = (t * (0.6 + beta * 2.0)) % 11 - 0.5;\nconst baseY = 0.5, Lh = 3.0;\n// Photon ticks at the PROPER rate inside the clock; the observer sees the clock\n// run slow by gamma, so its bounce takes gamma*dtau of THEIR time.\nconst obsPeriod = Math.max(0.4, gamma * dtau);\nconst ph = (t % obsPeriod) / obsPeriod;\nconst photonY = baseY + (Lh / 2) * (1 - Math.cos(ph * H.TAU));\nv.line(cx - 0.35, baseY, cx + 0.35, baseY, { color: H.colors.accent, width: 4 });\nv.line(cx - 0.35, baseY + Lh, cx + 0.35, baseY + Lh, { color: H.colors.accent, width: 4 });\nv.line(cx, baseY, cx, photonY, { color: H.colors.yellow, width: 1.5, dash: [3, 3] });\nv.dot(cx, photonY, { r: 6, fill: H.colors.yellow });\nv.arrow(cx, baseY + Lh + 0.7, cx + 0.4 + beta * 1.6, baseY + Lh + 0.7, { color: H.colors.warn, width: 2.5 });\nH.text(\"Time dilation: Δt = γ·Δτ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"β = v/c = \" + beta.toFixed(2) + \" γ = \" + gamma.toFixed(3), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Δτ moving clock = \" + dtau.toFixed(2) + \" s → Δt you measure = \" + dt.toFixed(2) + \" s\", 24, 74, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"light-clock photon\", color: H.colors.yellow }, { label: \"velocity v\", color: H.colors.warn }], w - 200, 28);" + }, + { + "id": "ph-special-relativity-length-contraction", + "area": "Physics", + "topic": "Special relativity: length contraction", + "title": "Length contraction: L = L0 / gamma", + "equation": "L = L0 / gamma, gamma = 1 / sqrt(1 - (v/c)^2)", + "keywords": [ + "length contraction", + "special relativity", + "lorentz contraction", + "gamma", + "proper length", + "contracted length", + "beta v over c", + "relativistic length", + "l = l0 / gamma", + "moving rod", + "lorentz factor", + "einstein" + ], + "explanation": "A fast-moving object is measured SHORTER along its direction of motion. The slider beta = v/c sets the speed and so the Lorentz factor gamma = 1/sqrt(1 - beta^2); the moving rocket's length shrinks to L = L0/gamma while its proper (rest) length L0 stays fixed. Compare the faint rest-frame rocket on top to the bold moving one below: as you push beta toward 1 the moving rocket squashes down while keeping the SAME height (only the direction of motion contracts).", + "bullets": [ + "Only the dimension ALONG the motion contracts; transverse lengths are unchanged.", + "L0 is the proper length (measured at rest); L = L0/gamma is what a moving observer sees.", + "At beta = 0.87 gamma ~ 2, so the rocket looks half as long." + ], + "params": [ + { + "name": "beta", + "label": "speed beta = v/c", + "min": 0, + "max": 0.99, + "step": 0.01, + "value": 0.8 + }, + { + "name": "L0", + "label": "proper length L0 (m)", + "min": 1, + "max": 8, + "step": 0.5, + "value": 6 + } + ], + "code": "H.background();\nconst w = H.W;\nconst beta = Math.min(0.99, Math.max(0, P.beta));\nconst gamma = 1 / Math.sqrt(Math.max(1e-6, 1 - beta * beta));\nconst L0 = Math.max(0.5, P.L0);\nconst L = L0 / gamma;\nconst v = H.plot2d({ xMin: 0, xMax: 12, yMin: -1, yMax: 6 });\nv.grid(); v.axes();\n// Rest-frame rocket (proper length L0) shown faint at top as the reference.\nconst restY = 4.2, hRk = 0.7;\nv.rect(1, restY, L0, hRk, { fill: H.hsl(210, 50, 35, 0.5), stroke: H.colors.sub, width: 1.5 });\nv.text(\"proper length L0 = \" + L0.toFixed(1) + \" m\", 1, restY + hRk + 0.5, { color: H.colors.sub, size: 12 });\n// Moving (contracted) rocket loops across the window.\nconst nose = (t * (0.8 + beta * 2.2)) % (12 + L) - L;\nconst tail = nose - L;\nconst movY = 1.3;\nv.rect(tail, movY, L, hRk, { fill: H.colors.accent2, stroke: H.colors.ink, width: 1.5 });\nv.line(tail, movY - 0.3, nose, movY - 0.3, { color: H.colors.warn, width: 2 });\nv.arrow(nose - L * 0.5, movY + hRk + 0.6, nose - L * 0.5 + 0.4 + beta * 1.8, movY + hRk + 0.6, { color: H.colors.warn, width: 2.5 });\nH.text(\"Length contraction: L = L0 / γ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"β = v/c = \" + beta.toFixed(2) + \" γ = \" + gamma.toFixed(3), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"L0 = \" + L0.toFixed(1) + \" m → contracted L = \" + L.toFixed(2) + \" m\", 24, 74, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"rest length L0\", color: H.colors.sub }, { label: \"moving (contracted) L\", color: H.colors.accent2 }], w - 230, 28);" + }, + { + "id": "ph-mass-energy-equivalence", + "area": "Physics", + "topic": "Mass-energy equivalence (E = m c^2)", + "title": "Mass-energy: E = gamma * m * c^2", + "equation": "E = gamma * m * c^2, rest energy E0 = m * c^2", + "keywords": [ + "mass energy equivalence", + "e=mc^2", + "e equals mc squared", + "rest energy", + "relativistic energy", + "einstein", + "mass energy", + "total energy", + "gamma m c squared", + "speed of light", + "modern physics", + "joules" + ], + "explanation": "Mass IS a kind of energy: even at rest a mass m holds E0 = m*c^2 joules, and because c^2 is enormous (~9e16) a tiny mass packs a huge energy. As the object speeds up, its total energy grows to E = gamma*m*c^2; the curve plots E/E0 = gamma against beta = v/c and shoots toward infinity as beta -> 1, which is why nothing with mass can reach light speed. The dashed line marks the rest energy floor E0 that you can never go below.", + "bullets": [ + "Rest energy E0 = m*c^2: 1 kg holds ~9.0e16 J, the yield of a large bomb.", + "Total energy E = gamma*m*c^2 adds kinetic energy on top of the rest energy.", + "E/E0 = gamma blows up as v -> c, so a massive object can't reach light speed." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.001, + "max": 2, + "step": 0.001, + "value": 1 + }, + { + "name": "betaMax", + "label": "max speed beta = v/c", + "min": 0.1, + "max": 0.99, + "step": 0.01, + "value": 0.9 + } + ], + "code": "H.background();\nconst w = H.W;\nconst c = 2.998e8; // speed of light, m/s\nconst m = Math.max(0.001, P.m); // mass in kilograms\nconst E0 = m * c * c; // rest energy, joules\n// Total relativistic energy as the object's speed oscillates 0..betaMax.\nconst betaMax = Math.min(0.99, Math.max(0, P.betaMax));\nconst beta = betaMax * 0.5 * (1 - Math.cos(t * 0.8)); // 0..betaMax, looping\nconst gamma = 1 / Math.sqrt(Math.max(1e-6, 1 - beta * beta));\nconst E = gamma * E0; // total energy = gamma m c^2\nconst v = H.plot2d({ xMin: 0, xMax: 1, yMin: 0, yMax: 8 });\nv.grid({ stepX: 0.2 }); v.axes();\n// Energy as a multiple of rest energy (E/E0 = gamma) versus beta.\nv.fn(b => 1 / Math.sqrt(Math.max(1e-4, 1 - b * b)), { color: H.colors.accent, width: 3 });\nv.line(0, 1, 1, 1, { color: H.colors.violet, width: 1.5, dash: [5, 5] });\nv.dot(beta, Math.min(8, gamma), { r: 7, fill: H.colors.warn });\nv.text(\"E0 = m c²\", 0.04, 1.5, { color: H.colors.violet, size: 12 });\nH.text(\"Mass–energy: E = γ·m·c² (rest: E0 = m·c²)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"m = \" + m.toFixed(3) + \" kg c = 2.998e8 m/s β = \" + beta.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"E0 = \" + E0.toExponential(3) + \" J E = γ E0 = \" + E.toExponential(3) + \" J\", 24, 74, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"E / E0 = γ\", color: H.colors.accent }, { label: \"rest energy\", color: H.colors.violet }], w - 170, 28);" + }, + { + "id": "ph-radioactive-decay", + "area": "Physics", + "topic": "Radioactive decay", + "title": "Radioactive decay: N = N0 * e^(-lambda t)", + "equation": "N = N0 * e^(-lambda t), lambda = ln2 / t_half", + "keywords": [ + "radioactive decay", + "half life", + "decay constant", + "lambda", + "exponential decay", + "n0 e^-lambda t", + "nuclear decay", + "activity", + "carbon dating", + "isotope", + "ln2 over half life", + "modern physics" + ], + "explanation": "Unstable nuclei decay at random, but a huge population thins out predictably: every half-life t_half the count N halves, giving the smooth curve N = N0*e^(-lambda t) with lambda = ln2/t_half. Slide t_half to set the pace and N0 to set the starting count; the violet steps mark t_half, 2*t_half, ... where N drops to N0/2, N0/4, ... The red marker sweeps the curve and the readout shows how many nuclei remain and the percentage left at the current time.", + "bullets": [ + "Each half-life t_half cuts the count in half: N0 -> N0/2 -> N0/4 -> ...", + "Decay constant lambda = ln2/t_half sets the rate; bigger lambda = faster decay.", + "The decay is exponential, so it never quite reaches zero." + ], + "params": [ + { + "name": "N0", + "label": "initial nuclei N0", + "min": 100, + "max": 1000, + "step": 10, + "value": 800 + }, + { + "name": "thalf", + "label": "half-life t_half (s)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst w = H.W;\nconst N0 = Math.max(1, P.N0); // initial number of nuclei\nconst thalf = Math.max(0.2, P.thalf); // half-life (seconds)\nconst lambda = Math.LN2 / thalf; // decay constant\nconst tMax = thalf * 5;\nconst v = H.plot2d({ xMin: 0, xMax: tMax, yMin: 0, yMax: N0 * 1.08 });\nv.grid(); v.axes();\n// Decay curve N(t) = N0 e^(-lambda t).\nv.fn(x => N0 * Math.exp(-lambda * x), { color: H.colors.accent, width: 3 });\n// Half-life gridlines: N0/2, N0/4, ... at t = thalf, 2 thalf, ...\nfor (let k = 1; k <= 4; k++) {\n const tk = k * thalf, Nk = N0 * Math.pow(0.5, k);\n v.line(tk, 0, tk, Nk, { color: H.colors.violet, width: 1, dash: [4, 4] });\n v.line(0, Nk, tk, Nk, { color: H.colors.violet, width: 1, dash: [4, 4] });\n v.dot(tk, Nk, { r: 4, fill: H.colors.violet });\n}\n// Animated current-time marker sweeping the curve and looping.\nconst tc = (t * (tMax / 6)) % tMax;\nconst Nc = N0 * Math.exp(-lambda * tc);\nv.line(tc, 0, tc, Nc, { color: H.colors.warn, width: 1.5 });\nv.dot(tc, Nc, { r: 7, fill: H.colors.warn });\nH.text(\"Radioactive decay: N = N0·e^(−λt)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"N0 = \" + N0.toFixed(0) + \" t½ = \" + thalf.toFixed(2) + \" s λ = ln2/t½ = \" + lambda.toFixed(3) + \" /s\", 24, 52, { color: H.colors.sub, size: 12 });\nH.text(\"t = \" + tc.toFixed(2) + \" s → N = \" + Nc.toFixed(1) + \" (\" + (100 * Nc / N0).toFixed(1) + \"% left)\", 24, 74, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"N(t)\", color: H.colors.accent }, { label: \"half-life steps\", color: H.colors.violet }], w - 180, 28);" + }, + { + "id": "ph-nuclear-binding-energy-fission-fusion", + "area": "Physics", + "topic": "Nuclear binding energy, fission and fusion", + "title": "Binding energy per nucleon: B/A vs A", + "equation": "B/A vs A (peaks ~8.8 MeV near Fe-56); release dE = c^2 * dm", + "keywords": [ + "binding energy", + "binding energy per nucleon", + "fission", + "fusion", + "mass number", + "iron 56", + "semi-empirical mass formula", + "weizsacker", + "nuclear energy", + "mev per nucleon", + "mass defect", + "modern physics" + ], + "explanation": "How tightly a nucleus is bound, measured as binding energy per nucleon B/A, rises from light nuclei, peaks near iron-56 at about 8.8 MeV, then falls for heavy nuclei. Because the peak is the most stable point, moving TOWARD it releases energy: light nuclei FUSE (climb the steep left side) and heavy nuclei FISSION (slide down from the right). Slide A to pick a nucleus; the marker bobs along the Weizsacker B/A curve and the readout tells you whether fusing or splitting it would release energy.", + "bullets": [ + "The B/A curve peaks near Fe-56 (~8.8 MeV/nucleon) — the most tightly bound nuclei.", + "Fusion of light nuclei and fission of heavy nuclei both move toward the peak and release energy.", + "Energy released = c^2 times the mass lost (mass defect): E = dm*c^2." + ], + "params": [ + { + "name": "A", + "label": "mass number A", + "min": 2, + "max": 238, + "step": 1, + "value": 56 + } + ], + "code": "H.background();\nconst w = H.W;\n// Semi-empirical (Weizsacker) binding energy per nucleon B/A vs mass number A,\n// using Z ~ A/2.2 for the stable line. Coefficients in MeV.\nconst aV = 15.75, aS = 17.8, aC = 0.711, aA = 23.7;\nfunction BperA(A) {\n if (A < 2) return 0;\n const Z = A / 2.2; // rough stable Z(A)\n const B = aV * A - aS * Math.pow(A, 2/3)\n - aC * Z * (Z - 1) / Math.pow(A, 1/3)\n - aA * Math.pow(A - 2 * Z, 2) / A;\n return Math.max(0, B / A);\n}\nconst Asel = Math.max(2, Math.min(238, P.A)); // selected nucleus mass number\nconst v = H.plot2d({ xMin: 0, xMax: 240, yMin: 0, yMax: 10 });\nv.grid(); v.axes();\nv.fn(BperA, { color: H.colors.accent, width: 3, steps: 240 });\n// Peak near A=56 (iron): mark it.\nv.dot(56, BperA(56), { r: 5, fill: H.colors.yellow });\nv.text(\"Fe-56 peak\", 60, BperA(56) + 0.9, { color: H.colors.yellow, size: 11 });\n// Selected nucleus, animated bob along the curve.\nconst Aanim = Asel + 6 * Math.sin(t * 1.2);\nconst Ac = Math.max(2, Math.min(238, Aanim));\nconst Bc = BperA(Ac);\nv.line(Ac, 0, Ac, Bc, { color: H.colors.warn, width: 1.5, dash: [4, 4] });\nv.dot(Ac, Bc, { r: 7, fill: H.colors.warn });\n// Direction-of-energy-release arrows: fusion (light, climb right) / fission (heavy, drop left toward peak).\nv.arrow(8, 1.2, 40, 1.2, { color: H.colors.good, width: 2 });\nv.text(\"fusion →\", 10, 1.9, { color: H.colors.good, size: 11 });\nv.arrow(230, 1.2, 90, 1.2, { color: H.colors.violet, width: 2 });\nv.text(\"← fission\", 150, 1.9, { color: H.colors.violet, size: 11 });\nH.text(\"Binding energy per nucleon: B/A vs A\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"A = \" + Ac.toFixed(0) + \" B/A = \" + Bc.toFixed(2) + \" MeV (peak ≈ 8.8 MeV at Fe-56)\", 24, 52, { color: H.colors.sub, size: 12 });\nH.text(Ac < 56 ? \"light nucleus → FUSE toward the peak releases energy\" : \"heavy nucleus → SPLIT toward the peak releases energy\", 24, 74, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"B/A curve\", color: H.colors.accent }, { label: \"fusion\", color: H.colors.good }, { label: \"fission\", color: H.colors.violet }], w - 150, 28);" + }, + { + "id": "ph-scalars-and-vectors", + "area": "Physics", + "topic": "Scalars and vectors", + "title": "Scalars vs vectors: |V| = sqrt(Vx^2 + Vy^2)", + "equation": "|V| = sqrt(Vx^2 + Vy^2), angle = atan2(Vy, Vx)", + "keywords": [ + "scalar", + "vector", + "magnitude", + "direction", + "components", + "vx vy", + "resultant", + "arrow", + "magnitude and direction", + "vector components", + "pythagorean", + "vector addition" + ], + "explanation": "A scalar has only size (a number with units), while a vector carries both size AND direction. Drag Vx and Vy to build a vector from its components: the dashed legs are the two scalar pieces, and the colored arrow is the full vector. Its length is the magnitude |V| = sqrt(Vx^2 + Vy^2) (a scalar), and the angle tells you the direction — together they make the vector.", + "bullets": [ + "A scalar is just a number with units (mass, time, speed); a vector also has direction.", + "Components Vx and Vy are scalars; the arrow they build is the vector.", + "Magnitude |V| = sqrt(Vx^2 + Vy^2) by the Pythagorean theorem; direction = atan2(Vy, Vx)." + ], + "params": [ + { + "name": "Vx", + "label": "x-component Vx (m)", + "min": 0, + "max": 9, + "step": 0.5, + "value": 6 + }, + { + "name": "Vy", + "label": "y-component Vy (m)", + "min": 0, + "max": 7, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -1, yMax: 9 });\nv.grid(); v.axes();\nconst Vx = P.Vx, Vy = P.Vy;\nconst mag = Math.sqrt(Vx * Vx + Vy * Vy);\nconst grow = 0.5 + 0.5 * Math.sin(t * 1.2);\nconst tx = Vx * grow, ty = Vy * grow;\nv.line(0, 0, tx, 0, { color: H.colors.accent, width: 2, dash: [5, 5] });\nv.line(tx, 0, tx, ty, { color: H.colors.good, width: 2, dash: [5, 5] });\nv.arrow(0, 0, tx, ty, { color: H.colors.warn, width: 3, head: 11 });\nv.dot(0, 0, { r: 4, fill: H.colors.ink });\nconst ang = Math.atan2(Vy, Vx) * 180 / Math.PI;\nH.text(\"Scalars and vectors\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"vector V = (\" + Vx.toFixed(1) + \", \" + Vy.toFixed(1) + \") |V| = \" + mag.toFixed(2) + \" (scalar) angle = \" + ang.toFixed(0) + \" deg\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"Vx (scalar)\", color: H.colors.accent }, { label: \"Vy (scalar)\", color: H.colors.good }, { label: \"vector V\", color: H.colors.warn }], H.W - 170, 28);" + }, + { + "id": "ph-distance-vs-displacement", + "area": "Physics", + "topic": "Distance vs displacement", + "title": "Distance vs displacement: out-and-back", + "equation": "distance = total path length; displacement = x_final - x_start", + "keywords": [ + "distance", + "displacement", + "path length", + "scalar", + "vector", + "out and back", + "net change", + "round trip", + "position", + "how far", + "displacement vs distance", + "total distance" + ], + "explanation": "Walk out to a turning point at x = A and back again, and watch the two quantities split. Distance is the whole path length you covered — it only ever grows. Displacement is the straight arrow from where you started to where you are now; on the way back it shrinks, and after a full round trip it returns to zero even though you walked 2A.", + "bullets": [ + "Distance is a scalar: the total length of the path actually travelled.", + "Displacement is a vector: straight-line change in position, start to finish.", + "On a round trip distance = 2A but displacement = 0 — they are not the same." + ], + "params": [ + { + "name": "A", + "label": "turn-around point A (m)", + "min": 1, + "max": 6, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -7, xMax: 7, yMin: -5, yMax: 5 });\nv.grid(); v.axes();\nconst A = P.A;\nconst phase = t % 4;\nconst x = phase < 2 ? (A * phase / 2) : (A * (4 - phase) / 2);\nconst dist = phase < 2 ? x : (A + (A - x));\nconst disp = x;\nv.arrow(0, 0, x, 0, { color: H.colors.warn, width: 3, head: 11 });\nv.dot(x, 0, { r: 7, fill: H.colors.accent2 });\nv.dot(0, 0, { r: 5, fill: H.colors.good });\nv.dot(A, 0, { r: 5, fill: H.colors.violet });\nH.text(\"Distance vs displacement\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"distance walked = \" + dist.toFixed(2) + \" m (path length) displacement = \" + disp.toFixed(2) + \" m (start->now)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"start\", color: H.colors.good }, { label: \"turn (x=A)\", color: H.colors.violet }, { label: \"displacement\", color: H.colors.warn }], H.W - 200, 28);" + }, + { + "id": "ph-speed-vs-velocity", + "area": "Physics", + "topic": "Speed vs velocity", + "title": "Speed vs velocity: omega = v / R", + "equation": "v = omega * R, speed = |velocity|, velocity is tangent to the path", + "keywords": [ + "speed", + "velocity", + "scalar", + "vector", + "magnitude", + "direction", + "tangent", + "circular motion", + "constant speed", + "changing velocity", + "v=omega r", + "uniform circular motion" + ], + "explanation": "A runner laps a circular track at a CONSTANT speed, yet the velocity is never constant. Speed is the scalar magnitude — how fast — and it stays fixed here. Velocity is the vector arrow, always pointing along the path (tangent); since the direction keeps turning, the velocity keeps changing even though the speed does not. The runner sweeps the circle at angular speed omega = v / R.", + "bullets": [ + "Speed is a scalar (just the magnitude); velocity is a vector with direction.", + "In circular motion the velocity is tangent to the circle and constantly re-aims.", + "Constant speed can still mean changing velocity — that turning is what acceleration is." + ], + "params": [ + { + "name": "spd", + "label": "speed v (m/s)", + "min": 1, + "max": 12, + "step": 0.5, + "value": 6 + }, + { + "name": "R", + "label": "track radius R (m)", + "min": 3, + "max": 15, + "step": 0.5, + "value": 10 + } + ], + "code": "H.background();\nconst cx = H.W * 0.42, cy = H.H * 0.55, R = Math.min(H.W, H.H) * 0.32;\nconst spd = P.spd, Rkm = P.R;\nconst omega = spd / Math.max(0.1, Rkm);\nconst ang = (omega * t) % (Math.PI * 2);\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 2 });\nconst px = cx + R * Math.cos(ang), py = cy - R * Math.sin(ang);\nconst vx = -Math.sin(ang), vy = -Math.cos(ang);\nconst aL = 18 + spd * 5;\nH.arrow(px, py, px + vx * aL, py + vy * aL, { color: H.colors.warn, width: 4, head: 12 });\nH.circle(px, py, 8, { fill: H.colors.accent2, stroke: H.colors.bg, width: 2 });\nH.line(cx, cy, px, py, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nH.circle(cx, cy, 4, { fill: H.colors.ink });\nconst lapT = (2 * Math.PI * Rkm) / Math.max(0.1, spd);\nH.text(\"Speed vs velocity\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"speed = \" + spd.toFixed(1) + \" m/s (constant) velocity direction = \" + (ang * 180 / Math.PI).toFixed(0) + \" deg (always changing) lap = \" + lapT.toFixed(1) + \" s\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"velocity (vector)\", color: H.colors.warn }, { label: \"radius\", color: H.colors.violet }], H.W - 200, 28);" + }, + { + "id": "ph-acceleration", + "area": "Physics", + "topic": "Acceleration", + "title": "Acceleration: v = v0 + a t", + "equation": "v = v0 + a t, x = v0 t + (1/2) a t^2", + "keywords": [ + "acceleration", + "velocity", + "v = v0 + a t", + "rate of change of velocity", + "kinematics", + "speeding up", + "slowing down", + "m/s^2", + "constant acceleration", + "suvat", + "initial velocity", + "deceleration" + ], + "explanation": "Acceleration is how fast the velocity itself changes, in m/s every second. A cart starts with velocity v0 and gains a m/s of speed each second, so v = v0 + a t grows linearly while its position follows x = v0 t + (1/2) a t^2. Watch the velocity arrow (warn) stretch as the constant acceleration arrow (good) keeps pushing — make a negative and the cart slows, stops, and reverses.", + "bullets": [ + "Acceleration a = change in velocity per second (units m/s^2).", + "Velocity is linear in time: v = v0 + a t; position is quadratic: x = v0 t + (1/2) a t^2.", + "a and v can point opposite ways — that is deceleration (slowing down)." + ], + "params": [ + { + "name": "v0", + "label": "initial velocity v0 (m/s)", + "min": -4, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "a", + "label": "acceleration a (m/s^2)", + "min": -3, + "max": 4, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v0 = P.v0, a = P.a;\nconst w = H.W, h = H.H;\nconst y0 = h * 0.6;\nconst x0 = w * 0.08, x1 = w * 0.92, span = x1 - x0;\nH.line(x0, y0 + 24, x1, y0 + 24, { color: H.colors.axis, width: 2 });\nconst T = 4;\nconst tt = t % T;\nconst vt = v0 + a * tt;\nconst xt = v0 * tt + 0.5 * a * tt * tt;\nconst maxX = Math.abs(v0) * T + 0.5 * Math.abs(a) * T * T + 1;\nconst px = x0 + span * H.clamp(xt / maxX, 0, 1);\nconst r = 14;\nH.circle(px, y0, r, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\nconst vdir = vt >= 0 ? 1 : -1, adir = a >= 0 ? 1 : -1;\nH.arrow(px, y0, px + vdir * (10 + Math.abs(vt) * 8), y0, { color: H.colors.warn, width: 4, head: 11 });\nH.arrow(px, y0 + 40, px + adir * (8 + Math.abs(a) * 14), y0 + 40, { color: H.colors.good, width: 3, head: 10 });\nH.text(\"v\", px + vdir * (10 + Math.abs(vt) * 8) + vdir * 6, y0 - 8, { color: H.colors.warn, size: 13 });\nH.text(\"a\", px + adir * (8 + Math.abs(a) * 14) + adir * 6, y0 + 44, { color: H.colors.good, size: 13 });\nH.text(\"Acceleration: v = v0 + a t\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"v0 = \" + v0.toFixed(1) + \" m/s a = \" + a.toFixed(1) + \" m/s^2 t = \" + tt.toFixed(2) + \" s v = \" + vt.toFixed(2) + \" m/s x = \" + xt.toFixed(2) + \" m\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"velocity v\", color: H.colors.warn }, { label: \"acceleration a\", color: H.colors.good }], w - 200, 28);" + }, + { + "id": "ph-position-velocity-graphs", + "area": "Physics", + "topic": "Position-time and velocity-time graphs", + "title": "x-t and v-t graphs: slope of x-t = v, slope of v-t = a", + "equation": "x = v0 t + (1/2) a t^2, v = v0 + a t, slope(x-t) = v, slope(v-t) = a", + "keywords": [ + "position time graph", + "velocity time graph", + "x-t graph", + "v-t graph", + "slope", + "gradient", + "kinematics graphs", + "motion graphs", + "area under curve", + "rate of change", + "x vs t", + "v vs t" + ], + "explanation": "These two stacked graphs describe the SAME motion. The top x-t curve is position vs time; its steepness (slope) at any instant equals the velocity. The bottom v-t line is velocity vs time; its slope equals the acceleration a, and it stays straight because a is constant. Slide v0 and a and watch the cursor trace both: a curving x-t (quadratic) always pairs with a tilted v-t (linear).", + "bullets": [ + "Slope of the position-time graph at a point = the velocity there.", + "Slope of the velocity-time graph = the acceleration (a straight line when a is constant).", + "Constant acceleration makes x-t a parabola and v-t a straight line." + ], + "params": [ + { + "name": "v0", + "label": "initial velocity v0 (m/s)", + "min": -4, + "max": 4, + "step": 0.5, + "value": 3 + }, + { + "name": "a", + "label": "acceleration a (m/s^2)", + "min": -2, + "max": 3, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v0 = P.v0, a = P.a;\nconst Tmax = 6;\nconst tt = t % Tmax;\nconst w = H.W, h = H.H;\nconst xMaxVal = Math.max(2, Math.abs(v0) * Tmax + 0.5 * Math.abs(a) * Tmax * Tmax);\nconst vp = H.plot2d({ xMin: 0, xMax: Tmax, yMin: -xMaxVal, yMax: xMaxVal, box: { x: 60, y: 50, w: w - 100, h: h * 0.36 } });\nvp.grid(); vp.axes();\nvp.fn(s => v0 * s + 0.5 * a * s * s, { color: H.colors.accent, width: 3 });\nconst xNow = v0 * tt + 0.5 * a * tt * tt;\nvp.dot(tt, xNow, { r: 6, fill: H.colors.warn });\nvp.text(\"x(t) [m]\", 4, xMaxVal * 0.82, { color: H.colors.accent, size: 13 });\nconst vMaxVal = Math.max(2, Math.abs(v0) + Math.abs(a) * Tmax);\nconst vv = H.plot2d({ xMin: 0, xMax: Tmax, yMin: -vMaxVal, yMax: vMaxVal, box: { x: 60, y: h * 0.55, w: w - 100, h: h * 0.36 } });\nvv.grid(); vv.axes();\nvv.fn(s => v0 + a * s, { color: H.colors.good, width: 3 });\nconst vNow = v0 + a * tt;\nvv.dot(tt, vNow, { r: 6, fill: H.colors.warn });\nvv.text(\"v(t) [m/s]\", 4, vMaxVal * 0.82, { color: H.colors.good, size: 13 });\nH.text(\"Position-time and velocity-time graphs\", 24, 24, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"t = \" + tt.toFixed(2) + \" s x = \" + xNow.toFixed(2) + \" m v = \" + vNow.toFixed(2) + \" m/s (slope of x-t = v; slope of v-t = a = \" + a.toFixed(1) + \")\", 24, h - 14, { color: H.colors.sub, size: 13 });" + }, + { + "id": "ph-resonance", + "area": "Physics", + "topic": "Resonance", + "title": "Resonance: A(f) peaks at the natural frequency f0", + "equation": "A(f) = 1 / sqrt( (1 - (f/f0)^2)^2 + (1/Q^2)*(f/f0)^2 )", + "keywords": [ + "resonance", + "natural frequency", + "driving frequency", + "resonant frequency", + "amplitude response", + "driven oscillator", + "damped oscillator", + "quality factor", + "q factor", + "resonance curve", + "f0", + "forced vibration" + ], + "explanation": "Push a swing at just the right rhythm and it builds to a huge swing; push at the wrong rhythm and almost nothing happens. That right rhythm is the natural frequency f0. The curve is the steady-state amplitude of a driven, damped oscillator versus how fast you drive it: it spikes when the driving frequency f matches f0. The quality factor Q sets how sharp and tall that spike is — low damping means a high, narrow peak (a very 'choosy' resonator); raise the damping (lower Q) and the peak flattens and broadens.", + "bullets": [ + "Amplitude is largest when the driving frequency f matches the natural frequency f0.", + "High Q (low damping) gives a tall, narrow peak; low Q gives a short, broad one.", + "Far from f0 the response is small no matter how hard you drive." + ], + "params": [ + { + "name": "f0", + "label": "natural freq f0 (Hz)", + "min": 0.5, + "max": 3.5, + "step": 0.1, + "value": 2 + }, + { + "name": "Q", + "label": "quality factor Q", + "min": 1, + "max": 12, + "step": 0.5, + "value": 6 + }, + { + "name": "fd", + "label": "driving freq f (Hz, 0 = sweep)", + "min": 0, + "max": 4, + "step": 0.1, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 4, yMin: 0, yMax: 12 });\nv.grid(); v.axes();\nconst f0 = Math.max(0.1, P.f0), Q = Math.max(0.5, P.Q), fd = Math.max(0, P.fd);\n// Driven damped oscillator steady-state amplitude (arbitrary units), resonance curve.\n// A(f) = 1 / sqrt( (1 - (f/f0)^2)^2 + (1/Q)^2 (f/f0)^2 )\nconst amp = (f) => {\n const r = f / f0;\n const denom = Math.sqrt(Math.pow(1 - r * r, 2) + (r * r) / (Q * Q));\n return denom > 1e-6 ? 1 / denom : 12;\n};\nv.fn(amp, { color: H.colors.accent, width: 3 });\n// Live driving frequency sweeps across the band, dot rides the curve.\nconst fNow = fd > 0 ? fd : (2 + 1.8 * Math.sin(t * 0.6));\nconst aNow = amp(fNow);\nv.line(fNow, 0, fNow, Math.min(11.5, aNow), { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.dot(fNow, Math.min(11.5, aNow), { r: 6 + Math.sin(t * 3), fill: H.colors.warn });\nv.line(f0, 0, f0, 12, { color: H.colors.good, width: 1.5, dash: [6, 4] });\nv.text(\"f0\", f0, 11.4, { color: H.colors.good, size: 12 });\nH.text(\"Resonance: amplitude peaks at the natural frequency\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"f0 = \" + f0.toFixed(1) + \" Hz Q = \" + Q.toFixed(1) + \" f = \" + fNow.toFixed(2) + \" Hz A = \" + aNow.toFixed(2), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"response A(f)\", color: H.colors.accent }, { label: \"natural f0\", color: H.colors.good }, { label: \"driving f\", color: H.colors.warn }], H.W - 180, 28);" + }, + { + "id": "ph-wave-properties", + "area": "Physics", + "topic": "Wave properties (wavelength, frequency, amplitude)", + "title": "Wave properties: y = A sin(k x - omega t)", + "equation": "y = A sin(k x - omega t), k = 2 pi / lambda, omega = 2 pi f, T = 1 / f", + "keywords": [ + "wave properties", + "amplitude", + "wavelength", + "frequency", + "period", + "wavenumber", + "angular frequency", + "traveling wave", + "sine wave", + "lambda", + "crest trough", + "y = a sin" + ], + "explanation": "Three numbers describe a sine wave. Amplitude A is how far the medium swings above and below rest (the warn-colored arrows) — it sets the wave's strength, not its shape. Wavelength lambda is the distance between two crests (the green span) — the spatial size of one cycle. Frequency f is how many cycles pass per second; its reciprocal is the period T = 1/f, the time for one full oscillation. Notice the violet dot: a single point of the medium just bobs up and down in place — the SHAPE travels, the matter does not.", + "bullets": [ + "Amplitude A = how far the medium displaces; it sets energy, not the cycle length.", + "Wavelength lambda = crest-to-crest distance; frequency f = cycles per second.", + "Period T = 1/f, and any single particle only oscillates — it never travels with the wave." + ], + "params": [ + { + "name": "A", + "label": "amplitude A (m)", + "min": 0.5, + "max": 2.5, + "step": 0.1, + "value": 2 + }, + { + "name": "lambda", + "label": "wavelength lambda (m)", + "min": 0.5, + "max": 4, + "step": 0.1, + "value": 2 + }, + { + "name": "f", + "label": "frequency f (Hz)", + "min": 0.1, + "max": 1.5, + "step": 0.1, + "value": 0.5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 8, yMin: -3, yMax: 3 });\nv.grid(); v.axes();\nconst A = Math.max(0.1, P.A), lambda = Math.max(0.3, P.lambda), f = Math.max(0.1, P.f);\n// Traveling wave y(x,t) = A sin(k x - w t), k = 2pi/lambda, w = 2pi f.\nconst k = 2 * Math.PI / lambda, w = 2 * Math.PI * f;\nv.fn(x => A * Math.sin(k * x - w * t), { color: H.colors.accent, width: 3 });\n// Amplitude bracket (vertical double arrow) at left.\nv.arrow(0.5, 0, 0.5, A, { color: H.colors.warn, width: 2 });\nv.arrow(0.5, 0, 0.5, -A, { color: H.colors.warn, width: 2 });\nv.text(\"A\", 0.7, A * 0.6, { color: H.colors.warn, size: 13 });\n// Wavelength marker: distance between two crests at a fixed time snapshot.\n// Crest nearest x=2 then the next crest one lambda further.\nconst phase = -w * t;\nconst firstCrest = (Math.PI / 2 - phase) / k;\nlet c0 = firstCrest;\nwhile (c0 < 1.5) c0 += lambda;\nwhile (c0 > 1.5 + lambda) c0 -= lambda;\nv.arrow(c0, 2.4, c0 + lambda, 2.4, { color: H.colors.good, width: 2 });\nv.arrow(c0 + lambda, 2.4, c0, 2.4, { color: H.colors.good, width: 2 });\nv.text(\"lambda\", c0 + lambda * 0.5 - 0.2, 2.7, { color: H.colors.good, size: 12 });\n// A particle of the medium just oscillates up/down (no net travel).\nconst px = 6;\nv.dot(px, A * Math.sin(k * px - w * t), { r: 6, fill: H.colors.violet });\nH.text(\"Wave properties: amplitude, wavelength, frequency\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"A = \" + A.toFixed(1) + \" m lambda = \" + lambda.toFixed(1) + \" m f = \" + f.toFixed(1) + \" Hz T = \" + (1 / f).toFixed(2) + \" s\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"amplitude A\", color: H.colors.warn }, { label: \"wavelength\", color: H.colors.good }, { label: \"medium point\", color: H.colors.violet }], H.W - 190, 28);" + }, + { + "id": "ph-transverse-vs-longitudinal", + "area": "Physics", + "topic": "Transverse vs longitudinal waves", + "title": "Transverse vs longitudinal: particle motion vs travel", + "equation": "displacement = A sin(k x - omega t), v = f lambda", + "keywords": [ + "transverse wave", + "longitudinal wave", + "compression rarefaction", + "particle motion", + "sound wave", + "string wave", + "polarization", + "perpendicular parallel", + "wave types", + "medium oscillation", + "p wave s wave", + "pressure wave" + ], + "explanation": "Both rows obey the SAME wave equation; the only difference is the direction the particles wiggle. In a transverse wave (top) each particle moves perpendicular to the direction of travel — like a string flicked sideways or light. In a longitudinal wave (bottom) particles move back and forth ALONG the travel direction, bunching into compressions and spreading into rarefactions — that is exactly how sound moves through air. Watch the red arrows: top points up/down, bottom points left/right, while both wave patterns march to the right.", + "bullets": [ + "Transverse: particle displacement is perpendicular to wave travel (light, string waves).", + "Longitudinal: particle displacement is parallel to travel, forming compressions (sound).", + "Both carry the pattern forward at speed v = f*lambda while the medium just oscillates in place." + ], + "params": [ + { + "name": "A", + "label": "amplitude A (m)", + "min": 0.2, + "max": 1, + "step": 0.05, + "value": 0.7 + }, + { + "name": "f", + "label": "frequency f (Hz)", + "min": 0.2, + "max": 1.2, + "step": 0.1, + "value": 0.5 + }, + { + "name": "lambda", + "label": "wavelength lambda (m)", + "min": 1, + "max": 5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst A = Math.max(0.05, P.A), f = Math.max(0.1, P.f), lambda = Math.max(0.5, P.lambda);\nconst k = 2 * Math.PI / lambda, omega = 2 * Math.PI * f;\nH.text(\"Transverse vs Longitudinal waves\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Same wave equation y/s = A sin(k x - w t); the difference is the DIRECTION particles move\", 24, 52, { color: H.colors.sub, size: 12 });\n// ---- Transverse (top): particles displace PERPENDICULAR to travel (vertical) ----\nconst tyMid = h * 0.36, xL = 70, xR = w - 40, span = xR - xL;\nH.text(\"Transverse (e.g. light, string) — particle motion is up/down\", xL, tyMid - 70, { color: H.colors.accent, size: 13, weight: 600 });\nH.line(xL, tyMid, xR, tyMid, { color: H.colors.grid, width: 1, dash: [3, 5] });\nconst N = 26;\nfor (let i = 0; i < N; i++) {\n const xWave = (i / (N - 1)) * (span / 50) * lambda * 0 + i * 0.4; // wave-space coordinate\n const px = xL + (i / (N - 1)) * span;\n const disp = A * Math.sin(k * xWave - omega * t);\n const py = tyMid - disp * 42;\n H.circle(px, py, 4, { fill: H.colors.accent });\n if (i === 18) {\n H.arrow(px, tyMid, px, py, { color: H.colors.warn, width: 2, head: 7 });\n }\n}\nH.arrow(xR - 90, tyMid - 64, xR - 20, tyMid - 64, { color: H.colors.good, width: 2 });\nH.text(\"travel\", xR - 86, tyMid - 70, { color: H.colors.good, size: 11 });\n// ---- Longitudinal (bottom): particles displace ALONG travel (horizontal) ----\nconst lyMid = h * 0.78;\nH.text(\"Longitudinal (e.g. sound) — particle motion is back/forth, making compressions\", xL, lyMid - 56, { color: H.colors.accent2, size: 13, weight: 600 });\nfor (let i = 0; i < N; i++) {\n const xWave = i * 0.4;\n const px0 = xL + (i / (N - 1)) * span;\n const disp = A * Math.sin(k * xWave - omega * t);\n const px = px0 + disp * 18; // displaced ALONG x\n H.line(px, lyMid - 22, px, lyMid + 22, { color: H.colors.accent2, width: 2 });\n if (i === 18) {\n H.arrow(px0, lyMid + 34, px, lyMid + 34, { color: H.colors.warn, width: 2, head: 7 });\n }\n}\nH.arrow(xR - 90, lyMid - 40, xR - 20, lyMid - 40, { color: H.colors.good, width: 2 });\nH.text(\"travel\", xR - 86, lyMid - 46, { color: H.colors.good, size: 11 });\nH.text(\"A = \" + A.toFixed(2) + \" m f = \" + f.toFixed(1) + \" Hz lambda = \" + lambda.toFixed(1) + \" m v = \" + (f * lambda).toFixed(1) + \" m/s\", 24, h - 16, { color: H.colors.sub, size: 13 });" + }, + { + "id": "ph-wave-speed", + "area": "Physics", + "topic": "Wave speed", + "title": "Wave speed: v = f * lambda", + "equation": "v = f * lambda", + "keywords": [ + "wave speed", + "wave velocity", + "v = f lambda", + "frequency wavelength", + "propagation speed", + "speed of a wave", + "crest speed", + "phase velocity", + "speed of sound", + "f times lambda", + "wave equation", + "how fast wave travels" + ], + "explanation": "A wave advances one whole wavelength in one period, so its speed is wavelength over period — which is the same as v = f*lambda. Track the warn-colored crest: in each cycle it slides forward by exactly one lambda (the violet bracket). Raise the frequency OR stretch the wavelength and the crest moves faster; the green arrow grows to show the larger speed. In a given medium f and lambda trade off so v stays fixed, but here you set them independently to see how each one feeds the product.", + "bullets": [ + "v = f * lambda: a crest advances one wavelength every period T = 1/f.", + "Higher frequency or longer wavelength both raise the speed.", + "In one uniform medium the speed is fixed, so f and lambda are inversely related." + ], + "params": [ + { + "name": "f", + "label": "frequency f (Hz)", + "min": 0.2, + "max": 1.5, + "step": 0.1, + "value": 0.6 + }, + { + "name": "lambda", + "label": "wavelength lambda (m)", + "min": 1, + "max": 4, + "step": 0.5, + "value": 2.5 + }, + { + "name": "A", + "label": "amplitude A (m)", + "min": 0.5, + "max": 2, + "step": 0.1, + "value": 1.5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: -2.5, yMax: 2.5 });\nv.grid(); v.axes();\nconst freq = Math.max(0.1, P.f), lambda = Math.max(0.3, P.lambda), A = Math.max(0.1, P.A);\nconst speed = freq * lambda; // v = f * lambda\nconst k = 2 * Math.PI / lambda, omega = 2 * Math.PI * freq;\n// The traveling wave itself.\nv.fn(x => A * Math.sin(k * x - omega * t), { color: H.colors.accent, width: 3 });\n// Track one crest as it moves at speed v; wrap it across the SAME window.\n// A crest of A*sin(k x - w t) sits where k x - w t = pi/2, i.e. x = lambda/4 + speed*t,\n// so the dot rides exactly on the drawn wave's crest (y = A) at every frame.\nconst span = 10;\nconst crestX = ((lambda / 4 + speed * t) % span + span) % span;\nv.dot(crestX, A, { r: 6 + Math.sin(t * 3), fill: H.colors.warn });\n// Velocity arrow on the crest showing direction + magnitude of propagation.\nconst arrowLen = Math.min(2.5, speed * 0.4);\nv.arrow(crestX, A, Math.min(9.5, crestX + arrowLen), A, { color: H.colors.good, width: 2.5 });\nv.text(\"v\", Math.min(9.4, crestX + arrowLen * 0.5), A + 0.45, { color: H.colors.good, size: 13 });\n// One wavelength bracket near the bottom.\nv.arrow(1, -2.1, 1 + lambda, -2.1, { color: H.colors.violet, width: 2 });\nv.arrow(1 + lambda, -2.1, 1, -2.1, { color: H.colors.violet, width: 2 });\nv.text(\"lambda\", 1 + lambda * 0.5 - 0.2, -1.7, { color: H.colors.violet, size: 12 });\nH.text(\"Wave speed: v = f * lambda\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f = \" + freq.toFixed(1) + \" Hz lambda = \" + lambda.toFixed(1) + \" m -> v = \" + speed.toFixed(2) + \" m/s (crest moves one lambda per period)\", 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"wave\", color: H.colors.accent }, { label: \"crest\", color: H.colors.warn }, { label: \"v = f lambda\", color: H.colors.good }], H.W - 170, 28);" + }, + { + "id": "ph-superposition-interference", + "area": "Physics", + "topic": "Superposition and interference", + "title": "Superposition: resultant A = sqrt(A1^2 + A2^2 + 2 A1 A2 cos phi)", + "equation": "y = y1 + y2, A_result = sqrt(A1^2 + A2^2 + 2*A1*A2*cos(phi))", + "keywords": [ + "superposition", + "interference", + "constructive interference", + "destructive interference", + "phase difference", + "wave addition", + "resultant amplitude", + "two waves", + "in phase out of phase", + "principle of superposition", + "overlap", + "combine waves" + ], + "explanation": "When two waves overlap, the medium's displacement at every point is simply the SUM of the two — that is the principle of superposition. The bold green curve is wave 1 plus wave 2, added point by point. The phase difference decides everything: at 0 degrees the crests line up and reinforce (constructive interference, big resultant), at 180 degrees a crest meets a trough and they cancel (destructive interference, small or zero resultant). The violet envelope marks the resultant amplitude predicted by phasor addition; slide the phase to watch it swell and shrink.", + "bullets": [ + "Superposition: overlapping waves add displacement-by-displacement, y = y1 + y2.", + "In phase (0 deg) -> constructive, amplitudes add; out of phase (180 deg) -> destructive, they subtract.", + "Resultant amplitude A = sqrt(A1^2 + A2^2 + 2*A1*A2*cos(phase))." + ], + "params": [ + { + "name": "A1", + "label": "amplitude 1 A1", + "min": 0, + "max": 2.5, + "step": 0.1, + "value": 1.5 + }, + { + "name": "A2", + "label": "amplitude 2 A2", + "min": 0, + "max": 2.5, + "step": 0.1, + "value": 1.5 + }, + { + "name": "phase", + "label": "phase difference (deg)", + "min": 0, + "max": 360, + "step": 5, + "value": 60 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 4 * Math.PI, yMin: -5, yMax: 5 });\nv.grid(); v.axes();\nconst A1 = Math.max(0, P.A1), A2 = Math.max(0, P.A2), phaseDeg = P.phase;\nconst phi = phaseDeg * Math.PI / 180;\nconst k = 1, omega = 1.2;\nconst y1 = x => A1 * Math.sin(k * x - omega * t);\nconst y2 = x => A2 * Math.sin(k * x - omega * t + phi);\n// Component waves (faint) + their superposition (bold).\nv.fn(y1, { color: H.colors.accent, width: 1.8 });\nv.fn(y2, { color: H.colors.accent2, width: 1.8 });\nv.fn(x => y1(x) + y2(x), { color: H.colors.good, width: 3 });\n// Resultant amplitude from phasor addition: Ar = sqrt(A1^2 + A2^2 + 2 A1 A2 cos phi)\nconst Ar = Math.sqrt(A1 * A1 + A2 * A2 + 2 * A1 * A2 * Math.cos(phi));\n// Mark the resultant amplitude as dashed envelope lines.\nv.line(0, Ar, 4 * Math.PI, Ar, { color: H.colors.violet, width: 1.2, dash: [5, 5] });\nv.line(0, -Ar, 4 * Math.PI, -Ar, { color: H.colors.violet, width: 1.2, dash: [5, 5] });\n// Riding dot on the resultant.\nconst xs = (t * 0.9) % (4 * Math.PI);\nv.dot(xs, y1(xs) + y2(xs), { r: 6, fill: H.colors.warn });\nconst kind = Math.abs(((phaseDeg % 360) + 360) % 360) < 30 || Math.abs(((phaseDeg % 360) + 360) % 360 - 360) < 30 ? \"constructive (in phase)\" : (Math.abs(((phaseDeg % 360) + 360) % 360 - 180) < 30 ? \"destructive (out of phase)\" : \"partial\");\nH.text(\"Superposition: waves add point-by-point\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"A1 = \" + A1.toFixed(1) + \" A2 = \" + A2.toFixed(1) + \" phase = \" + phaseDeg.toFixed(0) + \" deg -> resultant A = \" + Ar.toFixed(2) + \" (\" + kind + \")\", 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"wave 1\", color: H.colors.accent }, { label: \"wave 2\", color: H.colors.accent2 }, { label: \"sum\", color: H.colors.good }], H.W - 150, 28);" + }, + { + "id": "ph-sound-waves", + "area": "Physics", + "topic": "Sound waves", + "title": "Sound wave: y = A sin(k x − omega t)", + "equation": "y = A sin(k x - omega t), lambda = v / f", + "keywords": [ + "sound wave", + "longitudinal wave", + "pressure wave", + "wavelength", + "frequency", + "compression", + "rarefaction", + "wavenumber", + "amplitude", + "traveling wave", + "acoustics", + "lambda = v/f" + ], + "explanation": "Sound is a longitudinal pressure wave: air parcels shove back and forth along the direction the wave travels, bunching into compressions and spreading into rarefactions. The curve plots the pressure disturbance traveling to the right, while the row of dots below shows the actual air parcels crowding and thinning. Raise the frequency f and the wavelength lambda = v/f shrinks (waves pack tighter); change the wave speed v and lambda scales with it. The amplitude A sets how loud (how big the pressure swing) the sound is.", + "bullets": [ + "Sound is longitudinal: air moves parallel to the wave's travel, not across it.", + "Wavelength, frequency and speed are locked together by lambda = v / f.", + "Crests are compressions (high pressure); troughs are rarefactions (low pressure)." + ], + "params": [ + { + "name": "A", + "label": "amplitude A (loudness)", + "min": 0.5, + "max": 2.5, + "step": 0.1, + "value": 2 + }, + { + "name": "f", + "label": "frequency f (Hz)", + "min": 1, + "max": 8, + "step": 0.5, + "value": 3 + }, + { + "name": "v", + "label": "wave speed v (m/s)", + "min": 1, + "max": 6, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\n// Sound wave: a traveling pressure disturbance y = A sin(k x - omega t)\n// A = pressure amplitude, f = frequency (Hz), v = wave speed (m/s).\nconst A = P.A, f = Math.max(0.1, P.f), vw = Math.max(1, P.v);\nconst lambda = vw / f; // wavelength = speed / frequency (m)\nconst k = 2 * Math.PI / lambda; // angular wavenumber (rad/m)\nconst omega = 2 * Math.PI * f; // angular frequency (rad/s)\nconst v = H.plot2d({ xMin: 0, xMax: 4, yMin: -3, yMax: 3 });\nv.grid(); v.axes();\n// the pressure waveform travels to the right; slow time so it reads on screen\nconst tau = t * 0.25;\nconst wave = (x) => A * Math.sin(k * x - omega * tau);\nv.fn(wave, { color: H.colors.accent, width: 3 });\n// dot riding a fixed crest: x where k x - omega tau = pi/2, kept in [0,4]\nlet xc = ((Math.PI / 2 + omega * tau) / k);\nxc = xc - lambda * Math.floor(xc / lambda); // wrap into first wavelength\nif (xc < 0.2) xc += lambda;\nv.dot(xc, wave(xc), { r: 6, fill: H.colors.warn });\n// air-parcel row: dots compress (bunch up) at crests, spread at troughs\nfor (let i = 0; i <= 40; i++) {\n const x0 = i * 0.1;\n const disp = 0.06 * Math.sin(k * x0 - omega * tau); // longitudinal shove\n v.dot(x0 + disp, -2.4, { r: 2.5, fill: H.colors.sub });\n}\nv.text(\"compressions & rarefactions of air\", 0.15, -2.0, { color: H.colors.violet, size: 12 });\nH.text(\"Sound wave: y = A sin(k x − omega t)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f = \" + f.toFixed(0) + \" Hz v = \" + vw.toFixed(0) + \" m/s lambda = v/f = \" + lambda.toFixed(2) + \" m\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"pressure y\", color: H.colors.accent }, { label: \"air parcels\", color: H.colors.sub }], H.W - 170, 28);" + }, + { + "id": "ph-speed-of-sound", + "area": "Physics", + "topic": "Speed of sound", + "title": "Speed of sound: v = sqrt(gamma R T / M)", + "equation": "v = sqrt(gamma * R * T / M)", + "keywords": [ + "speed of sound", + "sound speed", + "343 m/s", + "temperature", + "ideal gas", + "adiabatic", + "time of flight", + "gamma", + "mach", + "acoustics", + "v = sqrt(gamma r t / m)", + "sound in air" + ], + "explanation": "Sound travels through air by molecules bumping their neighbors, so the speed depends on how fast those molecules already move — which is set by temperature. The formula v = sqrt(gamma R T / M) uses gamma = 1.4 for air, the gas constant R, the absolute temperature T in Kelvin, and the molar mass M. Slide T and watch the pulse race or crawl across a 340 m field; the readout shows the time of flight = distance / v. At room temperature (about 293 K) you get the familiar ~343 m/s.", + "bullets": [ + "Speed of sound rises with the square root of absolute temperature T (in Kelvin).", + "At ~293 K in air, v is about 343 m/s — the everyday value.", + "Time for sound to cross a distance D is just D / v, so warmer air = faster arrival." + ], + "params": [ + { + "name": "T", + "label": "temperature T (K)", + "min": 200, + "max": 400, + "step": 5, + "value": 293 + } + ], + "code": "H.background();\n// Speed of sound in air: v = sqrt(gamma * R * T / M)\n// gamma = 1.4 (diatomic), R = 8.314 J/mol/K, M = 0.029 kg/mol for air.\n// Slide temperature T (Kelvin) and watch the pulse cross a 340 m field.\nconst T = Math.max(1, P.T);\nconst gamma = 1.4, R = 8.314, M = 0.029;\nconst vs = Math.sqrt(gamma * R * T / M); // speed of sound (m/s)\nconst D = 340; // field length (m)\nconst flight = D / vs; // time of flight (s)\nconst v = H.plot2d({ xMin: 0, xMax: D, yMin: -1, yMax: 1 });\nv.grid({ stepX: 100 }); v.axes({ stepX: 100 });\n// emitter at x=0, listener at x=340; pulse loops across the field\nconst period = flight + 0.4; // pause before re-firing\nconst phase = t % period;\nconst xp = Math.min(D, vs * phase); // pulse position (m)\n// expanding wavefront ring drawn as a vertical pressure spike\nv.line(xp, -0.8, xp, 0.8, { color: H.colors.warn, width: 3 });\nv.dot(xp, 0, { r: 6, fill: H.colors.warn });\n// fixed markers: speaker and ear\nv.dot(0, 0, { r: 8, fill: H.colors.accent });\nv.dot(D, 0, { r: 8, fill: H.colors.good });\nv.text(\"source\", 5, 0.5, { color: H.colors.accent, size: 12 });\nv.text(\"listener\", D - 60, 0.5, { color: H.colors.good, size: 12 });\nconst arrived = vs * phase >= D;\nH.text(\"Speed of sound: v = sqrt(gamma R T / M)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"T = \" + T.toFixed(0) + \" K v = \" + vs.toFixed(1) + \" m/s travel time over \" + D + \" m = \" + flight.toFixed(2) + \" s\" + (arrived ? \" ✓ heard\" : \"\"), 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"source\", color: H.colors.accent }, { label: \"pulse\", color: H.colors.warn }, { label: \"listener\", color: H.colors.good }], H.W - 160, 28);" + }, + { + "id": "ph-doppler-effect", + "area": "Physics", + "topic": "Doppler effect", + "title": "Doppler effect: f' = f v / (v ∓ v_source)", + "equation": "f' = f * v / (v -/+ v_source)", + "keywords": [ + "doppler effect", + "doppler shift", + "moving source", + "pitch change", + "wavefronts", + "frequency shift", + "observed frequency", + "siren", + "redshift blueshift", + "acoustics", + "f' = f v / (v - vs)", + "sound pitch" + ], + "explanation": "When a source moves, each new wavefront is emitted from a point a little farther along its path, so the rings crowd together ahead of it and stretch out behind. A listener in front meets crests more often — higher frequency, higher pitch — while a listener behind meets them less often. Slide the source speed v_src and watch the circles bunch ahead (f' = f v/(v − v_src)) and spread behind (f' = f v/(v + v_src)). This is why a passing siren drops in pitch the instant it goes by.", + "bullets": [ + "Wavefronts pile up ahead of a moving source and spread out behind it.", + "Approaching listener hears a higher pitch; receding listener hears a lower pitch.", + "The shift grows as the source speed approaches the wave speed v (here 340 m/s)." + ], + "params": [ + { + "name": "f", + "label": "source frequency f (Hz)", + "min": 100, + "max": 800, + "step": 10, + "value": 500 + }, + { + "name": "vs", + "label": "source speed v_src (m/s)", + "min": 0, + "max": 250, + "step": 10, + "value": 100 + } + ], + "code": "H.background();\n// Doppler effect: f_obs = f_src * v / (v -/+ v_src) (v = 340 m/s sound speed).\n// The source travels back and forth; each wavefront is a circle centered on\n// the source's PAST position, expanding at the sound speed. Because the source\n// chases its own forward wavefronts, the rings bunch AHEAD (higher pitch) and\n// stretch BEHIND (lower pitch). Source draw-speed scales with the v_src slider,\n// so the Mach ratio (drawSpeed/cDraw) equals the real ratio v_src/v.\nconst fsrc = Math.max(1, P.f); // emitted frequency (Hz)\nconst vsrc = Math.max(0, P.vs); // source speed (m/s)\nconst c = 340; // speed of sound (m/s)\nconst v = H.plot2d({ xMin: -200, xMax: 200, yMin: -110, yMax: 110 });\nv.grid({ stepX: 100, stepY: 50 }); v.axes({ stepX: 100, stepY: 50 });\n// --- drawing scales: ring radius grows at cDraw units/s; source moves at the\n// SAME fraction of cDraw that vsrc is of c, so the picture is to scale. ---\nconst cDraw = 70; // drawing units / sec for the wavefronts\nconst vDraw = (vsrc / c) * cDraw; // source speed in drawing units (subsonic for vsrc 260) continue; // off-field, skip\n const ring = [];\n for (let i = 0; i <= 48; i++) {\n const th = i / 48 * H.TAU;\n ring.push([cxEmit + rad * Math.cos(th), rad * Math.sin(th)]);\n }\n v.path(ring, { color: H.colors.accent, width: 1.5 });\n}\n// the source + a velocity arrow\nv.dot(sx, 0, { r: 7, fill: H.colors.warn });\nv.arrow(sx, 0, sx + Math.sign(sv) * 40, 0, { color: H.colors.warn, width: 2 });\n// observed frequencies: ahead (toward) raises pitch, behind (away) lowers it\nconst denom = Math.max(1, c - vsrc);\nconst fAhead = fsrc * c / denom; // listener the source moves toward\nconst fBehind = fsrc * c / (c + vsrc); // listener the source moves away from\nconst movingRight = sv >= 0;\nv.text(movingRight ? \"ahead (compressed)\" : \"behind (stretched)\", sx + (movingRight ? 30 : -150), 80, { color: H.colors.good, size: 12 });\nH.text(\"Doppler effect: f' = f · v / (v ∓ v_src)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f = \" + fsrc.toFixed(0) + \" Hz v_src = \" + vsrc.toFixed(0) + \" m/s ahead: \" + fAhead.toFixed(0) + \" Hz (higher) behind: \" + fBehind.toFixed(0) + \" Hz (lower)\", 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"wavefronts\", color: H.colors.accent }, { label: \"source\", color: H.colors.warn }], H.W - 170, 28);" + }, + { + "id": "ph-standing-waves", + "area": "Physics", + "topic": "Standing waves", + "title": "Standing wave: y = 2A sin(kx) cos(omega t)", + "equation": "y = 2*A*sin(k*x)*cos(omega*t), wavelength = 2*L/n, nodes at x = i*L/n", + "keywords": [ + "standing wave", + "stationary wave", + "node", + "antinode", + "harmonic", + "normal mode", + "fixed ends", + "string vibration", + "wavelength", + "resonance", + "fundamental", + "overtone" + ], + "explanation": "Two identical waves travelling in opposite directions on a clamped string add up to a pattern that vibrates in place instead of moving. The slider n picks the harmonic: it sets how many half-wavelengths fit on the string, so the wavelength is 2L/n and there are always n+1 fixed nodes (red, where sin(kx)=0). The amplitude slider A scales how far the antinodes (green) swing, while cos(omega t) just makes the whole pattern breathe up and down between the faint envelope lines without ever shifting sideways.", + "bullets": [ + "Only wavelengths that fit the clamped ends survive: lambda = 2L/n.", + "Nodes never move (red); antinodes swing the most (green) at +/- 2A.", + "The wave oscillates in place via cos(omega t); it does not propagate." + ], + "params": [ + { + "name": "n", + "label": "harmonic n", + "min": 1, + "max": 6, + "step": 1, + "value": 3 + }, + { + "name": "A", + "label": "amplitude A (cm)", + "min": 0.2, + "max": 1.2, + "step": 0.1, + "value": 0.8 + }, + { + "name": "L", + "label": "string length L (m)", + "min": 0.5, + "max": 2, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 1, yMin: -2.6, yMax: 2.6 });\nv.grid(); v.axes();\nconst n = Math.max(1, Math.round(P.n)); // harmonic number (loops)\nconst A = Math.max(0.05, P.A); // amplitude of each travelling wave (cm)\nconst L = 1; // string length normalized to 1 (label in m via P.L)\nconst Lm = Math.max(0.2, P.L); // physical string length (m)\nconst k = n * Math.PI / L; // wave number for fixed-fixed string\nconst env = 2 * A; // standing-wave envelope amplitude\n// temporal factor cos(omega t): bounded oscillation\nconst ph = Math.cos(t * 2.2);\n// the standing wave y(x) = 2A sin(kx) cos(wt)\nv.fn(x => env * Math.sin(k * x) * ph, { color: H.colors.accent, width: 3 });\n// faint envelope (the +/- 2A sin(kx) bound the string never exceeds)\nv.fn(x => env * Math.sin(k * x), { color: H.colors.sub, width: 1.2 });\nv.fn(x => -env * Math.sin(k * x), { color: H.colors.sub, width: 1.2 });\n// fixed ends (the string is clamped) + nodes (sin(kx)=0) and antinodes\nfor (let i = 0; i <= n; i++) {\n const xn = i / n; // node positions: x = i*lambda/2 = i*L/n\n v.dot(xn, 0, { r: 5, fill: H.colors.warn });\n}\nfor (let i = 0; i < n; i++) {\n const xa = (i + 0.5) / n; // antinode positions (max swing)\n v.dot(xa, env * Math.sin(k * xa) * ph, { r: 5, fill: H.colors.good });\n}\nconst lam = 2 * Lm / n; // wavelength = 2L/n\nH.text(\"Standing wave: y = 2A sin(kx) cos(wt)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"harmonic n = \" + n + \" wavelength = 2L/n = \" + lam.toFixed(2) + \" m nodes = \" + (n + 1), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"string\", color: H.colors.accent }, { label: \"node\", color: H.colors.warn }, { label: \"antinode\", color: H.colors.good }], H.W - 170, 28);" + }, + { + "id": "ph-sound-intensity-decibels", + "area": "Physics", + "topic": "Sound intensity and decibels", + "title": "Decibels: beta = 10 log10(I / I0)", + "equation": "beta = 10 * log10(I / I0), I = P / (4 pi r^2)", + "keywords": [ + "decibels", + "sound intensity", + "loudness", + "inverse square law", + "db", + "intensity level", + "logarithmic scale", + "threshold of hearing", + "acoustics", + "i = p / 4 pi r^2", + "beta = 10 log i/i0", + "sound power" + ], + "explanation": "Loudness is measured in decibels because the ear responds to ratios, not differences: beta = 10 log10(I / I0), where I0 = 1e-12 W/m^2 is the faint threshold of hearing. A point source of power P spreads its energy over an expanding sphere, so the intensity falls off as I = P / (4 pi r^2) — the inverse-square law. Walk the listener in and out and watch dB drop steeply up close but flatten far away, because each factor-of-10 in intensity adds only 10 dB. Doubling the distance cuts intensity to a quarter, which costs about 6 dB.", + "bullets": [ + "Decibels are logarithmic: every 10× in intensity adds a flat 10 dB.", + "Intensity obeys the inverse-square law, I = P / (4 pi r^2).", + "Doubling the distance quarters the intensity — roughly a 6 dB drop." + ], + "params": [ + { + "name": "P", + "label": "source power P (W)", + "min": 0.01, + "max": 1, + "step": 0.01, + "value": 0.1 + } + ], + "code": "H.background();\n// Sound intensity & decibels: beta = 10 * log10(I / I0), I0 = 1e-12 W/m^2\n// A point source of power P spreads over a sphere: I = P / (4 pi r^2)\n// (inverse-square law). A listener walks in and out; watch dB drop with r.\nconst Pw = Math.max(0.001, P.P); // acoustic power of source (W)\nconst I0 = 1e-12; // hearing threshold (W/m^2)\nconst v = H.plot2d({ xMin: 0.5, xMax: 20, yMin: 0, yMax: 130 });\nv.grid({ stepX: 5, stepY: 20 }); v.axes({ stepX: 5, stepY: 20 });\n// dB-vs-distance curve (the relationship, drawn across all r)\nconst dBat = (r) => 10 * Math.log10(Math.max(I0, Pw / (4 * Math.PI * r * r)) / I0);\nv.fn(dBat, { color: H.colors.accent, width: 3 });\n// listener oscillates between r = 1 m and r = 19 m (never off-screen)\nconst r = 10 + 9 * Math.sin(t * 0.5);\nconst I = Pw / (4 * Math.PI * r * r); // intensity at listener (W/m^2)\nconst beta = 10 * Math.log10(Math.max(I0, I) / I0); // level (dB)\nv.dot(r, beta, { r: 7, fill: H.colors.warn });\nv.line(r, 0, r, beta, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.text(\"listener\", r > 14 ? r - 4 : r + 0.4, beta + 6, { color: H.colors.warn, size: 12 });\nH.text(\"Decibels: beta = 10 log10(I / I0)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"P = \" + Pw.toFixed(2) + \" W r = \" + r.toFixed(1) + \" m I = \" + I.toExponential(2) + \" W/m² beta = \" + beta.toFixed(1) + \" dB\", 24, 52, { color: H.colors.sub, size: 12 });\nH.text(\"x: distance r (m) y: level (dB)\", 24, H.H - 16, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"dB vs r\", color: H.colors.accent }, { label: \"listener\", color: H.colors.warn }], H.W - 150, 28);" + }, + { + "id": "ph-photoelectric-effect", + "area": "Physics", + "topic": "Photoelectric effect", + "title": "Photoelectric effect: KE = h f - W", + "equation": "KE = h*f - W", + "keywords": [ + "photoelectric effect", + "photoelectron", + "work function", + "threshold frequency", + "kinetic energy of ejected electron", + "stopping voltage", + "ke = hf - w", + "photon ejects electron", + "einstein photoelectric", + "quantum of light", + "metal surface electrons", + "planck constant" + ], + "explanation": "A single photon of energy h*f strikes a metal. Only if that energy beats the metal's work function W (the binding 'cost' to pull an electron out) does an electron escape, carrying the leftover as kinetic energy KE = h*f - W. Slide the frequency up to cross the threshold and watch the electron fly out faster; slide it below and nothing is ejected no matter how bright the light. Intensity changes HOW MANY electrons leave, never their individual KE - that is the quantum surprise.", + "bullets": [ + "Light comes in packets (photons) of energy h*f; one photon ejects one electron.", + "Below the threshold frequency (h*f < W) no electrons escape, however intense the beam.", + "Above threshold, extra photon energy becomes the electron's kinetic energy KE = h*f - W." + ], + "params": [ + { + "name": "freq", + "label": "light frequency f (x10^14 Hz)", + "min": 3, + "max": 12, + "step": 0.1, + "value": 7 + }, + { + "name": "work", + "label": "work function W (eV)", + "min": 1, + "max": 5, + "step": 0.1, + "value": 2.3 + }, + { + "name": "intensity", + "label": "intensity (photons/s, x10^15)", + "min": 1, + "max": 10, + "step": 1, + "value": 4 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n// Photoelectric effect: a photon of energy hf hits a metal; if hf > work function W,\n// an electron escapes with KE = hf - W. h*f in eV, work function in eV.\nconst f = P.freq; // light frequency, x10^14 Hz\nconst Wf = P.work; // work function, eV\nconst I = P.intensity; // light intensity (number of photons), arbitrary\nconst h_eV = 4.136e-15; // Planck constant in eV*s\nconst Ephoton = h_eV * (f * 1e14); // photon energy in eV\nconst KE = Ephoton - Wf; // ejected electron kinetic energy, eV\nconst ejects = KE > 0;\n// metal slab on the right\nconst metalX = w * 0.62;\nH.rect(metalX, h * 0.18, w * 0.30, h * 0.64, { fill: \"#33405e\", stroke: H.colors.axis, width: 2, radius: 6 });\nH.text(\"metal surface\", metalX + 10, h * 0.18 - 10, { color: H.colors.sub, size: 12 });\n// incoming photon (wave packet) loops in toward the surface\nconst period = 2.2;\nconst ph = (t % period) / period; // 0..1\nconst px = H.lerp(w * 0.08, metalX, ph);\nconst py = h * 0.5;\n// draw the photon as a little oscillating wave segment\nconst pts = [];\nfor (let i = 0; i <= 40; i++) {\n const xx = px - 60 + i * 1.5;\n const yy = py + 10 * Math.sin((i / 40) * H.TAU * 3 + t * 8);\n if (xx < metalX) pts.push([xx, yy]);\n}\nif (pts.length >= 2) H.path(pts, { color: H.colors.yellow, width: 2.5 });\nH.circle(px, py, 6, { fill: H.colors.yellow });\n// bound electrons sitting in the metal\nfor (let i = 0; i < 5; i++) {\n H.circle(metalX + 30 + (i % 3) * 36, h * 0.30 + Math.floor(i / 3) * 60 + 2 * Math.sin(t * 2 + i), 7, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\n}\n// ejected electron flies LEFT out of the metal after the photon arrives, then resets\nif (ejects && ph > 0.5) {\n const ej = (ph - 0.5) / 0.5; // 0..1\n const speedScale = Math.sqrt(Math.max(0, KE));\n const ex = metalX - ej * (metalX - w * 0.12) * H.clamp(speedScale, 0.3, 1.5) / 1.5;\n const ey = py - ej * 40;\n H.arrow(metalX, py, ex, ey, { color: H.colors.good, width: 2.5, head: 9 });\n H.circle(ex, ey, 7, { fill: H.colors.good, stroke: H.colors.bg, width: 2 });\n H.text(\"e-\", ex - 4, ey - 12, { color: H.colors.good, size: 12 });\n}\n// title + readouts\nH.text(\"Photoelectric effect: KE = h·f − W\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"photon energy h·f = \" + Ephoton.toFixed(2) + \" eV\", 24, 56, { color: H.colors.sub, size: 13 });\nH.text(\"work function W = \" + Wf.toFixed(2) + \" eV\", 24, 76, { color: H.colors.sub, size: 13 });\nif (ejects) {\n H.text(\"KE = h·f − W = \" + KE.toFixed(2) + \" eV → electron ejected\", 24, 96, { color: H.colors.good, size: 13 });\n} else {\n H.text(\"h·f < W → no electron ejected (below threshold)\", 24, 96, { color: H.colors.warn, size: 13 });\n}\nH.text(\"intensity sets HOW MANY electrons, never their KE\", 24, h - 18, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"photon\", color: H.colors.yellow }, { label: \"electron\", color: H.colors.good }], w - 150, 28);" + }, + { + "id": "ph-photon-energy", + "area": "Physics", + "topic": "Photon energy (E = h f)", + "title": "Photon energy: E = h f", + "equation": "E = h*f", + "keywords": [ + "photon energy", + "e = hf", + "planck relation", + "planck constant", + "frequency", + "wavelength", + "quantum of light", + "energy of a photon", + "electron volt", + "e = hc / lambda", + "light quantum", + "joules per photon" + ], + "explanation": "Each photon's energy is set entirely by its frequency through E = h*f, with h = 6.63x10^-34 J*s. Slide the frequency up and the wave wiggles faster (shorter wavelength lambda = c/f) and the energy bar climbs - blue and violet photons carry far more energy than red ones. The same idea written with wavelength is E = h*c/lambda, so short wavelength means high energy.", + "bullets": [ + "A photon's energy depends only on its frequency: E = h*f (not on brightness).", + "Higher frequency means shorter wavelength (lambda = c/f) and a more energetic photon.", + "Equivalent form E = h*c/lambda: 1 eV photon has lambda about 1240 nm." + ], + "params": [ + { + "name": "freq", + "label": "frequency f (x10^14 Hz)", + "min": 3, + "max": 12, + "step": 0.1, + "value": 5 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n// Photon energy E = h f. Slider gives frequency in units of 10^14 Hz.\nconst f14 = P.freq; // x10^14 Hz\nconst f = f14 * 1e14; // Hz\nconst hSI = 6.626e-34; // J*s\nconst h_eV = 4.136e-15; // eV*s\nconst c = 3.0e8; // m/s\nconst E_J = hSI * f; // joules\nconst E_eV = h_eV * f; // eV\nconst lambda_nm = (c / f) * 1e9; // wavelength in nm\n// approximate visible color from wavelength for the wave stroke\nfunction waveColor(nm) {\n if (nm < 400) return H.colors.violet;\n if (nm < 450) return \"#8a7bff\";\n if (nm < 490) return \"#5aa9ff\";\n if (nm < 560) return H.colors.good;\n if (nm < 590) return H.colors.yellow;\n if (nm < 635) return H.colors.accent2;\n if (nm < 700) return H.colors.warn;\n return \"#b85a5a\";\n}\nconst col = waveColor(lambda_nm);\n// a propagating EM wave across the SAME window; more wiggles when f is higher\nconst y0 = h * 0.46;\nconst wavesShown = H.clamp(f14 * 0.6, 1, 10); // spatial cycles across the box\nconst pts = [];\nconst x1 = 40, x2 = w - 40;\nfor (let i = 0; i <= 300; i++) {\n const xx = H.lerp(x1, x2, i / 300);\n const phase = (i / 300) * H.TAU * wavesShown - t * 4;\n const yy = y0 + 70 * Math.sin(phase);\n pts.push([xx, yy]);\n}\nH.path(pts, { color: col, width: 3 });\nH.line(x1, y0, x2, y0, { color: H.colors.grid, width: 1, dash: [4, 4] });\n// a marching photon dot riding the wave (loops across the window)\nconst ride = (t * 0.18) % 1;\nconst rx = H.lerp(x1, x2, ride);\nconst rPhase = ride * H.TAU * wavesShown - t * 4;\nH.circle(rx, y0 + 70 * Math.sin(rPhase), 6, { fill: H.colors.ink });\n// energy bar that grows with E (E proportional to f)\nconst barMax = h * 0.20;\nconst barH = H.clamp(E_eV / 4, 0, 1) * barMax; // 0..4 eV maps to full bar\nH.rect(w - 60, y0 + 110 - barH, 26, barH, { fill: col, stroke: H.colors.axis, width: 1, radius: 3 });\nH.text(\"E\", w - 52, y0 + 128, { color: H.colors.sub, size: 12 });\n// title + readouts\nH.text(\"Photon energy: E = h · f\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f = \" + f14.toFixed(2) + \" ×10¹⁴ Hz λ = c/f = \" + lambda_nm.toFixed(0) + \" nm\", 24, 56, { color: H.colors.sub, size: 13 });\nH.text(\"E = h·f = \" + E_eV.toFixed(2) + \" eV\", 24, 78, { color: col, size: 14, weight: 700 });\nH.text(\" = \" + E_J.toExponential(2) + \" J (h = 6.63×10⁻³⁴ J·s)\", 24, 98, { color: H.colors.sub, size: 13 });\nH.text(\"higher frequency → shorter λ → MORE energy per photon\", 24, h - 18, { color: H.colors.sub, size: 12 });" + }, + { + "id": "ph-de-broglie-wavelength", + "area": "Physics", + "topic": "Wave-particle duality and de Broglie wavelength", + "title": "de Broglie wavelength: lambda = h / (m v)", + "equation": "lambda = h / (m*v)", + "keywords": [ + "de broglie wavelength", + "wave particle duality", + "matter wave", + "lambda = h / mv", + "momentum", + "electron wavelength", + "wave packet", + "quantum particle", + "planck constant", + "p = mv", + "duality", + "matter as a wave" + ], + "explanation": "Every moving particle has a wavelength lambda = h / (m*v) = h / p, where p = m*v is its momentum. Give the particle more mass or more speed and its momentum rises, so its wavelength shrinks and the wave packet tightens up - it behaves more 'particle-like'. Slow, light objects (like a slow electron) have a long, spread-out wave, which is why electron diffraction is observable while a thrown baseball's wavelength is unmeasurably tiny.", + "bullets": [ + "Matter is wavy: a particle of momentum p has wavelength lambda = h / p.", + "More mass or more speed means more momentum, so a shorter wavelength.", + "The wavelength is only noticeable for tiny, slow objects (electrons), not everyday ones." + ], + "params": [ + { + "name": "mass", + "label": "mass m (x10^-31 kg)", + "min": 1, + "max": 50, + "step": 0.5, + "value": 9.11 + }, + { + "name": "speed", + "label": "speed v (x10^6 m/s)", + "min": 0.5, + "max": 10, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n// de Broglie: lambda = h / (m v). Use an electron-scale mass slider (x10^-31 kg)\n// and a velocity slider (x10^6 m/s) so lambda lands in nm/pm range.\nconst m31 = P.mass; // x10^-31 kg (electron ~ 9.11)\nconst v6 = P.speed; // x10^6 m/s\nconst m = m31 * 1e-31; // kg\nconst vel = v6 * 1e6; // m/s\nconst hSI = 6.626e-34; // J*s\nconst p = m * vel; // momentum, kg*m/s\nconst lambda = p > 0 ? hSI / p : Infinity; // metres\nconst lambda_nm = lambda * 1e9; // nm\n// the particle travels left->right and WRAPS, carrying a wave packet whose\n// wavelength on screen shrinks as the real lambda shrinks\nconst y0 = h * 0.50;\nconst travel = (t * 0.22) % 1;\nconst cx = H.lerp(60, w - 60, travel);\n// screen wavelength: map real lambda (nm) to pixels, clamped so it stays visible\nconst screenLambda = H.clamp(lambda_nm * 60, 14, 160); // px per cycle\nconst k = H.TAU / screenLambda;\nconst halfW = 90;\nconst pts = [];\nfor (let i = -halfW; i <= halfW; i++) {\n const xx = cx + i;\n if (xx < 30 || xx > w - 30) continue;\n const env = Math.exp(-(i * i) / (2 * 45 * 45)); // Gaussian envelope (the \"particle\")\n const yy = y0 - 60 * env * Math.cos(k * i - t * 5);\n pts.push([xx, yy]);\n}\nif (pts.length >= 2) H.path(pts, { color: H.colors.accent, width: 2.5 });\n// the particle itself at the packet centre\nH.circle(cx, y0, 8, { fill: H.colors.accent2, stroke: H.colors.bg, width: 2 });\nH.arrow(cx, y0, cx + 34, y0, { color: H.colors.warn, width: 2.5, head: 8 });\nH.text(\"v\", cx + 38, y0 + 4, { color: H.colors.warn, size: 12 });\nH.line(40, y0 + 90, w - 40, y0 + 90, { color: H.colors.grid, width: 1 });\n// title + readouts\nH.text(\"Wave–particle duality: λ = h / (m·v)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"m = \" + m31.toFixed(2) + \"×10⁻³¹ kg v = \" + v6.toFixed(2) + \"×10⁶ m/s\", 24, 56, { color: H.colors.sub, size: 13 });\nH.text(\"p = m·v = \" + p.toExponential(2) + \" kg·m/s\", 24, 78, { color: H.colors.sub, size: 13 });\nif (isFinite(lambda)) {\n H.text(\"λ = h/p = \" + lambda_nm.toFixed(3) + \" nm\", 24, 98, { color: H.colors.accent, size: 14, weight: 700 });\n} else {\n H.text(\"λ → ∞ (v = 0: a particle at rest has no de Broglie wave)\", 24, 98, { color: H.colors.warn, size: 13 });\n}\nH.text(\"more momentum → shorter wavelength → more 'particle-like'\", 24, h - 18, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"wave packet\", color: H.colors.accent }, { label: \"particle\", color: H.colors.accent2 }], w - 175, 28);" + }, + { + "id": "ph-bohr-model", + "area": "Physics", + "topic": "Bohr model and atomic energy levels", + "title": "Bohr model: E_n = -13.6 / n^2 eV", + "equation": "E_n = -13.6 / n^2 (eV)", + "keywords": [ + "bohr model", + "atomic energy levels", + "energy quantization", + "principal quantum number", + "hydrogen atom", + "e_n = -13.6 / n^2", + "electron orbit", + "bohr radius", + "ionization energy", + "quantized energy", + "ground state", + "excited state" + ], + "explanation": "In Bohr's hydrogen atom the electron may only sit in special orbits labelled by n = 1, 2, 3..., each with a fixed energy E_n = -13.6/n^2 eV. The energy is negative because the electron is bound; n = 1 (the ground state, -13.6 eV) sits deepest, and the levels crowd toward 0 as n grows. Slide n and watch the orbit radius swell as n^2 while the level highlighted on the ladder climbs - the spacing between rungs shrinks, showing energy is quantized, not continuous.", + "bullets": [ + "Only certain orbits are allowed; each has a quantized energy E_n = -13.6 / n^2 eV.", + "Orbit radius grows as n^2 (r_n = n^2 * a0) while energy rises toward 0.", + "Reaching E = 0 means the electron is freed: ionization energy from level n is 13.6 / n^2 eV." + ], + "params": [ + { + "name": "level", + "label": "principal quantum number n", + "min": 1, + "max": 6, + "step": 1, + "value": 2 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n// Bohr model of hydrogen: E_n = -13.6 / n^2 eV, r_n = n^2 * a0.\nconst n = Math.round(P.level); // principal quantum number 1..6\nconst Z = 1; // hydrogen\nconst E_n = -13.6 * Z * Z / (n * n); // eV\nconst r_n = n * n; // in Bohr radii a0\n// --- left: orbiting electron picture ---\nconst ox = w * 0.30, oy = h * 0.52;\n// draw the allowed orbits (faint), highlight the current one\nfor (let k = 1; k <= 6; k++) {\n const rr = 18 + k * k * 4.2;\n H.circle(ox, oy, rr, { stroke: k === n ? H.colors.accent : H.colors.grid, width: k === n ? 2.5 : 1 });\n}\n// nucleus\nH.circle(ox, oy, 9, { fill: H.colors.warn, stroke: H.colors.bg, width: 2 });\nH.text(\"+\", ox - 4, oy + 5, { color: H.colors.bg, size: 14, weight: 700 });\n// electron orbiting on level n; angular speed falls with n (slower far out)\nconst rOrbit = 18 + n * n * 4.2;\nconst ang = t * (2.2 / (n * n)) * H.TAU * 0.5 + 0.4;\nconst ex = ox + rOrbit * Math.cos(ang);\nconst ey = oy + rOrbit * Math.sin(ang);\nH.circle(ex, ey, 7, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\nH.text(\"e-\", ex + 8, ey - 6, { color: H.colors.accent, size: 12 });\nH.text(\"orbit radius r = n²·a₀ = \" + r_n + \"·a₀\", ox - 90, oy + 150, { color: H.colors.sub, size: 12 });\n// --- right: energy-level ladder ---\nconst lx = w * 0.66, lw = w * 0.26;\nconst topY = h * 0.16, botY = h * 0.80;\n// map E from -13.6..0 eV onto botY..topY\nfunction Ey(E) { return H.map(E, -13.6, 0, botY, topY); }\nH.line(lx, topY, lx, botY, { color: H.colors.axis, width: 1.5 });\nH.text(\"E (eV)\", lx - 6, topY - 10, { color: H.colors.sub, size: 12, align: \"right\" });\nfor (let k = 1; k <= 6; k++) {\n const Ek = -13.6 / (k * k);\n const yk = Ey(Ek);\n const isCur = k === n;\n H.line(lx, yk, lx + lw, yk, { color: isCur ? H.colors.accent : H.colors.grid, width: isCur ? 3 : 1.5 });\n H.text(\"n=\" + k, lx + lw + 6, yk + 4, { color: isCur ? H.colors.accent : H.colors.sub, size: 12 });\n H.text(Ek.toFixed(2), lx - 8, yk + 4, { color: isCur ? H.colors.ink : H.colors.sub, size: 11, align: \"right\" });\n}\n// ionization level (E=0) and the electron marker pulsing on its level\nH.line(lx, Ey(0), lx + lw, Ey(0), { color: H.colors.good, width: 1.5, dash: [4, 4] });\nH.text(\"n=∞ (ionized, E=0)\", lx + lw + 6, Ey(0) + 4, { color: H.colors.good, size: 11 });\nconst pulse = 6 + 1.5 * Math.sin(t * 4);\nH.circle(lx + lw * 0.5, Ey(E_n), pulse, { fill: H.colors.accent });\n// title + readouts\nH.text(\"Bohr model: Eₙ = −13.6 / n² eV\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"n = \" + n + \" Eₙ = \" + E_n.toFixed(2) + \" eV\", 24, 56, { color: H.colors.accent, size: 14, weight: 700 });\nH.text(\"ionization energy from this level = \" + (0 - E_n).toFixed(2) + \" eV\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"levels crowd toward 0 as n grows — energy is QUANTIZED\", 24, h - 18, { color: H.colors.sub, size: 12 });" + }, + { + "id": "ph-atomic-spectra", + "area": "Physics", + "topic": "Atomic spectra", + "title": "Atomic spectra: 1/lambda = R(1/n_lo^2 - 1/n_hi^2)", + "equation": "1/lambda = R*(1/n_lo^2 - 1/n_hi^2)", + "keywords": [ + "atomic spectra", + "emission spectrum", + "rydberg formula", + "spectral lines", + "balmer series", + "hydrogen spectrum", + "energy level transition", + "1/lambda = r(1/n1^2 - 1/n2^2)", + "photon emission", + "rydberg constant", + "line spectrum", + "electron transition" + ], + "explanation": "When an electron drops from a higher level n_hi to a lower level n_lo it releases the energy difference as a single photon, whose wavelength obeys the Rydberg formula 1/lambda = R*(1/n_lo^2 - 1/n_hi^2) with R = 1.097x10^7 /m. Set n_lo = 2 and step n_hi up through 3, 4, 5 to trace the Balmer series: the 3 to 2 jump gives the red H-alpha line at 656 nm, 4 to 2 the blue-green H-beta at 486 nm. Each allowed jump makes one sharp colored line - that discrete fingerprint is why every element has a unique spectrum.", + "bullets": [ + "A jump from n_hi down to n_lo emits one photon of energy E_hi - E_lo.", + "The wavelength follows the Rydberg formula 1/lambda = R(1/n_lo^2 - 1/n_hi^2).", + "Discrete energy levels give discrete lines: n_lo = 2 is the visible Balmer series." + ], + "params": [ + { + "name": "nlow", + "label": "lower level n_lo", + "min": 1, + "max": 4, + "step": 1, + "value": 2 + }, + { + "name": "nhigh", + "label": "upper level n_hi", + "min": 2, + "max": 7, + "step": 1, + "value": 3 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n// Atomic spectra (hydrogen): an electron drops from n_hi to n_lo, emitting a\n// photon. 1/lambda = R*(1/n_lo^2 - 1/n_hi^2), R = 1.097e7 /m.\nconst nLo = Math.round(P.nlow); // lower level (2 = Balmer, visible)\nlet nHi = Math.round(P.nhigh); // upper level\nif (nHi <= nLo) nHi = nLo + 1; // guard: emission needs n_hi > n_lo\nconst R = 1.097e7; // Rydberg constant, /m\nconst invLam = R * (1 / (nLo * nLo) - 1 / (nHi * nHi)); // 1/m\nconst lambda_m = invLam > 0 ? 1 / invLam : Infinity;\nconst lambda_nm = lambda_m * 1e9;\nconst Ephoton_eV = 1239.84 / lambda_nm; // hc/lambda with hc = 1239.84 eV*nm\n// visible color of the emitted line\nfunction waveColor(nm) {\n if (nm < 380) return H.colors.violet; // UV (draw as violet)\n if (nm < 450) return \"#8a7bff\";\n if (nm < 490) return \"#5aa9ff\";\n if (nm < 560) return H.colors.good;\n if (nm < 590) return H.colors.yellow;\n if (nm < 635) return H.colors.accent2;\n if (nm < 750) return H.colors.warn;\n return \"#b85a5a\"; // IR (draw as deep red)\n}\nconst col = waveColor(lambda_nm);\n// --- left: energy-level drop ---\nconst lx = w * 0.10, lw = w * 0.30;\nconst topY = h * 0.16, botY = h * 0.74;\nfunction Ey(E) { return H.map(E, -13.6, 0, botY, topY); }\nH.text(\"electron drops n=\" + nHi + \" → n=\" + nLo, lx, topY - 18, { color: H.colors.sub, size: 12 });\n// draw enough levels to include the upper level (n_hi can reach 7)\nconst kMax = Math.max(6, nHi);\nfor (let k = 1; k <= kMax; k++) {\n const Ek = -13.6 / (k * k);\n const yk = Ey(Ek);\n const hot = (k === nLo || k === nHi);\n H.line(lx, yk, lx + lw, yk, { color: hot ? H.colors.ink : H.colors.grid, width: hot ? 2.5 : 1.2 });\n H.text(\"n=\" + k, lx + lw + 6, yk + 4, { color: hot ? H.colors.ink : H.colors.sub, size: 11 });\n}\n// the falling electron, animated; loops: starts at n_hi, falls to n_lo, resets\nconst fall = (t % 2.4) / 2.4;\nconst yHi = Ey(-13.6 / (nHi * nHi));\nconst yLo = Ey(-13.6 / (nLo * nLo));\nconst eY = H.lerp(yHi, yLo, H.ease(fall));\nconst eX = lx + lw * 0.5;\nH.arrow(eX, yHi, eX, yLo, { color: col, width: 2, head: 8 });\nH.circle(eX, eY, 7, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\n// emitted photon shoots right when the electron lands\nif (fall > 0.85) {\n const pp = (fall - 0.85) / 0.15;\n const phx = H.lerp(eX, w - 60, pp);\n const wp = [];\n for (let i = 0; i <= 30; i++) {\n const xx = phx - 40 + i * 1.4;\n if (xx < lx + lw) continue;\n wp.push([xx, yLo + 8 * Math.sin(i * 0.9 + t * 6)]);\n }\n if (wp.length >= 2) H.path(wp, { color: col, width: 2.5 });\n}\n// --- right: spectral line on a wavelength ruler ---\nconst sx = w * 0.50, sw = w * 0.44;\nconst ruleY = h * 0.86;\nH.line(sx, ruleY, sx + sw, ruleY, { color: H.colors.axis, width: 2 });\n// ruler from 380 to 750 nm (visible band)\nconst nmMin = 380, nmMax = 750;\nfor (let nm = 400; nm <= 700; nm += 100) {\n const tx = H.map(nm, nmMin, nmMax, sx, sx + sw);\n H.line(tx, ruleY - 4, tx, ruleY + 4, { color: H.colors.sub, width: 1 });\n H.text(nm + \"\", tx, ruleY + 18, { color: H.colors.sub, size: 10, align: \"center\" });\n}\nH.text(\"wavelength (nm)\", sx + sw * 0.5, ruleY + 34, { color: H.colors.sub, size: 11, align: \"center\" });\n// the emission line, blinking\nif (isFinite(lambda_nm) && lambda_nm >= nmMin && lambda_nm <= nmMax) {\n const linex = H.map(lambda_nm, nmMin, nmMax, sx, sx + sw);\n const glow = 0.6 + 0.4 * Math.abs(Math.sin(t * 3));\n H.line(linex, ruleY - 70, linex, ruleY, { color: col, width: 3 });\n H.circle(linex, ruleY - 70, 4 + 2 * glow, { fill: col });\n H.text(lambda_nm.toFixed(0) + \" nm\", linex, ruleY - 80, { color: col, size: 12, align: \"center\", weight: 700 });\n} else {\n H.text(\"line at \" + (isFinite(lambda_nm) ? lambda_nm.toFixed(0) + \" nm (outside visible band)\" : \"∞\"), sx + sw * 0.5, ruleY - 40, { color: H.colors.sub, size: 12, align: \"center\" });\n}\n// title + readouts\nH.text(\"Atomic spectra: 1/λ = R(1/n_lo² − 1/n_hi²)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"n_lo = \" + nLo + \", n_hi = \" + nHi + \" R = 1.097×10⁷ /m\", 24, 56, { color: H.colors.sub, size: 13 });\nH.text(\"λ = \" + (isFinite(lambda_nm) ? lambda_nm.toFixed(1) + \" nm\" : \"∞\") + \" E = \" + Ephoton_eV.toFixed(2) + \" eV\", 24, 76, { color: col, size: 14, weight: 700 });\nH.legend([{ label: \"emitted photon\", color: col }], w - 175, 28);" + }, + { + "id": "ph-electric-current", + "area": "Physics", + "topic": "Electric current", + "title": "Electric current: I = Q / t", + "equation": "I = Q / t", + "keywords": [ + "electric current", + "current", + "amperes", + "amps", + "charge flow", + "coulombs per second", + "i = q/t", + "drift velocity", + "electron flow", + "conventional current", + "ampere", + "charge" + ], + "explanation": "Current I is how much charge flows past a point each second: I = Q/t, measured in amperes (1 A = 1 coulomb per second). Turn up the current and the electrons stream across the cross-section plane faster, so more charge passes per second. The carrier-density slider adds more electrons in the wire; the cross-section slider sets the wire's thickness. Note conventional current (orange arrow) points opposite the negatively-charged electrons.", + "bullets": [ + "I = Q/t: current is charge per unit time, in amperes (C/s).", + "Conventional current points opposite to the actual electron drift.", + "More charge through the cross-section each second = more current." + ], + "params": [ + { + "name": "cur", + "label": "current I (A)", + "min": 0.5, + "max": 5, + "step": 0.1, + "value": 2 + }, + { + "name": "area", + "label": "cross-section A (mm^2)", + "min": 0.5, + "max": 4, + "step": 0.1, + "value": 1.5 + }, + { + "name": "dens", + "label": "carrier density (rel.)", + "min": 1, + "max": 5, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst I = P.cur, A = P.area, n = P.dens;\nconst wireY = H.H * 0.52, wireX0 = 40, wireX1 = H.W - 40;\nconst wireLen = wireX1 - wireX0;\nH.text(\"Electric current: I = Q / t\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\n// wire thickness encodes the cross-section A (mm^2)\nconst thick = H.clamp(28 + A * 12, 28, 80);\nH.rect(wireX0, wireY - thick / 2, wireLen, thick, { fill: H.colors.panel, stroke: H.colors.grid, width: 2, radius: 8 });\nconst planeX = wireX0 + wireLen * 0.5;\nH.line(planeX, wireY - thick / 2 - 10, planeX, wireY + thick / 2 + 10, { color: H.colors.warn, width: 2, dash: [5, 5] });\nH.text(\"cross-section A\", planeX - 44, wireY - thick / 2 - 16, { color: H.colors.warn, size: 12 });\nconst q = 1.6e-19;\n// drift velocity v_d = I / (n A q): more current -> faster, but more carriers\n// or a fatter wire -> slower drift for the SAME current. (rel. units for n,A)\nconst vdrift = I / (n * A);\nconst speed = H.clamp(30 + vdrift * 90, 18, 220);\n// number of carriers shown scales with carrier density n\nconst N = Math.round(H.clamp(6 + n * 4, 6, 26));\nconst rows = 3;\nfor (let i = 0; i < N; i++) {\n const phase = (i / N);\n let xp = ((t * speed / wireLen + phase) % 1);\n const ex = wireX0 + xp * wireLen;\n const ey = wireY - (thick / 2 - 8) + (i % rows) * ((thick - 16) / (rows - 1));\n H.circle(ex, ey, 5, { fill: H.colors.accent, stroke: H.colors.bg, width: 1 });\n H.text(\"-\", ex - 2.5, ey + 3.5, { color: H.colors.bg, size: 10, weight: 700 });\n}\nH.arrow(wireX0 + 30, wireY - thick / 2 - 24, wireX0 + 130, wireY - thick / 2 - 24, { color: H.colors.accent2, width: 3 });\nH.text(\"conventional current I\", wireX0 + 30, wireY - thick / 2 - 30, { color: H.colors.accent2, size: 12 });\nconst Q = I * t;\nH.text(\"I = \" + I.toFixed(2) + \" A charge passed Q = I·t = \" + Q.toFixed(1) + \" C (after \" + t.toFixed(1) + \" s)\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"A = \" + A.toFixed(1) + \" mm² carrier density n = \" + n.toFixed(1) + \" (rel.) drift v ∝ I/(nA) = \" + vdrift.toFixed(2), 24, 76, { color: H.colors.sub, size: 12 });\nH.text(\"electrons per second through plane ~ \" + (I / q).toExponential(2), 24, H.H - 24, { color: H.colors.good, size: 12 });\nH.legend([{ label: \"electron drift\", color: H.colors.accent }, { label: \"current I\", color: H.colors.accent2 }], H.W - 180, 30);" + }, + { + "id": "ph-resistance-resistivity", + "area": "Physics", + "topic": "Resistance and resistivity", + "title": "Resistance: R = rho L / A", + "equation": "R = rho * L / A", + "keywords": [ + "resistance", + "resistivity", + "rho", + "ohms", + "r = rho l / a", + "wire resistance", + "length", + "cross sectional area", + "conductor", + "material resistivity", + "resistor", + "ohm" + ], + "explanation": "A wire's resistance R depends on three things: its length L, its cross-sectional area A, and the material's resistivity rho (Ohm-meters). Make the wire longer and charges fight through more material, so R climbs; make it thicker (larger A) and there are more lanes, so R drops. A high-rho material (poor conductor) shows up reddish and resists strongly. The dots crawl slowly when R is large and zip through when R is small.", + "bullets": [ + "R = rho·L/A: longer wire -> more resistance, thicker wire -> less.", + "Resistivity rho is the material property; copper is low, nichrome is high.", + "Doubling the length doubles R; doubling the area halves R." + ], + "params": [ + { + "name": "rho", + "label": "resistivity rho (Ohm·m)", + "min": 0.1, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "len", + "label": "length L (m)", + "min": 0.5, + "max": 5, + "step": 0.1, + "value": 2 + }, + { + "name": "area", + "label": "area A (m^2)", + "min": 0.05, + "max": 1, + "step": 0.05, + "value": 0.5 + } + ], + "code": "H.background();\nconst rho = P.rho, L = P.len, A = P.area;\nconst R = rho * L / Math.max(A, 1e-6);\nH.text(\"Resistance: R = rho · L / A\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst midY = H.H * 0.52, x0 = 60;\nconst maxLen = H.W - 200;\nconst wireLen = H.clamp(40 + (L / 5) * maxLen, 40, maxLen);\nconst thick = H.clamp(8 + A * 40, 8, 70);\nconst hue = H.clamp(210 - rho * 60, 0, 210);\nconst bandColor = H.hsl(hue, 70, 55);\nH.rect(x0, midY - thick / 2, wireLen, thick, { fill: bandColor, stroke: H.colors.grid, width: 2, radius: 6 });\nH.text(\"L (length)\", x0 + wireLen / 2 - 28, midY - thick / 2 - 10, { color: H.colors.accent, size: 12 });\nH.line(x0, midY + thick / 2 + 8, x0 + wireLen, midY + thick / 2 + 8, { color: H.colors.accent, width: 1.5 });\nH.line(x0 + wireLen + 14, midY - thick / 2, x0 + wireLen + 14, midY + thick / 2, { color: H.colors.good, width: 1.5 });\nH.text(\"A\", x0 + wireLen + 20, midY + 4, { color: H.colors.good, size: 12 });\nconst speed = H.clamp(120 / Math.max(R, 0.2), 10, 200);\nconst N = 7;\nfor (let i = 0; i < N; i++) {\n let xp = ((t * speed / wireLen + i / N) % 1);\n const ex = x0 + xp * wireLen;\n const ey = midY + (Math.sin((i + xp * 4) * 2) * thick * 0.25);\n H.circle(ex, ey, 4, { fill: H.colors.warn, stroke: H.colors.bg, width: 1 });\n}\nH.text(\"rho = \" + rho.toFixed(2) + \" Ω·m L = \" + L.toFixed(2) + \" m A = \" + A.toFixed(3) + \" m²\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"R = rho·L/A = \" + R.toFixed(2) + \" Ω\", 24, H.H - 24, { color: H.colors.good, size: 15, weight: 700 });\nH.text(\"longer wire -> more R · thicker wire -> less R · better conductor (small rho) -> less R\", 24, H.H - 6, { color: H.colors.sub, size: 11 });" + }, + { + "id": "ph-ohms-law", + "area": "Physics", + "topic": "Ohm's law", + "title": "Ohm's law: V = I R", + "equation": "V = I * R", + "keywords": [ + "ohm's law", + "ohms law", + "voltage current resistance", + "v = i r", + "i = v/r", + "resistor", + "voltage", + "current", + "resistance", + "linear resistor", + "volts amps ohms" + ], + "explanation": "Ohm's law ties voltage, current, and resistance together: V = I·R, so for a fixed resistor the current is I = V/R. The graph plots current against voltage — a straight line through the origin whose slope is 1/R, and the pulsing dot is the live operating point as the applied voltage sweeps up and down. Raise R and the line flattens (less current for the same voltage); lower R and the same voltage pushes much more current, so charges race faster around the loop.", + "bullets": [ + "V = I·R, equivalently I = V/R: current is proportional to voltage.", + "On an I-vs-V graph an ohmic resistor is a straight line with slope 1/R.", + "Bigger R -> flatter line -> less current for the same voltage." + ], + "params": [ + { + "name": "volt", + "label": "battery V (V)", + "min": 1, + "max": 12, + "step": 0.5, + "value": 9 + }, + { + "name": "res", + "label": "resistance R (Ohm)", + "min": 1, + "max": 20, + "step": 0.5, + "value": 6 + } + ], + "code": "H.background();\nconst Vmax = P.volt, R = P.res;\nH.text(\"Ohm's law: V = I · R\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst V = Vmax * (0.55 + 0.45 * Math.sin(t * 1.2));\nconst I = V / Math.max(R, 0.1);\nconst v = H.plot2d({ xMin: 0, xMax: Math.max(Vmax * 1.05, 1), yMin: 0, yMax: Math.max(Vmax / Math.max(R, 0.1) * 1.05, 0.1), box: { x: 60, y: 50, w: H.W * 0.5 - 40, h: H.H - 120 } });\nv.grid(); v.axes();\nv.fn(x => x / Math.max(R, 0.1), { color: H.colors.accent, width: 3 });\nv.dot(V, I, { r: 6 + Math.sin(t * 4), fill: H.colors.warn });\nv.text(\"operating point\", V, I, { color: H.colors.warn, size: 11 });\nH.text(\"x: voltage V (V) y: current I (A) slope = 1/R\", 60, H.H - 30, { color: H.colors.sub, size: 11 });\nconst bx = H.W * 0.62, by = 90, w = H.W * 0.3, h = H.H * 0.5;\nH.rect(bx, by, w, h, { stroke: H.colors.grid, width: 2, radius: 8 });\nH.line(bx, by + h / 2 - 16, bx, by + h / 2 + 16, { color: H.colors.good, width: 4 });\nH.line(bx + 8, by + h / 2 - 9, bx + 8, by + h / 2 + 9, { color: H.colors.good, width: 2 });\nH.text(\"V\", bx - 18, by + h / 2 + 4, { color: H.colors.good, size: 14, weight: 700 });\nconst rx0 = bx + w * 0.35, rxw = w * 0.3, ry = by;\nlet zz = [[rx0, ry]];\nfor (let k = 0; k <= 6; k++) zz.push([rx0 + (k / 6) * rxw, ry + (k % 2 ? 10 : -10)]);\nzz.push([rx0 + rxw, ry]);\nH.path(zz, { color: H.colors.accent2, width: 3 });\nH.text(\"R\", rx0 + rxw / 2 - 4, ry - 8, { color: H.colors.accent2, size: 13 });\nconst per = 2 * (w + h);\nconst dd = ((I / Math.max(Vmax / Math.max(R, 0.1), 0.1)) * 80 + t * 60) % per;\nlet px, py;\nif (dd < w) { px = bx + dd; py = by; }\nelse if (dd < w + h) { px = bx + w; py = by + (dd - w); }\nelse if (dd < 2 * w + h) { px = bx + w - (dd - w - h); py = by + h; }\nelse { px = bx; py = by + h - (dd - 2 * w - h); }\nH.circle(px, py, 5, { fill: H.colors.accent });\nH.text(\"V = \" + V.toFixed(2) + \" V R = \" + R.toFixed(2) + \" Ω I = V/R = \" + I.toFixed(3) + \" A\", H.W * 0.5, 54, { color: H.colors.sub, size: 13 });" + }, + { + "id": "ph-series-circuits", + "area": "Physics", + "topic": "Series circuits", + "title": "Series circuit: R_total = R1 + R2 + R3", + "equation": "R_total = R1 + R2 + R3", + "keywords": [ + "series circuit", + "series resistors", + "resistors in series", + "r total = r1 + r2 + r3", + "same current", + "voltage divider", + "voltage drops add", + "single loop", + "kirchhoff voltage", + "resistance" + ], + "explanation": "In a series circuit there's a single loop, so the SAME current flows through every resistor — watch the pink charges all move at one speed around the loop. The resistances simply add: R_total = R1 + R2 + R3, and the battery's current is I = V/R_total. Each resistor takes its own share of the voltage (V = I·R for that resistor), and those drops add back up to the battery voltage. Crank one resistor up and the whole loop's current falls, dimming everything.", + "bullets": [ + "Series resistances add directly: R_total = R1 + R2 + R3.", + "The same current I = V/R_total flows through every component.", + "Individual voltage drops add up to the source voltage (V1+V2+V3 = V)." + ], + "params": [ + { + "name": "volt", + "label": "battery V (V)", + "min": 1, + "max": 12, + "step": 0.5, + "value": 10 + }, + { + "name": "r1", + "label": "R1 (Ohm)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 2 + }, + { + "name": "r2", + "label": "R2 (Ohm)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 3 + }, + { + "name": "r3", + "label": "R3 (Ohm)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\nconst R1 = P.r1, R2 = P.r2, R3 = P.r3, Vb = P.volt;\nconst Rtot = R1 + R2 + R3;\nconst I = Vb / Math.max(Rtot, 0.1);\nH.text(\"Series circuit: R_total = R1 + R2 + R3\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst bx = 70, by = 90, w = H.W - 140, h = H.H - 200;\nH.rect(bx, by, w, h, { stroke: H.colors.grid, width: 2, radius: 10 });\nH.line(bx, by + h / 2 - 18, bx, by + h / 2 + 18, { color: H.colors.good, width: 5 });\nH.line(bx - 7, by + h / 2 - 10, bx - 7, by + h / 2 + 10, { color: H.colors.good, width: 2 });\nH.text(\"V = \" + Vb.toFixed(1) + \" V\", bx - 4, by + h / 2 + 40, { color: H.colors.good, size: 12 });\nconst tops = [R1, R2, R3];\nconst cols = [H.colors.accent, H.colors.accent2, H.colors.violet];\nconst seg = w / 3;\nfor (let r = 0; r < 3; r++) {\n const rx0 = bx + r * seg + seg * 0.18, rxw = seg * 0.64, ry = by;\n let zz = [[rx0, ry]];\n for (let k = 0; k <= 6; k++) zz.push([rx0 + (k / 6) * rxw, ry + (k % 2 ? 11 : -11)]);\n zz.push([rx0 + rxw, ry]);\n H.path(zz, { color: cols[r], width: 3 });\n const Vr = I * tops[r];\n H.text(\"R\" + (r + 1) + \" = \" + tops[r].toFixed(1) + \" Ω\", rx0, ry - 16, { color: cols[r], size: 12 });\n H.text(\"V\" + (r + 1) + \" = \" + Vr.toFixed(2) + \" V\", rx0, ry + 30, { color: cols[r], size: 11 });\n}\nconst per = 2 * (w + h);\nconst speed = H.clamp(40 + I * 60, 20, 200);\nconst N = 5;\nfor (let i = 0; i < N; i++) {\n const dd = ((t * speed + i * per / N) % per);\n let px, py;\n if (dd < w) { px = bx + dd; py = by; }\n else if (dd < w + h) { px = bx + w; py = by + (dd - w); }\n else if (dd < 2 * w + h) { px = bx + w - (dd - w - h); py = by + h; }\n else { px = bx; py = by + h - (dd - 2 * w - h); }\n H.circle(px, py, 5, { fill: H.colors.warn });\n}\nH.text(\"R_total = \" + Rtot.toFixed(1) + \" Ω I = V/R_total = \" + I.toFixed(3) + \" A (same in every resistor)\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"voltage drops add: V1 + V2 + V3 = \" + (I * Rtot).toFixed(2) + \" V = battery V\", 24, H.H - 22, { color: H.colors.good, size: 12 });" + }, + { + "id": "ph-parallel-circuits", + "area": "Physics", + "topic": "Parallel circuits", + "title": "Parallel circuit: 1/R_total = 1/R1 + 1/R2", + "equation": "1 / R_total = 1 / R1 + 1 / R2", + "keywords": [ + "parallel circuit", + "parallel resistors", + "resistors in parallel", + "1/r = 1/r1 + 1/r2", + "reciprocal", + "same voltage", + "currents add", + "branches", + "kirchhoff current", + "equivalent resistance" + ], + "explanation": "In a parallel circuit every branch connects to the same two rails, so each resistor feels the FULL battery voltage. Each branch carries its own current I = V/R, and those branch currents add to give the total drawn from the battery (I = I1 + I2). The reciprocals of the resistances add: 1/R_total = 1/R1 + 1/R2, which always makes R_total smaller than either branch — adding a parallel path opens another lane, so more total current flows. Drop one resistance and watch its branch's charges speed up while the other branch is unchanged.", + "bullets": [ + "In parallel the reciprocals add: 1/R_total = 1/R1 + 1/R2.", + "Every branch sees the same full voltage; branch currents add (I = I1+I2).", + "R_total is always LESS than the smallest branch resistance." + ], + "params": [ + { + "name": "volt", + "label": "battery V (V)", + "min": 1, + "max": 12, + "step": 0.5, + "value": 8 + }, + { + "name": "r1", + "label": "R1 (Ohm)", + "min": 1, + "max": 12, + "step": 0.5, + "value": 4 + }, + { + "name": "r2", + "label": "R2 (Ohm)", + "min": 1, + "max": 12, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst R1 = P.r1, R2 = P.r2, Vb = P.volt;\nconst Rtot = 1 / (1 / Math.max(R1, 0.1) + 1 / Math.max(R2, 0.1));\nconst I1 = Vb / Math.max(R1, 0.1);\nconst I2 = Vb / Math.max(R2, 0.1);\nconst Itot = I1 + I2;\nH.text(\"Parallel circuit: 1/R_total = 1/R1 + 1/R2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst leftX = 90, rightX = H.W - 90, topY = 100, botY = H.H - 90;\nconst midX = (leftX + rightX) / 2;\nH.line(leftX, (topY + botY) / 2 - 18, leftX, (topY + botY) / 2 + 18, { color: H.colors.good, width: 5 });\nH.line(leftX - 7, (topY + botY) / 2 - 10, leftX - 7, (topY + botY) / 2 + 10, { color: H.colors.good, width: 2 });\nH.text(\"V = \" + Vb.toFixed(1) + \" V\", leftX - 12, (topY + botY) / 2 + 42, { color: H.colors.good, size: 12 });\nH.line(leftX, topY, rightX, topY, { color: H.colors.grid, width: 2 });\nH.line(leftX, botY, rightX, botY, { color: H.colors.grid, width: 2 });\nH.line(leftX, topY, leftX, botY, { color: H.colors.grid, width: 2 });\nH.line(rightX, topY, rightX, botY, { color: H.colors.grid, width: 2 });\nfunction branch(bx, R, col, label, Ibr) {\n H.line(bx, topY, bx, topY + 20, { color: col, width: 2 });\n let zz = [[bx, topY + 20]];\n const zh = botY - topY - 40;\n for (let k = 0; k <= 6; k++) zz.push([bx + (k % 2 ? 11 : -11), topY + 20 + (k / 6) * zh]);\n zz.push([bx, botY - 20]);\n H.path(zz, { color: col, width: 3 });\n H.line(bx, botY - 20, bx, botY, { color: col, width: 2 });\n H.text(label + \" = \" + R.toFixed(1) + \" Ω\", bx - 30, topY - 8, { color: col, size: 12 });\n H.text(\"I = \" + Ibr.toFixed(2) + \" A\", bx - 26, botY + 18, { color: col, size: 11 });\n}\nbranch(midX - (midX - leftX) * 0.45, R1, H.colors.accent, \"R1\", I1);\nbranch(midX + (rightX - midX) * 0.45, R2, H.colors.accent2, \"R2\", I2);\nfunction flow(bx, Ibr, col, ph) {\n const span = botY - topY;\n const speed = H.clamp(30 + Ibr * 40, 15, 200);\n for (let i = 0; i < 3; i++) {\n const yp = topY + ((t * speed + (i + ph) * span / 3) % span);\n H.circle(bx, yp, 4, { fill: col, stroke: H.colors.bg, width: 1 });\n }\n}\nflow(midX - (midX - leftX) * 0.45, I1, H.colors.warn, 0);\nflow(midX + (rightX - midX) * 0.45, I2, H.colors.warn, 1.5);\nH.text(\"each branch sees the SAME voltage \" + Vb.toFixed(1) + \" V\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"R_total = \" + Rtot.toFixed(2) + \" Ω (less than either) currents add: I = I1+I2 = \" + Itot.toFixed(2) + \" A\", 24, H.H - 22, { color: H.colors.good, size: 12 });" + }, + { + "id": "ph-electrical-power", + "area": "Physics", + "topic": "Electrical power", + "title": "Electrical power: P = V*I = I^2*R", + "equation": "P = V * I = I^2 * R = V^2 / R", + "keywords": [ + "electrical power", + "power", + "watts", + "p = vi", + "i squared r", + "joule heating", + "voltage", + "current", + "resistance", + "ohms law", + "dissipation", + "energy per second" + ], + "explanation": "Power is how fast a circuit element turns electrical energy into heat or light, measured in watts (joules per second). Raise the source voltage V and Ohm's law (I = V/R) pushes more current AND each charge falls through a bigger voltage, so power climbs fast. Raise the resistance R and the current drops, so for a fixed voltage less power flows. The parabola is P = I^2*R for this resistor: the throbbing operating point sits where the present current lands, and the resistor's glow tracks the heat it dissipates.", + "bullets": [ + "Power is energy per second: 1 watt = 1 joule per second.", + "P = V*I always; substitute Ohm's law to get P = I^2*R = V^2/R.", + "Doubling the current quadruples the heat (P grows with I squared)." + ], + "params": [ + { + "name": "V", + "label": "source voltage V (volts)", + "min": 1, + "max": 12, + "step": 0.5, + "value": 12 + }, + { + "name": "R", + "label": "resistance R (ohms)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst V = P.V, R = Math.max(0.1, P.R);\nconst I = V / R;\nconst Pw = V * I;\n// auto-scale so the operating point (I, Pw) and the P = I^2 R curve stay\n// on-screen at every slider setting (I up to 12 A, P up to 144 W).\nconst xMaxV = Math.max(I * 1.15, 1);\nconst yMaxV = Math.max(Pw * 1.15, 1);\nconst v = H.plot2d({ xMin: 0, xMax: xMaxV, yMin: 0, yMax: yMaxV, pad: 52 });\nv.grid(); v.axes();\nv.fn(x => x * x * R, { color: H.colors.accent, width: 3 });\nconst pulse = 6 + 2 * Math.sin(t * 3);\nv.dot(I, Pw, { r: pulse, fill: H.colors.warn });\nv.line(I, 0, I, Pw, { color: H.colors.good, width: 1.5, dash: [4, 4] });\nv.line(0, Pw, I, Pw, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.text(\"operating point\", I, Pw, { color: H.colors.warn, size: 11 });\nH.text(\"x: current I (A) y: power P (W)\", 60, H.H - 28, { color: H.colors.sub, size: 11 });\nconst gx = H.W - 150, gy = 90;\nconst glow = H.clamp(Pw / 144, 0, 1);\nH.circle(gx, gy, 18 + 10 * glow * (0.6 + 0.4 * Math.sin(t * 4)), { fill: H.hsl(20, 90, 30 + 30 * glow) });\nH.rect(gx - 26, gy - 9, 52, 18, { stroke: H.colors.sub, width: 2, radius: 3 });\nH.text(\"R\", gx, gy + 5, { color: H.colors.ink, size: 14, align: \"center\" });\nconst ax = gx - 70 + 8 * Math.sin(t * 2);\nH.arrow(ax, gy, ax + 30, gy, { color: H.colors.accent, width: 3 });\nH.text(\"I\", ax + 14, gy - 10, { color: H.colors.accent, size: 13, align: \"center\" });\nH.text(\"Electrical power: P = V·I = I²·R\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"V = \" + V.toFixed(1) + \" V R = \" + R.toFixed(1) + \" Ω I = \" + I.toFixed(2) + \" A P = \" + Pw.toFixed(2) + \" W\", 24, 54, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"P = I²R\", color: H.colors.accent }, { label: \"operating point\", color: H.colors.warn }], 24, 78);" + }, + { + "id": "ph-rc-circuits", + "area": "Physics", + "topic": "RC circuits (charging and discharging)", + "title": "RC circuit: Vc = Vs(1 - e^(-t/tau)), tau = R*C", + "equation": "Vc(t) = Vs * (1 - e^(-t / (R*C))) charging; Vc(t) = Vs * e^(-t / (R*C)) discharging", + "keywords": [ + "rc circuit", + "charging", + "discharging", + "capacitor", + "time constant", + "tau", + "exponential decay", + "resistor capacitor", + "voltage across capacitor", + "rc time constant", + "transient", + "e to the minus t" + ], + "explanation": "A capacitor fills with charge through a resistor, so its voltage approaches the source along an exponential curve, then drains the same way. The time constant tau = R*C sets the pace: after one tau the capacitor reaches about 63% of the way to its target (and loses 63% when discharging). Raise R or C and tau grows, so the curve flattens and charging takes longer; shrink them and it snaps up almost instantly. The dot rides the live curve while the scene loops between a charging phase and a discharging phase.", + "bullets": [ + "tau = R*C is the time constant, in seconds (ohms times farads).", + "After 1 tau a capacitor reaches ~63% of full charge; after ~5 tau it is essentially full.", + "Charging climbs as 1 - e^(-t/tau); discharging falls as e^(-t/tau)." + ], + "params": [ + { + "name": "Vs", + "label": "source voltage Vs (volts)", + "min": 1, + "max": 9, + "step": 0.5, + "value": 5 + }, + { + "name": "R", + "label": "resistance R (ohms)", + "min": 0.5, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "C", + "label": "capacitance C (farads)", + "min": 0.1, + "max": 1, + "step": 0.1, + "value": 0.5 + } + ], + "code": "H.background();\nconst Vs = P.Vs, R = Math.max(0.1, P.R), C = Math.max(0.01, P.C);\nconst tau = R * C;\nconst win = Math.max(0.5, 6 * tau);\nconst phase = (t % (2 * win)) / win;\nconst charging = phase < 1;\nconst tt = (charging ? phase : phase - 1) * win;\nconst Vc = charging ? Vs * (1 - Math.exp(-tt / tau)) : Vs * Math.exp(-tt / tau);\nconst v = H.plot2d({ xMin: 0, xMax: win, yMin: 0, yMax: Vs * 1.1 + 0.01, pad: 52 });\nv.grid(); v.axes();\nif (charging) {\n v.fn(x => Vs * (1 - Math.exp(-x / tau)), { color: H.colors.accent, width: 3 });\n} else {\n v.fn(x => Vs * Math.exp(-x / tau), { color: H.colors.accent2, width: 3 });\n}\nv.line(tau, 0, tau, Vs * 1.1, { color: H.colors.violet, width: 1.5, dash: [5, 5] });\nv.text(\"τ\", tau, Vs * 1.05, { color: H.colors.violet, size: 13 });\nv.dot(tt, Vc, { r: 6, fill: H.colors.warn });\nv.line(tt, 0, tt, Vc, { color: H.colors.good, width: 1.5, dash: [4, 4] });\nH.text(\"RC circuit: Vc(t) = Vs(1 − e^(−t/τ)), τ = R·C\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text((charging ? \"CHARGING\" : \"DISCHARGING\") + \" τ = \" + tau.toFixed(2) + \" s t = \" + tt.toFixed(2) + \" s Vc = \" + Vc.toFixed(2) + \" V\", 24, 54, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"charge\", color: H.colors.accent }, { label: \"discharge\", color: H.colors.accent2 }], H.W - 150, 28);" + }, + { + "id": "ph-kirchhoffs-laws", + "area": "Physics", + "topic": "Kirchhoff's laws", + "title": "Kirchhoff's laws: sum of V = 0, I shared in series", + "equation": "V1 + V2 = Vs (loop rule), I = Vs / (R1 + R2) (same I everywhere)", + "keywords": [ + "kirchhoff", + "kirchhoffs laws", + "loop rule", + "junction rule", + "kvl", + "kcl", + "voltage law", + "current law", + "series circuit", + "voltage drop", + "conservation of charge", + "conservation of energy" + ], + "explanation": "Kirchhoff's two laws are just conservation of charge and energy. The current law (KCL) says charge can't pile up, so in this single loop the same current I flows through every element - the equally spaced charges drifting around prove it. The voltage law (KVL) says energy is conserved around any loop, so the drops across R1 and R2 must add up to exactly the battery voltage Vs. Slide R1 and R2: the bigger resistor always claims the bigger share of the voltage, but V1 + V2 stays pinned to Vs.", + "bullets": [ + "KCL (junction rule): current in equals current out - charge is conserved.", + "KVL (loop rule): voltage drops around any closed loop sum to zero.", + "In series I = Vs/(R1+R2) is shared; the larger R takes the larger voltage drop." + ], + "params": [ + { + "name": "Vs", + "label": "battery Vs (volts)", + "min": 1, + "max": 12, + "step": 0.5, + "value": 9 + }, + { + "name": "R1", + "label": "resistance R1 (ohms)", + "min": 1, + "max": 8, + "step": 0.5, + "value": 2 + }, + { + "name": "R2", + "label": "resistance R2 (ohms)", + "min": 1, + "max": 8, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst Vs = P.Vs, R1 = Math.max(0.1, P.R1), R2 = Math.max(0.1, P.R2);\nconst Rtot = R1 + R2;\nconst I = Vs / Rtot;\nconst V1 = I * R1, V2 = I * R2;\nconst w = H.W, h = H.H;\nconst L = w * 0.18, Rr = w * 0.82, T = h * 0.40, B = h * 0.82;\nH.rect(L, T, Rr - L, B - T, { stroke: H.colors.axis, width: 2 });\nH.line(L, (T + B) / 2 - 14, L, (T + B) / 2 + 14, { color: H.colors.warn, width: 4 });\nH.line(L - 7, (T + B) / 2 - 7, L + 7, (T + B) / 2 - 7, { color: H.colors.ink, width: 3 });\nH.text(\"Vs \" + Vs.toFixed(1) + \"V\", L - 12, (T + B) / 2 + 4, { color: H.colors.warn, size: 12, align: \"right\" });\nH.rect((L + Rr) / 2 - 26, T - 9, 52, 18, { fill: H.colors.panel, stroke: H.colors.accent, width: 2, radius: 3 });\nH.text(\"R1\", (L + Rr) / 2, T + 5, { color: H.colors.accent, size: 12, align: \"center\" });\nH.rect(Rr - 9, (T + B) / 2 - 26, 18, 52, { fill: H.colors.panel, stroke: H.colors.accent2, width: 2, radius: 3 });\nH.text(\"R2\", Rr + 14, (T + B) / 2 + 4, { color: H.colors.accent2, size: 12, align: \"center\" });\nconst top = Rr - L, side = B - T;\nconst peri = 2 * (top + side);\nconst speed = 40 + I * 60;\nconst N = 12;\nfor (let k = 0; k < N; k++) {\n let s = ((t * speed + k * peri / N) % peri);\n let x, y;\n if (s < top) { x = L + s; y = T; }\n else if (s < top + side) { x = Rr; y = T + (s - top); }\n else if (s < 2 * top + side) { x = Rr - (s - top - side); y = B; }\n else { x = L; y = B - (s - 2 * top - side); }\n H.circle(x, y, 4, { fill: H.colors.good });\n}\nH.arrow((L + Rr) / 2 - 14, T, (L + Rr) / 2 + 14, T, { color: H.colors.good, width: 3 });\nH.text(\"Kirchhoff: ΣV = 0 (V1 + V2 = Vs), I same all around\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"I = Vs/(R1+R2) = \" + I.toFixed(2) + \" A V1 = \" + V1.toFixed(2) + \" V V2 = \" + V2.toFixed(2) + \" V V1+V2 = \" + (V1 + V2).toFixed(2) + \" V\", 24, 54, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"moving charge (I)\", color: H.colors.good }, { label: \"R1\", color: H.colors.accent }, { label: \"R2\", color: H.colors.accent2 }], 24, 76);" + }, + { + "id": "ph-elastic-collisions", + "area": "Physics", + "topic": "Elastic collisions", + "title": "Elastic collision: p and KE both conserved", + "equation": "v1' = ((m1 - m2) v1 + 2 m2 v2) / (m1 + m2), v2' = ((m2 - m1) v2 + 2 m1 v1) / (m1 + m2)", + "keywords": [ + "elastic collision", + "elastic", + "collision", + "momentum", + "conservation of momentum", + "kinetic energy", + "bounce", + "rebound", + "two carts", + "m1 v1 + m2 v2", + "exchange velocities", + "conserved" + ], + "explanation": "In an elastic collision the carts bounce apart and BOTH momentum and kinetic energy survive the hit. Slide the masses to change how each cart recoils: equal masses simply swap velocities, while a heavy cart barely slows as it knocks a light one ahead. Set the incoming speeds v1 and v2 (positive = rightward) and watch the live readout — the total momentum p (kg·m/s) and total KE (J) read the SAME before and after, which is exactly what 'elastic' means.", + "bullets": [ + "Momentum p = m1·v1 + m2·v2 is conserved (the p readout never changes).", + "Kinetic energy is also conserved — unique to elastic collisions.", + "Equal masses just exchange velocities; a wall (huge m) reverses the ball." + ], + "params": [ + { + "name": "m1", + "label": "mass m1 (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "m2", + "label": "mass m2 (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 1 + }, + { + "name": "v1", + "label": "speed v1 (m/s)", + "min": -4, + "max": 4, + "step": 0.5, + "value": 3 + }, + { + "name": "v2", + "label": "speed v2 (m/s)", + "min": -4, + "max": 4, + "step": 0.5, + "value": -1 + } + ], + "code": "H.background();\n// Elastic collision in 1D: both momentum AND kinetic energy are conserved.\n// Two carts approach, collide at center, and rebound with new velocities given\n// by the elastic-collision formulas. P sets the masses and incoming speeds.\nconst w = H.W, ht = H.H;\nconst m1 = Math.max(0.2, P.m1), m2 = Math.max(0.2, P.m2);\nconst u1 = P.v1, u2 = P.v2; // initial velocities (m/s)\n// Post-collision velocities (1D elastic):\nconst M = m1 + m2;\nconst v1f = ((m1 - m2) * u1 + 2 * m2 * u2) / M;\nconst v2f = ((m2 - m1) * u2 + 2 * m1 * u1) / M;\n// Track view in meters; carts meet at x = 0 at the collision instant tc.\nconst view = H.plot2d({ xMin: -10, xMax: 10, yMin: -3, yMax: 3, box: { x: 40, y: ht * 0.40, w: w - 80, h: ht * 0.34 } });\nview.line(-10, -1.2, 10, -1.2, { color: H.colors.axis, width: 2 });\nfor (let gx = -10; gx <= 10; gx += 2) view.line(gx, -1.2, gx, -1.5, { color: H.colors.grid, width: 1 });\n// Animation: loop period so carts come in, collide at tc, separate, then reset.\nconst period = 6.0, tc = 3.0;\nconst tt = t % period;\n// Choose start offsets so both reach x=0 at t=tc (guard against zero speed).\nconst x1 = u1 * (tt - tc);\nconst x2 = u2 * (tt - tc);\n// Use post-collision velocities after tc for visual rebound.\nconst X1 = tt < tc ? x1 : v1f * (tt - tc);\nconst X2 = tt < tc ? x2 : v2f * (tt - tc);\n// Cart sizes scale with mass (radius in pixels).\nconst r1 = 12 + 10 * Math.sqrt(m1), r2 = 12 + 10 * Math.sqrt(m2);\nconst cx1 = view.X(H.clamp(X1, -9.5, 9.5)), cy = view.Y(-0.6);\nconst cx2 = view.X(H.clamp(X2, -9.5, 9.5));\nH.circle(cx1, cy, r1 * 0.5, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\nH.circle(cx2, cy, r2 * 0.5, { fill: H.colors.accent2, stroke: H.colors.bg, width: 2 });\n// Velocity arrows.\nconst vNow1 = tt < tc ? u1 : v1f, vNow2 = tt < tc ? u2 : v2f;\nH.arrow(cx1, cy - r1 * 0.5 - 8, cx1 + vNow1 * 14, cy - r1 * 0.5 - 8, { color: H.colors.accent, width: 2.5 });\nH.arrow(cx2, cy - r2 * 0.5 - 8, cx2 + vNow2 * 14, cy - r2 * 0.5 - 8, { color: H.colors.accent2, width: 2.5 });\n// Conserved quantities.\nconst pBefore = m1 * u1 + m2 * u2;\nconst pAfter = m1 * v1f + m2 * v2f;\nconst keBefore = 0.5 * m1 * u1 * u1 + 0.5 * m2 * u2 * u2;\nconst keAfter = 0.5 * m1 * v1f * v1f + 0.5 * m2 * v2f * v2f;\nH.text(\"Elastic collision: p and KE both conserved\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"v1' = ((m1−m2)v1 + 2 m2 v2)/(m1+m2) v2' = ((m2−m1)v2 + 2 m1 v1)/(m1+m2)\", 24, 52, { color: H.colors.sub, size: 12 });\nH.text(\"before: v1=\" + u1.toFixed(1) + \" v2=\" + u2.toFixed(1) + \" m/s after: v1'=\" + v1f.toFixed(2) + \" v2'=\" + v2f.toFixed(2) + \" m/s\", 24, ht - 56, { color: H.colors.ink, size: 13 });\nH.text(\"p = \" + pBefore.toFixed(2) + \" → \" + pAfter.toFixed(2) + \" kg·m/s KE = \" + keBefore.toFixed(2) + \" → \" + keAfter.toFixed(2) + \" J\", 24, ht - 34, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"m1 = \" + m1.toFixed(1) + \" kg\", color: H.colors.accent }, { label: \"m2 = \" + m2.toFixed(1) + \" kg\", color: H.colors.accent2 }], w - 160, 28);" + }, + { + "id": "ph-inelastic-collisions", + "area": "Physics", + "topic": "Inelastic collisions", + "title": "Inelastic collision: vf = (m1 v1 + m2 v2) / (m1 + m2)", + "equation": "vf = (m1 v1 + m2 v2) / (m1 + m2)", + "keywords": [ + "inelastic collision", + "perfectly inelastic", + "stick together", + "collision", + "momentum", + "conservation of momentum", + "kinetic energy lost", + "common velocity", + "combined mass", + "m1 v1 + m2 v2", + "energy loss", + "conserved momentum" + ], + "explanation": "In a perfectly inelastic collision the carts STICK and move off as one combined lump at the common velocity vf. Momentum still has to balance, so vf is just the mass-weighted average of the incoming speeds — set the masses and speeds and watch p (kg·m/s) read the same before and after. Kinetic energy, however, does NOT survive: the readout shows KE drop, with the lost energy going into heat and deformation when they crunch together.", + "bullets": [ + "They stick, so both share one velocity vf = total momentum / total mass.", + "Momentum is conserved; the p readout is identical before and after.", + "Kinetic energy is LOST (to heat/deformation) — KE_after < KE_before." + ], + "params": [ + { + "name": "m1", + "label": "mass m1 (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "m2", + "label": "mass m2 (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 1 + }, + { + "name": "v1", + "label": "speed v1 (m/s)", + "min": -4, + "max": 4, + "step": 0.5, + "value": 3 + }, + { + "name": "v2", + "label": "speed v2 (m/s)", + "min": -4, + "max": 4, + "step": 0.5, + "value": -1 + } + ], + "code": "H.background();\n// Perfectly inelastic collision in 1D: the two carts STICK together. Momentum\n// is conserved, but kinetic energy is LOST (to heat/deformation). They approach,\n// collide at center, then travel as one combined lump at the common velocity vf.\nconst w = H.W, ht = H.H;\nconst m1 = Math.max(0.2, P.m1), m2 = Math.max(0.2, P.m2);\nconst u1 = P.v1, u2 = P.v2; // initial velocities (m/s)\nconst M = m1 + m2;\nconst vf = (m1 * u1 + m2 * u2) / M; // common velocity after sticking\nconst view = H.plot2d({ xMin: -10, xMax: 10, yMin: -3, yMax: 3, box: { x: 40, y: ht * 0.40, w: w - 80, h: ht * 0.34 } });\nview.line(-10, -1.2, 10, -1.2, { color: H.colors.axis, width: 2 });\nfor (let gx = -10; gx <= 10; gx += 2) view.line(gx, -1.2, gx, -1.5, { color: H.colors.grid, width: 1 });\nconst period = 6.0, tc = 3.0;\nconst tt = t % period;\n// Before tc each cart moves at its own speed; both reach x≈0 at tc, then the\n// stuck pair drifts together at vf.\nconst X1 = tt < tc ? u1 * (tt - tc) : vf * (tt - tc);\nconst X2 = tt < tc ? u2 * (tt - tc) : vf * (tt - tc);\nconst r1 = 12 + 10 * Math.sqrt(m1), r2 = 12 + 10 * Math.sqrt(m2);\nconst cy = view.Y(-0.6);\nconst cx1 = view.X(H.clamp(X1, -9.5, 9.5));\nconst cx2 = view.X(H.clamp(X2, -9.5, 9.5));\nH.circle(cx1, cy, r1 * 0.5, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\nH.circle(cx2, cy, r2 * 0.5, { fill: H.colors.accent2, stroke: H.colors.bg, width: 2 });\n// After collision draw a bond linking them (they move as one body).\nif (tt >= tc) H.line(cx1, cy, cx2, cy, { color: H.colors.violet, width: 3 });\nconst vNow1 = tt < tc ? u1 : vf, vNow2 = tt < tc ? u2 : vf;\nH.arrow(cx1, cy - r1 * 0.5 - 8, cx1 + vNow1 * 14, cy - r1 * 0.5 - 8, { color: H.colors.accent, width: 2.5 });\nH.arrow(cx2, cy - r2 * 0.5 - 8, cx2 + vNow2 * 14, cy - r2 * 0.5 - 8, { color: H.colors.accent2, width: 2.5 });\nconst pBefore = m1 * u1 + m2 * u2;\nconst pAfter = M * vf;\nconst keBefore = 0.5 * m1 * u1 * u1 + 0.5 * m2 * u2 * u2;\nconst keAfter = 0.5 * M * vf * vf;\nconst keLost = keBefore - keAfter;\nH.text(\"Inelastic collision: p conserved, KE lost\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"vf = (m1 v1 + m2 v2) / (m1 + m2) they stick and move together\", 24, 52, { color: H.colors.sub, size: 12 });\nH.text(\"before: v1=\" + u1.toFixed(1) + \" v2=\" + u2.toFixed(1) + \" m/s after: vf = \" + vf.toFixed(2) + \" m/s (both)\", 24, ht - 56, { color: H.colors.ink, size: 13 });\nH.text(\"p = \" + pBefore.toFixed(2) + \" → \" + pAfter.toFixed(2) + \" kg·m/s KE = \" + keBefore.toFixed(2) + \" → \" + keAfter.toFixed(2) + \" J (lost \" + keLost.toFixed(2) + \" J)\", 24, ht - 34, { color: H.colors.warn, size: 13 });\nH.legend([{ label: \"m1 = \" + m1.toFixed(1) + \" kg\", color: H.colors.accent }, { label: \"m2 = \" + m2.toFixed(1) + \" kg\", color: H.colors.accent2 }], w - 160, 28);" + }, + { + "id": "ph-newtons-first-law", + "area": "Physics", + "topic": "Newton's first law (inertia)", + "title": "Newton's 1st law: F_net = 0 → v constant", + "equation": "F_net = 0 => v = constant (friction: a = -mu * g)", + "keywords": [ + "newton's first law", + "inertia", + "constant velocity", + "net force zero", + "friction", + "frictionless", + "law of inertia", + "equilibrium", + "deceleration", + "coefficient of friction", + "mu", + "object at rest" + ], + "explanation": "Newton's first law says an object keeps its velocity unless a net force acts on it. Set friction mu = 0 and the puck glides forever at the same speed (green velocity arrow never shrinks) because the net force is zero. Add friction and a backward net force (red arrow) appears, decelerating the puck at a = mu*g until it stops — proving that a CHANGE in motion always needs a net force.", + "bullets": [ + "With zero net force, velocity stays constant — speed and direction unchanged.", + "Friction is the net force here; it decelerates the puck at a = mu*g.", + "Inertia is the tendency to resist any change in motion." + ], + "params": [ + { + "name": "v0", + "label": "initial speed v0 (m/s)", + "min": 1, + "max": 12, + "step": 0.5, + "value": 8 + }, + { + "name": "mu", + "label": "friction mu", + "min": 0, + "max": 0.5, + "step": 0.01, + "value": 0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 20, yMin: -3, yMax: 7 });\nv.grid(); v.axes();\nconst v0 = P.v0, mu = Math.max(0, P.mu), g = 9.8;\n// Friction decelerates: a = -mu*g. Puck slides, stops, then loops.\nconst a = mu * g;\nconst tStop = a > 1e-6 ? v0 / a : Infinity;\nconst dStop = a > 1e-6 ? v0 * v0 / (2 * a) : Infinity;\n// loop period: travel + a short pause; if frictionless, just wrap the track.\nconst period = a > 1e-6 ? tStop + 1.5 : 20 / Math.max(0.1, v0);\nconst tc = t % period;\nlet x, vel;\nif (a > 1e-6) {\n if (tc < tStop) { x = v0 * tc - 0.5 * a * tc * tc; vel = v0 - a * tc; }\n else { x = dStop; vel = 0; }\n} else {\n x = (v0 * tc) % 20; vel = v0;\n}\nx = Math.min(x, 19.5);\nconst yTrack = 0;\n// track\nv.line(0, yTrack, 20, yTrack, { color: H.colors.axis, width: 2 });\n// puck\nv.circle(x, yTrack + 0.45, 12, { fill: H.colors.accent2, stroke: H.colors.bg, width: 2 });\n// velocity arrow (scaled)\nif (vel > 0.01) v.arrow(x, yTrack + 1.6, x + Math.min(6, vel * 0.6), yTrack + 1.6, { color: H.colors.good, width: 3 });\n// net force arrow (friction, opposes motion) only when moving and mu>0\nif (a > 1e-6 && vel > 0.01) v.arrow(x, yTrack + 0.45, x - Math.min(4, a * 0.3), yTrack + 0.45, { color: H.colors.warn, width: 3 });\nH.text(\"Newton's 1st law: no net force → constant velocity\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nconst law = (mu < 1e-6) ? \"F_net = 0 → v stays constant\" : \"F_net = friction → v decreases\";\nH.text(law, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"v = \" + vel.toFixed(2) + \" m/s x = \" + x.toFixed(2) + \" m μ = \" + mu.toFixed(2), 24, 74, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"velocity v\", color: H.colors.good }, { label: \"net force\", color: H.colors.warn }], H.W - 170, 28);" + }, + { + "id": "ph-newtons-second-law", + "area": "Physics", + "topic": "Newton's second law (F = ma)", + "title": "Newton's 2nd law: F = m a", + "equation": "F = m * a => a = F / m", + "keywords": [ + "newton's second law", + "f = ma", + "force mass acceleration", + "acceleration", + "net force", + "a = f/m", + "newton", + "kilogram", + "dynamics", + "newtons", + "cart", + "second law" + ], + "explanation": "Newton's second law links force, mass, and acceleration: a = F/m. Push the cart with a larger force F (red arrow) and it speeds up faster — the green velocity arrow grows more quickly. Increase the mass m and the SAME force produces a smaller acceleration, because heavier objects resist changes in motion more. The readout shows a = F/m updating live as you tune the sliders.", + "bullets": [ + "Acceleration is proportional to net force: double F, double a.", + "Acceleration is inversely proportional to mass: double m, halve a.", + "1 newton accelerates 1 kg at 1 m/s² (F in N, m in kg, a in m/s²)." + ], + "params": [ + { + "name": "F", + "label": "force F (N)", + "min": -10, + "max": 10, + "step": 0.5, + "value": 6 + }, + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 8, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 20, yMin: -3, yMax: 7 });\nv.grid(); v.axes();\nconst F = P.F, m = Math.max(0.1, P.m);\nconst a = F / m; // Newton's 2nd law: a = F/m\n// Cart starts at rest, accelerates under F, resets when it reaches the wall.\nconst track = 18;\nconst period = a > 1e-6 ? Math.sqrt(2 * track / a) + 1.2 : 6;\nconst tc = t % period;\nlet x, vel;\nif (a > 1e-6) {\n const tReach = Math.sqrt(2 * track / a);\n if (tc < tReach) { x = 0.5 * a * tc * tc; vel = a * tc; }\n else { x = track; vel = a * tReach; }\n} else { x = 0; vel = 0; }\nx = Math.min(x, track);\nconst yTrack = 0;\nv.line(0, yTrack, 20, yTrack, { color: H.colors.axis, width: 2 });\n// cart body\nv.rect(x - 0.6, yTrack, 1.2, 0.9, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\n// applied force arrow (constant, points in motion direction)\nconst fLen = Math.min(6, Math.abs(F) * 0.5);\nv.arrow(x, yTrack + 0.45, x + (F >= 0 ? fLen : -fLen), yTrack + 0.45, { color: H.colors.warn, width: 4 });\n// velocity arrow\nif (vel > 0.01) v.arrow(x, yTrack + 1.8, x + Math.min(6, vel * 0.5), yTrack + 1.8, { color: H.colors.good, width: 3 });\nH.text(\"Newton's 2nd law: F = m·a\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a = F / m = \" + F.toFixed(1) + \" / \" + m.toFixed(1) + \" = \" + a.toFixed(2) + \" m/s²\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"v = \" + vel.toFixed(2) + \" m/s x = \" + x.toFixed(2) + \" m\", 24, 74, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"force F (N)\", color: H.colors.warn }, { label: \"velocity v\", color: H.colors.good }], H.W - 170, 28);" + }, + { + "id": "ph-newtons-third-law", + "area": "Physics", + "topic": "Newton's third law", + "title": "Newton's 3rd law: F(A on B) = -F(B on A)", + "equation": "F_(A on B) = - F_(B on A) => a = F / m for each", + "keywords": [ + "newton's third law", + "action reaction", + "equal and opposite", + "force pairs", + "third law", + "recoil", + "push off", + "reaction force", + "interaction", + "momentum", + "skaters", + "action-reaction pair" + ], + "explanation": "Newton's third law says forces come in equal, opposite pairs: when A pushes B, B pushes back on A with the same magnitude in the opposite direction. The two skaters push off and the red and purple arrows are always the same length pointing apart. Yet they don't move identically — each acceleration is a = F/m, so the lighter block speeds away faster. Equal forces, unequal accelerations.", + "bullets": [ + "Action and reaction are equal in size and opposite in direction.", + "The two forces act on DIFFERENT objects, so they never cancel.", + "Same force, different mass → lighter object accelerates more (a = F/m)." + ], + "params": [ + { + "name": "F", + "label": "push force F (N)", + "min": 1, + "max": 8, + "step": 0.5, + "value": 4 + }, + { + "name": "mA", + "label": "mass A (kg)", + "min": 1, + "max": 8, + "step": 0.5, + "value": 2 + }, + { + "name": "mB", + "label": "mass B (kg)", + "min": 1, + "max": 8, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -4, yMax: 6 });\nv.grid(); v.axes();\nconst mA = Math.max(0.1, P.mA), mB = Math.max(0.1, P.mB), F = Math.abs(P.F);\n// Two skaters push off. SAME force magnitude on each (3rd law), opposite\n// directions. They accelerate apart; lighter one moves faster: a = F/m.\nconst aA = F / mA, aB = F / mB;\nconst phase = 2.2; // push lasts a moment, then coast/reset\nconst tc = t % 5;\nconst tp = Math.min(tc, phase);\n// displacement during push (const accel), then constant-velocity coast\nlet xA, xB;\nconst dPush = 0.5; // scale displacement to fit on-screen\nif (tc <= phase) {\n xA = -1 - 0.5 * aA * tp * tp * dPush;\n xB = 1 + 0.5 * aB * tp * tp * dPush;\n} else {\n const vA = aA * phase, vB = aB * phase, dt = tc - phase;\n xA = -1 - (0.5 * aA * phase * phase + vA * dt) * dPush;\n xB = 1 + (0.5 * aB * phase * phase + vB * dt) * dPush;\n}\nxA = Math.max(-9.4, xA); xB = Math.min(9.4, xB);\nconst y0 = 0;\nv.line(-10, y0, 10, y0, { color: H.colors.axis, width: 2 });\n// blocks sized by mass\nconst sA = 0.5 + mA * 0.18, sB = 0.5 + mB * 0.18;\nv.rect(xA - sA / 2, y0, sA, sA, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\nv.rect(xB - sB / 2, y0, sB, sB, { fill: H.colors.accent2, stroke: H.colors.bg, width: 2 });\n// equal & opposite force arrows during the push\nif (tc <= phase) {\n const fLen = Math.min(4, F * 0.6);\n v.arrow(xA, y0 + 1.4, xA - fLen, y0 + 1.4, { color: H.colors.warn, width: 4 });\n v.arrow(xB, y0 + 1.4, xB + fLen, y0 + 1.4, { color: H.colors.violet, width: 4 });\n}\nH.text(\"Newton's 3rd law: F(A on B) = − F(B on A)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"|F| on each = \" + F.toFixed(1) + \" N (equal & opposite)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a_A = \" + aA.toFixed(2) + \" m/s² a_B = \" + aB.toFixed(2) + \" m/s² (lighter accelerates more)\", 24, 74, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"force on A\", color: H.colors.warn }, { label: \"force on B\", color: H.colors.violet }], H.W - 160, 28);" + }, + { + "id": "ph-weight-vs-mass", + "area": "Physics", + "topic": "Weight vs mass", + "title": "Weight vs mass: W = m g", + "equation": "W = m * g (g_Earth = 9.8 m/s^2)", + "keywords": [ + "weight vs mass", + "weight", + "mass", + "w = mg", + "gravity", + "gravitational field", + "kilogram", + "newton", + "g", + "spring scale", + "moon", + "different planets" + ], + "explanation": "Mass m is how much matter an object contains — it never changes. Weight W is the gravitational force on that mass, W = m*g, so it depends on where you are. Lower g (the Moon) and the spring stretches less and the down-pointing weight arrow shrinks, even though the block (its mass) is identical. Raise g toward Jupiter's and the same kilogram suddenly weighs much more.", + "bullets": [ + "Mass (kg) is fixed; weight (N) = mass × local gravity g.", + "On Earth g ≈ 9.8 m/s²; on the Moon g ≈ 1.6, so weight is ~1/6.", + "A scale measures weight (a force), not mass directly." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "g", + "label": "gravity g (m/s²)", + "min": 1, + "max": 25, + "step": 0.1, + "value": 9.8 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst m = Math.max(0.1, P.m), g = Math.max(0, P.g);\nconst W = m * g; // weight = mass × gravity\n// Hanging mass on a spring scale. Spring stretch ∝ weight; mass bobs gently.\nconst topX = w * 0.5, topY = 70;\nconst maxStretch = 220;\nconst stretch = H.clamp(W / 100 * maxStretch, 8, maxStretch);\nconst bob = 6 * Math.sin(t * 2); // small oscillation, loops\nconst massY = topY + stretch + bob;\n// ceiling\nH.line(topX - 90, topY, topX + 90, topY, { color: H.colors.axis, width: 4 });\nfor (let i = -4; i <= 4; i++) H.line(topX + i * 20, topY, topX + i * 20 - 8, topY - 10, { color: H.colors.axis, width: 2 });\n// spring coils\nconst coils = 10, pts = [];\nfor (let i = 0; i <= coils * 12; i++) {\n const f = i / (coils * 12);\n const yy = topY + f * (stretch + bob);\n const xx = topX + (i % 2 === 0 ? -14 : 14) * Math.min(1, f * coils);\n pts.push([xx, yy]);\n}\nH.path(pts, { color: H.colors.violet, width: 2.5 });\n// mass block (size fixed — mass doesn't change)\nconst bs = 30 + m * 6;\nH.rect(topX - bs / 2, massY, bs, bs, { fill: H.colors.accent, stroke: H.colors.bg, width: 2, radius: 6 });\nH.text(m.toFixed(1) + \" kg\", topX, massY + bs / 2 + 5, { color: H.colors.ink, size: 13, align: \"center\", baseline: \"middle\", weight: 700 });\n// weight arrow (down, scaled to W)\nconst wLen = H.clamp(W * 0.6, 10, 120);\nH.arrow(topX, massY + bs, topX, massY + bs + wLen, { color: H.colors.warn, width: 4 });\nH.text(\"W = \" + W.toFixed(0) + \" N\", topX + 14, massY + bs + wLen / 2, { color: H.colors.warn, size: 13 });\nH.text(\"Weight vs mass: W = m·g\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"mass m = \" + m.toFixed(1) + \" kg (same everywhere)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"g = \" + g.toFixed(2) + \" m/s² → W = m·g = \" + W.toFixed(1) + \" N\", 24, 74, { color: H.colors.sub, size: 13 });\nconst where = g < 2 ? \"Moon-ish\" : g < 5 ? \"Mars-ish\" : g < 11 ? \"Earth (9.8)\" : \"Jupiter-ish\";\nH.text(\"g ≈ \" + where, 24, 96, { color: H.colors.good, size: 13 });" + }, + { + "id": "ph-normal-force", + "area": "Physics", + "topic": "Normal force", + "title": "Normal force: N = m g cos(theta)", + "equation": "N = m * g * cos(theta) (flat ground: N = m g)", + "keywords": [ + "normal force", + "n = mg cos theta", + "incline", + "ramp", + "perpendicular force", + "support force", + "free body diagram", + "contact force", + "slope", + "angle", + "weight component", + "surface" + ], + "explanation": "The normal force N is the push a surface gives back on an object, always perpendicular to that surface. On flat ground it exactly balances the full weight, so N = mg. Tilt the ramp and only the component of weight pressing into the surface counts, giving N = m*g*cos(theta) — so N shrinks as the incline steepens, reaching mg/2 at 60°. Watch the green normal arrow stay perpendicular to the ramp while the red weight arrow keeps pointing straight down.", + "bullets": [ + "Normal force is perpendicular to the contact surface, not always vertical.", + "On level ground N = mg; on a ramp N = mg·cos(theta) (less than mg).", + "N is a reaction to contact — it adjusts to whatever the surface must support." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "deg", + "label": "incline angle θ (°)", + "min": 0, + "max": 60, + "step": 1, + "value": 25 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst m = Math.max(0.1, P.m), deg = H.clamp(P.deg, 0, 60), g = 9.8;\nconst th = deg * Math.PI / 180;\nconst N = m * g * Math.cos(th); // normal force on an incline\nconst Wt = m * g; // weight\n// Incline: hinge at lower-left, rises to the right.\nconst ox = w * 0.20, oy = h * 0.78; // pivot (bottom of slope)\nconst L = Math.min(w * 0.62, (h * 0.6) / Math.max(0.05, Math.sin(th) + 0.0001 + 0.5));\nconst ex = ox + L * Math.cos(th), ey = oy - L * Math.sin(th);\n// ground + incline surface\nH.line(ox - 30, oy, ex + 40, oy, { color: H.colors.axis, width: 2 });\nH.path([[ox, oy], [ex, ey], [ex, oy]], { color: H.colors.grid, width: 2, fill: \"rgba(124,196,255,0.10)\", close: true });\n// block slides up/down the ramp, looping (kinematic, just for life)\nconst s = (0.5 + 0.35 * Math.sin(t)) * L * 0.7; // distance along ramp, bounded\nconst bx = ox + (s) * Math.cos(th), by = oy - (s) * Math.sin(th);\n// Unit vectors in SCREEN coords (y points down). The ramp surface rises to the\n// right, so the up-ramp direction is (cos th, -sin th). The OUTWARD surface\n// normal (pointing away from the solid wedge, which lies below-right) is the\n// up-and-LEFT perpendicular: screen vector (-sin th, -cos th).\nconst ux = Math.cos(th), uy = -Math.sin(th);\nconst nx = -Math.sin(th), ny = Math.cos(th); // outward normal: arrow uses (nx, -ny) -> up-left\nconst bs = 26;\n// block centered slightly off the surface along the outward normal\nconst cx = bx + nx * bs * 0.7, cy = by - ny * bs * 0.7;\nH.rect(cx - bs / 2, cy - bs / 2, bs, bs, { fill: H.colors.accent2, stroke: H.colors.bg, width: 2, radius: 4 });\n// weight arrow: straight down, scaled\nconst wLen = H.clamp(Wt * 0.5, 14, 130);\nH.arrow(cx, cy, cx, cy + wLen, { color: H.colors.warn, width: 3.5 });\nH.text(\"W = mg = \" + Wt.toFixed(0) + \" N\", cx + 8, cy + wLen + 4, { color: H.colors.warn, size: 12 });\n// normal arrow: perpendicular to surface (outward, up-left), scaled\nconst nLen = H.clamp(N * 0.5, 10, 130);\nH.arrow(cx, cy, cx + nx * nLen, cy - ny * nLen, { color: H.colors.good, width: 3.5 });\nH.text(\"N\", cx + nx * nLen - 12, cy - ny * nLen - 4, { color: H.colors.good, size: 13, weight: 700 });\nH.text(\"Normal force: N = m·g·cos(theta)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"N is perpendicular to the surface; flat ground (θ=0) → N = mg\", 24, 52, { color: H.colors.sub, size: 12 });\nH.text(\"m = \" + m.toFixed(1) + \" kg θ = \" + deg.toFixed(0) + \"° N = \" + N.toFixed(1) + \" N (W = \" + Wt.toFixed(1) + \" N)\", 24, 74, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"weight W\", color: H.colors.warn }, { label: \"normal N\", color: H.colors.good }], H.W - 160, 28);" + }, + { + "id": "ph-concave-convex-mirrors", + "area": "Physics", + "topic": "Concave and convex mirrors", + "title": "Spherical mirror: 1/f = 1/do + 1/di", + "equation": "1/f = 1/do + 1/di, f = R/2, M = -di/do", + "keywords": [ + "mirror", + "concave mirror", + "convex mirror", + "spherical mirror", + "focal length", + "mirror equation", + "1/f=1/do+1/di", + "magnification", + "radius of curvature", + "real image", + "virtual image", + "ray diagram" + ], + "explanation": "A curved mirror obeys 1/f = 1/do + 1/di, where the focal length f is half the radius of curvature (f = R/2). Slide f positive for a concave (converging) mirror and negative for a convex (diverging) one; raise the object height ho to scale the arrows. The object distance do sweeps on its own so you can watch the image race in from far away, blow up near the focal point, and flip to a virtual upright image behind the mirror once the object crosses inside F. The magnification M = -di/do tells you the image is inverted (M<0) or upright (M>0) and by how much.", + "bullets": [ + "Concave mirror: f = R/2 > 0, converges light; convex mirror: f < 0, diverges it.", + "Solve 1/f = 1/do + 1/di for di; positive di is a real image in front, negative is virtual behind.", + "Magnification M = -di/do: |M|>1 enlarges, M<0 means inverted." + ], + "params": [ + { + "name": "f", + "label": "focal length f (cm)", + "min": -6, + "max": 6, + "step": 0.5, + "value": 3 + }, + { + "name": "ho", + "label": "object height ho (cm)", + "min": 0.5, + "max": 3.5, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\n// Spherical mirror: 1/f = 1/do + 1/di, with f = R/2\n// Concave: f > 0 (converging). Convex: f < 0 (diverging).\nconst f = (Math.abs(P.f) < 0.2 ? (P.f < 0 ? -0.2 : 0.2) : P.f); // focal length (cm)\nconst ho = P.ho; // object height (cm)\nconst af = Math.abs(f);\n// Plot window adapts to |f| so the object, image, F and C stay on-screen for\n// every focal length the slider can reach.\nconst v = H.plot2d({ xMin: -3.6 * af - 1, xMax: 2.4 * af + 1.5, yMin: -5, yMax: 5 });\nv.grid(); v.axes();\n// Object distance sweeps as a multiple of |f| (always outside F, so the real\n// image stays finite and bounded) and moves through its whole range.\nconst do_ = af * (2.5 + 0.7 * Math.sin(t * 0.6)); // in [1.8|f|, 3.2|f|], always > |f|\nconst denom = (1 / f) - (1 / do_);\nconst di = Math.abs(denom) < 1e-5 ? 1e6 : 1 / denom; // image distance (cm)\nconst M = -di / do_; // magnification\nconst hi = M * ho; // image height (cm)\nconst R = 2 * f; // radius of curvature\n// Object & mirror sit in FRONT of the mirror = the NEGATIVE-x side; the mirror\n// vertex is at the origin. For a concave mirror (f>0) F and C are real and lie\n// in front, so they plot at x = -f and x = -R = -2f. (Convex flips the signs,\n// putting F and C behind, as the negatives of negative f naturally give.)\nconst xF = -f, xC = -R;\n// Mirror surface as a shallow arc that always bulges toward the FRONT (object\n// side, -x): concave curves away from the object, convex toward it. Using |R|\n// for the sag and the sign of f for the direction keeps the cup correct both ways.\nconst arc = [];\nconst sag = (f > 0 ? -1 : 1); // concave opens toward -x; convex bulges toward -x\nfor (let yy = -4; yy <= 4.001; yy += 0.2) arc.push([sag * (yy * yy) / (2 * Math.abs(R)), yy]);\nv.path(arc, { color: H.colors.violet, width: 3 });\nv.line(0, -4, 0, 4, { color: H.colors.grid, width: 1, dash: [3, 3] });\nv.dot(xF, 0, { r: 5, fill: H.colors.good }); // focal point F\nv.dot(xC, 0, { r: 4, fill: H.colors.axis }); // center of curvature C\n// Object arrow at x = -do_, image arrow at x = -di (real in front, virtual behind).\nconst xi = -di;\nv.arrow(-do_, 0, -do_, ho, { color: H.colors.accent, width: 3 });\nv.arrow(xi, 0, xi, hi, { color: H.colors.warn, width: 3 });\n// Ray 1: parallel to axis to the mirror, then reflects THROUGH F.\n// Object tip, mirror point (0,ho), F=(-f,0) and the image tip are colinear, so\n// the segment (0,ho)->(xi,hi) genuinely passes through F.\nv.line(-do_, ho, 0, ho, { color: H.colors.yellow, width: 1.5 });\nv.line(0, ho, xi, hi, { color: H.colors.yellow, width: 1.5, dash: di < 0 ? [4, 4] : null });\n// Ray 2: aimed at the center of curvature C; it strikes the mirror along the\n// radius and reflects straight back on itself. Object tip, C and image tip are\n// colinear, so this single line through C reaches the image tip.\nv.line(-do_, ho, xi, hi, { color: H.colors.accent2, width: 1.5, dash: di < 0 ? [4, 4] : null });\nv.dot(-do_, ho, { r: 5, fill: H.colors.accent });\nv.dot(xi, hi, { r: 5, fill: H.colors.warn });\nH.text(f > 0 ? \"Concave mirror: 1/f = 1/do + 1/di\" : \"Convex mirror: 1/f = 1/do + 1/di\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f=\" + f.toFixed(1) + \"cm do=\" + do_.toFixed(1) + \" di=\" + di.toFixed(1) + \" M=\" + M.toFixed(2) + (di > 0 ? \" real/inverted\" : \" virtual/upright\"), 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"object\", color: H.colors.accent }, { label: \"image\", color: H.colors.warn }, { label: \"F\", color: H.colors.good }], H.W - 150, 28);" + }, + { + "id": "ph-thin-lens-equation", + "area": "Physics", + "topic": "Thin lens equation and ray diagrams", + "title": "Thin lens: 1/f = 1/do + 1/di", + "equation": "1/f = 1/do + 1/di, M = -di/do = hi/ho", + "keywords": [ + "thin lens", + "lens equation", + "1/f=1/do+1/di", + "ray diagram", + "converging lens", + "diverging lens", + "focal length", + "magnification", + "real image", + "virtual image", + "object distance", + "image distance" + ], + "explanation": "A thin lens bends parallel rays to a focus, and its image obeys 1/f = 1/do + 1/di. Make f positive for a converging (convex) lens or negative for a diverging (concave) one, set the object distance do and height ho, and watch three principal rays build the image: one parallel ray bending through F, and one passing straight through the lens center. As do shrinks past f the real inverted image (yellow, di>0) flips to a virtual upright image (gray, di<0) on the same side as the object. The magnification M = -di/do = hi/ho reports the size and orientation, and a photon rides ray 2 to show light flowing.", + "bullets": [ + "Converging lens f > 0; diverging lens f < 0; the focal points sit at +-f.", + "1/f = 1/do + 1/di gives di; di > 0 is a real image past the lens, di < 0 is a virtual one.", + "M = -di/do: object inside f gives a magnified virtual image (the magnifying-glass case)." + ], + "params": [ + { + "name": "f", + "label": "focal length f (cm)", + "min": -15, + "max": 15, + "step": 1, + "value": 8 + }, + { + "name": "dobj", + "label": "object distance do (cm)", + "min": 2, + "max": 20, + "step": 0.5, + "value": 14 + }, + { + "name": "hobj", + "label": "object height ho (cm)", + "min": 1, + "max": 6, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst f = (Math.abs(P.f) < 0.2 ? (P.f<0?-0.2:0.2) : P.f); // focal length cm (>0 converging, <0 diverging)\nconst dobj = Math.max(0.5, P.dobj); // object distance cm (left of lens)\nconst hobj = P.hobj; // object height cm\n// Thin lens: 1/f = 1/do + 1/di -> di = 1/(1/f - 1/do)\nconst invDi = (1/f) - (1/dobj);\nconst di = Math.abs(invDi) < 1e-6 ? 1e6 : 1/invDi; // image distance cm (+ right/real, - left/virtual)\nconst M = -di/dobj; // magnification\nconst himg = M*hobj;\nconst conv = f > 0;\nH.text(\"Thin lens: 1/f = 1/do + 1/di\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f = \" + f.toFixed(1) + \" cm (\" + (conv?\"converging\":\"diverging\") + \") do = \" + dobj.toFixed(1) + \" cm di = \" + (Math.abs(di)>9e5?\"infinity\":di.toFixed(1)+\" cm\") + \" M = \" + M.toFixed(2), 24, 52, { color: H.colors.sub, size: 12 });\n// Optical axis and lens at centre. Map cm -> px about lens at x=cx.\nconst cx = w*0.5, axY = h*0.56;\nconst sc = H.clamp(180/Math.max(dobj, Math.abs(f)*2, Math.abs(di)>9e5?dobj:Math.abs(di)), 2, 40); // cm->px\nconst PX = (cm) => cx + cm*sc; // +cm to the right\nconst PY = (cm) => axY - cm*sc;\nH.line(40, axY, w-40, axY, { color: H.colors.axis, width: 1 });\n// Lens drawn as a vertical lens shape (converging: convex; diverging: concave).\nconst lh = Math.min(h*0.30, 150);\nif (conv){\n H.path([[cx, axY-lh],[cx+14, axY],[cx, axY+lh]], { color: H.colors.accent, width: 2 });\n H.path([[cx, axY-lh],[cx-14, axY],[cx, axY+lh]], { color: H.colors.accent, width: 2 });\n} else {\n H.path([[cx-9,axY-lh],[cx+9,axY-lh],[cx+3,axY],[cx+9,axY+lh],[cx-9,axY+lh],[cx-3,axY],[cx-9,axY-lh]], { color: H.colors.accent, width: 2 });\n}\n// Focal points at +-f on the axis.\nH.circle(PX(f), axY, 4, { fill: H.colors.violet });\nH.circle(PX(-f), axY, 4, { fill: H.colors.violet });\nH.text(\"F\", PX(f)-4, axY+18, { color: H.colors.violet, size: 12 });\nH.text(\"F'\", PX(-f)-4, axY+18, { color: H.colors.violet, size: 12 });\n// Object: an upright arrow at x = -do.\nconst ox = PX(-dobj);\nH.arrow(ox, axY, ox, PY(hobj), { color: H.colors.good, width: 3 });\n// Three principal rays from object tip ( otx, oty) to build the image.\nconst otx = ox, oty = PY(hobj);\n// Ray 1: parallel to axis, then through F (far side) for converging.\nH.line(otx, oty, cx, oty, { color: H.colors.warn, width: 1.5 });\n// Image tip from the lens equation.\nconst imgx = PX(di>9e5?dobj:di), imgy = PY(himg);\nif (Math.abs(di) < 9e5){\n // refracted ray 1 continues toward image tip\n H.line(cx, oty, imgx, imgy, { color: H.colors.warn, width: 1.5 });\n // Ray 2: straight through lens centre (undeviated).\n H.line(otx, oty, imgx, imgy, { color: H.colors.accent2, width: 1.5, dash: [4,4] });\n // Image arrow.\n const real = di > 0;\n H.arrow(imgx, axY, imgx, imgy, { color: real?H.colors.yellow:H.colors.sub, width: 3, dash: real?null:[5,5] });\n H.text(real?\"real image\":\"virtual image\", imgx + (imgx>cx?8:-90), imgy - 6, { color: real?H.colors.yellow:H.colors.sub, size: 12 });\n}\n// Animated photon riding ray 2 (object tip -> through centre -> image side).\nconst per = 2.4, ph = (t%per)/per;\nlet dx, dy;\nif (Math.abs(di) < 9e5){\n if (ph<0.5){ const u=ph/0.5; dx=H.lerp(otx,cx,u); dy=H.lerp(oty,axY,u);} \n else { const u=(ph-0.5)/0.5; dx=H.lerp(cx,imgx,u); dy=H.lerp(axY,imgy,u);} \n} else { const u=ph; dx=H.lerp(otx,cx,u); dy=H.lerp(oty,oty,u); }\nH.circle(dx, dy, 5, { fill: H.colors.yellow });\nH.legend([{label:\"object\", color:H.colors.good},{label:\"image\", color:H.colors.yellow},{label:\"focal F\", color:H.colors.violet}], w-160, 28);" + }, + { + "id": "ph-double-slit-interference", + "area": "Physics", + "topic": "Double-slit interference", + "title": "Double slit: d sin(theta) = m lambda", + "equation": "d*sin(theta) = m*lambda, I/Imax = cos^2(pi*d*sin(theta)/lambda), fringe spacing dy = lambda*L/d", + "keywords": [ + "double slit", + "double-slit interference", + "young's experiment", + "d sin theta = m lambda", + "fringe spacing", + "constructive interference", + "destructive interference", + "wavelength", + "slit separation", + "bright fringes", + "path difference", + "interference pattern" + ], + "explanation": "Two slits a distance d apart send overlapping waves to a screen; where their path difference d*sin(theta) equals a whole number of wavelengths m*lambda, crests meet crests and you get a bright fringe. The intensity follows cos^2(pi*d*sin(theta)/lambda), so the bright spots are evenly spaced by dy = lambda*L/d. Increase the wavelength lambda or the screen distance L and the fringes spread apart; push the slits closer (smaller d) and they spread even more. The red probe sweeps across the screen reading the live intensity so you can see it peak exactly on the green fringe markers.", + "bullets": [ + "Bright fringes occur when the path difference d*sin(theta) = m*lambda (m = 0, +-1, +-2...).", + "Intensity I/Imax = cos^2(pi*d*sin(theta)/lambda): equal-brightness peaks, dark zeros between.", + "Fringe spacing dy = lambda*L/d grows with wavelength and screen distance, shrinks with slit separation." + ], + "params": [ + { + "name": "lambda", + "label": "wavelength lambda (nm)", + "min": 400, + "max": 700, + "step": 10, + "value": 550 + }, + { + "name": "d", + "label": "slit separation d (um)", + "min": 20, + "max": 120, + "step": 5, + "value": 50 + }, + { + "name": "L", + "label": "screen distance L (m)", + "min": 1, + "max": 3, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\n// Double-slit interference: d * sin(theta) = m * lambda (bright fringes)\n// On a distant screen, fringe spacing dy = lambda * L / d.\nconst v = H.plot2d({ xMin: -30, xMax: 30, yMin: 0, yMax: 1.25 });\nv.grid(); v.axes();\nconst lam = P.lambda; // wavelength (nm)\nconst d = P.d; // slit separation (micrometers)\nconst L = P.L; // slit-to-screen distance (m)\n// Position on screen in mm; theta small so sin(theta) ~ y/L.\nconst lam_m = lam * 1e-9, d_m = d * 1e-6, L_m = L;\nfunction intensity(y_mm) {\n const y = y_mm * 1e-3;\n const theta = Math.atan2(y, L_m);\n const phi = Math.PI * d_m * Math.sin(theta) / lam_m; // half phase diff\n const c = Math.cos(phi);\n return c * c; // I/Imax = cos^2(pi d sin / lambda)\n}\nv.fn(intensity, { color: H.colors.accent, width: 2.5, steps: 400 });\n// Fringe spacing (mm): dy = lambda L / d.\nconst dy = lam_m * L_m / d_m * 1e3;\n// Mark the central + first few bright fringes and sweep a probe.\nfor (let m = -3; m <= 3; m++) {\n const ym = m * dy;\n if (Math.abs(ym) <= 30) v.line(ym, 0, ym, 1, { color: H.colors.good, width: 1, dash: [4, 4] });\n}\nconst yp = 25 * Math.sin(t * 0.7);\nv.dot(yp, intensity(yp), { r: 6, fill: H.colors.warn });\nv.line(yp, 0, yp, intensity(yp), { color: H.colors.warn, width: 1, dash: [2, 3] });\nH.text(\"Double slit: d·sin θ = m·λ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"λ=\" + lam.toFixed(0) + \"nm d=\" + d.toFixed(1) + \"µm L=\" + L.toFixed(1) + \"m → fringe spacing Δy=\" + dy.toFixed(2) + \"mm\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"y = \" + yp.toFixed(1) + \" mm, I/Imax = \" + intensity(yp).toFixed(2), 24, 74, { color: H.colors.warn, size: 13 });\nH.legend([{ label: \"intensity\", color: H.colors.accent }, { label: \"bright fringes\", color: H.colors.good }], H.W - 180, 28);" + }, + { + "id": "ph-single-slit-diffraction", + "area": "Physics", + "topic": "Single-slit diffraction", + "title": "Single slit: a sin(theta) = m lambda", + "equation": "a*sin(theta) = m*lambda (dark), I/I0 = (sin(beta)/beta)^2, beta = pi*a*sin(theta)/lambda", + "keywords": [ + "single slit", + "single-slit diffraction", + "diffraction", + "a sin theta = m lambda", + "central maximum", + "sinc squared", + "dark fringes", + "slit width", + "wavelength", + "diffraction pattern", + "minima", + "central peak width" + ], + "explanation": "Light passing one slit of width a spreads out, with dark fringes wherever a*sin(theta) = m*lambda (m = +-1, +-2...) because the slit splits into pairs of wavelets that cancel. The full pattern is the sinc-squared curve I/I0 = (sin(beta)/beta)^2 with beta = pi*a*sin(theta)/lambda: one tall central peak twice as wide as the side lobes, which fade fast. Narrow the slit a (or lengthen the wavelength) and the central peak fans out wider — the smaller the opening, the more the light bends. The green probe sweeps the screen reading the live intensity, and the central-peak width 2*y1 = 2*lambda*L/a updates in the caption.", + "bullets": [ + "Dark fringes (minima) at a*sin(theta) = m*lambda for m = +-1, +-2... (note: m=0 is the bright center).", + "Intensity is sinc-squared: I/I0 = (sin(beta)/beta)^2, beta = pi*a*sin(theta)/lambda.", + "Central maximum half-width y1 = lambda*L/a: a narrower slit spreads the light wider." + ], + "params": [ + { + "name": "lambda", + "label": "wavelength lambda (nm)", + "min": 400, + "max": 700, + "step": 10, + "value": 600 + }, + { + "name": "a", + "label": "slit width a (um)", + "min": 20, + "max": 100, + "step": 5, + "value": 40 + }, + { + "name": "L", + "label": "screen distance L (m)", + "min": 1, + "max": 3, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\n// Single-slit diffraction: a * sin(theta) = m * lambda (DARK fringes, m=±1,±2,…)\n// Intensity: I/I0 = (sin(beta)/beta)^2, beta = pi * a * sin(theta) / lambda.\nconst v = H.plot2d({ xMin: -40, xMax: 40, yMin: 0, yMax: 1.2 });\nv.grid(); v.axes();\nconst lam = P.lambda; // wavelength (nm)\nconst a = P.a; // slit width (micrometers)\nconst L = P.L; // slit-to-screen distance (m)\nconst lam_m = lam * 1e-9, a_m = a * 1e-6, L_m = L;\nfunction intensity(y_mm) {\n const y = y_mm * 1e-3;\n const theta = Math.atan2(y, L_m);\n const beta = Math.PI * a_m * Math.sin(theta) / lam_m;\n if (Math.abs(beta) < 1e-6) return 1; // limit sinc^2 -> 1\n const s = Math.sin(beta) / beta;\n return s * s;\n}\nv.fn(intensity, { color: H.colors.accent, width: 2.5, steps: 500 });\n// First-minimum position (mm): a sin(theta)=lambda -> y1 ~ lambda L / a.\nconst y1 = lam_m * L_m / a_m * 1e3;\nfor (let m = -3; m <= 3; m++) {\n if (m === 0) continue;\n const ym = m * y1;\n if (Math.abs(ym) <= 40) v.line(ym, 0, ym, 0.25, { color: H.colors.warn, width: 1, dash: [4, 4] });\n}\nconst yp = 34 * Math.sin(t * 0.6);\nv.dot(yp, intensity(yp), { r: 6, fill: H.colors.good });\nv.line(yp, 0, yp, intensity(yp), { color: H.colors.good, width: 1, dash: [2, 3] });\nH.text(\"Single slit: a·sin θ = m·λ (dark fringes)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"λ=\" + lam.toFixed(0) + \"nm a=\" + a.toFixed(1) + \"µm L=\" + L.toFixed(1) + \"m → central width 2y₁=\" + (2 * y1).toFixed(1) + \"mm\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"y = \" + yp.toFixed(1) + \" mm, I/I0 = \" + intensity(yp).toFixed(3), 24, 74, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"intensity (sinc²)\", color: H.colors.accent }, { label: \"first minima\", color: H.colors.warn }], H.W - 200, 28);" + }, + { + "id": "ph-polarization-malus", + "area": "Physics", + "topic": "Polarization", + "title": "Polarization: Malus's law I = I0 cos^2(theta)", + "equation": "I = I0 * cos^2(theta)", + "keywords": [ + "polarization", + "malus's law", + "malus law", + "i = i0 cos^2 theta", + "polarizer", + "analyzer", + "transmission axis", + "polarized light", + "intensity", + "crossed polarizers", + "polarization angle", + "optics" + ], + "explanation": "When polarized light hits a polarizer (analyzer), only the component of its electric field along the transmission axis gets through, so the intensity follows Malus's law I = I0*cos^2(theta), where theta is the angle between the light's polarization and the axis. Here the incident E-field (blue) rotates while the analyzer axis (purple) stays fixed at angle phi; the green arrow is the transmitted projection and the green bar shows the fraction I/I0. Aligned (theta=0) lets everything through; at theta=45 degrees exactly half passes; crossed at theta=90 degrees blocks all light. Drag phi to rotate the analyzer and watch the output dim and brighten as cos^2.", + "bullets": [ + "Only the field component along the transmission axis passes: amplitude scales by cos(theta).", + "Intensity scales by the SQUARE: I = I0*cos^2(theta) — that is Malus's law.", + "theta=0 -> full intensity, theta=45 deg -> half, theta=90 deg (crossed) -> total darkness." + ], + "params": [ + { + "name": "I0", + "label": "incident intensity I0 (W/m^2)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "phi", + "label": "analyzer axis angle (deg)", + "min": 0, + "max": 180, + "step": 5, + "value": 0 + } + ], + "code": "H.background();\n// Polarization — Malus's law: I = I0 * cos^2(theta)\n// theta is the angle between the light's polarization and the analyzer axis.\nconst cx = H.W * 0.32, cy = H.H * 0.55, R = Math.min(H.W, H.H) * 0.26;\nconst I0 = P.I0; // incident intensity (W/m^2)\nconst phi = P.phi * Math.PI / 180; // analyzer axis angle (degrees) -> rad\n// The incoming light's polarization vector rotates with t (e.g. light through\n// a rotating source); the analyzer stays fixed at angle phi.\nconst psi = (t * 0.6) % (2 * Math.PI); // incident polarization angle\nconst theta = psi - phi; // angle between them\nconst I = I0 * Math.cos(theta) * Math.cos(theta);\n// Draw the analyzer disk + its transmission axis (fixed).\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 2 });\nH.line(cx - R * Math.cos(phi), cy + R * Math.sin(phi), cx + R * Math.cos(phi), cy - R * Math.sin(phi), { color: H.colors.violet, width: 3 });\n// Incident polarization vector (rotating).\nH.arrow(cx, cy, cx + R * Math.cos(psi), cy - R * Math.sin(psi), { color: H.colors.accent, width: 3 });\n// Component that passes = projection onto the analyzer axis.\nconst proj = Math.cos(theta);\nconst px = cx + R * proj * Math.cos(phi), py = cy - R * proj * Math.sin(phi);\nH.arrow(cx, cy, px, py, { color: H.colors.good, width: 3 });\nH.line(cx + R * Math.cos(psi), cy - R * Math.sin(psi), px, py, { color: H.colors.sub, width: 1, dash: [4, 4] });\n// Transmitted-intensity bar on the right.\nconst bx = H.W * 0.72, bw = 50, bh = R * 1.6, by = cy + bh / 2;\nH.rect(bx, by - bh, bw, bh, { stroke: H.colors.grid, width: 1.5 });\nconst frac = (I0 > 1e-9 ? I / I0 : 0);\nH.rect(bx, by - bh * frac, bw, bh * frac, { fill: H.colors.good });\nH.text(\"I\", bx + bw + 8, by - bh, { color: H.colors.sub, size: 13 });\nH.text(\"Malus's law: I = I₀·cos²θ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"I₀=\" + I0.toFixed(1) + \" W/m² θ=\" + (theta * 180 / Math.PI).toFixed(0) + \"° → I=\" + I.toFixed(2) + \" W/m² (\" + (frac * 100).toFixed(0) + \"%)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"incident E\", color: H.colors.accent }, { label: \"analyzer axis\", color: H.colors.violet }, { label: \"transmitted\", color: H.colors.good }], H.W - 200, 90);" + }, + { + "id": "ph-electromagnetic-spectrum", + "area": "Physics", + "topic": "Electromagnetic spectrum", + "title": "EM spectrum: c = lambda * f", + "equation": "c = lambda * f, E = h * f", + "keywords": [ + "electromagnetic spectrum", + "em spectrum", + "wavelength", + "frequency", + "light", + "c = lambda f", + "speed of light", + "radio infrared visible ultraviolet xray gamma", + "photon energy", + "color", + "hertz", + "nanometers" + ], + "explanation": "All electromagnetic waves travel at the same speed c = 3.0x10^8 m/s, so their frequency f and wavelength lambda are locked together by c = lambda * f. Slide the frequency up and the wave on screen bunches tighter (lambda shrinks), shifting from radio toward visible light and on to gamma rays. Because photon energy is E = h*f, higher frequency also means more energetic radiation — which is why X-rays and gamma rays are dangerous while radio waves are not. In the visible band (about 380–780 nm) the trace is even tinted with the real color of that wavelength.", + "bullets": [ + "c = lambda * f is fixed, so raising frequency must shorten the wavelength.", + "Visible light is a tiny slice (~380–780 nm) of the full spectrum.", + "Higher frequency = shorter wavelength = higher photon energy E = h*f." + ], + "params": [ + { + "name": "freq", + "label": "frequency f (x10^14 Hz)", + "min": 0.01, + "max": 7.5, + "step": 0.05, + "value": 5.45 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst f = Math.max(0.01, P.freq); // frequency in 10^14 Hz\nconst c = 3.0e8; // speed of light, m/s\nconst fHz = f * 1e14;\nconst lam = c / fHz; // wavelength in metres\nconst lamNm = lam * 1e9; // wavelength in nm\nH.text(\"Electromagnetic spectrum: c = lambda * f\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f = \" + f.toFixed(2) + \" x10^14 Hz lambda = \" + lamNm.toFixed(0) + \" nm c = 3.0x10^8 m/s\", 24, 52, { color: H.colors.sub, size: 13 });\n// Visible-light colour from wavelength (approx), else grey for non-visible.\nfunction vis(nm){\n let r=0,g=0,b=0;\n if (nm>=380&&nm<440){r=-(nm-440)/60;b=1;}\n else if(nm<490){g=(nm-440)/50;b=1;}\n else if(nm<510){g=1;b=-(nm-510)/20;}\n else if(nm<580){r=(nm-510)/70;g=1;}\n else if(nm<645){r=1;g=-(nm-645)/65;}\n else if(nm<=780){r=1;}\n else {r=g=b=0.35;}\n if(nm<380){r=g=b=0.35;}\n return \"rgb(\"+Math.round(255*H.clamp(r,0,1))+\",\"+Math.round(255*H.clamp(g,0,1))+\",\"+Math.round(255*H.clamp(b,0,1))+\")\";\n}\nconst col = (lamNm>=380&&lamNm<=780) ? vis(lamNm) : H.colors.accent;\n// Draw a travelling wave whose on-screen wavelength tracks the real lambda.\nconst midY = h*0.55, axL = 60, axR = w-60, span = axR-axL;\nH.line(axL, midY, axR, midY, { color: H.colors.axis, width: 1 });\n// Map real wavelength (log scale, 1pm .. 1km) to an on-screen pixel wavelength.\nconst pxLam = H.clamp(H.map(Math.log10(lam), Math.log10(1e-7), Math.log10(5e-7), 30, 180), 18, 240);\nconst amp = h*0.18, k = H.TAU/pxLam, phase = t*3.0;\nconst pts = [];\nfor(let x=axL;x<=axR;x+=2){ pts.push([x, midY - amp*Math.sin(k*(x-axL) - phase)]); }\nH.path(pts, { color: col, width: 3 });\n// Band label by wavelength.\nlet band = \"gamma\";\nif (lam>1e-11) band=\"X-ray\";\nif (lam>1e-8) band=\"ultraviolet\";\nif (lam>=3.8e-7) band=\"VISIBLE\";\nif (lam>7.8e-7) band=\"infrared\";\nif (lam>1e-3) band=\"microwave\";\nif (lam>0.1) band=\"radio\";\nH.text(band, w*0.5, midY+amp+34, { color: col, size: 16, weight: 700, align: \"center\" });\nH.text(\"higher f -> shorter lambda -> more energy (E = h*f)\", 24, h-24, { color: H.colors.sub, size: 12 });\n// A photon dot riding the wave to show propagation.\nconst xd = axL + ((t*120)% span);\nH.circle(xd, midY - amp*Math.sin(k*(xd-axL) - phase), 6, { fill: H.colors.warn });" + }, + { + "id": "ph-reflection", + "area": "Physics", + "topic": "Reflection", + "title": "Reflection: theta_i = theta_r", + "equation": "theta_i = theta_r (both measured from the normal)", + "keywords": [ + "reflection", + "law of reflection", + "angle of incidence", + "angle of reflection", + "mirror", + "normal", + "incident ray", + "reflected ray", + "specular", + "theta_i = theta_r", + "optics", + "bounce" + ], + "explanation": "When light hits a smooth surface it bounces off so that the angle of incidence equals the angle of reflection — and both are measured from the NORMAL, the dashed line perpendicular to the mirror, not from the mirror itself. Slide the angle and watch the incident (pink) and reflected (green) rays stay perfectly symmetric about that normal. The yellow photon traces the actual path: in along one ray, out along the other, always at matching angles. This single rule is why mirrors form predictable images and why a steeper-hitting beam leaves just as steeply.", + "bullets": [ + "Angles are measured from the normal (perpendicular), not the surface.", + "theta_i = theta_r: the reflected angle always equals the incident angle.", + "Incident ray, reflected ray, and normal all lie in one plane." + ], + "params": [ + { + "name": "angle", + "label": "angle of incidence (deg)", + "min": 0, + "max": 89, + "step": 1, + "value": 35 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst inc = H.clamp(P.angle, 0, 89); // angle of incidence (deg from normal)\nconst a = inc * Math.PI/180;\nH.text(\"Reflection: angle of incidence = angle of reflection\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"theta_i = \" + inc.toFixed(0) + \" deg theta_r = \" + inc.toFixed(0) + \" deg (measured from the normal)\", 24, 52, { color: H.colors.sub, size: 13 });\n// Mirror surface (horizontal) and the point of incidence.\nconst my = h*0.66, px = w*0.5;\nH.line(60, my, w-60, my, { color: H.colors.accent, width: 4 });\nfor(let x=70;x 1; // total internal reflection\nconst t2 = tir ? 0 : Math.asin(H.clamp(s2,-1,1));\nconst deg2 = t2*180/Math.PI;\nH.text(\"Refraction (Snell's law): n1 sin(theta1) = n2 sin(theta2)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"n1 = \" + n1.toFixed(2) + \" n2 = \" + n2.toFixed(2) + \" theta1 = \" + inc.toFixed(0) + \" deg theta2 = \" + (tir? \"-- (TIR)\" : deg2.toFixed(1)+\" deg\"), 24, 52, { color: H.colors.sub, size: 13 });\n// Interface (horizontal) between two media.\nconst iy = h*0.52, px = w*0.5;\nH.rect(50, 60, w-100, iy-60, { fill: \"rgba(124,196,255,0.06)\" }); // upper medium\nH.rect(50, iy, w-100, (h-30)-iy, { fill: \"rgba(244,162,89,0.10)\" }); // lower medium\nH.line(50, iy, w-50, iy, { color: H.colors.accent, width: 2 });\nH.text(\"n1 = \" + n1.toFixed(2), 64, 80, { color: H.colors.accent, size: 12 });\nH.text(\"n2 = \" + n2.toFixed(2), 64, h-40, { color: H.colors.accent2, size: 12 });\n// Normal.\nH.line(px, 70, px, h-30, { color: H.colors.violet, width: 1.5, dash: [6,6] });\nconst Lin = Math.min(iy-70, 220), Lout = Math.min((h-30)-iy, 200);\n// Incident ray from upper-left to (px,iy).\nconst ix = px - Lin*Math.sin(t1), iyy = iy - Lin*Math.cos(t1);\nH.arrow(ix, iyy, px, iy, { color: H.colors.warn, width: 3 });\n// Refracted (or reflected if TIR) ray.\nlet rx, ry, rcol, rlabel;\nif (tir){ rx = px + Lout*Math.sin(t1); ry = iy - Lout*Math.cos(t1); rcol = H.colors.warn; rlabel=\"reflected (TIR)\"; }\nelse { rx = px + Lout*Math.sin(t2); ry = iy + Lout*Math.cos(t2); rcol = H.colors.good; rlabel=\"refracted\"; }\nH.arrow(px, iy, rx, ry, { color: rcol, width: 3 });\n// Animated photon: down the incident ray then onward.\nconst per = 2.2, ph = (t % per)/per;\nlet dx, dy;\nif (ph<0.5){ const u=ph/0.5; dx=H.lerp(ix,px,u); dy=H.lerp(iyy,iy,u);} \nelse { const u=(ph-0.5)/0.5; dx=H.lerp(px,rx,u); dy=H.lerp(iy,ry,u);} \nH.circle(dx, dy, 6, { fill: H.colors.yellow });\n// Snell readout: show the conserved product.\nH.text(\"n1*sin(theta1) = \" + (n1*Math.sin(t1)).toFixed(3) + (tir? \" > n2 -> no refraction\" : \" = n2*sin(theta2) = \" + (n2*Math.sin(t2)).toFixed(3)), 24, h-18, { color: H.colors.sub, size: 12 });\nH.legend([{label:\"incident\", color:H.colors.warn},{label:rlabel, color:rcol}], w-180, 28);" + }, + { + "id": "ph-total-internal-reflection", + "area": "Physics", + "topic": "Total internal reflection", + "title": "Total internal reflection: sin(theta_c) = n2 / n1", + "equation": "sin(theta_c) = n2 / n1 (needs n1 > n2)", + "keywords": [ + "total internal reflection", + "tir", + "critical angle", + "sin theta_c = n2/n1", + "fiber optic", + "optical fiber", + "dense to rare", + "trapped light", + "snell's law", + "refraction limit", + "n1 > n2", + "optics" + ], + "explanation": "When light tries to leave a DENSE medium (high n1) for a thinner one (low n2), the refracted ray bends away from the normal — and past a special critical angle it can't bend any further, so 100% of the light reflects back inside. That critical angle obeys sin(theta_c) = n2/n1, which only has a solution when n1 > n2. Push the incidence angle below theta_c and the beam escapes (green); raise it above and the beam is trapped, reflecting like a perfect mirror (pink). This trapping is exactly how optical fibers pipe light around corners with almost no loss.", + "bullets": [ + "Only happens going dense -> rare (n1 > n2), never the other way.", + "Critical angle: sin(theta_c) = n2/n1; above it, all light reflects.", + "Optical fibers and diamonds' sparkle both rely on this trapping." + ], + "params": [ + { + "name": "angle", + "label": "incidence theta1 (deg)", + "min": 0, + "max": 89, + "step": 1, + "value": 50 + }, + { + "name": "n1", + "label": "dense index n1", + "min": 1, + "max": 2.5, + "step": 0.01, + "value": 1.5 + }, + { + "name": "n2", + "label": "rare index n2", + "min": 1, + "max": 2, + "step": 0.01, + "value": 1 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst n1 = Math.max(1.0, P.n1); // dense medium (incident side), e.g. glass/water\nconst n2 = Math.min(n1-0.001 > 1 ? n1-0.001 : 1.0, Math.max(1.0, P.n2)); // less-dense medium\nconst inc = H.clamp(P.angle, 0, 89); // angle of incidence from normal\nconst t1 = inc*Math.PI/180;\n// Critical angle: sin(theta_c) = n2/n1 (only real when n1 > n2).\nconst ratio = H.clamp(n2/n1, 0, 1);\nconst tc = Math.asin(ratio);\nconst tcDeg = tc*180/Math.PI;\nconst s2 = (n1/n2)*Math.sin(t1);\nconst tir = s2 >= 1; // beyond critical angle -> all reflected\nH.text(\"Total internal reflection: sin(theta_c) = n2 / n1\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"n1 = \" + n1.toFixed(2) + \" (dense) n2 = \" + n2.toFixed(2) + \" theta_c = \" + tcDeg.toFixed(1) + \" deg theta1 = \" + inc.toFixed(0) + \" deg\", 24, 52, { color: H.colors.sub, size: 13 });\n// Dense medium BELOW the interface (light travels up toward it).\nconst iy = h*0.46, px = w*0.5;\nH.rect(50, iy, w-100, (h-30)-iy, { fill: \"rgba(124,196,255,0.10)\" }); // dense (n1) below\nH.rect(50, 60, w-100, iy-60, { fill: \"rgba(244,162,89,0.05)\" }); // less dense (n2) above\nH.line(50, iy, w-50, iy, { color: H.colors.accent, width: 2 });\nH.line(px, 70, px, h-30, { color: H.colors.violet, width: 1.5, dash: [6,6] });\nconst Lin = Math.min((h-30)-iy, 210), Lout = Math.min(iy-70, 200);\n// Incident ray travels UP from lower-left to the interface point.\nconst ix = px - Lin*Math.sin(t1), iyy = iy + Lin*Math.cos(t1);\nH.arrow(ix, iyy, px, iy, { color: H.colors.warn, width: 3 });\nlet rx, ry, rcol, rlabel;\nif (tir){\n // Total internal reflection: ray reflects back DOWN, same angle.\n rx = px + Lin*Math.sin(t1); ry = iy + Lin*Math.cos(t1); rcol = H.colors.warn; rlabel = \"100% reflected\";\n} else {\n // Partially refracted into the upper medium, bending AWAY from normal.\n const t2 = Math.asin(H.clamp(s2,-1,1));\n rx = px + Lout*Math.sin(t2); ry = iy - Lout*Math.cos(t2); rcol = H.colors.good; rlabel = \"refracted (escapes)\";\n}\nH.arrow(px, iy, rx, ry, { color: rcol, width: 3 });\n// Photon animation: up the incident ray, then along the outgoing ray.\nconst per = 2.2, ph = (t % per)/per;\nlet dx, dy;\nif (ph<0.5){ const u=ph/0.5; dx=H.lerp(ix,px,u); dy=H.lerp(iyy,iy,u);} \nelse { const u=(ph-0.5)/0.5; dx=H.lerp(px,rx,u); dy=H.lerp(iy,ry,u);} \nH.circle(dx, dy, 6, { fill: H.colors.yellow });\n// Verdict caption.\nH.text(tir ? \"theta1 >= theta_c -> beam trapped (total internal reflection)\" : \"theta1 < theta_c -> beam refracts out into medium n2\", 24, h-18, { color: tir?H.colors.warn:H.colors.good, size: 12 });\nH.legend([{label:\"incident\", color:H.colors.warn},{label:rlabel, color:rcol}], w-190, 28);" + }, + { + "id": "ph-converging-diverging-lenses", + "area": "Physics", + "topic": "Converging and diverging lenses", + "title": "Thin lens: 1/f = 1/do + 1/di", + "equation": "1/f = 1/do + 1/di, M = -di/do", + "keywords": [ + "thin lens", + "lens equation", + "converging lens", + "diverging lens", + "focal length", + "object distance", + "image distance", + "magnification", + "1/f = 1/do + 1/di", + "real image", + "virtual image", + "convex concave lens", + "ray diagram" + ], + "explanation": "The thin-lens equation 1/f = 1/do + 1/di ties together the focal length f, the object distance do, and where the image forms, di. A converging (convex) lens has f > 0: place the object beyond f and it makes a real, inverted image on the far side (di > 0); move it inside f and the image flips to virtual and upright. A diverging (concave) lens has f < 0 and always makes a reduced virtual image. Drag the sliders and watch the principal rays — one parallel ray bent through the focus, one straight through the center — cross to build the image, with magnification M = -di/do shown live.", + "bullets": [ + "1/f = 1/do + 1/di sets the image distance from f and the object distance.", + "f > 0 converges (convex); f < 0 diverges (concave, always virtual).", + "M = -di/do: negative M means inverted, |M| > 1 means enlarged." + ], + "params": [ + { + "name": "f", + "label": "focal length f (cm, <0 diverging)", + "min": -20, + "max": 20, + "step": 0.5, + "value": 10 + }, + { + "name": "dobj", + "label": "object distance do (cm)", + "min": 1, + "max": 40, + "step": 0.5, + "value": 15 + }, + { + "name": "hobj", + "label": "object height (cm)", + "min": 1, + "max": 6, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst f = (Math.abs(P.f) < 0.2 ? (P.f<0?-0.2:0.2) : P.f); // focal length cm (>0 converging, <0 diverging)\nconst dobj = Math.max(0.5, P.dobj); // object distance cm (left of lens)\nconst hobj = P.hobj; // object height cm\n// Thin lens: 1/f = 1/do + 1/di -> di = 1/(1/f - 1/do)\nconst invDi = (1/f) - (1/dobj);\nconst di = Math.abs(invDi) < 1e-6 ? 1e6 : 1/invDi; // image distance cm (+ right/real, - left/virtual)\nconst M = -di/dobj; // magnification\nconst himg = M*hobj;\nconst conv = f > 0;\nH.text(\"Thin lens: 1/f = 1/do + 1/di\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f = \" + f.toFixed(1) + \" cm (\" + (conv?\"converging\":\"diverging\") + \") do = \" + dobj.toFixed(1) + \" cm di = \" + (Math.abs(di)>9e5?\"infinity\":di.toFixed(1)+\" cm\") + \" M = \" + M.toFixed(2), 24, 52, { color: H.colors.sub, size: 12 });\n// Optical axis and lens at centre. Map cm -> px about lens at x=cx.\nconst cx = w*0.5, axY = h*0.56;\nconst sc = H.clamp(180/Math.max(dobj, Math.abs(f)*2, Math.abs(di)>9e5?dobj:Math.abs(di)), 2, 40); // cm->px\nconst PX = (cm) => cx + cm*sc; // +cm to the right\nconst PY = (cm) => axY - cm*sc;\nH.line(40, axY, w-40, axY, { color: H.colors.axis, width: 1 });\n// Lens drawn as a vertical lens shape (converging: convex; diverging: concave).\nconst lh = Math.min(h*0.30, 150);\nif (conv){\n H.path([[cx, axY-lh],[cx+14, axY],[cx, axY+lh]], { color: H.colors.accent, width: 2 });\n H.path([[cx, axY-lh],[cx-14, axY],[cx, axY+lh]], { color: H.colors.accent, width: 2 });\n} else {\n H.path([[cx-9,axY-lh],[cx+9,axY-lh],[cx+3,axY],[cx+9,axY+lh],[cx-9,axY+lh],[cx-3,axY],[cx-9,axY-lh]], { color: H.colors.accent, width: 2 });\n}\n// Focal points at +-f on the axis.\nH.circle(PX(f), axY, 4, { fill: H.colors.violet });\nH.circle(PX(-f), axY, 4, { fill: H.colors.violet });\nH.text(\"F\", PX(f)-4, axY+18, { color: H.colors.violet, size: 12 });\nH.text(\"F'\", PX(-f)-4, axY+18, { color: H.colors.violet, size: 12 });\n// Object: an upright arrow at x = -do.\nconst ox = PX(-dobj);\nH.arrow(ox, axY, ox, PY(hobj), { color: H.colors.good, width: 3 });\n// Three principal rays from object tip ( otx, oty) to build the image.\nconst otx = ox, oty = PY(hobj);\n// Ray 1: parallel to axis, then through F (far side) for converging.\nH.line(otx, oty, cx, oty, { color: H.colors.warn, width: 1.5 });\n// Image tip from the lens equation.\nconst imgx = PX(di>9e5?dobj:di), imgy = PY(himg);\nif (Math.abs(di) < 9e5){\n // refracted ray 1 continues toward image tip\n H.line(cx, oty, imgx, imgy, { color: H.colors.warn, width: 1.5 });\n // Ray 2: straight through lens centre (undeviated).\n H.line(otx, oty, imgx, imgy, { color: H.colors.accent2, width: 1.5, dash: [4,4] });\n // Image arrow.\n const real = di > 0;\n H.arrow(imgx, axY, imgx, imgy, { color: real?H.colors.yellow:H.colors.sub, width: 3, dash: real?null:[5,5] });\n H.text(real?\"real image\":\"virtual image\", imgx + (imgx>cx?8:-90), imgy - 6, { color: real?H.colors.yellow:H.colors.sub, size: 12 });\n}\n// Animated photon riding ray 2 (object tip -> through centre -> image side).\nconst per = 2.4, ph = (t%per)/per;\nlet dx, dy;\nif (Math.abs(di) < 9e5){\n if (ph<0.5){ const u=ph/0.5; dx=H.lerp(otx,cx,u); dy=H.lerp(oty,axY,u);} \n else { const u=(ph-0.5)/0.5; dx=H.lerp(cx,imgx,u); dy=H.lerp(axY,imgy,u);} \n} else { const u=ph; dx=H.lerp(otx,cx,u); dy=H.lerp(oty,oty,u); }\nH.circle(dx, dy, 5, { fill: H.colors.yellow });\nH.legend([{label:\"object\", color:H.colors.good},{label:\"image\", color:H.colors.yellow},{label:\"focal F\", color:H.colors.violet}], w-160, 28);" + }, + { + "id": "ph-magnetic-fields", + "area": "Physics", + "topic": "Magnetic fields", + "title": "Magnetic field of a dipole: B falls off as 1/r^3", + "equation": "B ~ 1 / r^3 (dipole; field lines run N to S)", + "keywords": [ + "magnetic field", + "bar magnet", + "field lines", + "dipole", + "north pole", + "south pole", + "compass", + "b field", + "flux density", + "tesla", + "magnetism", + "field direction" + ], + "explanation": "A magnet's field B points out of the north pole, loops around, and back into the south pole — the little arrows trace those field lines. The 'strength' slider sets the dipole's power, and 'pole gap' widens the magnet so the lines spread out. The green compass needle orbits the magnet and always swings to point along the LOCAL field, just like a real compass; notice how the field weakens fast (as 1/r^3) the farther it gets from the magnet.", + "bullets": [ + "Field lines leave the north pole and enter the south pole, never crossing.", + "A compass aligns with the field's direction at its location.", + "A dipole field weakens rapidly with distance, roughly as 1/r^3." + ], + "params": [ + { + "name": "sep", + "label": "pole gap (half, units)", + "min": 0.5, + "max": 3, + "step": 0.25, + "value": 1.5 + }, + { + "name": "strength", + "label": "magnet strength (arb)", + "min": 0.5, + "max": 4, + "step": 0.25, + "value": 2 + } + ], + "code": "H.background();\n// Magnetic field of a bar magnet: field arrows loop N -> S; |B| falls off as 1/r^3.\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 6 });\nv.grid(); v.axes({ ticks: false });\nconst sep = Math.max(0.5, P.sep); // half pole separation (units)\nconst strength = Math.max(0.1, P.strength); // dipole strength (arb)\nconst Np = [sep, 0], Sp = [-sep, 0];\n// the magnet bar\nv.rect(-sep, -0.6, 2 * sep, 1.2, { fill: H.colors.panel, stroke: H.colors.axis, width: 2 });\nv.text(\"N\", sep - 0.4, 0.2, { color: H.colors.warn, size: 16, weight: 700 });\nv.text(\"S\", -sep - 0.1, 0.2, { color: H.colors.accent, size: 16, weight: 700 });\n// field from two opposite point poles (Coulomb-like for magnetic poles)\nfunction Bvec(x, y) {\n let bx = 0, by = 0;\n const poles = [[Np[0], Np[1], +1], [Sp[0], Sp[1], -1]];\n for (const p of poles) {\n const dx = x - p[0], dy = y - p[1];\n const r2 = dx * dx + dy * dy;\n const r = Math.sqrt(r2);\n if (r < 0.35) continue;\n const f = strength * p[2] / (r2 * r);\n bx += f * dx; by += f * dy;\n }\n return [bx, by];\n}\n// grid of field arrows\nfor (let gx = -7; gx <= 7; gx += 1.75) {\n for (let gy = -5; gy <= 5; gy += 1.5) {\n if (Math.abs(gy) < 0.8 && Math.abs(gx) < sep) continue; // skip inside bar\n const b = Bvec(gx, gy);\n const mag = Math.hypot(b[0], b[1]);\n if (mag < 1e-4) continue;\n const ux = b[0] / mag, uy = b[1] / mag, L = 0.85;\n v.arrow(gx, gy, gx + L * ux, gy + L * uy, { color: H.colors.accent, width: 1.6, head: 6 });\n }\n}\n// a compass needle that orbits and aligns with the local field B\nconst ang = t * 0.6, rr = 4 + 1.2 * Math.sin(t * 0.9);\nconst cxp = rr * Math.cos(ang), cyp = rr * Math.sin(ang) * 0.6;\nconst b = Bvec(cxp, cyp), bmag = Math.hypot(b[0], b[1]);\nif (bmag > 1e-5) {\n const ux = b[0] / bmag, uy = b[1] / bmag;\n v.arrow(cxp - ux, cyp - uy, cxp + ux, cyp + uy, { color: H.colors.good, width: 3, head: 9 });\n}\nv.dot(cxp, cyp, { r: 4, fill: H.colors.warn });\nH.text(\"Magnetic field of a dipole: B ∝ 1 / r³\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"|B| at compass = \" + bmag.toFixed(3) + \" (arb) pole gap = \" + (2 * sep).toFixed(1) + \" units\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"field B\", color: H.colors.accent }, { label: \"compass aligns with B\", color: H.colors.good }], H.W - 240, 28);" + }, + { + "id": "ph-magnetic-force-moving-charge", + "area": "Physics", + "topic": "Magnetic force on a moving charge", + "title": "Lorentz force: F = q v B sin(theta)", + "equation": "F = q * v * B * sin(theta); radius r = m v / (q B)", + "keywords": [ + "lorentz force", + "moving charge", + "magnetic force", + "qvb", + "circular motion", + "cyclotron", + "velocity", + "right hand rule", + "centripetal", + "charge in magnetic field", + "radius", + "gyroradius" + ], + "explanation": "A charge moving through a magnetic field feels a sideways push F = qvB sin(theta), always perpendicular to its velocity, so it never speeds the charge up — it just bends its path into a circle. Faster speed v or weaker field B both widen that circle, since the radius is r = mv/(qB); more mass m also makes it harder to turn. Flip the sign of the charge and it circles the other way. The green arrow is the velocity (tangent) and the pink arrow is the force, always aimed at the center.", + "bullets": [ + "The magnetic force is always perpendicular to v, so it turns the charge but does no work.", + "Equal speed and field give a circle of radius r = mv/(qB).", + "The force vanishes when v is parallel to B (sin theta = 0)." + ], + "params": [ + { + "name": "q", + "label": "charge q (C, sign)", + "min": -2, + "max": 2, + "step": 0.25, + "value": 1 + }, + { + "name": "speed", + "label": "speed v (m/s)", + "min": 0.5, + "max": 5, + "step": 0.25, + "value": 3 + }, + { + "name": "B", + "label": "field B (T)", + "min": 0.2, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 4, + "step": 0.25, + "value": 1.5 + } + ], + "code": "H.background();\n// Lorentz force on a moving charge: F = q v B sin(theta). B points OUT of the\n// screen; a charge moving in-plane curves into a circle of radius r = m v / (q B).\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 6 });\nv.grid(); v.axes({ ticks: false });\nconst q = P.q; // charge (C, sign matters)\nconst speed = Math.max(0.1, P.speed); // speed v (m/s, scaled)\nconst B = Math.max(0.05, P.B); // field strength B (T, scaled)\nconst m = Math.max(0.1, P.m); // mass (kg, scaled)\n// radius of circular motion r = m v / (|q| B); guard q=0\nconst absq = Math.max(0.05, Math.abs(q));\nconst r = m * speed / (absq * B);\nconst rc = Math.min(r, 5); // clamp drawn radius to stay on-screen\n// B out of page: draw a field of dots\nfor (let gx = -7; gx <= 7; gx += 2) {\n for (let gy = -5; gy <= 5; gy += 2) {\n v.dot(gx, gy, { r: 2.5, fill: H.colors.violet });\n H.circle(v.X(gx), v.Y(gy), 6, { stroke: H.colors.violet, width: 1 });\n }\n}\n// the charge goes around a circle; direction set by sign of q (q>0 clockwise for B out)\nconst dir = q >= 0 ? -1 : 1;\nconst omega = speed / Math.max(0.3, rc); // angular rate matches v = omega r\nconst phi = dir * omega * t;\nconst cx = 0, cy = 0;\nconst px = cx + rc * Math.cos(phi), py = cy + rc * Math.sin(phi);\n// draw the circular path\nconst pts = [];\nfor (let i = 0; i <= 80; i++) { const a = i / 80 * H.TAU; pts.push([cx + rc * Math.cos(a), cy + rc * Math.sin(a)]); }\nv.path(pts, { color: H.colors.grid, width: 1.5, dash: [4, 4], close: true });\n// velocity is tangent; force F = qv x B points to the center (centripetal)\nconst vx = -dir * rc * omega * Math.sin(phi), vy = dir * rc * omega * Math.cos(phi);\nconst vmag = Math.hypot(vx, vy) || 1;\nv.arrow(px, py, px + 2.2 * vx / vmag, py + 2.2 * vy / vmag, { color: H.colors.good, width: 3, head: 9 });\n// force points toward center\nconst fx = cx - px, fy = cy - py, fmag = Math.hypot(fx, fy) || 1;\nv.arrow(px, py, px + 1.8 * fx / fmag, py + 1.8 * fy / fmag, { color: H.colors.warn, width: 3, head: 9 });\nv.dot(px, py, { r: 7, fill: q >= 0 ? H.colors.accent2 : H.colors.accent });\nconst Fmax = absq * speed * B; // |F| = q v B sin(90) = q v B\nH.text(\"Lorentz force: F = q v B sin θ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"F = \" + Fmax.toFixed(2) + \" N radius r = mv/(qB) = \" + r.toFixed(2) + \" m\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"velocity v\", color: H.colors.good }, { label: \"force F (to center)\", color: H.colors.warn }, { label: \"B out of page\", color: H.colors.violet }], H.W - 200, 28);" + }, + { + "id": "ph-magnetic-force-wire", + "area": "Physics", + "topic": "Magnetic force on a current-carrying wire", + "title": "Force on a wire: F = B I L sin(theta)", + "equation": "F = B * I * L * sin(theta)", + "keywords": [ + "force on a wire", + "current carrying wire", + "bil", + "magnetic force", + "motor effect", + "current", + "ampere", + "wire in magnetic field", + "right hand rule", + "f=bil", + "angle", + "electromagnet" + ], + "explanation": "A wire carrying current I through a field B feels a force F = BIL sin(theta), where theta is the angle between the current and the field — this is the 'motor effect' that spins every electric motor. The force is largest when the wire is perpendicular to B (theta = 90 degrees) and drops to zero when the wire lies along the field (theta = 0). Crank up the current, the field, the wire length L, or the angle and watch the pink force arrow grow. The force is perpendicular to both the wire and B, so here it lifts the wire out of the page.", + "bullets": [ + "F = BIL sin(theta): the push is strongest when the wire is at 90 degrees to B.", + "No force when the current runs parallel to the field (sin 0 = 0).", + "The force is perpendicular to both the current and the field (the motor effect)." + ], + "params": [ + { + "name": "B", + "label": "field B (T)", + "min": 0, + "max": 2, + "step": 0.1, + "value": 1 + }, + { + "name": "I", + "label": "current I (A)", + "min": 0, + "max": 8, + "step": 0.5, + "value": 4 + }, + { + "name": "L", + "label": "wire length L (m)", + "min": 1, + "max": 6, + "step": 0.5, + "value": 4 + }, + { + "name": "theta", + "label": "angle to B (deg)", + "min": 0, + "max": 180, + "step": 5, + "value": 90 + } + ], + "code": "H.background();\n// Force on a current-carrying wire in a field: F = B I L sin(theta).\n// theta is the angle between the wire (current direction) and B.\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 6 });\nv.grid(); v.axes({ ticks: false });\nconst I = Math.max(0, P.I); // current (A)\nconst B = Math.max(0, P.B); // field strength (T)\nconst L = Math.max(0.2, P.L); // wire length (m)\nconst thetaDeg = P.theta; // angle between wire and B (deg)\nconst theta = thetaDeg * Math.PI / 180;\n// uniform B field to the RIGHT (+x): draw horizontal arrows\nfor (let gy = -5; gy <= 5; gy += 1.6) {\n v.arrow(-7, gy, -5.4, gy, { color: H.colors.violet, width: 1.4, head: 6 });\n v.arrow(5.4, gy, 7, gy, { color: H.colors.violet, width: 1.4, head: 6 });\n}\n// the wire: lies at angle theta from B (the +x axis), centered at origin\nconst half = L / 2;\nconst wx = half * Math.cos(theta), wy = half * Math.sin(theta);\nv.line(-wx, -wy, wx, wy, { color: H.colors.accent, width: 5 });\n// current direction arrow along the wire\nv.arrow(0, 0, 0.9 * wx, 0.9 * wy, { color: H.colors.good, width: 3, head: 10 });\nv.text(\"I\", wx * 0.5 + 0.3, wy * 0.5 + 0.3, { color: H.colors.good, size: 14, weight: 700 });\n// Force F = B I L sin(theta), perpendicular to BOTH wire and B -> out of/into page.\n// Magnitude scaled for drawing; sign of sin(theta) sets out vs in.\nconst F = B * I * L * Math.sin(theta);\nconst Fabs = Math.abs(F);\n// represent the out-of-page force as a pulsing marker at the wire midpoint,\n// with an arrow length proportional to F lifting the wire visually upward\nconst lift = H.clamp(F * 0.25, -4, 4);\nconst wobble = 0.15 * Math.sin(t * 2);\nv.arrow(0, 0, 0, lift + (lift >= 0 ? wobble : -wobble), { color: H.colors.warn, width: 4, head: 11 });\nH.circle(v.X(0), v.Y(lift), 5 + 2 * Math.abs(Math.sin(t * 2)), { stroke: H.colors.warn, width: 2 });\nH.text(\"Force on a wire: F = B·I·L·sin θ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"B=\" + B.toFixed(2) + \" T I=\" + I.toFixed(1) + \" A L=\" + L.toFixed(1) + \" m θ=\" + thetaDeg.toFixed(0) + \"° → F = \" + F.toFixed(2) + \" N\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"field B\", color: H.colors.violet }, { label: \"current I\", color: H.colors.good }, { label: \"force F\", color: H.colors.warn }], H.W - 160, 28);" + }, + { + "id": "ph-magnetic-field-wire-solenoid", + "area": "Physics", + "topic": "Magnetic field of a wire and solenoid", + "title": "Field of a wire: B = mu0 I / (2 pi r)", + "equation": "wire: B = mu0 I / (2 pi r); solenoid: B = mu0 n I", + "keywords": [ + "magnetic field of a wire", + "solenoid", + "ampere's law", + "mu naught", + "field around a wire", + "current", + "right hand rule", + "field lines", + "coil", + "1/r falloff", + "turns per meter", + "biot savart" + ], + "explanation": "Current in a long straight wire wraps the space around it in circular field lines, and the field weakens as you move away: B = mu0 I / (2 pi r), so doubling the distance r halves the field. Increase the current I and every loop gets stronger. The green arrow is a probe that sweeps in and out so you can watch the 1/r falloff in real units (microtesla). The violet readout compares a solenoid, where stacking n turns per metre gives a uniform interior field B = mu0 n I that does not depend on distance at all.", + "bullets": [ + "A straight wire's field circles it and falls off as 1/r: B = mu0 I / (2 pi r).", + "The right-hand rule sets the direction: thumb along I, fingers curl with B.", + "A solenoid concentrates the field to a uniform B = mu0 n I inside the coil." + ], + "params": [ + { + "name": "I", + "label": "current I (A)", + "min": 0.5, + "max": 20, + "step": 0.5, + "value": 10 + }, + { + "name": "n", + "label": "solenoid turns n (1/m)", + "min": 100, + "max": 2000, + "step": 50, + "value": 1000 + } + ], + "code": "H.background();\n// Field of a long straight wire: B = mu0 I / (2 pi r) - circles around the wire,\n// falling off as 1/r. Readout also gives a solenoid: B = mu0 n I (uniform inside).\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -6, yMax: 6 });\nv.grid(); v.axes({ ticks: false });\nconst I = Math.max(0.1, P.I); // current (A)\nconst n = Math.max(1, P.n); // solenoid turns per metre (1/m)\nconst mu0 = 1.2566e-6; // T·m/A\n// wire carries current OUT of the page at the origin\nH.circle(v.X(0), v.Y(0), 8, { fill: H.colors.accent2, stroke: H.colors.ink, width: 2 });\nH.circle(v.X(0), v.Y(0), 3, { fill: H.colors.ink }); // dot = current out of page\nv.text(\"I out\", 0.4, 0.6, { color: H.colors.accent2, size: 13, weight: 700 });\n// concentric field-line circles; arrows show the counterclockwise direction\nfor (const R of [1.2, 2.4, 3.6, 4.8]) {\n const pts = [];\n for (let i = 0; i <= 70; i++) { const a = i / 70 * H.TAU; pts.push([R * Math.cos(a), R * Math.sin(a)]); }\n v.path(pts, { color: H.colors.grid, width: 1.3, close: true });\n for (const a of [0.4, 2.5, 4.6]) {\n const x = R * Math.cos(a), y = R * Math.sin(a);\n const tx = -Math.sin(a), ty = Math.cos(a);\n v.arrow(x, y, x + 0.7 * tx, y + 0.7 * ty, { color: H.colors.accent, width: 1.6, head: 6 });\n }\n}\n// probe at distance r from the wire; r sweeps to show the 1/r falloff\nconst r = 2.5 + 1.8 * Math.sin(t * 0.7);\nconst ang = t * 0.5;\nconst px = r * Math.cos(ang), py = r * Math.sin(ang);\nconst B = mu0 * I / (2 * Math.PI * r); // tesla (wire)\nconst Bsol = mu0 * n * I; // tesla (solenoid)\nconst tx = -Math.sin(ang), ty = Math.cos(ang);\nv.arrow(px, py, px + 1.4 * tx, py + 1.4 * ty, { color: H.colors.good, width: 3, head: 9 });\nv.dot(px, py, { r: 5, fill: H.colors.warn });\nv.line(0, 0, px, py, { color: H.colors.sub, width: 1, dash: [3, 3] });\nH.text(\"Field of a straight wire: B = μ₀ I / (2π r)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"I=\" + I.toFixed(1) + \" A r=\" + r.toFixed(2) + \" m → B = \" + (B * 1e6).toFixed(2) + \" µT\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"solenoid (n=\" + n.toFixed(0) + \"/m): B = μ₀ n I = \" + (Bsol * 1e6).toFixed(1) + \" µT\", 24, 72, { color: H.colors.violet, size: 13 });\nH.legend([{ label: \"field line\", color: H.colors.accent }, { label: \"B at probe (∝1/r)\", color: H.colors.good }], H.W - 210, 28);" + }, + { + "id": "ph-electromagnetic-induction-faraday", + "area": "Physics", + "topic": "Electromagnetic induction (Faraday's law)", + "title": "Faraday's law: EMF = -N dPhi/dt", + "equation": "EMF = -N * dPhi/dt; Phi = B A cos(omega t), EMF = N B A omega sin(omega t)", + "keywords": [ + "faraday's law", + "electromagnetic induction", + "emf", + "magnetic flux", + "induced voltage", + "lenz's law", + "dphi/dt", + "rotating loop", + "generator", + "ac", + "coil", + "flux change" + ], + "explanation": "A changing magnetic flux through a coil induces a voltage: EMF = -N dPhi/dt. Here a loop of area A spins in a field B, so its flux Phi = BA cos(omega t) rises and falls, and the induced EMF is the rate of that change. The peak voltage is N*B*A*omega, so adding turns N, a stronger field B, a bigger loop A, or faster spinning omega all raise the output — this is exactly how a generator works. Watch the green loop-normal swing relative to the field while the pink EMF wave peaks when the flux is changing fastest (loop edge-on) and zeroes when the flux is momentarily steady (loop face-on).", + "bullets": [ + "EMF is induced only when the flux CHANGES; a steady field through a still loop gives nothing.", + "Flux Phi = B A cos(theta); the EMF peaks where the loop is edge-on and flux changes fastest.", + "Peak EMF = N B A omega, so more turns or faster spinning means more voltage." + ], + "params": [ + { + "name": "N", + "label": "turns N", + "min": 1, + "max": 50, + "step": 1, + "value": 20 + }, + { + "name": "B", + "label": "field B (T)", + "min": 0.1, + "max": 2, + "step": 0.1, + "value": 0.5 + }, + { + "name": "A", + "label": "loop area A (m^2)", + "min": 0.05, + "max": 1, + "step": 0.05, + "value": 0.3 + }, + { + "name": "omega", + "label": "spin omega (rad/s)", + "min": 0.5, + "max": 6, + "step": 0.25, + "value": 2 + } + ], + "code": "H.background();\n// Faraday's law: EMF = -N dPhi/dt. A loop of area A spins at angular speed omega\n// in a field B, so flux Phi = B A cos(omega t) and EMF = N B A omega sin(omega t).\nconst v = H.plot2d({ xMin: -2, xMax: 12, yMin: -6, yMax: 6 });\nv.grid(); v.axes({ ticks: false });\nconst N = Math.max(1, P.N); // number of turns\nconst B = Math.max(0.05, P.B); // field strength (T)\nconst A = Math.max(0.05, P.A); // loop area (m^2)\nconst omega = Math.max(0.2, P.omega);// angular speed (rad/s)\nconst peakEMF = N * B * A * omega; // amplitude of the induced EMF (V)\n// LEFT: the rotating loop seen edge-on inside a field pointing right (+x)\nconst lx = 1.5, ly = 0; // loop center (data coords)\nfor (let gy = -4; gy <= 4; gy += 2) {\n v.arrow(lx - 2.2, gy, lx - 0.6, gy, { color: H.colors.violet, width: 1.3, head: 5 });\n}\nconst phase = omega * t;\n// loop drawn as an ellipse whose width = projected area (cos of tilt)\nconst proj = Math.cos(phase);\nconst rw = 1.5 * Math.abs(proj) + 0.05, rh = 1.5;\nconst pts = [];\nfor (let i = 0; i <= 60; i++) { const a = i / 60 * H.TAU; pts.push([lx + rw * Math.cos(a), ly + rh * Math.sin(a)]); }\nv.path(pts, { color: H.colors.accent, width: 3, close: true });\n// normal vector of the loop (rotates with it); flux ∝ cos(angle between n and B)\nv.arrow(lx, ly, lx + 1.6 * proj, ly + 1.6 * Math.sin(phase) * 0.4, { color: H.colors.good, width: 2.5, head: 8 });\n// RIGHT: the EMF waveform scrolling, with a marker at the current value\nconst ax0 = 4.5, axw = 7; // plot region for the wave (data x)\nconst emf = peakEMF * Math.sin(phase);\nconst wpts = [];\nfor (let i = 0; i <= 120; i++) {\n const u = i / 120; // 0..1 across the wave window\n const tt = phase - (1 - u) * 6; // show the last 6 rad of history\n const yv = (peakEMF * Math.sin(tt)) / Math.max(peakEMF, 1e-6) * 4.5; // scale to ±4.5\n wpts.push([ax0 + u * axw, yv]);\n}\nv.line(ax0, 0, ax0 + axw, 0, { color: H.colors.axis, width: 1 });\nv.path(wpts, { color: H.colors.warn, width: 2.5 });\nconst yNow = (emf / Math.max(peakEMF, 1e-6)) * 4.5;\nv.dot(ax0 + axw, yNow, { r: 6, fill: H.colors.warn });\nconst flux = B * A * Math.cos(phase);\nH.text(\"Faraday's law: EMF = −N · dΦ/dt\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Φ = \" + flux.toFixed(3) + \" Wb EMF = \" + emf.toFixed(3) + \" V peak = \" + peakEMF.toFixed(3) + \" V\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"field B\", color: H.colors.violet }, { label: \"loop normal\", color: H.colors.good }, { label: \"induced EMF\", color: H.colors.warn }], H.W - 180, 28);" + }, + { + "id": "ph-uniform-circular-motion", + "area": "Physics", + "topic": "Uniform circular motion", + "title": "Uniform circular motion: v = 2*pi*r / T", + "equation": "v = 2*pi*r / T, omega = 2*pi / T, a_c = v^2 / r", + "keywords": [ + "uniform circular motion", + "circular motion", + "centripetal acceleration", + "angular velocity", + "omega", + "period", + "tangential velocity", + "radius", + "v = 2 pi r / t", + "rev per second", + "going in a circle", + "orbit" + ], + "explanation": "An object moving in a circle at CONSTANT speed is still accelerating, because its velocity vector keeps changing direction. The green arrow is the velocity: always tangent to the circle, never pointing where the ball is going next moment. Slide the radius r and period T: a bigger circle or a slower lap lowers the speed v = 2*pi*r/T, while the inward (centripetal) acceleration a_c = v^2/r is what bends the straight-line motion into a loop. The pink arrow always points to the center.", + "bullets": [ + "Speed is constant, but velocity changes direction every instant — so there IS acceleration.", + "v = 2*pi*r/T (distance once around / time once around); omega = 2*pi/T is the turn rate.", + "The acceleration a_c = v^2/r points straight at the center, perpendicular to the velocity." + ], + "params": [ + { + "name": "r", + "label": "radius r (m)", + "min": 0.5, + "max": 4, + "step": 0.1, + "value": 2 + }, + { + "name": "T", + "label": "period T (s)", + "min": 1, + "max": 8, + "step": 0.1, + "value": 4 + } + ], + "code": "H.background();\nconst cx = H.W * 0.40, cy = H.H * 0.54;\nconst R = P.r, T = Math.max(0.1, P.T);\nconst scale = Math.min(H.W, H.H) * 0.30 / Math.max(0.5, R);\nconst Rpx = R * scale;\nconst omega = 2 * Math.PI / T;\nconst v = omega * R;\nconst ac = v * v / R;\nconst ang = omega * t;\nconst px = cx + Rpx * Math.cos(ang);\nconst py = cy - Rpx * Math.sin(ang);\nH.circle(cx, cy, Rpx, { stroke: H.colors.grid, width: 1.5 });\nH.circle(cx, cy, 3, { fill: H.colors.sub });\nH.line(cx, cy, px, py, { color: H.colors.axis, width: 1, dash: [4, 4] });\nconst tx = -Math.sin(ang), ty = -Math.cos(ang);\nconst vlen = 46;\nH.arrow(px, py, px + tx * vlen, py + ty * vlen, { color: H.colors.good, width: 3, head: 10 });\nconst ix = (cx - px), iy = (cy - py);\nconst inlen = Math.hypot(ix, iy) || 1;\nH.arrow(px, py, px + ix / inlen * 38, py + iy / inlen * 38, { color: H.colors.warn, width: 3, head: 10 });\nH.circle(px, py, 8, { fill: H.colors.accent });\nH.text(\"Uniform circular motion: v = 2*pi*r / T\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"constant speed, ever-turning velocity\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + R.toFixed(1) + \" m T = \" + T.toFixed(1) + \" s v = \" + v.toFixed(2) + \" m/s\", 24, H.H - 44, { color: H.colors.sub, size: 13 });\nH.text(\"omega = \" + omega.toFixed(2) + \" rad/s a_c = v^2/r = \" + ac.toFixed(2) + \" m/s^2\", 24, H.H - 24, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"velocity (tangent)\", color: H.colors.good }, { label: \"accel (inward)\", color: H.colors.warn }], H.W - 200, 28);" + }, + { + "id": "ph-centripetal-force", + "area": "Physics", + "topic": "Centripetal force", + "title": "Centripetal force: F = m*v^2 / r", + "equation": "F = m*v^2 / r = m*omega^2*r", + "keywords": [ + "centripetal force", + "circular motion force", + "f = m v^2 / r", + "net inward force", + "center seeking", + "mass", + "speed", + "radius", + "tension string", + "banked curve", + "newton second law circle", + "centripetal" + ], + "explanation": "Newton's second law says a curving path needs a NET force, and for a circle that force points straight at the center — the centripetal force F = m*v^2/r. The pink arrow shows it; it grows when you speed the ball up (v squared) or add mass, and shrinks for a bigger radius. There is no outward force flinging the ball away: cut the string and it flies off along the green tangent, in a straight line. Whatever supplies the inward pull (a string's tension, gravity, friction) is what must equal m*v^2/r.", + "bullets": [ + "Centripetal force always points toward the center — it is a direction, supplied by tension, gravity, or friction.", + "F = m*v^2/r: doubling the speed quadruples the required force; doubling the radius halves it.", + "Remove the force and the object leaves along the tangent in a straight line (no 'centrifugal' pull)." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 4, + "step": 0.1, + "value": 1 + }, + { + "name": "v", + "label": "speed v (m/s)", + "min": 1, + "max": 6, + "step": 0.1, + "value": 3 + }, + { + "name": "r", + "label": "radius r (m)", + "min": 0.5, + "max": 4, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\nconst cx = H.W * 0.40, cy = H.H * 0.54;\nconst m = Math.max(0.1, P.m), R = Math.max(0.3, P.r), v = Math.max(0.1, P.v);\nconst scale = Math.min(H.W, H.H) * 0.30 / Math.max(0.5, R);\nconst Rpx = R * scale;\nconst omega = v / R;\nconst T = 2 * Math.PI / omega;\nconst Fc = m * v * v / R;\nconst ang = omega * t;\nconst px = cx + Rpx * Math.cos(ang);\nconst py = cy - Rpx * Math.sin(ang);\nH.circle(cx, cy, Rpx, { stroke: H.colors.grid, width: 1.5 });\nH.circle(cx, cy, 3, { fill: H.colors.sub });\nH.line(cx, cy, px, py, { color: H.colors.axis, width: 1, dash: [4, 4] });\nconst tx = -Math.sin(ang), ty = -Math.cos(ang);\nH.arrow(px, py, px + tx * 44, py + ty * 44, { color: H.colors.good, width: 3, head: 10 });\nconst ix = cx - px, iy = cy - py;\nconst inlen = Math.hypot(ix, iy) || 1;\nconst Farrow = H.clamp(Fc * 4, 24, 90);\nH.arrow(px, py, px + ix / inlen * Farrow, py + iy / inlen * Farrow, { color: H.colors.warn, width: 4, head: 12 });\nH.circle(px, py, 7 + m, { fill: H.colors.accent });\nH.text(\"Centripetal force: F = m*v^2 / r\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"the inward pull that bends the path into a circle\", 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"m = \" + m.toFixed(1) + \" kg v = \" + v.toFixed(1) + \" m/s r = \" + R.toFixed(1) + \" m\", 24, H.H - 44, { color: H.colors.sub, size: 13 });\nH.text(\"F = \" + Fc.toFixed(1) + \" N (toward center) T = \" + T.toFixed(2) + \" s\", 24, H.H - 24, { color: H.colors.warn, size: 13 });\nH.legend([{ label: \"velocity\", color: H.colors.good }, { label: \"centripetal F\", color: H.colors.warn }], H.W - 200, 28);" + }, + { + "id": "ph-kinematic-equations", + "area": "Physics", + "topic": "Kinematic equations (constant acceleration)", + "title": "Kinematics: x = x0 + v0 t + (1/2) a t^2", + "equation": "x = x0 + v0*t + (1/2)*a*t^2, v = v0 + a*t", + "keywords": [ + "kinematics", + "constant acceleration", + "kinematic equations", + "suvat", + "position", + "velocity", + "acceleration", + "v = v0 + a t", + "equations of motion", + "displacement", + "uniform acceleration", + "one dimensional motion" + ], + "explanation": "Under constant acceleration, position grows as a parabola in time while velocity grows as a straight line. Slide v0 to set how fast the object starts (the initial slope of x(t)) and a to set how strongly it speeds up or slows down (the curvature). The green velocity arrow on the moving dot lengthens as a feeds energy in; the dot rides the x(t) curve and loops every T seconds so you can watch the same motion repeat.", + "bullets": [ + "Position is quadratic in t; velocity v = v0 + a t is linear in t.", + "a is the curvature of x(t): a > 0 curves up, a < 0 curves down.", + "v0 is the starting slope of the position curve at t = 0." + ], + "params": [ + { + "name": "v0", + "label": "initial velocity v0 (m/s)", + "min": -5, + "max": 15, + "step": 0.5, + "value": 4 + }, + { + "name": "a", + "label": "acceleration a (m/s^2)", + "min": -3, + "max": 4, + "step": 0.1, + "value": 1 + }, + { + "name": "T", + "label": "window T (s)", + "min": 2, + "max": 10, + "step": 0.5, + "value": 8 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: -5, yMax: 60 });\nv.grid(); v.axes();\nconst v0 = P.v0, a = P.a, T = Math.max(0.5, P.T);\nconst x = (tt) => v0 * tt + 0.5 * a * tt * tt;\nv.fn(x, { color: H.colors.accent, width: 3, steps: 200 });\nconst tt = (t % T);\nconst xt = x(tt), vt = v0 + a * tt;\nv.dot(tt, xt, { r: 6, fill: H.colors.warn });\nconst ax = v.X(tt), ay = v.Y(xt);\nH.arrow(ax, ay, ax + 34, ay, { color: H.colors.good, width: 2.5 });\nH.text(\"x = x0 + v0 t + (1/2) a t^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"t = \" + tt.toFixed(2) + \" s x = \" + xt.toFixed(2) + \" m v = \" + vt.toFixed(2) + \" m/s\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"position x(t)\", color: H.colors.accent }, { label: \"velocity (arrow)\", color: H.colors.good }], H.W - 190, 28);" + }, + { + "id": "ph-free-fall", + "area": "Physics", + "topic": "Free fall", + "title": "Free fall: y = h0 - (1/2) g t^2", + "equation": "y = h0 - (1/2)*g*t^2, v = g*t, g = 9.8 m/s^2", + "keywords": [ + "free fall", + "gravity", + "g = 9.8", + "falling object", + "dropped", + "acceleration due to gravity", + "free fall acceleration", + "height", + "drop time", + "impact speed", + "y = h0 - 1/2 g t^2", + "falling" + ], + "explanation": "Drop an object from rest and gravity alone pulls it down at a constant 9.8 m/s^2, so its height falls as a parabola in time while its speed climbs linearly. Raise h0 and the ball starts higher, takes longer to land, and hits faster. The green arrow shows the velocity growing every instant; the readouts print the live height and speed, which reach v = sqrt(2 g h0) at the ground.", + "bullets": [ + "All objects fall with the same g = 9.8 m/s^2 regardless of mass.", + "Height is quadratic in t; speed v = g t is linear in t.", + "Impact speed is sqrt(2 g h0); doubling the height raises it by sqrt(2)." + ], + "params": [ + { + "name": "h0", + "label": "drop height h0 (m)", + "min": 2, + "max": 45, + "step": 1, + "value": 20 + } + ], + "code": "H.background();\nconst g = 9.8, h0 = Math.max(1, P.h0);\nconst tFall = Math.sqrt(2 * h0 / g);\nconst w = H.W, hh = H.H;\nconst groundY = hh - 70;\nconst topY = 80;\nconst Yof = (yy) => groundY - (yy / h0) * (groundY - topY);\nH.line(60, groundY, w - 60, groundY, { color: H.colors.axis, width: 2 });\nH.text(\"ground\", w - 110, groundY + 20, { color: H.colors.sub, size: 12 });\nconst tt = (t % (tFall + 0.6));\nlet y = h0 - 0.5 * g * tt * tt;\nlet vel = g * tt;\nif (y < 0) { y = 0; vel = g * tFall; }\nconst ballX = w * 0.5, ballY = Yof(y);\nH.line(ballX - 26, topY, ballX + 26, topY, { color: H.colors.grid, width: 2 });\nH.circle(ballX, ballY, 12, { fill: H.colors.warn });\nH.arrow(ballX, ballY + 14, ballX, ballY + 14 + Math.min(70, vel * 5), { color: H.colors.good, width: 2.5 });\nH.text(\"Free fall: y = h0 - (1/2) g t^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"g = 9.8 m/s^2 h0 = \" + h0.toFixed(1) + \" m t = \" + tt.toFixed(2) + \" s\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"height y = \" + y.toFixed(2) + \" m speed v = \" + vel.toFixed(2) + \" m/s\", 24, 74, { color: H.colors.accent, size: 13 });\nH.legend([{ label: \"g (gravity)\", color: H.colors.good }], w - 150, 28);" + }, + { + "id": "ph-projectile-horizontal", + "area": "Physics", + "topic": "Projectile motion: horizontal launch", + "title": "Horizontal launch: x = v0 t, y = h0 - (1/2) g t^2", + "equation": "x = v0*t, y = h0 - (1/2)*g*t^2, g = 9.8 m/s^2", + "keywords": [ + "projectile motion", + "horizontal launch", + "horizontal projectile", + "launched horizontally", + "off a cliff", + "range", + "time of flight", + "independence of motion", + "x = v0 t", + "parabolic trajectory", + "fired horizontally" + ], + "explanation": "A projectile launched horizontally keeps a constant sideways velocity while gravity acts only downward, so the horizontal and vertical motions are completely independent. The green arrow (vx) never changes length, but the violet arrow (vy) grows as it falls, and the two combine into a parabola. The fall time depends ONLY on the drop height h0, so raising v0 lengthens the range but never changes how long it stays in the air.", + "bullets": [ + "Horizontal velocity is constant; vertical velocity grows like free fall.", + "Time aloft depends only on height h0, not on launch speed v0.", + "Range = v0 * sqrt(2 h0 / g): faster launch reaches farther." + ], + "params": [ + { + "name": "v0", + "label": "launch speed v0 (m/s)", + "min": 2, + "max": 25, + "step": 1, + "value": 15 + }, + { + "name": "h0", + "label": "launch height h0 (m)", + "min": 2, + "max": 30, + "step": 1, + "value": 20 + } + ], + "code": "H.background();\nconst g = 9.8, v0 = Math.max(1, P.v0), h0 = Math.max(1, P.h0);\nconst tFlight = Math.sqrt(2 * h0 / g);\nconst range = v0 * tFlight;\nconst v = H.plot2d({ xMin: 0, xMax: Math.max(10, range * 1.15), yMin: 0, yMax: Math.max(6, h0 * 1.15) });\nv.grid(); v.axes();\nconst xf = (tt) => v0 * tt;\nconst yf = (tt) => h0 - 0.5 * g * tt * tt;\nconst pts = [];\nfor (let i = 0; i <= 80; i++) { const tt = tFlight * i / 80; pts.push([xf(tt), yf(tt)]); }\nv.path(pts, { color: H.colors.accent, width: 3 });\nconst tt = (t % (tFlight + 0.5));\nconst cx = Math.min(xf(tt), range), cy = Math.max(0, yf(tt));\nconst vx = v0, vy = -g * Math.min(tt, tFlight);\nv.dot(cx, cy, { r: 6, fill: H.colors.warn });\nv.arrow(cx, cy, cx + vx * 0.25, cy, { color: H.colors.good, width: 2 });\nv.arrow(cx, cy, cx, cy + vy * 0.25, { color: H.colors.violet, width: 2 });\nH.text(\"Horizontal launch: x = v0 t, y = h0 - (1/2) g t^2\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"v0 = \" + v0.toFixed(1) + \" m/s h0 = \" + h0.toFixed(1) + \" m range = \" + range.toFixed(2) + \" m t = \" + tt.toFixed(2) + \" s\", 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"vx (constant)\", color: H.colors.good }, { label: \"vy (grows)\", color: H.colors.violet }], H.W - 175, 28);" + }, + { + "id": "ph-projectile-angled", + "area": "Physics", + "topic": "Projectile motion: angled launch", + "title": "Angled launch: R = v0^2 sin(2 theta) / g", + "equation": "R = v0^2 * sin(2*theta) / g, apex = (v0 sin theta)^2 / (2 g)", + "keywords": [ + "projectile motion", + "angled launch", + "launch angle", + "range equation", + "45 degrees", + "maximum range", + "apex", + "trajectory", + "v0 sin theta", + "v0 cos theta", + "parabola", + "projectile range" + ], + "explanation": "Split the launch velocity into a horizontal part v0*cos(theta) that stays constant and a vertical part v0*sin(theta) that gravity slows, stops at the apex, then reverses. The range R = v0^2 sin(2 theta) / g peaks at theta = 45 deg, where sin(2 theta) = 1; angles symmetric about 45 deg (like 30 and 60) give the SAME range. Watch the green (vx) arrow stay fixed while the violet (vy) arrow shrinks to zero at the yellow apex and then flips downward.", + "bullets": [ + "Velocity splits into constant vx = v0 cos theta and changing vy = v0 sin theta - g t.", + "Range is maximized at theta = 45 deg; 30 deg and 60 deg share a range.", + "At the apex vy = 0, so apex height = (v0 sin theta)^2 / (2 g)." + ], + "params": [ + { + "name": "v0", + "label": "launch speed v0 (m/s)", + "min": 5, + "max": 25, + "step": 1, + "value": 20 + }, + { + "name": "deg", + "label": "launch angle theta (deg)", + "min": 10, + "max": 80, + "step": 1, + "value": 45 + } + ], + "code": "H.background();\nconst g = 9.8, v0 = Math.max(1, P.v0), deg = P.deg;\nconst ang = deg * Math.PI / 180;\nconst vx = v0 * Math.cos(ang), vy0 = v0 * Math.sin(ang);\nconst tFlight = Math.max(0.2, 2 * vy0 / g);\nconst range = vx * tFlight;\nconst hMax = (vy0 * vy0) / (2 * g);\nconst v = H.plot2d({ xMin: 0, xMax: Math.max(8, range * 1.15), yMin: 0, yMax: Math.max(4, hMax * 1.3) });\nv.grid(); v.axes();\nconst xf = (tt) => vx * tt;\nconst yf = (tt) => vy0 * tt - 0.5 * g * tt * tt;\nconst pts = [];\nfor (let i = 0; i <= 80; i++) { const tt = tFlight * i / 80; pts.push([xf(tt), Math.max(0, yf(tt))]); }\nv.path(pts, { color: H.colors.accent, width: 3 });\nconst tApex = vy0 / g;\nv.dot(xf(tApex), hMax, { r: 5, fill: H.colors.yellow });\nconst tt = (t % (tFlight + 0.5));\nconst cx = xf(Math.min(tt, tFlight)), cy = Math.max(0, yf(Math.min(tt, tFlight)));\nconst cvy = vy0 - g * Math.min(tt, tFlight);\nv.dot(cx, cy, { r: 6, fill: H.colors.warn });\nv.arrow(cx, cy, cx + vx * 0.12, cy, { color: H.colors.good, width: 2 });\nv.arrow(cx, cy, cx, cy + cvy * 0.12, { color: H.colors.violet, width: 2 });\nH.text(\"Angled launch: R = v0^2 sin(2*theta) / g\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"v0 = \" + v0.toFixed(1) + \" m/s theta = \" + deg.toFixed(0) + \" deg range = \" + range.toFixed(2) + \" m apex = \" + hMax.toFixed(2) + \" m\", 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"vx\", color: H.colors.good }, { label: \"vy\", color: H.colors.violet }, { label: \"apex\", color: H.colors.yellow }], H.W - 130, 28);" + }, + { + "id": "ph-relative-velocity", + "area": "Physics", + "topic": "Relative velocity", + "title": "Relative velocity: v_ground = v_boat + v_river", + "equation": "v_ground = v_boat + v_river, |v_ground| = sqrt(v_boat^2 + v_river^2)", + "keywords": [ + "relative velocity", + "reference frame", + "boat and river", + "river crossing", + "current", + "velocity addition", + "resultant velocity", + "downstream drift", + "vector addition", + "frame of reference", + "crossing a river" + ], + "explanation": "A boat aiming straight across a river is also carried sideways by the current, so its velocity relative to the GROUND is the vector sum of its velocity relative to the WATER plus the water's velocity relative to the ground. The green arrow (boat) and orange arrow (current) add tip-to-tail into the violet ground velocity, which is why the boat lands downstream of where it pointed. Crossing time depends only on the across-speed v_boat, but a stronger current pushes it farther downstream and tilts the resultant.", + "bullets": [ + "Velocities add as vectors: v_ground = v_boat + v_river.", + "Across-time depends only on v_boat; the current only adds downstream drift.", + "Resultant speed sqrt(v_boat^2 + v_river^2) and drift angle atan(v_river / v_boat)." + ], + "params": [ + { + "name": "vb", + "label": "boat speed across vb (m/s)", + "min": 1, + "max": 8, + "step": 0.5, + "value": 4 + }, + { + "name": "vr", + "label": "river current vr (m/s)", + "min": 0, + "max": 6, + "step": 0.5, + "value": 3 + }, + { + "name": "W", + "label": "river width W (m)", + "min": 3, + "max": 8, + "step": 0.5, + "value": 7 + } + ], + "code": "H.background();\nconst vb = P.vb, vr = P.vr, W = Math.max(2, P.W);\nconst v = H.plot2d({ xMin: -2, xMax: 14, yMin: -1, yMax: 9 });\nv.grid(); v.axes();\nv.line(-2, 0, 14, 0, { color: H.colors.axis, width: 2 });\nv.line(-2, W, 14, W, { color: H.colors.axis, width: 2 });\nv.text(\"far bank\", 9, W + 0.5, { color: H.colors.sub, size: 12 });\nv.text(\"near bank\", -1.5, -0.5, { color: H.colors.sub, size: 12 });\nconst tCross = W / Math.max(0.1, vb);\nconst tt = (t % (tCross + 0.4));\nconst yb = Math.min(W, vb * tt);\nconst xb = vr * Math.min(tt, tCross);\nv.dot(xb, yb, { r: 7, fill: H.colors.warn });\nv.arrow(xb, yb, xb, yb + vb * 0.55, { color: H.colors.good, width: 2.5 });\nv.arrow(xb, yb, xb + vr * 0.55, yb, { color: H.colors.accent2, width: 2.5 });\nv.arrow(xb, yb, xb + vr * 0.55, yb + vb * 0.55, { color: H.colors.violet, width: 2.5 });\nconst vg = Math.sqrt(vb * vb + vr * vr);\nconst driftAng = Math.atan2(vr, vb) * 180 / Math.PI;\nH.text(\"Relative velocity: v_ground = v_boat + v_river\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"v_boat = \" + vb.toFixed(1) + \" m/s v_river = \" + vr.toFixed(1) + \" m/s |v_ground| = \" + vg.toFixed(2) + \" m/s\", 24, 52, { color: H.colors.sub, size: 12 });\nH.text(\"downstream drift angle = \" + driftAng.toFixed(1) + \" deg from straight across\", 24, 74, { color: H.colors.accent, size: 12 });\nH.legend([{ label: \"v_boat\", color: H.colors.good }, { label: \"v_river\", color: H.colors.accent2 }, { label: \"v_ground\", color: H.colors.violet }], H.W - 150, 28);" + }, + { + "id": "ph-work-done-by-force", + "area": "Physics", + "topic": "Work done by a force", + "title": "Work done by a force: W = F·d·cos θ", + "equation": "W = F * d * cos(theta)", + "keywords": [ + "work", + "work done", + "force times distance", + "w = fd cos theta", + "joules", + "force", + "displacement", + "angle of force", + "f d cos", + "work energy", + "force component", + "newton meter" + ], + "explanation": "Work is energy transferred when a force pushes something through a distance, but ONLY the part of the force that lies ALONG the motion counts — that is the cos θ. Slide the force F (red arrow) and the displacement d (green arrow); the block slides back and forth so you can watch them. Tilt the angle θ to 0° and all the force does work; tilt it to 90° (straight up) and the force does ZERO work, no matter how strong, because it never moves the block forward.", + "bullets": [ + "Only the force component along the displacement does work: W = F·d·cos θ.", + "θ = 0° gives maximum work; θ = 90° gives zero work (force ⟂ motion).", + "Work is measured in joules (1 J = 1 N·m); θ > 90° makes W negative." + ], + "params": [ + { + "name": "F", + "label": "force F (N)", + "min": 0, + "max": 20, + "step": 0.5, + "value": 12 + }, + { + "name": "d", + "label": "displacement d (m)", + "min": 0, + "max": 12, + "step": 0.5, + "value": 4 + }, + { + "name": "ang", + "label": "angle θ (degrees)", + "min": 0, + "max": 180, + "step": 1, + "value": 30 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst F = P.F, d = P.d, deg = P.ang;\nconst ang = deg * Math.PI / 180;\nconst work = F * d * Math.cos(ang);\nconst y0 = h * 0.62;\nconst x0 = w * 0.12, x1 = w * 0.82;\nH.line(x0, y0, x1, y0, { color: H.colors.axis, width: 2 });\nconst frac = 0.5 - 0.5 * Math.cos(t * 0.8);\nconst bx = x0 + (x1 - x0 - 60) * frac;\nH.rect(bx, y0 - 34, 60, 34, { fill: H.colors.panel, stroke: H.colors.accent, width: 2, radius: 5 });\nconst cx = bx + 30, cy = y0 - 17;\nconst L = 30 + F * 9;\nH.arrow(cx, cy, cx + L * Math.cos(ang), cy - L * Math.sin(ang), { color: H.colors.warn, width: 4, head: 12 });\nH.arrow(x0, y0 + 26, x0 + d * 24, y0 + 26, { color: H.colors.good, width: 3, head: 10 });\nH.text(\"d\", x0 + d * 12, y0 + 44, { color: H.colors.good, size: 13 });\nH.text(\"F\", cx + L * Math.cos(ang) + 6, cy - L * Math.sin(ang) - 6, { color: H.colors.warn, size: 13 });\nH.text(\"Work done by a force: W = F·d·cos θ\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"F = \" + F.toFixed(1) + \" N d = \" + d.toFixed(1) + \" m θ = \" + deg.toFixed(0) + \"° W = \" + work.toFixed(1) + \" J\", 24, 54, { color: H.colors.sub, size: 14 });\nH.legend([{ label: \"force F\", color: H.colors.warn }, { label: \"displacement d\", color: H.colors.good }], w - 200, 30);" + }, + { + "id": "ph-kinetic-energy", + "area": "Physics", + "topic": "Kinetic energy", + "title": "Kinetic energy: KE = ½ m v²", + "equation": "KE = 1/2 * m * v^2", + "keywords": [ + "kinetic energy", + "ke", + "half m v squared", + "energy of motion", + "1/2 mv^2", + "mass", + "velocity", + "speed", + "joules", + "moving object", + "v squared", + "translational energy" + ], + "explanation": "Kinetic energy is the energy a moving object carries, and it grows with the SQUARE of the speed — double the speed and you quadruple the energy. The ball speeds up and slows down as it rolls; the red arrow is its velocity and the green bar is its kinetic energy. Slide the mass m to make the ball heavier (energy scales linearly with m) and the top speed v to see how dramatically faster motion piles on energy through that v² term.", + "bullets": [ + "KE = ½ m v²: doubling v multiplies the energy by FOUR (it's v², not v).", + "Energy scales linearly with mass m but quadratically with speed v.", + "At v = 0 the kinetic energy is zero; it is always ≥ 0, measured in joules." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "v", + "label": "top speed v (m/s)", + "min": 0, + "max": 10, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\nconst m = P.m, vmax = P.v;\nconst v = vmax * (0.5 - 0.5 * Math.cos(t * 1.1));\nconst KE = 0.5 * m * v * v;\nconst w = H.W, h = H.H;\nconst y0 = h * 0.55;\nconst x0 = w * 0.1, x1 = w * 0.9;\nH.line(x0, y0 + 22, x1, y0 + 22, { color: H.colors.axis, width: 2 });\nconst frac = 0.5 - 0.5 * Math.cos(t * 1.1);\nconst bx = x0 + (x1 - x0) * frac;\nconst r = 12 + m * 1.5;\nH.circle(bx, y0, r, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\nconst dir = Math.sin(t * 1.1) >= 0 ? 1 : -1;\nconst aL = 8 + v * 10;\nH.arrow(bx, y0, bx + dir * aL, y0, { color: H.colors.warn, width: 4, head: 11 });\nH.text(\"v\", bx + dir * aL + dir * 8, y0 - 6, { color: H.colors.warn, size: 13, align: dir > 0 ? \"left\" : \"right\" });\nconst KEmax = 0.5 * m * vmax * vmax || 1;\nconst barX = w * 0.86, barTop = h * 0.18, barH = h * 0.5;\nH.rect(barX, barTop, 26, barH, { stroke: H.colors.grid, width: 1.5 });\nconst fillH = barH * H.clamp(KE / KEmax, 0, 1);\nH.rect(barX, barTop + barH - fillH, 26, fillH, { fill: H.colors.good });\nH.text(\"KE\", barX + 2, barTop - 8, { color: H.colors.sub, size: 12 });\nH.text(\"Kinetic energy: KE = ½ m v²\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"m = \" + m.toFixed(1) + \" kg v = \" + v.toFixed(2) + \" m/s KE = \" + KE.toFixed(1) + \" J\", 24, 54, { color: H.colors.sub, size: 14 });" + }, + { + "id": "ph-gravitational-potential-energy", + "area": "Physics", + "topic": "Gravitational potential energy", + "title": "Gravitational PE: PE = m·g·h", + "equation": "PE = m * g * h", + "keywords": [ + "gravitational potential energy", + "potential energy", + "pe", + "mgh", + "m g h", + "height", + "mass", + "gravity", + "stored energy", + "elevation", + "joules", + "lifting energy" + ], + "explanation": "Gravitational potential energy is the energy stored by lifting a mass against gravity — the higher you raise it, the more it can give back when it falls. The ball rises and falls along the dashed height h while the red arrow shows its weight mg pulling straight down. Slide m and h: PE rises in direct proportion to BOTH, and to the gravitational field g = 9.8 m/s². Drop the height to zero and the stored energy vanishes (we measure PE from the ground, where PE = 0).", + "bullets": [ + "PE = m·g·h: energy grows linearly with mass m and height h.", + "g = 9.8 m/s² on Earth; the downward weight on the mass is mg.", + "PE is measured from a chosen reference (here the ground, PE = 0)." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "h", + "label": "max height h (m)", + "min": 0, + "max": 10, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\nconst m = P.m, hmax = P.h;\nconst g = 9.8;\nconst w = H.W, hh = H.H;\nconst groundY = hh * 0.82;\nconst x0 = w * 0.18, x1 = w * 0.78;\nH.line(x0 - 30, groundY, x1 + 60, groundY, { color: H.colors.axis, width: 2 });\nH.text(\"ground (PE = 0)\", x1 - 20, groundY + 20, { color: H.colors.sub, size: 12 });\nconst frac = 0.5 - 0.5 * Math.cos(t * 0.9);\nconst height = hmax * frac;\nconst PE = m * g * height;\nconst topY = hh * 0.16;\nconst py = groundY - (groundY - topY) * frac;\nconst bx = w * 0.4;\nH.line(bx, groundY, bx, py, { color: H.colors.violet, width: 2, dash: [5, 5] });\nH.text(\"h\", bx + 8, (groundY + py) / 2, { color: H.colors.violet, size: 13 });\nconst Wt = m * g;\nH.arrow(bx, py, bx, py + 18 + Wt * 0.35, { color: H.colors.warn, width: 3, head: 10 });\nH.text(\"mg\", bx + 8, py + 26, { color: H.colors.warn, size: 12 });\nconst r = 11 + m * 1.2;\nH.circle(bx, py, r, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\nconst PEmax = m * g * hmax || 1;\nconst barX = w * 0.84, barTop = topY, barH = groundY - topY;\nH.rect(barX, barTop, 26, barH, { stroke: H.colors.grid, width: 1.5 });\nconst fillH = barH * H.clamp(PE / PEmax, 0, 1);\nH.rect(barX, barTop + barH - fillH, 26, fillH, { fill: H.colors.good });\nH.text(\"PE\", barX + 2, barTop - 8, { color: H.colors.sub, size: 12 });\nH.text(\"Gravitational PE: PE = m·g·h\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"m = \" + m.toFixed(1) + \" kg g = 9.8 m/s² h = \" + height.toFixed(2) + \" m PE = \" + PE.toFixed(1) + \" J\", 24, 54, { color: H.colors.sub, size: 13 });" + }, + { + "id": "ph-spring-potential-energy", + "area": "Physics", + "topic": "Spring potential energy (Hooke's law)", + "title": "Spring PE (Hooke's law): PE = ½ k x²", + "equation": "PE = 1/2 * k * x^2, F = -k * x", + "keywords": [ + "spring potential energy", + "elastic potential energy", + "hooke's law", + "f = -kx", + "1/2 k x squared", + "spring constant", + "stiffness", + "displacement", + "restoring force", + "compression", + "stretch", + "shm" + ], + "explanation": "A stretched or compressed spring stores elastic potential energy, and Hooke's law says the restoring force F = −k x always points back toward the rest position (x = 0). The block oscillates in simple harmonic motion; the red arrow is the spring's restoring force and it grows with how far you pull. Slide the stiffness k to make a stronger spring and the amplitude A to set how far it swings — the stored energy ½ k x² peaks at the turning points (max stretch) and drops to zero as it whips through the middle.", + "bullets": [ + "Hooke's law: F = −k x — force is proportional to displacement and opposes it.", + "Stored energy PE = ½ k x² grows with the SQUARE of the displacement.", + "Energy is maximal at the extremes (x = ±A) and zero at equilibrium (x = 0)." + ], + "params": [ + { + "name": "k", + "label": "spring constant k (N/m)", + "min": 2, + "max": 50, + "step": 1, + "value": 20 + }, + { + "name": "A", + "label": "amplitude A (m)", + "min": 0.2, + "max": 3, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\nconst k = P.k, A = P.A;\nconst x = A * Math.sin(t * 1.4);\nconst Fspring = -k * x;\nconst PE = 0.5 * k * x * x;\nconst w = H.W, hh = H.H;\nconst wallX = w * 0.12, yMid = hh * 0.5;\nconst eqX = w * 0.5;\nconst pxPerM = (w * 0.32) / Math.max(0.1, A);\nconst bx = eqX + x * pxPerM;\nH.rect(wallX - 14, yMid - 60, 14, 120, { fill: H.colors.panel, stroke: H.colors.axis, width: 2 });\nconst coils = 14, sx = wallX, ex = bx - 26;\nconst pts = [];\nfor (let i = 0; i <= coils; i++) {\n const f = i / coils;\n const xx = sx + (ex - sx) * f;\n const yy = yMid + (i === 0 || i === coils ? 0 : (i % 2 ? -14 : 14));\n pts.push([xx, yy]);\n}\nH.path(pts, { color: H.colors.accent, width: 2.5 });\nH.rect(bx - 26, yMid - 22, 52, 44, { fill: H.colors.panel, stroke: H.colors.accent2, width: 2, radius: 5 });\nH.line(eqX, yMid - 70, eqX, yMid + 70, { color: H.colors.grid, width: 1.5, dash: [4, 4] });\nH.text(\"x = 0\", eqX - 14, yMid + 88, { color: H.colors.sub, size: 12 });\nconst aL = 6 + Math.abs(Fspring) * 6;\nconst dir = Fspring >= 0 ? 1 : -1;\nH.arrow(bx, yMid, bx + dir * aL, yMid, { color: H.colors.warn, width: 4, head: 11 });\nH.text(\"F = -kx\", bx + dir * aL + dir * 6, yMid - 8, { color: H.colors.warn, size: 12, align: dir > 0 ? \"left\" : \"right\" });\nH.text(\"Spring PE (Hooke's law): PE = ½ k x²\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"k = \" + k.toFixed(0) + \" N/m x = \" + x.toFixed(2) + \" m F = \" + Fspring.toFixed(1) + \" N PE = \" + PE.toFixed(2) + \" J\", 24, 54, { color: H.colors.sub, size: 13 });" + }, + { + "id": "ph-conservation-mechanical-energy", + "area": "Physics", + "topic": "Conservation of mechanical energy", + "title": "Conservation of energy: KE + PE = E", + "equation": "KE + PE = E = constant, v = sqrt(2 * g * (H0 - h))", + "keywords": [ + "conservation of energy", + "mechanical energy", + "ke + pe", + "energy conservation", + "frictionless", + "kinetic plus potential", + "total energy", + "energy transfer", + "ramp", + "roller coaster", + "constant energy", + "exchange" + ], + "explanation": "With no friction, mechanical energy just trades back and forth between kinetic and potential — the TOTAL E stays locked. The ball rolls in a frictionless bowl: at the rim it is all potential energy (PE high, ball at rest), and at the bottom it is all kinetic (fastest). Watch the two stacked bars: as the green KE bar grows the violet PE bar shrinks by exactly the same amount, so their sum never changes. Raise the release height H₀ or the mass m and the whole budget E = m·g·H₀ scales up, but it's still perfectly conserved every instant.", + "bullets": [ + "Without friction, KE + PE = E is constant — energy only changes form.", + "All PE at the top (slowest) ⇄ all KE at the bottom (fastest).", + "Speed at height h: v = √(2g(H₀ − h)), independent of the mass." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "H0", + "label": "release height H₀ (m)", + "min": 1, + "max": 8, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst m = P.m, H0 = P.H0;\nconst g = 9.8;\nconst w = H.W, hh = H.H;\nconst E = m * g * H0;\nconst x0 = w * 0.12, x1 = w * 0.78, bowlW = x1 - x0;\nconst groundY = hh * 0.78;\nconst topY = hh * 0.2;\nconst px2m = H0 / (groundY - topY);\nconst u = Math.sin(t * 1.2);\nconst bx = (x0 + x1) / 2 + (bowlW / 2) * u;\nconst height = H0 * u * u;\nconst by = groundY - height / px2m;\nconst bpts = [];\nfor (let i = 0; i <= 60; i++) {\n const uu = -1 + 2 * i / 60;\n const xx = (x0 + x1) / 2 + (bowlW / 2) * uu;\n const yy = groundY - (H0 * uu * uu) / px2m;\n bpts.push([xx, yy]);\n}\nH.path(bpts, { color: H.colors.axis, width: 2.5 });\nH.line(x0, topY, x1, topY, { color: H.colors.grid, width: 1, dash: [4, 4] });\nH.text(\"release height H₀\", x1 - 110, topY - 8, { color: H.colors.sub, size: 12 });\nconst r = 11 + m * 1.2;\nH.circle(bx, by, r, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\nH.line(bx, by, bx, groundY, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nconst PE = m * g * height;\nconst KE = Math.max(0, E - PE);\nconst v = Math.sqrt(2 * KE / m);\nconst dir = Math.cos(t * 1.2) >= 0 ? 1 : -1;\nconst aL = 6 + v * 5;\nH.arrow(bx, by, bx + dir * aL, by, { color: H.colors.warn, width: 3, head: 10 });\nconst barX = w * 0.84, barTop = topY, barH = groundY - topY;\nH.rect(barX, barTop, 30, barH, { stroke: H.colors.grid, width: 1.5 });\nconst keH = barH * H.clamp(KE / E, 0, 1);\nconst peH = barH * H.clamp(PE / E, 0, 1);\nH.rect(barX, barTop + barH - keH, 30, keH, { fill: H.colors.good });\nH.rect(barX, barTop, 30, peH, { fill: H.colors.violet });\nH.text(\"E\", barX + 4, barTop - 8, { color: H.colors.sub, size: 12 });\nH.text(\"Conservation of energy: KE + PE = E\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"PE = \" + PE.toFixed(1) + \" J KE = \" + KE.toFixed(1) + \" J E = \" + E.toFixed(1) + \" J v = \" + v.toFixed(2) + \" m/s\", 24, 54, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"KE\", color: H.colors.good }, { label: \"PE\", color: H.colors.violet }], barX - 70, barTop + 6);" + }, + { + "id": "ph-power", + "area": "Physics", + "topic": "Power", + "title": "Power: P = F * v", + "equation": "P = F * v (W = P * t)", + "keywords": [ + "power", + "watt", + "watts", + "rate of work", + "p=fv", + "force times velocity", + "work per time", + "joules per second", + "energy rate", + "p = w / t", + "mechanical power" + ], + "explanation": "Power is how FAST work is done, not how much. Push with force F on something moving at speed v and you deliver power P = F*v, measured in watts (joules per second). Raise either slider and the power line climbs steeper, so the same job finishes in less time. The probe rides W = P*t, showing work piling up linearly: double the power and you reach any amount of work in half the time.", + "bullets": [ + "Power P = F*v is the rate of doing work, in watts (J/s).", + "Work accumulates as W = P*t — a steeper line means faster work.", + "Same work, more power -> it gets done in less time." + ], + "params": [ + { + "name": "F", + "label": "force F (N)", + "min": 0, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "vel", + "label": "speed v (m/s)", + "min": 0, + "max": 4, + "step": 0.2, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 8, yMin: 0, yMax: 22 });\nv.grid(); v.axes();\nconst F = P.F, vel = P.vel;\n// Power delivered by a constant force on an object moving at speed vel: P = F * v\nconst power = F * vel;\n// Work done grows linearly with time: W = power * time, sampled across the window.\nv.fn(x => power * x, { color: H.colors.accent, width: 3 });\n// Sweep a probe that loops across the time window (0..8 s), riding the W(t) line.\nconst ts = (t % 8);\nconst Wnow = power * ts;\nv.dot(ts, Math.min(Wnow, 22), { r: 6, fill: H.colors.warn });\nv.line(ts, 0, ts, Math.min(Wnow, 22), { color: H.colors.violet, width: 1.5, dash: [4, 4] });\n// Force arrow on a little cart at the bottom showing F pushing it along.\nconst cartX = v.X(ts), cartY = v.Y(0.6);\nH.arrow(cartX, cartY, cartX + 18 + F * 4, cartY, { color: H.colors.good, width: 3 });\nH.text(\"Power: P = F · v (W = P · t)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"F = \" + F.toFixed(0) + \" N v = \" + vel.toFixed(1) + \" m/s → P = \" + power.toFixed(1) + \" W\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"t = \" + ts.toFixed(1) + \" s W = \" + Wnow.toFixed(1) + \" J\", v.box.x + v.box.w - 150, 70, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"work W = P·t\", color: H.colors.accent }, { label: \"force F\", color: H.colors.good }], H.W - 170, 28);" + }, + { + "id": "ph-work-energy-theorem", + "area": "Physics", + "topic": "Work-energy theorem", + "title": "Work-energy theorem: W_net = change in KE", + "equation": "W_net = (1/2) m v^2 - (1/2) m v0^2", + "keywords": [ + "work energy theorem", + "kinetic energy", + "net work", + "w = delta ke", + "1/2 m v^2", + "work done", + "change in kinetic energy", + "f times d", + "speeding up", + "energy", + "w=fd" + ], + "explanation": "The net work done on an object equals its change in kinetic energy: W_net = KE_final - KE_initial. A steady force F over distance d adds work W = F*d, which lifts the kinetic-energy line above its starting value (the dashed line at (1/2)m*v0^2). The green gap IS the work done, and from it the speed follows as v = sqrt(v0^2 + 2Fd/m). Push harder (bigger F) or farther (bigger d) and you pour in more KE, so the object ends up faster; a heavier mass m gains the same energy but less speed.", + "bullets": [ + "Net work changes kinetic energy: W_net = (1/2)m v^2 - (1/2)m v0^2.", + "Constant force F over distance d does work W = F*d.", + "Solve for speed: v = sqrt(v0^2 + 2Fd/m) — more work, more speed." + ], + "params": [ + { + "name": "F", + "label": "force F (N)", + "min": 0, + "max": 12, + "step": 1, + "value": 6 + }, + { + "name": "mass", + "label": "mass m (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "v0", + "label": "start speed v0 (m/s)", + "min": 0, + "max": 6, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: 0, yMax: 120 });\nv.grid(); v.axes();\nconst F = P.F, m = Math.max(0.1, P.mass), v0 = P.v0;\n// Work-energy theorem: net work = change in kinetic energy.\n// A constant force F over distance d does work W = F*d, which equals\n// (1/2)m v^2 - (1/2)m v0^2. Solve for v at distance d: v = sqrt(v0^2 + 2 F d / m).\nconst KE = (x) => 0.5 * m * v0 * v0 + F * x; // KE after distance x\nv.fn(x => KE(x), { color: H.colors.accent, width: 3 });\n// Baseline = starting kinetic energy.\nconst KE0 = 0.5 * m * v0 * v0;\nv.line(0, KE0, 10, KE0, { color: H.colors.violet, width: 1.5, dash: [5, 5] });\n// Probe loops across the distance window, showing W (shaded gap) = KE - KE0.\nconst d = (t % 10);\nconst keD = KE(d);\nconst vD = Math.sqrt(Math.max(0, v0 * v0 + 2 * F * d / m));\nv.dot(d, Math.min(keD, 120), { r: 6, fill: H.colors.warn });\nv.line(d, KE0, d, Math.min(keD, 120), { color: H.colors.good, width: 3 });\n// Force arrow along the motion at the bottom.\nconst ax = v.X(d), ay = v.Y(KE0 * 0.4 + 4);\nH.arrow(ax - 30, ay, ax + 14 + F * 0.6, ay, { color: H.colors.accent2, width: 3 });\nH.text(\"Work–energy theorem: W_net = ΔKE\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"F = \" + F.toFixed(0) + \" N m = \" + m.toFixed(1) + \" kg v0 = \" + v0.toFixed(1) + \" m/s\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"d = \" + d.toFixed(1) + \" m W = F·d = \" + (F * d).toFixed(0) + \" J → v = \" + vD.toFixed(2) + \" m/s\", v.box.x + 4, 74, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"KE = ½mv²\", color: H.colors.accent }, { label: \"W = F·d\", color: H.colors.good }, { label: \"starting KE\", color: H.colors.violet }], H.W - 180, 28);" + }, + { + "id": "ph-momentum", + "area": "Physics", + "topic": "Momentum", + "title": "Momentum: p = m * v", + "equation": "p = m * v", + "keywords": [ + "momentum", + "linear momentum", + "p = m v", + "mass times velocity", + "kg m/s", + "quantity of motion", + "p=mv", + "inertia in motion", + "moving mass", + "velocity", + "mass" + ], + "explanation": "Momentum p = m*v captures how hard it is to stop something: it combines how much mass is moving with how fast it goes. Slide the mass and the cart gets bigger and heavier; slide the velocity and it glides faster (the green velocity arrow grows). The pink momentum bar tracks their product p = m*v, so a slow truck and a fast bike can carry the same momentum. Reverse the velocity and the momentum points the other way too — momentum is a vector.", + "bullets": [ + "Momentum p = m*v measures mass in motion, in kg*m/s.", + "Doubling mass OR speed doubles momentum.", + "Momentum has direction — its sign follows the velocity's." + ], + "params": [ + { + "name": "mass", + "label": "mass m (kg)", + "min": 0.5, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "vel", + "label": "velocity v (m/s)", + "min": -4, + "max": 4, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst m = Math.max(0.1, P.mass), vel = P.vel;\n// Momentum p = m * v. A cart of mass m glides at speed v across a track,\n// looping back so it stays in frame. Its velocity arrow length and the\n// momentum bar both scale with p = m*v.\nconst p = m * vel;\nconst w = H.W, h = H.H;\nH.text(\"Momentum: p = m · v\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"m = \" + m.toFixed(1) + \" kg v = \" + vel.toFixed(1) + \" m/s → p = \" + p.toFixed(1) + \" kg·m/s\", 24, 52, { color: H.colors.sub, size: 13 });\n// Track line.\nconst trackY = h * 0.55;\nH.line(40, trackY, w - 40, trackY, { color: H.colors.axis, width: 2 });\n// Cart loops across the track (wrap motion, never drifts off).\nconst span = w - 120;\nconst cx = 60 + ((vel * 40 * t) % span + span) % span;\n// Cart size grows a touch with mass so heavier = visibly bigger.\nconst cw = 30 + m * 6, ch = 18 + m * 3;\nH.rect(cx - cw / 2, trackY - ch, cw, ch, { fill: H.colors.accent, stroke: H.colors.ink, width: 1.5, radius: 4 });\nH.circle(cx - cw / 4, trackY, 5, { fill: H.colors.sub });\nH.circle(cx + cw / 4, trackY, 5, { fill: H.colors.sub });\n// Velocity arrow from the cart, length proportional to v (direction = sign of v).\nconst dir = vel >= 0 ? 1 : -1;\nH.arrow(cx, trackY - ch - 14, cx + dir * (20 + Math.abs(vel) * 8), trackY - ch - 14, { color: H.colors.good, width: 3 });\nH.text(\"v\", cx + dir * 20, trackY - ch - 22, { color: H.colors.good, size: 13 });\n// Momentum bar at the bottom: length proportional to p.\nconst barY = h * 0.82, barX = 60;\nH.text(\"p = m·v\", barX, barY - 14, { color: H.colors.sub, size: 12 });\nH.rect(barX, barY, Math.min(Math.abs(p) * 10, w - 120), 16, { fill: H.colors.warn, radius: 3 });\nH.text(p.toFixed(1) + \" kg·m/s\", barX + Math.min(Math.abs(p) * 10, w - 120) + 8, barY + 13, { color: H.colors.ink, size: 12 });\nH.legend([{ label: \"velocity v\", color: H.colors.good }, { label: \"momentum p\", color: H.colors.warn }], w - 180, 28);" + }, + { + "id": "ph-impulse", + "area": "Physics", + "topic": "Impulse", + "title": "Impulse: J = F * delta-t = delta-p", + "equation": "J = F * deltat = deltap (deltav = J / m)", + "keywords": [ + "impulse", + "impulse momentum theorem", + "j = f t", + "f delta t", + "change in momentum", + "area under force time", + "newton seconds", + "force time graph", + "kick", + "delta p", + "f*t" + ], + "explanation": "An impulse is a force applied over a stretch of time, J = F*deltat, and it equals the change in momentum it produces. On the force-vs-time graph the impulse is literally the AREA of the shaded rectangle — height F times width deltat. Make the force taller or the contact longer and the area grows, so the momentum change deltap and the resulting speed change deltav = J/m both grow. That's why follow-through (a longer deltat) and a harder hit (a bigger F) both change an object's motion more.", + "bullets": [ + "Impulse J = F*deltat equals the change in momentum deltap (in N*s).", + "On a force-time graph, impulse is the area under the curve.", + "Velocity change deltav = J/m — same impulse moves a lighter mass faster." + ], + "params": [ + { + "name": "F", + "label": "force F (N)", + "min": 5, + "max": 50, + "step": 5, + "value": 30 + }, + { + "name": "dt", + "label": "contact time delta-t (s)", + "min": 0.1, + "max": 1.5, + "step": 0.1, + "value": 1 + }, + { + "name": "mass", + "label": "mass m (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 4, yMin: 0, yMax: 60 });\nv.grid(); v.axes();\nconst F = P.F, dt = Math.max(0.05, P.dt), m = Math.max(0.1, P.mass);\n// Impulse J = F * dt = change in momentum. A constant force F acts for a\n// duration dt (a \"kick\"), centered in the window. The shaded rectangle on the\n// force-time graph IS the impulse; its area = F*dt = Δp, so Δv = F*dt/m.\nconst t0 = 2 - dt / 2, t1 = 2 + dt / 2;\nconst J = F * dt;\nconst dv = J / m;\n// Force-time curve: F during the pulse, 0 otherwise.\nv.fn(x => (x >= t0 && x <= t1 ? F : 0), { color: H.colors.accent, width: 3 });\n// Shade the impulse rectangle (area = F*dt).\nv.rect(t0, 0, dt, F, { fill: \"rgba(124,196,255,0.25)\", stroke: H.colors.accent, width: 1 });\n// Sweep a time cursor across the window; it loops 0..4 s.\nconst ts = (t % 4);\nconst Fnow = (ts >= t0 && ts <= t1) ? F : 0;\nv.line(ts, 0, ts, 60, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.dot(ts, Fnow, { r: 6, fill: H.colors.warn });\n// Accumulated impulse so far (area swept) -> running momentum change.\nconst swept = Math.max(0, Math.min(ts, t1) - t0) * F * (ts >= t0 ? 1 : 0);\nconst Jnow = ts < t0 ? 0 : (ts > t1 ? J : Math.max(0, ts - t0) * F);\nH.text(\"Impulse: J = F · Δt = Δp\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"F = \" + F.toFixed(0) + \" N Δt = \" + dt.toFixed(2) + \" s m = \" + m.toFixed(1) + \" kg\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"J = F·Δt = \" + J.toFixed(1) + \" N·s = Δp → Δv = J/m = \" + dv.toFixed(2) + \" m/s\", v.box.x + 4, 74, { color: H.colors.good, size: 13 });\nH.text(\"impulse so far = \" + Jnow.toFixed(1) + \" N·s\", v.X(2.4), v.Y(F * 0.6), { color: H.colors.warn, size: 12 });\nH.legend([{ label: \"force F(t)\", color: H.colors.accent }, { label: \"area = impulse\", color: H.colors.violet }], H.W - 190, 28);" + }, + { + "id": "ph-conservation-of-momentum", + "area": "Physics", + "topic": "Conservation of momentum", + "title": "Conservation of momentum: m1*v1 + m2*v2 = (m1+m2)*vf", + "equation": "m1 * v1 + m2 * v2 = (m1 + m2) * vf", + "keywords": [ + "conservation of momentum", + "collision", + "inelastic collision", + "total momentum", + "carts collide", + "stick together", + "m1 v1 + m2 v2", + "isolated system", + "before and after", + "perfectly inelastic", + "momentum conserved" + ], + "explanation": "In an isolated system the total momentum stays the same — internal collision forces cancel in pairs (Newton's third law). Here a moving cart (mass m1, speed v1) strikes a resting cart (mass m2) and they stick together; the combined mass then moves at vf = (m1*v1)/(m1+m2). Watch the pink total p_total = m1*v1 + m2*v2 stay constant before and after, while the shared speed vf comes out smaller because the same momentum is now spread over more mass. Add mass to cart 2 and the pair slows down, but the total momentum never changes.", + "bullets": [ + "Total momentum before = total momentum after in an isolated system.", + "Perfectly inelastic: carts stick, vf = (m1*v1 + m2*v2)/(m1+m2).", + "More combined mass -> smaller shared speed, but same total momentum." + ], + "params": [ + { + "name": "m1", + "label": "cart 1 mass m1 (kg)", + "min": 0.5, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "v1", + "label": "cart 1 speed v1 (m/s)", + "min": 0.5, + "max": 4, + "step": 0.5, + "value": 3 + }, + { + "name": "m2", + "label": "cart 2 mass m2 (kg)", + "min": 0.5, + "max": 4, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst m1 = Math.max(0.1, P.m1), v1 = P.v1, m2 = Math.max(0.1, P.m2);\n// Conservation of momentum: total p before = total p after a collision.\n// Cart 2 starts at rest (v2 = 0); they collide and stick (perfectly inelastic),\n// so total mass (m1+m2) moves at vf = (m1*v1 + m2*0)/(m1+m2).\nconst v2 = 0;\nconst pTot = m1 * v1 + m2 * v2;\nconst vf = pTot / (m1 + m2);\nconst w = H.W, h = H.H;\nconst trackY = h * 0.55;\nH.text(\"Conservation of momentum: m₁v₁ + m₂v₂ = (m₁+m₂)v_f\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"m₁ = \" + m1.toFixed(1) + \" kg v₁ = \" + v1.toFixed(1) + \" m/s | m₂ = \" + m2.toFixed(1) + \" kg v₂ = 0\", 24, 52, { color: H.colors.sub, size: 13 });\nH.line(40, trackY, w - 40, trackY, { color: H.colors.axis, width: 2 });\n// Phase loops every 6 s: 0..3 s approach+collide, 3..6 s move together, then reset.\nconst T = 6, phase = t % T;\nconst cw1 = 26 + m1 * 5, cw2 = 26 + m2 * 5;\n// Collision point near center.\nconst xc = w * 0.5;\nlet x1, x2, lab1v, lab2v;\nconst approachT = 3;\nif (phase < approachT) {\n // Cart 1 starts left and moves right at v1 (scaled); cart 2 sits at xc + offset.\n const frac = phase / approachT;\n x2 = xc + cw2 / 2 + 30;\n const startX1 = 70;\n const endX1 = xc - cw1 / 2 - cw2 / 2 - 30; // just touching cart2's left\n x1 = startX1 + (endX1 - startX1) * frac;\n lab1v = v1; lab2v = 0;\n} else {\n // Stuck together, moving at vf (scaled), looping within frame.\n const frac = (phase - approachT) / (T - approachT);\n const startX = xc - cw1 / 2 - cw2 / 2 - 30;\n const driftEnd = Math.min(w - 120, startX + Math.abs(vf) * 60);\n const cx = startX + (driftEnd - startX) * frac;\n x1 = cx; x2 = cx + cw1 / 2 + cw2 / 2;\n lab1v = vf; lab2v = vf;\n}\n// Draw carts.\nH.rect(x1 - cw1 / 2, trackY - 20, cw1, 20, { fill: H.colors.accent, stroke: H.colors.ink, width: 1.5, radius: 4 });\nH.rect(x2 - cw2 / 2, trackY - 18, cw2, 18, { fill: H.colors.accent2, stroke: H.colors.ink, width: 1.5, radius: 4 });\nH.text(\"m₁\", x1, trackY - 26, { color: H.colors.accent, size: 12, align: \"center\" });\nH.text(\"m₂\", x2, trackY - 24, { color: H.colors.accent2, size: 12, align: \"center\" });\n// Velocity arrows.\nif (Math.abs(lab1v) > 1e-6) H.arrow(x1, trackY - 34, x1 + (lab1v >= 0 ? 1 : -1) * (16 + Math.abs(lab1v) * 8), trackY - 34, { color: H.colors.good, width: 3 });\nif (phase >= approachT && Math.abs(lab2v) > 1e-6) H.arrow(x2, trackY - 32, x2 + (lab2v >= 0 ? 1 : -1) * (16 + Math.abs(lab2v) * 8), trackY - 32, { color: H.colors.good, width: 3 });\n// Momentum readouts.\nH.text(\"p_total = m₁v₁ + m₂v₂ = \" + pTot.toFixed(1) + \" kg·m/s (conserved)\", 24, h - 70, { color: H.colors.warn, size: 13 });\nH.text(\"after collision: v_f = p_total / (m₁+m₂) = \" + vf.toFixed(2) + \" m/s\", 24, h - 48, { color: H.colors.good, size: 13 });\nH.text(phase < approachT ? \"before: cart 1 approaches cart 2 at rest\" : \"after: they stick and move together at v_f\", 24, h - 26, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"velocity\", color: H.colors.good }, { label: \"cart 1\", color: H.colors.accent }, { label: \"cart 2\", color: H.colors.accent2 }], w - 170, 28);" + }, + { + "id": "ph-angular-kinematics", + "area": "Physics", + "topic": "Angular kinematics", + "title": "Angular kinematics: theta = omega0 t + 1/2 alpha t^2", + "equation": "theta = omega0 * t + 1/2 * alpha * t^2, omega = omega0 + alpha * t", + "keywords": [ + "angular kinematics", + "angular velocity", + "angular acceleration", + "omega", + "alpha", + "theta", + "rotation", + "rad/s", + "spinning wheel", + "constant angular acceleration", + "rotational motion", + "angular displacement" + ], + "explanation": "Rotation obeys the same kinematics as straight-line motion, just with angle theta instead of position. omega0 is the starting spin rate and alpha is the angular acceleration that keeps speeding it up: the wheel's angle grows as theta = omega0 t + 1/2 alpha t^2 while its rate grows linearly as omega = omega0 + alpha t. Raise alpha and watch the wheel wind up faster every second; the green arrow is the rim's tangential velocity, which lengthens as omega climbs.", + "bullets": [ + "theta = omega0 t + 1/2 alpha t^2 — the rotational twin of x = x0 + v0 t + 1/2 a t^2.", + "omega = omega0 + alpha t: angular speed rises linearly when alpha is constant.", + "A rim point's speed is v = omega r, perpendicular to the radius (the green arrow)." + ], + "params": [ + { + "name": "w0", + "label": "initial omega0 (rad/s)", + "min": -3, + "max": 5, + "step": 0.1, + "value": 1 + }, + { + "name": "alpha", + "label": "angular accel alpha (rad/s²)", + "min": -2, + "max": 3, + "step": 0.1, + "value": 0.5 + } + ], + "code": "H.background();\nconst cx = H.W * 0.5, cy = H.H * 0.56, R = Math.min(H.W, H.H) * 0.30;\nconst w0 = P.w0, alpha = P.alpha;\n// loop time so the disk doesn't spin off forever\nconst tt = (t % 6);\nconst theta = w0 * tt + 0.5 * alpha * tt * tt; // theta = w0 t + 1/2 alpha t^2\nconst omega = w0 + alpha * tt; // omega = w0 + alpha t\n// wheel rim\nH.circle(cx, cy, R, { stroke: H.colors.grid, width: 2 });\nH.circle(cx, cy, R + 8, { stroke: H.colors.axis, width: 1 });\n// spokes that rotate by theta\nfor (let k = 0; k < 6; k++) {\n const a = theta + k * H.TAU / 6;\n H.line(cx, cy, cx + R * Math.cos(a), cy - R * Math.sin(a), { color: H.colors.grid, width: 1.5 });\n}\n// a marker on the rim at angle theta\nconst mx = cx + R * Math.cos(theta), my = cy - R * Math.sin(theta);\nH.line(cx, cy, mx, my, { color: H.colors.accent, width: 3 });\nH.circle(mx, my, 8, { fill: H.colors.warn });\n// tangential velocity arrow (perpendicular to radius), length scaled by omega\nconst vlen = H.clamp(Math.abs(omega) * 12, 0, R * 0.9) * Math.sign(omega || 1);\nH.arrow(mx, my, mx - vlen * Math.sin(theta), my - vlen * Math.cos(theta), { color: H.colors.good, width: 3 });\nH.text(\"Angular kinematics: theta = omega0 t + 1/2 alpha t^2\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"t = \" + tt.toFixed(2) + \" s omega = \" + omega.toFixed(2) + \" rad/s theta = \" + theta.toFixed(2) + \" rad\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"radius\", color: H.colors.accent }, { label: \"v (tangential)\", color: H.colors.good }], H.W - 175, 28);" + }, + { + "id": "ph-torque", + "area": "Physics", + "topic": "Torque", + "title": "Torque: tau = r F sin(theta)", + "equation": "tau = r * F * sin(theta)", + "keywords": [ + "torque", + "moment", + "lever arm", + "twist", + "rotational force", + "r f sin theta", + "pivot", + "newton meter", + "wrench", + "turning effect", + "moment arm", + "cross product" + ], + "explanation": "Torque is the twisting power of a force, and it depends on more than how hard you push. tau = r F sin(theta) says it grows with the force F, with how far out you apply it (the lever length r), and with the angle theta between the lever and the force — a push straight along the bar (theta = 0) does nothing. The force angle sweeps automatically: torque is maximal when the force is perpendicular (theta = 90°) and the dashed green line shows the effective lever arm r·sin(theta) shrinking as the angle flattens.", + "bullets": [ + "tau = r F sin(theta): bigger force, longer arm, or more perpendicular = more twist.", + "Only the perpendicular component F·sin(theta) turns things; a pull along the bar does nothing.", + "Equivalently tau = (r·sinθ)·F — the green 'lever arm' is the perpendicular distance to the line of force." + ], + "params": [ + { + "name": "r", + "label": "lever length r (m)", + "min": 0.2, + "max": 1.6, + "step": 0.05, + "value": 0.5 + }, + { + "name": "F", + "label": "force F (N)", + "min": 1, + "max": 20, + "step": 0.5, + "value": 10 + } + ], + "code": "H.background();\nconst px = H.W * 0.32, py = H.H * 0.60; // pivot point on screen\nconst r = P.r, F = P.F;\n// force angle oscillates so torque varies; bounded 20..160 deg\nconst phi = (90 + 70 * Math.sin(t * 0.8)) * Math.PI / 180; // angle between r and F\nconst torque = r * F * Math.sin(phi); // tau = r F sin(phi)\nconst scale = 42; // pixels per metre for the lever\nconst Lpx = r * scale;\n// lever arm (horizontal bar from pivot to the right)\nconst hx = px + Lpx, hy = py;\nH.line(px, py, hx, hy, { color: H.colors.accent, width: 5 });\nH.circle(px, py, 7, { fill: H.colors.axis }); // pivot\nH.text(\"pivot\", px - 10, py + 22, { color: H.colors.sub, size: 12, align: \"center\" });\n// force vector applied at the end of the lever, at angle phi above the bar\nconst Fscale = 26;\nconst fx = hx + F * Fscale * Math.cos(phi), fy = hy - F * Fscale * Math.sin(phi);\nH.arrow(hx, hy, fx, fy, { color: H.colors.warn, width: 3 });\n// perpendicular lever arm r*sin(phi) drawn as dashed from pivot\nconst perp = r * Math.sin(phi) * scale;\nH.line(px, py, px, py - perp, { color: H.colors.good, width: 2, dash: [5, 5] });\nH.circle(hx, hy, 6, { fill: H.colors.accent2 });\n// rotation sense indicator\nH.text(torque >= 0 ? \"↺ CCW\" : \"↻ CW\", hx + 14, hy - 6, { color: H.colors.violet, size: 14, weight: 700 });\nH.text(\"Torque: tau = r * F * sin(theta)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"r = \" + r.toFixed(2) + \" m F = \" + F.toFixed(1) + \" N theta = \" + (phi * 180 / Math.PI).toFixed(0) + \"° tau = \" + torque.toFixed(2) + \" N·m\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"lever r\", color: H.colors.accent }, { label: \"force F\", color: H.colors.warn }, { label: \"lever arm r·sinθ\", color: H.colors.good }], H.W - 215, 28);" + }, + { + "id": "ph-moment-of-inertia", + "area": "Physics", + "topic": "Moment of inertia", + "title": "Moment of inertia: I = m r^2", + "equation": "I = m * r^2", + "keywords": [ + "moment of inertia", + "rotational inertia", + "point mass", + "i = m r^2", + "mass distribution", + "kg m^2", + "resistance to rotation", + "radius of gyration", + "spinning", + "rotation", + "inertia", + "distance from axis" + ], + "explanation": "Moment of inertia is rotation's version of mass: it measures how hard it is to start or stop something spinning. For a point mass it is I = m r^2 — so mass matters, but distance from the axis matters far more because it is SQUARED. Doubling r quadruples I; that's why a mass far out on the rod is so much harder to spin up than the same mass near the axis. The green bar tracks I as you slide the mass in and out.", + "bullets": [ + "I = m r^2 for a point mass: it depends on mass AND where that mass sits.", + "Distance is squared, so moving mass outward raises I dramatically (2× r → 4× I).", + "I plays the role of mass in rotation — bigger I means more torque needed to change the spin." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.2, + "max": 5, + "step": 0.1, + "value": 2 + }, + { + "name": "r", + "label": "radius r (m)", + "min": 0.3, + "max": 2.5, + "step": 0.1, + "value": 1.5 + } + ], + "code": "H.background();\nconst cx = H.W * 0.40, cy = H.H * 0.56;\nconst m = P.m, r = P.r;\nconst I = m * r * r; // I = m r^2 for a point mass\nconst scale = 38; // px per metre\nconst Rpx = r * scale;\nconst ang = t * 1.2; // steady rotation (bounded, loops)\n// axis of rotation\nH.circle(cx, cy, 6, { fill: H.colors.axis });\nH.line(cx, cy - Rpx - 30, cx, cy + Rpx + 30, { color: H.colors.grid, width: 1, dash: [4, 4] });\n// rod from axis to the point mass\nconst mx = cx + Rpx * Math.cos(ang), my = cy - Rpx * Math.sin(ang);\nH.line(cx, cy, mx, my, { color: H.colors.accent, width: 3 });\n// the point mass — radius scaled by sqrt(m) so area ~ m\nconst rad = 6 + 5 * Math.sqrt(Math.max(0.1, m));\nH.circle(mx, my, rad, { fill: H.colors.warn });\n// faint circle showing the orbit radius\nH.circle(cx, cy, Rpx, { stroke: H.colors.grid, width: 1 });\n// I shown as a proportional bar at lower-left\nconst bx = 28, by = H.H - 40, bw = H.clamp(I * 9, 2, H.W - 60);\nH.rect(bx, by, bw, 16, { fill: H.colors.good, radius: 4 });\nH.text(\"I\", bx, by - 6, { color: H.colors.sub, size: 12 });\nH.text(\"Moment of inertia: I = m * r^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"m = \" + m.toFixed(2) + \" kg r = \" + r.toFixed(2) + \" m I = \" + I.toFixed(3) + \" kg·m²\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"rod (radius r)\", color: H.colors.accent }, { label: \"mass m\", color: H.colors.warn }, { label: \"I (resistance to spin)\", color: H.colors.good }], H.W - 230, 28);" + }, + { + "id": "ph-rotational-kinetic-energy", + "area": "Physics", + "topic": "Rotational kinetic energy", + "title": "Rotational KE: KE = 1/2 I omega^2", + "equation": "KE = 1/2 * I * omega^2, I = 1/2 * M * R^2 (solid disk)", + "keywords": [ + "rotational kinetic energy", + "rotational energy", + "ke = 1/2 i omega^2", + "spinning disk", + "flywheel", + "joules", + "angular velocity", + "energy storage", + "moment of inertia", + "omega squared", + "rotation", + "kinetic energy" + ], + "explanation": "A spinning object stores energy just like a moving one, but with I in place of mass and omega in place of v: KE = 1/2 I omega^2. For a solid disk the moment of inertia is I = 1/2 M R^2, so heavier and wider disks bank more energy. Because omega is SQUARED, doubling the spin rate quadruples the stored energy — that's why flywheels chase high RPM. The green bar shows the joules climbing as you crank up M, R, or omega.", + "bullets": [ + "KE = 1/2 I omega^2 — the rotational twin of KE = 1/2 m v^2.", + "Solid disk: I = 1/2 M R^2, so mass and radius both feed the energy.", + "Energy scales with omega^2: spinning twice as fast stores four times the energy." + ], + "params": [ + { + "name": "M", + "label": "disk mass M (kg)", + "min": 0.5, + "max": 10, + "step": 0.5, + "value": 4 + }, + { + "name": "R", + "label": "disk radius R (m)", + "min": 0.2, + "max": 1.5, + "step": 0.05, + "value": 0.5 + }, + { + "name": "omega", + "label": "spin omega (rad/s)", + "min": 0, + "max": 12, + "step": 0.2, + "value": 6 + } + ], + "code": "H.background();\nconst cx = H.W * 0.40, cy = H.H * 0.56, R = Math.min(H.W, H.H) * 0.26;\nconst M = P.M, Rm = P.R, omega = P.omega;\nconst I = 0.5 * M * Rm * Rm; // solid disk: I = 1/2 M R^2\nconst KE = 0.5 * I * omega * omega; // KE = 1/2 I omega^2\nconst ang = (omega * t) % H.TAU; // spins at the real omega, wrapped to one turn\n// disk body\nH.circle(cx, cy, R, { fill: H.colors.panel, stroke: H.colors.accent, width: 2 });\nH.circle(cx, cy, 5, { fill: H.colors.axis });\n// spokes rotating at omega so faster spin reads visually\nfor (let k = 0; k < 8; k++) {\n const a = ang + k * H.TAU / 8;\n H.line(cx, cy, cx + R * Math.cos(a), cy - R * Math.sin(a), { color: H.colors.grid, width: 1.5 });\n}\n// one highlighted rim marker\nH.circle(cx + (R - 6) * Math.cos(ang), cy - (R - 6) * Math.sin(ang), 7, { fill: H.colors.warn });\n// energy bar (proportional to KE)\nconst bx = 28, by = H.H - 42, bw = H.clamp(KE * 1.6, 2, H.W - 60);\nH.rect(bx, by, bw, 16, { fill: H.colors.good, radius: 4 });\nH.text(\"KE\", bx, by - 6, { color: H.colors.sub, size: 12 });\nH.text(\"Rotational KE: KE = 1/2 * I * omega^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"M = \" + M.toFixed(1) + \" kg R = \" + Rm.toFixed(2) + \" m omega = \" + omega.toFixed(2) + \" rad/s\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"I = 1/2 M R² = \" + I.toFixed(3) + \" kg·m² KE = \" + KE.toFixed(2) + \" J\", 24, 72, { color: H.colors.accent2, size: 13 });\nH.legend([{ label: \"spinning disk\", color: H.colors.accent }, { label: \"KE stored\", color: H.colors.good }], H.W - 175, 28);" + }, + { + "id": "ph-angular-momentum", + "area": "Physics", + "topic": "Angular momentum", + "title": "Angular momentum: L = I omega", + "equation": "L = I * omega = m * r^2 * omega (so omega = L / (m r^2))", + "keywords": [ + "angular momentum", + "l = i omega", + "conservation of angular momentum", + "spinning skater", + "ice skater", + "moment of inertia", + "spin faster", + "kg m^2 / s", + "pull arms in", + "rotation", + "conserved", + "omega" + ], + "explanation": "Angular momentum L = I omega is the spin a rotating body carries, and with no external torque it stays CONSTANT. Since I = m r^2, pulling the masses inward shrinks I, so omega must shoot up to keep L = m r^2 omega fixed — this is exactly why a skater spins faster as they pull their arms in. Slide r down and watch the masses whirl faster (omega = L / (m r^2)) while the violet L bar barely moves: the spin rate changes, the angular momentum doesn't.", + "bullets": [ + "L = I omega; with no external torque, L is conserved.", + "Smaller r → smaller I → larger omega: the skater speeds up by pulling in.", + "omega = L / (m r^2): rearranged conservation tells you exactly how fast it spins." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 5, + "step": 0.1, + "value": 2 + }, + { + "name": "r", + "label": "arm radius r (m)", + "min": 0.3, + "max": 1.6, + "step": 0.05, + "value": 0.8 + }, + { + "name": "L", + "label": "angular momentum L (kg·m²/s)", + "min": 0.5, + "max": 6, + "step": 0.1, + "value": 3 + } + ], + "code": "H.background();\nconst cx = H.W * 0.42, cy = H.H * 0.56;\nconst m = P.m, r = P.r, L = P.L;\n// L = I omega with I = m r^2 -> omega = L / (m r^2). Conserving L: small r -> big omega.\nconst I = m * r * r;\nconst omega = I > 1e-6 ? L / I : 0; // omega = L / (m r^2)\nconst scale = 46; // px per metre\nconst Rpx = r * scale;\nconst ang = (omega * t) % H.TAU; // spins at the real omega, wrapped\n// rotation axis\nH.circle(cx, cy, 6, { fill: H.colors.axis });\nH.circle(cx, cy, Rpx, { stroke: H.colors.grid, width: 1 });\n// two symmetric masses (the skater's hands) at radius r\nfor (let s = 0; s < 2; s++) {\n const a = ang + s * Math.PI;\n const mx = cx + Rpx * Math.cos(a), my = cy - Rpx * Math.sin(a);\n H.line(cx, cy, mx, my, { color: H.colors.accent, width: 3 });\n H.circle(mx, my, 9, { fill: H.colors.warn });\n // tangential velocity arrow v = omega r\n const vmag = H.clamp(omega * r * 14, -90, 90);\n H.arrow(mx, my, mx - vmag * Math.sin(a), my - vmag * Math.cos(a), { color: H.colors.good, width: 2.5 });\n}\n// L bar (should stay ~constant as you change r — that's the point)\nconst bx = 28, by = H.H - 42, bw = H.clamp(L * 24, 2, H.W - 60);\nH.rect(bx, by, bw, 16, { fill: H.colors.violet, radius: 4 });\nH.text(\"L (conserved)\", bx, by - 6, { color: H.colors.sub, size: 12 });\nH.text(\"Angular momentum: L = I * omega = m r^2 * omega\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"m = \" + m.toFixed(1) + \" kg r = \" + r.toFixed(2) + \" m L = \" + L.toFixed(2) + \" kg·m²/s\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"pull arms in (small r) → omega = \" + omega.toFixed(2) + \" rad/s (spins faster)\", 24, 72, { color: H.colors.accent2, size: 13 });\nH.legend([{ label: \"mass m\", color: H.colors.warn }, { label: \"v = omega·r\", color: H.colors.good }, { label: \"L stays fixed\", color: H.colors.violet }], H.W - 185, 28);" + }, + { + "id": "ph-static-kinetic-friction", + "area": "Physics", + "topic": "Static and kinetic friction", + "title": "Friction: f_s ≤ mu_s N, f_k = mu_k N", + "equation": "f_s <= mu_s * N, f_k = mu_k * N, N = m g", + "keywords": [ + "friction", + "static friction", + "kinetic friction", + "coefficient of friction", + "mu", + "normal force", + "f = mu n", + "breakaway", + "sliding", + "grip", + "mu_s", + "mu_k" + ], + "explanation": "Friction comes in two flavors. Push gently and STATIC friction silently matches your push so nothing moves — but only up to a ceiling f_s,max = mu_s·N. Slide mu_s up to raise that ceiling. Once your push exceeds it the block breaks free and KINETIC friction f_k = mu_k·N takes over; because mu_k is a bit smaller, the block lurches forward. Heavier mass m raises N = mg, so both frictions scale up with it.", + "bullets": [ + "Static friction self-adjusts up to f_s,max = mu_s·N — it never exceeds your push.", + "Once moving, kinetic friction is constant: f_k = mu_k·N, and mu_k ≤ mu_s.", + "Both scale with the normal force N = mg, not with contact area." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 8, + "step": 0.5, + "value": 3 + }, + { + "name": "muS", + "label": "static mu_s", + "min": 0, + "max": 1, + "step": 0.05, + "value": 0.6 + }, + { + "name": "muK", + "label": "kinetic mu_k", + "min": 0, + "max": 1, + "step": 0.05, + "value": 0.4 + } + ], + "code": "H.background();\n// Friction: f_s ≤ μ_s N, f_k = μ_k N, N = mg on a flat floor.\n// You push a block harder and harder. Static friction matches the push until it\n// hits f_s,max; then the block breaks free and slides under kinetic friction\n// (smaller, so it lurches forward). The whole cycle loops.\nconst g = 9.8;\nconst m = Math.max(0.1, P.m);\nconst muS = Math.max(0, P.muS);\nconst muK = Math.max(0, Math.min(P.muK, muS)); // kinetic ≤ static, physically\nconst N = m * g;\nconst fsMax = muS * N, fk = muK * N;\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: -1, yMax: 6, box: { x: 50, y: 70, w: H.W - 100, h: H.H - 150 } });\nv.line(0, 0, 10, 0, { color: H.colors.axis, width: 3 });\nfor (let i = 0; i < 22; i++) v.line(i * 0.5, 0, i * 0.5 - 0.35, -0.6, { color: H.colors.grid, width: 1.5 });\n// applied push ramps 0 → 2·f_s,max over an 8s loop\nconst phase = (t * 0.8) % 8;\nconst Fapp = phase * fsMax / 4;\nconst sliding = Fapp > fsMax + 1e-9;\nconst friction = sliding ? fk : Math.min(Fapp, fsMax);\n// block: parked at x=2 until it breaks free, then slides forward (bounded)\nlet bx = 2;\nif (sliding) bx = 2 + 4 * (phase - 4) / 4;\nbx = Math.max(1.5, Math.min(8, bx));\nconst bw = 1.2, bh = 1.0, cy = bh / 2;\nv.rect(bx - bw / 2, 0, bw, bh, { fill: H.colors.panel, stroke: H.colors.accent, width: 2 });\nv.text(\"m\", bx, cy, { color: H.colors.ink, size: 14, align: \"center\" });\n// applied push (blue, right), friction (orange, opposing left)\nv.arrow(bx + bw / 2, cy, bx + bw / 2 + Fapp / Math.max(fsMax, 1e-6) * 1.8 + 0.05, cy, { color: H.colors.accent, width: 3 });\nv.arrow(bx - bw / 2, cy, bx - bw / 2 - friction / Math.max(fsMax, 1e-6) * 1.8 - 0.02, cy, { color: H.colors.warn, width: 3 });\n// weight down, normal up\nv.arrow(bx, bh, bx, bh - 1.2, { color: H.colors.good, width: 2 });\nv.arrow(bx, 0.02, bx, 1.2, { color: H.colors.violet, width: 2 });\nH.text(\"Friction: f_s ≤ μ_s N, f_k = μ_k N\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"N = mg = \" + N.toFixed(1) + \" N f_s,max = \" + fsMax.toFixed(1) + \" N f_k = \" + fk.toFixed(1) + \" N\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text((sliding ? \"SLIDING — kinetic f_k = \" + fk.toFixed(1) + \" N\" : \"STATIC — friction matches push: f = \" + friction.toFixed(1) + \" N\") + \" (push = \" + Fapp.toFixed(1) + \" N)\", 24, H.H - 26, { color: sliding ? H.colors.warn : H.colors.good, size: 14, weight: 700 });\nH.legend([{ label: \"push F\", color: H.colors.accent }, { label: \"friction f\", color: H.colors.warn }, { label: \"weight mg\", color: H.colors.good }, { label: \"normal N\", color: H.colors.violet }], H.W - 160, 28);" + }, + { + "id": "ph-tension", + "area": "Physics", + "topic": "Tension", + "title": "Tension: T = m(g + a)", + "equation": "T = m (g + a), at rest T = m g", + "keywords": [ + "tension", + "rope tension", + "string tension", + "hanging mass", + "t = mg", + "weight", + "newton second law", + "accelerating elevator", + "apparent weight", + "cable force" + ], + "explanation": "Tension is the pull a rope transmits along its length. For a mass hanging still, the rope must exactly cancel gravity, so T = mg — raise the mass m and the rope pulls harder. But if the mass accelerates, Newton's second law says T = m(g + a): when it accelerates UPWARD the rope must do extra work (T > mg), and when it accelerates downward the rope relaxes (T < mg). Watch the green tension arrow breathe above and below the weight as the mass bobs — that's apparent weight changing.", + "bullets": [ + "A rope can only PULL; tension always points away from the object along the rope.", + "At rest, tension balances weight exactly: T = mg.", + "Accelerating up makes T > mg; accelerating down makes T < mg (T = m(g+a))." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "amp", + "label": "bob amplitude (m)", + "min": 0, + "max": 0.6, + "step": 0.05, + "value": 0.35 + } + ], + "code": "H.background();\n// Tension in a hanging mass on an elastic-ish rope: at rest T = m g.\n// We show a mass bobbing vertically (light SHM) on a rope; tension = m(g + a)\n// where a is the bob's acceleration, so the rope force readout breathes above\n// and below mg. Bounded oscillation, fully on-screen.\nconst g = 9.8;\nconst m = Math.max(0.1, P.m);\nconst amp = Math.max(0, Math.min(P.amp, 0.6)); // bob amplitude (m), small\nconst w = 2.2; // angular frequency (rad/s)\n// vertical position of the mass (sinusoidal bob about an equilibrium)\nconst yEq = 3.0;\nconst disp = amp * Math.sin(w * t);\nconst y = yEq + disp;\nconst acc = -amp * w * w * Math.sin(w * t); // a = y'' ; up positive\nconst T = m * (g + acc); // Newton's 2nd law on the mass\nconst Tstatic = m * g;\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: 0, yMax: 7, box: { x: 50, y: 70, w: H.W - 100, h: H.H - 150 } });\n// ceiling\nv.line(-2, 6.5, 2, 6.5, { color: H.colors.axis, width: 4 });\nfor (let i = 0; i < 8; i++) v.line(-2 + i * 0.5, 6.5, -2 + i * 0.5 - 0.3, 6.9, { color: H.colors.grid, width: 1.5 });\n// rope from ceiling to mass\nv.line(0, 6.5, 0, y + 0.5, { color: H.colors.accent2, width: 3 });\n// the mass (a box)\nv.rect(-0.5, y - 0.5, 1.0, 1.0, { fill: H.colors.panel, stroke: H.colors.accent, width: 2 });\nv.text(\"m\", 0, y, { color: H.colors.ink, size: 14, align: \"center\" });\n// tension arrow up (scaled by T/Tstatic) and weight arrow down (fixed mg)\nconst tScale = T / Math.max(Tstatic, 1e-6);\nv.arrow(0, y + 0.5, 0, y + 0.5 + 1.4 * tScale, { color: H.colors.good, width: 3 });\nv.arrow(0, y - 0.5, 0, y - 0.5 - 1.4, { color: H.colors.warn, width: 3 });\nv.text(\"T\", 0.35, y + 0.5 + 0.9 * tScale, { color: H.colors.good, size: 13 });\nv.text(\"mg\", 0.35, y - 0.5 - 0.9, { color: H.colors.warn, size: 13 });\nH.text(\"Tension: T = m(g + a)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"at rest (a = 0): T = mg = \" + Tstatic.toFixed(1) + \" N\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"T = \" + T.toFixed(1) + \" N a = \" + acc.toFixed(2) + \" m/s² \" + (acc > 0.02 ? \"(accelerating up → T > mg)\" : acc < -0.02 ? \"(accelerating down → T < mg)\" : \"(≈ equilibrium)\"), 24, H.H - 26, { color: H.colors.good, size: 14, weight: 700 });\nH.legend([{ label: \"tension T\", color: H.colors.good }, { label: \"weight mg\", color: H.colors.warn }], H.W - 150, 28);" + }, + { + "id": "ph-free-body-diagram", + "area": "Physics", + "topic": "Free-body diagrams", + "title": "Free-body diagram: ΣF = m a", + "equation": "sum of F = m a; Fx = F cos(theta), Fy = F sin(theta)", + "keywords": [ + "free body diagram", + "fbd", + "net force", + "newton second law", + "sum of forces", + "force components", + "normal force", + "weight", + "applied force", + "vector decomposition", + "resultant", + "sigma f = ma" + ], + "explanation": "A free-body diagram isolates one object and draws every force as an arrow from its center. Here a block on the floor feels four forces: the applied push F (which splits into horizontal Fx = F·cosθ and vertical Fy = F·sinθ), its weight W = mg pulling down, the floor's normal N pushing up, and friction f opposing horizontal motion. Vertically the forces balance, so N = mg − Fy (pushing up at an angle actually LIGHTENS the contact). Horizontally they don't: the leftover ΣFx = Fx − f is the net force that accelerates the block by a = ΣFx/m.", + "bullets": [ + "Draw every force from the object's center; split angled forces into x and y parts.", + "Vertical balance sets the normal force: N = mg − Fy (it can differ from mg!).", + "The unbalanced direction gives the net force, and a = ΣF/m." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "F", + "label": "applied force F (N)", + "min": 0, + "max": 30, + "step": 1, + "value": 15 + }, + { + "name": "mu", + "label": "friction mu", + "min": 0, + "max": 1, + "step": 0.05, + "value": 0.3 + } + ], + "code": "H.background();\n// Free-body diagram of a block pushed by force F at angle theta on a flat floor.\n// Forces: applied F (split into Fx, Fy), weight W = mg down, normal N up,\n// friction f = mu*N opposing horizontal motion. Net force drives Newton's 2nd law.\n// The push angle sweeps with t so every vector rotates; block stays centered.\nconst g = 9.8;\nconst m = Math.max(0.1, P.m);\nconst F = Math.max(0, P.F);\nconst mu = Math.max(0, P.mu);\n// applied angle sweeps 0..60 deg and back (bounded, looping)\nconst theta = (30 + 30 * Math.sin(t * 0.6)) * Math.PI / 180;\nconst Fx = F * Math.cos(theta), Fy = F * Math.sin(theta);\nconst W = m * g;\nconst N = Math.max(0, W - Fy); // vertical balance: N + Fy = W (push up reduces N)\nconst fMax = mu * N;\nconst f = Math.min(fMax, Fx); // opposes the horizontal push (static-style)\nconst net = Fx - f; // horizontal net force\nconst a = net / m;\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -5, yMax: 5, box: { x: 50, y: 70, w: H.W - 100, h: H.H - 150 } });\n// floor\nv.line(-6, -1.2, 6, -1.2, { color: H.colors.axis, width: 3 });\nfor (let i = 0; i < 24; i++) v.line(-6 + i * 0.5, -1.2, -6 + i * 0.5 - 0.3, -1.7, { color: H.colors.grid, width: 1 });\n// block centered at origin\nv.rect(-0.9, -1.2, 1.8, 1.5, { fill: H.colors.panel, stroke: H.colors.accent, width: 2 });\nv.dot(0, -0.45, { r: 4, fill: H.colors.ink });\nconst cx = 0, cyb = -0.45;\nconst S = 3.2 / Math.max(W, 1e-6); // pixels-per-newton scale so weight fits\n// vector arrows from the block's center\nv.arrow(cx, cyb, cx + Fx * S, cyb + Fy * S, { color: H.colors.accent, width: 3 }); // applied F\nv.arrow(cx, cyb, cx, cyb - W * S, { color: H.colors.warn, width: 3 }); // weight\nv.arrow(cx, cyb, cx, cyb + N * S, { color: H.colors.violet, width: 3 }); // normal\nv.arrow(cx, cyb, cx - f * S, cyb, { color: H.colors.good, width: 3 }); // friction\nv.text(\"F\", cx + Fx * S + 0.2, cyb + Fy * S, { color: H.colors.accent, size: 13 });\nv.text(\"W=mg\", cx + 0.2, cyb - W * S + 0.2, { color: H.colors.warn, size: 12 });\nv.text(\"N\", cx + 0.2, cyb + N * S, { color: H.colors.violet, size: 13 });\nv.text(\"f\", cx - f * S - 0.3, cyb + 0.25, { color: H.colors.good, size: 13 });\nH.text(\"Free-body diagram: ΣF = m a\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"F = \" + F.toFixed(1) + \" N @ \" + (theta * 180 / Math.PI).toFixed(0) + \"° W = \" + W.toFixed(1) + \" N N = \" + N.toFixed(1) + \" N f = \" + f.toFixed(1) + \" N\", 24, 52, { color: H.colors.sub, size: 12 });\nH.text(\"net horizontal ΣFx = \" + net.toFixed(1) + \" N → a = \" + a.toFixed(2) + \" m/s²\", 24, H.H - 26, { color: H.colors.good, size: 14, weight: 700 });\nH.legend([{ label: \"applied F\", color: H.colors.accent }, { label: \"weight W\", color: H.colors.warn }, { label: \"normal N\", color: H.colors.violet }, { label: \"friction f\", color: H.colors.good }], H.W - 160, 28);" + }, + { + "id": "ph-inclined-plane", + "area": "Physics", + "topic": "Inclined planes", + "title": "Inclined plane: a = g(sin θ − mu cos θ)", + "equation": "a = g (sin(theta) - mu cos(theta)); N = m g cos(theta)", + "keywords": [ + "inclined plane", + "ramp", + "slope", + "incline angle", + "mg sin theta", + "mg cos theta", + "normal force on incline", + "component of gravity", + "sliding down ramp", + "friction on incline", + "theta" + ], + "explanation": "On a ramp, gravity mg splits into two perpendicular parts: mg·sinθ pulls the block DOWN the slope (the part that makes it slide) and mg·cosθ presses it INTO the slope (which sets the normal force N). Steepen the angle θ and the sliding part grows while the pressing part shrinks. The block stays put as long as friction's ceiling μ·N can match mg·sinθ; once gravity wins it accelerates down at a = g(sinθ − μcosθ). Notice that mass cancels — a heavy and a light block slide identically.", + "bullets": [ + "Gravity splits into mg·sinθ (down-slope) and mg·cosθ (into the surface).", + "The normal force is reduced on a ramp: N = mg·cosθ, not mg.", + "Acceleration a = g(sinθ − μcosθ) is independent of mass." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 6, + "step": 0.5, + "value": 2 + }, + { + "name": "deg", + "label": "angle θ (degrees)", + "min": 5, + "max": 75, + "step": 1, + "value": 30 + }, + { + "name": "mu", + "label": "friction mu", + "min": 0, + "max": 1, + "step": 0.05, + "value": 0.2 + } + ], + "code": "H.background();\n// Inclined plane: a = g(sin θ − μ cos θ) along the ramp (down-slope positive).\n// Gravity mg splits into a component mg·sinθ down the slope and mg·cosθ into it\n// (which sets N). Friction μN opposes sliding. If mg sinθ ≤ μ mg cosθ the block\n// stays put; otherwise it accelerates down at a = g(sinθ − μcosθ) and resets.\nconst g = 9.8;\nconst m = Math.max(0.1, P.m);\nconst deg = Math.max(1, Math.min(P.deg, 80));\nconst mu = Math.max(0, P.mu);\nconst th = deg * Math.PI / 180;\nconst W = m * g;\nconst along = W * Math.sin(th); // mg sinθ (drives it down)\nconst into = W * Math.cos(th); // mg cosθ (sets normal)\nconst N = into;\nconst fMax = mu * N;\nconst net = along - fMax; // net once it slides (kinetic-style)\nconst slides = net > 0;\nconst a = slides ? net / m : 0; // a = g(sinθ − μcosθ) only if it overcomes friction\n// friction drawn: kinetic μN if sliding, else exactly cancels mg sinθ (static)\nconst fric = slides ? fMax : Math.min(fMax, along);\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -1, yMax: 8, box: { x: 50, y: 70, w: H.W - 100, h: H.H - 150 } });\nconst x0 = 0.5, y0 = 0.5; // bottom-left of ramp\nconst L = 9.5; // ramp base length\nconst rx = x0 + L, ry = y0 + L * Math.tan(th); // top corner\nv.path([[x0, y0], [rx, y0], [rx, ry], [x0, y0]], { color: H.colors.axis, width: 2, fill: H.colors.panel, close: true });\nconst ux = Math.cos(th), uy = Math.sin(th); // unit vector up the slope\nconst sMax = L / Math.cos(th); // slope length (hypotenuse)\n// Motion: if it slides, real kinematics s = ½ a τ² from the top until it reaches\n// the bottom, then reset (looping). If static, the block sits parked partway up.\nlet sPos;\nif (slides) {\n const tFall = Math.sqrt(2 * sMax / a); // time to slide the full ramp\n const tau = t % tFall; // loop each fall\n sPos = sMax - 0.5 * a * tau * tau; // start at top, accelerate down\n sPos = Math.max(0, Math.min(sMax, sPos));\n} else {\n sPos = sMax * 0.55; // stays put on the ramp\n}\nconst bx = x0 + sPos * ux, by = y0 + sPos * uy;\nconst bw = 0.9;\nconst nx = -Math.sin(th), ny = Math.cos(th); // unit normal to slope\nconst corner = (sx, sy) => [bx + sx * ux + sy * nx, by + sx * uy + sy * ny];\nv.path([corner(-bw / 2, 0), corner(bw / 2, 0), corner(bw / 2, bw), corner(-bw / 2, bw)], { color: H.colors.accent, width: 2, fill: H.colors.bg, close: true });\nconst ccx = bx + bw / 2 * nx, ccy = by + bw / 2 * ny; // block center\nv.text(\"m\", ccx, ccy, { color: H.colors.ink, size: 12, align: \"center\" });\nconst SF = 2.2 / Math.max(W, 1e-6);\nv.arrow(ccx, ccy, ccx, ccy - W * SF, { color: H.colors.warn, width: 3 }); // weight (down)\nv.arrow(ccx, ccy, ccx - along * SF * ux, ccy - along * SF * uy, { color: H.colors.accent, width: 3 }); // along (down-slope)\nv.arrow(ccx, ccy, ccx + N * SF * nx, ccy + N * SF * ny, { color: H.colors.violet, width: 3 }); // normal\nv.arrow(ccx, ccy, ccx + fric * SF * ux, ccy + fric * SF * uy, { color: H.colors.good, width: 3 }); // friction up-slope\nH.text(\"Inclined plane: a = g(sin θ − μ cos θ)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"θ = \" + deg.toFixed(0) + \"° mg sinθ = \" + along.toFixed(1) + \" N N = mg cosθ = \" + N.toFixed(1) + \" N f_max = \" + fMax.toFixed(1) + \" N\", 24, 52, { color: H.colors.sub, size: 12 });\nH.text(slides ? \"slides: a = \" + a.toFixed(2) + \" m/s² down the slope\" : \"static: mg sinθ ≤ μ mg cosθ → stays put\", 24, H.H - 26, { color: slides ? H.colors.good : H.colors.warn, size: 14, weight: 700 });\nH.legend([{ label: \"weight mg\", color: H.colors.warn }, { label: \"mg sinθ\", color: H.colors.accent }, { label: \"normal N\", color: H.colors.violet }, { label: \"friction f\", color: H.colors.good }], H.W - 150, 28);" + }, + { + "id": "ph-atwood-machine", + "area": "Physics", + "topic": "Atwood machine / connected objects", + "title": "Atwood machine: a = (m₂ − m₁)g / (m₁ + m₂)", + "equation": "a = (m2 - m1) g / (m1 + m2), T = 2 m1 m2 g / (m1 + m2)", + "keywords": [ + "atwood machine", + "connected objects", + "pulley", + "two masses", + "tension", + "system acceleration", + "newton second law", + "string over pulley", + "coupled masses", + "a = (m2-m1)g/(m1+m2)" + ], + "explanation": "Two masses share one string over a pulley, so they're locked together: whatever speed one gains, the other matches, and they accelerate at the SAME magnitude. The heavier side wins and falls, dragging the lighter side up, at a = (m₂ − m₁)g/(m₁ + m₂). Make the masses equal and the system balances (a = 0); make them very different and a approaches g. The string tension T = 2m₁m₂g/(m₁ + m₂) always sits BETWEEN the two weights — more than the light weight (it's being lifted) but less than the heavy one (it's falling).", + "bullets": [ + "Connected by one string, both masses share the same acceleration magnitude.", + "The heavier mass falls; a = (m₂ − m₁)g/(m₁ + m₂), zero when equal.", + "Tension T = 2m₁m₂g/(m₁ + m₂) lies between the two weights." + ], + "params": [ + { + "name": "m1", + "label": "left mass m₁ (kg)", + "min": 0.5, + "max": 8, + "step": 0.5, + "value": 2 + }, + { + "name": "m2", + "label": "right mass m₂ (kg)", + "min": 0.5, + "max": 8, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\n// Atwood machine: a = (m2 − m1) g / (m1 + m2), T = 2 m1 m2 g / (m1 + m2).\n// Two masses hang over a pulley. The heavier side accelerates down, the lighter\n// up, at the SAME magnitude a (one string). Masses bob up/down on a loop.\nconst g = 9.8;\nconst m1 = Math.max(0.1, P.m1); // left mass\nconst m2 = Math.max(0.1, P.m2); // right mass\nconst a = (m2 - m1) * g / (m1 + m2); // + means m2 (right) accelerates down\nconst T = 2 * m1 * m2 * g / (m1 + m2);\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: 0, yMax: 8, box: { x: 50, y: 70, w: H.W - 100, h: H.H - 150 } });\n// ceiling + pulley\nv.line(-3, 7.5, 3, 7.5, { color: H.colors.axis, width: 4 });\nconst pcx = 0, pcy = 6.6, pr = 0.6;\nconst pts = [];\nfor (let i = 0; i <= 40; i++) { const aa = i / 40 * H.TAU; pts.push([pcx + pr * Math.cos(aa), pcy + pr * Math.sin(aa)]); }\nv.path(pts, { color: H.colors.accent2, width: 3, close: true });\nv.line(0, 7.5, 0, 7.2, { color: H.colors.axis, width: 2 });\n// equilibrium heights of each hanging mass\nconst yL0 = 3.5, yR0 = 3.5;\n// bob: the heavier side moves DOWN. Use a bounded triangle-wave so it loops.\nconst dir = a >= 0 ? 1 : -1; // right goes down if a>0\nconst swing = 1.3 * Math.sin(t * 0.9); // displacement, bounded\nconst yR = yR0 - dir * swing; // right mass\nconst yL = yL0 + dir * swing; // left mass (opposite)\nconst xL = -pr, xR = pr;\n// strings over the pulley\nv.line(xL, pcy, xL, yL + 0.5, { color: H.colors.sub, width: 2 });\nv.line(xR, pcy, xR, yR + 0.5, { color: H.colors.sub, width: 2 });\n// mass boxes sized by mass\nconst sz1 = 0.5 + 0.5 * Math.min(m1, 8) / 8, sz2 = 0.5 + 0.5 * Math.min(m2, 8) / 8;\nv.rect(xL - sz1, yL - sz1, 2 * sz1, 2 * sz1, { fill: H.colors.panel, stroke: H.colors.accent, width: 2 });\nv.rect(xR - sz2, yR - sz2, 2 * sz2, 2 * sz2, { fill: H.colors.panel, stroke: H.colors.warn, width: 2 });\nv.text(\"m1\", xL, yL, { color: H.colors.ink, size: 12, align: \"center\" });\nv.text(\"m2\", xR, yR, { color: H.colors.ink, size: 12, align: \"center\" });\n// tension arrows up on each mass, weights down\nconst SF = 1.4 / Math.max(T, m1 * g, m2 * g, 1e-6);\nv.arrow(xL, yL + sz1, xL, yL + sz1 + T * SF, { color: H.colors.good, width: 2.5 });\nv.arrow(xR, yR + sz2, xR, yR + sz2 + T * SF, { color: H.colors.good, width: 2.5 });\nv.arrow(xL, yL - sz1, xL, yL - sz1 - m1 * g * SF, { color: H.colors.violet, width: 2.5 });\nv.arrow(xR, yR - sz2, xR, yR - sz2 - m2 * g * SF, { color: H.colors.violet, width: 2.5 });\nH.text(\"Atwood machine: a = (m₂ − m₁)g / (m₁ + m₂)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"m₁ = \" + m1.toFixed(1) + \" kg m₂ = \" + m2.toFixed(1) + \" kg g = 9.8 m/s²\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = \" + a.toFixed(2) + \" m/s² (\" + (a > 0.01 ? \"right side falls\" : a < -0.01 ? \"left side falls\" : \"balanced\") + \") T = \" + T.toFixed(1) + \" N\", 24, H.H - 26, { color: H.colors.good, size: 14, weight: 700 });\nH.legend([{ label: \"tension T\", color: H.colors.good }, { label: \"weight mg\", color: H.colors.violet }], H.W - 150, 28);" + }, + { + "id": "ph-bernoullis-principle", + "area": "Physics", + "topic": "Bernoulli's principle", + "title": "Bernoulli: P + 1/2 rho v^2 = constant", + "equation": "P1 + 1/2 * rho * v1^2 = P2 + 1/2 * rho * v2^2, A1*v1 = A2*v2", + "keywords": [ + "bernoulli", + "bernoulli's principle", + "fluid pressure", + "venturi", + "continuity equation", + "flow speed", + "pressure drop", + "incompressible flow", + "p + half rho v squared", + "streamline", + "fluid dynamics", + "constriction" + ], + "explanation": "Along a streamline the quantity P + 1/2*rho*v^2 stays constant, so wherever a fluid speeds up its pressure must drop. The inlet-speed slider sets how fast fluid enters the wide section; the throat-ratio slider pinches the pipe, and continuity (A1*v1 = A2*v2) forces the fluid to rush faster through the narrow throat. Watch the speed arrows lengthen and the purple pressure bar fall in the throat: that pressure deficit is exactly the 1/2*rho*(v2^2 - v1^2) Bernoulli demands. The inlet-pressure slider just slides the whole pressure level up or down.", + "bullets": [ + "Faster flow means lower pressure: P drops by 1/2*rho*(v2^2 - v1^2).", + "Continuity A1*v1 = A2*v2 makes a narrower throat speed the fluid up.", + "P + 1/2*rho*v^2 is identical at every station along one streamline." + ], + "params": [ + { + "name": "v1", + "label": "inlet speed v1 (m/s)", + "min": 0.5, + "max": 6, + "step": 0.1, + "value": 3 + }, + { + "name": "p1", + "label": "inlet pressure P1 (kPa)", + "min": 80, + "max": 200, + "step": 5, + "value": 120 + }, + { + "name": "ratio", + "label": "throat width / inlet width", + "min": 0.3, + "max": 0.9, + "step": 0.05, + "value": 0.5 + } + ], + "code": "H.background();\nconst rho = 1000;\nconst v1 = Math.max(0.1, P.v1);\nconst P1 = P.p1 * 1000;\nconst ratio = H.clamp(P.ratio, 0.2, 0.95);\nconst w = H.W, h = H.H;\nconst cx = w / 2, midY = h * 0.52;\nconst R1 = 78, R2 = R1 * ratio;\nconst A1 = R1 * R1, A2 = R2 * R2;\nconst v2 = v1 * A1 / A2;\nconst P2 = P1 + 0.5 * rho * (v1 * v1 - v2 * v2);\nconst xL = 60, xR = w - 60, xa = w * 0.36, xb = w * 0.64;\nfunction radAt(x) {\n if (x <= xa) return R1;\n if (x >= xb) return R1;\n const u = (x - xa) / (xb - xa);\n const s = 0.5 - 0.5 * Math.cos(u * Math.PI);\n return R1 + (R2 - R1) * s;\n}\nconst topPts = [], botPts = [];\nfor (let x = xL; x <= xR; x += 6) { const r = radAt(x); topPts.push([x, midY - r]); }\nfor (let x = xR; x >= xL; x -= 6) { const r = radAt(x); botPts.push([x, midY + r]); }\nH.path(topPts.concat(botPts), { color: H.colors.axis, width: 2.5, fill: \"rgba(124,196,255,0.10)\", close: true });\nH.path(topPts, { color: H.colors.accent, width: 3 });\nconst botUp = botPts.slice().reverse();\nH.path(botUp, { color: H.colors.accent, width: 3 });\nconst lanes = 5;\nfor (let li = 0; li < lanes; li++) {\n const frac = (li + 0.5) / lanes - 0.5;\n const pts = [];\n for (let x = xL; x <= xR; x += 8) {\n const r = radAt(x);\n pts.push([x, midY + frac * 2 * r]);\n }\n H.path(pts, { color: \"rgba(103,232,176,0.35)\", width: 1.2 });\n}\nconst nDots = 7;\nfor (let di = 0; di < nDots; di++) {\n const span = xR - xL;\n const phase = (t * v1 * 0.18 + di / nDots) % 1;\n let xpos = xL + phase * span;\n const r = radAt(xpos);\n const frac = ((di % lanes) + 0.5) / lanes - 0.5;\n const ypos = midY + frac * 2 * r * 0.85;\n const localSpeed = v1 * (R1 * R1) / (radAt(xpos) * radAt(xpos));\n const arrowLen = H.clamp(localSpeed * 1.1, 6, 40);\n H.arrow(xpos, ypos, xpos + arrowLen, ypos, { color: H.colors.warn, width: 2, head: 6 });\n H.circle(xpos, ypos, 3, { fill: H.colors.yellow });\n}\nfunction pressureH(Pval) { return H.clamp(40 + Pval / 1500, 10, 130); }\nconst stations = [{ x: xa - 30, r: R1, v: v1, P: P1, lbl: \"wide\" }, { x: cx, r: R2, v: v2, P: P2, lbl: \"throat\" }];\nstations.forEach(st => {\n const colTop = midY - radAt(st.x) - 6;\n const hh = pressureH(st.P);\n H.line(st.x, colTop, st.x, colTop - 8, { color: H.colors.sub, width: 1.5 });\n H.rect(st.x - 7, colTop - 8 - hh, 14, hh, { fill: H.colors.violet, stroke: H.colors.ink, width: 1 });\n H.text(\"P=\" + (st.P / 1000).toFixed(1) + \"kPa\", st.x, colTop - 12 - hh, { color: H.colors.violet, size: 11, align: \"center\" });\n H.text(\"v=\" + st.v.toFixed(1), st.x, midY + radAt(st.x) + 18, { color: H.colors.warn, size: 11, align: \"center\" });\n});\nH.text(\"Bernoulli: P + ½ρv² = constant\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Continuity A₁v₁ = A₂v₂ → faster flow in the throat → lower pressure\", 24, 52, { color: H.colors.sub, size: 12 });\nH.text(\"ρ = 1000 kg/m³ v₁ = \" + v1.toFixed(1) + \" m/s v₂ = \" + v2.toFixed(1) + \" m/s ΔP = \" + ((P2 - P1) / 1000).toFixed(2) + \" kPa\", 24, h - 18, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"flow speed (arrow)\", color: H.colors.warn }, { label: \"pressure (bar)\", color: H.colors.violet }], w - 190, 30);" + }, + { + "id": "ph-beats", + "area": "Physics", + "topic": "Beats", + "title": "Beats: f_beat = |f1 - f2|", + "equation": "y = cos(2*pi*f1*t) + cos(2*pi*f2*t), f_beat = |f1 - f2|", + "keywords": [ + "beats", + "beat frequency", + "interference", + "superposition", + "two tones", + "f1 - f2", + "constructive destructive", + "envelope", + "carrier", + "wah wah", + "tuning", + "amplitude modulation" + ], + "explanation": "Play two pure tones whose frequencies are close and they slip in and out of step, so the combined loudness throbs at the BEAT frequency |f1 - f2|. The blue curve is the raw sum of the two cosines; the dashed red lines are its slow envelope 2|cos(pi*(f1-f2)*t)|, which swells to 2 when the tones add (constructive) and pinches to 0 when they cancel (destructive). Drag f1 and f2 closer together and the throbbing slows; set them equal and the beats vanish entirely - which is exactly how you tune an instrument by ear.", + "bullets": [ + "The summed wave equals 2*cos(pi*(f1-f2)t)*cos(pi*(f1+f2)t): slow envelope x fast carrier.", + "You hear loudness pulse at f_beat = |f1 - f2|, one swell per cancellation cycle.", + "Equal frequencies give zero beats - the basis of tuning by ear." + ], + "params": [ + { + "name": "f1", + "label": "tone 1 f1 (Hz)", + "min": 4, + "max": 16, + "step": 1, + "value": 10 + }, + { + "name": "f2", + "label": "tone 2 f2 (Hz)", + "min": 4, + "max": 16, + "step": 1, + "value": 12 + } + ], + "code": "H.background();\n// x-axis is TIME in seconds; show a 1-second window of the combined signal\nconst v = H.plot2d({ xMin: 0, xMax: 1, yMin: -2.4, yMax: 2.4 });\nv.grid(); v.axes();\nconst f1 = Math.max(1, P.f1); // tone 1 frequency (Hz)\nconst f2 = Math.max(1, P.f2); // tone 2 frequency (Hz)\nconst fbeat = Math.abs(f1 - f2); // beat frequency |f1 - f2|\nconst fbar = (f1 + f2) / 2; // carrier (heard pitch)\nconst TAU = Math.PI * 2;\n// scroll the 1 s viewing window forward, wrapping so it loops in frame\nconst off = (t * 0.15) % 1;\nconst sig = (tt) => Math.cos(TAU * f1 * tt) + Math.cos(TAU * f2 * tt);\nconst envFn = (tt) => 2 * Math.abs(Math.cos(TAU * (fbeat / 2) * tt));\nv.fn(x => sig(x + off), { color: H.colors.accent, width: 2 });\n// slow beat envelope: 2*cos(pi*fbeat*t) - the loudness pulsing\nv.fn(x => envFn(x + off), { color: H.colors.warn, width: 1.6, dash: [5, 5] });\nv.fn(x => -envFn(x + off), { color: H.colors.warn, width: 1.6, dash: [5, 5] });\n// a marker riding the combined wave\nconst xm = 0.5;\nv.dot(xm, sig(xm + off), { r: 6, fill: H.colors.good });\nH.text(\"Beats: y = cos(2pi f1 t) + cos(2pi f2 t)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"f1 = \" + f1.toFixed(0) + \" Hz f2 = \" + f2.toFixed(0) + \" Hz beat = |f1-f2| = \" + fbeat.toFixed(0) + \" Hz\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"sum\", color: H.colors.accent }, { label: \"beat envelope\", color: H.colors.warn }], H.W - 200, 28);" + }, + { + "id": "ph-simple-harmonic-motion", + "area": "Physics", + "topic": "Simple harmonic motion", + "title": "Simple harmonic motion: x = A cos(omega t + phi)", + "equation": "x(t) = A * cos(omega * t + phi), omega = 2*pi / T", + "keywords": [ + "simple harmonic motion", + "shm", + "amplitude", + "period", + "angular frequency", + "omega", + "phase", + "oscillation", + "x = a cos", + "displacement", + "velocity", + "sinusoidal" + ], + "explanation": "In simple harmonic motion the displacement traces a cosine in time: A sets how far the object swings from center, the period T sets how long one full cycle takes (the angular frequency is omega = 2*pi/T), and the phase phi shifts where the motion starts. Watch the particle on the right ride exactly the cosine curve on the left, with its velocity arrow longest at the center (x = 0) and zero at the turning points (x = ±A).", + "bullets": [ + "A is the amplitude: the maximum distance from equilibrium (x = 0).", + "T is the period; omega = 2*pi/T, so a shorter period means faster oscillation.", + "Velocity is largest at the center and zero at the extremes ±A." + ], + "params": [ + { + "name": "A", + "label": "amplitude A (m)", + "min": 0.5, + "max": 3, + "step": 0.1, + "value": 2 + }, + { + "name": "T", + "label": "period T (s)", + "min": 0.5, + "max": 4, + "step": 0.1, + "value": 2 + }, + { + "name": "phi", + "label": "phase phi (rad)", + "min": -3.14, + "max": 3.14, + "step": 0.1, + "value": 0 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst A = P.A, T = Math.max(0.3, P.T), phi = P.phi;\nconst omega = 2 * Math.PI / T;\nconst x = A * Math.cos(omega * t + phi);\nconst vel = -A * omega * Math.sin(omega * t + phi);\n// Left: position-vs-time graph\nconst v = H.plot2d({ xMin: 0, xMax: 4, yMin: -3.2, yMax: 3.2, box: { x: 60, y: 70, w: w * 0.52, h: h - 130 } });\nv.grid(); v.axes();\nv.line(0, A, 4, A, { color: H.colors.grid, width: 1, dash: [4, 4] });\nv.line(0, -A, 4, -A, { color: H.colors.grid, width: 1, dash: [4, 4] });\nv.fn(tau => A * Math.cos(omega * tau + phi), { color: H.colors.accent, width: 3 });\nconst tw = t % 4;\nv.dot(tw, A * Math.cos(omega * tw + phi), { r: 6, fill: H.colors.warn });\nv.text(\"x(t)\", 3.4, A + 0.6, { color: H.colors.accent, size: 13 });\n// Right: the oscillating particle on its axis\nconst ox = w * 0.82, oy0 = 70, oyH = h - 130;\nconst Yc = (xx) => oy0 + (oyH) * (1 - (xx + 3.2) / 6.4);\nH.line(ox, oy0, ox, oy0 + oyH, { color: H.colors.axis, width: 1.5 });\nH.line(ox - 26, Yc(0), ox + 26, Yc(0), { color: H.colors.grid, width: 1, dash: [4, 4] });\nH.line(ox - 30, Yc(A), ox + 30, Yc(A), { color: H.colors.grid, width: 1, dash: [3, 3] });\nH.line(ox - 30, Yc(-A), ox + 30, Yc(-A), { color: H.colors.grid, width: 1, dash: [3, 3] });\nH.circle(ox, Yc(x), 12, { fill: H.colors.warn, stroke: H.colors.bg, width: 2 });\nH.arrow(ox + 40, Yc(x), ox + 40, Yc(x) - vel * 18, { color: H.colors.good, width: 2.5 });\nH.text(\"v\", ox + 46, Yc(x) - 6, { color: H.colors.good, size: 12 });\nH.text(\"Simple harmonic motion: x = A·cos(omega·t + phi)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"A = \" + A.toFixed(2) + \" m T = \" + T.toFixed(2) + \" s x = \" + x.toFixed(2) + \" m v = \" + vel.toFixed(2) + \" m/s\", 24, 52, { color: H.colors.sub, size: 13 });" + }, + { + "id": "ph-mass-spring-oscillator", + "area": "Physics", + "topic": "Mass-spring oscillator", + "title": "Mass-spring oscillator: T = 2 pi sqrt(m/k)", + "equation": "F = -k * x, omega = sqrt(k/m), T = 2*pi*sqrt(m/k)", + "keywords": [ + "mass spring", + "spring oscillator", + "hooke's law", + "spring constant", + "restoring force", + "f = -kx", + "period", + "angular frequency", + "stiffness", + "oscillation", + "natural frequency", + "block on spring" + ], + "explanation": "A spring pulls the block back toward equilibrium with a force F = -k*x — always opposite the displacement, which is what makes the motion oscillate. A stiffer spring (larger k) snaps back harder so the period T = 2*pi*sqrt(m/k) gets shorter, while a heavier mass m is more sluggish and lengthens the period. Watch the red restoring-force arrow flip direction as the block crosses x = 0, and the trace below show the resulting cosine.", + "bullets": [ + "Hooke's law F = -k*x: the force grows with displacement and points back to x = 0.", + "Period T = 2*pi*sqrt(m/k): stiffer (big k) is faster, heavier (big m) is slower.", + "The period does NOT depend on amplitude — a bigger swing has the same period." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.2, + "max": 4, + "step": 0.1, + "value": 1 + }, + { + "name": "k", + "label": "spring constant k (N/m)", + "min": 1, + "max": 30, + "step": 0.5, + "value": 10 + }, + { + "name": "A", + "label": "amplitude A (m)", + "min": 0.3, + "max": 1.5, + "step": 0.1, + "value": 1 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst m = Math.max(0.05, P.m), k = Math.max(0.1, P.k), A = P.A;\nconst omega = Math.sqrt(k / m);\nconst T = 2 * Math.PI / omega;\nconst x = A * Math.cos(omega * t);\nconst acc = -omega * omega * x;\nconst Fspring = -k * x;\n// geometry: a spring hanging horizontally from a wall on the left\nconst wallX = 70, eqX = w * 0.5, scale = 70; // px per meter for displacement\nconst blockX = eqX + x * scale;\nconst yMid = h * 0.42;\n// wall\nH.rect(wallX - 14, yMid - 60, 14, 120, { fill: H.colors.panel, stroke: H.colors.axis, width: 1.5 });\n// coiled spring from wall to block\nconst coils = 12, pts = [];\nconst x0 = wallX, x1 = blockX - 24;\nfor (let i = 0; i <= coils * 2; i++) {\n const f = i / (coils * 2);\n const px = x0 + (x1 - x0) * f;\n const py = yMid + (i === 0 || i === coils * 2 ? 0 : (i % 2 ? -16 : 16));\n pts.push([px, py]);\n}\nH.path(pts, { color: H.colors.violet, width: 2.5 });\n// equilibrium marker\nH.line(eqX, yMid - 70, eqX, yMid + 70, { color: H.colors.grid, width: 1, dash: [5, 5] });\nH.text(\"x = 0\", eqX - 16, yMid + 88, { color: H.colors.sub, size: 12 });\n// the block\nH.rect(blockX - 24, yMid - 24, 48, 48, { fill: H.colors.accent, stroke: H.colors.bg, width: 2, radius: 6 });\n// restoring-force arrow on the block\nH.arrow(blockX, yMid, blockX + Fspring * scale * 0.5, yMid, { color: H.colors.warn, width: 3, head: 11 });\nH.text(\"F = -k·x\", blockX + Fspring * scale * 0.5 + (Fspring < 0 ? -64 : 6), yMid - 12, { color: H.colors.warn, size: 12 });\n// mini x(t) trace at the bottom\nconst v = H.plot2d({ xMin: 0, xMax: 4 * Math.max(T, 0.5), yMin: -1.2 * Math.abs(A) - 0.5, yMax: 1.2 * Math.abs(A) + 0.5, box: { x: 60, y: h - 150, w: w - 120, h: 110 } });\nv.grid(); v.axes();\nv.fn(tau => A * Math.cos(omega * tau), { color: H.colors.accent2, width: 2.5 });\nconst tw = t % (4 * Math.max(T, 0.5));\nv.dot(tw, A * Math.cos(omega * tw), { r: 5, fill: H.colors.warn });\nH.text(\"Mass-spring oscillator: T = 2·pi·sqrt(m/k)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"m = \" + m.toFixed(2) + \" kg k = \" + k.toFixed(1) + \" N/m omega = \" + omega.toFixed(2) + \" rad/s T = \" + T.toFixed(2) + \" s\", 24, 52, { color: H.colors.sub, size: 13 });" + }, + { + "id": "ph-simple-pendulum", + "area": "Physics", + "topic": "Simple pendulum", + "title": "Simple pendulum: T = 2 pi sqrt(L/g)", + "equation": "T = 2*pi*sqrt(L/g), theta(t) = theta0 * cos(sqrt(g/L) * t)", + "keywords": [ + "simple pendulum", + "pendulum period", + "swing", + "length", + "gravity", + "small angle", + "t = 2 pi sqrt(l/g)", + "oscillation", + "bob", + "amplitude angle", + "restoring torque", + "g = 9.8" + ], + "explanation": "A pendulum's period T = 2*pi*sqrt(L/g) depends only on the rod length L and gravity g — not on the mass and (for small swings) not on the amplitude. Lengthen the rod and each swing slows down; gravity supplies the restoring pull that is largest when the bob is farthest from vertical. The red arrow is the tangential restoring force proportional to -g*sin(theta), which drives the back-and-forth motion shown by the dashed arc.", + "bullets": [ + "Period T = 2*pi*sqrt(L/g): only length and gravity matter, not the mass.", + "The restoring force along the swing is proportional to -g*sin(theta).", + "For small angles the motion is simple harmonic, with theta = theta0*cos(omega*t)." + ], + "params": [ + { + "name": "L", + "label": "length L (m)", + "min": 0.2, + "max": 4, + "step": 0.1, + "value": 1 + }, + { + "name": "theta0", + "label": "start angle theta0 (deg)", + "min": 5, + "max": 45, + "step": 1, + "value": 25 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst g = 9.8;\nconst L = Math.max(0.1, P.L);\nconst theta0deg = P.theta0;\nconst theta0 = theta0deg * Math.PI / 180;\nconst omega = Math.sqrt(g / L);\nconst T = 2 * Math.PI / omega;\nconst theta = theta0 * Math.cos(omega * t);\n// pivot near top-center; pendulum length in px scales with L but stays on-screen\nconst px = w * 0.5, py = h * 0.18;\nconst Lpx = Math.min(h * 0.6, 70 * L + 60);\nconst bx = px + Lpx * Math.sin(theta);\nconst by = py + Lpx * Math.cos(theta);\n// ceiling\nH.line(px - 80, py, px + 80, py, { color: H.colors.axis, width: 3 });\n// reference vertical (equilibrium)\nH.line(px, py, px, py + Lpx + 20, { color: H.colors.grid, width: 1, dash: [5, 5] });\n// arc showing swing amplitude\nconst arc = [];\nfor (let i = 0; i <= 40; i++) {\n const a = -theta0 + (2 * theta0) * i / 40;\n arc.push([px + (Lpx + 18) * Math.sin(a), py + (Lpx + 18) * Math.cos(a)]);\n}\nH.path(arc, { color: H.colors.grid, width: 1.5, dash: [4, 4] });\n// the rod and bob\nH.line(px, py, bx, by, { color: H.colors.violet, width: 2.5 });\nH.circle(px, py, 4, { fill: H.colors.axis });\nH.circle(bx, by, 16, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\n// angle label\nH.text(\"theta\", px + 10, py + 40, { color: H.colors.sub, size: 13 });\n// gravity restoring component arrow (tangential): magnitude ~ -g sin(theta)\nconst tangent = -g * Math.sin(theta);\nconst tx = Math.cos(theta), ty = -Math.sin(theta); // tangent direction in screen coords\nH.arrow(bx, by, bx + tangent * tx * 6, by + tangent * ty * 6, { color: H.colors.warn, width: 2.5, head: 10 });\nH.text(\"Simple pendulum: T = 2·pi·sqrt(L/g)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"L = \" + L.toFixed(2) + \" m theta0 = \" + theta0deg.toFixed(0) + \" deg T = \" + T.toFixed(2) + \" s theta = \" + (theta * 180 / Math.PI).toFixed(1) + \" deg\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"rod + bob\", color: H.colors.accent }, { label: \"restoring force\", color: H.colors.warn }], w - 190, 80);" + }, + { + "id": "ph-energy-in-shm", + "area": "Physics", + "topic": "Energy in SHM", + "title": "Energy in SHM: E = (1/2) k A^2", + "equation": "PE = (1/2)*k*x^2, KE = (1/2)*k*(A^2 - x^2), E = (1/2)*k*A^2", + "keywords": [ + "energy in shm", + "potential energy", + "kinetic energy", + "energy conservation", + "1/2 k x^2", + "1/2 k a^2", + "total mechanical energy", + "spring potential", + "oscillation energy", + "energy exchange", + "potential well", + "ke pe" + ], + "explanation": "As an oscillator moves, energy sloshes between potential and kinetic but the total E = (1/2)*k*A^2 stays fixed. At the turning points (x = ±A) everything is potential energy stored in the spring; at the center (x = 0) it is all kinetic. The ball rolls inside the parabolic potential well U(x) = (1/2)*k*x^2 below the constant energy line E, and the two bars show PE and KE always adding up to the same total.", + "bullets": [ + "Total energy E = (1/2)*k*A^2 is constant — set by the amplitude.", + "PE = (1/2)*k*x^2 peaks at the turning points; KE peaks at the center.", + "PE + KE = E at every instant: energy is conserved, just traded back and forth." + ], + "params": [ + { + "name": "k", + "label": "spring constant k (N/m)", + "min": 1, + "max": 20, + "step": 0.5, + "value": 8 + }, + { + "name": "A", + "label": "amplitude A (m)", + "min": 0.5, + "max": 2.5, + "step": 0.1, + "value": 1.5 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst k = Math.max(0.1, P.k), A = P.A;\nconst omega = 2; // fixed visual rate; energy split is what matters\nconst x = A * Math.cos(omega * t);\nconst vel = -A * omega * Math.sin(omega * t);\nconst PE = 0.5 * k * x * x;\nconst KE = 0.5 * k * (A * A - x * x); // since (1/2)m v^2 with m chosen so total = (1/2)kA^2\nconst E = 0.5 * k * A * A;\n// Top: potential well U(x) = 1/2 k x^2 with the ball rolling in it\nconst v = H.plot2d({ xMin: -Math.abs(A) - 0.5, xMax: Math.abs(A) + 0.5, yMin: -0.1 * E - 0.2, yMax: 1.25 * E + 0.3, box: { x: 60, y: 70, w: w * 0.56, h: h - 130 } });\nv.grid(); v.axes();\nv.fn(xx => 0.5 * k * xx * xx, { color: H.colors.violet, width: 3 });\nv.line(-Math.abs(A) - 0.5, E, Math.abs(A) + 0.5, E, { color: H.colors.good, width: 1.5, dash: [5, 5] });\nv.text(\"E (total)\", Math.abs(A) - 0.3, E + 0.15 * E + 0.1, { color: H.colors.good, size: 12 });\nv.dot(x, 0.5 * k * x * x, { r: 7, fill: H.colors.warn });\nv.text(\"U(x)\", -Math.abs(A) + 0.1, 1.05 * E, { color: H.colors.violet, size: 13 });\n// Right: energy bars KE + PE = E\nconst bx = w * 0.74, bw = 60, baseY = h - 70, barH = h - 180;\nconst peFrac = E > 1e-9 ? PE / E : 0, keFrac = E > 1e-9 ? KE / E : 0;\nH.rect(bx, baseY - barH * peFrac, bw, barH * peFrac, { fill: H.colors.violet });\nH.rect(bx + bw + 20, baseY - barH * keFrac, bw, barH * keFrac, { fill: H.colors.accent });\nH.line(bx - 10, baseY - barH, bx + 2 * bw + 30, baseY - barH, { color: H.colors.good, width: 1.5, dash: [4, 4] });\nH.text(\"PE\", bx + bw / 2 - 10, baseY + 18, { color: H.colors.violet, size: 13 });\nH.text(\"KE\", bx + bw + 20 + bw / 2 - 10, baseY + 18, { color: H.colors.accent, size: 13 });\nH.text(\"= E\", bx + 2 * bw + 36, baseY - barH - 4, { color: H.colors.good, size: 12 });\nH.text(\"Energy in SHM: E = KE + PE = (1/2)·k·A^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"x = \" + x.toFixed(2) + \" m PE = \" + PE.toFixed(2) + \" J KE = \" + KE.toFixed(2) + \" J E = \" + E.toFixed(2) + \" J\", 24, 52, { color: H.colors.sub, size: 13 });" + }, + { + "id": "ph-damped-oscillations", + "area": "Physics", + "topic": "Damped oscillations", + "title": "Damped oscillation: x = A e^(-gamma t) cos(omega_d t)", + "equation": "x(t) = A * e^(-gamma*t) * cos(omega_d * t), gamma = b/(2m), omega_d = sqrt(omega0^2 - gamma^2)", + "keywords": [ + "damped oscillation", + "damping", + "drag", + "exponential decay", + "envelope", + "damping coefficient", + "underdamped", + "overdamped", + "critically damped", + "amplitude decay", + "gamma", + "energy loss" + ], + "explanation": "Add a drag force -b*v and each swing loses energy, so the amplitude decays under an envelope A*e^(-gamma*t) with gamma = b/(2m). The oscillation frequency drops slightly to omega_d = sqrt(omega0^2 - gamma^2). Increase the damping b and the orange envelope closes in faster; push b high enough (gamma >= omega0) and the system stops oscillating altogether — critically or overdamped, it just creeps back to rest.", + "bullets": [ + "Damping multiplies the amplitude by a decaying e^(-gamma*t) envelope, gamma = b/(2m).", + "Underdamped (gamma < omega0): it still oscillates, but at omega_d = sqrt(omega0^2 - gamma^2).", + "Critically/overdamped (gamma >= omega0): no oscillation, just a slow return to equilibrium." + ], + "params": [ + { + "name": "k", + "label": "spring constant k (N/m)", + "min": 1, + "max": 20, + "step": 0.5, + "value": 4 + }, + { + "name": "b", + "label": "damping b (kg/s)", + "min": 0, + "max": 4, + "step": 0.05, + "value": 0.5 + }, + { + "name": "A", + "label": "amplitude A (m)", + "min": 0.5, + "max": 2.5, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst m = 1;\nconst k = Math.max(0.1, P.k), b = Math.max(0, P.b), A = P.A;\nconst omega0 = Math.sqrt(k / m);\nconst gamma = b / (2 * m);\n// underdamped frequency (guard against over/critical damping)\nconst disc = omega0 * omega0 - gamma * gamma;\nconst omegaD = disc > 1e-6 ? Math.sqrt(disc) : 0;\nconst tw = t % 16; // loop the whole decay so it replays\nfunction xOf(tau) {\n if (omegaD > 0) return A * Math.exp(-gamma * tau) * Math.cos(omegaD * tau);\n return A * Math.exp(-gamma * tau) * (1 + gamma * tau); // critical/over fallback\n}\nconst x = xOf(tw);\n// graph x(t) with the decaying envelope +/- A e^(-gamma t)\nconst v = H.plot2d({ xMin: 0, xMax: 16, yMin: -1.2 * Math.abs(A) - 0.3, yMax: 1.2 * Math.abs(A) + 0.3, box: { x: 60, y: 80, w: w - 120, h: h - 150 } });\nv.grid(); v.axes();\nv.fn(tau => A * Math.exp(-gamma * tau), { color: H.colors.warn, width: 1.5, steps: 200 });\nv.fn(tau => -A * Math.exp(-gamma * tau), { color: H.colors.warn, width: 1.5, steps: 200 });\nv.fn(xOf, { color: H.colors.accent, width: 3, steps: 300 });\nv.dot(tw, x, { r: 6, fill: H.colors.good });\n// little oscillating mass marker on the left axis\nconst mx = 40, my = v.Y(x);\nH.circle(mx, my, 9, { fill: H.colors.good, stroke: H.colors.bg, width: 2 });\nconst regime = gamma < omega0 - 1e-6 ? \"underdamped\" : (gamma > omega0 + 1e-6 ? \"overdamped\" : \"critically damped\");\nH.text(\"Damped oscillation: x = A·e^(-gamma·t)·cos(omega_d·t)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"b = \" + b.toFixed(2) + \" kg/s gamma = \" + gamma.toFixed(2) + \" 1/s omega_d = \" + omegaD.toFixed(2) + \" rad/s (\" + regime + \")\", 24, 54, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"x(t)\", color: H.colors.accent }, { label: \"envelope ±A·e^(-gamma·t)\", color: H.colors.warn }], w - 250, 84);" + }, + { + "id": "ph-conservation-of-angular-momentum", + "area": "Physics", + "topic": "Conservation of angular momentum", + "title": "Angular momentum: L = I * omega = const", + "equation": "L = I * omega = constant, I = m r^2", + "keywords": [ + "angular momentum", + "conservation of angular momentum", + "moment of inertia", + "spin", + "omega", + "ice skater", + "rotational inertia", + "i omega", + "l = i omega", + "tuck", + "rotational kinetic energy" + ], + "explanation": "Angular momentum L = I*omega stays fixed when no external torque acts, so if the moment of inertia I shrinks the spin rate omega must rise to compensate — this is why a skater spins faster when pulling their arms in. The mass slider sets I = m*r^2 (heavier or farther-out mass resists spin more), and L sets the conserved total. Watch r breathe in and out: as r drops, I drops, omega jumps up, and the rotational kinetic energy 1/2*I*omega^2 actually INCREASES (the skater's muscles do the work).", + "bullets": [ + "With no external torque, L = I*omega is conserved — pull mass in and omega rises.", + "Moment of inertia I = m*r^2: distance from the axis matters most (it's squared).", + "Tucking in keeps L fixed but RAISES kinetic energy (the body does work)." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "L", + "label": "angular momentum L (kg·m²/s)", + "min": 2, + "max": 16, + "step": 1, + "value": 8 + } + ], + "code": "H.background();\n// Conservation of angular momentum: L = I * omega = constant\n// A skater pulls arms in (smaller r -> smaller I) and spins faster.\nconst m = P.m, L = P.L;\n// radius oscillates between a wide and a tucked arm position\nconst rMax = 3.0, rMin = 0.8;\nconst r = rMin + (rMax - rMin) * (0.5 + 0.5 * Math.cos(t * 0.9));\nconst I = m * r * r; // point-mass moment of inertia\nconst omega = L / Math.max(1e-6, I); // omega from conserved L\nconst KE = 0.5 * I * omega * omega; // rotational kinetic energy\nconst cx = H.W * 0.40, cy = H.H * 0.55;\nconst scale = 42; // pixels per meter\n// central axis\nH.circle(cx, cy, 5, { fill: H.colors.sub });\n// the spinning mass on its arm\nconst ang = t * omega * 0.25; // visual spin (slowed for clarity)\nconst px = cx + r * scale * Math.cos(ang);\nconst py = cy - r * scale * Math.sin(ang);\nH.line(cx, cy, px, py, { color: H.colors.axis, width: 2 });\n// circular path\nH.circle(cx, cy, r * scale, { stroke: H.colors.grid, width: 1.2 });\nH.circle(px, py, 11, { fill: H.colors.accent });\n// tangential velocity arrow v = omega * r\nconst vmag = omega * r;\nconst tx = -Math.sin(ang), ty = -Math.cos(ang);\nH.arrow(px, py, px + tx * vmag * 7, py + ty * vmag * 7, { color: H.colors.warn, width: 2.5 });\n// labels\nH.text(\"Angular momentum: L = I·omega = const\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"pull the mass in -> I drops -> omega rises (L fixed)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + r.toFixed(2) + \" m I = \" + I.toFixed(2) + \" kg·m^2\", 24, H.H - 56, { color: H.colors.sub, size: 13 });\nH.text(\"omega = \" + omega.toFixed(2) + \" rad/s L = \" + L.toFixed(2) + \" kg·m^2/s\", 24, H.H - 36, { color: H.colors.good, size: 13 });\nH.text(\"KE_rot = ½·I·omega^2 = \" + KE.toFixed(2) + \" J\", 24, H.H - 16, { color: H.colors.accent2, size: 13 });\nH.legend([{ label: \"mass\", color: H.colors.accent }, { label: \"v = omega·r\", color: H.colors.warn }], H.W - 170, 28);" + }, + { + "id": "ph-center-of-mass", + "area": "Physics", + "topic": "Center of mass", + "title": "Center of mass: x_cm = (m1 x1 + m2 x2)/(m1+m2)", + "equation": "x_cm = (m1*x1 + m2*x2) / (m1 + m2)", + "keywords": [ + "center of mass", + "centre of mass", + "balance point", + "centroid", + "weighted average", + "barycenter", + "two masses", + "fulcrum", + "x_cm", + "mass distribution", + "lever" + ], + "explanation": "The center of mass is the mass-weighted average position of a system — the single point where it would balance on a fulcrum. The formula divides the total moment (m*x summed) by the total mass, so the heavier body always pulls x_cm toward itself. Drag m1 and m2: make them equal and x_cm sits exactly halfway; make one much heavier and the balance point slides almost on top of it. The rod's masses slide back and forth so you can see x_cm track them in real time.", + "bullets": [ + "x_cm is the mass-weighted average of position, not the geometric midpoint.", + "The heavier mass pulls the balance point toward itself; equal masses balance in the middle.", + "x_cm = (m1*x1 + m2*x2)/(m1+m2): total moment divided by total mass." + ], + "params": [ + { + "name": "m1", + "label": "mass m1 (kg)", + "min": 1, + "max": 8, + "step": 0.5, + "value": 2 + }, + { + "name": "m2", + "label": "mass m2 (kg)", + "min": 1, + "max": 8, + "step": 0.5, + "value": 5 + } + ], + "code": "H.background();\n// Center of mass of two bodies: x_cm = (m1*x1 + m2*x2) / (m1 + m2)\n// The heavier mass pulls the balance point toward itself.\nconst m1 = P.m1, m2 = P.m2;\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -3, yMax: 3 });\nv.grid(); v.axes();\n// the two masses slide along a massless rod, oscillating in view\nconst x1 = -3 + 1.2 * Math.sin(t * 0.8);\nconst x2 = 3 + 1.2 * Math.sin(t * 0.8 + 1.7);\nconst y = 0;\nconst xcm = (m1 * x1 + m2 * x2) / Math.max(1e-6, m1 + m2);\n// the connecting rod\nv.line(x1, y, x2, y, { color: H.colors.axis, width: 3 });\n// masses: radius scales with mass (visual cue)\nconst r1 = 8 + 4 * Math.sqrt(m1);\nconst r2 = 8 + 4 * Math.sqrt(m2);\nv.circle(x1, y, r1, { fill: H.colors.accent });\nv.circle(x2, y, r2, { fill: H.colors.accent2 });\nv.text(\"m1\", x1, y - 1.0, { color: H.colors.accent, size: 12, align: \"center\" });\nv.text(\"m2\", x2, y - 1.0, { color: H.colors.accent2, size: 12, align: \"center\" });\n// the center of mass (balance point) and a fulcrum triangle below it\nv.circle(xcm, y, 6 + Math.sin(t * 3), { fill: H.colors.warn });\nv.path([[xcm - 0.5, -1.3], [xcm + 0.5, -1.3], [xcm, -0.2]], { color: H.colors.good, width: 2, fill: H.colors.good, close: true });\nv.text(\"x_cm\", xcm, 1.0, { color: H.colors.warn, size: 13, align: \"center\" });\nH.text(\"Center of mass: x_cm = (m1·x1 + m2·x2)/(m1 + m2)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"the balance point sits closer to the heavier mass\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"m1 = \" + m1.toFixed(1) + \" kg @ x1 = \" + x1.toFixed(2) + \" m\", 24, H.H - 56, { color: H.colors.accent, size: 13 });\nH.text(\"m2 = \" + m2.toFixed(1) + \" kg @ x2 = \" + x2.toFixed(2) + \" m\", 24, H.H - 36, { color: H.colors.accent2, size: 13 });\nH.text(\"x_cm = \" + xcm.toFixed(2) + \" m\", 24, H.H - 16, { color: H.colors.warn, size: 13 });" + }, + { + "id": "ph-universal-gravitation", + "area": "Physics", + "topic": "Newton's law of universal gravitation", + "title": "Universal gravitation: F = G m1 m2 / r^2", + "equation": "F = G * m1 * m2 / r^2, G = 6.674e-11", + "keywords": [ + "universal gravitation", + "newton gravity", + "gravitational force", + "inverse square law", + "f = g m1 m2 / r^2", + "big g", + "gravitational constant", + "attraction", + "two masses", + "gravity force", + "newtons" + ], + "explanation": "Every pair of masses attracts with a force proportional to both masses and inversely proportional to the SQUARE of their separation. The two mass sliders scale the pull linearly — double either mass and F doubles — while r controls it far more dramatically: because of the r^2 in the denominator, halving the distance QUADRUPLES the force. Watch the separation breathe and the equal-and-opposite attraction arrows (Newton's third law) grow as the bodies approach.", + "bullets": [ + "Force is proportional to m1 AND m2 (double a mass, double the pull).", + "Inverse-square in r: halve the distance and the force grows 4x.", + "The two bodies feel equal and opposite forces (Newton's third law)." + ], + "params": [ + { + "name": "m1", + "label": "mass m1 (×10²⁴ kg)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 6 + }, + { + "name": "m2", + "label": "mass m2 (×10²⁴ kg)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 6 + } + ], + "code": "H.background();\n// Newton's law of universal gravitation: F = G * m1 * m2 / r^2\n// Two bodies attract; halving r quadruples the force (inverse-square).\nconst G = 6.674e-11;\nconst m1 = P.m1 * 1e24; // slider in units of 10^24 kg (Earth-ish)\nconst m2 = P.m2 * 1e24;\nconst cx = H.W * 0.5, cy = H.H * 0.52;\n// separation oscillates so you watch F respond to r (inverse square)\nconst rMin = 1.2, rMax = 4.5;\nconst r = rMin + (rMax - rMin) * (0.5 + 0.5 * Math.sin(t * 0.7)); // in units of 10^7 m\nconst rMeters = r * 1e7;\nconst F = G * m1 * m2 / (rMeters * rMeters); // newtons\nconst scale = 48; // px per 10^7 m\nconst ax = cx - r * scale * 0.5, bx = cx + r * scale * 0.5;\n// the two bodies (radius scales mildly with mass)\nconst ra = 12 + 5 * Math.cbrt(P.m1), rb = 12 + 5 * Math.cbrt(P.m2);\nH.circle(ax, cy, ra, { fill: H.colors.accent });\nH.circle(bx, cy, rb, { fill: H.colors.accent2 });\n// equal-and-opposite attraction arrows (Newton's third law), length ~ log(F)\nconst fArrow = H.clamp(8 * Math.log10(F + 1), 12, 120);\nH.arrow(ax + ra, cy, ax + ra + fArrow, cy, { color: H.colors.warn, width: 3 });\nH.arrow(bx - rb, cy, bx - rb - fArrow, cy, { color: H.colors.warn, width: 3 });\n// separation marker\nH.line(ax, cy + 40, bx, cy + 40, { color: H.colors.grid, width: 1.5, dash: [4, 4] });\nH.text(\"r\", (ax + bx) / 2 - 4, cy + 58, { color: H.colors.sub, size: 13 });\nH.text(\"Universal gravitation: F = G·m1·m2 / r^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"inverse-square: halve r and the pull quadruples\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"m1 = \" + P.m1.toFixed(1) + \"e24 kg m2 = \" + P.m2.toFixed(1) + \"e24 kg\", 24, H.H - 56, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + rMeters.toExponential(2) + \" m\", 24, H.H - 36, { color: H.colors.accent, size: 13 });\nH.text(\"F = \" + F.toExponential(2) + \" N\", 24, H.H - 16, { color: H.colors.warn, size: 14, weight: 700 });\nH.legend([{ label: \"attraction F\", color: H.colors.warn }], H.W - 160, 28);" + }, + { + "id": "ph-orbits-keplers-laws", + "area": "Physics", + "topic": "Orbits and Kepler's laws", + "title": "Kepler's laws: T^2 = a^3, r = a(1-e^2)/(1+e cosθ)", + "equation": "T^2 = a^3, r = a(1 - e^2)/(1 + e*cos(theta))", + "keywords": [ + "orbits", + "kepler's laws", + "elliptical orbit", + "eccentricity", + "semi-major axis", + "perihelion", + "aphelion", + "equal areas", + "orbital period", + "t^2 = a^3", + "focus", + "planetary motion" + ], + "explanation": "Kepler distilled planetary motion into three rules shown here at once. First law: orbits are ellipses with the Sun at one FOCUS (not the center), so semi-major axis a and eccentricity e set the shape. Second law: the line from Sun to planet sweeps EQUAL AREAS in equal times, which forces the planet to race at perihelion (close in) and crawl at aphelion — watch the shaded slice keep a steady area as the planet speeds up and slows down. Third law: the period follows T^2 = a^3 (years and AU), so bigger orbits take dramatically longer.", + "bullets": [ + "1st law: orbit is an ellipse with the Sun at a focus (offset c = a*e).", + "2nd law: equal areas in equal times — fastest near the Sun (perihelion).", + "3rd law: T^2 = a^3 — the period grows faster than the orbit size." + ], + "params": [ + { + "name": "a", + "label": "semi-major axis a (AU)", + "min": 0.6, + "max": 2.5, + "step": 0.1, + "value": 1.5 + }, + { + "name": "e", + "label": "eccentricity e", + "min": 0, + "max": 0.8, + "step": 0.05, + "value": 0.4 + } + ], + "code": "H.background();\n// Kepler's laws: orbit is an ellipse with the Sun at a focus.\n// 2nd law: the radius vector sweeps EQUAL AREAS in equal times, so the planet\n// must speed up at perihelion and slow at aphelion. We get that for free by\n// advancing the MEAN anomaly uniformly and solving Kepler's equation for the\n// true anomaly -> non-uniform angular speed (NOT constant dtheta/dt).\nconst a = P.a; // semi-major axis (AU)\nconst e = H.clamp(P.e, 0, 0.85); // eccentricity 0..0.85\nconst b = a * Math.sqrt(1 - e * e);\nconst c = a * e; // focus offset (Sun sits c from the ellipse center)\nconst cx = H.W * 0.5, cy = H.H * 0.52;\nconst scale = Math.min(H.W, H.H) * 0.30 / Math.max(1, a);\n// draw the ellipse (centered at cx-c so the Sun lands at the focus = origin)\nconst pts = [];\nfor (let i = 0; i <= 120; i++) {\n const th = i / 120 * H.TAU;\n pts.push([cx + (a * Math.cos(th) - c) * scale, cy - b * Math.sin(th) * scale]);\n}\nH.path(pts, { color: H.colors.grid, width: 1.6, close: true });\n// the Sun at the focus (origin of polar r)\nconst sx = cx, sy = cy;\nH.circle(sx, sy, 10, { fill: H.colors.yellow });\nH.text(\"Sun\", sx + 12, sy + 4, { color: H.colors.yellow, size: 12 });\n// Kepler's 3rd law: period (years) with a in AU. T^2 = a^3.\nconst period = Math.sqrt(a * a * a);\n// MEAN anomaly advances uniformly in time (this is the \"equal areas\" clock).\nconst M = (t * 0.6) % H.TAU;\n// solve Kepler's equation M = E - e*sin(E) for eccentric anomaly E (Newton).\nlet E = M;\nfor (let k = 0; k < 8; k++) {\n E = E - (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));\n}\n// true anomaly theta from eccentric anomaly E\nconst theta = 2 * Math.atan2(Math.sqrt(1 + e) * Math.sin(E / 2),\n Math.sqrt(1 - e) * Math.cos(E / 2));\n// radius from the focus (Sun): r = a(1 - e cosE) = a(1-e^2)/(1+e cos theta)\nconst rp = a * (1 - e * Math.cos(E));\nconst px = sx + rp * Math.cos(theta) * scale;\nconst py = sy - rp * Math.sin(theta) * scale;\n// the swept radius vector + a thin \"equal-area\" pie slice trailing it.\n// Because dtheta/dt is large near perihelion, this slice subtends a big angle\n// where r is small and a small angle where r is large -> equal area each frame.\nconst Mprev = M - 0.18;\nlet Ep = Mprev;\nfor (let k = 0; k < 8; k++) {\n Ep = Ep - (Ep - e * Math.sin(Ep) - Mprev) / (1 - e * Math.cos(Ep));\n}\nconst thp = 2 * Math.atan2(Math.sqrt(1 + e) * Math.sin(Ep / 2),\n Math.sqrt(1 - e) * Math.cos(Ep / 2));\nconst rpp = a * (1 - e * Math.cos(Ep));\nconst qx = sx + rpp * Math.cos(thp) * scale;\nconst qy = sy - rpp * Math.sin(thp) * scale;\nH.path([[sx, sy], [px, py], [qx, qy]], { color: \"none\", fill: H.hsl(210, 80, 65, 0.30), close: true });\nH.line(sx, sy, px, py, { color: H.colors.accent, width: 2 });\nH.circle(px, py, 8, { fill: H.colors.accent2 });\nH.text(\"Orbits & Kepler: T^2 = a^3, r = a(1-e^2)/(1+e cosθ)\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"equal areas in equal times - fastest at perihelion (near Sun)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = \" + a.toFixed(2) + \" AU e = \" + e.toFixed(2), 24, H.H - 56, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + rp.toFixed(2) + \" AU\", 24, H.H - 36, { color: H.colors.accent, size: 13 });\nH.text(\"period T = \" + period.toFixed(2) + \" yr\", 24, H.H - 16, { color: H.colors.good, size: 13 });" + }, + { + "id": "ph-gravitational-field", + "area": "Physics", + "topic": "Gravitational field", + "title": "Gravitational field: g = G M / r^2", + "equation": "g = G * M / r^2, G = 6.674e-11", + "keywords": [ + "gravitational field", + "field strength", + "g", + "g = g m / r^2", + "field lines", + "test mass", + "acceleration due to gravity", + "radial field", + "inverse square", + "field vector", + "source mass" + ], + "explanation": "A mass warps the space around it into a gravitational field g — the acceleration a test object would feel at each point, pointing straight toward the source. The arrows show this field: they all aim inward, and their length shrinks with the square of distance, so the inner ring's field is far stronger than the outer ring's. Slide M to scale the whole field up or down (more mass, stronger pull everywhere), and watch the orbiting test mass report g = G*M/r^2 in m/s^2 at its current radius.", + "bullets": [ + "g is the force per kilogram a test mass feels — it points toward the source.", + "Inverse-square: g falls off as 1/r^2, so the field weakens fast with distance.", + "g = G*M/r^2 depends only on the SOURCE mass M, not the test mass." + ], + "params": [ + { + "name": "M", + "label": "source mass M (×10²⁴ kg)", + "min": 1, + "max": 12, + "step": 0.5, + "value": 6 + } + ], + "code": "H.background();\n// Gravitational field: g = G * M / r^2 (points toward the mass)\n// Field strength weakens with the square of distance from the source.\nconst G = 6.674e-11;\nconst M = P.M * 1e24; // source mass in units of 10^24 kg\nconst cx = H.W * 0.5, cy = H.H * 0.52;\nconst scale = 36; // px per 10^6 m\n// the central mass (field source)\nH.circle(cx, cy, 14, { fill: H.colors.accent2 });\nH.text(\"M\", cx - 5, cy + 5, { color: H.colors.bg, size: 13, weight: 700 });\n// a ring of fixed field arrows pointing inward, length ~ g(r) (inverse square)\nconst rings = [2.2, 3.6, 5.2];\nfor (let k = 0; k < rings.length; k++) {\n const rPix = rings[k] * scale;\n const rMeters = rings[k] * 1e6;\n const g = G * M / (rMeters * rMeters); // m/s^2\n const len = H.clamp(g * 1.1e6, 8, 46); // visual length ~ g\n for (let i = 0; i < 12; i++) {\n const ang = i / 12 * H.TAU;\n const ox = cx + rPix * Math.cos(ang), oy = cy + rPix * Math.sin(ang);\n const ix = cx + (rPix - len) * Math.cos(ang), iy = cy + (rPix - len) * Math.sin(ang);\n H.arrow(ox, oy, ix, iy, { color: H.colors.accent, width: 1.6, head: 6 });\n }\n}\n// a test mass sweeping around, with its own field-strength readout\nconst rT = 4.2, angT = t * 0.6;\nconst tx = cx + rT * scale * Math.cos(angT), ty = cy + rT * scale * Math.sin(angT);\nconst rTm = rT * 1e6;\nconst gT = G * M / (rTm * rTm);\nH.circle(tx, ty, 6, { fill: H.colors.warn });\nH.arrow(tx, ty, tx + (cx - tx) * 0.30, ty + (cy - ty) * 0.30, { color: H.colors.warn, width: 2.5 });\nH.text(\"Gravitational field: g = G·M / r^2\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"field points inward; strength falls off as 1/r^2\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"M = \" + P.M.toFixed(1) + \"e24 kg\", 24, H.H - 56, { color: H.colors.accent2, size: 13 });\nH.text(\"test mass at r = \" + rTm.toExponential(2) + \" m\", 24, H.H - 36, { color: H.colors.warn, size: 13 });\nH.text(\"g = \" + gT.toFixed(3) + \" m/s^2\", 24, H.H - 16, { color: H.colors.good, size: 14, weight: 700 });\nH.legend([{ label: \"field g\", color: H.colors.accent }, { label: \"test mass\", color: H.colors.warn }], H.W - 170, 28);" + }, + { + "id": "ph-density-and-pressure", + "area": "Physics", + "topic": "Density and pressure", + "title": "Density & pressure: P = F / A", + "equation": "P = F / A, F = m g", + "keywords": [ + "density", + "pressure", + "force per area", + "p = f/a", + "pascal", + "mass", + "area", + "weight", + "rho", + "newtons per square meter", + "contact area" + ], + "explanation": "Pressure is how hard a force pushes spread over the area it acts on: P = F/A. The block's weight F = m·g (with g = 9.8 m/s²) presses down on its contact area A, and the surface pushes back with that same pressure spread over A. Increase the mass and the green pressure arrows grow; spread the same weight over a larger area A and the pressure drops — that's why snowshoes keep you on top of snow. Density rho (mass per volume) just sets how heavy a given block is.", + "bullets": [ + "Pressure P = F/A: the SAME force over a bigger area gives LESS pressure.", + "Weight F = m·g points down; the surface reaction is spread over contact area A.", + "Density rho = mass/volume tells you how heavy a given chunk of material is." + ], + "params": [ + { + "name": "mass", + "label": "mass m (kg)", + "min": 1, + "max": 200, + "step": 1, + "value": 20 + }, + { + "name": "area", + "label": "contact area A (m^2)", + "min": 0.05, + "max": 2, + "step": 0.05, + "value": 0.25 + }, + { + "name": "rho", + "label": "density rho (kg/m^3)", + "min": 100, + "max": 11000, + "step": 100, + "value": 2700 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst rho = Math.max(100, P.rho), A = Math.max(0.01, P.area), m = Math.max(0.1, P.mass);\nconst g = 9.8;\nconst F = m * g;\nconst Pr = F / A;\nconst cx = w * 0.42, baseY = h * 0.78;\nconst bw = H.clamp(40 + 120 * Math.sqrt(A), 50, 240);\nconst bh = H.clamp(60 + 0.04 * m, 50, 150);\nH.line(40, baseY, w - 40, baseY, { color: H.colors.axis, width: 2 });\nconst settle = 4 * Math.abs(Math.sin(t * 1.4)) * Math.exp(-((t % 4)) * 0.5);\nconst by = baseY - bh - settle;\nH.rect(cx - bw / 2, by, bw, bh, { fill: H.colors.panel, stroke: H.colors.accent, width: 2, radius: 4 });\nH.text(\"m = \" + m.toFixed(1) + \" kg\", cx - bw / 2 + 6, by + bh / 2, { color: H.colors.ink, size: 13 });\nH.arrow(cx, by + bh, cx, by + bh + 46, { color: H.colors.warn, width: 3 });\nH.text(\"F = mg\", cx + 8, by + bh + 30, { color: H.colors.warn, size: 13 });\n// pressure reaction arrows: length scales with the actual pressure P = F/A,\n// so MORE mass -> taller arrows, and the SAME weight over a BIGGER area A\n// (which also widens the block) -> shorter arrows. log keeps the range sane.\nconst pLen = H.clamp(6 * Math.log10(Pr + 1), 6, 60);\nconst np = 5;\nfor (let i = 0; i < np; i++) {\n const px = cx - bw / 2 + bw * (i + 0.5) / np;\n const amp = pLen + 4 * Math.sin(t * 3 - i * 0.6);\n H.arrow(px, baseY, px, baseY - amp, { color: H.colors.good, width: 2, head: 7 });\n}\nH.text(\"pressure on area A\", cx - bw / 2, baseY + 18, { color: H.colors.good, size: 12 });\nH.text(\"Density & Pressure\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"P = F / A, F = m g\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"F = \" + F.toFixed(1) + \" N A = \" + A.toFixed(2) + \" m2\", 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"P = \" + Pr.toFixed(0) + \" Pa\", 24, 96, { color: H.colors.accent, size: 15, weight: 700 });\nH.text(\"density rho = \" + rho.toFixed(0) + \" kg/m3\", 24, 118, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"weight F=mg\", color: H.colors.warn }, { label: \"pressure F/A\", color: H.colors.good }], w - 175, 28);" + }, + { + "id": "ph-pressure-with-depth", + "area": "Physics", + "topic": "Pressure with depth", + "title": "Pressure with depth: P = Patm + rho g d", + "equation": "P = Patm + rho * g * d", + "keywords": [ + "pressure with depth", + "hydrostatic pressure", + "rho g h", + "fluid pressure", + "gauge pressure", + "atmospheric pressure", + "depth", + "water pressure", + "p = patm + rho g d", + "liquid column" + ], + "explanation": "In a still fluid the pressure grows with depth because every layer carries the weight of all the fluid stacked above it: P = Patm + rho·g·d. Move the depth dot down and watch P climb linearly — each extra meter of water (rho = 1000 kg/m³) adds rho·g ≈ 9800 Pa. The four green arrows show that fluid pressure pushes EQUALLY in all directions at a given depth, not just downward. Denser fluids build pressure faster with depth.", + "bullets": [ + "Pressure rises linearly with depth: P = Patm + rho·g·d.", + "At any point the pressure pushes equally in every direction.", + "Only depth matters — the shape or width of the container does not." + ], + "params": [ + { + "name": "rho", + "label": "fluid density rho (kg/m^3)", + "min": 500, + "max": 13600, + "step": 100, + "value": 1000 + }, + { + "name": "depth", + "label": "max depth (m)", + "min": 1, + "max": 20, + "step": 0.5, + "value": 10 + }, + { + "name": "patm", + "label": "surface pressure Patm (Pa)", + "min": 0, + "max": 120000, + "step": 5000, + "value": 101325 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst rho = Math.max(100, P.rho), Hd = Math.max(0.5, P.depth), P0 = Math.max(0, P.patm);\nconst g = 9.8;\nconst v = H.plot2d({ xMin: 0, xMax: 260, yMin: 0, yMax: Hd, pad: 56 });\nconst surfY = v.Y(0), botY = v.Y(Hd), leftX = v.X(0), rightX = v.X(260);\nH.rect(leftX, surfY, rightX - leftX, botY - surfY, { fill: \"#13314f\" });\nH.line(leftX, surfY, rightX, surfY, { color: H.colors.accent, width: 2 });\nH.text(\"surface (P = Patm)\", leftX + 8, surfY - 6, { color: H.colors.sub, size: 12 });\nconst depth = Hd * (0.5 + 0.45 * Math.sin(t * 0.8));\nconst dY = v.Y(depth);\nconst Pdepth = P0 + rho * g * depth;\nH.line(leftX, dY, rightX, dY, { color: H.colors.warn, width: 1.5, dash: [5, 5] });\nH.circle((leftX + rightX) / 2, dY, 7, { fill: H.colors.warn });\nH.arrow(v.X(70), dY, v.X(70) - 26, dY, { color: H.colors.good, width: 2 });\nH.arrow(v.X(190), dY, v.X(190) + 26, dY, { color: H.colors.good, width: 2 });\nH.arrow((leftX + rightX) / 2, dY, (leftX + rightX) / 2, dY + 26, { color: H.colors.good, width: 2 });\nH.arrow((leftX + rightX) / 2, dY, (leftX + rightX) / 2, dY - 26, { color: H.colors.good, width: 2 });\nH.line(rightX + 10, surfY, rightX + 10, dY, { color: H.colors.violet, width: 2 });\nH.text(\"d = \" + depth.toFixed(2) + \" m\", rightX - 86, dY - 8, { color: H.colors.ink, size: 13 });\nH.text(\"Pressure vs depth\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"P = Patm + rho g d\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"rho g d = \" + (rho * g * depth).toFixed(0) + \" Pa\", 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"P = \" + Pdepth.toFixed(0) + \" Pa\", 24, 96, { color: H.colors.accent, size: 15, weight: 700 });\nH.legend([{ label: \"pressure (all directions)\", color: H.colors.good }, { label: \"depth d\", color: H.colors.violet }], w - 240, 28);" + }, + { + "id": "ph-pascals-principle", + "area": "Physics", + "topic": "Pascal's principle", + "title": "Pascal's principle: F1/A1 = F2/A2", + "equation": "F1 / A1 = F2 / A2 (P transmitted equally)", + "keywords": [ + "pascal's principle", + "hydraulic", + "hydraulic lift", + "hydraulic press", + "f1/a1 = f2/a2", + "mechanical advantage", + "fluid pressure", + "force multiplier", + "pistons", + "brakes", + "incompressible" + ], + "explanation": "Pascal's principle says a pressure applied to a confined fluid is transmitted UNCHANGED to every part of it. So the small input piston and the large output piston feel the SAME pressure P = F1/A1, which means the big piston pushes out a much larger force F2 = P·A2. Make A2 bigger than A1 and you multiply force by the area ratio — that's how a hydraulic jack lifts a car. But the big piston moves a shorter distance, so energy is conserved (no free work).", + "bullets": [ + "The same pressure P acts on both pistons: F1/A1 = F2/A2.", + "Force is multiplied by the area ratio A2/A1.", + "The big piston moves less, so work in = work out (energy is conserved)." + ], + "params": [ + { + "name": "f1", + "label": "input force F1 (N)", + "min": 10, + "max": 500, + "step": 10, + "value": 50 + }, + { + "name": "a1", + "label": "small piston area A1 (m^2)", + "min": 0.005, + "max": 0.05, + "step": 0.005, + "value": 0.01 + }, + { + "name": "a2", + "label": "large piston area A2 (m^2)", + "min": 0.02, + "max": 0.3, + "step": 0.01, + "value": 0.1 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst A1 = Math.max(0.001, P.a1), A2 = Math.max(0.001, P.a2), F1 = Math.max(1, P.f1);\nconst Pr = F1 / A1;\nconst F2 = Pr * A2;\nconst cy = h * 0.62;\nconst x1 = w * 0.26, x2 = w * 0.7;\nconst r1 = H.clamp(18 + 220 * Math.sqrt(A1), 14, 60);\nconst r2 = H.clamp(18 + 220 * Math.sqrt(A2), 14, 120);\nconst fluidTop = cy;\nconst baseY = h * 0.86;\nH.rect(x1 - r1, fluidTop, r1 * 2, baseY - fluidTop, { fill: \"#13314f\", stroke: H.colors.axis, width: 1.5 });\nH.rect(x2 - r2, fluidTop, r2 * 2, baseY - fluidTop, { fill: \"#13314f\", stroke: H.colors.axis, width: 1.5 });\nH.rect(x1 - r1, baseY, (x2 + r2) - (x1 - r1), 18, { fill: \"#13314f\", stroke: H.colors.axis, width: 1.5 });\nconst osc = Math.sin(t * 1.2);\nconst d1 = 22 * (0.5 + 0.5 * osc);\nconst d2 = d1 * (A1 / A2);\nconst p1y = fluidTop + 6 + d1;\nconst p2y = fluidTop + 6 - d2;\nH.rect(x1 - r1, p1y, r1 * 2, 14, { fill: H.colors.accent, radius: 2 });\nH.rect(x2 - r2, p2y, r2 * 2, 14, { fill: H.colors.accent2, radius: 2 });\nH.arrow(x1, p1y - 40, x1, p1y - 2, { color: H.colors.warn, width: 3 });\nH.text(\"F1 = \" + F1.toFixed(0) + \" N\", x1 - 30, p1y - 46, { color: H.colors.warn, size: 12 });\nH.arrow(x2, p2y + 40, x2, p2y + 2, { color: H.colors.good, width: 3 });\nH.text(\"F2 = \" + F2.toFixed(0) + \" N\", x2 - 30, p2y - 6, { color: H.colors.good, size: 12 });\nH.text(\"Pascal's principle\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"F1/A1 = F2/A2 (same P)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"A1 = \" + A1.toFixed(3) + \" A2 = \" + A2.toFixed(3) + \" m2\", 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"P = \" + Pr.toFixed(0) + \" Pa\", 24, 96, { color: H.colors.violet, size: 13 });\nH.text(\"F2 = \" + F2.toFixed(0) + \" N (gain x\" + (A2 / A1).toFixed(1) + \")\", 24, 118, { color: H.colors.accent, size: 15, weight: 700 });\nH.legend([{ label: \"input F1\", color: H.colors.warn }, { label: \"output F2\", color: H.colors.good }], w - 160, 28);" + }, + { + "id": "ph-buoyancy-archimedes", + "area": "Physics", + "topic": "Buoyancy and Archimedes' principle", + "title": "Archimedes: Fb = rho_fluid g V_submerged", + "equation": "Fb = rho_fluid * g * V_submerged", + "keywords": [ + "buoyancy", + "archimedes principle", + "buoyant force", + "float or sink", + "displaced fluid", + "fb = rho g v", + "upthrust", + "density comparison", + "submerged volume", + "apparent weight" + ], + "explanation": "Archimedes' principle: the upward buoyant force equals the weight of the fluid the object pushes out of the way, Fb = rho_fluid·g·V_submerged. A floating object sinks just deep enough that the displaced fluid weighs exactly as much as the object — so its submerged fraction equals rho_object/rho_fluid. Make the object denser than the fluid (rho_object > rho_fluid) and it can't displace enough fluid to balance its weight, so it sinks. Compare the red weight arrow with the green buoyancy arrow.", + "bullets": [ + "Buoyant force = weight of displaced fluid: Fb = rho_fluid·g·V_sub.", + "A floating object's submerged fraction = rho_object / rho_fluid.", + "rho_object < rho_fluid floats; greater sinks; equal hovers (neutral)." + ], + "params": [ + { + "name": "rhof", + "label": "fluid density rho_f (kg/m^3)", + "min": 500, + "max": 2000, + "step": 50, + "value": 1000 + }, + { + "name": "rhoo", + "label": "object density rho_o (kg/m^3)", + "min": 100, + "max": 2000, + "step": 50, + "value": 600 + }, + { + "name": "vol", + "label": "object volume V (m^3)", + "min": 0.005, + "max": 0.1, + "step": 0.005, + "value": 0.02 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst rhoF = Math.max(100, P.rhof), rhoO = Math.max(50, P.rhoo), V = Math.max(0.001, P.vol);\nconst g = 9.8;\nconst Wt = rhoO * V * g;\nconst frac = H.clamp(rhoO / rhoF, 0, 1.4);\nconst subFrac = Math.min(1, frac);\nconst Fb = rhoF * subFrac * V * g;\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: 0, yMax: 6, pad: 52 });\nconst waterY = v.Y(3.2);\nconst botY = v.Y(0), leftX = v.X(0), rightX = v.X(10);\nH.rect(leftX, waterY, rightX - leftX, botY - waterY, { fill: \"#13314f\" });\nconst ripple = 0.06 * Math.sin(t * 1.5);\nH.line(leftX, waterY + ripple * 30, rightX, waterY + ripple * 30, { color: H.colors.accent, width: 2 });\nconst side = H.clamp(40 + 90 * Math.cbrt(V), 40, 130);\nconst cx = (leftX + rightX) / 2;\nconst bob = 4 * Math.sin(t * 1.5);\nconst topY = waterY - (1 - subFrac) * side + bob;\nH.rect(cx - side / 2, topY, side, side, { fill: H.colors.accent2, stroke: H.colors.ink, width: 1.5, radius: 3 });\nH.text(\"rho_obj=\" + rhoO.toFixed(0), cx - side / 2 + 4, topY + side / 2, { color: H.colors.bg, size: 11, weight: 700 });\nH.arrow(cx - 20, topY + side, cx - 20, topY + side + 40, { color: H.colors.warn, width: 3 });\nH.text(\"W\", cx - 16, topY + side + 26, { color: H.colors.warn, size: 13 });\nH.arrow(cx + 20, topY + side, cx + 20, topY + side - 40, { color: H.colors.good, width: 3 });\nH.text(\"Fb\", cx + 26, topY + side - 26, { color: H.colors.good, size: 13 });\nconst verdict = rhoO < rhoF ? \"floats\" : rhoO > rhoF ? \"sinks\" : \"neutral\";\nH.text(\"Buoyancy / Archimedes\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Fb = rho_fluid g V_sub\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"W = \" + Wt.toFixed(1) + \" N Fb = \" + Fb.toFixed(1) + \" N\", 24, 74, { color: H.colors.sub, size: 13 });\nH.text(\"submerged \" + (subFrac * 100).toFixed(0) + \"% -> \" + verdict, 24, 96, { color: H.colors.accent, size: 15, weight: 700 });\nH.legend([{ label: \"weight W\", color: H.colors.warn }, { label: \"buoyancy Fb\", color: H.colors.good }], w - 175, 28);" + }, + { + "id": "ph-continuity-equation", + "area": "Physics", + "topic": "Continuity equation", + "title": "Continuity: A1 v1 = A2 v2", + "equation": "A1 * v1 = A2 * v2 = Q (constant)", + "keywords": [ + "continuity equation", + "a1v1 = a2v2", + "flow rate", + "volume flow rate", + "conservation of mass", + "incompressible flow", + "pipe narrows", + "fluid speeds up", + "cross sectional area", + "streamlines", + "q = a v" + ], + "explanation": "For an incompressible fluid, the same volume per second must pass every cross-section of the pipe: A1·v1 = A2·v2 = Q. So when the pipe narrows (smaller A2) the fluid HAS to speed up to keep Q constant — watch the dots accelerate through the throat. Widen A1 or push faster v1 and the flow rate Q rises everywhere together. This is why a thumb over a hose makes the water shoot out faster.", + "bullets": [ + "Volume flow rate Q = A·v is the same at every cross-section.", + "Narrow the pipe (smaller A) and the speed v rises to compensate.", + "It is mass conservation for an incompressible fluid — nothing piles up." + ], + "params": [ + { + "name": "a1", + "label": "wide area A1 (m^2)", + "min": 1, + "max": 6, + "step": 0.5, + "value": 3 + }, + { + "name": "a2", + "label": "narrow area A2 (m^2)", + "min": 0.5, + "max": 4, + "step": 0.5, + "value": 1 + }, + { + "name": "v1", + "label": "inlet speed v1 (m/s)", + "min": 0.5, + "max": 4, + "step": 0.5, + "value": 1 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst A1 = Math.max(0.5, P.a1), A2 = Math.max(0.2, P.a2), v1 = Math.max(0.1, P.v1);\nconst v2 = v1 * A1 / A2;\nconst Q = A1 * v1;\nconst cy = h * 0.52;\nconst xL = w * 0.1, xM1 = w * 0.42, xM2 = w * 0.58, xR = w * 0.9;\nconst s = 14;\nconst r1 = A1 * s, r2 = A2 * s;\nconst pipe = [\n [xL, cy - r1], [xM1, cy - r1], [xM2, cy - r2], [xR, cy - r2],\n [xR, cy + r2], [xM2, cy + r2], [xM1, cy + r1], [xL, cy + r1]\n];\nH.path(pipe, { color: H.colors.axis, width: 2.5, fill: \"#13314f\", close: true });\nconst nLanes = 5;\nfor (let j = 0; j < nLanes; j++) {\n const yfrac = (j + 0.5) / nLanes - 0.5;\n const nDots = 6;\n for (let k = 0; k < nDots; k++) {\n const phase = ((t * v1 * 0.25 + k / nDots) % 1);\n let x, vloc, rloc;\n if (phase < 0.45) {\n const f = phase / 0.45;\n x = xL + (xM1 - xL) * f;\n rloc = r1; vloc = v1;\n } else if (phase < 0.55) {\n const f = (phase - 0.45) / 0.1;\n x = xM1 + (xM2 - xM1) * f;\n rloc = r1 + (r2 - r1) * f; vloc = v1 + (v2 - v1) * f;\n } else {\n const f = (phase - 0.55) / 0.45;\n x = xM2 + (xR - xM2) * f;\n rloc = r2; vloc = v2;\n }\n const y = cy + yfrac * 2 * rloc;\n H.circle(x, y, 3, { fill: H.colors.accent });\n }\n}\nH.arrow(xL + 30, cy - r1 - 14, xL + 30 + 18 * (v1 / 3), cy - r1 - 14, { color: H.colors.good, width: 2 });\nH.arrow(xR - 30 - 18 * (v2 / 3), cy + r2 + 14, xR - 30, cy + r2 + 14, { color: H.colors.warn, width: 2 });\nH.text(\"A1, v1\", xL + 6, cy - r1 - 8, { color: H.colors.sub, size: 12 });\nH.text(\"A2, v2\", xR - 56, cy + r2 + 26, { color: H.colors.sub, size: 12 });\nH.text(\"Continuity equation\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"A1 v1 = A2 v2 = Q\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"v2 = v1 A1/A2 = \" + v2.toFixed(2) + \" m/s\", 24, 74, { color: H.colors.accent, size: 15, weight: 700 });\nH.text(\"Q = \" + Q.toFixed(2) + \" m3/s (constant)\", 24, 96, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"v1 (wide)\", color: H.colors.good }, { label: \"v2 (narrow)\", color: H.colors.warn }], w - 160, 28);" + }, + { + "id": "ph-equipotential-lines", + "area": "Physics", + "topic": "Equipotential lines", + "title": "Equipotential lines: V = k·q/r", + "equation": "V = k * q / r, E = -gradient(V) (E perpendicular to equipotentials)", + "keywords": [ + "equipotential", + "equipotential lines", + "potential", + "electric potential", + "voltage", + "field lines", + "dipole", + "v=kq/r", + "perpendicular", + "gradient", + "electrostatics", + "equipotential surface" + ], + "explanation": "Equipotential lines connect every point at the SAME voltage, like contour lines on a topographic map. Each charge sets V = k·q/r around it (positive charge = hills, negative = valleys); slide the charges' size and separation to reshape the contours. The green arrow shows the electric field E = -gradient(V): it always points straight DOWNHILL in voltage, so it crosses every equipotential at a right angle — that perpendicularity is the key idea.", + "bullets": [ + "An equipotential connects points at equal V; no work is done moving a charge along one.", + "The field E is the negative gradient of V — it points downhill and is always perpendicular to equipotentials.", + "Near +q the potential is high (warm), near -q it is low (cool); contours bunch where the field is strong." + ], + "params": [ + { + "name": "qp", + "label": "positive charge +q (nC)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "qn", + "label": "negative charge -q (nC)", + "min": 0.5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "d", + "label": "half-separation d (m)", + "min": 0.5, + "max": 3, + "step": 0.25, + "value": 2 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -4, yMax: 4 });\nv.grid(); v.axes();\nconst k = 9e9;\nconst qp = P.qp * 1e-9, qn = -P.qn * 1e-9, d = P.d;\nconst cp = [d, 0], cn = [-d, 0];\nconst eps = 0.15;\nconst potAt = (x, y) => {\n const rp = Math.max(eps, Math.hypot(x - cp[0], y - cp[1]));\n const rn = Math.max(eps, Math.hypot(x - cn[0], y - cn[1]));\n return k * qp / rp + k * qn / rn; // volts (charges in nC over metres)\n};\n// Draw equipotential contours by marching a coarse grid and connecting cells\n// whose potential is near each chosen level (dotted level set, color by sign).\nconst levels = [-300, -150, -60, 60, 150, 300];\nconst nx = 120, ny = 80;\nfor (let li = 0; li < levels.length; li++) {\n const L = levels[li];\n const col = L > 0 ? H.colors.warn : H.colors.accent;\n for (let i = 0; i < nx; i++) {\n for (let j = 0; j < ny; j++) {\n const x = -6 + 12 * i / nx, y = -4 + 8 * j / ny;\n const Vc = potAt(x, y);\n const Vr = potAt(x + 12 / nx, y);\n const Vu = potAt(x, y + 8 / ny);\n // a contour at level L passes through this cell if L is bracketed\n if ((Vc - L) * (Vr - L) < 0 || (Vc - L) * (Vu - L) < 0) {\n v.dot(x, y, { r: 1.4, fill: col });\n }\n }\n }\n}\n// the two source charges\nv.circle(cp[0], cp[1], 9, { fill: H.colors.warn, stroke: H.colors.ink, width: 1.5 });\nv.circle(cn[0], cn[1], 9, { fill: H.colors.accent, stroke: H.colors.ink, width: 1.5 });\nv.text(\"+\", cp[0], cp[1] - 0.05, { color: H.colors.ink, size: 13, align: \"center\", baseline: \"middle\", weight: 700 });\nv.text(\"−\", cn[0], cn[1] - 0.05, { color: H.colors.ink, size: 13, align: \"center\", baseline: \"middle\", weight: 700 });\n// a test charge orbiting on roughly an equipotential ring, with the E-field\n// arrow (always PERPENDICULAR to equipotentials) drawn at its location\nconst ang = t * 0.7;\nconst R = 2.4;\nconst tx = R * Math.cos(ang), ty = 1.6 * Math.sin(ang);\n// numeric gradient of V -> E = -grad V\nconst h = 0.05;\nconst Ex = -(potAt(tx + h, ty) - potAt(tx - h, ty)) / (2 * h);\nconst Ey = -(potAt(tx, ty + h) - potAt(tx, ty - h)) / (2 * h);\nconst Emag = Math.hypot(Ex, Ey) || 1;\nconst al = 1.3;\nv.arrow(tx, ty, tx + al * Ex / Emag, ty + al * Ey / Emag, { color: H.colors.good, width: 2.5, head: 9 });\nv.dot(tx, ty, { r: 5, fill: H.colors.yellow });\nconst Vhere = potAt(tx, ty);\nH.text(\"Equipotential lines + field E ⟂ V\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"V(test) = \" + Vhere.toFixed(0) + \" V |E| = \" + Emag.toFixed(0) + \" V/m (E points downhill in V)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"V > 0\", color: H.colors.warn }, { label: \"V < 0\", color: H.colors.accent }, { label: \"E field\", color: H.colors.good }], H.W - 130, 28);" + }, + { + "id": "ph-capacitance", + "area": "Physics", + "topic": "Capacitance", + "title": "Parallel-plate capacitance: C = epsilon0·A/d", + "equation": "C = epsilon0 * A / d", + "keywords": [ + "capacitance", + "capacitor", + "parallel plate", + "farad", + "epsilon0", + "permittivity", + "plate area", + "plate separation", + "c=eps0 a/d", + "dielectric", + "electrostatics", + "charge storage" + ], + "explanation": "A parallel-plate capacitor stores charge by separating + and - on two plates a distance d apart. Its capacitance C = epsilon0·A/d says how many coulombs it holds per volt: bigger plate area A gives more room for charge (C grows), while a wider gap d weakens the field's grip and LOWERS C. Slide A and watch the plates grow taller; slide d and watch the gap — and the live pF readout — respond exactly as the formula predicts.", + "bullets": [ + "C measures charge stored per volt: Q = C·V, in farads (here picofarads).", + "More plate area A raises C; a larger gap d lowers C (C is inversely proportional to d).", + "epsilon0 = 8.85e-12 F/m is the permittivity of free space; a dielectric would multiply C up." + ], + "params": [ + { + "name": "A", + "label": "plate area A (cm^2)", + "min": 10, + "max": 300, + "step": 10, + "value": 100 + }, + { + "name": "d", + "label": "plate gap d (mm)", + "min": 0.25, + "max": 5, + "step": 0.25, + "value": 1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -4, yMax: 4 });\nv.grid(); v.axes();\nconst eps0 = 8.854e-12; // F/m\nconst A = P.A * 1e-4; // plate area, cm^2 -> m^2\nconst d_mm = P.d; // gap in mm\nconst d = d_mm * 1e-3; // gap in metres\nconst C = eps0 * A / Math.max(1e-9, d); // farads\nconst C_pF = C * 1e12;\n// geometry: plate half-height scales with sqrt(area); gap scales with d_mm\nconst ph = H.clamp(0.6 * Math.sqrt(P.A), 1.2, 3.4); // plate half-height (data units)\nconst gap = H.clamp(d_mm * 0.5, 0.3, 3.2); // half-gap (data units)\n// top (+) plate on the right, bottom (-) plate on the left of the gap\nv.rect(gap, -ph, 0.35, 2 * ph, { fill: H.colors.warn, stroke: H.colors.ink, width: 1 });\nv.rect(-gap - 0.35, -ph, 0.35, 2 * ph, { fill: H.colors.accent, stroke: H.colors.ink, width: 1 });\nv.text(\"+Q\", gap + 0.6, ph + 0.4, { color: H.colors.warn, size: 13, weight: 700 });\nv.text(\"−Q\", -gap - 0.95, ph + 0.4, { color: H.colors.accent, size: 13, weight: 700 });\n// uniform field lines from + plate to - plate; little charges drift across\n// (animated, looping) to show the gap is what's being changed\nconst nlines = 7;\nfor (let i = 0; i < nlines; i++) {\n const y = -ph + 2 * ph * (i + 0.5) / nlines;\n v.line(gap, y, -gap, y, { color: H.colors.grid, width: 1, dash: [4, 4] });\n const frac = ((t * 0.5 + i / nlines) % 1);\n const x = gap - frac * (2 * gap);\n v.arrow(x + 0.15, y, x - 0.15, y, { color: H.colors.good, width: 2, head: 7 });\n}\n// gap dimension marker\nv.line(gap, -ph - 0.3, -gap, -ph - 0.3, { color: H.colors.violet, width: 1.5 });\nv.text(\"d\", 0, -ph - 0.65, { color: H.colors.violet, size: 13, align: \"center\", weight: 700 });\nH.text(\"Parallel-plate capacitance: C = ε₀·A / d\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"A = \" + P.A.toFixed(0) + \" cm² d = \" + d_mm.toFixed(2) + \" mm → C = \" + C_pF.toFixed(2) + \" pF\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"+ plate\", color: H.colors.warn }, { label: \"− plate\", color: H.colors.accent }, { label: \"E field\", color: H.colors.good }], H.W - 130, 28);" + }, + { + "id": "ph-capacitor-energy-storage", + "area": "Physics", + "topic": "Capacitors and energy storage", + "title": "Capacitor energy: U = 1/2 C V^2", + "equation": "U = (1/2) * C * V^2, V(t) = V0 (1 - e^(-t/(R*C)))", + "keywords": [ + "capacitor energy", + "energy storage", + "u=1/2 cv^2", + "stored energy", + "charging", + "rc circuit", + "time constant", + "tau", + "q=cv", + "joules", + "electrostatics", + "discharge" + ], + "explanation": "Charging a capacitor through a resistor, its voltage rises along V(t) = V0(1 - e^(-t/RC)), reaching about 63% of V0 after one time constant tau = RC. The energy it banks is U = 1/2·C·V^2 — note the SQUARE, so the last volts store far more energy than the first. Slide C, V0, and R: bigger C or V0 fills the green energy bar higher, while bigger R (or C) stretches tau so it charges more slowly. The curve resets and recharges so you can watch the whole rise repeatedly.", + "bullets": [ + "Stored energy U = 1/2 C V^2 grows with the SQUARE of voltage — doubling V quadruples U.", + "Charge follows Q = C·V; on the curve V reaches ~63% of V0 after one time constant tau = RC.", + "Larger R or C lengthens tau (slower charging); the final energy depends only on C and V0." + ], + "params": [ + { + "name": "C", + "label": "capacitance C (uF)", + "min": 1, + "max": 50, + "step": 1, + "value": 10 + }, + { + "name": "V0", + "label": "supply V0 (V)", + "min": 1, + "max": 12, + "step": 0.5, + "value": 9 + }, + { + "name": "R", + "label": "resistance R (kohm)", + "min": 10, + "max": 300, + "step": 10, + "value": 100 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 6, yMin: 0, yMax: 12 });\nv.grid(); v.axes();\nconst C = P.C * 1e-6; // capacitance, microfarads -> farads\nconst V0 = P.V0; // supply voltage, volts\nconst R = P.R * 1e3; // resistance, kilo-ohms -> ohms\nconst tau = R * C; // time constant (s)\n// charging curve V(T) = V0 (1 - e^{-T/tau}); plot vs time on x-axis (seconds)\nconst Vof = (T) => V0 * (1 - Math.exp(-T / Math.max(1e-9, tau)));\nv.fn(x => Vof(x), { color: H.colors.accent, width: 3 });\n// supply (asymptote) and one-time-constant marker\nv.line(0, V0, 6, V0, { color: H.colors.violet, width: 1.5, dash: [5, 5] });\nv.text(\"V₀ = \" + V0.toFixed(1) + \" V\", 4.2, V0 + 0.5, { color: H.colors.violet, size: 12 });\nif (tau <= 6) {\n v.line(tau, 0, tau, Vof(tau), { color: H.colors.sub, width: 1, dash: [3, 3] });\n v.dot(tau, Vof(tau), { r: 4, fill: H.colors.sub });\n v.text(\"τ = RC\", tau + 0.1, Vof(tau) - 0.6, { color: H.colors.sub, size: 11 });\n}\n// charging dot loops: time sweeps 0..6 s then resets (capacitor charges again)\nconst T = (t * 0.6) % 6;\nconst Vnow = Vof(T);\nconst Unow = 0.5 * C * Vnow * Vnow; // joules\nconst Qnow = C * Vnow; // coulombs\nv.dot(T, Vnow, { r: 6, fill: H.colors.warn });\n// energy bar (U grows with V^2) on the right edge, full at U_max = 1/2 C V0^2\nconst Umax = 0.5 * C * V0 * V0;\nconst barFrac = Umax > 0 ? H.clamp(Unow / Umax, 0, 1) : 0;\nv.rect(5.4, 0, 0.45, 11 * barFrac, { fill: H.colors.good });\nv.rect(5.4, 0, 0.45, 11, { stroke: H.colors.axis, width: 1 });\nv.text(\"U\", 5.62, 11.4, { color: H.colors.good, size: 12, align: \"center\", weight: 700 });\nH.text(\"Energy stored in a capacitor: U = ½·C·V²\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"V = \" + Vnow.toFixed(2) + \" V Q = \" + (Qnow * 1e6).toFixed(1) + \" µC U = \" + (Unow * 1e6).toFixed(1) + \" µJ (τ = \" + tau.toFixed(2) + \" s)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"V(t) charging\", color: H.colors.accent }, { label: \"stored U\", color: H.colors.good }], H.W - 170, 28);" + }, + { + "id": "ph-kinetic-theory-gases", + "area": "Physics", + "topic": "Kinetic theory of gases", + "title": "Kinetic theory: v_rms = sqrt(3 k T / m)", + "equation": "v_rms = sqrt(3 * k * T / m), avg KE = (3/2) * k * T", + "keywords": [ + "kinetic theory", + "ideal gas", + "rms speed", + "root mean square speed", + "molecular speed", + "boltzmann constant", + "temperature", + "average kinetic energy", + "gas molecules", + "thermal motion", + "maxwell boltzmann" + ], + "explanation": "Temperature is just the average kinetic energy of the molecules: avg KE = (3/2) k T, so raising T makes every molecule jiggle faster. The root-mean-square speed v_rms = sqrt(3 k T / m) is the typical molecular speed — crank T up and the dots fly faster; make the molecules heavier (larger m, in atomic mass units) and the same temperature gives a slower v_rms. The red tracer molecule carries a velocity arrow so you can watch one particle bounce around the box while N sets how crowded it gets.", + "bullets": [ + "Temperature measures average molecular kinetic energy: (3/2) k T per molecule.", + "v_rms = sqrt(3 k T / m): hotter gas is faster, heavier molecules are slower at the same T.", + "All gases at the same T share the same average KE, regardless of mass." + ], + "params": [ + { + "name": "T", + "label": "temperature T (K)", + "min": 50, + "max": 1000, + "step": 10, + "value": 300 + }, + { + "name": "m", + "label": "molecular mass m (u)", + "min": 2, + "max": 60, + "step": 1, + "value": 28 + }, + { + "name": "N", + "label": "molecules N", + "min": 5, + "max": 60, + "step": 1, + "value": 30 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst T = Math.max(50, P.T), N = Math.max(1, Math.round(P.N)), m = Math.max(1, P.m);\nconst k = 1.38e-23; // Boltzmann constant\nconst mkg = m * 1.66e-27; // molar mass (u) -> kg per molecule\nconst vrms = Math.sqrt(3 * k * T / mkg); // root-mean-square speed (m/s)\nconst KEavg = 1.5 * k * T; // average translational KE per molecule (J)\n// gas box\nconst bx = 60, by = 80, bw = w - 120, bh = h - 150;\nH.rect(bx, by, bw, bh, { stroke: H.colors.axis, width: 2 });\nconst speedPx = H.clamp(vrms / 800, 0.5, 4.0); // on-screen speed proportional to v_rms\nconst rnd = (s) => { const x = Math.sin(s) * 43758.5453; return x - Math.floor(x); };\nconst tri = (val, range) => { const p = ((val % (2 * range)) + 2 * range) % (2 * range); return p < range ? p : 2 * range - p; };\nfor (let i = 0; i < N; i++) {\n const ang = rnd(i + 1) * H.TAU;\n const sp = speedPx * (0.7 + 0.6 * rnd(i + 7));\n const x0 = rnd(i + 13) * (bw - 30), y0 = rnd(i + 19) * (bh - 30);\n const px = bx + 15 + tri(x0 + Math.cos(ang) * sp * t * 50, bw - 30);\n const py = by + 15 + tri(y0 + Math.sin(ang) * sp * t * 50, bh - 30);\n if (i === 0) {\n H.circle(px, py, 6, { fill: H.colors.warn });\n H.arrow(px, py, px + Math.cos(ang) * 26, py + Math.sin(ang) * 26, { color: H.colors.warn, width: 2 });\n } else {\n H.circle(px, py, 4, { fill: H.color(i % 8) });\n }\n}\nH.text(\"Kinetic theory: v_rms = sqrt(3 k T / m)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"T = \" + T.toFixed(0) + \" K m = \" + m.toFixed(0) + \" u N = \" + N + \" v_rms = \" + vrms.toFixed(0) + \" m/s\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"avg KE = (3/2) k T = \" + KEavg.toExponential(2) + \" J per molecule\", 24, h - 18, { color: H.colors.sub, size: 12 });" + }, + { + "id": "ph-first-law-thermodynamics", + "area": "Physics", + "topic": "First law of thermodynamics", + "title": "First law: ΔU = Q − W", + "equation": "Delta U = Q - W", + "keywords": [ + "first law of thermodynamics", + "internal energy", + "heat", + "work", + "conservation of energy", + "delta u", + "q minus w", + "piston", + "gas expansion", + "thermodynamics", + "energy balance" + ], + "explanation": "The first law is energy conservation for a gas: the change in internal energy ΔU equals the heat Q you pour in minus the work W the gas does pushing the piston out. Add heat (Q > 0) and the gas's energy tends to rise; let the gas do work expanding (W > 0) and that energy drains away as the piston lifts. Slide Q and W and watch the three bars — heat in, work out, and the leftover ΔU — always satisfy ΔU = Q − W.", + "bullets": [ + "ΔU = Q − W: heat added raises internal energy, work done by the gas lowers it.", + "Q > 0 means heat flows IN; W > 0 means the gas does work pushing the piston OUT.", + "Internal energy U is a state function — only Q and W depend on the path taken." + ], + "params": [ + { + "name": "Q", + "label": "heat added Q (J)", + "min": -200, + "max": 300, + "step": 10, + "value": 200 + }, + { + "name": "W", + "label": "work by gas W (J)", + "min": -200, + "max": 300, + "step": 10, + "value": 80 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst Q = P.Q, Wk = P.W; // heat added (J), work done BY gas (J)\nconst dU = Q - Wk; // first law: dU = Q - W\n// animated phase to show the piston breathing\nconst ph = (Math.sin(t * 1.2) * 0.5 + 0.5); // 0..1 oscillation\n// piston cylinder on the left\nconst cx = w * 0.28, cyTop = 110, cylW = 150, cylH = 220;\nconst pistonMax = 70;\nconst pistonY = cyTop + 40 + pistonMax * (Wk >= 0 ? ph : (1 - ph)) * H.clamp(Math.abs(Wk) / 200, 0, 1);\nH.rect(cx - cylW / 2, cyTop, cylW, cylH, { stroke: H.colors.axis, width: 2 });\nH.rect(cx - cylW / 2 + 4, pistonY + 8, cylW - 8, cyTop + cylH - pistonY - 12, { fill: \"#1d2c52\" });\nH.rect(cx - cylW / 2 + 4, pistonY, cylW - 8, 8, { fill: H.colors.violet });\nH.text(\"gas (U)\", cx - 26, pistonY + 60, { color: H.colors.sub, size: 12 });\n// Heat arrow into the gas from below (Q)\nconst qy0 = cyTop + cylH + 36, qy1 = cyTop + cylH + 8;\nH.arrow(cx, qy0, cx, qy1, { color: Q >= 0 ? H.colors.warn : H.colors.accent, width: 3 });\nH.text(\"Q = \" + Q.toFixed(0) + \" J\", cx + 12, qy0 - 8, { color: H.colors.warn, size: 13 });\n// Work arrow on the piston (W done by gas pushes piston up)\nH.arrow(cx, pistonY - 6, cx, pistonY - 40, { color: H.colors.good, width: 3 });\nH.text(\"W = \" + Wk.toFixed(0) + \" J\", cx + 12, pistonY - 26, { color: H.colors.good, size: 13 });\n// Energy bar chart on the right\nconst baseX = w * 0.62, baseY = h * 0.62, barW = 46, scale = 0.55;\nconst bar = (x, val, col, lab) => {\n const hgt = val * scale;\n H.rect(x, baseY - Math.max(0, hgt), barW, Math.abs(hgt), { fill: col });\n if (hgt < 0) H.rect(x, baseY, barW, -hgt, { fill: col });\n H.text(lab, x + 2, baseY + 18, { color: H.colors.sub, size: 12 });\n};\nH.line(baseX - 20, baseY, w - 30, baseY, { color: H.colors.axis, width: 1.5 });\nbar(baseX, Q, H.colors.warn, \"Q\");\nbar(baseX + 70, Wk, H.colors.good, \"W\");\nbar(baseX + 140, dU, H.colors.accent, \"ΔU\");\nH.text(\"First law: ΔU = Q − W\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Q = \" + Q.toFixed(0) + \" J W = \" + Wk.toFixed(0) + \" J → ΔU = \" + dU.toFixed(0) + \" J\", 24, 54, { color: H.colors.sub, size: 13 });\nH.legend([{ label: \"Q heat in\", color: H.colors.warn }, { label: \"W work by gas\", color: H.colors.good }, { label: \"ΔU internal\", color: H.colors.accent }], w - 200, 90);" + }, + { + "id": "ph-pv-diagram-processes", + "area": "Physics", + "topic": "PV diagrams and thermodynamic processes", + "title": "PV diagram: W = ∫ P dV", + "equation": "W = integral of P dV; isothermal: W = n R T ln(Vf / Vi)", + "keywords": [ + "pv diagram", + "thermodynamic process", + "isothermal", + "isobaric", + "isochoric", + "work done by gas", + "area under curve", + "ideal gas law", + "p v t", + "pressure volume", + "isotherm" + ], + "explanation": "On a pressure–volume diagram, the work done by the gas is the area under the path: W = ∫ P dV. The faint hyperbola is an isotherm (P = nRT/V at fixed temperature T); switch the process to compare an isothermal expansion (P falls as V grows), an isobaric step (constant pressure, horizontal line), or an isochoric step (constant volume, vertical line — zero work, since the area is a flat line). The shaded region grows as the state point sweeps along, and the live W readout equals that area exactly.", + "bullets": [ + "Work done by the gas = area under the P–V path: W = ∫ P dV.", + "Isothermal W = n R T ln(Vf/Vi); isochoric (constant V) does zero work.", + "Each point obeys the ideal-gas law P V = n R T, so the path is constrained by T." + ], + "params": [ + { + "name": "T", + "label": "temperature T (K)", + "min": 100, + "max": 600, + "step": 10, + "value": 300 + }, + { + "name": "proc", + "label": "process: 0=isoT 1=isoP 2=isoV", + "min": 0, + "max": 2, + "step": 1, + "value": 0 + } + ], + "code": "H.background();\nconst n = 1, R = 8.314; // 1 mole, gas constant\nconst T = Math.max(100, P.T); // temperature (K) for isotherm\nconst proc = Math.round(P.proc); // 0=isothermal, 1=isobaric, 2=isochoric\nconst Vlo = 0.005, Vhi = 0.045, Vmid = 0.025;\nconst Pmax = n * R * T / Vlo * 1.05; // keep the whole isotherm on screen\nconst v = H.plot2d({ xMin: 0, xMax: 0.05, yMin: 0, yMax: Pmax });\nv.grid(); v.axes();\nconst procName = proc === 0 ? \"isothermal (T const)\" : proc === 1 ? \"isobaric (P const)\" : \"isochoric (V const)\";\n// reference isotherm (faint)\nv.fn(V => (V > 1e-6 ? n * R * T / V : NaN), { color: H.colors.sub, width: 1.2 });\n// the chosen process path (bold)\nconst pts = [];\nif (proc === 0) { for (let i = 0; i <= 60; i++) { const V = Vlo + (Vhi - Vlo) * i / 60; pts.push([V, n * R * T / V]); } }\nelse if (proc === 1) { const Pc = n * R * T / Vmid; pts.push([Vlo, Pc]); pts.push([Vhi, Pc]); }\nelse { pts.push([Vmid, n * R * T / Vhi]); pts.push([Vmid, n * R * T / 0.008]); }\nv.path(pts, { color: H.colors.accent, width: 3 });\n// moving state point (loops via sin)\nconst fr = 0.5 + 0.5 * Math.sin(t * 1.0);\nlet Vp, Pp;\nif (proc === 0) { Vp = Vlo + (Vhi - Vlo) * fr; Pp = n * R * T / Vp; }\nelse if (proc === 1) { Vp = Vlo + (Vhi - Vlo) * fr; Pp = n * R * T / Vmid; }\nelse { Vp = Vmid; Pp = n * R * T / Vhi + (n * R * T / 0.008 - n * R * T / Vhi) * fr; }\n// shade area under path so far (= work) for isothermal/isobaric\nif (proc !== 2) {\n const fill = [];\n for (let i = 0; i <= 40; i++) { const V = Vlo + (Vp - Vlo) * i / 40; const Pv = proc === 0 ? n * R * T / V : n * R * T / Vmid; fill.push([V, Pv]); }\n fill.push([Vp, 0]); fill.push([Vlo, 0]);\n v.path(fill, { color: \"none\", fill: \"rgba(124,196,255,0.18)\", close: true });\n}\nv.circle(Vp, Pp, 7, { fill: H.colors.warn });\nlet Wdone;\nif (proc === 0) Wdone = n * R * T * Math.log(Vp / Vlo);\nelse if (proc === 1) Wdone = (n * R * T / Vmid) * (Vp - Vlo);\nelse Wdone = 0;\nH.text(\"PV diagram: W = ∫ P dV (area under curve)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(procName + \" T = \" + T.toFixed(0) + \" K\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"P = \" + (Pp / 1000).toFixed(1) + \" kPa V = \" + (Vp * 1000).toFixed(1) + \" L W = \" + Wdone.toFixed(0) + \" J\", 24, H.H - 16, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"process\", color: H.colors.accent }, { label: \"isotherm\", color: H.colors.sub }], H.W - 160, 90);" + }, + { + "id": "ph-heat-engines-efficiency", + "area": "Physics", + "topic": "Heat engines and efficiency", + "title": "Heat engine: η = 1 − Tc/Th", + "equation": "eta = W / Qh = 1 - Tc / Th, W = Qh - Qc", + "keywords": [ + "heat engine", + "efficiency", + "carnot efficiency", + "hot reservoir", + "cold reservoir", + "work output", + "thermal efficiency", + "qh qc", + "second law", + "carnot cycle", + "thermodynamics" + ], + "explanation": "A heat engine takes heat Qh from a hot reservoir, converts part of it to useful work W, and must dump the rest as Qc into a cold reservoir — energy conservation forces W = Qh − Qc. The best possible (Carnot) efficiency is η = 1 − Tc/Th, set only by the two reservoir temperatures: a bigger temperature gap means a higher fraction of Qh becomes work. Widen Th versus Tc and watch the green efficiency bar climb and the work arrow grow while less heat trickles to the cold side.", + "bullets": [ + "Energy in must balance: W = Qh − Qc, so you can never turn ALL heat into work.", + "Carnot efficiency η = 1 − Tc/Th depends only on the reservoir temperatures.", + "A larger Th/Tc gap means higher efficiency; η = 1 would need Tc = 0 K (impossible)." + ], + "params": [ + { + "name": "Th", + "label": "hot reservoir Th (K)", + "min": 350, + "max": 900, + "step": 10, + "value": 600 + }, + { + "name": "Tc", + "label": "cold reservoir Tc (K)", + "min": 200, + "max": 500, + "step": 10, + "value": 300 + }, + { + "name": "Qh", + "label": "heat input Qh (J)", + "min": 50, + "max": 500, + "step": 10, + "value": 300 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst Th = Math.max(10, P.Th), Tc = H.clamp(P.Tc, 1, Th - 1); // reservoir temps (K)\nconst Qh = Math.max(1, P.Qh); // heat drawn from hot reservoir (J)\nconst eff = 1 - Tc / Th; // Carnot (max) efficiency\nconst Wout = eff * Qh; // work output\nconst Qc = Qh - Wout; // heat dumped to cold reservoir\nconst cx = w * 0.40;\nconst hotY = 90, coldY = h - 90, engY = h * 0.5;\nH.rect(cx - 130, hotY - 34, 260, 56, { fill: \"#3a1c2a\", stroke: H.colors.warn, width: 2, radius: 8 });\nH.text(\"HOT Th = \" + Th.toFixed(0) + \" K\", cx - 70, hotY, { color: H.colors.warn, size: 14, weight: 700 });\nH.rect(cx - 130, coldY - 22, 260, 56, { fill: \"#16263f\", stroke: H.colors.accent, width: 2, radius: 8 });\nH.text(\"COLD Tc = \" + Tc.toFixed(0) + \" K\", cx - 78, coldY + 12, { color: H.colors.accent, size: 14, weight: 700 });\n// engine wheel that spins with t\nconst rEng = 46;\nH.circle(cx, engY, rEng, { fill: H.colors.panel, stroke: H.colors.violet, width: 3 });\nfor (let i = 0; i < 8; i++) {\n const a = t * 2 + i * H.TAU / 8;\n H.line(cx, engY, cx + Math.cos(a) * rEng, engY + Math.sin(a) * rEng, { color: H.colors.violet, width: 1.5 });\n}\nH.text(\"engine\", cx - 22, engY + 4, { color: H.colors.sub, size: 12 });\nH.arrow(cx, hotY + 24, cx, engY - rEng - 4, { color: H.colors.warn, width: 4 });\nH.text(\"Qh = \" + Qh.toFixed(0) + \" J\", cx + 14, (hotY + engY) / 2, { color: H.colors.warn, size: 13 });\nH.arrow(cx, engY + rEng + 4, cx, coldY - 24, { color: H.colors.accent, width: 4 });\nH.text(\"Qc = \" + Qc.toFixed(0) + \" J\", cx + 14, (engY + coldY) / 2, { color: H.colors.accent, size: 13 });\nconst wob = 6 * Math.sin(t * 3);\nH.arrow(cx + rEng + 4, engY, cx + rEng + 90 + wob, engY, { color: H.colors.good, width: 4 });\nH.text(\"W = \" + Wout.toFixed(0) + \" J\", cx + rEng + 24, engY - 12, { color: H.colors.good, size: 13 });\n// efficiency bar\nconst bx = w - 70, byTop = 110, bh = h - 220;\nH.rect(bx, byTop, 24, bh, { stroke: H.colors.axis, width: 1.5 });\nH.rect(bx, byTop + bh * (1 - eff), 24, bh * eff, { fill: H.colors.good });\nH.text((eff * 100).toFixed(0) + \"%\", bx - 6, byTop - 10, { color: H.colors.good, size: 13, weight: 700 });\nH.text(\"η\", bx + 6, byTop + bh + 18, { color: H.colors.sub, size: 14 });\nH.text(\"Heat engine: η = W/Qh = 1 − Tc/Th\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"Qh = \" + Qh.toFixed(0) + \" J → W = \" + Wout.toFixed(0) + \" J + Qc = \" + Qc.toFixed(0) + \" J η = \" + (eff * 100).toFixed(1) + \"%\", 24, 54, { color: H.colors.sub, size: 13 });" + }, + { + "id": "ph-entropy-second-law", + "area": "Physics", + "topic": "Entropy and the second law", + "title": "Second law: ΔS = N k ln(Vf/Vi) ≥ 0", + "equation": "Delta S = N * k * ln(Vf / Vi) >= 0", + "keywords": [ + "entropy", + "second law of thermodynamics", + "free expansion", + "disorder", + "irreversible", + "delta s", + "boltzmann constant", + "gas spreading", + "microstates", + "spontaneous", + "thermodynamics" + ], + "explanation": "The second law says an isolated system's entropy can only rise: ΔS ≥ 0. Here a gas starts trapped in the left portion of a box; remove the partition and the molecules spontaneously spread to fill the whole volume, never crowding back into one corner on their own. The entropy increase for this free expansion is ΔS = N k ln(Vf/Vi) — more molecules N or a larger expansion ratio Vf/Vi means a bigger, always-positive jump in disorder.", + "bullets": [ + "Entropy of an isolated system never decreases: ΔS ≥ 0 (the second law).", + "Free expansion gives ΔS = N k ln(Vf/Vi) > 0 — disorder rises as the gas spreads.", + "The reverse (gas crowding into one half by itself) is allowed by energy but forbidden by entropy." + ], + "params": [ + { + "name": "N", + "label": "molecules N", + "min": 10, + "max": 80, + "step": 1, + "value": 40 + }, + { + "name": "ratio", + "label": "expansion Vf/Vi", + "min": 1.2, + "max": 4, + "step": 0.1, + "value": 2 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst N = Math.max(2, Math.round(P.N)); // number of molecules\nconst ratio = Math.max(1.01, P.ratio); // Vf / Vi expansion ratio\nconst k = 1.38e-23;\n// entropy increase for free (irreversible) expansion of ideal gas: dS = N k ln(Vf/Vi)\nconst dS = N * k * Math.log(ratio);\nconst bx = 60, by = 90, bw = w - 120, bh = h - 160;\nH.rect(bx, by, bw, bh, { stroke: H.colors.axis, width: 2 });\nconst frac = 1 / ratio; // initial occupied fraction (left)\nconst barrierX = bx + bw * frac;\nconst prog = 0.5 - 0.5 * Math.cos(t * 0.8); // 0..1 smooth loop\nH.line(barrierX, by, barrierX, by + bh, { color: H.colors.violet, width: 2, dash: [6, 6] });\nconst rnd = (s) => { const x = Math.sin(s) * 43758.5453; return x - Math.floor(x); };\nconst tri = (val, range) => { const p = ((val % (2 * range)) + 2 * range) % (2 * range); return p < range ? p : 2 * range - p; };\nfor (let i = 0; i < N; i++) {\n const ax = rnd(i + 1), ay = rnd(i + 5);\n const leftW = (bw - 30) * frac;\n const fullW = (bw - 30);\n const regionW = leftW + (fullW - leftW) * prog;\n const ang = rnd(i + 11) * H.TAU, sp = 18 + 22 * rnd(i + 17);\n const px = bx + 15 + tri(ax * regionW + Math.cos(ang) * sp * t, regionW);\n const py = by + 15 + tri(ay * (bh - 30) + Math.sin(ang) * sp * t, bh - 30);\n H.circle(px, py, 4, { fill: H.color(i % 8) });\n}\nH.text(\"Second law: entropy rises ΔS = N k ln(Vf/Vi) ≥ 0\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"N = \" + N + \" Vf/Vi = \" + ratio.toFixed(2) + \" ΔS = \" + dS.toExponential(2) + \" J/K\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"gas spontaneously fills the box; it never crowds back on its own\", 24, h - 16, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"barrier (removed)\", color: H.colors.violet }], w - 200, 96);" + }, + { + "id": "ph-electric-charge", + "area": "Physics", + "topic": "Electric charge", + "title": "Electric charge: like repel, unlike attract", + "equation": "charge q = N * e, e = 1.602e-19 C", + "keywords": [ + "electric charge", + "charge", + "positive", + "negative", + "like charges repel", + "unlike charges attract", + "coulomb", + "elementary charge", + "quantized charge", + "proton electron", + "static electricity", + "sign of charge" + ], + "explanation": "Charge comes in two signs and is quantized: every charge is a whole-number multiple of the elementary charge e = 1.6e-19 C. Set the signs of the two charges and watch the interaction arrows: when q1 and q2 have the SAME sign their product is positive and they push apart, but OPPOSITE signs pull together. A zero (neutral) charge feels no electric force at all.", + "bullets": [ + "Charge is quantized: q = N*e, with e = 1.602e-19 C the smallest unit.", + "Like signs (product > 0) repel; opposite signs (product < 0) attract.", + "A neutral object has net charge 0 and feels no Coulomb force." + ], + "params": [ + { + "name": "q1", + "label": "charge q1 (units of e)", + "min": -3, + "max": 3, + "step": 1, + "value": 1 + }, + { + "name": "q2", + "label": "charge q2 (units of e)", + "min": -3, + "max": 3, + "step": 1, + "value": -1 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst q1 = P.q1, q2 = P.q2;\n// Two fixed charges; a tiny neutral test-pith ball bobs between them so the\n// scene MOVES while the charges (defined only by their sign/magnitude) stay put.\nconst x1 = -5, x2 = 5, y0 = 0;\nconst col = (q) => q > 0 ? H.colors.warn : (q < 0 ? H.colors.accent : H.colors.sub);\nconst r1 = 8 + 4 * Math.abs(q1), r2 = 8 + 4 * Math.abs(q2);\nv.circle(x1, y0, r1, { fill: col(q1), stroke: H.colors.bg, width: 2 });\nv.circle(x2, y0, r2, { fill: col(q2), stroke: H.colors.bg, width: 2 });\nv.text((q1 > 0 ? \"+\" : q1 < 0 ? \"−\" : \"0\"), x1 - 0.3, y0 + 0.5, { color: H.colors.ink, size: 18, weight: 700 });\nv.text((q2 > 0 ? \"+\" : q2 < 0 ? \"−\" : \"0\"), x2 - 0.3, y0 + 0.5, { color: H.colors.ink, size: 18, weight: 700 });\n// like signs repel (product>0), opposite attract: show interaction arrows.\nconst prod = q1 * q2;\nconst phase = (Math.sin(t * 1.5) + 1) / 2; // 0..1 looping\nconst reach = 1.6 * phase;\nif (prod > 0) { // repel: arrows point outward, away from each other\n v.arrow(x1, y0, x1 - 1.5 - reach, y0, { color: col(q1), width: 3 });\n v.arrow(x2, y0, x2 + 1.5 + reach, y0, { color: col(q2), width: 3 });\n} else if (prod < 0) { // attract: arrows point toward each other\n v.arrow(x1, y0, x1 + 1.5 + reach, y0, { color: col(q1), width: 3 });\n v.arrow(x2, y0, x2 - 1.5 - reach, y0, { color: col(q2), width: 3 });\n}\n// quantized charge readout: charge as multiples of e\nconst e = 1.602e-19;\nH.text(\"Electric charge: like repel, unlike attract\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nconst verdict = prod > 0 ? \"same sign → REPEL\" : prod < 0 ? \"opposite sign → ATTRACT\" : \"a neutral charge feels no force\";\nH.text(\"q1 = \" + q1.toFixed(0) + \"e, q2 = \" + q2.toFixed(0) + \"e → \" + verdict, 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"q1 = \" + (q1 * e).toExponential(2) + \" C (charge is quantized in units of e = 1.6e-19 C)\", 24, 72, { color: H.colors.sub, size: 12 });" + }, + { + "id": "ph-coulombs-law", + "area": "Physics", + "topic": "Coulomb's law", + "title": "Coulomb's law: F = k q1 q2 / r^2", + "equation": "F = k * q1 * q2 / r^2, k = 9e9", + "keywords": [ + "coulomb's law", + "coulombs law", + "electrostatic force", + "f = k q1 q2 / r^2", + "inverse square law", + "coulomb constant", + "force between charges", + "k = 9e9", + "repulsive attractive force", + "point charge force", + "electric force" + ], + "explanation": "The force between two point charges grows with the product of the charges and falls off as the SQUARE of their separation r. Slide q1, q2, and the spacing r: doubling either charge doubles the force, but halving r quadruples it (the 1/r^2 law). The green arrows show the force on each charge — outward (repulsive) for like signs, inward (attractive) for opposite signs — and the live readout gives F in newtons.", + "bullets": [ + "F = k q1 q2 / r^2 with k = 9e9 N*m^2/C^2.", + "Inverse-square: halve r and the force becomes 4x larger.", + "Force is repulsive for like charges, attractive for opposite charges." + ], + "params": [ + { + "name": "q1", + "label": "charge q1 (µC)", + "min": -5, + "max": 5, + "step": 0.5, + "value": 2 + }, + { + "name": "q2", + "label": "charge q2 (µC)", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "r", + "label": "separation r (m)", + "min": 1, + "max": 9, + "step": 0.5, + "value": 6 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -4, yMax: 4 });\nv.grid(); v.axes();\nconst q1 = P.q1, q2 = P.q2, r0 = Math.max(0.3, P.r);\nconst k = 9e9, mu = 1e-6; // charges in microcoulombs\n// Charge 1 fixed at origin; charge 2 separation r oscillates so F changes live.\nconst sep = r0 * (0.7 + 0.3 * Math.sin(t * 1.2)); // looping distance, 0..r0\nconst x1 = 0, x2 = Math.max(0.4, sep), y0 = 0;\nconst col = (q) => q > 0 ? H.colors.warn : H.colors.accent;\nv.circle(x1, y0, 10, { fill: col(q1), stroke: H.colors.bg, width: 2 });\nv.circle(x2, y0, 10, { fill: col(q2), stroke: H.colors.bg, width: 2 });\nv.text(q1 > 0 ? \"+\" : \"−\", x1 - 0.12, y0 + 0.25, { color: H.colors.ink, size: 16, weight: 700 });\nv.text(q2 > 0 ? \"+\" : \"−\", x2 - 0.12, y0 + 0.25, { color: H.colors.ink, size: 16, weight: 700 });\n// Coulomb force magnitude F = k q1 q2 / r^2 (Newtons), arrow length scales with F\nconst F = k * Math.abs(q1) * mu * Math.abs(q2) * mu / (sep * sep);\nconst len = Math.min(3.5, 0.02 * F + 0.4); // bounded visual length in data units\nconst repel = q1 * q2 > 0;\nif (repel) { // forces push the pair apart\n v.arrow(x2, y0, x2 + len, y0, { color: H.colors.good, width: 3 });\n v.arrow(x1, y0, x1 - len, y0, { color: H.colors.good, width: 3 });\n} else { // forces pull them together\n v.arrow(x2, y0, x2 - Math.min(len, sep - 0.4), y0, { color: H.colors.good, width: 3 });\n v.arrow(x1, y0, x1 + len, y0, { color: H.colors.good, width: 3 });\n}\n// distance label\nv.line(x1, -1.2, x1, -0.6, { color: H.colors.sub, width: 1 });\nv.line(x2, -1.2, x2, -0.6, { color: H.colors.sub, width: 1 });\nv.line(x1, -0.9, x2, -0.9, { color: H.colors.sub, width: 1, dash: [4, 4] });\nv.text(\"r = \" + sep.toFixed(2) + \" m\", (x1 + x2) / 2 - 0.8, -1.5, { color: H.colors.sub, size: 12 });\nH.text(\"Coulomb's law: F = k q1 q2 / r²\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"F = \" + F.toExponential(2) + \" N \" + (repel ? \"(repulsive)\" : \"(attractive)\"), 24, 52, { color: H.colors.good, size: 13 });\nH.text(\"k = 9e9, q1 = \" + q1.toFixed(1) + \" µC, q2 = \" + q2.toFixed(1) + \" µC — halve r → 4× the force\", 24, 72, { color: H.colors.sub, size: 12 });" + }, + { + "id": "ph-electric-field", + "area": "Physics", + "topic": "Electric field", + "title": "Electric field: E = k Q / r^2", + "equation": "E = k * Q / r^2 (N/C), E = F / q", + "keywords": [ + "electric field", + "field strength", + "e = k q / r^2", + "e = f / q", + "newtons per coulomb", + "field of a point charge", + "test charge", + "field vector", + "force per unit charge", + "field direction", + "electric field intensity" + ], + "explanation": "The electric field E is the force per unit charge a tiny test charge would feel — so you can map the influence of a source charge even before you put another charge there. Around a point charge Q the field magnitude is E = k Q / r^2, the same inverse-square falloff as Coulomb's law. The purple arrows point AWAY from a positive Q and TOWARD a negative Q, and shrink with distance; the orbiting green test point shows E sampled at radius d.", + "bullets": [ + "E = F/q is force per unit charge, measured in N/C.", + "For a point charge, E = k Q / r^2 (inverse-square falloff).", + "Field arrows point away from + and toward -; longer = stronger." + ], + "params": [ + { + "name": "Q", + "label": "source charge Q (µC)", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "d", + "label": "test distance d (m)", + "min": 1, + "max": 6, + "step": 0.5, + "value": 3 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst Q = P.Q, d = Math.max(0.5, P.d);\nconst k = 9e9, mu = 1e-6;\n// A source charge at the origin. We draw the field-vector grid (E points away\n// from + and toward −) and a roving test point at distance d sampling E.\nconst x0 = 0, y0 = 0;\nconst col = Q > 0 ? H.colors.warn : H.colors.accent;\nv.circle(x0, y0, 11, { fill: col, stroke: H.colors.bg, width: 2 });\nv.text(Q > 0 ? \"+\" : \"−\", x0 - 0.18, y0 + 0.3, { color: H.colors.ink, size: 18, weight: 700 });\n// field arrows on a coarse grid, length ~ 1/r (clamped) so the inverse-square falloff reads\nfor (let gx = -8; gx <= 8; gx += 2) {\n for (let gy = -4; gy <= 4; gy += 2) {\n const rx = gx - x0, ry = gy - y0, r = Math.hypot(rx, ry);\n if (r < 1.2) continue;\n const dir = Q > 0 ? 1 : -1; // outward for +, inward for −\n const ux = dir * rx / r, uy = dir * ry / r;\n const L = Math.min(1.6, 6 / (r * r)); // visual length falls off like 1/r^2\n v.arrow(gx, gy, gx + ux * (0.5 + L), gy + uy * (0.5 + L), { color: H.colors.violet, width: 1.6 });\n }\n}\n// roving test charge orbiting at radius d; E = k|Q|/r^2 measured there\nconst ang = t * 0.8;\nconst tx = x0 + d * Math.cos(ang), ty = y0 + d * Math.sin(ang);\nconst E = k * Math.abs(Q) * mu / (d * d);\nconst dir = Q > 0 ? 1 : -1;\nconst ux = dir * Math.cos(ang), uy = dir * Math.sin(ang);\nv.dot(tx, ty, { r: 6, fill: H.colors.good });\nv.arrow(tx, ty, tx + ux * 1.6, ty + uy * 1.6, { color: H.colors.good, width: 3 });\nH.text(\"Electric field: E = k Q / r² (force per unit charge)\", 24, 30, { color: H.colors.ink, size: 17, weight: 700 });\nH.text(\"|E| at r = \" + d.toFixed(1) + \" m = \" + E.toExponential(2) + \" N/C\", 24, 52, { color: H.colors.good, size: 13 });\nH.text(\"Q = \" + Q.toFixed(1) + \" µC — arrows point away from +, toward −; longer = stronger field\", 24, 72, { color: H.colors.sub, size: 12 });" + }, + { + "id": "ph-electric-field-lines", + "area": "Physics", + "topic": "Electric field lines", + "title": "Electric field lines (dipole)", + "equation": "line tangent = E direction, density ~ |E|", + "keywords": [ + "electric field lines", + "field lines", + "lines of force", + "dipole", + "field direction", + "field line density", + "flux lines", + "start on positive end on negative", + "field map", + "electric flux", + "tangent to field" + ], + "explanation": "Field lines are a map of where the electric force points: the line's tangent is the field direction at each spot, and lines are denser where the field is stronger. They always start on positive charges and end on negative ones, and they never cross. Set the two charges' signs and magnitudes — a positive-negative pair gives the classic dipole pattern, and a charge with twice the magnitude sprouts twice as many lines; the green beads stream along to show the direction of E.", + "bullets": [ + "A line's tangent gives the field direction; lines never cross.", + "Lines begin on + charges and terminate on - charges.", + "More lines = more charge; closer lines = stronger field." + ], + "params": [ + { + "name": "q1", + "label": "left charge q1", + "min": -2, + "max": 2, + "step": 1, + "value": 1 + }, + { + "name": "q2", + "label": "right charge q2", + "min": -2, + "max": 2, + "step": 1, + "value": -1 + }, + { + "name": "sep", + "label": "separation (units)", + "min": 2, + "max": 8, + "step": 1, + "value": 5 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -6, yMax: 6 });\nv.grid(); v.axes();\nconst q1 = P.q1, q2 = P.q2, sep = Math.max(1, P.sep);\n// Two charges on the x-axis; trace field lines by stepping along E. Density of\n// lines ~ charge magnitude. A bead streams along each line to show direction.\nconst x1 = -sep / 2, x2 = sep / 2, y0 = 0;\nconst charges = [{ x: x1, y: y0, q: q1 }, { x: x2, y: y0, q: q2 }];\nfunction field(px, py) {\n let ex = 0, ey = 0;\n for (const c of charges) {\n const dx = px - c.x, dy = py - c.y, r2 = dx * dx + dy * dy, r = Math.sqrt(r2);\n if (r < 0.25) continue;\n const s = c.q / (r2 * r); // k folded out; direction + 1/r^2 magnitude\n ex += s * dx; ey += s * dy;\n }\n return [ex, ey];\n}\n// seed lines around each positive charge (lines start on + , end on −)\nconst nLines = 12;\nfor (const c of charges) {\n if (c.q <= 0) continue;\n const seeds = Math.max(6, Math.round(nLines * (c.q / 2)));\n for (let s = 0; s < seeds; s++) {\n const a0 = (s / seeds) * H.TAU + 0.01;\n let px = c.x + 0.4 * Math.cos(a0), py = c.y + 0.4 * Math.sin(a0);\n const pts = [[px, py]];\n for (let step = 0; step < 220; step++) {\n const [ex, ey] = field(px, py);\n const m = Math.hypot(ex, ey);\n if (m < 1e-6) break;\n px += 0.12 * ex / m; py += 0.12 * ey / m;\n if (Math.abs(px) > 11 || Math.abs(py) > 7) break;\n pts.push([px, py]);\n }\n v.path(pts, { color: H.colors.accent, width: 1.4 });\n // animated bead riding the line forward (loops along its length)\n if (pts.length > 4) {\n const idx = Math.floor((t * 18 + s * 7) % pts.length);\n v.dot(pts[idx][0], pts[idx][1], { r: 3, fill: H.colors.good });\n }\n }\n}\nconst col = (q) => q > 0 ? H.colors.warn : H.colors.accent2;\nv.circle(x1, y0, 11, { fill: col(q1), stroke: H.colors.bg, width: 2 });\nv.circle(x2, y0, 11, { fill: col(q2), stroke: H.colors.bg, width: 2 });\nv.text(q1 > 0 ? \"+\" : \"−\", x1 - 0.18, y0 + 0.3, { color: H.colors.ink, size: 16, weight: 700 });\nv.text(q2 > 0 ? \"+\" : \"−\", x2 - 0.18, y0 + 0.3, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"Electric field lines\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"lines start on +, end on −; tangent = field direction, density ∝ |E|\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"q1 = \" + q1.toFixed(0) + \", q2 = \" + q2.toFixed(0) + \" — more charge → more lines\", 24, 72, { color: H.colors.sub, size: 12 });" + }, + { + "id": "ph-electric-potential", + "area": "Physics", + "topic": "Electric potential (voltage)", + "title": "Electric potential: V = k Q / r", + "equation": "V = k * Q / r (volts), W = q * ΔV", + "keywords": [ + "electric potential", + "voltage", + "potential", + "v = k q / r", + "volts", + "potential energy per charge", + "work done", + "w = q delta v", + "1/r falloff", + "potential of a point charge", + "joules per coulomb" + ], + "explanation": "Electric potential V is the potential energy per unit charge at a point — how many joules each coulomb would carry there, measured in volts. Around a point charge V = k Q / r, which falls off like 1/r (more gently than the field's 1/r^2). Slide Q and the test distance r and read V off the curve in kilovolts; the work to carry a charge q between two points is just W = q*(V2 - V1), so it depends only on the potential difference, not the path.", + "bullets": [ + "V = k Q / r is energy per unit charge (volts = J/C).", + "Potential falls off as 1/r — slower than the 1/r^2 field.", + "Work to move charge q: W = q*ΔV, depends only on the voltage difference." + ], + "params": [ + { + "name": "Q", + "label": "source charge Q (µC)", + "min": -5, + "max": 5, + "step": 0.5, + "value": 3 + }, + { + "name": "r", + "label": "test distance r (m)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0.2, xMax: 12, yMin: -1, yMax: 11 });\nv.grid(); v.axes();\nconst Q = P.Q, rTest = Math.max(0.4, P.r);\nconst k = 9e9, mu = 1e-6;\n// Potential V(r) = k Q / r around a point charge. Plot V vs r (in kV) and ride\n// a test point in/out so the readout V changes; show the 1/r curve.\nconst Vof = (r) => k * Q * mu / r / 1000; // kilovolts\nv.fn(r => Vof(r), { color: H.colors.accent, width: 3 });\n// roving distance: oscillate between 0.6 and 11 m\nconst rr = 0.6 + 5 * (1 + Math.sin(t * 0.9)); // 0.6 .. 10.6, looping\nconst Vr = Vof(rr);\n// clamp the marker into the plot window so it stays visible\nconst Vmark = Math.max(-1, Math.min(11, Vr));\nv.line(rr, -1, rr, Vmark, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\nv.dot(rr, Vmark, { r: 6, fill: H.colors.warn });\n// reference: the slider's fixed test radius and its potential\nconst Vfix = Vof(rTest);\nv.dot(rTest, Math.max(-1, Math.min(11, Vfix)), { r: 5, fill: H.colors.good });\nv.text(\"test r\", rTest + 0.1, Math.max(-1, Math.min(10.4, Vfix)) + 0.5, { color: H.colors.good, size: 12 });\nH.text(\"Electric potential: V = k Q / r (voltage, energy per charge)\", 24, 30, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"V at r = \" + rr.toFixed(2) + \" m = \" + Vr.toFixed(2) + \" kV\", 24, 52, { color: H.colors.warn, size: 13 });\nH.text(\"Q = \" + Q.toFixed(1) + \" µC — V falls off like 1/r; W = qΔV to move a charge q\", 24, 72, { color: H.colors.sub, size: 12 });\nH.text(\"V (kV)\", v.box.x + 6, v.box.y + 12, { color: H.colors.sub, size: 12 });\nH.text(\"r (m)\", v.box.x + v.box.w - 36, v.Y(0) - 6, { color: H.colors.sub, size: 12 });" + }, + { + "id": "ph-thermal-expansion", + "area": "Physics", + "topic": "Temperature and thermal expansion", + "title": "Thermal expansion: delta L = alpha * L0 * delta T", + "equation": "delta L = alpha * L0 * delta T", + "keywords": [ + "thermal expansion", + "linear expansion", + "coefficient of expansion", + "alpha", + "delta l", + "temperature change", + "expand", + "heated rod", + "expansion coefficient", + "length change", + "delta t", + "expansion" + ], + "explanation": "Most materials grow when heated because the atoms vibrate harder and sit a little farther apart. The change in length delta L is proportional to three things you control: the expansion coefficient alpha (a material property — aluminum expands ~23e-6 per degree), the original length L0 (a longer rod gains more total length), and the temperature change delta T. Slide alpha up and the rod stretches faster; the readout shows the real millimeter change, which is tiny, so the bar's visual stretch is exaggerated to make it visible.", + "bullets": [ + "delta L grows with alpha, with the starting length L0, and with delta T — all three multiply.", + "Bigger alpha means a 'springier' lattice: metals expand more than glass.", + "A 2 m aluminum rod heated 100 deg C grows only about 4.6 mm — expansion is small but real." + ], + "params": [ + { + "name": "alpha", + "label": "expansion coef alpha (1e-6 /°C)", + "min": 1, + "max": 30, + "step": 1, + "value": 23 + }, + { + "name": "L0", + "label": "original length L0 (m)", + "min": 0.5, + "max": 4, + "step": 0.5, + "value": 2 + }, + { + "name": "dT", + "label": "temp change ΔT (°C)", + "min": 10, + "max": 200, + "step": 10, + "value": 100 + } + ], + "code": "H.background();\n// Linear thermal expansion: delta L = alpha * L0 * delta T\n// A heated rod grows; we read alpha, original length L0, and temperature change.\nconst w = H.W, h = H.H;\nconst alpha = P.alpha * 1e-6; // slider in units of 1e-6 per degC\nconst L0 = P.L0; // original length in meters\nconst dTmax = P.dT; // max temperature change in degC\n// Animate temperature change from 0 up to dTmax and back (looping heating/cooling).\nconst dT = dTmax * 0.5 * (1 - Math.cos(t * 0.8));\nconst dL = alpha * L0 * dT; // change in length (m)\nconst L = L0 + dL; // current length (m)\nconst T = 20 + dT; // current temperature (degC), start at 20\nH.text(\"Thermal expansion: ΔL = α·L0·ΔT\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"α = \" + P.alpha.toFixed(1) + \"e-6 /°C L0 = \" + L0.toFixed(2) + \" m ΔT = \" + dT.toFixed(1) + \" °C\", 24, 52, { color: H.colors.sub, size: 13 });\n// Draw the rod as a bar. Exaggerate the length change visually so it is visible.\nconst baseX = 70;\nconst baseY = h * 0.5;\nconst pxPerM = (w - 160) / Math.max(0.5, L0); // L0 maps to most of the width\n// visual exaggeration factor so a tiny ΔL is seeable\nconst exaggerate = 1 + (dL / Math.max(1e-9, L0)) * 400;\nconst pxLen = pxPerM * L0 * exaggerate * 0.85;\nconst barH = 46;\n// color from cool (blue) to hot (red) by temperature\nconst heat = H.clamp(dT / Math.max(1e-6, dTmax), 0, 1);\nconst rodColor = H.hsl(220 - 200 * heat, 80, 55);\n// reference outline at original length\nH.rect(baseX, baseY - barH / 2, pxPerM * L0 * 0.85, barH, { stroke: H.colors.grid, width: 1.5, radius: 4 });\n// hot rod\nH.rect(baseX, baseY - barH / 2, pxLen, barH, { fill: rodColor, radius: 4 });\n// expansion arrow showing growth direction at the tip\nH.arrow(baseX + pxPerM * L0 * 0.85, baseY, baseX + pxLen + 6, baseY, { color: H.colors.warn, width: 3, head: 10 });\n// fixed wall on the left\nH.rect(baseX - 14, baseY - barH, 14, barH * 2, { fill: H.colors.panel, stroke: H.colors.axis, width: 1.5 });\n// labels\nH.text(\"L0 = \" + L0.toFixed(2) + \" m\", baseX, baseY + barH / 2 + 22, { color: H.colors.sub, size: 12 });\nH.text(\"L = \" + L.toFixed(5) + \" m\", baseX, baseY - barH / 2 - 12, { color: H.colors.accent, size: 13, weight: 600 });\n// thermometer readout\nH.text(\"T = \" + T.toFixed(1) + \" °C\", w - 150, 52, { color: rodColor, size: 14, weight: 700 });\nH.text(\"ΔL = \" + (dL * 1000).toFixed(3) + \" mm\", w - 150, 74, { color: H.colors.good, size: 13 });\nH.text(\"(visual stretch exaggerated)\", baseX, baseY + barH / 2 + 40, { color: H.colors.sub, size: 11 });" + }, + { + "id": "ph-specific-heat", + "area": "Physics", + "topic": "Heat and specific heat", + "title": "Specific heat: Q = m * c * delta T", + "equation": "Q = m * c * delta T", + "keywords": [ + "specific heat", + "heat", + "calorimetry", + "q = mc delta t", + "thermal energy", + "joules", + "temperature rise", + "heat capacity", + "mass", + "specific heat capacity", + "heating", + "delta t" + ], + "explanation": "Heating something raises its temperature, but how much depends on the substance. The heat Q (in joules) needed equals the mass m times the specific heat c times the temperature rise delta T. Water has a large c (4186 J/kg/degC), so it heats slowly and resists temperature change — that's why oceans moderate climate. Here a burner pours in heat at a fixed power; with bigger mass or bigger c, the same heat produces a smaller delta T, so the thermometer climbs more slowly.", + "bullets": [ + "Q = m·c·ΔT: more mass or higher specific heat means more joules per degree.", + "Water's high c (4186 J/kg·°C) makes it a thermal sponge — slow to warm, slow to cool.", + "At constant heating power P, the temperature rises linearly: ΔT = P·t / (m·c)." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.1, + "max": 2, + "step": 0.1, + "value": 0.5 + }, + { + "name": "c", + "label": "specific heat c (J/kg·°C)", + "min": 130, + "max": 4186, + "step": 1, + "value": 4186 + }, + { + "name": "power", + "label": "heater power P (W)", + "min": 100, + "max": 1500, + "step": 50, + "value": 500 + } + ], + "code": "H.background();\n// Specific heat: Q = m * c * delta T\n// A burner adds heat at a steady rate; the temperature of the sample rises.\nconst w = H.W, h = H.H;\nconst m = P.m; // mass in kg\nconst c = P.c; // specific heat in J/(kg*degC)\nconst power = P.power; // heater power in watts (J/s)\n// Heat delivered grows with time, but loops so the bar resets and reheats.\nconst period = 10; // seconds per heating cycle\nconst tau = (t % period); // time since this cycle started\nconst Q = power * tau; // joules delivered so far\nconst dT = (m > 1e-9 && c > 1e-9) ? Q / (m * c) : 0; // temperature rise (degC)\nconst T = 20 + dT; // current temperature, starting at 20 degC\nH.text(\"Specific heat: Q = m·c·ΔT\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"m = \" + m.toFixed(2) + \" kg c = \" + c.toFixed(0) + \" J/(kg·°C) P = \" + power.toFixed(0) + \" W\", 24, 52, { color: H.colors.sub, size: 13 });\n// Beaker\nconst bx = 90, bw = 150, bTop = 110, bBot = h - 80;\nconst bh = bBot - bTop;\nH.rect(bx, bTop, bw, bh, { stroke: H.colors.axis, width: 2, radius: 6 });\n// fill level fixed; color encodes temperature (cool -> hot)\nconst heat = H.clamp(dT / 80, 0, 1);\nconst liqColor = H.hsl(220 - 210 * heat, 78, 52);\nconst fillTop = bTop + bh * 0.18;\nH.rect(bx + 4, fillTop, bw - 8, bBot - fillTop, { fill: liqColor, radius: 4 });\n// rising bubbles to show heating (loop within the liquid)\nfor (let i = 0; i < 6; i++) {\n const phase = (t * (0.6 + i * 0.12) + i * 0.5) % 1;\n const by = bBot - 6 - phase * (bBot - fillTop - 10);\n const bxp = bx + 20 + ((i * 37 + 13) % (bw - 40));\n H.circle(bxp, by, 2.5 + heat * 2, { fill: \"rgba(255,255,255,0.5)\" });\n}\n// burner flame under the beaker, flickering with t\nconst fx = bx + bw / 2;\nconst flick = 8 * Math.sin(t * 9) + 26;\nH.path([[fx - 18, bBot + 8], [fx, bBot + 8 - flick], [fx + 18, bBot + 8]], { color: \"none\", fill: H.colors.accent2, close: true });\nH.path([[fx - 9, bBot + 8], [fx, bBot + 8 - flick * 0.55], [fx + 9, bBot + 8]], { color: \"none\", fill: H.colors.warn, close: true });\n// thermometer bar on the right\nconst tx = w - 120, tbTop = 110, tbBot = h - 80;\nH.rect(tx, tbTop, 26, tbBot - tbTop, { stroke: H.colors.axis, width: 1.5, radius: 13 });\nconst lvl = H.clamp(dT / 100, 0, 1);\nconst merc = tbBot - 6 - lvl * (tbBot - tbTop - 12);\nH.rect(tx + 5, merc, 16, tbBot - 6 - merc, { fill: H.colors.warn, radius: 8 });\n// readouts\nH.text(\"Q = \" + (Q / 1000).toFixed(2) + \" kJ\", w - 160, 52, { color: H.colors.good, size: 14, weight: 700 });\nH.text(\"ΔT = \" + dT.toFixed(1) + \" °C\", w - 160, 74, { color: H.colors.accent, size: 13 });\nH.text(\"T = \" + T.toFixed(1) + \" °C\", tx - 4, tbTop - 12, { color: H.colors.warn, size: 13, weight: 700 });" + }, + { + "id": "ph-latent-heat", + "area": "Physics", + "topic": "Phase changes and latent heat", + "title": "Latent heat: Q = m * L", + "equation": "Q = m * L", + "keywords": [ + "latent heat", + "phase change", + "heat of fusion", + "heat of vaporization", + "melting", + "boiling", + "heating curve", + "q = ml", + "plateau", + "ice water steam", + "freezing", + "condensation" + ], + "explanation": "Follow the heating curve of water from ice to steam. While the temperature rises, heat goes into Q = m·c·ΔT and the line slopes up. But during melting and boiling the line goes FLAT (red plateaus): the added heat Q = m·L breaks molecular bonds instead of raising temperature. The latent heat L is huge — melting takes 334 kJ/kg and boiling 2260 kJ/kg, far more than warming the liquid all the way from 0 to 100 deg C. Slide Lf and Lv to stretch or shrink the plateaus, and watch the dot crawl across them.", + "bullets": [ + "Sloped parts (Q = mcΔT): temperature rises. Flat plateaus (Q = mL): phase changes at constant T.", + "Latent heat of vaporization (2260 kJ/kg for water) dwarfs that of fusion (334 kJ/kg).", + "Boiling away 0.1 kg of water absorbs ~226 kJ with no temperature change at all." + ], + "params": [ + { + "name": "m", + "label": "mass m (kg)", + "min": 0.05, + "max": 0.5, + "step": 0.05, + "value": 0.1 + }, + { + "name": "Lf", + "label": "heat of fusion Lf (kJ/kg)", + "min": 100, + "max": 500, + "step": 10, + "value": 334 + }, + { + "name": "Lv", + "label": "heat of vaporization Lv (kJ/kg)", + "min": 1000, + "max": 3000, + "step": 50, + "value": 2260 + } + ], + "code": "H.background();\n// Phase change & latent heat: heating curve of water.\n// During a phase change, heat Q = m * L goes into breaking bonds, NOT raising T,\n// so the temperature plateaus. Q = m*c*dT (sloped) vs Q = m*L (flat).\nconst m = P.m; // mass in kg\nconst Lf = P.Lf * 1000; // latent heat of fusion, slider in kJ/kg -> J/kg\nconst Lv = P.Lv * 1000; // latent heat of vaporization, slider in kJ/kg -> J/kg\n// Fixed specific heats for water phases (J/(kg*degC)).\nconst cIce = 2100, cWater = 4186, cSteam = 2010;\n// Segment heat costs (J), in order: warm ice -20->0, melt, warm water 0->100, boil, warm steam 100->120.\nconst q1 = m * cIce * 20;\nconst q2 = m * Lf;\nconst q3 = m * cWater * 100;\nconst q4 = m * Lv;\nconst q5 = m * cSteam * 20;\nconst qTot = q1 + q2 + q3 + q4 + q5;\n// Build the (Q, T) curve in kJ vs degC.\nconst k = 1 / 1000; // J -> kJ\nconst pts = [];\nlet Qacc = 0, Tacc = -20;\npts.push([Qacc * k, Tacc]);\nQacc += q1; Tacc = 0; pts.push([Qacc * k, Tacc]); // warm ice to 0\nQacc += q2; pts.push([Qacc * k, Tacc]); // melt (flat at 0)\nQacc += q3; Tacc = 100; pts.push([Qacc * k, Tacc]); // warm water to 100\nQacc += q4; pts.push([Qacc * k, Tacc]); // boil (flat at 100)\nQacc += q5; Tacc = 120; pts.push([Qacc * k, Tacc]); // warm steam to 120\nconst qTotk = qTot * k;\nconst v = H.plot2d({ xMin: 0, xMax: Math.max(1, qTotk) * 1.02, yMin: -30, yMax: 140 });\nv.grid(); v.axes();\nv.path(pts, { color: H.colors.accent, width: 3 });\n// mark the two flat plateaus (latent heat) in a different color\nv.path([pts[1], pts[2]], { color: H.colors.warn, width: 5 });\nv.path([pts[3], pts[4]], { color: H.colors.warn, width: 5 });\n// horizontal guide lines at melting (0) and boiling (100)\nv.line(0, 0, qTotk, 0, { color: H.colors.violet, width: 1, dash: [4, 4] });\nv.line(0, 100, qTotk, 100, { color: H.colors.violet, width: 1, dash: [4, 4] });\n// Animate a dot riding the curve: sweep Q from 0 to qTot and loop.\nconst qNow = (t % 9) / 9 * qTot; // joules added so far this loop\nconst qNowk = qNow * k;\n// find the temperature at qNow by walking the segments\nlet T;\nconst seg = [[0, q1, -20, 0], [q1, q1 + q2, 0, 0], [q1 + q2, q1 + q2 + q3, 0, 100], [q1 + q2 + q3, q1 + q2 + q3 + q4, 100, 100], [q1 + q2 + q3 + q4, qTot, 100, 120]];\nlet phase = \"solid (ice)\";\nfor (let i = 0; i < seg.length; i++) {\n const [a, b, Ta, Tb] = seg[i];\n if (qNow <= b || i === seg.length - 1) {\n const f = b > a ? (qNow - a) / (b - a) : 0;\n T = Ta + (Tb - Ta) * H.clamp(f, 0, 1);\n if (i === 0) phase = \"warming ice\";\n else if (i === 1) phase = \"MELTING (latent)\";\n else if (i === 2) phase = \"warming water\";\n else if (i === 3) phase = \"BOILING (latent)\";\n else phase = \"warming steam\";\n break;\n }\n}\nv.dot(qNowk, T, { r: 7, fill: H.colors.yellow });\nH.text(\"Phase change & latent heat: Q = m·L\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"m = \" + m.toFixed(2) + \" kg Lf = \" + P.Lf.toFixed(0) + \" kJ/kg Lv = \" + P.Lv.toFixed(0) + \" kJ/kg\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Q = \" + qNowk.toFixed(1) + \" kJ T = \" + T.toFixed(1) + \" °C \" + phase, 24, 74, { color: H.colors.good, size: 13, weight: 600 });\nH.legend([{ label: \"T rises: Q = mcΔT\", color: H.colors.accent }, { label: \"plateau: Q = mL\", color: H.colors.warn }], H.W - 210, 100);" + }, + { + "id": "ph-heat-conduction", + "area": "Physics", + "topic": "Heat transfer (conduction, convection, radiation)", + "title": "Conduction: Q/t = k * A * delta T / L", + "equation": "Q/t = k * A * delta T / L", + "keywords": [ + "heat transfer", + "conduction", + "convection", + "radiation", + "thermal conductivity", + "heat flow", + "fourier law", + "q over t", + "insulation", + "temperature gradient", + "heat rate", + "k a delta t over l" + ], + "explanation": "Heat moves three ways; this scene shows conduction quantitatively with convection and radiation as labeled accents. Conduction carries heat through a solid slab from the hot side to the cold side, and the flow rate Q/t equals the conductivity k times the area A times the temperature difference delta T, divided by the thickness L. Raise k (copper conducts far better than wood) or delta T and the heat-flow arrows speed up; make the slab thicker (bigger L) and the rate drops — that's exactly why insulation is thick and made of low-k material.", + "bullets": [ + "Q/t = k·A·ΔT / L: faster flow with higher conductivity, more area, or a bigger temperature gap.", + "A thicker slab (larger L) slows conduction — the principle behind insulation.", + "Conduction needs contact; convection carries heat in moving fluid; radiation needs no medium." + ], + "params": [ + { + "name": "k", + "label": "conductivity k (W/m·K)", + "min": 0.1, + "max": 50, + "step": 0.1, + "value": 0.8 + }, + { + "name": "dT", + "label": "temp difference ΔT (K)", + "min": 5, + "max": 100, + "step": 5, + "value": 50 + }, + { + "name": "L", + "label": "thickness L (m)", + "min": 0.01, + "max": 0.5, + "step": 0.01, + "value": 0.1 + } + ], + "code": "H.background();\n// Heat transfer by conduction: Q/t = k * A * (T_hot - T_cold) / L\n// A wall/rod conducts heat from a hot side to a cold side. Convection and\n// radiation also shown as labeled accents.\nconst w = H.W, h = H.H;\nconst kc = P.k; // thermal conductivity W/(m*K)\nconst dT = P.dT; // temperature difference across the slab (K)\nconst L = P.L; // thickness of the slab (m)\nconst A = 1.0; // area fixed at 1 m^2\nconst rate = (L > 1e-6) ? kc * A * dT / L : 0; // conduction rate in watts\nH.text(\"Heat conduction: Q/t = k·A·ΔT / L\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"k = \" + kc.toFixed(1) + \" W/(m·K) ΔT = \" + dT.toFixed(0) + \" K L = \" + L.toFixed(3) + \" m A = 1 m²\", 24, 52, { color: H.colors.sub, size: 12 });\n// Slab geometry\nconst sx = 220, sw = 260, sTop = 120, sBot = h - 90;\nconst sh = sBot - sTop;\n// hot reservoir (left) and cold reservoir (right)\nH.rect(sx - 80, sTop, 80, sh, { fill: H.hsl(10, 80, 45), radius: 4 });\nH.rect(sx + sw, sTop, 80, sh, { fill: H.hsl(210, 80, 45), radius: 4 });\nH.text(\"HOT\", sx - 70, sTop - 8, { color: H.colors.warn, size: 13, weight: 700 });\nH.text(\"COLD\", sx + sw + 6, sTop - 8, { color: H.colors.accent, size: 13, weight: 700 });\n// slab with a left-to-right temperature gradient (hot red -> cold blue)\nconst cols = 24;\nfor (let i = 0; i < cols; i++) {\n const f = i / (cols - 1);\n const hue = 10 + 200 * f; // 10 (red) -> 210 (blue)\n H.rect(sx + (sw / cols) * i, sTop, sw / cols + 1, sh, { fill: H.hsl(hue, 75, 48) });\n}\nH.rect(sx, sTop, sw, sh, { stroke: H.colors.ink, width: 1.5 });\n// thickness dimension marker\nH.line(sx, sBot + 14, sx + sw, sBot + 14, { color: H.colors.sub, width: 1 });\nH.text(\"L\", sx + sw / 2 - 4, sBot + 30, { color: H.colors.sub, size: 12 });\n// CONDUCTION: heat-flow arrows whose count/speed scale with the rate\nconst flow = (t * (0.4 + rate / 200)) % 1;\nconst nArrows = 3;\nfor (let i = 0; i < nArrows; i++) {\n const yy = sTop + sh * (0.3 + 0.2 * i);\n const xx = sx + ((flow + i / nArrows) % 1) * sw;\n H.arrow(xx, yy, xx + 26, yy, { color: H.colors.yellow, width: 3, head: 8 });\n}\n// CONVECTION: curling arrows rising off the hot side\nconst cv = (t * 0.8) % 1;\nconst cvy = sBot - cv * (sh - 10);\nH.arrow(sx - 40, cvy, sx - 40, cvy - 20, { color: H.colors.good, width: 2.5, head: 7 });\nH.text(\"convection\", sx - 78, sTop + sh + 24, { color: H.colors.good, size: 11 });\n// RADIATION: dashed wavy emission from the hot reservoir\nconst rphase = t * 4;\nconst rad = [];\nfor (let s = 0; s <= 30; s++) {\n const xx = sx - 80 - s * 1.6;\n const yy = sTop + sh * 0.5 + 6 * Math.sin(s * 0.6 - rphase);\n if (xx > 10) rad.push([xx, yy]);\n}\nH.path(rad, { color: H.colors.violet, width: 2 });\nH.text(\"radiation\", 24, sTop + sh * 0.5 - 10, { color: H.colors.violet, size: 11 });\nH.text(\"conduction →\", sx + 6, sTop + sh + 24, { color: H.colors.yellow, size: 11 });\n// readout\nH.text(\"Q/t = \" + rate.toFixed(1) + \" W\", w - 170, 52, { color: H.colors.good, size: 15, weight: 700 });\nH.text(\"(heat flow rate)\", w - 170, 74, { color: H.colors.sub, size: 11 });" + }, + { + "id": "ph-ideal-gas-law", + "area": "Physics", + "topic": "Ideal gas law", + "title": "Ideal gas law: P * V = n * R * T", + "equation": "P * V = n * R * T", + "keywords": [ + "ideal gas law", + "pv = nrt", + "pressure", + "volume", + "moles", + "gas constant", + "boyle", + "temperature kelvin", + "piston", + "gas pressure", + "n r t", + "thermodynamics" + ], + "explanation": "The ideal gas law ties together pressure P, volume V, amount n, and absolute temperature T through the gas constant R = 8.314 J/mol·K. Rearranged as P = nRT/V, it says pressure rises when you squeeze the gas into less volume (Boyle's law) or heat it up. Watch the piston breathe in and out: as the volume shrinks the gas particles hit the walls more often and the pressure gauge swings up; raise T and the particles fly faster, pushing harder. Always use kelvin and SI units so the numbers come out right (1 mol at 300 K in 22.4 L gives ~111 kPa).", + "bullets": [ + "P = nRT/V: pressure rises when V shrinks, or when T or n grows.", + "Squeezing at fixed T, n is Boyle's law (P·V constant); heating at fixed V, n raises P.", + "Temperature MUST be in kelvin; R = 8.314 J/(mol·K) makes the units work out to pascals." + ], + "params": [ + { + "name": "n", + "label": "amount n (mol)", + "min": 0.2, + "max": 3, + "step": 0.1, + "value": 1 + }, + { + "name": "T", + "label": "temperature T (K)", + "min": 200, + "max": 700, + "step": 10, + "value": 300 + }, + { + "name": "V", + "label": "volume V (L)", + "min": 2, + "max": 40, + "step": 1, + "value": 22.4 + } + ], + "code": "H.background();\n// Ideal gas law: P*V = n*R*T -> P = n*R*T / V\n// A piston holds n moles at temperature T; the volume breathes in and out, and\n// the pressure responds inversely. Particles bounce inside, faster when hot.\nconst w = H.W, h = H.H;\nconst R = 8.314; // gas constant J/(mol*K)\nconst n = P.n; // moles\nconst T = P.T; // temperature in kelvin\nconst Vset = P.V; // baseline volume in liters\n// Volume oscillates around the slider value (piston breathing), bounded > 0.\nconst V = Math.max(0.2, Vset * (1 + 0.35 * Math.sin(t * 0.9))); // liters\nconst Vm3 = V / 1000; // liters -> m^3\nconst Pp = (Vm3 > 1e-9) ? n * R * T / Vm3 : 0; // pressure in pascals\nconst Pkpa = Pp / 1000; // kPa for readout\nH.text(\"Ideal gas law: P·V = n·R·T\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"n = \" + n.toFixed(2) + \" mol T = \" + T.toFixed(0) + \" K V = \" + V.toFixed(2) + \" L R = 8.314 J/(mol·K)\", 24, 52, { color: H.colors.sub, size: 12 });\n// Cylinder\nconst cx = 110, cyTop = 100, cw = 230, cyBot = h - 70;\n// piston height maps from current volume (more volume -> piston higher up)\nconst fullH = cyBot - cyTop;\nconst fillFrac = H.clamp(V / (Vset * 1.5), 0.12, 1);\nconst pistonY = cyBot - fillFrac * fullH;\n// cylinder walls\nH.line(cx, cyTop - 10, cx, cyBot, { color: H.colors.axis, width: 2 });\nH.line(cx + cw, cyTop - 10, cx + cw, cyBot, { color: H.colors.axis, width: 2 });\nH.line(cx, cyBot, cx + cw, cyBot, { color: H.colors.axis, width: 2 });\n// gas region tint by temperature (cool->hot)\nconst heat = H.clamp((T - 200) / 500, 0, 1);\nH.rect(cx + 2, pistonY, cw - 4, cyBot - pistonY, { fill: H.hsl(220 - 200 * heat, 60, 28) });\n// piston plate + rod\nH.rect(cx - 6, pistonY - 14, cw + 12, 14, { fill: H.colors.sub, radius: 3 });\nH.rect(cx + cw / 2 - 6, pistonY - 54, 12, 40, { fill: H.colors.axis });\n// downward force arrows on the piston (pressure pushing back)\nH.arrow(cx + cw / 2, pistonY - 70, cx + cw / 2, pistonY - 18, { color: H.colors.warn, width: 3, head: 9 });\n// gas particles: bounce inside the gas box, speed scales with sqrt(T)\nconst speed = 0.4 + Math.sqrt(T) / 30;\nconst np = 14;\nfor (let i = 0; i < np; i++) {\n const sx = 0.13 + 0.74 * (((i * 0.6180339) % 1)); // seed x in [0.13,0.87]\n const sy = ((i * 0.41421) % 1);\n // triangle-wave bounce keeps each particle inside the box and looping\n const px = cx + 14 + (cw - 28) * (0.5 + 0.5 * Math.sin(t * speed * (1 + i * 0.05) + i));\n const boxH = cyBot - pistonY - 16;\n const py = (pistonY + 12) + Math.abs(((sy + t * speed * 0.7) % 2) - 1) * Math.max(6, boxH);\n H.circle(px, py, 3.5, { fill: H.colors.accent });\n}\n// pressure gauge (right): needle angle from pressure\nconst gx = w - 130, gy = 180, gr = 64;\nH.circle(gx, gy, gr, { stroke: H.colors.axis, width: 2 });\nconst pFrac = H.clamp(Pkpa / 5000, 0, 1);\nconst ang = Math.PI * (1 - pFrac); // needle sweeps 180deg (left) at 0 -> 0deg (right) at max\nH.arrow(gx, gy, gx + gr * 0.8 * Math.cos(ang), gy - gr * 0.8 * Math.sin(ang), { color: H.colors.warn, width: 3, head: 8 });\nH.text(\"P\", gx - 4, gy + gr + 18, { color: H.colors.sub, size: 13 });\n// readouts\nH.text(\"P = \" + Pkpa.toFixed(1) + \" kPa\", w - 200, 52, { color: H.colors.good, size: 15, weight: 700 });\nH.text(\"V = \" + V.toFixed(2) + \" L\", w - 200, 74, { color: H.colors.accent, size: 13 });\nH.text(\"P↑ when V↓ (T, n fixed)\", gx - 90, gy + gr + 40, { color: H.colors.sub, size: 11 });" + }, + { + "id": "ph-lenzs-law", + "area": "Physics", + "topic": "Lenz's law", + "title": "Lenz's law: EMF = -N dPhi/dt", + "equation": "EMF = -N * dPhi/dt (Phi = B * A)", + "keywords": [ + "lenz's law", + "lenz law", + "induced emf", + "faraday's law", + "magnetic flux", + "induced current", + "opposing flux", + "electromagnetic induction", + "coil and magnet", + "flux change", + "dphi/dt", + "back emf" + ], + "explanation": "Push a magnet at a coil and the magnetic flux Phi = B*A threading the loops changes, inducing an EMF = -N*dPhi/dt. The minus sign is Lenz's law: the induced current always flows so its own field OPPOSES the change that made it. So an approaching magnet (rising flux) is repelled, and a retreating one is pulled back. More turns N or a bigger loop area A both raise the induced voltage, and a faster magnet makes dPhi/dt steeper.", + "bullets": [ + "Only a CHANGING flux induces EMF; a stationary magnet gives zero.", + "Lenz's minus sign: induced current opposes the flux change (energy conservation).", + "EMF scales with turns N, area A, field strength B, and how fast the flux changes." + ], + "params": [ + { + "name": "N", + "label": "turns N", + "min": 10, + "max": 500, + "step": 10, + "value": 200 + }, + { + "name": "area", + "label": "loop area A (m^2)", + "min": 0.002, + "max": 0.05, + "step": 0.002, + "value": 0.01 + }, + { + "name": "Bmax", + "label": "magnet strength Bmax (T)", + "min": 0.1, + "max": 1, + "step": 0.05, + "value": 0.5 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst N = Math.max(1, Math.round(P.N));\nconst A = Math.max(0.0005, P.area); // floor only to avoid zero; slider range is 0.002-0.05 m^2\nconst Bm = Math.max(0.05, P.Bmax);\n// Magnet oscillates horizontally toward/away from a fixed coil. Position in meters.\nconst coilX = w * 0.66, coilY = h * 0.55;\nconst xMag = -0.30 * Math.sin(t * 1.1); // meters, magnet center relative to coil (negative = left of coil)\nconst vMag = -0.30 * 1.1 * Math.cos(t * 1.1); // d(xMag)/dt, m/s\nconst gap = (coilX - 70) - Math.abs(xMag) * 280; // pixel distance not used for physics, only drawing\n// Flux through coil: model B at the coil as Bmax * (d0^2)/(d0^2 + dist^2), dist = |xMag|.\nconst d0 = 0.12;\nconst dist = Math.abs(xMag);\nconst Bcoil = Bm * (d0 * d0) / (d0 * d0 + dist * dist);\nconst flux = Bcoil * A; // Wb (per turn)\n// dPhi/dt via chain rule: dB/ddist * ddist/dt ; ddist/dt = sign(xMag)*vMag\nconst dBddist = Bm * (d0 * d0) * (-2 * dist) / Math.pow(d0 * d0 + dist * dist, 2);\nconst ddistdt = (xMag === 0 ? 0 : Math.sign(xMag) * vMag);\nconst dPhidt = dBddist * ddistdt * A;\nconst emf = -N * dPhidt; // volts\n// Draw coil as stacked loops (front view ellipses)\nconst magX = coilX - 150 + xMag * 280; // pixels: magnet drawn left of coil, moves with xMag\nconst magY = coilY;\n// magnet (N red / S blue)\nH.rect(magX - 46, magY - 16, 46, 32, { fill: H.colors.warn });\nH.rect(magX, magY - 16, 46, 32, { fill: H.colors.accent });\nH.text(\"N\", magX - 30, magY + 6, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"S\", magX + 14, magY + 6, { color: H.colors.ink, size: 16, weight: 700 });\n// field arrow from magnet toward coil\nH.arrow(magX + 46, magY, magX + 46 + 64, magY, { color: H.colors.violet, width: 3 });\nH.text(\"B\", magX + 46 + 70, magY - 6, { color: H.colors.violet, size: 14 });\n// coil loops\nfor (let i = 0; i < 6; i++) {\n H.circle(coilX + i * 6, coilY, 46, { stroke: H.colors.good, width: 3 });\n}\nH.line(coilX - 2, coilY - 46, coilX + 34, coilY - 46, { color: H.colors.good, width: 3 });\n// Induced current direction: sign of emf -> arrow around the loop top\nconst approaching = (ddistdt < 0); // distance shrinking -> flux rising\nH.arrow(coilX + 16, coilY - 52, coilX + 16 + (emf >= 0 ? 30 : -30), coilY - 52, { color: H.colors.accent2, width: 3 });\nH.text(\"I_ind\", coilX + 30, coilY - 60, { color: H.colors.accent2, size: 13 });\n// Lenz verdict\nconst verdict = approaching ? \"magnet approaching: flux ↑ → induced I opposes (repels)\" : \"magnet leaving: flux ↓ → induced I sustains (attracts)\";\nH.text(\"Lenz's law: EMF = −N · dΦ/dt\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Φ = \" + flux.toFixed(4) + \" Wb dΦ/dt = \" + dPhidt.toFixed(4) + \" Wb/s EMF = \" + emf.toFixed(3) + \" V\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(verdict, 24, h - 22, { color: H.colors.good, size: 13 });\nH.legend([{ label: \"magnet B\", color: H.colors.violet }, { label: \"coil\", color: H.colors.good }, { label: \"induced I\", color: H.colors.accent2 }], w - 170, 28);" + }, + { + "id": "ph-motional-emf", + "area": "Physics", + "topic": "Motional EMF", + "title": "Motional EMF: e = B L v", + "equation": "EMF = B * L * v (I = EMF / R)", + "keywords": [ + "motional emf", + "moving rod", + "sliding rod", + "rails", + "induced emf", + "magnetic field", + "rod on rails", + "blv", + "b l v", + "force on charge", + "induced current", + "electromagnetic induction", + "qvb" + ], + "explanation": "A rod of length L slides at speed v across rails in a magnetic field B. Each free charge in the rod feels a magnetic force F = qv*B that pushes it along the rod, and that charge separation acts like a battery of voltage EMF = B*L*v. Connect the rails through a resistor R and a current I = EMF/R flows. Speed up the rod, lengthen it, or strengthen B and the induced voltage rises proportionally; the readout flips sign as the rod reverses.", + "bullets": [ + "EMF = B*L*v: it is the magnetic force on charges, F = qv*B, that does the driving.", + "The induced current is I = EMF/R; double v or B and you double the voltage.", + "Reverse the rod's motion and the EMF (and current) reverse direction too." + ], + "params": [ + { + "name": "B", + "label": "field B (T)", + "min": 0, + "max": 1.5, + "step": 0.05, + "value": 0.5 + }, + { + "name": "L", + "label": "rod length L (m)", + "min": 0.1, + "max": 1, + "step": 0.05, + "value": 0.5 + }, + { + "name": "v", + "label": "peak speed v (m/s)", + "min": 0.5, + "max": 6, + "step": 0.5, + "value": 3 + }, + { + "name": "R", + "label": "resistance R (ohm)", + "min": 0.5, + "max": 10, + "step": 0.5, + "value": 2 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst B = Math.max(0, P.B); // tesla\nconst L = Math.max(0.05, P.L); // meters (rail separation)\nconst v0 = Math.max(0, P.v); // m/s peak rod speed\nconst R = Math.max(0.1, P.R); // ohms (circuit resistance)\n// Rod slides right and left along rails, looping (oscillates between rails' ends).\nconst vRod = v0 * Math.cos(t * 1.0); // m/s, signed\nconst xRod = (v0 / 1.0) * Math.sin(t * 1.0); // m, position from center, bounded\nconst emf = B * L * vRod; // volts (motional EMF), signed with velocity\nconst I = emf / R; // amps\n// Drawing geometry: two horizontal rails, rod is a vertical bar between them.\nconst railTop = h * 0.34, railBot = h * 0.66;\nconst xLeft = w * 0.18, xRight = w * 0.82;\nconst cx = (xLeft + xRight) / 2;\nconst spanPx = (xRight - xLeft) * 0.42;\nconst rodPx = cx + xRod * (spanPx / Math.max(0.1, v0 / 1.0)); // map bounded x to pixels, kept in span\nconst rx = H.clamp(rodPx, xLeft + 8, xRight - 8);\n// Field region: dots = B out of page\nfor (let i = 0; i < 7; i++) for (let j = 0; j < 3; j++) {\n H.circle(xLeft + 30 + i * (xRight - xLeft - 60) / 6, railTop + 18 + j * (railBot - railTop - 36) / 2, 2.5, { fill: H.colors.violet });\n}\nH.text(\"B out of page\", xLeft, railTop - 12, { color: H.colors.violet, size: 12 });\n// rails\nH.line(xLeft, railTop, xRight, railTop, { color: H.colors.axis, width: 3 });\nH.line(xLeft, railBot, xRight, railBot, { color: H.colors.axis, width: 3 });\n// resistor on the left end\nH.rect(xLeft - 4, railTop, 8, railBot - railTop, { fill: H.colors.panel, stroke: H.colors.good, width: 2 });\nH.text(\"R\", xLeft - 22, (railTop + railBot) / 2, { color: H.colors.good, size: 14 });\n// the moving rod\nH.line(rx, railTop, rx, railBot, { color: H.colors.accent, width: 5 });\n// velocity arrow on rod\nconst dir = vRod >= 0 ? 1 : -1;\nH.arrow(rx, (railTop + railBot) / 2, rx + dir * 46, (railTop + railBot) / 2, { color: H.colors.accent2, width: 3 });\nH.text(\"v\", rx + dir * 52 - (dir < 0 ? 14 : 0), (railTop + railBot) / 2 - 8, { color: H.colors.accent2, size: 14 });\n// force on a + charge in rod: F = qv×B -> drives current; show along rod\nH.arrow(rx + 8, (railTop + railBot) / 2, rx + 8, railTop + 14, { color: H.colors.good, width: 2, head: 7 });\nH.text(\"F = qv×B\", rx + 14, railTop + 24, { color: H.colors.good, size: 11 });\nH.text(\"Motional EMF: ε = B · L · v\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"B = \" + B.toFixed(2) + \" T L = \" + L.toFixed(2) + \" m v = \" + vRod.toFixed(2) + \" m/s\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"ε = \" + emf.toFixed(3) + \" V I = ε/R = \" + I.toFixed(3) + \" A\", 24, h - 22, { color: H.colors.good, size: 14 });\nH.legend([{ label: \"rod & v\", color: H.colors.accent2 }, { label: \"rails / R\", color: H.colors.good }, { label: \"B field\", color: H.colors.violet }], w - 170, 28);" + }, + { + "id": "ph-transformers", + "area": "Physics", + "topic": "Transformers", + "title": "Transformer: Vs/Vp = Ns/Np", + "equation": "Vs / Vp = Ns / Np (ideal: Vp * Ip = Vs * Is)", + "keywords": [ + "transformer", + "turns ratio", + "step up", + "step down", + "primary secondary", + "vs/vp = ns/np", + "mutual induction", + "ac voltage", + "windings", + "voltage transformation", + "iron core", + "induced voltage" + ], + "explanation": "An AC voltage on the primary coil (Np turns) drives a changing flux around a shared iron core, and that same flux links every turn of the secondary coil (Ns turns). Because each turn sees the same dPhi/dt, the voltages scale with the turn counts: Vs/Vp = Ns/Np. More secondary turns than primary steps the voltage UP (ratio > 1); fewer steps it DOWN. An ideal transformer conserves power, so the current trades off inversely with the voltage.", + "bullets": [ + "Vs/Vp = Ns/Np: the same core flux links both coils, so voltage follows the turns.", + "Ns > Np steps up; Ns < Np steps down; Ns = Np is 1:1 isolation.", + "Transformers only work on AC (changing flux); ideally Vp*Ip = Vs*Is, so power is conserved." + ], + "params": [ + { + "name": "Vp", + "label": "primary voltage Vp (V)", + "min": 5, + "max": 240, + "step": 5, + "value": 120 + }, + { + "name": "Np", + "label": "primary turns Np", + "min": 10, + "max": 500, + "step": 10, + "value": 100 + }, + { + "name": "Ns", + "label": "secondary turns Ns", + "min": 10, + "max": 500, + "step": 10, + "value": 300 + } + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst Vp = Math.max(1, P.Vp); // primary rms voltage, volts\nconst Np = Math.max(1, Math.round(P.Np)); // primary turns\nconst Ns = Math.max(1, Math.round(P.Ns)); // secondary turns\nconst ratio = Ns / Np;\nconst Vs = Vp * ratio; // ideal transformer: Vs = Vp * Ns/Np\n// AC waveforms (instantaneous), peak = rms*sqrt(2), oscillate with t -> looping\nconst f = 0.6;\nconst vp_inst = Vp * Math.SQRT2 * Math.sin(t * f * H.TAU / 1.0 * 0.5 + 0);\nconst vs_inst = Vs * Math.SQRT2 * Math.sin(t * f * H.TAU / 1.0 * 0.5 + 0);\n// Core (two vertical bars + top/bottom yokes)\nconst coreL = w * 0.40, coreR = w * 0.60, coreT = h * 0.30, coreB = h * 0.78;\nH.rect(coreL - 10, coreT - 10, 20, coreB - coreT + 20, { fill: H.colors.panel, stroke: H.colors.axis, width: 2 });\nH.rect(coreR - 10, coreT - 10, 20, coreB - coreT + 20, { fill: H.colors.panel, stroke: H.colors.axis, width: 2 });\nH.rect(coreL - 10, coreT - 10, coreR - coreL + 20, 18, { fill: H.colors.panel, stroke: H.colors.axis, width: 2 });\nH.rect(coreL - 10, coreB - 8, coreR - coreL + 20, 18, { fill: H.colors.panel, stroke: H.colors.axis, width: 2 });\n// oscillating flux brightness in core (Faraday: same Φ links both coils)\nconst fluxGlow = Math.abs(Math.sin(t * f * H.TAU * 0.5));\nH.text(\"Φ\", (coreL + coreR) / 2 - 6, coreT + 6, { color: H.hsl(45, 90, 40 + 40 * fluxGlow), size: 16, weight: 700 });\n// windings — loop COUNTS reflect the turns ratio (larger side capped at 14)\nconst Nmax = Math.max(Np, Ns);\nconst drawCap = 14;\nconst npDraw = Math.max(1, Math.round(Np / Nmax * drawCap));\nconst nsDraw = Math.max(1, Math.round(Ns / Nmax * drawCap));\nfor (let i = 0; i < npDraw; i++) {\n const yy = coreT + 14 + i * (coreB - coreT - 28) / Math.max(1, npDraw);\n H.circle(coreL - 10, yy, 9, { stroke: H.colors.accent, width: 2.5 });\n}\nfor (let i = 0; i < nsDraw; i++) {\n const yy = coreT + 14 + i * (coreB - coreT - 28) / Math.max(1, nsDraw);\n H.circle(coreR + 10, yy, 9, { stroke: H.colors.accent2, width: 2.5 });\n}\n// AC source on primary, lamp/load on secondary\nconst sx = w * 0.16, lx = w * 0.84, midY = (coreT + coreB) / 2;\nH.circle(sx, midY, 22, { stroke: H.colors.accent, width: 3 });\nH.text(\"~\", sx - 5, midY + 7, { color: H.colors.accent, size: 22, weight: 700 });\nH.arrow(sx + 22, midY - vp_inst / (Vp * Math.SQRT2) * 28, coreL - 24, midY - vp_inst / (Vp * Math.SQRT2) * 28, { color: H.colors.accent, width: 2, head: 7 });\nH.circle(lx, midY, 16, { stroke: H.colors.accent2, width: 3 });\nH.text(\"Vp\", sx - 12, midY + 44, { color: H.colors.accent, size: 13 });\nH.text(\"Vs\", lx - 12, midY + 44, { color: H.colors.accent2, size: 13 });\n// little bar gauges for instantaneous voltage — shared px/volt scale so the\n// taller side (Vp or Vs) fills ~70px and the amplitude RATIO stays visible\nconst vMaxPeak = Math.max(Vp, Vs) * Math.SQRT2;\nconst gscale = 70 / vMaxPeak;\nH.line(sx - 20, midY + 56, sx - 20 + vp_inst * gscale, midY + 56, { color: H.colors.accent, width: 5 });\nH.line(lx - 20, midY + 56, lx - 20 + vs_inst * gscale, midY + 56, { color: H.colors.accent2, width: 5 });\nconst kind = ratio > 1 ? \"step-up\" : ratio < 1 ? \"step-down\" : \"isolation (1:1)\";\nH.text(\"Transformer: Vs / Vp = Ns / Np\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Np = \" + Np + \" Ns = \" + Ns + \" ratio Ns/Np = \" + ratio.toFixed(2) + \" (\" + kind + \")\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Vp = \" + Vp.toFixed(1) + \" V → Vs = \" + Vs.toFixed(1) + \" V (v_s now = \" + vs_inst.toFixed(1) + \" V)\", 24, h - 22, { color: H.colors.good, size: 14 });\nH.legend([{ label: \"primary\", color: H.colors.accent }, { label: \"secondary\", color: H.colors.accent2 }], w - 170, 28);" + }, + { + "id": "ph-standing-waves-pipes-strings", + "area": "Physics", + "topic": "Standing waves in pipes and strings", + "title": "Standing waves: f_n = n v / (2L)", + "equation": "f_n = n v / (2L), lambda_n = 2L / n", + "keywords": [ + "standing wave", + "string", + "pipe", + "harmonic", + "overtone", + "node", + "antinode", + "fundamental frequency", + "resonance", + "f = n v / 2L", + "wavelength", + "musical instrument", + "modes" + ], + "explanation": "A string fixed at both ends can only vibrate in whole-number patterns called harmonics: the nth harmonic fits exactly n half-wavelengths between the ends. Slide n to step through the modes and watch the nodes (fixed points, where the string never moves) multiply. The length L and wave speed v set the frequency f_n = n v / (2L) — longer or heavier strings (slower v) sound lower, which is exactly how instruments are tuned.", + "bullets": [ + "Only whole-number harmonics fit: the nth mode has n half-wavelengths and n+1 nodes.", + "lambda_n = 2L/n, so f_n = n v / (2L) — the frequencies are integer multiples of the fundamental.", + "Shorter L or higher wave speed v raises the pitch; this is how strings and pipes are tuned." + ], + "params": [ + { + "name": "n", + "label": "harmonic n", + "min": 1, + "max": 6, + "step": 1, + "value": 3 + }, + { + "name": "L", + "label": "length L (m)", + "min": 2, + "max": 8, + "step": 0.5, + "value": 6 + }, + { + "name": "v", + "label": "wave speed v (m/s)", + "min": 1, + "max": 10, + "step": 0.5, + "value": 4 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: -2.6, yMax: 2.6 });\nv.grid(); v.axes();\nconst n = Math.max(1, Math.round(P.n)); // harmonic number\nconst L = Math.max(1, P.L); // string length (m)\nconst speed = Math.max(0.1, P.v); // wave speed (m/s)\nconst wavelength = 2 * L / n; // lambda_n = 2L/n\nconst freq = speed / wavelength; // f_n = n v / (2L)\nconst k = n * Math.PI / L; // sin(kx), node spacing L/n\nconst A = 2;\n// y(x,t) = A sin(kx) cos(wt); a gentle VISUAL rate so the pattern breathes\nconst osc = Math.cos(t * 2.2);\nconst pts = [];\nfor (let i = 0; i <= 120; i++) { const x = L * i / 120; pts.push([x, A * Math.sin(k * x) * osc]); }\nv.path(pts, { color: H.colors.accent, width: 3 });\n// the two envelopes the string oscillates between (dashed)\nconst eup = [], edn = [];\nfor (let i = 0; i <= 120; i++) { const x = L * i / 120; const y = A * Math.sin(k * x); eup.push([x, y]); edn.push([x, -y]); }\nv.path(eup, { color: H.colors.sub, width: 1, dash: [4, 4] });\nv.path(edn, { color: H.colors.sub, width: 1, dash: [4, 4] });\n// nodes (fixed points where sin(kx)=0): x = m L / n\nfor (let m = 0; m <= n; m++) v.dot(m * L / n, 0, { r: 5, fill: H.colors.warn });\n// fixed ends of the string\nv.line(0, -A, 0, A, { color: H.colors.violet, width: 3 });\nv.line(L, -A, L, A, { color: H.colors.violet, width: 3 });\nH.text(\"Standing wave on a string (fixed ends)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"n = \" + n + \" L = \" + L.toFixed(1) + \" m lambda = \" + wavelength.toFixed(2) + \" m f = \" + freq.toFixed(2) + \" Hz (\" + (n + 1) + \" nodes)\", 24, 52, { color: H.colors.sub, size: 12 });\nH.legend([{ label: \"string\", color: H.colors.accent }, { label: \"nodes\", color: H.colors.warn }, { label: \"fixed end\", color: H.colors.violet }], H.W - 170, 28);" + } +] \ No newline at end of file diff --git a/render.yaml b/render.yaml index e6fb632..ef16936 100644 --- a/render.yaml +++ b/render.yaml @@ -8,6 +8,9 @@ services: name: visuallm runtime: docker plan: free + # Deploy the branch that has the full feature set (chemistry, demos, + # physics). Change to main once this branch is merged. + branch: parameterized-demos healthCheckPath: /api/health envVars: - key: ANTHROPIC_API_KEY diff --git a/requirements.txt b/requirements.txt index e262c52..262d62f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,9 @@ -# VisualLM uses only the Python standard library to run the server. -# The Anthropic SDK is optional but strongly recommended — it powers the -# high-quality animation generation. Without it (and an API key) the app -# falls back to a local Ollama model. +# VisualLM runs its server on the Python standard library alone — the AI relies +# only on code. The Anthropic SDK below is optional but recommended: with an +# ANTHROPIC_API_KEY it generates animations via Claude. With NO key, the app +# still works fully on its built-in pure-code library (interactive demos, 3D +# chemistry, and the step-by-step solver). No local model app (Ollama) is used. +# +# For a true native desktop window, also install pywebview (see install.sh / +# install.bat, or run: pip install pywebview). anthropic>=0.40.0 diff --git a/sandbox-worker.js b/sandbox-worker.js index 85eb0a2..9527aa6 100644 --- a/sandbox-worker.js +++ b/sandbox-worker.js @@ -58,6 +58,7 @@ let sceneFn = null; let running = false; let paused = false; let speed = 1; +let suspended = false; // true when the tab is hidden — stop drawing to save CPU // User-driven camera orbit, applied on top of whatever yaw/pitch the scene // sets. Updated by "orbit" messages from the main thread (canvas drag/wheel), @@ -75,6 +76,7 @@ let frameCount = 0; let consecutiveErrors = 0; let everRendered = false; let lastCode = ""; // raw source of the running scene, for offending-line lookup +let curParams = {}; // user-set demo parameters, exposed to the scene as `P` let loopTimer = null; const FRAME_MS = 1000 / 60; @@ -89,31 +91,39 @@ function post(msg) { const TAU = Math.PI * 2; -const COLORS = { - bg: "#0e1525", - panel: "#16203a", - ink: "#eef2ff", - sub: "#9fb0d4", - grid: "#26314f", - axis: "#566087", - accent: "#7cc4ff", - accent2: "#f4a259", - good: "#67e8b0", - warn: "#ff8aa0", - violet: "#c4a7ff", - yellow: "#ffe08a", +// Two palettes so the animation canvas can follow the app's light/dark toggle. +// COLORS and PALETTE are mutated IN PLACE by applyTheme() — their object/array +// identity never changes, so `H.colors`/`H.palette` (and any running scene that +// reads them each frame) pick up a theme switch live, with no recompile. +const THEMES = { + dark: { + bg: "#0e1525", bgTop: "#101a31", panel: "#16203a", + ink: "#eef2ff", sub: "#9fb0d4", grid: "#26314f", axis: "#566087", + accent: "#7cc4ff", accent2: "#f4a259", good: "#67e8b0", warn: "#ff8aa0", + violet: "#c4a7ff", yellow: "#ffe08a", quadEdge: "rgba(10,14,30,0.35)", + palette: ["#7cc4ff", "#f4a259", "#67e8b0", "#c4a7ff", "#ff8aa0", "#ffe08a", "#5eead4", "#fca5f1"], + }, + light: { + bg: "#f4f6fb", bgTop: "#ffffff", panel: "#e7ecf6", + ink: "#16203a", sub: "#4a5578", grid: "#d4dbea", axis: "#9aa6c4", + accent: "#2f7fd1", accent2: "#d9772b", good: "#1aa06a", warn: "#e0506a", + violet: "#8b5cf6", yellow: "#c79214", quadEdge: "rgba(40,50,80,0.22)", + palette: ["#2f7fd1", "#d9772b", "#1aa06a", "#8b5cf6", "#e0506a", "#c79214", "#0e9aa7", "#d83f87"], + }, }; -const PALETTE = [ - "#7cc4ff", - "#f4a259", - "#67e8b0", - "#c4a7ff", - "#ff8aa0", - "#ffe08a", - "#5eead4", - "#fca5f1", -]; +const COLORS = {}; +const PALETTE = []; +let THEME_NAME = "dark"; +function applyTheme(name) { + THEME_NAME = name === "light" ? "light" : "dark"; + const t = THEMES[THEME_NAME]; + for (const k in COLORS) delete COLORS[k]; + for (const k in t) if (k !== "palette") COLORS[k] = t[k]; + PALETTE.length = 0; + for (const c of t.palette) PALETTE.push(c); +} +applyTheme("dark"); function clamp(x, lo, hi) { return x < lo ? lo : x > hi ? hi : x; @@ -136,6 +146,9 @@ function makeHelpers() { PI: Math.PI, colors: COLORS, palette: PALETTE, + get theme() { + return THEME_NAME; + }, clamp, lerp, map, @@ -154,7 +167,7 @@ function makeHelpers() { }, background(top, bottom) { const g = ctx.createLinearGradient(0, 0, 0, logicalH); - g.addColorStop(0, top || "#101a31"); + g.addColorStop(0, top || COLORS.bgTop); g.addColorStop(1, bottom || COLORS.bg); ctx.save(); ctx.fillStyle = g; @@ -826,7 +839,7 @@ function drawShadedQuads(cam, quads, opts) { ctx.fillStyle = fill; ctx.fill(); if (wire) { - ctx.strokeStyle = "rgba(10, 14, 30, 0.35)"; + ctx.strokeStyle = COLORS.quadEdge; ctx.lineWidth = 0.7; ctx.stroke(); } @@ -902,6 +915,7 @@ function compile(code) { // // ctx is kept as a parameter (we never see the model redeclare it). self.H = HELP; + self.P = curParams; // demo parameters; a scene reads P., updated live /* eslint-disable no-new-func */ const factory = new Function( "ctx", @@ -980,7 +994,17 @@ function tick() { post({ type: "heartbeat", frame: frameCount, t: simTime }); } - loopTimer = setTimeout(tick, FRAME_MS); + // Suspended (tab hidden): stop the loop entirely instead of redrawing into a + // canvas nobody can see. The "visible" message restarts it. + if (suspended) { + loopTimer = null; + return; + } + // Self-correcting schedule: aim for a fixed ~16.7ms PERIOD, not 16.7ms AFTER + // the work. Without this, a scene that takes 10ms to draw runs at ~37fps + // (10 + 16.7) instead of 60. Subtract the time this frame already spent. + const work = performance.now() - now; + loopTimer = setTimeout(tick, Math.max(0, FRAME_MS - work)); } /* ------------------------------------------------------------------ */ @@ -999,6 +1023,7 @@ self.onmessage = (e) => { canvas.height = Math.round(logicalH * dpr); ctx = canvas.getContext("2d"); ctx.scale(dpr, dpr); + applyTheme(m.theme || THEME_NAME); HELP = wrapHelpers(makeHelpers()); seedConvenienceGlobals(); post({ type: "ready" }); @@ -1021,6 +1046,8 @@ self.onmessage = (e) => { } case "run": { try { + curParams = m.params && typeof m.params === "object" ? m.params : {}; + if (m.theme) applyTheme(m.theme); const fn = compile(m.code); sceneFn = fn; lastCode = typeof m.code === "string" ? m.code : ""; @@ -1058,6 +1085,24 @@ self.onmessage = (e) => { case "speed": speed = m.value; break; + case "visible": + // Tab visibility from the main thread. Hidden -> stop the loop (don't + // redraw an invisible canvas). Visible -> restart, resetting the wall + // clock so the paused gap doesn't jump simTime forward. + suspended = !m.value; + if (!suspended && running && !loopTimer) { + lastWall = performance.now(); + tick(); + } + break; + case "params": + // Live demo-parameter update from the UI sliders. Mutate in place so the + // running scene (which reads the `P` global each frame) sees new values + // immediately — no recompile, no flicker. + if (m.values && typeof m.values === "object") { + for (const k in m.values) curParams[k] = m.values[k]; + } + break; case "orbit": // Camera drag/zoom from the main thread. Applied inside cam3d.project, // so it affects every 3D scene without the generated code's cooperation. @@ -1070,6 +1115,11 @@ self.onmessage = (e) => { orbit.pitch = 0; orbit.zoom = 1; break; + case "theme": + // Live theme switch from the main thread. Mutates COLORS/PALETTE in place + // so the running scene redraws in the new palette next frame — no recompile. + if (m.theme) applyTheme(m.theme); + break; case "stop": running = false; if (loopTimer) clearTimeout(loopTimer); diff --git a/scene_library.py b/scene_library.py index a0fcb4f..034126c 100644 --- a/scene_library.py +++ b/scene_library.py @@ -106,7 +106,8 @@ "summary": "A launched projectile follows a parabola; horizontal speed is constant, vertical speed changes under gravity.", "keywords": [ "projectile", "projectile motion", "parabola", "trajectory", "launch", - "gravity", "kinematics", "ballistic", "range", "velocity", + "launched", "gravity", "kinematics", "ballistic", "range", "velocity", + "angle", "degrees", "thrown", "ball", ], "bullets": [ "Horizontal velocity stays constant; only gravity acts vertically.", @@ -343,7 +344,7 @@ "summary": "Two sugar-phosphate backbones twist around a common axis, joined by base pairs.", "keywords": [ "dna", "double helix", "helix", "genetics", "base pairs", "nucleotide", - "molecular biology", "strands", "chromosome", + "molecular biology", "strands", "strand", "chromosome", "structure", ], "bullets": [ "Two strands run in opposite (antiparallel) directions.", diff --git a/scene_library_generated.json b/scene_library_generated.json index c0bbe1b..18f916a 100644 --- a/scene_library_generated.json +++ b/scene_library_generated.json @@ -586,7 +586,7 @@ "If I doubled the temperature, how would the typical atom speed change?", "Why does the gas stay evenly spread between the two halves instead of clumping?" ], - "code": "H.background();\n// ---- Ideal gas: N atoms bouncing elastically inside a 3D box ----\nconst cam = H.cam3d({ scale: 30, dist: 16, pitch: -0.35, cy: H.H * 0.52 });\ncam.yaw = 0.25 * t; // slow auto-spin so it reads as 3D\nconst L = 3.2; // half-box size (world units)\n// Deterministic pseudo-random per-particle parameters (pure function of t-free\n// seeds) so motion is smooth and reproducible each frame.\nconst N = 28;\nconst rand = (i, k) => {\n const s = Math.sin(i * 12.9898 + k * 78.233) * 43758.5453;\n return s - Math.floor(s); // 0..1\n};\n// Continuous triangle wave: a particle bouncing elastically between walls at\n// -lim and +lim, given a linearly advancing argument.\nconst tri = (val, lim) => {\n // continuous triangle wave bouncing in [-lim, lim] given a linear val\n const span = 2 * lim;\n let m = ((val % (2 * span)) + 2 * span) % (2 * span); // 0..2span\n if (m > span) m = 2 * span - m; // 0..span\n return m - lim; // -lim..lim\n};\n// Draw box wireframe (depth-sorted edges look fine as plain lines)\nconst c = [\n [-L, -L, -L], [L, -L, -L], [L, L, -L], [-L, L, -L],\n [-L, -L, L], [L, -L, L], [L, L, L], [-L, L, L],\n];\nconst edges = [\n [0,1],[1,2],[2,3],[3,0], [4,5],[5,6],[6,7],[7,4], [0,4],[1,5],[2,6],[3,7],\n];\ncam.grid(L, L, { color: H.colors.grid });\nedges.forEach((e) => cam.line(c[e[0]], c[e[1]], { color: H.colors.axis, width: 1.3 }));\n// Particle positions + speeds\nconst r = 0.16;\nlet vSum = 0, vMax = 0, leftCount = 0;\nconst balls = [];\nfor (let i = 0; i < N; i++) {\n const sx = 0.4 + rand(i, 1) * 0.9; // speed components (world units / s)\n const sy = 0.4 + rand(i, 2) * 0.9;\n const sz = 0.4 + rand(i, 3) * 0.9;\n const ph = rand(i, 4) * 100; // phase offset\n const x = tri(sx * t + ph, L - r);\n const y = tri(sy * t + ph * 1.3, L - r);\n const z = tri(sz * t + ph * 0.7, L - r);\n const speed = Math.sqrt(sx * sx + sy * sy + sz * sz);\n vSum += speed;\n if (speed > vMax) vMax = speed;\n if (x < 0) leftCount++; // instantaneous count in the x<0 half\n // color warm = fast, cool = slow\n const frac = H.clamp((speed - 0.7) / 1.6, 0, 1);\n const col = H.hsl(H.lerp(205, 25, frac), 85, H.lerp(58, 60, frac));\n balls.push({ p: [x, y, z], r: r, col: col, depth: cam.project([x, y, z]).depth });\n}\nballs.sort((a, b) => b.depth - a.depth).forEach((o) => cam.sphere(o.p, o.r, { color: o.col }));\n// Faint divider plane at x=0 to make the left/right count meaningful.\ncam.line([0, -L, -L], [0, -L, L], { color: H.colors.violet, width: 1 });\ncam.line([0, -L, -L], [0, L, -L], { color: H.colors.violet, width: 1 });\ncam.axes(L + 0.6);\nconst vAvg = vSum / N;\nconst rightCount = N - leftCount;\n// ---- Title + live readout ----\nH.text(\"Ideal gas in a box\", 24, 34, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"N atoms in constant elastic motion — temperature ∝ mean kinetic energy\", 24, 56, { color: H.colors.sub, size: 13 });\nH.text(\"N = \" + N + \" atoms\", 24, H.H - 100, { color: H.colors.accent, size: 13 });\nH.text(\"⟨v⟩ = \" + vAvg.toFixed(3) + \" (arb.)\", 24, H.H - 78, { color: H.colors.ink, size: 14, weight: 700 });\nH.text(\"T ∝ ⟨v²⟩ → \" + (vAvg * vAvg).toFixed(3), 24, H.H - 56, { color: H.colors.accent2, size: 13 });\nH.text(\"left | right = \" + leftCount + \" | \" + rightCount + \" (t = \" + (t % 100).toFixed(1) + \" s)\", 24, H.H - 34, { color: H.colors.violet, size: 13 });\nH.legend([\n { label: \"fast (hot)\", color: H.hsl(25, 85, 60) },\n { label: \"slow (cool)\", color: H.hsl(205, 85, 58) },\n], H.W - 150, H.H - 70);" + "code": "H.background();\n// ---- Ideal gas: N atoms bouncing elastically inside a 3D box ----\nconst cam = H.cam3d({ scale: 30, dist: 16, pitch: -0.35, cy: H.H * 0.52 });\ncam.yaw = 0.25 * t; // slow auto-spin so it reads as 3D\nconst L = 3.2; // half-box size (world units)\n// Deterministic pseudo-random per-particle parameters (pure function of t-free\n// seeds) so motion is smooth and reproducible each frame.\nconst N = 28;\nconst rand = (i, k) => {\n const s = Math.sin(i * 12.9898 + k * 78.233) * 43758.5453;\n return s - Math.floor(s); // 0..1\n};\n// Continuous triangle wave: a particle bouncing elastically between walls at\n// -lim and +lim, given a linearly advancing argument.\nconst tri = (val, lim) => {\n // continuous triangle wave bouncing in [-lim, lim] given a linear val\n const span = 2 * lim;\n let m = ((val % (2 * span)) + 2 * span) % (2 * span); // 0..2span\n if (m > span) m = 2 * span - m; // 0..span\n return m - lim; // -lim..lim\n};\n// Draw box wireframe (depth-sorted edges look fine as plain lines)\nconst c = [\n [-L, -L, -L], [L, -L, -L], [L, L, -L], [-L, L, -L],\n [-L, -L, L], [L, -L, L], [L, L, L], [-L, L, L],\n];\nconst edges = [\n [0,1],[1,2],[2,3],[3,0], [4,5],[5,6],[6,7],[7,4], [0,4],[1,5],[2,6],[3,7],\n];\ncam.grid(L, L, { color: H.colors.grid });\nedges.forEach((e) => cam.line(c[e[0]], c[e[1]], { color: H.colors.axis, width: 1.3 }));\n// Particle positions + speeds\nconst r = 0.16;\nlet vSum = 0, v2Sum = 0, vMax = 0, leftCount = 0;\nconst balls = [];\nfor (let i = 0; i < N; i++) {\n const sx = 0.4 + rand(i, 1) * 0.9; // speed components (world units / s)\n const sy = 0.4 + rand(i, 2) * 0.9;\n const sz = 0.4 + rand(i, 3) * 0.9;\n const ph = rand(i, 4) * 100; // phase offset\n const x = tri(sx * t + ph, L - r);\n const y = tri(sy * t + ph * 1.3, L - r);\n const z = tri(sz * t + ph * 0.7, L - r);\n const speed = Math.sqrt(sx * sx + sy * sy + sz * sz);\n vSum += speed;\n v2Sum += speed * speed;\n if (speed > vMax) vMax = speed;\n if (x < 0) leftCount++; // instantaneous count in the x<0 half\n // color warm = fast, cool = slow\n const frac = H.clamp((speed - 0.7) / 1.6, 0, 1);\n const col = H.hsl(H.lerp(205, 25, frac), 85, H.lerp(58, 60, frac));\n balls.push({ p: [x, y, z], r: r, col: col, depth: cam.project([x, y, z]).depth });\n}\nballs.sort((a, b) => b.depth - a.depth).forEach((o) => cam.sphere(o.p, o.r, { color: o.col }));\n// Faint divider plane at x=0 to make the left/right count meaningful.\ncam.line([0, -L, -L], [0, -L, L], { color: H.colors.violet, width: 1 });\ncam.line([0, -L, -L], [0, L, -L], { color: H.colors.violet, width: 1 });\ncam.axes(L + 0.6);\nconst vAvg = vSum / N;\nconst v2Avg = v2Sum / N; // mean square speed ⟨v²⟩ (this sets T, not ⟨v⟩²)\nconst rightCount = N - leftCount;\n// ---- Title + live readout ----\nH.text(\"Ideal gas in a box\", 24, 34, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"N atoms in constant elastic motion — temperature ∝ mean kinetic energy\", 24, 56, { color: H.colors.sub, size: 13 });\nH.text(\"N = \" + N + \" atoms\", 24, H.H - 100, { color: H.colors.accent, size: 13 });\nH.text(\"⟨v⟩ = \" + vAvg.toFixed(3) + \" (arb.)\", 24, H.H - 78, { color: H.colors.ink, size: 14, weight: 700 });\nH.text(\"T ∝ ⟨v²⟩ → \" + v2Avg.toFixed(3), 24, H.H - 56, { color: H.colors.accent2, size: 13 });\nH.text(\"left | right = \" + leftCount + \" | \" + rightCount + \" (t = \" + (t % 100).toFixed(1) + \" s)\", 24, H.H - 34, { color: H.colors.violet, size: 13 });\nH.legend([\n { label: \"fast (hot)\", color: H.hsl(25, 85, 60) },\n { label: \"slow (cool)\", color: H.hsl(205, 85, 58) },\n], H.W - 150, H.H - 70);" }, { "id": "maxwell-boltzmann-speed-distribution", @@ -1456,4 +1456,4 @@ ], "code": "H.background();\nconst w = H.W, h = H.H;\n\nconst a = 2.4 + 1.1 * (0.5 + 0.5 * Math.sin(t * 0.6));\nconst b = 3.4 + 1.0 * (0.5 + 0.5 * Math.cos(t * 0.45));\nconst c = Math.sqrt(a * a + b * b);\n\nconst v = H.plot2d({\n box: { x: 60, y: 70, w: w - 120, h: h - 150 },\n xMin: -c - 1, xMax: b + 1.5, yMin: -1.5, yMax: a + c + 1,\n});\nv.grid({ stepX: 2, stepY: 2 });\nv.axes({ stepX: 2, stepY: 2 });\n\nconst A = [0, 0], B = [b, 0], C = [0, a];\n\nv.path([[0, 0], [b, 0], [b, -b], [0, -b]],\n { color: H.colors.accent2, width: 2, fill: \"rgba(244,162,89,0.18)\", close: true });\nv.text(\"b^2 = \" + (b * b).toFixed(2), b / 2, -b / 2, { color: H.colors.accent2, size: 13, align: \"center\" });\n\nv.path([[0, 0], [0, a], [-a, a], [-a, 0]],\n { color: H.colors.good, width: 2, fill: \"rgba(103,232,176,0.18)\", close: true });\nv.text(\"a^2 = \" + (a * a).toFixed(2), -a / 2, a / 2, { color: H.colors.good, size: 13, align: \"center\" });\n\nconst dx = (B[0] - C[0]) / c, dy = (B[1] - C[1]) / c;\nconst nx = -dy, ny = dx;\nconst hSq = [\n [C[0], C[1]],\n [B[0], B[1]],\n [B[0] + nx * c, B[1] + ny * c],\n [C[0] + nx * c, C[1] + ny * c],\n];\nv.path(hSq, { color: H.colors.violet, width: 2, fill: \"rgba(196,167,255,0.18)\", close: true });\nv.text(\"c^2 = \" + (c * c).toFixed(2),\n (hSq[0][0] + hSq[2][0]) / 2, (hSq[0][1] + hSq[2][1]) / 2,\n { color: H.colors.violet, size: 13, align: \"center\" });\n\nv.path([A, B, C], { color: H.colors.ink, width: 3, fill: \"rgba(124,196,255,0.22)\", close: true });\n\nv.path([[0.32, 0], [0.32, 0.32], [0, 0.32]], { color: H.colors.sub, width: 1.6 });\n\nv.text(\"a = \" + a.toFixed(2), -0.45, a / 2, { color: H.colors.good, size: 13, align: \"right\" });\nv.text(\"b = \" + b.toFixed(2), b / 2, 0.35, { color: H.colors.accent2, size: 13, align: \"center\" });\nv.text(\"c = \" + c.toFixed(2), (C[0] + B[0]) / 2 + 0.25, (C[1] + B[1]) / 2 + 0.1, { color: H.colors.violet, size: 13 });\n\nconst s = 0.5 + 0.5 * Math.sin(t * 1.2);\nv.dot(C[0] + (B[0] - C[0]) * s, C[1] + (B[1] - C[1]) * s, { r: 5, fill: H.colors.warn });\n\nH.text(\"Pythagorean Theorem - squares on the sides\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"The two leg-squares' areas always sum to the hypotenuse-square's area.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"a^2 + b^2 = \" + (a * a).toFixed(2) + \" + \" + (b * b).toFixed(2) + \" = \" + (a * a + b * b).toFixed(2),\n 24, h - 52, { color: H.colors.ink, size: 14 });\nH.text(\"c^2 = \" + (c * c).toFixed(2) + \" (match!)\", 24, h - 30, { color: H.colors.good, size: 14, weight: 700 });\n\nH.legend([\n { label: \"a^2 (leg)\", color: H.colors.good },\n { label: \"b^2 (leg)\", color: H.colors.accent2 },\n { label: \"c^2 (hypotenuse)\", color: H.colors.violet },\n], w - 220, 92);" } -] \ No newline at end of file +] diff --git a/solver.py b/solver.py new file mode 100644 index 0000000..f548113 --- /dev/null +++ b/solver.py @@ -0,0 +1,598 @@ +"""Step-by-step worked-solution engine for VisualLM. + +When a prompt is a SPECIFIC problem (actual numbers + labels) rather than a topic +name, this produces an animated *slide deck* that walks through solving it +gradually, with the student's own numbers — the "teach the process, show every +step" mode that complements the topic demos (which teach the general method). + +First solver: triangles. Parse the given sides/angles, classify the case +(SAS / SSS / ASA / AAS / SSA), solve every unknown (handling the ambiguous SSA +case that yields 0, 1, or 2 triangles), and emit a scene that steps through the +worked solution — drawing the triangle to scale and revealing each computation. + +Pure standard library. The emitted `code` is a bare scene(ctx, t) body using the +worker's `H` helpers; slides auto-advance over time and loop. +""" + +from __future__ import annotations + +import json +import math +import re + +# --------------------------------------------------------------------------- # +# Parsing a triangle problem +# --------------------------------------------------------------------------- # + +# "angle A = 40", "angle of a = 40", "A = 40 deg" -> angle (key upper-case) +# The \b before "angle" stops "tri{angle}", "rect{angle}" from matching. +_ANGLE_RE = re.compile(r"\bangle\s+(?:of\s+)?([abcABC])\s*=\s*(-?\d+(?:\.\d+)?)", re.I) +# "side a = 12", "a = 12", "b =14" -> side (key lower-case) +_SIDE_RE = re.compile(r"(?:side\s+)?\b([abcABC])\s*=\s*(-?\d+(?:\.\d+)?)") + + +def parse_triangle(prompt: str): + """Extract given triangle data -> {'a':..,'A':..} (sides lower, angles upper), + or None if it isn't a numeric triangle problem. Angles are in degrees.""" + if not prompt: + return None + text = prompt.strip() + given: dict[str, float] = {} + consumed = [] # (start, end) spans already taken by an angle match + + # Angles first (they carry the word "angle", so they're unambiguous). + for m in _ANGLE_RE.finditer(text): + given[m.group(1).upper()] = float(m.group(2)) + consumed.append((m.start(), m.end())) + + # Sides: bare "x = n". Skip spans already consumed by an angle phrase, and + # skip an uppercase letter that was clearly meant as an angle name. + for m in _SIDE_RE.finditer(text): + if any(s <= m.start() < e for (s, e) in consumed): + continue + key = m.group(1) + val = float(m.group(2)) + if key.isupper(): + given.setdefault(key, val) # uppercase bare -> angle + else: + given.setdefault(key.lower(), val) # lowercase -> side + # Need at least 3 givens, at least one of which is a side, to fix a triangle. + sides = [k for k in given if k in ("a", "b", "c")] + if len(given) < 3 or not sides: + return None + # Sanity: positive sides, angles in (0,180). + for k, v in given.items(): + if k in ("a", "b", "c") and v <= 0: + return None + if k in ("A", "B", "C") and not (0 < v < 180): + return None + angsum = sum(v for k, v in given.items() if k in ("A", "B", "C")) + if angsum >= 180: + return None + return given + + +# --------------------------------------------------------------------------- # +# Solving +# --------------------------------------------------------------------------- # + +def _classify(given: dict): + sides = sorted(k for k in given if k in ("a", "b", "c")) + angles = sorted(k for k in given if k in ("A", "B", "C")) + ns, na = len(sides), len(angles) + if ns == 3: + return "SSS" + if ns == 2 and na == 1: + # SAS if the angle is the one BETWEEN the two sides, else SSA. + opp = {"a": "A", "b": "B", "c": "C"} + included = {frozenset(("a", "b")): "C", frozenset(("a", "c")): "B", frozenset(("b", "c")): "A"} + ang = angles[0] + return "SAS" if ang == included[frozenset(sides)] else "SSA" + if ns == 1 and na == 2: + return "ASA_AAS" + return None + + +def _rad(d): + return d * math.pi / 180.0 + + +def _deg(r): + return r * 180.0 / math.pi + + +def solve_triangle(given: dict): + """Return {'case', 'solutions':[{a,b,c,A,B,C}], 'steps':[...]} or None. + + steps: list of {title, formula, detail} describing the worked solution. + For the ambiguous SSA case, solutions may hold two triangles.""" + case = _classify(given) + if case is None: + return None + g = dict(given) + steps = [] + + def sol_from(a, b, c, A, B, C): + return {"a": a, "b": b, "c": c, "A": A, "B": B, "C": C} + + if case == "SSS": + a, b, c = g["a"], g["b"], g["c"] + if a + b <= c or a + c <= b or b + c <= a: + return None # not a valid triangle + # Law of cosines for each angle. + A = _deg(math.acos(max(-1, min(1, (b * b + c * c - a * a) / (2 * b * c))))) + B = _deg(math.acos(max(-1, min(1, (a * a + c * c - b * b) / (2 * a * c))))) + C = 180 - A - B + steps = [ + {"title": "What's given", "formula": f"a = {a:g}, b = {b:g}, c = {c:g}", + "detail": "Three sides (SSS). To find an angle, use the Law of Cosines."}, + {"title": "Find angle A (Law of Cosines)", "formula": "cos A = (b² + c² − a²) / (2bc)", + "detail": f"cos A = ({b:g}² + {c:g}² − {a:g}²) / (2·{b:g}·{c:g}) → A = {A:.1f}°"}, + {"title": "Find angle B (Law of Cosines)", "formula": "cos B = (a² + c² − b²) / (2ac)", + "detail": f"B = {B:.1f}°"}, + {"title": "Find angle C", "formula": "C = 180° − A − B", + "detail": f"C = 180° − {A:.1f}° − {B:.1f}° = {C:.1f}°"}, + ] + return {"case": case, "solutions": [sol_from(a, b, c, A, B, C)], "steps": steps} + + if case == "SAS": + # two sides + included angle -> third side via Law of Cosines, then angles + opp = {"a": "A", "b": "B", "c": "C"} + sides = sorted(k for k in g if k in ("a", "b", "c")) + angK = [k for k in g if k in ("A", "B", "C")][0] + s1, s2 = sides + thirdS = ({"a", "b", "c"} - set(sides)).pop() + x1, x2, ang = g[s1], g[s2], g[angK] + third = math.sqrt(x1 * x1 + x2 * x2 - 2 * x1 * x2 * math.cos(_rad(ang))) + full = {s1: x1, s2: x2, thirdS: third} + a, b, c = full["a"], full["b"], full["c"] + # angle opposite s1 via law of sines (safe; third is the largest only if ang obtuse) + # use law of cosines to avoid ambiguity + A = _deg(math.acos(max(-1, min(1, (b * b + c * c - a * a) / (2 * b * c))))) + B = _deg(math.acos(max(-1, min(1, (a * a + c * c - b * b) / (2 * a * c))))) + C = 180 - A - B + steps = [ + {"title": "What's given", "formula": f"{s1} = {x1:g}, {s2} = {x2:g}, ∠{angK} = {ang:g}°", + "detail": "Two sides and the angle BETWEEN them (SAS). Start with the Law of Cosines."}, + {"title": f"Find side {thirdS} (Law of Cosines)", "formula": f"{thirdS}² = {s1}² + {s2}² − 2·{s1}·{s2}·cos {angK}", + "detail": f"{thirdS} = √({x1:g}² + {x2:g}² − 2·{x1:g}·{x2:g}·cos {ang:g}°) = {third:.2f}"}, + {"title": "Find the remaining angles", "formula": "Law of Cosines (or Sines)", + "detail": f"A = {A:.1f}°, B = {B:.1f}°, C = {C:.1f}°"}, + {"title": "Solved", "formula": "A + B + C = 180°", + "detail": f"a={a:.2f}, b={b:.2f}, c={c:.2f}; A={A:.1f}°, B={B:.1f}°, C={C:.1f}°"}, + ] + return {"case": case, "solutions": [sol_from(a, b, c, A, B, C)], "steps": steps} + + if case == "ASA_AAS": + angles = [k for k in g if k in ("A", "B", "C")] + sideK = [k for k in g if k in ("a", "b", "c")][0] + a3 = {"A", "B", "C"} - set(angles) + thirdA = a3.pop() + A_ = g.get("A"); B_ = g.get("B"); C_ = g.get("C") + known = {k: g[k] for k in angles} + known[thirdA] = 180 - sum(known.values()) + A_, B_, C_ = known["A"], known["B"], known["C"] + opp = {"a": "A", "b": "B", "c": "C"} + # Law of sines from the known side. + sval = g[sideK] + ratio = sval / math.sin(_rad(known[opp[sideK]])) + sides = {sk: ratio * math.sin(_rad(known[opp[sk]])) for sk in ("a", "b", "c")} + a, b, c = sides["a"], sides["b"], sides["c"] + steps = [ + {"title": "What's given", "formula": " ".join(f"∠{k}={g[k]:g}°" for k in angles) + f", {sideK}={sval:g}", + "detail": "Two angles and a side (ASA/AAS). The angles fix the shape."}, + {"title": f"Find ∠{thirdA}", "formula": "A + B + C = 180°", + "detail": f"∠{thirdA} = 180° − " + " − ".join(f"{g[k]:g}°" for k in angles) + f" = {known[thirdA]:.1f}°"}, + {"title": "Find the sides (Law of Sines)", "formula": "a/sin A = b/sin B = c/sin C", + "detail": f"common ratio = {sideK}/sin {opp[sideK]} = {ratio:.2f}"}, + {"title": "Solved", "formula": "every side from the ratio", + "detail": f"a={a:.2f}, b={b:.2f}, c={c:.2f}; A={A_:.1f}°, B={B_:.1f}°, C={C_:.1f}°"}, + ] + return {"case": case, "solutions": [sol_from(a, b, c, A_, B_, C_)], "steps": steps} + + if case == "SSA": + # two sides + a non-included angle -> ambiguous. + opp = {"a": "A", "b": "B", "c": "C"} + angK = [k for k in g if k in ("A", "B", "C")][0] + ang = g[angK] + known_side = opp_side = None + # side opposite the known angle: + os = {"A": "a", "B": "b", "C": "c"}[angK] + other = [s for s in ("a", "b", "c") if s in g and s != os] + if os not in g or not other: + return None + sideOpp = g[os] # side opposite the known angle + other_k = other[0] + sideOther = g[other_k] # the other given side + # Law of sines: sin(angle opposite the OTHER side) = sideOther·sin(ang)/sideOpp + sinX = sideOther * math.sin(_rad(ang)) / sideOpp + otherAngK = opp[other_k] + base_steps = [ + {"title": "What's given", "formula": f"{os} = {sideOpp:g}, {other_k} = {sideOther:g}, ∠{angK} = {ang:g}°", + "detail": "Two sides and an angle NOT between them (SSA) — the ambiguous case. Use the Law of Sines."}, + {"title": f"Set up the Law of Sines", "formula": f"sin {otherAngK} / {other_k} = sin {angK} / {os}", + "detail": f"sin {otherAngK} = {other_k}·sin {angK} / {os} = {sideOther:g}·sin {ang:g}° / {sideOpp:g} = {sinX:.3f}"}, + ] + if sinX > 1 + 1e-9: + base_steps.append({"title": "No triangle", "formula": "sin value > 1", + "detail": f"sin {otherAngK} = {sinX:.3f} > 1 is impossible — NO triangle exists."}) + return {"case": case, "solutions": [], "steps": base_steps, "ambiguous": True} + sinX = max(-1, min(1, sinX)) + X1 = _deg(math.asin(sinX)) + sols = [] + for X in (X1, 180 - X1): + if X <= 0 or ang + X >= 180: + continue + known = {angK: ang, otherAngK: X} + thirdA = ({"A", "B", "C"} - set(known)).pop() + known[thirdA] = 180 - ang - X + ratio = sideOpp / math.sin(_rad(ang)) + sides = {sk: ratio * math.sin(_rad(known[opp[sk]])) for sk in ("a", "b", "c")} + sols.append(sol_from(sides["a"], sides["b"], sides["c"], known["A"], known["B"], known["C"])) + if len(sols) == 2: + base_steps.append({"title": f"Two possible angles for ∠{otherAngK}", "formula": f"{otherAngK} = {X1:.1f}° OR 180° − {X1:.1f}° = {180-X1:.1f}°", + "detail": "Both keep the angle sum under 180°, so TWO triangles fit the data."}) + s1, s2 = sols + base_steps.append({"title": "Triangle 1", "formula": f"∠{otherAngK} = {X1:.1f}°", + "detail": f"A={s1['A']:.1f}°, B={s1['B']:.1f}°, C={s1['C']:.1f}°; a={s1['a']:.2f}, b={s1['b']:.2f}, c={s1['c']:.2f}"}) + base_steps.append({"title": "Triangle 2 (the other solution)", "formula": f"∠{otherAngK} = {180-X1:.1f}°", + "detail": f"A={s2['A']:.1f}°, B={s2['B']:.1f}°, C={s2['C']:.1f}°; a={s2['a']:.2f}, b={s2['b']:.2f}, c={s2['c']:.2f}"}) + elif len(sols) == 1: + s1 = sols[0] + base_steps.append({"title": f"∠{otherAngK} = {X1:.1f}°", "formula": "the obtuse option fails (angle sum ≥ 180°)", + "detail": "Only ONE triangle fits."}) + base_steps.append({"title": "Solved", "formula": "A + B + C = 180°", + "detail": f"A={s1['A']:.1f}°, B={s1['B']:.1f}°, C={s1['C']:.1f}°; a={s1['a']:.2f}, b={s1['b']:.2f}, c={s1['c']:.2f}"}) + return {"case": case, "solutions": sols, "steps": base_steps, "ambiguous": len(sols) == 2} + + return None + + +# --------------------------------------------------------------------------- # +# Scene: an animated step-by-step slide deck +# --------------------------------------------------------------------------- # + +def _triangle_vertices(sol): + """Place a triangle with the given side lengths at a readable scale. + Vertex A at origin, B along +x at distance c, C found from sides b (AC) and a (BC).""" + a, b, c = sol["a"], sol["b"], sol["c"] + if min(a, b, c) <= 0: + return None + Ax, Ay = 0.0, 0.0 + Bx, By = c, 0.0 + # C: distance b from A, distance a from B. + x = (b * b - a * a + c * c) / (2 * c) if c > 1e-9 else 0.0 + y2 = b * b - x * x + y = math.sqrt(y2) if y2 > 0 else 0.0 + return [(Ax, Ay), (Bx, By), (x, y)] + + +def _solver_code(result, prompt: str) -> str: + steps = result["steps"] + sols = result.get("solutions", []) + verts = _triangle_vertices(sols[0]) if sols else None + data = json.dumps({ + "steps": steps, + "verts": verts, + "labels": (sols[0] if sols else None), + "nslides": len(steps), + "ambiguous": bool(result.get("ambiguous")), + }, ensure_ascii=False) + return ( + "H.background();\n" + f"const D = {data};\n" + "const w = H.W, hgt = H.H;\n" + "const N = D.nslides;\n" + "const SLIDE = 3.4;\n" + "const k = Math.floor((t / SLIDE) % N);\n" + "const st = D.steps[k];\n" + "H.text('Step-by-step solution', 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\n" + "H.text('Slide ' + (k + 1) + ' of ' + N + ' · worked through with your numbers', 24, 52, { color: H.colors.sub, size: 13 });\n" + "// progress dots\n" + "for (let i = 0; i < N; i++) {\n" + " H.circle(24 + i * 16, 70, 5, { fill: i === k ? H.colors.accent : (i < k ? H.colors.good : H.colors.grid) });\n" + "}\n" + "// Draw the triangle (to scale) on the right, labels revealed as solved.\n" + "if (D.verts) {\n" + " const vs = D.verts;\n" + " let minx = 1e9, maxx = -1e9, miny = 1e9, maxy = -1e9;\n" + " for (const p of vs) { minx = Math.min(minx, p[0]); maxx = Math.max(maxx, p[0]); miny = Math.min(miny, p[1]); maxy = Math.max(maxy, p[1]); }\n" + " const tw = maxx - minx || 1, th = maxy - miny || 1;\n" + " const boxX = w * 0.60, boxY = hgt * 0.30, boxW = w * 0.34, boxH = hgt * 0.42;\n" + " const sc = Math.min(boxW / tw, boxH / th) * 0.82;\n" + " const ox = boxX + boxW / 2 - (minx + tw / 2) * sc;\n" + " const oy = boxY + boxH / 2 + (miny + th / 2) * sc;\n" + " const PX = vs.map(p => [ox + p[0] * sc, oy - p[1] * sc]);\n" + " H.path(PX, { color: H.colors.accent, width: 3, close: true });\n" + " const names = ['A', 'B', 'C'];\n" + " for (let i = 0; i < 3; i++) {\n" + " H.circle(PX[i][0], PX[i][1], 4, { fill: H.colors.ink });\n" + " const av = D.labels[names[i]];\n" + " H.text(names[i] + (av != null ? ' = ' + av.toFixed(0) + '\\u00B0' : ''), PX[i][0] + 8, PX[i][1] - 8, { color: H.colors.sub, size: 12, weight: 700 });\n" + " }\n" + " // side labels at edge midpoints (a opposite A = edge B-C, etc.)\n" + " const edges = [[1, 2, 'a'], [0, 2, 'b'], [0, 1, 'c']];\n" + " for (const e of edges) {\n" + " const mx = (PX[e[0]][0] + PX[e[1]][0]) / 2, my = (PX[e[0]][1] + PX[e[1]][1]) / 2;\n" + " const sv = D.labels[e[2]];\n" + " H.text(e[2] + (sv != null ? '=' + sv.toFixed(1) : ''), mx, my, { color: H.colors.accent2, size: 12, align: 'center', baseline: 'middle' });\n" + " }\n" + "}\n" + "// Slide text card on the left.\n" + "const cx = 24, cy = hgt * 0.34, cw = w * 0.50;\n" + "H.rect(cx, cy, cw, 132, { fill: 'rgba(124,196,255,0.07)', stroke: H.colors.accent, width: 1.4, radius: 12 });\n" + "H.text(st.title, cx + 16, cy + 28, { color: H.colors.accent, size: 16, weight: 700, maxWidth: cw - 32 });\n" + "H.text(st.formula, cx + 16, cy + 60, { color: H.colors.ink, size: 15, weight: 700, maxWidth: cw - 32 });\n" + "H.text(st.detail, cx + 16, cy + 92, { color: H.colors.sub, size: 13, maxWidth: cw - 32 });\n" + "if (D.ambiguous) H.text('\\u26A0 Ambiguous (SSA): two triangles satisfy the given data.', cx, cy + 156, { color: H.colors.warn, size: 13, weight: 700 });\n" + ) + + +# --------------------------------------------------------------------------- # +# Polynomial equation solver (numeric quadratic / linear in one variable) +# --------------------------------------------------------------------------- # + +_FUNC_RE = re.compile(r"sin|cos|tan|sec|csc|cot|sqrt|log|ln|abs|exp|pi|sum|int|mod") + + +def _parse_poly_side(s: str): + """'x^2-5x+6' -> {0:6.0,1:-5.0,2:1.0}, or None if not a deg<=2 polynomial.""" + s = s.replace("*", "") + if not s: + return {0: 0.0, 1: 0.0, 2: 0.0} + s = s.replace("-", "+-") + coeffs = {0: 0.0, 1: 0.0, 2: 0.0} + for term in s.split("+"): + if not term: + continue + m = re.match(r"^(-?\d*\.?\d*)x(?:\^(\d+))?$", term) + if m: + cs, ps = m.group(1), m.group(2) + c = -1.0 if cs == "-" else (1.0 if cs in ("", "+") else float(cs)) + p = int(ps) if ps else 1 + if p > 2: + return None + coeffs[p] += c + continue + m2 = re.match(r"^(-?\d+\.?\d*)$", term) + if m2: + coeffs[0] += float(m2.group(1)) + continue + return None + return coeffs + + +def parse_polynomial(prompt: str): + """Parse a NUMERIC single-variable polynomial equation (degree 1 or 2): + 'x^2 - 5x + 6 = 0' -> ('x', {2:1,1:-5,0:6}, 2). None if it isn't one + (multiple variables, symbolic coefficients, functions, etc.).""" + if not prompt or prompt.count("=") != 1: + return None + s = prompt.strip().lower().replace(" ", "") + s = s.replace("²", "^2").replace("³", "^3").replace("−", "-").replace("–", "-") + s = re.sub(r"^(solve|find|roots?of|zeros?of)", "", s) + if _FUNC_RE.search(s): + return None + letters = set(re.findall(r"[a-z]", s)) + if len(letters) != 1: + return None + var = letters.pop() + s = s.replace(var, "x") + if not re.fullmatch(r"[0-9x.+\-^=]+", s): + return None + left, right = s.split("=") + cl = _parse_poly_side(left) + cr = _parse_poly_side(right) + if cl is None or cr is None: + return None + coeffs = {p: cl[p] - cr[p] for p in (0, 1, 2)} + degree = 2 if abs(coeffs[2]) > 1e-12 else (1 if abs(coeffs[1]) > 1e-12 else 0) + if degree == 0: + return None + return (var if var != "x" else "x", coeffs, degree) + + +def _fmt(x: float) -> str: + """Trim a float for display: 3.0 -> '3', 2.5 -> '2.5', 1.4142 -> '1.41'.""" + if abs(x - round(x)) < 1e-9: + return str(int(round(x))) + return f"{x:.2f}".rstrip("0").rstrip(".") + + +def solve_quadratic(coeffs: dict, var: str): + a, b, c = coeffs[2], coeffs[1], coeffs[0] + D = b * b - 4 * a * c + vx = -b / (2 * a) + vy = c - b * b / (4 * a) + steps = [ + {"title": "Standard form", "formula": f"a{var}² + b{var} + c = 0", + "detail": f"a = {_fmt(a)}, b = {_fmt(b)}, c = {_fmt(c)}"}, + {"title": "Discriminant", "formula": "D = b² − 4ac", + "detail": f"D = ({_fmt(b)})² − 4·({_fmt(a)})·({_fmt(c)}) = {_fmt(D)}" + + (" → two real roots" if D > 1e-9 else (" → one repeated root" if abs(D) <= 1e-9 else " → no real roots"))}, + {"title": "Quadratic formula", "formula": f"{var} = (−b ± √D) / (2a)", + "detail": f"{var} = ({_fmt(-b)} ± √{_fmt(D)}) / {_fmt(2 * a)}"}, + ] + real_roots = [] + if D > 1e-9: + r1 = (-b + math.sqrt(D)) / (2 * a) + r2 = (-b - math.sqrt(D)) / (2 * a) + real_roots = [r1, r2] + steps.append({"title": "The two roots", "formula": f"{var}₁, {var}₂", + "detail": f"{var} = {_fmt(r1)} or {var} = {_fmt(r2)}"}) + elif abs(D) <= 1e-9: + r1 = -b / (2 * a) + real_roots = [r1] + steps.append({"title": "One repeated root", "formula": f"{var} = −b / 2a", + "detail": f"{var} = {_fmt(r1)} (the vertex touches the x-axis)"}) + else: + re_ = -b / (2 * a) + im = math.sqrt(-D) / (2 * a) + steps.append({"title": "Complex roots", "formula": f"{var} = −b/2a ± (√−D /2a) i", + "detail": f"{var} = {_fmt(re_)} ± {_fmt(abs(im))}i (parabola never crosses the x-axis)"}) + return {"kind": "quadratic", "var": var, "coeffs": coeffs, "steps": steps, + "real_roots": real_roots, "vertex": [vx, vy], "discriminant": D} + + +def solve_linear(coeffs: dict, var: str): + b, c = coeffs[1], coeffs[0] # b*x + c = 0 + root = -c / b + steps = [ + {"title": "Standard form", "formula": f"a{var} + b = 0", + "detail": f"a = {_fmt(b)}, b = {_fmt(c)}"}, + {"title": f"Isolate {var}", "formula": f"a{var} = −b", + "detail": f"{_fmt(b)}·{var} = {_fmt(-c)}"}, + {"title": "Solve", "formula": f"{var} = −b / a", + "detail": f"{var} = {_fmt(-c)} / {_fmt(b)} = {_fmt(root)}"}, + ] + return {"kind": "linear", "var": var, "coeffs": coeffs, "steps": steps, + "real_roots": [root], "vertex": None, "discriminant": None} + + +def _poly_code(result) -> str: + a = result["coeffs"][2] + b = result["coeffs"][1] + c = result["coeffs"][0] + roots = result.get("real_roots", []) + var = result["var"] + # Pick a viewing window that frames the roots / vertex on-screen. + if result["kind"] == "quadratic": + vx = result["vertex"][0] + span = max(3.0, *( [abs(r - vx) * 1.5 for r in roots] or [3.0] )) + xmin, xmax = vx - span, vx + span + else: + r = roots[0] + xmin, xmax = r - 5, r + 5 + ys = [a * xmin * xmin + b * xmin + c, a * xmax * xmax + b * xmax + c] + if result["kind"] == "quadratic": + ys.append(result["vertex"][1]) + lo, hi = min(ys + [0.0]), max(ys + [0.0]) + pad = (hi - lo) * 0.18 + 1 + data = json.dumps({"steps": result["steps"], "a": a, "b": b, "c": c, "roots": roots, + "var": var, "xMin": xmin, "xMax": xmax, "yMin": lo - pad, "yMax": hi + pad, + "vertex": result.get("vertex")}, ensure_ascii=False) + return ( + "H.background();\n" + f"const D = {data};\n" + "const w = H.W, hgt = H.H;\n" + "const N = D.steps.length;\n" + "const k = Math.floor((t / 3.4) % N);\n" + "const st = D.steps[k];\n" + "H.text('Step-by-step solution', 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\n" + "H.text('Slide ' + (k + 1) + ' of ' + N + ' · solving for ' + D.var + ' with your numbers', 24, 52, { color: H.colors.sub, size: 13 });\n" + "for (let i = 0; i < N; i++) {\n" + " H.circle(24 + i * 16, 70, 5, { fill: i === k ? H.colors.accent : (i < k ? H.colors.good : H.colors.grid) });\n" + "}\n" + "const v = H.plot2d({ xMin: D.xMin, xMax: D.xMax, yMin: D.yMin, yMax: D.yMax, box: { x: w * 0.40, y: hgt * 0.24, w: w * 0.55, h: hgt * 0.62 } });\n" + "v.grid(); v.axes();\n" + "v.fn(function (x) { return D.a * x * x + D.b * x + D.c; }, { color: H.colors.accent, width: 3 });\n" + "if (D.vertex) v.dot(D.vertex[0], D.vertex[1], { r: 5, fill: H.colors.warn });\n" + "for (let i = 0; i < D.roots.length; i++) {\n" + " v.dot(D.roots[i], 0, { r: 7, fill: H.colors.good });\n" + " v.text(D.var + '=' + (Math.round(D.roots[i] * 100) / 100), D.roots[i], 0, { color: H.colors.good, size: 12 });\n" + "}\n" + "// A probe sweeps the curve so it visibly animates.\n" + "const px = D.xMin + (D.xMax - D.xMin) * (0.5 + 0.5 * Math.sin(t));\n" + "v.dot(px, D.a * px * px + D.b * px + D.c, { r: 5, fill: H.colors.accent2 });\n" + "const cx = 24, cy = hgt * 0.30, cw = w * 0.33;\n" + "H.rect(cx, cy, cw, 150, { fill: 'rgba(124,196,255,0.07)', stroke: H.colors.accent, width: 1.4, radius: 12 });\n" + "H.text(st.title, cx + 16, cy + 28, { color: H.colors.accent, size: 16, weight: 700, maxWidth: cw - 32 });\n" + "H.text(st.formula, cx + 16, cy + 62, { color: H.colors.ink, size: 15, weight: 700, maxWidth: cw - 32 });\n" + "H.text(st.detail, cx + 16, cy + 96, { color: H.colors.sub, size: 13, maxWidth: cw - 32 });\n" + ) + + +def _polynomial_scene(prompt: str): + parsed = parse_polynomial(prompt) + if not parsed: + return None + var, coeffs, degree = parsed + if degree == 2: + result = solve_quadratic(coeffs, var) + kind_name = "quadratic equation" + nroots = len(result["real_roots"]) + summary = (f"A worked, step-by-step solution of your {kind_name}. The slides set up the " + "standard form, compute the discriminant, apply the quadratic formula, and read off " + + ("the two real roots" if nroots == 2 else ("the repeated root" if nroots == 1 else "the complex roots")) + + " — with the parabola drawn so you can see the roots as its x-intercepts.") + elif degree == 1: + result = solve_linear(coeffs, var) + kind_name = "linear equation" + summary = ("A worked, step-by-step solution of your linear equation: move to standard form, isolate " + f"{var}, and solve — with the line drawn so you can see the solution as its x-intercept.") + else: + return None + return { + "title": f"Solving your {kind_name} — step by step", + "tag": "Worked solution", + "dimension": "2D", + "equation": prompt.strip(), + "summary": summary, + "bullets": [ + "Each slide is one step of the solution, advancing automatically.", + "The curve is drawn to scale; roots show as x-intercepts (green dots).", + "It uses YOUR coefficients — not a generic example.", + "The discriminant tells you how many real roots to expect." if degree == 2 + else "A linear equation has exactly one solution.", + ], + "student_prompts": [ + "Why does the discriminant decide the number of real roots?" if degree == 2 else "How do I check a linear solution?", + "What do the roots mean on the graph?", + "How would I solve this by factoring instead?", + ], + "code": _poly_code(result), + "kind": result["kind"], + } + + +# --------------------------------------------------------------------------- # +# Public entry point +# --------------------------------------------------------------------------- # + +def _triangle_scene(prompt: str): + given = parse_triangle(prompt) + if not given: + return None + result = solve_triangle(given) + if result is None: + return None + nsol = len(result.get("solutions", [])) + case = result["case"] + case_name = {"SSS": "three sides (SSS)", "SAS": "two sides + included angle (SAS)", + "ASA_AAS": "two angles + a side (ASA/AAS)", "SSA": "two sides + a non-included angle (SSA)"}[case] + if nsol == 0: + summary = f"The given values ({case_name}) describe NO valid triangle — the slides show why." + elif result.get("ambiguous"): + summary = (f"This is the ambiguous case ({case_name}): TWO different triangles fit your numbers. " + "The slides solve it with the Law of Sines and show both solutions.") + else: + summary = (f"A worked, step-by-step solution for your triangle ({case_name}). " + "Each slide reveals one step — set-up, the law used, and the computation with your numbers.") + return { + "title": "Solving your triangle — step by step", + "tag": "Worked solution", + "dimension": "2D", + "equation": " ".join(f"{k}={given[k]:g}" + ("°" if k.isupper() else "") for k in given), + "summary": summary, + "bullets": [ + "Each slide is one step of the solution, advancing automatically.", + "The triangle is drawn to scale; values fill in as they're found.", + "It uses YOUR numbers — not a generic example.", + ("Two triangles fit (the SSA ambiguous case) — both are shown." + if result.get("ambiguous") else "The chosen law (Sines/Cosines) is named at each step."), + ], + "student_prompts": [ + "Why does this case use the Law of Sines (or Cosines)?", + "When does a triangle problem have two answers?", + "How do I check my solution is right?", + ], + "code": _solver_code(result, prompt), + "kind": "triangle", + } + + +def solver_scene(prompt: str): + """Return a worked-solution scene for a SPECIFIC numeric problem, or None. + Tries each solver in turn: triangle, then numeric polynomial (quadratic / + linear). `code` is RAW (the caller runs sanitize_code, like the other paths).""" + return _triangle_scene(prompt) or _polynomial_scene(prompt) diff --git a/stem-viz-plugin/.claude-plugin/plugin.json b/stem-viz-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..c6156e6 --- /dev/null +++ b/stem-viz-plugin/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "stem-viz", + "version": "1.0.0", + "description": "Generate, validate, and run animated 2D/3D STEM visualizations — sandboxed-JavaScript scene(ctx, t, H) animations of math, physics, ML, and CS concepts. Bundles the rendering contract, helper API, a concept→visual playbook, a self-repair protocol, a headless validator, and the portable runtime.", + "author": { + "name": "VisualLM" + }, + "homepage": "https://github.com/Maxpeng59/VisualLM", + "license": "MIT", + "keywords": [ + "visualization", + "animation", + "stem", + "education", + "math", + "physics", + "machine-learning", + "canvas", + "3d", + "skill" + ] +} diff --git a/stem-viz-plugin/README.md b/stem-viz-plugin/README.md new file mode 100644 index 0000000..f45e6d9 --- /dev/null +++ b/stem-viz-plugin/README.md @@ -0,0 +1,86 @@ +# stem-viz — a portable STEM-visualization skill + plugin + +A self-contained capability pack that teaches any AI to turn a STEM question into +an **animated 2D/3D explanation**, and gives it the tools to run and verify the +result. Extracted from [VisualLM](https://github.com/Maxpeng59/VisualLM) so the +capability travels — as a **Claude Skill** (portable across Claude Code, the Agent +SDK, claude.ai, the Skills API) and as a **Claude Code plugin** (this folder). + +## What's inside + +``` +stem-viz-plugin/ +├── .claude-plugin/plugin.json # plugin manifest (Claude Code installs this) +└── skills/stem-viz/ # the skill (portable on its own) + ├── SKILL.md # the contract + workflow + when to use + ├── references/ + │ ├── helper-api.md # full H / plot2d / cam3d / surface3d / mesh3d API + output schema + │ ├── examples.md # 8 complete, validator-passing scenes (2D + 3D) + │ ├── concepts.md # concept → visualization playbook (ML/STEM topics → recipes) + │ └── repair.md # error-class → minimal-change self-repair protocol + ├── runtime/ + │ ├── sandbox-worker.js # the real rendering engine (Web Worker + OffscreenCanvas + H) + │ ├── host.html # minimal no-build page: paste a scene, watch it render + │ └── README.md # how to embed the runtime in any app + └── scripts/ + └── validate_scene.js # headless Node validator (run before shipping a scene) +``` + +The skill does three things, end to end: +1. **Generate** — write `scene(ctx, t, H)` animation code from a STEM prompt. +2. **Self-repair** — fix code that throws, by error class, with minimal edits. +3. **Teach** — map concepts (gradient descent, Fourier series, projectile motion…) + to proven visual recipes. + +…and bundles the **runtime** so generated scenes actually run outside VisualLM, +plus a **headless validator** so an agent can verify a scene in ~1s without a +browser. + +## Use it as a Claude Code plugin + +Add the marketplace (the repo or a local clone) and install: + +``` +/plugin marketplace add Maxpeng59/VisualLM # or: add /path/to/VisualLM +/plugin install stem-viz +``` + +Once installed, the `stem-viz` skill triggers automatically when you ask to +visualize/animate/illustrate a STEM idea. (Claude Code discovers the skill under +`skills/` from the plugin manifest.) + +## Use it as a standalone Skill + +The `skills/stem-viz/` folder is a complete, portable skill — drop it into any +skills directory (Claude Code `~/.claude/skills/`, an Agent SDK skills path, +claude.ai, or the Skills API). Nothing in it depends on the plugin wrapper. + +## Try the runtime right now + +```bash +cd skills/stem-viz/runtime +python3 -m http.server 8000 +# open http://localhost:8000/host.html — paste a scene from references/examples.md +``` + +## Validate a scene headlessly + +```bash +node skills/stem-viz/scripts/validate_scene.js <<'SCENE' +H.background(); +const v = H.plot2d({ xMin: -6, xMax: 6, yMin: -2, yMax: 2 }); +v.grid(); v.axes(); +v.fn(x => Math.sin(x + t), { color: H.colors.accent, width: 3 }); +H.text("sine", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +SCENE +# → {"ok":true,"error":null,"painted":true,"text":true,"paint":N,"onscreen":true} +``` + +## Keeping it in sync + +`runtime/sandbox-worker.js` (the engine), `scripts/validate_scene.js` (its mock), +and `references/helper-api.md` (the docs) describe the same `H` API. If you extend +the helper library, update all three together. + +## License +MIT (matches VisualLM). diff --git a/stem-viz-plugin/skills/stem-viz/SKILL.md b/stem-viz-plugin/skills/stem-viz/SKILL.md new file mode 100644 index 0000000..341b452 --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/SKILL.md @@ -0,0 +1,241 @@ +--- +name: stem-viz +description: >- + Generate animated 2D/3D STEM visualizations as self-contained sandboxed-JavaScript + scene(ctx, t, H) code — math, physics, chemistry, biology, CS/algorithms, and + machine-learning concepts rendered as something you can watch move. Use this + whenever the user wants to visualize, animate, illustrate, simulate, or "show" + a STEM concept, equation, function, algorithm, or data idea (e.g. "animate + gradient descent", "visualize a Fourier series", "show projectile motion", + "plot z = f(x,y) as a 3D surface", "explain the derivative with a moving + tangent line", "draw the electric field of a dipole"), even when they don't say + the word "animation". Also use it to RUN a scene (bundled browser runtime), to + VALIDATE scene code headlessly before shipping (bundled Node validator), and to + REPAIR scene code that throws. Prefer this skill over hand-rolling canvas code. +--- + +# STEM Visualization — scene generator + runtime + +This skill produces **animated explanations of STEM ideas**. You write the body of +a `scene(ctx, t, H)` function in JavaScript; a small sandboxed renderer (bundled +in `runtime/`) calls it ~60×/second and paints the result on a canvas. The same +code can be validated headlessly (`scripts/validate_scene.js`) and run for real +(`runtime/host.html`) — so a scene you generate here is portable: it runs anywhere +the runtime goes, with no server and no dependencies beyond a browser (to render) +or Node (to validate). + +## Teach the mechanism, don't solve their problem + +This is the goal above all the rules below. These animations exist to help a +learner **understand** a concept — to make it *visible* — not to act as an +answer key. When a prompt is really a specific problem ("a ball thrown at +22 m/s at 58°, find the range"), don't just compute the number and display it as +*the answer* — that does the student's work for them. Show the **mechanism** +that produces it (the parabola forming, the velocity components, why it peaks +where it does) so the learner can see the structure and reason to the result. + +- **Show the relationship, not one instance.** Sweep a parameter with `t` (the + point of tangency, the angle, the harmonic count) so they watch *how* it + behaves across cases — not just the value at their number. +- **Make labels explain, not just state.** `"slope = f'(a): watch it flip sign + at the peak"` teaches; a bare `"answer = 41.2"` doesn't. +- **Provoke, don't spoon-feed.** Use `bullets` and `student_prompts` to invite + prediction ("where is the slope zero?"), not to hand over the solution. + +Not a license to be vague: scenes stay concrete, runnable, and labeled, and a +**live readout of a changing quantity is good** — it makes the relationship +visible. The line is between *illuminating the mechanism* (teach) and +*delivering the one specific answer to their assigned problem* (solve). + +## Workflow + +1. **Understand the concept** and what should *move*. The goal is teaching, not + decoration — decide the one mechanism the animation should reveal (a sweeping + tangent, a propagating wave, a descending optimizer, a rotating molecule). + Reveal that mechanism — don't just compute the prompt's specific answer (see + *Teach the mechanism, don't solve their problem* above). +2. **Pick 2D or 3D.** 3D when the idea lives in space (surfaces `z=f(x,y)`, + orbits, molecules, fields in space, multivariable calculus, rotations); 2D for + single-variable functions, time series, circuits, graphs/algorithms, planar + geometry. Honor an explicit user preference. +3. **Check the playbook.** For common topics, `references/concepts.md` maps the + concept to a proven visual recipe (and links a complete worked scene). Reuse + the recipe rather than inventing one. +4. **Write the scene body** following the contract below. Keep it a pure function + of `(ctx, t)` — drive *all* motion from `t`, never keep outer state. +5. **Validate before you ship.** Run `scripts/validate_scene.js` (see below). It + catches throws, blank output, missing labels, and off-screen drawing in ~1s + without a browser. +6. **If it throws or is blank, repair it** using `references/repair.md` — make the + *smallest* change that fixes the reported error; don't rewrite a working scene. +7. **Emit the result** in the output format below (or just the `code` body if the + caller only wants runnable code). + +## The rendering contract + +`code` is the BODY of a function with this exact signature — provide only the +statements that go *inside* it, do not write `function scene(...)` yourself: + +```js +function scene(ctx, t) { + // your code here +} +``` + +- `ctx` — a Canvas 2D context. The canvas is cleared before each call. +- `t` — elapsed time in **seconds** (a float; respects pause/speed). Drive all + motion from `t` so the animation loops smoothly. `scene` is called ~60×/s and + must be a **pure function of (ctx, t)** — no frame counters, no outer-state + mutation. +- `H` — the helper library, available as a **global** in the renderer scope. + Reference it directly (`H.text(...)`); never declare it. +- `H.W`, `H.H` — logical width/height of the drawing area (pixels). +- Only these globals exist: `Math`, `Number`, `Array`, `Object`, `JSON`, + `console`, and `H`. Call math through `Math.*` (`Math.sin(x)`, not `sin(x)`). +- **Never** use: unbounded loops, `while(true)`, `setTimeout`/`setInterval`/ + `requestAnimationFrame`, network, DOM, `import`, or `eval`. Keep every loop + finite and cheap (≤ a few hundred iterations per frame). + +### Three hard rules (code that breaks these is treated as a failed scene) + +**Rule 0 — every scene MUST PAINT.** The #1 failure is code that computes but +never draws. +1. Call `H.background()` (or `H.clear()`) on the **first line**. +2. Call **≥ 3 drawing helpers per frame** (a `for` loop calling one counts): + `H.text/line/path/circle/rect/arrow/legend/surface3d/mesh3d`, any `plot2d` + view method (`.grid/.axes/.fn/.dot`), or any `cam` method + (`.line/.path/.poly/.sphere/.grid/.axes`). +3. Use real **pixel** coordinates in `[0, H.W] × [0, H.H]`. Do **not** draw at + math coordinates like `(-3, 0.5)` directly — go through `H.plot2d` (which maps + for you) or scale with `H.map(...)`. Mixing the two (e.g. `v.line(v.X(x), ...)`) + double-transforms and draws off-screen — the validator flags this as + `onscreen: false`. + +**Rule 1 — every scene MUST MOVE.** It only animates if your code reads `t`. +Drive at least one primary element from `t`. If the concept is static (a +structure, a proof), animate the *explanation*: sweep a highlight, pulse the +region under discussion, orbit the camera (`cam.yaw = 0.3 * t`), or step through +stages with `const phase = Math.floor(t % 9 / 3);`. + +**Rule 2 — every scene MUST BE LABELED with real values.** A picture without +numbers teaches nothing. +- A title (`H.text`, size 18, weight 700) top-left + a one-line caption under it + (size 13, `H.colors.sub`). +- `view.axes()` (2D) and `cam.axes(len)` (3D) auto-draw numeric ticks — use them + whenever the scene has coordinates. +- At least one **live readout** that changes with `t`, e.g. + `H.text("E = " + E.toFixed(2) + " J", 24, 76, { color: H.colors.sub, size: 13 });` +- With 2+ colored elements, add `H.legend([{label, color}, ...], x, y)`. + +### Minimal complete skeleton + +```js +H.background(); +const v = H.plot2d({ xMin: -6, xMax: 6, yMin: -2, yMax: 2 }); +v.grid(); v.axes(); +v.fn(x => Math.sin(x + t), { color: H.colors.accent, width: 3 }); +H.text("Your title", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("one-line caption", 24, 52, { color: H.colors.sub, size: 13 }); +``` + +## Helper essentials + +Enough to write most 2D scenes; **full API in `references/helper-api.md`** (read it +for `plot2d` data-space methods, all `cam3d`/`surface3d`/`mesh3d` options, and the +exact option bags). + +- **Constants:** `H.W`, `H.H`, `H.TAU` (2π), `H.PI`, `H.colors`, `H.palette`. +- **Colors:** `H.colors.{bg,panel,ink,sub,grid,axis,accent,accent2,good,warn,violet,yellow}`. + For anything else use a CSS string (`"#ffaa00"`) or `H.hsl(h,s,l,a)` / `H.color(i)`. +- **Math:** `H.clamp`, `H.lerp`, `H.map(x,inMin,inMax,outMin,outMax)`, `H.ease`. +- **Draw (pixels):** `H.background()`, `H.text(s,x,y,opts)`, `H.line(x1,y1,x2,y2,opts)`, + `H.path([[x,y],...],opts)`, `H.circle(x,y,r,opts)`, `H.rect(x,y,w,h,opts)`, + `H.arrow(x1,y1,x2,y2,opts)`, `H.legend(items,x,y)`. +- **2D graph:** `const v = H.plot2d({xMin,xMax,yMin,yMax,pad})` → `v.grid()`, + `v.axes()`, `v.fn(x=>..., opts)`, `v.dot(x,y,opts)`, `v.X(v)`/`v.Y(v)`, plus + data-space `v.line/v.arrow/v.text/v.circle/v.path/v.rect` (args in **math** + units). +- **3D:** `const cam = H.cam3d({yaw,pitch,scale,dist,cx,cy})`; set `cam.yaw = 0.3*t`; + `cam.grid()`, `cam.axes(len)`, `cam.line/path/poly/sphere`; and the **solid** + surfaces `H.surface3d(cam, (x,y)=>height, opts)` and `H.mesh3d(cam, (u,v)=>[x,y,z], opts)`. + Make 3D solid (lit, depth-sorted) — never a cloud of flat dots. + +## Validate before shipping + +The bundled validator runs the scene against a faithful mock of `H` in a locked +V8 sandbox at several values of `t` and reports JSON — no browser needed: + +```bash +node scripts/validate_scene.js <<'SCENE' +H.background(); +const v = H.plot2d({ xMin: -6, xMax: 6, yMin: -2, yMax: 2 }); +v.grid(); v.axes(); +v.fn(x => Math.sin(x + t), { color: H.colors.accent, width: 3 }); +H.text("sine", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +SCENE +# → {"ok":true,"error":null,"painted":true,"text":true,"paint":N,"onscreen":true} +``` + +A scene is ready to ship only when `ok:true`, `painted:true`, `text:true`, and +`onscreen:true`. If `ok:false`, read `error` and repair (next). If `painted` or +`text` is false, you broke Rule 0/2. If `onscreen:false`, you mixed data and +pixel coordinates (Rule 0.3). + +## If it throws: repair + +`references/repair.md` maps each error class to a targeted fix and the guiding +principle: **make the smallest change that fixes the reported error — keep +everything that already works.** Re-validate after every repair. Most failures +are a tiny set: an undeclared name, an invented helper, a property read off +`undefined`, or a syntax slip from truncation. + +## Run it for real + +`runtime/` is the actual rendering engine, portable and dependency-free: +- `runtime/sandbox-worker.js` — the Web Worker that compiles and runs scene code + in an OffscreenCanvas sandbox (no DOM/network; watchdog kills runaways). It + exposes the real `H` library. +- `runtime/host.html` — a minimal standalone page: paste a scene, watch it render, + drag to orbit 3D. Open it in a browser, no build step. +- `runtime/README.md` — how to embed the runtime in your own app. + +## Output format + +When the caller wants a full scene record (the VisualLM shape), emit these fields +(JSON schema in `references/helper-api.md`): + +- `title` — short scene title (≤ 60 chars) +- `tag` — subject label ("Calculus", "Electromagnetism", "Algorithms") +- `dimension` — `"2D"` or `"3D"` +- `equation` — the central equation in plain text, or `""` +- `summary` — one or two sentences on what the animation shows +- `bullets` — exactly 3 short teaching points tied to what's on screen +- `student_prompts` — 3 good follow-up questions +- `code` — the `scene` body: self-contained, runnable, validated + +If the caller only asked for "an animation of X", returning just the validated +`code` body is fine. + +## Correctness rules that are easy to get wrong + +- **Defensive code.** Guard against divide-by-zero, NaN, and out-of-range values. + The animation must never throw — `+1e-6` denominators, `Math.max(0, ...)` for + physical floors, `H.clamp` for bounded quantities. +- **Physical quantities stay physical.** Lengths, radii, masses, probabilities, + energies must never display negative. Don't animate them with a bare + `Math.sin(t)` — use `2 + Math.sin(t)` or `Math.abs(...)`. +- **Numbers must match the picture.** If a readout says `a = 3.0`, the drawn + length must be 3 units. +- **No fake interactivity.** Generated code receives no mouse/keyboard input. + Never claim "drag the vertices" / "click to…" in code, summary, or bullets. The + only built-in interaction is camera orbit on 3D scenes (automatic). + +## Reference files + +| File | Read it when | +|---|---| +| `references/helper-api.md` | You need the full helper API — `plot2d` data-space methods, every `cam3d`/`surface3d`/`mesh3d` option, the color list, the output JSON schema. | +| `references/concepts.md` | The user named a common STEM topic — get a proven concept→visual recipe and a link to a complete worked scene. | +| `references/examples.md` | You want complete, validated end-to-end scenes (2D and 3D) to adapt. | +| `references/repair.md` | A generated scene threw or came back blank — error-class → minimal-change fix. | +| `runtime/README.md` | You need to actually render/host scenes, or embed the engine elsewhere. | diff --git a/stem-viz-plugin/skills/stem-viz/references/concepts.md b/stem-viz-plugin/skills/stem-viz/references/concepts.md new file mode 100644 index 0000000..31cc22e --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/references/concepts.md @@ -0,0 +1,122 @@ +# Concept → visualization playbook + +How to turn a STEM/ML topic into a scene that *teaches*. The hard part is rarely +the code — it's choosing **what should move** so the animation reveals the +mechanism. This file gives proven recipes for common topics and a general method +for anything not listed. + +Throughout, the goal is **understanding, not an answer key**: reveal the +*mechanism* and the *general relationship* (sweep the parameter with `t`) rather +than just computing the one specific number a prompt asks for. See *Teach the +mechanism, don't solve their problem* in SKILL.md. + +## The general method (use this for any concept) + +1. **Name the mechanism.** What is the one idea? ("the derivative is the tangent's + slope", "adding harmonics sharpens the wave", "the optimizer follows the + negative gradient"). The animation exists to show *that*. +2. **Choose the moving variable.** Map the mechanism to something driven by `t`: + a swept parameter, an accumulating sum, a propagating front, an integrated + trajectory, a rotating viewpoint. +3. **Pick the representation:** + - one-variable function / time series / signal → **2D `plot2d` + `fn`** + - geometry, vectors, planar fields, circuits, graphs → **2D pixel or `plot2d` + data-space** + - `z = f(x,y)`, optimization landscapes → **3D `surface3d`** + - parametric shapes (sphere, torus, tube, helix) → **3D `mesh3d`** + - discrete bodies in space (atoms, planets, particles) → **3D `cam.sphere`, + depth-sorted** +4. **Make the invisible legible.** Add a live readout of the key quantity, a + legend for multiple series, axes with units. If the steady-state is static, + animate the *construction* (sweep, accumulate, step through stages). +5. **Adapt the nearest worked scene** in `references/examples.md` rather than + starting blank. + +## Recipes by topic + +Each row: the visual idea, the technique, and the closest example to adapt. + +### Calculus & analysis +- **Derivative / tangent / rate of change** → plot `f`, sweep the point of + tangency, draw the tangent in data space, print the live slope. → example 1. +- **Integral / area under a curve** → plot `f`, fill Riemann rectangles whose + count grows with `t`, print the running sum vs the true integral. +- **Limit / secant → tangent** → draw a secant between `a` and `a+h`, shrink `h` + with `t`, show the slope converging. +- **Taylor / power series** → plot the target faintly, overlay the partial sum + with a breathing term count `N` (same shape as Fourier, example 2). +- **Multivariable / partial derivatives / gradient** → `surface3d` for `f(x,y)`, + draw the gradient arrow on the surface or a contour slice. → example 7. + +### Signals & linear algebra +- **Fourier series / harmonics / decomposition** → partial sums of sines with a + breathing `N`, target behind, legend. → example 2. +- **Sine/cosine / phase / radians** → unit circle with dropped perpendiculars + + linked sin/cos plot. → example 3. +- **Vectors / dot & cross product / projection** → 2D arrows from origin, show the + projection foot and the angle; animate one vector rotating. +- **Matrix transform / eigenvectors** → a grid of dots under `A`, interpolate from + identity to `A` with `t`; eigenvectors stay on their line. + +### Mechanics & physics +- **Projectile / kinematics / trajectory** → faint full path + moving point + + velocity arrow; floor physical quantities at 0. → example 4. +- **Oscillation / SHM / pendulum / spring** → position `= A*Math.cos(ω*t)`, draw + the body + a phase plot; keep amplitude positive. +- **Waves / interference / standing waves** → 2D `fn` of `sin(kx − ωt)`, or two + sources with summed displacement; for 3D ripples use `surface3d` (example 7's + cousin: `sin(r − t)/r`). +- **Orbits / gravity / planetary motion** → 3D `cam.path` ellipse + a `cam.sphere` + body moving along it, central sphere for the star. + +### Electromagnetism +- **Field lines / dipole / point charges** → a `field(x,y)` function, streamlines + by integration, a test charge advected with `t`. → example 5. +- **RC / RL / circuits / time constant** → `plot2d` of the exponential + a drawn + component whose fill/level tracks the value. → example 6. +- **EM wave** → 3D: oscillating E (one axis) and B (perpendicular) along a + propagation axis, both driven by `sin(kz − ωt)` via `cam.path`. + +### Machine learning & optimization +- **Gradient descent / loss surface / training** → `surface3d` loss + a sphere + stepping down the negative gradient, re-released each cycle, live loss readout. + → example 7. (Too-large learning rate = make the steps overshoot for a variant.) +- **Linear/logistic regression fit** → 2D scatter (fixed seed via a formula, not + randomness) + a line/curve whose parameters animate toward the fit; show the + loss dropping. +- **Neural net / perceptron** → 2D nodes as circles, edges as lines with + width ∝ |weight|, a pulse traveling forward; label the activation. +- **k-means / clustering** → 2D points colored by nearest centroid; move centroids + to the mean each cycle. + +### CS & algorithms +- **Graph traversal (BFS/DFS)** → fixed node positions, edges as lines, a frontier + that expands by stage `Math.floor(t * rate)`; color visited vs frontier; legend. +- **Sorting** → bars (`H.rect`) whose heights are a fixed array; animate compares/ + swaps by stage; highlight the active pair. +- **Recursion / trees** → draw a tree by depth; reveal levels with `t`. + +### Chemistry & biology +- **Molecules / crystal structure / DNA** → 3D `cam.sphere` atoms, depth-sorted, + `cam.line` bonds, slow spin. → example 8. +- **Reaction / rate / equilibrium** → 2D concentrations vs time as `plot2d` curves + approaching equilibrium; live readout of the ratio. + +### Probability & statistics +- **Distributions / CLT / sampling** → 2D histogram (`H.rect` bars from a + closed-form density, not RNG) + the limiting curve overlaid; grow the sample + with `t`. +- **Bayes / conditional probability** → area/tree diagram with regions sized to + probabilities; highlight the conditioning event. + +## Notes that keep scenes correct + +- **No randomness.** `scene` must be a pure function of `t` and is called every + frame, so `Math.random()` would jitter. Use deterministic formulas, or a fixed + hash like `frac(Math.sin(i*12.9898)*43758.5)` for "scattered" points. +- **Stage with `t`.** For discrete steps, `const stage = Math.floor(t % period / step)` + cycles cleanly and loops. +- **Keep physical quantities physical** (lengths, probabilities, energies ≥ 0) and + make displayed numbers match the drawing. See SKILL.md → "Correctness rules". +- **3D must look 3D.** Prefer `surface3d`/`mesh3d`/`cam.sphere` (lit, depth-sorted) + over flat dot clouds, add `cam.grid()` + `cam.axes()` + a slow `cam.yaw = 0.3*t`. diff --git a/stem-viz-plugin/skills/stem-viz/references/examples.md b/stem-viz-plugin/skills/stem-viz/references/examples.md new file mode 100644 index 0000000..ec80980 --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/references/examples.md @@ -0,0 +1,236 @@ +# Worked scenes (complete, validated) + +Eight end-to-end scenes across domains. Every one passes +`scripts/validate_scene.js` (`ok`, `painted`, `text`, `onscreen` all true) and +follows the three hard rules. Adapt the closest one rather than starting from a +blank file — match the *technique* (function plot, vector field, 3D surface, +sphere cloud) to your concept. + +Each block is the `scene` body — paste it straight into the validator or +`runtime/host.html`. + +--- + +## 1. Derivative as a moving tangent line — 2D, `plot2d` + data-space +Technique: plot `y=f(x)`, sweep the point of tangency with `t`, draw the tangent +in data space, print the live slope. + +```js +H.background(); +const v = H.plot2d({ xMin: -3.2, xMax: 3.2, yMin: -3, yMax: 5, pad: 50 }); +v.grid(); v.axes(); +const f = (x) => 0.15 * x * x * x - x; +const df = (x) => 0.45 * x * x - 1; +v.fn(f, { color: H.colors.accent, width: 3 }); +const a = 2.6 * Math.sin(t * 0.6); +const slope = df(a); +const tx = (x) => f(a) + slope * (x - a); +v.line(-3.2, tx(-3.2), 3.2, tx(3.2), { color: H.colors.accent2, width: 2.4, dash: [7, 6] }); +v.dot(a, f(a), { r: 7 }); +H.text("Derivative = slope of the tangent", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("a = " + a.toFixed(2) + " f'(a) = " + slope.toFixed(2), 24, 52, { color: H.colors.sub, size: 14 }); +``` + +## 2. Fourier series → square wave — 2D, partial sums + legend +Technique: a breathing harmonic count `N`, a partial-sum function, the target +drawn faintly behind it, a legend. + +```js +H.background(); +const v = H.plot2d({ xMin: -Math.PI, xMax: Math.PI, yMin: -1.5, yMax: 1.5, pad: 50 }); +v.grid(); v.axes(); +const N = 1 + Math.floor((1 + Math.sin(t * 0.5)) * 5); // 1..11 harmonics, breathing +const partial = (x) => { + let s = 0; + for (let k = 1; k <= N; k++) s += Math.sin((2 * k - 1) * x) / (2 * k - 1); + return (4 / Math.PI) * s; +}; +v.fn((x) => Math.sign(Math.sin(x)) || 0, { color: H.colors.sub, width: 1.5 }); +v.fn(partial, { color: H.colors.accent, width: 3 }); +H.text("Fourier series of a square wave", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("harmonics N = " + N, 24, 52, { color: H.colors.accent, size: 14 }); +H.legend([{ label: "target square", color: H.colors.sub }, { label: "partial sum", color: H.colors.accent }], H.W - 170, 28); +``` + +## 3. Unit circle generates sine & cosine — 2D, two linked panels +Technique: a pixel-space circle with dropped perpendiculars on the left, a +`plot2d` of sin/cos on the right, sharing the angle `th`. + +```js +H.background(); +const cx = H.W * 0.28, cy = H.H * 0.52, R = Math.min(H.W, H.H) * 0.28; +const th = t * 0.8; +H.circle(cx, cy, R, { stroke: H.colors.grid, width: 1.5 }); +H.line(cx - R - 10, cy, cx + R + 10, cy, { color: H.colors.axis, width: 1 }); +H.line(cx, cy - R - 10, cx, cy + R + 10, { color: H.colors.axis, width: 1 }); +const px = cx + R * Math.cos(th), py = cy - R * Math.sin(th); +H.line(cx, cy, px, py, { color: H.colors.accent2, width: 2 }); +H.line(px, py, px, cy, { color: H.colors.good, width: 2, dash: [4, 4] }); +H.line(px, py, cx, py, { color: H.colors.accent, width: 2, dash: [4, 4] }); +H.circle(px, py, 6, { fill: H.colors.warn }); +const v = H.plot2d({ xMin: 0, xMax: 12, yMin: -1.3, yMax: 1.3, box: { x: H.W * 0.56, y: 70, w: H.W * 0.4, h: H.H - 150 } }); +v.grid(); v.axes(); +v.fn((x) => Math.sin(x), { color: H.colors.good, width: 2.5 }); +v.fn((x) => Math.cos(x), { color: H.colors.accent, width: 2.5 }); +v.dot(th % 12, Math.sin(th)); +H.text("Unit circle → sine & cosine", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("theta = " + (th % (2 * Math.PI)).toFixed(2) + " rad", 24, 52, { color: H.colors.sub, size: 14 }); +H.legend([{ label: "sin", color: H.colors.good }, { label: "cos", color: H.colors.accent }], H.W * 0.56, 50); +``` + +## 4. Projectile motion — 2D, trajectory + velocity vector + live readout +Technique: precompute the full parabola as a faint dashed `path`, animate the +moving point and its velocity `arrow`, keep physical quantities non-negative +(`Math.max(0, y)`). + +```js +H.background(); +const g = 9.8, v0 = 22, ang = 58 * Math.PI / 180; +const flight = 2 * v0 * Math.sin(ang) / g; +const tau = (t % (flight + 1)); +const xr = v0 * Math.cos(ang) * tau; +const yr = Math.max(0, v0 * Math.sin(ang) * tau - 0.5 * g * tau * tau); +const v = H.plot2d({ xMin: 0, xMax: 60, yMin: 0, yMax: 26, pad: 50 }); +v.grid(); v.axes(); +const path = []; +for (let i = 0; i <= 60; i++) { + const tt = i / 60 * flight; + path.push([v0 * Math.cos(ang) * tt, v0 * Math.sin(ang) * tt - 0.5 * g * tt * tt]); +} +v.path(path, { color: H.colors.sub, width: 1.5, dash: [6, 5] }); +const vx = v0 * Math.cos(ang), vy = v0 * Math.sin(ang) - g * tau; +v.arrow(xr, yr, xr + vx * 0.25, yr + vy * 0.25, { color: H.colors.good, width: 2 }); +v.dot(xr, yr, { r: 7, fill: H.colors.warn }); +H.text("Projectile: 22 m/s at 58 deg", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("x = " + xr.toFixed(1) + " m y = " + yr.toFixed(1) + " m", 24, 52, { color: H.colors.sub, size: 14 }); +``` + +## 5. Electric field of a dipole — 2D, vector field + streamlines (pixel space) +Technique: a `field(x,y)` function, streamlines integrated by following the field, +a test charge advected with `t`. Pure pixel space (no `plot2d`) because the field +is over the whole canvas. + +```js +H.background(); +const w = H.W, h = H.H; +const q1 = { x: w * 0.38, y: h * 0.52, s: +1 }; +const q2 = { x: w * 0.62, y: h * 0.52, s: -1 }; +function field(x, y) { + let ex = 0, ey = 0; + for (const q of [q1, q2]) { + const dx = x - q.x, dy = y - q.y; + const r2 = dx * dx + dy * dy + 80; + const r = Math.sqrt(r2); + const e = q.s / r2; + ex += e * dx / r; ey += e * dy / r; + } + return [ex, ey]; +} +for (let k = 0; k < 16; k++) { + const a0 = (k / 16) * H.TAU; + let x = q1.x + 16 * Math.cos(a0), y = q1.y + 16 * Math.sin(a0); + const pts = [[x, y]]; + for (let i = 0; i < 220; i++) { + const [ex, ey] = field(x, y); + const m = Math.hypot(ex, ey) + 1e-9; + x += 3 * ex / m; y += 3 * ey / m; + if (x < 0 || x > w || y < 0 || y > h) break; + if (Math.hypot(x - q2.x, y - q2.y) < 14) break; + pts.push([x, y]); + } + H.path(pts, { color: H.colors.accent, width: 1.4 }); +} +const tt = (t % 6) / 6; +let tx = H.lerp(q1.x, q2.x, 0.15), ty = q1.y - 70; +for (let i = 0; i < Math.floor(tt * 200); i++) { + const [ex, ey] = field(tx, ty); const m = Math.hypot(ex, ey) + 1e-9; + tx += 2.4 * ex / m; ty += 2.4 * ey / m; +} +H.circle(tx, ty, 6, { fill: H.colors.yellow, stroke: H.colors.bg, width: 2 }); +H.circle(q1.x, q1.y, 13, { fill: H.colors.warn }); +H.circle(q2.x, q2.y, 13, { fill: H.colors.accent2 }); +H.text("+", q1.x - 5, q1.y + 5, { color: H.colors.bg, size: 16, weight: 700 }); +H.text("-", q2.x - 4, q2.y + 5, { color: H.colors.bg, size: 18, weight: 700 }); +H.text("Electric field of a dipole", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("test charge along the field, t = " + (t % 6).toFixed(1) + "s", 24, 52, { color: H.colors.sub, size: 13 }); +``` + +## 6. RC circuit charging a capacitor — 2D, plot + schematic with live fill +Technique: a `plot2d` of the exponential on the left, a drawn capacitor whose fill +height tracks `V` on the right, a charge/discharge cycle from `t`. + +```js +H.background(); +const RC = 1.6, V0 = 5; +const cycle = t % 8; +const charging = cycle < 5; +const tau = charging ? cycle : cycle - 5; +const V = charging ? V0 * (1 - Math.exp(-tau / RC)) : V0 * Math.exp(-tau / RC); +const v = H.plot2d({ xMin: 0, xMax: 5, yMin: 0, yMax: 5.5, box: { x: 70, y: 70, w: H.W * 0.5, h: H.H - 150 } }); +v.grid(); v.axes(); +v.fn((x) => V0 * (1 - Math.exp(-x / RC)), { color: H.colors.sub, width: 1.5 }); +v.line(RC, 0, RC, V0, { color: H.colors.violet, width: 1.5, dash: [5, 5] }); +v.dot(tau, V, { r: 7, fill: H.colors.good }); +v.text("t = RC", RC + 0.1, 0.5, { color: H.colors.violet, size: 12 }); +const bx = H.W * 0.68, by = 120, bw = 220, bh = 260; +H.rect(bx, by, bw, bh, { stroke: H.colors.axis, width: 2, radius: 8 }); +H.text("battery", bx + 10, by + 24, { color: H.colors.sub, size: 12 }); +const capX = bx + bw - 60, capY = by + 60, capH = 140; +H.rect(capX, capY, 36, capH, { stroke: H.colors.accent, width: 2 }); +H.rect(capX + 3, capY + capH - capH * (V / V0) + 3, 30, capH * (V / V0) - 6, { fill: H.colors.accent }); +H.text("Q", capX + 44, capY + capH / 2, { color: H.colors.accent, size: 14 }); +H.text("RC circuit: charging a capacitor", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text((charging ? "charging" : "discharging") + " V = " + V.toFixed(2) + " V", 24, 52, { color: H.colors.sub, size: 14 }); +``` + +## 7. Gradient descent on a loss surface — 3D, `surface3d` + descending sphere +Technique: `H.surface3d` for the loss, an optimizer point re-released from a corner +each cycle and stepped along the negative gradient, `cam.yaw = 0.3*t` spin. + +```js +H.background(); +const cam = H.cam3d({ scale: 70, dist: 16, pitch: -0.5, cy: H.H * 0.56 }); +cam.yaw = 0.3 * t; +const loss = (x, y) => 0.6 * (x * x + y * y) - 1.1 * Math.exp(-((x - 1) ** 2 + (y - 1) ** 2)); +cam.grid(3, 1); +H.surface3d(cam, loss, { xMin: -2.6, xMax: 2.6, yMin: -2.6, yMax: 2.6, nx: 34, ny: 34, alpha: 0.9 }); +let px = 2.2, py = 2.2; +const steps = Math.floor((t % 6) * 14); +for (let i = 0; i < steps; i++) { + const gx = 1.2 * px + 1.1 * 2 * (px - 1) * Math.exp(-((px - 1) ** 2 + (py - 1) ** 2)); + const gy = 1.2 * py + 1.1 * 2 * (py - 1) * Math.exp(-((px - 1) ** 2 + (py - 1) ** 2)); + px -= 0.06 * gx; py -= 0.06 * gy; +} +cam.sphere([px, loss(px, py) + 0.15, py], 0.18, { color: H.colors.warn }); +cam.axes(3); +H.text("Gradient descent on a loss surface", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("loss = " + loss(px, py).toFixed(3), 24, 52, { color: H.colors.sub, size: 14 }); +``` + +## 8. DNA double helix — 3D, sphere cloud + depth sort + bonds +Technique: build two antiparallel strands of spheres, **depth-sort descending** +before drawing so near atoms occlude far ones, bonds via `cam.line`. + +```js +H.background(); +const cam = H.cam3d({ scale: 34, dist: 18, pitch: -0.2 }); +cam.yaw = 0.5 * t; +const balls = []; +const N = 34; +for (let i = 0; i < N; i++) { + const s = i / (N - 1); + const ang = s * H.TAU * 2.1 + t * 0.3; + const ypos = (s - 0.5) * 9; + const a = [2.1 * Math.cos(ang), ypos, 2.1 * Math.sin(ang)]; + const b = [-2.1 * Math.cos(ang), ypos, -2.1 * Math.sin(ang)]; + balls.push({ p: a, color: H.colors.accent, r: 0.32 }); + balls.push({ p: b, color: H.colors.accent2, r: 0.32 }); + if (i % 3 === 0) cam.line(a, b, { color: H.colors.sub, width: 1.6 }); +} +balls + .map((o) => ({ ...o, depth: cam.project(o.p).depth })) + .sort((a, b) => b.depth - a.depth) + .forEach((o) => cam.sphere(o.p, o.r, { color: o.color })); +H.text("DNA double helix", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("two antiparallel strands joined by base pairs", 24, 52, { color: H.colors.sub, size: 13 }); +``` diff --git a/stem-viz-plugin/skills/stem-viz/references/helper-api.md b/stem-viz-plugin/skills/stem-viz/references/helper-api.md new file mode 100644 index 0000000..7672681 --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/references/helper-api.md @@ -0,0 +1,140 @@ +# Helper API (`H`) — full reference + +The renderer hands your `scene(ctx, t)` body a global `H`. Everything below is a +method or constant on `H` (or on the `view`/`cam` objects it returns). Coordinates +are **pixels**, origin top-left, **y grows downward**, unless a method says +otherwise. This mirrors `runtime/sandbox-worker.js` exactly — if you change the +runtime, update this file. + +## Contents +- [Constants](#constants) +- [Math helpers](#math-helpers) +- [2D drawing (pixel space)](#2d-drawing-pixel-space) +- [2D graphing — `H.plot2d`](#2d-graphing--hplot2d) +- [3D camera — `H.cam3d`](#3d-camera--hcam3d) +- [Solid 3D surfaces — `H.surface3d` / `H.mesh3d`](#solid-3d-surfaces) +- [Resilience: invented helpers no-op](#resilience) +- [Output JSON schema](#output-json-schema) + +## Constants +- `H.W`, `H.H` — logical canvas width / height in pixels. +- `H.TAU` = 2π, `H.PI` = π. +- `H.colors` — themed palette. Use ONLY these names: `bg`, `panel`, `ink` (bright + text), `sub` (dim text), `grid`, `axis`, `accent`, `accent2`, `good`, `warn`, + `violet`, `yellow`. For any other color use a CSS string (`"#ffaa00"`), + `H.hsl(h,s,l,a)`, or `H.color(i)`. +- `H.palette` — array of 8 distinct CSS colors; `H.color(i)` indexes it (wraps). + +## Math helpers +- `H.clamp(x, lo, hi)` — constrain x to `[lo, hi]`. +- `H.lerp(a, b, t)` — linear blend; `t=0`→a, `t=1`→b. +- `H.map(x, inMin, inMax, outMin, outMax)` — remap a range (used for data→pixel). +- `H.ease(t)` — smoothstep on `[0,1]` (eased 0→1), good for transitions. +- `H.color(i)` — pick palette color i (wraps). +- `H.hsl(h, s, l, a?)` — `"hsla(h,s%,l%,a)"` string; h in degrees, s/l in percent. + +## 2D drawing (pixel space) +Every option bag is optional; sensible defaults apply. +- `H.clear(color?)` — fill the whole canvas with a solid color. +- `H.background(top?, bottom?)` — vertical gradient background. **Call this first.** +- `H.text(str, x, y, {size, color, align, baseline, weight, maxWidth, font})`. +- `H.line(x1, y1, x2, y2, {color, width, dash, cap})`. +- `H.path(points, {color, width, fill, close, dash})` — `points = [[x,y], ...]`. + Set `color:"none"` to fill only; `fill:"#.."` fills the polygon. +- `H.circle(x, y, r, {fill, stroke, width})`. +- `H.rect(x, y, w, h, {fill, stroke, width, radius})` — `radius` rounds corners. +- `H.arrow(x1, y1, x2, y2, {color, width, head})` — line with an arrowhead at the + end; `head` sets arrowhead size. +- `H.legend([{label, color}, ...], x, y)` — color-swatch legend; counts as labels. + +## 2D graphing — `H.plot2d` +`const view = H.plot2d({ xMin, xMax, yMin, yMax, pad, box })` returns a `view` +that maps DATA coordinates to pixels for you. Defaults: x∈[-10,10], y∈[-6,6], +`pad:46`. Pass an explicit `box:{x,y,w,h}` (pixels) to place multiple plots. + +**Scaffold + functions:** +- `view.grid()` — light reference grid. +- `view.axes()` — x/y axes **with numeric tick labels** (satisfies Rule 2 for + coordinates). +- `view.fn(f, {color, width, steps})` — plot `y = f(x)` across the domain. +- `view.dot(x, y, {r, fill, stroke})` — marker at a **data** point. +- `view.X(v)` / `view.Y(v)` — map a single data x/y to a pixel; `view.box` is the + pixel box. + +**Data-space drawing** (arguments are in MATH units; the view maps them): +- `view.line(x1, y1, x2, y2, opts)` +- `view.arrow(x1, y1, x2, y2, opts)` +- `view.text(str, x, y, opts)` +- `view.circle(x, y, rPx, opts)` — center in data units, radius in **pixels**. +- `view.path([[x,y], ...], opts)` +- `view.rect(x, y, w, h, opts)` — lower-left corner + size, all in data units. + +> **Coordinate trap:** with a `view`, pass RAW math coords to the data-space +> methods — `view.line(x1, y1, x2, y2)`, NOT `view.line(view.X(x1), ...)`. Double +> transforming draws far off-screen (validator → `onscreen:false`). Use the +> pixel-space `H.*` methods only for chrome that isn't tied to graph coordinates. + +Methods chain: `const v = H.plot2d({xMin:-6,xMax:6}); v.grid(); v.axes(); v.fn(Math.sin);` + +## 3D camera — `H.cam3d` +`const cam = H.cam3d({ yaw, pitch, scale, dist, cx, cy })`. Convention: **+y is UP** +on screen; the ground plane is x/z. Larger `depth` = farther from camera, so for +correct overlap **sort polygons/spheres DESCENDING by depth and draw far first**. +The user can drag to orbit and scroll to zoom automatically — still set a slow +default spin `cam.yaw = 0.3 * t` so it reads as 3D before they touch it. + +- `cam.project([x, y, z])` → `{ x, y, depth, f }` (screen point + depth + scale). +- `cam.yaw`, `cam.pitch` — settable; drive with `t` to rotate. +- `cam.line(a, b, opts)` — segment between two `[x,y,z]` points. +- `cam.path(points, opts)` — 3D polyline (orbits, trajectories, curves). +- `cam.poly(points, {fill, stroke, width})` — filled 3D polygon (you depth-sort). +- `cam.sphere([x,y,z], r, {color})` — **shaded** ball, world-unit radius, any CSS + color. Returns its projection. For many: sort by `cam.project(p).depth` + descending, then draw (atoms, planets, particles). +- `cam.grid(size, step)` — ground-plane grid at y=0 (depth perception). +- `cam.axes(len)` — labeled x/y/z axes. + +## Solid 3D surfaces +Use these instead of point clouds — they render filled, light-shaded, +depth-sorted meshes that genuinely look 3D. + +- `H.surface3d(cam, (x, y) => height, { xMin, xMax, yMin, yMax, nx, ny, alpha, wire, hueMin, hueMax })` + — THE way to draw any height function `z = f(x,y)`. The returned height is drawn + along the screen-up axis; color maps from height (override with `hueMin`/`hueMax`). +- `H.mesh3d(cam, (u, v) => [x, y, z], { uMin, uMax, vMin, vMax, nu, nv, hue, alpha, wire })` + — parametric surface: spheres, tori, cylinders, tubes, ribbons, Möbius strips. + `u`/`v` default to `[0, TAU]`; use a fixed `hue` (0–360). + Sphere of radius R: `(u, v) => [Math.cos(u)*Math.sin(v)*R, Math.cos(v)*R, Math.sin(u)*Math.sin(v)*R]` + with `vMin: 0, vMax: Math.PI`. + +Keep `nx`/`ny`/`nu`/`nv` ≤ ~40 each (hard-capped at 64) for smooth framerate. + +## Resilience +The runtime wraps `H`, every `view`, and every `cam` in a proxy: calling a helper +that does NOT exist returns a **chainable no-op** instead of throwing, so a single +typo (`H.spinner()`, `cam.glow()`) degrades to "that one thing didn't draw" rather +than blanking the frame. Don't rely on this — use only the documented helpers — +but it means an invented method is a quality bug, not a crash. + +## Output JSON schema +When emitting a full scene record, this is the exact shape (all fields required): + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "title": { "type": "string" }, + "tag": { "type": "string" }, + "dimension": { "type": "string", "enum": ["2D", "3D"] }, + "equation": { "type": "string" }, + "summary": { "type": "string" }, + "bullets": { "type": "array", "items": { "type": "string" } }, + "student_prompts": { "type": "array", "items": { "type": "string" } }, + "code": { "type": "string" } + }, + "required": ["title", "tag", "dimension", "equation", "summary", "bullets", "student_prompts", "code"] +} +``` +`bullets` should be exactly 3; `student_prompts` 3. `code` is the validated +`scene` body. diff --git a/stem-viz-plugin/skills/stem-viz/references/repair.md b/stem-viz-plugin/skills/stem-viz/references/repair.md new file mode 100644 index 0000000..60aeb49 --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/references/repair.md @@ -0,0 +1,55 @@ +# Repair protocol — fixing a scene that throws or comes back blank + +When `scripts/validate_scene.js` (or the live runtime) reports a problem, fix it +with the **smallest change that addresses the reported error**. The most common +mistake is rewriting the whole scene — that usually trades the original bug for a +new one. Keep everything that already works; touch only the failing line. + +## Loop +1. Run `node scripts/validate_scene.js <<'SCENE' … SCENE`. +2. Read the JSON: `ok`, `error`, `painted`, `text`, `onscreen`. +3. Apply the targeted fix for that signal (tables below). +4. **Re-validate.** Repeat only while it's making progress — if two repairs hit + the *same* error, stop and rethink the approach rather than looping. + +## `ok:false` — the scene threw. Fix by error class. + +| Error contains | What it means | Minimal fix | +|---|---|---| +| `is not defined` | A name was used before it was declared (usually a typo). | Declare it with `const`/`let`, or fix the misspelling. Do **not** add other new names. | +| `is not a function` | You called something that isn't a real helper (invented method, or a non-function value). | Use only the documented helpers (`references/helper-api.md`). Delete or replace the invalid call. | +| `Cannot read properties of undefined` / `… of null` | You read a property off `undefined`/`null` — an out-of-range array index, or a value that wasn't created. | Guard the access: check the value exists and any index is in range before use. | +| `is not iterable` | You spread or looped over a non-array. | Ensure the value is an array (default to `[]`) before iterating. | +| `Unexpected token` / `SyntaxError` | The code didn't parse — usually an unbalanced bracket or a statement cut off mid-way. | Return complete, valid JavaScript; check brackets/quotes balance. | +| `hung` / `timed out` | An unbounded or huge loop. | Bound every loop; drive animation from `t`, never a `while`-until-condition. Cap mesh `nx/ny/nu/nv` ≤ 40. | + +The error message names *what* threw; if you also have a line (the live runtime +reports `where: "line N: "`), fix exactly that line. + +## `painted:false` — nothing drew (Rule 0) +The body computed but never issued a content draw. Ensure `H.background()` is the +first line **and** at least three real drawing calls run every frame (a `for` loop +calling one counts). Setup-only code (`const`s, math, no `H.*` draw) renders a +blank canvas. + +## `text:false` — no labels (Rule 2) +Add a title (`H.text`, size 18, weight 700), a caption, and at least one live +readout. `view.axes()`/`cam.axes()` also count as labels. + +## `onscreen:false` — drawn off-canvas (the coordinate trap) +Content was drawn but none landed on the canvas — almost always **data vs pixel +coordinate mixing**. Symptoms and fixes: +- You called a `view` *data-space* method with already-mapped pixels: + `v.line(v.X(x1), v.Y(y1), …)` double-transforms. Pass **raw math coords**: + `v.line(x1, y1, x2, y2)`. +- You drew with pixel-space `H.line/H.circle` using math coordinates like + `(-3, 0.5)`. Either go through a `view`, or map with `H.map(...)` / `v.X`/`v.Y` + first. +- Your data range doesn't contain what you're plotting — widen `xMin/xMax`, + `yMin/yMax` to include the values. + +## When stuck +If the same error survives two minimal fixes, the approach is wrong, not the line. +Re-read the relevant recipe in `references/concepts.md`, or adapt the nearest +working scene in `references/examples.md` instead of patching further. A correct +scene built from a known-good template beats a heavily-patched broken one. diff --git a/stem-viz-plugin/skills/stem-viz/runtime/README.md b/stem-viz-plugin/skills/stem-viz/runtime/README.md new file mode 100644 index 0000000..dc72019 --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/runtime/README.md @@ -0,0 +1,65 @@ +# Runtime — render scenes anywhere + +This folder is the actual VisualLM rendering engine, extracted so generated +scenes run **without the VisualLM server** and with **no dependencies** beyond a +browser. It is what makes a scene from this skill *portable*: ship `runtime/` +alongside the `code` and any host can render it. + +## Files +- **`sandbox-worker.js`** — a Web Worker that compiles a `scene(ctx, t, H)` body + and runs it ~60×/s in an **OffscreenCanvas sandbox**. It defines the real `H` + helper library (the one `references/helper-api.md` documents). Hardened: no DOM, + no network, a watchdog kills runaway frames, invented helpers degrade to chainable + no-ops, and a transient throwing frame doesn't kill a running scene. +- **`host.html`** — a minimal, self-contained host page: paste a scene, press Run, + watch it render; drag to orbit 3D and scroll to zoom. No build step. + +## Quick start +Serve the folder over HTTP (workers don't load from `file://`) and open the host: + +```bash +cd runtime +python3 -m http.server 8000 +# open http://localhost:8000/host.html +``` + +## Embed in your own app +The worker speaks a small message protocol. Wire it to a canvas: + +```js +const canvas = document.querySelector("canvas"); +const offscreen = canvas.transferControlToOffscreen(); +const worker = new Worker("./sandbox-worker.js"); + +worker.onmessage = (e) => { + const m = e.data; + if (m.type === "ready") worker.postMessage({ type: "run", code: sceneBody, resetTime: true }); + if (m.type === "heartbeat") {/* a frame rendered — scene is live */} + if (m.type === "runtime-error" || m.type === "compile-error") { + console.error(m.message, m.where); // m.where = "line N: " when available + } +}; + +const dpr = Math.min(2, devicePixelRatio || 1); +worker.postMessage({ type: "init", canvas: offscreen, width: 900, height: 560, dpr }, [offscreen]); +``` + +**Messages you send:** +| type | payload | effect | +|---|---|---| +| `init` | `{canvas, width, height, dpr}` (transfer `canvas`) | one-time setup | +| `run` | `{code, resetTime}` | compile + run a scene body | +| `pause` / `resume` | — | freeze / continue | +| `speed` | `{value}` | time multiplier | +| `resize` | `{width, height, dpr}` | re-fit the canvas | +| `orbit` | `{dyaw, dpitch, dzoom}` | nudge the 3D camera (drag/scroll) | +| `orbit-reset` | — | reset the camera | + +**Messages you receive:** `ready`, `running`, `heartbeat` (every ~20 frames), +`compile-error` / `runtime-error` (`{message, stack, where}`). + +## Headless validation +To check a scene *without* a browser (CI, an agent loop), use +`../scripts/validate_scene.js` (Node only) — see SKILL.md → "Validate before +shipping". The validator mirrors this runtime's `H` surface; if you add a helper +to `sandbox-worker.js`, mirror it in the validator's mock too. diff --git a/stem-viz-plugin/skills/stem-viz/runtime/host.html b/stem-viz-plugin/skills/stem-viz/runtime/host.html new file mode 100644 index 0000000..d1e3d0a --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/runtime/host.html @@ -0,0 +1,137 @@ + + + + + + STEM-viz runtime — scene host + + + +
+
+

STEM-viz scene host

+

Paste a scene(ctx, t, H) body, press Run. Drag the canvas to orbit 3D, scroll to zoom.

+
+ +
+ + + loading… +
+
+ +
+
+
drag = orbit · scroll = zoom · double-click = reset
+
+ + + + diff --git a/stem-viz-plugin/skills/stem-viz/runtime/sandbox-worker.js b/stem-viz-plugin/skills/stem-viz/runtime/sandbox-worker.js new file mode 100644 index 0000000..85eb0a2 --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/runtime/sandbox-worker.js @@ -0,0 +1,1080 @@ +/* + * VisualLM sandbox worker. + * + * Runs AI-generated animation code inside a dedicated Web Worker with an + * OffscreenCanvas. The worker has no DOM access and no same-origin window, so + * generated code cannot touch the page, cookies, or storage. Network access is + * additionally blocked by the page CSP (connect-src 'none'). If generated code + * hangs (e.g. `while (true)`), the worker stops emitting heartbeats and the main + * thread terminates and recreates it. + * + * Contract for generated code: it is the *body* of + * function scene(ctx, t, H) { ... } + * called once per frame. `t` is elapsed seconds (honoring pause + speed). The + * function must fully redraw each frame. `H` is the helper library below. + */ + +// Defense in depth: strip network / module APIs from the worker scope before +// any generated code runs. The worker already has no DOM and no same-origin +// window; removing these closes the remaining exfiltration paths. +try { + self.fetch = undefined; + self.XMLHttpRequest = undefined; + self.WebSocket = undefined; + self.EventSource = undefined; + self.indexedDB = undefined; + // Block sub-workers: without this, generated code could spawn a sub-worker + // from a Blob URL (`new Worker(URL.createObjectURL(new Blob([...])))`) and + // get a fresh global scope with fetch/XHR re-enabled — escaping the strip + // we just did. The page also has no CSP `worker-src`, so the browser would + // not block it on its own. + self.Worker = undefined; + self.SharedWorker = undefined; + // sendBeacon and WebTransport are alternate POST/UDP channels available in + // worker scope; BroadcastChannel can leak to other same-origin tabs. + self.WebTransport = undefined; + self.BroadcastChannel = undefined; + if (self.navigator) { + try { + self.navigator.sendBeacon = undefined; + } catch (e) { + /* ignore */ + } + } + self.importScripts = function () { + throw new Error("importScripts is disabled in the sandbox."); + }; +} catch (e) { + /* ignore */ +} + +let canvas = null; +let ctx = null; +let dpr = 1; +let logicalW = 0; +let logicalH = 0; + +let sceneFn = null; +let running = false; +let paused = false; +let speed = 1; + +// User-driven camera orbit, applied on top of whatever yaw/pitch the scene +// sets. Updated by "orbit" messages from the main thread (canvas drag/wheel), +// reset when a new scene loads. Every cam3d instance reads these, so dragging +// rotates any 3D scene without the generated code having to cooperate. +const orbit = { yaw: 0, pitch: 0, zoom: 1 }; + +let simTime = 0; // accumulated, speed-scaled seconds passed to scene() +let lastWall = 0; // last wall-clock timestamp (ms) +let frameCount = 0; +// Per-frame error tolerance. A scene that has rendered at least one clean frame +// (everRendered) is kept alive through an occasional throwing frame rather than +// being killed and shipped to the slow repair loop. Only a first-frame failure +// or a sustained run of throwing frames escalates to a real runtime-error. +let consecutiveErrors = 0; +let everRendered = false; +let lastCode = ""; // raw source of the running scene, for offending-line lookup +let loopTimer = null; + +const FRAME_MS = 1000 / 60; + +function post(msg) { + self.postMessage(msg); +} + +/* ------------------------------------------------------------------ */ +/* Helper library handed to generated code as `H`. */ +/* ------------------------------------------------------------------ */ + +const TAU = Math.PI * 2; + +const COLORS = { + bg: "#0e1525", + panel: "#16203a", + ink: "#eef2ff", + sub: "#9fb0d4", + grid: "#26314f", + axis: "#566087", + accent: "#7cc4ff", + accent2: "#f4a259", + good: "#67e8b0", + warn: "#ff8aa0", + violet: "#c4a7ff", + yellow: "#ffe08a", +}; + +const PALETTE = [ + "#7cc4ff", + "#f4a259", + "#67e8b0", + "#c4a7ff", + "#ff8aa0", + "#ffe08a", + "#5eead4", + "#fca5f1", +]; + +function clamp(x, lo, hi) { + return x < lo ? lo : x > hi ? hi : x; +} +function lerp(a, b, t) { + return a + (b - a) * t; +} +function map(x, inMin, inMax, outMin, outMax) { + if (inMax === inMin) return outMin; + return outMin + ((x - inMin) * (outMax - outMin)) / (inMax - inMin); +} +function ease(t) { + t = clamp(t, 0, 1); + return t * t * (3 - 2 * t); +} + +function makeHelpers() { + const H = { + TAU, + PI: Math.PI, + colors: COLORS, + palette: PALETTE, + clamp, + lerp, + map, + ease, + get W() { + return logicalW; + }, + get H() { + return logicalH; + }, + clear(color) { + ctx.save(); + ctx.fillStyle = color || COLORS.bg; + ctx.fillRect(0, 0, logicalW, logicalH); + ctx.restore(); + }, + background(top, bottom) { + const g = ctx.createLinearGradient(0, 0, 0, logicalH); + g.addColorStop(0, top || "#101a31"); + g.addColorStop(1, bottom || COLORS.bg); + ctx.save(); + ctx.fillStyle = g; + ctx.fillRect(0, 0, logicalW, logicalH); + ctx.restore(); + }, + text(str, x, y, opts) { + opts = opts || {}; + ctx.save(); + const size = opts.size || 16; + const weight = opts.weight || 500; + const family = + opts.font || "'Inter', system-ui, -apple-system, sans-serif"; + ctx.font = `${weight} ${size}px ${family}`; + ctx.fillStyle = opts.color || COLORS.ink; + ctx.textAlign = opts.align || "left"; + ctx.textBaseline = opts.baseline || "alphabetic"; + if (opts.maxWidth) ctx.fillText(String(str), x, y, opts.maxWidth); + else ctx.fillText(String(str), x, y); + ctx.restore(); + }, + line(x1, y1, x2, y2, opts) { + opts = opts || {}; + ctx.save(); + ctx.strokeStyle = opts.color || COLORS.axis; + ctx.lineWidth = opts.width || 1.5; + ctx.lineCap = opts.cap || "round"; + if (opts.dash) ctx.setLineDash(opts.dash); + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + ctx.restore(); + }, + path(points, opts) { + if (!points || points.length < 2) return; + opts = opts || {}; + ctx.save(); + ctx.strokeStyle = opts.color || COLORS.accent; + ctx.lineWidth = opts.width || 2.5; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + if (opts.dash) ctx.setLineDash(opts.dash); + ctx.beginPath(); + ctx.moveTo(points[0][0], points[0][1]); + for (let i = 1; i < points.length; i++) + ctx.lineTo(points[i][0], points[i][1]); + if (opts.close) ctx.closePath(); + if (opts.fill) { + ctx.fillStyle = opts.fill; + ctx.fill(); + } + if (opts.color !== "none") ctx.stroke(); + ctx.restore(); + }, + circle(x, y, r, opts) { + opts = opts || {}; + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, Math.max(0, r), 0, TAU); + if (opts.fill) { + ctx.fillStyle = opts.fill; + ctx.fill(); + } + if (opts.stroke) { + ctx.strokeStyle = opts.stroke; + ctx.lineWidth = opts.width || 2; + ctx.stroke(); + } + ctx.restore(); + }, + rect(x, y, w, h, opts) { + opts = opts || {}; + ctx.save(); + const r = opts.radius || 0; + ctx.beginPath(); + if (r > 0) { + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + } else { + ctx.rect(x, y, w, h); + } + if (opts.fill) { + ctx.fillStyle = opts.fill; + ctx.fill(); + } + if (opts.stroke) { + ctx.strokeStyle = opts.stroke; + ctx.lineWidth = opts.width || 1.5; + ctx.stroke(); + } + ctx.restore(); + }, + arrow(x1, y1, x2, y2, opts) { + opts = opts || {}; + const color = opts.color || COLORS.accent; + const width = opts.width || 2.5; + const head = opts.head || 9; + ctx.save(); + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = width; + ctx.lineCap = "round"; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + const ang = Math.atan2(y2 - y1, x2 - x1); + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo( + x2 - head * Math.cos(ang - 0.4), + y2 - head * Math.sin(ang - 0.4) + ); + ctx.lineTo( + x2 - head * Math.cos(ang + 0.4), + y2 - head * Math.sin(ang + 0.4) + ); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + }, + // Map an HSL-ish index to a palette color. + color(i) { + return PALETTE[((i % PALETTE.length) + PALETTE.length) % PALETTE.length]; + }, + hsl(h, s, l, a) { + return `hsla(${h}, ${s}%, ${l}%, ${a == null ? 1 : a})`; + }, + + /* 2D plotting view. Maps data coordinates to pixels with a padded box. */ + plot2d(o) { + o = o || {}; + const pad = o.pad == null ? 46 : o.pad; + const box = o.box || { + x: pad, + y: pad * 0.6, + w: logicalW - pad * 2, + h: logicalH - pad * 1.6, + }; + const xMin = o.xMin == null ? -10 : o.xMin; + const xMax = o.xMax == null ? 10 : o.xMax; + const yMin = o.yMin == null ? -6 : o.yMin; + const yMax = o.yMax == null ? 6 : o.yMax; + const X = (v) => box.x + map(v, xMin, xMax, 0, box.w); + const Y = (v) => box.y + map(v, yMin, yMax, box.h, 0); + let view = { + box, + xMin, + xMax, + yMin, + yMax, + X, + Y, + grid(opts) { + opts = opts || {}; + const stepX = opts.stepX || niceStep(xMax - xMin); + const stepY = opts.stepY || niceStep(yMax - yMin); + ctx.save(); + ctx.strokeStyle = opts.color || COLORS.grid; + ctx.lineWidth = 1; + ctx.fillStyle = COLORS.sub; + ctx.font = "12px 'Inter', sans-serif"; + for (let gx = Math.ceil(xMin / stepX) * stepX; gx <= xMax + 1e-9; gx += stepX) { + ctx.beginPath(); + ctx.moveTo(X(gx), box.y); + ctx.lineTo(X(gx), box.y + box.h); + ctx.stroke(); + } + for (let gy = Math.ceil(yMin / stepY) * stepY; gy <= yMax + 1e-9; gy += stepY) { + ctx.beginPath(); + ctx.moveTo(box.x, Y(gy)); + ctx.lineTo(box.x + box.w, Y(gy)); + ctx.stroke(); + } + ctx.restore(); + return view; + }, + axes(opts) { + opts = opts || {}; + const x0 = clamp(0, xMin, xMax); + const y0 = clamp(0, yMin, yMax); + ctx.save(); + ctx.strokeStyle = opts.color || COLORS.axis; + ctx.lineWidth = 1.6; + ctx.beginPath(); + ctx.moveTo(box.x, Y(y0)); + ctx.lineTo(box.x + box.w, Y(y0)); + ctx.moveTo(X(x0), box.y); + ctx.lineTo(X(x0), box.y + box.h); + ctx.stroke(); + // Numeric tick labels. Scenes without axis values read as a vague + // picture instead of a graph, so these are on by default + // (opts.ticks === false disables them for stylized scenes). + if (opts.ticks !== false) { + const stepX = opts.stepX || niceStep(xMax - xMin); + const stepY = opts.stepY || niceStep(yMax - yMin); + const fmt = (v) => + Math.abs(v) >= 1000 || (Math.abs(v) < 0.01 && v !== 0) + ? v.toExponential(0) + : +v.toFixed(2) + ""; + ctx.fillStyle = opts.tickColor || COLORS.sub; + ctx.font = "11px 'Inter', sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + for (let gx = Math.ceil(xMin / stepX) * stepX; gx <= xMax + 1e-9; gx += stepX) { + if (Math.abs(gx) < stepX * 1e-6) continue; // skip 0 (origin clutter) + ctx.fillText(fmt(gx), X(gx), Y(y0) + 5); + } + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + for (let gy = Math.ceil(yMin / stepY) * stepY; gy <= yMax + 1e-9; gy += stepY) { + if (Math.abs(gy) < stepY * 1e-6) continue; + ctx.fillText(fmt(gy), X(x0) - 6, Y(gy)); + } + } + ctx.restore(); + return view; + }, + fn(f, opts) { + opts = opts || {}; + const steps = opts.steps || 240; + const pts = []; + for (let i = 0; i <= steps; i++) { + const xv = lerp(xMin, xMax, i / steps); + let yv; + try { + yv = f(xv); + } catch (e) { + yv = NaN; + } + if (Number.isFinite(yv) && yv >= yMin - 2 && yv <= yMax + 2) { + pts.push([X(xv), Y(yv)]); + } else if (pts.length) { + H.path(pts.splice(0), { color: opts.color || COLORS.accent, width: opts.width || 2.6 }); + } + } + if (pts.length) + H.path(pts, { color: opts.color || COLORS.accent, width: opts.width || 2.6 }); + return view; + }, + dot(xv, yv, opts) { + H.circle(X(xv), Y(yv), (opts && opts.r) || 5, { + fill: (opts && opts.fill) || COLORS.accent2, + stroke: (opts && opts.stroke) || COLORS.bg, + width: 2, + }); + return view; + }, + /* Data-space drawing. Models naturally write `v.line(x1,y1,x2,y2)` + * meaning math coordinates — these make that correct instead of a + * fatal "not a function". */ + line(x1, y1, x2, y2, opts) { + H.line(X(x1), Y(y1), X(x2), Y(y2), opts); + return view; + }, + arrow(x1, y1, x2, y2, opts) { + H.arrow(X(x1), Y(y1), X(x2), Y(y2), opts); + return view; + }, + text(str, xv, yv, opts) { + H.text(str, X(xv), Y(yv), opts); + return view; + }, + circle(xv, yv, r, opts) { + H.circle(X(xv), Y(yv), r, opts); // r stays in pixels + return view; + }, + path(points, opts) { + if (!points || !points.length) return view; + H.path(points.map((p) => [X(p[0]), Y(p[1])]), opts); + return view; + }, + rect(xv, yv, w, h, opts) { + // (xv, yv) is the lower-left corner in data coords; w/h in data units. + H.rect(X(xv), Y(yv + h), X(xv + w) - X(xv), Y(yv) - Y(yv + h), opts); + return view; + }, + }; + // Same tolerance as H itself: an invented view method becomes a no-op + // instead of killing the whole frame with "v.foo is not a function". + // Reassign the `view` binding to the proxy so every `return view` inside + // the methods above yields the wrapped object — that keeps invented + // helpers no-op-able even mid-chain (e.g. view.fn(...).annotate(...)). + view = wrapHelpers(view); + return view; + }, + + /* 3D camera. project([x,y,z]) -> {x, y, depth, f}. Larger depth = farther + * from the camera, so painter's algorithm = sort DESCENDING by depth and + * draw in order. User drag/zoom (the `orbit` worker global) is layered on + * top of the scene's own yaw/pitch automatically. Convention: y is UP. */ + cam3d(o) { + o = o || {}; + let yaw = o.yaw || 0; + let pitch = o.pitch == null ? -0.5 : o.pitch; + const scale = o.scale || 60; + const dist = o.dist || 9; + const cx = o.cx == null ? logicalW / 2 : o.cx; + const cy = o.cy == null ? logicalH / 2 : o.cy; + let cam = { + set yaw(v) { + yaw = v; + }, + get yaw() { + return yaw; + }, + set pitch(v) { + pitch = v; + }, + get pitch() { + return pitch; + }, + project(p) { + let x = p[0], + y = p[1], + z = p[2]; + const ya = yaw + orbit.yaw; + const pa = clamp(pitch + orbit.pitch, -1.45, 1.45); + // yaw about Y + let xz = x * Math.cos(ya) - z * Math.sin(ya); + let zz = x * Math.sin(ya) + z * Math.cos(ya); + x = xz; + z = zz; + // pitch about X + let yz = y * Math.cos(pa) - z * Math.sin(pa); + let zp = y * Math.sin(pa) + z * Math.cos(pa); + y = yz; + z = zp; + const f = (dist / (dist + z)) * orbit.zoom; + return { x: cx + x * scale * f, y: cy - y * scale * f, depth: z, f }; + }, + /* Straight 3D segment. */ + line(a, b, opts) { + const p1 = cam.project(a); + const p2 = cam.project(b); + H.line(p1.x, p1.y, p2.x, p2.y, opts); + return cam; + }, + /* Polyline through 3D points: [[x,y,z], ...]. */ + path(points, opts) { + if (!points || points.length < 2) return cam; + const px = []; + for (let i = 0; i < points.length; i++) { + const p = cam.project(points[i]); + px.push([p.x, p.y]); + } + H.path(px, opts); + return cam; + }, + /* Filled/stroked 3D polygon (caller is responsible for depth order). */ + poly(points, opts) { + if (!points || points.length < 3) return cam; + opts = opts || {}; + const px = []; + for (let i = 0; i < points.length; i++) { + const p = cam.project(points[i]); + px.push([p.x, p.y]); + } + H.path(px, { + color: opts.stroke || opts.color || "none", + width: opts.width || 1, + fill: opts.fill, + close: true, + }); + return cam; + }, + /* Shaded ball at a 3D point. Radius is in WORLD units (scales with + * perspective). Works with any CSS base color. Returns the projected + * point so callers can depth-sort before drawing. */ + sphere(p, r, opts) { + opts = opts || {}; + const q = cam.project(p); + const rr = Math.max(0.5, r * scale * q.f); + const base = opts.color || COLORS.accent; + ctx.save(); + ctx.beginPath(); + ctx.arc(q.x, q.y, rr, 0, TAU); + ctx.fillStyle = base; + ctx.fill(); + // Highlight toward the light, darkened rim away from it. Layered + // gradients shade any base color without parsing it. + const hx = q.x - rr * 0.35; + const hy = q.y - rr * 0.4; + let g = ctx.createRadialGradient(hx, hy, rr * 0.05, hx, hy, rr * 1.25); + g.addColorStop(0, "rgba(255,255,255,0.65)"); + g.addColorStop(0.45, "rgba(255,255,255,0.08)"); + g.addColorStop(1, "rgba(8,10,24,0.55)"); + ctx.fillStyle = g; + ctx.fill(); + if (opts.stroke) { + ctx.strokeStyle = opts.stroke; + ctx.lineWidth = opts.width || 1; + ctx.stroke(); + } + ctx.restore(); + return q; + }, + /* Ground-plane grid at y=0 for depth perception. Inputs come from + * generated code, so both are sanitized: a zero/negative/NaN step or + * a huge size would otherwise hang the worker. */ + grid(size, step, opts) { + size = Number.isFinite(size) && size > 0 ? Math.min(size, 1000) : 4; + step = Number.isFinite(step) && step > 0 ? step : 1; + if (size / step > 80) step = size / 80; // cap at 161 lines per axis + opts = opts || {}; + const color = opts.color || COLORS.grid; + const width = opts.width || 1; + for (let v = -size; v <= size + 1e-9; v += step) { + cam.line([v, 0, -size], [v, 0, size], { color, width }); + cam.line([-size, 0, v], [size, 0, v], { color, width }); + } + return cam; + }, + axes(len, opts) { + len = Number.isFinite(len) && len > 0 ? Math.min(len, 1000) : 3; + opts = opts || {}; + const o0 = cam.project([0, 0, 0]); + const ax = [ + [[len, 0, 0], COLORS.accent, "x"], + [[0, len, 0], COLORS.good, "y"], + [[0, 0, len], COLORS.accent2, "z"], + ]; + ax.forEach(([v, c, label]) => { + const p = cam.project(v); + H.arrow(o0.x, o0.y, p.x, p.y, { color: c, width: 2 }); + H.text(label, p.x + 4, p.y - 4, { color: c, size: 13 }); + }); + // Unit tick marks + numbers so 3D scenes have a readable scale. + // Skipped for long axes (labels would smear together). + if (opts.ticks !== false && len <= 12) { + const step = len > 6 ? 2 : 1; + for (let v = step; v <= len - step * 0.5; v += step) { + ax.forEach(([dir, c]) => { + const u = [ + (dir[0] / len) * v, + (dir[1] / len) * v, + (dir[2] / len) * v, + ]; + const p = cam.project(u); + H.circle(p.x, p.y, 1.6, { fill: c }); + H.text(String(v), p.x + 3, p.y - 3, { + color: COLORS.sub, + size: 10, + }); + }); + } + } + return cam; + }, + }; + // Invented cam methods degrade to no-ops, like H and plot2d views. + // Reassign the `cam` binding to the proxy so every `return cam` inside the + // methods above yields the wrapped object — chains keep working in any + // order (e.g. cam.grid().glow(), cam.line(...).spiral(...)). + cam = wrapHelpers(cam); + return cam; + }, + + /* Solid, lit, depth-sorted height surface: screenHeight = f(x, y). + * THE way to draw z = f(x, y) surfaces. `f` receives the two ground-plane + * coordinates and returns the height drawn along the screen-up axis. */ + surface3d(cam, f, opts) { + opts = opts || {}; + const xMin = opts.xMin == null ? -3 : opts.xMin; + const xMax = opts.xMax == null ? 3 : opts.xMax; + const yMin = opts.yMin == null ? -3 : opts.yMin; + const yMax = opts.yMax == null ? 3 : opts.yMax; + const nx = clamp(Math.round(opts.nx || 36), 4, 64); + const ny = clamp(Math.round(opts.ny || 36), 4, 64); + // Sample the grid once; reuse corner samples between quads. + const pts = []; + let hMin = Infinity; + let hMax = -Infinity; + for (let j = 0; j <= ny; j++) { + const row = []; + for (let i = 0; i <= nx; i++) { + const x = map(i, 0, nx, xMin, xMax); + const y = map(j, 0, ny, yMin, yMax); + let h; + try { + h = f(x, y); + } catch (e) { + h = NaN; + } + if (Number.isFinite(h)) { + if (h < hMin) hMin = h; + if (h > hMax) hMax = h; + row.push([x, h, y]); + } else { + row.push(null); + } + } + pts.push(row); + } + if (hMin > hMax) return; // nothing finite to draw + const quads = []; + for (let j = 0; j < ny; j++) { + for (let i = 0; i < nx; i++) { + const a = pts[j][i]; + const b = pts[j][i + 1]; + const c = pts[j + 1][i + 1]; + const d = pts[j + 1][i]; + if (!a || !b || !c || !d) continue; + const value = + hMax > hMin + ? ((a[1] + b[1] + c[1] + d[1]) / 4 - hMin) / (hMax - hMin) + : 0.5; + quads.push({ pts: [a, b, c, d], value }); + } + } + drawShadedQuads(cam, quads, opts); + }, + + /* Solid, lit, depth-sorted parametric surface: fn(u, v) -> [x, y, z]. + * Spheres, tori, cylinders, tubes, ribbons, Möbius strips, orbitals... */ + mesh3d(cam, fn, opts) { + opts = opts || {}; + const uMin = opts.uMin == null ? 0 : opts.uMin; + const uMax = opts.uMax == null ? TAU : opts.uMax; + const vMin = opts.vMin == null ? 0 : opts.vMin; + const vMax = opts.vMax == null ? TAU : opts.vMax; + const nu = clamp(Math.round(opts.nu || 32), 3, 64); + const nv = clamp(Math.round(opts.nv || 18), 3, 64); + const pts = []; + let hMin = Infinity; + let hMax = -Infinity; + for (let j = 0; j <= nv; j++) { + const row = []; + for (let i = 0; i <= nu; i++) { + const u = map(i, 0, nu, uMin, uMax); + const v = map(j, 0, nv, vMin, vMax); + let p; + try { + p = fn(u, v); + } catch (e) { + p = null; + } + if ( + p && + Number.isFinite(p[0]) && + Number.isFinite(p[1]) && + Number.isFinite(p[2]) + ) { + if (p[1] < hMin) hMin = p[1]; + if (p[1] > hMax) hMax = p[1]; + row.push(p); + } else { + row.push(null); + } + } + pts.push(row); + } + if (hMin > hMax) return; + const quads = []; + for (let j = 0; j < nv; j++) { + for (let i = 0; i < nu; i++) { + const a = pts[j][i]; + const b = pts[j][i + 1]; + const c = pts[j + 1][i + 1]; + const d = pts[j + 1][i]; + if (!a || !b || !c || !d) continue; + const value = + hMax > hMin + ? ((a[1] + b[1] + c[1] + d[1]) / 4 - hMin) / (hMax - hMin) + : 0.5; + quads.push({ pts: [a, b, c, d], value }); + } + } + drawShadedQuads(cam, quads, opts); + }, + + // Convenience: draw a soft legend chip. + legend(items, x, y) { + ctx.save(); + let cy = y; + items.forEach((it) => { + H.circle(x + 6, cy - 4, 5, { fill: it.color }); + H.text(it.label, x + 18, cy, { size: 13, color: COLORS.sub }); + cy += 20; + }); + ctx.restore(); + }, + }; + return H; +} + +/* Shared core of surface3d / mesh3d: project quads, Lambert-shade them from a + * fixed light, sort far-to-near, and fill. Color options: + * hue — fixed hue (parametric meshes default to 210) + * hueMin/hueMax — height-mapped hue ramp (surfaces default to 215 → 25) + * colorFn(value, lambert) — full custom CSS color escape hatch + * alpha, wire (stroke the quad edges, default true), shade (default true) + */ +const LIGHT_DIR = (() => { + const v = [0.45, 0.85, 0.35]; + const n = Math.hypot(v[0], v[1], v[2]); + return [v[0] / n, v[1] / n, v[2] / n]; +})(); + +function drawShadedQuads(cam, quads, opts) { + opts = opts || {}; + const alpha = opts.alpha == null ? 0.96 : clamp(opts.alpha, 0.05, 1); + const wire = opts.wire !== false; + const shade = opts.shade !== false; + const hueFixed = opts.hue; + const hueMin = opts.hueMin == null ? 215 : opts.hueMin; + const hueMax = opts.hueMax == null ? 25 : opts.hueMax; + const polys = []; + for (let k = 0; k < quads.length; k++) { + const q = quads[k]; + const [a, b, c, d] = q.pts; + // Normal from the diagonals — stable even for non-planar quads. + const u = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + const v = [d[0] - b[0], d[1] - b[1], d[2] - b[2]]; + let nx = u[1] * v[2] - u[2] * v[1]; + let ny = u[2] * v[0] - u[0] * v[2]; + let nz = u[0] * v[1] - u[1] * v[0]; + const nl = Math.hypot(nx, ny, nz) || 1; + nx /= nl; + ny /= nl; + nz /= nl; + // abs(): quad winding is arbitrary, light both faces. + const lambert = Math.abs( + nx * LIGHT_DIR[0] + ny * LIGHT_DIR[1] + nz * LIGHT_DIR[2] + ); + const pr = [ + cam.project(a), + cam.project(b), + cam.project(c), + cam.project(d), + ]; + polys.push({ + pr, + depth: (pr[0].depth + pr[1].depth + pr[2].depth + pr[3].depth) / 4, + lambert: shade ? 0.25 + 0.75 * lambert : 1, + value: q.value, + }); + } + // Painter's algorithm: larger depth = farther; draw far first. + polys.sort((p1, p2) => p2.depth - p1.depth); + ctx.save(); + ctx.lineJoin = "round"; + for (let k = 0; k < polys.length; k++) { + const p = polys[k]; + let fill; + if (typeof opts.colorFn === "function") { + try { + fill = opts.colorFn(p.value, p.lambert); + } catch (e) { + fill = null; + } + } + if (!fill) { + const hue = + hueFixed == null ? lerp(hueMin, hueMax, p.value) : hueFixed; + const lightness = clamp(18 + 44 * p.lambert, 8, 78); + fill = `hsla(${hue}, 72%, ${lightness}%, ${alpha})`; + } + ctx.beginPath(); + ctx.moveTo(p.pr[0].x, p.pr[0].y); + ctx.lineTo(p.pr[1].x, p.pr[1].y); + ctx.lineTo(p.pr[2].x, p.pr[2].y); + ctx.lineTo(p.pr[3].x, p.pr[3].y); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + if (wire) { + ctx.strokeStyle = "rgba(10, 14, 30, 0.35)"; + ctx.lineWidth = 0.7; + ctx.stroke(); + } + } + ctx.restore(); +} + +function niceStep(range) { + const raw = range / 8; + const mag = Math.pow(10, Math.floor(Math.log10(raw))); + const norm = raw / mag; + let step; + if (norm < 1.5) step = 1; + else if (norm < 3) step = 2; + else if (norm < 7) step = 5; + else step = 10; + return step * mag; +} + +let HELP = null; + +/* Safety net for a common model mistake: referencing `cam` / `view` / `v` + * without creating them first (previously a fatal ReferenceError that burned + * the whole repair budget). These globals provide sane defaults; code that + * properly declares `const cam = H.cam3d({...})` shadows them cleanly, same + * as the `H` global. */ +function seedConvenienceGlobals() { + if (!HELP) return; + self.cam = HELP.cam3d({}); + self.view = HELP.plot2d({}); + self.v = self.view; +} + +/* Wrap the helper object in a Proxy that returns a harmless no-op for any + * helper the model invents but we don't ship. Without this, a single + * `H.spinner()`-style typo blanks the whole frame; with it, the rest of the + * scene still renders and the model just doesn't get that specific helper. */ +function wrapHelpers(h) { + if (typeof Proxy === "undefined") return h; + const proxy = new Proxy(h, { + get(target, key) { + // Real helpers and any symbol access (Symbol.toPrimitive, iterators, …) + // pass straight through — intercepting symbols would break coercion. + if (key in target || typeof key === "symbol") return target[key]; + // An invented helper (`H.spinner()`, `cam.spiral()`, `view.heatmap()`). + // Return a no-op that RETURNS THE HOST so chains keep flowing: + // `cam.invented(...).line(...)` used to throw "Cannot read properties of + // undefined (reading 'line')" and burn the whole repair budget. Now the + // invented call does nothing and `.line(...)` resolves on the real host. + // A bare `H.invented(...)` still just no-ops. + return function chainableNoop() { + return proxy; + }; + }, + }); + return proxy; +} + +/* ------------------------------------------------------------------ */ +/* Frame loop */ +/* ------------------------------------------------------------------ */ + +function compile(code) { + // Important shadowing fix: we used to pass `H` as a function parameter, + // which made `const H = H.H` in the generated code throw a SyntaxError + // ("Identifier 'H' has already been declared") and the whole scene died. + // + // Solution: expose H as a *worker global* instead. Generated code that + // references bare `H` still resolves it through the global scope chain, + // BUT a `const H = ...` declaration in the function body now shadows the + // global cleanly — no syntax error, and the local `H` overrides for the + // rest of that block, which is what the model intended anyway. + // + // ctx is kept as a parameter (we never see the model redeclare it). + self.H = HELP; + /* eslint-disable no-new-func */ + const factory = new Function( + "ctx", + "t", + '"use strict";\n' + code + "\n" + ); + return factory; +} + +// A scene that throws every frame from t=0 is broken and must go to repair. +// One that renders cleanly then throws on a single frame (a NaN at one value of +// `t`, a transient out-of-range index) should NOT — killing it wastes a full +// repair round-trip on a scene that's 99% working. We escalate only when the +// scene never produced a clean frame, or has thrown continuously for ~half a +// second (state is corrupted, not a one-frame blip). +const MAX_CONSECUTIVE_ERRORS = 30; // ~0.5s at 60fps + +// Best-effort: pull the offending source line out of a V8 `new Function` stack +// frame (`:LINE:COL`). The compiled wrapper adds 3 lines ahead of the +// user's code (the `function(ctx,t)` header, the `) {` line, and the injected +// `"use strict";`), so user line = anonLine - 3. Non-V8 engines format stacks +// differently, miss the regex, and yield "" — the repair model then falls back +// to the message + code alone. Handing the model the exact failing line cuts +// repair rounds, especially for terse errors like "Cannot read properties of +// undefined" that don't say *which* access broke. +function offendingLine(stack, code) { + try { + if (!stack || !code) return ""; + const m = /:(\d+):\d+/.exec(stack); + if (!m) return ""; + const lineNo = parseInt(m[1], 10) - 3; // 1-based line within `code` + const lines = code.split("\n"); + if (lineNo < 1 || lineNo > lines.length) return ""; + const text = String(lines[lineNo - 1]).trim(); + return text ? "line " + lineNo + ": " + text : ""; + } catch (e) { + return ""; + } +} + +function tick() { + if (!running) return; + const now = performance.now(); + if (!paused) { + const dt = Math.min(0.05, (now - lastWall) / 1000); // clamp big gaps + simTime += dt * speed; + } + lastWall = now; + + if (sceneFn) { + try { + ctx.clearRect(0, 0, logicalW, logicalH); + sceneFn(ctx, simTime); // H is a global (see compile()) + everRendered = true; + consecutiveErrors = 0; + } catch (err) { + consecutiveErrors++; + if (!everRendered || consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + running = false; + post({ + type: "runtime-error", + message: String((err && err.message) || err), + stack: String((err && err.stack) || ""), + where: offendingLine(err && err.stack, lastCode), + }); + return; + } + // Transient bad frame: skip drawing it, keep the loop alive. + } + } + + frameCount++; + // Heartbeat on the first clean frame (so the main thread's run() promise + // resolves ~immediately rather than after ~20 frames), then every 20 frames. + if (everRendered && (frameCount === 1 || frameCount % 20 === 0)) { + post({ type: "heartbeat", frame: frameCount, t: simTime }); + } + + loopTimer = setTimeout(tick, FRAME_MS); +} + +/* ------------------------------------------------------------------ */ +/* Message handling */ +/* ------------------------------------------------------------------ */ + +self.onmessage = (e) => { + const m = e.data || {}; + switch (m.type) { + case "init": { + canvas = m.canvas; + dpr = m.dpr || 1; + logicalW = m.width; + logicalH = m.height; + canvas.width = Math.round(logicalW * dpr); + canvas.height = Math.round(logicalH * dpr); + ctx = canvas.getContext("2d"); + ctx.scale(dpr, dpr); + HELP = wrapHelpers(makeHelpers()); + seedConvenienceGlobals(); + post({ type: "ready" }); + break; + } + case "resize": { + if (!canvas) return; + logicalW = m.width; + logicalH = m.height; + // Honor the new DPR when the user drags the window across displays — + // otherwise text/strokes go blurry on a switch into Retina (or aliased + // when going back). + if (typeof m.dpr === "number" && m.dpr > 0) dpr = m.dpr; + canvas.width = Math.round(logicalW * dpr); + canvas.height = Math.round(logicalH * dpr); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + seedConvenienceGlobals(); // defaults capture canvas center/box at creation + break; + } + case "run": { + try { + const fn = compile(m.code); + sceneFn = fn; + lastCode = typeof m.code === "string" ? m.code : ""; + if (m.resetTime !== false) { + simTime = 0; + orbit.yaw = 0; + orbit.pitch = 0; + orbit.zoom = 1; + } + frameCount = 0; + consecutiveErrors = 0; + everRendered = false; + paused = false; + lastWall = performance.now(); + if (!running) { + running = true; + tick(); + } + post({ type: "running" }); + } catch (err) { + post({ + type: "compile-error", + message: String((err && err.message) || err), + }); + } + break; + } + case "pause": + paused = true; + break; + case "resume": + paused = false; + lastWall = performance.now(); + break; + case "speed": + speed = m.value; + break; + case "orbit": + // Camera drag/zoom from the main thread. Applied inside cam3d.project, + // so it affects every 3D scene without the generated code's cooperation. + orbit.yaw += m.dyaw || 0; + orbit.pitch = clamp(orbit.pitch + (m.dpitch || 0), -1.45, 1.45); + if (m.dzoom) orbit.zoom = clamp(orbit.zoom * m.dzoom, 0.35, 4); + break; + case "orbit-reset": + orbit.yaw = 0; + orbit.pitch = 0; + orbit.zoom = 1; + break; + case "stop": + running = false; + if (loopTimer) clearTimeout(loopTimer); + break; + default: + break; + } +}; diff --git a/stem-viz-plugin/skills/stem-viz/scripts/validate_scene.js b/stem-viz-plugin/skills/stem-viz/scripts/validate_scene.js new file mode 100644 index 0000000..c46a3be --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/scripts/validate_scene.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node +/* + * Headless validator for VisualLM scene code. + * + * Reads a scene body (the inside of `function scene(ctx, t) { ... }`) on stdin + * and runs it against a BEHAVIOR-FAITHFUL mock of the worker's `H` helper + * library + a counting 2D context, at several values of `t`. Reports JSON on + * stdout: + * ok — did the body run without throwing at every sampled frame? + * error — first throw's message (null if ok) + * painted — did it issue any content draw call (background/clear excluded)? + * text — did it draw any text (title/labels/readouts)? + * paint — content draw-call count + * + * This lets the Python server validate (and drive repairs) WITHOUT a browser + * round-trip — the slow part of the old loop. + * + * SECURITY: the scene is AI-generated, hence untrusted. It runs in a FRESH, + * EMPTY V8 context via `vm` — no `process`, `require`, `console`, timers, or + * dynamic `import` reach it, and NO host object is passed in (the classic + * `obj.constructor.constructor("return process")()` escape needs a host object + * on the prototype chain; we hand in nothing and read results back only as a + * primitive JSON string). A hard `timeout` kills infinite loops. + * + * The mock mirrors the helper API SURFACE (return shapes + chaining), not the + * pixel internals: legitimate scenes never false-fail, and genuine + * SyntaxError/ReferenceError/TypeError throws are caught exactly as the browser + * sandbox would catch them. Keep the method list in sync with + * sandbox-worker.js's makeHelpers() when helpers are added. + */ +"use strict"; + +const vm = require("vm"); + +// The mock helper library, as source evaluated INSIDE the sandbox context so +// every object's prototype chain stays inside the sandbox (no host leak). No +// backticks in here — this whole thing is a template literal. `var`-declared +// names (H/ctx/cam/view/v/paint/text) persist on the context global. +const SANDBOX_SRC = ` +var paint = 0, text = 0, conscr = 0; +var console = { log: function(){}, warn: function(){}, error: function(){}, info: function(){} }; +var W = 900, H_ = 560, TAU = Math.PI * 2; +// On-screen test (generous margin). A draw whose points all land far outside +// the canvas is invisible — the tell-tale of mixing data and pixel coords +// (e.g. v.line(v.X(x), ...) double-transforms). 'paint' counts CONTENT draws +// (not background/grid/axes scaffold); 'conscr' counts those that land +// on-screen. paint>0 with conscr===0 means "everything was drawn off-screen". +function inb(x, y){ return typeof x === "number" && typeof y === "number" && isFinite(x) && isFinite(y) && x >= -120 && x <= W + 120 && y >= -120 && y <= H_ + 120; } +function onAny(pairs){ for (var i = 0; i < pairs.length; i++) if (inb(pairs[i][0], pairs[i][1])) return true; return false; } +function content(on){ paint++; if (on) conscr++; } +var COLORS = { + bg:"#0e1525", panel:"#16203a", ink:"#eef2ff", sub:"#9fb0d4", grid:"#26314f", + axis:"#566087", accent:"#7cc4ff", accent2:"#f4a259", good:"#67e8b0", + warn:"#ff8aa0", violet:"#c4a7ff", yellow:"#ffe08a" +}; +var PALETTE = ["#7cc4ff","#f4a259","#67e8b0","#c4a7ff","#ff8aa0","#ffe08a","#5eead4","#fca5f1"]; +function clamp(x,lo,hi){ return xhi?hi:x; } +function lerp(a,b,t){ return a+(b-a)*t; } +function map(x,a,b,c,d){ return b===a?c:c+((x-a)*(d-c))/(b-a); } +function ease(t){ t=clamp(t,0,1); return t*t*(3-2*t); } +function wrap(obj){ + return new Proxy(obj, { get: function(t,k){ if(k in t) return t[k]; return function(){ return undefined; }; } }); +} +function makeCtx(){ + var grad = { addColorStop: function(){} }; + var PAINT = { fill:1, stroke:1, fillRect:1, strokeRect:1, fillText:1, strokeText:1, drawImage:1, putImageData:1 }; + var TEXTOP = { fillText:1, strokeText:1 }; + var target = { + canvas: { width: W, height: H_ }, + createLinearGradient: function(){ return grad; }, + createRadialGradient: function(){ return grad; }, + createPattern: function(){ return null; }, + measureText: function(){ return { width: 8 }; }, + getImageData: function(){ return { data: [], width: 0, height: 0 }; }, + setLineDash: function(){}, getLineDash: function(){ return []; } + }; + return new Proxy(target, { + get: function(t,k){ + if(k in t) return t[k]; + if(typeof k !== "string") return undefined; + return function(){ if(PAINT[k]) paint++; if(TEXTOP[k]) text++; return undefined; }; + }, + set: function(){ return true; } + }); +} +function makeH(){ + var H = { + TAU: TAU, PI: Math.PI, colors: COLORS, palette: PALETTE, + clamp: clamp, lerp: lerp, map: map, ease: ease, W: W, H: H_, + clear: function(){}, background: function(){}, + text: function(s,x,y){ text++; content(inb(x,y)); }, + line: function(x1,y1,x2,y2){ content(onAny([[x1,y1],[x2,y2]])); }, + path: function(p){ if(p && p.length>=2) content(onAny(p)); }, + circle: function(x,y){ content(inb(x,y)); }, + rect: function(x,y,w,h){ content(onAny([[x,y],[x+w,y+h]])); }, + arrow: function(x1,y1,x2,y2){ content(onAny([[x1,y1],[x2,y2]])); }, + legend: function(items){ (items||[]).forEach(function(){ text++; }); content(true); }, + color: function(i){ return PALETTE[((i%PALETTE.length)+PALETTE.length)%PALETTE.length]; }, + hsl: function(h,s,l,a){ return "hsla("+h+","+s+"%,"+l+"%,"+(a==null?1:a)+")"; }, + plot2d: function(o){ + o=o||{}; + var xMin=o.xMin==null?-10:o.xMin, xMax=o.xMax==null?10:o.xMax; + var yMin=o.yMin==null?-6:o.yMin, yMax=o.yMax==null?6:o.yMax; + var pad=o.pad==null?46:o.pad; + var box=o.box||{ x:pad, y:pad*0.6, w:W-pad*2, h:H_-pad*1.6 }; + var X=function(v){ return box.x+map(v,xMin,xMax,0,box.w); }; + var Y=function(v){ return box.y+map(v,yMin,yMax,box.h,0); }; + // grid/axes are scaffold (don't count as content); fn maps internally so + // it's reliably on-screen. The data-space methods convert via X/Y then + // check bounds, so a scene that wraps args in v.X()/v.Y() (double map) + // shows up as off-screen content. + var view={ + box:box, xMin:xMin, xMax:xMax, yMin:yMin, yMax:yMax, X:X, Y:Y, + grid:function(){ return view; }, + axes:function(){ text++; return view; }, + fn:function(f){ for(var i=0;i<=12;i++){ try{ f(lerp(xMin,xMax,i/12)); }catch(e){} } content(true); return view; }, + dot:function(x,y){ content(inb(X(x),Y(y))); return view; }, + line:function(x1,y1,x2,y2){ content(onAny([[X(x1),Y(y1)],[X(x2),Y(y2)]])); return view; }, + arrow:function(x1,y1,x2,y2){ content(onAny([[X(x1),Y(y1)],[X(x2),Y(y2)]])); return view; }, + text:function(s,x,y){ text++; content(inb(X(x),Y(y))); return view; }, + circle:function(x,y){ content(inb(X(x),Y(y))); return view; }, + path:function(p){ if(p && p.length){ var q=[]; for(var i=0;i=2){ var q=[]; for(var i=0;i=3){ var q=[]; for(var i=0;i { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (c) => (data += c)); + process.stdin.on("end", () => resolve(data)); + }); +} + +(async () => { + const code = await readStdin(); + let result = { ok: false, error: null, painted: false, text: false, paint: 0, onscreen: true }; + + // The runner: define the scene as the body of a function (so a scene's own + // `const v = ...` shadows the global v instead of colliding with a + // parameter), call it at several frames, and RETURN a JSON string. Building + // it with string concatenation means the scene's own backticks/quotes need + // no escaping. A `};` injection only lands the attacker back in this same + // empty sandbox — no escalation. + const runner = + SANDBOX_SRC + + "\n;(function(){var __ok=false,__err=null;try{var __s=function(ctx,t){\n" + + code + + "\n};var __ts=[0,0.4,1.3,3.0];for(var __i=0;__i<__ts.length;__i++){__s(ctx,__ts[__i]);}__ok=true;}" + + "catch(e){__ok=false;__err=(e&&e.message)?String(e.message):String(e);}" + + "return JSON.stringify({ok:__ok,error:__err,paint:paint,text:text,conscr:conscr});})()"; + + try { + const context = vm.createContext(Object.create(null)); + const out = vm.runInContext(runner, context, { timeout: 2000 }); + const parsed = JSON.parse(out); + result.ok = !!parsed.ok; + result.error = parsed.error || null; + result.paint = parsed.paint || 0; + result.painted = (parsed.paint || 0) > 0; + result.text = (parsed.text || 0) > 0; + // Content was drawn, but none of it landed on the canvas → the scene is + // effectively blank (almost always a data-vs-pixel coordinate mixup). + result.onscreen = !((parsed.paint || 0) > 0 && (parsed.conscr || 0) === 0); + } catch (err) { + const msg = err && err.message ? String(err.message) : String(err); + if (/timed out|execution timed/i.test(msg)) { + result.error = "The scene hung (possible infinite loop)."; + } else { + result.error = msg; // SyntaxError etc. — compile failed before running + } + } + process.stdout.write(JSON.stringify(result)); +})(); diff --git a/styles.css b/styles.css index 77e1643..049b1d2 100644 --- a/styles.css +++ b/styles.css @@ -75,7 +75,15 @@ html, body { } body { - min-height: 100vh; + /* App-shell: a fixed-height flex column. The top workspace and the resizable + * AI Tutor split the viewport; each region scrolls internally instead of the + * whole page scrolling. Mobile reverts to a normal scrolling stack (see the + * max-width:900px block) so nothing is ever trapped off-screen. */ + height: 100vh; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; } h1, h2, h3, h4 { @@ -128,10 +136,11 @@ textarea:focus, input[type="text"]:focus { /* ============================================================== */ .topbar { + flex: none; display: flex; align-items: center; justify-content: space-between; - padding: 12px 22px; + padding: 9px 20px; background: var(--bg); border-bottom: 1px solid var(--border); position: sticky; @@ -185,10 +194,25 @@ textarea:focus, input[type="text"]:focus { .workspace { display: grid; - grid-template-columns: minmax(280px, 360px) minmax(0, 1fr) minmax(260px, 340px); - gap: 14px; - padding: 14px 16px; + /* Five tracks: [left] [handle] [center] [handle] [right]. The left and right + * column WIDTHS are user-draggable (--col-left / --col-right, persisted via + * JS); the center canvas takes whatever remains. Only width changes here — the + * height is governed by the app-shell row below. The two 12px handle columns + * replace the old gap. */ + grid-template-columns: + clamp(200px, var(--col-left, 280px), 460px) + 12px + minmax(0, 1fr) + 12px + clamp(190px, var(--col-right, 280px), 420px); + /* The top "self-adapts": as a flex:1 region it fills whatever height the + * app-shell leaves above the resizable tutor. The single grid row fills that + * height; the three cards stay equal-height and scroll internally past it. */ + grid-template-rows: minmax(0, 1fr); + gap: 0; + padding: 10px 16px 0; min-height: 0; + flex: 1 1 0; align-items: stretch; } @@ -258,12 +282,12 @@ textarea:focus, input[type="text"]:focus { } .tab-panel { - padding: 18px; + padding: 16px; overflow-y: auto; flex: 1; display: flex; flex-direction: column; - gap: 18px; + gap: 14px; } .tab-panel[hidden] { display: none; } @@ -477,14 +501,129 @@ textarea:focus, input[type="text"]:focus { padding: 6px 0; } +/* ============================================================== */ +/* Study Packs (DLC) */ +/* ============================================================== */ + +.dlc-catalog { display: grid; gap: 8px; } +.dlc-card { + display: flex; + gap: 10px; + align-items: flex-start; + width: 100%; + text-align: left; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 12px; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} +.dlc-card:hover { background: var(--hover); border-color: var(--border-2); } +.dlc-card.custom { border-style: dashed; } +.dlc-card-icon { font-size: 20px; line-height: 1.2; flex: none; } +.dlc-card-body { display: grid; gap: 2px; min-width: 0; } +.dlc-card-body strong { font-size: 13px; color: var(--text); } +.dlc-card-meta { font-size: 11px; color: var(--info); font-weight: 500; } +.dlc-card-desc { + font-size: 11.5px; + color: var(--subtle); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.dlc-back { + background: transparent; + border: 0; + color: var(--muted); + font-size: 12px; + font-weight: 500; + cursor: pointer; + padding: 0 0 8px; +} +.dlc-back:hover { color: var(--text); } +.dlc-detail { display: grid; gap: 4px; } +.dlc-detail-head { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 4px; } +.dlc-detail-head strong { font-size: 14px; color: var(--text); } +.dlc-detail-desc { font-size: 11.5px; color: var(--subtle); line-height: 1.45; margin-top: 3px; } +.dlc-remove { + font-size: 10.5px; + color: var(--warn); + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + padding: 1px 7px; + cursor: pointer; +} +.dlc-remove:hover { border-color: rgba(252, 165, 165, 0.4); } +.dlc-dl { + font-size: 10.5px; + color: var(--info); + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + padding: 1px 7px; + cursor: pointer; +} +.dlc-dl:hover { border-color: var(--border-2); background: var(--hover); } +.dlc-group-title { + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--subtle); + margin: 12px 0 4px; + font-weight: 600; +} +.dlc-demo { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; + cursor: pointer; + font-size: 12.5px; + color: var(--text); + transition: background 120ms ease, border-color 120ms ease; +} +.dlc-demo:hover { background: var(--hover); border-color: var(--border-2); } +.dlc-demo > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dlc-demo code { + color: var(--info); + background: transparent; + font-size: 11px; + flex: none; + max-width: 42%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dlc-msg { font-size: 12px; color: var(--good); padding: 4px 0; } +.dlc-msg.error { color: var(--warn); } +.dlc-drop code { color: var(--info); background: transparent; padding: 0; font-size: 12px; } +.dlc-material { min-height: 76px; font-size: 12.5px; margin-top: 8px; } +.dlc-name { margin-top: 8px; } +#dlcGenerate { width: 100%; margin-top: 8px; } + /* ============================================================== */ /* CENTER — stage / canvas */ /* ============================================================== */ .stage-panel { - padding: 14px; + padding: 12px; background: var(--panel); - min-height: 70vh; + /* The row height is governed by the workspace's grid-template-rows clamp + * (was a 70vh min here, which pushed the tutor ~300px below the fold). Floor + * at 0 and hide overflow so the canvas always fills the capped row exactly. */ + min-height: 0; + overflow: hidden; } .canvas-frame { @@ -494,7 +633,10 @@ textarea:focus, input[type="text"]:focus { overflow: hidden; background: #050507; border: 1px solid var(--border); - min-height: 520px; + /* No floor — flex:1 fills the stage row, and shrinks to make room for the demo + * parameter panel below it so the sliders are never clipped when the top is + * dragged short. */ + min-height: 0; display: flex; } @@ -522,26 +664,33 @@ textarea:focus, input[type="text"]:focus { width: fit-content; padding: 5px 10px; border-radius: 999px; - background: rgba(10,10,10,0.72); + /* No backdrop-filter: blur — it sits over the animating canvas, so the + browser would recompute the blur every frame (a real perf cost). A solid, + slightly-more-opaque pill reads just as well and costs nothing per frame. */ + background: rgba(8,10,18,0.85); border: 1px solid var(--border); font-size: 12px; color: var(--muted); - backdrop-filter: blur(8px); } #sceneTag { color: var(--info); font-weight: 500; } +/* Bare canvas (the browser-edition help card): hide the status/tag pills — the + * card explains itself, so the overlay chrome is just redundant clutter. */ +.canvas-frame.bare .canvas-overlay { display: none; } .orbit-hint { position: absolute; - bottom: 12px; + /* Top-right, not bottom-right: the long hint text would otherwise extend + * across a narrow canvas and overlap the bottom-left status pills. The scene + * title is top-LEFT, so the top-right corner is free. */ + top: 12px; right: 14px; z-index: 2; pointer-events: none; padding: 5px 10px; border-radius: 999px; - background: rgba(10,10,10,0.62); + background: rgba(8,10,18,0.82); /* solid pill; no per-frame backdrop blur */ border: 1px solid var(--border); font-size: 11px; color: var(--muted); - backdrop-filter: blur(8px); } /* The canvas is now an orbit control surface for 3D scenes. */ .canvas-frame { cursor: grab; touch-action: none; } @@ -550,19 +699,142 @@ textarea:focus, input[type="text"]:focus { #sceneConfidence[data-tone="ok"] { color: var(--good); } #sceneConfidence[data-tone="warn"] { color: var(--warn); } +/* Light theme: the animation canvas follows the app toggle. The scene itself + * repaints in the light palette (sandbox-worker.js); this sets the frame's + * fallback fill (shown for a frame before the scene paints) and flips the + * overlay pills from a dark wash to a light one so their text stays readable. */ +[data-theme="light"] .canvas-frame { background: #eef1f7; } +[data-theme="light"] .canvas-overlay span, +[data-theme="light"] .orbit-hint { background: rgba(255, 255, 255, 0.82); } + +/* ============================================================== */ +/* Interactive demo parameter controls ("fill in the values") */ +/* ============================================================== */ +/* Lives in the stylesheet (not a JS-injected