diff --git a/src/lib/algebra-engine/index.js b/src/lib/algebra-engine/index.js
index f712f48..3714f76 100644
--- a/src/lib/algebra-engine/index.js
+++ b/src/lib/algebra-engine/index.js
@@ -1 +1,2 @@
export { lineFromPoints, lineFromSlope, yAt, linePoints } from './linear.js';
+export { solveSystem, clipToBox, constantThrough, isDegenerate, EPSILON_COEF } from './system.js';
diff --git a/src/lib/algebra-engine/system.js b/src/lib/algebra-engine/system.js
new file mode 100644
index 0000000..b0724ba
--- /dev/null
+++ b/src/lib/algebra-engine/system.js
@@ -0,0 +1,116 @@
+/**
+ * Systems of two linear equations in standard form `a·x + b·y = c`.
+ *
+ * Standard form is used instead of the slope-intercept `y = a·x + b` of
+ * `linear.js` because it represents vertical lines and makes the three
+ * solution cases fall out of a single determinant.
+ */
+
+/**
+ * @typedef {import('../geom-engine/vec.js').Vec2} Vec2
+ * @typedef {Readonly<{a: number, b: number, c: number}>} StdLine
+ * @typedef {{kind: 'unique', point: Vec2}
+ * | {kind: 'parallel'}
+ * | {kind: 'coincident'}
+ * | {kind: 'degenerate'}} SystemSolution
+ */
+
+/**
+ * Coefficient tolerance. Coefficients are small integers in lesson use, so a
+ * tight numeric epsilon is right here — unlike the pixel-scale `EPSILON_LEN`
+ * of the geometry engine.
+ */
+export const EPSILON_COEF = 1e-9;
+
+/** Geometric tolerance for "is this point on the box edge", in math units. */
+const EPSILON_BOX = 1e-7;
+
+/**
+ * True when both coefficients vanish, i.e. the equation describes no line.
+ * @param {StdLine} line @returns {boolean}
+ */
+export function isDegenerate(line) {
+ return Math.abs(line.a) < EPSILON_COEF && Math.abs(line.b) < EPSILON_COEF;
+}
+
+/**
+ * Solve the system of two standard-form equations.
+ *
+ * `unique` — the lines cross at one point (determinant ≠ 0).
+ * `coincident` — same line, infinitely many solutions.
+ * `parallel` — distinct parallel lines, no solution.
+ * `degenerate` — an equation has a = b = 0 and is not a line.
+ *
+ * @param {StdLine} l1 @param {StdLine} l2 @returns {SystemSolution}
+ */
+export function solveSystem(l1, l2) {
+ if (isDegenerate(l1) || isDegenerate(l2)) return { kind: 'degenerate' };
+
+ const det = l1.a * l2.b - l2.a * l1.b;
+ if (Math.abs(det) > EPSILON_COEF) {
+ return {
+ kind: 'unique',
+ point: {
+ x: (l1.c * l2.b - l2.c * l1.b) / det,
+ y: (l1.a * l2.c - l2.a * l1.c) / det,
+ },
+ };
+ }
+
+ // Determinant is zero: the lines are parallel. They coincide only when the
+ // constant terms scale by the same factor as the coefficients.
+ const detX = l1.c * l2.b - l2.c * l1.b;
+ const detY = l1.a * l2.c - l2.a * l1.c;
+ const coincident = Math.abs(detX) < EPSILON_COEF && Math.abs(detY) < EPSILON_COEF;
+ return { kind: coincident ? 'coincident' : 'parallel' };
+}
+
+/**
+ * Clip a standard-form line to an axis-aligned box, returning the two points
+ * where it meets the boundary. Returns `null` when the line misses the box,
+ * only touches a corner, or the equation is degenerate.
+ *
+ * @param {StdLine} line
+ * @param {number} xMin @param {number} xMax
+ * @param {number} yMin @param {number} yMax
+ * @returns {[Vec2, Vec2] | null}
+ */
+export function clipToBox(line, xMin, xMax, yMin, yMax) {
+ if (isDegenerate(line)) return null;
+ const { a, b, c } = line;
+
+ /** @type {Vec2[]} */
+ const hits = [];
+ const push = (/** @type {Vec2} */ p) => {
+ if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) return;
+ if (p.x < xMin - EPSILON_BOX || p.x > xMax + EPSILON_BOX) return;
+ if (p.y < yMin - EPSILON_BOX || p.y > yMax + EPSILON_BOX) return;
+ const dup = hits.some(
+ (q) => Math.abs(q.x - p.x) < EPSILON_BOX && Math.abs(q.y - p.y) < EPSILON_BOX
+ );
+ if (!dup) hits.push(p);
+ };
+
+ // Vertical edges: solve for y at x = xMin, xMax (needs b ≠ 0).
+ if (Math.abs(b) > EPSILON_COEF) {
+ push({ x: xMin, y: (c - a * xMin) / b });
+ push({ x: xMax, y: (c - a * xMax) / b });
+ }
+ // Horizontal edges: solve for x at y = yMin, yMax (needs a ≠ 0).
+ if (Math.abs(a) > EPSILON_COEF) {
+ push({ x: (c - b * yMin) / a, y: yMin });
+ push({ x: (c - b * yMax) / a, y: yMax });
+ }
+
+ return hits.length >= 2 ? [hits[0], hits[1]] : null;
+}
+
+/**
+ * Constant term that moves `line` onto the point `p` without changing its
+ * direction — the value of `a·x + b·y` at `p`. Used when a line is dragged.
+ *
+ * @param {StdLine} line @param {Vec2} p @returns {number}
+ */
+export function constantThrough(line, p) {
+ return line.a * p.x + line.b * p.y;
+}
diff --git a/src/lib/algebra-engine/system.test.js b/src/lib/algebra-engine/system.test.js
new file mode 100644
index 0000000..55b3b67
--- /dev/null
+++ b/src/lib/algebra-engine/system.test.js
@@ -0,0 +1,113 @@
+import { describe, it, expect } from 'vitest';
+import { solveSystem, clipToBox, constantThrough, isDegenerate } from './system.js';
+
+describe('solveSystem', () => {
+ it('crossing lines yield the single intersection point', () => {
+ // x + y = 3 and x - y = 1 meet at (2, 1)
+ const s = solveSystem({ a: 1, b: 1, c: 3 }, { a: 1, b: -1, c: 1 });
+ expect(s.kind).toBe('unique');
+ if (s.kind !== 'unique') throw new Error('unreachable');
+ expect(s.point.x).toBeCloseTo(2);
+ expect(s.point.y).toBeCloseTo(1);
+ });
+
+ it('distinct parallel lines have no solution', () => {
+ expect(solveSystem({ a: 2, b: 3, c: 6 }, { a: 4, b: 6, c: 1 }).kind).toBe('parallel');
+ });
+
+ it('proportional equations describe the same line', () => {
+ expect(solveSystem({ a: 2, b: 3, c: 6 }, { a: 4, b: 6, c: 12 }).kind).toBe('coincident');
+ });
+
+ it('negated equations describe the same line', () => {
+ expect(solveSystem({ a: 1, b: -2, c: 4 }, { a: -1, b: 2, c: -4 }).kind).toBe('coincident');
+ });
+
+ it('a vertical and a horizontal line cross at their constants', () => {
+ // x = 3 and y = -2
+ const s = solveSystem({ a: 1, b: 0, c: 3 }, { a: 0, b: 1, c: -2 });
+ expect(s.kind).toBe('unique');
+ if (s.kind !== 'unique') throw new Error('unreachable');
+ expect(s.point).toEqual({ x: 3, y: -2 });
+ });
+
+ it('two vertical lines are parallel, not unique', () => {
+ expect(solveSystem({ a: 1, b: 0, c: 3 }, { a: 1, b: 0, c: 5 }).kind).toBe('parallel');
+ });
+
+ it('an equation with a = b = 0 is degenerate', () => {
+ expect(solveSystem({ a: 0, b: 0, c: 1 }, { a: 1, b: 1, c: 2 }).kind).toBe('degenerate');
+ expect(solveSystem({ a: 1, b: 1, c: 2 }, { a: 0, b: 0, c: 0 }).kind).toBe('degenerate');
+ });
+
+ it('solution satisfies both equations', () => {
+ const l1 = { a: 3, b: -5, c: 7 };
+ const l2 = { a: -2, b: 4, c: 1 };
+ const s = solveSystem(l1, l2);
+ if (s.kind !== 'unique') throw new Error('expected unique');
+ expect(l1.a * s.point.x + l1.b * s.point.y).toBeCloseTo(l1.c);
+ expect(l2.a * s.point.x + l2.b * s.point.y).toBeCloseTo(l2.c);
+ });
+});
+
+describe('isDegenerate', () => {
+ it('flags only the zero-coefficient equation', () => {
+ expect(isDegenerate({ a: 0, b: 0, c: 5 })).toBe(true);
+ expect(isDegenerate({ a: 0, b: 1, c: 5 })).toBe(false);
+ });
+});
+
+describe('clipToBox', () => {
+ it('a slanted line meets two box edges', () => {
+ // y = x → x - y = 0, clipped to [-10,10]²
+ const seg = clipToBox({ a: 1, b: -1, c: 0 }, -10, 10, -10, 10);
+ expect(seg).not.toBeNull();
+ if (!seg) throw new Error('unreachable');
+ expect(seg[0]).toEqual({ x: -10, y: -10 });
+ expect(seg[1]).toEqual({ x: 10, y: 10 });
+ });
+
+ it('a vertical line spans the box height', () => {
+ const seg = clipToBox({ a: 1, b: 0, c: 4 }, -10, 10, -10, 10);
+ if (!seg) throw new Error('expected a segment');
+ expect(seg.map((p) => p.x)).toEqual([4, 4]);
+ expect(seg.map((p) => p.y).sort((m, n) => m - n)).toEqual([-10, 10]);
+ });
+
+ it('a horizontal line spans the box width', () => {
+ const seg = clipToBox({ a: 0, b: 1, c: -3 }, -10, 10, -10, 10);
+ if (!seg) throw new Error('expected a segment');
+ expect(seg.map((p) => p.y)).toEqual([-3, -3]);
+ expect(seg.map((p) => p.x).sort((m, n) => m - n)).toEqual([-10, 10]);
+ });
+
+ it('a line outside the box returns null', () => {
+ expect(clipToBox({ a: 0, b: 1, c: 50 }, -10, 10, -10, 10)).toBeNull();
+ });
+
+ it('a line touching only a corner returns null', () => {
+ // x + y = 20 meets [-10,10]² only at (10, 10)
+ expect(clipToBox({ a: 1, b: 1, c: 20 }, -10, 10, -10, 10)).toBeNull();
+ });
+
+ it('a degenerate equation returns null', () => {
+ expect(clipToBox({ a: 0, b: 0, c: 1 }, -10, 10, -10, 10)).toBeNull();
+ });
+
+ it('endpoints satisfy the equation', () => {
+ const line = { a: 2, b: 3, c: 6 };
+ const seg = clipToBox(line, -10, 10, -10, 10);
+ if (!seg) throw new Error('expected a segment');
+ for (const p of seg) expect(line.a * p.x + line.b * p.y).toBeCloseTo(line.c);
+ });
+});
+
+describe('constantThrough', () => {
+ it('returns the constant that puts the line on the given point', () => {
+ const line = { a: 2, b: 3, c: 0 };
+ const c = constantThrough(line, { x: 1, y: 2 });
+ expect(c).toBe(8);
+ // the translated line really does pass through the point
+ expect(line.a * 1 + line.b * 2).toBeCloseTo(c);
+ });
+});
diff --git a/src/lib/lessons/he-phuong-trinh-bac-nhat/copy.vi.js b/src/lib/lessons/he-phuong-trinh-bac-nhat/copy.vi.js
new file mode 100644
index 0000000..8804b63
--- /dev/null
+++ b/src/lib/lessons/he-phuong-trinh-bac-nhat/copy.vi.js
@@ -0,0 +1,47 @@
+export const vi = {
+ slug: 'he-phuong-trinh-bac-nhat',
+ topic: 'dai-so',
+ grade: 'lop-9',
+ title: 'Hệ phương trình bậc nhất hai ẩn',
+ gradeLabel: 'Lớp 9',
+ intro:
+ 'Mỗi phương trình bậc nhất hai ẩn ax + by = c là một đường thẳng. Nghiệm của hệ chính là điểm chung của hai đường thẳng đó. Kéo hai đường thẳng hoặc chỉnh các hệ số để thấy ba trường hợp: cắt nhau, song song, trùng nhau.',
+
+ eq1Label: 'Phương trình (1)',
+ eq2Label: 'Phương trình (2)',
+ coefA: 'Hệ số a',
+ coefB: 'Hệ số b',
+ coefC: 'Hằng số c',
+
+ instructionSlider: 'Chỉnh hệ số a, b, c của từng phương trình',
+ instructionDrag: 'Hoặc kéo điểm vuông trên mỗi đường thẳng để tịnh tiến nó (chỉ c thay đổi)',
+ handle1Label: 'Điểm kéo của đường thẳng (1)',
+ handle2Label: 'Điểm kéo của đường thẳng (2)',
+
+ caseTitle: 'Số nghiệm của hệ',
+ caseUnique: 'Hai đường thẳng cắt nhau — hệ có nghiệm duy nhất',
+ caseParallel: 'Hai đường thẳng song song — hệ vô nghiệm',
+ caseCoincident: 'Hai đường thẳng trùng nhau — hệ có vô số nghiệm',
+ caseDegenerate: 'Một phương trình có a = b = 0 nên không phải phương trình bậc nhất hai ẩn',
+ solutionLabel: 'Nghiệm',
+ noSolutionLabel: 'Không có điểm chung',
+
+ offscreenNote:
+ 'Có đường thẳng đang nằm ngoài khung nhìn. Giảm |c| hoặc tăng hệ số a, b để kéo nó trở lại.',
+
+ presetTitle: 'Thử nhanh ba trường hợp',
+ presetUnique: 'Cắt nhau',
+ presetParallel: 'Song song',
+ presetCoincident: 'Trùng nhau',
+ resetLabel: 'Đặt lại',
+
+ theoremTitle: 'Tính chất',
+ theoremStatement:
+ 'Xét hệ ax + by = c và a′x + b′y = c′ với các hệ số a, b và a′, b′ không đồng thời bằng 0. Nếu ab′ − a′b ≠ 0 thì hai đường thẳng cắt nhau và hệ có nghiệm duy nhất. Nếu ab′ − a′b = 0 thì hai đường thẳng song song (hệ vô nghiệm) hoặc trùng nhau (hệ có vô số nghiệm), tuỳ theo hằng số c, c′ có tỉ lệ cùng hệ số hay không.',
+
+ exampleTitle: 'Ví dụ',
+ exampleBody:
+ 'Với hệ x + y = 3 và x − y = 1, ta có ab′ − a′b = 1·(−1) − 1·1 = −2 ≠ 0 nên hệ có nghiệm duy nhất. Cộng hai phương trình được 2x = 4, suy ra x = 2, rồi y = 1. Trên đồ thị, hai đường thẳng cắt nhau đúng tại điểm (2; 1).',
+
+ nextTeaser: 'Sắp ra mắt: Giải hệ bằng phương pháp thế và phương pháp cộng đại số',
+};
diff --git a/src/lib/lessons/he-phuong-trinh-bac-nhat/equation-card.svelte b/src/lib/lessons/he-phuong-trinh-bac-nhat/equation-card.svelte
new file mode 100644
index 0000000..e22c993
--- /dev/null
+++ b/src/lib/lessons/he-phuong-trinh-bac-nhat/equation-card.svelte
@@ -0,0 +1,74 @@
+
+
+
diff --git a/src/lib/lessons/he-phuong-trinh-bac-nhat/system-plane.svelte b/src/lib/lessons/he-phuong-trinh-bac-nhat/system-plane.svelte
new file mode 100644
index 0000000..a1f7b98
--- /dev/null
+++ b/src/lib/lessons/he-phuong-trinh-bac-nhat/system-plane.svelte
@@ -0,0 +1,139 @@
+
+
+
diff --git a/src/lib/lessons/registry.js b/src/lib/lessons/registry.js
index 4423e86..9730d60 100644
--- a/src/lib/lessons/registry.js
+++ b/src/lib/lessons/registry.js
@@ -2,6 +2,7 @@ import { vi as gcdCopy } from './uoc-chung-lon-nhat/copy.vi.js';
import { vi as sieveCopy } from './sang-eratosthenes/copy.vi.js';
import { vi as diffSquaresCopy } from './hieu-hai-binh-phuong/copy.vi.js';
import { vi as linearCopy } from './duong-thang/copy.vi.js';
+import { vi as systemCopy } from './he-phuong-trinh-bac-nhat/copy.vi.js';
import { vi as pythagorasCopy } from './dinh-ly-pythagoras/copy.vi.js';
import { vi as sssCopy } from './tam-giac-bang-nhau/copy.vi.js';
import { vi as similarityCopy } from './tam-giac-dong-dang/copy.vi.js';
@@ -19,6 +20,7 @@ export const lessons = [
sieveCopy,
diffSquaresCopy,
linearCopy,
+ systemCopy,
pythagorasCopy,
sssCopy,
similarityCopy,
diff --git a/src/routes/dai-so/he-phuong-trinh-bac-nhat/+page.svelte b/src/routes/dai-so/he-phuong-trinh-bac-nhat/+page.svelte
new file mode 100644
index 0000000..e7f97a4
--- /dev/null
+++ b/src/routes/dai-so/he-phuong-trinh-bac-nhat/+page.svelte
@@ -0,0 +1,341 @@
+
+
+
+ {m.title} — {copy.site.title}
+
+
+
+
+