mirror of
https://github.com/tiennm99/ai-labs.git
synced 2026-10-14 02:15:24 +00:00
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:
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;
|
||||
+51
-27
@@ -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);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
File renamed without changes.
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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'] },
|
||||
];
|
||||
---
|
||||
|
||||
|
||||
Reference in new issue
Block a user