Initial Commit

This commit is contained in:
2026-03-01 18:50:29 -04:00
parent 261c52d602
commit 364facd9f0
69 changed files with 7829 additions and 87 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.next
.git
*.md
term-2-unit.pdf
.env*
.claude

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

58
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,58 @@
pipeline {
agent any
environment {
REGISTRY = 'registry.dwarrington.com'
IMAGE_NAME = 'cabrits'
IMAGE_TAG = "${env.BUILD_NUMBER}"
CREDENTIALS_ID = 'registry-creds'
SUBDIR = ''
COMPOSE_FILE = 'docker-compose.yml'
SERVICE_NAME = 'cabrits'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Build & Push') {
steps {
script {
docker.withRegistry("https://${REGISTRY}", CREDENTIALS_ID) {
def buildArgs = (!env.SUBDIR || env.SUBDIR.trim() == '')
? "-f Dockerfile ."
: "-f ${env.SUBDIR}/Dockerfile ${env.SUBDIR}"
def img = docker.build("${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}", buildArgs)
img.push()
img.push('latest')
}
}
}
}
stage('Deploy') {
steps {
withCredentials([usernamePassword(
credentialsId: CREDENTIALS_ID,
usernameVariable: 'DOCKER_USER',
passwordVariable: 'DOCKER_PASS'
)]) {
sh '''
export PATH=$PATH:/usr/libexec/docker/cli-plugins:/usr/bin:/usr/local/bin
echo "$DOCKER_PASS" | docker login ${REGISTRY} -u "$DOCKER_USER" --password-stdin
sed -i "/${SERVICE_NAME}:/,/image:/s|image:.*|image: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}|" ${COMPOSE_FILE}
docker compose -f ${COMPOSE_FILE} pull ${SERVICE_NAME}
docker compose -f ${COMPOSE_FILE} up -d --force-recreate --remove-orphans
'''
}
}
}
}
}

View File

