mirror of
https://github.com/HananoshikaYomaru/openfreebooks.git
synced 2026-11-12 20:45:52 +00:00
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:
parent
7636bb4a86
commit
ede9a7aa63
28 changed files with 2289 additions and 111 deletions
|
|
@ -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 `<div id="…">` or use existing).
|
||||
|
|
|
|||
|
|
@ -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 `<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)
|
||||
|
||||
### 4. Build and check
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<PresetId>(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 (
|
||||
<section
|
||||
|
|
@ -57,7 +53,10 @@ function FractionSimplifyDemo() {
|
|||
id="fraction-preset"
|
||||
class="formula-rearrange-demo__select"
|
||||
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}>
|
||||
{(p) => <option value={p.id}>{p.label}</option>}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div class={`addition-groups__pile ${props.class ?? ""}`}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
374
content/math/linear-equations-two-unknowns/_index.md
Normal file
374
content/math/linear-equations-two-unknowns/_index.md
Normal 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 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 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 cm. The length is 4 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>
|
||||
304
content/math/linear-equations-two-unknowns/chapter.scss
Normal file
304
content/math/linear-equations-two-unknowns/chapter.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
128
content/math/linear-equations-two-unknowns/core.html
Normal file
128
content/math/linear-equations-two-unknowns/core.html
Normal 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>
|
||||
234
content/math/linear-equations-two-unknowns/supplement.html
Normal file
234
content/math/linear-equations-two-unknowns/supplement.html
Normal 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 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 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 cm. The length is 4 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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 & 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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div class={`prop-assoc__block prop-assoc__block--${props.tone}`}>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div class={`prop-comm__row ${props.class ?? ""}`}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
62
frontend/src/lib/katex.ts
Normal file
62
frontend/src/lib/katex.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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<HTMLElement>(".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";
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>("[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<void>[] = [];
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
rel="stylesheet"
|
||||
/>
|
||||
<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
|
||||
src="{{ get_url(path='pagefind/pagefind-component-ui.js', trailing_slash=false) | safe }}"
|
||||
type="module"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@
|
|||
{% set chapter_key = subject ~ "/" ~ slug %}
|
||||
{% set chapters_meta = load_data(path="data/_generated/chapters-meta.json", format="json") %}
|
||||
{% 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 %}
|
||||
<link rel="stylesheet" href="{{ get_url(path='css/chapters/' ~ subject ~ '-' ~ slug ~ '.css', trailing_slash=false) | safe }}" />
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@
|
|||
"jsxImportSource": "solid-js",
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@ofb/katex": ["./frontend/src/lib/katex.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["frontend/src", "content/**/widgets/**/*.ts", "content/**/widgets/**/*.tsx"],
|
||||
"exclude": ["frontend/src/**/*.test.ts", "content/**/widgets/**/*.test.ts"]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import solid from "vite-plugin-solid";
|
||||
|
||||
const rootDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solid()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@ofb/katex": path.resolve(rootDir, "frontend/src/lib/katex.ts"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
allow: [".."],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue