From a23fa53772769494bac638e30aba29f17768f248 Mon Sep 17 00:00:00 2001 From: warringtond Date: Sun, 5 Jul 2026 11:12:45 -0400 Subject: [PATCH] form 1 topics added --- app/globals.css | 14 + app/lessons/layout.tsx | 15 +- .../negative-fractions/page.tsx | 22 + .../unit-5-integers/order-compare/page.tsx | 22 + .../unit-6-number-system/place-value/page.tsx | 22 + app/lessons/unit-7-coordinates/page.tsx | 40 + .../unit-7-coordinates/plot-points/page.tsx | 22 + .../read-coordinates/page.tsx | 22 + .../exponents/page.tsx | 22 + .../identity-inverse/page.tsx | 22 + .../number-laws/page.tsx | 22 + app/lessons/unit-8-number-properties/page.tsx | 40 + app/not-found.tsx | 2 +- app/page.tsx | 12 +- app/practice/page.tsx | 457 ++++-- components/explorers/bodmas-explorer.tsx | 20 +- components/explorers/cartesian-grid.tsx | 181 +++ components/explorers/conversion-explorer.tsx | 6 +- components/explorers/exponents-explorer.tsx | 372 +++++ .../explorers/fraction-operation-explorer.tsx | 8 +- .../explorers/identity-inverse-explorer.tsx | 331 +++++ .../integer-add-subtract-explorer.tsx | 23 +- .../integer-multiply-divide-explorer.tsx | 43 +- .../integer-order-compare-explorer.tsx | 795 +++++++++++ .../explorers/negative-fractions-explorer.tsx | 393 ++++++ components/explorers/number-laws-explorer.tsx | 356 +++++ components/explorers/place-value-explorer.tsx | 351 +++++ components/explorers/plot-points-explorer.tsx | 418 ++++++ .../explorers/read-coordinates-explorer.tsx | 258 ++++ components/home/topic-art.tsx | 72 + components/layout/collapsible-sidebar.tsx | 74 + components/layout/header.tsx | 2 +- components/layout/lessons-shell.tsx | 22 + components/layout/mobile-nav.tsx | 93 -- components/layout/sidebar.tsx | 32 +- components/practice/fraction-input.tsx | 45 +- components/practice/practice-section.tsx | 85 +- components/ui/badge.tsx | 4 +- components/ui/button.tsx | 5 +- components/ui/card.tsx | 4 +- components/ui/client-only.tsx | 29 + lib/curriculum.ts | 74 +- lib/math/fractions.test.ts | 94 ++ lib/math/fractions.ts | 185 +-- lib/math/validation.test.ts | 73 + lib/math/validation.ts | 69 +- lib/problems/generators/fraction-problems.ts | 154 ++- lib/problems/generators/generators.test.ts | 72 + lib/problems/generators/integer-problems.ts | 210 +++ .../generators/number-properties-problems.ts | 136 ++ .../generators/number-system-problems.ts | 104 ++ lib/problems/generators/ratio-problems.ts | 52 + next.config.ts | 3 + package-lock.json | 1220 ++++++++++++++++- package.json | 7 +- plan.md | 319 +++++ unit_plan_term_1.pdf | Bin 0 -> 422523 bytes vitest.config.ts | 14 + 58 files changed, 7089 insertions(+), 475 deletions(-) create mode 100644 app/lessons/unit-5-integers/negative-fractions/page.tsx create mode 100644 app/lessons/unit-5-integers/order-compare/page.tsx create mode 100644 app/lessons/unit-6-number-system/place-value/page.tsx create mode 100644 app/lessons/unit-7-coordinates/page.tsx create mode 100644 app/lessons/unit-7-coordinates/plot-points/page.tsx create mode 100644 app/lessons/unit-7-coordinates/read-coordinates/page.tsx create mode 100644 app/lessons/unit-8-number-properties/exponents/page.tsx create mode 100644 app/lessons/unit-8-number-properties/identity-inverse/page.tsx create mode 100644 app/lessons/unit-8-number-properties/number-laws/page.tsx create mode 100644 app/lessons/unit-8-number-properties/page.tsx create mode 100644 components/explorers/cartesian-grid.tsx create mode 100644 components/explorers/exponents-explorer.tsx create mode 100644 components/explorers/identity-inverse-explorer.tsx create mode 100644 components/explorers/integer-order-compare-explorer.tsx create mode 100644 components/explorers/negative-fractions-explorer.tsx create mode 100644 components/explorers/number-laws-explorer.tsx create mode 100644 components/explorers/place-value-explorer.tsx create mode 100644 components/explorers/plot-points-explorer.tsx create mode 100644 components/explorers/read-coordinates-explorer.tsx create mode 100644 components/layout/collapsible-sidebar.tsx create mode 100644 components/layout/lessons-shell.tsx delete mode 100644 components/layout/mobile-nav.tsx create mode 100644 components/ui/client-only.tsx create mode 100644 lib/math/fractions.test.ts create mode 100644 lib/math/validation.test.ts create mode 100644 lib/problems/generators/generators.test.ts create mode 100644 lib/problems/generators/integer-problems.ts create mode 100644 lib/problems/generators/number-properties-problems.ts create mode 100644 lib/problems/generators/number-system-problems.ts create mode 100644 plan.md create mode 100644 unit_plan_term_1.pdf create mode 100644 vitest.config.ts diff --git a/app/globals.css b/app/globals.css index dfd04fb..652f4ae 100644 --- a/app/globals.css +++ b/app/globals.css @@ -33,6 +33,14 @@ --unit-6-light: #eef2ff; --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-light: #dcfce7; --incorrect: #dc2626; @@ -73,6 +81,12 @@ --color-unit-6: var(--unit-6); --color-unit-6-light: var(--unit-6-light); --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-light: var(--correct-light); diff --git a/app/lessons/layout.tsx b/app/lessons/layout.tsx index 3bd86c8..c17d5e6 100644 --- a/app/lessons/layout.tsx +++ b/app/lessons/layout.tsx @@ -1,7 +1,6 @@ import { Header } from "@/components/layout/header"; import { Footer } from "@/components/layout/footer"; -import { Sidebar } from "@/components/layout/sidebar"; -import { MobileNav } from "@/components/layout/mobile-nav"; +import { LessonsShell } from "@/components/layout/lessons-shell"; export default function LessonsLayout({ children, @@ -11,17 +10,7 @@ export default function LessonsLayout({ return (
-
- -
-
- -
-
- {children} -
-
-
+ {children}
); diff --git a/app/lessons/unit-5-integers/negative-fractions/page.tsx b/app/lessons/unit-5-integers/negative-fractions/page.tsx new file mode 100644 index 0000000..55bb39f --- /dev/null +++ b/app/lessons/unit-5-integers/negative-fractions/page.tsx @@ -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 ( +
+ + +

Negative Fractions

+ + +
+ ); +} diff --git a/app/lessons/unit-5-integers/order-compare/page.tsx b/app/lessons/unit-5-integers/order-compare/page.tsx new file mode 100644 index 0000000..00e8f73 --- /dev/null +++ b/app/lessons/unit-5-integers/order-compare/page.tsx @@ -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 ( +
+ + +

Order and Compare Integers

+ + +
+ ); +} diff --git a/app/lessons/unit-6-number-system/place-value/page.tsx b/app/lessons/unit-6-number-system/place-value/page.tsx new file mode 100644 index 0000000..656b0cd --- /dev/null +++ b/app/lessons/unit-6-number-system/place-value/page.tsx @@ -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 ( +
+ + +

Denary Place Value (Base 10)

+ + +
+ ); +} diff --git a/app/lessons/unit-7-coordinates/page.tsx b/app/lessons/unit-7-coordinates/page.tsx new file mode 100644 index 0000000..77a2971 --- /dev/null +++ b/app/lessons/unit-7-coordinates/page.tsx @@ -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 ( +
+ +
+ Unit 7 +

{unit.title}

+

{unit.description}

+
+
+ {unit.topics.map((topic, i) => ( + + +
+ + {i + 1} + +
+

{topic.title}

+

{topic.description}

+
+ + ))} +
+
+ ); +} diff --git a/app/lessons/unit-7-coordinates/plot-points/page.tsx b/app/lessons/unit-7-coordinates/plot-points/page.tsx new file mode 100644 index 0000000..f619739 --- /dev/null +++ b/app/lessons/unit-7-coordinates/plot-points/page.tsx @@ -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 ( +
+ + +

Plot Points on the Cartesian Plane

+ + +
+ ); +} diff --git a/app/lessons/unit-7-coordinates/read-coordinates/page.tsx b/app/lessons/unit-7-coordinates/read-coordinates/page.tsx new file mode 100644 index 0000000..04dcb47 --- /dev/null +++ b/app/lessons/unit-7-coordinates/read-coordinates/page.tsx @@ -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 ( +
+ + +

State Coordinates of Points

+ + +
+ ); +} diff --git a/app/lessons/unit-8-number-properties/exponents/page.tsx b/app/lessons/unit-8-number-properties/exponents/page.tsx new file mode 100644 index 0000000..8d74b92 --- /dev/null +++ b/app/lessons/unit-8-number-properties/exponents/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { ExponentsExplorer } from "@/components/explorers/exponents-explorer"; + +export default function ExponentsPage() { + return ( +
+ + +

Powers and Exponents

+ + +
+ ); +} diff --git a/app/lessons/unit-8-number-properties/identity-inverse/page.tsx b/app/lessons/unit-8-number-properties/identity-inverse/page.tsx new file mode 100644 index 0000000..70fcd96 --- /dev/null +++ b/app/lessons/unit-8-number-properties/identity-inverse/page.tsx @@ -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 ( +
+ + +

Identity and Inverse

+ + +
+ ); +} diff --git a/app/lessons/unit-8-number-properties/number-laws/page.tsx b/app/lessons/unit-8-number-properties/number-laws/page.tsx new file mode 100644 index 0000000..4a5d96c --- /dev/null +++ b/app/lessons/unit-8-number-properties/number-laws/page.tsx @@ -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 ( +
+ + +

Commutative, Associative and Distributive Laws

+ + +
+ ); +} diff --git a/app/lessons/unit-8-number-properties/page.tsx b/app/lessons/unit-8-number-properties/page.tsx new file mode 100644 index 0000000..2226d85 --- /dev/null +++ b/app/lessons/unit-8-number-properties/page.tsx @@ -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 ( +
+ +
+ Unit 8 +

{unit.title}

+

{unit.description}

+
+
+ {unit.topics.map((topic, i) => ( + + +
+ + {i + 1} + +
+

{topic.title}

+

{topic.description}

+
+ + ))} +
+
+ ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx index 0706160..73b99a0 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -6,7 +6,7 @@ export default function NotFound() { return (
-
+

diff --git a/app/page.tsx b/app/page.tsx index a6b2165..df54769 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -35,6 +35,16 @@ const unitStyles = { unitCard: "border-[#a5b4fc] bg-[#eef2ff]", 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 = [ @@ -59,7 +69,7 @@ export default function Home() { return (

-
+

Interactive maths practice for secondary school students diff --git a/app/practice/page.tsx b/app/practice/page.tsx index 3faa066..ad78b88 100644 --- a/app/practice/page.tsx +++ b/app/practice/page.tsx @@ -1,167 +1,338 @@ "use client"; import { useState } from "react"; -import { Card } from "@/components/ui/card"; +import Link from "next/link"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; import { Header } from "@/components/layout/header"; import { Footer } from "@/components/layout/footer"; 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 { 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 { 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 { - unitSlug: string; - topicSlug: string; - label: string; - generator: (difficulty: Difficulty) => MathProblem; - unitColor: "unit-1" | "unit-2" | "unit-3" | "unit-4"; -} +type UnitColor = Unit["color"]; +type PracticeUnitColor = Exclude; +type Generator = (difficulty: Difficulty) => MathProblem; -type UnitColor = TopicGenerator["unitColor"]; - -const TOPIC_GENERATORS: TopicGenerator[] = [ - { unitSlug: "unit-1-fractions", topicSlug: "add-subtract", label: "Add & Subtract Fractions", generator: generateFractionAddSubtract, unitColor: "unit-1" }, - { 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" }, - { unitSlug: "unit-1-fractions", topicSlug: "fraction-of-quantity", label: "Fraction of a Quantity", generator: generateFractionOfQuantity, unitColor: "unit-1" }, - { unitSlug: "unit-1-fractions", topicSlug: "whole-from-fractions", label: "Find the Whole", generator: generateWholeFromFraction, unitColor: "unit-1" }, - { unitSlug: "unit-2-decimals", topicSlug: "compare-order", label: "Compare & Order Decimals", generator: generateDecimalCompareOrder, unitColor: "unit-2" }, - { unitSlug: "unit-2-decimals", topicSlug: "approximate", label: "Approximate Decimals", generator: generateDecimalRounding, unitColor: "unit-2" }, - { unitSlug: "unit-2-decimals", topicSlug: "standard-form", label: "Standard Form", generator: generateStandardForm, unitColor: "unit-2" }, - { unitSlug: "unit-3-decimal-operations", topicSlug: "convert", label: "Convert Decimals & Fractions", generator: generateDecimalConversion, unitColor: "unit-3" }, - { unitSlug: "unit-3-decimal-operations", topicSlug: "add-subtract", label: "Add & Subtract Decimals", generator: generateDecimalAddSubtract, unitColor: "unit-3" }, - { unitSlug: "unit-3-decimal-operations", topicSlug: "multiply-divide", label: "Multiply & Divide Decimals", generator: generateDecimalMultiplyDivide, unitColor: "unit-3" }, - { unitSlug: "unit-4-ratio-proportion", topicSlug: "simplify-ratios", label: "Simplify Ratios", generator: generateSimplifyRatio, unitColor: "unit-4" }, - { unitSlug: "unit-4-ratio-proportion", topicSlug: "divide-in-ratio", label: "Divide in a Ratio", generator: generateDivideInRatio, unitColor: "unit-4" }, - { unitSlug: "unit-4-ratio-proportion", topicSlug: "word-problems", label: "Ratio Word Problems", generator: generateRatioWordProblem, unitColor: "unit-4" }, -]; - -const unitColors: Record = { - "unit-1": { - activeFilter: "border-unit-1 bg-unit-1 text-white shadow-[var(--shadow-sm)]", - inactiveFilter: "border-unit-1/40 text-unit-1-dark hover:bg-unit-1-light", - label: "Fractions", - }, - "unit-2": { - activeFilter: "border-unit-2 bg-unit-2 text-white shadow-[var(--shadow-sm)]", - inactiveFilter: "border-unit-2/40 text-unit-2-dark hover:bg-unit-2-light", - label: "Decimals", - }, - "unit-3": { - activeFilter: "border-unit-3 bg-unit-3 text-white shadow-[var(--shadow-sm)]", - inactiveFilter: "border-unit-3/40 text-unit-3-dark hover:bg-unit-3-light", - label: "Decimal Operations", - }, - "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", - }, +/** + * Practice for each curriculum topic. "lesson-only" topics (the coordinate grid) + * need the interactive visual, so they link to their lesson instead of a + * fill-in-the-answer generator. Keyed by `${unitSlug}/${topicSlug}`. + */ +const PRACTICE_BY_TOPIC: Record = { + "unit-1-fractions/add-subtract": generateFractionAddSubtract, + "unit-1-fractions/multiply": generateFractionMultiply, + "unit-1-fractions/divide": generateFractionDivide, + "unit-1-fractions/mixed-operations": generateFractionBodmas, + "unit-1-fractions/fraction-of-quantity": generateFractionOfQuantity, + "unit-1-fractions/whole-from-fractions": generateWholeFromFraction, + "unit-2-decimals/compare-order": generateDecimalCompareOrder, + "unit-2-decimals/approximate": generateDecimalRounding, + "unit-2-decimals/standard-form": generateStandardForm, + "unit-3-decimal-operations/convert": generateDecimalConversion, + "unit-3-decimal-operations/add-subtract": generateDecimalAddSubtract, + "unit-3-decimal-operations/multiply-divide": generateDecimalMultiplyDivide, + "unit-4-ratio-proportion/define-ratio": generateDefineRatio, + "unit-4-ratio-proportion/fractions-and-ratios": generateFractionsAndRatios, + "unit-4-ratio-proportion/simplify-ratios": generateSimplifyRatio, + "unit-4-ratio-proportion/divide-in-ratio": generateDivideInRatio, + "unit-4-ratio-proportion/word-problems": generateRatioWordProblem, + "unit-5-integers/order-compare": generateIntegerOrderCompare, + "unit-5-integers/add-subtract": generateIntegerAddSubtractMixed, + "unit-5-integers/multiply-divide": generateIntegerMultiplyDivide, + "unit-5-integers/negative-fractions": generateSignedFraction, + "unit-6-number-system/place-value": generateDigitValue, + "unit-6-number-system/binary": generateBinaryToDenary, + "unit-6-number-system/quaternary": generateQuaternaryToDenary, + "unit-7-coordinates/plot-points": "lesson-only", + "unit-7-coordinates/read-coordinates": "lesson-only", + "unit-8-number-properties/identity-inverse": generateIdentityInverse, + "unit-8-number-properties/number-laws": generateDistributive, + "unit-8-number-properties/exponents": generateExponentEvaluate, }; -export default function PracticePage() { - const [selectedTopic, setSelectedTopic] = useState(null); - const [filterUnit, setFilterUnit] = useState(null); +function practiceFor(unit: Unit, topic: Topic): Generator | "lesson-only" { + return PRACTICE_BY_TOPIC[`${unit.slug}/${topic.slug}`] ?? "lesson-only"; +} - const filtered = filterUnit - ? TOPIC_GENERATORS.filter((t) => t.unitColor === filterUnit) - : TOPIC_GENERATORS; +interface UnitStyle { + heading: string; + dot: string; + accent: string; + iconBg: string; + hover: string; + activeItem: string; + headingHover: string; +} + +const UNIT_STYLE: Record = { + "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 ( + + ); +} + +// ── 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>(() => 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 ( +

+ ); +} + +// ── Topic browser (main area, all topics) ──────────────────────────────────── + +function TopicBrowser({ onSelect }: { onSelect: (s: Selection) => void }) { + return ( +
+ {curriculum.map((unit) => { + const style = UNIT_STYLE[unit.color]; + return ( +
+
+ +

+ Unit {unit.number}: {unit.title} +

+ + {unit.topics.length} + +
+ +
+ {unit.topics.map((topic) => ( + + ))} +
+
+ ); + })} +
+ ); +} + +// ── Panel for topics that are practised in the interactive lesson ──────────── + +function LessonOnlyPanel({ unit, topic }: { unit: Unit; topic: Topic }) { + return ( +
+
+ +
+

{topic.title}

+

+ This topic uses the interactive coordinate grid, so it's best practised right in the lesson. +

+ + + +
+ ); +} + +// ── Page ───────────────────────────────────────────────────────────────────── + +export default function PracticePage() { + const [selected, setSelected] = useState(null); + const { mobileOpen, desktopCollapsed, toggle, closeMobile } = useCollapsibleSidebar(); + + function select(s: Selection) { + setSelected(s); + closeMobile(); + } + + const generator = selected ? practiceFor(selected.unit, selected.topic) : null; return (
-
-
-

Practice

-

- Pick a topic and build your confidence with fresh questions each round. -

+
+ - {/* Unit filters */} -
- - {(["unit-1", "unit-2", "unit-3", "unit-4"] as const).map((uc) => ( - - ))} + { + setSelected(null); + closeMobile(); + }} + mobileOpen={mobileOpen} + desktopCollapsed={desktopCollapsed} + /> + +
+
+ + + {!selected ? ( + <> +

Practice

+

+ Pick a topic and build your confidence with fresh questions each round. +

+ + + ) : ( + <> + + + {generator === "lesson-only" || generator === null ? ( + + ) : ( + + )} + + )}
- - {/* Topic grid */} - {!selectedTopic && ( -
- {filtered.map((topic) => ( - setSelectedTopic(topic)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setSelectedTopic(topic); - } - }} - > -
- {topic.label} - - {unitColors[topic.unitColor].label} - -
-
- ))} -
- )} - - {/* Practice area */} - {selectedTopic && ( -
- - - -
- )} -
-
+
+
); diff --git a/components/explorers/bodmas-explorer.tsx b/components/explorers/bodmas-explorer.tsx index 4fe9340..374fc6c 100644 --- a/components/explorers/bodmas-explorer.tsx +++ b/components/explorers/bodmas-explorer.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { Card } from "@/components/ui/card"; import { StepControls } from "./step-controls"; 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 { num: number; @@ -30,24 +30,26 @@ const OP_PRIORITY: Record = { }; 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) { case "+": - result = add(a.num, a.den, b.num, b.den); + result = fa.plus(fb); break; case "−": - result = subtract(a.num, a.den, b.num, b.den); + result = fa.minus(fb); break; case "×": - result = multiply(a.num, a.den, b.num, b.den); + result = fa.times(fb); break; case "÷": - result = divide(a.num, a.den, b.num, b.den); + result = fa.dividedBy(fb); break; 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 { @@ -97,7 +99,7 @@ function buildBodmasSteps(expr: Expression): Step[] { // Perform the operation 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]; newFracs.splice(opIdx, 2, { num: sn, den: sd }); diff --git a/components/explorers/cartesian-grid.tsx b/components/explorers/cartesian-grid.tsx new file mode 100644 index 0000000..7bb50ba --- /dev/null +++ b/components/explorers/cartesian-grid.tsx @@ -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, 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) { + 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 ( + + + + + + + + + + + {/* Light gridlines */} + {ticks.map((v) => ( + + + + + ))} + + {/* Axes */} + + + x + y + + {/* Axis labels */} + {labelTicks.map((v) => ( + + {v} + + ))} + {labelTicks.map((v) => ( + + {v} + + ))} + 0 + + {/* Guide lines */} + {highlight && ( + + {(highlight.axis ?? "both") !== "y" && ( + + )} + {(highlight.axis ?? "both") !== "x" && ( + + )} + + )} + + {/* Segments */} + {segments.map((s, i) => ( + + ))} + + {/* Movement arrows */} + {arrows.map((a, i) => ( + + ))} + + {/* Points */} + {points.map((p, i) => { + const color = toneColor[p.tone ?? "solid"]; + return ( + + + {p.label && ( + + {p.label} + + )} + + ); + })} + + ); +} diff --git a/components/explorers/conversion-explorer.tsx b/components/explorers/conversion-explorer.tsx index 62bd957..03a9dc5 100644 --- a/components/explorers/conversion-explorer.tsx +++ b/components/explorers/conversion-explorer.tsx @@ -4,7 +4,7 @@ import { useState, useCallback } from "react"; import { Card } from "@/components/ui/card"; import { StepControls } from "./step-controls"; 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"; type ConvertMode = "decToFrac" | "fracToDec"; @@ -42,7 +42,7 @@ function buildDecToFracSteps(decimal: string): Step[] { }); // Simplify - const [sn, sd] = simplify(numerator, denominator); + const { n: sn, d: sd } = new Fraction(numerator, denominator).simplified(); if (sn !== numerator || sd !== denominator) { const g = gcd(numerator, denominator); steps.push({ @@ -89,7 +89,7 @@ function buildFracToDecSteps(num: number, den: number): Step[] { // Check for terminating vs repeating let tempDen = den; - const [, simpDen] = simplify(num, den); + const { d: simpDen } = new Fraction(num, den).simplified(); tempDen = simpDen; // Remove factors of 2 and 5 while (tempDen % 2 === 0) tempDen /= 2; diff --git a/components/explorers/exponents-explorer.tsx b/components/explorers/exponents-explorer.tsx new file mode 100644 index 0000000..86477c3 --- /dev/null +++ b/components/explorers/exponents-explorer.tsx @@ -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 = { + "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(() => 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 ( + +
+

Quick Practice

+ + {score.correct}/{score.total} + +
+

{q.prompt}

+
+ { + 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 ? ( + + ) : ( + + )} +
+ {feedback === "correct" &&

Correct!

} + {feedback === "incorrect" && ( +

Not quite — the answer is {q.answer}.

+ )} +
+ ); +} + +// ── Main explorer ──────────────────────────────────────────────────────────── + +export function ExponentsExplorer() { + const [mode, setMode] = useState("expand"); + const [indexOp, setIndexOp] = useState("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(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 ( +
+ {/* Mode toggle */} +
+ {(["expand", "index"] as Mode[]).map((md) => ( + + ))} +
+ + {/* Index op tabs */} + {mode === "index" && ( +
+ {(["multiply", "divide"] as IndexOp[]).map((o) => ( + + ))} +
+ )} + + {/* Input card */} + + {mode === "expand" ? ( + <> +

Enter a base and a power

+
+ 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" /> + to the power + 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" /> + +
+ + ) : ( + <> +

+ Same base, two powers {indexOp === "multiply" ? "to multiply" : "to divide"} +

+
+ base + 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" /> + powers + 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" /> + {indexOp === "multiply" ? "×" : "÷"} + 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" /> + +
+ + )} + {error &&

{error}

} +
+ + {/* Display card */} + + {!step ? ( +

+ Enter values above and click Go +

+ ) : ( + <> +

{step.label}

+ + + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Quick practice */} + }> + + +
+ ); +} diff --git a/components/explorers/fraction-operation-explorer.tsx b/components/explorers/fraction-operation-explorer.tsx index 5217ca1..07386fc 100644 --- a/components/explorers/fraction-operation-explorer.tsx +++ b/components/explorers/fraction-operation-explorer.tsx @@ -3,7 +3,7 @@ import { useState, useCallback } from "react"; import { Card } from "@/components/ui/card"; 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"; type Operation = "add" | "subtract" | "multiply" | "divide"; @@ -68,7 +68,7 @@ function buildSteps( }); // 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) { const g = gcd(Math.abs(resultNum), lcd); steps.push({ @@ -92,7 +92,7 @@ function buildSteps( }); // Step 2: Simplify - const [sn, sd] = simplify(rn, rd); + const { n: sn, d: sd } = new Fraction(rn, rd).simplified(); if (sn !== rn || sd !== rd) { const g = gcd(Math.abs(rn), rd); steps.push({ @@ -122,7 +122,7 @@ function buildSteps( 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) { const g = gcd(Math.abs(rn), rd); steps.push({ diff --git a/components/explorers/identity-inverse-explorer.tsx b/components/explorers/identity-inverse-explorer.tsx new file mode 100644 index 0000000..ad8ef5d --- /dev/null +++ b/components/explorers/identity-inverse-explorer.tsx @@ -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(() => 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 ( + +
+

