mirror of
https://github.com/HananoshikaYomaru/openfreebooks.git
synced 2026-11-12 20:45:52 +00:00
Refactor math chapter templates and enhance navigation: update CONTRIBUTING.md for clarity on content structure, integrate new chapter heading navigation component, and improve math chapter layout with core and supplemental content. Add styles for chapter navigation and scenario demos to enhance user experience.
This commit is contained in:
parent
7c09a57610
commit
742a72abe2
17 changed files with 2184 additions and 12 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
270
frontend/src/components/chapter-heading-nav.tsx
Normal file
270
frontend/src/components/chapter-heading-nav.tsx
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js";
|
||||
|
||||
type NavHeading = {
|
||||
id: string;
|
||||
label: string;
|
||||
level: 2 | 3;
|
||||
};
|
||||
|
||||
/** Align with sticky site header (see .book-prose__heading scroll-margin-top). */
|
||||
const SCROLL_OFFSET = 96;
|
||||
const CLICK_LOCK_FALLBACK_MS = 900;
|
||||
|
||||
function collectHeadings(article: HTMLElement): NavHeading[] {
|
||||
const nodes = article.querySelectorAll<HTMLHeadingElement>(
|
||||
".book-prose h2.book-prose__heading[id], .book-prose h3.book-prose__heading[id]"
|
||||
);
|
||||
return Array.from(nodes).map((node) => ({
|
||||
id: node.id,
|
||||
label: node.textContent?.trim() ?? node.id,
|
||||
level: node.tagName === "H3" ? 3 : 2,
|
||||
}));
|
||||
}
|
||||
|
||||
function headingDocumentTop(el: HTMLElement) {
|
||||
return el.getBoundingClientRect().top + window.scrollY;
|
||||
}
|
||||
|
||||
function pickActiveId(items: NavHeading[]): string | null {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const position = window.scrollY + SCROLL_OFFSET;
|
||||
let active = items[0].id;
|
||||
|
||||
for (const item of items) {
|
||||
const el = document.getElementById(item.id);
|
||||
if (!el) continue;
|
||||
if (headingDocumentTop(el) <= position + 2) {
|
||||
active = item.id;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return active;
|
||||
}
|
||||
|
||||
export function ChapterHeadingNav() {
|
||||
const [headings, setHeadings] = createSignal<NavHeading[]>([]);
|
||||
const [activeId, setActiveId] = createSignal<string | null>(null);
|
||||
const [railOpen, setRailOpen] = createSignal(false);
|
||||
const [sheetOpen, setSheetOpen] = createSignal(false);
|
||||
const [isMobile, setIsMobile] = createSignal(false);
|
||||
|
||||
let scrollToHeading: ((id: string) => void) | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const article = document.querySelector<HTMLElement>(".book-chapter");
|
||||
if (!article) return;
|
||||
|
||||
const items = collectHeadings(article);
|
||||
setHeadings(items);
|
||||
|
||||
const hash = window.location.hash.slice(1);
|
||||
const hashMatch = hash && items.some((item) => item.id === hash);
|
||||
setActiveId(hashMatch ? hash : pickActiveId(items));
|
||||
|
||||
let lockedId: string | null = null;
|
||||
let lockTimer: number | undefined;
|
||||
let rafId: number | undefined;
|
||||
let scrollEndHandler: (() => void) | null = null;
|
||||
|
||||
const releaseLock = () => {
|
||||
lockedId = null;
|
||||
if (scrollEndHandler) {
|
||||
window.removeEventListener("scrollend", scrollEndHandler);
|
||||
scrollEndHandler = null;
|
||||
}
|
||||
if (lockTimer !== undefined) {
|
||||
window.clearTimeout(lockTimer);
|
||||
lockTimer = undefined;
|
||||
}
|
||||
setActiveId(pickActiveId(items));
|
||||
};
|
||||
|
||||
const syncActive = () => {
|
||||
if (rafId !== undefined) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = undefined;
|
||||
if (lockedId) {
|
||||
setActiveId(lockedId);
|
||||
return;
|
||||
}
|
||||
setActiveId(pickActiveId(items));
|
||||
});
|
||||
};
|
||||
|
||||
scrollToHeading = (id: string) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
|
||||
lockedId = id;
|
||||
setActiveId(id);
|
||||
|
||||
if (scrollEndHandler) {
|
||||
window.removeEventListener("scrollend", scrollEndHandler);
|
||||
}
|
||||
if (lockTimer !== undefined) {
|
||||
window.clearTimeout(lockTimer);
|
||||
}
|
||||
|
||||
const top = Math.max(0, headingDocumentTop(el) - SCROLL_OFFSET);
|
||||
window.scrollTo({ top, behavior: "smooth" });
|
||||
history.replaceState(null, "", `#${id}`);
|
||||
|
||||
scrollEndHandler = () => releaseLock();
|
||||
window.addEventListener("scrollend", scrollEndHandler, { once: true });
|
||||
lockTimer = window.setTimeout(releaseLock, CLICK_LOCK_FALLBACK_MS);
|
||||
};
|
||||
|
||||
const onScroll = () => syncActive();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
|
||||
const mq = window.matchMedia("(max-width: 899px)");
|
||||
const updateMobile = () => setIsMobile(mq.matches);
|
||||
updateMobile();
|
||||
mq.addEventListener("change", updateMobile);
|
||||
|
||||
if (hashMatch) {
|
||||
requestAnimationFrame(() => scrollToHeading?.(hash));
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
mq.removeEventListener("change", updateMobile);
|
||||
releaseLock();
|
||||
if (rafId !== undefined) cancelAnimationFrame(rafId);
|
||||
scrollToHeading = undefined;
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!isMobile()) {
|
||||
setSheetOpen(false);
|
||||
} else {
|
||||
setRailOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
const jump = (id: string) => {
|
||||
if (scrollToHeading) {
|
||||
scrollToHeading(id);
|
||||
} else {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
const top = Math.max(0, headingDocumentTop(el) - SCROLL_OFFSET);
|
||||
window.scrollTo({ top, behavior: "smooth" });
|
||||
history.replaceState(null, "", `#${id}`);
|
||||
setActiveId(id);
|
||||
}
|
||||
}
|
||||
setSheetOpen(false);
|
||||
};
|
||||
|
||||
const NavList = (props: { class?: string }) => (
|
||||
<ul class={`chapter-heading-nav__list ${props.class ?? ""}`.trim()} role="list">
|
||||
<For each={headings()}>
|
||||
{(item) => (
|
||||
<li
|
||||
class="chapter-heading-nav__item"
|
||||
classList={{
|
||||
"chapter-heading-nav__item--active": activeId() === item.id,
|
||||
"chapter-heading-nav__item--h3": item.level === 3,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="chapter-heading-nav__link"
|
||||
onClick={() => jump(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<Show when={headings().length > 0}>
|
||||
<nav class="chapter-heading-nav" aria-label="On this page">
|
||||
<Show when={!isMobile()}>
|
||||
<div
|
||||
class="chapter-heading-nav__rail-wrap"
|
||||
onMouseEnter={() => setRailOpen(true)}
|
||||
onMouseLeave={() => setRailOpen(false)}
|
||||
onFocusIn={() => setRailOpen(true)}
|
||||
onFocusOut={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setRailOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="chapter-heading-nav__rail" aria-hidden="true">
|
||||
<For each={headings()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="chapter-heading-nav__tick"
|
||||
classList={{
|
||||
"chapter-heading-nav__tick--active": activeId() === item.id,
|
||||
"chapter-heading-nav__tick--h3": item.level === 3,
|
||||
}}
|
||||
aria-label={item.label}
|
||||
aria-current={activeId() === item.id ? "true" : undefined}
|
||||
onClick={() => jump(item.id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={railOpen()}>
|
||||
<div class="chapter-heading-nav__popover" role="dialog" aria-label="Page sections">
|
||||
<p class="chapter-heading-nav__popover-title">On this page</p>
|
||||
<NavList />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isMobile()}>
|
||||
<button
|
||||
type="button"
|
||||
class="chapter-heading-nav__fab"
|
||||
aria-expanded={sheetOpen()}
|
||||
aria-controls="chapter-heading-sheet"
|
||||
onClick={() => setSheetOpen((open) => !open)}
|
||||
>
|
||||
On this page
|
||||
</button>
|
||||
<Show when={sheetOpen()}>
|
||||
<button
|
||||
type="button"
|
||||
class="chapter-heading-nav__backdrop"
|
||||
aria-label="Close section menu"
|
||||
onClick={() => setSheetOpen(false)}
|
||||
/>
|
||||
<div
|
||||
id="chapter-heading-sheet"
|
||||
class="chapter-heading-nav__sheet"
|
||||
role="dialog"
|
||||
aria-label="On this page"
|
||||
>
|
||||
<div class="chapter-heading-nav__sheet-header">
|
||||
<p class="chapter-heading-nav__popover-title">On this page</p>
|
||||
<button
|
||||
type="button"
|
||||
class="chapter-heading-nav__sheet-close"
|
||||
aria-label="Close"
|
||||
onClick={() => setSheetOpen(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<NavList />
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</nav>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
750
frontend/src/components/quadratic-application-demos.tsx
Normal file
750
frontend/src/components/quadratic-application-demos.tsx
Normal file
|
|
@ -0,0 +1,750 @@
|
|||
import katex from "katex";
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
|
||||
import { computeRoots, discriminant, formatNum } from "../lib/math-graph";
|
||||
|
||||
const HORIZONTAL_SPEED = 12;
|
||||
|
||||
function renderLatex(latex: string) {
|
||||
return katex.renderToString(latex, { throwOnError: false, strict: "ignore" });
|
||||
}
|
||||
|
||||
function SliderRow(props: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
unit?: string;
|
||||
disabled?: boolean;
|
||||
onInput: (v: number) => void;
|
||||
}) {
|
||||
const display = () =>
|
||||
props.unit ? `${formatNum(props.value)} ${props.unit}` : formatNum(props.value);
|
||||
|
||||
return (
|
||||
<div class="scenario-demo__control">
|
||||
<label class="scenario-demo__label" for={props.id}>
|
||||
<span>{props.label}</span>
|
||||
<span class="scenario-demo__value">{display()}</span>
|
||||
</label>
|
||||
<input
|
||||
id={props.id}
|
||||
class="scenario-demo__slider"
|
||||
type="range"
|
||||
min={props.min}
|
||||
max={props.max}
|
||||
step={props.step}
|
||||
value={props.value}
|
||||
disabled={props.disabled}
|
||||
onInput={(e) => props.onInput(Number(e.currentTarget.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectileDemo() {
|
||||
const [v0y, setV0y] = createSignal(20);
|
||||
const [g, setG] = createSignal(10);
|
||||
const [h0, setH0] = createSignal(0);
|
||||
const [t, setT] = createSignal(0);
|
||||
const [playing, setPlaying] = createSignal(false);
|
||||
|
||||
let canvasRef: HTMLCanvasElement | undefined;
|
||||
let rafId: number | undefined;
|
||||
let playStart = 0;
|
||||
|
||||
const heightAt = (time: number) => h0() + v0y() * time - 0.5 * g() * time * time;
|
||||
const distanceAt = (time: number) => HORIZONTAL_SPEED * time;
|
||||
|
||||
const landingTime = createMemo(() => {
|
||||
const a = -0.5 * g();
|
||||
const d = discriminant(a, v0y(), h0());
|
||||
const roots = computeRoots(a, v0y(), h0(), d);
|
||||
const positive = roots.filter((r) => r > 0.01);
|
||||
if (positive.length > 0) return Math.max(...positive);
|
||||
return Math.max(2, (2 * v0y()) / g());
|
||||
});
|
||||
|
||||
const maxX = createMemo(() => distanceAt(landingTime()) * 1.1 + 5);
|
||||
const maxY = createMemo(() => {
|
||||
const peak = h0() + (v0y() * v0y()) / (2 * g());
|
||||
return Math.max(peak * 1.15, h0() + 2, 5);
|
||||
});
|
||||
|
||||
const drawScene = (time: number) => {
|
||||
const canvas = canvasRef;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const w = 400;
|
||||
const h = 240;
|
||||
const pad = { l: 36, r: 12, t: 12, b: 28 };
|
||||
const plotW = w - pad.l - pad.r;
|
||||
const plotH = h - pad.t - pad.b;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const mx = maxX();
|
||||
const my = maxY();
|
||||
|
||||
const toX = (x: number) => pad.l + (x / mx) * plotW;
|
||||
const toY = (y: number) => pad.t + plotH - (y / my) * plotH;
|
||||
|
||||
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-canvas-bg").trim() || "#fff";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue("--math-grid").trim() || "#e5e5e5";
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = (my * i) / 4;
|
||||
const py = toY(y);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.l, py);
|
||||
ctx.lineTo(w - pad.r, py);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue("--math-axis").trim() || "#a8a29e";
|
||||
ctx.lineWidth = 2;
|
||||
const groundY = toY(0);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.l, groundY);
|
||||
ctx.lineTo(w - pad.r, groundY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e";
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.beginPath();
|
||||
const steps = 80;
|
||||
const tEnd = landingTime();
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const ti = (tEnd * i) / steps;
|
||||
const px = toX(distanceAt(ti));
|
||||
const py = toY(Math.max(0, heightAt(ti)));
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
const bx = distanceAt(time);
|
||||
const by = heightAt(time);
|
||||
const cx = toX(bx);
|
||||
const cy = toY(Math.max(0, by));
|
||||
|
||||
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e";
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--muted").trim() || "#767676";
|
||||
ctx.font = "11px IBM Plex Mono, monospace";
|
||||
ctx.fillText("x (m)", w / 2 - 12, h - 6);
|
||||
ctx.save();
|
||||
ctx.translate(10, h / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.fillText("h (m)", 0, 0);
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
const stopPlayback = () => {
|
||||
if (rafId !== undefined) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = undefined;
|
||||
}
|
||||
setPlaying(false);
|
||||
};
|
||||
|
||||
const startPlayback = () => {
|
||||
stopPlayback();
|
||||
setT(0);
|
||||
setPlaying(true);
|
||||
playStart = performance.now();
|
||||
|
||||
const tick = (now: number) => {
|
||||
const elapsed = (now - playStart) / 1000;
|
||||
const tEnd = landingTime();
|
||||
if (elapsed >= tEnd) {
|
||||
setT(tEnd);
|
||||
drawScene(tEnd);
|
||||
stopPlayback();
|
||||
return;
|
||||
}
|
||||
setT(elapsed);
|
||||
drawScene(elapsed);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
v0y();
|
||||
g();
|
||||
h0();
|
||||
t();
|
||||
if (!playing()) drawScene(t());
|
||||
});
|
||||
|
||||
onCleanup(() => stopPlayback());
|
||||
|
||||
return (
|
||||
<section
|
||||
class="scenario-demo scenario-demo--projectile"
|
||||
aria-label="Projectile motion interactive example"
|
||||
>
|
||||
<div class="scenario-demo__stage">
|
||||
<canvas
|
||||
ref={(el) => {
|
||||
canvasRef = el;
|
||||
drawScene(0);
|
||||
}}
|
||||
class="projectile-scenario__canvas"
|
||||
width={400}
|
||||
height={240}
|
||||
role="img"
|
||||
aria-label="Side view of projectile path with horizontal and vertical position"
|
||||
/>
|
||||
<p class="scenario-demo__live" aria-live="polite">
|
||||
t = {formatNum(t())} s · x = {formatNum(distanceAt(t()))} m · h ={" "}
|
||||
{formatNum(Math.max(0, heightAt(t())))} m
|
||||
</p>
|
||||
<p class="scenario-demo__note scenario-demo__note--muted">
|
||||
Horizontal speed is fixed at {HORIZONTAL_SPEED} m/s. Height follows
|
||||
\(h = h_0 + v_0 t - \frac{1}{2}gt^2\).
|
||||
</p>
|
||||
</div>
|
||||
<div class="scenario-demo__panel">
|
||||
<div class="scenario-demo__controls">
|
||||
<SliderRow
|
||||
id="projectile-v0"
|
||||
label="Launch speed (vertical) v₀"
|
||||
value={v0y()}
|
||||
min={5}
|
||||
max={35}
|
||||
step={1}
|
||||
unit="m/s"
|
||||
disabled={playing()}
|
||||
onInput={setV0y}
|
||||
/>
|
||||
<SliderRow
|
||||
id="projectile-g"
|
||||
label="Gravity g"
|
||||
value={g()}
|
||||
min={5}
|
||||
max={15}
|
||||
step={0.5}
|
||||
unit="m/s²"
|
||||
disabled={playing()}
|
||||
onInput={setG}
|
||||
/>
|
||||
<SliderRow
|
||||
id="projectile-h0"
|
||||
label="Start height h₀"
|
||||
value={h0()}
|
||||
min={0}
|
||||
max={15}
|
||||
step={1}
|
||||
unit="m"
|
||||
disabled={playing()}
|
||||
onInput={setH0}
|
||||
/>
|
||||
</div>
|
||||
<div class="scenario-demo__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="scenario-demo__btn scenario-demo__btn--primary"
|
||||
onClick={() => (playing() ? stopPlayback() : startPlayback())}
|
||||
>
|
||||
{playing() ? "Stop" : "Start"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="scenario-demo__btn"
|
||||
disabled={playing()}
|
||||
onClick={() => {
|
||||
stopPlayback();
|
||||
setT(0);
|
||||
drawScene(0);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const PROFIT_PRICE_MIN = 5;
|
||||
const PROFIT_PRICE_MAX = 20;
|
||||
/** Π(p) = -2p² + 40p - 150 with Q(p) = 40 - 2p and fixed cost C = $150 */
|
||||
const PROFIT_OPTIMAL_PRICE = 10;
|
||||
const PROFIT_FIXED_COST = 150;
|
||||
|
||||
const demandQuantity = (price: number) => Math.max(0, 40 - 2 * price);
|
||||
const demandPrice = (quantity: number) => Math.max(0, (40 - quantity) / 2);
|
||||
const profitAtPrice = (price: number) => -2 * price * price + 40 * price - 150;
|
||||
const revenueAtQ = (q: number) => q * demandPrice(q);
|
||||
const costAtQ = (_q: number) => PROFIT_FIXED_COST;
|
||||
const profitAtQ = (q: number) => revenueAtQ(q) - costAtQ(q);
|
||||
|
||||
function drawProfitChart(
|
||||
canvas: HTMLCanvasElement,
|
||||
price: number,
|
||||
quantity: number,
|
||||
revenue: number,
|
||||
cost: number,
|
||||
profit: number
|
||||
) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const w = 400;
|
||||
const h = 240;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const bg = getComputedStyle(canvas).getPropertyValue("--math-canvas-bg").trim() || "#fff";
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const pad = { l: 44, r: 44, t: 20, b: 40 };
|
||||
const plotW = w - pad.l - pad.r;
|
||||
const plotH = h - pad.t - pad.b;
|
||||
|
||||
const qMax = 42;
|
||||
const pMax = 22;
|
||||
let dollarMin = -120;
|
||||
let dollarMax = 420;
|
||||
for (let q = 0; q <= 40; q += 1) {
|
||||
dollarMin = Math.min(dollarMin, profitAtQ(q), revenueAtQ(q));
|
||||
dollarMax = Math.max(dollarMax, profitAtQ(q), revenueAtQ(q), costAtQ(q));
|
||||
}
|
||||
const dollarSpan = dollarMax - dollarMin || 1;
|
||||
|
||||
const toX = (q: number) => pad.l + (q / qMax) * plotW;
|
||||
const toPriceY = (p: number) => pad.t + plotH - (p / pMax) * plotH;
|
||||
const toDollarY = (d: number) => pad.t + plotH - ((d - dollarMin) / dollarSpan) * plotH;
|
||||
|
||||
const grid = getComputedStyle(canvas).getPropertyValue("--math-grid").trim() || "#e5e5e5";
|
||||
const axis = getComputedStyle(canvas).getPropertyValue("--math-axis").trim() || "#a8a29e";
|
||||
const ink = getComputedStyle(canvas).getPropertyValue("--ink").trim() || "#1a1a1a";
|
||||
const muted = getComputedStyle(canvas).getPropertyValue("--muted").trim() || "#767676";
|
||||
|
||||
ctx.strokeStyle = grid;
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const py = pad.t + (plotH * i) / 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.l, py);
|
||||
ctx.lineTo(w - pad.r, py);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = axis;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.l, pad.t);
|
||||
ctx.lineTo(pad.l, h - pad.b);
|
||||
ctx.lineTo(w - pad.r, h - pad.b);
|
||||
ctx.stroke();
|
||||
|
||||
const drawCurve = (
|
||||
fn: (q: number) => number,
|
||||
toY: (v: number) => number,
|
||||
color: string,
|
||||
width = 2,
|
||||
dash: number[] = []
|
||||
) => {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.setLineDash(dash);
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= 80; i++) {
|
||||
const q = (40 * i) / 80;
|
||||
const px = toX(q);
|
||||
const py = toY(fn(q));
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
};
|
||||
|
||||
drawCurve(demandPrice, toPriceY, getComputedStyle(canvas).getPropertyValue("--math-root").trim() || "#2563eb", 2.5);
|
||||
drawCurve(revenueAtQ, toDollarY, "#4a6b4a", 2);
|
||||
drawCurve(costAtQ, toDollarY, "#a8a29e", 2);
|
||||
drawCurve(
|
||||
profitAtQ,
|
||||
toDollarY,
|
||||
getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e",
|
||||
2.5
|
||||
);
|
||||
|
||||
const qOpt = demandQuantity(PROFIT_OPTIMAL_PRICE);
|
||||
const pxOpt = toX(qOpt);
|
||||
const pyOptP = toPriceY(PROFIT_OPTIMAL_PRICE);
|
||||
const pyOptPi = toDollarY(profitAtQ(qOpt));
|
||||
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--success").trim() || "#2a7d52";
|
||||
ctx.beginPath();
|
||||
ctx.arc(pxOpt, pyOptP, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(pxOpt, pyOptPi, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
const qx = toX(quantity);
|
||||
const py = toPriceY(price);
|
||||
const pRev = toDollarY(revenue);
|
||||
const pCost = toDollarY(cost);
|
||||
const pProfit = toDollarY(profit);
|
||||
|
||||
ctx.strokeStyle = muted;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(qx, pad.t);
|
||||
ctx.lineTo(qx, h - pad.b);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-root").trim() || "#2563eb";
|
||||
ctx.beginPath();
|
||||
ctx.arc(qx, py, 7, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "#4a6b4a";
|
||||
ctx.beginPath();
|
||||
ctx.arc(qx, pRev, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "#a8a29e";
|
||||
ctx.beginPath();
|
||||
ctx.arc(qx, pCost, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e";
|
||||
ctx.beginPath();
|
||||
ctx.arc(qx, pProfit, 7, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.font = "10px IBM Plex Mono, monospace";
|
||||
ctx.fillStyle = ink;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Quantity Q (books)", w / 2, h - 6);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(12, h / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Price P ($)", 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(w - 10, h / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = muted;
|
||||
ctx.fillText("$ (R, C, Π)", 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
ctx.font = "9px IBM Plex Mono, monospace";
|
||||
ctx.textAlign = "left";
|
||||
const legendX = pad.l + 6;
|
||||
let legendY = pad.t + 6;
|
||||
const legend = [
|
||||
{ color: getComputedStyle(canvas).getPropertyValue("--math-root").trim() || "#2563eb", label: "Demand" },
|
||||
{ color: "#4a6b4a", label: "Revenue" },
|
||||
{ color: "#a8a29e", label: "Cost" },
|
||||
{ color: getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e", label: "Profit Π" },
|
||||
];
|
||||
for (const item of legend) {
|
||||
ctx.fillStyle = item.color;
|
||||
ctx.fillRect(legendX, legendY, 10, 3);
|
||||
ctx.fillStyle = ink;
|
||||
ctx.fillText(item.label, legendX + 14, legendY + 4);
|
||||
legendY += 14;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProfitDemo() {
|
||||
const [price, setPrice] = createSignal(10);
|
||||
let canvasRef: HTMLCanvasElement | undefined;
|
||||
|
||||
const quantity = createMemo(() => demandQuantity(price()));
|
||||
const revenue = createMemo(() => price() * quantity());
|
||||
const cost = createMemo(() => costAtQ(quantity()));
|
||||
const profit = createMemo(() => revenue() - cost());
|
||||
const atOptimum = createMemo(() => Math.abs(price() - PROFIT_OPTIMAL_PRICE) < 0.26);
|
||||
|
||||
const formulaHtml = createMemo(() => renderLatex(`\\Pi = -2p^2 + 40p - 150`));
|
||||
|
||||
const redraw = () => {
|
||||
if (!canvasRef) return;
|
||||
drawProfitChart(
|
||||
canvasRef,
|
||||
price(),
|
||||
quantity(),
|
||||
revenue(),
|
||||
cost(),
|
||||
profit()
|
||||
);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
price();
|
||||
quantity();
|
||||
revenue();
|
||||
cost();
|
||||
profit();
|
||||
redraw();
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
class="scenario-demo scenario-demo--profit"
|
||||
aria-label="Profit and pricing interactive example"
|
||||
>
|
||||
<div class="scenario-demo__stage scenario-demo__stage--wide">
|
||||
<p class="profit-scenario__scenario">
|
||||
A textbook shop sets price <strong>${formatNum(price())}</strong>. Higher price means
|
||||
lower demand: <strong>{formatNum(quantity())}</strong> books sold (demand curve). Revenue,
|
||||
cost, and profit Π are read off the curves at that quantity.
|
||||
</p>
|
||||
<canvas
|
||||
ref={(el) => {
|
||||
canvasRef = el;
|
||||
redraw();
|
||||
}}
|
||||
class="profit-scenario__canvas"
|
||||
width={400}
|
||||
height={240}
|
||||
role="img"
|
||||
aria-label="Demand, revenue, cost, and profit curves with quantity on the horizontal axis"
|
||||
/>
|
||||
<SliderRow
|
||||
id="profit-price"
|
||||
label="Price per book P"
|
||||
value={price()}
|
||||
min={PROFIT_PRICE_MIN}
|
||||
max={PROFIT_PRICE_MAX}
|
||||
step={0.5}
|
||||
unit="$"
|
||||
onInput={setPrice}
|
||||
/>
|
||||
</div>
|
||||
<div class="scenario-demo__panel">
|
||||
<div class="scenario-demo__formula" aria-live="polite">
|
||||
<p class="scenario-demo__formula-label">Profit model</p>
|
||||
<div class="scenario-demo__formula-math" innerHTML={formulaHtml()} />
|
||||
</div>
|
||||
<div class="scenario-demo__stats">
|
||||
<div class="scenario-demo__stat">
|
||||
<span class="scenario-demo__stat-label">Books sold</span>
|
||||
<span class="scenario-demo__stat-value">{formatNum(quantity())}</span>
|
||||
</div>
|
||||
<div class="scenario-demo__stat">
|
||||
<span class="scenario-demo__stat-label">Revenue</span>
|
||||
<span class="scenario-demo__stat-value">${formatNum(revenue())}</span>
|
||||
</div>
|
||||
<div class="scenario-demo__stat">
|
||||
<span class="scenario-demo__stat-label">Cost</span>
|
||||
<span class="scenario-demo__stat-value">${formatNum(cost())}</span>
|
||||
</div>
|
||||
<div class="scenario-demo__stat scenario-demo__stat--highlight">
|
||||
<span class="scenario-demo__stat-label">Profit Π</span>
|
||||
<span class="scenario-demo__stat-value">${formatNum(profit())}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="profit-scenario__optimum"
|
||||
classList={{ "profit-scenario__optimum--on": atOptimum() }}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{atOptimum()
|
||||
? `Maximum profit at P = $${formatNum(PROFIT_OPTIMAL_PRICE)} (Q = ${formatNum(demandQuantity(PROFIT_OPTIMAL_PRICE))}, Π = $${formatNum(profitAtPrice(PROFIT_OPTIMAL_PRICE))}).`
|
||||
: "Profit is positive between break-even prices P = $5 and P = $15. Drag toward the peak of the gold curve."}
|
||||
</p>
|
||||
<p class="scenario-demo__note scenario-demo__note--muted">
|
||||
Blue: demand (P vs Q). Green, gray, gold: revenue, cost, and profit in dollars (right
|
||||
scale).
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AreaDemo() {
|
||||
const [perimeter, setPerimeter] = createSignal(20);
|
||||
const [widthUnits, setWidthUnits] = createSignal(5);
|
||||
const [dragging, setDragging] = createSignal(false);
|
||||
|
||||
let gridRef: HTMLDivElement | undefined;
|
||||
|
||||
const halfP = createMemo(() => Math.round(perimeter() / 2));
|
||||
const lengthUnits = createMemo(() => halfP() - widthUnits());
|
||||
const area = createMemo(() => widthUnits() * lengthUnits());
|
||||
const maxArea = createMemo(() => {
|
||||
const h = halfP();
|
||||
let best = 0;
|
||||
for (let w = 1; w < h; w += 1) {
|
||||
best = Math.max(best, w * (h - w));
|
||||
}
|
||||
return best;
|
||||
});
|
||||
const isMax = createMemo(() => area() === maxArea());
|
||||
|
||||
const clampWidth = (w: number, h: number) =>
|
||||
Math.max(1, Math.min(h - 1, Math.round(w)));
|
||||
|
||||
const widthFromPointer = (clientX: number) => {
|
||||
const grid = gridRef;
|
||||
if (!grid) return widthUnits();
|
||||
const h = halfP();
|
||||
const bounds = grid.getBoundingClientRect();
|
||||
const cell = bounds.width / h;
|
||||
const col = Math.floor((clientX - bounds.left) / cell) + 1;
|
||||
return clampWidth(col, h);
|
||||
};
|
||||
|
||||
const onCornerPointerDown = (e: PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragging(true);
|
||||
setWidthUnits(widthFromPointer(e.clientX));
|
||||
|
||||
const onMove = (ev: PointerEvent) => setWidthUnits(widthFromPointer(ev.clientX));
|
||||
const onUp = () => {
|
||||
setDragging(false);
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", onUp);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onUp);
|
||||
};
|
||||
|
||||
const changePerimeter = (p: number) => {
|
||||
const newHalf = Math.round(p / 2);
|
||||
const oldHalf = halfP();
|
||||
const ratio = oldHalf > 0 ? widthUnits() / oldHalf : 0.5;
|
||||
setPerimeter(p);
|
||||
setWidthUnits(clampWidth(Math.round(ratio * newHalf), newHalf));
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const h = halfP();
|
||||
const clamped = clampWidth(widthUnits(), h);
|
||||
if (widthUnits() !== clamped) setWidthUnits(clamped);
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
class="scenario-demo scenario-demo--area"
|
||||
aria-label="Fixed perimeter area interactive example"
|
||||
>
|
||||
<div class="scenario-demo__stage">
|
||||
<div class="area-scenario__garden">
|
||||
<p class="area-scenario__fence-label">
|
||||
Each square is 1 m. Perimeter P = {perimeter()} m (fixed while resizing). Drag the
|
||||
corner — snaps to whole metres.
|
||||
</p>
|
||||
<div
|
||||
class="area-scenario__grid-wrap"
|
||||
ref={gridRef}
|
||||
style={{ "--grid-units": String(halfP()) }}
|
||||
>
|
||||
<div class="area-scenario__grid" role="img" aria-hidden="true">
|
||||
<For each={Array.from({ length: halfP() * halfP() })}>
|
||||
{() => <span class="area-scenario__cell" />}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
class="area-scenario__rect"
|
||||
classList={{
|
||||
"area-scenario__rect--max": isMax(),
|
||||
"area-scenario__rect--dragging": dragging(),
|
||||
}}
|
||||
style={{
|
||||
width: `calc(${widthUnits()} * var(--cell-size))`,
|
||||
height: `calc(${lengthUnits()} * var(--cell-size))`,
|
||||
}}
|
||||
>
|
||||
<span class="area-scenario__dim area-scenario__dim--w">
|
||||
w = {widthUnits()} m
|
||||
</span>
|
||||
<span class="area-scenario__dim area-scenario__dim--l">
|
||||
ℓ = {lengthUnits()} m
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="area-scenario__corner"
|
||||
aria-label="Resize rectangle; perimeter stays fixed"
|
||||
onPointerDown={onCornerPointerDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="scenario-demo__live" aria-live="polite">
|
||||
2w + 2ℓ = {perimeter()} m · w + ℓ = {halfP()} m
|
||||
</p>
|
||||
</div>
|
||||
<SliderRow
|
||||
id="area-perimeter"
|
||||
label="Set fence perimeter P"
|
||||
value={perimeter()}
|
||||
min={12}
|
||||
max={40}
|
||||
step={2}
|
||||
unit="m"
|
||||
disabled={dragging()}
|
||||
onInput={changePerimeter}
|
||||
/>
|
||||
</div>
|
||||
<div class="scenario-demo__panel">
|
||||
<div class="scenario-demo__formula" aria-live="polite">
|
||||
<p class="scenario-demo__formula-label">Area model</p>
|
||||
<div
|
||||
class="scenario-demo__formula-math"
|
||||
innerHTML={renderLatex(
|
||||
`A = w(\\tfrac{${perimeter()}}{2} - w) = ${widthUnits()} \\times ${lengthUnits()} = ${area()}`
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div class="scenario-demo__stats">
|
||||
<div class="scenario-demo__stat scenario-demo__stat--highlight">
|
||||
<span class="scenario-demo__stat-label">Area A</span>
|
||||
<span class="scenario-demo__stat-value">{area()} m²</span>
|
||||
</div>
|
||||
<div class="scenario-demo__stat">
|
||||
<span class="scenario-demo__stat-label">Largest possible</span>
|
||||
<span class="scenario-demo__stat-value">{maxArea()} m²</span>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="area-scenario__badge area-scenario__badge--panel"
|
||||
classList={{ "area-scenario__badge--visible": isMax() }}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{isMax()
|
||||
? `Maximum area on this grid — ${widthUnits()} m × ${lengthUnits()} m (A = ${area()} m²).`
|
||||
: "\u00a0"}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
195
frontend/src/lib/math-graph.ts
Normal file
195
frontend/src/lib/math-graph.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
export const VIEW_SIZE = 400;
|
||||
const PAD = 36;
|
||||
|
||||
export type GraphBounds = {
|
||||
xMin: number;
|
||||
xMax: number;
|
||||
yMin: number;
|
||||
yMax: number;
|
||||
};
|
||||
|
||||
export type GraphColors = {
|
||||
grid: string;
|
||||
axis: string;
|
||||
curve: string;
|
||||
marker: string;
|
||||
root: string;
|
||||
bg: string;
|
||||
};
|
||||
|
||||
export function formatNum(n: number, digits = 2) {
|
||||
if (Number.isInteger(n)) return String(n);
|
||||
const p = 10 ** digits;
|
||||
return String(Math.round(n * p) / p);
|
||||
}
|
||||
|
||||
export function discriminant(a: number, b: number, c: number) {
|
||||
return b * b - 4 * a * c;
|
||||
}
|
||||
|
||||
export function computeRoots(a: number, b: number, c: number, delta: number) {
|
||||
if (delta < 0 || Math.abs(a) < 1e-9) return [] as number[];
|
||||
if (delta === 0) return [-b / (2 * a)];
|
||||
const sqrtDelta = Math.sqrt(delta);
|
||||
return [(-b + sqrtDelta) / (2 * a), (-b - sqrtDelta) / (2 * a)].sort((x, y) => x - y);
|
||||
}
|
||||
|
||||
export function natureOfRoots(delta: number) {
|
||||
if (delta > 0) return "Two distinct real roots";
|
||||
if (delta === 0) return "One repeated real root";
|
||||
return "No real roots";
|
||||
}
|
||||
|
||||
export function readGraphColors(canvas: HTMLCanvasElement): GraphColors {
|
||||
const style = getComputedStyle(canvas);
|
||||
return {
|
||||
grid: style.getPropertyValue("--math-grid").trim() || "#e5e5e5",
|
||||
axis: style.getPropertyValue("--math-axis").trim() || "#a8a29e",
|
||||
curve: style.getPropertyValue("--math-curve").trim() || "#9a6b2e",
|
||||
marker: style.getPropertyValue("--math-vertex").trim() || "#4a6b4a",
|
||||
root: style.getPropertyValue("--math-root").trim() || "#2563eb",
|
||||
bg: style.getPropertyValue("--math-canvas-bg").trim() || "#ffffff",
|
||||
};
|
||||
}
|
||||
|
||||
function toCanvasX(x: number, bounds: GraphBounds) {
|
||||
const w = VIEW_SIZE - PAD * 2;
|
||||
return PAD + ((x - bounds.xMin) / (bounds.xMax - bounds.xMin)) * w;
|
||||
}
|
||||
|
||||
function toCanvasY(y: number, bounds: GraphBounds) {
|
||||
const h = VIEW_SIZE - PAD * 2;
|
||||
return VIEW_SIZE - PAD - ((y - bounds.yMin) / (bounds.yMax - bounds.yMin)) * h;
|
||||
}
|
||||
|
||||
function niceStep(span: number) {
|
||||
const raw = span / 5;
|
||||
const mag = 10 ** Math.floor(Math.log10(raw));
|
||||
const norm = raw / mag;
|
||||
if (norm < 1.5) return mag;
|
||||
if (norm < 3.5) return 2 * mag;
|
||||
if (norm < 7.5) return 5 * mag;
|
||||
return 10 * mag;
|
||||
}
|
||||
|
||||
export function drawGraph(
|
||||
canvas: HTMLCanvasElement,
|
||||
bounds: GraphBounds,
|
||||
fn: (x: number) => number,
|
||||
options?: {
|
||||
roots?: number[];
|
||||
highlightX?: number;
|
||||
highlightLabel?: string;
|
||||
xLabel?: string;
|
||||
yLabel?: string;
|
||||
}
|
||||
) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = VIEW_SIZE * dpr;
|
||||
canvas.height = VIEW_SIZE * dpr;
|
||||
canvas.style.width = `${VIEW_SIZE}px`;
|
||||
canvas.style.height = `${VIEW_SIZE}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const colors = readGraphColors(canvas);
|
||||
|
||||
ctx.fillStyle = colors.bg;
|
||||
ctx.fillRect(0, 0, VIEW_SIZE, VIEW_SIZE);
|
||||
|
||||
const xStep = niceStep(bounds.xMax - bounds.xMin);
|
||||
const yStep = niceStep(bounds.yMax - bounds.yMin);
|
||||
|
||||
ctx.strokeStyle = colors.grid;
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = Math.ceil(bounds.xMin / xStep) * xStep; x <= bounds.xMax; x += xStep) {
|
||||
const px = toCanvasX(x, bounds);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px, PAD);
|
||||
ctx.lineTo(px, VIEW_SIZE - PAD);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = Math.ceil(bounds.yMin / yStep) * yStep; y <= bounds.yMax; y += yStep) {
|
||||
const py = toCanvasY(y, bounds);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(PAD, py);
|
||||
ctx.lineTo(VIEW_SIZE - PAD, py);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const xZero = toCanvasX(0, bounds);
|
||||
const yZero = toCanvasY(0, bounds);
|
||||
if (0 >= bounds.xMin && 0 <= bounds.xMax) {
|
||||
ctx.strokeStyle = colors.axis;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xZero, PAD);
|
||||
ctx.lineTo(xZero, VIEW_SIZE - PAD);
|
||||
ctx.stroke();
|
||||
}
|
||||
if (0 >= bounds.yMin && 0 <= bounds.yMax) {
|
||||
ctx.strokeStyle = colors.axis;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(PAD, yZero);
|
||||
ctx.lineTo(VIEW_SIZE - PAD, yZero);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = colors.curve;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineCap = "round";
|
||||
ctx.beginPath();
|
||||
const samples = 120;
|
||||
let started = false;
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const x = bounds.xMin + ((bounds.xMax - bounds.xMin) * i) / samples;
|
||||
const y = fn(x);
|
||||
const px = toCanvasX(x, bounds);
|
||||
const py = toCanvasY(y, bounds);
|
||||
if (!started) {
|
||||
ctx.moveTo(px, py);
|
||||
started = true;
|
||||
} else {
|
||||
ctx.lineTo(px, py);
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
const drawDot = (x: number, y: number, fill: string, radius = 6) => {
|
||||
ctx.fillStyle = fill;
|
||||
ctx.beginPath();
|
||||
ctx.arc(toCanvasX(x, bounds), toCanvasY(y, bounds), radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
};
|
||||
|
||||
for (const root of options?.roots ?? []) {
|
||||
if (root >= bounds.xMin && root <= bounds.xMax) {
|
||||
drawDot(root, 0, colors.root);
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.highlightX !== undefined) {
|
||||
const hx = options.highlightX;
|
||||
const hy = fn(hx);
|
||||
if (hx >= bounds.xMin && hx <= bounds.xMax && hy >= bounds.yMin && hy <= bounds.yMax) {
|
||||
drawDot(hx, hy, colors.marker, 7);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = colors.axis;
|
||||
ctx.font = "11px IBM Plex Mono, monospace";
|
||||
ctx.textAlign = "center";
|
||||
if (options?.xLabel) {
|
||||
ctx.fillText(options.xLabel, VIEW_SIZE / 2, VIEW_SIZE - 10);
|
||||
}
|
||||
if (options?.yLabel) {
|
||||
ctx.save();
|
||||
ctx.translate(12, VIEW_SIZE / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.fillText(options.yLabel, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
|
@ -78,13 +78,40 @@ if (copyPageRoot) {
|
|||
});
|
||||
}
|
||||
|
||||
const quadraticRoot = document.getElementById("quadratic-explorer");
|
||||
if (quadraticRoot) {
|
||||
const hasQuadraticChapter =
|
||||
document.getElementById("quadratic-explorer") ||
|
||||
document.getElementById("projectile-demo") ||
|
||||
document.getElementById("profit-demo") ||
|
||||
document.getElementById("area-demo");
|
||||
|
||||
const chapterNavRoot = document.getElementById("chapter-heading-nav");
|
||||
if (chapterNavRoot) {
|
||||
void import("./components/chapter-heading-nav").then(({ ChapterHeadingNav }) => {
|
||||
render(() => <ChapterHeadingNav />, chapterNavRoot);
|
||||
});
|
||||
}
|
||||
|
||||
if (hasQuadraticChapter) {
|
||||
void Promise.all([
|
||||
import("./components/quadratic-explorer"),
|
||||
import("./components/quadratic-application-demos"),
|
||||
import("./lib/render-math"),
|
||||
]).then(([{ QuadraticExplorer }, { renderBookMath }]) => {
|
||||
render(() => <QuadraticExplorer />, quadraticRoot);
|
||||
]).then(([{ QuadraticExplorer }, demos, { renderBookMath }]) => {
|
||||
const quadraticRoot = document.getElementById("quadratic-explorer");
|
||||
if (quadraticRoot) {
|
||||
render(() => <QuadraticExplorer />, quadraticRoot);
|
||||
}
|
||||
const appDemos: Array<[string, () => import("solid-js").JSX.Element]> = [
|
||||
["projectile-demo", demos.ProjectileDemo],
|
||||
["profit-demo", demos.ProfitDemo],
|
||||
["area-demo", demos.AreaDemo],
|
||||
];
|
||||
for (const [id, Demo] of appDemos) {
|
||||
const root = document.getElementById(id);
|
||||
if (root) {
|
||||
render(() => <Demo />, root);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(() => renderBookMath());
|
||||
});
|
||||
}
|
||||
|
|
|
|||
25
spec.md
25
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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
224
themes/openfreebooks/sass/_chapter-heading-nav.scss
Normal file
224
themes/openfreebooks/sass/_chapter-heading-nav.scss
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
.chapter-heading-nav {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chapter-heading-nav > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chapter-heading-nav__rail-wrap {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 20px;
|
||||
z-index: 40;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.chapter-heading-nav__rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
padding: 10px 6px;
|
||||
}
|
||||
|
||||
.chapter-heading-nav__tick {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 3px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--line);
|
||||
cursor: pointer;
|
||||
transition: width 160ms ease, background 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
|
||||
.chapter-heading-nav__tick--h3 {
|
||||
width: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.chapter-heading-nav__tick--active {
|
||||
width: 26px;
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
.chapter-heading-nav__tick--h3.chapter-heading-nav__tick--active {
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.chapter-heading-nav__popover {
|
||||
position: absolute;
|
||||
right: 36px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: min(260px, calc(100vw - 80px));
|
||||
padding: 14px 12px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
background-color: #f5f4f1;
|
||||
color: var(--ink);
|
||||
isolation: isolate;
|
||||
box-shadow: 0 18px 48px var(--shadow-soft);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .chapter-heading-nav__popover {
|
||||
background-color: #1a1917;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .chapter-heading-nav__popover {
|
||||
background-color: #1a1917;
|
||||
}
|
||||
}
|
||||
|
||||
.chapter-heading-nav__popover-title {
|
||||
margin: 0 0 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chapter-heading-nav__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.chapter-heading-nav__item--h3 .chapter-heading-nav__link {
|
||||
padding-left: 14px;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.chapter-heading-nav__link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--soft-ink);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.chapter-heading-nav__link:hover,
|
||||
.chapter-heading-nav__item--active .chapter-heading-nav__link {
|
||||
background: rgba(154, 107, 46, 0.1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.chapter-heading-nav__fab {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 20px;
|
||||
z-index: 45;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 999px;
|
||||
background-color: #f5f4f1;
|
||||
isolation: isolate;
|
||||
box-shadow: 0 8px 24px var(--shadow-soft);
|
||||
color: var(--ink);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .chapter-heading-nav__fab {
|
||||
background-color: #1a1917;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .chapter-heading-nav__fab {
|
||||
background-color: #1a1917;
|
||||
}
|
||||
}
|
||||
|
||||
.chapter-heading-nav__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 46;
|
||||
border: none;
|
||||
background: rgba(26, 26, 26, 0.45);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .chapter-heading-nav__backdrop {
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .chapter-heading-nav__backdrop {
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
}
|
||||
}
|
||||
|
||||
.chapter-heading-nav__sheet {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 47;
|
||||
max-height: 70vh;
|
||||
padding: 16px 18px 28px;
|
||||
overflow-y: auto;
|
||||
border-radius: 16px 16px 0 0;
|
||||
border: 1px solid var(--card-border);
|
||||
background-color: #f5f4f1;
|
||||
color: var(--ink);
|
||||
isolation: isolate;
|
||||
box-shadow: 0 -18px 48px var(--shadow-soft);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .chapter-heading-nav__sheet {
|
||||
background-color: #1a1917;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .chapter-heading-nav__sheet {
|
||||
background-color: #1a1917;
|
||||
}
|
||||
}
|
||||
|
||||
.chapter-heading-nav__sheet-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chapter-heading-nav__sheet-close {
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.chapter-heading-nav__rail-wrap {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.chapter-heading-nav__fab,
|
||||
.chapter-heading-nav__backdrop,
|
||||
.chapter-heading-nav__sheet {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
466
themes/openfreebooks/sass/_scenario-demo.scss
Normal file
466
themes/openfreebooks/sass/_scenario-demo.scss
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
.scenario-demo {
|
||||
--math-grid: #e5e5e5;
|
||||
--math-axis: #a8a29e;
|
||||
--math-curve: #9a6b2e;
|
||||
--math-root: #2563eb;
|
||||
--math-canvas-bg: #ffffff;
|
||||
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
margin-top: 1.25rem;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 14px;
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 10px 32px var(--shadow-soft);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .scenario-demo {
|
||||
--math-grid: #3a3834;
|
||||
--math-axis: #8f8a80;
|
||||
--math-curve: #c4924a;
|
||||
--math-root: #60a5fa;
|
||||
--math-canvas-bg: #242120;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.scenario-demo {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.scenario-demo__stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.scenario-demo__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scenario-demo__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scenario-demo__stat {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(154, 107, 46, 0.04);
|
||||
}
|
||||
|
||||
.scenario-demo__stat--highlight {
|
||||
border-color: var(--accent);
|
||||
background: rgba(154, 107, 46, 0.1);
|
||||
}
|
||||
|
||||
.scenario-demo__stat-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.scenario-demo__stat-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.scenario-demo__note {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
color: var(--soft-ink);
|
||||
}
|
||||
|
||||
.scenario-demo__note--muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.scenario-demo__controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.scenario-demo__control {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.scenario-demo__label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.86rem;
|
||||
color: var(--soft-ink);
|
||||
}
|
||||
|
||||
.scenario-demo__value {
|
||||
font-family: var(--mono);
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.scenario-demo__slider {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.scenario-demo__slider:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.scenario-demo__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.scenario-demo__btn {
|
||||
padding: 10px 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scenario-demo__btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.scenario-demo__btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.scenario-demo__btn--primary {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.scenario-demo__btn--primary:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.scenario-demo__live {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--soft-ink);
|
||||
}
|
||||
|
||||
.scenario-demo__formula {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(154, 107, 46, 0.05);
|
||||
}
|
||||
|
||||
.scenario-demo__formula-label {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.scenario-demo__formula-math {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.scenario-demo__stage--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.scenario-demo__stage--wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Projectile */
|
||||
.projectile-scenario__canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.projectile-scenario__track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.projectile-scenario__sky {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(96, 165, 250, 0.2) 0%, rgba(154, 107, 46, 0.06) 100%);
|
||||
}
|
||||
|
||||
.projectile-scenario__ground {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 12px;
|
||||
background: #6b5a45;
|
||||
}
|
||||
|
||||
.projectile-scenario__ball {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-left: -11px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 30% 30%, #f97316, #c2410c);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
transition: bottom 120ms ease-out;
|
||||
}
|
||||
|
||||
.projectile-scenario__ground-hit {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--card-bg);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Profit */
|
||||
.profit-scenario__shop {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--accent);
|
||||
background: rgba(154, 107, 46, 0.06);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profit-scenario__sign {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.profit-scenario__price-tag {
|
||||
margin: 0;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.profit-scenario__per {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.profit-scenario__units {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.profit-scenario__scenario {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.55;
|
||||
color: var(--soft-ink);
|
||||
}
|
||||
|
||||
.profit-scenario__canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* Area */
|
||||
.area-scenario__garden {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(74, 107, 74, 0.08);
|
||||
}
|
||||
|
||||
.area-scenario__fence-label {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.area-scenario__grid-wrap {
|
||||
--cell-size: calc(100% / var(--grid-units, 10));
|
||||
position: relative;
|
||||
width: min(100%, 280px);
|
||||
aspect-ratio: 1;
|
||||
margin-inline: auto;
|
||||
border: 2px solid #6b5a45;
|
||||
border-radius: 8px;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.area-scenario__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--grid-units, 10), 1fr);
|
||||
grid-template-rows: repeat(var(--grid-units, 10), 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 1px;
|
||||
padding: 4px;
|
||||
background: #6b5a45;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.area-scenario__cell {
|
||||
display: block;
|
||||
background: rgba(134, 168, 106, 0.45);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.area-scenario__rect {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
background: rgba(154, 107, 46, 0.55);
|
||||
transition:
|
||||
width 80ms ease,
|
||||
height 80ms ease;
|
||||
}
|
||||
|
||||
.area-scenario__rect--dragging {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.area-scenario__rect--max {
|
||||
border-color: var(--success);
|
||||
background: rgba(42, 125, 82, 0.45);
|
||||
}
|
||||
|
||||
.area-scenario__corner {
|
||||
position: absolute;
|
||||
right: -7px;
|
||||
bottom: -7px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 2px solid var(--paper);
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: nwse-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.area-scenario__dim {
|
||||
position: absolute;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.area-scenario__dim--w {
|
||||
top: -18px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.area-scenario__dim--l {
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
.area-scenario__badge {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(42, 125, 82, 0.15);
|
||||
color: var(--success);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.area-scenario__badge--panel {
|
||||
min-height: 2.75rem;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.area-scenario__badge--panel.area-scenario__badge--visible {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.profit-scenario__optimum {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
background: rgba(154, 107, 46, 0.06);
|
||||
}
|
||||
|
||||
.profit-scenario__optimum--on {
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
background: rgba(42, 125, 82, 0.15);
|
||||
}
|
||||
|
|
@ -12,3 +12,5 @@
|
|||
@import "catalog";
|
||||
@import "search";
|
||||
@import "math-widget";
|
||||
@import "scenario-demo";
|
||||
@import "chapter-heading-nav";
|
||||
|
|
|
|||
|
|
@ -35,8 +35,10 @@
|
|||
</header>
|
||||
|
||||
{% if section.extra.chapter_id == "quadratic-equations" %}
|
||||
{% include "partials/math/quadratic-equations-content.html" %}
|
||||
<div id="chapter-heading-nav" data-pagefind-ignore></div>
|
||||
{% include "partials/math/quadratic-equations-core-content.html" %}
|
||||
<div id="quadratic-explorer" class="math-widget-mount" data-pagefind-ignore></div>
|
||||
{% include "partials/math/quadratic-equations-supplement-content.html" %}
|
||||
{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ themes/.../partials/... → chapter HTML bodies (today)</code></pre>
|
|||
<h3 class="contributing-section__subtitle">2. Zola section</h3>
|
||||
<p>Create <code>content/{subject}/{slug}/_index.md</code> with <code>chapter_id</code> matching the slug.</p>
|
||||
<h3 class="contributing-section__subtitle">3. Chapter HTML</h3>
|
||||
<p>Add <code>themes/openfreebooks/templates/partials/math/your-slug-content.html</code> and wire it in <code>math/chapter.html</code>. Copy patterns from <code>quadratic-equations-content.html</code>.</p>
|
||||
<p>Add <code>themes/openfreebooks/templates/partials/math/your-slug-content.html</code> and wire it in <code>math/chapter.html</code>. Copy patterns from <code>quadratic-equations-core-content.html</code>.</p>
|
||||
<h3 class="contributing-section__subtitle">4. Optional widget</h3>
|
||||
<p>Add a Solid component under <code>frontend/src/components/</code> and mount it in <code>main.tsx</code>. Run <code>bun run build:js</code>.</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
formula, or by reading information from the graph of \(y = ax^2 + bx + c\).
|
||||
</p>
|
||||
|
||||
<h2 class="book-prose__heading">The discriminant</h2>
|
||||
<h2 id="discriminant" class="book-prose__heading">The discriminant</h2>
|
||||
<p>
|
||||
The <strong>discriminant</strong> \(\Delta\) (delta) is defined below. It tells you how many
|
||||
<em>real</em> roots the equation has without fully solving it.
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
<p class="book-formula">\[ \Delta = b^2 - 4ac \]</p>
|
||||
</div>
|
||||
|
||||
<h2 class="book-prose__heading">Nature of roots</h2>
|
||||
<h2 id="nature-of-roots" class="book-prose__heading">Nature of roots</h2>
|
||||
<div class="book-table-wrap">
|
||||
<table class="book-table">
|
||||
<thead>
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
\[ x = \frac{-b \pm \sqrt{\Delta}}{2a} \]
|
||||
</p>
|
||||
|
||||
<h2 class="book-prose__heading">Try it yourself</h2>
|
||||
<h2 id="try-it-yourself" class="book-prose__heading">Try it yourself</h2>
|
||||
<p>
|
||||
Use the explorer below to change \(a\), \(b\), and \(c\). Watch how \(\Delta\) and the
|
||||
parabola change, and read off the nature of the roots.
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
<div class="book-prose">
|
||||
<h2 id="history" class="book-prose__heading">History</h2>
|
||||
<p>
|
||||
People have worked on problems that lead to quadratic equations for thousands of years.
|
||||
Babylonian clay tablets from around 1800 BCE record area and length puzzles — for example,
|
||||
finding the sides of a rectangle when you know its area and perimeter. Those situations
|
||||
produce equations in one unknown whose highest power is two.
|
||||
</p>
|
||||
|
||||
<figure class="book-figure">
|
||||
<img
|
||||
class="book-figure__img"
|
||||
src="{{ get_url(path='img/math/babylonian-quadratic-tablet.png', trailing_slash=false) | safe }}"
|
||||
alt="Babylonian clay tablet with cuneiform text recording a quadratic area problem"
|
||||
width="960"
|
||||
height="640"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<figcaption class="book-figure__caption">
|
||||
A Babylonian clay tablet (about 1800 BCE) with a problem equivalent to finding the
|
||||
sides of a rectangle from its area and perimeter.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p>
|
||||
Early methods were often geometric: you imagine completing a rectangle to make a square,
|
||||
which is the same idea behind <em>completing the square</em> today. In the 9th century,
|
||||
<strong>al-Khwarizmi</strong> wrote systematic rules for many types of quadratic problems,
|
||||
sorted by which coefficients were present. He did not use modern symbols like \(x^2\), but
|
||||
the logic matches what we still teach.
|
||||
</p>
|
||||
<p>
|
||||
During the Renaissance, mathematicians such as <strong>Cardano</strong> and
|
||||
<strong>Tartaglia</strong> pushed further into solving cubics and quartics. Along the way they
|
||||
met equations whose discriminants were negative — no real roots, yet the algebra still
|
||||
demanded answers. That tension helped open the door to <strong>complex numbers</strong>, a
|
||||
topic you will meet in a later chapter.
|
||||
</p>
|
||||
|
||||
<h2 id="derivation" class="book-prose__heading">Derivation</h2>
|
||||
<p>
|
||||
The quadratic formula comes from rearranging \(ax^2 + bx + c = 0\) and completing the square.
|
||||
Below is a guided derivation; routine algebra between steps is left for you to check.
|
||||
</p>
|
||||
<p>
|
||||
Start with \(a \neq 0\) and divide every term by \(a\):
|
||||
\[ x^2 + \frac{b}{a}x + \frac{c}{a} = 0. \]
|
||||
Move the constant to the right:
|
||||
\[ x^2 + \frac{b}{a}x = -\frac{c}{a}. \]
|
||||
</p>
|
||||
<p>
|
||||
The expression \(x^2 + \frac{b}{a}x\) is almost a perfect square. Add and subtract
|
||||
\(\left(\frac{b}{2a}\right)^2\) on the left (equivalently, add that square to both sides):
|
||||
\[ x^2 + \frac{b}{a}x + \left(\frac{b}{2a}\right)^2 = -\frac{c}{a} + \left(\frac{b}{2a}\right)^2. \]
|
||||
The left side factors as a square:
|
||||
\[ \left(x + \frac{b}{2a}\right)^2 = \frac{b^2 - 4ac}{4a^2}. \]
|
||||
</p>
|
||||
<p>
|
||||
Take square roots (when the right-hand side is non-negative you get real solutions):
|
||||
\[ x + \frac{b}{2a} = \pm \frac{\sqrt{b^2 - 4ac}}{2a}. \]
|
||||
Solve for \(x\):
|
||||
</p>
|
||||
<div class="book-callout book-callout--formula" role="note">
|
||||
<p class="book-callout__label">Quadratic formula</p>
|
||||
<p class="book-formula">
|
||||
\[ x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a} \]
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
The quantity under the square root is exactly the discriminant:
|
||||
\(\Delta = b^2 - 4ac\). So the formula is \(x = \frac{-b \pm \sqrt{\Delta}}{2a}\) whenever
|
||||
\(\Delta \geq 0\). If \(\Delta > 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.
|
||||
</p>
|
||||
|
||||
<h2 id="checkpoints" class="book-prose__heading">Checkpoints</h2>
|
||||
<p>
|
||||
Pause and think before moving on. There are no answers on this page — discuss with a
|
||||
classmate or write your reasoning in a notebook.
|
||||
</p>
|
||||
|
||||
<div class="book-callout" role="note">
|
||||
<p class="book-callout__label">Checkpoint</p>
|
||||
<p>
|
||||
In the explorer, keep \(a\) and \(b\) fixed and slowly change \(c\) so that \(\Delta\) goes
|
||||
from positive to zero. What happens to the two x-intercepts on the graph?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="book-callout" role="note">
|
||||
<p class="book-callout__label">Checkpoint</p>
|
||||
<p>
|
||||
Can \(ax^2 + bx + c = 0\) with \(a \neq 0\) have three distinct real solutions? Why or why not?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="book-callout" role="note">
|
||||
<p class="book-callout__label">Checkpoint</p>
|
||||
<p>
|
||||
Suppose \(a > 0\) and \(\Delta < 0\). Where does the entire parabola \(y = ax^2 + bx + c\)
|
||||
sit relative to the x-axis?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="book-callout" role="note">
|
||||
<p class="book-callout__label">Checkpoint</p>
|
||||
<p>
|
||||
A ball is thrown straight upward from ground level. Without using numbers, describe a
|
||||
situation where \(\Delta > 0\) for height versus time, and another where \(\Delta = 0\).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="book-callout" role="note">
|
||||
<p class="book-callout__label">Checkpoint</p>
|
||||
<p>
|
||||
After completing the square you had \(\left(x + \frac{b}{2a}\right)^2 = \frac{\Delta}{4a^2}\).
|
||||
Why must the sign of \(\Delta\) match whether the parabola crosses the x-axis?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 id="applications" class="book-prose__heading">Applications</h2>
|
||||
|
||||
<h3 id="projectile-motion" class="book-prose__heading">Projectile motion</h3>
|
||||
<p>
|
||||
Near Earth’s surface, height \(h\) (in metres) of an object thrown upward often follows
|
||||
\[ h = -\tfrac{1}{2}gt^2 + v_0 t + h_0, \]
|
||||
where \(t\) is time in seconds, \(g \approx 9.8\) is gravitational acceleration,
|
||||
\(v_0\) is initial upward speed, and \(h_0\) is starting height. This is a quadratic in
|
||||
\(t\). Setting \(h = 0\) asks when the object is at ground level; the solutions are the
|
||||
landing times. The discriminant tells you whether there are two crossing times
|
||||
(\(\Delta > 0\)), one (\(\Delta = 0\)), or none (\(\Delta < 0\)) — for example if the
|
||||
model never reaches the ground in the time you care about.
|
||||
</p>
|
||||
|
||||
<div id="projectile-demo" class="math-widget-mount" data-pagefind-ignore></div>
|
||||
|
||||
<h3 id="profit-pricing" class="book-prose__heading">Profit and pricing</h3>
|
||||
<p>
|
||||
A shop might find that when it charges \(p\) dollars per item, revenue is \(p\) times
|
||||
quantity sold, while cost has a fixed part plus a cost per item. Profit (revenue minus cost)
|
||||
is often a quadratic in \(p\) that opens downward — there is a best price at the vertex.
|
||||
For example, with profit \(\Pi\) in dollars,
|
||||
\[ \Pi(p) = -2p^2 + 40p - 150. \]
|
||||
Setting \(\Pi = 0\) asks for <em>break-even</em> prices; \(\Delta\) tells you whether two
|
||||
break-even prices exist, one, or none.
|
||||
</p>
|
||||
|
||||
<div id="profit-demo" class="math-widget-mount" data-pagefind-ignore></div>
|
||||
|
||||
<h3 id="area-fixed-perimeter" class="book-prose__heading">Area with fixed perimeter</h3>
|
||||
<p>
|
||||
A rectangle with perimeter \(P\) can be described by width \(x\) and length
|
||||
\(\frac{P}{2} - x\). Its area is
|
||||
\[ A(x) = x\left(\frac{P}{2} - x\right) = -\!x^2 + \frac{P}{2}x, \]
|
||||
a downward-opening parabola. The largest area occurs at the vertex, not at the extremes
|
||||
\(x = 0\) or \(x = \frac{P}{2}\) (which give zero area).
|
||||
</p>
|
||||
|
||||
<div id="area-demo" class="math-widget-mount" data-pagefind-ignore></div>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue