312 lines
9.9 KiB
TypeScript
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>
|
|
);
|
|
}
|