feat(dai-so): add the linear system lesson for lop 9

Two lines in standard form ax + by = c, with the intersection point as the
solution. Standard form rather than the slope-intercept form of linear.js,
because it represents vertical lines and the three cases fall out of one
determinant.

Coefficient sliders plus a drag handle per line that translates it, and
presets where the parallel and coincident cases differ only in c.
This commit is contained in:
2026-09-15 10:07:05 +07:00
parent 03c976cb5f
commit 737657b69a
8 changed files with 833 additions and 0 deletions
+1
View File
@@ -1 +1,2 @@
export { lineFromPoints, lineFromSlope, yAt, linePoints } from './linear.js';
export { solveSystem, clipToBox, constantThrough, isDegenerate, EPSILON_COEF } from './system.js';
+116
View File
@@ -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;
}
+113
View File
@@ -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);
});
});
@@ -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à ax + by = c với các hệ số a, b và a, b không đồng thời bằng 0. Nếu ab ab ≠ 0 thì hai đường thẳng cắt nhau và hệ có nghiệm duy nhất. Nếu ab ab = 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 ab = 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ố',
};
@@ -0,0 +1,74 @@
<script>
import Tex from '$lib/components/tex.svelte';
/** @type {{
* a: number,
* b: number,
* c: number,
* title: string,
* tex: string,
* accentClass: string,
* coefMax: number,
* constMax: number,
* coefALabel: string,
* coefBLabel: string,
* coefCLabel: string,
* }} */
let {
a = $bindable(),
b = $bindable(),
c = $bindable(),
title,
tex,
accentClass,
coefMax,
constMax,
coefALabel,
coefBLabel,
coefCLabel,
} = $props();
</script>
<fieldset class="bg-white rounded-lg border border-slate-200 p-5">
<legend class="px-1 text-sm font-semibold {accentClass}">{title}</legend>
<div class="mb-3 text-center">
<Tex math={tex} ariaLabel="{title}: {tex}" />
</div>
<label class="block mb-2">
<span class="text-sm text-slate-700">
{coefALabel}: <span class="tabular-nums font-semibold">{a}</span>
</span>
<input
type="range" min={-coefMax} max={coefMax} step="1"
bind:value={a}
aria-label="{title} {coefALabel}, hiện tại {a}"
class="mt-1 w-full accent-indigo-600"
/>
</label>
<label class="block mb-2">
<span class="text-sm text-slate-700">
{coefBLabel}: <span class="tabular-nums font-semibold">{b}</span>
</span>
<input
type="range" min={-coefMax} max={coefMax} step="1"
bind:value={b}
aria-label="{title} {coefBLabel}, hiện tại {b}"
class="mt-1 w-full accent-indigo-600"
/>
</label>
<label class="block">
<span class="text-sm text-slate-700">
{coefCLabel}: <span class="tabular-nums font-semibold">{c}</span>
</span>
<input
type="range" min={-constMax} max={constMax} step="1"
bind:value={c}
aria-label="{title} {coefCLabel}, hiện tại {c}"
class="mt-1 w-full accent-indigo-600"
/>
</label>
</fieldset>
@@ -0,0 +1,139 @@
<script>
import { draggable } from '$lib/actions/draggable.svelte.js';
/**
* @typedef {{x: number, y: number}} MutablePoint
* @typedef {import('$lib/geom-engine/vec.js').Vec2} Vec2
*/
/** @type {{
* svgEl: SVGSVGElement | undefined,
* view: number,
* pad: number,
* mx: (x: number) => number,
* my: (y: number) => number,
* gridLines: number[],
* seg1: [Vec2, Vec2] | null,
* seg2: [Vec2, Vec2] | null,
* handle1: MutablePoint,
* handle2: MutablePoint,
* drag1Opts: import('$lib/actions/draggable.svelte.js').DraggableParams,
* drag2Opts: import('$lib/actions/draggable.svelte.js').DraggableParams,
* solutionPoint: Vec2 | null,
* solutionMarkerLabel: string,
* coincident: boolean,
* plotLabel: string,
* handle1Label: string,
* handle2Label: string,
* }} */
let {
svgEl = $bindable(),
view,
pad,
mx,
my,
gridLines,
seg1,
seg2,
handle1,
handle2,
drag1Opts,
drag2Opts,
solutionPoint,
solutionMarkerLabel,
coincident,
plotLabel,
handle1Label,
handle2Label,
} = $props();
const LINE_1 = '#1B998B';
const LINE_2 = '#5E60CE';
const SOLUTION = '#D7263D';
</script>
<svg
bind:this={svgEl}
viewBox="0 0 {view} {view}"
preserveAspectRatio="xMidYMid meet"
class="block w-full max-w-md mx-auto bg-white rounded-lg border border-slate-200"
style="touch-action:none; aspect-ratio:1/1"
role="img"
aria-label={plotLabel}
>
<!-- Grid lines -->
{#each gridLines as v}
<line x1={mx(v)} y1={pad} x2={mx(v)} y2={view - pad} stroke="#e2e8f0" stroke-width="0.5" />
<line x1={pad} y1={my(v)} x2={view - pad} y2={my(v)} stroke="#e2e8f0" stroke-width="0.5" />
{/each}
<!-- Axes -->
<line x1={mx(0)} y1={pad} x2={mx(0)} y2={view - pad} stroke="#334155" stroke-width="1.5" />
<line x1={pad} y1={my(0)} x2={view - pad} y2={my(0)} stroke="#334155" stroke-width="1.5" />
<!-- Integer axis labels every 2 units -->
{#each gridLines as v}
{#if v !== 0 && v % 2 === 0}
<text x={mx(v)} y={my(0) + 14} text-anchor="middle" font-size="9" fill="#64748b">{v}</text>
<text x={mx(0) - 5} y={my(v) + 3} text-anchor="end" font-size="9" fill="#64748b">{v}</text>
{/if}
{/each}
<text x={mx(0) - 5} y={my(0) + 14} text-anchor="end" font-size="9" fill="#64748b">0</text>
<!-- Line (1): solid. When the lines coincide it is drawn thicker so the
overlap stays visible under the dashed line (2). -->
{#if seg1}
<line
x1={mx(seg1[0].x)} y1={my(seg1[0].y)}
x2={mx(seg1[1].x)} y2={my(seg1[1].y)}
stroke={LINE_1} stroke-width={coincident ? 6 : 2.5} stroke-linecap="round"
/>
{/if}
<!-- Line (2): dashed, so the two lines differ by more than color alone. -->
{#if seg2}
<line
x1={mx(seg2[0].x)} y1={my(seg2[0].y)}
x2={mx(seg2[1].x)} y2={my(seg2[1].y)}
stroke={LINE_2} stroke-width="2.5" stroke-linecap="round" stroke-dasharray="8 5"
/>
{/if}
<!-- Intersection point: the solution of the system -->
{#if solutionPoint}
<circle cx={mx(solutionPoint.x)} cy={my(solutionPoint.y)} r="6" fill={SOLUTION} stroke="#fff" stroke-width="2" />
<text
x={mx(solutionPoint.x) + 10}
y={my(solutionPoint.y) - 8}
font-size="11" font-weight="600" fill={SOLUTION}
>{solutionMarkerLabel}</text>
{/if}
<!-- Drag handle for line (1): square marker -->
{#if seg1}
<rect
x={handle1.x - 9} y={handle1.y - 9} width="18" height="18" rx="3"
fill={LINE_1} stroke="#fff" stroke-width="2"
role="button"
tabindex="0"
aria-label={handle1Label}
style="cursor:grab; outline:none"
use:draggable={drag1Opts}
/>
<text x={handle1.x} y={handle1.y + 4} text-anchor="middle" font-size="11" font-weight="700" fill="#fff" style="pointer-events:none">1</text>
{/if}
<!-- Drag handle for line (2): square marker -->
{#if seg2}
<rect
x={handle2.x - 9} y={handle2.y - 9} width="18" height="18" rx="3"
fill={LINE_2} stroke="#fff" stroke-width="2"
role="button"
tabindex="0"
aria-label={handle2Label}
style="cursor:grab; outline:none"
use:draggable={drag2Opts}
/>
<text x={handle2.x} y={handle2.y + 4} text-anchor="middle" font-size="11" font-weight="700" fill="#fff" style="pointer-events:none">2</text>
{/if}
</svg>
+2
View File
@@ -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,
@@ -0,0 +1,341 @@
<script>
import { base } from '$app/paths';
import { t } from '$lib/i18n/index.js';
import { vi as m } from '$lib/lessons/he-phuong-trinh-bac-nhat/copy.vi.js';
import EquationCard from '$lib/lessons/he-phuong-trinh-bac-nhat/equation-card.svelte';
import {
solveSystem,
clipToBox,
constantThrough,
isDegenerate,
} from '$lib/algebra-engine/system.js';
import SystemPlane from '$lib/lessons/he-phuong-trinh-bac-nhat/system-plane.svelte';
const copy = t();
// SVG layout constants — same 20-unit square as the other plane lessons.
const VIEW = 420;
const PAD = 30;
const SPAN = 10; // math units from origin to each edge
const U = (VIEW - PAD * 2) / (SPAN * 2); // px per math unit
/** Convert math x → SVG x */
const mx = (/** @type {number} */ x) => PAD + (x + SPAN) * U;
/** Convert math y → SVG y (Y-axis flipped in SVG) */
const my = (/** @type {number} */ y) => PAD + (SPAN - y) * U;
/** Convert SVG x → math x */
const svgXtoMath = (/** @type {number} */ sx) => (sx - PAD) / U - SPAN;
/** Convert SVG y → math y */
const svgYtoMath = (/** @type {number} */ sy) => SPAN - (sy - PAD) / U;
const COEF_MAX = 5; // slider bound for a and b
const CONST_MAX = 20; // slider bound for c
const INIT_1 = { a: 1, b: 1, c: 3 };
const INIT_2 = { a: 1, b: -1, c: 1 };
// ── Single source of truth: the six coefficients ────────────────────────────
let eq1 = $state({ ...INIT_1 });
let eq2 = $state({ ...INIT_2 });
const seg1 = $derived(clipToBox(eq1, -SPAN, SPAN, -SPAN, SPAN));
const seg2 = $derived(clipToBox(eq2, -SPAN, SPAN, -SPAN, SPAN));
const solution = $derived(solveSystem(eq1, eq2));
// Only show the intersection marker when it actually falls inside the plot.
const solutionPoint = $derived(
solution.kind === 'unique' &&
Math.abs(solution.point.x) <= SPAN &&
Math.abs(solution.point.y) <= SPAN
? solution.point
: null
);
// ── Drag handles: one per line, parked at the midpoint of its visible part ──
let handle1 = $state({ x: mx(0), y: my(0) });
let handle2 = $state({ x: mx(0), y: my(0) });
// Direction 1: coefficients → handle pixel position.
$effect(() => {
if (!seg1) return;
handle1.x = (mx(seg1[0].x) + mx(seg1[1].x)) / 2;
handle1.y = (my(seg1[0].y) + my(seg1[1].y)) / 2;
});
$effect(() => {
if (!seg2) return;
handle2.x = (mx(seg2[0].x) + mx(seg2[1].x)) / 2;
handle2.y = (my(seg2[0].y) + my(seg2[1].y)) / 2;
});
// Direction 2: handle drag → new constant term. Dragging translates the line
// so it passes through the pointer; direction (a, b) is untouched. Driven by
// the action's onChange hook, not by a state-watching effect, so slider
// writes cannot bounce back.
/** @param {{x: number, y: number}} handle @param {{a: number, b: number, c: number}} eq */
function constantFromHandle(handle, eq) {
const raw = constantThrough(eq, { x: svgXtoMath(handle.x), y: svgYtoMath(handle.y) });
return Math.max(-CONST_MAX, Math.min(CONST_MAX, Math.round(raw)));
}
function handleDrag1() {
eq1.c = constantFromHandle(handle1, eq1);
}
function handleDrag2() {
eq2.c = constantFromHandle(handle2, eq2);
}
/** @type {SVGSVGElement | undefined} */
let svgEl = $state();
/** Keep a dragged handle inside the plotted square. */
const clampPx = (/** @type {{x: number, y: number}} */ p) => ({
x: Math.max(mx(-SPAN), Math.min(mx(SPAN), p.x)),
y: Math.max(my(SPAN), Math.min(my(-SPAN), p.y)),
});
const drag1Opts = $derived({
point: handle1,
svg: () => svgEl ?? null,
viewBox: { w: VIEW, h: VIEW },
projector: clampPx,
pad: 0,
keyStep: U,
onChange: handleDrag1,
});
const drag2Opts = $derived({
point: handle2,
svg: () => svgEl ?? null,
viewBox: { w: VIEW, h: VIEW },
projector: clampPx,
pad: 0,
keyStep: U,
onChange: handleDrag2,
});
// ── Presentation helpers ────────────────────────────────────────────────────
/**
* Render `a·x + b·y = c` as TeX. Coefficients are integers, so terms with a
* coefficient of ±1 drop the digit and zero terms disappear entirely.
* @param {{a: number, b: number, c: number}} eq
*/
function eqTex(eq) {
const term = (/** @type {number} */ coef, /** @type {string} */ sym) => {
const mag = Math.abs(coef);
return (mag === 1 ? '' : String(mag)) + sym;
};
let lhs = '';
if (eq.a !== 0) lhs = (eq.a < 0 ? '-' : '') + term(eq.a, 'x');
if (eq.b !== 0) {
lhs =
lhs === ''
? (eq.b < 0 ? '-' : '') + term(eq.b, 'y')
: lhs + (eq.b < 0 ? ' - ' : ' + ') + term(eq.b, 'y');
}
if (lhs === '') lhs = '0';
return `${lhs} = ${eq.c}`;
}
/** Integers stay integers; fractional solutions get two decimals. */
const fmt = (/** @type {number} */ n) => (Number.isInteger(n) ? String(n) : n.toFixed(2));
const eq1Tex = $derived(eqTex(eq1));
const eq2Tex = $derived(eqTex(eq2));
const caseText = $derived(
solution.kind === 'unique'
? m.caseUnique
: solution.kind === 'parallel'
? m.caseParallel
: solution.kind === 'coincident'
? m.caseCoincident
: m.caseDegenerate
);
const offscreen = $derived(
(seg1 === null && !isDegenerate(eq1)) || (seg2 === null && !isDegenerate(eq2))
);
const solutionMarkerLabel = $derived(
solutionPoint ? `(${fmt(solutionPoint.x)}; ${fmt(solutionPoint.y)})` : ''
);
const solutionText = $derived(
solution.kind === 'unique'
? `(x; y) = (${fmt(solution.point.x)}; ${fmt(solution.point.y)})`
: m.noSolutionLabel
);
const plotLabel = $derived(
`Đồ thị của hệ ${eq1Tex}${eq2Tex}. ${caseText}.`
);
// Debounced aria-live announcement (300 ms) so dragging does not flood
// screen readers with intermediate states.
let ariaAnnounce = $state('');
let announceTimer = /** @type {ReturnType<typeof setTimeout> | undefined} */ (undefined);
$effect(() => {
const msg = `${eq1Tex}; ${eq2Tex}. ${caseText}. ${solutionText}`;
clearTimeout(announceTimer);
announceTimer = setTimeout(() => {
ariaAnnounce = msg;
}, 300);
return () => clearTimeout(announceTimer);
});
/**
* @param {{a: number, b: number, c: number}} next1
* @param {{a: number, b: number, c: number}} next2
*/
function applyPreset(next1, next2) {
eq1 = { ...next1 };
eq2 = { ...next2 };
}
// Presets share the same (a, b) pair within each of the parallel/coincident
// cases, so only the constant term distinguishes them.
const presets = [
{ label: m.presetUnique, one: { a: 1, b: 1, c: 3 }, two: { a: 1, b: -1, c: 1 } },
{ label: m.presetParallel, one: { a: 1, b: 2, c: 4 }, two: { a: 2, b: 4, c: -6 } },
{ label: m.presetCoincident, one: { a: 1, b: 2, c: 4 }, two: { a: 2, b: 4, c: 8 } },
];
function reset() {
applyPreset(INIT_1, INIT_2);
}
const gridLines = Array.from({ length: SPAN * 2 + 1 }, (_, i) => i - SPAN);
</script>
<svelte:head>
<title>{m.title}{copy.site.title}</title>
<meta name="description" content={m.intro} />
</svelte:head>
<header class="border-b border-slate-200 bg-white">
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<a href={base + '/'} class="text-xl font-bold text-indigo-600 tracking-tight">MathMax</a>
</div>
</header>
<main class="bg-slate-50 min-h-screen">
<article class="max-w-3xl mx-auto px-4 py-8">
<nav class="mb-4 text-sm">
<a href={base + '/dai-so/'} class="text-indigo-600 hover:underline">{copy.lessonChrome.backToTopic}</a>
</nav>
<header class="mb-6">
<div class="text-sm uppercase tracking-wide text-slate-500">{m.gradeLabel}</div>
<h1 class="text-3xl font-bold text-slate-900 mt-1 mb-2">{m.title}</h1>
<p class="text-slate-700 leading-relaxed">{m.intro}</p>
</header>
<!-- Coefficient sliders, one card per equation -->
<section class="mb-4">
<p class="text-sm text-slate-500 mb-3">{m.instructionSlider}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<EquationCard
bind:a={eq1.a}
bind:b={eq1.b}
bind:c={eq1.c}
title={m.eq1Label}
tex={eq1Tex}
accentClass="text-teal-700"
coefMax={COEF_MAX}
constMax={CONST_MAX}
coefALabel={m.coefA}
coefBLabel={m.coefB}
coefCLabel={m.coefC}
/>
<EquationCard
bind:a={eq2.a}
bind:b={eq2.b}
bind:c={eq2.c}
title={m.eq2Label}
tex={eq2Tex}
accentClass="text-indigo-700"
coefMax={COEF_MAX}
constMax={CONST_MAX}
coefALabel={m.coefA}
coefBLabel={m.coefB}
coefCLabel={m.coefC}
/>
</div>
</section>
<!-- SVG plane: grid, axes, both lines, intersection, drag handles -->
<section class="mb-4">
<p class="text-sm text-slate-500 mb-2">{m.instructionDrag}</p>
<SystemPlane
bind:svgEl
view={VIEW}
pad={PAD}
{mx} {my}
{gridLines}
{seg1} {seg2}
{handle1} {handle2}
{drag1Opts} {drag2Opts}
{solutionPoint}
{solutionMarkerLabel}
coincident={solution.kind === 'coincident'}
{plotLabel}
handle1Label="{m.handle1Label} kéo hoặc dùng phím mũi tên"
handle2Label="{m.handle2Label} — kéo hoặc dùng phím mũi tên"
/>
</section>
{#if offscreen}
<p class="mb-4 rounded-lg border border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900">
{m.offscreenNote}
</p>
{/if}
<!-- Case readout -->
<section class="mb-6 rounded-lg border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500 mb-2">{m.caseTitle}</h2>
<p class="text-slate-900 font-medium mb-1">{caseText}</p>
<p class="text-slate-700">
<span class="text-sm text-slate-500">{m.solutionLabel}:</span>
<span class="tabular-nums font-semibold">{solutionText}</span>
</p>
</section>
<!-- aria-live (visually hidden): announces the case after input settles -->
<div aria-live="polite" aria-atomic="true" class="sr-only">{ariaAnnounce}</div>
<!-- Presets + reset -->
<section class="mb-8">
<h2 class="text-sm font-semibold text-slate-700 mb-2">{m.presetTitle}</h2>
<div class="flex flex-wrap items-center gap-3">
{#each presets as preset (preset.label)}
<button
onclick={() => applyPreset(preset.one, preset.two)}
class="px-4 py-1.5 rounded-lg border border-slate-300 text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 active:bg-slate-100 transition-colors"
>
{preset.label}
</button>
{/each}
<button
onclick={reset}
class="ml-auto px-4 py-1.5 rounded-lg border border-slate-300 text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 active:bg-slate-100 transition-colors"
>
{m.resetLabel}
</button>
</div>
</section>
<section class="mb-8">
<h2 class="text-lg font-bold text-slate-900 mb-2">{m.theoremTitle}</h2>
<p class="rounded-lg bg-slate-100 p-4 text-slate-800">{m.theoremStatement}</p>
</section>
<section class="mb-10">
<h2 class="text-lg font-bold text-slate-900 mb-2">{m.exampleTitle}</h2>
<p class="text-slate-700 leading-relaxed">{m.exampleBody}</p>
</section>
<footer class="border-t border-slate-200 pt-4 text-sm text-slate-500">
{m.nextTeaser}
</footer>
</article>
</main>