form 1 topics added
This commit is contained in:
418
components/explorers/plot-points-explorer.tsx
Normal file
418
components/explorers/plot-points-explorer.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user