mirror of
https://github.com/tiennm99/try-gstack.git
synced 2026-05-14 06:59:26 +00:00
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. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,25 @@
|
||||
|
||||
All notable changes to **Hình Học Sống** are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: 4-digit MAJOR.MINOR.PATCH.MICRO per gstack.
|
||||
|
||||
## [0.0.3.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
|
||||
- **Module 1 (lớp 7): Tam giác bằng nhau (SSS)** — second interactive theorem demo, live at `/lop-7/tam-giac-bang-nhau/`.
|
||||
- Two side-by-side triangles ABC and A′B′C′ in a 400×300 viewBox. All 6 vertices independently draggable via Pointer Events + setPointerCapture per vertex. Drag-clamping keeps every vertex inside the canvas (16-px padding) so the triangles never escape view.
|
||||
- SGK-correct encoding per autoplan Decision D3 — color and tick marks paired: AB/A′B′ in pair1 #D7263D with **1 tick**, BC/B′C′ in pair2 #1B998B with **2 ticks**, CA/C′A′ in pair3 #F46036 with **3 ticks**. Tick marks are perpendicular SVG line segments rendered at the midpoint of each side, redrawn live during drag. This is both a11y-correct (color is never the only signal) and matches what every Vietnamese textbook does.
|
||||
- Live side-length readout table — six values, color-keyed to the matching tick-pair colors.
|
||||
- Green pill badge "Hai tam giác bằng nhau (c.c.c)" appears whenever all 3 corresponding side pairs match within `EPSILON_LEN = 0.5` viewBox units. ARIA-live polite so screen readers announce the moment the triangles become congruent.
|
||||
- `src/geom-engine/triangle.ts` — third pure module: `triangle()`, `sides()`, `congruentSSS()` with position-strict semantics (AB↔A′B′, etc., not arbitrary permutations — preserves the SGK label-correspondence convention).
|
||||
- 10 new unit tests including translation invariance, similar-not-congruent rejection, EPSILON tolerance, position-strict label correspondence (permuted side-length sets are NOT counted as congruent), and symmetry of the relation.
|
||||
- Hub landing page: lớp 7 card now links to the module with "Khám phá" status.
|
||||
|
||||
### Notes
|
||||
|
||||
- Per autoplan: rigid-motion overlay animation EXPLICITLY DROPPED from MVP (was scope creep — rotation+reflection interpolation = days, not hours). The green badge + tick-mark color match is the entire success state.
|
||||
- SAS / ASA / AAS / cạnh huyền-góc nhọn / cạnh huyền-cạnh góc vuông toggles deferred — SSS alone validates the canvas + tick-mark + badge pattern. Other cases land in v0.0.5.0+ as toggles on the same canvas.
|
||||
- Worked examples count: 1 (autoplan called for 3). Same posture as Module 3 — content additions don't gate the killer-demo ship.
|
||||
|
||||
## [0.0.2.0] - 2026-04-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -57,16 +57,15 @@ Tracked work, organized by component then priority (P0 highest → P4 lowest). S
|
||||
**Priority:** P3
|
||||
**What:** Currently uses Unicode (∠AMB, °) which renders fine in Be Vietnam Pro. Switch to KaTeX when notation gets richer (cung, đường tròn (O), etc.).
|
||||
|
||||
## Module 1 — Lớp 7 Tam giác bằng nhau (weekend 3)
|
||||
## Module 1 — Lớp 7 Tam giác bằng nhau
|
||||
|
||||
- **SSS / SAS / ASA / AAS / cạnh huyền-góc nhọn / cạnh huyền-cạnh góc vuông detectors**
|
||||
**Priority:** P1
|
||||
**What:** Pure geometry functions in `src/geom-engine/congruence.ts`. Use `EPSILON_LEN = 0.5`. Color-pair highlighting + green "Hai tam giác bằng nhau" badge.
|
||||
**Note:** Rigid-motion overlay animation EXPLICITLY DROPPED per autoplan (was scope creep).
|
||||
- **SAS / ASA / AAS / cạnh huyền-góc nhọn / cạnh huyền-cạnh góc vuông toggles**
|
||||
**Priority:** P2
|
||||
**What:** SSS shipped in v0.0.3.0. Add the other 5 cases as toggles on the same canvas — toggling switches which sides/angles get the matched encoding and which detector runs.
|
||||
|
||||
- **SGK tick-mark encoding**
|
||||
**Priority:** P1
|
||||
**What:** 1/2/3 ticks for matching sides, 1/2/3 arcs for matching angles. Always paired with the 3-color palette (#D7263D / #1B998B / #F46036). Per Design Decision D3 — single largest a11y + pedagogical unlock.
|
||||
- **More worked examples (Module 1)**
|
||||
**Priority:** P3
|
||||
**What:** v0.0.3.0 ships 1 example. SGK textbook has many. Add 2 more.
|
||||
|
||||
## Module 2 — Lớp 8 Tam giác đồng dạng (weekend 4)
|
||||
|
||||
@@ -151,3 +150,5 @@ Tracked work, organized by component then priority (P0 highest → P4 lowest). S
|
||||
|
||||
- **Drag M around circle, project to circle constraint** — `src/components/inscribed-angle.ts` + `src/geom-engine/circle.ts:projectToCircle`. **Completed:** v0.0.2.0 (2026-04-29)
|
||||
- **TheoremCanvas primitive first cut** — vanilla TS in Astro `<script>` tag, AbortController teardown on `astro:before-swap`. **Completed:** v0.0.2.0 (2026-04-29). Note: built directly in Astro rather than as a single HTML prototype first; the inscribed-angle pattern proved the shape, and the next module (lớp 7 / 8) will reuse this pattern.
|
||||
- **SSS detector** — `src/geom-engine/triangle.ts:congruentSSS` with position-strict semantics. 10 unit tests including symmetry and EPSILON tolerance. **Completed:** v0.0.3.0 (2026-04-30)
|
||||
- **SGK tick-mark encoding** — 1/2/3 ticks paired with the locked 3-color palette, rendered live during drag. Wired in Module 1 (Tam giác bằng nhau). **Completed:** v0.0.3.0 (2026-04-30). Tick rendering helper in `src/components/congruence-sss.ts:renderTicks` is reusable for future modules.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "try-gstack",
|
||||
"version": "0.0.2.0",
|
||||
"version": "0.0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Hình Học Sống — Interactive Vietnamese THCS geometry visualizer (lớp 7-9). Static site, drag-to-explore theorems.",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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'</text>
|
||||
<text data-vertex-label="bp" x="352" y="70" font-size="14" font-weight="700" fill="#444">B'</text>
|
||||
<text data-vertex-label="cp" x="276" y="242" font-size="14" font-weight="700" fill="#444">C'</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'B'C'</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'B'</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'C'</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'A'</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>
|
||||
Reference in New Issue
Block a user