319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useMemo, useCallback } from "react";
|
||
import { Card } from "@/components/ui/card";
|
||
|
||
const PRESETS = ["3", "12", "23", "33", "123", "213", "321", "1230", "3333"];
|
||
|
||
interface Column {
|
||
exponent: number;
|
||
placeValue: number;
|
||
digit: string;
|
||
contrib: number;
|
||
isOn: boolean;
|
||
}
|
||
|
||
function powerOfFour(n: number) {
|
||
let v = 1;
|
||
for (let i = 0; i < n; i++) v *= 4;
|
||
return v;
|
||
}
|
||
|
||
function buildColumnsFor(quaternaryStr: string): Column[] {
|
||
const n = quaternaryStr.length;
|
||
const cols: Column[] = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const exponent = n - 1 - i;
|
||
const digit = quaternaryStr[i];
|
||
const placeValue = powerOfFour(exponent);
|
||
const value = parseInt(digit, 10);
|
||
cols.push({
|
||
exponent,
|
||
placeValue,
|
||
digit,
|
||
contrib: value * placeValue,
|
||
isOn: value > 0,
|
||
});
|
||
}
|
||
return cols;
|
||
}
|
||
|
||
function emptyColumns(): Column[] {
|
||
const cols: Column[] = [];
|
||
for (let exp = 4; exp >= 0; exp--) {
|
||
cols.push({
|
||
exponent: exp,
|
||
placeValue: powerOfFour(exp),
|
||
digit: "_",
|
||
contrib: 0,
|
||
isOn: false,
|
||
});
|
||
}
|
||
return cols;
|
||
}
|
||
|
||
export function QuaternaryToDenaryExplorer() {
|
||
const [input, setInput] = useState("");
|
||
const [submitted, setSubmitted] = useState<string | null>(null);
|
||
const [error, setError] = useState("");
|
||
const [answerHidden, setAnswerHidden] = useState(false);
|
||
const [chartHidden, setChartHidden] = useState(false);
|
||
|
||
const handleInputChange = (value: string) => {
|
||
setInput(value.replace(/[^0-3]/g, ""));
|
||
setError("");
|
||
};
|
||
|
||
const convert = useCallback((raw?: string) => {
|
||
const source = (raw ?? input).trim();
|
||
if (source.length === 0) {
|
||
setError("Type a quaternary number first (digits 0-3 only).");
|
||
setSubmitted(null);
|
||
return;
|
||
}
|
||
if (!/^[0-3]+$/.test(source)) {
|
||
setError("Quaternary numbers use only digits 0, 1, 2 and 3.");
|
||
setSubmitted(null);
|
||
return;
|
||
}
|
||
if (source.length > 5) {
|
||
setError("Keep it to 5 digits or fewer (largest is 33333₄ = 1023).");
|
||
return;
|
||
}
|
||
setError("");
|
||
setSubmitted(source);
|
||
}, [input]);
|
||
|
||
const applyPreset = (value: string) => {
|
||
setInput(value);
|
||
convert(value);
|
||
};
|
||
|
||
const handleClear = () => {
|
||
setInput("");
|
||
setSubmitted(null);
|
||
setError("");
|
||
};
|
||
|
||
const handleRandom = () => {
|
||
const len = 2 + Math.floor(Math.random() * 3);
|
||
const first = 1 + Math.floor(Math.random() * 3);
|
||
let s = String(first);
|
||
for (let i = 1; i < len; i++) {
|
||
s += String(Math.floor(Math.random() * 4));
|
||
}
|
||
setInput(s);
|
||
convert(s);
|
||
};
|
||
|
||
const columns = useMemo(() => {
|
||
if (!submitted) return emptyColumns();
|
||
return buildColumnsFor(submitted);
|
||
}, [submitted]);
|
||
|
||
const total = useMemo(() => {
|
||
if (!submitted) return 0;
|
||
return columns.reduce((acc, c) => acc + c.contrib, 0);
|
||
}, [columns, submitted]);
|
||
|
||
const fullExpansion = submitted
|
||
? columns.map((c) => `${c.digit}×${c.placeValue}`).join(" + ")
|
||
: "";
|
||
|
||
const onesOnly = submitted
|
||
? columns.filter((c) => c.isOn).length > 0
|
||
? columns.filter((c) => c.isOn).map((c) => `${c.digit}×${c.placeValue}`).join(" + ") + " = " + total
|
||
: "0"
|
||
: "";
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Input Card */}
|
||
<Card>
|
||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-6">
|
||
Type a Quaternary Number (digits 0-3)
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<label
|
||
htmlFor="quaternaryInput"
|
||
className="text-sm font-bold text-unit-6-dark"
|
||
>
|
||
Base 4:
|
||
</label>
|
||
<input
|
||
id="quaternaryInput"
|
||
type="text"
|
||
inputMode="numeric"
|
||
maxLength={5}
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
placeholder="e.g. 123"
|
||
value={input}
|
||
onChange={(e) => handleInputChange(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") convert();
|
||
}}
|
||
className="w-44 rounded-lg border-2 border-unit-6 bg-surface px-3 py-2 text-center font-mono text-2xl font-bold tracking-[0.3em] text-unit-6 outline-none focus:border-unit-6-dark"
|
||
aria-label="Quaternary number input"
|
||
/>
|
||
<button
|
||
onClick={() => convert()}
|
||
className="rounded-lg bg-unit-6 px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-6-dark"
|
||
>
|
||
Convert
|
||
</button>
|
||
<button
|
||
onClick={handleClear}
|
||
className="rounded-lg border-2 border-border bg-surface px-4 py-2 text-sm font-bold text-muted transition-colors hover:border-unit-6/50 hover:text-unit-6"
|
||
>
|
||
Clear
|
||
</button>
|
||
<button
|
||
onClick={handleRandom}
|
||
className="rounded-lg border-2 border-border bg-surface px-4 py-2 text-sm font-bold text-muted transition-colors hover:border-unit-6/50 hover:text-unit-6"
|
||
>
|
||
Random
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
{PRESETS.map((p) => (
|
||
<button
|
||
key={p}
|
||
onClick={() => applyPreset(p)}
|
||
className="rounded-lg border-2 border-unit-6/30 bg-unit-6-light px-3 py-1 font-mono text-sm font-bold tracking-widest text-unit-6-dark transition-colors hover:border-unit-6 hover:bg-unit-6 hover:text-white"
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{error && (
|
||
<p className="mt-3 rounded-lg border-2 border-incorrect bg-incorrect-light px-3 py-2 text-center text-sm font-bold text-incorrect">
|
||
{error}
|
||
</p>
|
||
)}
|
||
</Card>
|
||
|
||
{/* Place value chart */}
|
||
<Card>
|
||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||
<p className="text-xs font-bold uppercase tracking-wider text-unit-6">
|
||
Place Value Chart (powers of 4)
|
||
</p>
|
||
<button
|
||
onClick={() => setChartHidden((v) => !v)}
|
||
className="rounded-md border border-border bg-surface px-2.5 py-1 text-xs font-semibold text-muted transition-colors hover:border-unit-6/50 hover:text-unit-6"
|
||
>
|
||
{chartHidden ? "Show Chart" : "Hide Chart"}
|
||
</button>
|
||
</div>
|
||
|
||
{!chartHidden && (
|
||
<div className="overflow-x-auto rounded-xl border-2 border-unit-6/30 bg-unit-6-light/40 p-3">
|
||
<table className="mx-auto border-collapse font-mono">
|
||
<tbody>
|
||
<tr>
|
||
{columns.map((c) => (
|
||
<td
|
||
key={`p-${c.exponent}`}
|
||
className="min-w-[72px] border-2 border-unit-6/40 bg-unit-6-light px-4 py-2 text-center text-sm font-bold text-unit-6-dark"
|
||
>
|
||
4<sup>{c.exponent}</sup>
|
||
</td>
|
||
))}
|
||
</tr>
|
||
<tr>
|
||
{columns.map((c) => (
|
||
<td
|
||
key={`v-${c.exponent}`}
|
||
className="min-w-[72px] border-2 border-unit-6/40 bg-surface px-4 py-2 text-center text-base font-bold text-unit-6"
|
||
>
|
||
{c.placeValue}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
<tr>
|
||
{columns.map((c, i) => (
|
||
<td
|
||
key={`d-${i}`}
|
||
className={`min-w-[72px] border-2 border-unit-6/40 px-4 py-2 text-center text-2xl font-extrabold ${
|
||
c.isOn
|
||
? "bg-correct-light text-correct"
|
||
: c.digit === "_"
|
||
? "bg-surface text-muted/60"
|
||
: "bg-surface text-muted"
|
||
}`}
|
||
>
|
||
{c.digit}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
<tr>
|
||
{columns.map((c, i) => (
|
||
<td
|
||
key={`c-${i}`}
|
||
className={`min-w-[72px] border-2 border-unit-6/40 px-4 py-2 text-center text-sm font-bold ${
|
||
c.isOn
|
||
? "bg-correct-light text-correct"
|
||
: c.digit === "_"
|
||
? "bg-[#fafbfc] text-muted/60"
|
||
: "bg-[#fafbfc] text-muted"
|
||
}`}
|
||
>
|
||
{submitted ? c.contrib : "_"}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{submitted && (
|
||
<div className="mt-4 text-center font-mono text-base font-bold text-unit-6">
|
||
<div>{fullExpansion}</div>
|
||
<div>= {onesOnly}</div>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* Answer banner */}
|
||
<Card
|
||
className={`flex min-h-[90px] flex-col items-center justify-center gap-2 text-center ${
|
||
submitted && !answerHidden
|
||
? "border-correct bg-correct-light"
|
||
: "border-unit-6/30 bg-unit-6-light/60"
|
||
}`}
|
||
>
|
||
{!submitted ? (
|
||
<p className="font-mono text-base font-bold text-unit-6">
|
||
Type a quaternary number above and press Convert.
|
||
</p>
|
||
) : answerHidden ? (
|
||
<p className="font-mono text-lg font-bold text-unit-6">
|
||
{submitted}<sub>4</sub> = ? (press Show Answer)
|
||
</p>
|
||
) : (
|
||
<p className="font-mono text-2xl font-extrabold text-correct">
|
||
{submitted}<sub>4</sub> = {total} in denary
|
||
</p>
|
||
)}
|
||
<button
|
||
onClick={() => setAnswerHidden((v) => !v)}
|
||
className="mt-1 rounded-md border border-border bg-surface px-3 py-1 text-xs font-semibold text-muted transition-colors hover:border-unit-6/50 hover:text-unit-6"
|
||
>
|
||
{answerHidden ? "Show Answer" : "Hide Answer"}
|
||
</button>
|
||
</Card>
|
||
|
||
{/* Footer note */}
|
||
<Card className="border-l-4 border-l-unit-6 bg-unit-6-light/40">
|
||
<p className="text-sm text-unit-6-dark">
|
||
<strong>For students:</strong> right-align your base-4 number to the 1s column. Multiply each digit by its place value and add. Solve on paper <em>first</em>, then check here. Digits 4-9 are illegal in base 4 — the input box rejects them.
|
||
</p>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|