feat: port 3 interactive geometry lessons + geom-engine

- Add pure geom-engine module (vec, triangle, circle, ticks) with 34 vitest tests
- Add 3 lessons under /hinh-hoc/: tam-giac-bang-nhau (SSS), tam-giac-dong-dang (similarity), goc-noi-tiep (inscribed angle)
- Add reactive draggable Svelte action with arrow-key a11y
- Add per-lesson colocated i18n + site chrome + lesson registry
- Enable Hình học topic card on landing; keep Số học/Đại số as Sắp ra mắt
- Codify pedagogical tick palette as Tailwind colors.pair.{1,2,3}
- Add Be Vietnam Pro via @fontsource
This commit is contained in:
2026-04-30 22:27:26 +07:00
parent 8804c192db
commit a0da079500
31 changed files with 4761 additions and 106 deletions
+11
View File
@@ -1,3 +1,7 @@
@import '@fontsource/be-vietnam-pro/400.css';
@import '@fontsource/be-vietnam-pro/500.css';
@import '@fontsource/be-vietnam-pro/700.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -11,5 +15,12 @@
html {
-webkit-text-size-adjust: 100%;
font-family: 'Be Vietnam Pro', system-ui, sans-serif;
}
/* Visible focus ring for keyboard users on draggable SVG vertices. */
svg [role='slider']:focus-visible {
stroke: #4f46e5;
stroke-width: 3;
}
}
+92
View File
@@ -0,0 +1,92 @@
import { clientToSvg, clampToViewBox } from '$lib/utils/svg.js';
/**
* @typedef {{x: number, y: number}} MutablePoint
* @typedef {{
* point: MutablePoint;
* svg: SVGSVGElement | (() => SVGSVGElement | null);
* viewBox: { w: number; h: number };
* projector?: (p: import('$lib/geom-engine/vec.js').Vec2) => import('$lib/geom-engine/vec.js').Vec2;
* pad?: number;
* keyStep?: number;
* keyShiftStep?: number;
* onChange?: () => void;
* }} DraggableParams
*/
/**
* Svelte action: makes the host element draggable inside an SVG viewBox.
* Mutates `params.point.x/y` so reactivity propagates through `$state`.
* Also wires arrow-key movement for accessibility.
*
* @param {SVGGraphicsElement} node
* @param {DraggableParams} params
*/
export function draggable(node, params) {
let active = -1; // pointerId, -1 = idle
let current = params;
const getSvg = () => (typeof current.svg === 'function' ? current.svg() : current.svg);
const apply = (/** @type {{x:number,y:number}} */ raw) => {
const projected = current.projector ? current.projector(raw) : raw;
const clamped = current.pad === 0 ? projected : clampToViewBox(projected, current.viewBox.w, current.viewBox.h, current.pad);
current.point.x = clamped.x;
current.point.y = clamped.y;
current.onChange?.();
};
const onPointerDown = (/** @type {PointerEvent} */ e) => {
active = e.pointerId;
node.setPointerCapture(e.pointerId);
e.preventDefault();
};
const onPointerMove = (/** @type {PointerEvent} */ e) => {
if (active !== e.pointerId) return;
const svg = getSvg();
if (!svg) return;
apply(clientToSvg(svg, e.clientX, e.clientY, current.viewBox.w, current.viewBox.h));
};
const onPointerEnd = (/** @type {PointerEvent} */ e) => {
if (active !== e.pointerId) return;
if (node.hasPointerCapture(e.pointerId)) node.releasePointerCapture(e.pointerId);
active = -1;
};
const onKeyDown = (/** @type {KeyboardEvent} */ e) => {
const step = e.shiftKey ? (current.keyShiftStep ?? 10) : (current.keyStep ?? 2);
let dx = 0;
let dy = 0;
switch (e.key) {
case 'ArrowLeft': dx = -step; break;
case 'ArrowRight': dx = step; break;
case 'ArrowUp': dy = -step; break;
case 'ArrowDown': dy = step; break;
default: return;
}
e.preventDefault();
apply({ x: current.point.x + dx, y: current.point.y + dy });
};
node.addEventListener('pointerdown', onPointerDown);
node.addEventListener('pointermove', onPointerMove);
node.addEventListener('pointerup', onPointerEnd);
node.addEventListener('pointercancel', onPointerEnd);
node.addEventListener('keydown', onKeyDown);
return {
/** @param {DraggableParams} next */
update(next) {
current = next;
},
destroy() {
node.removeEventListener('pointerdown', onPointerDown);
node.removeEventListener('pointermove', onPointerMove);
node.removeEventListener('pointerup', onPointerEnd);
node.removeEventListener('pointercancel', onPointerEnd);
node.removeEventListener('keydown', onKeyDown);
},
};
}
+44
View File
@@ -0,0 +1,44 @@
import { add, dot, len, normalize, scale, sub, vec } from './vec.js';
/**
* @typedef {import('./vec.js').Vec2} Vec2
* @typedef {Readonly<{center: Vec2, radius: number}>} Circle
*/
/** @param {number} cx @param {number} cy @param {number} r @returns {Circle} */
export function circle(cx, cy, r) {
return { center: vec(cx, cy), radius: r };
}
/** @param {Vec2} point @param {Circle} c @returns {Vec2} */
export function projectToCircle(point, c) {
const dir = sub(point, c.center);
const d = len(dir);
if (d === 0) {
// Point at center; pick +x direction by convention.
return add(c.center, vec(c.radius, 0));
}
return add(c.center, scale(normalize(dir), c.radius));
}
/** @param {Circle} c @param {number} angleDeg @returns {Vec2} */
export function pointOnCircle(c, angleDeg) {
const rad = (angleDeg * Math.PI) / 180;
return vec(c.center.x + c.radius * Math.cos(rad), c.center.y + c.radius * Math.sin(rad));
}
/**
* Unsigned angle at `vertex` between rays to `a` and `b`, in degrees [0,180].
* Returns 0 if vertex coincides with a or b.
* @param {Vec2} a @param {Vec2} vertex @param {Vec2} b
*/
export function angleAtVertex(a, vertex, b) {
const va = sub(a, vertex);
const vb = sub(b, vertex);
const lenA = len(va);
const lenB = len(vb);
if (lenA === 0 || lenB === 0) return 0;
// Clamp guards float drift outside [-1, 1] which would NaN the acos.
const cosTheta = Math.max(-1, Math.min(1, dot(va, vb) / (lenA * lenB)));
return (Math.acos(cosTheta) * 180) / Math.PI;
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { vec } from './vec.js';
import { angleAtVertex, circle, pointOnCircle, projectToCircle } from './circle.js';
describe('pointOnCircle', () => {
it('places 0° at +x', () => {
const p = pointOnCircle(circle(0, 0, 10), 0);
expect(p.x).toBeCloseTo(10);
expect(p.y).toBeCloseTo(0);
});
it('places 90° at +y', () => {
const p = pointOnCircle(circle(0, 0, 10), 90);
expect(p.x).toBeCloseTo(0);
expect(p.y).toBeCloseTo(10);
});
});
describe('projectToCircle', () => {
it('keeps direction, sets magnitude to radius', () => {
const c = circle(0, 0, 5);
const p = projectToCircle(vec(3, 4), c);
expect(p.x).toBeCloseTo(3);
expect(p.y).toBeCloseTo(4);
});
it('falls back to +x when point is at center', () => {
const c = circle(0, 0, 5);
expect(projectToCircle(vec(0, 0), c)).toEqual(vec(5, 0));
});
it('projects far points back to the circle', () => {
const c = circle(0, 0, 5);
const p = projectToCircle(vec(100, 0), c);
expect(p).toEqual(vec(5, 0));
});
});
describe('angleAtVertex', () => {
it('right angle is 90°', () => {
expect(angleAtVertex(vec(1, 0), vec(0, 0), vec(0, 1))).toBeCloseTo(90);
});
it('straight angle is 180°', () => {
expect(angleAtVertex(vec(-1, 0), vec(0, 0), vec(1, 0))).toBeCloseTo(180);
});
it('returns 0 when vertex coincides with one ray endpoint', () => {
expect(angleAtVertex(vec(0, 0), vec(0, 0), vec(1, 1))).toBe(0);
});
it('inscribed angle theorem — half the central angle', () => {
const c = circle(0, 0, 10);
const a = pointOnCircle(c, 0);
const b = pointOnCircle(c, 120);
const m = pointOnCircle(c, 250); // any point on the major arc
const central = angleAtVertex(a, c.center, b);
const inscribed = angleAtVertex(a, m, b);
expect(inscribed).toBeCloseTo(central / 2, 1);
});
});
+16
View File
@@ -0,0 +1,16 @@
export {
EPSILON_LEN,
EPSILON_ANGLE_DEG,
vec,
add,
sub,
scale,
dot,
len,
dist,
normalize,
approxEqualLen,
} from './vec.js';
export { triangle, sides, congruentSSS } from './triangle.js';
export { circle, projectToCircle, pointOnCircle, angleAtVertex } from './circle.js';
export { tickPositions } from './ticks.js';
+49
View File
@@ -0,0 +1,49 @@
/**
* @typedef {import('./vec.js').Vec2} Vec2
* @typedef {Readonly<{x1: number, y1: number, x2: number, y2: number}>} TickSegment
*/
const DEFAULT_LEN = 6;
const DEFAULT_SPACING = 5;
/**
* Compute tick-mark segments perpendicular to side p1→p2, centered on the
* midpoint. `count` ticks indicate side identity (1/2/3 = side group).
* Returns [] for degenerate sides (length < 1).
*
* @param {Vec2} p1 @param {Vec2} p2
* @param {1|2|3} count
* @param {{length?: number, spacing?: number}} [opts]
* @returns {TickSegment[]}
*/
export function tickPositions(p1, p2, count, opts = {}) {
const length = opts.length ?? DEFAULT_LEN;
const spacing = opts.spacing ?? DEFAULT_SPACING;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.hypot(dx, dy);
if (len < 1) return [];
const dirX = dx / len;
const dirY = dy / len;
const perpX = -dirY;
const perpY = dirX;
const midX = (p1.x + p2.x) / 2;
const midY = (p1.y + p2.y) / 2;
const start = -((count - 1) * spacing) / 2;
/** @type {TickSegment[]} */
const out = [];
for (let i = 0; i < count; i++) {
const offset = start + i * spacing;
const cx = midX + dirX * offset;
const cy = midY + dirY * offset;
out.push({
x1: cx + perpX * length,
y1: cy + perpY * length,
x2: cx - perpX * length,
y2: cy - perpY * length,
});
}
return out;
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { vec } from './vec.js';
import { tickPositions } from './ticks.js';
describe('tickPositions', () => {
it('returns N segments for count=N', () => {
const out = tickPositions(vec(0, 0), vec(100, 0), 3);
expect(out).toHaveLength(3);
});
it('returns [] for degenerate sides', () => {
expect(tickPositions(vec(5, 5), vec(5, 5), 2)).toEqual([]);
expect(tickPositions(vec(5, 5), vec(5.5, 5), 2)).toEqual([]);
});
it('places single tick at midpoint, perpendicular', () => {
const [t] = tickPositions(vec(0, 0), vec(100, 0), 1, { length: 10, spacing: 5 });
// Midpoint is (50,0); perpendicular is ±y of length 10.
expect((t.x1 + t.x2) / 2).toBeCloseTo(50);
expect((t.y1 + t.y2) / 2).toBeCloseTo(0);
expect(Math.abs(t.y1 - t.y2)).toBeCloseTo(20);
});
it('spaces multiple ticks evenly along the side direction', () => {
const ticks = tickPositions(vec(0, 0), vec(100, 0), 2, { length: 10, spacing: 5 });
const mids = ticks.map((t) => (t.x1 + t.x2) / 2);
// Two ticks centered on midpoint 50, spacing 5 → 47.5 and 52.5.
expect(mids[0]).toBeCloseTo(47.5);
expect(mids[1]).toBeCloseTo(52.5);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { dist, EPSILON_LEN } from './vec.js';
/**
* @typedef {import('./vec.js').Vec2} Vec2
* @typedef {Readonly<{a: Vec2, b: Vec2, c: Vec2}>} Triangle
* @typedef {Readonly<{ab: number, bc: number, ca: number}>} SideLengths
*/
/** @param {Vec2} a @param {Vec2} b @param {Vec2} c @returns {Triangle} */
export function triangle(a, b, c) {
return { a, b, c };
}
/** @param {Triangle} t @returns {SideLengths} */
export function sides(t) {
return {
ab: dist(t.a, t.b),
bc: dist(t.b, t.c),
ca: dist(t.c, t.a),
};
}
/**
* Position-strict SSS: corresponding sides must match (AB↔A'B', BC↔B'C',
* CA↔C'A'). Matches the colored-tick correspondence shown to the student.
* @param {Triangle} t1 @param {Triangle} t2 @param {number} [eps]
*/
export function congruentSSS(t1, t2, eps = EPSILON_LEN) {
const s1 = sides(t1);
const s2 = sides(t2);
return (
Math.abs(s1.ab - s2.ab) < eps &&
Math.abs(s1.bc - s2.bc) < eps &&
Math.abs(s1.ca - s2.ca) < eps
);
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { vec } from './vec.js';
import { congruentSSS, sides, triangle } from './triangle.js';
describe('sides', () => {
it('measures all three side lengths', () => {
const t = triangle(vec(0, 0), vec(3, 0), vec(0, 4));
const s = sides(t);
expect(s.ab).toBe(3);
expect(s.bc).toBe(5);
expect(s.ca).toBe(4);
});
});
describe('congruentSSS', () => {
it('detects identical triangles', () => {
const t = triangle(vec(0, 0), vec(3, 0), vec(0, 4));
expect(congruentSSS(t, t)).toBe(true);
});
it('detects translated triangles as congruent', () => {
const t1 = triangle(vec(0, 0), vec(3, 0), vec(0, 4));
const t2 = triangle(vec(10, 10), vec(13, 10), vec(10, 14));
expect(congruentSSS(t1, t2)).toBe(true);
});
it('rejects scaled triangles', () => {
const t1 = triangle(vec(0, 0), vec(3, 0), vec(0, 4));
const t2 = triangle(vec(0, 0), vec(6, 0), vec(0, 8));
expect(congruentSSS(t1, t2)).toBe(false);
});
it('is position-strict (different vertex correspondence fails)', () => {
const t1 = triangle(vec(0, 0), vec(3, 0), vec(0, 4));
// Same shape, but vertex labels rotated.
const t2 = triangle(vec(3, 0), vec(0, 4), vec(0, 0));
expect(congruentSSS(t1, t2)).toBe(false);
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* @typedef {Readonly<{x: number, y: number}>} Vec2
*/
export const EPSILON_LEN = 0.5;
export const EPSILON_ANGLE_DEG = 0.5;
/** @param {number} x @param {number} y @returns {Vec2} */
export function vec(x, y) {
return { x, y };
}
/** @param {Vec2} a @param {Vec2} b @returns {Vec2} */
export function add(a, b) {
return { x: a.x + b.x, y: a.y + b.y };
}
/** @param {Vec2} a @param {Vec2} b @returns {Vec2} */
export function sub(a, b) {
return { x: a.x - b.x, y: a.y - b.y };
}
/** @param {Vec2} a @param {number} k @returns {Vec2} */
export function scale(a, k) {
// `+ 0` normalizes IEEE-754 -0 → +0 so === / Object.is comparisons don't
// see a signed-zero ghost when k=0.
return { x: a.x * k + 0, y: a.y * k + 0 };
}
/** @param {Vec2} a @param {Vec2} b */
export function dot(a, b) {
return a.x * b.x + a.y * b.y;
}
/** @param {Vec2} a */
export function len(a) {
return Math.hypot(a.x, a.y);
}
/** @param {Vec2} a @param {Vec2} b */
export function dist(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
/** @param {Vec2} a @returns {Vec2} */
export function normalize(a) {
const l = len(a);
if (l === 0) return { x: 0, y: 0 };
return { x: a.x / l, y: a.y / l };
}
/** @param {number} a @param {number} b @param {number} [eps] */
export function approxEqualLen(a, b, eps = EPSILON_LEN) {
return Math.abs(a - b) < eps;
}
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import {
add,
approxEqualLen,
dist,
dot,
EPSILON_LEN,
len,
normalize,
scale,
sub,
vec,
} from './vec.js';
describe('add', () => {
it('is commutative', () => {
const a = vec(2, 3);
const b = vec(-1, 4);
expect(add(a, b)).toEqual(add(b, a));
});
it('does not mutate inputs', () => {
const a = vec(1, 2);
const b = vec(3, 4);
const before = { ...a };
add(a, b);
expect(a).toEqual(before);
});
});
describe('sub', () => {
it('is the inverse of add', () => {
const a = vec(5, 7);
const b = vec(2, 1);
expect(sub(add(a, b), b)).toEqual(a);
});
});
describe('scale', () => {
it('multiplies both components by k', () => {
expect(scale(vec(2, 3), 4)).toEqual(vec(8, 12));
});
it('handles k = 0 without signed-zero', () => {
expect(scale(vec(7, -3), 0)).toEqual(vec(0, 0));
expect(Object.is(scale(vec(7, -3), 0).y, 0)).toBe(true);
});
});
describe('dot', () => {
it('computes the standard inner product', () => {
expect(dot(vec(1, 2), vec(3, 4))).toBe(11);
});
it('returns 0 for orthogonal vectors', () => {
expect(dot(vec(1, 0), vec(0, 1))).toBe(0);
});
});
describe('len and dist', () => {
it('len of (3,4) is 5', () => {
expect(len(vec(3, 4))).toBe(5);
});
it('dist is symmetric', () => {
const a = vec(1, 2);
const b = vec(4, 6);
expect(dist(a, b)).toBeCloseTo(dist(b, a));
});
it('dist of a point with itself is 0', () => {
expect(dist(vec(7, -3), vec(7, -3))).toBe(0);
});
});
describe('normalize', () => {
it('returns a unit vector for non-zero input', () => {
const n = normalize(vec(3, 4));
expect(len(n)).toBeCloseTo(1, 10);
});
it('returns the zero vector for the zero vector', () => {
expect(normalize(vec(0, 0))).toEqual(vec(0, 0));
});
it('preserves direction', () => {
expect(normalize(vec(2, 0))).toEqual(vec(1, 0));
});
});
describe('approxEqualLen', () => {
it('treats values within epsilon as equal', () => {
expect(approxEqualLen(10, 10 + EPSILON_LEN / 2)).toBe(true);
});
it('rejects values outside epsilon', () => {
expect(approxEqualLen(10, 10 + EPSILON_LEN * 2)).toBe(false);
});
it('uses 0.5 as the default tolerance', () => {
expect(EPSILON_LEN).toBe(0.5);
});
});
+9
View File
@@ -0,0 +1,9 @@
import * as siteCopy from './site.vi.js';
const locales = { vi: siteCopy };
const defaultLocale = 'vi';
/** Site-wide chrome copy for the default locale. */
export function t() {
return locales[defaultLocale];
}
+52
View File
@@ -0,0 +1,52 @@
export const site = {
title: 'MathMax',
tagline: 'Toán tương tác cho học sinh THCS',
description:
'MathMax giúp học sinh THCS lớp 6-9 học toán qua tương tác — Số học, Đại số, Hình học bằng cách kéo, thử nghiệm, và minh hoạ trực quan.',
};
export const hub = {
scopeLabel: 'Phạm vi',
topicsTitle: 'Chủ đề',
};
export const status = {
comingSoon: 'Sắp ra mắt',
live: 'Khám phá',
};
export const grades = {
'lop-6': 'Lớp 6',
'lop-7': 'Lớp 7',
'lop-8': 'Lớp 8',
'lop-9': 'Lớp 9',
};
export const topics = {
'so-hoc': {
title: 'Số học',
blurb: 'Phép tính, ước-bội, phân số, số nguyên. Trực quan hoá thuật toán và quy luật.',
status: 'comingSoon',
href: null,
},
'dai-so': {
title: 'Đại số',
blurb: 'Biểu thức, phương trình, hàm số. Thao tác kéo-thả các đối tượng đại số.',
status: 'comingSoon',
href: null,
},
'hinh-hoc': {
title: 'Hình học',
blurb: 'Tam giác, tứ giác, đường tròn. Kéo điểm, định lý sống động.',
status: 'live',
href: '/hinh-hoc/',
},
};
export const lessonChrome = {
backToTopic: '← Về danh sách bài',
backToHub: '← Về trang chủ',
theoremTitle: 'Định lý',
exampleTitle: 'Ví dụ',
instructionAria: 'Dùng phím mũi tên hoặc kéo điểm bằng chuột/cảm ứng',
};
+19
View File
@@ -0,0 +1,19 @@
export const vi = {
slug: 'goc-noi-tiep',
topic: 'hinh-hoc',
grade: 'lop-9',
title: 'Góc nội tiếp',
gradeLabel: 'Lớp 9',
intro:
'Định lý góc nội tiếp nói rằng: khi M chạy trên cùng một cung của đường tròn, góc nội tiếp ∠AMB không đổi và bằng nửa góc ở tâm cùng chắn cung. Hãy kéo điểm M trên đường tròn để tự kiểm chứng.',
instruction: 'Kéo điểm M (đỏ) quanh đường tròn — hoặc dùng phím mũi tên',
inscribedLabel: 'Góc nội tiếp ∠AMB',
centralLabel: 'Góc ở tâm ∠AOB',
theoremTitle: 'Định lý',
theoremStatement:
'Trong một đường tròn, số đo của góc nội tiếp bằng nửa số đo của góc ở tâm cùng chắn một cung.',
exampleTitle: 'Ví dụ',
exampleBody:
'Cho đường tròn (O) với hai điểm A, B cố định sao cho góc ở tâm ∠AOB = 120°. Theo định lý, mọi điểm M nằm trên cung lớn AB đều cho ∠AMB = 60° (= 120° / 2). Khi M chuyển sang cung nhỏ, ∠AMB = 120° vì khi đó góc nội tiếp chắn cung lớn (240°), một nửa của nó là 120°. Hãy kéo điểm M để kiểm tra.',
nextTeaser: 'Sắp ra mắt: Tứ giác nội tiếp',
};
+21
View File
@@ -0,0 +1,21 @@
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';
/**
* @typedef {{slug: string, topic: string, grade: string, title: string,
* gradeLabel: string, intro: string, [k: string]: any}} LessonCopy
*/
/** @type {LessonCopy[]} */
export const lessons = [sssCopy, similarityCopy, inscribedCopy];
/** @param {string} topic */
export function lessonsByTopic(topic) {
return lessons.filter((l) => l.topic === topic);
}
/** @param {string} slug */
export function lessonBySlug(slug) {
return lessons.find((l) => l.slug === slug);
}
@@ -0,0 +1,19 @@
export const vi = {
slug: 'tam-giac-bang-nhau',
topic: 'hinh-hoc',
grade: 'lop-7',
title: 'Tam giác bằng nhau (SSS)',
gradeLabel: 'Lớp 7',
intro:
'Hai tam giác bằng nhau khi cả ba cặp cạnh tương ứng có độ dài bằng nhau (trường hợp Cạnh Cạnh Cạnh). Hãy kéo từng đỉnh để xem khi nào hai tam giác trùng khớp.',
instruction: 'Kéo bất kỳ đỉnh nào của hai tam giác — hoặc dùng phím mũi tên',
congruentBadge: 'Hai tam giác bằng nhau (c.c.c)',
lengthsTitle: 'Độ dài cạnh',
theoremTitle: 'Định lý (cạnh cạnh cạnh)',
theoremStatement:
'Nếu ba cạnh của tam giác này lần lượt bằng ba cạnh của tam giác kia thì hai tam giác đó bằng nhau.',
exampleTitle: 'Ví dụ',
exampleBody:
'Cho hai tam giác △ABC và △ABC với AB = AB (cùng có một dấu gạch đỏ), BC = BC (cùng có hai dấu gạch xanh), CA = CA (cùng có ba dấu gạch cam). Theo trường hợp c.c.c, △ABC = △ABC — và do đó các góc tương ứng cũng bằng nhau. Hãy kéo các đỉnh để các cặp cạnh có cùng độ dài, huy hiệu sẽ bật lên.',
nextTeaser: 'Sắp ra mắt: SAS / ASA / cạnh huyền góc nhọn / cạnh huyền cạnh góc vuông',
};
@@ -0,0 +1,22 @@
export const vi = {
slug: 'tam-giac-dong-dang',
topic: 'hinh-hoc',
grade: 'lop-8',
title: 'Tam giác đồng dạng',
gradeLabel: 'Lớp 8',
intro:
'Hai tam giác đồng dạng có các góc tương ứng bằng nhau và các cạnh tương ứng tỉ lệ. Hãy kéo thanh trượt phóng/thu △ABC — tỉ số AB/AB luôn bằng BC/BC và CA/CA, dù tam giác lớn hay nhỏ. Các góc thì không đổi.',
instruction: 'Kéo thanh trượt để phóng to hoặc thu nhỏ △ABC',
kLabel: 'Hệ số phóng',
sidesTitle: 'Cạnh tương ứng',
ratioTitle: 'Tỉ số AB/AB = BC/BC = CA/CA',
anglesNote:
'Khi △ABC phóng/thu theo hệ số k, các cạnh nhân với k nhưng các góc tại A, B, C không thay đổi — đó chính là định nghĩa của hai tam giác đồng dạng.',
theoremTitle: 'Định nghĩa',
theoremStatement:
'Hai tam giác gọi là đồng dạng khi các góc tương ứng bằng nhau và các cạnh tương ứng tỉ lệ. Tỉ số đó được gọi là tỉ số đồng dạng k.',
exampleTitle: 'Ví dụ',
exampleBody:
'Ở hệ số k = 2, tam giác △ABC to gấp đôi △ABC: mỗi cạnh AB = 2 · AB. Khi đó tỉ số AB/AB = 1/2 = 0,50, đúng bằng BC/BC và CA/CA. Khi k = 0,5, tam giác ABC nhỏ bằng nửa: tỉ số AB/AB = 2,00. Hãy kéo thanh trượt để xác nhận.',
nextTeaser: 'Sắp ra mắt: kéo từng đỉnh tự do (AA / SAS / SSS đồng dạng)',
};
+28
View File
@@ -0,0 +1,28 @@
import { vec } from '$lib/geom-engine/vec.js';
/**
* Convert client (mouse/touch) coordinates to the SVG's viewBox space.
* @param {SVGSVGElement} svg
* @param {number} clientX
* @param {number} clientY
* @param {number} viewW
* @param {number} viewH
* @returns {import('$lib/geom-engine/vec.js').Vec2}
*/
export function clientToSvg(svg, clientX, clientY, viewW, viewH) {
const r = svg.getBoundingClientRect();
return vec(((clientX - r.left) / r.width) * viewW, ((clientY - r.top) / r.height) * viewH);
}
/**
* Clamp a point inside the viewBox with optional padding from edges.
* @param {import('$lib/geom-engine/vec.js').Vec2} v
* @param {number} viewW @param {number} viewH @param {number} [pad]
* @returns {import('$lib/geom-engine/vec.js').Vec2}
*/
export function clampToViewBox(v, viewW, viewH, pad = 16) {
return vec(
Math.max(pad, Math.min(viewW - pad, v.x)),
Math.max(pad, Math.min(viewH - pad, v.y)),
);
}
+53 -90
View File
@@ -1,125 +1,88 @@
<script>
import { base } from '$app/paths';
import { t } from '$lib/i18n/index.js';
const copy = t();
/** @type {Array<keyof typeof copy.topics>} */
const topicOrder = ['so-hoc', 'dai-so', 'hinh-hoc'];
const topics = topicOrder.map((key) => ({ key, ...copy.topics[key] }));
</script>
<svelte:head>
<title>MathMax — Toán cho học sinh THCS</title>
<meta name="description" content="MathMax giúp học sinh THCS lớp 6-9 học toán qua tương tác. Số học, Đại số, Hình học. Sắp ra mắt." />
<meta name="description" content={copy.site.description} />
</svelte:head>
<!-- Header -->
<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">
<span class="text-xl font-bold text-indigo-600 tracking-tight">MathMax</span>
<!-- Nav placeholder — future phases will add topic links -->
<nav aria-label="Điều hướng chính"></nav>
</div>
</header>
<!-- Hero -->
<main class="bg-slate-50 min-h-screen">
<section class="max-w-4xl mx-auto px-4 py-16 text-center">
<h1 class="text-5xl font-bold text-slate-900 mb-4">MathMax</h1>
<h1 class="text-5xl font-bold text-slate-900 mb-4">{copy.site.title}</h1>
<p class="text-lg text-slate-600 max-w-2xl mx-auto mb-8 leading-relaxed">
Toán tương tác cho học sinh THCS — lớp 6 đến lớp 9. Khám phá Số học, Đại số, Hình học qua các hoạt động kéo-thả, thử nghiệm, và minh hoạ trực quan.
{copy.site.description}
</p>
<!-- Scope strip -->
<div class="flex flex-col items-center gap-3">
<span class="text-sm font-semibold text-slate-500 uppercase tracking-wide">Phạm vi</span>
<span class="text-sm font-semibold text-slate-500 uppercase tracking-wide">{copy.hub.scopeLabel}</span>
<ul class="flex flex-wrap justify-center gap-2" aria-label="Các lớp học được hỗ trợ">
<li>
<span class="px-4 py-1.5 rounded-full bg-indigo-100 text-indigo-700 text-sm font-medium">Lớp 6</span>
</li>
<li>
<span class="px-4 py-1.5 rounded-full bg-indigo-100 text-indigo-700 text-sm font-medium">Lớp 7</span>
</li>
<li>
<span class="px-4 py-1.5 rounded-full bg-indigo-100 text-indigo-700 text-sm font-medium">Lớp 8</span>
</li>
<li>
<span class="px-4 py-1.5 rounded-full bg-indigo-100 text-indigo-700 text-sm font-medium">Lớp 9</span>
</li>
{#each Object.values(copy.grades) as label}
<li><span class="px-4 py-1.5 rounded-full bg-indigo-100 text-indigo-700 text-sm font-medium">{label}</span></li>
{/each}
</ul>
</div>
</section>
<!-- Topics -->
<section class="max-w-4xl mx-auto px-4 pb-20">
<h2 class="text-2xl font-bold text-slate-900 mb-8 text-center">Chủ đề</h2>
<h2 class="text-2xl font-bold text-slate-900 mb-8 text-center">{copy.hub.topicsTitle}</h2>
<ul class="grid md:grid-cols-3 gap-6" aria-label="Danh sách chủ đề">
<!-- Card: Số học -->
<li
aria-disabled="true"
class="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col gap-4 opacity-75 cursor-default select-none"
>
<div class="flex items-center justify-between">
<h3 class="text-lg font-bold text-slate-900">Số học</h3>
<span class="text-xs font-semibold uppercase tracking-wide text-amber-600 bg-amber-50 border border-amber-200 rounded-full px-2.5 py-0.5">
Sắp ra mắt
</span>
</div>
<p class="text-sm text-slate-500 leading-relaxed flex-1">
Phép tính, ước-bội, phân số, số nguyên. Trực quan hoá thuật toán và quy luật.
</p>
</li>
<!-- Card: Đại số -->
<li
aria-disabled="true"
class="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col gap-4 opacity-75 cursor-default select-none"
>
<div class="flex items-center justify-between">
<h3 class="text-lg font-bold text-slate-900">Đại số</h3>
<span class="text-xs font-semibold uppercase tracking-wide text-amber-600 bg-amber-50 border border-amber-200 rounded-full px-2.5 py-0.5">
Sắp ra mắt
</span>
</div>
<p class="text-sm text-slate-500 leading-relaxed flex-1">
Biểu thức, phương trình, hàm số. Thao tác kéo-thả các đối tượng đại số.
</p>
</li>
<!-- Card: Hình học -->
<li
aria-disabled="true"
class="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col gap-4 opacity-75 cursor-default select-none"
>
<div class="flex items-center justify-between">
<h3 class="text-lg font-bold text-slate-900">Hình học</h3>
<span class="text-xs font-semibold uppercase tracking-wide text-amber-600 bg-amber-50 border border-amber-200 rounded-full px-2.5 py-0.5">
Sắp ra mắt
</span>
</div>
<p class="text-sm text-slate-500 leading-relaxed flex-1">
Tam giác, tứ giác, đường tròn. Kéo điểm, định lý sống động.
</p>
</li>
{#each topics as topic (topic.key)}
{@const isLive = topic.status === 'live' && topic.href}
<li>
{#if isLive}
<a
href={base + topic.href}
class="block bg-white rounded-2xl border border-slate-200 p-6 transition hover:border-indigo-400 hover:shadow-sm h-full"
>
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-bold text-slate-900">{topic.title}</h3>
<span class="text-xs font-semibold uppercase tracking-wide text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-full px-2.5 py-0.5">
{copy.status[/** @type {keyof typeof copy.status} */ (topic.status)]}
</span>
</div>
<p class="text-sm text-slate-500 leading-relaxed">{topic.blurb}</p>
</a>
{:else}
<div
aria-disabled="true"
class="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col gap-4 opacity-75 cursor-default select-none h-full"
>
<div class="flex items-center justify-between">
<h3 class="text-lg font-bold text-slate-900">{topic.title}</h3>
<span class="text-xs font-semibold uppercase tracking-wide text-amber-600 bg-amber-50 border border-amber-200 rounded-full px-2.5 py-0.5">
{copy.status[/** @type {keyof typeof copy.status} */ (topic.status)]}
</span>
</div>
<p class="text-sm text-slate-500 leading-relaxed flex-1">{topic.blurb}</p>
</div>
{/if}
</li>
{/each}
</ul>
</section>
</main>
<!-- Footer -->
<footer class="border-t border-slate-200 bg-white">
<div class="max-w-4xl mx-auto px-4 py-6 flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-sm text-slate-500">
<span>© {new Date().getFullYear()}</span>
<a
href="https://github.com/tiennm99"
class="text-indigo-600 hover:underline"
target="_blank"
rel="noopener noreferrer"
>tiennm99</a>
<a href="https://github.com/tiennm99" class="text-indigo-600 hover:underline" target="_blank" rel="noopener noreferrer">tiennm99</a>
<span aria-hidden="true">·</span>
<a
href="https://github.com/tiennm99/mathmax/blob/main/LICENSE"
class="text-indigo-600 hover:underline"
target="_blank"
rel="noopener noreferrer"
>Apache-2.0</a>
<a href="https://github.com/tiennm99/mathmax/blob/main/LICENSE" class="text-indigo-600 hover:underline" target="_blank" rel="noopener noreferrer">Apache-2.0</a>
<span aria-hidden="true">·</span>
<a
href="https://github.com/tiennm99/mathmax"
class="text-indigo-600 hover:underline"
target="_blank"
rel="noopener noreferrer"
>Mã nguồn</a>
<a href="https://github.com/tiennm99/mathmax" class="text-indigo-600 hover:underline" target="_blank" rel="noopener noreferrer">Mã nguồn</a>
</div>
</footer>
+59
View File
@@ -0,0 +1,59 @@
<script>
import { base } from '$app/paths';
import { t } from '$lib/i18n/index.js';
import { lessonsByTopic } from '$lib/lessons/registry.js';
const copy = t();
const topic = copy.topics['hinh-hoc'];
const lessons = lessonsByTopic('hinh-hoc');
</script>
<svelte:head>
<title>{topic.title}{copy.site.title}</title>
<meta name="description" content={topic.blurb} />
</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>
<nav aria-label="Điều hướng chính"></nav>
</div>
</header>
<main class="bg-slate-50 min-h-screen">
<section class="max-w-4xl mx-auto px-4 py-12">
<nav class="mb-6 text-sm">
<a href={base + '/'} class="text-indigo-600 hover:underline">{copy.lessonChrome.backToHub}</a>
</nav>
<header class="mb-8">
<h1 class="text-4xl font-bold text-slate-900 mb-2">{topic.title}</h1>
<p class="text-lg text-slate-600">{topic.blurb}</p>
</header>
<ul class="grid md:grid-cols-2 gap-4" aria-label="Danh sách bài học hình học">
{#each lessons as lesson (lesson.slug)}
<li>
<a
href={base + `/hinh-hoc/${lesson.slug}/`}
class="block bg-white rounded-2xl border border-slate-200 p-5 transition hover:border-indigo-400 hover:shadow-sm"
>
<div class="flex items-baseline justify-between gap-3 mb-2">
<span class="text-xs font-semibold uppercase tracking-wide text-slate-500">{lesson.gradeLabel}</span>
<span class="text-xs text-emerald-700">{copy.status.live}</span>
</div>
<h2 class="text-lg font-bold text-slate-900 mb-1">{lesson.title}</h2>
<p class="text-sm text-slate-600 leading-relaxed">{lesson.intro}</p>
</a>
</li>
{/each}
</ul>
</section>
</main>
<footer class="border-t border-slate-200 bg-white">
<div class="max-w-4xl mx-auto px-4 py-6 text-center text-sm text-slate-500">
© {new Date().getFullYear()} ·
<a href="https://github.com/tiennm99/mathmax" class="text-indigo-600 hover:underline" target="_blank" rel="noopener noreferrer">Mã nguồn</a>
</div>
</footer>
@@ -0,0 +1,119 @@
<script>
import { base } from '$app/paths';
import { t } from '$lib/i18n/index.js';
import { vi as m } from '$lib/lessons/goc-noi-tiep/copy.vi.js';
import { circle, pointOnCircle, projectToCircle, angleAtVertex } from '$lib/geom-engine/circle.js';
import { draggable } from '$lib/actions/draggable.svelte.js';
const copy = t();
const VIEW = 400;
const C = circle(VIEW / 2, VIEW / 2, 150);
const A = pointOnCircle(C, 150);
const B = pointOnCircle(C, 30);
/** @type {SVGSVGElement | undefined} */
let svgEl = $state();
let M = $state(pointOnCircle(C, 270));
const inscribed = $derived(angleAtVertex(A, M, B));
const central = $derived(angleAtVertex(A, C.center, B));
/** @param {{x: number, y: number}} p */
const projector = (p) => projectToCircle(p, C);
const dragOpts = $derived({
point: M,
svg: () => svgEl ?? null,
viewBox: { w: VIEW, h: VIEW },
projector,
pad: 0,
});
</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 + '/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>
<section class="mb-2 flex items-baseline justify-between gap-3 text-sm tabular-nums" aria-live="polite">
<span><strong style="color:#D7263D">{m.inscribedLabel}:</strong> {inscribed.toFixed(1)}°</span>
<span><strong style="color:#1B998B">{m.centralLabel}:</strong> {central.toFixed(1)}°</span>
</section>
<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}
>
<circle cx={C.center.x} cy={C.center.y} r={C.radius} fill="none" stroke="#999" stroke-width="2" />
<line x1={C.center.x} y1={C.center.y} x2={A.x} y2={A.y} stroke="#D0D0D0" stroke-width="1" stroke-dasharray="4 4" />
<line x1={C.center.x} y1={C.center.y} x2={B.x} y2={B.y} stroke="#D0D0D0" stroke-width="1" stroke-dasharray="4 4" />
<line x1={A.x} y1={A.y} x2={M.x} y2={M.y} stroke="#444" stroke-width="2" />
<line x1={B.x} y1={B.y} x2={M.x} y2={M.y} stroke="#444" stroke-width="2" />
<circle cx={C.center.x} cy={C.center.y} r="3" fill="#888" />
<text x={C.center.x + 8} y={C.center.y - 2} font-size="14" fill="#888">O</text>
<circle cx={A.x} cy={A.y} r="6" fill="#1B998B" />
<text x={A.x - 18} y={A.y + 18} font-size="14" font-weight="700" fill="#1B998B">A</text>
<circle cx={B.x} cy={B.y} r="6" fill="#1B998B" />
<text x={B.x + 10} y={B.y + 18} font-size="14" font-weight="700" fill="#1B998B">B</text>
<circle
cx={M.x}
cy={M.y}
r="14"
fill="#D7263D"
stroke="#fff"
stroke-width="2"
role="button"
tabindex="0"
aria-label="Điểm M trên đường tròn — kéo hoặc dùng phím mũi tên"
style="cursor:grab; outline:none"
use:draggable={dragOpts}
/>
<text x={M.x + 12} y={M.y - 8} font-size="14" font-weight="700" fill="#D7263D">M</text>
</svg>
<p class="mt-2 text-center text-sm text-slate-500">{m.instruction}</p>
</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,159 @@
<script>
import { base } from '$app/paths';
import { t } from '$lib/i18n/index.js';
import { vi as m } from '$lib/lessons/tam-giac-bang-nhau/copy.vi.js';
import { triangle, sides, congruentSSS } from '$lib/geom-engine/triangle.js';
import { tickPositions } from '$lib/geom-engine/ticks.js';
import { draggable } from '$lib/actions/draggable.svelte.js';
const copy = t();
const VIEW_W = 400;
const VIEW_H = 300;
const PAIR1 = '#D7263D';
const PAIR2 = '#1B998B';
const PAIR3 = '#F46036';
/** @type {SVGSVGElement | undefined} */
let svgEl = $state();
let a = $state({ x: 60, y: 80 });
let b = $state({ x: 180, y: 80 });
let c = $state({ x: 120, y: 220 });
let ap = $state({ x: 220, y: 80 });
let bp = $state({ x: 340, y: 80 });
let cp = $state({ x: 280, y: 220 });
const t1 = $derived(triangle(a, b, c));
const t2 = $derived(triangle(ap, bp, cp));
const s1 = $derived(sides(t1));
const s2 = $derived(sides(t2));
const isCongruent = $derived(congruentSSS(t1, t2));
const ticksAB = $derived(tickPositions(a, b, 1));
const ticksBC = $derived(tickPositions(b, c, 2));
const ticksCA = $derived(tickPositions(c, a, 3));
const ticksApBp = $derived(tickPositions(ap, bp, 1));
const ticksBpCp = $derived(tickPositions(bp, cp, 2));
const ticksCpAp = $derived(tickPositions(cp, ap, 3));
const getSvg = () => svgEl ?? null;
/** @param {{x: number, y: number}} point */
const dragOpts = (point) => ({ point, svg: getSvg, viewBox: { w: VIEW_W, h: VIEW_H } });
</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 + '/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>
<section class="mb-6">
<div class="flex items-center justify-end mb-2 min-h-[28px]">
{#if isCongruent}
<span class="inline-block px-3 py-1 rounded-full bg-emerald-50 text-emerald-700 text-sm font-semibold border border-emerald-200">
{m.congruentBadge}
</span>
{/if}
</div>
<svg
bind:this={svgEl}
viewBox="0 0 {VIEW_W} {VIEW_H}"
preserveAspectRatio="xMidYMid meet"
class="block w-full bg-white rounded-lg border border-slate-200"
style="touch-action:none"
role="img"
aria-label={m.instruction}
>
<!-- Triangle 1 -->
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke="#444" stroke-width="2" />
<line x1={b.x} y1={b.y} x2={c.x} y2={c.y} stroke="#444" stroke-width="2" />
<line x1={c.x} y1={c.y} x2={a.x} y2={a.y} stroke="#444" stroke-width="2" />
{#each ticksAB as t (t.x1 + ',' + t.y1)}
<line x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={PAIR1} stroke-width="2.5" stroke-linecap="round" />
{/each}
{#each ticksBC as t (t.x1 + ',' + t.y1)}
<line x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={PAIR2} stroke-width="2.5" stroke-linecap="round" />
{/each}
{#each ticksCA as t (t.x1 + ',' + t.y1)}
<line x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={PAIR3} stroke-width="2.5" stroke-linecap="round" />
{/each}
<!-- Triangle 2 -->
<line x1={ap.x} y1={ap.y} x2={bp.x} y2={bp.y} stroke="#444" stroke-width="2" />
<line x1={bp.x} y1={bp.y} x2={cp.x} y2={cp.y} stroke="#444" stroke-width="2" />
<line x1={cp.x} y1={cp.y} x2={ap.x} y2={ap.y} stroke="#444" stroke-width="2" />
{#each ticksApBp as t (t.x1 + ',' + t.y1)}
<line x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={PAIR1} stroke-width="2.5" stroke-linecap="round" />
{/each}
{#each ticksBpCp as t (t.x1 + ',' + t.y1)}
<line x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={PAIR2} stroke-width="2.5" stroke-linecap="round" />
{/each}
{#each ticksCpAp as t (t.x1 + ',' + t.y1)}
<line x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={PAIR3} stroke-width="2.5" stroke-linecap="round" />
{/each}
<!-- Vertices -->
<circle cx={a.x} cy={a.y} r="12" fill="#4F46E5" stroke="#fff" stroke-width="2" role="button" tabindex="0" aria-label="Đỉnh A — kéo hoặc dùng phím mũi tên" style="cursor:grab; outline:none" use:draggable={dragOpts(a)} />
<text x={a.x - 16} y={a.y - 14} font-size="14" font-weight="700" fill="#1F2937">A</text>
<circle cx={b.x} cy={b.y} r="12" fill="#4F46E5" stroke="#fff" stroke-width="2" role="button" tabindex="0" aria-label="Đỉnh B — kéo hoặc dùng phím mũi tên" style="cursor:grab; outline:none" use:draggable={dragOpts(b)} />
<text x={b.x + 12} y={b.y - 14} font-size="14" font-weight="700" fill="#1F2937">B</text>
<circle cx={c.x} cy={c.y} r="12" fill="#4F46E5" stroke="#fff" stroke-width="2" role="button" tabindex="0" aria-label="Đỉnh C — kéo hoặc dùng phím mũi tên" style="cursor:grab; outline:none" use:draggable={dragOpts(c)} />
<text x={c.x - 4} y={c.y + 24} font-size="14" font-weight="700" fill="#1F2937">C</text>
<circle cx={ap.x} cy={ap.y} r="12" fill="#4F46E5" stroke="#fff" stroke-width="2" role="button" tabindex="0" aria-label="Đỉnh A' — kéo hoặc dùng phím mũi tên" style="cursor:grab; outline:none" use:draggable={dragOpts(ap)} />
<text x={ap.x - 16} y={ap.y - 14} font-size="14" font-weight="700" fill="#1F2937">A</text>
<circle cx={bp.x} cy={bp.y} r="12" fill="#4F46E5" stroke="#fff" stroke-width="2" role="button" tabindex="0" aria-label="Đỉnh B' — kéo hoặc dùng phím mũi tên" style="cursor:grab; outline:none" use:draggable={dragOpts(bp)} />
<text x={bp.x + 12} y={bp.y - 14} font-size="14" font-weight="700" fill="#1F2937">B</text>
<circle cx={cp.x} cy={cp.y} r="12" fill="#4F46E5" stroke="#fff" stroke-width="2" role="button" tabindex="0" aria-label="Đỉnh C' — kéo hoặc dùng phím mũi tên" style="cursor:grab; outline:none" use:draggable={dragOpts(cp)} />
<text x={cp.x - 4} y={cp.y + 24} font-size="14" font-weight="700" fill="#1F2937">C</text>
</svg>
<p class="mt-2 text-center text-sm text-slate-500">{m.instruction}</p>
</section>
<section class="mb-8">
<h2 class="text-lg font-bold text-slate-900 mb-3">{m.lengthsTitle}</h2>
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm tabular-nums" aria-live="polite">
<div><span style="color:{PAIR1}" class="font-semibold">AB:</span> {s1.ab.toFixed(1)}</div>
<div><span style="color:{PAIR1}" class="font-semibold">AB:</span> {s2.ab.toFixed(1)}</div>
<div><span style="color:{PAIR2}" class="font-semibold">BC:</span> {s1.bc.toFixed(1)}</div>
<div><span style="color:{PAIR2}" class="font-semibold">BC:</span> {s2.bc.toFixed(1)}</div>
<div><span style="color:{PAIR3}" class="font-semibold">CA:</span> {s1.ca.toFixed(1)}</div>
<div><span style="color:{PAIR3}" class="font-semibold">CA:</span> {s2.ca.toFixed(1)}</div>
</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>
@@ -0,0 +1,145 @@
<script>
import { base } from '$app/paths';
import { t } from '$lib/i18n/index.js';
import { vi as m } from '$lib/lessons/tam-giac-dong-dang/copy.vi.js';
import { triangle, sides } from '$lib/geom-engine/triangle.js';
import { add, scale, sub, vec } from '$lib/geom-engine/vec.js';
import { tickPositions } from '$lib/geom-engine/ticks.js';
const copy = t();
const VIEW_W = 400;
const VIEW_H = 300;
const PAIR1 = '#D7263D';
const PAIR2 = '#1B998B';
const PAIR3 = '#F46036';
// △ABC fixed; centroid near (101.67, 146.67).
const A = vec(70, 110);
const B = vec(140, 130);
const C = vec(95, 200);
const CENTROID_ABC = vec((A.x + B.x + C.x) / 3, (A.y + B.y + C.y) / 3);
const CENTROID_TARGET = vec(300, 145);
let k = $state(1);
const t2 = $derived.by(() => {
/** @param {{x: number, y: number}} p */
const make = (p) => add(CENTROID_TARGET, scale(sub(p, CENTROID_ABC), k));
return triangle(make(A), make(B), make(C));
});
const s1 = sides(triangle(A, B, C));
const s2 = $derived(sides(t2));
const ratio = $derived((1 / k));
const ticks1AB = tickPositions(A, B, 1);
const ticks1BC = tickPositions(B, C, 2);
const ticks1CA = tickPositions(C, A, 3);
const ticks2AB = $derived(tickPositions(t2.a, t2.b, 1));
const ticks2BC = $derived(tickPositions(t2.b, t2.c, 2));
const ticks2CA = $derived(tickPositions(t2.c, t2.a, 3));
</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 + '/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>
<section class="mb-6">
<svg
viewBox="0 0 {VIEW_W} {VIEW_H}"
preserveAspectRatio="xMidYMid meet"
class="block w-full bg-white rounded-lg border border-slate-200"
role="img"
aria-label={m.title}
>
<!-- △ABC fixed -->
<line x1={A.x} y1={A.y} x2={B.x} y2={B.y} stroke="#444" stroke-width="2" />
<line x1={B.x} y1={B.y} x2={C.x} y2={C.y} stroke="#444" stroke-width="2" />
<line x1={C.x} y1={C.y} x2={A.x} y2={A.y} stroke="#444" stroke-width="2" />
{#each ticks1AB as t}<line x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={PAIR1} stroke-width="2.5" stroke-linecap="round" />{/each}
{#each ticks1BC as t}<line x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={PAIR2} stroke-width="2.5" stroke-linecap="round" />{/each}
{#each ticks1CA as t}<line x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={PAIR3} stroke-width="2.5" stroke-linecap="round" />{/each}
<text x={A.x - 14} y={A.y - 6} font-size="14" font-weight="700" fill="#1F2937">A</text>
<text x={B.x + 6} y={B.y - 6} font-size="14" font-weight="700" fill="#1F2937">B</text>
<text x={C.x - 4} y={C.y + 18} font-size="14" font-weight="700" fill="#1F2937">C</text>
<!-- △A'B'C' scaled -->
<line x1={t2.a.x} y1={t2.a.y} x2={t2.b.x} y2={t2.b.y} stroke="#444" stroke-width="2" />
<line x1={t2.b.x} y1={t2.b.y} x2={t2.c.x} y2={t2.c.y} stroke="#444" stroke-width="2" />
<line x1={t2.c.x} y1={t2.c.y} x2={t2.a.x} y2={t2.a.y} stroke="#444" stroke-width="2" />
{#each ticks2AB as t (t.x1 + ',' + t.y1)}<line x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={PAIR1} stroke-width="2.5" stroke-linecap="round" />{/each}
{#each ticks2BC as t (t.x1 + ',' + t.y1)}<line x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={PAIR2} stroke-width="2.5" stroke-linecap="round" />{/each}
{#each ticks2CA as t (t.x1 + ',' + t.y1)}<line x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={PAIR3} stroke-width="2.5" stroke-linecap="round" />{/each}
<text x={t2.a.x - 14} y={t2.a.y - 6} font-size="14" font-weight="700" fill="#1F2937">A</text>
<text x={t2.b.x + 6} y={t2.b.y - 6} font-size="14" font-weight="700" fill="#1F2937">B</text>
<text x={t2.c.x - 4} y={t2.c.y + 18} font-size="14" font-weight="700" fill="#1F2937">C</text>
</svg>
</section>
<section class="mb-6">
<label class="block">
<span class="text-sm font-semibold text-slate-700">{m.kLabel}: <span class="tabular-nums">{k.toFixed(2)}</span></span>
<input
type="range"
min="0.4"
max="2"
step="0.05"
bind:value={k}
class="mt-2 w-full accent-indigo-600"
aria-label={m.kLabel}
/>
</label>
<p class="mt-2 text-sm text-slate-500">{m.instruction}</p>
</section>
<section class="mb-8">
<h2 class="text-lg font-bold text-slate-900 mb-3">{m.sidesTitle}</h2>
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm tabular-nums" aria-live="polite">
<div><span style="color:{PAIR1}" class="font-semibold">AB:</span> {s1.ab.toFixed(1)}</div>
<div><span style="color:{PAIR1}" class="font-semibold">AB:</span> {s2.ab.toFixed(1)}</div>
<div><span style="color:{PAIR2}" class="font-semibold">BC:</span> {s1.bc.toFixed(1)}</div>
<div><span style="color:{PAIR2}" class="font-semibold">BC:</span> {s2.bc.toFixed(1)}</div>
<div><span style="color:{PAIR3}" class="font-semibold">CA:</span> {s1.ca.toFixed(1)}</div>
<div><span style="color:{PAIR3}" class="font-semibold">CA:</span> {s2.ca.toFixed(1)}</div>
</div>
<p class="mt-3 rounded-lg bg-indigo-50 border border-indigo-200 p-3 text-sm text-indigo-900 tabular-nums">
{m.ratioTitle} = <strong>{ratio.toFixed(2)}</strong>
</p>
</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>
<p class="mt-3 text-sm text-slate-600 leading-relaxed">{m.anglesNote}</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>