448 lines
16 KiB
TypeScript
448 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|