561 lines
18 KiB
TypeScript
561 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback } from "react";
|
|
import { Card } from "@/components/ui/card";
|
|
import { StepControls } from "./step-controls";
|
|
import { parseStrictDecimal } from "@/lib/math/validation";
|
|
|
|
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 = parseStrictDecimal(trimmed);
|
|
if (val === null) {
|
|
setError("Enter a valid number (digits and decimal point only).");
|
|
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>
|
|
);
|
|
}
|