feat: 3 interactive demo pages + transforms/sieve/linear engines

Lessons:
- /hinh-hoc/dinh-ly-pythagoras (lớp 7) — drag right-angle vertex; "Chứng minh"
  button plays Euclid-style dissection-shear animation tweening two leg-squares
  into the hypotenuse-square. Respects prefers-reduced-motion.
- /so-hoc/sang-eratosthenes (lớp 6) — 10×10 grid; click 2/3/5/7 → ripple
  cross-out of multiples in 4 colors; roving tabindex; aria-live announces.
- /dai-so/duong-thang (lớp 7) — y = ax + b plot; 2 sliders + 2 draggable
  anchors bidirectionally bound via draggable.onChange + viewport clamping +
  rounded epsilon gate (no oscillation at any drag speed).

Engines:
- geom-engine/transforms.js — translate, rotate, shear, compose, applyToPolygon (13 tests)
- numtheory-engine/sieve.js — sieveUpTo, multiplesOf, isPrime (10 tests)
- algebra-engine/linear.js — lineFromPoints, yAt, linePoints (6 tests); new module bootstrapped

Glue:
- registry: insert 3 new lessons in topic+grade order
- tailwind: extend colors.pair to 4 entries (pair.4 = #5E60CE for prime 7)
- README: bump count 5→8, list new URLs, update architecture section
This commit is contained in:
2026-05-15 20:35:22 +07:00
parent fb6089aff0
commit 9d5d3f04a0
21 changed files with 1478 additions and 4 deletions
+6 -3
View File
@@ -4,10 +4,13 @@ Toán tương tác cho học sinh THCS Việt Nam (lớp 6-9). Số học, Đạ
## Status
5 bài đã ra mắt — số học, đại số, hình học đều có ít nhất một bài.
8 bài đã ra mắt — số học, đại số, hình học đều có ít nhất hai bài.
- Lớp 6 — Ước chung lớn nhất (Euclid): `/so-hoc/uoc-chung-lon-nhat/`
- Lớp 6 — Sàng Eratosthenes: `/so-hoc/sang-eratosthenes/`
- Lớp 7 — Hiệu hai bình phương: `/dai-so/hieu-hai-binh-phuong/`
- Lớp 7 — Đồ thị y = ax + b: `/dai-so/duong-thang/`
- Lớp 7 — Định lý Pythagoras: `/hinh-hoc/dinh-ly-pythagoras/`
- Lớp 7 — Tam giác bằng nhau (SSS): `/hinh-hoc/tam-giac-bang-nhau/`
- Lớp 8 — Tam giác đồng dạng: `/hinh-hoc/tam-giac-dong-dang/`
- Lớp 9 — Góc nội tiếp: `/hinh-hoc/goc-noi-tiep/`
@@ -34,9 +37,9 @@ Live URL: https://tiennm99.github.io/mathmax/
## Architecture
- **Static**: SvelteKit + `@sveltejs/adapter-static`, `paths.base = '/mathmax'`, output `build/`.
- **Styling**: Tailwind 3 (PostCSS) + Be Vietnam Pro (woff2 qua `@fontsource`). Tick palette `colors.pair.{1,2,3}` được khai báo trong `tailwind.config.js`.
- **Styling**: Tailwind 3 (PostCSS) + Be Vietnam Pro (woff2 qua `@fontsource`). Tick palette `colors.pair.{1,2,3,4}` được khai báo trong `tailwind.config.js`.
- **Language**: JavaScript only (Svelte 5, JSDoc qua `jsconfig.json` với `checkJs: true`).
- **Math engines**: `src/lib/geom-engine/` (vec, triangle, circle, ticks) và `src/lib/numtheory-engine/` (gcd, lcm, gcdSteps). Module thuần, không phụ thuộc DOM. Vitest unit tests đi kèm.
- **Math engines**: `src/lib/geom-engine/` (vec, triangle, circle, ticks, transforms), `src/lib/numtheory-engine/` (gcd, lcm, gcdSteps, sieve), `src/lib/algebra-engine/` (linear). Module thuần, không phụ thuộc DOM. Vitest unit tests đi kèm.
- **Math typography**: `src/lib/components/tex.svelte` — wrapper KaTeX duy nhất. SSR qua `renderToString`, không cần JS phía client để hiển thị.
- **Lessons**: mỗi bài là một `+page.svelte`; copy tiếng Việt colocate trong `src/lib/lessons/<slug>/copy.vi.js`.
- **Drag**: Svelte action `use:draggable` (`src/lib/actions/draggable.svelte.js`) — Pointer Events + bàn phím mũi tên cho a11y.
+1
View File
@@ -0,0 +1 @@
export { lineFromPoints, lineFromSlope, yAt, linePoints } from './linear.js';
+46
View File
@@ -0,0 +1,46 @@
import { EPSILON_LEN } from '../geom-engine/vec.js';
/**
* @typedef {Readonly<{a: number, b: number}>} Line // y = a·x + b
* @typedef {import('../geom-engine/vec.js').Vec2} Vec2
*/
/**
* Slope-intercept line through two points. Returns `null` when the points
* share an x-coordinate (vertical line — not representable as y = ax + b).
* @param {Vec2} p1 @param {Vec2} p2 @returns {Line | null}
*/
export function lineFromPoints(p1, p2) {
if (Math.abs(p2.x - p1.x) < EPSILON_LEN) return null;
const a = (p2.y - p1.y) / (p2.x - p1.x);
const b = p1.y - a * p1.x;
return { a, b };
}
/**
* Construct a line from slope `a` and intercept `b`.
* @param {number} a @param {number} b @returns {Line}
*/
export function lineFromSlope(a, b) {
return { a, b };
}
/**
* Evaluate the line at `x`.
* @param {Line} line @param {number} x @returns {number}
*/
export function yAt(line, x) {
return line.a * x + line.b;
}
/**
* Return the two endpoints of the line clipped to the x-range [xMin, xMax].
* @param {Line} line @param {number} xMin @param {number} xMax
* @returns {[Vec2, Vec2]}
*/
export function linePoints(line, xMin, xMax) {
return [
{ x: xMin, y: yAt(line, xMin) },
{ x: xMax, y: yAt(line, xMax) },
];
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { lineFromPoints, lineFromSlope, yAt, linePoints } from './linear.js';
describe('lineFromPoints', () => {
it('two distinct-x points yield slope+intercept', () => {
expect(lineFromPoints({ x: 0, y: 1 }, { x: 1, y: 3 })).toEqual({ a: 2, b: 1 });
});
it('vertical (same x) returns null', () => {
expect(lineFromPoints({ x: 2, y: 5 }, { x: 2, y: 7 })).toBeNull();
});
it('round-trip: yAt recovers original y at p1.x and p2.x', () => {
const p1 = { x: -3, y: 4 };
const p2 = { x: 5, y: -2 };
const line = lineFromPoints(p1, p2);
if (!line) throw new Error('unexpected null');
expect(yAt(line, p1.x)).toBeCloseTo(p1.y);
expect(yAt(line, p2.x)).toBeCloseTo(p2.y);
});
});
describe('yAt', () => {
it('evaluates y = 2x + 1 at x = 3 → 7', () => {
expect(yAt({ a: 2, b: 1 }, 3)).toBe(7);
});
});
describe('linePoints', () => {
it('y = x clipped to [-5, 5] yields the two diagonal endpoints', () => {
expect(linePoints({ a: 1, b: 0 }, -5, 5)).toEqual([
{ x: -5, y: -5 },
{ x: 5, y: 5 },
]);
});
});
describe('lineFromSlope', () => {
it('horizontal line y = 4', () => {
expect(lineFromSlope(0, 4)).toEqual({ a: 0, b: 4 });
});
});
+9
View File
@@ -14,3 +14,12 @@ export {
export { triangle, sides, congruentSSS } from './triangle.js';
export { circle, projectToCircle, pointOnCircle, angleAtVertex } from './circle.js';
export { tickPositions } from './ticks.js';
export {
translate,
rotate,
shear,
compose,
applyToPoint,
applyToPolygon,
approxEqualMat,
} from './transforms.js';
+99
View File
@@ -0,0 +1,99 @@
import { EPSILON_LEN } from './vec.js';
/**
* Row-major 3x3 affine-transform matrix stored as a length-9 array:
* [a b c | d e f | 0 0 1] applied to homogeneous (x, y, 1).
* @typedef {readonly number[]} Mat3
* @typedef {import('./vec.js').Vec2} Vec2
*/
/** @type {Mat3} */
const IDENTITY = Object.freeze([1, 0, 0, 0, 1, 0, 0, 0, 1]);
/**
* Translate by (dx, dy).
* @param {number} dx @param {number} dy @returns {Mat3}
*/
export function translate(dx, dy) {
return [1, 0, dx, 0, 1, dy, 0, 0, 1];
}
/**
* Rotate by `theta` radians, optionally around `center` (defaults to origin).
* @param {number} theta @param {Vec2} [center] @returns {Mat3}
*/
export function rotate(theta, center) {
const c = Math.cos(theta);
const s = Math.sin(theta);
if (!center) return [c, -s, 0, s, c, 0, 0, 0, 1];
// Apply order: translate(-c) → rotate-origin → translate(+c).
return compose(translate(-center.x, -center.y), [c, -s, 0, s, c, 0, 0, 0, 1], translate(center.x, center.y));
}
/**
* Shear: x' = x + kx·y, y' = y + ky·x.
* @param {number} kx @param {number} ky @returns {Mat3}
*/
export function shear(kx, ky) {
return [1, kx, 0, ky, 1, 0, 0, 0, 1];
}
/**
* Left-to-right composition: compose(A, B, C) applies A then B then C
* (i.e. returns C · B · A in matrix multiply notation, so applying it
* to a point p means C(B(A(p)))).
* Zero args returns identity.
* @param {...Mat3} matrices @returns {Mat3}
*/
export function compose(...matrices) {
if (matrices.length === 0) return IDENTITY.slice();
return matrices.reduce((acc, m) => multiply(m, acc));
}
/**
* Apply transform `m` to point `p`.
* @param {Mat3} m @param {Vec2} p @returns {Vec2}
*/
export function applyToPoint(m, p) {
return {
x: m[0] * p.x + m[1] * p.y + m[2],
y: m[3] * p.x + m[4] * p.y + m[5],
};
}
/**
* Apply transform `m` to every point in `points`. Returns a new array.
* @param {Mat3} m @param {readonly Vec2[]} points @returns {Vec2[]}
*/
export function applyToPolygon(m, points) {
return points.map((p) => applyToPoint(m, p));
}
/**
* Approximate matrix equality (per-element, within EPSILON_LEN by default).
* @param {Mat3} a @param {Mat3} b @param {number} [eps]
*/
export function approxEqualMat(a, b, eps = EPSILON_LEN) {
for (let i = 0; i < 9; i++) {
if (Math.abs(a[i] - b[i]) > eps) return false;
}
return true;
}
/**
* 3x3 matrix multiply: returns A · B.
* @param {Mat3} a @param {Mat3} b @returns {Mat3}
*/
function multiply(a, b) {
return [
a[0] * b[0] + a[1] * b[3] + a[2] * b[6],
a[0] * b[1] + a[1] * b[4] + a[2] * b[7],
a[0] * b[2] + a[1] * b[5] + a[2] * b[8],
a[3] * b[0] + a[4] * b[3] + a[5] * b[6],
a[3] * b[1] + a[4] * b[4] + a[5] * b[7],
a[3] * b[2] + a[4] * b[5] + a[5] * b[8],
a[6] * b[0] + a[7] * b[3] + a[8] * b[6],
a[6] * b[1] + a[7] * b[4] + a[8] * b[7],
a[6] * b[2] + a[7] * b[5] + a[8] * b[8],
];
}
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import {
translate,
rotate,
shear,
compose,
applyToPoint,
applyToPolygon,
approxEqualMat,
} from './transforms.js';
import { EPSILON_LEN } from './vec.js';
const HALF_PI = Math.PI / 2;
describe('translate', () => {
it('identity translate (0,0) leaves point unchanged', () => {
const m = translate(0, 0);
expect(applyToPoint(m, { x: 3, y: 4 })).toEqual({ x: 3, y: 4 });
});
it('translates a point', () => {
const m = translate(2, -1);
expect(applyToPoint(m, { x: 1, y: 1 })).toEqual({ x: 3, y: 0 });
});
});
describe('rotate', () => {
it('rotates 90° around origin: (1,0) → (0,1)', () => {
const m = rotate(HALF_PI);
const p = applyToPoint(m, { x: 1, y: 0 });
expect(p.x).toBeCloseTo(0);
expect(p.y).toBeCloseTo(1);
});
it('rotates 90° around custom center (5,5): (6,5) → (5,6)', () => {
const m = rotate(HALF_PI, { x: 5, y: 5 });
const p = applyToPoint(m, { x: 6, y: 5 });
expect(p.x).toBeCloseTo(5);
expect(p.y).toBeCloseTo(6);
});
it('rotate θ then rotate -θ is identity', () => {
const m = compose(rotate(0.7), rotate(-0.7));
expect(applyToPoint(m, { x: 3, y: 4 }).x).toBeCloseTo(3);
expect(applyToPoint(m, { x: 3, y: 4 }).y).toBeCloseTo(4);
});
});
describe('shear', () => {
it('horizontal shear (1,0): (0,1) → (1,1)', () => {
const m = shear(1, 0);
expect(applyToPoint(m, { x: 0, y: 1 })).toEqual({ x: 1, y: 1 });
});
it('vertical shear (0,1): (1,0) → (1,1)', () => {
const m = shear(0, 1);
expect(applyToPoint(m, { x: 1, y: 0 })).toEqual({ x: 1, y: 1 });
});
});
describe('compose', () => {
it('zero args returns identity (length 9)', () => {
const m = compose();
expect(m).toHaveLength(9);
expect(applyToPoint(m, { x: 7, y: -3 })).toEqual({ x: 7, y: -3 });
});
it('order is left-to-right: translate then rotate', () => {
// Apply translate(2,0) first, then rotate 90° around origin.
// (1,0) → translate → (3,0) → rotate 90° → (0,3)
const m = compose(translate(2, 0), rotate(HALF_PI));
const p = applyToPoint(m, { x: 1, y: 0 });
expect(p.x).toBeCloseTo(0);
expect(p.y).toBeCloseTo(3);
});
});
describe('applyToPolygon', () => {
it('preserves point count', () => {
const pts = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }];
expect(applyToPolygon(translate(5, 5), pts)).toHaveLength(3);
});
it('does not mutate input array', () => {
const pts = [{ x: 0, y: 0 }, { x: 1, y: 1 }];
const snapshot = JSON.stringify(pts);
applyToPolygon(translate(10, 10), pts);
expect(JSON.stringify(pts)).toBe(snapshot);
});
});
describe('approxEqualMat', () => {
it('matches within EPSILON_LEN tolerance', () => {
const a = translate(1, 1);
const b = translate(1 + EPSILON_LEN / 2, 1);
expect(approxEqualMat(a, b)).toBe(true);
});
it('rejects beyond tolerance', () => {
expect(approxEqualMat(translate(0, 0), translate(2, 0))).toBe(false);
});
});
@@ -0,0 +1,25 @@
export const vi = {
slug: 'dinh-ly-pythagoras',
topic: 'hinh-hoc',
grade: 'lop-7',
gradeLabel: 'Lớp 7',
title: 'Định lý Pythagoras',
intro:
'Định lý Pythagoras phát biểu rằng: trong một tam giác vuông, bình phương cạnh huyền bằng tổng bình phương hai cạnh góc vuông. Kéo đỉnh góc vuông để thay đổi kích thước tam giác và quan sát ba hình vuông cập nhật theo thời gian thực.',
instruction:
'Kéo điểm R (góc vuông, màu đỏ) — hoặc dùng phím mũi tên. Nhấn "Chứng minh" để xem hoạt họa minh hoạ.',
buttonProve: 'Chứng minh',
buttonReset: 'Đặt lại',
theoremTitle: 'Định lý Pythagoras',
theoremStatement:
'Trong một tam giác vuông có hai cạnh góc vuông a, b và cạnh huyền c, ta luôn có: a² + b² = c².',
proofTitle: 'Chứng minh bằng hình học',
proofBody:
'Vẽ hình vuông trên mỗi cạnh của tam giác vuông. Bằng cách chiếu đường cao từ đỉnh góc vuông xuống cạnh huyền, hình vuông trên cạnh a sẽ "trượt và biến dạng" khớp vào phần đầu của hình vuông cạnh huyền, và hình vuông trên cạnh b khớp vào phần còn lại. Hai mảnh này lấp đầy hình vuông cạnh huyền, chứng tỏ a² + b² = c².',
exampleTitle: 'Ví dụ',
exampleBody:
'Tam giác vuông 3-4-5 là ví dụ nổi tiếng nhất: a = 3, b = 4, c = 5. Kiểm tra: 3² + 4² = 9 + 16 = 25 = 5². Các bộ số nguyên dương (a, b, c) thoả mãn a² + b² = c² được gọi là bộ ba Pythagoras (ví dụ: 5-12-13, 8-15-17).',
sidesLabel: 'Các cạnh',
areasLabel: 'Diện tích',
nextTeaser: 'Sắp ra mắt: Tam giác đồng dạng',
};
@@ -0,0 +1,120 @@
/**
* Geometry helpers for the Pythagoras dissection-shear lesson.
* All coordinates are in SVG viewBox units (0 0 400 400).
*
* Triangle layout (legs axis-aligned):
* A — top apex, x = R.x, y = fixed top
* R — right-angle vertex, draggable
* H — horizontal foot, y = R.y, x = fixed right
*
* @typedef {{ x: number; y: number }} Pt
* @typedef {Pt[]} Poly
*/
/**
* Build square-a polygon (on vertical leg AR, extends LEFT from the leg).
* Vertices in order: A, R, bottom-left, top-left.
* @param {Pt} A @param {Pt} R @returns {Poly}
*/
export function squareA(A, R) {
const a = R.y - A.y; // leg length
return [A, R, { x: R.x - a, y: R.y }, { x: R.x - a, y: A.y }];
}
/**
* Build square-b polygon (on horizontal leg RH, extends DOWN from the leg).
* Vertices: R, H, bottom-right, bottom-left.
* @param {Pt} R @param {Pt} H @returns {Poly}
*/
export function squareB(R, H) {
const b = H.x - R.x; // leg length
return [R, H, { x: H.x, y: R.y + b }, { x: R.x, y: R.y + b }];
}
/**
* Build square-c polygon (on hypotenuse AH, extends AWAY from triangle).
* The outward normal direction from AH (away from R) is (a/c, -b/c) scaled by c.
* Vertices: A, H, H+normal, A+normal.
* @param {Pt} A @param {Pt} H @param {number} a @param {number} b @param {number} c
* @returns {Poly}
*/
export function squareC(A, H, a, b, c) {
// Outward normal unit vector (rotated 90° clockwise from AH direction):
// AH direction = (b/c, a/c) → normal = (a/c, -b/c)
const nx = a; // not yet divided by c; we scale by c below so net offset = (a, -b)
const ny = -b;
return [
A,
H,
{ x: H.x + nx, y: H.y + ny },
{ x: A.x + nx, y: A.y + ny },
];
}
/**
* Compute the foot of the altitude from R onto hypotenuse AH.
* F = A + t*(H-A) where t = dot(R-A, H-A) / |H-A|²
* For axis-aligned legs (a vertical, b horizontal):
* t = a² / c²
* @param {Pt} A @param {Pt} H @param {number} a @param {number} c @returns {Pt}
*/
export function altitudeFoot(A, H, a, c) {
const t = (a * a) / (c * c);
return { x: A.x + t * (H.x - A.x), y: A.y + t * (H.y - A.y) };
}
/**
* Target polygon for shear-a animation: the "a-rectangle" portion of square-c.
* This is the rectangle bounded by A, F (altitude foot), and the corresponding
* two points on the far edge of square-c. Area = a² = a-side × AF.
* @param {Pt} A @param {Pt} F @param {number} a @param {number} b @param {number} c
* @returns {Poly}
*/
export function shearATarget(A, F, a, b, c) {
const nx = a;
const ny = -b;
return [
A,
F,
{ x: F.x + nx, y: F.y + ny },
{ x: A.x + nx, y: A.y + ny },
];
}
/**
* Target polygon for shear-b animation: the "b-rectangle" portion of square-c.
* Bounded by F, H, and the far edge of square-c. Area = b².
* @param {Pt} F @param {Pt} H @param {number} a @param {number} b @param {number} c
* @returns {Poly}
*/
export function shearBTarget(F, H, a, b, c) {
const nx = a;
const ny = -b;
return [
F,
H,
{ x: H.x + nx, y: H.y + ny },
{ x: F.x + nx, y: F.y + ny },
];
}
/**
* Linearly interpolate between two polygons of equal length.
* Returns a new polygon with each vertex lerped.
* @param {Poly} from @param {Poly} to @param {number} t — 0..1
* @returns {Poly}
*/
export function lerpPoly(from, to, t) {
return from.map((p, i) => ({
x: p.x + (to[i].x - p.x) * t,
y: p.y + (to[i].y - p.y) * t,
}));
}
/**
* Convert a Poly to an SVG points attribute string.
* @param {Poly} pts @returns {string}
*/
export function polyPoints(pts) {
return pts.map((p) => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ');
}
@@ -0,0 +1,130 @@
<script>
import { draggable } from '$lib/actions/draggable.svelte.js';
/**
* @typedef {{x: number, y: number}} MutablePoint
* @typedef {import('$lib/algebra-engine/linear.js').Line} Line
*/
/** @type {{
* svgEl: SVGSVGElement | undefined,
* view: number,
* pad: number,
* u: number,
* mx: (x: number) => number,
* my: (y: number) => number,
* gridLines: number[],
* pts: [{x:number,y:number},{x:number,y:number}],
* p1: MutablePoint,
* p2: MutablePoint,
* anchor1MathY: number,
* anchor2MathY: number,
* drag1Opts: import('$lib/actions/draggable.svelte.js').DraggableParams,
* drag2Opts: import('$lib/actions/draggable.svelte.js').DraggableParams,
* showTriangle: boolean,
* anchor1X: number,
* anchor2X: number,
* deltaY: number,
* texMath: string,
* anchor1Label: string,
* anchor2Label: string,
* }} */
let {
svgEl = $bindable(),
view,
pad,
u,
mx,
my,
gridLines,
pts,
p1,
p2,
anchor1MathY,
anchor2MathY,
drag1Opts,
drag2Opts,
showTriangle,
anchor1X,
anchor2X,
deltaY,
texMath,
anchor1Label,
anchor2Label,
} = $props();
</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="Đồ thị hàm số {texMath}"
>
<!-- Grid lines (light slate) -->
{#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 (slate-700, thicker) -->
<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>
<!-- Rise/run triangle overlay (optional) -->
{#if showTriangle}
{@const tx1 = mx(anchor1X)}
{@const ty1 = my(anchor1MathY)}
{@const tx2 = mx(anchor2X)}
{@const ty2 = my(anchor2MathY)}
{@const cornerX = tx2}
{@const cornerY = ty1}
<polygon
points="{tx1},{ty1} {cornerX},{cornerY} {tx2},{ty2}"
fill="#D7263D" fill-opacity="0.12"
stroke="#D7263D" stroke-width="1.5" stroke-dasharray="5 3"
/>
<text x={(tx1 + tx2) / 2} y={cornerY + 14} text-anchor="middle" font-size="11" fill="#D7263D" font-weight="600">Δx = 10</text>
<text x={tx2 + 6} y={(cornerY + ty2) / 2} text-anchor="start" dominant-baseline="middle" font-size="11" fill="#D7263D" font-weight="600">Δy = {deltaY.toFixed(1)}</text>
{/if}
<!-- Line (teal) -->
<line
x1={mx(pts[0].x)} y1={my(pts[0].y)}
x2={mx(pts[1].x)} y2={my(pts[1].y)}
stroke="#1B998B" stroke-width="2.5" stroke-linecap="round"
/>
<!-- Anchor 1 (x = anchor1X) -->
<circle
cx={p1.x} cy={p1.y} r="12"
fill="#D7263D" stroke="#fff" stroke-width="2"
role="button"
tabindex="0"
aria-label="{anchor1Label}, y = {anchor1MathY.toFixed(1)} — kéo hoặc dùng phím mũi tên"
style="cursor:grab; outline:none"
use:draggable={drag1Opts}
/>
<!-- Anchor 2 (x = anchor2X) -->
<circle
cx={p2.x} cy={p2.y} r="12"
fill="#D7263D" stroke="#fff" stroke-width="2"
role="button"
tabindex="0"
aria-label="{anchor2Label}, y = {anchor2MathY.toFixed(1)} — kéo hoặc dùng phím mũi tên"
style="cursor:grab; outline:none"
use:draggable={drag2Opts}
/>
</svg>
+24
View File
@@ -0,0 +1,24 @@
export const vi = {
slug: 'duong-thang',
topic: 'dai-so',
grade: 'lop-7',
title: 'Đồ thị hàm số y = ax + b',
gradeLabel: 'Lớp 7',
intro:
'Hàm số bậc nhất y = ax + b có đồ thị là một đường thẳng. Hệ số a (độ dốc) quyết định độ nghiêng; hằng số b (tung độ gốc) quyết định vị trí đường thẳng cắt trục tung. Kéo hai thanh trượt hoặc kéo trực tiếp hai điểm neo để khám phá.',
sliderALabel: 'Độ dốc a',
sliderBLabel: 'Tung độ gốc b',
instructionSlider: 'Kéo các thanh trượt để thay đổi a và b',
instructionAnchor: 'Hoặc kéo trực tiếp hai điểm đỏ trên đường thẳng',
anchor1Label: 'Điểm neo 1 tại x = 5',
anchor2Label: 'Điểm neo 2 tại x = 5',
showTriangleLabel: 'Hiện tam giác đo độ dốc',
resetLabel: 'Đặt lại',
theoremTitle: 'Tính chất',
theoremStatement:
'Đồ thị hàm số bậc nhất y = ax + b (a ≠ 0) là một đường thẳng không đi qua gốc toạ độ (trừ khi b = 0). Khi a > 0, đường thẳng đi lên từ trái sang phải; khi a < 0, đường thẳng đi xuống.',
exampleTitle: 'Ví dụ',
exampleBody:
'Với a = 2 và b = 1, hàm số là y = 2x 1. Tại x = 0 ta có y = 1, tại x = 1 ta có y = 1. Độ dốc bằng 2 nghĩa là khi x tăng 1 đơn vị, y tăng 2 đơn vị.',
nextTeaser: 'Sắp ra mắt: Phương trình đường thẳng qua hai điểm',
};
+13 -1
View File
@@ -1,5 +1,8 @@
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 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';
import { vi as inscribedCopy } from './goc-noi-tiep/copy.vi.js';
@@ -11,7 +14,16 @@ import { vi as inscribedCopy } from './goc-noi-tiep/copy.vi.js';
// Order: by topic (số học → đại số → hình học), then by grade ascending.
/** @type {LessonCopy[]} */
export const lessons = [gcdCopy, diffSquaresCopy, sssCopy, similarityCopy, inscribedCopy];
export const lessons = [
gcdCopy,
sieveCopy,
diffSquaresCopy,
linearCopy,
pythagorasCopy,
sssCopy,
similarityCopy,
inscribedCopy,
];
/** @param {string} topic */
export function lessonsByTopic(topic) {
@@ -0,0 +1,31 @@
export const vi = {
slug: 'sang-eratosthenes',
topic: 'so-hoc',
grade: 'lop-6',
gradeLabel: 'Lớp 6',
title: 'Sàng Eratosthenes',
intro:
'Cách đơn giản nhất để tìm tất cả số nguyên tố từ 1 đến 100: lần lượt gạch bỏ các bội số của từng số nguyên tố nhỏ nhất. Những ô còn lại chính là số nguyên tố.',
instruction:
'Bấm vào số 2 trước, rồi 3, rồi 5, rồi 7 — và quan sát các bội số bị gạch dần. Sau bốn lần bấm, các số chưa bị gạch chính là tất cả số nguyên tố từ 2 đến 100.',
legendTitle: 'Chú giải màu',
legendLabels: {
2: 'Bội của 2',
3: 'Bội của 3',
5: 'Bội của 5',
7: 'Bội của 7',
},
primeTooltip:
'Số nguyên tố là số tự nhiên lớn hơn 1, chỉ chia hết cho 1 và chính nó.',
specialOne: 'Số 1 không phải số nguyên tố và không phải hợp số.',
resetLabel: 'Đặt lại',
theoremTitle: 'Sàng Eratosthenes là gì?',
theoremStatement:
'Sàng Eratosthenes là thuật toán cổ đại do nhà toán học Hy Lạp Eratosthenes (khoảng 276194 TCN) phát minh. Để tìm số nguyên tố đến n: bắt đầu từ 2, gạch bỏ tất cả bội số của nó; chuyển sang số chưa bị gạch tiếp theo (3), gạch bỏ các bội; lặp lại cho đến khi đã xét hết các số đến √n. Tất cả số còn lại là số nguyên tố.',
exampleTitle: 'Ví dụ',
exampleBody:
'Sau khi bấm vào 2, 3, 5 và 7, lưới 10×10 sẽ giữ lại đúng 25 số nguyên tố: 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97. Mỗi số nguyên tố không bị gạch vì không phải bội số của số nào nhỏ hơn nó (trừ 1).',
nextTeaser: 'Sắp ra mắt: Ước chung lớn nhất và thuật toán Euclid',
announceRipple: /** @param {number} p @param {number[]} mults */ (p, mults) =>
`Đã gạch các bội của ${p}: ${mults.join(', ')}`,
};
@@ -0,0 +1,136 @@
/**
* Reactive state and interaction logic for the Sàng Eratosthenes grid.
* Call createGridState() once per component; it returns a reactive proxy
* whose properties are live $state reads (Svelte 5 runes).
*/
import { multiplesOf, isPrime } from '$lib/numtheory-engine/sieve.js';
const STAGGER_MS = 30;
/**
* Detects prefers-reduced-motion in browser; false on SSR.
* @returns {boolean}
*/
function detectReducedMotion() {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/**
* @param {(p: number, mults: number[]) => string} buildAnnouncement
*/
export function createGridState(buildAnnouncement) {
/** @type {number[]} */
let markedPrimes = $state([]);
/** @type {Map<number, number[]>} */
let crossings = $state(new Map());
let rippling = $state(false);
/** @type {number[]} */
let pendingTimeouts = $state([]);
let announcement = $state('');
let focusIndex = $state(0);
/** @type {number | null} */
let shakeIndex = $state(null);
/** @type {HTMLButtonElement[]} */
const cellRefs = $state(new Array(100).fill(null));
const reducedMotion = detectReducedMotion();
/** @param {number} n */
function handleCellActivate(n) {
if (rippling) return;
if (markedPrimes.includes(n)) return;
if (!isPrime(n)) {
shakeIndex = n - 1;
setTimeout(() => { shakeIndex = null; }, 400);
return;
}
markedPrimes = [...markedPrimes, n];
const multiples = multiplesOf(n, 100);
if (reducedMotion) {
const next = new Map(crossings);
for (const cell of multiples) {
next.set(cell, [...(next.get(cell) ?? []), n]);
}
crossings = next;
announcement = buildAnnouncement(n, multiples);
return;
}
rippling = true;
/** @type {number[]} */
const newTids = [];
for (let i = 0; i < multiples.length; i++) {
const cell = multiples[i];
const isLast = i === multiples.length - 1;
const tid = /** @type {number} */ (setTimeout(() => {
const next = new Map(crossings);
next.set(cell, [...(next.get(cell) ?? []), n]);
crossings = next;
if (isLast) {
rippling = false;
announcement = buildAnnouncement(n, multiples);
}
}, i * STAGGER_MS));
newTids.push(tid);
}
pendingTimeouts = [...pendingTimeouts, ...newTids];
}
function handleReset() {
for (const tid of pendingTimeouts) clearTimeout(tid);
pendingTimeouts = [];
markedPrimes = [];
crossings = new Map();
rippling = false;
announcement = '';
focusIndex = 0;
}
/**
* @param {KeyboardEvent} e
* @param {number} idx
*/
function handleKeydown(e, idx) {
const row = Math.floor(idx / 10);
const col = idx % 10;
let next = idx;
switch (e.key) {
case 'ArrowRight': next = row * 10 + Math.min(col + 1, 9); break;
case 'ArrowLeft': next = row * 10 + Math.max(col - 1, 0); break;
case 'ArrowDown': next = Math.min((row + 1) * 10 + col, 99); break;
case 'ArrowUp': next = Math.max((row - 1) * 10 + col, 0); break;
case 'Home': next = row * 10; break;
case 'End': next = row * 10 + 9; break;
default: return;
}
if (next !== idx) {
e.preventDefault();
focusIndex = next;
cellRefs[next]?.focus();
}
}
// Return a reactive proxy so template reads stay live.
// Svelte 5 tracks $state access through object property reads.
return {
get markedPrimes() { return markedPrimes; },
get crossings() { return crossings; },
get rippling() { return rippling; },
get announcement() { return announcement; },
get focusIndex() { return focusIndex; },
get shakeIndex() { return shakeIndex; },
cellRefs,
handleCellActivate,
handleReset,
handleKeydown,
};
}
+1
View File
@@ -1 +1,2 @@
export { gcd, lcm, gcdSteps } from './gcd.js';
export { sieveUpTo, multiplesOf, isPrime } from './sieve.js';
+59
View File
@@ -0,0 +1,59 @@
/**
* Sieve of Eratosthenes for integers in [2, n].
* Returns the list of primes and a set of composite values for fast lookup.
* For `n < 2`, returns empty results.
* @param {number} n
* @returns {{ primes: number[], composite: Set<number> }}
*/
export function sieveUpTo(n) {
const upper = Math.trunc(n);
if (upper < 2) return { primes: [], composite: new Set() };
const marks = new Uint8Array(upper + 1); // 0 = prime, 1 = composite
marks[0] = 1;
marks[1] = 1;
for (let i = 2; i * i <= upper; i++) {
if (marks[i] === 0) {
for (let k = i * i; k <= upper; k += i) marks[k] = 1;
}
}
/** @type {number[]} */
const primes = [];
/** @type {Set<number>} */
const composite = new Set();
for (let k = 2; k <= upper; k++) {
if (marks[k] === 0) primes.push(k);
else composite.add(k);
}
return { primes, composite };
}
/**
* Multiples of `p` in `[2*p, n]`, excluding `p` itself.
* Useful for the "cross out multiples of this prime" interaction.
* @param {number} p @param {number} n @returns {number[]}
*/
export function multiplesOf(p, n) {
const prime = Math.trunc(p);
const upper = Math.trunc(n);
if (prime < 2 || upper < 2 * prime) return [];
/** @type {number[]} */
const out = [];
for (let k = 2 * prime; k <= upper; k += prime) out.push(k);
return out;
}
/**
* Primality check via trial division up to √k.
* @param {number} k @returns {boolean}
*/
export function isPrime(k) {
const n = Math.trunc(k);
if (n < 2) return false;
if (n === 2) return true;
if (n % 2 === 0) return false;
const limit = Math.floor(Math.sqrt(n));
for (let d = 3; d <= limit; d += 2) {
if (n % d === 0) return false;
}
return true;
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { sieveUpTo, multiplesOf, isPrime } from './sieve.js';
describe('sieveUpTo', () => {
it('returns empty for n < 2', () => {
expect(sieveUpTo(1)).toEqual({ primes: [], composite: new Set() });
expect(sieveUpTo(0)).toEqual({ primes: [], composite: new Set() });
expect(sieveUpTo(-5)).toEqual({ primes: [], composite: new Set() });
});
it('returns [2] for n = 2', () => {
const { primes } = sieveUpTo(2);
expect(primes).toEqual([2]);
});
it('returns [2,3,5,7] for n = 10', () => {
const { primes, composite } = sieveUpTo(10);
expect(primes).toEqual([2, 3, 5, 7]);
expect(composite.has(4)).toBe(true);
expect(composite.has(9)).toBe(true);
});
it('returns 25 primes for n = 100 — includes 97, excludes 91', () => {
const { primes, composite } = sieveUpTo(100);
expect(primes).toHaveLength(25);
expect(primes.at(-1)).toBe(97);
expect(composite.has(91)).toBe(true); // 7 · 13
expect(composite.has(97)).toBe(false);
});
});
describe('multiplesOf', () => {
it('multiplesOf(2, 10) excludes 2 itself', () => {
expect(multiplesOf(2, 10)).toEqual([4, 6, 8, 10]);
});
it('multiplesOf(7, 100) starts at 14, ends at 98', () => {
const m = multiplesOf(7, 100);
expect(m[0]).toBe(14);
expect(m.at(-1)).toBe(98);
});
it('returns [] for p < 2 or n < 2*p', () => {
expect(multiplesOf(1, 100)).toEqual([]);
expect(multiplesOf(50, 99)).toEqual([]);
});
});
describe('isPrime', () => {
it('false for k < 2', () => {
expect(isPrime(0)).toBe(false);
expect(isPrime(1)).toBe(false);
expect(isPrime(-7)).toBe(false);
});
it('true for 2', () => {
expect(isPrime(2)).toBe(true);
});
it('rejects 91 (= 7·13)', () => {
expect(isPrime(91)).toBe(false);
});
});
+235
View File
@@ -0,0 +1,235 @@
<script>
import { base } from '$app/paths';
import { t } from '$lib/i18n/index.js';
import { vi as m } from '$lib/lessons/duong-thang/copy.vi.js';
import Tex from '$lib/components/tex.svelte';
import { yAt, lineFromPoints, linePoints } from '$lib/algebra-engine/linear.js';
import CartesianPlane from '$lib/lessons/duong-thang/cartesian-plane.svelte';
const copy = t();
// SVG layout constants
const VIEW = 420;
const PAD = 30;
const U = (VIEW - PAD * 2) / 20; // px per math unit; 20 units span -10..10
/** Convert math x → SVG x */
const mx = (/** @type {number} */ x) => PAD + (x + 10) * U;
/** Convert math y → SVG y (Y-axis flipped in SVG) */
const my = (/** @type {number} */ y) => PAD + (10 - y) * U;
/** Convert SVG y → math y */
const svgYtoMath = (/** @type {number} */ sy) => 10 - (sy - PAD) / U;
const CLAMP_Y = 10; // math-space clamp for anchor y
const EPSILON = 0.01;
const ANCHOR1_X = -5; // fixed math x for anchor 1
const ANCHOR2_X = 5; // fixed math x for anchor 2
// Locked defaults: a=1, b=0 (used for both init and reset)
const INIT_A = 1;
const INIT_B = 0;
// ── Single source of truth ──────────────────────────────────────────────────
let a = $state(INIT_A);
let b = $state(INIT_B);
// Clamp a pixel y to the viewport's vertical range (math y ∈ [-CLAMP_Y, CLAMP_Y]).
const clampPx = (/** @type {number} */ py) =>
Math.max(my(CLAMP_Y), Math.min(my(-CLAMP_Y), py));
// Anchor pixel state — mutated either by Direction 1 (sliders) or by the
// draggable action. Always clamped to viewport so anchors never leave the box.
let p1 = $state({ x: mx(ANCHOR1_X), y: clampPx(my(INIT_A * ANCHOR1_X + INIT_B)) });
let p2 = $state({ x: mx(ANCHOR2_X), y: clampPx(my(INIT_A * ANCHOR2_X + INIT_B)) });
// Direction 1: (a, b) → anchor pixel positions. Clamped so extreme slider
// values still leave anchors visible at the viewport edge.
$effect(() => {
p1.y = clampPx(my(yAt({ a, b }, ANCHOR1_X)));
p2.y = clampPx(my(yAt({ a, b }, ANCHOR2_X)));
});
// Direction 2: anchor drag → (a, b). Fired by the draggable action's
// onChange hook (NOT a state-watching effect) so slider writes don't bounce
// back into a/b. Rounded to slider step (0.1) and epsilon-gated.
function handleDragChange() {
const newLine = lineFromPoints(
{ x: ANCHOR1_X, y: svgYtoMath(p1.y) },
{ x: ANCHOR2_X, y: svgYtoMath(p2.y) }
);
if (!newLine) return;
const newA = Math.round(newLine.a * 10) / 10;
const newB = Math.round(newLine.b * 10) / 10;
if (Math.abs(newA - a) < EPSILON && Math.abs(newB - b) < EPSILON) return;
a = Math.max(-5, Math.min(5, newA));
b = Math.max(-10, Math.min(10, newB));
}
/** @type {SVGSVGElement | undefined} */
let svgEl = $state();
// Projectors: lock x to fixed SVG column, clamp y within math bounds
const proj1 = (/** @type {{x:number,y:number}} */ p) => ({
x: mx(ANCHOR1_X),
y: clampPx(p.y),
});
const proj2 = (/** @type {{x:number,y:number}} */ p) => ({
x: mx(ANCHOR2_X),
y: clampPx(p.y),
});
const drag1Opts = $derived({
point: p1,
svg: () => svgEl ?? null,
viewBox: { w: VIEW, h: VIEW },
projector: proj1,
pad: 0,
keyStep: U,
onChange: handleDragChange,
});
const drag2Opts = $derived({
point: p2,
svg: () => svgEl ?? null,
viewBox: { w: VIEW, h: VIEW },
projector: proj2,
pad: 0,
keyStep: U,
onChange: handleDragChange,
});
const line = $derived({ a, b });
const pts = $derived(linePoints(line, -10, 10));
const texMath = $derived(`y = ${a.toFixed(1)}x + ${b.toFixed(1)}`);
const anchor1MathY = $derived(yAt(line, ANCHOR1_X));
const anchor2MathY = $derived(yAt(line, ANCHOR2_X));
const deltaY = $derived(anchor2MathY - anchor1MathY);
let showTriangle = $state(false);
// Debounced aria-live announcement (300 ms) to avoid spamming screen readers during drag
let ariaAnnounce = $state('');
let announceTimer = /** @type {ReturnType<typeof setTimeout> | undefined} */ (undefined);
$effect(() => {
const msg = texMath;
clearTimeout(announceTimer);
announceTimer = setTimeout(() => { ariaAnnounce = msg; }, 300);
});
function reset() { a = INIT_A; b = INIT_B; }
const gridLines = Array.from({ length: 21 }, (_, i) => i - 10); // -10..10
</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>
<!-- Sliders -->
<section class="mb-4 bg-white rounded-lg border border-slate-200 p-5">
<p class="text-sm text-slate-500 mb-3">{m.instructionSlider}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="block">
<span class="text-sm font-semibold text-slate-700">
{m.sliderALabel}: <span class="tabular-nums text-indigo-700">{a.toFixed(1)}</span>
</span>
<input
type="range" min="-5" max="5" step="0.1"
bind:value={a}
aria-label="{m.sliderALabel} hiện tại {a.toFixed(1)}"
class="mt-1 w-full accent-indigo-600"
/>
</label>
<label class="block">
<span class="text-sm font-semibold text-slate-700">
{m.sliderBLabel}: <span class="tabular-nums text-indigo-700">{b.toFixed(1)}</span>
</span>
<input
type="range" min="-10" max="10" step="0.1"
bind:value={b}
aria-label="{m.sliderBLabel} hiện tại {b.toFixed(1)}"
class="mt-1 w-full accent-indigo-600"
/>
</label>
</div>
</section>
<!-- SVG Cartesian plane (grid, axes, line, anchors, triangle) -->
<section class="mb-4">
<p class="text-sm text-slate-500 mb-2">{m.instructionAnchor}</p>
<CartesianPlane
bind:svgEl
view={VIEW}
pad={PAD}
u={U}
{mx} {my}
{gridLines}
{pts}
{p1} {p2}
{anchor1MathY} {anchor2MathY}
{drag1Opts} {drag2Opts}
{showTriangle}
anchor1X={ANCHOR1_X}
anchor2X={ANCHOR2_X}
{deltaY}
{texMath}
anchor1Label={m.anchor1Label}
anchor2Label={m.anchor2Label}
/>
</section>
<!-- KaTeX live equation -->
<section class="mb-4 text-center">
<Tex display math={texMath} ariaLabel="Phương trình đường thẳng: {texMath}" />
</section>
<!-- aria-live (visually hidden): announces equation after drag/slider settles -->
<div aria-live="polite" aria-atomic="true" class="sr-only">{ariaAnnounce}</div>
<!-- Triangle toggle + reset -->
<section class="mb-6 flex flex-wrap items-center gap-4">
<label class="flex items-center gap-2 text-sm text-slate-700 cursor-pointer select-none">
<input type="checkbox" bind:checked={showTriangle} class="accent-indigo-600 w-4 h-4" />
{m.showTriangleLabel}
</label>
<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>
</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>
@@ -0,0 +1,197 @@
<script>
import { base } from '$app/paths';
import { t } from '$lib/i18n/index.js';
import { vi as m } from '$lib/lessons/dinh-ly-pythagoras/copy.vi.js';
import Tex from '$lib/components/tex.svelte';
import { draggable } from '$lib/actions/draggable.svelte.js';
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import {
squareA, squareB, squareC, altitudeFoot,
shearATarget, shearBTarget, lerpPoly, polyPoints,
} from '$lib/lessons/dinh-ly-pythagoras/geom-helpers.js';
const copy = t();
const VIEW = 400;
// Layout: apex fixed at y=100, foot fixed at x=320; right-angle vertex R is draggable.
// Min leg 40px prevents degenerate triangles and keeps squares visible.
const APEX_Y = 100, FOOT_X = 320;
const INIT = { x: 160, y: 280 };
const reducedMotion = typeof window !== 'undefined'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** @type {SVGSVGElement | undefined} */ let svgEl = $state();
let R = $state({ ...INIT });
/** @type {'idle'|'animating'|'proven'} */ let phase = $state('idle');
let announcement = $state('');
/** @type {ReturnType<typeof setTimeout>|null} */ let debounceId = null;
const tp = tweened(0, { duration: reducedMotion ? 0 : 2000, easing: cubicOut });
// ── Derived geometry ──────────────────────────────────────────────────────
const A = $derived({ x: R.x, y: APEX_Y });
const H = $derived({ x: FOOT_X, y: R.y });
const a = $derived(R.y - APEX_Y);
const b = $derived(FOOT_X - R.x);
const c = $derived(Math.hypot(a, b));
const sqA = $derived(squareA(A, R));
const sqB = $derived(squareB(R, H));
const sqC = $derived(squareC(A, H, a, b, c));
const F = $derived(altitudeFoot(A, H, a, c));
const tgtA = $derived(shearATarget(A, F, a, b, c));
const tgtB = $derived(shearBTarget(F, H, a, b, c));
// Phase A: shear-a moves in first half of tween; phase B: shear-b in second half
const tA = $derived(Math.min(1, $tp * 2));
const tB = $derived(Math.max(0, $tp * 2 - 1));
const shearA = $derived(lerpPoly(sqA, tgtA, tA));
const shearB = $derived(lerpPoly(sqB, tgtB, tB));
const texSides = $derived(`a=${a.toFixed(1)},\\;b=${b.toFixed(1)},\\;c=${c.toFixed(1)}`);
const texNums = $derived(`${(a*a).toFixed(1)}+${(b*b).toFixed(1)}=${(c*c).toFixed(1)}`);
/** @param {{ x: number; y: number }} p */
const clampR = (p) => ({
x: Math.max(60, Math.min(FOOT_X - 40, p.x)),
y: Math.max(APEX_Y + 40, Math.min(VIEW - 60, p.y)),
});
const dragOpts = $derived({
point: R, svg: () => svgEl ?? null,
viewBox: { w: VIEW, h: VIEW }, projector: clampR, pad: 0,
keyStep: 1, keyShiftStep: 10,
onChange: () => {
if (debounceId) clearTimeout(debounceId);
debounceId = setTimeout(() => {
announcement = `Cạnh a=${a.toFixed(1)}, b=${b.toFixed(1)}, huyền c=${c.toFixed(1)}`;
}, 300);
},
});
async function prove() {
if (phase === 'animating') return;
phase = 'animating';
tp.set(0, { duration: 0 });
await tp.set(1);
phase = 'proven';
}
function reset() {
phase = 'idle'; tp.set(0, { duration: 0 });
R = { ...INIT }; announcement = '';
}
if (import.meta.env.DEV) {
$effect(() => {
if (phase === 'proven' && Math.abs(a*a + b*b - c*c) > 0.01)
console.error(`[pythagoras] area mismatch: a²+b²=${a*a+b*b} c²=${c*c}`);
});
}
</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">
<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 + '/hinh-hoc/'} 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>
<!-- aria-live: announces a/b/c when vertex moves (debounced 300ms) -->
<div aria-live="polite" aria-atomic="true" class="sr-only">{announcement}</div>
<section class="mb-6">
<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={m.instruction}>
<!-- Square-c (orange/hypotenuse) behind everything -->
<polygon points={polyPoints(sqC)} fill="#F46036"
fill-opacity={phase === 'proven' ? '0.15' : '0.35'} stroke="#F46036" stroke-width="1.5" />
{#if phase === 'idle'}
<polygon points={polyPoints(sqA)} fill="#D7263D" fill-opacity="0.45" stroke="#D7263D" stroke-width="1.5" />
<polygon points={polyPoints(sqB)} fill="#1B998B" fill-opacity="0.45" stroke="#1B998B" stroke-width="1.5" />
{:else}
<!-- Shear-a morphs from square-a into the a²-sub-rectangle of square-c -->
<polygon points={polyPoints(shearA)} fill="#D7263D" fill-opacity="0.65" stroke="#D7263D" stroke-width="1.5" />
<!-- Shear-b morphs from square-b into the b²-sub-rectangle of square-c -->
<polygon points={polyPoints(shearB)} fill="#1B998B" fill-opacity="0.65" stroke="#1B998B" stroke-width="1.5" />
{/if}
<!-- Triangle -->
<polygon points="{A.x},{A.y} {R.x},{R.y} {H.x},{H.y}"
fill="#e2e8f0" fill-opacity="0.7" stroke="#475569" stroke-width="2" />
<!-- Right-angle box at R -->
<rect x={R.x} y={R.y - 12} width="12" height="12" fill="none" stroke="#475569" stroke-width="1.5" />
<!-- Labels -->
<text x={R.x - 18} y={(APEX_Y + R.y) / 2} font-size="14" font-weight="700" fill="#D7263D">a</text>
<text x={(R.x + FOOT_X) / 2} y={R.y + 18} font-size="14" font-weight="700" fill="#1B998B">b</text>
<text x={(R.x + FOOT_X) / 2 + 6} y={(APEX_Y + R.y) / 2 - 10} font-size="14" font-weight="700" fill="#F46036">c</text>
<text x={A.x + 6} y={A.y - 6} font-size="13" fill="#475569">A</text>
<text x={H.x + 6} y={H.y + 4} font-size="13" fill="#475569">H</text>
<!-- Draggable right-angle vertex -->
<circle cx={R.x} cy={R.y} r="14" fill="#D7263D" stroke="#fff" stroke-width="2"
role="button" tabindex="0"
aria-label="Đỉnh góc vuông R — kéo hoặc dùng phím mũi tên để thay đổi tam giác"
style="cursor:grab; outline:none" use:draggable={dragOpts} />
<text x={R.x - 5} y={R.y + 5} font-size="13" font-weight="700" fill="#fff" pointer-events="none">R</text>
</svg>
<p class="mt-2 text-center text-sm text-slate-500">{m.instruction}</p>
</section>
<!-- Live KaTeX panel -->
<section class="mb-6 rounded-lg border border-slate-200 bg-white p-5">
<div class="mb-2 text-sm text-slate-700">
<Tex math={texSides} ariaLabel="a={a.toFixed(1)}, b={b.toFixed(1)}, c={c.toFixed(1)}" />
</div>
<Tex display math="a^2 + b^2 = c^2" />
<div class="mt-1 text-sm text-slate-600 tabular-nums">
<Tex math={texNums} ariaLabel="{(a*a).toFixed(1)} + {(b*b).toFixed(1)} = {(c*c).toFixed(1)}" />
</div>
</section>
<!-- Action buttons -->
<section class="mb-6 flex gap-3 flex-wrap">
<button onclick={prove} disabled={phase === 'animating'}
class="px-5 py-2 rounded-lg bg-indigo-600 text-white font-semibold text-sm
hover:bg-indigo-700 focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-indigo-600
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>{m.buttonProve}</button>
<button onclick={reset}
class="px-5 py-2 rounded-lg border border-slate-300 bg-white text-slate-700
font-semibold text-sm hover:bg-slate-50 focus-visible:outline
focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-indigo-600 transition-colors"
>{m.buttonReset}</button>
</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-8">
<h2 class="text-lg font-bold text-slate-900 mb-2">{m.proofTitle}</h2>
<p class="text-slate-700 leading-relaxed">{m.proofBody}</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>
@@ -0,0 +1,138 @@
<script>
import { base } from '$app/paths';
import { t } from '$lib/i18n/index.js';
import { vi as m } from '$lib/lessons/sang-eratosthenes/copy.vi.js';
import { createGridState } from '$lib/lessons/sang-eratosthenes/grid-interaction.svelte.js';
const copy = t();
/** One color per small prime — Map avoids numeric-literal indexing issues. */
const PRIME_COLORS = new Map([
[2, '#D7263D'],
[3, '#1B998B'],
[5, '#F46036'],
[7, '#5E60CE'],
]);
const TARGET_PRIMES = [2, 3, 5, 7];
const grid = createGridState(m.announceRipple);
/** @param {number} n */
function cellColor(n) {
return grid.markedPrimes.includes(n) ? PRIME_COLORS.get(n) : null;
}
</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">
<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 + '/so-hoc/'} 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>
<section class="mb-4">
<p class="text-sm text-slate-600 bg-white border border-slate-200 rounded-lg px-4 py-3">{m.instruction}</p>
</section>
<!-- Legend -->
<section class="mb-4" aria-label={m.legendTitle}>
<div class="flex flex-wrap gap-3">
{#each TARGET_PRIMES as p (p)}
<span class="inline-flex items-center gap-1.5 text-sm">
<span class="inline-block w-3.5 h-3.5 rounded-sm flex-shrink-0" style="background:{PRIME_COLORS.get(p)}" aria-hidden="true"></span>
<span style="color:{PRIME_COLORS.get(p)}" class="font-medium">{m.legendLabels[/** @type {keyof typeof m.legendLabels} */ (p)]}</span>
</span>
{/each}
</div>
</section>
<!-- Grid -->
<section class="mb-6">
<div
role="grid"
aria-label="Lưới số 1 đến 100"
aria-readonly={grid.rippling ? 'true' : 'false'}
class="inline-grid grid-cols-10 gap-px bg-slate-200 border border-slate-200 rounded-lg overflow-hidden w-full max-w-lg mx-auto select-none"
>
{#each { length: 100 } as _, idx}
{@const n = idx + 1}
{@const dotPrimes = grid.crossings.get(n) ?? []}
{@const isCrossed = dotPrimes.length > 0}
{@const color = cellColor(n)}
<div role="row">
<button
role="gridcell"
tabindex={idx === grid.focusIndex ? 0 : -1}
bind:this={grid.cellRefs[idx]}
onclick={() => grid.handleCellActivate(n)}
onkeydown={(e) => grid.handleKeydown(e, idx)}
aria-label="{n}{isCrossed ? ` — bội của ${dotPrimes.join(' và ')}` : ''}${color ? ' — số nguyên tố đã chọn' : ''}"
aria-disabled={n === 1 ? 'true' : undefined}
class="relative flex flex-col items-center justify-center bg-white w-full aspect-square text-xs font-semibold transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500
{n === 1 ? 'text-slate-300 cursor-default' : ''}
{isCrossed ? 'opacity-50' : ''}
{grid.shakeIndex === idx ? 'animate-[shake_0.35s_ease]' : ''}"
style="{color ? `box-shadow:inset 0 0 0 2px ${color};` : ''}{isCrossed ? 'text-decoration:line-through;' : ''}"
>
<span>{n}</span>
{#if dotPrimes.length > 0}
<span class="absolute bottom-0.5 left-0 right-0 flex justify-center gap-0.5" aria-hidden="true">
{#each dotPrimes as dp (dp)}
<span class="w-1 h-1 rounded-full flex-shrink-0" style="background:{PRIME_COLORS.get(dp)}"></span>
{/each}
</span>
{/if}
</button>
</div>
{/each}
</div>
</section>
<!-- Reset -->
<section class="mb-8 flex justify-center">
<button
onclick={grid.handleReset}
class="px-5 py-2 rounded-lg bg-slate-700 text-white text-sm font-medium hover:bg-slate-600 transition-colors focus-visible:ring-2 focus-visible:ring-slate-700 focus-visible:ring-offset-2"
>{m.resetLabel}</button>
</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 leading-relaxed">{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>
<div aria-live="polite" aria-atomic="true" class="sr-only">{grid.announcement}</div>
<style>
@keyframes shake {
0%,100% { transform: translateX(0); }
20%,60% { transform: translateX(-3px); }
40%,80% { transform: translateX(3px); }
}
</style>
+1
View File
@@ -12,6 +12,7 @@ export default {
1: '#D7263D',
2: '#1B998B',
3: '#F46036',
4: '#5E60CE',
},
},
},