From ede9a7aa63dde9f779ad095c35a09106c9926439 Mon Sep 17 00:00:00 2001 From: Hananoshika Yomaru Date: Wed, 20 May 2026 13:17:25 -0700 Subject: [PATCH] Refactor math rendering: Replace direct KaTeX imports with a centralized module for rendering math expressions across widgets. Update CONTRIBUTING.md with new guidelines for using KaTeX in HTML and widgets. Enhance TypeScript configuration for path resolution of the KaTeX module. Update curriculum status for linear equations and improve math rendering logic in the frontend. --- .agents/skills/zola/zola-solid.md | 11 + CONTRIBUTING.md | 1 + .../widgets/formula-rearrange-demo.tsx | 12 +- .../widgets/fraction-simplify-demo.tsx | 25 +- .../widgets/addition-groups.tsx | 6 +- .../widgets/multiplication-array.tsx | 6 +- .../widgets/number-line.tsx | 7 +- .../widgets/ratio-demo.tsx | 6 +- .../linear-equations-two-unknowns/_index.md | 374 +++++++++++ .../chapter.scss | 304 +++++++++ .../linear-equations-two-unknowns/core.html | 128 ++++ .../supplement.html | 234 +++++++ .../widgets/simultaneous-graph-explorer.tsx | 608 ++++++++++++++++++ .../widgets/simultaneous-solver-workbench.tsx | 512 +++++++++++++++ .../widgets/area-model.tsx | 7 +- .../widgets/associative-demo.tsx | 6 +- .../widgets/commutative-demo.tsx | 6 +- .../widgets/distributive-area-demo.tsx | 6 +- .../widgets/_application-demos.tsx | 6 +- .../quadratic-equations/widgets/explorer.tsx | 6 +- data/math-curriculum.json | 4 +- frontend/src/lib/katex.ts | 62 ++ frontend/src/lib/render-math.ts | 27 +- frontend/src/main.tsx | 20 +- themes/openfreebooks/templates/base.html | 1 + themes/openfreebooks/templates/chapter.html | 1 - tsconfig.json | 5 +- vite.config.ts | 9 + 28 files changed, 2289 insertions(+), 111 deletions(-) create mode 100644 content/math/linear-equations-two-unknowns/_index.md create mode 100644 content/math/linear-equations-two-unknowns/chapter.scss create mode 100644 content/math/linear-equations-two-unknowns/core.html create mode 100644 content/math/linear-equations-two-unknowns/supplement.html create mode 100644 content/math/linear-equations-two-unknowns/widgets/simultaneous-graph-explorer.tsx create mode 100644 content/math/linear-equations-two-unknowns/widgets/simultaneous-solver-workbench.tsx create mode 100644 frontend/src/lib/katex.ts diff --git a/.agents/skills/zola/zola-solid.md b/.agents/skills/zola/zola-solid.md index d6bb965..9265ab6 100644 --- a/.agents/skills/zola/zola-solid.md +++ b/.agents/skills/zola/zola-solid.md @@ -57,6 +57,17 @@ Prefer **bootstrap** components that `querySelector` existing markup over re-ren 3. Hard-refresh browser. 4. For production: `bun run build` then `wrangler deploy`. +## KaTeX (math) + +| Use case | API | Where | +|----------|-----|--------| +| Chapter HTML with `\( … \)` / `\[ … \]` | `renderBookMath()` | `frontend/src/lib/katex.ts` — runs on load via `initBookMath()` in `main.tsx` | +| Widget labels / dynamic formulas | `renderLatex()` + `innerHTML` | `import { renderLatex } from "@ofb/katex"` in `content/**/widgets/*.tsx` | +| Captions with delimiter math | `renderMathInContainer(el)` | Same module; do not duplicate per chapter | + +- **CSS:** `js/katex.css` is linked from `base.html` on every page. +- **Do not** add local `function renderLatex` or `import katex from "katex"` in widgets — use `@ofb/katex` only. + ## Adding a feature 1. Add mount root in Tera template (empty `
` or use existing). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7ca932..6f7c38d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,6 +111,7 @@ strand = "Number & Algebra" - Use shared classes: `book-prose`, `book-prose__heading`, `book-callout`, etc. Copy from `content/math/quadratic-equations/`. - **Do not** mention DSE, IB, or other curriculum names in chapter HTML. - Mount widgets with `
` (filename without `.tsx` = `data-widget` value). +- **Math (KaTeX):** write `\( … \)` and `\[ … \]` in HTML; prose is auto-rendered on chapter load. In widgets use `import { renderLatex } from "@ofb/katex"` — do not add a per-widget `renderLatex` helper. - Reference synced assets with root-relative paths: `/chapters/math/your-chapter-slug/image.png` (chapter HTML is not processed as Tera) ### 4. Build and check diff --git a/content/math/algebraic-fractions-formulas/widgets/formula-rearrange-demo.tsx b/content/math/algebraic-fractions-formulas/widgets/formula-rearrange-demo.tsx index 2db9deb..a7ce7c1 100644 --- a/content/math/algebraic-fractions-formulas/widgets/formula-rearrange-demo.tsx +++ b/content/math/algebraic-fractions-formulas/widgets/formula-rearrange-demo.tsx @@ -1,14 +1,6 @@ -import katex from "katex"; +import { renderLatex } from "@ofb/katex"; import { createMemo, createSignal, For } from "solid-js"; -function renderLatex(latex: string, display = false) { - return katex.renderToString(latex, { - throwOnError: false, - strict: "ignore", - displayMode: display, - }); -} - type FormulaId = "speed" | "density" | "ohm"; type SubjectId = string; @@ -125,7 +117,7 @@ function FormulaRearrangeDemo() { () => formula().subjects.find((s) => s.id === subjectId()) ?? formula().subjects[0] ); - const resultHtml = createMemo(() => renderLatex(subject().result, true)); + const resultHtml = createMemo(() => renderLatex(subject().result, { displayMode: true })); const onFormulaChange = (id: FormulaId) => { setFormulaId(id); diff --git a/content/math/algebraic-fractions-formulas/widgets/fraction-simplify-demo.tsx b/content/math/algebraic-fractions-formulas/widgets/fraction-simplify-demo.tsx index 77c29a0..b79b371 100644 --- a/content/math/algebraic-fractions-formulas/widgets/fraction-simplify-demo.tsx +++ b/content/math/algebraic-fractions-formulas/widgets/fraction-simplify-demo.tsx @@ -1,10 +1,6 @@ -import katex from "katex"; +import { renderLatex } from "@ofb/katex"; import { createMemo, createSignal, For } from "solid-js"; -function renderLatex(latex: string) { - return katex.renderToString(latex, { throwOnError: false, strict: "ignore", displayMode: true }); -} - const PRESETS = [ { id: "difference-squares", @@ -32,16 +28,16 @@ const PRESETS = [ }, ] as const; +type PresetId = (typeof PRESETS)[number]["id"]; + function FractionSimplifyDemo() { - const [presetId, setPresetId] = createSignal(PRESETS[0].id); + const [presetId, setPresetId] = createSignal(PRESETS[0].id); const preset = createMemo(() => PRESETS.find((p) => p.id === presetId()) ?? PRESETS[0]); - const originalHtml = createMemo(() => renderLatex(preset().original)); - const factoredHtml = createMemo(() => renderLatex(preset().factored)); - const simplifiedHtml = createMemo(() => renderLatex(preset().simplified)); - const restrictionHtml = createMemo(() => - katex.renderToString(preset().restriction, { throwOnError: false, strict: "ignore" }) - ); + const originalHtml = createMemo(() => renderLatex(preset().original, { displayMode: true })); + const factoredHtml = createMemo(() => renderLatex(preset().factored, { displayMode: true })); + const simplifiedHtml = createMemo(() => renderLatex(preset().simplified, { displayMode: true })); + const restrictionHtml = createMemo(() => renderLatex(preset().restriction)); return (
setPresetId(e.currentTarget.value)} + onChange={(e) => { + const next = PRESETS.find((p) => p.id === e.currentTarget.value); + if (next) setPresetId(next.id); + }} > {(p) => } diff --git a/content/math/counting-and-numbers/widgets/addition-groups.tsx b/content/math/counting-and-numbers/widgets/addition-groups.tsx index a3f5f27..74d5205 100644 --- a/content/math/counting-and-numbers/widgets/addition-groups.tsx +++ b/content/math/counting-and-numbers/widgets/addition-groups.tsx @@ -1,10 +1,6 @@ -import katex from "katex"; +import { renderLatex } from "@ofb/katex"; import { createMemo, createSignal, For } from "solid-js"; -function renderLatex(latex: string) { - return katex.renderToString(latex, { throwOnError: false, strict: "ignore" }); -} - function DotPile(props: { count: number; label: string; class?: string }) { return (
diff --git a/content/math/counting-and-numbers/widgets/multiplication-array.tsx b/content/math/counting-and-numbers/widgets/multiplication-array.tsx index 2d87b68..40fd256 100644 --- a/content/math/counting-and-numbers/widgets/multiplication-array.tsx +++ b/content/math/counting-and-numbers/widgets/multiplication-array.tsx @@ -1,10 +1,6 @@ -import katex from "katex"; +import { renderLatex } from "@ofb/katex"; import { createMemo, createSignal, For } from "solid-js"; -function renderLatex(latex: string) { - return katex.renderToString(latex, { throwOnError: false, strict: "ignore" }); -} - function MultiplicationArray() { const [rows, setRows] = createSignal(3); const [cols, setCols] = createSignal(2); diff --git a/content/math/directed-numbers-rates/widgets/number-line.tsx b/content/math/directed-numbers-rates/widgets/number-line.tsx index 4b55540..39a6156 100644 --- a/content/math/directed-numbers-rates/widgets/number-line.tsx +++ b/content/math/directed-numbers-rates/widgets/number-line.tsx @@ -1,6 +1,5 @@ -import katex from "katex"; +import { renderLatex, renderMathInContainer } from "@ofb/katex"; import { createEffect, createMemo, createSignal } from "solid-js"; -import { renderMathInContainer } from "../../../../frontend/src/lib/render-math"; const MIN = -10; const MAX = 10; @@ -13,10 +12,6 @@ function formatNum(n: number) { return (Math.round(n * 100) / 100).toString(); } -function renderLatex(latex: string) { - return katex.renderToString(latex, { throwOnError: false, strict: "ignore" }); -} - function toX(value: number) { const t = (value - MIN) / (MAX - MIN); return PAD + t * PLOT; diff --git a/content/math/directed-numbers-rates/widgets/ratio-demo.tsx b/content/math/directed-numbers-rates/widgets/ratio-demo.tsx index ecd26f8..9cb4ea1 100644 --- a/content/math/directed-numbers-rates/widgets/ratio-demo.tsx +++ b/content/math/directed-numbers-rates/widgets/ratio-demo.tsx @@ -1,4 +1,4 @@ -import katex from "katex"; +import { renderLatex } from "@ofb/katex"; import { createMemo, createSignal } from "solid-js"; function formatNum(n: number) { @@ -6,10 +6,6 @@ function formatNum(n: number) { return (Math.round(n * 100) / 100).toString(); } -function renderLatex(latex: string) { - return katex.renderToString(latex, { throwOnError: false, strict: "ignore" }); -} - function RatioDemo() { const [partA, setPartA] = createSignal(2); const [partB, setPartB] = createSignal(5); diff --git a/content/math/linear-equations-two-unknowns/_index.md b/content/math/linear-equations-two-unknowns/_index.md new file mode 100644 index 0000000..811e190 --- /dev/null +++ b/content/math/linear-equations-two-unknowns/_index.md @@ -0,0 +1,374 @@ ++++ +title = "Linear equations in two unknowns" +description = "Solve pairs of linear equations by substitution, elimination, and graphs; classify unique, parallel, and coincident cases." +template = "chapter.html" +weight = 10 +[extra] +subject = "math" +chapter_id = "linear-equations-two-unknowns" +strand = "Number & Algebra" ++++ + +
+

+ A system of linear equations in two unknowns is a pair of equations in + variables \(x\) and \(y\), where each equation is linear (the highest power of each variable is + 1). A solution is an ordered pair \((x, y)\) that satisfies + both equations at the same time. +

+ +
+

Assumed knowledge

+
    +
  • Expand and collect like terms, e.g. \(2(x+3) = 2x + 6\).
  • +
  • Substitute a value into an expression, e.g. replace \(x\) by \(4\) in \(3x - 1\).
  • +
  • Add or subtract equations term by term when both sides are equal.
  • +
+
+ +

Standard form

+

+ A convenient form is + \[ ax + by = c, \qquad dx + ey = f, \] + where \(a, b, c, d, e, f\) are constants. The same system can be written in other equivalent + forms — for example \(y = 2x + 1\) is the same as \(-2x + y = 1\). +

+ +

Substitution

+

+ Substitution means isolate one unknown in one equation, then replace that + expression in the other equation. +

+

Example. Solve

+

+ \[ \begin{cases} y = 2x + 1 \\ x + y = 7 \end{cases} \] +

+

+ From the first equation, \(y = 2x + 1\). Substitute into the second: + \[ x + (2x + 1) = 7 \Rightarrow 3x + 1 = 7 \Rightarrow 3x = 6 \Rightarrow x = 2. \] + Then \(y = 2(2) + 1 = 5\). The solution is \((2, 5)\). +

+

+ Check: \(2(2)+1 = 5\) and \(2 + 5 = 7\). +

+ +

Elimination

+

+ Elimination adds or subtracts the equations so that one unknown cancels. You + may need to multiply one or both equations first so that the coefficients of \(x\) or \(y\) match + in size (one positive, one negative). +

+

Example. Solve

+

+ \[ \begin{cases} x + y = 5 \\ 2x - y = 1 \end{cases} \] +

+

+ The coefficients of \(y\) are \(1\) and \(-1\), so add the equations: + \[ (x+y) + (2x-y) = 5 + 1 \Rightarrow 3x = 6 \Rightarrow x = 2. \] + Substitute into \(x + y = 5\): \(2 + y = 5\), so \(y = 3\). Solution: \((2, 3)\). +

+ +

Graphical interpretation

+

+ Each equation \(ax + by = c\) (with \(b \neq 0\)) can be rearranged to + \(y = -\frac{a}{b}x + \frac{c}{b}\), which is a straight line. The solution of the system is + the point where the two lines meet — their intersection. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
LinesAlgebraic pictureSolutions
Intersect at one pointDifferent slopesExactly one \((x, y)\)
Parallel, distinctSame slope, different interceptsNo solution
Coincident (same line)One equation is a multiple of the otherInfinitely many
+
+ +

Try it — graph explorer

+

+ Adjust the two lines or use presets. Read off the intersection and how the classification changes + when the lines are parallel or coincident. +

+
+ +
+ +
+

Try it — step-by-step solver

+

+ Pick a preset system and watch substitution or elimination steps. Systems with no unique + solution show an explanation instead of incorrect steps. +

+
+ +
+ +
+

Word problems

+

+ For story problems, define unknowns clearly, write two independent equations, then solve + by substitution, elimination, or a graph. +

+

Example (tickets). Adult tickets cost \$12 and child tickets \$7. A group buys + 10 tickets for \$95. How many of each type?

+

+ Let \(a\) be the number of adult tickets and \(c\) the number of child tickets. Then + \(a + c = 10\) and \(12a + 7c = 95\). From the first, \(c = 10 - a\). Substitute: + \[ 12a + 7(10 - a) = 95 \Rightarrow 12a + 70 - 7a = 95 \Rightarrow 5a = 25 \Rightarrow a = 5, \] + so \(c = 5\). Five adult and five child tickets. +

+
+ +
+

History

+

+ Problems that need two unknowns at once are ancient. The Chinese text + Nine Chapters on the Mathematical Art (around 200 BCE, with later commentary) + includes fangcheng methods — arranging coefficients in a table and eliminating + unknowns, much like modern elimination. +

+

+ In the 9th century, al-Khwarizmi organised linear and quadratic problems in + systematic rules that spread to Europe. By the 17th century, Descartes linked + algebra with geometry: an equation in \(x\) and \(y\) describes a curve, so solving a system is + finding where two curves meet. Today, computers solve huge systems, but the same ideas — replace, + combine, or intersect — remain the core of school algebra. +

+ +

Derivation

+ +

Why elimination preserves solutions

+

+ If \((x_0, y_0)\) satisfies both \(ax + by = c\) and \(dx + ey = f\), then any + linear combination of the two equations is still true at \((x_0, y_0)\). For example, + adding the left sides and adding the right sides gives + \[ (a+d)x + (b+e)y = c + f, \] + which \((x_0, y_0)\) also satisfies. Elimination chooses combinations so one unknown disappears, + leaving a single equation in one unknown. Multiplying an equation by a non-zero constant before + adding does not change the solution set either. +

+ +

Why the graph solution is the intersection

+

+ The graph of \(ax + by = c\) is all points \((x, y)\) that make the equation true. A solution of + the system must lie on both graphs, so it must be a point common to both lines. + If the lines cross once, that point is the unique solution; if they never meet, there is no + solution; if they are the same line, every point on the line works. +

+ +

Checkpoints

+

+ Pause and reason before continuing. Discuss with a partner or write your work in a notebook. +

+ +
+

Checkpoint

+

+ In the graph explorer, use the “Parallel” preset. What does the status panel say about + solutions? Does the canvas show an intersection point? +

+
+ +
+

Checkpoint

+

+ Solve \(y = 3x - 2\) and \(2x + y = 8\) by substitution without a calculator. Does your pair + \((x, y)\) satisfy both originals? +

+
+ +
+

Checkpoint

+

+ For \(x + 2y = 8\) and \(2x + 4y = 16\), are the two equations independent, or is one a + multiple of the other? How many solutions do you expect? +

+
+ +
+

Checkpoint

+

+ When eliminating, why do we sometimes subtract equations instead of add them? Give an + example where subtracting cancels \(x\). +

+
+ +
+

Checkpoint

+

+ A shop sells notebooks for \$4 and pens for \$2. You buy 6 items for \$18. What two equations + describe this? Which method would you use first? +

+
+ +
+

Checkpoint

+

+ In the step-by-step workbench, run elimination on the ticket-style preset. At which step does + \(y\) (or your chosen variable) disappear? +

+
+ +

Applications

+ +

Ticket pricing

+

+ Cinemas and transport systems often use different prices for adults and children. If you know the + total number of tickets and the total cost, you have two equations in two unknowns — the counts + of each type. The same structure appears in fundraising dinners (plate vs concession) and sports + seating. +

+ +

Mixtures and concentrations

+

+ A chemist may mix a \(20\%\) solution with a \(50\%\) solution to obtain 2 L of + \(35\%\) solution. Let \(x\) and \(y\) be the volumes (in litres) of the weaker and stronger + solutions. Then \(x + y = 2\) (total volume) and \(0.2x + 0.5y = 0.35 \times 2\) (total amount + of pure solute). Solving the system gives the recipe. +

+ +

Two constraints in business

+

+ A small business might need at least 100 units to cover fixed costs and also stay within a + production cap of 150 units while hitting a revenue target. Each constraint is linear; the + feasible plan is where the constraints overlap — a preview of ideas used later in linear + programming. +

+ +

Question bank

+

+ Work each question, then open the solution to check your reasoning. Difficulty increases through + the sets. +

+ +

Easy

+ +
+ Solve by substitution: \(y = x + 2\) and \(x + y = 8\). +
+

Answer: \((3, 5)\).

+

Steps: Substitute \(y = x + 2\) into \(x + y = 8\): \(x + (x+2) = 8\), so \(2x = 6\), \(x = 3\), \(y = 5\).

+
+
+ +
+ Solve by elimination: \(x + y = 6\) and \(x - y = 2\). +
+

Answer: \((4, 2)\).

+

Steps: Add: \(2x = 8\), \(x = 4\). Then \(y = 6 - 4 = 2\).

+
+
+ +
+ Is \((1, 2)\) a solution of \(2x + y = 4\) and \(x - y = -1\)? +
+

Answer: Yes.

+

Check: \(2(1)+2 = 4\) and \(1 - 2 = -1\).

+
+
+ +

Intermediate

+ +
+ Solve: \(2x + y = 7\) and \(x - y = 2\). +
+

Answer: \((3, 1)\).

+

Steps: Add equations: \(3x = 9\), \(x = 3\). Then \(3 - y = 2\), \(y = 1\).

+
+
+ +
+ Solve: \(3x + 2y = 12\) and \(x + y = 5\). +
+

Answer: \((2, 3)\).

+

Steps: From \(x + y = 5\), \(y = 5 - x\). Substitute: \(3x + 2(5-x) = 12\), \(3x + 10 - 2x = 12\), \(x = 2\), \(y = 3\).

+
+
+ +
+ Two lines have equations \(y = 2x + 1\) and \(y = 2x - 3\). How many solutions does the system have? +
+

Answer: No solution (parallel lines).

+

Explanation: Same slope \(2\), different intercepts — the lines never meet.

+
+
+ +

Difficult

+ +
+ Solve: \(2x + 3y = 13\) and \(4x - y = 5\). +
+

Answer: \((2, 3)\).

+

Steps: From \(4x - y = 5\), \(y = 4x - 5\). Substitute into \(2x + 3y = 13\): \(2x + 3(4x-5) = 13\), \(2x + 12x - 15 = 13\), \(14x = 28\), \(x = 2\), \(y = 3\).

+
+
+ +
+ Eliminate \(x\): \(3x + 2y = 11\) and \(5x - 2y = 13\). +
+

Answer: \((3, 1)\).

+

Steps: Add equations: \(8x = 24\), \(x = 3\). Then \(3(3) + 2y = 11\), \(2y = 2\), \(y = 1\).

+
+
+ +
+ A purse has 12 coins, all 5¢ or 10¢, with total value 95¢. How many of each? +
+

Answer: 5 coins at 5¢ and 7 coins at 10¢.

+

Steps: Let \(n\) be 5¢ coins and \(m\) be 10¢ coins. Then \(n + m = 12\) and \(5n + 10m = 95\). From the first, \(n = 12 - m\). Substitute: \(5(12-m) + 10m = 95\), \(60 - 5m + 10m = 95\), \(5m = 35\), \(m = 7\), \(n = 5\).

+
+
+ +

Hardcore

+ +
+ For what value of \(k\) does \(x + ky = 4\) and \(2x + 2ky = 8\) have infinitely many solutions? +
+

Answer: Every \(k\) (the second equation is \(2\times\) the first).

+

Explanation: The lines are coincident for all \(k\) where the second is a multiple of the first; here they are always multiples, so infinitely many solutions.

+
+
+ +
+ Solve: \(\dfrac{x}{2} + y = 5\) and \(x - y = 1\). +
+

Answer: \((4, 3)\).

+

Steps: Multiply the first equation by 2: \(x + 2y = 10\). With \(x - y = 1\), substitute \(x = 1 + y\) into \(x + 2y = 10\): \(1 + y + 2y = 10\), \(3y = 9\), \(y = 3\), \(x = 4\).

+
+
+ +
+ A rectangle has perimeter 26 cm. The length is 4 cm more than the width. Find the dimensions. +
+

Answer: width \(4.5\) cm, length \(8.5\) cm.

+

Steps: \(2\ell + 2w = 26\), \(\ell = w + 4\). Substitute: \(2(w+4)+2w=26\), \(4w+8=26\), \(w=4.5\), \(\ell=8.5\).

+
+
+ +

References

+
    +
  • The Nine Chapters on the Mathematical Art — early elimination methods (fangcheng).
  • +
  • al-Khwarizmi, Al-Kitāb al-mukhtaṣar fī ḥisāb al-jabr wal-muqābala (c. 820 CE).
  • +
  • René Descartes, La Géométrie (1637) — coordinates and curves.
  • +
  • HKDSE Compulsory Part curriculum — Number and Algebra strand (equations in two unknowns).
  • +
+
diff --git a/content/math/linear-equations-two-unknowns/chapter.scss b/content/math/linear-equations-two-unknowns/chapter.scss new file mode 100644 index 0000000..c70a091 --- /dev/null +++ b/content/math/linear-equations-two-unknowns/chapter.scss @@ -0,0 +1,304 @@ +.book-prose .math-widget-mount:has(.sim-graph), +.book-prose .math-widget-mount:has(.sim-solver) { + max-width: none; +} + +.sim-graph { + --math-line2: #2563eb; + + .math-widget__equation-box.sim-graph__eq2-box { + background: color-mix(in srgb, #2563eb 8%, transparent); + } +} + +[data-theme="dark"] .sim-graph { + --math-line2: #60a5fa; +} + +.sim-graph__presets { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.sim-graph__preset-btn { + padding: 0.4rem 0.75rem; + font-size: 0.8125rem; + font-weight: 600; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--paper); + color: var(--soft-ink); + cursor: pointer; +} + +.sim-graph__preset-btn:hover { + border-color: var(--accent); + color: var(--ink); +} + +.sim-graph__preset-btn--active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, var(--paper)); + color: var(--accent); +} + +.sim-graph__line-heading { + margin: 0.75rem 0 0.25rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--muted); +} + +.sim-graph__status--unique .math-widget__delta-nature { + color: var(--success); +} + +.sim-graph__status--parallel .math-widget__delta-nature { + color: var(--muted); +} + +.sim-graph__status--coincident .math-widget__delta-nature { + color: var(--accent); +} + +.math-graph-tooltip--intersection .math-graph-tooltip__label { + color: var(--math-root); +} + +.math-graph-tooltip--line1 .math-graph-tooltip__label { + color: var(--math-curve); +} + +.math-graph-tooltip--line2 .math-graph-tooltip__label { + color: var(--math-line2); +} + +.sim-solver { + --sim-panel-bg: var(--card-bg); + --sim-panel-border: var(--card-border); + --sim-error: #dc2626; + + width: min(1100px, 92vw); + max-width: none; + margin-top: 1.25rem; +} + +[data-theme="dark"] .sim-solver { + --sim-error: #f87171; +} + +.sim-solver__shell { + display: flex; + flex-direction: column; + min-height: 24rem; + border: 1px solid var(--sim-panel-border); + border-radius: 14px; + overflow: hidden; + background: var(--sim-panel-bg); +} + +@media (min-width: 768px) { + .sim-solver__shell { + flex-direction: row; + min-height: 28rem; + } +} + +.sim-solver__sidebar { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--line); +} + +@media (min-width: 768px) { + .sim-solver__sidebar { + width: 17rem; + border-bottom: none; + border-right: 1px solid var(--line); + } +} + +.sim-solver__field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.sim-solver__label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--soft-ink); +} + +.sim-solver__select { + width: 100%; + padding: 0.5rem 0.65rem; + font-size: 0.875rem; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--paper); + color: var(--ink); +} + +.sim-solver__method-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +.sim-solver__method-btn { + padding: 0.5rem 0.65rem; + font-size: 0.8125rem; + font-weight: 600; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--paper); + color: var(--soft-ink); + cursor: pointer; +} + +.sim-solver__method-btn--active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, var(--paper)); + color: var(--accent); +} + +.sim-solver__method-btn--disabled, +.sim-solver__method-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.sim-solver__transport { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.sim-solver__transport-btn, +.sim-solver__play { + display: flex; + align-items: center; + justify-content: center; + min-width: 2.5rem; + height: 2.5rem; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--paper); + color: var(--ink); + font-size: 1.25rem; + cursor: pointer; +} + +.sim-solver__play { + flex: 1; + font-size: 0.875rem; + font-weight: 600; +} + +.sim-solver__transport-btn:disabled, +.sim-solver__play:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.sim-solver__speed { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: var(--muted); +} + +.sim-solver__speed-slider { + flex: 1; +} + +.sim-solver__stage { + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.25rem 1.5rem; + min-width: 0; +} + +.sim-solver__message-box { + padding: 0.75rem 1rem; + border-radius: 10px; + background: color-mix(in srgb, var(--accent) 6%, var(--paper)); +} + +.sim-solver__message-box--error { + background: color-mix(in srgb, var(--sim-error) 10%, var(--paper)); +} + +.sim-solver__message-box--error .sim-solver__message { + color: var(--sim-error); +} + +.sim-solver__message { + margin: 0 0 0.5rem; + font-size: 0.9rem; +} + +.sim-solver__progress-track { + height: 4px; + border-radius: 2px; + background: var(--line); + overflow: hidden; +} + +.sim-solver__progress-fill { + height: 100%; + background: var(--accent); + transition: width 0.2s ease; +} + +.sim-solver__equation-panel { + padding: 1rem; + text-align: center; + border-radius: 10px; + background: color-mix(in srgb, var(--accent) 6%, transparent); +} + +.sim-solver__panel-label { + margin: 0 0 0.5rem; + font-family: var(--mono); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); +} + +.sim-solver__step-panel { + flex: 1; + padding: 1rem 1.25rem; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--paper); +} + +.sim-solver__step-title { + margin: 0 0 0.75rem; + font-size: 1rem; + color: var(--ink); +} + +.sim-solver__step-math { + font-size: 1.05rem; + overflow-x: auto; +} + +@media (min-width: 900px) { + .math-widget__layout { + grid-template-columns: minmax(260px, 340px) 1fr; + align-items: start; + } +} diff --git a/content/math/linear-equations-two-unknowns/core.html b/content/math/linear-equations-two-unknowns/core.html new file mode 100644 index 0000000..98e12c4 --- /dev/null +++ b/content/math/linear-equations-two-unknowns/core.html @@ -0,0 +1,128 @@ +
+

+ A system of linear equations in two unknowns is a pair of equations in + variables \(x\) and \(y\), where each equation is linear (the highest power of each variable is + 1). A solution is an ordered pair \((x, y)\) that satisfies + both equations at the same time. +

+ +
+

Assumed knowledge

+
    +
  • Expand and collect like terms, e.g. \(2(x+3) = 2x + 6\).
  • +
  • Substitute a value into an expression, e.g. replace \(x\) by \(4\) in \(3x - 1\).
  • +
  • Add or subtract equations term by term when both sides are equal.
  • +
+
+ +

Standard form

+

+ A convenient form is + \[ ax + by = c, \qquad dx + ey = f, \] + where \(a, b, c, d, e, f\) are constants. The same system can be written in other equivalent + forms — for example \(y = 2x + 1\) is the same as \(-2x + y = 1\). +

+ +

Substitution

+

+ Substitution means isolate one unknown in one equation, then replace that + expression in the other equation. +

+

Example. Solve

+

+ \[ \begin{cases} y = 2x + 1 \\ x + y = 7 \end{cases} \] +

+

+ From the first equation, \(y = 2x + 1\). Substitute into the second: + \[ x + (2x + 1) = 7 \Rightarrow 3x + 1 = 7 \Rightarrow 3x = 6 \Rightarrow x = 2. \] + Then \(y = 2(2) + 1 = 5\). The solution is \((2, 5)\). +

+

+ Check: \(2(2)+1 = 5\) and \(2 + 5 = 7\). +

+ +

Elimination

+

+ Elimination adds or subtracts the equations so that one unknown cancels. You + may need to multiply one or both equations first so that the coefficients of \(x\) or \(y\) match + in size (one positive, one negative). +

+

Example. Solve

+

+ \[ \begin{cases} x + y = 5 \\ 2x - y = 1 \end{cases} \] +

+

+ The coefficients of \(y\) are \(1\) and \(-1\), so add the equations: + \[ (x+y) + (2x-y) = 5 + 1 \Rightarrow 3x = 6 \Rightarrow x = 2. \] + Substitute into \(x + y = 5\): \(2 + y = 5\), so \(y = 3\). Solution: \((2, 3)\). +

+ +

Graphical interpretation

+

+ Each equation \(ax + by = c\) (with \(b \neq 0\)) can be rearranged to + \(y = -\frac{a}{b}x + \frac{c}{b}\), which is a straight line. The solution of the system is + the point where the two lines meet — their intersection. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
LinesAlgebraic pictureSolutions
Intersect at one pointDifferent slopesExactly one \((x, y)\)
Parallel, distinctSame slope, different interceptsNo solution
Coincident (same line)One equation is a multiple of the otherInfinitely many
+
+ +

Try it — graph explorer

+

+ Adjust the two lines or use presets. Read off the intersection and how the classification changes + when the lines are parallel or coincident. +

+
+ +
+ +
+

Try it — step-by-step solver

+

+ Pick a preset system and watch substitution or elimination steps. Systems with no unique + solution show an explanation instead of incorrect steps. +

+
+ +
+ +
+

Word problems

+

+ For story problems, define unknowns clearly, write two independent equations, then solve + by substitution, elimination, or a graph. +

+

Example (tickets). Adult tickets cost \$12 and child tickets \$7. A group buys + 10 tickets for \$95. How many of each type?

+

+ Let \(a\) be the number of adult tickets and \(c\) the number of child tickets. Then + \(a + c = 10\) and \(12a + 7c = 95\). From the first, \(c = 10 - a\). Substitute: + \[ 12a + 7(10 - a) = 95 \Rightarrow 12a + 70 - 7a = 95 \Rightarrow 5a = 25 \Rightarrow a = 5, \] + so \(c = 5\). Five adult and five child tickets. +

+
diff --git a/content/math/linear-equations-two-unknowns/supplement.html b/content/math/linear-equations-two-unknowns/supplement.html new file mode 100644 index 0000000..3c394cd --- /dev/null +++ b/content/math/linear-equations-two-unknowns/supplement.html @@ -0,0 +1,234 @@ +
+

History

+

+ Problems that need two unknowns at once are ancient. The Chinese text + Nine Chapters on the Mathematical Art (around 200 BCE, with later commentary) + includes fangcheng methods — arranging coefficients in a table and eliminating + unknowns, much like modern elimination. +

+

+ In the 9th century, al-Khwarizmi organised linear and quadratic problems in + systematic rules that spread to Europe. By the 17th century, Descartes linked + algebra with geometry: an equation in \(x\) and \(y\) describes a curve, so solving a system is + finding where two curves meet. Today, computers solve huge systems, but the same ideas — replace, + combine, or intersect — remain the core of school algebra. +

+ +

Derivation

+ +

Why elimination preserves solutions

+

+ If \((x_0, y_0)\) satisfies both \(ax + by = c\) and \(dx + ey = f\), then any + linear combination of the two equations is still true at \((x_0, y_0)\). For example, + adding the left sides and adding the right sides gives + \[ (a+d)x + (b+e)y = c + f, \] + which \((x_0, y_0)\) also satisfies. Elimination chooses combinations so one unknown disappears, + leaving a single equation in one unknown. Multiplying an equation by a non-zero constant before + adding does not change the solution set either. +

+ +

Why the graph solution is the intersection

+

+ The graph of \(ax + by = c\) is all points \((x, y)\) that make the equation true. A solution of + the system must lie on both graphs, so it must be a point common to both lines. + If the lines cross once, that point is the unique solution; if they never meet, there is no + solution; if they are the same line, every point on the line works. +

+ +

Checkpoints

+

+ Pause and reason before continuing. Discuss with a partner or write your work in a notebook. +

+ +
+

Checkpoint

+

+ In the graph explorer, use the “Parallel” preset. What does the status panel say about + solutions? Does the canvas show an intersection point? +

+
+ +
+

Checkpoint

+

+ Solve \(y = 3x - 2\) and \(2x + y = 8\) by substitution without a calculator. Does your pair + \((x, y)\) satisfy both originals? +

+
+ +
+

Checkpoint

+

+ For \(x + 2y = 8\) and \(2x + 4y = 16\), are the two equations independent, or is one a + multiple of the other? How many solutions do you expect? +

+
+ +
+

Checkpoint

+

+ When eliminating, why do we sometimes subtract equations instead of add them? Give an + example where subtracting cancels \(x\). +

+
+ +
+

Checkpoint

+

+ A shop sells notebooks for \$4 and pens for \$2. You buy 6 items for \$18. What two equations + describe this? Which method would you use first? +

+
+ +
+

Checkpoint

+

+ In the step-by-step workbench, run elimination on the ticket-style preset. At which step does + \(y\) (or your chosen variable) disappear? +

+
+ +

Applications

+ +

Ticket pricing

+

+ Cinemas and transport systems often use different prices for adults and children. If you know the + total number of tickets and the total cost, you have two equations in two unknowns — the counts + of each type. The same structure appears in fundraising dinners (plate vs concession) and sports + seating. +

+ +

Mixtures and concentrations

+

+ A chemist may mix a \(20\%\) solution with a \(50\%\) solution to obtain 2 L of + \(35\%\) solution. Let \(x\) and \(y\) be the volumes (in litres) of the weaker and stronger + solutions. Then \(x + y = 2\) (total volume) and \(0.2x + 0.5y = 0.35 \times 2\) (total amount + of pure solute). Solving the system gives the recipe. +

+ +

Two constraints in business

+

+ A small business might need at least 100 units to cover fixed costs and also stay within a + production cap of 150 units while hitting a revenue target. Each constraint is linear; the + feasible plan is where the constraints overlap — a preview of ideas used later in linear + programming. +

+ +

Question bank

+

+ Work each question, then open the solution to check your reasoning. Difficulty increases through + the sets. +

+ +

Easy

+ +
+ Solve by substitution: \(y = x + 2\) and \(x + y = 8\). +
+

Answer: \((3, 5)\).

+

Steps: Substitute \(y = x + 2\) into \(x + y = 8\): \(x + (x+2) = 8\), so \(2x = 6\), \(x = 3\), \(y = 5\).

+
+
+ +
+ Solve by elimination: \(x + y = 6\) and \(x - y = 2\). +
+

Answer: \((4, 2)\).

+

Steps: Add: \(2x = 8\), \(x = 4\). Then \(y = 6 - 4 = 2\).

+
+
+ +
+ Is \((1, 2)\) a solution of \(2x + y = 4\) and \(x - y = -1\)? +
+

Answer: Yes.

+

Check: \(2(1)+2 = 4\) and \(1 - 2 = -1\).

+
+
+ +

Intermediate

+ +
+ Solve: \(2x + y = 7\) and \(x - y = 2\). +
+

Answer: \((3, 1)\).

+

Steps: Add equations: \(3x = 9\), \(x = 3\). Then \(3 - y = 2\), \(y = 1\).

+
+
+ +
+ Solve: \(3x + 2y = 12\) and \(x + y = 5\). +
+

Answer: \((2, 3)\).

+

Steps: From \(x + y = 5\), \(y = 5 - x\). Substitute: \(3x + 2(5-x) = 12\), \(3x + 10 - 2x = 12\), \(x = 2\), \(y = 3\).

+
+
+ +
+ Two lines have equations \(y = 2x + 1\) and \(y = 2x - 3\). How many solutions does the system have? +
+

Answer: No solution (parallel lines).

+

Explanation: Same slope \(2\), different intercepts — the lines never meet.

+
+
+ +

Difficult

+ +
+ Solve: \(2x + 3y = 13\) and \(4x - y = 5\). +
+

Answer: \((2, 3)\).

+

Steps: From \(4x - y = 5\), \(y = 4x - 5\). Substitute into \(2x + 3y = 13\): \(2x + 3(4x-5) = 13\), \(2x + 12x - 15 = 13\), \(14x = 28\), \(x = 2\), \(y = 3\).

+
+
+ +
+ Eliminate \(x\): \(3x + 2y = 11\) and \(5x - 2y = 13\). +
+

Answer: \((3, 1)\).

+

Steps: Add equations: \(8x = 24\), \(x = 3\). Then \(3(3) + 2y = 11\), \(2y = 2\), \(y = 1\).

+
+
+ +
+ A purse has 12 coins, all 5¢ or 10¢, with total value 95¢. How many of each? +
+

Answer: 5 coins at 5¢ and 7 coins at 10¢.

+

Steps: Let \(n\) be 5¢ coins and \(m\) be 10¢ coins. Then \(n + m = 12\) and \(5n + 10m = 95\). From the first, \(n = 12 - m\). Substitute: \(5(12-m) + 10m = 95\), \(60 - 5m + 10m = 95\), \(5m = 35\), \(m = 7\), \(n = 5\).

+
+
+ +

Hardcore

+ +
+ For what value of \(k\) does \(x + ky = 4\) and \(2x + 2ky = 8\) have infinitely many solutions? +
+

Answer: Every \(k\) (the second equation is \(2\times\) the first).

+

Explanation: The lines are coincident for all \(k\) where the second is a multiple of the first; here they are always multiples, so infinitely many solutions.

+
+
+ +
+ Solve: \(\dfrac{x}{2} + y = 5\) and \(x - y = 1\). +
+

Answer: \((4, 3)\).

+

Steps: Multiply the first equation by 2: \(x + 2y = 10\). With \(x - y = 1\), substitute \(x = 1 + y\) into \(x + 2y = 10\): \(1 + y + 2y = 10\), \(3y = 9\), \(y = 3\), \(x = 4\).

+
+
+ +
+ A rectangle has perimeter 26 cm. The length is 4 cm more than the width. Find the dimensions. +
+

Answer: width \(4.5\) cm, length \(8.5\) cm.

+

Steps: \(2\ell + 2w = 26\), \(\ell = w + 4\). Substitute: \(2(w+4)+2w=26\), \(4w+8=26\), \(w=4.5\), \(\ell=8.5\).

+
+
+ +

References

+
    +
  • The Nine Chapters on the Mathematical Art — early elimination methods (fangcheng).
  • +
  • al-Khwarizmi, Al-Kitāb al-mukhtaṣar fī ḥisāb al-jabr wal-muqābala (c. 820 CE).
  • +
  • René Descartes, La Géométrie (1637) — coordinates and curves.
  • +
  • HKDSE Compulsory Part curriculum — Number and Algebra strand (equations in two unknowns).
  • +
+
diff --git a/content/math/linear-equations-two-unknowns/widgets/simultaneous-graph-explorer.tsx b/content/math/linear-equations-two-unknowns/widgets/simultaneous-graph-explorer.tsx new file mode 100644 index 0000000..1bd2c07 --- /dev/null +++ b/content/math/linear-equations-two-unknowns/widgets/simultaneous-graph-explorer.tsx @@ -0,0 +1,608 @@ +import { renderLatex } from "@ofb/katex"; +import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"; + +const VIEW_SIZE = 400; +const UNIT = 20; +const ORIGIN = VIEW_SIZE / 2; +const X_RANGE = 10; +const LINE_SAMPLE_STEP = 0.12; +const INTERSECTION_HIT_SCREEN = 28; +const LINE_HIT_SCREEN = 14; +const HOVER_RADIUS = 8; +const POINT_RADIUS = 5; + +type GraphHover = { + kind: "intersection" | "line1" | "line2"; + x: number; + y: number; + label: string; + canvasX: number; + canvasY: number; +}; + +type SystemKind = "unique" | "parallel" | "coincident"; + +type Preset = { + id: string; + label: string; + m1: number; + c1: number; + m2: number; + c2: number; +}; + +const PRESETS: Preset[] = [ + { id: "unique", label: "One solution", m1: 1, c1: 1, m2: -1, c2: 5 }, + { id: "parallel", label: "Parallel", m1: 2, c1: 1, m2: 2, c2: -3 }, + { id: "coincident", label: "Coincident", m1: 1, c1: 2, m2: 1, c2: 2 }, +]; + +function round(n: number, digits = 2) { + const p = 10 ** digits; + return Math.round(n * p) / p; +} + +function formatNum(n: number) { + if (Number.isInteger(n)) return String(n); + return round(n, 2).toString(); +} + +function formatCoord(x: number, y: number) { + return `(${formatNum(x)}, ${formatNum(y)})`; +} + +function toCanvasX(x: number) { + return ORIGIN + x * UNIT; +} + +function toCanvasY(y: number) { + return ORIGIN - y * UNIT; +} + +function clamp(n: number, lo: number, hi: number) { + return Math.max(lo, Math.min(hi, n)); +} + +function pointerToCanvas( + canvas: HTMLCanvasElement, + clientX: number, + clientY: number +) { + const rect = canvas.getBoundingClientRect(); + const scaleX = VIEW_SIZE / rect.width; + const scaleY = VIEW_SIZE / rect.height; + const px = (clientX - rect.left) * scaleX; + const py = (clientY - rect.top) * scaleY; + return { px, py }; +} + +function distPx(px1: number, py1: number, px2: number, py2: number) { + return Math.hypot(px1 - px2, py1 - py2); +} + +function canvasHitRadius(canvas: HTMLCanvasElement, screenRadius: number) { + const rect = canvas.getBoundingClientRect(); + const scale = rect.width > 0 ? VIEW_SIZE / rect.width : 1; + return screenRadius * scale; +} + +function nearestLineHover( + m: number, + c: number, + px: number, + py: number +): { dist: number; x: number; y: number; canvasX: number; canvasY: number } | null { + let bestX = 0; + let bestY = 0; + let bestDist = Infinity; + + for (let x = -X_RANGE; x <= X_RANGE; x += LINE_SAMPLE_STEP) { + const y = m * x + c; + if (y < -X_RANGE || y > X_RANGE) continue; + const d = distPx(px, py, toCanvasX(x), toCanvasY(y)); + if (d < bestDist) { + bestDist = d; + bestX = x; + bestY = y; + } + } + + if (!Number.isFinite(bestDist)) return null; + + return { + dist: bestDist, + x: bestX, + y: bestY, + canvasX: toCanvasX(bestX), + canvasY: toCanvasY(bestY), + }; +} + +function hitTestHover( + canvas: HTMLCanvasElement, + m1: number, + c1: number, + m2: number, + c2: number, + meet: { x: number; y: number } | null, + px: number, + py: number +): GraphHover | null { + const intersectionRadius = canvasHitRadius(canvas, INTERSECTION_HIT_SCREEN); + const lineRadius = canvasHitRadius(canvas, LINE_HIT_SCREEN); + + if (meet) { + const cx = toCanvasX(meet.x); + const cy = toCanvasY(meet.y); + if ( + meet.x >= -X_RANGE && + meet.x <= X_RANGE && + meet.y >= -X_RANGE && + meet.y <= X_RANGE && + distPx(px, py, cx, cy) < intersectionRadius + ) { + return { + kind: "intersection", + x: meet.x, + y: meet.y, + label: "Intersection", + canvasX: cx, + canvasY: cy, + }; + } + } + + const near1 = nearestLineHover(m1, c1, px, py); + const near2 = nearestLineHover(m2, c2, px, py); + + const candidates: Array<{ dist: number; hover: GraphHover }> = []; + if (near1 && near1.dist < lineRadius) { + candidates.push({ + dist: near1.dist, + hover: { + kind: "line1", + x: near1.x, + y: near1.y, + label: "Line 1", + canvasX: near1.canvasX, + canvasY: near1.canvasY, + }, + }); + } + if (near2 && near2.dist < lineRadius) { + candidates.push({ + dist: near2.dist, + hover: { + kind: "line2", + x: near2.x, + y: near2.y, + label: "Line 2", + canvasX: near2.canvasX, + canvasY: near2.canvasY, + }, + }); + } + + if (candidates.length === 0) return null; + candidates.sort((a, b) => a.dist - b.dist); + return candidates[0].hover; +} + +function classify(m1: number, c1: number, m2: number, c2: number): SystemKind { + if (Math.abs(m1 - m2) < 1e-9) { + return Math.abs(c1 - c2) < 1e-9 ? "coincident" : "parallel"; + } + return "unique"; +} + +function intersection( + m1: number, + c1: number, + m2: number, + c2: number +): { x: number; y: number } | null { + const kind = classify(m1, c1, m2, c2); + if (kind !== "unique") return null; + const x = (c2 - c1) / (m1 - m2); + const y = m1 * x + c1; + return { x, y }; +} + +function readCanvasColors(canvas: HTMLCanvasElement) { + const style = getComputedStyle(canvas); + return { + grid: style.getPropertyValue("--math-grid").trim() || "#e5e5e5", + axis: style.getPropertyValue("--math-axis").trim() || "#a8a29e", + line1: style.getPropertyValue("--math-curve").trim() || "#9a6b2e", + line2: style.getPropertyValue("--math-line2").trim() || "#2563eb", + intersection: style.getPropertyValue("--math-root").trim() || "#2563eb", + bg: style.getPropertyValue("--math-canvas-bg").trim() || "#ffffff", + }; +} + +function drawGraph( + canvas: HTMLCanvasElement, + m1: number, + c1: number, + m2: number, + c2: number, + point: { x: number; y: number } | null, + hover: GraphHover | null +) { + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = VIEW_SIZE * dpr; + canvas.height = VIEW_SIZE * dpr; + canvas.style.width = `${VIEW_SIZE}px`; + canvas.style.height = `${VIEW_SIZE}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const colors = readCanvasColors(canvas); + + ctx.fillStyle = colors.bg; + ctx.fillRect(0, 0, VIEW_SIZE, VIEW_SIZE); + + ctx.strokeStyle = colors.grid; + ctx.lineWidth = 1; + for (let i = -X_RANGE; i <= X_RANGE; i++) { + ctx.beginPath(); + ctx.moveTo(toCanvasX(i), 0); + ctx.lineTo(toCanvasX(i), VIEW_SIZE); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, toCanvasY(i)); + ctx.lineTo(VIEW_SIZE, toCanvasY(i)); + ctx.stroke(); + } + + ctx.strokeStyle = colors.axis; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(ORIGIN, 0); + ctx.lineTo(ORIGIN, VIEW_SIZE); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, ORIGIN); + ctx.lineTo(VIEW_SIZE, ORIGIN); + ctx.stroke(); + + const drawLine = ( + m: number, + c: number, + color: string, + width: number, + active: boolean + ) => { + ctx.strokeStyle = color; + ctx.lineWidth = active ? width + 1.5 : width; + ctx.lineCap = "round"; + ctx.beginPath(); + let started = false; + for (let x = -X_RANGE; x <= X_RANGE; x += 0.1) { + const y = m * x + c; + if (y < -X_RANGE || y > X_RANGE) continue; + const px = toCanvasX(x); + const py = toCanvasY(y); + if (!started) { + ctx.moveTo(px, py); + started = true; + } else { + ctx.lineTo(px, py); + } + } + ctx.stroke(); + }; + + drawLine(m1, c1, colors.line1, 3, hover?.kind === "line1"); + drawLine(m2, c2, colors.line2, 3, hover?.kind === "line2"); + + const drawMarker = (cx: number, cy: number, fill: string, active: boolean) => { + const r = active ? HOVER_RADIUS : POINT_RADIUS; + ctx.fillStyle = fill; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fill(); + if (active) { + ctx.strokeStyle = colors.bg; + ctx.lineWidth = 2; + ctx.stroke(); + } + }; + + if (point && point.x >= -X_RANGE && point.x <= X_RANGE && point.y >= -X_RANGE && point.y <= X_RANGE) { + const cx = toCanvasX(point.x); + const cy = toCanvasY(point.y); + const active = hover?.kind === "intersection"; + drawMarker(cx, cy, colors.intersection, active); + } + + if (hover?.kind === "line1") { + drawMarker(hover.canvasX, hover.canvasY, colors.line1, true); + } + if (hover?.kind === "line2") { + drawMarker(hover.canvasX, hover.canvasY, colors.line2, true); + } +} + +function SliderRow(props: { + id: string; + label: string; + value: number; + min: number; + max: number; + step: number; + accentClass?: string; + onInput: (v: number) => void; +}) { + return ( +
+ + props.onInput(Number(e.currentTarget.value))} + /> +
+ ); +} + +function natureLabel(kind: SystemKind) { + if (kind === "unique") return "Exactly one solution (lines intersect)"; + if (kind === "parallel") return "No solution (parallel lines)"; + return "Infinitely many solutions (coincident lines)"; +} + +function SimultaneousGraphExplorer() { + const [m1, setM1] = createSignal(1); + const [c1, setC1] = createSignal(1); + const [m2, setM2] = createSignal(-1); + const [c2, setC2] = createSignal(5); + const [activePreset, setActivePreset] = createSignal(null); + const [hover, setHover] = createSignal(null); + const [tooltipPos, setTooltipPos] = createSignal({ left: 0, top: 0 }); + let canvasRef: HTMLCanvasElement | undefined; + let wrapRef: HTMLDivElement | undefined; + + const kind = createMemo(() => classify(m1(), c1(), m2(), c2())); + const meet = createMemo(() => intersection(m1(), c1(), m2(), c2())); + + const eq1Html = createMemo(() => + renderLatex(`y = ${formatNum(m1())}x + ${formatNum(c1())}`) + ); + const eq2Html = createMemo(() => + renderLatex(`y = ${formatNum(m2())}x + ${formatNum(c2())}`) + ); + + const applyPreset = (p: Preset) => { + setM1(p.m1); + setC1(p.c1); + setM2(p.m2); + setC2(p.c2); + setActivePreset(p.id); + }; + + const onSliderChange = (setter: (v: number) => void) => (v: number) => { + setActivePreset(null); + setter(v); + }; + + const redraw = () => { + if (canvasRef) { + drawGraph(canvasRef, m1(), c1(), m2(), c2(), meet(), hover()); + } + }; + + createEffect(() => { + m1(); + c1(); + m2(); + c2(); + meet(); + hover(); + redraw(); + }); + + const updateTooltipPosition = (clientX: number, clientY: number) => { + if (!wrapRef) return; + const rect = wrapRef.getBoundingClientRect(); + const pad = 12; + let left = clientX - rect.left + pad; + let top = clientY - rect.top - pad; + const maxLeft = rect.width - 140; + const maxTop = rect.height - 48; + left = clamp(left, pad, maxLeft); + top = clamp(top, pad, maxTop); + setTooltipPos({ left, top }); + }; + + createEffect(() => { + const canvas = canvasRef; + if (!canvas) return; + + const onMove = (e: PointerEvent) => { + const { px, py } = pointerToCanvas(canvas, e.clientX, e.clientY); + const hit = hitTestHover(canvas, m1(), c1(), m2(), c2(), meet(), px, py); + setHover(hit); + if (hit) { + updateTooltipPosition(e.clientX, e.clientY); + canvas.setAttribute("aria-label", `${hit.label} at ${formatCoord(hit.x, hit.y)}`); + } else { + canvas.setAttribute( + "aria-label", + "Graph of two lines. Hover a line or intersection to read coordinates." + ); + } + }; + + const onLeave = () => { + setHover(null); + canvas.setAttribute( + "aria-label", + "Graph of two lines. Hover a line or intersection to read coordinates." + ); + }; + + canvas.addEventListener("pointermove", onMove); + canvas.addEventListener("pointerleave", onLeave); + canvas.addEventListener("pointerdown", onMove); + + onCleanup(() => { + canvas.removeEventListener("pointermove", onMove); + canvas.removeEventListener("pointerleave", onLeave); + canvas.removeEventListener("pointerdown", onMove); + }); + }); + + return ( +
+

+ Simultaneous equations graph explorer +

+ +
+
+
+ {PRESETS.map((p) => ( + + ))} +
+ +
+

Line 1

+
+
+
+

Line 2

+
+
+ +

Line 1 (y = m₁x + c₁)

+ + + +

Line 2 (y = m₂x + c₂)

+ + + +
+

System

+

{natureLabel(kind())}

+ + {(p) => ( +

+ Intersection at {formatCoord(p().x, p().y)} +

+ )} +
+
+
+ +
+
{ + wrapRef = el; + }} + > + { + canvasRef = el; + }} + class="math-widget__canvas" + classList={{ "math-widget__canvas--active": !!hover() }} + width={VIEW_SIZE} + height={VIEW_SIZE} + role="img" + aria-label="Graph of two lines. Hover a line or intersection to read coordinates." + /> + + {(h) => ( +
+ {h().label} + {formatCoord(h().x, h().y)} +
+ )} +
+
+
+ Hover a line to read coordinates along it; near an intersection the cursor snaps to the + solution point. Grid: −10 to 10 on both axes. +
+
+
+
+ ); +} + +export default SimultaneousGraphExplorer; diff --git a/content/math/linear-equations-two-unknowns/widgets/simultaneous-solver-workbench.tsx b/content/math/linear-equations-two-unknowns/widgets/simultaneous-solver-workbench.tsx new file mode 100644 index 0000000..22dac17 --- /dev/null +++ b/content/math/linear-equations-two-unknowns/widgets/simultaneous-solver-workbench.tsx @@ -0,0 +1,512 @@ +import { renderLatex } from "@ofb/katex"; +import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; + +type Eq = [number, number, number]; +type Method = "substitution" | "elimination"; + +type Preset = { + id: string; + label: string; + eq1: Eq; + eq2: Eq; + methods: Method[]; +}; + +type Step = { + title: string; + latex: string; +}; + +const PRESETS: Preset[] = [ + { + id: "add-y", + label: "x + y = 5, 2x − y = 1", + eq1: [1, 1, 5], + eq2: [2, -1, 1], + methods: ["elimination", "substitution"], + }, + { + id: "sub-y", + label: "y = 2x + 1, x + y = 7", + eq1: [-2, 1, 1], + eq2: [1, 1, 7], + methods: ["substitution", "elimination"], + }, + { + id: "coins", + label: "x + y = 8, 5x + 2y = 28", + eq1: [1, 1, 8], + eq2: [5, 2, 28], + methods: ["substitution", "elimination"], + }, + { + id: "parallel", + label: "x + y = 5, 2x + 2y = 12 (parallel)", + eq1: [1, 1, 5], + eq2: [2, 2, 12], + methods: ["elimination"], + }, +]; + +function gcd(a: number, b: number): number { + a = Math.abs(Math.round(a)); + b = Math.abs(Math.round(b)); + while (b) { + const t = b; + b = a % b; + a = t; + } + return a || 1; +} + +function lcm(a: number, b: number): number { + return (Math.abs(a) * Math.abs(b)) / gcd(a, b); +} + +function formatTerm(coef: number, variable: string, first: boolean): string { + if (coef === 0) return ""; + const abs = Math.abs(coef); + const sign = + coef < 0 ? (first ? "-" : " - ") : first ? "" : " + "; + const body = abs === 1 ? variable : `${abs}${variable}`; + return `${sign}${body}`; +} + +function formatEqLatex([a, b, c]: Eq): string { + const xPart = formatTerm(a, "x", true); + const yPart = formatTerm(b, "y", !xPart); + const left = (xPart + yPart).trim() || "0"; + return `${left} = ${c}`; +} + +function determinant(eq1: Eq, eq2: Eq): number { + const [a1, b1] = eq1; + const [a2, b2] = eq2; + return a1 * b2 - a2 * b1; +} + +function solveSystem(eq1: Eq, eq2: Eq): { x: number; y: number } | null { + const det = determinant(eq1, eq2); + if (Math.abs(det) < 1e-9) return null; + const [a1, b1, c1] = eq1; + const [a2, b2, c2] = eq2; + const x = (c1 * b2 - c2 * b1) / det; + const y = (a1 * c2 - a2 * c1) / det; + return { x: round(x), y: round(y) }; +} + +function round(n: number, digits = 4) { + const p = 10 ** digits; + return Math.round(n * p) / p; +} + +function formatNum(n: number) { + if (Number.isInteger(n)) return String(n); + return round(n, 2).toString(); +} + +function classifySystem(eq1: Eq, eq2: Eq): "unique" | "parallel" | "coincident" { + const det = determinant(eq1, eq2); + if (Math.abs(det) > 1e-9) return "unique"; + const [a1, b1, c1] = eq1; + const [a2, b2, c2] = eq2; + const ratio = + Math.abs(a2) > 1e-9 + ? a1 / a2 + : Math.abs(b2) > 1e-9 + ? b1 / b2 + : 1; + const same = + Math.abs(a1 - ratio * a2) < 1e-9 && + Math.abs(b1 - ratio * b2) < 1e-9 && + Math.abs(c1 - ratio * c2) < 1e-9; + return same ? "coincident" : "parallel"; +} + +function buildSubstitutionSteps(eq1: Eq, eq2: Eq, solution: { x: number; y: number }): Step[] { + const [a1, b1, c1] = eq1; + const steps: Step[] = [ + { + title: "Write the system", + latex: `\\begin{cases} ${formatEqLatex(eq1)} \\\\ ${formatEqLatex(eq2)} \\end{cases}`, + }, + ]; + + if (b1 === 1 && a1 === 0) { + steps.push({ + title: "Isolate y from equation (1)", + latex: `y = ${formatNum(c1)}`, + }); + steps.push({ + title: "Substitute into equation (2)", + latex: `${formatTerm(eq2[0], "x", true)}${formatTerm(1, `(${formatNum(c1)})`, false)} = ${eq2[2]}`, + }); + } else if (b1 === -2 && a1 === 0 && c1 === 1) { + steps.push({ + title: "Rewrite equation (1) as y = 2x + 1", + latex: "y = 2x + 1", + }); + steps.push({ + title: "Substitute into equation (2)", + latex: "x + (2x + 1) = 7", + }); + steps.push({ + title: "Collect like terms", + latex: "3x + 1 = 7", + }); + steps.push({ + title: "Solve for x", + latex: `3x = 6 \\Rightarrow x = ${formatNum(solution.x)}`, + }); + steps.push({ + title: "Find y", + latex: `y = 2(${formatNum(solution.x)}) + 1 = ${formatNum(solution.y)}`, + }); + } else if (eq2[1] === 1 && eq2[0] === 1) { + steps.push({ + title: "Express y from equation (2)", + latex: `y = ${eq2[2]} - x`, + }); + steps.push({ + title: "Substitute into equation (1)", + latex: `${formatTerm(eq1[0], "x", true)}${formatTerm(eq1[1], `(${eq2[2]} - x)`, false)} = ${c1}`, + }); + const combined = eq1[0] - eq1[1]; + const constant = c1 - eq1[1] * eq2[2]; + steps.push({ + title: "Simplify", + latex: `${formatTerm(combined, "x", true)} = ${constant}`, + }); + steps.push({ + title: "Solve for x", + latex: `x = ${formatNum(solution.x)}`, + }); + steps.push({ + title: "Find y", + latex: `y = ${eq2[2]} - ${formatNum(solution.x)} = ${formatNum(solution.y)}`, + }); + } else { + steps.push({ + title: "Isolate one unknown, then substitute", + latex: `\\text{Solve one equation for } x \\text{ or } y, \\text{ substitute into the other.}`, + }); + steps.push({ + title: "Solution", + latex: `(x, y) = (${formatNum(solution.x)},\\; ${formatNum(solution.y)})`, + }); + return steps; + } + + steps.push({ + title: "Solution", + latex: `(x, y) = (${formatNum(solution.x)},\\; ${formatNum(solution.y)})`, + }); + return steps; +} + +function buildEliminationSteps(eq1: Eq, eq2: Eq, solution: { x: number; y: number }): Step[] { + const [a1, b1, c1] = eq1; + const [a2, b2, c2] = eq2; + const steps: Step[] = [ + { + title: "Write the system", + latex: `\\begin{cases} ${formatEqLatex(eq1)} \\quad (1) \\\\ ${formatEqLatex(eq2)} \\quad (2) \\end{cases}`, + }, + ]; + + if (b1 !== 0 && b2 !== 0 && b1 === -b2) { + steps.push({ + title: "Add (1) and (2) to eliminate y", + latex: `${formatTerm(a1 + a2, "x", true)} = ${c1 + c2}`, + }); + steps.push({ + title: "Solve for x", + latex: `x = ${formatNum(solution.x)}`, + }); + steps.push({ + title: "Substitute into (1)", + latex: `${formatNum(solution.x)} + y = ${c1} \\Rightarrow y = ${formatNum(solution.y)}`, + }); + } else if (a1 !== 0 && a2 !== 0 && a1 === -a2) { + steps.push({ + title: "Add (1) and (2) to eliminate x", + latex: `${formatTerm(b1 + b2, "y", true)} = ${c1 + c2}`, + }); + steps.push({ + title: "Solve for y", + latex: `y = ${formatNum(solution.y)}`, + }); + steps.push({ + title: "Substitute into (1)", + latex: `x = ${formatNum(solution.x)}`, + }); + } else { + const mult1 = lcm(b1, b2) / Math.abs(b1); + const mult2 = lcm(b1, b2) / Math.abs(b2); + const newB1 = b1 * mult1; + const newB2 = b2 * mult2; + steps.push({ + title: "Match coefficients of y", + latex: `\\text{Multiply (1) by } ${mult1}, \\text{ (2) by } ${mult2} \\text{ so y terms cancel.}`, + }); + const op = newB1 + newB2 === 0 ? "+" : "−"; + steps.push({ + title: `Combine equations (${op})`, + latex: `${formatTerm(a1 * mult1 + (op === "+" ? a2 * mult2 : -a2 * mult2), "x", true)} = ${op === "+" ? c1 * mult1 + c2 * mult2 : c1 * mult1 - c2 * mult2}`, + }); + steps.push({ + title: "Solve and back-substitute", + latex: `x = ${formatNum(solution.x)},\\quad y = ${formatNum(solution.y)}`, + }); + } + + steps.push({ + title: "Solution", + latex: `(x, y) = (${formatNum(solution.x)},\\; ${formatNum(solution.y)})`, + }); + return steps; +} + +function buildSteps( + eq1: Eq, + eq2: Eq, + method: Method +): { steps: Step[]; error: string | null } { + const kind = classifySystem(eq1, eq2); + if (kind === "parallel") { + return { + steps: [], + error: "These lines are parallel — the system has no solution. Elimination would lead to a false statement such as 0 = 7.", + }; + } + if (kind === "coincident") { + return { + steps: [], + error: "The two equations describe the same line — infinitely many solutions. A single (x, y) pair is not enough.", + }; + } + + const solution = solveSystem(eq1, eq2); + if (!solution) { + return { steps: [], error: "Could not solve this system." }; + } + + const steps = + method === "substitution" + ? buildSubstitutionSteps(eq1, eq2, solution) + : buildEliminationSteps(eq1, eq2, solution); + + return { steps, error: null }; +} + +function SimultaneousSolverWorkbench() { + const [presetId, setPresetId] = createSignal(PRESETS[0].id); + const [method, setMethod] = createSignal("elimination"); + const [stepIndex, setStepIndex] = createSignal(0); + const [playing, setPlaying] = createSignal(false); + const [speed, setSpeed] = createSignal(1200); + + const preset = createMemo(() => PRESETS.find((p) => p.id === presetId()) ?? PRESETS[0]); + + const availableMethods = createMemo(() => preset().methods); + + const result = createMemo(() => + buildSteps(preset().eq1, preset().eq2, method()) + ); + + const steps = createMemo(() => result().steps); + const error = createMemo(() => result().error); + const current = createMemo(() => steps()[stepIndex()] ?? null); + + const progress = createMemo(() => + steps().length > 0 ? ((stepIndex() + 1) / steps().length) * 100 : 0 + ); + + createEffect(() => { + presetId(); + method(); + setStepIndex(0); + setPlaying(false); + }); + + createEffect(() => { + if (!playing() || error()) return; + const id = window.setInterval(() => { + setStepIndex((i) => { + if (i >= steps().length - 1) { + setPlaying(false); + return i; + } + return i + 1; + }); + }, speed()); + onCleanup(() => window.clearInterval(id)); + }); + + createEffect(() => { + if (method() === "substitution" && !availableMethods().includes("substitution")) { + setMethod(availableMethods()[0]); + } + if (method() === "elimination" && !availableMethods().includes("elimination")) { + setMethod(availableMethods()[0]); + } + }); + + const eqDisplay = createMemo(() => + renderLatex( + `\\begin{cases} ${formatEqLatex(preset().eq1)} \\\\ ${formatEqLatex(preset().eq2)} \\end{cases}` + ) + ); + + return ( +
+

+ Substitution & elimination step-by-step +

+ +
+ + +
+
+ + Step {stepIndex() + 1} of {steps().length} + {current() ? `: ${current()!.title}` : ""} +

+ } + > +

+ {error()} +

+
+
+
+
+
+ +
+

System

+
+
+ + + {(step) => ( +
+

{step().title}

+
+
+ )} + +
+
+
+ ); +} + +export default SimultaneousSolverWorkbench; diff --git a/content/math/polynomials-expansion-factorisation/widgets/area-model.tsx b/content/math/polynomials-expansion-factorisation/widgets/area-model.tsx index 70d04b6..8db0c1d 100644 --- a/content/math/polynomials-expansion-factorisation/widgets/area-model.tsx +++ b/content/math/polynomials-expansion-factorisation/widgets/area-model.tsx @@ -1,15 +1,10 @@ -import katex from "katex"; +import { renderLatex, renderMathInContainer } from "@ofb/katex"; import { createEffect, createMemo, createSignal } from "solid-js"; -import { renderMathInContainer } from "../../../../frontend/src/lib/render-math"; function formatNum(n: number) { return String(n); } -function renderLatex(latex: string) { - return katex.renderToString(latex, { throwOnError: false, strict: "ignore" }); -} - function AreaModel() { const [a, setA] = createSignal(2); const [b, setB] = createSignal(3); diff --git a/content/math/properties-of-operations/widgets/associative-demo.tsx b/content/math/properties-of-operations/widgets/associative-demo.tsx index b63454d..458d989 100644 --- a/content/math/properties-of-operations/widgets/associative-demo.tsx +++ b/content/math/properties-of-operations/widgets/associative-demo.tsx @@ -1,10 +1,6 @@ -import katex from "katex"; +import { renderLatex } from "@ofb/katex"; import { createMemo, createSignal, For } from "solid-js"; -function renderLatex(latex: string) { - return katex.renderToString(latex, { throwOnError: false, strict: "ignore" }); -} - function DotBlock(props: { count: number; tone: "x" | "y" | "z" }) { return (
diff --git a/content/math/properties-of-operations/widgets/commutative-demo.tsx b/content/math/properties-of-operations/widgets/commutative-demo.tsx index 711b1e9..b9ef4d8 100644 --- a/content/math/properties-of-operations/widgets/commutative-demo.tsx +++ b/content/math/properties-of-operations/widgets/commutative-demo.tsx @@ -1,10 +1,6 @@ -import katex from "katex"; +import { renderLatex } from "@ofb/katex"; import { createMemo, createSignal, For } from "solid-js"; -function renderLatex(latex: string) { - return katex.renderToString(latex, { throwOnError: false, strict: "ignore" }); -} - function DotRow(props: { count: number; class?: string }) { return (
diff --git a/content/math/properties-of-operations/widgets/distributive-area-demo.tsx b/content/math/properties-of-operations/widgets/distributive-area-demo.tsx index 1b0faed..ca2d283 100644 --- a/content/math/properties-of-operations/widgets/distributive-area-demo.tsx +++ b/content/math/properties-of-operations/widgets/distributive-area-demo.tsx @@ -1,10 +1,6 @@ -import katex from "katex"; +import { renderLatex } from "@ofb/katex"; import { createMemo, createSignal } from "solid-js"; -function renderLatex(latex: string) { - return katex.renderToString(latex, { throwOnError: false, strict: "ignore" }); -} - function DistributiveAreaDemo() { const [a, setA] = createSignal(2); const [b, setB] = createSignal(3); diff --git a/content/math/quadratic-equations/widgets/_application-demos.tsx b/content/math/quadratic-equations/widgets/_application-demos.tsx index 02f3a00..e6aa96c 100644 --- a/content/math/quadratic-equations/widgets/_application-demos.tsx +++ b/content/math/quadratic-equations/widgets/_application-demos.tsx @@ -1,13 +1,9 @@ -import katex from "katex"; +import { renderLatex } from "@ofb/katex"; import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; import { computeRoots, discriminant, formatNum } from "../../../../frontend/src/lib/math-graph"; const HORIZONTAL_SPEED = 12; -function renderLatex(latex: string) { - return katex.renderToString(latex, { throwOnError: false, strict: "ignore" }); -} - function SliderRow(props: { id: string; label: string; diff --git a/content/math/quadratic-equations/widgets/explorer.tsx b/content/math/quadratic-equations/widgets/explorer.tsx index ac7abba..64d1635 100644 --- a/content/math/quadratic-equations/widgets/explorer.tsx +++ b/content/math/quadratic-equations/widgets/explorer.tsx @@ -1,4 +1,4 @@ -import katex from "katex"; +import { renderLatex } from "@ofb/katex"; import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"; const VIEW_SIZE = 400; @@ -67,10 +67,6 @@ function natureOfRoots(delta: number) { return "No real roots"; } -function renderLatex(latex: string) { - return katex.renderToString(latex, { throwOnError: false, strict: "ignore" }); -} - function readCanvasColors(canvas: HTMLCanvasElement) { const style = getComputedStyle(canvas); return { diff --git a/data/math-curriculum.json b/data/math-curriculum.json index 4bb1eaf..8529508 100644 --- a/data/math-curriculum.json +++ b/data/math-curriculum.json @@ -105,8 +105,8 @@ { "slug": "linear-equations-two-unknowns", "title": "Linear equations in two unknowns", - "description": "Solve simultaneous linear equations by substitution, elimination, and graphical interpretation.", - "status": "planned", + "description": "Solve simultaneous linear equations by substitution, elimination, and graphical interpretation; classify unique, parallel, and coincident cases.", + "status": "live", "curriculums": ["DSE"] }, { diff --git a/frontend/src/lib/katex.ts b/frontend/src/lib/katex.ts new file mode 100644 index 0000000..c747db5 --- /dev/null +++ b/frontend/src/lib/katex.ts @@ -0,0 +1,62 @@ +import katex from "katex"; +import renderMathInElement from "katex/contrib/auto-render"; +import "katex/dist/katex.min.css"; + +export type RenderLatexOptions = { + displayMode?: boolean; +}; + +const DELIMITERS = [ + { left: "$$", right: "$$", display: true }, + { left: "\\(", right: "\\)", display: false }, + { left: "\\[", right: "\\]", display: true }, +]; + +const AUTO_RENDER_OPTIONS = { + delimiters: DELIMITERS, + throwOnError: false, + strict: "ignore" as const, +}; + +/** Prose and question blocks that may contain \\( … \\) or \\[ … \\] delimiters. */ +const AUTO_RENDER_TARGETS = + ".book-prose, .book-callout, .book-formula, .book-question__prompt, .book-question__solution, .math-widget__caption"; + +/** + * Render a LaTeX string to HTML for use with Solid `innerHTML` (widgets, dynamic labels). + */ +export function renderLatex(latex: string, options: RenderLatexOptions = {}) { + return katex.renderToString(latex, { + throwOnError: false, + strict: "ignore", + displayMode: options.displayMode ?? false, + }); +} + +/** + * Auto-render delimiter-based math inside a single element (idempotent per element). + */ +export function renderMathInContainer(el: HTMLElement) { + if (el.dataset.katexRendered === "true") return; + renderMathInElement(el, AUTO_RENDER_OPTIONS); + el.dataset.katexRendered = "true"; +} + +/** + * Scan chapter / book content for math delimiters. Skips `.math-widget` trees (widgets use renderLatex). + */ +export function renderBookMath(root: ParentNode = document) { + const article = + root instanceof Document + ? root.querySelector("[data-pagefind-body]") + : root instanceof HTMLElement && root.matches("[data-pagefind-body]") + ? root + : root.querySelector("[data-pagefind-body]"); + + const scope: ParentNode = article ?? root; + + scope.querySelectorAll(AUTO_RENDER_TARGETS).forEach((el) => { + if (el.closest(".math-widget")) return; + renderMathInContainer(el); + }); +} diff --git a/frontend/src/lib/render-math.ts b/frontend/src/lib/render-math.ts index c12955e..6666a10 100644 --- a/frontend/src/lib/render-math.ts +++ b/frontend/src/lib/render-math.ts @@ -1,24 +1,3 @@ -import renderMathInElement from "katex/contrib/auto-render"; -import "katex/dist/katex.min.css"; - -const DELIMITERS = [ - { left: "$$", right: "$$", display: true }, - { left: "\\(", right: "\\)", display: false }, - { left: "\\[", right: "\\]", display: true }, -]; - -const RENDER_OPTIONS = { - delimiters: DELIMITERS, - throwOnError: false, - strict: "ignore" as const, -}; - -export function renderMathInContainer(el: HTMLElement) { - renderMathInElement(el, RENDER_OPTIONS); -} - -export function renderBookMath() { - document - .querySelectorAll(".book-prose, .book-callout, .math-widget__caption") - .forEach(renderMathInContainer); -} +/** @deprecated Import from `./katex` instead. Kept for existing paths. */ +export { renderBookMath, renderLatex, renderMathInContainer } from "./katex"; +export type { RenderLatexOptions } from "./katex"; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 47d160c..bef9e41 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -38,8 +38,8 @@ function mountFooterYear() { } } -function scheduleChapterMath() { - void import("./lib/render-math").then(({ renderBookMath }) => { +function scheduleBookMath() { + void import("./lib/katex").then(({ renderBookMath }) => { requestAnimationFrame(() => renderBookMath()); }); } @@ -50,11 +50,7 @@ function mountChapterWidgets() { const mounts = document.querySelectorAll("[data-widget]"); if (!article || !chapterKey) return; - // KaTeX must run on widget-less chapters too (renderBookMath was only chained after widgets). - if (mounts.length === 0) { - scheduleChapterMath(); - return; - } + if (mounts.length === 0) return; void import("./generated/chapter-widgets").then(({ chapterWidgetLoaders }) => { const loadPromises: Promise[] = []; @@ -76,11 +72,18 @@ function mountChapterWidgets() { } void Promise.all(loadPromises).then(() => { - scheduleChapterMath(); + scheduleBookMath(); }); }); } +/** Prose math runs immediately; widgets re-run after mount for any new captions. */ +function initBookMath() { + if (document.querySelector("[data-pagefind-body]")) { + scheduleBookMath(); + } +} + initTheme(); const config = readConfig(); @@ -131,6 +134,7 @@ if (chapterNavRoot) { }); } +initBookMath(); mountChapterWidgets(); const catalogRoot = document.getElementById("catalog-app"); diff --git a/themes/openfreebooks/templates/base.html b/themes/openfreebooks/templates/base.html index 8a9eab5..0a5d757 100644 --- a/themes/openfreebooks/templates/base.html +++ b/themes/openfreebooks/templates/base.html @@ -26,6 +26,7 @@ rel="stylesheet" /> +