Quick Practice

+ + {score.correct}/{score.total} + +
+

{q.prompt}

+
+ { + 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 ? ( + + ) : ( + + )} +
+ {feedback === "correct" &&

Correct!

} + {feedback === "incorrect" && ( +

Not quite — the answer is {q.answer}.

+ )} +
+ ); +} + +// ── Main explorer ──────────────────────────────────────────────────────────── + +export function IdentityInverseExplorer() { + const [mode, setMode] = useState("identity"); + const [op, setOp] = useState("add"); + const [input, setInput] = useState("3"); + const [error, setError] = useState(""); + + const [steps, setSteps] = useState(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 ( +
+ {/* Mode toggle */} +
+ {(["identity", "inverse", "zero"] as Mode[]).map((m) => ( + + ))} +
+ + {/* Operation tabs */} + {showOp && ( +
+ {(["add", "multiply"] as Op[]).map((o) => ( + + ))} +
+ )} + + {/* Input card */} + +

+ {mode === "identity" + ? "See how the identity leaves a number unchanged" + : mode === "inverse" + ? "Find the inverse of a number" + : "Explore what happens with zero"} +

+
+ 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" + /> + +
+ {error &&

{error}

} +
+ + {/* Display card */} + + {!step ? ( +

+ Enter a number above and click Go +

+ ) : ( + <> +

{step.label}

+ + + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Quick practice */} + }> + + +
+ ); +} diff --git a/components/explorers/integer-add-subtract-explorer.tsx b/components/explorers/integer-add-subtract-explorer.tsx index 06ccb2e..4a15a01 100644 --- a/components/explorers/integer-add-subtract-explorer.tsx +++ b/components/explorers/integer-add-subtract-explorer.tsx @@ -2,6 +2,7 @@ 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"; @@ -282,21 +283,21 @@ function SignRuleCard({ rule, active }: { rule: string; active: boolean }) { ); } +function generateProblem() { + const ops = ["+", "-"] as const; + const op = ops[Math.floor(Math.random() * ops.length)]; + const a = Math.floor(Math.random() * 21) - 10; + const b = Math.floor(Math.random() * 21) - 10; + const answer = op === "+" ? a + b : a - b; + 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 generateProblem() { - const ops = ["+", "-"] as const; - const op = ops[Math.floor(Math.random() * ops.length)]; - const a = Math.floor(Math.random() * 21) - 10; - const b = Math.floor(Math.random() * 21) - 10; - const answer = op === "+" ? a + b : a - b; - return { a, b, op, answer }; - } - function checkAnswer() { const parsed = parseInt(userAnswer); if (isNaN(parsed)) return; @@ -587,7 +588,9 @@ export function IntegerAddSubtractExplorer() { {/* Quick Practice */} - + }> + +
); } diff --git a/components/explorers/integer-multiply-divide-explorer.tsx b/components/explorers/integer-multiply-divide-explorer.tsx index 01e28fa..ffb8fb5 100644 --- a/components/explorers/integer-multiply-divide-explorer.tsx +++ b/components/explorers/integer-multiply-divide-explorer.tsx @@ -2,6 +2,7 @@ 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"; @@ -191,8 +192,6 @@ function Thermometer({ data }: { data?: Step["thermometer"] }) { const startTemp = data?.startTemp ?? 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 ticks = []; @@ -372,30 +371,30 @@ function ThermometerPractice() { ); } +function generateProblem() { + const ops = ["×", "÷"] as const; + const op = ops[Math.floor(Math.random() * ops.length)]; + + if (op === "×") { + const a = Math.floor(Math.random() * 25) - 12; + const b = Math.floor(Math.random() * 25) - 12; + return { a, b, op, answer: a * b }; + } else { + // Generate division with clean integer results + const b = Math.floor(Math.random() * 11) - 5; + const nonZeroB = b === 0 ? 3 : b; + const answer = Math.floor(Math.random() * 21) - 10; + const a = answer * nonZeroB; + 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 generateProblem() { - const ops = ["×", "÷"] as const; - const op = ops[Math.floor(Math.random() * ops.length)]; - - if (op === "×") { - const a = Math.floor(Math.random() * 25) - 12; - const b = Math.floor(Math.random() * 25) - 12; - return { a, b, op, answer: a * b }; - } else { - // Generate division with clean integer results - const b = Math.floor(Math.random() * 11) - 5; - const nonZeroB = b === 0 ? 3 : b; - const answer = Math.floor(Math.random() * 21) - 10; - const a = answer * nonZeroB; - return { a, b: nonZeroB, op, answer }; - } - } - function check() { const parsed = parseInt(userAnswer); if (isNaN(parsed)) return; @@ -702,7 +701,9 @@ export function IntegerMultiplyDivideExplorer() { {/* Practice sections */} - {tab === "thermometer" ? : } + }> + {tab === "thermometer" ? : } +
); } diff --git a/components/explorers/integer-order-compare-explorer.tsx b/components/explorers/integer-order-compare-explorer.tsx new file mode 100644 index 0000000..74971a9 --- /dev/null +++ b/components/explorers/integer-order-compare-explorer.tsx @@ -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 ( +
+
+ {/* 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 ( +
+
+ {m.label !== undefined && ( + + {m.label} + + )} +
+
+
+ ); + })} + + {/* Number line */} +
+
+ {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 ( +
+
+ {(val % 5 === 0 || marked) && ( + + {val} + + )} +
+ ); + })} +
+ + {/* Edge labels */} +
+ ← negative + positive → +
+
+
+ ); +} + +function OrderChipsView({ order, direction }: { order: OrderChips; direction: Direction }) { + const emptySlots = order.total - order.ordered.length; + return ( +
+
+

Still to place

+
+ {order.pool.length === 0 ? ( + — all placed — + ) : ( + order.pool.map((v, i) => ( + + {v} + + )) + )} +
+
+
+

+ Ordered ({direction === "asc" ? "small → large" : "large → small"}) +

+
+ {order.ordered.map((v, i) => ( + + {i > 0 && {direction === "asc" ? "<" : ">"}} + + {v} + + + ))} + {Array.from({ length: emptySlots }, (_, i) => ( + + {(order.ordered.length > 0 || i > 0) && ( + {direction === "asc" ? "<" : ">"} + )} + + + ))} +
+
+
+ ); +} + +// ── 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(); + 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 ( +
+

