Initial Commit

This commit is contained in:
2026-03-01 18:50:29 -04:00
parent 261c52d602
commit 364facd9f0
69 changed files with 7829 additions and 87 deletions

View 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>
);
}