24 Commits

Author SHA1 Message Date
79fc2eacdc fix(inventory): fix inventory modal instantiation 2026-03-04 01:23:21 +06:00
9074b17a83 fix(import): fix imports on quiz.tsx 2026-03-04 01:10:23 +06:00
2a00c44157 feat(lessons): add lessons from client db 2026-03-01 20:24:14 +06:00
2eaf77e13c fix(api): fix api integration for quest map and adjacent components 2026-03-01 12:57:54 +06:00
c7f0183956 feat(ui): add infoheader component, improve quest map visuals 2026-02-27 02:18:47 +06:00
f64d2cac4a feat(treasure): add treasure quest, quest modal, island node, quest widget 2026-02-26 01:31:48 +06:00
894863c196 fix(rewards): fix null state in rewards screen 2026-02-22 13:00:28 +06:00
d56ea14a22 fix(ui): fix minor ui bugs 2026-02-22 03:38:16 +06:00
be63ca5ed2 fix(leaderboard): fix leaderboard scheme for questions and streaks 2026-02-22 03:29:01 +06:00
a48a50ae77 fix(ui): fix minor ui bugs 2026-02-21 17:06:55 +06:00
f054c7179b refactor(search): refactor search ui for overall style coherence 2026-02-21 16:53:36 +06:00
65dbe99647 feat(ui): improve ui for test, drills and htm screens 2026-02-21 02:04:50 +06:00
76d2108aec feat(ui): add new ui 2026-02-20 19:10:13 +06:00
3c8f945539 fix(ui): change ui theme color
feat(calc): add geogebra based graph calculator for tests
2026-02-20 00:03:23 +06:00
626616c8b5 fix(ui): fix minor ui bugs 2026-02-17 16:41:24 +06:00
b56642fda8 fix(ui): fix minor ui bugs 2026-02-15 18:37:28 +06:00
e5305a1ca2 feat(ui): add sidebar navigation for desktop 2026-02-15 17:24:11 +06:00
96eb2c13b0 feat(home): add spotlight search functionality 2026-02-14 03:24:22 +06:00
7f82e640e0 feat(results): add resutls page
fix(leaderboard): fix leaderboard fetch logic

fix(test): fix navigation bug upon test quit
2026-02-10 19:32:46 +06:00
8cfcb11f0a feat(error): add error handling on test screen 2026-02-07 20:21:47 +06:00
c9db96f97f feat(leaderboard): add leaderboard functionaltiy 2026-02-07 18:58:50 +06:00
02419678b7 feat(test): add functionality for drill, hard test module testing 2026-02-07 15:28:43 +06:00
903653a212 feat(targeted): add targeted practice functionality
feat(analytics); add analytics page
2026-02-05 15:07:24 +06:00
2ac88835f9 feat(lesson): add lesson modal 2026-02-01 18:20:03 +06:00
218 changed files with 92796 additions and 1606 deletions

View File

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://www.geogebra.org/apps/deployggb.js"></script>
<title>Edbridge Scholars</title>
</head>
<body>

View File

@ -17,17 +17,21 @@
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-table": "^8.21.3",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.30.0",
"katex": "^0.16.28",
"lucide-react": "^0.562.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-katex": "^3.1.0",
"react-router-dom": "^7.12.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vaul": "^1.1.2",
"zustand": "^5.0.9"
},
"devDependencies": {

994
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,9 @@ import { StudentLayout } from "./pages/student/StudentLayout";
import { TargetedPractice } from "./pages/student/targeted-practice/page";
import { Drills } from "./pages/student/drills/page";
import { HardTestModules } from "./pages/student/hard-test-modules/page";
import { Analytics } from "./pages/student/Analytics";
import { QuestMap } from "./pages/student/QuestMap";
import ErrorPage from "./pages/ErrorPage";
function App() {
const router = createBrowserRouter([
@ -53,6 +56,14 @@ function App() {
path: "profile",
element: <Profile />,
},
{
path: "analytics",
element: <Analytics />,
},
{
path: "quests",
element: <QuestMap />,
},
{
path: "practice/:sheetId",
element: <Pretest />,

View File

@ -0,0 +1,216 @@
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuSub,
} from "../components/ui/sidebar";
import {
ChevronDown,
BookOpen,
Home,
Video,
User,
Target,
Zap,
Trophy,
LayoutGrid,
} from "lucide-react";
import { useState } from "react";
import logo from "../assets/ed_logo1.png";
import { NavLink } from "react-router-dom";
import { useAuthStore } from "../stores/authStore";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
export function AppSidebar() {
const [open, setOpen] = useState(true);
const user = useAuthStore((s) => s.user);
return (
<Sidebar className="border-r bg-black text-white">
{/* HEADER */}
<SidebarHeader>
<div className="flex items-center justify-between px-2 py-2 rounded-lg hover:bg-white/10 cursor-pointer">
<div className="flex items-center gap-3">
<div className="flex rounded-md w-10 h-10 border overflow-hidden">
<img
src={logo}
className="w-full h-full object-cover object-left"
alt="Logo"
/>
</div>
<div className="flex flex-col text-sm">
<span className="font-satoshi-medium text-black">
Edbridge Scholars
</span>
<span className="text-xs text-gray-400 font-satoshi">
Student
</span>
</div>
</div>
<ChevronDown size={16} />
</div>
</SidebarHeader>
{/* CONTENT */}
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-gray-400 font-satoshi">
Platform
</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<NavLink
to="/student/home"
className={({ isActive }) =>
isActive
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:bg-zinc-800"
}
>
<Home size={18} className="text-black" />
<span className="font-satoshi text-black">Home</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
className="cursor-pointer"
asChild
onClick={() => setOpen(!open)}
>
<div>
<BookOpen size={18} className="text-black" />
<span className="font-satoshi text-black">Practice</span>
<ChevronDown
size={16}
className={`ml-auto transition-transform ${
open ? "rotate-180" : ""
}`}
/>
</div>
</SidebarMenuButton>
{open && (
<SidebarMenuSub className="space-y-3 mt-2">
<NavLink
to="/student/practice"
className="text-black text-sm flex items-center gap-3"
>
<LayoutGrid size={18} className="text-black" />
<span className="font-satoshi text-black">
Practice your way
</span>
</NavLink>
<NavLink
to="/student/practice/targeted-practice"
className="text-black text-sm flex items-center gap-3"
>
<Target size={18} className="text-black" />
<span className="font-satoshi text-black">
Targeted Practice
</span>
</NavLink>
<NavLink
to="/student/practice/drills"
className="text-black text-sm flex items-center gap-3"
>
<Zap size={18} className="text-black" />
<span className="font-satoshi text-black">Drills</span>
</NavLink>
<NavLink
to="/student/practice/hard-test-modules"
className="text-black text-sm flex items-center gap-3"
>
<Trophy size={18} className="text-black" />
<span className="font-satoshi text-black">
Hard Test Modules
</span>
</NavLink>
</SidebarMenuSub>
)}
</SidebarMenuItem>
{/* DOCS */}
<SidebarMenuItem>
<NavLink
to={`/student/lessons`}
className={({ isActive }) =>
isActive
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:bg-zinc-800"
}
>
<SidebarMenuButton className="cursor-pointer">
<Video size={18} className="text-black" />
<span className="text-black font-satoshi">Lessons</span>
</SidebarMenuButton>
</NavLink>
</SidebarMenuItem>
{/* SETTINGS */}
<SidebarMenuItem>
<NavLink
to={`/student/rewards`}
className={({ isActive }) =>
isActive
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:bg-zinc-800"
}
>
<SidebarMenuButton className="cursor-pointer">
<Trophy size={18} className="text-black" />
<span className="text-black font-satoshi">Rewards</span>
</SidebarMenuButton>
</NavLink>
</SidebarMenuItem>
<SidebarMenuItem>
<NavLink
to={`/student/profile`}
className={({ isActive }) =>
isActive
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:bg-zinc-800"
}
>
<SidebarMenuButton className="cursor-pointer">
<User size={18} className="text-black" />
<span className="text-black font-satoshi">Profile</span>
</SidebarMenuButton>
</NavLink>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
{/* FOOTER */}
<SidebarFooter>
<div className="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-white/10 cursor-pointer">
<Avatar>
<AvatarImage src={user?.avatar_url} />
<AvatarFallback className="font-satoshi-bold bg-linear-to-br from-purple-400 to-purple-500 uppercase">
{user?.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col text-sm">
<span className="font-medium text-black">{user?.name}</span>
<span className="text-xs text-gray-400">{user?.email}</span>
</div>
<ChevronDown size={16} className="ml-auto" />
</div>
</SidebarFooter>
</Sidebar>
);
}

View File

@ -0,0 +1,250 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { createPortal } from "react-dom";
import { X, Calculator, Maximize2, Minimize2 } from "lucide-react";
// ─── GeoGebra type shim ───────────────────────────────────────────────────────
declare global {
interface Window {
GGBApplet: new (
params: Record<string, unknown>,
defer?: boolean,
) => {
inject: (containerId: string) => void;
};
ggbApplet?: {
reset: () => void;
setXML: (xml: string) => void;
};
}
}
// ─── Hook: load GeoGebra script once ─────────────────────────────────────────
const GEOGEBRA_SCRIPT = "https://www.geogebra.org/apps/deployggb.js";
const useGeoGebraScript = () => {
const [ready, setReady] = useState(false);
useEffect(() => {
if (document.querySelector(`script[src="${GEOGEBRA_SCRIPT}"]`)) {
if (window.GGBApplet) setReady(true);
return;
}
const script = document.createElement("script");
script.src = GEOGEBRA_SCRIPT;
script.async = true;
script.onload = () => setReady(true);
document.head.appendChild(script);
}, []);
return ready;
};
// ─── GeoGebra Calculator ──────────────────────────────────────────────────────
const GeoGebraCalculator = ({ containerId }: { containerId: string }) => {
const scriptReady = useGeoGebraScript();
const injected = useRef(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
// Measure the wrapper first — GeoGebra needs explicit px dimensions
useEffect(() => {
const el = wrapperRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) {
setDims({ w: Math.floor(width), h: Math.floor(height) });
}
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => {
if (!scriptReady || !dims || injected.current) return;
injected.current = true;
const params = {
appName: "graphing",
width: dims.w,
height: dims.h,
showToolBar: true,
showAlgebraInput: true,
showMenuBar: false,
enableLabelDrags: true,
enableShiftDragZoom: true,
enableRightClick: true,
showZoomButtons: true,
capturingThreshold: null,
showFullscreenButton: false,
scale: 1,
disableAutoScale: false,
allowUpscale: false,
clickToLoad: false,
appletOnLoad: () => {},
useBrowserForJS: false,
showLogging: false,
errorDialogsActive: true,
showTutorialLink: false,
showSuggestionButtons: false,
language: "en",
id: "ggbApplet",
};
try {
const applet = new window.GGBApplet(params, true);
applet.inject(containerId);
} catch (e) {
console.error("GeoGebra init error:", e);
}
}, [scriptReady, dims, containerId]);
return (
<div ref={wrapperRef} className="w-full h-full">
{!dims && (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-sm font-satoshi gap-2">
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
/>
</svg>
Loading calculator...
</div>
)}
<div id={containerId} style={{ width: dims?.w, height: dims?.h }} />
</div>
);
};
// ─── Modal ────────────────────────────────────────────────────────────────────
interface GraphCalculatorModalProps {
open: boolean;
onClose: () => void;
}
const GraphCalculatorModal = ({ open, onClose }: GraphCalculatorModalProps) => {
const [fullscreen, setFullscreen] = useState(false);
const containerId = "geogebra-container";
// Trap focus & keyboard dismiss
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Prevent body scroll while open
useEffect(() => {
document.body.style.overflow = open ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [open]);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-label="Graph Calculator"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<div
className={`
relative z-10 flex flex-col bg-white rounded-2xl shadow-2xl overflow-hidden
transition-all duration-300
${
fullscreen
? "w-screen h-screen rounded-none"
: "w-[95vw] h-[90vh] max-w-5xl"
}
`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-white shrink-0">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-purple-50 border border-purple-100">
<Calculator size={16} className="text-purple-500" />
</div>
<span className="font-satoshi-bold text-gray-800 text-sm">
Graph Calculator
</span>
<span className="text-[10px] font-satoshi text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
Powered by GeoGebra
</span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => setFullscreen((f) => !f)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition"
title={fullscreen ? "Exit fullscreen" : "Fullscreen"}
>
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 transition"
title="Close"
>
<X size={16} />
</button>
</div>
</div>
{/* GeoGebra canvas area */}
<div className="flex-1 overflow-hidden">
<GeoGebraCalculator containerId={containerId} />
</div>
</div>
</div>,
document.body,
);
};
// ─── Trigger button + modal — drop this wherever you need it ──────────────────
export const GraphCalculatorButton = () => {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-full font-satoshi-medium text-sm"
>
<Calculator size={16} />
Calculator
</button>
<GraphCalculatorModal open={open} onClose={() => setOpen(false)} />
</>
);
};
// ─── Standalone modal export if you need to control it externally ─────────────
export { GraphCalculatorModal };

View File

@ -0,0 +1,750 @@
import { useState, useEffect, useRef } from "react";
import type { QuestNode, ClaimedRewardResponse } from "../types/quest";
// ─── Styles ───────────────────────────────────────────────────────────────────
const S = `
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@700;900&family=Nunito:wght@800;900&display=swap');
/* ══ FULL SCREEN OVERLAY ══ */
.com-overlay {
position:fixed; inset:0; z-index:80;
display:flex; flex-direction:column;
align-items:center; justify-content:center;
overflow:hidden;
}
/* ── Sky/sea background that animates in ── */
.com-bg {
position:absolute; inset:0;
background:
radial-gradient(ellipse 80% 60% at 50% 80%, rgba(0,60,120,0.9) 0%, transparent 70%),
radial-gradient(ellipse 60% 40% at 50% 20%, rgba(80,0,160,0.7) 0%, transparent 60%),
linear-gradient(180deg, #050010 0%, #0a0520 40%, #020818 100%);
animation: comBgIn 0.5s ease both;
}
@keyframes comBgIn {
from{ opacity:0; } to{ opacity:1; }
}
/* ── Stars in background ── */
.com-star {
position:absolute; border-radius:50%;
background:white; pointer-events:none;
animation:comStarTwinkle var(--sdur) ease-in-out infinite;
animation-delay:var(--sdelay);
}
@keyframes comStarTwinkle {
0%,100%{ opacity:0.3; transform:scale(1); }
50% { opacity:1; transform:scale(1.4); }
}
/* ── Gold radial burst (appears on open) ── */
.com-burst {
position:absolute; inset:0;
display:flex; align-items:center; justify-content:center;
pointer-events:none; z-index:2;
}
.com-burst-ring {
position:absolute; border-radius:50%;
border:3px solid rgba(251,191,36,0.6);
animation: comBurstRing var(--brdur) ease-out forwards;
animation-delay: var(--brdelay);
opacity:0;
}
@keyframes comBurstRing {
0% { opacity:0.9; transform:scale(0.1); }
100%{ opacity:0; transform:scale(var(--brs)); }
}
/* ── Ray beams (crepuscular rays) ── */
.com-rays {
position:absolute; inset:0;
display:flex; align-items:center; justify-content:center;
pointer-events:none; z-index:1;
}
.com-ray {
position:absolute;
width:3px;
height:55vh;
border-radius:100px;
background:linear-gradient(180deg,rgba(251,191,36,0.5) 0%,transparent 100%);
transform-origin:50% 100%;
bottom:50%;
left:calc(50% - 1.5px);
transform:rotate(var(--angle)) scaleY(0);
animation:comRayIn 0.6s ease-out forwards;
animation-delay:var(--raydelay);
}
@keyframes comRayIn {
0% { transform:rotate(var(--angle)) scaleY(0); opacity:0; }
40% { opacity:0.8; }
100%{ transform:rotate(var(--angle)) scaleY(1); opacity:0.15; }
}
/* ── Particle explosion ── */
.com-particle {
position:absolute; border-radius:50%;
pointer-events:none; z-index:4;
animation:comParticleOut var(--pdur) cubic-bezier(0.25,0.8,0.35,1) forwards;
animation-delay:var(--pdelay);
opacity:0;
}
@keyframes comParticleOut {
0% { opacity:1; transform:translate(0,0) scale(1) rotate(0deg); }
80% { opacity:0.7; }
100%{ opacity:0; transform:translate(var(--ptx),var(--pty)) scale(0.2) rotate(var(--prot)); }
}
/* ── Coin emojis bursting ── */
.com-coin {
position:absolute;
font-size:var(--csize);
pointer-events:none; z-index:4;
animation:comCoinOut var(--cdur) cubic-bezier(0.2,0.9,0.3,1) forwards;
animation-delay:var(--cdelay);
opacity:0;
}
@keyframes comCoinOut {
0% { opacity:0; transform:translate(0,0) rotate(0deg) scale(0.3); }
12% { opacity:1; }
100%{ opacity:0; transform:translate(var(--ctx),var(--cty)) rotate(var(--crot)) scale(1.1); }
}
/* ── Floating sparkles (stay on screen) ── */
.com-sparkle {
position:absolute; pointer-events:none; z-index:3;
font-size:var(--spsize);
animation:comSparkleFloat var(--spdur) ease-in-out infinite;
animation-delay:var(--spdelay);
opacity:0.7;
}
@keyframes comSparkleFloat {
0%,100%{ transform:translateY(0) rotate(0deg) scale(1); opacity:0.6; }
50% { transform:translateY(-18px) rotate(180deg) scale(1.2); opacity:1; }
}
/* ── XP number that flies up from chest ── */
.com-xp-blast {
position:absolute; pointer-events:none; z-index:5;
top:50%; left:50%;
font-family:'Cinzel',serif;
font-size:2.6rem; font-weight:900;
color:#fbbf24;
text-shadow:0 0 30px rgba(251,191,36,1),0 0 60px rgba(251,191,36,0.7),0 0 100px rgba(251,191,36,0.3);
white-space:nowrap;
animation:comXPBlast 2s cubic-bezier(0.2,0.8,0.3,1) forwards;
}
@keyframes comXPBlast {
0% { opacity:0; transform:translate(-50%,-40%) scale(0.4); filter:blur(4px); }
15% { opacity:1; transform:translate(-50%,-60%) scale(1.3); filter:blur(0); }
60% { opacity:1; transform:translate(-50%,-90%) scale(1); }
100%{ opacity:0; transform:translate(-50%,-130%) scale(0.8); }
}
/* ── Main card ── */
.com-card {
position:relative; z-index:6;
width:calc(100% - 2.5rem); max-width:340px;
border-radius:28px; overflow:hidden;
display:flex; flex-direction:column; align-items:center;
padding:0;
box-shadow:0 0 80px rgba(251,191,36,0.2), 0 24px 64px rgba(0,0,0,0.7);
animation:comCardIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
animation-delay:0.1s;
}
@keyframes comCardIn {
from{ opacity:0; transform:scale(0.8) translateY(24px); }
to { opacity:1; transform:scale(1) translateY(0); }
}
/* Gold shimmer top border */
.com-card::before {
content:''; position:absolute; top:0; left:0; right:0; height:2px; z-index:1;
background:linear-gradient(90deg,transparent 0%,#f59e0b 30%,#fbbf24 50%,#f59e0b 70%,transparent 100%);
background-size:200% 100%;
animation:comShimmer 2s linear infinite;
}
@keyframes comShimmer {
0% { background-position:200% 0; }
100%{ background-position:-200% 0; }
}
/* Card inner bg */
.com-card-inner {
width:100%; padding:1.75rem 1.6rem 1.6rem;
background:linear-gradient(160deg,#12083a 0%,#0c0525 60%,#090320 100%);
border:1.5px solid rgba(251,191,36,0.25);
border-radius:28px;
display:flex; flex-direction:column; align-items:center; gap:0;
}
/* ── Phase label ── */
.com-label {
font-family:'Cinzel',serif;
font-size:0.62rem; font-weight:700; letter-spacing:0.2em;
text-transform:uppercase; color:rgba(251,191,36,0.55);
margin-bottom:1.2rem; text-align:center;
}
/* ── Chest area ── */
.com-chest-area {
position:relative; width:140px; height:140px;
display:flex; align-items:center; justify-content:center;
margin-bottom:1.25rem; cursor:pointer;
}
/* Glow platform beneath chest */
.com-glow-pad {
position:absolute; bottom:6px; left:50%;
transform:translateX(-50%);
width:100px; height:24px; border-radius:50%;
background:radial-gradient(ellipse at center,rgba(251,191,36,0.45) 0%,transparent 70%);
animation:comGlowPad 1.8s ease-in-out infinite;
filter:blur(4px);
}
@keyframes comGlowPad {
0%,100%{ transform:translateX(-50%) scaleX(1); opacity:0.7; }
50% { transform:translateX(-50%) scaleX(1.2); opacity:1; }
}
/* Orbit ring */
.com-orbit {
position:absolute; inset:8px; border-radius:50%;
border:1.5px dashed rgba(251,191,36,0.2);
animation:comOrbit 8s linear infinite;
}
@keyframes comOrbit { from{transform:rotate(0deg);} to{transform:rotate(360deg);} }
.com-orbit-dot {
position:absolute; top:-5px; left:50%; transform:translateX(-50%);
width:8px; height:8px; border-radius:50%;
background:#fbbf24; box-shadow:0 0 10px #fbbf24;
}
/* The chest emoji */
.com-chest {
font-size:5.5rem; position:relative; z-index:2;
filter:drop-shadow(0 8px 20px rgba(251,191,36,0.45));
transition:filter 0.2s;
}
.com-chest.idle {
animation:comChestIdle 3s ease-in-out infinite;
}
@keyframes comChestIdle {
0%,100%{ transform:translateY(0) rotate(-2deg); }
50% { transform:translateY(-6px) rotate(2deg); }
}
.com-chest.shake {
animation:comChestShake 0.55s cubic-bezier(0.36,0.07,0.19,0.97) both;
}
@keyframes comChestShake {
0%,100%{ transform:rotate(0deg) scale(1); }
10% { transform:rotate(-14deg) scale(1.06); }
25% { transform:rotate(14deg) scale(1.1); }
40% { transform:rotate(-10deg) scale(1.07); }
55% { transform:rotate(10deg) scale(1.12); }
70% { transform:rotate(-6deg) scale(1.06); }
85% { transform:rotate(6deg) scale(1.04); }
}
.com-chest.opening {
animation:comChestOpen 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
}
@keyframes comChestOpen {
0% { transform:scale(0.7); filter:brightness(0.4) drop-shadow(0 8px 20px rgba(251,191,36,0.3)); }
50% { transform:scale(1.25); filter:brightness(1.8) drop-shadow(0 0 50px rgba(251,191,36,1)); }
100%{ transform:scale(1); filter:brightness(1) drop-shadow(0 8px 30px rgba(251,191,36,0.6)); }
}
/* ── Tap prompt ── */
.com-tap-title {
font-family:'Cinzel',serif;
font-size:1.2rem; font-weight:900; color:white;
text-align:center; margin-bottom:0.3rem;
animation:comPulse 1.8s ease-in-out infinite;
}
@keyframes comPulse {
0%,100%{ opacity:1; transform:scale(1); }
50% { opacity:0.65; transform:scale(0.97); }
}
.com-tap-sub {
font-family:'Nunito',sans-serif;
font-size:0.75rem; font-weight:800;
color:rgba(255,255,255,0.35); text-align:center; margin-bottom:1.5rem;
letter-spacing:0.06em;
}
/* ── Shaking text ── */
.com-shake-text {
font-family:'Cinzel',serif;
font-size:1.1rem; font-weight:900; color:#fbbf24;
text-align:center; margin-bottom:0.3rem;
animation:comShakeText 0.3s ease-in-out infinite alternate;
}
@keyframes comShakeText {
from{ transform:translateX(-3px); }
to { transform:translateX(3px); }
}
.com-shake-dots {
font-size:1.4rem; text-align:center; margin-bottom:1.5rem;
animation:comShakeText 0.25s ease-in-out infinite alternate;
}
/* ── Reward rows ── */
.com-rewards-title {
font-family:'Cinzel',serif;
font-size:0.65rem; font-weight:700; letter-spacing:0.18em;
text-transform:uppercase; color:rgba(251,191,36,0.5);
text-align:center; margin-bottom:0.85rem;
}
.com-rewards { display:flex; flex-direction:column; gap:0.55rem; width:100%; margin-bottom:1.1rem; }
.com-reward-row {
display:flex; align-items:center; gap:0.85rem;
padding:0.8rem 1rem;
background:rgba(255,255,255,0.04);
border:1px solid rgba(251,191,36,0.18);
border-radius:16px;
animation:comRowIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
}
@keyframes comRowIn {
from{ opacity:0; transform:translateY(18px) scale(0.88); }
to { opacity:1; transform:translateY(0) scale(1); }
}
.com-reward-icon { font-size:1.5rem; flex-shrink:0; filter:drop-shadow(0 2px 8px rgba(251,191,36,0.5)); }
.com-reward-lbl {
font-family:'Cinzel',serif;
font-size:0.65rem; font-weight:700; letter-spacing:0.1em; text-transform:uppercase;
color:rgba(255,255,255,0.4); margin-bottom:0.12rem;
}
.com-reward-val {
font-family:'Nunito',sans-serif;
font-size:1.05rem; font-weight:900;
color:#fbbf24;
text-shadow:0 0 16px rgba(251,191,36,0.6);
}
/* Special XP row highlight */
.com-reward-row.xp-row {
border-color:rgba(251,191,36,0.35);
background:rgba(251,191,36,0.06);
}
/* Loading state inside reward area */
.com-rewards-loading {
font-family:'Cinzel',serif;
font-size:0.72rem; font-weight:700; color:rgba(251,191,36,0.4);
text-align:center; padding:1rem 0; letter-spacing:0.1em;
animation:comPulse 1.2s ease-in-out infinite;
}
/* ── CTA button ── */
.com-cta {
width:100%; padding:1rem;
background:linear-gradient(135deg,#fbbf24,#f59e0b);
border:none; border-radius:16px; cursor:pointer;
font-family:'Cinzel',serif;
font-size:1rem; font-weight:900; color:#1a0800;
letter-spacing:0.05em;
box-shadow:0 5px 0 #b45309, 0 8px 24px rgba(251,191,36,0.4);
transition:all 0.12s ease;
animation:comRowIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
}
.com-cta:hover { transform:translateY(-3px); box-shadow:0 8px 0 #b45309, 0 14px 32px rgba(251,191,36,0.5); }
.com-cta:active { transform:translateY(2px); box-shadow:0 3px 0 #b45309; }
/* ── Skip hint ── */
.com-skip {
position:absolute; bottom:1.5rem;
font-family:'Nunito',sans-serif;
font-size:0.65rem; font-weight:700;
color:rgba(255,255,255,0.2); letter-spacing:0.1em;
text-transform:uppercase; cursor:pointer; z-index:7;
transition:color 0.2s;
}
.com-skip:hover { color:rgba(255,255,255,0.5); }
`;
// ─── Config ───────────────────────────────────────────────────────────────────
const PARTICLE_COLORS = [
"#fbbf24",
"#f59e0b",
"#ef4444",
"#ec4899",
"#a855f7",
"#6366f1",
"#22d3ee",
"#4ade80",
"#fb923c",
];
const COIN_EMOJIS = ["🪙", "💰", "✨", "⭐", "💎", "🌟", "💫", "🏅"];
const SPARKLE_EMOJIS = ["✨", "⭐", "💫", "🌟"];
const RAYS = Array.from({ length: 12 }, (_, i) => ({
id: i,
angle: `${(i / 12) * 360}deg`,
delay: `${i * 0.04}s`,
}));
const BURST_RINGS = [
{ id: 0, size: "3", dur: "0.7s", delay: "0s" },
{ id: 1, size: "5", dur: "0.9s", delay: "0.1s" },
{ id: 2, size: "8", dur: "1.1s", delay: "0.2s" },
{ id: 3, size: "12", dur: "1.4s", delay: "0.3s" },
];
const STARS = Array.from({ length: 40 }, (_, i) => ({
id: i,
w: 1 + ((i * 7) % 3),
top: `${(i * 17 + 3) % 95}%`,
left: `${(i * 23 + 11) % 97}%`,
dur: `${2 + ((i * 3) % 4)}s`,
delay: `${(i * 7) % 3}s`,
}));
const SPARKLES = Array.from({ length: 8 }, (_, i) => ({
id: i,
emoji: SPARKLE_EMOJIS[i % 4],
size: `${0.9 + (i % 3) * 0.35}rem`,
top: `${10 + ((i * 12) % 75)}%`,
left: `${5 + ((i * 14) % 85)}%`,
dur: `${2 + (i % 3) * 1.2}s`,
delay: `${i * 0.3}s`,
}));
type Phase = "idle" | "shaking" | "opening" | "revealed";
interface Props {
node: QuestNode;
claimResult: ClaimedRewardResponse | null;
onClose: () => void;
}
export const ChestOpenModal = ({ node, claimResult, onClose }: Props) => {
const [phase, setPhase] = useState<Phase>("idle");
const [showXP, setShowXP] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Stable particle arrays computed once per mount
const particles = useRef(
Array.from({ length: 55 }, (_, i) => ({
id: i,
color: PARTICLE_COLORS[i % PARTICLE_COLORS.length],
w: 3 + (i % 3) * 4,
tx: ((i % 2 === 0 ? 1 : -1) * (40 + i * 7)) % 200,
ty: -(30 + ((i * 11) % 190)),
rot: ((i * 23) % 720) - 360,
dur: `${0.7 + ((i * 7) % 10) / 10}s`,
delay: `${((i * 3) % 8) / 30}s`,
})),
).current;
const coins = useRef(
Array.from({ length: 18 }, (_, i) => ({
id: i,
emoji: COIN_EMOJIS[i % COIN_EMOJIS.length],
size: `${1 + (i % 3) * 0.45}rem`,
tx: (i % 2 === 0 ? 1 : -1) * (30 + ((i * 9) % 180)),
ty: -(40 + ((i * 13) % 200)),
rot: ((i * 31) % 540) - 270,
dur: `${0.75 + ((i * 7) % 8) / 10}s`,
delay: `${((i * 5) % 10) / 30}s`,
})),
).current;
const tap = () => {
if (phase !== "idle") return;
setPhase("shaking");
timerRef.current = setTimeout(() => {
setPhase("opening");
setShowXP(true);
timerRef.current = setTimeout(() => {
setShowXP(false);
setPhase("revealed");
}, 1800);
}, 650);
};
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
},
[],
);
// ── Build reward rows from ClaimedRewardResponse ──────────────────────────
// claimResult may be null while the API call is in flight; we show a loading
// state in that case rather than crashing or showing stale data.
const xpAwarded = claimResult?.xp_awarded ?? 0;
// Defensively coerce to arrays — the API may return null, a single object,
// or omit these fields entirely rather than returning an empty array.
const titlesAwarded = Array.isArray(claimResult?.title_unlocked)
? claimResult!.title_unlocked
: claimResult?.title_unlocked
? [claimResult.title_unlocked]
: [];
const itemsAwarded = Array.isArray(claimResult?.items_awarded)
? claimResult!.items_awarded
: claimResult?.items_awarded
? [claimResult.items_awarded]
: [];
const rewards = claimResult
? [
// XP row — always present
{
key: "xp",
cls: "xp-row",
icon: "⚡",
lbl: "XP Gained",
val: `+${xpAwarded} XP`,
delay: "0.05s",
},
// One row per unlocked title (usually 0 or 1)
...titlesAwarded.map((t, i) => ({
key: `title-${t.id}`,
cls: "",
icon: "🏴‍☠️",
lbl: "Crew Title",
val: t.name,
delay: `${0.1 + i * 0.1}s`,
})),
// One row per awarded item
...itemsAwarded.map((inv, i) => ({
key: `item-${inv.id}`,
cls: "",
icon: "🎁",
lbl: inv.item.type ?? "Item",
val: inv.item.name,
delay: `${0.1 + (titlesAwarded.length + i) * 0.1}s`,
})),
]
: [];
const chestClass =
phase === "idle"
? "idle"
: phase === "shaking"
? "shake"
: phase === "opening"
? "opening"
: "";
const chestEmoji = phase === "opening" || phase === "revealed" ? "📬" : "📦";
return (
<div className="com-overlay" onClick={phase === "idle" ? tap : undefined}>
<style>{S}</style>
{/* Background */}
<div className="com-bg" />
{/* Stars */}
{STARS.map((s) => (
<div
key={s.id}
className="com-star"
style={
{
width: s.w,
height: s.w,
top: s.top,
left: s.left,
"--sdur": s.dur,
"--sdelay": s.delay,
} as React.CSSProperties
}
/>
))}
{/* Crepuscular rays */}
{(phase === "opening" || phase === "revealed") && (
<div className="com-rays">
{RAYS.map((r) => (
<div
key={r.id}
className="com-ray"
style={
{
"--angle": r.angle,
"--raydelay": r.delay,
} as React.CSSProperties
}
/>
))}
</div>
)}
{/* Burst rings */}
{(phase === "opening" || phase === "revealed") && (
<div className="com-burst">
{BURST_RINGS.map((r) => (
<div
key={r.id}
className="com-burst-ring"
style={
{
width: "100px",
height: "100px",
"--brs": r.size,
"--brdur": r.dur,
"--brdelay": r.delay,
} as React.CSSProperties
}
/>
))}
</div>
)}
{/* Particle explosion */}
{(phase === "opening" || phase === "revealed") && (
<>
{particles.map((p) => (
<div
key={p.id}
className="com-particle"
style={
{
width: p.w,
height: p.w,
background: p.color,
top: "50%",
left: "50%",
"--ptx": `${p.tx}px`,
"--pty": `${p.ty}px`,
"--prot": `${p.rot}deg`,
"--pdur": p.dur,
"--pdelay": p.delay,
} as React.CSSProperties
}
/>
))}
{coins.map((c) => (
<div
key={c.id}
className="com-coin"
style={
{
top: "50%",
left: "50%",
"--csize": c.size,
"--ctx": `${c.tx}px`,
"--cty": `${c.ty}px`,
"--crot": `${c.rot}deg`,
"--cdur": c.dur,
"--cdelay": c.delay,
} as React.CSSProperties
}
>
{c.emoji}
</div>
))}
</>
)}
{/* Floating sparkles in revealed state */}
{phase === "revealed" &&
SPARKLES.map((sp) => (
<div
key={sp.id}
className="com-sparkle"
style={
{
top: sp.top,
left: sp.left,
"--spsize": sp.size,
"--spdur": sp.dur,
"--spdelay": sp.delay,
} as React.CSSProperties
}
>
{sp.emoji}
</div>
))}
{/* XP blast — uses xp_awarded from claimResult */}
{showXP && (
<div className="com-xp-blast">
{xpAwarded > 0 ? `+${xpAwarded} XP` : "✨"}
</div>
)}
{/* Card */}
<div className="com-card" onClick={(e) => e.stopPropagation()}>
<div className="com-card-inner">
<p className="com-label">
{phase === "revealed" ? "⚓ Treasure Claimed" : "📦 Treasure Chest"}
</p>
{/* Chest */}
<div
className="com-chest-area"
onClick={phase === "idle" ? tap : undefined}
style={{ cursor: phase === "idle" ? "pointer" : "default" }}
>
{phase !== "revealed" && <div className="com-glow-pad" />}
{phase !== "revealed" && (
<div className="com-orbit">
<div className="com-orbit-dot" />
</div>
)}
<span className={`com-chest ${chestClass}`}>{chestEmoji}</span>
</div>
{/* Phase content */}
{phase === "idle" && (
<>
<p className="com-tap-title">Tap to Open!</p>
<p className="com-tap-sub">YOUR HARD WORK HAS PAID OFF, PIRATE</p>
</>
)}
{phase === "shaking" && (
<>
<p className="com-shake-text">The chest stirs...</p>
<p className="com-shake-dots"> </p>
</>
)}
{phase === "revealed" && (
<>
<p className="com-rewards-title"> Spoils of Victory</p>
<div className="com-rewards">
{/* claimResult not yet available — API still in flight */}
{!claimResult && (
<p className="com-rewards-loading">
Counting your spoils...
</p>
)}
{rewards.map((r) => (
<div
key={r.key}
className={`com-reward-row ${r.cls}`}
style={{ animationDelay: r.delay }}
>
<span className="com-reward-icon">{r.icon}</span>
<div>
<p className="com-reward-lbl">{r.lbl}</p>
<p className="com-reward-val">{r.val}</p>
</div>
</div>
))}
</div>
<button
className="com-cta"
style={{ animationDelay: `${rewards.length * 0.1}s` }}
onClick={onClose}
>
Set Sail
</button>
</>
)}
</div>
</div>
{/* Skip link */}
{phase === "revealed" && (
<p className="com-skip" onClick={onClose}>
tap anywhere to continue
</p>
)}
</div>
);
};

View File

@ -0,0 +1,184 @@
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@600;700&display=swap');
.cc-btn {
width: 100%;
background: white;
border: 2.5px solid #f3f4f6;
border-radius: 18px;
padding: 0.85rem 1rem;
text-align: left;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.2rem;
box-shadow: 0 3px 10px rgba(0,0,0,0.04);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, background 0.15s ease;
font-family: 'Nunito', sans-serif;
position: relative;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
.cc-btn:hover:not(.cc-selected) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0,0,0,0.07);
border-color: #e5e7eb;
}
.cc-btn:active {
transform: translateY(1px);
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
}
/* Selected state */
.cc-btn.cc-selected {
border-color: #c4b5fd;
background: #fdf4ff;
box-shadow: 0 6px 0 #e9d5ff, 0 8px 20px rgba(168,85,247,0.1);
}
/* Selected shimmer bar on left edge */
.cc-btn.cc-selected::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 4px;
background: linear-gradient(180deg, #a855f7, #7c3aed);
border-radius: 0 2px 2px 0;
}
/* Top row */
.cc-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.cc-label {
font-size: 0.9rem;
font-weight: 900;
color: #1e1b4b;
line-height: 1.2;
flex: 1;
transition: color 0.15s ease;
}
.cc-btn.cc-selected .cc-label { color: #7c3aed; }
/* Section badge */
.cc-section-badge {
font-size: 0.6rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
border-radius: 100px;
padding: 0.2rem 0.6rem;
flex-shrink: 0;
border: 2px solid transparent;
}
.cc-section-badge.ebrw {
background: #eff6ff;
border-color: #bfdbfe;
color: #2563eb;
}
.cc-section-badge.math {
background: #fff1f2;
border-color: #fecdd3;
color: #e11d48;
}
/* Sub label */
.cc-sublabel {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.75rem;
font-weight: 600;
color: #9ca3af;
line-height: 1.3;
padding-left: 0.05rem;
transition: color 0.15s ease;
}
.cc-btn.cc-selected .cc-sublabel { color: #a855f7; }
/* Checkmark */
.cc-check {
position: absolute;
top: 0.65rem;
right: 0.75rem;
width: 20px; height: 20px;
border-radius: 50%;
border: 2px solid #e5e7eb;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
transition: all 0.2s cubic-bezier(0.34,1.56,0.64,1);
background: white;
}
.cc-btn.cc-selected .cc-check {
background: #a855f7;
border-color: #a855f7;
transform: scale(1.1);
}
`;
let stylesInjected = false;
export const ChoiceCard = ({
label,
selected,
subLabel,
section,
onClick,
}: {
label: string;
selected?: boolean;
subLabel?: string;
section?: string;
onClick: () => void;
}) => {
if (!stylesInjected) {
const tag = document.createElement("style");
tag.textContent = STYLES;
document.head.appendChild(tag);
stylesInjected = true;
}
const sectionClass =
section === "EBRW"
? "ebrw"
: section === "Math" || section === "MATH"
? "math"
: "";
return (
<button
onClick={onClick}
className={`cc-btn${selected ? " cc-selected" : ""}`}
>
{/* Checkmark */}
<div className="cc-check">
{selected && (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path
d="M1.5 5L4 7.5L8.5 2.5"
stroke="white"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
{/* Top row: label + section badge */}
<div className="cc-top" style={{ paddingRight: "1.75rem" }}>
<span className="cc-label">{label}</span>
{section && (
<span className={`cc-section-badge ${sectionClass}`}>{section}</span>
)}
</div>
{/* Sub label */}
{subLabel && <span className="cc-sublabel">{subLabel}</span>}
</button>
);
};

View File

@ -0,0 +1,300 @@
import { useEffect, useState } from "react";
import { ConfettiBurst } from "./ConfettiBurst";
type Props = {
size?: number;
strokeWidth?: number;
previousXP: number;
gainedXP: number;
levelMinXP: number;
levelMaxXP: number;
level: number;
};
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600&display=swap');
.clp-wrap {
width: 100%;
font-family: 'Nunito', sans-serif;
}
/* Outer card — full width */
.clp-card {
width: 100%;
background: white;
border: 2.5px solid #f3f4f6;
border-radius: 24px;
padding: 1.25rem 1.5rem;
box-shadow: 0 6px 24px rgba(0,0,0,0.05);
display: flex;
flex-direction: column;
gap: 0.85rem;
box-sizing: border-box;
}
/* Top row: level badge + XP gained chip */
.clp-top-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.clp-level-badge {
display: flex;
align-items: center;
gap: 0.6rem;
}
.clp-level-bubble {
width: 52px; height: 52px;
border-radius: 50%;
background: linear-gradient(135deg, #c084fc, #a855f7);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 0 #7e22ce44;
flex-shrink: 0;
}
.clp-level-num {
font-size: 1.5rem;
font-weight: 900;
color: white;
line-height: 1;
letter-spacing: -0.02em;
}
.clp-level-text {
display: flex;
flex-direction: column;
gap: 1px;
}
.clp-level-word {
font-size: 0.62rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #9ca3af;
}
.clp-level-title {
font-size: 1rem;
font-weight: 900;
color: #1e1b4b;
line-height: 1;
}
/* XP gained chip */
.clp-xp-chip {
display: flex;
align-items: center;
gap: 0.35rem;
background: #fff7ed;
border: 2px solid #fed7aa;
border-radius: 100px;
padding: 0.4rem 0.9rem;
font-size: 0.82rem;
font-weight: 800;
color: #f97316;
}
/* Bar section */
.clp-bar-wrap {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.clp-bar-labels {
display: flex;
justify-content: space-between;
font-size: 0.66rem;
font-weight: 700;
color: #9ca3af;
}
.clp-bar-track {
width: 100%;
height: 12px;
background: #f3f4f6;
border-radius: 100px;
overflow: hidden;
}
.clp-bar-fill {
height: 100%;
border-radius: 100px;
background: linear-gradient(90deg, #c084fc, #f97316);
transition: width 1.2s cubic-bezier(0.4,0,0.2,1);
}
/* XP total */
.clp-xp-pill {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
font-weight: 700;
color: #9ca3af;
animation: clpFadeUp 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
}
.clp-xp-pill .xp-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: #f97316;
flex-shrink: 0;
}
/* Level-up banner */
.clp-levelup {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: #fdf4ff;
border: 2.5px solid #e9d5ff;
border-radius: 14px;
padding: 0.6rem 1rem;
font-size: 0.85rem;
font-weight: 900;
color: #9333ea;
animation: clpPop 0.45s cubic-bezier(0.34,1.56,0.64,1) both;
box-shadow: 0 4px 12px rgba(147,51,234,0.1);
}
@keyframes clpPop {
from { opacity:0; transform: scale(0.8); }
to { opacity:1; transform: scale(1); }
}
@keyframes clpFadeUp {
from { opacity:0; transform: translateY(6px); }
to { opacity:1; transform: translateY(0); }
}
`;
export const CircularLevelProgress = ({
previousXP,
gainedXP,
levelMinXP,
levelMaxXP,
level,
}: Props) => {
const levelRange = levelMaxXP - levelMinXP;
const normalize = (xp: number) =>
Math.min(Math.max(xp - levelMinXP, 0), levelRange) / levelRange;
const [barProgress, setBarProgress] = useState(normalize(previousXP));
const [currentLevel, setCurrentLevel] = useState(level);
const [showLevelUp, setShowLevelUp] = useState(false);
const [showXPTotal, setShowXPTotal] = useState(false);
useEffect(() => {
let animationFrame: number;
let start: number | null = null;
const availableXP = previousXP + gainedXP;
const crossesLevel = availableXP >= levelMaxXP;
const phase1Target = crossesLevel ? 1 : normalize(availableXP);
const leftoverXP = crossesLevel ? availableXP - levelMaxXP : 0;
const duration = 1200;
const animatePhase1 = (timestamp: number) => {
if (!start) start = timestamp;
const t = Math.min((timestamp - start) / duration, 1);
setBarProgress(
normalize(previousXP) + t * (phase1Target - normalize(previousXP)),
);
if (t < 1) {
animationFrame = requestAnimationFrame(animatePhase1);
} else if (crossesLevel) {
setShowLevelUp(true);
setTimeout(startPhase2, 1200);
} else {
setShowXPTotal(true);
}
};
const startPhase2 = () => {
start = null;
setShowLevelUp(false);
setCurrentLevel((l) => l + 1);
setBarProgress(0);
const target = Math.min(leftoverXP / levelRange, 1);
const animatePhase2 = (timestamp: number) => {
if (!start) start = timestamp;
const t = Math.min((timestamp - start) / duration, 1);
setBarProgress(t * target);
if (t < 1) {
animationFrame = requestAnimationFrame(animatePhase2);
} else {
setShowXPTotal(true);
}
};
animationFrame = requestAnimationFrame(animatePhase2);
};
animationFrame = requestAnimationFrame(animatePhase1);
return () => cancelAnimationFrame(animationFrame);
}, []);
const barPct = Math.round(barProgress * 100);
const totalXP = previousXP + gainedXP;
return (
<div className="clp-wrap">
<style>{STYLES}</style>
{showLevelUp && <ConfettiBurst />}
<div className="clp-card">
{/* Top row */}
<div className="clp-top-row">
<div className="clp-level-badge">
<div className="clp-level-bubble">
<span className="clp-level-num">{currentLevel}</span>
</div>
<div className="clp-level-text">
<span className="clp-level-word">Current Level</span>
<span className="clp-level-title">Level {currentLevel}</span>
</div>
</div>
<div className="clp-xp-chip"> +{gainedXP} XP</div>
</div>
{/* Progress bar */}
<div className="clp-bar-wrap">
<div className="clp-bar-labels">
<span>{levelMinXP} XP</span>
<span>{barPct}%</span>
<span>{levelMaxXP} XP</span>
</div>
<div className="clp-bar-track">
<div className="clp-bar-fill" style={{ width: `${barPct}%` }} />
</div>
</div>
{/* Footer state */}
{showLevelUp && (
<div className="clp-levelup">
🎉 You leveled up! Welcome to Level {currentLevel}!
</div>
)}
{showXPTotal && !showLevelUp && (
<div className="clp-xp-pill">
<div className="xp-dot" />
Total XP:{" "}
<strong style={{ color: "#1e1b4b", marginLeft: 3 }}>
{totalXP}
</strong>
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,73 @@
type CircularProgressProps = {
value: number;
min?: number;
max?: number;
size?: number;
strokeWidth?: number;
};
export function CircularProgress({
value,
min = 0,
max = 100,
size = 80,
strokeWidth = 6,
}: CircularProgressProps) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
// normalize value to 01
const normalized = max === min ? 0 : (value - min) / (max - min);
// clamp between 0 and 1
const clamped = Math.min(1, Math.max(0, normalized));
const offset = circumference * (1 - clamped);
return (
<svg width={size} height={size}>
{/* background */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#fff"
strokeOpacity={0.5}
strokeWidth={strokeWidth}
fill="transparent"
/>
{/* progress */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#fff"
strokeWidth={strokeWidth}
fill="transparent"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
style={{
transform: "rotate(-90deg)",
transformOrigin: "50% 50%",
transition: "stroke-dashoffset 0.4s ease",
}}
/>
{/* label */}
<text
x="50%"
y="50%"
textAnchor="middle"
dy=".3em"
fontSize="24"
fontWeight="600"
fontFamily="Satoshi"
color="#fff"
>
{value}
</text>
</svg>
);
}

View File

@ -0,0 +1,44 @@
import { useEffect } from "react";
type ConfettiBurstProps = {
count?: number;
};
export const ConfettiBurst = ({ count = 30 }: ConfettiBurstProps) => {
useEffect(() => {
const timeout = setTimeout(() => {
const container = document.getElementById("confetti-container");
if (container) container.innerHTML = "";
}, 1200);
return () => clearTimeout(timeout);
}, []);
return (
<div
id="confetti-container"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
{Array.from({ length: count }).map((_, i) => (
<span
key={i}
className="confetti"
style={{
left: `${Math.random() * 100}%`,
backgroundColor: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
animationDelay: `${Math.random() * 0.2}s`,
transform: `rotate(${Math.random() * 360}deg)`,
}}
/>
))}
</div>
);
};
const CONFETTI_COLORS = [
"#a855f7", // purple
"#6366f1", // indigo
"#ec4899", // pink
"#22c55e", // green
"#facc15", // yellow
];

View File

@ -0,0 +1,199 @@
// lessonRegistry.tsx
import { lazy, type ComponentType } from "react";
// lessonTypes.ts
export type LessonId =
// ---- EBRW ----
| "ebrw-main-idea"
| "ebrw-explicit-meaning"
| "ebrw-inferences"
| "ebrw-graphic-displays"
| "ebrw-craft-structure"
| "ebrw-vocab-precise"
| "ebrw-vocab-meaning"
| "ebrw-expression-ideas"
| "ebrw-transitions"
| "ebrw-commas"
| "ebrw-semicolons-colons"
| "ebrw-dashes-apostrophes"
| "ebrw-subject-verb"
| "ebrw-pronouns"
| "ebrw-verbs"
| "ebrw-sentence-structure"
// ---- MATH ----
| "alg-linear-eq-1var"
| "alg-linear-eq-2var"
| "alg-linear-functions"
| "alg-systems"
| "alg-linear-inequalities"
| "adv-equivalent-expr"
| "adv-nonlinear-eq"
| "adv-systems-2var"
| "adv-nonlinear-func"
| "data-ratios-rates"
| "data-percentages"
| "data-one-var"
| "data-two-var"
| "data-probability"
| "data-sample-stats"
| "data-eval-claims"
| "geom-area-volume"
| "geom-lines-angles"
| "geom-right-tri-trig"
| "geom-circles";
// ---- EBRW ----
const EBRWMainIdea = lazy(
() => import("../pages/student/lessons/EBRWMainIdeaLesson"),
);
const EBRWExplicitMeaning = lazy(
() => import("../pages/student/lessons/EBRWExplicitMeaningLesson"),
);
const EBRWInferences = lazy(
() => import("../pages/student/lessons/EBRWInferencesLesson"),
);
const EBRWGraphicDisplays = lazy(
() => import("../pages/student/lessons/EBRWGraphicDisplaysLesson"),
);
const EBRWCraftStructure = lazy(
() => import("../pages/student/lessons/EBRWCraftStructureLesson"),
);
const EBRWVocabPrecise = lazy(
() => import("../pages/student/lessons/EBRWVocabPreciseLesson"),
);
const EBRWVocabMeaning = lazy(
() => import("../pages/student/lessons/EBRWVocabMeaningLesson"),
);
const EBRWExpressionIdeas = lazy(
() => import("../pages/student/lessons/EBRWExpressionIdeasLesson"),
);
const EBRWTransitions = lazy(
() => import("../pages/student/lessons/EBRWTransitionsLesson"),
);
const EBRWCommas = lazy(
() => import("../pages/student/lessons/EBRWCommasLesson"),
);
const EBRWSemicolonsColons = lazy(
() => import("../pages/student/lessons/EBRWSemicolonsColonsLesson"),
);
const EBRWDashesApostrophes = lazy(
() => import("../pages/student/lessons/EBRWDashesApostrophesLesson"),
);
const EBRWSubjectVerb = lazy(
() => import("../pages/student/lessons/EBRWSubjectVerbLesson"),
);
const EBRWPronouns = lazy(
() => import("../pages/student/lessons/EBRWPronounsLesson"),
);
const EBRWVerbs = lazy(
() => import("../pages/student/lessons/EBRWVerbsLesson"),
);
const EBRWSentenceStructure = lazy(
() => import("../pages/student/lessons/EBRWSentenceStructureLesson"),
);
// ---- MATH ----
const AlgLinearEq1Var = lazy(
() => import("../pages/student/lessons/LinearEq1VarLesson"),
);
const AlgLinearEq2Var = lazy(
() => import("../pages/student/lessons/LinearEq2VarLesson"),
);
const AlgLinearFunctions = lazy(
() => import("../pages/student/lessons/LinearFunctionsLesson"),
);
const AlgSystems = lazy(
() => import("../pages/student/lessons/SystemsEquationsLesson"),
);
const AlgLinearInequalities = lazy(
() => import("../pages/student/lessons/LinearInequalitiesLesson"),
);
const AdvEquivalentExpr = lazy(
() => import("../pages/student/lessons/EquivalentExpressionsLesson"),
);
const AdvNonlinearEq = lazy(
() => import("../pages/student/lessons/NonlinearEq1VarLesson"),
);
const AdvSystems2Var = lazy(
() => import("../pages/student/lessons/SystemsEq2VarLesson"),
);
const AdvNonlinearFunc = lazy(
() => import("../pages/student/lessons/NonlinearFunctionsLesson"),
);
const DataRatiosRates = lazy(
() => import("../pages/student/lessons/RatiosRatesLesson"),
);
const DataPercentages = lazy(
() => import("../pages/student/lessons/PercentagesLesson"),
);
const DataOneVar = lazy(
() => import("../pages/student/lessons/OneVariableDataLesson"),
);
const DataTwoVar = lazy(
() => import("../pages/student/lessons/TwoVariableDataLesson"),
);
const DataProbability = lazy(
() => import("../pages/student/lessons/ProbabilityLesson"),
);
const DataSampleStats = lazy(
() => import("../pages/student/lessons/SampleStatsLesson"),
);
const DataEvalClaims = lazy(
() => import("../pages/student/lessons/EvalStatisticalClaimsLesson"),
);
const GeomAreaVolume = lazy(
() => import("../pages/student/lessons/AreaVolumeLesson"),
);
const GeomLinesAngles = lazy(
() => import("../pages/student/lessons/LinesAnglesLesson"),
);
const GeomRightTriTrig = lazy(
() => import("../pages/student/lessons/RightTrianglesTrigLesson"),
);
const GeomCircles = lazy(
() => import("../pages/student/lessons/CirclesLesson"),
);
// ---- Registry Map ----
export const LESSON_COMPONENT_MAP: Record<LessonId, ComponentType> = {
// EBRW
"ebrw-main-idea": EBRWMainIdea,
"ebrw-explicit-meaning": EBRWExplicitMeaning,
"ebrw-inferences": EBRWInferences,
"ebrw-graphic-displays": EBRWGraphicDisplays,
"ebrw-craft-structure": EBRWCraftStructure,
"ebrw-vocab-precise": EBRWVocabPrecise,
"ebrw-vocab-meaning": EBRWVocabMeaning,
"ebrw-expression-ideas": EBRWExpressionIdeas,
"ebrw-transitions": EBRWTransitions,
"ebrw-commas": EBRWCommas,
"ebrw-semicolons-colons": EBRWSemicolonsColons,
"ebrw-dashes-apostrophes": EBRWDashesApostrophes,
"ebrw-subject-verb": EBRWSubjectVerb,
"ebrw-pronouns": EBRWPronouns,
"ebrw-verbs": EBRWVerbs,
"ebrw-sentence-structure": EBRWSentenceStructure,
// MATH
"alg-linear-eq-1var": AlgLinearEq1Var,
"alg-linear-eq-2var": AlgLinearEq2Var,
"alg-linear-functions": AlgLinearFunctions,
"alg-systems": AlgSystems,
"alg-linear-inequalities": AlgLinearInequalities,
"adv-equivalent-expr": AdvEquivalentExpr,
"adv-nonlinear-eq": AdvNonlinearEq,
"adv-systems-2var": AdvSystems2Var,
"adv-nonlinear-func": AdvNonlinearFunc,
"data-ratios-rates": DataRatiosRates,
"data-percentages": DataPercentages,
"data-one-var": DataOneVar,
"data-two-var": DataTwoVar,
"data-probability": DataProbability,
"data-sample-stats": DataSampleStats,
"data-eval-claims": DataEvalClaims,
"geom-area-volume": GeomAreaVolume,
"geom-lines-angles": GeomLinesAngles,
"geom-right-tri-trig": GeomRightTriTrig,
"geom-circles": GeomCircles,
};

View File

@ -0,0 +1,825 @@
import { useEffect, useRef, useState } from "react";
import { ChevronDown, ChevronRight, Gauge, Map } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "../stores/authStore";
import {
useQuestStore,
getQuestSummary,
getCrewRank,
} from "../stores/useQuestStore";
import type {
QuestNode,
QuestArc,
ClaimedRewardResponse,
} from "../types/quest";
import { CREW_RANKS } from "../types/quest";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Drawer, DrawerContent, DrawerTrigger } from "./ui/drawer";
import { PredictedScoreCard } from "./PredictedScoreCard";
import { ChestOpenModal } from "./ChestOpenModal";
// Re-use the same theme generator that QuestMap uses so arc colours are consistent
import { generateArcTheme } from "../pages/student/QuestMap";
import { InventoryButton } from "./InventoryButton";
// ─── Requirement helpers (mirrors QuestMap) ───────────────────────────────────
const REQ_EMOJI: Record<string, string> = {
questions: "❓",
accuracy: "🎯",
streak: "🔥",
sessions: "📚",
topics: "🗺️",
xp: "⚡",
leaderboard: "🏆",
};
const REQ_LABEL: Record<string, string> = {
questions: "questions answered",
accuracy: "% accuracy",
streak: "day streak",
sessions: "sessions",
topics: "topics covered",
xp: "XP earned",
leaderboard: "leaderboard rank",
};
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&family=Cinzel:wght@700;900&display=swap');
/* ════ SHARED ANIMATION ════ */
@keyframes hcIn {
from { opacity:0; transform:translateY(10px) scale(0.97); }
to { opacity:1; transform:translateY(0) scale(1); }
}
/* ════ WHITE CARD (DEFAULT / LEVEL / QUEST_COMPACT) ════ */
.hc-card {
background: white;
border: 2.5px solid #f3f4f6;
border-radius: 26px;
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
overflow: hidden;
animation: hcIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
}
/* Identity */
.hc-top {
display: flex; align-items: center;
justify-content: space-between; gap: 0.75rem;
padding: 1.1rem 1.2rem 0.9rem;
}
.hc-identity { display: flex; align-items: center; gap: 0.7rem; flex: 1; min-width: 0; }
.hc-av-wrap { position: relative; flex-shrink: 0; }
.hc-av-pip {
position: absolute; bottom: -3px; right: -3px;
min-width: 18px; height: 18px; border-radius: 9px; padding: 0 4px;
background: linear-gradient(135deg, #a855f7, #7c3aed);
border: 2px solid white;
display: flex; align-items: center; justify-content: center;
font-family: 'Nunito', sans-serif;
font-size: 0.55rem; font-weight: 900; color: white;
}
.hc-nameblock { flex: 1; min-width: 0; }
.hc-greeting {
font-family: 'Nunito', sans-serif;
font-size: 0.98rem; font-weight: 900; color: #1e1b4b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2;
}
.hc-greeting em { font-style: normal; color: #a855f7; }
.hc-role {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.63rem; font-weight: 700; letter-spacing: 0.09em;
text-transform: uppercase; color: #9ca3af; margin-top: 0.05rem;
}
.hc-score-btn {
display: flex; align-items: center; gap: 0.3rem;
background: #f7ffe4; border: 2px solid #d9f99d; border-radius: 100px;
padding: 0.42rem 0.72rem; font-family: 'Nunito', sans-serif;
font-size: 0.76rem; font-weight: 800; color: #65a30d;
cursor: pointer; flex-shrink: 0;
transition: transform 0.15s, box-shadow 0.15s;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.hc-score-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.07); }
.hc-sep { height: 1px; margin: 0 1.2rem; background: #f3f4f6; }
/* XP bar */
.hc-xp-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.85rem 1.2rem; }
.hc-lvl-tag {
font-family: 'Nunito', sans-serif; font-size: 0.7rem; font-weight: 900;
color: #a855f7; flex-shrink: 0; background: #f3e8ff;
border-radius: 8px; padding: 0.22rem 0.5rem; white-space: nowrap;
}
.hc-bar-wrap { flex: 1; display: flex; flex-direction: column; gap: 0.22rem; }
.hc-track { height: 8px; background: #f3f4f6; border-radius: 100px; overflow: hidden; }
.hc-fill {
height: 100%; border-radius: 100px;
background: linear-gradient(90deg, #a855f7, #f97316);
transition: width 1.1s cubic-bezier(0.34,1.56,0.64,1);
position: relative; overflow: hidden;
}
.hc-fill::after {
content: ''; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
transform: translateX(-100%);
animation: hcShimmer 2.6s ease-in-out 1s infinite;
}
@keyframes hcShimmer { to { transform: translateX(200%); } }
.hc-xp-label {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.6rem; font-weight: 700; color: #9ca3af;
display: flex; justify-content: space-between;
}
.hc-xp-label span:first-child { color: #a855f7; font-weight: 900; }
/* Rank row (compact) */
.hc-rank-row {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.75rem 1.2rem; cursor: pointer;
transition: background 0.15s; border-top: 1px solid #f3f4f6;
}
.hc-rank-row:first-child { border-top: none; }
.hc-rank-row:hover { background: #fafafa; }
.hc-rank-emoji { font-size: 1.15rem; flex-shrink: 0; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1)); }
.hc-rank-text { flex: 1; min-width: 0; }
.hc-rank-name {
font-family: 'Cinzel', serif; font-size: 0.8rem; font-weight: 700; color: #1e1b4b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.hc-rank-progress-label {
font-family: 'Nunito Sans', sans-serif; font-size: 0.58rem; font-weight: 700;
color: #9ca3af; margin-top: 0.08rem;
}
.hc-rank-right { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
.hc-streak-pill {
display: flex; align-items: center; gap: 0.22rem;
background: #fff5f5; border: 1.5px solid #fecaca; border-radius: 100px;
padding: 0.2rem 0.5rem; font-family: 'Nunito', sans-serif;
font-size: 0.7rem; font-weight: 900; color: #ef4444;
}
.hc-chest-badge {
display: flex; align-items: center; gap: 0.18rem;
background: #fef3c7; border: 1.5px solid #fde68a; border-radius: 100px;
padding: 0.2rem 0.5rem; font-family: 'Nunito', sans-serif;
font-size: 0.7rem; font-weight: 900; color: #b45309;
animation: hcPop 1.8s ease-in-out infinite;
}
@keyframes hcPop { 0%,100%{transform:scale(1);} 50%{transform:scale(1.07);} }
.hc-chevron { color: #d1d5db; transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), color 0.2s; }
.hc-chevron.open { transform: rotate(180deg); color: #a855f7; }
/* Collapsible quest panel */
.hc-quests-wrap {
overflow: hidden; max-height: 0;
transition: max-height 0.38s cubic-bezier(0.4,0,0.2,1);
background: #fafafa; border-top: 1px solid #f3f4f6;
}
.hc-quests-wrap.open { max-height: 480px; }
.hc-quest-list { display: flex; flex-direction: column; padding: 0.35rem 0; }
.hc-quest-row {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.65rem 1.2rem; cursor: pointer; transition: background 0.13s; position: relative;
}
.hc-quest-row:hover { background: #f3f4f6; }
.hc-quest-row::before {
content: ''; position: absolute; left: 0; top: 20%; bottom: 20%;
width: 3px; border-radius: 0 3px 3px 0; background: var(--ac);
}
.hc-q-icon {
width: 34px; height: 34px; border-radius: 10px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 1rem; background: white; border: 1.5px solid #f3f4f6;
transition: transform 0.15s;
}
.hc-quest-row:hover .hc-q-icon { transform: scale(1.08) rotate(-4deg); }
.hc-q-icon.claimable { background: #fef3c7; border-color: #fde68a; animation: hcWiggle 2s ease-in-out infinite; }
@keyframes hcWiggle { 0%,100%{transform:rotate(0);} 30%{transform:rotate(-7deg) scale(1.05);} 70%{transform:rotate(7deg) scale(1.05);} }
.hc-q-body { flex: 1; min-width: 0; }
.hc-q-name { font-family: 'Nunito', sans-serif; font-size: 0.8rem; font-weight: 800; color: #1e1b4b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hc-q-sub { font-family: 'Nunito Sans', sans-serif; font-size: 0.62rem; font-weight: 600; color: #9ca3af; margin-top: 0.1rem; }
.hc-q-claimable { font-family: 'Nunito Sans', sans-serif; font-size: 0.62rem; font-weight: 700; color: #d97706; margin-top: 0.1rem; }
.hc-claim-btn {
padding: 0.28rem 0.62rem; border: none; border-radius: 100px; cursor: pointer;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
font-family: 'Nunito', sans-serif; font-size: 0.65rem; font-weight: 900; color: #1a0800;
box-shadow: 0 2px 0 #d97706; flex-shrink: 0; transition: all 0.12s;
}
.hc-claim-btn:hover { transform: translateY(-1px); }
.hc-claim-btn:active { transform: translateY(1px); }
.hc-empty { padding: 1rem 1.2rem; text-align: center; font-family: 'Nunito', sans-serif; font-size: 0.82rem; font-weight: 700; color: #9ca3af; }
.hc-map-link {
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
padding: 0.6rem 1.2rem; border-top: 1px solid #f3f4f6;
cursor: pointer; transition: background 0.13s;
font-family: 'Nunito', sans-serif; font-size: 0.7rem; font-weight: 800; color: #a855f7;
}
.hc-map-link:hover { background: #fdf4ff; }
/* ════ DARK OCEAN CARD (QUEST_EXTENDED) ════ */
.hc-ext {
background: linear-gradient(160deg, #0b1a35 0%, #060e1f 55%, #0d1530 100%);
border-radius: 26px; overflow: hidden; position: relative;
box-shadow: 0 8px 32px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06);
animation: hcIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
margin-bottom: 12px;
}
.hc-ext::before {
content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
background:
repeating-linear-gradient(105deg, transparent 55%, rgba(56,189,248,0.018) 56%, transparent 57%),
repeating-linear-gradient(75deg, transparent 70%, rgba(56,189,248,0.012) 71%, transparent 72%);
background-size: 320% 320%, 260% 260%;
animation: hcExtSea 14s ease-in-out infinite alternate;
}
@keyframes hcExtSea {
0% { background-position: 0% 0%, 100% 0%; }
100% { background-position: 100% 100%, 0% 100%; }
}
.hc-ext::after {
content: ''; position: absolute; top: -40px; right: -30px; z-index: 0;
width: 180px; height: 180px; border-radius: 50%;
background: radial-gradient(circle, rgba(251,191,36,0.1), transparent 70%);
pointer-events: none;
}
.hc-ext-header {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.2rem 0.3rem;
}
.hc-ext-title {
font-family: 'Cinzel', serif; font-size: 0.6rem; font-weight: 700;
letter-spacing: 0.2em; text-transform: uppercase; color: rgba(251,191,36,0.65);
}
.hc-ext-earned {
font-family: 'Nunito', sans-serif; font-size: 0.7rem; font-weight: 900;
color: #fbbf24; background: rgba(251,191,36,0.1);
border: 1px solid rgba(251,191,36,0.18); border-radius: 100px;
padding: 0.2rem 0.6rem;
}
.hc-ext-scroll {
position: relative; z-index: 2;
overflow-x: auto; overflow-y: hidden;
-webkit-overflow-scrolling: touch; scrollbar-width: none;
cursor: grab; padding: 1.0rem 1.0rem 0.8rem;
}
.hc-ext-scroll::-webkit-scrollbar { display: none; }
.hc-ext-scroll:active { cursor: grabbing; }
.hc-ext-inner {
display: flex; align-items: flex-end;
position: relative;
height: 110px;
}
.hc-ext-baseline {
position: absolute;
top: 56px; left: 26px; right: 26px; height: 2px;
background: rgba(255,255,255,0.07);
border-radius: 2px; z-index: 0;
}
.hc-ext-progress-line {
position: absolute;
top: 56px; left: 26px; height: 2px;
background: linear-gradient(90deg, #fbbf24, #f59e0b);
box-shadow: 0 0 10px rgba(251,191,36,0.5);
border-radius: 2px; z-index: 1;
transition: width 1.2s cubic-bezier(0.34,1.56,0.64,1);
}
.hc-ext-ship-wrap {
position: absolute;
top: 25px; z-index: 10; pointer-events: none;
display: flex; flex-direction: column; align-items: center; gap: 0px;
transition: left 1.2s cubic-bezier(0.34,1.56,0.64,1);
transform: translateX(-50%);
}
.hc-ext-ship {
font-size: 1.5rem;
filter: drop-shadow(0 2px 12px rgba(251,191,36,0.6));
animation: hcShipBob 2.8s ease-in-out infinite;
display: block;
}
@keyframes hcShipBob {
0%,100% { transform: translateY(0) rotate(-3deg); }
50% { transform: translateY(-6px) rotate(3deg); }
}
.hc-ext-ship-tether {
width: 1px; height: 14px;
background: linear-gradient(to bottom, rgba(251,191,36,0.5), transparent);
}
.hc-ext-col {
display: flex; flex-direction: column; align-items: center;
position: relative; z-index: 2;
width: 88px; flex-shrink: 0;
}
.hc-ext-col:first-child,
.hc-ext-col:last-child { width: 52px; }
.hc-ext-node {
width: 52px; height: 52px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem; position: relative; z-index: 2;
margin-top: 42px;
}
.hc-ext-node.reached {
background: linear-gradient(145deg, #1e0e4a, #3730a3);
border: 2px solid rgba(251,191,36,0.45);
box-shadow: 0 0 18px rgba(251,191,36,0.2), 0 4px 0 rgba(20,10,50,0.7);
}
.hc-ext-node.current {
background: linear-gradient(145deg, #6d28d9, #a855f7);
border: 2.5px solid #fbbf24;
box-shadow:
0 0 0 4px rgba(251,191,36,0.12),
0 0 22px rgba(168,85,247,0.45),
0 4px 0 rgba(80,30,150,0.5);
animation: hcExtNodePulse 2.2s ease-in-out infinite;
}
@keyframes hcExtNodePulse {
0%,100% { box-shadow: 0 0 0 4px rgba(251,191,36,0.12), 0 0 22px rgba(168,85,247,0.45), 0 4px 0 rgba(80,30,150,0.5); }
50% { box-shadow: 0 0 0 7px rgba(251,191,36,0.06), 0 0 30px rgba(168,85,247,0.6), 0 4px 0 rgba(80,30,150,0.5); }
}
.hc-ext-node.locked {
background: rgba(0,0,0,0.4);
border: 2px solid rgba(255,255,255,0.09);
filter: grayscale(0.7) opacity(0.45);
}
.hc-ext-label {
margin-top: 7px;
display: flex; flex-direction: column; align-items: center; gap: 2px;
}
.hc-ext-label-name {
font-family: 'Cinzel', serif; font-size: 0.48rem; font-weight: 700;
text-align: center; line-height: 1.3; letter-spacing: 0.03em; max-width: 70px;
}
.hc-ext-label-name.reached { color: #fbbf24; }
.hc-ext-label-name.current { color: #c084fc; }
.hc-ext-label-name.locked { color: rgba(255,255,255,0.2); }
.hc-ext-label-xp {
font-family: 'Nunito Sans', sans-serif; font-size: 0.42rem; font-weight: 700;
text-align: center;
}
.hc-ext-label-xp.reached { color: rgba(251,191,36,0.4); }
.hc-ext-label-xp.current { color: rgba(192,132,252,0.6); }
.hc-ext-label-xp.locked { color: rgba(255,255,255,0.15); }
.hc-ext-footer {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
padding: 0.5rem 1.2rem 0.85rem; margin-top: 0.2rem;
border-top: 1px solid rgba(255,255,255,0.06);
cursor: pointer; transition: opacity 0.15s;
font-family: 'Nunito', sans-serif; font-size: 0.68rem; font-weight: 800;
color: rgba(251,191,36,0.55); letter-spacing: 0.04em;
}
.hc-ext-footer:hover { opacity: 0.75; }
`;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getActiveQuests(arcs: QuestArc[]) {
const out: { node: QuestNode; arc: QuestArc }[] = [];
for (const arc of arcs)
for (const node of arc.nodes)
if (node.status === "claimable" || node.status === "active")
out.push({ node, arc });
// Claimable nodes bubble to the top
out.sort((a, b) =>
a.node.status === "claimable" && b.node.status !== "claimable"
? -1
: b.node.status === "claimable" && a.node.status !== "claimable"
? 1
: 0,
);
return out.slice(0, 2);
}
const SEG_W = 88;
const EDGE_W = 52;
function nodeX(i: number, total: number): number {
if (i === 0) return EDGE_W / 2;
if (i === total - 1) return EDGE_W / 2 + SEG_W * (total - 2) + EDGE_W / 2;
return EDGE_W + SEG_W * (i - 1) + SEG_W / 2;
}
// ─── QUEST_EXTENDED sub-component ────────────────────────────────────────────
const RankLadder = ({
earnedXP,
}: {
earnedXP: number;
onViewAll: () => void;
}) => {
const scrollRef = useRef<HTMLDivElement>(null);
const ladder = [...CREW_RANKS] as typeof CREW_RANKS;
const N = ladder.length;
let currentIdx = 0;
for (let i = N - 1; i >= 0; i--) {
if (earnedXP >= ladder[i].xpRequired) {
currentIdx = i;
break;
}
}
const current = ladder[currentIdx];
const nextRank = ladder[currentIdx + 1] ?? null;
const progressToNext = nextRank
? Math.min(
1,
(earnedXP - current.xpRequired) /
(nextRank.xpRequired - current.xpRequired),
)
: 1;
const shipX = nextRank
? nodeX(currentIdx, N) +
(nodeX(currentIdx + 1, N) - nodeX(currentIdx, N)) * progressToNext
: nodeX(currentIdx, N);
const progressLineW = shipX;
const totalW = EDGE_W + SEG_W * (N - 2) + EDGE_W;
const [animated, setAnimated] = useState(false);
useEffect(() => {
const id = requestAnimationFrame(() =>
requestAnimationFrame(() => setAnimated(true)),
);
return () => cancelAnimationFrame(id);
}, []);
useEffect(() => {
if (!scrollRef.current) return;
const el = scrollRef.current;
el.scrollTo({
left: Math.max(0, shipX - el.offsetWidth / 2),
behavior: "smooth",
});
}, [shipX]);
const rankPct = nextRank ? Math.round(progressToNext * 100) : 100;
const nextLabel = nextRank
? `${rankPct}% · ${nextRank.xpRequired - earnedXP} XP to ${nextRank.label}`
: "Maximum rank achieved";
return (
<div className="hc-ext">
<div className="hc-ext-header">
<span className="hc-ext-title"> Crew Rank</span>
<span className="hc-ext-earned">{earnedXP.toLocaleString()} XP</span>
</div>
<div
style={{
position: "relative",
zIndex: 2,
padding: "0 1.2rem 0.1rem",
display: "flex",
alignItems: "baseline",
gap: "0.4rem",
}}
>
<span
style={{
fontFamily: "'Cinzel', serif",
fontSize: "1.05rem",
fontWeight: 900,
color: "#fbbf24",
textShadow: "0 0 18px rgba(251,191,36,0.4)",
}}
>
{current.emoji} {current.label}
</span>
<span
style={{
fontFamily: "'Nunito Sans', sans-serif",
fontSize: "0.6rem",
fontWeight: 700,
color: "rgba(255,255,255,0.3)",
}}
>
{nextLabel}
</span>
</div>
<div className="hc-ext-scroll" ref={scrollRef}>
<div className="hc-ext-inner" style={{ width: totalW }}>
<div className="hc-ext-baseline" />
<div
className="hc-ext-progress-line"
style={{ width: animated ? progressLineW : 26 }}
/>
<div
className="hc-ext-ship-wrap"
style={{ left: animated ? shipX : nodeX(0, N) }}
>
<span className="hc-ext-ship" role="img" aria-label="ship">
</span>
<div className="hc-ext-ship-tether" />
</div>
{ladder.map((r, i) => {
const state =
i < currentIdx
? "reached"
: i === currentIdx
? "current"
: "locked";
return (
<div key={r.id} className="hc-ext-col">
<div className={`hc-ext-node ${state}`}>{r.emoji}</div>
<div className="hc-ext-label">
<span className={`hc-ext-label-name ${state}`}>
{r.label}
</span>
<span className={`hc-ext-label-xp ${state}`}>
{r.xpRequired === 0
? "Start"
: `${r.xpRequired.toLocaleString()} XP`}
</span>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
// ─── Props ────────────────────────────────────────────────────────────────────
type Mode = "DEFAULT" | "LEVEL" | "QUEST_COMPACT" | "QUEST_EXTENDED";
interface Props {
onViewAll?: () => void;
mode?: Mode;
}
// ─── Main component ───────────────────────────────────────────────────────────
export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
// Select all needed store slices — earnedXP and earnedTitles are now first-class state
const arcs = useQuestStore((s) => s.arcs);
const earnedXP = user?.total_xp ?? 0;
const earnedTitles = useQuestStore((s) => s.earnedTitles);
const claimNode = useQuestStore((s) => s.claimNode);
// Updated signatures: getQuestSummary needs earnedXP + earnedTitles,
// getCrewRank takes earnedXP directly (no longer iterates nodes)
const summary = getQuestSummary(arcs, earnedXP, earnedTitles);
const rank = getCrewRank(earnedXP);
const activeQuests = getActiveQuests(arcs);
const u = user as any;
const level = u?.current_level ?? 1;
const totalXP = u?.total_xp ?? 5;
const levelStart = u?.current_level_start ?? u?.level_min_xp ?? 0;
const levelEnd =
u?.next_level_threshold ?? u?.level_max_xp ?? levelStart + 1000;
const streak = u?.streak ?? u?.current_streak ?? 0;
const firstName = user?.name?.split(" ")[0] || "there";
const roleLabel =
u?.role === "ADMIN"
? "Admin"
: u?.role === "TEACHER"
? "Teacher"
: "Student";
const hour = new Date().getHours();
const timeLabel = hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening";
const levelRange = Math.max(levelEnd - levelStart, 1);
const xpIntoLevel = Math.max(totalXP - levelStart, 0);
const rawPct = Math.min(Math.round((xpIntoLevel / levelRange) * 100), 100);
const xpToGo = Math.max(levelEnd - totalXP, 0);
const [barPct, setBarPct] = useState(0);
useEffect(() => {
const id = requestAnimationFrame(() =>
requestAnimationFrame(() => setBarPct(rawPct)),
);
return () => cancelAnimationFrame(id);
}, [rawPct]);
const [open, setOpen] = useState(false);
const [claimingNode, setClaimingNode] = useState<{
node: QuestNode;
arcId: string;
} | null>(null);
// Holds the API response from the claim call so ChestOpenModal can display real rewards
const [claimResult, setClaimResult] = useState<ClaimedRewardResponse | null>(
null,
);
const handleViewAll = () => {
if (onViewAll) onViewAll();
else navigate("/student/quests");
};
const handleClaim = (node: QuestNode, arcId: string) => {
setClaimResult(null); // clear any previous result before opening
setClaimingNode({ node, arcId });
};
const handleChestClose = () => {
if (!claimingNode) return;
claimNode(
claimingNode.arcId,
claimingNode.node.node_id, // node_id replaces old id
claimResult?.xp_awarded ?? 0,
claimResult?.title_unlocked.map((t) => t.name) ?? [],
);
setClaimingNode(null);
setClaimResult(null);
};
const rankProgress = Math.round(rank.progressToNext * 100);
const nextLabel = rank.next
? `${rankProgress}% to ${rank.next.label}`
: "Max rank";
const showIdentity = mode === "DEFAULT";
const showLevel = mode === "DEFAULT" || mode === "LEVEL";
const showQuestCompact = mode === "DEFAULT" || mode === "QUEST_COMPACT";
const showQuestExtended = mode === "QUEST_EXTENDED";
if (showQuestExtended) {
return (
<>
<style>{STYLES}</style>
<RankLadder earnedXP={earnedXP} onViewAll={handleViewAll} />
{claimingNode && (
<ChestOpenModal
node={claimingNode.node}
claimResult={claimResult}
onClose={handleChestClose}
/>
)}
</>
);
}
return (
<>
<style>{STYLES}</style>
<div className="hc-card">
{/* Identity — DEFAULT only */}
{showIdentity && (
<>
<div className="hc-top">
<div className="hc-identity">
<div className="hc-av-wrap">
<Avatar style={{ width: 46, height: 46, display: "block" }}>
<AvatarImage src={u?.avatar_url} />
<AvatarFallback
style={{
fontWeight: 900,
fontSize: "1rem",
color: "white",
textTransform: "uppercase",
background: "linear-gradient(135deg,#a855f7,#7c3aed)",
}}
>
{user?.name?.slice(0, 1)}
</AvatarFallback>
</Avatar>
<div className="hc-av-pip">{level}</div>
</div>
<div className="hc-nameblock">
<p className="hc-greeting">
Good {timeLabel}, <em>{firstName}</em> 👋
</p>
<p className="hc-role">{roleLabel}</p>
</div>
</div>
<InventoryButton label="Inventory" />
<Drawer direction="top">
<DrawerTrigger asChild>
<button className="hc-score-btn">
<Gauge size={14} />
</button>
</DrawerTrigger>
<DrawerContent>
<PredictedScoreCard />
</DrawerContent>
</Drawer>
</div>
<div className="hc-sep" />
</>
)}
{/* XP bar — DEFAULT + LEVEL */}
{showLevel && (
<div className="hc-xp-row">
<span className="hc-lvl-tag">Lv {level}</span>
<div className="hc-bar-wrap">
<div className="hc-track">
<div className="hc-fill" style={{ width: `${barPct}%` }} />
</div>
<div className="hc-xp-label">
<span>{totalXP.toLocaleString()} XP</span>
<span>{xpToGo.toLocaleString()} to go</span>
</div>
</div>
</div>
)}
{/* Rank + collapsible quests — DEFAULT + QUEST_COMPACT */}
{showQuestCompact && (
<>
<div className="hc-rank-row" onClick={() => setOpen((o) => !o)}>
<span className="hc-rank-emoji">{rank.emoji}</span>
<div className="hc-rank-text">
<p className="hc-rank-name">{rank.label}</p>
<p className="hc-rank-progress-label">{nextLabel}</p>
</div>
<div className="hc-rank-right">
{streak > 0 && (
<span className="hc-streak-pill">🔥 {streak}</span>
)}
{summary.claimableNodes > 0 && (
<span className="hc-chest-badge">
📦 {summary.claimableNodes}
</span>
)}
<ChevronDown
size={16}
className={`hc-chevron${open ? " open" : ""}`}
/>
</div>
</div>
<div className={`hc-quests-wrap${open ? " open" : ""}`}>
<div className="hc-quest-list">
{activeQuests.length === 0 ? (
<p className="hc-empty"> All caught up keep sailing!</p>
) : (
activeQuests.map(({ node, arc }) => {
// Progress uses new field names
const pct = Math.min(
100,
Math.round((node.current_value / node.req_target) * 100),
);
const isClaimable = node.status === "claimable";
// Arc accent colour via theme generator — arc.accentColor no longer exists
const accentColor = generateArcTheme(arc).accent;
// Node icon derived from req_type — node.emoji no longer exists
const nodeEmoji = REQ_EMOJI[node.req_type] ?? "🏝️";
// Progress label derived from req_type — node.requirement.label no longer exists
const reqLabel = REQ_LABEL[node.req_type] ?? node.req_type;
return (
<div
key={node.node_id} // node_id replaces old id
className="hc-quest-row"
style={{ "--ac": accentColor } as React.CSSProperties}
onClick={() => !isClaimable && handleViewAll()}
>
<div
className={`hc-q-icon${isClaimable ? " claimable" : ""}`}
>
{isClaimable ? "📦" : nodeEmoji}
</div>
<div className="hc-q-body">
{/* node.name replaces old node.title */}
<p className="hc-q-name">{node.name ?? "—"}</p>
{isClaimable ? (
<p className="hc-q-claimable"> Ready to claim!</p>
) : (
<p className="hc-q-sub">
{/* current_value / req_target replace old progress / requirement.target */}
{node.current_value}/{node.req_target} {reqLabel}{" "}
· {pct}%
</p>
)}
</div>
{isClaimable ? (
<button
className="hc-claim-btn"
onClick={(e) => {
e.stopPropagation();
handleClaim(node, arc.id);
}}
>
Open
</button>
) : (
<ChevronRight size={14} color="#d1d5db" />
)}
</div>
);
})
)}
</div>
<div className="hc-map-link" onClick={handleViewAll}>
<Map size={13} /> View quest map
</div>
</div>
</>
)}
</div>
{claimingNode && (
<ChestOpenModal
node={claimingNode.node}
claimResult={claimResult}
onClose={handleChestClose}
/>
)}
</>
);
};

View File

@ -0,0 +1,217 @@
import { useState } from "react";
import {
useInventoryStore,
getLiveEffects,
formatTimeLeft,
hasActiveEffect,
} from "../stores/useInventoryStore";
import { InventoryModal } from "./InventoryModal";
// ─── Styles ───────────────────────────────────────────────────────────────────
const BTN_STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&family=Cinzel:wght@700&display=swap');
/* ── Inventory trigger button ── */
.inv-btn {
position: relative;
display: inline-flex; align-items: center; gap: 0.38rem;
padding: 0.48rem 0.85rem;
background: rgba(255,255,255,0.05);
border: 1.5px solid rgba(255,255,255,0.1);
border-radius: 100px;
cursor: pointer;
font-family: 'Nunito', sans-serif;
font-size: 0.72rem; font-weight: 900;
color: rgba(255,255,255,0.7);
transition: all 0.18s ease;
outline: none;
white-space: nowrap;
}
.inv-btn:hover {
background: rgba(255,255,255,0.09);
border-color: rgba(255,255,255,0.2);
color: white;
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
.inv-btn:active { transform: translateY(0) scale(0.97); }
/* When active effects are running — gold glow */
.inv-btn.has-active {
border-color: rgba(251,191,36,0.45);
color: #fbbf24;
background: rgba(251,191,36,0.08);
animation: invBtnGlow 2.6s ease-in-out infinite;
}
@keyframes invBtnGlow {
0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
50% { box-shadow: 0 0 14px 3px rgba(251,191,36,0.2); }
}
.inv-btn.has-active:hover {
border-color: rgba(251,191,36,0.7);
background: rgba(251,191,36,0.14);
}
/* Badge dot */
.inv-btn-badge {
position: absolute; top: -4px; right: -4px;
width: 14px; height: 14px; border-radius: 50%;
background: #fbbf24;
border: 2px solid transparent; /* will be set to match parent bg via CSS var */
display: flex; align-items: center; justify-content: center;
font-family: 'Nunito', sans-serif;
font-size: 0.45rem; font-weight: 900; color: #1a0800;
animation: invBadgePop 1.8s ease-in-out infinite;
}
@keyframes invBadgePop {
0%,100%{ transform: scale(1); }
50% { transform: scale(1.15); }
}
/* ── Active Effect Banner (shown on other screens, e.g. pretest) ── */
.aeb-wrap {
display: flex; gap: 0.5rem; flex-wrap: wrap;
}
.aeb-pill {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.38rem 0.85rem;
border-radius: 100px;
font-family: 'Nunito', sans-serif;
font-size: 0.72rem; font-weight: 900;
animation: aebPillIn 0.35s cubic-bezier(0.34,1.56,0.64,1) both;
animation-delay: var(--aeb-delay, 0s);
}
@keyframes aebPillIn {
from { opacity:0; transform: scale(0.8) translateY(6px); }
to { opacity:1; transform: scale(1) translateY(0); }
}
/* Color variants per effect type */
.aeb-pill.xp_boost {
background: rgba(251,191,36,0.12);
border: 1.5px solid rgba(251,191,36,0.4);
color: #fbbf24;
}
.aeb-pill.streak_shield {
background: rgba(96,165,250,0.1);
border: 1.5px solid rgba(96,165,250,0.35);
color: #60a5fa;
}
.aeb-pill.coin_boost {
background: rgba(167,243,208,0.08);
border: 1.5px solid rgba(52,211,153,0.35);
color: #34d399;
}
.aeb-pill.default {
background: rgba(255,255,255,0.06);
border: 1.5px solid rgba(255,255,255,0.15);
color: rgba(255,255,255,0.7);
}
.aeb-pill-icon { font-size: 0.9rem; line-height:1; }
.aeb-pill-label { line-height:1; }
.aeb-pill-time {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.58rem; font-weight: 700;
opacity: 0.55; margin-left: 0.1rem;
}
`;
const ITEM_ICON: Record<string, string> = {
xp_boost: "⚡",
streak_shield: "🛡️",
title: "🏴‍☠️",
coin_boost: "🪙",
};
function itemIcon(effectType: string): string {
return ITEM_ICON[effectType] ?? "📦";
}
// ─── InventoryButton ──────────────────────────────────────────────────────────
/**
* Drop-in trigger button. Can be placed in any nav bar, header, or screen.
* Shows a gold glow + badge count when active effects are running.
*
* Usage:
* <InventoryButton />
* <InventoryButton label="Hold" />
*/
export const InventoryButton = ({}: {}) => {
const [open, setOpen] = useState(false);
const activeEffects = useInventoryStore((s) => s.activeEffects);
const liveEffects = getLiveEffects(activeEffects);
const hasActive = liveEffects.length > 0;
return (
<>
<style>{BTN_STYLES}</style>
<button
className={`inv-btn${hasActive ? " has-active" : ""}`}
onClick={() => setOpen(true)}
aria-label="Open inventory"
>
🎒
{hasActive && (
<span className="inv-btn-badge">{liveEffects.length}</span>
)}
</button>
{open && <InventoryModal onClose={() => setOpen(false)} />}
</>
);
};
// ─── ActiveEffectBanner ───────────────────────────────────────────────────────
/**
* Shows pills for each currently-active effect.
* Place wherever you want a contextual reminder (pretest screen, dashboard, etc.)
*
* Usage:
* <ActiveEffectBanner />
* <ActiveEffectBanner filter="xp_boost" /> ← only show a specific effect
*
* Example output on Pretest screen:
* ⚡ XP Boost ×2 · 1h 42m 🛡️ Streak Shield · 23m
*/
export const ActiveEffectBanner = ({
filter,
className,
}: {
filter?: string;
className?: string;
}) => {
const activeEffects = useInventoryStore((s) => s.activeEffects);
const live = getLiveEffects(activeEffects).filter(
(e) => !filter || e.item.effect_type === filter,
);
if (live.length === 0) return null;
return (
<>
<style>{BTN_STYLES}</style>
<div className={`aeb-wrap${className ? ` ${className}` : ""}`}>
{live.map((e, i) => (
<div
key={e.id}
className={`aeb-pill ${e.item.effect_type ?? "default"}`}
style={{ "--aeb-delay": `${i * 0.07}s` } as React.CSSProperties}
>
<span className="aeb-pill-icon">
{itemIcon(e.item.effect_type)}
</span>
<span className="aeb-pill-label">
{e.item.name}
{e.item.effect_type === "xp_boost" && e.item.effect_value
? ` ×${e.item.effect_value}`
: ""}
</span>
<span className="aeb-pill-time">
{formatTimeLeft(e.expires_at)}
</span>
</div>
))}
</div>
</>
);
};

View File

@ -0,0 +1,609 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import type { InventoryItem, ActiveEffect } from "../types/quest";
import {
useInventoryStore,
getLiveEffects,
formatTimeLeft,
} from "../stores/useInventoryStore";
import { useAuthStore } from "../stores/authStore";
import { api } from "../utils/api";
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700;900&family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
/* ══ OVERLAY ══ */
.inv-overlay {
position: fixed; inset: 0; z-index: 60;
background: rgba(2,5,15,0.78);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
display: flex; align-items: flex-end; justify-content: center;
animation: invFadeIn 0.2s ease both;
}
@keyframes invFadeIn { from{opacity:0} to{opacity:1} }
/* ══ SHEET ══ */
.inv-sheet {
width: 100%; max-width: 540px;
background: linear-gradient(180deg, #08111f 0%, #050d1a 100%);
border-radius: 28px 28px 0 0;
border-top: 1.5px solid rgba(251,191,36,0.25);
box-shadow:
0 -16px 60px rgba(0,0,0,0.7),
inset 0 1px 0 rgba(255,255,255,0.06);
overflow: hidden;
display: flex; flex-direction: column;
max-height: 88vh;
animation: invSlideUp 0.38s cubic-bezier(0.34,1.56,0.64,1) both;
position: relative;
}
@keyframes invSlideUp {
from { transform: translateY(100%); opacity:0; }
to { transform: translateY(0); opacity:1; }
}
.inv-sheet::before {
content: '';
position: absolute; inset: 0; pointer-events: none; z-index: 0;
background:
repeating-linear-gradient(110deg, transparent 60%, rgba(56,189,248,0.015) 61%, transparent 62%),
repeating-linear-gradient(70deg, transparent 72%, rgba(56,189,248,0.01) 73%, transparent 74%);
background-size: 300% 300%, 240% 240%;
animation: invSeaSway 16s ease-in-out infinite alternate;
}
@keyframes invSeaSway {
0% { background-position: 0% 0%, 100% 0%; }
100% { background-position: 100% 100%, 0% 100%; }
}
.inv-sheet::after {
content: '';
position: absolute; top: -60px; right: -40px; z-index: 0;
width: 220px; height: 220px; border-radius: 50%;
background: radial-gradient(circle, rgba(251,191,36,0.07), transparent 68%);
pointer-events: none;
}
.inv-handle-row {
display: flex; justify-content: center;
padding: 0.75rem 0 0; flex-shrink: 0; position: relative; z-index: 2;
}
.inv-handle {
width: 40px; height: 4px; border-radius: 100px;
background: rgba(255,255,255,0.1);
}
.inv-header {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: space-between;
padding: 0.85rem 1.3rem 0;
}
.inv-header-left { display: flex; flex-direction: column; gap: 0.1rem; }
.inv-eyebrow {
font-family: 'Cinzel', serif;
font-size: 0.5rem; font-weight: 700; letter-spacing: 0.22em;
text-transform: uppercase; color: rgba(251,191,36,0.55);
}
.inv-title {
font-family: 'Cinzel', serif;
font-size: 1.28rem; font-weight: 900; color: #fff;
letter-spacing: 0.03em;
text-shadow: 0 0 24px rgba(251,191,36,0.3);
}
.inv-close {
width: 32px; height: 32px; border-radius: 50%;
border: 1.5px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.05);
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.15s;
flex-shrink: 0;
}
.inv-close:hover {
border-color: rgba(251,191,36,0.5);
background: rgba(251,191,36,0.1);
}
.inv-active-bar {
position: relative; z-index: 2;
display: flex; gap: 0.5rem; overflow-x: auto; scrollbar-width: none;
padding: 0.75rem 1.3rem 0;
}
.inv-active-bar::-webkit-scrollbar { display: none; }
.inv-active-pill {
display: flex; align-items: center; gap: 0.4rem;
flex-shrink: 0;
padding: 0.35rem 0.75rem;
border-radius: 100px;
border: 1.5px solid rgba(251,191,36,0.35);
background: rgba(251,191,36,0.08);
animation: invPillGlow 2.4s ease-in-out infinite;
}
@keyframes invPillGlow {
0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
50% { box-shadow: 0 0 12px 2px rgba(251,191,36,0.18); }
}
.inv-active-pill-icon { font-size: 0.9rem; }
.inv-active-pill-name {
font-family: 'Nunito', sans-serif;
font-size: 0.72rem; font-weight: 900; color: #fbbf24;
}
.inv-active-pill-time {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.6rem; font-weight: 700;
color: rgba(251,191,36,0.5);
margin-left: 0.1rem;
}
.inv-divider {
position: relative; z-index: 2;
height: 1px; margin: 0.85rem 1.3rem 0;
background: rgba(255,255,255,0.06);
}
.inv-section-label {
position: relative; z-index: 2;
padding: 0.7rem 1.3rem 0.35rem;
font-family: 'Cinzel', serif;
font-size: 0.48rem; font-weight: 700; letter-spacing: 0.2em;
text-transform: uppercase; color: rgba(255,255,255,0.25);
}
.inv-scroll {
position: relative; z-index: 2;
flex: 1; overflow-y: auto; scrollbar-width: none;
padding: 0 1.1rem calc(1.5rem + env(safe-area-inset-bottom));
}
.inv-scroll::-webkit-scrollbar { display: none; }
.inv-empty {
display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 0.6rem;
padding: 3rem 1rem;
font-family: 'Nunito', sans-serif;
font-size: 0.85rem; font-weight: 800;
color: rgba(255,255,255,0.25);
}
.inv-empty-icon { font-size: 2.5rem; opacity: 0.4; }
.inv-skeleton-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
}
.inv-skeleton-card {
height: 140px; border-radius: 20px;
background: rgba(255,255,255,0.04);
animation: invSkel 1.6s ease-in-out infinite;
}
@keyframes invSkel {
0%,100% { opacity: 0.6; }
50% { opacity: 1; }
}
.inv-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
}
.inv-card {
border-radius: 20px; padding: 1rem;
border: 1.5px solid rgba(255,255,255,0.07);
background: rgba(255,255,255,0.03);
display: flex; flex-direction: column; gap: 0.6rem;
cursor: pointer; position: relative; overflow: hidden;
transition: border-color 0.2s, background 0.2s, transform 0.15s;
animation: invCardIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
animation-delay: var(--ci-delay, 0s);
}
@keyframes invCardIn {
from { opacity:0; transform: translateY(14px) scale(0.95); }
to { opacity:1; transform: translateY(0) scale(1); }
}
.inv-card:hover {
border-color: rgba(255,255,255,0.14);
background: rgba(255,255,255,0.06);
transform: translateY(-2px);
}
.inv-card:active { transform: translateY(0) scale(0.98); }
.inv-card.is-active {
border-color: rgba(251,191,36,0.4);
background: rgba(251,191,36,0.06);
}
.inv-card.is-active:hover {
border-color: rgba(251,191,36,0.6);
background: rgba(251,191,36,0.09);
}
@keyframes invActivateFlash {
0% { background: rgba(251,191,36,0.25); border-color: rgba(251,191,36,0.8); }
100%{ background: rgba(251,191,36,0.06); border-color: rgba(251,191,36,0.4); }
}
.inv-card.just-activated { animation: invActivateFlash 0.9s ease forwards; }
.inv-card-sheen {
position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(135deg, transparent 30%, rgba(255,255,255,0.04) 50%, transparent 70%);
transform: translateX(-100%); transition: transform 0.5s ease;
}
.inv-card:hover .inv-card-sheen { transform: translateX(100%); }
.inv-card-icon-wrap {
width: 44px; height: 44px; border-radius: 14px;
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0; position: relative;
}
.inv-card.is-active .inv-card-icon-wrap {
background: rgba(251,191,36,0.12);
border-color: rgba(251,191,36,0.3);
}
.inv-card-active-dot {
position: absolute; top: -3px; right: -3px;
width: 10px; height: 10px; border-radius: 50%;
background: #fbbf24; border: 2px solid #08111f;
animation: invDotPulse 2s ease-in-out infinite;
}
@keyframes invDotPulse {
0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,0.6); }
50% { box-shadow: 0 0 0 5px rgba(251,191,36,0); }
}
.inv-card-name {
font-family: 'Nunito', sans-serif;
font-size: 0.82rem; font-weight: 900; color: #fff; line-height: 1.2;
}
.inv-card.is-active .inv-card-name { color: #fbbf24; }
.inv-card-desc {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.63rem; font-weight: 600;
color: rgba(255,255,255,0.38); line-height: 1.4; flex: 1;
}
.inv-card-meta {
display: flex; align-items: center; justify-content: space-between;
gap: 0.4rem; margin-top: auto;
}
.inv-card-qty {
font-family: 'Nunito', sans-serif;
font-size: 0.65rem; font-weight: 900;
color: rgba(255,255,255,0.3);
background: rgba(255,255,255,0.05);
border-radius: 100px; padding: 0.15rem 0.45rem;
}
.inv-card-type {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.56rem; font-weight: 700;
letter-spacing: 0.1em; text-transform: uppercase;
color: rgba(255,255,255,0.22);
}
.inv-activate-btn {
width: 100%; padding: 0.48rem;
border-radius: 10px; border: none; cursor: pointer;
font-family: 'Nunito', sans-serif;
font-size: 0.7rem; font-weight: 900;
transition: all 0.15s ease;
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
}
.inv-activate-btn.idle {
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.1);
color: rgba(255,255,255,0.6);
}
.inv-activate-btn.idle:hover { background: rgba(255,255,255,0.12); color: white; }
.inv-activate-btn.activating {
background: rgba(251,191,36,0.1);
border: 1px solid rgba(251,191,36,0.25);
color: rgba(251,191,36,0.6);
cursor: not-allowed;
animation: invSpinLabel 0.4s ease infinite alternate;
}
@keyframes invSpinLabel { from{opacity:0.5} to{opacity:1} }
.inv-activate-btn.active-state {
background: rgba(251,191,36,0.12);
border: 1px solid rgba(251,191,36,0.3);
color: #fbbf24; cursor: default;
}
.inv-activate-btn.success-flash {
background: rgba(74,222,128,0.18);
border: 1px solid rgba(74,222,128,0.4);
color: #4ade80;
animation: invSuccessScale 0.35s cubic-bezier(0.34,1.56,0.64,1) both;
}
@keyframes invSuccessScale { from{transform:scale(0.94)} to{transform:scale(1)} }
.inv-activate-btn:disabled { pointer-events: none; }
.inv-active-time {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.55rem; font-weight: 700; color: rgba(251,191,36,0.5);
}
.inv-toast {
position: fixed; bottom: calc(1.5rem + env(safe-area-inset-bottom));
left: 50%; transform: translateX(-50%);
z-index: 9999;
display: flex; align-items: center; gap: 0.55rem;
padding: 0.7rem 1.2rem;
background: linear-gradient(135deg, #1a3a1a, #0d2010);
border: 1.5px solid rgba(74,222,128,0.45);
border-radius: 100px;
box-shadow: 0 4px 24px rgba(0,0,0,0.5), 0 0 20px rgba(74,222,128,0.12);
font-family: 'Nunito', sans-serif;
font-size: 0.8rem; font-weight: 900; color: #4ade80;
white-space: nowrap;
animation: invToastIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both,
invToastOut 0.3s 2.7s ease forwards;
}
@keyframes invToastIn { from{opacity:0; transform:translateX(-50%) translateY(20px) scale(0.9)} to{opacity:1; transform:translateX(-50%) translateY(0) scale(1)} }
@keyframes invToastOut { from{opacity:1} to{opacity:0; transform:translateX(-50%) translateY(8px)} }
`;
// ─── Item metadata ─────────────────────────────────────────────────────────────
const ITEM_ICON: Record<string, string> = {
xp_boost: "⚡",
streak_shield: "🛡️",
title: "🏴‍☠️",
coin_boost: "🪙",
};
function itemIcon(effectType: string): string {
return ITEM_ICON[effectType] ?? "📦";
}
function isItemActive(
item: InventoryItem,
activeEffects: ActiveEffect[],
): ActiveEffect | null {
const now = Date.now();
return (
activeEffects.find(
(e) =>
e.item.id === item.item.id && new Date(e.expires_at).getTime() > now,
) ?? null
);
}
// ─── Item card ────────────────────────────────────────────────────────────────
const ItemCard = ({
inv,
activeEffects,
activatingId,
lastActivatedId,
onActivate,
index,
}: {
inv: InventoryItem;
activeEffects: ActiveEffect[];
activatingId: string | null;
lastActivatedId: string | null;
onActivate: (id: string) => void;
index: number;
}) => {
const activeEffect = isItemActive(inv, activeEffects);
const isActive = !!activeEffect;
const isActivating = activatingId === inv.id;
const justActivated = lastActivatedId === inv.id;
let btnState: "idle" | "activating" | "active-state" | "success-flash" =
"idle";
if (justActivated) btnState = "success-flash";
else if (isActivating) btnState = "activating";
else if (isActive) btnState = "active-state";
let btnLabel = "Use Item";
if (btnState === "activating") btnLabel = "Activating…";
else if (btnState === "success-flash") btnLabel = "✓ Activated!";
else if (btnState === "active-state") btnLabel = "✓ Active";
return (
<div
className={`inv-card${isActive ? " is-active" : ""}${justActivated ? " just-activated" : ""}`}
style={{ "--ci-delay": `${index * 0.045}s` } as React.CSSProperties}
>
<div className="inv-card-sheen" />
<div className="inv-card-icon-wrap">
{itemIcon(inv.item.effect_type)}
{isActive && <div className="inv-card-active-dot" />}
</div>
<p className="inv-card-name">{inv.item.name}</p>
<p className="inv-card-desc">{inv.item.description}</p>
<div className="inv-card-meta">
<span className="inv-card-qty">×{inv.quantity}</span>
<span className="inv-card-type">
{inv.item.type.replace(/_/g, " ")}
</span>
</div>
{isActive && activeEffect && (
<div className="inv-active-time">
{formatTimeLeft(activeEffect.expires_at)} remaining
</div>
)}
<button
className={`inv-activate-btn ${btnState}`}
onClick={() => !isActive && !isActivating && onActivate(inv.id)}
disabled={isActive || isActivating}
>
{btnLabel}
</button>
</div>
);
};
// ─── Main component ───────────────────────────────────────────────────────────
interface Props {
onClose: () => void;
}
export const InventoryModal = ({ onClose }: Props) => {
const token = useAuthStore((s) => s.token);
const items = useInventoryStore((s) => s.items);
const activeEffects = useInventoryStore((s) => s.activeEffects);
const loading = useInventoryStore((s) => s.loading);
const activatingId = useInventoryStore((s) => s.activatingId);
const lastActivatedId = useInventoryStore((s) => s.lastActivatedId);
const error = useInventoryStore((s) => s.error);
const syncFromAPI = useInventoryStore((s) => s.syncFromAPI);
const setLoading = useInventoryStore((s) => s.setLoading);
const activateItemOptimistic = useInventoryStore(
(s) => s.activateItemOptimistic,
);
const activateItemSuccess = useInventoryStore((s) => s.activateItemSuccess);
const activateItemError = useInventoryStore((s) => s.activateItemError);
const clearLastActivated = useInventoryStore((s) => s.clearLastActivated);
const [showToast, setShowToast] = useState(false);
const [toastMsg, setToastMsg] = useState("");
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!token) return;
let cancelled = false;
const fetchInv = async () => {
setLoading(true);
try {
const inv = await api.fetchUserInventory(token);
if (!cancelled) syncFromAPI(inv);
} catch (e) {
// Silently fail — cached data stays visible
} finally {
if (!cancelled) setLoading(false);
}
};
fetchInv();
return () => {
cancelled = true;
};
}, [token]);
const handleActivate = useCallback(
async (itemId: string) => {
if (!token) return;
activateItemOptimistic(itemId);
try {
const updatedInv = await api.activateItem(token, itemId);
activateItemSuccess(updatedInv, itemId);
const name = items.find((i) => i.id === itemId)?.item.name ?? "Item";
setToastMsg(
`${itemIcon(items.find((i) => i.id === itemId)?.item.effect_type ?? "")} ${name} activated!`,
);
setShowToast(true);
if (toastTimer.current) clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => {
setShowToast(false);
clearLastActivated();
}, 3000);
} catch (e) {
activateItemError(
itemId,
e instanceof Error ? e.message : "Failed to activate",
);
}
},
[token, items],
);
useEffect(
() => () => {
if (toastTimer.current) clearTimeout(toastTimer.current);
},
[],
);
const liveEffects = getLiveEffects(activeEffects);
// Portal the entire modal to document.body so it always
// renders at the top of the DOM tree, escaping any parent
// stacking context, overflow:hidden, or z-index constraints.
return createPortal(
<>
<style>{STYLES}</style>
<div className="inv-overlay" onClick={onClose}>
<div className="inv-sheet" onClick={(e) => e.stopPropagation()}>
<div className="inv-handle-row">
<div className="inv-handle" />
</div>
<div className="inv-header">
<div className="inv-header-left">
<span className="inv-eyebrow"> Pirate's Hold</span>
<h2 className="inv-title">Inventory</h2>
</div>
<button className="inv-close" onClick={onClose}>
<X size={14} color="rgba(255,255,255,0.5)" />
</button>
</div>
{liveEffects.length > 0 && (
<div className="inv-active-bar">
{liveEffects.map((e) => (
<div key={e.id} className="inv-active-pill">
<span className="inv-active-pill-icon">
{itemIcon(e.item.effect_type)}
</span>
<span className="inv-active-pill-name">{e.item.name}</span>
<span className="inv-active-pill-time">
{formatTimeLeft(e.expires_at)}
</span>
</div>
))}
</div>
)}
<div className="inv-divider" />
<p className="inv-section-label">
{items.length > 0
? `${items.length} item${items.length !== 1 ? "s" : ""} in your hold`
: "Your hold"}
</p>
<div className="inv-scroll">
{loading && items.length === 0 ? (
<div className="inv-skeleton-grid">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="inv-skeleton-card"
style={{ animationDelay: `${i * 0.1}s` }}
/>
))}
</div>
) : items.length === 0 ? (
<div className="inv-empty">
<span className="inv-empty-icon">🏴‍☠️</span>
<p>Your hold is empty — claim quests to earn items!</p>
</div>
) : (
<div className="inv-grid">
{items.map((inv, i) => (
<ItemCard
key={inv.id}
inv={inv}
activeEffects={activeEffects}
activatingId={activatingId}
lastActivatedId={lastActivatedId}
onActivate={handleActivate}
index={i}
/>
))}
</div>
)}
{error && (
<p
style={{
textAlign: "center",
padding: "0.5rem",
fontFamily: "'Nunito',sans-serif",
fontSize: "0.72rem",
color: "#ef4444",
fontWeight: 800,
}}
>
⚠️ {error}
</p>
)}
</div>
</div>
</div>
{showToast && <div className="inv-toast">{toastMsg}</div>}
</>,
document.body,
);
};

View File

@ -0,0 +1,22 @@
export const LeaderboardRowSkeleton = () => {
return (
<div className="flex justify-between items-center animate-pulse">
<div className="flex items-center gap-3">
{/* Rank / Trophy */}
<div className="w-12 h-12 rounded-full bg-gray-200" />
{/* Avatar */}
<div className="w-12 h-12 rounded-full bg-gray-300" />
{/* Name */}
<div className="h-4 w-32 bg-gray-200 rounded" />
</div>
{/* XP */}
<div className="flex items-center gap-2">
<div className="h-4 w-10 bg-gray-200 rounded" />
<div className="w-5 h-5 rounded bg-gray-200" />
</div>
</div>
);
};

View File

@ -0,0 +1,343 @@
import { useEffect, useRef, useState, Suspense } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { api } from "../utils/api";
import { useAuthStore } from "../stores/authStore";
import { Loader, X } from "lucide-react";
import { LESSON_COMPONENT_MAP } from "./FetchLessonPage";
import type { LessonId } from "./FetchLessonPage";
import type { LessonDetails } from "../types/lesson";
interface LessonModalProps {
lessonId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
// UUIDs are video lessons; local lessons use readable keys like "ebrw-main-idea"
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const isVideoLesson = (id: string) => UUID_REGEX.test(id);
function getLocalLessonTitle(lessonId: string): string {
const comp = LESSON_COMPONENT_MAP[lessonId as LessonId] as any;
if (comp?.displayName) return comp.displayName;
return lessonId
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
.lm-content {
font-family: 'Nunito', sans-serif;
background: #fffbf4;
border: 2.5px solid #f3f4f6;
border-radius: 28px !important;
padding: 0;
overflow: hidden;
max-width: 680px;
width: calc(100vw - 2rem);
box-shadow: 0 20px 60px rgba(0,0,0,0.12);
max-height: 90vh;
display: flex;
flex-direction: column;
}
.lm-dialog-header-hidden {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
.lm-header {
display: flex; align-items: flex-start; justify-content: space-between;
padding: 1.25rem 1.5rem 0; flex-shrink: 0; gap: 1rem;
}
.lm-title-wrap { display:flex; flex-direction:column; gap:0.2rem; flex:1; }
.lm-eyebrow {
font-size: 0.62rem; font-weight: 800; letter-spacing: 0.16em;
text-transform: uppercase; color: #a855f7;
}
.lm-title {
font-size: 1.2rem; font-weight: 900; color: #1e1b4b;
letter-spacing: -0.01em; line-height: 1.25;
}
.lm-close-btn {
width: 34px; height: 34px; flex-shrink: 0;
border-radius: 50%; border: 2.5px solid #f3f4f6;
background: white; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.06); transition: all 0.15s ease;
}
.lm-close-btn:hover { border-color: #fecdd3; background: #fff1f2; }
.lm-body {
overflow-y: auto; flex: 1;
padding: 1rem 1.5rem 1.5rem;
display: flex; flex-direction: column; gap: 1rem;
-webkit-overflow-scrolling: touch;
}
.lm-video {
width: 100%; border-radius: 18px;
aspect-ratio: 16/9; background: #1e1b4b; display: block;
}
.lm-topic-chip {
display: inline-flex; align-items: center; gap: 0.4rem;
background: #f3e8ff; border: 2px solid #e9d5ff;
border-radius: 100px; padding: 0.3rem 0.8rem;
font-size: 0.7rem; font-weight: 800; letter-spacing: 0.08em;
text-transform: uppercase; color: #9333ea; width: fit-content;
}
.lm-card {
background: white; border: 2.5px solid #f3f4f6;
border-radius: 18px; padding: 1rem 1.1rem;
box-shadow: 0 3px 10px rgba(0,0,0,0.04);
}
.lm-card-label {
font-size: 0.62rem; font-weight: 800; letter-spacing: 0.14em;
text-transform: uppercase; color: #9ca3af; margin-bottom: 0.4rem;
}
.lm-card-text {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.88rem; font-weight: 600; color: #374151; line-height: 1.6;
}
.lm-loading {
display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 0.75rem; padding: 3rem 1.5rem; flex: 1;
}
.lm-loading-spinner { animation: lmSpin 0.8s linear infinite; }
@keyframes lmSpin { to { transform: rotate(360deg); } }
.lm-loading-text { font-size: 0.85rem; font-weight: 700; color: #9ca3af; }
.lm-error {
display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 0.5rem;
padding: 3rem 1.5rem; text-align: center; flex: 1;
}
.lm-error-emoji { font-size: 2rem; }
.lm-error-text { font-size: 0.85rem; font-weight: 700; color: #9ca3af; }
/* Resources list */
.lm-resources { display: flex; flex-direction: column; gap: 0.5rem; }
.lm-resource-link {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.6rem 0.8rem; border-radius: 12px;
background: #f5f3ff; border: 1.5px solid #e9d5ff;
color: #7c3aed; font-size: 0.8rem; font-weight: 700;
text-decoration: none; transition: background 0.15s ease;
}
.lm-resource-link:hover { background: #ede9fe; }
/* Creator badge */
.lm-creator {
display: flex; align-items: center; gap: 0.5rem;
font-family: 'Nunito Sans', sans-serif;
font-size: 0.75rem; font-weight: 600; color: #9ca3af;
}
.lm-creator-avatar {
width: 24px; height: 24px; border-radius: 50%;
background: linear-gradient(135deg, #a855f7, #3b82f6);
display: flex; align-items: center; justify-content: center;
font-size: 0.65rem; font-weight: 900; color: white; flex-shrink: 0;
}
`;
const LoadingSpinner = () => (
<div className="lm-loading">
<Loader size={28} color="#a855f7" className="lm-loading-spinner" />
<p className="lm-loading-text">Loading lesson...</p>
</div>
);
export const LessonModal = ({
lessonId,
open,
onOpenChange,
}: LessonModalProps) => {
const user = useAuthStore((state) => state.user);
const [loading, setLoading] = useState(false);
const [lesson, setLesson] = useState<LessonDetails | null>(null);
const [error, setError] = useState(false);
const fetchingForId = useRef<string | null>(null);
const LocalLessonComponent =
lessonId && !isVideoLesson(lessonId)
? LESSON_COMPONENT_MAP[lessonId as LessonId]
: null;
const modalTitle = LocalLessonComponent
? getLocalLessonTitle(lessonId!)
: loading
? "Loading..."
: (lesson?.title ?? "Lesson");
useEffect(() => {
if (!open) {
setLesson(null);
setLoading(false);
setError(false);
fetchingForId.current = null;
return;
}
if (!lessonId || !user || LocalLessonComponent) return;
if (fetchingForId.current === lessonId) return;
const fetchLesson = async () => {
fetchingForId.current = lessonId;
setLesson(null);
setError(false);
setLoading(true);
try {
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) throw new Error("No auth storage");
const {
state: { token },
} = JSON.parse(authStorage) as { state?: { token?: string } };
if (!token) throw new Error("No token");
// fetchLessonById returns LessonDetails directly
const response: LessonDetails = await api.fetchLessonById(
token,
lessonId,
);
if (fetchingForId.current !== lessonId) return;
setLesson(response);
} catch (err) {
console.error("Failed to fetch lesson", err);
if (fetchingForId.current === lessonId) setError(true);
} finally {
if (fetchingForId.current === lessonId) setLoading(false);
}
};
fetchLesson();
}, [open, lessonId, user, LocalLessonComponent]);
// topic on LessonDetails is Topic[] — use the first entry
const topicName = Array.isArray(lesson?.topic)
? lesson.topic[0]?.name
: ((lesson?.topic as any)?.name ?? null);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<style>{STYLES}</style>
<DialogContent className="lm-content" showCloseButton={false}>
<DialogHeader className="lm-dialog-header-hidden">
<DialogTitle>{modalTitle}</DialogTitle>
</DialogHeader>
<div className="lm-header">
<div className="lm-title-wrap">
<span className="lm-eyebrow">📖 Lesson</span>
<h2 className="lm-title">{modalTitle}</h2>
</div>
<button className="lm-close-btn" onClick={() => onOpenChange(false)}>
<X size={16} color="#6b7280" />
</button>
</div>
{loading ? (
<LoadingSpinner />
) : error ? (
<div className="lm-error">
<span className="lm-error-emoji">😕</span>
<p className="lm-error-text">
Couldn't load this lesson. Please try again.
</p>
</div>
) : (
<div className="lm-body">
{LocalLessonComponent ? (
<Suspense fallback={<LoadingSpinner />}>
<LocalLessonComponent />
</Suspense>
) : (
lesson && (
<>
{/* Video */}
{lesson.video_url && (
<video
src={lesson.video_url}
controls
className="lm-video"
/>
)}
{/* Topic chip */}
{topicName && (
<div>
<span className="lm-topic-chip">
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: "#a855f7",
flexShrink: 0,
}}
/>
{topicName}
</span>
</div>
)}
{/* Description */}
{lesson.description && (
<div className="lm-card">
<p className="lm-card-label">About this lesson</p>
<p className="lm-card-text">{lesson.description}</p>
</div>
)}
{/* Content */}
{lesson.content && (
<div className="lm-card">
<p className="lm-card-label">Content</p>
<p className="lm-card-text">{lesson.content}</p>
</div>
)}
{/* Resources */}
{lesson.resources && lesson.resources.length > 0 && (
<div className="lm-card">
<p className="lm-card-label">Resources</p>
<div className="lm-resources">
{lesson.resources.map((r: any, i: number) => (
<a
key={i}
href={r.url ?? r.link ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="lm-resource-link"
>
📎 {r.title ?? r.name ?? `Resource ${i + 1}`}
</a>
))}
</div>
</div>
)}
{/* Created by */}
{lesson.created_by?.name && (
<div className="lm-creator">
<div className="lm-creator-avatar">
{lesson.created_by.name.charAt(0).toUpperCase()}
</div>
Lesson by {lesson.created_by.name}
</div>
)}
</>
)
)}
</div>
)}
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,11 @@
import { Card, CardContent } from "./ui/card";
export const LessonSkeleton = () => (
<Card className="py-0 pb-5 rounded-4xl overflow-hidden animate-pulse">
<div className="w-full h-48 bg-muted" />
<CardContent className="space-y-2 pt-4">
<div className="h-5 w-2/3 bg-muted rounded" />
<div className="h-4 w-1/2 bg-muted rounded" />
</CardContent>
</Card>
);

View File

@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import { useAuthStore } from "../stores/authStore";
const STYLES = `
.lb-wrap {
display: flex; align-items: center; gap: 0.55rem;
background: white;
border: 2px solid #f3f4f6;
border-radius: 100px;
padding: 0.38rem 0.75rem 0.38rem 0.42rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
animation: lbIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
}
@keyframes lbIn {
from { opacity:0; transform: scale(0.9) translateX(6px); }
to { opacity:1; transform: scale(1) translateX(0); }
}
/* Level bubble */
.lb-bubble {
width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
background: linear-gradient(135deg, #a855f7, #7c3aed);
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 0 #5b21b644;
font-family: 'Nunito', sans-serif;
font-size: 0.7rem; font-weight: 900; color: white;
letter-spacing: -0.02em;
}
/* Bar track */
.lb-track {
width: 80px; height: 7px;
background: #f3f4f6; border-radius: 100px; overflow: hidden;
flex-shrink: 0;
}
.lb-fill {
height: 100%; border-radius: 100px;
background: linear-gradient(90deg, #a855f7, #f97316);
transition: width 1s cubic-bezier(0.34,1.56,0.64,1);
position: relative; overflow: hidden;
}
.lb-fill::after {
content: '';
position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.45), transparent);
transform: translateX(-100%);
animation: lbShimmer 2.2s ease-in-out 1s infinite;
}
@keyframes lbShimmer { to { transform: translateX(200%); } }
/* XP label */
.lb-label {
font-family: 'Nunito', sans-serif;
font-size: 0.68rem; font-weight: 900;
color: #a855f7; white-space: nowrap;
}
`;
export const LevelBar = () => {
const user = useAuthStore((s) => s.user);
const u = user as any;
const level = u?.current_level ?? u?.level ?? 1;
const totalXP = u?.total_xp ?? u?.xp ?? 0;
const levelStart = u?.current_level_start ?? u?.level_min_xp ?? 0;
const levelEnd =
u?.next_level_threshold ?? u?.level_max_xp ?? levelStart + 1000;
const levelRange = Math.max(levelEnd - levelStart, 1);
const xpIntoLevel = Math.max(totalXP - levelStart, 0);
const rawPct = Math.min(Math.round((xpIntoLevel / levelRange) * 100), 100);
const [pct, setPct] = useState(0);
useEffect(() => {
const id = requestAnimationFrame(() =>
requestAnimationFrame(() => setPct(rawPct)),
);
return () => cancelAnimationFrame(id);
}, [rawPct]);
return (
<>
<style>{STYLES}</style>
<div className="lb-wrap">
<div className="lb-bubble">{level}</div>
<div className="lb-track">
<div className="lb-fill" style={{ width: `${pct}%` }} />
</div>
<span className="lb-label">{pct}%</span>
</div>
</>
);
};

25
src/components/Math.tsx Normal file
View File

@ -0,0 +1,25 @@
import React from "react";
/**
* Renders a proper stacked fraction with numerator above denominator.
* Usage: <Frac n="x² 1" d="x² 2x + 1" />
*/
export const Frac = ({ n, d }: { n: React.ReactNode; d: React.ReactNode }) => (
<span
style={{
display: "inline-flex",
flexDirection: "column",
alignItems: "center",
verticalAlign: "middle",
lineHeight: 1.25,
margin: "0 3px",
}}
>
<span
style={{ borderBottom: "1.5px solid currentColor", padding: "0 4px 2px" }}
>
{n}
</span>
<span style={{ padding: "2px 4px 0" }}>{d}</span>
</span>
);

View File

@ -0,0 +1,474 @@
import { useEffect, useState } from "react";
import { api } from "../utils/api";
import { useAuthToken } from "../hooks/useAuthToken";
import {
TrendingUp,
BookOpen,
Calculator,
Loader2,
ChevronDown,
ChevronUp,
} from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────────
interface SectionPrediction {
score: number;
range_min: number;
range_max: number;
confidence: string;
}
interface PredictedScoreResponse {
total_score: number;
math_prediction: SectionPrediction;
rw_prediction: SectionPrediction;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const confidenceConfig: Record<
string,
{ label: string; color: string; bg: string; border: string; dot: string }
> = {
high: {
label: "High confidence",
color: "#16a34a",
bg: "#f0fdf4",
border: "#bbf7d0",
dot: "#22c55e",
},
medium: {
label: "Medium confidence",
color: "#d97706",
bg: "#fffbeb",
border: "#fde68a",
dot: "#f59e0b",
},
low: {
label: "Low confidence",
color: "#e11d48",
bg: "#fff1f2",
border: "#fecdd3",
dot: "#f43f5e",
},
};
const getConfidenceStyle = (confidence: string) =>
confidenceConfig[confidence.toLowerCase()] ?? {
label: confidence,
color: "#6b7280",
bg: "#f9fafb",
border: "#f3f4f6",
dot: "#9ca3af",
};
const useCountUp = (target: number, duration = 900) => {
const [value, setValue] = useState(0);
useEffect(() => {
if (!target) return;
let start: number | null = null;
const step = (ts: number) => {
if (!start) start = ts;
const progress = Math.min((ts - start) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setValue(Math.floor(eased * target));
if (progress < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}, [target, duration]);
return value;
};
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
.psc-card {
background: white;
border: 2.5px solid #f3f4f6;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
font-family: 'Nunito', sans-serif;
width: 100%;
}
/* Header */
.psc-header {
padding: 1.1rem 1.25rem 0.75rem;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 2px solid #f9fafb;
}
.psc-header-left { display:flex;flex-direction:column;gap:0.15rem; }
.psc-header-title {
font-size: 0.88rem; font-weight: 900; color: #1e1b4b;
letter-spacing: -0.01em;
}
.psc-header-sub {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.7rem; font-weight: 600; color: #9ca3af;
}
.psc-header-icon {
width: 36px; height: 36px; border-radius: 12px;
background: linear-gradient(135deg, #a855f7, #7c3aed);
display: flex; align-items: center; justify-content: center;
box-shadow: 0 4px 0 #5b21b644;
flex-shrink: 0;
}
/* Body */
.psc-body { padding: 1.1rem 1.25rem; display:flex;flex-direction:column;gap:0.85rem; }
/* Scores row */
.psc-scores-row {
display: flex; align-items: stretch; gap: 0;
background: #fafaf9; border: 2px solid #f3f4f6;
border-radius: 18px; overflow: hidden;
}
.psc-score-cell {
flex: 1; display:flex;flex-direction:column;align-items:center;
padding: 1rem 0.5rem;
position: relative;
}
.psc-score-cell + .psc-score-cell::before {
content:''; position:absolute; left:0; top:20%; bottom:20%;
width:2px; background:#f3f4f6; border-radius:2px;
}
/* Total cell — slightly different bg */
.psc-score-cell.total {
background: white;
border-right: 2px solid #f3f4f6;
flex: 1.2;
}
.psc-cell-label {
display: flex; align-items: center; gap: 0.3rem;
font-size: 0.58rem; font-weight: 800; letter-spacing: 0.12em;
text-transform: uppercase; color: #9ca3af; margin-bottom: 0.3rem;
}
.psc-cell-score {
font-weight: 900; color: #1e1b4b; line-height: 1;
}
.psc-cell-score.large { font-size: 2.8rem; }
.psc-cell-score.medium { font-size: 1.7rem; }
.psc-cell-out {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.62rem; font-weight: 600; color: #d1d5db;
margin-top: 0.2rem;
}
/* Toggle button */
.psc-toggle-btn {
width: 100%; display:flex;align-items:center;justify-content:center;gap:0.4rem;
padding: 0.55rem; border-radius: 12px; border: 2px solid #f3f4f6;
background: white; cursor: pointer;
font-family: 'Nunito', sans-serif;
font-size: 0.72rem; font-weight: 800; color: #9ca3af;
transition: all 0.15s ease;
}
.psc-toggle-btn:hover { border-color: #e9d5ff; color: #a855f7; background: #fdf4ff; }
/* Section detail cards */
.psc-detail-card {
background: #fafaf9; border: 2.5px solid #f3f4f6; border-radius: 18px;
padding: 0.9rem 1rem;
display: flex; flex-direction: column; gap: 0.65rem;
}
.psc-detail-top {
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
}
.psc-detail-icon-wrap {
width: 30px; height: 30px; border-radius: 10px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
}
.psc-detail-label {
font-size: 0.8rem; font-weight: 900; color: #1e1b4b; flex: 1;
}
.psc-conf-badge {
display: flex; align-items: center; gap: 0.3rem;
padding: 0.2rem 0.6rem; border-radius: 100px; border: 2px solid;
font-family: 'Nunito Sans', sans-serif;
font-size: 0.6rem; font-weight: 700; flex-shrink: 0;
}
.psc-conf-dot { width:6px;height:6px;border-radius:50%;flex-shrink:0; }
.psc-score-range-row {
display: flex; align-items: flex-end; justify-content: space-between;
}
.psc-detail-score {
font-size: 1.6rem; font-weight: 900; color: #1e1b4b; line-height: 1;
}
.psc-range-text {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.68rem; font-weight: 600; color: #9ca3af;
text-align: right; line-height: 1.4;
}
.psc-range-text span { font-weight: 800; color: #6b7280; }
/* Range bar */
.psc-bar-wrap {
height: 8px; border-radius: 100px; background: #f3f4f6;
position: relative; overflow: visible;
}
.psc-bar-fill {
position: absolute; height: 100%; border-radius: 100px; opacity: 0.4;
}
.psc-bar-dot {
position: absolute; width: 14px; height: 14px;
border-radius: 50%; border: 2.5px solid white;
top: 50%; transform: translate(-50%, -50%);
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
}
.psc-bar-labels {
display: flex; justify-content: space-between;
font-family: 'Nunito Sans', sans-serif;
font-size: 0.58rem; font-weight: 600; color: #d1d5db;
margin-top: 0.3rem;
}
/* Expanded animation */
.psc-expanded-wrap {
display: flex; flex-direction: column; gap: 0.6rem;
animation: pscFadeIn 0.3s cubic-bezier(0.34,1.56,0.64,1) both;
}
@keyframes pscFadeIn {
from { opacity:0; transform:translateY(-8px); }
to { opacity:1; transform:translateY(0); }
}
/* Loading */
.psc-loading {
display: flex; align-items: center; justify-content: center;
gap: 0.5rem; padding: 2rem;
font-family: 'Nunito Sans', sans-serif;
font-size: 0.82rem; font-weight: 600; color: #9ca3af;
}
.psc-spinner { animation: pscSpin 0.8s linear infinite; }
@keyframes pscSpin { to { transform: rotate(360deg); } }
.psc-error {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.82rem; font-weight: 700; color: #e11d48;
text-align: center; padding: 1.5rem;
background: #fff1f2; border-radius: 14px; border: 2px solid #fecdd3;
}
`;
// ─── Section detail ───────────────────────────────────────────────────────────
const SectionDetail = ({
label,
icon: Icon,
prediction,
iconBg,
barColor,
}: {
label: string;
icon: React.ElementType;
prediction: SectionPrediction;
iconBg: string;
barColor: string;
}) => {
const conf = getConfidenceStyle(prediction.confidence);
const pct = (v: number) => ((v - 200) / (800 - 200)) * 100;
return (
<div className="psc-detail-card">
<div className="psc-detail-top">
<div className="psc-detail-icon-wrap" style={{ background: iconBg }}>
<Icon size={15} color={barColor} />
</div>
<span className="psc-detail-label">{label}</span>
<div
className="psc-conf-badge"
style={{
background: conf.bg,
borderColor: conf.border,
color: conf.color,
}}
>
<div className="psc-conf-dot" style={{ background: conf.dot }} />
{conf.label}
</div>
</div>
<div className="psc-score-range-row">
<span className="psc-detail-score">{prediction.score}</span>
<div className="psc-range-text">
<span>Range</span>
<br />
<span>
{prediction.range_min}{prediction.range_max}
</span>
</div>
</div>
<div>
<div className="psc-bar-wrap">
<div
className="psc-bar-fill"
style={{
left: `${pct(prediction.range_min)}%`,
right: `${100 - pct(prediction.range_max)}%`,
background: barColor,
}}
/>
<div
className="psc-bar-dot"
style={{
left: `${pct(prediction.score)}%`,
background: barColor,
}}
/>
</div>
<div className="psc-bar-labels">
<span>200</span>
<span>800</span>
</div>
</div>
</div>
);
};
// ─── Main component ───────────────────────────────────────────────────────────
let stylesInjected = false;
export const PredictedScoreCard = () => {
const token = useAuthToken();
const [data, setData] = useState<PredictedScoreResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (!token) return;
(async () => {
try {
setLoading(true);
const result = await api.fetchPredictedScore(token);
setData(result);
} catch (err) {
setError("Couldn't load your predicted score.");
console.error(err);
} finally {
setLoading(false);
}
})();
}, [token]);
if (!stylesInjected) {
const tag = document.createElement("style");
tag.textContent = STYLES;
document.head.appendChild(tag);
stylesInjected = true;
}
const animatedTotal = useCountUp(data?.total_score ?? 0, 1000);
return (
<div className="psc-card">
{/* Header */}
<div className="psc-header">
<div className="psc-header-left">
<p className="psc-header-title">Predicted SAT Score</p>
<p className="psc-header-sub">Based on your practice performance</p>
</div>
<div className="psc-header-icon">
<TrendingUp size={17} color="white" />
</div>
</div>
{/* Body */}
<div className="psc-body">
{loading && (
<div className="psc-loading">
<Loader2 size={20} color="#a855f7" className="psc-spinner" />
Calculating your score...
</div>
)}
{error && !loading && <div className="psc-error"> {error}</div>}
{data && !loading && (
<>
{/* Score cells */}
<div className="psc-scores-row">
{/* Total */}
<div className="psc-score-cell total">
<div className="psc-cell-label">
<TrendingUp size={10} color="#a855f7" /> Total
</div>
<span className="psc-cell-score large">{animatedTotal}</span>
<span className="psc-cell-out">/ 1600</span>
</div>
{/* Math */}
<div className="psc-score-cell">
<div className="psc-cell-label">
<Calculator size={10} color="#7c3aed" /> Math
</div>
<span className="psc-cell-score medium">
{data.math_prediction.score}
</span>
<span className="psc-cell-out">/ 800</span>
</div>
{/* R&W */}
<div className="psc-score-cell">
<div className="psc-cell-label">
<BookOpen size={10} color="#0891b2" /> R&amp;W
</div>
<span className="psc-cell-score medium">
{data.rw_prediction.score}
</span>
<span className="psc-cell-out">/ 800</span>
</div>
</div>
{/* Toggle */}
<button
className="psc-toggle-btn"
onClick={() => setExpanded((p) => !p)}
>
{expanded ? (
<>
<ChevronUp size={13} /> Less detail
</>
) : (
<>
<ChevronDown size={13} /> Score breakdown
</>
)}
</button>
{/* Expanded */}
{expanded && (
<div className="psc-expanded-wrap">
<SectionDetail
label="Mathematics"
icon={Calculator}
prediction={data.math_prediction}
iconBg="#fdf4ff"
barColor="#a855f7"
/>
<SectionDetail
label="Reading & Writing"
icon={BookOpen}
prediction={data.rw_prediction}
iconBg="#ecfeff"
barColor="#0891b2"
/>
</div>
)}
</>
)}
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,507 @@
import { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { QuestNode, QuestArc } from "../types/quest";
import { CREW_RANKS } from "../types/quest";
import {
useQuestStore,
getQuestSummary,
getCrewRank,
} from "../stores/useQuestStore";
import { ChestOpenModal } from "./ChestOpenModal";
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700;900&family=Sorts+Mill+Goudy:ital@0;1&family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
/* ══ CARD SHELL ══ */
.qpc2-card {
position: relative; overflow: hidden;
border-radius: 24px;
background: linear-gradient(160deg, #0b1a35 0%, #060e1f 55%, #0d1530 100%);
border: 1.5px solid rgba(251,191,36,0.2);
box-shadow:
0 8px 32px rgba(0,0,0,0.35),
0 0 0 1px rgba(255,255,255,0.04) inset,
0 1px 0 rgba(255,255,255,0.08) inset;
}
/* Animated sea shimmer behind everything */
.qpc2-sea {
position: absolute; inset: 0; pointer-events: none; z-index: 0;
background:
repeating-linear-gradient(105deg, transparent 0%, transparent 55%,
rgba(56,189,248,0.022) 56%, transparent 57%),
repeating-linear-gradient(75deg, transparent 0%, transparent 70%,
rgba(56,189,248,0.014) 71%, transparent 72%);
background-size: 300% 300%, 250% 250%;
animation: qpc2Sea 12s ease-in-out infinite alternate;
}
@keyframes qpc2Sea {
0% { background-position: 0% 0%, 100% 0%; }
100% { background-position: 100% 100%, 0% 100%; }
}
/* Faint gold orb top-right */
.qpc2-orb {
position: absolute; top: -40px; right: -30px;
width: 160px; height: 160px; border-radius: 50%;
background: radial-gradient(circle, rgba(251,191,36,0.14) 0%, transparent 70%);
pointer-events: none; z-index: 0;
}
/* ══ RANK HERO (always visible) ══ */
.qpc2-hero {
position: relative; z-index: 2;
padding: 1rem 1.1rem 0.9rem;
cursor: pointer;
transition: background 0.18s ease;
}
.qpc2-hero:hover { background: rgba(255,255,255,0.025); }
.qpc2-hero-row {
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
}
.qpc2-hero-left { display: flex; align-items: center; gap: 0.75rem; flex: 1; min-width: 0; }
.qpc2-hero-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
/* Rank badge icon */
.qpc2-rank-icon {
width: 44px; height: 44px; border-radius: 14px; flex-shrink: 0;
background: linear-gradient(135deg, #1e0e4a, #3730a3);
border: 1.5px solid rgba(251,191,36,0.35);
display: flex; align-items: center; justify-content: center;
font-size: 1.35rem;
box-shadow: 0 4px 0 rgba(30,14,74,0.7), 0 0 16px rgba(251,191,36,0.1);
}
.qpc2-rank-label {
font-family: 'Cinzel', serif;
font-size: 0.78rem; font-weight: 700;
color: rgba(255,255,255,0.45); letter-spacing: 0.12em;
text-transform: uppercase; margin-bottom: 0.1rem;
}
.qpc2-rank-name {
font-family: 'Sorts Mill Goudy', serif;
font-size: 1.05rem; font-weight: 700;
color: #fbbf24;
text-shadow: 0 0 18px rgba(251,191,36,0.45);
line-height: 1.1;
}
/* Rank progress bar */
.qpc2-rank-bar-wrap {
margin-top: 0.55rem;
display: flex; align-items: center; gap: 0.6rem;
}
.qpc2-rank-bar-track {
flex: 1; height: 5px; border-radius: 100px;
background: rgba(255,255,255,0.1); overflow: hidden;
}
.qpc2-rank-bar-fill {
height: 100%; border-radius: 100px;
background: linear-gradient(90deg, #fbbf24, #f59e0b);
box-shadow: 0 0 8px rgba(251,191,36,0.5);
transition: width 0.7s cubic-bezier(0.34,1.56,0.64,1);
}
.qpc2-rank-bar-label {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.6rem; font-weight: 700;
color: rgba(255,255,255,0.35); white-space: nowrap;
}
/* Stats row */
.qpc2-stats {
display: flex; gap: 0.5rem; margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px solid rgba(255,255,255,0.07);
}
.qpc2-stat {
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 0.1rem;
}
.qpc2-stat-val {
font-family: 'Nunito', sans-serif;
font-size: 0.95rem; font-weight: 900; color: #fbbf24;
}
.qpc2-stat-lbl {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.56rem; font-weight: 700;
color: rgba(255,255,255,0.35); text-align: center;
letter-spacing: 0.06em; text-transform: uppercase;
}
.qpc2-stat-div {
width: 1px; background: rgba(255,255,255,0.08); margin: 0.1rem 0;
}
/* Chest badge */
.qpc2-chest-badge {
display: flex; align-items: center; gap: 0.22rem;
padding: 0.22rem 0.6rem;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
border-radius: 100px;
font-family: 'Nunito', sans-serif;
font-size: 0.65rem; font-weight: 900; color: #1a0800;
box-shadow: 0 2px 0 #d97706, 0 0 10px rgba(251,191,36,0.35);
animation: qpc2ChestPop 1.8s ease-in-out infinite;
}
@keyframes qpc2ChestPop {
0%,100%{ transform: scale(1); }
50% { transform: scale(1.07); }
}
/* Expand chevron */
.qpc2-chevron {
color: rgba(255,255,255,0.35);
transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), color 0.2s;
}
.qpc2-chevron.open { transform: rotate(180deg); color: #fbbf24; }
/* ══ COLLAPSIBLE BODY ══ */
.qpc2-body {
position: relative; z-index: 2;
overflow: hidden;
max-height: 0;
transition: max-height 0.4s cubic-bezier(0.4,0,0.2,1);
}
.qpc2-body.open { max-height: 600px; }
.qpc2-divider {
height: 1px; background: rgba(255,255,255,0.07); margin: 0 1.1rem;
}
/* ══ QUEST ROWS ══ */
.qpc2-quest-list { display: flex; flex-direction: column; padding: 0.5rem 0; }
.qpc2-quest-row {
display: flex; align-items: center; gap: 0.7rem;
padding: 0.75rem 1.1rem;
cursor: pointer;
transition: background 0.15s ease;
position: relative;
}
.qpc2-quest-row:hover { background: rgba(255,255,255,0.03); }
/* Left accent line = arc colour */
.qpc2-quest-row::before {
content: ''; position: absolute; left: 0; top: 16%; bottom: 16%;
width: 3px; border-radius: 0 3px 3px 0;
background: var(--ac);
opacity: 0.7;
}
.qpc2-quest-icon {
width: 38px; height: 38px; border-radius: 12px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem;
background: rgba(255,255,255,0.05);
border: 1.5px solid rgba(255,255,255,0.08);
transition: transform 0.2s ease;
}
.qpc2-quest-row:hover .qpc2-quest-icon { transform: scale(1.1) rotate(-5deg); }
.qpc2-quest-icon.claimable {
background: rgba(251,191,36,0.12);
border-color: rgba(251,191,36,0.4);
animation: qpc2Wiggle 2s ease-in-out infinite;
}
@keyframes qpc2Wiggle {
0%,100%{ transform: rotate(0deg); }
25% { transform: rotate(-8deg) scale(1.06); }
75% { transform: rotate(8deg) scale(1.06); }
}
.qpc2-quest-body { flex: 1; min-width: 0; }
.qpc2-quest-arc {
font-size: 0.57rem; font-weight: 800; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--ac);
margin-bottom: 0.08rem;
}
.qpc2-quest-title {
font-family: 'Sorts Mill Goudy', serif;
font-size: 0.82rem; font-weight: 700; color: rgba(255,255,255,0.9);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: 0.28rem;
}
.qpc2-mini-track {
height: 4px; background: rgba(255,255,255,0.08);
border-radius: 100px; overflow: hidden; margin-bottom: 0.18rem;
}
.qpc2-mini-fill {
height: 100%; border-radius: 100px;
background: var(--ac);
box-shadow: 0 0 5px color-mix(in srgb, var(--ac) 55%, transparent);
transition: width 0.5s cubic-bezier(0.34,1.56,0.64,1);
}
.qpc2-mini-label {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.58rem; font-weight: 700; color: rgba(255,255,255,0.3);
}
.qpc2-claimable-label {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.62rem; font-weight: 700; color: #fbbf24;
}
/* Claim button */
.qpc2-claim-btn {
padding: 0.32rem 0.7rem; border: none; border-radius: 100px; cursor: pointer;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
font-family: 'Nunito', sans-serif;
font-size: 0.65rem; font-weight: 900; color: #1a0800;
box-shadow: 0 2px 0 #d97706, 0 3px 8px rgba(251,191,36,0.25);
flex-shrink: 0; white-space: nowrap;
transition: all 0.12s ease;
}
.qpc2-claim-btn:hover { transform: translateY(-1px); box-shadow: 0 3px 0 #d97706; }
.qpc2-claim-btn:active { transform: translateY(1px); }
/* ══ FOOTER LINK ══ */
.qpc2-footer {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
padding: 0.65rem 1.1rem;
border-top: 1px solid rgba(255,255,255,0.07);
cursor: pointer;
transition: background 0.15s ease;
}
.qpc2-footer:hover { background: rgba(255,255,255,0.03); }
.qpc2-footer-label {
font-family: 'Nunito', sans-serif;
font-size: 0.72rem; font-weight: 800;
color: rgba(251,191,36,0.7);
letter-spacing: 0.04em;
}
.qpc2-footer:hover .qpc2-footer-label { color: #fbbf24; }
/* ══ EMPTY STATE ══ */
.qpc2-empty {
padding: 1.25rem 1.1rem; text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 0.35rem;
}
.qpc2-empty-title {
font-family: 'Sorts Mill Goudy', serif;
font-size: 0.88rem; font-weight: 700; color: rgba(255,255,255,0.55);
}
.qpc2-empty-sub {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.68rem; font-weight: 600; color: rgba(255,255,255,0.25);
}
`;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getActiveQuests(arcs: QuestArc[]) {
const results: { node: QuestNode; arc: QuestArc }[] = [];
for (const arc of arcs) {
for (const node of arc.nodes) {
if (node.status === "claimable" || node.status === "active") {
results.push({ node, arc });
}
}
}
// Claimable first, then active; max 2 shown
results.sort((a, b) => {
if (a.node.status === "claimable" && b.node.status !== "claimable")
return -1;
if (b.node.status === "claimable" && a.node.status !== "claimable")
return 1;
return 0;
});
return results.slice(0, 2);
}
// ─── Component ────────────────────────────────────────────────────────────────
interface Props {
onViewAll?: () => void;
}
export const QuestProgressCard = ({ onViewAll }: Props) => {
const navigate = useNavigate();
const arcs = useQuestStore((s) => s.arcs);
const claimNode = useQuestStore((s) => s.claimNode);
const summary = getQuestSummary(arcs);
const rank = getCrewRank(arcs);
const activeQuests = getActiveQuests(arcs);
const [open, setOpen] = useState(false);
const [claimingNode, setClaimingNode] = useState<{
node: QuestNode;
arcId: string;
} | null>(null);
const handleViewAll = () => {
if (onViewAll) onViewAll();
else navigate("/student/quests");
};
const handleClaim = (node: QuestNode, arcId: string) => {
setClaimingNode({ node, arcId });
};
const handleChestClose = () => {
if (!claimingNode) return;
claimNode(claimingNode.arcId, claimingNode.node.id);
setClaimingNode(null);
};
// Next rank label
const nextRankLabel = rank.next
? `${Math.round(rank.progressToNext * 100)}% to ${rank.next.label}`
: "Max rank reached";
return (
<>
<style>{STYLES}</style>
<div className="qpc2-card">
{/* Atmosphere layers */}
<div className="qpc2-sea" />
<div className="qpc2-orb" />
{/* ── Rank hero (always visible, tap to expand) ── */}
<div className="qpc2-hero" onClick={() => setOpen((o) => !o)}>
<div className="qpc2-hero-row">
<div className="qpc2-hero-left">
<div className="qpc2-rank-icon">{rank.emoji}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p className="qpc2-rank-label">Crew Rank</p>
<p className="qpc2-rank-name">{rank.label}</p>
</div>
</div>
<div className="qpc2-hero-right">
{summary.claimableNodes > 0 && (
<div className="qpc2-chest-badge">
📦 {summary.claimableNodes}
</div>
)}
<ChevronDown
size={18}
className={`qpc2-chevron${open ? " open" : ""}`}
/>
</div>
</div>
{/* Rank progress bar */}
<div className="qpc2-rank-bar-wrap">
<div className="qpc2-rank-bar-track">
<div
className="qpc2-rank-bar-fill"
style={{ width: `${Math.round(rank.progressToNext * 100)}%` }}
/>
</div>
<span className="qpc2-rank-bar-label">{nextRankLabel}</span>
</div>
{/* Stats strip */}
<div className="qpc2-stats">
{[
{ val: `${summary.earnedXP}`, lbl: "XP Earned" },
null,
{
val: `${summary.completedNodes}/${summary.totalNodes}`,
lbl: "Quests Done",
},
null,
{
val: `${summary.arcsCompleted}/${summary.totalArcs}`,
lbl: "Arcs",
},
].map((item, i) =>
item === null ? (
<div key={i} className="qpc2-stat-div" />
) : (
<div key={i} className="qpc2-stat">
<span className="qpc2-stat-val">{item.val}</span>
<span className="qpc2-stat-lbl">{item.lbl}</span>
</div>
),
)}
</div>
</div>
{/* ── Collapsible quest list ── */}
<div className={`qpc2-body${open ? " open" : ""}`}>
<div className="qpc2-divider" />
<div className="qpc2-quest-list">
{activeQuests.length === 0 ? (
<div className="qpc2-empty">
<span style={{ fontSize: "1.75rem" }}></span>
<p className="qpc2-empty-title">All caught up, Captain!</p>
<p className="qpc2-empty-sub">
No active quests keep sailing
</p>
</div>
) : (
activeQuests.map(({ node, arc }) => {
const pct = Math.min(
100,
Math.round((node.progress / node.requirement.target) * 100),
);
const isClaimable = node.status === "claimable";
return (
<div
key={node.id}
className="qpc2-quest-row"
style={{ "--ac": arc.accentColor } as React.CSSProperties}
onClick={() => !isClaimable && handleViewAll()}
>
<div
className={`qpc2-quest-icon${isClaimable ? " claimable" : ""}`}
>
{isClaimable ? "📦" : node.emoji}
</div>
<div className="qpc2-quest-body">
<p className="qpc2-quest-arc">
{arc.emoji} {arc.name}
</p>
<p className="qpc2-quest-title">{node.title}</p>
{isClaimable ? (
<p className="qpc2-claimable-label">
Chest ready to open!
</p>
) : (
<>
<div className="qpc2-mini-track">
<div
className="qpc2-mini-fill"
style={{ width: `${pct}%` }}
/>
</div>
<p className="qpc2-mini-label">
{node.progress} / {node.requirement.target}{" "}
{node.requirement.label}
</p>
</>
)}
</div>
{isClaimable ? (
<button
className="qpc2-claim-btn"
onClick={(e) => {
e.stopPropagation();
handleClaim(node, arc.id);
}}
>
Open 📦
</button>
) : (
<ChevronRight size={14} color="rgba(255,255,255,0.2)" />
)}
</div>
);
})
)}
</div>
{/* Footer — navigate to full map */}
<div className="qpc2-footer" onClick={handleViewAll}>
<span className="qpc2-footer-label">View full quest map</span>
<ChevronRight size={14} color="rgba(251,191,36,0.7)" />
</div>
</div>
</div>
{claimingNode && (
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} />
)}
</>
);
};

View File

@ -0,0 +1,36 @@
import {
Target,
BookOpen,
BarChart3,
Layers,
Calculator,
TrendingUp,
Grid,
Scale,
Percent,
Car,
Square,
Triangle,
Circle,
} from "lucide-react";
import type { JSX } from "react";
export const renderLessonIcon = (iconName: string) => {
const icons: Record<string, JSX.Element> = {
Target: <Target size={16} />,
BookOpen: <BookOpen size={16} />,
BarChart3: <BarChart3 size={16} />,
Layers: <Layers size={16} />,
Calculator: <Calculator size={16} />,
TrendingUp: <TrendingUp size={16} />,
Grid: <Grid size={16} />,
Scale: <Scale size={16} />,
Percent: <Percent size={16} />,
Chart: <Car size={16} />,
Square: <Square size={16} />,
Triangle: <Triangle size={16} />,
Circle: <Circle size={16} />,
};
return icons[iconName] ?? <BookOpen size={16} />;
};

View File

@ -0,0 +1,682 @@
import { motion, AnimatePresence } from "framer-motion";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Search,
X,
BookOpen,
Zap,
Target,
Trophy,
User,
Home,
ArrowRight,
Clock,
Flame,
} from "lucide-react";
import type { PracticeSheet } from "../types/sheet";
import { useNavigate } from "react-router-dom";
import type { SearchItem } from "../types/search";
import { formatGroupTitle } from "../lib/utils";
interface Props {
sheets: PracticeSheet[];
onClose: () => void;
searchQuery: string;
setSearchQuery: (value: string) => void;
}
// ─── Nav items ────────────────────────────────────────────────────────────────
const NAV_ITEMS: (SearchItem & {
icon: React.ElementType;
color: string;
bg: string;
})[] = [
{
type: "route",
title: "Hard Test Modules",
description: "Tackle the hardest SAT questions",
route: "/student/hard-test-modules",
group: "Pages",
icon: Trophy,
color: "#84cc16",
bg: "#f7ffe4",
},
{
type: "route",
title: "Targeted Practice",
description: "Focus on your weak spots",
route: "/student/practice/targeted-practice",
group: "Pages",
icon: Target,
color: "#ef4444",
bg: "#fff5f5",
},
{
type: "route",
title: "Drills",
description: "Train speed and accuracy",
route: "/student/practice/drills",
group: "Pages",
icon: Zap,
color: "#0891b2",
bg: "#ecfeff",
},
{
type: "route",
title: "Leaderboard",
description: "See how you rank against others",
route: "/student/rewards",
group: "Pages",
icon: Trophy,
color: "#f97316",
bg: "#fff7ed",
},
{
type: "route",
title: "Practice",
description: "Browse all practice modes",
route: "/student/practice",
group: "Pages",
icon: BookOpen,
color: "#a855f7",
bg: "#fdf4ff",
},
{
type: "route",
title: "Lessons",
description: "Watch expert SAT technique lessons",
route: "/student/lessons",
group: "Pages",
icon: BookOpen,
color: "#0891b2",
bg: "#ecfeff",
},
{
type: "route",
title: "Profile",
description: "View your profile and settings",
route: "/student/profile",
group: "Pages",
icon: User,
color: "#e11d48",
bg: "#fff1f2",
},
{
type: "route",
title: "Home",
description: "Go back to home",
route: "/student/home",
group: "Pages",
icon: Home,
color: "#f97316",
bg: "#fff7ed",
},
];
const NAV_MAP = Object.fromEntries(NAV_ITEMS.map((n) => [n.route, n]));
const STATUS_META = {
IN_PROGRESS: {
label: "In Progress",
color: "#9333ea",
bg: "#f3e8ff",
icon: "🔄",
},
COMPLETED: {
label: "Completed",
color: "#16a34a",
bg: "#f0fdf4",
icon: "✅",
},
NOT_STARTED: {
label: "Not Started",
color: "#6b7280",
bg: "#f3f4f6",
icon: "📋",
},
};
// ─── Recent items (session memory) ───────────────────────────────────────────
const SESSION_KEY = "so_recent";
const MAX_RECENT = 5;
const getRecent = (): SearchItem[] => {
try {
return JSON.parse(sessionStorage.getItem(SESSION_KEY) ?? "[]");
} catch {
return [];
}
};
const addRecent = (item: SearchItem) => {
const prev = getRecent().filter((r) => r.route !== item.route);
const next = [item, ...prev].slice(0, MAX_RECENT);
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
};
// ─── Highlight helper ─────────────────────────────────────────────────────────
const highlightText = (text: string, query: string) => {
if (!query.trim()) return <>{text}</>;
const esc = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${esc})`, "gi");
const parts = text.split(regex);
return (
<>
{parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? (
<mark
key={i}
style={{
background: "#e9d5ff",
color: "#6b21a8",
borderRadius: 4,
padding: "0 2px",
}}
>
{part}
</mark>
) : (
part
),
)}
</>
);
};
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
.so-overlay {
position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,0.35);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex; flex-direction: column;
align-items: center; padding-top: 5rem;
padding-left: 1rem; padding-right: 1rem;
}
.so-box {
width: 100%; max-width: 560px;
background: #fffbf4;
border: 2.5px solid #f3f4f6;
border-radius: 28px;
box-shadow: 0 20px 60px rgba(0,0,0,0.18), 0 6px 16px rgba(0,0,0,0.08);
overflow: hidden;
display: flex; flex-direction: column;
max-height: calc(100vh - 6rem);
}
/* Input row */
.so-input-row {
display: flex; align-items: center; gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 2px solid #f3f4f6;
flex-shrink: 0;
}
.so-input {
flex: 1; outline: none; border: none; background: transparent;
font-family: 'Nunito', sans-serif;
font-size: 0.95rem; font-weight: 800; color: #1e1b4b;
}
.so-input::placeholder { color: #d1d5db; font-weight: 700; }
.so-close-btn {
width: 30px; height: 30px; border-radius: 50%; border: 2.5px solid #f3f4f6;
background: white; cursor: pointer; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s ease;
}
.so-close-btn:hover { border-color: #fecdd3; background: #fff1f2; }
/* Scrollable results */
.so-results {
overflow-y: auto; flex: 1;
padding: 0.75rem 0.75rem 1rem;
-webkit-overflow-scrolling: touch;
display: flex; flex-direction: column; gap: 1rem;
}
/* Section label */
.so-section-label {
font-size: 0.58rem; font-weight: 800; letter-spacing: 0.18em;
text-transform: uppercase; color: #9ca3af;
padding: 0 0.5rem; margin-bottom: -0.35rem;
display: flex; align-items: center; gap: 0.4rem;
}
/* Result rows */
.so-item {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.7rem 0.75rem; border-radius: 16px; cursor: pointer;
transition: background 0.15s ease, transform 0.1s ease;
border: 2px solid transparent;
}
.so-item:hover {
background: white; border-color: #f3f4f6;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
transform: translateX(2px);
}
.so-item:active { transform: scale(0.98); }
.so-item-icon {
width: 36px; height: 36px; border-radius: 11px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 0.85rem;
}
.so-item-body { flex: 1; min-width: 0; }
.so-item-title {
font-family: 'Nunito', sans-serif;
font-size: 0.88rem; font-weight: 900; color: #1e1b4b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.so-item-desc {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.72rem; font-weight: 600; color: #9ca3af;
margin-top: 0.05rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.so-item-arrow { color: #d1d5db; flex-shrink: 0; transition: color 0.15s ease; }
.so-item:hover .so-item-arrow { color: #a855f7; }
/* Sheet status chip inline */
.so-status-chip {
font-size: 0.6rem; font-weight: 800; letter-spacing: 0.08em;
text-transform: uppercase; border-radius: 100px; padding: 0.15rem 0.5rem;
flex-shrink: 0;
}
/* Quick nav chips (shown when empty query) */
.so-quick-wrap {
display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0 0.25rem;
}
.so-quick-chip {
display: flex; align-items: center; gap: 0.4rem;
background: white; border: 2.5px solid #f3f4f6; border-radius: 100px;
padding: 0.45rem 0.85rem; cursor: pointer;
font-family: 'Nunito', sans-serif; font-size: 0.75rem; font-weight: 800;
color: #6b7280;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
transition: all 0.15s ease;
}
.so-quick-chip:hover { transform: translateY(-2px); box-shadow: 0 6px 14px rgba(0,0,0,0.07); border-color: #e9d5ff; color: #a855f7; }
/* Empty state */
.so-empty {
display: flex; flex-direction: column; align-items: center;
padding: 2rem 1rem; gap: 0.5rem;
font-family: 'Nunito Sans', sans-serif;
}
.so-empty-emoji { font-size: 2rem; }
.so-empty-text { font-size: 0.85rem; font-weight: 700; color: #9ca3af; }
.so-empty-sub { font-size: 0.75rem; font-weight: 600; color: #d1d5db; text-align: center; }
/* Keyboard hint */
.so-kbd-row {
display: flex; align-items: center; justify-content: center; gap: 1rem;
padding: 0.6rem 1rem;
border-top: 2px solid #f9fafb;
flex-shrink: 0;
}
.so-kbd-hint {
display: flex; align-items: center; gap: 0.3rem;
font-family: 'Nunito Sans', sans-serif;
font-size: 0.62rem; font-weight: 600; color: #d1d5db;
}
.so-kbd {
background: white; border: 1.5px solid #e5e7eb; border-radius: 5px;
padding: 0.1rem 0.4rem; font-size: 0.6rem; font-weight: 800;
color: #9ca3af; box-shadow: 0 1px 0 #d1d5db;
}
/* Highlight count badge */
.so-count-badge {
font-family: 'Nunito', sans-serif;
font-size: 0.65rem; font-weight: 800;
background: #f3e8ff; color: #9333ea;
border-radius: 100px; padding: 0.15rem 0.5rem; flex-shrink: 0;
}
`;
// ─── Main component ───────────────────────────────────────────────────────────
export const SearchOverlay = ({
sheets,
onClose,
searchQuery,
setSearchQuery,
}: Props) => {
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const [recent, setRecent] = useState<SearchItem[]>(getRecent);
const [focused, setFocused] = useState(-1); // keyboard nav index
// Build full search item list
const searchItems = useMemo<SearchItem[]>(() => {
const sheetItems = sheets.map((sheet) => ({
type: "sheet" as const,
id: sheet.id,
title: sheet.title,
description: sheet.description ?? "Practice sheet",
route: `/student/practice/${sheet.id}`,
group: formatGroupTitle(sheet.user_status),
status: sheet.user_status,
}));
return [...NAV_ITEMS, ...sheetItems];
}, [sheets]);
// Filtered + grouped results
const groupedResults = useMemo(() => {
if (!searchQuery.trim()) return {};
const q = searchQuery.toLowerCase();
const filtered = searchItems.filter(
(item) =>
item.title?.toLowerCase().includes(q) ||
item.description?.toLowerCase().includes(q),
);
return filtered.reduce<Record<string, SearchItem[]>>((acc, item) => {
(acc[item.group] ??= []).push(item);
return acc;
}, {});
}, [searchQuery, searchItems]);
const flatResults = useMemo(
() => Object.values(groupedResults).flat(),
[groupedResults],
);
// ESC to close, arrow keys + enter for keyboard nav
useEffect(() => {
const handle = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setFocused((f) => Math.min(f + 1, flatResults.length - 1));
}
if (e.key === "ArrowUp") {
e.preventDefault();
setFocused((f) => Math.max(f - 1, 0));
}
if (e.key === "Enter" && focused >= 0 && flatResults[focused]) {
handleSelect(flatResults[focused]);
}
};
window.addEventListener("keydown", handle);
return () => window.removeEventListener("keydown", handle);
}, [onClose, focused, flatResults]);
// Reset focused when query changes
useEffect(() => {
setFocused(-1);
}, [searchQuery]);
const handleSelect = (item: SearchItem) => {
addRecent(item);
setRecent(getRecent());
onClose();
navigate(item.route!);
};
const totalCount = flatResults.length;
return (
<AnimatePresence>
<motion.div
className="so-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<style>{STYLES}</style>
<motion.div
className="so-box"
initial={{ y: -24, opacity: 0, scale: 0.97 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: -24, opacity: 0, scale: 0.97 }}
transition={{ type: "spring", stiffness: 380, damping: 28 }}
onClick={(e) => e.stopPropagation()}
>
{/* Input row */}
<div className="so-input-row">
<Search size={18} color="#9ca3af" />
<input
ref={inputRef}
autoFocus
className="so-input"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search sheets, pages, topics..."
/>
{searchQuery && totalCount > 0 && (
<span className="so-count-badge">
{totalCount} result{totalCount !== 1 ? "s" : ""}
</span>
)}
<button className="so-close-btn" onClick={onClose}>
<X size={13} color="#9ca3af" />
</button>
</div>
{/* Results */}
<div className="so-results">
{/* ── Empty query: recent + quick nav ── */}
{!searchQuery && (
<>
{recent.length > 0 && (
<div>
<p className="so-section-label">
<Clock size={10} /> Recent
</p>
{recent.map((item, i) => {
const navMeta = NAV_MAP[item.route!];
const Icon = navMeta?.icon ?? BookOpen;
const color = navMeta?.color ?? "#a855f7";
const bg = navMeta?.bg ?? "#fdf4ff";
return (
<div
key={i}
className="so-item"
onClick={() => handleSelect(item)}
>
<div
className="so-item-icon"
style={{ background: bg }}
>
<Icon size={16} color={color} />
</div>
<div className="so-item-body">
<p className="so-item-title">{item.title}</p>
{item.description && (
<p className="so-item-desc">{item.description}</p>
)}
</div>
<ArrowRight size={15} className="so-item-arrow" />
</div>
);
})}
</div>
)}
<div>
<p className="so-section-label"> Quick nav</p>
<div
className="so-quick-wrap"
style={{ marginTop: "0.5rem" }}
>
{NAV_ITEMS.map((item, i) => (
<button
key={i}
className="so-quick-chip"
onClick={() => handleSelect(item)}
>
<item.icon size={13} color={item.color} />
{item.title}
</button>
))}
</div>
</div>
{sheets.length > 0 && (
<div>
<p className="so-section-label">
<Flame size={10} /> In progress
</p>
{sheets
.filter((s) => s.user_status === "IN_PROGRESS")
.slice(0, 3)
.map((sheet) => {
const item: SearchItem = {
type: "sheet",
title: sheet.title,
description: sheet.description,
route: `/student/practice/${sheet.id}`,
group: "In Progress",
status: sheet.user_status,
};
return (
<div
key={sheet.id}
className="so-item"
onClick={() => handleSelect(item)}
>
<div
className="so-item-icon"
style={{ background: "#f3e8ff" }}
>
<BookOpen size={16} color="#a855f7" />
</div>
<div className="so-item-body">
<p className="so-item-title">{sheet.title}</p>
{sheet.description && (
<p className="so-item-desc">
{sheet.description}
</p>
)}
</div>
<span
className="so-status-chip"
style={{
background: "#f3e8ff",
color: "#9333ea",
}}
>
In Progress
</span>
</div>
);
})}
</div>
)}
</>
)}
{/* ── No results ── */}
{searchQuery && totalCount === 0 && (
<div className="so-empty">
<span className="so-empty-emoji">🔍</span>
<p className="so-empty-text">No results for "{searchQuery}"</p>
<p className="so-empty-sub">
Try searching for a topic, sheet title, or page name
</p>
</div>
)}
{/* ── Results grouped ── */}
{searchQuery &&
totalCount > 0 &&
Object.entries(groupedResults).map(([group, items]) => (
<div key={group}>
<p className="so-section-label">{group}</p>
{items.map((item, index) => {
const globalIdx = flatResults.indexOf(item);
const isFocused = globalIdx === focused;
const navMeta = NAV_MAP[item.route!];
const Icon = navMeta?.icon ?? BookOpen;
const iconColor = navMeta?.color ?? "#a855f7";
const iconBg = navMeta?.bg ?? "#fdf4ff";
const statusMeta = item.status
? STATUS_META[item.status as keyof typeof STATUS_META]
: null;
return (
<motion.div
key={index}
className="so-item"
style={{
background: isFocused ? "white" : undefined,
borderColor: isFocused ? "#e9d5ff" : undefined,
boxShadow: isFocused
? "0 4px 12px rgba(0,0,0,0.06)"
: undefined,
}}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
onClick={() => handleSelect(item)}
>
<div
className="so-item-icon"
style={{ background: iconBg }}
>
{item.type === "sheet" ? (
<span style={{ fontSize: "1rem" }}>
{statusMeta?.icon ?? "📋"}
</span>
) : (
<Icon size={16} color={iconColor} />
)}
</div>
<div className="so-item-body">
<p className="so-item-title">
{highlightText(item.title, searchQuery)}
</p>
{item.description && (
<p className="so-item-desc">
{highlightText(item.description, searchQuery)}
</p>
)}
</div>
{statusMeta && (
<span
className="so-status-chip"
style={{
background: statusMeta.bg,
color: statusMeta.color,
}}
>
{statusMeta.label}
</span>
)}
<ArrowRight size={15} className="so-item-arrow" />
</motion.div>
);
})}
</div>
))}
</div>
{/* Keyboard hints */}
<div className="so-kbd-row">
<div className="so-kbd-hint">
<kbd className="so-kbd"></kbd> Navigate
</div>
<div className="so-kbd-hint">
<kbd className="so-kbd"></kbd> Open
</div>
<div className="so-kbd-hint">
<kbd className="so-kbd">Esc</kbd> Close
</div>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
};

View File

@ -0,0 +1,87 @@
import React, { useState } from 'react';
const BoxPlotAnatomyWidget: React.FC = () => {
const [q1, setQ1] = useState(20);
const [q3, setQ3] = useState(60);
const [med, setMed] = useState(40);
const min = 10;
const max = 90;
// Enforce constraints
const handleMedChange = (val: number) => {
setMed(Math.max(q1, Math.min(q3, val)));
};
const handleQ1Change = (val: number) => {
const newQ1 = Math.min(val, med);
setQ1(Math.max(min, newQ1));
};
const handleQ3Change = (val: number) => {
const newQ3 = Math.max(val, med);
setQ3(Math.min(max, newQ3));
};
const scale = (val: number) => ((val) / 100) * 100; // 0-100 domain mapped to %
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 select-none">
<div className="relative h-40 mt-8 mb-4">
{/* Axis Line */}
<div className="absolute top-1/2 left-[10%] right-[10%] h-0.5 bg-slate-300 -translate-y-1/2"></div>
{/* Range Line (Whiskers) */}
<div className="absolute top-1/2 bg-slate-800 h-0.5 -translate-y-1/2 transition-all"
style={{ left: `${scale(min)}%`, right: `${100 - scale(max)}%` }}></div>
{/* Endpoints (Min/Max) */}
<div className="absolute top-1/2 h-4 w-0.5 bg-slate-800 -translate-y-1/2" style={{ left: `${scale(min)}%` }}>
<span className="absolute -top-6 left-1/2 -translate-x-1/2 text-xs font-bold text-slate-500">Min</span>
</div>
<div className="absolute top-1/2 h-4 w-0.5 bg-slate-800 -translate-y-1/2" style={{ left: `${scale(max)}%` }}>
<span className="absolute -top-6 left-1/2 -translate-x-1/2 text-xs font-bold text-slate-500">Max</span>
</div>
{/* The Box */}
<div className="absolute top-1/2 -translate-y-1/2 h-16 bg-amber-100 border-2 border-amber-500 rounded-sm transition-all"
style={{ left: `${scale(q1)}%`, width: `${scale(q3) - scale(q1)}%` }}>
{/* Median Line */}
<div className="absolute top-0 bottom-0 w-1 bg-amber-600 left-[50%] -translate-x-1/2 transition-all"
style={{ left: `${((med - q1) / (q3 - q1)) * 100}%` }}>
</div>
</div>
{/* Labels below for Q1, Med, Q3 */}
<div className="absolute top-[70%] text-xs font-bold text-amber-700 -translate-x-1/2 transition-all" style={{ left: `${scale(q1)}%` }}>Q1</div>
<div className="absolute top-[70%] text-xs font-bold text-amber-900 -translate-x-1/2 transition-all" style={{ left: `${scale(med)}%` }}>Median</div>
<div className="absolute top-[70%] text-xs font-bold text-amber-700 -translate-x-1/2 transition-all" style={{ left: `${scale(q3)}%` }}>Q3</div>
</div>
<div className="grid grid-cols-3 gap-6 mt-8">
<div>
<label className="text-xs font-bold text-slate-400 uppercase">Q1 (25th %)</label>
<input type="range" min={min} max={max} value={q1} onChange={e => handleQ1Change(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-amber-500"/>
</div>
<div>
<label className="text-xs font-bold text-slate-400 uppercase">Median (50th %)</label>
<input type="range" min={min} max={max} value={med} onChange={e => handleMedChange(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-amber-700"/>
</div>
<div>
<label className="text-xs font-bold text-slate-400 uppercase">Q3 (75th %)</label>
<input type="range" min={min} max={max} value={q3} onChange={e => handleQ3Change(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-amber-500"/>
</div>
</div>
<div className="mt-6 p-4 bg-amber-50 border border-amber-100 rounded-lg text-center">
<div className="text-sm font-mono text-amber-900">
IQR (Box Width) = <span className="font-bold">{q3 - q1}</span>
</div>
<p className="text-xs text-amber-700/70 mt-1">The middle 50% of data lies inside the box.</p>
</div>
</div>
);
};
export default BoxPlotAnatomyWidget;

View File

@ -0,0 +1,111 @@
import React, { useState } from 'react';
const BoxPlotComparisonWidget: React.FC = () => {
// Box Plot A is fixed
const statsA = { min: 10, q1: 18, med: 24, q3: 30, max: 42 };
// Box Plot B is adjustable
const [shift, setShift] = useState(0); // Shift median
const [spread, setSpread] = useState(1); // Scale spread
const statsB = {
min: 10 + shift - (5 * (spread - 1)), // Just approximating visual expansion
q1: 16 + shift - (2 * (spread - 1)),
med: 26 + shift,
q3: 34 + shift + (2 * (spread - 1)),
max: 38 + shift + (4 * (spread - 1))
};
const scaleX = (val: number) => (val / 60) * 100; // 0 to 60 range mapping to %
const BoxPlot = ({ stats, color, label }: { stats: any, color: string, label: string }) => {
const leftW = scaleX(stats.min);
const rightW = scaleX(stats.max);
const boxL = scaleX(stats.q1);
const boxR = scaleX(stats.q3);
const med = scaleX(stats.med);
return (
<div className="relative h-16 w-full mb-8 group">
<div className="absolute left-0 top-0 text-xs font-bold text-slate-400">{label}</div>
{/* Main Line (Whisker to Whisker) */}
<div className="absolute top-1/2 left-0 h-0.5 bg-slate-300 -translate-y-1/2"
style={{ left: `${leftW}%`, width: `${rightW - leftW}%` }} />
{/* Whiskers */}
<div className="absolute top-1/2 h-3 w-0.5 bg-slate-400 -translate-y-1/2" style={{ left: `${leftW}%` }} />
<div className="absolute top-1/2 h-3 w-0.5 bg-slate-400 -translate-y-1/2" style={{ left: `${rightW}%` }} />
{/* Box */}
<div className={`absolute top-1/2 -translate-y-1/2 h-8 border-2 ${color} bg-white opacity-90`}
style={{ left: `${boxL}%`, width: `${boxR - boxL}%`, borderColor: 'currentColor' }}>
</div>
{/* Median Line */}
<div className="absolute top-1/2 h-8 w-1 bg-slate-800 -translate-y-1/2" style={{ left: `${med}%` }} />
{/* Labels on Hover */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-10 left-0 w-full text-center text-xs font-mono text-slate-500 pointer-events-none">
Min:{stats.min.toFixed(0)} Q1:{stats.q1.toFixed(0)} Med:{stats.med.toFixed(0)} Q3:{stats.q3.toFixed(0)} Max:{stats.max.toFixed(0)}
</div>
</div>
);
};
const iqrA = statsA.q3 - statsA.q1;
const iqrB = statsB.q3 - statsB.q1;
const rangeA = statsA.max - statsA.min;
const rangeB = statsB.max - statsB.min;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="mb-6 relative h-48 border-b border-slate-200">
<BoxPlot stats={statsA} color="text-indigo-500" label="Dataset A (Fixed)" />
<BoxPlot stats={statsB} color="text-rose-500" label="Dataset B (Adjustable)" />
{/* Axis */}
<div className="absolute bottom-0 w-full flex justify-between text-xs text-slate-400 font-mono px-2">
<span>0</span><span>10</span><span>20</span><span>30</span><span>40</span><span>50</span><span>60</span>
</div>
</div>
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div>
<label className="text-xs font-bold text-slate-500 uppercase">Shift Center (Median B)</label>
<input type="range" min="-15" max="15" value={shift} onChange={e => setShift(parseInt(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"/>
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase">Adjust Spread (IQR B)</label>
<input type="range" min="0.5" max="2" step="0.1" value={spread} onChange={e => setSpread(parseFloat(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"/>
</div>
</div>
<div className="flex-1 grid grid-cols-2 gap-4">
<div className="bg-slate-50 p-3 rounded border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase">Median Comparison</div>
<div className="flex justify-between items-center mt-1">
<span className="text-indigo-600 font-bold">{statsA.med}</span>
<span className="text-slate-400">{statsA.med > statsB.med ? '>' : statsA.med < statsB.med ? '<' : '='}</span>
<span className="text-rose-600 font-bold">{statsB.med}</span>
</div>
</div>
<div className="bg-slate-50 p-3 rounded border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase">IQR Comparison</div>
<div className="flex justify-between items-center mt-1">
<span className="text-indigo-600 font-bold">{iqrA.toFixed(0)}</span>
<span className="text-slate-400">{iqrA > iqrB ? '>' : iqrA < iqrB ? '<' : '='}</span>
<span className="text-rose-600 font-bold">{iqrB.toFixed(0)}</span>
</div>
</div>
<div className="col-span-2 text-xs text-slate-500 text-center">
The box length represents the IQR (Middle 50%). The whiskers represent the full Range.
</div>
</div>
</div>
</div>
);
};
export default BoxPlotComparisonWidget;

View File

@ -0,0 +1,121 @@
import React, { useState, useRef, useEffect } from 'react';
const CircleTheoremsWidget: React.FC = () => {
// C is the point on the major arc
const [angleC, setAngleC] = useState(230); // Position in degrees on the circle
const svgRef = useRef<SVGSVGElement>(null);
const isDragging = useRef(false);
const R = 120;
const center = { x: 200, y: 180 };
// Fixed points A and B at the bottom
const angleA = 330; // 30 deg below x axis
const angleB = 210; // 150 deg below x axis? No, let's place them symmetrically
// Let's place A and B to define a nice arc
// A at -30 deg (330), B at 210 is too far.
// Let's put A at 320 (-40) and B at 220 (-140).
// Wait, standard unit circle angles.
// A at 340 (-20), B at 200. Arc is 140 deg at bottom.
// Major arc is top. C moves on top.
const posA = { x: center.x + R * Math.cos(340 * Math.PI/180), y: center.y - R * Math.sin(340 * Math.PI/180) }; // SVG y inverted logic?
// Let's just use standard math cos/sin and add to center.y
// SVG y is positive down.
const getPos = (deg: number) => ({
x: center.x + R * Math.cos(deg * Math.PI / 180),
y: center.y + R * Math.sin(deg * Math.PI / 180)
});
const A = getPos(30); // Bottom Right
const B = getPos(150); // Bottom Left
// Central angle is 120 degrees (150 - 30).
const centralAngleValue = 120;
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging.current || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const dx = e.clientX - rect.left - center.x;
const dy = e.clientY - rect.top - center.y;
let deg = Math.atan2(dy, dx) * 180 / Math.PI;
if (deg < 0) deg += 360;
// Constrain C to the major arc (approx 160 to 350 is the "bad" zone? No, A=30, B=150.
// Bad zone is between 30 and 150 (the minor arc).
// Let's allow C anywhere except the minor arc to avoid crossing lines weirdly.
if (deg > 40 && deg < 140) return; // Simple constraint
setAngleC(deg);
};
const C = getPos(angleC);
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col items-center">
<h3 className="font-bold text-slate-700 mb-2">Central vs. Inscribed Angle</h3>
<div className="text-sm text-slate-500 mb-4 text-center max-w-md">
Drag point <strong className="text-emerald-600">C</strong> along the top arc. Notice that the inscribed angle stays constant!
</div>
<svg
ref={svgRef}
width="400"
height="350"
onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = false}
onMouseLeave={() => isDragging.current = false}
className="select-none"
>
{/* Circle */}
<circle cx={center.x} cy={center.y} r={R} stroke="#cbd5e1" strokeWidth="2" fill="transparent" />
{/* Central Angle Lines */}
<path d={`M ${A.x} ${A.y} L ${center.x} ${center.y} L ${B.x} ${B.y}`} stroke="#e2e8f0" strokeWidth="2" fill="transparent" strokeDasharray="5,5"/>
{/* Central Angle Wedge */}
{/* 30 to 150 */}
<path d={`M ${center.x} ${center.y} L ${A.x} ${A.y} A ${R} ${R} 0 0 1 ${B.x} ${B.y} Z`} fill="rgba(99, 102, 241, 0.1)" stroke="none" />
<text x={center.x} y={center.y + 40} textAnchor="middle" className="text-sm font-bold fill-indigo-600">{centralAngleValue}°</text>
<text x={center.x} y={center.y + 60} textAnchor="middle" className="text-xs fill-indigo-400 uppercase">Central</text>
{/* Inscribed Angle Lines */}
<path d={`M ${A.x} ${A.y} L ${C.x} ${C.y} L ${B.x} ${B.y}`} stroke="#059669" strokeWidth="3" fill="transparent" strokeLinejoin="round" />
{/* Points */}
<circle cx={center.x} cy={center.y} r="4" fill="#64748b" /> {/* Center */}
<text x={center.x + 10} y={center.y} className="text-xs fill-slate-400">O</text>
<circle cx={A.x} cy={A.y} r="5" fill="#475569" />
<text x={A.x + 10} y={A.y} className="text-xs font-bold fill-slate-600">A</text>
<circle cx={B.x} cy={B.y} r="5" fill="#475569" />
<text x={B.x - 20} y={B.y} className="text-xs font-bold fill-slate-600">B</text>
{/* Draggable C */}
<g onMouseDown={() => isDragging.current = true} className="cursor-grab active:cursor-grabbing">
<circle cx={C.x} cy={C.y} r="15" fill="transparent" /> {/* Hit area */}
<circle cx={C.x} cy={C.y} r="8" fill="#059669" stroke="white" strokeWidth="2" className="shadow-lg" />
<text x={C.x} y={C.y - 15} textAnchor="middle" className="text-sm font-bold fill-emerald-700">C</text>
</g>
{/* Inscribed Angle Label */}
{/* Simple approximation for label placement: slightly "in" from C towards center */}
<text x={C.x + (center.x - C.x)*0.2} y={C.y + (center.y - C.y)*0.2 + 5} textAnchor="middle" className="text-lg font-bold fill-emerald-600">
{centralAngleValue / 2}°
</text>
</svg>
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200 mt-4 w-full text-center">
<p className="font-mono text-lg text-slate-800">
Inscribed Angle = <span className="text-emerald-600">½</span> × Central Angle
</p>
<p className="font-mono text-md text-slate-600 mt-1">
{centralAngleValue / 2}° = ½ × {centralAngleValue}°
</p>
</div>
</div>
);
};
export default CircleTheoremsWidget;

View File

@ -0,0 +1,163 @@
import React, { useState } from 'react';
import { MousePointerClick } from 'lucide-react';
export type SegmentType = 'ic' | 'dc' | 'modifier' | 'conjunction' | 'punct' | 'subject' | 'verb';
export interface Segment {
text: string;
type: SegmentType;
label?: string;
}
export interface ClauseExample {
title: string;
segments: Segment[];
}
interface ClauseBreakdownWidgetProps {
examples: ClauseExample[];
accentColor?: string;
}
const TYPE_STYLES: Record<SegmentType, { bg: string; text: string; border: string; ring: string }> = {
ic: { bg: 'bg-blue-100', text: 'text-blue-800', border: 'border-blue-300', ring: '#93c5fd' },
dc: { bg: 'bg-green-100', text: 'text-green-800', border: 'border-green-300', ring: '#86efac' },
modifier: { bg: 'bg-orange-100', text: 'text-orange-800', border: 'border-orange-300', ring: '#fdba74' },
conjunction: { bg: 'bg-purple-100', text: 'text-purple-800', border: 'border-purple-300', ring: '#c4b5fd' },
subject: { bg: 'bg-sky-100', text: 'text-sky-800', border: 'border-sky-300', ring: '#7dd3fc' },
verb: { bg: 'bg-rose-100', text: 'text-rose-800', border: 'border-rose-300', ring: '#fda4af' },
punct: { bg: 'bg-gray-100', text: 'text-gray-600', border: 'border-gray-300', ring: '#d1d5db' },
};
const TYPE_LABELS: Record<SegmentType, string> = {
ic: 'Independent Clause',
dc: 'Dependent Clause',
modifier: 'Modifier',
conjunction: 'Conjunction',
subject: 'Subject',
verb: 'Verb / Predicate',
punct: 'Punctuation',
};
// Pre-resolved tab accent classes (avoids Tailwind purge issues with dynamic strings)
const TAB_ACTIVE: Record<string, string> = {
purple: 'border-b-2 border-purple-600 text-purple-700 bg-white',
teal: 'border-b-2 border-teal-600 text-teal-700 bg-white',
fuchsia: 'border-b-2 border-fuchsia-600 text-fuchsia-700 bg-white',
amber: 'border-b-2 border-amber-600 text-amber-700 bg-white',
};
export default function ClauseBreakdownWidget({ examples, accentColor = 'purple' }: ClauseBreakdownWidgetProps) {
const [activeTab, setActiveTab] = useState(0);
const [selected, setSelected] = useState<number | null>(null);
const example = examples[activeTab];
const switchTab = (i: number) => { setActiveTab(i); setSelected(null); };
const selectedSeg = selected !== null ? example.segments[selected] : null;
const tabActive = TAB_ACTIVE[accentColor] ?? TAB_ACTIVE.purple;
// Unique labeled segment types for the legend
const legendTypes = Array.from(
new Set(example.segments.filter(s => s.label).map(s => s.type))
);
return (
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
{/* Tab strip */}
{examples.length > 1 && (
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
{examples.map((ex, i) => (
<button
key={i}
onClick={() => switchTab(i)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
i === activeTab ? tabActive : 'text-gray-500 hover:text-gray-700'
}`}
>
{ex.title}
</button>
))}
</div>
)}
{examples.length === 1 && (
<div className="px-5 pt-4 pb-1">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400">{example.title}</p>
</div>
)}
{/* Instruction */}
<div className="px-5 pt-3 pb-1 flex items-center gap-1.5">
<MousePointerClick className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<p className="text-xs text-gray-400 italic">Click any colored part to see its grammatical role</p>
</div>
{/* Sentence display */}
<div className="px-5 pt-2 pb-3">
<div className="text-base leading-10 bg-gray-50 rounded-xl border border-gray-200 px-5 py-4 select-none">
{example.segments.map((seg, i) => {
if (!seg.label) {
// Punctuation / unlabeled — plain unstyled text, not clickable
return <span key={i} className="text-gray-700">{seg.text}</span>;
}
const style = TYPE_STYLES[seg.type];
const isSelected = selected === i;
return (
<span
key={i}
onClick={() => setSelected(isSelected ? null : i)}
className={`inline cursor-pointer rounded px-1 py-0.5 mx-0.5 transition-all ${style.bg} ${style.text} ${
isSelected
? `border-2 ${style.border} font-semibold`
: `border ${style.border} hover:opacity-80`
}`}
style={isSelected ? { outline: `2.5px solid ${style.ring}`, outlineOffset: '1px' } : {}}
>
{seg.text}
</span>
);
})}
</div>
{/* Selection indicator */}
{selectedSeg ? (
<div
className={`mt-3 rounded-xl border-2 px-4 py-3 flex items-start gap-3 ${TYPE_STYLES[selectedSeg.type].bg} ${TYPE_STYLES[selectedSeg.type].border}`}
>
<div
className="w-2.5 h-2.5 rounded-full mt-1.5 shrink-0"
style={{ backgroundColor: TYPE_STYLES[selectedSeg.type].ring }}
/>
<div className="flex-1 min-w-0">
<p className={`text-xs font-bold uppercase tracking-wider mb-0.5 ${TYPE_STYLES[selectedSeg.type].text}`}>
{selectedSeg.label ?? TYPE_LABELS[selectedSeg.type]}
</p>
<p className={`text-sm font-semibold leading-snug ${TYPE_STYLES[selectedSeg.type].text}`}>
"{selectedSeg.text.trim()}"
</p>
</div>
</div>
) : (
<p className="mt-2 text-xs text-gray-400 italic px-1">No element selected click a colored span above.</p>
)}
</div>
{/* Legend */}
<div className="px-5 py-3 border-t border-gray-100 bg-gray-50 flex flex-wrap gap-2">
{legendTypes.map(type => {
const style = TYPE_STYLES[type];
return (
<span
key={type}
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border ${style.bg} ${style.text} ${style.border}`}
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: TYPE_STYLES[type].ring }} />
{TYPE_LABELS[type]}
</span>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,166 @@
import React, { useState } from 'react';
// Example: x^2 + y^2 - 6x + 8y - 11 = 0
// Center (3, -4), Radius 6
// Steps:
// 1. Group: (x^2 - 6x) + (y^2 + 8y) = 11
// 2. Add magic numbers: (x^2 - 6x + 9) + (y^2 + 8y + 16) = 11 + 9 + 16
// 3. Factor: (x - 3)^2 + (y + 4)^2 = 36
const CompletingSquareWidget: React.FC = () => {
const [step, setStep] = useState(0);
const [inputs, setInputs] = useState({
magicX: '',
magicY: '',
factorX: '',
factorY: '',
totalR: ''
});
// Removed explicit generic Record<string, boolean> to prevent parsing error
const [errors, setErrors] = useState<any>({});
const correct = {
magicX: '9', // (-6/2)^2
magicY: '16', // (8/2)^2
factorX: '3', // h
factorY: '4', // -k (actually displayed as + 4)
totalR: '36' // 11 + 9 + 16
};
const handleChange = (field: string, value: string) => {
setInputs(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev: any) => ({ ...prev, [field]: false }));
}
};
const validateStep1 = () => {
const isXCorrect = inputs.magicX === correct.magicX;
const isYCorrect = inputs.magicY === correct.magicY;
setErrors({
magicX: !isXCorrect,
magicY: !isYCorrect
});
if (isXCorrect && isYCorrect) setStep(1);
};
const validateStep2 = () => {
const isFXCorrect = inputs.factorX === correct.factorX;
const isFYCorrect = inputs.factorY === correct.factorY;
const isRCorrect = inputs.totalR === correct.totalR;
setErrors({
factorX: !isFXCorrect,
factorY: !isFYCorrect,
totalR: !isRCorrect
});
if (isFXCorrect && isFYCorrect && isRCorrect) setStep(2);
};
return (
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200 w-full max-w-2xl mx-auto">
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center">
<span className="bg-indigo-100 text-indigo-700 text-xs px-2 py-1 rounded uppercase tracking-wide mr-2">Interactive</span>
Convert to Standard Form
</h3>
<div className="mb-6 p-4 bg-slate-50 rounded-lg text-center font-mono text-lg text-slate-700">
x² + y² - 6x + 8y - 11 = 0
</div>
<div className="space-y-6">
{/* Step 0: Group and Move */}
<div className={`transition-opacity duration-500 ${step >= 0 ? 'opacity-100' : 'opacity-50'}`}>
<p className="text-sm font-semibold text-slate-500 mb-2">Step 1: Group terms & move constant</p>
<div className="font-mono text-lg flex flex-wrap items-center gap-2">
<span>(x² - 6x + <input
type="text"
placeholder="?"
value={inputs.magicX}
onChange={(e) => handleChange('magicX', e.target.value)}
disabled={step > 0}
className={`w-12 text-center border-b-2 bg-transparent outline-none ${errors.magicX ? 'border-red-500 text-red-600' : 'border-slate-300'}`}
/>)</span>
<span>+</span>
<span>(y² + 8y + <input
type="text"
placeholder="?"
value={inputs.magicY}
onChange={(e) => handleChange('magicY', e.target.value)}
disabled={step > 0}
className={`w-12 text-center border-b-2 bg-transparent outline-none ${errors.magicY ? 'border-red-500 text-red-600' : 'border-slate-300'}`}
/>)</span>
<span>=</span>
<span>11 + <span className="text-indigo-600">{inputs.magicX || '?'}</span> + <span className="text-indigo-600">{inputs.magicY || '?'}</span></span>
</div>
{step === 0 && (
<div className="mt-2 text-xs text-slate-500">
Hint: Take half the coefficient of the linear term (-6 and 8), then square it.
</div>
)}
{step === 0 && (
<button
onClick={validateStep1}
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 text-sm font-medium transition-colors"
>
Check Magic Numbers
</button>
)}
</div>
{/* Step 2: Factor */}
{step >= 1 && (
<div className="animate-fade-in-up">
<p className="text-sm font-semibold text-slate-500 mb-2">Step 2: Factor & Sum</p>
<div className="font-mono text-lg flex flex-wrap items-center gap-2">
<span>(x - <input
type="text"
value={inputs.factorX}
onChange={(e) => handleChange('factorX', e.target.value)}
disabled={step > 1}
className={`w-10 text-center border-b-2 bg-transparent outline-none ${errors.factorX ? 'border-red-500' : 'border-slate-300'}`}
/>)²</span>
<span>+</span>
<span>(y + <input
type="text"
value={inputs.factorY}
onChange={(e) => handleChange('factorY', e.target.value)}
disabled={step > 1}
className={`w-10 text-center border-b-2 bg-transparent outline-none ${errors.factorY ? 'border-red-500' : 'border-slate-300'}`}
/>)²</span>
<span>=</span>
<input
type="text"
value={inputs.totalR}
onChange={(e) => handleChange('totalR', e.target.value)}
disabled={step > 1}
className={`w-16 text-center border-b-2 bg-transparent outline-none ${errors.totalR ? 'border-red-500' : 'border-slate-300'}`}
/>
</div>
{step === 1 && (
<button
onClick={validateStep2}
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 text-sm font-medium transition-colors"
>
Check Final Equation
</button>
)}
</div>
)}
{/* Step 3: Success */}
{step === 2 && (
<div className="animate-fade-in-up p-4 bg-green-50 border border-green-200 rounded-lg text-green-800">
<p className="font-bold mb-1">🎉 Awesome work!</p>
<p className="text-sm">You've successfully converted the equation. The center is (3, -4) and radius is 6 (36).</p>
</div>
)}
</div>
</div>
);
};
export default CompletingSquareWidget;

View File

@ -0,0 +1,227 @@
import React, { useState } from "react";
const CompositeAreaWidget: React.FC = () => {
const [mode, setMode] = useState<"add" | "subtract">("add");
const [width, setWidth] = useState(10);
const [height, setHeight] = useState(6);
// Scale for display
const scale = 20;
const displayW = width * scale;
const displayH = height * scale;
const radius = width / 2; // Semicircle on top (width is diameter)
const displayR = radius * scale;
// Areas
const rectArea = width * height;
const semiArea = 0.5 * Math.PI * radius * radius;
const totalArea = mode === "add" ? rectArea + semiArea : rectArea - semiArea;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col items-center">
<div className="flex gap-4 mb-8">
<button
onClick={() => setMode("add")}
className={`px-4 py-2 rounded-full font-bold text-sm transition-all ${
mode === "add"
? "bg-orange-600 text-white shadow-md transform scale-105"
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
}`}
>
Add Semicircle (Composite)
</button>
<button
onClick={() => setMode("subtract")}
className={`px-4 py-2 rounded-full font-bold text-sm transition-all ${
mode === "subtract"
? "bg-rose-600 text-white shadow-md transform scale-105"
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
}`}
>
Subtract Semicircle (Hole)
</button>
</div>
<div
className="relative mb-8 flex items-end justify-center"
style={{ height: "300px", width: "100%" }}
>
<svg
width="400"
height="300"
className="overflow-visible transition-all duration-500"
>
<defs>
<pattern
id="grid"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<path
d="M 20 0 L 0 0 0 20"
fill="none"
stroke="#f1f5f9"
strokeWidth="1"
/>
</pattern>
</defs>
<g transform={`translate(${200 - displayW / 2}, ${250})`}>
{/* Rectangle */}
<rect
x="0"
y={-displayH}
width={displayW}
height={displayH}
fill={
mode === "add"
? "rgba(255,237,213, 1)"
: "rgba(254, 226, 226, 1)"
}
stroke={mode === "add" ? "#f97316" : "#e11d48"}
strokeWidth="2"
/>
{mode === "add" && (
// Semicircle on TOP
<path
d={`M 0 ${-displayH} A ${displayR} ${displayR} 0 0 1 ${displayW} ${-displayH} Z`}
fill="rgba(255,237,213, 1)"
stroke="#f97316"
strokeWidth="2"
transform={`translate(0,0)`}
/>
)}
{mode === "add" && (
// Hide the seam line
<line
x1="2"
y1={-displayH}
x2={displayW - 2}
y2={-displayH}
stroke="rgba(255,237,213, 1)"
strokeWidth="4"
/>
)}
{mode === "subtract" && (
// Semicircle Cutting INTO top
<path
d={`M 0 ${-displayH} A ${displayR} ${displayR} 0 0 0 ${displayW} ${-displayH} Z`}
fill="white"
stroke="#e11d48"
strokeWidth="2"
strokeDasharray="4,4"
/>
)}
{/* Labels */}
<text
x={displayW / 2}
y={-displayH / 2}
textAnchor="middle"
className="font-bold fill-slate-500 opacity-50 text-xl"
>
Rect
</text>
{mode === "add" && (
<text
x={displayW / 2}
y={-displayH - displayR / 2}
textAnchor="middle"
className="font-bold fill-orange-600 text-sm"
>
Semicircle
</text>
)}
{mode === "subtract" && (
<text
x={displayW / 2}
y={-displayH + displayR / 2}
textAnchor="middle"
className="font-bold fill-rose-600 text-sm"
>
Hole
</text>
)}
</g>
</svg>
</div>
<div className="grid grid-cols-2 gap-8 w-full max-w-2xl">
<div>
<label className="text-xs font-bold text-slate-400 uppercase">
Width (Diameter)
</label>
<input
type="range"
min="4"
max="14"
step="2"
value={width}
onChange={(e) => setWidth(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-slate-600 mt-2"
/>
<div className="text-right font-mono font-bold text-slate-700">
{width}
</div>
</div>
<div>
<label className="text-xs font-bold text-slate-400 uppercase">
Height
</label>
<input
type="range"
min="4"
max="12"
step="1"
value={height}
onChange={(e) => setHeight(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-slate-600 mt-2"
/>
<div className="text-right font-mono font-bold text-slate-700">
{height}
</div>
</div>
</div>
<div className="mt-8 p-4 bg-slate-50 rounded-xl border border-slate-200 w-full max-w-2xl">
<div className="flex justify-between items-center mb-4">
<span className="font-bold text-slate-700">Calculation</span>
<span
className={`px-2 py-1 rounded text-xs font-bold uppercase ${mode === "add" ? "bg-orange-100 text-orange-800" : "bg-rose-100 text-rose-800"}`}
>
{mode === "add" ? "Sum" : "Difference"}
</span>
</div>
<div className="font-mono text-lg space-y-2">
<div className="flex justify-between">
<span className="text-slate-500">Rectangle Area (w×h)</span>
<span>
{width} × {height} = <strong>{rectArea}</strong>
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Semicircle Area (½πr²)</span>
<span>
½ × π × {radius}² <strong>{semiArea.toFixed(1)}</strong>
</span>
</div>
<div className="border-t border-slate-300 my-2 pt-2 flex justify-between font-bold text-xl">
<span>Total Area</span>
<span
className={mode === "add" ? "text-orange-600" : "text-rose-600"}
>
{totalArea.toFixed(1)}
</span>
</div>
</div>
</div>
</div>
);
};
export default CompositeAreaWidget;

View File

@ -0,0 +1,255 @@
import React, { useState } from "react";
const CompositeSolidsWidget: React.FC = () => {
const [isMerged, setIsMerged] = useState(false);
const [w, setW] = useState(60);
const [h, setH] = useState(80);
const [d, setD] = useState(60);
// Surface Area Calcs
const singleSA = 2 * (w * h + w * d + h * d);
const hiddenFaceArea = d * h;
const totalSeparateSA = singleSA * 2;
const mergedSA = totalSeparateSA - 2 * hiddenFaceArea;
// Helper to generate a face style
const getFaceStyle = (
width: number,
height: number,
transform: string,
color: string,
) => ({
width: `${width}px`,
height: `${height}px`,
position: "absolute" as const,
left: "50%",
top: "50%",
marginLeft: `-${width / 2}px`,
marginTop: `-${height / 2}px`,
transform: transform,
backgroundColor: color,
border: "1px solid rgba(255,255,255,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
backfaceVisibility: "hidden" as const, // Hide backfaces for cleaner look if opaque
transition: "all 0.5s",
});
// Prism Component
const Prism = ({
positionX,
baseHue,
highlightSide, // 'left' or 'right' indicates the face to highlight red
}: {
positionX: number;
baseHue: "indigo" | "sky";
highlightSide?: "left" | "right";
}) => {
// Define shades based on hue
// Lighting: Top is lightest, Front is base, Side is darkest
const colors =
baseHue === "indigo"
? { top: "#818cf8", front: "#6366f1", side: "#4f46e5" } // Indigo 400, 500, 600
: { top: "#38bdf8", front: "#0ea5e9", side: "#0284c7" }; // Sky 400, 500, 600
const hiddenColor = "#f43f5e"; // Rose 500
return (
<div
className="absolute top-0 left-0 transition-all duration-700 ease-in-out transform-style-3d"
style={{ transform: `translateX(${positionX}px)` }}
>
{/* Front (w x h) */}
<div
style={getFaceStyle(w, h, `translateZ(${d / 2}px)`, colors.front)}
/>
{/* Back (w x h) - usually hidden but good for completeness */}
<div
style={getFaceStyle(
w,
h,
`rotateY(180deg) translateZ(${d / 2}px)`,
colors.front,
)}
/>
{/* Right (d x h) */}
<div
style={getFaceStyle(
d,
h,
`rotateY(90deg) translateZ(${w / 2}px)`,
highlightSide === "right" ? hiddenColor : colors.side,
)}
>
{highlightSide === "right" && (
<span className="text-white font-bold text-xs rotate-90 tracking-widest">
FACE
</span>
)}
</div>
{/* Left (d x h) */}
<div
style={getFaceStyle(
d,
h,
`rotateY(-90deg) translateZ(${w / 2}px)`,
highlightSide === "left" ? hiddenColor : colors.side,
)}
>
{highlightSide === "left" && (
<span className="text-white font-bold text-xs -rotate-90 tracking-widest">
FACE
</span>
)}
</div>
{/* Top (w x d) */}
<div
style={getFaceStyle(
w,
d,
`rotateX(90deg) translateZ(${h / 2}px)`,
colors.top,
)}
/>
{/* Bottom (w x d) */}
<div
style={getFaceStyle(
w,
d,
`rotateX(-90deg) translateZ(${h / 2}px)`,
colors.side,
)}
/>
</div>
);
};
// Gap Logic
const gap = isMerged ? 0 : 40;
const posLeft = -(w / 2 + gap / 2);
const posRight = w / 2 + gap / 2;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col items-center">
<div className="flex justify-between w-full items-center mb-8">
<h3 className="text-lg font-bold text-slate-800">
The "Hidden Face" Trap
</h3>
<button
onClick={() => setIsMerged(!isMerged)}
className={`px-6 py-2 rounded-full font-bold shadow-sm transition-all text-sm ${isMerged ? "bg-slate-200 text-slate-700 hover:bg-slate-300" : "bg-indigo-600 text-white hover:bg-indigo-700"}`}
>
{isMerged ? "Separate Prisms" : "Glue Together"}
</button>
</div>
{/* 3D Scene */}
<div className="relative h-72 w-full flex items-center justify-center perspective-1000 overflow-visible mb-8">
{/* Container rotated for Isometric-ish view */}
<div
className="relative transform-style-3d transition-transform duration-700"
style={{ transform: "rotateX(-15deg) rotateY(35deg)" }}
>
{/* Left Prism (Indigo) - Right face hidden */}
<Prism positionX={posLeft} baseHue="indigo" highlightSide="right" />
{/* Right Prism (Sky) - Left face hidden */}
<Prism positionX={posRight} baseHue="sky" highlightSide="left" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
<div className="space-y-4">
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
<h4 className="text-xs font-bold text-slate-400 uppercase mb-3">
Dimensions
</h4>
<div className="space-y-4">
<div>
<div className="flex justify-between text-xs font-bold text-slate-600 mb-1">
<span>Width (w)</span> <span>{w}</span>
</div>
<input
type="range"
min="40"
max="80"
value={w}
onChange={(e) => setW(parseInt(e.target.value))}
className="w-full h-1 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
<div>
<div className="flex justify-between text-xs font-bold text-slate-600 mb-1">
<span>Height (h)</span> <span>{h}</span>
</div>
<input
type="range"
min="40"
max="100"
value={h}
onChange={(e) => setH(parseInt(e.target.value))}
className="w-full h-1 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
<div>
<div className="flex justify-between text-xs font-bold text-slate-600 mb-1">
<span>Depth (d)</span> <span>{d}</span>
</div>
<input
type="range"
min="40"
max="80"
value={d}
onChange={(e) => setD(parseInt(e.target.value))}
className="w-full h-1 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div
className={`p-5 rounded-xl border transition-colors ${isMerged ? "bg-indigo-50 border-indigo-200" : "bg-slate-50 border-slate-200"}`}
>
<div className="text-xs uppercase font-bold text-slate-500 mb-2">
Total Surface Area
</div>
<div className="text-4xl font-mono font-bold text-slate-800 tracking-tight">
{isMerged ? mergedSA : totalSeparateSA}
</div>
<div className="text-sm mt-2 text-slate-600 font-medium">
{isMerged
? "⬇ Area decreased (Faces Hidden)"
: "Sum of 2 separated prisms"}
</div>
</div>
<div
className={`p-4 rounded-lg border flex justify-between items-center transition-colors ${isMerged ? "bg-rose-50 border-rose-200 opacity-50" : "bg-rose-50 border-rose-200"}`}
>
<span className="text-xs font-bold text-rose-800 uppercase">
Hidden Area Calculation
</span>
<div className="text-right">
<div className="font-mono font-bold text-rose-600 text-lg">
2 × ({d}×{h})
</div>
<div className="text-xs text-rose-700/70 font-bold">
= {2 * hiddenFaceArea} lost
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default CompositeSolidsWidget;

View File

@ -0,0 +1,98 @@
import React, { useState } from 'react';
const ConfidenceIntervalWidget: React.FC = () => {
const [meanA, setMeanA] = useState(46);
const [moeA, setMoeA] = useState(4);
const [meanB, setMeanB] = useState(52);
const [moeB, setMoeB] = useState(5);
const minA = meanA - moeA;
const maxA = meanA + moeA;
const minB = meanB - moeB;
const maxB = meanB + moeB;
// Overlap Logic
const overlap = Math.max(0, Math.min(maxA, maxB) - Math.max(minA, minB));
const isOverlapping = overlap > 0;
// Visual Scale (Range 30 to 70)
const scale = (val: number) => ((val - 30) / 40) * 100;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="mb-8 relative h-32 bg-slate-50 rounded-lg border border-slate-100">
{/* Grid lines */}
{[35, 40, 45, 50, 55, 60, 65].map(v => (
<div key={v} className="absolute top-0 bottom-0 border-r border-slate-200 text-xs text-slate-300 pt-2" style={{ left: `${scale(v)}%` }}>
<span className="absolute -bottom-5 -translate-x-1/2">{v}</span>
</div>
))}
{/* Interval A */}
<div className="absolute top-8 h-4 bg-indigo-500/20 border-l-2 border-r-2 border-indigo-500 rounded flex items-center justify-center group"
style={{ left: `${scale(minA)}%`, width: `${scale(maxA) - scale(minA)}%` }}>
<div className="w-1.5 h-1.5 bg-indigo-600 rounded-full"></div> {/* Point Estimate */}
<div className="absolute -top-6 text-xs font-bold text-indigo-600 whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
Group A: {minA} to {maxA}
</div>
</div>
{/* Interval B */}
<div className="absolute top-20 h-4 bg-emerald-500/20 border-l-2 border-r-2 border-emerald-500 rounded flex items-center justify-center group"
style={{ left: `${scale(minB)}%`, width: `${scale(maxB) - scale(minB)}%` }}>
<div className="w-1.5 h-1.5 bg-emerald-600 rounded-full"></div>
<div className="absolute -top-6 text-xs font-bold text-emerald-600 whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
Group B: {minB} to {maxB}
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-6">
<div className="p-4 bg-indigo-50 rounded-lg border border-indigo-100">
<h4 className="font-bold text-indigo-900 mb-2">Group A</h4>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span>Mean</span>
<input type="range" min="35" max="65" value={meanA} onChange={e => setMeanA(parseInt(e.target.value))} className="w-24 accent-indigo-600"/>
<span className="font-bold">{meanA}%</span>
</div>
<div className="flex justify-between text-sm">
<span>Margin of Error</span>
<input type="range" min="1" max="10" value={moeA} onChange={e => setMoeA(parseInt(e.target.value))} className="w-24 accent-indigo-600"/>
<span className="font-bold">±{moeA}</span>
</div>
</div>
</div>
<div className="p-4 bg-emerald-50 rounded-lg border border-emerald-100">
<h4 className="font-bold text-emerald-900 mb-2">Group B</h4>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span>Mean</span>
<input type="range" min="35" max="65" value={meanB} onChange={e => setMeanB(parseInt(e.target.value))} className="w-24 accent-emerald-600"/>
<span className="font-bold">{meanB}%</span>
</div>
<div className="flex justify-between text-sm">
<span>Margin of Error</span>
<input type="range" min="1" max="10" value={moeB} onChange={e => setMoeB(parseInt(e.target.value))} className="w-24 accent-emerald-600"/>
<span className="font-bold">±{moeB}</span>
</div>
</div>
</div>
</div>
<div className={`p-4 rounded-xl border-l-4 ${isOverlapping ? 'bg-amber-50 border-amber-400 text-amber-900' : 'bg-green-50 border-green-500 text-green-900'}`}>
<h4 className="font-bold text-lg mb-1">
{isOverlapping ? "⚠️ Conclusion: Inconclusive" : "✅ Conclusion: Strong Evidence"}
</h4>
<p className="text-sm">
{isOverlapping
? `The intervals overlap (between ${Math.max(minA, minB)} and ${Math.min(maxA, maxB)}). We cannot rule out that the true means are equal.`
: "The intervals do not overlap. It is highly likely that there is a real difference between the groups."}
</p>
</div>
</div>
);
};
export default ConfidenceIntervalWidget;

View File

@ -0,0 +1,175 @@
import React, { useState } from 'react';
import { CheckCircle2, RotateCcw, ChevronRight } from 'lucide-react';
export interface VocabOption {
id: string;
definition: string;
isCorrect: boolean;
elimReason: string; // why wrong (for eliminated options) or why right (for correct option)
}
export interface VocabExercise {
sentence: string;
word: string; // the target word — will be highlighted
question: string;
options: VocabOption[];
}
interface ContextEliminationWidgetProps {
exercises: VocabExercise[];
accentColor?: string;
}
export default function ContextEliminationWidget({ exercises, accentColor = 'rose' }: ContextEliminationWidgetProps) {
const [activeEx, setActiveEx] = useState(0);
const [eliminated, setEliminated] = useState<Set<string>>(new Set());
const [revealed, setRevealed] = useState(false);
const [triedCorrect, setTriedCorrect] = useState(false);
const exercise = exercises[activeEx];
const wrongIds = exercise.options.filter(o => !o.isCorrect).map(o => o.id);
const allWrongEliminated = wrongIds.every(id => eliminated.has(id));
const eliminate = (id: string) => {
const opt = exercise.options.find(o => o.id === id)!;
if (opt.isCorrect) {
setTriedCorrect(true);
setTimeout(() => setTriedCorrect(false), 1500);
} else {
const newElim = new Set([...eliminated, id]);
setEliminated(newElim);
if (wrongIds.every(wid => newElim.has(wid))) {
setRevealed(true);
}
}
};
const reset = () => { setEliminated(new Set()); setRevealed(false); setTriedCorrect(false); };
const switchEx = (i: number) => { setActiveEx(i); setEliminated(new Set()); setRevealed(false); setTriedCorrect(false); };
// Highlight the target word in the sentence
const renderSentence = () => {
const idx = exercise.sentence.toLowerCase().indexOf(exercise.word.toLowerCase());
if (idx === -1) return <>{exercise.sentence}</>;
return (
<>
{exercise.sentence.slice(0, idx)}
<mark className={`bg-${accentColor}-200 text-${accentColor}-900 font-bold px-0.5 rounded not-italic`}>
{exercise.sentence.slice(idx, idx + exercise.word.length)}
</mark>
{exercise.sentence.slice(idx + exercise.word.length)}
</>
);
};
return (
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
{/* Tab strip */}
{exercises.length > 1 && (
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
{exercises.map((_, i) => (
<button
key={i}
onClick={() => switchEx(i)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
i === activeEx
? `bg-white border-b-2 border-${accentColor}-600 text-${accentColor}-700`
: 'text-gray-500 hover:text-gray-700'
}`}
>
Word {i + 1}
</button>
))}
</div>
)}
{/* Sentence in context */}
<div className={`px-5 py-4 border-b border-gray-100 bg-${accentColor}-50`}>
<p className={`text-xs font-semibold uppercase tracking-wider text-${accentColor}-500 mb-2`}>Sentence in Context</p>
<p className="text-gray-700 italic leading-relaxed text-sm">{renderSentence()}</p>
</div>
{/* Question + instruction */}
<div className="px-5 pt-4 pb-2">
<p className="font-medium text-gray-800 text-sm mb-1">{exercise.question}</p>
<p className="text-xs text-gray-400 italic">
{revealed
? 'You found it! The correct definition is highlighted.'
: 'Click "Eliminate" on definitions that don\'t fit the context. Work by elimination.'}
</p>
</div>
{/* Tried to eliminate correct option flash */}
{triedCorrect && (
<div className="mx-5 mb-2 px-3 py-2 bg-amber-50 border border-amber-200 rounded-xl text-xs text-amber-700 font-medium">
Can't eliminate that one — it fits the context too well!
</div>
)}
{/* Options */}
<div className="px-5 py-3 space-y-2">
{exercise.options.map(opt => {
const isElim = eliminated.has(opt.id);
const isAnswer = opt.isCorrect && revealed;
let wrapCls = 'border-gray-200 bg-white';
if (isAnswer) wrapCls = 'border-green-400 bg-green-50';
else if (isElim) wrapCls = 'border-gray-100 bg-gray-50';
return (
<div
key={opt.id}
className={`rounded-xl border px-4 py-3 transition-all ${wrapCls} ${isElim ? 'opacity-50' : ''}`}
>
<div className="flex items-start gap-3">
<span className={`text-xs font-bold mt-0.5 shrink-0 ${isElim ? 'text-gray-400' : isAnswer ? 'text-green-700' : 'text-gray-500'}`}>
{opt.id}.
</span>
<div className="flex-1 min-w-0">
<p className={`text-sm leading-snug ${
isElim ? 'text-gray-400 line-through' :
isAnswer ? 'text-green-800 font-semibold' :
'text-gray-700'
}`}>
{opt.definition}
</p>
{isElim && (
<p className="text-xs text-gray-400 mt-0.5 italic">{opt.elimReason}</p>
)}
{isAnswer && (
<p className="text-xs text-green-700 mt-1">✓ {opt.elimReason}</p>
)}
</div>
<div className="shrink-0">
{isAnswer && <CheckCircle2 className="w-5 h-5 text-green-500" />}
{!isElim && !isAnswer && !revealed && (
<button
onClick={() => eliminate(opt.id)}
className="text-xs font-semibold text-red-500 hover:text-red-700 hover:bg-red-50 px-2.5 py-1 rounded-lg transition-colors border border-red-200 hover:border-red-300"
>
Eliminate ✗
</button>
)}
</div>
</div>
</div>
);
})}
</div>
<div className="px-5 pb-5 flex items-center gap-3">
<button onClick={reset} className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors">
<RotateCcw className="w-3.5 h-3.5" /> Reset
</button>
{revealed && activeEx < exercises.length - 1 && (
<button
onClick={() => switchEx(activeEx + 1)}
className={`ml-auto flex items-center gap-1.5 text-sm font-semibold text-${accentColor}-700 hover:text-${accentColor}-900 transition-colors`}
>
Next word <ChevronRight className="w-4 h-4" />
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,186 @@
import React, { useRef, useState, useEffect } from 'react';
import { scaleToSvg, scaleFromSvg, round, calculateDistanceSquared } from '../utils/math';
import { CircleState, Point } from '../types';
interface CoordinatePlaneProps {
circle: CircleState;
point?: Point | null;
onPointClick?: (p: Point) => void;
interactive?: boolean;
showDistance?: boolean;
mode?: 'view' | 'place_point';
}
const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
circle,
point,
onPointClick,
showDistance = false,
mode = 'view'
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const [hoverPoint, setHoverPoint] = useState<Point | null>(null);
// Viewport settings
const width = 400;
const height = 400;
const range = 10; // -10 to 10
const tickSpacing = 1;
// Scales
const toX = (val: number) => scaleToSvg(val, -range, range, 0, width);
const toY = (val: number) => scaleToSvg(val, range, -range, 0, height); // Inverted Y for SVG
const fromX = (px: number) => scaleFromSvg(px, -range, range, 0, width);
const fromY = (px: number) => scaleFromSvg(px, range, -range, 0, height);
const cx = toX(circle.h);
const cy = toY(circle.k);
// Radius in pixels (assuming uniform aspect ratio)
const rPx = toX(circle.r) - toX(0);
const handleMouseMove = (e: React.MouseEvent) => {
if (mode !== 'place_point' || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const rawX = e.clientX - rect.left;
const rawY = e.clientY - rect.top;
// Snap to nearest 0.5 for cleaner UX
const graphX = Math.round(fromX(rawX) * 2) / 2;
const graphY = Math.round(fromY(rawY) * 2) / 2;
setHoverPoint({ x: graphX, y: graphY });
};
const handleClick = () => {
if (mode === 'place_point' && hoverPoint && onPointClick) {
onPointClick(hoverPoint);
}
};
// Generate grid lines
const ticks = [];
for (let i = -range; i <= range; i += tickSpacing) {
if (i === 0) continue; // Skip axes (drawn separately)
ticks.push(i);
}
const dSquared = point ? calculateDistanceSquared(point.x, point.y, circle.h, circle.k) : 0;
const isInside = dSquared < circle.r * circle.r;
const isOn = Math.abs(dSquared - circle.r * circle.r) < 0.01;
const pointColor = isOn ? 'text-yellow-600' : isInside ? 'text-green-600' : 'text-red-600';
const pointFill = isOn ? '#ca8a04' : isInside ? '#16a34a' : '#dc2626';
return (
<div className="flex flex-col items-center">
<div className="relative shadow-lg rounded-xl overflow-hidden bg-white border border-slate-200">
<svg
ref={svgRef}
width={width}
height={height}
onMouseMove={handleMouseMove}
onMouseLeave={() => setHoverPoint(null)}
onClick={handleClick}
className={`${mode === 'place_point' ? 'cursor-crosshair' : 'cursor-default'}`}
>
{/* Grid Background */}
{ticks.map(t => (
<React.Fragment key={t}>
<line x1={toX(t)} y1={0} x2={toX(t)} y2={height} stroke="#e2e8f0" strokeWidth="1" />
<line x1={0} y1={toY(t)} x2={width} y2={toY(t)} stroke="#e2e8f0" strokeWidth="1" />
</React.Fragment>
))}
{/* Axes */}
<line x1={toX(0)} y1={0} x2={toX(0)} y2={height} stroke="#64748b" strokeWidth="2" />
<line x1={0} y1={toY(0)} x2={width} y2={toY(0)} stroke="#64748b" strokeWidth="2" />
{/* Circle */}
<circle
cx={cx}
cy={cy}
r={Math.abs(rPx)}
fill="rgba(99, 102, 241, 0.1)"
stroke="#4f46e5"
strokeWidth="3"
className="transition-all duration-300 ease-out"
/>
{/* Center Point */}
<circle cx={cx} cy={cy} r={4} fill="#4f46e5" />
<text x={cx + 8} y={cy - 8} fontSize="12" fill="#4f46e5" fontWeight="bold">Center ({circle.h}, {circle.k})</text>
{/* Radius Line (only if distance line is not active to avoid clutter) */}
{!point && (
<line
x1={cx}
y1={cy}
x2={cx + rPx}
y2={cy}
stroke="#4f46e5"
strokeWidth="2"
strokeDasharray="5,5"
/>
)}
{!point && (
<text x={cx + rPx/2} y={cy - 5} fontSize="12" fill="#4f46e5">r = {circle.r}</text>
)}
{/* Placed Point */}
{point && (
<>
<line
x1={cx}
y1={cy}
x2={toX(point.x)}
y2={toY(point.y)}
stroke="#94a3b8"
strokeWidth="2"
strokeDasharray="4,4"
/>
<circle cx={toX(point.x)} cy={toY(point.y)} r={6} fill={pointFill} stroke="white" strokeWidth="2" />
<text x={toX(point.x) + 8} y={toY(point.y) - 8} fontSize="12" fontWeight="bold" fill={pointFill}>
({point.x}, {point.y})
</text>
</>
)}
{/* Hover Ghost Point */}
{mode === 'place_point' && hoverPoint && !point && (
<circle cx={toX(hoverPoint.x)} cy={toY(hoverPoint.y)} r={4} fill="rgba(0,0,0,0.3)" />
)}
</svg>
<div className="absolute bottom-2 left-2 text-xs text-slate-400 bg-white/80 px-2 py-1 rounded">
1 unit = {width / (range * 2)}px
</div>
</div>
{/* Info Panel below graph */}
{point && showDistance && (
<div className={`mt-4 p-4 rounded-lg border-l-4 w-full max-w-md bg-white shadow-sm transition-colors ${
isOn ? 'border-yellow-500 bg-yellow-50' :
isInside ? 'border-green-500 bg-green-50' :
'border-red-500 bg-red-50'
}`}>
<div className="flex justify-between items-center mb-2">
<span className="font-bold text-slate-700">Distance Check:</span>
<span className={`px-2 py-0.5 rounded text-sm font-bold uppercase ${
isOn ? 'bg-yellow-200 text-yellow-800' :
isInside ? 'bg-green-200 text-green-800' :
'bg-red-200 text-red-800'
}`}>
{isOn ? 'On Circle' : isInside ? 'Inside' : 'Outside'}
</span>
</div>
<div className="font-mono text-sm space-y-1">
<p>d² = (x - h)² + (y - k)²</p>
<p>d² = ({point.x} - {circle.h})² + ({point.y} - {circle.k})²</p>
<p>d² = {round(calculateDistanceSquared(point.x, point.y, circle.h, circle.k))} <span className="mx-2 text-slate-400">vs</span> r² = {circle.r * circle.r}</p>
</div>
</div>
)}
</div>
);
};
export default CoordinatePlane;

View File

@ -0,0 +1,443 @@
import React, { useState } from 'react';
import { CheckCircle2, XCircle, RotateCcw } from 'lucide-react';
// ── Types ──────────────────────────────────────────────────────────────────
export type Verdict = 'supported' | 'contradicted' | 'neither';
export interface ChartSeries {
name: string;
data: { label: string; value: number }[];
}
export interface ChartData {
type: 'bar' | 'line';
title: string;
yLabel?: string;
xLabel?: string;
source?: string;
unit?: string; // e.g. '%', '°C', 'min'
series: ChartSeries[];
}
export interface DataClaim {
text: string;
verdict: Verdict;
explanation: string;
}
export interface DataExercise {
title: string;
chart: ChartData;
claims: DataClaim[];
}
// ── Chart palette ──────────────────────────────────────────────────────────
const PALETTE = ['#3b82f6', '#8b5cf6', '#f97316', '#10b981', '#ef4444', '#ec4899'];
// ── BarChart ───────────────────────────────────────────────────────────────
function BarChart({ chart }: { chart: ChartData }) {
const [hovered, setHovered] = useState<{ si: number; pi: number } | null>(null);
const labels = chart.series[0].data.map(d => d.label);
const allValues = chart.series.flatMap(s => s.data.map(d => d.value));
const maxVal = Math.max(...allValues);
// Round up max to nearest 10 for cleaner y-axis
const yMax = Math.ceil(maxVal / 10) * 10;
const yTicks = [0, yMax * 0.25, yMax * 0.5, yMax * 0.75, yMax];
const chartH = 180; // px height of bar area
return (
<div className="px-2">
<p className="text-xs font-semibold text-gray-600 text-center mb-4">{chart.title}</p>
<div className="flex gap-2">
{/* Y-axis */}
<div className="flex flex-col-reverse justify-between items-end pr-1" style={{ height: chartH, minWidth: 32 }}>
{yTicks.map(t => (
<span key={t} className="text-[10px] text-gray-400 leading-none">{t}{chart.unit ?? ''}</span>
))}
</div>
{/* Bar groups */}
<div className="flex-1 flex items-end gap-2 border-b border-l border-gray-300" style={{ height: chartH }}>
{labels.map((label, pi) => (
<div key={pi} className="flex-1 flex flex-col items-center gap-0">
{/* Bar group */}
<div className="w-full flex items-end gap-0.5" style={{ height: chartH - 2 }}>
{chart.series.map((s, si) => {
const val = s.data[pi].value;
const heightPct = (val / yMax) * 100;
const isHov = hovered?.si === si && hovered?.pi === pi;
return (
<div
key={si}
className="relative flex-1 rounded-t-sm transition-all duration-150 cursor-pointer"
style={{
height: `${heightPct}%`,
backgroundColor: isHov
? PALETTE[si % PALETTE.length] + 'dd'
: PALETTE[si % PALETTE.length] + 'cc',
outline: isHov ? `2px solid ${PALETTE[si % PALETTE.length]}` : 'none',
}}
onMouseEnter={() => setHovered({ si, pi })}
onMouseLeave={() => setHovered(null)}
>
{/* Value label on hover */}
{isHov && (
<div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-white whitespace-nowrap z-10 pointer-events-none"
style={{ backgroundColor: PALETTE[si % PALETTE.length] }}
>
{val}{chart.unit ?? ''}
</div>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
{/* X-axis labels */}
<div className="flex gap-2 ml-10 mt-1">
{labels.map((label, i) => (
<div key={i} className="flex-1 text-center text-[10px] text-gray-500 leading-tight">{label}</div>
))}
</div>
{chart.xLabel && <p className="text-[10px] text-gray-400 text-center mt-1">{chart.xLabel}</p>}
{/* Legend */}
{chart.series.length > 1 && (
<div className="flex flex-wrap gap-3 mt-3 justify-center">
{chart.series.map((s, si) => (
<div key={si} className="flex items-center gap-1.5 text-xs text-gray-600">
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: PALETTE[si % PALETTE.length] }} />
{s.name}
</div>
))}
</div>
)}
{/* Hover info bar */}
{hovered && (
<div className="mt-3 text-xs text-center text-gray-600 bg-gray-50 rounded-lg py-1.5 px-3">
<span className="font-semibold" style={{ color: PALETTE[hovered.si % PALETTE.length] }}>
{chart.series[hovered.si].name}
</span>
{' — '}
{chart.series[0].data[hovered.pi].label}: <span className="font-semibold">
{chart.series[hovered.si].data[hovered.pi].value}{chart.unit ?? ''}
</span>
</div>
)}
{chart.source && <p className="text-[10px] text-gray-400 text-center mt-2">Source: {chart.source}</p>}
</div>
);
}
// ── LineChart ──────────────────────────────────────────────────────────────
function LineChart({ chart }: { chart: ChartData }) {
const [hovered, setHovered] = useState<{ si: number; pi: number } | null>(null);
const W = 480, H = 200;
const PAD = { top: 20, right: 20, bottom: 36, left: 48 };
const cW = W - PAD.left - PAD.right;
const cH = H - PAD.top - PAD.bottom;
const allValues = chart.series.flatMap(s => s.data.map(d => d.value));
const minVal = Math.min(...allValues);
const maxVal = Math.max(...allValues);
const spread = maxVal - minVal || 1;
// Add 10% padding on y-axis
const yPad = spread * 0.15;
const yMin = minVal - yPad;
const yMax = maxVal + yPad;
const yRange = yMax - yMin;
const labels = chart.series[0].data.map(d => d.label);
const xStep = cW / (labels.length - 1);
const xPos = (i: number) => PAD.left + i * xStep;
const yPos = (v: number) => PAD.top + cH - ((v - yMin) / yRange) * cH;
// Y-axis ticks: 5 evenly spaced
const yTicks = Array.from({ length: 5 }, (_, i) => minVal + ((maxVal - minVal) / 4) * i);
return (
<div>
<p className="text-xs font-semibold text-gray-600 text-center mb-2">{chart.title}</p>
<div className="overflow-x-auto">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ maxHeight: 220 }}>
{/* Grid lines */}
{yTicks.map((t, i) => {
const y = yPos(t);
return (
<g key={i}>
<line x1={PAD.left} x2={W - PAD.right} y1={y} y2={y} stroke="#e5e7eb" strokeWidth="1" />
<text x={PAD.left - 4} y={y + 3.5} textAnchor="end" fontSize="9" fill="#9ca3af">
{t % 1 === 0 ? t : t.toFixed(2)}{chart.unit ?? ''}
</text>
</g>
);
})}
{/* Lines + dots */}
{chart.series.map((s, si) => {
const color = PALETTE[si % PALETTE.length];
const pts = s.data.map((d, i) => `${xPos(i)},${yPos(d.value)}`).join(' ');
return (
<g key={si}>
<polyline points={pts} fill="none" stroke={color} strokeWidth="2.5" strokeLinejoin="round" />
{s.data.map((d, pi) => {
const isHov = hovered?.si === si && hovered?.pi === pi;
const cx = xPos(pi);
const cy = yPos(d.value);
return (
<g key={pi}>
<circle
cx={cx} cy={cy} r={isHov ? 7 : 5}
fill={color} stroke="white" strokeWidth="2"
style={{ cursor: 'pointer', transition: 'r 0.1s' }}
onMouseEnter={() => setHovered({ si, pi })}
onMouseLeave={() => setHovered(null)}
/>
{isHov && (
<>
<rect
x={cx - 28} y={cy - 26} width="56" height="18"
rx="4" fill="#1f2937"
/>
<text x={cx} y={cy - 13} textAnchor="middle" fontSize="10" fill="white" fontWeight="bold">
{d.value}{chart.unit ?? ''}
</text>
</>
)}
</g>
);
})}
</g>
);
})}
{/* X-axis labels */}
{labels.map((label, i) => (
<text key={i} x={xPos(i)} y={H - PAD.bottom + 14} textAnchor="middle" fontSize="9.5" fill="#6b7280">
{label}
</text>
))}
{/* Axes */}
<line x1={PAD.left} x2={PAD.left} y1={PAD.top} y2={H - PAD.bottom} stroke="#d1d5db" strokeWidth="1.5" />
<line x1={PAD.left} x2={W - PAD.right} y1={H - PAD.bottom} y2={H - PAD.bottom} stroke="#d1d5db" strokeWidth="1.5" />
{/* Y-axis label */}
{chart.yLabel && (
<text
x={12} y={H / 2}
transform={`rotate(-90, 12, ${H / 2})`}
textAnchor="middle" fontSize="9" fill="#9ca3af"
>
{chart.yLabel}
</text>
)}
</svg>
</div>
{/* Legend */}
{chart.series.length > 1 && (
<div className="flex flex-wrap gap-3 mt-1 justify-center">
{chart.series.map((s, si) => (
<div key={si} className="flex items-center gap-1.5 text-xs text-gray-600">
<div className="w-5 h-0.5" style={{ backgroundColor: PALETTE[si % PALETTE.length] }} />
{s.name}
</div>
))}
</div>
)}
{/* Hover tooltip */}
{hovered && (
<div className="mt-2 text-xs text-center text-gray-600 bg-gray-50 rounded-lg py-1.5 px-3">
<span className="font-semibold" style={{ color: PALETTE[hovered.si % PALETTE.length] }}>
{chart.series[hovered.si].name}
</span>
{' · '}
{chart.series[0].data[hovered.pi].label}: <span className="font-semibold">
{chart.series[hovered.si].data[hovered.pi].value}{chart.unit ?? ''}
</span>
</div>
)}
{chart.source && <p className="text-[10px] text-gray-400 text-center mt-2">Source: {chart.source}</p>}
</div>
);
}
// ── Main widget ────────────────────────────────────────────────────────────
const VERDICT_LABELS: Record<Verdict, string> = {
supported: 'Supported by data',
contradicted: 'Contradicted by data',
neither: 'Neither proven nor disproven',
};
interface DataClaimWidgetProps {
exercises: DataExercise[];
accentColor?: string;
}
// Pre-resolved accent classes to avoid Tailwind purge issues
const ACCENT_CLASSES: Record<string, { tab: string; header: string; label: string; btn: string }> = {
amber: { tab: 'border-b-2 border-amber-600 text-amber-700', header: 'bg-amber-50', label: 'text-amber-600', btn: 'bg-amber-600 hover:bg-amber-700' },
teal: { tab: 'border-b-2 border-teal-600 text-teal-700', header: 'bg-teal-50', label: 'text-teal-600', btn: 'bg-teal-600 hover:bg-teal-700' },
purple: { tab: 'border-b-2 border-purple-600 text-purple-700', header: 'bg-purple-50', label: 'text-purple-600', btn: 'bg-purple-600 hover:bg-purple-700' },
fuchsia: { tab: 'border-b-2 border-fuchsia-600 text-fuchsia-700', header: 'bg-fuchsia-50', label: 'text-fuchsia-600', btn: 'bg-fuchsia-600 hover:bg-fuchsia-700' },
};
export default function DataClaimWidget({ exercises, accentColor = 'amber' }: DataClaimWidgetProps) {
const [activeEx, setActiveEx] = useState(0);
const [answers, setAnswers] = useState<Record<number, Verdict>>({});
const [submitted, setSubmitted] = useState(false);
const exercise = exercises[activeEx];
const allAnswered = exercise.claims.every((_, i) => answers[i] !== undefined);
const score = submitted ? exercise.claims.filter((c, i) => answers[i] === c.verdict).length : 0;
const c = ACCENT_CLASSES[accentColor] ?? ACCENT_CLASSES.amber;
const reset = () => { setAnswers({}); setSubmitted(false); };
const switchEx = (i: number) => { setActiveEx(i); setAnswers({}); setSubmitted(false); };
return (
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
{/* Tabs */}
{exercises.length > 1 && (
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
{exercises.map((ex, i) => (
<button
key={i}
onClick={() => switchEx(i)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors bg-white ${
i === activeEx ? c.tab : 'text-gray-500 hover:text-gray-700'
}`}
>
{ex.title}
</button>
))}
</div>
)}
{/* Chart */}
<div className={`px-5 pt-5 pb-4 border-b border-gray-200 ${c.header}`}>
<p className={`text-xs font-bold uppercase tracking-wider mb-4 ${c.label}`}>Data Source</p>
{exercise.chart.type === 'bar'
? <BarChart chart={exercise.chart} />
: <LineChart chart={exercise.chart} />
}
</div>
{/* Claims */}
<div className="px-5 py-4">
<p className="text-sm text-gray-600 mb-4">
For each claim, decide if the data{' '}
<strong className="text-green-700">supports</strong>,{' '}
<strong className="text-red-600">contradicts</strong>, or{' '}
<strong className="text-gray-600">neither proves nor disproves</strong> it:
</p>
<div className="space-y-4">
{exercise.claims.map((claim, i) => {
const userAnswer = answers[i];
const isCorrect = submitted && userAnswer === claim.verdict;
const isWrong = submitted && userAnswer !== undefined && userAnswer !== claim.verdict;
return (
<div
key={i}
className={`rounded-xl border p-4 transition-all ${
submitted
? isCorrect ? 'border-green-300 bg-green-50'
: isWrong ? 'border-red-200 bg-red-50'
: 'border-gray-200'
: 'border-gray-200'
}`}
>
<p className="text-sm text-gray-800 mb-3">
<span className="font-bold text-gray-400 mr-2">Claim {i + 1}:</span>
{claim.text}
</p>
<div className="flex flex-wrap gap-2">
{(['supported', 'contradicted', 'neither'] as Verdict[]).map(v => {
const isSelected = userAnswer === v;
const isCorrectOpt = submitted && v === claim.verdict;
let cls = 'border-gray-200 text-gray-600 hover:border-gray-400 hover:bg-gray-50';
if (isSelected && !submitted) cls = `border-amber-500 bg-amber-50 text-amber-800 font-semibold`;
if (submitted) {
if (isCorrectOpt) cls = 'border-green-400 bg-green-100 text-green-800 font-semibold';
else if (isSelected) cls = 'border-red-300 bg-red-100 text-red-700';
else cls = 'border-gray-100 text-gray-400';
}
return (
<button
key={v}
disabled={submitted}
onClick={() => setAnswers(prev => ({ ...prev, [i]: v }))}
className={`px-3 py-1.5 rounded-full border text-xs transition-all ${cls}`}
>
{VERDICT_LABELS[v]}
</button>
);
})}
</div>
{submitted && (
<div className="mt-3 pt-2 border-t border-gray-100 flex gap-2">
{isCorrect
? <CheckCircle2 className="w-4 h-4 text-green-600 shrink-0 mt-0.5" />
: <XCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
}
<p className="text-xs text-gray-600 leading-relaxed">
{!isCorrect && (
<span className="font-semibold text-red-700">Answer: {VERDICT_LABELS[claim.verdict]}. </span>
)}
{claim.explanation}
</p>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Footer */}
<div className="px-5 pb-5">
{!submitted ? (
<button
disabled={!allAnswered}
onClick={() => setSubmitted(true)}
className={`px-5 py-2.5 rounded-full text-sm font-bold text-white transition-colors ${
allAnswered ? c.btn : 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Check all answers
</button>
) : (
<div className="flex items-center gap-4">
<p className="text-sm font-semibold text-gray-700">
{score}/{exercise.claims.length} correct
</p>
<button onClick={reset} className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors">
<RotateCcw className="w-3.5 h-3.5" /> Try again
</button>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,127 @@
import React, { useState } from 'react';
const DataModifierWidget: React.FC = () => {
const initialData = [10, 12, 13, 15, 16, 18, 20];
const [data, setData] = useState<number[]>(initialData);
const calculateStats = (arr: number[]) => {
const sorted = [...arr].sort((a, b) => a - b);
const sum = sorted.reduce((a, b) => a + b, 0);
const mean = sum / sorted.length;
const min = sorted[0];
const max = sorted[sorted.length - 1];
const range = max - min;
// Median
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 !== 0
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
// SD (Population)
const variance = sorted.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / sorted.length;
const sd = Math.sqrt(variance);
return { mean, median, range, sd, sorted };
};
const stats = calculateStats(data);
// Operations
const reset = () => setData(initialData);
const addConstant = (k: number) => {
setData(prev => prev.map(n => n + k));
};
const multiplyConstant = (k: number) => {
setData(prev => prev.map(n => n * k));
};
const addOutlier = (val: number) => {
setData(prev => [...prev, val]);
};
// Visual scaling
const minDisplay = Math.min(0, ...data) - 5;
const maxDisplay = Math.max(Math.max(...data), 100) + 10;
const getX = (val: number) => ((val - minDisplay) / (maxDisplay - minDisplay)) * 100;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-6">
{/* Controls */}
<div className="w-full md:w-1/3 space-y-3">
<h4 className="font-bold text-slate-700 mb-2">Apply Transformation</h4>
<button onClick={() => addConstant(5)} className="w-full py-2 px-4 bg-amber-100 hover:bg-amber-200 text-amber-900 rounded-lg font-bold text-sm text-left transition-colors">
+ Add 5 (Shift Right)
</button>
<button onClick={() => multiplyConstant(2)} className="w-full py-2 px-4 bg-amber-100 hover:bg-amber-200 text-amber-900 rounded-lg font-bold text-sm text-left transition-colors">
× Multiply by 2 (Scale)
</button>
<button onClick={() => addOutlier(80)} className="w-full py-2 px-4 bg-rose-100 hover:bg-rose-200 text-rose-900 rounded-lg font-bold text-sm text-left transition-colors border border-rose-200">
Add Outlier (80)
</button>
<button onClick={reset} className="w-full py-2 px-4 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg font-bold text-sm text-left transition-colors mt-4">
Reset Data
</button>
</div>
{/* Visualization */}
<div className="flex-1">
{/* Stats Panel */}
<div className="grid grid-cols-4 gap-2 mb-6 text-center">
<div className="p-2 bg-slate-50 border border-slate-200 rounded">
<div className="text-xs uppercase font-bold text-slate-500">Mean</div>
<div className="font-mono font-bold text-lg text-indigo-600">{stats.mean.toFixed(1)}</div>
</div>
<div className="p-2 bg-slate-50 border border-slate-200 rounded">
<div className="text-xs uppercase font-bold text-slate-500">Median</div>
<div className="font-mono font-bold text-lg text-emerald-600">{stats.median.toFixed(1)}</div>
</div>
<div className="p-2 bg-slate-50 border border-slate-200 rounded">
<div className="text-xs uppercase font-bold text-slate-500">Range</div>
<div className="font-mono font-bold text-lg text-slate-700">{stats.range.toFixed(0)}</div>
</div>
<div className="p-2 bg-slate-50 border border-slate-200 rounded">
<div className="text-xs uppercase font-bold text-slate-500">SD</div>
<div className="font-mono font-bold text-lg text-slate-700">{stats.sd.toFixed(1)}</div>
</div>
</div>
{/* Dot Plot */}
<div className="h-32 relative border-b border-slate-300">
{stats.sorted.map((val, idx) => (
<div
key={idx}
className="absolute w-4 h-4 rounded-full bg-indigo-500 shadow-sm border border-white transform -translate-x-1/2"
style={{ left: `${getX(val)}%`, bottom: '10px' }}
title={`Value: ${val}`}
/>
))}
{/* Mean Marker */}
<div className="absolute top-0 bottom-0 w-0.5 bg-indigo-300 border-l border-dashed border-indigo-500 opacity-60" style={{ left: `${getX(stats.mean)}%` }}>
<span className="absolute -top-6 left-1/2 -translate-x-1/2 text-xs font-bold text-indigo-600 bg-white px-1 rounded shadow-sm"></span>
</div>
{/* Median Marker */}
<div className="absolute top-0 bottom-0 w-0.5 bg-emerald-300 border-l border-dashed border-emerald-500 opacity-60" style={{ left: `${getX(stats.median)}%` }}>
<span className="absolute -bottom-6 left-1/2 -translate-x-1/2 text-xs font-bold text-emerald-600 bg-white px-1 rounded shadow-sm">M</span>
</div>
</div>
<div className="mt-8 text-sm text-slate-500 leading-relaxed bg-slate-50 p-3 rounded">
{data.length > 7 ? (
<span className="text-rose-600 font-bold">Notice how the Mean is pulled towards the outlier, while the Median barely moves!</span>
) : (
"Experiment with adding constants and multipliers to see which stats change."
)}
</div>
</div>
</div>
</div>
);
};
export default DataModifierWidget;

View File

@ -0,0 +1,235 @@
import React, { useState } from 'react';
import { ChevronRight, RotateCcw, CheckCircle2, AlertTriangle, Info } from 'lucide-react';
export interface TreeNode {
id: string;
question: string;
hint?: string;
yesLabel?: string;
noLabel?: string;
yes?: TreeNode;
no?: TreeNode;
result?: string;
resultType?: 'correct' | 'warning' | 'info';
ruleRef?: string;
}
export interface TreeScenario {
label: string; // Short tab label, e.g. "Sentence 1"
sentence: string; // The sentence to analyze
tree: TreeNode;
}
interface DecisionTreeWidgetProps {
scenarios: TreeScenario[];
accentColor?: string;
}
type Answers = Record<string, 'yes' | 'no'>;
/** Walk the tree following answers, return ordered list of [node, answer|null] pairs traversed */
function getPath(root: TreeNode, answers: Answers): Array<{ node: TreeNode; answer: 'yes' | 'no' | null }> {
const path: Array<{ node: TreeNode; answer: 'yes' | 'no' | null }> = [];
let current: TreeNode | undefined = root;
while (current) {
const ans = answers[current.id] ?? null;
path.push({ node: current, answer: ans });
if (ans === null) break; // not answered yet — this is the active node
if (current.result !== undefined) break; // leaf
current = ans === 'yes' ? current.yes : current.no;
}
return path;
}
const RESULT_STYLES = {
correct: {
bg: 'bg-green-50',
border: 'border-green-300',
text: 'text-green-800',
icon: <CheckCircle2 className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />,
},
warning: {
bg: 'bg-amber-50',
border: 'border-amber-300',
text: 'text-amber-800',
icon: <AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />,
},
info: {
bg: 'bg-blue-50',
border: 'border-blue-300',
text: 'text-blue-800',
icon: <Info className="w-5 h-5 text-blue-600 shrink-0 mt-0.5" />,
},
};
export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }: DecisionTreeWidgetProps) {
const [activeScenario, setActiveScenario] = useState(0);
const [answers, setAnswers] = useState<Answers>({});
const scenario = scenarios[activeScenario];
const path = getPath(scenario.tree, answers);
const lastStep = path[path.length - 1];
const isLeaf = lastStep.node.result !== undefined;
const isComplete = isLeaf && lastStep.answer === null; // reached leaf, no more choices needed
// Actually leaf nodes don't have yes/no — they just show result when we arrive
const atLeaf = lastStep.node.result !== undefined;
const handleAnswer = (nodeId: string, ans: 'yes' | 'no') => {
setAnswers(prev => {
// Remove all answers for nodes that come AFTER this one in the current path
const pathIds = path.map(p => p.node.id);
const idx = pathIds.indexOf(nodeId);
const newAnswers: Answers = {};
for (let i = 0; i < idx; i++) {
newAnswers[pathIds[i]] = prev[pathIds[i]]!;
}
newAnswers[nodeId] = ans;
return newAnswers;
});
};
const resetScenario = () => setAnswers({});
const switchScenario = (i: number) => {
setActiveScenario(i);
setAnswers({});
};
return (
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
{/* Scenario tab strip */}
{scenarios.length > 1 && (
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
{scenarios.map((sc, i) => (
<button
key={i}
onClick={() => switchScenario(i)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
i === activeScenario
? `bg-white border-b-2 border-${accentColor}-600 text-${accentColor}-700`
: 'text-gray-500 hover:text-gray-700'
}`}
>
{sc.label}
</button>
))}
</div>
)}
{/* Sentence under analysis */}
<div className={`px-5 py-4 border-b border-gray-100 bg-${accentColor}-50`}>
<p className={`text-xs font-semibold uppercase tracking-wider text-${accentColor}-500 mb-1`}>Analyze this sentence</p>
<p className="text-gray-800 font-medium italic leading-relaxed">"{scenario.sentence}"</p>
</div>
{/* Breadcrumb path */}
{path.length > 1 && (
<div className="px-5 py-2.5 border-b border-gray-100 bg-gray-50 flex flex-wrap items-center gap-1 text-xs text-gray-500">
{path.map((step, i) => {
if (i === path.length - 1) return null; // last step shown below, not in crumb
const isAnswered = step.answer !== null;
return (
<React.Fragment key={step.node.id}>
<button
onClick={() => {
// Reset from this node forward
const pathIds = path.map(p => p.node.id);
const idx = pathIds.indexOf(step.node.id);
setAnswers(prev => {
const newAnswers: Answers = {};
for (let j = 0; j < idx; j++) newAnswers[pathIds[j]] = prev[pathIds[j]]!;
return newAnswers;
});
}}
className={`px-2 py-0.5 rounded transition-colors ${
isAnswered ? 'text-gray-600 hover:text-gray-900 hover:bg-gray-200' : 'text-gray-400'
}`}
>
{step.node.question.length > 40 ? step.node.question.slice(0, 40) + '…' : step.node.question}
{step.answer && (
<span className={`ml-1 font-semibold ${step.answer === 'yes' ? 'text-green-600' : 'text-red-500'}`}>
{step.answer === 'yes' ? (step.node.yesLabel ?? 'Yes') : (step.node.noLabel ?? 'No')}
</span>
)}
</button>
<ChevronRight className="w-3 h-3 shrink-0" />
</React.Fragment>
);
})}
</div>
)}
{/* Active node */}
<div className="px-5 py-5">
{atLeaf ? (
/* Leaf result */
(() => {
const node = lastStep.node;
const rType = node.resultType ?? 'correct';
const s = RESULT_STYLES[rType];
return (
<div className={`rounded-xl border p-4 ${s.bg} ${s.border}`}>
<div className="flex gap-3">
{s.icon}
<div>
<p className={`font-semibold ${s.text} leading-snug`}>{node.result}</p>
{node.ruleRef && (
<p className={`mt-2 text-sm font-mono ${s.text} opacity-80 bg-white/60 rounded px-2 py-1 inline-block`}>
{node.ruleRef}
</p>
)}
</div>
</div>
</div>
);
})()
) : (
/* Decision question */
(() => {
const node = lastStep.node;
return (
<div>
<p className="font-semibold text-gray-800 text-base leading-snug mb-1">{node.question}</p>
{node.hint && <p className="text-sm text-gray-500 mb-4">{node.hint}</p>}
{!node.hint && <div className="mb-4" />}
<div className="flex flex-wrap gap-3">
<button
onClick={() => handleAnswer(node.id, 'yes')}
className="flex-1 min-w-[140px] px-4 py-3 rounded-xl border-2 border-green-300 bg-green-50 text-green-800 font-semibold text-sm hover:bg-green-100 transition-colors"
>
{node.yesLabel ?? 'Yes'}
</button>
<button
onClick={() => handleAnswer(node.id, 'no')}
className="flex-1 min-w-[140px] px-4 py-3 rounded-xl border-2 border-red-200 bg-red-50 text-red-700 font-semibold text-sm hover:bg-red-100 transition-colors"
>
{node.noLabel ?? 'No'}
</button>
</div>
</div>
);
})()
)}
</div>
{/* Footer */}
<div className="px-5 pb-4 flex items-center gap-3">
<button
onClick={resetScenario}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
Try again
</button>
{atLeaf && scenarios.length > 1 && activeScenario < scenarios.length - 1 && (
<button
onClick={() => switchScenario(activeScenario + 1)}
className={`ml-auto flex items-center gap-1.5 text-sm font-semibold text-${accentColor}-700 hover:text-${accentColor}-900 transition-colors`}
>
Next sentence <ChevronRight className="w-4 h-4" />
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,80 @@
import React, { useState } from 'react';
const DiscriminantWidget: React.FC = () => {
const [a, setA] = useState(1);
const [b, setB] = useState(-4);
const [c, setC] = useState(3); // Default x^2 - 4x + 3 (Roots 1, 3)
const discriminant = b*b - 4*a*c;
const rootsCount = discriminant > 0 ? 2 : discriminant === 0 ? 1 : 0;
// Viewport
const range = 10;
const size = 300;
const scale = size / (range * 2);
const center = size / 2;
const toPx = (v: number, isY = false) => isY ? center - v * scale : center + v * scale;
const generatePath = () => {
let dStr = "";
for (let x = -range; x <= range; x += 0.5) {
const y = a * x*x + b*x + c;
if (Math.abs(y) > range) continue;
const px = toPx(x);
const py = toPx(y, true);
dStr += dStr ? ` L ${px} ${py}` : `M ${px} ${py}`;
}
return dStr;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col gap-6">
<div className="flex justify-between items-center bg-slate-50 p-4 rounded-lg border border-slate-200">
<div className="font-mono text-lg font-bold text-slate-800">
Δ = b² - 4ac = <span className={discriminant > 0 ? "text-green-600" : discriminant < 0 ? "text-rose-600" : "text-amber-600"}>{discriminant}</span>
</div>
<div className={`px-3 py-1 rounded text-sm font-bold uppercase text-white ${discriminant > 0 ? "bg-green-500" : discriminant < 0 ? "bg-rose-500" : "bg-amber-500"}`}>
{rootsCount} Real Solution{rootsCount !== 1 ? 's' : ''}
</div>
</div>
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-4">
<div>
<label className="text-xs font-bold text-slate-500 uppercase">a = {a}</label>
<input type="range" min="-3" max="3" step="0.5" value={a} onChange={e => setA(parseFloat(e.target.value) || 0.1)} className="w-full h-1 bg-slate-200 rounded accent-slate-600"/>
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase">b = {b}</label>
<input type="range" min="-10" max="10" step="1" value={b} onChange={e => setB(parseFloat(e.target.value))} className="w-full h-1 bg-slate-200 rounded accent-slate-600"/>
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase">c = {c}</label>
<input type="range" min="-10" max="10" step="1" value={c} onChange={e => setC(parseFloat(e.target.value))} className="w-full h-1 bg-slate-200 rounded accent-slate-600"/>
</div>
<div className="text-xs text-slate-500 mt-4">
<p>If Δ &gt; 0: Crosses axis twice</p>
<p>If Δ = 0: Touches axis once (Vertex on axis)</p>
<p>If Δ &lt; 0: Never touches axis</p>
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[200px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300" preserveAspectRatio="xMidYMid slice">
<line x1="0" y1={center} x2={size} y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={size} stroke="#cbd5e1" strokeWidth="2" />
<path d={generatePath()} fill="none" stroke="#4f46e5" strokeWidth="3" />
</svg>
</div>
</div>
</div>
</div>
</div>
);
};
export default DiscriminantWidget;

View File

@ -0,0 +1,255 @@
import React, { useState } from "react";
import {
CheckCircle2,
XCircle,
RotateCcw,
ChevronRight,
MousePointerClick,
} from "lucide-react";
export interface EvidenceExercise {
question: string;
passage: string[]; // array of sentences rendered as a flowing paragraph
evidenceIndex: number; // 0-based index of the correct sentence
explanation: string;
}
interface EvidenceHunterWidgetProps {
exercises: EvidenceExercise[];
accentColor?: string;
}
// Tailwind needs complete class strings — map accent to concrete classes
const ACCENT: Record<
string,
{
tab: string;
header: string;
label: string;
hover: string;
selected: string;
btn: string;
next: string;
}
> = {
teal: {
tab: "border-b-2 border-teal-600 text-teal-700",
header: "bg-teal-50",
label: "text-teal-500",
hover: "hover:bg-teal-50 hover:border-teal-400",
selected: "bg-teal-100 border-teal-500",
btn: "bg-teal-600 hover:bg-teal-700",
next: "text-teal-700 hover:text-teal-900",
},
fuchsia: {
tab: "border-b-2 border-fuchsia-600 text-fuchsia-700",
header: "bg-fuchsia-50",
label: "text-fuchsia-500",
hover: "hover:bg-fuchsia-50 hover:border-fuchsia-400",
selected: "bg-fuchsia-100 border-fuchsia-500",
btn: "bg-fuchsia-600 hover:bg-fuchsia-700",
next: "text-fuchsia-700 hover:text-fuchsia-900",
},
purple: {
tab: "border-b-2 border-purple-600 text-purple-700",
header: "bg-purple-50",
label: "text-purple-500",
hover: "hover:bg-purple-50 hover:border-purple-400",
selected: "bg-purple-100 border-purple-500",
btn: "bg-purple-600 hover:bg-purple-700",
next: "text-purple-700 hover:text-purple-900",
},
amber: {
tab: "border-b-2 border-amber-600 text-amber-700",
header: "bg-amber-50",
label: "text-amber-500",
hover: "hover:bg-amber-50 hover:border-amber-400",
selected: "bg-amber-100 border-amber-500",
btn: "bg-amber-600 hover:bg-amber-700",
next: "text-amber-700 hover:text-amber-900",
},
};
export default function EvidenceHunterWidget({
exercises,
accentColor = "teal",
}: EvidenceHunterWidgetProps) {
const [activeEx, setActiveEx] = useState(0);
const [selected, setSelected] = useState<number | null>(null);
const [submitted, setSubmitted] = useState(false);
const exercise = exercises[activeEx];
const isCorrect = submitted && selected === exercise.evidenceIndex;
const c = ACCENT[accentColor] ?? ACCENT.teal;
const reset = () => {
setSelected(null);
setSubmitted(false);
};
const switchEx = (i: number) => {
setActiveEx(i);
setSelected(null);
setSubmitted(false);
};
return (
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
{/* Tab strip */}
{exercises.length > 1 && (
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
{exercises.map((_, i) => (
<button
key={i}
onClick={() => switchEx(i)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors bg-white ${
i === activeEx ? c.tab : "text-gray-500 hover:text-gray-700"
}`}
>
Passage {i + 1}
</button>
))}
</div>
)}
{/* Question */}
<div className={`px-5 py-4 border-b border-gray-200 ${c.header}`}>
<p
className={`text-xs font-bold uppercase tracking-wider mb-1.5 ${c.label}`}
>
Question
</p>
<p className="text-gray-800 font-semibold leading-snug text-base">
{exercise.question}
</p>
</div>
{/* Passage — flowing text with inline clickable sentences */}
<div className="px-5 pt-5 pb-3">
<div className="flex items-center gap-2 mb-3">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Passage
</p>
{!submitted && (
<span className="flex items-center gap-1 text-xs text-gray-400 italic">
<MousePointerClick className="w-3 h-3" /> click the sentence that
answers the question
</span>
)}
</div>
{/* Render as a flowing paragraph with clickable sentence spans */}
<div className="text-sm text-gray-700 leading-8 bg-gray-50 rounded-xl border border-gray-200 px-5 py-4 select-none">
{exercise.passage.map((sentence, i) => {
// Determine highlight class for this sentence
let spanCls = `inline cursor-pointer rounded px-0.5 py-0.5 border border-transparent transition-all ${c.hover}`;
if (submitted) {
if (i === exercise.evidenceIndex) {
spanCls =
"inline rounded px-0.5 py-0.5 border bg-green-100 border-green-400 text-green-800 font-medium cursor-default";
} else if (i === selected) {
spanCls =
"inline rounded px-0.5 py-0.5 border bg-red-100 border-red-300 text-red-600 cursor-default line-through";
} else {
spanCls =
"inline rounded px-0.5 py-0.5 border border-transparent text-gray-400 cursor-default";
}
} else if (selected === i) {
spanCls = `inline rounded px-0.5 py-0.5 border cursor-pointer ${c.selected} font-medium`;
}
return (
<React.Fragment key={i}>
<span
onClick={() => {
if (!submitted) setSelected(i);
}}
className={spanCls}
title={
submitted ? undefined : `Click to select sentence ${i + 1}`
}
>
{sentence}
</span>
{i < exercise.passage.length - 1 ? " " : ""}
</React.Fragment>
);
})}
</div>
{/* Selection indicator */}
{!submitted && selected !== null && (
<p className="mt-2 text-xs text-gray-500 italic">
Selected:{" "}
<span className="font-semibold text-gray-700">
"{exercise.passage[selected].slice(0, 60)}
{exercise.passage[selected].length > 60 ? "" : ""}"
</span>
</p>
)}
{!submitted && selected === null && (
<p className="mt-2 text-xs text-gray-400 italic">
No sentence selected yet.
</p>
)}
</div>
{/* Submit / result */}
<div className="px-5 pb-5">
{!submitted ? (
<button
disabled={selected === null}
onClick={() => setSubmitted(true)}
className={`mt-2 px-5 py-2.5 rounded-full text-sm font-bold text-white transition-colors ${
selected !== null
? c.btn
: "bg-gray-200 text-gray-400 cursor-not-allowed"
}`}
>
Check my answer
</button>
) : (
<div
className={`mt-3 rounded-xl border p-4 ${isCorrect ? "bg-green-50 border-green-300" : "bg-amber-50 border-amber-300"}`}
>
<div className="flex gap-2 mb-2">
{isCorrect ? (
<CheckCircle2 className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
) : (
<XCircle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
)}
<p
className={`font-semibold text-sm ${isCorrect ? "text-green-800" : "text-amber-800"}`}
>
{isCorrect
? "Correct — that's the key sentence."
: `Not quite. The highlighted sentence above is the correct one.`}
</p>
</div>
<p className="text-sm text-gray-700 leading-relaxed">
{exercise.explanation}
</p>
</div>
)}
<div className="flex items-center gap-3 mt-3">
{submitted && (
<button
onClick={reset}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" /> Try again
</button>
)}
{submitted && activeEx < exercises.length - 1 && (
<button
onClick={() => switchEx(activeEx + 1)}
className={`ml-auto flex items-center gap-1.5 text-sm font-semibold transition-colors ${c.next}`}
>
Next passage <ChevronRight className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,88 @@
import React, { useState } from 'react';
const ExponentialExplorer: React.FC = () => {
const [a, setA] = useState(2); // Initial Value
const [b, setB] = useState(1.5); // Growth Factor
const [k, setK] = useState(0); // Horizontal Asymptote shift
const width = 300;
const height = 300;
const range = 5; // x range -5 to 5
// Mapping
const toPx = (v: number, isY = false) => {
const scale = width / (range * 2);
const center = width / 2;
return isY ? center - v * scale : center + v * scale;
};
const generatePath = () => {
let d = "";
for (let x = -range; x <= range; x += 0.1) {
const y = a * Math.pow(b, x) + k;
if (y > range * 2 || y < -range * 2) continue; // Clip
const px = toPx(x);
const py = toPx(y, true);
d += d ? ` L ${px} ${py}` : `M ${px} ${py}`;
}
return d;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div className="p-4 bg-violet-50 rounded-xl border border-violet-100 text-center">
<div className="text-xs font-bold text-violet-400 uppercase mb-1">Standard Form</div>
<div className="text-xl font-mono font-bold text-violet-900">
y = <span className="text-indigo-600">{a}</span> · <span className="text-emerald-600">{b}</span><sup>x</sup> {k >= 0 ? '+' : ''} <span className="text-rose-600">{k}</span>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-xs font-bold text-indigo-600 uppercase flex justify-between">
Initial Value (a) <span>{a}</span>
</label>
<input type="range" min="0.5" max="5" step="0.5" value={a} onChange={e => setA(parseFloat(e.target.value))} className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"/>
</div>
<div>
<label className="text-xs font-bold text-emerald-600 uppercase flex justify-between">
Growth Factor (b) <span>{b}</span>
</label>
<input type="range" min="0.1" max="3" step="0.1" value={b} onChange={e => setB(parseFloat(e.target.value))} className="w-full h-2 bg-emerald-100 rounded-lg appearance-none cursor-pointer accent-emerald-600"/>
<p className="text-xs text-slate-400 mt-1">{b > 1 ? "Growth (b > 1)" : "Decay (0 < b < 1)"}</p>
</div>
<div>
<label className="text-xs font-bold text-rose-600 uppercase flex justify-between">
Vertical Shift (k) <span>{k}</span>
</label>
<input type="range" min="-3" max="3" step="1" value={k} onChange={e => setK(parseFloat(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"/>
</div>
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300">
<line x1="0" y1="150" x2="300" y2="150" stroke="#cbd5e1" strokeWidth="2" />
<line x1="150" y1="0" x2="150" y2="300" stroke="#cbd5e1" strokeWidth="2" />
{/* Asymptote */}
<line x1="0" y1={toPx(k, true)} x2="300" y2={toPx(k, true)} stroke="#e11d48" strokeWidth="1" strokeDasharray="4,4" />
<text x="10" y={toPx(k, true) - 5} className="text-xs font-bold fill-rose-500">y = {k}</text>
{/* Function */}
<path d={generatePath()} fill="none" stroke="#8b5cf6" strokeWidth="3" />
{/* Intercept */}
<circle cx={toPx(0)} cy={toPx(a+k, true)} r="4" fill="#4f46e5" stroke="white" strokeWidth="2" />
</svg>
</div>
</div>
</div>
</div>
);
};
export default ExponentialExplorer;

View File

@ -0,0 +1,116 @@
import React, { useState } from 'react';
const FactoringWidget: React.FC = () => {
// ax^2 + bx + c
const [a, setA] = useState(1);
const [b, setB] = useState(5);
const [c, setC] = useState(6);
const product = a * c;
const sum = b;
// We won't solve it for them immediately, let them guess or think
// But we will show if it's factorable over integers
// Simple check for nice numbers
const getFactors = () => {
// Find two numbers p, q such that p*q = product and p+q = sum
// Brute force range reasonable for typical SAT (up to +/- 100)
for (let i = -100; i <= 100; i++) {
if (i === 0) continue;
const q = product / i;
if (Math.abs(q - Math.round(q)) < 0.001) { // is integer
if (i + q === sum) return [i, q].sort((x,y) => x-y);
}
}
return null;
};
const solution = getFactors();
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8 items-center">
{/* Input Side */}
<div className="w-full md:w-1/2 space-y-4">
<h4 className="font-bold text-violet-900 mb-2">Polynomial: <span className="font-mono text-lg">{a === 1 ? '' : a}x² {b >= 0 ? '+' : ''}{b}x {c >= 0 ? '+' : ''}{c}</span></h4>
<div className="grid grid-cols-3 gap-2">
<div>
<label className="text-xs font-bold text-slate-400">a</label>
<input type="number" value={a} onChange={e => setA(parseInt(e.target.value) || 0)} className="w-full p-2 border rounded font-mono font-bold text-center" />
</div>
<div>
<label className="text-xs font-bold text-slate-400">b (Sum)</label>
<input type="number" value={b} onChange={e => setB(parseInt(e.target.value) || 0)} className="w-full p-2 border rounded font-mono font-bold text-center" />
</div>
<div>
<label className="text-xs font-bold text-slate-400">c</label>
<input type="number" value={c} onChange={e => setC(parseInt(e.target.value) || 0)} className="w-full p-2 border rounded font-mono font-bold text-center" />
</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg text-sm text-slate-600">
<p><strong>AC Method (Diamond):</strong></p>
<p>Find two numbers that multiply to <strong>a·c</strong> and add to <strong>b</strong>.</p>
<p className="mt-2 font-mono text-center">
Product (ac) = {a} × {c} = <strong>{product}</strong> <br/>
Sum (b) = <strong>{sum}</strong>
</p>
</div>
</div>
{/* Visual Side */}
<div className="flex-1 flex flex-col items-center justify-center">
<div className="relative w-48 h-48">
{/* X Shape */}
<div className="absolute top-0 left-0 w-full h-full">
<svg width="100%" height="100%" viewBox="0 0 200 200">
<line x1="20" y1="20" x2="180" y2="180" stroke="#cbd5e1" strokeWidth="4" />
<line x1="180" y1="20" x2="20" y2="180" stroke="#cbd5e1" strokeWidth="4" />
</svg>
</div>
{/* Top (Product) */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-4 bg-violet-100 px-3 py-1 rounded border border-violet-300 text-violet-800 font-bold shadow-sm">
{product}
</div>
{/* Bottom (Sum) */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-4 bg-indigo-100 px-3 py-1 rounded border border-indigo-300 text-indigo-800 font-bold shadow-sm">
{sum}
</div>
{/* Left (Factor 1) */}
<div className="absolute left-0 top-1/2 -translate-x-6 -translate-y-1/2 bg-white px-3 py-2 rounded border-2 border-emerald-400 text-emerald-700 font-bold shadow-md min-w-[3rem] text-center">
{solution ? solution[0] : "?"}
</div>
{/* Right (Factor 2) */}
<div className="absolute right-0 top-1/2 translate-x-6 -translate-y-1/2 bg-white px-3 py-2 rounded border-2 border-emerald-400 text-emerald-700 font-bold shadow-md min-w-[3rem] text-center">
{solution ? solution[1] : "?"}
</div>
</div>
<div className="mt-8 text-center">
{solution ? (
<div className="text-emerald-700 font-bold bg-emerald-50 px-4 py-2 rounded-lg border border-emerald-200">
Factors Found: {solution[0]} and {solution[1]}
{a === 1 && (
<div className="text-sm mt-1 font-mono text-slate-600">
(x {solution[0] >= 0 ? '+' : ''}{solution[0]})(x {solution[1] >= 0 ? '+' : ''}{solution[1]})
</div>
)}
</div>
) : (
<div className="text-rose-600 font-bold text-sm bg-rose-50 px-4 py-2 rounded-lg border border-rose-200">
No integer factors found. (Prime)
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default FactoringWidget;

View File

@ -0,0 +1,114 @@
import React, { useState } from 'react';
const FrequencyMeanWidget: React.FC = () => {
// Data: Value -> Frequency
const [counts, setCounts] = useState({ 0: 3, 1: 5, 2: 6, 3: 4, 4: 2 });
const handleChange = (val: number, delta: number) => {
setCounts(prev => ({
...prev,
[val]: Math.max(0, (prev[val as keyof typeof prev] || 0) + delta)
}));
};
const values = [0, 1, 2, 3, 4];
const totalStudents = values.reduce((sum, v) => sum + counts[v as keyof typeof counts], 0);
const totalBooks = values.reduce((sum, v) => sum + v * counts[v as keyof typeof counts], 0);
const mean = totalStudents > 0 ? (totalBooks / totalStudents).toFixed(2) : '0';
// Calculate Median position
let cumulative = 0;
let medianVal = 0;
const mid = (totalStudents + 1) / 2;
if (totalStudents > 0) {
for (const v of values) {
cumulative += counts[v as keyof typeof counts];
if (cumulative >= mid) {
medianVal = v;
break;
}
}
}
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Table Control */}
<div>
<h4 className="font-bold text-slate-700 mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-500"></span>
Edit Frequencies
</h4>
<div className="overflow-hidden rounded-lg border border-slate-200">
<table className="w-full text-sm text-center">
<thead className="bg-slate-50 text-slate-500 font-bold uppercase">
<tr>
<th className="p-3 border-b border-r border-slate-200">Books Read</th>
<th className="p-3 border-b border-slate-200">Students (Freq)</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{values.map(v => (
<tr key={v} className="group hover:bg-amber-50/50 transition-colors">
<td className="p-3 border-r border-slate-100 font-mono font-bold text-slate-700">{v}</td>
<td className="p-2 flex justify-center items-center gap-3">
<button
onClick={() => handleChange(v, -1)}
className="w-6 h-6 rounded bg-slate-100 hover:bg-slate-200 text-slate-600 font-bold flex items-center justify-center transition-colors"
>-</button>
<span className="w-4 font-mono font-bold text-slate-800">{counts[v as keyof typeof counts]}</span>
<button
onClick={() => handleChange(v, 1)}
className="w-6 h-6 rounded bg-amber-100 hover:bg-amber-200 text-amber-700 font-bold flex items-center justify-center transition-colors"
>+</button>
</td>
</tr>
))}
<tr className="bg-slate-50 font-bold text-slate-800">
<td className="p-3 border-r border-slate-200">TOTAL</td>
<td className="p-3">{totalStudents}</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Visualization & Stats */}
<div className="flex flex-col justify-between">
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 mb-6">
<h4 className="text-xs font-bold text-slate-400 uppercase mb-3">Dot Plot Preview</h4>
<div className="flex justify-between items-end h-32 px-2 pb-2 border-b border-slate-300">
{values.map(v => (
<div key={v} className="flex flex-col-reverse items-center gap-1 w-8">
{Array.from({length: counts[v as keyof typeof counts]}).map((_, i) => (
<div key={i} className="w-3 h-3 rounded-full bg-amber-500 shadow-sm"></div>
))}
</div>
))}
</div>
<div className="flex justify-between px-2 pt-2 text-xs font-mono font-bold text-slate-500">
{values.map(v => <span key={v} className="w-8 text-center">{v}</span>)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-indigo-50 border border-indigo-100 rounded-lg">
<p className="text-xs font-bold text-indigo-400 uppercase">Weighted Mean</p>
<p className="text-2xl font-bold text-indigo-700">{mean}</p>
<p className="text-[10px] text-indigo-400 mt-1">Total Books ({totalBooks}) / Students ({totalStudents})</p>
</div>
<div className="p-4 bg-emerald-50 border border-emerald-100 rounded-lg">
<p className="text-xs font-bold text-emerald-400 uppercase">Median</p>
<p className="text-2xl font-bold text-emerald-700">{medianVal}</p>
<p className="text-[10px] text-emerald-400 mt-1">Middle Position (~{mid.toFixed(1)})</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default FrequencyMeanWidget;

View File

@ -0,0 +1,84 @@
import React, { useState } from 'react';
const GrowthComparisonWidget: React.FC = () => {
const [linearRate, setLinearRate] = useState(10); // +10 per step
const [expRate, setExpRate] = useState(10); // +10% per step
const start = 100;
const steps = 10;
// Generate Data
const data = Array.from({ length: steps + 1 }, (_, i) => {
return {
x: i,
lin: start + linearRate * i,
exp: start * Math.pow(1 + expRate/100, i)
};
});
const maxY = Math.max(data[steps].lin, data[steps].exp);
// Scales
const width = 100;
const height = 60;
const getX = (i: number) => (i / steps) * width;
const getY = (val: number) => height - (val / maxY) * height; // Inverted Y
const linPath = `M ${data.map(d => `${getX(d.x)},${getY(d.lin)}`).join(' L ')}`;
const expPath = `M ${data.map(d => `${getX(d.x)},${getY(d.exp)}`).join(' L ')}`;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="grid grid-cols-2 gap-8 mb-6">
<div>
<label className="text-xs font-bold text-indigo-600 uppercase">Linear Rate (+)</label>
<input
type="range" min="5" max="50" value={linearRate}
onChange={e => setLinearRate(Number(e.target.value))}
className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600 mt-2"
/>
<div className="text-right font-mono font-bold text-indigo-700">+{linearRate} / step</div>
</div>
<div>
<label className="text-xs font-bold text-rose-600 uppercase">Exponential Rate (%)</label>
<input
type="range" min="2" max="30" value={expRate}
onChange={e => setExpRate(Number(e.target.value))}
className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600 mt-2"
/>
<div className="text-right font-mono font-bold text-rose-700">+{expRate}% / step</div>
</div>
</div>
<div className="relative h-64 w-full bg-slate-50 rounded-lg border border-slate-200 mb-6 overflow-hidden">
<svg viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" className="w-full h-full p-4 overflow-visible">
{/* Grid */}
<line x1="0" y1={height} x2={width} y2={height} stroke="#cbd5e1" strokeWidth="0.5" />
<line x1="0" y1="0" x2="0" y2={height} stroke="#cbd5e1" strokeWidth="0.5" />
{/* Paths */}
<path d={linPath} fill="none" stroke="#4f46e5" strokeWidth="1" />
<path d={expPath} fill="none" stroke="#e11d48" strokeWidth="1" />
{/* Points */}
{data.map((d, i) => (
<g key={i}>
<circle cx={getX(d.x)} cy={getY(d.lin)} r="1" fill="#4f46e5" />
<circle cx={getX(d.x)} cy={getY(d.exp)} r="1" fill="#e11d48" />
</g>
))}
</svg>
{/* Labels */}
<div className="absolute top-2 right-2 text-xs font-bold bg-white/80 p-2 rounded shadow-sm">
<div className="text-indigo-600">Linear Final: {Math.round(data[steps].lin)}</div>
<div className="text-rose-600">Exp Final: {Math.round(data[steps].exp)}</div>
</div>
</div>
<p className="text-sm text-slate-500">
Exponential growth eventually overtakes Linear growth, even if the linear rate seems larger at first!
</p>
</div>
);
};
export default GrowthComparisonWidget;

View File

@ -0,0 +1,86 @@
import React, { useState } from 'react';
const HistogramBuilderWidget: React.FC = () => {
const [mode, setMode] = useState<'count' | 'percent'>('count');
// Data: [60, 70), [70, 80), [80, 90), [90, 100)
const data = [
{ bin: '60-70', count: 4, label: '60s' },
{ bin: '70-80', count: 9, label: '70s' },
{ bin: '80-90', count: 6, label: '80s' },
{ bin: '90-100', count: 1, label: '90s' },
];
const total = data.reduce((acc, curr) => acc + curr.count, 0); // 20
const maxCount = Math.max(...data.map(d => d.count));
const maxPercent = maxCount / total; // 0.45
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex justify-between items-center mb-6">
<h3 className="font-bold text-slate-700">Test Scores Distribution</h3>
<div className="flex bg-slate-100 p-1 rounded-lg">
<button
onClick={() => setMode('count')}
className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all ${mode === 'count' ? 'bg-white shadow-sm text-indigo-600' : 'text-slate-500 hover:text-slate-700'}`}
>
Frequency (Count)
</button>
<button
onClick={() => setMode('percent')}
className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all ${mode === 'percent' ? 'bg-white shadow-sm text-rose-600' : 'text-slate-500 hover:text-slate-700'}`}
>
Relative Freq (%)
</button>
</div>
</div>
<div className="relative h-64 border-b-2 border-slate-200 flex items-end px-8 gap-1">
{/* Y Axis Labels */}
<div className="absolute left-0 top-0 bottom-0 flex flex-col justify-between text-xs font-mono text-slate-400 py-2">
<span>{mode === 'count' ? maxCount + 1 : ((maxPercent + 0.05)*100).toFixed(0) + '%'}</span>
<span>{mode === 'count' ? Math.round((maxCount+1)/2) : (((maxPercent + 0.05)/2)*100).toFixed(0) + '%'}</span>
<span>0</span>
</div>
{data.map((d, i) => {
const heightRatio = d.count / maxCount; // Normalize to max height of graph area roughly
// Actually map 0 to maxScale
const maxScale = mode === 'count' ? maxCount + 1 : (maxPercent + 0.05);
const val = mode === 'count' ? d.count : d.count / total;
const hPercent = (val / maxScale) * 100;
return (
<div key={i} className="flex-1 flex flex-col justify-end group relative h-full">
{/* Tooltip */}
<div className="opacity-0 group-hover:opacity-100 absolute -top-10 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-xs py-1 px-2 rounded pointer-events-none transition-opacity z-10 whitespace-nowrap">
{d.bin}: {mode === 'count' ? d.count : `${(d.count/total*100).toFixed(0)}%`}
</div>
{/* Bar */}
<div
className={`w-full transition-all duration-500 rounded-t ${mode === 'count' ? 'bg-indigo-500 group-hover:bg-indigo-600' : 'bg-rose-500 group-hover:bg-rose-600'}`}
style={{ height: `${hPercent}%` }}
></div>
{/* Bin Label */}
<div className="absolute -bottom-6 w-full text-center text-xs font-bold text-slate-500">
{d.label}
</div>
</div>
);
})}
</div>
<div className="mt-8 p-4 bg-slate-50 rounded-xl border border-slate-200">
<p className="text-sm text-slate-600">
<strong>Key Takeaway:</strong> Notice that the <span className="font-bold text-slate-800">shape</span> of the distribution stays exactly the same.
Only the <span className="font-bold text-slate-800">Y-axis scale</span> changes.
</p>
</div>
</div>
);
};
export default HistogramBuilderWidget;

View File

@ -0,0 +1,173 @@
import React, { useState, useRef } from 'react';
const InequalityRegionWidget: React.FC = () => {
// State for Inequalities: y > or < mx + b
// isGreater: true for >=, false for <=
const [ineq1, setIneq1] = useState({ m: 1, b: 1, isGreater: true });
const [ineq2, setIneq2] = useState({ m: -0.5, b: -2, isGreater: false });
const [testPoint, setTestPoint] = useState({ x: 0, y: 0 });
const isDragging = useRef(false);
const svgRef = useRef<SVGSVGElement>(null);
// Viewport
const range = 10;
const size = 300;
const scale = size / (range * 2);
const center = size / 2;
// Helpers
const toPx = (val: number, isY = false) => {
return isY ? center - val * scale : center + val * scale;
};
const fromPx = (px: number, isY = false) => {
return isY ? (center - px) / scale : (px - center) / scale;
};
// Generate polygon points for shading
const getRegionPoints = (m: number, b: number, isGreater: boolean) => {
const xMin = -range;
const xMax = range;
const yAtMin = m * xMin + b;
const yAtMax = m * xMax + b;
// y limit is the top (range) or bottom (-range) of the graph
const limitY = isGreater ? range : -range;
const p1 = { x: xMin, y: yAtMin };
const p2 = { x: xMax, y: yAtMax };
const p3 = { x: xMax, y: limitY };
const p4 = { x: xMin, y: limitY };
return `${toPx(p1.x)},${toPx(p1.y, true)} ${toPx(p2.x)},${toPx(p2.y, true)} ${toPx(p3.x)},${toPx(p3.y, true)} ${toPx(p4.x)},${toPx(p4.y, true)}`;
};
const getLinePath = (m: number, b: number) => {
const x1 = -range;
const y1 = m * x1 + b;
const x2 = range;
const y2 = m * x2 + b;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
// Interaction
const handleInteraction = (e: React.MouseEvent) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const x = fromPx(e.clientX - rect.left);
const y = fromPx(e.clientY - rect.top, true);
// Clamp
const cx = Math.max(-range, Math.min(range, x));
const cy = Math.max(-range, Math.min(range, y));
setTestPoint({ x: cx, y: cy });
};
// Logic Check
const check1 = ineq1.isGreater ? testPoint.y >= ineq1.m * testPoint.x + ineq1.b : testPoint.y <= ineq1.m * testPoint.x + ineq1.b;
const check2 = ineq2.isGreater ? testPoint.y >= ineq2.m * testPoint.x + ineq2.b : testPoint.y <= ineq2.m * testPoint.x + ineq2.b;
const isSolution = check1 && check2;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
{/* Controls */}
<div className="w-full md:w-1/3 space-y-6">
{/* Inequality 1 */}
<div className={`p-4 rounded-lg border ${check1 ? 'bg-indigo-50 border-indigo-200' : 'bg-slate-50 border-slate-200'}`}>
<div className="flex justify-between items-center mb-2">
<span className="font-bold text-indigo-800 text-sm">Region 1 (Blue)</span>
<button
onClick={() => setIneq1(p => ({...p, isGreater: !p.isGreater}))}
className="text-xs bg-white border border-indigo-200 px-2 py-1 rounded font-bold text-indigo-600"
>
{ineq1.isGreater ? 'y ≥ ...' : 'y ≤ ...'}
</button>
</div>
<div className="space-y-3">
<div>
<div className="flex justify-between text-xs text-slate-500"><span>Slope</span><span>{ineq1.m}</span></div>
<input type="range" min="-4" max="4" step="0.5" value={ineq1.m} onChange={e => setIneq1({...ineq1, m: parseFloat(e.target.value)})} className="w-full h-1 bg-indigo-200 rounded accent-indigo-600"/>
</div>
<div>
<div className="flex justify-between text-xs text-slate-500"><span>Y-Int</span><span>{ineq1.b}</span></div>
<input type="range" min="-8" max="8" step="1" value={ineq1.b} onChange={e => setIneq1({...ineq1, b: parseFloat(e.target.value)})} className="w-full h-1 bg-indigo-200 rounded accent-indigo-600"/>
</div>
</div>
</div>
{/* Inequality 2 */}
<div className={`p-4 rounded-lg border ${check2 ? 'bg-rose-50 border-rose-200' : 'bg-slate-50 border-slate-200'}`}>
<div className="flex justify-between items-center mb-2">
<span className="font-bold text-rose-800 text-sm">Region 2 (Red)</span>
<button
onClick={() => setIneq2(p => ({...p, isGreater: !p.isGreater}))}
className="text-xs bg-white border border-rose-200 px-2 py-1 rounded font-bold text-rose-600"
>
{ineq2.isGreater ? 'y ≥ ...' : 'y ≤ ...'}
</button>
</div>
<div className="space-y-3">
<div>
<div className="flex justify-between text-xs text-slate-500"><span>Slope</span><span>{ineq2.m}</span></div>
<input type="range" min="-4" max="4" step="0.5" value={ineq2.m} onChange={e => setIneq2({...ineq2, m: parseFloat(e.target.value)})} className="w-full h-1 bg-rose-200 rounded accent-rose-600"/>
</div>
<div>
<div className="flex justify-between text-xs text-slate-500"><span>Y-Int</span><span>{ineq2.b}</span></div>
<input type="range" min="-8" max="8" step="1" value={ineq2.b} onChange={e => setIneq2({...ineq2, b: parseFloat(e.target.value)})} className="w-full h-1 bg-rose-200 rounded accent-rose-600"/>
</div>
</div>
</div>
<div className={`p-3 rounded-lg text-center font-bold text-sm border-2 transition-colors ${isSolution ? 'bg-emerald-100 border-emerald-400 text-emerald-800' : 'bg-slate-100 border-slate-300 text-slate-500'}`}>
Test Point: ({testPoint.x.toFixed(1)}, {testPoint.y.toFixed(1)}) <br/>
{isSolution ? "SOLUTION FOUND" : "NOT A SOLUTION"}
</div>
</div>
{/* Graph */}
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden cursor-crosshair">
<svg
ref={svgRef}
width="300" height="300" viewBox="0 0 300 300"
onMouseDown={(e) => { isDragging.current = true; handleInteraction(e); }}
onMouseMove={(e) => { if(isDragging.current) handleInteraction(e); }}
onMouseUp={() => isDragging.current = false}
onMouseLeave={() => isDragging.current = false}
>
<defs>
<pattern id="grid-ineq" width="15" height="15" patternUnits="userSpaceOnUse">
<path d="M 15 0 L 0 0 0 15" fill="none" stroke="#f8fafc" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid-ineq)" />
{/* Axes */}
<line x1="0" y1={center} x2={size} y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={size} stroke="#cbd5e1" strokeWidth="2" />
{/* Region 1 */}
<polygon points={getRegionPoints(ineq1.m, ineq1.b, ineq1.isGreater)} fill="rgba(99, 102, 241, 0.2)" />
<path d={getLinePath(ineq1.m, ineq1.b)} stroke="#4f46e5" strokeWidth="2" />
{/* Region 2 */}
<polygon points={getRegionPoints(ineq2.m, ineq2.b, ineq2.isGreater)} fill="rgba(225, 29, 72, 0.2)" />
<path d={getLinePath(ineq2.m, ineq2.b)} stroke="#e11d48" strokeWidth="2" />
{/* Test Point */}
<circle
cx={toPx(testPoint.x)} cy={toPx(testPoint.y, true)} r="6"
fill={isSolution ? "#10b981" : "#64748b"} stroke="white" strokeWidth="2" className="shadow-sm"
/>
</svg>
</div>
</div>
</div>
</div>
);
};
export default InequalityRegionWidget;

View File

@ -0,0 +1,232 @@
import React, { useState, useRef } from "react";
const InteractiveSectorWidget: React.FC = () => {
const [angle, setAngle] = useState(60); // degrees
const [radius, setRadius] = useState(120); // pixels
const isDragging = useRef<"angle" | "radius" | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const cx = 200;
const cy = 200;
const maxRadius = 160;
// Calculate Handle Position
const rad = (angle * Math.PI) / 180;
const hx = cx + radius * Math.cos(-rad); // SVG Y is down, so -rad for standard math "up" rotation behavior if we want counter-clockwise from East
const hy = cy + radius * Math.sin(-rad);
// For the arc path
// Start point is (cx + r, cy) [0 degrees]
// End point is (hx, hy)
const largeArc = angle > 180 ? 1 : 0;
// Sweep flag 0 because we are using -rad (counter clockwise visual in SVG)
const pathData = `
M ${cx} ${cy}
L ${cx + radius} ${cy}
A ${radius} ${radius} 0 ${largeArc} 0 ${hx} ${hy}
Z
`;
// Interaction
const handleInteraction = (e: React.MouseEvent) => {
if (!svgRef.current || !isDragging.current) return;
const rect = svgRef.current.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const dx = mx - cx;
const dy = my - cy;
if (isDragging.current === "angle") {
let deg = Math.atan2(-dy, dx) * (180 / Math.PI); // -dy to correct for SVG coords
if (deg < 0) deg += 360;
setAngle(Math.round(deg));
} else if (isDragging.current === "radius") {
const dist = Math.sqrt(dx * dx + dy * dy);
setRadius(Math.max(50, Math.min(maxRadius, dist)));
}
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col md:flex-row items-center gap-8">
<div className="relative select-none">
<svg
ref={svgRef}
width="400"
height="400"
onMouseMove={handleInteraction}
onMouseUp={() => (isDragging.current = null)}
onMouseLeave={() => (isDragging.current = null)}
className="cursor-crosshair"
>
{/* Full Circle Ghost */}
<circle
cx={cx}
cy={cy}
r={radius}
stroke="#e2e8f0"
strokeWidth="1"
fill="none"
strokeDasharray="4,4"
/>
{/* Sector */}
<path
d={pathData}
fill="rgba(249, 115, 22, 0.2)"
stroke="#f97316"
strokeWidth="2"
/>
{/* Radius Handle Line (Baseline) */}
<line
x1={cx}
y1={cy}
x2={cx + radius}
y2={cy}
stroke="#cbd5e1"
strokeWidth="2"
/>
{/* Radius Drag Handle (on baseline) */}
<circle
cx={cx + radius}
cy={cy}
r={8}
fill="#94a3b8"
stroke="white"
strokeWidth="2"
className="cursor-ew-resize hover:fill-slate-600 shadow-sm"
onMouseDown={() => (isDragging.current = "radius")}
/>
{/* Angle Drag Handle (on arc) */}
<circle
cx={hx}
cy={hy}
r={10}
fill="#f97316"
stroke="white"
strokeWidth="2"
className="cursor-pointer hover:scale-110 transition-transform shadow-md"
onMouseDown={() => (isDragging.current = "angle")}
/>
{/* Angle Text */}
<text
x={cx + 20}
y={cy - 10}
className="text-xs font-bold fill-orange-600"
>
{angle}°
</text>
{/* Radius Text */}
<text
x={cx + radius / 2}
y={cy + 15}
textAnchor="middle"
className="text-xs font-bold fill-slate-400"
>
r = {Math.round(radius / 10)}
</text>
{/* Center */}
<circle cx={cx} cy={cy} r={4} fill="#64748b" />
</svg>
</div>
<div className="flex-1 w-full space-y-6">
<div className="bg-orange-50 border border-orange-100 p-4 rounded-xl">
<h3 className="text-orange-900 font-bold mb-2 flex items-center gap-2">
<span className="p-1 bg-orange-200 rounded text-xs">INPUT</span>
Parameters
</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between text-xs text-orange-700 uppercase font-bold mb-1">
Angle (θ): {angle}°
</div>
<input
type="range"
min="1"
max="360"
value={angle}
onChange={(e) => setAngle(parseInt(e.target.value))}
className="w-full h-2 bg-orange-200 rounded-lg appearance-none cursor-pointer accent-orange-600"
/>
</div>
<div>
<div className="flex justify-between text-xs text-orange-700 uppercase font-bold mb-1">
Radius (r): {Math.round(radius / 10)}
</div>
<input
type="range"
min="50"
max={maxRadius}
value={radius}
onChange={(e) => setRadius(parseInt(e.target.value))}
className="w-full h-2 bg-orange-200 rounded-lg appearance-none cursor-pointer accent-orange-600"
/>
</div>
</div>
</div>
<div className="space-y-4">
<div className="p-4 bg-white border border-slate-200 rounded-xl shadow-sm">
<div className="flex justify-between items-center mb-1">
<span className="text-sm font-bold text-slate-600">
Fraction of Circle
</span>
<span className="font-mono text-orange-600 font-bold">
{angle}/360 {(angle / 360).toFixed(2)}
</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2">
<div
className="bg-orange-500 h-2 rounded-full transition-all"
style={{ width: `${(angle / 360) * 100}%` }}
></div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="p-4 bg-white border border-slate-200 rounded-xl shadow-sm">
<span className="text-xs font-bold text-slate-400 uppercase">
Arc Length
</span>
<div className="font-mono text-lg text-slate-800 mt-1">
2π({Math.round(radius / 10)}) ×{" "}
<span className="text-orange-600">
{(angle / 360).toFixed(2)}
</span>
</div>
<div className="font-bold text-xl text-slate-900 mt-1">
= {(2 * Math.PI * (radius / 10) * (angle / 360)).toFixed(1)}
</div>
</div>
<div className="p-4 bg-white border border-slate-200 rounded-xl shadow-sm">
<span className="text-xs font-bold text-slate-400 uppercase">
Sector Area
</span>
<div className="font-mono text-lg text-slate-800 mt-1">
π({Math.round(radius / 10)})² ×{" "}
<span className="text-orange-600">
{(angle / 360).toFixed(2)}
</span>
</div>
<div className="font-bold text-xl text-slate-900 mt-1">
={" "}
{(Math.PI * Math.pow(radius / 10, 2) * (angle / 360)).toFixed(
1,
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default InteractiveSectorWidget;

View File

@ -0,0 +1,186 @@
import React, { useState } from 'react';
type Relationship = 'none' | 'vertical' | 'linear' | 'corresponding' | 'alt-interior' | 'same-side';
const InteractiveTransversal: React.FC = () => {
const [activeRel, setActiveRel] = useState<Relationship>('same-side');
// SVG Config
const width = 500;
const height = 300;
const line1Y = 100;
const line2Y = 200;
// Transversal passes through (200, 100) and (300, 200)
// Slope is 1 (45 degrees down-right)
const intersection1 = { x: 200, y: 100 };
const intersection2 = { x: 300, y: 200 };
// Angle Definitions (SVG y-down coordinates)
// 0 deg = Right, 90 deg = Down, 180 deg = Left, 270 deg = Up
// Transversal vector is (1, 1), angle is 45 deg.
// Opposite ray is 225 deg.
const angles = [
// Intersection 1 (Top)
{ id: 1, cx: intersection1.x, cy: intersection1.y, start: 180, end: 225, labelPos: 202.5, quadrant: 'TL' }, // Top-Left (Acute)
{ id: 2, cx: intersection1.x, cy: intersection1.y, start: 225, end: 360, labelPos: 292.5, quadrant: 'TR' }, // Top-Right (Obtuse)
{ id: 3, cx: intersection1.x, cy: intersection1.y, start: 0, end: 45, labelPos: 22.5, quadrant: 'BR' }, // Bottom-Right (Acute)
{ id: 4, cx: intersection1.x, cy: intersection1.y, start: 45, end: 180, labelPos: 112.5, quadrant: 'BL' }, // Bottom-Left (Obtuse)
// Intersection 2 (Bottom)
{ id: 5, cx: intersection2.x, cy: intersection2.y, start: 180, end: 225, labelPos: 202.5, quadrant: 'TL' },
{ id: 6, cx: intersection2.x, cy: intersection2.y, start: 225, end: 360, labelPos: 292.5, quadrant: 'TR' },
{ id: 7, cx: intersection2.x, cy: intersection2.y, start: 0, end: 45, labelPos: 22.5, quadrant: 'BR' },
{ id: 8, cx: intersection2.x, cy: intersection2.y, start: 45, end: 180, labelPos: 112.5, quadrant: 'BL' },
];
const getArcPath = (cx: number, cy: number, r: number, startDeg: number, endDeg: number) => {
// Convert to radians
const startRad = (startDeg * Math.PI) / 180;
const endRad = (endDeg * Math.PI) / 180;
const x1 = cx + r * Math.cos(startRad);
const y1 = cy + r * Math.sin(startRad);
const x2 = cx + r * Math.cos(endRad);
const y2 = cy + r * Math.sin(endRad);
const largeArc = (endDeg - startDeg) > 180 ? 1 : 0;
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
};
const getLabelPos = (cx: number, cy: number, r: number, angleDeg: number) => {
const rad = (angleDeg * Math.PI) / 180;
return {
x: cx + r * Math.cos(rad),
y: cy + r * Math.sin(rad)
};
};
const getStyles = (id: number) => {
const base = { fill: 'transparent', stroke: 'transparent', label: 'text-slate-400' };
const highlightBlue = { fill: 'rgba(99, 102, 241, 0.3)', stroke: '#4f46e5', label: 'text-indigo-600 font-bold' };
const highlightPink = { fill: 'rgba(244, 63, 94, 0.3)', stroke: '#e11d48', label: 'text-rose-600 font-bold' };
switch (activeRel) {
case 'vertical':
// 1 & 3 are equal
if ([1, 3].includes(id)) return highlightBlue;
return base;
case 'linear':
// 1 & 2 are supplementary
if (id === 1) return highlightBlue;
if (id === 2) return highlightPink;
return base;
case 'corresponding':
// 2 & 6 are equal
if ([2, 6].includes(id)) return highlightBlue;
return base;
case 'alt-interior':
// 4 & 6 are equal
if ([4, 6].includes(id)) return highlightBlue;
return base;
case 'same-side':
// 3 & 6 are supplementary
if (id === 3) return highlightBlue;
if (id === 6) return highlightPink;
return base;
default:
return base;
}
};
const getDescription = () => {
switch (activeRel) {
case 'vertical': return "Vertical Angles are equal (e.g. ∠1 = ∠3)";
case 'linear': return "Linear Pairs sum to 180° (e.g. ∠1 + ∠2 = 180°)";
case 'corresponding': return "Corresponding Angles are equal (e.g. ∠2 = ∠6)";
case 'alt-interior': return "Alternate Interior Angles are equal (e.g. ∠4 = ∠6)";
case 'same-side': return "Same-Side Interior sum to 180° (e.g. ∠3 + ∠6 = 180°)";
default: return "Select a relationship to highlight";
}
};
const buttons: { id: Relationship, label: string }[] = [
{ id: 'vertical', label: 'Vertical Angles' },
{ id: 'linear', label: 'Linear Pair' },
{ id: 'corresponding', label: 'Corresponding' },
{ id: 'alt-interior', label: 'Alt. Interior' },
{ id: 'same-side', label: 'Same-Side Interior' },
];
return (
<div className="flex flex-col items-center bg-white p-8 rounded-xl shadow-sm border border-slate-200">
<div className="flex flex-wrap gap-2 justify-center mb-8">
{buttons.map(btn => (
<button
key={btn.id}
onClick={() => setActiveRel(activeRel === btn.id ? 'none' : btn.id)}
className={`px-4 py-2 rounded-full text-sm font-bold transition-all border ${
activeRel === btn.id
? 'bg-slate-900 text-white border-slate-900 shadow-md'
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-400 hover:bg-slate-50'
}`}
>
{btn.label}
</button>
))}
</div>
<div className="relative w-full flex justify-center">
<div className="absolute top-0 left-0 w-full text-center">
<p className="text-slate-500 font-medium">{getDescription()}</p>
</div>
<svg width={width} height={height} className="mt-8 select-none">
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="4" orient="auto">
<path d="M0,0 L0,8 L12,4 z" fill="#64748b" />
</marker>
</defs>
{/* Parallel Lines */}
<line x1="50" y1={line1Y} x2="450" y2={line1Y} stroke="#64748b" strokeWidth="3" markerEnd="url(#arrow)" />
<line x1="450" y1={line1Y} x2="50" y2={line1Y} stroke="#64748b" strokeWidth="3" markerEnd="url(#arrow)" />
<line x1="50" y1={line2Y} x2="450" y2={line2Y} stroke="#64748b" strokeWidth="3" markerEnd="url(#arrow)" />
<line x1="450" y1={line2Y} x2="50" y2={line2Y} stroke="#64748b" strokeWidth="3" markerEnd="url(#arrow)" />
{/* Transversal (infinite line simulation) */}
<line x1="100" y1="0" x2="400" y2="300" stroke="#0f172a" strokeWidth="3" strokeLinecap="round" />
{/* Angles */}
{angles.map((angle) => {
const styles = getStyles(angle.id);
const r = 35;
const labelPos = getLabelPos(angle.cx, angle.cy, r + 15, angle.labelPos);
return (
<g key={angle.id}>
<path
d={getArcPath(angle.cx, angle.cy, r, angle.start, angle.end)}
fill={styles.fill}
stroke={styles.stroke}
strokeWidth={styles.stroke === 'transparent' ? 0 : 2}
/>
<text
x={labelPos.x}
y={labelPos.y}
textAnchor="middle"
dominantBaseline="middle"
className={`text-sm select-none ${styles.label}`}
>
{angle.id}
</text>
</g>
);
})}
</svg>
</div>
</div>
);
};
export default InteractiveTransversal;

View File

@ -0,0 +1,236 @@
import React, { useState, useRef, useEffect } from 'react';
// Helper to convert radians to degrees
const toDeg = (rad: number) => (rad * 180) / Math.PI;
const InteractiveTriangle: React.FC = () => {
// Vertex B state (the draggable top vertex)
// Default position forming a nice scalene triangle
const [bPos, setBPos] = useState({ x: 120, y: 50 });
const [isDragging, setIsDragging] = useState(false);
const [showProof, setShowProof] = useState(false);
const svgRef = useRef<SVGSVGElement>(null);
// Fixed vertices
const A = { x: 50, y: 250 };
const C = { x: 300, y: 250 };
const D = { x: 450, y: 250 }; // Extension of base AC
// Colors
const colors = {
A: { text: "text-indigo-600", stroke: "#4f46e5", fill: "rgba(79, 70, 229, 0.2)" },
B: { text: "text-emerald-600", stroke: "#059669", fill: "rgba(5, 150, 105, 0.2)" },
Ext: { text: "text-rose-600", stroke: "#e11d48", fill: "rgba(225, 29, 72, 0.2)" }
};
// Drag logic
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
let x = e.clientX - rect.left;
let y = e.clientY - rect.top;
// Constraints
x = Math.max(20, Math.min(x, 380));
y = Math.max(20, Math.min(y, 230)); // Keep B above the base (y < 250)
setBPos({ x, y });
};
const handleMouseUp = () => setIsDragging(false);
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging]);
// Calculations
// SVG Coordinate system: Y is Down.
// We use atan2(dy, dx) to get angles.
// Angle of vector (dx, dy).
// Angle of AB
const angleAB_rad = Math.atan2(bPos.y - A.y, bPos.x - A.x);
const angleAB_deg = toDeg(angleAB_rad); // usually negative (e.g. -60)
// Angle of AC is 0.
// Angle A (magnitude)
const valA = Math.abs(angleAB_deg);
// Angle of CB
const angleCB_rad = Math.atan2(bPos.y - C.y, bPos.x - C.x);
const angleCB_deg = toDeg(angleCB_rad); // usually negative (e.g. -120)
// Angle of CA is 180.
// Angle C Interior (magnitude) = 180 - abs(angleCB_deg) if y < C.y (which it is).
const valC = 180 - Math.abs(angleCB_deg);
// Angle B (Interior)
const valB = 180 - valA - valC;
// Exterior Angle (magnitude)
// Between CD (0) and CB (angleCB_deg).
// Ext = abs(angleCB_deg).
const valExt = Math.abs(angleCB_deg);
// Arc Generation Helper
const getArcPath = (cx: number, cy: number, r: number, startDeg: number, endDeg: number) => {
// SVG standard: degrees clockwise from X-axis.
// Our atan2 returns degrees relative to X-axis (clockwise positive if Y down).
// so we can use them directly.
const startRad = (startDeg * Math.PI) / 180;
const endRad = (endDeg * Math.PI) / 180;
const x1 = cx + r * Math.cos(startRad);
const y1 = cy + r * Math.sin(startRad);
const x2 = cx + r * Math.cos(endRad);
const y2 = cy + r * Math.sin(endRad);
// Sweep flag: 0 if counter-clockwise, 1 if clockwise.
// We want to draw from start to end.
// If we go from negative angle (AB) to 0 (AC), difference is positive.
const largeArc = Math.abs(endDeg - startDeg) > 180 ? 1 : 0;
const sweep = endDeg > startDeg ? 1 : 0;
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} ${sweep} ${x2} ${y2} Z`;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col items-center select-none">
<div className="w-full flex justify-between items-center mb-4 px-2">
<h3 className="font-bold text-slate-700">Interactive Triangle</h3>
<label className="flex items-center gap-2 text-sm cursor-pointer hover:bg-slate-50 p-2 rounded transition-colors">
<input
type="checkbox"
checked={showProof}
onChange={(e) => setShowProof(e.target.checked)}
className="rounded text-indigo-600 focus:ring-indigo-500"
/>
<span className="font-medium text-slate-600">Show Proof (Parallel Line)</span>
</label>
</div>
<svg ref={svgRef} width="500" height="300" className="cursor-default">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#94a3b8" />
</marker>
</defs>
{/* Base Line Extension */}
<line x1={C.x} y1={C.y} x2={D.x} y2={D.y} stroke="#cbd5e1" strokeWidth="2" strokeDasharray="6,6" />
<text x={D.x} y={D.y + 20} fontSize="12" fill="#94a3b8">D</text>
{/* Angle Arcs */}
{/* Angle A: from angleAB to 0 */}
<path
d={getArcPath(A.x, A.y, 40, angleAB_deg, 0)}
fill={colors.A.fill} stroke={colors.A.stroke} strokeWidth="1"
/>
<text x={A.x + 50} y={A.y - 10} className={`text-xs font-bold ${colors.A.text}`} style={{opacity: 0.8}}>{Math.round(valA)}°</text>
{/* Angle B: from angle of BA to angle of BC */}
{/* Angle of BA is angleAB + 180. Angle of BC is angleCB + 180. */}
{/* Wait, B is center. */}
{/* Vector BA: A - B. Angle = atan2(Ay - By, Ax - Bx). */}
{/* Vector BC: C - B. Angle = atan2(Cy - By, Cx - Bx). */}
<path
d={getArcPath(bPos.x, bPos.y, 40, Math.atan2(A.y - bPos.y, A.x - bPos.x) * 180/Math.PI, Math.atan2(C.y - bPos.y, C.x - bPos.x) * 180/Math.PI)}
fill={colors.B.fill} stroke={colors.B.stroke} strokeWidth="1"
/>
{/* Label B slightly above vertex */}
<text x={bPos.x} y={bPos.y - 15} textAnchor="middle" className={`text-xs font-bold ${colors.B.text}`} style={{opacity: 0.8}}>{Math.round(valB)}°</text>
{/* Exterior Angle: at C, from angleCB to 0 */}
{/* If showing proof, split it */}
{!showProof && (
<path
d={getArcPath(C.x, C.y, 50, angleCB_deg, 0)}
fill={colors.Ext.fill} stroke={colors.Ext.stroke} strokeWidth="1"
/>
)}
{/* Proof Visuals */}
{showProof && (
<>
{/* Parallel Line CE. Angle same as AB: angleAB_deg */}
<line
x1={C.x} y1={C.y}
x2={C.x + 100 * Math.cos(angleAB_rad)} y2={C.y + 100 * Math.sin(angleAB_rad)}
stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4,4"
/>
<text x={C.x + 110 * Math.cos(angleAB_rad)} y={C.y + 110 * Math.sin(angleAB_rad)} fontSize="12" fill="#94a3b8">E</text>
{/* Lower part of Ext (Corresponding to A) - From angleAB_deg to 0 */}
<path
d={getArcPath(C.x, C.y, 50, angleAB_deg, 0)}
fill={colors.A.fill} stroke={colors.A.stroke} strokeWidth="1"
/>
<text x={C.x + 60} y={C.y - 10} className={`text-xs font-bold ${colors.A.text}`}>{Math.round(valA)}°</text>
{/* Upper part of Ext (Alt Interior to B) - From angleCB_deg to angleAB_deg */}
<path
d={getArcPath(C.x, C.y, 50, angleCB_deg, angleAB_deg)}
fill={colors.B.fill} stroke={colors.B.stroke} strokeWidth="1"
/>
<text x={C.x + 35} y={C.y - 50} className={`text-xs font-bold ${colors.B.text}`}>{Math.round(valB)}°</text>
</>
)}
{/* Label Ext if not split or just general label */}
{!showProof && (
<text x={C.x + 60} y={C.y - 30} className={`text-sm font-bold ${colors.Ext.text}`}>Ext {Math.round(valExt)}°</text>
)}
{/* Triangle Lines */}
<path d={`M ${A.x} ${A.y} L ${bPos.x} ${bPos.y} L ${C.x} ${C.y} Z`} fill="none" stroke="#1e293b" strokeWidth="2" strokeLinejoin="round" />
{/* Vertices */}
<circle cx={A.x} cy={A.y} r="4" fill="#1e293b" />
<text x={A.x - 15} y={A.y + 5} fontSize="14" fontWeight="bold" fill="#334155">A</text>
<circle cx={C.x} cy={C.y} r="4" fill="#1e293b" />
<text x={C.x + 5} y={C.y + 20} fontSize="14" fontWeight="bold" fill="#334155">C</text>
{/* Draggable B */}
<g
onMouseDown={() => setIsDragging(true)}
className="cursor-grab active:cursor-grabbing"
>
<circle cx={bPos.x} cy={bPos.y} r="12" fill="transparent" /> {/* Hit area */}
<circle cx={bPos.x} cy={bPos.y} r="6" fill="#4f46e5" stroke="white" strokeWidth="2" className="shadow-sm" />
<text x={bPos.x} y={bPos.y - 20} textAnchor="middle" fontSize="14" fontWeight="bold" fill="#334155">B</text>
</g>
</svg>
<div className="w-full mt-4 p-4 bg-slate-50 rounded-lg border border-slate-100 flex flex-col items-center">
<div className="flex items-center gap-4 text-lg font-mono">
<span className={colors.Ext.text}>Ext ({Math.round(valExt)}°)</span>
<span className="text-slate-400">=</span>
<span className={colors.A.text}>A ({Math.round(valA)}°)</span>
<span className="text-slate-400">+</span>
<span className={colors.B.text}>B ({Math.round(valB)}°)</span>
</div>
<p className="text-xs text-slate-400 mt-2">
{showProof
? "Notice how the parallel line 'transports' angle A and B to the exterior?"
: "Drag vertex B to see the values update."}
</p>
</div>
</div>
);
};
export default InteractiveTriangle;

View File

@ -0,0 +1,23 @@
// LessonRenderer.tsx
import { Suspense } from "react";
import { LESSON_COMPONENT_MAP } from "../FetchLessonPage";
import type { LessonId } from "../FetchLessonPage";
interface Props {
lessonId: LessonId;
}
export const LessonRenderer = ({ lessonId }: Props) => {
const LessonComponent = LESSON_COMPONENT_MAP[lessonId];
if (!LessonComponent) {
return <p>Lesson not found.</p>;
}
return (
<Suspense fallback={<p>Loading lesson...</p>}>
<LessonComponent />
</Suspense>
);
};

View File

@ -0,0 +1,499 @@
import React, { useRef, useState, useEffect } from "react";
import { Check, ChevronDown, ChevronUp, ChevronRight } from "lucide-react";
import type { PracticeQuestion } from "../../types/lesson";
import {
transformMathHtml,
isQuestionBroken,
} from "../../utils/mathHtmlTransform";
export interface SectionDef {
title: string;
icon: React.ComponentType<{ className?: string }>;
}
interface LessonShellProps {
title: string;
sections: SectionDef[];
color: "blue" | "violet" | "amber" | "emerald";
onFinish?: () => void;
children: React.ReactNode;
}
/* ─── colour palette for each category ─── */
const PALETTES = {
blue: {
activeBg: "bg-blue-600",
activeText: "text-blue-900",
pastBg: "bg-blue-400",
sidebarActive: "bg-white/80 shadow-md border border-blue-100",
dotBg: "bg-blue-100",
dotText: "text-blue-500",
glassClass: "glass-blue",
},
violet: {
activeBg: "bg-violet-600",
activeText: "text-violet-900",
pastBg: "bg-violet-400",
sidebarActive: "bg-white/80 shadow-md border border-violet-100",
dotBg: "bg-violet-100",
dotText: "text-violet-500",
glassClass: "glass-violet",
},
amber: {
activeBg: "bg-amber-600",
activeText: "text-amber-900",
pastBg: "bg-amber-400",
sidebarActive: "bg-white/80 shadow-md border border-amber-100",
dotBg: "bg-amber-100",
dotText: "text-amber-500",
glassClass: "glass-amber",
},
emerald: {
activeBg: "bg-emerald-600",
activeText: "text-emerald-900",
pastBg: "bg-emerald-400",
sidebarActive: "bg-white/80 shadow-md border border-emerald-100",
dotBg: "bg-emerald-100",
dotText: "text-emerald-500",
glassClass: "glass-emerald",
},
};
export default function LessonShell({
title,
sections,
color,
onFinish,
children,
}: LessonShellProps) {
const palette = PALETTES[color];
const [activeSection, setActiveSection] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(false);
const sectionsRef = useRef<(HTMLElement | null)[]>([]);
const scrollToSection = (index: number) => {
setActiveSection(index);
sectionsRef.current[index]?.scrollIntoView({
behavior: "smooth",
block: "start",
});
setSidebarOpen(false);
};
/* scroll-spy */
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const idx = sectionsRef.current.indexOf(
entry.target as HTMLElement,
);
if (idx !== -1) setActiveSection(idx);
}
});
},
{ rootMargin: "-20% 0px -60% 0px" },
);
sectionsRef.current.forEach((s) => {
if (s) observer.observe(s);
});
return () => observer.disconnect();
}, []);
/* Inject ref callbacks onto section-wrapper children */
const childArray = React.Children.toArray(children);
return (
<div className="flex flex-col lg:flex-row min-h-screen lesson-bg">
{/* ── Mobile toggle ── */}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="lg:hidden fixed bottom-4 right-4 z-50 flex items-center gap-2 px-4 py-2.5 rounded-full shadow-lg text-sm font-bold text-slate-700 bg-white"
>
{sidebarOpen ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronUp className="w-4 h-4" />
)}
{sections[activeSection]?.title ?? "Sections"}
</button>
{/* ── Sidebar ── */}
<aside
className={`
${sidebarOpen ? "translate-y-0" : "translate-y-full lg:translate-y-0"}
fixed bottom-0 left-0 right-0 lg:top-20 lg:bottom-0 lg:left-0 lg:right-auto
w-full lg:w-64 z-40 lg:z-0
glass-sidebar p-4 lg:overflow-y-auto
transition-transform duration-300 ease-out
rounded-t-2xl lg:rounded-none
border-t lg:border-t-0 border-slate-200/50
`}
>
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-400 mb-3 px-1 hidden lg:block">
Sections
</p>
<nav className="space-y-1.5 bg-white">
{sections.map((sec, i) => {
const isActive = activeSection === i;
const isPast = activeSection > i;
const Icon = sec.icon;
return (
<button
key={i}
onClick={() => scrollToSection(i)}
className={`flex items-center gap-3 p-2.5 w-full rounded-xl transition-all text-left ${
isActive ? palette.sidebarActive : "hover:bg-white/50"
}`}
>
<div
className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 transition-colors ${
isActive
? `${palette.activeBg} text-white`
: isPast
? `${palette.pastBg} text-white`
: `${palette.dotBg} ${palette.dotText}`
}`}
>
{isPast ? (
<Check className="w-3.5 h-3.5" />
) : (
<Icon className="w-3.5 h-3.5" />
)}
</div>
<span
className={`text-xs font-semibold leading-tight ${isActive ? palette.activeText : "text-slate-600"}`}
>
{sec.title}
</span>
</button>
);
})}
</nav>
</aside>
{/* ── Main content ── */}
<div className="flex-1 max-w-4xl mx-auto w-full">
{childArray.map((child, i) => (
<section
key={i}
ref={(el) => {
sectionsRef.current[i] = el;
}}
className="min-h-[70vh] mb-20 pt-16 lg:pt-4"
>
{child}
{/* next-section / finish button */}
{i < sections.length - 1 ? (
<button
onClick={() => scrollToSection(i + 1)}
className={`mt-10 group flex items-center gap-2 font-bold transition-colors ${
color === "blue"
? "text-blue-600 hover:text-blue-800"
: color === "violet"
? "text-violet-600 hover:text-violet-800"
: color === "amber"
? "text-amber-600 hover:text-amber-800"
: "text-emerald-600 hover:text-emerald-800"
}`}
>
Next: {sections[i + 1]?.title}
<ChevronRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</button>
) : onFinish ? (
<button
onClick={onFinish}
className="mt-10 px-8 py-3 rounded-xl bg-linear-to-r from-slate-800 to-slate-900 text-white font-bold shadow-lg hover:from-slate-700 hover:to-slate-800 transition-all hover:scale-[1.02]"
>
Complete Lesson
</button>
) : null}
</section>
))}
</div>
</div>
);
}
/* ─── Reusable concept-card wrapper ─── */
export function ConceptCard({
color = "blue",
children,
className = "",
}: {
color?: "blue" | "violet" | "amber" | "emerald";
children: React.ReactNode;
className?: string;
}) {
const glassClass =
color === "blue"
? "glass-blue"
: color === "violet"
? "glass-violet"
: color === "amber"
? "glass-amber"
: "glass-emerald";
return (
<div
className={`${glassClass} glass-card rounded-2xl p-6 mb-8 space-y-5 ${className}`}
>
{children}
</div>
);
}
/* ─── Formula display box ─── */
export function FormulaBox({
children,
className = "",
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={`glass-formula text-center py-4 px-6 font-mono text-lg font-bold text-slate-800 ${className}`}
>
{children}
</div>
);
}
/* ─── Worked-example card ─── */
export function ExampleCard({
title,
color = "blue",
children,
}: {
title: string;
color?: "blue" | "violet" | "amber" | "emerald";
children: React.ReactNode;
}) {
const border =
color === "blue"
? "border-blue-200 bg-gradient-to-br from-blue-50/60 to-indigo-50/40"
: color === "violet"
? "border-violet-200 bg-gradient-to-br from-violet-50/60 to-purple-50/40"
: color === "amber"
? "border-amber-200 bg-gradient-to-br from-amber-50/60 to-orange-50/40"
: "border-emerald-200 bg-gradient-to-br from-emerald-50/60 to-green-50/40";
const titleColor =
color === "blue"
? "text-blue-800"
: color === "violet"
? "text-violet-800"
: color === "amber"
? "text-amber-800"
: "text-emerald-800";
return (
<div className={`rounded-xl border ${border} p-5`}>
<p className={`font-bold ${titleColor} mb-3`}>{title}</p>
<div className="font-mono text-sm space-y-1 text-slate-700">
{children}
</div>
</div>
);
}
/* ─── Tip / Warning card ─── */
export function TipCard({
type = "tip",
children,
}: {
type?: "tip" | "warning" | "remember";
children: React.ReactNode;
}) {
const style =
type === "warning"
? "bg-red-50/70 border-red-200 text-red-900"
: type === "remember"
? "bg-amber-50/70 border-amber-200 text-amber-900"
: "bg-blue-50/70 border-blue-200 text-blue-900";
const label =
type === "warning"
? "Common Mistake"
: type === "remember"
? "Remember"
: "SAT Tip";
return (
<div className={`rounded-xl border p-4 text-sm ${style}`}>
<p className="font-bold mb-1">{label}</p>
{children}
</div>
);
}
/* ─── Practice question from dataset ─── */
type PracticeColor =
| "blue"
| "violet"
| "amber"
| "emerald"
| "teal"
| "fuchsia"
| "rose"
| "purple";
export function PracticeFromDataset({
question,
color = "blue",
}: {
key?: React.Key;
question: PracticeQuestion;
color?: PracticeColor;
}) {
const [selected, setSelected] = useState<string | null>(null);
const [submitted, setSubmitted] = useState(false);
const [sprInput, setSprInput] = useState("");
const isCorrect =
question.type === "mcq"
? selected === question.correctAnswer
: (() => {
const u = sprInput.trim().toLowerCase();
const answers = question.correctAnswer
.split(",")
.map((a) => a.trim().toLowerCase());
if (answers.includes(u)) return true;
const toNum = (s: string): number | null => {
if (s.includes("/")) {
const p = s.split("/");
return p.length === 2
? parseFloat(p[0]) / parseFloat(p[1])
: null;
}
const n = parseFloat(s);
return isNaN(n) ? null : n;
};
const uN = toNum(u);
return (
uN !== null &&
answers.some((a) => {
const aN = toNum(a);
return aN !== null && Math.abs(uN - aN) < 0.0015;
})
);
})();
const handleSubmit = () => {
if (question.type === "mcq" && !selected) return;
if (question.type === "spr" && !sprInput.trim()) return;
setSubmitted(true);
};
const accentMap: Record<PracticeColor, string> = {
blue: "border-blue-500 bg-blue-50",
violet: "border-violet-500 bg-violet-50",
amber: "border-amber-500 bg-amber-50",
emerald: "border-emerald-500 bg-emerald-50",
teal: "border-teal-500 bg-teal-50",
fuchsia: "border-fuchsia-500 bg-fuchsia-50",
rose: "border-rose-500 bg-rose-50",
purple: "border-purple-500 bg-purple-50",
};
const accent = accentMap[color] ?? accentMap.blue;
// Skip broken questions
if (isQuestionBroken(question)) return null;
return (
<div className="glass-card rounded-2xl p-5 mb-6 space-y-4">
{/* Passage (EBRW questions) */}
{question.passage && (
<div className="bg-linear-to-b from-slate-50 to-white rounded-xl border border-slate-200 p-4 text-sm text-slate-700 leading-relaxed max-h-60 overflow-y-auto">
<p className="text-[10px] font-extrabold text-slate-400 uppercase tracking-widest mb-2">
Passage
</p>
<div dangerouslySetInnerHTML={{ __html: question.passage }} />
</div>
)}
{question.hasFigure && question.figureUrl && (
<img
src={question.figureUrl}
alt="Figure"
className="max-w-full max-h-80 mx-auto rounded-xl border border-slate-200"
/>
)}
<div
className="text-sm text-slate-700 leading-relaxed"
dangerouslySetInnerHTML={{
__html: transformMathHtml(question.questionHtml),
}}
/>
{question.type === "mcq" ? (
<div className="space-y-2">
{question.choices.map((c) => {
const isThis = selected === c.label;
let ring = "border-slate-200 hover:border-slate-300";
if (submitted && isThis)
ring = isCorrect
? "border-emerald-500 bg-emerald-50"
: "border-red-400 bg-red-50";
else if (submitted && c.label === question.correctAnswer)
ring = "border-emerald-500 bg-emerald-50";
else if (isThis) ring = accent;
const isTable = c.text.includes("<br><br>");
return (
<button
key={c.label}
onClick={() => !submitted && setSelected(c.label)}
disabled={submitted}
className={`w-full text-left flex items-center gap-3 p-3 rounded-xl border transition-all text-sm ${ring}`}
>
<span className="w-7 h-7 rounded-full border border-current flex items-center justify-center text-xs font-bold shrink-0">
{c.label}
</span>
<div
className={`flex-1 ${isTable ? "columns-2 gap-8" : ""}`}
dangerouslySetInnerHTML={{
__html: transformMathHtml(c.text),
}}
/>
</button>
);
})}
</div>
) : (
<input
type="text"
value={sprInput}
onChange={(e) => !submitted && setSprInput(e.target.value)}
disabled={submitted}
placeholder="Type your answer…"
className="w-full px-4 py-2.5 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300"
/>
)}
{!submitted ? (
<button
onClick={handleSubmit}
className="px-5 py-2 rounded-xl bg-slate-800 text-white text-sm font-bold hover:bg-slate-700 transition-colors"
>
Check Answer
</button>
) : (
<div
className={`rounded-xl border p-4 text-sm ${isCorrect ? "bg-emerald-50/70 border-emerald-200" : "bg-slate-50 border-slate-200"}`}
>
<p
className={`font-bold mb-1 ${isCorrect ? "text-emerald-700" : "text-red-600"}`}
>
{isCorrect
? "Correct!"
: `Incorrect — the answer is ${question.correctAnswer}`}
</p>
<div
className="text-slate-600 leading-relaxed"
dangerouslySetInnerHTML={{
__html: transformMathHtml(question.explanation),
}}
/>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,111 @@
import React, { useState } from 'react';
const LinearQuadraticSystemWidget: React.FC = () => {
// Parabola: y = x^2
// Line: y = mx + b
const [m, setM] = useState(1);
const [b, setB] = useState(-2);
// System: x^2 = mx + b => x^2 - mx - b = 0
// Discriminant: D = (-m)^2 - 4(1)(-b) = m^2 + 4b
const disc = m*m + 4*b;
const numSolutions = disc > 0 ? 2 : disc === 0 ? 1 : 0;
// Visualization
const width = 300;
const height = 300;
const range = 5;
const scale = width / (range * 2);
const center = width / 2;
const toPx = (v: number, isY = false) => isY ? center - v * scale : center + v * scale;
const generateParabola = () => {
let d = "";
for (let x = -range; x <= range; x += 0.1) {
const y = x * x;
if (y > range) continue;
const px = toPx(x);
const py = toPx(y, true);
d += d ? ` L ${px} ${py}` : `M ${px} ${py}`;
}
return d;
};
const generateLine = () => {
const x1 = -range;
const y1 = m * x1 + b;
const x2 = range;
const y2 = m * x2 + b;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase mb-2">System</div>
<div className="font-mono text-lg font-bold text-slate-800">
y = x² <br/>
y = {m}x {b >= 0 ? '+' : ''}{b}
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-xs font-bold text-indigo-600 uppercase">Slope (m) = {m}</label>
<input type="range" min="-4" max="4" step="0.5" value={m} onChange={e => setM(parseFloat(e.target.value))} className="w-full h-2 bg-indigo-100 rounded-lg accent-indigo-600"/>
</div>
<div>
<label className="text-xs font-bold text-rose-600 uppercase">Intercept (b) = {b}</label>
<input type="range" min="-5" max="5" step="0.5" value={b} onChange={e => setB(parseFloat(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg accent-rose-600"/>
</div>
</div>
<div className={`p-4 rounded-xl border-l-4 ${numSolutions > 0 ? 'bg-emerald-50 border-emerald-500' : 'bg-rose-50 border-rose-500'}`}>
<div className="text-xs font-bold uppercase text-slate-500">Discriminant (m² + 4b)</div>
<div className="text-xl font-bold text-slate-800 my-1">{disc.toFixed(2)}</div>
<div className="text-sm font-bold">
{numSolutions === 0 && <span className="text-rose-600">No Solutions</span>}
{numSolutions === 1 && <span className="text-amber-600">1 Solution (Tangent)</span>}
{numSolutions === 2 && <span className="text-emerald-600">2 Solutions</span>}
</div>
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300">
{/* Axes */}
<line x1="0" y1={center} x2={width} y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={height} stroke="#cbd5e1" strokeWidth="2" />
{/* Parabola */}
<path d={generateParabola()} fill="none" stroke="#64748b" strokeWidth="3" />
{/* Line */}
<path d={generateLine()} fill="none" stroke="#4f46e5" strokeWidth="3" />
{/* Intersections */}
{numSolutions > 0 && (
<>
{disc === 0 ? (
<circle cx={toPx(m/2)} cy={toPx((m/2)**2, true)} r="5" fill="#10b981" stroke="white" strokeWidth="2" />
) : (
<>
<circle cx={toPx((m + Math.sqrt(disc))/2)} cy={toPx(((m + Math.sqrt(disc))/2)**2, true)} r="5" fill="#10b981" stroke="white" strokeWidth="2" />
<circle cx={toPx((m - Math.sqrt(disc))/2)} cy={toPx(((m - Math.sqrt(disc))/2)**2, true)} r="5" fill="#10b981" stroke="white" strokeWidth="2" />
</>
)}
</>
)}
</svg>
</div>
</div>
</div>
</div>
);
};
export default LinearQuadraticSystemWidget;

View File

@ -0,0 +1,140 @@
import React, { useState } from 'react';
const LinearSolutionsWidget: React.FC = () => {
// Model: ax + b = cx + d
const [a, setA] = useState(2);
const [b, setB] = useState(4);
const [c, setC] = useState(2);
const [d, setD] = useState(8);
const isParallel = a === c;
const isCoincident = isParallel && b === d;
// Calculate solution if not parallel
// ax + b = cx + d => (a-c)x = d-b => x = (d-b)/(a-c)
const intersectionX = isParallel ? 0 : (d - b) / (a - c);
const intersectionY = a * intersectionX + b;
// Visualization range
const range = 10;
const scale = 20; // 1 unit = 20px
const center = 150; // px
const toPx = (val: number, isY = false) => {
if (isY) return center - val * scale;
return center + val * scale;
};
const getLinePath = (slope: number, intercept: number) => {
const x1 = -range;
const y1 = slope * x1 + intercept;
const x2 = range;
const y2 = slope * x2 + intercept;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col gap-6">
<div className="flex justify-between items-center bg-slate-50 p-4 rounded-lg border border-slate-200">
<div className="font-mono text-xl text-blue-700 font-bold">
<span className="text-indigo-600">{a}x + {b}</span> = <span className="text-emerald-600">{c}x + {d}</span>
</div>
<div className={`px-3 py-1 rounded text-sm font-bold uppercase ${
isCoincident ? 'bg-green-100 text-green-800' :
isParallel ? 'bg-rose-100 text-rose-800' :
'bg-blue-100 text-blue-800'
}`}>
{isCoincident ? "Infinite Solutions" : isParallel ? "No Solution" : "One Solution"}
</div>
</div>
<div className="flex flex-col md:flex-row gap-8">
{/* Controls */}
<div className="w-full md:w-1/3 space-y-4">
<div className="space-y-2 p-3 bg-indigo-50 rounded-lg border border-indigo-100">
<p className="text-xs font-bold text-indigo-800 uppercase">Left Side (Line 1)</p>
<div>
<label className="text-xs text-slate-500">Slope (a): {a}</label>
<input type="range" min="-5" max="5" step="1" value={a} onChange={e => setA(Number(e.target.value))} className="w-full h-1 bg-indigo-200 rounded accent-indigo-600" />
</div>
<div>
<label className="text-xs text-slate-500">Intercept (b): {b}</label>
<input type="range" min="-10" max="10" step="1" value={b} onChange={e => setB(Number(e.target.value))} className="w-full h-1 bg-indigo-200 rounded accent-indigo-600" />
</div>
</div>
<div className="space-y-2 p-3 bg-emerald-50 rounded-lg border border-emerald-100">
<p className="text-xs font-bold text-emerald-800 uppercase">Right Side (Line 2)</p>
<div>
<label className="text-xs text-slate-500">Slope (c): {c}</label>
<input type="range" min="-5" max="5" step="1" value={c} onChange={e => setC(Number(e.target.value))} className="w-full h-1 bg-emerald-200 rounded accent-emerald-600" />
</div>
<div>
<label className="text-xs text-slate-500">Intercept (d): {d}</label>
<input type="range" min="-10" max="10" step="1" value={d} onChange={e => setD(Number(e.target.value))} className="w-full h-1 bg-emerald-200 rounded accent-emerald-600" />
</div>
</div>
</div>
{/* Graph */}
<div className="w-full md:flex-1 border border-slate-200 rounded-lg overflow-hidden relative h-[300px] bg-white">
<svg width="100%" height="100%" viewBox="0 0 300 300" className="absolute top-0 left-0">
<defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{/* Axes */}
<line x1="0" y1="150" x2="300" y2="150" stroke="#cbd5e1" strokeWidth="2" />
<line x1="150" y1="0" x2="150" y2="300" stroke="#cbd5e1" strokeWidth="2" />
{/* Lines */}
<path d={getLinePath(a, b)} stroke="#4f46e5" strokeWidth="3" fill="none" />
<path d={getLinePath(c, d)} stroke={isCoincident ? "#4f46e5" : "#10b981"} strokeWidth="3" strokeDasharray={isCoincident ? "5,5" : ""} fill="none" />
{/* Intersection Point */}
{!isParallel && (
<circle cx={toPx(intersectionX)} cy={toPx(intersectionY, true)} r="5" fill="#f43f5e" stroke="white" strokeWidth="2" />
)}
</svg>
{/* Labels */}
{!isParallel && (
<div className="absolute bottom-2 right-2 bg-white/90 p-2 rounded text-xs border border-slate-200 shadow-sm">
Intersection: ({intersectionX.toFixed(2)}, {intersectionY.toFixed(2)})
</div>
)}
</div>
</div>
{/* Logic Explanation */}
<div className="bg-slate-50 p-4 rounded-lg text-sm text-slate-700">
<p className="font-bold mb-1">Algebraic Check:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Subtract {c}x from both sides: <span className="font-mono font-bold">{(a-c).toFixed(0)}x + {b} = {d}</span></li>
{a === c ? (
<>
<li><span className="text-rose-600 font-bold">0x</span> (Variables cancel!)</li>
<li>Remaining statement: <span className="font-mono font-bold">{b} = {d}</span></li>
<li className={`font-bold ${b === d ? 'text-green-600' : 'text-rose-600'}`}>
{b === d ? "TRUE (Identity) → Infinite Solutions" : "FALSE (Contradiction) → No Solution"}
</li>
</>
) : (
<>
<li>Variables do NOT cancel.</li>
<li><span className="font-mono">{(a-c).toFixed(0)}x = {d - b}</span></li>
<li>One unique solution exists.</li>
</>
)}
</ul>
</div>
</div>
</div>
);
};
export default LinearSolutionsWidget;

View File

@ -0,0 +1,120 @@
import React, { useState } from 'react';
const LinearTransformationWidget: React.FC = () => {
const [h, setH] = useState(0); // Horizontal shift (x - h)
const [k, setK] = useState(0); // Vertical shift + k
const [reflectX, setReflectX] = useState(false); // -f(x)
const [stretch, setStretch] = useState(1); // a * f(x)
// Base function f(x) = 0.5x
// Transformed g(x) = a * f(x - h) + k
// g(x) = a * (0.5 * (x - h)) + k
// Actually, let's use f(x) = x for simplicity, or 0.5x to show slope changes easier?
// PDF examples use general f(x). Let's use f(x) = x as base.
// g(x) = stretch * (x - h) + k. If reflectX is true, stretch becomes -stretch.
const effectiveStretch = reflectX ? -stretch : stretch;
const range = 10;
const scale = 20; // 20px per unit
const size = 300;
const center = size / 2;
const toPx = (v: number, isY = false) => isY ? center - v * scale : center + v * scale;
// Base: y = 0.5x (to make it distinct from diagonals)
const getBasePath = () => {
const m = 0.5;
const x1 = -range, x2 = range;
const y1 = m * x1;
const y2 = m * x2;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
const getTransformedPath = () => {
// f(x) = 0.5x
// g(x) = effectiveStretch * (0.5 * (x - h)) + k
const x1 = -range, x2 = range;
const y1 = effectiveStretch * (0.5 * (x1 - h)) + k;
const y2 = effectiveStretch * (0.5 * (x2 - h)) + k;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div className="p-4 bg-slate-50 border border-slate-200 rounded-xl font-mono text-sm">
<p className="text-slate-400 mb-2">Base: <span className="text-slate-600 font-bold">f(x) = 0.5x</span></p>
<p className="text-indigo-900 font-bold text-lg">
g(x) = {reflectX ? '-' : ''}{stretch !== 1 ? stretch : ''}f(x {h > 0 ? '-' : '+'} {Math.abs(h)}) {k >= 0 ? '+' : '-'} {Math.abs(k)}
</p>
</div>
<div className="space-y-4">
<div>
<label className="text-xs font-bold text-indigo-600 uppercase flex justify-between">
Horizontal Shift (h) <span>{h}</span>
</label>
<input
type="range" min="-5" max="5" step="1"
value={h} onChange={e => setH(parseInt(e.target.value))}
className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600 mt-1"
/>
<div className="flex justify-between text-[10px] text-slate-400">
<span>Left (x+h)</span>
<span>Right (x-h)</span>
</div>
</div>
<div>
<label className="text-xs font-bold text-emerald-600 uppercase flex justify-between">
Vertical Shift (k) <span>{k}</span>
</label>
<input
type="range" min="-5" max="5" step="1"
value={k} onChange={e => setK(parseInt(e.target.value))}
className="w-full h-2 bg-emerald-100 rounded-lg appearance-none cursor-pointer accent-emerald-600 mt-1"
/>
</div>
<div className="flex items-center gap-4 pt-2">
<label className="flex items-center gap-2 text-sm font-bold text-slate-700 cursor-pointer">
<input type="checkbox" checked={reflectX} onChange={e => setReflectX(e.target.checked)} className="accent-rose-600 w-4 h-4"/>
Reflect (-f(x))
</label>
</div>
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] border border-slate-200 rounded-xl overflow-hidden bg-white">
<svg width="300" height="300" viewBox="0 0 300 300">
<defs>
<pattern id="grid-t" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid-t)" />
{/* Axes */}
<line x1="0" y1={center} x2={size} y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={size} stroke="#cbd5e1" strokeWidth="2" />
{/* Base Function (Ghost) */}
<path d={getBasePath()} stroke="#94a3b8" strokeWidth="2" strokeDasharray="4,4" />
<text x="260" y={toPx(0.5*8, true) - 5} className="text-xs fill-slate-400 font-bold">f(x)</text>
{/* Transformed Function */}
<path d={getTransformedPath()} stroke="#4f46e5" strokeWidth="3" />
<text x="20" y="20" className="text-xs fill-indigo-600 font-bold">g(x)</text>
</svg>
</div>
</div>
</div>
</div>
);
};
export default LinearTransformationWidget;

View File

@ -0,0 +1,201 @@
import React, { useState } from 'react';
import { Check, RotateCcw, ArrowRight } from 'lucide-react';
const LiteralEquationWidget: React.FC = () => {
const [problemIdx, setProblemIdx] = useState(0);
const [step, setStep] = useState(0);
const problems = [
{
id: 'perimeter',
title: 'Perimeter Formula',
goal: 'Isolate W',
steps: [
{
startEq: <>P = 2L + 2<span className="text-indigo-600">W</span></>,
options: [
{ text: 'Subtract 2L', correct: true },
{ text: 'Divide by 2', correct: false }
],
feedback: 'Moved 2L to the other side.',
nextEq: <>P - 2L = 2<span className="text-indigo-600">W</span></>
},
{
startEq: <>P - 2L = 2<span className="text-indigo-600">W</span></>,
options: [
{ text: 'Divide by 2', correct: true },
{ text: 'Subtract 2', correct: false }
],
feedback: 'Solved!',
nextEq: <><span className="text-indigo-600">W</span> = <span className="inline-block align-middle text-center border-t border-slate-800 pt-1 leading-none"><span className="block pb-1">P - 2L</span>2</span></>
}
]
},
{
id: 'linear',
title: 'Slope-Intercept',
goal: 'Isolate x',
steps: [
{
startEq: <>y = m<span className="text-indigo-600">x</span> + b</>,
options: [
{ text: 'Subtract b', correct: true },
{ text: 'Divide by m', correct: false }
],
feedback: 'Isolated the x term.',
nextEq: <>y - b = m<span className="text-indigo-600">x</span></>
},
{
startEq: <>y - b = m<span className="text-indigo-600">x</span></>,
options: [
{ text: 'Divide by m', correct: true },
{ text: 'Subtract m', correct: false }
],
feedback: 'Solved!',
nextEq: <><span className="text-indigo-600">x</span> = <span className="inline-block align-middle text-center border-t border-slate-800 pt-1 leading-none"><span className="block pb-1">y - b</span>m</span></>
}
]
},
{
id: 'standard',
title: 'Standard Form',
goal: 'Isolate y',
steps: [
{
startEq: <>Ax + B<span className="text-indigo-600">y</span> = C</>,
options: [
{ text: 'Subtract Ax', correct: true },
{ text: 'Divide by B', correct: false }
],
feedback: 'Moved the x term away.',
nextEq: <>B<span className="text-indigo-600">y</span> = C - Ax</>
},
{
startEq: <>B<span className="text-indigo-600">y</span> = C - Ax</>,
options: [
{ text: 'Divide by B', correct: true },
{ text: 'Subtract B', correct: false }
],
feedback: 'Solved!',
nextEq: <><span className="text-indigo-600">y</span> = <span className="inline-block align-middle text-center border-t border-slate-800 pt-1 leading-none"><span className="block pb-1">C - Ax</span>B</span></>
}
]
},
{
id: 'physics',
title: 'Velocity Formula',
goal: 'Isolate a',
steps: [
{
startEq: <>v = u + <span className="text-indigo-600">a</span>t</>,
options: [
{ text: 'Subtract u', correct: true },
{ text: 'Divide by t', correct: false }
],
feedback: 'Isolated the term with a.',
nextEq: <>v - u = <span className="text-indigo-600">a</span>t</>
},
{
startEq: <>v - u = <span className="text-indigo-600">a</span>t</>,
options: [
{ text: 'Divide by t', correct: true },
{ text: 'Subtract t', correct: false }
],
feedback: 'Solved!',
nextEq: <><span className="text-indigo-600">a</span> = <span className="inline-block align-middle text-center border-t border-slate-800 pt-1 leading-none"><span className="block pb-1">v - u</span>t</span></>
}
]
}
];
const currentProb = problems[problemIdx];
const currentStepData = currentProb.steps[step];
const handleNextProblem = () => {
let next = Math.floor(Math.random() * problems.length);
while (next === problemIdx) {
next = Math.floor(Math.random() * problems.length);
}
setProblemIdx(next);
setStep(0);
};
const reset = () => {
setStep(0);
};
const handleOption = (isCorrect: boolean) => {
if (isCorrect) {
setStep(step + 1);
}
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex justify-between items-center mb-6">
<h4 className="font-bold text-slate-700 flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-indigo-100 text-indigo-700 flex items-center justify-center text-xs font-bold">Ex</span>
{currentProb.goal}
</h4>
<button onClick={reset} className="text-slate-400 hover:text-indigo-600 transition-colors" title="Reset this problem">
<RotateCcw className="w-4 h-4" />
</button>
</div>
<div className="text-center mb-8 h-32 flex flex-col items-center justify-center transition-all">
{step < 2 ? (
<div className="animate-fade-in">
<div className="text-3xl font-mono font-bold text-slate-800 mb-2">
{currentStepData.startEq}
</div>
{step === 1 && <p className="text-sm text-green-600 font-bold mb-2 animate-pulse">{problems[problemIdx].steps[0].feedback}</p>}
<p className="text-sm text-slate-500">
{step === 0 ? "What is the first step?" : "What is the next step?"}
</p>
</div>
) : (
<div className="animate-fade-in bg-green-50 p-6 rounded-xl border border-green-200 w-full">
<div className="text-3xl font-mono font-bold text-green-800 mb-2">
{currentProb.steps[1].nextEq}
</div>
<div className="flex items-center justify-center gap-2 text-green-700 font-bold">
<Check className="w-5 h-5" /> {currentProb.steps[1].feedback}
</div>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
{step < 2 ? (
<>
{currentStepData.options.map((opt, i) => (
<button
key={i}
onClick={() => handleOption(opt.correct)}
className={`p-4 rounded-xl border-2 transition-all text-left group ${
opt.correct
? 'bg-slate-50 border-slate-200 hover:border-indigo-400 hover:bg-indigo-50'
: 'bg-slate-50 border-slate-200 hover:border-slate-300 opacity-80'
}`}
>
<span className={`text-xs font-bold uppercase mb-1 block ${opt.correct ? 'text-indigo-400 group-hover:text-indigo-600' : 'text-slate-400'}`}>
Option {i+1}
</span>
<span className="font-bold text-slate-700 group-hover:text-indigo-900">{opt.text}</span>
</button>
))}
</>
) : (
<button
onClick={handleNextProblem}
className="col-span-2 p-4 bg-indigo-600 text-white font-bold rounded-xl hover:bg-indigo-700 transition-colors flex items-center justify-center gap-2 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
Try Another Problem <ArrowRight className="w-4 h-4" />
</button>
)}
</div>
</div>
);
};
export default LiteralEquationWidget;

View File

@ -0,0 +1,103 @@
import React, { useState } from 'react';
import { ArrowRight } from 'lucide-react';
const MultiStepPercentWidget: React.FC = () => {
const [start, setStart] = useState(100);
const [change1, setChange1] = useState(40); // +40%
const [change2, setChange2] = useState(-25); // -25%
const step1Val = start * (1 + change1/100);
const finalVal = step1Val * (1 + change2/100);
const overallChange = ((finalVal - start) / start) * 100;
const naiveChange = change1 + change2;
// Scale for visualization
const maxVal = Math.max(start, step1Val, finalVal, 150);
const getWidth = (val: number) => (val / maxVal) * 100;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8 mb-8">
<div className="w-full md:w-1/3 space-y-6">
<div>
<label className="text-xs font-bold text-slate-400 uppercase">Change 1 (Markup)</label>
<div className="flex items-center gap-3">
<input
type="range" min="-50" max="100" step="5"
value={change1} onChange={e => setChange1(parseInt(e.target.value))}
className="flex-1 accent-indigo-600"
/>
<span className="font-bold text-indigo-600 w-12 text-right">{change1 > 0 ? '+' : ''}{change1}%</span>
</div>
</div>
<div>
<label className="text-xs font-bold text-slate-400 uppercase">Change 2 (Discount)</label>
<div className="flex items-center gap-3">
<input
type="range" min="-50" max="50" step="5"
value={change2} onChange={e => setChange2(parseInt(e.target.value))}
className="flex-1 accent-rose-600"
/>
<span className="font-bold text-rose-600 w-12 text-right">{change2 > 0 ? '+' : ''}{change2}%</span>
</div>
</div>
</div>
<div className="flex-1 space-y-4">
{/* Step 0 */}
<div className="relative">
<div className="flex justify-between text-xs font-bold text-slate-400 mb-1">
<span>Start</span>
<span>${start}</span>
</div>
<div className="h-8 bg-slate-200 rounded-md" style={{ width: `${getWidth(start)}%` }}></div>
</div>
{/* Step 1 */}
<div className="relative">
<div className="flex justify-between text-xs font-bold text-indigo-500 mb-1">
<span>After {change1 > 0 ? '+' : ''}{change1}%</span>
<span>${step1Val.toFixed(2)}</span>
</div>
<div className="h-8 bg-indigo-100 rounded-md transition-all duration-500" style={{ width: `${getWidth(step1Val)}%` }}>
<div className="h-full bg-indigo-500 rounded-l-md" style={{ width: `${(start/step1Val)*100}%` }}></div>
</div>
</div>
{/* Step 2 */}
<div className="relative">
<div className="flex justify-between text-xs font-bold text-rose-500 mb-1">
<span>After {change2 > 0 ? '+' : ''}{change2}%</span>
<span>${finalVal.toFixed(2)}</span>
</div>
<div className="h-8 bg-rose-100 rounded-md transition-all duration-500" style={{ width: `${getWidth(finalVal)}%` }}>
<div className="h-full bg-rose-500 rounded-l-md" style={{ width: `${(step1Val/finalVal)*100}%` }}></div>
</div>
</div>
</div>
</div>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-xs font-bold text-slate-400 uppercase mb-1">The Trap (Additive)</div>
<div className="text-lg font-bold text-slate-400 line-through decoration-red-500 decoration-2">
{naiveChange > 0 ? '+' : ''}{naiveChange}%
</div>
<div className="text-[10px] text-slate-400">({change1} + {change2})</div>
</div>
<div>
<div className="text-xs font-bold text-emerald-600 uppercase mb-1">Actual Change</div>
<div className="text-2xl font-bold text-emerald-600">
{overallChange > 0 ? '+' : ''}{overallChange.toFixed(2)}%
</div>
<div className="text-[10px] text-emerald-600 font-mono">
1.{change1} × {1 + change2/100} = {(1 + change1/100) * (1 + change2/100)}
</div>
</div>
</div>
</div>
);
};
export default MultiStepPercentWidget;

View File

@ -0,0 +1,124 @@
import React, { useState } from 'react';
const MultiplicityWidget: React.FC = () => {
const [m1, setM1] = useState(1); // Multiplicity for (x+2)
const [m2, setM2] = useState(2); // Multiplicity for (x-1)
// f(x) = 0.1 * (x+2)^m1 * (x-1)^m2
// Scale factor to keep y-values reasonable for visualization
const width = 300;
const height = 200;
const rangeX = 4;
const scaleX = width / (rangeX * 2);
const centerX = width / 2;
const centerY = height / 2;
const scaleY = 15; // Vertical compression
const toPx = (x: number, y: number) => ({
x: centerX + x * scaleX,
y: centerY - y * scaleY
});
const generatePath = () => {
let d = "";
// f(x) scaling factor depends on degree to keep graph in view
const k = 0.5;
for (let x = -rangeX; x <= rangeX; x += 0.05) {
const y = k * Math.pow(x + 2, m1) * Math.pow(x - 1, m2);
if (Math.abs(y) > 100) continue; // Clip
const pos = toPx(x, y);
d += d ? ` L ${pos.x} ${pos.y}` : `M ${pos.x} ${pos.y}`;
}
return d;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="mb-6 text-center">
<div className="inline-block bg-slate-50 p-4 rounded-xl border border-slate-200">
<span className="font-mono text-xl font-bold text-slate-800">
P(x) = (x + 2)<sup className="text-rose-600">{m1}</sup> (x - 1)<sup className="text-indigo-600">{m2}</sup>
</span>
</div>
</div>
<div className="flex flex-col md:flex-row gap-8 items-center">
<div className="w-full md:w-1/3 space-y-6">
<div>
<label className="text-xs font-bold text-rose-600 uppercase mb-2 block">Root x = -2</label>
<div className="flex gap-2">
<button
onClick={() => setM1(1)}
className={`flex-1 py-2 rounded-lg font-bold text-sm border transition-all ${m1 === 1 ? 'bg-rose-600 text-white border-rose-600' : 'bg-white text-slate-500 border-slate-200'}`}
>
Odd (1)
</button>
<button
onClick={() => setM1(2)}
className={`flex-1 py-2 rounded-lg font-bold text-sm border transition-all ${m1 === 2 ? 'bg-rose-600 text-white border-rose-600' : 'bg-white text-slate-500 border-slate-200'}`}
>
Even (2)
</button>
</div>
<p className="text-xs text-rose-600 mt-2 font-bold text-center">
{m1 % 2 !== 0 ? "CROSSES Axis" : "TOUCHES Axis"}
</p>
</div>
<div>
<label className="text-xs font-bold text-indigo-600 uppercase mb-2 block">Root x = 1</label>
<div className="flex gap-2">
<button
onClick={() => setM2(1)}
className={`flex-1 py-2 rounded-lg font-bold text-sm border transition-all ${m2 === 1 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-500 border-slate-200'}`}
>
Odd (1)
</button>
<button
onClick={() => setM2(2)}
className={`flex-1 py-2 rounded-lg font-bold text-sm border transition-all ${m2 === 2 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-500 border-slate-200'}`}
>
Even (2)
</button>
</div>
<p className="text-xs text-indigo-600 mt-2 font-bold text-center">
{m2 % 2 !== 0 ? "CROSSES Axis" : "TOUCHES Axis"}
</p>
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[200px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 200">
{/* Grid Lines */}
<defs>
<pattern id="grid-mult" width="37.5" height="20" patternUnits="userSpaceOnUse">
<path d="M 37.5 0 L 0 0 0 20" fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid-mult)" />
{/* Axes */}
<line x1="0" y1={centerY} x2={width} y2={centerY} stroke="#94a3b8" strokeWidth="2" />
<line x1={centerX} y1="0" x2={centerX} y2={height} stroke="#94a3b8" strokeWidth="2" />
{/* Graph */}
<path d={generatePath()} fill="none" stroke="#8b5cf6" strokeWidth="3" />
{/* Roots */}
<circle cx={toPx(-2, 0).x} cy={toPx(-2, 0).y} r="5" fill="#e11d48" stroke="white" strokeWidth="2" />
<text x={toPx(-2, 0).x} y={toPx(-2, 0).y + 20} textAnchor="middle" className="text-xs font-bold fill-rose-600">-2</text>
<circle cx={toPx(1, 0).x} cy={toPx(1, 0).y} r="5" fill="#4f46e5" stroke="white" strokeWidth="2" />
<text x={toPx(1, 0).x} y={toPx(1, 0).y + 20} textAnchor="middle" className="text-xs font-bold fill-indigo-600">1</text>
</svg>
</div>
</div>
</div>
</div>
);
};
export default MultiplicityWidget;

View File

@ -0,0 +1,96 @@
import React, { useState } from 'react';
const ParabolaWidget: React.FC = () => {
const [a, setA] = useState(1);
const [h, setH] = useState(2);
const [k, setK] = useState(1);
// Viewport
const range = 10;
const size = 300;
const scale = 300 / (range * 2);
const center = size / 2;
const toPx = (v: number, isY = false) => isY ? center - v * scale : center + v * scale;
// Generate Path
const generatePath = () => {
const step = 0.5;
let d = "";
for (let x = -range; x <= range; x += step) {
const y = a * Math.pow(x - h, 2) + k;
// Clip if way out of bounds to avoid SVG issues
if (Math.abs(y) > range * 2) continue;
const px = toPx(x);
const py = toPx(y, true);
d += x === -range ? `M ${px} ${py}` : ` L ${px} ${py}`;
}
return d;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div className="bg-slate-50 p-4 rounded-xl text-center border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase mb-1">Vertex Form</div>
<div className="text-xl font-mono font-bold text-slate-800">
y = <span className="text-indigo-600">{a}</span>(x - <span className="text-emerald-600">{h}</span>)² + <span className="text-rose-600">{k}</span>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-xs font-bold text-indigo-600 uppercase flex justify-between">
Stretch (a) <span>{a}</span>
</label>
<input type="range" min="-3" max="3" step="0.5" value={a} onChange={e => setA(parseFloat(e.target.value))} className="w-full h-2 bg-indigo-100 rounded-lg accent-indigo-600"/>
</div>
<div>
<label className="text-xs font-bold text-emerald-600 uppercase flex justify-between">
H-Shift (h) <span>{h}</span>
</label>
<input type="range" min="-5" max="5" step="0.5" value={h} onChange={e => setH(parseFloat(e.target.value))} className="w-full h-2 bg-emerald-100 rounded-lg accent-emerald-600"/>
</div>
<div>
<label className="text-xs font-bold text-rose-600 uppercase flex justify-between">
V-Shift (k) <span>{k}</span>
</label>
<input type="range" min="-5" max="5" step="0.5" value={k} onChange={e => setK(parseFloat(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg accent-rose-600"/>
</div>
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300">
<defs>
<pattern id="para-grid" width={scale} height={scale} patternUnits="userSpaceOnUse">
<path d={`M ${scale} 0 L 0 0 0 ${scale}`} fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#para-grid)" />
{/* Axes */}
<line x1="0" y1={center} x2={size} y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={size} stroke="#cbd5e1" strokeWidth="2" />
{/* Parabola */}
<path d={generatePath()} fill="none" stroke="#8b5cf6" strokeWidth="3" />
{/* Vertex */}
<circle cx={toPx(h)} cy={toPx(k, true)} r="5" fill="#e11d48" stroke="white" strokeWidth="2" />
<text x={toPx(h)} y={toPx(k, true) - 10} textAnchor="middle" className="text-xs font-bold fill-rose-600 bg-white">V({h}, {k})</text>
{/* Axis of Symmetry */}
<line x1={toPx(h)} y1="0" x2={toPx(h)} y2={size} stroke="#10b981" strokeWidth="1" strokeDasharray="4,4" opacity="0.5" />
</svg>
</div>
</div>
</div>
</div>
);
};
export default ParabolaWidget;

View File

@ -0,0 +1,113 @@
import React, { useState } from 'react';
const ParallelPerpendicularWidget: React.FC = () => {
const [slope, setSlope] = useState(2);
const [showParallel, setShowParallel] = useState(true);
const [showPerpendicular, setShowPerpendicular] = useState(true);
const range = 10;
const scale = 20; // 20px per unit
const size = 300;
const center = size / 2;
const toPx = (v: number, isY = false) => isY ? center - v * scale : center + v * scale;
const getLinePath = (m: number, b: number) => {
// Find two points on edges of view box (-range, +range)
// y = mx + b
// Need to clip lines to viewBox to be nice
const x1 = -range;
const y1 = m * x1 + b;
const x2 = range;
const y2 = m * x2 + b;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
const perpSlope = slope === 0 ? 1000 : -1 / slope; // Hack for vertical
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div className="p-4 bg-slate-50 border border-slate-200 rounded-xl">
<label className="text-xs font-bold text-slate-500 uppercase mb-2 block">Reference Slope (m)</label>
<div className="flex items-center gap-4">
<input
type="range" min="-4" max="4" step="0.5"
value={slope} onChange={e => setSlope(parseFloat(e.target.value))}
className="flex-1 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
<span className="font-mono font-bold text-indigo-700 w-12 text-right">{slope}</span>
</div>
</div>
<div className="space-y-3">
<button
onClick={() => setShowParallel(!showParallel)}
className={`w-full flex items-center justify-between p-3 rounded-lg border-2 transition-all ${
showParallel ? 'border-sky-500 bg-sky-50 text-sky-900' : 'border-slate-200 text-slate-400'
}`}
>
<span className="font-bold">Parallel</span>
<span className="font-mono text-sm">{showParallel ? `m = ${slope}` : 'Hidden'}</span>
</button>
<button
onClick={() => setShowPerpendicular(!showPerpendicular)}
className={`w-full flex items-center justify-between p-3 rounded-lg border-2 transition-all ${
showPerpendicular ? 'border-rose-500 bg-rose-50 text-rose-900' : 'border-slate-200 text-slate-400'
}`}
>
<span className="font-bold">Perpendicular</span>
<span className="font-mono text-sm">{showPerpendicular ? `m = ${slope === 0 ? 'Undef' : (-1/slope).toFixed(2)}` : 'Hidden'}</span>
</button>
</div>
<div className="text-xs text-slate-500 bg-slate-50 p-3 rounded">
<strong>Key Rule:</strong> Perpendicular slopes are negative reciprocals ($m$ vs $-1/m$). Their product is always -1.
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] border border-slate-200 rounded-xl overflow-hidden bg-white">
<svg width="300" height="300" viewBox="0 0 300 300">
<defs>
<pattern id="grid-p" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid-p)" />
{/* Axes */}
<line x1="0" y1={center} x2={size} y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={size} stroke="#cbd5e1" strokeWidth="2" />
{/* Reference Line (Indigo) */}
<path d={getLinePath(slope, 0)} stroke="#4f46e5" strokeWidth="3" />
{/* Parallel Line (Sky) - Shifted up by 3 units */}
{showParallel && (
<path d={getLinePath(slope, 3)} stroke="#0ea5e9" strokeWidth="3" strokeDasharray="5,5" />
)}
{/* Perpendicular Line (Rose) - Through Origin */}
{showPerpendicular && (
<>
<path d={getLinePath(perpSlope, 0)} stroke="#e11d48" strokeWidth="3" />
{/* Right Angle Marker approx */}
<rect
x={center} y={center} width="15" height="15"
fill="rgba(225, 29, 72, 0.2)"
transform={`rotate(${-Math.atan(slope) * 180 / Math.PI} ${center} ${center})`}
/>
</>
)}
</svg>
</div>
</div>
</div>
</div>
);
};
export default ParallelPerpendicularWidget;

View File

@ -0,0 +1,69 @@
import React, { useState } from 'react';
const PercentChangeWidget: React.FC = () => {
const [original, setOriginal] = useState(100);
const [percent, setPercent] = useState(25); // -100 to 100
const multiplier = 1 + (percent / 100);
const newValue = original * multiplier;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="mb-8 space-y-4">
<div className="flex items-center justify-between">
<label className="text-sm font-bold text-slate-500 uppercase">Original Value</label>
<input
type="number" value={original} onChange={e => setOriginal(Number(e.target.value))}
className="w-24 p-1 border border-slate-300 rounded font-mono font-bold text-slate-700 text-right"
/>
</div>
<div>
<div className="flex justify-between mb-2">
<label className="text-sm font-bold text-slate-500 uppercase">Percent Change</label>
<span className={`font-bold font-mono ${percent >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{percent > 0 ? '+' : ''}{percent}%
</span>
</div>
<input
type="range" min="-50" max="100" step="5" value={percent}
onChange={e => setPercent(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
</div>
<div className="flex items-end justify-center gap-8 h-48 border-b border-slate-200 pb-0 mb-6">
{/* Original Bar */}
<div className="flex flex-col items-center gap-2 w-24">
<span className="font-bold text-slate-500">{original}</span>
<div className="w-full bg-slate-400 rounded-t-lg transition-all duration-500" style={{ height: '120px' }}></div>
<span className="text-xs font-bold text-slate-400 uppercase mt-2">Original</span>
</div>
{/* New Bar */}
<div className="flex flex-col items-center gap-2 w-24">
<span className={`font-bold ${percent >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{newValue.toFixed(1)}
</span>
<div
className={`w-full rounded-t-lg transition-all duration-500 ${percent >= 0 ? 'bg-emerald-500' : 'bg-rose-500'}`}
style={{ height: `${120 * multiplier}px` }}
></div>
<span className="text-xs font-bold text-slate-400 uppercase mt-2">New</span>
</div>
</div>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<h4 className="text-xs font-bold text-slate-400 uppercase mb-2">Formula</h4>
<div className="font-mono text-lg text-center text-slate-800">
New = Original × (1 {percent >= 0 ? '+' : '-'} <span className="text-indigo-600">{Math.abs(percent/100)}</span>)
</div>
<div className="font-mono text-xl font-bold text-center text-indigo-700 mt-2">
New = {original} × {multiplier.toFixed(2)}
</div>
</div>
</div>
);
};
export default PercentChangeWidget;

View File

@ -0,0 +1,98 @@
import React, { useState, useEffect } from 'react';
const PolygonWidget: React.FC = () => {
const [n, setN] = useState(5);
// Math
const interiorSum = (n - 2) * 180;
const eachInterior = Math.round((interiorSum / n) * 100) / 100;
const eachExterior = Math.round((360 / n) * 100) / 100;
// SVG Config
const width = 300;
const height = 300;
const cx = width / 2;
const cy = height / 2;
const r = 80;
// Generate points
const points = [];
for (let i = 0; i < n; i++) {
const angle = (i * 2 * Math.PI) / n - Math.PI / 2; // Start at top
points.push({
x: cx + r * Math.cos(angle),
y: cy + r * Math.sin(angle)
});
}
// Generate path string
const pathD = points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ') + ' Z';
// Generate exterior lines (extensions)
const exteriorLines = points.map((p, i) => {
const nextP = points[(i + 1) % n];
// Vector from p to nextP
const dx = nextP.x - p.x;
const dy = nextP.y - p.y;
// Normalize and extend
const len = Math.sqrt(dx*dx + dy*dy);
const exLen = 40;
const exX = nextP.x + (dx/len) * exLen;
const exY = nextP.y + (dy/len) * exLen;
return { x1: nextP.x, y1: nextP.y, x2: exX, y2: exY };
});
return (
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200 flex flex-col md:flex-row gap-8 items-center">
<div className="flex-1 w-full max-w-xs">
<label className="block text-sm font-bold text-slate-500 uppercase mb-2">Number of Sides (n): <span className="text-slate-900 text-lg">{n}</span></label>
<input
type="range" min="3" max="10" step="1"
value={n} onChange={(e) => setN(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-emerald-600 mb-6"
/>
<div className="space-y-3 font-mono text-sm">
<div className="p-3 bg-slate-50 rounded border border-slate-200">
<div className="text-xs text-slate-500 font-bold uppercase">Interior Sum</div>
<div className="text-slate-800">(n - 2) × 180° = <strong className="text-emerald-600">{interiorSum}°</strong></div>
</div>
<div className="p-3 bg-slate-50 rounded border border-slate-200">
<div className="text-xs text-slate-500 font-bold uppercase">Each Interior Angle</div>
<div className="text-slate-800">{interiorSum} / {n} = <strong className="text-emerald-600">{eachInterior}°</strong></div>
</div>
<div className="p-3 bg-slate-50 rounded border border-slate-200">
<div className="text-xs text-slate-500 font-bold uppercase">Each Exterior Angle</div>
<div className="text-slate-800">360 / {n} = <strong className="text-rose-600">{eachExterior}°</strong></div>
</div>
</div>
</div>
<div className="flex-shrink-0 relative">
<svg width={width} height={height}>
{/* Extensions for exterior angles */}
{exteriorLines.map((line, i) => (
<line key={i} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2} stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4,4" />
))}
{/* Polygon */}
<path d={pathD} fill="rgba(16, 185, 129, 0.1)" stroke="#059669" strokeWidth="3" />
{/* Vertices */}
{points.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r="4" fill="#059669" />
))}
{/* Center text */}
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle" fill="#059669" fontSize="24" fontWeight="bold" opacity="0.2">
{n}-gon
</text>
</svg>
</div>
</div>
);
};
export default PolygonWidget;

View File

@ -0,0 +1,77 @@
import React, { useState } from 'react';
const PolynomialBehaviorWidget: React.FC = () => {
const [degreeType, setDegreeType] = useState<'even' | 'odd'>('odd');
const [lcSign, setLcSign] = useState<'pos' | 'neg'>('pos');
// Visualization
const width = 300;
const height = 200;
const getPath = () => {
// Create schematic shapes
// Odd +: Low Left -> High Right
// Odd -: High Left -> Low Right
// Even +: High Left -> High Right
// Even -: Low Left -> Low Right
const startY = (degreeType === 'odd' && lcSign === 'pos') || (degreeType === 'even' && lcSign === 'neg') ? 180 : 20;
const endY = (lcSign === 'pos') ? 20 : 180;
// Control points for curvy polynomial look
const cp1Y = startY === 20 ? 150 : 50;
const cp2Y = endY === 20 ? 150 : 50;
return `M 20 ${startY} C 100 ${cp1Y}, 200 ${cp2Y}, 280 ${endY}`;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="space-y-2">
<p className="text-xs font-bold text-slate-400 uppercase">Degree (Highest Power)</p>
<div className="flex gap-2">
<button onClick={() => setDegreeType('even')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${degreeType === 'even' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-600 border-slate-200'}`}>Even (x², x)</button>
<button onClick={() => setDegreeType('odd')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${degreeType === 'odd' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-600 border-slate-200'}`}>Odd (x, x³)</button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-bold text-slate-400 uppercase">Leading Coefficient</p>
<div className="flex gap-2">
<button onClick={() => setLcSign('pos')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${lcSign === 'pos' ? 'bg-emerald-600 text-white border-emerald-600' : 'bg-white text-slate-600 border-slate-200'}`}>Positive (+)</button>
<button onClick={() => setLcSign('neg')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${lcSign === 'neg' ? 'bg-rose-600 text-white border-rose-600' : 'bg-white text-slate-600 border-slate-200'}`}>Negative (-)</button>
</div>
</div>
</div>
<div className="relative h-48 bg-slate-50 border border-slate-200 rounded-xl overflow-hidden flex items-center justify-center">
<svg width="300" height="200">
<line x1="150" y1="20" x2="150" y2="180" stroke="#cbd5e1" strokeWidth="2" />
<line x1="20" y1="100" x2="280" y2="100" stroke="#cbd5e1" strokeWidth="2" />
<path d={getPath()} stroke="#8b5cf6" strokeWidth="4" fill="none" markerEnd="url(#arrow)" markerStart="url(#arrow-start)" />
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#8b5cf6" />
</marker>
<marker id="arrow-start" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto-start-reverse">
<path d="M0,0 L0,6 L9,3 z" fill="#8b5cf6" />
</marker>
</defs>
</svg>
<div className="absolute top-2 left-2 text-xs font-bold text-slate-400">End Behavior</div>
</div>
<div className="mt-4 p-3 bg-indigo-50 border border-indigo-100 rounded-lg text-sm text-indigo-900 text-center">
{degreeType === 'even' && lcSign === 'pos' && "Ends go in the SAME direction (UP)."}
{degreeType === 'even' && lcSign === 'neg' && "Ends go in the SAME direction (DOWN)."}
{degreeType === 'odd' && lcSign === 'pos' && "Ends go in OPPOSITE directions (Down Left, Up Right)."}
{degreeType === 'odd' && lcSign === 'neg' && "Ends go in OPPOSITE directions (Up Left, Down Right)."}
</div>
</div>
);
};
export default PolynomialBehaviorWidget;

View File

@ -0,0 +1,364 @@
import React, { useState, useRef } from 'react';
type Mode = 'chords' | 'secants';
interface Point {
x: number;
y: number;
}
const PowerOfPointWidget: React.FC = () => {
const [mode, setMode] = useState<Mode>('chords');
// -- Common State --
const svgRef = useRef<SVGSVGElement>(null);
const isDragging = useRef<string | null>(null);
const center = { x: 200, y: 180 };
const radius = 100;
// -- Chords Mode State --
// Store angles for points A, B, C, D on the circle
const [chordAngles, setChordAngles] = useState({
a: 220, b: 40, // Chord 1
c: 140, d: 320 // Chord 2
});
// -- Secants Mode State --
// P is external point.
// Secant 1 defined by angle theta1 (offset from center-P line)
// Secant 2 defined by angle theta2
const [secantState, setSecantState] = useState({
px: 380, py: 180, // Point P
theta1: 15, // Angle offset for secant 1
});
// --- Helper Math ---
const getPosOnCircle = (deg: number) => ({
x: center.x + radius * Math.cos(deg * Math.PI / 180),
y: center.y + radius * Math.sin(deg * Math.PI / 180)
});
const dist = (p1: Point, p2: Point) => Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
const getIntersection = (p1: Point, p2: Point, p3: Point, p4: Point) => {
// Line AB represented as a1x + b1y = c1
const a1 = p2.y - p1.y;
const b1 = p1.x - p2.x;
const c1 = a1 * p1.x + b1 * p1.y;
// Line CD represented as a2x + b2y = c2
const a2 = p4.y - p3.y;
const b2 = p3.x - p4.x;
const c2 = a2 * p3.x + b2 * p3.y;
const determinant = a1 * b2 - a2 * b1;
if (Math.abs(determinant) < 0.001) return null; // Parallel
const x = (b2 * c1 - b1 * c2) / determinant;
const y = (a1 * c2 - a2 * c1) / determinant;
// Check if inside circle
if (dist({x,y}, center) > radius + 1) return null;
return { x, y };
};
// --- Interaction Handlers ---
const handleChordDrag = (e: React.MouseEvent, key: string) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const dx = e.clientX - rect.left - center.x;
const dy = e.clientY - rect.top - center.y;
let deg = Math.atan2(dy, dx) * 180 / Math.PI;
if (deg < 0) deg += 360;
setChordAngles(prev => ({ ...prev, [key]: deg }));
};
const handleSecantDrag = (e: React.MouseEvent) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (isDragging.current === 'P') {
// Constrain P outside
const dx = x - center.x;
const dy = y - center.y;
const d = Math.sqrt(dx*dx + dy*dy);
if (d > radius + 20) {
setSecantState(prev => ({...prev, px: x, py: y}));
} else {
const ang = Math.atan2(dy, dx);
setSecantState(prev => ({
...prev,
px: center.x + (radius+20)*Math.cos(ang),
py: center.y + (radius+20)*Math.sin(ang)
}));
}
} else if (isDragging.current === 'SecantEnd') {
// Calculate angle relative to PO line
// Vector PO
const pdx = center.x - secantState.px;
const pdy = center.y - secantState.py;
const poAngle = Math.atan2(pdy, pdx);
// Vector PA (mouse to P)
const mdx = x - secantState.px;
const mdy = y - secantState.py;
const mAngle = Math.atan2(mdy, mdx);
let diff = (mAngle - poAngle) * 180 / Math.PI;
// Normalize to -180 to 180
while (diff > 180) diff -= 360;
while (diff < -180) diff += 360;
// Clamp to hit circle. Max angle is asin(R/dist)
const distPO = Math.sqrt(pdx*pdx + pdy*pdy);
const maxAngle = Math.asin(radius/distPO) * 180 / Math.PI;
// Clamp
const clamped = Math.max(-maxAngle + 1, Math.min(maxAngle - 1, diff));
setSecantState(prev => ({...prev, theta1: clamped}));
}
};
// --- Render Helpers ---
const renderChords = () => {
const A = getPosOnCircle(chordAngles.a);
const B = getPosOnCircle(chordAngles.b);
const C = getPosOnCircle(chordAngles.c);
const D = getPosOnCircle(chordAngles.d);
const E = getIntersection(A, B, C, D);
const valid = !!E;
const ae = valid ? dist(A, E) : 0;
const eb = valid ? dist(E, B) : 0;
const ce = valid ? dist(C, E) : 0;
const ed = valid ? dist(E, D) : 0;
const points = [
{ k: 'a', p: A, l: 'A', c: '#7c3aed' },
{ k: 'b', p: B, l: 'B', c: '#7c3aed' },
{ k: 'c', p: C, l: 'C', c: '#059669' },
{ k: 'd', p: D, l: 'D', c: '#059669' }
];
return (
<>
<line x1={A.x} y1={A.y} x2={B.x} y2={B.y} stroke="#7c3aed" strokeWidth="3" />
<line x1={C.x} y1={C.y} x2={D.x} y2={D.y} stroke="#059669" strokeWidth="3" />
{/* Points */}
{points.map((pt) => (
<g key={pt.k} onMouseDown={() => isDragging.current = pt.k} className="cursor-pointer hover:scale-110 transition-transform">
<circle cx={pt.p.x} cy={pt.p.y} r="15" fill="transparent" />
<circle cx={pt.p.x} cy={pt.p.y} r="6" fill={pt.c} stroke="white" strokeWidth="2" />
<text x={pt.p.x} y={pt.p.y - 12} textAnchor="middle" className="text-sm font-bold fill-slate-700">{pt.l}</text>
</g>
))}
{valid && (
<>
<circle cx={E.x} cy={E.y} r="4" fill="#0f172a" />
<text x={E.x + 10} y={E.y} className="text-xs font-bold fill-slate-500">E</text>
</>
)}
{/* Info Panel */}
<div className="absolute top-4 left-4 bg-white/90 p-4 rounded-xl border border-slate-200 shadow-sm backdrop-blur-sm pointer-events-none select-none">
{!valid ? (
<p className="text-red-500 font-bold">Chords must intersect inside!</p>
) : (
<div className="space-y-3 font-mono text-sm">
<div className="flex gap-4">
<div>
<div className="text-xs font-bold text-violet-600">Purple Chord</div>
<div>{ae.toFixed(0)} × {eb.toFixed(0)} = <strong>{(ae*eb).toFixed(0)}</strong></div>
</div>
</div>
<div className="flex gap-4">
<div>
<div className="text-xs font-bold text-emerald-600">Green Chord</div>
<div>{ce.toFixed(0)} × {ed.toFixed(0)} = <strong>{(ce*ed).toFixed(0)}</strong></div>
</div>
</div>
<div className="h-px bg-slate-200"></div>
<p className="text-slate-500 text-xs text-center font-sans">
AE · EB = CE · ED
</p>
</div>
)}
</div>
</>
);
};
const renderSecant = () => {
const { px, py, theta1 } = secantState;
// Calculate Tangent Point T (Upper)
const dx = px - center.x;
const dy = py - center.y;
const distPO = Math.sqrt(dx*dx + dy*dy);
const anglePO = Math.atan2(dy, dx);
const angleOffset = Math.acos(radius/distPO);
const tAngle = anglePO - angleOffset;
const T = {
x: center.x + radius * Math.cos(tAngle),
y: center.y + radius * Math.sin(tAngle)
};
const tangentLen = Math.sqrt(distPO*distPO - radius*radius);
// Calculate Secant Intersection Points
// Secant Line angle
const secantAngle = anglePO + theta1 * Math.PI / 180;
const vx = px - center.x;
const vy = py - center.y;
const cos = Math.cos(secantAngle);
const sin = Math.sin(secantAngle);
// t^2 + 2(V.D)t + (V^2 - R^2) = 0
const b = 2 * (vx * cos + vy * sin);
const c = vx*vx + vy*vy - radius*radius;
const det = b*b - 4*c;
let A = {x:0, y:0}, B = {x:0, y:0};
let valid = false;
if (det > 0) {
const tFar = (-b - Math.sqrt(det)) / 2;
const tNear = (-b + Math.sqrt(det)) / 2;
// A is Near (External part)
A = { x: px + tNear * cos, y: py + tNear * sin };
// B is Far (Whole secant endpoint)
B = { x: px + tFar * cos, y: py + tFar * sin };
valid = true;
}
const distPA = valid ? dist({x:px, y:py}, A) : 0;
const distPB = valid ? dist({x:px, y:py}, B) : 0;
return (
<>
{/* Tangent Line */}
<line x1={px} y1={py} x2={T.x} y2={T.y} stroke="#e11d48" strokeWidth="3" />
<circle cx={T.x} cy={T.y} r="5" fill="#e11d48" />
<text x={T.x} y={T.y - 10} className="text-xs font-bold fill-rose-600">T</text>
{/* Secant Line (Draw full segment P to B) */}
{valid && <line x1={px} y1={py} x2={B.x} y2={B.y} stroke="#7c3aed" strokeWidth="3" />}
{valid && (
<>
{/* Point A (Near/External) */}
<circle cx={A.x} cy={A.y} r="5" fill="#7c3aed" />
<text x={A.x + 15} y={A.y} className="text-xs font-bold fill-violet-600">A</text>
{/* Point B (Far/Whole) */}
<circle cx={B.x} cy={B.y} r="5" fill="#7c3aed" />
<text x={B.x - 15} y={B.y} className="text-xs font-bold fill-violet-600">B</text>
</>
)}
{/* Point P */}
<g onMouseDown={() => isDragging.current = 'P'} className="cursor-grab active:cursor-grabbing">
<circle cx={px} cy={py} r="15" fill="transparent" />
<circle cx={px} cy={py} r="6" fill="#0f172a" stroke="white" strokeWidth="2" />
<text x={px + 10} y={py} className="text-sm font-bold fill-slate-800">P</text>
</g>
{/* Drag Handle for Secant Angle (at B, the far end) */}
{valid && (
<circle
cx={B.x} cy={B.y} r="12" fill="transparent" stroke="white" strokeWidth="2" strokeDasharray="2,2"
className="cursor-pointer hover:stroke-violet-400"
onMouseDown={() => isDragging.current = 'SecantEnd'}
/>
)}
{/* Info */}
<div className="absolute top-4 left-4 bg-white/90 p-4 rounded-xl border border-slate-200 shadow-sm backdrop-blur-sm pointer-events-none select-none">
<div className="space-y-3 font-mono text-sm">
<div className="mb-2">
<div className="text-xs font-bold text-rose-600 uppercase">Tangent² (PT²)</div>
<div>{tangentLen.toFixed(0)}² = <strong>{(tangentLen*tangentLen).toFixed(0)}</strong></div>
</div>
<div>
<div className="text-xs font-bold text-violet-600 uppercase">Secant (PA · PB)</div>
<div className="flex items-center gap-1">
<span title="External Part (PA)">{distPA.toFixed(0)}</span>
<span className="text-slate-400">×</span>
<span title="Whole Secant (PB)">{distPB.toFixed(0)}</span>
<span className="text-slate-400">=</span>
<strong>{(distPA*distPB).toFixed(0)}</strong>
</div>
</div>
<div className="h-px bg-slate-200 my-2"></div>
<p className="text-slate-500 text-xs text-center font-sans">
Tangent² = External × Whole
</p>
</div>
</div>
</>
);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging.current) return;
if (mode === 'chords') {
// Check if dragging specific points
if (['a','b','c','d'].includes(isDragging.current as string)) {
handleChordDrag(e, isDragging.current as string);
}
} else {
handleSecantDrag(e);
}
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex gap-4 mb-6 justify-center">
<button
onClick={() => setMode('chords')}
className={`px-4 py-2 rounded-full font-bold text-sm transition-all ${mode === 'chords' ? 'bg-slate-900 text-white shadow-md' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}
>
Intersecting Chords
</button>
<button
onClick={() => setMode('secants')}
className={`px-4 py-2 rounded-full font-bold text-sm transition-all ${mode === 'secants' ? 'bg-slate-900 text-white shadow-md' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}
>
Tangent-Secant
</button>
</div>
<div className="relative flex justify-center bg-slate-50 rounded-xl border border-slate-100 overflow-hidden">
<svg
ref={svgRef}
width="500" height="360"
className="select-none"
onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = null}
onMouseLeave={() => isDragging.current = null}
>
<defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#e2e8f0" strokeWidth="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{/* Circle */}
<circle cx={center.x} cy={center.y} r={radius} fill="white" stroke="#94a3b8" strokeWidth="2" />
<circle cx={center.x} cy={center.y} r="3" fill="#cbd5e1" />
{mode === 'chords' ? renderChords() : renderSecant()}
</svg>
</div>
<div className="mt-4 text-center text-sm text-slate-500">
{mode === 'chords'
? "Drag the colored points along the circle."
: "Drag point P or the secant endpoint B."
}
</div>
</div>
);
};
export default PowerOfPointWidget;

View File

@ -0,0 +1,110 @@
import React, { useState } from 'react';
type HighlightMode = 'none' | 'bus' | 'club' | 'cond_club_bus' | 'cond_bus_club' | 'or_bus_club';
const ProbabilityTableWidget: React.FC = () => {
const [highlight, setHighlight] = useState<HighlightMode>('none');
const data = {
bus_club: 36, bus_noClub: 24,
noBus_club: 30, noBus_noClub: 30
};
const totals = {
bus: data.bus_club + data.bus_noClub, // 60
noBus: data.noBus_club + data.noBus_noClub, // 60
club: data.bus_club + data.noBus_club, // 66
noClub: data.bus_noClub + data.noBus_noClub, // 54
total: 120
};
const getCellClass = (cell: string) => {
const base = "p-4 text-center border font-mono font-bold transition-colors duration-300 ";
// Logic for highlighting based on mode
let isNum = false;
let isDenom = false;
if (highlight === 'bus') {
if (cell === 'bus_total') isNum = true;
if (cell === 'grand_total') isDenom = true;
} else if (highlight === 'club') {
if (cell === 'club_total') isNum = true;
if (cell === 'grand_total') isDenom = true;
} else if (highlight === 'cond_club_bus') {
if (cell === 'bus_club') isNum = true;
if (cell === 'bus_total') isDenom = true;
} else if (highlight === 'cond_bus_club') {
if (cell === 'bus_club') isNum = true;
if (cell === 'club_total') isDenom = true;
} else if (highlight === 'or_bus_club') {
if (['bus_club', 'bus_noClub', 'noBus_club'].includes(cell)) isNum = true;
if (cell === 'grand_total') isDenom = true;
}
if (isNum) return base + "bg-emerald-100 text-emerald-800 border-emerald-300";
if (isDenom) return base + "bg-indigo-100 text-indigo-800 border-indigo-300";
return base + "bg-white border-slate-200 text-slate-600";
};
const explanation = () => {
switch(highlight) {
case 'bus': return { title: "P(Bus)", math: `${totals.bus} / ${totals.total} = 0.50` };
case 'club': return { title: "P(Club)", math: `${totals.club} / ${totals.total} = 0.55` };
case 'cond_club_bus': return { title: "P(Club | Bus)", math: `${data.bus_club} / ${totals.bus} = 0.60`, sub: "Given Bus, restrict to Bus row." };
case 'cond_bus_club': return { title: "P(Bus | Club)", math: `${data.bus_club} / ${totals.club} ≈ 0.55`, sub: "Given Club, restrict to Club column." };
case 'or_bus_club': return { title: "P(Bus OR Club)", math: `(${totals.bus} + ${totals.club} - ${data.bus_club}) / ${totals.total} = ${totals.bus+totals.club-data.bus_club}/${totals.total} = 0.75`, sub: "Add totals, subtract overlap." };
default: return { title: "Select a Probability", math: "---" };
}
};
const exp = explanation();
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-wrap gap-2 mb-6 justify-center">
<button onClick={() => setHighlight('bus')} className="px-3 py-1 bg-slate-100 hover:bg-slate-200 rounded text-sm font-bold text-slate-700">P(Bus)</button>
<button onClick={() => setHighlight('club')} className="px-3 py-1 bg-slate-100 hover:bg-slate-200 rounded text-sm font-bold text-slate-700">P(Club)</button>
<button onClick={() => setHighlight('cond_club_bus')} className="px-3 py-1 bg-indigo-100 hover:bg-indigo-200 rounded text-sm font-bold text-indigo-700">P(Club | Bus)</button>
<button onClick={() => setHighlight('cond_bus_club')} className="px-3 py-1 bg-indigo-100 hover:bg-indigo-200 rounded text-sm font-bold text-indigo-700">P(Bus | Club)</button>
<button onClick={() => setHighlight('or_bus_club')} className="px-3 py-1 bg-emerald-100 hover:bg-emerald-200 rounded text-sm font-bold text-emerald-700">P(Bus OR Club)</button>
</div>
<div className="overflow-hidden rounded-lg border border-slate-200 mb-6">
<div className="grid grid-cols-4 bg-slate-50 border-b border-slate-200">
<div className="p-3 text-center text-xs font-bold text-slate-400 uppercase"></div>
<div className="p-3 text-center text-xs font-bold text-slate-500 uppercase">Club</div>
<div className="p-3 text-center text-xs font-bold text-slate-500 uppercase">No Club</div>
<div className="p-3 text-center text-xs font-bold text-slate-800 uppercase">Total</div>
</div>
<div className="grid grid-cols-4">
<div className="p-4 flex items-center justify-center font-bold text-slate-600 bg-slate-50 border-r border-slate-200">Bus</div>
<div className={getCellClass('bus_club')}>{data.bus_club}</div>
<div className={getCellClass('bus_noClub')}>{data.bus_noClub}</div>
<div className={getCellClass('bus_total')}>{totals.bus}</div>
<div className="p-4 flex items-center justify-center font-bold text-slate-600 bg-slate-50 border-r border-slate-200 border-t border-slate-200">No Bus</div>
<div className={getCellClass('noBus_club')}>{data.noBus_club}</div>
<div className={getCellClass('noBus_noClub')}>{data.noBus_noClub}</div>
<div className={getCellClass('noBus_total')}>{totals.noBus}</div>
<div className="p-4 flex items-center justify-center font-bold text-slate-900 bg-slate-100 border-r border-slate-200 border-t border-slate-200">Total</div>
<div className={getCellClass('club_total')}>{totals.club}</div>
<div className={getCellClass('noClub_total')}>{totals.noClub}</div>
<div className={getCellClass('grand_total')}>{totals.total}</div>
</div>
</div>
<div className="bg-slate-50 p-4 rounded-xl text-center">
<h4 className="text-sm font-bold text-slate-500 uppercase mb-2">{exp.title}</h4>
<div className="text-2xl font-mono font-bold text-slate-800 mb-1">
{exp.math}
</div>
{exp.sub && <p className="text-xs text-slate-400">{exp.sub}</p>}
</div>
</div>
);
};
export default ProbabilityTableWidget;

View File

@ -0,0 +1,253 @@
import React, { useState } from 'react';
const ProbabilityTreeWidget: React.FC = () => {
const [replacement, setReplacement] = useState(false);
const [initR, setInitR] = useState(3);
const [initB, setInitB] = useState(4);
const [hoverPath, setHoverPath] = useState<string | null>(null); // 'RR', 'RB', 'BR', 'BB'
const total = initR + initB;
// Level 1 Probs
const pR = initR / total;
const pB = initB / total;
// Level 2 Probs (Given R)
const r_R = replacement ? initR : Math.max(0, initR - 1);
const r_Total = replacement ? total : total - 1;
const pR_R = r_Total > 0 ? r_R / r_Total : 0;
const pB_R = r_Total > 0 ? 1 - pR_R : 0;
// Level 2 Probs (Given B)
const b_B = replacement ? initB : Math.max(0, initB - 1);
const b_Total = replacement ? total : total - 1;
const pB_B = b_Total > 0 ? b_B / b_Total : 0;
const pR_B = b_Total > 0 ? 1 - pB_B : 0;
// Final Probs
const pRR = pR * pR_R;
const pRB = pR * pB_R;
const pBR = pB * pR_B;
const pBB = pB * pB_B;
const fraction = (num: number, den: number) => {
if (den === 0) return "0";
return (
<span className="font-mono bg-white px-1 rounded shadow-sm border border-slate-200 text-xs inline-block mx-1">
{num}/{den}
</span>
);
};
const getPathColor = (path: string, segment: 'top' | 'bottom' | 'top-top' | 'top-bottom' | 'bottom-top' | 'bottom-bottom') => {
const defaultColor = "#cbd5e1"; // Slate 300
if (!hoverPath) {
// Default coloring based on branch type
if (segment.includes('top')) return "#f43f5e"; // Red branches
if (segment.includes('bottom')) return "#3b82f6"; // Blue branches
return defaultColor;
}
// Highlighting logic based on hoverPath
if (segment === 'top') return (hoverPath === 'RR' || hoverPath === 'RB') ? "#f43f5e" : "#f1f5f9";
if (segment === 'bottom') return (hoverPath === 'BR' || hoverPath === 'BB') ? "#3b82f6" : "#f1f5f9";
if (segment === 'top-top') return hoverPath === 'RR' ? "#f43f5e" : "#f1f5f9";
if (segment === 'top-bottom') return hoverPath === 'RB' ? "#3b82f6" : "#f1f5f9";
if (segment === 'bottom-top') return hoverPath === 'BR' ? "#f43f5e" : "#f1f5f9";
if (segment === 'bottom-bottom') return hoverPath === 'BB' ? "#3b82f6" : "#f1f5f9";
return defaultColor;
};
const getStrokeWidth = (segment: string) => {
if (!hoverPath) return 2;
if (segment === 'top') return (hoverPath === 'RR' || hoverPath === 'RB') ? 4 : 1;
if (segment === 'bottom') return (hoverPath === 'BR' || hoverPath === 'BB') ? 4 : 1;
if (segment === 'top-top') return hoverPath === 'RR' ? 4 : 1;
if (segment === 'top-bottom') return hoverPath === 'RB' ? 4 : 1;
if (segment === 'bottom-top') return hoverPath === 'BR' ? 4 : 1;
if (segment === 'bottom-bottom') return hoverPath === 'BB' ? 4 : 1;
return 2;
}
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
{/* Controls */}
<div className="flex flex-wrap justify-between items-center mb-6 gap-4">
<div className="flex gap-4">
<div className="flex flex-col">
<label className="text-xs font-bold text-rose-600 uppercase mb-1">Red Items</label>
<div className="flex items-center gap-2">
<button onClick={() => setInitR(Math.max(1, initR-1))} className="w-6 h-6 bg-rose-100 text-rose-700 rounded hover:bg-rose-200">-</button>
<span className="font-bold w-4 text-center">{initR}</span>
<button onClick={() => setInitR(Math.min(10, initR+1))} className="w-6 h-6 bg-rose-100 text-rose-700 rounded hover:bg-rose-200">+</button>
</div>
</div>
<div className="flex flex-col">
<label className="text-xs font-bold text-blue-600 uppercase mb-1">Blue Items</label>
<div className="flex items-center gap-2">
<button onClick={() => setInitB(Math.max(1, initB-1))} className="w-6 h-6 bg-blue-100 text-blue-700 rounded hover:bg-blue-200">-</button>
<span className="font-bold w-4 text-center">{initB}</span>
<button onClick={() => setInitB(Math.min(10, initB+1))} className="w-6 h-6 bg-blue-100 text-blue-700 rounded hover:bg-blue-200">+</button>
</div>
</div>
</div>
<div className="flex bg-slate-100 p-1 rounded-lg">
<button
onClick={() => setReplacement(true)}
className={`px-3 py-1 text-xs font-bold rounded transition-all ${replacement ? 'bg-white shadow text-indigo-600' : 'text-slate-500'}`}
>
With Replacement
</button>
<button
onClick={() => setReplacement(false)}
className={`px-3 py-1 text-xs font-bold rounded transition-all ${!replacement ? 'bg-white shadow text-indigo-600' : 'text-slate-500'}`}
>
Without Replacement
</button>
</div>
</div>
<div className="relative h-64 w-full max-w-lg mx-auto select-none">
<svg width="100%" height="100%" className="overflow-visible">
{/* Root */}
<circle cx="20" cy="128" r="6" fill="#64748b" />
{/* Level 1 Branches */}
<path d="M 20 128 C 50 128, 50 64, 150 64" fill="none" stroke={getPathColor('R', 'top')} strokeWidth={getStrokeWidth('top')} className="transition-all duration-300" />
<path d="M 20 128 C 50 128, 50 192, 150 192" fill="none" stroke={getPathColor('B', 'bottom')} strokeWidth={getStrokeWidth('bottom')} className="transition-all duration-300" />
{/* Level 1 Labels */}
<foreignObject x="60" y="70" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath && (hoverPath[0]!=='R') ? 'text-slate-300' : 'text-rose-600'}`}>{initR}/{total}</div>
</foreignObject>
<foreignObject x="60" y="150" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath && (hoverPath[0]!=='B') ? 'text-slate-300' : 'text-blue-600'}`}>{initB}/{total}</div>
</foreignObject>
{/* Level 1 Nodes */}
<circle cx="150" cy="64" r="18" fill="#f43f5e" className={`transition-all ${hoverPath && hoverPath[0] !== 'R' ? 'opacity-20' : 'opacity-100 shadow-md'}`} />
<text x="150" y="68" textAnchor="middle" fill="white" className={`text-xs font-bold pointer-events-none ${hoverPath && hoverPath[0] !== 'R' ? 'opacity-20' : ''}`}>R</text>
<circle cx="150" cy="192" r="18" fill="#3b82f6" className={`transition-all ${hoverPath && hoverPath[0] !== 'B' ? 'opacity-20' : 'opacity-100 shadow-md'}`} />
<text x="150" y="196" textAnchor="middle" fill="white" className={`text-xs font-bold pointer-events-none ${hoverPath && hoverPath[0] !== 'B' ? 'opacity-20' : ''}`}>B</text>
{/* Level 2 Branches (Top) */}
<path d="M 168 64 L 280 32" fill="none" stroke={getPathColor('RR', 'top-top')} strokeWidth={getStrokeWidth('top-top')} strokeDasharray="4,2" className="transition-all duration-300" />
<path d="M 168 64 L 280 96" fill="none" stroke={getPathColor('RB', 'top-bottom')} strokeWidth={getStrokeWidth('top-bottom')} strokeDasharray="4,2" className="transition-all duration-300" />
{/* Level 2 Top Labels */}
<foreignObject x="190" y="25" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'RR' ? 'text-rose-600 scale-110' : 'text-slate-400'}`}>{r_R}/{r_Total}</div>
</foreignObject>
<foreignObject x="190" y="80" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'RB' ? 'text-blue-600 scale-110' : 'text-slate-400'}`}>{initB}/{r_Total}</div>
</foreignObject>
{/* Level 2 Branches (Bottom) */}
<path d="M 168 192 L 280 160" fill="none" stroke={getPathColor('BR', 'bottom-top')} strokeWidth={getStrokeWidth('bottom-top')} strokeDasharray="4,2" className="transition-all duration-300" />
<path d="M 168 192 L 280 224" fill="none" stroke={getPathColor('BB', 'bottom-bottom')} strokeWidth={getStrokeWidth('bottom-bottom')} strokeDasharray="4,2" className="transition-all duration-300" />
{/* Level 2 Bottom Labels */}
<foreignObject x="190" y="150" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'BR' ? 'text-rose-600 scale-110' : 'text-slate-400'}`}>{initR}/{b_Total}</div>
</foreignObject>
<foreignObject x="190" y="210" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'BB' ? 'text-blue-600 scale-110' : 'text-slate-400'}`}>{b_B}/{b_Total}</div>
</foreignObject>
{/* Outcomes (Interactive Targets) */}
<g
className="cursor-pointer"
onMouseEnter={() => setHoverPath('RR')}
onMouseLeave={() => setHoverPath(null)}
>
<text x="300" y="36" className={`text-xs font-bold transition-all ${hoverPath === 'RR' ? 'fill-rose-600 text-base' : 'fill-slate-500'}`}>RR: {(pRR * 100).toFixed(1)}%</text>
<rect x="290" y="20" width="80" height="20" fill="transparent" />
</g>
<g
className="cursor-pointer"
onMouseEnter={() => setHoverPath('RB')}
onMouseLeave={() => setHoverPath(null)}
>
<text x="300" y="100" className={`text-xs font-bold transition-all ${hoverPath === 'RB' ? 'fill-indigo-600 text-base' : 'fill-slate-500'}`}>RB: {(pRB * 100).toFixed(1)}%</text>
<rect x="290" y="85" width="80" height="20" fill="transparent" />
</g>
<g
className="cursor-pointer"
onMouseEnter={() => setHoverPath('BR')}
onMouseLeave={() => setHoverPath(null)}
>
<text x="300" y="164" className={`text-xs font-bold transition-all ${hoverPath === 'BR' ? 'fill-indigo-600 text-base' : 'fill-slate-500'}`}>BR: {(pBR * 100).toFixed(1)}%</text>
<rect x="290" y="150" width="80" height="20" fill="transparent" />
</g>
<g
className="cursor-pointer"
onMouseEnter={() => setHoverPath('BB')}
onMouseLeave={() => setHoverPath(null)}
>
<text x="300" y="228" className={`text-xs font-bold transition-all ${hoverPath === 'BB' ? 'fill-blue-600 text-base' : 'fill-slate-500'}`}>BB: {(pBB * 100).toFixed(1)}%</text>
<rect x="290" y="215" width="80" height="20" fill="transparent" />
</g>
</svg>
</div>
{/* Calculation Panel */}
<div className={`p-4 rounded-lg border text-sm mt-4 transition-colors ${hoverPath ? 'bg-amber-50 border-amber-200 text-amber-900' : 'bg-slate-50 border-slate-100 text-slate-400'}`}>
{!hoverPath ? (
<p className="text-center italic">Hover over an outcome (e.g., RR) to see the calculation.</p>
) : (
<>
<p className="font-bold mb-1">
Calculation for <span className="font-mono bg-white px-1 rounded border border-amber-200">{hoverPath}</span>
({hoverPath[0] === 'R' ? 'Red' : 'Blue'} then {hoverPath[1] === 'R' ? 'Red' : 'Blue'}):
</p>
<div className="flex flex-wrap items-center gap-2 font-mono text-lg mt-2 justify-center sm:justify-start">
{/* First Draw */}
<span>P({hoverPath[0]})</span>
<span>×</span>
<span>P({hoverPath[1]} | {hoverPath[0]})</span>
<span>=</span>
{/* Numbers */}
{fraction(hoverPath[0] === 'R' ? initR : initB, total)}
<span>×</span>
{fraction(
hoverPath === 'RR' ? r_R : hoverPath === 'RB' ? initB : hoverPath === 'BR' ? initR : b_B,
hoverPath[0] === 'R' ? r_Total : b_Total
)}
<span>=</span>
{/* Result */}
<strong className="text-amber-700">
{fraction(
(hoverPath[0] === 'R' ? initR : initB) * (hoverPath === 'RR' ? r_R : hoverPath === 'RB' ? initB : hoverPath === 'BR' ? initR : b_B),
total * (hoverPath[0] === 'R' ? r_Total : b_Total)
)}
</strong>
</div>
{!replacement && hoverPath[0] === hoverPath[1] && (
<p className="text-xs mt-3 text-rose-600 font-bold bg-white p-2 rounded inline-block border border-rose-100">
Notice: The numerator decreased because we kept the first {hoverPath[0] === 'R' ? 'Red' : 'Blue'} item!
</p>
)}
</>
)}
</div>
</div>
);
};
export default ProbabilityTreeWidget;

View File

@ -0,0 +1,150 @@
import React, { useState } from "react";
import { type QuizData } from "../../types/lesson";
import { CheckCircle2, XCircle, ChevronRight } from "lucide-react";
interface QuizProps {
data: QuizData;
onComplete?: () => void;
}
const Quiz: React.FC<QuizProps> = ({ data, onComplete }) => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isSubmitted, setIsSubmitted] = useState(false);
const handleOptionClick = (id: string) => {
if (isSubmitted && selectedId === id) return; // Allow changing selection if not correct? No, lock after submit usually. Let's strictly lock.
if (!isSubmitted) {
setSelectedId(id);
}
};
const handleSubmit = () => {
if (!selectedId) return;
setIsSubmitted(true);
const selectedOption = data.options.find((opt) => opt.id === selectedId);
if (selectedOption?.isCorrect && onComplete) {
onComplete();
}
};
const selectedOption = data.options.find((opt) => opt.id === selectedId);
const isCorrect = selectedOption?.isCorrect;
return (
<div className="w-full max-w-2xl mx-auto bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden mt-6">
<div className="p-6">
<h4 className="text-sm font-bold text-slate-400 uppercase tracking-wider mb-2">
Concept Check
</h4>
<p className="text-lg font-medium text-slate-900 mb-6 whitespace-pre-line">
{data.question}
</p>
<div className="space-y-3">
{data.options.map((option) => {
let borderClass = "border-slate-200 hover:border-indigo-300";
let bgClass = "bg-white hover:bg-slate-50";
let icon = null;
if (isSubmitted) {
if (option.id === selectedId) {
if (option.isCorrect) {
borderClass = "border-green-500 bg-green-50";
icon = <CheckCircle2 className="w-5 h-5 text-green-600" />;
} else {
borderClass = "border-red-500 bg-red-50";
icon = <XCircle className="w-5 h-5 text-red-600" />;
}
} else if (option.isCorrect) {
// Highlight correct answer if wrong one was picked
borderClass = "border-green-200 bg-green-50/50";
}
} else if (selectedId === option.id) {
borderClass = "border-indigo-600 bg-indigo-50";
}
return (
<button
key={option.id}
onClick={() => handleOptionClick(option.id)}
disabled={isSubmitted}
className={`w-full text-left p-4 rounded-lg border-2 transition-all duration-200 flex items-center justify-between group ${borderClass} ${bgClass}`}
>
<div className="flex items-center">
<span
className={`w-6 h-6 flex items-center justify-center rounded-full text-xs font-bold mr-3 ${
isSubmitted && option.isCorrect
? "bg-green-200 text-green-800"
: isSubmitted && option.id === selectedId
? "bg-red-200 text-red-800"
: selectedId === option.id
? "bg-indigo-600 text-white"
: "bg-slate-100 text-slate-500"
}`}
>
{option.id}
</span>
<span className="text-slate-700 group-hover:text-slate-900">
{option.text}
</span>
</div>
{icon}
</button>
);
})}
</div>
</div>
{/* Feedback Section */}
{isSubmitted && (
<div
className={`p-6 border-t ${isCorrect ? "bg-green-50 border-green-100" : "bg-slate-50 border-slate-100"}`}
>
<div className="flex items-start gap-3">
<div
className={`mt-1 p-1 rounded-full ${isCorrect ? "bg-green-200" : "bg-slate-200"}`}
>
{isCorrect ? (
<CheckCircle2 className="w-4 h-4 text-green-700" />
) : (
<div className="w-4 h-4 text-slate-500 font-bold text-center leading-4">
i
</div>
)}
</div>
<div>
<p
className={`font-bold ${isCorrect ? "text-green-800" : "text-slate-800"} mb-1`}
>
{isCorrect ? "That's right!" : "Not quite."}
</p>
<p className="text-slate-600 mb-2">{selectedOption?.feedback}</p>
<div className="text-sm text-slate-500 bg-white p-3 rounded border border-slate-200">
<span className="font-semibold block mb-1">Explanation:</span>
{data.explanation}
</div>
</div>
</div>
</div>
)}
{!isSubmitted && (
<div className="p-4 bg-slate-50 border-t border-slate-100 flex justify-end">
<button
onClick={handleSubmit}
disabled={!selectedId}
className={`px-6 py-2 rounded-full font-semibold transition-all flex items-center ${
selectedId
? "bg-slate-900 text-white hover:bg-slate-800 shadow-md transform hover:-translate-y-0.5"
: "bg-slate-200 text-slate-400 cursor-not-allowed"
}`}
>
Check Answer <ChevronRight className="w-4 h-4 ml-1" />
</button>
</div>
)}
</div>
);
};
export default Quiz;

View File

@ -0,0 +1,135 @@
import React, { useState } from 'react';
const RadicalSolutionWidget: React.FC = () => {
// Equation: sqrt(x) = x - k
const [k, setK] = useState(2);
// Intersection logic
// x = (x-k)^2 => x = x^2 - 2kx + k^2 => x^2 - (2k+1)x + k^2 = 0
// Roots via quadratic formula
const a = 1;
const b = -(2*k + 1);
const c = k*k;
const disc = b*b - 4*a*c;
let solutions: number[] = [];
if (disc >= 0) {
const x1 = (-b + Math.sqrt(disc)) / (2*a);
const x2 = (-b - Math.sqrt(disc)) / (2*a);
solutions = [x1, x2].filter(val => val >= 0); // Domain x>=0
}
// Check validity against original equation sqrt(x) = x - k
const validSolutions = solutions.filter(x => Math.abs(Math.sqrt(x) - (x - k)) < 0.01);
const extraneousSolutions = solutions.filter(x => Math.abs(Math.sqrt(x) - (x - k)) >= 0.01);
// Vis
const width = 300;
const height = 300;
const range = 10;
const scale = 25;
const toPx = (v: number, isY = false) => isY ? height - v * scale - 20 : v * scale + 20;
const pathSqrt = () => {
let d = "";
for(let x=0; x<=range; x+=0.1) {
d += d ? ` L ${toPx(x)} ${toPx(Math.sqrt(x), true)}` : `M ${toPx(x)} ${toPx(Math.sqrt(x), true)}`;
}
return d;
};
const pathLine = () => {
// y = x - k
const x1 = 0; const y1 = -k;
const x2 = range; const y2 = range - k;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
// Phantom parabola path (x = y^2) - representing the squared equation
// This includes y = -sqrt(x)
const pathPhantom = () => {
let d = "";
for(let x=0; x<=range; x+=0.1) {
d += d ? ` L ${toPx(x)} ${toPx(-Math.sqrt(x), true)}` : `M ${toPx(x)} ${toPx(-Math.sqrt(x), true)}`;
}
return d;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase mb-2">Equation</div>
<div className="font-mono text-lg font-bold text-slate-800">
x = x - {k}
</div>
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase">Shift Line (k) = {k}</label>
<input type="range" min="0" max="6" step="0.5" value={k} onChange={e => setK(parseFloat(e.target.value))} className="w-full h-2 bg-slate-200 rounded-lg accent-indigo-600 mt-2"/>
</div>
<div className="space-y-3">
<div className="p-3 bg-emerald-50 rounded border border-emerald-100">
<div className="text-xs font-bold text-emerald-700 uppercase mb-1">Valid Solutions</div>
<div className="font-mono text-sm font-bold text-emerald-900">
{validSolutions.length > 0 ? validSolutions.map(n => `x = ${n.toFixed(2)}`).join(', ') : "None"}
</div>
</div>
<div className="p-3 bg-rose-50 rounded border border-rose-100">
<div className="text-xs font-bold text-rose-700 uppercase mb-1">Extraneous Solutions</div>
<div className="font-mono text-sm font-bold text-rose-900">
{extraneousSolutions.length > 0 ? extraneousSolutions.map(n => `x = ${n.toFixed(2)}`).join(', ') : "None"}
</div>
</div>
</div>
<p className="text-xs text-slate-400 leading-relaxed">
The <span className="text-rose-400 font-bold">extraneous</span> solution is a real intersection for the <em>squared</em> equation (the phantom curve), but not for the original radical.
</p>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300">
{/* Grid */}
<defs>
<pattern id="grid-rad" width="25" height="25" patternUnits="userSpaceOnUse">
<path d="M 25 0 L 0 0 0 25" fill="none" stroke="#f8fafc" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid-rad)" />
{/* Axes */}
<line x1="20" y1="0" x2="20" y2="300" stroke="#cbd5e1" strokeWidth="2" />
<line x1="0" y1={toPx(0, true)} x2="300" y2={toPx(0, true)} stroke="#cbd5e1" strokeWidth="2" />
{/* Phantom -sqrt(x) */}
<path d={pathPhantom()} fill="none" stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4,4" />
{/* Real sqrt(x) */}
<path d={pathSqrt()} fill="none" stroke="#4f46e5" strokeWidth="3" />
{/* Line x-k */}
<path d={pathLine()} fill="none" stroke="#64748b" strokeWidth="2" />
{/* Points */}
{validSolutions.map(x => (
<circle key={`v-${x}`} cx={toPx(x)} cy={toPx(Math.sqrt(x), true)} r="5" fill="#10b981" stroke="white" strokeWidth="2" />
))}
{extraneousSolutions.map(x => (
<circle key={`e-${x}`} cx={toPx(x)} cy={toPx(-(Math.sqrt(x)), true)} r="5" fill="#f43f5e" stroke="white" strokeWidth="2" />
))}
</svg>
<div className="absolute top-2 right-2 text-xs font-bold text-indigo-600">y = x</div>
<div className="absolute bottom-10 right-2 text-xs font-bold text-slate-500">y = x - {k}</div>
</div>
</div>
</div>
</div>
);
};
export default RadicalSolutionWidget;

View File

@ -0,0 +1,71 @@
import React, { useState } from 'react';
const RadicalWidget: React.FC = () => {
const [power, setPower] = useState(3);
const [root, setRoot] = useState(2);
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-center mb-8">
<div className="space-y-6">
<div>
<label className="text-xs font-bold text-violet-600 uppercase mb-1 block">Power (Numerator) m</label>
<div className="flex items-center gap-4">
<input
type="range" min="1" max="9" value={power} onChange={e => setPower(parseInt(e.target.value))}
className="flex-1 h-2 bg-violet-100 rounded-lg accent-violet-600"
/>
<span className="font-mono font-bold text-violet-800 text-xl">{power}</span>
</div>
</div>
<div>
<label className="text-xs font-bold text-fuchsia-600 uppercase mb-1 block">Root (Denominator) n</label>
<div className="flex items-center gap-4">
<input
type="range" min="2" max="9" value={root} onChange={e => setRoot(parseInt(e.target.value))}
className="flex-1 h-2 bg-fuchsia-100 rounded-lg accent-fuchsia-600"
/>
<span className="font-mono font-bold text-fuchsia-800 text-xl">{root}</span>
</div>
</div>
</div>
<div className="flex flex-col items-center justify-center p-6 bg-slate-50 rounded-xl border border-slate-200 min-h-[160px]">
<div className="flex items-center gap-8 text-4xl font-serif">
{/* Rational Exponent Form */}
<div className="text-center group">
<span className="font-bold text-slate-700 italic">x</span>
<sup className="text-2xl font-sans font-bold">
<span className="text-violet-600 group-hover:scale-110 inline-block transition-transform">{power}</span>
<span className="text-slate-400 mx-1">/</span>
<span className="text-fuchsia-600 group-hover:scale-110 inline-block transition-transform">{root}</span>
</sup>
</div>
<span className="text-slate-300">=</span>
{/* Radical Form */}
<div className="text-center relative group">
<span className="absolute -top-3 -left-3 text-lg font-bold text-fuchsia-600 font-sans group-hover:scale-110 transition-transform">{root}</span>
<span className="text-slate-400"></span>
<span className="border-t-2 border-slate-400 px-1 font-bold text-slate-700 italic">
x
<sup className="text-violet-600 text-2xl font-sans ml-0.5 group-hover:scale-110 inline-block transition-transform">{power}</sup>
</span>
</div>
</div>
</div>
</div>
<div className="text-sm text-slate-600 bg-indigo-50 p-4 rounded-lg border border-indigo-100">
<p className="mb-2"><strong>The Golden Rule:</strong> The top number stays with x (power), the bottom number becomes the root.</p>
<p className="font-mono">
Exponent <span className="text-violet-600 font-bold">{power}</span> goes inside.
Root <span className="text-fuchsia-600 font-bold">{root}</span> goes outside.
</p>
</div>
</div>
);
};
export default RadicalWidget;

View File

@ -0,0 +1,80 @@
import React, { useState } from 'react';
const RatioVisualizerWidget: React.FC = () => {
const [partA, setPartA] = useState(3);
const [partB, setPartB] = useState(2);
const [scale, setScale] = useState(1);
const totalParts = partA + partB;
const scaledA = partA * scale;
const scaledB = partB * scale;
const scaledTotal = totalParts * scale;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8 mb-8">
<div className="w-full md:w-1/3 space-y-6">
<div>
<label className="text-xs font-bold text-indigo-600 uppercase">Part A (Indigo)</label>
<input
type="range" min="1" max="10" value={partA}
onChange={e => setPartA(parseInt(e.target.value))}
className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600 mt-2"
/>
<div className="text-right font-mono font-bold text-indigo-700">{partA} parts</div>
</div>
<div>
<label className="text-xs font-bold text-rose-600 uppercase">Part B (Rose)</label>
<input
type="range" min="1" max="10" value={partB}
onChange={e => setPartB(parseInt(e.target.value))}
className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600 mt-2"
/>
<div className="text-right font-mono font-bold text-rose-700">{partB} parts</div>
</div>
<div className="pt-4 border-t border-slate-200">
<label className="text-xs font-bold text-slate-500 uppercase">Multiplier (k)</label>
<input
type="range" min="1" max="5" value={scale}
onChange={e => setScale(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-slate-600 mt-2"
/>
<div className="text-right font-mono font-bold text-slate-700">k = {scale}</div>
</div>
</div>
<div className="flex-1 flex flex-col justify-center">
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 mb-4">
<div className="flex flex-wrap gap-2 justify-center content-start min-h-[100px]">
{Array.from({ length: scaledA }).map((_, i) => (
<div key={`a-${i}`} className="w-6 h-6 rounded-full bg-indigo-500 shadow-sm animate-fade-in"></div>
))}
{Array.from({ length: scaledB }).map((_, i) => (
<div key={`b-${i}`} className="w-6 h-6 rounded-full bg-rose-500 shadow-sm animate-fade-in"></div>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-center">
<div className="p-3 bg-white border border-slate-200 rounded-lg shadow-sm">
<p className="text-xs font-bold text-slate-400 uppercase">Part-to-Part Ratio</p>
<p className="text-lg font-bold text-slate-800">
<span className="text-indigo-600">{scaledA}</span> : <span className="text-rose-600">{scaledB}</span>
</p>
<p className="text-xs text-slate-400 mt-1">({partA}k : {partB}k)</p>
</div>
<div className="p-3 bg-white border border-slate-200 rounded-lg shadow-sm">
<p className="text-xs font-bold text-slate-400 uppercase">Part-to-Whole (Indigo)</p>
<p className="text-lg font-bold text-slate-800">
<span className="text-indigo-600">{scaledA}</span> / {scaledTotal}
</p>
<p className="text-xs text-slate-400 mt-1">({partA}k / {totalParts}k)</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default RatioVisualizerWidget;

View File

@ -0,0 +1,109 @@
import React, { useState } from 'react';
const RationalExplorer: React.FC = () => {
const [cancelFactor, setCancelFactor] = useState(false); // If true, (x-2) is in numerator
// Base function f(x) = (x+1) / [(x-2)(x+1)] ? No simple case.
// Let's do: f(x) = (x+1) * [ (x-2) if cancel ] / [ (x-2) * (x-3) ]
// If cancel: f(x) = (x+1)/(x-3) with Hole at 2.
// If not cancel: f(x) = (x+1) / [(x-2)(x-3)] ... complex.
// Better example: f(x) = [numerator] / (x-2)
// Numerator options: (x-2) -> Hole. 1 -> VA.
const width = 300;
const height = 200;
const range = 6;
const scale = width / (range * 2);
const center = width / 2;
const toPx = (v: number, isY = false) => isY ? height/2 - v * scale : center + v * scale;
const generatePath = () => {
let d = "";
for (let x = -range; x <= range; x += 0.05) {
if (Math.abs(x - 2) < 0.1) continue; // Skip near discontinuity
let y = 0;
if (cancelFactor) {
// f(x) = (x-2) / (x-2) = 1
y = 1;
} else {
// f(x) = 1 / (x-2)
y = 1 / (x - 2);
}
if (Math.abs(y) > range) {
d += ` M `; // Break path
continue;
}
const px = toPx(x);
const py = toPx(y, true);
d += d.endsWith('M ') ? `${px} ${py}` : ` L ${px} ${py}`;
}
return d;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="mb-6 flex flex-col items-center">
<div className="text-xl font-mono font-bold bg-slate-50 px-6 py-3 rounded-lg border border-slate-200 mb-4">
f(x) = <div className="inline-block align-middle text-center mx-2">
<div className="border-b border-slate-800 pb-1 mb-1">{cancelFactor ? <span className="text-rose-600">(x-2)</span> : "1"}</div>
<div className="text-indigo-600">(x-2)</div>
</div>
</div>
<div className="flex bg-slate-100 p-1 rounded-lg">
<button
onClick={() => setCancelFactor(false)}
className={`px-4 py-2 text-sm font-bold rounded-md transition-all ${!cancelFactor ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
>
Different Factor (1)
</button>
<button
onClick={() => setCancelFactor(true)}
className={`px-4 py-2 text-sm font-bold rounded-md transition-all ${cancelFactor ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
>
Common Factor (x-2)
</button>
</div>
</div>
<div className="relative h-[200px] w-full bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox={`0 0 ${width} ${height}`}>
{/* Axes */}
<line x1="0" y1={height/2} x2={width} y2={height/2} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={height} stroke="#cbd5e1" strokeWidth="2" />
{/* Discontinuity at x=2 */}
<line x1={toPx(2)} y1={0} x2={toPx(2)} y2={height} stroke="#cbd5e1" strokeDasharray="4,4" />
{/* Graph */}
<path d={generatePath()} stroke="#8b5cf6" strokeWidth="3" fill="none" />
{/* Hole Visualization */}
{cancelFactor && (
<circle cx={toPx(2)} cy={toPx(1, true)} r="4" fill="white" stroke="#8b5cf6" strokeWidth="2" />
)}
</svg>
{/* Labels */}
<div className="absolute top-2 right-2 text-xs font-bold text-slate-400">x=2</div>
</div>
<div className="mt-4 p-4 rounded-lg bg-violet-50 border border-violet-100 text-sm text-violet-900 text-center">
{cancelFactor ? (
<span>
<strong>Hole:</strong> The factor (x-2) cancels out. The graph looks like y=1, but x=2 is undefined.
</span>
) : (
<span>
<strong>Vertical Asymptote:</strong> The factor (x-2) stays in the denominator. y approaches infinity near x=2.
</span>
)}
</div>
</div>
);
};
export default RationalExplorer;

View File

@ -0,0 +1,86 @@
import React, { useState } from 'react';
const RemainderTheoremWidget: React.FC = () => {
const [a, setA] = useState(2); // Dividing by (x - a)
// Polynomial P(x) = x^3 - 3x^2 - x + 3
const calculateP = (x: number) => Math.pow(x, 3) - 3 * Math.pow(x, 2) - x + 3;
const remainder = calculateP(a);
// Visualization
const width = 300;
const height = 200;
const rangeX = 4;
const rangeY = 10;
const scaleX = width / (rangeX * 2);
const scaleY = height / (rangeY * 2);
const centerX = width / 2;
const centerY = height / 2;
const toPx = (x: number, y: number) => ({
x: centerX + x * scaleX,
y: centerY - y * scaleY
});
const path = [];
for(let x = -rangeX; x <= rangeX; x+=0.1) {
const y = calculateP(x);
if(Math.abs(y) <= rangeY) {
path.push(toPx(x, y));
}
}
const pathD = `M ${path.map(p => `${p.x} ${p.y}`).join(' L ')}`;
const point = toPx(a, remainder);
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8 items-center">
<div className="flex-1 space-y-4">
<div className="p-4 bg-violet-50 rounded-xl border border-violet-100">
<p className="text-xs font-bold text-violet-400 uppercase mb-1">Polynomial</p>
<p className="font-mono font-bold text-lg text-slate-700">P(x) = x³ - 3x² - x + 3</p>
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase">Divisor (x - a)</label>
<div className="flex items-center gap-4 mt-1">
<span className="font-mono font-bold text-lg text-slate-700">x - </span>
<input
type="number" value={a} onChange={e => setA(parseFloat(e.target.value))}
className="w-16 p-2 border rounded text-center font-bold text-indigo-600"
/>
</div>
<input
type="range" min="-3" max="4" step="0.1" value={a}
onChange={e => setA(parseFloat(e.target.value))}
className="w-full mt-2 h-2 bg-slate-200 rounded-lg accent-indigo-600"
/>
</div>
<div className="p-4 bg-emerald-50 rounded-xl border border-emerald-100">
<p className="text-xs font-bold text-emerald-600 uppercase mb-1">Remainder Theorem Result</p>
<p className="text-sm text-slate-600 mb-2">Remainder of P(x) ÷ (x - {a}) is <strong>P({a})</strong></p>
<p className="font-mono font-bold text-2xl text-emerald-700">
R = {remainder.toFixed(2)}
</p>
</div>
</div>
<div className="flex-none">
<div className="relative w-[300px] h-[200px] border border-slate-200 rounded-xl bg-white overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 200">
<line x1="0" y1={centerY} x2={width} y2={centerY} stroke="#e2e8f0" strokeWidth="2" />
<line x1={centerX} y1="0" x2={centerX} y2={height} stroke="#e2e8f0" strokeWidth="2" />
<path d={pathD} fill="none" stroke="#8b5cf6" strokeWidth="3" />
<circle cx={point.x} cy={point.y} r="6" fill="#10b981" stroke="white" strokeWidth="2" />
<text x={point.x + 10} y={point.y} className="text-xs font-bold fill-slate-500">({a}, {remainder.toFixed(1)})</text>
</svg>
</div>
</div>
</div>
</div>
);
};
export default RemainderTheoremWidget;

View File

@ -0,0 +1,166 @@
import React, { useState, useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
interface Dot {
id: number;
type: 'red' | 'blue';
x: number;
y: number;
}
const SamplingVisualizerWidget: React.FC = () => {
const [population, setPopulation] = useState<Dot[]>([]);
const [sample, setSample] = useState<number[]>([]); // IDs of selected dots
const [mode, setMode] = useState<'none' | 'random' | 'biased'>('none');
// Generate population on mount
useEffect(() => {
const dots: Dot[] = [];
for (let i = 0; i < 100; i++) {
// Biased distribution: Reds cluster in top-right
const isClustered = i < 40; // 40% Red
let x, y;
if (isClustered) {
// Cluster Reds (Type A) in top right (50-100, 0-50)
x = 50 + Math.random() * 50;
y = Math.random() * 50;
} else {
// Blues scattered everywhere else, but mostly bottom/left
// To make it simple, just uniform random, but if we hit the "Red Zone" we retry or accept overlap
// Let's force Blues to be mostly Bottom or Left
if (Math.random() > 0.5) {
x = Math.random() * 50; // Left half
y = Math.random() * 100;
} else {
x = 50 + Math.random() * 50; // Right half
y = 50 + Math.random() * 50; // Bottom right
}
}
dots.push({
id: i,
type: isClustered ? 'red' : 'blue',
x,
y
});
}
setPopulation(dots);
}, []);
const takeRandomSample = () => {
const indices: number[] = [];
const pool = [...population];
// Pick 10 random
for (let i = 0; i < 10; i++) {
const idx = Math.floor(Math.random() * pool.length);
indices.push(pool[idx].id);
pool.splice(idx, 1);
}
setSample(indices);
setMode('random');
};
const takeBiasedSample = () => {
// Simulate "Convenience": Pick from top-right (the Red cluster)
// Find dots with x > 50 and y < 50
const candidates = population.filter(d => d.x > 50 && d.y < 50);
// Take 10 from there
const selected = candidates.slice(0, 10).map(d => d.id);
setSample(selected);
setMode('biased');
};
// Stats
const sampleDots = population.filter(d => sample.includes(d.id));
const sampleRedCount = sampleDots.filter(d => d.type === 'red').length;
const samplePercent = sampleDots.length > 0 ? (sampleRedCount / sampleDots.length) * 100 : 0;
const truePercent = 40; // Hardcoded based on generation logic
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="flex-1">
<div className="relative h-64 bg-slate-50 rounded-lg border border-slate-200 overflow-hidden mb-4">
{population.map(dot => {
const isSelected = sample.includes(dot.id);
const isRed = dot.type === 'red';
return (
<div
key={dot.id}
className={`absolute w-3 h-3 rounded-full transition-all duration-500 ${
isSelected
? 'ring-4 ring-offset-1 z-10 scale-125'
: 'opacity-40 scale-75'
} ${
isRed ? 'bg-rose-500' : 'bg-indigo-500'
} ${
isSelected && isRed ? 'ring-rose-200' : ''
} ${
isSelected && !isRed ? 'ring-indigo-200' : ''
}`}
style={{ left: `${dot.x}%`, top: `${dot.y}%` }}
/>
);
})}
{/* Labels for Bias Zone */}
<div className="absolute top-2 right-2 text-xs font-bold text-rose-300 uppercase pointer-events-none">
Cluster Zone
</div>
</div>
<p className="text-xs text-center text-slate-400">Population: 100 individuals (40% Red)</p>
</div>
<div className="w-full md:w-1/3 flex flex-col justify-center space-y-4">
<div className="space-y-2">
<button
onClick={takeRandomSample}
className="w-full py-3 px-4 bg-emerald-100 hover:bg-emerald-200 text-emerald-800 rounded-lg font-bold transition-colors flex items-center justify-center gap-2"
>
<RefreshCw className="w-4 h-4" /> Random Sample
</button>
<button
onClick={takeBiasedSample}
className="w-full py-3 px-4 bg-amber-100 hover:bg-amber-200 text-amber-800 rounded-lg font-bold transition-colors"
>
Convenience Sample
</button>
</div>
<div className={`p-4 rounded-xl border ${mode === 'none' ? 'border-slate-100 bg-slate-50' : 'bg-white border-slate-200'}`}>
<h4 className="text-xs font-bold text-slate-400 uppercase mb-2">Sample Result (n=10)</h4>
{mode === 'none' ? (
<p className="text-sm text-slate-500 italic">Select a method...</p>
) : (
<div>
<div className="flex justify-between items-end mb-1">
<span className="text-slate-600 font-medium">Estimated Red %</span>
<span className={`text-2xl font-bold ${Math.abs(samplePercent - truePercent) > 15 ? 'text-rose-600' : 'text-emerald-600'}`}>
{samplePercent}%
</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2 mb-2">
<div
className={`h-2 rounded-full transition-all duration-500 ${Math.abs(samplePercent - truePercent) > 15 ? 'bg-rose-500' : 'bg-emerald-500'}`}
style={{ width: `${samplePercent}%` }}
></div>
</div>
<p className="text-xs text-slate-400 text-right">True Population: 40%</p>
{mode === 'biased' && (
<p className="mt-2 text-xs font-bold text-amber-600 bg-amber-50 p-2 rounded">
Bias Alert: Selecting only from the "easy to reach" cluster overestimates the Red group.
</p>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default SamplingVisualizerWidget;

View File

@ -0,0 +1,133 @@
import React, { useState } from "react";
const ScaleFactorWidget: React.FC = () => {
const [k, setK] = useState(2);
const unit = 24; // Base size in px
const size = k * unit;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 w-full max-w-3xl">
<div className="mb-8">
<label className="flex justify-between font-bold text-slate-700 mb-2">
Scale Factor (k):{" "}
<span className="text-indigo-600 text-xl">{k}x</span>
</label>
<input
type="range"
min="1"
max="4"
step="1"
value={k}
onChange={(e) => setK(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
<div className="flex justify-between text-xs text-slate-400 mt-1 font-mono">
<span>1x</span>
<span>2x</span>
<span>3x</span>
<span>4x</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
{/* 1D: Length */}
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex flex-col">
<h4 className="text-sm font-bold uppercase text-slate-500 mb-4">
1D: Length
</h4>
<div className="flex-1 flex items-center justify-center min-h-[160px]">
<div
className="h-3 bg-indigo-500 rounded-full transition-all duration-500 shadow-sm"
style={{ width: `${k * 20}%` }}
></div>
</div>
<div className="mt-auto border-t border-slate-200 pt-2">
<p className="text-slate-500 text-xs">Multiplier</p>
<p className="text-2xl font-bold text-indigo-700">k = {k}</p>
</div>
</div>
{/* 2D: Area */}
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex flex-col">
<h4 className="text-sm font-bold uppercase text-slate-500 mb-4">
2D: Area
</h4>
<div className="flex-1 flex items-center justify-center relative min-h-[160px]">
{/* Base */}
<div className="w-8 h-8 border-2 border-emerald-500/30 absolute"></div>
{/* Scaled */}
<div
className="bg-emerald-500 shadow-lg transition-all duration-500 ease-out"
style={{ width: `${size}px`, height: `${size}px` }}
></div>
</div>
<div className="mt-auto border-t border-slate-200 pt-2">
<p className="text-slate-500 text-xs">Multiplier</p>
<p className="text-2xl font-bold text-emerald-700">k² = {k * k}</p>
</div>
</div>
{/* 3D: Volume */}
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex flex-col overflow-hidden">
<h4 className="text-sm font-bold uppercase text-slate-500 mb-4">
3D: Volume
</h4>
<div className="flex-1 flex items-center justify-center perspective-1000 min-h-[160px]">
<div
className="relative transform-style-3d transition-all duration-500 ease-out"
style={{
width: `${size}px`,
height: `${size}px`,
transform: "rotateX(-20deg) rotateY(-30deg)",
}}
>
{/* Faces */}
{[
// Front
{ trans: `translateZ(${size / 2}px)`, color: "bg-rose-500" },
// Back
{
trans: `rotateY(180deg) translateZ(${size / 2}px)`,
color: "bg-rose-600",
},
// Right
{
trans: `rotateY(90deg) translateZ(${size / 2}px)`,
color: "bg-rose-600",
},
// Left
{
trans: `rotateY(-90deg) translateZ(${size / 2}px)`,
color: "bg-rose-500",
},
// Top
{
trans: `rotateX(90deg) translateZ(${size / 2}px)`,
color: "bg-rose-400",
},
// Bottom
{
trans: `rotateX(-90deg) translateZ(${size / 2}px)`,
color: "bg-rose-700",
},
].map((face, i) => (
<div
key={i}
className={`absolute inset-0 border border-white/20 ${face.color} shadow-sm`}
style={{ transform: face.trans }}
/>
))}
</div>
</div>
<div className="mt-auto border-t border-slate-200 pt-2">
<p className="text-slate-500 text-xs">Multiplier</p>
<p className="text-2xl font-bold text-rose-700">k³ = {k * k * k}</p>
</div>
</div>
</div>
</div>
);
};
export default ScaleFactorWidget;

View File

@ -0,0 +1,125 @@
import React, { useState } from 'react';
interface DataPoint {
x: number;
y: number;
isOutlier?: boolean;
}
const ScatterplotInteractiveWidget: React.FC = () => {
const [showLine, setShowLine] = useState(false);
const [showResiduals, setShowResiduals] = useState(false);
const [hasOutlier, setHasOutlier] = useState(false);
// Base Data (approx linear y = 1.5x + 10)
const basePoints: DataPoint[] = [
{x: 1, y: 12}, {x: 2, y: 14}, {x: 3, y: 13}, {x: 4, y: 17},
{x: 5, y: 18}, {x: 6, y: 19}, {x: 7, y: 22}, {x: 8, y: 21}
];
const points: DataPoint[] = hasOutlier
? [...basePoints, {x: 7, y: 5, isOutlier: true}]
: basePoints;
// Simple Linear Regression Calculation
const n = points.length;
const sumX = points.reduce((a, p) => a + p.x, 0);
const sumY = points.reduce((a, p) => a + p.y, 0);
const sumXY = points.reduce((a, p) => a + p.x * p.y, 0);
const sumXX = points.reduce((a, p) => a + p.x * p.x, 0);
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
const predict = (x: number) => slope * x + intercept;
// Scales
const width = 400;
const height = 250;
const maxX = 9;
const maxY = 25;
const toX = (val: number) => (val / maxX) * width;
const toY = (val: number) => height - (val / maxY) * height;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-wrap gap-4 mb-6 justify-center">
<button
onClick={() => setShowLine(!showLine)}
className={`px-4 py-2 rounded-full font-bold text-sm transition-all ${showLine ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-600'}`}
>
{showLine ? 'Hide Line' : 'Show Line of Best Fit'}
</button>
<button
onClick={() => setShowResiduals(!showResiduals)}
className={`px-4 py-2 rounded-full font-bold text-sm transition-all ${showResiduals ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-600'}`}
>
{showResiduals ? 'Hide Residuals' : 'Show Residuals'}
</button>
<button
onClick={() => setHasOutlier(!hasOutlier)}
className={`px-4 py-2 rounded-full font-bold text-sm transition-all border ${hasOutlier ? 'bg-rose-100 text-rose-700 border-rose-300' : 'bg-white text-slate-600 border-slate-300'}`}
>
{hasOutlier ? 'Remove Outlier' : 'Add Outlier'}
</button>
</div>
<div className="relative border-b border-l border-slate-300 bg-slate-50 rounded-tr-lg mb-4 h-[250px]">
<svg width="100%" height="100%" viewBox={`0 0 ${width} ${height}`} className="overflow-visible">
{/* Line of Best Fit */}
{showLine && (
<line
x1={toX(0)} y1={toY(predict(0))}
x2={toX(maxX)} y2={toY(predict(maxX))}
stroke="#4f46e5" strokeWidth="2" strokeDasharray={hasOutlier ? "5,5" : ""}
/>
)}
{/* Residuals */}
{showLine && showResiduals && points.map((p, i) => (
<line
key={`res-${i}`}
x1={toX(p.x)} y1={toY(p.y)}
x2={toX(p.x)} y2={toY(predict(p.x))}
stroke={p.y > predict(p.x) ? "#10b981" : "#f43f5e"} strokeWidth="1.5" opacity="0.6"
/>
))}
{/* Points */}
{points.map((p, i) => (
<g key={i}>
<circle
cx={toX(p.x)} cy={toY(p.y)}
r={p.isOutlier ? 6 : 4}
fill={p.isOutlier ? "#f43f5e" : "#475569"}
stroke="white" strokeWidth="2"
className="transition-all duration-300"
/>
{p.isOutlier && (
<text x={toX(p.x)+10} y={toY(p.y)} className="text-xs font-bold fill-rose-600">Outlier</text>
)}
</g>
))}
</svg>
{/* Axes Labels */}
<div className="absolute -bottom-6 w-full text-center text-xs font-bold text-slate-400">Variable X</div>
<div className="absolute -left-8 top-1/2 -rotate-90 text-xs font-bold text-slate-400">Variable Y</div>
</div>
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200 flex justify-between items-center text-sm">
<div>
<span className="font-bold text-slate-500 block text-xs uppercase">Slope (m)</span>
<span className="font-mono font-bold text-indigo-700 text-lg">{slope.toFixed(2)}</span>
</div>
{hasOutlier && (
<div className="text-rose-600 font-bold bg-rose-50 px-3 py-1 rounded border border-rose-200">
Outlier pulls the line down!
</div>
)}
</div>
</div>
);
};
export default ScatterplotInteractiveWidget;

View File

@ -0,0 +1,300 @@
import React, { useState, useRef, useEffect } from 'react';
type Mode = 'AA' | 'SAS' | 'SSS';
const SimilarityTestsWidget: React.FC = () => {
const [mode, setMode] = useState<Mode>('AA');
const [scale, setScale] = useState(1.5);
// Store Vertex B's position relative to A (x offset, y height)
// A is at (40, 220). SVG Y is down.
const [vertexB, setVertexB] = useState({ x: 40, y: 100 });
const isDragging = useRef(false);
const svgRef = useRef<SVGSVGElement>(null);
// Triangle 1 (ABC) - Fixed base AC
const A = { x: 40, y: 220 };
const C = { x: 120, y: 220 }; // Base length = 80
// Calculate B in SVG coordinates based on state
// vertexB.y is the height (upwards), so we subtract from A.y
const B = { x: A.x + vertexB.x, y: A.y - vertexB.y };
// Calculate lengths and angles for T1
const dist = (p1: {x:number, y:number}, p2: {x:number, y:number}) => Math.sqrt((p1.x - p2.x)**2 + (p1.y - p2.y)**2);
const c1 = dist(A, B); // side c (opp C) - Side AB
const a1 = dist(B, C); // side a (opp A) - Side BC
const b1 = dist(A, C); // side b (opp B) - Side AC (Base)
const getAngle = (a: number, b: number, c: number) => {
return Math.acos((b**2 + c**2 - a**2) / (2 * b * c)) * (180 / Math.PI);
};
const angleA = getAngle(a1, b1, c1);
const angleB = getAngle(b1, a1, c1);
// const angleC = getAngle(c1, a1, b1);
// Triangle 2 (DEF) - Scaled version of ABC
// Start D with enough margin. Max width of T1 is ~100-140.
// Let's place D at x=240.
const D = { x: 240, y: 220 };
// F is horizontal from D by scaled base length
const F = { x: D.x + b1 * scale, y: D.y };
// E is scaled vector AB from D
const vecAB = { x: B.x - A.x, y: B.y - A.y };
const E = {
x: D.x + vecAB.x * scale,
y: D.y + vecAB.y * scale
};
// Interaction
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging.current || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Constraints for B relative to A
// Keep B within reasonable bounds to prevent breaking the layout
// Base is 40 to 120. B.x can range from 0 to 140?
const newX = x - A.x;
const height = A.y - y;
// Clamp
const clampedX = Math.max(-20, Math.min(100, newX));
const clampedH = Math.max(40, Math.min(180, height));
setVertexB({ x: clampedX, y: clampedH });
};
const angleColor = "#6366f1"; // Indigo
const sideColor = "#059669"; // Emerald
// Helper: draw filled angle wedge + labelled badge at a vertex
const angleC = 180 - angleA - angleB;
const renderAngle = (
vx: number, vy: number,
p1x: number, p1y: number,
p2x: number, p2y: number,
deg: number,
r = 28
) => {
const d1 = Math.atan2(p1y - vy, p1x - vx);
const d2 = Math.atan2(p2y - vy, p2x - vx);
const sx = vx + r * Math.cos(d1), sy = vy + r * Math.sin(d1);
const ex = vx + r * Math.cos(d2), ey = vy + r * Math.sin(d2);
const cross = (p1x - vx) * (p2y - vy) - (p1y - vy) * (p2x - vx);
const sweep = cross > 0 ? 1 : 0;
let diff = d2 - d1;
while (diff > Math.PI) diff -= 2 * Math.PI;
while (diff < -Math.PI) diff += 2 * Math.PI;
const mid = d1 + diff / 2;
const lr = r + 18;
const lx = vx + lr * Math.cos(mid), ly = vy + lr * Math.sin(mid);
const txt = `${Math.round(deg)}°`;
return (
<g>
<path d={`M ${vx} ${vy} L ${sx} ${sy} A ${r} ${r} 0 0 ${sweep} ${ex} ${ey} Z`} fill={angleColor} fillOpacity={0.12} stroke={angleColor} strokeWidth={2} />
<rect x={lx - 18} y={ly - 10} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={angleColor} strokeWidth={0.8} />
<text x={lx} y={ly + 5} textAnchor="middle" fill={angleColor} fontSize="13" fontWeight="bold" fontFamily="system-ui">{txt}</text>
</g>
);
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col sm:flex-row gap-4 justify-between items-center mb-6">
<div className="flex bg-slate-100 p-1 rounded-lg overflow-x-auto max-w-full">
{(['AA', 'SAS', 'SSS'] as Mode[]).map(m => (
<button
key={m}
onClick={() => setMode(m)}
className={`px-4 py-2 rounded-md text-sm font-bold transition-all whitespace-nowrap ${
mode === m
? 'bg-white text-rose-600 shadow-sm'
: 'text-slate-500 hover:text-rose-600'
}`}
>
{m}
</button>
))}
</div>
<div className="flex items-center gap-3 bg-slate-50 px-4 py-2 rounded-lg border border-slate-200">
<span className="text-xs font-bold text-slate-400 uppercase">Scale (k)</span>
<input
type="range" min="0.5" max="2.5" step="0.1"
value={scale}
onChange={e => setScale(parseFloat(e.target.value))}
className="w-24 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-rose-600"
/>
<span className="font-mono font-bold text-rose-600 text-sm w-12 text-right">{scale.toFixed(1)}x</span>
</div>
</div>
<div className="relative border border-slate-100 rounded-lg bg-slate-50 mb-6 overflow-hidden flex justify-center">
<svg
ref={svgRef}
width="550" height="280"
className="cursor-default select-none"
onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = false}
onMouseLeave={() => isDragging.current = false}
>
<defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#e2e8f0" strokeWidth="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{/* Triangle 1 (ABC) */}
<path d={`M ${A.x} ${A.y} L ${B.x} ${B.y} L ${C.x} ${C.y} Z`} fill="rgba(255, 255, 255, 0.8)" stroke="#334155" strokeWidth="2" />
{/* Vertices T1 */}
<circle cx={A.x} cy={A.y} r="4" fill="#334155" />
<text x={A.x - 16} y={A.y + 14} fontWeight="bold" fill="#334155" fontSize="14">A</text>
<circle cx={C.x} cy={C.y} r="4" fill="#334155" />
<text x={C.x + 8} y={C.y + 14} fontWeight="bold" fill="#334155" fontSize="14">C</text>
{/* Draggable B */}
<g onMouseDown={() => isDragging.current = true} className="cursor-grab active:cursor-grabbing">
<circle cx={B.x} cy={B.y} r="20" fill="transparent" /> {/* Hit area */}
<circle cx={B.x} cy={B.y} r="7" fill="#f43f5e" stroke="white" strokeWidth="2" />
<text x={B.x} y={B.y - 16} textAnchor="middle" fontWeight="bold" fill="#f43f5e" fontSize="14">B</text>
</g>
{/* Triangle 2 (DEF) */}
<path d={`M ${D.x} ${D.y} L ${E.x} ${E.y} L ${F.x} ${F.y} Z`} fill="rgba(255, 255, 255, 0.8)" stroke="#334155" strokeWidth="2" />
<circle cx={D.x} cy={D.y} r="4" fill="#334155" />
<text x={D.x - 16} y={D.y + 14} fontWeight="bold" fill="#334155" fontSize="14">D</text>
<circle cx={F.x} cy={F.y} r="4" fill="#334155" />
<text x={F.x + 8} y={F.y + 14} fontWeight="bold" fill="#334155" fontSize="14">F</text>
<circle cx={E.x} cy={E.y} r="4" fill="#334155" />
<text x={E.x} y={E.y - 16} textAnchor="middle" fontWeight="bold" fill="#334155" fontSize="14">E</text>
{/* Visual Overlays based on Mode */}
{mode === 'AA' && (
<>
{/* Angle A and D (base-left) */}
{renderAngle(A.x, A.y, C.x, C.y, B.x, B.y, angleA)}
{renderAngle(D.x, D.y, F.x, F.y, E.x, E.y, angleA)}
{/* Angle B and E (apex) */}
{renderAngle(B.x, B.y, A.x, A.y, C.x, C.y, angleB)}
{renderAngle(E.x, E.y, D.x, D.y, F.x, F.y, angleB)}
</>
)}
{mode === 'SAS' && (
<>
{/* Included Angle A and D */}
{renderAngle(A.x, A.y, C.x, C.y, B.x, B.y, angleA)}
{renderAngle(D.x, D.y, F.x, F.y, E.x, E.y, angleA)}
{/* Side labels with background badges */}
{/* Side AB / DE */}
<rect x={(A.x + B.x)/2 - 24} y={(A.y + B.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(A.x + B.x)/2 - 6} y={(A.y + B.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1)}</text>
<rect x={(D.x + E.x)/2 - 24} y={(D.y + E.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(D.x + E.x)/2 - 6} y={(D.y + E.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1 * scale)}</text>
{/* Side AC / DF */}
<rect x={(A.x + C.x)/2 - 18} y={A.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(A.x + C.x)/2} y={A.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1)}</text>
<rect x={(D.x + F.x)/2 - 18} y={D.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(D.x + F.x)/2} y={D.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1 * scale)}</text>
</>
)}
{mode === 'SSS' && (
<>
{/* Side AB / DE */}
<rect x={(A.x + B.x)/2 - 24} y={(A.y + B.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(A.x + B.x)/2 - 6} y={(A.y + B.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1)}</text>
<rect x={(D.x + E.x)/2 - 24} y={(D.y + E.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(D.x + E.x)/2 - 6} y={(D.y + E.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1 * scale)}</text>
{/* Side AC / DF */}
<rect x={(A.x + C.x)/2 - 18} y={A.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(A.x + C.x)/2} y={A.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1)}</text>
<rect x={(D.x + F.x)/2 - 18} y={D.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(D.x + F.x)/2} y={D.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1 * scale)}</text>
{/* Side BC / EF */}
<rect x={(B.x + C.x)/2 + 2} y={(B.y + C.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(B.x + C.x)/2 + 20} y={(B.y + C.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(a1)}</text>
<rect x={(E.x + F.x)/2 + 2} y={(E.y + F.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(E.x + F.x)/2 + 20} y={(E.y + F.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(a1 * scale)}</text>
</>
)}
</svg>
</div>
<div className="bg-rose-50 border border-rose-100 rounded-lg p-4 text-rose-900">
<h4 className="font-bold mb-2 flex items-center gap-2 text-lg">
<span className="w-3 h-3 rounded-full bg-rose-500"></span>
{mode === 'AA' && "Angle-Angle (AA) Similarity"}
{mode === 'SAS' && "Side-Angle-Side (SAS) Similarity"}
{mode === 'SSS' && "Side-Side-Side (SSS) Similarity"}
</h4>
<div className="text-sm font-mono space-y-2">
{mode === 'AA' && (
<>
<p className="leading-relaxed">If two angles of one triangle are equal to two angles of another triangle, then the triangles are similar.</p>
<div className="flex gap-8 mt-2">
<div>
<span className="text-xs font-bold text-rose-400 uppercase">First Angle</span>
<p className="font-bold text-lg">A = D = {Math.round(angleA)}°</p>
</div>
<div>
<span className="text-xs font-bold text-rose-400 uppercase">Second Angle</span>
<p className="font-bold text-lg">B = E = {Math.round(angleB)}°</p>
</div>
</div>
</>
)}
{mode === 'SAS' && (
<>
<p className="leading-relaxed">If two sides are proportional and the included angles are equal, the triangles are similar.</p>
<div className="grid grid-cols-2 gap-4 mt-2">
<div className="bg-white p-2 rounded border border-rose-100">
<p className="text-xs text-rose-500 font-bold uppercase">Side Ratio (c)</p>
<p>DE / AB = {(c1*scale).toFixed(0)} / {c1.toFixed(0)} = <strong>{scale.toFixed(1)}</strong></p>
</div>
<div className="bg-white p-2 rounded border border-rose-100">
<p className="text-xs text-rose-500 font-bold uppercase">Side Ratio (b)</p>
<p>DF / AC = {(b1*scale).toFixed(0)} / {b1.toFixed(0)} = <strong>{scale.toFixed(1)}</strong></p>
</div>
</div>
<p className="mt-2 font-bold text-rose-800">Included Angle: A = D = {Math.round(angleA)}°</p>
</>
)}
{mode === 'SSS' && (
<>
<p className="leading-relaxed">If the corresponding sides of two triangles are proportional, then the triangles are similar.</p>
<p className="bg-white inline-block px-2 py-1 rounded border border-rose-100 font-bold text-rose-600 mb-2">Scale Factor k = {scale.toFixed(1)}</p>
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div className="bg-white p-1 rounded">
DE/AB = {scale.toFixed(1)}
</div>
<div className="bg-white p-1 rounded">
EF/BC = {scale.toFixed(1)}
</div>
<div className="bg-white p-1 rounded">
DF/AC = {scale.toFixed(1)}
</div>
</div>
</>
)}
</div>
<p className="text-xs text-rose-400 mt-4 border-t border-rose-100 pt-2">
Drag vertex <strong>B</strong> on the first triangle to explore different shapes!
</p>
</div>
</div>
);
};
export default SimilarityTestsWidget;

View File

@ -0,0 +1,119 @@
import React, { useState, useRef, useEffect } from 'react';
const SimilarityWidget: React.FC = () => {
const [ratio, setRatio] = useState(0.5); // Position of D along AB (0 to 1)
const isDragging = useRef(false);
const svgRef = useRef<SVGSVGElement>(null);
// Triangle Vertices
const A = { x: 200, y: 50 };
const B = { x: 50, y: 300 };
const C = { x: 350, y: 300 };
// Calculate D and E based on ratio
const D = {
x: A.x + (B.x - A.x) * ratio,
y: A.y + (B.y - A.y) * ratio
};
const E = {
x: A.x + (C.x - A.x) * ratio,
y: A.y + (C.y - A.y) * ratio
};
const handleInteraction = (clientY: number) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const y = clientY - rect.top;
// Clamp y between A.y and B.y
const clampedY = Math.max(A.y, Math.min(B.y, y));
// Calculate new ratio
const newRatio = (clampedY - A.y) / (B.y - A.y);
setRatio(Math.max(0.1, Math.min(0.9, newRatio))); // clamp to avoid degenerate
};
const handleMouseDown = (e: React.MouseEvent) => {
isDragging.current = true;
handleInteraction(e.clientY);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging.current) {
handleInteraction(e.clientY);
}
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col md:flex-row items-center gap-8">
<svg
ref={svgRef}
width="400"
height="350"
className="select-none cursor-ns-resize"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = false}
onMouseLeave={() => isDragging.current = false}
>
{/* Main Triangle */}
<path d={`M ${A.x} ${A.y} L ${B.x} ${B.y} L ${C.x} ${C.y} Z`} fill="none" stroke="#e2e8f0" strokeWidth="2" />
{/* Filled Top Triangle (Similar) */}
<path d={`M ${A.x} ${A.y} L ${D.x} ${D.y} L ${E.x} ${E.y} Z`} fill="rgba(244, 63, 94, 0.1)" stroke="none" />
{/* Parallel Line DE */}
<line x1={D.x} y1={D.y} x2={E.x} y2={E.y} stroke="#e11d48" strokeWidth="3" />
{/* Labels */}
<text x={A.x} y={A.y - 10} textAnchor="middle" fontWeight="bold" fill="#64748b">A</text>
<text x={B.x - 10} y={B.y} textAnchor="end" fontWeight="bold" fill="#64748b">B</text>
<text x={C.x + 10} y={C.y} textAnchor="start" fontWeight="bold" fill="#64748b">C</text>
<text x={D.x - 10} y={D.y} textAnchor="end" fontWeight="bold" fill="#e11d48">D</text>
<text x={E.x + 10} y={E.y} textAnchor="start" fontWeight="bold" fill="#e11d48">E</text>
{/* Drag Handle */}
<circle cx={D.x} cy={D.y} r="6" fill="#e11d48" stroke="white" strokeWidth="2" />
<circle cx={E.x} cy={E.y} r="6" fill="#e11d48" stroke="white" strokeWidth="2" />
</svg>
<div className="flex-1 w-full">
<h3 className="text-lg font-bold text-slate-800 mb-4">Triangle Proportionality</h3>
<p className="text-sm text-slate-500 mb-6">Drag the red line. Because DE || BC, the small triangle is similar to the large triangle.</p>
<div className="space-y-4">
<div className="bg-slate-50 p-4 rounded-lg border-l-4 border-rose-500">
<p className="text-xs font-bold text-slate-400 uppercase mb-1">Scale Factor</p>
<p className="font-mono text-xl text-rose-700">{ratio.toFixed(2)}</p>
</div>
<div className="bg-white border border-slate-200 p-4 rounded-lg shadow-sm">
<p className="font-mono text-sm mb-2 text-slate-600">Corresponding Sides Ratio:</p>
<div className="flex items-center justify-between font-mono font-bold text-lg">
<div className="text-rose-600">AD / AB</div>
<div className="text-slate-400">=</div>
<div className="text-rose-600">AE / AC</div>
<div className="text-slate-400">=</div>
<div className="text-rose-600">{ratio.toFixed(2)}</div>
</div>
</div>
<div className="bg-white border border-slate-200 p-4 rounded-lg shadow-sm">
<p className="font-mono text-sm mb-2 text-slate-600">Area Ratio (k²):</p>
<div className="flex items-center justify-between font-mono font-bold text-lg">
<div className="text-rose-600">Area(ADE)</div>
<div className="text-slate-400">/</div>
<div className="text-slate-600">Area(ABC)</div>
<div className="text-slate-400">=</div>
<div className="text-rose-600">{(ratio * ratio).toFixed(2)}</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default SimilarityWidget;

View File

@ -0,0 +1,93 @@
import React, { useState } from 'react';
const SlopeInterceptWidget: React.FC = () => {
const [m, setM] = useState(2);
const [b, setB] = useState(1);
// Visualization config
const range = 10;
const scale = 25; // px per unit
const center = 150;
const toPx = (val: number, isY = false) => isY ? center - val * scale : center + val * scale;
// Points for triangle
const p1 = { x: 0, y: b };
const p2 = { x: 1, y: m * 1 + b };
// Triangle vertex (1, b)
const p3 = { x: 1, y: b };
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div className="p-4 bg-slate-50 rounded-xl border border-slate-200 text-center">
<div className="text-sm text-slate-500 font-bold uppercase mb-1">Equation</div>
<div className="text-2xl font-mono font-bold text-slate-800">
y = <span className="text-blue-600">{m}</span>x + <span className="text-rose-600">{b}</span>
</div>
</div>
<div>
<label className="text-xs font-bold text-blue-600 uppercase">Slope (m) = {m}</label>
<input
type="range" min="-5" max="5" step="0.5"
value={m} onChange={e => setM(parseFloat(e.target.value))}
className="w-full h-2 bg-blue-100 rounded-lg appearance-none cursor-pointer accent-blue-600 mt-2"
/>
<p className="text-xs text-slate-400 mt-1">Rate of Change (Rise / Run)</p>
</div>
<div>
<label className="text-xs font-bold text-rose-600 uppercase">Y-Intercept (b) = {b}</label>
<input
type="range" min="-5" max="5" step="1"
value={b} onChange={e => setB(parseFloat(e.target.value))}
className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600 mt-2"
/>
<p className="text-xs text-slate-400 mt-1">Starting Value (when x=0)</p>
</div>
</div>
<div className="w-full md:flex-1 h-[300px] bg-white border border-slate-200 rounded-xl relative overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300" className="absolute top-0 left-0">
<defs>
<pattern id="si-grid" width={scale} height={scale} patternUnits="userSpaceOnUse">
<path d={`M ${scale} 0 L 0 0 0 ${scale}`} fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#si-grid)" />
{/* Axes */}
<line x1="0" y1={center} x2="300" y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2="300" stroke="#cbd5e1" strokeWidth="2" />
{/* The Line */}
<line
x1={toPx(-range)} y1={toPx(m * -range + b, true)}
x2={toPx(range)} y2={toPx(m * range + b, true)}
stroke="#1e293b" strokeWidth="3"
/>
{/* Slope Triangle (between x=0 and x=1) */}
<path
d={`M ${toPx(p1.x)} ${toPx(p1.y, true)} L ${toPx(p3.x)} ${toPx(p3.y, true)} L ${toPx(p2.x)} ${toPx(p2.y, true)} Z`}
fill="rgba(37, 99, 235, 0.1)" stroke="#2563eb" strokeWidth="1" strokeDasharray="4,2"
/>
{/* Intercept Point */}
<circle cx={toPx(0)} cy={toPx(b, true)} r="5" fill="#e11d48" stroke="white" strokeWidth="2" />
<text x={toPx(0) + 10} y={toPx(b, true)} className="text-xs font-bold fill-rose-600">b={b}</text>
{/* Rise/Run Labels */}
<text x={toPx(0.5)} y={toPx(b, true) + (m>0 ? 15 : -10)} textAnchor="middle" className="text-[10px] font-bold fill-blue-400">Run: 1</text>
<text x={toPx(1) + 5} y={toPx(b + m/2, true)} className="text-[10px] font-bold fill-blue-600">Rise: {m}</text>
</svg>
</div>
</div>
</div>
);
};
export default SlopeInterceptWidget;

View File

@ -0,0 +1,121 @@
import React, { useState } from 'react';
const StandardFormWidget: React.FC = () => {
const [A, setA] = useState(2);
const [B, setB] = useState(3);
const [C, setC] = useState(12);
// Intercepts
const xInt = A !== 0 ? C / A : null;
const yInt = B !== 0 ? C / B : null;
// Vis
const range = 15;
const scale = 15;
const center = 150;
const toPx = (val: number, isY = false) => isY ? center - val * scale : center + val * scale;
// Line points
// If B!=0, y = (C - Ax)/B. If A!=0, x = (C - By)/A.
let p1, p2;
if (B !== 0) {
p1 = { x: -range, y: (C - A * -range) / B };
p2 = { x: range, y: (C - A * range) / B };
} else {
// Vertical line x = C/A
p1 = { x: C/A, y: -range };
p2 = { x: C/A, y: range };
}
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col gap-6">
<div className="flex justify-center mb-4">
<div className="px-6 py-3 bg-slate-800 text-white rounded-xl shadow-md text-2xl font-mono font-bold tracking-wider">
{A}x + {B}y = {C}
</div>
</div>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="bg-indigo-50 p-3 rounded-lg border border-indigo-100">
<label className="text-xs font-bold text-indigo-800 uppercase block mb-1">A (x-coeff)</label>
<input type="number" value={A} onChange={e => setA(Number(e.target.value))} className="w-full p-1 border rounded text-center font-bold"/>
</div>
<div className="bg-emerald-50 p-3 rounded-lg border border-emerald-100">
<label className="text-xs font-bold text-emerald-800 uppercase block mb-1">B (y-coeff)</label>
<input type="number" value={B} onChange={e => setB(Number(e.target.value))} className="w-full p-1 border rounded text-center font-bold"/>
</div>
<div className="bg-amber-50 p-3 rounded-lg border border-amber-100">
<label className="text-xs font-bold text-amber-800 uppercase block mb-1">C (constant)</label>
<input type="number" value={C} onChange={e => setC(Number(e.target.value))} className="w-full p-1 border rounded text-center font-bold"/>
</div>
</div>
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-4">
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<h4 className="font-bold text-slate-700 mb-2 border-b pb-1">Cover-Up Method</h4>
<div className="mb-4">
<p className="text-xs text-slate-500 uppercase font-bold mb-1">Find X-Intercept (Set y=0)</p>
<div className="font-mono text-sm bg-white p-2 rounded border border-slate-200 text-slate-600">
{A}x = {C} <br/>
x = {C} / {A} <br/>
<span className="text-indigo-600 font-bold">x = {xInt !== null ? xInt.toFixed(2) : 'Undefined'}</span>
</div>
</div>
<div>
<p className="text-xs text-slate-500 uppercase font-bold mb-1">Find Y-Intercept (Set x=0)</p>
<div className="font-mono text-sm bg-white p-2 rounded border border-slate-200 text-slate-600">
{B}y = {C} <br/>
y = {C} / {B} <br/>
<span className="text-emerald-600 font-bold">y = {yInt !== null ? yInt.toFixed(2) : 'Undefined'}</span>
</div>
</div>
</div>
</div>
<div className="w-full md:flex-1 h-[300px] border border-slate-200 rounded-lg relative bg-white overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300" className="absolute">
{/* Grid */}
<defs>
<pattern id="sf-grid" width={scale} height={scale} patternUnits="userSpaceOnUse">
<path d={`M ${scale} 0 L 0 0 0 ${scale}`} fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#sf-grid)" />
{/* Axes */}
<line x1="0" y1={center} x2="300" y2={center} stroke="#94a3b8" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2="300" stroke="#94a3b8" strokeWidth="2" />
{/* Line */}
<line
x1={toPx(p1.x)} y1={toPx(p1.y, true)}
x2={toPx(p2.x)} y2={toPx(p2.y, true)}
stroke="#0f172a" strokeWidth="3"
/>
{/* Intercepts */}
{xInt !== null && Math.abs(xInt) <= range && (
<g>
<circle cx={toPx(xInt)} cy={center} r="5" fill="#4f46e5" stroke="white" strokeWidth="2"/>
<text x={toPx(xInt)} y={center + 20} textAnchor="middle" className="text-xs font-bold fill-indigo-700">{xInt.toFixed(1)}</text>
</g>
)}
{yInt !== null && Math.abs(yInt) <= range && (
<g>
<circle cx={center} cy={toPx(yInt, true)} r="5" fill="#10b981" stroke="white" strokeWidth="2"/>
<text x={center + 10} y={toPx(yInt, true)} dominantBaseline="middle" className="text-xs font-bold fill-emerald-700">{yInt.toFixed(1)}</text>
</g>
)}
</svg>
</div>
</div>
</div>
</div>
);
};
export default StandardFormWidget;

View File

@ -0,0 +1,88 @@
import React, { useState } from 'react';
import { ArrowRight, Users, FlaskConical } from 'lucide-react';
const StudyDesignWidget: React.FC = () => {
const [isRandomSample, setIsRandomSample] = useState(false);
const [isRandomAssign, setIsRandomAssign] = useState(false);
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{/* Sampling */}
<div className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${isRandomSample ? 'border-amber-500 bg-amber-50' : 'border-slate-200 hover:border-amber-200'}`}
onClick={() => setIsRandomSample(!isRandomSample)}>
<div className="flex items-center gap-3 mb-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${isRandomSample ? 'bg-amber-500 text-white' : 'bg-slate-200'}`}>
<Users className="w-4 h-4" />
</div>
<h4 className="font-bold text-slate-800">Selection Method</h4>
</div>
<p className="text-sm text-slate-600 mb-3">How were participants chosen?</p>
<div className="flex justify-between items-center bg-white p-2 rounded border border-slate-100">
<span className="text-xs font-bold uppercase text-slate-400">Current:</span>
<span className={`font-bold ${isRandomSample ? 'text-amber-600' : 'text-slate-500'}`}>
{isRandomSample ? "Random Sample" : "Convenience / Voluntary"}
</span>
</div>
</div>
{/* Assignment */}
<div className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${isRandomAssign ? 'border-amber-500 bg-amber-50' : 'border-slate-200 hover:border-amber-200'}`}
onClick={() => setIsRandomAssign(!isRandomAssign)}>
<div className="flex items-center gap-3 mb-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${isRandomAssign ? 'bg-amber-500 text-white' : 'bg-slate-200'}`}>
<FlaskConical className="w-4 h-4" />
</div>
<h4 className="font-bold text-slate-800">Assignment Method</h4>
</div>
<p className="text-sm text-slate-600 mb-3">How were treatments assigned?</p>
<div className="flex justify-between items-center bg-white p-2 rounded border border-slate-100">
<span className="text-xs font-bold uppercase text-slate-400">Current:</span>
<span className={`font-bold ${isRandomAssign ? 'text-amber-600' : 'text-slate-500'}`}>
{isRandomAssign ? "Random Assignment" : "Observational / None"}
</span>
</div>
</div>
</div>
{/* Conclusions */}
<div className="bg-slate-900 text-white p-6 rounded-xl relative overflow-hidden">
<div className="relative z-10 grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<h4 className="text-slate-400 text-xs font-bold uppercase mb-1">Generalization</h4>
<p className="text-lg font-bold mb-2">
Can apply to Population?
</p>
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full font-bold text-sm ${isRandomSample ? 'bg-green-500 text-white' : 'bg-red-500 text-white'}`}>
{isRandomSample ? <ArrowRight className="w-4 h-4" /> : null}
{isRandomSample ? "YES" : "NO (Sample Only)"}
</div>
<p className="text-xs text-slate-400 mt-2">
{isRandomSample
? "Random sampling reduces bias, allowing results to represent the whole population."
: "Without random sampling, results may be biased and only apply to the specific people studied."}
</p>
</div>
<div>
<h4 className="text-slate-400 text-xs font-bold uppercase mb-1">Causation</h4>
<p className="text-lg font-bold mb-2">
Can prove Cause & Effect?
</p>
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full font-bold text-sm ${isRandomAssign ? 'bg-green-500 text-white' : 'bg-red-500 text-white'}`}>
{isRandomAssign ? <ArrowRight className="w-4 h-4" /> : null}
{isRandomAssign ? "YES" : "NO (Association Only)"}
</div>
<p className="text-xs text-slate-400 mt-2">
{isRandomAssign
? "Random assignment creates comparable groups, so differences can be attributed to the treatment."
: "Without random assignment (experiment), confounding variables might explain the difference."}
</p>
</div>
</div>
</div>
</div>
);
};
export default StudyDesignWidget;

View File

@ -0,0 +1,108 @@
import React, { useState } from 'react';
const SystemVisualizerWidget: React.FC = () => {
// Line 1: y = m1x + b1
const [m1, setM1] = useState(1);
const [b1, setB1] = useState(2);
// Line 2: y = m2x + b2
const [m2, setM2] = useState(-1);
const [b2, setB2] = useState(6);
// Visualization params
const range = 10;
const scale = 20;
const size = 300;
const center = size / 2;
const toPx = (v: number, isY = false) => {
return isY ? center - v * scale : center + v * scale;
};
// Logic
let intersectX = 0;
let intersectY = 0;
let solutionType = 'one'; // 'one', 'none', 'inf'
if (m1 === m2) {
if (b1 === b2) solutionType = 'inf';
else solutionType = 'none';
} else {
intersectX = (b2 - b1) / (m1 - m2);
intersectY = m1 * intersectX + b1;
}
const getLinePath = (m: number, b: number) => {
const x1 = -range;
const y1 = m * x1 + b;
const x2 = range;
const y2 = m * x2 + b;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
{/* Line 1 */}
<div className="p-4 bg-indigo-50 border border-indigo-100 rounded-lg">
<div className="flex justify-between items-center mb-2">
<span className="font-bold text-indigo-800 text-sm">Line 1: y = {m1}x + {b1}</span>
</div>
<div className="space-y-2">
<input type="range" min="-4" max="4" step="0.5" value={m1} onChange={e => setM1(parseFloat(e.target.value))} className="w-full h-1 bg-indigo-200 rounded accent-indigo-600"/>
<input type="range" min="-8" max="8" step="1" value={b1} onChange={e => setB1(parseFloat(e.target.value))} className="w-full h-1 bg-indigo-200 rounded accent-indigo-600"/>
</div>
</div>
{/* Line 2 */}
<div className="p-4 bg-rose-50 border border-rose-100 rounded-lg">
<div className="flex justify-between items-center mb-2">
<span className="font-bold text-rose-800 text-sm">Line 2: y = {m2}x + {b2}</span>
</div>
<div className="space-y-2">
<input type="range" min="-4" max="4" step="0.5" value={m2} onChange={e => setM2(parseFloat(e.target.value))} className="w-full h-1 bg-rose-200 rounded accent-rose-600"/>
<input type="range" min="-8" max="8" step="1" value={b2} onChange={e => setB2(parseFloat(e.target.value))} className="w-full h-1 bg-rose-200 rounded accent-rose-600"/>
</div>
</div>
{/* Result */}
<div className={`p-4 rounded-lg text-center font-bold border-2 ${
solutionType === 'one' ? 'bg-emerald-50 border-emerald-200 text-emerald-800' :
solutionType === 'none' ? 'bg-slate-50 border-slate-200 text-slate-500' :
'bg-amber-50 border-amber-200 text-amber-800'
}`}>
{solutionType === 'one' && `Intersection: (${intersectX.toFixed(1)}, ${intersectY.toFixed(1)})`}
{solutionType === 'none' && "No Solution (Parallel Lines)"}
{solutionType === 'inf' && "Infinite Solutions (Same Line)"}
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="300" height="300" viewBox="0 0 300 300">
<defs>
<pattern id="grid-sys" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid-sys)" />
<line x1="0" y1={center} x2={size} y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={size} stroke="#cbd5e1" strokeWidth="2" />
<path d={getLinePath(m1, b1)} stroke="#4f46e5" strokeWidth="3" />
<path d={getLinePath(m2, b2)} stroke="#e11d48" strokeWidth="3" strokeDasharray={solutionType === 'inf' ? "5,5" : ""} />
{solutionType === 'one' && (
<circle cx={toPx(intersectX)} cy={toPx(intersectY, true)} r="5" fill="#10b981" stroke="white" strokeWidth="2" />
)}
</svg>
</div>
</div>
</div>
</div>
);
};
export default SystemVisualizerWidget;

View File

@ -0,0 +1,179 @@
import React, { useState, useRef } from 'react';
const TangentPropertiesWidget: React.FC = () => {
const [pointP, setPointP] = useState({ x: 350, y: 150 });
const isDragging = useRef(false);
const svgRef = useRef<SVGSVGElement>(null);
const center = { x: 150, y: 150 };
const radius = 60;
// Interaction
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging.current || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Constrain P to be outside the circle (distance > radius)
const dx = x - center.x;
const dy = y - center.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Min distance to keep things looking nice (radius + padding)
if (dist < radius + 20) {
const angle = Math.atan2(dy, dx);
setPointP({
x: center.x + (radius + 20) * Math.cos(angle),
y: center.y + (radius + 20) * Math.sin(angle)
});
} else {
setPointP({ x, y });
}
};
// Calculations
const dx = pointP.x - center.x;
const dy = pointP.y - center.y;
const distPO = Math.sqrt(dx * dx + dy * dy);
const anglePO = Math.atan2(dy, dx);
// Angle offset to tangent points
// cos(theta) = Adjacent / Hypotenuse = radius / distPO
const theta = Math.acos(radius / distPO);
const t1Angle = anglePO - theta;
const t2Angle = anglePO + theta;
const T1 = {
x: center.x + radius * Math.cos(t1Angle),
y: center.y + radius * Math.sin(t1Angle)
};
const T2 = {
x: center.x + radius * Math.cos(t2Angle),
y: center.y + radius * Math.sin(t2Angle)
};
const tangentLength = Math.sqrt(distPO * distPO - radius * radius);
// Right Angle Markers
const markerSize = 10;
const getRightAnglePath = (p: {x:number, y:number}, angle: number) => {
// angle is the angle of the radius. We need to go inwards and perpendicular
// Actually simpler: Vector from Center to T, and Vector T to P are perp.
// Let's just draw a small square aligned with radius
const rAngle = angle;
// Point on radius
const p1 = { x: p.x - markerSize * Math.cos(rAngle), y: p.y - markerSize * Math.sin(rAngle) };
// Point on tangent (towards P)
// Tangent is perpendicular to radius.
// We need to know if we go clockwise or counter clockwise.
// Vector T->P
const tpAngle = Math.atan2(pointP.y - p.y, pointP.x - p.x);
const p2 = { x: p.x + markerSize * Math.cos(tpAngle), y: p.y + markerSize * Math.sin(tpAngle) };
// Corner
const p3 = { x: p1.x + markerSize * Math.cos(tpAngle), y: p1.y + markerSize * Math.sin(tpAngle) };
return `M ${p1.x} ${p1.y} L ${p3.x} ${p3.y} L ${p2.x} ${p2.y}`;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col md:flex-row gap-8 items-center">
<div className="relative">
<svg
ref={svgRef}
width="400" height="300"
className="select-none cursor-default bg-slate-50 rounded-lg border border-slate-100"
onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = false}
onMouseLeave={() => isDragging.current = false}
>
{/* Circle */}
<circle cx={center.x} cy={center.y} r={radius} fill="white" stroke="#94a3b8" strokeWidth="2" />
<circle cx={center.x} cy={center.y} r="3" fill="#64748b" />
<text x={center.x - 15} y={center.y + 5} className="text-xs font-bold fill-slate-400">O</text>
{/* Radii */}
<line x1={center.x} y1={center.y} x2={T1.x} y2={T1.y} stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4,4" />
<line x1={center.x} y1={center.y} x2={T2.x} y2={T2.y} stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4,4" />
{/* Tangents */}
<line x1={pointP.x} y1={pointP.y} x2={T1.x} y2={T1.y} stroke="#7c3aed" strokeWidth="3" />
<line x1={pointP.x} y1={pointP.y} x2={T2.x} y2={T2.y} stroke="#7c3aed" strokeWidth="3" />
{/* Right Angle Markers */}
<path d={getRightAnglePath(T1, t1Angle)} stroke="#64748b" fill="transparent" strokeWidth="1" />
<path d={getRightAnglePath(T2, t2Angle)} stroke="#64748b" fill="transparent" strokeWidth="1" />
{/* Points */}
<circle cx={T1.x} cy={T1.y} r="5" fill="#7c3aed" />
<text x={T1.x + (T1.x - center.x)*0.2} y={T1.y + (T1.y - center.y)*0.2} className="text-xs font-bold fill-violet-700">A</text>
<circle cx={T2.x} cy={T2.y} r="5" fill="#7c3aed" />
<text x={T2.x + (T2.x - center.x)*0.2} y={T2.y + (T2.y - center.y)*0.2} className="text-xs font-bold fill-violet-700">B</text>
{/* External Point P */}
<g
onMouseDown={() => isDragging.current = true}
className="cursor-grab active:cursor-grabbing"
>
<circle cx={pointP.x} cy={pointP.y} r="15" fill="transparent" />
<circle cx={pointP.x} cy={pointP.y} r="6" fill="#f43f5e" stroke="white" strokeWidth="2" />
<text x={pointP.x + 10} y={pointP.y} className="text-sm font-bold fill-rose-600">P</text>
</g>
{/* Length Labels (Midpoints) */}
<rect
x={(pointP.x + T1.x)/2 - 15} y={(pointP.y + T1.y)/2 - 10}
width="30" height="20" rx="4" fill="white" stroke="#e2e8f0"
/>
<text x={(pointP.x + T1.x)/2} y={(pointP.y + T1.y)/2 + 4} textAnchor="middle" className="text-xs font-bold fill-violet-600">
{Math.round(tangentLength)}
</text>
<rect
x={(pointP.x + T2.x)/2 - 15} y={(pointP.y + T2.y)/2 - 10}
width="30" height="20" rx="4" fill="white" stroke="#e2e8f0"
/>
<text x={(pointP.x + T2.x)/2} y={(pointP.y + T2.y)/2 + 4} textAnchor="middle" className="text-xs font-bold fill-violet-600">
{Math.round(tangentLength)}
</text>
</svg>
</div>
<div className="flex-1 space-y-6">
<div className="bg-violet-50 p-4 rounded-xl border border-violet-100">
<h4 className="font-bold text-violet-900 mb-2 flex items-center gap-2">
<span className="bg-violet-200 text-xs px-2 py-0.5 rounded-full text-violet-800">Rule 1</span>
Equal Tangents
</h4>
<p className="text-sm text-violet-800 mb-2">
Tangents from the same external point are always congruent.
</p>
<p className="font-mono text-lg font-bold text-violet-600 bg-white p-2 rounded border border-violet-100 text-center">
PA = PB = {Math.round(tangentLength)}
</p>
</div>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<h4 className="font-bold text-slate-700 mb-2 flex items-center gap-2">
<span className="bg-slate-200 text-xs px-2 py-0.5 rounded-full text-slate-600">Rule 2</span>
Perpendicular Radius
</h4>
<p className="text-sm text-slate-600">
The radius to the point of tangency is always perpendicular to the tangent line.
</p>
<div className="flex gap-4 mt-2 justify-center">
<span className="text-xs font-bold bg-white px-2 py-1 rounded border border-slate-200">OAP = 90°</span>
<span className="text-xs font-bold bg-white px-2 py-1 rounded border border-slate-200">OBP = 90°</span>
</div>
</div>
<p className="text-xs text-center text-slate-400">Drag point <strong>P</strong> to verify!</p>
</div>
</div>
);
};
export default TangentPropertiesWidget;

View File

@ -0,0 +1,233 @@
import React, { useState, useRef, useEffect } from 'react';
const SPECIAL_ANGLES = [0, 30, 45, 60, 90, 120, 135, 150, 180, 210, 225, 240, 270, 300, 315, 330, 360];
const UnitCircleWidget: React.FC = () => {
const [angle, setAngle] = useState(45); // Degrees
const [snap, setSnap] = useState(true); // Snap to special angles
const svgRef = useRef<SVGSVGElement>(null);
const isDragging = useRef(false);
const radius = 140;
const center = { x: 200, y: 200 };
const handleInteraction = (clientX: number, clientY: number) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const dx = clientX - rect.left - center.x;
const dy = clientY - rect.top - center.y;
// Calculate angle from 0 to 360
let rad = Math.atan2(-dy, dx);
if (rad < 0) rad += 2 * Math.PI;
let deg = (rad * 180) / Math.PI;
if (snap) {
const nearest = SPECIAL_ANGLES.reduce((prev, curr) =>
Math.abs(curr - deg) < Math.abs(prev - deg) ? curr : prev
);
if (Math.abs(nearest - deg) < 15) {
deg = nearest;
}
}
if (deg > 360) deg = 360;
setAngle(Math.round(deg));
};
const handleMouseDown = (e: React.MouseEvent) => {
isDragging.current = true;
handleInteraction(e.clientX, e.clientY);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging.current) {
handleInteraction(e.clientX, e.clientY);
}
};
const handleMouseUp = () => {
isDragging.current = false;
};
useEffect(() => {
const upHandler = () => isDragging.current = false;
window.addEventListener('mouseup', upHandler);
return () => window.removeEventListener('mouseup', upHandler);
}, []);
const rad = (angle * Math.PI) / 180;
const x = Math.cos(rad);
const y = Math.sin(rad);
const px = center.x + radius * x;
const py = center.y - radius * y;
const getExactValue = (val: number) => {
if (Math.abs(val) < 0.01) return "0";
if (Math.abs(val - 0.5) < 0.01) return "1/2";
if (Math.abs(val + 0.5) < 0.01) return "-1/2";
if (Math.abs(val - Math.sqrt(2)/2) < 0.01) return "√2/2";
if (Math.abs(val + Math.sqrt(2)/2) < 0.01) return "-√2/2";
if (Math.abs(val - Math.sqrt(3)/2) < 0.01) return "√3/2";
if (Math.abs(val + Math.sqrt(3)/2) < 0.01) return "-√3/2";
if (Math.abs(val - 1) < 0.01) return "1";
if (Math.abs(val + 1) < 0.01) return "-1";
return val.toFixed(3);
};
const getRadianLabel = (deg: number) => {
// Removed Record type annotation to prevent parsing error
const map: any = {
0: "0", 30: "π/6", 45: "π/4", 60: "π/3", 90: "π/2",
120: "2π/3", 135: "3π/4", 150: "5π/6", 180: "π",
210: "7π/6", 225: "5π/4", 240: "4π/3", 270: "3π/2",
300: "5π/3", 315: "7π/4", 330: "11π/6", 360: "2π"
};
if (map[deg]) return map[deg];
return ((deg * Math.PI) / 180).toFixed(2);
};
const cosStr = getExactValue(x);
const sinStr = getExactValue(y);
const getAngleColor = () => {
if (angle < 90) return "text-emerald-600";
if (angle < 180) return "text-indigo-600";
if (angle < 270) return "text-amber-600";
return "text-rose-600";
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col md:flex-row gap-8 select-none">
<div className="flex-shrink-0 flex flex-col items-center">
<svg
ref={svgRef}
width="400"
height="400"
className="cursor-pointer touch-none"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<line x1="200" y1="20" x2="200" y2="380" stroke="#f1f5f9" strokeWidth="1" />
<line x1="20" y1="200" x2="380" y2="200" stroke="#f1f5f9" strokeWidth="1" />
<line x1="200" y1="40" x2="200" y2="360" stroke="#cbd5e1" strokeWidth="1" />
<line x1="40" y1="200" x2="360" y2="200" stroke="#cbd5e1" strokeWidth="1" />
<circle cx="200" cy="200" r={radius} fill="transparent" stroke="#e2e8f0" strokeWidth="2" />
{SPECIAL_ANGLES.map(a => {
const rTick = (a * Math.PI) / 180;
const x1 = 200 + (radius - 5) * Math.cos(rTick);
const y1 = 200 - (radius - 5) * Math.sin(rTick);
const x2 = 200 + radius * Math.cos(rTick);
const y2 = 200 - radius * Math.sin(rTick);
return <line key={a} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#94a3b8" strokeWidth="1" />;
})}
<path d={`M 200 200 L ${px} 200 L ${px} ${py} Z`} fill="rgba(224, 231, 255, 0.4)" stroke="none" />
<line x1="200" y1="200" x2={px} y2={py} stroke="#1e293b" strokeWidth="2" />
<line x1="200" y1="200" x2={px} y2="200" stroke="#4f46e5" strokeWidth="3" />
<line x1={px} y1="200" x2={px} y2={py} stroke="#e11d48" strokeWidth="3" />
{angle > 0 && (
<path
d={`M 230 200 A 30 30 0 ${angle > 180 ? 1 : 0} 0 ${200 + 30*Math.cos(rad)} ${200 - 30*Math.sin(rad)}`}
fill="none" stroke="#0f172a" strokeWidth="1.5"
/>
)}
<circle cx={px} cy={py} r="8" fill="#0f172a" stroke="white" strokeWidth="2" className="shadow-sm" />
<circle cx={px} cy={py} r="20" fill="transparent" cursor="grab" />
<text x={200 + (px - 200)/2} y={200 + (y >= 0 ? 15 : -10)} textAnchor="middle" className="text-xs font-bold fill-indigo-600">cos</text>
<text x={px + (x >= 0 ? 10 : -10)} y={200 - (200 - py)/2} textAnchor={x >= 0 ? "start" : "end"} className="text-xs font-bold fill-rose-600">sin</text>
<g transform={`translate(${x >= 0 ? 280 : 40}, ${y >= 0 ? 40 : 360})`}>
<rect x="-10" y="-20" width="130" height="40" rx="8" fill="white" stroke="#e2e8f0" className="shadow-sm" />
<text x="55" y="5" textAnchor="middle" className="font-mono text-sm font-bold fill-slate-700">
({cosStr}, {sinStr})
</text>
</g>
</svg>
<div className="flex gap-4 mt-2">
<button
onClick={() => setSnap(!snap)}
className={`text-xs px-3 py-1 rounded-full font-bold border transition-colors ${snap ? 'bg-slate-800 text-white border-slate-800' : 'bg-white text-slate-500 border-slate-200'}`}
>
{snap ? "Snapping ON" : "Snapping OFF"}
</button>
</div>
</div>
<div className="flex-1 w-full space-y-6">
<div className="bg-slate-50 p-5 rounded-xl border border-slate-200">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-sm font-bold uppercase text-slate-500">Current Angle</h3>
<div className="flex items-baseline gap-3 mt-1">
<span className={`text-4xl font-mono font-bold ${getAngleColor()}`}>{Math.round(angle)}°</span>
<span className="text-2xl font-mono text-slate-400">=</span>
<span className="text-3xl font-mono font-bold text-slate-700">{getRadianLabel(angle)}</span>
<span className="text-sm text-slate-400 ml-1">rad</span>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-bold text-slate-400 uppercase">Common Angles</p>
<div className="flex flex-wrap gap-2">
{[0, 30, 45, 60, 90, 180, 270].map(a => (
<button
key={a}
onClick={() => setAngle(a)}
className={`w-10 h-10 rounded-lg text-sm font-bold transition-all ${
angle === a
? 'bg-indigo-600 text-white shadow-md scale-110'
: 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300 hover:text-indigo-600'
}`}
>
{a}°
</button>
))}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-indigo-50 border border-indigo-100 rounded-xl">
<div className="text-xs font-bold uppercase text-indigo-800 mb-1">Cosine (x)</div>
<div className="text-3xl font-mono font-bold text-indigo-900">{cosStr}</div>
<div className="text-xs text-indigo-400 mt-1 font-mono">adj / hyp</div>
</div>
<div className="p-4 bg-rose-50 border border-rose-100 rounded-xl">
<div className="text-xs font-bold uppercase text-rose-800 mb-1">Sine (y)</div>
<div className="text-3xl font-mono font-bold text-rose-900">{sinStr}</div>
<div className="text-xs text-rose-400 mt-1 font-mono">opp / hyp</div>
</div>
</div>
<div className="p-4 bg-amber-50 border border-amber-100 rounded-xl text-center">
<div className="text-xs font-bold uppercase text-amber-800 mb-1">Tangent (sin/cos)</div>
<div className="text-2xl font-mono font-bold text-amber-900">
{Math.abs(x) < 0.001 ? "Undefined" : getExactValue(y/x)}
</div>
</div>
<p className="text-xs text-slate-400 text-center">
Pro tip: On the SAT, memorize the values for 30°, 45°, and 60°!
</p>
</div>
</div>
);
};
export default UnitCircleWidget;

View File

@ -0,0 +1,71 @@
import React, { useState } from 'react';
import { ArrowRight } from 'lucide-react';
const UnitConversionWidget: React.FC = () => {
const [speed, setSpeed] = useState(60); // miles per hour
// Steps
const ftPerMile = 5280;
const secPerHour = 3600;
const ftPerHour = speed * ftPerMile;
const ftPerSec = ftPerHour / secPerHour;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="mb-8">
<label className="text-sm font-bold text-slate-500 uppercase">Speed (mph)</label>
<div className="flex items-center gap-4 mt-2">
<input
type="range" min="10" max="100" step="5" value={speed}
onChange={e => setSpeed(Number(e.target.value))}
className="flex-1 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-amber-600"
/>
<span className="font-mono font-bold text-2xl text-slate-800 w-20 text-right">{speed}</span>
</div>
</div>
<div className="space-y-6">
{/* Step 1: Write initial */}
<div className="flex items-center gap-4 p-4 bg-slate-50 rounded-lg border border-slate-200 overflow-x-auto">
<div className="flex flex-col items-center">
<span className="font-bold text-lg text-slate-800">{speed} miles</span>
<div className="w-full h-0.5 bg-slate-800 my-1"></div>
<span className="font-bold text-lg text-slate-800">1 hour</span>
</div>
<span className="text-slate-400 font-bold">×</span>
<div className="flex flex-col items-center">
<span className="font-bold text-lg text-emerald-600">5280 feet</span>
<div className="w-full h-0.5 bg-emerald-600 my-1"></div>
<span className="font-bold text-lg text-rose-600 line-through decoration-2">1 mile</span>
</div>
<span className="text-slate-400 font-bold">×</span>
<div className="flex flex-col items-center">
<span className="font-bold text-lg text-slate-800 line-through decoration-2 decoration-rose-600">1 hour</span>
<div className="w-full h-0.5 bg-slate-800 my-1"></div>
<span className="font-bold text-lg text-emerald-600">3600 sec</span>
</div>
<ArrowRight className="w-6 h-6 text-slate-400 shrink-0" />
<div className="flex flex-col items-center bg-white px-4 py-2 rounded shadow-sm border border-emerald-200">
<span className="font-bold text-xl text-emerald-700">{ftPerSec.toFixed(1)} ft</span>
<div className="w-full h-0.5 bg-emerald-700 my-1"></div>
<span className="font-bold text-xl text-emerald-700">1 sec</span>
</div>
</div>
<div className="text-sm text-slate-500">
<p><strong className="text-rose-600">Red units</strong> cancel out (top and bottom).</p>
<p><strong className="text-emerald-600">Green units</strong> remain.</p>
</div>
</div>
</div>
);
};
export default UnitConversionWidget;

View File

@ -0,0 +1,378 @@
import React, { useState, useEffect, useRef } from 'react';
import {
ArrowLeft, User, Shield, Clock, BookOpen, Calculator, Award,
TrendingUp, CheckCircle2, Circle, Lock, Eye, EyeOff, AlertCircle,
Check, Sparkles,
} from 'lucide-react';
import { useAuth, UserRecord } from './auth/AuthContext';
import { useProgress } from './progress/ProgressContext';
import { useGoldCoins } from './practice/GoldCoinContext';
import { LESSONS, EBRW_LESSONS } from '../constants';
import Mascot from './Mascot';
// Animated count-up
function useCountUp(target: number, duration = 900) {
const [count, setCount] = useState(0);
const started = useRef(false);
useEffect(() => {
if (started.current) return;
started.current = true;
const startTime = performance.now();
const animate = (now: number) => {
const progress = Math.min((now - startTime) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 2.5);
setCount(Math.round(eased * target));
if (progress < 1) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [target, duration]);
return count;
}
interface UserDashboardProps {
onExit: () => void;
}
export default function UserDashboard({ onExit }: UserDashboardProps) {
const { username, role, getUserRecord, changePassword, updateDisplayName } = useAuth();
const { getSubjectStats, getLessonStatus } = useProgress();
const { totalCoins, state: coinState } = useGoldCoins();
const user = getUserRecord(username || '');
const mathStats = getSubjectStats('math');
const ebrwStats = getSubjectStats('ebrw');
// Account settings
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showCurrentPw, setShowCurrentPw] = useState(false);
const [showNewPw, setShowNewPw] = useState(false);
const [pwMsg, setPwMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [pwLoading, setPwLoading] = useState(false);
const [editName, setEditName] = useState(false);
const [nameInput, setNameInput] = useState(user?.displayName || '');
const [nameSaved, setNameSaved] = useState(false);
const animCoins = useCountUp(totalCoins, 1200);
// Count completed topics across all practice
const topicsAttempted = Object.keys(coinState.topicProgress).length;
// Calculate total accuracy
let totalAttempted = 0;
let totalCorrect = 0;
Object.values(coinState.topicProgress).forEach((tp: any) => {
(['easy', 'medium', 'hard'] as const).forEach(d => {
totalAttempted += tp[d]?.attempted || 0;
totalCorrect += tp[d]?.correct || 0;
});
});
const accuracy = totalAttempted > 0 ? Math.round((totalCorrect / totalAttempted) * 100) : 0;
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setPwMsg(null);
if (newPassword !== confirmPassword) {
setPwMsg({ type: 'error', text: 'New passwords do not match.' });
return;
}
setPwLoading(true);
const result = await changePassword(username || '', currentPassword, newPassword);
setPwLoading(false);
if (result.success) {
setPwMsg({ type: 'success', text: 'Password changed successfully!' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} else {
setPwMsg({ type: 'error', text: result.error || 'Failed to change password.' });
}
};
const handleSaveName = () => {
if (username && nameInput.trim()) {
updateDisplayName(username, nameInput.trim());
setEditName(false);
setNameSaved(true);
setTimeout(() => setNameSaved(false), 2000);
}
};
// Progress ring
function ProgressRing({ percent, size = 72, stroke = 6, color }: { percent: number; size?: number; stroke?: number; color: string }) {
const r = (size - stroke) / 2;
const circ = 2 * Math.PI * r;
const offset = circ - (percent / 100) * circ;
return (
<svg width={size} height={size} className="transform -rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="currentColor" strokeWidth={stroke} className="text-slate-100" />
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={stroke}
strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round"
className="transition-all duration-1000 ease-out" />
<text x={size / 2} y={size / 2} textAnchor="middle" dominantBaseline="central"
className="text-sm font-bold fill-slate-800 transform rotate-90" style={{ transformOrigin: 'center' }}>
{percent}%
</text>
</svg>
);
}
function StatusIcon({ status }: { status: string }) {
if (status === 'completed') return <CheckCircle2 className="w-4 h-4 text-emerald-500" />;
if (status === 'in_progress') return <Circle className="w-4 h-4 text-blue-400" />;
return <Lock className="w-3.5 h-3.5 text-slate-300" />;
}
return (
<div className="min-h-screen bg-gradient-to-b from-white via-slate-50/50 to-white">
{/* Header */}
<header className="sticky top-0 z-40 glass-nav border-b border-slate-100">
<div className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
<button onClick={onExit} className="flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-slate-900 transition-colors">
<ArrowLeft className="w-4 h-4" /> Back to Home
</button>
<h1 className="text-sm font-bold text-slate-800">My Dashboard</h1>
<div className="w-20" />
</div>
</header>
<div className="max-w-5xl mx-auto px-6 py-10 space-y-10">
{/* ── Welcome Hero ── */}
<div className="relative bg-gradient-to-br from-cyan-50 via-white to-blue-50 rounded-2xl p-8 border border-cyan-100 overflow-hidden anim-fade-in-up">
<div className="absolute -top-2 -right-2 pointer-events-none select-none opacity-80">
<Mascot pose="waving" height={120} />
</div>
<div className="relative">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 rounded-xl bg-cyan-100 flex items-center justify-center">
<User className="w-6 h-6 text-cyan-600" />
</div>
<div>
<div className="flex items-center gap-2">
{editName ? (
<div className="flex items-center gap-2">
<input value={nameInput} onChange={e => setNameInput(e.target.value)}
className="text-xl font-bold text-slate-900 bg-white border border-slate-200 rounded-lg px-2 py-0.5 focus:outline-none focus:ring-2 focus:ring-cyan-400 w-48"
autoFocus onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
<button onClick={handleSaveName} className="text-xs font-bold text-cyan-600 hover:text-cyan-800">Save</button>
<button onClick={() => setEditName(false)} className="text-xs text-slate-400 hover:text-slate-600">Cancel</button>
</div>
) : (
<>
<h2 className="text-xl font-bold text-slate-900">{user?.displayName || username}</h2>
<button onClick={() => { setNameInput(user?.displayName || ''); setEditName(true); }}
className="text-xs text-cyan-500 hover:text-cyan-700 font-medium">edit</button>
{nameSaved && <span className="text-xs text-emerald-500 font-medium flex items-center gap-1"><Check className="w-3 h-3" /> Saved</span>}
</>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-widest ${
role === 'admin' ? 'bg-amber-100 text-amber-700' : 'bg-cyan-100 text-cyan-700'
}`}>
{role === 'admin' && <Shield className="w-3 h-3" />}
{role}
</span>
<span className="text-xs text-slate-400">@{username}</span>
</div>
</div>
</div>
{user?.lastLoginAt && (
<p className="text-xs text-slate-400 mt-2 flex items-center gap-1">
<Clock className="w-3 h-3" />
Last login: {new Date(user.lastLoginAt).toLocaleString()}
{user.lastLoginIp && user.lastLoginIp !== 'unknown' && <span className="ml-1">from {user.lastLoginIp}</span>}
</p>
)}
</div>
</div>
{/* ── Stats Overview ── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 anim-fade-in-up stagger-1">
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-slate-900 tabular-nums">{mathStats.completed + ebrwStats.completed}</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Lessons Done</p>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-amber-500 tabular-nums flex items-center justify-center gap-1">
<Award className="w-5 h-5" />{animCoins}
</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Gold Coins</p>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-emerald-500 tabular-nums">{accuracy}%</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Accuracy</p>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-blue-500 tabular-nums">{topicsAttempted}</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Topics Practiced</p>
</div>
</div>
{/* ── Lesson Progress ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 anim-fade-in-up stagger-2">
{/* Math */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 card-lift">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center">
<Calculator className="w-5 h-5 text-blue-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Mathematics</h3>
<p className="text-xs text-slate-400">{mathStats.completed}/{mathStats.total} lessons completed</p>
</div>
</div>
<ProgressRing percent={mathStats.percentComplete} color="#3b82f6" />
</div>
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden mb-4">
<div className="h-full bg-blue-500 rounded-full transition-all duration-1000" style={{ width: `${mathStats.percentComplete}%` }} />
</div>
<div className="space-y-1 max-h-48 overflow-y-auto pr-1">
{LESSONS.map(l => (
<div key={l.id} className="flex items-center gap-2 py-1 text-xs">
<StatusIcon status={getLessonStatus(l.id, 'math')} />
<span className="text-slate-600 truncate">{l.title}</span>
</div>
))}
</div>
</div>
{/* EBRW */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 card-lift">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-50 flex items-center justify-center">
<BookOpen className="w-5 h-5 text-purple-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Reading & Writing</h3>
<p className="text-xs text-slate-400">{ebrwStats.completed}/{ebrwStats.total} lessons completed</p>
</div>
</div>
<ProgressRing percent={ebrwStats.percentComplete} color="#a855f7" />
</div>
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden mb-4">
<div className="h-full bg-purple-500 rounded-full transition-all duration-1000" style={{ width: `${ebrwStats.percentComplete}%` }} />
</div>
<div className="space-y-1 max-h-48 overflow-y-auto pr-1">
{EBRW_LESSONS.map(l => (
<div key={l.id} className="flex items-center gap-2 py-1 text-xs">
<StatusIcon status={getLessonStatus(l.id, 'ebrw')} />
<span className="text-slate-600 truncate">{l.title}</span>
</div>
))}
</div>
</div>
</div>
{/* ── Practice Performance ── */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 anim-fade-in-up stagger-3">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 rounded-xl bg-amber-50 flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-amber-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Practice Performance</h3>
<p className="text-xs text-slate-400">{totalAttempted} questions attempted across {topicsAttempted} topics</p>
</div>
</div>
{topicsAttempted === 0 ? (
<div className="py-8 text-center text-slate-400 text-sm">
<Sparkles className="w-6 h-6 mx-auto mb-2 text-amber-300" />
No practice sessions yet. Start practicing to see your performance!
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Object.entries(coinState.topicProgress).map(([topicId, tp]: [string, any]) => {
const easy = tp.easy || { attempted: 0, correct: 0 };
const medium = tp.medium || { attempted: 0, correct: 0 };
const hard = tp.hard || { attempted: 0, correct: 0 };
const total = easy.attempted + medium.attempted + hard.attempted;
const correct = easy.correct + medium.correct + hard.correct;
const acc = total > 0 ? Math.round((correct / total) * 100) : 0;
return (
<div key={topicId} className="border border-slate-100 rounded-xl p-3 hover:border-slate-200 transition-colors">
<p className="text-xs font-semibold text-slate-700 truncate mb-2">{topicId}</p>
<div className="flex items-center justify-between text-[10px] text-slate-400 mb-1">
<span>{correct}/{total} correct</span>
<span className={`font-bold ${acc >= 70 ? 'text-emerald-500' : acc >= 40 ? 'text-amber-500' : 'text-rose-500'}`}>{acc}%</span>
</div>
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${acc >= 70 ? 'bg-emerald-400' : acc >= 40 ? 'bg-amber-400' : 'bg-rose-400'}`} style={{ width: `${acc}%` }} />
</div>
<div className="flex gap-3 mt-2 text-[10px] text-slate-400">
<span>E: {easy.correct}/{easy.attempted}</span>
<span>M: {medium.correct}/{medium.attempted}</span>
<span>H: {hard.correct}/{hard.attempted}</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ── Account Settings ── */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 anim-fade-in-up stagger-4">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 rounded-xl bg-slate-100 flex items-center justify-center">
<Lock className="w-5 h-5 text-slate-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Change Password</h3>
<p className="text-xs text-slate-400">Update your account password</p>
</div>
</div>
<form onSubmit={handleChangePassword} className="max-w-sm space-y-3">
{pwMsg && (
<div className={`flex items-center gap-2 p-3 rounded-xl text-sm ${
pwMsg.type === 'success' ? 'bg-emerald-50 border border-emerald-200 text-emerald-700' : 'bg-rose-50 border border-rose-200 text-rose-700'
}`}>
{pwMsg.type === 'success' ? <Check className="w-4 h-4 shrink-0" /> : <AlertCircle className="w-4 h-4 shrink-0" />}
{pwMsg.text}
</div>
)}
<div className="relative">
<label className="block text-xs font-semibold text-slate-600 mb-1">Current Password</label>
<input type={showCurrentPw ? 'text' : 'password'} value={currentPassword} onChange={e => setCurrentPassword(e.target.value)}
className="w-full px-3 py-2 pr-9 text-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-400" required />
<button type="button" onClick={() => setShowCurrentPw(!showCurrentPw)} className="absolute right-3 top-7 text-slate-400 hover:text-slate-600">
{showCurrentPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<div className="relative">
<label className="block text-xs font-semibold text-slate-600 mb-1">New Password</label>
<input type={showNewPw ? 'text' : 'password'} value={newPassword} onChange={e => setNewPassword(e.target.value)}
className="w-full px-3 py-2 pr-9 text-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-400" required minLength={4} />
<button type="button" onClick={() => setShowNewPw(!showNewPw)} className="absolute right-3 top-7 text-slate-400 hover:text-slate-600">
{showNewPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">Confirm New Password</label>
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-400" required minLength={4} />
</div>
<button type="submit" disabled={pwLoading}
className="px-5 py-2 bg-slate-900 text-white text-sm font-bold rounded-xl hover:bg-slate-700 transition-all btn-primary disabled:opacity-50">
{pwLoading ? 'Changing...' : 'Change Password'}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,31 @@
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -0,0 +1,133 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

246
src/components/ui/field.tsx Normal file
View File

@ -0,0 +1,246 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,29 @@
import * as React from "react";
import { Progress as ProgressPrimitive } from "radix-ui";
import { cn } from "../../lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-purple-100/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-black h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

143
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,726 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,55 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,670 @@
import { type PracticeQuestion } from "../../types/lesson";
export const AREA_VOL_EASY: PracticeQuestion[] = [
{
id: "02b02213",
type: "mcq",
questionHtml:
"What is the perimeter, in inches, of a rectangle with a length of <strong>4</strong> inches and a width of <strong>9</strong> inches?",
choices: [
{ label: "A", text: "<strong>13</strong>" },
{ label: "B", text: "<strong>17</strong>" },
{ label: "C", text: "<strong>22</strong>" },
{ label: "D", text: "<strong>26</strong>" },
],
correctAnswer: "D",
explanation:
"Choice D is correct. The perimeter of a figure is equal to the sum of the measurements of the sides of the figure. Its given that the rectangle has a length of <strong>4</strong> inches and a width of <strong>9</strong> inches. Since a rectangle has <strong>4</strong> sides, of which opposite sides are parallel and equal, it follows that the rectangle has two sides with a length of <strong>4</strong> inches and two sides with a width of <strong>9</strong> inches. Therefore, the perimeter of this rectangle is <strong>4 + 4 + 9 + 9</strong>, or <strong>26</strong> inches.<br>Choice A is incorrect. This is the sum, in inches, of the length and the width of the rectangle.<br>Choice B is incorrect. This is the sum, in inches, of the two lengths and the width of the rectangle.<br>Choice C is incorrect. This is the sum, in inches, of the length and the two widths of the rectangle.",
hasFigure: false,
},
{
id: "0837c3b9",
type: "mcq",
questionHtml:
"Triangle ABC and triangle DEF are similar triangles, where <strong>A B</strong> and <strong>D E</strong> are corresponding sides. If <strong>the length of D E = 2 · the length of A B</strong> and the perimeter of triangle ABC is 20, what is the perimeter of triangle DEF ?",
choices: [
{ label: "A", text: "10" },
{ label: "B", text: "40" },
{ label: "C", text: "80" },
{ label: "D", text: "120" },
],
correctAnswer: "B",
explanation:
"Choice B is correct. Since triangles ABC and DEF are similar and <strong>the length of side D E = 2 · the length of side A B</strong>, the length of each side of triangle DEF is two times the length of its corresponding side in triangle ABC. Therefore, the perimeter of triangle DEF is two times the perimeter of triangle ABC. Since the perimeter of triangle ABC is 20, the perimeter of triangle DEF is 40.Choice A is incorrect. This is half, not two times, the perimeter of triangle ABC. Choice C is incorrect. This is two times the perimeter of triangle DEF rather than two times the perimeter of triangle ABC. Choice D is incorrect. This is six times, not two times, the perimeter of triangle ABC.",
hasFigure: false,
},
{
id: "165c30c4",
type: "spr",
questionHtml:
"A rectangle has a length of <strong>64</strong> inches and a width of <strong>32</strong> inches. What is the area, in square inches, of the rectangle?",
choices: [],
correctAnswer: "2048",
explanation:
"The correct answer is <strong>2, 048</strong>. The area <strong>A</strong>, in square inches, of a rectangle is equal to the product of its length <strong>script l</strong>, in inches, and its width <strong>w</strong>, in inches, or <strong>A = script l w</strong>. It's given that the rectangle has a length of <strong>64</strong> inches and a width of <strong>32</strong> inches. Substituting <strong>64</strong> for <strong>script l</strong> and <strong>32</strong> for <strong>w</strong> in the equation <strong>A = script l w</strong> yields <strong>A = (64) (32)</strong>, or <strong>A = 2, 048</strong>. Therefore, the area, in square inches, of the rectangle is <strong>2, 048</strong>.",
hasFigure: false,
},
{
id: "29e9b28c",
type: "mcq",
questionHtml:
"The lengths of the sides are x, y, and z.<br>A note indicates the figure is not drawn to scale.<br><br> <br>The triangle shown has a perimeter of <strong>22</strong> units. If <strong>x = 9</strong> units and <strong>y = 7</strong> units, what is the value of <strong>z</strong>, in units?",
choices: [
{ label: "A", text: "<strong>6</strong>" },
{ label: "B", text: "<strong>7</strong>" },
{ label: "C", text: "<strong>9</strong>" },
{ label: "D", text: "<strong>16</strong>" },
],
correctAnswer: "A",
explanation:
"Choice A is correct. The perimeter of a triangle is the sum of the lengths of its three sides. The triangle shown has side lengths <strong>x</strong>, <strong>y</strong>, and <strong>z</strong>. It's given that the triangle has a perimeter of <strong>22</strong> units. Therefore, <strong>x + y + z = 22</strong>. If <strong>x = 9</strong> units and <strong>y = 7</strong> units, the value of <strong>z</strong>, in units, can be found by substituting <strong>9</strong> for <strong>x</strong> and <strong>7</strong> for <strong>y</strong> in the equation <strong>x + y + z = 22</strong>, which yields <strong>9 + 7 + z = 22</strong>, or <strong>16 + z = 22</strong>. Subtracting <strong>16</strong> from both sides of this equation yields <strong>z = 6</strong>. Therefore, if <strong>x = 9</strong> units and <strong>y = 7</strong> units, the value of <strong>z</strong>, in units, is <strong>6</strong>.<br>Choice B is incorrect. This is the value of <strong>y</strong>, in units, not the value of <strong>z</strong>, in units.<br>Choice C is incorrect. This is the value of <strong>x</strong>, in units, not the value of <strong>z</strong>, in units.<br>Choice D is incorrect. This is the value of <strong>x + y</strong>, in units, not the value of <strong>z</strong>, in units.",
hasFigure: true,
figureUrl: "/practice-images/29e9b28c_svg1.svg",
},
{
id: "3453aafc",
type: "mcq",
questionHtml:
"What is the area, in square centimeters, of a rectangle with a length of <strong>36</strong> centimeters and a width of <strong>34</strong> centimeters?",
choices: [
{ label: "A", text: "<strong>70</strong>" },
{ label: "B", text: "<strong>140</strong>" },
{ label: "C", text: "<strong>1, 156</strong>" },
{ label: "D", text: "<strong>1, 224</strong>" },
],
correctAnswer: "D",
explanation:
"Choice D is correct. The area <strong>A</strong>, in square centimeters, of a rectangle can be found using the formula <strong>A = script l w</strong>, where <strong>script l</strong> is the length, in centimeters, of the rectangle and <strong>w</strong> is its width, in centimeters. It's given that the rectangle has a length of <strong>36</strong> centimeters and a width of <strong>34</strong> centimeters. Substituting <strong>36</strong> for <strong>script l</strong> and <strong>34</strong> for <strong>w</strong> in the formula <strong>A = script l w</strong> yields <strong>A = 36 (34)</strong>, or <strong>A = 1, 224</strong>. Therefore, the area, in square centimeters, of this rectangle is <strong>1, 224</strong>.<br>Choice A is incorrect and may result from conceptual or calculation errors.<br>Choice B is incorrect. This is the perimeter, in centimeters, not the area, in square centimeters, of the rectangle.<br>Choice C is incorrect and may result from conceptual or calculation errors.",
hasFigure: false,
},
{
id: "4420e500",
type: "mcq",
questionHtml:
"What is the area of a rectangle with a length of <strong>4 centimeters (cm)</strong> and a width of <strong>2 cm</strong>?",
choices: [
{ label: "A", text: "<strong>6 cm²</strong>" },
{ label: "B", text: "<strong>8 cm²</strong>" },
{ label: "C", text: "<strong>12 cm²</strong>" },
{ label: "D", text: "<strong>36 cm²</strong>" },
],
correctAnswer: "B",
explanation:
"Choice B is correct. The area of a rectangle with length <strong>script l</strong> and width <strong>w</strong> can be found using the formula <strong>A = script l w</strong>. Its given that the rectangle has a length of <strong>4 cm</strong> and a width of <strong>2 cm</strong>. Therefore, the area of this rectangle is <strong>(4 cm) (2 cm)</strong>, or <strong>8 cm²</strong>.<br>Choice A is incorrect. This is the sum, <strong>in cm</strong>, of the length and width of the rectangle, not the area, <strong>in cm²</strong>.<br>Choice C is incorrect. This is the perimeter, <strong>in cm</strong>, of the rectangle, not the area, <strong>in cm²</strong>.<br>Choice D is incorrect. This is the sum of the length and width of the rectangle squared, not the area.",
hasFigure: false,
},
{
id: "5252e606",
type: "mcq",
questionHtml:
"The side length of a square is <strong>55 centimeters (cm)</strong>. What is the area, <strong>in cm²</strong>, of the square?",
choices: [
{ label: "A", text: "<strong>110</strong>" },
{ label: "B", text: "<strong>220</strong>" },
{ label: "C", text: "<strong>3, 025</strong>" },
{ label: "D", text: "<strong>12, 100</strong>" },
],
correctAnswer: "C",
explanation:
"Choice C is correct. The area <strong>A</strong>, <strong>in square centimeters (cm²)</strong>, of a square with side length <strong>s</strong>, <strong>in cm</strong>, is given by the formula <strong>A = s²</strong>. Its given that the square has a side length of <strong>55 cm</strong>. Substituting <strong>55</strong> for <strong>s</strong> in the formula <strong>A = s²</strong> yields <strong>A = 55²</strong>, or <strong>A = 3, 025</strong>. Therefore, the area, <strong>in cm²</strong>, of the square is <strong>3, 025</strong>.<br>Choice A is incorrect and may result from conceptual or calculation errors.<br>Choice B is incorrect. This is the perimeter, <strong>in cm</strong>, of the square, not its area, <strong>in cm²</strong>.<br>Choice D is incorrect and may result from conceptual or calculation errors.",
hasFigure: false,
},
{
id: "575f1e12",
type: "spr",
questionHtml:
"What is the area, in square centimeters, of a rectangle with a length of <strong>34 centimeters (cm)</strong> and a width of <strong>29 cm</strong>?",
choices: [],
correctAnswer: "986",
explanation:
"The correct answer is <strong>986</strong>. The area, <strong>A</strong>, of a rectangle is given by <strong>A = script l w</strong>, where <strong>script l</strong> is the length of the rectangle and <strong>w</strong> is its width. Its given that the length of the rectangle is <strong>34</strong> centimeters (cm) and the width is <strong>29</strong> cm. Substituting <strong>34</strong> for <strong>script l</strong> and <strong>29</strong> for <strong>w</strong> in the equation <strong>A = script l w</strong> yields <strong>A = (34) (29)</strong>, or <strong>A = 986</strong>. Therefore, the area, in square centimeters, of this rectangle is <strong>986</strong>.",
hasFigure: false,
},
{
id: "59cb654c",
type: "mcq",
questionHtml:
"The area of a square is <strong>64</strong> square inches. What is the side length, in inches, of this square?",
choices: [
{ label: "A", text: "<strong>8</strong>" },
{ label: "B", text: "<strong>16</strong>" },
{ label: "C", text: "<strong>64</strong>" },
{ label: "D", text: "<strong>128</strong>" },
],
correctAnswer: "A",
explanation:
"Choice A is correct. It's given that the area of a square is <strong>64</strong> square inches. The area <strong>A</strong>, in square inches, of a square is given by the formula <strong>A = s²</strong>, where <strong>s</strong> is the side length, in inches, of the square. Substituting <strong>64</strong> for <strong>A</strong> in this formula yields <strong>64 = s²</strong>. Taking the positive square root of both sides of this equation yields <strong>8 = s</strong>. Thus, the side length, in inches, of this square is <strong>8</strong>.<br>Choice B is incorrect and may result from conceptual or calculation errors.<br>Choice C is incorrect. This is the area, in square inches, of the square, not the side length, in inches, of the square.<br>Choice D is incorrect and may result from conceptual or calculation errors.",
hasFigure: false,
},
{
id: "76670c80",
type: "spr",
questionHtml:
"Each side of a square has a length of <strong>45</strong>. What is the perimeter of this square?",
choices: [],
correctAnswer: "180",
explanation:
"The correct answer is <strong>180</strong>. The perimeter of a polygon is equal to the sum of the lengths of the sides of the polygon. Its given that each side of the square has a length of <strong>45</strong>. Since a square is a polygon with <strong>4</strong> sides, the perimeter of this square is <strong>45 + 45 + 45 + 45</strong>, or <strong>180</strong>.",
hasFigure: false,
},
{
id: "c88183f7",
type: "mcq",
questionHtml:
"A rectangle has a length of <strong>13</strong> and a width of <strong>6</strong>. What is the perimeter of the rectangle?",
choices: [
{ label: "A", text: "<strong>12</strong>" },
{ label: "B", text: "<strong>26</strong>" },
{ label: "C", text: "<strong>38</strong>" },
{ label: "D", text: "<strong>52</strong>" },
],
correctAnswer: "C",
explanation:
"Choice C is correct. The perimeter of a quadrilateral is the sum of the lengths of its four sides. It's given that the rectangle has a length of <strong>13</strong> and a width of <strong>6</strong>. It follows that the rectangle has two sides with length <strong>13</strong> and two sides with length <strong>6</strong>. Therefore, the perimeter of the rectangle is <strong>13 + 13 + 6 + 6</strong>, or <strong>38</strong>.<br>Choice A is incorrect. This is the sum of the lengths of the two sides with length <strong>6</strong>, not the sum of the lengths of all four sides of the rectangle.<br>Choice B is incorrect. This is the sum of the lengths of the two sides with length <strong>13</strong>, not the sum of the lengths of all four sides of the rectangle.<br>Choice D is incorrect. This is the perimeter of a rectangle that has four sides with length <strong>13</strong>, not two sides with length <strong>13</strong> and two sides with length <strong>6</strong>.",
hasFigure: false,
},
{
id: "d0b6d927",
type: "mcq",
questionHtml:
"A rectangle has an area of <strong>63</strong> square meters and a length of <strong>9</strong> meters. What is the width, in meters, of the rectangle?",
choices: [
{ label: "A", text: "<strong>7</strong>" },
{ label: "B", text: "<strong>54</strong>" },
{ label: "C", text: "<strong>81</strong>" },
{ label: "D", text: "<strong>567</strong>" },
],
correctAnswer: "A",
explanation:
"Choice A is correct. The area <strong>A</strong>, in square meters, of a rectangle is the product of its length <strong>script l</strong>, in meters, and its width <strong>w</strong>, in meters; thus, <strong>A = script l w</strong>. It's given that a rectangle has an area of <strong>63</strong> square meters and a length of <strong>9</strong> meters. Substituting <strong>63</strong> for <strong>A</strong> and <strong>9</strong> for <strong>script l</strong> in the equation <strong>A = script l w</strong> yields <strong>63 = 9 w</strong>. Dividing both sides of this equation by <strong>9</strong> yields <strong>7 = w</strong>. Therefore, the width, in meters, of the rectangle is <strong>7</strong>.<br>Choice B is incorrect. This is the difference between the area, in square meters, and the length, in meters, of the rectangle, not the width, in meters, of the rectangle.<br>Choice C is incorrect. This is the square of the length, in meters, not the width, in meters, of the rectangle.<br>Choice D is incorrect. This is the product of the area, in square meters, and the length, in meters, of the rectangle, not the width, in meters, of the rectangle.",
hasFigure: false,
},
{
id: "d2047497",
type: "mcq",
questionHtml:
"What is the area of a rectangle with a length of <strong>17 centimeters (cm)</strong> and a width of <strong>7 cm</strong>?",
choices: [
{ label: "A", text: "<strong>24 cm²</strong>" },
{ label: "B", text: "<strong>48 cm²</strong>" },
{ label: "C", text: "<strong>119 cm²</strong>" },
{ label: "D", text: "<strong>576 cm²</strong>" },
],
correctAnswer: "C",
explanation:
"Choice C is correct. The area of a rectangle with length <strong>l</strong> and width <strong>w</strong> can be found using the formula <strong>A = l w</strong>. Its given that the rectangle has a length of <strong>17 cm</strong> and a width of <strong>7 cm</strong>. Therefore, the area of this rectangle is <strong>A = 17 (7)</strong>, or <strong>119 cm²</strong>.<br>Choice A is incorrect. This is the sum of the length and width of the rectangle, not the area.<br>Choice B is incorrect. This is the perimeter of the rectangle, not the area.<br>Choice D is incorrect. This is the sum of the length and width of the rectangle squared, not the area.",
hasFigure: false,
},
{
id: "d683a9cc",
type: "mcq",
questionHtml:
"The figure shows the lengths, in centimeters (cm), of the edges of a right rectangular prism. The volume V of a right rectangular prism is <strong>l w h</strong>, where <strong>l</strong> is the length of the prism, w is the width of the prism, and h is the height of the prism. What is the volume, in cubic centimeters, of the prism?",
choices: [
{ label: "A", text: "36" },
{ label: "B", text: "24" },
{ label: "C", text: "12" },
{ label: "D", text: "11" },
],
correctAnswer: "A",
explanation:
"Choice A is correct. Its given that the volume of a right rectangular prism is <strong>l w h</strong>. The prism shown has a length of 6 cm, a width of 2 cm, and a height of 3 cm. Thus, <strong>l w h = 6 · 2 · 3</strong>, or 36 cubic centimeters.Choice B is incorrect. This is the volume of a rectangular prism with edge lengths of 6, 2, and 2. Choice C is incorrect and may result from only finding the product of the length and width of the base of the prism. Choice D is incorrect and may result from finding the sum, not the product, of the edge lengths of the prism.",
hasFigure: true,
figureUrl: "/practice-images/d683a9cc_img1.png",
},
{
id: "f60bb551",
type: "mcq",
questionHtml:
"The area of a rectangle is <strong>630</strong> square inches. The length of the rectangle is <strong>70</strong> inches. What is the width, in inches, of this rectangle?",
choices: [
{ label: "A", text: "<strong>9</strong>" },
{ label: "B", text: "<strong>70</strong>" },
{ label: "C", text: "<strong>315</strong>" },
{ label: "D", text: "<strong>560</strong>" },
],
correctAnswer: "A",
explanation:
"Choice A is correct. The area <strong>A</strong>, in square inches, of a rectangle is the product of its length <strong>script l</strong>, in inches, and its width <strong>w</strong>, in inches; thus, <strong>A = script l w</strong>. It's given that the area of a rectangle is <strong>630</strong> square inches and the length of the rectangle is <strong>70</strong> inches. Substituting <strong>630</strong> for <strong>A</strong> and <strong>70</strong> for <strong>script l</strong> in the equation <strong>A = script l w</strong> yields <strong>630 = 70 w</strong>. Dividing both sides of this equation by <strong>70</strong> yields <strong>9 = w</strong>. Therefore, the width, in inches, of this rectangle is <strong>9</strong>.<br>Choice B is incorrect. This is the length, not the width, in inches, of the rectangle.<br>Choice C is incorrect. This is half the area, in square inches, not the width, in inches, of the rectangle.<br>Choice D is incorrect. This is the difference between the area, in square inches, and the length, in inches, of the rectangle, not the width, in inches, of the rectangle.",
hasFigure: false,
},
];
export const AREA_VOL_MEDIUM: PracticeQuestion[] = [
{
id: "08b7a3f5",
type: "spr",
questionHtml:
"A triangular prism has a height of <strong>8 centimeters (cm)</strong> and a volume of <strong>216 cm³</strong>. What is the area, <strong>in cm²</strong>, of the base of the prism? (The volume of a triangular prism is equal to <strong>B h</strong>, where <strong>B</strong> is the area of the base and <strong>h</strong> is the height of the prism.)",
choices: [],
correctAnswer: "27",
explanation:
"The correct answer is <strong>27</strong>. It's given that a triangular prism has a volume of <strong>216 cubic centimeters (cm³)</strong> and the volume of a triangular prism is equal to <strong>B h</strong>, where <strong>B</strong> is the area of the base and <strong>h</strong> is the height of the prism. Therefore, <strong>216 = B h</strong>. It's also given that the triangular prism has a height of <strong>8 cm</strong>. Therefore, <strong>h = 8</strong>. Substituting <strong>8</strong> for <strong>h</strong> in the equation <strong>216 = B h</strong> yields <strong>216 = B (8)</strong>. Dividing both sides of this equation by <strong>8</strong> yields <strong>27 = B</strong>. Therefore, the area, <strong>in cm²</strong>, of the base of the prism is <strong>27</strong>.",
hasFigure: false,
},
{
id: "151eda3c",
type: "mcq",
questionHtml:
"A manufacturing company produces two sizes of cylindrical containers that each have a height of 50 centimeters. The radius of container A is 16 centimeters, and the radius of container B is 25% longer than the radius of container A. What is the volume, in cubic centimeters, of container B?",
choices: [
{ label: "A", text: "<strong>16, 000 π</strong>" },
{ label: "B", text: "<strong>20, 000 π</strong>" },
{ label: "C", text: "<strong>25, 000 π</strong>" },
{ label: "D", text: "<strong>31, 250 π</strong>" },
],
correctAnswer: "B",
explanation:
"Choice B is correct. If the radius of container A is 16 centimeters and the radius of container B is 25% longer than the radius of container A, then the radius of container B is <strong>16 + 0 . 2 5 · 16 = 20</strong> centimeters. The volume of a cylinder is <strong>π · r² · h</strong>, where r is the radius of the cylinder and h is its height. Substituting <strong>r = 20</strong> and <strong>h = 50</strong> into <strong>π · r² · h</strong> yields that the volume of cylinder B is <strong>π · (20, ), ² · 50 = 20, 000 π</strong> cubic centimeters.Choice A is incorrect and may result from multiplying the radius of cylinder B by the radius of cylinder A rather than squaring the radius of cylinder B. Choice C is incorrect and may result from multiplying the radius of cylinder B by 25 rather than squaring it. Choice D is incorrect and may result from taking the radius of cylinder B to be 25 centimeters rather than 20 centimeters.",
hasFigure: false,
},
{
id: "1f0b582e",
type: "mcq",
questionHtml:
"Square X has a side length of <strong>12</strong> centimeters. The perimeter of square Y is <strong>2</strong> times the perimeter of square X. What is the length, in centimeters, of one side of square Y?",
choices: [
{ label: "A", text: "<strong>6</strong>" },
{ label: "B", text: "<strong>10</strong>" },
{ label: "C", text: "<strong>14</strong>" },
{ label: "D", text: "<strong>24</strong>" },
],
correctAnswer: "D",
explanation:
"Choice D is correct. The perimeter, <strong>P</strong>, of a square can be found using the formula <strong>P = 4 s</strong>, where <strong>s</strong> is the length of each side of the square. It's given that square X has a side length of <strong>12</strong> centimeters. Substituting <strong>12</strong> for <strong>s</strong> in the formula for the perimeter of a square yields <strong>P = 4 (12)</strong>, or <strong>P = 48</strong>. Therefore, the perimeter of square X is <strong>48</strong> centimeters. Its also given that the perimeter of square Y is <strong>2</strong> times the perimeter of square X. Therefore, the perimeter of square Y is <strong>2 (48)</strong>, or <strong>96</strong>, centimeters. Substituting <strong>96</strong> for <strong>P</strong> in the formula <strong>P = 4 s</strong> gives <strong>96 = 4 s</strong>. Dividing both sides of this equation by <strong>4</strong> gives <strong>24 = s</strong>. Therefore, the length of one side of square Y is <strong>24</strong> centimeters.<br>Choice A is incorrect and may result from conceptual or calculation errors.<br>Choice B is incorrect and may result from conceptual or calculation errors.<br>Choice C is incorrect and may result from conceptual or calculation errors.",
hasFigure: false,
},
{
id: "37dde49f",
type: "mcq",
questionHtml:
"<strong>The figure presents a cylindrical shape with a circular base and a larger circular top. The diameter of the circular base is labeled “k over 2, ” the diameter of the circular top is labeled “k, ” and the height is labeled “k.” The volume of the figure = the fraction with numerator 7 π k³, and denominator 48</strong>The glass pictured above can hold a maximum volume of 473 cubic centimeters, which is approximately 16 fluid ounces. What is the value of k, in centimeters?",
choices: [
{ label: "A", text: "2.52" },
{ label: "B", text: "7.67" },
{ label: "C", text: "7.79" },
{ label: "D", text: "10.11" },
],
correctAnswer: "D",
explanation:
"Choice D is correct. Using the volume formula <strong>V = the fraction with numerator 7 π · k³, and denominator 48</strong> and the given information that the volume of the glass is 473 cubic centimeters, the value of k can be found as follows:<br> <strong>473 = the fraction with numerator 7 π · k³, and denominator 48</strong><br><br> <strong>k³ = the fraction with numerator 473 · 48, and denominator 7 π, end fraction</strong><br><br> <strong>k = the cube root of the fraction with numerator 473 · 48, and denominator 7 π, end fraction, end root, which is ≈ 10 . 1 0 6 9 0</strong><br>Therefore, the value of k is approximately 10.11 centimeters.<br>Choices A, B, and C are incorrect. Substituting the values of k from these choices in the formula results in volumes of approximately 7 cubic centimeters, 207 cubic centimeters, and 217 cubic centimeters, respectively, all of which contradict the given information that the volume of the glass is 473 cubic centimeters.",
hasFigure: true,
figureUrl: "/practice-images/37dde49f_img1.png",
},
{
id: "38517165",
type: "spr",
questionHtml:
"A circle has a circumference of <strong>31 π</strong> centimeters. What is the diameter, in centimeters, of the circle?",
choices: [],
correctAnswer: "31",
explanation:
"The correct answer is <strong>31</strong>. The circumference of a circle is equal to <strong>2 π r</strong> centimeters, where <strong>r</strong> represents the radius, in centimeters, of the circle, and the diameter of the circle is equal to <strong>2 r</strong> centimeters. It's given that a circle has a circumference of <strong>31 π</strong> centimeters. Therefore, <strong>31 π = 2 π r</strong>. Dividing both sides of this equation by <strong>π</strong> yields <strong>31 = 2 r</strong>. Since the diameter of the circle is equal to <strong>2 r</strong> centimeters, it follows that the diameter, in centimeters, of the circle is <strong>31</strong>.",
hasFigure: false,
},
{
id: "5afbdc8e",
type: "mcq",
questionHtml:
"What is the length of one side of a square that has the same area as a circle with radius 2 ?",
choices: [
{ label: "A", text: "2" },
{ label: "B", text: "<strong>the √ 2 π, end root</strong>" },
{ label: "C", text: "<strong>2 · the √ π</strong>" },
{ label: "D", text: "<strong>2 π</strong>" },
],
correctAnswer: "C",
explanation:
"Choice C is correct. The area A of a circle with radius r is given by the formula <strong>A = π · r²</strong>. Thus, a circle with radius 2 has area <strong>π · 2²</strong>, which can be rewritten as <strong>4 π</strong>. The area of a square with side length s is given by the formula <strong>A = s²</strong>. Thus, if a square has the same area as a circle with radius 2, then <strong>s² = 4 π</strong>. Since the side length of a square must be a positive number, taking the square root of both sides of <strong>s² = 4 π</strong> gives <strong>s = the √ 4 π, end root</strong>. Using the properties of square roots, <strong>the √ 4 π, end root</strong> can be rewritten as <strong>(the √ 4, ) · (the √ π, )</strong>, which is equivalent to <strong>2 · the √ π</strong>. Therefore, <strong>s = 2 · the √ π</strong>.Choice A is incorrect. The side length of the square isnt equal to the radius of the circle. Choices B and D are incorrect and may result from incorrectly simplifying the expression <strong>the √ 4 π, end root</strong>.",
hasFigure: false,
},
{
id: "a2e76b60",
type: "mcq",
questionHtml:
"A cylindrical can containing pieces of fruit is filled to the top with syrup before being sealed. The base of the can has an area of <strong>75 centimeters²</strong>, and the height of the can is 10 cm. If <strong>110 centimeters³</strong> of syrup is needed to fill the can to the top, which of the following is closest to the total volume of the pieces of fruit in the can?",
choices: [
{ label: "A", text: "<strong>7 . 5 centimeters³</strong>" },
{ label: "B", text: "<strong>185 centimeters³</strong>" },
{ label: "C", text: "<strong>640 centimeters³</strong>" },
{ label: "D", text: "<strong>750 centimeters³</strong>" },
],
correctAnswer: "C",
explanation:
"Choice C is correct. The total volume of the cylindrical can is found by multiplying the area of the base of the can, <strong>75 square centimeters</strong>, by the height of the can, 10 cm, which yields <strong>750 cubic centimeters</strong>. If the syrup needed to fill the can has a volume of <strong>110 cubic centimeters</strong>, then the remaining volume for the pieces of<br><br>fruit is <strong>750 110 = 640 cubic centimeters</strong>.Choice A is incorrect because if the fruit had a volume of <strong>7 . 5 cubic centimeters</strong>, there would be <strong>750 7 . 5 = 742 . 5 cubic centimeters</strong> of syrup needed to fill the can to the top. Choice B is incorrect because if the fruit had a volume of <strong>185 cubic centimeters</strong>, there would be <strong>750 185 = 565 cubic centimeters</strong> of syrup needed to fill the can to the top. Choice D is incorrect because it is the total volume of the can, not just of the pieces of fruit.",
hasFigure: false,
},
{
id: "c0586eb5",
type: "mcq",
questionHtml:
"A cylinder has a diameter of <strong>8</strong> inches and a height of <strong>12</strong> inches. What is the volume, in cubic inches, of the cylinder?",
choices: [
{ label: "A", text: "<strong>16 π</strong>" },
{ label: "B", text: "<strong>96 π</strong>" },
{ label: "C", text: "<strong>192 π</strong>" },
{ label: "D", text: "<strong>768 π</strong>" },
],
correctAnswer: "C",
explanation:
"Choice C is correct. The base of a cylinder is a circle with a diameter equal to the diameter of the cylinder. The volume, <strong>V</strong>, of a cylinder can be found by multiplying the area of the circular base, <strong>A</strong>, by the height of the cylinder, <strong>h</strong>, or <strong>V = A h</strong>. The area of a circle can be found using the formula <strong>A = π r²</strong>, where <strong>r</strong> is the radius of the circle. Its given that the diameter of the cylinder is <strong>8</strong> inches. Thus, the radius of this circle is <strong>4</strong> inches. Therefore, the area of the circular base of the cylinder is <strong>A = π (4)²</strong>, or <strong>16 π</strong> square inches. Its given that the height <strong>h</strong> of the cylinder is <strong>12</strong> inches. Substituting <strong>16 π</strong> for <strong>A</strong> and <strong>12</strong> for <strong>h</strong> in the formula <strong>V = A h</strong> gives <strong>V = 16 π (12)</strong>, or <strong>192 π</strong> cubic inches.<br>Choice A is incorrect. This is the area of the circular base of the cylinder.<br>Choice B is incorrect and may result from using <strong>8</strong>, instead of <strong>16</strong>, as the value of <strong>r²</strong> in the formula for the area of a circle.<br>Choice D is incorrect and may result from using <strong>8</strong>, instead of <strong>4</strong>, for the radius of the circular base.",
hasFigure: false,
},
{
id: "cf53cb56",
type: "mcq",
questionHtml:
"In the xy-plane shown, square ABCD has its diagonals on the x- and y-axes. What is the area, in square units, of the square?",
choices: [
{ label: "A", text: "20" },
{ label: "B", text: "25" },
{ label: "C", text: "50" },
{ label: "D", text: "100" },
],
correctAnswer: "C",
explanation:
"Choice C is correct. The two diagonals of square ABCD divide the square into 4 congruent right triangles, where each triangle has a vertex at the origin of the graph shown. The formula for the area of a triangle is <strong>A = one half · b h</strong>, where b is the base length of the triangle and h is the height of the triangle. Each of the 4 congruent right triangles has a height of 5 units and a base length of 5 units. Therefore, the area of each triangle is <strong>A = one half · 5 · 5</strong>, or 12.5 square units. Since the 4 right triangles are congruent, the area of each is <strong>one fourth</strong> of the area of square ABCD. It follows that the area of the square ABCD is equal to <strong>4 · 12 . 5</strong>, or 50 square units.Choices A and D are incorrect and may result from using 5 or 25, respectively, as the area of one of the 4 congruent right triangles formed by diagonals of square ABCD. However, the area of these triangles is 12.5. Choice B is incorrect and may result from using 5 as the length of one side of square ABCD. However, the length of a side of square ABCD is <strong>5 · the √ 2</strong>.",
hasFigure: true,
figureUrl: "/practice-images/cf53cb56_img1.png",
},
{
id: "e336a1d2",
type: "mcq",
questionHtml:
"A cube has an edge length of <strong>41</strong> inches. What is the volume, in cubic inches, of the cube?",
choices: [
{ label: "A", text: "<strong>164</strong>" },
{ label: "B", text: "<strong>1, 681</strong>" },
{ label: "C", text: "<strong>10, 086</strong>" },
{ label: "D", text: "<strong>68, 921</strong>" },
],
correctAnswer: "D",
explanation:
"Choice D is correct. The volume, <strong>V</strong>, of a cube can be found using the formula <strong>V = s³</strong>, where <strong>s</strong> is the edge length of the cube. It's given that a cube has an edge length of <strong>41</strong> inches. Substituting <strong>41</strong> inches for <strong>s</strong> in this equation yields <strong>V = 41³</strong> cubic inches, or <strong>V = 68, 921</strong> cubic inches. Therefore, the volume of the cube is <strong>68, 921</strong> cubic inches.<br>Choice A is incorrect. This is the perimeter, in inches, of the cube.<br>Choice B is incorrect. This is the area, in square inches, of a face of the cube.<br>Choice C is incorrect. This is the surface area, in square inches, of the cube.",
hasFigure: false,
},
{
id: "ec5d4823",
type: "spr",
questionHtml:
"What is the volume, in cubic centimeters, of a right rectangular prism that has a length of 4 centimeters, a width of 9 centimeters, and a height of 10 centimeters?",
choices: [],
correctAnswer: "",
explanation:
"The correct answer is 360. The volume of a right rectangular prism is calculated by multiplying its dimensions: length, width, and height. Multiplying the values given for these dimensions yields a volume of <strong>4 · 9 · 10 = 360</strong> cubic centimeters.",
hasFigure: false,
},
{
id: "f67e4efc",
type: "mcq",
questionHtml:
"A right circular cylinder has a volume of <strong>45 π</strong>. If the height of the cylinder is 5, what is the radius of the cylinder?",
choices: [
{ label: "A", text: "3" },
{ label: "B", text: "4.5" },
{ label: "C", text: "9" },
{ label: "D", text: "40" },
],
correctAnswer: "A",
explanation:
"Choice A is correct. The volume of a right circular cylinder with a radius of r is the product of the area of the base, <strong>π, r²</strong>, and the height, h. The volume of the right circular cylinder described is <strong>45 π</strong> and its height is 5. If the radius is r, it follows that <strong>45 π = π · r, ² · 5</strong>. Dividing both sides of this equation by <strong>5 π</strong> yields <strong>9 = r²</strong>. Taking the square root of both sides yields <strong>r = 3</strong> or <strong>r = 3</strong>. Since r represents the radius, the value must be positive. Therefore, the radius is 3.Choice B is incorrect and may result from finding that the square of the radius is 9, but then from dividing 9 by 2, rather than taking the square root of 9. Choice C is incorrect. This represents the square of the radius. Choice D is incorrect and may result from solving the equation <strong>45 π = π · r, ² · 5</strong> for <strong>r²</strong>, not r, by dividing by <strong>π</strong> on both sides and then by subtracting, not dividing, 5 from both sides.",
hasFigure: false,
},
];
export const AREA_VOL_HARD: PracticeQuestion[] = [
{
id: "306264ab",
type: "mcq",
questionHtml:
"A right triangle has sides of length <strong>2 √(2)</strong>, <strong>6 √(2)</strong>, and <strong>√(80)</strong> units. What is the area of the triangle, in square units?",
choices: [
{ label: "A", text: "<strong>8 √(2) + √(80)</strong>" },
{ label: "B", text: "<strong>12</strong>" },
{ label: "C", text: "<strong>24 √(80)</strong>" },
{ label: "D", text: "<strong>24</strong>" },
],
correctAnswer: "B",
explanation:
"Choice B is correct. The area, <strong>A</strong>, of a triangle can be found using the formula <strong>A = one half b h</strong>, where <strong>b</strong> is the length of the base of the triangle and <strong>h</strong> is the height of the triangle. It's given that the triangle is a right triangle. Therefore, its base and height can be represented by the two legs. Its also given that the triangle has sides of length <strong>2 √(2)</strong>, <strong>6 √(2)</strong>, and <strong>√(80)</strong> units. Since <strong>√(80)</strong> units is the greatest of these lengths, it's the length of the hypotenuse. Therefore, the two legs have lengths <strong>2 √(2)</strong> and <strong>6 √(2)</strong> units. Substituting these values for <strong>b</strong> and <strong>h</strong> in the formula <strong>A = one half b h</strong> gives <strong>A = one half (2 √(2)) (6 √(2))</strong>, which is equivalent to <strong>A = 6 √(4)</strong> square units, or <strong>A = 12</strong> square units.<br>Choice A is incorrect. This expression represents the perimeter, rather than the area, of the triangle.<br>Choice C is incorrect and may result from conceptual or calculation errors. <br>Choice D is incorrect and may result from conceptual or calculation errors.",
hasFigure: false,
},
{
id: "310c87fe",
type: "mcq",
questionHtml:
"A cube has a surface area of 54 square meters. What is the volume, in cubic meters, of the cube?",
choices: [
{ label: "A", text: "18" },
{ label: "B", text: "27" },
{ label: "C", text: "36" },
{ label: "D", text: "81" },
],
correctAnswer: "B",
explanation:
"Choice B is correct. The surface area of a cube with side length s is equal to <strong>6 s²</strong>. Since the surface area is given as 54 square meters, the equation <strong>54 = 6 s²</strong> can be used to solve for s. Dividing both sides of the equation by 6 yields <strong>9 = s²</strong>. Taking the square root of both sides of this equation yields <strong>3 = s</strong> and <strong>3 = s</strong>. Since the side length of a cube must be a positive value, <strong>s = 3</strong> can be discarded as a possible solution, leaving <strong>s = 3</strong>. The volume of a cube with side length s is equal to <strong>s³</strong>. Therefore, the volume of this cube, in cubic meters, is <strong>3³</strong>, or 27.Choices A, C, and D are incorrect and may result from calculation errors.",
hasFigure: false,
},
{
id: "459dd6c5",
type: "spr",
questionHtml:
"Triangles <strong>italic A italic B italic C</strong> and <strong>italic D italic E italic F</strong> are similar. Each side length of triangle <strong>italic A italic B italic C</strong> is <strong>4</strong> times the corresponding side length of triangle <strong>italic D italic E italic F</strong>. The area of triangle <strong>italic A italic B italic C</strong> is <strong>270</strong> square inches. What is the area, in square inches, of triangle <strong>italic D italic E italic F</strong>?",
choices: [],
correctAnswer: "135/8, 16.87, 16.88",
explanation:
"The correct answer is <strong>(135) / (8)</strong>. It's given that triangles <strong>italic A italic B italic C</strong> and <strong>italic D italic E italic F</strong> are similar and each side length of triangle <strong>italic A italic B italic C</strong> is <strong>4</strong> times the corresponding side length of triangle <strong>italic D italic E italic F</strong>. For two similar triangles, if each side length of the first triangle is <strong>k</strong> times the corresponding side length of the second triangle, then the area of the first triangle is <strong>k²</strong> times the area of the second triangle. Therefore, the area of triangle <strong>italic A italic B italic C</strong> is <strong>4²</strong>, or <strong>16</strong>, times the area of triangle <strong>italic D italic E italic F</strong>. It's given that the area of triangle <strong>italic A italic B italic C</strong> is <strong>270</strong> square inches. Let <strong>a</strong> represent the area, in square inches, of triangle <strong>italic D italic E italic F</strong>. It follows that <strong>270</strong> is <strong>16</strong> times <strong>a</strong>, or <strong>270 = 16 a</strong>. Dividing both sides of this equation by <strong>16</strong> yields <strong>(270) / (16) = a</strong>, which is equivalent to <strong>(135) / (8) = a</strong>. Thus, the area, in square inches, of triangle <strong>italic D italic E italic F</strong> is <strong>(135) / (8)</strong>. Note that 135/8, 16.87, and 16.88 are examples of ways to enter a correct answer.",
hasFigure: false,
},
{
id: "5b2b8866",
type: "spr",
questionHtml:
"A rectangular poster has an area of <strong>360</strong> square inches. A copy of the poster is made in which the length and width of the original poster are each increased by <strong>20 % sign</strong>. What is the area of the copy, in square inches?",
choices: [],
correctAnswer: "2592/5, 518.4",
explanation:
"The correct answer is <strong>518.4</strong>. It's given that the area of the original poster is <strong>360</strong> square inches. Let <strong>script l</strong> represent the length, in inches, of the original poster, and let <strong>w</strong> represent the width, in inches, of the original poster. Since the area of a rectangle is equal to its length times its width, it follows that <strong>360 = script l w</strong>. It's also given that a copy of the poster is made in which the length and width of the original poster are each increased by <strong>20 % sign</strong>. It follows that the length of the copy is the length of the original poster plus <strong>20 % sign</strong> of the length of the original poster, which is equivalent to <strong>script l + (20) / (100) script l</strong> inches. This length can be rewritten as <strong>script l + 0.2 script l</strong> inches, or <strong>1.2 script l</strong> inches. Similarly, the width of the copy is the width of the original poster plus <strong>20 % sign</strong> of the width of the original poster, which is equivalent to <strong>w + (20) / (100) w</strong> inches. This width can be rewritten as <strong>w + 0.2 w</strong> inches, or <strong>1.2 w</strong> inches. Since the area of a rectangle is equal to its length times its width, it follows that the area, in square inches, of the copy is equal to <strong>(1.2 script l) (1.2 w)</strong>, which can be rewritten as <strong>(1.2) (1.2) (script l w)</strong>. Since <strong>360 = script l w</strong>, the area, in square inches, of the copy can be found by substituting <strong>360</strong> for <strong>script l w</strong> in the expression <strong>(1.2) (1.2) (script l w)</strong>, which yields <strong>(1.2) (1.2) (360)</strong>, or <strong>518.4</strong>. Therefore, the area of the copy, in square inches, is <strong>518.4</strong>.",
hasFigure: false,
},
{
id: "899c6042",
type: "spr",
questionHtml:
"A right circular cone has a height of <strong>22 centimeters (cm)</strong> and a base with a diameter of <strong>6 cm</strong>. The volume of this cone is <strong>n π cm³</strong>. What is the value of <strong>n</strong>?",
choices: [],
correctAnswer: "66",
explanation:
"The correct answer is <strong>66</strong>. Its given that the right circular cone has a height of <strong>22</strong> centimeters <strong>(cm)</strong> and a base with a diameter of <strong>6 cm</strong>. Since the diameter of the base of the cone is <strong>6 cm</strong>, the radius of the base is <strong>3 cm</strong>. The volume <strong>V</strong>, <strong>in cm³</strong>, of a right circular cone can be found using the formula <strong>V = one third π r² h</strong>, where <strong>h</strong> is the height, <strong>in cm</strong>, and <strong>r</strong> is the radius, <strong>in cm</strong>, of the base of the cone. Substituting <strong>22</strong> for <strong>h</strong> and <strong>3</strong> for <strong>r</strong> in this formula yields <strong>V = one third π (3)² (22)</strong>, or <strong>V = 66 π</strong>. Therefore, the volume of the cone is <strong>66 π italic cm³</strong>. Its given that the volume of the cone is <strong>n π italic cm³</strong>. Therefore, the value of <strong>n</strong> is <strong>66</strong>.",
hasFigure: false,
},
{
id: "93de3f84",
type: "mcq",
questionHtml:
"The volume of right circular cylinder A is 22 cubic centimeters. What is the volume, in cubic centimeters, of a right circular cylinder with twice the radius and half the height of cylinder A?",
choices: [
{ label: "A", text: "11" },
{ label: "B", text: "22" },
{ label: "C", text: "44" },
{ label: "D", text: "66" },
],
correctAnswer: "C",
explanation:
"Choice C is correct. The volume of right circular cylinder A is given by the expression <strong>π r² · h</strong>, where r is the radius of its circular base and h is its height. The volume of a cylinder with twice the radius and half the height of cylinder A is given by <strong>π · (2 r, ), ² · one half h</strong>, which is equivalent to <strong>4 π r² · one half h, and = 2 π r² · h</strong>. Therefore, the volume is twice the volume of cylinder A, or <strong>2 · 22 = 44</strong>.Choice A is incorrect and likely results from not multiplying the radius of cylinder A by 2. Choice B is incorrect and likely results from not squaring the 2 in 2r when applying the volume formula. Choice D is incorrect and likely results from a conceptual error.",
hasFigure: false,
},
{
id: "9966235e",
type: "mcq",
questionHtml:
"A cube has an edge length of <strong>68</strong> inches. A solid sphere with a radius of <strong>34</strong> inches is inside the cube, such that the sphere touches the center of each face of the cube. To the nearest cubic inch, what is the volume of the space in the cube not taken up by the sphere?",
choices: [
{ label: "A", text: "<strong>149, 796</strong>" },
{ label: "B", text: "<strong>164, 500</strong>" },
{ label: "C", text: "<strong>190, 955</strong>" },
{ label: "D", text: "<strong>310, 800</strong>" },
],
correctAnswer: "A",
explanation:
"Choice A is correct. The volume of a cube can be found by using the formula <strong>V = s³</strong>, where <strong>V</strong> is the volume and <strong>s</strong> is the edge length of the cube. Therefore, the volume of the given cube is <strong>V = 68³</strong>, or <strong>314, 432</strong> cubic inches. The volume of a sphere can be found by using the formula <strong>V = four thirds π r³</strong> , where <strong>V</strong> is the volume and <strong>r</strong> is the radius of the sphere. Therefore, the volume of the given sphere is <strong>V = four thirds π (34)³</strong>, or approximately <strong>164, 636</strong> cubic inches. The volume of the space in the cube not taken up by the sphere is the difference between the volume of the cube and volume of the sphere. Subtracting the approximate volume of the sphere from the volume of the cube gives <strong>314, 432 164, 636 = 149, 796</strong> cubic inches.<br>Choice B is incorrect and may result from conceptual or calculation errors.<br>Choice C is incorrect and may result from conceptual or calculation errors.<br>Choice D is incorrect and may result from conceptual or calculation errors.",
hasFigure: false,
},
{
id: "9f934297",
type: "spr",
questionHtml:
"A right rectangular prism has a length of <strong>28 centimeters (cm)</strong>, a width of <strong>15 cm</strong>, and a height of <strong>16 cm</strong>. What is the surface area, <strong>in cm²</strong>, of the right rectangular prism?",
choices: [],
correctAnswer: "2216",
explanation:
"The correct answer is <strong>2, 216</strong>. The surface area of a prism is the sum of the areas of all its faces. A right rectangular prism consists of six rectangular faces, where opposite faces are congruent. It's given that this prism has a length of <strong>28 cm</strong>, a width of <strong>15 cm</strong>, and a height of <strong>16 cm</strong>. Thus, for this prism, there are two faces with area <strong>(28) (15) cm²</strong>, two faces with area <strong>(28) (16) cm²</strong>, and two faces with area <strong>(15) (16) cm²</strong>. Therefore, the surface area, <strong>in cm²</strong>, of the right rectangular prism is <strong>2 (28) (15) + 2 (28) (16) + 2 (15) (16)</strong>, or <strong>2, 216</strong>.",
hasFigure: false,
},
{
id: "a07ed090",
type: "mcq",
questionHtml:
"The figure shown is a right circular cylinder with a radius of <strong>r</strong> and height of <strong>h</strong>. A second right circular cylinder (not shown) has a volume that is <strong>392</strong> times as large as the volume of the cylinder shown. Which of the following could represent the radius <strong>R</strong>, in terms of <strong>r</strong>, and the height <strong>H</strong>, in terms of <strong>h</strong>, of the second cylinder?",
choices: [
{
label: "A",
text: "<strong>R = 8 r</strong> and <strong>H = 7 h</strong>",
},
{
label: "B",
text: "<strong>R = 8 r</strong> and <strong>H = 49 h</strong>",
},
{
label: "C",
text: "<strong>R = 7 r</strong> and <strong>H = 8 h</strong>",
},
{
label: "D",
text: "<strong>R = 49 r</strong> and <strong>H = 8 h</strong>",
},
],
correctAnswer: "C",
explanation:
"Choice C is correct. The volume of a right circular cylinder is equal to <strong>π a² b</strong>, where <strong>a</strong> is the radius of a base of the cylinder and <strong>b</strong> is the height of the cylinder. Its given that the cylinder shown has a radius of <strong>r</strong> and a height of <strong>h</strong>. It follows that the volume of the cylinder shown is equal to <strong>π r² h</strong>. Its given that the second right circular cylinder has a radius of <strong>R</strong> and a height of <strong>H</strong>. It follows that the volume of the second cylinder is equal to <strong>π R² H</strong>. Choice C gives <strong>R = 7 r</strong> and <strong>H = 8 h</strong>. Substituting <strong>7 r</strong> for <strong>R</strong> and <strong>8 h</strong> for <strong>H</strong> in the expression that represents the volume of the second cylinder yields <strong>π (7 r)² (8 h)</strong>, or <strong>π (49 r²) (8 h)</strong>, which is equivalent to <strong>π (392 r² h)</strong>, or <strong>392 (π r² h)</strong>. This expression is equal to <strong>392</strong> times the volume of the cylinder shown, <strong>π r² h</strong>. Therefore, <strong>R = 7 r</strong> and <strong>H = 8 h</strong> could represent the radius <strong>R</strong>, in terms of <strong>r</strong>, and the height <strong>H</strong>, in terms of <strong>h</strong>, of the second cylinder.<br>Choice A is incorrect. Substituting <strong>8 r</strong> for <strong>R</strong> and <strong>7 h</strong> for <strong>H</strong> in the expression that represents the volume of the second cylinder yields <strong>π (8 r)² (7 h)</strong>, or <strong>π (64 r²) (7 h)</strong>, which is equivalent to <strong>π (448 r² h)</strong>, or <strong>448 (π r² h)</strong>. This expression is equal to <strong>448</strong>, not <strong>392</strong>, times the volume of the cylinder shown. <br>Choice B is incorrect. Substituting <strong>8 r</strong> for <strong>R</strong> and <strong>49 h</strong> for <strong>H</strong> in the expression that represents the volume of the second cylinder yields <strong>π (8 r)² (49 h)</strong>, or <strong>π (64 r²) (49 h)</strong>, which is equivalent to <strong>π (3, 136 r² h)</strong>, or <strong>3, 136 (π r² h)</strong>. This expression is equal to <strong>3, 136</strong>, not <strong>392</strong>, times the volume of the cylinder shown.<br>Choice D is incorrect. Substituting <strong>49 r</strong> for <strong>R</strong> and <strong>8 h</strong> for <strong>H</strong> in the expression that represents the volume of the second cylinder yields <strong>π (49 r)² (8 h)</strong>, or <strong>π (2, 401 r²) (8 h)</strong>, which is equivalent to <strong>π (19, 208 r² h)</strong>, or <strong>19, 208 (π r² h)</strong>. This expression is equal to <strong>19, 208</strong>, not <strong>392</strong>, times the volume of the cylinder shown.",
hasFigure: true,
figureUrl: "/practice-images/a07ed090_svg1.svg",
},
{
id: "b0dc920d",
type: "mcq",
questionHtml:
"A manufacturer determined that right cylindrical containers with a height that is 4 inches longer than the radius offer the optimal number of containers to be displayed on a shelf. Which of the following expresses the volume, V, in cubic inches, of such containers, where r is the radius, in inches?",
choices: [
{ label: "A", text: "<strong>V = 4 π r³</strong>" },
{ label: "B", text: "<strong>V = π · (2 r, ), ³</strong>" },
{ label: "C", text: "<strong>V = π r² + 4 π r</strong>" },
{ label: "D", text: "<strong>V = π r³ + 4 π r²</strong>" },
],
correctAnswer: "D",
explanation:
"Choice D is correct. The volume, V, of a right cylinder is given by the formula <strong>V = π r² · h</strong>, where r represents the radius of the base of the cylinder and h represents the height. Since the height is 4 inches longer than the radius, the expression <strong>r + 4</strong> represents the height of each cylindrical container. It follows that the volume of each container is represented by the equation <strong>V = π r² · (r + 4, )</strong>. Distributing the expression <strong>π r²</strong> into each term in the parentheses yields <strong>V = π r³ + 4 π r²</strong>.Choice A is incorrect and may result from representing the height as <strong>4 r</strong> instead of <strong>r + 4</strong>. Choice B is incorrect and may result from representing the height as <strong>2 r</strong> instead of <strong>r + 4</strong>. Choice C is incorrect and may result from representing the volume of a right cylinder as <strong>V = π r h</strong> instead of <strong>V = π r² · h</strong>.",
hasFigure: false,
},
{
id: "ba8ca563",
type: "spr",
questionHtml:
"A cube has a volume of <strong>474, 552</strong> cubic units. What is the surface area, in square units, of the cube?",
choices: [],
correctAnswer: "36504",
explanation:
"The correct answer is <strong>36, 504</strong>. The volume of a cube can be found using the formula <strong>V = s³</strong>, where <strong>s</strong> represents the edge length of a cube. Its given that this cube has a volume of <strong>474, 552</strong> cubic units. Substituting <strong>474, 552</strong> for <strong>V</strong> in <strong>V = s³</strong> yields <strong>474, 552 = s³</strong>. Taking the cube root of both sides of this equation yields <strong>78 = s</strong>. Thus, the edge length of the cube is <strong>78</strong> units. Since each face of a cube is a square, it follows that each face has an edge length of <strong>78</strong> units. The area of a square can be found using the formula <strong>A = s²</strong>. Substituting <strong>78</strong> for <strong>s</strong> in this formula yields <strong>A = 78²</strong>, or <strong>A = 6, 084</strong>. Therefore, the area of one face of this cube is <strong>6, 084</strong> square units. Since a cube has <strong>6</strong> faces, the surface area, in square units, of this cube is <strong>6 (6, 084)</strong>, or <strong>36, 504</strong>.",
hasFigure: false,
},
{
id: "dc71597b",
type: "mcq",
questionHtml:
"A right circular cone has a volume of <strong>one third, π</strong> cubic feet and a height of 9 feet. What is the radius, in feet, of the base of the cone?",
choices: [
{ label: "A", text: "<strong>one third</strong>" },
{
label: "B",
text: "<strong>the fraction 1 over the √ 3, end fraction</strong>",
},
{ label: "C", text: "<strong>the √ 3</strong>" },
{ label: "D", text: "<strong>3</strong>" },
],
correctAnswer: "A",
explanation:
"Choice A is correct. The equation for the volume of a right circular cone is <strong>V = one third π r² · h</strong>. Its given that the volume of the right circular cone is <strong>one third π</strong> cubic feet and the height is 9 feet. Substituting these values for V and h, respectively, gives <strong>one third π = one third π r² · 9</strong>. Dividing both sides of the equation by <strong>one third π</strong> gives <strong>1 = r² · 9</strong>. Dividing both sides of the equation by 9 gives <strong>one ninth = r²</strong>. Taking the square root of both sides results in two possible values for the radius, <strong>the √ one ninth</strong> or <strong>the of the √ one ninth</strong>. Since the radius cant have a negative value, that leaves <strong>the √ one ninth</strong> as the only possibility. Applying the quotient property of square roots, <strong>the √ the fraction a, over b = the fraction the √ a, over the √ b</strong>, results in <strong>r = the fraction the √ 1 over the √ 9</strong>, or <strong>r = one third</strong>.Choices B and C are incorrect and may result from incorrectly evaluating <strong>the √ one ninth</strong>. Choice D is incorrect and may result from solving <strong>r² = 9</strong> instead of <strong>r² = one ninth</strong>.",
hasFigure: false,
},
{
id: "e5c57163",
type: "spr",
questionHtml:
"Square A has side lengths that are <strong>166</strong> times the side lengths of square B. The area of square A is <strong>k</strong> times the area of square B. What is the value of <strong>k</strong>?",
choices: [],
correctAnswer: "27556",
explanation:
"The correct answer is <strong>27, 556</strong>. The area of a square is <strong>s²</strong>, where <strong>s</strong> is the side length of the square. Let <strong>x</strong> represent the length of each side of square B. Substituting <strong>x</strong> for <strong>s</strong> in <strong>s²</strong> yields <strong>x²</strong>. It follows that the area of square B is <strong>x²</strong>. Its given that square A has side lengths that are <strong>166</strong> times the side lengths of square B. Since <strong>x</strong> represents the length of each side of square B, the length of each side of square A can be represented by the expression <strong>166 x</strong>. It follows that the area of square A is <strong>(166 x)²</strong>, or <strong>27, 556 x²</strong>. Its given that the area of square A is <strong>k</strong> times the area of square B. Since the area of square A is equal to <strong>27, 556 x²</strong>, and the area of square B is equal to <strong>x²</strong>, an equation representing the given statement is <strong>27, 556 x² = k x²</strong>. Since <strong>x</strong> represents the length of each side of square B, the value of <strong>x</strong> must be positive. Therefore, the value of <strong>x²</strong> is also positive, so it does not equal <strong>0</strong>. Dividing by <strong>x²</strong> on both sides of the equation <strong>27, 556 x² = k x²</strong> yields <strong>27, 556 = k</strong>. Therefore, the value of <strong>k</strong> is <strong>27, 556</strong>.",
hasFigure: false,
},
{
id: "eb70d2d0",
type: "spr",
questionHtml:
"Moving from left to right, the points have the following coordinates:<br><br>(negative 3 comma 4)<br>(4 comma negative 3)<br>(5 comma 3)<br><br>What is the area, in square units, of the triangle formed by connecting the three points shown?",
choices: [],
correctAnswer: "24.5, 49/2",
explanation:
"The correct answer is <strong>24.5</strong>. It's given that a triangle is formed by connecting the three points shown, which are <strong>(3, 4)</strong>, <strong>(5, 3)</strong>, and <strong>(4 3)</strong>. Let this triangle be triangle A. The area of triangle A can be found by calculating the area of the rectangle that circumscribes it and subtracting the areas of the three triangles that are inside the rectangle but outside triangle A. The rectangle formed by the points <strong>(3, 4)</strong>, <strong>(5, 4)</strong>, <strong>(5 3)</strong>, and <strong>(3 3)</strong> circumscribes triangle A. The width, in units, of this rectangle can be found by calculating the distance between the points <strong>(5, 4)</strong> and <strong>(5 3)</strong>. This distance is <strong>4 (3)</strong>, or <strong>7</strong>. The length, in units, of this rectangle can be found by calculating the distance between the points <strong>(5, 4)</strong> and <strong>(3, 4)</strong>. This distance is <strong>5 (3)</strong>, or <strong>8</strong>. It follows that the area, in square units, of the rectangle is <strong>(7) (8)</strong>, or <strong>56</strong>. One of the triangles that lies inside the rectangle but outside triangle A is formed by the points <strong>(3, 4)</strong>, <strong>(5, 4)</strong>, and <strong>(5, 3)</strong>. The length, in units, of a base of this triangle can be found by calculating the distance between the points <strong>(5, 4)</strong> and <strong>(5, 3)</strong>. This distance is <strong>4 3</strong>, or <strong>1</strong>. The corresponding height, in units, of this triangle can be found by calculating the distance between the points <strong>(5, 4)</strong> and <strong>(3, 4)</strong>. This distance is <strong>5 (3)</strong>, or <strong>8</strong>. It follows that the area, in square units, of this triangle is <strong>one half (8) (1)</strong>, or <strong>4</strong>. A second triangle that lies inside the rectangle but outside triangle A is formed by the points <strong>(4 3)</strong>, <strong>(5, 3)</strong>, and <strong>(5 3)</strong>. The length, in units, of a base of this triangle can be found by calculating the distance between the points <strong>(5, 3)</strong> and <strong>(5 3)</strong>. This distance is <strong>3 (3)</strong> , or <strong>6</strong>. The corresponding height, in units, of this triangle can be found by calculating the distance between the points <strong>(5 3)</strong> and <strong>(4 3)</strong>. This distance is <strong>5 4</strong>, or <strong>1</strong>. It follows that the area, in square units, of this triangle is <strong>one half (1) (6)</strong>, or <strong>3</strong>. The third triangle that lies inside the rectangle but outside triangle A is formed by the points <strong>(3, 4)</strong>, <strong>(3 3)</strong>, and <strong>(4 3)</strong>. The length, in units, of a base of this triangle can be found by calculating the distance between the points <strong>(4 3)</strong> and <strong>(3 3)</strong>. This distance is <strong>4 (3)</strong>, or <strong>7</strong>. The corresponding height, in units, of this triangle can be found by calculating the distance between the points <strong>(3, 4)</strong> and <strong>(3 3)</strong>. This distance is <strong>4 (3)</strong>, or <strong>7</strong>. It follows that the area, in square units, of this triangle is <strong>one half (7) (7)</strong>, or <strong>24.5</strong>. Thus, the area, in square units, of the triangle formed by connecting the three points shown is <strong>56 4 3 24.5</strong>, or <strong>24.5</strong>. Note that 24.5 and 49/2 are examples of ways to enter a correct answer.",
hasFigure: true,
figureUrl: "/practice-images/eb70d2d0_svg1.svg",
},
{
id: "f243c383",
type: "mcq",
questionHtml:
"Two identical rectangular prisms each have a height of <strong>90 centimeters (cm)</strong>. The base of each prism is a square, and the surface area of each prism is <strong>K cm²</strong>. If the prisms are glued together along a square base, the resulting prism has a surface area of <strong>(92) / (47) K cm²</strong>. What is the side length, in <strong>cm</strong>, of each square base?",
choices: [
{ label: "A", text: "<strong>4</strong>" },
{ label: "B", text: "<strong>8</strong>" },
{ label: "C", text: "<strong>9</strong>" },
{ label: "D", text: "<strong>16</strong>" },
],
correctAnswer: "B",
explanation:
"Choice B is correct. Let <strong>x</strong> represent the side length, in <strong>cm</strong>, of each square base. If the two prisms are glued together along a square base, the resulting prism has a surface area equal to twice the surface area of one of the prisms, minus the area of the two square bases that are being glued together, which yields <strong>2 K 2 x² cm²</strong> . Its given that this resulting surface area is equal to <strong>(92) / (47) K cm²</strong>, so <strong>2 K 2 x² = (92) / (47) K</strong>. Subtracting <strong>(92) / (47) K</strong> from both sides of this equation yields <strong>2 K (92) / (47) K 2 x² = 0</strong>. This equation can be rewritten by multiplying <strong>2 K</strong> on the left-hand side by <strong>(47) / (47)</strong>, which yields <strong>(94) / (47) K (92) / (47) K 2 x² = 0</strong>, or <strong>two forty sevenths K 2 x² = 0</strong>. Adding <strong>2 x²</strong> to both sides of this equation yields <strong>two forty sevenths K = 2 x²</strong>. Multiplying both sides of this equation by <strong>(47) / (2)</strong> yields <strong>K = 47 x²</strong>. The surface area <strong>K</strong>, in <strong>cm²</strong>, of each rectangular prism is equivalent to the sum of the areas of the two square bases and the areas of the four lateral faces. Since the height of each rectangular prism is <strong>90 cm</strong> and the side length of each square base is <strong>x cm</strong>, it follows that the area of each square base is <strong>x² cm²</strong> and the area of each lateral face is <strong>90 x cm²</strong>. Therefore, the surface area of each rectangular prism can be represented by the expression <strong>2 x² + 4 (90 x)</strong>, or <strong>2 x² + 360 x</strong>. Substituting this expression for <strong>K</strong> in the equation <strong>K = 47 x²</strong> yields <strong>2 x² + 360 x = 47 x²</strong>. Subtracting <strong>2 x²</strong> and <strong>360 x</strong> from both sides of this equation yields <strong>0 = 45 x² 360 x</strong>. Factoring <strong>x</strong> from the right-hand side of this equation yields <strong>0 = x (45 x 360)</strong>. Applying the zero product property, it follows that <strong>x = 0</strong> and <strong>45 x 360 = 0</strong>. Adding <strong>360</strong> to both sides of the equation <strong>45 x 360 = 0</strong> yields <strong>45 x = 360</strong>. Dividing both sides of this equation by <strong>45</strong> yields <strong>x = 8</strong>. Since a side length of a rectangular prism cant be <strong>0</strong>, the length of each square base is <strong>8</strong> <strong>cm</strong>.<br>Choice A is incorrect and may result from conceptual or calculation errors.<br>Choice C is incorrect and may result from conceptual or calculation errors.<br>Choice D is incorrect and may result from conceptual or calculation errors.",
hasFigure: false,
},
{
id: "f329442c",
type: "mcq",
questionHtml:
"Circle <strong>A</strong> has a radius of <strong>3 n</strong> and circle <strong>B</strong> has a radius of <strong>129 n</strong>, where <strong>n</strong> is a positive constant. The area of circle <strong>B</strong> is how many times the area of circle <strong>A</strong>?",
choices: [
{ label: "A", text: "<strong>43</strong>" },
{ label: "B", text: "<strong>86</strong>" },
{ label: "C", text: "<strong>129</strong>" },
{ label: "D", text: "<strong>1, 849</strong>" },
],
correctAnswer: "D",
explanation:
"Choice D is correct. The area of a circle can be found by using the formula <strong>A = π r²</strong>, where <strong>A</strong> is the area and <strong>r</strong> is the radius of the circle. Its given that the radius of circle A is <strong>3 n</strong>. Substituting this value for <strong>r</strong> into the formula <strong>A = π r²</strong> gives <strong>A = π (3 n)²</strong>, or <strong>9 π n²</strong>. Its also given that the radius of circle B is <strong>129 n</strong>. Substituting this value for <strong>r</strong> into the formula <strong>A = π r²</strong> gives <strong>A = π (129 n)²</strong>, or <strong>16, 641 π n²</strong>. Dividing the area of circle B by the area of circle A gives <strong>(16, 641 π n²) / (9 π n²)</strong>, which simplifies to <strong>1, 849</strong>. Therefore, the area of circle B is <strong>1, 849</strong> times the area of circle A.<br>Choice A is incorrect. This is how many times greater the radius of circle B is than the radius of circle A.<br>Choice B is incorrect and may result from conceptual or calculation errors.<br>Choice C is incorrect. This is the coefficient on the term that describes the radius of circle B.",
hasFigure: false,
},
{
id: "f7e626b2",
type: "mcq",
questionHtml:
"The dimensions of a right rectangular prism are 4 inches by 5 inches by 6 inches. What is the surface area, in square inches, of the prism?",
choices: [
{ label: "A", text: "30" },
{ label: "B", text: "74" },
{ label: "C", text: "120" },
{ label: "D", text: "148" },
],
correctAnswer: "",
explanation:
"Choice D is correct. The surface area is found by summing the area of each face. A right rectangular prism consists of three pairs of congruent rectangles, so the surface area is found by multiplying the areas of three adjacent rectangles by 2 and adding these products. For this prism, the surface area is equal to <strong>2 · (4 · 5, ) + 2 · (5 · 6, ) + 2 · (4 · 6, )</strong>, or <strong>2 · 20 + 2 · 30 + 2 · 24</strong>, which is equal to 148.Choice A is incorrect. This is the area of one of the faces of the prism. Choice B is incorrect and may result from adding the areas of three adjacent rectangles without multiplying by 2. Choice C is incorrect. This is the volume, in cubic inches, of the prism.",
hasFigure: false,
},
];

424
src/data/math/circles.ts Normal file
View File

@ -0,0 +1,424 @@
import { type PracticeQuestion } from "../../types/lesson";
export const CIRCLES_EASY: PracticeQuestion[] = [
{
id: "23c5fcce",
type: "mcq",
questionHtml:
"The circle above with center O has a circumference of 36. What is the length of minor arc <strong>A, C</strong>?",
choices: [
{ label: "A", text: "9" },
{ label: "B", text: "12" },
{ label: "C", text: "18" },
{ label: "D", text: "36" },
],
correctAnswer: "A",
explanation:
"Choice A is correct. A circle has 360 degrees of arc. In the circle shown, O is the center of the circle and <strong>angle A, O C</strong> is a central angle of the circle. From the figure, the two diameters that meet to form <strong>angle A, O C</strong> are perpendicular, so the measure of <strong>angle A, O C</strong> is <strong>90 °</strong>. Therefore, the length of minor arc <strong>A, C</strong> is <strong>the fraction 90 over 360</strong> of the circumference of the circle. Since the circumference of the circle is 36, the length of minor arc <strong>A, C</strong> is <strong>the fraction 90 over 360, end fraction · 36 = 9</strong>.Choices B, C, and D are incorrect. The perpendicular diameters divide the circumference of the circle into four equal arcs; therefore, minor arc <strong>A, C</strong> is <strong>one fourth</strong> of the circumference. However, the lengths in choices B and C are, respectively, <strong>one third</strong> and <strong>one half</strong> the circumference of the circle, and the length in choice D is the length of the entire circumference. None of these lengths is <strong>one fourth</strong> the circumference.",
hasFigure: true,
figureUrl: "/practice-images/23c5fcce_img1.png",
},
];
export const CIRCLES_MEDIUM: PracticeQuestion[] = [
{
id: "0815a5af",
type: "mcq",
questionHtml:
"The center of the circle is point upper O.<br>Points upper S, upper R, upper Q, and upper P are on the circle.<br>Line segment upper P upper R is a diameter of the circle.<br>Line segment upper Q upper S is a diameter of the circle.<br>Diameters upper P upper R and upper Q upper S intersect at point upper O.<br>A note indicates the figure is not drawn to scale.<br><br>The circle shown has center <strong>O</strong>, circumference <strong>144 π</strong>, and diameters <strong>P R</strong> and <strong>Q S</strong>. The length of arc <strong>P S</strong> is twice the length of arc <strong>P Q</strong>. What is the length of arc <strong>Q R</strong>?",
choices: [
{ label: "A", text: "<strong>24 π</strong>" },
{ label: "B", text: "<strong>48 π</strong>" },
{ label: "C", text: "<strong>72 π</strong>" },
{ label: "D", text: "<strong>96 π</strong>" },
],
correctAnswer: "B",
explanation:
"Choice B is correct. Since <strong>P R</strong> and <strong>Q S</strong> are diameters of the circle shown, <strong>O S</strong>, <strong>O R</strong>, <strong>O P</strong>, and <strong>O Q</strong> are radii of the circle and are therefore congruent. Since <strong>angle S O P</strong> and <strong>angle R O Q</strong> are vertical angles, they are congruent. Therefore, arc <strong>P S</strong> and arc <strong>Q R</strong> are formed by congruent radii and have the same angle measure, so they are congruent arcs. Similarly, <strong>angle S O R</strong> and <strong>angle P O Q</strong> are vertical angles, so they are congruent. Therefore, arc <strong>S R</strong> and arc <strong>P Q</strong> are formed by congruent radii and have the same angle measure, so they are congruent arcs. Let <strong>x</strong> represent the length of arc <strong>S R</strong>. Since arc <strong>S R</strong> and arc <strong>P Q</strong> are congruent arcs, the length of arc <strong>P Q</strong> can also be represented by <strong>x</strong>. Its given that the length of arc <strong>P S</strong> is twice the length of arc <strong>P Q</strong>. Therefore, the length of arc <strong>P S</strong> can be represented by the expression <strong>2 x</strong>. Since arc <strong>P S</strong> and arc <strong>Q R</strong> are congruent arcs, the length of arc <strong>Q R</strong> can also be represented by <strong>2 x</strong>. This gives the expression <strong>x + x + 2 x + 2 x</strong>. Since it's given that the circumference is <strong>144 π</strong>, the expression <strong>x + x + 2 x + 2 x</strong> is equal to <strong>144 π</strong>. Thus <strong>x + x + 2 x + 2 x = 144 π</strong>, or <strong>6 x = 144 π</strong>. Dividing both sides of this equation by <strong>6</strong> yields <strong>x = 24 π</strong>. Therefore, the length of arc <strong>Q R</strong> is <strong>2 (24 π)</strong>, or <strong>48 π</strong>.<br>Choice A is incorrect. This is the length of arc <strong>P Q</strong>, not arc <strong>Q R</strong>.<br>Choice C is incorrect and may result from conceptual or calculation errors.<br>Choice D is incorrect and may result from conceptual or calculation errors.",
hasFigure: true,
figureUrl: "/practice-images/0815a5af_svg1.svg",
},
{
id: "74d8b897",
type: "spr",
questionHtml:
"An angle has a measure of <strong>(9 π) / (20)</strong> radians. What is the measure of the angle in degrees?",
choices: [],
correctAnswer: "81",
explanation:
"The correct answer is <strong>81</strong>. The measure of an angle, in degrees, can be found by multiplying its measure, in radians, by <strong>(180 °) / (π radians)</strong>. Multiplying the given angle measure, <strong>(9 π) / (20)</strong> radians, by <strong>(180 °) / (π radians)</strong> yields <strong>((9 π) / (20) radians) ((180 °) / (π radians))</strong>, which is equivalent to <strong>81</strong> degrees.",
hasFigure: false,
},
{
id: "82c8325f",
type: "mcq",
questionHtml:
"A circle in the xy-plane has its center at <strong>(4, 5)</strong> and the point <strong>(8, 8)</strong> lies on the circle. Which equation represents this circle?",
choices: [
{ label: "A", text: "<strong>(x 4)² + (y + 5)² = 5</strong>" },
{ label: "B", text: "<strong>(x + 4)² + (y 5)² = 5</strong>" },
{ label: "C", text: "<strong>(x 4)² + (y + 5)² = 25</strong>" },
{ label: "D", text: "<strong>(x + 4)² + (y 5)² = 25</strong>" },
],
correctAnswer: "D",
explanation:
"Choice D is correct. A circle in the xy-plane can be represented by an equation of the form <strong>(x h)² + (y k)² = r²</strong>, where <strong>(h, k)</strong> is the center of the circle and <strong>r</strong> is the length of a radius of the circle. It's given that the circle has its center at <strong>(4, 5)</strong>. Therefore, <strong>h = 4</strong> and <strong>k = 5</strong>. Substituting <strong>4</strong> for <strong>h</strong> and <strong>5</strong> for <strong>k</strong> in the equation <strong>(x h)² + (y k)² = r²</strong> yields <strong>(x (4))² + (y 5)² = r²</strong>, or <strong>(x + 4)² + (y 5)² = r²</strong>. It's also given that the point <strong>(8, 8)</strong> lies on the circle. Substituting <strong>8</strong> for <strong>x</strong> and <strong>8</strong> for <strong>y</strong> in the equation <strong>(x + 4)² + (y 5)² = r²</strong> yields <strong>(8 + 4)² + (8 5)² = r²</strong>, or <strong>(4)² + (3)² = r²</strong>, which is equivalent to <strong>16 + 9 = r²</strong>, or <strong>25 = r²</strong>. Substituting <strong>25</strong> for <strong>r²</strong> in the equation <strong>(x + 4)² + (y 5)² = r²</strong> yields <strong>(x + 4)² + (y 5)² = 25</strong>. Thus, the equation <strong>(x + 4)² + (y 5)² = 25</strong> represents the circle.<br>Choice A is incorrect. The circle represented by this equation has its center at <strong>(4 5)</strong>, not <strong>(4, 5)</strong>, and the point <strong>(8, 8)</strong> doesn't lie on the circle.<br>Choice B is incorrect. The point <strong>(8, 8)</strong> doesn't lie on the circle represented by this equation.<br>Choice C is incorrect. The circle represented by this equation has its center at <strong>(4 5)</strong>, not <strong>(4, 5)</strong>, and the point <strong>(8, 8)</strong> doesn't lie on the circle.",
hasFigure: false,
},
{
id: "856372ca",
type: "mcq",
questionHtml:
"In the xy-plane, a circle with radius 5 has center <strong>with coordinates 8, 6</strong>. Which of the following is an equation of the circle?",
choices: [
{ label: "A", text: "<strong>(x 8, ), ² + (y + 6, ), ² = 25</strong>" },
{ label: "B", text: "<strong>(x + 8, ), ² + (y 6, ), ² = 25</strong>" },
{ label: "C", text: "<strong>(x 8, ), ² + (y + 6, ), ² = 5</strong>" },
{ label: "D", text: "<strong>(x + 8, ), ² + (y 6, ), ² = 5</strong>" },
],
correctAnswer: "B",
explanation:
"Choice B is correct. An equation of a circle is <strong>(x h, ), ² + (y k, ), ² = r²</strong>, where the center of the circle is <strong>h, k</strong> and the radius is r. Its given that the center of this circle is <strong>8, 6</strong> and the radius is 5. Substituting these values into the equation gives <strong>(x 8, ), ² + (y 6, ), ² = 5²</strong>, or <strong>(x + 8, ), ² + (y 6, ), ² = 25</strong>.Choice A is incorrect. This is an equation of a circle that has center <strong>8 6</strong>. Choice C is incorrect. This is an equation of a circle that has center <strong>8 6</strong> and radius <strong>the √ 5</strong>. Choice D is incorrect. This is an equation of a circle that has radius  <strong>the √ 5</strong>.",
hasFigure: false,
},
{
id: "8e7689e0",
type: "spr",
questionHtml:
"The number of radians in a 720-degree angle can be written as <strong>a · π</strong>, where a is a constant. What is the value of a ?",
choices: [],
correctAnswer: "",
explanation:
"The correct answer is 4. There are <strong>π</strong> radians in a <strong>180 °</strong> angle. An angle measure of <strong>720 °</strong> is 4 times greater than an angle measure of <strong>180 °</strong>. Therefore, the number of radians in a <strong>720 °</strong> angle is <strong>4 π</strong>.",
hasFigure: false,
},
{
id: "95ba2d09",
type: "mcq",
questionHtml:
"In the xy-plane above, points P, Q, R, and T lie on the circle with center O. The degree measures of angles <strong>P O Q</strong> and <strong>R O T</strong> are each 30°. What is the radian measure of angle <strong>Q O R</strong> ?",
choices: [
{ label: "A", text: "<strong>five sixths, π</strong>" },
{ label: "B", text: "<strong>three fourths, π</strong>" },
{ label: "C", text: "<strong>two thirds, π</strong>" },
{ label: "D", text: "<strong>one third, π</strong>" },
],
correctAnswer: "C",
explanation:
"Choice C is correct. Because points T, O, and P all lie on the x-axis, they form a line. Since the angles on a line add up to <strong>180 °</strong>, and its given that angles POQ and ROT each measure <strong>30 °</strong>, it follows that the measure of angle QOR is <strong>180 ° 30 ° 30 ° = 120 °</strong>. Since the arc of a complete circle is <strong>360 °</strong> or <strong>2 π</strong> radians, a proportion can be set up to convert the measure of angle QOR from degrees to radians: <strong>the fraction 360 ° over 2 π radians = the fraction 120 ° over x radians</strong>, where x is the radian measure of angle QOR. Multiplying each side of the proportion by <strong>2 π x</strong> gives <strong>360 x = 240 π</strong>. Solving for x gives <strong>the fraction 240 over 360 · π</strong>, or <strong>two thirds π</strong>.Choice A is incorrect and may result from subtracting only angle POQ from <strong>180 °</strong>to get a value of <strong>150 °</strong>and then finding the radian measure equivalent to that value. Choice B is incorrect and may result from a calculation error. Choice D is incorrect and may result from calculating the sum of the angle measures, in radians, of angles POQ and ROT.",
hasFigure: true,
figureUrl: "/practice-images/95ba2d09_img1.png",
},
{
id: "a0cacec1",
type: "spr",
questionHtml:
"An angle has a measure of <strong>(16 π) / (15)</strong> radians. What is the measure of the angle, in degrees?",
choices: [],
correctAnswer: "192",
explanation:
"The correct answer is <strong>192</strong>. The measure of an angle, in degrees, can be found by multiplying its measure, in radians, by <strong>(180 °) / (π radians)</strong>. Multiplying the given angle measure, <strong>(16 π) / (15) radians</strong>, by <strong>(180 °) / (π radians)</strong> yields <strong>((16 π) / (15) radians) ((180 °) / (π r a d i a n s))</strong>, which simplifies to <strong>192</strong> degrees.",
hasFigure: false,
},
{
id: "f1c1e971",
type: "mcq",
questionHtml:
"The measure of angle <strong>R</strong> is <strong>(2 π) / (3)</strong> radians. The measure of angle <strong>T</strong> is <strong>(5 π) / (12)</strong> radians greater than the measure of angle <strong>R</strong>. What is the measure of angle <strong>T</strong>, in degrees?",
choices: [
{ label: "A", text: "<strong>75</strong>" },
{ label: "B", text: "<strong>120</strong>" },
{ label: "C", text: "<strong>195</strong>" },
{ label: "D", text: "<strong>390</strong>" },
],
correctAnswer: "C",
explanation:
"Choice C is correct. Its given that the measure of angle <strong>R</strong> is <strong>(2 π) / (3)</strong> radians, and the measure of angle <strong>T</strong> is <strong>(5 π) / (12)</strong> radians greater than the measure of angle <strong>R</strong>. Therefore, the measure of angle <strong>T</strong> is equal to <strong>(2 π) / (3) + (5 π) / (12)</strong> radians. Multiplying <strong>(2 π) / (3)</strong> by <strong>four fourths</strong> to get a common denominator with <strong>(5 π) / (12)</strong> yields <strong>(8 π) / (12)</strong>. Therefore, <strong>(2 π) / (3) + (5 π) / (12)</strong> is equivalent to <strong>(8 π) / (12) + (5 π) / (12)</strong>, or <strong>(13 π) / (12)</strong>. Therefore, the measure of angle <strong>T</strong> is <strong>(13 π) / (12)</strong> radians. The measure of angle <strong>T</strong>, in degrees, can be found by multiplying its measure, in radians, by <strong>(180) / (π)</strong>. This yields <strong>(13 π) / (12) · (180) / (π)</strong>, which is equivalent to <strong>195</strong> degrees. Therefore, the measure of angle <strong>T</strong> is <strong>195</strong> degrees.<br>Choice A is incorrect. This is the number of degrees that the measure of angle <strong>T</strong> is greater than the measure of angle <strong>R</strong>.<br>Choice B is incorrect. This is the measure of angle <strong>R</strong>, in degrees.<br>Choice D is incorrect and may result from conceptual or calculation errors.",
hasFigure: false,
},
];
export const CIRCLES_HARD: PracticeQuestion[] = [
{
id: "2266984b",
type: "mcq",
questionHtml:
"The equation above defines a circle in the xy-plane. What are the coordinates of the center of the circle?",
choices: [
{ label: "A", text: "<strong>20 16</strong>" },
{ label: "B", text: "<strong>10 8</strong>" },
{ label: "C", text: "<strong>10, 8</strong>" },
{ label: "D", text: "<strong>20, 16</strong>" },
],
correctAnswer: "B",
explanation:
"Choice B is correct. The standard equation of a circle in the xy-plane is of the form <strong>(x h, ), ² + (y k, ), ² = r²</strong>, where <strong>the ordered pair h, k</strong> are the coordinates of the center of the circle and r is the radius. The given equation can be rewritten in standard form by completing the squares. So the sum of the first two terms, <strong>x² + 20 x</strong>, needs a 100 to complete the square, and the sum of the second two terms, <strong>y² + 16 y</strong>, needs a 64 to complete the square. Adding 100 and 64 to both sides of the given equation yields <strong>(x² + 20 x + 100, ) + (y² + 16 y + 64, ) = 20 + 100 + 64</strong>, which is equivalent to <strong>(x + 10, ), ² + (y + 8, ), ² = 144</strong>. Therefore, the coordinates of the center of the circle are <strong>10 8</strong>.Choices A, C, and D are incorrect and may result from computational errors made when attempting to complete the squares or when identifying the coordinates of the center.",
hasFigure: false,
},
{
id: "249d3f80",
type: "spr",
questionHtml:
"Point <strong>O</strong> is the center of a circle. The measure of arc <strong>R S</strong> on this circle is <strong>100 °</strong>. What is the measure, in degrees, of its associated angle <strong>R O S</strong>?",
choices: [],
correctAnswer: "100",
explanation:
"The correct answer is <strong>100</strong>. It's given that point <strong>O</strong> is the center of a circle and the measure of arc <strong>R S</strong> on the circle is <strong>100 °</strong>. It follows that points <strong>R</strong> and <strong>S</strong> lie on the circle. Therefore, <strong>ModifyingAbove O R With bar</strong> and <strong>ModifyingAbove O S With bar</strong> are radii of the circle. A central angle is an angle formed by two radii of a circle, with its vertex at the center of the circle. Therefore, <strong>angle R O S</strong> is a central angle. Because the degree measure of an arc is equal to the measure of its associated central angle, it follows that the measure, in degrees, of <strong>angle R O S</strong> is <strong>100</strong>.",
hasFigure: false,
},
{
id: "24cec8d1",
type: "spr",
questionHtml:
"A circle has center <strong>O</strong>, and points <strong>R</strong> and <strong>S</strong> lie on the circle. In triangle <strong>O R S</strong>, the measure of <strong>angle R O S</strong> is <strong>88 °</strong>. What is the measure of <strong>angle R S O</strong>, in degrees? (Disregard the degree symbol when entering your answer.)",
choices: [],
correctAnswer: "46",
explanation:
"The correct answer is <strong>46</strong>. It's given that <strong>O</strong> is the center of a circle and that points <strong>R</strong> and <strong>S</strong> lie on the circle. Therefore, <strong>ModifyingAbove O R With bar</strong> and <strong>ModifyingAbove O S With bar</strong> are radii of the circle. It follows that <strong>O R = O S</strong>. If two sides of a triangle are congruent, then the angles opposite them are congruent. It follows that the angles <strong>angle R S O</strong> and <strong>angle O R S</strong>, which are across from the sides of equal length, are congruent. Let <strong>x °</strong> represent the measure of <strong>angle R S O</strong>. It follows that the measure of <strong>angle O R S</strong> is also <strong>x °</strong>. It's given that the measure of <strong>angle R O S</strong> is <strong>88 °</strong>. Because the sum of the measures of the interior angles of a triangle is <strong>180 °</strong>, the equation <strong>x ° + x ° + 88 ° = 180 °</strong>, or <strong>2 x + 88 = 180</strong>, can be used to find the measure of <strong>angle R S O</strong>. Subtracting <strong>88</strong> from both sides of this equation yields <strong>2 x = 92</strong>. Dividing both sides of this equation by <strong>2</strong> yields <strong>x = 46</strong>. Therefore, the measure of <strong>angle R S O</strong>, in degrees, is <strong>46</strong>.",
hasFigure: false,
},
{
id: "3e577e4a",
type: "mcq",
questionHtml:
"A circle in the xy-plane has its center at <strong>(4 6)</strong>. Line <strong>k</strong> is tangent to this circle at the point <strong>(7 7)</strong>. What is the slope of line <strong>k</strong>?",
choices: [
{ label: "A", text: "<strong>3</strong>" },
{ label: "B", text: "<strong>one third</strong>" },
{ label: "C", text: "<strong>one third</strong>" },
{ label: "D", text: "<strong>3</strong>" },
],
correctAnswer: "A",
explanation:
"Choice A is correct. A line that's tangent to a circle is perpendicular to the radius of the circle at the point of tangency. It's given that the circle has its center at <strong>(4 6)</strong> and line <strong>k</strong> is tangent to the circle at the point <strong>(7 7)</strong>. The slope of a radius defined by the points <strong>(q, r)</strong> and <strong>(s, t)</strong> can be calculated as <strong>(t r) / (s q)</strong>. The points <strong>(7 7)</strong> and <strong>(4 6)</strong> define the radius of the circle at the point of tangency. Therefore, the slope of this radius can be calculated as <strong>((6) (7)) / ((4) (7))</strong>, or <strong>one third</strong>. If a line and a radius are perpendicular, the slope of the line must be the negative reciprocal of the slope of the radius. The negative reciprocal of <strong>one third</strong> is <strong>3</strong>. Thus, the slope of line <strong>k</strong> is <strong>3</strong>.<br>Choice B is incorrect and may result from conceptual or calculation errors.<br>Choice C is incorrect. This is the slope of the radius of the circle at the point of tangency, not the slope of line <strong>k</strong>.<br>Choice D is incorrect and may result from conceptual or calculation errors.",
hasFigure: false,
},
{
id: "69b0d79d",
type: "mcq",
questionHtml:
"Point O is the center of the circle above, and the measure of <strong>angle O A, B</strong> is <strong>30 °</strong>. If the length of <strong>O C</strong> is 18, what is the length of arc <strong>A, B</strong>?",
choices: [
{ label: "A", text: "<strong>9 π</strong>" },
{ label: "B", text: "<strong>12 π</strong>" },
{ label: "C", text: "<strong>15 π</strong>" },
{ label: "D", text: "<strong>18 π</strong>" },
],
correctAnswer: "B",
explanation:
"Choice B is correct. Because segments OA and OB are radii of the circle centered at point O, these segments have equal lengths. Therefore, triangle AOB is an isosceles triangle, where angles OAB and OBA are congruent base angles of the triangle. Its given that angle OAB measures <strong>30 °</strong>. Therefore, angle OBA also measures <strong>30 °</strong>. Let <strong>x °</strong> represent the measure of angle AOB. Since the sum of the measures of the three angles of any triangle is <strong>180 °</strong>, it follows that <strong>30 ° + 30 ° + x ° = 180 °</strong>, or <strong>60 ° + x ° = 180 °</strong>. Subtracting <strong>60 °</strong> from both sides of this equation yields <strong>x ° = 120 °</strong>, or <strong>the fraction 2 π over 3</strong> radians. Therefore, the measure of angle AOB, and thus the measure of arc <strong>A, B</strong>, is <strong>the fraction 2 π over 3</strong> radians. Since <strong>the O C</strong> is a radius of the given circle and its length is 18, the length of the radius of the circle is 18. Therefore, the length of arc <strong>A, B</strong> can be calculated as <strong>the fraction 2 π over 3, end fraction · 18</strong>, or <strong>12 π</strong>.Choices A, C, and D are incorrect and may result from conceptual or computational errors.",
hasFigure: true,
figureUrl: "/practice-images/69b0d79d_img1.png",
},
{
id: "76c73dbf",
type: "spr",
questionHtml:
"The graph of <strong>x² + x + y² + y = (199) / (2)</strong> in the xy-plane is a circle. What is the length of the circles radius?",
choices: [],
correctAnswer: "10",
explanation:
"The correct answer is <strong>10</strong>. It's given that the graph of <strong>x² + x + y² + y = (199) / (2)</strong> in the xy-plane is a circle. The equation of a circle in the xy-plane can be written in the form <strong>(x h)² + (y k)² = r²</strong>, where the coordinates of the center of the circle are <strong>(h, k)</strong> and the length of the radius of the circle is <strong>r</strong>. The term <strong>(x h)²</strong> in this equation can be obtained by adding the square of half the coefficient of <strong>x</strong> to both sides of the given equation to complete the square. The coefficient of <strong>x</strong> is <strong>1</strong>. Half the coefficient of <strong>x</strong> is <strong>one half</strong>. The square of half the coefficient of <strong>x</strong> is <strong>one fourth</strong>. Adding <strong>one fourth</strong> to each side of <strong>(x² + x) + (y² + y) = (199) / (2)</strong> yields <strong>(x² + x + one fourth) + (y² + y) = (199) / (2) + one fourth</strong>, or <strong>(x + one half)² + (y² + y) = (199) / (2) + one fourth</strong>. Similarly, the term <strong>(y k)²</strong> can be obtained by adding the square of half the coefficient of <strong>y</strong> to both sides of this equation, which yields <strong>(x + one half)² + (y² + y + one fourth) = (199) / (2) + one fourth + one fourth</strong>, or <strong>(x + one half)² + (y + one half)² = (199) / (2) + one fourth + one fourth</strong>. This equation is equivalent to <strong>(x + one half)² + (y + one half)² = 100</strong>, or <strong>(x + one half)² + (y + one half)² = 10²</strong>. Therefore, the length of the circle's radius is <strong>10</strong>.",
hasFigure: false,
},
{
id: "89661424",
type: "spr",
questionHtml:
"A circle in the xy-plane has its center at <strong>(5, 2)</strong> and has a radius of <strong>9</strong>. An equation of this circle is <strong>x² + y² + a x + b y + c = 0</strong>, where <strong>a</strong>, <strong>b</strong>, and <strong>c</strong> are constants. What is the value of <strong>c</strong>?",
choices: [],
correctAnswer: "-52",
explanation:
"The correct answer is <strong>52</strong>. The equation of a circle in the xy-plane with its center at <strong>(h, k)</strong> and a radius of <strong>r</strong> can be written in the form <strong>(x h)² + (y k)² = r²</strong>. It's given that a circle in the xy-plane has its center at <strong>(5, 2)</strong> and has a radius of <strong>9</strong>. Substituting <strong>5</strong> for <strong>h</strong>, <strong>2</strong> for <strong>k</strong>, and <strong>9</strong> for <strong>r</strong> in the equation <strong>(x h)² + (y k)² = r²</strong> yields <strong>(x (5))² + (y 2)² = 9²</strong>, or <strong>(x + 5)² + (y 2)² = 81</strong>. It's also given that an equation of this circle is <strong>x² + y² + a x + b y + c = 0</strong>, where <strong>a</strong>, <strong>b</strong>, and <strong>c</strong> are constants. Therefore, <strong>(x + 5)² + (y 2)² = 81</strong> can be rewritten in the form <strong>x² + y² + a x + b y + c = 0</strong>. The equation <strong>(x + 5)² + (y 2)² = 81</strong>, or <strong>(x + 5) (x + 5) + (y 2) (y 2) = 81</strong>, can be rewritten as <strong>x² + 5 x + 5 x + 25 + y² 2 y 2 y + 4 = 81</strong>. Combining like terms on the left-hand side of this equation yields <strong>x² + y² + 10 x 4 y + 29 = 81</strong>. Subtracting <strong>81</strong> from both sides of this equation yields <strong>x² + y² + 10 x 4 y 52 = 0</strong>, which is equivalent to <strong>x² + y² + 10 x + (4) y + (52) = 0</strong>. This equation is in the form <strong>x² + y² + a x + b y + c = 0</strong>. Therefore, the value of <strong>c</strong> is <strong>52</strong>.",
hasFigure: false,
},
{
id: "981275d2",
type: "mcq",
questionHtml:
"In the xy-plane, the graph of the equation above is a circle. Point P is on the circle and has coordinates <strong>10 5</strong>. If <strong>P Q</strong> is a diameter of the circle, what are the coordinates of point Q ?",
choices: [
{ label: "A", text: "<strong>2 5</strong>" },
{ label: "B", text: "<strong>6 1</strong>" },
{ label: "C", text: "<strong>6 5</strong>" },
{ label: "D", text: "<strong>6 9</strong>" },
],
correctAnswer: "A",
explanation:
"Choice A is correct. The standard form for the equation of a circle is <strong>(x h, ), ² + (y k, ), ² = r²</strong>, where <strong>the ordered pair h, k</strong> are the coordinates of the center and r is the length of the radius. According to the given equation, the center of the circle is <strong>6 5</strong>. Let <strong>x sub 1, y sub 1</strong> represent the coordinates of point Q. Since point P <strong>10 5</strong> and point Q <strong>x sub 1, y sub 1</strong> are the endpoints of a diameter of the circle, the center <strong>with coordinates 6 5</strong> lies on the diameter, halfway between P and Q. Therefore, the following relationships hold: <strong>the fraction with numerator x sub 1 + 10, and denominator 2 = 6</strong> and <strong>the fraction with numerator y sub 1 + 5, and denominator 2 = 5</strong>. Solving the equations for <strong>x sub 1</strong> and <strong>y sub 1</strong>, respectively, yields <strong>x sub 1 = 2</strong> and <strong>y sub 1 = 5</strong>. Therefore, the coordinates of point Q are <strong>2 5</strong>.Alternate approach: Since point P <strong>10 5</strong> on the circle and the center of the circle <strong>6 5</strong> have the same y-coordinate, it follows that the radius of the circle is <strong>10 6 = 4</strong>. In addition, the opposite end of the diameter <strong>P Q</strong> must have the same y-coordinate as P and be 4 units away from the center. Hence, the coordinates of point Q must be <strong>2 5</strong>.<br>Choices B and D are incorrect because the points given in these choices lie on a diameter that is perpendicular to the diameter <strong>P Q</strong>. If either of these points were point Q, then <strong>P Q</strong> would not be the diameter of the circle. Choice C is incorrect because <strong>6 5</strong> is the center of the circle and does not lie on the circle.",
hasFigure: false,
},
{
id: "9acd101f",
type: "mcq",
questionHtml:
"The equation <strong>x² + (y 1)² = 49</strong> represents circle A. Circle B is obtained by shifting circle A down <strong>2</strong> units in the xy-plane. Which of the following equations represents circle B?",
choices: [
{ label: "A", text: "<strong>(x 2)² + (y 1)² = 49</strong>" },
{ label: "B", text: "<strong>x² + (y 3)² = 49</strong>" },
{ label: "C", text: "<strong>(x + 2)² + (y 1)² = 49</strong>" },
{ label: "D", text: "<strong>x² + (y + 1)² = 49</strong>" },
],
correctAnswer: "D",
explanation:
"Choice D is correct. The graph in the xy-plane of an equation of the form <strong>(x h)² + (y k)² = r²</strong> is a circle with center <strong>(h, k)</strong> and a radius of length <strong>r</strong>. It's given that circle A is represented by <strong>x² + (y 1)² = 49</strong>, which can be rewritten as <strong>x² + (y 1)² = 7²</strong>. Therefore, circle A has center <strong>(0, 1)</strong> and a radius of length <strong>7</strong>. Shifting circle A down two units is a rigid vertical translation of circle A that does not change its size or shape. Since circle B is obtained by shifting circle A down two units, it follows that circle B has the same radius as circle A, and for each point <strong>(x, y)</strong> on circle A, the point <strong>(x, y 2)</strong> lies on circle B. Moreover, if <strong>(h, k)</strong> is the center of circle A, then <strong>(h, k 2)</strong> is the center of circle B. Therefore, circle B has a radius of <strong>7</strong> and the center of circle B is <strong>(0, 1 2)</strong>, or <strong>(0 1)</strong>. Thus, circle B can be represented by the equation <strong>x² + (y + 1)² = 7²</strong>, or <strong>x² + (y + 1)² = 49</strong>.<br>Choice A is incorrect. This is the equation of a circle obtained by shifting circle A right <strong>2</strong> units.<br>Choice B is incorrect. This is the equation of a circle obtained by shifting circle A up <strong>2</strong> units.<br>Choice C is incorrect. This is the equation of a circle obtained by shifting circle A left <strong>2</strong> units.",
hasFigure: false,
},
{
id: "9d159400",
type: "mcq",
questionHtml:
"Which of the following equations represents a circle in the xy-plane that intersects the y-axis at exactly one point?",
choices: [
{ label: "A", text: "<strong>(x 8)² + (y 8)² = 16</strong>" },
{ label: "B", text: "<strong>(x 8)² + (y 4)² = 16</strong>" },
{ label: "C", text: "<strong>(x 4)² + (y 9)² = 16</strong>" },
{ label: "D", text: "<strong>x² + (y 9)² = 16</strong>" },
],
correctAnswer: "C",
explanation:
"Choice C is correct. The graph of the equation <strong>(x h)² + (y k)² = r²</strong> in the xy-plane is a circle with center <strong>(h, k)</strong> and a radius of length <strong>r</strong>. The radius of a circle is the distance from the center of the circle to any point on the circle. If a circle in the xy-plane intersects the y-axis at exactly one point, then the perpendicular distance from the center of the circle to this point on the y-axis must be equal to the length of the circle's radius. It follows that the x-coordinate of the circle's center must be equivalent to the length of the circle's radius. In other words, if the graph of <strong>(x h)² + (y k)² = r²</strong> is a circle that intersects the y-axis at exactly one point, then <strong>r = |h|</strong> must be true. The equation in choice C is <strong>(x 4)² + (y 9)² = 16</strong>, or <strong>(x 4)² + (y 9)² = 4²</strong>. This equation is in the form <strong>(x h)² + (y k)² = r²</strong>, where <strong>h = 4</strong>, <strong>k = 9</strong>, and <strong>r = 4</strong>, and represents a circle in the xy-plane with center <strong>(4, 9)</strong> and radius of length <strong>4</strong>. Substituting <strong>4</strong> for <strong>r</strong> and <strong>4</strong> for <strong>h</strong> in the equation <strong>r = |h|</strong> yields <strong>4 = |4|</strong>, or <strong>4 = 4</strong>, which is true. Therefore, the equation in choice C represents a circle in the xy-plane that intersects the y-axis at exactly one point. <br>Choice A is incorrect. This is the equation of a circle that does not intersect the y-axis at any point.<br>Choice B is incorrect. This is an equation of a circle that intersects the x-axis, not the y-axis, at exactly one point.<br>Choice D is incorrect. This is the equation of a circle with the center located on the y-axis and thus intersects the y-axis at exactly two points, not exactly one point.",
hasFigure: false,
},
{
id: "9e44284b",
type: "mcq",
questionHtml:
"In the xy-plane, the graph of <strong>2 x² 6 x + 2 y² + 2 y = 45</strong> is a circle. What is the radius of the circle?",
choices: [
{ label: "A", text: "5" },
{ label: "B", text: "6.5" },
{ label: "C", text: "<strong>√ 40</strong>" },
{ label: "D", text: "<strong>√ 50</strong>" },
],
correctAnswer: "A",
explanation:
"Choice A is correct. One way to find the radius of the circle is to rewrite the given equation in standard form, <strong>(x h, ), ² + (y k, ), ² = r²</strong>, where <strong>the ordered pair h, k</strong> is the center of the circle and the radius of the circle is r. To do this, divide the original equation, <strong>2 x² 6 x + 2 y² + 2 y = 45</strong>, by 2 to make the leading coefficients of <strong>x²</strong> and <strong>y²</strong> each equal to 1: <strong>as follows: x² 3 x + y² + y = 22 . 5</strong>. Then complete the square to put the equation in standard form. To do so, first rewrite <strong>x² 3 x + y² + y = 22 . 5</strong> as <strong>(x² 3 x + 2 . 2 5, ) 2 . 2 5 + (y² + y + 0 . 2 5, ) 0 . 2 5 = 22 . 5</strong>. Second, add 2.25 and 0.25 to both sides of the equation: <strong>(x² 3 x + 2 . 2 5, ) + (y² + y + 0 . 2 5, ) = 25</strong>. Since <strong>x² 3 x + 2 . 2 5 = (x 1 . 5, ), ²</strong>, <strong>y² + y + 0 . 2 5 = (y + 0 . 5, ), ²</strong>, and <strong>25 = 5²</strong>, it follows that <strong>(x 1 . 5, ), ² + (y + 0 . 5, ), ² = 5²</strong>. Therefore, the radius of the circle is 5.Choices B, C, and D are incorrect and may be the result of errors in manipulating the equation or of a misconception about the standard form of the equation of a circle in the xy-plane.",
hasFigure: false,
},
{
id: "ab176ad6",
type: "spr",
questionHtml:
"The equation <strong>(x + 6, ), ² + (y + 3, ), ² = 121</strong> defines a circle in the xyplane. What is the radius of the circle?",
choices: [],
correctAnswer: "",
explanation:
"The correct answer is 11. A circle with equation <strong>(x a, ), ² + (y b, ), ² = r²</strong>, where a, b, and r are constants, has center <strong>with coordinates a, , b</strong> and radius r. Therefore, the radius of the given circle is <strong>the √ 121</strong>, or 11.",
hasFigure: false,
},
{
id: "acd30391",
type: "mcq",
questionHtml:
"A circle in the xy-plane has equation <strong>(x + 3, ), ² + (y 1, ), ² = 25</strong>. Which of the following points does NOT lie in the interior of the circle?",
choices: [
{ label: "A", text: "<strong>7, 3</strong>" },
{ label: "B", text: "<strong>3, 1</strong>" },
{ label: "C", text: "<strong>zero, zero</strong>" },
{ label: "D", text: "<strong>3, 2</strong>" },
],
correctAnswer: "D",
explanation:
"Choice D is correct. The circle with equation <strong>(x + 3, ), ² + (y 1, ), ² = 25</strong> has center <strong>with coordinates 3, 1</strong> and radius 5. For a point to be inside of the circle, the distance from that point to the center must be less than the radius, 5. The distance between <strong>3, 2</strong> and <strong>3, 1</strong> is <strong>the √, (3 3, ), ² + (1 2, ), ², end root = the √, (6, ), ² + (1, ), ², end root, which = the √ 37</strong>, which is greater than 5. Therefore, <strong>3, 2</strong> does NOT lie in the interior of the circle.Choice A is incorrect. The distance between <strong>7, 3</strong> and <strong>3, 1</strong> is <strong>the √, (7 + 3, ), ² + (3 1, ), ², end root = the √, (4, ), ² + (2, ), ², end root, which = the √ 20</strong>, which is less than 5, and therefore <strong>7, 3</strong> lies in the interior of the circle. Choice B is incorrect because it is the center of the circle. Choice C is incorrect because the distance between <strong>0, 0</strong> and <strong>3, 1</strong> is <strong>the √, (0 + 3, ), ² + (0 1, ), ², end root = the √, (3, ), ² + (1, ), ², end root, which = the √ 8</strong>, which is less than 5, and therefore <strong>0, 0</strong> in the interior of the circle.",
hasFigure: false,
},
{
id: "b0a72bdc",
type: "mcq",
questionHtml:
"What is the diameter of the circle in the xy-plane with equation <strong>(x 5)² + (y 3)² = 16</strong>?",
choices: [
{ label: "A", text: "<strong>4</strong>" },
{ label: "B", text: "<strong>8</strong>" },
{ label: "C", text: "<strong>16</strong>" },
{ label: "D", text: "<strong>32</strong>" },
],
correctAnswer: "B",
explanation:
"Choice B is correct. The standard form of an equation of a circle in the xy-plane is <strong>(x h)² + (y k)² = r²</strong>, where the coordinates of the center of the circle are <strong>(h, k)</strong> and the length of the radius of the circle is <strong>r</strong>. For the circle in the xy-plane with equation <strong>(x 5)² + (y 3)² = 16</strong>, it follows that <strong>r² = 16</strong>. Taking the square root of both sides of this equation yields <strong>r = 4</strong> or <strong>r = 4</strong>. Because <strong>r</strong> represents the length of the radius of the circle and this length must be positive, <strong>r = 4</strong>. Therefore, the radius of the circle is <strong>4</strong>. The diameter of a circle is twice the length of the radius of the circle. Thus, <strong>2 (4)</strong> yields <strong>8</strong>. Therefore, the diameter of the circle is <strong>8</strong>.<br>Choice A is incorrect. This is the radius of the circle. <br>Choice C is incorrect. This is the square of the radius of the circle. <br>Choice D is incorrect and may result from conceptual or calculation errors.",
hasFigure: false,
},
{
id: "b8a225ff",
type: "spr",
questionHtml:
"Circle A in the xy-plane has the equation <strong>(x + 5)² + (y 5)² = 4</strong>. Circle B has the same center as circle A. The radius of circle B is two times the radius of circle A. The equation defining circle B in the xy-plane is <strong>(x + 5)² + (y 5)² = k</strong>, where <strong>k</strong> is a constant. What is the value of <strong>k</strong>?",
choices: [],
correctAnswer: "16",
explanation:
"The correct answer is <strong>16</strong>. An equation of a circle in the xy-plane can be written as <strong>(x t)² + (y u)² = r²</strong>, where the center of the circle is <strong>(t, u)</strong> , the radius of the circle is <strong>r</strong>, and where <strong>t</strong>, <strong>u</strong>, and <strong>r</strong> are constants. Its given that the equation of circle A is <strong>(x + 5)² + (y 5)² = 4</strong>, which is equivalent to <strong>(x + 5)² + (y 5)² = 2²</strong>. Therefore, the center of circle A is <strong>(5, 5)</strong> and the radius of circle A is <strong>2</strong>. Its given that circle B has the same center as circle A and that the radius of circle B is two times the radius of circle A. Therefore, the center of circle B is <strong>(5, 5)</strong> and the radius of circle B is <strong>2 (2)</strong>, or <strong>4</strong>. Substituting <strong>5</strong> for <strong>t</strong>, <strong>5</strong> for <strong>u</strong>, and <strong>4</strong> for <strong>r</strong> into the equation <strong>(x t)² + (y u)² = r²</strong>  yields <strong>(x + 5)² + (y 5)² = 4²</strong>, which is equivalent to <strong>(x + 5)² + (y 5)² = 16</strong>. It follows that the equation of circle B in the xy-plane is <strong>(x + 5)² + (y 5)² = 16</strong>. Therefore, the value of <strong>k</strong> is <strong>16</strong>.",
hasFigure: false,
},
{
id: "c8345903",
type: "mcq",
questionHtml:
"The circle above has center O, the length of arc <strong>A, D C</strong> is <strong>5 π</strong>, and <strong>x = 100</strong>. What is the length of arc <strong>A, B C</strong> ?",
choices: [
{ label: "A", text: "<strong>9 π</strong>" },
{ label: "B", text: "<strong>13 π</strong>" },
{ label: "C", text: "<strong>18 π</strong>" },
{ label: "D", text: "<strong>13 halves π</strong>" },
],
correctAnswer: "B",
explanation:
"Choice B is correct. The ratio of the lengths of two arcs of a circle is equal to the ratio of the measures of the central angles that subtend the arcs. Its given that arc <strong>A D C</strong> is subtended by a central angle with measure 100°. Since the sum of the measures of the angles about a point is 360°, it follows that arc <strong>A B C</strong> is subtended by a central angle with measure <strong>360 ° 100 ° = 260 °</strong>. If s is the length of arc <strong>A B C</strong>, then s must satisfy the ratio <strong>the fraction s over 5 π, end fraction = the fraction 260 over 100</strong>. Reducing the fraction <strong>260 over 100</strong> to its simplest form gives <strong>the fraction 13 over 5</strong>. Therefore, <strong>the fraction s over 5 π, end fraction = the fraction 13 over 5</strong>. Multiplying both sides of <strong>the fraction s over 5 π, end fraction = the fraction 13 over 5</strong> by <strong>5 π</strong> yields <strong>s = 13 π</strong>.Choice A is incorrect. This is the length of an arc consisting of exactly half of the circle, but arc <strong>A B C</strong> is greater than half of the circle. Choice C is incorrect. This is the total circumference of the circle. Choice D is incorrect. This is half the length of arc <strong>A B C</strong>, not its full length.",
hasFigure: true,
figureUrl: "/practice-images/c8345903_img1.png",
},
{
id: "ca2235f6",
type: "mcq",
questionHtml:
"A circle has center <em>(expression)</em>, and points <em>(expression)</em> and <em>(expression)</em> lie on the circle. The measure of arc <em>(expression)</em> is <em>(expression)</em> and the length of arc <em>(expression)</em> is <em>(expression)</em> inches. What is the circumference, in inches, of the circle?",
choices: [
{ label: "A", text: "<em>(expression)</em>" },
{ label: "B", text: "<em>(expression)</em>" },
{ label: "C", text: "<em>(expression)</em>" },
{ label: "D", text: "<em>(expression)</em>" },
],
correctAnswer: "D",
explanation:
"Choice D is correct. Its given that the measure of arc <strong>A B</strong> is <strong>45 °</strong> and the length of arc <strong>A B</strong> is <strong>3 inches</strong>. The arc measure of the full circle is <strong>360 °</strong>. If <strong>x</strong> represents the circumference, in inches, of the circle, it follows that <strong>(45 °) / (360 °) = (3 inches) / (x inches)</strong>. This equation is equivalent to <strong>(45) / (360) = (3) / (x)</strong>, or <strong>one eighth = (3) / (x)</strong>. Multiplying both sides of this equation by <strong>8 x</strong> yields <strong>1 (x) = 3 (8)</strong>, or <strong>x = 24</strong>. Therefore, the circumference of the circle is <strong>24 inches</strong>.<br>Choice A is incorrect. This is the length of arc <strong>A B</strong>.<br>Choice B is incorrect and may result from multiplying the length of arc <strong>A B</strong> by <strong>2</strong>.<br>Choice C is incorrect and may result from squaring the length of arc <strong>A B</strong>.",
hasFigure: false,
},
{
id: "e80d62c6",
type: "mcq",
questionHtml:
"The equation <strong>x² + (y 2)² = 36</strong> represents circle A. Circle B is obtained by shifting circle A down <strong>4</strong> units in the xy-plane. Which of the following equations represents circle B?",
choices: [
{ label: "A", text: "<strong>x² + (y + 2)² = 36</strong>" },
{ label: "B", text: "<strong>x² + (y 6)² = 36</strong>" },
{ label: "C", text: "<strong>(x 4)² + (y 2)² = 36</strong>" },
{ label: "D", text: "<strong>(x + 4)² + (y 2)² = 36</strong>" },
],
correctAnswer: "A",
explanation:
"Choice A is correct. The standard form of an equation of a circle in the xy-plane is <strong>(x h)² + (y k)² = r²</strong>, where the coordinates of the center of the circle are <strong>(h, k)</strong> and the length of the radius of the circle is <strong>r</strong>. The equation of circle A, <strong>x² + (y 2)² = 36</strong>, can be rewritten as <strong>(x 0)² + (y 2)² = 6²</strong>. Therefore, the center of circle A is at <strong>(0, 2)</strong> and the length of the radius of circle A is <strong>6</strong>. If circle A is shifted down <strong>4</strong> units, the y-coordinate of its center will decrease by <strong>4</strong>; the radius of the circle and the x-coordinate of its center will not change. Therefore, the center of circle B is at <strong>(0, 2 4)</strong>, or <strong>(0 2)</strong>, and its radius is <strong>6</strong>. Substituting <strong>0</strong> for <strong>h</strong>, <strong>2</strong> for <strong>k</strong>, and <strong>6</strong> for <strong>r</strong> in the equation <strong>(x h)² + (y k)² = r²</strong> yields <strong>(x 0)² + (y (2))² = (6)²</strong>, or <strong>x² + (y + 2)² = 36</strong>. Therefore, the equation <strong>x² + (y + 2)² = 36</strong> represents circle B.<br>Choice B is incorrect. This equation represents a circle obtained by shifting circle A up, rather than down, <strong>4</strong> units.<br>Choice C is incorrect. This equation represents a circle obtained by shifting circle A right, rather than down, <strong>4</strong> units.<br>Choice D is incorrect. This equation represents a circle obtained by shifting circle A left, rather than down, <strong>4</strong> units.",
hasFigure: false,
},
{
id: "ebbf23ae",
type: "spr",
questionHtml:
"A circle in the xy-plane has a diameter with endpoints <strong>(2, 4)</strong> and <strong>(2, 14)</strong>. An equation of this circle is <strong>(x 2)² + (y 9)² = r²</strong>, where <strong>r</strong> is a positive constant. What is the value of <strong>r</strong>?",
choices: [],
correctAnswer: "5",
explanation:
"The correct answer is <strong>5</strong>. The standard form of an equation of a circle in the xy-plane is <strong>(x h)² + (y k)² = r²</strong>, where <strong>h</strong>, <strong>k</strong>, and <strong>r</strong> are constants, the coordinates of the center of the circle are <strong>(h, k)</strong>, and the length of the radius of the circle is <strong>r</strong>. Its given that an equation of the circle is <strong>(x 2)² + (y 9)² = r²</strong>. Therefore, the center of this circle is <strong>(2, 9)</strong>. Its given that the endpoints of a diameter of the circle are <strong>(2, 4)</strong> and <strong>(2, 14)</strong>. The length of the radius is the distance from the center of the circle to an endpoint of a diameter of the circle, which can be found using the distance formula, <strong>√((x 1 x 2)² + (y 1 y 2)²)</strong>. Substituting the center of the circle <strong>(2, 9)</strong> and one endpoint of the diameter <strong>(2, 4)</strong> in this formula gives a distance of <strong>√((2 2)² + (9 4)²)</strong>, or <strong>√(0² + 5²)</strong>, which is equivalent to <strong>5</strong>. Since the distance from the center of the circle to an endpoint of a diameter is <strong>5</strong>, the value of <strong>r</strong> is <strong>5</strong>.",
hasFigure: false,
},
{
id: "fb58c0db",
type: "spr",
questionHtml:
"Points A and B lie on a circle with radius 1, and arc <strong>A, B</strong> has length <strong>π over 3</strong>. What fraction of the circumference of the circle is the length of arc <strong>A, B</strong> ?",
choices: [],
correctAnswer: "",
explanation:
"The correct answer is <strong>one sixth</strong>. The circumference, C, of a circle is <strong>C = 2 π, r</strong>, where r is the length of the radius of the circle. For the given circle with a radius of 1, the circumference is <strong>C = 2 π · 1</strong>, or <strong>C = 2 π</strong>. To find what fraction of the circumference the length of arc <strong>A, B</strong> is, divide the length of the arc by the circumference, which gives <strong>the fraction π over 3, end fraction ÷ 2 π</strong>. This division can be represented by <strong>the fraction π over 3, end fraction · the fraction 1 over 2 π, end fraction = one sixth</strong>. Note that 1/6, .1666, .1667, 0.166, and 0.167 are examples of ways to enter a correct answer.",
hasFigure: false,
},
];

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,236 @@
import { type PracticeQuestion } from "../../types/lesson";
export const EVAL_STATS_EASY: PracticeQuestion[] = [
{
id: "82dfb646",
type: "mcq",
questionHtml:
"A market researcher selected 200 people at random from a group of people who indicated that they liked a certain book. The 200 people were shown a movie based on the book and then asked whether they liked or disliked the movie. Of those surveyed, 95% said they disliked the movie. Which of the following inferences can appropriately be drawn from this survey result?",
choices: [
{
label: "A",
text: "At least 95% of people who go see movies will dislike this movie.",
},
{
label: "B",
text: "At least 95% of people who read books will dislike this movie.",
},
{
label: "C",
text: "Most people who dislike this book will like this movie.",
},
{
label: "D",
text: "Most people who like this book will dislike this movie.",
},
],
correctAnswer: "D",
explanation:
"Choice D is correct. The sample was selected from a group of people who indicated that they liked the book. It is inappropriate to generalize the result of the survey beyond the population from which the participants were selected. Choice D is the most appropriate inference from the survey results because it describes a conclusion about people who liked the book, and the results of the survey indicate that most people who like the book disliked the movie.Choices A, B, and C are incorrect because none of these inferences can be drawn from the survey results. Choices A and B need not be true. The people surveyed all liked the book on which the movie was based, which is not necessarily true of all people who go see movies or all people who read books. Thus, the people surveyed are not representative of all people who go see movies or all people who read books. Therefore, the results of this survey cannot appropriately be extended to at least 95% of people who go see movies or to at least 95% of people who read books. Choice C need not be true because the sample includes only people who liked the book, and so the results do not extend to people who dislike the book.",
hasFigure: false,
},
{
id: "9bf4c545",
type: "mcq",
questionHtml:
"The members of a city council wanted to assess the opinions of all city residents about converting an open field into a dog park. The council surveyed a sample of 500 city residents who own dogs. The survey showed that the majority of those sampled were in favor of the dog park. Which of the following is true about the city councils survey?",
choices: [
{
label: "A",
text: "It shows that the majority of city residents are in favor of the dog park.",
},
{
label: "B",
text: "The survey sample should have included more residents who are dog owners.",
},
{
label: "C",
text: "The survey sample should have consisted entirely of residents who do not own dogs.",
},
{
label: "D",
text: "The survey sample is biased because it is not representative of all city residents.",
},
],
correctAnswer: "D",
explanation:
"Choice D is correct. The members of the city council wanted to assess opinions of all city residents. To gather an unbiased sample, the council should have used a random sampling design to select subjects from all city residents. The given survey introduced a sampling bias because the 500 city residents surveyed were all dog owners. This sample is not representative of all city residents because not all city residents are dog owners.Choice A is incorrect because when the sampling method isnt random, there is no guarantee that the survey results will be reliable; hence, they cannot be generalized to the entire population. Choice B is incorrect because a larger sample of residents who are dog owners would not correct the sampling bias. Choice C is incorrect because a survey sample of entirely nondog owners would likely have a biased opinion, just as a sample of dog owners would likely have a biased opinion.",
hasFigure: false,
},
];
export const EVAL_STATS_MEDIUM: PracticeQuestion[] = [
{
id: "37930b2a",
type: "mcq",
questionHtml:
"Residents of a town were surveyed to determine whether they are satisfied with the concession stand at the local park. A random sample of 200 residents was selected. All 200 responded, and 87% said they are satisfied. Based on this information, which of the following statements must be true?I. Of all the town residents, 87% would say they are satisfied with the concession stand at the local park.<br><br>II. If another random sample of 200 residents were surveyed, 87% would say they are satisfied.",
choices: [
{ label: "A", text: "Neither" },
{ label: "B", text: "I only" },
{ label: "C", text: "II only" },
{ label: "D", text: "I and II" },
],
correctAnswer: "A",
explanation:
"Choice A is correct. The purpose of surveying a random sample of residents is to approximate the percent of the town residents that are satisfied with the concession stand. The sample doesnt necessarily get the same result as surveying every resident of the town, nor would another sample necessarily have identical results. Therefore, although its possible that either statement I or statement II could prove true by surveying every resident of the town, these statements cannot be proven true solely based on the results of the sample.Choice B is incorrect because surveying a sample of the town residents may not have the same result as surveying all the town residents. Choices C and D are incorrect because surveying a different sample of residents could yield different results.",
hasFigure: false,
},
{
id: "642519d7",
type: "mcq",
questionHtml:
"A polling agency recently surveyed 1,000 adults who were selected at random from a large city and asked each of the adults, “Are you satisfied with the quality of air in the city?” Of those surveyed, 78 percent responded that they were satisfied with the quality of air in the city. Based on the results of the survey, which of the following statements must be true?Of all adults in the city, 78 percent are satisfied with the quality of air in the city.<br> If another 1,000 adults selected at random from the city were surveyed, 78 percent of them would report they are satisfied with the quality of air in the city.<br> If 1,000 adults selected at random from a different city were surveyed, 78 percent of them would report they are satisfied with the quality of air in the city.",
choices: [
{ label: "A", text: "None" },
{ label: "B", text: "II only" },
{ label: "C", text: "I and II only" },
{ label: "D", text: "I and III only" },
],
correctAnswer: "A",
explanation:
"Choice A is correct. Statement I need not be true. The fact that 78% of the 1,000 adults who were surveyed responded that they were satisfied with the air quality in the city does not mean that the exact same percentage of all adults in the city will be satisfied with the air quality in the city. Statement II need not be true because random samples, even when they are of the same size, are not necessarily identical with regard to percentages of people in them who have a certain opinion. Statement III need not be true for the same reason that statement II need not be true: results from different samples can vary. The variation may be even bigger for this sample since it would be selected from a different city. Therefore, none of the statements must be true.Choices B, C, and D are incorrect because none of the statements must be true.",
hasFigure: false,
},
{
id: "b4f5a7ca",
type: "mcq",
questionHtml:
"A survey was conducted using a sample of history professors selected at random from the California State Universities. The professors surveyed were asked to name the publishers of their current texts. What is the largest population to which the results of the survey can be generalized?",
choices: [
{ label: "A", text: "All professors in the United States" },
{ label: "B", text: "All history professors in the United States" },
{
label: "C",
text: "All history professors at all California State Universities",
},
{
label: "D",
text: "All professors at all California State Universities",
},
],
correctAnswer: "C",
explanation:
"Choice C is correct. Selecting a sample at random when conducting a survey allows the results to be generalized to the population from which the sample was selected, but not beyond this population. In this situation, the population that the sample was selected from is history professors from the California State Universities. Therefore, the largest population to which the results of the survey can be generalized is all history professors at all California State Universities.Choices A, B, and D are incorrect. Since the sample was selected at random from history professors from the California State Universities, the results of the survey cant be generalized to all professors in the United States, all history professors in the United States, or all professors at all California State Universities. All three of these populations may use different texts and therefore may name different publishers.",
hasFigure: false,
},
];
export const EVAL_STATS_HARD: PracticeQuestion[] = [
{
id: "1ea09200",
type: "mcq",
questionHtml:
"A sample of 40 fourth-grade students was selected at random from a certain school. The 40 students completed a survey about the morning announcements, and 32 thought the announcements were helpful. Which of the following is the largest population to which the results of the survey can be applied?",
choices: [
{ label: "A", text: "The 40 students who were surveyed" },
{ label: "B", text: "All fourth-grade students at the school" },
{ label: "C", text: "All students at the school" },
{
label: "D",
text: "All fourth-grade students in the county in which the school is located",
},
],
correctAnswer: "B",
explanation:
"Choice B is correct. Selecting a sample of a reasonable size at random to use for a survey allows the results from that survey to be applied to the population from which the sample was selected, but not beyond this population. In this case, the population from which the sample was selected is all fourth-grade students at a certain school. Therefore, the results of the survey can be applied to all fourth-grade students at the school.Choice A is incorrect. The results of the survey can be applied to the 40 students who were surveyed. However, this isnt the largest group to which the results of the survey can be applied. Choices C and D are incorrect. Since the sample was selected at random from among the fourth-grade students at a certain school, the results of the survey cant be applied to other students at the school or to other fourth-grade students who werent represented in the survey results. Students in other grades in the school or other fourth-grade students in the country may feel differently about announcements than the fourth-grade students at the school.",
hasFigure: false,
},
{
id: "4a422e3e",
type: "mcq",
questionHtml:
"To determine the mean number of children per household in a community, Tabitha surveyed 20 families at a playground. For the 20 families surveyed, the mean number of children per household was 2.4. Which of the following statements must be true?",
choices: [
{
label: "A",
text: "The mean number of children per household in the community is 2.4.",
},
{
label: "B",
text: "A determination about the mean number of children per household in the community should not be made because the sample size is too small.",
},
{
label: "C",
text: "The sampling method is flawed and may produce a biased estimate of the mean number of children per household in the community.",
},
{
label: "D",
text: "The sampling method is not flawed and is likely to produce an unbiased estimate of the mean number of children per household in the community.",
},
],
correctAnswer: "C",
explanation:
"Choice C is correct. In order to use a sample mean to estimate the mean for a population, the sample must be representative of the population (for example, a simple random sample). In this case, Tabitha surveyed 20 families in a playground. Families in the playground are more likely to have children than other households in the community. Therefore, the sample isnt representative of the population. Hence, the sampling method is flawed and may produce a biased estimate.Choices A and D are incorrect because they incorrectly assume the sampling method is unbiased. Choice B is incorrect because a sample of size 20 could be large enough to make an estimate if the sample had been representative of all the families in the community.",
hasFigure: false,
},
{
id: "7ce2830a",
type: "mcq",
questionHtml:
"A psychologist designed and conducted a study to determine whether playing a certain educational game increases middle school students accuracy in adding fractions. For the study, the psychologist chose a random sample of 35 students from all of the students at one of the middle schools in a large city. The psychologist found that students who played the game showed significant improvement in accuracy when adding fractions. What is the largest group to which the results of the study can be generalized?",
choices: [
{ label: "A", text: "The 35 students in the sample" },
{ label: "B", text: "All students at the school" },
{ label: "C", text: "All middle school students in the city" },
{ label: "D", text: "All students in the city" },
],
correctAnswer: "B",
explanation:
"Choice B is correct. The largest group to which the results of a study can be generalized is the population from which the random sample was chosen. In this case, the psychologist chose a random sample from all students at one particular middle school. Therefore, the largest group to which the results can be generalized is all the students at the school.Choice A is incorrect because this isnt the largest group the results can be generalized to. Choices C and D are incorrect because these groups are larger than the population from which the random sample was chosen. Therefore, the sample isnt representative of these groups.",
hasFigure: false,
},
{
id: "7d68096f",
type: "mcq",
questionHtml:
"A trivia tournament organizer wanted to study the relationship between the number of points a team scores in a trivia round and the number of hours that a team practices each week. For the study, the organizer selected <strong>55</strong> teams at random from all trivia teams in a certain tournament. The table displays the information for the <strong>40</strong> teams in the sample that practiced for at least <strong>3</strong> hours per week.<br><br>Hours practiced<br>Number of points per round<br><br>6 to 13 points<br>14 or more points<br>Total<br><br>3 to 5 hours<br><strong>6</strong><br><strong>4</strong><br><strong>10</strong><br><br>More than 5 hours<br><strong>4</strong><br><strong>26</strong><br><strong>30</strong><br><br>Total<br><strong>10</strong><br><strong>30</strong><br><strong>40</strong><br><br>Which of the following is the largest population to which the results of the study can be generalized?",
choices: [
{
label: "A",
text: "All trivia teams in the tournament that scored <strong>14</strong> or more points in the round",
},
{
label: "B",
text: "The <strong>55</strong> trivia teams in the sample",
},
{
label: "C",
text: "The <strong>40</strong> trivia teams in the sample that practiced for at least <strong>3</strong> hours per week",
},
{ label: "D", text: "All trivia teams in the tournament" },
],
correctAnswer: "D",
explanation:
"Choice D is correct. It's given that the organizer selected <strong>55</strong> teams at random from all trivia teams in the tournament. A table is also given displaying the information for the <strong>40</strong> teams in the sample that practiced for at least <strong>3</strong> hours per week. Selecting a sample of a reasonable size at random to use for a survey allows the results from that survey to be applied to the population from which the sample was selected, but not beyond this population. Thus, only the sampling method information is necessary to determine the largest population to which the results of the study can be generalized. Since the organizer selected the sample at random from all trivia teams in the tournament, the largest population to which the results of the study can be generalized is all trivia teams in the tournament.<br>Choice A is incorrect. The sample was selected at random from all trivia teams in the tournament, not just from the teams that scored an average of <strong>14</strong> or more points per round.<br>Choice B is incorrect. If a study uses a sample selected at random from a population, the results of the study can be generalized to the population, not just the sample.<br>Choice C is incorrect. If a study uses a sample selected at random from a population, the results of the study can be generalized to the population, not just a subset of the sample.",
hasFigure: false,
},
{
id: "aa43b41f",
type: "mcq",
questionHtml:
"Near the end of a US cable news show, the host invited viewers to respond to a poll on the shows website that asked, “Do you support the new federal policy discussed during the show?” At the end of the show, the host reported that 28% responded “Yes,” and 70% responded “No.” Which of the following best explains why the results are unlikely to represent the sentiments of the population of the United States?",
choices: [
{
label: "A",
text: "The percentages do not add up to 100%, so any possible conclusions from the poll are invalid.",
},
{
label: "B",
text: "Those who responded to the poll were not a random sample of the population of the United States.",
},
{
label: "C",
text: "There were not 50% “Yes” responses and 50% “No” responses.",
},
{
label: "D",
text: "The show did not allow viewers enough time to respond to the poll.",
},
],
correctAnswer: "B",
explanation:
"Choice B is correct. In order for the poll results from a sample of a population to represent the entire population, the sample must be representative of the population. A sample that is randomly selected from a population is more likely than a sample of the type described to represent the population. In this case, the people who responded were people with access to cable television and websites, which arent accessible to the entire population. Moreover, the people who responded also chose to watch the show and respond to the poll. The people who made these choices arent representative of the entire population of the United States because they were not a random sample of the population of the United States.Choices A, C, and D are incorrect because they present reasons unrelated to whether the sample is representative of the population of the United States.",
hasFigure: false,
},
];

Some files were not shown because too many files have changed in this diff Show More