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.

This commit is contained in:
Hananoshika Yomaru 2026-05-20 13:17:25 -07:00
parent 7636bb4a86
commit ede9a7aa63
28 changed files with 2289 additions and 111 deletions

View file

@ -57,6 +57,17 @@ Prefer **bootstrap** components that `querySelector` existing markup over re-ren
3. Hard-refresh browser. 3. Hard-refresh browser.
4. For production: `bun run build` then `wrangler deploy`. 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 ## Adding a feature
1. Add mount root in Tera template (empty `<div id="…">` or use existing). 1. Add mount root in Tera template (empty `<div id="…">` or use existing).

View file

@ -111,6 +111,7 @@ strand = "Number & Algebra"
- Use shared classes: `book-prose`, `book-prose__heading`, `book-callout`, etc. Copy from `content/math/quadratic-equations/`. - 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. - **Do not** mention DSE, IB, or other curriculum names in chapter HTML.
- Mount widgets with `<div class="math-widget-mount" data-widget="widget-name" data-pagefind-ignore></div>` (filename without `.tsx` = `data-widget` value). - Mount widgets with `<div class="math-widget-mount" data-widget="widget-name" data-pagefind-ignore></div>` (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) - 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 ### 4. Build and check

View file

@ -1,14 +1,6 @@
import katex from "katex"; import { renderLatex } from "@ofb/katex";
import { createMemo, createSignal, For } from "solid-js"; 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 FormulaId = "speed" | "density" | "ohm";
type SubjectId = string; type SubjectId = string;
@ -125,7 +117,7 @@ function FormulaRearrangeDemo() {
() => formula().subjects.find((s) => s.id === subjectId()) ?? formula().subjects[0] () => 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) => { const onFormulaChange = (id: FormulaId) => {
setFormulaId(id); setFormulaId(id);

View file

@ -1,10 +1,6 @@
import katex from "katex"; import { renderLatex } from "@ofb/katex";
import { createMemo, createSignal, For } from "solid-js"; import { createMemo, createSignal, For } from "solid-js";
function renderLatex(latex: string) {
return katex.renderToString(latex, { throwOnError: false, strict: "ignore", displayMode: true });
}
const PRESETS = [ const PRESETS = [
{ {
id: "difference-squares", id: "difference-squares",
@ -32,16 +28,16 @@ const PRESETS = [
}, },
] as const; ] as const;
type PresetId = (typeof PRESETS)[number]["id"];
function FractionSimplifyDemo() { function FractionSimplifyDemo() {
const [presetId, setPresetId] = createSignal(PRESETS[0].id); const [presetId, setPresetId] = createSignal<PresetId>(PRESETS[0].id);
const preset = createMemo(() => PRESETS.find((p) => p.id === presetId()) ?? PRESETS[0]); const preset = createMemo(() => PRESETS.find((p) => p.id === presetId()) ?? PRESETS[0]);
const originalHtml = createMemo(() => renderLatex(preset().original)); const originalHtml = createMemo(() => renderLatex(preset().original, { displayMode: true }));
const factoredHtml = createMemo(() => renderLatex(preset().factored)); const factoredHtml = createMemo(() => renderLatex(preset().factored, { displayMode: true }));
const simplifiedHtml = createMemo(() => renderLatex(preset().simplified)); const simplifiedHtml = createMemo(() => renderLatex(preset().simplified, { displayMode: true }));
const restrictionHtml = createMemo(() => const restrictionHtml = createMemo(() => renderLatex(preset().restriction));
katex.renderToString(preset().restriction, { throwOnError: false, strict: "ignore" })
);
return ( return (
<section <section
@ -57,7 +53,10 @@ function FractionSimplifyDemo() {
id="fraction-preset" id="fraction-preset"
class="formula-rearrange-demo__select" class="formula-rearrange-demo__select"
value={presetId()} value={presetId()}
onChange={(e) => setPresetId(e.currentTarget.value)} onChange={(e) => {
const next = PRESETS.find((p) => p.id === e.currentTarget.value);
if (next) setPresetId(next.id);
}}
> >
<For each={PRESETS}> <For each={PRESETS}>
{(p) => <option value={p.id}>{p.label}</option>} {(p) => <option value={p.id}>{p.label}</option>}

View file

@ -1,10 +1,6 @@
import katex from "katex"; import { renderLatex } from "@ofb/katex";
import { createMemo, createSignal, For } from "solid-js"; 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 }) { function DotPile(props: { count: number; label: string; class?: string }) {
return ( return (
<div class={`addition-groups__pile ${props.class ?? ""}`}> <div class={`addition-groups__pile ${props.class ?? ""}`}>

View file

@ -1,10 +1,6 @@
import katex from "katex"; import { renderLatex } from "@ofb/katex";
import { createMemo, createSignal, For } from "solid-js"; import { createMemo, createSignal, For } from "solid-js";
function renderLatex(latex: string) {
return katex.renderToString(latex, { throwOnError: false, strict: "ignore" });
}
function MultiplicationArray() { function MultiplicationArray() {
const [rows, setRows] = createSignal(3); const [rows, setRows] = createSignal(3);
const [cols, setCols] = createSignal(2); const [cols, setCols] = createSignal(2);

View file

@ -1,6 +1,5 @@
import katex from "katex"; import { renderLatex, renderMathInContainer } from "@ofb/katex";
import { createEffect, createMemo, createSignal } from "solid-js"; import { createEffect, createMemo, createSignal } from "solid-js";
import { renderMathInContainer } from "../../../../frontend/src/lib/render-math";
const MIN = -10; const MIN = -10;
const MAX = 10; const MAX = 10;
@ -13,10 +12,6 @@ function formatNum(n: number) {
return (Math.round(n * 100) / 100).toString(); return (Math.round(n * 100) / 100).toString();
} }
function renderLatex(latex: string) {
return katex.renderToString(latex, { throwOnError: false, strict: "ignore" });
}
function toX(value: number) { function toX(value: number) {
const t = (value - MIN) / (MAX - MIN); const t = (value - MIN) / (MAX - MIN);
return PAD + t * PLOT; return PAD + t * PLOT;

View file

@ -1,4 +1,4 @@
import katex from "katex"; import { renderLatex } from "@ofb/katex";
import { createMemo, createSignal } from "solid-js"; import { createMemo, createSignal } from "solid-js";
function formatNum(n: number) { function formatNum(n: number) {
@ -6,10 +6,6 @@ function formatNum(n: number) {
return (Math.round(n * 100) / 100).toString(); return (Math.round(n * 100) / 100).toString();
} }
function renderLatex(latex: string) {
return katex.renderToString(latex, { throwOnError: false, strict: "ignore" });
}
function RatioDemo() { function RatioDemo() {
const [partA, setPartA] = createSignal(2); const [partA, setPartA] = createSignal(2);
const [partB, setPartB] = createSignal(5); const [partB, setPartB] = createSignal(5);

View file

@ -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"
+++
<div class="book-prose">
<p>
A <strong>system of linear equations in two unknowns</strong> is a pair of equations in
variables \(x\) and \(y\), where each equation is linear (the highest power of each variable is
1). A <strong>solution</strong> is an ordered pair \((x, y)\) that satisfies
<em>both</em> equations at the same time.
</p>
<div class="book-callout" role="note">
<p class="book-callout__label">Assumed knowledge</p>
<ul>
<li>Expand and collect like terms, e.g. \(2(x+3) = 2x + 6\).</li>
<li>Substitute a value into an expression, e.g. replace \(x\) by \(4\) in \(3x - 1\).</li>
<li>Add or subtract equations term by term when both sides are equal.</li>
</ul>
</div>
<h2 id="standard-form" class="book-prose__heading">Standard form</h2>
<p>
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\).
</p>
<h2 id="substitution" class="book-prose__heading">Substitution</h2>
<p>
<strong>Substitution</strong> means isolate one unknown in one equation, then replace that
expression in the other equation.
</p>
<p><strong>Example.</strong> Solve</p>
<p class="book-formula">
\[ \begin{cases} y = 2x + 1 \\ x + y = 7 \end{cases} \]
</p>
<p>
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)\).
</p>
<p>
Check: \(2(2)+1 = 5\) and \(2 + 5 = 7\).
</p>
<h2 id="elimination" class="book-prose__heading">Elimination</h2>
<p>
<strong>Elimination</strong> 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).
</p>
<p><strong>Example.</strong> Solve</p>
<p class="book-formula">
\[ \begin{cases} x + y = 5 \\ 2x - y = 1 \end{cases} \]
</p>
<p>
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)\).
</p>
<h2 id="graphical" class="book-prose__heading">Graphical interpretation</h2>
<p>
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 <strong>intersection</strong>.
</p>
<div class="book-table-wrap">
<table class="book-table">
<thead>
<tr>
<th scope="col">Lines</th>
<th scope="col">Algebraic picture</th>
<th scope="col">Solutions</th>
</tr>
</thead>
<tbody>
<tr>
<td>Intersect at one point</td>
<td>Different slopes</td>
<td>Exactly one \((x, y)\)</td>
</tr>
<tr>
<td>Parallel, distinct</td>
<td>Same slope, different intercepts</td>
<td>No solution</td>
</tr>
<tr>
<td>Coincident (same line)</td>
<td>One equation is a multiple of the other</td>
<td>Infinitely many</td>
</tr>
</tbody>
</table>
</div>
<h2 id="try-graph" class="book-prose__heading">Try it — graph explorer</h2>
<p>
Adjust the two lines or use presets. Read off the intersection and how the classification changes
when the lines are parallel or coincident.
</p>
</div>
<div class="math-widget-mount" data-widget="simultaneous-graph-explorer" data-pagefind-ignore></div>
<div class="book-prose">
<h2 id="try-solver" class="book-prose__heading">Try it — step-by-step solver</h2>
<p>
Pick a preset system and watch substitution or elimination steps. Systems with no unique
solution show an explanation instead of incorrect steps.
</p>
</div>
<div class="math-widget-mount" data-widget="simultaneous-solver-workbench" data-pagefind-ignore></div>
<div class="book-prose">
<h2 id="word-problems" class="book-prose__heading">Word problems</h2>
<p>
For story problems, define unknowns clearly, write <em>two</em> independent equations, then solve
by substitution, elimination, or a graph.
</p>
<p><strong>Example (tickets).</strong> Adult tickets cost \$12 and child tickets \$7. A group buys
10 tickets for \$95. How many of each type?</p>
<p>
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.
</p>
</div>
<div class="book-prose">
<h2 id="history" class="book-prose__heading">History</h2>
<p>
Problems that need two unknowns at once are ancient. The Chinese text
<em>Nine Chapters on the Mathematical Art</em> (around 200&nbsp;BCE, with later commentary)
includes <strong>fangcheng</strong> methods — arranging coefficients in a table and eliminating
unknowns, much like modern elimination.
</p>
<p>
In the 9th century, <strong>al-Khwarizmi</strong> organised linear and quadratic problems in
systematic rules that spread to Europe. By the 17th century, <strong>Descartes</strong> 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.
</p>
<h2 id="derivation" class="book-prose__heading">Derivation</h2>
<h3 id="why-elimination" class="book-prose__heading">Why elimination preserves solutions</h3>
<p>
If \((x_0, y_0)\) satisfies both \(ax + by = c\) and \(dx + ey = f\), then any
<em>linear combination</em> 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.
</p>
<h3 id="why-intersection" class="book-prose__heading">Why the graph solution is the intersection</h3>
<p>
The graph of \(ax + by = c\) is all points \((x, y)\) that make the equation true. A solution of
the <em>system</em> must lie on <em>both</em> 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.
</p>
<h2 id="checkpoints" class="book-prose__heading">Checkpoints</h2>
<p>
Pause and reason before continuing. Discuss with a partner or write your work in a notebook.
</p>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
In the graph explorer, use the “Parallel” preset. What does the status panel say about
solutions? Does the canvas show an intersection point?
</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
Solve \(y = 3x - 2\) and \(2x + y = 8\) by substitution without a calculator. Does your pair
\((x, y)\) satisfy both originals?
</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
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?
</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
When eliminating, why do we sometimes <em>subtract</em> equations instead of add them? Give an
example where subtracting cancels \(x\).
</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
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?
</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
In the step-by-step workbench, run elimination on the ticket-style preset. At which step does
\(y\) (or your chosen variable) disappear?
</p>
</div>
<h2 id="applications" class="book-prose__heading">Applications</h2>
<h3 id="ticket-pricing" class="book-prose__heading">Ticket pricing</h3>
<p>
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.
</p>
<h3 id="mixtures" class="book-prose__heading">Mixtures and concentrations</h3>
<p>
A chemist may mix a \(20\%\) solution with a \(50\%\) solution to obtain 2&nbsp;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.
</p>
<h3 id="break-even" class="book-prose__heading">Two constraints in business</h3>
<p>
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.
</p>
<h2 id="question-bank" class="book-prose__heading">Question bank</h2>
<p>
Work each question, then open the solution to check your reasoning. Difficulty increases through
the sets.
</p>
<h3 id="questions-easy" class="book-prose__heading">Easy</h3>
<details class="book-question">
<summary class="book-question__prompt">Solve by substitution: \(y = x + 2\) and \(x + y = 8\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \((3, 5)\).</p>
<p><strong>Steps:</strong> Substitute \(y = x + 2\) into \(x + y = 8\): \(x + (x+2) = 8\), so \(2x = 6\), \(x = 3\), \(y = 5\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Solve by elimination: \(x + y = 6\) and \(x - y = 2\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \((4, 2)\).</p>
<p><strong>Steps:</strong> Add: \(2x = 8\), \(x = 4\). Then \(y = 6 - 4 = 2\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Is \((1, 2)\) a solution of \(2x + y = 4\) and \(x - y = -1\)?</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> Yes.</p>
<p><strong>Check:</strong> \(2(1)+2 = 4\) and \(1 - 2 = -1\).</p>
</div>
</details>
<h3 id="questions-intermediate" class="book-prose__heading">Intermediate</h3>
<details class="book-question">
<summary class="book-question__prompt">Solve: \(2x + y = 7\) and \(x - y = 2\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \((3, 1)\).</p>
<p><strong>Steps:</strong> Add equations: \(3x = 9\), \(x = 3\). Then \(3 - y = 2\), \(y = 1\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Solve: \(3x + 2y = 12\) and \(x + y = 5\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \((2, 3)\).</p>
<p><strong>Steps:</strong> From \(x + y = 5\), \(y = 5 - x\). Substitute: \(3x + 2(5-x) = 12\), \(3x + 10 - 2x = 12\), \(x = 2\), \(y = 3\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Two lines have equations \(y = 2x + 1\) and \(y = 2x - 3\). How many solutions does the system have?</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> No solution (parallel lines).</p>
<p><strong>Explanation:</strong> Same slope \(2\), different intercepts — the lines never meet.</p>
</div>
</details>
<h3 id="questions-difficult" class="book-prose__heading">Difficult</h3>
<details class="book-question">
<summary class="book-question__prompt">Solve: \(2x + 3y = 13\) and \(4x - y = 5\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \((2, 3)\).</p>
<p><strong>Steps:</strong> 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\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Eliminate \(x\): \(3x + 2y = 11\) and \(5x - 2y = 13\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \((3, 1)\).</p>
<p><strong>Steps:</strong> Add equations: \(8x = 24\), \(x = 3\). Then \(3(3) + 2y = 11\), \(2y = 2\), \(y = 1\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">A purse has 12 coins, all 5¢ or 10¢, with total value 95¢. How many of each?</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> 5 coins at 5¢ and 7 coins at 10¢.</p>
<p><strong>Steps:</strong> 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\).</p>
</div>
</details>
<h3 id="questions-hardcore" class="book-prose__heading">Hardcore</h3>
<details class="book-question">
<summary class="book-question__prompt">For what value of \(k\) does \(x + ky = 4\) and \(2x + 2ky = 8\) have infinitely many solutions?</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> Every \(k\) (the second equation is \(2\times\) the first).</p>
<p><strong>Explanation:</strong> 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.</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Solve: \(\dfrac{x}{2} + y = 5\) and \(x - y = 1\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \((4, 3)\).</p>
<p><strong>Steps:</strong> 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\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">A rectangle has perimeter 26&nbsp;cm. The length is 4&nbsp;cm more than the width. Find the dimensions.</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> width \(4.5\) cm, length \(8.5\) cm.</p>
<p><strong>Steps:</strong> \(2\ell + 2w = 26\), \(\ell = w + 4\). Substitute: \(2(w+4)+2w=26\), \(4w+8=26\), \(w=4.5\), \(\ell=8.5\).</p>
</div>
</details>
<h2 id="references" class="book-prose__heading">References</h2>
<ul>
<li><em>The Nine Chapters on the Mathematical Art</em> — early elimination methods (fangcheng).</li>
<li>al-Khwarizmi, <em>Al-Kitāb al-mukhtaṣar fī ḥisāb al-jabr wal-muqābala</em> (c. 820 CE).</li>
<li>René Descartes, <em>La Géométrie</em> (1637) — coordinates and curves.</li>
<li>HKDSE Compulsory Part curriculum — Number and Algebra strand (equations in two unknowns).</li>
</ul>
</div>

View file

@ -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;
}
}

View file

@ -0,0 +1,128 @@
<div class="book-prose">
<p>
A <strong>system of linear equations in two unknowns</strong> is a pair of equations in
variables \(x\) and \(y\), where each equation is linear (the highest power of each variable is
1). A <strong>solution</strong> is an ordered pair \((x, y)\) that satisfies
<em>both</em> equations at the same time.
</p>
<div class="book-callout" role="note">
<p class="book-callout__label">Assumed knowledge</p>
<ul>
<li>Expand and collect like terms, e.g. \(2(x+3) = 2x + 6\).</li>
<li>Substitute a value into an expression, e.g. replace \(x\) by \(4\) in \(3x - 1\).</li>
<li>Add or subtract equations term by term when both sides are equal.</li>
</ul>
</div>
<h2 id="standard-form" class="book-prose__heading">Standard form</h2>
<p>
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\).
</p>
<h2 id="substitution" class="book-prose__heading">Substitution</h2>
<p>
<strong>Substitution</strong> means isolate one unknown in one equation, then replace that
expression in the other equation.
</p>
<p><strong>Example.</strong> Solve</p>
<p class="book-formula">
\[ \begin{cases} y = 2x + 1 \\ x + y = 7 \end{cases} \]
</p>
<p>
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)\).
</p>
<p>
Check: \(2(2)+1 = 5\) and \(2 + 5 = 7\).
</p>
<h2 id="elimination" class="book-prose__heading">Elimination</h2>
<p>
<strong>Elimination</strong> 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).
</p>
<p><strong>Example.</strong> Solve</p>
<p class="book-formula">
\[ \begin{cases} x + y = 5 \\ 2x - y = 1 \end{cases} \]
</p>
<p>
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)\).
</p>
<h2 id="graphical" class="book-prose__heading">Graphical interpretation</h2>
<p>
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 <strong>intersection</strong>.
</p>
<div class="book-table-wrap">
<table class="book-table">
<thead>
<tr>
<th scope="col">Lines</th>
<th scope="col">Algebraic picture</th>
<th scope="col">Solutions</th>
</tr>
</thead>
<tbody>
<tr>
<td>Intersect at one point</td>
<td>Different slopes</td>
<td>Exactly one \((x, y)\)</td>
</tr>
<tr>
<td>Parallel, distinct</td>
<td>Same slope, different intercepts</td>
<td>No solution</td>
</tr>
<tr>
<td>Coincident (same line)</td>
<td>One equation is a multiple of the other</td>
<td>Infinitely many</td>
</tr>
</tbody>
</table>
</div>
<h2 id="try-graph" class="book-prose__heading">Try it — graph explorer</h2>
<p>
Adjust the two lines or use presets. Read off the intersection and how the classification changes
when the lines are parallel or coincident.
</p>
</div>
<div class="math-widget-mount" data-widget="simultaneous-graph-explorer" data-pagefind-ignore></div>
<div class="book-prose">
<h2 id="try-solver" class="book-prose__heading">Try it — step-by-step solver</h2>
<p>
Pick a preset system and watch substitution or elimination steps. Systems with no unique
solution show an explanation instead of incorrect steps.
</p>
</div>
<div class="math-widget-mount" data-widget="simultaneous-solver-workbench" data-pagefind-ignore></div>
<div class="book-prose">
<h2 id="word-problems" class="book-prose__heading">Word problems</h2>
<p>
For story problems, define unknowns clearly, write <em>two</em> independent equations, then solve
by substitution, elimination, or a graph.
</p>
<p><strong>Example (tickets).</strong> Adult tickets cost \$12 and child tickets \$7. A group buys
10 tickets for \$95. How many of each type?</p>
<p>
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.
</p>
</div>

View file

@ -0,0 +1,234 @@
<div class="book-prose">
<h2 id="history" class="book-prose__heading">History</h2>
<p>
Problems that need two unknowns at once are ancient. The Chinese text
<em>Nine Chapters on the Mathematical Art</em> (around 200&nbsp;BCE, with later commentary)
includes <strong>fangcheng</strong> methods — arranging coefficients in a table and eliminating
unknowns, much like modern elimination.
</p>
<p>
In the 9th century, <strong>al-Khwarizmi</strong> organised linear and quadratic problems in
systematic rules that spread to Europe. By the 17th century, <strong>Descartes</strong> 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.
</p>
<h2 id="derivation" class="book-prose__heading">Derivation</h2>
<h3 id="why-elimination" class="book-prose__heading">Why elimination preserves solutions</h3>
<p>
If \((x_0, y_0)\) satisfies both \(ax + by = c\) and \(dx + ey = f\), then any
<em>linear combination</em> 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.
</p>
<h3 id="why-intersection" class="book-prose__heading">Why the graph solution is the intersection</h3>
<p>
The graph of \(ax + by = c\) is all points \((x, y)\) that make the equation true. A solution of
the <em>system</em> must lie on <em>both</em> 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.
</p>
<h2 id="checkpoints" class="book-prose__heading">Checkpoints</h2>
<p>
Pause and reason before continuing. Discuss with a partner or write your work in a notebook.
</p>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
In the graph explorer, use the “Parallel” preset. What does the status panel say about
solutions? Does the canvas show an intersection point?
</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
Solve \(y = 3x - 2\) and \(2x + y = 8\) by substitution without a calculator. Does your pair
\((x, y)\) satisfy both originals?
</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
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?
</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
When eliminating, why do we sometimes <em>subtract</em> equations instead of add them? Give an
example where subtracting cancels \(x\).
</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
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?
</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
In the step-by-step workbench, run elimination on the ticket-style preset. At which step does
\(y\) (or your chosen variable) disappear?
</p>
</div>
<h2 id="applications" class="book-prose__heading">Applications</h2>
<h3 id="ticket-pricing" class="book-prose__heading">Ticket pricing</h3>
<p>
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.
</p>
<h3 id="mixtures" class="book-prose__heading">Mixtures and concentrations</h3>
<p>
A chemist may mix a \(20\%\) solution with a \(50\%\) solution to obtain 2&nbsp;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.
</p>
<h3 id="break-even" class="book-prose__heading">Two constraints in business</h3>
<p>
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.
</p>
<h2 id="question-bank" class="book-prose__heading">Question bank</h2>
<p>
Work each question, then open the solution to check your reasoning. Difficulty increases through
the sets.
</p>
<h3 id="questions-easy" class="book-prose__heading">Easy</h3>
<details class="book-question">
<summary class="book-question__prompt">Solve by substitution: \(y = x + 2\) and \(x + y = 8\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \((3, 5)\).</p>
<p><strong>Steps:</strong> Substitute \(y = x + 2\) into \(x + y = 8\): \(x + (x+2) = 8\), so \(2x = 6\), \(x = 3\), \(y = 5\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Solve by elimination: \(x + y = 6\) and \(x - y = 2\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \((4, 2)\).</p>
<p><strong>Steps:</strong> Add: \(2x = 8\), \(x = 4\). Then \(y = 6 - 4 = 2\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Is \((1, 2)\) a solution of \(2x + y = 4\) and \(x - y = -1\)?</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> Yes.</p>
<p><strong>Check:</strong> \(2(1)+2 = 4\) and \(1 - 2 = -1\).</p>
</div>
</details>
<h3 id="questions-intermediate" class="book-prose__heading">Intermediate</h3>
<details class="book-question">
<summary class="book-question__prompt">Solve: \(2x + y = 7\) and \(x - y = 2\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \((3, 1)\).</p>
<p><strong>Steps:</strong> Add equations: \(3x = 9\), \(x = 3\). Then \(3 - y = 2\), \(y = 1\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Solve: \(3x + 2y = 12\) and \(x + y = 5\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \((2, 3)\).</p>
<p><strong>Steps:</strong> From \(x + y = 5\), \(y = 5 - x\). Substitute: \(3x + 2(5-x) = 12\), \(3x + 10 - 2x = 12\), \(x = 2\), \(y = 3\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Two lines have equations \(y = 2x + 1\) and \(y = 2x - 3\). How many solutions does the system have?</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> No solution (parallel lines).</p>
<p><strong>Explanation:</strong> Same slope \(2\), different intercepts — the lines never meet.</p>
</div>
</details>
<h3 id="questions-difficult" class="book-prose__heading">Difficult</h3>
<details class="book-question">
<summary class="book-question__prompt">Solve: \(2x + 3y = 13\) and \(4x - y = 5\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \((2, 3)\).</p>
<p><strong>Steps:</strong> 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\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Eliminate \(x\): \(3x + 2y = 11\) and \(5x - 2y = 13\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \((3, 1)\).</p>
<p><strong>Steps:</strong> Add equations: \(8x = 24\), \(x = 3\). Then \(3(3) + 2y = 11\), \(2y = 2\), \(y = 1\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">A purse has 12 coins, all 5¢ or 10¢, with total value 95¢. How many of each?</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> 5 coins at 5¢ and 7 coins at 10¢.</p>
<p><strong>Steps:</strong> 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\).</p>
</div>
</details>
<h3 id="questions-hardcore" class="book-prose__heading">Hardcore</h3>
<details class="book-question">
<summary class="book-question__prompt">For what value of \(k\) does \(x + ky = 4\) and \(2x + 2ky = 8\) have infinitely many solutions?</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> Every \(k\) (the second equation is \(2\times\) the first).</p>
<p><strong>Explanation:</strong> 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.</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Solve: \(\dfrac{x}{2} + y = 5\) and \(x - y = 1\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \((4, 3)\).</p>
<p><strong>Steps:</strong> 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\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">A rectangle has perimeter 26&nbsp;cm. The length is 4&nbsp;cm more than the width. Find the dimensions.</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> width \(4.5\) cm, length \(8.5\) cm.</p>
<p><strong>Steps:</strong> \(2\ell + 2w = 26\), \(\ell = w + 4\). Substitute: \(2(w+4)+2w=26\), \(4w+8=26\), \(w=4.5\), \(\ell=8.5\).</p>
</div>
</details>
<h2 id="references" class="book-prose__heading">References</h2>
<ul>
<li><em>The Nine Chapters on the Mathematical Art</em> — early elimination methods (fangcheng).</li>
<li>al-Khwarizmi, <em>Al-Kitāb al-mukhtaṣar fī ḥisāb al-jabr wal-muqābala</em> (c. 820 CE).</li>
<li>René Descartes, <em>La Géométrie</em> (1637) — coordinates and curves.</li>
<li>HKDSE Compulsory Part curriculum — Number and Algebra strand (equations in two unknowns).</li>
</ul>
</div>

View file

@ -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 (
<div class="math-slider-row">
<label class="math-slider-label" for={props.id}>
<span>{props.label}</span>
<span class="math-slider-value">{formatNum(props.value)}</span>
</label>
<input
id={props.id}
class={`math-slider ${props.accentClass ?? ""}`}
type="range"
min={props.min}
max={props.max}
step={props.step}
value={props.value}
onInput={(e) => props.onInput(Number(e.currentTarget.value))}
/>
</div>
);
}
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<string | null>(null);
const [hover, setHover] = createSignal<GraphHover | null>(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 (
<section class="math-widget sim-graph" aria-labelledby="sim-graph-title">
<h2 id="sim-graph-title" class="math-widget__title">
Simultaneous equations graph explorer
</h2>
<div class="math-widget__layout">
<div class="math-widget__controls">
<div class="sim-graph__presets" role="group" aria-label="Presets">
{PRESETS.map((p) => (
<button
type="button"
class="sim-graph__preset-btn"
classList={{ "sim-graph__preset-btn--active": activePreset() === p.id }}
onClick={() => applyPreset(p)}
>
{p.label}
</button>
))}
</div>
<div class="math-widget__equation-box" aria-live="polite">
<p class="math-widget__equation-label">Line 1</p>
<div class="math-widget__equation" innerHTML={eq1Html()} />
</div>
<div class="math-widget__equation-box sim-graph__eq2-box" aria-live="polite">
<p class="math-widget__equation-label">Line 2</p>
<div class="math-widget__equation" innerHTML={eq2Html()} />
</div>
<p class="sim-graph__line-heading">Line 1 (y = m₁x + c₁)</p>
<SliderRow
id="sim-m1"
label="Slope m₁"
value={m1()}
min={-4}
max={4}
step={0.5}
accentClass="math-slider--a"
onInput={onSliderChange(setM1)}
/>
<SliderRow
id="sim-c1"
label="Intercept c₁"
value={c1()}
min={-8}
max={8}
step={1}
accentClass="math-slider--b"
onInput={onSliderChange(setC1)}
/>
<p class="sim-graph__line-heading">Line 2 (y = m₂x + c₂)</p>
<SliderRow
id="sim-m2"
label="Slope m₂"
value={m2()}
min={-4}
max={4}
step={0.5}
accentClass="math-slider--c"
onInput={onSliderChange(setM2)}
/>
<SliderRow
id="sim-c2"
label="Intercept c₂"
value={c2()}
min={-8}
max={8}
step={1}
onInput={onSliderChange(setC2)}
/>
<div
class="math-widget__delta-panel sim-graph__status"
classList={{
"sim-graph__status--unique": kind() === "unique",
"sim-graph__status--parallel": kind() === "parallel",
"sim-graph__status--coincident": kind() === "coincident",
}}
aria-live="polite"
>
<h3 class="math-widget__delta-title">System</h3>
<p class="math-widget__delta-nature">{natureLabel(kind())}</p>
<Show when={meet()}>
{(p) => (
<p class="math-widget__delta-roots">
Intersection at {formatCoord(p().x, p().y)}
</p>
)}
</Show>
</div>
</div>
<figure class="math-widget__graph">
<div
class="math-widget__canvas-wrap"
ref={(el) => {
wrapRef = el;
}}
>
<canvas
ref={(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."
/>
<Show when={hover()}>
{(h) => (
<div
class="math-graph-tooltip"
classList={{
[`math-graph-tooltip--${h().kind}`]: true,
}}
role="status"
aria-live="polite"
style={{
left: `${tooltipPos().left}px`,
top: `${tooltipPos().top}px`,
}}
>
<span class="math-graph-tooltip__label">{h().label}</span>
<span class="math-graph-tooltip__coords">{formatCoord(h().x, h().y)}</span>
</div>
)}
</Show>
</div>
<figcaption class="math-widget__caption">
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.
</figcaption>
</figure>
</div>
</section>
);
}
export default SimultaneousGraphExplorer;

View file

@ -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<Method>("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 (
<section
class="math-widget sim-solver"
aria-labelledby="sim-solver-title"
>
<h2 id="sim-solver-title" class="math-widget__title">
Substitution &amp; elimination step-by-step
</h2>
<div class="sim-solver__shell">
<aside class="sim-solver__sidebar">
<div class="sim-solver__field">
<span class="sim-solver__label">Preset system</span>
<select
class="sim-solver__select"
value={presetId()}
onChange={(e) => setPresetId(e.currentTarget.value)}
>
<For each={PRESETS}>
{(p) => <option value={p.id}>{p.label}</option>}
</For>
</select>
</div>
<div class="sim-solver__field">
<span class="sim-solver__label">Method</span>
<div class="sim-solver__method-grid" role="group" aria-label="Solution method">
<button
type="button"
class="sim-solver__method-btn"
classList={{
"sim-solver__method-btn--active": method() === "substitution",
"sim-solver__method-btn--disabled":
!availableMethods().includes("substitution"),
}}
disabled={!availableMethods().includes("substitution")}
aria-pressed={method() === "substitution"}
onClick={() => setMethod("substitution")}
>
Substitution
</button>
<button
type="button"
class="sim-solver__method-btn"
classList={{
"sim-solver__method-btn--active": method() === "elimination",
"sim-solver__method-btn--disabled":
!availableMethods().includes("elimination"),
}}
disabled={!availableMethods().includes("elimination")}
aria-pressed={method() === "elimination"}
onClick={() => setMethod("elimination")}
>
Elimination
</button>
</div>
</div>
<div class="sim-solver__anim">
<span class="sim-solver__label">Step controls</span>
<div class="sim-solver__transport">
<button
type="button"
class="sim-solver__transport-btn"
aria-label="Previous step"
disabled={!!error() || stepIndex() === 0}
onClick={() => setStepIndex((i) => Math.max(0, i - 1))}
>
‹
</button>
<button
type="button"
class="sim-solver__play"
disabled={!!error() || steps().length === 0}
onClick={() => setPlaying((p) => !p)}
>
{playing() ? "Pause" : "Play"}
</button>
<button
type="button"
class="sim-solver__transport-btn"
aria-label="Next step"
disabled={!!error() || stepIndex() >= steps().length - 1}
onClick={() => setStepIndex((i) => Math.min(steps().length - 1, i + 1))}
>
›
</button>
</div>
<div class="sim-solver__speed">
<span>Slow</span>
<input
type="range"
class="sim-solver__speed-slider"
min={600}
max={2400}
step={200}
value={speed()}
dir="rtl"
aria-label="Animation speed"
onInput={(e) => setSpeed(Number(e.currentTarget.value))}
/>
<span>Fast</span>
</div>
</div>
</aside>
<div class="sim-solver__stage">
<div
class="sim-solver__message-box"
classList={{ "sim-solver__message-box--error": !!error() }}
>
<Show
when={error()}
fallback={
<p class="sim-solver__message" aria-live="polite">
Step {stepIndex() + 1} of {steps().length}
{current() ? `: ${current()!.title}` : ""}
</p>
}
>
<p class="sim-solver__message" aria-live="polite">
{error()}
</p>
</Show>
<div class="sim-solver__progress-track">
<div
class="sim-solver__progress-fill"
style={{ width: `${progress()}%` }}
/>
</div>
</div>
<div class="sim-solver__equation-panel" aria-live="polite">
<p class="sim-solver__panel-label">System</p>
<div class="sim-solver__system" innerHTML={eqDisplay()} />
</div>
<Show when={current() && !error()}>
{(step) => (
<div class="sim-solver__step-panel">
<h3 class="sim-solver__step-title">{step().title}</h3>
<div class="sim-solver__step-math" innerHTML={renderLatex(step().latex)} />
</div>
)}
</Show>
</div>
</div>
</section>
);
}
export default SimultaneousSolverWorkbench;

View file

@ -1,15 +1,10 @@
import katex from "katex"; import { renderLatex, renderMathInContainer } from "@ofb/katex";
import { createEffect, createMemo, createSignal } from "solid-js"; import { createEffect, createMemo, createSignal } from "solid-js";
import { renderMathInContainer } from "../../../../frontend/src/lib/render-math";
function formatNum(n: number) { function formatNum(n: number) {
return String(n); return String(n);
} }
function renderLatex(latex: string) {
return katex.renderToString(latex, { throwOnError: false, strict: "ignore" });
}
function AreaModel() { function AreaModel() {
const [a, setA] = createSignal(2); const [a, setA] = createSignal(2);
const [b, setB] = createSignal(3); const [b, setB] = createSignal(3);

View file

@ -1,10 +1,6 @@
import katex from "katex"; import { renderLatex } from "@ofb/katex";
import { createMemo, createSignal, For } from "solid-js"; 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" }) { function DotBlock(props: { count: number; tone: "x" | "y" | "z" }) {
return ( return (
<div class={`prop-assoc__block prop-assoc__block--${props.tone}`}> <div class={`prop-assoc__block prop-assoc__block--${props.tone}`}>

View file

@ -1,10 +1,6 @@
import katex from "katex"; import { renderLatex } from "@ofb/katex";
import { createMemo, createSignal, For } from "solid-js"; 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 }) { function DotRow(props: { count: number; class?: string }) {
return ( return (
<div class={`prop-comm__row ${props.class ?? ""}`}> <div class={`prop-comm__row ${props.class ?? ""}`}>

View file

@ -1,10 +1,6 @@
import katex from "katex"; import { renderLatex } from "@ofb/katex";
import { createMemo, createSignal } from "solid-js"; import { createMemo, createSignal } from "solid-js";
function renderLatex(latex: string) {
return katex.renderToString(latex, { throwOnError: false, strict: "ignore" });
}
function DistributiveAreaDemo() { function DistributiveAreaDemo() {
const [a, setA] = createSignal(2); const [a, setA] = createSignal(2);
const [b, setB] = createSignal(3); const [b, setB] = createSignal(3);

View file

@ -1,13 +1,9 @@
import katex from "katex"; import { renderLatex } from "@ofb/katex";
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
import { computeRoots, discriminant, formatNum } from "../../../../frontend/src/lib/math-graph"; import { computeRoots, discriminant, formatNum } from "../../../../frontend/src/lib/math-graph";
const HORIZONTAL_SPEED = 12; const HORIZONTAL_SPEED = 12;
function renderLatex(latex: string) {
return katex.renderToString(latex, { throwOnError: false, strict: "ignore" });
}
function SliderRow(props: { function SliderRow(props: {
id: string; id: string;
label: string; label: string;

View file

@ -1,4 +1,4 @@
import katex from "katex"; import { renderLatex } from "@ofb/katex";
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"; import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js";
const VIEW_SIZE = 400; const VIEW_SIZE = 400;
@ -67,10 +67,6 @@ function natureOfRoots(delta: number) {
return "No real roots"; return "No real roots";
} }
function renderLatex(latex: string) {
return katex.renderToString(latex, { throwOnError: false, strict: "ignore" });
}
function readCanvasColors(canvas: HTMLCanvasElement) { function readCanvasColors(canvas: HTMLCanvasElement) {
const style = getComputedStyle(canvas); const style = getComputedStyle(canvas);
return { return {

View file

@ -105,8 +105,8 @@
{ {
"slug": "linear-equations-two-unknowns", "slug": "linear-equations-two-unknowns",
"title": "Linear equations in two unknowns", "title": "Linear equations in two unknowns",
"description": "Solve simultaneous linear equations by substitution, elimination, and graphical interpretation.", "description": "Solve simultaneous linear equations by substitution, elimination, and graphical interpretation; classify unique, parallel, and coincident cases.",
"status": "planned", "status": "live",
"curriculums": ["DSE"] "curriculums": ["DSE"]
}, },
{ {

62
frontend/src/lib/katex.ts Normal file
View file

@ -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<HTMLElement>("[data-pagefind-body]")
: root instanceof HTMLElement && root.matches("[data-pagefind-body]")
? root
: root.querySelector<HTMLElement>("[data-pagefind-body]");
const scope: ParentNode = article ?? root;
scope.querySelectorAll<HTMLElement>(AUTO_RENDER_TARGETS).forEach((el) => {
if (el.closest(".math-widget")) return;
renderMathInContainer(el);
});
}

View file

@ -1,24 +1,3 @@
import renderMathInElement from "katex/contrib/auto-render"; /** @deprecated Import from `./katex` instead. Kept for existing paths. */
import "katex/dist/katex.min.css"; export { renderBookMath, renderLatex, renderMathInContainer } from "./katex";
export type { RenderLatexOptions } from "./katex";
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<HTMLElement>(".book-prose, .book-callout, .math-widget__caption")
.forEach(renderMathInContainer);
}

View file

@ -38,8 +38,8 @@ function mountFooterYear() {
} }
} }
function scheduleChapterMath() { function scheduleBookMath() {
void import("./lib/render-math").then(({ renderBookMath }) => { void import("./lib/katex").then(({ renderBookMath }) => {
requestAnimationFrame(() => renderBookMath()); requestAnimationFrame(() => renderBookMath());
}); });
} }
@ -50,11 +50,7 @@ function mountChapterWidgets() {
const mounts = document.querySelectorAll<HTMLElement>("[data-widget]"); const mounts = document.querySelectorAll<HTMLElement>("[data-widget]");
if (!article || !chapterKey) return; if (!article || !chapterKey) return;
// KaTeX must run on widget-less chapters too (renderBookMath was only chained after widgets). if (mounts.length === 0) return;
if (mounts.length === 0) {
scheduleChapterMath();
return;
}
void import("./generated/chapter-widgets").then(({ chapterWidgetLoaders }) => { void import("./generated/chapter-widgets").then(({ chapterWidgetLoaders }) => {
const loadPromises: Promise<void>[] = []; const loadPromises: Promise<void>[] = [];
@ -76,11 +72,18 @@ function mountChapterWidgets() {
} }
void Promise.all(loadPromises).then(() => { 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(); initTheme();
const config = readConfig(); const config = readConfig();
@ -131,6 +134,7 @@ if (chapterNavRoot) {
}); });
} }
initBookMath();
mountChapterWidgets(); mountChapterWidgets();
const catalogRoot = document.getElementById("catalog-app"); const catalogRoot = document.getElementById("catalog-app");

View file

@ -26,6 +26,7 @@
rel="stylesheet" rel="stylesheet"
/> />
<link rel="stylesheet" href="{{ get_url(path='main.css', trailing_slash=false) | safe }}" /> <link rel="stylesheet" href="{{ get_url(path='main.css', trailing_slash=false) | safe }}" />
<link rel="stylesheet" href="{{ get_url(path='js/katex.css', trailing_slash=false) | safe }}" />
<script <script
src="{{ get_url(path='pagefind/pagefind-component-ui.js', trailing_slash=false) | safe }}" src="{{ get_url(path='pagefind/pagefind-component-ui.js', trailing_slash=false) | safe }}"
type="module" type="module"

View file

@ -15,7 +15,6 @@
{% set chapter_key = subject ~ "/" ~ slug %} {% set chapter_key = subject ~ "/" ~ slug %}
{% set chapters_meta = load_data(path="data/_generated/chapters-meta.json", format="json") %} {% set chapters_meta = load_data(path="data/_generated/chapters-meta.json", format="json") %}
{% set chapter_meta = chapters_meta.chapters[chapter_key] %} {% set chapter_meta = chapters_meta.chapters[chapter_key] %}
<link rel="stylesheet" href="{{ get_url(path='js/katex.css', trailing_slash=false) | safe }}" />
{% if chapter_meta.hasCss %} {% if chapter_meta.hasCss %}
<link rel="stylesheet" href="{{ get_url(path='css/chapters/' ~ subject ~ '-' ~ slug ~ '.css', trailing_slash=false) | safe }}" /> <link rel="stylesheet" href="{{ get_url(path='css/chapters/' ~ subject ~ '-' ~ slug ~ '.css', trailing_slash=false) | safe }}" />
{% endif %} {% endif %}

View file

@ -8,7 +8,10 @@
"jsxImportSource": "solid-js", "jsxImportSource": "solid-js",
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"skipLibCheck": true "skipLibCheck": true,
"paths": {
"@ofb/katex": ["./frontend/src/lib/katex.ts"]
}
}, },
"include": ["frontend/src", "content/**/widgets/**/*.ts", "content/**/widgets/**/*.tsx"], "include": ["frontend/src", "content/**/widgets/**/*.ts", "content/**/widgets/**/*.tsx"],
"exclude": ["frontend/src/**/*.test.ts", "content/**/widgets/**/*.test.ts"] "exclude": ["frontend/src/**/*.test.ts", "content/**/widgets/**/*.test.ts"]

View file

@ -1,8 +1,17 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import solid from "vite-plugin-solid"; import solid from "vite-plugin-solid";
const rootDir = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({ export default defineConfig({
plugins: [solid()], plugins: [solid()],
resolve: {
alias: {
"@ofb/katex": path.resolve(rootDir, "frontend/src/lib/katex.ts"),
},
},
server: { server: {
fs: { fs: {
allow: [".."], allow: [".."],