diff --git a/src/lib/geom-engine/circle.js b/src/lib/geom-engine/circle.js index 1749c87..0fe3120 100644 --- a/src/lib/geom-engine/circle.js +++ b/src/lib/geom-engine/circle.js @@ -42,3 +42,32 @@ export function angleAtVertex(a, vertex, b) { const cosTheta = Math.max(-1, Math.min(1, dot(va, vb) / (lenA * lenB))); return (Math.acos(cosTheta) * 180) / Math.PI; } + +/** + * Angle of `p` as seen from the circle's center, in degrees [0, 360). + * @param {Circle} c @param {Vec2} p @returns {number} + */ +export function angleOnCircle(c, p) { + const deg = (Math.atan2(p.y - c.center.y, p.x - c.center.x) * 180) / Math.PI; + return ((deg % 360) + 360) % 360; +} + +/** + * Measure in degrees of the arc AB that does **not** contain M — the arc an + * inscribed angle at M subtends. Ranges over (0, 360): it exceeds 180° when M + * sits on the minor arc, which is exactly the case where the inscribed angle + * is not half of the unsigned central angle ∠AOB. + * + * All three points are assumed to lie on `c`. + * + * @param {Circle} c @param {Vec2} a @param {Vec2} b @param {Vec2} m + * @returns {number} + */ +export function subtendedArc(c, a, b, m) { + const norm = (/** @type {number} */ d) => ((d % 360) + 360) % 360; + const angA = angleOnCircle(c, a); + const sweepToB = norm(angleOnCircle(c, b) - angA); + const sweepToM = norm(angleOnCircle(c, m) - angA); + // M inside the A→B sweep means the subtended arc is the complementary one. + return sweepToM > 0 && sweepToM < sweepToB ? 360 - sweepToB : sweepToB; +} diff --git a/src/lib/geom-engine/circle.test.js b/src/lib/geom-engine/circle.test.js index 9192d59..ae0d71d 100644 --- a/src/lib/geom-engine/circle.test.js +++ b/src/lib/geom-engine/circle.test.js @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { vec } from './vec.js'; -import { angleAtVertex, circle, pointOnCircle, projectToCircle } from './circle.js'; +import { angleAtVertex, circle, pointOnCircle, projectToCircle, subtendedArc } from './circle.js'; describe('pointOnCircle', () => { it('places 0° at +x', () => { @@ -59,3 +59,46 @@ describe('angleAtVertex', () => { expect(inscribed).toBeCloseTo(central / 2, 1); }); }); + +describe('subtendedArc', () => { + const c = circle(0, 0, 10); + const at = (/** @type {number} */ deg) => pointOnCircle(c, deg); + + it('M on the major arc subtends the minor arc', () => { + // A at 150°, B at 30° — the minor arc AB measures 120°. + expect(subtendedArc(c, at(150), at(30), at(270))).toBeCloseTo(120); + }); + + it('M on the minor arc subtends the major arc', () => { + for (const mDeg of [45, 90, 120]) { + expect(subtendedArc(c, at(150), at(30), at(mDeg))).toBeCloseTo(240); + } + }); + + it('swapping A and B gives the same arc', () => { + const arcAB = subtendedArc(c, at(150), at(30), at(270)); + const arcBA = subtendedArc(c, at(30), at(150), at(270)); + expect(arcBA).toBeCloseTo(arcAB); + }); + + it('the inscribed angle is half the subtended arc, everywhere on the circle', () => { + const a = at(150); + const b = at(30); + for (let mDeg = 0; mDeg < 360; mDeg += 7) { + const m = at(mDeg); + // Skip the two positions where the inscribed angle is undefined. + if (Math.abs(mDeg - 150) < 1e-9 || Math.abs(mDeg - 30) < 1e-9) continue; + expect(angleAtVertex(a, m, b)).toBeCloseTo(subtendedArc(c, a, b, m) / 2, 6); + } + }); + + it('holds for a second, asymmetric chord', () => { + const a = at(10); + const b = at(200); + for (let mDeg = 0; mDeg < 360; mDeg += 11) { + const m = at(mDeg); + if (Math.abs(mDeg - 10) < 1e-9 || Math.abs(mDeg - 200) < 1e-9) continue; + expect(angleAtVertex(a, m, b)).toBeCloseTo(subtendedArc(c, a, b, m) / 2, 6); + } + }); +}); diff --git a/src/lib/lessons/goc-noi-tiep/copy.vi.js b/src/lib/lessons/goc-noi-tiep/copy.vi.js index a740f9b..5148156 100644 --- a/src/lib/lessons/goc-noi-tiep/copy.vi.js +++ b/src/lib/lessons/goc-noi-tiep/copy.vi.js @@ -5,13 +5,15 @@ export const vi = { title: 'Góc nội tiếp', gradeLabel: '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.', + 'Đị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 số đo cung bị chắn. Hãy kéo điểm M trên đường tròn để tự kiểm chứng — chú ý số đo cung đổi khi M vượt qua A hoặc B.', instruction: 'Kéo điểm M (đỏ) quanh đường tròn — hoặc dùng phím mũi tên', inscribedLabel: 'Góc nội tiếp ∠AMB', - centralLabel: 'Góc ở tâm ∠AOB', + arcLabel: 'Cung bị chắn AB', + relationOk: 'Góc nội tiếp = nửa cung bị chắn', + undefinedLabel: 'Chưa xác định — M đang trùng với A hoặc B', 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.', + 'Trong một đường tròn, số đo của góc nội tiếp bằng nửa số đo của cung bị chắn. Khi M nằm trên cung lớn, cung bị chắn là cung nhỏ AB và góc nội tiếp bằng nửa góc ở tâm ∠AOB; khi M nằm trên cung nhỏ, cung bị chắn là cung lớn (> 180°).', 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.', diff --git a/src/routes/hinh-hoc/goc-noi-tiep/+page.svelte b/src/routes/hinh-hoc/goc-noi-tiep/+page.svelte index 128c025..94ba4cd 100644 --- a/src/routes/hinh-hoc/goc-noi-tiep/+page.svelte +++ b/src/routes/hinh-hoc/goc-noi-tiep/+page.svelte @@ -2,7 +2,14 @@ import { base } from '$app/paths'; import { t } from '$lib/i18n/index.js'; import { vi as m } from '$lib/lessons/goc-noi-tiep/copy.vi.js'; - import { circle, pointOnCircle, projectToCircle, angleAtVertex } from '$lib/geom-engine/circle.js'; + import { + circle, + pointOnCircle, + projectToCircle, + angleAtVertex, + subtendedArc, + } from '$lib/geom-engine/circle.js'; + import { dist, EPSILON_LEN } from '$lib/geom-engine/vec.js'; import { draggable } from '$lib/actions/draggable.svelte.js'; const copy = t(); @@ -16,7 +23,16 @@ let M = $state(pointOnCircle(C, 270)); const inscribed = $derived(angleAtVertex(A, M, B)); - const central = $derived(angleAtVertex(A, C.center, B)); + + // The arc AB that M does NOT sit on — the arc ∠AMB actually subtends. It is + // the major arc (> 180°) whenever M is on the minor arc, which is why the + // fixed central angle ∠AOB alone does not describe the relationship. + const arc = $derived(subtendedArc(C, A, B, M)); + + // ∠AMB degenerates when M reaches either endpoint of the chord. + const atEndpoint = $derived( + dist(M, A) < EPSILON_LEN || dist(M, B) < EPSILON_LEN + ); /** @param {{x: number, y: number}} p */ const projector = (p) => projectToCircle(p, C); @@ -52,9 +68,18 @@
{m.intro}
-{m.undefinedLabel}
+ {:else} ++ {m.relationOk}: {inscribed.toFixed(1)}° = {arc.toFixed(1)}° / 2 +
+ {/if}