Which sign makes it true?

+
+ {a} + + ? + + {b} +
+
+ {(["<", ">"] as const).map((sign) => ( + + ))} +
+ {feedback === "correct" && ( +
+

Correct! {a} {answer} {b}

+ +
+ )} + {feedback === "incorrect" && ( +

Not quite — remember, further right means larger. Try again.

+ )} +
+ ); +} + +function OrderPractice({ onScore }: { onScore: (correct: boolean) => void }) { + const [values, setValues] = useState(() => makeOrderValues()); + const [placed, setPlaced] = useState([]); + const [wrong, setWrong] = useState(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 ( +
+

Click the numbers in ascending order (smallest first).

+
+ {values.map((v) => { + const isPlaced = placed.includes(v); + const isWrong = wrong === v; + return ( + + ); + })} +
+
+ {placed.map((v, i) => ( + + {i > 0 && {"<"}} + {v} + + ))} + {placed.length < sorted.length && } +
+ {done && ( +
+

+ {mistakes === 0 ? "Perfect ordering!" : `Ordered — with ${mistakes} slip${mistakes === 1 ? "" : "s"}.`} +

+ +
+ )} +
+ ); +} + +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 ( + +
+
+ {(["compare", "order"] as const).map((k) => ( + + ))} +
+ + {score.correct}/{score.total} + +
+ {kind === "compare" ? ( + + ) : ( + + )} +
+ ); +} + +// ── Main explorer ──────────────────────────────────────────────────────────── + +export function IntegerOrderCompareExplorer() { + const [mode, setMode] = useState("number-line"); + + // number-line mode + const [nlInput, setNlInput] = useState("-7"); + const [context, setContext] = useState("thermometer"); + + // order mode + const [orderInput, setOrderInput] = useState("-8, -10, -4, -3, -6, 5"); + const [direction, setDirection] = useState("asc"); + + // compare mode + const [cmpA, setCmpA] = useState("-5"); + const [cmpB, setCmpB] = useState("5"); + + const [error, setError] = useState(""); + const [steps, setSteps] = useState(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 ( +
+ {/* Mode toggle */} +
+ {(["number-line", "order", "compare"] as Mode[]).map((m) => ( + + ))} +
+ + {/* Input card */} + + {mode === "number-line" && ( + <> +

+ Place an integer on the number line +

+
+ {(Object.keys(CONTEXTS) as ContextKey[]).map((key) => ( + + ))} +
+
+ 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" + /> + +
+ + )} + + {mode === "order" && ( + <> +

+ Enter 3–6 integers to order +

+
+ {(["asc", "desc"] as Direction[]).map((d) => ( + + ))} +
+
+ 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" + /> + +
+ + )} + + {mode === "compare" && ( + <> +

+ Compare two integers with < or > +

+
+ 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" + /> + ? + 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" + /> + +
+ + )} + {error &&

{error}

} +
+ + {/* Display card */} + + {!step ? ( +

+ Set up the numbers above and click Go +

+ ) : ( + <> +

{step.label}

+ + + {step.order && } + + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Quick practice */} + }> + + +
+ ); +} diff --git a/components/explorers/negative-fractions-explorer.tsx b/components/explorers/negative-fractions-explorer.tsx new file mode 100644 index 0000000..d7c1050 --- /dev/null +++ b/components/explorers/negative-fractions-explorer.tsx @@ -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 = { "+": "+", "-": "−", "\\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(() => 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 ( + +
+

Quick Practice

+ + {score.correct}/{score.total} + +
+

Work it out and give your answer in its simplest form.

+
+ + + {status === "solved" ? ( + + ) : ( + + )} +
+ {status === "solved" &&

Correct!

} + {status === "unsimplified" && ( +

Right value — now write it in its simplest form.

+ )} + {status === "wrong" && ( +

Not quite. Watch the signs, then try again.

+ )} +
+ ); +} + +// ── Main explorer ──────────────────────────────────────────────────────────── + +export function NegativeFractionsExplorer() { + const [op, setOp] = useState("+"); + 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(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 ( +
+ {/* Operation selector */} +
+ {(["+", "-", "\\times", "\\div"] as Op[]).map((o) => ( + + ))} +
+ + {/* Input card */} + +

