Files
cabrits-math/components/explorers/decimal-order-explorer.tsx
2026-03-01 18:50:29 -04:00

312 lines
9.9 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
type SortDirection = "ascending" | "descending";
interface Step {
label: string;
values: { value: number; str: string; sorted: boolean; comparing: boolean }[];
comparingPair?: [number, number];
}
function padDecimal(s: string, maxInt: number, maxDec: number): string {
const parts = s.split(".");
const intPart = (parts[0] || "0").padStart(maxInt, "\u00A0"); // nbsp padding
const decPart = (parts[1] || "").padEnd(maxDec, "0");
return maxDec > 0 ? `${intPart}.${decPart}` : intPart;
}
function buildSortSteps(values: number[], direction: SortDirection): Step[] {
const steps: Step[] = [];
const strs = values.map((v) => v.toString());
// Find max integer and decimal lengths for alignment
const maxInt = Math.max(...strs.map((s) => s.split(".")[0].length));
const maxDec = Math.max(...strs.map((s) => (s.split(".")[1] || "").length));
// Step 0: Show unsorted
steps.push({
label: `Sort ${values.length} decimals in ${direction} order`,
values: values.map((v, i) => ({
value: v,
str: padDecimal(strs[i], maxInt, maxDec),
sorted: false,
comparing: false,
})),
});
// Step 1: Show aligned place values
steps.push({
label: "Align decimal points to compare place values",
values: values.map((v, i) => ({
value: v,
str: padDecimal(strs[i], maxInt, maxDec),
sorted: false,
comparing: false,
})),
});
// Bubble sort with steps
const arr = [...values];
const arrStrs = [...strs];
const cmp = direction === "ascending" ? (a: number, b: number) => a > b : (a: number, b: number) => a < b;
for (let i = 0; i < arr.length - 1; i++) {
for (let j = 0; j < arr.length - 1 - i; j++) {
// Show comparison
steps.push({
label: `Compare ${arrStrs[j]} and ${arrStrs[j + 1]}`,
values: arr.map((v, k) => ({
value: v,
str: padDecimal(arrStrs[k], maxInt, maxDec),
sorted: k >= arr.length - i,
comparing: k === j || k === j + 1,
})),
comparingPair: [j, j + 1],
});
if (cmp(arr[j], arr[j + 1])) {
// Swap
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
[arrStrs[j], arrStrs[j + 1]] = [arrStrs[j + 1], arrStrs[j]];
steps.push({
label: `Swap: ${arrStrs[j]} ${direction === "ascending" ? "<" : ">"} ${arrStrs[j + 1]}`,
values: arr.map((v, k) => ({
value: v,
str: padDecimal(arrStrs[k], maxInt, maxDec),
sorted: k >= arr.length - i,
comparing: k === j || k === j + 1,
})),
});
}
}
}
// Final step: Show sorted
steps.push({
label: `Sorted (${direction}): ${arrStrs.join(direction === "ascending" ? " < " : " > ")}`,
values: arr.map((v, k) => ({
value: v,
str: padDecimal(arrStrs[k], maxInt, maxDec),
sorted: true,
comparing: false,
})),
});
return steps;
}
function NumberLine({ values, min, max }: { values: { value: number; sorted: boolean; comparing: boolean }[]; min: number; max: number }) {
const range = max - min || 1;
return (
<div className="w-full max-w-lg">
<div className="relative h-2 rounded-full bg-border">
{values.map((v, i) => {
const pct = ((v.value - min) / range) * 100;
return (
<div
key={i}
className={`absolute -top-2.5 h-7 w-7 -translate-x-1/2 rounded-full border-2 text-center text-[0.6rem] font-bold leading-[1.5rem] transition-all duration-500 ${
v.comparing
? "border-hint bg-hint-light text-foreground z-10"
: v.sorted
? "border-correct bg-correct-light text-foreground"
: "border-unit-2 bg-unit-2-light text-foreground"
}`}
style={{ left: `${Math.max(5, Math.min(95, pct))}%` }}
>
{i + 1}
</div>
);
})}
</div>
<div className="mt-4 flex justify-between text-xs text-muted">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
);
}
export function DecimalOrderExplorer() {
const [input, setInput] = useState("3.14, 3.1, 3.141, 3.04, 3.4");
const [direction, setDirection] = useState<SortDirection>("ascending");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
function handleGo() {
setError("");
const parts = input
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
if (parts.length < 2) {
setError("Enter at least 2 decimals separated by commas.");
return;
}
if (parts.length > 6) {
setError("Maximum 6 decimals.");
return;
}
const values = parts.map(Number);
if (values.some(isNaN)) {
setError("All values must be valid numbers.");
return;
}
try {
const s = buildSortSteps(values, direction);
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not process. Check your input.");
}
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Direction tabs */}
<div className="flex gap-2">
{(["ascending", "descending"] as SortDirection[]).map((d) => (
<button
key={d}
onClick={() => {
setDirection(d);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold capitalize transition-colors ${
direction === d
? "border-unit-2 bg-unit-2 text-white"
: "border-unit-2/40 text-unit-2 hover:bg-unit-2-light"
}`}
>
{d}
</button>
))}
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-2">
Enter 2-6 decimal numbers (comma separated)
</p>
<div className="flex flex-wrap items-center gap-2.5">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleGo()}
placeholder="e.g. 3.14, 3.1, 3.141"
autoComplete="off"
className="max-w-[320px] 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={handleGo}
className="rounded-lg bg-unit-2 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-2-dark"
>
Sort
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<Card className="flex min-h-[280px] flex-col items-center justify-center gap-5 p-6">
{!step ? (
<p className="text-muted/50">
Enter decimals above and click <strong>Sort</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
{/* Place value columns */}
<div className="flex flex-col gap-1.5 font-mono text-xl">
{step.values.map((v, i) => (
<div
key={i}
className={`rounded-lg border-2 px-4 py-2 text-center transition-all duration-300 ${
v.comparing
? "border-hint bg-hint-light"
: v.sorted
? "border-correct bg-correct-light"
: "border-border bg-surface"
}`}
>
{v.str}
</div>
))}
</div>
{/* Number line */}
<NumberLine
values={step.values}
min={Math.min(...step.values.map((v) => v.value)) - 0.1}
max={Math.max(...step.values.map((v) => v.value)) + 0.1}
/>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !steps ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">
Sorted ({direction})
</p>
<p className="text-2xl font-extrabold text-foreground max-sm:text-xl">
{step!.values.map((v) => v.value).join(direction === "ascending" ? " < " : " > ")}
</p>
</>
)}
</Card>
</div>
);
}