419 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|