+ Enter two fractions (numerators can be negative) +

+
+
+ setN1(e.target.value)} className={fieldClass} aria-label="First numerator" /> +
+ setD1(e.target.value)} className={fieldClass} aria-label="First denominator" /> +
+ {OP_LABEL[op]} +
+ setN2(e.target.value)} className={fieldClass} aria-label="Second numerator" /> +
+ setD2(e.target.value)} className={fieldClass} aria-label="Second denominator" /> +
+ +
+ {error &&

{error}

} + + + {/* Display card */} + + {!step ? ( +

+ Enter two fractions above and click Go +

+ ) : ( + <> +

{step.label}

+ + + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Quick practice */} + }> + + +
+ ); +} diff --git a/components/explorers/number-laws-explorer.tsx b/components/explorers/number-laws-explorer.tsx new file mode 100644 index 0000000..357dba6 --- /dev/null +++ b/components/explorers/number-laws-explorer.tsx @@ -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 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(() => 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 ( + +
+

Quick Practice

+ + {score.correct}/{score.total} + +
+

{q.prompt}

+
+ { + 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 ? ( + + ) : ( + + )} +
+ {feedback === "correct" &&

Correct!

} + {feedback === "incorrect" && ( +

Not quite — the answer is {q.answer}.

+ )} +
+ ); +} + +// ── Main explorer ──────────────────────────────────────────────────────────── + +export function NumberLawsExplorer() { + const [mode, setMode] = useState("commutative"); + const [op, setOp] = useState("+"); + const [a, setA] = useState("8"); + const [b, setB] = useState("3"); + const [c, setC] = useState("2"); + const [error, setError] = useState(""); + + const [steps, setSteps] = useState(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 = { "+": "+", "-": "−", "\\times": "×", "\\div": "÷" }; + + return ( +
+ {/* Mode toggle */} +
+ {(["commutative", "associative", "distributive"] as Mode[]).map((m) => ( + + ))} +
+ + {/* Operation tabs */} + {showOp && ( +
+ {opChoices.map((o) => ( + + ))} +
+ )} + + {/* Input card */} + +

