mirror of
https://github.com/tiennm99/mathmax.git
synced 2026-06-18 00:48:01 +00:00
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:
+11
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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à △A′B′C′ với AB = A′B′ (cùng có một dấu gạch đỏ), BC = B′C′ (cùng có hai dấu gạch xanh), CA = C′A′ (cùng có ba dấu gạch cam). Theo trường hợp c.c.c, △ABC = △A′B′C′ — 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 △A′B′C′ — tỉ số AB/A′B′ luôn bằng BC/B′C′ và CA/C′A′, 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ỏ △A′B′C′',
|
||||
kLabel: 'Hệ số phóng',
|
||||
sidesTitle: 'Cạnh tương ứng',
|
||||
ratioTitle: 'Tỉ số AB/A′B′ = BC/B′C′ = CA/C′A′',
|
||||
anglesNote:
|
||||
'Khi △A′B′C′ 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 △A′B′C′ to gấp đôi △ABC: mỗi cạnh A′B′ = 2 · AB. Khi đó tỉ số AB/A′B′ = 1/2 = 0,50, đúng bằng BC/B′C′ và CA/C′A′. Khi k = 0,5, tam giác A′B′C′ nhỏ bằng nửa: tỉ số AB/A′B′ = 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)',
|
||||
};
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">A′B′:</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">B′C′:</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">C′A′:</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">A′B′:</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">B′C′:</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">C′A′:</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>
|
||||
Reference in New Issue
Block a user