form 1 topics added
This commit is contained in:
@@ -33,6 +33,14 @@
|
|||||||
--unit-6-light: #eef2ff;
|
--unit-6-light: #eef2ff;
|
||||||
--unit-6-dark: #3730a3;
|
--unit-6-dark: #3730a3;
|
||||||
|
|
||||||
|
--unit-7: #16a34a;
|
||||||
|
--unit-7-light: #dcfce7;
|
||||||
|
--unit-7-dark: #15803d;
|
||||||
|
|
||||||
|
--unit-8: #e11d48;
|
||||||
|
--unit-8-light: #ffe4e9;
|
||||||
|
--unit-8-dark: #be123c;
|
||||||
|
|
||||||
--correct: #16a34a;
|
--correct: #16a34a;
|
||||||
--correct-light: #dcfce7;
|
--correct-light: #dcfce7;
|
||||||
--incorrect: #dc2626;
|
--incorrect: #dc2626;
|
||||||
@@ -73,6 +81,12 @@
|
|||||||
--color-unit-6: var(--unit-6);
|
--color-unit-6: var(--unit-6);
|
||||||
--color-unit-6-light: var(--unit-6-light);
|
--color-unit-6-light: var(--unit-6-light);
|
||||||
--color-unit-6-dark: var(--unit-6-dark);
|
--color-unit-6-dark: var(--unit-6-dark);
|
||||||
|
--color-unit-7: var(--unit-7);
|
||||||
|
--color-unit-7-light: var(--unit-7-light);
|
||||||
|
--color-unit-7-dark: var(--unit-7-dark);
|
||||||
|
--color-unit-8: var(--unit-8);
|
||||||
|
--color-unit-8-light: var(--unit-8-light);
|
||||||
|
--color-unit-8-dark: var(--unit-8-dark);
|
||||||
|
|
||||||
--color-correct: var(--correct);
|
--color-correct: var(--correct);
|
||||||
--color-correct-light: var(--correct-light);
|
--color-correct-light: var(--correct-light);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
import { Footer } from "@/components/layout/footer";
|
import { Footer } from "@/components/layout/footer";
|
||||||
import { Sidebar } from "@/components/layout/sidebar";
|
import { LessonsShell } from "@/components/layout/lessons-shell";
|
||||||
import { MobileNav } from "@/components/layout/mobile-nav";
|
|
||||||
|
|
||||||
export default function LessonsLayout({
|
export default function LessonsLayout({
|
||||||
children,
|
children,
|
||||||
@@ -11,17 +10,7 @@ export default function LessonsLayout({
|
|||||||
return (
|
return (
|
||||||
<div className="playground-bg flex min-h-screen flex-col">
|
<div className="playground-bg flex min-h-screen flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="playground-frame relative mx-auto flex w-full max-w-6xl flex-1">
|
<LessonsShell>{children}</LessonsShell>
|
||||||
<Sidebar />
|
|
||||||
<main className="flex-1 bg-white">
|
|
||||||
<div className="relative p-4 lg:hidden">
|
|
||||||
<MobileNav />
|
|
||||||
</div>
|
|
||||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
22
app/lessons/unit-5-integers/negative-fractions/page.tsx
Normal file
22
app/lessons/unit-5-integers/negative-fractions/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
|
||||||
|
import { NegativeFractionsExplorer } from "@/components/explorers/negative-fractions-explorer";
|
||||||
|
|
||||||
|
export default function NegativeFractionsPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Lessons", href: "/lessons" },
|
||||||
|
{ label: "Unit 5: Integers", href: "/lessons/unit-5-integers" },
|
||||||
|
{ label: "Negative Fractions" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Negative Fractions</h1>
|
||||||
|
|
||||||
|
<NegativeFractionsExplorer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
app/lessons/unit-5-integers/order-compare/page.tsx
Normal file
22
app/lessons/unit-5-integers/order-compare/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
|
||||||
|
import { IntegerOrderCompareExplorer } from "@/components/explorers/integer-order-compare-explorer";
|
||||||
|
|
||||||
|
export default function IntegerOrderComparePage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Lessons", href: "/lessons" },
|
||||||
|
{ label: "Unit 5: Integers", href: "/lessons/unit-5-integers" },
|
||||||
|
{ label: "Order & Compare" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Order and Compare Integers</h1>
|
||||||
|
|
||||||
|
<IntegerOrderCompareExplorer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
app/lessons/unit-6-number-system/place-value/page.tsx
Normal file
22
app/lessons/unit-6-number-system/place-value/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
|
||||||
|
import { PlaceValueExplorer } from "@/components/explorers/place-value-explorer";
|
||||||
|
|
||||||
|
export default function PlaceValuePage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Lessons", href: "/lessons" },
|
||||||
|
{ label: "Unit 6: Number System", href: "/lessons/unit-6-number-system" },
|
||||||
|
{ label: "Place Value" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Denary Place Value (Base 10)</h1>
|
||||||
|
|
||||||
|
<PlaceValueExplorer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
app/lessons/unit-7-coordinates/page.tsx
Normal file
40
app/lessons/unit-7-coordinates/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { curriculum } from "@/lib/curriculum";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
|
||||||
|
|
||||||
|
export default function Unit7Overview() {
|
||||||
|
const unit = curriculum[6];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Lessons", href: "/lessons" },
|
||||||
|
{ label: `Unit 7: ${unit.title}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="mb-10">
|
||||||
|
<Badge variant="unit-7" className="mb-3">Unit 7</Badge>
|
||||||
|
<h1 className="mb-2 text-3xl font-bold tracking-tight">{unit.title}</h1>
|
||||||
|
<p className="text-muted leading-relaxed">{unit.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{unit.topics.map((topic, i) => (
|
||||||
|
<Link key={topic.slug} href={`/lessons/${unit.slug}/${topic.slug}`}>
|
||||||
|
<Card accent="unit-7" hover className="group h-full">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-unit-7-light text-xs font-bold text-unit-7-dark shadow-[var(--shadow-sm)]">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-1 font-semibold">{topic.title}</h3>
|
||||||
|
<p className="text-sm leading-relaxed text-muted">{topic.description}</p>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
app/lessons/unit-7-coordinates/plot-points/page.tsx
Normal file
22
app/lessons/unit-7-coordinates/plot-points/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
|
||||||
|
import { PlotPointsExplorer } from "@/components/explorers/plot-points-explorer";
|
||||||
|
|
||||||
|
export default function PlotPointsPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Lessons", href: "/lessons" },
|
||||||
|
{ label: "Unit 7: Coordinates", href: "/lessons/unit-7-coordinates" },
|
||||||
|
{ label: "Plot Points" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Plot Points on the Cartesian Plane</h1>
|
||||||
|
|
||||||
|
<PlotPointsExplorer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
app/lessons/unit-7-coordinates/read-coordinates/page.tsx
Normal file
22
app/lessons/unit-7-coordinates/read-coordinates/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
|
||||||
|
import { ReadCoordinatesExplorer } from "@/components/explorers/read-coordinates-explorer";
|
||||||
|
|
||||||
|
export default function ReadCoordinatesPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Lessons", href: "/lessons" },
|
||||||
|
{ label: "Unit 7: Coordinates", href: "/lessons/unit-7-coordinates" },
|
||||||
|
{ label: "Read Coordinates" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">State Coordinates of Points</h1>
|
||||||
|
|
||||||
|
<ReadCoordinatesExplorer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
app/lessons/unit-8-number-properties/exponents/page.tsx
Normal file
22
app/lessons/unit-8-number-properties/exponents/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
|
||||||
|
import { ExponentsExplorer } from "@/components/explorers/exponents-explorer";
|
||||||
|
|
||||||
|
export default function ExponentsPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Lessons", href: "/lessons" },
|
||||||
|
{ label: "Unit 8: Number Properties", href: "/lessons/unit-8-number-properties" },
|
||||||
|
{ label: "Exponents" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Powers and Exponents</h1>
|
||||||
|
|
||||||
|
<ExponentsExplorer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
|
||||||
|
import { IdentityInverseExplorer } from "@/components/explorers/identity-inverse-explorer";
|
||||||
|
|
||||||
|
export default function IdentityInversePage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Lessons", href: "/lessons" },
|
||||||
|
{ label: "Unit 8: Number Properties", href: "/lessons/unit-8-number-properties" },
|
||||||
|
{ label: "Identity & Inverse" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Identity and Inverse</h1>
|
||||||
|
|
||||||
|
<IdentityInverseExplorer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
app/lessons/unit-8-number-properties/number-laws/page.tsx
Normal file
22
app/lessons/unit-8-number-properties/number-laws/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
|
||||||
|
import { NumberLawsExplorer } from "@/components/explorers/number-laws-explorer";
|
||||||
|
|
||||||
|
export default function NumberLawsPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Lessons", href: "/lessons" },
|
||||||
|
{ label: "Unit 8: Number Properties", href: "/lessons/unit-8-number-properties" },
|
||||||
|
{ label: "Number Laws" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Commutative, Associative and Distributive Laws</h1>
|
||||||
|
|
||||||
|
<NumberLawsExplorer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
app/lessons/unit-8-number-properties/page.tsx
Normal file
40
app/lessons/unit-8-number-properties/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { curriculum } from "@/lib/curriculum";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
|
||||||
|
|
||||||
|
export default function Unit8Overview() {
|
||||||
|
const unit = curriculum[7];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Lessons", href: "/lessons" },
|
||||||
|
{ label: `Unit 8: ${unit.title}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="mb-10">
|
||||||
|
<Badge variant="unit-8" className="mb-3">Unit 8</Badge>
|
||||||
|
<h1 className="mb-2 text-3xl font-bold tracking-tight">{unit.title}</h1>
|
||||||
|
<p className="text-muted leading-relaxed">{unit.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{unit.topics.map((topic, i) => (
|
||||||
|
<Link key={topic.slug} href={`/lessons/${unit.slug}/${topic.slug}`}>
|
||||||
|
<Card accent="unit-8" hover className="group h-full">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-unit-8-light text-xs font-bold text-unit-8-dark shadow-[var(--shadow-sm)]">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-1 font-semibold">{topic.title}</h3>
|
||||||
|
<p className="text-sm leading-relaxed text-muted">{topic.description}</p>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ export default function NotFound() {
|
|||||||
return (
|
return (
|
||||||
<div className="playground-bg flex min-h-screen flex-col">
|
<div className="playground-bg flex min-h-screen flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="playground-frame hero-gradient relative mx-auto flex w-full max-w-6xl flex-1 items-center justify-center">
|
<main className="playground-frame hero-gradient relative mx-auto flex w-full max-w-7xl flex-1 items-center justify-center">
|
||||||
<div className="dot-pattern absolute inset-0 opacity-25" />
|
<div className="dot-pattern absolute inset-0 opacity-25" />
|
||||||
<div className="relative text-center">
|
<div className="relative text-center">
|
||||||
<p className="mb-2 text-sm font-semibold uppercase tracking-widest text-muted">
|
<p className="mb-2 text-sm font-semibold uppercase tracking-widest text-muted">
|
||||||
|
|||||||
12
app/page.tsx
12
app/page.tsx
@@ -35,6 +35,16 @@ const unitStyles = {
|
|||||||
unitCard: "border-[#a5b4fc] bg-[#eef2ff]",
|
unitCard: "border-[#a5b4fc] bg-[#eef2ff]",
|
||||||
chip: "bg-[#3730a3]",
|
chip: "bg-[#3730a3]",
|
||||||
},
|
},
|
||||||
|
"unit-7": {
|
||||||
|
tile: "from-[#4ade80] to-[#15803d]",
|
||||||
|
unitCard: "border-[#86efac] bg-[#f0fdf4]",
|
||||||
|
chip: "bg-[#15803d]",
|
||||||
|
},
|
||||||
|
"unit-8": {
|
||||||
|
tile: "from-[#fb7185] to-[#be123c]",
|
||||||
|
unitCard: "border-[#fda4af] bg-[#fff1f2]",
|
||||||
|
chip: "bg-[#be123c]",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const topicTiles = [
|
const topicTiles = [
|
||||||
@@ -59,7 +69,7 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<div className="playground-bg flex min-h-screen flex-col">
|
<div className="playground-bg flex min-h-screen flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="playground-frame mx-auto w-full max-w-6xl flex-1 px-3 pb-10 sm:px-5">
|
<main className="playground-frame mx-auto w-full max-w-7xl flex-1 px-3 pb-10 sm:px-5">
|
||||||
<section className="mt-4 rounded-md border-2 border-[#1f50bf] bg-[#f1f6ff] p-4 sm:p-6">
|
<section className="mt-4 rounded-md border-2 border-[#1f50bf] bg-[#f1f6ff] p-4 sm:p-6">
|
||||||
<p className="text-center text-sm font-bold text-[#26438b]">
|
<p className="text-center text-sm font-bold text-[#26438b]">
|
||||||
Interactive maths practice for secondary school students
|
Interactive maths practice for secondary school students
|
||||||
|
|||||||
@@ -1,167 +1,338 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Card } from "@/components/ui/card";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
import { Footer } from "@/components/layout/footer";
|
import { Footer } from "@/components/layout/footer";
|
||||||
import { PracticeSection } from "@/components/practice/practice-section";
|
import { PracticeSection } from "@/components/practice/practice-section";
|
||||||
|
import { useCollapsibleSidebar, SidebarBackdrop, SidebarToggle, sidebarAsideClass, sidebarNavClass } from "@/components/layout/collapsible-sidebar";
|
||||||
|
import { curriculum, type Unit, type Topic } from "@/lib/curriculum";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import type { Difficulty, MathProblem } from "@/lib/problems/types";
|
import type { Difficulty, MathProblem } from "@/lib/problems/types";
|
||||||
|
|
||||||
import { generateFractionAddSubtract, generateFractionMultiply, generateFractionDivide, generateFractionOfQuantity, generateWholeFromFraction } from "@/lib/problems/generators/fraction-problems";
|
import { generateFractionAddSubtract, generateFractionMultiply, generateFractionDivide, generateFractionOfQuantity, generateWholeFromFraction, generateFractionBodmas, generateSignedFraction } from "@/lib/problems/generators/fraction-problems";
|
||||||
import { generateDecimalCompareOrder, generateDecimalRounding, generateStandardForm, generateDecimalAddSubtract, generateDecimalMultiplyDivide, generateDecimalConversion } from "@/lib/problems/generators/decimal-problems";
|
import { generateDecimalCompareOrder, generateDecimalRounding, generateStandardForm, generateDecimalAddSubtract, generateDecimalMultiplyDivide, generateDecimalConversion } from "@/lib/problems/generators/decimal-problems";
|
||||||
import { generateSimplifyRatio, generateDivideInRatio, generateRatioWordProblem } from "@/lib/problems/generators/ratio-problems";
|
import { generateSimplifyRatio, generateDivideInRatio, generateRatioWordProblem, generateDefineRatio, generateFractionsAndRatios } from "@/lib/problems/generators/ratio-problems";
|
||||||
|
import { generateIntegerAddSubtractMixed, generateIntegerMultiplyDivide, generateIntegerOrderCompare } from "@/lib/problems/generators/integer-problems";
|
||||||
|
import { generateDigitValue, generateBinaryToDenary, generateQuaternaryToDenary } from "@/lib/problems/generators/number-system-problems";
|
||||||
|
import { generateIdentityInverse, generateExponentEvaluate, generateDistributive } from "@/lib/problems/generators/number-properties-problems";
|
||||||
|
|
||||||
interface TopicGenerator {
|
type UnitColor = Unit["color"];
|
||||||
unitSlug: string;
|
type PracticeUnitColor = Exclude<UnitColor, "unit-7">;
|
||||||
topicSlug: string;
|
type Generator = (difficulty: Difficulty) => MathProblem;
|
||||||
label: string;
|
|
||||||
generator: (difficulty: Difficulty) => MathProblem;
|
|
||||||
unitColor: "unit-1" | "unit-2" | "unit-3" | "unit-4";
|
|
||||||
}
|
|
||||||
|
|
||||||
type UnitColor = TopicGenerator["unitColor"];
|
/**
|
||||||
|
* Practice for each curriculum topic. "lesson-only" topics (the coordinate grid)
|
||||||
const TOPIC_GENERATORS: TopicGenerator[] = [
|
* need the interactive visual, so they link to their lesson instead of a
|
||||||
{ unitSlug: "unit-1-fractions", topicSlug: "add-subtract", label: "Add & Subtract Fractions", generator: generateFractionAddSubtract, unitColor: "unit-1" },
|
* fill-in-the-answer generator. Keyed by `${unitSlug}/${topicSlug}`.
|
||||||
{ unitSlug: "unit-1-fractions", topicSlug: "multiply", label: "Multiply Fractions", generator: generateFractionMultiply, unitColor: "unit-1" },
|
*/
|
||||||
{ unitSlug: "unit-1-fractions", topicSlug: "divide", label: "Divide Fractions", generator: generateFractionDivide, unitColor: "unit-1" },
|
const PRACTICE_BY_TOPIC: Record<string, Generator | "lesson-only"> = {
|
||||||
{ unitSlug: "unit-1-fractions", topicSlug: "fraction-of-quantity", label: "Fraction of a Quantity", generator: generateFractionOfQuantity, unitColor: "unit-1" },
|
"unit-1-fractions/add-subtract": generateFractionAddSubtract,
|
||||||
{ unitSlug: "unit-1-fractions", topicSlug: "whole-from-fractions", label: "Find the Whole", generator: generateWholeFromFraction, unitColor: "unit-1" },
|
"unit-1-fractions/multiply": generateFractionMultiply,
|
||||||
{ unitSlug: "unit-2-decimals", topicSlug: "compare-order", label: "Compare & Order Decimals", generator: generateDecimalCompareOrder, unitColor: "unit-2" },
|
"unit-1-fractions/divide": generateFractionDivide,
|
||||||
{ unitSlug: "unit-2-decimals", topicSlug: "approximate", label: "Approximate Decimals", generator: generateDecimalRounding, unitColor: "unit-2" },
|
"unit-1-fractions/mixed-operations": generateFractionBodmas,
|
||||||
{ unitSlug: "unit-2-decimals", topicSlug: "standard-form", label: "Standard Form", generator: generateStandardForm, unitColor: "unit-2" },
|
"unit-1-fractions/fraction-of-quantity": generateFractionOfQuantity,
|
||||||
{ unitSlug: "unit-3-decimal-operations", topicSlug: "convert", label: "Convert Decimals & Fractions", generator: generateDecimalConversion, unitColor: "unit-3" },
|
"unit-1-fractions/whole-from-fractions": generateWholeFromFraction,
|
||||||
{ unitSlug: "unit-3-decimal-operations", topicSlug: "add-subtract", label: "Add & Subtract Decimals", generator: generateDecimalAddSubtract, unitColor: "unit-3" },
|
"unit-2-decimals/compare-order": generateDecimalCompareOrder,
|
||||||
{ unitSlug: "unit-3-decimal-operations", topicSlug: "multiply-divide", label: "Multiply & Divide Decimals", generator: generateDecimalMultiplyDivide, unitColor: "unit-3" },
|
"unit-2-decimals/approximate": generateDecimalRounding,
|
||||||
{ unitSlug: "unit-4-ratio-proportion", topicSlug: "simplify-ratios", label: "Simplify Ratios", generator: generateSimplifyRatio, unitColor: "unit-4" },
|
"unit-2-decimals/standard-form": generateStandardForm,
|
||||||
{ unitSlug: "unit-4-ratio-proportion", topicSlug: "divide-in-ratio", label: "Divide in a Ratio", generator: generateDivideInRatio, unitColor: "unit-4" },
|
"unit-3-decimal-operations/convert": generateDecimalConversion,
|
||||||
{ unitSlug: "unit-4-ratio-proportion", topicSlug: "word-problems", label: "Ratio Word Problems", generator: generateRatioWordProblem, unitColor: "unit-4" },
|
"unit-3-decimal-operations/add-subtract": generateDecimalAddSubtract,
|
||||||
];
|
"unit-3-decimal-operations/multiply-divide": generateDecimalMultiplyDivide,
|
||||||
|
"unit-4-ratio-proportion/define-ratio": generateDefineRatio,
|
||||||
const unitColors: Record<UnitColor, { activeFilter: string; inactiveFilter: string; label: string }> = {
|
"unit-4-ratio-proportion/fractions-and-ratios": generateFractionsAndRatios,
|
||||||
"unit-1": {
|
"unit-4-ratio-proportion/simplify-ratios": generateSimplifyRatio,
|
||||||
activeFilter: "border-unit-1 bg-unit-1 text-white shadow-[var(--shadow-sm)]",
|
"unit-4-ratio-proportion/divide-in-ratio": generateDivideInRatio,
|
||||||
inactiveFilter: "border-unit-1/40 text-unit-1-dark hover:bg-unit-1-light",
|
"unit-4-ratio-proportion/word-problems": generateRatioWordProblem,
|
||||||
label: "Fractions",
|
"unit-5-integers/order-compare": generateIntegerOrderCompare,
|
||||||
},
|
"unit-5-integers/add-subtract": generateIntegerAddSubtractMixed,
|
||||||
"unit-2": {
|
"unit-5-integers/multiply-divide": generateIntegerMultiplyDivide,
|
||||||
activeFilter: "border-unit-2 bg-unit-2 text-white shadow-[var(--shadow-sm)]",
|
"unit-5-integers/negative-fractions": generateSignedFraction,
|
||||||
inactiveFilter: "border-unit-2/40 text-unit-2-dark hover:bg-unit-2-light",
|
"unit-6-number-system/place-value": generateDigitValue,
|
||||||
label: "Decimals",
|
"unit-6-number-system/binary": generateBinaryToDenary,
|
||||||
},
|
"unit-6-number-system/quaternary": generateQuaternaryToDenary,
|
||||||
"unit-3": {
|
"unit-7-coordinates/plot-points": "lesson-only",
|
||||||
activeFilter: "border-unit-3 bg-unit-3 text-white shadow-[var(--shadow-sm)]",
|
"unit-7-coordinates/read-coordinates": "lesson-only",
|
||||||
inactiveFilter: "border-unit-3/40 text-unit-3-dark hover:bg-unit-3-light",
|
"unit-8-number-properties/identity-inverse": generateIdentityInverse,
|
||||||
label: "Decimal Operations",
|
"unit-8-number-properties/number-laws": generateDistributive,
|
||||||
},
|
"unit-8-number-properties/exponents": generateExponentEvaluate,
|
||||||
"unit-4": {
|
|
||||||
activeFilter: "border-unit-4 bg-unit-4 text-white shadow-[var(--shadow-sm)]",
|
|
||||||
inactiveFilter: "border-unit-4/40 text-unit-4-dark hover:bg-unit-4-light",
|
|
||||||
label: "Ratio & Proportion",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PracticePage() {
|
function practiceFor(unit: Unit, topic: Topic): Generator | "lesson-only" {
|
||||||
const [selectedTopic, setSelectedTopic] = useState<TopicGenerator | null>(null);
|
return PRACTICE_BY_TOPIC[`${unit.slug}/${topic.slug}`] ?? "lesson-only";
|
||||||
const [filterUnit, setFilterUnit] = useState<UnitColor | null>(null);
|
}
|
||||||
|
|
||||||
const filtered = filterUnit
|
interface UnitStyle {
|
||||||
? TOPIC_GENERATORS.filter((t) => t.unitColor === filterUnit)
|
heading: string;
|
||||||
: TOPIC_GENERATORS;
|
dot: string;
|
||||||
|
accent: string;
|
||||||
|
iconBg: string;
|
||||||
|
hover: string;
|
||||||
|
activeItem: string;
|
||||||
|
headingHover: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNIT_STYLE: Record<UnitColor, UnitStyle> = {
|
||||||
|
"unit-1": { heading: "text-unit-1-dark", dot: "bg-unit-1", accent: "border-l-unit-1", iconBg: "bg-unit-1-light text-unit-1-dark", hover: "hover:border-unit-1/40", activeItem: "bg-unit-1-light text-unit-1-dark border-unit-1/20", headingHover: "hover:bg-unit-1-light/50" },
|
||||||
|
"unit-2": { heading: "text-unit-2-dark", dot: "bg-unit-2", accent: "border-l-unit-2", iconBg: "bg-unit-2-light text-unit-2-dark", hover: "hover:border-unit-2/40", activeItem: "bg-unit-2-light text-unit-2-dark border-unit-2/20", headingHover: "hover:bg-unit-2-light/50" },
|
||||||
|
"unit-3": { heading: "text-unit-3-dark", dot: "bg-unit-3", accent: "border-l-unit-3", iconBg: "bg-unit-3-light text-unit-3-dark", hover: "hover:border-unit-3/40", activeItem: "bg-unit-3-light text-unit-3-dark border-unit-3/20", headingHover: "hover:bg-unit-3-light/50" },
|
||||||
|
"unit-4": { heading: "text-unit-4-dark", dot: "bg-unit-4", accent: "border-l-unit-4", iconBg: "bg-unit-4-light text-unit-4-dark", hover: "hover:border-unit-4/40", activeItem: "bg-unit-4-light text-unit-4-dark border-unit-4/20", headingHover: "hover:bg-unit-4-light/50" },
|
||||||
|
"unit-5": { heading: "text-unit-5-dark", dot: "bg-unit-5", accent: "border-l-unit-5", iconBg: "bg-unit-5-light text-unit-5-dark", hover: "hover:border-unit-5/40", activeItem: "bg-unit-5-light text-unit-5-dark border-unit-5/20", headingHover: "hover:bg-unit-5-light/50" },
|
||||||
|
"unit-6": { heading: "text-unit-6-dark", dot: "bg-unit-6", accent: "border-l-unit-6", iconBg: "bg-unit-6-light text-unit-6-dark", hover: "hover:border-unit-6/40", activeItem: "bg-unit-6-light text-unit-6-dark border-unit-6/20", headingHover: "hover:bg-unit-6-light/50" },
|
||||||
|
"unit-7": { heading: "text-unit-7-dark", dot: "bg-unit-7", accent: "border-l-unit-7", iconBg: "bg-unit-7-light text-unit-7-dark", hover: "hover:border-unit-7/40", activeItem: "bg-unit-7-light text-unit-7-dark border-unit-7/20", headingHover: "hover:bg-unit-7-light/50" },
|
||||||
|
"unit-8": { heading: "text-unit-8-dark", dot: "bg-unit-8", accent: "border-l-unit-8", iconBg: "bg-unit-8-light text-unit-8-dark", hover: "hover:border-unit-8/40", activeItem: "bg-unit-8-light text-unit-8-dark border-unit-8/20", headingHover: "hover:bg-unit-8-light/50" },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Selection {
|
||||||
|
unit: Unit;
|
||||||
|
topic: Topic;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sidebar (mirrors the lessons sidebar) ────────────────────────────────────
|
||||||
|
|
||||||
|
function PracticeSidebar({
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
onAllTopics,
|
||||||
|
mobileOpen,
|
||||||
|
desktopCollapsed,
|
||||||
|
}: {
|
||||||
|
selected: Selection | null;
|
||||||
|
onSelect: (s: Selection) => void;
|
||||||
|
onAllTopics: () => void;
|
||||||
|
mobileOpen: boolean;
|
||||||
|
desktopCollapsed: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState<Set<number>>(() => new Set([curriculum[0].number]));
|
||||||
|
|
||||||
|
function toggle(num: number) {
|
||||||
|
setOpen((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(num)) next.delete(num);
|
||||||
|
else next.add(num);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={sidebarAsideClass(mobileOpen, desktopCollapsed)}>
|
||||||
|
<nav className={sidebarNavClass}>
|
||||||
|
<button
|
||||||
|
onClick={onAllTopics}
|
||||||
|
className={cn(
|
||||||
|
"mb-4 flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-200",
|
||||||
|
selected === null
|
||||||
|
? "bg-foreground text-background shadow-[var(--shadow-sm)]"
|
||||||
|
: "text-muted hover:bg-background hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
All Topics
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mb-3 h-px bg-border/60" />
|
||||||
|
|
||||||
|
{curriculum.map((unit) => {
|
||||||
|
const style = UNIT_STYLE[unit.color];
|
||||||
|
const isOpen = open.has(unit.number);
|
||||||
|
return (
|
||||||
|
<div key={unit.slug} className="mb-1">
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(unit.number)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm font-semibold transition-all duration-200",
|
||||||
|
style.heading,
|
||||||
|
style.headingHover,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">Unit {unit.number}: {unit.title}</span>
|
||||||
|
<svg
|
||||||
|
className={cn("h-4 w-4 shrink-0 transition-transform duration-200", isOpen && "rotate-90")}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="ml-2 mt-0.5 space-y-0.5 border-l-2 border-border/40 pl-2">
|
||||||
|
{unit.topics.map((topic) => {
|
||||||
|
const isActive = selected?.unit.slug === unit.slug && selected?.topic.slug === topic.slug;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={topic.slug}
|
||||||
|
onClick={() => onSelect({ unit, topic })}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm transition-all duration-200",
|
||||||
|
isActive
|
||||||
|
? cn(style.activeItem, "font-medium shadow-[var(--shadow-sm)]")
|
||||||
|
: "text-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("h-1.5 w-1.5 shrink-0 rounded-full transition-colors", isActive ? style.dot : "bg-border")} />
|
||||||
|
<span className="truncate">{topic.shortTitle}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Topic browser (main area, all topics) ────────────────────────────────────
|
||||||
|
|
||||||
|
function TopicBrowser({ onSelect }: { onSelect: (s: Selection) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-9">
|
||||||
|
{curriculum.map((unit) => {
|
||||||
|
const style = UNIT_STYLE[unit.color];
|
||||||
|
return (
|
||||||
|
<section key={unit.slug}>
|
||||||
|
<div className="mb-3 flex items-center gap-2.5">
|
||||||
|
<span className={cn("h-3.5 w-3.5 rounded-full", style.dot)} />
|
||||||
|
<h2 className={cn("text-sm font-extrabold uppercase tracking-wider", style.heading)}>
|
||||||
|
Unit {unit.number}: {unit.title}
|
||||||
|
</h2>
|
||||||
|
<span className="rounded-full bg-border/50 px-2 py-0.5 text-[0.7rem] font-bold text-muted">
|
||||||
|
{unit.topics.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{unit.topics.map((topic) => (
|
||||||
|
<button
|
||||||
|
key={topic.slug}
|
||||||
|
onClick={() => onSelect({ unit, topic })}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-3 rounded-2xl border-2 border-l-4 border-border/60 bg-surface p-4 text-left shadow-[var(--shadow-sm)] transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[var(--shadow-md)]",
|
||||||
|
style.accent,
|
||||||
|
style.hover,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-xl", style.iconBg)}>
|
||||||
|
<PlayIcon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 font-bold leading-tight">{topic.title}</span>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 shrink-0 text-muted/40 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-muted"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Panel for topics that are practised in the interactive lesson ────────────
|
||||||
|
|
||||||
|
function LessonOnlyPanel({ unit, topic }: { unit: Unit; topic: Topic }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border-2 border-border/60 bg-surface p-8 text-center shadow-[var(--shadow-sm)]">
|
||||||
|
<div className={cn("mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl", UNIT_STYLE[unit.color].iconBg)}>
|
||||||
|
<PlayIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-2 text-xl font-bold">{topic.title}</h2>
|
||||||
|
<p className="mx-auto mb-6 max-w-md text-sm text-muted">
|
||||||
|
This topic uses the interactive coordinate grid, so it's best practised right in the lesson.
|
||||||
|
</p>
|
||||||
|
<Link href={`/lessons/${unit.slug}/${topic.slug}`}>
|
||||||
|
<Button variant="primary" size="md">Open the interactive lesson →</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function PracticePage() {
|
||||||
|
const [selected, setSelected] = useState<Selection | null>(null);
|
||||||
|
const { mobileOpen, desktopCollapsed, toggle, closeMobile } = useCollapsibleSidebar();
|
||||||
|
|
||||||
|
function select(s: Selection) {
|
||||||
|
setSelected(s);
|
||||||
|
closeMobile();
|
||||||
|
}
|
||||||
|
|
||||||
|
const generator = selected ? practiceFor(selected.unit, selected.topic) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="playground-bg flex min-h-screen flex-col">
|
<div className="playground-bg flex min-h-screen flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="playground-frame mx-auto w-full max-w-6xl flex-1 bg-white">
|
<div className="playground-frame relative mx-auto flex w-full max-w-7xl flex-1">
|
||||||
|
<SidebarBackdrop show={mobileOpen} onClick={closeMobile} />
|
||||||
|
|
||||||
|
<PracticeSidebar
|
||||||
|
selected={selected}
|
||||||
|
onSelect={select}
|
||||||
|
onAllTopics={() => {
|
||||||
|
setSelected(null);
|
||||||
|
closeMobile();
|
||||||
|
}}
|
||||||
|
mobileOpen={mobileOpen}
|
||||||
|
desktopCollapsed={desktopCollapsed}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="min-w-0 flex-1 bg-white">
|
||||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<SidebarToggle onClick={toggle} />
|
||||||
|
|
||||||
|
{!selected ? (
|
||||||
|
<>
|
||||||
<h1 className="mb-2 text-3xl font-extrabold tracking-tight">Practice</h1>
|
<h1 className="mb-2 text-3xl font-extrabold tracking-tight">Practice</h1>
|
||||||
<p className="mb-8 text-muted">
|
<p className="mb-8 text-muted">
|
||||||
Pick a topic and build your confidence with fresh questions each round.
|
Pick a topic and build your confidence with fresh questions each round.
|
||||||
</p>
|
</p>
|
||||||
|
<TopicBrowser onSelect={select} />
|
||||||
{/* Unit filters */}
|
</>
|
||||||
<div className="mb-6 flex flex-wrap gap-2">
|
) : (
|
||||||
<button
|
<>
|
||||||
onClick={() => setFilterUnit(null)}
|
<Button variant="secondary" size="sm" onClick={() => setSelected(null)} className="mb-6">
|
||||||
className={`rounded-full border-2 px-4 py-1.5 text-sm font-extrabold transition-all duration-200 ${
|
|
||||||
filterUnit === null
|
|
||||||
? "border-foreground bg-foreground text-background shadow-[var(--shadow-sm)]"
|
|
||||||
: "border-border text-muted hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
All Topics
|
|
||||||
</button>
|
|
||||||
{(["unit-1", "unit-2", "unit-3", "unit-4"] as const).map((uc) => (
|
|
||||||
<button
|
|
||||||
key={uc}
|
|
||||||
onClick={() => setFilterUnit(filterUnit === uc ? null : uc)}
|
|
||||||
className={`rounded-full border-2 px-4 py-1.5 text-sm font-extrabold transition-all duration-200 ${
|
|
||||||
filterUnit === uc
|
|
||||||
? unitColors[uc].activeFilter
|
|
||||||
: unitColors[uc].inactiveFilter
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{unitColors[uc].label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Topic grid */}
|
|
||||||
{!selectedTopic && (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
{filtered.map((topic) => (
|
|
||||||
<Card
|
|
||||||
key={`${topic.unitSlug}-${topic.topicSlug}`}
|
|
||||||
hover
|
|
||||||
className="cursor-pointer"
|
|
||||||
accent={topic.unitColor}
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
onClick={() => setSelectedTopic(topic)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedTopic(topic);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-bold">{topic.label}</span>
|
|
||||||
<Badge variant={topic.unitColor}>
|
|
||||||
{unitColors[topic.unitColor].label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Practice area */}
|
|
||||||
{selectedTopic && (
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setSelectedTopic(null)}
|
|
||||||
className="mb-6"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
</svg>
|
</svg>
|
||||||
Back to Topics
|
All Topics
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{generator === "lesson-only" || generator === null ? (
|
||||||
|
<LessonOnlyPanel unit={selected.unit} topic={selected.topic} />
|
||||||
|
) : (
|
||||||
<PracticeSection
|
<PracticeSection
|
||||||
title={`Practice: ${selectedTopic.label}`}
|
key={`${selected.unit.slug}/${selected.topic.slug}`}
|
||||||
generator={selectedTopic.generator}
|
title={`Practice: ${selected.topic.title}`}
|
||||||
unitColor={selectedTopic.unitColor}
|
generator={generator}
|
||||||
|
unitColor={selected.unit.color as PracticeUnitColor}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { StepControls } from "./step-controls";
|
import { StepControls } from "./step-controls";
|
||||||
import { MathDisplay } from "@/components/math/math-display";
|
import { MathDisplay } from "@/components/math/math-display";
|
||||||
import { toKatex, simplify, add, subtract, multiply, divide } from "@/lib/math/fractions";
|
import { toKatex, Fraction } from "@/lib/math/fractions";
|
||||||
|
|
||||||
interface FractionVal {
|
interface FractionVal {
|
||||||
num: number;
|
num: number;
|
||||||
@@ -30,24 +30,26 @@ const OP_PRIORITY: Record<string, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function performOp(a: FractionVal, b: FractionVal, op: string): FractionVal {
|
function performOp(a: FractionVal, b: FractionVal, op: string): FractionVal {
|
||||||
let result: [number, number];
|
const fa = new Fraction(a.num, a.den);
|
||||||
|
const fb = new Fraction(b.num, b.den);
|
||||||
|
let result: Fraction;
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "+":
|
case "+":
|
||||||
result = add(a.num, a.den, b.num, b.den);
|
result = fa.plus(fb);
|
||||||
break;
|
break;
|
||||||
case "−":
|
case "−":
|
||||||
result = subtract(a.num, a.den, b.num, b.den);
|
result = fa.minus(fb);
|
||||||
break;
|
break;
|
||||||
case "×":
|
case "×":
|
||||||
result = multiply(a.num, a.den, b.num, b.den);
|
result = fa.times(fb);
|
||||||
break;
|
break;
|
||||||
case "÷":
|
case "÷":
|
||||||
result = divide(a.num, a.den, b.num, b.den);
|
result = fa.dividedBy(fb);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
result = [0, 1];
|
result = new Fraction(0, 1);
|
||||||
}
|
}
|
||||||
return { num: result[0], den: result[1] };
|
return { num: result.n, den: result.d };
|
||||||
}
|
}
|
||||||
|
|
||||||
function expressionToKatex(fracs: FractionVal[], ops: string[], highlightIdx: number | null): string {
|
function expressionToKatex(fracs: FractionVal[], ops: string[], highlightIdx: number | null): string {
|
||||||
@@ -97,7 +99,7 @@ function buildBodmasSteps(expr: Expression): Step[] {
|
|||||||
|
|
||||||
// Perform the operation
|
// Perform the operation
|
||||||
const result = performOp(fracs[opIdx], fracs[opIdx + 1], ops[opIdx]);
|
const result = performOp(fracs[opIdx], fracs[opIdx + 1], ops[opIdx]);
|
||||||
const [sn, sd] = simplify(result.num, result.den);
|
const { n: sn, d: sd } = new Fraction(result.num, result.den).simplified();
|
||||||
|
|
||||||
const newFracs = [...fracs];
|
const newFracs = [...fracs];
|
||||||
newFracs.splice(opIdx, 2, { num: sn, den: sd });
|
newFracs.splice(opIdx, 2, { num: sn, den: sd });
|
||||||
|
|||||||
181
components/explorers/cartesian-grid.tsx
Normal file
181
components/explorers/cartesian-grid.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId } from "react";
|
||||||
|
|
||||||
|
export interface GridPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
label?: string;
|
||||||
|
tone?: "solid" | "target" | "wrong" | "muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridSegment {
|
||||||
|
from: { x: number; y: number };
|
||||||
|
to: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridArrow {
|
||||||
|
from: { x: number; y: number };
|
||||||
|
to: { x: number; y: number };
|
||||||
|
dashed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CartesianGridProps {
|
||||||
|
points?: GridPoint[];
|
||||||
|
segments?: GridSegment[];
|
||||||
|
arrows?: GridArrow[];
|
||||||
|
/** Draws dashed guide lines from this point to the axes. `axis` picks which (default both). */
|
||||||
|
highlight?: { x: number; y: number; axis?: "x" | "y" | "both" };
|
||||||
|
onGridClick?: (x: number, y: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN = -10;
|
||||||
|
const MAX = 10;
|
||||||
|
const UNIT = 20;
|
||||||
|
const MARGIN = 20;
|
||||||
|
const SIZE = MARGIN * 2 + (MAX - MIN) * UNIT; // 440
|
||||||
|
|
||||||
|
const sx = (x: number) => MARGIN + (x - MIN) * UNIT;
|
||||||
|
const sy = (y: number) => MARGIN + (MAX - y) * UNIT;
|
||||||
|
|
||||||
|
const toneColor: Record<NonNullable<GridPoint["tone"]>, string> = {
|
||||||
|
solid: "var(--unit-7)",
|
||||||
|
target: "var(--correct)",
|
||||||
|
wrong: "var(--incorrect)",
|
||||||
|
muted: "var(--muted)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CartesianGrid({
|
||||||
|
points = [],
|
||||||
|
segments = [],
|
||||||
|
arrows = [],
|
||||||
|
highlight,
|
||||||
|
onGridClick,
|
||||||
|
}: CartesianGridProps) {
|
||||||
|
const arrowHeadId = useId();
|
||||||
|
const axisArrowId = useId();
|
||||||
|
|
||||||
|
const ticks: number[] = [];
|
||||||
|
for (let v = MIN; v <= MAX; v++) ticks.push(v);
|
||||||
|
const labelTicks = ticks.filter((v) => v !== 0 && v % 2 === 0);
|
||||||
|
|
||||||
|
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
|
||||||
|
if (!onGridClick) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const px = ((e.clientX - rect.left) / rect.width) * SIZE;
|
||||||
|
const py = ((e.clientY - rect.top) / rect.height) * SIZE;
|
||||||
|
const x = Math.round((px - MARGIN) / UNIT + MIN);
|
||||||
|
const y = Math.round(MAX - (py - MARGIN) / UNIT);
|
||||||
|
if (x < MIN || x > MAX || y < MIN || y > MAX) return;
|
||||||
|
onGridClick(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
|
className={`h-auto w-full max-w-md ${onGridClick ? "cursor-crosshair" : ""}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
role="img"
|
||||||
|
aria-label="Cartesian coordinate grid"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<marker id={arrowHeadId} markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
|
||||||
|
<path d="M0 0 L8 4 L0 8 z" fill="var(--unit-7-dark)" />
|
||||||
|
</marker>
|
||||||
|
<marker id={axisArrowId} markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto">
|
||||||
|
<path d="M0 0 L9 4.5 L0 9 z" fill="var(--foreground)" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Light gridlines */}
|
||||||
|
{ticks.map((v) => (
|
||||||
|
<g key={`grid-${v}`}>
|
||||||
|
<line x1={sx(v)} y1={sy(MIN)} x2={sx(v)} y2={sy(MAX)} stroke="var(--border)" strokeWidth={1} />
|
||||||
|
<line x1={sx(MIN)} y1={sy(v)} x2={sx(MAX)} y2={sy(v)} stroke="var(--border)" strokeWidth={1} />
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Axes */}
|
||||||
|
<line x1={12} y1={sy(0)} x2={SIZE - 8} y2={sy(0)} stroke="var(--foreground)" strokeWidth={2} markerEnd={`url(#${axisArrowId})`} />
|
||||||
|
<line x1={sx(0)} y1={SIZE - 8} x2={sx(0)} y2={12} stroke="var(--foreground)" strokeWidth={2} markerEnd={`url(#${axisArrowId})`} />
|
||||||
|
<text x={SIZE - 12} y={sy(0) - 8} fontSize={14} fontWeight={700} fill="var(--foreground)" textAnchor="end">x</text>
|
||||||
|
<text x={sx(0) + 10} y={18} fontSize={14} fontWeight={700} fill="var(--foreground)">y</text>
|
||||||
|
|
||||||
|
{/* Axis labels */}
|
||||||
|
{labelTicks.map((v) => (
|
||||||
|
<text key={`xl-${v}`} x={sx(v)} y={sy(0) + 15} fontSize={10} fill="var(--muted)" textAnchor="middle">
|
||||||
|
{v}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
{labelTicks.map((v) => (
|
||||||
|
<text key={`yl-${v}`} x={sx(0) - 7} y={sy(v) + 3.5} fontSize={10} fill="var(--muted)" textAnchor="end">
|
||||||
|
{v}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
<text x={sx(0) - 7} y={sy(0) + 15} fontSize={10} fill="var(--muted)" textAnchor="end">0</text>
|
||||||
|
|
||||||
|
{/* Guide lines */}
|
||||||
|
{highlight && (
|
||||||
|
<g>
|
||||||
|
{(highlight.axis ?? "both") !== "y" && (
|
||||||
|
<line x1={sx(highlight.x)} y1={sy(highlight.y)} x2={sx(highlight.x)} y2={sy(0)} stroke="var(--unit-7)" strokeWidth={1.5} strokeDasharray="4 3" opacity={0.8} />
|
||||||
|
)}
|
||||||
|
{(highlight.axis ?? "both") !== "x" && (
|
||||||
|
<line x1={sx(highlight.x)} y1={sy(highlight.y)} x2={sx(0)} y2={sy(highlight.y)} stroke="var(--unit-7)" strokeWidth={1.5} strokeDasharray="4 3" opacity={0.8} />
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Segments */}
|
||||||
|
{segments.map((s, i) => (
|
||||||
|
<line
|
||||||
|
key={`seg-${i}`}
|
||||||
|
x1={sx(s.from.x)}
|
||||||
|
y1={sy(s.from.y)}
|
||||||
|
x2={sx(s.to.x)}
|
||||||
|
y2={sy(s.to.y)}
|
||||||
|
stroke="var(--unit-7)"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Movement arrows */}
|
||||||
|
{arrows.map((a, i) => (
|
||||||
|
<line
|
||||||
|
key={`arr-${i}`}
|
||||||
|
x1={sx(a.from.x)}
|
||||||
|
y1={sy(a.from.y)}
|
||||||
|
x2={sx(a.to.x)}
|
||||||
|
y2={sy(a.to.y)}
|
||||||
|
stroke="var(--unit-7-dark)"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={a.dashed ? "5 4" : undefined}
|
||||||
|
markerEnd={`url(#${arrowHeadId})`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Points */}
|
||||||
|
{points.map((p, i) => {
|
||||||
|
const color = toneColor[p.tone ?? "solid"];
|
||||||
|
return (
|
||||||
|
<g key={`pt-${i}`}>
|
||||||
|
<circle cx={sx(p.x)} cy={sy(p.y)} r={5} fill={color} stroke="white" strokeWidth={1.5} />
|
||||||
|
{p.label && (
|
||||||
|
<text
|
||||||
|
x={sx(p.x) + 8}
|
||||||
|
y={sy(p.y) - 8}
|
||||||
|
fontSize={12}
|
||||||
|
fontWeight={700}
|
||||||
|
fill={color}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { useState, useCallback } from "react";
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { StepControls } from "./step-controls";
|
import { StepControls } from "./step-controls";
|
||||||
import { MathDisplay } from "@/components/math/math-display";
|
import { MathDisplay } from "@/components/math/math-display";
|
||||||
import { simplify, toKatex, gcd } from "@/lib/math/fractions";
|
import { toKatex, gcd, Fraction } from "@/lib/math/fractions";
|
||||||
import { parseStrictDecimal, parseStrictInt } from "@/lib/math/validation";
|
import { parseStrictDecimal, parseStrictInt } from "@/lib/math/validation";
|
||||||
|
|
||||||
type ConvertMode = "decToFrac" | "fracToDec";
|
type ConvertMode = "decToFrac" | "fracToDec";
|
||||||
@@ -42,7 +42,7 @@ function buildDecToFracSteps(decimal: string): Step[] {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Simplify
|
// Simplify
|
||||||
const [sn, sd] = simplify(numerator, denominator);
|
const { n: sn, d: sd } = new Fraction(numerator, denominator).simplified();
|
||||||
if (sn !== numerator || sd !== denominator) {
|
if (sn !== numerator || sd !== denominator) {
|
||||||
const g = gcd(numerator, denominator);
|
const g = gcd(numerator, denominator);
|
||||||
steps.push({
|
steps.push({
|
||||||
@@ -89,7 +89,7 @@ function buildFracToDecSteps(num: number, den: number): Step[] {
|
|||||||
|
|
||||||
// Check for terminating vs repeating
|
// Check for terminating vs repeating
|
||||||
let tempDen = den;
|
let tempDen = den;
|
||||||
const [, simpDen] = simplify(num, den);
|
const { d: simpDen } = new Fraction(num, den).simplified();
|
||||||
tempDen = simpDen;
|
tempDen = simpDen;
|
||||||
// Remove factors of 2 and 5
|
// Remove factors of 2 and 5
|
||||||
while (tempDen % 2 === 0) tempDen /= 2;
|
while (tempDen % 2 === 0) tempDen /= 2;
|
||||||
|
|||||||
372
components/explorers/exponents-explorer.tsx
Normal file
372
components/explorers/exponents-explorer.tsx
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
type Mode = "expand" | "index";
|
||||||
|
type IndexOp = "multiply" | "divide";
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
label: string;
|
||||||
|
math: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repeat(base: number, count: number): string {
|
||||||
|
return Array.from({ length: count }, () => `${base}`).join(" \\times ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExpandSteps(base: number, exp: number): Step[] {
|
||||||
|
if (exp === 0) {
|
||||||
|
return [
|
||||||
|
{ label: "The power (index) is 0.", math: `${base}^{0}` },
|
||||||
|
{ label: "Any number raised to the power 0 is equal to 1.", math: `${base}^{0} = 1` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (exp === 1) {
|
||||||
|
return [
|
||||||
|
{ label: "The power (index) is 1.", math: `${base}^{1}` },
|
||||||
|
{ label: "Any number raised to the power 1 is itself.", math: `${base}^{1} = ${base}` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps: Step[] = [
|
||||||
|
{
|
||||||
|
label: `The base is ${base} and the power is ${exp}: multiply the base by itself ${exp} times.`,
|
||||||
|
math: `${base}^{${exp}}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let running = base;
|
||||||
|
for (let k = 2; k <= exp; k++) {
|
||||||
|
running *= base;
|
||||||
|
steps.push({
|
||||||
|
label: `Multiply on another ${base}.`,
|
||||||
|
math: `${repeat(base, k)} = ${running}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
steps.push({ label: "So the power is worked out.", math: `${base}^{${exp}} = ${running}` });
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIndexSteps(base: number, m: number, n: number, op: IndexOp): Step[] {
|
||||||
|
if (op === "multiply") {
|
||||||
|
const total = m + n;
|
||||||
|
const value = Math.pow(base, total);
|
||||||
|
const steps: Step[] = [
|
||||||
|
{ label: "Multiplying powers of the SAME base.", math: `${base}^{${m}} \\times ${base}^{${n}}` },
|
||||||
|
{ label: "Write each power out in full.", math: `(${repeat(base, m)}) \\times (${repeat(base, n)})` },
|
||||||
|
{ label: `Count the factors: ${m} + ${n} = ${total}. When multiplying, ADD the powers.`, math: `${base}^{${m}} \\times ${base}^{${n}} = ${base}^{${m}+${n}} = ${base}^{${total}}` },
|
||||||
|
];
|
||||||
|
if (value <= 1e7) steps.push({ label: "Work out the value.", math: `${base}^{${total}} = ${value}` });
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
// divide, m >= n
|
||||||
|
const diff = m - n;
|
||||||
|
const value = Math.pow(base, diff);
|
||||||
|
const steps: Step[] = [
|
||||||
|
{ label: "Dividing powers of the SAME base.", math: `\\frac{${base}^{${m}}}{${base}^{${n}}}` },
|
||||||
|
{ label: "Write each power out in full.", math: `\\frac{${repeat(base, m)}}{${repeat(base, n)}}` },
|
||||||
|
{ label: `Cancel the ${n} matching factor${n === 1 ? "" : "s"} from top and bottom.`, math: `${base}^{${m}} \\div ${base}^{${n}} = ${base}^{${m}-${n}} = ${base}^{${diff}}` },
|
||||||
|
];
|
||||||
|
if (value <= 1e7) steps.push({ label: "Work out the value.", math: `${base}^{${diff}} = ${value}` });
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quick practice ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PracticeQ {
|
||||||
|
prompt: string;
|
||||||
|
answer: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randInt(min: number, max: number) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPERSCRIPT: Record<string, string> = {
|
||||||
|
"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴",
|
||||||
|
"5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹",
|
||||||
|
};
|
||||||
|
function sup(n: number): string {
|
||||||
|
return String(n).split("").map((c) => SUPERSCRIPT[c] ?? c).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeQuestion(): PracticeQ {
|
||||||
|
const roll = Math.floor(Math.random() * 5);
|
||||||
|
if (roll === 0) {
|
||||||
|
const base = randInt(2, 7);
|
||||||
|
const exp = randInt(2, 3);
|
||||||
|
return { prompt: `What is ${base}${sup(exp)}?`, answer: Math.pow(base, exp) };
|
||||||
|
}
|
||||||
|
if (roll === 1) {
|
||||||
|
const base = randInt(2, 9);
|
||||||
|
return { prompt: `What is ${base}${sup(0)}?`, answer: 1 };
|
||||||
|
}
|
||||||
|
if (roll === 2) {
|
||||||
|
const base = randInt(2, 4);
|
||||||
|
const a = randInt(2, 3);
|
||||||
|
const b = 1;
|
||||||
|
return { prompt: `What is ${base}${sup(a)} − ${base}${sup(b)}?`, answer: Math.pow(base, a) - Math.pow(base, b) };
|
||||||
|
}
|
||||||
|
if (roll === 3) {
|
||||||
|
const base = randInt(2, 5);
|
||||||
|
const m = randInt(3, 6);
|
||||||
|
const n = randInt(1, m - 1);
|
||||||
|
return { prompt: `Simplify ${base}${sup(m)} ÷ ${base}${sup(n)}. The answer is ${base} to what power?`, answer: m - n };
|
||||||
|
}
|
||||||
|
const base = randInt(2, 6);
|
||||||
|
const m = randInt(1, 4);
|
||||||
|
const n = randInt(1, 4);
|
||||||
|
return { prompt: `Simplify ${base}${sup(m)} × ${base}${sup(n)}. The answer is ${base} to what power?`, answer: m + n };
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickPractice() {
|
||||||
|
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
|
||||||
|
const [val, setVal] = useState("");
|
||||||
|
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
|
||||||
|
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||||
|
|
||||||
|
function check() {
|
||||||
|
const parsed = parseInt(val, 10);
|
||||||
|
if (isNaN(parsed)) return;
|
||||||
|
const correct = parsed === q.answer;
|
||||||
|
setFeedback(correct ? "correct" : "incorrect");
|
||||||
|
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
setQ(makeQuestion());
|
||||||
|
setVal("");
|
||||||
|
setFeedback(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-unit-8">Quick Practice</p>
|
||||||
|
<span className="rounded-full bg-unit-8-light px-3 py-1 text-xs font-bold text-unit-8-dark">
|
||||||
|
{score.correct}/{score.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-lg font-bold">{q.prompt}</p>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={val}
|
||||||
|
onChange={(e) => {
|
||||||
|
setVal(e.target.value);
|
||||||
|
setFeedback(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (feedback !== null) next();
|
||||||
|
else check();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`w-24 rounded-lg border-2 px-3 py-2 text-center text-xl font-bold outline-none transition-colors ${
|
||||||
|
feedback === "correct"
|
||||||
|
? "border-correct bg-correct-light"
|
||||||
|
: feedback === "incorrect"
|
||||||
|
? "border-incorrect bg-incorrect-light"
|
||||||
|
: "border-border focus:border-unit-8"
|
||||||
|
}`}
|
||||||
|
placeholder="?"
|
||||||
|
aria-label="Your answer"
|
||||||
|
/>
|
||||||
|
{feedback === null ? (
|
||||||
|
<button onClick={check} className="rounded-lg bg-unit-8 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-8-dark">
|
||||||
|
Check
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
|
||||||
|
{feedback === "incorrect" && (
|
||||||
|
<p className="text-center text-sm font-bold text-incorrect">Not quite — the answer is {q.answer}.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main explorer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ExponentsExplorer() {
|
||||||
|
const [mode, setMode] = useState<Mode>("expand");
|
||||||
|
const [indexOp, setIndexOp] = useState<IndexOp>("multiply");
|
||||||
|
const [base, setBase] = useState("2");
|
||||||
|
const [power, setPower] = useState("4");
|
||||||
|
const [m, setM] = useState("4");
|
||||||
|
const [n, setN] = useState("3");
|
||||||
|
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(md: Mode) {
|
||||||
|
setMode(md);
|
||||||
|
setError("");
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGo() {
|
||||||
|
setError("");
|
||||||
|
const b = parseInt(base, 10);
|
||||||
|
if (isNaN(b) || b < 2 || b > 12) return setError("Use a base between 2 and 12.");
|
||||||
|
|
||||||
|
if (mode === "expand") {
|
||||||
|
const e = parseInt(power, 10);
|
||||||
|
if (isNaN(e) || e < 0 || e > 6) return setError("Use a power between 0 and 6.");
|
||||||
|
if (Math.pow(b, e) > 1_000_000) return setError("That gets too big. Try a smaller base or power.");
|
||||||
|
setSteps(buildExpandSteps(b, e));
|
||||||
|
} else {
|
||||||
|
const mm = parseInt(m, 10);
|
||||||
|
const nn = parseInt(n, 10);
|
||||||
|
if (isNaN(mm) || isNaN(nn) || mm < 1 || nn < 1 || mm > 8 || nn > 8) return setError("Use powers between 1 and 8.");
|
||||||
|
if (indexOp === "divide" && mm < nn) return setError("For division, the top power must be at least the bottom power.");
|
||||||
|
setSteps(buildIndexSteps(b, mm, nn, indexOp));
|
||||||
|
}
|
||||||
|
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">
|
||||||
|
{(["expand", "index"] as Mode[]).map((md) => (
|
||||||
|
<button
|
||||||
|
key={md}
|
||||||
|
onClick={() => switchMode(md)}
|
||||||
|
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
|
||||||
|
mode === md
|
||||||
|
? "border-unit-8 bg-unit-8 text-white"
|
||||||
|
: "border-unit-8/40 text-unit-8 hover:bg-unit-8-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{md === "expand" ? "Expand a Power" : "Index Laws"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Index op tabs */}
|
||||||
|
{mode === "index" && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["multiply", "divide"] as IndexOp[]).map((o) => (
|
||||||
|
<button
|
||||||
|
key={o}
|
||||||
|
onClick={() => {
|
||||||
|
setIndexOp(o);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
|
||||||
|
indexOp === o
|
||||||
|
? "border-unit-8-dark bg-unit-8-dark text-white"
|
||||||
|
: "border-unit-8/30 text-unit-8-dark hover:bg-unit-8-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o === "multiply" ? "Multiply (add powers)" : "Divide (subtract powers)"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input card */}
|
||||||
|
<Card>
|
||||||
|
{mode === "expand" ? (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">Enter a base and a power</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<input type="number" value={base} onChange={(e) => setBase(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-8" aria-label="Base" />
|
||||||
|
<span className="text-sm font-bold text-muted">to the power</span>
|
||||||
|
<input type="number" value={power} onChange={(e) => setPower(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-8" aria-label="Power" />
|
||||||
|
<button onClick={handleGo} className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-dark">
|
||||||
|
Go →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">
|
||||||
|
Same base, two powers {indexOp === "multiply" ? "to multiply" : "to divide"}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="text-sm font-bold text-muted">base</span>
|
||||||
|
<input type="number" value={base} onChange={(e) => setBase(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-8" aria-label="Base" />
|
||||||
|
<span className="text-sm font-bold text-muted">powers</span>
|
||||||
|
<input type="number" value={m} onChange={(e) => setM(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-8" aria-label="First power" />
|
||||||
|
<span className="text-lg font-bold text-muted">{indexOp === "multiply" ? "×" : "÷"}</span>
|
||||||
|
<input type="number" value={n} onChange={(e) => setN(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-8" aria-label="Second power" />
|
||||||
|
<button onClick={handleGo} className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-dark">
|
||||||
|
Go →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Display card */}
|
||||||
|
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
|
||||||
|
{!step ? (
|
||||||
|
<p className="text-muted/50">
|
||||||
|
Enter values above and click <strong>Go</strong>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
|
||||||
|
<MathDisplay math={step.math} className="text-2xl" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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]" />}>
|
||||||
|
<QuickPractice />
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { StepControls } from "./step-controls";
|
import { StepControls } from "./step-controls";
|
||||||
import { gcd, lcm, simplify, toKatex } from "@/lib/math/fractions";
|
import { gcd, lcm, toKatex, Fraction } from "@/lib/math/fractions";
|
||||||
import { MathDisplay } from "@/components/math/math-display";
|
import { MathDisplay } from "@/components/math/math-display";
|
||||||
|
|
||||||
type Operation = "add" | "subtract" | "multiply" | "divide";
|
type Operation = "add" | "subtract" | "multiply" | "divide";
|
||||||
@@ -68,7 +68,7 @@ function buildSteps(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Step 4: Simplify if needed
|
// Step 4: Simplify if needed
|
||||||
const [sn, sd] = simplify(resultNum, lcd);
|
const { n: sn, d: sd } = new Fraction(resultNum, lcd).simplified();
|
||||||
if (Math.abs(sn) !== Math.abs(resultNum) || sd !== lcd) {
|
if (Math.abs(sn) !== Math.abs(resultNum) || sd !== lcd) {
|
||||||
const g = gcd(Math.abs(resultNum), lcd);
|
const g = gcd(Math.abs(resultNum), lcd);
|
||||||
steps.push({
|
steps.push({
|
||||||
@@ -92,7 +92,7 @@ function buildSteps(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Step 2: Simplify
|
// Step 2: Simplify
|
||||||
const [sn, sd] = simplify(rn, rd);
|
const { n: sn, d: sd } = new Fraction(rn, rd).simplified();
|
||||||
if (sn !== rn || sd !== rd) {
|
if (sn !== rn || sd !== rd) {
|
||||||
const g = gcd(Math.abs(rn), rd);
|
const g = gcd(Math.abs(rn), rd);
|
||||||
steps.push({
|
steps.push({
|
||||||
@@ -122,7 +122,7 @@ function buildSteps(
|
|||||||
barResult: { filled: rn, total: rd },
|
barResult: { filled: rn, total: rd },
|
||||||
});
|
});
|
||||||
|
|
||||||
const [sn, sd] = simplify(rn, rd);
|
const { n: sn, d: sd } = new Fraction(rn, rd).simplified();
|
||||||
if (sn !== rn || sd !== rd) {
|
if (sn !== rn || sd !== rd) {
|
||||||
const g = gcd(Math.abs(rn), rd);
|
const g = gcd(Math.abs(rn), rd);
|
||||||
steps.push({
|
steps.push({
|
||||||
|
|||||||
331
components/explorers/identity-inverse-explorer.tsx
Normal file
331
components/explorers/identity-inverse-explorer.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
type Mode = "identity" | "inverse" | "zero";
|
||||||
|
type Op = "add" | "multiply";
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
label: string;
|
||||||
|
math: string;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIdentitySteps(n: number, op: Op): Step[] {
|
||||||
|
if (op === "add") {
|
||||||
|
return [
|
||||||
|
{ label: "The identity for addition is 0 — it leaves a number unchanged.", math: `${n} + 0` },
|
||||||
|
{ label: "Adding 0 changes nothing.", math: `${n} + 0 = ${n}` },
|
||||||
|
{ label: "So 0 is the identity element for addition.", math: `n + 0 = n` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ label: "The identity for multiplication is 1 — it leaves a number unchanged.", math: `${n} \\times 1` },
|
||||||
|
{ label: "Multiplying by 1 changes nothing.", math: `${n} \\times 1 = ${n}` },
|
||||||
|
{ label: "So 1 is the identity element for multiplication.", math: `n \\times 1 = n` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInverseSteps(n: number, op: Op): Step[] {
|
||||||
|
if (op === "add") {
|
||||||
|
const inv = -n;
|
||||||
|
const invText = inv < 0 ? `(${inv})` : `${inv}`;
|
||||||
|
return [
|
||||||
|
{ label: "The additive inverse is the number that brings you back to the identity, 0.", math: `${n} + \\square = 0` },
|
||||||
|
{ label: `Flip the sign of ${n} to get its additive inverse.`, math: `\\text{inverse of } ${n} = ${inv}` },
|
||||||
|
{ label: "Check: adding a number and its inverse gives 0.", math: `${n} + ${invText} = 0` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// multiplicative inverse (reciprocal)
|
||||||
|
const absN = Math.abs(n);
|
||||||
|
const recip = n < 0 ? `-\\frac{1}{${absN}}` : `\\frac{1}{${absN}}`;
|
||||||
|
const recipParen = n < 0 ? `\\left(-\\frac{1}{${absN}}\\right)` : `\\frac{1}{${absN}}`;
|
||||||
|
return [
|
||||||
|
{ label: "The multiplicative inverse (reciprocal) brings you back to the identity, 1.", math: `${n} \\times \\square = 1` },
|
||||||
|
{ label: `Write ${n} as a fraction and flip it. The sign stays in front.`, math: `\\text{inverse of } ${n} = ${recip}` },
|
||||||
|
{ label: "Check: a number times its reciprocal gives 1.", math: `${n} \\times ${recipParen} = 1` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildZeroSteps(n: number): Step[] {
|
||||||
|
return [
|
||||||
|
{ label: "Multiplying any number by zero always gives zero.", math: `${n} \\times 0 = 0` },
|
||||||
|
{ label: "Zero shared into any number of groups is still zero.", math: `0 \\div ${n === 0 ? 5 : n} = 0` },
|
||||||
|
{
|
||||||
|
label: "But you can never divide BY zero — there is no answer. It is undefined.",
|
||||||
|
math: `${n} \\div 0 = \\text{undefined}`,
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quick practice ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PracticeQ {
|
||||||
|
prompt: string;
|
||||||
|
answer: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randInt(min: number, max: number) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeQuestion(): PracticeQ {
|
||||||
|
const roll = Math.floor(Math.random() * 5);
|
||||||
|
if (roll === 0) {
|
||||||
|
const n = randInt(-12, 12) || 3;
|
||||||
|
return { prompt: `What is the additive inverse of ${n}?`, answer: -n };
|
||||||
|
}
|
||||||
|
if (roll === 1) {
|
||||||
|
const n = randInt(1, 20);
|
||||||
|
return { prompt: `What is ${n} × 0?`, answer: 0 };
|
||||||
|
}
|
||||||
|
if (roll === 2) {
|
||||||
|
return { prompt: `What number is the identity for addition?`, answer: 0 };
|
||||||
|
}
|
||||||
|
if (roll === 3) {
|
||||||
|
return { prompt: `What number is the identity for multiplication?`, answer: 1 };
|
||||||
|
}
|
||||||
|
const n = randInt(2, 12);
|
||||||
|
return { prompt: `The multiplicative inverse of ${n} is 1 over what number?`, answer: n };
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickPractice() {
|
||||||
|
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
|
||||||
|
const [val, setVal] = useState("");
|
||||||
|
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
|
||||||
|
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||||
|
|
||||||
|
function check() {
|
||||||
|
const parsed = parseInt(val, 10);
|
||||||
|
if (isNaN(parsed)) return;
|
||||||
|
const correct = parsed === q.answer;
|
||||||
|
setFeedback(correct ? "correct" : "incorrect");
|
||||||
|
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
setQ(makeQuestion());
|
||||||
|
setVal("");
|
||||||
|
setFeedback(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-unit-8">Quick Practice</p>
|
||||||
|
<span className="rounded-full bg-unit-8-light px-3 py-1 text-xs font-bold text-unit-8-dark">
|
||||||
|
{score.correct}/{score.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-lg font-bold">{q.prompt}</p>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={val}
|
||||||
|
onChange={(e) => {
|
||||||
|
setVal(e.target.value);
|
||||||
|
setFeedback(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (feedback !== null) next();
|
||||||
|
else check();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`w-24 rounded-lg border-2 px-3 py-2 text-center text-xl font-bold outline-none transition-colors ${
|
||||||
|
feedback === "correct"
|
||||||
|
? "border-correct bg-correct-light"
|
||||||
|
: feedback === "incorrect"
|
||||||
|
? "border-incorrect bg-incorrect-light"
|
||||||
|
: "border-border focus:border-unit-8"
|
||||||
|
}`}
|
||||||
|
placeholder="?"
|
||||||
|
aria-label="Your answer"
|
||||||
|
/>
|
||||||
|
{feedback === null ? (
|
||||||
|
<button onClick={check} className="rounded-lg bg-unit-8 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-8-dark">
|
||||||
|
Check
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
|
||||||
|
{feedback === "incorrect" && (
|
||||||
|
<p className="text-center text-sm font-bold text-incorrect">Not quite — the answer is {q.answer}.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main explorer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function IdentityInverseExplorer() {
|
||||||
|
const [mode, setMode] = useState<Mode>("identity");
|
||||||
|
const [op, setOp] = useState<Op>("add");
|
||||||
|
const [input, setInput] = useState("3");
|
||||||
|
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 handleGo() {
|
||||||
|
setError("");
|
||||||
|
const n = parseInt(input, 10);
|
||||||
|
if (isNaN(n)) return setError("Enter a valid whole number.");
|
||||||
|
if (Math.abs(n) > 100) return setError("Keep the number between -100 and 100.");
|
||||||
|
if (mode === "inverse" && op === "multiply" && n === 0) {
|
||||||
|
return setError("Zero has no multiplicative inverse — try another number.");
|
||||||
|
}
|
||||||
|
if (mode === "identity") setSteps(buildIdentitySteps(n, op));
|
||||||
|
else if (mode === "inverse") setSteps(buildInverseSteps(n, op));
|
||||||
|
else setSteps(buildZeroSteps(n));
|
||||||
|
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;
|
||||||
|
const showOp = mode !== "zero";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Mode toggle */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["identity", "inverse", "zero"] 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-8 bg-unit-8 text-white"
|
||||||
|
: "border-unit-8/40 text-unit-8 hover:bg-unit-8-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === "identity" ? "Identity" : m === "inverse" ? "Inverse" : "Zero Facts"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operation tabs */}
|
||||||
|
{showOp && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["add", "multiply"] as Op[]).map((o) => (
|
||||||
|
<button
|
||||||
|
key={o}
|
||||||
|
onClick={() => {
|
||||||
|
setOp(o);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
|
||||||
|
op === o
|
||||||
|
? "border-unit-8-dark bg-unit-8-dark text-white"
|
||||||
|
: "border-unit-8/30 text-unit-8-dark hover:bg-unit-8-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o === "add" ? "Addition" : "Multiplication"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input card */}
|
||||||
|
<Card>
|
||||||
|
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">
|
||||||
|
{mode === "identity"
|
||||||
|
? "See how the identity leaves a number unchanged"
|
||||||
|
: mode === "inverse"
|
||||||
|
? "Find the inverse of a number"
|
||||||
|
: "Explore what happens with zero"}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
className="w-24 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8"
|
||||||
|
aria-label="Number"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleGo}
|
||||||
|
className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-dark"
|
||||||
|
>
|
||||||
|
Go →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Display card */}
|
||||||
|
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
|
||||||
|
{!step ? (
|
||||||
|
<p className="text-muted/50">
|
||||||
|
Enter a number above and click <strong>Go</strong>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
|
||||||
|
<MathDisplay
|
||||||
|
math={step.math}
|
||||||
|
className={`text-3xl ${step.danger ? "text-incorrect" : ""}`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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]" />}>
|
||||||
|
<QuickPractice />
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { ClientOnly } from "@/components/ui/client-only";
|
||||||
import { StepControls } from "./step-controls";
|
import { StepControls } from "./step-controls";
|
||||||
import { MathDisplay } from "@/components/math/math-display";
|
import { MathDisplay } from "@/components/math/math-display";
|
||||||
|
|
||||||
@@ -282,20 +283,20 @@ function SignRuleCard({ rule, active }: { rule: string; active: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuickPractice() {
|
function generateProblem() {
|
||||||
const [problem, setProblem] = useState(() => generateProblem());
|
|
||||||
const [userAnswer, setUserAnswer] = useState("");
|
|
||||||
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
|
|
||||||
const [score, setScore] = useState({ correct: 0, total: 0 });
|
|
||||||
|
|
||||||
function generateProblem() {
|
|
||||||
const ops = ["+", "-"] as const;
|
const ops = ["+", "-"] as const;
|
||||||
const op = ops[Math.floor(Math.random() * ops.length)];
|
const op = ops[Math.floor(Math.random() * ops.length)];
|
||||||
const a = Math.floor(Math.random() * 21) - 10;
|
const a = Math.floor(Math.random() * 21) - 10;
|
||||||
const b = Math.floor(Math.random() * 21) - 10;
|
const b = Math.floor(Math.random() * 21) - 10;
|
||||||
const answer = op === "+" ? a + b : a - b;
|
const answer = op === "+" ? a + b : a - b;
|
||||||
return { a, b, op, answer };
|
return { a, b, op, answer };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QuickPractice() {
|
||||||
|
const [problem, setProblem] = useState(() => generateProblem());
|
||||||
|
const [userAnswer, setUserAnswer] = useState("");
|
||||||
|
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
|
||||||
|
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||||
|
|
||||||
function checkAnswer() {
|
function checkAnswer() {
|
||||||
const parsed = parseInt(userAnswer);
|
const parsed = parseInt(userAnswer);
|
||||||
@@ -587,7 +588,9 @@ export function IntegerAddSubtractExplorer() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Quick Practice */}
|
{/* Quick Practice */}
|
||||||
|
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
|
||||||
<QuickPractice />
|
<QuickPractice />
|
||||||
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { ClientOnly } from "@/components/ui/client-only";
|
||||||
import { StepControls } from "./step-controls";
|
import { StepControls } from "./step-controls";
|
||||||
import { MathDisplay } from "@/components/math/math-display";
|
import { MathDisplay } from "@/components/math/math-display";
|
||||||
|
|
||||||
@@ -191,8 +192,6 @@ function Thermometer({ data }: { data?: Step["thermometer"] }) {
|
|||||||
const startTemp = data?.startTemp ?? 0;
|
const startTemp = data?.startTemp ?? 0;
|
||||||
const endTemp = data?.endTemp ?? 0;
|
const endTemp = data?.endTemp ?? 0;
|
||||||
|
|
||||||
const startY = ((startTemp - minTemp) / range) * 100;
|
|
||||||
const endY = ((endTemp - minTemp) / range) * 100;
|
|
||||||
const fillY = ((endTemp - minTemp) / range) * 100;
|
const fillY = ((endTemp - minTemp) / range) * 100;
|
||||||
|
|
||||||
const ticks = [];
|
const ticks = [];
|
||||||
@@ -372,13 +371,7 @@ function ThermometerPractice() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MultiplyDividePractice() {
|
function generateProblem() {
|
||||||
const [problem, setProblem] = useState(() => generateProblem());
|
|
||||||
const [userAnswer, setUserAnswer] = useState("");
|
|
||||||
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
|
|
||||||
const [score, setScore] = useState({ correct: 0, total: 0 });
|
|
||||||
|
|
||||||
function generateProblem() {
|
|
||||||
const ops = ["×", "÷"] as const;
|
const ops = ["×", "÷"] as const;
|
||||||
const op = ops[Math.floor(Math.random() * ops.length)];
|
const op = ops[Math.floor(Math.random() * ops.length)];
|
||||||
|
|
||||||
@@ -394,7 +387,13 @@ function MultiplyDividePractice() {
|
|||||||
const a = answer * nonZeroB;
|
const a = answer * nonZeroB;
|
||||||
return { a, b: nonZeroB, op, answer };
|
return { a, b: nonZeroB, op, answer };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MultiplyDividePractice() {
|
||||||
|
const [problem, setProblem] = useState(() => generateProblem());
|
||||||
|
const [userAnswer, setUserAnswer] = useState("");
|
||||||
|
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
|
||||||
|
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||||
|
|
||||||
function check() {
|
function check() {
|
||||||
const parsed = parseInt(userAnswer);
|
const parsed = parseInt(userAnswer);
|
||||||
@@ -702,7 +701,9 @@ export function IntegerMultiplyDivideExplorer() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Practice sections */}
|
{/* Practice sections */}
|
||||||
|
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
|
||||||
{tab === "thermometer" ? <ThermometerPractice /> : <MultiplyDividePractice />}
|
{tab === "thermometer" ? <ThermometerPractice /> : <MultiplyDividePractice />}
|
||||||
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
795
components/explorers/integer-order-compare-explorer.tsx
Normal file
795
components/explorers/integer-order-compare-explorer.tsx
Normal file
@@ -0,0 +1,795 @@
|
|||||||
|
"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 { shuffle } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Mode = "number-line" | "order" | "compare";
|
||||||
|
type Direction = "asc" | "desc";
|
||||||
|
type Tone = "start" | "highlight" | "muted";
|
||||||
|
|
||||||
|
const MIN_VAL = -20;
|
||||||
|
const MAX_VAL = 20;
|
||||||
|
const RANGE = MAX_VAL - MIN_VAL;
|
||||||
|
|
||||||
|
interface Marker {
|
||||||
|
value: number;
|
||||||
|
label?: string;
|
||||||
|
tone?: Tone;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderChips {
|
||||||
|
pool: number[];
|
||||||
|
ordered: number[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
label: string;
|
||||||
|
math: string;
|
||||||
|
markers: Marker[];
|
||||||
|
order?: OrderChips;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real-world contexts for the "Number Line" mode
|
||||||
|
const CONTEXTS = {
|
||||||
|
number: {
|
||||||
|
emoji: "🔢",
|
||||||
|
label: "Number",
|
||||||
|
describe: (n: number) =>
|
||||||
|
n === 0
|
||||||
|
? "0 is neither positive nor negative — it separates the two sides."
|
||||||
|
: `${n} is ${Math.abs(n)} step${Math.abs(n) === 1 ? "" : "s"} to the ${n < 0 ? "LEFT" : "RIGHT"} of zero.`,
|
||||||
|
},
|
||||||
|
thermometer: {
|
||||||
|
emoji: "🌡️",
|
||||||
|
label: "Temperature",
|
||||||
|
describe: (n: number) =>
|
||||||
|
n === 0
|
||||||
|
? "0 °C is freezing point — the boundary between warm and cold."
|
||||||
|
: `${n} °C means ${Math.abs(n)} degree${Math.abs(n) === 1 ? "" : "s"} ${n < 0 ? "BELOW" : "ABOVE"} freezing.`,
|
||||||
|
},
|
||||||
|
money: {
|
||||||
|
emoji: "💰",
|
||||||
|
label: "Money",
|
||||||
|
describe: (n: number) =>
|
||||||
|
n === 0
|
||||||
|
? "$0 means you have nothing and owe nothing."
|
||||||
|
: n < 0
|
||||||
|
? `−$${Math.abs(n)} means you OWE $${Math.abs(n)} (an overdraft).`
|
||||||
|
: `+$${n} means you HAVE $${n} in the bank.`,
|
||||||
|
},
|
||||||
|
sea: {
|
||||||
|
emoji: "🌊",
|
||||||
|
label: "Sea level",
|
||||||
|
describe: (n: number) =>
|
||||||
|
n === 0
|
||||||
|
? "0 m is exactly at sea level."
|
||||||
|
: `${n} m means ${Math.abs(n)} metre${Math.abs(n) === 1 ? "" : "s"} ${n < 0 ? "BELOW" : "ABOVE"} sea level.`,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ContextKey = keyof typeof CONTEXTS;
|
||||||
|
|
||||||
|
function buildNumberLineSteps(n: number, ctx: ContextKey): Step[] {
|
||||||
|
const context = CONTEXTS[ctx];
|
||||||
|
const typeWord = n === 0 ? "zero" : n < 0 ? "a NEGATIVE integer" : "a POSITIVE integer";
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Zero sits in the middle. Negative numbers are to its LEFT, positive numbers to its RIGHT.",
|
||||||
|
math: `\\ldots -3,\\ -2,\\ -1,\\ 0,\\ 1,\\ 2,\\ 3 \\ldots`,
|
||||||
|
markers: [{ value: 0, label: "0", tone: "start" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `Find ${n} on the number line and mark it.`,
|
||||||
|
math: `\\text{Mark } ${n}`,
|
||||||
|
markers: [
|
||||||
|
{ value: 0, tone: "muted" },
|
||||||
|
{ value: n, label: `${n}`, tone: "highlight" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `${context.emoji} ${context.describe(n)}`,
|
||||||
|
math: `${n}`,
|
||||||
|
markers: [{ value: n, label: `${n}`, tone: "start" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `So ${n} is ${typeWord}.`,
|
||||||
|
math: n === 0 ? `0` : `${n} \\text{ is } ${n < 0 ? "<" : ">"} 0`,
|
||||||
|
markers: [{ value: n, label: `${n}`, tone: "start" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrderSteps(values: number[], direction: Direction): Step[] {
|
||||||
|
const sorted = [...values].sort((a, b) => (direction === "asc" ? a - b : b - a));
|
||||||
|
const sizeWord = direction === "asc" ? "smallest" : "largest";
|
||||||
|
const posWord = direction === "asc" ? "furthest LEFT" : "furthest RIGHT";
|
||||||
|
const total = values.length;
|
||||||
|
|
||||||
|
const steps: Step[] = [
|
||||||
|
{
|
||||||
|
label: "Here are the numbers we need to put in order.",
|
||||||
|
math: `\\{\\, ${values.join(",\\ ")} \\,\\}`,
|
||||||
|
markers: values.map((v) => ({ value: v, tone: "muted" as Tone })),
|
||||||
|
order: { pool: [...values], ordered: [], total },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Place every number on the number line. Smaller numbers sit further left.",
|
||||||
|
math: `\\text{left = smaller} \\qquad \\text{right = larger}`,
|
||||||
|
markers: values.map((v) => ({ value: v, tone: "highlight" as Tone })),
|
||||||
|
order: { pool: [...values], ordered: [], total },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const pool = [...values];
|
||||||
|
const ordered: number[] = [];
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
const pick = sorted[i];
|
||||||
|
pool.splice(pool.indexOf(pick), 1);
|
||||||
|
ordered.push(pick);
|
||||||
|
steps.push({
|
||||||
|
label: `The ${sizeWord} number still to place is the one ${posWord}: ${pick}.`,
|
||||||
|
math: `${ordered.join(direction === "asc" ? " < " : " > ")}${pool.length ? "\\ \\ldots" : ""}`,
|
||||||
|
markers: [
|
||||||
|
...ordered.map((v) => ({ value: v, tone: "start" as Tone })),
|
||||||
|
...pool.map((v) => ({ value: v, tone: "muted" as Tone })),
|
||||||
|
],
|
||||||
|
order: { pool: [...pool], ordered: [...ordered], total },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
label:
|
||||||
|
direction === "asc"
|
||||||
|
? "Done! In ascending order: least → greatest."
|
||||||
|
: "Done! In descending order: greatest → least.",
|
||||||
|
math: `${sorted.join(direction === "asc" ? " < " : " > ")}`,
|
||||||
|
markers: sorted.map((v) => ({ value: v, tone: "start" as Tone })),
|
||||||
|
order: { pool: [], ordered: [...sorted], total },
|
||||||
|
});
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCompareSteps(a: number, b: number): Step[] {
|
||||||
|
const symbol = a < b ? "<" : a > b ? ">" : "=";
|
||||||
|
const steps: Step[] = [
|
||||||
|
{
|
||||||
|
label: `Put both numbers on the number line.`,
|
||||||
|
math: `${a} \\;\\square\\; ${b}`,
|
||||||
|
markers: [
|
||||||
|
{ value: a, label: `${a}`, tone: "highlight" },
|
||||||
|
{ value: b, label: `${b}`, tone: "highlight" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (symbol === "=") {
|
||||||
|
steps.push({
|
||||||
|
label: "Both numbers land on the same spot, so they are equal.",
|
||||||
|
math: `${a} = ${b}`,
|
||||||
|
markers: [{ value: a, label: `${a}`, tone: "start" }],
|
||||||
|
});
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const larger = a > b ? a : b;
|
||||||
|
const smaller = a > b ? b : a;
|
||||||
|
steps.push(
|
||||||
|
{
|
||||||
|
label: `The number further RIGHT is always larger. ${larger} is to the right of ${smaller}.`,
|
||||||
|
math: `${larger} \\text{ is larger}`,
|
||||||
|
markers: [
|
||||||
|
{ value: smaller, label: `${smaller}`, tone: "muted" },
|
||||||
|
{ value: larger, label: `${larger}`, tone: "start" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `The open mouth of the sign always points to the LARGER number.`,
|
||||||
|
math: `${a} ${symbol} ${b}`,
|
||||||
|
markers: [
|
||||||
|
{ value: a, label: `${a}`, tone: a === larger ? "start" : "muted" },
|
||||||
|
{ value: b, label: `${b}`, tone: b === larger ? "start" : "muted" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberLine({ markers }: { markers: Marker[] }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-2xl select-none">
|
||||||
|
<div className="relative h-28 px-4">
|
||||||
|
{/* Markers above the line */}
|
||||||
|
{markers.map((m, i) => {
|
||||||
|
const x = ((m.value - MIN_VAL) / RANGE) * 100;
|
||||||
|
const tone = m.tone ?? "highlight";
|
||||||
|
const bg =
|
||||||
|
tone === "start" ? "bg-unit-5" : tone === "muted" ? "bg-muted/70" : "bg-correct";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${m.value}-${i}`}
|
||||||
|
className="absolute bottom-14 -translate-x-1/2 transition-all duration-500"
|
||||||
|
style={{ left: `calc(${x}% + 16px)` }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
{m.label !== undefined && (
|
||||||
|
<span
|
||||||
|
className={`rounded-full ${bg} px-2 py-0.5 text-[10px] font-bold text-white shadow-md`}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className={`h-3 w-0.5 ${bg}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Number line */}
|
||||||
|
<div className="absolute bottom-8 left-4 right-4">
|
||||||
|
<div className="h-0.5 bg-foreground/40" />
|
||||||
|
{Array.from({ length: RANGE + 1 }, (_, i) => {
|
||||||
|
const val = MIN_VAL + i;
|
||||||
|
const x = (i / RANGE) * 100;
|
||||||
|
const isZero = val === 0;
|
||||||
|
const marked = markers.some((m) => m.value === val);
|
||||||
|
return (
|
||||||
|
<div key={val} className="absolute -translate-x-1/2" style={{ left: `${x}%` }}>
|
||||||
|
<div
|
||||||
|
className={`h-3 w-0.5 -translate-y-1.5 transition-colors duration-300 ${
|
||||||
|
marked
|
||||||
|
? "h-4 bg-unit-5"
|
||||||
|
: isZero
|
||||||
|
? "bg-foreground"
|
||||||
|
: "bg-foreground/30"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{(val % 5 === 0 || marked) && (
|
||||||
|
<span
|
||||||
|
className={`mt-1 block text-center text-[10px] transition-all duration-300 ${
|
||||||
|
marked
|
||||||
|
? "font-extrabold text-unit-5"
|
||||||
|
: isZero
|
||||||
|
? "font-bold text-foreground"
|
||||||
|
: "text-muted/70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{val}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edge labels */}
|
||||||
|
<div className="absolute bottom-0 left-2 right-2 flex justify-between text-xs text-muted/50">
|
||||||
|
<span>← negative</span>
|
||||||
|
<span>positive →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrderChipsView({ order, direction }: { order: OrderChips; direction: Direction }) {
|
||||||
|
const emptySlots = order.total - order.ordered.length;
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-bold uppercase tracking-wider text-muted">Still to place</p>
|
||||||
|
<div className="flex min-h-9 flex-wrap gap-2">
|
||||||
|
{order.pool.length === 0 ? (
|
||||||
|
<span className="text-sm text-muted/50">— all placed —</span>
|
||||||
|
) : (
|
||||||
|
order.pool.map((v, i) => (
|
||||||
|
<span
|
||||||
|
key={`${v}-${i}`}
|
||||||
|
className="rounded-lg border-2 border-border bg-surface px-3 py-1.5 text-sm font-bold text-muted"
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-bold uppercase tracking-wider text-unit-5">
|
||||||
|
Ordered ({direction === "asc" ? "small → large" : "large → small"})
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{order.ordered.map((v, i) => (
|
||||||
|
<span key={`${v}-${i}`} className="flex items-center gap-2">
|
||||||
|
{i > 0 && <span className="text-sm font-bold text-unit-5">{direction === "asc" ? "<" : ">"}</span>}
|
||||||
|
<span className="rounded-lg border-2 border-unit-5 bg-unit-5-light px-3 py-1.5 text-sm font-extrabold text-unit-5-dark">
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: emptySlots }, (_, i) => (
|
||||||
|
<span key={`empty-${i}`} className="flex items-center gap-2">
|
||||||
|
{(order.ordered.length > 0 || i > 0) && (
|
||||||
|
<span className="text-sm font-bold text-muted/40">{direction === "asc" ? "<" : ">"}</span>
|
||||||
|
)}
|
||||||
|
<span className="h-9 w-11 rounded-lg border-2 border-dashed border-border" />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quick practice ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SEED_PAIRS: [number, number][] = [
|
||||||
|
[-5, 5],
|
||||||
|
[4, -16],
|
||||||
|
[-6, -11],
|
||||||
|
[0, -3],
|
||||||
|
[8, -1],
|
||||||
|
];
|
||||||
|
|
||||||
|
function randInt(min: number, max: number) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePair(): [number, number] {
|
||||||
|
if (Math.random() < 0.4) return SEED_PAIRS[Math.floor(Math.random() * SEED_PAIRS.length)];
|
||||||
|
const a = randInt(-15, 15);
|
||||||
|
let b = randInt(-15, 15);
|
||||||
|
if (a === b) b = a + 1;
|
||||||
|
return [a, b];
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeOrderValues(): number[] {
|
||||||
|
const set = new Set<number>();
|
||||||
|
while (set.size < 4) set.add(randInt(-12, 12));
|
||||||
|
return shuffle([...set]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComparePractice({ onScore }: { onScore: (correct: boolean) => void }) {
|
||||||
|
const [pair, setPair] = useState<[number, number]>(() => makePair());
|
||||||
|
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
|
||||||
|
|
||||||
|
const [a, b] = pair;
|
||||||
|
const answer = a < b ? "<" : ">";
|
||||||
|
|
||||||
|
function pick(sign: "<" | ">") {
|
||||||
|
if (feedback === "correct") return;
|
||||||
|
const correct = sign === answer;
|
||||||
|
setFeedback(correct ? "correct" : "incorrect");
|
||||||
|
if (correct) onScore(true);
|
||||||
|
else onScore(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
setPair(makePair());
|
||||||
|
setFeedback(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<p className="text-sm text-muted">Which sign makes it true?</p>
|
||||||
|
<div className="flex items-center justify-center gap-4 text-3xl font-extrabold">
|
||||||
|
<span>{a}</span>
|
||||||
|
<span className="inline-flex h-11 w-11 items-center justify-center rounded-xl border-2 border-dashed border-unit-5/50 text-unit-5">
|
||||||
|
?
|
||||||
|
</span>
|
||||||
|
<span>{b}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center gap-3">
|
||||||
|
{(["<", ">"] as const).map((sign) => (
|
||||||
|
<button
|
||||||
|
key={sign}
|
||||||
|
onClick={() => pick(sign)}
|
||||||
|
disabled={feedback === "correct"}
|
||||||
|
className={`h-14 w-14 rounded-xl border-2 text-2xl font-extrabold transition-colors ${
|
||||||
|
feedback === "correct" && sign === answer
|
||||||
|
? "border-correct bg-correct-light text-correct"
|
||||||
|
: feedback === "incorrect" && sign !== answer
|
||||||
|
? "border-border text-muted"
|
||||||
|
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light disabled:opacity-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sign}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{feedback === "correct" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-bold text-correct">Correct! {a} {answer} {b}</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>
|
||||||
|
)}
|
||||||
|
{feedback === "incorrect" && (
|
||||||
|
<p className="text-sm font-bold text-incorrect">Not quite — remember, further right means larger. Try again.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrderPractice({ onScore }: { onScore: (correct: boolean) => void }) {
|
||||||
|
const [values, setValues] = useState<number[]>(() => makeOrderValues());
|
||||||
|
const [placed, setPlaced] = useState<number[]>([]);
|
||||||
|
const [wrong, setWrong] = useState<number | null>(null);
|
||||||
|
const [mistakes, setMistakes] = useState(0);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const nextExpected = sorted[placed.length];
|
||||||
|
|
||||||
|
function clickChip(v: number) {
|
||||||
|
if (done || placed.includes(v)) return;
|
||||||
|
if (v === nextExpected) {
|
||||||
|
const nextPlaced = [...placed, v];
|
||||||
|
setPlaced(nextPlaced);
|
||||||
|
setWrong(null);
|
||||||
|
if (nextPlaced.length === sorted.length) {
|
||||||
|
setDone(true);
|
||||||
|
onScore(mistakes === 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setWrong(v);
|
||||||
|
setMistakes((m) => m + 1);
|
||||||
|
setTimeout(() => setWrong((w) => (w === v ? null : w)), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
setValues(makeOrderValues());
|
||||||
|
setPlaced([]);
|
||||||
|
setWrong(null);
|
||||||
|
setMistakes(0);
|
||||||
|
setDone(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<p className="text-sm text-muted">Click the numbers in ascending order (smallest first).</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
{values.map((v) => {
|
||||||
|
const isPlaced = placed.includes(v);
|
||||||
|
const isWrong = wrong === v;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
onClick={() => clickChip(v)}
|
||||||
|
disabled={isPlaced || done}
|
||||||
|
className={`h-12 w-14 rounded-xl border-2 text-lg font-extrabold transition-all ${
|
||||||
|
isPlaced
|
||||||
|
? "border-correct bg-correct-light text-correct"
|
||||||
|
: isWrong
|
||||||
|
? "border-incorrect bg-incorrect-light text-incorrect"
|
||||||
|
: "border-unit-5/40 text-unit-5-dark hover:bg-unit-5-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-2 text-lg font-bold text-unit-5">
|
||||||
|
{placed.map((v, i) => (
|
||||||
|
<span key={v}>
|
||||||
|
{i > 0 && <span className="mr-2 text-muted">{"<"}</span>}
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{placed.length < sorted.length && <span className="text-muted/40">…</span>}
|
||||||
|
</div>
|
||||||
|
{done && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-bold text-correct">
|
||||||
|
{mistakes === 0 ? "Perfect ordering!" : `Ordered — with ${mistakes} slip${mistakes === 1 ? "" : "s"}.`}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickPractice() {
|
||||||
|
const [kind, setKind] = useState<"compare" | "order">("compare");
|
||||||
|
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||||
|
|
||||||
|
const record = useCallback((correct: boolean) => {
|
||||||
|
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{(["compare", "order"] as const).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
onClick={() => setKind(k)}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-bold transition-colors ${
|
||||||
|
kind === k ? "bg-unit-5 text-white" : "text-unit-5 hover:bg-unit-5-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{k === "compare" ? "Compare" : "Order"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-unit-5-light px-3 py-1 text-xs font-bold text-unit-5-dark">
|
||||||
|
{score.correct}/{score.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{kind === "compare" ? (
|
||||||
|
<ComparePractice key={`c-${score.total}`} onScore={record} />
|
||||||
|
) : (
|
||||||
|
<OrderPractice key={`o-${score.total}`} onScore={record} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main explorer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function IntegerOrderCompareExplorer() {
|
||||||
|
const [mode, setMode] = useState<Mode>("number-line");
|
||||||
|
|
||||||
|
// number-line mode
|
||||||
|
const [nlInput, setNlInput] = useState("-7");
|
||||||
|
const [context, setContext] = useState<ContextKey>("thermometer");
|
||||||
|
|
||||||
|
// order mode
|
||||||
|
const [orderInput, setOrderInput] = useState("-8, -10, -4, -3, -6, 5");
|
||||||
|
const [direction, setDirection] = useState<Direction>("asc");
|
||||||
|
|
||||||
|
// compare mode
|
||||||
|
const [cmpA, setCmpA] = useState("-5");
|
||||||
|
const [cmpB, setCmpB] = useState("5");
|
||||||
|
|
||||||
|
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 handleGo() {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
if (mode === "number-line") {
|
||||||
|
const n = parseInt(nlInput, 10);
|
||||||
|
if (isNaN(n)) return setError("Enter a valid integer.");
|
||||||
|
if (n < MIN_VAL || n > MAX_VAL) return setError(`Keep the number between ${MIN_VAL} and ${MAX_VAL}.`);
|
||||||
|
setSteps(buildNumberLineSteps(n, context));
|
||||||
|
} else if (mode === "order") {
|
||||||
|
const parts = orderInput
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
const nums = parts.map((s) => parseInt(s, 10));
|
||||||
|
if (nums.some((n) => isNaN(n))) return setError("Use whole numbers separated by commas, e.g. -8, -10, 5.");
|
||||||
|
if (nums.length < 3 || nums.length > 6) return setError("Enter between 3 and 6 numbers.");
|
||||||
|
if (nums.some((n) => n < MIN_VAL || n > MAX_VAL)) return setError(`Keep every number between ${MIN_VAL} and ${MAX_VAL}.`);
|
||||||
|
setSteps(buildOrderSteps(nums, direction));
|
||||||
|
} else {
|
||||||
|
const a = parseInt(cmpA, 10);
|
||||||
|
const b = parseInt(cmpB, 10);
|
||||||
|
if (isNaN(a) || isNaN(b)) return setError("Enter two valid integers.");
|
||||||
|
if ([a, b].some((n) => n < MIN_VAL || n > MAX_VAL)) return setError(`Keep both numbers between ${MIN_VAL} and ${MAX_VAL}.`);
|
||||||
|
setSteps(buildCompareSteps(a, b));
|
||||||
|
}
|
||||||
|
setCurrentStep(0);
|
||||||
|
setIsPlaying(false);
|
||||||
|
} catch {
|
||||||
|
setError("Could not build the steps. 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 step = steps ? steps[currentStep] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Mode toggle */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["number-line", "order", "compare"] 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-5 bg-unit-5 text-white"
|
||||||
|
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === "number-line" ? "Number Line" : m === "order" ? "Order" : "Compare"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input card */}
|
||||||
|
<Card>
|
||||||
|
{mode === "number-line" && (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
|
||||||
|
Place an integer on the number line
|
||||||
|
</p>
|
||||||
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
|
{(Object.keys(CONTEXTS) as ContextKey[]).map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => {
|
||||||
|
setContext(key);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
className={`rounded-full border-2 px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||||
|
context === key
|
||||||
|
? "border-unit-5-dark bg-unit-5-dark text-white"
|
||||||
|
: "border-unit-5/30 text-unit-5-dark hover:bg-unit-5-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{CONTEXTS[key].emoji} {CONTEXTS[key].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={nlInput}
|
||||||
|
onChange={(e) => setNlInput(e.target.value)}
|
||||||
|
className="w-24 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
|
||||||
|
aria-label="Integer"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleGo}
|
||||||
|
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
|
||||||
|
>
|
||||||
|
Go →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "order" && (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
|
||||||
|
Enter 3–6 integers to order
|
||||||
|
</p>
|
||||||
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
|
{(["asc", "desc"] as Direction[]).map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
onClick={() => {
|
||||||
|
setDirection(d);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
className={`rounded-full border-2 px-4 py-1.5 text-xs font-semibold transition-colors ${
|
||||||
|
direction === d
|
||||||
|
? "border-unit-5-dark bg-unit-5-dark text-white"
|
||||||
|
: "border-unit-5/30 text-unit-5-dark hover:bg-unit-5-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d === "asc" ? "Ascending (small → large)" : "Descending (large → small)"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={orderInput}
|
||||||
|
onChange={(e) => setOrderInput(e.target.value)}
|
||||||
|
className="min-w-[16rem] flex-1 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
|
||||||
|
aria-label="Comma-separated integers"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleGo}
|
||||||
|
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
|
||||||
|
>
|
||||||
|
Go →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "compare" && (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
|
||||||
|
Compare two integers with < or >
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={cmpA}
|
||||||
|
onChange={(e) => setCmpA(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-5"
|
||||||
|
aria-label="First integer"
|
||||||
|
/>
|
||||||
|
<span className="text-xl font-bold text-muted">?</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={cmpB}
|
||||||
|
onChange={(e) => setCmpB(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-5"
|
||||||
|
aria-label="Second integer"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleGo}
|
||||||
|
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
|
||||||
|
>
|
||||||
|
Go →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Display card */}
|
||||||
|
<Card className="flex min-h-[280px] flex-col items-center justify-center gap-4 p-6">
|
||||||
|
{!step ? (
|
||||||
|
<p className="text-muted/50">
|
||||||
|
Set up the numbers above and click <strong>Go</strong>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
|
||||||
|
<MathDisplay math={step.math} className="text-2xl" />
|
||||||
|
<NumberLine markers={step.markers} />
|
||||||
|
{step.order && <OrderChipsView order={step.order} direction={direction} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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]" />}>
|
||||||
|
<QuickPractice />
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
393
components/explorers/negative-fractions-explorer.tsx
Normal file
393
components/explorers/negative-fractions-explorer.tsx
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
"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 { FractionInput } from "@/components/practice/fraction-input";
|
||||||
|
import { Fraction, lcm } from "@/lib/math/fractions";
|
||||||
|
import { checkFractionAnswer } from "@/lib/math/validation";
|
||||||
|
|
||||||
|
type Op = "+" | "-" | "\\times" | "\\div";
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
label: string;
|
||||||
|
math: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OP_LABEL: Record<Op, string> = { "+": "+", "-": "−", "\\times": "×", "\\div": "÷" };
|
||||||
|
|
||||||
|
/** Wrap a negative operand in parentheses so "a + (-b)" reads clearly. */
|
||||||
|
function operand(f: Fraction): string {
|
||||||
|
return f.n < 0 ? `\\left(${f.toSignedKatex()}\\right)` : f.toSignedKatex();
|
||||||
|
}
|
||||||
|
|
||||||
|
function signWord(f: Fraction): string {
|
||||||
|
return f.n < 0 ? "negative" : "positive";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAddSubtractSteps(a: Fraction, b: Fraction, op: "+" | "-"): Step[] {
|
||||||
|
const opSym = op;
|
||||||
|
const steps: Step[] = [
|
||||||
|
{
|
||||||
|
label: "Start with the expression.",
|
||||||
|
math: `${a.toSignedKatex()} ${opSym} ${operand(b)}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const lcd = lcm(a.d, b.d);
|
||||||
|
let an = a.n;
|
||||||
|
let bn = b.n;
|
||||||
|
if (a.d !== b.d) {
|
||||||
|
an = a.n * (lcd / a.d);
|
||||||
|
bn = b.n * (lcd / b.d);
|
||||||
|
steps.push({
|
||||||
|
label: `The denominators are different. Find the common denominator: the LCM of ${a.d} and ${b.d} is ${lcd}.`,
|
||||||
|
math: `\\text{LCD} = ${lcd}`,
|
||||||
|
});
|
||||||
|
steps.push({
|
||||||
|
label: "Rewrite both fractions over the common denominator (the sign travels with the numerator).",
|
||||||
|
math: `${a.toSignedKatex()} = ${new Fraction(an, lcd).toSignedKatex()} \\quad ${b.toSignedKatex()} = ${new Fraction(bn, lcd).toSignedKatex()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawNum = op === "+" ? an + bn : an - bn;
|
||||||
|
const bnShown = bn < 0 ? `\\left(${bn}\\right)` : `${bn}`;
|
||||||
|
steps.push({
|
||||||
|
label:
|
||||||
|
op === "+"
|
||||||
|
? "Add the numerators using the integer rules, keeping the common denominator."
|
||||||
|
: "Subtract the numerators using the integer rules (subtracting a negative adds), keeping the common denominator.",
|
||||||
|
math: `\\frac{${an} ${opSym} ${bnShown}}{${lcd}} = \\frac{${rawNum}}{${lcd}}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = new Fraction(rawNum, lcd);
|
||||||
|
const result = raw.simplified();
|
||||||
|
if (result.n !== raw.n || result.d !== raw.d) {
|
||||||
|
steps.push({
|
||||||
|
label: "Simplify to lowest terms.",
|
||||||
|
math: `${raw.toSignedKatex()} = ${result.toSignedKatex()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
label: `The answer is ${result.n < 0 ? "negative" : "positive"}.`,
|
||||||
|
math: `${a.toSignedKatex()} ${opSym} ${operand(b)} = ${result.toSignedKatex()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMultiplyDivideSteps(a: Fraction, b: Fraction, op: "\\times" | "\\div"): Step[] {
|
||||||
|
const steps: Step[] = [
|
||||||
|
{
|
||||||
|
label: "Start with the expression.",
|
||||||
|
math: `${a.toSignedKatex()} ${op} ${operand(b)}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let working = b;
|
||||||
|
if (op === "\\div") {
|
||||||
|
working = new Fraction(b.d, b.n); // reciprocal keeps the sign
|
||||||
|
steps.push({
|
||||||
|
label: "Dividing by a fraction is the same as multiplying by its reciprocal — keep, change, flip.",
|
||||||
|
math: `${a.toSignedKatex()} \\times ${operand(working)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const negatives = (a.n < 0 ? 1 : 0) + (working.n < 0 ? 1 : 0);
|
||||||
|
const resultNegative = negatives % 2 === 1;
|
||||||
|
steps.push({
|
||||||
|
label: `Decide the sign: ${signWord(a)} and ${signWord(working)}. ${
|
||||||
|
negatives === 1 ? "Different signs give a NEGATIVE answer." : "The signs match, so the answer is POSITIVE."
|
||||||
|
}`,
|
||||||
|
math: `(${a.n < 0 ? "-" : "+"}) \\times (${working.n < 0 ? "-" : "+"}) = (${resultNegative ? "-" : "+"})`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const absNum = Math.abs(a.n) * Math.abs(working.n);
|
||||||
|
const den = a.d * working.d;
|
||||||
|
steps.push({
|
||||||
|
label: "Multiply the numerators, multiply the denominators, then apply the sign.",
|
||||||
|
math: `\\frac{${Math.abs(a.n)} \\times ${Math.abs(working.n)}}{${a.d} \\times ${working.d}} = ${new Fraction(resultNegative ? -absNum : absNum, den).toSignedKatex()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = new Fraction(resultNegative ? -absNum : absNum, den);
|
||||||
|
const result = raw.simplified();
|
||||||
|
if (result.n !== raw.n || result.d !== raw.d) {
|
||||||
|
steps.push({
|
||||||
|
label: "Simplify to lowest terms.",
|
||||||
|
math: `${raw.toSignedKatex()} = ${result.toSignedKatex()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
label: "Final answer.",
|
||||||
|
math: `${a.toSignedKatex()} ${op} ${operand(b)} = ${result.toSignedKatex()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quick practice ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function randInt(min: number, max: number) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
/** A proper-fraction numerator with a random sign: magnitude 1..den-1. */
|
||||||
|
function properSigned(den: number): number {
|
||||||
|
const mag = randInt(1, Math.max(1, den - 1));
|
||||||
|
return Math.random() < 0.5 ? -mag : mag;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PracticeProblem {
|
||||||
|
a: Fraction;
|
||||||
|
b: Fraction;
|
||||||
|
op: Op;
|
||||||
|
answer: Fraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProblem(): PracticeProblem {
|
||||||
|
const op = (["+", "-", "\\times", "\\div"] as Op[])[randInt(0, 3)];
|
||||||
|
const d1 = randInt(2, 6);
|
||||||
|
const d2 = randInt(2, 6);
|
||||||
|
let n1 = properSigned(d1);
|
||||||
|
let n2 = properSigned(d2);
|
||||||
|
// This is the negative-fractions topic — make sure at least one operand is negative.
|
||||||
|
if (n1 > 0 && n2 > 0) {
|
||||||
|
if (Math.random() < 0.5) n1 = -n1;
|
||||||
|
else n2 = -n2;
|
||||||
|
}
|
||||||
|
const a = new Fraction(n1, d1);
|
||||||
|
const b = new Fraction(n2, d2);
|
||||||
|
let answer: Fraction;
|
||||||
|
if (op === "+") answer = a.plus(b).simplified();
|
||||||
|
else if (op === "-") answer = a.minus(b).simplified();
|
||||||
|
else if (op === "\\times") answer = a.times(b).simplified();
|
||||||
|
else answer = a.dividedBy(b).simplified();
|
||||||
|
return { a, b, op, answer };
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickPractice() {
|
||||||
|
const [problem, setProblem] = useState<PracticeProblem>(() => makeProblem());
|
||||||
|
const [num, setNum] = useState("");
|
||||||
|
const [den, setDen] = useState("");
|
||||||
|
const [status, setStatus] = useState<"solved" | "unsimplified" | "wrong" | null>(null);
|
||||||
|
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||||
|
const [missed, setMissed] = useState(false);
|
||||||
|
const [scored, setScored] = useState(false);
|
||||||
|
|
||||||
|
function check() {
|
||||||
|
const n = parseInt(num, 10);
|
||||||
|
const d = parseInt(den, 10);
|
||||||
|
if (isNaN(n) || isNaN(d)) return;
|
||||||
|
const res = checkFractionAnswer(n, d, problem.answer.n, problem.answer.d);
|
||||||
|
setStatus(res.status);
|
||||||
|
if (res.status === "wrong") {
|
||||||
|
setMissed(true);
|
||||||
|
} else if (res.status === "solved" && !scored) {
|
||||||
|
setScored(true);
|
||||||
|
setScore((s) => ({ correct: s.correct + (missed ? 0 : 1), total: s.total + 1 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
setProblem(makeProblem());
|
||||||
|
setNum("");
|
||||||
|
setDen("");
|
||||||
|
setStatus(null);
|
||||||
|
setMissed(false);
|
||||||
|
setScored(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-unit-5">Quick Practice</p>
|
||||||
|
<span className="rounded-full bg-unit-5-light px-3 py-1 text-xs font-bold text-unit-5-dark">
|
||||||
|
{score.correct}/{score.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-sm text-muted">Work it out and give your answer in its simplest form.</p>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<MathDisplay
|
||||||
|
math={`${problem.a.toSignedKatex()} ${problem.op} ${problem.b.n < 0 ? `\\left(${problem.b.toSignedKatex()}\\right)` : problem.b.toSignedKatex()} =`}
|
||||||
|
className="text-2xl"
|
||||||
|
/>
|
||||||
|
<FractionInput
|
||||||
|
numerator={num}
|
||||||
|
denominator={den}
|
||||||
|
onNumeratorChange={setNum}
|
||||||
|
onDenominatorChange={setDen}
|
||||||
|
onSubmit={status === "solved" ? next : check}
|
||||||
|
disabled={status === "solved"}
|
||||||
|
/>
|
||||||
|
{status === "solved" ? (
|
||||||
|
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={check} className="rounded-lg bg-unit-5 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-5-dark">
|
||||||
|
Check
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{status === "solved" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
|
||||||
|
{status === "unsimplified" && (
|
||||||
|
<p className="text-center text-sm font-bold text-hint">Right value — now write it in its simplest form.</p>
|
||||||
|
)}
|
||||||
|
{status === "wrong" && (
|
||||||
|
<p className="text-center text-sm font-bold text-incorrect">Not quite. Watch the signs, then try again.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main explorer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function NegativeFractionsExplorer() {
|
||||||
|
const [op, setOp] = useState<Op>("+");
|
||||||
|
const [n1, setN1] = useState("-1");
|
||||||
|
const [d1, setD1] = useState("2");
|
||||||
|
const [n2, setN2] = useState("3");
|
||||||
|
const [d2, setD2] = useState("4");
|
||||||
|
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 handleGo() {
|
||||||
|
setError("");
|
||||||
|
const pn1 = parseInt(n1, 10);
|
||||||
|
const pd1 = parseInt(d1, 10);
|
||||||
|
const pn2 = parseInt(n2, 10);
|
||||||
|
const pd2 = parseInt(d2, 10);
|
||||||
|
if ([pn1, pd1, pn2, pd2].some((v) => isNaN(v))) return setError("Fill in every box with a whole number.");
|
||||||
|
if (pd1 === 0 || pd2 === 0) return setError("A denominator cannot be zero.");
|
||||||
|
if ([pn1, pd1, pn2, pd2].some((v) => Math.abs(v) > 20)) return setError("Keep the numbers between -20 and 20.");
|
||||||
|
if (op === "\\div" && pn2 === 0) return setError("You cannot divide by zero.");
|
||||||
|
|
||||||
|
const a = new Fraction(pn1, pd1);
|
||||||
|
const b = new Fraction(pn2, pd2);
|
||||||
|
setSteps(
|
||||||
|
op === "+" || op === "-"
|
||||||
|
? buildAddSubtractSteps(a, b, op)
|
||||||
|
: buildMultiplyDivideSteps(a, b, op),
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
|
||||||
|
const fieldClass =
|
||||||
|
"w-16 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-5";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Operation selector */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["+", "-", "\\times", "\\div"] as Op[]).map((o) => (
|
||||||
|
<button
|
||||||
|
key={o}
|
||||||
|
onClick={() => {
|
||||||
|
setOp(o);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
className={`h-11 w-14 rounded-full border-2 text-xl font-bold transition-colors ${
|
||||||
|
op === o
|
||||||
|
? "border-unit-5 bg-unit-5 text-white"
|
||||||
|
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light"
|
||||||
|
}`}
|
||||||
|
aria-label={OP_LABEL[o]}
|
||||||
|
>
|
||||||
|
{OP_LABEL[o]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input card */}
|
||||||
|
<Card>
|
||||||
|
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
|
||||||
|
Enter two fractions (numerators can be negative)
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="inline-flex flex-col items-center gap-0.5">
|
||||||
|
<input type="number" value={n1} onChange={(e) => setN1(e.target.value)} className={fieldClass} aria-label="First numerator" />
|
||||||
|
<div className="h-0.5 w-16 bg-foreground" />
|
||||||
|
<input type="number" value={d1} onChange={(e) => setD1(e.target.value)} className={fieldClass} aria-label="First denominator" />
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-muted">{OP_LABEL[op]}</span>
|
||||||
|
<div className="inline-flex flex-col items-center gap-0.5">
|
||||||
|
<input type="number" value={n2} onChange={(e) => setN2(e.target.value)} className={fieldClass} aria-label="Second numerator" />
|
||||||
|
<div className="h-0.5 w-16 bg-foreground" />
|
||||||
|
<input type="number" value={d2} onChange={(e) => setD2(e.target.value)} className={fieldClass} aria-label="Second denominator" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleGo}
|
||||||
|
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
|
||||||
|
>
|
||||||
|
Go →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Display card */}
|
||||||
|
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
|
||||||
|
{!step ? (
|
||||||
|
<p className="text-muted/50">
|
||||||
|
Enter two fractions above and click <strong>Go</strong>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
|
||||||
|
<MathDisplay math={step.math} className="text-2xl" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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]" />}>
|
||||||
|
<QuickPractice />
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
356
components/explorers/number-laws-explorer.tsx
Normal file
356
components/explorers/number-laws-explorer.tsx
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
type Mode = "commutative" | "associative" | "distributive";
|
||||||
|
type Op = "+" | "-" | "\\times" | "\\div";
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
label: string;
|
||||||
|
math: string;
|
||||||
|
match?: boolean; // colour the result green (holds) / red (fails)
|
||||||
|
}
|
||||||
|
|
||||||
|
const OP_META: Record<Op, { apply: (a: number, b: number) => number; commutative: boolean; associative: boolean; name: string }> = {
|
||||||
|
"+": { apply: (a, b) => a + b, commutative: true, associative: true, name: "Addition" },
|
||||||
|
"-": { apply: (a, b) => a - b, commutative: false, associative: false, name: "Subtraction" },
|
||||||
|
"\\times": { apply: (a, b) => a * b, commutative: true, associative: true, name: "Multiplication" },
|
||||||
|
"\\div": { apply: (a, b) => a / b, commutative: false, associative: false, name: "Division" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmt(x: number): string {
|
||||||
|
if (Number.isInteger(x)) return `${x}`;
|
||||||
|
return `${parseFloat(x.toFixed(3))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCommutativeSteps(a: number, b: number, op: Op): Step[] {
|
||||||
|
const meta = OP_META[op];
|
||||||
|
const r1 = meta.apply(a, b);
|
||||||
|
const r2 = meta.apply(b, a);
|
||||||
|
const same = r1 === r2;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Commutative law: does the ORDER of the numbers change the answer?",
|
||||||
|
math: `${a} ${op} ${b} \\quad \\text{vs} \\quad ${b} ${op} ${a}`,
|
||||||
|
},
|
||||||
|
{ label: `Work out the first order.`, math: `${a} ${op} ${b} = ${fmt(r1)}` },
|
||||||
|
{ label: `Now swap the order.`, math: `${b} ${op} ${a} = ${fmt(r2)}` },
|
||||||
|
meta.commutative
|
||||||
|
? {
|
||||||
|
label: `Same answer! ${meta.name} IS commutative — order does not matter.`,
|
||||||
|
math: `a ${op} b = b ${op} a`,
|
||||||
|
match: true,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: same
|
||||||
|
? `They match here only because the numbers are special. In general, ${meta.name.toLowerCase()} is NOT commutative.`
|
||||||
|
: `Different answers! ${meta.name} is NOT commutative — order matters.`,
|
||||||
|
math: `a ${op} b \\neq b ${op} a`,
|
||||||
|
match: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAssociativeSteps(a: number, b: number, c: number, op: Op): Step[] {
|
||||||
|
const meta = OP_META[op];
|
||||||
|
const left = meta.apply(meta.apply(a, b), c);
|
||||||
|
const right = meta.apply(a, meta.apply(b, c));
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Associative law: does where we put the BRACKETS change the answer?",
|
||||||
|
math: `(${a} ${op} ${b}) ${op} ${c} \\quad \\text{vs} \\quad ${a} ${op} (${b} ${op} ${c})`,
|
||||||
|
},
|
||||||
|
{ label: "Do the brackets on the LEFT first.", math: `(${a} ${op} ${b}) ${op} ${c} = ${fmt(meta.apply(a, b))} ${op} ${c} = ${fmt(left)}` },
|
||||||
|
{ label: "Now do the brackets on the RIGHT first.", math: `${a} ${op} (${b} ${op} ${c}) = ${a} ${op} ${fmt(meta.apply(b, c))} = ${fmt(right)}` },
|
||||||
|
meta.associative
|
||||||
|
? { label: `Same answer! ${meta.name} IS associative — the grouping does not matter.`, math: `(a ${op} b) ${op} c = a ${op} (b ${op} c)`, match: true }
|
||||||
|
: { label: `Different answers! ${meta.name} is NOT associative — the grouping matters.`, math: `(a ${op} b) ${op} c \\neq a ${op} (b ${op} c)`, match: false },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDistributiveSteps(a: number, b: number, c: number): Step[] {
|
||||||
|
const inside = b + c;
|
||||||
|
const answer = a * inside;
|
||||||
|
return [
|
||||||
|
{ label: "Distributive law: a number outside a bracket multiplies EVERYTHING inside.", math: `${a} \\times (${b} + ${c})` },
|
||||||
|
{
|
||||||
|
label: `Multiply the ${a} by each number inside the bracket separately.`,
|
||||||
|
math: `${a} \\times ${b} \\; + \\; ${a} \\times ${c}`,
|
||||||
|
},
|
||||||
|
{ label: "Working the direct way: add inside the bracket first, then multiply.", math: `${a} \\times (${b} + ${c}) = ${a} \\times ${inside} = ${answer}` },
|
||||||
|
{ label: "Working the expanded way gives the same answer.", math: `${a * b} + ${a * c} = ${answer}`, match: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quick practice ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PracticeQ {
|
||||||
|
prompt: string;
|
||||||
|
answer: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randInt(min: number, max: number) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeQuestion(): PracticeQ {
|
||||||
|
const roll = Math.floor(Math.random() * 4);
|
||||||
|
if (roll === 0) {
|
||||||
|
const a = randInt(2, 9), b = randInt(1, 9), c = randInt(1, 9);
|
||||||
|
return { prompt: `Fill the blank: ${a} × (${b} + ${c}) = ${a} × ${b} + ${a} × ▢`, answer: c };
|
||||||
|
}
|
||||||
|
if (roll === 1) {
|
||||||
|
const a = randInt(3, 12), b = randInt(1, a - 1);
|
||||||
|
return { prompt: `Subtraction is not commutative. What is ${a} − ${b}?`, answer: a - b };
|
||||||
|
}
|
||||||
|
if (roll === 2) {
|
||||||
|
const a = randInt(2, 8), b = randInt(1, 6), c = randInt(1, 6);
|
||||||
|
return { prompt: `Work out ${a} × (${b} + ${c})`, answer: a * (b + c) };
|
||||||
|
}
|
||||||
|
const a = randInt(2, 9), b = randInt(2, 9);
|
||||||
|
return { prompt: `Multiplication is commutative, so ${a} × ${b} equals ${b} × ▢. What is ▢?`, answer: a };
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickPractice() {
|
||||||
|
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
|
||||||
|
const [val, setVal] = useState("");
|
||||||
|
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
|
||||||
|
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||||
|
|
||||||
|
function check() {
|
||||||
|
const parsed = parseInt(val, 10);
|
||||||
|
if (isNaN(parsed)) return;
|
||||||
|
const correct = parsed === q.answer;
|
||||||
|
setFeedback(correct ? "correct" : "incorrect");
|
||||||
|
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
setQ(makeQuestion());
|
||||||
|
setVal("");
|
||||||
|
setFeedback(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-unit-8">Quick Practice</p>
|
||||||
|
<span className="rounded-full bg-unit-8-light px-3 py-1 text-xs font-bold text-unit-8-dark">
|
||||||
|
{score.correct}/{score.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-lg font-bold">{q.prompt}</p>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={val}
|
||||||
|
onChange={(e) => {
|
||||||
|
setVal(e.target.value);
|
||||||
|
setFeedback(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (feedback !== null) next();
|
||||||
|
else check();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`w-24 rounded-lg border-2 px-3 py-2 text-center text-xl font-bold outline-none transition-colors ${
|
||||||
|
feedback === "correct"
|
||||||
|
? "border-correct bg-correct-light"
|
||||||
|
: feedback === "incorrect"
|
||||||
|
? "border-incorrect bg-incorrect-light"
|
||||||
|
: "border-border focus:border-unit-8"
|
||||||
|
}`}
|
||||||
|
placeholder="?"
|
||||||
|
aria-label="Your answer"
|
||||||
|
/>
|
||||||
|
{feedback === null ? (
|
||||||
|
<button onClick={check} className="rounded-lg bg-unit-8 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-8-dark">
|
||||||
|
Check
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
|
||||||
|
{feedback === "incorrect" && (
|
||||||
|
<p className="text-center text-sm font-bold text-incorrect">Not quite — the answer is {q.answer}.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main explorer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function NumberLawsExplorer() {
|
||||||
|
const [mode, setMode] = useState<Mode>("commutative");
|
||||||
|
const [op, setOp] = useState<Op>("+");
|
||||||
|
const [a, setA] = useState("8");
|
||||||
|
const [b, setB] = useState("3");
|
||||||
|
const [c, setC] = useState("2");
|
||||||
|
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("");
|
||||||
|
// distributive is fixed to + inside; keep whatever op for the others
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
const opChoices: Op[] = mode === "associative" ? ["+", "\\times", "-"] : ["+", "-", "\\times", "\\div"];
|
||||||
|
|
||||||
|
function handleGo() {
|
||||||
|
setError("");
|
||||||
|
const na = parseInt(a, 10);
|
||||||
|
const nb = parseInt(b, 10);
|
||||||
|
const nc = parseInt(c, 10);
|
||||||
|
if (isNaN(na) || isNaN(nb) || (mode !== "commutative" && isNaN(nc))) {
|
||||||
|
return setError("Enter whole numbers in every box.");
|
||||||
|
}
|
||||||
|
const vals = mode === "commutative" ? [na, nb] : [na, nb, nc];
|
||||||
|
if (vals.some((v) => Math.abs(v) > 50)) return setError("Keep the numbers between -50 and 50.");
|
||||||
|
if ((op === "\\div") && (nb === 0 || (mode === "associative" && nc === 0))) {
|
||||||
|
return setError("Cannot divide by zero — change the numbers.");
|
||||||
|
}
|
||||||
|
if (mode === "commutative") setSteps(buildCommutativeSteps(na, nb, op));
|
||||||
|
else if (mode === "associative") setSteps(buildAssociativeSteps(na, nb, nc, op));
|
||||||
|
else setSteps(buildDistributiveSteps(na, nb, nc));
|
||||||
|
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;
|
||||||
|
const showThree = mode !== "commutative";
|
||||||
|
const showOp = mode !== "distributive";
|
||||||
|
|
||||||
|
const opLabel: Record<Op, string> = { "+": "+", "-": "−", "\\times": "×", "\\div": "÷" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Mode toggle */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["commutative", "associative", "distributive"] as Mode[]).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => switchMode(m)}
|
||||||
|
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold capitalize transition-colors ${
|
||||||
|
mode === m
|
||||||
|
? "border-unit-8 bg-unit-8 text-white"
|
||||||
|
: "border-unit-8/40 text-unit-8 hover:bg-unit-8-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operation tabs */}
|
||||||
|
{showOp && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{opChoices.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o}
|
||||||
|
onClick={() => {
|
||||||
|
setOp(o);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
className={`h-10 w-12 rounded-full border-2 text-lg font-bold transition-colors ${
|
||||||
|
op === o
|
||||||
|
? "border-unit-8-dark bg-unit-8-dark text-white"
|
||||||
|
: "border-unit-8/30 text-unit-8-dark hover:bg-unit-8-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opLabel[o]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input card */}
|
||||||
|
<Card>
|
||||||
|
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">
|
||||||
|
{mode === "distributive" ? "Enter a, b and c for a × (b + c)" : showThree ? "Enter three numbers" : "Enter two numbers"}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<input type="number" value={a} onChange={(e) => setA(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-8" aria-label="a" />
|
||||||
|
<input type="number" value={b} onChange={(e) => setB(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-8" aria-label="b" />
|
||||||
|
{showThree && (
|
||||||
|
<input type="number" value={c} onChange={(e) => setC(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-8" aria-label="c" />
|
||||||
|
)}
|
||||||
|
<button onClick={handleGo} className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-dark">
|
||||||
|
Go →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Display card */}
|
||||||
|
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
|
||||||
|
{!step ? (
|
||||||
|
<p className="text-muted/50">
|
||||||
|
Enter numbers above and click <strong>Go</strong>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
|
||||||
|
<MathDisplay
|
||||||
|
math={step.math}
|
||||||
|
className={`text-2xl ${step.match === true ? "text-correct" : step.match === false ? "text-incorrect" : ""}`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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]" />}>
|
||||||
|
<QuickPractice />
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
351
components/explorers/place-value-explorer.tsx
Normal file
351
components/explorers/place-value-explorer.tsx
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
interface Column {
|
||||||
|
exponent: number;
|
||||||
|
placeValue: number;
|
||||||
|
digit: string;
|
||||||
|
contrib: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
label: string;
|
||||||
|
math: string;
|
||||||
|
activeCol: number | null;
|
||||||
|
revealed: number; // how many columns show their contribution
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLACE_NAMES = ["ones", "tens", "hundreds", "thousands", "ten thousands", "hundred thousands"];
|
||||||
|
|
||||||
|
function columnsFor(n: number): Column[] {
|
||||||
|
const str = `${n}`;
|
||||||
|
const len = str.length;
|
||||||
|
return str.split("").map((d, i) => {
|
||||||
|
const exponent = len - 1 - i;
|
||||||
|
return {
|
||||||
|
exponent,
|
||||||
|
placeValue: Math.pow(10, exponent),
|
||||||
|
digit: d,
|
||||||
|
contrib: parseInt(d, 10) * Math.pow(10, exponent),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSteps(n: number): Step[] {
|
||||||
|
const cols = columnsFor(n);
|
||||||
|
const len = cols.length;
|
||||||
|
const steps: Step[] = [
|
||||||
|
{
|
||||||
|
label: "In the denary (base 10) system, each digit has a place value that is a power of 10.",
|
||||||
|
math: `${n}`,
|
||||||
|
activeCol: null,
|
||||||
|
revealed: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
cols.forEach((c, i) => {
|
||||||
|
steps.push({
|
||||||
|
label: `The ${PLACE_NAMES[c.exponent]} digit is ${c.digit}. Its place value is 10^${c.exponent} = ${c.placeValue.toLocaleString("en-US")}.`,
|
||||||
|
math: `${c.digit} \\times 10^{${c.exponent}} = ${c.contrib.toLocaleString("en-US")}`,
|
||||||
|
activeCol: i,
|
||||||
|
revealed: i + 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const expanded = cols.map((c) => `${c.digit} \\times 10^{${c.exponent}}`).join(" + ");
|
||||||
|
steps.push({
|
||||||
|
label: "Put it all together — this is the number in expanded form.",
|
||||||
|
math: `${n} = ${expanded}`,
|
||||||
|
activeCol: null,
|
||||||
|
revealed: len,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sumParts = cols.map((c) => c.contrib.toLocaleString("en-US")).join(" + ");
|
||||||
|
steps.push({
|
||||||
|
label: "Add every part back up and you return to the original number.",
|
||||||
|
math: `${sumParts} = ${n.toLocaleString("en-US")}`,
|
||||||
|
activeCol: null,
|
||||||
|
revealed: len,
|
||||||
|
});
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceValueChart({ columns, step }: { columns: Column[]; step: Step }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-xl border-2 border-unit-6/30 bg-unit-6-light/40 p-3">
|
||||||
|
<table className="mx-auto border-collapse font-mono">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
{columns.map((c, i) => (
|
||||||
|
<td
|
||||||
|
key={`p-${i}`}
|
||||||
|
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-sm font-bold text-unit-6-dark ${
|
||||||
|
step.activeCol === i ? "bg-unit-6 text-white" : "bg-unit-6-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
10<sup>{c.exponent}</sup>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{columns.map((c, i) => (
|
||||||
|
<td
|
||||||
|
key={`v-${i}`}
|
||||||
|
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-sm font-bold text-unit-6 ${
|
||||||
|
step.activeCol === i ? "bg-unit-6-light" : "bg-surface"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.placeValue.toLocaleString("en-US")}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{columns.map((c, i) => (
|
||||||
|
<td
|
||||||
|
key={`d-${i}`}
|
||||||
|
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-2xl font-extrabold ${
|
||||||
|
step.activeCol === i ? "bg-unit-6-light text-unit-6-dark" : "bg-surface text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.digit}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{columns.map((c, i) => (
|
||||||
|
<td
|
||||||
|
key={`c-${i}`}
|
||||||
|
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-sm font-bold ${
|
||||||
|
i < step.revealed ? "bg-correct-light text-correct" : "bg-[#fafbfc] text-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i < step.revealed ? c.contrib.toLocaleString("en-US") : "_"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quick practice ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PracticeQ {
|
||||||
|
prompt: string;
|
||||||
|
answer: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randInt(min: number, max: number) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeQuestion(): PracticeQ {
|
||||||
|
if (Math.random() < 0.5) {
|
||||||
|
// value of a digit
|
||||||
|
const n = randInt(1000, 99999);
|
||||||
|
const cols = columnsFor(n);
|
||||||
|
const nonZero = cols.filter((c) => c.digit !== "0");
|
||||||
|
const pick = nonZero[Math.floor(Math.random() * nonZero.length)];
|
||||||
|
return {
|
||||||
|
prompt: `In ${n.toLocaleString("en-US")}, what is the VALUE of the digit ${pick.digit}?`,
|
||||||
|
answer: pick.contrib,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// exponent of a named column
|
||||||
|
const exp = randInt(0, 5);
|
||||||
|
return {
|
||||||
|
prompt: `Which power of 10 is the ${PLACE_NAMES[exp]} column? Enter the exponent.`,
|
||||||
|
answer: exp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickPractice() {
|
||||||
|
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
|
||||||
|
const [val, setVal] = useState("");
|
||||||
|
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
|
||||||
|
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||||
|
|
||||||
|
function check() {
|
||||||
|
const parsed = parseInt(val, 10);
|
||||||
|
if (isNaN(parsed)) return;
|
||||||
|
const correct = parsed === q.answer;
|
||||||
|
setFeedback(correct ? "correct" : "incorrect");
|
||||||
|
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
setQ(makeQuestion());
|
||||||
|
setVal("");
|
||||||
|
setFeedback(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-unit-6">Quick Practice</p>
|
||||||
|
<span className="rounded-full bg-unit-6-light px-3 py-1 text-xs font-bold text-unit-6-dark">
|
||||||
|
{score.correct}/{score.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-lg font-bold">{q.prompt}</p>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={val}
|
||||||
|
onChange={(e) => {
|
||||||
|
setVal(e.target.value);
|
||||||
|
setFeedback(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (feedback !== null) next();
|
||||||
|
else check();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`w-32 rounded-lg border-2 px-3 py-2 text-center text-xl font-bold outline-none transition-colors ${
|
||||||
|
feedback === "correct"
|
||||||
|
? "border-correct bg-correct-light"
|
||||||
|
: feedback === "incorrect"
|
||||||
|
? "border-incorrect bg-incorrect-light"
|
||||||
|
: "border-border focus:border-unit-6"
|
||||||
|
}`}
|
||||||
|
placeholder="?"
|
||||||
|
aria-label="Your answer"
|
||||||
|
/>
|
||||||
|
{feedback === null ? (
|
||||||
|
<button onClick={check} className="rounded-lg bg-unit-6 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-6-dark">
|
||||||
|
Check
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
|
||||||
|
{feedback === "incorrect" && (
|
||||||
|
<p className="text-center text-sm font-bold text-incorrect">Not quite — the answer is {q.answer.toLocaleString("en-US")}.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main explorer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function PlaceValueExplorer() {
|
||||||
|
const [input, setInput] = useState("983275");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [columns, setColumns] = useState<Column[] | null>(null);
|
||||||
|
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);
|
||||||
|
setColumns(null);
|
||||||
|
setCurrentStep(0);
|
||||||
|
setIsPlaying(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleGo() {
|
||||||
|
setError("");
|
||||||
|
const cleaned = input.replace(/[,\s]/g, "");
|
||||||
|
const n = parseInt(cleaned, 10);
|
||||||
|
if (isNaN(n) || !/^\d+$/.test(cleaned)) return setError("Enter a whole number (digits only).");
|
||||||
|
if (n < 1 || n > 999999) return setError("Use a whole number between 1 and 999,999.");
|
||||||
|
setColumns(columnsFor(n));
|
||||||
|
setSteps(buildSteps(n));
|
||||||
|
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">
|
||||||
|
{/* Input card */}
|
||||||
|
<Card>
|
||||||
|
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-6">
|
||||||
|
Enter a whole number (up to 999,999)
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleGo();
|
||||||
|
}}
|
||||||
|
className="w-44 rounded-lg border-2 border-unit-6 bg-surface px-3 py-2 text-center font-mono text-2xl font-bold text-unit-6 outline-none focus:border-unit-6-dark"
|
||||||
|
aria-label="Number input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleGo}
|
||||||
|
className="rounded-lg bg-unit-6 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-6-dark"
|
||||||
|
>
|
||||||
|
Break Down →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Display card */}
|
||||||
|
<Card className="flex min-h-[280px] flex-col items-center justify-center gap-4 p-6">
|
||||||
|
{!step || !columns ? (
|
||||||
|
<p className="text-muted/50">
|
||||||
|
Enter a number above and click <strong>Break Down</strong>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
|
||||||
|
<MathDisplay math={step.math} className="text-xl" />
|
||||||
|
<PlaceValueChart columns={columns} step={step} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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]" />}>
|
||||||
|
<QuickPractice />
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
258
components/explorers/read-coordinates-explorer.tsx
Normal file
258
components/explorers/read-coordinates-explorer.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"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 } from "./cartesian-grid";
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
label: string;
|
||||||
|
math: string;
|
||||||
|
points: GridPoint[];
|
||||||
|
highlight?: { x: number; y: number };
|
||||||
|
guide?: "x" | "y" | "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
function randInt(min: number, max: number) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quadrantName(x: number, y: number): string {
|
||||||
|
if (x === 0 && y === 0) return "the origin";
|
||||||
|
if (x === 0) return "on the y-axis";
|
||||||
|
if (y === 0) return "on the x-axis";
|
||||||
|
if (x > 0 && y > 0) return "the top-right quadrant";
|
||||||
|
if (x < 0 && y > 0) return "the top-left quadrant";
|
||||||
|
if (x < 0 && y < 0) return "the bottom-left quadrant";
|
||||||
|
return "the bottom-right quadrant";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReadSteps(x: number, y: number): Step[] {
|
||||||
|
const point: GridPoint = { x, y, tone: "solid" };
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Here is a point. Let's read off its coordinates.",
|
||||||
|
math: `(?,\\ ?)`,
|
||||||
|
points: [point],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `Trace straight ${y >= 0 ? "DOWN" : "UP"} to the x-axis. It lands on ${x}.`,
|
||||||
|
math: `x = ${x}`,
|
||||||
|
points: [point],
|
||||||
|
highlight: { x, y },
|
||||||
|
guide: "x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `Now trace straight across to the y-axis. It lands on ${y}.`,
|
||||||
|
math: `y = ${y}`,
|
||||||
|
points: [point],
|
||||||
|
highlight: { x, y },
|
||||||
|
guide: "both",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `The coordinates are (${x}, ${y}) — x first, then y. This point is in ${quadrantName(x, y)}.`,
|
||||||
|
math: `(${x},\\ ${y})`,
|
||||||
|
points: [{ x, y, label: `(${x}, ${y})`, tone: "solid" }],
|
||||||
|
highlight: { x, y },
|
||||||
|
guide: "both",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quick practice: read the coordinates ─────────────────────────────────────
|
||||||
|
|
||||||
|
function ReadPractice() {
|
||||||
|
const [target, setTarget] = useState(() => ({ x: randInt(-9, 9), y: randInt(-9, 9) }));
|
||||||
|
const [xVal, setXVal] = useState("");
|
||||||
|
const [yVal, setYVal] = useState("");
|
||||||
|
const [feedback, setFeedback] = useState<"correct" | "swapped" | "incorrect" | null>(null);
|
||||||
|
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||||
|
|
||||||
|
function check() {
|
||||||
|
const gx = parseInt(xVal, 10);
|
||||||
|
const gy = parseInt(yVal, 10);
|
||||||
|
if (isNaN(gx) || isNaN(gy)) return;
|
||||||
|
if (gx === target.x && gy === target.y) {
|
||||||
|
setFeedback("correct");
|
||||||
|
setScore((s) => ({ correct: s.correct + 1, total: s.total + 1 }));
|
||||||
|
} else if (gx === target.y && gy === target.x && target.x !== target.y) {
|
||||||
|
setFeedback("swapped");
|
||||||
|
setScore((s) => ({ correct: s.correct, total: s.total + 1 }));
|
||||||
|
} else {
|
||||||
|
setFeedback("incorrect");
|
||||||
|
setScore((s) => ({ correct: s.correct, total: s.total + 1 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
setTarget({ x: randInt(-9, 9), y: randInt(-9, 9) });
|
||||||
|
setXVal("");
|
||||||
|
setYVal("");
|
||||||
|
setFeedback(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">What are the coordinates of this point?</p>
|
||||||
|
<div className="mx-auto max-w-md">
|
||||||
|
<CartesianGrid points={[{ x: target.x, y: target.y, tone: "solid" }]} highlight={feedback ? target : undefined} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<span className="text-lg font-bold text-muted">(</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={xVal}
|
||||||
|
onChange={(e) => {
|
||||||
|
setXVal(e.target.value);
|
||||||
|
setFeedback(null);
|
||||||
|
}}
|
||||||
|
disabled={feedback === "correct"}
|
||||||
|
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"
|
||||||
|
placeholder="x"
|
||||||
|
aria-label="x coordinate"
|
||||||
|
/>
|
||||||
|
<span className="text-lg font-bold text-muted">,</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={yVal}
|
||||||
|
onChange={(e) => {
|
||||||
|
setYVal(e.target.value);
|
||||||
|
setFeedback(null);
|
||||||
|
}}
|
||||||
|
disabled={feedback === "correct"}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (feedback === "correct") next();
|
||||||
|
else check();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
placeholder="y"
|
||||||
|
aria-label="y coordinate"
|
||||||
|
/>
|
||||||
|
<span className="text-lg font-bold text-muted">)</span>
|
||||||
|
{feedback === "correct" ? (
|
||||||
|
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={check} className="rounded-lg bg-unit-7 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-7-dark">
|
||||||
|
Check
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct! ({target.x}, {target.y})</p>}
|
||||||
|
{feedback === "swapped" && (
|
||||||
|
<p className="text-center text-sm font-bold text-incorrect">You swapped them — the x-coordinate comes first! Try again.</p>
|
||||||
|
)}
|
||||||
|
{feedback === "incorrect" && (
|
||||||
|
<p className="text-center text-sm font-bold text-incorrect">Not quite. Trace down to the x-axis, then across to the y-axis.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main explorer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ReadCoordinatesExplorer() {
|
||||||
|
const [point, setPoint] = useState(() => ({ x: 3, y: 2 }));
|
||||||
|
const [steps, setSteps] = useState<Step[] | null>(() => buildReadSteps(3, 2));
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
|
||||||
|
const done = steps ? currentStep >= steps.length - 1 : false;
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setCurrentStep(0);
|
||||||
|
setIsPlaying(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function newPoint() {
|
||||||
|
let x = randInt(-9, 9);
|
||||||
|
const y = randInt(-9, 9);
|
||||||
|
if (x === point.x && y === point.y) x = -x || 1;
|
||||||
|
setPoint({ x, y });
|
||||||
|
setSteps(buildReadSteps(x, y));
|
||||||
|
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">
|
||||||
|
<Card>
|
||||||
|
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-7">
|
||||||
|
Read the coordinates of the point
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={newPoint}
|
||||||
|
className="rounded-lg bg-unit-7 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-7-dark"
|
||||||
|
>
|
||||||
|
New Point ↻
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Display card */}
|
||||||
|
<Card className="flex min-h-[320px] flex-col items-center justify-center gap-4 p-6">
|
||||||
|
{step && (
|
||||||
|
<>
|
||||||
|
<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}
|
||||||
|
highlight={
|
||||||
|
step.guide && step.highlight
|
||||||
|
? { ...step.highlight, axis: step.guide === "x" ? "x" : "both" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</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]" />}>
|
||||||
|
<ReadPractice />
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -208,6 +208,78 @@ const art: Record<string, ReactNode> = {
|
|||||||
<text x="50" y="44" fontSize="11" textAnchor="middle" dominantBaseline="middle" opacity="0.8" {...glyphText}>base 4</text>
|
<text x="50" y="44" fontSize="11" textAnchor="middle" dominantBaseline="middle" opacity="0.8" {...glyphText}>base 4</text>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Unit 5 — Integers (Order & Compare)
|
||||||
|
"unit-5-integers/order-compare": (
|
||||||
|
<>
|
||||||
|
<path d="M14 44 H86 M24 44 v-6 M44 44 v-6 M64 44 v-6 M84 44 v-6" stroke="white" strokeWidth="2.5" strokeLinecap="round" fill="none" />
|
||||||
|
<circle cx="30" cy="44" r="4" fill="white" opacity="0.6" />
|
||||||
|
<circle cx="70" cy="44" r="4" fill="white" />
|
||||||
|
<text x="50" y="24" fontSize="22" textAnchor="middle" dominantBaseline="middle" {...glyphText}><</text>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
|
||||||
|
"unit-5-integers/negative-fractions": (
|
||||||
|
<>
|
||||||
|
<text x="34" y="24" fontSize="22" textAnchor="middle" dominantBaseline="middle" {...glyphText}>−</text>
|
||||||
|
<text x="52" y="18" fontSize="15" textAnchor="middle" dominantBaseline="middle" {...glyphText}>3</text>
|
||||||
|
<rect x="44" y="26" width="18" height="3" rx="1.5" fill="white" />
|
||||||
|
<text x="52" y="40" fontSize="15" textAnchor="middle" dominantBaseline="middle" {...glyphText}>4</text>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
|
||||||
|
// Unit 6 — Number System (Place Value)
|
||||||
|
"unit-6-number-system/place-value": (
|
||||||
|
<>
|
||||||
|
<text x="30" y="24" fontSize="18" textAnchor="middle" dominantBaseline="middle" {...glyphText}>3</text>
|
||||||
|
<text x="50" y="24" fontSize="18" textAnchor="middle" dominantBaseline="middle" {...glyphText}>8</text>
|
||||||
|
<text x="70" y="24" fontSize="18" textAnchor="middle" dominantBaseline="middle" {...glyphText}>5</text>
|
||||||
|
<line x1="18" y1="33" x2="82" y2="33" stroke="white" strokeWidth="1.5" opacity="0.5" />
|
||||||
|
<text x="30" y="46" fontSize="10" textAnchor="middle" dominantBaseline="middle" opacity="0.85" {...glyphText}>10²</text>
|
||||||
|
<text x="50" y="46" fontSize="10" textAnchor="middle" dominantBaseline="middle" opacity="0.85" {...glyphText}>10¹</text>
|
||||||
|
<text x="70" y="46" fontSize="10" textAnchor="middle" dominantBaseline="middle" opacity="0.85" {...glyphText}>10⁰</text>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
|
||||||
|
// Unit 7 — Coordinates
|
||||||
|
"unit-7-coordinates/plot-points": (
|
||||||
|
<>
|
||||||
|
<path d="M20 12 V60 M35 12 V60 M50 12 V60 M65 12 V60 M80 12 V60 M20 12 H80 M20 24 H80 M20 36 H80 M20 48 H80 M20 60 H80" stroke="white" strokeWidth="1" opacity="0.25" fill="none" />
|
||||||
|
<path d="M50 12 V60 M20 36 H80" stroke="white" strokeWidth="2" opacity="0.9" fill="none" />
|
||||||
|
<path d="M65 24 V36 M50 24 H65" stroke="white" strokeWidth="1.5" strokeDasharray="3 2" opacity="0.75" fill="none" />
|
||||||
|
<circle cx="65" cy="24" r="4.5" fill="white" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"unit-7-coordinates/read-coordinates": (
|
||||||
|
<>
|
||||||
|
<path d="M50 14 V62 M20 38 H80" stroke="white" strokeWidth="2" opacity="0.9" fill="none" />
|
||||||
|
<path d="M64 26 V38 M50 26 H64" stroke="white" strokeWidth="1.5" strokeDasharray="3 2" opacity="0.6" fill="none" />
|
||||||
|
<circle cx="64" cy="26" r="4" fill="white" />
|
||||||
|
<text x="64" y="17" fontSize="11" textAnchor="middle" dominantBaseline="middle" {...glyphText}>(3, 2)</text>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
|
||||||
|
// Unit 8 — Number Properties
|
||||||
|
"unit-8-number-properties/identity-inverse": (
|
||||||
|
<>
|
||||||
|
<text x="50" y="24" fontSize="16" textAnchor="middle" dominantBaseline="middle" {...glyphText}>n + 0 = n</text>
|
||||||
|
<text x="50" y="46" fontSize="12" textAnchor="middle" dominantBaseline="middle" opacity="0.8" {...glyphText}>3 + (−3) = 0</text>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"unit-8-number-properties/number-laws": (
|
||||||
|
<>
|
||||||
|
<text x="50" y="24" fontSize="15" textAnchor="middle" dominantBaseline="middle" {...glyphText}>a + b = b + a</text>
|
||||||
|
<path d="M36 34 C42 44, 58 44, 64 34" stroke="white" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||||
|
<path d="M64 34 l-4 1 M64 34 l-1 -4" stroke="white" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"unit-8-number-properties/exponents": (
|
||||||
|
<>
|
||||||
|
<text x="34" y="34" fontSize="30" textAnchor="middle" dominantBaseline="middle" {...glyphText}>2</text>
|
||||||
|
<text x="50" y="22" fontSize="16" textAnchor="middle" dominantBaseline="middle" {...glyphText}>4</text>
|
||||||
|
<text x="73" y="34" fontSize="18" textAnchor="middle" dominantBaseline="middle" {...glyphText}>= 16</text>
|
||||||
|
</>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const fallback: ReactNode = (
|
const fallback: ReactNode = (
|
||||||
|
|||||||
74
components/layout/collapsible-sidebar.tsx
Normal file
74
components/layout/collapsible-sidebar.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared state + styling for a sidebar that collapses in place on large screens
|
||||||
|
* and opens as a slide-in drawer on small ones. One toggle drives both, choosing
|
||||||
|
* the right behaviour by media query at click time. Defaults: open on desktop
|
||||||
|
* (CSS `lg:` classes), closed on mobile (state) — so SSR and hydration agree.
|
||||||
|
*/
|
||||||
|
export function useCollapsibleSidebar() {
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [desktopCollapsed, setDesktopCollapsed] = useState(false);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (typeof window !== "undefined" && window.matchMedia("(min-width: 1024px)").matches) {
|
||||||
|
setDesktopCollapsed((c) => !c);
|
||||||
|
} else {
|
||||||
|
setMobileOpen((o) => !o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mobileOpen,
|
||||||
|
desktopCollapsed,
|
||||||
|
toggle,
|
||||||
|
closeMobile: () => setMobileOpen(false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sidebarAsideClass(mobileOpen: boolean, desktopCollapsed: boolean) {
|
||||||
|
return cn(
|
||||||
|
"z-40 w-64 shrink-0 border-r border-border/60 bg-surface",
|
||||||
|
// Small screens: a fixed slide-in drawer sitting below the header.
|
||||||
|
"fixed bottom-0 left-0 top-[4.125rem] transition-transform duration-200",
|
||||||
|
mobileOpen ? "translate-x-0 shadow-[var(--shadow-lg)]" : "-translate-x-full",
|
||||||
|
// Large screens: part of the layout flow, collapsible in place.
|
||||||
|
"lg:static lg:z-auto lg:translate-x-0 lg:shadow-none lg:transition-none",
|
||||||
|
desktopCollapsed ? "lg:hidden" : "lg:block",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sidebarNavClass =
|
||||||
|
"h-full overflow-y-auto p-4 lg:sticky lg:top-[4.125rem] lg:h-[calc(100vh-4.125rem)]";
|
||||||
|
|
||||||
|
export function SidebarBackdrop({ show, onClick }: { show: boolean; onClick: () => void }) {
|
||||||
|
if (!show) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-30 bg-foreground/30 lg:hidden"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarToggle({ onClick, className }: { onClick: () => void; className?: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label="Toggle topics menu"
|
||||||
|
className={cn(
|
||||||
|
"mb-5 inline-flex items-center gap-2 rounded-xl border border-border/60 bg-surface px-3 py-2 text-sm font-bold text-muted shadow-[var(--shadow-sm)] transition-colors hover:text-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
Topics
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ const navItems = [
|
|||||||
export function Header() {
|
export function Header() {
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 border-b-4 border-[#1c4ab7] bg-[#2f65e8] text-white">
|
<header className="sticky top-0 z-50 border-b-4 border-[#1c4ab7] bg-[#2f65e8] text-white">
|
||||||
<div className="mx-auto max-w-6xl playground-frame border-b-0 border-t-0 bg-[#2f65e8] shadow-none">
|
<div className="mx-auto max-w-7xl playground-frame border-b-0 border-t-0 bg-[#2f65e8] shadow-none">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||||
<Link href="/" className="group flex items-center gap-3">
|
<Link href="/" className="group flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#ffd043] text-[#10308a] shadow-md">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#ffd043] text-[#10308a] shadow-md">
|
||||||
|
|||||||
22
components/layout/lessons-shell.tsx
Normal file
22
components/layout/lessons-shell.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Sidebar } from "./sidebar";
|
||||||
|
import { useCollapsibleSidebar, SidebarBackdrop, SidebarToggle } from "./collapsible-sidebar";
|
||||||
|
|
||||||
|
export function LessonsShell({ children }: { children: ReactNode }) {
|
||||||
|
const { mobileOpen, desktopCollapsed, toggle, closeMobile } = useCollapsibleSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="playground-frame relative mx-auto flex w-full max-w-7xl flex-1">
|
||||||
|
<SidebarBackdrop show={mobileOpen} onClick={closeMobile} />
|
||||||
|
<Sidebar mobileOpen={mobileOpen} desktopCollapsed={desktopCollapsed} onNavigate={closeMobile} />
|
||||||
|
<main className="min-w-0 flex-1 bg-white">
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<SidebarToggle onClick={toggle} />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { curriculum } from "@/lib/curriculum";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
const unitColorMap = {
|
|
||||||
"unit-1": "text-unit-1-dark",
|
|
||||||
"unit-2": "text-unit-2-dark",
|
|
||||||
"unit-3": "text-unit-3-dark",
|
|
||||||
"unit-4": "text-unit-4-dark",
|
|
||||||
"unit-5": "text-unit-5-dark",
|
|
||||||
"unit-6": "text-unit-6-dark",
|
|
||||||
};
|
|
||||||
|
|
||||||
const unitDotColor = {
|
|
||||||
"unit-1": "bg-unit-1",
|
|
||||||
"unit-2": "bg-unit-2",
|
|
||||||
"unit-3": "bg-unit-3",
|
|
||||||
"unit-4": "bg-unit-4",
|
|
||||||
"unit-5": "bg-unit-5",
|
|
||||||
"unit-6": "bg-unit-6",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MobileNav() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="lg:hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="flex items-center gap-2 rounded-xl border border-border/60 bg-surface px-3.5 py-2 text-sm font-medium shadow-[var(--shadow-sm)] transition-all hover:shadow-[var(--shadow-md)]"
|
|
||||||
aria-label="Toggle navigation"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
{isOpen ? (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
) : (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
Topics
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div className="absolute left-0 right-0 top-full z-40 max-h-[70vh] overflow-y-auto border-b border-border/60 bg-surface p-4 shadow-[var(--shadow-lg)]">
|
|
||||||
<Link
|
|
||||||
href="/lessons"
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
className="mb-4 flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-muted transition-colors hover:bg-background hover:text-foreground"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
All Topics
|
|
||||||
</Link>
|
|
||||||
<div className="mb-3 h-px bg-border/60" />
|
|
||||||
{curriculum.map((unit) => (
|
|
||||||
<div key={unit.slug} className="mb-4">
|
|
||||||
<p className={cn("mb-1.5 flex items-center gap-2 px-3 text-xs font-bold uppercase tracking-wider", unitColorMap[unit.color])}>
|
|
||||||
<span className={cn("h-2 w-2 rounded-full", unitDotColor[unit.color])} />
|
|
||||||
Unit {unit.number}: {unit.title}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{unit.topics.map((topic) => {
|
|
||||||
const href = `/lessons/${unit.slug}/${topic.slug}`;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={topic.slug}
|
|
||||||
href={href}
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
className={cn(
|
|
||||||
"block rounded-lg px-3 py-1.5 text-sm transition-colors",
|
|
||||||
pathname === href
|
|
||||||
? "bg-foreground text-background font-medium"
|
|
||||||
: "text-muted hover:bg-background hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{topic.shortTitle}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
|
|||||||
import { curriculum } from "@/lib/curriculum";
|
import { curriculum } from "@/lib/curriculum";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { sidebarAsideClass, sidebarNavClass } from "./collapsible-sidebar";
|
||||||
|
|
||||||
const unitColorMap = {
|
const unitColorMap = {
|
||||||
"unit-1": {
|
"unit-1": {
|
||||||
@@ -43,9 +44,29 @@ const unitColorMap = {
|
|||||||
heading: "text-unit-6-dark",
|
heading: "text-unit-6-dark",
|
||||||
hoverBg: "hover:bg-unit-6-light/50",
|
hoverBg: "hover:bg-unit-6-light/50",
|
||||||
},
|
},
|
||||||
|
"unit-7": {
|
||||||
|
active: "bg-unit-7-light text-unit-7-dark border-unit-7/20",
|
||||||
|
dot: "bg-unit-7",
|
||||||
|
heading: "text-unit-7-dark",
|
||||||
|
hoverBg: "hover:bg-unit-7-light/50",
|
||||||
|
},
|
||||||
|
"unit-8": {
|
||||||
|
active: "bg-unit-8-light text-unit-8-dark border-unit-8/20",
|
||||||
|
dot: "bg-unit-8",
|
||||||
|
heading: "text-unit-8-dark",
|
||||||
|
hoverBg: "hover:bg-unit-8-light/50",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar({
|
||||||
|
mobileOpen = false,
|
||||||
|
desktopCollapsed = false,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
mobileOpen?: boolean;
|
||||||
|
desktopCollapsed?: boolean;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [openUnits, setOpenUnits] = useState<Set<number>>(() => {
|
const [openUnits, setOpenUnits] = useState<Set<number>>(() => {
|
||||||
const open = new Set<number>();
|
const open = new Set<number>();
|
||||||
@@ -68,10 +89,11 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden w-64 shrink-0 border-r border-border/60 bg-surface lg:block">
|
<aside className={sidebarAsideClass(mobileOpen, desktopCollapsed)}>
|
||||||
<nav className="sticky top-[4.125rem] h-[calc(100vh-4.125rem)] overflow-y-auto p-4">
|
<nav className={sidebarNavClass}>
|
||||||
<Link
|
<Link
|
||||||
href="/lessons"
|
href="/lessons"
|
||||||
|
onClick={onNavigate}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mb-4 flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-200",
|
"mb-4 flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-200",
|
||||||
pathname === "/lessons"
|
pathname === "/lessons"
|
||||||
@@ -79,9 +101,6 @@ export function Sidebar() {
|
|||||||
: "text-muted hover:bg-background hover:text-foreground",
|
: "text-muted hover:bg-background hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
All Topics
|
All Topics
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -126,6 +145,7 @@ export function Sidebar() {
|
|||||||
<Link
|
<Link
|
||||||
key={topic.slug}
|
key={topic.slug}
|
||||||
href={href}
|
href={href}
|
||||||
|
onClick={onNavigate}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm transition-all duration-200",
|
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm transition-all duration-200",
|
||||||
isActive
|
isActive
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, type KeyboardEvent } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** Keep only digits and a single leading minus sign, so junk like "1-2" can't be typed. */
|
||||||
|
function sanitizeSigned(value: string): string {
|
||||||
|
const cleaned = value.replace(/[^0-9-]/g, "");
|
||||||
|
return cleaned.replace(/(?!^)-/g, ""); // drop any minus that isn't at the start
|
||||||
|
}
|
||||||
|
|
||||||
interface FractionInputProps {
|
interface FractionInputProps {
|
||||||
numerator: string;
|
numerator: string;
|
||||||
denominator: string;
|
denominator: string;
|
||||||
onNumeratorChange: (value: string) => void;
|
onNumeratorChange: (value: string) => void;
|
||||||
onDenominatorChange: (value: string) => void;
|
onDenominatorChange: (value: string) => void;
|
||||||
|
onSubmit?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -16,31 +24,50 @@ export function FractionInput({
|
|||||||
denominator,
|
denominator,
|
||||||
onNumeratorChange,
|
onNumeratorChange,
|
||||||
onDenominatorChange,
|
onDenominatorChange,
|
||||||
|
onSubmit,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
}: FractionInputProps) {
|
}: FractionInputProps) {
|
||||||
|
const denominatorRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-16 rounded-lg border border-border bg-surface px-2 py-1.5 text-center text-lg font-bold focus:border-unit-1 focus:outline-none";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("inline-flex flex-col items-center gap-0.5", className)}>
|
<div className={cn("inline-flex flex-col items-center gap-0.5", className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="[0-9-]*"
|
|
||||||
value={numerator}
|
value={numerator}
|
||||||
onChange={(e) => onNumeratorChange(e.target.value)}
|
onChange={(e) => onNumeratorChange(sanitizeSigned(e.target.value))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// "/" is how pupils think of a fraction — jump to the denominator.
|
||||||
|
if (e.key === "/" || e.key === "Enter") {
|
||||||
|
if (e.key === "/") {
|
||||||
|
e.preventDefault();
|
||||||
|
denominatorRef.current?.focus();
|
||||||
|
} else {
|
||||||
|
onSubmit?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-16 rounded-lg border border-border bg-surface px-2 py-1.5 text-center text-lg font-bold focus:border-unit-1 focus:outline-none"
|
className={inputClass}
|
||||||
placeholder="?"
|
placeholder="?"
|
||||||
aria-label="Numerator"
|
aria-label="Numerator"
|
||||||
/>
|
/>
|
||||||
<div className="h-0.5 w-16 bg-foreground" />
|
<div className="h-0.5 w-16 bg-foreground" />
|
||||||
<input
|
<input
|
||||||
|
ref={denominatorRef}
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="[0-9-]*"
|
|
||||||
value={denominator}
|
value={denominator}
|
||||||
onChange={(e) => onDenominatorChange(e.target.value)}
|
onChange={(e) => onDenominatorChange(sanitizeSigned(e.target.value))}
|
||||||
|
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") onSubmit?.();
|
||||||
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-16 rounded-lg border border-border bg-surface px-2 py-1.5 text-center text-lg font-bold focus:border-unit-1 focus:outline-none"
|
className={inputClass}
|
||||||
placeholder="?"
|
placeholder="?"
|
||||||
aria-label="Denominator"
|
aria-label="Denominator"
|
||||||
/>
|
/>
|
||||||
@@ -51,17 +78,21 @@ export function FractionInput({
|
|||||||
interface DecimalInputProps {
|
interface DecimalInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
onSubmit?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DecimalInput({ value, onChange, disabled, className }: DecimalInputProps) {
|
export function DecimalInput({ value, onChange, onSubmit, disabled, className }: DecimalInputProps) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") onSubmit?.();
|
||||||
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-28 rounded-lg border border-border bg-surface px-3 py-2 text-center text-lg font-bold focus:border-unit-2 focus:outline-none",
|
"w-28 rounded-lg border border-border bg-surface px-3 py-2 text-center text-lg font-bold focus:border-unit-2 focus:outline-none",
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
parseStrictDecimal,
|
parseStrictDecimal,
|
||||||
parseStrictSignedDecimal,
|
parseStrictSignedDecimal,
|
||||||
parseStrictSignedInt,
|
parseStrictSignedInt,
|
||||||
|
wrong,
|
||||||
|
SOLVED,
|
||||||
type AnswerResult,
|
type AnswerResult,
|
||||||
} from "@/lib/math/validation";
|
} from "@/lib/math/validation";
|
||||||
import { FractionInput, DecimalInput, RatioInput } from "./fraction-input";
|
import { FractionInput, DecimalInput, RatioInput } from "./fraction-input";
|
||||||
@@ -22,7 +24,7 @@ import katex from "katex";
|
|||||||
interface PracticeSectionProps {
|
interface PracticeSectionProps {
|
||||||
title: string;
|
title: string;
|
||||||
generator: (difficulty: Difficulty) => MathProblem;
|
generator: (difficulty: Difficulty) => MathProblem;
|
||||||
unitColor?: "unit-1" | "unit-2" | "unit-3" | "unit-4";
|
unitColor?: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6" | "unit-8";
|
||||||
}
|
}
|
||||||
|
|
||||||
type UnitColor = NonNullable<PracticeSectionProps["unitColor"]>;
|
type UnitColor = NonNullable<PracticeSectionProps["unitColor"]>;
|
||||||
@@ -32,6 +34,9 @@ const activeDifficultyStyle: Record<UnitColor, string> = {
|
|||||||
"unit-2": "bg-unit-2 text-white",
|
"unit-2": "bg-unit-2 text-white",
|
||||||
"unit-3": "bg-unit-3 text-white",
|
"unit-3": "bg-unit-3 text-white",
|
||||||
"unit-4": "bg-unit-4 text-white",
|
"unit-4": "bg-unit-4 text-white",
|
||||||
|
"unit-5": "bg-unit-5 text-white",
|
||||||
|
"unit-6": "bg-unit-6 text-white",
|
||||||
|
"unit-8": "bg-unit-8 text-white",
|
||||||
};
|
};
|
||||||
|
|
||||||
const RECENT_PROBLEM_LIMIT = 8;
|
const RECENT_PROBLEM_LIMIT = 8;
|
||||||
@@ -54,7 +59,11 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
|||||||
const [hintIndex, setHintIndex] = useState(0);
|
const [hintIndex, setHintIndex] = useState(0);
|
||||||
const [showSolution, setShowSolution] = useState(false);
|
const [showSolution, setShowSolution] = useState(false);
|
||||||
const [score, setScore] = useState({ correct: 0, total: 0 });
|
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||||
const isFullyCorrect = result?.correct === true && result.simplified === true;
|
const isFullyCorrect = result?.status === "solved";
|
||||||
|
// Per-problem scoring guards: a problem is counted at most once, when solved,
|
||||||
|
// and only counts as "correct" if solved with no earlier wrong attempt or peek.
|
||||||
|
const missedCurrentRef = useRef(false);
|
||||||
|
const scoredCurrentRef = useRef(false);
|
||||||
|
|
||||||
const generateUniqueProblem = useCallback((diff: Difficulty): MathProblem => {
|
const generateUniqueProblem = useCallback((diff: Difficulty): MathProblem => {
|
||||||
const recentProblemKeys = recentProblemKeysRef.current ?? [getProblemKey(problem)];
|
const recentProblemKeys = recentProblemKeysRef.current ?? [getProblemKey(problem)];
|
||||||
@@ -86,6 +95,8 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
|||||||
setShowHint(false);
|
setShowHint(false);
|
||||||
setHintIndex(0);
|
setHintIndex(0);
|
||||||
setShowSolution(false);
|
setShowSolution(false);
|
||||||
|
missedCurrentRef.current = false;
|
||||||
|
scoredCurrentRef.current = false;
|
||||||
}, [generateUniqueProblem]);
|
}, [generateUniqueProblem]);
|
||||||
|
|
||||||
function checkAnswer() {
|
function checkAnswer() {
|
||||||
@@ -97,7 +108,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
|||||||
const num = parseStrictSignedInt(userAnswer.numerator || "");
|
const num = parseStrictSignedInt(userAnswer.numerator || "");
|
||||||
const den = parseStrictSignedInt(userAnswer.denominator || "");
|
const den = parseStrictSignedInt(userAnswer.denominator || "");
|
||||||
if (num === null || den === null) {
|
if (num === null || den === null) {
|
||||||
res = { correct: false, message: "Enter valid numbers" };
|
res = wrong("Enter valid numbers");
|
||||||
} else {
|
} else {
|
||||||
res = checkFractionAnswer(num, den, answer.numerator, answer.denominator);
|
res = checkFractionAnswer(num, den, answer.numerator, answer.denominator);
|
||||||
}
|
}
|
||||||
@@ -106,7 +117,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
|||||||
case "decimal": {
|
case "decimal": {
|
||||||
const val = parseStrictSignedDecimal(userAnswer.value || "");
|
const val = parseStrictSignedDecimal(userAnswer.value || "");
|
||||||
if (val === null) {
|
if (val === null) {
|
||||||
res = { correct: false, message: "Enter a valid number" };
|
res = wrong("Enter a valid number");
|
||||||
} else {
|
} else {
|
||||||
res = checkDecimalAnswer(val, answer.value);
|
res = checkDecimalAnswer(val, answer.value);
|
||||||
}
|
}
|
||||||
@@ -115,7 +126,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
|||||||
case "integer": {
|
case "integer": {
|
||||||
const val = parseStrictSignedInt(userAnswer.value || "");
|
const val = parseStrictSignedInt(userAnswer.value || "");
|
||||||
if (val === null) {
|
if (val === null) {
|
||||||
res = { correct: false, message: "Enter a valid number" };
|
res = wrong("Enter a valid number");
|
||||||
} else {
|
} else {
|
||||||
res = checkIntegerAnswer(val, answer.value);
|
res = checkIntegerAnswer(val, answer.value);
|
||||||
}
|
}
|
||||||
@@ -124,7 +135,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
|||||||
case "ratio": {
|
case "ratio": {
|
||||||
const parts = (userAnswer.ratio || "").split(":").map((p) => parseInt(p.trim()));
|
const parts = (userAnswer.ratio || "").split(":").map((p) => parseInt(p.trim()));
|
||||||
if (parts.some(isNaN)) {
|
if (parts.some(isNaN)) {
|
||||||
res = { correct: false, message: "Enter valid ratio parts" };
|
res = wrong("Enter valid ratio parts");
|
||||||
} else {
|
} else {
|
||||||
res = checkRatioAnswer(parts, answer.parts);
|
res = checkRatioAnswer(parts, answer.parts);
|
||||||
}
|
}
|
||||||
@@ -134,27 +145,44 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
|||||||
const coeff = parseStrictDecimal(userAnswer.coefficient || "");
|
const coeff = parseStrictDecimal(userAnswer.coefficient || "");
|
||||||
const exp = parseStrictSignedInt(userAnswer.exponent || "");
|
const exp = parseStrictSignedInt(userAnswer.exponent || "");
|
||||||
if (coeff === null || exp === null) {
|
if (coeff === null || exp === null) {
|
||||||
res = { correct: false, message: "Enter valid numbers" };
|
res = wrong("Enter valid numbers");
|
||||||
} else if (
|
} else if (
|
||||||
Math.abs(coeff - answer.coefficient) < 0.01 &&
|
Math.abs(coeff - answer.coefficient) < 0.01 &&
|
||||||
exp === answer.exponent
|
exp === answer.exponent
|
||||||
) {
|
) {
|
||||||
res = { correct: true, simplified: true };
|
res = SOLVED;
|
||||||
} else {
|
} else {
|
||||||
res = { correct: false, message: "That's not quite right. Try again!" };
|
res = wrong("That's not quite right. Try again!");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setResult(res);
|
setResult(res);
|
||||||
if (res.correct) {
|
|
||||||
setScore((s) => ({ correct: s.correct + 1, total: s.total + 1 }));
|
// Score each problem exactly once, when it is solved. A wrong attempt (or an
|
||||||
} else {
|
// "unsimplified" nudge that never gets finished) never scores on its own; it
|
||||||
setScore((s) => ({ ...s, total: s.total + 1 }));
|
// just marks the current problem as missed so the eventual solve counts as
|
||||||
|
// incorrect. This makes the score "problems solved / first-time right" and
|
||||||
|
// makes double-counting impossible.
|
||||||
|
if (res.status === "wrong") {
|
||||||
|
missedCurrentRef.current = true;
|
||||||
|
} else if (res.status === "solved" && !scoredCurrentRef.current) {
|
||||||
|
scoredCurrentRef.current = true;
|
||||||
|
const gotFirstTime = !missedCurrentRef.current;
|
||||||
|
setScore((s) => ({
|
||||||
|
correct: s.correct + (gotFirstTime ? 1 : 0),
|
||||||
|
total: s.total + 1,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enter key: check the answer, or move to the next problem once it's solved.
|
||||||
|
function submit() {
|
||||||
|
if (isFullyCorrect) generateNew(difficulty);
|
||||||
|
else checkAnswer();
|
||||||
|
}
|
||||||
|
|
||||||
function nextHint() {
|
function nextHint() {
|
||||||
if (!showHint) {
|
if (!showHint) {
|
||||||
setShowHint(true);
|
setShowHint(true);
|
||||||
@@ -210,6 +238,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
|||||||
denominator={userAnswer.denominator || ""}
|
denominator={userAnswer.denominator || ""}
|
||||||
onNumeratorChange={(v) => setUserAnswer((a) => ({ ...a, numerator: v }))}
|
onNumeratorChange={(v) => setUserAnswer((a) => ({ ...a, numerator: v }))}
|
||||||
onDenominatorChange={(v) => setUserAnswer((a) => ({ ...a, denominator: v }))}
|
onDenominatorChange={(v) => setUserAnswer((a) => ({ ...a, denominator: v }))}
|
||||||
|
onSubmit={submit}
|
||||||
disabled={isFullyCorrect}
|
disabled={isFullyCorrect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -218,6 +247,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
|||||||
<DecimalInput
|
<DecimalInput
|
||||||
value={userAnswer.value || ""}
|
value={userAnswer.value || ""}
|
||||||
onChange={(v) => setUserAnswer((a) => ({ ...a, value: v }))}
|
onChange={(v) => setUserAnswer((a) => ({ ...a, value: v }))}
|
||||||
|
onSubmit={submit}
|
||||||
disabled={isFullyCorrect}
|
disabled={isFullyCorrect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -284,7 +314,14 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
|||||||
<Button variant="secondary" size="sm" onClick={nextHint}>
|
<Button variant="secondary" size="sm" onClick={nextHint}>
|
||||||
Hint
|
Hint
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowSolution(true)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
missedCurrentRef.current = true;
|
||||||
|
setShowSolution(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
Show Solution
|
Show Solution
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -304,17 +341,17 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className={`rounded-xl px-4 py-3 text-center text-sm font-medium ${
|
className={`rounded-xl px-4 py-3 text-center text-sm font-medium ${
|
||||||
result.correct
|
result.status === "solved"
|
||||||
? result.simplified
|
|
||||||
? "bg-correct-light text-correct"
|
? "bg-correct-light text-correct"
|
||||||
: "bg-hint-light text-hint"
|
: result.status === "unsimplified"
|
||||||
|
? "bg-hint-light text-hint"
|
||||||
: "bg-incorrect-light text-incorrect"
|
: "bg-incorrect-light text-incorrect"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{result.correct
|
{result.status === "solved"
|
||||||
? result.simplified
|
|
||||||
? "Correct!"
|
? "Correct!"
|
||||||
: "Correct, but can you simplify further?"
|
: result.status === "unsimplified"
|
||||||
|
? "Correct, but can you simplify further?"
|
||||||
: result.message}
|
: result.message}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { type HTMLAttributes } from "react";
|
import { type HTMLAttributes } from "react";
|
||||||
|
|
||||||
type BadgeVariant = "default" | "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6";
|
type BadgeVariant = "default" | "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6" | "unit-7" | "unit-8";
|
||||||
|
|
||||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||||
variant?: BadgeVariant;
|
variant?: BadgeVariant;
|
||||||
@@ -15,6 +15,8 @@ const variantStyles: Record<BadgeVariant, string> = {
|
|||||||
"unit-4": "border border-unit-4/20 bg-unit-4-light text-unit-4-dark",
|
"unit-4": "border border-unit-4/20 bg-unit-4-light text-unit-4-dark",
|
||||||
"unit-5": "border border-unit-5/20 bg-unit-5-light text-unit-5-dark",
|
"unit-5": "border border-unit-5/20 bg-unit-5-light text-unit-5-dark",
|
||||||
"unit-6": "border border-unit-6/20 bg-unit-6-light text-unit-6-dark",
|
"unit-6": "border border-unit-6/20 bg-unit-6-light text-unit-6-dark",
|
||||||
|
"unit-7": "border border-unit-7/20 bg-unit-7-light text-unit-7-dark",
|
||||||
|
"unit-8": "border border-unit-8/20 bg-unit-8-light text-unit-8-dark",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Badge({ variant = "default", className, children, ...props }: BadgeProps) {
|
export function Badge({ variant = "default", className, children, ...props }: BadgeProps) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { type ButtonHTMLAttributes } from "react";
|
import { type ButtonHTMLAttributes } from "react";
|
||||||
|
|
||||||
type ButtonVariant = "primary" | "secondary" | "ghost" | "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5";
|
type ButtonVariant = "primary" | "secondary" | "ghost" | "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6" | "unit-7" | "unit-8";
|
||||||
type ButtonSize = "sm" | "md" | "lg";
|
type ButtonSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
@@ -18,6 +18,9 @@ const variantStyles: Record<ButtonVariant, string> = {
|
|||||||
"unit-3": "bg-unit-3 text-white shadow-[var(--shadow-sm)] hover:bg-unit-3-dark hover:shadow-[var(--shadow-lg)]",
|
"unit-3": "bg-unit-3 text-white shadow-[var(--shadow-sm)] hover:bg-unit-3-dark hover:shadow-[var(--shadow-lg)]",
|
||||||
"unit-4": "bg-unit-4 text-white shadow-[var(--shadow-sm)] hover:bg-unit-4-dark hover:shadow-[var(--shadow-lg)]",
|
"unit-4": "bg-unit-4 text-white shadow-[var(--shadow-sm)] hover:bg-unit-4-dark hover:shadow-[var(--shadow-lg)]",
|
||||||
"unit-5": "bg-unit-5 text-white shadow-[var(--shadow-sm)] hover:bg-unit-5-dark hover:shadow-[var(--shadow-lg)]",
|
"unit-5": "bg-unit-5 text-white shadow-[var(--shadow-sm)] hover:bg-unit-5-dark hover:shadow-[var(--shadow-lg)]",
|
||||||
|
"unit-6": "bg-unit-6 text-white shadow-[var(--shadow-sm)] hover:bg-unit-6-dark hover:shadow-[var(--shadow-lg)]",
|
||||||
|
"unit-7": "bg-unit-7 text-white shadow-[var(--shadow-sm)] hover:bg-unit-7-dark hover:shadow-[var(--shadow-lg)]",
|
||||||
|
"unit-8": "bg-unit-8 text-white shadow-[var(--shadow-sm)] hover:bg-unit-8-dark hover:shadow-[var(--shadow-lg)]",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeStyles: Record<ButtonSize, string> = {
|
const sizeStyles: Record<ButtonSize, string> = {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { type HTMLAttributes } from "react";
|
import { type HTMLAttributes } from "react";
|
||||||
|
|
||||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
accent?: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6";
|
accent?: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6" | "unit-7" | "unit-8";
|
||||||
hover?: boolean;
|
hover?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ const accentStyles = {
|
|||||||
"unit-4": "border-l-4 border-l-unit-4",
|
"unit-4": "border-l-4 border-l-unit-4",
|
||||||
"unit-5": "border-l-4 border-l-unit-5",
|
"unit-5": "border-l-4 border-l-unit-5",
|
||||||
"unit-6": "border-l-4 border-l-unit-6",
|
"unit-6": "border-l-4 border-l-unit-6",
|
||||||
|
"unit-7": "border-l-4 border-l-unit-7",
|
||||||
|
"unit-8": "border-l-4 border-l-unit-8",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Card({
|
export function Card({
|
||||||
|
|||||||
29
components/ui/client-only.tsx
Normal file
29
components/ui/client-only.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSyncExternalStore, type ReactNode } from "react";
|
||||||
|
|
||||||
|
const emptySubscribe = () => () => {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders its children only after hydration on the client. Use this to wrap UI
|
||||||
|
* whose initial render is non-deterministic (e.g. seeded with Math.random()),
|
||||||
|
* which would otherwise cause a server/client hydration mismatch. The
|
||||||
|
* `fallback` is shown during SSR and the first (hydrating) client render.
|
||||||
|
*
|
||||||
|
* Uses useSyncExternalStore rather than a mount effect so there is no
|
||||||
|
* setState-in-effect and the server/client snapshots stay explicit.
|
||||||
|
*/
|
||||||
|
export function ClientOnly({
|
||||||
|
children,
|
||||||
|
fallback = null,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const isServer = useSyncExternalStore(
|
||||||
|
emptySubscribe,
|
||||||
|
() => false,
|
||||||
|
() => true,
|
||||||
|
);
|
||||||
|
return <>{isServer ? fallback : children}</>;
|
||||||
|
}
|
||||||
@@ -6,11 +6,11 @@ export interface Topic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Unit {
|
export interface Unit {
|
||||||
number: 1 | 2 | 3 | 4 | 5 | 6;
|
number: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
color: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6";
|
color: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6" | "unit-7" | "unit-8";
|
||||||
topics: Topic[];
|
topics: Topic[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +160,12 @@ export const curriculum: Unit[] = [
|
|||||||
description: "Add, subtract, multiply, divide integers and apply to real-world problems",
|
description: "Add, subtract, multiply, divide integers and apply to real-world problems",
|
||||||
color: "unit-5",
|
color: "unit-5",
|
||||||
topics: [
|
topics: [
|
||||||
|
{
|
||||||
|
slug: "order-compare",
|
||||||
|
title: "Order and Compare Integers",
|
||||||
|
shortTitle: "Order & Compare",
|
||||||
|
description: "Place integers on a number line, order them, and compare with < and >",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
slug: "add-subtract",
|
slug: "add-subtract",
|
||||||
title: "Add and Subtract Integers",
|
title: "Add and Subtract Integers",
|
||||||
@@ -172,6 +178,12 @@ export const curriculum: Unit[] = [
|
|||||||
shortTitle: "Multiply & Divide",
|
shortTitle: "Multiply & Divide",
|
||||||
description: "Sign rules for multiplication and division with real-world thermometer problems",
|
description: "Sign rules for multiplication and division with real-world thermometer problems",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: "negative-fractions",
|
||||||
|
title: "Negative Fractions",
|
||||||
|
shortTitle: "Negative Fractions",
|
||||||
|
description: "Apply the sign rules to add, subtract, multiply and divide fractions with negative signs",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -181,6 +193,12 @@ export const curriculum: Unit[] = [
|
|||||||
description: "Explore how numbers can be written in different bases, beyond the everyday denary system",
|
description: "Explore how numbers can be written in different bases, beyond the everyday denary system",
|
||||||
color: "unit-6",
|
color: "unit-6",
|
||||||
topics: [
|
topics: [
|
||||||
|
{
|
||||||
|
slug: "place-value",
|
||||||
|
title: "Denary Place Value (Base 10)",
|
||||||
|
shortTitle: "Place Value",
|
||||||
|
description: "Powers of 10 and writing numbers in expanded form",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
slug: "binary",
|
slug: "binary",
|
||||||
title: "Binary Numbers",
|
title: "Binary Numbers",
|
||||||
@@ -195,6 +213,54 @@ export const curriculum: Unit[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
number: 7,
|
||||||
|
slug: "unit-7-coordinates",
|
||||||
|
title: "Coordinates",
|
||||||
|
description: "Plot points and read coordinates on the Cartesian plane",
|
||||||
|
color: "unit-7",
|
||||||
|
topics: [
|
||||||
|
{
|
||||||
|
slug: "plot-points",
|
||||||
|
title: "Plot Points on the Cartesian Plane",
|
||||||
|
shortTitle: "Plot Points",
|
||||||
|
description: "Use x and y coordinates to place points and reveal hidden shapes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "read-coordinates",
|
||||||
|
title: "State Coordinates of Points",
|
||||||
|
shortTitle: "Read Coordinates",
|
||||||
|
description: "Read off the (x, y) coordinates of points in all four quadrants",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 8,
|
||||||
|
slug: "unit-8-number-properties",
|
||||||
|
title: "Number Properties",
|
||||||
|
description: "Identity and inverse, the laws of arithmetic, and powers of numbers",
|
||||||
|
color: "unit-8",
|
||||||
|
topics: [
|
||||||
|
{
|
||||||
|
slug: "identity-inverse",
|
||||||
|
title: "Identity and Inverse",
|
||||||
|
shortTitle: "Identity & Inverse",
|
||||||
|
description: "Identity for addition and multiplication, inverses, and the zero facts",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "number-laws",
|
||||||
|
title: "Commutative, Associative and Distributive Laws",
|
||||||
|
shortTitle: "Number Laws",
|
||||||
|
description: "Which operations can be swapped, regrouped, and expanded",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "exponents",
|
||||||
|
title: "Powers and Exponents",
|
||||||
|
shortTitle: "Exponents",
|
||||||
|
description: "Base and power, expanding powers, and the index laws",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getUnit(slug: string): Unit | undefined {
|
export function getUnit(slug: string): Unit | undefined {
|
||||||
@@ -206,7 +272,7 @@ export function getTopic(unitSlug: string, topicSlug: string): Topic | undefined
|
|||||||
return unit?.topics.find((t) => t.slug === topicSlug);
|
return unit?.topics.find((t) => t.slug === topicSlug);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUnitColor(unitNumber: 1 | 2 | 3 | 4 | 5 | 6): string {
|
export function getUnitColor(unitNumber: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8): string {
|
||||||
const colors = {
|
const colors = {
|
||||||
1: "unit-1",
|
1: "unit-1",
|
||||||
2: "unit-2",
|
2: "unit-2",
|
||||||
@@ -214,6 +280,8 @@ export function getUnitColor(unitNumber: 1 | 2 | 3 | 4 | 5 | 6): string {
|
|||||||
4: "unit-4",
|
4: "unit-4",
|
||||||
5: "unit-5",
|
5: "unit-5",
|
||||||
6: "unit-6",
|
6: "unit-6",
|
||||||
|
7: "unit-7",
|
||||||
|
8: "unit-8",
|
||||||
};
|
};
|
||||||
return colors[unitNumber];
|
return colors[unitNumber];
|
||||||
}
|
}
|
||||||
|
|||||||
94
lib/math/fractions.test.ts
Normal file
94
lib/math/fractions.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { gcd, lcm, Fraction } from "./fractions";
|
||||||
|
|
||||||
|
const f = (n: number, d: number) => new Fraction(n, d);
|
||||||
|
|
||||||
|
describe("gcd / lcm", () => {
|
||||||
|
it("computes gcd", () => {
|
||||||
|
expect(gcd(12, 8)).toBe(4);
|
||||||
|
expect(gcd(7, 3)).toBe(1);
|
||||||
|
expect(gcd(0, 5)).toBe(5);
|
||||||
|
expect(gcd(-12, 8)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes lcm", () => {
|
||||||
|
expect(lcm(4, 6)).toBe(12);
|
||||||
|
expect(lcm(3, 5)).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Fraction sign + simplify", () => {
|
||||||
|
it("keeps the sign on the numerator", () => {
|
||||||
|
expect([f(2, -4).n, f(2, -4).d]).toEqual([-2, 4]);
|
||||||
|
expect([f(-2, -4).n, f(-2, -4).d]).toEqual([2, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reduces to lowest terms", () => {
|
||||||
|
const check = (n: number, d: number, en: number, ed: number) => {
|
||||||
|
const s = f(n, d).simplified();
|
||||||
|
expect([s.n, s.d]).toEqual([en, ed]);
|
||||||
|
};
|
||||||
|
check(5, 10, 1, 2);
|
||||||
|
check(8, 12, 2, 3);
|
||||||
|
check(3, 1, 3, 1);
|
||||||
|
check(2, -4, -1, 2);
|
||||||
|
check(-2, -4, 1, 2);
|
||||||
|
check(0, 5, 0, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports whether it is already simplified", () => {
|
||||||
|
expect(f(1, 2).isSimplified).toBe(true);
|
||||||
|
expect(f(5, 10).isSimplified).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Fraction arithmetic (raw, then .simplified())", () => {
|
||||||
|
const s = (fr: Fraction) => [fr.simplified().n, fr.simplified().d];
|
||||||
|
|
||||||
|
it("adds", () => {
|
||||||
|
expect(s(f(1, 2).plus(f(1, 2)))).toEqual([1, 1]);
|
||||||
|
expect(s(f(2, 10).plus(f(3, 10)))).toEqual([1, 2]);
|
||||||
|
expect(s(f(1, 3).plus(f(1, 4)))).toEqual([7, 12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts", () => {
|
||||||
|
expect(s(f(7, 8).minus(f(6, 8)))).toEqual([1, 8]);
|
||||||
|
expect(s(f(1, 2).minus(f(1, 2)))).toEqual([0, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiplies", () => {
|
||||||
|
expect(s(f(2, 3).times(f(3, 4)))).toEqual([1, 2]);
|
||||||
|
expect(s(f(1, 2).times(f(1, 2)))).toEqual([1, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("divides (invert and multiply)", () => {
|
||||||
|
expect(s(f(1, 2).dividedBy(f(1, 4)))).toEqual([2, 1]);
|
||||||
|
expect(s(f(3, 4).dividedBy(f(1, 2)))).toEqual([3, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles negative fractions with the sign rules", () => {
|
||||||
|
expect(s(f(-1, 2).plus(f(3, 4)))).toEqual([1, 4]); // -1/2 + 3/4
|
||||||
|
expect(s(f(1, 2).minus(f(-3, 4)))).toEqual([5, 4]); // subtracting a negative adds
|
||||||
|
expect(s(f(-2, 3).times(f(1, 4)))).toEqual([-1, 6]); // different signs -> negative
|
||||||
|
expect(s(f(-2, 3).times(f(-3, 5)))).toEqual([2, 5]); // same signs -> positive
|
||||||
|
expect(s(f(-1, 2).dividedBy(f(1, 4)))).toEqual([-2, 1]); // -1/2 ÷ 1/4
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Fraction presentation + equality", () => {
|
||||||
|
it("renders KaTeX, collapsing whole numbers", () => {
|
||||||
|
expect(f(1, 2).toKatex()).toBe("\\frac{1}{2}");
|
||||||
|
expect(f(3, 1).toKatex()).toBe("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders signed KaTeX with the sign in front", () => {
|
||||||
|
expect(f(-3, 4).toSignedKatex()).toBe("-\\frac{3}{4}");
|
||||||
|
expect(f(3, 4).toSignedKatex()).toBe("\\frac{3}{4}");
|
||||||
|
expect(f(-5, 1).toSignedKatex()).toBe("-5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares by value, not written form", () => {
|
||||||
|
expect(f(5, 10).equals(f(1, 2))).toBe(true);
|
||||||
|
expect(f(1, 2).equals(f(1, 3))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,97 +11,100 @@ export function lcm(a: number, b: number): number {
|
|||||||
return Math.abs(a * b) / gcd(a, b);
|
return Math.abs(a * b) / gcd(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function simplify(num: number, den: number): [number, number] {
|
/**
|
||||||
if (den === 0) return [num, den];
|
* An immutable fraction. Sign is kept on the numerator; arithmetic returns a new
|
||||||
const g = gcd(Math.abs(num), Math.abs(den));
|
* Fraction and does NOT auto-simplify — call `.simplified()` when you want lowest
|
||||||
const sign = den < 0 ? -1 : 1;
|
* terms (this lets teaching views show the un-reduced working). Prefer this over
|
||||||
return [(num / g) * sign, (den / g) * sign];
|
* passing `(num, den)` pairs around: positional pairs are easy to mis-order.
|
||||||
|
*/
|
||||||
|
export class Fraction {
|
||||||
|
readonly n: number;
|
||||||
|
readonly d: number;
|
||||||
|
|
||||||
|
constructor(n: number, d: number) {
|
||||||
|
// Canonical sign: keep it on the numerator. A zero denominator is tolerated
|
||||||
|
// (kept as-is) so divide-by-zero in a lesson renders rather than throwing.
|
||||||
|
if (d < 0) {
|
||||||
|
n = -n;
|
||||||
|
d = -d;
|
||||||
|
}
|
||||||
|
this.n = n;
|
||||||
|
this.d = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
static of(n: number, d: number): Fraction {
|
||||||
|
return new Fraction(n, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isZero(): boolean {
|
||||||
|
return this.n === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
plus(o: Fraction): Fraction {
|
||||||
|
return new Fraction(this.n * o.d + o.n * this.d, this.d * o.d);
|
||||||
|
}
|
||||||
|
|
||||||
|
minus(o: Fraction): Fraction {
|
||||||
|
return new Fraction(this.n * o.d - o.n * this.d, this.d * o.d);
|
||||||
|
}
|
||||||
|
|
||||||
|
times(o: Fraction): Fraction {
|
||||||
|
return new Fraction(this.n * o.n, this.d * o.d);
|
||||||
|
}
|
||||||
|
|
||||||
|
dividedBy(o: Fraction): Fraction {
|
||||||
|
return new Fraction(this.n * o.d, this.d * o.n);
|
||||||
|
}
|
||||||
|
|
||||||
|
simplified(): Fraction {
|
||||||
|
if (this.d === 0) return this;
|
||||||
|
if (this.n === 0) return new Fraction(0, 1);
|
||||||
|
const g = gcd(Math.abs(this.n), Math.abs(this.d));
|
||||||
|
return new Fraction(this.n / g, this.d / g);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSimplified(): boolean {
|
||||||
|
return gcd(Math.abs(this.n), Math.abs(this.d)) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Value equality (cross-multiplication), independent of the written form. */
|
||||||
|
equals(o: Fraction): boolean {
|
||||||
|
return this.n * o.d === o.n * this.d;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDecimal(): number {
|
||||||
|
return this.n / this.d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [whole, remainderNumerator, denominator] */
|
||||||
|
toMixed(): [number, number, number] {
|
||||||
|
const whole = Math.trunc(this.n / this.d);
|
||||||
|
const remainder = Math.abs(this.n % this.d);
|
||||||
|
return [whole, remainder, this.d];
|
||||||
|
}
|
||||||
|
|
||||||
|
toKatex(): string {
|
||||||
|
if (this.d === 1) return `${this.n}`;
|
||||||
|
return `\\frac{${this.n}}{${this.d}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like toKatex, but writes negatives with the sign in front: -\frac{3}{4} rather than \frac{-3}{4}. */
|
||||||
|
toSignedKatex(): string {
|
||||||
|
if (this.d === 1) return `${this.n}`;
|
||||||
|
const sign = this.n < 0 ? "-" : "";
|
||||||
|
return `${sign}\\frac{${Math.abs(this.n)}}{${this.d}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.d === 1 ? `${this.n}` : `${this.n}/${this.d}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function add(
|
/**
|
||||||
n1: number, d1: number,
|
* Convenience KaTeX formatter for raw (num, den) pairs. Handy in the step-by-step
|
||||||
n2: number, d2: number,
|
* lesson views, which build up strings from loose numerators/denominators rather
|
||||||
): [number, number] {
|
* than Fraction objects. For arithmetic, use the Fraction class instead.
|
||||||
const num = n1 * d2 + n2 * d1;
|
*/
|
||||||
const den = d1 * d2;
|
export function toKatex(n: number, d: number): string {
|
||||||
return simplify(num, den);
|
return new Fraction(n, d).toKatex();
|
||||||
}
|
|
||||||
|
|
||||||
export function subtract(
|
|
||||||
n1: number, d1: number,
|
|
||||||
n2: number, d2: number,
|
|
||||||
): [number, number] {
|
|
||||||
const num = n1 * d2 - n2 * d1;
|
|
||||||
const den = d1 * d2;
|
|
||||||
return simplify(num, den);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function multiply(
|
|
||||||
n1: number, d1: number,
|
|
||||||
n2: number, d2: number,
|
|
||||||
): [number, number] {
|
|
||||||
return simplify(n1 * n2, d1 * d2);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function divide(
|
|
||||||
n1: number, d1: number,
|
|
||||||
n2: number, d2: number,
|
|
||||||
): [number, number] {
|
|
||||||
return simplify(n1 * d2, d1 * n2);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toDecimal(num: number, den: number): number {
|
|
||||||
return num / den;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromDecimal(decimal: number, precision: number = 6): [number, number] {
|
|
||||||
const str = decimal.toFixed(precision);
|
|
||||||
const parts = str.split(".");
|
|
||||||
if (!parts[1]) return [parseInt(parts[0]), 1];
|
|
||||||
const den = Math.pow(10, parts[1].length);
|
|
||||||
const num = Math.round(decimal * den);
|
|
||||||
return simplify(num, den);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compare(
|
|
||||||
n1: number, d1: number,
|
|
||||||
n2: number, d2: number,
|
|
||||||
): -1 | 0 | 1 {
|
|
||||||
const diff = n1 * d2 - n2 * d1;
|
|
||||||
if (diff > 0) return 1;
|
|
||||||
if (diff < 0) return -1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fractionOfQuantity(num: number, den: number, quantity: number): number {
|
|
||||||
return (num / den) * quantity;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function wholeFromFraction(num: number, den: number, part: number): number {
|
|
||||||
return (part * den) / num;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isProper(num: number, den: number): boolean {
|
|
||||||
return Math.abs(num) < Math.abs(den);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toMixed(num: number, den: number): [number, number, number] {
|
|
||||||
const whole = Math.floor(Math.abs(num) / Math.abs(den));
|
|
||||||
const remainder = Math.abs(num) % Math.abs(den);
|
|
||||||
const sign = (num < 0) !== (den < 0) ? -1 : 1;
|
|
||||||
return [whole * sign, remainder, Math.abs(den)];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromMixed(whole: number, num: number, den: number): [number, number] {
|
|
||||||
const sign = whole < 0 ? -1 : 1;
|
|
||||||
return [sign * (Math.abs(whole) * den + num), den];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSimplified(num: number, den: number): boolean {
|
|
||||||
return gcd(Math.abs(num), Math.abs(den)) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toKatex(num: number, den: number): string {
|
|
||||||
if (den === 1) return `${num}`;
|
|
||||||
return `\\frac{${num}}{${den}}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
73
lib/math/validation.test.ts
Normal file
73
lib/math/validation.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
checkFractionAnswer,
|
||||||
|
checkIntegerAnswer,
|
||||||
|
checkDecimalAnswer,
|
||||||
|
checkRatioAnswer,
|
||||||
|
checkOrderingAnswer,
|
||||||
|
} from "./validation";
|
||||||
|
|
||||||
|
describe("checkIntegerAnswer", () => {
|
||||||
|
it("solves an exact match", () => {
|
||||||
|
expect(checkIntegerAnswer(6, 6).status).toBe("solved");
|
||||||
|
});
|
||||||
|
it("rejects a wrong value", () => {
|
||||||
|
expect(checkIntegerAnswer(5, 6).status).toBe("wrong");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkFractionAnswer", () => {
|
||||||
|
it("solves a fully simplified answer", () => {
|
||||||
|
expect(checkFractionAnswer(1, 2, 1, 2).status).toBe("solved");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: 5/10 is the right value but unsimplified — it must NOT be treated
|
||||||
|
// as fully solved (this is what let the score double-count before the fix).
|
||||||
|
it("flags a correct but unsimplified answer", () => {
|
||||||
|
expect(checkFractionAnswer(5, 10, 1, 2).status).toBe("unsimplified");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts any non-zero denominator for zero", () => {
|
||||||
|
expect(checkFractionAnswer(0, 5, 0, 1).status).toBe("solved");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a wrong value", () => {
|
||||||
|
expect(checkFractionAnswer(3, 4, 1, 8).status).toBe("wrong");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a zero denominator", () => {
|
||||||
|
const res = checkFractionAnswer(1, 0, 1, 2);
|
||||||
|
expect(res.status).toBe("wrong");
|
||||||
|
if (res.status === "wrong") expect(res.message).toMatch(/denominator/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkDecimalAnswer", () => {
|
||||||
|
it("solves within tolerance", () => {
|
||||||
|
expect(checkDecimalAnswer(3.14, 3.14).status).toBe("solved");
|
||||||
|
});
|
||||||
|
it("rejects outside tolerance", () => {
|
||||||
|
expect(checkDecimalAnswer(3.2, 3.14).status).toBe("wrong");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkRatioAnswer", () => {
|
||||||
|
it("solves an already-simplified ratio", () => {
|
||||||
|
expect(checkRatioAnswer([1, 2], [1, 2]).status).toBe("solved");
|
||||||
|
});
|
||||||
|
it("flags an equivalent but unsimplified ratio", () => {
|
||||||
|
expect(checkRatioAnswer([2, 4], [1, 2]).status).toBe("unsimplified");
|
||||||
|
});
|
||||||
|
it("rejects a non-equivalent ratio", () => {
|
||||||
|
expect(checkRatioAnswer([1, 3], [1, 2]).status).toBe("wrong");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkOrderingAnswer", () => {
|
||||||
|
it("solves a correct ascending order", () => {
|
||||||
|
expect(checkOrderingAnswer([-10, -8, -6, -4, -3, 5], [-10, -8, -6, -4, -3, 5]).status).toBe("solved");
|
||||||
|
});
|
||||||
|
it("rejects a wrong order", () => {
|
||||||
|
expect(checkOrderingAnswer([5, -10], [-10, 5]).status).toBe("wrong");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { gcd } from "./fractions";
|
import { Fraction } from "./fractions";
|
||||||
import { simplifyRatio } from "./ratios";
|
import { simplifyRatio } from "./ratios";
|
||||||
|
|
||||||
/** Parse a strict decimal string (digits and optional single dot). Rejects scientific notation, trailing letters, etc. */
|
/** Parse a strict decimal string (digits and optional single dot). Rejects scientific notation, trailing letters, etc. */
|
||||||
@@ -29,9 +29,25 @@ export function parseStrictSignedDecimal(input: string): number | null {
|
|||||||
return parseFloat(s);
|
return parseFloat(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outcome of checking one submitted answer.
|
||||||
|
* - `solved` — correct and in the required form (fully done)
|
||||||
|
* - `unsimplified` — correct value, but still needs simplifying (a nudge, not done)
|
||||||
|
* - `wrong` — incorrect; `message` explains why
|
||||||
|
*
|
||||||
|
* A single `status` field means callers branch on one thing, instead of an
|
||||||
|
* error-prone `correct`/`simplified` boolean pair.
|
||||||
|
*/
|
||||||
export type AnswerResult =
|
export type AnswerResult =
|
||||||
| { correct: true; simplified: boolean }
|
| { status: "solved" }
|
||||||
| { correct: false; message: string };
|
| { status: "unsimplified" }
|
||||||
|
| { status: "wrong"; message: string };
|
||||||
|
|
||||||
|
export const SOLVED: AnswerResult = { status: "solved" };
|
||||||
|
export const UNSIMPLIFIED: AnswerResult = { status: "unsimplified" };
|
||||||
|
export function wrong(message: string): AnswerResult {
|
||||||
|
return { status: "wrong", message };
|
||||||
|
}
|
||||||
|
|
||||||
export function checkFractionAnswer(
|
export function checkFractionAnswer(
|
||||||
userNum: number,
|
userNum: number,
|
||||||
@@ -41,28 +57,26 @@ export function checkFractionAnswer(
|
|||||||
requireSimplified: boolean = true,
|
requireSimplified: boolean = true,
|
||||||
): AnswerResult {
|
): AnswerResult {
|
||||||
if (userDen === 0) {
|
if (userDen === 0) {
|
||||||
return { correct: false, message: "Denominator cannot be zero" };
|
return wrong("Denominator cannot be zero");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check mathematical equivalence
|
const user = new Fraction(userNum, userDen);
|
||||||
const isEquivalent = userNum * expectedDen === expectedNum * userDen;
|
const expected = new Fraction(expectedNum, expectedDen);
|
||||||
|
|
||||||
if (!isEquivalent) {
|
if (!user.equals(expected)) {
|
||||||
return { correct: false, message: "That's not quite right. Try again!" };
|
return wrong("That's not quite right. Try again!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any non-zero denominator gives the same value for zero, so don't force 0/1 in the UI.
|
// Any non-zero denominator gives the same value for zero, so don't force 0/1 in the UI.
|
||||||
if (userNum === 0) {
|
if (user.isZero) {
|
||||||
return { correct: true, simplified: true };
|
return SOLVED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSimplified = gcd(Math.abs(userNum), Math.abs(userDen)) === 1;
|
if (requireSimplified && !user.isSimplified) {
|
||||||
|
return UNSIMPLIFIED;
|
||||||
if (requireSimplified && !isSimplified) {
|
|
||||||
return { correct: true, simplified: false };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { correct: true, simplified: true };
|
return SOLVED;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkDecimalAnswer(
|
export function checkDecimalAnswer(
|
||||||
@@ -71,9 +85,9 @@ export function checkDecimalAnswer(
|
|||||||
tolerance: number = 0.001,
|
tolerance: number = 0.001,
|
||||||
): AnswerResult {
|
): AnswerResult {
|
||||||
if (Math.abs(userValue - expectedValue) <= tolerance) {
|
if (Math.abs(userValue - expectedValue) <= tolerance) {
|
||||||
return { correct: true, simplified: true };
|
return SOLVED;
|
||||||
}
|
}
|
||||||
return { correct: false, message: "That's not quite right. Try again!" };
|
return wrong("That's not quite right. Try again!");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkRatioAnswer(
|
export function checkRatioAnswer(
|
||||||
@@ -82,34 +96,30 @@ export function checkRatioAnswer(
|
|||||||
requireSimplified: boolean = true,
|
requireSimplified: boolean = true,
|
||||||
): AnswerResult {
|
): AnswerResult {
|
||||||
if (userParts.length !== expectedParts.length) {
|
if (userParts.length !== expectedParts.length) {
|
||||||
return { correct: false, message: "Check the number of parts in your ratio" };
|
return wrong("Check the number of parts in your ratio");
|
||||||
}
|
}
|
||||||
|
|
||||||
const userSimplified = simplifyRatio(userParts);
|
const userSimplified = simplifyRatio(userParts);
|
||||||
const expectedSimplified = simplifyRatio(expectedParts);
|
const expectedSimplified = simplifyRatio(expectedParts);
|
||||||
|
|
||||||
const isEquivalent = userSimplified.every((v, i) => v === expectedSimplified[i]);
|
const isEquivalent = userSimplified.every((v, i) => v === expectedSimplified[i]);
|
||||||
|
|
||||||
if (!isEquivalent) {
|
if (!isEquivalent) {
|
||||||
return { correct: false, message: "That's not quite right. Try again!" };
|
return wrong("That's not quite right. Try again!");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requireSimplified) {
|
if (requireSimplified) {
|
||||||
const isAlreadySimplified = userParts.every((v, i) => v === userSimplified[i]);
|
const isAlreadySimplified = userParts.every((v, i) => v === userSimplified[i]);
|
||||||
return { correct: true, simplified: isAlreadySimplified };
|
return isAlreadySimplified ? SOLVED : UNSIMPLIFIED;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { correct: true, simplified: true };
|
return SOLVED;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkIntegerAnswer(
|
export function checkIntegerAnswer(
|
||||||
userValue: number,
|
userValue: number,
|
||||||
expectedValue: number,
|
expectedValue: number,
|
||||||
): AnswerResult {
|
): AnswerResult {
|
||||||
if (userValue === expectedValue) {
|
return userValue === expectedValue ? SOLVED : wrong("That's not quite right. Try again!");
|
||||||
return { correct: true, simplified: true };
|
|
||||||
}
|
|
||||||
return { correct: false, message: "That's not quite right. Try again!" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkOrderingAnswer(
|
export function checkOrderingAnswer(
|
||||||
@@ -117,11 +127,8 @@ export function checkOrderingAnswer(
|
|||||||
expectedOrder: number[],
|
expectedOrder: number[],
|
||||||
): AnswerResult {
|
): AnswerResult {
|
||||||
if (userOrder.length !== expectedOrder.length) {
|
if (userOrder.length !== expectedOrder.length) {
|
||||||
return { correct: false, message: "Make sure you've ordered all the numbers" };
|
return wrong("Make sure you've ordered all the numbers");
|
||||||
}
|
}
|
||||||
const isCorrect = userOrder.every((v, i) => v === expectedOrder[i]);
|
const isCorrect = userOrder.every((v, i) => v === expectedOrder[i]);
|
||||||
if (isCorrect) {
|
return isCorrect ? SOLVED : wrong("Check your ordering. Try again!");
|
||||||
return { correct: true, simplified: true };
|
|
||||||
}
|
|
||||||
return { correct: false, message: "Check your ordering. Try again!" };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { MathProblem, Difficulty } from "../types";
|
import type { MathProblem, Difficulty } from "../types";
|
||||||
import { randomInt, randomChoice } from "@/lib/utils";
|
import { randomInt, randomChoice } from "@/lib/utils";
|
||||||
import * as frac from "@/lib/math/fractions";
|
import { Fraction, lcm } from "@/lib/math/fractions";
|
||||||
|
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
function nextId() {
|
function nextId() {
|
||||||
@@ -35,13 +35,13 @@ export function generateFractionAddSubtract(difficulty: Difficulty): MathProblem
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prompt = `\\frac{${n1}}{${d1}} ${op} \\frac{${n2}}{${d2}}`;
|
const prompt = `\\frac{${n1}}{${d1}} ${op} \\frac{${n2}}{${d2}}`;
|
||||||
const commonDen = frac.lcm(d1, d2);
|
const commonDen = lcm(d1, d2);
|
||||||
const convertedN1 = n1 * (commonDen / d1);
|
const convertedN1 = n1 * (commonDen / d1);
|
||||||
const convertedN2 = n2 * (commonDen / d2);
|
const convertedN2 = n2 * (commonDen / d2);
|
||||||
const rawNum = operation === "add"
|
const rawNum = operation === "add"
|
||||||
? convertedN1 + convertedN2
|
? convertedN1 + convertedN2
|
||||||
: convertedN1 - convertedN2;
|
: convertedN1 - convertedN2;
|
||||||
const [ansNum, ansDen] = frac.simplify(rawNum, commonDen);
|
const { n: ansNum, d: ansDen } = new Fraction(rawNum, commonDen).simplified();
|
||||||
|
|
||||||
const steps = d1 === d2
|
const steps = d1 === d2
|
||||||
? [
|
? [
|
||||||
@@ -100,7 +100,7 @@ export function generateFractionMultiply(difficulty: Difficulty): MathProblem {
|
|||||||
const n1 = randomInt(1, d1 - 1);
|
const n1 = randomInt(1, d1 - 1);
|
||||||
const n2 = randomInt(1, d2 - 1);
|
const n2 = randomInt(1, d2 - 1);
|
||||||
|
|
||||||
const [ansNum, ansDen] = frac.multiply(n1, d1, n2, d2);
|
const { n: ansNum, d: ansDen } = Fraction.of(n1, d1).times(Fraction.of(n2, d2)).simplified();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
@@ -126,7 +126,7 @@ export function generateFractionDivide(difficulty: Difficulty): MathProblem {
|
|||||||
const n1 = randomInt(1, d1 - 1);
|
const n1 = randomInt(1, d1 - 1);
|
||||||
const n2 = randomInt(1, d2 - 1);
|
const n2 = randomInt(1, d2 - 1);
|
||||||
|
|
||||||
const [ansNum, ansDen] = frac.divide(n1, d1, n2, d2);
|
const { n: ansNum, d: ansDen } = Fraction.of(n1, d1).dividedBy(Fraction.of(n2, d2)).simplified();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
@@ -168,6 +168,150 @@ export function generateFractionOfQuantity(difficulty: Difficulty): MathProblem
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A proper-fraction numerator with a random sign, magnitude 1..den-1. */
|
||||||
|
function signedProperNum(den: number): number {
|
||||||
|
const mag = randomInt(1, Math.max(1, den - 1));
|
||||||
|
return Math.random() < 0.5 ? -mag : mag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two proper fractions where at least one is negative (this is the negative-fractions topic). */
|
||||||
|
function signedFractionPair(d1: number, d2: number): [Fraction, Fraction] {
|
||||||
|
let n1 = signedProperNum(d1);
|
||||||
|
let n2 = signedProperNum(d2);
|
||||||
|
if (n1 > 0 && n2 > 0) {
|
||||||
|
if (Math.random() < 0.5) n1 = -n1;
|
||||||
|
else n2 = -n2;
|
||||||
|
}
|
||||||
|
return [new Fraction(n1, d1), new Fraction(n2, d2)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedOperand(f: Fraction): string {
|
||||||
|
return f.n < 0 ? `\\left(${f.toSignedKatex()}\\right)` : f.toSignedKatex();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSignedFractionAddSubtract(difficulty: Difficulty): MathProblem {
|
||||||
|
const operation = randomChoice(["+", "-"] as const);
|
||||||
|
const dens = difficulty === 1 ? [2, 3, 4] : difficulty === 2 ? [2, 3, 4, 5, 6] : [3, 4, 5, 6, 8];
|
||||||
|
const d1 = randomChoice(dens);
|
||||||
|
const d2 = randomChoice(dens);
|
||||||
|
const [a, b] = signedFractionPair(d1, d2);
|
||||||
|
const result = (operation === "+" ? a.plus(b) : a.minus(b)).simplified();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${a.toSignedKatex()} ${operation} ${signedOperand(b)}`,
|
||||||
|
answer: { kind: "fraction", numerator: result.n, denominator: result.d },
|
||||||
|
hints: [
|
||||||
|
d1 === d2 ? "The denominators already match." : "Find a common denominator first.",
|
||||||
|
"Combine the numerators using the integer sign rules (subtracting a negative adds).",
|
||||||
|
"Simplify your answer.",
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
explanation: d1 === d2 ? `Keep the denominator ${d1}.` : `Use a common denominator: LCM(${d1}, ${d2}) = ${lcm(d1, d2)}.`,
|
||||||
|
},
|
||||||
|
{ explanation: `${operation === "+" ? "Add" : "Subtract"} the numerators, watching the signs.` },
|
||||||
|
{ explanation: "Simplify to lowest terms.", math: result.toSignedKatex() },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSignedFractionMultiplyDivide(difficulty: Difficulty): MathProblem {
|
||||||
|
const operation = randomChoice(["\\times", "\\div"] as const);
|
||||||
|
const dens = difficulty === 1 ? [2, 3, 4] : difficulty === 2 ? [2, 3, 4, 5] : [3, 4, 5, 6, 7];
|
||||||
|
const d1 = randomChoice(dens);
|
||||||
|
const d2 = randomChoice(dens);
|
||||||
|
const [a, b] = signedFractionPair(d1, d2);
|
||||||
|
const result = (operation === "\\times" ? a.times(b) : a.dividedBy(b)).simplified();
|
||||||
|
const sameSign = a.n < 0 === b.n < 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${a.toSignedKatex()} ${operation} ${signedOperand(b)}`,
|
||||||
|
answer: { kind: "fraction", numerator: result.n, denominator: result.d },
|
||||||
|
hints: [
|
||||||
|
operation === "\\div" ? "Keep, change, flip: multiply by the reciprocal." : "Multiply numerators and denominators.",
|
||||||
|
`The signs are ${sameSign ? "the same, so the answer is positive" : "different, so the answer is negative"}.`,
|
||||||
|
"Simplify your answer.",
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
operation === "\\div"
|
||||||
|
? { explanation: "Multiply by the reciprocal of the second fraction (keep, change, flip)." }
|
||||||
|
: { explanation: "Multiply the numerators and multiply the denominators." },
|
||||||
|
{ explanation: `Same signs give positive, different signs give negative — here the answer is ${result.n < 0 ? "negative" : "positive"}.` },
|
||||||
|
{ explanation: "Simplify to lowest terms.", math: result.toSignedKatex() },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateFractionBodmas(difficulty: Difficulty): MathProblem {
|
||||||
|
const dens = difficulty === 1 ? [2, 3, 4] : difficulty === 2 ? [2, 3, 4, 5] : [2, 3, 4, 5, 6];
|
||||||
|
const mk = () => {
|
||||||
|
const d = randomChoice(dens);
|
||||||
|
return new Fraction(randomInt(1, d - 1), d);
|
||||||
|
};
|
||||||
|
const f1 = mk(), f2 = mk(), f3 = mk();
|
||||||
|
const multFirst = Math.random() < 0.5;
|
||||||
|
|
||||||
|
let prompt: string;
|
||||||
|
let result: Fraction;
|
||||||
|
if (multFirst) {
|
||||||
|
const prod = f2.times(f3);
|
||||||
|
// avoid a negative answer in this (positive-fractions) unit
|
||||||
|
const op = f1.toDecimal() >= prod.toDecimal() && Math.random() < 0.5 ? "-" : "+";
|
||||||
|
result = (op === "+" ? f1.plus(prod) : f1.minus(prod)).simplified();
|
||||||
|
prompt = `${f1.toKatex()} ${op} ${f2.toKatex()} \\times ${f3.toKatex()}`;
|
||||||
|
} else {
|
||||||
|
const prod = f1.times(f2);
|
||||||
|
const op = prod.toDecimal() >= f3.toDecimal() && Math.random() < 0.5 ? "-" : "+";
|
||||||
|
result = (op === "+" ? prod.plus(f3) : prod.minus(f3)).simplified();
|
||||||
|
prompt = `${f1.toKatex()} \\times ${f2.toKatex()} ${op} ${f3.toKatex()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt,
|
||||||
|
answer: { kind: "fraction", numerator: result.n, denominator: result.d },
|
||||||
|
hints: [
|
||||||
|
"BODMAS: do the multiplication before the addition or subtraction.",
|
||||||
|
"Multiply the two fractions first, then combine with a common denominator.",
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ explanation: "Multiply first (that's the B-O-D-M before A-S)." },
|
||||||
|
{ explanation: "Then add or subtract, using a common denominator." },
|
||||||
|
{ explanation: "Simplify.", math: result.toKatex() },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combined signed-fraction practice covering +, -, x and / (Unit 5 topic). */
|
||||||
|
export function generateSignedFraction(difficulty: Difficulty): MathProblem {
|
||||||
|
const operation = randomChoice(["+", "-", "\\times", "\\div"] as const);
|
||||||
|
const dens = difficulty === 1 ? [2, 3, 4] : difficulty === 2 ? [2, 3, 4, 5] : [3, 4, 5, 6];
|
||||||
|
const [a, b] = signedFractionPair(randomChoice(dens), randomChoice(dens));
|
||||||
|
|
||||||
|
let result: Fraction;
|
||||||
|
if (operation === "+") result = a.plus(b);
|
||||||
|
else if (operation === "-") result = a.minus(b);
|
||||||
|
else if (operation === "\\times") result = a.times(b);
|
||||||
|
else result = a.dividedBy(b);
|
||||||
|
result = result.simplified();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${a.toSignedKatex()} ${operation} ${signedOperand(b)}`,
|
||||||
|
answer: { kind: "fraction", numerator: result.n, denominator: result.d },
|
||||||
|
hints: [
|
||||||
|
"Apply the integer sign rules as you work.",
|
||||||
|
"Give your answer in its simplest form.",
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ explanation: "Work through the operation, watching the signs." },
|
||||||
|
{ explanation: "Simplify.", math: result.toSignedKatex() },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function generateWholeFromFraction(difficulty: Difficulty): MathProblem {
|
export function generateWholeFromFraction(difficulty: Difficulty): MathProblem {
|
||||||
const denChoices = difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [3, 5, 6, 8] : [7, 8, 9, 12];
|
const denChoices = difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [3, 5, 6, 8] : [7, 8, 9, 12];
|
||||||
const den = randomChoice(denChoices);
|
const den = randomChoice(denChoices);
|
||||||
|
|||||||
72
lib/problems/generators/generators.test.ts
Normal file
72
lib/problems/generators/generators.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { Difficulty, MathProblem } from "../types";
|
||||||
|
import { generateFractionBodmas, generateSignedFraction } from "./fraction-problems";
|
||||||
|
import { generateDefineRatio, generateFractionsAndRatios } from "./ratio-problems";
|
||||||
|
import { generateIntegerOrderCompare, generateIntegerAddSubtractMixed } from "./integer-problems";
|
||||||
|
import { generateQuaternaryToDenary } from "./number-system-problems";
|
||||||
|
|
||||||
|
function assertWellFormed(p: MathProblem) {
|
||||||
|
expect(typeof p.prompt).toBe("string");
|
||||||
|
expect(p.prompt.length).toBeGreaterThan(0);
|
||||||
|
expect(Array.isArray(p.hints)).toBe(true);
|
||||||
|
expect(Array.isArray(p.steps)).toBe(true);
|
||||||
|
|
||||||
|
const a = p.answer;
|
||||||
|
if (a.kind === "integer") {
|
||||||
|
expect(Number.isFinite(a.value)).toBe(true);
|
||||||
|
} else if (a.kind === "fraction") {
|
||||||
|
expect(Number.isInteger(a.numerator)).toBe(true);
|
||||||
|
expect(Number.isInteger(a.denominator)).toBe(true);
|
||||||
|
expect(a.denominator).not.toBe(0);
|
||||||
|
} else if (a.kind === "ratio") {
|
||||||
|
expect(a.parts.length).toBeGreaterThan(0);
|
||||||
|
a.parts.forEach((v) => expect(Number.isFinite(v)).toBe(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEW_GENERATORS: Record<string, (d: Difficulty) => MathProblem> = {
|
||||||
|
"fraction BODMAS": generateFractionBodmas,
|
||||||
|
"signed fraction": generateSignedFraction,
|
||||||
|
"define ratio": generateDefineRatio,
|
||||||
|
"fractions and ratios": generateFractionsAndRatios,
|
||||||
|
"integer order/compare": generateIntegerOrderCompare,
|
||||||
|
"integer add/subtract (mixed)": generateIntegerAddSubtractMixed,
|
||||||
|
"quaternary to denary": generateQuaternaryToDenary,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("new practice generators produce well-formed problems", () => {
|
||||||
|
for (const [name, gen] of Object.entries(NEW_GENERATORS)) {
|
||||||
|
it(name, () => {
|
||||||
|
for (const d of [1, 2, 3] as Difficulty[]) {
|
||||||
|
for (let i = 0; i < 40; i++) assertWellFormed(gen(d));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("quaternary answer matches its base-4 prompt", () => {
|
||||||
|
it("converts base 4 correctly", () => {
|
||||||
|
for (const d of [1, 2, 3] as Difficulty[]) {
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const p = generateQuaternaryToDenary(d);
|
||||||
|
const digits = p.prompt.match(/^(\d+)_\{4\}/)?.[1];
|
||||||
|
expect(digits).toBeTruthy();
|
||||||
|
if (p.answer.kind === "integer") {
|
||||||
|
expect(p.answer.value).toBe(parseInt(digits!, 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("order/compare answer is one of the numbers asked about", () => {
|
||||||
|
it("picks a value from the prompt", () => {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const p = generateIntegerOrderCompare(1);
|
||||||
|
const nums = (p.prompt.match(/-?\d+/g) ?? []).map(Number);
|
||||||
|
if (p.answer.kind === "integer") {
|
||||||
|
expect(nums).toContain(p.answer.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
210
lib/problems/generators/integer-problems.ts
Normal file
210
lib/problems/generators/integer-problems.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import type { MathProblem, Difficulty } from "../types";
|
||||||
|
import { randomInt, randomChoice } from "@/lib/utils";
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
function nextId() {
|
||||||
|
return `int-${++counter}-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap negatives in parentheses when they follow an operator, e.g. 5 + (-3). */
|
||||||
|
function signed(n: number): string {
|
||||||
|
return n < 0 ? `(${n})` : `${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateIntegerAddSubtract(difficulty: Difficulty): MathProblem {
|
||||||
|
if (difficulty === 3) {
|
||||||
|
const a = randomInt(-12, 12);
|
||||||
|
const b = randomInt(-12, 12);
|
||||||
|
const c = randomInt(-12, 12);
|
||||||
|
const op1 = randomChoice(["+", "-"] as const);
|
||||||
|
const op2 = randomChoice(["+", "-"] as const);
|
||||||
|
const partial = op1 === "+" ? a + b : a - b;
|
||||||
|
const value = op2 === "+" ? partial + c : partial - c;
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${a} ${op1} ${signed(b)} ${op2} ${signed(c)}`,
|
||||||
|
answer: { kind: "integer", value },
|
||||||
|
hints: [
|
||||||
|
"Work from left to right, one operation at a time.",
|
||||||
|
"Subtracting a negative is the same as adding a positive.",
|
||||||
|
`First: ${a} ${op1} ${signed(b)} = ${partial}`,
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ explanation: `Work left to right. First: ${a} ${op1} ${signed(b)} = ${partial}` },
|
||||||
|
{ explanation: `Then: ${partial} ${op2} ${signed(c)} = ${value}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = difficulty === 1 ? 10 : 20;
|
||||||
|
const a = randomInt(-range, range);
|
||||||
|
const b = randomInt(-range, range);
|
||||||
|
const op = randomChoice(["+", "-"] as const);
|
||||||
|
const value = op === "+" ? a + b : a - b;
|
||||||
|
|
||||||
|
const rule =
|
||||||
|
op === "+"
|
||||||
|
? b < 0
|
||||||
|
? "Adding a negative means moving left — the same as subtracting."
|
||||||
|
: "Adding a positive means moving right."
|
||||||
|
: b < 0
|
||||||
|
? "Subtracting a negative is the same as adding a positive (move right)."
|
||||||
|
: "Subtracting a positive means moving left.";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${a} ${op} ${signed(b)}`,
|
||||||
|
answer: { kind: "integer", value },
|
||||||
|
hints: [
|
||||||
|
"Picture the first number on a number line.",
|
||||||
|
rule,
|
||||||
|
op === "-" && b < 0 ? "Two minus signs make a plus." : "Watch the sign of the second number.",
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ explanation: `Start at ${a} on the number line.` },
|
||||||
|
{ explanation: rule },
|
||||||
|
{ explanation: `${a} ${op} ${signed(b)} = ${value}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateIntegerMultiplyDivide(difficulty: Difficulty): MathProblem {
|
||||||
|
if (difficulty === 3) {
|
||||||
|
// triple product, e.g. (-2) × (-3) × (-4)
|
||||||
|
const a = randomInt(-5, 5) || 2;
|
||||||
|
const b = randomInt(-5, 5) || 3;
|
||||||
|
const c = randomInt(-5, 5) || 4;
|
||||||
|
const value = a * b * c;
|
||||||
|
const negatives = [a, b, c].filter((n) => n < 0).length;
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${signed(a)} \\times ${signed(b)} \\times ${signed(c)}`,
|
||||||
|
answer: { kind: "integer", value },
|
||||||
|
hints: [
|
||||||
|
"Multiply two numbers at a time.",
|
||||||
|
"Count the negative signs: an even count gives a positive result, an odd count gives a negative result.",
|
||||||
|
`There are ${negatives} negative sign${negatives === 1 ? "" : "s"} here.`,
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ explanation: `Multiply the first two: ${signed(a)} × ${signed(b)} = ${a * b}` },
|
||||||
|
{ explanation: `Then multiply by the last: ${signed(a * b)} × ${signed(c)} = ${value}` },
|
||||||
|
{ explanation: `${negatives} negative signs → ${negatives % 2 === 0 ? "positive" : "negative"} answer.` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const op = randomChoice(["\\times", "\\div"] as const);
|
||||||
|
const factorRange = difficulty === 1 ? 9 : 12;
|
||||||
|
|
||||||
|
if (op === "\\times") {
|
||||||
|
const a = randomInt(-factorRange, factorRange) || 3;
|
||||||
|
const b = randomInt(-factorRange, factorRange) || 4;
|
||||||
|
const value = a * b;
|
||||||
|
const sameSign = a < 0 === b < 0;
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${signed(a)} \\times ${signed(b)}`,
|
||||||
|
answer: { kind: "integer", value },
|
||||||
|
hints: [
|
||||||
|
"Multiply the numbers as normal, then decide the sign.",
|
||||||
|
"Same signs → positive. Different signs → negative.",
|
||||||
|
`These signs are ${sameSign ? "the same" : "different"}.`,
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ explanation: `Multiply the sizes: ${Math.abs(a)} × ${Math.abs(b)} = ${Math.abs(value)}` },
|
||||||
|
{ explanation: `${sameSign ? "Same signs give a positive" : "Different signs give a negative"} answer.` },
|
||||||
|
{ explanation: `${signed(a)} × ${signed(b)} = ${value}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// division — build from an exact product so the answer is a whole number
|
||||||
|
const divisor = (randomInt(-factorRange, factorRange) || 3);
|
||||||
|
const quotient = (randomInt(-factorRange, factorRange) || 2);
|
||||||
|
const dividend = divisor * quotient;
|
||||||
|
const sameSign = divisor < 0 === dividend < 0;
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${signed(dividend)} \\div ${signed(divisor)}`,
|
||||||
|
answer: { kind: "integer", value: quotient },
|
||||||
|
hints: [
|
||||||
|
"Divide the numbers as normal, then decide the sign.",
|
||||||
|
"Same signs → positive. Different signs → negative.",
|
||||||
|
`These signs are ${sameSign ? "the same" : "different"}.`,
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ explanation: `Divide the sizes: ${Math.abs(dividend)} ÷ ${Math.abs(divisor)} = ${Math.abs(quotient)}` },
|
||||||
|
{ explanation: `${sameSign ? "Same signs give a positive" : "Different signs give a negative"} answer.` },
|
||||||
|
{ explanation: `${signed(dividend)} ÷ ${signed(divisor)} = ${quotient}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateIntegerOrderCompare(difficulty: Difficulty): MathProblem {
|
||||||
|
const range = difficulty === 1 ? 10 : difficulty === 2 ? 20 : 30;
|
||||||
|
|
||||||
|
if (difficulty === 3) {
|
||||||
|
const nums: number[] = [];
|
||||||
|
while (nums.length < 3) {
|
||||||
|
const v = randomInt(-range, range);
|
||||||
|
if (!nums.includes(v)) nums.push(v);
|
||||||
|
}
|
||||||
|
const wantLargest = Math.random() < 0.5;
|
||||||
|
const value = wantLargest ? Math.max(...nums) : Math.min(...nums);
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `\\text{Which is the ${wantLargest ? "largest" : "smallest"}: } ${nums.join(",\\ ")}\\text{ ?}`,
|
||||||
|
answer: { kind: "integer", value },
|
||||||
|
hints: ["Picture them on a number line.", "Further right is larger; further left is smaller."],
|
||||||
|
steps: [{ explanation: `The ${wantLargest ? "largest" : "smallest"} is ${value}.` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = randomInt(-range, range);
|
||||||
|
let b = randomInt(-range, range);
|
||||||
|
if (a === b) b = a + 1;
|
||||||
|
const wantGreater = Math.random() < 0.5;
|
||||||
|
const value = wantGreater ? Math.max(a, b) : Math.min(a, b);
|
||||||
|
const dir = wantGreater ? "right" : "left";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `\\text{Which is ${wantGreater ? "greater" : "smaller"}, } ${a} \\text{ or } ${b}\\text{? Enter it.}`,
|
||||||
|
answer: { kind: "integer", value },
|
||||||
|
hints: [`On a number line, the number further ${dir} wins.`, "Negatives get smaller the further left they are."],
|
||||||
|
steps: [{ explanation: `${value} is ${wantGreater ? "greater" : "smaller"}.` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add/subtract integers, occasionally as a real-world temperature word problem. */
|
||||||
|
export function generateIntegerAddSubtractMixed(difficulty: Difficulty): MathProblem {
|
||||||
|
return Math.random() < 0.3 ? generateTemperatureProblem(difficulty) : generateIntegerAddSubtract(difficulty);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateTemperatureProblem(difficulty: Difficulty): MathProblem {
|
||||||
|
const cities = ["London", "Moscow", "Toronto", "Oslo", "Helsinki"];
|
||||||
|
const days = ["Monday", "Tuesday", "Wednesday", "Thursday"];
|
||||||
|
const city = randomChoice(cities);
|
||||||
|
const day = randomChoice(days);
|
||||||
|
const start = randomInt(-8, 3);
|
||||||
|
const rose = Math.random() < 0.5;
|
||||||
|
const change = randomInt(2, difficulty === 1 ? 6 : 12);
|
||||||
|
const value = rose ? start + change : start - change;
|
||||||
|
const verb = rose ? "rose" : "fell";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `\\begin{array}{l}\\text{In ${city} the temperature was ${start}°C on ${day}.}\\\\\\text{The next day it ${verb} by ${change}°C.}\\\\\\text{What was the temperature then?}\\end{array}`,
|
||||||
|
answer: { kind: "integer", value },
|
||||||
|
hints: [
|
||||||
|
`Start at ${start}°C.`,
|
||||||
|
`${rose ? "Rising" : "Falling"} means ${rose ? "add" : "subtract"} ${change}.`,
|
||||||
|
`${start} ${rose ? "+" : "-"} ${change} = ${value}`,
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ explanation: `Start at ${start}°C.` },
|
||||||
|
{ explanation: `It ${verb}, so ${rose ? "add" : "subtract"} ${change}: ${start} ${rose ? "+" : "-"} ${change} = ${value}` },
|
||||||
|
{ explanation: `The temperature was ${value}°C.` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
136
lib/problems/generators/number-properties-problems.ts
Normal file
136
lib/problems/generators/number-properties-problems.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import type { MathProblem, Difficulty } from "../types";
|
||||||
|
import { randomInt, randomChoice } from "@/lib/utils";
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
function nextId() {
|
||||||
|
return `np-${++counter}-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateIdentityInverse(difficulty: Difficulty): MathProblem {
|
||||||
|
const kind = randomChoice(
|
||||||
|
difficulty === 1
|
||||||
|
? (["identity-add", "zero", "inverse-add"] as const)
|
||||||
|
: (["identity-add", "identity-mul", "zero", "inverse-add"] as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (kind === "identity-add") {
|
||||||
|
const n = randomInt(-20, 20);
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${n} + 0`,
|
||||||
|
answer: { kind: "integer", value: n },
|
||||||
|
hints: ["0 is the identity for addition.", "Adding 0 changes nothing."],
|
||||||
|
steps: [{ explanation: "Adding the identity 0 leaves the number unchanged.", math: `${n} + 0 = ${n}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "identity-mul") {
|
||||||
|
const n = randomInt(-20, 20);
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${n} \\times 1`,
|
||||||
|
answer: { kind: "integer", value: n },
|
||||||
|
hints: ["1 is the identity for multiplication.", "Multiplying by 1 changes nothing."],
|
||||||
|
steps: [{ explanation: "Multiplying by the identity 1 leaves the number unchanged.", math: `${n} \\times 1 = ${n}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "zero") {
|
||||||
|
const n = randomInt(1, 30);
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${n} \\times 0`,
|
||||||
|
answer: { kind: "integer", value: 0 },
|
||||||
|
hints: ["Anything multiplied by zero is zero."],
|
||||||
|
steps: [{ explanation: "Any number times zero is zero.", math: `${n} \\times 0 = 0` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// inverse-add: additive inverse
|
||||||
|
const n = randomInt(-20, 20) || 5;
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `\\text{The additive inverse of } ${n} \\text{ is?}`,
|
||||||
|
answer: { kind: "integer", value: -n },
|
||||||
|
hints: ["The additive inverse adds up to 0.", "Flip the sign."],
|
||||||
|
steps: [{ explanation: "The additive inverse flips the sign so the sum is 0.", math: `${n} + (${-n}) = 0` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateExponentEvaluate(difficulty: Difficulty): MathProblem {
|
||||||
|
if (difficulty === 3) {
|
||||||
|
// index law: find the resulting exponent
|
||||||
|
const base = randomInt(2, 6);
|
||||||
|
const op = randomChoice(["\\times", "\\div"] as const);
|
||||||
|
if (op === "\\times") {
|
||||||
|
const m = randomInt(1, 5);
|
||||||
|
const n = randomInt(1, 5);
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${base}^{${m}} \\times ${base}^{${n}} = ${base}^{x}`,
|
||||||
|
answer: { kind: "integer", value: m + n },
|
||||||
|
hints: ["Same base, multiplying → add the powers.", `${m} + ${n} = ?`],
|
||||||
|
steps: [{ explanation: "When multiplying powers of the same base, add the exponents.", math: `${base}^{${m}} \\times ${base}^{${n}} = ${base}^{${m + n}}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const m = randomInt(3, 7);
|
||||||
|
const n = randomInt(1, m - 1);
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${base}^{${m}} \\div ${base}^{${n}} = ${base}^{x}`,
|
||||||
|
answer: { kind: "integer", value: m - n },
|
||||||
|
hints: ["Same base, dividing → subtract the powers.", `${m} - ${n} = ?`],
|
||||||
|
steps: [{ explanation: "When dividing powers of the same base, subtract the exponents.", math: `${base}^{${m}} \\div ${base}^{${n}} = ${base}^{${m - n}}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (difficulty === 2 && Math.random() < 0.5) {
|
||||||
|
// difference of two powers, e.g. 2^3 - 2^1
|
||||||
|
const base = randomInt(2, 4);
|
||||||
|
const a = randomInt(2, 3);
|
||||||
|
const b = 1;
|
||||||
|
const value = Math.pow(base, a) - Math.pow(base, b);
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${base}^{${a}} - ${base}^{${b}}`,
|
||||||
|
answer: { kind: "integer", value },
|
||||||
|
hints: [`${base}^${a} = ${Math.pow(base, a)}`, `${base}^${b} = ${Math.pow(base, b)}`],
|
||||||
|
steps: [{ explanation: "Work out each power, then subtract.", math: `${Math.pow(base, a)} - ${Math.pow(base, b)} = ${value}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluate a single power (includes n^0 and n^1)
|
||||||
|
const base = randomInt(2, difficulty === 1 ? 6 : 9);
|
||||||
|
const exp = difficulty === 1 ? randomChoice([0, 1, 2, 2, 3]) : randomChoice([0, 1, 2, 3]);
|
||||||
|
const value = Math.pow(base, exp);
|
||||||
|
const note = exp === 0 ? "Any number to the power 0 is 1." : exp === 1 ? "Any number to the power 1 is itself." : `Multiply ${base} by itself ${exp} times.`;
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${base}^{${exp}}`,
|
||||||
|
answer: { kind: "integer", value },
|
||||||
|
hints: [note],
|
||||||
|
steps: [{ explanation: note, math: `${base}^{${exp}} = ${value}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateDistributive(difficulty: Difficulty): MathProblem {
|
||||||
|
const a = randomInt(2, difficulty === 1 ? 6 : 9);
|
||||||
|
const b = randomInt(1, difficulty === 1 ? 6 : 9);
|
||||||
|
const c = randomInt(1, difficulty === 1 ? 6 : 9);
|
||||||
|
const value = a * (b + c);
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${a} \\times (${b} + ${c})`,
|
||||||
|
answer: { kind: "integer", value },
|
||||||
|
hints: [
|
||||||
|
"Add inside the bracket first, then multiply.",
|
||||||
|
"Or multiply the outside number by each number inside.",
|
||||||
|
`${a} × ${b} + ${a} × ${c}`,
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ explanation: `Add inside the bracket: ${b} + ${c} = ${b + c}` },
|
||||||
|
{ explanation: `Multiply: ${a} × ${b + c} = ${value}` },
|
||||||
|
{ explanation: `Check by distributing: ${a * b} + ${a * c} = ${value}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
104
lib/problems/generators/number-system-problems.ts
Normal file
104
lib/problems/generators/number-system-problems.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import type { MathProblem, Difficulty } from "../types";
|
||||||
|
import { randomInt } from "@/lib/utils";
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
function nextId() {
|
||||||
|
return `ns-${++counter}-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateBinaryToDenary(difficulty: Difficulty): MathProblem {
|
||||||
|
const bits = difficulty === 1 ? randomInt(3, 4) : difficulty === 2 ? randomInt(5, 6) : randomInt(7, 8);
|
||||||
|
// ensure a leading 1 so the length is meaningful
|
||||||
|
let binary = "1";
|
||||||
|
for (let i = 1; i < bits; i++) binary += Math.random() < 0.5 ? "0" : "1";
|
||||||
|
|
||||||
|
const len = binary.length;
|
||||||
|
let value = 0;
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const exp = len - 1 - i;
|
||||||
|
const digit = parseInt(binary[i], 10);
|
||||||
|
const place = Math.pow(2, exp);
|
||||||
|
if (digit === 1) parts.push(`${place}`);
|
||||||
|
value += digit * place;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${binary}_{2} = ?_{10}`,
|
||||||
|
answer: { kind: "integer", value },
|
||||||
|
hints: [
|
||||||
|
"Each column is a power of 2, doubling from the right: 1, 2, 4, 8, 16 …",
|
||||||
|
"Add up the place values wherever there is a 1.",
|
||||||
|
`${parts.join(" + ")} = ${value}`,
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ explanation: "Label each column with its power of 2, starting at 1 on the right." },
|
||||||
|
{ explanation: `Add the place values under each 1: ${parts.join(" + ")}` },
|
||||||
|
{ explanation: `${binary} in binary = ${value} in denary.` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateQuaternaryToDenary(difficulty: Difficulty): MathProblem {
|
||||||
|
const digits = difficulty === 1 ? randomInt(2, 3) : difficulty === 2 ? randomInt(3, 4) : randomInt(4, 5);
|
||||||
|
let quaternary = `${randomInt(1, 3)}`; // leading non-zero digit
|
||||||
|
for (let i = 1; i < digits; i++) quaternary += `${randomInt(0, 3)}`;
|
||||||
|
|
||||||
|
const len = quaternary.length;
|
||||||
|
let value = 0;
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const exp = len - 1 - i;
|
||||||
|
const digit = parseInt(quaternary[i], 10);
|
||||||
|
const place = Math.pow(4, exp);
|
||||||
|
if (digit !== 0) parts.push(`${digit}×${place}`);
|
||||||
|
value += digit * place;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `${quaternary}_{4} = ?_{10}`,
|
||||||
|
answer: { kind: "integer", value },
|
||||||
|
hints: [
|
||||||
|
"Each column is a power of 4, from the right: 1, 4, 16, 64 …",
|
||||||
|
"Multiply each digit by its place value and add them up.",
|
||||||
|
`${parts.join(" + ")} = ${value}`,
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ explanation: "Label each column with its power of 4, starting at 1 on the right." },
|
||||||
|
{ explanation: `Add the products: ${parts.join(" + ")}` },
|
||||||
|
{ explanation: `${quaternary} in base 4 = ${value} in denary.` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateDigitValue(difficulty: Difficulty): MathProblem {
|
||||||
|
const digits = difficulty === 1 ? randomInt(3, 4) : difficulty === 2 ? 5 : 6;
|
||||||
|
const min = Math.pow(10, digits - 1);
|
||||||
|
const max = Math.pow(10, digits) - 1;
|
||||||
|
const n = randomInt(min, max);
|
||||||
|
const str = `${n}`;
|
||||||
|
|
||||||
|
// pick a non-zero digit position
|
||||||
|
const nonZeroPositions = str.split("").map((d, i) => ({ d, i })).filter((x) => x.d !== "0");
|
||||||
|
const pick = nonZeroPositions[randomInt(0, nonZeroPositions.length - 1)];
|
||||||
|
const exp = str.length - 1 - pick.i;
|
||||||
|
const place = Math.pow(10, exp);
|
||||||
|
const value = parseInt(pick.d, 10) * place;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `\\text{In } ${n} \\text{, what is the value of the digit } ${pick.d}\\text{?}`,
|
||||||
|
answer: { kind: "integer", value },
|
||||||
|
hints: [
|
||||||
|
"Find which column the digit sits in.",
|
||||||
|
`That column is worth 10^${exp} = ${place}.`,
|
||||||
|
`${pick.d} × ${place} = ${value}`,
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ explanation: `The digit ${pick.d} is in the 10^${exp} (${place}) column.` },
|
||||||
|
{ explanation: `Its value is ${pick.d} × ${place} = ${value}.` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,58 @@ function nextId() {
|
|||||||
return `rp-${++counter}-${Date.now()}`;
|
return `rp-${++counter}-${Date.now()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateDefineRatio(difficulty: Difficulty): MathProblem {
|
||||||
|
const contexts = [
|
||||||
|
{ a: "red", b: "blue", item: "counters in a bag" },
|
||||||
|
{ a: "boys", b: "girls", item: "in the class" },
|
||||||
|
{ a: "cats", b: "dogs", item: "at the shelter" },
|
||||||
|
{ a: "apples", b: "oranges", item: "in the basket" },
|
||||||
|
];
|
||||||
|
const ctx = randomChoice(contexts);
|
||||||
|
const g = randomChoice(difficulty === 1 ? [1, 2] : difficulty === 2 ? [2, 3, 4] : [3, 4, 5, 6]);
|
||||||
|
const x = randomInt(1, difficulty === 1 ? 6 : 8) * g;
|
||||||
|
const y = randomInt(1, difficulty === 1 ? 6 : 8) * g;
|
||||||
|
const simplified = simplifyRatio([x, y]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `\\begin{array}{l}\\text{There are ${x} ${ctx.a} and ${y} ${ctx.b} ${ctx.item}.}\\\\\\text{Write the ratio of ${ctx.a} to ${ctx.b} in its simplest form.}\\end{array}`,
|
||||||
|
answer: { kind: "ratio", parts: simplified },
|
||||||
|
hints: [
|
||||||
|
`Write it as ${x} : ${y} first.`,
|
||||||
|
"Divide both parts by their highest common factor.",
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ explanation: `The ratio of ${ctx.a} to ${ctx.b} is ${x} : ${y}.` },
|
||||||
|
{ explanation: `In simplest form: ${simplified.join(" : ")}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateFractionsAndRatios(difficulty: Difficulty): MathProblem {
|
||||||
|
const a = randomInt(1, difficulty === 1 ? 4 : 7);
|
||||||
|
const b = randomInt(1, difficulty === 1 ? 4 : 7);
|
||||||
|
const whole = a + b;
|
||||||
|
const which = randomChoice([0, 1] as const);
|
||||||
|
const part = which === 0 ? a : b;
|
||||||
|
const [n, d] = simplifyRatio([part, whole]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
prompt: `\\begin{array}{l}\\text{Two amounts are shared in the ratio } ${a} : ${b}.\\\\\\text{What fraction of the whole is the ${which === 0 ? "first" : "second"} part?}\\end{array}`,
|
||||||
|
answer: { kind: "fraction", numerator: n, denominator: d },
|
||||||
|
hints: [
|
||||||
|
`The whole is ${a} + ${b} = ${whole} parts.`,
|
||||||
|
`The ${which === 0 ? "first" : "second"} part is ${part} out of ${whole}.`,
|
||||||
|
"Simplify the fraction.",
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ explanation: `Total parts: ${a} + ${b} = ${whole}.` },
|
||||||
|
{ explanation: `Fraction = ${part}/${whole}, which simplifies to ${n}/${d}.` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function generateSimplifyRatio(difficulty: Difficulty): MathProblem {
|
export function generateSimplifyRatio(difficulty: Difficulty): MathProblem {
|
||||||
const gcd = randomChoice(difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [3, 4, 5, 6, 7] : [4, 6, 8, 9, 12]);
|
const gcd = randomChoice(difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [3, 4, 5, 6, 7] : [4, 6, 8, 9, 12]);
|
||||||
const a = randomInt(1, difficulty === 1 ? 5 : 10) * gcd;
|
const a = randomInt(1, difficulty === 1 ? 5 : 10) * gcd;
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
// Pin the workspace root to this project. A stray package-lock.json in the
|
||||||
|
// parent directory otherwise makes Turbopack infer the wrong root and crash.
|
||||||
|
turbopack: { root: __dirname },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
1220
package-lock.json
generated
1220
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
319
plan.md
Normal file
319
plan.md
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
# Implementation Plan: Term 1 Unit Plan (`unit_plan_term_1.pdf`) → Cabrits App
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
Cabrits is an interactive mathematics teaching tool for Portsmouth Secondary School Form 1 (age 11–12), used live in class by the teacher. Pages contain **interactive tools only — no lesson text** (the teacher explains verbally). Stack: Next.js 16 (App Router, `output: "standalone"`), React 19, TypeScript, Tailwind CSS v4 (`@theme inline`), KaTeX via `components/math/math-display.tsx`, custom SVG visualizations (no chart libraries), no database/auth — everything is client-side and generated on the fly.
|
||||||
|
|
||||||
|
The app currently implements the **Term 2** curriculum as Units 1–4 (Fractions, Decimals, Decimal Operations, Ratio & Proportion) and has already started on Term 1 material as Units 5–6 (Integers, Number System).
|
||||||
|
|
||||||
|
`unit_plan_term_1.pdf` (8 pages, AY 2025–2026, Form 1, "NUMBER" unit) specifies these topics. The website is a **resource organized by topic** — the source document's scheduling is irrelevant and must never appear in the app (no week numbers, no dates, nothing schedule-related in any UI text, slug, title, or description):
|
||||||
|
|
||||||
|
| Topic | Objectives | Content |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| Integers: define, order, compare | Define integers & negative numbers; represent on a number line; order integers (ascending/descending); compare integers with `<` / `>` | Number line, real-world contexts (temperature/thermometer, banking/overdraft, sports goal differences, time). Evaluation: order sets like `-8, -10, -4, -3, -6, 5`; fill in `<` or `>` for pairs like `-5 __ 5`, `4 __ -16`, `-6 __ -11` |
|
||||||
|
| Integers: add & subtract | Add & subtract integers, with and without number lines | Number-line movement rules; sign rules ("two like signs become +, two unlike signs become −"); borrow/owe strategy. Evaluation: `5 + (-3)`, `8 - (-7)`, `-2 + 9`, `-9 - (-1)`, etc. |
|
||||||
|
| Integers: multiply & divide | Multiply & divide integers | Sign rules (`− × − = +` etc.); "Eat! / Do not eat!" story; thermometer word problems (e.g. "In London, temperature is −3 °C, next day it rose by 7 °C…"). Evaluation: `3 × -5`, `-2 × -9`, `-12 / 4`, `-24 / -8`, `(-2) × (-3) × (-4)` |
|
||||||
|
| Coordinates | Plot coordinates; state coordinates of points | Cartesian plane: x-axis (horizontal), y-axis (vertical), origin (0, 0). Evaluation: plot `A(-4,-8), B(-3,-9), C(-2,-8), D(-2,0), E(-5,0), F(-6,4), G(-2,5), H(2,4), I(5,0)`, join alphabetically then I→A to reveal a shape |
|
||||||
|
| Identity, inverse & zero facts | Basic operations; identity for addition (0) & multiplication (1); inverse under addition (additive inverse, e.g. inverse of 3 is −3 because `3 + (-3) = 0`); inverse under multiplication (reciprocal, e.g. inverse of 6 is 1/6 because `6 × 1/6 = 1`; inverse of −5 is −1/5); multiplication by zero → 0; division by zero → undefined | "Math Wheel" activity. Evaluation: classwork + homework on identities/inverses |
|
||||||
|
| Number laws & exponents | Commutative, associative, distributive laws; powers/exponents | Addition & multiplication are commutative/associative, subtraction & division are not; distributive law (number outside bracket multiplies every number inside). Exponents: `2⁴ = 2×2×2×2 = 16`; `a¹ = a`; `a⁰ = 1`; product rule `2⁴ × 2³ = 2⁷`; quotient rule `6⁵ / 6³ = 6²`. Evaluation quiz: `6³`, `2³ − 2¹`, `5⁶ / 5⁴`, `7⁰`, `4⁴ × 4³` |
|
||||||
|
| Number systems (base 10 & base 2) | Understand & use the decimal system (base 10); understand & use the binary system (base 2) | Base-10 place value chart with powers of 10, expanded form (`983275 = 9×10⁵ + 8×10⁴ + 3×10³ + 2×10² + 7×10¹ + 5×10⁰`); binary place value with powers of 2; convert binary→denary (`111001₂ = 32+16+8+0+0+1 = 57`). Evaluation: convert `1011₂`, `111₂`, `1101₂` to denary |
|
||||||
|
|
||||||
|
## 2. Gap Analysis
|
||||||
|
|
||||||
|
**Already implemented (do NOT rebuild — leave untouched):**
|
||||||
|
|
||||||
|
| PDF topic | Existing route | Existing explorer |
|
||||||
|
|---|---|---|
|
||||||
|
| Add/subtract integers | `/lessons/unit-5-integers/add-subtract` | `components/explorers/integer-add-subtract-explorer.tsx` (number-line + sign-rules modes, step-through, inline QuickPractice) |
|
||||||
|
| Multiply/divide integers | `/lessons/unit-5-integers/multiply-divide` | `components/explorers/integer-multiply-divide-explorer.tsx` |
|
||||||
|
| Binary ↔ denary | `/lessons/unit-6-number-system/binary` | `components/explorers/binary-converter-explorer.tsx`, `denary-to-binary-explorer.tsx` |
|
||||||
|
| (bonus, not in PDF) quaternary | `/lessons/unit-6-number-system/quaternary` | `quaternary-to-denary-explorer.tsx`, `denary-to-quaternary-explorer.tsx` |
|
||||||
|
|
||||||
|
**Missing (this plan builds these):**
|
||||||
|
|
||||||
|
1. Order & compare integers (new topic in existing Unit 5)
|
||||||
|
2. Cartesian plane: plot points & read coordinates (new Unit 7)
|
||||||
|
3. Identity, inverse, zero facts (new Unit 8, topic 1)
|
||||||
|
4. Commutative/associative/distributive laws (Unit 8, topic 2)
|
||||||
|
5. Powers & exponents with index laws (Unit 8, topic 3)
|
||||||
|
6. Base-10 place value / expanded form (new topic in existing Unit 6)
|
||||||
|
|
||||||
|
## 3. Target Curriculum Structure After Implementation
|
||||||
|
|
||||||
|
- **Unit 5 – Integers** (existing, violet `#7c3aed`): `order-compare` **(NEW, insert as first topic)**, `add-subtract`, `multiply-divide`
|
||||||
|
- **Unit 6 – Number System** (existing, indigo `#4f46e5`): `place-value` **(NEW, insert as first topic)**, `binary`, `quaternary`
|
||||||
|
- **Unit 7 – Coordinates** (NEW, green `#16a34a`): `plot-points`, `read-coordinates`
|
||||||
|
- **Unit 8 – Number Properties** (NEW, rose `#e11d48`): `identity-inverse`, `number-laws`, `exponents`
|
||||||
|
|
||||||
|
New routes (7 topic pages + 2 unit overview pages):
|
||||||
|
```
|
||||||
|
/lessons/unit-5-integers/order-compare
|
||||||
|
/lessons/unit-6-number-system/place-value
|
||||||
|
/lessons/unit-7-coordinates (overview)
|
||||||
|
/lessons/unit-7-coordinates/plot-points
|
||||||
|
/lessons/unit-7-coordinates/read-coordinates
|
||||||
|
/lessons/unit-8-number-properties (overview)
|
||||||
|
/lessons/unit-8-number-properties/identity-inverse
|
||||||
|
/lessons/unit-8-number-properties/number-laws
|
||||||
|
/lessons/unit-8-number-properties/exponents
|
||||||
|
```
|
||||||
|
|
||||||
|
No existing route changes. Inserting topics at the front of a unit's `topics` array only changes the displayed numbering on overview pages (which is index-based), not URLs.
|
||||||
|
|
||||||
|
## 4. Codebase Conventions (READ THESE FILES FIRST as templates)
|
||||||
|
|
||||||
|
1. **`lib/curriculum.ts`** — single source of truth. `Unit` interface has `number: 1|2|3|4|5|6` and `color: "unit-1"|...|"unit-6"` unions that must be extended to include 7 and 8. The sidebar (`components/layout/sidebar.tsx`), mobile nav (`components/layout/mobile-nav.tsx`), lessons index (`app/lessons/page.tsx`), and landing page (`app/page.tsx`) all iterate `curriculum` automatically — but each keeps its **own hardcoded color-class map keyed by `"unit-N"`** that must gain `unit-7`/`unit-8` entries (Tailwind cannot generate class names dynamically).
|
||||||
|
2. **Topic page pattern** — see `app/lessons/unit-5-integers/add-subtract/page.tsx` (the most recent convention): a `"use client"` page containing only `<Breadcrumbs>`, an `<h1>`, and one `<XyzExplorer />` component. Breadcrumb items: `Lessons → Unit N: Title → Short topic label`.
|
||||||
|
3. **Unit overview page pattern** — see `app/lessons/unit-5-integers/page.tsx`: server component, reads `curriculum[unitIndex]`, renders a `<Badge variant="unit-N">` header and a `sm:grid-cols-2` grid of `<Card accent="unit-N" hover>` links with numbered chips (`bg-unit-N-light text-unit-N-dark`).
|
||||||
|
4. **Explorer pattern** — see `components/explorers/integer-add-subtract-explorer.tsx` (594 lines; the canonical template). Structure:
|
||||||
|
- `"use client"`, local `Step` interface `{ label: string; math: string; ...visualization payload }`
|
||||||
|
- A pure `buildXyzSteps(...)` function that returns `Step[]` narrating the worked solution
|
||||||
|
- Mode-toggle pill buttons (`rounded-full border-2 ...` with unit color classes)
|
||||||
|
- An input `<Card>` with validated number inputs and a "Go →" button; friendly error messages via a local `error` state
|
||||||
|
- A display `<Card>` showing `step.label`, `<MathDisplay math={step.math} />` (KaTeX string), and the custom SVG/div visualization that animates with the current step
|
||||||
|
- `<StepControls>` from `components/explorers/step-controls.tsx` wired via `currentStep / totalSteps / isPlaying / onStepForward / onStepBack / onTogglePlay / onReset / canStepForward / canStepBack` (note: it expects `currentStep + 1` as the 1-based display value)
|
||||||
|
- A result `<Card>` shown when steps complete
|
||||||
|
- An inline `QuickPractice` component (local to the file): random problem generator, number input, Check/Next buttons, running `score.correct/score.total` chip, Enter-key handling, correct/incorrect feedback using `border-correct bg-correct-light` / `border-incorrect bg-incorrect-light` classes
|
||||||
|
5. **Color system** — `app/globals.css` defines per-unit CSS vars in `:root` (`--unit-N`, `--unit-N-light`, `--unit-N-dark`) and mirrors them in the `@theme inline` block as `--color-unit-N*` so Tailwind classes like `bg-unit-7`, `text-unit-7-dark`, `bg-unit-7-light`, `border-unit-7/40` work. Also available: `--correct`, `--incorrect` (+ `-light` variants), `--foreground`, `--muted`, `--surface`, `--border`.
|
||||||
|
6. **Practice hub** — `app/practice/page.tsx` holds `TOPIC_GENERATORS` (generator functions from `lib/problems/generators/*`), a `unitColors` map (currently only units 1–4), and a filter-pill array `(["unit-1","unit-2","unit-3","unit-4"] as const)`. `components/practice/practice-section.tsx` renders input UI switched on `problem.answer.kind` — it already supports `"integer"` (plain number input), so all new generators should return `{ kind: "integer", value }` answers. `MathProblem` (in `lib/problems/types.ts`) requires `id`, `prompt` (may contain KaTeX-able text), `answer`, `hints: string[]`, `steps: SolutionStep[]` (`{ explanation, math? }`). `Difficulty = 1 | 2 | 3`.
|
||||||
|
7. **Landing page art** — `components/home/topic-art.tsx` exports `art: Record<string, ReactNode>` keyed by `"unitSlug/topicSlug"`, each a small white-on-gradient SVG glyph inside the shared 100×75 viewBox `Frame`. Every new topic needs an entry or its tile renders without artwork.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Implementation Phases
|
||||||
|
|
||||||
|
Work in this order; the app must compile after every phase (`npx tsc --noEmit` and `npm run build`).
|
||||||
|
|
||||||
|
### Phase 1 — Shared infrastructure (types, colors, nav maps)
|
||||||
|
|
||||||
|
1. **`lib/curriculum.ts`**
|
||||||
|
- Extend `Unit["number"]` union to `1|2|3|4|5|6|7|8` and `Unit["color"]` union to include `"unit-7" | "unit-8"`.
|
||||||
|
- Extend `getUnitColor`'s parameter type and `colors` record with `7: "unit-7"`, `8: "unit-8"`.
|
||||||
|
- Insert into unit 5's `topics` (as the FIRST element):
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
slug: "order-compare",
|
||||||
|
title: "Order and Compare Integers",
|
||||||
|
shortTitle: "Order & Compare",
|
||||||
|
description: "Place integers on a number line, order them, and compare with < and >",
|
||||||
|
},
|
||||||
|
```
|
||||||
|
- Insert into unit 6's `topics` (as the FIRST element):
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
slug: "place-value",
|
||||||
|
title: "Denary Place Value (Base 10)",
|
||||||
|
shortTitle: "Place Value",
|
||||||
|
description: "Powers of 10 and writing numbers in expanded form",
|
||||||
|
},
|
||||||
|
```
|
||||||
|
- Append two new units after unit 6:
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
number: 7,
|
||||||
|
slug: "unit-7-coordinates",
|
||||||
|
title: "Coordinates",
|
||||||
|
description: "Plot points and read coordinates on the Cartesian plane",
|
||||||
|
color: "unit-7",
|
||||||
|
topics: [
|
||||||
|
{ slug: "plot-points", title: "Plot Points on the Cartesian Plane", shortTitle: "Plot Points", description: "Use x and y coordinates to place points and reveal hidden shapes" },
|
||||||
|
{ slug: "read-coordinates", title: "State Coordinates of Points", shortTitle: "Read Coordinates", description: "Read off the (x, y) coordinates of points in all four quadrants" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 8,
|
||||||
|
slug: "unit-8-number-properties",
|
||||||
|
title: "Number Properties",
|
||||||
|
description: "Identity and inverse, the laws of arithmetic, and powers of numbers",
|
||||||
|
color: "unit-8",
|
||||||
|
topics: [
|
||||||
|
{ slug: "identity-inverse", title: "Identity and Inverse", shortTitle: "Identity & Inverse", description: "Identity for addition and multiplication, inverses, and the zero facts" },
|
||||||
|
{ slug: "number-laws", title: "Commutative, Associative and Distributive Laws", shortTitle: "Number Laws", description: "Which operations can be swapped, regrouped, and expanded" },
|
||||||
|
{ slug: "exponents", title: "Powers and Exponents", shortTitle: "Exponents", description: "Base and power, expanding powers, and the index laws" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`app/globals.css`** — add beside the existing `--unit-6` definitions in `:root`:
|
||||||
|
```css
|
||||||
|
--unit-7: #16a34a;
|
||||||
|
--unit-7-light: #dcfce7;
|
||||||
|
--unit-7-dark: #15803d;
|
||||||
|
|
||||||
|
--unit-8: #e11d48;
|
||||||
|
--unit-8-light: #ffe4e9;
|
||||||
|
--unit-8-dark: #be123c;
|
||||||
|
```
|
||||||
|
and in the `@theme inline` block (beside `--color-unit-6-dark`):
|
||||||
|
```css
|
||||||
|
--color-unit-7: var(--unit-7);
|
||||||
|
--color-unit-7-light: var(--unit-7-light);
|
||||||
|
--color-unit-7-dark: var(--unit-7-dark);
|
||||||
|
--color-unit-8: var(--unit-8);
|
||||||
|
--color-unit-8-light: var(--unit-8-light);
|
||||||
|
--color-unit-8-dark: var(--unit-8-dark);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **`components/ui/badge.tsx`** — extend `BadgeVariant` union with `"unit-7" | "unit-8"` and add to `variantStyles`:
|
||||||
|
```ts
|
||||||
|
"unit-7": "border border-unit-7/20 bg-unit-7-light text-unit-7-dark",
|
||||||
|
"unit-8": "border border-unit-8/20 bg-unit-8-light text-unit-8-dark",
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **`components/ui/card.tsx`** — extend the `accent` prop union and `accentStyles` with `"unit-7": "border-l-4 border-l-unit-7"` and the unit-8 equivalent.
|
||||||
|
|
||||||
|
5. **`components/layout/sidebar.tsx`** — add `unit-7` / `unit-8` entries to `unitColorMap`, following the exact shape of the existing entries (`active`, `dot`, `heading`, `hoverBg` keys with the unit's color classes).
|
||||||
|
|
||||||
|
6. **`components/layout/mobile-nav.tsx`** — same: extend both color maps (there is a text-color map at ~line 10 and a dot/bg map at ~line 19) with unit-7/unit-8 entries.
|
||||||
|
|
||||||
|
7. **`app/page.tsx`** (landing) — add to `unitStyles`:
|
||||||
|
```ts
|
||||||
|
"unit-7": {
|
||||||
|
tile: "from-[#4ade80] to-[#15803d]",
|
||||||
|
unitCard: "border-[#86efac] bg-[#f0fdf4]",
|
||||||
|
chip: "bg-[#15803d]",
|
||||||
|
},
|
||||||
|
"unit-8": {
|
||||||
|
tile: "from-[#fb7185] to-[#be123c]",
|
||||||
|
unitCard: "border-[#fda4af] bg-[#fff1f2]",
|
||||||
|
chip: "bg-[#be123c]",
|
||||||
|
},
|
||||||
|
```
|
||||||
|
The topic tiles cycle through the `topicTiles` gradient array by index, so no per-topic change is needed there.
|
||||||
|
|
||||||
|
8. **`components/home/topic-art.tsx`** — add 7 entries to the `art` record (white SVG glyphs in the 100×75 viewBox, matching the visual weight of existing entries):
|
||||||
|
- `"unit-5-integers/order-compare"`: a horizontal number line (line + tick marks) with `<` glyph, e.g. ticks at intervals with two highlighted dots and a `<` between two numbers
|
||||||
|
- `"unit-6-number-system/place-value"`: three columns of stacked digits over small `10² 10¹ 10⁰` labels (use `<text>` with `fontSize` ~8–10)
|
||||||
|
- `"unit-7-coordinates/plot-points"`: a small grid (4 light `<line>`s each way), two axes drawn heavier, one bright plotted point with small crosshair lines to the axes
|
||||||
|
- `"unit-7-coordinates/read-coordinates"`: axes plus a point labelled `(3, 2)`
|
||||||
|
- `"unit-8-number-properties/identity-inverse"`: `n + 0 = n` style glyph, or two circles balancing on a seesaw with `0` at the pivot
|
||||||
|
- `"unit-8-number-properties/number-laws"`: `a + b = b + a` with a swap arrow (curved `<path>` with arrowhead)
|
||||||
|
- `"unit-8-number-properties/exponents"`: large `2` with superscript `4` and `= 16`
|
||||||
|
|
||||||
|
**Checkpoint:** `npx tsc --noEmit` passes; landing page, sidebar, and mobile nav show Units 7–8 with correct colors; the two new Unit 5/6 topics appear (links 404 until later phases — acceptable mid-work, but all phases must be done before merging).
|
||||||
|
|
||||||
|
### Phase 2 — Unit 5: Order & Compare Integers
|
||||||
|
|
||||||
|
**New file: `components/explorers/integer-order-compare-explorer.tsx`** — model on `integer-add-subtract-explorer.tsx`. Unit color: `unit-5` (violet). Three modes via pill toggle:
|
||||||
|
|
||||||
|
1. **"Number Line" mode** (objective 1–2: define/represent):
|
||||||
|
- Input: one integer from −15 to 15 (reuse the NumberLine rendering approach from the add-subtract explorer: absolute-positioned ticks over a percentage-scaled track, labels every 5 plus highlighted values).
|
||||||
|
- Step sequence: (1) show the number line with zero highlighted, label "Zero separates negative numbers (left) from positive numbers (right)"; (2) mark the entered integer with a Start-style pill marker; (3) state its type: `\text{-7 is a negative integer — it is 7 steps left of zero}` (KaTeX via `MathDisplay`).
|
||||||
|
- Below the line keep the existing convention: `← negative` / `positive →` edge labels.
|
||||||
|
- Add a context selector row of small buttons — **Thermometer 🌡, Money 💰, Sea level 🌊** — that re-labels the step text (e.g. thermometer: "−7 means 7 degrees below zero"; money: "−7 means owing $7"; sea level: "−7 means 7 m below sea level"). This delivers the PDF's real-world contexts (temperature, banking/overdraft). A vertical thermometer variant is optional polish, not required.
|
||||||
|
|
||||||
|
2. **"Order" mode** (objective 3):
|
||||||
|
- Input: a comma-separated list of 3–6 integers (validate: integers, within −20…20, at least 3). Direction toggle: Ascending / Descending.
|
||||||
|
- Step sequence built by `buildOrderSteps(values, direction)`: (1) show unordered values as chips; (2) place all values as dots on the number line simultaneously; (3) one step per pick — "The smallest is the furthest LEFT on the number line: −10" — moving each chip into the ordered row; (4) final ordered list, `\text{Ascending: least} \to \text{greatest}`.
|
||||||
|
- The visualization: the shared number line with dots, plus a row of value chips that fill in as steps advance (conditionally render based on `currentStep`, same technique as the existing explorers' step-driven rendering).
|
||||||
|
|
||||||
|
3. **"Compare" mode** (objective 4):
|
||||||
|
- Input: two integers a, b. Steps: (1) plot both on the number line; (2) "Numbers to the RIGHT are always larger" with the right-hand one highlighted; (3) result `−5 < 5` rendered with `MathDisplay`.
|
||||||
|
|
||||||
|
**Inline `QuickPractice`** (same Card layout/score-chip pattern as the template): alternate two question types randomly —
|
||||||
|
- *Compare*: show `a \; \square \; b` with two big `<` / `>` buttons instead of a number input (immediate check on click). Seed pairs like the PDF's evaluation: `(-5, 5)`, `(4, -16)`, `(-6, -11)`, plus randoms in −20…20.
|
||||||
|
- *Order*: show 4 shuffled integers as clickable chips; student clicks them in ascending order; each click either locks the chip (correct next pick) or flashes incorrect. Score a point when all 4 are placed without a mistake.
|
||||||
|
|
||||||
|
**New file: `app/lessons/unit-5-integers/order-compare/page.tsx`** — copy the shape of `add-subtract/page.tsx`; breadcrumbs `Lessons → Unit 5: Integers → Order & Compare`; `<h1>Order and Compare Integers</h1>`; render the new explorer.
|
||||||
|
|
||||||
|
### Phase 3 — Unit 7: Coordinates
|
||||||
|
|
||||||
|
**New file: `app/lessons/unit-7-coordinates/page.tsx`** — copy `app/lessons/unit-5-integers/page.tsx`, change `curriculum[4]` → `curriculum[6]`, `unit-5` → `unit-7` classes, badge variant `unit-7`, breadcrumb label `Unit 7: Coordinates`.
|
||||||
|
|
||||||
|
**Shared visualization — build a reusable `CartesianGrid` component** (either inside each explorer or as `components/visualizations/cartesian-grid.tsx` if `components/visualizations/` exists — check; if the repo keeps visuals inline in explorers, put it in a shared file under `components/explorers/` such as `cartesian-grid.tsx`). Spec:
|
||||||
|
- SVG `viewBox="0 0 440 440"`, responsive (`className="h-auto w-full max-w-md"`), grid from −10 to +10 both axes (21 lines each way; light `stroke="var(--border)"` gridlines, heavier axes through the origin in `var(--foreground)`), axis numeric labels every 2 (skip 0 clutter; label origin `0` once), arrowheads + `x` / `y` labels at the axis ends.
|
||||||
|
- Props: `points: { x: number; y: number; label?: string; color?: string }[]`, `segments: { from: Point; to: Point }[]` (polylines drawn so far), optional `highlight` point with **guide lines**: a dashed line from the point straight down/up to the x-axis and across to the y-axis (this is the key teaching visual for both plotting and reading), optional `onGridClick?: (x, y) => void` (attach a click handler on the SVG, invert the coordinate transform, snap to nearest integer intersection).
|
||||||
|
- Coordinate transform: `sx = 20 + (x + 10) * 20`, `sy = 20 + (10 - y) * 20`.
|
||||||
|
|
||||||
|
**New file: `components/explorers/plot-points-explorer.tsx`** — unit color `unit-7`. Two modes:
|
||||||
|
|
||||||
|
1. **"Plot a Point"**: inputs for x and y (−10…10). `buildPlotSteps(x, y)`: (1) "Start at the origin (0, 0), where the axes cross"; (2) "Move ALONG the x-axis: 4 to the right" (grid shows a horizontal arrow from origin to (x, 0)); (3) "Then move UP/DOWN parallel to the y-axis: 3 up" (vertical arrow (x,0)→(x,y), dashed guides); (4) "Mark the point (4, 3) — x always comes first!". Each step's payload tells `CartesianGrid` what arrows/guides/points to draw.
|
||||||
|
2. **"Shape Reveal"** (the PDF's evaluation activity): a select of 3–4 preset shape datasets, the first being exactly the PDF's: `A(-4,-8), B(-3,-9), C(-2,-8), D(-2,0), E(-5,0), F(-6,4), G(-2,5), H(2,4), I(5,0)` joined A→B→…→I→A. Add 2–3 simpler presets (e.g. a square, a star, a house). Step-through: each step plots the next labelled point and draws the segment from the previous one; final step closes the shape (I back to A). `StepControls` drives it, so the teacher can play it like an animation.
|
||||||
|
|
||||||
|
Inline `QuickPractice`: "Click where (−3, 5) is!" — random target coordinates, student clicks the grid (via `onGridClick`), snap-checked for exact match; correct → green pulse on the point, score increments; incorrect → show the clicked point in red and the target in green with guide lines.
|
||||||
|
|
||||||
|
**New file: `components/explorers/read-coordinates-explorer.tsx`** — unit color `unit-7`.
|
||||||
|
- **Explore mode**: a random (or stepped) point is shown; `buildReadSteps(x, y)`: (1) "Drop a line straight DOWN/UP to the x-axis — it lands on 4" (dashed vertical guide); (2) "Go straight ACROSS to the y-axis — it lands on 3" (dashed horizontal guide); (3) "The coordinates are (4, 3): x first, then y". Include a quadrant call-out step ("Both negative → bottom-left quadrant") since the PDF's shape spans all four quadrants.
|
||||||
|
- Inline `QuickPractice`: show a random point on the grid; two number inputs `( __ , __ )`; check both; classic error worth catching in feedback: if the student enters (y, x) swapped, say "You swapped them — x comes first!".
|
||||||
|
|
||||||
|
**New pages:** `app/lessons/unit-7-coordinates/plot-points/page.tsx` and `.../read-coordinates/page.tsx` following the standard topic-page shape (breadcrumb unit label `Unit 7: Coordinates`).
|
||||||
|
|
||||||
|
### Phase 4 — Unit 8: Number Properties
|
||||||
|
|
||||||
|
**New file: `app/lessons/unit-8-number-properties/page.tsx`** — copy the unit-overview pattern with `curriculum[7]`, `unit-8` classes.
|
||||||
|
|
||||||
|
**New file: `components/explorers/identity-inverse-explorer.tsx`** — unit color `unit-8`. Mode pills: **Identity · Inverse · Zero Facts**.
|
||||||
|
- *Identity*: input a number n; toggle Add / Multiply. Steps: `n + 0 = n` — "Adding 0 leaves the number unchanged: 0 is the identity for addition"; `n × 1 = n` — "Multiplying by 1 leaves it unchanged: 1 is the identity for multiplication". Visual: a "machine" card — number goes in, `+0`/`×1` badge in the middle, same number comes out (simple flex layout with an animated arrow; framer-motion optional, CSS transitions fine).
|
||||||
|
- *Inverse*: input n (non-zero for multiplication). Steps for addition: "The additive inverse is the number that gets you back to the identity 0" → `n + (−n) = 0` (e.g. inverse of 3 is −3; inverse of −5 is 5 — flip the sign). Steps for multiplication: "The multiplicative inverse (reciprocal) gets you back to 1" → `n × \frac{1}{n} = 1`, with the PDF's note that the inverse of −5 is `−\frac{1}{5}` (sign stays in front). Render fractions with KaTeX.
|
||||||
|
- *Zero Facts*: input n. Steps: `n × 0 = 0` — "Anything times zero is zero"; `n ÷ 0 = \text{undefined}` — "You cannot share among zero groups — division by zero is UNDEFINED" (style the word in `text-incorrect`). Also show `0 ÷ n = 0` for contrast.
|
||||||
|
- Inline `QuickPractice`: random prompts with integer answers — "What is the additive inverse of −8?" (answer 8), "What is 17 × 0?", "What number is the identity for multiplication?"; multiplicative-inverse questions phrased to keep integer answers ("The inverse of 6 under multiplication is 1/□ — what goes in the box?").
|
||||||
|
|
||||||
|
**New file: `components/explorers/number-laws-explorer.tsx`** — unit color `unit-8`. Mode pills: **Commutative · Associative · Distributive**.
|
||||||
|
- *Commutative*: inputs a, b; operation selector (+ − × ÷). Steps compute both `a ∘ b` and `b ∘ a` side by side and conclude: for + and ×, "Same answer — addition/multiplication IS commutative ✓"; for − and ÷, show the two different results and "Different answers — subtraction/division is NOT commutative ✗" (this contrast is explicitly in the PDF's evaluation questions). Visual: two mirrored expression cards with a swap arrow between them; results colored `correct`/`incorrect` by match.
|
||||||
|
- *Associative*: inputs a, b, c; operation + or × (plus a "try −" demo option that shows the counterexample). Steps: evaluate `(a ∘ b) ∘ c` bracket-first, then `a ∘ (b ∘ c)`, compare.
|
||||||
|
- *Distributive*: inputs a, b, c. Steps for `a × (b + c)`: (1) show the expression; (2) "The number outside multiplies EVERY number inside" → `a×b + a×c` with curved arrows from a to each term (SVG or absolutely-positioned spans); (3) evaluate both ways to the same answer.
|
||||||
|
- Inline `QuickPractice`: e.g. "Fill in the blank: 4 × (5 + 2) = 4×5 + 4×□" (integer answer), "Does 9 − 4 = 4 − 9? Enter 9 − 4:" style computations.
|
||||||
|
|
||||||
|
**New file: `components/explorers/exponents-explorer.tsx`** — unit color `unit-8`. Mode pills: **Expand · Index Laws**.
|
||||||
|
- *Expand*: inputs base (2–12) and power (0–6), guard result ≤ ~1,000,000. `buildExpandSteps(base, exp)`: (1) `2^4` — "the base is 2, the power (index) is 4: multiply the base by itself 4 times"; (2) `2 × 2 × 2 × 2` shown building up factor by factor across steps with running product; (3) `= 16`. Special-case steps: power 1 → `3^1 = 3` "any number to the power 1 is itself"; power 0 → `6^0 = 1` "any number to the power 0 is 1".
|
||||||
|
- *Index Laws*: choose Multiply or Divide; inputs base b, powers m, n. Steps for multiply: (1) `b^m × b^n`; (2) expand both into factor strings side by side; (3) "count the factors: m + n of them" → `b^{m+n}`; (4) evaluated value. Divide analogously with cancellation visual (strike-through matching factors — render the expansion as individual chips and add `line-through opacity-40` to cancelled ones as steps advance) → `b^{m−n}`.
|
||||||
|
- Inline `QuickPractice`: seeded directly from the PDF quiz — `6³`, `2³ − 2¹`, `5⁶ / 5⁴` (ask for the answer as a power's value, keep numbers small), `7⁰`, plus randoms like `a^m × a^n = a^?` (answer: the exponent, an integer).
|
||||||
|
|
||||||
|
**New pages:** `app/lessons/unit-8-number-properties/{identity-inverse,number-laws,exponents}/page.tsx` following the standard topic-page shape.
|
||||||
|
|
||||||
|
### Phase 5 — Unit 6: Denary Place Value
|
||||||
|
|
||||||
|
**New file: `components/explorers/place-value-explorer.tsx`** — unit color `unit-6` (indigo), consistent with the binary explorers it sits beside (read `binary-converter-explorer.tsx` first and mirror its table/chart styling so the base-10 and base-2 pages feel like siblings).
|
||||||
|
- Input: a whole number 1–999,999.
|
||||||
|
- `buildPlaceValueSteps(n)`: (1) show the number and "each digit has a place value that is a power of 10"; (2) reveal the place-value chart header row: `10^5 = 100000 · 10^4 = 10000 · … · 10^0 = 1` (only as many columns as the number has digits); (3) one step per digit left→right: highlight the column, show `9 × 10^5 = 900000`; (4) assemble expanded form `983275 = 9×10^5 + 8×10^4 + 3×10^3 + 2×10^2 + 7×10^1 + 5×10^0` in KaTeX; (5) sum check back to the original number.
|
||||||
|
- Visual: an HTML table styled like the PDF's chart (Place Value / Digit / Product rows), current column highlighted with `bg-unit-6-light`, inside an `overflow-x-auto` wrapper for mobile.
|
||||||
|
- Inline `QuickPractice`: "In 47,382 — what is the VALUE of the digit 7?" (answer 7000); "Which power of 10 is the hundreds column? Enter the exponent" (answer 2).
|
||||||
|
|
||||||
|
**New page:** `app/lessons/unit-6-number-system/place-value/page.tsx` (breadcrumb unit label `Unit 6: Number System`).
|
||||||
|
|
||||||
|
### Phase 6 — Practice hub generators
|
||||||
|
|
||||||
|
**New file: `lib/problems/generators/integer-problems.ts`** exporting (all return `MathProblem` with `answer: { kind: "integer", value }`, difficulty-scaled ranges, unique `id` via the same scheme other generators use — check `fraction-problems.ts` for the id/random helpers and reuse its style):
|
||||||
|
- `generateIntegerAddSubtract(difficulty)` — d1: single op, values −10…10; d2: values −20…20 including double negatives like `8 − (−7)`; d3: three-term chains. Steps narrate the sign rules exactly as the explorer does.
|
||||||
|
- `generateIntegerMultiplyDivide(difficulty)` — d1: single product/quotient with small factors (division always exact); d2: larger values; d3: triple products like `(−2) × (−3) × (−4)` (the PDF homework question). Steps narrate sign-counting ("two negatives → positive").
|
||||||
|
- `generateTemperatureProblem(difficulty)` — word problems modeled on the PDF: "In London, the temperature is −3 °C on Monday. The next day it rose by 7 °C. What was the temperature on Tuesday?" Vary city, start temp, rise/fall.
|
||||||
|
|
||||||
|
**New file: `lib/problems/generators/number-properties-problems.ts`** exporting:
|
||||||
|
- `generateIdentityInverse(difficulty)` — additive-inverse and zero-fact questions with integer answers.
|
||||||
|
- `generateExponentEvaluate(difficulty)` — d1: squares/cubes of 2–9 and `n^0`/`n^1`; d2: PDF-quiz style (`6^3`, `2^3 − 2^1`); d3: index-law simplifications where the answer is the resulting exponent ("`4^4 × 4^3 = 4^x` — find x").
|
||||||
|
- `generateDistributive(difficulty)` — evaluate `a × (b + c)` numerically.
|
||||||
|
|
||||||
|
**New file: `lib/problems/generators/number-system-problems.ts`** exporting:
|
||||||
|
- `generateBinaryToDenary(difficulty)` — d1: 3–4 bits; d2: 5–6 bits (the PDF's `1011₂`, `111₂`, `1101₂` range); d3: 7–8 bits. Steps show the power-of-2 column products and the sum.
|
||||||
|
- `generateDigitValue(difficulty)` — "value of digit d in N" questions for base 10.
|
||||||
|
|
||||||
|
**Modify `app/practice/page.tsx`:**
|
||||||
|
- Import the new generators and append `TOPIC_GENERATORS` entries for: unit-5 `order-compare`→ *(skip — comparison answers don't fit integer input; leave its practice inline in the explorer)*, unit-5 `add-subtract` (`generateIntegerAddSubtract`), unit-5 `multiply-divide` (`generateIntegerMultiplyDivide`), unit-5 temperature word problems (attach to `multiply-divide` or `add-subtract` as a second entry with label "Temperature Problems"), unit-6 `place-value` (`generateDigitValue`), unit-6 `binary` (`generateBinaryToDenary`), unit-8 all three topics.
|
||||||
|
- Extend the `TopicGenerator["unitColor"]` union to `"unit-1" | ... | "unit-8"`, add `unit-5`–`unit-8` entries to the `unitColors` map (labels: "Integers", "Number System", "Coordinates", "Number Properties" — include unit-7 only if a coordinates generator is added; otherwise omit unit-7 from the filter row), and extend the filter-pill `as const` array accordingly.
|
||||||
|
|
||||||
|
Coordinates practice stays inside the explorers (click-the-grid needs custom input UI that `PracticeSection` doesn't support — do NOT extend `MathAnswer` for this; it's out of scope).
|
||||||
|
|
||||||
|
### Phase 7 — Verification
|
||||||
|
|
||||||
|
1. `npx tsc --noEmit` — zero errors.
|
||||||
|
2. `npm run lint` — zero errors.
|
||||||
|
3. `npm run build` — succeeds (catches server/client component violations; every page that renders an explorer must be `"use client"` or import a `"use client"` explorer, matching the existing pattern).
|
||||||
|
4. `npm run dev` and manually visit every new route (list in §3). For each: breadcrumbs correct, unit color correct, explorer loads, "Go" produces steps, StepControls play/step/reset work, QuickPractice checks answers correctly (test at least one correct and one incorrect answer each).
|
||||||
|
5. Specifically verify: the PDF shape-reveal dataset plots correctly and closes I→A; `n ÷ 0` shows "undefined" and never computes; `7⁰ = 1`; negative coordinates plot in the correct quadrants; ordering `-8, -10, -4, -3, -6, 5` ascending yields `-10, -8, -6, -4, -3, 5`.
|
||||||
|
6. `/practice`: new topics appear, unit filter pills for the new units work, answers validate.
|
||||||
|
7. Landing page: Units 7–8 render with art on every topic tile (no blank tiles — confirms `topic-art.tsx` keys match `unitSlug/topicSlug` exactly).
|
||||||
|
8. Check mobile layout (narrow viewport): the Cartesian grid and place-value table must scroll or scale, not overflow the page.
|
||||||
|
|
||||||
|
## 6. Acceptance Criteria (map back to the PDF)
|
||||||
|
|
||||||
|
- [ ] Order & compare integers: explorer covers number-line placement, real-world contexts, ascending/descending ordering, and `<`/`>` comparison, including the PDF's evaluation numbers
|
||||||
|
- [ ] Integer add/subtract and multiply/divide: already live; unchanged and still passing build
|
||||||
|
- [ ] Coordinates: plot-points + read-coordinates explorers, including the PDF's A–I shape activity as a preset
|
||||||
|
- [ ] Identity & inverse: explorer covers identity 0/1, additive & multiplicative inverse (with the −1/5 sign note), ×0 = 0, ÷0 undefined
|
||||||
|
- [ ] Number laws: explorer demonstrates all three laws AND the non-commutativity/non-associativity of − and ÷
|
||||||
|
- [ ] Exponents: explorer covers expansion, `a¹`, `a⁰`, product & quotient index laws; PDF quiz questions appear in QuickPractice
|
||||||
|
- [ ] Denary place value: explorer reproduces the PDF's 983275 expanded-form walkthrough for any input
|
||||||
|
- [ ] Binary system: already live (binary page)
|
||||||
|
- [ ] No week numbers, dates, or scheduling language anywhere in app UI text, slugs, titles, or descriptions
|
||||||
|
- [ ] All new topics wired into: curriculum.ts, sidebar, mobile nav, lessons index, landing page (with art), and the practice hub where applicable
|
||||||
|
- [ ] `tsc`, `lint`, and `build` all clean
|
||||||
BIN
unit_plan_term_1.pdf
Normal file
BIN
unit_plan_term_1.pdf
Normal file
Binary file not shown.
14
vitest.config.ts
Normal file
14
vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
include: ["lib/**/*.test.ts"],
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user