v0.0.4.0 feat(module-2): tam giác đồng dạng (lớp 8, scale slider) (#4)

* feat(component): similarity-scale slider controller

Single-slider similarity demo for Module 2. Fixed △ABC, scaled △A'B'C' computed
each frame as `centroid_target + k · (vertex − centroid_ABC)` for k ∈ [0.5, 2].
The slider's input event is the only event listener — no Pointer Events here
(we're not dragging vertices in this MVP). AbortController teardown on
astro:before-swap as before.

The renderTicks helper is duplicated from congruence-sss.ts for now — when
Module 4 lands we'll factor it out into a shared module. Premature
abstraction would force a circular import structure for one helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(module-2): tam giác đồng dạng (lớp 8) with scale slider

Page at /lop-8/tam-giac-dong-dang/. △ABC fixed on the left at scalene scale-72.8 / 83.2 / 93.4 sides. △A'B'C' is the runtime scaled image, controlled by a
single range input k ∈ [0.5, 2] step 0.05.

Live readout table shows all six side lengths and all three ratios. The killer
moment: drag the slider, watch all 3 ratios stay numerically equal to each
other (= 1/k). That equality IS the definition of similarity, made visible.

Tick marks per Decision D3: 1/2/3 ticks color-paired (#D7263D / #1B998B /
#F46036). Triangle 1 ticks rendered once on mount; triangle 2 ticks redrawn
on every slider input.

Hub: lớp 8 card flips to live status. All 3 grade cards now active —
the autoplan 3-module hero set is complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: VERSION 0.0.4.0 + CHANGELOG + TODOS update

v0.0.4.0 = scaffold + Module 3 (góc nội tiếp) + Module 1 (tam giác bằng nhau)
+ Module 2 (tam giác đồng dạng). All three MVP modules from autoplan now live.

Move 2 P1 items to Completed (scale slider, ratio readout) and reduce Module 2
remaining items to P2 (free-drag mode) + P3 (numeric angle readouts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 00:25:08 +07:00
committed by GitHub
parent def8d67274
commit f7f31e41d0
7 changed files with 406 additions and 12 deletions
+20
View File
@@ -2,6 +2,26 @@
All notable changes to **Hình Học Sống** are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: 4-digit MAJOR.MINOR.PATCH.MICRO per gstack.
## [0.0.4.0] - 2026-04-30
### Added
- **Module 2 (lớp 8): Tam giác đồng dạng** — third interactive theorem demo, live at `/lop-8/tam-giac-dong-dang/`. MVP completes the autoplan plan's 3-module hero set.
- Slider-driven scale-similarity demo: a fixed △ABC on the left, a scaled △ABC on the right with vertices computed at runtime as `centroid_target + k · (vertex centroid_ABC)` for `k ∈ [0.5, 2.0]`, step 0.05.
- Live readout table: 6 side lengths + 3 ratios (AB/AB, BC/BC, CA/CA). All 3 ratios stay equal as `k` varies — the killer-demo property of similarity in motion. Color-keyed and tick-marked per Decision D3 (1/2/3 ticks paired with the 3-color palette).
- The `k` value displays prominently above the slider in pair1 red. ARIA-labels on the slider so screen readers can drive the demo by keyboard.
- Page reuses `src/components/similarity-scale.ts` + `renderTicks` helper (extracted as a pattern from Module 1). No new geom-engine module needed — `triangle.sides()` and `vec.scale()` carry the math.
- Hub: lớp 8 card now links forward with "Khám phá" status. All three grade cards are now live.
### Changed
- The Astro build now extracts shared geom-engine chunks (`vec.js` ~441B, `triangle.js` ~288B) instead of inlining per-page scripts. Per-page script weight: ~23.6KB. Cached across modules after first visit.
### Notes
- Module 2's MVP picks the BONUS variant from the autoplan plan (slider-driven scale) over the harder free-vertex-drag form. Reasoning: scale slider gives the strongest "wow" moment in a single gesture and ships in one weekend; free-drag with AA/SAS/SSS đồng dạng detectors is a separate UI surface. Free-drag deferred to v0.0.5.0+ (P2 in TODOS.md).
- Angles are not displayed numerically but are pinned conceptually in the prose: "khi △ABC phóng/thu theo hệ số k, các cạnh nhân với k nhưng các góc tại A, B, C không thay đổi". Numeric angle readouts arrive when the free-drag mode lands.
## [0.0.3.0] - 2026-04-30
### Added
+9 -7
View File
@@ -67,15 +67,15 @@ Tracked work, organized by component then priority (P0 highest → P4 lowest). S
**Priority:** P3
**What:** v0.0.3.0 ships 1 example. SGK textbook has many. Add 2 more.
## Module 2 — Lớp 8 Tam giác đồng dạng (weekend 4)
## Module 2 — Lớp 8 Tam giác đồng dạng
- **Similarity ratio + AA/SAS/SSS-similar detectors**
**Priority:** P1
**What:** `src/geom-engine/similarity.ts`. Live ratio display via numeric text-node updates only (KaTeX template rendered at build, never re-parsed during drag).
- **Free-vertex-drag mode (AA / SAS / SSS đồng dạng detectors)**
**Priority:** P2
**What:** v0.0.4.0 ships scale-slider mode. Add a toggle to switch into free-drag mode where both triangles' vertices can be moved independently and the detector identifies which similarity case (if any) holds. Adds `src/geom-engine/similarity.ts`.
- **Scale slider clamped to [0.5, 2.0]**
**Priority:** P1
**What:** `min=0.5, max=2, step=0.05`. Cannot reach 0. Per Eng failure mode #3.
- **Numeric angle readouts on the canvas**
**Priority:** P3
**What:** Currently angles are pinned conceptually in prose. Add live numeric readouts (∠A, ∠B, ∠C and primes) so students see "the angles don't change" empirically.
## Testing & CI infrastructure (with first canvas module)
@@ -152,3 +152,5 @@ Tracked work, organized by component then priority (P0 highest → P4 lowest). S
- **TheoremCanvas primitive first cut** — vanilla TS in Astro `<script>` tag, AbortController teardown on `astro:before-swap`. **Completed:** v0.0.2.0 (2026-04-29). Note: built directly in Astro rather than as a single HTML prototype first; the inscribed-angle pattern proved the shape, and the next module (lớp 7 / 8) will reuse this pattern.
- **SSS detector** — `src/geom-engine/triangle.ts:congruentSSS` with position-strict semantics. 10 unit tests including symmetry and EPSILON tolerance. **Completed:** v0.0.3.0 (2026-04-30)
- **SGK tick-mark encoding** — 1/2/3 ticks paired with the locked 3-color palette, rendered live during drag. Wired in Module 1 (Tam giác bằng nhau). **Completed:** v0.0.3.0 (2026-04-30). Tick rendering helper in `src/components/congruence-sss.ts:renderTicks` is reusable for future modules.
- **Scale slider clamped to [0.5, 2.0]** — `src/components/similarity-scale.ts` step 0.05. Cannot reach 0. **Completed:** v0.0.4.0 (2026-04-30)
- **Similarity ratio readout** — three ratio readouts (AB/AB, BC/BC, CA/CA) showing all-equal under scaling. **Completed:** v0.0.4.0 (2026-04-30) as part of Module 2.
+1 -1
View File
@@ -1 +1 @@
0.0.3.0
0.0.4.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "try-gstack",
"version": "0.0.3.0",
"version": "0.0.4.0",
"private": true,
"type": "module",
"description": "Hình Học Sống — Interactive Vietnamese THCS geometry visualizer (lớp 7-9). Static site, drag-to-explore theorems.",
+208
View File
@@ -0,0 +1,208 @@
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 });
}
+26 -3
View File
@@ -22,9 +22,9 @@ export const vi = {
title: 'Lớp 8',
hero: 'Tam giác đồng dạng',
blurb:
'AA, SAS, SSS đồng dạng. Kéo để xem tỉ số cạnh giữ nguyên khi tam giác phóng to.',
status: 'sap-ra-mat',
href: null,
'Kéo thanh trượt phóng/thu — tỉ số ba cạnh tương ứng giữ nguyên, các góc cũng không đổi.',
status: 'live',
href: '/lop-8/tam-giac-dong-dang/',
},
'lop-9': {
title: 'Lớp 9',
@@ -74,6 +74,29 @@ export const vi = {
backToHub: '← Về trang chủ',
nextTeaser: 'Sắp ra mắt: SAS / ASA / cạnh huyền góc nhọn / cạnh huyền cạnh góc vuông',
},
module2: {
title: 'Tam giác đồng dạng',
grade: 'Lớp 8',
intro:
'Hai tam giác đồng dạng có các góc tương ứng bằng nhau và các cạnh tương ứng tỉ lệ. Hãy kéo thanh trượt phóng/thu △ABC — tỉ số AB/AB luôn bằng BC/BC và CA/CA, dù tam giác lớn hay nhỏ. Các góc thì không đổi.',
instruction: 'Kéo thanh trượt để phóng to hoặc thu nhỏ △ABC',
kLabel: 'Hệ số phóng',
sidesTitle: 'Cạnh tương ứng',
tabSide: 'Cặp cạnh',
tabT1: '△ABC',
tabT2: '△ABC',
tabRatio: 'Tỉ số AB/AB',
anglesNote:
'Khi △ABC phóng/thu theo hệ số k, các cạnh nhân với k nhưng các góc tại A, B, C không thay đổi — đó chính là định nghĩa của hai tam giác đồng dạng.',
theoremTitle: 'Định nghĩa',
theoremStatement:
'Hai tam giác gọi là đồng dạng khi các góc tương ứng bằng nhau và các cạnh tương ứng tỉ lệ. Tỉ số đó được gọi là tỉ số đồng dạng k.',
exampleTitle: 'Ví dụ',
exampleBody:
'Ở hệ số k = 2, tam giác △ABC to gấp đôi △ABC: mỗi cạnh AB = 2 · AB. Khi đó tỉ số AB/AB = 1/2 = 0,50, đúng bằng BC/BC và CA/CA. Khi k = 0,5, tam giác ABC nhỏ bằng nửa: tỉ số AB/AB = 2,00. Hãy kéo thanh trượt để xác nhận.',
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;
+141
View File
@@ -0,0 +1,141 @@
---
import BaseLayout from '~/layouts/BaseLayout.astro';
import { t } from '~/i18n';
const copy = t();
const m = copy.module2;
const baseUrl = import.meta.env.BASE_URL;
---
<BaseLayout title={`${m.title} — ${copy.site.title}`} description={m.intro}>
<main class="mx-auto max-w-prose px-5 py-6">
<nav class="mb-4 text-sm">
<a href={baseUrl}>{m.backToHub}</a>
</nav>
<header class="mb-6">
<div class="text-sm uppercase tracking-wide" style="color:#888">{m.grade}</div>
<h1 class="mb-2">{m.title}</h1>
<p>{m.intro}</p>
</header>
<section class="mb-3">
<label
for="k-slider"
class="flex flex-wrap items-center justify-between gap-2 text-sm"
style="color:#444"
>
<span>{m.instruction}</span>
<span class="tabular-nums">
{m.kLabel}: <strong data-readout="k" style="color:#D7263D">1.00</strong>
</span>
</label>
<input
id="k-slider"
data-control="k-slider"
type="range"
min="0.5"
max="2"
step="0.05"
value="1"
class="block w-full mt-2"
aria-label={m.kLabel}
/>
</section>
<section class="mb-6">
<svg
id="similarity-canvas"
viewBox="0 0 400 300"
preserveAspectRatio="xMidYMid meet"
class="block w-full mx-auto"
style="max-width:640px; touch-action:pan-y; aspect-ratio:4/3; background:#FAFAFA; border-radius:8px"
role="img"
aria-label={m.instruction}
>
<line data-side="ab" x1="70" y1="110" x2="140" y2="130" stroke="#444" stroke-width="2"></line>
<line data-side="bc" x1="140" y1="130" x2="95" y2="200" stroke="#444" stroke-width="2"></line>
<line data-side="ca" x1="95" y1="200" x2="70" y2="110" stroke="#444" stroke-width="2"></line>
<line data-side="apbp" stroke="#444" stroke-width="2"></line>
<line data-side="bpcp" stroke="#444" stroke-width="2"></line>
<line data-side="cpap" stroke="#444" stroke-width="2"></line>
<g data-ticks="ab"></g>
<g data-ticks="bc"></g>
<g data-ticks="ca"></g>
<g data-ticks="apbp"></g>
<g data-ticks="bpcp"></g>
<g data-ticks="cpap"></g>
<text x="54" y="100" font-size="14" font-weight="700" fill="#444">A</text>
<text x="148" y="120" font-size="14" font-weight="700" fill="#444">B</text>
<text x="89" y="222" font-size="14" font-weight="700" fill="#444">C</text>
<circle cx="70" cy="110" r="6" fill="#444"></circle>
<circle cx="140" cy="130" r="6" fill="#444"></circle>
<circle cx="95" cy="200" r="6" fill="#444"></circle>
<text data-vertex-label="ap" font-size="14" font-weight="700" fill="#444">A&#39;</text>
<text data-vertex-label="bp" font-size="14" font-weight="700" fill="#444">B&#39;</text>
<text data-vertex-label="cp" font-size="14" font-weight="700" fill="#444">C&#39;</text>
<circle data-vertex="ap" r="6" fill="#444"></circle>
<circle data-vertex="bp" r="6" fill="#444"></circle>
<circle data-vertex="cp" r="6" fill="#444"></circle>
</svg>
</section>
<section class="mb-6">
<h2 class="mb-2 text-base">{m.sidesTitle}</h2>
<table class="w-full text-sm tabular-nums">
<thead>
<tr style="border-bottom:1px solid #ddd">
<th class="text-left py-1 font-medium" style="color:#666">{m.tabSide}</th>
<th class="text-right py-1 font-medium" style="color:#666">{m.tabT1}</th>
<th class="text-right py-1 font-medium" style="color:#666">{m.tabT2}</th>
<th class="text-right py-1 font-medium" style="color:#666">{m.tabRatio}</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #f0f0f0">
<td class="py-1"><span style="color:#D7263D; font-weight:700">|</span> AB / A&#39;B&#39;</td>
<td class="text-right" style="color:#D7263D">72.8</td>
<td class="text-right" style="color:#D7263D"><span data-readout-side="apbp">—</span></td>
<td class="text-right tabular-nums"><span data-readout-ratio="ab" style="color:#D7263D">—</span></td>
</tr>
<tr style="border-bottom:1px solid #f0f0f0">
<td class="py-1"><span style="color:#1B998B; font-weight:700">| |</span> BC / B&#39;C&#39;</td>
<td class="text-right" style="color:#1B998B">83.2</td>
<td class="text-right" style="color:#1B998B"><span data-readout-side="bpcp">—</span></td>
<td class="text-right tabular-nums"><span data-readout-ratio="bc" style="color:#1B998B">—</span></td>
</tr>
<tr>
<td class="py-1"><span style="color:#F46036; font-weight:700">| | |</span> CA / C&#39;A&#39;</td>
<td class="text-right" style="color:#F46036">93.4</td>
<td class="text-right" style="color:#F46036"><span data-readout-side="cpap">—</span></td>
<td class="text-right tabular-nums"><span data-readout-ratio="ca" style="color:#F46036">—</span></td>
</tr>
</tbody>
</table>
<p class="mt-3 text-xs" style="color:#666">{m.anglesNote}</p>
</section>
<section class="mb-6">
<h2 class="mb-2">{m.theoremTitle}</h2>
<p class="rounded-lg p-4" style="background:#F4F4F4">{m.theoremStatement}</p>
</section>
<section class="mb-10">
<h2 class="mb-2">{m.exampleTitle}</h2>
<p>{m.exampleBody}</p>
</section>
<footer class="border-t border-neutral-200 pt-4 text-sm" style="color:#888">{m.nextTeaser}</footer>
</main>
</BaseLayout>
<script>
import { setupSimilarityScale } from '~/components/similarity-scale';
setupSimilarityScale('#similarity-canvas');
</script>