mirror of
https://github.com/HananoshikaYomaru/openfreebooks.git
synced 2026-11-13 21:15:54 +00:00
Update .gitignore to exclude generated chapter sync output, enhance package.json scripts for chapter synchronization, and update README and CONTRIBUTING.md for improved clarity on chapter structure and development processes. Remove obsolete components related to quadratic demos to streamline the project.
This commit is contained in:
parent
742a72abe2
commit
2ac34d52fc
23 changed files with 700 additions and 214 deletions
|
|
@ -1,750 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,558 +0,0 @@
|
|||
import katex from "katex";
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js";
|
||||
|
||||
const VIEW_SIZE = 400;
|
||||
const UNIT = 20;
|
||||
const ORIGIN = VIEW_SIZE / 2;
|
||||
const X_RANGE = 10;
|
||||
/** Target size on screen (CSS pixels), scaled into canvas space in hitTestHover. */
|
||||
const VERTEX_HIT_SCREEN = 28;
|
||||
const ROOT_HIT_SCREEN = 22;
|
||||
const CURVE_HIT_SCREEN = 14;
|
||||
const HOVER_RADIUS = 8;
|
||||
const POINT_RADIUS = 5;
|
||||
const CURVE_SAMPLE_STEP = 0.15;
|
||||
|
||||
type PointKind = "vertex" | "root" | "curve";
|
||||
|
||||
type GraphHover = {
|
||||
kind: PointKind;
|
||||
x: number;
|
||||
y: number;
|
||||
label: string;
|
||||
canvasX: number;
|
||||
canvasY: number;
|
||||
};
|
||||
|
||||
function round(n: number, digits = 2) {
|
||||
const p = 10 ** digits;
|
||||
return Math.round(n * p) / p;
|
||||
}
|
||||
|
||||
function formatNum(n: number) {
|
||||
if (Number.isInteger(n)) return String(n);
|
||||
return round(n, 2).toString();
|
||||
}
|
||||
|
||||
function formatCoord(x: number, y: number) {
|
||||
return `(${formatNum(x)}, ${formatNum(y)})`;
|
||||
}
|
||||
|
||||
function toCanvasX(x: number) {
|
||||
return ORIGIN + x * UNIT;
|
||||
}
|
||||
|
||||
function toCanvasY(y: number) {
|
||||
return ORIGIN - y * UNIT;
|
||||
}
|
||||
|
||||
function clamp(n: number, lo: number, hi: number) {
|
||||
return Math.max(lo, Math.min(hi, n));
|
||||
}
|
||||
|
||||
function discriminant(a: number, b: number, c: number) {
|
||||
return b * b - 4 * a * c;
|
||||
}
|
||||
|
||||
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)];
|
||||
}
|
||||
|
||||
function natureOfRoots(delta: number) {
|
||||
if (delta > 0) return "Two distinct real roots";
|
||||
if (delta === 0) return "One repeated real root (equal roots)";
|
||||
return "No real roots";
|
||||
}
|
||||
|
||||
function renderLatex(latex: string) {
|
||||
return katex.renderToString(latex, { throwOnError: false, strict: "ignore" });
|
||||
}
|
||||
|
||||
function readCanvasColors(canvas: HTMLCanvasElement) {
|
||||
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",
|
||||
vertex: style.getPropertyValue("--math-vertex").trim() || "#4a6b4a",
|
||||
root: style.getPropertyValue("--math-root").trim() || "#2563eb",
|
||||
bg: style.getPropertyValue("--math-canvas-bg").trim() || "#ffffff",
|
||||
};
|
||||
}
|
||||
|
||||
function pointerToCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
clientX: number,
|
||||
clientY: number
|
||||
) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = VIEW_SIZE / rect.width;
|
||||
const scaleY = VIEW_SIZE / rect.height;
|
||||
const px = (clientX - rect.left) * scaleX;
|
||||
const py = (clientY - rect.top) * scaleY;
|
||||
const x = (px - ORIGIN) / UNIT;
|
||||
const y = -(py - ORIGIN) / UNIT;
|
||||
return { px, py, x, y };
|
||||
}
|
||||
|
||||
function distPx(px1: number, py1: number, px2: number, py2: number) {
|
||||
return Math.hypot(px1 - px2, py1 - py2);
|
||||
}
|
||||
|
||||
function canvasHitRadius(canvas: HTMLCanvasElement, screenRadius: number) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scale = rect.width > 0 ? VIEW_SIZE / rect.width : 1;
|
||||
return screenRadius * scale;
|
||||
}
|
||||
|
||||
function nearestCurveHover(
|
||||
a: number,
|
||||
b: number,
|
||||
c: number,
|
||||
px: number,
|
||||
py: number
|
||||
): { hover: GraphHover; dist: number } | null {
|
||||
let bestX = 0;
|
||||
let bestY = 0;
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (let x = -X_RANGE; x <= X_RANGE; x += CURVE_SAMPLE_STEP) {
|
||||
const y = a * x * x + b * x + c;
|
||||
const d = distPx(px, py, toCanvasX(x), toCanvasY(y));
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestX = x;
|
||||
bestY = y;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(bestDist)) return null;
|
||||
|
||||
const cx = toCanvasX(bestX);
|
||||
const cy = toCanvasY(bestY);
|
||||
return {
|
||||
dist: bestDist,
|
||||
hover: {
|
||||
kind: "curve",
|
||||
x: bestX,
|
||||
y: bestY,
|
||||
label: "On curve",
|
||||
canvasX: cx,
|
||||
canvasY: cy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hitTestHover(
|
||||
canvas: HTMLCanvasElement,
|
||||
a: number,
|
||||
b: number,
|
||||
c: number,
|
||||
rootXs: number[],
|
||||
px: number,
|
||||
py: number
|
||||
): GraphHover | null {
|
||||
const vertexRadius = canvasHitRadius(canvas, VERTEX_HIT_SCREEN);
|
||||
const rootRadius = canvasHitRadius(canvas, ROOT_HIT_SCREEN);
|
||||
const curveRadius = canvasHitRadius(canvas, CURVE_HIT_SCREEN);
|
||||
|
||||
if (Math.abs(a) > 1e-9) {
|
||||
const vx = -b / (2 * a);
|
||||
const vy = a * vx * vx + b * vx + c;
|
||||
const cx = toCanvasX(vx);
|
||||
const cy = toCanvasY(vy);
|
||||
const d = distPx(px, py, cx, cy);
|
||||
if (d < vertexRadius) {
|
||||
return {
|
||||
kind: "vertex",
|
||||
x: vx,
|
||||
y: vy,
|
||||
label: "Vertex",
|
||||
canvasX: cx,
|
||||
canvasY: cy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of rootXs) {
|
||||
const cx = toCanvasX(root);
|
||||
const cy = toCanvasY(0);
|
||||
const d = distPx(px, py, cx, cy);
|
||||
if (d < rootRadius) {
|
||||
return {
|
||||
kind: "root",
|
||||
x: root,
|
||||
y: 0,
|
||||
label: "Root",
|
||||
canvasX: cx,
|
||||
canvasY: cy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const curve = nearestCurveHover(a, b, c, px, py);
|
||||
if (curve && curve.dist < curveRadius) {
|
||||
return curve.hover;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function drawGraph(
|
||||
canvas: HTMLCanvasElement,
|
||||
a: number,
|
||||
b: number,
|
||||
c: number,
|
||||
rootXs: number[],
|
||||
hover: GraphHover | null
|
||||
) {
|
||||
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 = readCanvasColors(canvas);
|
||||
|
||||
ctx.fillStyle = colors.bg;
|
||||
ctx.fillRect(0, 0, VIEW_SIZE, VIEW_SIZE);
|
||||
|
||||
ctx.strokeStyle = colors.grid;
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = -X_RANGE; i <= X_RANGE; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toCanvasX(i), 0);
|
||||
ctx.lineTo(toCanvasX(i), VIEW_SIZE);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, toCanvasY(i));
|
||||
ctx.lineTo(VIEW_SIZE, toCanvasY(i));
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = colors.axis;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ORIGIN, 0);
|
||||
ctx.lineTo(ORIGIN, VIEW_SIZE);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, ORIGIN);
|
||||
ctx.lineTo(VIEW_SIZE, ORIGIN);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = colors.curve;
|
||||
ctx.lineWidth = hover?.kind === "curve" ? 3.5 : 3;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
for (let x = -X_RANGE; x <= X_RANGE; x += 0.25) {
|
||||
const y = a * x * x + b * x + c;
|
||||
const px = toCanvasX(x);
|
||||
const py = toCanvasY(y);
|
||||
if (!started) {
|
||||
ctx.moveTo(px, py);
|
||||
started = true;
|
||||
} else {
|
||||
ctx.lineTo(px, py);
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
const drawMarker = (
|
||||
cx: number,
|
||||
cy: number,
|
||||
fill: string,
|
||||
active: boolean
|
||||
) => {
|
||||
const r = active ? HOVER_RADIUS : POINT_RADIUS;
|
||||
ctx.fillStyle = fill;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
if (active) {
|
||||
ctx.strokeStyle = colors.bg;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
if (Math.abs(a) > 1e-9) {
|
||||
const vx = -b / (2 * a);
|
||||
const vy = a * vx * vx + b * vx + c;
|
||||
if (vx >= -X_RANGE && vx <= X_RANGE && vy >= -X_RANGE && vy <= X_RANGE) {
|
||||
const cx = toCanvasX(vx);
|
||||
const cy = toCanvasY(vy);
|
||||
const active = hover?.kind === "vertex";
|
||||
drawMarker(cx, cy, colors.vertex, active);
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of rootXs) {
|
||||
if (root >= -X_RANGE && root <= X_RANGE) {
|
||||
const cx = toCanvasX(root);
|
||||
const cy = toCanvasY(0);
|
||||
const active = hover?.kind === "root" && Math.abs(hover.x - root) < 1e-6;
|
||||
drawMarker(cx, cy, colors.root, active);
|
||||
}
|
||||
}
|
||||
|
||||
if (hover?.kind === "curve") {
|
||||
drawMarker(hover.canvasX, hover.canvasY, colors.curve, true);
|
||||
}
|
||||
}
|
||||
|
||||
function SliderRow(props: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
accentClass?: string;
|
||||
onInput: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div class="math-slider-row">
|
||||
<label class="math-slider-label" for={props.id}>
|
||||
<span>{props.label}</span>
|
||||
<span class="math-slider-value">{formatNum(props.value)}</span>
|
||||
</label>
|
||||
<input
|
||||
id={props.id}
|
||||
class={`math-slider ${props.accentClass ?? ""}`}
|
||||
type="range"
|
||||
min={props.min}
|
||||
max={props.max}
|
||||
step={props.step}
|
||||
value={props.value}
|
||||
onInput={(e) => props.onInput(Number(e.currentTarget.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuadraticExplorer() {
|
||||
const [a, setA] = createSignal(1);
|
||||
const [b, setB] = createSignal(0);
|
||||
const [c, setC] = createSignal(-4);
|
||||
const [hover, setHover] = createSignal<GraphHover | null>(null);
|
||||
const [tooltipPos, setTooltipPos] = createSignal({ left: 0, top: 0 });
|
||||
let canvasRef: HTMLCanvasElement | undefined;
|
||||
let wrapRef: HTMLDivElement | undefined;
|
||||
|
||||
const aSafe = createMemo(() => {
|
||||
const v = a();
|
||||
if (v === 0) return a() > 0 ? 1 : -1;
|
||||
return v;
|
||||
});
|
||||
|
||||
const delta = createMemo(() => discriminant(aSafe(), b(), c()));
|
||||
const nature = createMemo(() => natureOfRoots(delta()));
|
||||
const rootValues = createMemo(() => computeRoots(aSafe(), b(), c(), delta()));
|
||||
|
||||
const equationHtml = createMemo(() =>
|
||||
renderLatex(`y = ${formatNum(aSafe())}x^2 + ${formatNum(b())}x + ${formatNum(c())}`)
|
||||
);
|
||||
|
||||
const deltaHtml = createMemo(() => {
|
||||
const av = aSafe();
|
||||
const bv = b();
|
||||
const cv = c();
|
||||
const d = delta();
|
||||
const tone = d > 0 ? "pos" : d < 0 ? "neg" : "zero";
|
||||
return {
|
||||
html: renderLatex(`\\Delta = ${formatNum(bv)}^2 - 4(${formatNum(av)})(${formatNum(cv)}) = ${formatNum(d)}`),
|
||||
tone,
|
||||
};
|
||||
});
|
||||
|
||||
const handleAChange = (raw: number) => {
|
||||
if (raw === 0) {
|
||||
setA(a() > 0 ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
setA(raw);
|
||||
};
|
||||
|
||||
const redraw = () => {
|
||||
if (canvasRef) {
|
||||
drawGraph(canvasRef, aSafe(), b(), c(), rootValues(), hover());
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
aSafe();
|
||||
b();
|
||||
c();
|
||||
rootValues();
|
||||
hover();
|
||||
redraw();
|
||||
});
|
||||
|
||||
const updateTooltipPosition = (clientX: number, clientY: number) => {
|
||||
if (!wrapRef) return;
|
||||
const rect = wrapRef.getBoundingClientRect();
|
||||
const pad = 12;
|
||||
let left = clientX - rect.left + pad;
|
||||
let top = clientY - rect.top - pad;
|
||||
const maxLeft = rect.width - 140;
|
||||
const maxTop = rect.height - 48;
|
||||
left = clamp(left, pad, maxLeft);
|
||||
top = clamp(top, pad, maxTop);
|
||||
setTooltipPos({ left, top });
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const canvas = canvasRef;
|
||||
if (!canvas) return;
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const { px, py } = pointerToCanvas(canvas, e.clientX, e.clientY);
|
||||
const hit = hitTestHover(canvas, aSafe(), b(), c(), rootValues(), px, py);
|
||||
setHover(hit);
|
||||
if (hit) {
|
||||
updateTooltipPosition(e.clientX, e.clientY);
|
||||
canvas.setAttribute("aria-label", `${hit.label} at ${formatCoord(hit.x, hit.y)}`);
|
||||
} else {
|
||||
canvas.setAttribute(
|
||||
"aria-label",
|
||||
`Graph of y equals ${formatNum(aSafe())} x squared plus ${formatNum(b())} x plus ${formatNum(c())}. Hover for coordinates.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onLeave = () => {
|
||||
setHover(null);
|
||||
canvas.setAttribute(
|
||||
"aria-label",
|
||||
`Graph of y equals ${formatNum(aSafe())} x squared plus ${formatNum(b())} x plus ${formatNum(c())}. Hover for coordinates.`
|
||||
);
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointermove", onMove);
|
||||
canvas.addEventListener("pointerleave", onLeave);
|
||||
canvas.addEventListener("pointerdown", onMove);
|
||||
|
||||
onCleanup(() => {
|
||||
canvas.removeEventListener("pointermove", onMove);
|
||||
canvas.removeEventListener("pointerleave", onLeave);
|
||||
canvas.removeEventListener("pointerdown", onMove);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<section class="math-widget" aria-labelledby="quadratic-explorer-title">
|
||||
<h2 id="quadratic-explorer-title" class="math-widget__title">
|
||||
Quadratic parabola & discriminant explorer
|
||||
</h2>
|
||||
|
||||
<div class="math-widget__layout">
|
||||
<div class="math-widget__controls">
|
||||
<div class="math-widget__equation-box" aria-live="polite">
|
||||
<p class="math-widget__equation-label">Equation</p>
|
||||
<div class="math-widget__equation" innerHTML={equationHtml()} />
|
||||
</div>
|
||||
|
||||
<SliderRow
|
||||
id="coef-a"
|
||||
label="Value of a (cannot be 0)"
|
||||
value={a()}
|
||||
min={-5}
|
||||
max={5}
|
||||
step={1}
|
||||
accentClass="math-slider--a"
|
||||
onInput={handleAChange}
|
||||
/>
|
||||
<SliderRow
|
||||
id="coef-b"
|
||||
label="Value of b"
|
||||
value={b()}
|
||||
min={-10}
|
||||
max={10}
|
||||
step={1}
|
||||
accentClass="math-slider--b"
|
||||
onInput={setB}
|
||||
/>
|
||||
<SliderRow
|
||||
id="coef-c"
|
||||
label="Value of c"
|
||||
value={c()}
|
||||
min={-10}
|
||||
max={10}
|
||||
step={1}
|
||||
accentClass="math-slider--c"
|
||||
onInput={setC}
|
||||
/>
|
||||
|
||||
<div class="math-widget__delta-panel" aria-live="polite">
|
||||
<h3 class="math-widget__delta-title">Discriminant (Δ)</h3>
|
||||
<div
|
||||
class="math-widget__delta-formula"
|
||||
classList={{ [`math-widget__delta-formula--${deltaHtml().tone}`]: true }}
|
||||
innerHTML={deltaHtml().html}
|
||||
/>
|
||||
<p class="math-widget__delta-nature">{nature()}</p>
|
||||
<Show when={rootValues().length > 0}>
|
||||
<p class="math-widget__delta-roots">
|
||||
Roots at x ≈ {rootValues().map((r) => formatNum(r)).join(", ")}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<figure class="math-widget__graph">
|
||||
<div
|
||||
class="math-widget__canvas-wrap"
|
||||
ref={(el) => {
|
||||
wrapRef = el;
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={(el) => {
|
||||
canvasRef = el;
|
||||
}}
|
||||
class="math-widget__canvas"
|
||||
classList={{ "math-widget__canvas--active": !!hover() }}
|
||||
width={VIEW_SIZE}
|
||||
height={VIEW_SIZE}
|
||||
role="img"
|
||||
aria-label={`Graph of y equals ${formatNum(aSafe())} x squared plus ${formatNum(b())} x plus ${formatNum(c())}. Hover for coordinates.`}
|
||||
/>
|
||||
<Show when={hover()}>
|
||||
{(h) => (
|
||||
<div
|
||||
class="math-graph-tooltip"
|
||||
classList={{
|
||||
[`math-graph-tooltip--${h().kind}`]: true,
|
||||
}}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
left: `${tooltipPos().left}px`,
|
||||
top: `${tooltipPos().top}px`,
|
||||
}}
|
||||
>
|
||||
<span class="math-graph-tooltip__label">{h().label}</span>
|
||||
<span class="math-graph-tooltip__coords">{formatCoord(h().x, h().y)}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<figcaption class="math-widget__caption">
|
||||
Hover the curve, vertex (green), or roots (blue) to read coordinates. Grid: x and y from
|
||||
−10 to 10.
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -36,6 +36,40 @@ function mountFooterYear() {
|
|||
}
|
||||
}
|
||||
|
||||
function mountChapterWidgets() {
|
||||
const article = document.querySelector<HTMLElement>("[data-chapter]");
|
||||
const chapterKey = article?.dataset.chapter;
|
||||
const mounts = document.querySelectorAll<HTMLElement>("[data-widget]");
|
||||
if (!chapterKey || mounts.length === 0) return;
|
||||
|
||||
void import("./generated/chapter-widgets").then(({ chapterWidgetLoaders }) => {
|
||||
const loadPromises: Promise<void>[] = [];
|
||||
|
||||
for (const el of mounts) {
|
||||
const widget = el.dataset.widget;
|
||||
if (!widget) continue;
|
||||
const loaderKey = `${chapterKey}/${widget}`;
|
||||
const loader = chapterWidgetLoaders[loaderKey];
|
||||
if (!loader) {
|
||||
console.warn(`No widget loader registered for ${loaderKey}`);
|
||||
continue;
|
||||
}
|
||||
loadPromises.push(
|
||||
loader().then((mod) => {
|
||||
render(() => mod.default(), el);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
void Promise.all(loadPromises).then(() => {
|
||||
if (!document.querySelector(".book-prose")) return;
|
||||
void import("./lib/render-math").then(({ renderBookMath }) => {
|
||||
requestAnimationFrame(() => renderBookMath());
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initTheme();
|
||||
|
||||
const config = readConfig();
|
||||
|
|
@ -78,12 +112,6 @@ if (copyPageRoot) {
|
|||
});
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
|
|
@ -91,30 +119,7 @@ if (chapterNavRoot) {
|
|||
});
|
||||
}
|
||||
|
||||
if (hasQuadraticChapter) {
|
||||
void Promise.all([
|
||||
import("./components/quadratic-explorer"),
|
||||
import("./components/quadratic-application-demos"),
|
||||
import("./lib/render-math"),
|
||||
]).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());
|
||||
});
|
||||
}
|
||||
mountChapterWidgets();
|
||||
|
||||
const catalogRoot = document.getElementById("catalog-app");
|
||||
if (catalogRoot) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue