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:
2026-04-30 00:03:34 +07:00
committed by GitHub
parent c5388349c0
commit bc3df68e5b
11 changed files with 427 additions and 19 deletions
+1
View File
@@ -31,3 +31,4 @@ bun-debug.log*
coverage/
playwright-report/
test-results/
.gstack/
+24
View File
@@ -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
+12 -4
View File
@@ -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
View File
@@ -1 +1 @@
0.0.1.0
0.0.2.0
+1 -1
View File
@@ -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.",
+91
View File
@@ -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 });
}
+109
View File
@@ -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);
});
});
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+97
View File
@@ -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>