Refactor math chapter templates and enhance navigation: update CONTRIBUTING.md for clarity on content structure, integrate new chapter heading navigation component, and improve math chapter layout with core and supplemental content. Add styles for chapter navigation and scenario demos to enhance user experience.

This commit is contained in:
Hananoshika Yomaru 2026-05-19 15:07:01 -07:00
parent 7c09a57610
commit 742a72abe2
17 changed files with 2184 additions and 12 deletions

View file

@ -110,7 +110,7 @@ Prerequisites: **only** `graph.edges` (required, global DAG, cross-strand OK). L
Live math example:
1. `content/math/quadratic-equations/_index.md` — front matter, `template = "math/chapter.html"`, `[extra] chapter_id`
2. `templates/partials/math/quadratic-equations-content.html` — prose + KaTeX
2. `templates/partials/math/quadratic-equations-core-content.html` — prose + KaTeX (supplement partial for history, derivation, etc.)
3. `templates/math/chapter.html` — `{% if section.extra.chapter_id == "…" %}` includes partial
4. Optional: `frontend/src/components/quadratic-explorer.tsx` + mount in `main.tsx`

View file

@ -120,7 +120,7 @@ Today, chapter bodies live as HTML partials under the theme, not Markdown:
{% endif %}
```
Use existing classes: `book-prose`, `book-prose__heading`, `book-callout`, etc. Copy from `quadratic-equations-content.html`.
Use existing classes: `book-prose`, `book-prose__heading`, `book-callout`, etc. Copy from `quadratic-equations-core-content.html`.
**Do not** mention DSE, IB, or other curriculum names in the chapter body.

View file

@ -0,0 +1,270 @@
import { createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js";
type NavHeading = {
id: string;
label: string;
level: 2 | 3;
};
/** Align with sticky site header (see .book-prose__heading scroll-margin-top). */
const SCROLL_OFFSET = 96;
const CLICK_LOCK_FALLBACK_MS = 900;
function collectHeadings(article: HTMLElement): NavHeading[] {
const nodes = article.querySelectorAll<HTMLHeadingElement>(
".book-prose h2.book-prose__heading[id], .book-prose h3.book-prose__heading[id]"
);
return Array.from(nodes).map((node) => ({
id: node.id,
label: node.textContent?.trim() ?? node.id,
level: node.tagName === "H3" ? 3 : 2,
}));
}
function headingDocumentTop(el: HTMLElement) {
return el.getBoundingClientRect().top + window.scrollY;
}
function pickActiveId(items: NavHeading[]): string | null {
if (items.length === 0) return null;
const position = window.scrollY + SCROLL_OFFSET;
let active = items[0].id;
for (const item of items) {
const el = document.getElementById(item.id);
if (!el) continue;
if (headingDocumentTop(el) <= position + 2) {
active = item.id;
} else {
break;
}
}
return active;
}
export function ChapterHeadingNav() {
const [headings, setHeadings] = createSignal<NavHeading[]>([]);
const [activeId, setActiveId] = createSignal<string | null>(null);
const [railOpen, setRailOpen] = createSignal(false);
const [sheetOpen, setSheetOpen] = createSignal(false);
const [isMobile, setIsMobile] = createSignal(false);
let scrollToHeading: ((id: string) => void) | undefined;
onMount(() => {
const article = document.querySelector<HTMLElement>(".book-chapter");
if (!article) return;
const items = collectHeadings(article);
setHeadings(items);
const hash = window.location.hash.slice(1);
const hashMatch = hash && items.some((item) => item.id === hash);
setActiveId(hashMatch ? hash : pickActiveId(items));
let lockedId: string | null = null;
let lockTimer: number | undefined;
let rafId: number | undefined;
let scrollEndHandler: (() => void) | null = null;
const releaseLock = () => {
lockedId = null;
if (scrollEndHandler) {
window.removeEventListener("scrollend", scrollEndHandler);
scrollEndHandler = null;
}
if (lockTimer !== undefined) {
window.clearTimeout(lockTimer);
lockTimer = undefined;
}
setActiveId(pickActiveId(items));
};
const syncActive = () => {
if (rafId !== undefined) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
rafId = undefined;
if (lockedId) {
setActiveId(lockedId);
return;
}
setActiveId(pickActiveId(items));
});
};
scrollToHeading = (id: string) => {
const el = document.getElementById(id);
if (!el) return;
lockedId = id;
setActiveId(id);
if (scrollEndHandler) {
window.removeEventListener("scrollend", scrollEndHandler);
}
if (lockTimer !== undefined) {
window.clearTimeout(lockTimer);
}
const top = Math.max(0, headingDocumentTop(el) - SCROLL_OFFSET);
window.scrollTo({ top, behavior: "smooth" });
history.replaceState(null, "", `#${id}`);
scrollEndHandler = () => releaseLock();
window.addEventListener("scrollend", scrollEndHandler, { once: true });
lockTimer = window.setTimeout(releaseLock, CLICK_LOCK_FALLBACK_MS);
};
const onScroll = () => syncActive();
window.addEventListener("scroll", onScroll, { passive: true });
const mq = window.matchMedia("(max-width: 899px)");
const updateMobile = () => setIsMobile(mq.matches);
updateMobile();
mq.addEventListener("change", updateMobile);
if (hashMatch) {
requestAnimationFrame(() => scrollToHeading?.(hash));
}
onCleanup(() => {
window.removeEventListener("scroll", onScroll);
mq.removeEventListener("change", updateMobile);
releaseLock();
if (rafId !== undefined) cancelAnimationFrame(rafId);
scrollToHeading = undefined;
});
});
createEffect(() => {
if (!isMobile()) {
setSheetOpen(false);
} else {
setRailOpen(false);
}
});
const jump = (id: string) => {
if (scrollToHeading) {
scrollToHeading(id);
} else {
const el = document.getElementById(id);
if (el) {
const top = Math.max(0, headingDocumentTop(el) - SCROLL_OFFSET);
window.scrollTo({ top, behavior: "smooth" });
history.replaceState(null, "", `#${id}`);
setActiveId(id);
}
}
setSheetOpen(false);
};
const NavList = (props: { class?: string }) => (
<ul class={`chapter-heading-nav__list ${props.class ?? ""}`.trim()} role="list">
<For each={headings()}>
{(item) => (
<li
class="chapter-heading-nav__item"
classList={{
"chapter-heading-nav__item--active": activeId() === item.id,
"chapter-heading-nav__item--h3": item.level === 3,
}}
>
<button
type="button"
class="chapter-heading-nav__link"
onClick={() => jump(item.id)}
>
{item.label}
</button>
</li>
)}
</For>
</ul>
);
return (
<Show when={headings().length > 0}>
<nav class="chapter-heading-nav" aria-label="On this page">
<Show when={!isMobile()}>
<div
class="chapter-heading-nav__rail-wrap"
onMouseEnter={() => setRailOpen(true)}
onMouseLeave={() => setRailOpen(false)}
onFocusIn={() => setRailOpen(true)}
onFocusOut={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setRailOpen(false);
}
}}
>
<div class="chapter-heading-nav__rail" aria-hidden="true">
<For each={headings()}>
{(item) => (
<button
type="button"
class="chapter-heading-nav__tick"
classList={{
"chapter-heading-nav__tick--active": activeId() === item.id,
"chapter-heading-nav__tick--h3": item.level === 3,
}}
aria-label={item.label}
aria-current={activeId() === item.id ? "true" : undefined}
onClick={() => jump(item.id)}
/>
)}
</For>
</div>
<Show when={railOpen()}>
<div class="chapter-heading-nav__popover" role="dialog" aria-label="Page sections">
<p class="chapter-heading-nav__popover-title">On this page</p>
<NavList />
</div>
</Show>
</div>
</Show>
<Show when={isMobile()}>
<button
type="button"
class="chapter-heading-nav__fab"
aria-expanded={sheetOpen()}
aria-controls="chapter-heading-sheet"
onClick={() => setSheetOpen((open) => !open)}
>
On this page
</button>
<Show when={sheetOpen()}>
<button
type="button"
class="chapter-heading-nav__backdrop"
aria-label="Close section menu"
onClick={() => setSheetOpen(false)}
/>
<div
id="chapter-heading-sheet"
class="chapter-heading-nav__sheet"
role="dialog"
aria-label="On this page"
>
<div class="chapter-heading-nav__sheet-header">
<p class="chapter-heading-nav__popover-title">On this page</p>
<button
type="button"
class="chapter-heading-nav__sheet-close"
aria-label="Close"
onClick={() => setSheetOpen(false)}
>
×
</button>
</div>
<NavList />
</div>
</Show>
</Show>
</nav>
</Show>
);
}

