From b58ae9e205564a32e4382a6bc25241c68aa6e42d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Sat, 13 Jun 2026 21:22:58 +0700 Subject: [PATCH 01/43] =?UTF-8?q?Refine=20generation=E2=86=92repair=20pipe?= =?UTF-8?q?line:=20resilience,=20latency,=20converging=20repairs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce runtime crashes that exhaust the repair loop, and cut the latency of both generation and repair, so fewer scenes hit the slow LLM repair path and the ones that do converge (or fail fast) instead of grinding the whole budget. Sandbox (sandbox-worker.js): - Invented helpers (H/cam/view) degrade to a chainable no-op in any chain order; cam/view rebind to the proxy so real().invented() is safe too. - Per-frame error tolerance: a scene that renders then throws on an occasional frame keeps running; only a first-frame failure or ~0.5s of sustained throwing escalates to repair. - Heartbeat on the first clean frame (load "ready" signal ~300ms sooner). - offendingLine(): pull the exact failing source line out of the stack. Client (app.js, index.html): - MAX_REPAIRS 3->2; forward the offending line to /api/repair. - Non-convergence guards: bail when a repair repeats the same error or returns unchanged code, instead of grinding the full (slow) repair budget. - Bump app.js cache-buster v10->v11 (was stranding all client changes behind the stale ?v=10 for returning users). Server (main.py): - Generator-aware retry budget (cloud=2, local Ollama=3). - Credit ".axes(" as a label so well-labeled plots stop triggering false "unlabeled" regenerations. - VISUALLM_CLAUDE_EFFORT / VISUALLM_CLAUDE_MAX_TOKENS env knobs (default high/32k). - _repair_hint(): error-class fix guidance + a minimal-change directive, wired into every repair provider; fold the offending line into the repair error. Tests: +5 (repair hints, repair-visualization line folding). Suite 36->41, green. Co-Authored-By: Claude Opus 4.8 (1M context) --- app.js | 77 ++++++++++++++++++++++++-------- index.html | 2 +- main.py | 109 ++++++++++++++++++++++++++++++++++++++------- sandbox-worker.js | 100 ++++++++++++++++++++++++++++++++++------- tests/test_main.py | 51 +++++++++++++++++++++ 5 files changed, 287 insertions(+), 52 deletions(-) diff --git a/app.js b/app.js index f41e164..b44c149 100644 --- a/app.js +++ b/app.js @@ -341,7 +341,7 @@ } const canvas = this._newCanvas(); const offscreen = canvas.transferControlToOffscreen(); - const worker = new Worker("./sandbox-worker.js?v=13"); + const worker = new Worker("./sandbox-worker.js?v=16"); this.worker = worker; worker.onmessage = (e) => this._onMessage(e.data || {}); const d = this._dims(); @@ -366,13 +366,20 @@ if (this.pending && !this.pending.settled) this._settle(true); break; case "compile-error": - case "runtime-error": + case "runtime-error": { + // Carry the sandbox-extracted offending line (m.where) on the Error so + // the repair request can forward it to the model. if (this.pending && !this.pending.settled) { - this._settle(false, new Error(m.message || "Animation failed.")); + const e = new Error(m.message || "Animation failed."); + e.where = m.where || ""; + this._settle(false, e); } else if (this.onCrash) { - this.onCrash(new Error(m.message || "Animation crashed.")); + const e = new Error(m.message || "Animation crashed."); + e.where = m.where || ""; + this.onCrash(e); } break; + } default: break; } @@ -489,6 +496,7 @@ prompt: state.scene.prompt, code: state.scene.code, error: err.message, + where: err.where || "", }); await runSceneWithRepair(repaired, state.scene.prompt); } catch (repairErr) { @@ -557,7 +565,11 @@ /* Visualization flow: generate -> run -> repair loop */ /* ============================================================== */ - const MAX_REPAIRS = 3; + // Each repair is a full, slow LLM round-trip. With the sandbox now tolerant of + // invented helpers and transient bad frames (see sandbox-worker.js), genuinely + // unfixable scenes are rarer — so cap repairs at 2 and show the fallback + // sooner instead of burning a third slow call that usually fails the same way. + const MAX_REPAIRS = 2; // Guaranteed-renderable placeholder for when generation + all repairs fail. // Without it the canvas sits dead-black under the error message. @@ -618,8 +630,25 @@ } } + // Show the guaranteed-renderable placeholder under a warning. Used whenever + // the repair loop gives up — budget exhausted, or non-convergence detected + // (repeated identical error, or the model returned the code unchanged). + async function showClientFallback(scene, message) { + if (scene) { + state.scene = scene; + updatePanels(scene); + } + setConfidence(message, "warn"); + try { + await runner.run(CLIENT_FALLBACK_CODE); + } catch (fallbackErr) { + /* placeholder is hand-written and can't realistically fail */ + } + } + async function runSceneWithRepair(scene, prompt) { let current = scene; + let prevError = null; for (let attempt = 0; attempt <= MAX_REPAIRS; attempt++) { const engineLabel = engineName(current.engine); try { @@ -672,36 +701,48 @@ seedTutorForScene(current); return; } catch (err) { - if (attempt >= MAX_REPAIRS) { - state.scene = current; - updatePanels(current); - setConfidence( - `Couldn't fix it after ${MAX_REPAIRS} tries. Last error: ${err.message}. ` + - "Try a different prompt, or set ANTHROPIC_API_KEY for the stronger Claude generator.", - "warn" + // Non-convergence guard: if this error follows a repair and matches the + // PREVIOUS attempt's error, the repairs aren't making progress — bail + // now instead of grinding through the rest of the (slow) repair budget. + const stuck = attempt > 0 && err.message === prevError; + prevError = err.message; + if (attempt >= MAX_REPAIRS || stuck) { + await showClientFallback( + current, + (stuck + ? `The fix kept hitting the same error ("${err.message}") — stopping early. ` + : `Couldn't fix it after ${MAX_REPAIRS} tries. Last error: ${err.message}. `) + + "Try a different prompt, or set ANTHROPIC_API_KEY for the stronger Claude generator." ); - // Don't leave a dead-black canvas under the error message. - try { - await runner.run(CLIENT_FALLBACK_CODE); - } catch (fallbackErr) { - /* placeholder is hand-written and can't realistically fail */ - } return; } setConfidence( `Animation error: "${err.message}". Asking ${engineLabel} for a fix…`, "pending" ); + const triedCode = current.code; try { current = await postJSON("/api/repair", { prompt, code: current.code, error: err.message, + where: err.where || "", }); } catch (repairErr) { setConfidence("Repair failed: " + repairErr.message, "warn"); return; } + // Unchanged-code guard: the model returned the same code it was given, + // so re-running it would fail identically — stop rather than spend + // another slow round on a guaranteed repeat. + if (current.code && triedCode && current.code.trim() === triedCode.trim()) { + await showClientFallback( + current, + "The model returned the same code unchanged — stopping early. " + + "Try rephrasing the prompt." + ); + return; + } } } } diff --git a/index.html b/index.html index 10113de..fa2e228 100644 --- a/index.html +++ b/index.html @@ -258,6 +258,6 @@

Quick questions

- + diff --git a/main.py b/main.py index c1a493a..a48677a 100644 --- a/main.py +++ b/main.py @@ -43,6 +43,16 @@ # --- 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. +# Default "high": code-gen for novel STEM scenes is intelligence-sensitive, and +# correct first-try code is precisely what AVOIDS the slow client-side repair +# loop. Set VISUALLM_CLAUDE_EFFORT=medium to trade a little quality for speed. +ANTHROPIC_EFFORT = os.environ.get("VISUALLM_CLAUDE_EFFORT", "high").strip() or "high" +# Hard per-response ceiling (the model isn't aware of it). A scene is a few KB of +# code plus short text, so 32k is generous headroom for thinking + output; lower +# it only if you need to cap cost. Too low risks truncated JSON -> a parse error +# that the caller counts as a failed generation. +ANTHROPIC_MAX_TOKENS = int(os.environ.get("VISUALLM_CLAUDE_MAX_TOKENS", "32000")) # --- OpenAI / Gemini are optional cloud fallbacks (key = enabled). --- OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/") @@ -649,10 +659,10 @@ def claude_call_scene(user_blocks: list[dict]) -> dict: with client.messages.stream( model=ANTHROPIC_MODEL, - max_tokens=32000, + max_tokens=ANTHROPIC_MAX_TOKENS, thinking={"type": "adaptive"}, output_config={ - "effort": "high", + "effort": ANTHROPIC_EFFORT, "format": {"type": "json_schema", "schema": SCENE_SCHEMA}, }, system=[ @@ -686,12 +696,58 @@ def generate_with_claude(prompt: str, preferred_mode: str) -> dict: return claude_call_scene([{"type": "text", "text": user_text}]) +def _repair_hint(error: str) -> str: + """Turn a sandbox error into a targeted fix instruction. + + The repair loop's worst failure mode is the model *rewriting the whole + scene* and introducing a different bug — so every hint ends with a + minimal-change directive. The error-class prefix points the model straight + at the fault. Substring match: sandbox error messages aren't structured. + """ + e = (error or "").lower() + if "is not defined" in e: + specific = ( + "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." + ) + elif "is not a function" in e: + specific = ( + "You called something that isn't a real helper. Use ONLY the helpers " + "from the contract above (H.*, the plot2d view methods, the cam3d " + "methods). Delete or replace the invalid call." + ) + elif "cannot read" in e and ("undefined" in e or "null" in e): + specific = ( + "You read a property of undefined/null. Guard the access — confirm " + "the value exists and any array index is in range before using it." + ) + elif "is not iterable" in e: + specific = ( + "You looped over or spread a non-array. Ensure the value is an array " + "(default to []) before iterating." + ) + elif "unexpected" in e or "syntaxerror" in e or "token" in e: + specific = ( + "The code did not parse — likely an unbalanced bracket or an " + "unfinished statement. Return complete, valid JavaScript." + ) + else: + specific = "Identify exactly what threw, then fix that specific cause." + return ( + specific + + " Make the SMALLEST change that fixes the error: keep every part that " + "already works, do not rewrite the whole scene, and do not change the " + "teaching intent." + ) + + def repair_with_claude(prompt: str, code: str, error: str) -> dict: user_text = ( "The animation code you wrote threw an error in the sandbox. Fix it and " - "return the full corrected scene. Keep the same teaching intent.\n\n" + "return the full corrected scene.\n\n" f"Original request:\n{prompt}\n\n" f"Error:\n{error}\n\n" + f"How to fix it:\n{_repair_hint(error)}\n\n" f"Broken code (function body of scene(ctx, t, H)):\n{code}" ) return claude_call_scene([{"type": "text", "text": user_text}]) @@ -760,6 +816,7 @@ def generate_with_ollama(prompt: str, preferred_mode: str, fix: dict | None = No 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: @@ -919,6 +976,7 @@ def _scene_user_text(prompt: str, preferred_mode: str, fix: dict | None) -> str: 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: @@ -1089,7 +1147,11 @@ def code_paints_something(code: str) -> bool: # Helpers that put words/numbers on screen. Scenes with zero of these are # unlabeled pictures — no title, no values — which defeats the teaching goal. -_LABEL_CALLS = ("H.text", "H.legend", "fillText") +# `.axes(` covers both plot2d and cam3d axes, which render numeric tick labels / +# axis names internally — without it, a correctly-labeled plot that relies on +# axes() and skips a bare H.text title gets a false "unlabeled" flag and a +# wasted regeneration. +_LABEL_CALLS = ("H.text", "H.legend", "fillText", ".axes(") def code_is_animated(code: str) -> bool: @@ -1218,20 +1280,26 @@ def _fallback_scene(prompt: str, reason: str) -> dict: } -def _try_generate(fn, prompt: str, preferred_mode: str, label: str) -> dict: +def _try_generate( + fn, prompt: str, preferred_mode: str, label: str, max_attempts: int = 3 +) -> dict: """Generate once, run the quality gate, and retry with targeted feedback. Three failure tiers, detected syntactically (we can't run JS here): - blank — no drawing call at all. Never shippable; after three + blank — no drawing call at all. Never shippable; after max_attempts blank attempts we raise (caller may fall back). static — never reads `t`, so the "animation" is a still image. unlabeled — no text anywhere: no title, no values, no readouts. static/unlabeled trigger a regeneration with a hint naming exactly what was missing; if the last attempt still has them, we ship it anyway and annotate the scene so the UI can tell the user. + + max_attempts is generator-aware: strong cloud models (Claude/OpenAI/Gemini) + almost never trip the gate, so 2 keeps a single corrective retry without + paying for a wasted third slow call; weak local models (Ollama) keep 3. """ best_imperfect = None # most recent paints-but-imperfect scene - for attempt in range(3): + for attempt in range(max_attempts): scene = normalize_scene(fn(prompt, preferred_mode), prompt) problems = scene_problems(scene.get("code", "")) if not problems: @@ -1249,8 +1317,8 @@ def _try_generate(fn, prompt: str, preferred_mode: str, label: str) -> dict: if best_imperfect is not None: return best_imperfect raise RuntimeError( - f"{label} produced a blank scene three times — the code didn't call " - f"any drawing helper. Try a different prompt, or use Claude for " + f"{label} produced a blank scene {max_attempts} times — the code didn't " + f"call any drawing helper. Try a different prompt, or use Claude for " f"more reliable generation." ) @@ -1271,12 +1339,16 @@ def plan_visualization(prompt: str, preferred_mode: str) -> dict: errors = [] for label, fn in _cloud_generators(): try: - return _try_generate(fn, prompt, preferred_mode, label) + return _try_generate(fn, prompt, preferred_mode, label, max_attempts=2) except Exception as error: # noqa: BLE001 errors.append(f"{label}: {error}") try: return _try_generate( - lambda p, m: generate_with_ollama(p, m), prompt, preferred_mode, "Ollama" + lambda p, m: generate_with_ollama(p, m), + prompt, + preferred_mode, + "Ollama", + max_attempts=3, ) except Exception as error: # noqa: BLE001 errors.append(f"Ollama: {error}") @@ -1315,11 +1387,15 @@ def plan_visualization(prompt: str, preferred_mode: str) -> dict: raise RuntimeError(f"Generator failed ({detail}). {hint}") -def repair_visualization(prompt: str, code: str, error: str) -> dict: +def repair_visualization(prompt: str, code: str, error: str, where: str = "") -> dict: errors = [] + # `where` is the offending source line the sandbox pulled from the stack + # trace (see sandbox-worker.js offendingLine). Folding it into the error text + # points every repair provider straight at the failing line. + error_ctx = error + (f"\n\nThe error was thrown at: {where}" if where else "") if claude_available()["available"]: try: - return normalize_scene(repair_with_claude(prompt, code, error), prompt) + return normalize_scene(repair_with_claude(prompt, code, error_ctx), prompt) except Exception as e: # noqa: BLE001 errors.append(f"Claude: {e}") for label, avail, fn in ( @@ -1330,13 +1406,13 @@ def repair_visualization(prompt: str, code: str, error: str) -> dict: continue try: return normalize_scene( - fn(prompt, "auto", fix={"code": code, "error": error}), prompt + fn(prompt, "auto", fix={"code": code, "error": error_ctx}), prompt ) 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}), + generate_with_ollama(prompt, "auto", fix={"code": code, "error": error_ctx}), prompt, ) except Exception as e: # noqa: BLE001 @@ -1690,12 +1766,13 @@ def _handle_repair(self, payload: dict) -> None: prompt = payload.get("prompt", "") code = payload.get("code", "") error = payload.get("error", "") + where = payload.get("where", "") if not isinstance(prompt, str) or not isinstance(code, str) or not code.strip(): send_json(self, HTTPStatus.BAD_REQUEST, {"error": "prompt and code are required."}) return try: result = repair_visualization( - prompt.strip()[:4000], code[:20000], str(error)[:2000] + prompt.strip()[:4000], code[:20000], str(error)[:2000], str(where)[:300] ) except RuntimeError as err: send_json(self, HTTPStatus.SERVICE_UNAVAILABLE, {"error": str(err)}) diff --git a/sandbox-worker.js b/sandbox-worker.js index 567dc58..85eb0a2 100644 --- a/sandbox-worker.js +++ b/sandbox-worker.js @@ -68,6 +68,13 @@ 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; @@ -297,7 +304,7 @@ function makeHelpers() { 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); - const view = { + let view = { box, xMin, xMax, @@ -432,7 +439,11 @@ function makeHelpers() { }; // 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". - return wrapHelpers(view); + // 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 @@ -447,7 +458,7 @@ function makeHelpers() { const dist = o.dist || 9; const cx = o.cx == null ? logicalW / 2 : o.cx; const cy = o.cy == null ? logicalH / 2 : o.cy; - const cam = { + let cam = { set yaw(v) { yaw = v; }, @@ -599,7 +610,11 @@ function makeHelpers() { }, }; // Invented cam methods degrade to no-ops, like H and plot2d views. - return wrapHelpers(cam); + // 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). @@ -851,15 +866,23 @@ function seedConvenienceGlobals() { * scene still renders and the model just doesn't get that specific helper. */ function wrapHelpers(h) { if (typeof Proxy === "undefined") return h; - return new Proxy(h, { + const proxy = new Proxy(h, { get(target, key) { - if (key in target) return target[key]; - // Common shape: `H.foo(...)` -> return a no-op function. - return function noop() { - return undefined; + // 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; } /* ------------------------------------------------------------------ */ @@ -888,6 +911,37 @@ function compile(code) { 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(); @@ -901,19 +955,28 @@ function tick() { try { ctx.clearRect(0, 0, logicalW, logicalH); sceneFn(ctx, simTime); // H is a global (see compile()) + everRendered = true; + consecutiveErrors = 0; } catch (err) { - running = false; - post({ - type: "runtime-error", - message: String((err && err.message) || err), - stack: String((err && err.stack) || ""), - }); - return; + 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++; - if (frameCount % 20 === 0) { + // 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 }); } @@ -960,6 +1023,7 @@ self.onmessage = (e) => { try { const fn = compile(m.code); sceneFn = fn; + lastCode = typeof m.code === "string" ? m.code : ""; if (m.resetTime !== false) { simTime = 0; orbit.yaw = 0; @@ -967,6 +1031,8 @@ self.onmessage = (e) => { orbit.zoom = 1; } frameCount = 0; + consecutiveErrors = 0; + everRendered = false; paused = false; lastWall = performance.now(); if (!running) { diff --git a/tests/test_main.py b/tests/test_main.py index 59b1c73..69c209e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -133,6 +133,57 @@ def always_blank(prompt, mode): main._try_generate(always_blank, "orig", "auto", "Test") +class RepairHintTests(unittest.TestCase): + def test_error_classes_get_specific_hints(self): + self.assertIn("declared", main._repair_hint("ReferenceError: radius is not defined")) + self.assertIn("real helper", main._repair_hint("TypeError: H.glow is not a function")) + self.assertIn( + "undefined/null", + main._repair_hint("TypeError: Cannot read properties of undefined (reading 'x')"), + ) + self.assertIn("parse", main._repair_hint("SyntaxError: Unexpected token '}'")) + + def test_every_hint_demands_a_minimal_change(self): + for err in ["x is not defined", "boom", "", "Cannot read properties of null"]: + self.assertIn("SMALLEST change", main._repair_hint(err)) + + def test_handles_non_string_error(self): + self.assertIn("SMALLEST change", main._repair_hint(None)) + + +class RepairVisualizationTests(unittest.TestCase): + """repair_visualization folds the sandbox's offending line into the error + text handed to the provider, and leaves it clean when there's no location.""" + + def _capture_repair_error(self, error, where): + captured = {} + + def fake_repair(prompt, code, err): + captured["error"] = err + return {"code": "H.background(); H.text('x', 1, 2);", "engine": "claude"} + + orig_avail, orig_repair = main.claude_available, main.repair_with_claude + main.claude_available = lambda: {"available": True} + main.repair_with_claude = fake_repair + try: + main.repair_visualization("draw it", "H.circle(p.x,1,2);", error, where=where) + finally: + main.claude_available = orig_avail + main.repair_with_claude = orig_repair + return captured["error"] + + def test_offending_line_is_folded_in(self): + err = self._capture_repair_error( + "Cannot read properties of undefined", "line 5: H.circle(p.x, 1, 2)" + ) + self.assertIn("Cannot read properties of undefined", err) + self.assertIn("line 5: H.circle(p.x, 1, 2)", err) + + def test_no_where_leaves_error_clean(self): + err = self._capture_repair_error("boom", "") + self.assertEqual(err, "boom") + + class NormalizeSceneTests(unittest.TestCase): def test_dimension_normalization(self): scene = main.normalize_scene({"dimension": "3d surface"}, "p") From b426c440031855fcd21dd208791369c16681c3a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Sun, 14 Jun 2026 18:18:10 +0700 Subject: [PATCH 02/43] Harden server: catch-all 500 + client-disconnect guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two production-robustness gaps in the public HTTP server, independent of the generation→repair work on this branch: - do_POST dispatched to handlers with no catch-all, so an UNEXPECTED exception (a bug, a new SDK error type — distinct from the RuntimeErrors that handlers already turn into 503s) escaped, dropped the client connection, and spilled a bare traceback to stderr with no response. Now logged and returned as a clean 500. - send_json's writes weren't guarded, so a client disconnecting mid-response (common during a slow generation) surfaced BrokenPipeError as an unhandled traceback. Now swallowed. Test: +1 (unexpected handler exception -> 500). Suite 41->42, green. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 45 ++++++++++++++++++++++++++++++++------------- tests/test_main.py | 20 ++++++++++++++++++++ 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/main.py b/main.py index a48677a..aa18c08 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,7 @@ import textwrap import threading import time +import traceback import urllib.error import urllib.parse import urllib.request @@ -138,11 +139,17 @@ def read_json_body(handler: SimpleHTTPRequestHandler) -> dict: def send_json(handler: SimpleHTTPRequestHandler, status: HTTPStatus, payload: dict) -> None: data = json.dumps(payload).encode("utf-8") - handler.send_response(status) - handler.send_header("Content-Type", "application/json; charset=utf-8") - handler.send_header("Content-Length", str(len(data))) - handler.end_headers() - handler.wfile.write(data) + try: + handler.send_response(status) + handler.send_header("Content-Type", "application/json; charset=utf-8") + handler.send_header("Content-Length", str(len(data))) + handler.end_headers() + handler.wfile.write(data) + except (BrokenPipeError, ConnectionError): + # Client disconnected mid-response (common when the user navigates away + # during a slow generation). There's no one to send to — swallow it + # rather than let it surface as an unhandled traceback in the log. + pass # ===================================================================== # @@ -1724,14 +1731,26 @@ def do_POST(self) -> None: send_json(self, HTTPStatus.BAD_REQUEST, {"error": "Body must be a JSON object."}) return - if parsed.path == "/api/visualize": - self._handle_visualize(payload) - elif parsed.path == "/api/repair": - self._handle_repair(payload) - elif parsed.path == "/api/resources": - self._handle_resource_upload(payload) - else: - self._handle_chat(payload) + # Last-resort guard. Handlers catch their own *expected* errors (bad + # input -> 400, generator failure -> 503). An UNEXPECTED exception (a + # bug, a new SDK error type) must not escape do_POST — that drops the + # client connection with a bare stderr traceback and no response. Log + # it and return a clean 500. Handlers do their heavy work before + # sending anything, so no response has started when we land here. + try: + if parsed.path == "/api/visualize": + self._handle_visualize(payload) + elif parsed.path == "/api/repair": + self._handle_repair(payload) + elif parsed.path == "/api/resources": + self._handle_resource_upload(payload) + else: + self._handle_chat(payload) + except Exception: # noqa: BLE001 + traceback.print_exc() + send_json( + self, HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Internal server error."} + ) def _handle_resource_upload(self, payload: dict) -> None: name = payload.get("name", "") diff --git a/tests/test_main.py b/tests/test_main.py index 69c209e..3649279 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -8,6 +8,8 @@ """ from __future__ import annotations +import contextlib +import io import json import sys import threading @@ -354,6 +356,24 @@ def test_resource_rejects_binary(self): self.assertEqual(status, 400) self.assertIn("binary", body["error"].lower()) + def test_handler_exception_returns_500(self): + # An UNEXPECTED exception in a handler (not the RuntimeError that + # handlers already convert to 503) must come back as a clean 500, not a + # dropped connection. stderr is suppressed because the guard logs the + # traceback by design. + def boom(prompt, mode): + raise ValueError("unexpected handler bug") + + original = main.plan_visualization + main.plan_visualization = boom + try: + with contextlib.redirect_stderr(io.StringIO()): + status, body = self.request("POST", "/api/visualize", {"prompt": "p"}) + finally: + main.plan_visualization = original + self.assertEqual(status, 500) + self.assertIn("error", body) + if __name__ == "__main__": unittest.main() From c50b05b229af34ea5ffa9d4daa4da6d0c74f2cd1 Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Sun, 14 Jun 2026 21:42:50 +0700 Subject: [PATCH 03/43] Faster + more reliable generation: validator, auto-fixer, library, cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two complaints — "still errors after 3 tries" and "took a long time" — both trace to the same root: the browser was the validator, so every bad scene cost a full client round-trip + a slow model repair. This restructures the pipeline around catching and fixing errors WITHOUT model calls. - validate_scene.js: headless server-side scene validator. Runs untrusted generated code in a hardened Node `vm` (fresh empty context — no process/ require/import reachable; classic constructor.constructor escape contained; hard 2s timeout) and reports throws / blank / no-motion / no-labels in ~50ms. - main.py evaluate_scene(): single source of truth, validator-authoritative, static-gate fallback when node is absent. _try_generate now repairs fatal scenes SERVER-SIDE (exact runtime error folded into the prompt) so the browser receives an already-runnable scene. - autofix_code(): deterministically prefixes bare Math.* calls / PI / TAU outside strings & comments — fixes the #1 "X is not defined" throw with ZERO model round-trips. Wired into sanitize_code. - scene_library.py + library_match(): curated, hand-verified STEM corpus with keyword retrieval. Strong match short-circuits to an instant, known-correct scene (Fourier prompt: 5ms vs a 10-60s generation). This is the practical, retrieval-augmented realization of "feed it STEM data". - Exact-prompt cache of validated scenes (instant repeats), only caches clean scenes. plan_visualization chain: cache -> strong library -> models -> library fallback -> placeholder. - Frontend: elapsed-seconds ticker during generation; curated/cached badges. - Dockerfile installs Node so the validator runs in production too. - Health reports validator + library_size. 66 tests pass (24 new). Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 14 +- app.js | 39 +++- index.html | 2 +- main.py | 437 ++++++++++++++++++++++++++++++++++++++++----- scene_library.py | 274 ++++++++++++++++++++++++++++ tests/test_main.py | 167 +++++++++++++++++ validate_scene.js | 191 ++++++++++++++++++++ 7 files changed, 1074 insertions(+), 50 deletions(-) create mode 100644 scene_library.py create mode 100644 validate_scene.js diff --git a/Dockerfile b/Dockerfile index cdf478d..9b4f125 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,23 @@ # VisualLM — single-container deployment. -# The server is stdlib-only; the anthropic SDK is the one optional dependency. +# The server is stdlib-only; the anthropic SDK is the one optional Python dep. +# Node.js is installed so the server-side scene validator (validate_scene.js) +# runs in production too — it's what lets us catch & repair broken animations +# before they ever reach the browser. Without it the app still works (it falls +# back to the static gate + browser repair), but reliability is best with it. FROM python:3.12-slim +# Minimal Node runtime for the headless scene validator. +RUN apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY main.py index.html app.js sandbox-worker.js styles.css ./ +COPY main.py scene_library.py validate_scene.js \ + index.html app.js sandbox-worker.js styles.css ./ # Cloud platforms inject PORT; main.py binds 0.0.0.0 automatically when set. ENV PORT=8080 diff --git a/app.js b/app.js index b44c149..8284129 100644 --- a/app.js +++ b/app.js @@ -589,12 +589,33 @@ openai: "ChatGPT", gemini: "Gemini", ollama: "local model", + library: "curated library", fallback: "fallback", }; function engineName(engine) { return ENGINE_NAMES[engine] || "the model"; } + // A live elapsed-seconds ticker so a slow local-model generation reads as + // "working" rather than "frozen". Updates the status line in place. + let _elapsedTimer = null; + function startElapsed(baseLabel) { + stopElapsed(); + const t0 = Date.now(); + const tick = () => { + const s = Math.round((Date.now() - t0) / 1000); + setConfidence(`${baseLabel} (${s}s)`, "pending"); + }; + tick(); + _elapsedTimer = setInterval(tick, 1000); + } + function stopElapsed() { + if (_elapsedTimer) { + clearInterval(_elapsedTimer); + _elapsedTimer = null; + } + } + async function visualize(prompt) { if (state.busy) return; const clean = (prompt || "").trim(); @@ -602,7 +623,10 @@ setBusyVisual(true, "Thinking…"); el.topic.textContent = "Generating…"; - setConfidence("The AI is writing a custom animation for your prompt…", "pending"); + // Elapsed ticker — local generation can take 10-60s; a live counter makes + // the wait legible instead of looking hung. (Curated/cached hits return so + // fast the ticker never visibly advances.) + startElapsed("The AI is writing a custom animation for your prompt…"); // A new scene always starts playing. Without this, pausing one scene // left every FUTURE scene frozen on its first frame — which reads as @@ -617,8 +641,10 @@ prompt: clean, preferred_mode: state.mode, }); + stopElapsed(); await runSceneWithRepair(scene, clean); } catch (err) { + stopElapsed(); setConfidence("Could not generate: " + err.message, "warn"); el.topic.textContent = "Generation failed"; el.summary.textContent = err.message; @@ -626,6 +652,7 @@ // is now stale (Ollama died, Claude key expired, server restarted, etc.). refreshStatus(); } finally { + stopElapsed(); setBusyVisual(false); } } @@ -683,6 +710,13 @@ "Regenerate or rephrase for a better scene.", "warn", ); + } else if (current.from_library) { + // Instant, hand-verified scene from the curated STEM corpus. + setConfidence( + `${current.dimension} • curated STEM scene (instant, verified)` + + (current.fallback_reason ? " — " + current.fallback_reason : ""), + "ok", + ); } else if (current.recovered_after_retry) { const n = current.recovered_after_retry; setConfidence( @@ -694,7 +728,8 @@ } else { setConfidence( `${current.dimension} • generated by ${engineLabel}` + - (current.model ? " (" + current.model + ")" : ""), + (current.model ? " (" + current.model + ")" : "") + + (current.cached ? " — instant (cached)" : ""), "ok" ); } diff --git a/index.html b/index.html index fa2e228..a6ff235 100644 --- a/index.html +++ b/index.html @@ -258,6 +258,6 @@

Quick questions

- + diff --git a/main.py b/main.py index aa18c08..a84230b 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,8 @@ import json import os import re +import shutil +import subprocess import textwrap import threading import time @@ -1023,6 +1025,143 @@ def generate_with_gemini(prompt: str, preferred_mode: str, fix: dict | None = No return plan +# ===================================================================== # +# Deterministic auto-fixer (no model round-trip) # +# ===================================================================== # +# +# The cheapest repair is the one that never calls the model. Local models +# (and occasionally the cloud ones) make a handful of MECHANICAL mistakes that +# we can fix with certainty in microseconds — the biggest being bare math +# calls (`sin(x)` instead of `Math.sin(x)`), the single most common reason a +# 7B scene throws "sin is not defined". Fixing these here means they never +# burn a slow repair attempt. + +# Every Math.* function a scene might call bare. Longest names first so the +# alternation never matches a prefix (e.g. `sin` inside `sinh`). +_MATH_FNS = sorted( + [ + "atan2", "asinh", "acosh", "atanh", "expm1", "log10", "log1p", "log2", + "cbrt", "sinh", "cosh", "tanh", "asin", "acos", "atan", "sign", + "trunc", "sqrt", "hypot", "floor", "ceil", "round", "abs", "exp", + "log", "pow", "min", "max", "sin", "cos", "tan", "random", + ], + key=len, + reverse=True, +) +# A bare math call: the name not preceded by `.`/word-char/`$` (so `Math.sin` +# and `mySin` are skipped) and followed by `(`. +_MATH_FN_RE = re.compile(r"(? str: + """Run `transform` only on the code OUTSIDE string literals and comments.""" + out = [] + last = 0 + for m in _JS_LITERAL_RE.finditer(code): + out.append(transform(code[last : m.start()])) + out.append(m.group(0)) + last = m.end() + out.append(transform(code[last:])) + 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) + return segment + + +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. + """ + if not code: + return code + try: + return _apply_outside_strings(code, _autofix_math) + except re.error: + return code + + +# ===================================================================== # +# Headless scene validator (Node, optional) # +# ===================================================================== # +# +# If `node` is on PATH, we run every generated/repaired scene through +# validate_scene.js BEFORE sending it to the browser. That sandboxed run +# (fresh empty V8 context, hard timeout) tells us — in ~50 ms, server-side — +# whether the scene throws, hangs, draws nothing, or lacks labels. This is the +# big latency + reliability win: the browser used to BE the validator, so each +# bad scene cost a full client round-trip + repair. Now we validate and repair +# server-side and hand the browser a scene that already runs. +# +# Degrades gracefully: with no `node`, headless_validate returns None and the +# pipeline falls back to the static gate (scene_problems) + the browser's own +# repair loop, exactly as before. + +_NODE_BIN = shutil.which("node") +_VALIDATOR_PATH = BASE_DIR / "validate_scene.js" + + +def node_validator_available() -> bool: + return bool(_NODE_BIN) and _VALIDATOR_PATH.exists() + + +def headless_validate(code: str, timeout: float = 6.0) -> dict | None: + """Run `code` in the sandboxed Node validator. None = couldn't validate. + + Returns {ok, error, painted, text, paint} on success. None when the + 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(): + return None + try: + proc = subprocess.run( + [_NODE_BIN, str(_VALIDATOR_PATH)], + input=code.encode("utf-8"), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + # The validator has its own 2 s in-VM timeout; hitting THIS one means + # node itself wedged. Treat as a hang so the scene gets repaired. + return { + "ok": False, + "error": "The scene hung (possible infinite loop).", + "painted": False, + "text": False, + } + except Exception: # noqa: BLE001 — any spawn failure → "couldn't validate" + return None + if proc.returncode != 0: + return None + try: + out = json.loads(proc.stdout.decode("utf-8") or "{}") + except (json.JSONDecodeError, UnicodeDecodeError): + return None + return out if isinstance(out, dict) else None + + _RESERVED_BINDINGS = ("H", "ctx", "t") @@ -1092,6 +1231,10 @@ def sanitize_code(code: object) -> str: c, flags=re.DOTALL, ) + + # Deterministically fix bare Math calls / constants. This resolves the + # most common "X is not defined" throw with ZERO model round-trips. + c = autofix_code(c) except re.error: pass @@ -1206,6 +1349,50 @@ def scene_problems(code: str) -> list[str]: } +def evaluate_scene(code: str) -> dict: + """Single source of truth for "is this scene good?". + + Prefers the Node validator (authoritative for runtime throws + whether the + scene actually painted), and falls back to the static gate when node is + absent. Returns {fatal, error, problems}: + fatal=True → the scene throws / hangs / draws nothing. Must be repaired. + problems=[] → ship it. ['static'|'unlabeled'] → regenerate with feedback. + """ + val = headless_validate(code) + if val is None: + # No runtime validator — fall back to the purely-static gate. + probs = scene_problems(code) + if probs == ["blank"]: + return { + "fatal": True, + "error": "The scene didn't call any drawing helper, so the canvas stayed blank.", + "problems": [], + } + return {"fatal": False, "error": None, "problems": probs} + # Node validator available — authoritative for runtime behaviour. + if not val.get("ok"): + return { + "fatal": True, + "error": val.get("error") or "The scene threw an error at runtime.", + "problems": [], + } + if not val.get("painted"): + return { + "fatal": True, + "error": ( + "The scene ran but drew nothing (blank canvas). Call H.background() " + "and then drawing helpers that actually paint." + ), + "problems": [], + } + problems = [] + if not code_is_animated(code): + problems.append("static") + if not (val.get("text") or code_has_labels(code)): + problems.append("unlabeled") + return {"fatal": False, "error": None, "problems": problems} + + def normalize_scene(plan: dict, prompt: str) -> dict: def s(key, default=""): v = plan.get(key) @@ -1287,46 +1474,73 @@ def _fallback_scene(prompt: str, reason: str) -> dict: } +def _repair_feedback(error: str, code: str) -> str: + """A feedback block that turns the next generation into a targeted repair. + + Folding the runtime error + the broken code into the prompt lets us drive a + server-side fix through the SAME generate function (keeping _try_generate + provider-agnostic) without a separate repair call path. + """ + return ( + "\n\nCRITICAL: your previous code FAILED to run in the sandbox.\n" + f"Error: {error}\n" + f"How to fix it: {_repair_hint(error)}\n" + "Return a corrected, complete scene. Broken code was:\n" + (code or "") + ) + + def _try_generate( fn, prompt: str, preferred_mode: str, label: str, max_attempts: int = 3 ) -> dict: - """Generate once, run the quality gate, and retry with targeted feedback. - - Three failure tiers, detected syntactically (we can't run JS here): - blank — no drawing call at all. Never shippable; after max_attempts - blank attempts we raise (caller may fall back). - static — never reads `t`, so the "animation" is a still image. - unlabeled — no text anywhere: no title, no values, no readouts. - static/unlabeled trigger a regeneration with a hint naming exactly what - was missing; if the last attempt still has them, we ship it anyway and - annotate the scene so the UI can tell the user. - - max_attempts is generator-aware: strong cloud models (Claude/OpenAI/Gemini) - almost never trip the gate, so 2 keeps a single corrective retry without - paying for a wasted third slow call; weak local models (Ollama) keep 3. + """Generate, validate server-side, and retry with targeted feedback. + + Every candidate is run through evaluate_scene (the Node validator when + available, else the static gate), which classifies it as: + fatal — throws / hangs / draws nothing. We regenerate with the exact + runtime error + the broken code folded into the prompt, so + the model performs a precise fix. After max_attempts we raise. + static — never reads `t`, so it's a still image. + unlabeled — no title / values / readouts. + static/unlabeled regenerate with a hint naming what was missing; if the + last attempt still trips them, we ship it anyway, annotated, so the user + gets a working (if imperfect) scene instead of an error. + + The deterministic auto-fixer runs inside normalize_scene, so mechanically + fixable faults (bare Math.*) are resolved here with ZERO extra model calls. + + max_attempts is generator-aware: strong cloud models almost never trip the + gate, so 2 keeps a single corrective retry; weak local models keep 3. """ best_imperfect = None # most recent paints-but-imperfect scene + scene = normalize_scene(fn(prompt, preferred_mode), prompt) + last_error = None for attempt in range(max_attempts): - scene = normalize_scene(fn(prompt, preferred_mode), prompt) - problems = scene_problems(scene.get("code", "")) - if not problems: + ev = evaluate_scene(scene.get("code", "")) + if not ev["fatal"] and not ev["problems"]: if attempt > 0: - # Annotate so the UI can surface what happened. scene["recovered_after_retry"] = attempt return scene - if problems != ["blank"]: - scene["quality_warnings"] = problems + if not ev["fatal"]: + scene["quality_warnings"] = ev["problems"] best_imperfect = scene - # Reprompt with feedback naming exactly what was wrong. Models - # reliably do better on the second try with a targeted hint. - complaints = " ALSO, ".join(_PROBLEM_HINTS[p] for p in problems) - prompt = prompt + "\n\nCRITICAL FEEDBACK on your previous attempt: " + complaints + if attempt == max_attempts - 1: + break + # Regenerate with feedback. Fatal → repair the exact fault; quality → + # name what was missing. Always feed back off the ORIGINAL prompt so + # complaints don't pile up across attempts. + if ev["fatal"]: + last_error = ev["error"] + feedback = _repair_feedback(ev["error"], scene.get("code", "")) + else: + complaints = " ALSO, ".join(_PROBLEM_HINTS[p] for p in ev["problems"]) + feedback = "\n\nCRITICAL FEEDBACK on your previous attempt: " + complaints + scene = normalize_scene(fn(prompt + feedback, preferred_mode), prompt) if best_imperfect is not None: return best_imperfect raise RuntimeError( - f"{label} produced a blank scene {max_attempts} times — the code didn't " - f"call any drawing helper. Try a different prompt, or use Claude for " - f"more reliable generation." + f"{label} couldn't produce a runnable scene after {max_attempts} attempts" + + (f" (last error: {last_error})" if last_error else "") + + ". Try a different prompt, or use a stronger model (ANTHROPIC_API_KEY)." ) @@ -1342,42 +1556,172 @@ def _cloud_generators() -> list[tuple[str, object]]: return chain +# ===================================================================== # +# Curated STEM scene library + validated-scene cache # +# ===================================================================== # +# +# The fastest, most reliable scene is one we don't have to generate. Two +# layers sit in front of the model: +# 1. An exact-prompt cache of scenes that already passed validation, so a +# repeated prompt (or a shared link) renders instantly. +# 2. A curated library of hand-verified scenes across STEM domains. A strong +# keyword match short-circuits to an instant, known-correct animation — +# this is the practical, retrieval-augmented version of "feed it STEM +# data": a vetted corpus the matcher draws on instead of training a model. + +try: + from scene_library import SCENE_LIBRARY # type: ignore +except Exception: # noqa: BLE001 — library is optional; never block startup + SCENE_LIBRARY = [] + +_scene_cache_lock = threading.Lock() +_scene_cache: dict[tuple, dict] = {} +_SCENE_CACHE_MAX = 256 + + +def _cache_key(prompt: str, mode: str) -> tuple: + return (mode, re.sub(r"\s+", " ", prompt.strip().lower())) + + +def scene_cache_get(prompt: str, mode: str) -> dict | None: + with _scene_cache_lock: + hit = _scene_cache.get(_cache_key(prompt, mode)) + return dict(hit) if hit else None + + +def scene_cache_put(prompt: str, mode: str, scene: dict) -> None: + # Only cache CLEAN scenes: never a fallback or a known-imperfect one, so a + # later retry still gets a chance at something better. + if not scene or scene.get("is_fallback") or scene.get("quality_warnings"): + return + with _scene_cache_lock: + if len(_scene_cache) >= _SCENE_CACHE_MAX: + _scene_cache.pop(next(iter(_scene_cache))) + _scene_cache[_cache_key(prompt, mode)] = dict(scene) + + +def _tokenize(s: str) -> set: + return set(re.findall(r"[a-z0-9]+", (s or "").lower())) + + +def library_match(prompt: str, mode: str) -> tuple[dict | None, float]: + """Best curated scene for this prompt + its match score (0 = no match).""" + if not SCENE_LIBRARY: + return None, 0.0 + ptext = " " + re.sub(r"\s+", " ", (prompt or "").lower()) + " " + ptoks = _tokenize(prompt) + best, best_score = None, 0.0 + for sc in SCENE_LIBRARY: + score = 0.0 + for kw in sc.get("keywords", []): + k = kw.lower().strip() + if not k: + continue + if " " in k: # multi-word phrase: weight a contiguous hit higher + if k in ptext: + score += 2.0 + elif k in ptoks: + score += 1.0 + # Shared title words are a mild positive signal. + score += 0.5 * len(_tokenize(sc.get("title", "")) & ptoks) + # Respect an explicit 2D/3D preference. + if mode in ("2d", "3d") and sc.get("dimension", "").lower() != mode: + score -= 1.5 + if score > best_score: + best, best_score = sc, score + return best, best_score + + +def _library_scene(sc: dict, prompt: str) -> dict: + return { + "title": sc.get("title", "STEM Visualization"), + "tag": sc.get("tag", "STEM"), + "dimension": "3D" if str(sc.get("dimension", "")).lower().startswith("3") else "2D", + "equation": sc.get("equation", ""), + "summary": sc.get("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", "")), + "model": "curated", + "engine": "library", + "prompt": prompt, + "from_library": True, + } + + +def _has_cloud() -> bool: + return bool(_cloud_generators()) + + 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) + if cached: + cached["cached"] = True + return cached + + # 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. + lib_scene, lib_score = library_match(prompt, preferred_mode) + strong_threshold = 6.0 if _has_cloud() else 3.0 + if lib_scene is not None and lib_score >= strong_threshold: + result = _library_scene(lib_scene, prompt) + scene_cache_put(prompt, preferred_mode, result) + return result + + # 3. Generate with the provider chain (each validated + repaired server-side). errors = [] for label, fn in _cloud_generators(): try: - return _try_generate(fn, prompt, preferred_mode, label, max_attempts=2) + scene = _try_generate(fn, prompt, preferred_mode, label, max_attempts=2) + scene_cache_put(prompt, preferred_mode, scene) + return scene except Exception as error: # noqa: BLE001 errors.append(f"{label}: {error}") try: - return _try_generate( + 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 curated scene — even a loose match — beats a + # dead canvas, so prefer it over the placeholder fallback. + if lib_scene is not None and lib_score > 0: + result = _library_scene(lib_scene, prompt) + result["fallback_reason"] = ( + "The live generator couldn't produce a runnable scene, so here's the " + "closest curated STEM animation." + ) + return result + detail = " | ".join(errors) if errors else "no backend available" - # Connection / timeout / no-backend failures are unrecoverable here — let - # the frontend show a real error. But "blank scene three times" failures - # are recoverable: we return a fallback scene that at least renders - # something visible plus a clear explanation. - blank_failure = any("blank scene" in e.lower() for e in errors) - fatal_failure = any( - ("timed out" in e.lower()) - or ("timeout" in e.lower()) - or ("connection" in e.lower()) - or ("could not reach" in e.lower()) - for e in errors - ) or not blank_failure - if blank_failure and not fatal_failure: + # A backend that was REACHABLE but produced unrunnable code is "soft" — show + # the animated placeholder + an explanation rather than a hard error. + # UNREACHABLE backends (connection/timeout/auth) are "hard" — surface them. + def _is_hard(e: str) -> bool: + e = e.lower() + return any( + k in e + for k in ( + "timed out", "timeout", "connection", "could not reach", + "no content", "http ", "api key", "invalid json", "not configured", + ) + ) + + if errors and not all(_is_hard(e) for e in errors): return _fallback_scene( prompt, - "The local model produced code that didn't draw anything three " - "times in a row. Rephrase the prompt or enable Claude for stronger " - "results.", + "The generator's code kept failing to run. Rephrase the prompt, or " + "enable a stronger model (ANTHROPIC_API_KEY / OPENAI_API_KEY / " + "GEMINI_API_KEY) for more reliable results.", ) if any("timed out" in e.lower() or "timeout" in e.lower() for e in errors): hint = ( @@ -1625,6 +1969,9 @@ def get_health() -> dict: "gemini": gemini_info, "generator": generator, "access_code_required": bool(ACCESS_CODE), + # Reliability/speed layers, surfaced so the UI (and ops) can see them. + "validator": node_validator_available(), + "library_size": len(SCENE_LIBRARY), } diff --git a/scene_library.py b/scene_library.py new file mode 100644 index 0000000..85bde32 --- /dev/null +++ b/scene_library.py @@ -0,0 +1,274 @@ +"""Curated, hand-verified STEM scene library for VisualLM. + +This is the retrieval corpus behind the "instant + always correct" fast path: +a prompt that strongly matches an entry's `keywords` renders one of these +vetted scenes immediately, with no model call (see library_match in main.py). + +Each entry is a complete scene the renderer can run as-is. Every `code` here +is validated by validate_scene.js (run `python3 -m unittest tests.test_main`, +which checks the whole library). Scenes authored/verified by the +stem-scene-library workflow are appended to `_GENERATED` at the bottom. + +To add a scene by hand: follow the H API contract in sandbox-worker.js / +main.py's SCENE_SYSTEM_PROMPT, give it broad synonym-rich keywords, and keep +it animated + labeled. +""" +from __future__ import annotations + +_BASE: list[dict] = [ + { + "id": "fourier-square-wave", + "title": "Fourier series → square wave", + "tag": "Signals", + "dimension": "2D", + "equation": "f(x) = (4/pi) * sum sin((2k-1)x)/(2k-1)", + "summary": "Odd harmonics add up to approximate a square wave; more terms = sharper edges.", + "keywords": [ + "fourier", "fourier series", "square wave", "harmonics", "sine", + "sum of sines", "signal", "decomposition", "approximation", + ], + "bullets": [ + "Each term is an odd harmonic: sin(x), sin(3x)/3, sin(5x)/5, ...", + "Adding more harmonics sharpens the corners toward a true square wave.", + "The ripple near the jump never fully vanishes (Gibbs phenomenon).", + ], + "student_prompts": [ + "Why only odd harmonics?", + "What is the Gibbs phenomenon?", + "How many terms to get within 1% of a square wave?", + ], + "code": r""" +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); +""".strip(), + }, + { + "id": "unit-circle-sin-cos", + "title": "Unit circle generates sine and cosine", + "tag": "Trigonometry", + "dimension": "2D", + "equation": "(cos t, sin t)", + "summary": "A point sweeping the unit circle traces cosine on x and sine on y.", + "keywords": [ + "unit circle", "sine", "cosine", "trig", "trigonometry", "angle", + "radians", "sin", "cos", "rotation", + ], + "bullets": [ + "The angle theta grows with time; the point is at (cos theta, sin theta).", + "Its height above the axis IS sin(theta); its horizontal offset is cos(theta).", + "One full revolution is 2*pi radians.", + ], + "student_prompts": [ + "Why is the radius always 1?", + "How do radians relate to degrees?", + "Where is cosine negative on the circle?", + ], + "code": r""" +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); +""".strip(), + }, + { + "id": "projectile-motion", + "title": "Projectile motion", + "tag": "Mechanics", + "dimension": "2D", + "equation": "x = v0 cos(a) t, y = v0 sin(a) t - g t^2 / 2", + "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", + ], + "bullets": [ + "Horizontal velocity stays constant; only gravity acts vertically.", + "The path is a parabola; peak height is where vertical velocity is zero.", + "Range is maximized at a 45 degree launch angle (no air resistance).", + ], + "student_prompts": [ + "Why is 45 degrees the optimal angle?", + "How long is the projectile in the air?", + "What is the speed at the peak?", + ], + "code": r""" +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 }); +""".strip(), + }, + { + "id": "derivative-tangent", + "title": "Derivative as a moving tangent line", + "tag": "Calculus", + "dimension": "2D", + "equation": "f'(a) = slope of the tangent at x = a", + "summary": "The derivative at a point is the slope of the tangent line there; watch it sweep along the curve.", + "keywords": [ + "derivative", "tangent", "tangent line", "slope", "calculus", + "differentiation", "rate of change", "instantaneous", + ], + "bullets": [ + "The tangent line touches the curve at one point and matches its slope.", + "Slope = f'(a); it changes as the point of tangency moves.", + "Where the curve is flat the derivative is zero.", + ], + "student_prompts": [ + "What does a negative slope mean here?", + "Where is the derivative zero?", + "How is this the limit of a secant line?", + ], + "code": r""" +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 }); +""".strip(), + }, + { + "id": "gradient-descent-3d", + "title": "Gradient descent on a loss surface", + "tag": "Machine Learning", + "dimension": "3D", + "equation": "x <- x - lr * grad f(x)", + "summary": "A ball rolls downhill along the negative gradient of a 3D loss surface toward the minimum.", + "keywords": [ + "gradient descent", "loss surface", "optimization", "machine learning", + "minimum", "gradient", "training", "cost function", "3d surface", + ], + "bullets": [ + "The surface height is the loss; lower is better.", + "Each step moves opposite the gradient — the steepest downhill direction.", + "The learning rate sets the step size; too big overshoots, too small crawls.", + ], + "student_prompts": [ + "What happens if the learning rate is too large?", + "How do local minima trap gradient descent?", + "What is the gradient, intuitively?", + ], + "code": r""" +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 }); +// A descending point, re-released every few seconds from a corner. +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 }); +""".strip(), + }, + { + "id": "dna-double-helix", + "title": "DNA double helix", + "tag": "Biology", + "dimension": "3D", + "equation": "two antiparallel strands + base pairs", + "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", + ], + "bullets": [ + "Two strands run in opposite (antiparallel) directions.", + "Base pairs (the rungs) hold the strands together.", + "The whole structure twists into a right-handed double helix.", + ], + "student_prompts": [ + "Which bases pair with which?", + "What does antiparallel mean?", + "How is DNA copied?", + ], + "code": r""" +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 }); +""".strip(), + }, +] + +# Scenes authored + adversarially verified by the stem-scene-library workflow +# are appended here. Kept separate from _BASE so the hand-verified baseline is +# always present even if the generated batch is regenerated. +_GENERATED: list[dict] = [] + +SCENE_LIBRARY: list[dict] = _BASE + _GENERATED diff --git a/tests/test_main.py b/tests/test_main.py index 3649279..50e446b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -375,5 +375,172 @@ def boom(prompt, mode): self.assertIn("error", body) +class AutofixTests(unittest.TestCase): + def test_prefixes_bare_math(self): + out = main.autofix_code("r = sqrt(x*x) + sin(t) + abs(v);") + self.assertIn("Math.sqrt(", out) + self.assertIn("Math.sin(", out) + self.assertIn("Math.abs(", out) + + def test_bare_constants(self): + self.assertIn("Math.PI", main.autofix_code("a = 2 * PI;")) + self.assertIn("H.TAU", main.autofix_code("a = TAU;")) + + def test_does_not_touch_qualified_or_user_names(self): + out = main.autofix_code("Math.sin(x); obj.max(1,2); mySin(3); api(1);") + self.assertEqual(out, "Math.sin(x); obj.max(1,2); mySin(3); api(1);") + + def test_skips_strings_and_comments(self): + out = main.autofix_code('H.text("use sin(x) here"); // call cos(y)\nr = sin(z);') + self.assertIn('"use sin(x) here"', out) # string untouched + self.assertIn("// call cos(y)", out) # comment untouched + self.assertIn("Math.sin(z)", out) # real call fixed + + def test_idempotent(self): + once = main.autofix_code("r = sqrt(2);") + self.assertEqual(once, main.autofix_code(once)) + + def test_sanitize_runs_autofix(self): + self.assertIn("Math.sin", main.sanitize_code("H.background(); const y = sin(t);")) + + +@unittest.skipUnless(main.node_validator_available(), "node validator not installed") +class HeadlessValidatorTests(unittest.TestCase): + def test_valid_scene_passes(self): + r = main.headless_validate( + 'H.background(); const v = H.plot2d({}); v.grid(); v.axes();' + ' v.fn(x => Math.sin(x + t)); H.text("t=" + t, 24, 30, {});' + ) + self.assertTrue(r["ok"]) + self.assertTrue(r["painted"]) + self.assertTrue(r["text"]) + + def test_runtime_throw_caught(self): + r = main.headless_validate("H.background(); const a = nope.bar; H.text('x',1,2,{});") + self.assertFalse(r["ok"]) + self.assertIn("nope", r["error"]) + + def test_blank_detected(self): + r = main.headless_validate("const a = 1; const b = a * 2;") + self.assertTrue(r["ok"]) + self.assertFalse(r["painted"]) + + def test_infinite_loop_is_killed(self): + r = main.headless_validate("H.background(); while (true) {}") + self.assertFalse(r["ok"]) + self.assertIn("hung", r["error"].lower()) + + def test_const_v_shadow_does_not_collide(self): + # The whole reason H/cam/view/v are globals, not params. + r = main.headless_validate( + 'H.background(); const v = H.plot2d({}); v.grid(); v.axes(); v.fn(x=>x); H.text("x",1,2,{});' + ) + self.assertTrue(r["ok"]) + + def test_host_escape_is_contained(self): + # process must be unreachable inside the sandbox. + r = main.headless_validate( + 'H.background(); const p = ({}).constructor.constructor("return typeof process")();' + ' H.text(""+p, 1, 2, {}); H.line(0,0,1,1,{}); H.circle(1,1,1,{});' + ) + self.assertTrue(r["ok"]) # didn't crash, and couldn't reach process + + +class EvaluateSceneTests(unittest.TestCase): + """evaluate_scene with the validator forced on/off via monkeypatch, so the + behaviour is deterministic regardless of whether node is installed.""" + + def setUp(self): + self._orig = main.headless_validate + + def tearDown(self): + main.headless_validate = self._orig + + def test_fatal_on_runtime_error(self): + main.headless_validate = lambda code, timeout=6.0: {"ok": False, "error": "boom", "painted": False, "text": False} + ev = main.evaluate_scene("whatever") + self.assertTrue(ev["fatal"]) + self.assertIn("boom", ev["error"]) + + def test_fatal_on_blank(self): + main.headless_validate = lambda code, timeout=6.0: {"ok": True, "error": None, "painted": False, "text": False} + ev = main.evaluate_scene("H.clear();") + self.assertTrue(ev["fatal"]) + + def test_static_and_unlabeled(self): + main.headless_validate = lambda code, timeout=6.0: {"ok": True, "error": None, "painted": True, "text": False} + ev = main.evaluate_scene("H.background(); H.line(0,0,1,1,{});") # no t, no text + self.assertFalse(ev["fatal"]) + self.assertEqual(set(ev["problems"]), {"static", "unlabeled"}) + + def test_clean_scene(self): + main.headless_validate = lambda code, timeout=6.0: {"ok": True, "error": None, "painted": True, "text": True} + ev = main.evaluate_scene('H.background(); H.text("t="+t,1,2,{});') + self.assertFalse(ev["fatal"]) + self.assertEqual(ev["problems"], []) + + def test_falls_back_to_static_gate_without_node(self): + main.headless_validate = lambda code, timeout=6.0: None + self.assertTrue(main.evaluate_scene("const a = 1;")["fatal"]) # blank → fatal + self.assertEqual(main.evaluate_scene('H.background(); H.text("t="+t,1,2); H.line(0,0,1,1);')["problems"], []) + + +class SceneCacheTests(unittest.TestCase): + def setUp(self): + with main._scene_cache_lock: + main._scene_cache.clear() + + def tearDown(self): + with main._scene_cache_lock: + main._scene_cache.clear() + + def test_roundtrip_and_normalization(self): + scene = {"title": "T", "code": "H.background();"} + main.scene_cache_put("Show A Wave", "auto", scene) + # case/whitespace-insensitive key + hit = main.scene_cache_get(" show a wave ", "auto") + self.assertIsNotNone(hit) + self.assertEqual(hit["title"], "T") + + def test_does_not_cache_imperfect_or_fallback(self): + main.scene_cache_put("p", "auto", {"title": "x", "is_fallback": True}) + main.scene_cache_put("q", "auto", {"title": "y", "quality_warnings": ["static"]}) + self.assertIsNone(main.scene_cache_get("p", "auto")) + self.assertIsNone(main.scene_cache_get("q", "auto")) + + def test_mode_is_part_of_key(self): + main.scene_cache_put("orbit", "2d", {"title": "flat"}) + self.assertIsNone(main.scene_cache_get("orbit", "3d")) + + +class LibraryMatchTests(unittest.TestCase): + def test_strong_match(self): + sc, score = main.library_match("show a fourier series building a square wave", "auto") + self.assertIsNotNone(sc) + self.assertEqual(sc["id"], "fourier-square-wave") + self.assertGreaterEqual(score, 3.0) + + def test_no_match_for_unrelated(self): + sc, score = main.library_match("my favorite pasta recipe", "auto") + self.assertEqual(score, 0.0) + self.assertIsNone(sc) + + def test_mode_mismatch_penalized(self): + # dna-double-helix is 3D; forcing 2D should drop its score. + _, s3 = main.library_match("dna double helix", "3d") + _, s2 = main.library_match("dna double helix", "2d") + self.assertGreater(s3, s2) + + def test_every_library_scene_is_runnable(self): + if not main.node_validator_available(): + self.skipTest("node validator not installed") + for sc in main.SCENE_LIBRARY: + r = main.headless_validate(sc["code"]) + self.assertIsNotNone(r, sc["id"]) + self.assertTrue(r["ok"], f"{sc['id']} threw: {r.get('error')}") + self.assertTrue(r["painted"], f"{sc['id']} drew nothing") + self.assertTrue(r["text"], f"{sc['id']} had no labels") + + if __name__ == "__main__": unittest.main() diff --git a/validate_scene.js b/validate_scene.js new file mode 100644 index 0000000..07e2192 --- /dev/null +++ b/validate_scene.js @@ -0,0 +1,191 @@ +#!/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; +var console = { log: function(){}, warn: function(){}, error: function(){}, info: function(){} }; +var W = 900, H_ = 560, TAU = Math.PI * 2; +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(){ + function draw(){ paint++; } + 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(){ text++; paint++; }, + line: function(){ draw(); }, + path: function(p){ if(p && p.length>=2) draw(); }, + circle: function(){ draw(); }, + rect: function(){ draw(); }, + arrow: function(){ draw(); }, + legend: function(items){ (items||[]).forEach(function(){ text++; }); paint++; }, + 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); }; + var view={ + box:box, xMin:xMin, xMax:xMax, yMin:yMin, yMax:yMax, X:X, Y:Y, + grid:function(){ draw(); return view; }, + axes:function(){ draw(); text++; return view; }, + fn:function(f){ for(var i=0;i<=12;i++){ try{ f(lerp(xMin,xMax,i/12)); }catch(e){} } draw(); return view; }, + dot:function(){ draw(); return view; }, + line:function(){ draw(); return view; }, + arrow:function(){ draw(); return view; }, + text:function(){ text++; paint++; return view; }, + circle:function(){ draw(); return view; }, + path:function(p){ if(p && p.length) draw(); return view; }, + rect:function(){ draw(); return view; } + }; + return wrap(view); + }, + cam3d: function(o){ + o=o||{}; + var yaw=o.yaw||0, pitch=o.pitch==null?-0.5:o.pitch; + var scale=o.scale||60, dist=o.dist||9; + var cx=o.cx==null?W/2:o.cx, cy=o.cy==null?H_/2:o.cy; + var cam={ + get yaw(){ return yaw; }, set yaw(v){ yaw=v; }, + get pitch(){ return pitch; }, set pitch(v){ pitch=v; }, + project:function(p){ var x=(p&&p[0])||0,y=(p&&p[1])||0,z=(p&&p[2])||0; var f=dist/(dist+z); return { x:cx+x*scale*f, y:cy-y*scale*f, depth:z, f:f }; }, + line:function(){ draw(); return cam; }, + path:function(p){ if(p && p.length>=2) draw(); return cam; }, + poly:function(p){ if(p && p.length>=3) draw(); return cam; }, + sphere:function(p){ draw(); return cam.project(p); }, + grid:function(){ draw(); return cam; }, + axes:function(){ draw(); text++; return cam; } + }; + return wrap(cam); + }, + surface3d: function(cam,f){ if(typeof f==="function"){ for(var i=0;i<6;i++) for(var j=0;j<6;j++){ try{ f(i-3,j-3); }catch(e){} } } draw(); }, + mesh3d: function(cam,fn){ if(typeof fn==="function"){ for(var i=0;i<6;i++) for(var j=0;j<6;j++){ try{ fn((i/6)*TAU,(j/6)*TAU); }catch(e){} } } draw(); } + }; + return wrap(H); +} +var H = makeH(); +var ctx = makeCtx(); +var cam = H.cam3d({}); +var view = H.plot2d({}); +var v = view; +`; + +function readStdin() { + return new Promise((resolve) => { + 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 }; + + // 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});})()"; + + 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; + } 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)); +})(); From 7bdf7cba89bdc6da14e8d77d2961f7d2b1fcf346 Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Sun, 14 Jun 2026 21:51:39 +0700 Subject: [PATCH 04/43] Validator catches off-screen draws (data-vs-pixel coordinate mixups) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weak local model's most common SEMANTIC failure isn't a crash — it's drawing real content in the wrong coordinate space (e.g. v.line(v.X(x), ...), which double-maps every point off the canvas). It runs, it "paints", it has labels, so the old checks passed it — but the screen shows only axes. The validator now tracks, per content draw, whether it lands on-canvas (data- space view/cam methods convert via X/Y or project() first, exactly like the real helpers). paint>0 with zero on-screen => onscreen:false, which evaluate_scene treats as fatal and repairs with a coordinate-space hint. 'paint' now counts CONTENT draws only (grid/axes/background are scaffold), so an axes-only "scene" is correctly seen as blank. All 6 library scenes pass; 72 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 12 ++++++++ tests/test_main.py | 27 ++++++++++++++++- validate_scene.js | 75 +++++++++++++++++++++++++++------------------- 3 files changed, 83 insertions(+), 31 deletions(-) diff --git a/main.py b/main.py index a84230b..efc6447 100644 --- a/main.py +++ b/main.py @@ -1385,6 +1385,18 @@ def evaluate_scene(code: str) -> dict: ), "problems": [], } + if val.get("onscreen") is False: + 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." + ), + "problems": [], + } problems = [] if not code_is_animated(code): problems.append("static") diff --git a/tests/test_main.py b/tests/test_main.py index 50e446b..794df00 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -437,6 +437,25 @@ def test_const_v_shadow_does_not_collide(self): ) self.assertTrue(r["ok"]) + def test_offscreen_draws_detected(self): + # The classic data-vs-pixel mixup: wrapping data-space v.line args in + # v.X()/v.Y() double-maps them off the canvas. Runs fine, paints, but + # nothing is visible. + r = main.headless_validate( + "const v = H.plot2d({xMin:-5,xMax:5,yMin:-5,yMax:5}); v.grid(); v.axes();" + " v.line(v.X(0), v.Y(0), v.X(3), v.Y(3), {}); v.text('t', 24, 30, {});" + ) + self.assertTrue(r["ok"]) + self.assertTrue(r["painted"]) + self.assertFalse(r["onscreen"]) + + def test_onscreen_content_passes(self): + r = main.headless_validate( + "const v = H.plot2d({xMin:-5,xMax:5,yMin:-5,yMax:5}); v.grid(); v.axes();" + " v.line(0, 0, 3, 3, {}); H.text('t', 24, 30, {});" + ) + self.assertTrue(r["onscreen"]) + def test_host_escape_is_contained(self): # process must be unreachable inside the sandbox. r = main.headless_validate( @@ -474,11 +493,17 @@ def test_static_and_unlabeled(self): self.assertEqual(set(ev["problems"]), {"static", "unlabeled"}) def test_clean_scene(self): - main.headless_validate = lambda code, timeout=6.0: {"ok": True, "error": None, "painted": True, "text": True} + main.headless_validate = lambda code, timeout=6.0: {"ok": True, "error": None, "painted": True, "text": True, "onscreen": True} ev = main.evaluate_scene('H.background(); H.text("t="+t,1,2,{});') self.assertFalse(ev["fatal"]) self.assertEqual(ev["problems"], []) + def test_fatal_on_offscreen(self): + main.headless_validate = lambda code, timeout=6.0: {"ok": True, "error": None, "painted": True, "text": True, "onscreen": False} + ev = main.evaluate_scene("v.line(v.X(0),v.Y(0),v.X(3),v.Y(3),{});") + self.assertTrue(ev["fatal"]) + self.assertIn("OFF-SCREEN", ev["error"]) + def test_falls_back_to_static_gate_without_node(self): main.headless_validate = lambda code, timeout=6.0: None self.assertTrue(main.evaluate_scene("const a = 1;")["fatal"]) # blank → fatal diff --git a/validate_scene.js b/validate_scene.js index 07e2192..c46a3be 100644 --- a/validate_scene.js +++ b/validate_scene.js @@ -37,9 +37,17 @@ const vm = require("vm"); // 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; +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", @@ -76,18 +84,17 @@ function makeCtx(){ }); } function makeH(){ - function draw(){ paint++; } 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(){ text++; paint++; }, - line: function(){ draw(); }, - path: function(p){ if(p && p.length>=2) draw(); }, - circle: function(){ draw(); }, - rect: function(){ draw(); }, - arrow: function(){ draw(); }, - legend: function(items){ (items||[]).forEach(function(){ text++; }); paint++; }, + 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){ @@ -98,18 +105,22 @@ function makeH(){ 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(){ draw(); return view; }, - axes:function(){ draw(); text++; return view; }, - fn:function(f){ for(var i=0;i<=12;i++){ try{ f(lerp(xMin,xMax,i/12)); }catch(e){} } draw(); return view; }, - dot:function(){ draw(); return view; }, - line:function(){ draw(); return view; }, - arrow:function(){ draw(); return view; }, - text:function(){ text++; paint++; return view; }, - circle:function(){ draw(); return view; }, - path:function(p){ if(p && p.length) draw(); return view; }, - rect:function(){ draw(); return view; } + 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) draw(); return cam; }, - poly:function(p){ if(p && p.length>=3) draw(); return cam; }, - sphere:function(p){ draw(); return cam.project(p); }, - grid:function(){ draw(); return cam; }, - axes:function(){ draw(); text++; return cam; } + project:proj, + line:function(a,b){ var p1=proj(a),p2=proj(b); content(onAny([[p1.x,p1.y],[p2.x,p2.y]])); return cam; }, + path:function(p){ if(p && p.length>=2){ var q=[]; for(var i=0;i=3){ var q=[]; for(var i=0;i { const code = await readStdin(); - let result = { ok: false, error: null, painted: false, text: false, paint: 0 }; + 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 @@ -168,7 +180,7 @@ function readStdin() { 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});})()"; + "return JSON.stringify({ok:__ok,error:__err,paint:paint,text:text,conscr:conscr});})()"; try { const context = vm.createContext(Object.create(null)); @@ -179,6 +191,9 @@ function readStdin() { 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)) { From 6cd20c7939b44f5e786905c63a3e2b2ca6566035 Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Sun, 14 Jun 2026 22:07:15 +0700 Subject: [PATCH 05/43] Integrate 50-scene verified STEM library + smarter retrieval - scene_library_generated.json: 42 scenes authored by the stem-scene-library multi-agent workflow (one author per domain + adversarial per-scene review), every one re-validated through validate_scene.js (runs, paints on-screen, animates, labeled). Covers ~24 domains: quantum, linear algebra, waves, algorithms, astronomy, neuroscience, control systems, probability, etc. - Hand-written electromagnetism scenes (dipole field lines, RC circuit) to cover the two UI example chips the workflow batch missed. - library_match: weight title/tag tokens, drop stopwords, dominance gap so an ambiguous near-tie doesn't fast-path; high-confidence matches fast-path even when a duplicate-topic scene also scores well. Library fallback now requires real relevance (>=2.0) instead of any score, so a failed generation never shows a wrong-topic curated scene. - Dockerfile copies the generated JSON; README documents the reliability/speed architecture + how to regenerate the library. 69 tests pass. 7/10 example chips now render instantly from the verified library; the rest generate. Verified in-browser: dipole + 3D moon-phases scenes render correctly. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 2 +- README.md | 52 +- main.py | 92 ++- scene_library.py | 132 ++- scene_library_generated.json | 1459 ++++++++++++++++++++++++++++++++++ tests/test_main.py | 4 +- 6 files changed, 1702 insertions(+), 39 deletions(-) create mode 100644 scene_library_generated.json diff --git a/Dockerfile b/Dockerfile index 9b4f125..6b1deb6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY main.py scene_library.py validate_scene.js \ +COPY main.py scene_library.py scene_library_generated.json validate_scene.js \ 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 dad25e5..88074dc 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,44 @@ molecules, fields in space) versus 2D (single-variable functions, circuits, graphs, planar geometry), and worked examples show exactly how to use the pipeline. +## Getting the *right* animation — fast and reliably + +Generating animation code from a prompt is error-prone, especially with a small +local model. Four layers make it fast and correct without training a model from +scratch (which would be worse and need heavy infrastructure): + +1. **Curated STEM scene library** (`scene_library.py` + + `scene_library_generated.json`) — ~50 hand- and AI-authored scenes across + two dozen STEM domains, every one re-validated to actually run, paint + on-screen, animate, and carry labels. A strong keyword match short-circuits + straight to the matching scene: a "Fourier series" prompt renders in ~5 ms + instead of a 10–60 s generation that might fail. This is the practical, + retrieval-augmented realization of "feed it STEM data." +2. **Headless validator** (`validate_scene.js`, run server-side via Node) — + runs each generated scene in a hardened sandbox (`vm`, fresh empty context, + 2 s timeout) and reports throws / blank / off-screen / no-motion / no-labels + in ~50 ms. The browser is no longer the validator, so a bad scene is caught + and repaired *server-side* before it's ever sent — instead of costing a full + client round-trip per repair. It even catches the subtle "draws everything + off-screen" coordinate-space mixup. Degrades gracefully if Node is absent. +3. **Deterministic auto-fixer** — mechanically prefixes bare `Math.*` calls + (`sin(x)` → `Math.sin(x)`, the #1 cause of "X is not defined"), fixing the + most common failure with zero model round-trips. +4. **Validated-scene cache** — a repeated prompt (or a shared link) returns the + already-verified scene instantly. + +The result: common topics are instant and always correct; novel prompts are +generated, validated, and repaired server-side, then handed to the browser +ready to run. `GET /api/health` reports whether the validator and library are +active (`validator`, `library_size`). + +### Regenerating the library + +The generated scenes were produced by a multi-agent workflow (one author per +STEM domain, an independent adversarial review per scene). To re-run or extend +it, regenerate `scene_library_generated.json`; every scene is re-checked by +`validate_scene.js` (the test suite asserts the whole library runs). + ## Brains (multi-provider) Generation tries providers in this order — the first one with a key wins: @@ -116,15 +154,21 @@ HTTP round-trips against every endpoint (with stubbed generators). ## Files - `main.py` — web server, the four AI bridges (Claude/OpenAI/Gemini/Ollama), - scene generation/repair/tutor endpoints, rate limiting, and the system - prompt that defines the rendering contract. + the validate→auto-fix→repair pipeline, library retrieval + cache, rate + limiting, and the system prompt that defines the rendering contract. - `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 + `vm` sandbox); mirrors the worker's `H` API surface. +- `scene_library.py` / `scene_library_generated.json` — curated, verified STEM + scene corpus + the keyword retrieval used for the instant fast path. - `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. -- `Dockerfile` / `render.yaml` — production deployment. -- `tests/` — stdlib-only test suite. +- `Dockerfile` / `render.yaml` — production deployment (Docker image bundles + Node for the validator). +- `tests/` — stdlib-only test suite (covers the validator, auto-fixer, + library, cache, and every endpoint). ## API diff --git a/main.py b/main.py index efc6447..70ec7b7 100644 --- a/main.py +++ b/main.py @@ -1616,32 +1616,51 @@ def _tokenize(s: str) -> set: return set(re.findall(r"[a-z0-9]+", (s or "").lower())) -def library_match(prompt: str, mode: str) -> tuple[dict | None, float]: - """Best curated scene for this prompt + its match score (0 = no match).""" +# Common words that shouldn't drive a topic match on their own. +_MATCH_STOPWORDS = frozenset( + "the a an of in on to and or is are show me how visualize animate explain " + "draw plot with for as its it this that what why over time using see".split() +) + + +def _scene_score(sc: dict, ptext: str, ptoks: set, mode: str) -> float: + score = 0.0 + for kw in sc.get("keywords", []): + k = kw.lower().strip() + if not k: + continue + if " " in k: # multi-word phrase: a contiguous hit is a strong signal + if k in ptext: + score += 2.0 + elif k in ptoks: + score += 1.0 + # Title and tag words are strong topic signals (minus generic stopwords). + title_toks = _tokenize(sc.get("title", "")) - _MATCH_STOPWORDS + tag_toks = _tokenize(sc.get("tag", "")) - _MATCH_STOPWORDS + score += 1.0 * len(title_toks & ptoks) + score += 0.5 * len(tag_toks & ptoks) + # Respect an explicit 2D/3D preference. + if mode in ("2d", "3d") and sc.get("dimension", "").lower() != mode: + score -= 1.5 + return score + + +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 None, 0.0 + return [] ptext = " " + re.sub(r"\s+", " ", (prompt or "").lower()) + " " - ptoks = _tokenize(prompt) - best, best_score = None, 0.0 - for sc in SCENE_LIBRARY: - score = 0.0 - for kw in sc.get("keywords", []): - k = kw.lower().strip() - if not k: - continue - if " " in k: # multi-word phrase: weight a contiguous hit higher - if k in ptext: - score += 2.0 - elif k in ptoks: - score += 1.0 - # Shared title words are a mild positive signal. - score += 0.5 * len(_tokenize(sc.get("title", "")) & ptoks) - # Respect an explicit 2D/3D preference. - if mode in ("2d", "3d") and sc.get("dimension", "").lower() != mode: - score -= 1.5 - if score > best_score: - best, best_score = sc, score - return best, best_score + ptoks = _tokenize(prompt) - _MATCH_STOPWORDS + scored = [(sc, _scene_score(sc, ptext, ptoks, mode)) 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 + + +def library_match(prompt: str, mode: str) -> tuple[dict | None, float]: + """Best curated scene for this prompt + its match score (0 = no match).""" + scored = _library_scored(prompt, mode) + return scored[0] if scored else (None, 0.0) def _library_scene(sc: dict, prompt: str) -> dict: @@ -1674,10 +1693,20 @@ def plan_visualization(prompt: str, preferred_mode: str) -> dict: # 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. - lib_scene, lib_score = library_match(prompt, preferred_mode) - strong_threshold = 6.0 if _has_cloud() else 3.0 - if lib_scene is not None and lib_score >= strong_threshold: + # lower when we'd otherwise lean on the slow/weak local model. A + # "dominance" gap guards against ambiguous matches: the top scene must + # clearly beat the runner-up before we short-circuit to it. + scored = _library_scored(prompt, preferred_mode) + lib_scene, lib_score = scored[0] if scored else (None, 0.0) + runner_up = scored[1][1] if len(scored) > 1 else 0.0 + strong_threshold = 6.0 if _has_cloud() else 2.5 + # A clearly on-topic match (high absolute score) fast-paths regardless of + # ties — a 9-point Fourier match is right even if a second Fourier scene + # also scores high. The dominance gap only guards BORDERLINE matches from + # 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: result = _library_scene(lib_scene, prompt) scene_cache_put(prompt, preferred_mode, result) return result @@ -1704,9 +1733,10 @@ def plan_visualization(prompt: str, preferred_mode: str) -> dict: except Exception as error: # noqa: BLE001 errors.append(f"Ollama: {error}") - # 4. Every generator failed. A curated scene — even a loose match — beats a - # dead canvas, so prefer it over the placeholder fallback. - if lib_scene is not None and lib_score > 0: + # 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 + # moderate match (not just any score > 0). + if lib_scene is not None and lib_score >= 2.0: result = _library_scene(lib_scene, prompt) result["fallback_reason"] = ( "The live generator couldn't produce a runnable scene, so here's the " diff --git a/scene_library.py b/scene_library.py index 85bde32..a0fcb4f 100644 --- a/scene_library.py +++ b/scene_library.py @@ -217,6 +217,121 @@ 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 }); +""".strip(), + }, + { + "id": "electric-dipole-field", + "title": "Electric field lines of a dipole", + "tag": "Electromagnetism", + "dimension": "2D", + "equation": "E = k q / r^2 (superposed from + and - charges)", + "summary": "Field lines stream from the positive charge to the negative charge; a test charge follows the local field.", + "keywords": [ + "electric field", "field lines", "dipole", "charge", "electrostatics", + "coulomb", "electromagnetism", "point charge", "positive", "negative", + "voltage", "potential", + ], + "bullets": [ + "Field lines leave the + charge and enter the - charge.", + "Line density is higher where the field is stronger (near the charges).", + "A positive test charge feels a force along the local field direction.", + ], + "student_prompts": [ + "Why do field lines never cross?", + "How does field strength fall off with distance?", + "What is the field exactly between the two charges?", + ], + "code": r""" +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]; +} +// Streamlines seeded in a ring around the + charge. +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 }); +} +// A test charge advected by the field. +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 }); +""".strip(), + }, + { + "id": "rc-circuit-charging", + "title": "RC circuit charging a capacitor", + "tag": "Electromagnetism", + "dimension": "2D", + "equation": "V(t) = V0 (1 - e^(-t / RC))", + "summary": "A capacitor charges toward the supply voltage on an exponential curve set by the time constant RC.", + "keywords": [ + "rc circuit", "capacitor", "charging", "time constant", "exponential", + "circuit", "resistor", "voltage", "electronics", "discharge", "rc", + ], + "bullets": [ + "The capacitor voltage rises fast at first, then levels off.", + "After one time constant (t = RC) it reaches about 63% of the supply.", + "Larger R or C means a slower charge (a bigger time constant).", + ], + "student_prompts": [ + "What is the time constant here?", + "How long until the capacitor is ~99% charged?", + "What happens when it discharges?", + ], + "code": r""" +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 }); +// A little circuit + a capacitor whose fill tracks V. +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 }); """.strip(), }, { @@ -267,8 +382,21 @@ ] # Scenes authored + adversarially verified by the stem-scene-library workflow -# are appended here. Kept separate from _BASE so the hand-verified baseline is -# always present even if the generated batch is regenerated. +# (one agent per STEM domain → independent correctness review per scene), then +# re-validated through validate_scene.js so every one is guaranteed to run, +# paint on-screen, animate, and carry labels. Stored as data in +# scene_library_generated.json and merged here. Kept separate from _BASE so the +# hand-verified baseline is always present even if the batch is regenerated. +import json as _json +from pathlib import Path as _Path + _GENERATED: list[dict] = [] +_gen_path = _Path(__file__).resolve().parent / "scene_library_generated.json" +try: + _GENERATED = _json.loads(_gen_path.read_text(encoding="utf-8")) + if not isinstance(_GENERATED, list): + _GENERATED = [] +except (OSError, ValueError): + _GENERATED = [] SCENE_LIBRARY: list[dict] = _BASE + _GENERATED diff --git a/scene_library_generated.json b/scene_library_generated.json new file mode 100644 index 0000000..c0bbe1b --- /dev/null +++ b/scene_library_generated.json @@ -0,0 +1,1459 @@ +[ + { + "id": "kepler-elliptical-orbit-equal-areas", + "title": "Kepler's Laws: Elliptical Orbit & Equal Areas", + "tag": "Orbital Mechanics", + "dimension": "2D", + "equation": "M = E - e*sin(E), dA/dt = const", + "summary": "A planet sweeps around an elliptical orbit with the star at one focus. Equal-time sectors are highlighted to show they enclose equal area (Kepler's 2nd law), while the planet visibly speeds up near perihelion and slows near aphelion.", + "keywords": [ + "kepler", + "keplers laws", + "elliptical orbit", + "ellipse", + "equal areas", + "second law", + "areal velocity", + "eccentric anomaly", + "eccentricity", + "perihelion", + "aphelion", + "planet", + "orbit", + "focus" + ], + "bullets": [ + "The star sits at one focus of the ellipse, not the center (Kepler's 1st law).", + "Each orange sector covers the same area in the same elapsed time, so the planet moves fastest near perihelion (Kepler's 2nd law).", + "The live speed v comes from the vis-viva equation v = sqrt(2/r - 1/a) with GM = 1, peaking at closest approach." + ], + "student_prompts": [ + "Why does the planet move faster when it is closer to the star?", + "How is the eccentric anomaly E related to the actual angle swept from the focus?", + "Derive the vis-viva equation and show where the readout speed comes from." + ], + "code": "H.background();\n// --- Kepler's first & second law: elliptical orbit + equal areas in equal times ---\nconst a = 5.2; // semi-major axis (data units)\nconst e = 0.6; // eccentricity\nconst b = a * Math.sqrt(1 - e * e); // semi-minor axis\nconst c = a * e; // focus offset from center\nconst v = H.plot2d({ xMin: -7.2, xMax: 7.2, yMin: -4.6, yMax: 4.6, pad: 50 });\nv.grid(); v.axes();\n\n// Star sits at the focus (origin). Center of the ellipse is at (-c, 0).\nconst cxData = -c, cyData = 0;\n// Draw the full ellipse path.\nconst ell = [];\nconst NE = 160;\nfor (let i = 0; i <= NE; i++) {\n const th = (i / NE) * H.TAU;\n ell.push([cxData + a * Math.cos(th), cyData + b * Math.sin(th)]);\n}\nv.path(ell, { color: H.colors.accent, width: 2.4 });\n\n// Solve Kepler's equation M = E - e sin E for the eccentric anomaly E.\n// Mean motion: full period = 6s of sim time, so M sweeps uniformly with t.\nconst period = 6;\nconst M = ((t % period) / period) * H.TAU;\nlet E = M; // Newton iteration (few steps, converges fast)\nfor (let k = 0; k < 6; k++) {\n E -= (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));\n}\n// Planet position on the ellipse (focus at origin).\nconst px = cxData + a * Math.cos(E);\nconst py = cyData + b * Math.sin(E);\n\n// Equal-area swept sector over a fixed slice of the period, ending at \"now\".\nconst dM = H.TAU * 0.10; // mean-anomaly width of the highlighted sweep\nconst swept = [[0, 0]];\nconst SEG = 28;\nfor (let i = 0; i <= SEG; i++) {\n const Mi = M - dM + (dM * i) / SEG;\n let Ei = Mi;\n for (let k = 0; k < 5; k++) {\n Ei -= (Ei - e * Math.sin(Ei) - Mi) / (1 - e * Math.cos(Ei));\n }\n swept.push([cxData + a * Math.cos(Ei), cyData + b * Math.sin(Ei)]);\n}\nv.path(swept, { color: H.colors.accent2, width: 1, fill: \"rgba(244,162,89,0.32)\", close: true });\n\n// Radius line star -> planet, and the star itself at the focus.\nv.line(0, 0, px, py, { color: H.colors.sub, width: 1.4, dash: [5, 5] });\nv.circle(0, 0, 9, { fill: H.colors.yellow, stroke: \"#a8791f\", width: 1.5 });\nv.dot(px, py, { r: 6, fill: H.colors.accent, stroke: H.colors.bg });\n// Mark the empty (other) focus too.\nv.circle(-2 * c, 0, 3, { fill: H.colors.sub });\n\n// Live numbers: orbital radius r and instantaneous speed (vis-viva, GM=1).\nconst r = Math.hypot(px, py);\nconst speed = Math.sqrt(Math.max(0, 2 / r - 1 / a)); // vis-viva, never negative\nconst phaseName = E < Math.PI ? \"speeding up toward perihelion\" : \"slowing toward aphelion\";\n\nH.text(\"Kepler's Laws: elliptical orbit & equal areas\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Orange sectors swept in equal times have equal area (2nd law).\",\n 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + r.toFixed(2) + \" AU\", 24, H.H - 54, { color: H.colors.accent2, size: 14 });\nH.text(\"v = \" + speed.toFixed(2) + \" (vis-viva, GM=1)\", 24, H.H - 34,\n { color: H.colors.good, size: 14 });\nH.text(\"e = \" + e.toFixed(2) + \" \" + phaseName, 24, H.H - 14,\n { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"orbit (ellipse)\", color: H.colors.accent },\n { label: \"star at focus\", color: H.colors.yellow },\n { label: \"swept area\", color: H.colors.accent2 },\n], H.W - 200, 30);" + }, + { + "id": "moon-phases-lighting-geometry-3d", + "title": "Moon Phases: Lighting Geometry in 3D", + "tag": "Astronomy", + "dimension": "3D", + "equation": "illuminated fraction = (1 - cos(elongation)) / 2", + "summary": "The Moon orbits Earth in 3D while parallel sunlight from one side lights exactly half of it. A corner inset shows the resulting Earth-view disk cycling through new, crescent, quarter, gibbous and full, with a live illuminated-percentage readout.", + "keywords": [ + "moon phases", + "lunar phases", + "moon", + "earth", + "sun", + "crescent", + "gibbous", + "waxing", + "waning", + "new moon", + "full moon", + "first quarter", + "synodic month", + "terminator" + ], + "bullets": [ + "The Sun always lights exactly half the Moon; phase is just how much of that lit half faces Earth.", + "Illuminated fraction equals (1 - cos(elongation))/2, ranging from 0% at new moon to 100% at full moon.", + "The inset disk shows the familiar crescent-to-gibbous shape that an observer on Earth actually sees as the Moon orbits." + ], + "student_prompts": [ + "Why do we see the same face of the Moon even though its phase changes?", + "What is the difference between the Moon's phase and a lunar eclipse?", + "How long is one full cycle of phases and why is it longer than the orbital period?" + ], + "code": "H.background();\n// --- Moon phases (3D): the Moon orbiting Earth, lit from one side by the Sun ---\nconst cam = H.cam3d({ scale: 46, dist: 17, pitch: -0.62, cy: H.H * 0.52 });\ncam.yaw = 0.18 * t; // gentle default spin so it reads as 3D\ncam.grid(7, 1);\n\n// Sun is far away in the +X direction: light rays point toward -X.\nconst sunDir = [1, 0, 0]; // unit vector FROM scene TOWARD the Sun\nconst orbitR = 4.0; // Moon's orbital radius about Earth (data units)\nconst period = 12; // seconds per synodic cycle\nconst phase = ((t % period) / period) * H.TAU; // 0..TAU around the orbit\n// Moon position in the ecliptic plane (x/z ground plane; y is up).\nconst mx = orbitR * Math.cos(phase);\nconst mz = orbitR * Math.sin(phase);\nconst moonPos = [mx, 0, mz];\n\n// Draw the orbit ring of the Moon.\nconst ring = [];\nconst NR = 96;\nfor (let i = 0; i <= NR; i++) {\n const th = (i / NR) * H.TAU;\n ring.push([orbitR * Math.cos(th), 0, orbitR * Math.sin(th)]);\n}\ncam.path(ring, { color: H.colors.grid, width: 1.4 });\n\n// Sunlight direction indicator: a long arrow sweeping in across the scene.\nconst sunHead = cam.project([-orbitR - 1.4, 0, 0]);\nconst sunTail = cam.project([-6.2, 0, 0]);\nH.arrow(sunTail.x, sunTail.y, sunHead.x, sunHead.y, { color: H.colors.yellow, width: 2 });\n\n// Phase angle: angle between Sun direction and Earth->Moon direction.\n// Illuminated fraction = (1 - cos(elongation)) / 2.\nconst moonHat = [Math.cos(phase), 0, Math.sin(phase)];\nconst cosElong = moonHat[0] * sunDir[0] + moonHat[2] * sunDir[2];\nconst illum = (1 - cosElong) / 2; // 0 = new moon, 1 = full moon\nconst elong = Math.acos(H.clamp(cosElong, -1, 1));\n\n// Depth-sort the bodies and draw far-to-near.\nconst bodies = [\n { p: [0, 0, 0], r: 1.05, color: H.colors.accent },\n { p: moonPos, r: 0.55, color: \"#cdd3e0\" },\n];\nbodies\n .map((o) => ({ p: o.p, r: o.r, color: o.color, depth: cam.project(o.p).depth }))\n .sort((a, b) => b.depth - a.depth)\n .forEach((o) => cam.sphere(o.p, o.r, { color: o.color }));\n\n// Terminator shading on the Moon: a dark cap on the side facing away from the\n// Sun, sized by the unlit fraction, drawn in screen space.\nconst mProj = cam.project(moonPos);\nconst moonRpx = Math.max(4, 0.55 * 46 * mProj.f);\nconst darkCenter = [moonPos[0] - sunDir[0] * 0.55, 0, moonPos[2] - sunDir[2] * 0.55];\nconst dProj = cam.project(darkCenter);\nH.circle(dProj.x, dProj.y, moonRpx * (0.45 + 0.55 * (1 - illum)),\n { fill: \"rgba(8,10,24,0.55)\" });\n\n// As-seen-from-Earth phase disk in the corner (crescent/gibbous).\nconst diskX = H.W - 90, diskY = 96, diskR = 34;\nH.circle(diskX, diskY, diskR + 4, { fill: \"#11182b\", stroke: H.colors.grid, width: 1.5 });\nH.circle(diskX, diskY, diskR, { fill: \"#1a2236\" });\nconst waxing = Math.sin(phase) >= 0 ? 1 : -1;\nconst lit = [];\nconst NP = 40;\nfor (let i = 0; i <= NP; i++) {\n const ang = -Math.PI / 2 + (Math.PI * i) / NP;\n lit.push([diskX + waxing * diskR * Math.cos(ang), diskY + diskR * Math.sin(ang)]);\n}\nconst termW = diskR * (1 - 2 * illum);\nfor (let i = NP; i >= 0; i--) {\n const ang = -Math.PI / 2 + (Math.PI * i) / NP;\n lit.push([diskX + waxing * termW * Math.cos(ang), diskY + diskR * Math.sin(ang)]);\n}\nH.path(lit, { color: \"none\", fill: H.colors.yellow, close: true });\n\nlet phaseName;\nif (illum < 0.04) phaseName = \"New Moon\";\nelse if (illum > 0.96) phaseName = \"Full Moon\";\nelse if (Math.abs(illum - 0.5) < 0.06) phaseName = (waxing > 0 ? \"First\" : \"Last\") + \" Quarter\";\nelse if (illum < 0.5) phaseName = (waxing > 0 ? \"Waxing\" : \"Waning\") + \" Crescent\";\nelse phaseName = (waxing > 0 ? \"Waxing\" : \"Waning\") + \" Gibbous\";\n\ncam.axes(5);\nH.text(\"Moon Phases: lighting geometry in 3D\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"The Sun lights one hemisphere; phase = how much of it we see from Earth.\",\n 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"illuminated = \" + (illum * 100).toFixed(0) + \"%\", 24, H.H - 54,\n { color: H.colors.yellow, size: 14 });\nH.text(\"elongation = \" + (elong * 180 / Math.PI).toFixed(0) + \" deg\", 24, H.H - 34,\n { color: H.colors.sub, size: 13 });\nH.text(\"phase: \" + phaseName, 24, H.H - 14, { color: H.colors.accent, size: 14, weight: 700 });\nH.legend([\n { label: \"Earth\", color: H.colors.accent },\n { label: \"Moon\", color: \"#cdd3e0\" },\n { label: \"sunlight\", color: H.colors.yellow },\n], H.W - 170, H.H - 80);" + }, + { + "id": "inverse-square-gravity-potential-well", + "title": "Inverse-Square Gravity & the Potential Well", + "tag": "Gravitation", + "dimension": "3D", + "equation": "g(r) = GM/r^2, Phi(r) = -GM/r", + "summary": "The gravitational potential Phi = -GM/r is drawn as a 3D funnel-shaped well with a test mass orbiting on its wall and an inward force arrow. A 2D inset plots the inverse-square field g(r) = GM/r^2 with the current orbital radius marked, both updating live.", + "keywords": [ + "inverse square law", + "inverse-square", + "gravity", + "gravitational field", + "potential well", + "gravitational potential", + "newton", + "gm over r squared", + "force field", + "field strength", + "central force", + "two-body", + "orbit", + "test mass" + ], + "bullets": [ + "The gravitational potential Phi = -GM/r forms a steep funnel that deepens toward the central mass.", + "Field strength g = GM/r^2 follows the inverse-square law: halving r quadruples the force, as the inset curve shows.", + "The green arrow is the inward gravitational pull on the orbiting test mass; it grows as the mass drops deeper into the well." + ], + "student_prompts": [ + "Why is the gravitational force an inverse-square law rather than inverse-distance?", + "How does the potential Phi = -GM/r relate to the force g = GM/r^2?", + "What orbital speed keeps the test mass on a stable circular orbit at this radius?" + ], + "code": "H.background();\n// --- Inverse-square gravity: the potential well and a test mass orbiting in it ---\nconst cam = H.cam3d({ scale: 30, dist: 17, pitch: -0.6, cy: H.H * 0.56 });\ncam.yaw = 0.3 * t; // slow spin so the well reads as 3D\ncam.grid(6, 2);\n\nconst GM = 6.0; // gravitational parameter\nconst soft = 0.45; // softening so the funnel stays finite at r=0\n// Potential height: Phi = -GM / sqrt(r^2 + soft^2), scaled to a visible depth.\nH.surface3d(cam, (x, y) => {\n const r2 = x * x + y * y;\n return (-GM / Math.sqrt(r2 + soft * soft)) * 0.95 + 7.5; // lift so it's in frame\n}, { xMin: -5.5, xMax: 5.5, yMin: -5.5, yMax: 5.5, nx: 40, ny: 40,\n hueMin: 215, hueMax: 280, alpha: 0.94 });\n\n// Test mass on a circular orbit at radius rOrb, sitting on the funnel wall.\nconst rOrb = 2.6;\nconst ang = t * 0.9;\nconst ox = rOrb * Math.cos(ang);\nconst oz = rOrb * Math.sin(ang);\nconst height = (-GM / Math.sqrt(rOrb * rOrb + soft * soft)) * 0.95 + 7.5;\nconst massPos = [ox, height + 0.35, oz];\ncam.sphere(massPos, 0.32, { color: H.colors.accent2 });\n// Central mass at the bottom of the well.\nconst wellBottom = (-GM / Math.sqrt(soft * soft)) * 0.95 + 7.5;\ncam.sphere([0, wellBottom + 0.3, 0], 0.5, { color: H.colors.yellow });\n\n// Inward gravitational force arrow on the test mass.\nconst g = GM / (rOrb * rOrb);\nconst ux = -Math.cos(ang), uz = -Math.sin(ang);\nconst tip = [ox + ux * g * 0.18, massPos[1], oz + uz * g * 0.18];\nconst aP = cam.project(massPos);\nconst bP = cam.project(tip);\nH.arrow(aP.x, aP.y, bP.x, bP.y, { color: H.colors.good, width: 2.4 });\n\ncam.axes(5);\n\n// 2D inset: g(r) = GM/r^2 falling off, with the current radius marked.\nconst v = H.plot2d({\n xMin: 0.4, xMax: 6, yMin: 0, yMax: 12,\n box: { x: H.W - 230, y: 64, w: 200, h: 130 },\n});\nv.grid(); v.axes();\nv.fn((rr) => GM / (rr * rr), { color: H.colors.accent, width: 2.4 });\nv.dot(rOrb, g, { r: 5, fill: H.colors.accent2 });\nv.line(rOrb, 0, rOrb, g, { color: H.colors.sub, width: 1, dash: [3, 4] });\nH.text(\"g(r) = GM/r^2\", H.W - 224, 58, { color: H.colors.accent, size: 12 });\n\nH.text(\"Inverse-square gravity & the potential well\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Phi = -GM/r forms a funnel; force g = GM/r^2 grows steeply as r shrinks.\",\n 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + rOrb.toFixed(2), 24, H.H - 54, { color: H.colors.accent2, size: 14 });\nH.text(\"g(r) = \" + g.toFixed(2) + \" (GM = \" + GM.toFixed(1) + \")\", 24, H.H - 34,\n { color: H.colors.good, size: 14 });\nH.text(\"Phi(r) = \" + (-GM / Math.sqrt(rOrb * rOrb + soft * soft)).toFixed(2),\n 24, H.H - 14, { color: H.colors.violet, size: 13 });\nH.legend([\n { label: \"central mass\", color: H.colors.yellow },\n { label: \"test mass\", color: H.colors.accent2 },\n { label: \"gravity (inward)\", color: H.colors.good },\n], 24, H.H - 110);" + }, + { + "id": "sieve-of-eratosthenes-primes", + "title": "Sieve of Eratosthenes: Finding Primes", + "tag": "Number Theory", + "dimension": "2D", + "equation": "cross out kp for k>=2; survivors are prime", + "summary": "A 10x10 grid of the integers 2 to 101. The animation steps through each prime p (2, 3, 5, 7) and sweeps out its multiples one by one, leaving the prime numbers highlighted. A live counter shows how many survivors remain.", + "keywords": [ + "sieve of eratosthenes", + "prime numbers", + "primes", + "number theory", + "find primes", + "prime sieve", + "composite numbers", + "factors", + "multiples", + "crossing out", + "integer grid", + "primality" + ], + "bullets": [ + "Start at the smallest unmarked number p; it must be prime, so circle it.", + "Cross out every multiple of p starting from p*p, since smaller multiples were already removed by smaller primes.", + "Only primes up to sqrt(101) (2,3,5,7) need a pass; whatever survives is guaranteed prime." + ], + "student_prompts": [ + "Why can we start crossing out at p squared instead of 2p?", + "How many primes are there below 100, and why is 101 included here?", + "What is the time complexity of the Sieve of Eratosthenes?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n// --- Sieve of Eratosthenes on a 10x10 grid of the integers 2..101 ---\nconst N = 100; // numbers shown: 2 .. 101\nconst cols = 10, rows = 10;\nconst first = 2;\n// Rebuild the sieve state up to the \"stage\" the animation has reached.\n// stage advances roughly one prime every ~1.6s and loops.\nconst primesToProcess = [2, 3, 5, 7]; // sqrt(101) < 11, so these suffice\nconst cycle = primesToProcess.length + 1;\nconst stageF = (t / 1.6) % cycle; // 0..cycle, fractional\nconst stage = Math.floor(stageF); // which prime index we're on\nconst subt = stageF - stage; // progress within this prime's pass\n\n// composite[k] === true -> crossed out (k is the integer 2..101)\nconst composite = new Array(first + N).fill(false);\nconst activePrime = stage < primesToProcess.length ? primesToProcess[stage] : 0;\n// Fully cross out all multiples of every prime strictly before the active one.\nfor (let s = 0; s < stage && s < primesToProcess.length; s++){\n const p = primesToProcess[s];\n for (let m = p * p; m < first + N; m += p) composite[m] = true;\n}\n// Partially reveal the active prime's multiples as subt grows 0->1.\nlet sweepHit = -1;\nif (activePrime > 0){\n const mults = [];\n for (let m = activePrime * activePrime; m < first + N; m += activePrime) mults.push(m);\n const reveal = Math.floor(H.ease(subt) * (mults.length + 0.999));\n for (let i = 0; i < reveal && i < mults.length; i++) composite[mults[i]] = true;\n if (reveal > 0 && reveal <= mults.length) sweepHit = mults[reveal - 1];\n}\n\n// --- layout the grid cell box ---\nconst gx = 54, gy = 96;\nconst gw = Math.min(w - 110, h - 150);\nconst cell = gw / cols;\nconst gh = cell * rows;\n\nlet primeCount = 0;\nfor (let i = 0; i < N; i++){\n const n = first + i;\n const c = i % cols;\n const r = Math.floor(i / cols);\n const x = gx + c * cell;\n const y = gy + r * cell;\n const isComp = composite[n];\n const isActivePrime = (n === activePrime);\n if (!isComp) primeCount++;\n // cell background\n let fill = H.colors.panel;\n if (isActivePrime) fill = \"#274064\";\n else if (n === sweepHit) fill = \"#3a2a44\";\n H.rect(x + 2, y + 2, cell - 4, cell - 4,\n { fill, stroke: isComp ? \"#202a44\" : H.colors.grid, width: 1, radius: 5 });\n // number\n const tcol = isComp ? \"#4f5d80\" : (isActivePrime ? H.colors.yellow : H.colors.ink);\n H.text(String(n), x + cell / 2, y + cell / 2 + 4,\n { color: tcol, size: Math.max(9, cell * 0.34), align: \"center\", weight: isComp ? 400 : 600 });\n // strike-through for composites\n if (isComp){\n const pad = cell * 0.22;\n H.line(x + pad, y + pad, x + cell - pad, y + cell - pad,\n { color: H.colors.warn, width: 1.6 });\n }\n}\n\n// Pulsing ring on the current prime being sieved.\nif (activePrime > 0){\n const idx = activePrime - first;\n const c = idx % cols, r = Math.floor(idx / cols);\n const cxp = gx + c * cell + cell / 2;\n const cyp = gy + r * cell + cell / 2;\n const pr = cell * 0.5 + 4 + 2 * Math.sin(t * 6);\n H.circle(cxp, cyp, pr, { stroke: H.colors.yellow, width: 2.4 });\n}\n\n// --- titles + live readout ---\nH.text(\"Sieve of Eratosthenes\", 24, 34, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Cross out every multiple of each prime; survivors are prime.\", 24, 56,\n { color: H.colors.sub, size: 13 });\nconst passLabel = activePrime > 0\n ? (\"sieving multiples of p = \" + activePrime)\n : \"sweep complete - survivors are prime\";\nH.text(passLabel, gx, gy + gh + 28, { color: H.colors.accent2, size: 14, weight: 600 });\nH.text(\"primes remaining (2..101): \" + primeCount, gx, gy + gh + 50,\n { color: H.colors.good, size: 13 });\n\nH.legend([\n { label: \"prime (survivor)\", color: H.colors.ink },\n { label: \"current prime p\", color: H.colors.yellow },\n { label: \"crossed out\", color: H.colors.warn },\n], gx + gw + 18, gy + 10);" + }, + { + "id": "modular-arithmetic-clock", + "title": "Modular Arithmetic Clock (mod 12)", + "tag": "Number Theory", + "dimension": "2D", + "equation": "n mod 12 = n - 12*floor(n/12)", + "summary": "A 12-hour clock dial where a hand steadily advances one position at a time and wraps from 11 back to 0. Live readouts show the running count n, its residue n mod 12, the number of completed laps, and the division identity n = q*12 + r.", + "keywords": [ + "modular arithmetic", + "mod", + "modulo", + "clock arithmetic", + "congruence", + "residue", + "remainder", + "wrap around", + "cyclic", + "number theory", + "division algorithm", + "clock face", + "mod 12" + ], + "bullets": [ + "Counting on a clock is arithmetic modulo 12: after 11 the hand wraps back to 0.", + "The residue n mod 12 is the remainder when n is divided by 12, always between 0 and 11.", + "Every integer n splits uniquely as n = (laps)*12 + residue, the division algorithm in action." + ], + "student_prompts": [ + "What is 100 mod 12, and how does the clock show it?", + "Why is modular arithmetic called 'clock arithmetic'?", + "How do you add and multiply numbers modulo 12?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst m = 12; // modulus\nconst cx = w * 0.40, cy = h * 0.55;\nconst R = Math.min(w * 0.34, h * 0.40);\n\n// A counter that increments ~1 step per 0.9s, wrapping mod m.\nconst k = (t / 0.9); // continuous count\nconst kInt = Math.floor(k); // whole steps taken\nconst frac = k - kInt; // 0..1 within current step\nconst residue = ((kInt % m) + m) % m; // current residue class\nconst nextResidue = (residue + 1) % m;\n// Hand angle eases from residue -> residue+1 across the step.\nconst eased = residue + H.ease(frac);\n// 12 o'clock at top, going clockwise: angle measured from top.\nconst angOf = (pos) => -H.PI / 2 + (pos / m) * H.TAU;\n\n// --- dial face ---\nH.circle(cx, cy, R + 14, { fill: H.colors.panel, stroke: H.colors.grid, width: 2 });\nH.circle(cx, cy, R, { stroke: H.colors.axis, width: 1.5 });\n\n// tick positions 0..11, highlight the current residue\nfor (let i = 0; i < m; i++){\n const a = angOf(i);\n const px = cx + Math.cos(a) * R;\n const py = cy + Math.sin(a) * R;\n const lx = cx + Math.cos(a) * (R + 26);\n const ly = cy + Math.sin(a) * (R + 26);\n const active = (i === residue);\n const isNext = (i === nextResidue);\n H.circle(px, py, active ? 9 : 5.5, {\n fill: active ? H.colors.accent2 : (isNext ? H.colors.violet : H.colors.grid),\n stroke: H.colors.bg, width: 2,\n });\n H.text(String(i), lx, ly + 5, {\n color: active ? H.colors.yellow : H.colors.sub,\n size: active ? 17 : 14, align: \"center\",\n weight: active ? 700 : 500,\n });\n}\n\n// arc swept since 0 to show how many full laps the count has made\nconst laps = Math.floor(kInt / m);\n// the moving hand\nconst ha = angOf(eased);\nconst hx = cx + Math.cos(ha) * (R - 8);\nconst hy = cy + Math.sin(ha) * (R - 8);\nH.line(cx, cy, hx, hy, { color: H.colors.accent, width: 4 });\nH.arrow(cx, cy, hx, hy, { color: H.colors.accent, width: 4, head: 12 });\nH.circle(cx, cy, 7, { fill: H.colors.ink });\n\n// little orbiting marker that travels the full count (shows wrap-around)\nconst ma = angOf(eased);\nconst mx = cx + Math.cos(ma) * (R + 40);\nconst my = cy + Math.sin(ma) * (R + 40);\nH.circle(mx, my, 6, { fill: H.colors.good, stroke: H.colors.bg, width: 2 });\n\n// --- titles + live readout ---\nH.text(\"Modular Arithmetic Clock (mod \" + m + \")\", 24, 34,\n { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Counting wraps around the dial: after \" + (m - 1) + \" comes 0.\", 24, 56,\n { color: H.colors.sub, size: 13 });\n\n// readout panel on the right\nconst px0 = w * 0.74, py0 = h * 0.30;\nH.text(\"count n = \" + kInt, px0, py0, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"n mod \" + m + \" = \" + residue, px0, py0 + 28,\n { color: H.colors.accent2, size: 22, weight: 700 });\nH.text(\"full laps = \" + laps, px0, py0 + 56, { color: H.colors.good, size: 14 });\nH.text(kInt + \" = \" + laps + \"x\" + m + \" + \" + residue, px0, py0 + 80,\n { color: H.colors.violet, size: 14 });\nH.text(\"next: (\" + residue + \" + 1) mod \" + m + \" = \" + nextResidue, px0, py0 + 104,\n { color: H.colors.sub, size: 13 });\n\nH.legend([\n { label: \"clock hand (residue)\", color: H.colors.accent },\n { label: \"current residue\", color: H.colors.accent2 },\n { label: \"running count\", color: H.colors.good },\n], px0, py0 + 140);" + }, + { + "id": "fibonacci-spiral-golden-ratio", + "title": "Fibonacci Spiral & the Golden Ratio", + "tag": "Number Theory", + "dimension": "2D", + "equation": "F(n+1)/F(n) -> phi = (1+sqrt(5))/2", + "summary": "Squares with Fibonacci side lengths 1,1,2,3,5,8,... are tiled in a spiral, each adding a quarter-circle arc that builds the golden spiral. Live readouts show the ratio F(n+1)/F(n) converging to phi as the spiral grows.", + "keywords": [ + "fibonacci", + "fibonacci spiral", + "golden ratio", + "phi", + "golden spiral", + "golden rectangle", + "sequence", + "ratio convergence", + "number theory", + "spiral", + "quarter circle arcs", + "recurrence" + ], + "bullets": [ + "Each square's side is the sum of the two previous sides: the Fibonacci recurrence F(n)=F(n-1)+F(n-2).", + "Connecting opposite corners of the squares with quarter-circle arcs traces the golden spiral.", + "The ratio of consecutive Fibonacci numbers F(n+1)/F(n) converges to the golden ratio phi ~= 1.61803." + ], + "student_prompts": [ + "Why does the ratio of consecutive Fibonacci numbers approach the golden ratio?", + "Is the Fibonacci spiral exactly a logarithmic (golden) spiral, or just an approximation?", + "Where does the golden ratio appear in nature and art?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n// Fibonacci sequence\nconst fib = [1, 1];\nfor (let i = 2; i < 11; i++) fib.push(fib[i - 1] + fib[i - 2]);\nconst PHI = (1 + Math.sqrt(5)) / 2;\n\n// How many squares are \"grown in\" - sweeps up then resets, looping.\nconst maxSq = 9;\nconst period = maxSq + 2.0;\nconst tt = (t / 1.1) % period;\nconst grown = Math.min(maxSq, Math.floor(tt)); // fully drawn squares\nconst partial = Math.min(1, tt - grown); // growth of the next square\n\n// Build square placements by spiraling out: each new square sits on a side\n// of the running bounding box. Directions cycle: right, up, left, down.\nconst squares = [];\n// Canonical Fibonacci tiling: seed a 1x1 square at the origin, then attach\n// each successive square to a side of the running bounding box.\nconst s0 = fib[0];\nsquares.push({ x: 0, y: 0, s: s0, dir: 0, idx: 0 });\nlet minx = 0, miny = 0, maxx = s0, maxy = s0;\nfor (let i = 1; i < fib.length; i++){\n const s = fib[i];\n let nx, ny;\n // attach on a side of current bounding box, going counter-clockwise\n const d = i % 4;\n if (d === 1){ nx = minx - s; ny = miny; } // left\n else if (d === 2){ nx = minx; ny = miny - s; } // bottom\n else if (d === 3){ nx = maxx; ny = miny; } // right (aligned bottom)\n else { nx = minx; ny = maxy; } // top\n squares.push({ x: nx, y: ny, s, dir: d, idx: i });\n minx = Math.min(minx, nx); miny = Math.min(miny, ny);\n maxx = Math.max(maxx, nx + s); maxy = Math.max(maxy, ny + s);\n}\n\n// Fit the bounding box of grown squares into a centered viewport.\nlet bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;\nfor (let i = 0; i <= grown && i < squares.length; i++){\n const q = squares[i];\n bx0 = Math.min(bx0, q.x); by0 = Math.min(by0, q.y);\n bx1 = Math.max(bx1, q.x + q.s); by1 = Math.max(by1, q.y + q.s);\n}\nif (!Number.isFinite(bx0)){ bx0 = 0; by0 = 0; bx1 = 1; by1 = 1; }\nconst bw = Math.max(1e-6, bx1 - bx0), bh = Math.max(1e-6, by1 - by0);\nconst vpw = w * 0.56, vph = h * 0.72;\nconst vx = w * 0.05, vy = h * 0.16;\nconst sc = Math.min(vpw / bw, vph / bh);\n// center inside viewport\nconst offx = vx + (vpw - bw * sc) / 2;\nconst offy = vy + (vph - bh * sc) / 2;\n// world (fib units, y up) -> screen (y down)\nconst SX = (wx) => offx + (wx - bx0) * sc;\nconst SY = (wy) => offy + (by1 - wy) * sc;\n\n// draw each square + its quarter-circle arc\nfor (let i = 0; i <= grown && i < squares.length; i++){\n const q = squares[i];\n const grow = (i === grown) ? partial : 1;\n const col = H.color(i % H.palette.length);\n // square (optionally growing from a corner)\n const sx = SX(q.x), sy = SY(q.y + q.s);\n const pw = q.s * sc, ph = q.s * sc;\n H.rect(sx, sy, pw * grow, ph * grow, {\n stroke: col, width: 2, fill: H.hsl(200 + i * 14, 45, 22, 0.35), radius: 0,\n });\n H.text(\"F=\" + q.s, sx + 6, sy + 16, { color: col, size: Math.min(15, 6 + pw * 0.06) });\n\n // quarter-circle arc spiraling through the square (full square only)\n if (grow >= 0.999){\n const d = q.dir;\n let ccx, ccy, a0;\n // y is up in world; convert to screen for arc center\n if (d === 0){ ccx = q.x; ccy = q.y; a0 = 0; } // first / top\n else if (d === 1){ ccx = q.x + q.s; ccy = q.y; a0 = H.PI / 2; } // left\n else if (d === 2){ ccx = q.x + q.s; ccy = q.y + q.s; a0 = H.PI; } // bottom\n else { ccx = q.x; ccy = q.y + q.s; a0 = -H.PI / 2; } // right\n const pts = [];\n const seg = 22;\n for (let j = 0; j <= seg; j++){\n const a = a0 + (j / seg) * (H.PI / 2);\n const ax = ccx + Math.cos(a) * q.s;\n const ay = ccy + Math.sin(a) * q.s;\n pts.push([SX(ax), SY(ay)]);\n }\n H.path(pts, { color: H.colors.yellow, width: 3 });\n }\n}\n\n// moving dot tracing the very tip of the spiral\nif (grown >= 1){\n const q = squares[Math.min(grown, squares.length - 1)];\n const d = q.dir;\n let ccx, ccy, a0;\n if (d === 0){ ccx = q.x; ccy = q.y; a0 = 0; }\n else if (d === 1){ ccx = q.x + q.s; ccy = q.y; a0 = H.PI / 2; }\n else if (d === 2){ ccx = q.x + q.s; ccy = q.y + q.s; a0 = H.PI; }\n else { ccx = q.x; ccy = q.y + q.s; a0 = -H.PI / 2; }\n const a = a0 + H.ease(partial) * (H.PI / 2);\n const ax = ccx + Math.cos(a) * q.s, ay = ccy + Math.sin(a) * q.s;\n H.circle(SX(ax), SY(ay), 6, { fill: H.colors.good, stroke: H.colors.bg, width: 2 });\n}\n\n// --- titles + live readout ---\nH.text(\"Fibonacci Spiral & the Golden Ratio\", 24, 34,\n { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Quarter-circle arcs across squares of side 1,1,2,3,5,8,...\", 24, 56,\n { color: H.colors.sub, size: 13 });\n\n// ratio readout: F(n+1)/F(n) -> phi\nconst ni = Math.max(1, Math.min(grown, fib.length - 2));\nconst ratio = fib[ni + 1] / fib[ni];\nconst px0 = w * 0.66, py0 = h * 0.24;\nH.text(\"squares drawn: \" + (grown + (grown < maxSq ? 1 : 0)), px0, py0,\n { color: H.colors.ink, size: 15, weight: 600 });\nH.text(\"F(\" + (ni + 2) + \")/F(\" + (ni + 1) + \") = \" + fib[ni + 1] + \"/\" + fib[ni],\n px0, py0 + 28, { color: H.colors.accent, size: 15 });\nH.text(\"= \" + ratio.toFixed(5), px0, py0 + 52, { color: H.colors.accent2, size: 20, weight: 700 });\nH.text(\"phi = \" + PHI.toFixed(5), px0, py0 + 80, { color: H.colors.yellow, size: 16, weight: 700 });\nH.text(\"error = \" + Math.abs(ratio - PHI).toFixed(5), px0, py0 + 104,\n { color: H.colors.good, size: 13 });\n\nH.legend([\n { label: \"Fibonacci squares\", color: H.colors.accent },\n { label: \"golden spiral arc\", color: H.colors.yellow },\n { label: \"spiral tip\", color: H.colors.good },\n], px0, py0 + 140);" + }, + { + "id": "central-limit-theorem-means-converge", + "title": "Central Limit Theorem: means converge", + "tag": "Probability & Statistics", + "dimension": "3D", + "equation": "SE = sigma / sqrt(n)", + "summary": "A 3D ridge of normal curves for the sampling distribution of the sample mean. As the sample size n grows along the depth axis, each bell becomes taller and narrower, visualizing why the standard error shrinks like sigma/sqrt(n) and sample means converge on the true mean.", + "keywords": [ + "central limit theorem", + "clt", + "sampling distribution", + "sample mean", + "standard error", + "law of large numbers", + "convergence", + "normal distribution", + "gaussian", + "bell curve", + "variance shrinks", + "sqrt n", + "mean of means", + "sampling" + ], + "bullets": [ + "The peak narrows and rises as n increases because the standard error SE = sigma/sqrt(n) shrinks.", + "Every bell stays centered on the same true mean mu = 0 — only the spread changes.", + "The yellow marker rides the curve for the current n, where the live readout shows n and its SE." + ], + "student_prompts": [ + "Why does the standard error fall off like 1/sqrt(n) instead of 1/n?", + "If the population is NOT normal, does the sampling distribution of the mean still become bell-shaped?", + "How large does n need to be before the normal approximation is good enough?" + ], + "code": "H.background();\nconst cam = H.cam3d({ scale: 30, dist: 17, pitch: -0.5, cy: H.H * 0.56 });\ncam.yaw = 0.3 * t;\ncam.grid(6, 1);\n\n// Sample size n sweeps up and down over time so the sampling distribution\n// of the sample mean visibly narrows (CLT: SE = sigma / sqrt(n)).\nconst sigma = 1.0; // population standard deviation\nconst mu = 0; // true mean\nconst nMin = 1, nMax = 40;\nconst phase = (Math.sin(t * 0.5) + 1) / 2; // 0..1, smooth\nconst nF = H.lerp(nMin, nMax, phase);\nconst n = Math.max(1, Math.round(nF));\nconst se = sigma / Math.sqrt(n); // standard error\nconst seSafe = Math.max(se, 1e-3);\n\n// A ridge of normal curves, one per \"step\" of n along the z axis, each\n// scaled to the standard error at that n. Draw as a height surface:\n// height(x, z) = peak of a gaussian whose width depends on n(z).\nconst xMin = -4, xMax = 4;\nconst zMin = 0, zMax = 6;\nH.surface3d(cam, (x, z) => {\n // map z -> an n value so the back of the ridge is large-n (narrow/tall)\n const frac = (z - zMin) / (zMax - zMin);\n const nz = H.lerp(nMin, nMax, frac);\n const sez = Math.max(sigma / Math.sqrt(nz), 1e-3);\n const g = Math.exp(-0.5 * ((x - mu) / sez) * ((x - mu) / sez));\n return (g / sez) * 0.55; // taller + narrower as n grows\n}, { xMin: xMin, xMax: xMax, yMin: zMin, yMax: zMax, nx: 40, ny: 40,\n hueMin: 210, hueMax: 30, alpha: 0.95 });\n\ncam.axes(6);\n\n// A travelling marker on the \"current n\" curve to keep a live focal point.\nconst frac = H.clamp((nF - nMin) / (nMax - nMin), 0, 1);\nconst zc = H.lerp(zMin, zMax, frac);\nconst peak = (1 / seSafe) * 0.55;\nconst m = cam.sphere([mu, peak, zc], 0.22, { color: H.colors.yellow });\n\nH.text(\"Central Limit Theorem: means converge\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Sampling distribution of the mean narrows as n grows.\", 24, 52,\n { color: H.colors.sub, size: 13 });\nH.text(\"n = \" + n + \" SE = sigma/sqrt(n) = \" + seSafe.toFixed(3), 24, 76,\n { color: H.colors.sub, size: 13 });\nH.text(\"mu = \" + mu.toFixed(1) + \" sigma = \" + sigma.toFixed(1), 24, 96,\n { color: H.colors.sub, size: 13 });\nH.legend([\n { label: \"small n (wide, flat)\", color: H.hsl(210, 72, 55) },\n { label: \"large n (tall, narrow)\", color: H.hsl(30, 72, 55) },\n { label: \"current n\", color: H.colors.yellow },\n], 24, H.H - 70);" + }, + { + "id": "linear-regression-line-of-best-fit", + "title": "Linear regression: line of best fit", + "tag": "Probability & Statistics", + "dimension": "2D", + "equation": "y = m x + b (minimize SSE = sum (y - y_hat)^2)", + "summary": "A fixed scatter of points with a candidate line that rotates and slides toward the ordinary-least-squares solution. Dashed residual segments and a live sum-of-squared-errors readout show why the best-fit line is the one that minimizes total squared vertical distance.", + "keywords": [ + "linear regression", + "line of best fit", + "least squares", + "ols", + "residuals", + "sum of squared errors", + "sse", + "slope", + "intercept", + "scatter plot", + "trend line", + "correlation", + "fitting", + "regression line" + ], + "bullets": [ + "Each dashed red segment is a residual: the vertical gap between a point and the line's prediction.", + "The line settles at the slope and intercept that minimize SSE = sum of squared residuals.", + "Watch the SSE readout fall toward its minimum as the animated line locks onto the OLS fit." + ], + "student_prompts": [ + "Why squared residuals instead of absolute residuals for the line of best fit?", + "How do the closed-form OLS formulas for slope and intercept come from calculus?", + "What does R-squared tell us that SSE alone does not?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -1, yMax: 11, pad: 50 });\nv.grid(); v.axes();\n\n// Fixed scatter (deterministic pseudo-random so the cloud is stable).\nconst N = 14;\nconst xs = [], ys = [];\nconst trueM = 0.8, trueB = 1.4;\nfor (let i = 0; i < N; i++) {\n const x = 0.6 + i * (9.0 / (N - 1));\n // deterministic \"noise\" from trig hashing of i\n const noise = 1.6 * Math.sin(i * 12.9898) + 1.1 * Math.cos(i * 4.231);\n const y = trueM * x + trueB + noise;\n xs.push(x); ys.push(y);\n}\n\n// Closed-form ordinary-least-squares fit of the fixed cloud.\nlet sx = 0, sy = 0, sxx = 0, sxy = 0;\nfor (let i = 0; i < N; i++) {\n sx += xs[i]; sy += ys[i]; sxx += xs[i] * xs[i]; sxy += xs[i] * ys[i];\n}\nconst denom = N * sxx - sx * sx;\nconst mBest = denom !== 0 ? (N * sxy - sx * sy) / denom : 0;\nconst bBest = (sy - mBest * sx) / N;\n\n// Animate a candidate line rotating toward the OLS best fit, then settling.\nconst settle = H.ease(H.clamp((Math.sin(t * 0.6) + 1) / 2, 0, 1));\nconst mStart = 0.1, bStart = 6.5;\nconst mNow = H.lerp(mStart, mBest, settle);\nconst bNow = H.lerp(bStart, bBest, settle);\nconst line = (x) => mNow * x + bNow;\n\n// Residual segments + running SSE for the current candidate line.\nlet sse = 0;\nfor (let i = 0; i < N; i++) {\n const yhat = line(xs[i]);\n const r = ys[i] - yhat;\n sse += r * r;\n v.line(xs[i], ys[i], xs[i], yhat,\n { color: H.colors.warn, width: 1.3, dash: [4, 4] });\n}\n\n// The candidate line of best fit.\nv.line(-1, line(-1), 11, line(11), { color: H.colors.accent2, width: 3 });\n\n// Scatter points on top.\nfor (let i = 0; i < N; i++) {\n v.dot(xs[i], ys[i], { r: 5, fill: H.colors.accent });\n}\n\nH.text(\"Linear regression: line of best fit\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Slope/intercept slide toward the least-squares minimum.\", 24, 52,\n { color: H.colors.sub, size: 13 });\nH.text(\"y = \" + mNow.toFixed(2) + \" x + \" + bNow.toFixed(2), 24, 76,\n { color: H.colors.accent2, size: 14, weight: 700 });\nH.text(\"SSE = \" + sse.toFixed(1) + \" (best = \"\n + (function () {\n let s = 0;\n for (let i = 0; i < N; i++) { const r = ys[i] - (mBest * xs[i] + bBest); s += r * r; }\n return s.toFixed(1);\n })() + \")\", 24, 96, { color: H.colors.sub, size: 13 });\nH.legend([\n { label: \"data points\", color: H.colors.accent },\n { label: \"fit line\", color: H.colors.accent2 },\n { label: \"residuals\", color: H.colors.warn },\n], H.W - 180, 40);" + }, + { + "id": "bayesian-update-coin-bias-beta", + "title": "Bayesian update: learning a coin's bias", + "tag": "Probability & Statistics", + "dimension": "2D", + "equation": "posterior = Beta(a0 + heads, b0 + tails)", + "summary": "A Beta(a,b) belief distribution over a coin's hidden bias updates as flips stream in. The dim prior reshapes into a sharper posterior that concentrates around the true bias, with a live readout of the flip counts, posterior parameters, and posterior mean.", + "keywords": [ + "bayes", + "bayesian update", + "bayes theorem", + "posterior", + "prior", + "beta distribution", + "conjugate prior", + "coin bias", + "belief update", + "probability", + "inference", + "likelihood", + "credible interval", + "learning" + ], + "bullets": [ + "Beta is the conjugate prior for a coin: each flip just adds 1 to a (head) or b (tail).", + "As evidence accumulates the posterior sharpens and its peak slides toward the true bias.", + "The green dashed line is the hidden true p; the orange dot tracks the posterior mean a/(a+b)." + ], + "student_prompts": [ + "Why does a Beta prior stay Beta after a Bernoulli observation — what makes it conjugate?", + "How would a strong prior like Beta(20,20) change how fast the posterior moves?", + "What is the difference between the posterior mean and the maximum a posteriori (MAP) estimate here?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 1, yMin: 0, yMax: 6, pad: 50 });\nv.grid({ stepX: 0.2 }); v.axes({ stepX: 0.2 });\n\n// Beta(a,b) density on theta in [0,1] — conjugate prior for a coin's bias.\n// log-gamma via Lanczos for a normalized, accurate Beta pdf.\nfunction logGamma(z) {\n const g = 7;\n const c = [0.99999999999980993, 676.5203681218851, -1259.1392167224028,\n 771.32342877765313, -176.61502916214059, 12.507343278686905,\n -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7];\n if (z < 0.5) {\n return Math.log(Math.PI / Math.sin(Math.PI * z)) - logGamma(1 - z);\n }\n z -= 1;\n let x = c[0];\n for (let i = 1; i < g + 2; i++) x += c[i] / (z + i);\n const tt = z + g + 0.5;\n return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(tt) - tt + Math.log(x);\n}\nfunction betaPdf(x, a, b) {\n if (x <= 0 || x >= 1) return 0;\n const lb = logGamma(a + b) - logGamma(a) - logGamma(b)\n + (a - 1) * Math.log(x) + (b - 1) * Math.log(1 - x);\n const y = Math.exp(lb);\n return Number.isFinite(y) ? y : 0;\n}\n\n// Prior Beta(2,2). Stream in coin flips over time; each flip updates a,b.\nconst trueP = 0.7; // hidden bias we are learning\nconst a0 = 2, b0 = 2;\nconst maxFlips = 60;\nconst flips = Math.min(maxFlips, Math.floor((t % 14) / 14 * (maxFlips + 1)));\nlet heads = 0;\nfor (let i = 0; i < flips; i++) {\n // deterministic stream: ~trueP fraction are heads\n const u = (Math.sin(i * 78.233) * 43758.5453);\n const frac = u - Math.floor(u); // 0..1 pseudo-random\n if (frac < trueP) heads++;\n}\nconst tails = flips - heads;\nconst a = a0 + heads;\nconst b = b0 + tails;\nconst postMean = a / (a + b);\n\n// Draw prior (dim) and posterior (bright).\nv.fn((x) => betaPdf(x, a0, b0), { color: H.colors.sub, width: 2 });\nv.fn((x) => betaPdf(x, a, b), { color: H.colors.violet, width: 3.4 });\n\n// Mark the true bias and the posterior mean.\nv.line(trueP, 0, trueP, 6, { color: H.colors.good, width: 2, dash: [6, 5] });\nconst peakY = betaPdf(postMean, a, b);\nv.dot(postMean, Math.min(peakY, 6), { r: 6, fill: H.colors.accent2 });\n\nH.text(\"Bayesian update: learning a coin's bias\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Each flip reshapes Beta(a,b) toward the true value.\", 24, 52,\n { color: H.colors.sub, size: 13 });\nH.text(\"flips = \" + flips + \" (H \" + heads + \" / T \" + tails + \")\", 24, 76,\n { color: H.colors.sub, size: 13 });\nH.text(\"posterior Beta(\" + a + \", \" + b + \") mean = \" + postMean.toFixed(3),\n 24, 96, { color: H.colors.violet, size: 13 });\nH.text(\"true p = \" + trueP.toFixed(2), v.X(trueP) + 6, v.Y(5.4),\n { color: H.colors.good, size: 12 });\nH.legend([\n { label: \"prior Beta(2,2)\", color: H.colors.sub },\n { label: \"posterior\", color: H.colors.violet },\n { label: \"true bias\", color: H.colors.good },\n { label: \"posterior mean\", color: H.colors.accent2 },\n], H.W - 190, 40);" + }, + { + "id": "gradient-of-surface-z-fxy-3d", + "title": "Surface z = f(x,y) and Its Gradient", + "tag": "Multivariable Calculus", + "dimension": "3D", + "equation": "grad f = (df/dx, df/dy)", + "summary": "A solid, lit height surface z = f(x,y) (a two-bump landscape) rotates while a point orbits over the x-y plane. At that point the gradient vector is drawn on the ground plane, always pointing uphill, with its components and magnitude shown live.", + "keywords": [ + "gradient", + "grad f", + "del f", + "nabla", + "surface", + "z=f(x,y)", + "partial derivatives", + "steepest ascent", + "slope", + "multivariable", + "scalar field", + "3d surface", + "height map", + "vector field of gradient" + ], + "bullets": [ + "The gradient grad f = (df/dx, df/dy) lives in the x-y plane and points in the direction of steepest increase of f.", + "The length |grad f| equals the steepest slope of the surface at that point — it shrinks near flat tops and grows on steep flanks.", + "Partial derivatives here are estimated with central differences, the same idea as the analytic df/dx and df/dy." + ], + "student_prompts": [ + "Why does the gradient lie flat in the x-y plane instead of along the surface?", + "How is the gradient related to the contour (level) curves of f?", + "What happens to grad f exactly at a peak or a saddle point?" + ], + "code": "H.background();\nconst cam = H.cam3d({ scale: 40, dist: 15, pitch: -0.5, cy: H.H * 0.56 });\ncam.yaw = 0.3 * t;\nconst f = (x, y) =>\n 1.6 * Math.exp(-((x - 1) * (x - 1) + (y - 1) * (y - 1)) / 2.2) +\n 1.1 * Math.exp(-((x + 1.4) * (x + 1.4) + (y + 1.2) * (y + 1.2)) / 1.8);\ncam.grid(3.2, 0.8);\nH.surface3d(cam, (x, y) => f(x, y) * 1.4, {\n xMin: -3, xMax: 3, yMin: -3, yMax: 3, nx: 40, ny: 40, hueMin: 205, hueMax: 35,\n});\nconst px = 1.8 * Math.cos(t * 0.7);\nconst py = 1.8 * Math.sin(t * 0.7);\nconst h = 1e-3;\nconst fx = (f(px + h, py) - f(px - h, py)) / (2 * h);\nconst fy = (f(px, py + h) - f(px, py - h)) / (2 * h);\nconst gmag = Math.sqrt(fx * fx + fy * fy);\nconst zp = f(px, py) * 1.4;\ncam.line([px, 0, py], [px, zp, py], { color: H.colors.sub, width: 1.4 });\ncam.sphere([px, zp, py], 0.16, { color: H.colors.warn });\nconst s = 0.7;\nconst tip = [px + fx * s, 0.04, py + fy * s];\nconst a0 = cam.project([px, 0.04, py]);\nconst a1 = cam.project(tip);\nH.arrow(a0.x, a0.y, a1.x, a1.y, { color: H.colors.yellow, width: 3, head: 11 });\ncam.line([px, 0.04, py], [px, zp, py], { color: H.colors.warn, width: 1, dash: [4, 4] });\ncam.axes(3.4);\nH.text(\"Surface z = f(x, y) and its gradient\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"grad f points uphill in the x-y plane; its length is the steepest slope.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"point (x, y) = (\" + px.toFixed(2) + \", \" + py.toFixed(2) + \")\", 24, 84, { color: H.colors.ink, size: 13 });\nH.text(\"grad f = (\" + fx.toFixed(2) + \", \" + fy.toFixed(2) + \") |grad f| = \" + gmag.toFixed(2), 24, 104, { color: H.colors.yellow, size: 13 });\nH.legend([\n { label: \"surface z = f(x,y)\", color: H.colors.accent },\n { label: \"point on surface\", color: H.colors.warn },\n { label: \"grad f (gradient on x-y plane)\", color: H.colors.yellow },\n], 24, H.H - 64);" + }, + { + "id": "gradient-descent-loss-surface-3d", + "title": "Gradient Descent on a Loss Surface", + "tag": "Optimization / Machine Learning", + "dimension": "3D", + "equation": "x_{k+1} = x_k - alpha * grad L(x_k)", + "summary": "A ball runs gradient descent down a rippled bowl-shaped loss surface L(x,y). The descent is recomputed deterministically each frame, tracing a path that slides downhill into a minimum, with step count, learning rate, current loss, and gradient magnitude shown live.", + "keywords": [ + "gradient descent", + "optimization", + "loss surface", + "cost function", + "learning rate", + "minimum", + "convergence", + "steepest descent", + "backpropagation", + "training", + "machine learning", + "descent path", + "local minimum", + "step size" + ], + "bullets": [ + "Each iteration updates the position by x <- x - alpha*grad L, stepping opposite the gradient so the loss decreases.", + "The learning rate alpha sets the step length; the yellow trail shows the trajectory bending toward the basin's minimum.", + "|grad L| shrinks toward zero as the ball nears the minimum — that flattening is the signal that descent is converging." + ], + "student_prompts": [ + "What happens to the path if the learning rate is too large or too small?", + "How would momentum change the shape of this descent trajectory?", + "Why can gradient descent get stuck in a local minimum instead of the global one?" + ], + "code": "H.background();\nconst cam = H.cam3d({ scale: 42, dist: 15, pitch: -0.5, cy: H.H * 0.58 });\ncam.yaw = 0.25 * t;\nconst L = (x, y) =>\n 0.35 * (x * x + y * y) + 0.5 * Math.sin(1.3 * x) * Math.cos(1.3 * y) + 0.5;\ncam.grid(3, 1);\nH.surface3d(cam, (x, y) => L(x, y), {\n xMin: -3, xMax: 3, yMin: -3, yMax: 3, nx: 40, ny: 40, hueMin: 265, hueMax: 150, alpha: 0.9,\n});\nconst lr = 0.18;\nconst eps = 1e-3;\nconst grad = (x, y) => [\n (L(x + eps, y) - L(x - eps, y)) / (2 * eps),\n (L(x, y + eps) - L(x, y - eps)) / (2 * eps),\n];\nconst totalSteps = 60;\nconst k = Math.min(totalSteps, Math.floor((t % 9) / 9 * totalSteps));\nlet x = 2.5, y = -2.3;\nconst trail = [[x, L(x, y), y]];\nfor (let i = 0; i < k; i++) {\n const g0 = grad(x, y);\n x -= lr * g0[0];\n y -= lr * g0[1];\n if (!Number.isFinite(x) || !Number.isFinite(y)) break;\n x = H.clamp(x, -3, 3); y = H.clamp(y, -3, 3);\n trail.push([x, L(x, y), y]);\n}\nconst loss = L(x, y);\nconst g = grad(x, y);\nconst gmag = Math.sqrt(g[0] * g[0] + g[1] * g[1]);\ncam.path(trail, { color: H.colors.yellow, width: 3 });\ncam.path(trail.map((p) => [p[0], 0.02, p[2]]), { color: H.colors.grid, width: 1.5 });\ncam.sphere([x, loss, y], 0.18, { color: H.colors.warn });\ncam.line([x, 0, y], [x, loss, y], { color: H.colors.sub, width: 1, dash: [4, 4] });\ncam.axes(3.4);\nH.text(\"Gradient descent on a loss surface\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Each step moves against grad L (downhill). The ball settles in a minimum.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"step \" + k + \" / \" + totalSteps + \" learning rate = \" + lr.toFixed(2), 24, 84, { color: H.colors.ink, size: 13 });\nH.text(\"L(x, y) = \" + loss.toFixed(3) + \" |grad L| = \" + gmag.toFixed(3), 24, 104, { color: H.colors.yellow, size: 13 });\nH.legend([\n { label: \"loss surface L(x,y)\", color: H.colors.violet },\n { label: \"descent path\", color: H.colors.yellow },\n { label: \"current iterate\", color: H.colors.warn },\n], 24, H.H - 64);" + }, + { + "id": "divergence-curl-2d-vector-field", + "title": "Divergence & Curl of a 2D Vector Field", + "tag": "Vector Calculus", + "dimension": "2D", + "equation": "div F = dFx/dx + dFy/dy, curl F = dFy/dx - dFx/dy", + "summary": "A 2D vector field F(x,y) is drawn as speed-colored arrows with particles advected along its streamlines. A sweeping sample point shows the live divergence (net outflow) and scalar curl (rotation), plus a rotating unit vector u marking the direction for a directional derivative.", + "keywords": [ + "divergence", + "curl", + "vector field", + "flux", + "circulation", + "del dot f", + "del cross f", + "flow", + "streamlines", + "directional derivative", + "gradient", + "rotation", + "sources and sinks", + "2d field" + ], + "bullets": [ + "Divergence div F = dFx/dx + dFy/dy measures net outflow at a point — positive for a source, negative for a sink.", + "Scalar curl dFy/dx - dFx/dy measures local rotation; the ring turns blue for counterclockwise spin and pink for clockwise.", + "The rotating unit vector u sets the direction along which a directional derivative would be measured at the sample point." + ], + "student_prompts": [ + "How do divergence and curl relate to the flux and circulation forms of Green's theorem?", + "What does it mean physically when divergence is zero everywhere (an incompressible flow)?", + "How would I compute the directional derivative of a scalar field along the vector u?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -3.2, yMax: 3.2, pad: 50 });\nv.grid();\nv.axes();\nconst Fx = (x, y) => 0.6 * x - 0.9 * y + 0.4 * Math.sin(0.8 * y);\nconst Fy = (x, y) => 0.9 * x + 0.6 * y;\nconst step = 1.0;\nfor (let gx = -4.5; gx <= 4.5 + 1e-9; gx += step) {\n for (let gy = -3; gy <= 3 + 1e-9; gy += step) {\n const fx = Fx(gx, gy), fy = Fy(gx, gy);\n const mag = Math.sqrt(fx * fx + fy * fy) + 1e-9;\n const sc = 0.42 / Math.max(1, mag * 0.5);\n const hue = H.clamp(210 - mag * 14, 20, 210);\n v.arrow(gx, gy, gx + fx * sc, gy + fy * sc, { color: H.hsl(hue, 80, 62), width: 1.6, head: 6 });\n }\n}\nconst NP = 26;\nfor (let i = 0; i < NP; i++) {\n const ang = (i / NP) * H.TAU;\n let x = 3.4 * Math.cos(ang), y = 2.0 * Math.sin(ang);\n const dt = 0.05;\n const phase = (t * 0.9 + i * 0.13) % 1;\n const adv = Math.floor(phase * 10) + 3;\n for (let sN = 0; sN < adv && sN < 30; sN++) {\n const fx = Fx(x, y), fy = Fy(x, y);\n x += fx * dt; y += fy * dt;\n if (!Number.isFinite(x) || !Number.isFinite(y)) break;\n }\n if (Math.abs(x) < 5 && Math.abs(y) < 3.2)\n v.dot(x, y, { r: 3, fill: H.colors.good, stroke: H.colors.bg });\n}\nconst sx = 2.6 * Math.cos(t * 0.6);\nconst sy = 1.7 * Math.sin(t * 0.6);\nconst e = 1e-3;\nconst dFxdx = (Fx(sx + e, sy) - Fx(sx - e, sy)) / (2 * e);\nconst dFydy = (Fy(sx, sy + e) - Fy(sx, sy - e)) / (2 * e);\nconst dFydx = (Fy(sx + e, sy) - Fy(sx - e, sy)) / (2 * e);\nconst dFxdy = (Fx(sx, sy + e) - Fx(sx, sy - e)) / (2 * e);\nconst div = dFxdx + dFydy;\nconst curl = dFydx - dFxdy;\nconst ua = t * 0.8;\nconst ux = Math.cos(ua), uy = Math.sin(ua);\nconst dirArrowLen = 1.0;\nv.arrow(sx, sy, sx + ux * dirArrowLen, sy + uy * dirArrowLen, { color: H.colors.yellow, width: 2.6, head: 9 });\nconst ringR = 26 + 6 * Math.sin(t * 2);\nH.circle(v.X(sx), v.Y(sy), ringR, { stroke: curl >= 0 ? H.colors.accent : H.colors.warn, width: 2 });\nv.dot(sx, sy, { r: 6, fill: H.colors.violet });\nv.text(\"u\", sx + ux * dirArrowLen + 0.1, sy + uy * dirArrowLen + 0.1, { color: H.colors.yellow, size: 13 });\nH.text(\"Divergence & curl of a 2D vector field\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"F(x,y) = (0.6x - 0.9y + .4sin.8y, 0.9x + 0.6y). div = outflow, curl = spin.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"sample (x,y) = (\" + sx.toFixed(2) + \", \" + sy.toFixed(2) + \")\", 24, 84, { color: H.colors.violet, size: 13 });\nH.text(\"div F = \" + div.toFixed(2) + \" curl F = \" + curl.toFixed(2), 24, 104, { color: H.colors.ink, size: 13 });\nH.text(\"u = (\" + ux.toFixed(2) + \", \" + uy.toFixed(2) + \") -> direction of derivative\", 24, 124, { color: H.colors.yellow, size: 13 });\nH.legend([\n { label: \"field F (arrows)\", color: H.colors.accent },\n { label: \"advected particles\", color: H.colors.good },\n { label: \"sample point\", color: H.colors.violet },\n { label: \"direction u\", color: H.colors.yellow },\n], 24, H.H - 84);" + }, + { + "id": "dna-double-helix-3d", + "title": "DNA Double Helix (B-form)", + "tag": "Molecular Biology", + "dimension": "3D", + "equation": "strand: (r·cos θ, y, r·sin θ), θ = 2π·turns·s", + "summary": "A right-handed B-DNA double helix rotates in 3D: two antiparallel sugar–phosphate backbones (shaded spheres) wound 180° apart and joined by color-coded base-pair rungs, with a reading highlight sweeping up the molecule.", + "keywords": [ + "dna", + "double helix", + "b-dna", + "nucleotide", + "base pair", + "adenine thymine guanine cytosine", + "genetics", + "molecular biology", + "backbone", + "antiparallel strands", + "chromosome", + "genome", + "biochemistry", + "3d molecule" + ], + "bullets": [ + "Two antiparallel strands (5'→3' blue, 3'→5' orange) coil into one right-handed helix.", + "Rungs are complementary base pairs (A-T / G-C) holding the strands together via hydrogen bonds.", + "B-DNA rises ~0.34 nm per base pair, so the displayed helix length tracks the base-pair count." + ], + "student_prompts": [ + "Why are the two strands antiparallel and what does 5'→3' mean?", + "How do A-T and G-C base pairing rules keep the helix width constant?", + "What is the difference between B-DNA, A-DNA, and Z-DNA conformations?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst cam = H.cam3d({ scale: 30, dist: 20, pitch: -0.18, cy: h * 0.52 });\ncam.yaw = 0.45 * t;\n\n// One full turn of the helix unwinds/rewinds slightly so it breathes.\nconst N = 40; // base-pair rungs\nconst turns = 2.2; // helical turns across the strand\nconst radius = 2.2;\nconst ylo = -5.2, yhi = 5.2;\nconst twist = 0.22 * Math.sin(t * 0.6); // gentle live winding\n\n// Base-pair color coding (A-T vs G-C) cycles deterministically.\nconst pairColors = [H.colors.good, H.colors.warn, H.colors.violet, H.colors.yellow];\n\n// Collect every drawable (backbone balls + rung balls) for depth sorting.\nconst balls = [];\nconst rungs = [];\n\nfor (let i = 0; i < N; i++) {\n const s = i / (N - 1);\n const ang = s * H.TAU * turns + twist * i;\n const ypos = H.lerp(ylo, yhi, s);\n // Two antiparallel sugar-phosphate backbones, 180 deg apart.\n const a = [radius * Math.cos(ang), ypos, radius * Math.sin(ang)];\n const b = [radius * Math.cos(ang + H.PI), ypos, radius * Math.sin(ang + H.PI)];\n balls.push({ p: a, color: H.colors.accent, r: 0.34 });\n balls.push({ p: b, color: H.colors.accent2, r: 0.34 });\n\n // A travelling \"replication / read\" highlight sweeps up the molecule.\n const head = (t * 0.32) % 1;\n const lit = Math.abs(s - head) < 0.06;\n const c = pairColors[i % pairColors.length];\n rungs.push({ a: a, b: b, color: c, lit: lit });\n}\n\n// Draw rungs (base pairs) first as thin bonds behind the spheres.\nfor (let i = 0; i < rungs.length; i++) {\n const r = rungs[i];\n cam.line(r.a, r.b, {\n color: r.lit ? H.colors.ink : r.color,\n width: r.lit ? 3.2 : 1.5,\n });\n}\n\n// Depth-sort spheres far-to-near and shade them.\nballs\n .map((o) => ({ p: o.p, color: o.color, r: o.r, depth: cam.project(o.p).depth }))\n .sort((m, n) => n.depth - m.depth)\n .forEach((o) => cam.sphere(o.p, o.r, { color: o.color }));\n\ncam.axes(6);\n\n// Labels + live readout.\nconst rise = 0.34; // nm per base pair (B-DNA)\nconst helixLen = (N * rise);\nH.text(\"DNA double helix (B-form)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Two antiparallel strands wound into a right-handed helix.\", 24, 52,\n { color: H.colors.sub, size: 13 });\nH.text(\"read position = \" + ((t * 0.32) % 1 * 100).toFixed(0) + \"% length ≈ \"\n + helixLen.toFixed(1) + \" nm\", 24, 76, { color: H.colors.sub, size: 13 });\nH.legend([\n { label: \"strand 5'→3'\", color: H.colors.accent },\n { label: \"strand 3'→5'\", color: H.colors.accent2 },\n { label: \"base pair (A-T/G-C)\", color: H.colors.good },\n], 24, h - 70);" + }, + { + "id": "lotka-volterra-predator-prey", + "title": "Lotka–Volterra Predator–Prey Cycles", + "tag": "Population Ecology", + "dimension": "2D", + "equation": "dx/dt = a·x − b·x·y , dy/dt = d·x·y − c·y", + "summary": "Side-by-side time series and phase portrait of the Lotka–Volterra model: prey and predator populations are integrated forward in real time, tracing oscillating waves on the left and a closed orbit around the equilibrium on the right.", + "keywords": [ + "lotka volterra", + "predator prey", + "population dynamics", + "ecology", + "oscillation", + "rabbits foxes", + "phase portrait", + "limit cycle", + "differential equations", + "equilibrium", + "prey predator cycle", + "mathematical biology", + "food web", + "carrying capacity" + ], + "bullets": [ + "Prey grows exponentially but is eaten; predators die off but reproduce by eating prey.", + "The two populations oscillate out of phase — prey peaks lead predator peaks.", + "On the phase plane the trajectory is a closed loop circling the fixed point (c/d, a/b)." + ], + "student_prompts": [ + "Why do the predator and prey peaks happen at different times?", + "What happens to the cycles if I increase the predator death rate c?", + "How does adding a prey carrying capacity (logistic term) change the closed orbit?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n\n// Lotka-Volterra parameters: prey grows (a), eaten (b); predator dies (c), fed (d).\nconst a = 1.1, b = 0.4, d = 0.1, c = 0.4;\n// Integrate forward each frame from a fixed start so motion is a pure fn of t.\nconst dt = 0.02;\nconst steps = Math.min(900, Math.floor((t % 30) / dt) + 1);\nlet x = 10, y = 5; // prey, predator populations\nconst trail = [[x, y]];\nfor (let i = 0; i < steps; i++) {\n const dx = a * x - b * x * y;\n const dy = d * x * y - c * y;\n // midpoint to keep the closed orbit from spiralling out numerically\n const xm = x + 0.5 * dt * dx;\n const ym = y + 0.5 * dt * dy;\n const dxm = a * xm - b * xm * ym;\n const dym = d * xm * ym - c * ym;\n x = Math.max(0.01, x + dt * dxm);\n y = Math.max(0.01, y + dt * dym);\n if (i % 3 === 0) trail.push([x, y]);\n}\n\n// LEFT: time series of both populations.\nconst vt = H.plot2d({\n xMin: 0, xMax: 30, yMin: 0, yMax: 28,\n box: { x: 60, y: 70, w: w * 0.52 - 70, h: h - 150 },\n});\nvt.grid(); vt.axes();\n// Rebuild the two time series up to \"now\".\nconst preyPts = [], predPts = [];\nlet xx = 10, yy = 5;\nfor (let i = 0; i <= steps; i++) {\n const tm = i * dt;\n if (i % 2 === 0) { preyPts.push([tm, xx]); predPts.push([tm, yy]); }\n const dx = a * xx - b * xx * yy;\n const dy = d * xx * yy - c * yy;\n const xm = xx + 0.5 * dt * dx, ym = yy + 0.5 * dt * dy;\n const dxm = a * xm - b * xm * ym, dym = d * xm * ym - c * ym;\n xx = Math.max(0.01, xx + dt * dxm);\n yy = Math.max(0.01, yy + dt * dym);\n}\nvt.path(preyPts, { color: H.colors.good, width: 2.6 });\nvt.path(predPts, { color: H.colors.warn, width: 2.6 });\nvt.dot(Math.min(30, steps * dt), x, { r: 5, fill: H.colors.good });\nvt.dot(Math.min(30, steps * dt), y, { r: 5, fill: H.colors.warn });\nH.text(\"population\", 60, 60, { color: H.colors.sub, size: 12 });\nH.text(\"time\", vt.box.x + vt.box.w - 26, vt.box.y + vt.box.h + 30,\n { color: H.colors.sub, size: 12 });\n\n// RIGHT: phase portrait (predator vs prey) - the closed orbit.\nconst vp = H.plot2d({\n xMin: 0, xMax: 26, yMin: 0, yMax: 18,\n box: { x: w * 0.56 + 20, y: 70, w: w * 0.40 - 40, h: h - 150 },\n});\nvp.grid(); vp.axes();\nvp.path(trail, { color: H.colors.accent, width: 2.2 });\n// Equilibrium fixed point (c/d, a/b).\nvp.dot(c / d, a / b, { r: 4, fill: H.colors.yellow, stroke: H.colors.bg });\nvp.dot(x, y, { r: 6, fill: H.colors.accent2 });\nH.text(\"predator\", vp.box.x - 6, 60, { color: H.colors.sub, size: 12 });\nH.text(\"prey →\", vp.box.x + vp.box.w - 50, vp.box.y + vp.box.h + 30,\n { color: H.colors.sub, size: 12 });\n\n// Title, caption, live readout, legend.\nH.text(\"Lotka–Volterra predator–prey cycles\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Prey booms feed predators; predators crash, prey recover — a closed loop.\",\n 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"prey = \" + x.toFixed(1) + \" predators = \" + y.toFixed(1)\n + \" t = \" + (steps * dt).toFixed(1) + \"s\",\n w * 0.56 + 20, 52, { color: H.colors.sub, size: 13 });\nH.legend([\n { label: \"prey (rabbits)\", color: H.colors.good },\n { label: \"predators (foxes)\", color: H.colors.warn },\n { label: \"phase orbit\", color: H.colors.accent },\n], 60, h - 46);" + }, + { + "id": "neuron-action-potential", + "title": "Neuron Action Potential", + "tag": "Neuroscience", + "dimension": "2D", + "equation": "Vm(t): rest −70 mV → threshold −55 → peak +40 → AHP −80", + "summary": "A repeating action-potential trace plots membrane voltage versus time with threshold and resting reference lines, while a live phase label and an animated Na⁺/K⁺ channel diagram show which ion current is driving each stage of the spike.", + "keywords": [ + "action potential", + "neuron", + "membrane potential", + "depolarization", + "repolarization", + "hyperpolarization", + "sodium potassium", + "na+ k+ channel", + "neuroscience", + "spike", + "threshold", + "resting potential", + "refractory period", + "electrophysiology" + ], + "bullets": [ + "Slow depolarization to −55 mV threshold triggers an all-or-none spike to +40 mV.", + "Voltage-gated Na⁺ channels open first (rising phase); K⁺ channels open to repolarize.", + "K⁺ efflux overshoots to an after-hyperpolarization before the −70 mV resting state returns." + ], + "student_prompts": [ + "Why is the action potential described as 'all-or-none'?", + "What causes the refractory period and why can't a second spike fire immediately?", + "How do voltage-gated Na⁺ and K⁺ channels differ in their opening and closing timing?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n\n// Action-potential waveform as a function of phase within a repeating spike.\n// Resting -70 mV, threshold -55, peak +40, after-hyperpolarization -80.\nconst period = 4.0; // seconds between spikes (slowed for teaching)\nconst phase = (t % period);\nfunction Vm(ph) {\n const rest = -70, thr = -55, peak = 40, ahp = -80;\n if (ph < 1.2) { // resting / slow depolarization to threshold\n return H.lerp(rest, thr, H.ease(ph / 1.2));\n } else if (ph < 1.55) { // fast Na+ influx: depolarize to peak\n return H.lerp(thr, peak, H.ease((ph - 1.2) / 0.35));\n } else if (ph < 2.1) { // K+ efflux: repolarize\n return H.lerp(peak, ahp, H.ease((ph - 1.55) / 0.55));\n } else if (ph < 2.9) { // after-hyperpolarization recovers to rest\n return H.lerp(ahp, rest, H.ease((ph - 2.1) / 0.8));\n }\n return rest;\n}\nconst v = Vm(phase);\n\n// Plot the membrane potential trace over one cycle.\nconst view = H.plot2d({\n xMin: 0, xMax: period, yMin: -90, yMax: 50,\n box: { x: 64, y: 78, w: w - 230, h: h - 160 },\n});\nview.grid(); view.axes();\n\n// Threshold and resting reference lines.\nview.line(0, -55, period, -55, { color: H.colors.violet, width: 1.4, dash: [6, 5] });\nview.text(\"threshold –55 mV\", period * 0.62, -49,\n { color: H.colors.violet, size: 11 });\nview.line(0, -70, period, -70, { color: H.colors.axis, width: 1.2, dash: [3, 5] });\nview.text(\"rest –70 mV\", period * 0.02, -66, { color: H.colors.sub, size: 11 });\n\n// Full waveform (faint) + the swept-in portion (bright).\nconst full = [], swept = [];\nconst M = 160;\nfor (let i = 0; i <= M; i++) {\n const ph = (i / M) * period;\n const pt = [ph, Vm(ph)];\n full.push(pt);\n if (ph <= phase) swept.push(pt);\n}\nview.path(full, { color: H.hsl(205, 40, 45, 0.35), width: 2 });\nif (swept.length > 1) view.path(swept, { color: H.colors.accent, width: 3 });\nview.dot(phase, v, { r: 6, fill: H.colors.accent2 });\n\n// Phase label that names the current ion event.\nlet stage = \"resting\", sc = H.colors.sub;\nif (phase >= 1.2 && phase < 1.55) { stage = \"depolarization (Na+ in)\"; sc = H.colors.warn; }\nelse if (phase >= 1.55 && phase < 2.1) { stage = \"repolarization (K+ out)\"; sc = H.colors.accent; }\nelse if (phase >= 2.1 && phase < 2.9) { stage = \"hyperpolarization (refractory)\"; sc = H.colors.violet; }\nelse if (phase >= 0.7 && phase < 1.2) { stage = \"approaching threshold\"; sc = H.colors.good; }\n\n// A small membrane channel diagram on the right that opens/closes with phase.\nconst mx = w - 140, my = h * 0.5;\nH.text(\"membrane\", mx - 20, my - 92, { color: H.colors.sub, size: 12 });\nH.line(mx - 44, my - 70, mx + 44, my - 70, { color: H.colors.axis, width: 2 });\nH.line(mx - 44, my + 70, mx + 44, my + 70, { color: H.colors.axis, width: 2 });\nconst naOpen = phase >= 1.2 && phase < 1.6;\nconst kOpen = phase >= 1.5 && phase < 2.3;\n// Na+ channel\nH.circle(mx - 22, my, 16, { stroke: H.colors.warn, width: 2, fill: naOpen ? H.hsl(350, 70, 40, 0.5) : \"rgba(0,0,0,0)\" });\nH.text(\"Na⁺\", mx - 33, my + 4, { color: H.colors.warn, size: 11 });\nif (naOpen) H.arrow(mx - 22, my - 60, mx - 22, my + 18, { color: H.colors.warn, width: 2, head: 7 });\n// K+ channel\nH.circle(mx + 22, my, 16, { stroke: H.colors.accent, width: 2, fill: kOpen ? H.hsl(205, 70, 45, 0.5) : \"rgba(0,0,0,0)\" });\nH.text(\"K⁺\", mx + 13, my + 4, { color: H.colors.accent, size: 11 });\nif (kOpen) H.arrow(mx + 22, my + 18, mx + 22, my + 84, { color: H.colors.accent, width: 2, head: 7 });\n\n// Titles + live readout.\nH.text(\"Neuron action potential\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Membrane voltage spikes as Na⁺ rushes in, then K⁺ restores rest.\",\n 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Vm = \" + v.toFixed(1) + \" mV\", 24, 76, { color: H.colors.accent2, size: 14, weight: 600 });\nH.text(\"phase: \" + stage, 200, 76, { color: sc, size: 13, weight: 600 });\nH.text(\"mV\", 30, 70, { color: H.colors.sub, size: 12 });\nH.text(\"time (cycle)\", view.box.x + view.box.w - 70, view.box.y + view.box.h + 30,\n { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"membrane potential Vm\", color: H.colors.accent },\n { label: \"Na⁺ influx\", color: H.colors.warn },\n { label: \"K⁺ efflux / AHP\", color: H.colors.violet },\n], 64, h - 46);" + }, + { + "id": "heat-diffusion-plate-colormap", + "title": "Heat diffusion across a plate", + "tag": "Thermodynamics", + "dimension": "2D", + "equation": "u_t = α ∇²u", + "summary": "A hot spot on a square metal plate spreads out and cools toward the fixed-cold edges, shown as a live blue-to-red temperature color map driven by the analytic Fourier-mode solution of the heat equation.", + "keywords": [ + "heat diffusion", + "heat equation", + "thermal conduction", + "temperature field", + "color map", + "heatmap", + "fourier modes", + "diffusivity", + "laplacian", + "cooling plate", + "conduction", + "thermodynamics", + "u_t = alpha laplacian u", + "heat spreading" + ], + "bullets": [ + "Each Fourier sine mode decays like exp(-α π²(m²+n²)t), so fine/sharp features (high m,n) vanish fastest — the bump smooths before it fades.", + "The color bar maps temperature relative to the initial peak T0; watch the hottest cell drop from 100% toward 0 as heat leaks to the cold boundary.", + "Larger thermal diffusivity α makes every mode decay faster, so a more conductive plate equilibrates sooner." + ], + "student_prompts": [ + "What happens if I make the diffusivity α ten times larger?", + "How would the picture change if the edges were insulated instead of held cold?", + "Why do the sharp, high-frequency features in the heat map disappear before the broad ones?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n// ---- Heat diffusion on a square plate, u_t = alpha * laplacian(u) ----\n// Closed-form solution on [0,1]x[0,1] with fixed cold edges (u=0 on boundary):\n// a hot Gaussian-ish initial bump decays as a sum of sine modes, each mode\n// shrinking like exp(-alpha*pi^2*(m^2+n^2)*tau). Stable, never blows up.\nconst alpha = 0.18; // thermal diffusivity (arb. units)\nconst tau = (t % 14) * 1.0; // simulation clock, loops every 14 s\n// Plate pixel box\nconst M = 26; // grid cells per side\nconst plateX = 70, plateY = 96;\nconst plateW = Math.min(w - 320, h - 150);\nconst cell = plateW / M;\nconst plateH = plateW;\n// Precompute a small set of Fourier amplitudes for an initial off-center hot spot.\nconst modes = [];\nconst MX = 5, MY = 5;\nconst cx0 = 0.38, cy0 = 0.42; // initial hot-spot center (unit square)\nfor (let mx = 1; mx <= MX; mx++) {\n for (let my = 1; my <= MY; my++) {\n // amplitude ~ projection of a localized bump onto sin modes\n const amp =\n Math.sin(mx * Math.PI * cx0) *\n Math.sin(my * Math.PI * cy0) *\n Math.exp(-0.5 * (mx * mx + my * my) * 0.10);\n modes.push({ mx, my, amp, lam: (mx * mx + my * my) });\n }\n}\n// Temperature field T(x,y,tau) in [0,1]; track max for the readout.\nconst temp = (ux, uy) => {\n let s = 0;\n for (let k = 0; k < modes.length; k++) {\n const m = modes[k];\n s +=\n m.amp *\n Math.sin(m.mx * Math.PI * ux) *\n Math.sin(m.my * Math.PI * uy) *\n Math.exp(-alpha * Math.PI * Math.PI * m.lam * tau);\n }\n return s;\n};\n// Color ramp cold(blue) -> hot(red/white). v in [0,1].\nconst heatColor = (v) => {\n v = H.clamp(v, 0, 1);\n // blue(230) -> cyan -> yellow -> red(0), brighten toward the top end.\n const hue = H.lerp(235, 0, v);\n const light = H.lerp(22, 62, v);\n const sat = H.lerp(70, 92, v);\n return H.hsl(hue, sat, light);\n};\n// Normalize against the t=0 peak so the color scale is steady.\nlet peak0 = 1e-6;\nfor (let i = 0; i < M; i++) {\n for (let j = 0; j < M; j++) {\n const v = temp((i + 0.5) / M, (j + 0.5) / M);\n if (v > peak0) peak0 = v;\n }\n}\nlet curMax = 0;\nfor (let j = 0; j < M; j++) {\n for (let i = 0; i < M; i++) {\n const ux = (i + 0.5) / M;\n const uy = (j + 0.5) / M;\n let v = temp(ux, uy) / peak0;\n if (v < 0) v = 0; // temperature above ambient is nonnegative\n if (v > curMax) curMax = v;\n const px = plateX + i * cell;\n const py = plateY + (M - 1 - j) * cell; // flip so +y is up\n H.rect(px, py, cell + 0.8, cell + 0.8, { fill: heatColor(v) });\n }\n}\n// Plate frame\nH.rect(plateX, plateY, plateW, plateH, { stroke: H.colors.axis, width: 1.6 });\n// Axis ticks (physical plate coordinates 0..1)\nfor (let g = 0; g <= 1.0001; g += 0.25) {\n const gx = plateX + g * plateW;\n const gy = plateY + plateH;\n H.text(g.toFixed(2), gx, gy + 16, { color: H.colors.sub, size: 11, align: \"center\" });\n H.text(g.toFixed(2), plateX - 8, plateY + (1 - g) * plateH, { color: H.colors.sub, size: 11, align: \"right\", baseline: \"middle\" });\n}\nH.text(\"x\", plateX + plateW / 2, plateY + plateH + 32, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(\"y\", plateX - 30, plateY + plateH / 2, { color: H.colors.sub, size: 12, align: \"center\", baseline: \"middle\" });\n// ---- Color legend bar ----\nconst barX = plateX + plateW + 40, barY = plateY, barW = 22, barH = plateH;\nconst segs = 40;\nfor (let k = 0; k < segs; k++) {\n const v = 1 - k / segs;\n H.rect(barX, barY + (k / segs) * barH, barW, barH / segs + 0.8, { fill: heatColor(v) });\n}\nH.rect(barX, barY, barW, barH, { stroke: H.colors.axis, width: 1.2 });\nH.text(\"hot\", barX + barW + 8, barY + 6, { color: H.colors.warn, size: 12 });\nH.text(\"cold\", barX + barW + 8, barY + barH, { color: H.colors.accent, size: 12 });\nH.text(\"T / T0\", barX, barY - 12, { color: H.colors.sub, size: 12 });\n// ---- Title + live readout ----\nH.text(\"Heat diffusion across a plate\", 24, 34, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"u_t = α ∇²u — a hot spot spreads and cools toward the edges\", 24, 56, { color: H.colors.sub, size: 13 });\nconst peakPct = (curMax * 100);\nH.text(\"t = \" + tau.toFixed(2) + \" s\", barX - 4, barY + barH + 60, { color: H.colors.ink, size: 14, weight: 700 });\nH.text(\"peak temp = \" + peakPct.toFixed(1) + \"% of T0\", barX - 4, barY + barH + 82, { color: H.colors.accent2, size: 13 });\nH.text(\"α = \" + alpha.toFixed(2), barX - 4, barY + barH + 102, { color: H.colors.sub, size: 13 });" + }, + { + "id": "ideal-gas-particles-box-3d", + "title": "Ideal gas in a box", + "tag": "Statistical Mechanics", + "dimension": "3D", + "equation": "PV = NkT, T ∝ ⟨v²⟩", + "summary": "Atoms of an ideal gas fly in straight lines and bounce elastically off the walls of a 3D box, each colored by its speed; a live left/right count shows the gas stays statistically uniform while temperature tracks the mean square speed.", + "keywords": [ + "ideal gas", + "gas particles", + "kinetic theory", + "molecules in a box", + "elastic collisions", + "gas law", + "pv = nkt", + "temperature kinetic energy", + "atoms bouncing", + "gas pressure", + "statistical mechanics", + "particle simulation", + "3d box gas" + ], + "bullets": [ + "Each atom moves at constant velocity between walls and reverses on impact (elastic collision), so total kinetic energy — and therefore temperature — is conserved.", + "Faster atoms are drawn warmer (orange) and slower ones cooler (blue); temperature is proportional to the mean of v², not v.", + "The instantaneous left|right tally near 14|14 shows that random motion keeps the two halves of the box equally populated — the basis of uniform pressure." + ], + "student_prompts": [ + "How is the pressure on a wall related to how fast and how often atoms hit it?", + "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);" + }, + { + "id": "maxwell-boltzmann-speed-distribution", + "title": "Maxwell–Boltzmann speed distribution", + "tag": "Statistical Mechanics", + "dimension": "2D", + "equation": "f(v) = √(2/π) · v² e^(−v²/2a²) / a³, a = √(kT/m)", + "summary": "A histogram of thousands of random molecular speeds fills in over time and converges onto the analytic Maxwell-Boltzmann curve, with the most-probable speed marked and temperature slowly breathing.", + "keywords": [ + "maxwell-boltzmann", + "speed distribution", + "molecular speeds", + "velocity distribution", + "kinetic theory", + "statistical mechanics", + "most probable speed", + "rms speed", + "mean speed", + "histogram converging", + "probability density", + "gas speeds", + "thermal distribution", + "boltzmann" + ], + "bullets": [ + "The curve f(v) rises as v² for slow molecules but is killed by the e^(−v²/2a²) factor at high speed, giving the characteristic skewed peak.", + "Three speeds differ: most-probable v_p = a√2 (the peak), mean ⟨v⟩ = a√(8/π), and root-mean-square v_rms = a√3 — always v_p < ⟨v⟩ < v_rms.", + "As more samples accumulate, the noisy histogram settles onto the smooth theoretical density; raising temperature T widens the curve and shifts the peak right." + ], + "student_prompts": [ + "Why is the most-probable speed smaller than the average speed?", + "How does the whole distribution change when the gas is heated?", + "Where does the v² factor in front of the exponential come from?" + ], + "code": "H.background();\n// ---- Maxwell-Boltzmann speed distribution forming from samples ----\n// Analytic 3D speed pdf (mass=kT=1 reduced units):\n// f(v) = sqrt(2/pi) * v^2 * exp(-v^2 / (2 a^2)) / a^3, a = sqrt(kT/m)\n// We let temperature breathe slowly and draw both the smooth curve and a live\n// histogram of N pseudo-random samples that \"fills in\" toward the curve.\nconst T = 1.6 + 0.6 * Math.sin(t * 0.35); // temperature (>0 always)\nconst a = Math.sqrt(T); // scale parameter\nconst vMaxAxis = 6;\nconst v = H.plot2d({ xMin: 0, xMax: vMaxAxis, yMin: 0, yMax: 0.7, pad: 56 });\nv.grid(); v.axes();\nconst pdf = (x) => {\n if (x < 0) return 0;\n const a2 = a * a;\n return Math.sqrt(2 / Math.PI) * (x * x) * Math.exp(-(x * x) / (2 * a2)) / (a2 * a);\n};\n// ---- Histogram of samples (Box-Muller -> 3 normals -> speed) ----\nconst bins = 24;\nconst counts = new Array(bins).fill(0);\n// Number of samples grows with time then holds — the distribution \"forms\".\nconst Nmax = 1400;\nconst N = Math.min(Nmax, Math.floor(60 + (t % 16) * 130));\nconst rnd = (k) => {\n const s = Math.sin(k * 12.9898) * 43758.5453;\n return s - Math.floor(s);\n};\nconst normal = (k) => {\n // Box-Muller from two deterministic uniforms\n let u1 = rnd(k * 2 + 1); let u2 = rnd(k * 2 + 2);\n u1 = Math.min(Math.max(u1, 1e-6), 1 - 1e-6);\n return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n};\nlet speedSum = 0, vrmsSum = 0;\nfor (let i = 0; i < N; i++) {\n const base = i * 3 + Math.floor(t) * 9973; // shuffle seed slowly with time\n const vx = a * normal(base + 1);\n const vy = a * normal(base + 2);\n const vz = a * normal(base + 3);\n const sp = Math.sqrt(vx * vx + vy * vy + vz * vz);\n speedSum += sp;\n vrmsSum += sp * sp;\n const b = Math.floor((sp / vMaxAxis) * bins);\n if (b >= 0 && b < bins) counts[b]++;\n}\n// Draw histogram bars (normalized to a density so they sit under the curve)\nconst binW = vMaxAxis / bins;\nfor (let b = 0; b < bins; b++) {\n const density = counts[b] / (N * binW); // probability density estimate\n if (density <= 0) continue;\n const x0 = b * binW;\n H.rect(\n v.X(x0), v.Y(density), v.X(x0 + binW) - v.X(x0), v.Y(0) - v.Y(density),\n { fill: H.hsl(205, 70, 52, 0.45), stroke: H.hsl(205, 70, 66, 0.7), width: 1 }\n );\n}\n// Analytic curve on top\nv.fn(pdf, { color: H.colors.accent2, width: 3 });\n// Mark characteristic speeds: most-probable v_p = a*sqrt(2)\nconst vp = a * Math.SQRT2;\nconst vAvg = a * Math.sqrt(8 / Math.PI);\nconst vRms = a * Math.sqrt(3);\nH.line(v.X(vp), v.Y(0), v.X(vp), v.Y(pdf(vp)), { color: H.colors.good, width: 2, dash: [5, 5] });\nv.dot(vp, pdf(vp), { r: 5, fill: H.colors.good });\nH.text(\"v_p\", v.X(vp) + 6, v.Y(pdf(vp)) - 6, { color: H.colors.good, size: 12 });\n// Axis labels\nH.text(\"speed v (arb. units)\", v.box.x + v.box.w / 2, v.box.y + v.box.h + 38, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(\"f(v)\", v.box.x - 40, v.box.y + 8, { color: H.colors.sub, size: 12 });\n// ---- Title + live readouts ----\nH.text(\"Maxwell–Boltzmann speed distribution\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Histogram of \" + N + \" random molecular speeds filling in the analytic curve\", 24, 54, { color: H.colors.sub, size: 13 });\nconst measAvg = N > 0 ? speedSum / N : 0;\nconst measRms = N > 0 ? Math.sqrt(vrmsSum / N) : 0;\nH.legend([\n { label: \"analytic f(v)\", color: H.colors.accent2 },\n { label: \"sampled histogram\", color: H.hsl(205, 70, 60) },\n { label: \"v_p (most probable)\", color: H.colors.good },\n], H.W - 230, 92);\nH.text(\"T = \" + T.toFixed(2) + \" N = \" + N, H.W - 230, 168, { color: H.colors.ink, size: 13, weight: 700 });\nH.text(\"⟨v⟩ ≈ \" + measAvg.toFixed(2) + \" (theory \" + vAvg.toFixed(2) + \")\", H.W - 230, 188, { color: H.colors.sub, size: 12 });\nH.text(\"v_rms ≈ \" + measRms.toFixed(2) + \" (theory \" + vRms.toFixed(2) + \")\", H.W - 230, 206, { color: H.colors.sub, size: 12 });" + }, + { + "id": "matrix-transform-eigenvectors", + "title": "2x2 Matrix Transforming the Plane", + "tag": "Linear Algebra", + "dimension": "2D", + "equation": "M = [[2,1],[1,2]], eigenvalues 3 and 1", + "summary": "A 2x2 matrix smoothly morphs the coordinate grid and the unit square between identity and M = [[2,1],[1,2]]. The basis-vector images i and j swing, the square's area scales by det(M), and the two eigenvectors stay pinned on their dashed span lines while only their lengths (the eigenvalues) change.", + "keywords": [ + "matrix", + "2x2 matrix", + "linear transformation", + "transform the plane", + "unit square", + "determinant", + "eigenvector", + "eigenvalue", + "eigenvectors stay on span", + "basis vectors", + "i hat", + "j hat", + "shear", + "scaling" + ], + "bullets": [ + "The image of the unit square is a parallelogram whose area equals det(M).", + "Eigenvectors never leave their own line (span); the matrix only stretches them by lambda.", + "i-hat and j-hat are the columns of M, so watching them tells you the whole transform." + ], + "student_prompts": [ + "Why does the area of the square equal the determinant of M?", + "What happens to the eigenvectors if an eigenvalue is negative?", + "How do I compute the eigenvectors of [[2,1],[1,2]] by hand?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -3.6, yMax: 3.6, pad: 50 });\nconst s = (1 - Math.cos(t * 0.8)) * 0.5;\nconst m00 = H.lerp(1, 2, s), m01 = H.lerp(0, 1, s);\nconst m10 = H.lerp(0, 1, s), m11 = H.lerp(1, 2, s);\nconst apply = (x, y) => [m00 * x + m01 * y, m10 * x + m11 * y];\nfor (let gx = -5; gx <= 5; gx++) {\n const pts = [];\n for (let gy = -4; gy <= 4; gy += 0.5) pts.push(apply(gx, gy));\n v.path(pts, { color: H.colors.grid, width: 1 });\n}\nfor (let gy = -4; gy <= 4; gy++) {\n const pts = [];\n for (let gx = -5; gx <= 5; gx += 0.5) pts.push(apply(gx, gy));\n v.path(pts, { color: H.colors.grid, width: 1 });\n}\nv.axes();\nconst sq = [[0, 0], [1, 0], [1, 1], [0, 1]].map((p) => apply(p[0], p[1]));\nv.path(sq, { color: H.colors.accent2, width: 2.4, close: true, fill: \"rgba(244,162,89,0.18)\" });\nconst ix = apply(1, 0), jx = apply(0, 1);\nv.arrow(0, 0, ix[0], ix[1], { color: H.colors.accent, width: 3 });\nv.arrow(0, 0, jx[0], jx[1], { color: H.colors.good, width: 3 });\nv.text(\"i\", ix[0] * 0.55 + 0.15, ix[1] * 0.55 - 0.2, { color: H.colors.accent, size: 14 });\nv.text(\"j\", jx[0] * 0.55 - 0.35, jx[1] * 0.55 + 0.1, { color: H.colors.good, size: 14 });\nconst e1 = [1 / Math.SQRT2, 1 / Math.SQRT2];\nconst e2 = [1 / Math.SQRT2, -1 / Math.SQRT2];\nconst lam1 = H.lerp(1, 3, s), lam2 = 1;\nv.line(-4 * e1[0], -4 * e1[1], 4 * e1[0], 4 * e1[1], { color: H.colors.violet, width: 1.2, dash: [6, 6] });\nv.line(-4 * e2[0], -4 * e2[1], 4 * e2[0], 4 * e2[1], { color: H.colors.yellow, width: 1.2, dash: [6, 6] });\nv.arrow(0, 0, lam1 * e1[0], lam1 * e1[1], { color: H.colors.violet, width: 3.2 });\nv.arrow(0, 0, lam2 * e2[0], lam2 * e2[1], { color: H.colors.yellow, width: 3.2 });\nv.text(\"L1 = \" + lam1.toFixed(2), lam1 * e1[0] + 0.2, lam1 * e1[1] + 0.3, { color: H.colors.violet, size: 13 });\nv.text(\"L2 = \" + lam2.toFixed(2), lam2 * e2[0] + 0.3, lam2 * e2[1] - 0.25, { color: H.colors.yellow, size: 13 });\nconst det = m00 * m11 - m01 * m10;\nH.text(\"2x2 matrix transforming the plane\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Eigenvectors keep their direction; the square's area scales by det.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"M = [[\" + m00.toFixed(2) + \", \" + m01.toFixed(2) + \"], [\" + m10.toFixed(2) + \", \" + m11.toFixed(2) + \"]]\", 24, 78, { color: H.colors.accent, size: 13 });\nH.text(\"det(M) = \" + det.toFixed(2) + \" (unit-square area)\", 24, 98, { color: H.colors.accent2, size: 13 });\nH.legend([\n { label: \"i, j (basis images)\", color: H.colors.accent },\n { label: \"eigvec L1 span\", color: H.colors.violet },\n { label: \"eigvec L2 span\", color: H.colors.yellow },\n], H.W - 210, 96);" + }, + { + "id": "vector-addition-dot-projection", + "title": "Vector Addition & Dot Product as Projection", + "tag": "Linear Algebra", + "dimension": "2D", + "equation": "a . b = |a||b|cos(theta) = |a| * (scalar projection of b)", + "summary": "A fixed vector a and a sweeping vector b show two ideas at once: the parallelogram law builds a+b from dashed translated copies, while b's shadow onto a (the vector projection) slides along a as b's angle changes. Live readouts track the dot product, cos(theta), and the scalar projection length.", + "keywords": [ + "vector addition", + "parallelogram law", + "resultant", + "dot product", + "scalar product", + "inner product", + "projection", + "vector projection", + "scalar projection", + "shadow", + "cosine angle", + "orthogonal", + "components", + "a dot b" + ], + "bullets": [ + "a+b is the diagonal of the parallelogram spanned by a and b (tip-to-tail also works).", + "The dot product equals |a| times the signed length of b's projection onto a.", + "When b is perpendicular to a the dot product is 0 and the projection collapses to the origin." + ], + "student_prompts": [ + "Why does a . b become negative when the angle between a and b exceeds 90 degrees?", + "How is the scalar projection different from the vector projection?", + "Can you show the tip-to-tail method of adding a and b instead of the parallelogram?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1.5, xMax: 6.5, yMin: -1.5, yMax: 5, pad: 50 });\nv.grid();\nv.axes();\nconst a = [4, 1];\nconst ang = 1.05 + 0.85 * Math.sin(t * 0.6);\nconst bMag = 3.2;\nconst b = [bMag * Math.cos(ang), bMag * Math.sin(ang)];\nconst sum = [a[0] + b[0], a[1] + b[1]];\nv.line(a[0], a[1], sum[0], sum[1], { color: H.colors.good, width: 1.4, dash: [5, 5] });\nv.line(b[0], b[1], sum[0], sum[1], { color: H.colors.accent, width: 1.4, dash: [5, 5] });\nv.arrow(0, 0, sum[0], sum[1], { color: H.colors.violet, width: 3 });\nv.text(\"a + b\", sum[0] + 0.15, sum[1] + 0.25, { color: H.colors.violet, size: 14 });\nv.arrow(0, 0, a[0], a[1], { color: H.colors.accent, width: 3.4 });\nv.arrow(0, 0, b[0], b[1], { color: H.colors.good, width: 3.4 });\nv.text(\"a\", a[0] * 0.6 + 0.1, a[1] * 0.6 - 0.25, { color: H.colors.accent, size: 15 });\nv.text(\"b\", b[0] * 0.6 - 0.3, b[1] * 0.6 + 0.2, { color: H.colors.good, size: 15 });\nconst aLen2 = a[0] * a[0] + a[1] * a[1];\nconst aLen = Math.sqrt(aLen2);\nconst dot = a[0] * b[0] + a[1] * b[1];\nconst scal = dot / aLen2;\nconst proj = [scal * a[0], scal * a[1]];\nv.line(b[0], b[1], proj[0], proj[1], { color: H.colors.warn, width: 1.6, dash: [4, 4] });\nv.line(0, 0, proj[0], proj[1], { color: H.colors.accent2, width: 5 });\nv.dot(proj[0], proj[1], { r: 5, fill: H.colors.accent2 });\nv.text(\"proj_a b\", proj[0] - 0.2, proj[1] - 0.35, { color: H.colors.accent2, size: 13 });\nconst scalarProj = dot / aLen;\nconst cosA = dot / (aLen * bMag);\nH.text(\"Vector addition + dot product as projection\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a+b closes the parallelogram; a.b measures b's shadow on a.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = (\" + a[0].toFixed(1) + \", \" + a[1].toFixed(1) + \") b = (\" + b[0].toFixed(2) + \", \" + b[1].toFixed(2) + \")\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"a . b = \" + dot.toFixed(2) + \" = |a||b|cos(t), cos = \" + cosA.toFixed(2), 24, 98, { color: H.colors.accent2, size: 13 });\nH.text(\"scalar proj = a.b/|a| = \" + scalarProj.toFixed(2), 24, 118, { color: H.colors.warn, size: 13 });\nH.legend([\n { label: \"a\", color: H.colors.accent },\n { label: \"b\", color: H.colors.good },\n { label: \"a + b\", color: H.colors.violet },\n { label: \"proj of b on a\", color: H.colors.accent2 },\n], H.W - 190, 96);" + }, + { + "id": "rotation-matrix-3d-y-axis", + "title": "3D Rotation About the Y-Axis", + "tag": "Linear Algebra", + "dimension": "3D", + "equation": "R_y(theta) = [[cos,0,sin],[0,1,0],[-sin,0,cos]]", + "summary": "A unit cube and a reference vector v spin in 3D under the rotation matrix R_y(theta), driven by time. The vertical rotation axis is highlighted in violet and stays fixed, points on it never move, while the vector's tip sweeps a circular trail. A live angle readout and the matrix entries cos/sin update each frame.", + "keywords": [ + "3d rotation", + "rotation matrix", + "r_y", + "rotate about axis", + "yaw", + "orthogonal matrix", + "rotation in space", + "spinning cube", + "axis of rotation", + "invariant axis", + "angle theta", + "cos sin matrix", + "rigid rotation", + "linear transformation 3d" + ], + "bullets": [ + "A rotation matrix is orthogonal: it preserves lengths and angles, so the cube never deforms.", + "Vectors lying on the rotation axis are eigenvectors with eigenvalue 1, they do not move.", + "The matrix entries are just cos(theta) and sin(theta) placed to mix the x and z coordinates." + ], + "student_prompts": [ + "Why is the rotation axis an eigenvector of R_y with eigenvalue 1?", + "How would the matrix change to rotate about the x-axis or z-axis instead?", + "What makes a rotation matrix orthogonal, and why does that preserve lengths?" + ], + "code": "H.background();\nconst cam = H.cam3d({ scale: 52, dist: 14, pitch: -0.5, cy: H.H * 0.56 });\ncam.yaw = 0.25 * t;\ncam.grid(4, 1);\nconst theta = t * 0.9;\nconst c = Math.cos(theta), sN = Math.sin(theta);\nconst rot = (p) => [\n c * p[0] + sN * p[2],\n p[1],\n -sN * p[0] + c * p[2],\n];\ncam.line([0, -2.4, 0], [0, 2.4, 0], { color: H.colors.violet, width: 2.4 });\ncam.sphere([0, 2.4, 0], 0.12, { color: H.colors.violet });\nconst half = 1.1;\nconst corners = [];\nfor (let xi = -1; xi <= 1; xi += 2)\n for (let yi = -1; yi <= 1; yi += 2)\n for (let zi = -1; zi <= 1; zi += 2)\n corners.push(rot([xi * half, yi * half, zi * half]));\nconst edges = [[0,1],[0,2],[0,4],[1,3],[1,5],[2,3],[2,6],[3,7],[4,5],[4,6],[5,7],[6,7]];\nedges.forEach((e) => cam.line(corners[e[0]], corners[e[1]], { color: H.colors.accent, width: 1.8 }));\nconst v0 = [1.8, 0.6, 0];\nconst vr = rot(v0);\nconst o = cam.project([0, 0, 0]);\nconst g = cam.project(v0);\nconst p = cam.project(vr);\nH.arrow(o.x, o.y, g.x, g.y, { color: H.colors.sub, width: 1.6 });\nH.arrow(o.x, o.y, p.x, p.y, { color: H.colors.accent2, width: 3 });\nH.text(\"v\", p.x + 6, p.y - 4, { color: H.colors.accent2, size: 14 });\nconst trail = [];\nfor (let k = 0; k <= 40; k++) {\n const a = theta - k / 40 * Math.PI * 0.9;\n trail.push([Math.cos(a) * v0[0] + Math.sin(a) * v0[2], v0[1], -Math.sin(a) * v0[0] + Math.cos(a) * v0[2]]);\n}\ncam.path(trail, { color: H.colors.yellow, width: 1.6 });\ncam.axes(3);\nconst deg = ((theta * 180 / Math.PI) % 360 + 360) % 360;\nH.text(\"3D rotation about the y-axis\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"R_y(t) spins the cube and v; points on the axis stay put. Drag to orbit.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"theta = \" + deg.toFixed(1) + \" deg\", 24, 78, { color: H.colors.accent2, size: 13 });\nH.text(\"R_y = [[cos, 0, sin],[0, 1, 0],[-sin, 0, cos]], cos=\" + c.toFixed(2) + \" sin=\" + sN.toFixed(2), 24, 98, { color: H.colors.accent, size: 13 });\nH.legend([\n { label: \"rotation axis (y)\", color: H.colors.violet },\n { label: \"rotated v\", color: H.colors.accent2 },\n { label: \"tip path\", color: H.colors.yellow },\n], H.W - 180, 96);" + }, + { + "id": "beat-frequencies-sine-sum", + "title": "Beat Frequencies: Two Close Tones Add Up", + "tag": "Signals & Systems", + "dimension": "2D", + "equation": "y = sin(2*pi*f1*x) + sin(2*pi*f2*x), beat = |f1 - f2|", + "summary": "Two pure sine tones at slightly different frequencies travel across the top panel; their sum below swells and fades inside a slow beat envelope that pulses at the difference frequency |f1 - f2|. A live readout tracks the carrier, the beat rate, and the throbbing amplitude.", + "keywords": [ + "beat frequency", + "beats", + "sine sum", + "superposition", + "interference", + "two tones", + "amplitude modulation", + "envelope", + "carrier frequency", + "detuned", + "constructive destructive interference", + "signals", + "acoustics", + "fourier" + ], + "bullets": [ + "Adding two sines of nearby frequency f1 and f2 produces a fast carrier at (f1+f2)/2 inside a slow envelope.", + "The envelope amplitude is 2A*|cos(pi*(f1-f2)*x)|, so the loudness throbs at the beat rate |f1-f2|.", + "Watch the green envelope curve hug the orange sum: where the two tones align it swells, where they cancel it pinches to zero." + ], + "student_prompts": [ + "Why does the beat rate equal |f1 - f2| and not f1 + f2?", + "What would I hear if f1 and f2 were identical, and what does the picture become?", + "How is this beat pattern related to amplitude modulation in radio?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst f1 = 4.0;\nconst f2 = 4.6;\nconst fBeat = Math.abs(f1 - f2);\nconst fCar = (f1 + f2) / 2;\nconst A = 0.9;\nconst xMin = 0, xMax = 6;\nconst scroll = t * 0.9;\nconst top = H.plot2d({\n xMin, xMax, yMin: -1.2, yMax: 1.2,\n box: { x: 56, y: 70, w: w - 96, h: (h - 150) * 0.42 },\n});\ntop.grid({ stepX: 1, stepY: 1 });\ntop.axes({ stepX: 1, stepY: 1 });\nconst s1 = (x) => A * Math.sin(H.TAU * f1 * (x + scroll));\nconst s2 = (x) => A * Math.sin(H.TAU * f2 * (x + scroll));\ntop.fn(s1, { color: H.colors.accent, width: 2.2, steps: 480 });\ntop.fn(s2, { color: H.colors.violet, width: 2.2, steps: 480 });\ntop.text(\"tone 1 + tone 2\", xMin + 0.1, 1.05, { color: H.colors.sub, size: 12 });\nconst bot = H.plot2d({\n xMin, xMax, yMin: -2.1, yMax: 2.1,\n box: { x: 56, y: 70 + (h - 150) * 0.46 + 28, w: w - 96, h: (h - 150) * 0.46 },\n});\nbot.grid({ stepX: 1, stepY: 1 });\nbot.axes({ stepX: 1, stepY: 1 });\nconst sum = (x) => s1(x) + s2(x);\nconst env = (x) => 2 * A * Math.abs(Math.cos(Math.PI * fBeat * (x + scroll)));\nbot.fn((x) => env(x), { color: H.colors.good, width: 1.6 });\nbot.fn((x) => -env(x), { color: H.colors.good, width: 1.6 });\nbot.fn(sum, { color: H.colors.accent2, width: 2.6, steps: 520 });\nconst xm = xMin + ((t * 0.8) % (xMax - xMin));\nbot.dot(xm, sum(xm), { r: 6, fill: H.colors.yellow });\nconst ampNow = env(xm);\nbot.text(\"x = t\", xMin + 0.1, 1.9, { color: H.colors.sub, size: 12 });\nH.text(\"Beat frequencies: two close tones add up\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"sum = sin(2pi f1 x) + sin(2pi f2 x) -> amplitude throbs at |f1-f2|\",\n 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"f1 = \" + f1.toFixed(2) + \" Hz\", 24, h - 58, { color: H.colors.accent, size: 13 });\nH.text(\"f2 = \" + f2.toFixed(2) + \" Hz\", 140, h - 58, { color: H.colors.violet, size: 13 });\nH.text(\"beat = \" + fBeat.toFixed(2) + \" Hz\", 260, h - 58, { color: H.colors.good, size: 13 });\nH.text(\"envelope |amp| = \" + ampNow.toFixed(2), 410, h - 58,\n { color: H.colors.yellow, size: 13 });\nH.text(\"carrier ~ \" + fCar.toFixed(2) + \" Hz\", 410, h - 38,\n { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"tone 1\", color: H.colors.accent },\n { label: \"tone 2\", color: H.colors.violet },\n { label: \"sum\", color: H.colors.accent2 },\n { label: \"beat envelope\", color: H.colors.good },\n], w - 168, 84);" + }, + { + "id": "low-pass-filter-noisy-signal", + "title": "Low-Pass Filter Smoothing a Noisy Signal", + "tag": "Signal Processing", + "dimension": "2D", + "equation": "y[n] = y[n-1] + a*(x[n] - y[n-1]), a = dt/(RC+dt), fc = 1/(2*pi*RC)", + "summary": "A scrolling noisy waveform (true signal plus jitter) flows left while a one-pole RC low-pass filter, applied sample by sample, traces a smooth orange output that follows the dashed true signal. The cutoff frequency breathes up and down with time so you can watch heavier smoothing trade noise rejection for lag.", + "keywords": [ + "low-pass filter", + "lowpass", + "rc filter", + "smoothing", + "noise reduction", + "moving average", + "exponential moving average", + "cutoff frequency", + "one pole filter", + "denoise", + "signal processing", + "filtering", + "time constant", + "rolloff" + ], + "bullets": [ + "Each output sample is a blend of the new noisy input and the previous output: y[n] = y[n-1] + a*(x[n] - y[n-1]).", + "The smoothing factor a = dt/(RC+dt) sets the cutoff fc = 1/(2*pi*RC): smaller cutoff means smoother output but more lag.", + "Compare the gray noisy input, the dashed true signal, and the orange filtered output - the filter rejects the fast jitter while tracking the slow trend." + ], + "student_prompts": [ + "How does raising the cutoff frequency change the smoothing and the lag?", + "Why does a one-pole low-pass filter introduce phase delay in the output?", + "How is this discrete recursion equivalent to a physical resistor-capacitor circuit?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst xMin = 0, xMax = 10;\nconst N = 260;\nconst scroll = t * 1.4;\nconst clean = (x) => 1.3 * Math.sin(0.9 * (x + scroll)) + 0.5 * Math.sin(0.42 * (x + scroll) + 0.7);\nconst samples = [];\nfor (let i = 0; i <= N; i++) {\n const x = H.lerp(xMin, xMax, i / N);\n const k = Math.round((x + scroll) * 11);\n let lseed = ((k * 2654435761) % 4294967296 + 4294967296) % 4294967296;\n lseed = (lseed * 1664525 + 1013904223) % 4294967296;\n const noise = (lseed / 4294967296 - 0.5) * 2.0;\n samples.push([x, clean(x) + noise]);\n}\nconst RC = 0.16 + 0.14 * (1 + Math.sin(t * 0.5));\nconst dx = (xMax - xMin) / N;\nconst alpha = dx / (RC + dx);\nconst fc = 1 / (H.TAU * RC);\nconst filtered = [];\nlet y = samples[0][1];\nfor (let i = 0; i <= N; i++) {\n y = y + alpha * (samples[i][1] - y);\n filtered.push([samples[i][0], y]);\n}\nconst view = H.plot2d({\n xMin, xMax, yMin: -3.4, yMax: 3.4,\n box: { x: 56, y: 86, w: w - 96, h: h - 170 },\n});\nview.grid({ stepX: 1, stepY: 1 });\nview.axes({ stepX: 1, stepY: 1 });\nview.path(samples, { color: H.colors.sub, width: 1 });\nview.path(filtered, { color: H.colors.accent2, width: 3 });\nview.path(samples.map((p, i) => [p[0], clean(p[0])]), { color: H.colors.good, width: 1.4, dash: [6, 6] });\nconst xm = xMin + ((t * 1.0) % (xMax - xMin));\nconst idx = H.clamp(Math.round((xm - xMin) / dx), 0, N);\nview.dot(filtered[idx][0], filtered[idx][1], { r: 6, fill: H.colors.accent });\nconst noiseAmp = Math.abs(samples[idx][1] - clean(samples[idx][0]));\nconst resid = Math.abs(filtered[idx][1] - clean(filtered[idx][0]));\nH.text(\"Low-pass filter smoothing a noisy signal\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"one-pole RC filter: y[n] = y[n-1] + a*(x[n] - y[n-1]), a = dt/(RC+dt)\",\n 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"cutoff fc = \" + fc.toFixed(2) + \" Hz\", 24, h - 56, { color: H.colors.accent, size: 13 });\nH.text(\"RC = \" + RC.toFixed(2) + \" s\", 190, h - 56, { color: H.colors.violet, size: 13 });\nH.text(\"a = \" + alpha.toFixed(3), 300, h - 56, { color: H.colors.yellow, size: 13 });\nH.text(\"noise in = \" + noiseAmp.toFixed(2), 400, h - 56, { color: H.colors.sub, size: 13 });\nH.text(\"error out = \" + resid.toFixed(2), 530, h - 56, { color: H.colors.accent2, size: 13 });\nH.legend([\n { label: \"noisy input\", color: H.colors.sub },\n { label: \"filtered output\", color: H.colors.accent2 },\n { label: \"true signal\", color: H.colors.good },\n], w - 184, 100);" + }, + { + "id": "pid-feedback-settling-setpoint", + "title": "PID Feedback Settling to a Setpoint", + "tag": "Control Systems", + "dimension": "2D", + "equation": "u = Kp*e + Ki*integral(e dt) + Kd*de/dt, e = setpoint - output", + "summary": "A closed-loop PID controller drives a second-order plant toward a target. The green dashed setpoint steps between two levels every few seconds; the orange process output rises, overshoots, and settles inside a +/-5% band, while live readouts show the setpoint, output, error, and control effort u as the loop reacts in real time.", + "keywords": [ + "pid controller", + "feedback control", + "setpoint", + "closed loop", + "proportional integral derivative", + "control system", + "settling time", + "overshoot", + "steady state error", + "step response", + "controller", + "regulation", + "stability", + "second order system" + ], + "bullets": [ + "The controller forms error e = setpoint - output and computes u = Kp*e + Ki*integral(e) + Kd*de/dt every step.", + "The proportional term gives a fast push, the integral term erases steady-state error, and the derivative term damps overshoot.", + "After each setpoint step the orange output rises, overshoots, and settles into the +/-5% band - that recovery time is the settling time." + ], + "student_prompts": [ + "What happens to overshoot and settling time if I increase Kp or Kd?", + "Why does removing the integral term Ki leave a steady-state error?", + "How would integral windup affect this response after a large setpoint step?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst Kp = 1.8, Ki = 0.9, Kd = 0.35;\nconst tau = 0.7;\nconst setpointAt = (tt) => (Math.floor(tt / 6) % 2 === 0 ? 1.0 : 2.2);\nconst winT = 12;\nconst t0 = Math.max(0, t - winT);\nconst dt = 0.02;\nconst steps = Math.min(900, Math.round((t - t0) / dt) + 1);\nlet pos = 0, vel = 0, integ = 0, prevErr = setpointAt(t0) - 0;\nconst trace = [], spTrace = [];\nlet curErr = 0, curU = 0, curSp = setpointAt(t0), curPos = 0;\nfor (let i = 0; i < steps; i++) {\n const tt = t0 + i * dt;\n const sp = setpointAt(tt);\n const err = sp - pos;\n integ = H.clamp(integ + err * dt, -5, 5);\n const deriv = (err - prevErr) / dt;\n prevErr = err;\n const u = Kp * err + Ki * integ + Kd * deriv;\n const acc = (u - pos) / (tau * tau) - (2 / tau) * vel;\n vel += acc * dt;\n pos += vel * dt;\n if (!Number.isFinite(pos)) { pos = sp; vel = 0; integ = 0; }\n trace.push([tt, pos]);\n spTrace.push([tt, sp]);\n curErr = err; curU = u; curSp = sp; curPos = pos;\n}\nconst view = H.plot2d({\n xMin: t0, xMax: t0 + winT, yMin: -0.4, yMax: 3.0,\n box: { x: 60, y: 92, w: w - 100, h: h - 176 },\n});\nview.grid({ stepX: 2, stepY: 1 });\nview.axes({ stepX: 2, stepY: 1 });\nview.path(spTrace, { color: H.colors.good, width: 2, dash: [7, 6] });\nconst band = 0.05 * curSp;\nview.path([[t0, curSp + band], [t0 + winT, curSp + band]], { color: H.colors.grid, width: 1, dash: [3, 5] });\nview.path([[t0, curSp - band], [t0 + winT, curSp - band]], { color: H.colors.grid, width: 1, dash: [3, 5] });\nview.path(trace, { color: H.colors.accent2, width: 3 });\nview.dot(t, curPos, { r: 6, fill: H.colors.accent });\nview.line(t, curPos, t, curSp, { color: H.colors.warn, width: 1.6, dash: [4, 4] });\nH.text(\"PID feedback settling to a setpoint\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"u = Kp*e + Ki*integral(e) + Kd*de/dt drives the plant toward the target\",\n 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"setpoint = \" + curSp.toFixed(2), 24, h - 58, { color: H.colors.good, size: 13 });\nH.text(\"output = \" + curPos.toFixed(2), 170, h - 58, { color: H.colors.accent2, size: 13 });\nH.text(\"error = \" + curErr.toFixed(3), 320, h - 58, { color: H.colors.warn, size: 13 });\nH.text(\"control u = \" + curU.toFixed(2), 470, h - 58, { color: H.colors.accent, size: 13 });\nH.text(\"Kp=\" + Kp.toFixed(1) + \" Ki=\" + Ki.toFixed(1) + \" Kd=\" + Kd.toFixed(2) +\n \" +/-5% band\", 24, h - 36, { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"setpoint\", color: H.colors.good },\n { label: \"process output\", color: H.colors.accent2 },\n { label: \"error\", color: H.colors.warn },\n], w - 176, 106);" + }, + { + "id": "fourier-series-square-wave", + "title": "Fourier Series Building a Square Wave", + "tag": "Waves & Optics", + "dimension": "2D", + "equation": "f(x) = (4/pi) * sum_{k odd} sin(kx)/k", + "summary": "A square wave is reconstructed term by term from its odd sine harmonics. The number of harmonics ramps up over time so you watch the partial sum sharpen toward the ideal square wave, complete with the Gibbs overshoot at the jumps.", + "keywords": [ + "fourier series", + "fourier", + "square wave", + "harmonics", + "partial sum", + "sine series", + "odd harmonics", + "gibbs phenomenon", + "superposition", + "spectrum", + "sawtooth", + "synthesis", + "waveform", + "signal" + ], + "bullets": [ + "Each faint sine is one odd harmonic sin(kx)/k; the bright orange curve is their running sum S_N(x).", + "As more harmonics are added the sum hugs the dashed square wave more tightly, but a fixed overshoot (Gibbs) lingers at the jumps.", + "Higher harmonics carry smaller amplitude (1/k) yet sharpen the vertical edges where the wave switches sign." + ], + "student_prompts": [ + "Why does the overshoot near the jump never disappear no matter how many terms I add?", + "Where do the (4/pi) and the 1/k amplitude factors come from?", + "How would the series change for a sawtooth or triangle wave instead of a square wave?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst cycle = 14;\nconst phase = t % cycle;\nconst maxN = 12;\nconst built = H.clamp(Math.floor(phase / cycle * (maxN + 1)) + 1, 1, maxN);\nconst odd = [];\nfor (let k = 1, n = 0; n < built; k += 2, n++) odd.push(k);\n\nconst v = H.plot2d({ xMin: -H.PI, xMax: H.PI, yMin: -1.6, yMax: 1.6, pad: 54 });\nv.grid({ stepX: H.PI / 2 });\nv.axes({ stepX: H.PI / 2, stepY: 0.5 });\n\nv.fn(x => (Math.sin(x) >= 0 ? 1 : -1) * 1, { color: H.colors.grid, width: 2 });\n\nconst four = (x) => {\n let s = 0;\n for (let i = 0; i < odd.length; i++) {\n const k = odd[i];\n s += Math.sin(k * x) / k;\n }\n return (4 / H.PI) * s;\n};\nfor (let i = 0; i < odd.length; i++) {\n const k = odd[i];\n const amp = (4 / H.PI) / k;\n v.fn(x => amp * Math.sin(k * x), {\n color: H.hsl(200 + i * 14, 70, 60, 0.32), width: 1.4,\n });\n}\nv.fn(four, { color: H.colors.accent2, width: 3.4 });\n\nconst sx = H.map(Math.sin(t * 0.9), -1, 1, -H.PI + 0.2, H.PI - 0.2);\nv.dot(sx, four(sx), { r: 6, fill: H.colors.yellow });\nv.text(\"sample\", sx + 0.05, four(sx) + 0.22, { color: H.colors.sub, size: 12 });\n\nH.text(\"Fourier series -> square wave\", 24, 32, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Adding odd sine harmonics (4/pi) sin(kx)/k builds the square wave.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"harmonics N = \" + odd.length + \" highest k = \" + odd[odd.length - 1], 24, 78, { color: H.colors.accent, size: 14, weight: 600 });\nH.text(\"f(\" + sx.toFixed(2) + \") = \" + four(sx).toFixed(3), 24, 98, { color: H.colors.sub, size: 13 });\n\nH.legend([\n { label: \"partial sum S_N(x)\", color: H.colors.accent2 },\n { label: \"individual harmonics\", color: H.hsl(214, 70, 60) },\n { label: \"ideal square wave\", color: H.colors.grid },\n], 24, h - 70);" + }, + { + "id": "two-source-interference-field", + "title": "Two-Source Interference (Ripple Field)", + "tag": "Waves & Optics", + "dimension": "3D", + "equation": "u(x,z,t) = A cos(k r1 - wt) + A cos(k r2 - wt)", + "summary": "Two coherent point sources emit circular waves over a plane. Their height fields add, carving the classic interference pattern of constructive ridges and destructive nodal lines into a lit, depth-sorted 3D surface you can orbit.", + "keywords": [ + "interference", + "two source", + "double slit", + "ripple tank", + "coherent sources", + "constructive", + "destructive", + "wave field", + "superposition", + "nodal lines", + "fringes", + "diffraction", + "huygens", + "circular waves" + ], + "bullets": [ + "Where the two path lengths differ by a whole wavelength the crests add (constructive ridges); a half-wavelength difference cancels them (nodal valleys).", + "Each source radiates expanding circular wavefronts; the spheres mark the two coherent emitters bobbing in phase.", + "The fixed pattern of ridges and troughs is set by the source spacing and the wavenumber k, while the whole field oscillates at angular frequency w." + ], + "student_prompts": [ + "How does the spacing between the two sources change the number of bright fringes?", + "What is the path-difference condition for a constructive vs destructive point?", + "How does this 2D ripple pattern connect to Young's double-slit experiment?" + ], + "code": "H.background();\nconst cam = H.cam3d({ scale: 30, dist: 17, pitch: -0.62, cy: H.H * 0.56 });\ncam.yaw = 0.28 * t;\n\nconst k = 2.2;\nconst omega = 3.0;\nconst sx1 = -2.0, sz1 = 0.0;\nconst sx2 = 2.0, sz2 = 0.0;\nconst amp = 0.9;\n\ncam.grid(6, 1.5);\n\nH.surface3d(cam, (x, z) => {\n const r1 = Math.sqrt((x - sx1) * (x - sx1) + (z - sz1) * (z - sz1)) + 1e-6;\n const r2 = Math.sqrt((x - sx2) * (x - sx2) + (z - sz2) * (z - sz2)) + 1e-6;\n const w1 = amp * Math.cos(k * r1 - omega * t) / (1 + 0.35 * r1);\n const w2 = amp * Math.cos(k * r2 - omega * t) / (1 + 0.35 * r2);\n return (w1 + w2) * 1.6;\n}, { xMin: -6, xMax: 6, yMin: -6, yMax: 6, nx: 44, ny: 44, hueMin: 205, hueMax: 320 });\n\ncam.axes(6);\n\nconst s1 = [sx1, amp * Math.cos(-omega * t) * 1.6 + 0.2, sz1];\nconst s2 = [sx2, amp * Math.cos(-omega * t) * 1.6 + 0.2, sz2];\n[{ p: s1, c: H.colors.yellow }, { p: s2, c: H.colors.good }]\n .map(o => ({ p: o.p, c: o.c, d: cam.project(o.p).depth }))\n .sort((a, b) => b.d - a.d)\n .forEach(o => cam.sphere(o.p, 0.28, { color: o.c }));\n\nconst dt = (2 * H.PI) / omega;\nH.text(\"Two-source interference\", 24, 32, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Two coherent sources -> crests reinforce, troughs cancel.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"k = \" + k.toFixed(1) + \" period T = \" + dt.toFixed(2) + \" s\", 24, 78, { color: H.colors.accent, size: 14, weight: 600 });\nH.text(\"phase = \" + ((omega * t) % (2 * H.PI)).toFixed(2) + \" rad\", 24, 98, { color: H.colors.sub, size: 13 });\n\nH.legend([\n { label: \"source A\", color: H.colors.yellow },\n { label: \"source B\", color: H.colors.good },\n { label: \"combined wave field\", color: H.hsl(260, 72, 60) },\n], 24, H.H - 70);" + }, + { + "id": "wave-packet-phase-group-velocity", + "title": "Wave Packet: Phase vs Group Velocity", + "tag": "Waves & Optics", + "dimension": "2D", + "equation": "u(x,t) = exp(-(x-vg t)^2 / 2s^2) * cos(k0 x - w0 t)", + "summary": "A Gaussian-enveloped wave packet travels to the right. A tracked carrier crest races forward at the phase velocity vp = w/k while the whole envelope (and the energy it carries) drifts more slowly at the group velocity vg, making the distinction concrete.", + "keywords": [ + "wave packet", + "wavepacket", + "phase velocity", + "group velocity", + "dispersion", + "carrier", + "envelope", + "gaussian envelope", + "modulation", + "beat", + "pulse", + "quantum wave packet", + "de broglie", + "vp vg" + ], + "bullets": [ + "The fast pink dot is a single carrier crest moving at the phase velocity vp = w0/k0; individual crests outrun the pulse.", + "The green dot tracks the envelope peak, which moves at the slower group velocity vg = dw/dk and carries the packet's energy.", + "Crests visibly appear at the back of the envelope and vanish at the front because the carrier and the envelope travel at different speeds." + ], + "student_prompts": [ + "Why does the energy of the pulse travel at the group velocity and not the phase velocity?", + "What happens to the packet's shape over time when the medium is dispersive?", + "How do phase and group velocity relate to a de Broglie matter wave in quantum mechanics?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n\nconst k0 = 6.0;\nconst omega0 = 9.0;\nconst vg = 0.8;\nconst vp = omega0 / k0;\nconst sigma = 1.4;\n\nconst v = H.plot2d({ xMin: -9, xMax: 9, yMin: -1.5, yMax: 1.5, pad: 54 });\nv.grid({ stepX: 3 });\nv.axes({ stepX: 3, stepY: 0.5 });\n\nconst span = 16;\nlet xc = (-7 + vg * t) % span;\nif (xc > 9) xc -= span;\n\nconst env = (x) => Math.exp(-((x - xc) * (x - xc)) / (2 * sigma * sigma));\nconst packet = (x) => env(x) * Math.cos(k0 * x - omega0 * t);\n\nv.fn(x => env(x), { color: H.hsl(150, 70, 55, 0.45), width: 2 });\nv.fn(x => -env(x), { color: H.hsl(150, 70, 55, 0.45), width: 2 });\nv.fn(packet, { color: H.colors.accent, width: 3 });\n\nconst m = Math.round((k0 * xc - omega0 * t) / (2 * H.PI));\nconst crestX = (2 * H.PI * m + omega0 * t) / k0;\nif (crestX > -9 && crestX < 9) {\n v.dot(crestX, packet(crestX), { r: 6, fill: H.colors.warn });\n v.text(\"crest (vp)\", crestX + 0.2, packet(crestX) + 0.28, { color: H.colors.warn, size: 12 });\n}\nv.dot(xc, 1.0, { r: 6, fill: H.colors.good });\nv.text(\"envelope (vg)\", xc + 0.2, 1.18, { color: H.colors.good, size: 12 });\n\nH.text(\"Wave packet: phase vs group velocity\", 24, 32, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Carrier crests slide through a slower-moving Gaussian envelope.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"vp = w/k = \" + vp.toFixed(2) + \" vg = \" + vg.toFixed(2), 24, 78, { color: H.colors.accent, size: 14, weight: 600 });\nH.text(\"envelope center xc = \" + xc.toFixed(2), 24, 98, { color: H.colors.sub, size: 13 });\n\nH.legend([\n { label: \"wave packet\", color: H.colors.accent },\n { label: \"Gaussian envelope\", color: H.hsl(150, 70, 55) },\n { label: \"carrier crest (vp)\", color: H.colors.warn },\n { label: \"envelope peak (vg)\", color: H.colors.good },\n], 24, h - 90);" + }, + { + "id": "bubble-sort-bars", + "title": "Bubble Sort on Bars", + "tag": "Algorithms", + "dimension": "2D", + "equation": "if a[j] > a[j+1] then swap; O(n^2) comparisons", + "summary": "A bar chart of 9 values runs the full bubble sort one comparison at a time. Adjacent bars are highlighted as they are compared, flash red on a swap, and the already-sorted tail locks in green as larger values bubble to the right.", + "keywords": [ + "bubble sort", + "sorting algorithm", + "insertion sort", + "sort bars", + "comparison swap", + "adjacent elements", + "array sorting", + "o(n^2)", + "quadratic sort", + "ascending order", + "in-place sort", + "passes", + "swap animation" + ], + "bullets": [ + "Each pass compares neighbors left to right; the largest unsorted value bubbles to its final spot on the right.", + "Yellow = comparing, red = swapping, green = locked into the sorted tail.", + "The comparison counter and pass number make the O(n^2) cost concrete as the array converges." + ], + "student_prompts": [ + "Why is bubble sort O(n^2) in the worst case but O(n) on an already-sorted array?", + "How does insertion sort differ from bubble sort step by step?", + "Which sorting algorithm would you use for nearly-sorted data and why?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst base = [5, 2, 8, 1, 9, 4, 7, 3, 6];\nconst n = base.length;\nconst steps = [];\nconst a = base.slice();\nsteps.push({ arr: a.slice(), i: -1, j: -1, swap: false, done: false });\nfor (let i = 0; i < n - 1; i++) {\n for (let j = 0; j < n - 1 - i; j++) {\n const swap = a[j] > a[j + 1];\n if (swap) { const tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; }\n steps.push({ arr: a.slice(), i: i, j: j, swap: swap, done: false });\n }\n}\nsteps.push({ arr: a.slice(), i: n, j: -1, swap: false, done: true });\nconst total = steps.length;\nconst period = total * 0.55 + 2.2;\nconst tt = t % period;\nlet idx = Math.min(total - 1, Math.floor(tt / 0.55));\nif (tt > total * 0.55) idx = total - 1;\nconst step = steps[idx];\nconst arr = step.arr;\nconst maxV = 9;\nconst box = { x: 60, y: 110, w: w - 120, h: h - 190 };\nconst bw = box.w / n;\nconst sortedFrom = step.done ? 0 : n - 1 - step.i;\nfor (let k = 0; k < n; k++) {\n const val = arr[k];\n const bh = H.map(val, 0, maxV, 8, box.h);\n const bx = box.x + k * bw + bw * 0.12;\n const by = box.y + box.h - bh;\n const bwi = bw * 0.76;\n let fill = H.colors.accent;\n if (!step.done && (k === step.j || k === step.j + 1)) {\n fill = step.swap ? H.colors.warn : H.colors.yellow;\n } else if (k >= sortedFrom || step.done) {\n fill = H.colors.good;\n }\n H.rect(bx, by, bwi, bh, { fill: fill, stroke: H.colors.bg, width: 1.5, radius: 5 });\n H.text(String(val), bx + bwi / 2, by - 8,\n { color: H.colors.ink, size: 14, align: \"center\", weight: 600 });\n H.text(String(k), bx + bwi / 2, box.y + box.h + 18,\n { color: H.colors.sub, size: 11, align: \"center\" });\n}\nif (!step.done && step.j >= 0) {\n const j = step.j;\n const x1 = box.x + j * bw + bw * 0.5;\n const x2 = box.x + (j + 1) * bw + bw * 0.5;\n const yb = box.y + box.h + 30;\n H.line(x1, yb, x2, yb, { color: step.swap ? H.colors.warn : H.colors.yellow, width: 2 });\n H.text(step.swap ? \"swap\" : \"keep\", (x1 + x2) / 2, yb + 16,\n { color: step.swap ? H.colors.warn : H.colors.yellow, size: 12, align: \"center\" });\n}\nH.text(\"Bubble sort\", 24, 36, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Adjacent pairs are compared; the larger bubbles right each pass.\",\n 24, 58, { color: H.colors.sub, size: 13 });\nconst passNum = step.done ? n - 1 : step.i + 1;\nconst compares = idx;\nH.text(\"pass \" + passNum + \" / \" + (n - 1) + \" comparisons: \" + compares +\n (step.done ? \" SORTED\" : \"\"), 24, 84,\n { color: step.done ? H.colors.good : H.colors.accent2, size: 13, weight: 600 });\nH.legend([\n { label: \"comparing\", color: H.colors.yellow },\n { label: \"swapping\", color: H.colors.warn },\n { label: \"sorted\", color: H.colors.good },\n], w - 150, 120);" + }, + { + "id": "bfs-graph-traversal", + "title": "BFS Graph Traversal", + "tag": "Algorithms", + "dimension": "2D", + "equation": "frontier expands level by level; depth d(v) = d(u) + 1", + "summary": "Breadth-first search runs on a small 8-node graph from node 0. Nodes light up one at a time in queue order, the expanding node pulses, BFS tree edges thicken, and each node is annotated with its discovery rank and shortest-path depth.", + "keywords": [ + "bfs", + "breadth first search", + "graph traversal", + "dfs", + "depth first search", + "queue", + "fifo", + "shortest path", + "level order", + "graph algorithm", + "adjacency list", + "node visit order", + "frontier", + "tree edges" + ], + "bullets": [ + "A FIFO queue guarantees nodes are discovered in increasing distance from the source, so depth d equals the shortest hop count.", + "The thick blue edges form the BFS tree; thin gray edges are non-tree (cross) edges.", + "The #rank label is queue/visit order while d= is the level — BFS explores all of one level before the next." + ], + "student_prompts": [ + "How would the visit order change if this were DFS with a stack instead of BFS with a queue?", + "Why does BFS find shortest paths in unweighted graphs but not in weighted ones?", + "What is the time complexity of BFS in terms of vertices V and edges E?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst nodes = [\n { id: 0, x: -7, y: 3 },\n { id: 1, x: -3, y: 5 },\n { id: 2, x: -3, y: 1 },\n { id: 3, x: 1, y: 5 },\n { id: 4, x: 1, y: 1 },\n { id: 5, x: 5, y: 3.5 },\n { id: 6, x: 5, y: 0 },\n { id: 7, x: 8, y: 2 },\n];\nconst edges = [\n [0, 1], [0, 2], [1, 3], [2, 4], [3, 5], [4, 5], [4, 6], [5, 7], [6, 7],\n];\nconst adj = nodes.map(() => []);\nedges.forEach(([u, v]) => { adj[u].push(v); adj[v].push(u); });\nadj.forEach((l) => l.sort((p, q) => p - q));\nconst start = 0;\nconst visited = nodes.map(() => false);\nconst depth = nodes.map(() => -1);\nconst order = [];\nconst queue = [start];\nvisited[start] = true; depth[start] = 0;\nlet guard = 0;\nwhile (queue.length && guard++ < 200) {\n const u = queue.shift();\n order.push(u);\n for (const v of adj[u]) {\n if (!visited[v]) { visited[v] = true; depth[v] = depth[u] + 1; queue.push(v); }\n }\n}\nconst total = order.length;\nconst period = total * 0.7 + 2.5;\nconst tt = t % period;\nconst revealed = Math.min(total, Math.floor(tt / 0.7) + 1);\nconst frontIdx = revealed - 1;\nconst frontNode = order[Math.max(0, Math.min(total - 1, frontIdx))];\nconst visOrder = new Array(nodes.length).fill(-1);\nfor (let k = 0; k < revealed; k++) visOrder[order[k]] = k;\nconst view = H.plot2d({ xMin: -9, xMax: 10, yMin: -1.5, yMax: 6.5, pad: 40 });\nview.grid({ color: H.colors.grid });\nview.axes({ ticks: true });\nedges.forEach(([u, v]) => {\n const tree = visOrder[u] >= 0 && visOrder[v] >= 0 &&\n (depth[u] + 1 === depth[v] || depth[v] + 1 === depth[u]);\n view.line(nodes[u].x, nodes[u].y, nodes[v].x, nodes[v].y,\n { color: tree ? H.colors.accent : H.colors.grid, width: tree ? 2.6 : 1.4 });\n});\nnodes.forEach((nd) => {\n const k = visOrder[nd.id];\n let fill = H.colors.panel;\n let ink = H.colors.sub;\n if (k >= 0) {\n fill = nd.id === frontNode ? H.colors.accent2 : H.colors.accent;\n ink = H.colors.bg;\n }\n const px = view.X(nd.x), py = view.Y(nd.y);\n let r = 18;\n if (nd.id === frontNode && k >= 0) r = 18 + 3 * Math.sin(t * 6);\n H.circle(px, py, r, { fill: fill, stroke: H.colors.bg, width: 2 });\n H.text(String(nd.id), px, py + 5, { color: ink, size: 15, align: \"center\", weight: 700 });\n if (k >= 0) {\n H.text(\"d=\" + depth[nd.id], px, py - r - 6,\n { color: H.colors.sub, size: 11, align: \"center\" });\n H.text(\"#\" + (k + 1), px, py + r + 14,\n { color: H.colors.violet, size: 11, align: \"center\" });\n }\n});\nH.text(\"Breadth-first search (BFS)\", 24, 36, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"A FIFO queue explores the graph level by level from node 0.\",\n 24, 58, { color: H.colors.sub, size: 13 });\nconst visitedStr = order.slice(0, revealed).join(\" → \");\nH.text(\"visit order: \" + visitedStr, 24, 84,\n { color: H.colors.accent2, size: 13, weight: 600 });\nconst maxD = revealed > 0 ? depth[frontNode] : 0;\nH.text(\"discovered \" + revealed + \" / \" + nodes.length +\n \" nodes current depth: \" + maxD, 24, h - 22,\n { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"expanding\", color: H.colors.accent2 },\n { label: \"discovered\", color: H.colors.accent },\n { label: \"unseen\", color: H.colors.panel },\n], w - 150, 120);" + }, + { + "id": "recursion-tree-fibonacci", + "title": "Recursion Tree: fib(5)", + "tag": "Algorithms", + "dimension": "2D", + "equation": "fib(n) = fib(n-1) + fib(n-2), fib(0)=0, fib(1)=1", + "summary": "The full call tree of naive recursive Fibonacci fib(5) is drawn, then animated in post-order so you watch leaf values return and sums bubble up the tree. Repeated identical subtrees make the exponential blow-up of naive recursion visible.", + "keywords": [ + "recursion tree", + "recursion", + "fibonacci", + "call stack", + "divide and conquer", + "post-order", + "tree traversal", + "exponential time", + "memoization", + "dynamic programming", + "base case", + "subproblems", + "recursive calls", + "call tree" + ], + "bullets": [ + "Each node is a call fib(n); the value returned appears only once the call resolves, mirroring how recursion unwinds bottom-up.", + "Returns happen in post-order: both children must finish before a parent can sum them.", + "Notice fib(2) and fib(3) appear in multiple places — recomputing them is exactly why naive recursion is exponential and memoization helps." + ], + "student_prompts": [ + "How many total calls does naive fib(n) make, and why is it roughly the golden-ratio raised to n?", + "How would memoization or dynamic programming change this recursion tree?", + "What is the maximum depth of the call stack while computing fib(5)?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nlet counter = 0;\nfunction build(n, depth) {\n const node = { n: n, depth: depth, id: counter++, children: [], val: 0 };\n if (n < 2) { node.val = n; return node; }\n node.left = build(n - 1, depth + 1);\n node.right = build(n - 2, depth + 1);\n node.children = [node.left, node.right];\n node.val = node.left.val + node.right.val;\n return node;\n}\nconst root = build(5, 0);\nconst all = [];\nlet leafX = 0;\nfunction layout(node) {\n if (node.children.length === 0) { node.ux = leafX++; }\n else {\n node.children.forEach(layout);\n node.ux = (node.left.ux + node.right.ux) / 2;\n }\n all.push(node);\n}\nlayout(root);\nconst maxDepth = all.reduce((m, nd) => Math.max(m, nd.depth), 0);\nconst leaves = leafX;\nconst finishOrder = [];\nfunction postorder(node) {\n node.children.forEach(postorder);\n finishOrder.push(node.id);\n}\npostorder(root);\nconst total = finishOrder.length;\nconst period = total * 0.45 + 2.4;\nconst tt = t % period;\nconst resolved = Math.min(total, Math.floor(tt / 0.45) + 1);\nconst doneSet = new Array(counter).fill(false);\nfor (let k = 0; k < resolved; k++) doneSet[finishOrder[k]] = true;\nconst activeId = finishOrder[Math.max(0, Math.min(total - 1, resolved - 1))];\nconst box = { x: 50, y: 120, w: w - 100, h: h - 180 };\nconst px = (ux) => box.x + (leaves <= 1 ? box.w / 2 : H.map(ux, 0, leaves - 1, 0, box.w));\nconst py = (d) => box.y + (maxDepth === 0 ? 0 : H.map(d, 0, maxDepth, 0, box.h));\nall.forEach((nd) => {\n nd.children.forEach((c) => {\n const lit = doneSet[c.id];\n H.line(px(nd.ux), py(nd.depth), px(c.ux), py(c.depth),\n { color: lit ? H.colors.good : H.colors.grid, width: lit ? 2.4 : 1.3 });\n });\n});\nall.forEach((nd) => {\n const x = px(nd.ux), y = py(nd.depth);\n const done = doneSet[nd.id];\n let fill = H.colors.panel;\n let ink = H.colors.sub;\n if (done) { fill = H.colors.good; ink = H.colors.bg; }\n if (nd.id === activeId && done) { fill = H.colors.accent2; ink = H.colors.bg; }\n let r = 16;\n if (nd.id === activeId && done) r = 16 + 2.5 * Math.sin(t * 6);\n H.circle(x, y, r, { fill: fill, stroke: H.colors.bg, width: 2 });\n H.text(\"f\" + nd.n, x, y + 1, { color: ink, size: 13, align: \"center\", weight: 700 });\n if (done) {\n H.text(\"=\" + nd.val, x, y + r + 13,\n { color: H.colors.accent, size: 11, align: \"center\", weight: 600 });\n }\n});\nH.text(\"Recursion tree: fib(5)\", 24, 36, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"fib(n) = fib(n-1) + fib(n-2). Leaves return; values bubble up post-order.\",\n 24, 58, { color: H.colors.sub, size: 13 });\nconst rootVal = doneSet[root.id] ? root.val : \"...\";\nH.text(\"calls resolved: \" + resolved + \" / \" + total +\n \" fib(5) = \" + rootVal, 24, 84,\n { color: doneSet[root.id] ? H.colors.good : H.colors.accent2, size: 13, weight: 600 });\nH.text(\"Repeated subtrees (e.g. fib(2)) show why naive recursion is exponential.\",\n 24, h - 22, { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"returning now\", color: H.colors.accent2 },\n { label: \"resolved\", color: H.colors.good },\n { label: \"pending\", color: H.colors.panel },\n], w - 160, 120);" + }, + { + "id": "projectile-motion-velocity-vectors", + "title": "Projectile Motion: Velocity Vectors", + "tag": "Classical Mechanics", + "dimension": "2D", + "equation": "x = v0 cos(theta) t, y = v0 sin(theta) t - 1/2 g t^2", + "summary": "A ball is launched at a fixed speed and angle and flies along a parabola. Live arrows show the constant horizontal velocity, the shrinking-then-growing vertical velocity, and their resultant as the ball traces the trajectory.", + "keywords": [ + "projectile", + "projectile motion", + "parabola", + "parabolic trajectory", + "velocity vector", + "velocity components", + "kinematics", + "launch angle", + "range", + "apex", + "gravity", + "ballistics", + "horizontal velocity", + "vertical velocity" + ], + "bullets": [ + "Horizontal velocity vx stays constant for the whole flight; only vy changes.", + "vy decreases, hits zero at the apex, then reverses sign on the way down.", + "The resultant speed |v| is minimum at the top and equal at matching heights." + ], + "student_prompts": [ + "How does the launch angle that maximizes range come out to 45 degrees?", + "What happens to the trajectory if I double the initial speed?", + "Why does air resistance break the symmetry of the parabola?" + ], + "code": "H.background();\n const g = 9.8;\n const v0 = 22;\n const ang = 58 * Math.PI / 180;\n const vx0 = v0 * Math.cos(ang);\n const vy0 = v0 * Math.sin(ang);\n const tFlight = (2 * vy0) / g;\n const range = vx0 * tFlight;\n const apex = (vy0 * vy0) / (2 * g);\n const xMax = Math.max(range * 1.12, 1);\n const yMax = Math.max(apex * 1.45, 1);\n const view = H.plot2d({ xMin: -xMax * 0.05, xMax: xMax, yMin: -yMax * 0.08, yMax: yMax, pad: 52 });\n view.grid();\n view.axes();\n\n const arcPts = [];\n const steps = 90;\n for (let i = 0; i <= steps; i++) {\n const tt = (i / steps) * tFlight;\n const x = vx0 * tt;\n const y = vy0 * tt - 0.5 * g * tt * tt;\n if (y >= -1e-6) arcPts.push([x, y]);\n }\n view.path(arcPts, { color: H.colors.grid, width: 2, dash: [6, 6] });\n\n const period = tFlight + 0.6;\n const tau = (t % period);\n const ct = Math.min(tau, tFlight);\n const px = vx0 * ct;\n const py = Math.max(0, vy0 * ct - 0.5 * g * ct * ct);\n const cvx = vx0;\n const cvy = vy0 - g * ct;\n const speed = Math.sqrt(cvx * cvx + cvy * cvy);\n\n const trail = [];\n for (let i = 0; i <= 40; i++) {\n const tt = ct * (i / 40);\n const x = vx0 * tt;\n const y = Math.max(0, vy0 * tt - 0.5 * g * tt * tt);\n trail.push([x, y]);\n }\n view.path(trail, { color: H.colors.accent, width: 3 });\n\n const vscale = (range * 0.012);\n view.arrow(px, py, px + cvx * vscale, py, { color: H.colors.good, width: 2.4, head: 9 });\n view.arrow(px, py, px, py + cvy * vscale, { color: H.colors.warn, width: 2.4, head: 9 });\n view.arrow(px, py, px + cvx * vscale, py + cvy * vscale, { color: H.colors.yellow, width: 2.8, head: 10 });\n\n view.line(-xMax * 0.05, 0, xMax, 0, { color: H.colors.axis, width: 2 });\n view.dot(range * 0.5, apex, { r: 4, fill: H.colors.violet, stroke: H.colors.bg });\n view.text(\"apex \" + apex.toFixed(1) + \" m\", range * 0.5, apex + yMax * 0.06, { color: H.colors.violet, size: 12, align: \"center\" });\n\n view.circle(px, py, 8, { fill: H.colors.accent2, stroke: H.colors.bg, width: 2 });\n\n H.text(\"Projectile Motion: velocity vectors\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\n H.text(\"v0 = \" + v0 + \" m/s at \" + (ang * 180 / Math.PI).toFixed(0) + \" deg. vx is constant; vy flips sign at the top.\", 24, 52, { color: H.colors.sub, size: 13 });\n H.text(\"t = \" + ct.toFixed(2) + \" s |v| = \" + speed.toFixed(1) + \" m/s\", 24, 76, { color: H.colors.sub, size: 13 });\n H.text(\"vx = \" + cvx.toFixed(1) + \" vy = \" + cvy.toFixed(1) + \" m/s range = \" + range.toFixed(1) + \" m\", 24, 94, { color: H.colors.sub, size: 13 });\n\n H.legend([\n { label: \"vx (horizontal)\", color: H.colors.good },\n { label: \"vy (vertical)\", color: H.colors.warn },\n { label: \"v (resultant)\", color: H.colors.yellow }\n ], H.W - 190, 84);\n\n view.text(\"x (m)\", xMax * 0.92, -yMax * 0.04, { color: H.colors.sub, size: 12 });\n view.text(\"y (m)\", xMax * 0.015, yMax * 0.95, { color: H.colors.sub, size: 12 });" + }, + { + "id": "simple-harmonic-motion-spring", + "title": "Simple Harmonic Motion: Mass on a Spring", + "tag": "Classical Mechanics", + "dimension": "2D", + "equation": "x(t) = A cos(omega t), omega = sqrt(k/m)", + "summary": "A mass oscillates on a frictionless spring while a synchronized x(t) cosine curve and energy bars show kinetic and spring potential energy trading off so the total stays constant.", + "keywords": [ + "simple harmonic motion", + "shm", + "mass on spring", + "spring oscillator", + "hooke's law", + "oscillation", + "sinusoidal motion", + "angular frequency", + "period", + "amplitude", + "kinetic energy", + "potential energy", + "energy conservation", + "restoring force" + ], + "bullets": [ + "The restoring force F = -kx gives sinusoidal motion x = A cos(omega t).", + "Angular frequency omega = sqrt(k/m): stiffer spring or lighter mass means faster.", + "Energy continuously swaps between spring PE and kinetic KE while the total E is fixed." + ], + "student_prompts": [ + "Why is the period independent of the amplitude in ideal SHM?", + "How would adding damping change the x(t) curve over time?", + "Where in the cycle are the speed and the acceleration each largest?" + ], + "code": "H.background();\n const w = H.W, h = H.H;\n\n const A = 1.6;\n const k = 8;\n const m = 1.2;\n const omega = Math.sqrt(k / m);\n const x = A * Math.cos(omega * t);\n const vel = -A * omega * Math.sin(omega * t);\n const PE = 0.5 * k * x * x;\n const KE = 0.5 * m * vel * vel;\n const E = PE + KE;\n\n const wallX = w * 0.10;\n const eqX = w * 0.42;\n const pxPerM = (w * 0.20);\n const massY = h * 0.40;\n const massX = eqX + x * pxPerM;\n\n H.rect(wallX - 14, massY - 70, 14, 140, { fill: H.colors.panel, stroke: H.colors.axis, width: 1.5 });\n for (let i = 0; i < 7; i++) {\n H.line(wallX - 14, massY - 70 + i * 22, wallX, massY - 70 + i * 22 + 12, { color: H.colors.axis, width: 1 });\n }\n\n const coils = 14;\n const sp = [];\n sp.push([wallX, massY]);\n for (let i = 0; i <= coils; i++) {\n const s = i / coils;\n const sx = wallX + s * (massX - wallX);\n const sy = massY + (i === 0 || i === coils ? 0 : (i % 2 === 0 ? -14 : 14));\n sp.push([sx, sy]);\n }\n sp.push([massX, massY]);\n H.path(sp, { color: H.colors.accent, width: 2.4 });\n\n H.line(eqX, massY - 78, eqX, massY + 78, { color: H.colors.grid, width: 1.5, dash: [5, 5] });\n H.text(\"x = 0\", eqX, massY + 96, { color: H.colors.sub, size: 12, align: \"center\" });\n\n const va = vel * pxPerM * 0.18;\n if (Math.abs(va) > 1) H.arrow(massX, massY - 44, massX + va, massY - 44, { color: H.colors.good, width: 2.4, head: 9 });\n\n H.rect(massX - 24, massY - 24, 48, 48, { fill: H.colors.accent2, stroke: H.colors.bg, width: 2, radius: 6 });\n H.text(\"m\", massX, massY + 6, { color: H.colors.bg, size: 16, weight: 700, align: \"center\" });\n\n H.arrow(eqX, massY + 64, massX, massY + 64, { color: H.colors.violet, width: 2, head: 8 });\n H.text(\"x = \" + x.toFixed(2) + \" m\", (eqX + massX) / 2, massY + 58, { color: H.colors.violet, size: 12, align: \"center\" });\n\n const gx = w * 0.66, gy = h * 0.16, gw = w * 0.30, gh = h * 0.34;\n const view = H.plot2d({ box: { x: gx, y: gy, w: gw, h: gh }, xMin: 0, xMax: 8, yMin: -A * 1.3, yMax: A * 1.3 });\n view.grid({ stepX: 2, stepY: 1 });\n view.axes({ stepX: 2, stepY: 1 });\n view.fn((tt) => A * Math.cos(omega * (t - (8 - tt))), { color: H.colors.accent, width: 2.4 });\n view.dot(8, x, { r: 5, fill: H.colors.accent2, stroke: H.colors.bg });\n H.text(\"x(t) = A cos(omega t)\", gx, gy - 8, { color: H.colors.sub, size: 12 });\n\n const bx = w * 0.66, by = h * 0.62, bw = w * 0.30, bh = h * 0.22;\n H.rect(bx, by, bw, bh, { stroke: H.colors.grid, width: 1 });\n const maxE = 0.5 * k * A * A + 1e-9;\n const peW = (PE / maxE) * bw;\n const keW = (KE / maxE) * bw;\n H.rect(bx, by + 6, peW, bh * 0.36, { fill: H.colors.warn, radius: 3 });\n H.rect(bx, by + bh * 0.52, keW, bh * 0.36, { fill: H.colors.good, radius: 3 });\n H.text(\"PE = \" + PE.toFixed(2) + \" J\", bx + 6, by + bh * 0.30, { color: H.colors.ink, size: 12 });\n H.text(\"KE = \" + KE.toFixed(2) + \" J\", bx + 6, by + bh * 0.78, { color: H.colors.ink, size: 12 });\n\n const T = (2 * Math.PI) / omega;\n H.text(\"Simple Harmonic Motion: mass on a spring\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\n H.text(\"Frictionless oscillator. Energy sloshes between spring PE and kinetic KE; total stays fixed.\", 24, 52, { color: H.colors.sub, size: 13 });\n H.text(\"omega = \" + omega.toFixed(2) + \" rad/s T = \" + T.toFixed(2) + \" s\", 24, 76, { color: H.colors.sub, size: 13 });\n H.text(\"x = \" + x.toFixed(2) + \" m v = \" + vel.toFixed(2) + \" m/s E = \" + E.toFixed(2) + \" J\", 24, 94, { color: H.colors.sub, size: 13 });\n\n H.legend([\n { label: \"spring PE\", color: H.colors.warn },\n { label: \"kinetic KE\", color: H.colors.good },\n { label: \"velocity\", color: H.colors.good }\n ], 24, h - 70);" + }, + { + "id": "pendulum-phase-space", + "title": "Pendulum Phase Space", + "tag": "Classical Mechanics", + "dimension": "2D", + "equation": "theta'' = -(g/L) sin(theta)", + "summary": "A large-amplitude pendulum swings on the right while its state (angle, angular velocity) traces a closed energy orbit in the phase plane on the left, showing how one conserved energy pins the motion to a single loop.", + "keywords": [ + "pendulum", + "phase space", + "phase portrait", + "phase plane", + "angular velocity", + "nonlinear oscillator", + "energy orbit", + "theta omega", + "state space", + "conservation of energy", + "anharmonic", + "simple pendulum", + "dynamical system", + "limit cycle" + ], + "bullets": [ + "A point in phase space is a full state: angle theta and angular velocity omega.", + "Conserved energy confines the pendulum to one closed loop (libration orbit).", + "At large angles the motion is anharmonic: the orbit is not a perfect ellipse." + ], + "student_prompts": [ + "What does the phase orbit look like if the pendulum has enough energy to go over the top?", + "How does adding friction turn the closed loop into an inward spiral?", + "Why does the period grow as the swing amplitude increases?" + ], + "code": "H.background();\n const w = H.W, h = H.H;\n\n const g = 9.8, L = 1.0;\n const w2 = g / L;\n const theta0 = 2.3;\n let th = theta0, om = 0;\n const dt = 1 / 240;\n const N = Math.min(Math.floor(t * 240), 6000);\n for (let i = 0; i < N; i++) {\n const a1 = -w2 * Math.sin(th);\n const omh = om + a1 * dt * 0.5;\n const thh = th + om * dt * 0.5;\n const a2 = -w2 * Math.sin(thh);\n om = om + a2 * dt;\n th = th + omh * dt;\n }\n let thW = th;\n while (thW > Math.PI) thW -= 2 * Math.PI;\n while (thW < -Math.PI) thW += 2 * Math.PI;\n\n const gx = w * 0.07, gy = h * 0.16, gw = w * 0.50, gh = h * 0.72;\n const omMax = Math.sqrt(2 * w2 * (1 - Math.cos(theta0))) * 1.25 + 0.5;\n const view = H.plot2d({ box: { x: gx, y: gy, w: gw, h: gh }, xMin: -Math.PI, xMax: Math.PI, yMin: -omMax, yMax: omMax });\n view.grid({ stepX: 1, stepY: 1 });\n view.axes({ stepX: 1, stepY: 1 });\n\n const upper = [], lower = [];\n const M = 160;\n for (let i = 0; i <= M; i++) {\n const a = -theta0 + (2 * theta0) * (i / M);\n const val = 2 * w2 * (Math.cos(a) - Math.cos(theta0));\n const omv = val > 0 ? Math.sqrt(val) : 0;\n upper.push([a, omv]);\n lower.push([a, -omv]);\n }\n view.path(upper, { color: H.colors.violet, width: 1.6 });\n view.path(lower, { color: H.colors.violet, width: 1.6 });\n\n view.dot(thW, om, { r: 6, fill: H.colors.accent2, stroke: H.colors.bg });\n view.arrow(thW, om, thW + (om * 0.06), om + (-w2 * Math.sin(thW)) * 0.06, { color: H.colors.good, width: 2, head: 8 });\n\n H.text(\"theta (rad)\", gx + gw - 70, gy + gh + 26, { color: H.colors.sub, size: 12 });\n H.text(\"omega (rad/s)\", gx - 4, gy - 8, { color: H.colors.sub, size: 12 });\n\n const pivotX = w * 0.80, pivotY = h * 0.30;\n const Lpx = h * 0.34;\n const bobX = pivotX + Lpx * Math.sin(thW);\n const bobY = pivotY + Lpx * Math.cos(thW);\n H.line(pivotX, pivotY, pivotX, pivotY + Lpx, { color: H.colors.grid, width: 1.5, dash: [5, 5] });\n const arcPts = [];\n for (let i = 0; i <= 24; i++) {\n const a = (thW) * (i / 24);\n arcPts.push([pivotX + Lpx * 0.32 * Math.sin(a), pivotY + Lpx * 0.32 * Math.cos(a)]);\n }\n H.path(arcPts, { color: H.colors.yellow, width: 2 });\n H.line(pivotX, pivotY, bobX, bobY, { color: H.colors.sub, width: 3 });\n H.circle(pivotX, pivotY, 5, { fill: H.colors.axis });\n H.circle(bobX, bobY, 15, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\n H.text(\"theta = \" + (thW * 180 / Math.PI).toFixed(0) + \" deg\", pivotX, pivotY - 16, { color: H.colors.sub, size: 12, align: \"center\" });\n\n const KE = 0.5 * L * L * om * om;\n const PE = g * L * (1 - Math.cos(thW));\n const E = KE + PE;\n\n H.text(\"Pendulum Phase Space\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\n H.text(\"Each point is a state (theta, omega). The pendulum traces one closed loop = one conserved energy.\", 24, 52, { color: H.colors.sub, size: 13 });\n H.text(\"theta = \" + thW.toFixed(2) + \" rad omega = \" + om.toFixed(2) + \" rad/s\", 24, 76, { color: H.colors.sub, size: 13 });\n H.text(\"KE = \" + KE.toFixed(2) + \" PE = \" + PE.toFixed(2) + \" E = \" + E.toFixed(2) + \" (per unit mass)\", 24, 94, { color: H.colors.sub, size: 13 });\n\n H.legend([\n { label: \"energy orbit\", color: H.colors.violet },\n { label: \"current state\", color: H.colors.accent2 },\n { label: \"phase velocity\", color: H.colors.good }\n ], gx, gy + gh + 40);" + }, + { + "id": "derivative-tangent-slope-secant-limit", + "title": "Derivative as the Slope of the Tangent Line", + "tag": "Calculus", + "dimension": "2D", + "equation": "f'(a) = lim_{h->0} (f(a+h) - f(a)) / h", + "summary": "A point of tangency sweeps along a curve while the tangent line tracks the exact derivative. A second secant line, drawn through a and a+h, shrinks its step h toward zero so you watch the secant slope converge to the tangent slope.", + "keywords": [ + "derivative", + "tangent line", + "slope", + "secant line", + "rate of change", + "instantaneous rate", + "limit definition", + "difference quotient", + "f prime", + "calculus", + "differentiation", + "gradient of curve" + ], + "bullets": [ + "The orange dashed line is the tangent at x=a; its slope equals f'(a)=a-cos(a) for this curve.", + "The purple secant through a and a+h has slope (f(a+h)-f(a))/h, shown with a rise/run triangle.", + "As h shrinks the secant slope readout approaches the tangent slope readout: the limit definition in action." + ], + "student_prompts": [ + "Why does the secant slope approach the tangent slope as h goes to 0?", + "How would the tangent line look at a point where f'(a) = 0?", + "What does a negative derivative tell us about the shape of the curve there?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -3.4, xMax: 3.4, yMin: -3.2, yMax: 5.2, pad: 50 });\nv.grid(); v.axes();\nconst f = (x) => 0.5 * x * x - Math.sin(x) + 0.5;\nconst df = (x) => x - Math.cos(x);\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst a = 2.8 * Math.sin(t * 0.55);\nconst fa = f(a);\nconst m = df(a);\nconst tx = (x) => fa + m * (x - a);\nv.line(v.xMin, tx(v.xMin), v.xMax, tx(v.xMax), { color: H.colors.accent2, width: 2.4, dash: [7, 6] });\nconst dh = 1.2 + 1.0 * Math.cos(t * 0.55);\nconst xb = a + dh;\nconst fb = f(xb);\nconst secSlope = Math.abs(xb - a) > 1e-6 ? (fb - fa) / (xb - a) : m;\nv.line(a, fa, xb, fb, { color: H.colors.violet, width: 2 });\nv.line(a, fa, xb, fa, { color: H.colors.sub, width: 1.4, dash: [3, 4] });\nv.line(xb, fa, xb, fb, { color: H.colors.sub, width: 1.4, dash: [3, 4] });\nv.dot(a, fa, { r: 6, fill: H.colors.accent2 });\nv.dot(xb, fb, { r: 5, fill: H.colors.violet });\nv.text(\"f(x)\", a - 2.4, f(a - 2.4) + 0.9, { color: H.colors.accent, size: 14 });\nv.text(\"a\", a, fa - 0.55, { color: H.colors.ink, size: 13, align: \"center\" });\nv.text(\"a+h\", xb, fb + 0.7, { color: H.colors.violet, size: 12, align: \"center\" });\nH.text(\"Derivative = slope of the tangent line\", 22, 28, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"As h -> 0 the secant (purple) becomes the tangent (orange).\", 22, 50, { color: H.colors.sub, size: 13 });\nH.text(\"a = \" + a.toFixed(2) + \" f'(a) = \" + m.toFixed(2), 22, 78, { color: H.colors.accent2, size: 14, weight: 600 });\nH.text(\"h = \" + dh.toFixed(2) + \" secant slope = \" + secSlope.toFixed(2), 22, 98, { color: H.colors.violet, size: 14, weight: 600 });\nH.legend([\n { label: \"f(x)\", color: H.colors.accent },\n { label: \"tangent (f')\", color: H.colors.accent2 },\n { label: \"secant\", color: H.colors.violet },\n], 22, 126);" + }, + { + "id": "riemann-sum-accumulating-integral-area", + "title": "Definite Integral as Accumulating Riemann Area", + "tag": "Calculus", + "dimension": "2D", + "equation": "∫_a^b f(x) dx = lim_{N->∞} Σ f(x_i*) Δx", + "summary": "Midpoint-rule rectangles fill the area under a curve, and their count climbs from a couple to dozens. The running rectangle sum and the exact integral are shown side by side, with the gap between them visibly shrinking as N grows.", + "keywords": [ + "integral", + "definite integral", + "riemann sum", + "area under curve", + "accumulation", + "midpoint rule", + "rectangles", + "antiderivative", + "fundamental theorem", + "integration", + "numerical integration", + "summation" + ], + "bullets": [ + "Each rectangle has width Δx=(b-a)/N and midpoint height f(x_i*); their total approximates the area.", + "The function is kept nonnegative on [0,6], so every bar height and the accumulated area stay positive.", + "As N rises the sum readout converges to the exact integral and the error readout drops toward zero." + ], + "student_prompts": [ + "Why does the midpoint rule usually beat left or right Riemann sums?", + "What happens to the error if I double the number of rectangles?", + "How is this Riemann sum connected to the antiderivative of f?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -0.4, xMax: 6.6, yMin: -0.4, yMax: 3.0, pad: 52 });\nv.grid(); v.axes();\nconst f = (x) => 0.9 + 0.8 * Math.sin(0.65 * x) + 0.25 * x;\nconst A = 0, B = 6;\nconst cyc = (t * 0.12) % 1;\nconst N = Math.round(H.lerp(2, 60, H.ease(cyc)));\nconst dx = (B - A) / N;\nlet area = 0;\nfor (let i = 0; i < N; i++) {\n const xl = A + i * dx;\n const xm = xl + dx / 2;\n const hh = Math.max(0, f(xm));\n area += hh * dx;\n const shade = H.hsl(205 - 120 * (i / Math.max(1, N - 1)), 70, 58, 0.5);\n v.rect(xl, 0, dx, hh, { fill: shade, stroke: \"rgba(10,14,30,0.45)\", width: 0.8 });\n}\nv.fn(f, { color: H.colors.accent2, width: 3 });\nlet exact = 0;\nconst M = 600;\nfor (let i = 0; i < M; i++) {\n const xm = A + (i + 0.5) * (B - A) / M;\n exact += Math.max(0, f(xm)) * (B - A) / M;\n}\nconst err = Math.abs(exact - area);\nv.line(A, 0, A, f(A), { color: H.colors.sub, width: 1.4, dash: [4, 4] });\nv.line(B, 0, B, f(B), { color: H.colors.sub, width: 1.4, dash: [4, 4] });\nv.text(\"a=0\", A, -0.18, { color: H.colors.sub, size: 12, align: \"center\" });\nv.text(\"b=6\", B, -0.18, { color: H.colors.sub, size: 12, align: \"center\" });\nv.text(\"y = f(x)\", 4.4, f(4.4) + 0.35, { color: H.colors.accent2, size: 14 });\nH.text(\"Definite integral as accumulating area\", 22, 28, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Midpoint Riemann sum: more rectangles -> exact area under f.\", 22, 50, { color: H.colors.sub, size: 13 });\nH.text(\"N = \" + N + \" rectangles sum ~ \" + area.toFixed(3), 22, 78, { color: H.colors.accent, size: 14, weight: 600 });\nH.text(\"exact integral = \" + exact.toFixed(3) + \" error = \" + err.toFixed(3), 22, 98, { color: H.colors.good, size: 14, weight: 600 });" + }, + { + "id": "taylor-series-convergence-error-landscape", + "title": "Taylor Series Convergence Landscape", + "tag": "Calculus", + "dimension": "3D", + "equation": "sin(x) = Σ_{k=0}^{∞} (-1)^k x^{2k+1} / (2k+1)!", + "summary": "A lit 3D surface where horizontal position is x, depth is the number of Taylor terms N, and height is the approximation error |sin(x) - T_N(x)|. As more terms switch on, the ridges collapse toward the floor, making convergence of the Maclaurin series for sine literally a flattening landscape.", + "keywords": [ + "taylor series", + "maclaurin series", + "convergence", + "polynomial approximation", + "power series", + "sine expansion", + "error", + "remainder", + "truncation error", + "series", + "3d surface", + "approximation order" + ], + "bullets": [ + "The up axis is the error |sin(x)-T_N(x)|, which is clamped nonnegative so the surface never dips below the floor.", + "Moving back along the ground axis adds Taylor terms; the tall error ridges far from x=0 shrink as N increases.", + "The live readout tracks the error at x=pi, a hard spot far from the expansion center, falling as terms are added." + ], + "student_prompts": [ + "Why does the Taylor approximation get worse far from x=0 for a fixed number of terms?", + "How fast does the error at x=pi shrink as I add more terms?", + "What is the radius of convergence for the Taylor series of sin(x)?" + ], + "code": "H.background();\nconst cam = H.cam3d({ scale: 30, dist: 17, pitch: -0.5, cy: H.H * 0.56 });\ncam.yaw = 0.3 * t;\ncam.grid(6, 2);\nconst factorial = (n) => { let p = 1; for (let k = 2; k <= n; k++) p *= k; return p; };\nconst Nmax = 9;\nconst grow = (Math.sin(t * 0.35) * 0.5 + 0.5);\nconst frontier = 1 + Math.floor(grow * (Nmax - 1));\nconst taylorSin = (x, terms) => {\n let s = 0;\n for (let k = 0; k < terms; k++) {\n const n = 2 * k + 1;\n s += (k % 2 === 0 ? 1 : -1) * Math.pow(x, n) / factorial(n);\n }\n return s;\n};\nH.surface3d(cam, (u, w) => {\n const tt = 1 + Math.round(H.map(w, -6, 6, 0, frontier - 1));\n const approx = taylorSin(u, tt);\n let e = Math.abs(Math.sin(u) - approx);\n if (!Number.isFinite(e)) e = 6;\n return Math.min(e, 6) * 0.9;\n}, { xMin: -6, xMax: 6, yMin: -6, yMax: 6, nx: 38, ny: 24, hueMin: 25, hueMax: 200 });\ncam.axes(7);\nconst xProbe = Math.PI;\nconst errProbe = Math.abs(Math.sin(xProbe) - taylorSin(xProbe, frontier));\nH.text(\"Taylor series convergence of sin(x)\", 22, 28, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Height = |sin(x) - T_N(x)| error. As terms N grow the surface flattens to 0.\", 22, 50, { color: H.colors.sub, size: 13 });\nH.text(\"active terms N = \" + frontier + \" (degree \" + (2 * frontier - 1) + \")\", 22, 78, { color: H.colors.accent2, size: 14, weight: 600 });\nH.text(\"error at x = pi : \" + errProbe.toFixed(4), 22, 98, { color: H.colors.good, size: 14, weight: 600 });\nH.text(\"x axis -> x, ground axis -> term count N, up -> error\", 22, 118, { color: H.colors.sub, size: 12 });" + }, + { + "id": "particle-in-a-box-wavefunctions", + "title": "Particle in a Box: Stationary States", + "tag": "Quantum Mechanics", + "dimension": "2D", + "equation": "psi_n(x) = sqrt(2/L) sin(n pi x / L), E_n = n^2 pi^2 hbar^2 / (2 m L^2)", + "summary": "An infinite square well showing the n-th energy eigenstate: the real part of the wavefunction oscillates in time while its probability density stays steady, and n cycles 1 to 4 so you watch nodes appear and the energy climb as n^2.", + "keywords": [ + "particle in a box", + "infinite square well", + "wavefunction", + "probability density", + "psi squared", + "quantum well", + "standing wave", + "energy levels", + "eigenstate", + "quantum number", + "nodes", + "stationary state", + "schrodinger", + "quantum mechanics" + ], + "bullets": [ + "The wavefunction must vanish at both walls, which forces only whole-number half-wavelengths to fit — that quantization gives energies E_n proportional to n^2.", + "|psi_n(x)|^2 (the filled green curve) is the probability of finding the particle at x; it has n-1 interior nodes where the particle is never found.", + "Re[psi e^{-i w t}] (blue) oscillates in time, but the measurable probability density |psi|^2 is time-independent — that is why these are called stationary states." + ], + "student_prompts": [ + "Why does the energy grow like n^2 instead of linearly with n?", + "What happens to the spacing between energy levels if I make the box L wider?", + "How does the number of nodes relate to the quantum number n, and why?" + ], + "code": "H.background();\nconst L = 1; // box width (nm), well from x=0..L\nconst n = 1 + Math.floor((t * 0.25) % 4); // quantum number cycles 1..4\nconst hbar = 1.0545718e-34, me = 9.109e-31; // SI for energy readout\nconst Ln = L * 1e-9;\nconst E_J = (n * n * Math.PI * Math.PI * hbar * hbar) / (2 * me * Ln * Ln);\nconst E_eV = E_J / 1.602e-19;\nconst omega = 2 + 0.6 * n;\nconst phase = Math.cos(omega * t);\nconst A = Math.sqrt(2 / L); // normalization\nconst psi = (x) => A * Math.sin((n * Math.PI * x) / L);\nconst v = H.plot2d({ xMin: -0.12, xMax: 1.12, yMin: -2.4, yMax: 3.0, pad: 52 });\nv.grid({ stepX: 0.25, stepY: 1 });\nv.axes({ stepX: 0.25, stepY: 1 });\nv.line(0, -2.4, 0, 3.0, { color: H.colors.warn, width: 3 });\nv.line(L, -2.4, L, 3.0, { color: H.colors.warn, width: 3 });\nv.text(\"V=inf\", 0.0, 2.85, { color: H.colors.warn, size: 12, align: \"center\" });\nv.text(\"V=inf\", L, 2.85, { color: H.colors.warn, size: 12, align: \"center\" });\nv.text(\"V = 0\", 0.5, 0.2, { color: H.colors.sub, size: 12, align: \"center\" });\nconst fillPts = [[0, 0]];\nconst dens = [];\nconst NS = 120;\nfor (let i = 0; i <= NS; i++) {\n const x = (i / NS) * L;\n const p = psi(x);\n const d = p * p; // |psi|^2 >= 0\n dens.push([x, d]);\n fillPts.push([x, d]);\n}\nfillPts.push([L, 0]);\nv.path(fillPts, { color: \"none\", fill: \"rgba(103,232,176,0.18)\", close: true });\nv.path(dens, { color: H.colors.good, width: 2.4 });\nconst wavePts = [];\nfor (let i = 0; i <= NS; i++) {\n const x = (i / NS) * L;\n wavePts.push([x, psi(x) * phase]);\n}\nv.path(wavePts, { color: H.colors.accent, width: 3 });\nconst xb = 0.5 * L + 0.45 * L * Math.sin(t * 0.9);\nconst xbc = H.clamp(xb, 0, L);\nv.dot(xbc, psi(xbc) * phase, { r: 6, fill: H.colors.accent2 });\nconst nodes = n - 1;\nH.text(\"Particle in a box: stationary states\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Infinite square well -- psi_n(x) = sqrt(2/L)*sin(n pi x/L)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"n = \" + n + \" nodes = \" + nodes + \" E_n = \" + E_eV.toFixed(2) + \" eV\", 24, 76, { color: H.colors.yellow, size: 14, weight: 600 });\nH.text(\"Re[psi e^{-i w t}] oscillates; |psi|^2 (filled) is steady\", 24, 96, { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"psi_n(x) real part\", color: H.colors.accent },\n { label: \"|psi_n(x)|^2 probability density\", color: H.colors.good },\n { label: \"infinite walls\", color: H.colors.warn },\n], 24, H.H - 76);" + }, + { + "id": "hydrogen-orbital-3d-shape", + "title": "Hydrogen Orbital Shapes (3D)", + "tag": "Quantum Mechanics", + "dimension": "3D", + "equation": "psi_nlm(r,theta,phi) = R_nl(r) Y_lm(theta,phi); surface radius proportional to |Y_lm|", + "summary": "A rotating solid surface whose radius in each direction is proportional to the angular probability amplitude |Y_lm|, cycling through 1s, 2p_z, 3d_z2 and 3d_xy so you see how the electron cloud reshapes as the quantum numbers l and m change.", + "keywords": [ + "hydrogen orbital", + "atomic orbital", + "spherical harmonic", + "electron cloud", + "s orbital", + "p orbital", + "d orbital", + "probability cloud", + "quantum numbers", + "angular momentum", + "wavefunction shape", + "y_lm", + "mesh3d", + "3d orbital" + ], + "bullets": [ + "The distance from the nucleus to the surface in any direction shows how likely the electron is to be found heading that way — round for s, two-lobed for p, more structured for d.", + "Angular nodes (planes where the amplitude is zero) increase with the orbital angular momentum l: 0 for s, 1 for p, 2 for d.", + "The quantum number m sets how the lobes are oriented in space; the d_z2 and d_xy shapes share l=2 but point very differently." + ], + "student_prompts": [ + "What is the difference between the radial part R_nl and the angular part Y_lm of an orbital?", + "Why does a p orbital have exactly one nodal plane while a d orbital has two?", + "How does this angular shape relate to the full 3D probability cloud that includes the radial distance?" + ], + "code": "H.background();\nconst cam = H.cam3d({ scale: 46, dist: 16, pitch: -0.32, cy: H.H * 0.54 });\ncam.yaw = 0.3 * t;\ncam.grid(5, 1);\nconst orbitals = [\n { name: \"1s (l=0)\", desc: \"spherical, no angular nodes\" },\n { name: \"2pz (l=1, m=0)\", desc: \"two lobes along z, one nodal plane\" },\n { name: \"3dz2 (l=2, m=0)\", desc: \"torus + two axial lobes\" },\n { name: \"3dxy (l=2, m=2)\", desc: \"four lobes, two nodal planes\" },\n];\nconst idx = Math.floor((t / 5) % orbitals.length);\nconst orb = orbitals[idx];\nfunction angular(theta, phi) {\n if (idx === 0) return 1; // s\n if (idx === 1) return Math.abs(Math.cos(theta)); // p_z ~ cos(theta)\n if (idx === 2) { // d_z^2 ~ 3cos^2-1\n return Math.abs(3 * Math.cos(theta) * Math.cos(theta) - 1) / 2;\n }\n return Math.abs(Math.sin(theta) * Math.sin(theta) * Math.sin(2 * phi));\n}\nconst breathe = 0.9 + 0.12 * Math.sin(t * 1.6);\nconst Rmax = 3.4;\nH.mesh3d(cam, (u, v) => {\n const r = Rmax * (0.12 + 0.88 * angular(v, u)) * breathe;\n const y = r * Math.cos(v);\n const s = r * Math.sin(v);\n const x = s * Math.cos(u);\n const z = s * Math.sin(u);\n return [x, y, z];\n}, {\n uMin: 0, uMax: H.TAU, vMin: 0, vMax: Math.PI,\n nu: 40, nv: 30,\n hueMin: 280, hueMax: 200, alpha: 0.9,\n});\ncam.sphere([0, 0, 0], 0.16, { color: H.colors.warn });\ncam.axes(4);\nconst extent = (Rmax * breathe).toFixed(2);\nH.text(\"Hydrogen orbital -- angular shape |Y_lm|\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Surface radius proportional to amplitude in each direction. Drag to orbit.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"orbital: \" + orb.name, 24, 78, { color: H.colors.yellow, size: 15, weight: 600 });\nH.text(orb.desc, 24, 98, { color: H.colors.sub, size: 12 });\nH.text(\"lobe extent ~ \" + extent + \" a0 (breathing pulse)\", 24, 118, { color: H.colors.accent, size: 12 });\nH.legend([\n { label: \"nucleus (proton)\", color: H.colors.warn },\n { label: \"electron cloud lobe\", color: \"#c4a7ff\" },\n], 24, H.H - 56);" + }, + { + "id": "harmonic-oscillator-levels-collapse", + "title": "Harmonic Oscillator + Collapse", + "tag": "Quantum Mechanics", + "dimension": "2D", + "equation": "E_n = (n + 1/2) hbar omega; V(x) = (1/2) m omega^2 x^2", + "summary": "The quantum harmonic oscillator's evenly-spaced energy ladder sits inside its parabolic well; the system shimmers as a superposition of levels, then a periodic 'measurement' sweeps through and collapses it to a single randomly-chosen eigenstate whose probability hump brightens.", + "keywords": [ + "quantum harmonic oscillator", + "energy levels", + "energy ladder", + "quantized energy", + "wavefunction collapse", + "measurement", + "superposition", + "eigenstate", + "hermite polynomial", + "parabolic potential", + "zero point energy", + "quantum mechanics", + "probability density", + "collapse" + ], + "bullets": [ + "Unlike the box, the oscillator's levels E_n = (n + 1/2) hbar omega are equally spaced, and the lowest one sits at 1/2 hbar omega above the bottom — the zero-point energy.", + "Before measurement the system is a superposition: several |psi_n|^2 humps coexist and shimmer, meaning the energy is genuinely undetermined.", + "A measurement (the sweeping line) collapses the superposition to one eigenstate; its probability hump brightens and fills while the others vanish." + ], + "student_prompts": [ + "Why are the harmonic oscillator's energy levels equally spaced while the box's grow like n^2?", + "What is zero-point energy and why can't the oscillator have exactly E = 0?", + "What does it physically mean for the wavefunction to 'collapse' when we measure energy?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -0.4, yMax: 7.2, pad: 54 });\nv.grid({ stepX: 1, stepY: 1 });\nv.axes({ stepX: 1, stepY: 1 });\nv.fn((x) => 0.5 * x * x, { color: H.colors.sub, width: 2 });\nv.text(\"V(x)=0.5 x^2\", -4.2, 6.6, { color: H.colors.sub, size: 13 });\nfunction herm(n, x) {\n if (n === 0) return 1;\n if (n === 1) return 2 * x;\n let hm1 = 1, h = 2 * x;\n for (let k = 1; k < n; k++) {\n const hp = 2 * x * h - 2 * k * hm1;\n hm1 = h; h = hp;\n }\n return h;\n}\nconst fact = [1, 1, 2, 6, 24];\nfunction psi2(n, x) {\n const Hn = herm(n, x);\n const norm = 1 / Math.sqrt(Math.pow(2, n) * fact[n] * Math.sqrt(Math.PI));\n const val = norm * Hn * Math.exp(-x * x / 2);\n return val * val; // |psi|^2 >= 0\n}\nconst NMAX = 5;\nfor (let n = 0; n < NMAX; n++) {\n const E = n + 0.5;\n const xt = Math.sqrt(2 * E); // classical turning points where V=E\n v.line(-xt, E, xt, E, { color: H.colors.grid, width: 1.4, dash: [5, 5] });\n v.text(\"n=\" + n, xt + 0.15, E, { color: H.colors.sub, size: 11 });\n}\nconst cycle = 6;\nconst tc = t % cycle;\nconst measuring = tc > 3;\nconst measured = Math.floor((t / cycle)) % NMAX;\nconst collapse = measuring ? H.ease((tc - 3) / 1.2) : 0;\nfor (let n = 0; n < NMAX; n++) {\n const E = n + 0.5;\n const isPicked = n === measured;\n let amp;\n if (!measuring) {\n amp = 0.45 + 0.25 * Math.sin(t * 1.3 + n);\n amp = Math.abs(amp);\n } else {\n amp = isPicked ? (0.5 + 0.5 * collapse) : (0.5 * (1 - collapse));\n }\n amp = H.clamp(amp, 0, 1);\n if (amp < 0.02) continue;\n const col = isPicked && measuring ? H.colors.accent2 : H.colors.accent;\n const pts = [];\n const NS = 90;\n for (let i = 0; i <= NS; i++) {\n const x = -5 + (10 * i) / NS;\n const y = E + 2.2 * psi2(n, x) * amp;\n pts.push([x, y]);\n }\n const rgba = isPicked && measuring\n ? \"rgba(244,162,89,\" + (0.18 + 0.5 * collapse).toFixed(3) + \")\"\n : \"rgba(124,196,255,\" + (0.10 * amp).toFixed(3) + \")\";\n if (isPicked && measuring) {\n const fillp = [[-5, E]].concat(pts).concat([[5, E]]);\n v.path(fillp, { color: \"none\", fill: rgba, close: true });\n }\n v.path(pts, { color: col, width: isPicked && measuring ? 3 : 1.6 });\n}\nif (measuring) {\n const mx = -4 + 8 * H.ease((tc - 3) / 3);\n v.line(mx, -0.4, mx, 7.2, { color: H.colors.violet, width: 1.4, dash: [3, 6] });\n}\nconst Emeas = (measured + 0.5);\nH.text(\"Quantum harmonic oscillator + collapse\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"E_n = (n + 0.5) hbar omega -- equally spaced ladder in V(x)=0.5 m omega^2 x^2\", 24, 52, { color: H.colors.sub, size: 13 });\nconst stateTxt = measuring\n ? \"MEASURED -> collapsed to n=\" + measured + \", E=\" + Emeas.toFixed(1) + \" hbar omega\"\n : \"SUPERPOSITION of levels (shimmering)\";\nH.text(stateTxt, 24, 76, { color: measuring ? H.colors.accent2 : H.colors.violet, size: 14, weight: 600 });\nH.text(\"phase: \" + (measuring ? \"after measurement\" : \"before measurement\"), 24, 96, { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"potential V(x)\", color: H.colors.sub },\n { label: \"|psi_n|^2 superposition\", color: H.colors.accent },\n { label: \"collapsed eigenstate\", color: H.colors.accent2 },\n], 24, H.H - 76);" + }, + { + "id": "unit-circle-sine-cosine", + "title": "Unit Circle Generating Sine & Cosine", + "tag": "Trigonometry", + "dimension": "2D", + "equation": "x = cos(theta), y = sin(theta), sin^2(theta) + cos^2(theta) = 1", + "summary": "A point sweeps around the unit circle while its vertical height unwraps into the sine curve and its horizontal shadow unwraps into the cosine curve, with a dashed tie-line linking the rotating point to the moving graph dots.", + "keywords": [ + "unit circle", + "sine", + "cosine", + "sin", + "cos", + "trigonometry", + "trig", + "angle", + "theta", + "radians", + "wave", + "sinusoid", + "periodic", + "circular motion" + ], + "bullets": [ + "The height of the point on the circle IS sin(theta); its horizontal shadow IS cos(theta).", + "As theta grows past 2pi the same values repeat, which is why sine and cosine are periodic.", + "The orange (cos) and green (sin) legs form a right triangle whose hypotenuse is the radius 1, so sin^2 + cos^2 = 1 every frame." + ], + "student_prompts": [ + "Why does cosine lead sine by a quarter turn on the graph?", + "How would the curves change if the circle had radius 2 instead of 1?", + "Show me how tan(theta) relates to this same picture." + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nH.text(\"Unit Circle - generating sine & cosine\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"As the angle theta sweeps, the point's height traces sin and its shadow traces cos.\", 24, 54, { color: H.colors.sub, size: 13 });\n\nconst theta = (t * 0.7) % H.TAU;\nconst cosT = Math.cos(theta);\nconst sinT = Math.sin(theta);\n\nconst cx = w * 0.26;\nconst cy = h * 0.56;\nconst R = Math.min(w * 0.2, h * 0.34);\n\nH.line(cx - R - 18, cy, cx + R + 18, cy, { color: H.colors.grid, width: 1.4 });\nH.line(cx, cy - R - 18, cx, cy + R + 18, { color: H.colors.grid, width: 1.4 });\nH.text(\"cos\", cx + R + 6, cy + 16, { color: H.colors.sub, size: 11 });\nH.text(\"sin\", cx + 6, cy - R - 6, { color: H.colors.sub, size: 11 });\n\nH.circle(cx, cy, R, { stroke: H.colors.axis, width: 2 });\n\nconst px = cx + cosT * R;\nconst py = cy - sinT * R;\n\nctx.save();\nctx.beginPath();\nctx.strokeStyle = H.colors.violet;\nctx.lineWidth = 3;\nctx.arc(cx, cy, R * 0.32, 0, -theta, true);\nctx.stroke();\nctx.restore();\nH.text(\"theta = \" + theta.toFixed(2) + \" rad\", cx - R, cy + R + 30, { color: H.colors.violet, size: 12 });\n\nH.line(cx, cy, px, py, { color: H.colors.ink, width: 2 });\nH.line(cx, cy, px, cy, { color: H.colors.accent2, width: 3 });\nH.line(px, cy, px, py, { color: H.colors.good, width: 3 });\nH.circle(px, py, 6, { fill: H.colors.warn, stroke: H.colors.bg, width: 2 });\nH.text(\"(\" + cosT.toFixed(2) + \", \" + sinT.toFixed(2) + \")\", px + 8, py - 8, { color: H.colors.ink, size: 12 });\n\nconst v = H.plot2d({\n box: { x: w * 0.5, y: h * 0.2, w: w * 0.44, h: h * 0.6 },\n xMin: 0, xMax: H.TAU, yMin: -1.25, yMax: 1.25,\n});\nv.grid({ stepX: Math.PI / 2 });\nv.axes({ stepX: Math.PI / 2, stepY: 0.5 });\nv.fn((x) => Math.sin(x), { color: H.colors.good, width: 3 });\nv.fn((x) => Math.cos(x), { color: H.colors.accent2, width: 3 });\n\nconst sx = v.X(theta);\nv.line(theta, 0, theta, sinT, { color: H.colors.grid, width: 1.2, dash: [4, 4] });\nv.dot(theta, sinT, { r: 6, fill: H.colors.good });\nv.dot(theta, cosT, { r: 6, fill: H.colors.accent2 });\nH.line(px, py, sx, v.Y(sinT), { color: H.colors.grid, width: 1.2, dash: [3, 5] });\nH.text(\"y = sin(theta)\", v.X(0.15), v.Y(1.18), { color: H.colors.good, size: 12 });\nH.text(\"y = cos(theta)\", v.X(3.4), v.Y(1.18), { color: H.colors.accent2, size: 12 });\n\nH.text(\"sin(theta) = \" + sinT.toFixed(3), 24, 80, { color: H.colors.good, size: 13 });\nH.text(\"cos(theta) = \" + cosT.toFixed(3), 24, 100, { color: H.colors.accent2, size: 13 });\nH.text(\"sin^2 + cos^2 = \" + (sinT * sinT + cosT * cosT).toFixed(3), 24, 120, { color: H.colors.sub, size: 13 });\n\nH.legend([\n { label: \"sin theta (height)\", color: H.colors.good },\n { label: \"cos theta (shadow)\", color: H.colors.accent2 },\n { label: \"swept angle theta\", color: H.colors.violet },\n], 24, h - 70);" + }, + { + "id": "conic-sections-slicing-cone", + "title": "Conic Sections - Slicing a Cone", + "tag": "Geometry", + "dimension": "3D", + "equation": "cutting plane y = m x intersecting cone x^2 + z^2 = (r/R)^2 y^2", + "summary": "A lit double cone is sliced by a cutting plane whose tilt steps through four stages, and the intersection curve traced in space morphs from a circle to an ellipse to a parabola to a hyperbola while the camera slowly orbits.", + "keywords": [ + "conic sections", + "conics", + "cone", + "slice", + "circle", + "ellipse", + "parabola", + "hyperbola", + "cutting plane", + "intersection", + "geometry", + "double cone", + "nappe", + "eccentricity" + ], + "bullets": [ + "Every conic is just the curve where a flat plane cuts a cone - the tilt of the plane decides which one you get.", + "A horizontal cut gives a circle; tilting it gently gives an ellipse, parallel to the side gives a parabola, and steeper still cuts both nappes into a hyperbola.", + "The highlighted curve is computed by solving the cone and plane equations together, so it sits exactly on the cone's surface." + ], + "student_prompts": [ + "What exact plane angle turns the ellipse into a parabola?", + "Why does the hyperbola need both halves (nappes) of the cone?", + "How does eccentricity connect these four shapes into one family?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst cam = H.cam3d({ scale: 30, dist: 16, pitch: -0.42, cy: H.H * 0.56 });\ncam.yaw = 0.3 * t;\n\nconst stageNames = [\"Circle\", \"Ellipse\", \"Parabola\", \"Hyperbola\"];\nconst stage = Math.floor((t % 16) / 4);\nconst slope = [0.0, 0.42, 1.0, 1.55][stage];\nconst planeY0 = 0.0;\n\ncam.grid(5, 1, { color: H.colors.grid });\n\nconst R = 3.2;\nconst coneR = 2.4;\nH.mesh3d(cam, (u, vv) => {\n const y = (vv - 0.5) * 2 * R;\n const rad = (Math.abs(y) / R) * coneR;\n return [rad * Math.cos(u), y, rad * Math.sin(u)];\n}, { uMin: 0, uMax: H.TAU, vMin: 0, vMax: 1, nu: 40, nv: 28, hue: 205, alpha: 0.5, wire: true });\n\nconst S = 3.6;\nconst planeAt = (x, z) => planeY0 + slope * x;\nconst corners = [\n [-S, planeAt(-S, -S), -S],\n [ S, planeAt( S, -S), -S],\n [ S, planeAt( S, S), S],\n [-S, planeAt(-S, S), S],\n];\ncam.poly(corners, { fill: \"rgba(124,196,255,0.16)\", stroke: H.colors.accent, width: 1.4 });\n\nconst k = coneR / R;\nconst curve = [];\nconst N = 220;\nfor (let i = 0; i <= N; i++) {\n const ang = (i / N) * H.TAU;\n const cax = Math.cos(ang), saz = Math.sin(ang);\n const A = 1 - k * k * slope * slope * cax * cax;\n const B = -2 * k * k * slope * planeY0 * cax;\n const C = -k * k * planeY0 * planeY0;\n let r;\n if (Math.abs(A) < 1e-6) {\n r = Math.abs(B) > 1e-9 ? -C / B : NaN;\n } else {\n const disc = B * B - 4 * A * C;\n if (disc < 0) { r = NaN; }\n else {\n const sq = Math.sqrt(disc);\n const r1 = (-B + sq) / (2 * A);\n const r2 = (-B - sq) / (2 * A);\n const ok1 = Number.isFinite(r1) && Math.abs(planeY0 + slope * r1 * cax) <= R + 0.05;\n const ok2 = Number.isFinite(r2) && Math.abs(planeY0 + slope * r2 * cax) <= R + 0.05;\n r = ok1 ? r1 : (ok2 ? r2 : NaN);\n }\n }\n if (Number.isFinite(r) && r >= 0 && r < 12) {\n curve.push([r * cax, planeY0 + slope * r * cax, r * saz]);\n } else {\n if (curve.length > 1) cam.path(curve.splice(0), { color: H.colors.warn, width: 3.5 });\n else curve.length = 0;\n }\n}\nif (curve.length > 1) cam.path(curve, { color: H.colors.warn, width: 3.5 });\n\ncam.axes(3.6);\n\nH.text(\"Conic Sections - slicing a cone\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Tilt the cutting plane and the intersection curve changes its type.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"Current slice: \" + stageNames[stage], 24, 84, { color: H.colors.warn, size: 15, weight: 700 });\nH.text(\"plane slope = \" + slope.toFixed(2), 24, 106, { color: H.colors.accent, size: 13 });\nH.text(\"y = \" + slope.toFixed(2) + \" x (cutting plane)\", 24, 126, { color: H.colors.sub, size: 12 });\n\nH.legend([\n { label: \"double cone\", color: H.hsl(205, 72, 55) },\n { label: \"cutting plane\", color: H.colors.accent },\n { label: \"conic section\", color: H.colors.warn },\n], 24, h - 70);" + }, + { + "id": "pythagorean-theorem-squares", + "title": "Pythagorean Theorem - Squares on the Sides", + "tag": "Geometry", + "dimension": "2D", + "equation": "a^2 + b^2 = c^2", + "summary": "A right triangle with breathing legs carries a colored square on each side, and the live readout shows the two leg-squares' areas summing to exactly the hypotenuse-square's area every frame.", + "keywords": [ + "pythagorean theorem", + "pythagoras", + "right triangle", + "hypotenuse", + "legs", + "squares on sides", + "a squared plus b squared", + "a2 b2 c2", + "geometry", + "area", + "right angle", + "proof", + "euclid", + "distance" + ], + "bullets": [ + "Each side of the right triangle carries a square whose area equals that side length squared.", + "As the legs a and b grow and shrink, the green and orange leg-squares always add up to the violet hypotenuse-square.", + "The right-angle marker at the corner is what makes the identity a^2 + b^2 = c^2 hold - it fails for non-right triangles." + ], + "student_prompts": [ + "Show me a rearrangement proof that the two small squares fill the big one.", + "What happens to a^2 + b^2 vs c^2 if the angle is not exactly 90 degrees?", + "How is the distance formula just the Pythagorean theorem in disguise?" + ], + "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/tests/test_main.py b/tests/test_main.py index 794df00..b9e0635 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -540,9 +540,11 @@ def test_mode_is_part_of_key(self): class LibraryMatchTests(unittest.TestCase): def test_strong_match(self): + # The library may hold more than one Fourier scene (hand-written + + # workflow-generated); any of them is a correct match for this prompt. sc, score = main.library_match("show a fourier series building a square wave", "auto") self.assertIsNotNone(sc) - self.assertEqual(sc["id"], "fourier-square-wave") + self.assertIn("fourier", sc["id"]) self.assertGreaterEqual(score, 3.0) def test_no_match_for_unrelated(self): From 4116ad6b4da15db820d0900892ebf67f51558a68 Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Mon, 15 Jun 2026 19:34:14 +0700 Subject: [PATCH 06/43] Validator catches motion that drifts off-screen and never loops Watched the local model write a Doppler scene with pos = t*4 (unbounded): the source and all wavefronts sail off the right edge within seconds, leaving only a static observer line. It passed validation because early frames (t<=3) still had content on-screen. Now the validator samples a late frame (t=30) and tracks the FINAL frame's on-screen fraction: if <15% of the last frame's content is on canvas, the scene "drifted off and never loops" -> fatal, repaired with a hint to bound/loop the motion (t % period, Math.sin(t)). All 50 library scenes still pass; 71 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 14 +- stem-viz-plugin/.claude-plugin/plugin.json | 22 + stem-viz-plugin/README.md | 86 ++ stem-viz-plugin/skills/stem-viz/SKILL.md | 216 ++++ .../skills/stem-viz/references/concepts.md | 117 ++ .../skills/stem-viz/references/examples.md | 236 ++++ .../skills/stem-viz/references/helper-api.md | 140 +++ .../skills/stem-viz/references/repair.md | 55 + .../skills/stem-viz/runtime/README.md | 65 + .../skills/stem-viz/runtime/host.html | 137 +++ .../skills/stem-viz/runtime/sandbox-worker.js | 1080 +++++++++++++++++ .../skills/stem-viz/scripts/validate_scene.js | 206 ++++ tests/test_main.py | 19 + validate_scene.js | 22 +- 14 files changed, 2405 insertions(+), 10 deletions(-) create mode 100644 stem-viz-plugin/.claude-plugin/plugin.json create mode 100644 stem-viz-plugin/README.md create mode 100644 stem-viz-plugin/skills/stem-viz/SKILL.md create mode 100644 stem-viz-plugin/skills/stem-viz/references/concepts.md create mode 100644 stem-viz-plugin/skills/stem-viz/references/examples.md create mode 100644 stem-viz-plugin/skills/stem-viz/references/helper-api.md create mode 100644 stem-viz-plugin/skills/stem-viz/references/repair.md create mode 100644 stem-viz-plugin/skills/stem-viz/runtime/README.md create mode 100644 stem-viz-plugin/skills/stem-viz/runtime/host.html create mode 100644 stem-viz-plugin/skills/stem-viz/runtime/sandbox-worker.js create mode 100644 stem-viz-plugin/skills/stem-viz/scripts/validate_scene.js diff --git a/main.py b/main.py index 70ec7b7..0e3388d 100644 --- a/main.py +++ b/main.py @@ -1389,11 +1389,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": [], } 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..5a76d7c --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/SKILL.md @@ -0,0 +1,216 @@ +--- +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). + +## 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). +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..41b0ab1 --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/references/concepts.md @@ -0,0 +1,117 @@ +# 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. + +## 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/tests/test_main.py b/tests/test_main.py index b9e0635..6101fa6 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -456,6 +456,25 @@ def test_onscreen_content_passes(self): ) self.assertTrue(r["onscreen"]) + def test_unbounded_drift_off_screen_detected(self): + # pos = t*4 with no loop: by the late frame the moving content has + # sailed off the canvas and never returns. + r = main.headless_validate( + "const v = H.plot2d({xMin:-10,xMax:10,yMin:-5,yMax:5}); v.grid(); v.axes();" + " const pos = t*4; for (let i=-5;i<=5;i++){ v.dot(pos+i, 0, {}); }" + " v.text('x='+pos.toFixed(1), v.X(pos), v.Y(2), {});" + ) + self.assertTrue(r["ok"]) + self.assertFalse(r["onscreen"]) + + def test_looping_motion_stays_on_screen(self): + r = main.headless_validate( + "const v = H.plot2d({xMin:-10,xMax:10,yMin:-5,yMax:5}); v.grid(); v.axes();" + " const pos = ((t*4+10)%20)-10; for (let i=-3;i<=3;i++){ v.dot(pos+i*0.3, 0, {}); }" + " H.text('looping', 24, 30, {});" + ) + self.assertTrue(r["onscreen"]) + def test_host_escape_is_contained(self): # process must be unreachable inside the sandbox. r = main.headless_validate( diff --git a/validate_scene.js b/validate_scene.js index c46a3be..d077d9f 100644 --- a/validate_scene.js +++ b/validate_scene.js @@ -174,13 +174,20 @@ function readStdin() { // 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. + // Sample early frames AND a late one (t=30). The late sample catches the + // "drifts off and never loops" bug — e.g. a source at pos = t*4 that has + // sailed off the right edge by the time anyone watches. We record the LAST + // frame's content draws (__lc) and how many landed on-canvas (__ls): if most + // of the final frame's content is off-screen, the animation doesn't hold its + // subject in view. (The scene contract requires looping, bounded motion.) 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;}" + + "\n};var __ts=[0,0.4,1.3,3.0,8.0,30.0];var __lc=0,__ls=0;" + + "for(var __i=0;__i<__ts.length;__i++){var __p0=paint,__s0=conscr;__s(ctx,__ts[__i]);__lc=paint-__p0;__ls=conscr-__s0;}__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});})()"; + "return JSON.stringify({ok:__ok,error:__err,paint:paint,text:text,conscr:conscr,lc:__lc,ls:__ls});})()"; try { const context = vm.createContext(Object.create(null)); @@ -191,9 +198,14 @@ function readStdin() { 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); + // off-screen if: nothing ever landed on-canvas (a data-vs-pixel mixup), OR + // the final frame drew content but <15% of it is on-canvas (content drifted + // off and never loops back). A frame that draws no content isn't penalized. + const lc = parsed.lc || 0; + const ls = parsed.ls || 0; + const neverOnScreen = (parsed.paint || 0) > 0 && (parsed.conscr || 0) === 0; + const driftedOff = lc > 0 && ls * 100 < lc * 15; + result.onscreen = !(neverOnScreen || driftedOff); } catch (err) { const msg = err && err.message ? String(err.message) : String(err); if (/timed out|execution timed/i.test(msg)) { From 883202f157c14ddcbf00b59ed9805cc97b768d38 Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Mon, 15 Jun 2026 19:43:52 +0700 Subject: [PATCH 07/43] Catch drifting-subject motion + steer the model away from it up front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live re-run exposed the subtler case: the local model wrote xSource = -speed*t again, and fixed labels (title, origin marker) kept the last-frame on-screen fraction just above 15%, so the moving subject sailed off-screen undetected. - Validator now also flags a COLLAPSE: content abundant on-screen early (>=5 draws in some early frame) but the late frame retains <40% of that peak => the subject drifted away while only fixed annotations remain. Catches the exact generated Doppler; no false-flags across the 50-scene library. - System prompt Rule 1 now bans `x = speed*t` outright and requires looping/ bounded motion (Math.sin(t), t % period) so moving subjects stay in frame — fixing the root cause for every generation, not just repairs. - 72 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 7 +++++++ tests/test_main.py | 15 +++++++++++++++ validate_scene.js | 18 +++++++++++------- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 0e3388d..3e0dc5f 100644 --- a/main.py +++ b/main.py @@ -420,6 +420,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 diff --git a/tests/test_main.py b/tests/test_main.py index 6101fa6..2e94041 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -475,6 +475,21 @@ def test_looping_motion_stays_on_screen(self): ) self.assertTrue(r["onscreen"]) + def test_drifting_subject_with_fixed_labels_detected(self): + # The hard case from the live Doppler run: a moving subject + # (xSource = -3*t) drifts off, but fixed annotations (title, origin + # marker) remain. On-screen content COLLAPSES from abundant early to + # sparse late — caught even though a few fixed elements stay visible. + r = main.headless_validate( + "const v = H.plot2d({xMin:-10,xMax:10,yMin:-5,yMax:5}); v.grid(); v.axes();" + " const xs = -3*t; v.dot(0,0,{});" + " for (let i=-3;i<=3;i++){ const x=xs+i*2; if(x>=-10&&x<=10) v.line(x,-0.5,x,0.5,{}); }" + " v.dot(xs,0,{}); v.text('x='+xs.toFixed(1), v.X(xs), v.Y(-0.5), {});" + " H.text('Doppler', 24, 30, {});" + ) + self.assertTrue(r["ok"]) + self.assertFalse(r["onscreen"]) + def test_host_escape_is_contained(self): # process must be unreachable inside the sandbox. r = main.headless_validate( diff --git a/validate_scene.js b/validate_scene.js index d077d9f..cd1316e 100644 --- a/validate_scene.js +++ b/validate_scene.js @@ -184,10 +184,10 @@ function readStdin() { 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,8.0,30.0];var __lc=0,__ls=0;" + - "for(var __i=0;__i<__ts.length;__i++){var __p0=paint,__s0=conscr;__s(ctx,__ts[__i]);__lc=paint-__p0;__ls=conscr-__s0;}__ok=true;}" + + "\n};var __ts=[0,0.4,1.3,3.0,8.0,30.0];var __lc=0,__ls=0,__em=0;" + + "for(var __i=0;__i<__ts.length;__i++){var __p0=paint,__s0=conscr;__s(ctx,__ts[__i]);__lc=paint-__p0;__ls=conscr-__s0;if(__i<__ts.length-1&&(conscr-__s0)>__em)__em=conscr-__s0;}__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,lc:__lc,ls:__ls});})()"; + "return JSON.stringify({ok:__ok,error:__err,paint:paint,text:text,conscr:conscr,lc:__lc,ls:__ls,em:__em});})()"; try { const context = vm.createContext(Object.create(null)); @@ -198,14 +198,18 @@ function readStdin() { result.paint = parsed.paint || 0; result.painted = (parsed.paint || 0) > 0; result.text = (parsed.text || 0) > 0; - // off-screen if: nothing ever landed on-canvas (a data-vs-pixel mixup), OR - // the final frame drew content but <15% of it is on-canvas (content drifted - // off and never loops back). A frame that draws no content isn't penalized. + // off-screen if any of: (a) nothing ever landed on-canvas (a data-vs-pixel + // mixup); (b) the final frame drew content but <15% is on-canvas; or (c) the + // on-screen content COLLAPSED — abundant early (>=5 on-screen draws in some + // early frame) but the late frame keeps under 40% of that peak, i.e. the + // moving subject drifted away while only fixed labels remain. const lc = parsed.lc || 0; const ls = parsed.ls || 0; + const em = parsed.em || 0; const neverOnScreen = (parsed.paint || 0) > 0 && (parsed.conscr || 0) === 0; const driftedOff = lc > 0 && ls * 100 < lc * 15; - result.onscreen = !(neverOnScreen || driftedOff); + const collapsed = em >= 5 && ls * 10 < em * 4; + result.onscreen = !(neverOnScreen || driftedOff || collapsed); } catch (err) { const msg = err && err.message ? String(err.message) : String(err); if (/timed out|execution timed/i.test(msg)) { From 191e34f97e9dc2d7f1edcee4d9febffb75e98ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Mon, 15 Jun 2026 19:49:46 +0700 Subject: [PATCH 08/43] Pedagogy: teach the mechanism, don't solve the student's problem Bake the product's teaching philosophy into the generator. Scenes must build understanding of the mechanism (sweep the parameter, explain in labels, provoke prediction) rather than just computing and displaying the answer to a student's specific problem. Applied consistently to SCENE_SYSTEM_PROMPT ("Style and pedagogy") and the stem-viz skill (SKILL.md + concepts.md). Guarded against over-correction: scenes stay concrete and labeled, and a live readout of a CHANGING quantity remains good teaching (it makes the relationship visible). The line is illuminate-the-mechanism vs. hand-over-the-answer. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 20 +++++++++++++++ stem-viz-plugin/skills/stem-viz/SKILL.md | 25 +++++++++++++++++++ .../skills/stem-viz/references/concepts.md | 5 ++++ 3 files changed, 50 insertions(+) diff --git a/main.py b/main.py index 3e0dc5f..0a4978b 100644 --- a/main.py +++ b/main.py @@ -525,6 +525,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. diff --git a/stem-viz-plugin/skills/stem-viz/SKILL.md b/stem-viz-plugin/skills/stem-viz/SKILL.md index 5a76d7c..341b452 100644 --- a/stem-viz-plugin/skills/stem-viz/SKILL.md +++ b/stem-viz-plugin/skills/stem-viz/SKILL.md @@ -24,11 +24,36 @@ code can be validated headlessly (`scripts/validate_scene.js`) and run for real 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 diff --git a/stem-viz-plugin/skills/stem-viz/references/concepts.md b/stem-viz-plugin/skills/stem-viz/references/concepts.md index 41b0ab1..31cc22e 100644 --- a/stem-viz-plugin/skills/stem-viz/references/concepts.md +++ b/stem-viz-plugin/skills/stem-viz/references/concepts.md @@ -5,6 +5,11 @@ 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 From 64e8118a8f430844f31c03eae9686245f16f6f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Mon, 15 Jun 2026 19:57:16 +0700 Subject: [PATCH 09/43] Run VisualLM as an app: one-click launcher + native-window option Make it runnable without touching the terminal after a one-time key setup. - launch.py: loads .env, picks a free port, starts main.py, waits until /api/health is green, then opens the UI in a chrome-less app window (Chrome/Edge/Brave --app, else the default browser). Flags: --no-browser, --smoke. - VisualLM.command: macOS double-click wrapper (chmod +x). - desktop.py: optional TRUE native window via pywebview (one pip install). - .env.example: API-key template (.env stays gitignored). - README: new "Run it (as an app)" section + Files entries. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 19 ++++++ README.md | 40 +++++++++---- VisualLM.command | 5 ++ desktop.py | 59 +++++++++++++++++++ launch.py | 148 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 .env.example create mode 100755 VisualLM.command create mode 100755 desktop.py create mode 100755 launch.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7db9fcb --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# 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 falls back to a local Ollama model if one is running +# (install from https://ollama.com, then e.g. ollama pull qwen2.5:7b). + +# 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/README.md b/README.md index 88074dc..3320e21 100644 --- a/README.md +++ b/README.md @@ -101,22 +101,36 @@ Generation tries providers in this order — the first one with a key wins: All cloud providers are called over plain REST — no extra SDKs required. -## Run locally +## Run it (as an app) + +The easy way — after a one-time setup, **no terminal needed**: + +1. **Set a key (once).** Copy `.env.example` to `.env` and paste your key: + `ANTHROPIC_API_KEY=sk-ant-...`. For Claude also run `pip install -r requirements.txt`. + No cloud key? Install [Ollama](https://ollama.com) and `ollama pull qwen2.5:7b` + for a local fallback. +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` + + 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`. + +### Plain manual start + +Equivalent to what the launcher does, if you'd rather drive it yourself: ```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 - -# 2. (Optional) local fallback + tutor: run Ollama with a model -ollama pull qwen2.5:7b - -# 3. Start the app -python3 main.py +ollama pull qwen2.5:7b # optional local fallback + tutor +python3 main.py # then open http://127.0.0.1:4173 ``` -Then open . - ## Deploy to the web The repo is deploy-ready for any Docker host. The server reads `PORT` and @@ -165,6 +179,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, 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/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/launch.py b/launch.py new file mode 100755 index 0000000..79d0278 --- /dev/null +++ b/launch.py @@ -0,0 +1,148 @@ +#!/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. VisualLM will try a local Ollama model, and") + print(" otherwise show a fallback. For the best animations, 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()) From 8af5deb9207d4ce240487690e026140325a18c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Mon, 15 Jun 2026 20:47:05 +0700 Subject: [PATCH 10/43] Fix Pylance type error: narrow node path before subprocess.run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shutil.which("node") is `str | None`, and pyright/Pylance can't carry the non-None proof across the separate node_validator_available() helper (it's a module global), so the subprocess.run arg list was typed `list[str | None]` (reportCallIssue + reportArgumentType). Rebind to a local and guard it inline so it narrows to `str`. Behavior-identical — the inline check is De Morgan-equivalent to the old `not node_validator_available()`. pyright now reports 0 errors on main.py. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 0a4978b..d6af838 100644 --- a/main.py +++ b/main.py @@ -1159,11 +1159,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, From 82df16d7af45243e634f3d5cd98197f05b545998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Tue, 16 Jun 2026 18:42:16 +0700 Subject: [PATCH 11/43] Build VisualLM as a real macOS app (double-click .app + custom icon) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make_app.sh assembles VisualLM.app — a proper bundle (Info.plist + a generated multi-resolution .icns from make_icon.py, a dependency-free stdlib PNG writer). Its launcher resolves the repo by the bundle's own location and opens VisualLM in a native window (pywebview if installed) or an app-style browser window otherwise. The python3 path is baked at build time so a Finder launch — which gets a minimal PATH — still finds the right interpreter. The .app is a git-ignored build artifact; run ./make_app.sh to (re)build. A fully self-contained PyInstaller bundle is the next step (in progress). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 9 +++++ make_app.sh | 81 ++++++++++++++++++++++++++++++++++++++++++++ make_icon.py | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100755 make_app.sh create mode 100755 make_icon.py diff --git a/.gitignore b/.gitignore index 18df511..87036b9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,12 @@ __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 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()) From ef63a25251a9a5e71b6f9c0e1f8f444d8582521d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Tue, 16 Jun 2026 18:50:10 +0700 Subject: [PATCH 12/43] App foundation: in-process entry point + frozen-aware asset paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app_main.py runs the VisualLM server IN-PROCESS (server in a daemon thread, window on the main thread) instead of spawning `python main.py` — required for a PyInstaller bundle, where sys.executable is the app binary, not a Python interpreter. main.py's BASE_DIR now resolves to sys._MEIPASS when frozen so the static assets + validate_scene.js load from the bundle (no-op unfrozen). Opens a native pywebview window if installed, else an app-style browser window. Verified: `app_main.py --smoke` serves /api/health; 72/72 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- app_main.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++ main.py | 9 +++++- 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100755 app_main.py diff --git a/app_main.py b/app_main.py new file mode 100755 index 0000000..2709d5c --- /dev/null +++ b/app_main.py @@ -0,0 +1,79 @@ +#!/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 main() -> int: + 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}" + + # 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: + print("✓ Smoke check passed.") + return 0 + 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/main.py b/main.py index d6af838..fc42db1 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,13 @@ 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). +if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + BASE_DIR = Path(sys._MEIPASS) +else: + BASE_DIR = Path(__file__).resolve().parent # Hard caps for the in-memory resource store (single-user local app). MAX_RESOURCES = 12 From ad559a3b0550cba84f820e28ea1be95b10e1e54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Tue, 16 Jun 2026 19:00:39 +0700 Subject: [PATCH 13/43] Self-contained app: PyInstaller bundle (dist/VisualLM.app) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a committed PyInstaller spec + build_app.sh that freeze app_main.py into a standalone double-click VisualLM.app — bundles Python, the static assets, validate_scene.js, and the scene library; runs the server in-process and serves from the bundle (BASE_DIR -> sys._MEIPASS). Verified: the frozen binary's `--smoke` boots the server and serves index.html from the bundle (~22 MB app). Also: app_main.py --smoke now confirms static assets serve (the real bundle-path test), and the packaged app reads .env from next to VisualLM.app or ~/.visuallm/ (when frozen, its __file__ is inside the bundle). Build artifacts (build/, dist/) stay git-ignored. 72/72 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 7 +++++ VisualLM.spec | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++ app_main.py | 31 ++++++++++++++++++--- build_app.sh | 38 ++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 VisualLM.spec create mode 100755 build_app.sh diff --git a/README.md b/README.md index 3320e21..8afcecb 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,13 @@ The easy way — after a one-time setup, **no terminal needed**: **Prefer a true native window** (no browser chrome at all)? `pip install pywebview` then `python3 desktop.py`. +**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: diff --git a/VisualLM.spec b/VisualLM.spec new file mode 100644 index 0000000..0eaf99f --- /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 / Ollama (all stdlib HTTP). +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/app_main.py b/app_main.py index 2709d5c..01330cc 100755 --- a/app_main.py +++ b/app_main.py @@ -24,8 +24,24 @@ 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: - launch.load_dotenv(HERE / ".env") + 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) @@ -47,8 +63,17 @@ def main() -> int: return 1 print(f"✓ VisualLM is up on {url}") if smoke: - print("✓ Smoke check passed.") - return 0 + # 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() diff --git a/build_app.sh b/build_app.sh new file mode 100755 index 0000000..497e017 --- /dev/null +++ b/build_app.sh @@ -0,0 +1,38 @@ +#!/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 / Ollama. 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" From c5e56926986929fa08822866f8f0820703e73d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Tue, 16 Jun 2026 19:08:12 +0700 Subject: [PATCH 14/43] Test coverage for the app launcher (launch.py + app_main) Add tests/test_launcher.py (stdlib, headless): .env loading (quote-stripping, skipping comments/junk lines, never overriding an existing shell env var, missing-file no-op), free_port (returns the preferred port when free; falls back to a bindable port when it's taken), wait_healthy (true against a live 200 server, false on a dead port within the timeout), and app_main._env_files (unfrozen -> local .env; frozen -> alongside VisualLM.app + ~/.visuallm). The new launcher/app code was previously untested. Suite 72 -> 81, green. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_launcher.py | 137 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tests/test_launcher.py diff --git a/tests/test_launcher.py b/tests/test_launcher.py new file mode 100644 index 0000000..5f84f99 --- /dev/null +++ b/tests/test_launcher.py @@ -0,0 +1,137 @@ +"""Tests for the app launcher (launch.py) and native-app entry (app_main.py). + +Stdlib only, headless: covers .env loading, free-port selection, the health +poll, and the frozen-vs-source .env search-path logic — the glue that runs +`python3 launch.py`, the VisualLM.app wrapper, and the PyInstaller bundle. +""" +from __future__ import annotations + +import os +import socket +import sys +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import app_main # noqa: E402 +import launch # noqa: E402 + + +class LoadDotenvTests(unittest.TestCase): + def setUp(self): + self._saved = dict(os.environ) + + def tearDown(self): + os.environ.clear() + os.environ.update(self._saved) + + def test_loads_keys_strips_quotes_skips_junk(self): + with tempfile.TemporaryDirectory() as d: + p = Path(d) / ".env" + p.write_text('# comment\nFOO_T=bar\nQUOTED="hello world"\n\nNO_EQUALS\n') + for k in ("FOO_T", "QUOTED"): + os.environ.pop(k, None) + launch.load_dotenv(p) + self.assertEqual(os.environ.get("FOO_T"), "bar") + self.assertEqual(os.environ.get("QUOTED"), "hello world") + self.assertNotIn("NO_EQUALS", os.environ) + + def test_does_not_override_existing_env(self): + with tempfile.TemporaryDirectory() as d: + p = Path(d) / ".env" + p.write_text("FOO_T2=fromfile\n") + os.environ["FOO_T2"] = "preset" + launch.load_dotenv(p) + self.assertEqual(os.environ["FOO_T2"], "preset") # shell wins + + def test_missing_file_is_noop(self): + launch.load_dotenv(Path("/no/such/.env")) # must not raise + + +class FreePortTests(unittest.TestCase): + @staticmethod + def _an_unused_port() -> int: + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + def test_returns_preferred_when_free(self): + port = self._an_unused_port() + self.assertEqual(launch.free_port(port), port) + + def test_falls_back_when_preferred_is_taken(self): + held = socket.socket() + held.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + held.bind(("127.0.0.1", 0)) + held.listen(1) + taken = held.getsockname()[1] + try: + got = launch.free_port(taken) + self.assertNotEqual(got, taken) + probe = socket.socket() # the returned port must be bindable + probe.bind(("127.0.0.1", got)) + probe.close() + finally: + held.close() + + +class WaitHealthyTests(unittest.TestCase): + def test_true_when_server_responds_200(self): + class _H(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *a): + pass + + srv = HTTPServer(("127.0.0.1", 0), _H) + port = srv.server_address[1] + threading.Thread(target=srv.serve_forever, daemon=True).start() + try: + self.assertTrue(launch.wait_healthy(f"http://127.0.0.1:{port}/health", timeout=5)) + finally: + srv.shutdown() + srv.server_close() + + def test_false_when_nothing_listens(self): + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + self.assertFalse(launch.wait_healthy(f"http://127.0.0.1:{port}/health", timeout=1.0)) + + +class EnvFilesTests(unittest.TestCase): + def test_unfrozen_uses_local_env(self): + self.assertFalse(getattr(sys, "frozen", False)) + self.assertEqual(app_main._env_files(), [app_main.HERE / ".env"]) + + def test_frozen_searches_alongside_app_and_user_dir(self): + had_frozen = hasattr(sys, "frozen") + orig_frozen = getattr(sys, "frozen", None) + orig_exe = sys.executable + sys.frozen = True + sys.executable = str(Path(tempfile.gettempdir()) / "X/VisualLM.app/Contents/MacOS/VisualLM") + try: + files = app_main._env_files() + finally: + sys.executable = orig_exe + if had_frozen: + sys.frozen = orig_frozen + else: + del sys.frozen + self.assertEqual(len(files), 2) + self.assertEqual(files[0].name, ".env") + self.assertIn(Path.home() / ".visuallm" / ".env", files) + + +if __name__ == "__main__": + unittest.main() From c01b413f54a3c2862ae0fe05dd59358846286506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Tue, 16 Jun 2026 19:18:41 +0700 Subject: [PATCH 15/43] Fix auto-fixer corrupting scenes that declare their own PI/TAU/math-fn names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit autofix_code blindly prefixed every bare PI / TAU / Math-fn name. A scene that aliases one — `const PI = Math.PI` or `const TAU = 6.28` — was rewritten to `const Math.PI = ...` (a SYNTAX error), and a local `function log(){}` / `const log = ...` became `Math.log`. Now we collect the names the scene declares itself (const/let/var/function) and never rewrite those; genuinely bare calls and constants are still fixed. Regression test added; suite 81 -> 82, green. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 31 ++++++++++++++++++++++++------- tests/test_main.py | 18 ++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index fc42db1..afffc79 100644 --- a/main.py +++ b/main.py @@ -1112,25 +1112,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 diff --git a/tests/test_main.py b/tests/test_main.py index 2e94041..5ccb5ac 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -400,6 +400,24 @@ def test_idempotent(self): once = main.autofix_code("r = sqrt(2);") self.assertEqual(once, main.autofix_code(once)) + def test_does_not_corrupt_locally_declared_names(self): + # A scene that declares its own PI/TAU/function must be left intact — + # rewriting `const PI` -> `const Math.PI` is a syntax error, and a local + # `log(...)` is the scene's function, not Math.log. + self.assertEqual( + main.autofix_code("const PI = Math.PI; const r = PI * 2;"), + "const PI = Math.PI; const r = PI * 2;", + ) + self.assertEqual( + main.autofix_code("const TAU = 6.28; const a = TAU;"), + "const TAU = 6.28; const a = TAU;", + ) + out = main.autofix_code("const log = (x) => x + 1; const y = log(5);") + self.assertNotIn("Math.log", out) + # ...but a genuinely bare call/constant (no local decl) is still fixed. + self.assertIn("Math.sin(", main.autofix_code("r = sin(t);")) + self.assertIn("Math.PI", main.autofix_code("a = 2 * PI;")) + def test_sanitize_runs_autofix(self): self.assertIn("Math.sin", main.sanitize_code("H.background(); const y = sin(t);")) From 0a346906e9b1e5c735b9e9c9f8ee97c78b619bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Tue, 16 Jun 2026 19:28:21 +0700 Subject: [PATCH 16/43] Fix sanitize_code corrupting t/H/ctx used as call or array values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _rewrite_declared_names renamed a reserved binding (H/ctx/t) after ANY comma in a const/let/var span — including expression commas inside ()/[]/{}. So a very common pattern like `const y = H.lerp(a, b, t);` became `H.lerp(a, b, _t)` (an undefined reference), and `const p = [Math.cos(t), t]` lost its `t` — silently feeding the server-side repair loop and burning round-trips. The docstring already said "top level of the statement" but the regex never enforced bracket depth. Replaced the naive regex with a depth-aware scan that renames only genuine declarators (at depth 0, right after const/let/var or a top-level comma). Real redeclarations still rename (`const W = H.W, H = H.H` -> `_H`; `let H, ctx` -> `let _H, _ctx`). Also removed a SyntaxWarning (a `\s` in a non-raw docstring). 2 regression tests; suite 82 -> 84, green. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 69 +++++++++++++++++++++++++++++++++++++++++----- tests/test_main.py | 17 ++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index afffc79..1225f86 100644 --- a/main.py +++ b/main.py @@ -1230,14 +1230,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: diff --git a/tests/test_main.py b/tests/test_main.py index 5ccb5ac..2e6fef7 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -40,6 +40,23 @@ def test_rewrites_reserved_bindings(self): self.assertIn("_H = H.H", out) self.assertNotIn(", H =", out) + def test_keeps_real_redeclarations_renamed(self): + # Genuine TDZ/shadow declarations must still be renamed. + self.assertEqual(main.sanitize_code("const W = H.W, H = H.H;"), "const W = H.W, _H = H.H;") + self.assertEqual(main.sanitize_code("let H, ctx;"), "let _H, _ctx;") + + def test_does_not_rewrite_reserved_used_as_values(self): + # t/H/ctx used as a VALUE inside a declaration's expression (after a + # comma within ()/[]) is NOT a declarator — renaming it to _t/_H would + # create an undefined reference and break a very common scene pattern. + for code in ( + "const y = H.lerp(a, b, t);", + "const p = [Math.cos(t), t];", + "const v = H.map(x, 0, 10, t);", + "const r = Math.sin(t);", + ): + self.assertEqual(main.sanitize_code(code), code) + def test_strips_import_lines(self): out = main.sanitize_code("import x from 'y';\nH.background();") self.assertNotIn("import", out) From 46434de83f97896217d42d53d27d27a78b7459eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Tue, 16 Jun 2026 19:35:27 +0700 Subject: [PATCH 17/43] Lock sanitize_code unwrap behavior with edge-case tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `function scene(...) { ... }` unwrap (rfind-based) and the fence strip run on every generated scene but were only tested on the trivial case. Verified and pinned the tricky edges: nested arrow-function braces, a '}' inside a string literal (rfind must still land on the function's own closing brace), and a fenced + wrapped scene where both layers are stripped. No code change — coverage only. Suite 84 -> 86, green. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_main.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index 2e6fef7..2693791 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -61,6 +61,26 @@ def test_strips_import_lines(self): out = main.sanitize_code("import x from 'y';\nH.background();") self.assertNotIn("import", out) + def test_unwrap_handles_nested_braces_and_string_braces(self): + # rfind('}') must land on the function's OWN closing brace even when the + # body has nested blocks or a '}' inside a string literal. + out = main.sanitize_code( + "function scene(ctx, t) { const f = () => { return 1; }; H.circle(f(),1,2); }" + ) + self.assertNotIn("function scene", out) + self.assertIn("const f = () => { return 1; };", out) + self.assertIn("H.circle(f(),1,2);", out) + self.assertEqual( + main.sanitize_code('function scene(ctx, t) { H.text("a } b", 1, 2); }'), + 'H.text("a } b", 1, 2);', + ) + + def test_fence_and_function_wrapper_both_stripped(self): + self.assertEqual( + main.sanitize_code("```js\nfunction scene(ctx, t) {\n H.background();\n}\n```"), + "H.background();", + ) + def test_non_string_returns_empty(self): self.assertEqual(main.sanitize_code(None), "") self.assertEqual(main.sanitize_code(42), "") From 7bfb540f47242cc33f035e2d37c909d3e8229d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Thu, 18 Jun 2026 16:22:38 +0700 Subject: [PATCH 18/43] Interactive curriculum demos: classify topic, fill in values, learn the why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a prompt clearly names an Algebra 1 → Precalculus topic, VisualLM now shows a known-correct interactive demo instead of generating code. The graph renders instantly, the student drags sliders to fill in the values (live, no recompile), and a plain-language explanation teaches the idea behind the formula — building understanding of the mechanism rather than solving a specific problem. - demo_library.py: 12 validated demos (lines, systems, absolute value, exponential growth/decay, quadratic vertex form, discriminant/roots, polynomial end behavior, log/exp inverse, function transformations, unit circle, sine wave, ellipse), each with editable params + a teaching explanation and key points. - main.py: demo_match() classifies a prompt to its best demo via the existing library scorer (mode-neutral so a 2D demo isn't penalized); _demo_scene() shapes it into a response carrying its params + explanation; plan_visualization() serves the demo right after the exact-prompt cache, ahead of the curated/generative paths. - sandbox-worker.js: scenes read live params from a P global; a new `params` message updates them in place without recompiling the scene. - app.js: renders the slider panel + explanation below the graph; moving a slider pushes a live param update to the worker (setParams). Worker bumped to v17, app.js to v18. - validate_scene.js: P proxy so headless validation handles demo code. - tests: 7 new tests (well-formed params that the code actually reads, every demo runnable in the validator, topic routing incl. discriminant vs vertex, scene shape, plan routing). Full suite: 93 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- app.js | 103 ++++++++- demo_library.py | 528 +++++++++++++++++++++++++++++++++++++++++++++ index.html | 2 +- main.py | 76 +++++++ sandbox-worker.js | 11 + tests/test_main.py | 78 +++++++ validate_scene.js | 4 + 7 files changed, 796 insertions(+), 6 deletions(-) create mode 100644 demo_library.py diff --git a/app.js b/app.js index 8284129..a41ccd3 100644 --- a/app.js +++ b/app.js @@ -341,7 +341,7 @@ } 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=17"); this.worker = worker; worker.onmessage = (e) => this._onMessage(e.data || {}); const d = this._dims(); @@ -395,8 +395,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 +409,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 }); if (this.paused) this.worker.postMessage({ type: "pause" }); this.worker.postMessage({ type: "speed", value: this.speed }); }); @@ -426,6 +427,10 @@ 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 || {} }); + } _resize() { if (!this.worker || !this.ready) return; const d = this._dims(); @@ -666,6 +671,7 @@ updatePanels(scene); } setConfidence(message, "warn"); + renderDemoControls(null); try { await runner.run(CLIENT_FALLBACK_CODE); } catch (fallbackErr) { @@ -673,6 +679,92 @@ } } + /* ============================================================== */ + /* 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); + } + + function injectDemoStyles() { + if (document.getElementById("demoControlsStyle")) return; + const st = document.createElement("style"); + st.id = "demoControlsStyle"; + st.textContent = + ".demo-controls{margin:10px auto 0;width:92%;max-width:1100px;background:#16203a;border:1px solid #26314f;border-radius:12px;padding:12px 16px;box-sizing:border-box;}" + + ".demo-controls-head{font-size:12px;color:#9fb0d4;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;}" + + ".demo-params{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px 18px;}" + + ".demo-param{display:grid;grid-template-columns:1fr auto;align-items:center;gap:2px 10px;}" + + ".demo-param-name{font-size:13px;color:#cfe0ff;}" + + ".demo-param-val{font:600 13px 'JetBrains Mono',ui-monospace,monospace;color:#7cc4ff;min-width:3ch;text-align:right;}" + + ".demo-param input[type=range]{grid-column:1 / -1;width:100%;accent-color:#7cc4ff;}"; + document.head.appendChild(st); + } + + // 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) { + injectDemoStyles(); + 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 graph 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 +780,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 — diff --git a/demo_library.py b/demo_library.py new file mode 100644 index 0000000..30875c4 --- /dev/null +++ b/demo_library.py @@ -0,0 +1,528 @@ +"""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 + +DEMO_LIBRARY: 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(), + }, +] diff --git a/index.html b/index.html index a6ff235..33474b5 100644 --- a/index.html +++ b/index.html @@ -258,6 +258,6 @@

Quick questions

- + diff --git a/main.py b/main.py index 1225f86..e20f26a 100644 --- a/main.py +++ b/main.py @@ -1701,6 +1701,15 @@ 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 = [] + _scene_cache_lock = threading.Lock() _scene_cache: dict[tuple, dict] = {} _SCENE_CACHE_MAX = 256 @@ -1799,6 +1808,65 @@ 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 + scored = [(d, _scene_score(d, ptext, ptoks, "auto")) 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, + } + + +# 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) @@ -1806,6 +1874,14 @@ def plan_visualization(prompt: str, preferred_mode: str) -> dict: cached["cached"] = True return cached + # 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: + 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 diff --git a/sandbox-worker.js b/sandbox-worker.js index 85eb0a2..b76783e 100644 --- a/sandbox-worker.js +++ b/sandbox-worker.js @@ -75,6 +75,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; @@ -902,6 +903,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", @@ -1021,6 +1023,7 @@ self.onmessage = (e) => { } case "run": { try { + curParams = m.params && typeof m.params === "object" ? m.params : {}; const fn = compile(m.code); sceneFn = fn; lastCode = typeof m.code === "string" ? m.code : ""; @@ -1058,6 +1061,14 @@ self.onmessage = (e) => { case "speed": speed = m.value; 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. diff --git a/tests/test_main.py b/tests/test_main.py index 2693791..fd095b0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -658,5 +658,83 @@ def test_every_library_scene_is_runnable(self): self.assertTrue(r["text"], f"{sc['id']} had no labels") +class DemoLibraryTests(unittest.TestCase): + """The parameterized 'fill in the values' curriculum demos.""" + + def test_library_nonempty(self): + self.assertGreater(len(main.DEMO_LIBRARY), 0) + + def test_each_demo_well_formed(self): + seen_ids = set() + for d in main.DEMO_LIBRARY: + did = d.get("id") + self.assertTrue(did, "demo missing id") + self.assertNotIn(did, seen_ids, f"duplicate demo id {did}") + seen_ids.add(did) + for key in ("title", "area", "topic", "explanation", "code", "keywords", "params"): + self.assertIn(key, d, f"{did} missing {key}") + self.assertTrue(d["code"].strip(), f"{did} has empty code") + self.assertTrue(d["explanation"].strip(), f"{did} has no explanation") + self.assertTrue(d["params"], f"{did} has no params to fill in") + for p in d["params"]: + for key in ("name", "label", "min", "max", "step", "value"): + self.assertIn(key, p, f"{did}.{p.get('name')} missing {key}") + self.assertLessEqual(p["min"], p["value"], f"{did}.{p['name']} value < min") + self.assertLessEqual(p["value"], p["max"], f"{did}.{p['name']} value > max") + self.assertGreater(p["step"], 0, f"{did}.{p['name']} step must be > 0") + # The code must actually read the parameter (P.). + self.assertIn(f"P.{p['name']}", d["code"], f"{did} never reads P.{p['name']}") + + def test_every_demo_is_runnable(self): + if not main.node_validator_available(): + self.skipTest("node validator not installed") + for d in main.DEMO_LIBRARY: + code = main.sanitize_code(d["code"]) + r = main.headless_validate(code) + self.assertIsNotNone(r, d["id"]) + self.assertTrue(r["ok"], f"{d['id']} threw: {r.get('error')}") + self.assertTrue(r["painted"], f"{d['id']} drew nothing") + self.assertTrue(r["text"], f"{d['id']} had no labels") + + def test_match_routes_curriculum_prompt(self): + cases = [ + ("slope intercept form of a line", "linear-slope-intercept"), + ("graph a quadratic and show the vertex", "quadratic-vertex"), + ("discriminant of a quadratic equation", "quadratic-discriminant"), + ("exponential growth and decay", "exponential-growth-decay"), + ("unit circle with degrees", "unit-circle"), + ("sine wave amplitude and period", "sine-wave"), + ] + for prompt, expected in cases: + d, score = main.demo_match(prompt) + self.assertIsNotNone(d, prompt) + self.assertEqual(d["id"], expected, prompt) + self.assertGreaterEqual(score, main._DEMO_THRESHOLD, prompt) + + def test_no_match_for_unrelated(self): + d, score = main.demo_match("my favorite pasta recipe") + self.assertIsNone(d) + self.assertEqual(score, 0.0) + + def test_demo_scene_shape(self): + d, _ = main.demo_match("slope intercept form of a line") + scene = main._demo_scene(d, "slope intercept form of a line") + self.assertTrue(scene["from_demo"]) + self.assertEqual(scene["engine"], "demo") + self.assertTrue(scene["code"].strip()) + self.assertTrue(scene["explanation"].strip()) + self.assertTrue(scene["params"]) + self.assertIn(scene["dimension"], ("2D", "3D")) + # params must be copies, not aliases into the library (UI mutates them). + self.assertIsNot(scene["params"][0], d["params"][0]) + + def test_plan_routes_to_demo(self): + # A clear topic prompt should resolve to the interactive demo before any + # network/generative path (demo check sits after the exact-prompt cache). + plan = main.plan_visualization("show me slope intercept form of a line", "auto") + self.assertTrue(plan.get("from_demo"), plan.get("engine")) + self.assertEqual(plan["demo_id"], "linear-slope-intercept") + + if __name__ == "__main__": unittest.main() diff --git a/validate_scene.js b/validate_scene.js index cd1316e..f593575 100644 --- a/validate_scene.js +++ b/validate_scene.js @@ -153,6 +153,10 @@ var ctx = makeCtx(); var cam = H.cam3d({}); var view = H.plot2d({}); var v = view; +// Demo parameters: a real run gets concrete values from the UI; for validation +// any P. resolves to 1 (a safe non-zero default) so parameterized demo +// code runs without "P is not defined". +var P = new Proxy({}, { get: function(){ return 1; } }); `; function readStdin() { From ef1959296ef6e565806d47368d03aba913806f7e Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Thu, 18 Jun 2026 19:27:14 +0700 Subject: [PATCH 19/43] Tutor: step-by-step problem solver + robust LaTeX cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tutor can now SOLVE problems, not just chat. New prompt mode (triggered by "solve / find / calculate / how do I"): a structured walkthrough that teaches the method — Goal: what we solve for (symbol + unit) What you need: the formula + each known value AND where it comes from (problem / constant / read off the animation) Steps: numbered, each substitutes the needed value right there, shows the intermediate result, and points to where it appears on screen Answer: result + a sanity check A persistent "→ Solve it step by step" quick-action chip fills the chat box (so the student can append their own numbers) and is styled to stand out. Local models ignore the "no LaTeX" rule, so the frontend stripper is the real safety net — hardened to handle the gaps seen live: \frac{{a}}{{b}} (doubled braces), \, \; \quad spacing macros, ^\circ -> °, \mathrm/\operatorname, and stray residue backslashes. Also: projectile/DNA example chips now fast-path to their curated scenes (added "launched/angle/degrees" and "structure" keywords); _MEIPASS read via getattr to silence Pylance. 93 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- app.js | 66 ++++++++++++++++++++++++++++++++++++------------ index.html | 4 +-- main.py | 34 ++++++++++++++++++++++--- scene_library.py | 5 ++-- styles.css | 8 ++++++ 5 files changed, 94 insertions(+), 23 deletions(-) diff --git a/app.js b/app.js index a41ccd3..9323c0c 100644 --- a/app.js +++ b/app.js @@ -54,19 +54,36 @@ 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"); // Greek + common symbols / function names. s = s.replace(/\\([A-Za-z]+)/g, (_, name) => @@ -74,6 +91,8 @@ ); // 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"); } @@ -936,24 +955,39 @@ 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", () => { + el.chatInput.value = (opts && opts.value) || text; + el.chatInput.focus(); + // 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 = ""; + // Always offer the step-by-step solver first. + addSuggestionChip("Solve it step by step", { solve: true, value: SOLVE_PROMPT }); (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(); - }); - el.chatSuggestions.appendChild(btn); + addSuggestionChip(prompt); }); } diff --git a/index.html b/index.html index 33474b5..cf70cd0 100644 --- a/index.html +++ b/index.html @@ -25,7 +25,7 @@ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" /> - + + diff --git a/main.py b/main.py index e20f26a..aa3203f 100644 --- a/main.py +++ b/main.py @@ -24,9 +24,12 @@ # 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). -if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): - BASE_DIR = Path(sys._MEIPASS) +# 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 @@ -2035,6 +2038,31 @@ 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): + + 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 thing — say what you do and WHY, + substitute the specific values you need RIGHT THERE, and show the + intermediate result with units. 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 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 it focused; 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, 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/styles.css b/styles.css index 77e1643..7cc9464 100644 --- a/styles.css +++ b/styles.css @@ -777,6 +777,14 @@ textarea:focus, input[type="text"]:focus { transition: background 120ms ease, border-color 120ms ease; } .suggestion-chip:hover { background: var(--hover); border-color: var(--border-2); } +/* The step-by-step solver: stands out as the primary tutor action. */ +.suggestion-chip.solve { + border-color: var(--info); + color: var(--info); + font-weight: 600; +} +.suggestion-chip.solve::before { content: "→ "; opacity: 0.8; } +.suggestion-chip.solve:hover { background: color-mix(in srgb, var(--info) 12%, transparent); } /* ============================================================== */ /* Responsive */ From 7b5b9ef80f93fe47e81b39d5208521a851a392ce Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Tue, 23 Jun 2026 19:10:00 +0700 Subject: [PATCH 20/43] Add 200 interactive Algebra 1 / Algebra 2 / Precalculus demos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authored by a multi-agent workflow (one agent per topic-batch, each self- validating its code via validate_scene.js), then RE-validated here through the same gate — all 200 pass (runs, paints on-screen, animates, labeled, has sliders). Covers the full requested curriculum: Algebra 1 (60), Algebra 2 (70), Precalculus (70). With the 12 hand-written demos that's 212 total. Each demo is a parameterized "fill in the values" scene: sliders feed P. into the code, the graph updates live, and an explanation builds intuition. Even non-graphable topics (order of operations, translating expressions, word problems, matrices, probability) became visual + interactive — worked examples with step sliders, number-line / bar / area models, tape diagrams. - demo_library_generated.json: the 200 validated demos (data). - demo_library.py: loads + merges the generated set with the hand-written base. - app.js: demos now labeled " demo • drag the sliders to explore". Verified in-browser: Law of Sines (live triangle, correct c≈0.48 at A=115°) and Order of Operations (stepped worked example) render with working sliders. 93 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- app.js | 6 + demo_library.py | 22 +- demo_library_generated.json | 10215 ++++++++++++++++++++++++++++++++++ index.html | 2 +- 4 files changed, 10243 insertions(+), 2 deletions(-) create mode 100644 demo_library_generated.json diff --git a/app.js b/app.js index 9323c0c..dee677f 100644 --- a/app.js +++ b/app.js @@ -822,6 +822,12 @@ "Regenerate or rephrase for a better scene.", "warn", ); + } 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( diff --git a/demo_library.py b/demo_library.py index 30875c4..ef14873 100644 --- a/demo_library.py +++ b/demo_library.py @@ -24,7 +24,7 @@ """ from __future__ import annotations -DEMO_LIBRARY: list[dict] = [ +_BASE: list[dict] = [ { "id": "linear-slope-intercept", "area": "Algebra 1", @@ -526,3 +526,23 @@ """.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 + +_GENERATED: list[dict] = [] +_gen_path = _Path(__file__).resolve().parent / "demo_library_generated.json" +try: + _loaded = _json.loads(_gen_path.read_text(encoding="utf-8")) + if isinstance(_loaded, list): + _GENERATED = _loaded +except (OSError, ValueError): + _GENERATED = [] + +# Hand-written demos first so they win id/keyword ties over generated ones. +DEMO_LIBRARY: list[dict] = _BASE + _GENERATED diff --git a/demo_library_generated.json b/demo_library_generated.json new file mode 100644 index 0000000..de71bf6 --- /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.0, + "max": 6.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "d", + "label": "distance d", + "min": 0.0, + "max": 6.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "d", + "label": "threshold d", + "min": 0.0, + "max": 7.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "gt", + "label": "0 = less than, 1 = greater", + "min": 0.0, + "max": 1.0, + "step": 1.0, + "value": 0.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.0, + "max": 5.0, + "step": 1.0, + "value": 2.0 + }, + { + "name": "b", + "label": "x-terms b", + "min": 0.0, + "max": 5.0, + "step": 1.0, + "value": 3.0 + }, + { + "name": "c", + "label": "units c", + "min": 0.0, + "max": 5.0, + "step": 1.0, + "value": 1.0 + }, + { + "name": "d", + "label": "units d", + "min": 0.0, + "max": 5.0, + "step": 1.0, + "value": 2.0 + } + ], + "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.0, + "max": 8.0, + "step": 0.5, + "value": -3.0 + }, + { + "name": "hi", + "label": "upper bound hi", + "min": -8.0, + "max": 8.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "mode", + "label": "0 = AND 1 = OR", + "min": 0.0, + "max": 1.0, + "step": 1.0, + "value": 0.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.0, + "max": 20.0, + "step": 1.0, + "value": 5.0 + }, + { + "name": "gap", + "label": "gap between terms", + "min": 1.0, + "max": 3.0, + "step": 1.0, + "value": 1.0 + }, + { + "name": "count", + "label": "how many terms", + "min": 2.0, + "max": 5.0, + "step": 1.0, + "value": 3.0 + } + ], + "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.0, + "max": 7.0, + "step": 1.0, + "value": 4.0 + }, + { + "name": "b", + "label": "y-coordinate b", + "min": -7.0, + "max": 7.0, + "step": 1.0, + "value": 3.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "b", + "label": "first term b", + "min": 1.0, + "max": 5.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "c", + "label": "second term c", + "min": 1.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "max": 0.0, + "step": 0.5, + "value": -6.0 + }, + { + "name": "xhi", + "label": "domain end x", + "min": 0.0, + "max": 7.0, + "step": 0.5, + "value": 6.0 + }, + { + "name": "amp", + "label": "amplitude (sets range)", + "min": 1.0, + "max": 6.0, + "step": 0.5, + "value": 5.0 + } + ], + "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;\nfor (let i = 0; i <= N; i++) { const x = lo + (hi - lo) * i / N; pts.push([x, f(x)]); }\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, -amp, 0, amp, { 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 [\" + (-amp).toFixed(1) + \", \" + amp.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);" + }, + { + "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.0, + "max": 8.0, + "step": 1.0, + "value": 4.0 + }, + { + "name": "q", + "label": "denominator q", + "min": 2.0, + "max": 8.0, + "step": 1.0, + "value": 6.0 + }, + { + "name": "r", + "label": "right side r", + "min": 0.5, + "max": 4.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "step", + "label": "solving step", + "min": 0.0, + "max": 2.0, + "step": 1.0, + "value": 0.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.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "b", + "label": "inside add b", + "min": 0.0, + "max": 4.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "c", + "label": "result c", + "min": 1.0, + "max": 20.0, + "step": 1.0, + "value": 14.0 + }, + { + "name": "step", + "label": "solving step", + "min": 0.0, + "max": 2.0, + "step": 1.0, + "value": 1.0 + } + ], + "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.0, + "max": 4.0, + "step": 1.0, + "value": 2.0 + }, + { + "name": "x", + "label": "value of x", + "min": -6.0, + "max": 6.0, + "step": 1.0, + "value": 3.0 + }, + { + "name": "b", + "label": "constant b", + "min": -8.0, + "max": 8.0, + "step": 1.0, + "value": 1.0 + } + ], + "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.0, + "max": 5.0, + "step": 1.0, + "value": 2.0 + }, + { + "name": "m", + "label": "exponent m", + "min": 0.0, + "max": 5.0, + "step": 1.0, + "value": 3.0 + }, + { + "name": "n", + "label": "exponent n", + "min": 0.0, + "max": 5.0, + "step": 1.0, + "value": 2.0 + } + ], + "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.0, + "max": 6.0, + "step": 1.0, + "value": 2.0 + }, + { + "name": "q", + "label": "second number q", + "min": -6.0, + "max": 6.0, + "step": 1.0, + "value": 3.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "q", + "label": "run (denominator) q", + "min": 1.0, + "max": 4.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "b", + "label": "intercept b", + "min": -1.0, + "max": 4.0, + "step": 0.5, + "value": 0.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.0, + "max": 1.0, + "step": 0.1, + "value": 0.5 + }, + { + "name": "b", + "label": "b", + "min": -3.0, + "max": 3.0, + "step": 0.5, + "value": -1.0 + }, + { + "name": "c", + "label": "c", + "min": -2.0, + "max": 8.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 12.0, + "step": 0.5, + "value": 8.0 + }, + { + "name": "width", + "label": "width W", + "min": 1.0, + "max": 8.0, + "step": 0.5, + "value": 5.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.1, + "value": 2.0 + }, + { + "name": "b1", + "label": "left const b1", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "a2", + "label": "right slope a2", + "min": -3.0, + "max": 3.0, + "step": 0.1, + "value": -1.0 + }, + { + "name": "b2", + "label": "right const b2", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "max": 120.0, + "step": 5.0, + "value": 100.0 + }, + { + "name": "fixed", + "label": "fixed cost $", + "min": 0.0, + "max": 40.0, + "step": 5.0, + "value": 20.0 + }, + { + "name": "cost", + "label": "cost / item $", + "min": 1.0, + "max": 20.0, + "step": 0.5, + "value": 8.0 + } + ], + "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.0, + "max": 9.0, + "step": 1.0, + "value": -3.0 + }, + { + "name": "b", + "label": "add b", + "min": -9.0, + "max": 9.0, + "step": 1.0, + "value": 5.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.1, + "value": 0.5 + }, + { + "name": "r1", + "label": "root r1", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": -3.0 + }, + { + "name": "r2", + "label": "root r2", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.5, + "value": 1.5 + }, + { + "name": "b", + "label": "start value b", + "min": -1.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 6.0, + "step": 1.0, + "value": -3.0 + }, + { + "name": "y1", + "label": "point 1 y", + "min": -6.0, + "max": 6.0, + "step": 1.0, + "value": -1.0 + }, + { + "name": "x2", + "label": "point 2 x", + "min": -6.0, + "max": 6.0, + "step": 1.0, + "value": 2.0 + }, + { + "name": "y2", + "label": "point 2 y", + "min": -6.0, + "max": 6.0, + "step": 1.0, + "value": 3.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.05, + "value": 1.0 + }, + { + "name": "b", + "label": "intercept b", + "min": -3.0, + "max": 5.0, + "step": 0.25, + "value": 1.0 + } + ], + "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.0, + "max": 40.0, + "step": 1.0, + "value": 24.0 + }, + { + "name": "width", + "label": "width w", + "min": 1.0, + "max": 10.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "max": 12.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "volB", + "label": "volume B (L)", + "min": 0.0, + "max": 12.0, + "step": 0.5, + "value": 6.0 + }, + { + "name": "concA", + "label": "concentration A (%)", + "min": 0.0, + "max": 100.0, + "step": 5.0, + "value": 10.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "b", + "label": "added b", + "min": -10.0, + "max": 10.0, + "step": 1.0, + "value": 5.0 + }, + { + "name": "c", + "label": "result c", + "min": -10.0, + "max": 20.0, + "step": 1.0, + "value": 14.0 + }, + { + "name": "step", + "label": "solving step", + "min": 0.0, + "max": 2.0, + "step": 1.0, + "value": 2.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b", + "label": "add b", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "c", + "label": "threshold c", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "max": 2.0, + "step": 1.0, + "value": 0.0 + }, + { + "name": "b", + "label": "left intercept b", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "gap", + "label": "intercept gap", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "max": 6.0, + "step": 1.0, + "value": 3.0 + }, + { + "name": "c", + "label": "right side c", + "min": -12.0, + "max": 12.0, + "step": 1.0, + "value": 6.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "c", + "label": "right side c", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 5.0 + } + ], + "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.0, + "max": 9.0, + "step": 1.0, + "value": 2.0 + }, + { + "name": "b", + "label": "b", + "min": 0.0, + "max": 9.0, + "step": 1.0, + "value": 3.0 + }, + { + "name": "c", + "label": "c", + "min": 0.0, + "max": 9.0, + "step": 1.0, + "value": 4.0 + } + ], + "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 });\nH.text(\"≠ (\" + a + \" + \" + b + \") × \" + c + \" = \" + ((a + b) * c), 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.0, + "max": 4.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b1", + "label": "intercept b1", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "b2", + "label": "intercept b2", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": -2.0 + } + ], + "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.0, + "max": 100.0, + "step": 5.0, + "value": 50.0 + }, + { + "name": "pct", + "label": "percent change p (%)", + "min": -80.0, + "max": 80.0, + "step": 5.0, + "value": 20.0 + } + ], + "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.0, + "max": 4.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b", + "label": "shared intercept b", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 4.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "x1", + "label": "point x1", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": -2.0 + }, + { + "name": "y1", + "label": "point y1", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 5.0, + "step": 1.0, + "value": 2.0 + }, + { + "name": "b", + "label": "b (const, left)", + "min": -4.0, + "max": 5.0, + "step": 1.0, + "value": 3.0 + }, + { + "name": "c", + "label": "c (x coef, right)", + "min": 1.0, + "max": 5.0, + "step": 1.0, + "value": 1.0 + }, + { + "name": "d", + "label": "d (const, right)", + "min": -4.0, + "max": 5.0, + "step": 1.0, + "value": 4.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);\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.0, + "max": 9.0, + "step": 1.0, + "value": 2.0 + }, + { + "name": "b", + "label": "b (bottom of ratio 1)", + "min": 1.0, + "max": 9.0, + "step": 1.0, + "value": 3.0 + }, + { + "name": "c", + "label": "c (top of ratio 2)", + "min": 1.0, + "max": 9.0, + "step": 1.0, + "value": 6.0 + } + ], + "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.0, + "max": 75.0, + "step": 5.0, + "value": 55.0 + }, + { + "name": "time", + "label": "time t (hours)", + "min": 1.0, + "max": 10.0, + "step": 0.5, + "value": 8.0 + } + ], + "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.0, + "max": 10.0, + "step": 0.5, + "value": 6.0 + }, + { + "name": "b", + "label": "x-amount b", + "min": 1.0, + "max": 6.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "b", + "label": "piece b", + "min": 1.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "step", + "label": "order: a+b / b+a", + "min": 0.0, + "max": 1.0, + "step": 1.0, + "value": 0.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.0, + "max": 100.0, + "step": 1.0, + "value": 20.0 + } + ], + "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.0, + "max": 4.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "h", + "label": "base height", + "min": 1.0, + "max": 3.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "k", + "label": "scale factor k", + "min": 0.5, + "max": 3.0, + "step": 0.25, + "value": 2.0 + } + ], + "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.0, + "max": 9.9, + "step": 0.1, + "value": 4.2 + }, + { + "name": "exp", + "label": "exponent n", + "min": -6.0, + "max": 9.0, + "step": 1.0, + "value": 3.0 + } + ], + "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.0, + "max": 7.0, + "step": 1.0, + "value": -3.0 + }, + { + "name": "y1", + "label": "point 1 y", + "min": -7.0, + "max": 7.0, + "step": 1.0, + "value": -2.0 + }, + { + "name": "x2", + "label": "point 2 x", + "min": -7.0, + "max": 7.0, + "step": 1.0, + "value": 4.0 + }, + { + "name": "y2", + "label": "point 2 y", + "min": -7.0, + "max": 7.0, + "step": 1.0, + "value": 3.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.5, + "value": -2.0 + }, + { + "name": "dx", + "label": "step in x Δx", + "min": 0.5, + "max": 3.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "m", + "label": "slope m", + "min": -3.0, + "max": 3.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "b", + "label": "start value b", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.floor(t % rows);\nconst hiNext = (hi + 1) % rows;\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.0, + "max": 4.0, + "step": 0.1, + "value": 1.5 + }, + { + "name": "b", + "label": "y-intercept b", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 0.0, + "step": 0.5, + "value": -4.0 + }, + { + "name": "y1", + "label": "point 1 y1", + "min": -7.0, + "max": 7.0, + "step": 0.5, + "value": -2.0 + }, + { + "name": "x2", + "label": "point 2 x2", + "min": 0.5, + "max": 7.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "y2", + "label": "point 2 y2", + "min": -7.0, + "max": 7.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "max": 4.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b", + "label": "y-intercept b", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "B", + "label": "B", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "C", + "label": "C", + "min": -8.0, + "max": 8.0, + "step": 1.0, + "value": 6.0 + } + ], + "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.0, + "max": 4.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b1", + "label": "intercept 1 b1", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "m2", + "label": "slope 2 m2", + "min": -4.0, + "max": 4.0, + "step": 0.1, + "value": -1.0 + }, + { + "name": "b2", + "label": "intercept 2 b2", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": -1.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "c1", + "label": "eq1 constant c1", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "a2", + "label": "eq2 x-coef a2", + "min": -3.0, + "max": 3.0, + "step": 0.1, + "value": -1.0 + }, + { + "name": "c2", + "label": "eq2 constant c2", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": -2.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b1", + "label": "lower intercept b1", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": -2.0 + }, + { + "name": "m2", + "label": "upper slope m2", + "min": -3.0, + "max": 3.0, + "step": 0.1, + "value": -1.0 + }, + { + "name": "b2", + "label": "upper intercept b2", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.1, + "value": 0.5 + }, + { + "name": "d", + "label": "line-2 gap d", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.1, + "value": 1.5 + }, + { + "name": "b", + "label": "intercept b", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": -1.0 + }, + { + "name": "c", + "label": "eq2 constant c", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "max": 12.0, + "step": 1.0, + "value": 10.0 + }, + { + "name": "aprice", + "label": "adult price", + "min": 2.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "money", + "label": "total money", + "min": 10.0, + "max": 40.0, + "step": 1.0, + "value": 18.0 + } + ], + "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.0, + "max": 6.0, + "step": 1.0, + "value": 3.0 + }, + { + "name": "b", + "label": "number b", + "min": -9.0, + "max": 9.0, + "step": 1.0, + "value": 6.0 + }, + { + "name": "rel", + "label": "relation (0-3)", + "min": 0.0, + "max": 3.0, + "step": 1.0, + "value": 0.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.0, + "max": 12.0, + "step": 1.0, + "value": 5.0 + }, + { + "name": "phrase", + "label": "phrase (0-3)", + "min": 0.0, + "max": 3.0, + "step": 1.0, + "value": 0.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.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "b", + "label": "constant b", + "min": -6.0, + "max": 6.0, + "step": 1.0, + "value": 1.0 + }, + { + "name": "c", + "label": "right side c", + "min": -8.0, + "max": 8.0, + "step": 1.0, + "value": 5.0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -10, yMax: 10 });\nv.grid(); v.axes();\nconst a = (Math.abs(P.a) < 1e-6 ? 1 : P.a), b = P.b, c = P.c;\nconst sol = H.clamp((c - b) / a, -10, 10);\n// line y = a x + b\nv.fn(x => a * x + b, { color: H.colors.accent, width: 3 });\n// target horizontal line y = c\nv.line(-10, c, 10, c, { color: H.colors.accent2, width: 2, dash: [6, 5] });\n// intersection = the solution x\nv.dot(sol, c, { r: 6 + Math.sin(t * 3), fill: H.colors.warn });\nv.line(sol, -10, sol, c, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\n// stepped reveal: phase 0 show equation, 1 subtract b, 2 divide a\nconst phase = Math.floor((t % 9) / 3);\nconst steps = [\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 = \" + ((c - b) / a).toFixed(2)\n];\n// moving dot riding the line toward the solution\nconst xs = sol * (0.5 + 0.5 * Math.sin(t * 0.8));\nv.dot(xs, a * xs + b, { r: 5, fill: H.colors.good });\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.text(\"x = \" + ((c - b) / a).toFixed(2), v.X(sol) + 8, v.Y(c) - 8, { color: H.colors.warn, size: 13, weight: 700 });\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.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "b", + "label": "left constant b", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "c", + "label": "right slope c", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": -1.0 + }, + { + "name": "d", + "label": "right constant d", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 4.0 + } + ], + "code": "H.background();\nconst a = P.a, b = P.b, c = P.c, d = P.d;\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\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 (Math.abs(a - c) > 1e-6) {\n const xs = (d - b) / (a - c), ys = a * xs + b;\n v.line(xs, v.yMin, 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 + 0.2, ys + 0.6, { 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\nconst xp = 6 * 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.0, + "max": 4.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b", + "label": "y-intercept b", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.good });\nv.text(\"y-int (0, \" + b.toFixed(1) + \")\", 0.3, b + 0.7, { color: H.colors.good, size: 12 });\nlet xint = NaN;\nif (Math.abs(m) > 1e-9) {\n xint = -b / m;\n v.dot(xint, 0, { r: 7, fill: H.colors.warn });\n v.text(\"x-int (\" + xint.toFixed(1) + \", 0)\", xint + 0.3, -0.7, { color: H.colors.warn, size: 12 });\n}\nconst xs = 6 * 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.0, + "max": 5.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "d", + "label": "right side d", + "min": -1.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -2, yMax: 10 });\nv.grid(); v.axes();\nconst h = P.h, d = P.d;\nconst f = x => Math.abs(x - h);\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(-8, d, 8, 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 + 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.0, + "max": 3.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "h", + "label": "shift right h", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "k", + "label": "shift up k", + "min": -3.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "k", + "label": "tolerance k", + "min": 0.5, + "max": 6.0, + "step": 0.5, + "value": 4.0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -2, yMax: 10 });\nv.grid(); v.axes();\nconst c = P.c, k = Math.max(0, P.k);\nconst f = (x) => Math.abs(x - c);\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(-10, k, 10, 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 + (k + 3) * 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.0, + "max": 6.0, + "step": 1.0, + "value": 1.0 + }, + { + "name": "b", + "label": "bottom of 1st b", + "min": 1.0, + "max": 5.0, + "step": 1.0, + "value": 2.0 + }, + { + "name": "c", + "label": "top of 2nd c", + "min": 1.0, + "max": 6.0, + "step": 1.0, + "value": 1.0 + }, + { + "name": "d", + "label": "bottom of 2nd d", + "min": 1.0, + "max": 5.0, + "step": 1.0, + "value": 3.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);\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;\nfunction bar(y, label, val, den, col, lit) {\n H.text(label, 24, y + rowH * 0.62, { color: H.colors.sub, size: 14 });\n H.rect(barX, y, barW, rowH * 0.7, { stroke: H.colors.grid, width: 1.5, radius: 6 });\n const n = Math.max(1, Math.abs(den));\n const seg = barW / n;\n const fillN = H.clamp(val, 0, n);\n for (let i = 0; i < n; i++) {\n const lit2 = i < fillN;\n H.rect(barX + i * seg + 2, y + 2, seg - 4, rowH * 0.7 - 4, { fill: lit2 ? col : H.colors.panel, radius: 3 });\n }\n // sweeping highlight to keep motion alive and bounded\n if (lit) {\n const sx = barX + ((t * 0.6) % 1) * barW;\n H.line(sx, y - 4, sx, y + rowH * 0.7 + 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.0, + "max": 8.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "d", + "label": "common difference d", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 10.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "d", + "label": "common difference d", + "min": -2.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "n", + "label": "how many terms n", + "min": 2.0, + "max": 10.0, + "step": 1.0, + "value": 6.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "c", + "label": "constant c", + "min": -4.0, + "max": 8.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "b", + "label": "imag part b", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "b", + "label": "Im z1 b", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "c", + "label": "Re z2 c", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": -2.0 + }, + { + "name": "d", + "label": "Im z2 d", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b", + "label": "b", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "c", + "label": "c", + "min": -8.0, + "max": 8.0, + "step": 0.5, + "value": 5.0 + } + ], + "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.0, + "max": 2000.0, + "step": 100.0, + "value": 1000.0 + }, + { + "name": "rate", + "label": "annual rate r", + "min": 0.0, + "max": 0.2, + "step": 0.01, + "value": 0.05 + }, + { + "name": "n", + "label": "compounds / year n", + "min": 1.0, + "max": 12.0, + "step": 1.0, + "value": 12.0 + } + ], + "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.0, + "max": 20.0, + "step": 1.0, + "value": 10.0 + }, + { + "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.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "k", + "label": "center y k", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "r", + "label": "radius r", + "min": 0.5, + "max": 5.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "max": 1.0, + "step": 1.0, + "value": 0.0 + }, + { + "name": "a", + "label": "semi-axis a", + "min": 1.0, + "max": 7.0, + "step": 0.5, + "value": 5.0 + }, + { + "name": "b", + "label": "semi-axis b", + "min": 1.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + } + ], + "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]);\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) + \" = 2a = \" + (2 * a).toFixed(2)) : (\"d1 + d2 = \" + (d1 + d2).toFixed(2) + \" = 2a = \" + (2 * Math.max(a, b)).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.0, + "max": 2.0, + "step": 0.1, + "value": 0.5 + }, + { + "name": "h", + "label": "vertex x h", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "k", + "label": "vertex y k", + "min": -2.0, + "max": 8.0, + "step": 0.5, + "value": -2.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b", + "label": "b", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "c", + "label": "c", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": -3.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "c", + "label": "col1 y c", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "b", + "label": "col2 x b", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "d", + "label": "col2 y d", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b", + "label": "b (slant)", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "c", + "label": "c (raises/lowers)", + "min": -8.0, + "max": 8.0, + "step": 0.5, + "value": -4.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": -1.0 + }, + { + "name": "k", + "label": "start y k", + "min": -1.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 10.0, + "step": 0.5, + "value": 8.0 + }, + { + "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.0, + "step": 0.1, + "value": 2.0 + }, + { + "name": "C", + "label": "target C", + "min": 0.5, + "max": 10.0, + "step": 0.5, + "value": 5.0 + } + ], + "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.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "r", + "label": "growth rate r", + "min": 0.0, + "max": 1.0, + "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.0, + "step": 0.1, + "value": 2.0 + }, + { + "name": "k", + "label": "vertical shift k", + "min": -3.0, + "max": 3.0, + "step": 0.5, + "value": 0.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.0, + "max": 6.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "b", + "label": "line shift b", + "min": -4.0, + "max": 3.0, + "step": 0.5, + "value": -1.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "c", + "label": "candidate c", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "r1", + "label": "root r₁", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": -2.0 + }, + { + "name": "r2", + "label": "root r₂", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "probe", + "label": "probe c", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": -4.0 + }, + { + "name": "r2", + "label": "zero r₂", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "r3", + "label": "zero r₃", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b", + "label": "inner shift b", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "c", + "label": "outer shift c", + "min": -4.0, + "max": 6.0, + "step": 0.5, + "value": 0.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.0, + "max": 2.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b", + "label": "b", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "c", + "label": "c", + "min": -4.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "input", + "label": "input x", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b", + "label": "f intercept b", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "op", + "label": "op: 1+ 2- 3* 4/", + "min": 1.0, + "max": 4.0, + "step": 1.0, + "value": 1.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "h", + "label": "shift right h", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": -2.0 + }, + { + "name": "k", + "label": "shift up k", + "min": -4.0, + "max": 8.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "r", + "label": "common ratio r", + "min": -2.0, + "max": 2.0, + "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.0, + "max": 8.0, + "step": 0.5, + "value": 4.0 + }, + { + "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.0, + "max": 12.0, + "step": 1.0, + "value": 8.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": -2.0 + }, + { + "name": "va", + "label": "asymptote at x =", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "b", + "label": "next coef b", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "d", + "label": "vert. asym. x = d", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "topdeg", + "label": "top degree (1 or 2)", + "min": 1.0, + "max": 2.0, + "step": 1.0, + "value": 1.0 + } + ], + "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.0, + "max": 12.0, + "step": 1.0, + "value": 7.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.1, + "value": 2.0 + }, + { + "name": "b", + "label": "intercept b", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.1, + "value": 0.5 + }, + { + "name": "c", + "label": "parabola c", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": -2.0 + }, + { + "name": "m", + "label": "line slope m", + "min": -4.0, + "max": 4.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "k", + "label": "line intercept k", + "min": -6.0, + "max": 8.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "step": 0.1, + "value": 2.0 + }, + { + "name": "x", + "label": "input x", + "min": 0.5, + "max": 8.0, + "step": 0.5, + "value": 4.0 + } + ], + "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=\" + Math.pow(b, e).toFixed(1) + \", y=\" + e.toFixed(2) + \")\", 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.0, + "max": 5.0, + "step": 1.0, + "value": 2.0 + }, + { + "name": "M", + "label": "factor M", + "min": 1.0, + "max": 16.0, + "step": 1.0, + "value": 4.0 + }, + { + "name": "N", + "label": "factor N", + "min": 1.0, + "max": 16.0, + "step": 1.0, + "value": 8.0 + } + ], + "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.0, + "step": 0.1, + "value": 2.0 + }, + { + "name": "C", + "label": "target C", + "min": -2.0, + "max": 3.0, + "step": 0.1, + "value": 2.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "b", + "label": "B entry b11", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "s", + "label": "scalar s", + "min": -3.0, + "max": 3.0, + "step": 0.5, + "value": 2.0 + } + ], + "code": "H.background();\nconst w = H.W, hh = H.H;\nconst s = Math.max(0.2, 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.0, + "max": 4.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "b", + "label": "b (row1 y)", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "c", + "label": "c (row2 x)", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "d", + "label": "d (row2 y)", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": -1.0 + } + ], + "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.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "b", + "label": "A entry a12", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "c", + "label": "B entry b11", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "d", + "label": "B entry b22", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.5, + "value": 0.5 + }, + { + "name": "root", + "label": "root r", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "m", + "label": "multiplicity m", + "min": 1.0, + "max": 5.0, + "step": 1.0, + "value": 2.0 + } + ], + "code": "H.background();\nconst root = P.root, m = Math.max(1, Math.round(P.m)), a = 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(xs, f(xs), { 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.0, + "max": 12.0, + "step": 1.0, + "value": 2.0 + }, + { + "name": "b", + "label": "bottom 1 b", + "min": 1.0, + "max": 12.0, + "step": 1.0, + "value": 3.0 + }, + { + "name": "c", + "label": "top 2 c", + "min": 1.0, + "max": 12.0, + "step": 1.0, + "value": 4.0 + }, + { + "name": "d", + "label": "bottom 2 d", + "min": 1.0, + "max": 12.0, + "step": 1.0, + "value": 6.0 + }, + { + "name": "op", + "label": "0 = × 1 = ÷", + "min": 0.0, + "max": 1.0, + "step": 1.0, + "value": 0.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.0, + "max": 6.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "sigma", + "label": "std dev σ", + "min": 1.0, + "max": 4.0, + "step": 0.25, + "value": 2.0 + } + ], + "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 = mu + 3 * sd * Math.sin(t * 0.6);\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.0, + "max": 6.0, + "step": 1.0, + "value": 2.0 + } + ], + "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);\nif (Number.isFinite(ys) && ys >= -6 && ys <= 6) {\n v.dot(xs, ys, { r: 7, fill: H.colors.warn });\n} else {\n v.dot(0, f(0.0001) || 0, { r: 7, fill: H.colors.warn });\n ys = NaN;\n}\nv.dot(0, Number.isFinite(f(0)) ? f(0) : 0, { 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) : \"undef\"), 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.0, + "max": 10.0, + "step": 1.0, + "value": 5.0 + }, + { + "name": "r", + "label": "pick r", + "min": 1.0, + "max": 6.0, + "step": 1.0, + "value": 3.0 + }, + { + "name": "kind", + "label": "0 perm / 1 comb", + "min": 0.0, + "max": 1.0, + "step": 1.0, + "value": 1.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "m1", + "label": "left slope m1", + "min": -3.0, + "max": 3.0, + "step": 0.1, + "value": -1.0 + }, + { + "name": "m2", + "label": "right slope m2", + "min": -3.0, + "max": 3.0, + "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, yb, { r: 6, fill: H.colors.warn });\nconst x0 = 6 * Math.sin(t * 0.6);\nv.dot(x0, piece(x0), { 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.0, + "max": 2.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "n", + "label": "degree n", + "min": 1.0, + "max": 6.0, + "step": 1.0, + "value": 3.0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -3, xMax: 3, yMin: -12, yMax: 12 });\nv.grid(); v.axes();\nconst a = P.a, 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.0, + "max": 2.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "r1", + "label": "root r1", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": -3.0 + }, + { + "name": "r2", + "label": "root r2", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "r3", + "label": "root r3", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "max": 4.0, + "step": 0.5, + "value": -3.0 + }, + { + "name": "r2", + "label": "root r2", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "r3", + "label": "root r3", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "dir", + "label": "0 = (<0) 1 = (>0)", + "min": 0.0, + "max": 1.0, + "step": 1.0, + "value": 1.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "c", + "label": "dividend const c", + "min": -8.0, + "max": 8.0, + "step": 0.5, + "value": -6.0 + }, + { + "name": "r", + "label": "divisor root r", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "step", + "label": "reveal step", + "min": 0.0, + "max": 5.0, + "step": 1.0, + "value": 5.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.25, + "value": 1.0 + }, + { + "name": "b1", + "label": "p: x coef b1", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "a2", + "label": "q: x² coef a2", + "min": -2.0, + "max": 2.0, + "step": 0.25, + "value": -0.5 + }, + { + "name": "b2", + "label": "q: x coef b2", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "r1", + "label": "root r1", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": -3.0 + }, + { + "name": "r2", + "label": "root r2", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b", + "label": "b", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": -1.0 + }, + { + "name": "c", + "label": "c", + "min": -8.0, + "max": 8.0, + "step": 0.5, + "value": -6.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b", + "label": "b", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "c", + "label": "c", + "min": -8.0, + "max": 8.0, + "step": 0.5, + "value": -4.0 + } + ], + "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 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 v.dot(lo, 0, { r: 6, fill: H.colors.good });\n v.dot(hi, 0, { r: 6, fill: H.colors.good });\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 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.0, + "max": 20.0, + "step": 0.5, + "value": 12.0 + }, + { + "name": "h0", + "label": "start height h0", + "min": 0.0, + "max": 8.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "g", + "label": "gravity g", + "min": 2.0, + "max": 12.0, + "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.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "c", + "label": "vertical shift c", + "min": -3.0, + "max": 4.0, + "step": 0.5, + "value": -1.0 + }, + { + "name": "L", + "label": "level L", + "min": -3.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "h", + "label": "start x h", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": -2.0 + }, + { + "name": "k", + "label": "start y k", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 0.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.0, + "max": 200.0, + "step": 1.0, + "value": 72.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "h", + "label": "shift h", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "k", + "label": "target k", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 6.0, + "step": 1.0, + "value": 3.0 + }, + { + "name": "q", + "label": "denominator q (root)", + "min": 1.0, + "max": 6.0, + "step": 1.0, + "value": 2.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "h", + "label": "vert asymptote h", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "k", + "label": "horiz asymptote k", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": -1.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": -3.0 + }, + { + "name": "q", + "label": "undefined at q", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "L", + "label": "level L", + "min": -3.0, + "max": 3.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 8.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "b", + "label": "multiplier b", + "min": -1.5, + "max": 2.0, + "step": 0.1, + "value": 1.5 + }, + { + "name": "c", + "label": "add each step c", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "b", + "label": "coef b (x)", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": -1.0 + }, + { + "name": "c", + "label": "coef c (1)", + "min": -8.0, + "max": 8.0, + "step": 0.5, + "value": -2.0 + }, + { + "name": "k", + "label": "test value k", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "q", + "label": "zero q", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": -2.0 + }, + { + "name": "r", + "label": "asymptote r", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": -3.0 + } + ], + "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.0, + "max": 4.0, + "step": 1.0, + "value": 1.0 + }, + { + "name": "b", + "label": "coef b (x)", + "min": -8.0, + "max": 8.0, + "step": 1.0, + "value": -5.0 + }, + { + "name": "c", + "label": "coef c (1)", + "min": -12.0, + "max": 12.0, + "step": 1.0, + "value": 6.0 + }, + { + "name": "r", + "label": "divisor r", + "min": -4.0, + "max": 4.0, + "step": 1.0, + "value": 2.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": -2.0 + }, + { + "name": "c", + "label": "range floor c", + "min": -1.0, + "max": 6.0, + "step": 0.5, + "value": 0.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.0, + "max": 3.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b", + "label": "horiz squeeze b", + "min": 0.2, + "max": 3.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "h", + "label": "shift right h", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "k", + "label": "shift up k", + "min": -4.0, + "max": 6.0, + "step": 0.5, + "value": 0.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.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 75.0, + "step": 1.0, + "value": 35.0 + }, + { + "name": "b", + "label": "side b (adjacent)", + "min": 3.0, + "max": 8.0, + "step": 0.5, + "value": 6.0 + }, + { + "name": "a", + "label": "side a (opposite)", + "min": 1.0, + "max": 8.0, + "step": 0.25, + "value": 4.0 + } + ], + "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.0, + "step": 0.1, + "value": 3.0 + }, + { + "name": "B", + "label": "frequency B", + "min": 0.5, + "max": 4.0, + "step": 0.1, + "value": 1.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "ay", + "label": "a y-component", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "bx", + "label": "b x-component", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 5.0 + }, + { + "name": "by", + "label": "b y-component", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "step": 0.1, + "value": 2.0 + }, + { + "name": "deg", + "label": "central angle (degrees)", + "min": 10.0, + "max": 350.0, + "step": 1.0, + "value": 120.0 + } + ], + "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.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "d", + "label": "difference d", + "min": -2.0, + "max": 3.0, + "step": 0.5, + "value": 1.0 + }, + { + "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.0, + "max": 1.0, + "step": 1.0, + "value": 0.0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 11, yMin: -2, yMax: 12 });\nv.grid(); v.axes();\nconst a1 = P.a1, d = P.d, r = P.r, mode = P.mode;\nconst geometric = mode >= 0.5;\nconst N = 10;\nconst pts = [];\nconst term = (n) => geometric ? a1 * Math.pow(r, n - 1) : a1 + (n - 1) * d;\nfor (let n = 1; n <= N; n++) {\n let y = term(n);\n if (!Number.isFinite(y)) y = 0;\n y = H.clamp(y, -1.8, 11.8);\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 = term(k);\nv.line(k, -2, 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.0, + "max": 1.0, + "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.0, + "max": 6.0, + "step": 1.0, + "value": 4.0 + }, + { + "name": "x", + "label": "x value", + "min": 0.5, + "max": 3.0, + "step": 0.1, + "value": 1.5 + }, + { + "name": "y", + "label": "y value", + "min": 0.5, + "max": 3.0, + "step": 0.1, + "value": 1.0 + } + ], + "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.0 + }, + { + "name": "d1", + "label": "angle θ1 (deg)", + "min": 0.0, + "max": 180.0, + "step": 1.0, + "value": 30.0 + }, + { + "name": "r2", + "label": "modulus r2", + "min": 0.5, + "max": 2.5, + "step": 0.1, + "value": 2.0 + }, + { + "name": "d2", + "label": "angle θ2 (deg)", + "min": 0.0, + "max": 180.0, + "step": 1.0, + "value": 45.0 + } + ], + "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.0, + "step": 0.1, + "value": 2.0 + }, + { + "name": "arg", + "label": "angle θ (deg)", + "min": 0.0, + "max": 360.0, + "step": 1.0, + "value": 60.0 + }, + { + "name": "n", + "label": "root count n", + "min": 2.0, + "max": 8.0, + "step": 1.0, + "value": 5.0 + } + ], + "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.0, + "step": 0.1, + "value": 3.5 + }, + { + "name": "deg", + "label": "argument θ (degrees)", + "min": 0.0, + "max": 360.0, + "step": 1.0, + "value": 40.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b", + "label": "inner shift b", + "min": -3.0, + "max": 3.0, + "step": 0.5, + "value": 0.0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -6, yMax: 10 });\nv.grid(); v.axes();\nconst m = P.m, b = P.b;\nconst g = (x) => m * x + b;\nconst f = (u) => u * u;\nconst comp = (x) => f(g(x));\nv.fn(g, { color: H.colors.accent2, width: 2 });\nv.fn(comp, { color: H.colors.accent, width: 3 });\nconst x0 = 4 * 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.0, + "max": 6.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "k", + "label": "center y k", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "r", + "label": "radius r", + "min": 0.5, + "max": 5.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "k", + "label": "center y k", + "min": -3.0, + "max": 3.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "a", + "label": "semi-axis a", + "min": 1.0, + "max": 8.0, + "step": 0.5, + "value": 6.0 + }, + { + "name": "b", + "label": "semi-axis b", + "min": 1.0, + "max": 6.0, + "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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "b", + "label": "semi-conjugate b", + "min": 0.5, + "max": 5.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "k", + "label": "vertex y k", + "min": -1.0, + "max": 5.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "p", + "label": "focal length p", + "min": 0.3, + "max": 3.0, + "step": 0.1, + "value": 1.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "jump", + "label": "jump size", + "min": -3.0, + "max": 3.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "hole", + "label": "hole (0=no,1=yes)", + "min": 0.0, + "max": 1.0, + "step": 1.0, + "value": 0.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.0, + "max": 120.0, + "step": 1.0, + "value": 35.0 + }, + { + "name": "n", + "label": "power n", + "min": 1.0, + "max": 8.0, + "step": 1.0, + "value": 5.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 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.0, + "max": 360.0, + "step": 1.0, + "value": 90.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.1, + "value": 1.5 + }, + { + "name": "h", + "label": "gap h", + "min": 0.2, + "max": 4.0, + "step": 0.1, + "value": 3.0 + } + ], + "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.0, + "max": 8.0, + "step": 0.5, + "value": 6.0 + }, + { + "name": "ay", + "label": "a: y-component", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "bmag", + "label": "|b| length", + "min": 1.0, + "max": 8.0, + "step": 0.5, + "value": 5.0 + }, + { + "name": "bangle", + "label": "b angle (deg)", + "min": 0.0, + "max": 360.0, + "step": 1.0, + "value": 70.0 + } + ], + "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.0, + "max": 360.0, + "step": 1.0, + "value": 60.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "k", + "label": "shift up k", + "min": -2.0, + "max": 5.0, + "step": 0.5, + "value": 0.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.0, + "max": 1.0, + "step": 0.1, + "value": 0.2 + }, + { + "name": "c2", + "label": "x^2 coef (even)", + "min": -1.0, + "max": 1.0, + "step": 0.1, + "value": 0.0 + }, + { + "name": "c1", + "label": "x coef (odd)", + "min": -3.0, + "max": 3.0, + "step": 0.5, + "value": 0.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.0, + "max": 15.0, + "step": 1.0, + "value": 2.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 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 });\nconst co = Math.cos(ang), si = Math.sin(ang);\nconst sName = (val) => {\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[val.toFixed(4)] || 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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "r", + "label": "rate % r", + "min": -30.0, + "max": 30.0, + "step": 1.0, + "value": 15.0 + }, + { + "name": "target", + "label": "target value", + "min": 1.0, + "max": 12.0, + "step": 0.5, + "value": 8.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "r", + "label": "ratio r", + "min": -0.9, + "max": 0.9, + "step": 0.05, + "value": 0.5 + }, + { + "name": "N", + "label": "terms N", + "min": 1.0, + "max": 12.0, + "step": 1.0, + "value": 8.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "C", + "label": "y² coefficient C", + "min": -2.0, + "max": 2.0, + "step": 0.5, + "value": 1.0 + } + ], + "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\"; col = H.colors.sub; }\nelse if (Math.abs(A) < 1e-6 || Math.abs(C) < 1e-6) { kind = \"Parabola\"; 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; }\nconst pts = [];\nfor (let i = 0; i <= 240; i++) {\n const x = -8 + (16 * i) / 240;\n if (Math.abs(C) > 1e-6) {\n const rhs = (k - A * x * x) / C;\n if (rhs >= 0) { pts.push([x, Math.sqrt(rhs)]); }\n }\n}\nconst pts2 = [];\nfor (let i = 240; i >= 0; i--) {\n const x = -8 + (16 * i) / 240;\n if (Math.abs(C) > 1e-6) {\n const rhs = (k - A * x * x) / C;\n if (rhs >= 0) { pts2.push([x, -Math.sqrt(rhs)]); }\n }\n}\nif (Math.abs(C) < 1e-6 && Math.abs(A) > 1e-6) {\n v.fn(x => A * x * x - k, { color: col, width: 3 });\n} else {\n if (pts.length) v.path(pts, { color: col, width: 3 });\n if (pts2.length) v.path(pts2, { color: col, width: 3 });\n}\nconst xs = 6 * Math.sin(t * 0.6);\nlet ys = 0;\nif (Math.abs(C) > 1e-6) { const rhs = (k - A * xs * xs) / C; ys = rhs >= 0 ? Math.sqrt(rhs) : 0; }\nelse { ys = A * xs * xs - k; }\nv.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(\"same sign → ellipse/circle · opposite signs → hyperbola · one is 0 → parabola\", 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.0, + "max": 4.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 1.0, + "step": 0.05, + "value": 0.5 + }, + { + "name": "lo", + "label": "window low", + "min": -1.0, + "max": 2.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "hi", + "label": "window high", + "min": 3.0, + "max": 7.0, + "step": 0.5, + "value": 7.0 + } + ], + "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.0, + "max": 1.0, + "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.0, + "max": 7.0, + "step": 0.5, + "value": 5.0 + }, + { + "name": "b", + "label": "side b", + "min": 1.0, + "max": 6.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "C", + "label": "included angle C (deg)", + "min": 20.0, + "max": 160.0, + "step": 1.0, + "value": 60.0 + } + ], + "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.0, + "max": 120.0, + "step": 1.0, + "value": 50.0 + }, + { + "name": "B", + "label": "angle B (deg)", + "min": 20.0, + "max": 120.0, + "step": 1.0, + "value": 60.0 + }, + { + "name": "a", + "label": "side a", + "min": 2.0, + "max": 7.0, + "step": 0.5, + "value": 5.0 + } + ], + "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.0, + "max": 3.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "L", + "label": "limit value L", + "min": 1.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "gap", + "label": "start distance", + "min": 0.5, + "max": 3.0, + "step": 0.1, + "value": 2.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": -2.0 + }, + { + "name": "r2", + "label": "root r2", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "c", + "label": "cutoff c", + "min": -6.0, + "max": 8.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 5.0 + }, + { + "name": "b", + "label": "y amplitude b", + "min": 1.0, + "max": 4.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "p", + "label": "x frequency p", + "min": 1.0, + "max": 5.0, + "step": 1.0, + "value": 1.0 + }, + { + "name": "q", + "label": "y frequency q", + "min": 1.0, + "max": 5.0, + "step": 1.0, + "value": 2.0 + } + ], + "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.0, + "max": 12.0, + "step": 0.5, + "value": 12.0 + }, + { + "name": "angle", + "label": "launch angle (deg)", + "min": 25.0, + "max": 75.0, + "step": 1.0, + "value": 55.0 + }, + { + "name": "g", + "label": "gravity g", + "min": 8.0, + "max": 13.0, + "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.0, + "max": 3.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "D", + "label": "vertical shift D", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "k", + "label": "lobes k", + "min": 1.0, + "max": 6.0, + "step": 1.0, + "value": 3.0 + }, + { + "name": "b", + "label": "offset b", + "min": 0.0, + "max": 3.0, + "step": 0.5, + "value": 0.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.0, + "max": 5.0, + "step": 0.1, + "value": 4.0 + }, + { + "name": "k", + "label": "petal count k", + "min": 1.0, + "max": 6.0, + "step": 1.0, + "value": 3.0 + } + ], + "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.0, + "step": 0.1, + "value": 2.0 + }, + { + "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.0, + "max": 5.0, + "step": 0.1, + "value": 4.0 + }, + { + "name": "deg", + "label": "angle θ (degrees)", + "min": 0.0, + "max": 360.0, + "step": 1.0, + "value": 35.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "n", + "label": "degree n", + "min": 1.0, + "max": 6.0, + "step": 1.0, + "value": 3.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.1, + "value": 0.4 + }, + { + "name": "r1", + "label": "zero r1", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": -3.0 + }, + { + "name": "r2", + "label": "zero r2", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 0.0 + }, + { + "name": "r3", + "label": "zero r3", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "max": 4.0, + "step": 1.0, + "value": 0.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.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "q", + "label": "horiz asym q", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": -1.0 + }, + { + "name": "k", + "label": "stretch k", + "min": -8.0, + "max": 8.0, + "step": 0.5, + "value": 4.0 + } + ], + "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] });\nlet xs = p + 3 + 2.5 * Math.sin(t * 0.8);\nif (Math.abs(xs - p) < 0.3) xs = p + 0.3;\nv.dot(xs, f(xs), { r: 6, fill: H.colors.violet });\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.0, + "max": 80.0, + "step": 1.0, + "value": 35.0 + }, + { + "name": "dist", + "label": "distance d (m)", + "min": 10.0, + "max": 100.0, + "step": 1.0, + "value": 40.0 + } + ], + "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.0, + "max": 359.0, + "step": 1.0, + "value": 40.0 + } + ], + "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.0, + "max": 3.0, + "step": 1.0, + "value": 1.0 + }, + { + "name": "A", + "label": "vertical stretch A", + "min": 0.5, + "max": 3.0, + "step": 0.1, + "value": 1.0 + } + ], + "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.0, + "max": 360.0, + "step": 5.0, + "value": 210.0 + }, + { + "name": "k", + "label": "full turns k", + "min": -2.0, + "max": 2.0, + "step": 1.0, + "value": 1.0 + } + ], + "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 wraps = Math.abs(k);\nconst turns = (t * 0.4) % (wraps + 1);\nconst sgn = k >= 0 ? 1 : -1;\nconst showAng = ang + turns * Math.PI * 2 * sgn;\nconst sx = cx + R * Math.cos(showAng), sy = cy - R * Math.sin(showAng);\nconst sweep = [];\nconst maxA = ang + turns * Math.PI * 2 * sgn;\nfor (let i = 0; i <= 80; i++) { const a = maxA * (i / 80); 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.0, + "max": 85.0, + "step": 1.0, + "value": 37.0 + }, + { + "name": "hyp", + "label": "hypotenuse length", + "min": 1.0, + "max": 9.0, + "step": 0.5, + "value": 6.0 + } + ], + "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: 8 });\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.0, + "step": 0.1, + "value": 2.0 + }, + { + "name": "deg", + "label": "central angle (degrees)", + "min": 10.0, + "max": 350.0, + "step": 1.0, + "value": 90.0 + } + ], + "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.0, + "max": 6.0, + "step": 1.0, + "value": 1.0 + }, + { + "name": "hi", + "label": "upper bound", + "min": 1.0, + "max": 10.0, + "step": 1.0, + "value": 5.0 + }, + { + "name": "c", + "label": "coefficient c", + "min": 0.5, + "max": 2.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 3.0, + "step": 1.0, + "value": 1.0 + } + ], + "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.0 + }, + { + "name": "which", + "label": "1=sin 2=cos 3=both", + "min": 1.0, + "max": 3.0, + "step": 1.0, + "value": 3.0 + } + ], + "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.0, + "max": 7.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "per", + "label": "period (hours)", + "min": 4.0, + "max": 24.0, + "step": 1.0, + "value": 12.0 + }, + { + "name": "peak", + "label": "peak hour", + "min": 0.0, + "max": 24.0, + "step": 1.0, + "value": 15.0 + }, + { + "name": "mid", + "label": "midline (deg)", + "min": 2.0, + "max": 14.0, + "step": 0.5, + "value": 9.0 + } + ], + "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));\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 const yt = mid + amp * Math.cos((2 * Math.PI / 12) * (hr - 15));\n v.dot(hr, yt, { 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] });\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.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.0, + "max": 360.0, + "step": 1.0, + "value": 60.0 + } + ], + "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.0, + "max": 1.0, + "step": 1.0, + "value": 0.0 + }, + { + "name": "a", + "label": "point / width a", + "min": 1.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "N", + "label": "rectangles N", + "min": 1.0, + "max": 20.0, + "step": 1.0, + "value": 6.0 + } + ], + "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.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "b", + "label": "frequency b", + "min": 0.2, + "max": 3.0, + "step": 0.1, + "value": 1.0 + } + ], + "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.0, + "max": 8.0, + "step": 0.5, + "value": 6.0 + }, + { + "name": "b", + "label": "side b", + "min": 1.0, + "max": 6.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "C", + "label": "angle C (deg)", + "min": 10.0, + "max": 170.0, + "step": 1.0, + "value": 70.0 + } + ], + "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(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.4, -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 });\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.0, + "max": 1.0, + "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.0, + "max": 2.0, + "step": 0.05, + "value": 1.0 + } + ], + "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.0, + "max": 360.0, + "step": 2.0, + "value": 200.0 + } + ], + "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 co = Math.cos(ang), si = Math.sin(ang), ta = Math.tan(ang);\nconst q = deg < 90 ? \"I\" : deg < 180 ? \"II\" : deg < 270 ? \"III\" : \"IV\";\nconst sg = (x) => x >= 0 ? \"+\" : \"−\";\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 \" + sg(ta), 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.0, + "max": 360.0, + "step": 1.0, + "value": 60.0 + } + ], + "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.0, + "max": 9.0, + "step": 0.5, + "value": 6.0 + }, + { + "name": "angle", + "label": "angle theta (deg)", + "min": 0.0, + "max": 360.0, + "step": 1.0, + "value": 35.0 + } + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -7, yMax: 7 });\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.6, { 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.6, { 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.0, + "max": 9.0, + "step": 0.5, + "value": 6.0 + }, + { + "name": "vy", + "label": "y-component vy", + "min": -6.0, + "max": 6.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "ay", + "label": "a: y-component", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "bx", + "label": "b: x / scalar s", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "op", + "label": "0=add 1=sub 2=scale", + "min": 0.0, + "max": 2.0, + "step": 1.0, + "value": 0.0 + } + ], + "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, bx = P.bx, by = P.by;\nconst s = P.scalar;\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\"; }\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 + 0.5, { color: H.colors.good, size: 13 });\nview.text(label, rx / 2 + 0.3, ry / 2 - 0.4, { 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(\"a = (\" + ax.toFixed(1) + \", \" + ay.toFixed(1) + \") b = (\" + bx.toFixed(1) + \", \" + by.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/index.html b/index.html index cf70cd0..becdc9e 100644 --- a/index.html +++ b/index.html @@ -258,6 +258,6 @@

Quick questions

- + From ee11e0842652d1005483da4acb5738ddd8d57d0d Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Wed, 24 Jun 2026 16:22:57 +0700 Subject: [PATCH 21/43] Tutor: make the step-by-step solver beginner-proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per request for a "dumb-proofed" explanation: the solve-mode prompt now tells the tutor to assume a total beginner — define every term in plain words, NEVER skip an arithmetic/algebra move (show subtract-from-both-sides then divide as separate lines, not a jump to the answer), say WHY each move is allowed, and split any two-move line into two steps. 93 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/main.py b/main.py index aa3203f..d596f2f 100644 --- a/main.py +++ b/main.py @@ -2041,7 +2041,20 @@ def build_tutor_system_prompt(viz: dict) -> str: 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): + 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. @@ -2050,18 +2063,19 @@ def build_tutor_system_prompt(viz: dict) -> str: 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 thing — say what you do and WHY, - substitute the specific values you need RIGHT THERE, and show the - intermediate result with units. 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"). + 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 sanity check (does - the sign/size make sense?). + 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 it focused; still end with - one short follow-up question. + 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 \\(...\\), \\[...\\], $...$, From ff53afb9e1ba149e8b41f08fb28f7b8fac831f2b Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Wed, 24 Jun 2026 16:33:49 +0700 Subject: [PATCH 22/43] Correctness audit: fix 11 demos with wrong math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran an adversarial multi-agent audit (29 agents) over all 200 generated demos checking MATHEMATICAL correctness against each topic — the thing the runtime validator can't see. Result: 189/200 correct as-is (94.5%), 11 flagged with genuine bugs, all fixed and re-validated: - pc-vector-operations: read sliders P.by/P.scalar that didn't exist (NaN). - a2-quadratic-inequalities: shaded between roots + printed wrong inequality when a<0. - a1-slope-from-a-table: highlight wrapped last row -> first, wrong slope. - a1-domain-and-range: range bar/readout hardcoded instead of computed. - pc-sinusoidal-modeling, pc-identify-conic-from-equation: major. - 5 minor (order-of-operations != symbol when a=0/c=1, log readout, matrix scalar clamp/label, exact-trig near-zero, ellipse readout label). Every fix re-checked through validate_scene.js. All 200 still validate, 0 structural issues, 93 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- demo_library_generated.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/demo_library_generated.json b/demo_library_generated.json index de71bf6..a09e666 100644 --- a/demo_library_generated.json +++ b/demo_library_generated.json @@ -420,7 +420,7 @@ "value": 5.0 } ], - "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;\nfor (let i = 0; i <= N; i++) { const x = lo + (hi - lo) * i / N; pts.push([x, f(x)]); }\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, -amp, 0, amp, { 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 [\" + (-amp).toFixed(1) + \", \" + amp.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);" + "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", @@ -1624,7 +1624,7 @@ "value": 4.0 } ], - "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 });\nH.text(\"≠ (\" + a + \" + \" + b + \") × \" + c + \" = \" + ((a + b) * c), 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 });" + "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", @@ -2342,7 +2342,7 @@ "value": 1.0 } ], - "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.floor(t % rows);\nconst hiNext = (hi + 1) % rows;\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 });" + "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", @@ -3867,7 +3867,7 @@ "value": 3.0 } ], - "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]);\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) + \" = 2a = \" + (2 * a).toFixed(2)) : (\"d1 + d2 = \" + (d1 + d2).toFixed(2) + \" = 2a = \" + (2 * Math.max(a, b)).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);" + "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", @@ -5149,7 +5149,7 @@ "value": 4.0 } ], - "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=\" + Math.pow(b, e).toFixed(1) + \", y=\" + e.toFixed(2) + \")\", 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);" + "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", @@ -5301,7 +5301,7 @@ "value": 2.0 } ], - "code": "H.background();\nconst w = H.W, hh = H.H;\nconst s = Math.max(0.2, 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);" + "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", @@ -6195,7 +6195,7 @@ "value": -4.0 } ], - "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 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 v.dot(lo, 0, { r: 6, fill: H.colors.good });\n v.dot(hi, 0, { r: 6, fill: H.colors.good });\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 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 });" + "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", @@ -8180,7 +8180,7 @@ "value": 2.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 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 });\nconst co = Math.cos(ang), si = Math.sin(ang);\nconst sName = (val) => {\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[val.toFixed(4)] || 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);" + "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", @@ -8333,7 +8333,7 @@ "value": 1.0 } ], - "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\"; col = H.colors.sub; }\nelse if (Math.abs(A) < 1e-6 || Math.abs(C) < 1e-6) { kind = \"Parabola\"; 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; }\nconst pts = [];\nfor (let i = 0; i <= 240; i++) {\n const x = -8 + (16 * i) / 240;\n if (Math.abs(C) > 1e-6) {\n const rhs = (k - A * x * x) / C;\n if (rhs >= 0) { pts.push([x, Math.sqrt(rhs)]); }\n }\n}\nconst pts2 = [];\nfor (let i = 240; i >= 0; i--) {\n const x = -8 + (16 * i) / 240;\n if (Math.abs(C) > 1e-6) {\n const rhs = (k - A * x * x) / C;\n if (rhs >= 0) { pts2.push([x, -Math.sqrt(rhs)]); }\n }\n}\nif (Math.abs(C) < 1e-6 && Math.abs(A) > 1e-6) {\n v.fn(x => A * x * x - k, { color: col, width: 3 });\n} else {\n if (pts.length) v.path(pts, { color: col, width: 3 });\n if (pts2.length) v.path(pts2, { color: col, width: 3 });\n}\nconst xs = 6 * Math.sin(t * 0.6);\nlet ys = 0;\nif (Math.abs(C) > 1e-6) { const rhs = (k - A * xs * xs) / C; ys = rhs >= 0 ? Math.sqrt(rhs) : 0; }\nelse { ys = A * xs * xs - k; }\nv.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(\"same sign → ellipse/circle · opposite signs → hyperbola · one is 0 → parabola\", 24, H.H - 16, { color: H.colors.sub, size: 12 });" + "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", @@ -9716,7 +9716,7 @@ "value": 9.0 } ], - "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));\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 const yt = mid + amp * Math.cos((2 * Math.PI / 12) * (hr - 15));\n v.dot(hr, yt, { 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] });\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.legend([{ label: \"fitted model\", color: H.colors.accent }, { label: \"data points\", color: H.colors.good }], H.W - 175, 28);" + "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", @@ -10210,6 +10210,6 @@ "value": 0.0 } ], - "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, bx = P.bx, by = P.by;\nconst s = P.scalar;\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\"; }\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 + 0.5, { color: H.colors.good, size: 13 });\nview.text(label, rx / 2 + 0.3, ry / 2 - 0.4, { 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(\"a = (\" + ax.toFixed(1) + \", \" + ay.toFixed(1) + \") b = (\" + bx.toFixed(1) + \", \" + by.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);" + "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, 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\"; }\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 + 0.5, { color: H.colors.good, size: 13 });\nview.text(label, rx / 2 + 0.3, ry / 2 - 0.4, { 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 From 7d069c145dd50851502aa05a1c7f7e8627b8592d Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Wed, 24 Jun 2026 16:59:46 +0700 Subject: [PATCH 23/43] Fix Docker image: ship the curriculum demos (and future physics) too The Dockerfile didn't COPY demo_library.py / demo_library_generated.json, so a production deploy would silently load ZERO demos (the import falls back to an empty list). Now copies demo_library.py + a *_generated.json glob, so the scene library, the 212 curriculum demos, and the upcoming physics set all ship automatically. Verified in PORT-injected prod mode: a "completing the square" request serves the demo with its sliders over the wire. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 6b1deb6..9d23e1e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,11 @@ 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, both libraries (STEM scenes + curriculum demos), the generated data +# (*_generated.json — scene, demo, and physics), the validator, and the UI. +# The glob keeps new generated data files (e.g. physics) shipping automatically. +COPY main.py scene_library.py demo_library.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. From 3f1e6954169c30f51051bbccba510507362c5ef2 Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Wed, 24 Jun 2026 17:14:16 +0700 Subject: [PATCH 24/43] Performance: smoother animations + lighter web rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses "quite laggy" with four universal wins: - Self-correcting frame scheduler in the worker: schedule the next tick for a fixed ~16.7ms PERIOD, not 16.7ms AFTER the work. A 10ms scene was running at ~37fps (10+16.7); now it targets 60. - Cap canvas DPR at 1.5 (was 2.0): on Retina, dpr=2 means 4x the pixels to fill every frame, which dominates fill-heavy 3D scenes. 1.5 stays crisp and ~halves the fill work. - Suspend rendering when the tab is hidden (visibilitychange -> worker stops the loop) — no CPU/battery spent animating an invisible canvas; doesn't suspend mid-load so the run watchdog still settles. - Drop backdrop-filter: blur on the two overlays sitting over the canvas — the browser was recomputing the blur every animated frame. Solid pills read just as well at zero per-frame cost. Verified in-browser: heavy 40x40 surface renders smoothly, effective DPR 1.49, no console errors. 93 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- app.js | 16 ++++++++++++++-- index.html | 4 ++-- sandbox-worker.js | 23 ++++++++++++++++++++++- styles.css | 9 +++++---- 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/app.js b/app.js index dee677f..16a8d36 100644 --- a/app.js +++ b/app.js @@ -272,6 +272,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. @@ -345,7 +354,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), }; } @@ -360,7 +372,7 @@ } const canvas = this._newCanvas(); const offscreen = canvas.transferControlToOffscreen(); - const worker = new Worker("./sandbox-worker.js?v=17"); + const worker = new Worker("./sandbox-worker.js?v=23"); this.worker = worker; worker.onmessage = (e) => this._onMessage(e.data || {}); const d = this._dims(); diff --git a/index.html b/index.html index becdc9e..4acf526 100644 --- a/index.html +++ b/index.html @@ -25,7 +25,7 @@ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" /> - + + diff --git a/sandbox-worker.js b/sandbox-worker.js index b76783e..2f4b422 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), @@ -982,7 +983,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)); } /* ------------------------------------------------------------------ */ @@ -1061,6 +1072,16 @@ 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 diff --git a/styles.css b/styles.css index 7cc9464..6586eab 100644 --- a/styles.css +++ b/styles.css @@ -522,11 +522,13 @@ 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; } .orbit-hint { @@ -537,11 +539,10 @@ textarea:focus, input[type="text"]:focus { 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; } From 5a08c4bb5031aceaca40c6d1192a4b96e57edf8b Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Wed, 24 Jun 2026 17:16:20 +0700 Subject: [PATCH 25/43] =?UTF-8?q?Add=20120=20interactive=20physics=20demos?= =?UTF-8?q?=20(mechanics=20=E2=86=92=20modern)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pipeline as the math set: a multi-agent workflow authored one parameterized demo per intro-physics topic (kinematics, forces, energy, momentum, rotation, gravitation, oscillations, waves, sound, fluids, thermo, electrostatics, circuits, magnetism, optics, modern), each self-validating its code; then re-validated here through validate_scene.js — all 120 pass (run, paint on-screen, animate, labeled, sliders with real units). - physics_library_generated.json: the 120 validated physics demos. - demo_library.py: loads + merges math demos AND physics. DEMO_LIBRARY is now 332 (12 base + 200 math + 120 physics). - Physics topics route correctly (projectile, F=ma, SHM, Ohm's law, Snell, Doppler, momentum, centripetal, photoelectric, RC, torque, buoyancy...). A physical-correctness audit (same adversarial review that fixed 11 math demos) is running; its fixes land in a follow-up. 93 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- demo_library.py | 21 +- physics_library_generated.json | 5951 ++++++++++++++++++++++++++++++++ 2 files changed, 5963 insertions(+), 9 deletions(-) create mode 100644 physics_library_generated.json diff --git a/demo_library.py b/demo_library.py index ef14873..ea4a454 100644 --- a/demo_library.py +++ b/demo_library.py @@ -535,14 +535,17 @@ import json as _json from pathlib import Path as _Path -_GENERATED: list[dict] = [] -_gen_path = _Path(__file__).resolve().parent / "demo_library_generated.json" -try: - _loaded = _json.loads(_gen_path.read_text(encoding="utf-8")) - if isinstance(_loaded, list): - _GENERATED = _loaded -except (OSError, ValueError): - _GENERATED = [] +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. +_GENERATED: list[dict] = _load_generated("demo_library_generated.json") +_PHYSICS: list[dict] = _load_generated("physics_library_generated.json") # Hand-written demos first so they win id/keyword ties over generated ones. -DEMO_LIBRARY: list[dict] = _BASE + _GENERATED +DEMO_LIBRARY: list[dict] = _BASE + _GENERATED + _PHYSICS diff --git a/physics_library_generated.json b/physics_library_generated.json new file mode 100644 index 0000000..093ec11 --- /dev/null +++ b/physics_library_generated.json @@ -0,0 +1,5951 @@ +[ + { + "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.0, + "max": 0.99, + "step": 0.01, + "value": 0.8 + }, + { + "name": "dtau", + "label": "proper tick Delta tau (s)", + "min": 0.1, + "max": 2.0, + "step": 0.1, + "value": 1.0 + } + ], + "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.0, + "max": 0.99, + "step": 0.01, + "value": 0.8 + }, + { + "name": "L0", + "label": "proper length L0 (m)", + "min": 1.0, + "max": 8.0, + "step": 0.5, + "value": 6.0 + } + ], + "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.0, + "step": 0.001, + "value": 1.0 + }, + { + "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.0, + "max": 1000.0, + "step": 10.0, + "value": 800.0 + }, + { + "name": "thalf", + "label": "half-life t_half (s)", + "min": 0.5, + "max": 5.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 238.0, + "step": 1.0, + "value": 56.0 + } + ], + "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.0, + "max": 9.0, + "step": 0.5, + "value": 6.0 + }, + { + "name": "Vy", + "label": "y-component Vy (m)", + "min": 0.0, + "max": 7.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 5.0 + } + ], + "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.0, + "max": 12.0, + "step": 0.5, + "value": 6.0 + }, + { + "name": "R", + "label": "track radius R (m)", + "min": 3.0, + "max": 15.0, + "step": 0.5, + "value": 10.0 + } + ], + "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.0, + "max": 6.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "a", + "label": "acceleration a (m/s^2)", + "min": -3.0, + "max": 4.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "max": 4.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "a", + "label": "acceleration a (m/s^2)", + "min": -2.0, + "max": 3.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0 + }, + { + "name": "Q", + "label": "quality factor Q", + "min": 1.0, + "max": 12.0, + "step": 0.5, + "value": 6.0 + }, + { + "name": "fd", + "label": "driving freq f (Hz, 0 = sweep)", + "min": 0.0, + "max": 4.0, + "step": 0.1, + "value": 0.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.0 + }, + { + "name": "lambda", + "label": "wavelength lambda (m)", + "min": 0.5, + "max": 4.0, + "step": 0.1, + "value": 2.0 + }, + { + "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.0, + "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.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "max": 4.0, + "step": 0.5, + "value": 2.5 + }, + { + "name": "A", + "label": "amplitude A (m)", + "min": 0.5, + "max": 2.0, + "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.\nconst span = 10;\nconst crestX = ((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.0, + "max": 2.5, + "step": 0.1, + "value": 1.5 + }, + { + "name": "A2", + "label": "amplitude 2 A2", + "min": 0.0, + "max": 2.5, + "step": 0.1, + "value": 1.5 + }, + { + "name": "phase", + "label": "phase difference (deg)", + "min": 0.0, + "max": 360.0, + "step": 5.0, + "value": 60.0 + } + ], + "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.0 + }, + { + "name": "f", + "label": "frequency f (Hz)", + "min": 1.0, + "max": 8.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "v", + "label": "wave speed v (m/s)", + "min": 1.0, + "max": 6.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "max": 400.0, + "step": 5.0, + "value": 293.0 + } + ], + "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.0, + "max": 800.0, + "step": 10.0, + "value": 500.0 + }, + { + "name": "vs", + "label": "source speed v_src (m/s)", + "min": 0.0, + "max": 250.0, + "step": 10.0, + "value": 100.0 + } + ], + "code": "H.background();\n// Doppler effect: f_obs = f_src * v / (v - v_src) (source moving TOWARD a still observer)\n// v = 340 m/s speed of sound. The source flies back and forth; wavefronts\n// bunch ahead of it (higher pitch) and spread behind (lower pitch).\nconst fsrc = Math.max(1, P.f); // emitted frequency (Hz)\nconst vsrc = P.vs; // source speed (m/s), + = moving right\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// source oscillates across the field so it never drifts away\nconst sx = 150 * Math.sin(t * 0.5);\nconst dir = Math.cos(t * 0.5); // +1 moving right, -1 moving left\nconst svel = vsrc * dir; // signed source velocity\n// draw wavefronts emitted at past times: each is a circle centered where the\n// source WAS, radius = c * age. Centers trail behind a moving source => bunching.\nconst Temit = 1 / fsrc * 12; // visible emission spacing (scaled)\nfor (let n = 1; n <= 7; n++) {\n const age = n * Temit;\n const cxEmit = 150 * Math.sin((t - age) * 0.5); // where source was then\n const rad = (c / 12) * age; // wavefront radius (scaled)\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\nv.dot(sx, 0, { r: 7, fill: H.colors.warn });\nv.arrow(sx, 0, sx + svel * 0.25, 0, { color: H.colors.warn, width: 2 });\n// observed frequency ahead of the source (denominator guarded away from 0)\nconst denom = Math.max(1, c - Math.abs(svel));\nconst fAhead = fsrc * c / denom;\nconst fBehind = fsrc * c / (c + Math.abs(svel));\nH.text(\"Doppler effect: f' = f · v / (v ∓ v_source)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f = \" + fsrc.toFixed(0) + \" Hz v_src = \" + Math.abs(svel).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.0, + "max": 6.0, + "step": 1.0, + "value": 3.0 + }, + { + "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.0, + "step": 0.1, + "value": 1.0 + } + ], + "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.0, + "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.0, + "max": 12.0, + "step": 0.1, + "value": 7.0 + }, + { + "name": "work", + "label": "work function W (eV)", + "min": 1.0, + "max": 5.0, + "step": 0.1, + "value": 2.3 + }, + { + "name": "intensity", + "label": "intensity (photons/s, x10^15)", + "min": 1.0, + "max": 10.0, + "step": 1.0, + "value": 4.0 + } + ], + "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.0, + "max": 12.0, + "step": 0.1, + "value": 5.0 + } + ], + "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.0, + "max": 50.0, + "step": 0.5, + "value": 9.11 + }, + { + "name": "speed", + "label": "speed v (x10^6 m/s)", + "min": 0.5, + "max": 10.0, + "step": 0.1, + "value": 2.0 + } + ], + "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.0, + "max": 6.0, + "step": 1.0, + "value": 2.0 + } + ], + "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.0, + "max": 4.0, + "step": 1.0, + "value": 2.0 + }, + { + "name": "nhigh", + "label": "upper level n_hi", + "min": 2.0, + "max": 7.0, + "step": 1.0, + "value": 3.0 + } + ], + "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 });\nfor (let k = 1; k <= 6; 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.0, + "step": 0.1, + "value": 2.0 + }, + { + "name": "area", + "label": "cross-section A (mm^2)", + "min": 0.5, + "max": 4.0, + "step": 0.1, + "value": 1.5 + }, + { + "name": "dens", + "label": "carrier density (rel.)", + "min": 1.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + } + ], + "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 });\nH.rect(wireX0, wireY - 24, wireLen, 48, { fill: H.colors.panel, stroke: H.colors.grid, width: 2, radius: 8 });\nconst planeX = wireX0 + wireLen * 0.5;\nH.line(planeX, wireY - 34, planeX, wireY + 34, { color: H.colors.warn, width: 2, dash: [5, 5] });\nH.text(\"cross-section\", planeX - 38, wireY - 40, { color: H.colors.warn, size: 12 });\nconst q = 1.6e-19;\nconst speed = H.clamp(40 + I * 30, 20, 220);\nconst N = Math.round(H.clamp(6 + n * 4, 6, 26));\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 - 12 + (i % 3) * 12;\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 - 44, wireX0 + 130, wireY - 44, { color: H.colors.accent2, width: 3 });\nH.text(\"conventional current I\", wireX0 + 30, wireY - 50, { 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(\"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.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "len", + "label": "length L (m)", + "min": 0.5, + "max": 5.0, + "step": 0.1, + "value": 2.0 + }, + { + "name": "area", + "label": "area A (m^2)", + "min": 0.05, + "max": 1.0, + "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.0, + "max": 12.0, + "step": 0.5, + "value": 9.0 + }, + { + "name": "res", + "label": "resistance R (Ohm)", + "min": 1.0, + "max": 20.0, + "step": 0.5, + "value": 6.0 + } + ], + "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.0, + "max": 12.0, + "step": 0.5, + "value": 10.0 + }, + { + "name": "r1", + "label": "R1 (Ohm)", + "min": 1.0, + "max": 10.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "r2", + "label": "R2 (Ohm)", + "min": 1.0, + "max": 10.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "r3", + "label": "R3 (Ohm)", + "min": 1.0, + "max": 10.0, + "step": 0.5, + "value": 5.0 + } + ], + "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.0, + "max": 12.0, + "step": 0.5, + "value": 8.0 + }, + { + "name": "r1", + "label": "R1 (Ohm)", + "min": 1.0, + "max": 12.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "r2", + "label": "R2 (Ohm)", + "min": 1.0, + "max": 12.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "max": 12.0, + "step": 0.5, + "value": 12.0 + }, + { + "name": "R", + "label": "resistance R (ohms)", + "min": 1.0, + "max": 10.0, + "step": 0.5, + "value": 4.0 + } + ], + "code": "H.background();\nconst V = P.V, R = Math.max(0.1, P.R);\nconst I = V / R;\nconst Pw = V * I;\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: 0, yMax: 12, 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] });\nconst gx = H.W - 150, gy = 90;\nconst glow = H.clamp(Pw / 120, 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.0, + "max": 9.0, + "step": 0.5, + "value": 5.0 + }, + { + "name": "R", + "label": "resistance R (ohms)", + "min": 0.5, + "max": 4.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "C", + "label": "capacitance C (farads)", + "min": 0.1, + "max": 1.0, + "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.0, + "max": 12.0, + "step": 0.5, + "value": 9.0 + }, + { + "name": "R1", + "label": "resistance R1 (ohms)", + "min": 1.0, + "max": 8.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "R2", + "label": "resistance R2 (ohms)", + "min": 1.0, + "max": 8.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "m2", + "label": "mass m2 (kg)", + "min": 0.5, + "max": 5.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "v1", + "label": "speed v1 (m/s)", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "v2", + "label": "speed v2 (m/s)", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": -1.0 + } + ], + "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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "m2", + "label": "mass m2 (kg)", + "min": 0.5, + "max": 5.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "v1", + "label": "speed v1 (m/s)", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "v2", + "label": "speed v2 (m/s)", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": -1.0 + } + ], + "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.0, + "max": 12.0, + "step": 0.5, + "value": 8.0 + }, + { + "name": "mu", + "label": "friction mu", + "min": 0.0, + "max": 0.5, + "step": 0.01, + "value": 0.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.0, + "max": 10.0, + "step": 0.5, + "value": 6.0 + }, + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 8.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 8.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "mA", + "label": "mass A (kg)", + "min": 1.0, + "max": 8.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "mB", + "label": "mass B (kg)", + "min": 1.0, + "max": 8.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "g", + "label": "gravity g (m/s²)", + "min": 1.0, + "max": 25.0, + "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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "deg", + "label": "incline angle θ (°)", + "min": 0.0, + "max": 60.0, + "step": 1.0, + "value": 25.0 + } + ], + "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: along-slope (u) and surface-normal (n)\nconst ux = Math.cos(th), uy = -Math.sin(th);\nconst nx = Math.sin(th), ny = Math.cos(th); // outward normal (up-left)\nconst bs = 26;\n// block centered slightly above the surface along the 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), 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 + 4, 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.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "ho", + "label": "object height ho (cm)", + "min": 0.5, + "max": 3.5, + "step": 0.5, + "value": 2.0 + } + ], + "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 v = H.plot2d({ xMin: -12, xMax: 8, yMin: -5, yMax: 5 });\nv.grid(); v.axes();\nconst f = (Math.abs(P.f) < 0.2 ? 0.2 : P.f); // focal length (cm)\nconst ho = P.ho; // object height (cm)\n// Object distance sweeps so the image moves through its whole range.\nconst do_ = 7 + 3.5 * Math.sin(t * 0.6); // always positive (in front)\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// Mirror surface as a shallow arc x = y^2 / (2R) (vertex at origin).\nconst arc = [];\nfor (let yy = -4; yy <= 4.001; yy += 0.2) arc.push([(yy * yy) / (2 * 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(f, 0, { r: 5, fill: H.colors.good }); // focal point F\nv.dot(R, 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, then 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: through the center C reflects straight back (hits mirror, returns to 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.0, + "max": 15.0, + "step": 1.0, + "value": 8.0 + }, + { + "name": "dobj", + "label": "object distance do (cm)", + "min": 2.0, + "max": 20.0, + "step": 0.5, + "value": 14.0 + }, + { + "name": "hobj", + "label": "object height ho (cm)", + "min": 1.0, + "max": 6.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "max": 700.0, + "step": 10.0, + "value": 550.0 + }, + { + "name": "d", + "label": "slit separation d (um)", + "min": 20.0, + "max": 120.0, + "step": 5.0, + "value": 50.0 + }, + { + "name": "L", + "label": "screen distance L (m)", + "min": 1.0, + "max": 3.0, + "step": 0.1, + "value": 2.0 + } + ], + "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.0, + "max": 700.0, + "step": 10.0, + "value": 600.0 + }, + { + "name": "a", + "label": "slit width a (um)", + "min": 20.0, + "max": 100.0, + "step": 5.0, + "value": 40.0 + }, + { + "name": "L", + "label": "screen distance L (m)", + "min": 1.0, + "max": 3.0, + "step": 0.1, + "value": 2.0 + } + ], + "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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "phi", + "label": "analyzer axis angle (deg)", + "min": 0.0, + "max": 180.0, + "step": 5.0, + "value": 0.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.0, + "max": 89.0, + "step": 1.0, + "value": 35.0 + } + ], + "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.0, + "max": 89.0, + "step": 1.0, + "value": 50.0 + }, + { + "name": "n1", + "label": "dense index n1", + "min": 1.0, + "max": 2.5, + "step": 0.01, + "value": 1.5 + }, + { + "name": "n2", + "label": "rare index n2", + "min": 1.0, + "max": 2.0, + "step": 0.01, + "value": 1.0 + } + ], + "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.0, + "max": 20.0, + "step": 0.5, + "value": 10.0 + }, + { + "name": "dobj", + "label": "object distance do (cm)", + "min": 1.0, + "max": 40.0, + "step": 0.5, + "value": 15.0 + }, + { + "name": "hobj", + "label": "object height (cm)", + "min": 1.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "step": 0.25, + "value": 1.5 + }, + { + "name": "strength", + "label": "magnet strength (arb)", + "min": 0.5, + "max": 4.0, + "step": 0.25, + "value": 2.0 + } + ], + "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.0, + "max": 2.0, + "step": 0.25, + "value": 1.0 + }, + { + "name": "speed", + "label": "speed v (m/s)", + "min": 0.5, + "max": 5.0, + "step": 0.25, + "value": 3.0 + }, + { + "name": "B", + "label": "field B (T)", + "min": 0.2, + "max": 3.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "m", + "label": "mass m (kg)", + "min": 0.5, + "max": 4.0, + "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.0, + "max": 2.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "I", + "label": "current I (A)", + "min": 0.0, + "max": 8.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "L", + "label": "wire length L (m)", + "min": 1.0, + "max": 6.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "theta", + "label": "angle to B (deg)", + "min": 0.0, + "max": 180.0, + "step": 5.0, + "value": 90.0 + } + ], + "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.0, + "step": 0.5, + "value": 10.0 + }, + { + "name": "n", + "label": "solenoid turns n (1/m)", + "min": 100.0, + "max": 2000.0, + "step": 50.0, + "value": 1000.0 + } + ], + "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.0, + "max": 50.0, + "step": 1.0, + "value": 20.0 + }, + { + "name": "B", + "label": "field B (T)", + "min": 0.1, + "max": 2.0, + "step": 0.1, + "value": 0.5 + }, + { + "name": "A", + "label": "loop area A (m^2)", + "min": 0.05, + "max": 1.0, + "step": 0.05, + "value": 0.3 + }, + { + "name": "omega", + "label": "spin omega (rad/s)", + "min": 0.5, + "max": 6.0, + "step": 0.25, + "value": 2.0 + } + ], + "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.0, + "step": 0.1, + "value": 2.0 + }, + { + "name": "T", + "label": "period T (s)", + "min": 1.0, + "max": 8.0, + "step": 0.1, + "value": 4.0 + } + ], + "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.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "v", + "label": "speed v (m/s)", + "min": 1.0, + "max": 6.0, + "step": 0.1, + "value": 3.0 + }, + { + "name": "r", + "label": "radius r (m)", + "min": 0.5, + "max": 4.0, + "step": 0.1, + "value": 2.0 + } + ], + "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.0, + "max": 15.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "a", + "label": "acceleration a (m/s^2)", + "min": -3.0, + "max": 4.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "T", + "label": "window T (s)", + "min": 2.0, + "max": 10.0, + "step": 0.5, + "value": 8.0 + } + ], + "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.0, + "max": 45.0, + "step": 1.0, + "value": 20.0 + } + ], + "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.0, + "max": 25.0, + "step": 1.0, + "value": 15.0 + }, + { + "name": "h0", + "label": "launch height h0 (m)", + "min": 2.0, + "max": 30.0, + "step": 1.0, + "value": 20.0 + } + ], + "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.0, + "max": 25.0, + "step": 1.0, + "value": 20.0 + }, + { + "name": "deg", + "label": "launch angle theta (deg)", + "min": 10.0, + "max": 80.0, + "step": 1.0, + "value": 45.0 + } + ], + "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.0, + "max": 8.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "vr", + "label": "river current vr (m/s)", + "min": 0.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "W", + "label": "river width W (m)", + "min": 3.0, + "max": 8.0, + "step": 0.5, + "value": 7.0 + } + ], + "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.0, + "max": 20.0, + "step": 0.5, + "value": 12.0 + }, + { + "name": "d", + "label": "displacement d (m)", + "min": 0.0, + "max": 12.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "ang", + "label": "angle θ (degrees)", + "min": 0.0, + "max": 180.0, + "step": 1.0, + "value": 30.0 + } + ], + "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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "v", + "label": "top speed v (m/s)", + "min": 0.0, + "max": 10.0, + "step": 0.5, + "value": 5.0 + } + ], + "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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "h", + "label": "max height h (m)", + "min": 0.0, + "max": 10.0, + "step": 0.5, + "value": 5.0 + } + ], + "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.0, + "max": 50.0, + "step": 1.0, + "value": 20.0 + }, + { + "name": "A", + "label": "amplitude A (m)", + "min": 0.2, + "max": 3.0, + "step": 0.1, + "value": 2.0 + } + ], + "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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "H0", + "label": "release height H₀ (m)", + "min": 1.0, + "max": 8.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "vel", + "label": "speed v (m/s)", + "min": 0.0, + "max": 4.0, + "step": 0.2, + "value": 2.0 + } + ], + "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.0, + "max": 12.0, + "step": 1.0, + "value": 6.0 + }, + { + "name": "mass", + "label": "mass m (kg)", + "min": 0.5, + "max": 5.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "v0", + "label": "start speed v0 (m/s)", + "min": 0.0, + "max": 6.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "vel", + "label": "velocity v (m/s)", + "min": -4.0, + "max": 4.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 50.0, + "step": 5.0, + "value": 30.0 + }, + { + "name": "dt", + "label": "contact time delta-t (s)", + "min": 0.1, + "max": 1.5, + "step": 0.1, + "value": 1.0 + }, + { + "name": "mass", + "label": "mass m (kg)", + "min": 0.5, + "max": 5.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "v1", + "label": "cart 1 speed v1 (m/s)", + "min": 0.5, + "max": 4.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "m2", + "label": "cart 2 mass m2 (kg)", + "min": 0.5, + "max": 4.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "alpha", + "label": "angular accel alpha (rad/s²)", + "min": -2.0, + "max": 3.0, + "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.0, + "max": 20.0, + "step": 0.5, + "value": 10.0 + } + ], + "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.0, + "step": 0.1, + "value": 2.0 + }, + { + "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.0, + "step": 0.5, + "value": 4.0 + }, + { + "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.0, + "max": 12.0, + "step": 0.2, + "value": 6.0 + } + ], + "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.0, + "step": 0.1, + "value": 2.0 + }, + { + "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.0, + "step": 0.1, + "value": 3.0 + } + ], + "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.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "muS", + "label": "static mu_s", + "min": 0.0, + "max": 1.0, + "step": 0.05, + "value": 0.6 + }, + { + "name": "muK", + "label": "kinetic mu_k", + "min": 0.0, + "max": 1.0, + "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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "amp", + "label": "bob amplitude (m)", + "min": 0.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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "F", + "label": "applied force F (N)", + "min": 0.0, + "max": 30.0, + "step": 1.0, + "value": 15.0 + }, + { + "name": "mu", + "label": "friction mu", + "min": 0.0, + "max": 1.0, + "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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "deg", + "label": "angle θ (degrees)", + "min": 5.0, + "max": 75.0, + "step": 1.0, + "value": 30.0 + }, + { + "name": "mu", + "label": "friction mu", + "min": 0.0, + "max": 1.0, + "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. A block slides down 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 fric = Math.min(fMax, along); // can't exceed the driving force at rest\nconst net = along - fMax; // net once it slides (kinetic-style)\nconst a = net > 0 ? net / m : 0; // slides only if it overcomes friction\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)\nconst prog = (t % 3) / 3; // 0..1 loop\nconst sPos = sMax * (1 - prog); // start at top, slide to bottom\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(net > 0 ? \"slides: a = \" + a.toFixed(2) + \" m/s² down the slope\" : \"static: mg sinθ ≤ μ mg cosθ → stays put\", 24, H.H - 26, { color: net > 0 ? 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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "m2", + "label": "right mass m₂ (kg)", + "min": 0.5, + "max": 8.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "step": 0.1, + "value": 3.0 + }, + { + "name": "p1", + "label": "inlet pressure P1 (kPa)", + "min": 80.0, + "max": 200.0, + "step": 5.0, + "value": 120.0 + }, + { + "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.0, + "max": 16.0, + "step": 1.0, + "value": 10.0 + }, + { + "name": "f2", + "label": "tone 2 f2 (Hz)", + "min": 4.0, + "max": 16.0, + "step": 1.0, + "value": 12.0 + } + ], + "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.0, + "step": 0.1, + "value": 2.0 + }, + { + "name": "T", + "label": "period T (s)", + "min": 0.5, + "max": 4.0, + "step": 0.1, + "value": 2.0 + }, + { + "name": "phi", + "label": "phase phi (rad)", + "min": -3.14, + "max": 3.14, + "step": 0.1, + "value": 0.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.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "k", + "label": "spring constant k (N/m)", + "min": 1.0, + "max": 30.0, + "step": 0.5, + "value": 10.0 + }, + { + "name": "A", + "label": "amplitude A (m)", + "min": 0.3, + "max": 1.5, + "step": 0.1, + "value": 1.0 + } + ], + "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.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "theta0", + "label": "start angle theta0 (deg)", + "min": 5.0, + "max": 45.0, + "step": 1.0, + "value": 25.0 + } + ], + "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.0, + "max": 20.0, + "step": 0.5, + "value": 8.0 + }, + { + "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.0, + "max": 20.0, + "step": 0.5, + "value": 4.0 + }, + { + "name": "b", + "label": "damping b (kg/s)", + "min": 0.0, + "max": 4.0, + "step": 0.05, + "value": 0.5 + }, + { + "name": "A", + "label": "amplitude A (m)", + "min": 0.5, + "max": 2.5, + "step": 0.1, + "value": 2.0 + } + ], + "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.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "L", + "label": "angular momentum L (kg·m²/s)", + "min": 2.0, + "max": 16.0, + "step": 1.0, + "value": 8.0 + } + ], + "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.0, + "max": 8.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "m2", + "label": "mass m2 (kg)", + "min": 1.0, + "max": 8.0, + "step": 0.5, + "value": 5.0 + } + ], + "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.0, + "max": 10.0, + "step": 0.5, + "value": 6.0 + }, + { + "name": "m2", + "label": "mass m2 (×10²⁴ kg)", + "min": 1.0, + "max": 10.0, + "step": 0.5, + "value": 6.0 + } + ], + "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.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// the radius vector sweeps EQUAL AREAS in equal times (faster near the Sun).\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\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, Sun shifted to +c focus)\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// planet position via true anomaly that advances; use Kepler-consistent speed\n// r(theta) = a(1-e^2)/(1+e cos theta). dtheta/dt ~ 1/r^2 -> equal areas.\nconst period = Math.sqrt(a * a * a); // Kepler's 3rd: T^2 = a^3 (years, AU)\nconst theta = (t * 0.6) % H.TAU; // sweep angle (visual)\nconst rp = a * (1 - e * e) / (1 + e * Math.cos(theta));\nconst px = sx + rp * Math.cos(theta) * scale;\nconst py = sy - rp * Math.sin(theta) * scale;\n// the swept radius vector (equal-area \"pie slice\")\nH.path([[sx, sy], [px, py]], { color: H.colors.accent, width: 1, fill: H.hsl(210, 70, 60, 0.18), close: true });\nconst th2 = theta - 0.4;\nconst r2 = a * (1 - e * e) / (1 + e * Math.cos(th2));\nH.path([[sx, sy], [px, py], [sx + r2 * Math.cos(th2) * scale, sy - r2 * Math.sin(th2) * scale]], { color: \"none\", width: 1, 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.0, + "max": 12.0, + "step": 0.5, + "value": 6.0 + } + ], + "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.0, + "max": 200.0, + "step": 1.0, + "value": 20.0 + }, + { + "name": "area", + "label": "contact area A (m^2)", + "min": 0.05, + "max": 2.0, + "step": 0.05, + "value": 0.25 + }, + { + "name": "rho", + "label": "density rho (kg/m^3)", + "min": 100.0, + "max": 11000.0, + "step": 100.0, + "value": 2700.0 + } + ], + "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 });\nconst np = 5;\nfor (let i = 0; i < np; i++) {\n const px = cx - bw / 2 + bw * (i + 0.5) / np;\n const amp = 14 + 6 * 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.0, + "max": 13600.0, + "step": 100.0, + "value": 1000.0 + }, + { + "name": "depth", + "label": "max depth (m)", + "min": 1.0, + "max": 20.0, + "step": 0.5, + "value": 10.0 + }, + { + "name": "patm", + "label": "surface pressure Patm (Pa)", + "min": 0.0, + "max": 120000.0, + "step": 5000.0, + "value": 101325.0 + } + ], + "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.0, + "max": 500.0, + "step": 10.0, + "value": 50.0 + }, + { + "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.0, + "max": 2000.0, + "step": 50.0, + "value": 1000.0 + }, + { + "name": "rhoo", + "label": "object density rho_o (kg/m^3)", + "min": 100.0, + "max": 2000.0, + "step": 50.0, + "value": 600.0 + }, + { + "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.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "a2", + "label": "narrow area A2 (m^2)", + "min": 0.5, + "max": 4.0, + "step": 0.5, + "value": 1.0 + }, + { + "name": "v1", + "label": "inlet speed v1 (m/s)", + "min": 0.5, + "max": 4.0, + "step": 0.5, + "value": 1.0 + } + ], + "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.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "qn", + "label": "negative charge -q (nC)", + "min": 0.5, + "max": 5.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "d", + "label": "half-separation d (m)", + "min": 0.5, + "max": 3.0, + "step": 0.25, + "value": 2.0 + } + ], + "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.0, + "max": 300.0, + "step": 10.0, + "value": 100.0 + }, + { + "name": "d", + "label": "plate gap d (mm)", + "min": 0.25, + "max": 5.0, + "step": 0.25, + "value": 1.0 + } + ], + "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.0, + "max": 50.0, + "step": 1.0, + "value": 10.0 + }, + { + "name": "V0", + "label": "supply V0 (V)", + "min": 1.0, + "max": 12.0, + "step": 0.5, + "value": 9.0 + }, + { + "name": "R", + "label": "resistance R (kohm)", + "min": 10.0, + "max": 300.0, + "step": 10.0, + "value": 100.0 + } + ], + "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.0, + "max": 1000.0, + "step": 10.0, + "value": 300.0 + }, + { + "name": "m", + "label": "molecular mass m (u)", + "min": 2.0, + "max": 60.0, + "step": 1.0, + "value": 28.0 + }, + { + "name": "N", + "label": "molecules N", + "min": 5.0, + "max": 60.0, + "step": 1.0, + "value": 30.0 + } + ], + "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.0, + "max": 300.0, + "step": 10.0, + "value": 200.0 + }, + { + "name": "W", + "label": "work by gas W (J)", + "min": -200.0, + "max": 300.0, + "step": 10.0, + "value": 80.0 + } + ], + "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.0, + "max": 600.0, + "step": 10.0, + "value": 300.0 + }, + { + "name": "proc", + "label": "process: 0=isoT 1=isoP 2=isoV", + "min": 0.0, + "max": 2.0, + "step": 1.0, + "value": 0.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.0, + "max": 900.0, + "step": 10.0, + "value": 600.0 + }, + { + "name": "Tc", + "label": "cold reservoir Tc (K)", + "min": 200.0, + "max": 500.0, + "step": 10.0, + "value": 300.0 + }, + { + "name": "Qh", + "label": "heat input Qh (J)", + "min": 50.0, + "max": 500.0, + "step": 10.0, + "value": 300.0 + } + ], + "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.0, + "max": 80.0, + "step": 1.0, + "value": 40.0 + }, + { + "name": "ratio", + "label": "expansion Vf/Vi", + "min": 1.2, + "max": 4.0, + "step": 0.1, + "value": 2.0 + } + ], + "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.0, + "max": 3.0, + "step": 1.0, + "value": 1.0 + }, + { + "name": "q2", + "label": "charge q2 (units of e)", + "min": -3.0, + "max": 3.0, + "step": 1.0, + "value": -1.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "q2", + "label": "charge q2 (µC)", + "min": -5.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "r", + "label": "separation r (m)", + "min": 1.0, + "max": 9.0, + "step": 0.5, + "value": 6.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "d", + "label": "test distance d (m)", + "min": 1.0, + "max": 6.0, + "step": 0.5, + "value": 3.0 + } + ], + "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.0, + "max": 2.0, + "step": 1.0, + "value": 1.0 + }, + { + "name": "q2", + "label": "right charge q2", + "min": -2.0, + "max": 2.0, + "step": 1.0, + "value": -1.0 + }, + { + "name": "sep", + "label": "separation (units)", + "min": 2.0, + "max": 8.0, + "step": 1.0, + "value": 5.0 + } + ], + "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.0, + "max": 5.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "r", + "label": "test distance r (m)", + "min": 1.0, + "max": 10.0, + "step": 0.5, + "value": 4.0 + } + ], + "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.0, + "max": 30.0, + "step": 1.0, + "value": 23.0 + }, + { + "name": "L0", + "label": "original length L0 (m)", + "min": 0.5, + "max": 4.0, + "step": 0.5, + "value": 2.0 + }, + { + "name": "dT", + "label": "temp change ΔT (°C)", + "min": 10.0, + "max": 200.0, + "step": 10.0, + "value": 100.0 + } + ], + "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.0, + "step": 0.1, + "value": 0.5 + }, + { + "name": "c", + "label": "specific heat c (J/kg·°C)", + "min": 130.0, + "max": 4186.0, + "step": 1.0, + "value": 4186.0 + }, + { + "name": "power", + "label": "heater power P (W)", + "min": 100.0, + "max": 1500.0, + "step": 50.0, + "value": 500.0 + } + ], + "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.0, + "max": 500.0, + "step": 10.0, + "value": 334.0 + }, + { + "name": "Lv", + "label": "heat of vaporization Lv (kJ/kg)", + "min": 1000.0, + "max": 3000.0, + "step": 50.0, + "value": 2260.0 + } + ], + "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.0, + "step": 0.1, + "value": 0.8 + }, + { + "name": "dT", + "label": "temp difference ΔT (K)", + "min": 5.0, + "max": 100.0, + "step": 5.0, + "value": 50.0 + }, + { + "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.0, + "step": 0.1, + "value": 1.0 + }, + { + "name": "T", + "label": "temperature T (K)", + "min": 200.0, + "max": 700.0, + "step": 10.0, + "value": 300.0 + }, + { + "name": "V", + "label": "volume V (L)", + "min": 2.0, + "max": 40.0, + "step": 1.0, + "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.0, + "max": 500.0, + "step": 10.0, + "value": 200.0 + }, + { + "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.0, + "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.05, P.area);\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.0, + "max": 1.5, + "step": 0.05, + "value": 0.5 + }, + { + "name": "L", + "label": "rod length L (m)", + "min": 0.1, + "max": 1.0, + "step": 0.05, + "value": 0.5 + }, + { + "name": "v", + "label": "peak speed v (m/s)", + "min": 0.5, + "max": 6.0, + "step": 0.5, + "value": 3.0 + }, + { + "name": "R", + "label": "resistance R (ohm)", + "min": 0.5, + "max": 10.0, + "step": 0.5, + "value": 2.0 + } + ], + "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.0, + "max": 240.0, + "step": 5.0, + "value": 120.0 + }, + { + "name": "Np", + "label": "primary turns Np", + "min": 10.0, + "max": 500.0, + "step": 10.0, + "value": 100.0 + }, + { + "name": "Ns", + "label": "secondary turns Ns", + "min": 10.0, + "max": 500.0, + "step": 10.0, + "value": 300.0 + } + ], + "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// primary windings (left bar) — draw Np-ish loops, capped for screen\nconst npDraw = Math.min(12, Np);\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}\n// secondary windings (right bar)\nconst nsDraw = Math.min(12, Ns);\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\nH.line(sx - 20, midY + 56, sx - 20 + vp_inst, midY + 56, { color: H.colors.accent, width: 5 });\nH.line(lx - 20, midY + 56, lx - 20 + vs_inst, 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);" + } +] \ No newline at end of file From 8c6e3d33494a20e4bd49a7d184395bb9e8e3f168 Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Wed, 24 Jun 2026 17:18:06 +0700 Subject: [PATCH 26/43] Docs: custom-domain (VisualLM.com) deploy steps + no-key note The app is origin-agnostic (relative paths, CSP 'self'), so a custom domain needs only DNS. Document the Render custom-domain + registrar DNS flow, and note the site works with no API key (demos/library served without model calls). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 8afcecb..bdeea9d 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,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 From 136f9dab76fff8e24e9e5403d89f225db01f5340 Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Wed, 24 Jun 2026 17:24:48 +0700 Subject: [PATCH 27/43] Physics correctness audit: fix 9 demos with wrong physics Adversarial review of all 120 physics demos: 111/120 correct (92.5%), 9 fixed and re-validated. Real conceptual bugs caught: - ph-orbits-keplers-laws: planet swept at constant angular speed (violates Kepler's 2nd law / equal areas). - ph-normal-force: normal arrow not perpendicular to the incline surface. - ph-concave-convex-mirrors: focal point and center drawn behind the mirror. - ph-lenzs-law: loop area pinned to slider max. - ph-doppler-effect: wavefronts microscopic, no visible bunching, source motion decoupled from the v_src slider. - 4 minor (density pressure bar, wave-speed crest on a node, transformer secondary, inclined-plane direction). Every fix re-checked through validate_scene.js; all 120 still validate. 93 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- physics_library_generated.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/physics_library_generated.json b/physics_library_generated.json index 093ec11..5a24b9c 100644 --- a/physics_library_generated.json +++ b/physics_library_generated.json @@ -657,7 +657,7 @@ "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.\nconst span = 10;\nconst crestX = ((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);" + "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", @@ -849,7 +849,7 @@ "value": 100.0 } ], - "code": "H.background();\n// Doppler effect: f_obs = f_src * v / (v - v_src) (source moving TOWARD a still observer)\n// v = 340 m/s speed of sound. The source flies back and forth; wavefronts\n// bunch ahead of it (higher pitch) and spread behind (lower pitch).\nconst fsrc = Math.max(1, P.f); // emitted frequency (Hz)\nconst vsrc = P.vs; // source speed (m/s), + = moving right\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// source oscillates across the field so it never drifts away\nconst sx = 150 * Math.sin(t * 0.5);\nconst dir = Math.cos(t * 0.5); // +1 moving right, -1 moving left\nconst svel = vsrc * dir; // signed source velocity\n// draw wavefronts emitted at past times: each is a circle centered where the\n// source WAS, radius = c * age. Centers trail behind a moving source => bunching.\nconst Temit = 1 / fsrc * 12; // visible emission spacing (scaled)\nfor (let n = 1; n <= 7; n++) {\n const age = n * Temit;\n const cxEmit = 150 * Math.sin((t - age) * 0.5); // where source was then\n const rad = (c / 12) * age; // wavefront radius (scaled)\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\nv.dot(sx, 0, { r: 7, fill: H.colors.warn });\nv.arrow(sx, 0, sx + svel * 0.25, 0, { color: H.colors.warn, width: 2 });\n// observed frequency ahead of the source (denominator guarded away from 0)\nconst denom = Math.max(1, c - Math.abs(svel));\nconst fAhead = fsrc * c / denom;\nconst fBehind = fsrc * c / (c + Math.abs(svel));\nH.text(\"Doppler effect: f' = f · v / (v ∓ v_source)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"f = \" + fsrc.toFixed(0) + \" Hz v_src = \" + Math.abs(svel).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);" + "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", @@ -1944,7 +1944,7 @@ "value": 25.0 } ], - "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: along-slope (u) and surface-normal (n)\nconst ux = Math.cos(th), uy = -Math.sin(th);\nconst nx = Math.sin(th), ny = Math.cos(th); // outward normal (up-left)\nconst bs = 26;\n// block centered slightly above the surface along the 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), 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 + 4, 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);" + "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", @@ -1990,7 +1990,7 @@ "value": 2.0 } ], - "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 v = H.plot2d({ xMin: -12, xMax: 8, yMin: -5, yMax: 5 });\nv.grid(); v.axes();\nconst f = (Math.abs(P.f) < 0.2 ? 0.2 : P.f); // focal length (cm)\nconst ho = P.ho; // object height (cm)\n// Object distance sweeps so the image moves through its whole range.\nconst do_ = 7 + 3.5 * Math.sin(t * 0.6); // always positive (in front)\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// Mirror surface as a shallow arc x = y^2 / (2R) (vertex at origin).\nconst arc = [];\nfor (let yy = -4; yy <= 4.001; yy += 0.2) arc.push([(yy * yy) / (2 * 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(f, 0, { r: 5, fill: H.colors.good }); // focal point F\nv.dot(R, 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, then 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: through the center C reflects straight back (hits mirror, returns to 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);" + "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 v = H.plot2d({ xMin: -12, xMax: 8, yMin: -5, yMax: 5 });\nv.grid(); v.axes();\nconst f = (Math.abs(P.f) < 0.2 ? 0.2 : P.f); // focal length (cm)\nconst ho = P.ho; // object height (cm)\n// Object distance sweeps so the image moves through its whole range.\nconst do_ = 7 + 3.5 * Math.sin(t * 0.6); // always positive (in front)\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 x = -y^2 / (2R) so it curves toward the\n// front (concave bulges away from the object for f>0).\nconst arc = [];\nfor (let yy = -4; yy <= 4.001; yy += 0.2) arc.push([-(yy * yy) / (2 * 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 (front)\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// Since object tip, mirror point (0,ho), F=(-f,0) and the image tip are\n// colinear, 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", @@ -3989,7 +3989,7 @@ "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. A block slides down 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 fric = Math.min(fMax, along); // can't exceed the driving force at rest\nconst net = along - fMax; // net once it slides (kinetic-style)\nconst a = net > 0 ? net / m : 0; // slides only if it overcomes friction\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)\nconst prog = (t % 3) / 3; // 0..1 loop\nconst sPos = sMax * (1 - prog); // start at top, slide to bottom\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(net > 0 ? \"slides: a = \" + a.toFixed(2) + \" m/s² down the slope\" : \"static: mg sinθ ≤ μ mg cosθ → stays put\", 24, H.H - 26, { color: net > 0 ? 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);" + "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", @@ -4568,7 +4568,7 @@ "value": 0.4 } ], - "code": "H.background();\n// Kepler's laws: orbit is an ellipse with the Sun at a focus;\n// the radius vector sweeps EQUAL AREAS in equal times (faster near the Sun).\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\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, Sun shifted to +c focus)\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// planet position via true anomaly that advances; use Kepler-consistent speed\n// r(theta) = a(1-e^2)/(1+e cos theta). dtheta/dt ~ 1/r^2 -> equal areas.\nconst period = Math.sqrt(a * a * a); // Kepler's 3rd: T^2 = a^3 (years, AU)\nconst theta = (t * 0.6) % H.TAU; // sweep angle (visual)\nconst rp = a * (1 - e * e) / (1 + e * Math.cos(theta));\nconst px = sx + rp * Math.cos(theta) * scale;\nconst py = sy - rp * Math.sin(theta) * scale;\n// the swept radius vector (equal-area \"pie slice\")\nH.path([[sx, sy], [px, py]], { color: H.colors.accent, width: 1, fill: H.hsl(210, 70, 60, 0.18), close: true });\nconst th2 = theta - 0.4;\nconst r2 = a * (1 - e * e) / (1 + e * Math.cos(th2));\nH.path([[sx, sy], [px, py], [sx + r2 * Math.cos(th2) * scale, sy - r2 * Math.sin(th2) * scale]], { color: \"none\", width: 1, 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 });" + "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", @@ -4658,7 +4658,7 @@ "value": 2700.0 } ], - "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 });\nconst np = 5;\nfor (let i = 0; i < np; i++) {\n const px = cx - bw / 2 + bw * (i + 0.5) / np;\n const amp = 14 + 6 * 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);" + "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", @@ -5829,7 +5829,7 @@ "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.05, P.area);\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);" + "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", @@ -5946,6 +5946,6 @@ "value": 300.0 } ], - "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// primary windings (left bar) — draw Np-ish loops, capped for screen\nconst npDraw = Math.min(12, Np);\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}\n// secondary windings (right bar)\nconst nsDraw = Math.min(12, Ns);\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\nH.line(sx - 20, midY + 56, sx - 20 + vp_inst, midY + 56, { color: H.colors.accent, width: 5 });\nH.line(lx - 20, midY + 56, lx - 20 + vs_inst, 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);" + "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);" } ] \ No newline at end of file From ee654fd9ec876d3eba6bf027f10c2b96434d2d5a Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Wed, 24 Jun 2026 17:26:21 +0700 Subject: [PATCH 28/43] Complete physics coverage: add standing waves in pipes/strings (121/121) The Sound batch produced 120 of 121 topics. Hand-wrote the missing one (ph-standing-waves-pipes-strings: harmonics f_n = n v / (2L) with nodes, fixed ends, breathing standing-wave pattern), validated via validate_scene.js. All 121 physics topics now covered. DEMO_LIBRARY total: 333 (12 + 200 math + 121 physics). Co-Authored-By: Claude Opus 4.8 (1M context) --- physics_library_generated.json | 55 ++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/physics_library_generated.json b/physics_library_generated.json index 5a24b9c..a25a906 100644 --- a/physics_library_generated.json +++ b/physics_library_generated.json @@ -5947,5 +5947,60 @@ } ], "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 From ebed0cfb5409a70dbd11098b95e7952b2ace0352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Thu, 25 Jun 2026 16:18:57 +0700 Subject: [PATCH 29/43] Add chemistry: 3D molecule structures + equation balancing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new capabilities wired into plan_visualization (step 1.4, before the demo/library/generative paths), in a new chemistry.py module: - Molecule mode: a chemical formula ("CH4", "C6H12O6") or known name ("benzene", "ethanol") renders a 3D ball-and-stick structure (CPK colors, depth-sorted spheres, multi-order bonds, element labels). Geometry comes from a VSEPR generator (correct single-centre hydride/halide angles from valence electrons) plus a curated library: 12 computed seeds + 52 workflow-generated molecules (organics, oxoacids, polyatomic ions, multi-center inorganics) that were adversarially chemist-verified, then gated deterministically (atom multiset == formula, sane bond lengths, no overlaps, single connected graph) and render-validated via the headless node validator. - Balance mode: a reaction ("C3H8 + O2 -> CO2 + H2O") is balanced exactly via the rational null space of the element-composition matrix, then rendered as reactant cards (with coefficient badges) -> products + a per-element conservation tally. Layout is width-responsive so 6-species redox reactions fit (e.g. 2 KMnO4 + 16 HCl -> 2 KCl + 2 MnCl2 + 8 H2O + 5 Cl2). Detection is conservative — formulas/reactions/known names only, never hijacking math or physics prompts. Also refine the AI tutor's Claude call: adaptive thinking + effort=medium + max_tokens 8000 (was no-thinking / 2000) for stronger step-by-step, beginner-proof math explanations. Ships chemistry.py + chemistry_molecules_generated.json via the Dockerfile. 23 new tests (parser, exact balancer incl. permanganate redox, VSEPR shapes, conservative detection, node render-validation of every library molecule); full suite 116 passing. Verified end-to-end in the browser preview. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 9 +- app.js | 10 + chemistry.py | 1020 +++++ chemistry_molecules_generated.json | 6097 ++++++++++++++++++++++++++++ index.html | 11 +- main.py | 54 +- tests/test_main.py | 149 + 7 files changed, 7344 insertions(+), 6 deletions(-) create mode 100644 chemistry.py create mode 100644 chemistry_molecules_generated.json diff --git a/Dockerfile b/Dockerfile index 9d23e1e..7091589 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,10 +16,11 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Server, both libraries (STEM scenes + curriculum demos), the generated data -# (*_generated.json — scene, demo, and physics), the validator, and the UI. -# The glob keeps new generated data files (e.g. physics) shipping automatically. -COPY main.py scene_library.py demo_library.py 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 ./ diff --git a/app.js b/app.js index 16a8d36..c61ea0f 100644 --- a/app.js +++ b/app.js @@ -626,6 +626,7 @@ gemini: "Gemini", ollama: "local model", library: "curated library", + chemistry: "chemistry engine", fallback: "fallback", }; function engineName(engine) { @@ -834,6 +835,15 @@ "Regenerate or rephrase for a better scene.", "warn", ); + } 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( diff --git a/chemistry.py b/chemistry.py new file mode 100644 index 0000000..6681d40 --- /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: '#9aa3c0', 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/index.html b/index.html index 4acf526..85c1881 100644 --- a/index.html +++ b/index.html @@ -158,6 +158,15 @@

Try an example

+ + + @@ -258,6 +267,6 @@

Quick questions

- + diff --git a/main.py b/main.py index d596f2f..bd01fb6 100644 --- a/main.py +++ b/main.py @@ -1713,6 +1713,15 @@ def _cloud_generators() -> list[tuple[str, object]]: except Exception: # noqa: BLE001 — optional; never block startup DEMO_LIBRARY = [] +# 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 + _scene_cache_lock = threading.Lock() _scene_cache: dict[tuple, dict] = {} _SCENE_CACHE_MAX = 256 @@ -1865,6 +1874,28 @@ def _demo_scene(demo: dict, prompt: str) -> dict: } +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", ""), + } + + # 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 @@ -1877,6 +1908,17 @@ 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.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 @@ -2147,9 +2189,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, ) diff --git a/tests/test_main.py b/tests/test_main.py index fd095b0..f168c5b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -736,5 +736,154 @@ def test_plan_routes_to_demo(self): self.assertEqual(plan["demo_id"], "linear-slope-intercept") +import chemistry # noqa: E402 + + +class ChemistryFormulaTests(unittest.TestCase): + def test_parse_simple(self): + self.assertEqual(chemistry.parse_formula("H2O"), {"H": 2, "O": 1}) + self.assertEqual(chemistry.parse_formula("C6H12O6"), {"C": 6, "H": 12, "O": 6}) + + def test_parse_parentheses(self): + self.assertEqual(chemistry.parse_formula("Ca(OH)2"), {"Ca": 1, "O": 2, "H": 2}) + + def test_parse_strips_charge(self): + self.assertEqual(chemistry.parse_formula("SO4^2-"), {"S": 1, "O": 4}) + self.assertEqual(chemistry.parse_formula("NH4+"), {"N": 1, "H": 4}) + + def test_parse_rejects_nonformula(self): + self.assertIsNone(chemistry.parse_formula("xyz")) + self.assertIsNone(chemistry.parse_formula("2H2O")) # leading coefficient + self.assertIsNone(chemistry.parse_formula("")) + + +class ChemistryBalanceTests(unittest.TestCase): + def test_combustion_propane(self): + self.assertEqual( + chemistry.balance_equation(["C3H8", "O2"], ["CO2", "H2O"]), [1, 5, 3, 4] + ) + + def test_water_synthesis(self): + self.assertEqual(chemistry.balance_equation(["H2", "O2"], ["H2O"]), [2, 1, 2]) + + def test_rust(self): + self.assertEqual(chemistry.balance_equation(["Fe", "O2"], ["Fe2O3"]), [4, 3, 2]) + + def test_redox_permanganate(self): + # A genuinely hard balance — exercises the rational null-space solver. + self.assertEqual( + chemistry.balance_equation( + ["KMnO4", "HCl"], ["KCl", "MnCl2", "H2O", "Cl2"] + ), + [2, 16, 2, 2, 8, 5], + ) + + def test_unbalanceable_returns_none(self): + # No carbon source for the product — mass can't be conserved. + self.assertIsNone(chemistry.balance_equation(["H2", "O2"], ["CO2"])) + + +class ChemistryVseprTests(unittest.TestCase): + def test_known_shapes(self): + cases = { + "CH4": "tetrahedral", + "NH3": "trigonal pyramidal", + "H2O": "bent", + "SF6": "octahedral", + "PCl5": "trigonal bipyramidal", + "BF3": "trigonal planar", + "BeCl2": "linear", + "XeF4": "square planar", + } + for formula, shape in cases.items(): + mol = chemistry.vsepr_molecule(formula) + self.assertIsNotNone(mol, formula) + self.assertEqual(mol["shape"], shape, formula) + counts = chemistry.parse_formula(formula) + self.assertEqual(len(mol["atoms"]), sum(counts.values()), formula) + + def test_co2_not_vsepr(self): + # CO2 has double bonds — not the single-bond hydride/halide VSEPR path. + self.assertIsNone(chemistry.vsepr_molecule("CO2")) + + +class ChemistryDetectionTests(unittest.TestCase): + def test_formula_is_molecule(self): + self.assertEqual(chemistry.detect_chemistry("CH4"), ("molecule", "CH4")) + + def test_reaction_is_balance(self): + kind, payload = chemistry.detect_chemistry("2H2 + O2 -> 2H2O") + self.assertEqual(kind, "balance") + self.assertEqual(payload, (["H2", "O2"], ["H2O"])) + + def test_balance_command_prefix(self): + kind, payload = chemistry.detect_chemistry("balance Fe + O2 -> Fe2O3") + self.assertEqual(kind, "balance") + self.assertEqual(payload, (["Fe", "O2"], ["Fe2O3"])) + + def test_bare_name(self): + self.assertEqual(chemistry.detect_chemistry("benzene"), ("molecule", "benzene")) + + def test_does_not_hijack_math_or_physics(self): + for p in [ + "Explain heat diffusion across a metal plate", + "y = sin(x) + 0.35 sin(3x)", + "derivative of x^2", + "Show a projectile launched at 22 m/s", + "x = 5", + ]: + self.assertIsNone(chemistry.detect_chemistry(p), p) + + def test_ambiguous_word_needs_cue(self): + self.assertIsNone(chemistry.detect_chemistry("water")) + self.assertEqual( + chemistry.detect_chemistry("structure of water"), ("molecule", "water") + ) + + +class ChemistrySceneTests(unittest.TestCase): + def test_molecule_scene_shape(self): + sc = chemistry.chemistry_scene("CH4") + self.assertEqual(sc["kind"], "molecule") + self.assertEqual(sc["dimension"], "3D") + for needle in ("H.background", "cam.sphere", "H.text"): + self.assertIn(needle, sc["code"]) + + def test_balance_scene_shape(self): + sc = chemistry.chemistry_scene("C3H8 + O2 -> CO2 + H2O") + self.assertEqual(sc["kind"], "balance") + self.assertIn("5", sc["equation"]) # balanced coefficient present + self.assertIn("H.background", sc["code"]) + + def test_plan_routes_formula_to_chemistry(self): + plan = main.plan_visualization("CH4", "auto") + self.assertTrue(plan.get("from_chemistry"), plan.get("engine")) + self.assertEqual(plan["chem_kind"], "molecule") + self.assertEqual(plan["engine"], "chemistry") + + def test_plan_routes_reaction_to_chemistry(self): + plan = main.plan_visualization("2H2 + O2 -> 2H2O", "auto") + self.assertTrue(plan.get("from_chemistry")) + self.assertEqual(plan["chem_kind"], "balance") + + def test_plan_does_not_hijack_physics(self): + plan = main.plan_visualization("Show a projectile launched at 22 m/s", "auto") + self.assertFalse(plan.get("from_chemistry")) + + def test_every_library_molecule_renders(self): + # Each curated molecule's rendered scene must run, paint, and label. + if not main.node_validator_available(): + self.skipTest("node validator not installed") + for canon in chemistry.MOLECULES: + mol = chemistry.lookup_molecule(chemistry.MOLECULES[canon].get("formula", canon)) + self.assertIsNotNone(mol, canon) + code = main.sanitize_code(chemistry._molecule_code(mol)) + r = main.headless_validate(code) + self.assertIsNotNone(r, canon) + self.assertTrue(r["ok"], f"{canon} threw: {r.get('error')}") + self.assertTrue(r["painted"], f"{canon} drew nothing") + self.assertTrue(r["text"], f"{canon} had no labels") + + if __name__ == "__main__": unittest.main() From fe20b50bb461b09fc086eac067312c67187dbacf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Thu, 25 Jun 2026 16:24:20 +0700 Subject: [PATCH 30/43] Apply correctness-audit fixes to curriculum libraries 25 entries refined from the correctness audit (20 demo, 4 physics, 1 scene). Every demo and scene still passes the headless node validator (runs, paints, animates, stays on-screen) via the existing test_every_* gates. Co-Authored-By: Claude Opus 4.8 (1M context) --- demo_library_generated.json | 20426 +++++++++++++++---------------- physics_library_generated.json | 12008 +++++++++--------- scene_library_generated.json | 4 +- 3 files changed, 16219 insertions(+), 16219 deletions(-) diff --git a/demo_library_generated.json b/demo_library_generated.json index a09e666..85961a2 100644 --- a/demo_library_generated.json +++ b/demo_library_generated.json @@ -1,10215 +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.0, - "max": 6.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "d", - "label": "distance d", - "min": 0.0, - "max": 6.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "d", - "label": "threshold d", - "min": 0.0, - "max": 7.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "gt", - "label": "0 = less than, 1 = greater", - "min": 0.0, - "max": 1.0, - "step": 1.0, - "value": 0.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.0, - "max": 5.0, - "step": 1.0, - "value": 2.0 - }, - { - "name": "b", - "label": "x-terms b", - "min": 0.0, - "max": 5.0, - "step": 1.0, - "value": 3.0 - }, - { - "name": "c", - "label": "units c", - "min": 0.0, - "max": 5.0, - "step": 1.0, - "value": 1.0 - }, - { - "name": "d", - "label": "units d", - "min": 0.0, - "max": 5.0, - "step": 1.0, - "value": 2.0 - } - ], - "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.0, - "max": 8.0, - "step": 0.5, - "value": -3.0 - }, - { - "name": "hi", - "label": "upper bound hi", - "min": -8.0, - "max": 8.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "mode", - "label": "0 = AND 1 = OR", - "min": 0.0, - "max": 1.0, - "step": 1.0, - "value": 0.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.0, - "max": 20.0, - "step": 1.0, - "value": 5.0 - }, - { - "name": "gap", - "label": "gap between terms", - "min": 1.0, - "max": 3.0, - "step": 1.0, - "value": 1.0 - }, - { - "name": "count", - "label": "how many terms", - "min": 2.0, - "max": 5.0, - "step": 1.0, - "value": 3.0 - } - ], - "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.0, - "max": 7.0, - "step": 1.0, - "value": 4.0 - }, - { - "name": "b", - "label": "y-coordinate b", - "min": -7.0, - "max": 7.0, - "step": 1.0, - "value": 3.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "b", - "label": "first term b", - "min": 1.0, - "max": 5.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "c", - "label": "second term c", - "min": 1.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "max": 0.0, - "step": 0.5, - "value": -6.0 - }, - { - "name": "xhi", - "label": "domain end x", - "min": 0.0, - "max": 7.0, - "step": 0.5, - "value": 6.0 - }, - { - "name": "amp", - "label": "amplitude (sets range)", - "min": 1.0, - "max": 6.0, - "step": 0.5, - "value": 5.0 - } - ], - "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.0, - "max": 8.0, - "step": 1.0, - "value": 4.0 - }, - { - "name": "q", - "label": "denominator q", - "min": 2.0, - "max": 8.0, - "step": 1.0, - "value": 6.0 - }, - { - "name": "r", - "label": "right side r", - "min": 0.5, - "max": 4.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "step", - "label": "solving step", - "min": 0.0, - "max": 2.0, - "step": 1.0, - "value": 0.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.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "b", - "label": "inside add b", - "min": 0.0, - "max": 4.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "c", - "label": "result c", - "min": 1.0, - "max": 20.0, - "step": 1.0, - "value": 14.0 - }, - { - "name": "step", - "label": "solving step", - "min": 0.0, - "max": 2.0, - "step": 1.0, - "value": 1.0 - } - ], - "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.0, - "max": 4.0, - "step": 1.0, - "value": 2.0 - }, - { - "name": "x", - "label": "value of x", - "min": -6.0, - "max": 6.0, - "step": 1.0, - "value": 3.0 - }, - { - "name": "b", - "label": "constant b", - "min": -8.0, - "max": 8.0, - "step": 1.0, - "value": 1.0 - } - ], - "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.0, - "max": 5.0, - "step": 1.0, - "value": 2.0 - }, - { - "name": "m", - "label": "exponent m", - "min": 0.0, - "max": 5.0, - "step": 1.0, - "value": 3.0 - }, - { - "name": "n", - "label": "exponent n", - "min": 0.0, - "max": 5.0, - "step": 1.0, - "value": 2.0 - } - ], - "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.0, - "max": 6.0, - "step": 1.0, - "value": 2.0 - }, - { - "name": "q", - "label": "second number q", - "min": -6.0, - "max": 6.0, - "step": 1.0, - "value": 3.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "q", - "label": "run (denominator) q", - "min": 1.0, - "max": 4.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "b", - "label": "intercept b", - "min": -1.0, - "max": 4.0, - "step": 0.5, - "value": 0.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.0, - "max": 1.0, - "step": 0.1, - "value": 0.5 - }, - { - "name": "b", - "label": "b", - "min": -3.0, - "max": 3.0, - "step": 0.5, - "value": -1.0 - }, - { - "name": "c", - "label": "c", - "min": -2.0, - "max": 8.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 12.0, - "step": 0.5, - "value": 8.0 - }, - { - "name": "width", - "label": "width W", - "min": 1.0, - "max": 8.0, - "step": 0.5, - "value": 5.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.1, - "value": 2.0 - }, - { - "name": "b1", - "label": "left const b1", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "a2", - "label": "right slope a2", - "min": -3.0, - "max": 3.0, - "step": 0.1, - "value": -1.0 - }, - { - "name": "b2", - "label": "right const b2", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "max": 120.0, - "step": 5.0, - "value": 100.0 - }, - { - "name": "fixed", - "label": "fixed cost $", - "min": 0.0, - "max": 40.0, - "step": 5.0, - "value": 20.0 - }, - { - "name": "cost", - "label": "cost / item $", - "min": 1.0, - "max": 20.0, - "step": 0.5, - "value": 8.0 - } - ], - "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.0, - "max": 9.0, - "step": 1.0, - "value": -3.0 - }, - { - "name": "b", - "label": "add b", - "min": -9.0, - "max": 9.0, - "step": 1.0, - "value": 5.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.1, - "value": 0.5 - }, - { - "name": "r1", - "label": "root r1", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": -3.0 - }, - { - "name": "r2", - "label": "root r2", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.5, - "value": 1.5 - }, - { - "name": "b", - "label": "start value b", - "min": -1.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 6.0, - "step": 1.0, - "value": -3.0 - }, - { - "name": "y1", - "label": "point 1 y", - "min": -6.0, - "max": 6.0, - "step": 1.0, - "value": -1.0 - }, - { - "name": "x2", - "label": "point 2 x", - "min": -6.0, - "max": 6.0, - "step": 1.0, - "value": 2.0 - }, - { - "name": "y2", - "label": "point 2 y", - "min": -6.0, - "max": 6.0, - "step": 1.0, - "value": 3.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.05, - "value": 1.0 - }, - { - "name": "b", - "label": "intercept b", - "min": -3.0, - "max": 5.0, - "step": 0.25, - "value": 1.0 - } - ], - "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.0, - "max": 40.0, - "step": 1.0, - "value": 24.0 - }, - { - "name": "width", - "label": "width w", - "min": 1.0, - "max": 10.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "max": 12.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "volB", - "label": "volume B (L)", - "min": 0.0, - "max": 12.0, - "step": 0.5, - "value": 6.0 - }, - { - "name": "concA", - "label": "concentration A (%)", - "min": 0.0, - "max": 100.0, - "step": 5.0, - "value": 10.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "b", - "label": "added b", - "min": -10.0, - "max": 10.0, - "step": 1.0, - "value": 5.0 - }, - { - "name": "c", - "label": "result c", - "min": -10.0, - "max": 20.0, - "step": 1.0, - "value": 14.0 - }, - { - "name": "step", - "label": "solving step", - "min": 0.0, - "max": 2.0, - "step": 1.0, - "value": 2.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b", - "label": "add b", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "c", - "label": "threshold c", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "max": 2.0, - "step": 1.0, - "value": 0.0 - }, - { - "name": "b", - "label": "left intercept b", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "gap", - "label": "intercept gap", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "max": 6.0, - "step": 1.0, - "value": 3.0 - }, - { - "name": "c", - "label": "right side c", - "min": -12.0, - "max": 12.0, - "step": 1.0, - "value": 6.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "c", - "label": "right side c", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 5.0 - } - ], - "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.0, - "max": 9.0, - "step": 1.0, - "value": 2.0 - }, - { - "name": "b", - "label": "b", - "min": 0.0, - "max": 9.0, - "step": 1.0, - "value": 3.0 - }, - { - "name": "c", - "label": "c", - "min": 0.0, - "max": 9.0, - "step": 1.0, - "value": 4.0 - } - ], - "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.0, - "max": 4.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b1", - "label": "intercept b1", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "b2", - "label": "intercept b2", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": -2.0 - } - ], - "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.0, - "max": 100.0, - "step": 5.0, - "value": 50.0 - }, - { - "name": "pct", - "label": "percent change p (%)", - "min": -80.0, - "max": 80.0, - "step": 5.0, - "value": 20.0 - } - ], - "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.0, - "max": 4.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b", - "label": "shared intercept b", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 4.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "x1", - "label": "point x1", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": -2.0 - }, - { - "name": "y1", - "label": "point y1", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 5.0, - "step": 1.0, - "value": 2.0 - }, - { - "name": "b", - "label": "b (const, left)", - "min": -4.0, - "max": 5.0, - "step": 1.0, - "value": 3.0 - }, - { - "name": "c", - "label": "c (x coef, right)", - "min": 1.0, - "max": 5.0, - "step": 1.0, - "value": 1.0 - }, - { - "name": "d", - "label": "d (const, right)", - "min": -4.0, - "max": 5.0, - "step": 1.0, - "value": 4.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);\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.0, - "max": 9.0, - "step": 1.0, - "value": 2.0 - }, - { - "name": "b", - "label": "b (bottom of ratio 1)", - "min": 1.0, - "max": 9.0, - "step": 1.0, - "value": 3.0 - }, - { - "name": "c", - "label": "c (top of ratio 2)", - "min": 1.0, - "max": 9.0, - "step": 1.0, - "value": 6.0 - } - ], - "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.0, - "max": 75.0, - "step": 5.0, - "value": 55.0 - }, - { - "name": "time", - "label": "time t (hours)", - "min": 1.0, - "max": 10.0, - "step": 0.5, - "value": 8.0 - } - ], - "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.0, - "max": 10.0, - "step": 0.5, - "value": 6.0 - }, - { - "name": "b", - "label": "x-amount b", - "min": 1.0, - "max": 6.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "b", - "label": "piece b", - "min": 1.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "step", - "label": "order: a+b / b+a", - "min": 0.0, - "max": 1.0, - "step": 1.0, - "value": 0.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.0, - "max": 100.0, - "step": 1.0, - "value": 20.0 - } - ], - "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.0, - "max": 4.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "h", - "label": "base height", - "min": 1.0, - "max": 3.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "k", - "label": "scale factor k", - "min": 0.5, - "max": 3.0, - "step": 0.25, - "value": 2.0 - } - ], - "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.0, - "max": 9.9, - "step": 0.1, - "value": 4.2 - }, - { - "name": "exp", - "label": "exponent n", - "min": -6.0, - "max": 9.0, - "step": 1.0, - "value": 3.0 - } - ], - "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.0, - "max": 7.0, - "step": 1.0, - "value": -3.0 - }, - { - "name": "y1", - "label": "point 1 y", - "min": -7.0, - "max": 7.0, - "step": 1.0, - "value": -2.0 - }, - { - "name": "x2", - "label": "point 2 x", - "min": -7.0, - "max": 7.0, - "step": 1.0, - "value": 4.0 - }, - { - "name": "y2", - "label": "point 2 y", - "min": -7.0, - "max": 7.0, - "step": 1.0, - "value": 3.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.5, - "value": -2.0 - }, - { - "name": "dx", - "label": "step in x Δx", - "min": 0.5, - "max": 3.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "m", - "label": "slope m", - "min": -3.0, - "max": 3.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "b", - "label": "start value b", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 4.0, - "step": 0.1, - "value": 1.5 - }, - { - "name": "b", - "label": "y-intercept b", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 0.0, - "step": 0.5, - "value": -4.0 - }, - { - "name": "y1", - "label": "point 1 y1", - "min": -7.0, - "max": 7.0, - "step": 0.5, - "value": -2.0 - }, - { - "name": "x2", - "label": "point 2 x2", - "min": 0.5, - "max": 7.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "y2", - "label": "point 2 y2", - "min": -7.0, - "max": 7.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "max": 4.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b", - "label": "y-intercept b", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "B", - "label": "B", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "C", - "label": "C", - "min": -8.0, - "max": 8.0, - "step": 1.0, - "value": 6.0 - } - ], - "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.0, - "max": 4.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b1", - "label": "intercept 1 b1", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "m2", - "label": "slope 2 m2", - "min": -4.0, - "max": 4.0, - "step": 0.1, - "value": -1.0 - }, - { - "name": "b2", - "label": "intercept 2 b2", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": -1.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "c1", - "label": "eq1 constant c1", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "a2", - "label": "eq2 x-coef a2", - "min": -3.0, - "max": 3.0, - "step": 0.1, - "value": -1.0 - }, - { - "name": "c2", - "label": "eq2 constant c2", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": -2.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b1", - "label": "lower intercept b1", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": -2.0 - }, - { - "name": "m2", - "label": "upper slope m2", - "min": -3.0, - "max": 3.0, - "step": 0.1, - "value": -1.0 - }, - { - "name": "b2", - "label": "upper intercept b2", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.1, - "value": 0.5 - }, - { - "name": "d", - "label": "line-2 gap d", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.1, - "value": 1.5 - }, - { - "name": "b", - "label": "intercept b", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": -1.0 - }, - { - "name": "c", - "label": "eq2 constant c", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "max": 12.0, - "step": 1.0, - "value": 10.0 - }, - { - "name": "aprice", - "label": "adult price", - "min": 2.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "money", - "label": "total money", - "min": 10.0, - "max": 40.0, - "step": 1.0, - "value": 18.0 - } - ], - "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.0, - "max": 6.0, - "step": 1.0, - "value": 3.0 - }, - { - "name": "b", - "label": "number b", - "min": -9.0, - "max": 9.0, - "step": 1.0, - "value": 6.0 - }, - { - "name": "rel", - "label": "relation (0-3)", - "min": 0.0, - "max": 3.0, - "step": 1.0, - "value": 0.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.0, - "max": 12.0, - "step": 1.0, - "value": 5.0 - }, - { - "name": "phrase", - "label": "phrase (0-3)", - "min": 0.0, - "max": 3.0, - "step": 1.0, - "value": 0.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.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "b", - "label": "constant b", - "min": -6.0, - "max": 6.0, - "step": 1.0, - "value": 1.0 - }, - { - "name": "c", - "label": "right side c", - "min": -8.0, - "max": 8.0, - "step": 1.0, - "value": 5.0 - } - ], - "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -10, yMax: 10 });\nv.grid(); v.axes();\nconst a = (Math.abs(P.a) < 1e-6 ? 1 : P.a), b = P.b, c = P.c;\nconst sol = H.clamp((c - b) / a, -10, 10);\n// line y = a x + b\nv.fn(x => a * x + b, { color: H.colors.accent, width: 3 });\n// target horizontal line y = c\nv.line(-10, c, 10, c, { color: H.colors.accent2, width: 2, dash: [6, 5] });\n// intersection = the solution x\nv.dot(sol, c, { r: 6 + Math.sin(t * 3), fill: H.colors.warn });\nv.line(sol, -10, sol, c, { color: H.colors.violet, width: 1.5, dash: [4, 4] });\n// stepped reveal: phase 0 show equation, 1 subtract b, 2 divide a\nconst phase = Math.floor((t % 9) / 3);\nconst steps = [\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 = \" + ((c - b) / a).toFixed(2)\n];\n// moving dot riding the line toward the solution\nconst xs = sol * (0.5 + 0.5 * Math.sin(t * 0.8));\nv.dot(xs, a * xs + b, { r: 5, fill: H.colors.good });\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.text(\"x = \" + ((c - b) / a).toFixed(2), v.X(sol) + 8, v.Y(c) - 8, { color: H.colors.warn, size: 13, weight: 700 });\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.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "b", - "label": "left constant b", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "c", - "label": "right slope c", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": -1.0 - }, - { - "name": "d", - "label": "right constant d", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 4.0 - } - ], - "code": "H.background();\nconst a = P.a, b = P.b, c = P.c, d = P.d;\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -8, yMax: 8 });\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 (Math.abs(a - c) > 1e-6) {\n const xs = (d - b) / (a - c), ys = a * xs + b;\n v.line(xs, v.yMin, 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 + 0.2, ys + 0.6, { 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\nconst xp = 6 * 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.0, - "max": 4.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b", - "label": "y-intercept b", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.good });\nv.text(\"y-int (0, \" + b.toFixed(1) + \")\", 0.3, b + 0.7, { color: H.colors.good, size: 12 });\nlet xint = NaN;\nif (Math.abs(m) > 1e-9) {\n xint = -b / m;\n v.dot(xint, 0, { r: 7, fill: H.colors.warn });\n v.text(\"x-int (\" + xint.toFixed(1) + \", 0)\", xint + 0.3, -0.7, { color: H.colors.warn, size: 12 });\n}\nconst xs = 6 * 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.0, - "max": 5.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "d", - "label": "right side d", - "min": -1.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - } - ], - "code": "H.background();\nconst v = H.plot2d({ xMin: -8, xMax: 8, yMin: -2, yMax: 10 });\nv.grid(); v.axes();\nconst h = P.h, d = P.d;\nconst f = x => Math.abs(x - h);\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(-8, d, 8, 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 + 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.0, - "max": 3.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "h", - "label": "shift right h", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "k", - "label": "shift up k", - "min": -3.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "k", - "label": "tolerance k", - "min": 0.5, - "max": 6.0, - "step": 0.5, - "value": 4.0 - } - ], - "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -2, yMax: 10 });\nv.grid(); v.axes();\nconst c = P.c, k = Math.max(0, P.k);\nconst f = (x) => Math.abs(x - c);\nv.fn(f, { color: H.colors.accent, width: 3 });\nv.line(-10, k, 10, 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 + (k + 3) * 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.0, - "max": 6.0, - "step": 1.0, - "value": 1.0 - }, - { - "name": "b", - "label": "bottom of 1st b", - "min": 1.0, - "max": 5.0, - "step": 1.0, - "value": 2.0 - }, - { - "name": "c", - "label": "top of 2nd c", - "min": 1.0, - "max": 6.0, - "step": 1.0, - "value": 1.0 - }, - { - "name": "d", - "label": "bottom of 2nd d", - "min": 1.0, - "max": 5.0, - "step": 1.0, - "value": 3.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);\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;\nfunction bar(y, label, val, den, col, lit) {\n H.text(label, 24, y + rowH * 0.62, { color: H.colors.sub, size: 14 });\n H.rect(barX, y, barW, rowH * 0.7, { stroke: H.colors.grid, width: 1.5, radius: 6 });\n const n = Math.max(1, Math.abs(den));\n const seg = barW / n;\n const fillN = H.clamp(val, 0, n);\n for (let i = 0; i < n; i++) {\n const lit2 = i < fillN;\n H.rect(barX + i * seg + 2, y + 2, seg - 4, rowH * 0.7 - 4, { fill: lit2 ? col : H.colors.panel, radius: 3 });\n }\n // sweeping highlight to keep motion alive and bounded\n if (lit) {\n const sx = barX + ((t * 0.6) % 1) * barW;\n H.line(sx, y - 4, sx, y + rowH * 0.7 + 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.0, - "max": 8.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "d", - "label": "common difference d", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 10.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "d", - "label": "common difference d", - "min": -2.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "n", - "label": "how many terms n", - "min": 2.0, - "max": 10.0, - "step": 1.0, - "value": 6.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "c", - "label": "constant c", - "min": -4.0, - "max": 8.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "b", - "label": "imag part b", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "b", - "label": "Im z1 b", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "c", - "label": "Re z2 c", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": -2.0 - }, - { - "name": "d", - "label": "Im z2 d", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b", - "label": "b", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "c", - "label": "c", - "min": -8.0, - "max": 8.0, - "step": 0.5, - "value": 5.0 - } - ], - "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.0, - "max": 2000.0, - "step": 100.0, - "value": 1000.0 - }, - { - "name": "rate", - "label": "annual rate r", - "min": 0.0, - "max": 0.2, - "step": 0.01, - "value": 0.05 - }, - { - "name": "n", - "label": "compounds / year n", - "min": 1.0, - "max": 12.0, - "step": 1.0, - "value": 12.0 - } - ], - "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.0, - "max": 20.0, - "step": 1.0, - "value": 10.0 - }, - { - "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.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "k", - "label": "center y k", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "r", - "label": "radius r", - "min": 0.5, - "max": 5.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "max": 1.0, - "step": 1.0, - "value": 0.0 - }, - { - "name": "a", - "label": "semi-axis a", - "min": 1.0, - "max": 7.0, - "step": 0.5, - "value": 5.0 - }, - { - "name": "b", - "label": "semi-axis b", - "min": 1.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.1, - "value": 0.5 - }, - { - "name": "h", - "label": "vertex x h", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "k", - "label": "vertex y k", - "min": -2.0, - "max": 8.0, - "step": 0.5, - "value": -2.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b", - "label": "b", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "c", - "label": "c", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": -3.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "c", - "label": "col1 y c", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "b", - "label": "col2 x b", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "d", - "label": "col2 y d", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b", - "label": "b (slant)", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "c", - "label": "c (raises/lowers)", - "min": -8.0, - "max": 8.0, - "step": 0.5, - "value": -4.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": -1.0 - }, - { - "name": "k", - "label": "start y k", - "min": -1.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 10.0, - "step": 0.5, - "value": 8.0 - }, - { - "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.0, - "step": 0.1, - "value": 2.0 - }, - { - "name": "C", - "label": "target C", - "min": 0.5, - "max": 10.0, - "step": 0.5, - "value": 5.0 - } - ], - "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.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "r", - "label": "growth rate r", - "min": 0.0, - "max": 1.0, - "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.0, - "step": 0.1, - "value": 2.0 - }, - { - "name": "k", - "label": "vertical shift k", - "min": -3.0, - "max": 3.0, - "step": 0.5, - "value": 0.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.0, - "max": 6.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "b", - "label": "line shift b", - "min": -4.0, - "max": 3.0, - "step": 0.5, - "value": -1.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "c", - "label": "candidate c", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "r1", - "label": "root r₁", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": -2.0 - }, - { - "name": "r2", - "label": "root r₂", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "probe", - "label": "probe c", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": -4.0 - }, - { - "name": "r2", - "label": "zero r₂", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "r3", - "label": "zero r₃", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b", - "label": "inner shift b", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "c", - "label": "outer shift c", - "min": -4.0, - "max": 6.0, - "step": 0.5, - "value": 0.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.0, - "max": 2.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b", - "label": "b", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "c", - "label": "c", - "min": -4.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "input", - "label": "input x", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b", - "label": "f intercept b", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "op", - "label": "op: 1+ 2- 3* 4/", - "min": 1.0, - "max": 4.0, - "step": 1.0, - "value": 1.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "h", - "label": "shift right h", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": -2.0 - }, - { - "name": "k", - "label": "shift up k", - "min": -4.0, - "max": 8.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "r", - "label": "common ratio r", - "min": -2.0, - "max": 2.0, - "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.0, - "max": 8.0, - "step": 0.5, - "value": 4.0 - }, - { - "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.0, - "max": 12.0, - "step": 1.0, - "value": 8.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": -2.0 - }, - { - "name": "va", - "label": "asymptote at x =", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "b", - "label": "next coef b", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "d", - "label": "vert. asym. x = d", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "topdeg", - "label": "top degree (1 or 2)", - "min": 1.0, - "max": 2.0, - "step": 1.0, - "value": 1.0 - } - ], - "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.0, - "max": 12.0, - "step": 1.0, - "value": 7.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.1, - "value": 2.0 - }, - { - "name": "b", - "label": "intercept b", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.1, - "value": 0.5 - }, - { - "name": "c", - "label": "parabola c", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": -2.0 - }, - { - "name": "m", - "label": "line slope m", - "min": -4.0, - "max": 4.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "k", - "label": "line intercept k", - "min": -6.0, - "max": 8.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "step": 0.1, - "value": 2.0 - }, - { - "name": "x", - "label": "input x", - "min": 0.5, - "max": 8.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "max": 5.0, - "step": 1.0, - "value": 2.0 - }, - { - "name": "M", - "label": "factor M", - "min": 1.0, - "max": 16.0, - "step": 1.0, - "value": 4.0 - }, - { - "name": "N", - "label": "factor N", - "min": 1.0, - "max": 16.0, - "step": 1.0, - "value": 8.0 - } - ], - "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.0, - "step": 0.1, - "value": 2.0 - }, - { - "name": "C", - "label": "target C", - "min": -2.0, - "max": 3.0, - "step": 0.1, - "value": 2.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "b", - "label": "B entry b11", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "s", - "label": "scalar s", - "min": -3.0, - "max": 3.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 4.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "b", - "label": "b (row1 y)", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "c", - "label": "c (row2 x)", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "d", - "label": "d (row2 y)", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": -1.0 - } - ], - "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.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "b", - "label": "A entry a12", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "c", - "label": "B entry b11", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "d", - "label": "B entry b22", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.5, - "value": 0.5 - }, - { - "name": "root", - "label": "root r", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "m", - "label": "multiplicity m", - "min": 1.0, - "max": 5.0, - "step": 1.0, - "value": 2.0 - } - ], - "code": "H.background();\nconst root = P.root, m = Math.max(1, Math.round(P.m)), a = 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(xs, f(xs), { 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.0, - "max": 12.0, - "step": 1.0, - "value": 2.0 - }, - { - "name": "b", - "label": "bottom 1 b", - "min": 1.0, - "max": 12.0, - "step": 1.0, - "value": 3.0 - }, - { - "name": "c", - "label": "top 2 c", - "min": 1.0, - "max": 12.0, - "step": 1.0, - "value": 4.0 - }, - { - "name": "d", - "label": "bottom 2 d", - "min": 1.0, - "max": 12.0, - "step": 1.0, - "value": 6.0 - }, - { - "name": "op", - "label": "0 = × 1 = ÷", - "min": 0.0, - "max": 1.0, - "step": 1.0, - "value": 0.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.0, - "max": 6.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "sigma", - "label": "std dev σ", - "min": 1.0, - "max": 4.0, - "step": 0.25, - "value": 2.0 - } - ], - "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 = mu + 3 * sd * Math.sin(t * 0.6);\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.0, - "max": 6.0, - "step": 1.0, - "value": 2.0 - } - ], - "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);\nif (Number.isFinite(ys) && ys >= -6 && ys <= 6) {\n v.dot(xs, ys, { r: 7, fill: H.colors.warn });\n} else {\n v.dot(0, f(0.0001) || 0, { r: 7, fill: H.colors.warn });\n ys = NaN;\n}\nv.dot(0, Number.isFinite(f(0)) ? f(0) : 0, { 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) : \"undef\"), 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.0, - "max": 10.0, - "step": 1.0, - "value": 5.0 - }, - { - "name": "r", - "label": "pick r", - "min": 1.0, - "max": 6.0, - "step": 1.0, - "value": 3.0 - }, - { - "name": "kind", - "label": "0 perm / 1 comb", - "min": 0.0, - "max": 1.0, - "step": 1.0, - "value": 1.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "m1", - "label": "left slope m1", - "min": -3.0, - "max": 3.0, - "step": 0.1, - "value": -1.0 - }, - { - "name": "m2", - "label": "right slope m2", - "min": -3.0, - "max": 3.0, - "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, yb, { r: 6, fill: H.colors.warn });\nconst x0 = 6 * Math.sin(t * 0.6);\nv.dot(x0, piece(x0), { 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.0, - "max": 2.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "n", - "label": "degree n", - "min": 1.0, - "max": 6.0, - "step": 1.0, - "value": 3.0 - } - ], - "code": "H.background();\nconst v = H.plot2d({ xMin: -3, xMax: 3, yMin: -12, yMax: 12 });\nv.grid(); v.axes();\nconst a = P.a, 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.0, - "max": 2.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "r1", - "label": "root r1", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": -3.0 - }, - { - "name": "r2", - "label": "root r2", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "r3", - "label": "root r3", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "max": 4.0, - "step": 0.5, - "value": -3.0 - }, - { - "name": "r2", - "label": "root r2", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "r3", - "label": "root r3", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "dir", - "label": "0 = (<0) 1 = (>0)", - "min": 0.0, - "max": 1.0, - "step": 1.0, - "value": 1.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "c", - "label": "dividend const c", - "min": -8.0, - "max": 8.0, - "step": 0.5, - "value": -6.0 - }, - { - "name": "r", - "label": "divisor root r", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "step", - "label": "reveal step", - "min": 0.0, - "max": 5.0, - "step": 1.0, - "value": 5.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.25, - "value": 1.0 - }, - { - "name": "b1", - "label": "p: x coef b1", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "a2", - "label": "q: x² coef a2", - "min": -2.0, - "max": 2.0, - "step": 0.25, - "value": -0.5 - }, - { - "name": "b2", - "label": "q: x coef b2", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "r1", - "label": "root r1", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": -3.0 - }, - { - "name": "r2", - "label": "root r2", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b", - "label": "b", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": -1.0 - }, - { - "name": "c", - "label": "c", - "min": -8.0, - "max": 8.0, - "step": 0.5, - "value": -6.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b", - "label": "b", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "c", - "label": "c", - "min": -8.0, - "max": 8.0, - "step": 0.5, - "value": -4.0 - } - ], - "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.0, - "max": 20.0, - "step": 0.5, - "value": 12.0 - }, - { - "name": "h0", - "label": "start height h0", - "min": 0.0, - "max": 8.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "g", - "label": "gravity g", - "min": 2.0, - "max": 12.0, - "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.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "c", - "label": "vertical shift c", - "min": -3.0, - "max": 4.0, - "step": 0.5, - "value": -1.0 - }, - { - "name": "L", - "label": "level L", - "min": -3.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "h", - "label": "start x h", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": -2.0 - }, - { - "name": "k", - "label": "start y k", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 0.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.0, - "max": 200.0, - "step": 1.0, - "value": 72.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "h", - "label": "shift h", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "k", - "label": "target k", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 6.0, - "step": 1.0, - "value": 3.0 - }, - { - "name": "q", - "label": "denominator q (root)", - "min": 1.0, - "max": 6.0, - "step": 1.0, - "value": 2.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "h", - "label": "vert asymptote h", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "k", - "label": "horiz asymptote k", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": -1.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": -3.0 - }, - { - "name": "q", - "label": "undefined at q", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "L", - "label": "level L", - "min": -3.0, - "max": 3.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 8.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "b", - "label": "multiplier b", - "min": -1.5, - "max": 2.0, - "step": 0.1, - "value": 1.5 - }, - { - "name": "c", - "label": "add each step c", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "b", - "label": "coef b (x)", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": -1.0 - }, - { - "name": "c", - "label": "coef c (1)", - "min": -8.0, - "max": 8.0, - "step": 0.5, - "value": -2.0 - }, - { - "name": "k", - "label": "test value k", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "q", - "label": "zero q", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": -2.0 - }, - { - "name": "r", - "label": "asymptote r", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": -3.0 - } - ], - "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.0, - "max": 4.0, - "step": 1.0, - "value": 1.0 - }, - { - "name": "b", - "label": "coef b (x)", - "min": -8.0, - "max": 8.0, - "step": 1.0, - "value": -5.0 - }, - { - "name": "c", - "label": "coef c (1)", - "min": -12.0, - "max": 12.0, - "step": 1.0, - "value": 6.0 - }, - { - "name": "r", - "label": "divisor r", - "min": -4.0, - "max": 4.0, - "step": 1.0, - "value": 2.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": -2.0 - }, - { - "name": "c", - "label": "range floor c", - "min": -1.0, - "max": 6.0, - "step": 0.5, - "value": 0.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.0, - "max": 3.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b", - "label": "horiz squeeze b", - "min": 0.2, - "max": 3.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "h", - "label": "shift right h", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "k", - "label": "shift up k", - "min": -4.0, - "max": 6.0, - "step": 0.5, - "value": 0.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.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 75.0, - "step": 1.0, - "value": 35.0 - }, - { - "name": "b", - "label": "side b (adjacent)", - "min": 3.0, - "max": 8.0, - "step": 0.5, - "value": 6.0 - }, - { - "name": "a", - "label": "side a (opposite)", - "min": 1.0, - "max": 8.0, - "step": 0.25, - "value": 4.0 - } - ], - "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.0, - "step": 0.1, - "value": 3.0 - }, - { - "name": "B", - "label": "frequency B", - "min": 0.5, - "max": 4.0, - "step": 0.1, - "value": 1.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "ay", - "label": "a y-component", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "bx", - "label": "b x-component", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 5.0 - }, - { - "name": "by", - "label": "b y-component", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "step": 0.1, - "value": 2.0 - }, - { - "name": "deg", - "label": "central angle (degrees)", - "min": 10.0, - "max": 350.0, - "step": 1.0, - "value": 120.0 - } - ], - "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.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "d", - "label": "difference d", - "min": -2.0, - "max": 3.0, - "step": 0.5, - "value": 1.0 - }, - { - "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.0, - "max": 1.0, - "step": 1.0, - "value": 0.0 - } - ], - "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 11, yMin: -2, yMax: 12 });\nv.grid(); v.axes();\nconst a1 = P.a1, d = P.d, r = P.r, mode = P.mode;\nconst geometric = mode >= 0.5;\nconst N = 10;\nconst pts = [];\nconst term = (n) => geometric ? a1 * Math.pow(r, n - 1) : a1 + (n - 1) * d;\nfor (let n = 1; n <= N; n++) {\n let y = term(n);\n if (!Number.isFinite(y)) y = 0;\n y = H.clamp(y, -1.8, 11.8);\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 = term(k);\nv.line(k, -2, 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.0, - "max": 1.0, - "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.0, - "max": 6.0, - "step": 1.0, - "value": 4.0 - }, - { - "name": "x", - "label": "x value", - "min": 0.5, - "max": 3.0, - "step": 0.1, - "value": 1.5 - }, - { - "name": "y", - "label": "y value", - "min": 0.5, - "max": 3.0, - "step": 0.1, - "value": 1.0 - } - ], - "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.0 - }, - { - "name": "d1", - "label": "angle θ1 (deg)", - "min": 0.0, - "max": 180.0, - "step": 1.0, - "value": 30.0 - }, - { - "name": "r2", - "label": "modulus r2", - "min": 0.5, - "max": 2.5, - "step": 0.1, - "value": 2.0 - }, - { - "name": "d2", - "label": "angle θ2 (deg)", - "min": 0.0, - "max": 180.0, - "step": 1.0, - "value": 45.0 - } - ], - "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.0, - "step": 0.1, - "value": 2.0 - }, - { - "name": "arg", - "label": "angle θ (deg)", - "min": 0.0, - "max": 360.0, - "step": 1.0, - "value": 60.0 - }, - { - "name": "n", - "label": "root count n", - "min": 2.0, - "max": 8.0, - "step": 1.0, - "value": 5.0 - } - ], - "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.0, - "step": 0.1, - "value": 3.5 - }, - { - "name": "deg", - "label": "argument θ (degrees)", - "min": 0.0, - "max": 360.0, - "step": 1.0, - "value": 40.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b", - "label": "inner shift b", - "min": -3.0, - "max": 3.0, - "step": 0.5, - "value": 0.0 - } - ], - "code": "H.background();\nconst v = H.plot2d({ xMin: -6, xMax: 6, yMin: -6, yMax: 10 });\nv.grid(); v.axes();\nconst m = P.m, b = P.b;\nconst g = (x) => m * x + b;\nconst f = (u) => u * u;\nconst comp = (x) => f(g(x));\nv.fn(g, { color: H.colors.accent2, width: 2 });\nv.fn(comp, { color: H.colors.accent, width: 3 });\nconst x0 = 4 * 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.0, - "max": 6.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "k", - "label": "center y k", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "r", - "label": "radius r", - "min": 0.5, - "max": 5.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "k", - "label": "center y k", - "min": -3.0, - "max": 3.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "a", - "label": "semi-axis a", - "min": 1.0, - "max": 8.0, - "step": 0.5, - "value": 6.0 - }, - { - "name": "b", - "label": "semi-axis b", - "min": 1.0, - "max": 6.0, - "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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "b", - "label": "semi-conjugate b", - "min": 0.5, - "max": 5.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "k", - "label": "vertex y k", - "min": -1.0, - "max": 5.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "p", - "label": "focal length p", - "min": 0.3, - "max": 3.0, - "step": 0.1, - "value": 1.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "jump", - "label": "jump size", - "min": -3.0, - "max": 3.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "hole", - "label": "hole (0=no,1=yes)", - "min": 0.0, - "max": 1.0, - "step": 1.0, - "value": 0.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.0, - "max": 120.0, - "step": 1.0, - "value": 35.0 - }, - { - "name": "n", - "label": "power n", - "min": 1.0, - "max": 8.0, - "step": 1.0, - "value": 5.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 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.0, - "max": 360.0, - "step": 1.0, - "value": 90.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.1, - "value": 1.5 - }, - { - "name": "h", - "label": "gap h", - "min": 0.2, - "max": 4.0, - "step": 0.1, - "value": 3.0 - } - ], - "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.0, - "max": 8.0, - "step": 0.5, - "value": 6.0 - }, - { - "name": "ay", - "label": "a: y-component", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "bmag", - "label": "|b| length", - "min": 1.0, - "max": 8.0, - "step": 0.5, - "value": 5.0 - }, - { - "name": "bangle", - "label": "b angle (deg)", - "min": 0.0, - "max": 360.0, - "step": 1.0, - "value": 70.0 - } - ], - "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.0, - "max": 360.0, - "step": 1.0, - "value": 60.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "k", - "label": "shift up k", - "min": -2.0, - "max": 5.0, - "step": 0.5, - "value": 0.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.0, - "max": 1.0, - "step": 0.1, - "value": 0.2 - }, - { - "name": "c2", - "label": "x^2 coef (even)", - "min": -1.0, - "max": 1.0, - "step": 0.1, - "value": 0.0 - }, - { - "name": "c1", - "label": "x coef (odd)", - "min": -3.0, - "max": 3.0, - "step": 0.5, - "value": 0.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.0, - "max": 15.0, - "step": 1.0, - "value": 2.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 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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "r", - "label": "rate % r", - "min": -30.0, - "max": 30.0, - "step": 1.0, - "value": 15.0 - }, - { - "name": "target", - "label": "target value", - "min": 1.0, - "max": 12.0, - "step": 0.5, - "value": 8.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "r", - "label": "ratio r", - "min": -0.9, - "max": 0.9, - "step": 0.05, - "value": 0.5 - }, - { - "name": "N", - "label": "terms N", - "min": 1.0, - "max": 12.0, - "step": 1.0, - "value": 8.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "C", - "label": "y² coefficient C", - "min": -2.0, - "max": 2.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 4.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 1.0, - "step": 0.05, - "value": 0.5 - }, - { - "name": "lo", - "label": "window low", - "min": -1.0, - "max": 2.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "hi", - "label": "window high", - "min": 3.0, - "max": 7.0, - "step": 0.5, - "value": 7.0 - } - ], - "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.0, - "max": 1.0, - "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.0, - "max": 7.0, - "step": 0.5, - "value": 5.0 - }, - { - "name": "b", - "label": "side b", - "min": 1.0, - "max": 6.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "C", - "label": "included angle C (deg)", - "min": 20.0, - "max": 160.0, - "step": 1.0, - "value": 60.0 - } - ], - "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.0, - "max": 120.0, - "step": 1.0, - "value": 50.0 - }, - { - "name": "B", - "label": "angle B (deg)", - "min": 20.0, - "max": 120.0, - "step": 1.0, - "value": 60.0 - }, - { - "name": "a", - "label": "side a", - "min": 2.0, - "max": 7.0, - "step": 0.5, - "value": 5.0 - } - ], - "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.0, - "max": 3.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "L", - "label": "limit value L", - "min": 1.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "gap", - "label": "start distance", - "min": 0.5, - "max": 3.0, - "step": 0.1, - "value": 2.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": -2.0 - }, - { - "name": "r2", - "label": "root r2", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "c", - "label": "cutoff c", - "min": -6.0, - "max": 8.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 5.0 - }, - { - "name": "b", - "label": "y amplitude b", - "min": 1.0, - "max": 4.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "p", - "label": "x frequency p", - "min": 1.0, - "max": 5.0, - "step": 1.0, - "value": 1.0 - }, - { - "name": "q", - "label": "y frequency q", - "min": 1.0, - "max": 5.0, - "step": 1.0, - "value": 2.0 - } - ], - "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.0, - "max": 12.0, - "step": 0.5, - "value": 12.0 - }, - { - "name": "angle", - "label": "launch angle (deg)", - "min": 25.0, - "max": 75.0, - "step": 1.0, - "value": 55.0 - }, - { - "name": "g", - "label": "gravity g", - "min": 8.0, - "max": 13.0, - "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.0, - "max": 3.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "D", - "label": "vertical shift D", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "k", - "label": "lobes k", - "min": 1.0, - "max": 6.0, - "step": 1.0, - "value": 3.0 - }, - { - "name": "b", - "label": "offset b", - "min": 0.0, - "max": 3.0, - "step": 0.5, - "value": 0.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.0, - "max": 5.0, - "step": 0.1, - "value": 4.0 - }, - { - "name": "k", - "label": "petal count k", - "min": 1.0, - "max": 6.0, - "step": 1.0, - "value": 3.0 - } - ], - "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.0, - "step": 0.1, - "value": 2.0 - }, - { - "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.0, - "max": 5.0, - "step": 0.1, - "value": 4.0 - }, - { - "name": "deg", - "label": "angle θ (degrees)", - "min": 0.0, - "max": 360.0, - "step": 1.0, - "value": 35.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "n", - "label": "degree n", - "min": 1.0, - "max": 6.0, - "step": 1.0, - "value": 3.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.1, - "value": 0.4 - }, - { - "name": "r1", - "label": "zero r1", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": -3.0 - }, - { - "name": "r2", - "label": "zero r2", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 0.0 - }, - { - "name": "r3", - "label": "zero r3", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "max": 4.0, - "step": 1.0, - "value": 0.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.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "q", - "label": "horiz asym q", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": -1.0 - }, - { - "name": "k", - "label": "stretch k", - "min": -8.0, - "max": 8.0, - "step": 0.5, - "value": 4.0 - } - ], - "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] });\nlet xs = p + 3 + 2.5 * Math.sin(t * 0.8);\nif (Math.abs(xs - p) < 0.3) xs = p + 0.3;\nv.dot(xs, f(xs), { r: 6, fill: H.colors.violet });\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.0, - "max": 80.0, - "step": 1.0, - "value": 35.0 - }, - { - "name": "dist", - "label": "distance d (m)", - "min": 10.0, - "max": 100.0, - "step": 1.0, - "value": 40.0 - } - ], - "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.0, - "max": 359.0, - "step": 1.0, - "value": 40.0 - } - ], - "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.0, - "max": 3.0, - "step": 1.0, - "value": 1.0 - }, - { - "name": "A", - "label": "vertical stretch A", - "min": 0.5, - "max": 3.0, - "step": 0.1, - "value": 1.0 - } - ], - "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.0, - "max": 360.0, - "step": 5.0, - "value": 210.0 - }, - { - "name": "k", - "label": "full turns k", - "min": -2.0, - "max": 2.0, - "step": 1.0, - "value": 1.0 - } - ], - "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 wraps = Math.abs(k);\nconst turns = (t * 0.4) % (wraps + 1);\nconst sgn = k >= 0 ? 1 : -1;\nconst showAng = ang + turns * Math.PI * 2 * sgn;\nconst sx = cx + R * Math.cos(showAng), sy = cy - R * Math.sin(showAng);\nconst sweep = [];\nconst maxA = ang + turns * Math.PI * 2 * sgn;\nfor (let i = 0; i <= 80; i++) { const a = maxA * (i / 80); 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.0, - "max": 85.0, - "step": 1.0, - "value": 37.0 - }, - { - "name": "hyp", - "label": "hypotenuse length", - "min": 1.0, - "max": 9.0, - "step": 0.5, - "value": 6.0 - } - ], - "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: 8 });\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.0, - "step": 0.1, - "value": 2.0 - }, - { - "name": "deg", - "label": "central angle (degrees)", - "min": 10.0, - "max": 350.0, - "step": 1.0, - "value": 90.0 - } - ], - "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.0, - "max": 6.0, - "step": 1.0, - "value": 1.0 - }, - { - "name": "hi", - "label": "upper bound", - "min": 1.0, - "max": 10.0, - "step": 1.0, - "value": 5.0 - }, - { - "name": "c", - "label": "coefficient c", - "min": 0.5, - "max": 2.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 3.0, - "step": 1.0, - "value": 1.0 - } - ], - "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.0 - }, - { - "name": "which", - "label": "1=sin 2=cos 3=both", - "min": 1.0, - "max": 3.0, - "step": 1.0, - "value": 3.0 - } - ], - "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.0, - "max": 7.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "per", - "label": "period (hours)", - "min": 4.0, - "max": 24.0, - "step": 1.0, - "value": 12.0 - }, - { - "name": "peak", - "label": "peak hour", - "min": 0.0, - "max": 24.0, - "step": 1.0, - "value": 15.0 - }, - { - "name": "mid", - "label": "midline (deg)", - "min": 2.0, - "max": 14.0, - "step": 0.5, - "value": 9.0 - } - ], - "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.0, - "max": 360.0, - "step": 1.0, - "value": 60.0 - } - ], - "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.0, - "max": 1.0, - "step": 1.0, - "value": 0.0 - }, - { - "name": "a", - "label": "point / width a", - "min": 1.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "N", - "label": "rectangles N", - "min": 1.0, - "max": 20.0, - "step": 1.0, - "value": 6.0 - } - ], - "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.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "b", - "label": "frequency b", - "min": 0.2, - "max": 3.0, - "step": 0.1, - "value": 1.0 - } - ], - "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.0, - "max": 8.0, - "step": 0.5, - "value": 6.0 - }, - { - "name": "b", - "label": "side b", - "min": 1.0, - "max": 6.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "C", - "label": "angle C (deg)", - "min": 10.0, - "max": 170.0, - "step": 1.0, - "value": 70.0 - } - ], - "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(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.4, -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 });\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.0, - "max": 1.0, - "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.0, - "max": 2.0, - "step": 0.05, - "value": 1.0 - } - ], - "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.0, - "max": 360.0, - "step": 2.0, - "value": 200.0 - } - ], - "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 co = Math.cos(ang), si = Math.sin(ang), ta = Math.tan(ang);\nconst q = deg < 90 ? \"I\" : deg < 180 ? \"II\" : deg < 270 ? \"III\" : \"IV\";\nconst sg = (x) => x >= 0 ? \"+\" : \"−\";\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 \" + sg(ta), 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.0, - "max": 360.0, - "step": 1.0, - "value": 60.0 - } - ], - "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.0, - "max": 9.0, - "step": 0.5, - "value": 6.0 - }, - { - "name": "angle", - "label": "angle theta (deg)", - "min": 0.0, - "max": 360.0, - "step": 1.0, - "value": 35.0 - } - ], - "code": "H.background();\nconst v = H.plot2d({ xMin: -10, xMax: 10, yMin: -7, yMax: 7 });\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.6, { 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.6, { 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.0, - "max": 9.0, - "step": 0.5, - "value": 6.0 - }, - { - "name": "vy", - "label": "y-component vy", - "min": -6.0, - "max": 6.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "ay", - "label": "a: y-component", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "bx", - "label": "b: x / scalar s", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "op", - "label": "0=add 1=sub 2=scale", - "min": 0.0, - "max": 2.0, - "step": 1.0, - "value": 0.0 - } - ], - "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, 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\"; }\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 + 0.5, { color: H.colors.good, size: 13 });\nview.text(label, rx / 2 + 0.3, ry / 2 - 0.4, { 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);" - } + { + "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/physics_library_generated.json b/physics_library_generated.json index a25a906..1db36db 100644 --- a/physics_library_generated.json +++ b/physics_library_generated.json @@ -1,6006 +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.0, - "max": 0.99, - "step": 0.01, - "value": 0.8 - }, - { - "name": "dtau", - "label": "proper tick Delta tau (s)", - "min": 0.1, - "max": 2.0, - "step": 0.1, - "value": 1.0 - } - ], - "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.0, - "max": 0.99, - "step": 0.01, - "value": 0.8 - }, - { - "name": "L0", - "label": "proper length L0 (m)", - "min": 1.0, - "max": 8.0, - "step": 0.5, - "value": 6.0 - } - ], - "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.0, - "step": 0.001, - "value": 1.0 - }, - { - "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.0, - "max": 1000.0, - "step": 10.0, - "value": 800.0 - }, - { - "name": "thalf", - "label": "half-life t_half (s)", - "min": 0.5, - "max": 5.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 238.0, - "step": 1.0, - "value": 56.0 - } - ], - "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.0, - "max": 9.0, - "step": 0.5, - "value": 6.0 - }, - { - "name": "Vy", - "label": "y-component Vy (m)", - "min": 0.0, - "max": 7.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 5.0 - } - ], - "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.0, - "max": 12.0, - "step": 0.5, - "value": 6.0 - }, - { - "name": "R", - "label": "track radius R (m)", - "min": 3.0, - "max": 15.0, - "step": 0.5, - "value": 10.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "a", - "label": "acceleration a (m/s^2)", - "min": -3.0, - "max": 4.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "max": 4.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "a", - "label": "acceleration a (m/s^2)", - "min": -2.0, - "max": 3.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0 - }, - { - "name": "Q", - "label": "quality factor Q", - "min": 1.0, - "max": 12.0, - "step": 0.5, - "value": 6.0 - }, - { - "name": "fd", - "label": "driving freq f (Hz, 0 = sweep)", - "min": 0.0, - "max": 4.0, - "step": 0.1, - "value": 0.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.0 - }, - { - "name": "lambda", - "label": "wavelength lambda (m)", - "min": 0.5, - "max": 4.0, - "step": 0.1, - "value": 2.0 - }, - { - "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.0, - "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.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "max": 4.0, - "step": 0.5, - "value": 2.5 - }, - { - "name": "A", - "label": "amplitude A (m)", - "min": 0.5, - "max": 2.0, - "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.0, - "max": 2.5, - "step": 0.1, - "value": 1.5 - }, - { - "name": "A2", - "label": "amplitude 2 A2", - "min": 0.0, - "max": 2.5, - "step": 0.1, - "value": 1.5 - }, - { - "name": "phase", - "label": "phase difference (deg)", - "min": 0.0, - "max": 360.0, - "step": 5.0, - "value": 60.0 - } - ], - "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.0 - }, - { - "name": "f", - "label": "frequency f (Hz)", - "min": 1.0, - "max": 8.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "v", - "label": "wave speed v (m/s)", - "min": 1.0, - "max": 6.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "max": 400.0, - "step": 5.0, - "value": 293.0 - } - ], - "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.0, - "max": 800.0, - "step": 10.0, - "value": 500.0 - }, - { - "name": "vs", - "label": "source speed v_src (m/s)", - "min": 0.0, - "max": 250.0, - "step": 10.0, - "value": 100.0 - } - ], - "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.0, - "max": 6.0, - "step": 1.0, - "value": 3.0 - }, - { - "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.0, - "step": 0.1, - "value": 1.0 - } - ], - "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.0, - "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.0, - "max": 12.0, - "step": 0.1, - "value": 7.0 - }, - { - "name": "work", - "label": "work function W (eV)", - "min": 1.0, - "max": 5.0, - "step": 0.1, - "value": 2.3 - }, - { - "name": "intensity", - "label": "intensity (photons/s, x10^15)", - "min": 1.0, - "max": 10.0, - "step": 1.0, - "value": 4.0 - } - ], - "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.0, - "max": 12.0, - "step": 0.1, - "value": 5.0 - } - ], - "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.0, - "max": 50.0, - "step": 0.5, - "value": 9.11 - }, - { - "name": "speed", - "label": "speed v (x10^6 m/s)", - "min": 0.5, - "max": 10.0, - "step": 0.1, - "value": 2.0 - } - ], - "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.0, - "max": 6.0, - "step": 1.0, - "value": 2.0 - } - ], - "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.0, - "max": 4.0, - "step": 1.0, - "value": 2.0 - }, - { - "name": "nhigh", - "label": "upper level n_hi", - "min": 2.0, - "max": 7.0, - "step": 1.0, - "value": 3.0 - } - ], - "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 });\nfor (let k = 1; k <= 6; 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.0, - "step": 0.1, - "value": 2.0 - }, - { - "name": "area", - "label": "cross-section A (mm^2)", - "min": 0.5, - "max": 4.0, - "step": 0.1, - "value": 1.5 - }, - { - "name": "dens", - "label": "carrier density (rel.)", - "min": 1.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - } - ], - "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 });\nH.rect(wireX0, wireY - 24, wireLen, 48, { fill: H.colors.panel, stroke: H.colors.grid, width: 2, radius: 8 });\nconst planeX = wireX0 + wireLen * 0.5;\nH.line(planeX, wireY - 34, planeX, wireY + 34, { color: H.colors.warn, width: 2, dash: [5, 5] });\nH.text(\"cross-section\", planeX - 38, wireY - 40, { color: H.colors.warn, size: 12 });\nconst q = 1.6e-19;\nconst speed = H.clamp(40 + I * 30, 20, 220);\nconst N = Math.round(H.clamp(6 + n * 4, 6, 26));\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 - 12 + (i % 3) * 12;\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 - 44, wireX0 + 130, wireY - 44, { color: H.colors.accent2, width: 3 });\nH.text(\"conventional current I\", wireX0 + 30, wireY - 50, { 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(\"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.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "len", - "label": "length L (m)", - "min": 0.5, - "max": 5.0, - "step": 0.1, - "value": 2.0 - }, - { - "name": "area", - "label": "area A (m^2)", - "min": 0.05, - "max": 1.0, - "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.0, - "max": 12.0, - "step": 0.5, - "value": 9.0 - }, - { - "name": "res", - "label": "resistance R (Ohm)", - "min": 1.0, - "max": 20.0, - "step": 0.5, - "value": 6.0 - } - ], - "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.0, - "max": 12.0, - "step": 0.5, - "value": 10.0 - }, - { - "name": "r1", - "label": "R1 (Ohm)", - "min": 1.0, - "max": 10.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "r2", - "label": "R2 (Ohm)", - "min": 1.0, - "max": 10.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "r3", - "label": "R3 (Ohm)", - "min": 1.0, - "max": 10.0, - "step": 0.5, - "value": 5.0 - } - ], - "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.0, - "max": 12.0, - "step": 0.5, - "value": 8.0 - }, - { - "name": "r1", - "label": "R1 (Ohm)", - "min": 1.0, - "max": 12.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "r2", - "label": "R2 (Ohm)", - "min": 1.0, - "max": 12.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "max": 12.0, - "step": 0.5, - "value": 12.0 - }, - { - "name": "R", - "label": "resistance R (ohms)", - "min": 1.0, - "max": 10.0, - "step": 0.5, - "value": 4.0 - } - ], - "code": "H.background();\nconst V = P.V, R = Math.max(0.1, P.R);\nconst I = V / R;\nconst Pw = V * I;\nconst v = H.plot2d({ xMin: 0, xMax: 10, yMin: 0, yMax: 12, 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] });\nconst gx = H.W - 150, gy = 90;\nconst glow = H.clamp(Pw / 120, 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.0, - "max": 9.0, - "step": 0.5, - "value": 5.0 - }, - { - "name": "R", - "label": "resistance R (ohms)", - "min": 0.5, - "max": 4.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "C", - "label": "capacitance C (farads)", - "min": 0.1, - "max": 1.0, - "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.0, - "max": 12.0, - "step": 0.5, - "value": 9.0 - }, - { - "name": "R1", - "label": "resistance R1 (ohms)", - "min": 1.0, - "max": 8.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "R2", - "label": "resistance R2 (ohms)", - "min": 1.0, - "max": 8.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "m2", - "label": "mass m2 (kg)", - "min": 0.5, - "max": 5.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "v1", - "label": "speed v1 (m/s)", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "v2", - "label": "speed v2 (m/s)", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": -1.0 - } - ], - "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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "m2", - "label": "mass m2 (kg)", - "min": 0.5, - "max": 5.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "v1", - "label": "speed v1 (m/s)", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "v2", - "label": "speed v2 (m/s)", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": -1.0 - } - ], - "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.0, - "max": 12.0, - "step": 0.5, - "value": 8.0 - }, - { - "name": "mu", - "label": "friction mu", - "min": 0.0, - "max": 0.5, - "step": 0.01, - "value": 0.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.0, - "max": 10.0, - "step": 0.5, - "value": 6.0 - }, - { - "name": "m", - "label": "mass m (kg)", - "min": 0.5, - "max": 8.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 8.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "mA", - "label": "mass A (kg)", - "min": 1.0, - "max": 8.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "mB", - "label": "mass B (kg)", - "min": 1.0, - "max": 8.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "g", - "label": "gravity g (m/s²)", - "min": 1.0, - "max": 25.0, - "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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "deg", - "label": "incline angle θ (°)", - "min": 0.0, - "max": 60.0, - "step": 1.0, - "value": 25.0 - } - ], - "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.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "ho", - "label": "object height ho (cm)", - "min": 0.5, - "max": 3.5, - "step": 0.5, - "value": 2.0 - } - ], - "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 v = H.plot2d({ xMin: -12, xMax: 8, yMin: -5, yMax: 5 });\nv.grid(); v.axes();\nconst f = (Math.abs(P.f) < 0.2 ? 0.2 : P.f); // focal length (cm)\nconst ho = P.ho; // object height (cm)\n// Object distance sweeps so the image moves through its whole range.\nconst do_ = 7 + 3.5 * Math.sin(t * 0.6); // always positive (in front)\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 x = -y^2 / (2R) so it curves toward the\n// front (concave bulges away from the object for f>0).\nconst arc = [];\nfor (let yy = -4; yy <= 4.001; yy += 0.2) arc.push([-(yy * yy) / (2 * 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 (front)\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// Since object tip, mirror point (0,ho), F=(-f,0) and the image tip are\n// colinear, 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.0, - "max": 15.0, - "step": 1.0, - "value": 8.0 - }, - { - "name": "dobj", - "label": "object distance do (cm)", - "min": 2.0, - "max": 20.0, - "step": 0.5, - "value": 14.0 - }, - { - "name": "hobj", - "label": "object height ho (cm)", - "min": 1.0, - "max": 6.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "max": 700.0, - "step": 10.0, - "value": 550.0 - }, - { - "name": "d", - "label": "slit separation d (um)", - "min": 20.0, - "max": 120.0, - "step": 5.0, - "value": 50.0 - }, - { - "name": "L", - "label": "screen distance L (m)", - "min": 1.0, - "max": 3.0, - "step": 0.1, - "value": 2.0 - } - ], - "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.0, - "max": 700.0, - "step": 10.0, - "value": 600.0 - }, - { - "name": "a", - "label": "slit width a (um)", - "min": 20.0, - "max": 100.0, - "step": 5.0, - "value": 40.0 - }, - { - "name": "L", - "label": "screen distance L (m)", - "min": 1.0, - "max": 3.0, - "step": 0.1, - "value": 2.0 - } - ], - "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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "phi", - "label": "analyzer axis angle (deg)", - "min": 0.0, - "max": 180.0, - "step": 5.0, - "value": 0.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.0, - "max": 89.0, - "step": 1.0, - "value": 35.0 - } - ], - "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.0, - "max": 89.0, - "step": 1.0, - "value": 50.0 - }, - { - "name": "n1", - "label": "dense index n1", - "min": 1.0, - "max": 2.5, - "step": 0.01, - "value": 1.5 - }, - { - "name": "n2", - "label": "rare index n2", - "min": 1.0, - "max": 2.0, - "step": 0.01, - "value": 1.0 - } - ], - "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.0, - "max": 20.0, - "step": 0.5, - "value": 10.0 - }, - { - "name": "dobj", - "label": "object distance do (cm)", - "min": 1.0, - "max": 40.0, - "step": 0.5, - "value": 15.0 - }, - { - "name": "hobj", - "label": "object height (cm)", - "min": 1.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "step": 0.25, - "value": 1.5 - }, - { - "name": "strength", - "label": "magnet strength (arb)", - "min": 0.5, - "max": 4.0, - "step": 0.25, - "value": 2.0 - } - ], - "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.0, - "max": 2.0, - "step": 0.25, - "value": 1.0 - }, - { - "name": "speed", - "label": "speed v (m/s)", - "min": 0.5, - "max": 5.0, - "step": 0.25, - "value": 3.0 - }, - { - "name": "B", - "label": "field B (T)", - "min": 0.2, - "max": 3.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "m", - "label": "mass m (kg)", - "min": 0.5, - "max": 4.0, - "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.0, - "max": 2.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "I", - "label": "current I (A)", - "min": 0.0, - "max": 8.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "L", - "label": "wire length L (m)", - "min": 1.0, - "max": 6.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "theta", - "label": "angle to B (deg)", - "min": 0.0, - "max": 180.0, - "step": 5.0, - "value": 90.0 - } - ], - "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.0, - "step": 0.5, - "value": 10.0 - }, - { - "name": "n", - "label": "solenoid turns n (1/m)", - "min": 100.0, - "max": 2000.0, - "step": 50.0, - "value": 1000.0 - } - ], - "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.0, - "max": 50.0, - "step": 1.0, - "value": 20.0 - }, - { - "name": "B", - "label": "field B (T)", - "min": 0.1, - "max": 2.0, - "step": 0.1, - "value": 0.5 - }, - { - "name": "A", - "label": "loop area A (m^2)", - "min": 0.05, - "max": 1.0, - "step": 0.05, - "value": 0.3 - }, - { - "name": "omega", - "label": "spin omega (rad/s)", - "min": 0.5, - "max": 6.0, - "step": 0.25, - "value": 2.0 - } - ], - "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.0, - "step": 0.1, - "value": 2.0 - }, - { - "name": "T", - "label": "period T (s)", - "min": 1.0, - "max": 8.0, - "step": 0.1, - "value": 4.0 - } - ], - "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.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "v", - "label": "speed v (m/s)", - "min": 1.0, - "max": 6.0, - "step": 0.1, - "value": 3.0 - }, - { - "name": "r", - "label": "radius r (m)", - "min": 0.5, - "max": 4.0, - "step": 0.1, - "value": 2.0 - } - ], - "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.0, - "max": 15.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "a", - "label": "acceleration a (m/s^2)", - "min": -3.0, - "max": 4.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "T", - "label": "window T (s)", - "min": 2.0, - "max": 10.0, - "step": 0.5, - "value": 8.0 - } - ], - "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.0, - "max": 45.0, - "step": 1.0, - "value": 20.0 - } - ], - "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.0, - "max": 25.0, - "step": 1.0, - "value": 15.0 - }, - { - "name": "h0", - "label": "launch height h0 (m)", - "min": 2.0, - "max": 30.0, - "step": 1.0, - "value": 20.0 - } - ], - "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.0, - "max": 25.0, - "step": 1.0, - "value": 20.0 - }, - { - "name": "deg", - "label": "launch angle theta (deg)", - "min": 10.0, - "max": 80.0, - "step": 1.0, - "value": 45.0 - } - ], - "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.0, - "max": 8.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "vr", - "label": "river current vr (m/s)", - "min": 0.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "W", - "label": "river width W (m)", - "min": 3.0, - "max": 8.0, - "step": 0.5, - "value": 7.0 - } - ], - "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.0, - "max": 20.0, - "step": 0.5, - "value": 12.0 - }, - { - "name": "d", - "label": "displacement d (m)", - "min": 0.0, - "max": 12.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "ang", - "label": "angle θ (degrees)", - "min": 0.0, - "max": 180.0, - "step": 1.0, - "value": 30.0 - } - ], - "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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "v", - "label": "top speed v (m/s)", - "min": 0.0, - "max": 10.0, - "step": 0.5, - "value": 5.0 - } - ], - "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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "h", - "label": "max height h (m)", - "min": 0.0, - "max": 10.0, - "step": 0.5, - "value": 5.0 - } - ], - "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.0, - "max": 50.0, - "step": 1.0, - "value": 20.0 - }, - { - "name": "A", - "label": "amplitude A (m)", - "min": 0.2, - "max": 3.0, - "step": 0.1, - "value": 2.0 - } - ], - "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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "H0", - "label": "release height H₀ (m)", - "min": 1.0, - "max": 8.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "vel", - "label": "speed v (m/s)", - "min": 0.0, - "max": 4.0, - "step": 0.2, - "value": 2.0 - } - ], - "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.0, - "max": 12.0, - "step": 1.0, - "value": 6.0 - }, - { - "name": "mass", - "label": "mass m (kg)", - "min": 0.5, - "max": 5.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "v0", - "label": "start speed v0 (m/s)", - "min": 0.0, - "max": 6.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "vel", - "label": "velocity v (m/s)", - "min": -4.0, - "max": 4.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 50.0, - "step": 5.0, - "value": 30.0 - }, - { - "name": "dt", - "label": "contact time delta-t (s)", - "min": 0.1, - "max": 1.5, - "step": 0.1, - "value": 1.0 - }, - { - "name": "mass", - "label": "mass m (kg)", - "min": 0.5, - "max": 5.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "v1", - "label": "cart 1 speed v1 (m/s)", - "min": 0.5, - "max": 4.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "m2", - "label": "cart 2 mass m2 (kg)", - "min": 0.5, - "max": 4.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "alpha", - "label": "angular accel alpha (rad/s²)", - "min": -2.0, - "max": 3.0, - "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.0, - "max": 20.0, - "step": 0.5, - "value": 10.0 - } - ], - "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.0, - "step": 0.1, - "value": 2.0 - }, - { - "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.0, - "step": 0.5, - "value": 4.0 - }, - { - "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.0, - "max": 12.0, - "step": 0.2, - "value": 6.0 - } - ], - "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.0, - "step": 0.1, - "value": 2.0 - }, - { - "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.0, - "step": 0.1, - "value": 3.0 - } - ], - "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.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "muS", - "label": "static mu_s", - "min": 0.0, - "max": 1.0, - "step": 0.05, - "value": 0.6 - }, - { - "name": "muK", - "label": "kinetic mu_k", - "min": 0.0, - "max": 1.0, - "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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "amp", - "label": "bob amplitude (m)", - "min": 0.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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "F", - "label": "applied force F (N)", - "min": 0.0, - "max": 30.0, - "step": 1.0, - "value": 15.0 - }, - { - "name": "mu", - "label": "friction mu", - "min": 0.0, - "max": 1.0, - "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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "deg", - "label": "angle θ (degrees)", - "min": 5.0, - "max": 75.0, - "step": 1.0, - "value": 30.0 - }, - { - "name": "mu", - "label": "friction mu", - "min": 0.0, - "max": 1.0, - "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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "m2", - "label": "right mass m₂ (kg)", - "min": 0.5, - "max": 8.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "step": 0.1, - "value": 3.0 - }, - { - "name": "p1", - "label": "inlet pressure P1 (kPa)", - "min": 80.0, - "max": 200.0, - "step": 5.0, - "value": 120.0 - }, - { - "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.0, - "max": 16.0, - "step": 1.0, - "value": 10.0 - }, - { - "name": "f2", - "label": "tone 2 f2 (Hz)", - "min": 4.0, - "max": 16.0, - "step": 1.0, - "value": 12.0 - } - ], - "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.0, - "step": 0.1, - "value": 2.0 - }, - { - "name": "T", - "label": "period T (s)", - "min": 0.5, - "max": 4.0, - "step": 0.1, - "value": 2.0 - }, - { - "name": "phi", - "label": "phase phi (rad)", - "min": -3.14, - "max": 3.14, - "step": 0.1, - "value": 0.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.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "k", - "label": "spring constant k (N/m)", - "min": 1.0, - "max": 30.0, - "step": 0.5, - "value": 10.0 - }, - { - "name": "A", - "label": "amplitude A (m)", - "min": 0.3, - "max": 1.5, - "step": 0.1, - "value": 1.0 - } - ], - "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.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "theta0", - "label": "start angle theta0 (deg)", - "min": 5.0, - "max": 45.0, - "step": 1.0, - "value": 25.0 - } - ], - "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.0, - "max": 20.0, - "step": 0.5, - "value": 8.0 - }, - { - "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.0, - "max": 20.0, - "step": 0.5, - "value": 4.0 - }, - { - "name": "b", - "label": "damping b (kg/s)", - "min": 0.0, - "max": 4.0, - "step": 0.05, - "value": 0.5 - }, - { - "name": "A", - "label": "amplitude A (m)", - "min": 0.5, - "max": 2.5, - "step": 0.1, - "value": 2.0 - } - ], - "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.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "L", - "label": "angular momentum L (kg·m²/s)", - "min": 2.0, - "max": 16.0, - "step": 1.0, - "value": 8.0 - } - ], - "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.0, - "max": 8.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "m2", - "label": "mass m2 (kg)", - "min": 1.0, - "max": 8.0, - "step": 0.5, - "value": 5.0 - } - ], - "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.0, - "max": 10.0, - "step": 0.5, - "value": 6.0 - }, - { - "name": "m2", - "label": "mass m2 (×10²⁴ kg)", - "min": 1.0, - "max": 10.0, - "step": 0.5, - "value": 6.0 - } - ], - "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.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.0, - "max": 12.0, - "step": 0.5, - "value": 6.0 - } - ], - "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.0, - "max": 200.0, - "step": 1.0, - "value": 20.0 - }, - { - "name": "area", - "label": "contact area A (m^2)", - "min": 0.05, - "max": 2.0, - "step": 0.05, - "value": 0.25 - }, - { - "name": "rho", - "label": "density rho (kg/m^3)", - "min": 100.0, - "max": 11000.0, - "step": 100.0, - "value": 2700.0 - } - ], - "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.0, - "max": 13600.0, - "step": 100.0, - "value": 1000.0 - }, - { - "name": "depth", - "label": "max depth (m)", - "min": 1.0, - "max": 20.0, - "step": 0.5, - "value": 10.0 - }, - { - "name": "patm", - "label": "surface pressure Patm (Pa)", - "min": 0.0, - "max": 120000.0, - "step": 5000.0, - "value": 101325.0 - } - ], - "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.0, - "max": 500.0, - "step": 10.0, - "value": 50.0 - }, - { - "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.0, - "max": 2000.0, - "step": 50.0, - "value": 1000.0 - }, - { - "name": "rhoo", - "label": "object density rho_o (kg/m^3)", - "min": 100.0, - "max": 2000.0, - "step": 50.0, - "value": 600.0 - }, - { - "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.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "a2", - "label": "narrow area A2 (m^2)", - "min": 0.5, - "max": 4.0, - "step": 0.5, - "value": 1.0 - }, - { - "name": "v1", - "label": "inlet speed v1 (m/s)", - "min": 0.5, - "max": 4.0, - "step": 0.5, - "value": 1.0 - } - ], - "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.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "qn", - "label": "negative charge -q (nC)", - "min": 0.5, - "max": 5.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "d", - "label": "half-separation d (m)", - "min": 0.5, - "max": 3.0, - "step": 0.25, - "value": 2.0 - } - ], - "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.0, - "max": 300.0, - "step": 10.0, - "value": 100.0 - }, - { - "name": "d", - "label": "plate gap d (mm)", - "min": 0.25, - "max": 5.0, - "step": 0.25, - "value": 1.0 - } - ], - "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.0, - "max": 50.0, - "step": 1.0, - "value": 10.0 - }, - { - "name": "V0", - "label": "supply V0 (V)", - "min": 1.0, - "max": 12.0, - "step": 0.5, - "value": 9.0 - }, - { - "name": "R", - "label": "resistance R (kohm)", - "min": 10.0, - "max": 300.0, - "step": 10.0, - "value": 100.0 - } - ], - "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.0, - "max": 1000.0, - "step": 10.0, - "value": 300.0 - }, - { - "name": "m", - "label": "molecular mass m (u)", - "min": 2.0, - "max": 60.0, - "step": 1.0, - "value": 28.0 - }, - { - "name": "N", - "label": "molecules N", - "min": 5.0, - "max": 60.0, - "step": 1.0, - "value": 30.0 - } - ], - "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.0, - "max": 300.0, - "step": 10.0, - "value": 200.0 - }, - { - "name": "W", - "label": "work by gas W (J)", - "min": -200.0, - "max": 300.0, - "step": 10.0, - "value": 80.0 - } - ], - "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.0, - "max": 600.0, - "step": 10.0, - "value": 300.0 - }, - { - "name": "proc", - "label": "process: 0=isoT 1=isoP 2=isoV", - "min": 0.0, - "max": 2.0, - "step": 1.0, - "value": 0.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.0, - "max": 900.0, - "step": 10.0, - "value": 600.0 - }, - { - "name": "Tc", - "label": "cold reservoir Tc (K)", - "min": 200.0, - "max": 500.0, - "step": 10.0, - "value": 300.0 - }, - { - "name": "Qh", - "label": "heat input Qh (J)", - "min": 50.0, - "max": 500.0, - "step": 10.0, - "value": 300.0 - } - ], - "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.0, - "max": 80.0, - "step": 1.0, - "value": 40.0 - }, - { - "name": "ratio", - "label": "expansion Vf/Vi", - "min": 1.2, - "max": 4.0, - "step": 0.1, - "value": 2.0 - } - ], - "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.0, - "max": 3.0, - "step": 1.0, - "value": 1.0 - }, - { - "name": "q2", - "label": "charge q2 (units of e)", - "min": -3.0, - "max": 3.0, - "step": 1.0, - "value": -1.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "q2", - "label": "charge q2 (µC)", - "min": -5.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "r", - "label": "separation r (m)", - "min": 1.0, - "max": 9.0, - "step": 0.5, - "value": 6.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "d", - "label": "test distance d (m)", - "min": 1.0, - "max": 6.0, - "step": 0.5, - "value": 3.0 - } - ], - "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.0, - "max": 2.0, - "step": 1.0, - "value": 1.0 - }, - { - "name": "q2", - "label": "right charge q2", - "min": -2.0, - "max": 2.0, - "step": 1.0, - "value": -1.0 - }, - { - "name": "sep", - "label": "separation (units)", - "min": 2.0, - "max": 8.0, - "step": 1.0, - "value": 5.0 - } - ], - "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.0, - "max": 5.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "r", - "label": "test distance r (m)", - "min": 1.0, - "max": 10.0, - "step": 0.5, - "value": 4.0 - } - ], - "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.0, - "max": 30.0, - "step": 1.0, - "value": 23.0 - }, - { - "name": "L0", - "label": "original length L0 (m)", - "min": 0.5, - "max": 4.0, - "step": 0.5, - "value": 2.0 - }, - { - "name": "dT", - "label": "temp change ΔT (°C)", - "min": 10.0, - "max": 200.0, - "step": 10.0, - "value": 100.0 - } - ], - "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.0, - "step": 0.1, - "value": 0.5 - }, - { - "name": "c", - "label": "specific heat c (J/kg·°C)", - "min": 130.0, - "max": 4186.0, - "step": 1.0, - "value": 4186.0 - }, - { - "name": "power", - "label": "heater power P (W)", - "min": 100.0, - "max": 1500.0, - "step": 50.0, - "value": 500.0 - } - ], - "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.0, - "max": 500.0, - "step": 10.0, - "value": 334.0 - }, - { - "name": "Lv", - "label": "heat of vaporization Lv (kJ/kg)", - "min": 1000.0, - "max": 3000.0, - "step": 50.0, - "value": 2260.0 - } - ], - "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.0, - "step": 0.1, - "value": 0.8 - }, - { - "name": "dT", - "label": "temp difference ΔT (K)", - "min": 5.0, - "max": 100.0, - "step": 5.0, - "value": 50.0 - }, - { - "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.0, - "step": 0.1, - "value": 1.0 - }, - { - "name": "T", - "label": "temperature T (K)", - "min": 200.0, - "max": 700.0, - "step": 10.0, - "value": 300.0 - }, - { - "name": "V", - "label": "volume V (L)", - "min": 2.0, - "max": 40.0, - "step": 1.0, - "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.0, - "max": 500.0, - "step": 10.0, - "value": 200.0 - }, - { - "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.0, - "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.0, - "max": 1.5, - "step": 0.05, - "value": 0.5 - }, - { - "name": "L", - "label": "rod length L (m)", - "min": 0.1, - "max": 1.0, - "step": 0.05, - "value": 0.5 - }, - { - "name": "v", - "label": "peak speed v (m/s)", - "min": 0.5, - "max": 6.0, - "step": 0.5, - "value": 3.0 - }, - { - "name": "R", - "label": "resistance R (ohm)", - "min": 0.5, - "max": 10.0, - "step": 0.5, - "value": 2.0 - } - ], - "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.0, - "max": 240.0, - "step": 5.0, - "value": 120.0 - }, - { - "name": "Np", - "label": "primary turns Np", - "min": 10.0, - "max": 500.0, - "step": 10.0, - "value": 100.0 - }, - { - "name": "Ns", - "label": "secondary turns Ns", - "min": 10.0, - "max": 500.0, - "step": 10.0, - "value": 300.0 - } - ], - "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);" - } + { + "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/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 +] From 35830c9d9af798fb6797d6bc44f7d648249d74ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Thu, 25 Jun 2026 16:25:07 +0700 Subject: [PATCH 31/43] render.yaml: deploy the parameterized-demos branch (full feature set) Co-Authored-By: Claude Opus 4.8 (1M context) --- render.yaml | 3 +++ 1 file changed, 3 insertions(+) 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 From bcbad71e8ddc88e74f940538a3163579337fbd74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Thu, 25 Jun 2026 21:57:55 +0700 Subject: [PATCH 32/43] Add browser edition (static, client-side) + GitHub Pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second flavor of VisualLM under web/ that runs 100% in the browser with no server, no install, and no API key — meant for GitHub Pages so it's shareable by link. What works fully client-side (verified parity with the Python desktop app): - Chemistry: formula -> 3D ball-and-stick molecule (VSEPR + 52-molecule library), reaction -> exact integer-balanced equation with conservation tally. Ported from chemistry.py to web/js/chemistry.js (BigInt-exact balancing). - 333 interactive curriculum demos + 50 curated scenes, with the same keyword matcher (web/js/matching.js) and live sliders. - A window.fetch shim (web/js/browser-backend.js) answers /api/* locally so the desktop app.js/sandbox-worker.js/styles.css run essentially unchanged. Free-form AI generation and the AI tutor stay desktop-only (they need server-side model keys); the browser edition shows a friendly "use the desktop app" card. Data bundles (web/data/*.json) are exported from the Python libraries, scene code pre-sanitized with the real sanitizer so browser == desktop output. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/launch.json | 6 + README.md | 24 + web/app.js | 1488 +++++++++ web/data/demos.json | 1 + web/data/molecules.json | 6097 +++++++++++++++++++++++++++++++++++++ web/data/scenes.json | 1 + web/index.html | 246 ++ web/js/browser-backend.js | 95 + web/js/chemistry.js | 686 +++++ web/js/matching.js | 89 + web/js/planner.js | Bin 0 -> 7505 bytes web/sandbox-worker.js | 1112 +++++++ web/styles.css | 831 +++++ 13 files changed, 10676 insertions(+) create mode 100644 web/app.js create mode 100644 web/data/demos.json create mode 100644 web/data/molecules.json create mode 100644 web/data/scenes.json create mode 100644 web/index.html create mode 100644 web/js/browser-backend.js create mode 100644 web/js/chemistry.js create mode 100644 web/js/matching.js create mode 100644 web/js/planner.js create mode 100644 web/sandbox-worker.js create mode 100644 web/styles.css 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/README.md b/README.md index bdeea9d..9934a03 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,30 @@ 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 ▸ Ollama) | +| 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 ``` diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..e84bcad --- /dev/null +++ b/web/app.js @@ -0,0 +1,1488 @@ +(function () { + "use strict"; + + /* ============================================================== */ + /* Small utilities */ + /* ============================================================== */ + + const $ = (id) => document.getElementById(id); + + function escapeHtml(text) { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">"); + } + + /* ============================================================== */ + /* Tutor text rendering */ + /* ============================================================== */ + // + // The tutor (Ollama or Claude) 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. + // 2. Strip LaTeX delimiters and rewrite the common commands into plain + // text (e.g. \(F\) -> F, \frac{dF}{dt} -> dF/dt, \alpha -> α). + // 3. Render a tiny safe subset of Markdown (bold, italic, inline code, + // bullet/numbered lists, paragraph breaks). NO arbitrary HTML. + + const GREEK = { + alpha: "α", beta: "β", gamma: "γ", delta: "δ", epsilon: "ε", + zeta: "ζ", eta: "η", theta: "θ", iota: "ι", kappa: "κ", + lambda: "λ", mu: "μ", nu: "ν", xi: "ξ", pi: "π", rho: "ρ", + sigma: "σ", tau: "τ", upsilon: "υ", phi: "φ", chi: "χ", + psi: "ψ", omega: "ω", + Alpha: "Α", Beta: "Β", Gamma: "Γ", Delta: "Δ", Epsilon: "Ε", + Theta: "Θ", Lambda: "Λ", Mu: "Μ", Pi: "Π", Sigma: "Σ", + Phi: "Φ", Psi: "Ψ", Omega: "Ω", + infty: "∞", partial: "∂", nabla: "∇", sum: "Σ", prod: "Π", + int: "∫", cdot: "·", times: "×", div: "÷", pm: "±", mp: "∓", + leq: "≤", geq: "≥", neq: "≠", approx: "≈", equiv: "≡", + rightarrow: "→", leftarrow: "←", Rightarrow: "⇒", Leftarrow: "⇐", + to: "→", sin: "sin", cos: "cos", tan: "tan", log: "log", + ln: "ln", exp: "exp", + }; + + function stripLatex(s) { + // Block math: \[ ... \] -> on its own line, plain. + s = s.replace(/\\\[([\s\S]*?)\\\]/g, "\n$1\n"); + // Display $$ ... $$ -> on its own line. + s = s.replace(/\$\$([\s\S]*?)\$\$/g, "\n$1\n"); + // Inline math: \(...\) and $...$ (avoid currency by requiring no space). + s = s.replace(/\\\(([\s\S]*?)\\\)/g, "$1"); + s = s.replace(/(? ° (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)"); + // \text{a} -> a + s = s.replace(/\\text\s*\{([^{}]*)\}/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"); + + // 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"); + } + + function renderTutorMarkdown(raw) { + // 1. Escape first — everything we insert after this is either literal + // escaped text or our own whitelisted tags. + let s = escapeHtml(raw); + // 2. Strip LaTeX -> plain text. + s = stripLatex(s); + // 3. Markdown: code, bold, italics, headers. + s = s.replace(/`([^`\n]+?)`/g, "$1"); + s = s.replace(/\*\*([^*\n][^*]*?)\*\*/g, "$1"); + s = s.replace(/(?$1"); + s = s.replace(/^#{1,6}\s+(.+)$/gm, "$1"); + + // 4. Lists, line by line. Group consecutive same-type items, even when + // separated by blank lines (tutors love "1. ...\n\n2. ..."). + const lines = s.split("\n"); + const out = []; + let listType = null; + let buf = []; + const flush = () => { + if (!listType) return; + out.push( + `<${listType}>` + + buf.map((i) => `
  • ${i}
  • `).join("") + + `` + ); + listType = null; + buf = []; + }; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const ol = line.match(/^\s*\d+\.\s+(.+)$/); + const ul = line.match(/^\s*[-•]\s+(.+)$/); + if (ol) { + if (listType !== "ol") flush(); + listType = "ol"; + buf.push(ol[1]); + } else if (ul) { + if (listType !== "ul") flush(); + listType = "ul"; + buf.push(ul[1]); + } else if (line.trim() === "" && listType) { + // Blank line between items — look ahead. If the next non-blank line + // continues the same list type, swallow this blank. Otherwise close. + let j = i + 1; + while (j < lines.length && lines[j].trim() === "") j++; + const next = lines[j] || ""; + const continuesOl = /^\s*\d+\.\s+/.test(next); + const continuesUl = /^\s*[-•]\s+/.test(next); + if ((listType === "ol" && continuesOl) || (listType === "ul" && continuesUl)) { + continue; // stay inside the list + } + flush(); + out.push(line); + } else { + flush(); + out.push(line); + } + } + flush(); + s = out.join("\n"); + + // 5. Paragraph breaks: collapse runs of blank lines, then \n ->
    . + s = s.replace(/\n{3,}/g, "\n\n"); + s = s.replace(/\n/g, "
    "); + // Strip
    noise adjacent to list tags. + s = s.replace(/(
    )+(<\/?(?:ol|ul|li)>)/g, "$2"); + s = s.replace(/(<\/(?:ol|ul)>)(
    )+/g, "$1"); + return s; + } + + /* Optional shared access code for published deployments. The server + * answers 401 + code_required until the right X-Access-Code header is + * sent; we ask once and remember it in localStorage. */ + const ACCESS_CODE_KEY = "visuallm-access-code"; + function getAccessCode() { + try { + return localStorage.getItem(ACCESS_CODE_KEY) || ""; + } catch (e) { + return ""; + } + } + function apiHeaders() { + const headers = { "Content-Type": "application/json" }; + const code = getAccessCode(); + if (code) headers["X-Access-Code"] = code; + return headers; + } + function promptForAccessCode() { + const entered = window.prompt( + "This VisualLM server requires an access code to generate animations:" + ); + if (!entered || !entered.trim()) return false; + try { + localStorage.setItem(ACCESS_CODE_KEY, entered.trim()); + } catch (e) { + /* private mode — the code just won't persist */ + } + return true; + } + + async function postJSON(path, body, opts) { + // Fetch with one retry by default: localhost fetches occasionally fail + // with the browser-generic `TypeError: Failed to fetch` for transient + // reasons (server thread mid-restart, socket race, etc.). Re-trying once + // turns most of those into a single visible blip instead of a hard fail. + // + // Callers MUST opt out (`{retry: false}`) for non-idempotent endpoints: + // if a request gets through to the server and only the *response* drops, + // the retry would re-process the side effect (e.g. duplicate uploads). + const maxAttempts = opts && opts.retry === false ? 1 : 2; + let res; + let lastFetchError = null; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + res = await fetch(path, { + method: "POST", + headers: apiHeaders(), + body: JSON.stringify(body), + }); + lastFetchError = null; + break; + } catch (err) { + lastFetchError = err; + if (attempt < maxAttempts - 1) { + await new Promise((r) => setTimeout(r, 250)); + } + } + } + if (lastFetchError) { + throw new Error( + "Couldn't reach the server. Is the Python server (`python3 main.py`) still running?" + ); + } + const data = await res.json().catch(() => ({})); + if (!res.ok) { + // Locked deployment: ask for the access code once and retry the same + // call. A wrong code lands back here and asks again. + if (res.status === 401 && data && data.code_required) { + if (promptForAccessCode()) return postJSON(path, body, opts); + } + const message = data && data.error ? data.error : `Request failed (${res.status}).`; + throw new Error(message); + } + return data; + } + + /* ============================================================== */ + /* Sandbox runner: a Web Worker + OffscreenCanvas that runs the */ + /* AI-generated animation code in isolation. */ + /* ============================================================== */ + + class SandboxRunner { + constructor(frame) { + this.frame = frame; + this.worker = null; + this.ready = false; + this.readyWaiters = []; + this.pending = null; // { resolve, reject, settled, watchdog } + this.speed = 1; + this.paused = false; + this.onCrash = null; // called for runtime errors after a scene is live + this._resizeRaf = 0; + this._spawn(); + // Debounce via rAF: a window-drag fires resize ~60×/s, and each + // _resize() sets canvas.width which CLEARS the bitmap. Without coalescing, + // the canvas flickers black for the entire drag. One resize message per + // animation frame is enough for any visible UI change. + window.addEventListener("resize", () => { + if (this._resizeRaf) return; + this._resizeRaf = requestAnimationFrame(() => { + this._resizeRaf = 0; + this._resize(); + }); + }); + 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. + * Listeners live on the persistent frame (the canvas inside is recreated + * on every worker respawn); the worker applies the deltas to every cam3d. */ + _wireOrbit() { + const frame = this.frame; + let dragging = false; + let lastX = 0; + let lastY = 0; + frame.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; + dragging = true; + lastX = e.clientX; + lastY = e.clientY; + if (frame.setPointerCapture) { + try { + frame.setPointerCapture(e.pointerId); + } catch (err) { + /* capture is best-effort */ + } + } + }); + frame.addEventListener("pointermove", (e) => { + if (!dragging || !this.worker) return; + const dx = e.clientX - lastX; + const dy = e.clientY - lastY; + lastX = e.clientX; + lastY = e.clientY; + this.worker.postMessage({ + type: "orbit", + dyaw: dx * 0.008, + dpitch: dy * 0.008, + }); + }); + const endDrag = () => { + dragging = false; + }; + frame.addEventListener("pointerup", endDrag); + frame.addEventListener("pointercancel", endDrag); + frame.addEventListener("pointerleave", endDrag); + frame.addEventListener( + "wheel", + (e) => { + if (!this.worker) return; + e.preventDefault(); + this.worker.postMessage({ + type: "orbit", + dzoom: Math.exp(-e.deltaY * 0.0012), + }); + }, + { passive: false } + ); + frame.addEventListener("dblclick", () => { + if (this.worker) this.worker.postMessage({ type: "orbit-reset" }); + }); + } + + _newCanvas() { + const old = this.frame.querySelector("canvas"); + if (old) old.remove(); + const canvas = document.createElement("canvas"); + canvas.id = "visualizerCanvas"; + canvas.setAttribute("aria-label", "STEM visualization canvas"); + this.frame.insertBefore(canvas, this.frame.firstChild); + return canvas; + } + + _dims() { + const rect = this.frame.getBoundingClientRect(); + return { + width: Math.max(320, Math.round(rect.width)), + height: Math.max(240, Math.round(rect.height)), + // 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), + }; + } + + _spawn() { + this.ready = false; + if (this.worker) { + try { + this.worker.terminate(); + } catch (e) { + /* ignore */ + } + } + const canvas = this._newCanvas(); + const offscreen = canvas.transferControlToOffscreen(); + const worker = new Worker("./sandbox-worker.js?v=23"); + 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 }, + [offscreen] + ); + } + + _whenReady() { + if (this.ready) return Promise.resolve(); + return new Promise((resolve) => this.readyWaiters.push(resolve)); + } + + _onMessage(m) { + switch (m.type) { + case "ready": + this.ready = true; + this.readyWaiters.splice(0).forEach((fn) => fn()); + break; + case "heartbeat": + if (this.pending && !this.pending.settled) this._settle(true); + break; + case "compile-error": + case "runtime-error": { + // Carry the sandbox-extracted offending line (m.where) on the Error so + // the repair request can forward it to the model. + if (this.pending && !this.pending.settled) { + const e = new Error(m.message || "Animation failed."); + e.where = m.where || ""; + this._settle(false, e); + } else if (this.onCrash) { + const e = new Error(m.message || "Animation crashed."); + e.where = m.where || ""; + this.onCrash(e); + } + break; + } + default: + break; + } + } + + _settle(ok, err) { + const p = this.pending; + if (!p || p.settled) return; + p.settled = true; + clearTimeout(p.watchdog); + this.pending = null; + if (ok) p.resolve(); + else p.reject(err); + } + + /* 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")); + + return new Promise((resolve, reject) => { + const pending = { resolve, reject, settled: false, watchdog: null }; + this.pending = pending; + pending.watchdog = setTimeout(() => { + // No heartbeat and no error -> the scene is likely stuck in a loop. + 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, params: params || {}, resetTime: true }); + if (this.paused) this.worker.postMessage({ type: "pause" }); + this.worker.postMessage({ type: "speed", value: this.speed }); + }); + } + + pause() { + this.paused = true; + if (this.worker) this.worker.postMessage({ type: "pause" }); + } + resume() { + this.paused = false; + if (this.worker) this.worker.postMessage({ type: "resume" }); + } + setSpeed(value) { + 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 || {} }); + } + _resize() { + if (!this.worker || !this.ready) return; + const d = this._dims(); + this.worker.postMessage({ + type: "resize", + width: d.width, + height: d.height, + dpr: d.dpr, + }); + } + } + + /* ============================================================== */ + /* App state + DOM references */ + /* ============================================================== */ + + const state = { + mode: "auto", + scene: null, + busy: false, + chat: [], + chatBusy: false, + health: null, + }; + + const el = { + prompt: $("promptInput"), + visualize: $("visualizeButton"), + random: $("randomButton"), + playPause: $("playPauseButton"), + speed: $("speedSlider"), + speedValue: $("speedValue"), + topic: $("detectedTopic"), + dimension: $("detectedDimension"), + equation: $("detectedEquation"), + summary: $("sceneSummary"), + tag: $("sceneTag"), + confidence: $("sceneConfidence"), + explanation: $("explanationList"), + frame: document.querySelector(".canvas-frame"), + chatMessages: $("chatMessages"), + chatForm: $("chatForm"), + chatInput: $("chatInput"), + sendChat: $("sendChatButton"), + chatSuggestions: $("chatSuggestions"), + statusBadge: $("ollamaStatusBadge"), + modelLabel: $("ollamaModelLabel"), + orbitHint: $("orbitHint"), + }; + + const runner = new SandboxRunner(el.frame); + // Crashes AFTER a scene has started (post-heartbeat runtime errors) used + // to just print the error and leave a frozen canvas. Now we auto-trigger + // the repair flow with the crashed scene's code + the error message, the + // same way runSceneWithRepair handles errors during initial load. + runner.onCrash = async (err) => { + if (!state.scene || state.busy) { + setConfidence("Animation error: " + err.message, "warn"); + return; + } + setBusyVisual(true, "Repairing…"); + setConfidence( + "Animation crashed: " + err.message + ". Asking the model to fix it…", + "pending", + ); + try { + const repaired = await postJSON("/api/repair", { + prompt: state.scene.prompt, + code: state.scene.code, + error: err.message, + where: err.where || "", + }); + await runSceneWithRepair(repaired, state.scene.prompt); + } catch (repairErr) { + setConfidence("Could not auto-repair: " + repairErr.message, "warn"); + } finally { + setBusyVisual(false); + } + }; + + /* ============================================================== */ + /* Panel rendering */ + /* ============================================================== */ + + function setConfidence(text, tone) { + el.confidence.textContent = text; + el.confidence.dataset.tone = tone || ""; + } + + function updatePanels(scene) { + el.topic.textContent = scene.title; + el.dimension.textContent = scene.dimension; + el.equation.textContent = scene.equation || "No single equation — concept scene"; + el.summary.textContent = scene.summary; + el.tag.textContent = scene.tag; + // 3D scenes are orbitable — surface that, since nothing else hints at it. + if (el.orbitHint) el.orbitHint.hidden = scene.dimension !== "3D"; + + el.explanation.innerHTML = ""; + (scene.bullets || []).forEach((b) => { + const li = document.createElement("li"); + li.textContent = b; + el.explanation.appendChild(li); + }); + renderSuggestions(scene); + } + + function setBusyVisual(isBusy, label) { + // Track focus so keyboard users aren't stranded when the button they + // pressed gets disabled (focus jumps to ). We restore after re-enable. + const focusedWasControl = + isBusy && (document.activeElement === el.visualize || + document.activeElement === el.random); + if (focusedWasControl) setBusyVisual._restoreFocus = document.activeElement; + state.busy = isBusy; + el.visualize.disabled = isBusy; + el.random.disabled = isBusy; + // Chips trigger visualize() too, but visualize() early-returns when busy. + // Without disabling, clicking a chip mid-generation silently overwrites + // the prompt textarea while the in-flight scene is still loading. + document.querySelectorAll(".chip").forEach((c) => { + c.disabled = isBusy; + }); + el.visualize.textContent = isBusy ? label || "Generating…" : "Visualize"; + // Tell screen readers the visualization region is loading / settled. + if (el.frame) el.frame.setAttribute("aria-busy", isBusy ? "true" : "false"); + if (!isBusy && setBusyVisual._restoreFocus) { + // Only restore focus if it's still on body (user hasn't moved on). + if (document.activeElement === document.body) { + setBusyVisual._restoreFocus.focus(); + } + setBusyVisual._restoreFocus = null; + } + } + + /* ============================================================== */ + /* Visualization flow: generate -> run -> repair loop */ + /* ============================================================== */ + + // Each repair is a full, slow LLM round-trip. With the sandbox now tolerant of + // invented helpers and transient bad frames (see sandbox-worker.js), genuinely + // unfixable scenes are rarer — so cap repairs at 2 and show the fallback + // sooner instead of burning a third slow call that usually fails the same way. + const MAX_REPAIRS = 2; + + // Guaranteed-renderable placeholder for when generation + all repairs fail. + // Without it the canvas sits dead-black under the error message. + const CLIENT_FALLBACK_CODE = [ + 'H.background();', + 'H.text("Couldn\'t render this prompt", 28, 40, { color: H.colors.ink, size: 20, weight: 700 });', + 'H.text("The model\'s code kept failing. Rephrase the prompt or try again.", 28, 64, { color: H.colors.sub, size: 13 });', + 'const cx = H.W / 2, cy = H.H / 2 + 20;', + 'const r = 60 + 16 * Math.sin(t * 1.5);', + 'H.circle(cx, cy, r, { stroke: H.colors.accent, width: 3 });', + 'H.circle(cx, cy, r * 0.6, { stroke: H.colors.accent2, width: 2 });', + 'H.circle(cx, cy, 6, { fill: H.colors.ink });', + ].join("\n"); + + const ENGINE_NAMES = { + claude: "Claude", + openai: "ChatGPT", + gemini: "Gemini", + ollama: "local model", + library: "curated library", + chemistry: "chemistry engine", + browser: "browser engine", + fallback: "fallback", + }; + function engineName(engine) { + return ENGINE_NAMES[engine] || "the model"; + } + + // A live elapsed-seconds ticker so a slow local-model generation reads as + // "working" rather than "frozen". Updates the status line in place. + let _elapsedTimer = null; + function startElapsed(baseLabel) { + stopElapsed(); + const t0 = Date.now(); + const tick = () => { + const s = Math.round((Date.now() - t0) / 1000); + setConfidence(`${baseLabel} (${s}s)`, "pending"); + }; + tick(); + _elapsedTimer = setInterval(tick, 1000); + } + function stopElapsed() { + if (_elapsedTimer) { + clearInterval(_elapsedTimer); + _elapsedTimer = null; + } + } + + async function visualize(prompt) { + if (state.busy) return; + const clean = (prompt || "").trim(); + if (!clean) return; + + setBusyVisual(true, "Thinking…"); + el.topic.textContent = "Generating…"; + // Elapsed ticker — local generation can take 10-60s; a live counter makes + // the wait legible instead of looking hung. (Curated/cached hits return so + // fast the ticker never visibly advances.) + startElapsed("The AI is writing a custom animation for your prompt…"); + + // A new scene always starts playing. Without this, pausing one scene + // left every FUTURE scene frozen on its first frame — which reads as + // "the animation doesn't move" rather than "I paused it earlier". + if (runner.paused) { + runner.resume(); + el.playPause.textContent = "Pause"; + } + + try { + const scene = await postJSON("/api/visualize", { + prompt: clean, + preferred_mode: state.mode, + }); + stopElapsed(); + await runSceneWithRepair(scene, clean); + } catch (err) { + stopElapsed(); + setConfidence("Could not generate: " + err.message, "warn"); + 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.). + refreshStatus(); + } finally { + stopElapsed(); + setBusyVisual(false); + } + } + + // Show the guaranteed-renderable placeholder under a warning. Used whenever + // the repair loop gives up — budget exhausted, or non-convergence detected + // (repeated identical error, or the model returned the code unchanged). + async function showClientFallback(scene, message) { + if (scene) { + state.scene = scene; + updatePanels(scene); + } + setConfidence(message, "warn"); + renderDemoControls(null); + try { + await runner.run(CLIENT_FALLBACK_CODE); + } catch (fallbackErr) { + /* placeholder is hand-written and can't realistically fail */ + } + } + + /* ============================================================== */ + /* 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); + } + + function injectDemoStyles() { + if (document.getElementById("demoControlsStyle")) return; + const st = document.createElement("style"); + st.id = "demoControlsStyle"; + st.textContent = + ".demo-controls{margin:10px auto 0;width:92%;max-width:1100px;background:#16203a;border:1px solid #26314f;border-radius:12px;padding:12px 16px;box-sizing:border-box;}" + + ".demo-controls-head{font-size:12px;color:#9fb0d4;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;}" + + ".demo-params{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px 18px;}" + + ".demo-param{display:grid;grid-template-columns:1fr auto;align-items:center;gap:2px 10px;}" + + ".demo-param-name{font-size:13px;color:#cfe0ff;}" + + ".demo-param-val{font:600 13px 'JetBrains Mono',ui-monospace,monospace;color:#7cc4ff;min-width:3ch;text-align:right;}" + + ".demo-param input[type=range]{grid-column:1 / -1;width:100%;accent-color:#7cc4ff;}"; + document.head.appendChild(st); + } + + // 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) { + injectDemoStyles(); + 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 graph 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; + for (let attempt = 0; attempt <= MAX_REPAIRS; attempt++) { + const engineLabel = engineName(current.engine); + try { + if (!current.code || !current.code.trim()) { + throw new Error("No animation code was produced."); + } + setConfidence( + attempt === 0 + ? `Running ${current.dimension} scene from ${engineLabel}…` + : `Repair ${attempt}/${MAX_REPAIRS}: trying the fixed code…`, + "pending" + ); + 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 — + // otherwise the user sees the breathing-circle placeholder and + // a confidence message that claims it was "generated by local model". + setConfidence( + "Fallback scene — the model didn't draw anything. " + + (current.summary || "Try rephrasing or enable Claude."), + "warn", + ); + } else if (current.quality_warnings && current.quality_warnings.length) { + const issues = current.quality_warnings + .map((w) => (w === "static" ? "may not animate" : "may lack labels")) + .join(", "); + setConfidence( + `${current.dimension} • generated by ${engineLabel} — heads-up: ${issues}. ` + + "Regenerate or rephrase for a better scene.", + "warn", + ); + } else if (current.browser_help) { + // BROWSER EDITION: no AI generator here — a friendly help card. + setConfidence( + "Browser edition • free-form AI generation runs in the desktop app — " + + "try a formula, a reaction, or a curriculum topic", + "pending", + ); + } 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( + `${current.dimension} • curated STEM scene (instant, verified)` + + (current.fallback_reason ? " — " + current.fallback_reason : ""), + "ok", + ); + } else if (current.recovered_after_retry) { + const n = current.recovered_after_retry; + setConfidence( + `${current.dimension} • generated by ${engineLabel}` + + (current.model ? " (" + current.model + ")" : "") + + ` — recovered after ${n} retr${n === 1 ? "y" : "ies"}`, + "ok", + ); + } else { + setConfidence( + `${current.dimension} • generated by ${engineLabel}` + + (current.model ? " (" + current.model + ")" : "") + + (current.cached ? " — instant (cached)" : ""), + "ok" + ); + } + seedTutorForScene(current); + return; + } catch (err) { + // Non-convergence guard: if this error follows a repair and matches the + // PREVIOUS attempt's error, the repairs aren't making progress — bail + // now instead of grinding through the rest of the (slow) repair budget. + const stuck = attempt > 0 && err.message === prevError; + prevError = err.message; + if (attempt >= MAX_REPAIRS || stuck) { + await showClientFallback( + current, + (stuck + ? `The fix kept hitting the same error ("${err.message}") — stopping early. ` + : `Couldn't fix it after ${MAX_REPAIRS} tries. Last error: ${err.message}. `) + + "Try a different prompt, or set ANTHROPIC_API_KEY for the stronger Claude generator." + ); + return; + } + setConfidence( + `Animation error: "${err.message}". Asking ${engineLabel} for a fix…`, + "pending" + ); + const triedCode = current.code; + try { + current = await postJSON("/api/repair", { + prompt, + code: current.code, + error: err.message, + where: err.where || "", + }); + } catch (repairErr) { + setConfidence("Repair failed: " + repairErr.message, "warn"); + return; + } + // Unchanged-code guard: the model returned the same code it was given, + // so re-running it would fail identically — stop rather than spend + // another slow round on a guaranteed repeat. + if (current.code && triedCode && current.code.trim() === triedCode.trim()) { + await showClientFallback( + current, + "The model returned the same code unchanged — stopping early. " + + "Try rephrasing the prompt." + ); + return; + } + } + } + } + + /* ============================================================== */ + /* Tutor chat */ + /* ============================================================== */ + + function renderChat() { + // Differential render: append new messages and refresh changed ones in + // place. A full innerHTML rebuild looks like "removed all + added all" + // to the aria-live="polite" region, which makes screen readers + // re-announce the entire conversation on every send. + const list = el.chatMessages; + // Shrunk (e.g., seedTutorForScene replaced everything) — wipe and start over. + if (list.children.length > state.chat.length) { + list.replaceChildren(); + } + for (let i = 0; i < state.chat.length; i++) { + const msg = state.chat[i]; + const key = `${msg.role}|${msg.pending ? "1" : "0"}|${msg.content}`; + let div = list.children[i]; + if (!div) { + div = document.createElement("div"); + list.appendChild(div); + } else if (div.dataset.key === key) { + continue; // unchanged — leave the DOM (and the screen reader) alone + } + div.dataset.key = key; + div.className = "chat-message " + msg.role + (msg.pending ? " pending" : ""); + // Pending bubbles are visual placeholders; hide from the AT tree so + // screen readers don't announce "Thinking…" and then re-announce when + // the real answer replaces it. The container's aria-busy below carries + // the loading state. + if (msg.pending) div.setAttribute("aria-hidden", "true"); + else div.removeAttribute("aria-hidden"); + const who = document.createElement("span"); + who.className = "chat-role"; + who.textContent = msg.role === "user" ? "You" : "Tutor"; + const body = document.createElement("p"); + body.className = "chat-body"; + body.innerHTML = renderTutorMarkdown(msg.content); + div.replaceChildren(who, body); + } + // Tell SRs the chat region is awaiting a response when any bubble is pending. + list.setAttribute( + "aria-busy", + state.chat.some((m) => m.pending) ? "true" : "false", + ); + list.scrollTop = list.scrollHeight; + } + + // Bumped every time the chat is wiped (seedTutorForScene). sendChat captures + // this at start; if it changes before the response lands, an unrelated reset + // happened (e.g. a new scene loaded) and we must NOT pop/push into the new + // chat — that ate the seed and orphaned the user's question. + let chatSessionId = 0; + + function seedTutorForScene(scene) { + chatSessionId++; + state.chat = [ + { + role: "assistant", + content: + `This is "${scene.title}". ${scene.summary} ` + + "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", () => { + el.chatInput.value = (opts && opts.value) || text; + el.chatInput.focus(); + // 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 = ""; + // Always offer the step-by-step solver first. + addSuggestionChip("Solve it step by step", { solve: true, value: SOLVE_PROMPT }); + (scene.student_prompts || []).slice(0, 4).forEach((prompt) => { + addSuggestionChip(prompt); + }); + } + + async function sendChat(question) { + const clean = (question || "").trim(); + if (!clean || state.chatBusy) return; + state.chatBusy = true; + el.sendChat.disabled = true; + + state.chat.push({ role: "user", content: clean }); + state.chat.push({ role: "assistant", content: "Thinking…", pending: true }); + renderChat(); + + const history = state.chat + .filter((m) => !m.pending) + .map((m) => ({ role: m.role, content: m.content })) + .slice(-6); + const session = chatSessionId; + + try { + const data = await postJSON("/api/chat", { + question: clean, + visualization: state.scene || {}, + history: history.slice(0, -1), + }); + if (session !== chatSessionId) return; // chat was reset; discard response + state.chat.pop(); + state.chat.push({ role: "assistant", content: data.answer }); + } catch (err) { + if (session !== chatSessionId) return; + state.chat.pop(); + state.chat.push({ role: "assistant", content: "Tutor unavailable: " + err.message }); + // Same reasoning as visualize(): the failure usually means the badge + // is now stale. + refreshStatus(); + } finally { + state.chatBusy = false; + el.sendChat.disabled = false; + renderChat(); + } + } + + /* ============================================================== */ + /* Status */ + /* ============================================================== */ + + async function refreshStatus() { + try { + const health = await fetch("/api/health").then((r) => r.json()); + state.health = health; + const gen = health.generator; + if (gen === "claude") { + el.statusBadge.textContent = "Claude online"; + el.statusBadge.className = "status-pill ok"; + el.modelLabel.textContent = "Generator: " + (health.claude.model || "claude"); + } else if (gen === "openai") { + el.statusBadge.textContent = "ChatGPT online"; + el.statusBadge.className = "status-pill ok"; + el.modelLabel.textContent = "Generator: " + (health.openai.model || "openai"); + } else if (gen === "gemini") { + 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 if (gen === "browser") { + // BROWSER EDITION: everything runs client-side; no server generator. + el.statusBadge.textContent = "Browser edition"; + el.statusBadge.className = "status-pill ok"; + el.modelLabel.textContent = "Chemistry + 333 demos · runs in your browser"; + } else { + el.statusBadge.textContent = "No generator"; + el.statusBadge.className = "status-pill error"; + el.modelLabel.textContent = + "Set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY or start Ollama"; + } + } catch (err) { + el.statusBadge.textContent = "Server offline"; + el.statusBadge.className = "status-pill error"; + el.modelLabel.textContent = "Could not reach the VisualLM server."; + } + } + + /* ============================================================== */ + /* Tabs (left panel) */ + /* ============================================================== */ + + function activateTab(name, focusActive) { + const tabs = Array.from(document.querySelectorAll(".tab")); + let activated = null; + tabs.forEach((t) => { + const active = t.dataset.tab === name; + t.classList.toggle("active", active); + t.setAttribute("aria-selected", active ? "true" : "false"); + // ARIA tablist: only the active tab is in the Tab order. + t.tabIndex = active ? 0 : -1; + if (active) activated = t; + }); + document.querySelectorAll(".tab-panel").forEach((p) => { + const active = p.dataset.panel === name; + p.classList.toggle("active", active); + if (active) p.removeAttribute("hidden"); + else p.setAttribute("hidden", ""); + }); + if (focusActive && activated) activated.focus(); + } + + (function wireTabs() { + const tabs = Array.from(document.querySelectorAll(".tab")); + // Initialize tabindex so only the currently-active tab is tabbable. + tabs.forEach((t) => { + t.tabIndex = t.classList.contains("active") ? 0 : -1; + }); + tabs.forEach((tab, i) => { + tab.addEventListener("click", () => activateTab(tab.dataset.tab)); + tab.addEventListener("keydown", (e) => { + let nextIndex = null; + if (e.key === "ArrowLeft" || e.key === "ArrowUp") nextIndex = i - 1; + else if (e.key === "ArrowRight" || e.key === "ArrowDown") nextIndex = i + 1; + else if (e.key === "Home") nextIndex = 0; + else if (e.key === "End") nextIndex = tabs.length - 1; + if (nextIndex === null) return; + e.preventDefault(); + const target = tabs[((nextIndex % tabs.length) + tabs.length) % tabs.length]; + activateTab(target.dataset.tab, true); + }); + }); + })(); + + /* ============================================================== */ + /* Resources */ + /* ============================================================== */ + + const resourceEl = { + drop: $("dropZone"), + file: $("resourceFileInput"), + list: $("resourceList"), + empty: $("resourceEmpty"), + count: $("resourceCount"), + }; + + function formatSize(bytes) { + if (bytes < 1024) return bytes + " ch"; + return (bytes / 1024).toFixed(1) + " KB"; + } + + function renderResources(resources) { + const items = resources || []; + resourceEl.list.innerHTML = ""; + if (items.length === 0) { + resourceEl.list.hidden = true; + resourceEl.empty.hidden = false; + resourceEl.count.hidden = true; + resourceEl.count.textContent = "0"; + return; + } + resourceEl.list.hidden = false; + resourceEl.empty.hidden = true; + resourceEl.count.hidden = false; + resourceEl.count.textContent = String(items.length); + + items.forEach((r) => { + const row = document.createElement("div"); + row.className = "resource-item"; + const info = document.createElement("div"); + const name = document.createElement("strong"); + name.textContent = r.name; + const meta = document.createElement("span"); + meta.className = "resource-meta"; + meta.textContent = formatSize(r.size); + info.appendChild(name); + info.appendChild(meta); + const del = document.createElement("button"); + del.className = "resource-delete"; + del.type = "button"; + del.textContent = "Remove"; + del.addEventListener("click", () => deleteResource(r.id)); + row.appendChild(info); + row.appendChild(del); + resourceEl.list.appendChild(row); + }); + } + + async function fetchResources() { + try { + const data = await fetch("/api/resources").then((r) => r.json()); + renderResources(data.resources || []); + } catch (err) { + console.error("Failed to load resources:", err); + } + } + + async function uploadResource(file) { + if (!file) return; + if (file.size > 1_500_000) { + alert(`"${file.name}" is too large. Max ~1.5 MB.`); + return; + } + let text; + try { + text = await file.text(); + } catch (err) { + alert(`Could not read "${file.name}".`); + return; + } + try { + // No retry: a connection-reset mid-response from a successful upload + // would cause the retry to create a duplicate resource entry. + const data = await postJSON( + "/api/resources", + { name: file.name, content: text }, + { retry: false }, + ); + renderResources(data.resources || []); + // Server silently trims content > MAX_RESOURCE_CHARS (60k); tell the + // user when that happens so they understand the model only sees a slice. + if (data.resource && data.resource.truncated) { + alert( + `"${file.name}" was over the 60K-character limit and was truncated. ` + + "Only the first 60K characters will be visible to the AI.", + ); + } + } catch (err) { + alert("Upload failed: " + err.message); + } + } + + async function deleteResource(id) { + try { + const res = await fetch("/api/resources/" + encodeURIComponent(id), { + method: "DELETE", + headers: apiHeaders(), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Delete failed."); + renderResources(data.resources || []); + } catch (err) { + alert("Could not delete: " + err.message); + } + } + + if (resourceEl.drop) { + resourceEl.file.addEventListener("change", (e) => { + const files = Array.from(e.target.files || []); + files.forEach(uploadResource); + resourceEl.file.value = ""; + }); + + ["dragenter", "dragover"].forEach((evt) => { + resourceEl.drop.addEventListener(evt, (e) => { + e.preventDefault(); + e.stopPropagation(); + resourceEl.drop.classList.add("dragover"); + }); + }); + ["dragleave", "drop"].forEach((evt) => { + resourceEl.drop.addEventListener(evt, (e) => { + e.preventDefault(); + e.stopPropagation(); + resourceEl.drop.classList.remove("dragover"); + }); + }); + resourceEl.drop.addEventListener("drop", (e) => { + const files = Array.from((e.dataTransfer && e.dataTransfer.files) || []); + files.forEach(uploadResource); + }); + } + + /* ============================================================== */ + /* Wiring */ + /* ============================================================== */ + + el.visualize.addEventListener("click", () => visualize(el.prompt.value)); + + el.random.addEventListener("click", () => { + const ideas = [ + "Show how a Fourier series builds a square wave from sine waves.", + "Visualize Newton's method converging to a root.", + "Animate gradient descent rolling down a 2D loss surface.", + "Explain simple harmonic motion with a mass on a spring.", + "Show the unit circle generating sine and cosine.", + "Visualize an RC circuit charging a capacitor over time.", + "Animate bubble sort on a small array of bars.", + "Show a wave packet and how phase and group velocity differ.", + "Explain the dot product as a projection between two vectors.", + "Visualize a planet's elliptical orbit and Kepler's equal areas.", + ]; + const pick = ideas[Math.floor(Math.random() * ideas.length)]; + el.prompt.value = pick; + visualize(pick); + }); + + el.prompt.addEventListener("keydown", (e) => { + // Skip if an IME is mid-composition — see Bug #41 in chat handler. + if (e.isComposing || e.keyCode === 229) return; + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + visualize(el.prompt.value); + } + }); + + el.playPause.addEventListener("click", () => { + if (runner.paused) { + runner.resume(); + el.playPause.textContent = "Pause"; + } else { + runner.pause(); + el.playPause.textContent = "Play"; + } + }); + + function applySpeedFromSlider(v) { + const label = v.toFixed(1) + "×"; + el.speedValue.textContent = label; + // Screen readers read aria-valuetext instead of the raw number, so they + // announce "1.5× speed" instead of just "1.5". + el.speed.setAttribute("aria-valuetext", label + " speed"); + runner.setSpeed(v); + } + // Seed aria-valuetext for the initial slider value. + applySpeedFromSlider(parseFloat(el.speed.value)); + el.speed.addEventListener("input", (e) => { + applySpeedFromSlider(parseFloat(e.target.value)); + }); + + // Mode picker (radiogroup): click + arrow-key keyboard navigation. ARIA + // pattern is "only the checked radio is in the Tab order; arrows move + // between options and check as they move." + (function () { + const buttons = Array.from(document.querySelectorAll(".mode-button")); + function selectAt(index, focus) { + const target = buttons[((index % buttons.length) + buttons.length) % buttons.length]; + buttons.forEach((b) => { + b.classList.remove("active"); + b.setAttribute("aria-checked", "false"); + b.tabIndex = -1; + }); + target.classList.add("active"); + target.setAttribute("aria-checked", "true"); + target.tabIndex = 0; + state.mode = target.dataset.mode || "auto"; + if (focus) target.focus(); + } + // Initialize tabindex so only the currently-active button is tabbable. + buttons.forEach((b, i) => { + b.tabIndex = b.classList.contains("active") ? 0 : -1; + b.addEventListener("click", () => selectAt(i, false)); + b.addEventListener("keydown", (e) => { + if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + selectAt(i - 1, true); + } else if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + selectAt(i + 1, true); + } else if (e.key === "Home") { + e.preventDefault(); + selectAt(0, true); + } else if (e.key === "End") { + e.preventDefault(); + selectAt(buttons.length - 1, true); + } + }); + }); + })(); + + document.querySelectorAll(".chip").forEach((btn) => { + btn.addEventListener("click", () => { + const prompt = btn.dataset.prompt; + if (!prompt) return; + el.prompt.value = prompt; + visualize(prompt); + }); + }); + + el.chatForm.addEventListener("submit", (e) => { + e.preventDefault(); + const value = el.chatInput.value; + // Don't clear the textarea if sendChat would early-return — the user's + // typing would otherwise disappear silently (e.g. they pressed Enter + // while a prior chat was still in flight, or with no text at all). + if (!value.trim() || state.chatBusy) return; + sendChat(value); + el.chatInput.value = ""; + }); + + el.chatInput.addEventListener("keydown", (e) => { + // isComposing/keyCode===229 means an IME is mid-composition (CJK input + // methods use Enter to confirm a character). Submitting on that Enter + // would eat the in-progress composition. + if (e.key === "Enter" && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { + e.preventDefault(); + el.chatForm.requestSubmit(); + } + }); + + /* ============================================================== */ + /* Theme toggle */ + /* ============================================================== */ + // + // The initial theme is applied by an inline + + +
    +
    + + VisualLM +
    +
    + Checking… + Detecting… + +
    +
    + +
    + + + + +
    +
    +
    + Browser Edition + Idle — type a formula, a reaction, or a topic. +
    + +
    +
    + + + +
    + + +
    +
    +

    AI Tutor

    +

    The AI tutor runs in the desktop app. In the browser, ask away and it'll point you to what works offline.

    +
    + +
    +
    +
    +
    + + +
    +

    Browser edition: full AI tutoring is in the desktop app.

    + +
    +
    +
    + + +
    +
    + + + + + + + + + diff --git a/web/js/browser-backend.js b/web/js/browser-backend.js new file mode 100644 index 0000000..a40cc8c --- /dev/null +++ b/web/js/browser-backend.js @@ -0,0 +1,95 @@ +/* VisualLM — browser backend shim. + * + * Intercepts window.fetch for /api/* routes so the UNMODIFIED desktop frontend + * (app.js) runs with no Python server. Everything deterministic (chemistry, + * 333 demos, 50 curated scenes, balancing) is answered locally; free-form AI + * generation and the AI tutor return a friendly "use the desktop app" notice. + * + * Must load AFTER planner.js/chemistry.js/matching.js and BEFORE app.js. + */ +(function (global) { + "use strict"; + + const realFetch = global.fetch.bind(global); + + function json(obj, status) { + return new Response(JSON.stringify(obj), { + status: status || 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // Inert in-browser resource store (no AI consumes it; keeps the UI working). + const resources = []; + let nextId = 1; + + function browserHealth() { + return { + generator: "browser", + ok: true, + browser: { engine: "client-side", features: ["chemistry", "demos", "scenes", "balancing"] }, + claude: { available: false }, openai: { available: false }, + gemini: { available: false }, ollama: { available: false }, + }; + } + + const TUTOR_NOTICE = + "**The AI tutor runs in the desktop app.**\n\n" + + "This is the free browser edition — it works entirely in your browser with no server, " + + "so it can't run the AI tutor (that needs a model + API key on a server).\n\n" + + "What *does* work here, instantly:\n" + + "- **Molecules** — type a formula like `CH4`, `C6H6`, or `glucose` for a 3D structure.\n" + + "- **Balancing** — type a reaction like `C3H8 + O2 -> CO2 + H2O`.\n" + + "- **333 interactive demos** + **50 curated scenes** — name a topic (e.g. *quadratic vertex*, *projectile motion*).\n\n" + + "For full AI generation and tutoring, run the desktop app (see the project README)."; + + async function handleApi(path, init) { + const method = ((init && init.method) || "GET").toUpperCase(); + let body = {}; + if (init && init.body && typeof init.body === "string") { + try { body = JSON.parse(init.body); } catch (e) { body = {}; } + } + + if (path === "/api/health") return json(browserHealth()); + + if (path === "/api/visualize" && method === "POST") { + await global.VisualLMPlanner.ready; + const scene = global.VisualLMPlanner.plan(body.prompt || "", body.preferred_mode || "auto"); + return json(scene); + } + + // No AI in the browser, so "repair" can't fix anything — echo the code back + // unchanged, which trips app.js's unchanged-code guard -> graceful fallback. + if (path === "/api/repair" && method === "POST") { + return json({ code: body.code || "", engine: "browser", model: "browser" }); + } + + if (path === "/api/chat" && method === "POST") { + return json({ answer: TUTOR_NOTICE, model: "browser", engine: "browser" }); + } + + if (path === "/api/resources" && method === "GET") { + return json({ resources: resources.slice() }); + } + if (path === "/api/resources" && method === "POST") { + const r = { id: String(nextId++), name: body.name || "file", chars: (body.content || "").length, truncated: false }; + resources.push(r); + return json({ resources: resources.slice(), resource: r }); + } + if (path.indexOf("/api/resources/") === 0 && method === "DELETE") { + const id = decodeURIComponent(path.slice("/api/resources/".length)); + const i = resources.findIndex((r) => r.id === id); + if (i >= 0) resources.splice(i, 1); + return json({ resources: resources.slice() }); + } + + return json({ error: "Not available in the browser edition." }, 404); + } + + global.fetch = function (input, init) { + const url = typeof input === "string" ? input : (input && input.url) || ""; + const path = url.replace(/^https?:\/\/[^/]+/, ""); + if (path.indexOf("/api/") === 0) return handleApi(path, init); + return realFetch(input, init); + }; +})(typeof window !== "undefined" ? window : this); diff --git a/web/js/chemistry.js b/web/js/chemistry.js new file mode 100644 index 0000000..3b22d79 --- /dev/null +++ b/web/js/chemistry.js @@ -0,0 +1,686 @@ +/* VisualLM — Chemistry subsystem (browser port of chemistry.py). + * + * Pure client-side: a chemical formula -> 3D ball-and-stick structure; a + * reaction -> exact integer-balanced equation with a conservation tally. + * Faithful 1:1 port of the Python so the browser edition is identical to the + * desktop app. Exposes a single global: + * + * VisualLMChem.loadMolecules(array) // merge the workflow-verified library + * VisualLMChem.scene(prompt) // -> scene-content object | null + * + * The returned object matches the Python chemistry_scene() shape + * (title/tag/dimension/equation/summary/bullets/student_prompts/code/kind). + */ +(function (global) { + "use strict"; + + // --- Element data -------------------------------------------------------- + const PERIODIC = new Set(( + "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" + ).split(" ")); + + // sym -> [color, covalentRadiusÅ, valenceElectrons|null] + const 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, null], + Fe: ["#e06633", 1.32, null], Co: ["#f090a0", 1.26, null], Ni: ["#50d050", 1.24, null], + Cu: ["#c88033", 1.32, null], Zn: ["#7d80b0", 1.22, null], 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, null], Au: ["#ffd123", 1.36, null], + Pb: ["#575961", 1.46, 4], Sn: ["#9e9eae", 1.39, 4], Te: ["#d47a00", 1.38, 6], + Sb: ["#9e63b5", 1.39, 5], + }; + const DEFAULT_E = ["#ff80c0", 1.40, null]; + const elColor = (s) => (E[s] || DEFAULT_E)[0]; + const covRadius = (s) => (E[s] || DEFAULT_E)[1]; + const valence = (s) => (E[s] || DEFAULT_E)[2]; + + function luminance(hex) { + const h = hex.replace("#", ""); + if (h.length !== 6) return 0.5; + const r = parseInt(h.slice(0, 2), 16) / 255; + const g = parseInt(h.slice(2, 4), 16) / 255; + const b = parseInt(h.slice(4, 6), 16) / 255; + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + const labelColor = (hex) => (luminance(hex) > 0.55 ? "#0c1020" : "#f3f6ff"); + + // --- Formula parsing ----------------------------------------------------- + const TERMINALS = new Set(["H", "F", "Cl", "Br", "I"]); + const FORMULA_TOKEN_RE = /^[A-Za-z0-9()]+[+-]?$/; + + function parseFormula(formula) { + if (!formula) return null; + let s = String(formula).trim().replace(/\^/g, ""); + s = s.replace(/\d*[+-]$/, ""); + if (!s || !/^[A-Za-z0-9()]+$/.test(s)) return null; + const counts = {}; + const stack = [counts]; + let i = 0; + const n = s.length; + while (i < n) { + const ch = s[i]; + if (ch === "(") { + stack.push({}); + i++; + } else if (ch === ")") { + i++; + let j = i; + while (j < n && /\d/.test(s[j])) j++; + const mult = j > i ? parseInt(s.slice(i, j), 10) : 1; + i = j; + if (stack.length < 2) return null; + const group = stack.pop(); + const top = stack[stack.length - 1]; + for (const el in group) top[el] = (top[el] || 0) + group[el] * mult; + } else if (ch >= "A" && ch <= "Z") { + let j = i + 1; + if (j < n && s[j] >= "a" && s[j] <= "z") j++; + const sym = s.slice(i, j); + if (!PERIODIC.has(sym)) return null; + i = j; + j = i; + while (j < n && /\d/.test(s[j])) j++; + const cnt = j > i ? parseInt(s.slice(i, j), 10) : 1; + i = j; + const top = stack[stack.length - 1]; + top[sym] = (top[sym] || 0) + cnt; + } else { + return null; + } + } + if (stack.length !== 1) return null; + const out = {}; + for (const k in counts) if (counts[k] > 0) out[k] = counts[k]; + return Object.keys(out).length ? out : null; + } + + const SUBS = { "0": "₀", "1": "₁", "2": "₂", "3": "₃", "4": "₄", "5": "₅", "6": "₆", "7": "₇", "8": "₈", "9": "₉" }; + const prettyFormula = (f) => String(f).replace(/\d+/g, (m) => m.replace(/\d/g, (d) => SUBS[d])); + + const HILL = ["C", "H"]; + function canonicalFormula(counts) { + const parts = []; + for (const el of HILL) if (el in counts) parts.push(el + (counts[el] > 1 ? counts[el] : "")); + for (const el of Object.keys(counts).filter((k) => HILL.indexOf(k) < 0).sort()) + parts.push(el + (counts[el] > 1 ? counts[el] : "")); + return parts.join(""); + } + function canon(formula) { + const c = parseFormula(formula); + return c ? canonicalFormula(c) : formula; + } + const shapeName = (f, shape) => (shape ? prettyFormula(f) + " — " + shape : prettyFormula(f)); + + // --- Equation parsing + balancing --------------------------------------- + const ARROW_RE = /\s*(?:=+>|-+>|→|⟶|⇌|=)\s*/; + const NONSPECIES = new Set(["heat", "energy", "light", "δ", "delta"]); + + function parseEquation(text) { + if (!text) return null; + const m = text.trim().split(ARROW_RE); + if (m.length < 2) return null; + const left = m[0]; + const right = m[m.length - 1]; + const species = (side) => { + const out = []; + for (let chunk of side.split("+")) { + chunk = chunk.trim(); + if (!chunk) continue; + if (NONSPECIES.has(chunk.toLowerCase())) continue; + const cm = chunk.match(/^(\d+)\s*([A-Za-z(].*)$/); + const formula = cm ? cm[2].trim() : chunk; + if (parseFormula(formula) === null) return null; + out.push(formula); + } + return out.length ? out : null; + }; + const r = species(left); + const p = species(right); + if (!r || !p) return null; + return [r, p]; + } + + // exact rational arithmetic over BigInt + function gcdBig(a, b) { a = a < 0n ? -a : a; b = b < 0n ? -b : b; while (b) { const t = a % b; a = b; b = t; } return a; } + function F(n, d) { + n = BigInt(n); d = d === undefined ? 1n : BigInt(d); + if (d < 0n) { n = -n; d = -d; } + const g = gcdBig(n, d) || 1n; + return { n: n / g, d: d / g }; + } + const fAdd = (a, b) => F(a.n * b.d + b.n * a.d, a.d * b.d); + const fSub = (a, b) => F(a.n * b.d - b.n * a.d, a.d * b.d); + const fMul = (a, b) => F(a.n * b.n, a.d * b.d); + const fDiv = (a, b) => F(a.n * b.d, a.d * b.n); + + function rref(matrix) { + const M = matrix.map((row) => row.slice()); + const rows = M.length; + const cols = rows ? M[0].length : 0; + const pivots = []; + let r = 0; + for (let c = 0; c < cols; c++) { + let pivot = -1; + for (let i = r; i < rows; i++) if (M[i][c].n !== 0n) { pivot = i; break; } + if (pivot < 0) continue; + const tmp = M[r]; M[r] = M[pivot]; M[pivot] = tmp; + const inv = M[r][c]; + M[r] = M[r].map((x) => fDiv(x, inv)); + for (let i = 0; i < rows; i++) { + if (i !== r && M[i][c].n !== 0n) { + const f = M[i][c]; + M[i] = M[i].map((a, k) => fSub(a, fMul(f, M[r][k]))); + } + } + pivots.push(c); + r++; + if (r === rows) break; + } + return { rref: M, pivots }; + } + + function balanceEquation(reactants, products) { + const species = reactants.concat(products); + const n = species.length; + if (n < 2) return null; + const comps = species.map(parseFormula); + if (comps.some((c) => c === null)) return null; + const elements = Array.from(new Set([].concat(...comps.map((c) => Object.keys(c))))).sort(); + const A = elements.map((el) => + comps.map((c, idx) => { + const v = c[el] || 0; + return F(idx < reactants.length ? v : -v); + }) + ); + const { rref: R, pivots } = rref(A); + const free = []; + for (let c = 0; c < n; c++) if (pivots.indexOf(c) < 0) free.push(c); + if (free.length !== 1) return null; + const fcol = free[0]; + const x = new Array(n).fill(null).map(() => F(0)); + x[fcol] = F(1); + pivots.forEach((pc, ri) => { x[pc] = F(-R[ri][fcol].n, R[ri][fcol].d); }); + let lcm = 1n; + for (const v of x) lcm = (lcm * v.d) / gcdBig(lcm, v.d); + let ints = x.map((v) => (v.n * lcm) / v.d); + let g = 0n; + for (const v of ints) g = gcdBig(g, v < 0n ? -v : v); + if (g === 0n) return null; + ints = ints.map((v) => v / g); + if (ints.every((v) => v <= 0n)) ints = ints.map((v) => -v); + if (ints.some((v) => v <= 0n)) return null; + return ints.map((v) => Number(v)); + } + + // --- VSEPR geometry ------------------------------------------------------ + function unit(v) { + const n = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) || 1; + return [v[0] / n, v[1] / n, v[2] / n]; + } + const tetra = () => [[1, 1, 1], [1, -1, -1], [-1, 1, -1], [-1, -1, 1]].map(unit); + const trigPlanar = () => [0, (2 * Math.PI) / 3, (4 * Math.PI) / 3].map((a) => [Math.cos(a), Math.sin(a), 0]); + const tbp = () => [[0, 1, 0], [0, -1, 0]].concat([0, (2 * Math.PI) / 3, (4 * Math.PI) / 3].map((a) => [Math.cos(a), 0, Math.sin(a)])); + const octa = () => [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]]; + + function geometry(sn, lp) { + if (sn === 2) return [[[1, 0, 0], [-1, 0, 0]], "linear"]; + if (sn === 3) { + const t = trigPlanar(); + if (lp === 0) return [t, "trigonal planar"]; + if (lp === 1) return [[t[1], t[2]], "bent"]; + } + if (sn === 4) { + const t = tetra(); + if (lp === 0) return [t, "tetrahedral"]; + if (lp === 1) return [t.slice(1), "trigonal pyramidal"]; + if (lp === 2) return [[t[1], t[2]], "bent"]; + } + if (sn === 5) { + const t = tbp(); + 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) { + const o = octa(); + if (lp === 0) return [o, "octahedral"]; + if (lp === 1) return [o.slice(0, 5), "square pyramidal"]; + if (lp === 2) return [[o[0], o[1], o[4], o[5]], "square planar"]; + } + return [null, null]; + } + + function vseprMolecule(formula) { + const counts = parseFormula(formula); + if (!counts) return null; + const nonTerminal = Object.keys(counts).filter((e) => !TERMINALS.has(e)); + if (nonTerminal.length !== 1 || counts[nonTerminal[0]] !== 1) return null; + const central = nonTerminal[0]; + const ve = valence(central); + if (ve === null) return null; + const terminals = []; + for (const el in counts) if (el !== central) for (let k = 0; k < counts[el]; k++) terminals.push(el); + const n = terminals.length; + if (n < 2) return null; + const lone2 = ve - n; + if (lone2 < 0 || lone2 % 2 !== 0) return null; + const lp = lone2 / 2; + const sn = n + lp; + const [dirs, shape] = geometry(sn, lp); + if (!dirs || dirs.length !== n) return null; + terminals.sort((a, b) => covRadius(b) - covRadius(a)); + const atoms = [{ e: central, p: [0, 0, 0] }]; + const bonds = []; + const rc = covRadius(central); + dirs.forEach((d, k) => { + const el = terminals[k]; + const len = rc + covRadius(el); + const u = unit(d); + atoms.push({ e: el, p: [u[0] * len, u[1] * len, u[2] * len] }); + bonds.push([0, k + 1, 1]); + }); + return { atoms, bonds, shape }; + } + + // --- Curated library (seed) + merged generated -------------------------- + function diatomic(a, b, order, length) { + if (length == null) length = covRadius(a) + covRadius(b); + return { atoms: [{ e: a, p: [-length / 2, 0, 0] }, { e: b, p: [length / 2, 0, 0] }], bonds: [[0, 1, order]] }; + } + function co2() { + const 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]] }; + } + function bentTri(center, outer, angleDeg, len, order) { + const half = (angleDeg * Math.PI) / 180 / 2; + const dx = len * Math.sin(half), dy = 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]] }; + } + function benzene() { + const R = 1.39, Rh = R + 1.09, atoms = [], bonds = []; + for (let i = 0; i < 6; i++) { const a = (i * Math.PI) / 3; atoms.push({ e: "C", p: [R * Math.cos(a), R * Math.sin(a), 0] }); } + for (let i = 0; i < 6; i++) { const a = (i * Math.PI) / 3; atoms.push({ e: "H", p: [Rh * Math.cos(a), Rh * Math.sin(a), 0] }); } + for (let i = 0; i < 6; i++) { bonds.push([i, (i + 1) % 6, i % 2 === 0 ? 2 : 1]); bonds.push([i, i + 6, 1]); } + return { atoms, bonds }; + } + + function seedLibrary() { + return { + H2: Object.assign(diatomic("H", "H", 1, 0.74), { name: "Hydrogen (H₂)", names: ["hydrogen", "dihydrogen"] }), + O2: Object.assign(diatomic("O", "O", 2, 1.21), { name: "Oxygen (O₂)", names: ["oxygen", "dioxygen"] }), + N2: Object.assign(diatomic("N", "N", 3, 1.10), { name: "Nitrogen (N₂)", names: ["nitrogen", "dinitrogen"] }), + Cl2: Object.assign(diatomic("Cl", "Cl", 1, 1.99), { name: "Chlorine (Cl₂)", names: ["chlorine"] }), + HCl: Object.assign(diatomic("H", "Cl", 1, 1.27), { name: "Hydrogen chloride (HCl)", names: ["hydrogen chloride", "hydrochloric acid"] }), + HF: Object.assign(diatomic("H", "F", 1, 0.92), { name: "Hydrogen fluoride (HF)", names: ["hydrogen fluoride"] }), + CO: Object.assign(diatomic("C", "O", 3, 1.13), { name: "Carbon monoxide (CO)", names: ["carbon monoxide"] }), + NaCl: Object.assign(diatomic("Na", "Cl", 1, 2.36), { name: "Sodium chloride (NaCl)", names: ["sodium chloride", "table salt", "halite"] }), + CO2: Object.assign(co2(), { name: "Carbon dioxide (CO₂)", names: ["carbon dioxide", "dry ice"] }), + SO2: Object.assign(bentTri("S", "O", 119.0, 1.43, 2), { name: "Sulfur dioxide (SO₂)", names: ["sulfur dioxide", "sulphur dioxide"] }), + O3: Object.assign(bentTri("O", "O", 117.0, 1.28, 1), { name: "Ozone (O₃)", names: ["ozone"] }), + C6H6: Object.assign(benzene(), { name: "Benzene (C₆H₆)", names: ["benzene"] }), + }; + } + + const 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", + }; + const AMBIGUOUS_NAMES = new Set(["water", "oxygen", "nitrogen", "hydrogen", "salt", "ozone"]); + + const MOLECULES = {}; + const NAME_INDEX = {}; + + function register(disp, mol) { + const c = canon(disp); + const m = Object.assign({}, mol); + if (!("formula" in m)) m.formula = disp; + MOLECULES[c] = m; + } + function indexNames() { + for (const k in NAME_INDEX) delete NAME_INDEX[k]; + for (const c in MOLECULES) { + NAME_INDEX[c.toLowerCase()] = c; + const disp = MOLECULES[c].formula || c; + NAME_INDEX[disp.toLowerCase()] = c; + for (const nm of MOLECULES[c].names || []) NAME_INDEX[String(nm).trim().toLowerCase()] = c; + } + } + function loadMolecules(items) { + for (const k in MOLECULES) delete MOLECULES[k]; + const seed = seedLibrary(); + for (const disp in seed) register(disp, seed[disp]); + if (Array.isArray(items)) { + for (const mol of items) { + if (mol && mol.formula && Array.isArray(mol.atoms) && mol.bonds != null) register(mol.formula, mol); + } + } + indexNames(); + } + loadMolecules([]); // seed-only until the generated library is injected + + // --- Lookup -------------------------------------------------------------- + function normalizeGeometry(mol) { + const atoms = mol.atoms; + const cx = atoms.reduce((s, a) => s + a.p[0], 0) / atoms.length; + const cy = atoms.reduce((s, a) => s + a.p[1], 0) / atoms.length; + const cz = atoms.reduce((s, a) => s + a.p[2], 0) / atoms.length; + let maxr = 0; + for (const a of atoms) { + const d = Math.sqrt((a.p[0] - cx) ** 2 + (a.p[1] - cy) ** 2 + (a.p[2] - cz) ** 2); + if (d > maxr) maxr = d; + } + const k = maxr > 1e-6 ? 3.0 / maxr : 1.0; + const round = (x) => Math.round(x * 1e4) / 1e4; + const outAtoms = atoms.map((a) => { + const col = elColor(a.e); + return { + e: a.e, + p: [round((a.p[0] - cx) * k), round((a.p[1] - cy) * k), round((a.p[2] - cz) * k)], + c: col, + lc: labelColor(col), + r: round(Math.max(0.28, covRadius(a.e) * 0.42) * k), + }; + }); + const bonds = mol.bonds.map((b) => [b[0] | 0, b[1] | 0, (b.length > 2 ? b[2] : 1) | 0]); + return { atoms: outAtoms, bonds }; + } + + function libraryGeo(c, dispOverride) { + const mol = MOLECULES[c]; + const geo = normalizeGeometry(mol); + geo.formula = dispOverride || mol.formula || c; + geo.name = mol.name || prettyFormula(geo.formula); + geo.shape = mol.shape || ""; + return geo; + } + + function lookupMolecule(query) { + if (!query) return null; + const q = String(query).trim(); + const counts = parseFormula(q); + if (counts) { + const c = canonicalFormula(counts); + if (c in MOLECULES) return libraryGeo(c); + const v = vseprMolecule(q); + if (v) { + const geo = normalizeGeometry(v); + geo.formula = q; + geo.name = shapeName(q, v.shape); + geo.shape = v.shape || ""; + return geo; + } + return null; + } + const key = q.toLowerCase(); + if (key in NAME_INDEX) return libraryGeo(NAME_INDEX[key]); + if (key in COMMON_NAMES) return lookupMolecule(COMMON_NAMES[key]); + return null; + } + + // --- Detection ----------------------------------------------------------- + const CHEM_KEYWORDS = /\b(molecul\w*|compound|lewis|vsepr|ball[- ]and[- ]stick|3d\s+structure|structure of|shape of|geometry of|bond(s|ing)?|chemical structure)\b/i; + const COMMAND_PREFIX = /^\s*(please\s+)?(balance|solve|complete|finish)\b(\s+(the|this))?(\s+(equation|reaction|chemical\s+equation))?\s*[:\-]?\s*/i; + + function looksLikeFormula(token) { + const counts = parseFormula(token); + if (!counts) return false; + const total = Object.values(counts).reduce((a, b) => a + b, 0); + if (total < 2) return false; + const hasDigit = /\d/.test(token); + const multiEl = Object.keys(counts).length >= 2; + return hasDigit || multiEl; + } + function findFormula(prompt) { + let best = null; + for (const raw of prompt.trim().split(/[\s,;:]+/)) { + const tok = raw.replace(/^[.?!)()"']+|[.?!)()"']+$/g, "").trim(); + if (!tok || !FORMULA_TOKEN_RE.test(tok)) continue; + if (looksLikeFormula(tok)) { if (best === null || tok.length > best.length) best = tok; } + } + return best; + } + function bareNameTriggers() { + const names = new Set(); + for (const k in NAME_INDEX) if (/^[a-z]+$/.test(k) && k.length >= 4) names.add(k); + for (const k in COMMON_NAMES) if (k.split(" ").every((p) => /^[a-z]+$/.test(p)) && k.length >= 4) names.add(k); + for (const a of AMBIGUOUS_NAMES) names.delete(a); + return names; + } + function detectChemistry(prompt) { + if (!prompt) return null; + const text = prompt.trim(); + const reactionText = text.replace(COMMAND_PREFIX, ""); + const eq = parseEquation(reactionText); + if (eq !== null) return ["balance", eq]; + const formula = findFormula(text); + if (formula !== null) return ["molecule", formula]; + const low = text.toLowerCase(); + let candidates; + if (CHEM_KEYWORDS.test(text)) { + candidates = new Set([].concat(Object.keys(NAME_INDEX), Object.keys(COMMON_NAMES))); + } else { + candidates = bareNameTriggers(); + } + const sorted = Array.from(candidates).sort((a, b) => b.length - a.length); + for (const name of sorted) { + if (name.length >= 4 && new RegExp("\\b" + name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\b").test(low)) + return ["molecule", name]; + } + return null; + } + + // --- Scene code builders (emit a bare scene(ctx,t) body) ---------------- + function moleculeCode(mol) { + const data = JSON.stringify({ atoms: mol.atoms, bonds: mol.bonds, name: mol.name, formula: prettyFormula(mol.formula) }); + return ( + "H.background();\n" + + "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: '#9aa3c0', 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" + ); + } + + function balanceCode(reactants, products, coeffs) { + const nr = reactants.length; + const rc = coeffs.slice(0, nr); + const pc = coeffs.slice(nr); + const comps = reactants.concat(products).map(parseFormula); + const elements = Array.from(new Set([].concat(...comps.map((c) => Object.keys(c))))).sort(); + const tally = elements.map((el) => { + let left = 0, right = 0; + for (let i = 0; i < nr; i++) left += rc[i] * (comps[i][el] || 0); + for (let i = 0; i < products.length; i++) right += pc[i] * (comps[nr + i][el] || 0); + return { el, left, right, color: elColor(el) }; + }); + const side = (formulas, cs) => formulas.map((f, i) => ({ coef: cs[i], formula: prettyFormula(f) })); + const eqstr = (formulas, cs) => formulas.map((f, i) => (cs[i] !== 1 ? cs[i] + " " : "") + prettyFormula(f)).join(" + "); + const balanced = eqstr(reactants, rc) + " → " + eqstr(products, pc); + const data = JSON.stringify({ reactants: side(reactants, rc), products: side(products, pc), tally, balanced }); + return ( + "H.background();\n" + + "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" + + "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" + + "H.text(EQ.balanced, w / 2, hgt * 0.62,\n" + + " { color: H.colors.accent, size: 18, weight: 700, align: 'center', baseline: 'middle' });\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 -------------------------------------------------------- + function scene(prompt) { + const hit = detectChemistry(prompt); + if (hit === null) return null; + const kind = hit[0], payload = hit[1]; + + if (kind === "molecule") { + const mol = lookupMolecule(payload); + if (mol === null) return null; + const shape = mol.shape || ""; + const summary = + "3D ball-and-stick structure of " + mol.name + + (shape ? ", a " + shape + " molecule." : ".") + + " Atoms are colored by element (CPK); sticks are bonds. Drag to orbit."; + const 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.", + shape + ? "The arrangement is " + shape + " — set by how electron pairs spread out (VSEPR)." + : "The 3D arrangement reflects how the atoms bond in space.", + ]; + return { + title: mol.name, tag: "Chemistry", dimension: "3D", equation: mol.formula, + summary, bullets, + student_prompts: [ + "Why does " + mol.formula + " take this shape?", + "What are the bond angles in this molecule?", + "How do lone pairs change the geometry?", + ], + code: moleculeCode(mol), kind: "molecule", + }; + } + + if (kind === "balance") { + const reactants = payload[0], products = payload[1]; + const coeffs = balanceEquation(reactants, products); + if (coeffs === null) return null; + const nr = reactants.length; + const eq = (formulas, cs) => formulas.map((f, i) => (cs[i] !== 1 ? cs[i] + " " : "") + f).join(" + "); + const balancedPlain = eq(reactants, coeffs.slice(0, nr)) + " -> " + eq(products, coeffs.slice(nr)); + return { + title: "Balanced chemical equation", tag: "Chemistry", dimension: "2D", + equation: balancedPlain, + 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: balanceCode(reactants, products, coeffs), kind: "balance", + }; + } + return null; + } + + global.VisualLMChem = { + loadMolecules, scene, + // exposed for parity testing + parseFormula, balanceEquation, detectChemistry, lookupMolecule, vseprMolecule, canonicalFormula, + }; +})(typeof window !== "undefined" ? window : this); diff --git a/web/js/matching.js b/web/js/matching.js new file mode 100644 index 0000000..5ac809a --- /dev/null +++ b/web/js/matching.js @@ -0,0 +1,89 @@ +/* VisualLM — curated matching (browser port of main.py's scorer). + * + * Scores the prompt against the demo library (333 interactive demos) and the + * scene library (50 curated scenes) with the exact same keyword/title/tag + * scoring as the Python server, so the browser routes a prompt to the same + * scene the desktop app would. Exposes a global VisualLMMatch. + */ +(function (global) { + "use strict"; + + let DEMOS = []; + let SCENES = []; + + const STOPWORDS = new Set(( + "the a an of in on to and or is are show me how visualize animate explain " + + "draw plot with for as its it this that what why over time using see" + ).split(" ")); + + function tokenize(s) { + const m = (s || "").toLowerCase().match(/[a-z0-9]+/g); + return new Set(m || []); + } + function tokensMinusStop(s) { + const out = new Set(); + for (const t of tokenize(s)) if (!STOPWORDS.has(t)) out.add(t); + return out; + } + function intersize(a, b) { + let n = 0; + for (const x of a) if (b.has(x)) n++; + return n; + } + + // Mirrors _scene_score(sc, ptext, ptoks, mode). + function sceneScore(sc, ptext, ptoks, mode) { + let score = 0; + for (const kw of sc.keywords || []) { + const k = String(kw).toLowerCase().trim(); + if (!k) continue; + if (k.indexOf(" ") >= 0) { + if (ptext.indexOf(k) >= 0) score += 2.0; + } else if (ptoks.has(k)) { + score += 1.0; + } + } + const titleToks = tokensMinusStop(sc.title || ""); + const tagToks = tokensMinusStop(sc.tag || sc.area || ""); + score += 1.0 * intersize(titleToks, ptoks); + score += 0.5 * intersize(tagToks, ptoks); + const dim = String(sc.dimension || "").toLowerCase(); + if ((mode === "2d" || mode === "3d") && dim !== mode) score -= 1.5; + return score; + } + + function ptextOf(prompt) { + return " " + String(prompt || "").toLowerCase().replace(/\s+/g, " ") + " "; + } + + function libraryScored(prompt, mode) { + if (!SCENES.length) return []; + const ptext = ptextOf(prompt); + const ptoks = tokensMinusStop(prompt); + const scored = SCENES.map((sc) => [sc, sceneScore(sc, ptext, ptoks, mode)]).filter((t) => t[1] > 0); + scored.sort((a, b) => b[1] - a[1]); + return scored; + } + + function demoScored(prompt) { + if (!DEMOS.length) return []; + const ptext = ptextOf(prompt); + const ptoks = tokensMinusStop(prompt); + // mode "auto" so a 2D demo isn't penalized by a 3D hint (matches Python). + const scored = DEMOS.map((d) => [d, sceneScore(d, ptext, ptoks, "auto")]).filter((t) => t[1] > 0); + scored.sort((a, b) => b[1] - a[1]); + return scored; + } + + function demoMatch(prompt) { + const s = demoScored(prompt); + return s.length ? { demo: s[0][0], score: s[0][1] } : { demo: null, score: 0 }; + } + + global.VisualLMMatch = { + loadData(demos, scenes) { DEMOS = demos || []; SCENES = scenes || []; }, + libraryScored, demoScored, demoMatch, sceneScore, tokenize, + get demos() { return DEMOS; }, + get scenes() { return SCENES; }, + }; +})(typeof window !== "undefined" ? window : this); diff --git a/web/js/planner.js b/web/js/planner.js new file mode 100644 index 0000000000000000000000000000000000000000..1a402f57351153be33795bcdf67a96ae77a9ec34 GIT binary patch literal 7505 zcmdT}TW{Rf5$>~p#e_g3uDzmGmvs}Rvf)Sy)`e{umRmolf^bBh-KE7PH@s-0$O8J* zA5ioc=9l!FITw;E$tlpM28gvhJZEOk%r{qVJ*B^;Rim;uZ|S#x{F^SzVpZvqmYK?P z{0)~ySyM5kg-Y{ic_S|_uH{JjtE$r?AJQVtn~LhWenL+v&Wa}4di9!S8U$nqEmb|I z)jW;olxv+-1Qyr2+^o_>)0Mud1`&?(=?_Iyr}=D1X--9%=yIP|iMAuGY%cVoC~qiM zvEfCu6A^wd(yGQfh0~&3G?}8!7qojsiz3r;lWF=wrB*Qu%Wp0fE*I;B^v{jRH!wVQ zL8~dt6i1o`CEx{$Ra}&sUL1hah!3ZEjaz}%wWcbA{AFd2@5ymhgK(B!mI?w@F?7I0 z>ayTV#YH~Fk-UaF>v(Ptjw4!Wb>&Y@RhC_<_-aTxt28N^mZ{E@?1qAAsr4pblAz!W zGE}T6t9hu2Aa-?C7fVvhWk7SCEupP4VG+_SBbBG%tdk+^00V$_qE&(|`L&XJuqKXw zSr!#2J7BywScn)C;j%0i%NjN>57+)3^?Pc!C8ev8w6l`0dHX z$H|A|)5*!3BNF@k;NJ>Du$b!=y;aL_ASYFIlgH%QF!#Tlp1cbgo`#vD4#{y^tyEgm zRKrwe4>KT2rxbpJ9Z_*Lz^&jh!soawi!uy;Qn+41bs@K-fSwunKf}+EpZH`|s~RqY zwLt*cm};r(rp)E-=c>qAWTLzF!bZyn&~yJ5X`a?$U-~n~ZZ$OU!(ZnI)w#cvHB|(t zg=fN_L|)NHz-HPKA`#0H42BPH5qwu)-{YCvZ4q#ghhZ|HgBKJg^zFBlM1B*!>^<#M zGO*0gt)2`vA_va5>_`!t@(4nQy_-LL;t=sd4!pJDp$a|wzAbJg2viV)^%-S4pVf1x z)_tp9zwC+ZQ%jJ(e4(@Rf!#!RV$^}fqer3-VIOq01iUrE&XA0&In!Ev+*(W2X`N}7 zA(CIrC*Ye&Bn+IrYBe)^)a;QRNxIN^g~+&1r*N`-2G)^ZltaNFs*5+pN|%QUq74|m zWnFzr>v&F-Mo4fpvh!v8*gIU&}1G2}d{o&mGB{{|I)0s6pGTKoIcjRgIe z2$uHTjHJwApXQfccec+xALcddzGCYKoYWSE#!uTAk04-cz7JX<;kuY_ycQ)foc3#h zpFPBuYqlu&*IUm3zvE2*C&B-Bkhq3llRwrLyWo2umO1QvB`NrL-=NLWNf#d0&RFUp z#4g_hiK!{qF#x+qZtVgP1~7mFw_=de3%NMJ&r&pQZP6=R?04FLYF?~zbYeP}w!$?N zMo5|?a^wX!L42PrpOdyg6!aM1rg4#0kpq}(7j=?KcV#KG-Ct3nv$G}pqRHBZWyAf^0ga+DN@@Q0DXRT? z9#v@`)_B;C_FAHosMf#K;l_`)#kDeT3~6U?NV}*cZXsw^l>1;MKdLCr(S=p%ugKur z-wn~GafIHdKaECocc1c4-R`W@{+3Pd`ZOrf_h%7JG-9{JYL%nML5H?)#Ti4S`{Gp1 zr59(nD}hm1TI&V(EoU1aZ*IQNEx zU=*Upqm=FV1OEJ(}Om&h28g9#uPvIc06IXQG6y8R7|r1 z4VpRf-GKhsV}$H;a6oD2`~;0{T!*VIT%L9qA7HvEIM{oRr(H1-npxxraCu1^P#Bw6yyX5LMWfvTw298PxB|R{g2$bTQOVUf=vQWCimrW? zCiNUxjKI58N!lQze%}QG@rksIGwlT5-eJLqG-g0`q-`k|MY7Imouqh<*ySuehZv$0 zz9sAA8EtpK+P?@Ks7t#C;2|b4mCmF--dLnblIe{u<2z^c?v+e5V5_r5*zb2?c!>n< z1^wNu9v|@4j~LGs7|vj{g+PWd0=YS&nHuc3fBp-$!8KcU-#r*0)-I$nlr1nfTThqm zZO)_51!7Y0cl6RSituGSk7!IeV1lqq{SQ~s!CLS_f{_FtxONx|z7)3om>+ZD*_Q>r z^$H#fTHWbVKWe=;nsTw<#jg@GDjq52H-6y27M59?th&%LloGdCauUL3V*%RT-R%w6 z)<$s{7@-9kk6d=L-M7RX)J`~xt~9SRARQ^p%op2&*B9s}?{K5jcFRb|CS1BG8Fm8X zENfy+!v-8e5gsvBgvT~gb+_a}%|uZ2&z_pQE}RzAPsC4*get8Ob(~iE_52aiE8IDk z=$g~3Y^E_q+Bmd&}%0kbnelEDx%b~OQ?t#6-ov)9Nn2H7YWtNz)NB3C!hMw;E*e73m>rc zJ#xF`swTT~!CrE&YLYs~JJJKw$y+JIZE7@!}vMFAs0gmbAlc|bTbzT>U(t1qD`i<3pf?`2TP7qRpB z{s6eWMCTfO%#K71jkOz~RPNtFZh<*IZow6>pUCr<5iX4R(}o?myV4gKydWM?bMh#r zQ%-00)OJ9Gf96yZ{W$K3?XilkejqtuiAwGCSfg#S)L*R>k^1pq8Q