+ {mode === "distributive" ? "Enter a, b and c for a × (b + c)" : showThree ? "Enter three numbers" : "Enter two numbers"} +

+
+ 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" /> + 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 && ( + 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" /> + )} + +
+ {error &&

{error}

} +
+ + {/* Display card */} + + {!step ? ( +

+ Enter numbers above and click Go +

+ ) : ( + <> +

{step.label}

+ + + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Quick practice */} + }> + + +
+ ); +} diff --git a/components/explorers/place-value-explorer.tsx b/components/explorers/place-value-explorer.tsx new file mode 100644 index 0000000..8678d60 --- /dev/null +++ b/components/explorers/place-value-explorer.tsx @@ -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 ( +
+ + + + {columns.map((c, i) => ( + + ))} + + + {columns.map((c, i) => ( + + ))} + + + {columns.map((c, i) => ( + + ))} + + + {columns.map((c, i) => ( + + ))} + + +
+ 10{c.exponent} +
+ {c.placeValue.toLocaleString("en-US")} +
+ {c.digit} +
+ {i < step.revealed ? c.contrib.toLocaleString("en-US") : "_"} +
+
+ ); +} + +// ── 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(() => 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 ( + +
+

Quick Practice

+ + {score.correct}/{score.total} + +
+

{q.prompt}

