From 9bb1f77c39fb7a601ec95cce631248335f3a69f3 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 30 Apr 2026 00:23:22 +0700 Subject: [PATCH 1/3] feat(component): similarity-scale slider controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-slider similarity demo for Module 2. Fixed △ABC, scaled △A'B'C' computed each frame as `centroid_target + k · (vertex − centroid_ABC)` for k ∈ [0.5, 2]. The slider's input event is the only event listener — no Pointer Events here (we're not dragging vertices in this MVP). AbortController teardown on astro:before-swap as before. The renderTicks helper is duplicated from congruence-sss.ts for now — when Module 4 lands we'll factor it out into a shared module. Premature abstraction would force a circular import structure for one helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/similarity-scale.ts | 208 +++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 src/components/similarity-scale.ts diff --git a/src/components/similarity-scale.ts b/src/components/similarity-scale.ts new file mode 100644 index 0000000..1d5d1ce --- /dev/null +++ b/src/components/similarity-scale.ts @@ -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 = (sel: string, root: ParentNode = document): T | null => + root.querySelector(sel); + + const ap = q('[data-vertex="ap"]', svg); + const bp = q('[data-vertex="bp"]', svg); + const cp = q('[data-vertex="cp"]', svg); + const apLabel = q('[data-vertex-label="ap"]', svg); + const bpLabel = q('[data-vertex-label="bp"]', svg); + const cpLabel = q('[data-vertex-label="cp"]', svg); + const apbp = q('[data-side="apbp"]', svg); + const bpcp = q('[data-side="bpcp"]', svg); + const cpap = q('[data-side="cpap"]', svg); + const tickApBp = q('[data-ticks="apbp"]', svg); + const tickBpCp = q('[data-ticks="bpcp"]', svg); + const tickCpAp = q('[data-ticks="cpap"]', svg); + + const kReadout = q('[data-readout="k"]'); + const kSlider = q('[data-control="k-slider"]'); + const apbpReadout = q('[data-readout-side="apbp"]'); + const bpcpReadout = q('[data-readout-side="bpcp"]'); + const cpapReadout = q('[data-readout-side="cpap"]'); + const ratioAB = q('[data-readout-ratio="ab"]'); + const ratioBC = q('[data-readout-ratio="bc"]'); + const ratioCA = q('[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(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('[data-ticks="ab"]'); + const tickBC = svg.querySelector('[data-ticks="bc"]'); + const tickCA = svg.querySelector('[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 }); +} From 18dd339973c42145e6aa6a612f355d9cb517978d Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 30 Apr 2026 00:23:22 +0700 Subject: [PATCH 2/3] =?UTF-8?q?feat(module-2):=20tam=20gi=C3=A1c=20=C4=91?= =?UTF-8?q?=E1=BB=93ng=20d=E1=BA=A1ng=20(l=E1=BB=9Bp=208)=20with=20scale?= =?UTF-8?q?=20slider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page at /lop-8/tam-giac-dong-dang/. △ABC fixed on the left at scalene scale-72.8 / 83.2 / 93.4 sides. △A'B'C' is the runtime scaled image, controlled by a single range input k ∈ [0.5, 2] step 0.05. Live readout table shows all six side lengths and all three ratios. The killer moment: drag the slider, watch all 3 ratios stay numerically equal to each other (= 1/k). That equality IS the definition of similarity, made visible. Tick marks per Decision D3: 1/2/3 ticks color-paired (#D7263D / #1B998B / #F46036). Triangle 1 ticks rendered once on mount; triangle 2 ticks redrawn on every slider input. Hub: lớp 8 card flips to live status. All 3 grade cards now active — the autoplan 3-module hero set is complete. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/i18n/vi.ts | 29 ++++- src/pages/lop-8/tam-giac-dong-dang.astro | 141 +++++++++++++++++++++++ 2 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 src/pages/lop-8/tam-giac-dong-dang.astro diff --git a/src/i18n/vi.ts b/src/i18n/vi.ts index 3ccd787..1df9c9d 100644 --- a/src/i18n/vi.ts +++ b/src/i18n/vi.ts @@ -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', @@ -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; diff --git a/src/pages/lop-8/tam-giac-dong-dang.astro b/src/pages/lop-8/tam-giac-dong-dang.astro new file mode 100644 index 0000000..e88ed52 --- /dev/null +++ b/src/pages/lop-8/tam-giac-dong-dang.astro @@ -0,0 +1,141 @@ +--- +import BaseLayout from '~/layouts/BaseLayout.astro'; +import { t } from '~/i18n'; + +const copy = t(); +const m = copy.module2; +const baseUrl = import.meta.env.BASE_URL; +--- + + +
+ + +
+
{m.grade}
+

{m.title}

+

{m.intro}

+
+ +
+ + +
+ +
+ + + + + + + + + + + + + + + + + A + B + C + + + + + + A' + B' + C' + + + + + +
+ +
+

{m.sidesTitle}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{m.tabSide}{m.tabT1}{m.tabT2}{m.tabRatio}
| AB / A'B'72.8
| | BC / B'C'83.2
| | | CA / C'A'93.4
+

{m.anglesNote}

+
+ +
+

{m.theoremTitle}

+

{m.theoremStatement}

+
+ +
+

{m.exampleTitle}

+

{m.exampleBody}

+
+ +
{m.nextTeaser}
+
+
+ + From f9ea4fd0db20a99c401aa2fd3755d9d0401f51e1 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 30 Apr 2026 00:23:22 +0700 Subject: [PATCH 3/3] chore: VERSION 0.0.4.0 + CHANGELOG + TODOS update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.0.4.0 = scaffold + Module 3 (góc nội tiếp) + Module 1 (tam giác bằng nhau) + Module 2 (tam giác đồng dạng). All three MVP modules from autoplan now live. Move 2 P1 items to Completed (scale slider, ratio readout) and reduce Module 2 remaining items to P2 (free-drag mode) + P3 (numeric angle readouts). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 20 ++++++++++++++++++++ TODOS.md | 16 +++++++++------- VERSION | 2 +- package.json | 2 +- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f05e6ad..54b4f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TODOS.md b/TODOS.md index e1caf21..5cef696 100644 --- a/TODOS.md +++ b/TODOS.md @@ -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) @@ -152,3 +152,5 @@ Tracked work, organized by component then priority (P0 highest → P4 lowest). S - **TheoremCanvas primitive first cut** — vanilla TS in Astro `