View file

@ -0,0 +1,750 @@
import katex from "katex";
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
import { computeRoots, discriminant, formatNum } from "../lib/math-graph";
const HORIZONTAL_SPEED = 12;
function renderLatex(latex: string) {
return katex.renderToString(latex, { throwOnError: false, strict: "ignore" });
}
function SliderRow(props: {
id: string;
label: string;
value: number;
min: number;
max: number;
step: number;
unit?: string;
disabled?: boolean;
onInput: (v: number) => void;
}) {
const display = () =>
props.unit ? `${formatNum(props.value)} ${props.unit}` : formatNum(props.value);
return (
<div class="scenario-demo__control">
<label class="scenario-demo__label" for={props.id}>
<span>{props.label}</span>
<span class="scenario-demo__value">{display()}</span>
</label>
<input
id={props.id}
class="scenario-demo__slider"
type="range"
min={props.min}
max={props.max}
step={props.step}
value={props.value}
disabled={props.disabled}
onInput={(e) => props.onInput(Number(e.currentTarget.value))}
/>
</div>
);
}
export function ProjectileDemo() {
const [v0y, setV0y] = createSignal(20);
const [g, setG] = createSignal(10);
const [h0, setH0] = createSignal(0);
const [t, setT] = createSignal(0);
const [playing, setPlaying] = createSignal(false);
let canvasRef: HTMLCanvasElement | undefined;
let rafId: number | undefined;
let playStart = 0;
const heightAt = (time: number) => h0() + v0y() * time - 0.5 * g() * time * time;
const distanceAt = (time: number) => HORIZONTAL_SPEED * time;
const landingTime = createMemo(() => {
const a = -0.5 * g();
const d = discriminant(a, v0y(), h0());
const roots = computeRoots(a, v0y(), h0(), d);
const positive = roots.filter((r) => r > 0.01);
if (positive.length > 0) return Math.max(...positive);
return Math.max(2, (2 * v0y()) / g());
});
const maxX = createMemo(() => distanceAt(landingTime()) * 1.1 + 5);
const maxY = createMemo(() => {
const peak = h0() + (v0y() * v0y()) / (2 * g());
return Math.max(peak * 1.15, h0() + 2, 5);
});
const drawScene = (time: number) => {
const canvas = canvasRef;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const w = 400;
const h = 240;
const pad = { l: 36, r: 12, t: 12, b: 28 };
const plotW = w - pad.l - pad.r;
const plotH = h - pad.t - pad.b;
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const mx = maxX();
const my = maxY();
const toX = (x: number) => pad.l + (x / mx) * plotW;
const toY = (y: number) => pad.t + plotH - (y / my) * plotH;
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-canvas-bg").trim() || "#fff";
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue("--math-grid").trim() || "#e5e5e5";
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = (my * i) / 4;
const py = toY(y);
ctx.beginPath();
ctx.moveTo(pad.l, py);
ctx.lineTo(w - pad.r, py);
ctx.stroke();
}
ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue("--math-axis").trim() || "#a8a29e";
ctx.lineWidth = 2;
const groundY = toY(0);
ctx.beginPath();
ctx.moveTo(pad.l, groundY);
ctx.lineTo(w - pad.r, groundY);
ctx.stroke();
ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e";
ctx.lineWidth = 2.5;
ctx.beginPath();
const steps = 80;
const tEnd = landingTime();
for (let i = 0; i <= steps; i++) {
const ti = (tEnd * i) / steps;
const px = toX(distanceAt(ti));
const py = toY(Math.max(0, heightAt(ti)));
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.stroke();
const bx = distanceAt(time);
const by = heightAt(time);
const cx = toX(bx);
const cy = toY(Math.max(0, by));
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e";
ctx.beginPath();
ctx.arc(cx, cy, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--muted").trim() || "#767676";
ctx.font = "11px IBM Plex Mono, monospace";
ctx.fillText("x (m)", w / 2 - 12, h - 6);
ctx.save();
ctx.translate(10, h / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText("h (m)", 0, 0);
ctx.restore();
};
const stopPlayback = () => {
if (rafId !== undefined) {
cancelAnimationFrame(rafId);
rafId = undefined;
}
setPlaying(false);
};
const startPlayback = () => {
stopPlayback();
setT(0);
setPlaying(true);
playStart = performance.now();
const tick = (now: number) => {
const elapsed = (now - playStart) / 1000;
const tEnd = landingTime();
if (elapsed >= tEnd) {
setT(tEnd);
drawScene(tEnd);
stopPlayback();
return;
}
setT(elapsed);
drawScene(elapsed);
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
};
createEffect(() => {
v0y();
g();
h0();
t();
if (!playing()) drawScene(t());
});
onCleanup(() => stopPlayback());
return (
<section
class="scenario-demo scenario-demo--projectile"
aria-label="Projectile motion interactive example"
>
<div class="scenario-demo__stage">
<canvas
ref={(el) => {
canvasRef = el;
drawScene(0);
}}
class="projectile-scenario__canvas"
width={400}
height={240}
role="img"
aria-label="Side view of projectile path with horizontal and vertical position"
/>
<p class="scenario-demo__live" aria-live="polite">
t = {formatNum(t())} s · x = {formatNum(distanceAt(t()))} m · h ={" "}
{formatNum(Math.max(0, heightAt(t())))} m
</p>
<p class="scenario-demo__note scenario-demo__note--muted">
Horizontal speed is fixed at {HORIZONTAL_SPEED} m/s. Height follows
\(h = h_0 + v_0 t - \frac{1}{2}gt^2\).
</p>
</div>
<div class="scenario-demo__panel">
<div class="scenario-demo__controls">
<SliderRow
id="projectile-v0"
label="Launch speed (vertical) v₀"
value={v0y()}
min={5}
max={35}
step={1}
unit="m/s"
disabled={playing()}
onInput={setV0y}
/>
<SliderRow
id="projectile-g"
label="Gravity g"
value={g()}
min={5}
max={15}
step={0.5}
unit="m/s²"
disabled={playing()}
onInput={setG}
/>
<SliderRow
id="projectile-h0"
label="Start height h₀"
value={h0()}
min={0}
max={15}
step={1}
unit="m"
disabled={playing()}
onInput={setH0}
/>
</div>
<div class="scenario-demo__actions">
<button
type="button"
class="scenario-demo__btn scenario-demo__btn--primary"
onClick={() => (playing() ? stopPlayback() : startPlayback())}
>
{playing() ? "Stop" : "Start"}
</button>
<button
type="button"
class="scenario-demo__btn"
disabled={playing()}
onClick={() => {
stopPlayback();
setT(0);
drawScene(0);
}}
>
Reset
</button>
</div>
</div>
</section>
);
}
const PROFIT_PRICE_MIN = 5;
const PROFIT_PRICE_MAX = 20;
/** Π(p) = -2p² + 40p - 150 with Q(p) = 40 - 2p and fixed cost C = $150 */
const PROFIT_OPTIMAL_PRICE = 10;
const PROFIT_FIXED_COST = 150;
const demandQuantity = (price: number) => Math.max(0, 40 - 2 * price);
const demandPrice = (quantity: number) => Math.max(0, (40 - quantity) / 2);
const profitAtPrice = (price: number) => -2 * price * price + 40 * price - 150;
const revenueAtQ = (q: number) => q * demandPrice(q);
const costAtQ = (_q: number) => PROFIT_FIXED_COST;
const profitAtQ = (q: number) => revenueAtQ(q) - costAtQ(q);
function drawProfitChart(
canvas: HTMLCanvasElement,
price: number,
quantity: number,
revenue: number,
cost: number,
profit: number
) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const w = 400;
const h = 240;
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const bg = getComputedStyle(canvas).getPropertyValue("--math-canvas-bg").trim() || "#fff";
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
const pad = { l: 44, r: 44, t: 20, b: 40 };
const plotW = w - pad.l - pad.r;
const plotH = h - pad.t - pad.b;
const qMax = 42;
const pMax = 22;
let dollarMin = -120;
let dollarMax = 420;
for (let q = 0; q <= 40; q += 1) {
dollarMin = Math.min(dollarMin, profitAtQ(q), revenueAtQ(q));
dollarMax = Math.max(dollarMax, profitAtQ(q), revenueAtQ(q), costAtQ(q));
}
const dollarSpan = dollarMax - dollarMin || 1;
const toX = (q: number) => pad.l + (q / qMax) * plotW;
const toPriceY = (p: number) => pad.t + plotH - (p / pMax) * plotH;
const toDollarY = (d: number) => pad.t + plotH - ((d - dollarMin) / dollarSpan) * plotH;
const grid = getComputedStyle(canvas).getPropertyValue("--math-grid").trim() || "#e5e5e5";
const axis = getComputedStyle(canvas).getPropertyValue("--math-axis").trim() || "#a8a29e";
const ink = getComputedStyle(canvas).getPropertyValue("--ink").trim() || "#1a1a1a";
const muted = getComputedStyle(canvas).getPropertyValue("--muted").trim() || "#767676";
ctx.strokeStyle = grid;
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const py = pad.t + (plotH * i) / 4;
ctx.beginPath();
ctx.moveTo(pad.l, py);
ctx.lineTo(w - pad.r, py);
ctx.stroke();
}
ctx.strokeStyle = axis;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(pad.l, pad.t);
ctx.lineTo(pad.l, h - pad.b);
ctx.lineTo(w - pad.r, h - pad.b);
ctx.stroke();
const drawCurve = (
fn: (q: number) => number,
toY: (v: number) => number,
color: string,
width = 2,
dash: number[] = []
) => {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.setLineDash(dash);
ctx.beginPath();
for (let i = 0; i <= 80; i++) {
const q = (40 * i) / 80;
const px = toX(q);
const py = toY(fn(q));
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.stroke();
ctx.setLineDash([]);
};
drawCurve(demandPrice, toPriceY, getComputedStyle(canvas).getPropertyValue("--math-root").trim() || "#2563eb", 2.5);
drawCurve(revenueAtQ, toDollarY, "#4a6b4a", 2);
drawCurve(costAtQ, toDollarY, "#a8a29e", 2);
drawCurve(
profitAtQ,
toDollarY,
getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e",
2.5
);
const qOpt = demandQuantity(PROFIT_OPTIMAL_PRICE);
const pxOpt = toX(qOpt);
const pyOptP = toPriceY(PROFIT_OPTIMAL_PRICE);
const pyOptPi = toDollarY(profitAtQ(qOpt));
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--success").trim() || "#2a7d52";
ctx.beginPath();
ctx.arc(pxOpt, pyOptP, 5, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(pxOpt, pyOptPi, 5, 0, Math.PI * 2);
ctx.fill();
const qx = toX(quantity);
const py = toPriceY(price);
const pRev = toDollarY(revenue);
const pCost = toDollarY(cost);
const pProfit = toDollarY(profit);
ctx.strokeStyle = muted;
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(qx, pad.t);
ctx.lineTo(qx, h - pad.b);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-root").trim() || "#2563eb";
ctx.beginPath();
ctx.arc(qx, py, 7, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#4a6b4a";
ctx.beginPath();
ctx.arc(qx, pRev, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#a8a29e";
ctx.beginPath();
ctx.arc(qx, pCost, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e";
ctx.beginPath();
ctx.arc(qx, pProfit, 7, 0, Math.PI * 2);
ctx.fill();
ctx.font = "10px IBM Plex Mono, monospace";
ctx.fillStyle = ink;
ctx.textAlign = "center";
ctx.fillText("Quantity Q (books)", w / 2, h - 6);
ctx.save();
ctx.translate(12, h / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.fillText("Price P ($)", 0, 0);
ctx.restore();
ctx.save();
ctx.translate(w - 10, h / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.fillStyle = muted;
ctx.fillText("$ (R, C, Π)", 0, 0);
ctx.restore();
ctx.font = "9px IBM Plex Mono, monospace";
ctx.textAlign = "left";
const legendX = pad.l + 6;
let legendY = pad.t + 6;
const legend = [
{ color: getComputedStyle(canvas).getPropertyValue("--math-root").trim() || "#2563eb", label: "Demand" },
{ color: "#4a6b4a", label: "Revenue" },
{ color: "#a8a29e", label: "Cost" },
{ color: getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e", label: "Profit Π" },
];
for (const item of legend) {
ctx.fillStyle = item.color;
ctx.fillRect(legendX, legendY, 10, 3);
ctx.fillStyle = ink;
ctx.fillText(item.label, legendX + 14, legendY + 4);
legendY += 14;
}
}
export function ProfitDemo() {
const [price, setPrice] = createSignal(10);
let canvasRef: HTMLCanvasElement | undefined;
const quantity = createMemo(() => demandQuantity(price()));
const revenue = createMemo(() => price() * quantity());
const cost = createMemo(() => costAtQ(quantity()));
const profit = createMemo(() => revenue() - cost());
const atOptimum = createMemo(() => Math.abs(price() - PROFIT_OPTIMAL_PRICE) < 0.26);
const formulaHtml = createMemo(() => renderLatex(`\\Pi = -2p^2 + 40p - 150`));
const redraw = () => {
if (!canvasRef) return;
drawProfitChart(
canvasRef,
price(),
quantity(),
revenue(),
cost(),
profit()
);
};
createEffect(() => {
price();
quantity();
revenue();
cost();
profit();
redraw();
});
return (
<section
class="scenario-demo scenario-demo--profit"
aria-label="Profit and pricing interactive example"
>
<div class="scenario-demo__stage scenario-demo__stage--wide">
<p class="profit-scenario__scenario">
A textbook shop sets price <strong>${formatNum(price())}</strong>. Higher price means
lower demand: <strong>{formatNum(quantity())}</strong> books sold (demand curve). Revenue,
cost, and profit Π are read off the curves at that quantity.
</p>
<canvas
ref={(el) => {
canvasRef = el;
redraw();
}}
class="profit-scenario__canvas"
width={400}
height={240}
role="img"
aria-label="Demand, revenue, cost, and profit curves with quantity on the horizontal axis"
/>
<SliderRow
id="profit-price"
label="Price per book P"
value={price()}
min={PROFIT_PRICE_MIN}
max={PROFIT_PRICE_MAX}
step={0.5}
unit="$"
onInput={setPrice}
/>
</div>
<div class="scenario-demo__panel">
<div class="scenario-demo__formula" aria-live="polite">
<p class="scenario-demo__formula-label">Profit model</p>
<div class="scenario-demo__formula-math" innerHTML={formulaHtml()} />
</div>
<div class="scenario-demo__stats">
<div class="scenario-demo__stat">
<span class="scenario-demo__stat-label">Books sold</span>
<span class="scenario-demo__stat-value">{formatNum(quantity())}</span>
</div>
<div class="scenario-demo__stat">
<span class="scenario-demo__stat-label">Revenue</span>
<span class="scenario-demo__stat-value">${formatNum(revenue())}</span>
</div>
<div class="scenario-demo__stat">
<span class="scenario-demo__stat-label">Cost</span>
<span class="scenario-demo__stat-value">${formatNum(cost())}</span>
</div>
<div class="scenario-demo__stat scenario-demo__stat--highlight">
<span class="scenario-demo__stat-label">Profit Π</span>
<span class="scenario-demo__stat-value">${formatNum(profit())}</span>
</div>
</div>
<p
class="profit-scenario__optimum"
classList={{ "profit-scenario__optimum--on": atOptimum() }}
role="status"
aria-live="polite"
>
{atOptimum()
? `Maximum profit at P = $${formatNum(PROFIT_OPTIMAL_PRICE)} (Q = ${formatNum(demandQuantity(PROFIT_OPTIMAL_PRICE))}, Π = $${formatNum(profitAtPrice(PROFIT_OPTIMAL_PRICE))}).`
: "Profit is positive between break-even prices P = $5 and P = $15. Drag toward the peak of the gold curve."}
</p>
<p class="scenario-demo__note scenario-demo__note--muted">
Blue: demand (P vs Q). Green, gray, gold: revenue, cost, and profit in dollars (right
scale).
</p>
</div>
</section>
);
}
export function AreaDemo() {
const [perimeter, setPerimeter] = createSignal(20);
const [widthUnits, setWidthUnits] = createSignal(5);
const [dragging, setDragging] = createSignal(false);
let gridRef: HTMLDivElement | undefined;
const halfP = createMemo(() => Math.round(perimeter() / 2));
const lengthUnits = createMemo(() => halfP() - widthUnits());
const area = createMemo(() => widthUnits() * lengthUnits());
const maxArea = createMemo(() => {
const h = halfP();
let best = 0;
for (let w = 1; w < h; w += 1) {
best = Math.max(best, w * (h - w));
}
return best;
});
const isMax = createMemo(() => area() === maxArea());
const clampWidth = (w: number, h: number) =>
Math.max(1, Math.min(h - 1, Math.round(w)));
const widthFromPointer = (clientX: number) => {
const grid = gridRef;
if (!grid) return widthUnits();
const h = halfP();
const bounds = grid.getBoundingClientRect();
const cell = bounds.width / h;
const col = Math.floor((clientX - bounds.left) / cell) + 1;
return clampWidth(col, h);
};
const onCornerPointerDown = (e: PointerEvent) => {
e.preventDefault();
e.stopPropagation();
setDragging(true);
setWidthUnits(widthFromPointer(e.clientX));
const onMove = (ev: PointerEvent) => setWidthUnits(widthFromPointer(ev.clientX));
const onUp = () => {
setDragging(false);
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
};
const changePerimeter = (p: number) => {
const newHalf = Math.round(p / 2);
const oldHalf = halfP();
const ratio = oldHalf > 0 ? widthUnits() / oldHalf : 0.5;
setPerimeter(p);
setWidthUnits(clampWidth(Math.round(ratio * newHalf), newHalf));
};
createEffect(() => {
const h = halfP();
const clamped = clampWidth(widthUnits(), h);
if (widthUnits() !== clamped) setWidthUnits(clamped);
});
return (
<section
class="scenario-demo scenario-demo--area"
aria-label="Fixed perimeter area interactive example"
>
<div class="scenario-demo__stage">
<div class="area-scenario__garden">
<p class="area-scenario__fence-label">
Each square is 1 m. Perimeter P = {perimeter()} m (fixed while resizing). Drag the
corner — snaps to whole metres.
</p>
<div
class="area-scenario__grid-wrap"
ref={gridRef}
style={{ "--grid-units": String(halfP()) }}
>
<div class="area-scenario__grid" role="img" aria-hidden="true">
<For each={Array.from({ length: halfP() * halfP() })}>
{() => <span class="area-scenario__cell" />}
</For>
</div>
<div
class="area-scenario__rect"
classList={{
"area-scenario__rect--max": isMax(),
"area-scenario__rect--dragging": dragging(),
}}
style={{
width: `calc(${widthUnits()} * var(--cell-size))`,
height: `calc(${lengthUnits()} * var(--cell-size))`,
}}
>
<span class="area-scenario__dim area-scenario__dim--w">
w = {widthUnits()} m
</span>
<span class="area-scenario__dim area-scenario__dim--l">
ℓ = {lengthUnits()} m
</span>
<button
type="button"
class="area-scenario__corner"
aria-label="Resize rectangle; perimeter stays fixed"
onPointerDown={onCornerPointerDown}
/>
</div>
</div>
<p class="scenario-demo__live" aria-live="polite">
2w + 2ℓ = {perimeter()} m · w + ℓ = {halfP()} m
</p>
</div>
<SliderRow
id="area-perimeter"
label="Set fence perimeter P"
value={perimeter()}
min={12}
max={40}
step={2}
unit="m"
disabled={dragging()}
onInput={changePerimeter}
/>
</div>
<div class="scenario-demo__panel">
<div class="scenario-demo__formula" aria-live="polite">
<p class="scenario-demo__formula-label">Area model</p>
<div
class="scenario-demo__formula-math"
innerHTML={renderLatex(
`A = w(\\tfrac{${perimeter()}}{2} - w) = ${widthUnits()} \\times ${lengthUnits()} = ${area()}`
)}
/>
</div>
<div class="scenario-demo__stats">
<div class="scenario-demo__stat scenario-demo__stat--highlight">
<span class="scenario-demo__stat-label">Area A</span>
<span class="scenario-demo__stat-value">{area()} m²</span>
</div>
<div class="scenario-demo__stat">
<span class="scenario-demo__stat-label">Largest possible</span>
<span class="scenario-demo__stat-value">{maxArea()} m²</span>
</div>
</div>
<p
class="area-scenario__badge area-scenario__badge--panel"
classList={{ "area-scenario__badge--visible": isMax() }}
role="status"
aria-live="polite"
>
{isMax()
? `Maximum area on this grid — ${widthUnits()} m × ${lengthUnits()} m (A = ${area()} m²).`
: "\u00a0"}
</p>
</div>
</section>
);
}

View file

@ -0,0 +1,195 @@
export const VIEW_SIZE = 400;
const PAD = 36;
export type GraphBounds = {
xMin: number;
xMax: number;
yMin: number;
yMax: number;
};
export type GraphColors = {
grid: string;
axis: string;
curve: string;
marker: string;
root: string;
bg: string;
};
export function formatNum(n: number, digits = 2) {
if (Number.isInteger(n)) return String(n);
const p = 10 ** digits;
return String(Math.round(n * p) / p);
}
export function discriminant(a: number, b: number, c: number) {
return b * b - 4 * a * c;
}
export function computeRoots(a: number, b: number, c: number, delta: number) {
if (delta < 0 || Math.abs(a) < 1e-9) return [] as number[];
if (delta === 0) return [-b / (2 * a)];
const sqrtDelta = Math.sqrt(delta);
return [(-b + sqrtDelta) / (2 * a), (-b - sqrtDelta) / (2 * a)].sort((x, y) => x - y);
}
export function natureOfRoots(delta: number) {
if (delta > 0) return "Two distinct real roots";
if (delta === 0) return "One repeated real root";
return "No real roots";
}
export function readGraphColors(canvas: HTMLCanvasElement): GraphColors {
const style = getComputedStyle(canvas);
return {
grid: style.getPropertyValue("--math-grid").trim() || "#e5e5e5",
axis: style.getPropertyValue("--math-axis").trim() || "#a8a29e",
curve: style.getPropertyValue("--math-curve").trim() || "#9a6b2e",
marker: style.getPropertyValue("--math-vertex").trim() || "#4a6b4a",
root: style.getPropertyValue("--math-root").trim() || "#2563eb",
bg: style.getPropertyValue("--math-canvas-bg").trim() || "#ffffff",
};
}
function toCanvasX(x: number, bounds: GraphBounds) {
const w = VIEW_SIZE - PAD * 2;
return PAD + ((x - bounds.xMin) / (bounds.xMax - bounds.xMin)) * w;
}
function toCanvasY(y: number, bounds: GraphBounds) {
const h = VIEW_SIZE - PAD * 2;
return VIEW_SIZE - PAD - ((y - bounds.yMin) / (bounds.yMax - bounds.yMin)) * h;
}
function niceStep(span: number) {
const raw = span / 5;
const mag = 10 ** Math.floor(Math.log10(raw));
const norm = raw / mag;
if (norm < 1.5) return mag;
if (norm < 3.5) return 2 * mag;
if (norm < 7.5) return 5 * mag;
return 10 * mag;
}
export function drawGraph(
canvas: HTMLCanvasElement,
bounds: GraphBounds,
fn: (x: number) => number,
options?: {
roots?: number[];
highlightX?: number;
highlightLabel?: string;
xLabel?: string;
yLabel?: string;
}
) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = VIEW_SIZE * dpr;
canvas.height = VIEW_SIZE * dpr;
canvas.style.width = `${VIEW_SIZE}px`;
canvas.style.height = `${VIEW_SIZE}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const colors = readGraphColors(canvas);
ctx.fillStyle = colors.bg;
ctx.fillRect(0, 0, VIEW_SIZE, VIEW_SIZE);
const xStep = niceStep(bounds.xMax - bounds.xMin);
const yStep = niceStep(bounds.yMax - bounds.yMin);
ctx.strokeStyle = colors.grid;
ctx.lineWidth = 1;
for (let x = Math.ceil(bounds.xMin / xStep) * xStep; x <= bounds.xMax; x += xStep) {
const px = toCanvasX(x, bounds);
ctx.beginPath();
ctx.moveTo(px, PAD);
ctx.lineTo(px, VIEW_SIZE - PAD);
ctx.stroke();
}
for (let y = Math.ceil(bounds.yMin / yStep) * yStep; y <= bounds.yMax; y += yStep) {
const py = toCanvasY(y, bounds);
ctx.beginPath();
ctx.moveTo(PAD, py);
ctx.lineTo(VIEW_SIZE - PAD, py);
ctx.stroke();
}
const xZero = toCanvasX(0, bounds);
const yZero = toCanvasY(0, bounds);
if (0 >= bounds.xMin && 0 <= bounds.xMax) {
ctx.strokeStyle = colors.axis;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xZero, PAD);
ctx.lineTo(xZero, VIEW_SIZE - PAD);
ctx.stroke();
}
if (0 >= bounds.yMin && 0 <= bounds.yMax) {
ctx.strokeStyle = colors.axis;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(PAD, yZero);
ctx.lineTo(VIEW_SIZE - PAD, yZero);
ctx.stroke();
}
ctx.strokeStyle = colors.curve;
ctx.lineWidth = 3;
ctx.lineCap = "round";
ctx.beginPath();
const samples = 120;
let started = false;
for (let i = 0; i <= samples; i++) {
const x = bounds.xMin + ((bounds.xMax - bounds.xMin) * i) / samples;
const y = fn(x);
const px = toCanvasX(x, bounds);
const py = toCanvasY(y, bounds);
if (!started) {
ctx.moveTo(px, py);
started = true;
} else {
ctx.lineTo(px, py);
}
}
ctx.stroke();
const drawDot = (x: number, y: number, fill: string, radius = 6) => {
ctx.fillStyle = fill;
ctx.beginPath();
ctx.arc(toCanvasX(x, bounds), toCanvasY(y, bounds), radius, 0, Math.PI * 2);
ctx.fill();
};
for (const root of options?.roots ?? []) {
if (root >= bounds.xMin && root <= bounds.xMax) {
drawDot(root, 0, colors.root);
}
}
if (options?.highlightX !== undefined) {
const hx = options.highlightX;
const hy = fn(hx);
if (hx >= bounds.xMin && hx <= bounds.xMax && hy >= bounds.yMin && hy <= bounds.yMax) {
drawDot(hx, hy, colors.marker, 7);
}
}
ctx.fillStyle = colors.axis;
ctx.font = "11px IBM Plex Mono, monospace";
ctx.textAlign = "center";
if (options?.xLabel) {
ctx.fillText(options.xLabel, VIEW_SIZE / 2, VIEW_SIZE - 10);
}
if (options?.yLabel) {
ctx.save();
ctx.translate(12, VIEW_SIZE / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText(options.yLabel, 0, 0);
ctx.restore();
}
}

View file

@ -78,13 +78,40 @@ if (copyPageRoot) {
});
}
const quadraticRoot = document.getElementById("quadratic-explorer");
if (quadraticRoot) {
const hasQuadraticChapter =
document.getElementById("quadratic-explorer") ||
document.getElementById("projectile-demo") ||
document.getElementById("profit-demo") ||
document.getElementById("area-demo");
const chapterNavRoot = document.getElementById("chapter-heading-nav");
if (chapterNavRoot) {
void import("./components/chapter-heading-nav").then(({ ChapterHeadingNav }) => {
render(() => <ChapterHeadingNav />, chapterNavRoot);
});
}
if (hasQuadraticChapter) {
void Promise.all([
import("./components/quadratic-explorer"),
import("./components/quadratic-application-demos"),
import("./lib/render-math"),
]).then(([{ QuadraticExplorer }, { renderBookMath }]) => {
render(() => <QuadraticExplorer />, quadraticRoot);
]).then(([{ QuadraticExplorer }, demos, { renderBookMath }]) => {
const quadraticRoot = document.getElementById("quadratic-explorer");
if (quadraticRoot) {
render(() => <QuadraticExplorer />, quadraticRoot);
}
const appDemos: Array<[string, () => import("solid-js").JSX.Element]> = [
["projectile-demo", demos.ProjectileDemo],
["profit-demo", demos.ProfitDemo],
["area-demo", demos.AreaDemo],
];
for (const [id, Demo] of appDemos) {
const root = document.getElementById(id);
if (root) {
render(() => <Demo />, root);
}
}
requestAnimationFrame(() => renderBookMath());
});
}

25
spec.md
View file

@ -13,3 +13,28 @@ we don't i18n. i18n will be solved in future by big companies.
search is supported by pagefind.
we have a catalog of subjects. Each subject will have chapters. Each chapters can be labelled (multiple) cirriculum. E.g. DSE, IB, A level. so that user knows if a chapter is needed in a cirriculum. Each subject should show all chapters by default (all cirriculum). chapters should be sequential in a tree structure. users can filter by themselves. in the chapter we don't specify or mention the cirriculum anymore.
we need to have a question bank feature. since this is purely static sites, question answer needs to be determined and able to grade in advanced.
1. multiple-choice
2. short-answer
3. fill-in-the-blank
4. choice
5. ranking
question bank not only show answer but also steps and calcultion
## Chapters requirements
### Maths
each chapter needs to have the following other than the core content:
1. history - when did people first study this? why? how?
2. derivation / calculation
3. check point / thought provoking questions
4. real life applications
5. questions bank - some exams questions from easy, intermediate, difficult, hardcore
6. reference (if any)
the order is that core content (with some interactive demos) -> history -> derivation -> check point -> applications -> question bank

View file

@ -242,7 +242,45 @@ a.book-chapter-link:focus-visible {
.book-prose__heading {
margin: 2em 0 0.6em;
}
.book-prose h2.book-prose__heading {
font-size: 1.35rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.book-prose h3.book-prose__heading {
margin-top: 1.35em;
font-size: 1.1rem;
font-weight: 500;
color: var(--soft-ink);
}
.book-prose h2.book-prose__heading,
.book-prose h3.book-prose__heading {
scroll-margin-top: 96px;
}
.book-figure {
margin: 1.5em 0 1.75em;
max-width: 100%;
}
.book-figure__img {
display: block;
width: 100%;
height: auto;
border-radius: 10px;
border: 1px solid var(--line);
box-shadow: 0 8px 24px var(--shadow-soft);
}
.book-figure__caption {
margin: 0.65em 0 0;
font-size: 0.88rem;
line-height: 1.5;
color: var(--muted);
}
.book-formula {

View file

@ -0,0 +1,224 @@
.chapter-heading-nav {
pointer-events: none;
}
.chapter-heading-nav > * {
pointer-events: auto;
}
.chapter-heading-nav__rail-wrap {
position: fixed;
top: 50%;
right: 20px;
z-index: 40;
transform: translateY(-50%);
}
.chapter-heading-nav__rail {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
padding: 10px 6px;
}
.chapter-heading-nav__tick {
display: block;
width: 18px;
height: 3px;
padding: 0;
border: none;
border-radius: 999px;
background: var(--line);
cursor: pointer;
transition: width 160ms ease, background 160ms ease, opacity 160ms ease;
}
.chapter-heading-nav__tick--h3 {
width: 12px;
opacity: 0.75;
}
.chapter-heading-nav__tick--active {
width: 26px;
background: var(--ink);
}
.chapter-heading-nav__tick--h3.chapter-heading-nav__tick--active {
width: 18px;
}
.chapter-heading-nav__popover {
position: absolute;
right: 36px;
top: 50%;
transform: translateY(-50%);
width: min(260px, calc(100vw - 80px));
padding: 14px 12px;
border: 1px solid var(--card-border);
border-radius: 12px;
background-color: #f5f4f1;
color: var(--ink);
isolation: isolate;
box-shadow: 0 18px 48px var(--shadow-soft);
}
[data-theme="dark"] .chapter-heading-nav__popover {
background-color: #1a1917;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .chapter-heading-nav__popover {
background-color: #1a1917;
}
}
.chapter-heading-nav__popover-title {
margin: 0 0 10px;
font-family: var(--mono);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.chapter-heading-nav__list {
margin: 0;
padding: 0;
list-style: none;
}
.chapter-heading-nav__item--h3 .chapter-heading-nav__link {
padding-left: 14px;
font-size: 0.86rem;
}
.chapter-heading-nav__link {
display: block;
width: 100%;
padding: 6px 8px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--soft-ink);
font: inherit;
font-size: 0.9rem;
text-align: left;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.chapter-heading-nav__link:hover,
.chapter-heading-nav__item--active .chapter-heading-nav__link {
background: rgba(154, 107, 46, 0.1);
color: var(--ink);
}
.chapter-heading-nav__fab {
position: fixed;
right: 16px;
bottom: 20px;
z-index: 45;
padding: 10px 16px;
border: 1px solid var(--card-border);
border-radius: 999px;
background-color: #f5f4f1;
isolation: isolate;
box-shadow: 0 8px 24px var(--shadow-soft);
color: var(--ink);
font-family: var(--mono);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
}
[data-theme="dark"] .chapter-heading-nav__fab {
background-color: #1a1917;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .chapter-heading-nav__fab {
background-color: #1a1917;
}
}
.chapter-heading-nav__backdrop {
position: fixed;
inset: 0;
z-index: 46;
border: none;
background: rgba(26, 26, 26, 0.45);
cursor: pointer;
}
[data-theme="dark"] .chapter-heading-nav__backdrop {
background: rgba(0, 0, 0, 0.62);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .chapter-heading-nav__backdrop {
background: rgba(0, 0, 0, 0.62);
}
}
.chapter-heading-nav__sheet {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 47;
max-height: 70vh;
padding: 16px 18px 28px;
overflow-y: auto;
border-radius: 16px 16px 0 0;
border: 1px solid var(--card-border);
background-color: #f5f4f1;
color: var(--ink);
isolation: isolate;
box-shadow: 0 -18px 48px var(--shadow-soft);
}
[data-theme="dark"] .chapter-heading-nav__sheet {
background-color: #1a1917;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .chapter-heading-nav__sheet {
background-color: #1a1917;
}
}
.chapter-heading-nav__sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.chapter-heading-nav__sheet-close {
padding: 4px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--muted);
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
}
@media (max-width: 899px) {
.chapter-heading-nav__rail-wrap {
display: none;
}
}
@media (min-width: 900px) {
.chapter-heading-nav__fab,
.chapter-heading-nav__backdrop,
.chapter-heading-nav__sheet {
display: none;
}
}

View file

@ -13,7 +13,10 @@ body::before {
/* Top-layer modals must stay opaque; grain blend would otherwise show through. */
.contributor-dialog[open],
.contributor-dialog[open] .contributor-dialog-panel,
pagefind-modal dialog.pf-modal[open] {
pagefind-modal dialog.pf-modal[open],
.chapter-heading-nav__popover,
.chapter-heading-nav__sheet,
.chapter-heading-nav__fab {
mix-blend-mode: normal;
}

View file

@ -1,3 +1,11 @@
.math-widget--application {
margin-top: 1.25rem;
}
.math-widget--application .math-widget__title {
font-size: 1.15rem;
}
.math-widget {
--math-grid: #e5e5e5;
--math-axis: #a8a29e;

View file

@ -0,0 +1,466 @@
.scenario-demo {
--math-grid: #e5e5e5;
--math-axis: #a8a29e;
--math-curve: #9a6b2e;
--math-root: #2563eb;
--math-canvas-bg: #ffffff;
display: grid;
gap: 20px;
margin-top: 1.25rem;
padding: 22px;
border: 1px solid var(--card-border);
border-radius: 14px;
background: var(--card-bg);
box-shadow: 0 10px 32px var(--shadow-soft);
}
[data-theme="dark"] .scenario-demo {
--math-grid: #3a3834;
--math-axis: #8f8a80;
--math-curve: #c4924a;
--math-root: #60a5fa;
--math-canvas-bg: #242120;
}
@media (min-width: 768px) {
.scenario-demo {
grid-template-columns: 1fr 1fr;
align-items: start;
}
}
.scenario-demo__stage {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 220px;
}
.scenario-demo__panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.scenario-demo__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 10px;
}
.scenario-demo__stat {
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(154, 107, 46, 0.04);
}
.scenario-demo__stat--highlight {
border-color: var(--accent);
background: rgba(154, 107, 46, 0.1);
}
.scenario-demo__stat-label {
display: block;
margin-bottom: 4px;
font-family: var(--mono);
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.scenario-demo__stat-value {
font-size: 1rem;
font-weight: 600;
color: var(--ink);
}
.scenario-demo__note {
margin: 0;
font-size: 0.88rem;
line-height: 1.5;
color: var(--soft-ink);
}
.scenario-demo__note--muted {
color: var(--muted);
font-size: 0.82rem;
}
.scenario-demo__controls {
display: flex;
flex-direction: column;
gap: 4px;
}
.scenario-demo__control {
margin-bottom: 8px;
}
.scenario-demo__label {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
font-size: 0.86rem;
color: var(--soft-ink);
}
.scenario-demo__value {
font-family: var(--mono);
font-weight: 700;
color: var(--accent);
}
.scenario-demo__slider {
width: 100%;
accent-color: var(--accent);
}
.scenario-demo__slider:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.scenario-demo__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
}
.scenario-demo__btn {
padding: 10px 18px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--card-bg);
color: var(--ink);
font-family: var(--mono);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
}
.scenario-demo__btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.scenario-demo__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.scenario-demo__btn--primary {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
.scenario-demo__btn--primary:hover:not(:disabled) {
filter: brightness(1.05);
color: #fff;
}
.scenario-demo__live {
margin: 0;
font-family: var(--mono);
font-size: 0.82rem;
color: var(--soft-ink);
}
.scenario-demo__formula {
padding: 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(154, 107, 46, 0.05);
}
.scenario-demo__formula-label {
margin: 0 0 8px;
font-family: var(--mono);
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.scenario-demo__formula-math {
font-size: 1rem;
}
.scenario-demo__stage--wide {
grid-column: 1 / -1;
}
@media (min-width: 768px) {
.scenario-demo__stage--wide {
grid-column: auto;
}
}
/* Projectile */
.projectile-scenario__canvas {
display: block;
width: 100%;
max-width: 400px;
border-radius: 10px;
border: 1px solid var(--line);
}
.projectile-scenario__track {
position: relative;
flex: 1;
min-height: 200px;
border-radius: 12px;
border: 1px solid var(--line);
overflow: hidden;
}
.projectile-scenario__sky {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(96, 165, 250, 0.2) 0%, rgba(154, 107, 46, 0.06) 100%);
}
.projectile-scenario__ground {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 12px;
background: #6b5a45;
}
.projectile-scenario__ball {
position: absolute;
left: 50%;
width: 22px;
height: 22px;
margin-left: -11px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #f97316, #c2410c);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transition: bottom 120ms ease-out;
}
.projectile-scenario__ground-hit {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
padding: 2px 8px;
border-radius: 6px;
background: var(--card-bg);
font-family: var(--mono);
font-size: 0.68rem;
color: var(--accent);
}
/* Profit */
.profit-scenario__shop {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 24px;
border-radius: 12px;
border: 1px dashed var(--accent);
background: rgba(154, 107, 46, 0.06);
text-align: center;
}
.profit-scenario__sign {
margin: 0;
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.profit-scenario__price-tag {
margin: 0;
font-size: 2.5rem;
font-weight: 700;
color: var(--accent);
line-height: 1;
}
.profit-scenario__per {
margin: 0;
font-size: 0.85rem;
color: var(--muted);
}
.profit-scenario__units {
margin: 8px 0 0;
font-size: 0.95rem;
font-weight: 500;
color: var(--ink);
}
.profit-scenario__scenario {
margin: 0;
font-size: 0.92rem;
line-height: 1.55;
color: var(--soft-ink);
}
.profit-scenario__canvas {
display: block;
width: 100%;
max-width: 400px;
border-radius: 10px;
border: 1px solid var(--line);
}
/* Area */
.area-scenario__garden {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
border-radius: 12px;
border: 1px solid var(--line);
background: rgba(74, 107, 74, 0.08);
}
.area-scenario__fence-label {
margin: 0;
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
.area-scenario__grid-wrap {
--cell-size: calc(100% / var(--grid-units, 10));
position: relative;
width: min(100%, 280px);
aspect-ratio: 1;
margin-inline: auto;
border: 2px solid #6b5a45;
border-radius: 8px;
touch-action: none;
}
.area-scenario__grid {
display: grid;
grid-template-columns: repeat(var(--grid-units, 10), 1fr);
grid-template-rows: repeat(var(--grid-units, 10), 1fr);
width: 100%;
height: 100%;
gap: 1px;
padding: 4px;
background: #6b5a45;
border-radius: 6px;
}
.area-scenario__cell {
display: block;
background: rgba(134, 168, 106, 0.45);
border-radius: 1px;
}
.area-scenario__rect {
position: absolute;
left: 4px;
bottom: 4px;
box-sizing: border-box;
border: 2px solid var(--accent);
border-radius: 3px;
background: rgba(154, 107, 46, 0.55);
transition:
width 80ms ease,
height 80ms ease;
}
.area-scenario__rect--dragging {
transition: none;
}
.area-scenario__rect--max {
border-color: var(--success);
background: rgba(42, 125, 82, 0.45);
}
.area-scenario__corner {
position: absolute;
right: -7px;
bottom: -7px;
width: 16px;
height: 16px;
padding: 0;
border: 2px solid var(--paper);
border-radius: 50%;
background: var(--accent);
cursor: nwse-resize;
touch-action: none;
}
.area-scenario__dim {
position: absolute;
font-family: var(--mono);
font-size: 0.65rem;
color: var(--ink);
white-space: nowrap;
}
.area-scenario__dim--w {
top: -18px;
left: 0;
}
.area-scenario__dim--l {
top: 4px;
right: 4px;
}
.area-scenario__badge {
margin: 0;
padding: 6px 10px;
border-radius: 8px;
background: rgba(42, 125, 82, 0.15);
color: var(--success);
font-size: 0.82rem;
font-weight: 600;
text-align: center;
}
.area-scenario__badge--panel {
min-height: 2.75rem;
visibility: hidden;
opacity: 0;
}
.area-scenario__badge--panel.area-scenario__badge--visible {
visibility: visible;
opacity: 1;
}
.profit-scenario__optimum {
margin: 0;
padding: 8px 10px;
border-radius: 8px;
font-size: 0.82rem;
line-height: 1.45;
color: var(--muted);
background: rgba(154, 107, 46, 0.06);
}
.profit-scenario__optimum--on {
color: var(--success);
font-weight: 600;
background: rgba(42, 125, 82, 0.15);
}

View file

@ -12,3 +12,5 @@
@import "catalog";
@import "search";
@import "math-widget";
@import "scenario-demo";
@import "chapter-heading-nav";

View file

@ -35,8 +35,10 @@
</header>
{% if section.extra.chapter_id == "quadratic-equations" %}
{% include "partials/math/quadratic-equations-content.html" %}
<div id="chapter-heading-nav" data-pagefind-ignore></div>
{% include "partials/math/quadratic-equations-core-content.html" %}
<div id="quadratic-explorer" class="math-widget-mount" data-pagefind-ignore></div>
{% include "partials/math/quadratic-equations-supplement-content.html" %}
{% endif %}
</article>
</div>

View file

@ -73,7 +73,7 @@ themes/.../partials/... → chapter HTML bodies (today)</code></pre>
<h3 class="contributing-section__subtitle">2. Zola section</h3>
<p>Create <code>content/{subject}/{slug}/_index.md</code> with <code>chapter_id</code> matching the slug.</p>
<h3 class="contributing-section__subtitle">3. Chapter HTML</h3>
<p>Add <code>themes/openfreebooks/templates/partials/math/your-slug-content.html</code> and wire it in <code>math/chapter.html</code>. Copy patterns from <code>quadratic-equations-content.html</code>.</p>
<p>Add <code>themes/openfreebooks/templates/partials/math/your-slug-content.html</code> and wire it in <code>math/chapter.html</code>. Copy patterns from <code>quadratic-equations-core-content.html</code>.</p>
<h3 class="contributing-section__subtitle">4. Optional widget</h3>
<p>Add a Solid component under <code>frontend/src/components/</code> and mount it in <code>main.tsx</code>. Run <code>bun run build:js</code>.</p>
</div>

View file

@ -5,7 +5,7 @@
formula, or by reading information from the graph of \(y = ax^2 + bx + c\).
</p>
<h2 class="book-prose__heading">The discriminant</h2>
<h2 id="discriminant" class="book-prose__heading">The discriminant</h2>
<p>
The <strong>discriminant</strong> \(\Delta\) (delta) is defined below. It tells you how many
<em>real</em> roots the equation has without fully solving it.
@ -16,7 +16,7 @@
<p class="book-formula">\[ \Delta = b^2 - 4ac \]</p>
</div>
<h2 class="book-prose__heading">Nature of roots</h2>
<h2 id="nature-of-roots" class="book-prose__heading">Nature of roots</h2>
<div class="book-table-wrap">
<table class="book-table">
<thead>
@ -51,7 +51,7 @@
\[ x = \frac{-b \pm \sqrt{\Delta}}{2a} \]
</p>
<h2 class="book-prose__heading">Try it yourself</h2>
<h2 id="try-it-yourself" class="book-prose__heading">Try it yourself</h2>
<p>
Use the explorer below to change \(a\), \(b\), and \(c\). Watch how \(\Delta\) and the
parabola change, and read off the nature of the roots.

View file

@ -0,0 +1,162 @@
<div class="book-prose">
<h2 id="history" class="book-prose__heading">History</h2>
<p>
People have worked on problems that lead to quadratic equations for thousands of years.
Babylonian clay tablets from around 1800&nbsp;BCE record area and length puzzles — for example,
finding the sides of a rectangle when you know its area and perimeter. Those situations
produce equations in one unknown whose highest power is two.
</p>
<figure class="book-figure">
<img
class="book-figure__img"
src="{{ get_url(path='img/math/babylonian-quadratic-tablet.png', trailing_slash=false) | safe }}"
alt="Babylonian clay tablet with cuneiform text recording a quadratic area problem"
width="960"
height="640"
loading="lazy"
decoding="async"
/>
<figcaption class="book-figure__caption">
A Babylonian clay tablet (about 1800&nbsp;BCE) with a problem equivalent to finding the
sides of a rectangle from its area and perimeter.
</figcaption>
</figure>
<p>
Early methods were often geometric: you imagine completing a rectangle to make a square,
which is the same idea behind <em>completing the square</em> today. In the 9th century,
<strong>al-Khwarizmi</strong> wrote systematic rules for many types of quadratic problems,
sorted by which coefficients were present. He did not use modern symbols like \(x^2\), but
the logic matches what we still teach.
</p>
<p>
During the Renaissance, mathematicians such as <strong>Cardano</strong> and
<strong>Tartaglia</strong> pushed further into solving cubics and quartics. Along the way they
met equations whose discriminants were negative — no real roots, yet the algebra still
demanded answers. That tension helped open the door to <strong>complex numbers</strong>, a
topic you will meet in a later chapter.
</p>
<h2 id="derivation" class="book-prose__heading">Derivation</h2>
<p>
The quadratic formula comes from rearranging \(ax^2 + bx + c = 0\) and completing the square.
Below is a guided derivation; routine algebra between steps is left for you to check.
</p>
<p>
Start with \(a \neq 0\) and divide every term by \(a\):
\[ x^2 + \frac{b}{a}x + \frac{c}{a} = 0. \]
Move the constant to the right:
\[ x^2 + \frac{b}{a}x = -\frac{c}{a}. \]
</p>
<p>
The expression \(x^2 + \frac{b}{a}x\) is almost a perfect square. Add and subtract
\(\left(\frac{b}{2a}\right)^2\) on the left (equivalently, add that square to both sides):
\[ x^2 + \frac{b}{a}x + \left(\frac{b}{2a}\right)^2 = -\frac{c}{a} + \left(\frac{b}{2a}\right)^2. \]
The left side factors as a square:
\[ \left(x + \frac{b}{2a}\right)^2 = \frac{b^2 - 4ac}{4a^2}. \]
</p>
<p>
Take square roots (when the right-hand side is non-negative you get real solutions):
\[ x + \frac{b}{2a} = \pm \frac{\sqrt{b^2 - 4ac}}{2a}. \]
Solve for \(x\):
</p>
<div class="book-callout book-callout--formula" role="note">
<p class="book-callout__label">Quadratic formula</p>
<p class="book-formula">
\[ x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a} \]
</p>
</div>
<p>
The quantity under the square root is exactly the discriminant:
\(\Delta = b^2 - 4ac\). So the formula is \(x = \frac{-b \pm \sqrt{\Delta}}{2a}\) whenever
\(\Delta \geq 0\). If \(\Delta &gt; 0\) you get two distinct real roots; if \(\Delta = 0\) the
\(\pm\) gives the same root twice; if \(\Delta &lt; 0\) there is no real square root and the
equation has no real solutions — matching the nature-of-roots table above.
</p>
<h2 id="checkpoints" class="book-prose__heading">Checkpoints</h2>
<p>
Pause and think before moving on. There are no answers on this page — discuss with a
classmate or write your reasoning in a notebook.
</p>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
In the explorer, keep \(a\) and \(b\) fixed and slowly change \(c\) so that \(\Delta\) goes
from positive to zero. What happens to the two x-intercepts on the graph?
</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
Can \(ax^2 + bx + c = 0\) with \(a \neq 0\) have three distinct real solutions? Why or why not?
</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
Suppose \(a &gt; 0\) and \(\Delta &lt; 0\). Where does the entire parabola \(y = ax^2 + bx + c\)
sit relative to the x-axis?
</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
A ball is thrown straight upward from ground level. Without using numbers, describe a
situation where \(\Delta &gt; 0\) for height versus time, and another where \(\Delta = 0\).
</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>
After completing the square you had \(\left(x + \frac{b}{2a}\right)^2 = \frac{\Delta}{4a^2}\).
Why must the sign of \(\Delta\) match whether the parabola crosses the x-axis?
</p>
</div>
<h2 id="applications" class="book-prose__heading">Applications</h2>
<h3 id="projectile-motion" class="book-prose__heading">Projectile motion</h3>
<p>
Near Earth’s surface, height \(h\) (in metres) of an object thrown upward often follows
\[ h = -\tfrac{1}{2}gt^2 + v_0 t + h_0, \]
where \(t\) is time in seconds, \(g \approx 9.8\) is gravitational acceleration,
\(v_0\) is initial upward speed, and \(h_0\) is starting height. This is a quadratic in
\(t\). Setting \(h = 0\) asks when the object is at ground level; the solutions are the
landing times. The discriminant tells you whether there are two crossing times
(\(\Delta &gt; 0\)), one (\(\Delta = 0\)), or none (\(\Delta &lt; 0\)) — for example if the
model never reaches the ground in the time you care about.
</p>
<div id="projectile-demo" class="math-widget-mount" data-pagefind-ignore></div>
<h3 id="profit-pricing" class="book-prose__heading">Profit and pricing</h3>
<p>
A shop might find that when it charges \(p\) dollars per item, revenue is \(p\) times
quantity sold, while cost has a fixed part plus a cost per item. Profit (revenue minus cost)
is often a quadratic in \(p\) that opens downward — there is a best price at the vertex.
For example, with profit \(\Pi\) in dollars,
\[ \Pi(p) = -2p^2 + 40p - 150. \]
Setting \(\Pi = 0\) asks for <em>break-even</em> prices; \(\Delta\) tells you whether two
break-even prices exist, one, or none.
</p>
<div id="profit-demo" class="math-widget-mount" data-pagefind-ignore></div>
<h3 id="area-fixed-perimeter" class="book-prose__heading">Area with fixed perimeter</h3>
<p>
A rectangle with perimeter \(P\) can be described by width \(x\) and length
\(\frac{P}{2} - x\). Its area is
\[ A(x) = x\left(\frac{P}{2} - x\right) = -\!x^2 + \frac{P}{2}x, \]
a downward-opening parabola. The largest area occurs at the vertex, not at the extremes
\(x = 0\) or \(x = \frac{P}{2}\) (which give zero area).
</p>
<div id="area-demo" class="math-widget-mount" data-pagefind-ignore></div>
</div>