Files
cabrits-math/components/explorers/standard-form-explorer.tsx
2026-03-01 19:48:29 -04:00

448 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
type Mode = "toSF" | "toOrd";
interface DisplayState {
digits: string[];
decimalPos: number;
initPos: number;
targetPos: number;
origPower: number;
}
function parseOrdinary(raw: string): { digits: string[]; decimalPos: number } {
const s = raw.trim().replace(/[\s,]/g, "");
if (s.startsWith("-")) throw new Error("Please enter a positive number.");
if (!/^\d*\.?\d+$/.test(s) || s === "")
throw new Error("Invalid number. Try something like 7438 or 0.0055");
if (s.length > 16) throw new Error("Number too long (max 16 digits).");
const dot = s.indexOf(".");
if (dot === -1) {
return { digits: s.split(""), decimalPos: s.length };
}
return {
digits: (s.slice(0, dot) + s.slice(dot + 1)).split(""),
decimalPos: dot,
};
}
function firstNonZero(digits: string[]): number {
return digits.findIndex((d) => d !== "0");
}
function sfTargetPos(digits: string[]): number {
const i = firstNonZero(digits);
return i === -1 ? 1 : i + 1;
}
function buildCoeff(sigDigs: string[]): string {
if (sigDigs.length === 0) return "0";
if (sigDigs.length === 1) return sigDigs[0] + ".0";
const rest = sigDigs.slice(1).join("").replace(/0+$/, "") || "0";
return sigDigs[0] + "." + rest;
}
function buildOrdinary(digits: string[], pos: number): string {
let s: string;
if (pos <= 0) {
s = "0." + "0".repeat(-pos) + digits.join("");
} else if (pos >= digits.length) {
s = digits.join("") + "0".repeat(pos - digits.length);
} else {
s = digits.slice(0, pos).join("") + "." + digits.slice(pos).join("");
}
s = s.replace(/^0+(?=[1-9])/, "");
if (s.startsWith(".")) s = "0" + s;
if (s.includes(".")) s = s.replace(/\.?0+$/, "");
return s || "0";
}
export function StandardFormExplorer() {
const [mode, setMode] = useState<Mode>("toSF");
const [ordinaryInput, setOrdinaryInput] = useState("");
const [coeffInput, setCoeffInput] = useState("");
const [powerInput, setPowerInput] = useState("");
const [error, setError] = useState("");
const [display, setDisplay] = useState<DisplayState | null>(null);
const [currentPos, setCurrentPos] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [done, setDone] = useState(false);
const numberRowRef = useRef<HTMLDivElement>(null);
const dotRef = useRef<HTMLDivElement>(null);
const [dotLeft, setDotLeft] = useState(0);
const [dotAnimate, setDotAnimate] = useState(false);
const [powerPop, setPowerPop] = useState(false);
const totalSteps = display ? Math.abs(display.targetPos - display.initPos) : 0;
const currentStep = display ? Math.abs(currentPos - display.initPos) : 0;
const currentPower = display
? mode === "toSF"
? display.initPos - currentPos
: display.origPower - (currentPos - display.initPos)
: 0;
// Position decimal dot
const placeDot = useCallback(
(animate: boolean) => {
if (!numberRowRef.current || !display) return;
const boxes = numberRowRef.current.querySelectorAll<HTMLDivElement>("[data-digit-box]");
if (!boxes.length) return;
let leftPx: number;
const dp = currentPos;
if (dp <= 0) {
leftPx = boxes[0].offsetLeft - 6;
} else if (dp >= boxes.length) {
const last = boxes[boxes.length - 1];
leftPx = last.offsetLeft + last.offsetWidth - 4;
} else {
const prev = boxes[dp - 1];
const next = boxes[dp];
leftPx = Math.round((prev.offsetLeft + prev.offsetWidth + next.offsetLeft) / 2);
}
setDotAnimate(animate);
setDotLeft(leftPx);
},
[currentPos, display],
);
useEffect(() => {
if (display) {
requestAnimationFrame(() => {
requestAnimationFrame(() => placeDot(true));
});
}
}, [currentPos, display, placeDot]);
// Initial placement without animation
useEffect(() => {
if (display) {
requestAnimationFrame(() => {
requestAnimationFrame(() => placeDot(false));
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [display?.digits.join("")]);
function initDisplay(digits: string[], start: number, target: number, origPower: number) {
setIsPlaying(false);
setDone(start === target);
setDisplay({ digits, decimalPos: start, initPos: start, targetPos: target, origPower });
setCurrentPos(start);
setError("");
}
function handleConvertToSF() {
setError("");
try {
const { digits, decimalPos } = parseOrdinary(ordinaryInput);
const target = sfTargetPos(digits);
initDisplay(digits, decimalPos, target, 0);
} catch (e) {
setError((e as Error).message);
}
}
function handleConvertToOrd() {
setError("");
try {
if (!coeffInput.trim()) throw new Error("Enter the coefficient A.");
const n = parseInt(powerInput, 10);
if (isNaN(n)) throw new Error("Enter the power n as a whole number.");
if (Math.abs(n) > 12) throw new Error("Power too large for display (max ±12).");
if (n === 0) throw new Error("Power is 0 — the number is already in ordinary form.");
const { digits } = parseOrdinary(coeffInput);
const A = parseFloat(coeffInput);
if (isNaN(A) || A < 1 || A >= 10)
throw new Error(`Coefficient must satisfy 1 ≤ A < 10 (got ${coeffInput}).`);
const decimalPos = 1;
const expandedDigits = [...digits];
let startPos: number;
let targetPos: number;
if (n > 0) {
targetPos = decimalPos + n;
startPos = decimalPos;
while (expandedDigits.length < targetPos) expandedDigits.push("0");
} else {
const absN = Math.abs(n);
const zeros = Array(absN).fill("0");
expandedDigits.unshift(...zeros);
startPos = decimalPos + absN;
targetPos = startPos + n;
}
initDisplay(expandedDigits, startPos, targetPos, n);
} catch (e) {
setError((e as Error).message);
}
}
const stepForward = useCallback(() => {
if (!display || currentPos === display.targetPos) return;
const dir = display.targetPos > display.initPos ? 1 : -1;
const nextPos = currentPos + dir;
setCurrentPos(nextPos);
setPowerPop(true);
setTimeout(() => setPowerPop(false), 300);
if (nextPos === display.targetPos) {
setDone(true);
setIsPlaying(false);
}
}, [display, currentPos]);
const stepBack = useCallback(() => {
if (!display || currentPos === display.initPos) return;
if (done) setDone(false);
const dir = display.targetPos > display.initPos ? -1 : 1;
setCurrentPos((p) => p + dir);
setPowerPop(true);
setTimeout(() => setPowerPop(false), 300);
}, [display, currentPos, done]);
const togglePlay = useCallback(() => {
setIsPlaying((p) => !p);
}, []);
const reset = useCallback(() => {
setIsPlaying(false);
setDisplay(null);
setDone(false);
setError("");
}, []);
// Direction label
const remaining = display ? display.targetPos - currentPos : 0;
const dirText =
!display || done || remaining === 0
? null
: remaining < 0
? `← Moving left · ${Math.abs(remaining)} step${Math.abs(remaining) !== 1 ? "s" : ""} remaining`
: `→ Moving right · ${remaining} step${remaining !== 1 ? "s" : ""} remaining`;
// Result
const fz = display ? firstNonZero(display.digits) : 0;
return (
<div className="space-y-4">
{/* Mode Tabs */}
<div className="flex gap-2">
<button
onClick={() => {
setMode("toSF");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "toSF"
? "border-unit-2 bg-unit-2 text-white"
: "border-unit-2/40 text-unit-2 hover:bg-unit-2-light"
}`}
>
Ordinary Standard Form
</button>
<button
onClick={() => {
setMode("toOrd");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "toOrd"
? "border-unit-2 bg-unit-2 text-white"
: "border-unit-2/40 text-unit-2 hover:bg-unit-2-light"
}`}
>
Standard Form Ordinary
</button>
</div>
{/* Input Card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-2">
{mode === "toSF" ? "Enter an ordinary number" : "Enter a number in standard form A × 10ⁿ"}
</p>
{mode === "toSF" ? (
<div className="flex flex-wrap items-center gap-2.5">
<input
type="text"
value={ordinaryInput}
onChange={(e) => setOrdinaryInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleConvertToSF()}
placeholder="e.g. 7438 or 0.0055"
maxLength={18}
autoComplete="off"
className="max-w-[220px] 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={handleConvertToSF}
className="rounded-lg bg-unit-2 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-2-dark"
>
Convert
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2.5">
<input
type="text"
value={coeffInput}
onChange={(e) => setCoeffInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleConvertToOrd()}
placeholder="A e.g. 3.6"
maxLength={12}
autoComplete="off"
className="max-w-[140px] 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"
/>
<span className="text-lg font-semibold">
× 10<sup>n</sup> where n =
</span>
<input
type="number"
value={powerInput}
onChange={(e) => setPowerInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleConvertToOrd()}
placeholder="e.g. 4 or 3"
autoComplete="off"
className="max-w-[110px] 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={handleConvertToOrd}
className="rounded-lg bg-unit-2 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-2-dark"
>
Convert
</button>
</div>
)}
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display Card */}
<Card className="flex min-h-[260px] flex-col items-center justify-center gap-3 p-6">
{!display ? (
<p className="text-muted/50">
Enter a number above and click <strong>Convert</strong>
</p>
) : (
<>
{/* Step info */}
<p className="text-sm text-muted">
{totalSteps === 0
? "Number is already in standard form position — power = 0"
: `Step ${currentStep + 1} of ${totalSteps}`}
</p>
{/* Digit row */}
<div className="overflow-x-auto max-sm:pb-2">
<div ref={numberRowRef} className="relative inline-flex items-end gap-2.5 px-4 pb-7 pt-2">
{display.digits.map((ch, i) => {
const isLeadingZero = i < fz && fz !== -1;
const isHighlight = i === display.targetPos - 1;
return (
<div
key={i}
data-digit-box
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] ${
isLeadingZero
? "border-border/60 bg-background text-muted/30"
: isHighlight
? "border-unit-2 bg-unit-2-light text-foreground"
: "border-border bg-surface text-foreground"
}`}
>
{ch}
</div>
);
})}
{/* Decimal dot */}
<div
ref={dotRef}
className="absolute bottom-1.5 z-10 h-[18px] w-[18px] -translate-x-1/2 rounded-full bg-incorrect shadow-[0_2px_8px_rgba(239,68,68,0.45)]"
style={{
left: dotLeft,
transition: dotAnimate ? "left 0.52s cubic-bezier(0.34,1.56,0.64,1), opacity 0.3s" : "none",
opacity: done && currentPos >= display.digits.length ? 0 : 1,
}}
/>
</div>
</div>
{/* Direction label */}
{dirText && (
<p
className={`text-sm font-semibold ${
remaining < 0 ? "text-incorrect" : "text-hint"
}`}
>
{dirText}
</p>
)}
{/* Power counter */}
<div className="flex items-baseline gap-1.5 text-2xl text-muted">
<span>×</span>
<span className="font-bold text-foreground">10</span>
<sup
className={`inline-block min-w-[38px] text-2xl font-extrabold text-hint ${
powerPop ? "animate-bounce" : ""
}`}
>
{currentPower}
</sup>
</div>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={totalSteps}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!display && currentPos !== display.targetPos}
canStepBack={!!display && currentPos !== display.initPos}
/>
</Card>
{/* Result Card */}
<Card className="flex min-h-[90px] flex-col items-center justify-center gap-1.5 text-center">
{!display || !done ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : mode === "toSF" ? (
<>
<p className="text-xs uppercase tracking-wider text-muted">Standard Form</p>
<p className="text-3xl font-extrabold max-sm:text-2xl">
<span className="text-foreground">
{buildCoeff(display.digits.slice(fz === -1 ? 0 : fz))}
</span>
<span className="mx-2 text-muted">×</span>
<span className="text-foreground">10</span>
<sup className="text-xl text-incorrect">{display.initPos - display.targetPos}</sup>
</p>
</>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Ordinary Form</p>
<p className="text-3xl font-extrabold text-foreground max-sm:text-2xl">
{buildOrdinary(display.digits, display.targetPos)}
</p>
</>
)}
</Card>
</div>
);
}