Files
cabrits-math/components/explorers/plot-points-explorer.tsx
2026-07-05 11:12:45 -04:00

419 lines
14 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { CartesianGrid, type GridPoint, type GridSegment, type GridArrow } from "./cartesian-grid";
type Mode = "plot" | "shape";
interface Step {
label: string;
math: string;
points: GridPoint[];
arrows?: GridArrow[];
segments?: GridSegment[];
highlight?: { x: number; y: number };
}
// ── Plot a single point ──────────────────────────────────────────────────────
function buildPlotSteps(x: number, y: number): Step[] {
const steps: Step[] = [
{
label: "Start at the origin (0, 0) — where the two axes cross.",
math: `(0,\\ 0)`,
points: [{ x: 0, y: 0, label: "start", tone: "muted" }],
},
];
// Move along x
if (x === 0) {
steps.push({
label: "The x-coordinate is 0, so stay on the y-axis — don't move left or right.",
math: `x = 0`,
points: [{ x: 0, y: 0, tone: "muted" }],
});
} else {
steps.push({
label: `Move ALONG the x-axis: ${Math.abs(x)} to the ${x > 0 ? "RIGHT" : "LEFT"}.`,
math: `x = ${x}`,
points: [{ x: 0, y: 0, tone: "muted" }],
arrows: [{ from: { x: 0, y: 0 }, to: { x, y: 0 } }],
});
}
// Move along y
if (y === 0) {
steps.push({
label: "The y-coordinate is 0, so stay on the x-axis — don't move up or down.",
math: `y = 0`,
points: [{ x, y: 0, tone: "muted" }],
arrows: x === 0 ? [] : [{ from: { x: 0, y: 0 }, to: { x, y: 0 } }],
});
} else {
steps.push({
label: `Now move parallel to the y-axis: ${Math.abs(y)} ${y > 0 ? "UP" : "DOWN"}.`,
math: `y = ${y}`,
points: [{ x, y: 0, tone: "muted" }],
arrows: [
...(x === 0 ? [] : [{ from: { x: 0, y: 0 }, to: { x, y: 0 } }]),
{ from: { x, y: 0 }, to: { x, y } },
],
highlight: { x, y },
});
}
steps.push({
label: `Mark the point (${x}, ${y}). Remember: the x-coordinate always comes first!`,
math: `(${x},\\ ${y})`,
points: [{ x, y, label: `(${x}, ${y})`, tone: "solid" }],
highlight: { x, y },
});
return steps;
}
// ── Shape reveal presets ─────────────────────────────────────────────────────
interface Shape {
name: string;
points: { label: string; x: number; y: number }[];
}
const SHAPES: Record<string, Shape> = {
mystery: {
name: "Mystery Shape",
points: [
{ label: "A", x: -4, y: -8 },
{ label: "B", x: -3, y: -9 },
{ label: "C", x: -2, y: -8 },
{ label: "D", x: -2, y: 0 },
{ label: "E", x: -5, y: 0 },
{ label: "F", x: -6, y: 4 },
{ label: "G", x: -2, y: 5 },
{ label: "H", x: 2, y: 4 },
{ label: "I", x: 5, y: 0 },
],
},
square: {
name: "Square",
points: [
{ label: "A", x: 2, y: 2 },
{ label: "B", x: 7, y: 2 },
{ label: "C", x: 7, y: 7 },
{ label: "D", x: 2, y: 7 },
],
},
house: {
name: "House",
points: [
{ label: "A", x: -5, y: -4 },
{ label: "B", x: 5, y: -4 },
{ label: "C", x: 5, y: 3 },
{ label: "D", x: 0, y: 7 },
{ label: "E", x: -5, y: 3 },
],
},
};
function buildShapeSteps(shape: Shape): Step[] {
const pts = shape.points;
const steps: Step[] = [];
for (let i = 0; i < pts.length; i++) {
const plotted = pts.slice(0, i + 1);
const segments: GridSegment[] = [];
for (let j = 1; j <= i; j++) {
segments.push({ from: pts[j - 1], to: pts[j] });
}
const p = pts[i];
steps.push({
label:
i === 0
? `Plot the first point ${p.label} at (${p.x}, ${p.y}).`
: `Plot ${p.label} at (${p.x}, ${p.y}) and join it to ${pts[i - 1].label}.`,
math: `${p.label} = (${p.x},\\ ${p.y})`,
points: plotted.map((q) => ({ x: q.x, y: q.y, label: q.label, tone: "solid" as const })),
segments,
highlight: { x: p.x, y: p.y },
});
}
// Closing segment
const allSegments: GridSegment[] = [];
for (let j = 1; j < pts.length; j++) allSegments.push({ from: pts[j - 1], to: pts[j] });
allSegments.push({ from: pts[pts.length - 1], to: pts[0] });
steps.push({
label: `Finally, join ${pts[pts.length - 1].label} back to ${pts[0].label} to close the shape. What is it?`,
math: `${pts[pts.length - 1].label} \\to ${pts[0].label}`,
points: pts.map((q) => ({ x: q.x, y: q.y, label: q.label, tone: "solid" as const })),
segments: allSegments,
});
return steps;
}
// ── Quick practice: click the point ──────────────────────────────────────────
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function ClickPractice() {
const [target, setTarget] = useState(() => ({ x: randInt(-8, 8), y: randInt(-8, 8) }));
const [clicked, setClicked] = useState<{ x: number; y: number } | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
const isCorrect = clicked && clicked.x === target.x && clicked.y === target.y;
function handleClick(x: number, y: number) {
if (clicked) return;
const correct = x === target.x && y === target.y;
setClicked({ x, y });
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
}
function next() {
setTarget({ x: randInt(-8, 8), y: randInt(-8, 8) });
setClicked(null);
}
const points: GridPoint[] = [];
if (clicked) {
if (isCorrect) {
points.push({ x: target.x, y: target.y, label: `(${target.x}, ${target.y})`, tone: "target" });
} else {
points.push({ x: clicked.x, y: clicked.y, label: "you", tone: "wrong" });
points.push({ x: target.x, y: target.y, label: `(${target.x}, ${target.y})`, tone: "target" });
}
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-7">Quick Practice</p>
<span className="rounded-full bg-unit-7-light px-3 py-1 text-xs font-bold text-unit-7-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-lg font-bold">
Click where <span className="text-unit-7-dark">({target.x}, {target.y})</span> belongs
</p>
<div className="mx-auto max-w-md">
<CartesianGrid points={points} highlight={isCorrect ? target : undefined} onGridClick={clicked ? undefined : handleClick} />
</div>
{clicked && (
<div className="space-y-2 text-center">
{isCorrect ? (
<p className="text-sm font-bold text-correct">Spot on! That is ({target.x}, {target.y}).</p>
) : (
<p className="text-sm font-bold text-incorrect">
You clicked ({clicked.x}, {clicked.y}). The target ({target.x}, {target.y}) is shown in green.
</p>
)}
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
</div>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function PlotPointsExplorer() {
const [mode, setMode] = useState<Mode>("plot");
const [inputX, setInputX] = useState("4");
const [inputY, setInputY] = useState("3");
const [shapeKey, setShapeKey] = useState<keyof typeof SHAPES>("mystery");
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;
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function switchMode(m: Mode) {
setMode(m);
setError("");
reset();
}
function handlePlot() {
setError("");
const x = parseInt(inputX, 10);
const y = parseInt(inputY, 10);
if (isNaN(x) || isNaN(y)) return setError("Enter whole numbers for x and y.");
if ([x, y].some((v) => v < -10 || v > 10)) return setError("Keep both coordinates between -10 and 10.");
setSteps(buildPlotSteps(x, y));
setCurrentStep(0);
setIsPlaying(false);
}
function handleShape() {
setError("");
setSteps(buildShapeSteps(SHAPES[shapeKey]));
setCurrentStep(0);
setIsPlaying(false);
}
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 step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex flex-wrap gap-2">
{(["plot", "shape"] as Mode[]).map((m) => (
<button
key={m}
onClick={() => switchMode(m)}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === m
? "border-unit-7 bg-unit-7 text-white"
: "border-unit-7/40 text-unit-7 hover:bg-unit-7-light"
}`}
>
{m === "plot" ? "Plot a Point" : "Shape Reveal"}
</button>
))}
</div>
{/* Input card */}
<Card>
{mode === "plot" ? (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-7">
Enter the coordinates to plot
</p>
<div className="flex flex-wrap items-center gap-3">
<span className="text-lg font-bold text-muted">(</span>
<input
type="number"
value={inputX}
onChange={(e) => setInputX(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-7"
aria-label="x coordinate"
/>
<span className="text-lg font-bold text-muted">,</span>
<input
type="number"
value={inputY}
onChange={(e) => setInputY(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-7"
aria-label="y coordinate"
/>
<span className="text-lg font-bold text-muted">)</span>
<button
onClick={handlePlot}
className="rounded-lg bg-unit-7 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-7-dark"
>
Plot
</button>
</div>
</>
) : (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-7">
Pick a shape, then step through the points to reveal it
</p>
<div className="flex flex-wrap items-center gap-3">
<select
value={shapeKey}
onChange={(e) => {
setShapeKey(e.target.value as keyof typeof SHAPES);
reset();
}}
className="rounded-lg border-2 border-border bg-surface px-3 py-2 text-sm font-bold outline-none focus:border-unit-7"
aria-label="Choose a shape"
>
{Object.entries(SHAPES).map(([key, s]) => (
<option key={key} value={key}>
{s.name}
</option>
))}
</select>
<button
onClick={handleShape}
className="rounded-lg bg-unit-7 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-7-dark"
>
Start
</button>
</div>
</>
)}
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[320px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
{mode === "plot" ? (
<>Enter coordinates above and click <strong>Plot</strong></>
) : (
<>Pick a shape and click <strong>Start</strong></>
)}
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
<div className="w-full max-w-md">
<CartesianGrid
points={step.points}
segments={step.segments}
arrows={step.arrows}
highlight={step.highlight}
/>
</div>
</>
)}
</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>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<ClickPractice />
</ClientOnly>
</div>
);
}