Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

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.4.0] - 2026-04-30

### Added

- **Module 2 (lớp 8): Tam giác đồng dạng** — third interactive theorem demo, live at `/lop-8/tam-giac-dong-dang/`. MVP completes the autoplan plan's 3-module hero set.
- Slider-driven scale-similarity demo: a fixed △ABC on the left, a scaled △A′B′C′ on the right with vertices computed at runtime as `centroid_target + k · (vertex − centroid_ABC)` for `k ∈ [0.5, 2.0]`, step 0.05.
- Live readout table: 6 side lengths + 3 ratios (AB/A′B′, BC/B′C′, CA/C′A′). All 3 ratios stay equal as `k` varies — the killer-demo property of similarity in motion. Color-keyed and tick-marked per Decision D3 (1/2/3 ticks paired with the 3-color palette).
- The `k` value displays prominently above the slider in pair1 red. ARIA-labels on the slider so screen readers can drive the demo by keyboard.
- Page reuses `src/components/similarity-scale.ts` + `renderTicks` helper (extracted as a pattern from Module 1). No new geom-engine module needed — `triangle.sides()` and `vec.scale()` carry the math.
- Hub: lớp 8 card now links forward with "Khám phá" status. All three grade cards are now live.

### Changed

- The Astro build now extracts shared geom-engine chunks (`vec.js` ~441B, `triangle.js` ~288B) instead of inlining per-page scripts. Per-page script weight: ~2–3.6KB. Cached across modules after first visit.

### Notes

- Module 2's MVP picks the BONUS variant from the autoplan plan (slider-driven scale) over the harder free-vertex-drag form. Reasoning: scale slider gives the strongest "wow" moment in a single gesture and ships in one weekend; free-drag with AA/SAS/SSS đồng dạng detectors is a separate UI surface. Free-drag deferred to v0.0.5.0+ (P2 in TODOS.md).
- Angles are not displayed numerically but are pinned conceptually in the prose: "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". Numeric angle readouts arrive when the free-drag mode lands.

## [0.0.3.0] - 2026-04-30

### Added
Expand Down
16 changes: 9 additions & 7 deletions TODOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,15 @@ Tracked work, organized by component then priority (P0 highest → P4 lowest). S
**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)
## Module 2 — Lớp 8 Tam giác đồng dạng

- **Similarity ratio + AA/SAS/SSS-similar detectors**
**Priority:** P1
**What:** `src/geom-engine/similarity.ts`. Live ratio display via numeric text-node updates only (KaTeX template rendered at build, never re-parsed during drag).
- **Free-vertex-drag mode (AA / SAS / SSS đồng dạng detectors)**
**Priority:** P2
**What:** v0.0.4.0 ships scale-slider mode. Add a toggle to switch into free-drag mode where both triangles' vertices can be moved independently and the detector identifies which similarity case (if any) holds. Adds `src/geom-engine/similarity.ts`.

- **Scale slider clamped to [0.5, 2.0]**
**Priority:** P1
**What:** `min=0.5, max=2, step=0.05`. Cannot reach 0. Per Eng failure mode #3.
- **Numeric angle readouts on the canvas**
**Priority:** P3
**What:** Currently angles are pinned conceptually in prose. Add live numeric readouts (∠A, ∠B, ∠C and primes) so students see "the angles don't change" empirically.

## Testing & CI infrastructure (with first canvas module)

Expand Down Expand Up @@ -152,3 +152,5 @@ Tracked work, organized by component then priority (P0 highest → P4 lowest). S
- **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.
- **Scale slider clamped to [0.5, 2.0]** — `src/components/similarity-scale.ts` step 0.05. Cannot reach 0. **Completed:** v0.0.4.0 (2026-04-30)
- **Similarity ratio readout** — three ratio readouts (AB/A′B′, BC/B′C′, CA/C′A′) showing all-equal under scaling. **Completed:** v0.0.4.0 (2026-04-30) as part of Module 2.
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.0.3.0
0.0.4.0
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "try-gstack",
"version": "0.0.3.0",
"version": "0.0.4.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.",
Expand Down
208 changes: 208 additions & 0 deletions src/components/similarity-scale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { add, scale, sub, vec } from '~/geom-engine/vec';
import type { Vec2 } from '~/geom-engine/vec';
import { angleAtVertex } from '~/geom-engine/circle';
import { sides, triangle as makeTriangle } from '~/geom-engine/triangle';
import type { Triangle } from '~/geom-engine/triangle';

