refactor: migrate quiz-project, gstack, and superpowers to JS+JSDoc

Convert all 41 TypeScript source/config files in cc4e-course/quiz-project
(Next.js), gstack (Astro), and superpowers (Vite/React/Phaser) to plain
JavaScript with JSDoc type annotations, replacing tsconfig.json with
jsconfig.json (strict + checkJs) in each subproject. No behavior changes:
lint, typecheck, test, and build gates match their TypeScript baselines
exactly in all three subprojects. Update stack references in the affected
READMEs accordingly.
This commit is contained in:
tiennm99 committed 2026-08-18 09:49:42 +07:00
1 parent 364efd422b
commit 51a4b90163
60 files changed
+1115 -728

No files matched your search

@@ -1,11 +1,12 @@
import { vec } from '~/geom-engine/vec';
import type { Vec2 } from '~/geom-engine/vec';
import {
congruentSSS,
sides,
triangle as makeTriangle,
} from '~/geom-engine/triangle';
/** @typedef {import('~/geom-engine/vec').Vec2} Vec2 */
const VIEW_W = 400;
const VIEW_H = 300;
const TICK_LEN = 6;
@@ -15,11 +16,15 @@ const PAIR1 = '#D7263D';
const PAIR2 = '#1B998B';
const PAIR3 = '#F46036';
type VertexId = 'a' | 'b' | 'c' | 'ap' | 'bp' | 'cp';
/** @typedef {'a' | 'b' | 'c' | 'ap' | 'bp' | 'cp'} VertexId */
const VERTEX_IDS: readonly VertexId[] = ['a', 'b', 'c', 'ap', 'bp', 'cp'];
/** @typedef {'ab' | 'bc' | 'ca' | 'apbp' | 'bpcp' | 'cpap'} SideKey */
const INITIAL: Record<VertexId, Vec2> = {
/** @type {readonly VertexId[]} */
const VERTEX_IDS = ['a', 'b', 'c', 'ap', 'bp', 'cp'];
/** @type {Record<VertexId, Vec2>} */
const INITIAL = {
a: vec(60, 80),
b: vec(180, 80),
c: vec(120, 220),
@@ -28,36 +33,60 @@ const INITIAL: Record<VertexId, Vec2> = {
cp: vec(280, 220),
};
interface Refs {
svg: SVGSVGElement;
vertices: Record<VertexId, SVGCircleElement>;
labels: Record<VertexId, SVGTextElement>;
sides: Record<'ab' | 'bc' | 'ca' | 'apbp' | 'bpcp' | 'cpap', SVGLineElement>;
ticks: Record<'ab' | 'bc' | 'ca' | 'apbp' | 'bpcp' | 'cpap', SVGGElement>;
readouts: Record<'ab' | 'bc' | 'ca' | 'apbp' | 'bpcp' | 'cpap', HTMLElement>;
badge: HTMLElement;
}
/**
* @typedef {object} Refs
* @property {SVGSVGElement} svg
* @property {Record<VertexId, SVGCircleElement>} vertices
* @property {Record<VertexId, SVGTextElement>} labels
* @property {Record<SideKey, SVGLineElement>} sides
* @property {Record<SideKey, SVGGElement>} ticks
* @property {Record<SideKey, HTMLElement>} readouts
* @property {HTMLElement} badge
*/
function clientToSvg(svg: SVGSVGElement, x: number, y: number): Vec2 {
/**
* @param {SVGSVGElement} svg
* @param {number} x
* @param {number} y
* @returns {Vec2}
*/
function clientToSvg(svg, x, y) {
const r = svg.getBoundingClientRect();
return vec(((x - r.left) / r.width) * VIEW_W, ((y - r.top) / r.height) * VIEW_H);
}
function clamp(v: Vec2, pad = 16): Vec2 {
/**
* @param {Vec2} v
* @param {number} [pad]
* @returns {Vec2}
*/
function clamp(v, pad = 16) {
return vec(
Math.max(pad, Math.min(VIEW_W - pad, v.x)),
Math.max(pad, Math.min(VIEW_H - pad, v.y)),
);
}
function setLine(line: SVGLineElement, p1: Vec2, p2: Vec2) {
/**
* @param {SVGLineElement} line
* @param {Vec2} p1
* @param {Vec2} p2
*/
function setLine(line, p1, p2) {
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) {
/**
* @param {SVGGElement} group
* @param {Vec2} p1
* @param {Vec2} p2
* @param {1 | 2 | 3} count
* @param {string} color
*/
function renderTicks(group, p1, p2, count, color) {
while (group.firstChild) group.removeChild(group.firstChild);
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
@@ -84,7 +113,11 @@ function renderTicks(group: SVGGElement, p1: Vec2, p2: Vec2, count: 1 | 2 | 3, c
}
}
function update(refs: Refs, state: Record<VertexId, Vec2>) {
/**
* @param {Refs} refs
* @param {Record<VertexId, Vec2>} state
*/
function update(refs, state) {
for (const id of VERTEX_IDS) {
const v = state[id];
refs.vertices[id].setAttribute('cx', v.x.toFixed(2));
@@ -126,64 +159,82 @@ function update(refs: Refs, state: Record<VertexId, Vec2>) {
refs.badge.style.display = congruent ? 'inline-block' : 'none';
}
function getRefs(svg: SVGSVGElement): Refs | null {
const vertices: Partial<Record<VertexId, SVGCircleElement>> = {};
const labels: Partial<Record<VertexId, SVGTextElement>> = {};
/**
* @param {SVGSVGElement} svg
* @returns {Refs | null}
*/
function getRefs(svg) {
/** @type {Partial<Record<VertexId, SVGCircleElement>>} */
const vertices = {};
/** @type {Partial<Record<VertexId, SVGTextElement>>} */
const labels = {};
for (const id of VERTEX_IDS) {
const v = svg.querySelector<SVGCircleElement>(`[data-vertex="${id}"]`);
const l = svg.querySelector<SVGTextElement>(`[data-vertex-label="${id}"]`);
const v = /** @type {SVGCircleElement | null} */ (svg.querySelector(`[data-vertex="${id}"]`));
const l = /** @type {SVGTextElement | null} */ (
svg.querySelector(`[data-vertex-label="${id}"]`)
);
if (!v || !l) return null;
vertices[id] = v;
labels[id] = l;
}
const sideKeys = ['ab', 'bc', 'ca', 'apbp', 'bpcp', 'cpap'] as const;
type SideKey = (typeof sideKeys)[number];
const sides: Partial<Record<SideKey, SVGLineElement>> = {};
const ticks: Partial<Record<SideKey, SVGGElement>> = {};
const readouts: Partial<Record<SideKey, HTMLElement>> = {};
/** @type {readonly SideKey[]} */
const sideKeys = ['ab', 'bc', 'ca', 'apbp', 'bpcp', 'cpap'];
/** @type {Partial<Record<SideKey, SVGLineElement>>} */
const sides = {};
/** @type {Partial<Record<SideKey, SVGGElement>>} */
const ticks = {};
/** @type {Partial<Record<SideKey, HTMLElement>>} */
const readouts = {};
for (const key of sideKeys) {
const s = svg.querySelector<SVGLineElement>(`[data-side="${key}"]`);
const t = svg.querySelector<SVGGElement>(`[data-ticks="${key}"]`);
const r = document.querySelector<HTMLElement>(`[data-readout-side="${key}"]`);
const s = /** @type {SVGLineElement | null} */ (svg.querySelector(`[data-side="${key}"]`));
const t = /** @type {SVGGElement | null} */ (svg.querySelector(`[data-ticks="${key}"]`));
const r = /** @type {HTMLElement | null} */ (
document.querySelector(`[data-readout-side="${key}"]`)
);
if (!s || !t || !r) return null;
sides[key] = s;
ticks[key] = t;
readouts[key] = r;
}
const badge = document.querySelector<HTMLElement>('[data-badge="congruent"]');
const badge = /** @type {HTMLElement | null} */ (
document.querySelector('[data-badge="congruent"]')
);
if (!badge) return null;
return {
svg,
vertices: vertices as Record<VertexId, SVGCircleElement>,
labels: labels as Record<VertexId, SVGTextElement>,
sides: sides as Record<SideKey, SVGLineElement>,
ticks: ticks as Record<SideKey, SVGGElement>,
readouts: readouts as Record<SideKey, HTMLElement>,
vertices: /** @type {Record<VertexId, SVGCircleElement>} */ (vertices),
labels: /** @type {Record<VertexId, SVGTextElement>} */ (labels),
sides: /** @type {Record<SideKey, SVGLineElement>} */ (sides),
ticks: /** @type {Record<SideKey, SVGGElement>} */ (ticks),
readouts: /** @type {Record<SideKey, HTMLElement>} */ (readouts),
badge,
};
}
export function setupCongruenceSSS(svgSelector: string) {
const svg = document.querySelector<SVGSVGElement>(svgSelector);
/** @param {string} svgSelector */
export function setupCongruenceSSS(svgSelector) {
const svg = /** @type {SVGSVGElement | null} */ (document.querySelector(svgSelector));
if (!svg) return;
const refs = getRefs(svg);
if (!refs) return;
const state: Record<VertexId, Vec2> = { ...INITIAL };
/** @type {Record<VertexId, Vec2>} */
const state = { ...INITIAL };
update(refs, state);
let active: { id: number; vertex: VertexId } | null = null;
/** @type {{ id: number, vertex: VertexId } | null} */
let active = null;
const ctrl = new AbortController();
const opts = { signal: ctrl.signal } as AddEventListenerOptions;
const opts = /** @type {AddEventListenerOptions} */ ({ signal: ctrl.signal });
for (const id of VERTEX_IDS) {
const el = refs.vertices[id];
el.addEventListener(
'pointerdown',
(e: PointerEvent) => {
(e) => {
active = { id: e.pointerId, vertex: id };
el.setPointerCapture(e.pointerId);
e.preventDefault();
@@ -192,7 +243,7 @@ export function setupCongruenceSSS(svgSelector: string) {
);
el.addEventListener(
'pointermove',
(e: PointerEvent) => {
(e) => {
if (!active || active.id !== e.pointerId) return;
const raw = clientToSvg(svg, e.clientX, e.clientY);
state[active.vertex] = clamp(raw);
@@ -200,7 +251,8 @@ export function setupCongruenceSSS(svgSelector: string) {
},
opts,
);
const release = (e: PointerEvent) => {
/** @param {PointerEvent} e */
const release = (e) => {
if (!active || active.id !== e.pointerId) return;
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
active = null;
@@ -1,33 +1,48 @@
import { angleAtVertex, circle, pointOnCircle, projectToCircle } from '~/geom-engine/circle';
import type { Vec2 } from '~/geom-engine/vec';
import { vec } from '~/geom-engine/vec';
/** @typedef {import('~/geom-engine/vec').Vec2} Vec2 */
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);
/** @type {Vec2} */
const A = pointOnCircle(C, 150);
/** @type {Vec2} */
const B = pointOnCircle(C, 30);
/** @type {Vec2} */
const M_INITIAL = pointOnCircle(C, 270);
interface Refs {
svg: SVGSVGElement;
m: SVGCircleElement;
segAM: SVGLineElement;
segBM: SVGLineElement;
inscribedReadout: HTMLElement;
centralReadout: HTMLElement;
}
/**
* @typedef {object} Refs
* @property {SVGSVGElement} svg
* @property {SVGCircleElement} m
* @property {SVGLineElement} segAM
* @property {SVGLineElement} segBM
* @property {HTMLElement} inscribedReadout
* @property {HTMLElement} centralReadout
*/
function clientToSvg(svg: SVGSVGElement, clientX: number, clientY: number): Vec2 {
// Convert a clientX/clientY coordinate to the SVG's viewBox space.
/**
* Convert a clientX/clientY coordinate to the SVG's viewBox space.
* @param {SVGSVGElement} svg
* @param {number} clientX
* @param {number} clientY
* @returns {Vec2}
*/
function clientToSvg(svg, clientX, clientY) {
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) {
/**
* @param {Refs} refs
* @param {Vec2} m
*/
function update(refs, m) {
refs.m.setAttribute('cx', m.x.toFixed(2));
refs.m.setAttribute('cy', m.y.toFixed(2));
refs.segAM.setAttribute('x2', m.x.toFixed(2));
@@ -42,43 +57,52 @@ function update(refs: Refs, m: Vec2) {
refs.centralReadout.textContent = `${central.toFixed(1)}°`;
}
export function setupInscribedAngle(svgSelector: string) {
const svg = document.querySelector<SVGSVGElement>(svgSelector);
/** @param {string} svgSelector */
export function setupInscribedAngle(svgSelector) {
const svg = /** @type {SVGSVGElement | null} */ (document.querySelector(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"]');
const m = /** @type {SVGCircleElement | null} */ (svg.querySelector('[data-vertex="M"]'));
const segAM = /** @type {SVGLineElement | null} */ (svg.querySelector('[data-segment="AM"]'));
const segBM = /** @type {SVGLineElement | null} */ (svg.querySelector('[data-segment="BM"]'));
const inscribedReadout = /** @type {HTMLElement | null} */ (
document.querySelector('[data-readout="inscribed"]')
);
const centralReadout = /** @type {HTMLElement | null} */ (
document.querySelector('[data-readout="central"]')
);
if (!m || !segAM || !segBM || !inscribedReadout || !centralReadout) return;
const refs: Refs = { svg, m, segAM, segBM, inscribedReadout, centralReadout };
/** @type {Refs} */
const refs = { svg, m, segAM, segBM, inscribedReadout, centralReadout };
let active = false;
// Render initial state once.
update(refs, M_INITIAL);
const onPointerDown = (e: PointerEvent) => {
/** @param {PointerEvent} e */
const onPointerDown = (e) => {
active = true;
m.setPointerCapture(e.pointerId);
e.preventDefault();
};
const onPointerMove = (e: PointerEvent) => {
/** @param {PointerEvent} e */
const onPointerMove = (e) => {
if (!active) return;
const raw = clientToSvg(svg, e.clientX, e.clientY);
const projected = projectToCircle(raw, C);
update(refs, projected);
};
const onPointerUp = (e: PointerEvent) => {
/** @param {PointerEvent} e */
const onPointerUp = (e) => {
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;
const opts = /** @type {AddEventListenerOptions} */ ({ signal: ctrl.signal });
m.addEventListener('pointerdown', onPointerDown, opts);
m.addEventListener('pointermove', onPointerMove, opts);
+243
View File
@@ -0,0 +1,243 @@
import { add, scale, sub, vec } from '~/geom-engine/vec';
import { angleAtVertex } from '~/geom-engine/circle';
import { sides, triangle as makeTriangle } from '~/geom-engine/triangle';
/** @typedef {import('~/geom-engine/vec').Vec2} Vec2 */
/** @typedef {import('~/geom-engine/triangle').Triangle} 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).
/** @type {Vec2} */
const A = vec(70, 110);
/** @type {Vec2} */
const B = vec(140, 130);
/** @type {Vec2} */
const C = vec(95, 200);
/** @type {Vec2} */
const CENTROID_ABC = vec(
(A.x + B.x + C.x) / 3,
(A.y + B.y + C.y) / 3,
);
/** @type {Vec2} */
const CENTROID_TARGET = vec(300, 145);
/**
* @param {number} k
* @returns {Triangle}
*/
function scaledTriangle(k) {
/** @param {Vec2} p */
const make = (p) => add(CENTROID_TARGET, scale(sub(p, CENTROID_ABC), k));
return makeTriangle(make(A), make(B), make(C));
}
/**
* @typedef {object} Refs
* @property {SVGCircleElement} ap
* @property {SVGCircleElement} bp
* @property {SVGCircleElement} cp
* @property {SVGTextElement} apLabel
* @property {SVGTextElement} bpLabel
* @property {SVGTextElement} cpLabel
* @property {SVGLineElement} apbp
* @property {SVGLineElement} bpcp
* @property {SVGLineElement} cpap
* @property {SVGGElement} tickApBp Tick group for triangle 2
* @property {SVGGElement} tickBpCp Tick group for triangle 2
* @property {SVGGElement} tickCpAp Tick group for triangle 2
* @property {HTMLElement} kReadout k display
* @property {HTMLInputElement} kSlider k display
* @property {HTMLElement} apbpReadout Side-length & ratio readouts
* @property {HTMLElement} bpcpReadout Side-length & ratio readouts
* @property {HTMLElement} cpapReadout Side-length & ratio readouts
* @property {HTMLElement} ratioAB Side-length & ratio readouts
* @property {HTMLElement} ratioBC Side-length & ratio readouts
* @property {HTMLElement} ratioCA Side-length & ratio readouts
*/
const TICK_LEN = 6;
const TICK_SPACING = 5;
/**
* @param {SVGLineElement} line
* @param {Vec2} p1
* @param {Vec2} p2
*/
function setLine(line, p1, p2) {
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));
}
/**
* @param {SVGGElement} group
* @param {Vec2} p1
* @param {Vec2} p2
* @param {1 | 2 | 3} count
* @param {string} color
*/
function renderTicks(group, p1, p2, count, color) {
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);
}
}
/**
* @param {Refs} refs
* @param {number} k
*/
function update(refs, k) {
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);
}
/**
* @param {SVGSVGElement} svg
* @returns {Refs | null}
*/
function getRefs(svg) {
/**
* @template {Element} T
* @param {string} sel
* @param {ParentNode} [root]
* @returns {T | null}
*/
const q = (sel, root = document) => /** @type {T | null} */ (root.querySelector(sel));
const ap = /** @type {SVGCircleElement | null} */ (q('[data-vertex="ap"]', svg));
const bp = /** @type {SVGCircleElement | null} */ (q('[data-vertex="bp"]', svg));
const cp = /** @type {SVGCircleElement | null} */ (q('[data-vertex="cp"]', svg));
const apLabel = /** @type {SVGTextElement | null} */ (q('[data-vertex-label="ap"]', svg));
const bpLabel = /** @type {SVGTextElement | null} */ (q('[data-vertex-label="bp"]', svg));
const cpLabel = /** @type {SVGTextElement | null} */ (q('[data-vertex-label="cp"]', svg));
const apbp = /** @type {SVGLineElement | null} */ (q('[data-side="apbp"]', svg));
const bpcp = /** @type {SVGLineElement | null} */ (q('[data-side="bpcp"]', svg));
const cpap = /** @type {SVGLineElement | null} */ (q('[data-side="cpap"]', svg));
const tickApBp = /** @type {SVGGElement | null} */ (q('[data-ticks="apbp"]', svg));
const tickBpCp = /** @type {SVGGElement | null} */ (q('[data-ticks="bpcp"]', svg));
const tickCpAp = /** @type {SVGGElement | null} */ (q('[data-ticks="cpap"]', svg));
const kReadout = /** @type {HTMLElement | null} */ (q('[data-readout="k"]'));
const kSlider = /** @type {HTMLInputElement | null} */ (q('[data-control="k-slider"]'));
const apbpReadout = /** @type {HTMLElement | null} */ (q('[data-readout-side="apbp"]'));
const bpcpReadout = /** @type {HTMLElement | null} */ (q('[data-readout-side="bpcp"]'));
const cpapReadout = /** @type {HTMLElement | null} */ (q('[data-readout-side="cpap"]'));
const ratioAB = /** @type {HTMLElement | null} */ (q('[data-readout-ratio="ab"]'));
const ratioBC = /** @type {HTMLElement | null} */ (q('[data-readout-ratio="bc"]'));
const ratioCA = /** @type {HTMLElement | null} */ (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,
};
}
/** @param {string} svgSelector */
export function setupSimilarityScale(svgSelector) {
const svg = /** @type {SVGSVGElement | null} */ (document.querySelector(svgSelector));
if (!svg) return;
const refs = getRefs(svg);
if (!refs) return;
const ctrl = new AbortController();
const opts = /** @type {AddEventListenerOptions} */ ({ signal: ctrl.signal });
// Static ticks for triangle 1 (ABC) — render once, never change.
const tickAB = /** @type {SVGGElement | null} */ (svg.querySelector('[data-ticks="ab"]'));
const tickBC = /** @type {SVGGElement | null} */ (svg.querySelector('[data-ticks="bc"]'));
const tickCA = /** @type {SVGGElement | null} */ (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 });
}
-208
View File
@@ -1,208 +0,0 @@
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 = <T extends Element>(sel: string, root: ParentNode = document): T | null =>
root.querySelector<T>(sel);
const ap = q<SVGCircleElement>('[data-vertex="ap"]', svg);
const bp = q<SVGCircleElement>('[data-vertex="bp"]', svg);
const cp = q<SVGCircleElement>('[data-vertex="cp"]', svg);
const apLabel = q<SVGTextElement>('[data-vertex-label="ap"]', svg);
const bpLabel = q<SVGTextElement>('[data-vertex-label="bp"]', svg);
const cpLabel = q<SVGTextElement>('[data-vertex-label="cp"]', svg);
const apbp = q<SVGLineElement>('[data-side="apbp"]', svg);
const bpcp = q<SVGLineElement>('[data-side="bpcp"]', svg);
const cpap = q<SVGLineElement>('[data-side="cpap"]', svg);
const tickApBp = q<SVGGElement>('[data-ticks="apbp"]', svg);
const tickBpCp = q<SVGGElement>('[data-ticks="bpcp"]', svg);
const tickCpAp = q<SVGGElement>('[data-ticks="cpap"]', svg);
const kReadout = q<HTMLElement>('[data-readout="k"]');
const kSlider = q<HTMLInputElement>('[data-control="k-slider"]');
const apbpReadout = q<HTMLElement>('[data-readout-side="apbp"]');
const bpcpReadout = q<HTMLElement>('[data-readout-side="bpcp"]');
const cpapReadout = q<HTMLElement>('[data-readout-side="cpap"]');
const ratioAB = q<HTMLElement>('[data-readout-ratio="ab"]');
const ratioBC = q<HTMLElement>('[data-readout-ratio="bc"]');
const ratioCA = q<HTMLElement>('[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<SVGSVGElement>(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<SVGGElement>('[data-ticks="ab"]');
const tickBC = svg.querySelector<SVGGElement>('[data-ticks="bc"]');
const tickCA = svg.querySelector<SVGGElement>('[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 });
}
@@ -1,16 +1,27 @@
import type { Vec2 } from './vec';
import { add, dot, len, normalize, scale, sub, vec } from './vec';
export interface Circle {
readonly center: Vec2;
readonly radius: number;
}
/** @typedef {import('./vec').Vec2} Vec2 */
export function circle(cx: number, cy: number, r: number): Circle {
/**
* @typedef {{ readonly center: Vec2, readonly radius: number }} Circle
*/
/**
* @param {number} cx
* @param {number} cy
* @param {number} r
* @returns {Circle}
*/
export function circle(cx, cy, r) {
return { center: vec(cx, cy), radius: r };
}
export function projectToCircle(point: Vec2, c: Circle): Vec2 {
/**
* @param {Vec2} point
* @param {Circle} c
* @returns {Vec2}
*/
export function projectToCircle(point, c) {
const dir = sub(point, c.center);
const d = len(dir);
if (d === 0) {
@@ -20,14 +31,25 @@ export function projectToCircle(point: Vec2, c: Circle): Vec2 {
return add(c.center, scale(normalize(dir), c.radius));
}
export function pointOnCircle(c: Circle, angleDeg: number): Vec2 {
/**
* @param {Circle} c
* @param {number} angleDeg
* @returns {Vec2}
*/
export function pointOnCircle(c, angleDeg) {
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`.
/**
* 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`.
* @param {Vec2} a
* @param {Vec2} vertex
* @param {Vec2} b
* @returns {number}
*/
export function angleAtVertex(a, vertex, b) {
const va = sub(a, vertex);
const vb = sub(b, vertex);
const lenA = len(va);
@@ -95,7 +95,7 @@ describe('inscribed-angle invariance (the killer-demo property)', () => {
);
it('all sampled M on the major arc give the same inscribed angle (within 0.5°)', () => {
const reference = inscribedAngles[0]!;
const reference = /** @type {number} */ (inscribedAngles[0]);
for (const angle of inscribedAngles) {
expect(Math.abs(angle - reference)).toBeLessThan(0.5);
}
+53
View File
@@ -0,0 +1,53 @@
import { dist, EPSILON_LEN } from './vec';
/** @typedef {import('./vec').Vec2} Vec2 */
/**
* @typedef {{ readonly a: Vec2, readonly b: Vec2, readonly c: Vec2 }} Triangle
*/
/**
* @param {Vec2} a
* @param {Vec2} b
* @param {Vec2} c
* @returns {Triangle}
*/
export function triangle(a, b, c) {
return { a, b, c };
}
/**
* @typedef {{ readonly ab: number, readonly bc: number, readonly ca: number }} SideLengths
*/
/**
* @param {Triangle} t
* @returns {SideLengths}
*/
export function sides(t) {
return {
ab: dist(t.a, t.b),
bc: dist(t.b, t.c),
ca: dist(t.c, t.a),
};
}
/**
* Position-strict SSS: corresponding sides must match (AB↔A'B', BC↔B'C', CA↔C'A').
* SGK pedagogy treats vertex labels as defining the correspondence — a permuted
* match would still be the same shape but a different theorem case. We want the
* strict labeled version so the UI's color/tick pairing has unambiguous meaning.
* @param {Triangle} t1
* @param {Triangle} t2
* @param {number} [eps]
* @returns {boolean}
*/
export function congruentSSS(t1, t2, eps = EPSILON_LEN) {
const s1 = sides(t1);
const s2 = sides(t2);
return (
Math.abs(s1.ab - s2.ab) < eps &&
Math.abs(s1.bc - s2.bc) < eps &&
Math.abs(s1.ca - s2.ca) < eps
);
}
-40
View File
@@ -1,40 +0,0 @@
import type { Vec2 } from './vec';
import { dist, EPSILON_LEN } from './vec';
export interface Triangle {
readonly a: Vec2;
readonly b: Vec2;
readonly c: Vec2;
}
export function triangle(a: Vec2, b: Vec2, c: Vec2): Triangle {
return { a, b, c };
}
export interface SideLengths {
readonly ab: number;
readonly bc: number;
readonly ca: number;
}
export function sides(t: Triangle): SideLengths {
return {
ab: dist(t.a, t.b),
bc: dist(t.b, t.c),
ca: dist(t.c, t.a),
};
}
// Position-strict SSS: corresponding sides must match (AB↔A'B', BC↔B'C', CA↔C'A').
// SGK pedagogy treats vertex labels as defining the correspondence — a permuted
// match would still be the same shape but a different theorem case. We want the
// strict labeled version so the UI's color/tick pairing has unambiguous meaning.
export function congruentSSS(t1: Triangle, t2: Triangle, eps = EPSILON_LEN): boolean {
const s1 = sides(t1);
const s2 = sides(t2);
return (
Math.abs(s1.ab - s2.ab) < eps &&
Math.abs(s1.bc - s2.bc) < eps &&
Math.abs(s1.ca - s2.ca) < eps
);
}
+90
View File
@@ -0,0 +1,90 @@
/**
* @typedef {{ readonly x: number, readonly y: number }} Vec2
*/
export const EPSILON_LEN = 0.5;
export const EPSILON_ANGLE_DEG = 0.5;
/**
* @param {number} x
* @param {number} y
* @returns {Vec2}
*/
export function vec(x, y) {
return { x, y };
}
/**
* @param {Vec2} a
* @param {Vec2} b
* @returns {Vec2}
*/
export function add(a, b) {
return { x: a.x + b.x, y: a.y + b.y };
}
/**
* @param {Vec2} a
* @param {Vec2} b
* @returns {Vec2}
*/
export function sub(a, b) {
return { x: a.x - b.x, y: a.y - b.y };
}
/**
* @param {Vec2} a
* @param {number} k
* @returns {Vec2}
*/
export function scale(a, k) {
// `+ 0` normalizes IEEE-754 -0 back to +0 so consumers comparing coordinates
// with === or Object.is don't see a signed-zero ghost from k=0 paths.
return { x: a.x * k + 0, y: a.y * k + 0 };
}
/**
* @param {Vec2} a
* @param {Vec2} b
* @returns {number}
*/
export function dot(a, b) {
return a.x * b.x + a.y * b.y;
}
/**
* @param {Vec2} a
* @returns {number}
*/
export function len(a) {
return Math.hypot(a.x, a.y);
}
/**
* @param {Vec2} a
* @param {Vec2} b
* @returns {number}
*/
export function dist(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
/**
* @param {Vec2} a
* @returns {Vec2}
*/
export function normalize(a) {
const l = len(a);
if (l === 0) return { x: 0, y: 0 };
return { x: a.x / l, y: a.y / l };
}
/**
* @param {number} a
* @param {number} b
* @param {number} [eps]
* @returns {boolean}
*/
export function approxEqualLen(a, b, eps = EPSILON_LEN) {
return Math.abs(a - b) < eps;
}
File renamed without changes.
-47
View File
@@ -1,47 +0,0 @@
export interface Vec2 {
readonly x: number;
readonly y: number;
}
export const EPSILON_LEN = 0.5;
export const EPSILON_ANGLE_DEG = 0.5;
export function vec(x: number, y: number): Vec2 {
return { x, y };
}
export function add(a: Vec2, b: Vec2): Vec2 {
return { x: a.x + b.x, y: a.y + b.y };
}
export function sub(a: Vec2, b: Vec2): Vec2 {
return { x: a.x - b.x, y: a.y - b.y };
}
export function scale(a: Vec2, k: number): Vec2 {
// `+ 0` normalizes IEEE-754 -0 back to +0 so consumers comparing coordinates
// with === or Object.is don't see a signed-zero ghost from k=0 paths.
return { x: a.x * k + 0, y: a.y * k + 0 };
}
export function dot(a: Vec2, b: Vec2): number {
return a.x * b.x + a.y * b.y;
}
export function len(a: Vec2): number {
return Math.hypot(a.x, a.y);
}
export function dist(a: Vec2, b: Vec2): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
export function normalize(a: Vec2): Vec2 {
const l = len(a);
if (l === 0) return { x: 0, y: 0 };
return { x: a.x / l, y: a.y / l };
}
export function approxEqualLen(a: number, b: number, eps = EPSILON_LEN): boolean {
return Math.abs(a - b) < eps;
}
+13
View File
@@ -0,0 +1,13 @@
import { vi } from './vi';
const locales = /** @type {const} */ ({ vi });
/** @typedef {keyof typeof locales} LocaleKey */
/** @type {LocaleKey} */
const defaultLocale = 'vi';
/** @returns {typeof vi} */
export function t() {
return locales[defaultLocale];
}
-11
View File
@@ -1,11 +0,0 @@
import { vi } from './vi';
const locales = { vi } as const;
export type LocaleKey = keyof typeof locales;
const defaultLocale: LocaleKey = 'vi';
export function t(): typeof vi {
return locales[defaultLocale];
}
@@ -1,4 +1,4 @@
export const vi = {
export const vi = /** @type {const} */ ({
site: {
title: 'Hình Học Sống',
tagline: 'Học hình bằng cách kéo',
@@ -97,6 +97,6 @@ export const vi = {
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;
/** @typedef {typeof vi} Locale */
+3 -3
View File
@@ -5,9 +5,9 @@ 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'] },
{ key: 'lop-9' as const, ...copy.grade['lop-9'] },
{ key: /** @type {const} */ ('lop-7'), ...copy.grade['lop-7'] },
{ key: /** @type {const} */ ('lop-8'), ...copy.grade['lop-8'] },
{ key: /** @type {const} */ ('lop-9'), ...copy.grade['lop-9'] },
];
---