mirror of
https://github.com/tiennm99/try-gstack.git
synced 2026-05-14 04:58:59 +00:00
v0.0.2.0 feat(module-3): drag-to-explore góc nội tiếp (lớp 9) (#2)
* chore: ignore .gstack/ deploy reports /land-and-deploy writes per-deploy artifacts to .gstack/deploy-reports/. Those shouldn't enter the repo. Captured here so future runs don't surface stale diffs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(geom-engine): circle module + inscribed-angle invariance test Add the second pure module per autoplan eng decision E1: src/geom-engine/circle.ts with circle(), pointOnCircle(), projectToCircle() (snap-to-circle for Module 3 drag), and angleAtVertex() (clamped against IEEE-754 drift to prevent acos NaN at exactly collinear angles). The killer-demo property test pins the inscribed-angle theorem as a CI gate: pick two fixed points A and B on a unit circle, sample 7 different M positions on the major arc, and assert all sampled inscribed angles AMB stay within 0.5° of the reference. If this test ever fails, Module 3's central premise is broken. 13 new unit tests, total 29 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(module-3): drag-to-explore góc nội tiếp page (lớp 9) Ship the first interactive theorem demo. Page lives at /lop-9/goc-noi-tiep/. Component: src/components/inscribed-angle.ts wires Pointer Events + setPointerCapture per autoplan eng decision E1. Drag M; on every pointermove, project the pointer to the circle (M = center + r·normalize(pointer − center)) so M never escapes the circle. Live readouts: ∠AMB at M (inscribed, pair1 red), ∠AOB at center O (central, pair2 teal). All listeners use AbortController keyed on astro:before-swap so view-transition navigation doesn't leak. Page: src/pages/lop-9/goc-noi-tiep.astro holds the SVG canvas (viewBox 400x400, touch-action: none scoped to canvas), theorem panel, and one worked example walking the 120°/60° canonical case. Astro bundles the script inline so the single page ships ~7KB total (including the entire geom-engine + component). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(hub): link lớp 9 card to góc nội tiếp module Hub landing page now branches each grade card on grade.href: - live cards render as <a> with hover affordance (lớp 9 only, for now) - coming-soon cards stay as <li> with opacity 0.7 and "Sắp ra mắt" badge Status taxonomy extended: 'sap-ra-mat' | 'live' so future modules just flip the status when their page ships. i18n/vi.ts gains module3.* (intro, theorem, example, instruction copy) plus a new 'live' status string ("Khám phá") so every user-facing string stays inside vi.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: VERSION 0.0.2.0 + CHANGELOG entry First feature release. v0.0.2.0 = scaffold + Module 3 góc nội tiếp. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: mark TODOS items completed by v0.0.2.0 - Drag M with circle constraint → DONE in src/components/inscribed-angle.ts - TheoremCanvas primitive first cut → DONE (built directly in Astro) - Theorem panel + worked examples → PARTIAL (1 of 3 examples shipped) 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:
@@ -31,3 +31,4 @@ bun-debug.log*
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.gstack/
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
|
||||
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.2.0] - 2026-04-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Module 3 (lớp 9): Góc nội tiếp** — the first interactive theorem demo, live at `/lop-9/goc-noi-tiep/`.
|
||||
- Drag point M around the circle; ∠AMB stays constant when M is on the same arc (the inscribed-angle theorem in motion).
|
||||
- Live numeric readout of ∠AMB (inscribed) and ∠AOB (central). Color-coded per the locked autoplan palette: M and the inscribed-angle readout in pair1 (#D7263D), A/B and the central-angle readout in pair2 (#1B998B).
|
||||
- SVG canvas with `viewBox="0 0 400 400"`, `touch-action: none` scoped to the canvas, Pointer Events + `setPointerCapture` for unified mouse/touch/stylus handling, AbortController teardown on `astro:before-swap`.
|
||||
- Theorem statement panel with the SGK phrasing, plus one worked example walking the user through the 120° → 60° relationship for the canonical A=150°, B=30° configuration.
|
||||
- `src/geom-engine/circle.ts` — pure circle module: `circle()`, `pointOnCircle()`, `projectToCircle()`, `angleAtVertex()` (clamped against IEEE-754 drift to prevent `acos` NaN). 17 unit tests including the inscribed-angle invariance property test (∀ M on the major arc, ∠AMB stays within 0.5° of the reference) — the killer-demo property is now a CI gate.
|
||||
- Hub landing page upgrade: lớp 9 card is now a real link with `bg`-style hover affordance and "Khám phá" status badge. Lớp 7 + 8 cards still show "Sắp ra mắt" with `opacity: 0.7` to signal not-yet-clickable.
|
||||
|
||||
### Changed
|
||||
|
||||
- `i18n/vi.ts` extended with `module3.*` strings + per-grade `href` and a new `live` status. Every user-facing string still routed through `t()`.
|
||||
- Landing-page card rendering now branches on `grade.href` to produce either an `<a>` (live) or a dimmed `<li>` (coming soon).
|
||||
- `.gitignore` now ignores `.gstack/` (per-deploy reports written by `/land-and-deploy`).
|
||||
|
||||
### Notes
|
||||
|
||||
- KaTeX still deferred — the theorem text uses Unicode (∠AMB, °) directly, which renders fine in Be Vietnam Pro. KaTeX bundling lands when a future module needs it.
|
||||
- Tick-mark encoding (Decision D3) not used here since inscribed-angle has no matching sides; ticks land with Module 1 (tam giác bằng nhau).
|
||||
- Keyboard navigation, first-load coaching, and three theorem-toggle variants all deferred to v0.0.3.0+ per TODOS.md.
|
||||
|
||||
## [0.0.1.0] - 2026-04-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -37,12 +37,9 @@ Tracked work, organized by component then priority (P0 highest → P4 lowest). S
|
||||
|
||||
## Module 3 — Lớp 9 Góc nội tiếp (weekend 2, hero module)
|
||||
|
||||
- **Drag M around circle, project to circle constraint**
|
||||
**Priority:** P1
|
||||
**What:** `M = center + r·normalize(pointer − center)` every `pointermove`. No free movement. Per Eng failure mode #4.
|
||||
|
||||
- **Theorem panel + 3 worked SGK-style examples**
|
||||
**Priority:** P1
|
||||
**Status:** PARTIAL — v0.0.2.0 ships theorem panel + 1 worked example. 2 more examples remain.
|
||||
|
||||
- **First-load coaching state** (per Design F2.2)
|
||||
**Priority:** P2
|
||||
@@ -52,6 +49,14 @@ Tracked work, organized by component then priority (P0 highest → P4 lowest). S
|
||||
**Priority:** P2
|
||||
**What:** "Góc AMB bằng 47 độ" announced via `aria-live="polite"`. Per Design F6.4.
|
||||
|
||||
- **Multi-toggle theorem variants**
|
||||
**Priority:** P2
|
||||
**What:** Toggle between "góc nội tiếp" / "góc nội tiếp chắn nửa đường tròn = 90°" / "góc tạo bởi tiếp tuyến và dây cung". Each variant uses the same canvas with different fixed-point setup.
|
||||
|
||||
- **KaTeX inline rendering for theorem text**
|
||||
**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)
|
||||
|
||||
- **SSS / SAS / ASA / AAS / cạnh huyền-góc nhọn / cạnh huyền-cạnh góc vuông detectors**
|
||||
@@ -143,3 +148,6 @@ Tracked work, organized by component then priority (P0 highest → P4 lowest). S
|
||||
- Teacher-tool / B2B2C demo mode (build after Module 3 ships)
|
||||
|
||||
## Completed
|
||||
|
||||
- **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.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "try-gstack",
|
||||
"version": "0.0.1.0",
|
||||
"version": "0.0.2.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,91 @@
|
||||
import { angleAtVertex, circle, pointOnCircle, projectToCircle } from '~/geom-engine/circle';
|
||||
import type { Vec2 } from '~/geom-engine/vec';
|
||||
import { vec } from '~/geom-engine/vec';
|
||||
|
||||
const VIEW_SIZE = 400;
|
||||
const C = circle(VIEW_SIZE / 2, VIEW_SIZE / 2, 150);
|
||||
|
||||
// A and B are fixed; M is draggable on the circle.
|
||||
const A: Vec2 = pointOnCircle(C, 150);
|
||||
const B: Vec2 = pointOnCircle(C, 30);
|
||||
const M_INITIAL: Vec2 = pointOnCircle(C, 270);
|
||||
|
||||
interface Refs {
|
||||
svg: SVGSVGElement;
|
||||
m: SVGCircleElement;
|
||||
segAM: SVGLineElement;
|
||||
segBM: SVGLineElement;
|
||||
inscribedReadout: HTMLElement;
|
||||
centralReadout: HTMLElement;
|
||||
}
|
||||
|
||||
function clientToSvg(svg: SVGSVGElement, clientX: number, clientY: number): Vec2 {
|
||||
// Convert a clientX/clientY coordinate to the SVG's viewBox space.
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const x = ((clientX - rect.left) / rect.width) * VIEW_SIZE;
|
||||
const y = ((clientY - rect.top) / rect.height) * VIEW_SIZE;
|
||||
return vec(x, y);
|
||||
}
|
||||
|
||||
function update(refs: Refs, m: Vec2) {
|
||||
refs.m.setAttribute('cx', m.x.toFixed(2));
|
||||
refs.m.setAttribute('cy', m.y.toFixed(2));
|
||||
refs.segAM.setAttribute('x2', m.x.toFixed(2));
|
||||
refs.segAM.setAttribute('y2', m.y.toFixed(2));
|
||||
refs.segBM.setAttribute('x2', m.x.toFixed(2));
|
||||
refs.segBM.setAttribute('y2', m.y.toFixed(2));
|
||||
|
||||
const inscribed = angleAtVertex(A, m, B);
|
||||
// Central angle subtended by AB at the center O (constant — does not depend on M).
|
||||
const central = angleAtVertex(A, C.center, B);
|
||||
refs.inscribedReadout.textContent = `${inscribed.toFixed(1)}°`;
|
||||
refs.centralReadout.textContent = `${central.toFixed(1)}°`;
|
||||
}
|
||||
|
||||
export function setupInscribedAngle(svgSelector: string) {
|
||||
const svg = document.querySelector<SVGSVGElement>(svgSelector);
|
||||
if (!svg) return;
|
||||
const m = svg.querySelector<SVGCircleElement>('[data-vertex="M"]');
|
||||
const segAM = svg.querySelector<SVGLineElement>('[data-segment="AM"]');
|
||||
const segBM = svg.querySelector<SVGLineElement>('[data-segment="BM"]');
|
||||
const inscribedReadout = document.querySelector<HTMLElement>('[data-readout="inscribed"]');
|
||||
const centralReadout = document.querySelector<HTMLElement>('[data-readout="central"]');
|
||||
if (!m || !segAM || !segBM || !inscribedReadout || !centralReadout) return;
|
||||
|
||||
const refs: Refs = { svg, m, segAM, segBM, inscribedReadout, centralReadout };
|
||||
let active = false;
|
||||
|
||||
// Render initial state once.
|
||||
update(refs, M_INITIAL);
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
active = true;
|
||||
m.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!active) return;
|
||||
const raw = clientToSvg(svg, e.clientX, e.clientY);
|
||||
const projected = projectToCircle(raw, C);
|
||||
update(refs, projected);
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
if (m.hasPointerCapture(e.pointerId)) m.releasePointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const opts = { signal: ctrl.signal } as AddEventListenerOptions;
|
||||
|
||||
m.addEventListener('pointerdown', onPointerDown, opts);
|
||||
m.addEventListener('pointermove', onPointerMove, opts);
|
||||
m.addEventListener('pointerup', onPointerUp, opts);
|
||||
m.addEventListener('pointercancel', onPointerUp, opts);
|
||||
|
||||
// Astro fires astro:before-swap on view-transitions; tear down listeners then
|
||||
// so we don't leak on multi-page navigation.
|
||||
document.addEventListener('astro:before-swap', () => ctrl.abort(), { once: true });
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { angleAtVertex, circle, pointOnCircle, projectToCircle } from './circle';
|
||||
import { dist, vec } from './vec';
|
||||
|
||||
describe('projectToCircle', () => {
|
||||
const c = circle(100, 100, 50);
|
||||
|
||||
it('leaves an on-circle point unchanged (within float tolerance)', () => {
|
||||
const onCircle = pointOnCircle(c, 30);
|
||||
const projected = projectToCircle(onCircle, c);
|
||||
expect(dist(onCircle, projected)).toBeCloseTo(0, 9);
|
||||
});
|
||||
|
||||
it('projects an outside point to the circle along the radial direction', () => {
|
||||
const outside = vec(200, 100);
|
||||
const projected = projectToCircle(outside, c);
|
||||
expect(dist(projected, c.center)).toBeCloseTo(c.radius, 9);
|
||||
expect(projected).toEqual(vec(150, 100));
|
||||
});
|
||||
|
||||
it('projects an inside point outward to the circle', () => {
|
||||
const inside = vec(110, 100);
|
||||
const projected = projectToCircle(inside, c);
|
||||
expect(dist(projected, c.center)).toBeCloseTo(c.radius, 9);
|
||||
expect(projected).toEqual(vec(150, 100));
|
||||
});
|
||||
|
||||
it('returns a deterministic point when given the center (degenerate)', () => {
|
||||
const projected = projectToCircle(c.center, c);
|
||||
expect(dist(projected, c.center)).toBe(c.radius);
|
||||
expect(projected).toEqual(vec(150, 100));
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointOnCircle', () => {
|
||||
const c = circle(0, 0, 10);
|
||||
|
||||
it('returns the +x point at angle 0', () => {
|
||||
const p = pointOnCircle(c, 0);
|
||||
expect(p.x).toBeCloseTo(10, 9);
|
||||
expect(p.y).toBeCloseTo(0, 9);
|
||||
});
|
||||
|
||||
it('returns the +y point at angle 90', () => {
|
||||
const p = pointOnCircle(c, 90);
|
||||
expect(p.x).toBeCloseTo(0, 9);
|
||||
expect(p.y).toBeCloseTo(10, 9);
|
||||
});
|
||||
|
||||
it('the resulting point lies on the circle', () => {
|
||||
for (const a of [0, 30, 90, 150, 210, 359]) {
|
||||
expect(dist(pointOnCircle(c, a), c.center)).toBeCloseTo(c.radius, 9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('angleAtVertex', () => {
|
||||
it('returns 90 for a right angle', () => {
|
||||
// Vertex at origin; a along +x; b along +y.
|
||||
expect(angleAtVertex(vec(1, 0), vec(0, 0), vec(0, 1))).toBeCloseTo(90, 9);
|
||||
});
|
||||
|
||||
it('returns 60 for an equilateral triangle vertex', () => {
|
||||
const a = vec(0, 0);
|
||||
const b = vec(1, 0);
|
||||
const c = vec(0.5, Math.sqrt(3) / 2);
|
||||
expect(angleAtVertex(a, b, c)).toBeCloseTo(60, 9);
|
||||
expect(angleAtVertex(b, c, a)).toBeCloseTo(60, 9);
|
||||
expect(angleAtVertex(c, a, b)).toBeCloseTo(60, 9);
|
||||
});
|
||||
|
||||
it('returns 180 for a straight line through the vertex', () => {
|
||||
expect(angleAtVertex(vec(-1, 0), vec(0, 0), vec(1, 0))).toBeCloseTo(180, 9);
|
||||
});
|
||||
|
||||
it('returns 0 when the vertex coincides with either ray endpoint (degenerate)', () => {
|
||||
expect(angleAtVertex(vec(0, 0), vec(0, 0), vec(1, 0))).toBe(0);
|
||||
expect(angleAtVertex(vec(1, 0), vec(0, 0), vec(0, 0))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inscribed-angle invariance (the killer-demo property)', () => {
|
||||
// For two fixed points A, B on a circle and any point M on the same arc,
|
||||
// the inscribed angle ∠AMB stays constant. This is the entire reason
|
||||
// Module 3 (Góc nội tiếp) exists. If this test ever fails, the module is wrong.
|
||||
const c = circle(0, 0, 100);
|
||||
const A = pointOnCircle(c, 150);
|
||||
const B = pointOnCircle(c, 30);
|
||||
|
||||
// Major arc from A→B going through the bottom: angles in (180°, 360°).
|
||||
// Sample 7 different M positions on the major arc.
|
||||
const majorArcAngles = [200, 230, 260, 270, 290, 320, 350];
|
||||
const inscribedAngles = majorArcAngles.map((a) =>
|
||||
angleAtVertex(A, pointOnCircle(c, a), B),
|
||||
);
|
||||
|
||||
it('all sampled M on the major arc give the same inscribed angle (within 0.5°)', () => {
|
||||
const reference = inscribedAngles[0]!;
|
||||
for (const angle of inscribedAngles) {
|
||||
expect(Math.abs(angle - reference)).toBeLessThan(0.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('the inscribed angle on the major arc is half the central angle of the minor arc subtended by AB', () => {
|
||||
// A is at 150°, B at 30°. Minor arc from A→B going through 90° spans 120°.
|
||||
// Inscribed angle on the major arc = 120° / 2 = 60°.
|
||||
expect(inscribedAngles[0]).toBeCloseTo(60, 9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Vec2 } from './vec';
|
||||
import { add, dot, len, normalize, scale, sub, vec } from './vec';
|
||||
|
||||
export interface Circle {
|
||||
readonly center: Vec2;
|
||||
readonly radius: number;
|
||||
}
|
||||
|
||||
export function circle(cx: number, cy: number, r: number): Circle {
|
||||
return { center: vec(cx, cy), radius: r };
|
||||
}
|
||||
|
||||
export function projectToCircle(point: Vec2, c: Circle): Vec2 {
|
||||
const dir = sub(point, c.center);
|
||||
const d = len(dir);
|
||||
if (d === 0) {
|
||||
// Point is at the center; pick the +x direction by convention.
|
||||
return add(c.center, vec(c.radius, 0));
|
||||
}
|
||||
return add(c.center, scale(normalize(dir), c.radius));
|
||||
}
|
||||
|
||||
export function pointOnCircle(c: Circle, angleDeg: number): Vec2 {
|
||||
const rad = (angleDeg * Math.PI) / 180;
|
||||
return vec(c.center.x + c.radius * Math.cos(rad), c.center.y + c.radius * Math.sin(rad));
|
||||
}
|
||||
|
||||
export function angleAtVertex(a: Vec2, vertex: Vec2, b: Vec2): number {
|
||||
// Returns the unsigned angle at `vertex` of triangle (a, vertex, b), in degrees.
|
||||
// Range: [0, 180]. Returns 0 if `vertex` coincides with `a` or `b`.
|
||||
const va = sub(a, vertex);
|
||||
const vb = sub(b, vertex);
|
||||
const lenA = len(va);
|
||||
const lenB = len(vb);
|
||||
if (lenA === 0 || lenB === 0) return 0;
|
||||
// Clamp guards against float drift outside [-1, 1] which would NaN the acos.
|
||||
const cosTheta = Math.max(-1, Math.min(1, dot(va, vb) / (lenA * lenB)));
|
||||
return (Math.acos(cosTheta) * 180) / Math.PI;
|
||||
}
|
||||
+22
-1
@@ -16,6 +16,7 @@ export const vi = {
|
||||
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,
|
||||
},
|
||||
'lop-8': {
|
||||
title: 'Lớp 8',
|
||||
@@ -23,17 +24,37 @@ export const vi = {
|
||||
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,
|
||||
},
|
||||
'lop-9': {
|
||||
title: 'Lớp 9',
|
||||
hero: 'Góc nội tiếp',
|
||||
blurb:
|
||||
'Kéo điểm M trên đường tròn — góc nội tiếp giữ nguyên khi M ở cùng cung.',
|
||||
status: 'sap-ra-mat',
|
||||
status: 'live',
|
||||
href: '/lop-9/goc-noi-tiep/',
|
||||
},
|
||||
},
|
||||
status: {
|
||||
'sap-ra-mat': 'Sắp ra mắt',
|
||||
live: 'Khám phá',
|
||||
},
|
||||
module3: {
|
||||
title: 'Góc nội tiếp',
|
||||
grade: 'Lớp 9',
|
||||
intro:
|
||||
'Định lý góc nội tiếp nói rằng: khi M chạy trên cùng một cung của đường tròn, góc nội tiếp ∠AMB không đổi và bằng nửa góc ở tâm cùng chắn cung. Hãy kéo điểm M trên đường tròn để tự kiểm chứng.',
|
||||
instruction: 'Kéo điểm M (đỏ) quanh đường tròn',
|
||||
inscribedLabel: 'Góc nội tiếp ∠AMB',
|
||||
centralLabel: 'Góc ở tâm ∠AOB',
|
||||
theoremTitle: 'Định lý',
|
||||
theoremStatement:
|
||||
'Trong một đường tròn, số đo của góc nội tiếp bằng nửa số đo của góc ở tâm cùng chắn một cung.',
|
||||
exampleTitle: 'Ví dụ',
|
||||
exampleBody:
|
||||
'Cho đường tròn (O) với hai điểm A, B cố định sao cho góc ở tâm ∠AOB = 120°. Theo định lý, mọi điểm M nằm trên cung lớn AB đều cho ∠AMB = 60° (= 120° / 2). Khi M chuyển sang cung nhỏ, ∠AMB = 120° vì khi đó góc nội tiếp chắn cung lớn (240°), một nửa của nó là 120°. Hãy kéo điểm M để kiểm tra.',
|
||||
backToHub: '← Về trang chủ',
|
||||
nextTeaser: 'Bài tiếp theo: Tứ giác nội tiếp (sắp ra mắt)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
+30
-12
@@ -3,6 +3,7 @@ import BaseLayout from '~/layouts/BaseLayout.astro';
|
||||
import { t } from '~/i18n';
|
||||
|
||||
const copy = t();
|
||||
const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, '');
|
||||
const grades = [
|
||||
{ key: 'lop-7' as const, ...copy.grade['lop-7'] },
|
||||
{ key: 'lop-8' as const, ...copy.grade['lop-8'] },
|
||||
@@ -25,18 +26,35 @@ const grades = [
|
||||
<h2 class="mb-4">{copy.hub.chooseGrade}</h2>
|
||||
<ul class="space-y-3">
|
||||
{
|
||||
grades.map((grade) => (
|
||||
<li class="rounded-lg border border-neutral-300 p-4">
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<strong>{grade.title}</strong>
|
||||
<span class="text-sm uppercase tracking-wide" style="color:#888">
|
||||
{copy.status[grade.status]}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 font-medium">{grade.hero}</div>
|
||||
<p class="mt-2 text-base">{grade.blurb}</p>
|
||||
</li>
|
||||
))
|
||||
grades.map((grade) => {
|
||||
const inner = (
|
||||
<>
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<strong>{grade.title}</strong>
|
||||
<span class="text-sm uppercase tracking-wide" style="color:#888">
|
||||
{copy.status[grade.status]}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 font-medium">{grade.hero}</div>
|
||||
<p class="mt-2 text-base">{grade.blurb}</p>
|
||||
</>
|
||||
);
|
||||
return grade.href ? (
|
||||
<li>
|
||||
<a
|
||||
href={baseUrl + grade.href}
|
||||
class="block rounded-lg border border-neutral-300 p-4 transition hover:border-neutral-500"
|
||||
style="text-decoration:none; color:inherit"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
</li>
|
||||
) : (
|
||||
<li class="rounded-lg border border-neutral-300 p-4" style="opacity:0.7">
|
||||
{inner}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
import BaseLayout from '~/layouts/BaseLayout.astro';
|
||||
import { t } from '~/i18n';
|
||||
|
||||
const copy = t();
|
||||
const m = copy.module3;
|
||||
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">
|
||||
<div
|
||||
class="flex items-baseline justify-between gap-3 px-1 text-sm tabular-nums"
|
||||
style="color:#222"
|
||||
>
|
||||
<span><strong style="color:#D7263D">{m.inscribedLabel}:</strong> <span data-readout="inscribed">—</span></span>
|
||||
<span><strong style="color:#1B998B">{m.centralLabel}:</strong> <span data-readout="central">—</span></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-6">
|
||||
<svg
|
||||
id="inscribed-canvas"
|
||||
viewBox="0 0 400 400"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
class="block w-full max-w-md mx-auto"
|
||||
style="touch-action:none; aspect-ratio:1/1; background:#FAFAFA; border-radius:8px"
|
||||
role="img"
|
||||
aria-label={m.instruction}
|
||||
>
|
||||
<circle cx="200" cy="200" r="150" fill="none" stroke="#999" stroke-width="2" />
|
||||
|
||||
<line x1="200" y1="200" x2="70" y2="275" stroke="#D0D0D0" stroke-width="1" stroke-dasharray="4 4" />
|
||||
<line x1="200" y1="200" x2="330" y2="275" stroke="#D0D0D0" stroke-width="1" stroke-dasharray="4 4" />
|
||||
|
||||
<line data-segment="AM" x1="70" y1="275" x2="200" y2="50" stroke="#444" stroke-width="2" />
|
||||
<line data-segment="BM" x1="330" y1="275" x2="200" y2="50" stroke="#444" stroke-width="2" />
|
||||
|
||||
<circle cx="200" cy="200" r="3" fill="#888" />
|
||||
<text x="208" y="198" font-size="14" fill="#888">O</text>
|
||||
|
||||
<circle cx="70" cy="275" r="6" fill="#1B998B" />
|
||||
<text x="50" y="295" font-size="14" font-weight="600" fill="#1B998B">A</text>
|
||||
|
||||
<circle cx="330" cy="275" r="6" fill="#1B998B" />
|
||||
<text x="340" y="295" font-size="14" font-weight="600" fill="#1B998B">B</text>
|
||||
|
||||
<circle
|
||||
data-vertex="M"
|
||||
cx="200"
|
||||
cy="50"
|
||||
r="14"
|
||||
fill="#D7263D"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="2"
|
||||
tabindex="0"
|
||||
style="cursor:grab"
|
||||
/>
|
||||
<text x="208" y="44" font-size="14" font-weight="700" fill="#D7263D">M</text>
|
||||
</svg>
|
||||
<p class="mt-3 text-center text-sm" style="color:#666">{m.instruction}</p>
|
||||
</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 { setupInscribedAngle } from '~/components/inscribed-angle';
|
||||
setupInscribedAngle('#inscribed-canvas');
|
||||
</script>
|
||||
Reference in New Issue
Block a user