Files
cabrits-math/components/explorers/rounding-explorer.tsx
2026-03-01 18:50:29 -04:00

560 lines
17 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
type RoundTarget = "whole" | "1dp" | "2dp" | "1sf" | "2sf" | "3sf";
interface Step {
label: string;
digits: DigitInfo[];
resultText?: string;
}
interface DigitInfo {
char: string;
state: "normal" | "target" | "decider" | "dim" | "significant";
}
function getDigits(value: string): string[] {
return value.split("");
}
// ── Decimal place rounding steps ─────────────────────────────────────────────
function buildDPSteps(rawValue: string, target: "whole" | "1dp" | "2dp"): Step[] {
const value = parseFloat(rawValue);
const digits = getDigits(rawValue);
const dotIndex = digits.indexOf(".");
const steps: Step[] = [];
function makeDigits(
targetIdx: number | null,
deciderIdx: number | null,
): DigitInfo[] {
return digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: i === targetIdx
? "target"
: i === deciderIdx
? "decider"
: "normal",
}));
}
let targetDigitIdx: number;
let roundedValue: number;
let targetLabel: string;
if (target === "whole") {
targetDigitIdx = dotIndex === -1 ? digits.length - 1 : dotIndex - 1;
roundedValue = Math.round(value);
targetLabel = "ones (whole number)";
} else if (target === "1dp") {
targetDigitIdx = dotIndex + 1;
roundedValue = Math.round(value * 10) / 10;
targetLabel = "1 decimal place (tenths)";
} else {
targetDigitIdx = dotIndex + 2;
roundedValue = Math.round(value * 100) / 100;
targetLabel = "2 decimal places (hundredths)";
}
// Compute decider index (skip over decimal point if needed)
const nextIdx = targetDigitIdx + 1;
const deciderIdx =
nextIdx < digits.length && digits[nextIdx] === "."
? nextIdx + 1
: nextIdx;
// Step 0: Show the number
steps.push({
label: `Rounding ${rawValue} to ${targetLabel}`,
digits: makeDigits(null, null),
});
// Step 1: Identify target
steps.push({
label: `Identify the target digit (${targetLabel})`,
digits: makeDigits(targetDigitIdx, null),
});
// Step 2: Identify decider
if (deciderIdx < digits.length) {
steps.push({
label: "Look at the digit to the right (the decider)",
digits: makeDigits(targetDigitIdx, deciderIdx),
});
const deciderDigit = parseInt(digits[deciderIdx]);
const roundsUp = deciderDigit >= 5;
steps.push({
label: roundsUp
? `Decider is ${deciderDigit} (≥ 5) → round UP`
: `Decider is ${deciderDigit} (< 5) → round DOWN`,
digits: makeDigits(targetDigitIdx, deciderIdx),
});
} else {
steps.push({
label: "No digit to the right — no rounding needed",
digits: makeDigits(targetDigitIdx, null),
});
}
// Step 3: Result
const resultStr =
target === "whole"
? roundedValue.toString()
: target === "1dp"
? roundedValue.toFixed(1)
: roundedValue.toFixed(2);
const resultDigits = getDigits(resultStr);
steps.push({
label: `Result: ${resultStr}`,
digits: resultDigits.map((ch) => ({
char: ch,
state: "normal" as const,
})),
resultText: resultStr,
});
return steps;
}
// ── Significant figures rounding steps ───────────────────────────────────────
function buildSFSteps(rawValue: string, sfCount: number): Step[] {
const value = parseFloat(rawValue);
const digits = getDigits(rawValue);
const steps: Step[] = [];
// Step 0: Show the number
steps.push({
label: `Rounding ${rawValue} to ${sfCount} significant figure${sfCount > 1 ? "s" : ""}`,
digits: digits.map((ch) => ({ char: ch, state: "normal" as const })),
});
// Step 1: Identify which digits are significant
// Rule: significant figures start at the first non-zero digit
const sigDigitIndices: number[] = [];
let foundFirstNonZero = false;
for (let i = 0; i < digits.length; i++) {
if (digits[i] === ".") continue;
if (!foundFirstNonZero && digits[i] === "0") continue;
foundFirstNonZero = true;
sigDigitIndices.push(i);
}
// Build display: highlight all significant digits found
const sigSet = new Set(sigDigitIndices);
steps.push({
label: `Identify significant figures — start from the first non-zero digit`,
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: sigSet.has(i)
? "significant"
: "dim",
})),
});
// Count and label the sig figs we need
const sigLabel = sigDigitIndices.slice(0, sfCount).map((idx, n) => {
return `${ordinal(n + 1)} s.f. = ${digits[idx]}`;
});
const targetIdx = sigDigitIndices[sfCount - 1]; // the last sig fig we keep
if (targetIdx === undefined) {
// Not enough significant figures in the number
steps.push({
label: `The number only has ${sigDigitIndices.length} significant figure${sigDigitIndices.length !== 1 ? "s" : ""} — already rounded`,
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: sigSet.has(i)
? "significant"
: "dim",
})),
resultText: rawValue,
});
return steps;
}
// Step 2: Show which sig figs we're keeping
const keepSet = new Set(sigDigitIndices.slice(0, sfCount));
steps.push({
label: `Keep ${sfCount} sig fig${sfCount > 1 ? "s" : ""}: ${sigLabel.join(", ")}`,
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: keepSet.has(i)
? "target"
: sigSet.has(i)
? "normal"
: "dim",
})),
});
// Step 3: Identify the decider
const deciderCandidateIdx = sigDigitIndices[sfCount]; // next sig fig after the ones we keep
// But the decider could also be after a decimal point
let deciderIdx: number | null = null;
if (deciderCandidateIdx !== undefined) {
deciderIdx = deciderCandidateIdx;
} else {
// Look for any digit after the target
for (let i = targetIdx + 1; i < digits.length; i++) {
if (digits[i] !== ".") {
deciderIdx = i;
break;
}
}
}
if (deciderIdx !== null && deciderIdx < digits.length) {
steps.push({
label: `Look at the next digit: ${digits[deciderIdx]} (the decider)`,
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: i === deciderIdx
? "decider"
: keepSet.has(i)
? "target"
: "dim",
})),
});
// Step 4: Round decision
const deciderDigit = parseInt(digits[deciderIdx]);
const roundsUp = deciderDigit >= 5;
steps.push({
label: roundsUp
? `Decider is ${deciderDigit} (≥ 5) → round UP the last kept digit`
: `Decider is ${deciderDigit} (< 5) → round DOWN (keep it the same)`,
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: i === deciderIdx
? "decider"
: keepSet.has(i)
? "target"
: "dim",
})),
});
} else {
steps.push({
label: "No digit to the right — no rounding needed",
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: keepSet.has(i)
? "target"
: "dim",
})),
});
}
// Step 5: Compute and show result
let roundedValue: number;
if (value === 0) {
roundedValue = 0;
} else {
const magnitude = Math.floor(Math.log10(Math.abs(value)));
const factor = Math.pow(10, sfCount - 1 - magnitude);
roundedValue = Math.round(value * factor) / factor;
}
// Format result with correct sig figs (preserve trailing zeros)
const resultStr = formatSigFigs(roundedValue, sfCount);
const resultDigits = getDigits(resultStr);
// Step: explain what happens to remaining digits
const dotIdx = rawValue.indexOf(".");
const hasIntegerPart = dotIdx === -1 || dotIdx > 0;
if (hasIntegerPart && sigDigitIndices.length > sfCount) {
// Some trailing digits in the integer part get replaced with zeros
steps.push({
label: "Replace remaining digits with zeros (to keep the place value)",
digits: resultDigits.map((ch) => ({
char: ch,
state: "normal" as const,
})),
});
}
steps.push({
label: `Result: ${resultStr} (${sfCount} significant figure${sfCount > 1 ? "s" : ""})`,
digits: resultDigits.map((ch) => ({
char: ch,
state: "normal" as const,
})),
resultText: resultStr,
});
return steps;
}
function formatSigFigs(value: number, sfCount: number): string {
if (value === 0) return "0";
const magnitude = Math.floor(Math.log10(Math.abs(value)));
if (magnitude >= sfCount - 1) {
// Integer result — no decimal point needed
return Math.round(value / Math.pow(10, magnitude - sfCount + 1))
* Math.pow(10, magnitude - sfCount + 1) + "";
}
// Decimal result — need to show enough places
const decimalPlaces = sfCount - 1 - magnitude;
return value.toFixed(decimalPlaces);
}
function ordinal(n: number): string {
if (n === 1) return "1st";
if (n === 2) return "2nd";
if (n === 3) return "3rd";
return `${n}th`;
}
// ── Main step builder ────────────────────────────────────────────────────────
function buildSteps(rawValue: string, target: RoundTarget): Step[] {
if (target === "whole" || target === "1dp" || target === "2dp") {
return buildDPSteps(rawValue, target);
}
const sfCount = target === "1sf" ? 1 : target === "2sf" ? 2 : 3;
return buildSFSteps(rawValue, sfCount);
}
// ── Digit box component ─────────────────────────────────────────────────────
function DigitBox({ char, state }: DigitInfo) {
if (char === ".") {
return (
<div className="flex h-[88px] w-6 items-end justify-center pb-2 max-sm:h-[70px]">
<div className="h-3 w-3 rounded-full bg-foreground" />
</div>
);
}
const styles: Record<DigitInfo["state"], string> = {
normal: "border-border bg-surface text-foreground",
target: "border-unit-2 bg-unit-2-light text-foreground ring-2 ring-unit-2/30",
decider: "border-hint bg-hint-light text-foreground ring-2 ring-hint/30",
significant: "border-unit-4 bg-unit-4-light text-foreground ring-2 ring-unit-4/30",
dim: "border-border/40 bg-background text-muted/30",
};
return (
<div
className={`flex h-[88px] w-[68px] shrink-0 select-none items-center justify-center rounded-xl border-2 text-[3.2rem] font-bold transition-all duration-300 max-sm:h-[70px] max-sm:w-[52px] max-sm:text-[2.4rem] ${styles[state]}`}
>
{char}
</div>
);
}
// ── Explorer component ──────────────────────────────────────────────────────
export function RoundingExplorer() {
const [input, setInput] = useState("3.4567");
const [target, setTarget] = useState<RoundTarget>("1dp");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
function handleGo() {
setError("");
const trimmed = input.trim();
const val = parseFloat(trimmed);
if (isNaN(val)) {
setError("Enter a valid number.");
return;
}
if (val < 0) {
setError("Enter a positive number.");
return;
}
if (trimmed.length > 16) {
setError("Number too long.");
return;
}
if (!trimmed.includes(".") && (target === "1dp" || target === "2dp")) {
setError("Enter a decimal number for decimal place rounding.");
return;
}
try {
const s = buildSteps(trimmed, target);
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not process. Check your input.");
}
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
const targetLabels: Record<RoundTarget, string> = {
whole: "Whole Number",
"1dp": "1 d.p.",
"2dp": "2 d.p.",
"1sf": "1 s.f.",
"2sf": "2 s.f.",
"3sf": "3 s.f.",
};
return (
<div className="space-y-4">
{/* Target tabs */}
<div className="flex flex-wrap gap-2">
{(["whole", "1dp", "2dp", "1sf", "2sf", "3sf"] as RoundTarget[]).map((t) => (
<button
key={t}
onClick={() => {
setTarget(t);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
target === t
? "border-unit-2 bg-unit-2 text-white"
: "border-unit-2/40 text-unit-2 hover:bg-unit-2-light"
}`}
>
{targetLabels[t]}
</button>
))}
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-2">
Enter a number
</p>
<div className="flex flex-wrap items-center gap-2.5">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleGo()}
placeholder="e.g. 3.4567 or 0.00456"
maxLength={18}
autoComplete="off"
className="max-w-[240px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-2 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-2-dark"
>
Round
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter a number above and click <strong>Round</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
<div className="flex flex-wrap items-end justify-center gap-2">
{step.digits.map((d, i) => (
<DigitBox key={i} {...d} />
))}
</div>
{/* Legend */}
<div className="flex flex-wrap items-center justify-center gap-4 text-xs text-muted">
<span className="flex items-center gap-1">
<span className="inline-block h-3 w-3 rounded border-2 border-unit-2 bg-unit-2-light" />
Target digit
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-3 w-3 rounded border-2 border-hint bg-hint-light" />
Decider digit
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-3 w-3 rounded border-2 border-unit-4 bg-unit-4-light" />
Significant
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-3 w-3 rounded border-2 border-border/40 bg-background" />
Not significant
</span>
</div>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !step?.resultText ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Rounded Value</p>
<p className="text-3xl font-extrabold text-foreground max-sm:text-2xl">
{step.resultText}
</p>
</>
)}
</Card>
</div>
);
}