From 9d5d3f04a09e41e8fde0c328f534d8b58113e574 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 15 May 2026 20:35:22 +0700 Subject: [PATCH] feat: 3 interactive demo pages + transforms/sieve/linear engines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 9 +- src/lib/algebra-engine/index.js | 1 + src/lib/algebra-engine/linear.js | 46 ++++ src/lib/algebra-engine/linear.test.js | 42 ++++ src/lib/geom-engine/index.js | 9 + src/lib/geom-engine/transforms.js | 99 ++++++++ src/lib/geom-engine/transforms.test.js | 102 ++++++++ src/lib/lessons/dinh-ly-pythagoras/copy.vi.js | 25 ++ .../dinh-ly-pythagoras/geom-helpers.js | 120 +++++++++ .../duong-thang/cartesian-plane.svelte | 130 ++++++++++ src/lib/lessons/duong-thang/copy.vi.js | 24 ++ src/lib/lessons/registry.js | 14 +- src/lib/lessons/sang-eratosthenes/copy.vi.js | 31 +++ .../grid-interaction.svelte.js | 136 ++++++++++ src/lib/numtheory-engine/index.js | 1 + src/lib/numtheory-engine/sieve.js | 59 +++++ src/lib/numtheory-engine/sieve.test.js | 63 +++++ src/routes/dai-so/duong-thang/+page.svelte | 235 ++++++++++++++++++ .../hinh-hoc/dinh-ly-pythagoras/+page.svelte | 197 +++++++++++++++ .../so-hoc/sang-eratosthenes/+page.svelte | 138 ++++++++++ tailwind.config.js | 1 + 21 files changed, 1478 insertions(+), 4 deletions(-) create mode 100644 src/lib/algebra-engine/index.js create mode 100644 src/lib/algebra-engine/linear.js create mode 100644 src/lib/algebra-engine/linear.test.js create mode 100644 src/lib/geom-engine/transforms.js create mode 100644 src/lib/geom-engine/transforms.test.js create mode 100644 src/lib/lessons/dinh-ly-pythagoras/copy.vi.js create mode 100644 src/lib/lessons/dinh-ly-pythagoras/geom-helpers.js create mode 100644 src/lib/lessons/duong-thang/cartesian-plane.svelte create mode 100644 src/lib/lessons/duong-thang/copy.vi.js create mode 100644 src/lib/lessons/sang-eratosthenes/copy.vi.js create mode 100644 src/lib/lessons/sang-eratosthenes/grid-interaction.svelte.js create mode 100644 src/lib/numtheory-engine/sieve.js create mode 100644 src/lib/numtheory-engine/sieve.test.js create mode 100644 src/routes/dai-so/duong-thang/+page.svelte create mode 100644 src/routes/hinh-hoc/dinh-ly-pythagoras/+page.svelte create mode 100644 src/routes/so-hoc/sang-eratosthenes/+page.svelte diff --git a/README.md b/README.md index 3000309..2209215 100644 --- a/README.md +++ b/README.md @@ -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//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. diff --git a/src/lib/algebra-engine/index.js b/src/lib/algebra-engine/index.js new file mode 100644 index 0000000..f712f48 --- /dev/null +++ b/src/lib/algebra-engine/index.js @@ -0,0 +1 @@ +export { lineFromPoints, lineFromSlope, yAt, linePoints } from './linear.js'; diff --git a/src/lib/algebra-engine/linear.js b/src/lib/algebra-engine/linear.js new file mode 100644 index 0000000..961105c --- /dev/null +++ b/src/lib/algebra-engine/linear.js @@ -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) }, + ]; +} diff --git a/src/lib/algebra-engine/linear.test.js b/src/lib/algebra-engine/linear.test.js new file mode 100644 index 0000000..de78403 --- /dev/null +++ b/src/lib/algebra-engine/linear.test.js @@ -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 }); + }); +}); diff --git a/src/lib/geom-engine/index.js b/src/lib/geom-engine/index.js index c6dff1e..df2b4b8 100644 --- a/src/lib/geom-engine/index.js +++ b/src/lib/geom-engine/index.js @@ -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'; diff --git a/src/lib/geom-engine/transforms.js b/src/lib/geom-engine/transforms.js new file mode 100644 index 0000000..fb2a039 --- /dev/null +++ b/src/lib/geom-engine/transforms.js @@ -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], + ]; +} diff --git a/src/lib/geom-engine/transforms.test.js b/src/lib/geom-engine/transforms.test.js new file mode 100644 index 0000000..96dbd41 --- /dev/null +++ b/src/lib/geom-engine/transforms.test.js @@ -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); + }); +}); diff --git a/src/lib/lessons/dinh-ly-pythagoras/copy.vi.js b/src/lib/lessons/dinh-ly-pythagoras/copy.vi.js new file mode 100644 index 0000000..1283769 --- /dev/null +++ b/src/lib/lessons/dinh-ly-pythagoras/copy.vi.js @@ -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', +}; diff --git a/src/lib/lessons/dinh-ly-pythagoras/geom-helpers.js b/src/lib/lessons/dinh-ly-pythagoras/geom-helpers.js new file mode 100644 index 0000000..a6578f0 --- /dev/null +++ b/src/lib/lessons/dinh-ly-pythagoras/geom-helpers.js @@ -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(' '); +} diff --git a/src/lib/lessons/duong-thang/cartesian-plane.svelte b/src/lib/lessons/duong-thang/cartesian-plane.svelte new file mode 100644 index 0000000..8a36cab --- /dev/null +++ b/src/lib/lessons/duong-thang/cartesian-plane.svelte @@ -0,0 +1,130 @@ + + + + + {#each gridLines as v} + + + {/each} + + + + + + + {#each gridLines as v} + {#if v !== 0 && v % 2 === 0} + {v} + {v} + {/if} + {/each} + 0 + + + {#if showTriangle} + {@const tx1 = mx(anchor1X)} + {@const ty1 = my(anchor1MathY)} + {@const tx2 = mx(anchor2X)} + {@const ty2 = my(anchor2MathY)} + {@const cornerX = tx2} + {@const cornerY = ty1} + + Δx = 10 + Δy = {deltaY.toFixed(1)} + {/if} + + + + + + + + + + diff --git a/src/lib/lessons/duong-thang/copy.vi.js b/src/lib/lessons/duong-thang/copy.vi.js new file mode 100644 index 0000000..ebcae65 --- /dev/null +++ b/src/lib/lessons/duong-thang/copy.vi.js @@ -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', +}; diff --git a/src/lib/lessons/registry.js b/src/lib/lessons/registry.js index b05266e..4423e86 100644 --- a/src/lib/lessons/registry.js +++ b/src/lib/lessons/registry.js @@ -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) { diff --git a/src/lib/lessons/sang-eratosthenes/copy.vi.js b/src/lib/lessons/sang-eratosthenes/copy.vi.js new file mode 100644 index 0000000..04e9ff0 --- /dev/null +++ b/src/lib/lessons/sang-eratosthenes/copy.vi.js @@ -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 276–194 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(', ')}`, +}; diff --git a/src/lib/lessons/sang-eratosthenes/grid-interaction.svelte.js b/src/lib/lessons/sang-eratosthenes/grid-interaction.svelte.js new file mode 100644 index 0000000..385297a --- /dev/null +++ b/src/lib/lessons/sang-eratosthenes/grid-interaction.svelte.js @@ -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} */ + 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, + }; +} diff --git a/src/lib/numtheory-engine/index.js b/src/lib/numtheory-engine/index.js index f2b8b34..37198c8 100644 --- a/src/lib/numtheory-engine/index.js +++ b/src/lib/numtheory-engine/index.js @@ -1 +1,2 @@ export { gcd, lcm, gcdSteps } from './gcd.js'; +export { sieveUpTo, multiplesOf, isPrime } from './sieve.js'; diff --git a/src/lib/numtheory-engine/sieve.js b/src/lib/numtheory-engine/sieve.js new file mode 100644 index 0000000..09027e1 --- /dev/null +++ b/src/lib/numtheory-engine/sieve.js @@ -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 }} + */ +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} */ + 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; +} diff --git a/src/lib/numtheory-engine/sieve.test.js b/src/lib/numtheory-engine/sieve.test.js new file mode 100644 index 0000000..6823b8e --- /dev/null +++ b/src/lib/numtheory-engine/sieve.test.js @@ -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); + }); +}); diff --git a/src/routes/dai-so/duong-thang/+page.svelte b/src/routes/dai-so/duong-thang/+page.svelte new file mode 100644 index 0000000..f1ddc5a --- /dev/null +++ b/src/routes/dai-so/duong-thang/+page.svelte @@ -0,0 +1,235 @@ + + + + {m.title} — {copy.site.title} + + + +
+
+ MathMax +
+
+ +
+
+ + +
+
{m.gradeLabel}
+

{m.title}

+

{m.intro}

+
+ + +
+

{m.instructionSlider}

+
+ + +
+
+ + +
+

{m.instructionAnchor}

+ +
+ + +
+ +
+ + +
{ariaAnnounce}
+ + +
+ + +
+ +
+

{m.theoremTitle}

+

{m.theoremStatement}

+
+ +
+

{m.exampleTitle}

+

{m.exampleBody}

+
+ +
+ {m.nextTeaser} +
+
+
diff --git a/src/routes/hinh-hoc/dinh-ly-pythagoras/+page.svelte b/src/routes/hinh-hoc/dinh-ly-pythagoras/+page.svelte new file mode 100644 index 0000000..b04f448 --- /dev/null +++ b/src/routes/hinh-hoc/dinh-ly-pythagoras/+page.svelte @@ -0,0 +1,197 @@ + + + + {m.title} — {copy.site.title} + + + +
+
+ MathMax +
+
+ +
+
+ +
+
{m.gradeLabel}
+

{m.title}

+

{m.intro}

+
+ + +
{announcement}
+ +
+ + + + + + {#if phase === 'idle'} + + + {:else} + + + + + {/if} + + + + + + + + a + b + c + A + H + + + + R + +

{m.instruction}

+
+ + +
+
+ +
+ +
+ +
+
+ + +
+ + +
+ +
+

{m.theoremTitle}

+

{m.theoremStatement}

+
+
+

{m.proofTitle}

+

{m.proofBody}

+
+
+

{m.exampleTitle}

+

{m.exampleBody}

+
+
{m.nextTeaser}
+
+
diff --git a/src/routes/so-hoc/sang-eratosthenes/+page.svelte b/src/routes/so-hoc/sang-eratosthenes/+page.svelte new file mode 100644 index 0000000..8b52300 --- /dev/null +++ b/src/routes/so-hoc/sang-eratosthenes/+page.svelte @@ -0,0 +1,138 @@ + + + + {m.title} — {copy.site.title} + + + +
+
+ MathMax +
+
+ +
+
+ + +
+
{m.gradeLabel}
+

{m.title}

+

{m.intro}

+
+ +
+

{m.instruction}

+
+ + +
+
+ {#each TARGET_PRIMES as p (p)} + + + {m.legendLabels[/** @type {keyof typeof m.legendLabels} */ (p)]} + + {/each} +
+
+ + +
+
+ {#each { length: 100 } as _, idx} + {@const n = idx + 1} + {@const dotPrimes = grid.crossings.get(n) ?? []} + {@const isCrossed = dotPrimes.length > 0} + {@const color = cellColor(n)} +
+ +
+ {/each} +
+
+ + +
+ +
+ +
+

{m.theoremTitle}

+

{m.theoremStatement}

+
+ +
+

{m.exampleTitle}

+

{m.exampleBody}

+
+ +
{m.nextTeaser}
+
+
+ +
{grid.announcement}
+ + diff --git a/tailwind.config.js b/tailwind.config.js index 07ea122..3cc8f11 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -12,6 +12,7 @@ export default { 1: '#D7263D', 2: '#1B998B', 3: '#F46036', + 4: '#5E60CE', }, }, },