mirror of
https://github.com/tiennm99/mathmax.git
synced 2026-09-17 16:20:41 +00:00
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:
@@ -1 +1,2 @@
|
||||
export { lineFromPoints, lineFromSlope, yAt, linePoints } from './linear.js';
|
||||
export { solveSystem, clipToBox, constantThrough, isDegenerate, EPSILON_COEF } from './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;
|
||||
}
|
||||
@@ -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à 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ố',
|
||||
};
|
||||
@@ -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,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} và ${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>
|
||||
Reference in New Issue
Block a user