const VIEW_W = 400;
const VIEW_H = 300;

const PAIR1 = '#D7263D';
const PAIR2 = '#1B998B';
const PAIR3 = '#F46036';

// Scalene triangle; centroid at (101.67, 146.67) — close to (100, 147).
const A: Vec2 = vec(70, 110);
const B: Vec2 = vec(140, 130);
const C: Vec2 = vec(95, 200);
const CENTROID_ABC: Vec2 = vec(
(A.x + B.x + C.x) / 3,
(A.y + B.y + C.y) / 3,
);
const CENTROID_TARGET: Vec2 = vec(300, 145);

function scaledTriangle(k: number): Triangle {
const make = (p: Vec2): Vec2 => add(CENTROID_TARGET, scale(sub(p, CENTROID_ABC), k));
return makeTriangle(make(A), make(B), make(C));
}

interface Refs {
ap: SVGCircleElement;
bp: SVGCircleElement;
cp: SVGCircleElement;
apLabel: SVGTextElement;
bpLabel: SVGTextElement;
cpLabel: SVGTextElement;
apbp: SVGLineElement;
bpcp: SVGLineElement;
cpap: SVGLineElement;
// Tick groups for triangle 2
tickApBp: SVGGElement;
tickBpCp: SVGGElement;
tickCpAp: SVGGElement;
// k display
kReadout: HTMLElement;
kSlider: HTMLInputElement;
// Side-length & ratio readouts
apbpReadout: HTMLElement;
bpcpReadout: HTMLElement;
cpapReadout: HTMLElement;
ratioAB: HTMLElement;
ratioBC: HTMLElement;
ratioCA: HTMLElement;
}

const TICK_LEN = 6;
const TICK_SPACING = 5;

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, k: number) {
const t2 = scaledTriangle(k);

refs.ap.setAttribute('cx', t2.a.x.toFixed(2));
refs.ap.setAttribute('cy', t2.a.y.toFixed(2));
refs.bp.setAttribute('cx', t2.b.x.toFixed(2));
refs.bp.setAttribute('cy', t2.b.y.toFixed(2));
refs.cp.setAttribute('cx', t2.c.x.toFixed(2));
refs.cp.setAttribute('cy', t2.c.y.toFixed(2));

refs.apLabel.setAttribute('x', (t2.a.x - 16).toFixed(2));
refs.apLabel.setAttribute('y', (t2.a.y - 10).toFixed(2));
refs.bpLabel.setAttribute('x', (t2.b.x + 8).toFixed(2));
refs.bpLabel.setAttribute('y', (t2.b.y - 10).toFixed(2));
refs.cpLabel.setAttribute('x', (t2.c.x - 6).toFixed(2));
refs.cpLabel.setAttribute('y', (t2.c.y + 22).toFixed(2));

setLine(refs.apbp, t2.a, t2.b);
setLine(refs.bpcp, t2.b, t2.c);
setLine(refs.cpap, t2.c, t2.a);

renderTicks(refs.tickApBp, t2.a, t2.b, 1, PAIR1);
renderTicks(refs.tickBpCp, t2.b, t2.c, 2, PAIR2);
renderTicks(refs.tickCpAp, t2.c, t2.a, 3, PAIR3);

const s2 = sides(t2);
refs.apbpReadout.textContent = s2.ab.toFixed(1);
refs.bpcpReadout.textContent = s2.bc.toFixed(1);
refs.cpapReadout.textContent = s2.ca.toFixed(1);

// Ratios AB/A'B' = 1/k. Display ALL three to show they stay equal.
const ratio = 1 / k;
const ratioStr = ratio.toFixed(2);
refs.ratioAB.textContent = ratioStr;
refs.ratioBC.textContent = ratioStr;
refs.ratioCA.textContent = ratioStr;

refs.kReadout.textContent = k.toFixed(2);
}

