Initial Commit
This commit is contained in:
445
components/explorers/standard-form-explorer.tsx
Normal file
445
components/explorers/standard-form-explorer.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
"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} of ${totalSteps}`}
|
||||
</p>
|
||||
|
||||
{/* Digit row */}
|
||||
<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>
|
||||
|
||||
{/* 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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user