@@ -1,26 +1,183 @@
@import "tailwindcss"; @import "tailwindcss";
@import "katex/dist/katex.min.css";
:root { :root {
--background: #ffffff; --background: #f4f8ff;
--foreground: #171717; --foreground: #10223f;
--muted: #4f6588;
--border: #cbdaef;
--surface: #FFFFFF;
--surface-raised: #FFFFFF;
--unit-1: #2563eb;
--unit-1-light: #e8f1ff;
--unit-1-dark: #1d4ed8;
--unit-2: #0ea5a4;
--unit-2-light: #e6fffb;
--unit-2-dark: #0f766e;
--unit-3: #f97316;
--unit-3-light: #fff1e7;
--unit-3-dark: #c2410c;
--unit-4: #e11d48;
--unit-4-light: #ffe8ef;
--unit-4-dark: #be123c;
--correct: #16a34a;
--correct-light: #dcfce7;
--incorrect: #dc2626;
--incorrect-light: #fee2e2;
--hint: #d97706;
--hint-light: #ffedd5;
--shadow-sm: 0 1px 2px rgb(16 34 63 / 0.06), 0 2px 8px rgb(37 99 235 / 0.04);
--shadow-md: 0 8px 18px rgb(16 34 63 / 0.09), 0 2px 8px rgb(37 99 235 / 0.08);
--shadow-lg: 0 16px 30px rgb(16 34 63 / 0.11), 0 6px 20px rgb(37 99 235 / 0.1);
--shadow-xl: 0 24px 44px rgb(16 34 63 / 0.14), 0 12px 28px rgb(37 99 235 / 0.12);
--shadow-glow: 0 0 24px rgb(37 99 235 / 0.18);
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --color-muted: var(--muted);
--font-mono: var(--font-geist-mono); --color-border: var(--border);
--color-surface: var(--surface);
--color-surface-raised: var(--surface-raised);
--color-unit-1: var(--unit-1);
--color-unit-1-light: var(--unit-1-light);
--color-unit-1-dark: var(--unit-1-dark);
--color-unit-2: var(--unit-2);
--color-unit-2-light: var(--unit-2-light);
--color-unit-2-dark: var(--unit-2-dark);
--color-unit-3: var(--unit-3);
--color-unit-3-light: var(--unit-3-light);
--color-unit-3-dark: var(--unit-3-dark);
--color-unit-4: var(--unit-4);
--color-unit-4-light: var(--unit-4-light);
--color-unit-4-dark: var(--unit-4-dark);
--color-correct: var(--correct);
--color-correct-light: var(--correct-light);
--color-incorrect: var(--incorrect);
--color-incorrect-light: var(--incorrect-light);
--color-hint: var(--hint);
--color-hint-light: var(--hint-light);
--font-sans: "Nunito", "Trebuchet MS", "Segoe UI", sans-serif;
--font-display: "Avenir Next", "Poppins", "Trebuchet MS", sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", "Consolas", monospace;
} }
@media (prefers-color-scheme: dark) { html {
:root { scroll-behavior: smooth;
--background: #0a0a0a;
--foreground: #ededed;
}
} }
body { body {
background: var(--background); background:
radial-gradient(circle at 15% 0%, color-mix(in srgb, var(--unit-1) 9%, transparent), transparent 46%),
radial-gradient(circle at 90% 8%, color-mix(in srgb, var(--unit-3) 9%, transparent), transparent 34%),
radial-gradient(circle at 85% 88%, color-mix(in srgb, var(--unit-2) 7%, transparent), transparent 40%),
linear-gradient(180deg, #f7fbff 0%, var(--background) 100%);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-sans);
text-wrap: pretty;
}
h1,
h2,
h3,
h4 {
font-family: var(--font-display);
letter-spacing: -0.02em;
}
::selection {
background: color-mix(in srgb, var(--unit-1) 20%, transparent);
}
/* Subtle gradient background pattern */
.hero-gradient {
background:
radial-gradient(ellipse 100% 70% at 55% -15%, color-mix(in srgb, var(--unit-1) 14%, transparent), transparent),
radial-gradient(ellipse 70% 40% at 95% 45%, color-mix(in srgb, var(--unit-3) 12%, transparent), transparent),
radial-gradient(ellipse 70% 50% at 10% 75%, color-mix(in srgb, var(--unit-2) 10%, transparent), transparent),
linear-gradient(180deg, rgb(255 255 255 / 0.94), rgb(255 255 255 / 0.82));
}
/* Dot pattern overlay */
.dot-pattern {
background-image:
linear-gradient(to right, rgb(203 218 239 / 0.45) 1px, transparent 1px),
linear-gradient(to bottom, rgb(203 218 239 / 0.45) 1px, transparent 1px);
background-size: 22px 22px;
}
@keyframes float-card {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
.float-card {
animation: float-card 3.2s ease-in-out infinite;
}
.playground-bg {
background: linear-gradient(180deg, #2f65e8 0%, #2a5fdc 100%);
}
.playground-frame {
border-left: 4px solid #1e4fc6;
border-right: 4px solid #1e4fc6;
background: #ffffff;
box-shadow: 0 16px 40px rgb(15 35 86 / 0.22);
}
.section-ribbon {
border: 2px solid #1f4fbe;
background: linear-gradient(180deg, #2f65e8 0%, #2051c3 100%);
color: white;
}
.game-thumb-grid {
background-image:
linear-gradient(90deg, rgb(255 255 255 / 0.22) 1px, transparent 1px),
linear-gradient(0deg, rgb(255 255 255 / 0.22) 1px, transparent 1px);
background-size: 14px 14px;
}
/* Smooth scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted);
}
/* Focus ring utility */
.focus-ring {
outline: 2px solid transparent;
outline-offset: 2px;
}
.focus-ring:focus-visible {
outline-color: var(--unit-1);
} }

View File

@@ -1,20 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Cabrits Math Lab",
description: "Generated by create next app", description:
"Build confidence in mathematics with interactive lessons and practice for secondary school students.",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -24,9 +14,7 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
<body <body className="antialiased">
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children} {children}
</body> </body>
</html> </html>

28
app/lessons/layout.tsx Normal file
View File

@@ -0,0 +1,28 @@
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";
export default function LessonsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="playground-bg flex min-h-screen flex-col">
<Header />
<div className="playground-frame relative mx-auto flex w-full max-w-6xl flex-1">
<Sidebar />
<main className="flex-1 bg-white">
<div className="relative p-4 lg:hidden">
<MobileNav />
</div>
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
{children}
</div>
</main>
</div>
<Footer />
</div>
);
}

42
app/lessons/page.tsx Normal file
View File

@@ -0,0 +1,42 @@
import Link from "next/link";
import { curriculum } from "@/lib/curriculum";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
export default function LessonsOverview() {
return (
<div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">All Topics</h1>
<p className="mb-10 text-muted">Form 1, Term 2 &mdash; Select a topic to explore</p>
{curriculum.map((unit) => (
<section key={unit.slug} className="mb-12">
<div className="mb-4 flex items-center gap-3">
<Badge variant={unit.color}>Unit {unit.number}</Badge>
<h2 className="text-xl font-semibold">{unit.title}</h2>
<span className="hidden text-sm text-muted sm:inline">{unit.weeks}</span>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{unit.topics.map((topic) => (
<Link key={topic.slug} href={`/lessons/${unit.slug}/${topic.slug}`}>
<Card accent={unit.color} hover className="group h-full">
<h3 className="mb-1 font-semibold">{topic.title}</h3>
<p className="mb-3 text-sm leading-relaxed text-muted">{topic.description}</p>
<div className="flex items-center justify-between text-xs text-muted">
<span>Week {topic.week}</span>
<span className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
Explore
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</span>
</div>
</Card>
</Link>
))}
</div>
</section>
))}
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { FractionOperationExplorer } from "@/components/explorers/fraction-operation-explorer";
export default function AddSubtractPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 1: Fractions", href: "/lessons/unit-1-fractions" },
{ label: "Add & Subtract" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Add and Subtract Fractions</h1>
<FractionOperationExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { FractionOperationExplorer } from "@/components/explorers/fraction-operation-explorer";
export default function DividePage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 1: Fractions", href: "/lessons/unit-1-fractions" },
{ label: "Divide" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Divide Fractions</h1>
<FractionOperationExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { FractionQuantityExplorer } from "@/components/explorers/fraction-quantity-explorer";
export default function FractionOfQuantityPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 1: Fractions", href: "/lessons/unit-1-fractions" },
{ label: "Of a Quantity" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Fraction of a Quantity</h1>
<FractionQuantityExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { BODMASExplorer } from "@/components/explorers/bodmas-explorer";
export default function MixedOperationsPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 1: Fractions", href: "/lessons/unit-1-fractions" },
{ label: "BODMAS" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Mixed Operations (BODMAS)</h1>
<BODMASExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { FractionOperationExplorer } from "@/components/explorers/fraction-operation-explorer";
export default function MultiplyPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 1: Fractions", href: "/lessons/unit-1-fractions" },
{ label: "Multiply" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Multiply Fractions</h1>
<FractionOperationExplorer />
</div>
);
}

View File

@@ -0,0 +1,41 @@
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 Unit1Overview() {
const unit = curriculum[0];
return (
<div>
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: `Unit 1: ${unit.title}` },
]}
/>
<div className="mb-10">
<Badge variant="unit-1" className="mb-3">Unit 1 &mdash; {unit.weeks}</Badge>
<h1 className="mb-2 text-3xl font-bold tracking-tight">{unit.title}</h1>
<p className="text-muted leading-relaxed">{unit.description}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{unit.topics.map((topic, i) => (
<Link key={topic.slug} href={`/lessons/${unit.slug}/${topic.slug}`}>
<Card accent="unit-1" hover className="group h-full">
<div className="mb-2 flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-unit-1-light text-xs font-bold text-unit-1-dark shadow-[var(--shadow-sm)]">
{i + 1}
</span>
<span className="text-xs text-muted">Week {topic.week}</span>
</div>
<h3 className="mb-1 font-semibold">{topic.title}</h3>
<p className="text-sm leading-relaxed text-muted">{topic.description}</p>
</Card>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { FractionQuantityExplorer } from "@/components/explorers/fraction-quantity-explorer";
export default function WholeFromFractionsPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 1: Fractions", href: "/lessons/unit-1-fractions" },
{ label: "Find the Whole" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Calculate the Whole from Fractions</h1>
<FractionQuantityExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { RoundingExplorer } from "@/components/explorers/rounding-explorer";
export default function ApproximatePage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 2: Decimals", href: "/lessons/unit-2-decimals" },
{ label: "Approximate" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Approximate Decimals</h1>
<RoundingExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { DecimalOrderExplorer } from "@/components/explorers/decimal-order-explorer";
export default function CompareOrderPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 2: Decimals", href: "/lessons/unit-2-decimals" },
{ label: "Compare & Order" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Compare and Order Decimals</h1>
<DecimalOrderExplorer />
</div>
);
}

View File

@@ -0,0 +1,41 @@
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 Unit2Overview() {
const unit = curriculum[1];
return (
<div>
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: `Unit 2: ${unit.title}` },
]}
/>
<div className="mb-10">
<Badge variant="unit-2" className="mb-3">Unit 2 &mdash; {unit.weeks}</Badge>
<h1 className="mb-2 text-3xl font-bold tracking-tight">{unit.title}</h1>
<p className="text-muted leading-relaxed">{unit.description}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{unit.topics.map((topic, i) => (
<Link key={topic.slug} href={`/lessons/${unit.slug}/${topic.slug}`}>
<Card accent="unit-2" hover className="group h-full">
<div className="mb-2 flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-unit-2-light text-xs font-bold text-unit-2-dark shadow-[var(--shadow-sm)]">
{i + 7}
</span>
<span className="text-xs text-muted">Week {topic.week}</span>
</div>
<h3 className="mb-1 font-semibold">{topic.title}</h3>
<p className="text-sm leading-relaxed text-muted">{topic.description}</p>
</Card>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { StandardFormExplorer } from "@/components/explorers/standard-form-explorer";
export default function StandardFormPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 2: Decimals", href: "/lessons/unit-2-decimals" },
{ label: "Standard Form" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Standard Form (Scientific Notation)</h1>
<StandardFormExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { DecimalArithmeticExplorer } from "@/components/explorers/decimal-arithmetic-explorer";
export default function AddSubtractPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 3: Decimal Operations", href: "/lessons/unit-3-decimal-operations" },
{ label: "Add & Subtract" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Add and Subtract Decimals</h1>
<DecimalArithmeticExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { ConversionExplorer } from "@/components/explorers/conversion-explorer";
export default function ConvertPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 3: Decimal Operations", href: "/lessons/unit-3-decimal-operations" },
{ label: "Convert" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Convert Decimals and Fractions</h1>
<ConversionExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { DecimalArithmeticExplorer } from "@/components/explorers/decimal-arithmetic-explorer";
export default function MultiplyDividePage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 3: Decimal Operations", href: "/lessons/unit-3-decimal-operations" },
{ label: "Multiply & Divide" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Multiply and Divide Decimals</h1>
<DecimalArithmeticExplorer />
</div>
);
}

View File

@@ -0,0 +1,41 @@
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 Unit3Overview() {
const unit = curriculum[2];
return (
<div>
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: `Unit 3: ${unit.title}` },
]}
/>
<div className="mb-10">
<Badge variant="unit-3" className="mb-3">Unit 3 &mdash; {unit.weeks}</Badge>
<h1 className="mb-2 text-3xl font-bold tracking-tight">{unit.title}</h1>
<p className="text-muted leading-relaxed">{unit.description}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{unit.topics.map((topic, i) => (
<Link key={topic.slug} href={`/lessons/${unit.slug}/${topic.slug}`}>
<Card accent="unit-3" hover className="group h-full">
<div className="mb-2 flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-unit-3-light text-xs font-bold text-unit-3-dark shadow-[var(--shadow-sm)]">
{i + 10}
</span>
<span className="text-xs text-muted">Week {topic.week}</span>
</div>
<h3 className="mb-1 font-semibold">{topic.title}</h3>
<p className="text-sm leading-relaxed text-muted">{topic.description}</p>
</Card>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { RatioExplorer } from "@/components/explorers/ratio-explorer";
export default function DefineRatioPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 4: Ratio & Proportion", href: "/lessons/unit-4-ratio-proportion" },
{ label: "Define Ratio" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Define a Ratio</h1>
<RatioExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { RatioExplorer } from "@/components/explorers/ratio-explorer";
export default function DivideInRatioPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 4: Ratio & Proportion", href: "/lessons/unit-4-ratio-proportion" },
{ label: "Divide in Ratio" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Divide a Quantity in a Given Ratio</h1>
<RatioExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { RatioExplorer } from "@/components/explorers/ratio-explorer";
export default function FractionsAndRatiosPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 4: Ratio & Proportion", href: "/lessons/unit-4-ratio-proportion" },
{ label: "Fractions & Ratios" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Fractions and Ratios</h1>
<RatioExplorer />
</div>
);
}

View File

@@ -0,0 +1,41 @@
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 Unit4Overview() {
const unit = curriculum[3];
return (
<div>
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: `Unit 4: ${unit.title}` },
]}
/>
<div className="mb-10">
<Badge variant="unit-4" className="mb-3">Unit 4 &mdash; {unit.weeks}</Badge>
<h1 className="mb-2 text-3xl font-bold tracking-tight">{unit.title}</h1>
<p className="text-muted leading-relaxed">{unit.description}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{unit.topics.map((topic, i) => (
<Link key={topic.slug} href={`/lessons/${unit.slug}/${topic.slug}`}>
<Card accent="unit-4" hover className="group h-full">
<div className="mb-2 flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-unit-4-light text-xs font-bold text-unit-4-dark shadow-[var(--shadow-sm)]">
{String.fromCharCode(97 + i)}
</span>
<span className="text-xs text-muted">Week {topic.week}</span>
</div>
<h3 className="mb-1 font-semibold">{topic.title}</h3>
<p className="text-sm leading-relaxed text-muted">{topic.description}</p>
</Card>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { RatioExplorer } from "@/components/explorers/ratio-explorer";
export default function SimplifyRatiosPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 4: Ratio & Proportion", href: "/lessons/unit-4-ratio-proportion" },
{ label: "Simplify Ratios" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Simplify Ratios</h1>
<RatioExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { RatioExplorer } from "@/components/explorers/ratio-explorer";
export default function WordProblemsPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 4: Ratio & Proportion", href: "/lessons/unit-4-ratio-proportion" },
{ label: "Word Problems" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Proportional Parts Word Problems</h1>
<RatioExplorer />
</div>
);
}

37
app/not-found.tsx Normal file
View File

@@ -0,0 +1,37 @@
import Link from "next/link";
import { Header } from "@/components/layout/header";
import { Footer } from "@/components/layout/footer";
export default function NotFound() {
return (
<div className="playground-bg flex min-h-screen flex-col">
<Header />
<main className="playground-frame hero-gradient relative mx-auto flex w-full max-w-6xl flex-1 items-center justify-center">
<div className="dot-pattern absolute inset-0 opacity-25" />
<div className="relative text-center">
<p className="mb-2 text-sm font-semibold uppercase tracking-widest text-muted">
Page not found
</p>
<h1 className="mb-4 text-8xl font-bold tracking-tighter">
<span className="bg-gradient-to-r from-unit-1 via-unit-4 to-unit-2 bg-clip-text text-transparent">
404
</span>
</h1>
<p className="mb-8 text-lg text-muted">
This page doesn&apos;t exist or has been moved.
</p>
<Link
href="/"
className="inline-flex items-center gap-2 rounded-xl bg-foreground px-6 py-3 font-medium text-background shadow-[var(--shadow-md)] transition-all duration-200 hover:shadow-[var(--shadow-lg)] hover:-translate-y-0.5"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Home
</Link>
</div>
</main>
<Footer />
</div>
);
}

View File

@@ -1,65 +1,133 @@
import Image from "next/image"; import Link from "next/link";
import { Header } from "@/components/layout/header";
import { Footer } from "@/components/layout/footer";
import { curriculum } from "@/lib/curriculum";
const unitStyles = {
"unit-1": {
tile: "from-[#4e95ff] to-[#1f63ea]",
unitCard: "border-[#8cb2ff] bg-[#eaf2ff]",
chip: "bg-[#2059cc]",
},
"unit-2": {
tile: "from-[#29c6c5] to-[#0f8d8c]",
unitCard: "border-[#89e5dd] bg-[#eafffb]",
chip: "bg-[#0f8d8c]",
},
"unit-3": {
tile: "from-[#ffab55] to-[#e97617]",
unitCard: "border-[#ffd2a3] bg-[#fff4e8]",
chip: "bg-[#d26a17]",
},
"unit-4": {
tile: "from-[#ff5f85] to-[#d11b56]",
unitCard: "border-[#ffb2c7] bg-[#fff0f4]",
chip: "bg-[#c6174e]",
},
};
const topicTiles = [
"from-[#4e95ff] via-[#357fe9] to-[#1f63ea]",
"from-[#2ac3be] via-[#1da8a3] to-[#0f8d8c]",
"from-[#ffb067] via-[#f2913a] to-[#d86e16]",
"from-[#ff6f98] via-[#ef3d72] to-[#cb1c54]",
"from-[#7d95ff] via-[#6078f2] to-[#3f56d9]",
"from-[#4ccf7b] via-[#2fb45f] to-[#1f944b]",
"from-[#ff8f8f] via-[#f26b6b] to-[#d94545]",
"from-[#5eb7ff] via-[#3f99ef] to-[#2a7fda]",
];
export default function Home() { export default function Home() {
const allTopics = curriculum.flatMap((unit) =>
unit.topics.map((topic) => ({
unit,
topic,
})),
);
return ( return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div className="playground-bg flex min-h-screen flex-col">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> <Header />
<Image <main className="playground-frame mx-auto w-full max-w-6xl flex-1 px-3 pb-10 sm:px-5">
className="dark:invert" <section className="mt-4 rounded-md border-2 border-[#1f50bf] bg-[#f1f6ff] p-4 sm:p-6">
src="/next.svg" <p className="text-center text-sm font-bold text-[#26438b]">
alt="Next.js logo" Interactive maths practice for secondary school students
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p> </p>
</div> <h1 className="mt-2 text-center text-3xl font-extrabold text-[#17367d] sm:text-5xl">
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> Math Topics Students Love to Explore
<a </h1>
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" <p className="mx-auto mt-3 max-w-3xl text-center text-sm font-semibold text-[#33508e] sm:text-base">
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" Explore each topic with visual examples, short explanations, and guided lessons.
target="_blank" Pick a unit, jump into a topic, and start solving.
rel="noopener noreferrer" </p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-3">
<Link
href="/lessons"
className="rounded-sm bg-[#e6503e] px-5 py-2.5 text-sm font-extrabold text-white shadow-md transition-colors hover:bg-[#cc4231]"
> >
<Image Browse Lessons
className="dark:invert" </Link>
src="/vercel.svg" <Link
alt="Vercel logomark" href="/practice"
width={16} className="rounded-sm bg-[#0e9d50] px-5 py-2.5 text-sm font-extrabold text-white shadow-md transition-colors hover:bg-[#0c7f41]"
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
> >
Documentation Start Practice
</a> </Link>
</div> </div>
</section>
<section className="mt-5">
<h2 className="section-ribbon rounded-sm px-3 py-2 text-lg font-extrabold sm:text-xl">
Unit Worlds
</h2>
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{curriculum.map((unit) => (
<Link
key={unit.slug}
href={`/lessons/${unit.slug}`}
className={`rounded-md border-2 p-3 transition-transform hover:-translate-y-0.5 ${unitStyles[unit.color].unitCard}`}
>
<div className="flex items-center justify-between gap-2">
<h3 className="text-lg font-extrabold text-[#142f75]">{unit.title}</h3>
<span className={`rounded-full px-2 py-1 text-xs font-extrabold text-white ${unitStyles[unit.color].chip}`}>
Unit {unit.number}
</span>
</div>
<p className="mt-2 text-sm font-semibold text-[#35508b]">{unit.description}</p>
<p className="mt-2 text-xs font-bold uppercase tracking-wide text-[#4262aa]">{unit.weeks}</p>
</Link>
))}
</div>
</section>
<section className="mt-6">
<h2 className="section-ribbon rounded-sm px-3 py-2 text-lg font-extrabold sm:text-xl">
Topic Explorer
</h2>
<div className="mt-3 grid grid-cols-2 gap-2.5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{allTopics.map(({ unit, topic }, index) => (
<Link
key={`${unit.slug}-${topic.slug}`}
href={`/lessons/${unit.slug}/${topic.slug}`}
className="rounded-sm border border-[#8ea9df] bg-white p-2 transition-transform hover:-translate-y-0.5 hover:shadow-md"
>
<div className={`game-thumb-grid mb-2 flex aspect-[4/3] flex-col justify-between rounded-sm bg-gradient-to-br p-2 text-white ${topicTiles[index % topicTiles.length]}`}>
<span className="w-fit rounded-sm bg-black/25 px-1.5 py-0.5 text-[10px] font-extrabold uppercase tracking-wide">
Week {topic.week}
</span>
<span className="text-center text-xs font-extrabold leading-tight">
{topic.shortTitle}
</span>
</div>
<p className="min-h-[2.5rem] text-xs font-extrabold text-[#193981]">
{topic.title}
</p>
</Link>
))}
</div>
</section>
</main> </main>
<Footer />
</div> </div>
); );
} }

160
app/practice/page.tsx Normal file
View File

@@ -0,0 +1,160 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/card";
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 type { Difficulty, MathProblem } from "@/lib/problems/types";
import { generateFractionAddSubtract, generateFractionMultiply, generateFractionDivide, generateFractionOfQuantity, generateWholeFromFraction } 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";
interface TopicGenerator {
unitSlug: string;
topicSlug: string;
label: string;
generator: (difficulty: Difficulty) => MathProblem;
unitColor: "unit-1" | "unit-2" | "unit-3" | "unit-4";
}
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<UnitColor, { activeFilter: string; inactiveFilter: string; label: string }> = {
"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",
},
};
export default function PracticePage() {
const [selectedTopic, setSelectedTopic] = useState<TopicGenerator | null>(null);
const [filterUnit, setFilterUnit] = useState<UnitColor | null>(null);
const filtered = filterUnit
? TOPIC_GENERATORS.filter((t) => t.unitColor === filterUnit)
: TOPIC_GENERATORS;
return (
<div className="playground-bg flex min-h-screen flex-col">
<Header />
<main className="playground-frame mx-auto w-full max-w-6xl flex-1 bg-white">
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<h1 className="mb-2 text-3xl font-extrabold tracking-tight">Practice</h1>
<p className="mb-8 text-muted">
Pick a topic and build your confidence with fresh questions each round.
</p>
{/* Unit filters */}
<div className="mb-6 flex flex-wrap gap-2">
<button
onClick={() => setFilterUnit(null)}
className={`rounded-full border-2 px-4 py-1.5 text-sm font-extrabold transition-all duration-200 ${
filterUnit === null
? "border-foreground bg-foreground text-background shadow-[var(--shadow-sm)]"
: "border-border text-muted hover:text-foreground"
}`}
>
All Topics
</button>
{(["unit-1", "unit-2", "unit-3", "unit-4"] as const).map((uc) => (
<button
key={uc}
onClick={() => setFilterUnit(filterUnit === uc ? null : uc)}
className={`rounded-full border-2 px-4 py-1.5 text-sm font-extrabold transition-all duration-200 ${
filterUnit === uc
? unitColors[uc].activeFilter
: unitColors[uc].inactiveFilter
}`}
>
{unitColors[uc].label}
</button>
))}
</div>
{/* Topic grid */}
{!selectedTopic && (
<div className="grid gap-4 sm:grid-cols-2">
{filtered.map((topic) => (
<Card
key={`${topic.unitSlug}-${topic.topicSlug}`}
hover
className="cursor-pointer"
accent={topic.unitColor}
onClick={() => setSelectedTopic(topic)}
>
<div className="flex items-center justify-between">
<span className="font-bold">{topic.label}</span>
<Badge variant={topic.unitColor}>
{unitColors[topic.unitColor].label}
</Badge>
</div>
</Card>
))}
</div>
)}
{/* Practice area */}
{selectedTopic && (
<div>
<Button
variant="secondary"
size="sm"
onClick={() => setSelectedTopic(null)}
className="mb-6"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Topics
</Button>
<PracticeSection
title={`Practice: ${selectedTopic.label}`}
generator={selectedTopic.generator}
unitColor={selectedTopic.unitColor}
/>
</div>
)}
</div>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,338 @@
"use client";
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";
interface FractionVal {
num: number;
den: number;
}
interface Expression {
fractions: FractionVal[];
operators: string[];
}
interface Step {
label: string;
math: string;
highlightIndex: number | null; // which operator is being evaluated
}
const OP_PRIORITY: Record<string, number> = {
"×": 2,
"÷": 2,
"+": 1,
"": 1,
};
function performOp(a: FractionVal, b: FractionVal, op: string): FractionVal {
let result: [number, number];
switch (op) {
case "+":
result = add(a.num, a.den, b.num, b.den);
break;
case "":
result = subtract(a.num, a.den, b.num, b.den);
break;
case "×":
result = multiply(a.num, a.den, b.num, b.den);
break;
case "÷":
result = divide(a.num, a.den, b.num, b.den);
break;
default:
result = [0, 1];
}
return { num: result[0], den: result[1] };
}
function expressionToKatex(fracs: FractionVal[], ops: string[], highlightIdx: number | null): string {
const parts: string[] = [];
fracs.forEach((f, i) => {
parts.push(toKatex(f.num, f.den));
if (i < ops.length) {
if (i === highlightIdx) {
parts.push(`\\;\\boxed{${ops[i]}}\\;`);
} else {
parts.push(`\\;${ops[i]}\\;`);
}
}
});
return parts.join("");
}
function buildBodmasSteps(expr: Expression): Step[] {
const steps: Step[] = [];
let fracs = [...expr.fractions];
let ops = [...expr.operators];
// Step 0: Show the expression
steps.push({
label: "Start with the expression",
math: expressionToKatex(fracs, ops, null),
highlightIndex: null,
});
while (ops.length > 0) {
// Find highest priority operator (leftmost if tie)
let maxPri = 0;
let opIdx = 0;
ops.forEach((op, i) => {
if (OP_PRIORITY[op] > maxPri) {
maxPri = OP_PRIORITY[op];
opIdx = i;
}
});
// Highlight the operation to perform
steps.push({
label: `BODMAS: do ${ops[opIdx]} first (${OP_PRIORITY[ops[opIdx]] === 2 ? "multiplication/division" : "addition/subtraction"})`,
math: expressionToKatex(fracs, ops, opIdx),
highlightIndex: opIdx,
});
// Perform the operation
const result = performOp(fracs[opIdx], fracs[opIdx + 1], ops[opIdx]);
const [sn, sd] = simplify(result.num, result.den);
const newFracs = [...fracs];
newFracs.splice(opIdx, 2, { num: sn, den: sd });
const newOps = [...ops];
newOps.splice(opIdx, 1);
steps.push({
label: `${fracs[opIdx].num}/${fracs[opIdx].den} ${ops[opIdx]} ${fracs[opIdx + 1].num}/${fracs[opIdx + 1].den} = ${sn}/${sd}`,
math: expressionToKatex(newFracs, newOps, null),
highlightIndex: null,
});
fracs = newFracs;
ops = newOps;
}
return steps;
}
const PRESETS: { label: string; fracs: FractionVal[]; ops: string[] }[] = [
{
label: "½ + ¼ × ⅗",
fracs: [
{ num: 1, den: 2 },
{ num: 1, den: 4 },
{ num: 3, den: 5 },
],
ops: ["+", "×"],
},
{
label: "⅔ × ¾ ½",
fracs: [
{ num: 2, den: 3 },
{ num: 3, den: 4 },
{ num: 1, den: 2 },
],
ops: ["×", ""],
},
{
label: "⅗ ÷ ¼ + ⅔",
fracs: [
{ num: 3, den: 5 },
{ num: 1, den: 4 },
{ num: 2, den: 3 },
],
ops: ["÷", "+"],
},
];
export function BODMASExplorer() {
const [fracs, setFracs] = useState<FractionVal[]>([
{ num: 1, den: 2 },
{ num: 1, den: 4 },
{ num: 3, den: 5 },
]);
const [ops, setOps] = useState<string[]>(["+", "×"]);
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
function updateFrac(idx: number, field: "num" | "den", value: string) {
setFracs((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: parseInt(value) || 0 };
return next;
});
}
function handleGo() {
setError("");
if (fracs.some((f) => f.den === 0)) {
setError("Denominators cannot be zero.");
return;
}
if (fracs.some((f) => Math.abs(f.num) > 99 || Math.abs(f.den) > 99)) {
setError("Keep numbers under 100.");
return;
}
try {
const s = buildBodmasSteps({ fractions: fracs, operators: ops });
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. Check your inputs.");
}
}
function loadPreset(idx: number) {
const p = PRESETS[idx];
setFracs([...p.fracs]);
setOps([...p.ops]);
reset();
}
function stepForward() {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}
function stepBack() {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}
function togglePlay() {
setIsPlaying((p) => !p);
}
function reset() {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}
const step = steps ? steps[currentStep] : null;
const opChoices = ["+", "", "×", "÷"];
return (
<div className="space-y-4">
{/* Presets */}
<div className="flex flex-wrap gap-2">
{PRESETS.map((p, i) => (
<button
key={i}
onClick={() => loadPreset(i)}
className="rounded-full border-2 border-unit-1/40 px-4 py-2 text-sm font-semibold text-unit-1 transition-colors hover:bg-unit-1-light"
>
{p.label}
</button>
))}
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-1">
Enter a BODMAS expression with fractions
</p>
<div className="flex flex-wrap items-center gap-2">
{fracs.map((f, i) => (
<div key={i} className="flex items-center gap-2">
<div className="flex flex-col items-center">
<input
type="number"
value={f.num || ""}
onChange={(e) => updateFrac(i, "num", e.target.value)}
className="w-12 rounded-lg border-2 border-border bg-surface px-1.5 py-1 text-center text-base font-bold outline-none focus:border-unit-1"
aria-label={`Numerator ${i + 1}`}
/>
<div className="my-0.5 h-0.5 w-10 bg-foreground" />
<input
type="number"
value={f.den || ""}
onChange={(e) => updateFrac(i, "den", e.target.value)}
className="w-12 rounded-lg border-2 border-border bg-surface px-1.5 py-1 text-center text-base font-bold outline-none focus:border-unit-1"
aria-label={`Denominator ${i + 1}`}
/>
</div>
{i < ops.length && (
<select
value={ops[i]}
onChange={(e) => {
const next = [...ops];
next[i] = e.target.value;
setOps(next);
}}
className="rounded-lg border-2 border-border bg-surface px-2 py-2 text-center text-lg font-bold text-foreground outline-none focus:border-unit-1"
>
{opChoices.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
)}
</div>
))}
<button
onClick={handleGo}
className="rounded-lg bg-unit-1 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-1-dark"
>
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<Card className="flex min-h-[200px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter an expression above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !steps ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Final Answer</p>
<div className="text-3xl font-extrabold max-sm:text-2xl">
<MathDisplay math={steps[steps.length - 1].math} />
</div>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,345 @@
"use client";
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";
type ConvertMode = "decToFrac" | "fracToDec";
interface Step {
label: string;
math: string;
gridFilled?: number; // out of 100 for decimal grid
barFilled?: number;
barTotal?: number;
}
function buildDecToFracSteps(decimal: string): Step[] {
const steps: Step[] = [];
const val = parseFloat(decimal);
steps.push({
label: `Convert ${decimal} to a fraction`,
math: `${decimal} = \\;?`,
gridFilled: Math.round(val * 100),
});
// Count decimal places
const parts = decimal.split(".");
const decPlaces = parts[1]?.length || 0;
const denominator = Math.pow(10, decPlaces);
const numerator = Math.round(val * denominator);
steps.push({
label: `${decPlaces} decimal place${decPlaces !== 1 ? "s" : ""} → denominator is ${denominator}`,
math: `${decimal} = ${toKatex(numerator, denominator)}`,
gridFilled: Math.round(val * 100),
barFilled: numerator,
barTotal: denominator,
});
// Simplify
const [sn, sd] = simplify(numerator, denominator);
if (sn !== numerator || sd !== denominator) {
const g = gcd(numerator, denominator);
steps.push({
label: `Simplify by dividing by GCD = ${g}`,
math: `${toKatex(numerator, denominator)} = ${toKatex(sn, sd)}`,
gridFilled: Math.round(val * 100),
barFilled: sn,
barTotal: sd,
});
}
steps.push({
label: `Result: ${decimal} = ${sn}/${sd}`,
math: `${decimal} = ${toKatex(sn, sd)}`,
gridFilled: Math.round(val * 100),
barFilled: sn,
barTotal: sd,
});
return steps;
}
function buildFracToDecSteps(num: number, den: number): Step[] {
const steps: Step[] = [];
steps.push({
label: `Convert ${num}/${den} to a decimal`,
math: `${toKatex(num, den)} = \\;?`,
barFilled: num,
barTotal: den,
});
// Step: Perform the division
const result = num / den;
const resultStr = Number.isInteger(result) ? result.toString() : result.toFixed(6).replace(/0+$/, "");
steps.push({
label: `Divide numerator by denominator`,
math: `${num} \\div ${den} = ${resultStr}`,
barFilled: num,
barTotal: den,
gridFilled: Math.min(100, Math.round(result * 100)),
});
// Check for terminating vs repeating
let tempDen = den;
const [, simpDen] = simplify(num, den);
tempDen = simpDen;
// Remove factors of 2 and 5
while (tempDen % 2 === 0) tempDen /= 2;
while (tempDen % 5 === 0) tempDen /= 5;
const isTerminating = tempDen === 1;
steps.push({
label: isTerminating
? `This is a terminating decimal`
: `This is a recurring decimal`,
math: `${toKatex(num, den)} = ${resultStr}${!isTerminating ? "..." : ""}`,
barFilled: num,
barTotal: den,
gridFilled: Math.min(100, Math.round(result * 100)),
});
return steps;
}
function DecimalGrid({ filled }: { filled: number }) {
return (
<div className="grid grid-cols-10 gap-0.5 rounded-lg border-2 border-border p-1" style={{ width: "fit-content" }}>
{Array.from({ length: 100 }, (_, i) => (
<div
key={i}
className="h-4 w-4 rounded-sm transition-colors duration-300"
style={{
backgroundColor: i < filled ? "var(--unit-3)" : "var(--background)",
}}
/>
))}
</div>
);
}
function FractionBarSmall({ filled, total }: { filled: number; total: number }) {
const segments = Math.min(total, 20);
const fillCount = Math.min(filled, segments);
return (
<div className="flex h-8 w-full max-w-[240px] overflow-hidden rounded-lg border-2 border-border">
{Array.from({ length: segments }, (_, i) => (
<div
key={i}
className="border-r border-border/30 transition-colors duration-300 last:border-r-0"
style={{
flex: 1,
backgroundColor: i < fillCount ? "var(--unit-3)" : "transparent",
}}
/>
))}
</div>
);
}
export function ConversionExplorer() {
const [mode, setMode] = useState<ConvertMode>("decToFrac");
const [decInput, setDecInput] = useState("0.75");
const [numInput, setNumInput] = useState("3");
const [denInput, setDenInput] = useState("8");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
function handleGo() {
setError("");
try {
let s: Step[];
if (mode === "decToFrac") {
const val = parseFloat(decInput);
if (isNaN(val) || val < 0 || val >= 10) {
setError("Enter a valid decimal between 0 and 10.");
return;
}
s = buildDecToFracSteps(decInput.trim());
} else {
const n = parseInt(numInput);
const d = parseInt(denInput);
if (isNaN(n) || isNaN(d) || d === 0) {
setError("Enter a valid fraction (denominator ≠ 0).");
return;
}
s = buildFracToDecSteps(n, d);
}
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. 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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Mode tabs */}
<div className="flex gap-2">
<button
onClick={() => {
setMode("decToFrac");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "decToFrac"
? "border-unit-3 bg-unit-3 text-white"
: "border-unit-3/40 text-unit-3 hover:bg-unit-3-light"
}`}
>
Decimal Fraction
</button>
<button
onClick={() => {
setMode("fracToDec");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "fracToDec"
? "border-unit-3 bg-unit-3 text-white"
: "border-unit-3/40 text-unit-3 hover:bg-unit-3-light"
}`}
>
Fraction Decimal
</button>
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-3">
{mode === "decToFrac" ? "Enter a decimal" : "Enter a fraction"}
</p>
<div className="flex flex-wrap items-center gap-2.5">
{mode === "decToFrac" ? (
<input
type="text"
value={decInput}
onChange={(e) => setDecInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleGo()}
placeholder="e.g. 0.75"
autoComplete="off"
className="max-w-[180px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-3"
/>
) : (
<div className="flex flex-col items-center">
<input
type="number"
value={numInput}
onChange={(e) => setNumInput(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-3"
aria-label="Numerator"
/>
<div className="my-0.5 h-0.5 w-12 bg-foreground" />
<input
type="number"
value={denInput}
onChange={(e) => setDenInput(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-3"
aria-label="Denominator"
/>
</div>
)}
<button
onClick={handleGo}
className="rounded-lg bg-unit-3 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-3-dark"
>
Convert
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<Card className="flex min-h-[260px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter a value above and click <strong>Convert</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
<div className="flex flex-wrap items-center justify-center gap-6">
{step.gridFilled !== undefined && (
<div className="flex flex-col items-center gap-1">
<DecimalGrid filled={step.gridFilled} />
<span className="text-xs text-muted">{step.gridFilled}/100</span>
</div>
)}
{step.barFilled !== undefined && step.barTotal !== undefined && (
<div className="flex flex-col items-center gap-1">
<FractionBarSmall filled={step.barFilled} total={step.barTotal} />
<span className="text-xs text-muted">
{step.barFilled}/{step.barTotal}
</span>
</div>
)}
</div>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !steps ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Answer</p>
<div className="text-3xl font-extrabold max-sm:text-2xl">
<MathDisplay math={steps[steps.length - 1].math} />
</div>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,403 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
type DecOp = "add" | "subtract" | "multiply" | "divide";
interface Step {
label: string;
columns: ColumnDisplay[];
carry?: string;
resultRow?: string;
}
interface ColumnDisplay {
rows: string[];
highlight?: number; // which row is highlighted
separator?: boolean; // line above this row
}
function padAndAlign(a: string, b: string): { aStr: string; bStr: string; dotPos: number } {
const aParts = a.split(".");
const bParts = b.split(".");
const aInt = aParts[0] || "0";
const bInt = bParts[0] || "0";
const aDec = aParts[1] || "";
const bDec = bParts[1] || "";
const maxInt = Math.max(aInt.length, bInt.length);
const maxDec = Math.max(aDec.length, bDec.length);
const aAligned = aInt.padStart(maxInt, " ") + (maxDec > 0 ? "." + aDec.padEnd(maxDec, "0") : "");
const bAligned = bInt.padStart(maxInt, " ") + (maxDec > 0 ? "." + bDec.padEnd(maxDec, "0") : "");
return { aStr: aAligned, bStr: bAligned, dotPos: maxInt };
}
function buildAddSubSteps(a: number, b: number, op: DecOp): Step[] {
const steps: Step[] = [];
const aStr = a.toString();
const bStr = b.toString();
const { aStr: aAligned, bStr: bAligned } = padAndAlign(aStr, bStr);
const opSymbol = op === "add" ? "+" : "";
const result = op === "add" ? a + b : a - b;
const resultStr = parseFloat(result.toFixed(10)).toString();
// Step 0: Show the problem
steps.push({
label: `${aStr} ${opSymbol} ${bStr}`,
columns: [],
});
// Step 1: Align decimal points
steps.push({
label: "Align the decimal points",
columns: [
{ rows: [aAligned, `${opSymbol} ${bAligned}`] },
],
});
// Step 2: Add trailing zeros
steps.push({
label: "Fill in zeros as placeholders",
columns: [
{ rows: [aAligned, `${opSymbol} ${bAligned}`], separator: true },
],
});
// Step 3: Compute
const { aStr: aFinal, bStr: bFinal } = padAndAlign(aStr, bStr);
const resultAligned = padAndAlign(resultStr, aStr).aStr;
steps.push({
label: `Compute column by column`,
columns: [
{ rows: [aFinal, `${opSymbol} ${bFinal}`, resultAligned], separator: true, highlight: 2 },
],
});
// Step 4: Result
steps.push({
label: `${aStr} ${opSymbol} ${bStr} = ${resultStr}`,
columns: [
{ rows: [aFinal, `${opSymbol} ${bFinal}`, resultAligned], separator: true, highlight: 2 },
],
resultRow: resultStr,
});
return steps;
}
function buildMultiplySteps(a: number, b: number): Step[] {
const steps: Step[] = [];
const aStr = a.toString();
const bStr = b.toString();
const aDec = (aStr.split(".")[1] || "").length;
const bDec = (bStr.split(".")[1] || "").length;
const totalDec = aDec + bDec;
// Remove decimals for integer multiplication
const aInt = Math.round(a * Math.pow(10, aDec));
const bInt = Math.round(b * Math.pow(10, bDec));
const result = a * b;
const resultStr = parseFloat(result.toFixed(10)).toString();
steps.push({
label: `${aStr} × ${bStr}`,
columns: [],
});
if (totalDec > 0) {
steps.push({
label: `Count decimal places: ${aDec} + ${bDec} = ${totalDec}`,
columns: [
{ rows: [`${aStr}${aDec} d.p.`, `${bStr}${bDec} d.p.`, `Total: ${totalDec} d.p.`] },
],
});
steps.push({
label: `Multiply as whole numbers: ${aInt} × ${bInt}`,
columns: [
{ rows: [`${aInt}`, `× ${bInt}`, `${aInt * bInt}`], separator: true, highlight: 2 },
],
});
steps.push({
label: `Place decimal point ${totalDec} place${totalDec !== 1 ? "s" : ""} from the right`,
columns: [
{ rows: [`${aInt * bInt}`, `${resultStr}`], highlight: 1 },
],
resultRow: resultStr,
});
} else {
const intResult = a * b;
steps.push({
label: `Multiply: ${aStr} × ${bStr} = ${intResult}`,
columns: [
{ rows: [`${aStr}`, `× ${bStr}`, `${intResult}`], separator: true, highlight: 2 },
],
resultRow: intResult.toString(),
});
}
return steps;
}
function buildDivideSteps(a: number, b: number): Step[] {
const steps: Step[] = [];
const aStr = a.toString();
const bStr = b.toString();
const result = a / b;
const resultStr = parseFloat(result.toFixed(10)).toString();
steps.push({
label: `${aStr} ÷ ${bStr}`,
columns: [],
});
// Make divisor a whole number
const bDec = (bStr.split(".")[1] || "").length;
if (bDec > 0) {
const factor = Math.pow(10, bDec);
const newA = parseFloat((a * factor).toFixed(10));
const newB = parseFloat((b * factor).toFixed(10));
steps.push({
label: `Make divisor whole: multiply both by ${factor}`,
columns: [
{ rows: [`${aStr} × ${factor} = ${newA}`, `${bStr} × ${factor} = ${newB}`] },
],
});
steps.push({
label: `Now divide: ${newA} ÷ ${newB}`,
columns: [
{ rows: [`${newA} ÷ ${newB}`, `= ${resultStr}`], highlight: 1 },
],
});
} else {
steps.push({
label: `Divide: ${aStr} ÷ ${bStr}`,
columns: [
{ rows: [`${aStr} ÷ ${bStr}`, `= ${resultStr}`], highlight: 1 },
],
});
}
steps.push({
label: `${aStr} ÷ ${bStr} = ${resultStr}`,
columns: [
{ rows: [`${aStr} ÷ ${bStr} = ${resultStr}`] },
],
resultRow: resultStr,
});
return steps;
}
function ColumnArithmetic({
columns,
}: {
columns: ColumnDisplay[];
}) {
if (columns.length === 0) return null;
return (
<div className="flex flex-col items-center gap-0.5 font-mono text-xl">
{columns.map((col, ci) =>
col.rows.map((row, ri) => (
<div key={`${ci}-${ri}`}>
{col.separator && ri === col.rows.length - 1 && (
<div className="mb-1 h-0.5 bg-foreground" />
)}
<div
className={`rounded-lg px-6 py-1.5 text-right tracking-wider transition-all duration-300 ${
ri === col.highlight
? "bg-unit-3-light font-bold text-foreground"
: "text-foreground"
}`}
>
{row}
</div>
</div>
)),
)}
</div>
);
}
export function DecimalArithmeticExplorer() {
const [op, setOp] = useState<DecOp>("add");
const [aInput, setAInput] = useState("12.45");
const [bInput, setBInput] = useState("3.7");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
function handleGo() {
setError("");
const a = parseFloat(aInput);
const b = parseFloat(bInput);
if (isNaN(a) || isNaN(b)) {
setError("Enter valid decimal numbers.");
return;
}
if (op === "divide" && b === 0) {
setError("Cannot divide by zero.");
return;
}
try {
let s: Step[];
if (op === "add" || op === "subtract") {
s = buildAddSubSteps(a, b, op);
} else if (op === "multiply") {
s = buildMultiplySteps(a, b);
} else {
s = buildDivideSteps(a, b);
}
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. Check your inputs.");
}
}
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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
const opLabels: Record<DecOp, string> = {
add: "Add (+)",
subtract: "Subtract ()",
multiply: "Multiply (×)",
divide: "Divide (÷)",
};
return (
<div className="space-y-4">
{/* Operation tabs */}
<div className="flex flex-wrap gap-2">
{(["add", "subtract", "multiply", "divide"] as DecOp[]).map((o) => (
<button
key={o}
onClick={() => {
setOp(o);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
op === o
? "border-unit-3 bg-unit-3 text-white"
: "border-unit-3/40 text-unit-3 hover:bg-unit-3-light"
}`}
>
{opLabels[o]}
</button>
))}
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-3">
Enter two decimal numbers
</p>
<div className="flex flex-wrap items-center gap-2.5">
<input
type="text"
value={aInput}
onChange={(e) => setAInput(e.target.value)}
placeholder="e.g. 12.45"
autoComplete="off"
className="max-w-[140px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-3"
aria-label="First number"
/>
<span className="text-xl font-bold text-muted">
{{ add: "+", subtract: "", multiply: "×", divide: "÷" }[op]}
</span>
<input
type="text"
value={bInput}
onChange={(e) => setBInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleGo()}
placeholder="e.g. 3.7"
autoComplete="off"
className="max-w-[140px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-3"
aria-label="Second number"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-3 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-3-dark"
>
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter numbers above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
<ColumnArithmetic columns={step.columns} />
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !step?.resultRow ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Answer</p>
<p className="text-3xl font-extrabold text-foreground max-sm:text-2xl">
{step.resultRow}
</p>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,311 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
type SortDirection = "ascending" | "descending";
interface Step {
label: string;
values: { value: number; str: string; sorted: boolean; comparing: boolean }[];
comparingPair?: [number, number];
}
function padDecimal(s: string, maxInt: number, maxDec: number): string {
const parts = s.split(".");
const intPart = (parts[0] || "0").padStart(maxInt, "\u00A0"); // nbsp padding
const decPart = (parts[1] || "").padEnd(maxDec, "0");
return maxDec > 0 ? `${intPart}.${decPart}` : intPart;
}
function buildSortSteps(values: number[], direction: SortDirection): Step[] {
const steps: Step[] = [];
const strs = values.map((v) => v.toString());
// Find max integer and decimal lengths for alignment
const maxInt = Math.max(...strs.map((s) => s.split(".")[0].length));
const maxDec = Math.max(...strs.map((s) => (s.split(".")[1] || "").length));
// Step 0: Show unsorted
steps.push({
label: `Sort ${values.length} decimals in ${direction} order`,
values: values.map((v, i) => ({
value: v,
str: padDecimal(strs[i], maxInt, maxDec),
sorted: false,
comparing: false,
})),
});
// Step 1: Show aligned place values
steps.push({
label: "Align decimal points to compare place values",
values: values.map((v, i) => ({
value: v,
str: padDecimal(strs[i], maxInt, maxDec),
sorted: false,
comparing: false,
})),
});
// Bubble sort with steps
const arr = [...values];
const arrStrs = [...strs];
const cmp = direction === "ascending" ? (a: number, b: number) => a > b : (a: number, b: number) => a < b;
for (let i = 0; i < arr.length - 1; i++) {
for (let j = 0; j < arr.length - 1 - i; j++) {
// Show comparison
steps.push({
label: `Compare ${arrStrs[j]} and ${arrStrs[j + 1]}`,
values: arr.map((v, k) => ({
value: v,
str: padDecimal(arrStrs[k], maxInt, maxDec),
sorted: k >= arr.length - i,
comparing: k === j || k === j + 1,
})),
comparingPair: [j, j + 1],
});
if (cmp(arr[j], arr[j + 1])) {
// Swap
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
[arrStrs[j], arrStrs[j + 1]] = [arrStrs[j + 1], arrStrs[j]];
steps.push({
label: `Swap: ${arrStrs[j]} ${direction === "ascending" ? "<" : ">"} ${arrStrs[j + 1]}`,
values: arr.map((v, k) => ({
value: v,
str: padDecimal(arrStrs[k], maxInt, maxDec),
sorted: k >= arr.length - i,
comparing: k === j || k === j + 1,
})),
});
}
}
}
// Final step: Show sorted
steps.push({
label: `Sorted (${direction}): ${arrStrs.join(direction === "ascending" ? " < " : " > ")}`,
values: arr.map((v, k) => ({
value: v,
str: padDecimal(arrStrs[k], maxInt, maxDec),
sorted: true,
comparing: false,
})),
});
return steps;
}
function NumberLine({ values, min, max }: { values: { value: number; sorted: boolean; comparing: boolean }[]; min: number; max: number }) {
const range = max - min || 1;
return (
<div className="w-full max-w-lg">
<div className="relative h-2 rounded-full bg-border">
{values.map((v, i) => {
const pct = ((v.value - min) / range) * 100;
return (
<div
key={i}
className={`absolute -top-2.5 h-7 w-7 -translate-x-1/2 rounded-full border-2 text-center text-[0.6rem] font-bold leading-[1.5rem] transition-all duration-500 ${
v.comparing
? "border-hint bg-hint-light text-foreground z-10"
: v.sorted
? "border-correct bg-correct-light text-foreground"
: "border-unit-2 bg-unit-2-light text-foreground"
}`}
style={{ left: `${Math.max(5, Math.min(95, pct))}%` }}
>
{i + 1}
</div>
);
})}
</div>
<div className="mt-4 flex justify-between text-xs text-muted">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
);
}
export function DecimalOrderExplorer() {
const [input, setInput] = useState("3.14, 3.1, 3.141, 3.04, 3.4");
const [direction, setDirection] = useState<SortDirection>("ascending");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
function handleGo() {
setError("");
const parts = input
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
if (parts.length < 2) {
setError("Enter at least 2 decimals separated by commas.");
return;
}
if (parts.length > 6) {
setError("Maximum 6 decimals.");
return;
}
const values = parts.map(Number);
if (values.some(isNaN)) {
setError("All values must be valid numbers.");
return;
}
try {
const s = buildSortSteps(values, direction);
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not process. 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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Direction tabs */}
<div className="flex gap-2">
{(["ascending", "descending"] as SortDirection[]).map((d) => (
<button
key={d}
onClick={() => {
setDirection(d);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold capitalize transition-colors ${
direction === d
? "border-unit-2 bg-unit-2 text-white"
: "border-unit-2/40 text-unit-2 hover:bg-unit-2-light"
}`}
>
{d}
</button>
))}
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-2">
Enter 2-6 decimal numbers (comma separated)
</p>
<div className="flex flex-wrap items-center gap-2.5">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleGo()}
placeholder="e.g. 3.14, 3.1, 3.141"
autoComplete="off"
className="max-w-[320px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-2 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-2-dark"
>
Sort
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<Card className="flex min-h-[280px] flex-col items-center justify-center gap-5 p-6">
{!step ? (
<p className="text-muted/50">
Enter decimals above and click <strong>Sort</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
{/* Place value columns */}
<div className="flex flex-col gap-1.5 font-mono text-xl">
{step.values.map((v, i) => (
<div
key={i}
className={`rounded-lg border-2 px-4 py-2 text-center transition-all duration-300 ${
v.comparing
? "border-hint bg-hint-light"
: v.sorted
? "border-correct bg-correct-light"
: "border-border bg-surface"
}`}
>
{v.str}
</div>
))}
</div>
{/* Number line */}
<NumberLine
values={step.values}
min={Math.min(...step.values.map((v) => v.value)) - 0.1}
max={Math.max(...step.values.map((v) => v.value)) + 0.1}
/>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !steps ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">
Sorted ({direction})
</p>
<p className="text-2xl font-extrabold text-foreground max-sm:text-xl">
{step!.values.map((v) => v.value).join(direction === "ascending" ? " < " : " > ")}
</p>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,389 @@
"use client";
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 { MathDisplay } from "@/components/math/math-display";
type Operation = "add" | "subtract" | "multiply" | "divide";
interface Step {
label: string;
math: string;
barA: { filled: number; total: number };
barB: { filled: number; total: number };
barResult?: { filled: number; total: number };
}
function buildSteps(
n1: number, d1: number,
n2: number, d2: number,
op: Operation,
): Step[] {
const steps: Step[] = [];
// Step 0: Show the original fractions
const opSymbol = { add: "+", subtract: "-", multiply: "\\times", divide: "\\div" }[op];
steps.push({
label: "Start with the two fractions",
math: `${toKatex(n1, d1)} ${opSymbol} ${toKatex(n2, d2)}`,
barA: { filled: n1, total: d1 },
barB: { filled: n2, total: d2 },
});
if (op === "add" || op === "subtract") {
// Step 1: Find LCD
const lcd = lcm(d1, d2);
const mult1 = lcd / d1;
const mult2 = lcd / d2;
const cn1 = n1 * mult1;
const cn2 = n2 * mult2;
if (d1 !== d2) {
steps.push({
label: `Find the LCD of ${d1} and ${d2}`,
math: `\\text{LCD}(${d1}, ${d2}) = ${lcd}`,
barA: { filled: n1, total: d1 },
barB: { filled: n2, total: d2 },
});
// Step 2: Convert both fractions
steps.push({
label: "Convert to equivalent fractions with the LCD",
math: `${toKatex(n1, d1)} = ${toKatex(cn1, lcd)} \\quad ${toKatex(n2, d2)} = ${toKatex(cn2, lcd)}`,
barA: { filled: cn1, total: lcd },
barB: { filled: cn2, total: lcd },
});
}
// Step 3: Perform the operation
const resultNum = op === "add" ? cn1 + cn2 : cn1 - cn2;
steps.push({
label: op === "add" ? "Add the numerators" : "Subtract the numerators",
math: `${toKatex(cn1, lcd)} ${opSymbol} ${toKatex(cn2, lcd)} = ${toKatex(resultNum, lcd)}`,
barA: { filled: cn1, total: lcd },
barB: { filled: cn2, total: lcd },
barResult: { filled: Math.abs(resultNum), total: lcd },
});
// Step 4: Simplify if needed
const [sn, sd] = simplify(resultNum, lcd);
if (Math.abs(sn) !== Math.abs(resultNum) || sd !== lcd) {
const g = gcd(Math.abs(resultNum), lcd);
steps.push({
label: `Simplify by dividing by GCD = ${g}`,
math: `${toKatex(resultNum, lcd)} = ${toKatex(sn, sd)}`,
barA: { filled: cn1, total: lcd },
barB: { filled: cn2, total: lcd },
barResult: { filled: Math.abs(sn), total: sd },
});
}
} else if (op === "multiply") {
// Step 1: Multiply numerators and denominators
const rn = n1 * n2;
const rd = d1 * d2;
steps.push({
label: "Multiply numerators and denominators",
math: `\\frac{${n1} \\times ${n2}}{${d1} \\times ${d2}} = ${toKatex(rn, rd)}`,
barA: { filled: n1, total: d1 },
barB: { filled: n2, total: d2 },
barResult: { filled: rn, total: rd },
});
// Step 2: Simplify
const [sn, sd] = simplify(rn, rd);
if (sn !== rn || sd !== rd) {
const g = gcd(Math.abs(rn), rd);
steps.push({
label: `Simplify by dividing by GCD = ${g}`,
math: `${toKatex(rn, rd)} = ${toKatex(sn, sd)}`,
barA: { filled: n1, total: d1 },
barB: { filled: n2, total: d2 },
barResult: { filled: Math.abs(sn), total: sd },
});
}
} else {
// divide: invert and multiply
steps.push({
label: "Invert the second fraction (reciprocal)",
math: `${toKatex(n1, d1)} \\times ${toKatex(d2, n2)}`,
barA: { filled: n1, total: d1 },
barB: { filled: d2, total: n2 },
});
const rn = n1 * d2;
const rd = d1 * n2;
steps.push({
label: "Multiply numerators and denominators",
math: `\\frac{${n1} \\times ${d2}}{${d1} \\times ${n2}} = ${toKatex(rn, rd)}`,
barA: { filled: n1, total: d1 },
barB: { filled: d2, total: n2 },
barResult: { filled: rn, total: rd },
});
const [sn, sd] = simplify(rn, rd);
if (sn !== rn || sd !== rd) {
const g = gcd(Math.abs(rn), rd);
steps.push({
label: `Simplify by dividing by GCD = ${g}`,
math: `${toKatex(rn, rd)} = ${toKatex(sn, sd)}`,
barA: { filled: n1, total: d1 },
barB: { filled: d2, total: n2 },
barResult: { filled: Math.abs(sn), total: sd },
});
}
}
return steps;
}
function FractionBar({
filled,
total,
color,
label,
}: {
filled: number;
total: number;
color: string;
label?: string;
}) {
const maxSegments = Math.min(total, 24);
const fillCount = Math.min(filled, maxSegments);
return (
<div className="flex flex-col items-center gap-1">
{label && <span className="text-xs font-medium text-muted">{label}</span>}
<div className="flex h-10 w-full max-w-[280px] overflow-hidden rounded-lg border-2 border-border">
{Array.from({ length: maxSegments }, (_, i) => (
<div
key={i}
className="border-r border-border/30 transition-colors duration-500 last:border-r-0"
style={{
flex: 1,
backgroundColor: i < fillCount ? color : "transparent",
}}
/>
))}
</div>
<span className="text-xs text-muted">
{filled}/{total}
</span>
</div>
);
}
export function FractionOperationExplorer() {
const [op, setOp] = useState<Operation>("add");
const [n1, setN1] = useState("1");
const [d1, setD1] = useState("3");
const [n2, setN2] = useState("1");
const [d2, setD2] = useState("4");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
function handleGo() {
setError("");
const nums = [parseInt(n1), parseInt(d1), parseInt(n2), parseInt(d2)];
if (nums.some(isNaN) || nums.some((n) => n === 0)) {
setError("Enter valid non-zero numbers.");
return;
}
if (nums[1] < 0 || nums[3] < 0) {
setError("Denominators must be positive.");
return;
}
if (nums.some((n) => Math.abs(n) > 99)) {
setError("Keep numbers under 100.");
return;
}
try {
const s = buildSteps(nums[0], nums[1], nums[2], nums[3], op);
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. Check your inputs.");
}
}
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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
const opLabels: Record<Operation, string> = {
add: "Add (+)",
subtract: "Subtract ()",
multiply: "Multiply (×)",
divide: "Divide (÷)",
};
return (
<div className="space-y-4">
{/* Operation tabs */}
<div className="flex flex-wrap gap-2">
{(["add", "subtract", "multiply", "divide"] as Operation[]).map((o) => (
<button
key={o}
onClick={() => {
setOp(o);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
op === o
? "border-unit-1 bg-unit-1 text-white"
: "border-unit-1/40 text-unit-1 hover:bg-unit-1-light"
}`}
>
{opLabels[o]}
</button>
))}
</div>
{/* Input Card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-1">
Enter two fractions
</p>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1">
<div className="flex flex-col items-center">
<input
type="number"
value={n1}
onChange={(e) => setN1(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1"
aria-label="Numerator 1"
/>
<div className="my-0.5 h-0.5 w-12 bg-foreground" />
<input
type="number"
value={d1}
onChange={(e) => setD1(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1"
aria-label="Denominator 1"
/>
</div>
</div>
<span className="text-xl font-bold text-muted">
{{ add: "+", subtract: "", multiply: "×", divide: "÷" }[op]}
</span>
<div className="flex items-center gap-1">
<div className="flex flex-col items-center">
<input
type="number"
value={n2}
onChange={(e) => setN2(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1"
aria-label="Numerator 2"
/>
<div className="my-0.5 h-0.5 w-12 bg-foreground" />
<input
type="number"
value={d2}
onChange={(e) => setD2(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1"
aria-label="Denominator 2"
/>
</div>
</div>
<button
onClick={handleGo}
className="rounded-lg bg-unit-1 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-1-dark"
>
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display Card */}
<Card className="flex min-h-[240px] flex-col items-center justify-center gap-5 p-6">
{!step ? (
<p className="text-muted/50">
Enter fractions above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
<div className="flex w-full max-w-md flex-col gap-3">
<FractionBar
filled={step.barA.filled}
total={step.barA.total}
color="var(--unit-1)"
label="First"
/>
<FractionBar
filled={step.barB.filled}
total={step.barB.total}
color="var(--unit-1-dark)"
label="Second"
/>
{step.barResult && (
<FractionBar
filled={step.barResult.filled}
total={step.barResult.total}
color="var(--correct)"
label="Result"
/>
)}
</div>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !steps ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Answer</p>
<div className="text-3xl font-extrabold max-sm:text-2xl">
<MathDisplay math={steps[steps.length - 1].math} />
</div>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,305 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { toKatex } from "@/lib/math/fractions";
type FQMode = "fractionOf" | "findWhole";
interface Step {
label: string;
math: string;
barFilled: number; // 01 proportion
barLabel?: string;
}
function buildFractionOfSteps(num: number, den: number, quantity: number): Step[] {
const steps: Step[] = [];
steps.push({
label: `Find ${toKatexStr(num, den)} of ${quantity}`,
math: `${toKatex(num, den)} \\text{ of } ${quantity}`,
barFilled: 0,
});
// Step 1: Divide quantity by denominator
const onePart = quantity / den;
steps.push({
label: `Divide ${quantity} by ${den} to find one part`,
math: `${quantity} \\div ${den} = ${fmt(onePart)}`,
barFilled: 1 / den,
barLabel: fmt(onePart),
});
// Step 2: Multiply by numerator
const result = onePart * num;
steps.push({
label: `Multiply one part by ${num}`,
math: `${fmt(onePart)} \\times ${num} = ${fmt(result)}`,
barFilled: num / den,
barLabel: fmt(result),
});
steps.push({
label: `Answer: ${toKatexStr(num, den)} of ${quantity} = ${fmt(result)}`,
math: `${toKatex(num, den)} \\times ${quantity} = ${fmt(result)}`,
barFilled: num / den,
barLabel: fmt(result),
});
return steps;
}
function buildFindWholeSteps(num: number, den: number, part: number): Step[] {
const steps: Step[] = [];
steps.push({
label: `If ${toKatexStr(num, den)} of the whole = ${part}, find the whole`,
math: `${toKatex(num, den)} \\text{ of ? } = ${part}`,
barFilled: num / den,
barLabel: `${part}`,
});
// Step 1: Find one part
const onePart = part / num;
steps.push({
label: `Divide ${part} by ${num} to find one ${den}th`,
math: `${part} \\div ${num} = ${fmt(onePart)}`,
barFilled: 1 / den,
barLabel: fmt(onePart),
});
// Step 2: Multiply by denominator
const whole = onePart * den;
steps.push({
label: `Multiply by ${den} to find the whole`,
math: `${fmt(onePart)} \\times ${den} = ${fmt(whole)}`,
barFilled: 1,
barLabel: fmt(whole),
});
steps.push({
label: `The whole is ${fmt(whole)}`,
math: `\\text{Whole} = ${fmt(whole)}`,
barFilled: 1,
barLabel: fmt(whole),
});
return steps;
}
function toKatexStr(n: number, d: number): string {
return `${n}/${d}`;
}
function fmt(n: number): string {
return Number.isInteger(n) ? n.toString() : n.toFixed(2);
}
function QuantityBar({ filled, label }: { filled: number; label?: string }) {
const pct = Math.max(0, Math.min(1, filled)) * 100;
return (
<div className="w-full max-w-md">
<div className="relative h-12 overflow-hidden rounded-xl border-2 border-border bg-background">
<div
className="flex h-full items-center justify-center bg-unit-1 text-sm font-bold text-white transition-all duration-700"
style={{ width: `${pct}%` }}
>
{label && pct > 15 ? label : ""}
</div>
</div>
{label && pct <= 15 && (
<p className="mt-1 text-center text-sm font-semibold text-unit-1">{label}</p>
)}
<div className="mt-1 flex justify-between text-xs text-muted">
<span>0</span>
<span>{Math.round(pct)}%</span>
</div>
</div>
);
}
export function FractionQuantityExplorer() {
const [mode, setMode] = useState<FQMode>("fractionOf");
const [num, setNum] = useState("3");
const [den, setDen] = useState("4");
const [quantity, setQuantity] = useState("80");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
function handleGo() {
setError("");
const n = parseInt(num);
const d = parseInt(den);
const q = parseFloat(quantity);
if (isNaN(n) || isNaN(d) || d === 0) {
setError("Enter a valid fraction (denominator ≠ 0).");
return;
}
if (isNaN(q) || q <= 0) {
setError("Enter a valid positive quantity.");
return;
}
try {
const s =
mode === "fractionOf"
? buildFractionOfSteps(n, d, q)
: buildFindWholeSteps(n, d, q);
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. Check your inputs.");
}
}
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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Mode tabs */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => {
setMode("fractionOf");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "fractionOf"
? "border-unit-1 bg-unit-1 text-white"
: "border-unit-1/40 text-unit-1 hover:bg-unit-1-light"
}`}
>
Fraction OF Quantity
</button>
<button
onClick={() => {
setMode("findWhole");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "findWhole"
? "border-unit-1 bg-unit-1 text-white"
: "border-unit-1/40 text-unit-1 hover:bg-unit-1-light"
}`}
>
Find the WHOLE
</button>
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-1">
{mode === "fractionOf" ? "Enter a fraction and quantity" : "Enter a fraction and the part value"}
</p>
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-col items-center">
<input
type="number"
value={num}
onChange={(e) => setNum(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1"
aria-label="Numerator"
/>
<div className="my-0.5 h-0.5 w-12 bg-foreground" />
<input
type="number"
value={den}
onChange={(e) => setDen(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1"
aria-label="Denominator"
/>
</div>
<span className="text-lg font-semibold text-muted">
{mode === "fractionOf" ? "of" : "= "}
</span>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleGo()}
className="w-24 rounded-lg border-2 border-border bg-surface px-3 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-1"
aria-label={mode === "fractionOf" ? "Quantity" : "Part value"}
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-1 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-1-dark"
>
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter values above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
<QuantityBar filled={step.barFilled} label={step.barLabel} />
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !steps ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Answer</p>
<div className="text-3xl font-extrabold max-sm:text-2xl">
<MathDisplay math={steps[steps.length - 1].math} />
</div>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,401 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { simplifyRatio } from "@/lib/math/ratios";
import { gcd } from "@/lib/math/fractions";
type RatioMode = "divide" | "simplify" | "equivalent";
interface Step {
label: string;
math: string;
barSegments: { value: number; filled: boolean; label?: string }[];
}
const COLORS = ["var(--unit-4)", "var(--unit-1)", "var(--unit-3)", "var(--hint)"];
function buildDivideSteps(total: number, parts: number[]): Step[] {
const steps: Step[] = [];
const sum = parts.reduce((a, b) => a + b, 0);
// Step 0: Show the ratio and total
steps.push({
label: `Divide ${total} in the ratio ${parts.join(" : ")}`,
math: `${total} \\div (${parts.join(" : ")})`,
barSegments: parts.map((p) => ({ value: p, filled: false })),
});
// Step 1: Sum the parts
steps.push({
label: `Add the ratio parts: ${parts.join(" + ")} = ${sum}`,
math: `${parts.join(" + ")} = ${sum} \\text{ total parts}`,
barSegments: parts.map((p) => ({ value: p, filled: false })),
});
// Step 2: Find one part
const onePart = total / sum;
steps.push({
label: `Find one part: ${total} ÷ ${sum} = ${onePart}`,
math: `\\text{One part} = ${total} \\div ${sum} = ${Number.isInteger(onePart) ? onePart : onePart.toFixed(2)}`,
barSegments: parts.map((p) => ({ value: p, filled: false })),
});
// Steps 3+: Calculate each share
const shares: number[] = [];
parts.forEach((p, i) => {
const share = p * onePart;
shares.push(share);
steps.push({
label: `Part ${i + 1}: ${p} × ${Number.isInteger(onePart) ? onePart : onePart.toFixed(2)} = ${Number.isInteger(share) ? share : share.toFixed(2)}`,
math: `${p} \\times ${Number.isInteger(onePart) ? onePart : onePart.toFixed(2)} = ${Number.isInteger(share) ? share : share.toFixed(2)}`,
barSegments: parts.map((pp, j) => ({
value: pp,
filled: j <= i,
label: j <= i ? `${Number.isInteger(shares[j]) ? shares[j] : shares[j].toFixed(2)}` : undefined,
})),
});
});
// Final step: Show all shares
const shareStrs = shares.map((s) => (Number.isInteger(s) ? s.toString() : s.toFixed(2)));
steps.push({
label: `Result: ${shareStrs.join(", ")}`,
math: `${total} = ${shareStrs.join(" + ")}`,
barSegments: parts.map((pp, j) => ({
value: pp,
filled: true,
label: shareStrs[j],
})),
});
return steps;
}
function buildSimplifySteps(parts: number[]): Step[] {
const steps: Step[] = [];
steps.push({
label: `Simplify the ratio ${parts.join(" : ")}`,
math: parts.join(" : "),
barSegments: parts.map((p) => ({ value: p, filled: true })),
});
// Find GCD
let g = parts[0];
for (let i = 1; i < parts.length; i++) g = gcd(g, parts[i]);
if (g === 1) {
steps.push({
label: "The ratio is already in its simplest form (GCD = 1)",
math: `\\text{GCD} = 1`,
barSegments: parts.map((p) => ({ value: p, filled: true })),
});
} else {
steps.push({
label: `Find the GCD of all parts: ${g}`,
math: `\\text{GCD}(${parts.join(", ")}) = ${g}`,
barSegments: parts.map((p) => ({ value: p, filled: true })),
});
const simplified = simplifyRatio(parts);
steps.push({
label: `Divide each part by ${g}`,
math: parts.map((p) => `${p} \\div ${g} = ${p / g}`).join(", \\quad "),
barSegments: simplified.map((p) => ({ value: p, filled: true })),
});
steps.push({
label: `Simplified ratio: ${simplified.join(" : ")}`,
math: `${parts.join(" : ")} = ${simplified.join(" : ")}`,
barSegments: simplified.map((p) => ({ value: p, filled: true })),
});
}
return steps;
}
function buildEquivalentSteps(parts: number[], multiplier: number): Step[] {
const steps: Step[] = [];
steps.push({
label: `Find an equivalent ratio: ${parts.join(" : ")} × ${multiplier}`,
math: `(${parts.join(" : ")}) \\times ${multiplier}`,
barSegments: parts.map((p) => ({ value: p, filled: true })),
});
const result = parts.map((p) => p * multiplier);
steps.push({
label: `Multiply each part by ${multiplier}`,
math: parts.map((p) => `${p} \\times ${multiplier} = ${p * multiplier}`).join(", \\quad "),
barSegments: result.map((p) => ({ value: p, filled: true })),
});
steps.push({
label: `Equivalent ratio: ${result.join(" : ")}`,
math: `${parts.join(" : ")} = ${result.join(" : ")}`,
barSegments: result.map((p) => ({ value: p, filled: true })),
});
return steps;
}
function RatioBar({
segments,
}: {
segments: { value: number; filled: boolean; label?: string }[];
}) {
const total = segments.reduce((s, seg) => s + seg.value, 0);
if (total === 0) return null;
return (
<div className="w-full max-w-lg">
<div className="flex h-14 overflow-hidden rounded-xl border-2 border-border">
{segments.map((seg, i) => (
<div
key={i}
className="relative flex items-center justify-center border-r border-white/40 text-sm font-bold text-white transition-all duration-500 last:border-r-0"
style={{
flex: seg.value,
backgroundColor: seg.filled ? COLORS[i % COLORS.length] : "transparent",
color: seg.filled ? "white" : "var(--foreground)",
}}
>
{seg.label || seg.value}
</div>
))}
</div>
<div className="mt-1 flex">
{segments.map((seg, i) => (
<div
key={i}
className="text-center text-xs text-muted"
style={{ flex: seg.value }}
>
{seg.label && `(${seg.value} parts)`}
</div>
))}
</div>
</div>
);
}
export function RatioExplorer() {
const [mode, setMode] = useState<RatioMode>("divide");
const [partA, setPartA] = useState("2");
const [partB, setPartB] = useState("3");
const [partC, setPartC] = useState("");
const [total, setTotal] = useState("60");
const [multiplier, setMultiplier] = useState("4");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
function getParts(): number[] {
const parts = [parseInt(partA), parseInt(partB)];
if (partC.trim()) parts.push(parseInt(partC));
return parts;
}
function handleGo() {
setError("");
const parts = getParts();
if (parts.some(isNaN) || parts.some((p) => p <= 0)) {
setError("Enter valid positive ratio parts.");
return;
}
try {
let s: Step[];
if (mode === "divide") {
const t = parseFloat(total);
if (isNaN(t) || t <= 0) {
setError("Enter a valid positive total.");
return;
}
s = buildDivideSteps(t, parts);
} else if (mode === "simplify") {
s = buildSimplifySteps(parts);
} else {
const m = parseInt(multiplier);
if (isNaN(m) || m <= 0) {
setError("Enter a valid positive multiplier.");
return;
}
s = buildEquivalentSteps(parts, m);
}
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. Check your inputs.");
}
}
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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
const modeLabels: Record<RatioMode, string> = {
divide: "Divide in Ratio",
simplify: "Simplify Ratio",
equivalent: "Equivalent Ratio",
};
return (
<div className="space-y-4">
{/* Mode tabs */}
<div className="flex flex-wrap gap-2">
{(["divide", "simplify", "equivalent"] as RatioMode[]).map((m) => (
<button
key={m}
onClick={() => {
setMode(m);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === m
? "border-unit-4 bg-unit-4 text-white"
: "border-unit-4/40 text-unit-4 hover:bg-unit-4-light"
}`}
>
{modeLabels[m]}
</button>
))}
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-4">
{mode === "divide" ? "Enter ratio parts and total" : mode === "simplify" ? "Enter ratio parts" : "Enter ratio parts and multiplier"}
</p>
<div className="flex flex-wrap items-center gap-2.5">
<input
type="number"
value={partA}
onChange={(e) => setPartA(e.target.value)}
className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4"
aria-label="Part A"
/>
<span className="text-xl font-bold text-muted">:</span>
<input
type="number"
value={partB}
onChange={(e) => setPartB(e.target.value)}
className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4"
aria-label="Part B"
/>
<span className="text-xl font-bold text-muted">:</span>
<input
type="number"
value={partC}
onChange={(e) => setPartC(e.target.value)}
placeholder="?"
className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4"
aria-label="Part C (optional)"
/>
{mode === "divide" && (
<>
<span className="text-sm font-medium text-muted">Total:</span>
<input
type="number"
value={total}
onChange={(e) => setTotal(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4"
aria-label="Total"
/>
</>
)}
{mode === "equivalent" && (
<>
<span className="text-sm font-medium text-muted">× </span>
<input
type="number"
value={multiplier}
onChange={(e) => setMultiplier(e.target.value)}
className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4"
aria-label="Multiplier"
/>
</>
)}
<button
onClick={handleGo}
className="rounded-lg bg-unit-4 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-4-dark"
>
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter values above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-xl" />
<RatioBar segments={step.barSegments} />
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !steps ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Answer</p>
<p className="text-2xl font-extrabold text-foreground max-sm:text-xl">
{steps[steps.length - 1].label.replace("Result: ", "").replace("Simplified ratio: ", "").replace("Equivalent ratio: ", "")}
</p>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,559 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
type RoundTarget = "whole" | "1dp" | "2dp" | "1sf" | "2sf" | "3sf";
interface Step {
label: string;
digits: DigitInfo[];
resultText?: string;
}
interface DigitInfo {
char: string;
state: "normal" | "target" | "decider" | "dim" | "significant";
}
function getDigits(value: string): string[] {
return value.split("");
}
// ── Decimal place rounding steps ─────────────────────────────────────────────
function buildDPSteps(rawValue: string, target: "whole" | "1dp" | "2dp"): Step[] {
const value = parseFloat(rawValue);
const digits = getDigits(rawValue);
const dotIndex = digits.indexOf(".");
const steps: Step[] = [];
function makeDigits(
targetIdx: number | null,
deciderIdx: number | null,
): DigitInfo[] {
return digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: i === targetIdx
? "target"
: i === deciderIdx
? "decider"
: "normal",
}));
}
let targetDigitIdx: number;
let roundedValue: number;
let targetLabel: string;
if (target === "whole") {
targetDigitIdx = dotIndex === -1 ? digits.length - 1 : dotIndex - 1;
roundedValue = Math.round(value);
targetLabel = "ones (whole number)";
} else if (target === "1dp") {
targetDigitIdx = dotIndex + 1;
roundedValue = Math.round(value * 10) / 10;
targetLabel = "1 decimal place (tenths)";
} else {
targetDigitIdx = dotIndex + 2;
roundedValue = Math.round(value * 100) / 100;
targetLabel = "2 decimal places (hundredths)";
}
// Compute decider index (skip over decimal point if needed)
const nextIdx = targetDigitIdx + 1;
const deciderIdx =
nextIdx < digits.length && digits[nextIdx] === "."
? nextIdx + 1
: nextIdx;
// Step 0: Show the number
steps.push({
label: `Rounding ${rawValue} to ${targetLabel}`,
digits: makeDigits(null, null),
});
// Step 1: Identify target
steps.push({
label: `Identify the target digit (${targetLabel})`,
digits: makeDigits(targetDigitIdx, null),
});
// Step 2: Identify decider
if (deciderIdx < digits.length) {
steps.push({
label: "Look at the digit to the right (the decider)",
digits: makeDigits(targetDigitIdx, deciderIdx),
});
const deciderDigit = parseInt(digits[deciderIdx]);
const roundsUp = deciderDigit >= 5;
steps.push({
label: roundsUp
? `Decider is ${deciderDigit} (≥ 5) → round UP`
: `Decider is ${deciderDigit} (< 5) → round DOWN`,
digits: makeDigits(targetDigitIdx, deciderIdx),
});
} else {
steps.push({
label: "No digit to the right — no rounding needed",
digits: makeDigits(targetDigitIdx, null),
});
}
// Step 3: Result
const resultStr =
target === "whole"
? roundedValue.toString()
: target === "1dp"
? roundedValue.toFixed(1)
: roundedValue.toFixed(2);
const resultDigits = getDigits(resultStr);
steps.push({
label: `Result: ${resultStr}`,
digits: resultDigits.map((ch) => ({
char: ch,
state: "normal" as const,
})),
resultText: resultStr,
});
return steps;
}
// ── Significant figures rounding steps ───────────────────────────────────────
function buildSFSteps(rawValue: string, sfCount: number): Step[] {
const value = parseFloat(rawValue);
const digits = getDigits(rawValue);
const steps: Step[] = [];
// Step 0: Show the number
steps.push({
label: `Rounding ${rawValue} to ${sfCount} significant figure${sfCount > 1 ? "s" : ""}`,
digits: digits.map((ch) => ({ char: ch, state: "normal" as const })),
});
// Step 1: Identify which digits are significant
// Rule: significant figures start at the first non-zero digit
const sigDigitIndices: number[] = [];
let foundFirstNonZero = false;
for (let i = 0; i < digits.length; i++) {
if (digits[i] === ".") continue;
if (!foundFirstNonZero && digits[i] === "0") continue;
foundFirstNonZero = true;
sigDigitIndices.push(i);
}
// Build display: highlight all significant digits found
const sigSet = new Set(sigDigitIndices);
steps.push({
label: `Identify significant figures — start from the first non-zero digit`,
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: sigSet.has(i)
? "significant"
: "dim",
})),
});
// Count and label the sig figs we need
const sigLabel = sigDigitIndices.slice(0, sfCount).map((idx, n) => {
return `${ordinal(n + 1)} s.f. = ${digits[idx]}`;
});
const targetIdx = sigDigitIndices[sfCount - 1]; // the last sig fig we keep
if (targetIdx === undefined) {
// Not enough significant figures in the number
steps.push({
label: `The number only has ${sigDigitIndices.length} significant figure${sigDigitIndices.length !== 1 ? "s" : ""} — already rounded`,
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: sigSet.has(i)
? "significant"
: "dim",
})),
resultText: rawValue,
});
return steps;
}
// Step 2: Show which sig figs we're keeping
const keepSet = new Set(sigDigitIndices.slice(0, sfCount));
steps.push({
label: `Keep ${sfCount} sig fig${sfCount > 1 ? "s" : ""}: ${sigLabel.join(", ")}`,
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: keepSet.has(i)
? "target"
: sigSet.has(i)
? "normal"
: "dim",
})),
});
// Step 3: Identify the decider
const deciderCandidateIdx = sigDigitIndices[sfCount]; // next sig fig after the ones we keep
// But the decider could also be after a decimal point
let deciderIdx: number | null = null;
if (deciderCandidateIdx !== undefined) {
deciderIdx = deciderCandidateIdx;
} else {
// Look for any digit after the target
for (let i = targetIdx + 1; i < digits.length; i++) {
if (digits[i] !== ".") {
deciderIdx = i;
break;
}
}
}
if (deciderIdx !== null && deciderIdx < digits.length) {
steps.push({
label: `Look at the next digit: ${digits[deciderIdx]} (the decider)`,
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: i === deciderIdx
? "decider"
: keepSet.has(i)
? "target"
: "dim",
})),
});
// Step 4: Round decision
const deciderDigit = parseInt(digits[deciderIdx]);
const roundsUp = deciderDigit >= 5;
steps.push({
label: roundsUp
? `Decider is ${deciderDigit} (≥ 5) → round UP the last kept digit`
: `Decider is ${deciderDigit} (< 5) → round DOWN (keep it the same)`,
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: i === deciderIdx
? "decider"
: keepSet.has(i)
? "target"
: "dim",
})),
});
} else {
steps.push({
label: "No digit to the right — no rounding needed",
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: keepSet.has(i)
? "target"
: "dim",
})),
});
}
// Step 5: Compute and show result
let roundedValue: number;
if (value === 0) {
roundedValue = 0;
} else {
const magnitude = Math.floor(Math.log10(Math.abs(value)));
const factor = Math.pow(10, sfCount - 1 - magnitude);
roundedValue = Math.round(value * factor) / factor;
}
// Format result with correct sig figs (preserve trailing zeros)
const resultStr = formatSigFigs(roundedValue, sfCount);
const resultDigits = getDigits(resultStr);
// Step: explain what happens to remaining digits
const dotIdx = rawValue.indexOf(".");
const hasIntegerPart = dotIdx === -1 || dotIdx > 0;
if (hasIntegerPart && sigDigitIndices.length > sfCount) {
// Some trailing digits in the integer part get replaced with zeros
steps.push({
label: "Replace remaining digits with zeros (to keep the place value)",
digits: resultDigits.map((ch) => ({
char: ch,
state: "normal" as const,
})),
});
}
steps.push({
label: `Result: ${resultStr} (${sfCount} significant figure${sfCount > 1 ? "s" : ""})`,
digits: resultDigits.map((ch) => ({
char: ch,
state: "normal" as const,
})),
resultText: resultStr,
});
return steps;
}
function formatSigFigs(value: number, sfCount: number): string {
if (value === 0) return "0";
const magnitude = Math.floor(Math.log10(Math.abs(value)));
if (magnitude >= sfCount - 1) {
// Integer result — no decimal point needed
return Math.round(value / Math.pow(10, magnitude - sfCount + 1))
* Math.pow(10, magnitude - sfCount + 1) + "";
}
// Decimal result — need to show enough places
const decimalPlaces = sfCount - 1 - magnitude;
return value.toFixed(decimalPlaces);
}
function ordinal(n: number): string {
if (n === 1) return "1st";
if (n === 2) return "2nd";
if (n === 3) return "3rd";
return `${n}th`;
}
// ── Main step builder ────────────────────────────────────────────────────────
function buildSteps(rawValue: string, target: RoundTarget): Step[] {
if (target === "whole" || target === "1dp" || target === "2dp") {
return buildDPSteps(rawValue, target);
}
const sfCount = target === "1sf" ? 1 : target === "2sf" ? 2 : 3;
return buildSFSteps(rawValue, sfCount);
}
// ── Digit box component ─────────────────────────────────────────────────────
function DigitBox({ char, state }: DigitInfo) {
if (char === ".") {
return (
<div className="flex h-[88px] w-6 items-end justify-center pb-2 max-sm:h-[70px]">
<div className="h-3 w-3 rounded-full bg-foreground" />
</div>
);
}
const styles: Record<DigitInfo["state"], string> = {
normal: "border-border bg-surface text-foreground",
target: "border-unit-2 bg-unit-2-light text-foreground ring-2 ring-unit-2/30",
decider: "border-hint bg-hint-light text-foreground ring-2 ring-hint/30",
significant: "border-unit-4 bg-unit-4-light text-foreground ring-2 ring-unit-4/30",
dim: "border-border/40 bg-background text-muted/30",
};
return (
<div
className={`flex h-[88px] w-[68px] shrink-0 select-none items-center justify-center rounded-xl border-2 text-[3.2rem] font-bold transition-all duration-300 max-sm:h-[70px] max-sm:w-[52px] max-sm:text-[2.4rem] ${styles[state]}`}
>
{char}
</div>
);
}
// ── Explorer component ──────────────────────────────────────────────────────
export function RoundingExplorer() {
const [input, setInput] = useState("3.4567");
const [target, setTarget] = useState<RoundTarget>("1dp");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
function handleGo() {
setError("");
const trimmed = input.trim();
const val = parseFloat(trimmed);
if (isNaN(val)) {
setError("Enter a valid number.");
return;
}
if (val < 0) {
setError("Enter a positive number.");
return;
}
if (trimmed.length > 16) {
setError("Number too long.");
return;
}
if (!trimmed.includes(".") && (target === "1dp" || target === "2dp")) {
setError("Enter a decimal number for decimal place rounding.");
return;
}
try {
const s = buildSteps(trimmed, target);
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not process. 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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
const targetLabels: Record<RoundTarget, string> = {
whole: "Whole Number",
"1dp": "1 d.p.",
"2dp": "2 d.p.",
"1sf": "1 s.f.",
"2sf": "2 s.f.",
"3sf": "3 s.f.",
};
return (
<div className="space-y-4">
{/* Target tabs */}
<div className="flex flex-wrap gap-2">
{(["whole", "1dp", "2dp", "1sf", "2sf", "3sf"] as RoundTarget[]).map((t) => (
<button
key={t}
onClick={() => {
setTarget(t);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
target === t
? "border-unit-2 bg-unit-2 text-white"
: "border-unit-2/40 text-unit-2 hover:bg-unit-2-light"
}`}
>
{targetLabels[t]}
</button>
))}
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-2">
Enter a number
</p>
<div className="flex flex-wrap items-center gap-2.5">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleGo()}
placeholder="e.g. 3.4567 or 0.00456"
maxLength={18}
autoComplete="off"
className="max-w-[240px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-2 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-2-dark"
>
Round
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter a number above and click <strong>Round</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
<div className="flex flex-wrap items-end justify-center gap-2">
{step.digits.map((d, i) => (
<DigitBox key={i} {...d} />
))}
</div>
{/* Legend */}
<div className="flex flex-wrap items-center justify-center gap-4 text-xs text-muted">
<span className="flex items-center gap-1">
<span className="inline-block h-3 w-3 rounded border-2 border-unit-2 bg-unit-2-light" />
Target digit
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-3 w-3 rounded border-2 border-hint bg-hint-light" />
Decider digit
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-3 w-3 rounded border-2 border-unit-4 bg-unit-4-light" />
Significant
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-3 w-3 rounded border-2 border-border/40 bg-background" />
Not significant
</span>
</div>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !step?.resultText ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Rounded Value</p>
<p className="text-3xl font-extrabold text-foreground max-sm:text-2xl">
{step.resultText}
</p>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,445 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
type Mode = "toSF" | "toOrd";
interface DisplayState {
digits: string[];
decimalPos: number;
initPos: number;
targetPos: number;
origPower: number;
}
function parseOrdinary(raw: string): { digits: string[]; decimalPos: number } {
const s = raw.trim().replace(/[\s,]/g, "");
if (s.startsWith("-")) throw new Error("Please enter a positive number.");
if (!/^\d*\.?\d+$/.test(s) || s === "")
throw new Error("Invalid number. Try something like 7438 or 0.0055");
if (s.length > 16) throw new Error("Number too long (max 16 digits).");
const dot = s.indexOf(".");
if (dot === -1) {
return { digits: s.split(""), decimalPos: s.length };
}
return {
digits: (s.slice(0, dot) + s.slice(dot + 1)).split(""),
decimalPos: dot,
};
}
function firstNonZero(digits: string[]): number {
return digits.findIndex((d) => d !== "0");
}
function sfTargetPos(digits: string[]): number {
const i = firstNonZero(digits);
return i === -1 ? 1 : i + 1;
}
function buildCoeff(sigDigs: string[]): string {
if (sigDigs.length === 0) return "0";
if (sigDigs.length === 1) return sigDigs[0] + ".0";
const rest = sigDigs.slice(1).join("").replace(/0+$/, "") || "0";
return sigDigs[0] + "." + rest;
}
function buildOrdinary(digits: string[], pos: number): string {
let s: string;
if (pos <= 0) {
s = "0." + "0".repeat(-pos) + digits.join("");
} else if (pos >= digits.length) {
s = digits.join("") + "0".repeat(pos - digits.length);
} else {
s = digits.slice(0, pos).join("") + "." + digits.slice(pos).join("");
}
s = s.replace(/^0+(?=[1-9])/, "");
if (s.startsWith(".")) s = "0" + s;
if (s.includes(".")) s = s.replace(/\.?0+$/, "");
return s || "0";
}
export function StandardFormExplorer() {
const [mode, setMode] = useState<Mode>("toSF");
const [ordinaryInput, setOrdinaryInput] = useState("");
const [coeffInput, setCoeffInput] = useState("");
const [powerInput, setPowerInput] = useState("");
const [error, setError] = useState("");
const [display, setDisplay] = useState<DisplayState | null>(null);
const [currentPos, setCurrentPos] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [done, setDone] = useState(false);
const numberRowRef = useRef<HTMLDivElement>(null);
const dotRef = useRef<HTMLDivElement>(null);
const [dotLeft, setDotLeft] = useState(0);
const [dotAnimate, setDotAnimate] = useState(false);
const [powerPop, setPowerPop] = useState(false);
const totalSteps = display ? Math.abs(display.targetPos - display.initPos) : 0;
const currentStep = display ? Math.abs(currentPos - display.initPos) : 0;
const currentPower = display
? mode === "toSF"
? display.initPos - currentPos
: display.origPower - (currentPos - display.initPos)
: 0;
// Position decimal dot
const placeDot = useCallback(
(animate: boolean) => {
if (!numberRowRef.current || !display) return;
const boxes = numberRowRef.current.querySelectorAll<HTMLDivElement>("[data-digit-box]");
if (!boxes.length) return;
let leftPx: number;
const dp = currentPos;
if (dp <= 0) {
leftPx = boxes[0].offsetLeft - 6;
} else if (dp >= boxes.length) {
const last = boxes[boxes.length - 1];
leftPx = last.offsetLeft + last.offsetWidth - 4;
} else {
const prev = boxes[dp - 1];
const next = boxes[dp];
leftPx = Math.round((prev.offsetLeft + prev.offsetWidth + next.offsetLeft) / 2);
}
setDotAnimate(animate);
setDotLeft(leftPx);
},
[currentPos, display],
);
useEffect(() => {
if (display) {
requestAnimationFrame(() => {
requestAnimationFrame(() => placeDot(true));
});
}
}, [currentPos, display, placeDot]);
// Initial placement without animation
useEffect(() => {
if (display) {
requestAnimationFrame(() => {
requestAnimationFrame(() => placeDot(false));
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [display?.digits.join("")]);
function initDisplay(digits: string[], start: number, target: number, origPower: number) {
setIsPlaying(false);
setDone(start === target);
setDisplay({ digits, decimalPos: start, initPos: start, targetPos: target, origPower });
setCurrentPos(start);
setError("");
}
function handleConvertToSF() {
setError("");
try {
const { digits, decimalPos } = parseOrdinary(ordinaryInput);
const target = sfTargetPos(digits);
initDisplay(digits, decimalPos, target, 0);
} catch (e) {
setError((e as Error).message);
}
}
function handleConvertToOrd() {
setError("");
try {
if (!coeffInput.trim()) throw new Error("Enter the coefficient A.");
const n = parseInt(powerInput, 10);
if (isNaN(n)) throw new Error("Enter the power n as a whole number.");
if (Math.abs(n) > 12) throw new Error("Power too large for display (max ±12).");
if (n === 0) throw new Error("Power is 0 — the number is already in ordinary form.");
const { digits } = parseOrdinary(coeffInput);
const A = parseFloat(coeffInput);
if (isNaN(A) || A < 1 || A >= 10)
throw new Error(`Coefficient must satisfy 1 ≤ A < 10 (got ${coeffInput}).`);
const decimalPos = 1;
const expandedDigits = [...digits];
let startPos: number;
let targetPos: number;
if (n > 0) {
targetPos = decimalPos + n;
startPos = decimalPos;
while (expandedDigits.length < targetPos) expandedDigits.push("0");
} else {
const absN = Math.abs(n);
const zeros = Array(absN).fill("0");
expandedDigits.unshift(...zeros);
startPos = decimalPos + absN;
targetPos = startPos + n;
}
initDisplay(expandedDigits, startPos, targetPos, n);
} catch (e) {
setError((e as Error).message);
}
}
const stepForward = useCallback(() => {
if (!display || currentPos === display.targetPos) return;
const dir = display.targetPos > display.initPos ? 1 : -1;
const nextPos = currentPos + dir;
setCurrentPos(nextPos);
setPowerPop(true);
setTimeout(() => setPowerPop(false), 300);
if (nextPos === display.targetPos) {
setDone(true);
setIsPlaying(false);
}
}, [display, currentPos]);
const stepBack = useCallback(() => {
if (!display || currentPos === display.initPos) return;
if (done) setDone(false);
const dir = display.targetPos > display.initPos ? -1 : 1;
setCurrentPos((p) => p + dir);
setPowerPop(true);
setTimeout(() => setPowerPop(false), 300);
}, [display, currentPos, done]);
const togglePlay = useCallback(() => {
setIsPlaying((p) => !p);
}, []);
const reset = useCallback(() => {
setIsPlaying(false);
setDisplay(null);
setDone(false);
setError("");
}, []);
// Direction label
const remaining = display ? display.targetPos - currentPos : 0;
const dirText =
!display || done || remaining === 0
? null
: remaining < 0
? `← Moving left · ${Math.abs(remaining)} step${Math.abs(remaining) !== 1 ? "s" : ""} remaining`
: `→ Moving right · ${remaining} step${remaining !== 1 ? "s" : ""} remaining`;
// Result
const fz = display ? firstNonZero(display.digits) : 0;
return (
<div className="space-y-4">
{/* Mode Tabs */}
<div className="flex gap-2">
<button
onClick={() => {
setMode("toSF");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "toSF"
? "border-unit-2 bg-unit-2 text-white"
: "border-unit-2/40 text-unit-2 hover:bg-unit-2-light"
}`}
>
Ordinary Standard Form
</button>
<button
onClick={() => {
setMode("toOrd");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "toOrd"
? "border-unit-2 bg-unit-2 text-white"
: "border-unit-2/40 text-unit-2 hover:bg-unit-2-light"
}`}
>
Standard Form Ordinary
</button>
</div>
{/* Input Card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-2">
{mode === "toSF" ? "Enter an ordinary number" : "Enter a number in standard form A × 10ⁿ"}
</p>
{mode === "toSF" ? (
<div className="flex flex-wrap items-center gap-2.5">
<input
type="text"
value={ordinaryInput}
onChange={(e) => setOrdinaryInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleConvertToSF()}
placeholder="e.g. 7438 or 0.0055"
maxLength={18}
autoComplete="off"
className="max-w-[220px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
/>
<button
onClick={handleConvertToSF}
className="rounded-lg bg-unit-2 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-2-dark"
>
Convert
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2.5">
<input
type="text"
value={coeffInput}
onChange={(e) => setCoeffInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleConvertToOrd()}
placeholder="A e.g. 3.6"
maxLength={12}
autoComplete="off"
className="max-w-[140px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
/>
<span className="text-lg font-semibold">
× 10<sup>n</sup> where n =
</span>
<input
type="number"
value={powerInput}
onChange={(e) => setPowerInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleConvertToOrd()}
placeholder="e.g. 4 or 3"
autoComplete="off"
className="max-w-[110px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
/>
<button
onClick={handleConvertToOrd}
className="rounded-lg bg-unit-2 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-2-dark"
>
Convert
</button>
</div>
)}
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display Card */}
<Card className="flex min-h-[260px] flex-col items-center justify-center gap-3 p-6">
{!display ? (
<p className="text-muted/50">
Enter a number above and click <strong>Convert</strong>
</p>
) : (
<>
{/* Step info */}
<p className="text-sm text-muted">
{totalSteps === 0
? "Number is already in standard form position — power = 0"
: `Step ${currentStep} of ${totalSteps}`}
</p>
{/* Digit row */}
<div ref={numberRowRef} className="relative inline-flex items-end gap-2.5 px-4 pb-7 pt-2">
{display.digits.map((ch, i) => {
const isLeadingZero = i < fz && fz !== -1;
const isHighlight = i === display.targetPos - 1;
return (
<div
key={i}
data-digit-box
className={`flex h-[88px] w-[68px] shrink-0 select-none items-center justify-center rounded-xl border-2 text-[3.2rem] font-bold transition-all duration-300 max-sm:h-[70px] max-sm:w-[52px] max-sm:text-[2.4rem] ${
isLeadingZero
? "border-border/60 bg-background text-muted/30"
: isHighlight
? "border-unit-2 bg-unit-2-light text-foreground"
: "border-border bg-surface text-foreground"
}`}
>
{ch}
</div>
);
})}
{/* Decimal dot */}
<div
ref={dotRef}
className="absolute bottom-1.5 z-10 h-[18px] w-[18px] -translate-x-1/2 rounded-full bg-incorrect shadow-[0_2px_8px_rgba(239,68,68,0.45)]"
style={{
left: dotLeft,
transition: dotAnimate ? "left 0.52s cubic-bezier(0.34,1.56,0.64,1), opacity 0.3s" : "none",
opacity: done && currentPos >= display.digits.length ? 0 : 1,
}}
/>
</div>
{/* Direction label */}
{dirText && (
<p
className={`text-sm font-semibold ${
remaining < 0 ? "text-incorrect" : "text-hint"
}`}
>
{dirText}
</p>
)}
{/* Power counter */}
<div className="flex items-baseline gap-1.5 text-2xl text-muted">
<span>×</span>
<span className="font-bold text-foreground">10</span>
<sup
className={`inline-block min-w-[38px] text-2xl font-extrabold text-hint ${
powerPop ? "animate-bounce" : ""
}`}
>
{currentPower}
</sup>
</div>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep}
totalSteps={totalSteps}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!display && currentPos !== display.targetPos}
canStepBack={!!display && currentPos !== display.initPos}
/>
</Card>
{/* Result Card */}
<Card className="flex min-h-[90px] flex-col items-center justify-center gap-1.5 text-center">
{!display || !done ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : mode === "toSF" ? (
<>
<p className="text-xs uppercase tracking-wider text-muted">Standard Form</p>
<p className="text-3xl font-extrabold max-sm:text-2xl">
<span className="text-foreground">
{buildCoeff(display.digits.slice(fz === -1 ? 0 : fz))}
</span>
<span className="mx-2 text-muted">×</span>
<span className="text-foreground">10</span>
<sup className="text-xl text-incorrect">{display.initPos - display.targetPos}</sup>
</p>
</>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Ordinary Form</p>
<p className="text-3xl font-extrabold text-foreground max-sm:text-2xl">
{buildOrdinary(display.digits, display.targetPos)}
</p>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useEffect, useCallback, useRef } from "react";
interface StepControlsProps {
currentStep: number;
totalSteps: number;
isPlaying: boolean;
onStepForward: () => void;
onStepBack: () => void;
onTogglePlay: () => void;
onReset: () => void;
canStepForward: boolean;
canStepBack: boolean;
playIntervalMs?: number;
}
export function StepControls({
currentStep,
totalSteps,
isPlaying,
onStepForward,
onStepBack,
onTogglePlay,
onReset,
canStepForward,
canStepBack,
playIntervalMs = 950,
}: StepControlsProps) {
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Auto-play timer
useEffect(() => {
if (isPlaying && canStepForward) {
timerRef.current = setInterval(() => {
onStepForward();
}, playIntervalMs);
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [isPlaying, canStepForward, onStepForward, playIntervalMs]);
// Keyboard shortcuts
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName;
if (["INPUT", "TEXTAREA", "SELECT"].includes(tag)) return;
if (e.key === "ArrowRight" || e.key === " ") {
e.preventDefault();
if (canStepForward) onStepForward();
}
if (e.key === "ArrowLeft") {
e.preventDefault();
if (canStepBack) onStepBack();
}
if (e.key.toLowerCase() === "p") onTogglePlay();
if (e.key.toLowerCase() === "r") onReset();
},
[canStepForward, canStepBack, onStepForward, onStepBack, onTogglePlay, onReset],
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
const progress = totalSteps > 0 ? (currentStep / totalSteps) * 100 : 0;
return (
<div className="space-y-3">
{/* Progress bar */}
{totalSteps > 0 && (
<div className="mx-auto max-w-md">
<div className="mb-1.5 flex items-center justify-between text-xs text-muted">
<span>Step {currentStep} of {totalSteps}</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-border/60">
<div
className="h-full rounded-full bg-gradient-to-r from-unit-1 to-unit-4 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{/* Controls */}
<div className="flex flex-wrap items-center justify-center gap-2">
<button
onClick={onStepBack}
disabled={!canStepBack || isPlaying}
className="rounded-xl border border-border/60 bg-surface px-4 py-2.5 text-sm font-semibold text-foreground shadow-[var(--shadow-sm)] transition-all duration-200 hover:shadow-[var(--shadow-md)] hover:-translate-y-px disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:translate-y-0 disabled:hover:shadow-[var(--shadow-sm)]"
>
&#9664; Back
</button>
<button
onClick={onTogglePlay}
disabled={!canStepForward && !isPlaying}
className="min-w-[110px] rounded-xl bg-correct px-5 py-2.5 text-sm font-semibold text-white shadow-[var(--shadow-sm)] transition-all duration-200 hover:shadow-[var(--shadow-md)] hover:-translate-y-px disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:translate-y-0 disabled:hover:shadow-[var(--shadow-sm)]"
>
{isPlaying ? "⏸ Pause" : "▶ Play"}
</button>
<button
onClick={onStepForward}
disabled={!canStepForward || isPlaying}
className="rounded-xl bg-foreground px-5 py-2.5 text-sm font-semibold text-background shadow-[var(--shadow-sm)] transition-all duration-200 hover:shadow-[var(--shadow-md)] hover:-translate-y-px disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:translate-y-0 disabled:hover:shadow-[var(--shadow-sm)]"
>
Next Step &#9654;
</button>
<button
onClick={onReset}
className="rounded-xl border border-incorrect/30 bg-surface px-4 py-2.5 text-sm font-semibold text-incorrect shadow-[var(--shadow-sm)] transition-all duration-200 hover:bg-incorrect hover:text-white hover:shadow-[var(--shadow-md)] hover:-translate-y-px"
>
&#8634; Reset
</button>
</div>
{/* Keyboard hint */}
<p className="text-center text-[0.7rem] text-muted/50">
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">Space</kbd>{" / "}
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">&#8594;</kbd>{" "}
next{" · "}
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">&#8592;</kbd>{" "}
back{" · "}
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">P</kbd>{" "}
play{" · "}
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">R</kbd>{" "}
reset
</p>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import Link from "next/link";
interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbsProps {
items: BreadcrumbItem[];
}
export function Breadcrumbs({ items }: BreadcrumbsProps) {
return (
<nav aria-label="Breadcrumb" className="mb-6">
<ol className="flex flex-wrap items-center gap-1.5 text-sm">
<li>
<Link href="/" className="text-muted transition-colors hover:text-foreground">
Home
</Link>
</li>
{items.map((item, i) => (
<li key={i} className="flex items-center gap-1.5">
<svg className="h-3.5 w-3.5 text-border" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
{item.href ? (
<Link href={item.href} className="text-muted transition-colors hover:text-foreground">
{item.label}
</Link>
) : (
<span className="font-medium text-foreground">{item.label}</span>
)}
</li>
))}
</ol>
</nav>
);
}

View File

@@ -0,0 +1,32 @@
import Link from "next/link";
export function Footer() {
return (
<footer className="border-t border-border/60 bg-surface/95">
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-unit-1 via-unit-2 to-unit-3 shadow-[var(--shadow-sm)]">
<span className="text-xs font-extrabold text-white">C</span>
</div>
<div>
<p className="text-sm font-extrabold text-[#17367d]">Cabrits Mathematics</p>
<p className="text-xs text-[#4c6290]">Learn it. Try it. Master it.</p>
</div>
</div>
<nav className="flex items-center gap-5 text-sm font-semibold text-muted">
<Link href="/lessons" className="transition-colors hover:text-foreground">
Lessons
</Link>
<Link href="/practice" className="transition-colors hover:text-foreground">
Practice
</Link>
</nav>
<p className="text-xs font-medium text-muted">
Portsmouth Secondary School
</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,42 @@
import Link from "next/link";
const navItems = [
{ href: "/", label: "Home", className: "bg-[#e0483d] hover:bg-[#cb3a30]" },
{ href: "/lessons", label: "Lessons", className: "bg-[#f08b24] hover:bg-[#d97706]" },
{ href: "/practice", label: "Practice", className: "bg-[#1faa59] hover:bg-[#168845]" },
];
export function Header() {
return (
<header className="sticky top-0 z-50 border-b-4 border-[#1c4ab7] bg-[#2f65e8] text-white">
<div className="mx-auto max-w-6xl playground-frame border-b-0 border-t-0 bg-[#2f65e8] shadow-none">
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<Link href="/" className="group flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#ffd043] text-[#10308a] shadow-md">
<span className="text-lg font-extrabold">C</span>
</div>
<div className="rounded-md bg-white px-2.5 py-1 leading-none shadow-sm">
<p className="text-2xl font-extrabold tracking-tight text-[#17367d]">Cabrits</p>
<p className="text-xs font-bold uppercase tracking-[0.14em] text-[#3a5ea8]">Math Playground</p>
</div>
</Link>
<div className="flex items-center gap-2 text-xs font-bold sm:text-sm">
<span className="rounded-full bg-[#1a49b6] px-3 py-1">Ages 12-16</span>
<span className="rounded-full bg-[#1a49b6] px-3 py-1">Form 1 Term 2</span>
</div>
</div>
<nav className="flex flex-wrap gap-1 bg-[#1b49b5] px-2 pb-2 pt-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`rounded-sm px-4 py-2 text-sm font-extrabold text-white transition-colors ${item.className}`}
>
{item.label}
</Link>
))}
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { curriculum } from "@/lib/curriculum";
import { cn } from "@/lib/utils";
import { useState } from "react";
const unitColorMap = {
"unit-1": "text-unit-1-dark",
"unit-2": "text-unit-2-dark",
"unit-3": "text-unit-3-dark",
"unit-4": "text-unit-4-dark",
};
const unitDotColor = {
"unit-1": "bg-unit-1",
"unit-2": "bg-unit-2",
"unit-3": "bg-unit-3",
"unit-4": "bg-unit-4",
};
export function MobileNav() {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
return (
<div className="lg:hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 rounded-xl border border-border/60 bg-surface px-3.5 py-2 text-sm font-medium shadow-[var(--shadow-sm)] transition-all hover:shadow-[var(--shadow-md)]"
aria-label="Toggle navigation"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{isOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
Topics
</button>
{isOpen && (
<div className="absolute left-0 right-0 top-full z-40 max-h-[70vh] overflow-y-auto border-b border-border/60 bg-surface p-4 shadow-[var(--shadow-lg)]">
<Link
href="/lessons"
onClick={() => setIsOpen(false)}
className="mb-4 flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-muted transition-colors hover:bg-background hover:text-foreground"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
All Topics
</Link>
<div className="mb-3 h-px bg-border/60" />
{curriculum.map((unit) => (
<div key={unit.slug} className="mb-4">
<p className={cn("mb-1.5 flex items-center gap-2 px-3 text-xs font-bold uppercase tracking-wider", unitColorMap[unit.color])}>
<span className={cn("h-2 w-2 rounded-full", unitDotColor[unit.color])} />
Unit {unit.number}: {unit.title}
</p>
<div className="space-y-0.5">
{unit.topics.map((topic) => {
const href = `/lessons/${unit.slug}/${topic.slug}`;
return (
<Link
key={topic.slug}
href={href}
onClick={() => setIsOpen(false)}
className={cn(
"block rounded-lg px-3 py-1.5 text-sm transition-colors",
pathname === href
? "bg-foreground text-background font-medium"
: "text-muted hover:bg-background hover:text-foreground",
)}
>
{topic.shortTitle}
</Link>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,142 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { curriculum } from "@/lib/curriculum";
import { cn } from "@/lib/utils";
import { useState } from "react";
const unitColorMap = {
"unit-1": {
active: "bg-unit-1-light text-unit-1-dark border-unit-1/20",
dot: "bg-unit-1",
heading: "text-unit-1-dark",
hoverBg: "hover:bg-unit-1-light/50",
},
"unit-2": {
active: "bg-unit-2-light text-unit-2-dark border-unit-2/20",
dot: "bg-unit-2",
heading: "text-unit-2-dark",
hoverBg: "hover:bg-unit-2-light/50",
},
"unit-3": {
active: "bg-unit-3-light text-unit-3-dark border-unit-3/20",
dot: "bg-unit-3",
heading: "text-unit-3-dark",
hoverBg: "hover:bg-unit-3-light/50",
},
"unit-4": {
active: "bg-unit-4-light text-unit-4-dark border-unit-4/20",
dot: "bg-unit-4",
heading: "text-unit-4-dark",
hoverBg: "hover:bg-unit-4-light/50",
},
};
export function Sidebar() {
const pathname = usePathname();
const [openUnits, setOpenUnits] = useState<Set<number>>(() => {
const open = new Set<number>();
for (const unit of curriculum) {
if (pathname.includes(unit.slug)) {
open.add(unit.number);
}
}
if (open.size === 0) open.add(1);
return open;
});
function toggleUnit(num: number) {
setOpenUnits((prev) => {
const next = new Set(prev);
if (next.has(num)) next.delete(num);
else next.add(num);
return next;
});
}
return (
<aside className="hidden w-64 shrink-0 border-r border-border/60 bg-surface lg:block">
<nav className="sticky top-[4.125rem] h-[calc(100vh-4.125rem)] overflow-y-auto p-4">
<Link
href="/lessons"
className={cn(
"mb-4 flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-200",
pathname === "/lessons"
? "bg-foreground text-background shadow-[var(--shadow-sm)]"
: "text-muted hover:bg-background hover:text-foreground",
)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
All Topics
</Link>
<div className="mb-3 h-px bg-border/60" />
{curriculum.map((unit) => {
const colors = unitColorMap[unit.color];
const isOpen = openUnits.has(unit.number);
return (
<div key={unit.slug} className="mb-1">
<button
onClick={() => toggleUnit(unit.number)}
className={cn(
"flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm font-semibold transition-all duration-200",
colors.heading,
colors.hoverBg,
)}
>
<span className="truncate">Unit {unit.number}: {unit.title}</span>
<svg
className={cn(
"h-4 w-4 shrink-0 transition-transform duration-200",
isOpen && "rotate-90",
)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{isOpen && (
<div className="ml-2 mt-0.5 space-y-0.5 border-l-2 border-border/40 pl-2">
{unit.topics.map((topic) => {
const href = `/lessons/${unit.slug}/${topic.slug}`;
const isActive = pathname === href;
return (
<Link
key={topic.slug}
href={href}
className={cn(
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm transition-all duration-200",
isActive
? cn(colors.active, "font-medium shadow-[var(--shadow-sm)]")
: "text-muted hover:text-foreground",
)}
>
<span
className={cn(
"h-1.5 w-1.5 shrink-0 rounded-full transition-colors",
isActive ? colors.dot : "bg-border",
)}
/>
<span className="truncate">{topic.shortTitle}</span>
</Link>
);
})}
</div>
)}
</div>
);
})}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import katex from "katex";
interface MathDisplayProps {
math: string;
className?: string;
}
export function MathDisplay({ math, className }: MathDisplayProps) {
const html = katex.renderToString(math, {
displayMode: true,
throwOnError: false,
});
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
export function MathInline({ math, className }: MathDisplayProps) {
const html = katex.renderToString(math, {
displayMode: false,
throwOnError: false,
});
return (
<span
className={className}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { cn } from "@/lib/utils";
interface FractionInputProps {
numerator: string;
denominator: string;
onNumeratorChange: (value: string) => void;
onDenominatorChange: (value: string) => void;
disabled?: boolean;
className?: string;
}
export function FractionInput({
numerator,
denominator,
onNumeratorChange,
onDenominatorChange,
disabled = false,
className,
}: FractionInputProps) {
return (
<div className={cn("inline-flex flex-col items-center gap-0.5", className)}>
<input
type="text"
inputMode="numeric"
pattern="[0-9-]*"
value={numerator}
onChange={(e) => onNumeratorChange(e.target.value)}
disabled={disabled}
className="w-16 rounded-lg border border-border bg-surface px-2 py-1.5 text-center text-lg font-bold focus:border-unit-1 focus:outline-none"
placeholder="?"
aria-label="Numerator"
/>
<div className="h-0.5 w-16 bg-foreground" />
<input
type="text"
inputMode="numeric"
pattern="[0-9-]*"
value={denominator}
onChange={(e) => onDenominatorChange(e.target.value)}
disabled={disabled}
className="w-16 rounded-lg border border-border bg-surface px-2 py-1.5 text-center text-lg font-bold focus:border-unit-1 focus:outline-none"
placeholder="?"
aria-label="Denominator"
/>
</div>
);
}
interface DecimalInputProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
className?: string;
}
export function DecimalInput({ value, onChange, disabled, className }: DecimalInputProps) {
return (
<input
type="text"
inputMode="decimal"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={cn(
"w-28 rounded-lg border border-border bg-surface px-3 py-2 text-center text-lg font-bold focus:border-unit-2 focus:outline-none",
className,
)}
placeholder="?"
aria-label="Answer"
/>
);
}
interface RatioInputProps {
parts: string[];
onChange: (index: number, value: string) => void;
disabled?: boolean;
className?: string;
}
export function RatioInput({ parts, onChange, disabled, className }: RatioInputProps) {
return (
<div className={cn("inline-flex items-center gap-1", className)}>
{parts.map((p, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-lg font-bold text-muted">:</span>}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={p}
onChange={(e) => onChange(i, e.target.value)}
disabled={disabled}
className="w-14 rounded-lg border border-border bg-surface px-2 py-2 text-center text-lg font-bold focus:border-unit-4 focus:outline-none"
placeholder="?"
aria-label={`Part ${i + 1}`}
/>
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,308 @@
"use client";
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import type { MathProblem, Difficulty } from "@/lib/problems/types";
import { checkFractionAnswer, checkDecimalAnswer, checkRatioAnswer, checkIntegerAnswer, type AnswerResult } from "@/lib/math/validation";
import { FractionInput, DecimalInput, RatioInput } from "./fraction-input";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import katex from "katex";
interface PracticeSectionProps {
title: string;
generator: (difficulty: Difficulty) => MathProblem;
unitColor?: "unit-1" | "unit-2" | "unit-3" | "unit-4";
}
type UnitColor = NonNullable<PracticeSectionProps["unitColor"]>;
const activeDifficultyStyle: Record<UnitColor, string> = {
"unit-1": "bg-unit-1 text-white",
"unit-2": "bg-unit-2 text-white",
"unit-3": "bg-unit-3 text-white",
"unit-4": "bg-unit-4 text-white",
};
export function PracticeSection({ title, generator, unitColor = "unit-1" }: PracticeSectionProps) {
const [difficulty, setDifficulty] = useState<Difficulty>(1);
const [problem, setProblem] = useState<MathProblem>(() => generator(1));
const [userAnswer, setUserAnswer] = useState<Record<string, string>>({});
const [result, setResult] = useState<AnswerResult | null>(null);
const [showHint, setShowHint] = useState(false);
const [hintIndex, setHintIndex] = useState(0);
const [showSolution, setShowSolution] = useState(false);
const [score, setScore] = useState({ correct: 0, total: 0 });
const generateNew = useCallback((diff: Difficulty) => {
setProblem(generator(diff));
setUserAnswer({});
setResult(null);
setShowHint(false);
setHintIndex(0);
setShowSolution(false);
}, [generator]);
function checkAnswer() {
const answer = problem.answer;
let res: AnswerResult;
switch (answer.kind) {
case "fraction": {
const num = parseInt(userAnswer.numerator || "0");
const den = parseInt(userAnswer.denominator || "0");
if (isNaN(num) || isNaN(den)) {
res = { correct: false, message: "Enter valid numbers" };
} else {
res = checkFractionAnswer(num, den, answer.numerator, answer.denominator);
}
break;
}
case "decimal": {
const val = parseFloat(userAnswer.value || "");
if (isNaN(val)) {
res = { correct: false, message: "Enter a valid number" };
} else {
res = checkDecimalAnswer(val, answer.value);
}
break;
}
case "integer": {
const val = parseInt(userAnswer.value || "");
if (isNaN(val)) {
res = { correct: false, message: "Enter a valid number" };
} else {
res = checkIntegerAnswer(val, answer.value);
}
break;
}
case "ratio": {
const parts = (userAnswer.ratio || "").split(":").map((p) => parseInt(p.trim()));
if (parts.some(isNaN)) {
res = { correct: false, message: "Enter valid ratio parts" };
} else {
res = checkRatioAnswer(parts, answer.parts);
}
break;
}
case "standardForm": {
const coeff = parseFloat(userAnswer.coefficient || "");
const exp = parseInt(userAnswer.exponent || "");
if (isNaN(coeff) || isNaN(exp)) {
res = { correct: false, message: "Enter valid numbers" };
} else if (
Math.abs(coeff - answer.coefficient) < 0.01 &&
exp === answer.exponent
) {
res = { correct: true, simplified: true };
} else {
res = { correct: false, message: "That's not quite right. Try again!" };
}
break;
}
}
setResult(res);
if (res.correct) {
setScore((s) => ({ correct: s.correct + 1, total: s.total + 1 }));
} else {
setScore((s) => ({ ...s, total: s.total + 1 }));
}
}
function nextHint() {
if (hintIndex < problem.hints.length - 1) {
setHintIndex((i) => i + 1);
}
setShowHint(true);
}
const promptHtml = katex.renderToString(problem.prompt, { throwOnError: false, displayMode: true });
return (
<Card className="mt-8 border-2">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-extrabold">{title}</h3>
<div className="flex items-center gap-2 text-sm">
<span className="rounded-full bg-background px-2.5 py-1 font-bold text-muted">
{score.correct}/{score.total}
</span>
<div className="flex gap-1">
{([1, 2, 3] as Difficulty[]).map((d) => (
<button
key={d}
onClick={() => {
setDifficulty(d);
generateNew(d);
}}
className={`rounded-lg px-2.5 py-1 text-xs font-extrabold transition-colors ${
difficulty === d
? activeDifficultyStyle[unitColor]
: "bg-border/70 text-muted hover:bg-border hover:text-foreground"
}`}
>
L{d}
</button>
))}
</div>
</div>
</div>
{/* Problem */}
<div
className="mb-6 text-center text-xl"
dangerouslySetInnerHTML={{ __html: promptHtml }}
/>
{/* Input */}
<div className="mb-4 flex flex-wrap items-center justify-center gap-4">
{problem.answer.kind === "fraction" && (
<FractionInput
numerator={userAnswer.numerator || ""}
denominator={userAnswer.denominator || ""}
onNumeratorChange={(v) => setUserAnswer((a) => ({ ...a, numerator: v }))}
onDenominatorChange={(v) => setUserAnswer((a) => ({ ...a, denominator: v }))}
disabled={result?.correct === true}
/>
)}
{(problem.answer.kind === "decimal" || problem.answer.kind === "integer") && (
<DecimalInput
value={userAnswer.value || ""}
onChange={(v) => setUserAnswer((a) => ({ ...a, value: v }))}
disabled={result?.correct === true}
/>
)}
{problem.answer.kind === "ratio" && (() => {
const ratioParts = (problem.answer as { kind: "ratio"; parts: number[] }).parts;
return (
<RatioInput
parts={
userAnswer.ratioParts
? JSON.parse(userAnswer.ratioParts)
: ratioParts.map(() => "")
}
onChange={(i, v) => {
const current = userAnswer.ratioParts
? JSON.parse(userAnswer.ratioParts)
: ratioParts.map(() => "");
current[i] = v;
setUserAnswer((a) => ({
...a,
ratioParts: JSON.stringify(current),
ratio: current.join(":"),
}));
}}
disabled={result?.correct === true}
/>
);
})()}
{problem.answer.kind === "standardForm" && (
<div className="flex items-center gap-1">
<input
type="text"
inputMode="decimal"
value={userAnswer.coefficient || ""}
onChange={(e) => setUserAnswer((a) => ({ ...a, coefficient: e.target.value }))}
disabled={result?.correct === true}
className="w-16 rounded-lg border border-border bg-surface px-2 py-2 text-center font-bold focus:border-unit-2 focus:outline-none"
placeholder="?"
aria-label="Coefficient"
/>
<span className="text-lg font-bold">x 10</span>
<input
type="text"
inputMode="numeric"
value={userAnswer.exponent || ""}
onChange={(e) => setUserAnswer((a) => ({ ...a, exponent: e.target.value }))}
disabled={result?.correct === true}
className="w-12 -translate-y-2 rounded-lg border border-border bg-surface px-2 py-1 text-center text-sm font-bold focus:border-unit-2 focus:outline-none"
placeholder="?"
aria-label="Exponent"
/>
</div>
)}
</div>
{/* Actions */}
<div className="mb-4 flex flex-wrap justify-center gap-3">
{!result?.correct && (
<>
<Button variant={unitColor} size="sm" onClick={checkAnswer}>
Check Answer
</Button>
<Button variant="secondary" size="sm" onClick={nextHint}>
Hint
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowSolution(true)}>
Show Solution
</Button>
</>
)}
{result?.correct && (
<Button variant={unitColor} size="sm" onClick={() => generateNew(difficulty)}>
Next Problem
</Button>
)}
</div>
{/* Feedback */}
<AnimatePresence>
{result && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className={`rounded-xl px-4 py-3 text-center text-sm font-medium ${
result.correct
? result.simplified
? "bg-correct-light text-correct"
: "bg-hint-light text-hint"
: "bg-incorrect-light text-incorrect"
}`}
>
{result.correct
? result.simplified
? "Correct!"
: "Correct, but can you simplify further?"
: result.message}
</motion.div>
)}
</AnimatePresence>
{/* Hint */}
<AnimatePresence>
{showHint && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-3 rounded-xl bg-hint-light px-4 py-3 text-sm text-hint"
>
{problem.hints[hintIndex]}
</motion.div>
)}
</AnimatePresence>
{/* Solution */}
<AnimatePresence>
{showSolution && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
className="mt-3 space-y-2 rounded-xl bg-background p-4"
>
<p className="text-xs font-semibold uppercase text-muted">Solution</p>
{problem.steps.map((step, i) => (
<div key={i} className="flex gap-2 text-sm">
<span className="shrink-0 font-bold text-muted">{i + 1}.</span>
<span>{step.explanation}</span>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</Card>
);
}

31
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { cn } from "@/lib/utils";
import { type HTMLAttributes } from "react";
type BadgeVariant = "default" | "unit-1" | "unit-2" | "unit-3" | "unit-4";
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
}
const variantStyles: Record<BadgeVariant, string> = {
default: "border border-border/70 bg-background text-muted",
"unit-1": "border border-unit-1/20 bg-unit-1-light text-unit-1-dark",
"unit-2": "border border-unit-2/20 bg-unit-2-light text-unit-2-dark",
"unit-3": "border border-unit-3/20 bg-unit-3-light text-unit-3-dark",
"unit-4": "border border-unit-4/20 bg-unit-4-light text-unit-4-dark",
};
export function Badge({ variant = "default", className, children, ...props }: BadgeProps) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-3 py-1 text-xs font-extrabold tracking-wide",
variantStyles[variant],
className,
)}
{...props}
>
{children}
</span>
);
}

51
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { cn } from "@/lib/utils";
import { type ButtonHTMLAttributes } from "react";
type ButtonVariant = "primary" | "secondary" | "ghost" | "unit-1" | "unit-2" | "unit-3" | "unit-4";
type ButtonSize = "sm" | "md" | "lg";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
}
const variantStyles: Record<ButtonVariant, string> = {
primary: "bg-foreground text-background shadow-[var(--shadow-sm)] hover:bg-foreground/90 hover:shadow-[var(--shadow-lg)]",
secondary: "border-2 border-border/70 bg-surface text-foreground shadow-[var(--shadow-sm)] hover:bg-background hover:shadow-[var(--shadow-md)]",
ghost: "text-foreground hover:bg-border/30",
"unit-1": "bg-unit-1 text-white shadow-[var(--shadow-sm)] hover:bg-unit-1-dark hover:shadow-[var(--shadow-lg)]",
"unit-2": "bg-unit-2 text-white shadow-[var(--shadow-sm)] hover:bg-unit-2-dark hover:shadow-[var(--shadow-lg)]",
"unit-3": "bg-unit-3 text-white shadow-[var(--shadow-sm)] hover:bg-unit-3-dark hover:shadow-[var(--shadow-lg)]",
"unit-4": "bg-unit-4 text-white shadow-[var(--shadow-sm)] hover:bg-unit-4-dark hover:shadow-[var(--shadow-lg)]",
};
const sizeStyles: Record<ButtonSize, string> = {
sm: "px-4 py-2 text-sm",
md: "px-5.5 py-2.5 text-base",
lg: "px-8 py-3.5 text-lg",
};
export function Button({
variant = "primary",
size = "md",
className,
children,
...props
}: ButtonProps) {
return (
<button
className={cn(
"inline-flex items-center justify-center gap-2 rounded-2xl font-extrabold tracking-wide transition-all duration-200",
"active:translate-y-0.5",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-unit-1",
"disabled:pointer-events-none disabled:opacity-50",
variantStyles[variant],
sizeStyles[size],
className,
)}
{...props}
>
{children}
</button>
);
}

76
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,76 @@
import { cn } from "@/lib/utils";
import { type HTMLAttributes } from "react";
interface CardProps extends HTMLAttributes<HTMLDivElement> {
accent?: "unit-1" | "unit-2" | "unit-3" | "unit-4";
hover?: boolean;
}
const accentStyles = {
"unit-1": "border-l-4 border-l-unit-1",
"unit-2": "border-l-4 border-l-unit-2",
"unit-3": "border-l-4 border-l-unit-3",
"unit-4": "border-l-4 border-l-unit-4",
};
export function Card({
accent,
hover = false,
className,
children,
...props
}: CardProps) {
return (
<div
className={cn(
"rounded-3xl border-2 border-border/60 bg-surface p-6",
"shadow-[var(--shadow-sm)]",
accent && accentStyles[accent],
hover && "transition-all duration-200 hover:-translate-y-1 hover:shadow-[var(--shadow-lg)]",
className,
)}
{...props}
>
{children}
</div>
);
}
export function CardHeader({
className,
children,
...props
}: HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("mb-4", className)} {...props}>
{children}
</div>
);
}
export function CardTitle({
className,
children,
...props
}: HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn("text-xl font-bold tracking-tight", className)}
{...props}
>
{children}
</h3>
);
}
export function CardDescription({
className,
children,
...props
}: HTMLAttributes<HTMLParagraphElement>) {
return (
<p className={cn("text-sm text-muted", className)} {...props}>
{children}
</p>
);
}

16
docker-compose.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
cabrits:
build:
context: .
dockerfile: Dockerfile
image: registry.dwarrington.com/cabrits:${IMAGE_TAG}
container_name: cabrits
restart: always
ports:
- "5030:3000"
networks:
- caddy_network
networks:
caddy_network:
external: true

198
lib/curriculum.ts Normal file
View File

@@ -0,0 +1,198 @@
export interface Topic {
slug: string;
title: string;
shortTitle: string;
week: number;
description: string;
}
export interface Unit {
number: 1 | 2 | 3 | 4;
slug: string;
title: string;
description: string;
weeks: string;
color: "unit-1" | "unit-2" | "unit-3" | "unit-4";
topics: Topic[];
}
export const curriculum: Unit[] = [
{
number: 1,
slug: "unit-1-fractions",
title: "Fractions",
description: "Add, subtract, multiply, divide fractions and apply to quantities",
weeks: "Weeks 1-3",
color: "unit-1",
topics: [
{
slug: "add-subtract",
title: "Add and Subtract Fractions",
shortTitle: "Add & Subtract",
week: 1,
description: "Common and uncommon denominators, Butterfly Method",
},
{
slug: "multiply",
title: "Multiply Fractions",
shortTitle: "Multiply",
week: 1,
description: "Multiply numerators and denominators, simplify",
},
{
slug: "divide",
title: "Divide Fractions",
shortTitle: "Divide",
week: 1,
description: "Invert and multiply (reciprocal method)",
},
{
slug: "mixed-operations",
title: "Mixed Operations (BODMAS)",
shortTitle: "BODMAS",
week: 2,
description: "Order of operations with fractions",
},
{
slug: "fraction-of-quantity",
title: "Fraction of a Quantity",
shortTitle: "Of a Quantity",
week: 3,
description: "Calculate a fraction of a given amount",
},
{
slug: "whole-from-fractions",
title: "Calculate the Whole from Fractions",
shortTitle: "Find the Whole",
week: 3,
description: "Find the whole when given a part and its fraction",
},
],
},
{
number: 2,
slug: "unit-2-decimals",
title: "Decimals",
description: "Compare, order, round decimals and express in standard form",
weeks: "Weeks 4-5",
color: "unit-2",
topics: [
{
slug: "compare-order",
title: "Compare and Order Decimals",
shortTitle: "Compare & Order",
week: 4,
description: "Place value, ascending, descending, greater/less than",
},
{
slug: "approximate",
title: "Approximate Decimals",
shortTitle: "Approximation",
week: 4,
description: "Round to whole numbers, decimal places, significant figures",
},
{
slug: "standard-form",
title: "Standard Form (Scientific Notation)",
shortTitle: "Standard Form",
week: 5,
description: "Express numbers as a × 10^n",
},
],
},
{
number: 3,
slug: "unit-3-decimal-operations",
title: "Decimal Operations",
description: "Convert, add, subtract, multiply and divide decimals",
weeks: "Weeks 6-7",
color: "unit-3",
topics: [
{
slug: "convert",
title: "Convert Decimals and Fractions",
shortTitle: "Convert",
week: 6,
description: "Decimals to fractions and fractions to decimals",
},
{
slug: "add-subtract",
title: "Add and Subtract Decimals",
shortTitle: "Add & Subtract",
week: 6,
description: "Align decimal points, insert zeros as placeholders",
},
{
slug: "multiply-divide",
title: "Multiply and Divide Decimals",
shortTitle: "Multiply & Divide",
week: 7,
description: "By powers of 10, whole numbers, and decimals",
},
],
},
{
number: 4,
slug: "unit-4-ratio-proportion",
title: "Ratio & Proportion",
description: "Define, simplify, and apply ratios to solve problems",
weeks: "Weeks 8-9",
color: "unit-4",
topics: [
{
slug: "define-ratio",
title: "Define a Ratio",
shortTitle: "Define Ratio",
week: 8,
description: "Relationship between two or more quantities",
},
{
slug: "fractions-and-ratios",
title: "Fractions and Ratios",
shortTitle: "Fractions & Ratios",
week: 8,
description: "Understand the relationship between fractions and ratios",
},
{
slug: "simplify-ratios",
title: "Simplify Ratios",
shortTitle: "Simplify",
week: 8,
description: "Write ratios in their simplest form",
},
{
slug: "divide-in-ratio",
title: "Divide a Quantity in a Given Ratio",
shortTitle: "Divide in Ratio",
week: 9,
description: "Share amounts using ratios",
},
{
slug: "word-problems",
title: "Proportional Parts Word Problems",
shortTitle: "Word Problems",
week: 9,
description: "Solve real-world problems with ratios",
},
],
},
];
export function getUnit(slug: string): Unit | undefined {
return curriculum.find((u) => u.slug === slug);
}
export function getTopic(unitSlug: string, topicSlug: string): Topic | undefined {
const unit = getUnit(unitSlug);
return unit?.topics.find((t) => t.slug === topicSlug);
}
export function getUnitColor(unitNumber: 1 | 2 | 3 | 4): string {
const colors = {
1: "unit-1",
2: "unit-2",
3: "unit-3",
4: "unit-4",
};
return colors[unitNumber];
}

65
lib/math/decimals.ts Normal file
View File

@@ -0,0 +1,65 @@
export function roundToWholeNumber(value: number): number {
return Math.round(value);
}
export function roundToDecimalPlaces(value: number, places: number): number {
const factor = Math.pow(10, places);
return Math.round(value * factor) / factor;
}
export function roundToSignificantFigures(value: number, sigFigs: number): number {
if (value === 0) return 0;
const magnitude = Math.floor(Math.log10(Math.abs(value)));
const factor = Math.pow(10, sigFigs - 1 - magnitude);
return Math.round(value * factor) / factor;
}
export function toStandardForm(value: number): { coefficient: number; exponent: number } {
if (value === 0) return { coefficient: 0, exponent: 0 };
const exponent = Math.floor(Math.log10(Math.abs(value)));
const coefficient = value / Math.pow(10, exponent);
return {
coefficient: roundToDecimalPlaces(coefficient, 10),
exponent,
};
}
export function fromStandardForm(coefficient: number, exponent: number): number {
return coefficient * Math.pow(10, exponent);
}
export function compareDecimals(a: number, b: number): -1 | 0 | 1 {
if (a > b) return 1;
if (a < b) return -1;
return 0;
}
export function getPlaceValue(value: number): { digit: number; place: string }[] {
const places = [
"thousands", "hundreds", "tens", "units", "tenths", "hundredths", "thousandths",
];
const str = Math.abs(value).toFixed(3);
const [intPart, decPart] = str.split(".");
const paddedInt = intPart.padStart(4, "0");
const result: { digit: number; place: string }[] = [];
for (let i = 0; i < 4; i++) {
result.push({ digit: parseInt(paddedInt[i]), place: places[i] });
}
for (let i = 0; i < 3; i++) {
result.push({ digit: parseInt(decPart[i]), place: places[4 + i] });
}
return result;
}
export function multiplyByPowerOf10(value: number, power: number): number {
return roundToDecimalPlaces(value * Math.pow(10, power), 10);
}
export function divideByPowerOf10(value: number, power: number): number {
return roundToDecimalPlaces(value / Math.pow(10, power), 10);
}
export function standardFormToKatex(coefficient: number, exponent: number): string {
return `${coefficient} \\times 10^{${exponent}}`;
}

107
lib/math/fractions.ts Normal file
View File

@@ -0,0 +1,107 @@
export function gcd(a: number, b: number): number {
a = Math.abs(a);
b = Math.abs(b);
while (b) {
[a, b] = [b, a % b];
}
return a;
}
export function lcm(a: number, b: number): number {
return Math.abs(a * b) / gcd(a, b);
}
export function simplify(num: number, den: number): [number, number] {
if (den === 0) return [num, den];
const g = gcd(Math.abs(num), Math.abs(den));
const sign = den < 0 ? -1 : 1;
return [(num / g) * sign, (den / g) * sign];
}
export function add(
n1: number, d1: number,
n2: number, d2: number,
): [number, number] {
const num = n1 * d2 + n2 * d1;
const den = d1 * d2;
return simplify(num, den);
}
export function subtract(
n1: number, d1: number,
n2: number, d2: number,
): [number, number] {
const num = n1 * d2 - n2 * d1;
const den = d1 * d2;
return simplify(num, den);
}
export function multiply(
n1: number, d1: number,
n2: number, d2: number,
): [number, number] {
return simplify(n1 * n2, d1 * d2);
}
export function divide(
n1: number, d1: number,
n2: number, d2: number,
): [number, number] {
return simplify(n1 * d2, d1 * n2);
}
export function toDecimal(num: number, den: number): number {
return num / den;
}
export function fromDecimal(decimal: number, precision: number = 6): [number, number] {
const str = decimal.toFixed(precision);
const parts = str.split(".");
if (!parts[1]) return [parseInt(parts[0]), 1];
const den = Math.pow(10, parts[1].length);
const num = Math.round(decimal * den);
return simplify(num, den);
}
export function compare(
n1: number, d1: number,
n2: number, d2: number,
): -1 | 0 | 1 {
const diff = n1 * d2 - n2 * d1;
if (diff > 0) return 1;
if (diff < 0) return -1;
return 0;
}
export function fractionOfQuantity(num: number, den: number, quantity: number): number {
return (num / den) * quantity;
}
export function wholeFromFraction(num: number, den: number, part: number): number {
return (part * den) / num;
}
export function isProper(num: number, den: number): boolean {
return Math.abs(num) < Math.abs(den);
}
export function toMixed(num: number, den: number): [number, number, number] {
const whole = Math.floor(Math.abs(num) / Math.abs(den));
const remainder = Math.abs(num) % Math.abs(den);
const sign = (num < 0) !== (den < 0) ? -1 : 1;
return [whole * sign, remainder, Math.abs(den)];
}
export function fromMixed(whole: number, num: number, den: number): [number, number] {
const sign = whole < 0 ? -1 : 1;
return [sign * (Math.abs(whole) * den + num), den];
}
export function isSimplified(num: number, den: number): boolean {
return gcd(Math.abs(num), Math.abs(den)) === 1;
}
export function toKatex(num: number, den: number): string {
if (den === 1) return `${num}`;
return `\\frac{${num}}{${den}}`;
}

35
lib/math/ratios.ts Normal file
View File

@@ -0,0 +1,35 @@
import { gcd } from "./fractions";
export function simplifyRatio(parts: number[]): number[] {
if (parts.length === 0) return [];
let g = parts[0];
for (let i = 1; i < parts.length; i++) {
g = gcd(g, parts[i]);
}
return parts.map((p) => p / g);
}
export function divideInRatio(total: number, parts: number[]): number[] {
const sum = parts.reduce((a, b) => a + b, 0);
const onePart = total / sum;
return parts.map((p) => p * onePart);
}
export function ratioToFraction(a: number, b: number): [number, number] {
return [a, a + b];
}
export function areEquivalent(ratio1: number[], ratio2: number[]): boolean {
if (ratio1.length !== ratio2.length) return false;
const s1 = simplifyRatio(ratio1);
const s2 = simplifyRatio(ratio2);
return s1.every((v, i) => v === s2[i]);
}
export function totalParts(parts: number[]): number {
return parts.reduce((a, b) => a + b, 0);
}
export function ratioToKatex(parts: number[]): string {
return parts.join(":");
}

94
lib/math/validation.ts Normal file
View File

@@ -0,0 +1,94 @@
import { gcd } from "./fractions";
import { simplifyRatio } from "./ratios";
export type AnswerResult =
| { correct: true; simplified: boolean }
| { correct: false; message: string };
export function checkFractionAnswer(
userNum: number,
userDen: number,
expectedNum: number,
expectedDen: number,
requireSimplified: boolean = true,
): AnswerResult {
if (userDen === 0) {
return { correct: false, message: "Denominator cannot be zero" };
}
// Check mathematical equivalence
const isEquivalent = userNum * expectedDen === expectedNum * userDen;
if (!isEquivalent) {
return { correct: false, message: "That's not quite right. Try again!" };
}
const isSimplified = gcd(Math.abs(userNum), Math.abs(userDen)) === 1;
if (requireSimplified && !isSimplified) {
return { correct: true, simplified: false };
}
return { correct: true, simplified: true };
}
export function checkDecimalAnswer(
userValue: number,
expectedValue: number,
tolerance: number = 0.001,
): AnswerResult {
if (Math.abs(userValue - expectedValue) <= tolerance) {
return { correct: true, simplified: true };
}
return { correct: false, message: "That's not quite right. Try again!" };
}
export function checkRatioAnswer(
userParts: number[],
expectedParts: number[],
requireSimplified: boolean = true,
): AnswerResult {
if (userParts.length !== expectedParts.length) {
return { correct: false, message: "Check the number of parts in your ratio" };
}
const userSimplified = simplifyRatio(userParts);
const expectedSimplified = simplifyRatio(expectedParts);
const isEquivalent = userSimplified.every((v, i) => v === expectedSimplified[i]);
if (!isEquivalent) {
return { correct: false, message: "That's not quite right. Try again!" };
}
if (requireSimplified) {
const isAlreadySimplified = userParts.every((v, i) => v === userSimplified[i]);
return { correct: true, simplified: isAlreadySimplified };
}
return { correct: true, simplified: true };
}
export function checkIntegerAnswer(
userValue: number,
expectedValue: number,
): AnswerResult {
if (userValue === expectedValue) {
return { correct: true, simplified: true };
}
return { correct: false, message: "That's not quite right. Try again!" };
}
export function checkOrderingAnswer(
userOrder: number[],
expectedOrder: number[],
): AnswerResult {
if (userOrder.length !== expectedOrder.length) {
return { correct: false, message: "Make sure you've ordered all the numbers" };
}
const isCorrect = userOrder.every((v, i) => v === expectedOrder[i]);
if (isCorrect) {
return { correct: true, simplified: true };
}
return { correct: false, message: "Check your ordering. Try again!" };
}

View File

@@ -0,0 +1,287 @@
import type { MathProblem, Difficulty } from "../types";
import { randomInt, randomChoice, shuffle } from "@/lib/utils";
import { roundToDecimalPlaces, roundToSignificantFigures, toStandardForm, standardFormToKatex } from "@/lib/math/decimals";
let counter = 0;
function nextId() {
return `dp-${++counter}-${Date.now()}`;
}
export function generateDecimalCompareOrder(difficulty: Difficulty): MathProblem {
const count = difficulty === 1 ? 3 : difficulty === 2 ? 4 : 5;
const values: number[] = [];
for (let i = 0; i < count; i++) {
const intPart = randomInt(0, difficulty === 1 ? 2 : 10);
const decPlaces = randomInt(1, difficulty === 1 ? 2 : 3);
const decPart = randomInt(1, Math.pow(10, decPlaces) - 1);
const val = intPart + decPart / Math.pow(10, decPlaces);
values.push(Math.round(val * 1000) / 1000);
}
const sorted = [...values].sort((a, b) => a - b);
const shuffled = shuffle(values);
return {
id: nextId(),
prompt: `\\text{Order from smallest to largest: } ${shuffled.join(", ")}`,
answer: { kind: "decimal", value: 0 },
hints: [
"Compare the whole number part first",
"If the whole parts are equal, compare tenths, then hundredths",
"Writing them with the same number of decimal places can help",
],
steps: sorted.map((v, i) => ({
explanation: `Position ${i + 1}: ${v}`,
})),
};
}
export function generateDecimalRounding(difficulty: Difficulty): MathProblem {
const value = randomInt(100, 99999) / (difficulty === 1 ? 100 : 1000);
const roundType = randomChoice(
difficulty === 1
? ["whole", "1dp"] as const
: difficulty === 2
? ["1dp", "2dp", "whole"] as const
: ["1sf", "2sf", "3sf"] as const,
);
let answer: number;
let instruction: string;
switch (roundType) {
case "whole":
answer = Math.round(value);
instruction = "the nearest whole number";
break;
case "1dp":
answer = roundToDecimalPlaces(value, 1);
instruction = "1 decimal place";
break;
case "2dp":
answer = roundToDecimalPlaces(value, 2);
instruction = "2 decimal places";
break;
case "1sf":
answer = roundToSignificantFigures(value, 1);
instruction = "1 significant figure";
break;
case "2sf":
answer = roundToSignificantFigures(value, 2);
instruction = "2 significant figures";
break;
case "3sf":
answer = roundToSignificantFigures(value, 3);
instruction = "3 significant figures";
break;
}
return {
id: nextId(),
prompt: `\\text{Round } ${value} \\text{ to ${instruction}}`,
answer: { kind: "decimal", value: answer },
hints: [
"Look at the digit to the right of where you're rounding",
"If it's 5 or more, round up. If it's less than 5, round down",
],
steps: [
{ explanation: `Rounding ${value} to ${instruction}` },
{ explanation: `Answer: ${answer}` },
],
};
}
export function generateStandardForm(difficulty: Difficulty): MathProblem {
let value: number;
if (difficulty === 1) {
value = randomChoice([7438, 1578, 62, 93000, 5200, 340]);
} else if (difficulty === 2) {
value = randomChoice([0.086, 0.748, 0.0055, 0.34, 0.007]);
} else {
value = randomChoice([12436.3, 0.00092, 504000, 0.0000067, 83100]);
}
const sf = toStandardForm(value);
return {
id: nextId(),
prompt: `\\text{Express } ${value} \\text{ in standard form}`,
answer: { kind: "standardForm", coefficient: sf.coefficient, exponent: sf.exponent },
hints: [
"Move the decimal point so there's one non-zero digit before it",
"Count how many places you moved the decimal",
value >= 1
? "Number is ≥ 1, so the power is positive"
: "Number is < 1, so the power is negative",
],
steps: [
{ explanation: `Move the decimal point to get a number between 1 and 10` },
{ explanation: `Coefficient: ${sf.coefficient}` },
{ explanation: `The decimal moved ${Math.abs(sf.exponent)} places, power = ${sf.exponent}` },
{ explanation: "Answer", math: standardFormToKatex(sf.coefficient, sf.exponent) },
],
};
}
export function generateDecimalAddSubtract(difficulty: Difficulty): MathProblem {
const operation = randomChoice(["add", "subtract"] as const);
const op = operation === "add" ? "+" : "-";
const places = difficulty === 1 ? 1 : difficulty === 2 ? 2 : 3;
const a = randomInt(100, 9999) / Math.pow(10, places);
const b = randomInt(100, 9999) / Math.pow(10, places);
const [first, second] = operation === "subtract" ? [Math.max(a, b), Math.min(a, b)] : [a, b];
const answer = roundToDecimalPlaces(
operation === "add" ? first + second : first - second,
places,
);
return {
id: nextId(),
prompt: `${first} ${op} ${second}`,
answer: { kind: "decimal", value: answer },
hints: [
"Line up the decimal points",
"Add zeros to fill empty places",
operation === "add" ? "Add column by column from right to left" : "Subtract column by column, borrow if needed",
],
steps: [
{ explanation: `Line up: ${first} ${op} ${second}` },
{ explanation: `= ${answer}` },
],
};
}
export function generateDecimalMultiplyDivide(difficulty: Difficulty): MathProblem {
const operation = randomChoice(["multiply", "divide"] as const);
if (difficulty === 1) {
// By powers of 10
const power = randomChoice([10, 100, 1000]);
const base = randomInt(1, 999) / 100;
if (operation === "multiply") {
const answer = roundToDecimalPlaces(base * power, 10);
return {
id: nextId(),
prompt: `${base} \\times ${power}`,
answer: { kind: "decimal", value: answer },
hints: [
`When multiplying by ${power}, move decimal ${Math.log10(power)} place(s) right`,
],
steps: [
{ explanation: `Move decimal point ${Math.log10(power)} place(s) to the right` },
{ explanation: `= ${answer}` },
],
};
} else {
const answer = roundToDecimalPlaces(base / power, 10);
return {
id: nextId(),
prompt: `${base} \\div ${power}`,
answer: { kind: "decimal", value: answer },
hints: [
`When dividing by ${power}, move decimal ${Math.log10(power)} place(s) left`,
],
steps: [
{ explanation: `Move decimal point ${Math.log10(power)} place(s) to the left` },
{ explanation: `= ${answer}` },
],
};
}
}
// By whole numbers or decimals
const a = randomInt(10, 999) / 10;
const b = difficulty === 2 ? randomInt(2, 12) : randomInt(10, 99) / 10;
if (operation === "multiply") {
const answer = roundToDecimalPlaces(a * b, 4);
return {
id: nextId(),
prompt: `${a} \\times ${b}`,
answer: { kind: "decimal", value: answer },
hints: [
"Multiply as if there are no decimal points",
"Count total decimal places in both numbers",
"Place the decimal point in your answer",
],
steps: [
{ explanation: `${a} × ${b}` },
{ explanation: `= ${answer}` },
],
};
} else {
const answer = roundToDecimalPlaces(a / b, 4);
return {
id: nextId(),
prompt: `${a} \\div ${b}`,
answer: { kind: "decimal", value: answer },
hints: [
"Place decimal point in answer directly above the one in the dividend",
"Divide as normal",
],
steps: [
{ explanation: `${a} ÷ ${b}` },
{ explanation: `= ${answer}` },
],
};
}
}
export function generateDecimalConversion(difficulty: Difficulty): MathProblem {
const direction = randomChoice(["toFraction", "toDecimal"] as const);
if (direction === "toFraction") {
const decimal = randomChoice(
difficulty === 1
? [0.5, 0.25, 0.75, 0.1, 0.2, 0.4]
: [0.125, 0.375, 0.625, 0.875, 0.15, 0.35, 0.45],
);
const den = decimal === 0.5 ? 2 : decimal === 0.25 || decimal === 0.75 ? 4 : [0.125, 0.375, 0.625, 0.875].includes(decimal) ? 8 : 100;
const num = Math.round(decimal * den);
const g = gcdSimple(num, den);
return {
id: nextId(),
prompt: `\\text{Convert } ${decimal} \\text{ to a fraction}`,
answer: { kind: "fraction", numerator: num / g, denominator: den / g },
hints: [
`Write as ${num}/${den}`,
"Simplify by dividing numerator and denominator by their GCD",
],
steps: [
{ explanation: `${decimal} = ${num}/${den}` },
{ explanation: `Simplified: ${num / g}/${den / g}` },
],
};
} else {
const den = randomChoice(difficulty === 1 ? [2, 4, 5, 10] : [3, 8, 20, 25, 40, 50]);
const num = randomInt(1, den - 1);
const answer = roundToDecimalPlaces(num / den, 6);
return {
id: nextId(),
prompt: `\\text{Convert } \\frac{${num}}{${den}} \\text{ to a decimal}`,
answer: { kind: "decimal", value: answer },
hints: [
`Divide ${num} by ${den}`,
"Or find an equivalent fraction with denominator 10, 100, etc.",
],
steps: [
{ explanation: `${num} ÷ ${den} = ${answer}` },
],
};
}
}
function gcdSimple(a: number, b: number): number {
a = Math.abs(a);
b = Math.abs(b);
while (b) {
[a, b] = [b, a % b];
}
return a;
}

View File

@@ -0,0 +1,163 @@
import type { MathProblem, Difficulty } from "../types";
import { randomInt, randomChoice } from "@/lib/utils";
import * as frac from "@/lib/math/fractions";
let counter = 0;
function nextId() {
return `fp-${++counter}-${Date.now()}`;
}
export function generateFractionAddSubtract(difficulty: Difficulty): MathProblem {
const operation = randomChoice(["add", "subtract"] as const);
const op = operation === "add" ? "+" : "-";
let n1: number, d1: number, n2: number, d2: number;
if (difficulty === 1) {
// Common denominators
d1 = randomChoice([2, 3, 4, 5, 6, 8, 10, 12]);
d2 = d1;
n1 = randomInt(1, d1 - 1);
n2 = operation === "subtract" ? randomInt(1, n1) : randomInt(1, d1 - n1);
} else if (difficulty === 2) {
// One denominator is a multiple of the other
d1 = randomChoice([2, 3, 4, 5, 6]);
const multiplier = randomChoice([2, 3]);
d2 = d1 * multiplier;
n1 = randomInt(1, d1 - 1);
n2 = randomInt(1, d2 - 1);
} else {
// Unlike denominators
d1 = randomChoice([3, 4, 5, 7, 8, 9]);
d2 = randomChoice([3, 4, 5, 7, 8, 9].filter((d) => d !== d1));
n1 = randomInt(1, d1 - 1);
n2 = randomInt(1, d2 - 1);
}
const [ansNum, ansDen] = operation === "add"
? frac.add(n1, d1, n2, d2)
: frac.subtract(n1, d1, n2, d2);
const prompt = `\\frac{${n1}}{${d1}} ${op} \\frac{${n2}}{${d2}}`;
const commonDen = frac.lcm(d1, d2);
const steps = d1 === d2
? [
{ explanation: "Denominators are the same, so just " + (operation === "add" ? "add" : "subtract") + " the numerators" },
{ explanation: `${n1} ${op} ${n2} = ${operation === "add" ? n1 + n2 : n1 - n2}`, math: `\\frac{${operation === "add" ? n1 + n2 : n1 - n2}}{${d1}}` },
]
: [
{ explanation: `Find the common denominator: LCM(${d1}, ${d2}) = ${commonDen}` },
{ explanation: `Convert: ${n1}×${commonDen / d1}/${d1}×${commonDen / d1} and ${n2}×${commonDen / d2}/${d2}×${commonDen / d2}` },
{ explanation: `${op === "+" ? "Add" : "Subtract"} numerators`, math: `\\frac{${ansNum}}{${ansDen}}` },
];
return {
id: nextId(),
prompt,
answer: { kind: "fraction", numerator: ansNum, denominator: ansDen },
hints: [
d1 === d2 ? "The denominators are already the same!" : "Find a common denominator first",
d1 !== d2 ? `Try using the Butterfly Method: cross multiply` : `Just ${operation} the numerators`,
],
steps,
};
}
export function generateFractionMultiply(difficulty: Difficulty): MathProblem {
const denChoices = difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [2, 3, 4, 5, 6, 7, 8] : [3, 5, 7, 9, 11, 12, 15];
const d1 = randomChoice(denChoices);
const d2 = randomChoice(denChoices);
const n1 = randomInt(1, d1 - 1);
const n2 = randomInt(1, d2 - 1);
const [ansNum, ansDen] = frac.multiply(n1, d1, n2, d2);
return {
id: nextId(),
prompt: `\\frac{${n1}}{${d1}} \\times \\frac{${n2}}{${d2}}`,
answer: { kind: "fraction", numerator: ansNum, denominator: ansDen },
hints: [
"Multiply numerator × numerator",
"Multiply denominator × denominator",
"Simplify if possible",
],
steps: [
{ explanation: `Multiply numerators: ${n1} × ${n2} = ${n1 * n2}` },
{ explanation: `Multiply denominators: ${d1} × ${d2} = ${d1 * d2}` },
{ explanation: "Simplify", math: `\\frac{${ansNum}}{${ansDen}}` },
],
};
}
export function generateFractionDivide(difficulty: Difficulty): MathProblem {
const denChoices = difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [2, 3, 4, 5, 6, 7, 8] : [3, 5, 7, 9, 11, 14, 15];
const d1 = randomChoice(denChoices);
const d2 = randomChoice(denChoices);
const n1 = randomInt(1, d1 - 1);
const n2 = randomInt(1, d2 - 1);
const [ansNum, ansDen] = frac.divide(n1, d1, n2, d2);
return {
id: nextId(),
prompt: `\\frac{${n1}}{${d1}} \\div \\frac{${n2}}{${d2}}`,
answer: { kind: "fraction", numerator: ansNum, denominator: ansDen },
hints: [
"Keep the first fraction the same",
"Flip the second fraction (reciprocal)",
"Then multiply",
],
steps: [
{ explanation: `Keep ${n1}/${d1}, flip ${n2}/${d2} to ${d2}/${n2}` },
{ explanation: `Multiply: ${n1}×${d2} / ${d1}×${n2}` },
{ explanation: "Simplify", math: `\\frac{${ansNum}}{${ansDen}}` },
],
};
}
export function generateFractionOfQuantity(difficulty: Difficulty): MathProblem {
const denChoices = difficulty === 1 ? [2, 4, 5, 10] : difficulty === 2 ? [3, 4, 5, 6, 8] : [7, 8, 9, 12, 15];
const den = randomChoice(denChoices);
const num = randomInt(1, den - 1);
const multiplier = randomChoice(difficulty === 1 ? [2, 4, 5, 10] : [3, 6, 7, 8, 9, 12]);
const quantity = den * multiplier;
const answer = num * multiplier;
return {
id: nextId(),
prompt: `\\frac{${num}}{${den}} \\text{ of } ${quantity}`,
answer: { kind: "integer", value: answer },
hints: [
`Divide ${quantity} by ${den} first`,
`Then multiply by ${num}`,
],
steps: [
{ explanation: `Divide the quantity by the denominator: ${quantity} ÷ ${den} = ${quantity / den}` },
{ explanation: `Multiply by the numerator: ${quantity / den} × ${num} = ${answer}` },
],
};
}
export function generateWholeFromFraction(difficulty: Difficulty): MathProblem {
const denChoices = difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [3, 5, 6, 8] : [7, 8, 9, 12];
const den = randomChoice(denChoices);
const num = randomInt(1, den - 1);
const whole = den * randomChoice(difficulty === 1 ? [2, 3, 4, 5] : [3, 5, 6, 7, 8, 10]);
const part = (num / den) * whole;
return {
id: nextId(),
prompt: `\\frac{${num}}{${den}} \\text{ of a number is } ${part}\\text{. Find the number.}`,
answer: { kind: "integer", value: whole },
hints: [
`If ${num}/${den} of the number is ${part}...`,
`Find 1/${den} first by dividing ${part} by ${num}`,
`Then multiply by ${den} to get the whole`,
],
steps: [
{ explanation: `1/${den} of the number = ${part} ÷ ${num} = ${part / num}` },
{ explanation: `The whole number = ${part / num} × ${den} = ${whole}` },
],
};
}

View File

@@ -0,0 +1,110 @@
import type { MathProblem, Difficulty } from "../types";
import { randomInt, randomChoice } from "@/lib/utils";
import { simplifyRatio, divideInRatio } from "@/lib/math/ratios";
let counter = 0;
function nextId() {
return `rp-${++counter}-${Date.now()}`;
}
export function generateSimplifyRatio(difficulty: Difficulty): MathProblem {
const gcd = randomChoice(difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [3, 4, 5, 6, 7] : [4, 6, 8, 9, 12]);
const a = randomInt(1, difficulty === 1 ? 5 : 10) * gcd;
const b = randomInt(1, difficulty === 1 ? 5 : 10) * gcd;
const simplified = simplifyRatio([a, b]);
return {
id: nextId(),
prompt: `\\text{Simplify the ratio } ${a}:${b}`,
answer: { kind: "ratio", parts: simplified },
hints: [
`Find the GCD of ${a} and ${b}`,
`The GCD is ${gcd}`,
`Divide both parts by ${gcd}`,
],
steps: [
{ explanation: `GCD of ${a} and ${b} is ${gcd}` },
{ explanation: `${a} ÷ ${gcd} = ${simplified[0]}, ${b} ÷ ${gcd} = ${simplified[1]}` },
{ explanation: `Simplified: ${simplified.join(":")}` },
],
};
}
export function generateDivideInRatio(difficulty: Difficulty): MathProblem {
const numParts = difficulty === 3 ? 3 : 2;
const parts: number[] = [];
for (let i = 0; i < numParts; i++) {
parts.push(randomInt(1, difficulty === 1 ? 5 : 9));
}
const sum = parts.reduce((a, b) => a + b, 0);
const multiplier = randomChoice(difficulty === 1 ? [2, 3, 4, 5] : [3, 4, 5, 6, 7, 8]);
const total = sum * multiplier;
const values = divideInRatio(total, parts);
const intValues = values.map(Math.round);
const names = ["first share", "second share", "third share"];
return {
id: nextId(),
prompt: `\\text{Share } ${total} \\text{ in the ratio } ${parts.join(":")}`,
answer: { kind: "ratio", parts: intValues },
hints: [
`Total parts: ${parts.join(" + ")} = ${sum}`,
`One part = ${total} ÷ ${sum} = ${multiplier}`,
`Multiply each ratio number by ${multiplier}`,
],
steps: [
{ explanation: `Total parts = ${parts.join(" + ")} = ${sum}` },
{ explanation: `One part = ${total} ÷ ${sum} = ${multiplier}` },
...parts.map((p, i) => ({
explanation: `${names[i]}: ${p} × ${multiplier} = ${intValues[i]}`,
})),
],
};
}
export function generateRatioWordProblem(difficulty: Difficulty): MathProblem {
const a = randomInt(2, 7);
const b = randomInt(2, 7);
const diff = Math.abs(a - b);
if (diff === 0) {
return generateDivideInRatio(difficulty);
}
const contexts = [
{ item: "sweets", nameA: "Josh", nameB: "Nathan" },
{ item: "marbles", nameA: "Amy", nameB: "Ben" },
{ item: "stickers", nameA: "Karen", nameB: "Natasha" },
];
const ctx = randomChoice(contexts);
const onePart = randomChoice(difficulty === 1 ? [5, 10, 12] : [6, 8, 9, 15, 20]);
const extraAmount = diff * onePart;
const totalParts = a + b;
const total = totalParts * onePart;
const bigger = a > b ? ctx.nameA : ctx.nameB;
const biggerRatio = Math.max(a, b);
const smallerRatio = Math.min(a, b);
return {
id: nextId(),
prompt: `\\text{${ctx.item.charAt(0).toUpperCase() + ctx.item.slice(1)} were shared between ${ctx.nameA} and ${ctx.nameB} in the ratio ${a}:${b}. If ${bigger} received ${extraAmount} more ${ctx.item}, find the total shared.}`,
answer: { kind: "integer", value: total },
hints: [
`The difference in ratio parts is ${biggerRatio} - ${smallerRatio} = ${diff}`,
`${diff} parts = ${extraAmount}, so 1 part = ${onePart}`,
`Total parts = ${a} + ${b} = ${totalParts}`,
],
steps: [
{ explanation: `Difference in parts: ${biggerRatio} - ${smallerRatio} = ${diff}` },
{ explanation: `${diff} parts = ${extraAmount}, so 1 part = ${extraAmount} ÷ ${diff} = ${onePart}` },
{ explanation: `Total parts: ${a} + ${b} = ${totalParts}` },
{ explanation: `Total ${ctx.item}: ${totalParts} × ${onePart} = ${total}` },
],
};
}

21
lib/problems/types.ts Normal file
View File

@@ -0,0 +1,21 @@
export type Difficulty = 1 | 2 | 3;
export type MathAnswer =
| { kind: "fraction"; numerator: number; denominator: number }
| { kind: "decimal"; value: number }
| { kind: "integer"; value: number }
| { kind: "ratio"; parts: number[] }
| { kind: "standardForm"; coefficient: number; exponent: number };
export interface SolutionStep {
explanation: string;
math?: string;
}
export interface MathProblem {
id: string;
prompt: string;
answer: MathAnswer;
hints: string[];
steps: SolutionStep[];
}

23
lib/utils.ts Normal file
View File

@@ -0,0 +1,23 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
export function randomChoice<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
export function shuffle<T>(arr: T[]): T[] {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
}; };
export default nextConfig; export default nextConfig;

92
package-lock.json generated
View File

@@ -8,12 +8,17 @@
"name": "cabrits", "name": "cabrits",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"clsx": "^2.1.1",
"framer-motion": "^12.34.3",
"katex": "^0.16.33",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/katex": "^0.16.8",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -1445,6 +1450,12 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true "dev": true
}, },
"node_modules/@types/katex": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
"integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
"dev": true
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.35", "version": "20.19.35",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz",
@@ -2436,6 +2447,14 @@
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2454,6 +2473,14 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true "dev": true
}, },
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"engines": {
"node": ">= 12"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3388,6 +3415,32 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/framer-motion": {
"version": "12.34.3",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz",
"integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==",
"dependencies": {
"motion-dom": "^12.34.3",
"motion-utils": "^12.29.2",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -4219,6 +4272,21 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/katex": {
"version": "0.16.33",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.33.tgz",
"integrity": "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4611,6 +4679,19 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/motion-dom": {
"version": "12.34.3",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
"integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==",
"dependencies": {
"motion-utils": "^12.29.2"
}
},
"node_modules/motion-utils": {
"version": "12.29.2",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5676,6 +5757,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/tailwind-merge": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",

View File

@@ -9,12 +9,17 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"clsx": "^2.1.1",
"framer-motion": "^12.34.3",
"katex": "^0.16.33",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/katex": "^0.16.8",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

836
standard-form-explorer.html Normal file
View File

@@ -0,0 +1,836 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Standard Form Explorer</title>
<style>
:root {
--primary: #1a237e;
--secondary: #3f51b5;
--accent: #e91e63;
--orange: #ff6f00;
--green: #2e7d32;
--digit-w: 68px;
--digit-h: 88px;
--digit-gap: 10px;
--dot-size: 18px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Tahoma, sans-serif;
background: #f0f2f8;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ── HEADER ───────────────────────────────────── */
header {
background: var(--primary);
color: white;
padding: 14px 28px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
header h1 { font-size: 1.4rem; letter-spacing: .3px; }
.mode-tabs { display: flex; gap: 8px; }
.tab {
background: rgba(255,255,255,.15);
border: 2px solid rgba(255,255,255,.4);
color: white;
padding: 8px 18px;
border-radius: 20px;
cursor: pointer;
font-size: .9rem;
font-family: inherit;
transition: all .2s;
}
.tab.active { background: white; color: var(--primary); border-color: white; font-weight: 700; }
.tab:hover:not(.active) { background: rgba(255,255,255,.25); }
/* ── MAIN ─────────────────────────────────────── */
main {
flex: 1;
max-width: 900px;
width: 100%;
margin: 0 auto;
padding: 20px 20px 32px;
display: flex;
flex-direction: column;
gap: 14px;
}
.card {
background: white;
border-radius: 12px;
padding: 18px 24px;
box-shadow: 0 2px 10px rgba(0,0,0,.09);
}
/* ── INPUT ────────────────────────────────────── */
.input-heading {
font-size: .78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .7px;
color: var(--secondary);
margin-bottom: 12px;
}
.input-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.input-row input {
font-size: 1.25rem;
padding: 10px 14px;
border: 2px solid #c5cae9;
border-radius: 8px;
outline: none;
font-family: inherit;
transition: border-color .2s;
}
.input-row input:focus { border-color: var(--secondary); }
#ordinary-input { max-width: 220px; }
#coeff-input { max-width: 140px; }
#power-input { max-width: 110px; }
.input-row label { font-size: 1.15rem; font-weight: 600; color: var(--primary); }
.btn-go {
background: var(--secondary);
color: white;
border: none;
padding: 11px 26px;
border-radius: 8px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
font-family: inherit;
transition: background .2s;
}
.btn-go:hover { background: var(--primary); }
.error-msg { color: #e53935; font-size: .92rem; margin-top: 8px; }
.hidden { display: none !important; }
/* ── DISPLAY ──────────────────────────────────── */
#display-card {
min-height: 260px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px;
}
#placeholder {
color: #bdbdbd;
font-size: 1.15rem;
text-align: center;
}
#step-info {
font-size: .88rem;
color: #9e9e9e;
min-height: 20px;
letter-spacing: .2px;
}
/* Digit row */
#number-row {
position: relative;
display: inline-flex;
align-items: flex-end;
gap: var(--digit-gap);
padding: 8px 16px 28px 16px;
}
.digit-box {
width: var(--digit-w);
height: var(--digit-h);
display: flex;
align-items: center;
justify-content: center;
font-size: 3.2rem;
font-weight: 700;
color: var(--primary);
background: var(--light);
border: 2px solid #c5cae9;
border-radius: 10px;
flex-shrink: 0;
transition: background .35s, border-color .35s, color .35s;
user-select: none;
}
.digit-box.dim {
color: #c0c0c0;
background: #f5f5f5;
border-color: #e0e0e0;
}
.digit-box.highlight {
background: #e3f2fd;
border-color: var(--secondary);
color: var(--primary);
}
/* Decimal dot */
#decimal-dot {
position: absolute;
bottom: 7px;
width: var(--dot-size);
height: var(--dot-size);
background: var(--accent);
border-radius: 50%;
transform: translateX(-50%);
transition: left .52s cubic-bezier(.34,1.56,.64,1), opacity .3s;
box-shadow: 0 2px 8px rgba(233,30,99,.45);
z-index: 5;
}
#decimal-dot.no-anim { transition: none; }
/* Direction label */
#direction-label {
font-size: .95rem;
font-weight: 600;
min-height: 22px;
}
.dir-left { color: var(--accent); }
.dir-right { color: var(--orange); }
.dir-done { color: var(--green); }
/* Power counter */
#power-row {
display: flex;
align-items: baseline;
gap: 5px;
font-size: 1.7rem;
color: #616161;
}
#power-row .base { font-weight: 700; color: var(--primary); }
#power-row sup { font-size: 2rem; font-weight: 800; color: var(--orange);
min-width: 38px; display: inline-block; }
#power-val { transition: all .15s; display: inline-block; }
#power-val.pop { animation: pop .28s ease; }
@keyframes pop {
0% { transform: scale(.5); }
65% { transform: scale(1.35); }
100%{ transform: scale(1); }
}
/* ── CONTROLS ─────────────────────────────────── */
#controls {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
padding: 14px 20px;
}
.ctrl {
padding: 12px 22px;
border: 2px solid;
border-radius: 9px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
font-family: inherit;
transition: all .15s;
}
.ctrl:disabled { opacity: .35; cursor: not-allowed; }
#btn-back { background: white; border-color: var(--secondary); color: var(--secondary); }
#btn-back:hover:not(:disabled) { background: var(--secondary); color: white; }
#btn-play { background: var(--green); border-color: var(--green); color: white; min-width: 115px; }
#btn-play:hover:not(:disabled) { background: #1b5e20; border-color: #1b5e20; }
#btn-step { background: var(--secondary); border-color: var(--secondary); color: white;
font-size: 1.05rem; padding: 12px 28px; }
#btn-step:hover:not(:disabled) { background: var(--primary); border-color: var(--primary); }
#btn-reset { background: white; border-color: #e53935; color: #e53935; }
#btn-reset:hover:not(:disabled) { background: #e53935; color: white; }
/* ── RESULT ───────────────────────────────────── */
#result-card {
text-align: center;
min-height: 90px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
}
#result-label { font-size: .82rem; color: #9e9e9e; text-transform: uppercase; letter-spacing: .5px; }
#result-value { font-size: 2.6rem; font-weight: 800; }
#result-value .r-coeff { color: var(--primary); }
#result-value .r-times { color: #616161; margin: 0 8px; }
#result-value .r-base { color: var(--primary); }
#result-value .r-exp { color: var(--accent); font-size: 1.9rem; }
#result-placeholder { color: #c5c5c5; font-size: 1rem; }
/* ── KEYBOARD HINT ────────────────────────────── */
#kb-hint {
text-align: center;
font-size: .78rem;
color: #b0b0b0;
padding: 4px 0 0;
}
#kb-hint kbd {
background: #ececec;
border: 1px solid #ccc;
border-radius: 4px;
padding: 1px 5px;
font-size: .75rem;
font-family: monospace;
}
/* ── RESPONSIVE ───────────────────────────────── */
@media (max-width: 580px) {
:root { --digit-w: 52px; --digit-h: 70px; --digit-gap: 6px; }
.digit-box { font-size: 2.4rem; }
#result-value { font-size: 1.9rem; }
}
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════════ HEADER -->
<header>
<h1>🔢 Standard Form Explorer</h1>
<div class="mode-tabs">
<button class="tab active" id="tab-sf" onclick="setMode('toSF')">Ordinary → Standard Form</button>
<button class="tab" id="tab-ord" onclick="setMode('toOrd')">Standard Form → Ordinary</button>
</div>
</header>
<!-- ═══════════════════════════════════════════════ MAIN -->
<main>
<!-- INPUT: Ordinary → Standard Form -->
<div class="card" id="panel-sf">
<div class="input-heading">Enter an ordinary number</div>
<div class="input-row">
<input type="text" id="ordinary-input"
placeholder="e.g. 7438 or 0.0055"
maxlength="18" autocomplete="off">
<button class="btn-go" onclick="convertToSF()">Convert →</button>
</div>
<div class="error-msg hidden" id="err-sf"></div>
</div>
<!-- INPUT: Standard Form → Ordinary -->
<div class="card hidden" id="panel-ord">
<div class="input-heading">Enter a number in standard form &nbsp;A × 10<sup>n</sup></div>
<div class="input-row">
<input type="text" id="coeff-input" placeholder="A e.g. 3.6" maxlength="12" autocomplete="off">
<label>× 10<sup>n</sup> &nbsp; where n =</label>
<input type="number" id="power-input" placeholder="e.g. 4 or 3" autocomplete="off">
<button class="btn-go" onclick="convertToOrd()">Convert →</button>
</div>
<div class="error-msg hidden" id="err-ord"></div>
</div>
<!-- DISPLAY -->
<div class="card" id="display-card">
<div id="placeholder">Enter a number above and click <strong>Convert</strong></div>
<div id="step-info" class="hidden"></div>
<div id="number-row" class="hidden">
<div id="decimal-dot"></div>
</div>
<div id="direction-label" class="hidden"></div>
<div id="power-row" class="hidden">
<span>×</span>
<span class="base">10</span>
<sup><span id="power-val">0</span></sup>
</div>
</div>
<!-- CONTROLS -->
<div class="card" id="controls">
<button class="ctrl" id="btn-back" onclick="stepBack()" disabled>◀ Back</button>
<button class="ctrl" id="btn-play" onclick="togglePlay()" disabled>▶ Play</button>
<button class="ctrl" id="btn-step" onclick="stepForward()" disabled>Next Step ▶</button>
<button class="ctrl" id="btn-reset" onclick="resetAll()" disabled>↺ Reset</button>
</div>
<!-- RESULT -->
<div class="card" id="result-card">
<div id="result-placeholder">Result will appear here when steps are complete</div>
<div id="result-label" class="hidden"></div>
<div id="result-value" class="hidden"></div>
</div>
<!-- KEYBOARD HINT -->
<div id="kb-hint">
Keyboard: <kbd>Space</kbd> or <kbd></kbd> next step &nbsp;·&nbsp;
<kbd></kbd> back &nbsp;·&nbsp; <kbd>P</kbd> play/pause &nbsp;·&nbsp; <kbd>R</kbd> reset
</div>
</main>
<!-- ═══════════════════════════════════════════════ SCRIPT -->
<script>
'use strict';
// ─── STATE ────────────────────────────────────────────────────────────────────
const S = {
mode: 'toSF', // 'toSF' | 'toOrd'
digits: [], // char array, e.g. ['7','4','3','8']
decimalPos: 0, // current position of decimal point
initPos: 0, // starting position (reset target)
targetPos: 0, // where we want to get to
origPower: 0, // original power stored for toOrd mode
ready: false,
done: false,
playing: false,
timer: null,
};
// ─── MODE SWITCH ──────────────────────────────────────────────────────────────
function setMode(m) {
S.mode = m;
show('panel-sf', m === 'toSF');
show('panel-ord', m === 'toOrd');
id('tab-sf') .classList.toggle('active', m === 'toSF');
id('tab-ord').classList.toggle('active', m === 'toOrd');
resetAll();
}
// ─── HELPERS ──────────────────────────────────────────────────────────────────
const id = s => document.getElementById(s);
const show = (el, vis) => id(el).classList.toggle('hidden', !vis);
function setErr(panelId, msg) {
const el = id(panelId);
el.textContent = msg;
el.classList.toggle('hidden', !msg);
}
// ─── PARSING ──────────────────────────────────────────────────────────────────
/**
* Parse an ordinary-form string into { digits, decimalPos }.
* decimalPos = number of digits before the decimal point.
* "7438" → digits=['7','4','3','8'], decimalPos=4
* "0.0055" → digits=['0','0','0','5','5'], decimalPos=1
* "15.78" → digits=['1','5','7','8'], decimalPos=2
*/
function parseOrdinary(raw) {
let s = raw.trim().replace(/[\s,]/g, '');
if (s.startsWith('-')) throw new Error('Please enter a positive number.');
if (!/^\d*\.?\d+$/.test(s) || s === '') throw new Error('Invalid number. Try something like 7438 or 0.0055');
if (s.length > 16) throw new Error('Number too long (max 16 digits).');
let dot = s.indexOf('.');
let digits, decimalPos;
if (dot === -1) {
digits = s.split('');
decimalPos = s.length;
} else {
digits = (s.slice(0, dot) + s.slice(dot + 1)).split('');
decimalPos = dot;
}
if (digits.length === 0) throw new Error('Invalid number.');
return { digits, decimalPos };
}
/** Index of the first non-zero digit, or -1 if all zeros. */
function firstNonZero(digits) {
return digits.findIndex(d => d !== '0');
}
/** Target decimal position for standard form: just after first non-zero digit. */
function sfTargetPos(digits) {
const i = firstNonZero(digits);
return i === -1 ? 1 : i + 1;
}
/** Current power based on mode and current decimalPos. */
function currentPower() {
if (S.mode === 'toSF') {
return S.initPos - S.decimalPos; // increases as decimal moves left
} else {
return S.origPower - (S.decimalPos - S.initPos); // decreases toward 0
}
}
// ─── CONVERT BUTTONS ──────────────────────────────────────────────────────────
function convertToSF() {
setErr('err-sf', '');
try {
let { digits, decimalPos } = parseOrdinary(id('ordinary-input').value);
let target = sfTargetPos(digits);
S.origPower = 0; // not used in toSF
initDisplay(digits, decimalPos, target);
} catch(e) { setErr('err-sf', e.message); }
}
function convertToOrd() {
setErr('err-ord', '');
try {
let coeffRaw = id('coeff-input').value.trim();
let n = parseInt(id('power-input').value, 10);
if (!coeffRaw) throw new Error('Enter the coefficient A.');
if (isNaN(n)) throw new Error('Enter the power n as a whole number.');
if (Math.abs(n) > 12) throw new Error('Power too large for display (max ±12).');
if (n === 0) throw new Error('Power is 0 — the number is already in ordinary form.');
let { digits, decimalPos } = parseOrdinary(coeffRaw);
// Validate 1 ≤ A < 10
let A = parseFloat(coeffRaw);
if (isNaN(A) || A < 1 || A >= 10)
throw new Error(`Coefficient must satisfy 1 ≤ A < 10 (got ${coeffRaw}).`);
// decimalPos for a valid coefficient is always 1
decimalPos = 1;
let expandedDigits = [...digits];
let startPos, targetPos;
if (n > 0) {
// Move decimal RIGHT n steps — need trailing zeros
targetPos = decimalPos + n; // = 1 + n
startPos = decimalPos; // = 1
while (expandedDigits.length < targetPos) expandedDigits.push('0');
} else {
// Move decimal LEFT |n| steps — need leading zeros
let absN = Math.abs(n);
let zeros = Array(absN).fill('0');
expandedDigits = zeros.concat(expandedDigits);
startPos = decimalPos + absN; // = 1 + |n| (decimal is now deeper in)
targetPos = startPos + n; // = 1 + |n| + n = 1 (for n negative)
}
S.origPower = n;
initDisplay(expandedDigits, startPos, targetPos);
} catch(e) { setErr('err-ord', e.message); }
}
// ─── INITIALISE DISPLAY ───────────────────────────────────────────────────────
function initDisplay(digits, start, target) {
stopPlay();
S.digits = digits;
S.decimalPos = start;
S.initPos = start;
S.targetPos = target;
S.ready = true;
S.done = (start === target);
// Show display elements
show('placeholder', false);
show('step-info', true);
show('number-row', true);
show('direction-label', true);
show('power-row', true);
// Render
renderDigits();
refreshPower(false);
refreshDirection();
refreshStepInfo();
refreshButtons();
id('decimal-dot').style.opacity = '1';
// Clear result
show('result-placeholder', true);
show('result-label', false);
show('result-value', false);
// Position dot instantly (after DOM paints)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
placeDot(false);
refreshDigitColors();
if (S.done) revealResult();
});
});
}
// ─── RENDERING ────────────────────────────────────────────────────────────────
function renderDigits() {
const row = id('number-row');
row.querySelectorAll('.digit-box').forEach(el => el.remove());
S.digits.forEach((ch, i) => {
const box = document.createElement('div');
box.className = 'digit-box';
box.id = `db-${i}`;
box.textContent = ch;
row.insertBefore(box, id('decimal-dot'));
});
}
function refreshDigitColors() {
const fz = firstNonZero(S.digits);
S.digits.forEach((_, i) => {
const box = id(`db-${i}`);
if (!box) return;
box.className = 'digit-box';
if (i < fz) box.classList.add('dim'); // leading zeros
else if (i === S.targetPos - 1) box.classList.add('highlight'); // coefficient lead digit
});
}
function placeDot(animate = true) {
const row = id('number-row');
const dot = id('decimal-dot');
const boxes = row.querySelectorAll('.digit-box');
if (!boxes.length) return;
let leftPx;
const dp = S.decimalPos;
if (dp <= 0) {
leftPx = boxes[0].offsetLeft - 6;
} else if (dp >= boxes.length) {
const last = boxes[boxes.length - 1];
leftPx = last.offsetLeft + last.offsetWidth - 4;
} else {
const prev = boxes[dp - 1];
const next = boxes[dp];
leftPx = Math.round((prev.offsetLeft + prev.offsetWidth + next.offsetLeft) / 2);
}
if (!animate) {
dot.classList.add('no-anim');
dot.style.left = leftPx + 'px';
// Re-enable transitions after reflow
requestAnimationFrame(() => requestAnimationFrame(() => dot.classList.remove('no-anim')));
} else {
dot.style.left = leftPx + 'px';
}
// Hide dot if it's sitting at trailing end and we are done
dot.style.opacity = (S.done && dp >= boxes.length) ? '0' : '1';
}
function refreshPower(animate = true) {
const p = currentPower();
const el = id('power-val');
el.textContent = p;
if (animate) {
el.classList.remove('pop');
void el.offsetWidth; // reflow
el.classList.add('pop');
}
}
function refreshDirection() {
const el = id('direction-label');
const rem = S.targetPos - S.decimalPos;
if (!S.ready || S.done || rem === 0) {
el.textContent = '';
el.className = '';
return;
}
if (rem < 0) {
el.innerHTML = `← Moving <strong>left</strong> &nbsp;·&nbsp; ${Math.abs(rem)} step${Math.abs(rem)!==1?'s':''} remaining`;
el.className = 'dir-left';
} else {
el.innerHTML = `→ Moving <strong>right</strong> &nbsp;·&nbsp; ${rem} step${rem!==1?'s':''} remaining`;
el.className = 'dir-right';
}
}
function refreshStepInfo() {
const el = id('step-info');
const total = Math.abs(S.targetPos - S.initPos);
const done = Math.abs(S.decimalPos - S.initPos);
if (total === 0) {
el.textContent = 'Number is already in standard form position — power = 0';
} else {
el.textContent = `Step ${done} of ${total}`;
}
}
function refreshButtons() {
const atStart = S.decimalPos === S.initPos;
const atTarget = S.decimalPos === S.targetPos;
id('btn-back') .disabled = !S.ready || atStart;
id('btn-step') .disabled = !S.ready || atTarget;
id('btn-play') .disabled = !S.ready || atTarget;
id('btn-reset').disabled = !S.ready;
}
// ─── STEPPING ─────────────────────────────────────────────────────────────────
function stepForward() {
if (!S.ready || S.decimalPos === S.targetPos) return;
const dir = S.targetPos > S.initPos ? 1 : -1;
S.decimalPos += dir;
placeDot(true);
refreshPower(true);
refreshDigitColors();
refreshDirection();
refreshStepInfo();
if (S.decimalPos === S.targetPos) {
S.done = true;
stopPlay();
// Wait for dot transition to settle before showing result
setTimeout(revealResult, 560);
setTimeout(() => placeDot(false), 580); // update opacity if trailing
}
refreshButtons();
}
function stepBack() {
if (!S.ready || S.decimalPos === S.initPos) return;
if (S.done) {
S.done = false;
id('decimal-dot').style.opacity = '1';
show('result-placeholder', true);
show('result-label', false);
show('result-value', false);
}
const dir = S.targetPos > S.initPos ? -1 : 1;
S.decimalPos += dir;
placeDot(true);
refreshPower(true);
refreshDigitColors();
refreshDirection();
refreshStepInfo();
refreshButtons();
}
// ─── AUTO-PLAY ────────────────────────────────────────────────────────────────
function togglePlay() {
S.playing ? stopPlay() : startPlay();
}
function startPlay() {
if (!S.ready || S.done) return;
S.playing = true;
id('btn-play').textContent = '⏸ Pause';
id('btn-step').disabled = true;
id('btn-back').disabled = true;
S.timer = setInterval(() => {
stepForward();
if (S.done) stopPlay();
}, 950);
}
function stopPlay() {
S.playing = false;
clearInterval(S.timer);
S.timer = null;
id('btn-play').textContent = '▶ Play';
refreshButtons();
}
// ─── RESET ────────────────────────────────────────────────────────────────────
function resetAll() {
stopPlay();
S.ready = S.done = false;
show('placeholder', true);
show('step-info', false);
show('number-row', false);
show('direction-label', false);
show('power-row', false);
show('result-placeholder', true);
show('result-label', false);
show('result-value', false);
id('number-row').querySelectorAll('.digit-box').forEach(el => el.remove());
id('decimal-dot').style.opacity = '1';
id('power-val').textContent = '0';
id('step-info').textContent = '';
id('direction-label').textContent = '';
['btn-back','btn-play','btn-step','btn-reset'].forEach(b => id(b).disabled = true);
id('btn-play').textContent = '▶ Play';
}
// ─── RESULT ───────────────────────────────────────────────────────────────────
function revealResult() {
if (S.mode === 'toSF') {
const fz = firstNonZero(S.digits);
const sigDigs = S.digits.slice(fz === -1 ? 0 : fz);
const power = S.initPos - S.targetPos;
const coeff = buildCoeff(sigDigs);
id('result-label').textContent = 'Standard Form';
id('result-value').innerHTML =
`<span class="r-coeff">${coeff}</span>` +
`<span class="r-times">×</span>` +
`<span class="r-base">10</span>` +
`<sup class="r-exp">${power}</sup>`;
} else {
const ord = buildOrdinary(S.digits, S.targetPos);
const power = S.origPower;
const fz = firstNonZero(S.digits);
const sig = S.digits.slice(fz === -1 ? 0 : fz);
const coeff = buildCoeff(sig);
id('result-label').textContent = 'Ordinary Form';
id('result-value').innerHTML =
`<span class="r-coeff">${ord}</span>`;
}
show('result-placeholder', false);
show('result-label', true);
show('result-value', true);
// Animate result card briefly
const rc = id('result-card');
rc.style.transition = 'box-shadow .3s';
rc.style.boxShadow = `0 0 0 4px ${getComputedStyle(document.documentElement)
.getPropertyValue('--green').trim()}`;
setTimeout(() => rc.style.boxShadow = '', 1200);
}
/** Build coefficient string: "7.438", "5.5", "1.0" */
function buildCoeff(sigDigs) {
if (sigDigs.length === 0) return '0';
if (sigDigs.length === 1) return sigDigs[0] + '.0';
const rest = sigDigs.slice(1).join('').replace(/0+$/, '') || '0';
return sigDigs[0] + '.' + rest;
}
/** Build ordinary number string from digits + decimal position. */
function buildOrdinary(digits, pos) {
let s;
if (pos <= 0) {
s = '0.' + '0'.repeat(-pos) + digits.join('');
} else if (pos >= digits.length) {
s = digits.join('') + '0'.repeat(pos - digits.length);
} else {
s = digits.slice(0, pos).join('') + '.' + digits.slice(pos).join('');
}
// Trim leading zeros (keep one before decimal)
s = s.replace(/^0+(?=[1-9])/, '');
if (s.startsWith('.')) s = '0' + s;
// Trim trailing zeros after decimal
if (s.includes('.')) s = s.replace(/\.?0+$/, '');
return s || '0';
}
// ─── KEYBOARD ─────────────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); stepForward(); }
if (e.key === 'ArrowLeft') { e.preventDefault(); stepBack(); }
if (e.key.toLowerCase() === 'p') togglePlay();
if (e.key.toLowerCase() === 'r') resetAll();
});
id('ordinary-input').addEventListener('keydown', e => { if(e.key==='Enter') convertToSF(); });
id('coeff-input') .addEventListener('keydown', e => { if(e.key==='Enter') convertToOrd(); });
id('power-input') .addEventListener('keydown', e => { if(e.key==='Enter') convertToOrd(); });
</script>
</body>
</html>

BIN
term-2-unit.pdf Normal file

Binary file not shown.

BIN
www.mathplayground.com_.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB