fix(goc-noi-tiep): report the subtended arc, not the unsigned central angle

The readout showed the angle AOB, which does not change as M moves, so the
page contradicted its own theorem whenever M sat on the minor arc: the
inscribed angle read 120 degrees against a central angle of 120. It now
shows the measure of the arc that M does not lie on, so the inscribed angle
is half of it wherever M is. The inscribed angle is also marked undefined
when M reaches either end of the chord instead of reading zero.
This commit is contained in:
2026-09-15 10:06:57 +07:00
parent 27ec89fe89
commit 03c976cb5f
4 changed files with 108 additions and 9 deletions
+29
View File
@@ -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;
}
+44 -1
View File
@@ -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);
}
});
});
+5 -3
View File
@@ -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 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.',
+30 -5
View File
@@ -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 @@
<p class="text-slate-700 leading-relaxed">{m.intro}</p>
</header>
<section class="mb-2 flex items-baseline justify-between gap-3 text-sm tabular-nums" aria-live="polite">
<span><strong style="color:#D7263D">{m.inscribedLabel}:</strong> {inscribed.toFixed(1)}°</span>
<span><strong style="color:#1B998B">{m.centralLabel}:</strong> {central.toFixed(1)}°</span>
<section class="mb-2 text-sm tabular-nums" aria-live="polite">
{#if atEndpoint}
<p class="text-slate-500">{m.undefinedLabel}</p>
{:else}
<div class="flex items-baseline justify-between gap-3">
<span><strong style="color:#D7263D">{m.inscribedLabel}:</strong> {inscribed.toFixed(1)}°</span>
<span><strong style="color:#1B998B">{m.arcLabel}:</strong> {arc.toFixed(1)}°</span>
</div>
<p class="mt-1 text-slate-600">
{m.relationOk}: {inscribed.toFixed(1)}° = {arc.toFixed(1)}° / 2
</p>
{/if}
</section>
<section class="mb-6">