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:
2026-04-30 00:17:26 +07:00
committed by GitHub
parent bc3df68e5b
commit 638af314ea
9 changed files with 490 additions and 12 deletions
+19
View File
@@ -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 ABC 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/AB in pair1 #D7263D with **1 tick**, BC/BC in pair2 #1B998B with **2 ticks**, CA/CA 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↔AB, 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
+9 -8
View File
@@ -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
View File
@@ -1 +1 @@
0.0.2.0
0.0.3.0
+1 -1
View File
@@ -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.",
+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à △ABC với AB = AB (cùng có một dấu gạch đỏ), BC = BC (cùng có hai dấu gạch xanh), CA = CA (cùng có ba dấu gạch cam). Theo trường hợp c.c.c, △ABC = △ABC — và do đó các góc tương ứng cũng bằng nhau. Hãy kéo các đỉnh để các cặp cạnh có cùng độ dài, huy hiệu 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;
+121
View File
@@ -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>