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" /> +