diff --git a/.agents/skills/ofb/SKILL.md b/.agents/skills/ofb/SKILL.md index 939edd6..c5c7605 100644 --- a/.agents/skills/ofb/SKILL.md +++ b/.agents/skills/ofb/SKILL.md @@ -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` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3aa55a..de69391 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/frontend/src/components/chapter-heading-nav.tsx b/frontend/src/components/chapter-heading-nav.tsx new file mode 100644 index 0000000..8e9d505 --- /dev/null +++ b/frontend/src/components/chapter-heading-nav.tsx @@ -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( + ".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([]); + const [activeId, setActiveId] = createSignal(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(".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 }) => ( +
    + + {(item) => ( +
  • + +
  • + )} +
    +
+ ); + + return ( + 0}> + + + ); +} diff --git a/frontend/src/components/quadratic-application-demos.tsx b/frontend/src/components/quadratic-application-demos.tsx new file mode 100644 index 0000000..d91b3d4 --- /dev/null +++ b/frontend/src/components/quadratic-application-demos.tsx @@ -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 ( +
+ + props.onInput(Number(e.currentTarget.value))} + /> +
+ ); +} + +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 ( +
+
+ { + 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" + /> +

+ t = {formatNum(t())} s · x = {formatNum(distanceAt(t()))} m · h ={" "} + {formatNum(Math.max(0, heightAt(t())))} m +

+

+ Horizontal speed is fixed at {HORIZONTAL_SPEED} m/s. Height follows + \(h = h_0 + v_0 t - \frac{1}{2}gt^2\). +

+
+
+
+ + + +
+
+ + +
+
+
+ ); +} + +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 ( +
+
+

+ A textbook shop sets price ${formatNum(price())}. Higher price means + lower demand: {formatNum(quantity())} books sold (demand curve). Revenue, + cost, and profit Π are read off the curves at that quantity. +

+ { + 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" + /> + +
+
+
+

Profit model

+
+
+
+
+ Books sold + {formatNum(quantity())} +
+
+ Revenue + ${formatNum(revenue())} +
+
+ Cost + ${formatNum(cost())} +
+
+ Profit Π + ${formatNum(profit())} +
+
+

+ {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."} +

+

+ Blue: demand (P vs Q). Green, gray, gold: revenue, cost, and profit in dollars (right + scale). +

+
+
+ ); +} + +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 ( +
+
+
+

+ Each square is 1 m. Perimeter P = {perimeter()} m (fixed while resizing). Drag the + corner — snaps to whole metres. +

+
+ +
+ + w = {widthUnits()} m + + + ℓ = {lengthUnits()} m + +
+
+

+ 2w + 2ℓ = {perimeter()} m · w + ℓ = {halfP()} m +

+
+ +
+
+
+

Area model

+
+
+
+
+ Area A + {area()} m² +
+
+ Largest possible + {maxArea()} m² +
+
+

+ {isMax() + ? `Maximum area on this grid — ${widthUnits()} m × ${lengthUnits()} m (A = ${area()} m²).` + : "\u00a0"} +

+
+
+ ); +} diff --git a/frontend/src/lib/math-graph.ts b/frontend/src/lib/math-graph.ts new file mode 100644 index 0000000..28f60e9 --- /dev/null +++ b/frontend/src/lib/math-graph.ts @@ -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(); + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 14262fe..1c1f754 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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(() => , chapterNavRoot); + }); +} + +if (hasQuadraticChapter) { void Promise.all([ import("./components/quadratic-explorer"), + import("./components/quadratic-application-demos"), import("./lib/render-math"), - ]).then(([{ QuadraticExplorer }, { renderBookMath }]) => { - render(() => , quadraticRoot); + ]).then(([{ QuadraticExplorer }, demos, { renderBookMath }]) => { + const quadraticRoot = document.getElementById("quadratic-explorer"); + if (quadraticRoot) { + render(() => , 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(() => , root); + } + } requestAnimationFrame(() => renderBookMath()); }); } diff --git a/spec.md b/spec.md index 35d4885..739402f 100644 --- a/spec.md +++ b/spec.md @@ -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 \ No newline at end of file diff --git a/themes/openfreebooks/sass/_book.scss b/themes/openfreebooks/sass/_book.scss index 67e1210..49b59f0 100644 --- a/themes/openfreebooks/sass/_book.scss +++ b/themes/openfreebooks/sass/_book.scss @@ -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 { diff --git a/themes/openfreebooks/sass/_chapter-heading-nav.scss b/themes/openfreebooks/sass/_chapter-heading-nav.scss new file mode 100644 index 0000000..59b8396 --- /dev/null +++ b/themes/openfreebooks/sass/_chapter-heading-nav.scss @@ -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; + } +} diff --git a/themes/openfreebooks/sass/_grain.scss b/themes/openfreebooks/sass/_grain.scss index 093bfe3..b87f80b 100644 --- a/themes/openfreebooks/sass/_grain.scss +++ b/themes/openfreebooks/sass/_grain.scss @@ -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; } diff --git a/themes/openfreebooks/sass/_math-widget.scss b/themes/openfreebooks/sass/_math-widget.scss index 008cf73..697200a 100644 --- a/themes/openfreebooks/sass/_math-widget.scss +++ b/themes/openfreebooks/sass/_math-widget.scss @@ -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; diff --git a/themes/openfreebooks/sass/_scenario-demo.scss b/themes/openfreebooks/sass/_scenario-demo.scss new file mode 100644 index 0000000..d0395ee --- /dev/null +++ b/themes/openfreebooks/sass/_scenario-demo.scss @@ -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); +} diff --git a/themes/openfreebooks/sass/main.scss b/themes/openfreebooks/sass/main.scss index 11bc7bc..12300b8 100644 --- a/themes/openfreebooks/sass/main.scss +++ b/themes/openfreebooks/sass/main.scss @@ -12,3 +12,5 @@ @import "catalog"; @import "search"; @import "math-widget"; +@import "scenario-demo"; +@import "chapter-heading-nav"; diff --git a/themes/openfreebooks/templates/math/chapter.html b/themes/openfreebooks/templates/math/chapter.html index b903c40..fc7e16e 100644 --- a/themes/openfreebooks/templates/math/chapter.html +++ b/themes/openfreebooks/templates/math/chapter.html @@ -35,8 +35,10 @@ {% if section.extra.chapter_id == "quadratic-equations" %} - {% include "partials/math/quadratic-equations-content.html" %} +
+ {% include "partials/math/quadratic-equations-core-content.html" %}
+ {% include "partials/math/quadratic-equations-supplement-content.html" %} {% endif %} diff --git a/themes/openfreebooks/templates/partials/contributing-content.html b/themes/openfreebooks/templates/partials/contributing-content.html index a5cccaa..7e8a093 100644 --- a/themes/openfreebooks/templates/partials/contributing-content.html +++ b/themes/openfreebooks/templates/partials/contributing-content.html @@ -73,7 +73,7 @@ themes/.../partials/... → chapter HTML bodies (today)

2. Zola section

Create content/{subject}/{slug}/_index.md with chapter_id matching the slug.

3. Chapter HTML

-

Add themes/openfreebooks/templates/partials/math/your-slug-content.html and wire it in math/chapter.html. Copy patterns from quadratic-equations-content.html.

+

Add themes/openfreebooks/templates/partials/math/your-slug-content.html and wire it in math/chapter.html. Copy patterns from quadratic-equations-core-content.html.

4. Optional widget

Add a Solid component under frontend/src/components/ and mount it in main.tsx. Run bun run build:js.

diff --git a/themes/openfreebooks/templates/partials/math/quadratic-equations-content.html b/themes/openfreebooks/templates/partials/math/quadratic-equations-core-content.html similarity index 88% rename from themes/openfreebooks/templates/partials/math/quadratic-equations-content.html rename to themes/openfreebooks/templates/partials/math/quadratic-equations-core-content.html index c4d279c..ea9f3d8 100644 --- a/themes/openfreebooks/templates/partials/math/quadratic-equations-content.html +++ b/themes/openfreebooks/templates/partials/math/quadratic-equations-core-content.html @@ -5,7 +5,7 @@ formula, or by reading information from the graph of \(y = ax^2 + bx + c\).

-

The discriminant

+

The discriminant

The discriminant \(\Delta\) (delta) is defined below. It tells you how many real roots the equation has without fully solving it. @@ -16,7 +16,7 @@

\[ \Delta = b^2 - 4ac \]

-

Nature of roots

+

Nature of roots

@@ -51,7 +51,7 @@ \[ x = \frac{-b \pm \sqrt{\Delta}}{2a} \]

-

Try it yourself

+

Try it yourself

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. diff --git a/themes/openfreebooks/templates/partials/math/quadratic-equations-supplement-content.html b/themes/openfreebooks/templates/partials/math/quadratic-equations-supplement-content.html new file mode 100644 index 0000000..216b7b5 --- /dev/null +++ b/themes/openfreebooks/templates/partials/math/quadratic-equations-supplement-content.html @@ -0,0 +1,162 @@ +

+

History

+

+ People have worked on problems that lead to quadratic equations for thousands of years. + Babylonian clay tablets from around 1800 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. +

+ +
+ Babylonian clay tablet with cuneiform text recording a quadratic area problem +
+ A Babylonian clay tablet (about 1800 BCE) with a problem equivalent to finding the + sides of a rectangle from its area and perimeter. +
+
+ +

+ Early methods were often geometric: you imagine completing a rectangle to make a square, + which is the same idea behind completing the square today. In the 9th century, + al-Khwarizmi 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. +

+

+ During the Renaissance, mathematicians such as Cardano and + Tartaglia 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 complex numbers, a + topic you will meet in a later chapter. +

+ +

Derivation

+

+ 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. +

+

+ 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}. \] +

+

+ 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}. \] +

+

+ 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\): +

+
+

Quadratic formula

+

+ \[ x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a} \] +

+
+

+ 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 > 0\) you get two distinct real roots; if \(\Delta = 0\) the + \(\pm\) gives the same root twice; if \(\Delta < 0\) there is no real square root and the + equation has no real solutions — matching the nature-of-roots table above. +

+ +

Checkpoints

+

+ Pause and think before moving on. There are no answers on this page — discuss with a + classmate or write your reasoning in a notebook. +

+ +
+

Checkpoint

+

+ 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? +

+
+ +
+

Checkpoint

+

+ Can \(ax^2 + bx + c = 0\) with \(a \neq 0\) have three distinct real solutions? Why or why not? +

+
+ +
+

Checkpoint

+

+ Suppose \(a > 0\) and \(\Delta < 0\). Where does the entire parabola \(y = ax^2 + bx + c\) + sit relative to the x-axis? +

+
+ +
+

Checkpoint

+

+ A ball is thrown straight upward from ground level. Without using numbers, describe a + situation where \(\Delta > 0\) for height versus time, and another where \(\Delta = 0\). +

+
+ +
+

Checkpoint

+

+ 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? +

+
+ +

Applications

+ +

Projectile motion

+

+ 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 > 0\)), one (\(\Delta = 0\)), or none (\(\Delta < 0\)) — for example if the + model never reaches the ground in the time you care about. +

+ +
+ +

Profit and pricing

+

+ 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 break-even prices; \(\Delta\) tells you whether two + break-even prices exist, one, or none. +

+ +
+ +

Area with fixed perimeter

+

+ 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). +

+ +
+