function getRefs(svg: SVGSVGElement): Refs | null {
const q = <T extends Element>(sel: string, root: ParentNode = document): T | null =>
root.querySelector<T>(sel);

const ap = q<SVGCircleElement>('[data-vertex="ap"]', svg);
const bp = q<SVGCircleElement>('[data-vertex="bp"]', svg);
const cp = q<SVGCircleElement>('[data-vertex="cp"]', svg);
const apLabel = q<SVGTextElement>('[data-vertex-label="ap"]', svg);
const bpLabel = q<SVGTextElement>('[data-vertex-label="bp"]', svg);
const cpLabel = q<SVGTextElement>('[data-vertex-label="cp"]', svg);
const apbp = q<SVGLineElement>('[data-side="apbp"]', svg);
const bpcp = q<SVGLineElement>('[data-side="bpcp"]', svg);
const cpap = q<SVGLineElement>('[data-side="cpap"]', svg);
const tickApBp = q<SVGGElement>('[data-ticks="apbp"]', svg);
const tickBpCp = q<SVGGElement>('[data-ticks="bpcp"]', svg);
const tickCpAp = q<SVGGElement>('[data-ticks="cpap"]', svg);

const kReadout = q<HTMLElement>('[data-readout="k"]');
const kSlider = q<HTMLInputElement>('[data-control="k-slider"]');
const apbpReadout = q<HTMLElement>('[data-readout-side="apbp"]');
const bpcpReadout = q<HTMLElement>('[data-readout-side="bpcp"]');
const cpapReadout = q<HTMLElement>('[data-readout-side="cpap"]');
const ratioAB = q<HTMLElement>('[data-readout-ratio="ab"]');
const ratioBC = q<HTMLElement>('[data-readout-ratio="bc"]');
const ratioCA = q<HTMLElement>('[data-readout-ratio="ca"]');

if (
!ap || !bp || !cp || !apLabel || !bpLabel || !cpLabel ||
!apbp || !bpcp || !cpap ||
!tickApBp || !tickBpCp || !tickCpAp ||
!kReadout || !kSlider ||
!apbpReadout || !bpcpReadout || !cpapReadout ||
!ratioAB || !ratioBC || !ratioCA
) return null;

return {
ap, bp, cp, apLabel, bpLabel, cpLabel,
apbp, bpcp, cpap,
tickApBp, tickBpCp, tickCpAp,
kReadout, kSlider,
apbpReadout, bpcpReadout, cpapReadout,
ratioAB, ratioBC, ratioCA,
};
}

export function setupSimilarityScale(svgSelector: string) {
const svg = document.querySelector<SVGSVGElement>(svgSelector);
if (!svg) return;
const refs = getRefs(svg);
if (!refs) return;

const ctrl = new AbortController();
const opts = { signal: ctrl.signal } as AddEventListenerOptions;

// Static ticks for triangle 1 (ABC) — render once, never change.
const tickAB = svg.querySelector<SVGGElement>('[data-ticks="ab"]');
const tickBC = svg.querySelector<SVGGElement>('[data-ticks="bc"]');
const tickCA = svg.querySelector<SVGGElement>('[data-ticks="ca"]');
if (tickAB && tickBC && tickCA) {
renderTicks(tickAB, A, B, 1, PAIR1);
renderTicks(tickBC, B, C, 2, PAIR2);
renderTicks(tickCA, C, A, 3, PAIR3);
}

const initialK = parseFloat(refs.kSlider.value) || 1;
update(refs, initialK);

refs.kSlider.addEventListener(
'input',
() => {
const k = parseFloat(refs.kSlider.value);
if (Number.isFinite(k)) update(refs, k);
},
opts,
);

document.addEventListener('astro:before-swap', () => ctrl.abort(), { once: true });
}
29 changes: 26 additions & 3 deletions src/i18n/vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ export const vi = {
title: 'Lớp 8',
hero: 'Tam giác đồng dạng',
blurb:
'AA, SAS, SSS đồng dạng. Kéo để xem tỉ số cạnh giữ nguyên khi tam giác phóng to.',
status: 'sap-ra-mat',
href: null,
'Kéo thanh trượt phóng/thu — tỉ số ba cạnh tương ứng giữ nguyên, các góc cũng không đổi.',
status: 'live',
href: '/lop-8/tam-giac-dong-dang/',
},
'lop-9': {
title: 'Lớp 9',
Expand Down Expand Up @@ -74,6 +74,29 @@ export const vi = {
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',
},
module2: {
title: 'Tam giác đồng dạng',
grade: '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',
tabSide: 'Cặp cạnh',
tabT1: '△ABC',
tabT2: '△A′B′C′',
tabRatio: 'Tỉ số AB/A′B′',
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.',
backToHub: '← Về trang chủ',
nextTeaser: 'Sắp ra mắt: kéo từng đỉnh tự do (AA / SAS / SSS đồng dạng)',
},
} as const;

export type Locale = typeof vi;
Loading
Loading