Initial Commit
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
*.md
|
||||
term-2-unit.pdf
|
||||
.env*
|
||||
.claude
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal 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
58
Jenkinsfile
vendored
Normal 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
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
179
app/globals.css
179
app/globals.css
@@ -1,26 +1,183 @@
|
||||
@import "tailwindcss";
|
||||
@import "katex/dist/katex.min.css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--background: #f4f8ff;
|
||||
--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 {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-muted: var(--muted);
|
||||
--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) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
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 = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Cabrits Math Lab",
|
||||
description:
|
||||
"Build confidence in mathematics with interactive lessons and practice for secondary school students.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,9 +14,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<body className="antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
28
app/lessons/layout.tsx
Normal file
28
app/lessons/layout.tsx
Normal 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
42
app/lessons/page.tsx
Normal 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 — 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>
|
||||
);
|
||||
}
|
||||
22
app/lessons/unit-1-fractions/add-subtract/page.tsx
Normal file
22
app/lessons/unit-1-fractions/add-subtract/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
app/lessons/unit-1-fractions/divide/page.tsx
Normal file
22
app/lessons/unit-1-fractions/divide/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
app/lessons/unit-1-fractions/fraction-of-quantity/page.tsx
Normal file
22
app/lessons/unit-1-fractions/fraction-of-quantity/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
app/lessons/unit-1-fractions/mixed-operations/page.tsx
Normal file
22
app/lessons/unit-1-fractions/mixed-operations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
app/lessons/unit-1-fractions/multiply/page.tsx
Normal file
22
app/lessons/unit-1-fractions/multiply/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
app/lessons/unit-1-fractions/page.tsx
Normal file
41
app/lessons/unit-1-fractions/page.tsx
Normal 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 — {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>
|
||||
);
|
||||
}
|
||||
22
app/lessons/unit-1-fractions/whole-from-fractions/page.tsx
Normal file
22
app/lessons/unit-1-fractions/whole-from-fractions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
app/lessons/unit-2-decimals/approximate/page.tsx
Normal file
22
app/lessons/unit-2-decimals/approximate/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
app/lessons/unit-2-decimals/compare-order/page.tsx
Normal file
22
app/lessons/unit-2-decimals/compare-order/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
app/lessons/unit-2-decimals/page.tsx
Normal file
41
app/lessons/unit-2-decimals/page.tsx
Normal 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 — {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>
|
||||
);
|
||||
}
|
||||
22
app/lessons/unit-2-decimals/standard-form/page.tsx
Normal file
22
app/lessons/unit-2-decimals/standard-form/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
app/lessons/unit-3-decimal-operations/add-subtract/page.tsx
Normal file
22
app/lessons/unit-3-decimal-operations/add-subtract/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
app/lessons/unit-3-decimal-operations/convert/page.tsx
Normal file
22
app/lessons/unit-3-decimal-operations/convert/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
41
app/lessons/unit-3-decimal-operations/page.tsx
Normal file
41
app/lessons/unit-3-decimal-operations/page.tsx
Normal 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 — {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>
|
||||
);
|
||||
}
|
||||
22
app/lessons/unit-4-ratio-proportion/define-ratio/page.tsx
Normal file
22
app/lessons/unit-4-ratio-proportion/define-ratio/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
app/lessons/unit-4-ratio-proportion/divide-in-ratio/page.tsx
Normal file
22
app/lessons/unit-4-ratio-proportion/divide-in-ratio/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
41
app/lessons/unit-4-ratio-proportion/page.tsx
Normal file
41
app/lessons/unit-4-ratio-proportion/page.tsx
Normal 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 — {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>
|
||||
);
|
||||
}
|
||||
22
app/lessons/unit-4-ratio-proportion/simplify-ratios/page.tsx
Normal file
22
app/lessons/unit-4-ratio-proportion/simplify-ratios/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
app/lessons/unit-4-ratio-proportion/word-problems/page.tsx
Normal file
22
app/lessons/unit-4-ratio-proportion/word-problems/page.tsx
Normal 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
37
app/not-found.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
176
app/page.tsx
176
app/page.tsx
@@ -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() {
|
||||
const allTopics = curriculum.flatMap((unit) =>
|
||||
unit.topics.map((topic) => ({
|
||||
unit,
|
||||
topic,
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<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">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
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.
|
||||
<div className="playground-bg flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="playground-frame mx-auto w-full max-w-6xl flex-1 px-3 pb-10 sm:px-5">
|
||||
<section className="mt-4 rounded-md border-2 border-[#1f50bf] bg-[#f1f6ff] p-4 sm:p-6">
|
||||
<p className="text-center text-sm font-bold text-[#26438b]">
|
||||
Interactive maths practice for secondary school students
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
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]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<h1 className="mt-2 text-center text-3xl font-extrabold text-[#17367d] sm:text-5xl">
|
||||
Math Topics Students Love to Explore
|
||||
</h1>
|
||||
<p className="mx-auto mt-3 max-w-3xl text-center text-sm font-semibold text-[#33508e] sm:text-base">
|
||||
Explore each topic with visual examples, short explanations, and guided lessons.
|
||||
Pick a unit, jump into a topic, and start solving.
|
||||
</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
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
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"
|
||||
Browse Lessons
|
||||
</Link>
|
||||
<Link
|
||||
href="/practice"
|
||||
className="rounded-sm bg-[#0e9d50] px-5 py-2.5 text-sm font-extrabold text-white shadow-md transition-colors hover:bg-[#0c7f41]"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
Start Practice
|
||||
</Link>
|
||||
</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>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
160
app/practice/page.tsx
Normal file
160
app/practice/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
338
components/explorers/bodmas-explorer.tsx
Normal file
338
components/explorers/bodmas-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
345
components/explorers/conversion-explorer.tsx
Normal file
345
components/explorers/conversion-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
403
components/explorers/decimal-arithmetic-explorer.tsx
Normal file
403
components/explorers/decimal-arithmetic-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
311
components/explorers/decimal-order-explorer.tsx
Normal file
311
components/explorers/decimal-order-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
389
components/explorers/fraction-operation-explorer.tsx
Normal file
389
components/explorers/fraction-operation-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
305
components/explorers/fraction-quantity-explorer.tsx
Normal file
305
components/explorers/fraction-quantity-explorer.tsx
Normal 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; // 0–1 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>
|
||||
);
|
||||
}
|
||||
401
components/explorers/ratio-explorer.tsx
Normal file
401
components/explorers/ratio-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
559
components/explorers/rounding-explorer.tsx
Normal file
559
components/explorers/rounding-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
445
components/explorers/standard-form-explorer.tsx
Normal file
445
components/explorers/standard-form-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
components/explorers/step-controls.tsx
Normal file
134
components/explorers/step-controls.tsx
Normal 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)]"
|
||||
>
|
||||
◀ 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 ▶
|
||||
</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"
|
||||
>
|
||||
↺ 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]">→</kbd>{" "}
|
||||
next{" · "}
|
||||
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">←</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>
|
||||
);
|
||||
}
|
||||
38
components/layout/breadcrumbs.tsx
Normal file
38
components/layout/breadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
components/layout/footer.tsx
Normal file
32
components/layout/footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
components/layout/header.tsx
Normal file
42
components/layout/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
components/layout/mobile-nav.tsx
Normal file
89
components/layout/mobile-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
components/layout/sidebar.tsx
Normal file
142
components/layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
components/math/math-display.tsx
Normal file
36
components/math/math-display.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
104
components/practice/fraction-input.tsx
Normal file
104
components/practice/fraction-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
308
components/practice/practice-section.tsx
Normal file
308
components/practice/practice-section.tsx
Normal 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
31
components/ui/badge.tsx
Normal 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
51
components/ui/button.tsx
Normal 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
76
components/ui/card.tsx
Normal 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
16
docker-compose.yml
Normal 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
198
lib/curriculum.ts
Normal 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
65
lib/math/decimals.ts
Normal 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
107
lib/math/fractions.ts
Normal 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
35
lib/math/ratios.ts
Normal 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
94
lib/math/validation.ts
Normal 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!" };
|
||||
}
|
||||
287
lib/problems/generators/decimal-problems.ts
Normal file
287
lib/problems/generators/decimal-problems.ts
Normal 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;
|
||||
}
|
||||
163
lib/problems/generators/fraction-problems.ts
Normal file
163
lib/problems/generators/fraction-problems.ts
Normal 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}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
110
lib/problems/generators/ratio-problems.ts
Normal file
110
lib/problems/generators/ratio-problems.ts
Normal 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
21
lib/problems/types.ts
Normal 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
23
lib/utils.ts
Normal 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;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
92
package-lock.json
generated
92
package-lock.json
generated
@@ -8,12 +8,17 @@
|
||||
"name": "cabrits",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.34.3",
|
||||
"katex": "^0.16.33",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -1445,6 +1450,12 @@
|
||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||
"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": {
|
||||
"version": "20.19.35",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -2454,6 +2473,14 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"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": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -3388,6 +3415,32 @@
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -4219,6 +4272,21 @@
|
||||
"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": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -4611,6 +4679,19 @@
|
||||
"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": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -5676,6 +5757,15 @@
|
||||
"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": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
||||
|
||||
@@ -9,12 +9,17 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.34.3",
|
||||
"katex": "^0.16.33",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
836
standard-form-explorer.html
Normal file
836
standard-form-explorer.html
Normal 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 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> 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 ·
|
||||
<kbd>←</kbd> back · <kbd>P</kbd> play/pause · <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> · ${Math.abs(rem)} step${Math.abs(rem)!==1?'s':''} remaining`;
|
||||
el.className = 'dir-left';
|
||||
} else {
|
||||
el.innerHTML = `→ Moving <strong>right</strong> · ${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
BIN
term-2-unit.pdf
Normal file
Binary file not shown.
BIN
www.mathplayground.com_.png
Normal file
BIN
www.mathplayground.com_.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
Reference in New Issue
Block a user