+
+ { + 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 ? ( + + ) : ( + + )} +
+ {feedback === "correct" &&

Correct!

} + {feedback === "incorrect" && ( +

Not quite — the answer is {q.answer.toLocaleString("en-US")}.

+ )} +
+ ); +} + +// ── Main explorer ──────────────────────────────────────────────────────────── + +export function PlaceValueExplorer() { + const [input, setInput] = useState("983275"); + const [error, setError] = useState(""); + const [columns, setColumns] = useState(null); + const [steps, setSteps] = useState(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 ( +
+ {/* Input card */} + +

+ Enter a whole number (up to 999,999) +

+
+ 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" + /> + +
+ {error &&

{error}

} +
+ + {/* Display card */} + + {!step || !columns ? ( +

+ Enter a number above and click Break Down +

+ ) : ( + <> +

{step.label}

+ + + + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Quick practice */} + }> + + +
+ ); +} diff --git a/components/explorers/plot-points-explorer.tsx b/components/explorers/plot-points-explorer.tsx new file mode 100644 index 0000000..345627b --- /dev/null +++ b/components/explorers/plot-points-explorer.tsx @@ -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 = { + 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 ( + +
+

Quick Practice

+ + {score.correct}/{score.total} + +
+

+ Click where ({target.x}, {target.y}) belongs +

+
+ +
+ {clicked && ( +
+ {isCorrect ? ( +

Spot on! That is ({target.x}, {target.y}).

+ ) : ( +

+ You clicked ({clicked.x}, {clicked.y}). The target ({target.x}, {target.y}) is shown in green. +

+ )} + +
+ )} +
+ ); +} + +// ── Main explorer ──────────────────────────────────────────────────────────── + +export function PlotPointsExplorer() { + const [mode, setMode] = useState("plot"); + const [inputX, setInputX] = useState("4"); + const [inputY, setInputY] = useState("3"); + const [shapeKey, setShapeKey] = useState("mystery"); + const [error, setError] = useState(""); + + const [steps, setSteps] = useState(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 ( +
+ {/* Mode toggle */} +
+ {(["plot", "shape"] as Mode[]).map((m) => ( + + ))} +
+ + {/* Input card */} + + {mode === "plot" ? ( + <> +

+ Enter the coordinates to plot +

+
+ ( + 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" + /> + , + 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" + /> + ) + +
+ + ) : ( + <> +

+ Pick a shape, then step through the points to reveal it +

+
+ + +
+ + )} + {error &&

{error}

} +
+ + {/* Display card */} + + {!step ? ( +

+ {mode === "plot" ? ( + <>Enter coordinates above and click Plot + ) : ( + <>Pick a shape and click Start + )} +

+ ) : ( + <> +

{step.label}

+ +
+ +
+ + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Quick practice */} + }> + + +
+ ); +} diff --git a/components/explorers/read-coordinates-explorer.tsx b/components/explorers/read-coordinates-explorer.tsx new file mode 100644 index 0000000..452dbf6 --- /dev/null +++ b/components/explorers/read-coordinates-explorer.tsx @@ -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 ( + +
+

Quick Practice

+ + {score.correct}/{score.total} + +
+

What are the coordinates of this point?

+
+ +
+
+ ( + { + 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" + /> + , + { + 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" + /> + ) + {feedback === "correct" ? ( + + ) : ( + + )} +
+ {feedback === "correct" &&

Correct! ({target.x}, {target.y})

} + {feedback === "swapped" && ( +

You swapped them — the x-coordinate comes first! Try again.

+ )} + {feedback === "incorrect" && ( +

Not quite. Trace down to the x-axis, then across to the y-axis.

+ )} +
+ ); +} + +// ── Main explorer ──────────────────────────────────────────────────────────── + +export function ReadCoordinatesExplorer() { + const [point, setPoint] = useState(() => ({ x: 3, y: 2 })); + const [steps, setSteps] = useState(() => 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 ( +
+ +

