From 742a72abe20932fb1593800ffa34fa964343522c Mon Sep 17 00:00:00 2001 From: Hananoshika Yomaru Date: Tue, 19 May 2026 15:07:01 -0700 Subject: [PATCH] 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. --- .agents/skills/ofb/SKILL.md | 2 +- CONTRIBUTING.md | 2 +- .../src/components/chapter-heading-nav.tsx | 270 +++++++ .../quadratic-application-demos.tsx | 750 ++++++++++++++++++ frontend/src/lib/math-graph.ts | 195 +++++ frontend/src/main.tsx | 35 +- spec.md | 25 + themes/openfreebooks/sass/_book.scss | 38 + .../sass/_chapter-heading-nav.scss | 224 ++++++ themes/openfreebooks/sass/_grain.scss | 5 +- themes/openfreebooks/sass/_math-widget.scss | 8 + themes/openfreebooks/sass/_scenario-demo.scss | 466 +++++++++++ themes/openfreebooks/sass/main.scss | 2 + .../openfreebooks/templates/math/chapter.html | 4 +- .../partials/contributing-content.html | 2 +- ... => quadratic-equations-core-content.html} | 6 +- ...uadratic-equations-supplement-content.html | 162 ++++ 17 files changed, 2184 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/chapter-heading-nav.tsx create mode 100644 frontend/src/components/quadratic-application-demos.tsx create mode 100644 frontend/src/lib/math-graph.ts create mode 100644 themes/openfreebooks/sass/_chapter-heading-nav.scss create mode 100644 themes/openfreebooks/sass/_scenario-demo.scss rename themes/openfreebooks/templates/partials/math/{quadratic-equations-content.html => quadratic-equations-core-content.html} (88%) create mode 100644 themes/openfreebooks/templates/partials/math/quadratic-equations-supplement-content.html 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). +

+ +
+