v0.0.3.0 feat(module-1): drag-to-explore tam giác bằng nhau (lớp 7, SSS) (#3)

* feat(geom-engine): triangle module + position-strict congruentSSS

Third pure module under src/geom-engine/. Adds Triangle type, sides() (returns
named AB / BC / CA lengths), and congruentSSS() with position-strict semantics:
labels define the correspondence (AB↔A'B', BC↔B'C', CA↔C'A'), permuted matches
are NOT counted as congruent. This preserves SGK pedagogy where the labels and
the matched-pair encoding (color + tick marks) carry meaning.

10 new unit tests: identical, translated-congruent, similar-not-congruent (2x scale),
shape-different, EPSILON_LEN tolerance (within and just outside), label-permutation
rejection, and symmetry of the relation.

Total tests: 29 → 39.

* feat(module-1): drag-to-explore tam giác bằng nhau (lớp 7, SSS)

Second interactive theorem demo. Page at /lop-7/tam-giac-bang-nhau/.

Component: src/components/congruence-sss.ts owns 6 draggable vertices (3 per
triangle), each independently captured via setPointerCapture per vertex.
Drag-clamping (16px viewBox padding) keeps every vertex on-screen.

Encoding per autoplan Decision D3 (a11y + SGK-correct in one move):
  AB / A'B' → pair1 #D7263D + 1 tick
  BC / B'C' → pair2 #1B998B + 2 ticks
  CA / C'A' → pair3 #F46036 + 3 ticks
Tick marks are SVG line segments perpendicular to each side at the midpoint,
spaced 5 viewBox-units apart for multi-tick. Redrawn live during drag.

Page: viewBox 400x300 (4:3, side-by-side triangles), single 6KB page including
inline JS bundle (entire geom-engine + canvas controller, minified).

Live side-length readout table + green badge "Hai tam giác bằng nhau (c.c.c)"
that appears when SSS condition holds within EPSILON_LEN. Badge has
role="status" + aria-live="polite" so screen readers announce the moment the
triangles become congruent.

Rigid-motion overlay animation EXPLICITLY DROPPED per autoplan — color+tick
match is the success state.

* feat(hub): activate lớp 7 card linking to tam giác bằng nhau module

Status flips from sap-ra-mat to live; href points at the new module.
i18n/vi.ts gains module1.* strings (intro, instruction, theorem, example,
SGK-aligned theorem statement) so every user-facing string still routes
through t() — adding English in the future is one en.ts file away.

* chore: VERSION 0.0.3.0 + CHANGELOG + TODOS update

v0.0.3.0 = scaffold + Module 3 (góc nội tiếp) + Module 1 (tam giác bằng nhau).
2 of 3 MVP modules now live. Module 2 (tam giác đồng dạng) is next.

* chore: TODOS Module 1 reflects v0.0.3.0 (SSS shipped)

Original P1 items "SSS detector" + "SGK tick-mark encoding" already moved to
Completed in the previous commit. Replace the still-pending M1 section with
the actual remaining work: P2 toggles for SAS/ASA/AAS/cạnh huyền cases, P3 for
extra examples.

---------
This commit is contained in:
tiennm99 authored and GitHub committed 2026-04-30 00:17:26 +07:00
1 parent 65326f9e10
commit 80379c7c08
9 files changed
+490 -12

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
import { vec } from '~/geom-engine/vec';
import type { Vec2 } from '~/geom-engine/vec';
import {
congruentSSS,
sides,
triangle as makeTriangle,
} from '~/geom-engine/triangle';
const VIEW_W = 400;
const VIEW_H = 300;
const TICK_LEN = 6;
const TICK_SPACING = 5;
const PAIR1 = '#D7263D';
const PAIR2 = '#1B998B';
const PAIR3 = '#F46036';
type VertexId = 'a' | 'b' | 'c' | 'ap' | 'bp' | 'cp';
const VERTEX_IDS: readonly VertexId[] = ['a', 'b', 'c', 'ap', 'bp', 'cp'];
const INITIAL: Record<VertexId, Vec2> = {
a: vec(60, 80),
b: vec(180, 80),
c: vec(120, 220),
ap: vec(220, 80),
bp: vec(340, 80),
cp: vec(280, 220),
};
interface Refs {
svg: SVGSVGElement;
vertices: Record<VertexId, SVGCircleElement>;
labels: Record<VertexId, SVGTextElement>;
sides: Record<'ab' | 'bc' | 'ca' | 'apbp' | 'bpcp' | 'cpap', SVGLineElement>;
ticks: Record<'ab' | 'bc' | 'ca' | 'apbp' | 'bpcp' | 'cpap', SVGGElement>;
readouts: Record<'ab' | 'bc' | 'ca' | 'apbp' | 'bpcp' | 'cpap', HTMLElement>;
badge: HTMLElement;
}
function clientToSvg(svg: SVGSVGElement, x: number, y: number): Vec2 {
const r = svg.getBoundingClientRect();
return vec(((x - r.left) / r.width) * VIEW_W, ((y - r.top) / r.height) * VIEW_H);
}
function clamp(v: Vec2, pad = 16): Vec2 {
return vec(
Math.max(pad, Math.min(VIEW_W - pad, v.x)),
Math.max(pad, Math.min(VIEW_H - pad, v.y)),
);
}
function setLine(line: SVGLineElement, p1: Vec2, p2: Vec2) {
line.setAttribute('x1', p1.x.toFixed(2));
line.setAttribute('y1', p1.y.toFixed(2));
line.setAttribute('x2', p2.x.toFixed(2));
line.setAttribute('y2', p2.y.toFixed(2));
}
function renderTicks(group: SVGGElement, p1: Vec2, p2: Vec2, count: 1 | 2 | 3, color: string) {
while (group.firstChild) group.removeChild(group.firstChild);
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.hypot(dx, dy);
if (len < 1) return;
const dir = vec(dx / len, dy / len);
const perp = vec(-dir.y, dir.x);
const mid = vec((p1.x + p2.x) / 2, (p1.y + p2.y) / 2);
const start = -((count - 1) * TICK_SPACING) / 2;
for (let i = 0; i < count; i++) {
const offset = start + i * TICK_SPACING;
const cx = mid.x + dir.x * offset;
const cy = mid.y + dir.y * offset;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', (cx + perp.x * TICK_LEN).toFixed(2));
line.setAttribute('y1', (cy + perp.y * TICK_LEN).toFixed(2));
line.setAttribute('x2', (cx - perp.x * TICK_LEN).toFixed(2));
line.setAttribute('y2', (cy - perp.y * TICK_LEN).toFixed(2));
line.setAttribute('stroke', color);
line.setAttribute('stroke-width', '2.5');
line.setAttribute('stroke-linecap', 'round');
group.appendChild(line);
}
}
function update(refs: Refs, state: Record<VertexId, Vec2>) {
for (const id of VERTEX_IDS) {
const v = state[id];
refs.vertices[id].setAttribute('cx', v.x.toFixed(2));
refs.vertices[id].setAttribute('cy', v.y.toFixed(2));
// Position label slightly offset from vertex
const offsetX = id === 'b' || id === 'bp' ? 12 : id === 'c' || id === 'cp' ? -4 : -16;
const offsetY = id === 'c' || id === 'cp' ? 22 : -10;
refs.labels[id].setAttribute('x', (v.x + offsetX).toFixed(2));
refs.labels[id].setAttribute('y', (v.y + offsetY).toFixed(2));
}
const t1 = makeTriangle(state.a, state.b, state.c);
const t2 = makeTriangle(state.ap, state.bp, state.cp);
setLine(refs.sides.ab, t1.a, t1.b);
setLine(refs.sides.bc, t1.b, t1.c);
setLine(refs.sides.ca, t1.c, t1.a);
setLine(refs.sides.apbp, t2.a, t2.b);
setLine(refs.sides.bpcp, t2.b, t2.c);
setLine(refs.sides.cpap, t2.c, t2.a);
renderTicks(refs.ticks.ab, t1.a, t1.b, 1, PAIR1);
renderTicks(refs.ticks.apbp, t2.a, t2.b, 1, PAIR1);
renderTicks(refs.ticks.bc, t1.b, t1.c, 2, PAIR2);
renderTicks(refs.ticks.bpcp, t2.b, t2.c, 2, PAIR2);
renderTicks(refs.ticks.ca, t1.c, t1.a, 3, PAIR3);
renderTicks(refs.ticks.cpap, t2.c, t2.a, 3, PAIR3);
const s1 = sides(t1);
const s2 = sides(t2);
refs.readouts.ab.textContent = s1.ab.toFixed(1);
refs.readouts.bc.textContent = s1.bc.toFixed(1);
refs.readouts.ca.textContent = s1.ca.toFixed(1);
refs.readouts.apbp.textContent = s2.ab.toFixed(1);
refs.readouts.bpcp.textContent = s2.bc.toFixed(1);
refs.readouts.cpap.textContent = s2.ca.toFixed(1);
const congruent = congruentSSS(t1, t2);
refs.badge.style.display = congruent ? 'inline-block' : 'none';
}
function getRefs(svg: SVGSVGElement): Refs | null {
const vertices: Partial<Record<VertexId, SVGCircleElement>> = {};
const labels: Partial<Record<VertexId, SVGTextElement>> = {};
for (const id of VERTEX_IDS) {
const v = svg.querySelector<SVGCircleElement>(`[data-vertex="${id}"]`);
const l = svg.querySelector<SVGTextElement>(`[data-vertex-label="${id}"]`);
if (!v || !l) return null;
vertices[id] = v;
labels[id] = l;
}
const sideKeys = ['ab', 'bc', 'ca', 'apbp', 'bpcp', 'cpap'] as const;
type SideKey = (typeof sideKeys)[number];
const sides: Partial<Record<SideKey, SVGLineElement>> = {};
const ticks: Partial<Record<SideKey, SVGGElement>> = {};
const readouts: Partial<Record<SideKey, HTMLElement>> = {};
for (const key of sideKeys) {
const s = svg.querySelector<SVGLineElement>(`[data-side="${key}"]`);
const t = svg.querySelector<SVGGElement>(`[data-ticks="${key}"]`);
const r = document.querySelector<HTMLElement>(`[data-readout-side="${key}"]`);
if (!s || !t || !r) return null;
sides[key] = s;
ticks[key] = t;
readouts[key] = r;
}
const badge = document.querySelector<HTMLElement>('[data-badge="congruent"]');
if (!badge) return null;
return {
svg,
vertices: vertices as Record<VertexId, SVGCircleElement>,
labels: labels as Record<VertexId, SVGTextElement>,
sides: sides as Record<SideKey, SVGLineElement>,
ticks: ticks as Record<SideKey, SVGGElement>,
readouts: readouts as Record<SideKey, HTMLElement>,
badge,
};
}
export function setupCongruenceSSS(svgSelector: string) {
const svg = document.querySelector<SVGSVGElement>(svgSelector);
if (!svg) return;
const refs = getRefs(svg);
if (!refs) return;
const state: Record<VertexId, Vec2> = { ...INITIAL };
update(refs, state);
let active: { id: number; vertex: VertexId } | null = null;
const ctrl = new AbortController();
const opts = { signal: ctrl.signal } as AddEventListenerOptions;
for (const id of VERTEX_IDS) {
const el = refs.vertices[id];
el.addEventListener(
'pointerdown',
(e: PointerEvent) => {
active = { id: e.pointerId, vertex: id };
el.setPointerCapture(e.pointerId);
e.preventDefault();
},
opts,
);
el.addEventListener(
'pointermove',
(e: PointerEvent) => {
if (!active || active.id !== e.pointerId) return;
const raw = clientToSvg(svg, e.clientX, e.clientY);
state[active.vertex] = clamp(raw);
update(refs, state);
},
opts,
);
const release = (e: PointerEvent) => {
if (!active || active.id !== e.pointerId) return;
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
active = null;
};
el.addEventListener('pointerup', release, opts);
el.addEventListener('pointercancel', release, opts);
}
document.addEventListener('astro:before-swap', () => ctrl.abort(), { once: true });
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { congruentSSS, sides, triangle } from './triangle';
import { EPSILON_LEN, vec } from './vec';
describe('sides', () => {
it('returns the three side lengths in the AB / BC / CA order', () => {
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);
});
it('handles degenerate (collinear) triangles without crashing', () => {
const t = triangle(vec(0, 0), vec(1, 0), vec(2, 0));
const s = sides(t);
expect(s.ab).toBe(1);
expect(s.bc).toBe(1);
expect(s.ca).toBe(2);
});
});
describe('congruentSSS', () => {
const t1 = triangle(vec(0, 0), vec(3, 0), vec(0, 4));
it('detects identical triangles', () => {
expect(congruentSSS(t1, t1)).toBe(true);
});
it('detects translated congruent triangles (translation preserves congruence)', () => {
const t2 = triangle(vec(10, 10), vec(13, 10), vec(10, 14));
expect(congruentSSS(t1, t2)).toBe(true);
});
it('rejects similar-but-not-congruent triangles (2x scale)', () => {
const t2 = triangle(vec(0, 0), vec(6, 0), vec(0, 8));
expect(congruentSSS(t1, t2)).toBe(false);
});
it('rejects triangles with different shape', () => {
const t2 = triangle(vec(0, 0), vec(3, 0), vec(2, 5));
expect(congruentSSS(t1, t2)).toBe(false);
});
it('treats matches within EPSILON_LEN as congruent', () => {
const t2 = triangle(vec(0, 0), vec(3 + EPSILON_LEN / 2, 0), vec(0, 4));
expect(congruentSSS(t1, t2)).toBe(true);
});
it('rejects matches just outside EPSILON_LEN', () => {
const t2 = triangle(vec(0, 0), vec(3 + EPSILON_LEN * 2, 0), vec(0, 4));
expect(congruentSSS(t1, t2)).toBe(false);
});
it('is position-strict: permuted side-length sets do NOT count as congruent', () => {
// Same side-length set {3, 4, 5} but in a different label order.
// Strict SSS: AB(t1)=3, AB(t2)=4 → not congruent under our convention.
const t2 = triangle(vec(0, 0), vec(0, 4), vec(3, 4));
expect(congruentSSS(t1, t2)).toBe(false);
});
it('is symmetric: congruentSSS(t1, t2) === congruentSSS(t2, t1)', () => {
const t2 = triangle(vec(7, 7), vec(10, 7), vec(7, 11));
expect(congruentSSS(t1, t2)).toBe(congruentSSS(t2, t1));
});
});
+40
View File
@@ -0,0 +1,40 @@
import type { Vec2 } from './vec';
import { dist, EPSILON_LEN } from './vec';
export interface Triangle {
readonly a: Vec2;
readonly b: Vec2;
readonly c: Vec2;
}
export function triangle(a: Vec2, b: Vec2, c: Vec2): Triangle {
return { a, b, c };
}
export interface SideLengths {
readonly ab: number;
readonly bc: number;
readonly ca: number;
}
export function sides(t: Triangle): SideLengths {
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').
// SGK pedagogy treats vertex labels as defining the correspondence — a permuted
// match would still be the same shape but a different theorem case. We want the
// strict labeled version so the UI's color/tick pairing has unambiguous meaning.
export function congruentSSS(t1: Triangle, t2: Triangle, eps = EPSILON_LEN): boolean {
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
);
}
+20 -2
View File
@@ -15,8 +15,8 @@ export const vi = {
title: 'Lớp 7',
hero: 'Tam giác bằng nhau',
blurb: 'SSS, SAS, ASA — kéo hai tam giác để xem khi nào chúng bằng nhau.',
status: 'sap-ra-mat',
href: null,
status: 'live',
href: '/lop-7/tam-giac-bang-nhau/',
},
'lop-8': {
title: 'Lớp 8',
@@ -56,6 +56,24 @@ export const vi = {
backToHub: '← Về trang chủ',
nextTeaser: 'Bài tiếp theo: Tứ giác nội tiếp (sắp ra mắt)',
},
module1: {
title: 'Tam giác bằng nhau (SSS)',
grade: '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',
congruentBadge: 'Hai tam giác bằng nhau (c.c.c)',
lengthsTitle: 'Độ dài cạnh',
tabSide: 'Cặp 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 xanh sẽ bật lên.',
backToHub: '← Về trang chủ',
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',
},
} as const;
export type Locale = typeof vi;
@@ -0,0 +1,121 @@
---
import BaseLayout from '~/layouts/BaseLayout.astro';
import { t } from '~/i18n';
const copy = t();
const m = copy.module1;
const baseUrl = import.meta.env.BASE_URL;
---
<BaseLayout title={`${m.title} — ${copy.site.title}`} description={m.intro}>
<main class="mx-auto max-w-prose px-5 py-6">
<nav class="mb-4 text-sm">
<a href={baseUrl}>{m.backToHub}</a>
</nav>
<header class="mb-6">
<div class="text-sm uppercase tracking-wide" style="color:#888">{m.grade}</div>
<h1 class="mb-2">{m.title}</h1>
<p>{m.intro}</p>
</header>
<section class="mb-2 flex flex-wrap items-center justify-between gap-3">
<p class="text-sm m-0" style="color:#666">{m.instruction}</p>
<span
data-badge="congruent"
class="rounded-full px-3 py-1 text-sm font-medium"
style="background:#1B998B; color:white; display:none"
role="status"
aria-live="polite"
>✓ {m.congruentBadge}</span>
</section>
<section class="mb-6">
<svg
id="congruence-canvas"
viewBox="0 0 400 300"
preserveAspectRatio="xMidYMid meet"
class="block w-full mx-auto"
style="max-width:640px; touch-action:none; aspect-ratio:4/3; background:#FAFAFA; border-radius:8px"
role="img"
aria-label={m.instruction}
>
<line data-side="ab" x1="60" y1="80" x2="180" y2="80" stroke="#444" stroke-width="2"></line>
<line data-side="bc" x1="180" y1="80" x2="120" y2="220" stroke="#444" stroke-width="2"></line>
<line data-side="ca" x1="120" y1="220" x2="60" y2="80" stroke="#444" stroke-width="2"></line>
<line data-side="apbp" x1="220" y1="80" x2="340" y2="80" stroke="#444" stroke-width="2"></line>
<line data-side="bpcp" x1="340" y1="80" x2="280" y2="220" stroke="#444" stroke-width="2"></line>
<line data-side="cpap" x1="280" y1="220" x2="220" y2="80" stroke="#444" stroke-width="2"></line>
<g data-ticks="ab"></g>
<g data-ticks="bc"></g>
<g data-ticks="ca"></g>
<g data-ticks="apbp"></g>
<g data-ticks="bpcp"></g>
<g data-ticks="cpap"></g>
<text data-vertex-label="a" x="44" y="70" font-size="14" font-weight="700" fill="#444">A</text>
<text data-vertex-label="b" x="192" y="70" font-size="14" font-weight="700" fill="#444">B</text>
<text data-vertex-label="c" x="116" y="242" font-size="14" font-weight="700" fill="#444">C</text>
<text data-vertex-label="ap" x="204" y="70" font-size="14" font-weight="700" fill="#444">A&#39;</text>
<text data-vertex-label="bp" x="352" y="70" font-size="14" font-weight="700" fill="#444">B&#39;</text>
<text data-vertex-label="cp" x="276" y="242" font-size="14" font-weight="700" fill="#444">C&#39;</text>
<circle data-vertex="a" cx="60" cy="80" r="10" fill="#444" stroke="#FFFFFF" stroke-width="2" tabindex="0" style="cursor:grab"></circle>
<circle data-vertex="b" cx="180" cy="80" r="10" fill="#444" stroke="#FFFFFF" stroke-width="2" tabindex="0" style="cursor:grab"></circle>
<circle data-vertex="c" cx="120" cy="220" r="10" fill="#444" stroke="#FFFFFF" stroke-width="2" tabindex="0" style="cursor:grab"></circle>
<circle data-vertex="ap" cx="220" cy="80" r="10" fill="#444" stroke="#FFFFFF" stroke-width="2" tabindex="0" style="cursor:grab"></circle>
<circle data-vertex="bp" cx="340" cy="80" r="10" fill="#444" stroke="#FFFFFF" stroke-width="2" tabindex="0" style="cursor:grab"></circle>
<circle data-vertex="cp" cx="280" cy="220" r="10" fill="#444" stroke="#FFFFFF" stroke-width="2" tabindex="0" style="cursor:grab"></circle>
</svg>
</section>
<section class="mb-6">
<h2 class="mb-2 text-base">{m.lengthsTitle}</h2>
<table class="w-full text-sm tabular-nums">
<thead>
<tr style="border-bottom:1px solid #ddd">
<th class="text-left py-1 font-medium" style="color:#666">{m.tabSide}</th>
<th class="text-right py-1 font-medium" style="color:#666">△ABC</th>
<th class="text-right py-1 font-medium" style="color:#666">△A&#39;B&#39;C&#39;</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #f0f0f0">
<td class="py-1"><span style="color:#D7263D; font-weight:700">|</span> AB / A&#39;B&#39;</td>
<td class="text-right" style="color:#D7263D"><span data-readout-side="ab">—</span></td>
<td class="text-right" style="color:#D7263D"><span data-readout-side="apbp">—</span></td>
</tr>
<tr style="border-bottom:1px solid #f0f0f0">
<td class="py-1"><span style="color:#1B998B; font-weight:700">| |</span> BC / B&#39;C&#39;</td>
<td class="text-right" style="color:#1B998B"><span data-readout-side="bc">—</span></td>
<td class="text-right" style="color:#1B998B"><span data-readout-side="bpcp">—</span></td>
</tr>
<tr>
<td class="py-1"><span style="color:#F46036; font-weight:700">| | |</span> CA / C&#39;A&#39;</td>
<td class="text-right" style="color:#F46036"><span data-readout-side="ca">—</span></td>
<td class="text-right" style="color:#F46036"><span data-readout-side="cpap">—</span></td>
</tr>
</tbody>
</table>
</section>
<section class="mb-6">
<h2 class="mb-2">{m.theoremTitle}</h2>
<p class="rounded-lg p-4" style="background:#F4F4F4">{m.theoremStatement}</p>
</section>
<section class="mb-10">
<h2 class="mb-2">{m.exampleTitle}</h2>
<p>{m.exampleBody}</p>
</section>
<footer class="border-t border-neutral-200 pt-4 text-sm" style="color:#888">{m.nextTeaser}</footer>
</main>
</BaseLayout>
<script>
import { setupCongruenceSSS } from '~/components/congruence-sss';
setupCongruenceSSS('#congruence-canvas');
</script>