+ Read the coordinates of the point +

+ +
+ + {/* Display card */} + + {step && ( + <> +

{step.label}

+ +
+ +
+ + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Quick practice */} + }> + + +
+ ); +} diff --git a/components/home/topic-art.tsx b/components/home/topic-art.tsx index fdf9db6..d99b05b 100644 --- a/components/home/topic-art.tsx +++ b/components/home/topic-art.tsx @@ -208,6 +208,78 @@ const art: Record = { base 4 ), + + // Unit 5 — Integers (Order & Compare) + "unit-5-integers/order-compare": ( + <> + + + + < + + ), + + "unit-5-integers/negative-fractions": ( + <> + + 3 + + 4 + + ), + + // Unit 6 — Number System (Place Value) + "unit-6-number-system/place-value": ( + <> + 3 + 8 + 5 + + 10² + 10¹ + 10⁰ + + ), + + // Unit 7 — Coordinates + "unit-7-coordinates/plot-points": ( + <> + + + + + + ), + "unit-7-coordinates/read-coordinates": ( + <> + + + + (3, 2) + + ), + + // Unit 8 — Number Properties + "unit-8-number-properties/identity-inverse": ( + <> + n + 0 = n + 3 + (−3) = 0 + + ), + "unit-8-number-properties/number-laws": ( + <> + a + b = b + a + + + + ), + "unit-8-number-properties/exponents": ( + <> + 2 + 4 + = 16 + + ), }; const fallback: ReactNode = ( diff --git a/components/layout/collapsible-sidebar.tsx b/components/layout/collapsible-sidebar.tsx new file mode 100644 index 0000000..3d2fcd1 --- /dev/null +++ b/components/layout/collapsible-sidebar.tsx @@ -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 ( +