38 Commits

Author SHA1 Message Date
b435b656e9 feat(practice-sheet): add practice sheet page 2026-03-13 01:24:13 +06:00
d7fb618e6f fix(test): fix quit test logic 2026-03-12 19:50:52 +06:00
25cfd2383b chore(troika): install troika utils 2026-03-12 19:35:58 +06:00
d24ab8f9cf fix(test): fix context image url path 2026-03-12 18:00:57 +06:00
c4d3988f8c fix(build): fix build errors 2026-03-12 17:47:04 +06:00
4c83fcf7b4 feat(auth): add registration message for successful registration 2026-03-12 13:51:20 +06:00
8d86da05b5 feat(avatar): add user avatar upload at registration 2026-03-12 04:05:32 +06:00
c8f2259154 chore(types): add three js types 2026-03-12 03:10:31 +06:00
a429b1c0b1 chore(html): add analytics script to index.html 2026-03-12 03:01:41 +06:00
bd35f6a852 chore(build): refactor codebase for production 2026-03-12 02:39:34 +06:00
121cc2bf71 feat(quests): improve 3d island styling
fix(test): fix context image not appearing on test screen
2026-03-12 01:09:07 +06:00
f00aad2bbd feat(quests): add 3d functionality for quests 2026-03-11 03:17:08 +06:00
575d392afc fix(quests): fix island rendering problems on desktop view 2026-03-11 00:59:31 +06:00
c09ecd7926 fix: sidebar spacing and topics overflowing 2026-03-10 07:38:56 +06:00
a1295a0eb3 fix(lesson): fix lesson modal title 2026-03-10 00:53:08 +06:00
59e601052f feat(lessons): add new lessons for english section 2026-03-09 16:41:06 +06:00
b5edb3554f feat(auth): add registration page 2026-03-09 15:05:09 +06:00
8dbadae58c fix(test): fix directions text 2026-03-04 20:54:25 +06:00
980eb130e2 fix: duplicate code related bug 2026-03-04 05:07:19 +06:00
bd3974e2f0 fix: responsive inventory 2026-03-04 03:34:31 +06:00
a08476ec53 Merge branch 'web' of https://git.omukk.dev/shafin808s/edbridge-scholars into web 2026-03-04 03:26:29 +06:00
437c7a517f feat: Quests page responsiveness and sidebar enhancements 2026-03-04 03:25:50 +06:00
c35f328e30 feat: responsive for web with sidebar and different styling for test ui on web 2026-03-04 03:24:17 +06:00
e75233929a feat: Quests page responsiveness and sidebar enhancements 2026-03-04 03:22:22 +06:00
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
f154ebf033 Merge branch 'web' of https://git.omukk.dev/shafin808s/edbridge-scholars into web 2026-03-03 23:31:57 +06:00
634c67b741 feat: responsive for web with sidebar and different styling for test ui on web 2026-03-03 23:29:20 +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
4df5707ebd Merge branch 'web' of https://git.omukk.dev/shafin808s/edbridge-scholars into web 2026-02-27 05:17:12 +06:00
e7db0a5d31 feat: responsive for web with sidebar and different styling for test ui on web 2026-02-27 05:13:06 +06:00
7dfa73c397 feat: responsive for web with sidebar and different styling for test ui on web 2026-02-27 02:36: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
222 changed files with 105925 additions and 2850 deletions

View File

@ -5,6 +5,12 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://www.geogebra.org/apps/deployggb.js"></script> <script src="https://www.geogebra.org/apps/deployggb.js"></script>
<script
defer
src="https://alt.omukk.dev/script.js"
data-website-id="e4aa7582-260a-4861-b363-eb1815d8b232"
></script>
<title>Edbridge Scholars</title> <title>Edbridge Scholars</title>
</head> </head>
<body> <body>

8044
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,8 @@
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
@ -23,6 +25,7 @@
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"framer-motion": "^12.30.0", "framer-motion": "^12.30.0",
"katex": "^0.16.28", "katex": "^0.16.28",
"leva": "^0.10.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
@ -31,6 +34,9 @@
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"three": "^0.183.2",
"troika-three-text": "^0.52.4",
"troika-worker-utils": "^0.52.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
@ -39,6 +45,7 @@
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "^0.183.1",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",

700
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,9 @@ import { StudentLayout } from "./pages/student/StudentLayout";
import { TargetedPractice } from "./pages/student/targeted-practice/page"; import { TargetedPractice } from "./pages/student/targeted-practice/page";
import { Drills } from "./pages/student/drills/page"; import { Drills } from "./pages/student/drills/page";
import { HardTestModules } from "./pages/student/hard-test-modules/page"; import { HardTestModules } from "./pages/student/hard-test-modules/page";
import { Analytics } from "./pages/student/Analytics"; import { QuestMap } from "./pages/student/QuestMap";
import { Register } from "./pages/auth/Register";
import { PracticeSheetList } from "./pages/student/practice-sheet/page";
function App() { function App() {
const router = createBrowserRouter([ const router = createBrowserRouter([
@ -27,12 +29,17 @@ function App() {
path: "/login", path: "/login",
element: <Login />, element: <Login />,
}, },
{
path: "/register",
element: <Register />,
},
{ {
path: "/student", path: "/student",
element: <ProtectedRoute />, element: <ProtectedRoute />,
children: [ children: [
{ {
element: <StudentLayout />, element: <StudentLayout />,
children: [ children: [
{ {
path: "home", path: "home",
@ -55,8 +62,8 @@ function App() {
element: <Profile />, element: <Profile />,
}, },
{ {
path: "analytics", path: "quests",
element: <Analytics />, element: <QuestMap />,
}, },
{ {
path: "practice/:sheetId", path: "practice/:sheetId",
@ -74,6 +81,10 @@ function App() {
path: "practice/hard-test-modules", path: "practice/hard-test-modules",
element: <HardTestModules />, element: <HardTestModules />,
}, },
{
path: "practice/practice-sheet",
element: <PracticeSheetList />,
},
], ],
}, },
{ {

View File

@ -1,5 +1,4 @@
import { import {
Sidebar,
SidebarContent, SidebarContent,
SidebarHeader, SidebarHeader,
SidebarFooter, SidebarFooter,
@ -15,202 +14,580 @@ import {
ChevronDown, ChevronDown,
BookOpen, BookOpen,
Home, Home,
Video,
User,
Target, Target,
Zap, Zap,
Trophy, Trophy,
LayoutGrid, Map,
SquareLibrary,
ListIcon,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import logo from "../assets/ed_logo1.png"; import logo from "../assets/ed_logo1.png";
import { NavLink } from "react-router-dom"; import { NavLink, useNavigate, useLocation } from "react-router-dom";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
export function AppSidebar() { export function AppSidebar() {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(false);
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const navigate = useNavigate();
const location = useLocation();
const isQuestPage = location.pathname.startsWith("/student/quests");
const STYLES = `
/* ══ DEFAULT sidebar (cream frosted glass) ══ */
.as-sidebar-container {
position: fixed;
top: 0.5rem;
bottom: 0.5rem;
left: 0.5rem;
width: 16rem;
z-index: 10;
border-radius: 1.75rem;
overflow: hidden;
pointer-events: auto;
background: rgba(255,251,244,0.72);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
border: 1.5px solid rgba(255,255,255,0.7);
box-shadow:
0 8px 32px rgba(0,0,0,0.12),
0 2px 8px rgba(0,0,0,0.06),
inset 0 1px 0 rgba(255,255,255,0.8);
transition:
background 0.4s ease,
border-color 0.4s ease,
box-shadow 0.4s ease,
z-index 0.3s ease;
display: flex;
flex-direction: column;
height: calc(100vh - 1rem);
}
/* ══ QUEST mode sidebar (dark navy pirate + gold) ══ */
.as-sidebar-container.quest-mode {
background: linear-gradient(
180deg,
rgba(251,191,36,0.12) 0%,
rgba(251,191,36,0.08) 50%,
rgba(251,191,36,0.1) 100%
);
background-size: 100% 200%;
animation: asSweepDown 3s linear infinite;
border-color: rgba(251,191,36,0.28);
box-shadow:
0 8px 32px rgba(0,0,0,0.25),
0 2px 8px rgba(0,0,0,0.15),
inset 0 1px 0 rgba(255,255,255,0.08);
}
/* Shimmer animation from top to bottom */
@keyframes asSweepDown {
0% { background-position: 0% 200%; }
100% { background-position: 0% -200%; }
}
/* On quest page, sidebar stays visible above content */
.as-sidebar-container.quest-mode {
z-index: 40;
}
.as-sidebar-inner {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
height: 100%;
}
.as-gradient-overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
}
.as-gradient-overlay.default {
background: radial-gradient(
circle at top,
rgba(168,85,247,0.18),
transparent 55%
),
radial-gradient(
circle at bottom,
rgba(249,115,22,0.1),
transparent 55%
);
}
.as-gradient-overlay.quest {
background: transparent;
}
/* Ensure Sidebar sub-components are transparent */
.as-sidebar-inner > * {
background: transparent;
}
.as-sidebar-inner [class*="SidebarHeader"],
.as-sidebar-inner [class*="SidebarContent"],
.as-sidebar-inner [class*="SidebarFooter"],
.as-sidebar-inner [class*="SidebarGroup"],
.as-sidebar-inner [class*="SidebarMenu"] {
background: transparent;
}
/* Quest mode text visibility */
.as-sidebar-container.quest-mode [class*="SidebarGroupLabel"] {
color: rgba(255, 255, 255, 0.4) !important;
}
.as-sidebar-container.quest-mode a {
color: rgba(255, 255, 255, 0.6) !important;
background: transparent !important;
}
.as-sidebar-container.quest-mode a:hover {
color: rgba(255, 255, 255, 0.85) !important;
background: rgba(255, 255, 255, 0.08) !important;
}
.as-sidebar-container.quest-mode a.active {
color: #fbbf24 !important;
}
.as-sidebar-container.quest-mode span {
color: inherit;
}
/* Quest header text */
.as-sidebar-container.quest-mode .text-slate-900 {
color: rgba(255, 255, 255, 0.9) !important;
}
.as-sidebar-container.quest-mode .text-slate-400 {
color: rgba(255, 255, 255, 0.4) !important;
}
/* Quest mode removes white hover background from menu buttons */
.as-sidebar-container.quest-mode [class*="SidebarMenuButton"]:hover {
background: transparent !important;
}
.as-sidebar-container.quest-mode [class*="SidebarMenuButton"] {
background: transparent !important;
}
/* Prevent group-hover from adding white background in quest mode */
.as-sidebar-container.quest-mode .group:hover {
background: transparent !important;
}
/* Quest mode footer button styling */
.as-sidebar-container.quest-mode [class*="SidebarFooter"] button {
background: rgba(255, 255, 255, 0.08) !important;
--tw-ring-color: rgba(255, 255, 255, 0.05) !important;
border-color: rgba(255, 255, 255, 0.05) !important;
}
.as-sidebar-container.quest-mode [class*="SidebarFooter"] button:hover {
background: rgba(255, 255, 255, 0.12) !important;
}
/* Override Tailwind bg-white and ring classes for quest mode */
.as-sidebar-container.quest-mode button[class*="bg-white"] {
background-color: rgba(255, 255, 255, 0.08) !important;
}
.as-sidebar-container.quest-mode button[class*="ring-white"]:not(:hover) {
--tw-ring-color: rgba(255, 255, 255, 0.05) !important;
border-color: rgba(255, 255, 255, 0.05) !important;
}
.as-sidebar-container.quest-mode button:hover[class*="hover:bg-white"] {
background-color: rgba(255, 255, 255, 0.12) !important;
}
.as-sidebar-container.quest-mode a:hover {
--tw-ring-color: rgba(255, 255, 255, 0.05) !important;
}
`;
return ( return (
<Sidebar className="border-r bg-black text-white"> <>
<style>{STYLES}</style>
<div
className={`as-sidebar-container${isQuestPage ? " quest-mode" : ""}`}
>
<div
className={`as-gradient-overlay ${isQuestPage ? "quest" : "default"}`}
/>
<div className="as-sidebar-inner">
{/* HEADER */} {/* HEADER */}
<SidebarHeader> <SidebarHeader className="px-3 pb-4 pt-1">
<div className="flex items-center justify-between px-2 py-2 rounded-lg hover:bg-white/10 cursor-pointer"> <div className="flex items-center justify-start gap-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 rounded-2xl px-2 py-2">
<div className="flex rounded-md w-10 h-10 border overflow-hidden"> <div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-linear-to-br from-purple-400 to-purple-500 shadow-[0_6px_18px_rgba(168,85,247,0.55)]">
<img <img
src={logo} src={logo}
className="w-full h-full object-cover object-left" className="h-full w-full object-cover object-left"
alt="Logo" alt="Logo"
/> />
</div> </div>
<div className="flex flex-col text-sm"> <div className="flex flex-col text-sm">
<span className="font-satoshi-medium text-black"> <span className="font-satoshi-medium text-slate-900">
Edbridge Scholars Edbridge Scholars
</span> </span>
<span className="text-xs text-gray-400 font-satoshi"> <span className="font-satoshi text-xs text-slate-400">
Student Student
</span> </span>
</div> </div>
</div> </div>
<ChevronDown size={16} />
</div> </div>
</SidebarHeader> </SidebarHeader>
{/* CONTENT */} {/* CONTENT */}
<SidebarContent> <SidebarContent className="px-1">
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel className="text-gray-400 font-satoshi"> <SidebarGroupLabel className="px-2 text-[0.7rem] font-satoshi tracking-[0.16em] text-slate-400">
Platform PLATFORM
</SidebarGroupLabel> </SidebarGroupLabel>
<SidebarMenu> <SidebarMenu className="mt-1 space-y-1.5">
{/* HOME */}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild> <SidebarMenuButton
asChild
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
>
<NavLink <NavLink
to="/student/home" to="/student/home"
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 ${
isActive isActive
? "bg-zinc-800 text-white" ? "text-slate-900"
: "text-zinc-400 hover:bg-zinc-800" : "text-slate-500 group-hover:text-slate-900"
}`
} }
> >
<Home size={18} className="text-black" /> {({ isActive }) => (
<span className="font-satoshi text-black">Home</span> <>
<Home
size={18}
strokeWidth={3}
className={
isActive ? "text-orange-400" : "text-slate-400"
}
/>
<span
className={
isActive
? "text-orange-400 font-extrabold"
: "text-slate-400 font-bold"
}
>
Home
</span>
</>
)}
</NavLink> </NavLink>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem> {/* PRACTICE */}
<SidebarMenuButton <SidebarMenuItem
className="cursor-pointer" onMouseEnter={() => setOpen(true)}
asChild onMouseLeave={() => setOpen(false)}
onClick={() => setOpen(!open)}
> >
<div> <SidebarMenuButton
<BookOpen size={18} className="text-black" /> className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
<span className="font-satoshi text-black">Practice</span> asChild
>
<NavLink
to="/student/practice"
className={({ isActive }) =>
`flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 ${
isActive
? "text-slate-900"
: "text-slate-500 group-hover:text-slate-900"
}`
}
>
{({ isActive }) => (
<>
<BookOpen
size={18}
strokeWidth={3}
className={
isActive ? "text-purple-500" : "text-slate-400"
}
/>
<span
className={
isActive
? "text-purple-500 font-extrabold"
: "text-slate-400 font-bold"
}
>
Practice
</span>
<ChevronDown <ChevronDown
size={16} size={16}
className={`ml-auto transition-transform ${ strokeWidth={3}
className={`ml-auto text-slate-400 transition-transform ${
open ? "rotate-180" : "" open ? "rotate-180" : ""
}`} }`}
/> />
</div> </>
)}
</NavLink>
</SidebarMenuButton> </SidebarMenuButton>
{open && ( {open && (
<SidebarMenuSub className="space-y-3 mt-2"> <SidebarMenuSub className="mt-2 space-y-1.5 pl-3">
<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 <NavLink
to="/student/practice/targeted-practice" to="/student/practice/targeted-practice"
className="text-black text-sm flex items-center gap-3" className={({ isActive }) =>
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
isActive
? "bg-white text-slate-900"
: "text-slate-500 hover:bg-white hover:text-slate-900"
}`
}
> >
<Target size={18} className="text-black" /> <Target
<span className="font-satoshi text-black"> size={18}
Targeted Practice strokeWidth={3}
</span> className="text-slate-400"
/>
<span>Targeted Practice</span>
</NavLink> </NavLink>
<NavLink <NavLink
to="/student/practice/drills" to="/student/practice/drills"
className="text-black text-sm flex items-center gap-3" className={({ isActive }) =>
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
isActive
? "bg-white text-slate-900"
: "text-slate-500 hover:bg-white hover:text-slate-900"
}`
}
> >
<Zap size={18} className="text-black" /> <Zap
<span className="font-satoshi text-black">Drills</span> size={18}
strokeWidth={3}
className="text-slate-400"
/>
<span>Drills</span>
</NavLink> </NavLink>
<NavLink <NavLink
to="/student/practice/hard-test-modules" to="/student/practice/hard-test-modules"
className="text-black text-sm flex items-center gap-3" className={({ isActive }) =>
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
isActive
? "bg-white text-slate-900"
: "text-slate-500 hover:bg-white hover:text-slate-900"
}`
}
> >
<Trophy size={18} className="text-black" /> <Trophy
<span className="font-satoshi text-black"> size={18}
Hard Test Modules strokeWidth={3}
</span> className="text-slate-400"
/>
<span>Hard Test Modules</span>
</NavLink>
<NavLink
to="/student/practice/practice-sheet"
className={({ isActive }) =>
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
isActive
? "bg-white text-slate-900"
: "text-slate-500 hover:bg-white hover:text-slate-900"
}`
}
>
<ListIcon
size={18}
strokeWidth={3}
className="text-slate-400"
/>
<span>Practice Sheet</span>
</NavLink> </NavLink>
</SidebarMenuSub> </SidebarMenuSub>
)} )}
</SidebarMenuItem> </SidebarMenuItem>
{/* DOCS */} {/* QUESTS */}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton
asChild
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
>
<NavLink <NavLink
to={`/student/lessons`} to="/student/quests"
className={({ isActive }) => className={({ isActive }) => {
isActive if (isActive && isQuestPage) {
? "bg-zinc-800 text-white" return "flex items-center gap-2.5 text-sm rounded-2xl px-2 py-2.5 transition-all duration-200";
: "text-zinc-400 hover:bg-zinc-800" }
if (isActive) {
return "flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 text-slate-900";
}
return "flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 text-slate-500 group-hover:text-slate-900";
}}
>
{({ isActive }) => (
<>
<Map
size={18}
strokeWidth={3}
className={
isActive && isQuestPage
? "text-amber-400"
: isActive
? "text-blue-500"
: "text-slate-400"
}
/>
<span
className={
isActive && isQuestPage
? ""
: isActive
? "text-blue-500 font-extrabold"
: "text-slate-400 font-bold"
}
style={
isActive && isQuestPage
? {
fontFamily: "'Sorts Mill Goudy', serif",
fontSize: "0.95rem",
fontWeight: 900,
letterSpacing: "0.05em",
color: "#fbbf24",
textShadow: "0 0 12px rgba(251,191,36,0.5)",
}
: {}
} }
> >
<SidebarMenuButton className="cursor-pointer"> Quests
<Video size={18} className="text-black" /> </span>
<span className="text-black font-satoshi">Lessons</span> </>
</SidebarMenuButton> )}
</NavLink> </NavLink>
</SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
{/* SETTINGS */} {/* LESSONS */}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton
asChild
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
>
<NavLink <NavLink
to={`/student/rewards`} to="/student/lessons"
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 ${
isActive isActive
? "bg-zinc-800 text-white" ? "text-slate-900"
: "text-zinc-400 hover:bg-zinc-800" : "text-slate-500 group-hover:text-slate-900"
}`
} }
> >
<SidebarMenuButton className="cursor-pointer"> {({ isActive }) => (
<Trophy size={18} className="text-black" /> <>
<span className="text-black font-satoshi">Rewards</span> <SquareLibrary
</SidebarMenuButton> size={18}
strokeWidth={3}
className={
isActive ? "text-cyan-500" : "text-slate-400"
}
/>
<span
className={
isActive
? "text-cyan-500 font-extrabold"
: "text-slate-400 font-bold"
}
>
Lessons
</span>
</>
)}
</NavLink> </NavLink>
</SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
{/* REWARDS */}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton
asChild
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
>
<NavLink <NavLink
to={`/student/profile`} to="/student/rewards"
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 ${
isActive isActive
? "bg-zinc-800 text-white" ? "text-slate-900"
: "text-zinc-400 hover:bg-zinc-800" : "text-slate-500 group-hover:text-slate-900"
}`
} }
> >
<SidebarMenuButton className="cursor-pointer"> {({ isActive }) => (
<User size={18} className="text-black" /> <>
<span className="text-black font-satoshi">Profile</span> <Trophy
</SidebarMenuButton> size={18}
strokeWidth={3}
className={
isActive ? "text-emerald-500" : "text-slate-400"
}
/>
<span
className={
isActive
? "text-emerald-500 font-extrabold"
: "text-slate-400 font-bold"
}
>
Rewards
</span>
</>
)}
</NavLink> </NavLink>
</SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
{/* FOOTER */} {/* FOOTER links to profile */}
<SidebarFooter> <SidebarFooter className="mt-auto px-3 pb-3 pt-4">
<div className="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-white/10 cursor-pointer"> <button
type="button"
onClick={() => navigate("/student/profile")}
className="flex w-full items-center gap-3 rounded-2xl bg-white/60 px-3 py-2 text-left shadow-sm ring-1 ring-white/80 hover:bg-white"
>
<Avatar> <Avatar>
<AvatarImage src={user?.avatar_url} /> <AvatarImage src={user?.avatar_url} />
<AvatarFallback className="font-satoshi-bold bg-linear-to-br from-purple-400 to-purple-500 uppercase"> <AvatarFallback className="bg-linear-to-br from-purple-400 to-purple-500 font-satoshi-bold uppercase text-white">
{user?.name.slice(0, 1)} {user?.name.slice(0, 1)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col text-sm"> <div className="flex flex-col text-sm">
<span className="font-medium text-black">{user?.name}</span> <span className="font-medium text-slate-900">{user?.name}</span>
<span className="text-xs text-gray-400">{user?.email}</span> <span className="text-xs text-slate-400">{user?.email}</span>
</div>
<ChevronDown size={16} className="ml-auto" />
</div> </div>
<ChevronDown
size={16}
strokeWidth={3}
className="ml-auto text-slate-400"
/>
</button>
</SidebarFooter> </SidebarFooter>
</Sidebar> </div>
</div>
</>
); );
} }

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { X, Calculator, Maximize2, Minimize2 } from "lucide-react"; import { X, Calculator, Maximize2, Minimize2 } from "lucide-react";

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 = ({ 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,178 @@
// lessonRegistry.tsx
import { lazy, type ComponentType } from "react";
// lessonTypes.ts
export type LessonId =
// ---- EBRW ----
| "ebrw-words-in-context"
| "ebrw-text-structure-purpose"
| "ebrw-cross-text-connections"
| "ebrw-central-ideas-details"
| "ebrw-inferences"
| "ebrw-command-of-evidence"
| "ebrw-boundaries"
| "ebrw-form-structure-sense"
| "ebrw-transitions"
| "ebrw-rhetorical-synthesis"
// ---- 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 EBRWWordsInContext = lazy(
() => import("../pages/student/lessons/EBRWWordsInContextLesson"),
);
const EBRWTextStructurePurpose = lazy(
() => import("../pages/student/lessons/EBRWTextStructurePurposeLesson"),
);
const EBRWCrossText = lazy(
() => import("../pages/student/lessons/EBRWCrossTextLesson"),
);
const EBRWCentralIdeas = lazy(
() => import("../pages/student/lessons/EBRWCentralIdeasLesson"),
);
const EBRWInferences = lazy(
() => import("../pages/student/lessons/EBRWInferencesLesson"),
);
const EBRWCommandEvidence = lazy(
() => import("../pages/student/lessons/EBRWCommandEvidenceLesson"),
);
const EBRWBoundaries = lazy(
() => import("../pages/student/lessons/EBRWBoundariesLesson"),
);
const EBRWFormStructureSense = lazy(
() => import("../pages/student/lessons/EBRWFormStructureSenseLesson"),
);
const EBRWTransitions = lazy(
() => import("../pages/student/lessons/EBRWTransitionsLesson"),
);
const EBRWRhetoricalSynthesis = lazy(
() => import("../pages/student/lessons/EBRWRhetoricalSynthesisLesson"),
);
// ---- 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-words-in-context": EBRWWordsInContext,
"ebrw-text-structure-purpose": EBRWTextStructurePurpose,
"ebrw-cross-text-connections": EBRWCrossText,
"ebrw-central-ideas-details": EBRWCentralIdeas,
"ebrw-inferences": EBRWInferences,
"ebrw-command-of-evidence": EBRWCommandEvidence,
"ebrw-boundaries": EBRWBoundaries,
"ebrw-form-structure-sense": EBRWFormStructureSense,
"ebrw-transitions": EBRWTransitions,
"ebrw-rhetorical-synthesis": EBRWRhetoricalSynthesis,
// ---- 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,904 @@
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";
import { generateArcTheme } from "../pages/student/QuestMap";
import { InventoryButton } from "./InventoryButton";
// ─── Requirement helpers ──────────────────────────────────────────────────────
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;
}
/* ── Rank ladder scroll container ──
Always scrollable on mobile. On desktop (≥1024px) we lock the width and
disable scrolling — nodes are spaced by percentage so no overflow occurs. */
.hc-ext-scroll {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
cursor: grab;
position: relative;
z-index: 2;
}
.hc-ext-scroll::-webkit-scrollbar { display: none; }
.hc-ext-scroll:active { cursor: grabbing; }
/* ── Rank ladder inner track ──
On mobile: fixed pixel width (fits all 6 nodes without squishing).
On desktop: 100% width, nodes spaced purely by percentage. */
.hc-ext-inner {
display: flex;
align-items: flex-end;
position: relative;
height: 110px;
/* Mobile: fixed width so nodes have room and scroll kicks in */
width: 520px;
}
@media (min-width: 1024px) {
.hc-ext-scroll {
overflow-x: hidden;
cursor: default;
}
.hc-ext-inner {
/* Desktop: fill the card width; percentage positions work correctly */
width: 100%;
}
/* Upscale fonts for desktop readability */
.hc-ext-title { font-size: 0.72rem; }
.hc-ext-earned { font-size: 0.82rem; }
.hc-ext-label-name { font-size: 0.56rem; }
.hc-ext-label-xp { font-size: 0.5rem; }
.hc-ext-node { width: 56px; height: 56px; font-size: 1.5rem; }
.hc-ext-ship { font-size: 1.7rem; }
}
/* ── Baseline and progress line ──
Use percentage-based left/right so they align with percentage-positioned nodes
regardless of container width. A small inset (half a node width as %) keeps
the line from starting/ending at the very edge of the outer nodes. */
.hc-ext-baseline {
position: absolute;
top: 56px;
/* Inset matches half the outer col width relative to inner width */
left: 4%;
right: 4%;
height: 2px;
background: rgba(255,255,255,0.07);
border-radius: 2px;
z-index: 0;
}
.hc-ext-progress-line {
position: absolute;
top: 56px;
left: 4%;
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;
/* Width is set inline as a % of the 92% usable span (100% - 4% - 4%) */
transition: width 1.2s cubic-bezier(0.34,1.56,0.64,1);
}
.hc-ext-progress-line::after {
content: "";
position: absolute;
right: -6px; top: -3px;
width: 10px; height: 10px;
background: #fbbf24;
border-radius: 50%;
box-shadow: 0 0 10px rgba(251,191,36,0.9);
}
.hc-ext-ship-wrap {
position: absolute;
top: 20px; z-index: 10; pointer-events: none;
display: flex; flex-direction: column; align-items: center;
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);
}
/* ── Node columns ──
Each col takes an equal share; absolute positioning within .hc-ext-inner
is replaced by evenly-spaced flex. The node itself is centred in the col. */
.hc-ext-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 2;
}
.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: 30px;
}
.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 });
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);
}
// ─── 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;
// ── Geometry ────────────────────────────────────────────────────────────────
// Nodes are evenly distributed via flex (each col = flex:1).
// The centre of node[i] sits at: leftInset + (i / (N-1)) * usableSpan
// where leftInset = 4% and usableSpan = 92% (100% - 4% left - 4% right).
// The baseline and progress line also start at 4% so everything aligns.
const LEFT_INSET_PCT = 4; // matches left: 4% on baseline/progress
const USABLE_PCT = 92; // 100 - 4 - 4
// Ship position as % of the inner container width
const nodePosPct = (i: number) => LEFT_INSET_PCT + (i / (N - 1)) * USABLE_PCT;
const shipPct = nextRank
? nodePosPct(currentIdx) +
(nodePosPct(currentIdx + 1) - nodePosPct(currentIdx)) * progressToNext
: nodePosPct(currentIdx);
// Progress line width: distance from left inset to ship position
// expressed as % of the container (not of usable span)
const progressLinePct = shipPct - LEFT_INSET_PCT;
const [animated, setAnimated] = useState(false);
useEffect(() => {
const id = requestAnimationFrame(() =>
requestAnimationFrame(() => setAnimated(true)),
);
return () => cancelAnimationFrame(id);
}, []);
// Mouse-drag scroll for mobile
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
let isDown = false,
startX = 0,
scrollLeft = 0;
const down = (e: MouseEvent) => {
isDown = true;
startX = e.pageX - el.offsetLeft;
scrollLeft = el.scrollLeft;
};
const leave = () => (isDown = false);
const up = () => (isDown = false);
const move = (e: MouseEvent) => {
if (!isDown) return;
e.preventDefault();
el.scrollLeft = scrollLeft - (e.pageX - el.offsetLeft - startX) * 1.2;
};
el.addEventListener("mousedown", down);
el.addEventListener("mouseleave", leave);
el.addEventListener("mouseup", up);
el.addEventListener("mousemove", move);
return () => {
el.removeEventListener("mousedown", down);
el.removeEventListener("mouseleave", leave);
el.removeEventListener("mouseup", up);
el.removeEventListener("mousemove", move);
};
}, []);
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">
{/* Baseline — left: 4%, right: 4% (set in CSS) */}
<div className="hc-ext-baseline" />
{/* Progress line — starts at left: 4%, width grows to ship position */}
<div
className="hc-ext-progress-line"
style={{ width: animated ? `${progressLinePct}%` : "0%" }}
/>
{/* Ship — positioned as % of container */}
<div
className="hc-ext-ship-wrap"
style={{ left: animated ? `${shipPct}%` : `${nodePosPct(0)}%` }}
>
<span className="hc-ext-ship" role="img" aria-label="ship">
</span>
<div className="hc-ext-ship-tether" />
</div>
{/* Nodes — evenly spaced via flex:1 on each col */}
{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);
const arcs = useQuestStore((s) => s.arcs);
const earnedXP = user?.total_xp ?? 0;
const earnedTitles = useQuestStore((s) => s.earnedTitles);
const claimNode = useQuestStore((s) => s.claimNode);
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);
const [claimResult, setClaimResult] = useState<ClaimedRewardResponse | null>(
null,
);
const handleViewAll = () => {
if (onViewAll) onViewAll();
else navigate("/student/quests");
};
const handleClaim = (node: QuestNode, arcId: string) => {
setClaimResult(null);
setClaimingNode({ node, arcId });
};
const handleChestClose = () => {
if (!claimingNode) return;
claimNode(
claimingNode.arcId,
claimingNode.node.node_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">
{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>
{/* @ts-ignore */}
<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" />
</>
)}
{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>
)}
{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 }) => {
const pct = Math.min(
100,
Math.round((node.current_value / node.req_target) * 100),
);
const isClaimable = node.status === "claimable";
const accentColor = generateArcTheme(arc).accent;
const nodeEmoji = REQ_EMOJI[node.req_type] ?? "🏝️";
const reqLabel = REQ_LABEL[node.req_type] ?? node.req_type;
return (
<div
key={node.node_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">
<p className="hc-q-name">{node.name ?? "—"}</p>
{isClaimable ? (
<p className="hc-q-claimable"> Ready to claim!</p>
) : (
<p className="hc-q-sub">
{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,216 @@
import { useState } from "react";
import {
useInventoryStore,
getLiveEffects,
formatTimeLeft,
} 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,615 @@
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;
}
@media (min-width: 1024px) {
.inv-sheet {
max-width: 1000px;
}
}
@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,
);
};

1047
src/components/Island3D.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,31 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState, Suspense } from "react";
import { Dialog, DialogContent, DialogHeader } from "../components/ui/dialog"; import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { api } from "../utils/api"; import { api } from "../utils/api";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
import { Loader, X } from "lucide-react"; 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 { interface LessonModalProps {
lessonId: string | null; selectedLessonData: { id: string | null; name: string | null };
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; 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);
const STYLES = ` 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'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
/* Override Dialog defaults */
.lm-content { .lm-content {
font-family: 'Nunito', sans-serif; font-family: 'Nunito', sans-serif;
background: #fffbf4; background: #fffbf4;
@ -29,14 +41,21 @@ const STYLES = `
flex-direction: column; flex-direction: column;
} }
/* Header bar */ @media (min-width: 1024px) {
.lm-content {
max-width: 1000px;
}
}
.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 { .lm-header {
display: flex; align-items: flex-start; justify-content: space-between; display: flex; align-items: flex-start; justify-content: space-between;
padding: 1.25rem 1.5rem 0; padding: 1.25rem 1.5rem 0; flex-shrink: 0; gap: 1rem;
flex-shrink: 0;
gap: 1rem;
} }
.lm-title-wrap { display:flex;flex-direction:column;gap:0.2rem; flex:1; } .lm-title-wrap { display:flex; flex-direction:column; gap:0.2rem; flex:1; }
.lm-eyebrow { .lm-eyebrow {
font-size: 0.62rem; font-weight: 800; letter-spacing: 0.16em; font-size: 0.62rem; font-weight: 800; letter-spacing: 0.16em;
text-transform: uppercase; color: #a855f7; text-transform: uppercase; color: #a855f7;
@ -50,38 +69,26 @@ const STYLES = `
border-radius: 50%; border: 2.5px solid #f3f4f6; border-radius: 50%; border: 2.5px solid #f3f4f6;
background: white; cursor: pointer; background: white; cursor: pointer;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.06); box-shadow: 0 2px 8px rgba(0,0,0,0.06); transition: all 0.15s ease;
transition: all 0.15s ease;
} }
.lm-close-btn:hover { border-color: #fecdd3; background: #fff1f2; } .lm-close-btn:hover { border-color: #fecdd3; background: #fff1f2; }
/* Scrollable body */
.lm-body { .lm-body {
overflow-y: auto; overflow-y: auto; flex: 1;
flex: 1;
padding: 1rem 1.5rem 1.5rem; padding: 1rem 1.5rem 1.5rem;
display: flex; flex-direction: column; gap: 1rem; display: flex; flex-direction: column; gap: 1rem;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
/* Video player */
.lm-video { .lm-video {
width: 100%; border-radius: 18px; width: 100%; border-radius: 18px;
aspect-ratio: 16/9; background: #1e1b4b; aspect-ratio: 16/9; background: #1e1b4b; display: block;
display: block;
} }
/* Topic chip */
.lm-topic-chip { .lm-topic-chip {
display: inline-flex; align-items: center; gap: 0.4rem; display: inline-flex; align-items: center; gap: 0.4rem;
background: #f3e8ff; border: 2px solid #e9d5ff; background: #f3e8ff; border: 2px solid #e9d5ff;
border-radius: 100px; padding: 0.3rem 0.8rem; border-radius: 100px; padding: 0.3rem 0.8rem;
font-size: 0.7rem; font-weight: 800; font-size: 0.7rem; font-weight: 800; letter-spacing: 0.08em;
letter-spacing: 0.08em; text-transform: uppercase; text-transform: uppercase; color: #9333ea; width: fit-content;
color: #9333ea; width: fit-content;
} }
/* Description & content cards */
.lm-card { .lm-card {
background: white; border: 2.5px solid #f3f4f6; background: white; border: 2.5px solid #f3f4f6;
border-radius: 18px; padding: 1rem 1.1rem; border-radius: 18px; padding: 1rem 1.1rem;
@ -93,88 +100,181 @@ const STYLES = `
} }
.lm-card-text { .lm-card-text {
font-family: 'Nunito Sans', sans-serif; font-family: 'Nunito Sans', sans-serif;
font-size: 0.88rem; font-weight: 600; color: #374151; font-size: 0.88rem; font-weight: 600; color: #374151; line-height: 1.6;
line-height: 1.6;
} }
/* Loading state */
.lm-loading { .lm-loading {
display: flex; flex-direction: column; align-items: center; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 0.75rem; justify-content: center; gap: 0.75rem; padding: 3rem 1.5rem; flex: 1;
padding: 3rem 1.5rem;
flex: 1;
} }
.lm-loading-spinner { animation: lmSpin 0.8s linear infinite; } .lm-loading-spinner { animation: lmSpin 0.8s linear infinite; }
@keyframes lmSpin { to { transform: rotate(360deg); } } @keyframes lmSpin { to { transform: rotate(360deg); } }
.lm-loading-text { .lm-loading-text { font-size: 0.85rem; font-weight: 700; color: #9ca3af; }
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 = ({ export const LessonModal = ({
lessonId, selectedLessonData,
open, open,
onOpenChange, onOpenChange,
}: LessonModalProps) => { }: LessonModalProps) => {
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [lesson, setLesson] = useState<any>(null); const [lesson, setLesson] = useState<LessonDetails | null>(null);
const [error, setError] = useState(false);
const fetchingForId = useRef<string | null>(null);
const lessonId = selectedLessonData.id;
const LocalLessonComponent =
lessonId && !isVideoLesson(lessonId)
? LESSON_COMPONENT_MAP[lessonId as LessonId]
: null;
// const modalTitle = LocalLessonComponent
// ? getLocalLessonTitle(lessonId!)
// : loading
// ? "Loading..."
// : (lesson?.title ?? "Lesson");
const modalTitle =
selectedLessonData.name || selectedLessonData.id || "Lesson";
useEffect(() => { useEffect(() => {
if (!open || !lessonId || !user) return; 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 () => { const fetchLesson = async () => {
try { fetchingForId.current = lessonId;
setLesson(null);
setError(false);
setLoading(true); setLoading(true);
try {
const authStorage = localStorage.getItem("auth-storage"); const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return; if (!authStorage) throw new Error("No auth storage");
const { const {
// @ts-ignore
state: { token }, state: { token },
} = JSON.parse(authStorage) as { state?: { token?: string } }; } = JSON.parse(authStorage) as { state?: { token?: string } };
if (!token) return; if (!token) throw new Error("No token");
const response = await api.fetchLessonById(token, lessonId);
// @ts-ignore
const response: LessonDetails = await api.fetchLessonById(
token,
lessonId,
);
if (fetchingForId.current !== lessonId) return;
setLesson(response); setLesson(response);
} catch (err) { } catch (err) {
console.error("Failed to fetch lesson", err); console.error("Failed to fetch lesson", err);
if (fetchingForId.current === lessonId) setError(true);
} finally { } finally {
setLoading(false); if (fetchingForId.current === lessonId) setLoading(false);
} }
}; };
fetchLesson(); fetchLesson();
}, [open, lessonId, user]); }, [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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<style>{STYLES}</style> <style>{STYLES}</style>
<DialogContent className="lm-content" showCloseButton={false}> <DialogContent className="lm-content" showCloseButton={false}>
<DialogHeader style={{ display: "none" }} /> <DialogHeader className="lm-dialog-header-hidden">
<DialogTitle>{modalTitle}</DialogTitle>
</DialogHeader>
{/* Header */}
<div className="lm-header"> <div className="lm-header">
<div className="lm-title-wrap"> <div className="lm-title-wrap">
<span className="lm-eyebrow">📖 Lesson</span> <span className="lm-eyebrow">📖 Lesson</span>
<h2 className="lm-title"> <h2 className="lm-title">{modalTitle}</h2>
{loading ? "Loading..." : (lesson?.title ?? "Lesson details")}
</h2>
</div> </div>
<button className="lm-close-btn" onClick={() => onOpenChange(false)}> <button className="lm-close-btn" onClick={() => onOpenChange(false)}>
<X size={16} color="#6b7280" /> <X size={16} color="#6b7280" />
</button> </button>
</div> </div>
{/* Body */}
{loading ? ( {loading ? (
<div className="lm-loading"> <LoadingSpinner />
<Loader size={28} color="#a855f7" className="lm-loading-spinner" /> ) : error ? (
<p className="lm-loading-text">Loading lesson...</p> <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>
) : ( ) : (
lesson && (
<div className="lm-body"> <div className="lm-body">
{LocalLessonComponent ? (
<Suspense fallback={<LoadingSpinner />}>
<LocalLessonComponent />
</Suspense>
) : (
lesson && (
<>
{/* Video */}
{lesson.video_url && ( {lesson.video_url && (
<video src={lesson.video_url} controls className="lm-video" /> <video
src={lesson.video_url}
controls
className="lm-video"
/>
)} )}
{lesson.topic?.name && ( {/* Topic chip */}
{topicName && (
<div> <div>
<span className="lm-topic-chip"> <span className="lm-topic-chip">
<span <span
@ -186,11 +286,12 @@ export const LessonModal = ({
flexShrink: 0, flexShrink: 0,
}} }}
/> />
{lesson.topic.name} {topicName}
</span> </span>
</div> </div>
)} )}
{/* Description */}
{lesson.description && ( {lesson.description && (
<div className="lm-card"> <div className="lm-card">
<p className="lm-card-label">About this lesson</p> <p className="lm-card-label">About this lesson</p>
@ -198,15 +299,48 @@ export const LessonModal = ({
</div> </div>
)} )}
{/* Content */}
{lesson.content && ( {lesson.content && (
<div className="lm-card"> <div className="lm-card">
<p className="lm-card-label">Content</p> <p className="lm-card-label">Content</p>
<p className="lm-card-text">{lesson.content}</p> <p className="lm-card-text">{lesson.content}</p>
</div> </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>
</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> </DialogContent>
</Dialog> </Dialog>
); );

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

@ -281,6 +281,7 @@ const SectionDetail = ({
<div className="psc-detail-card"> <div className="psc-detail-card">
<div className="psc-detail-top"> <div className="psc-detail-top">
<div className="psc-detail-icon-wrap" style={{ background: iconBg }}> <div className="psc-detail-icon-wrap" style={{ background: iconBg }}>
{/* @ts-ignore */}
<Icon size={15} color={barColor} /> <Icon size={15} color={barColor} />
</div> </div>
<span className="psc-detail-label">{label}</span> <span className="psc-detail-label">{label}</span>

File diff suppressed because it is too large Load Diff

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

@ -1,16 +1,77 @@
import { Component, type ReactNode } from "react";
// @ts-ignore
import { BlockMath, InlineMath } from "react-katex"; import { BlockMath, InlineMath } from "react-katex";
// ─── Error boundary ───────────────────────────────────────────────────────────
// react-katex throws synchronously during render for invalid LaTeX, so a class
// error boundary is the only reliable way to catch it.
interface MathErrorBoundaryProps {
raw: string; // the original LaTeX string, shown as fallback
children: ReactNode;
}
interface MathErrorBoundaryState {
failed: boolean;
}
export class MathErrorBoundary extends Component<
MathErrorBoundaryProps,
MathErrorBoundaryState
> {
state: MathErrorBoundaryState = { failed: false };
static getDerivedStateFromError(): MathErrorBoundaryState {
return { failed: true };
}
render() {
if (this.state.failed) {
return (
<span
title={`Could not render: ${this.props.raw}`}
style={{
fontFamily: "monospace",
background: "rgba(239,68,68,0.08)",
border: "1px solid rgba(239,68,68,0.3)",
borderRadius: 4,
padding: "0 4px",
color: "#f87171",
fontSize: "0.9em",
}}
>
{this.props.raw}
</span>
);
}
return this.props.children;
}
}
// ─── Renderer ─────────────────────────────────────────────────────────────────
export const renderQuestionText = (text: string) => { export const renderQuestionText = (text: string) => {
const parts = text.split(/(\$\$.*?\$\$|\$.*?\$)/g); if (!text) return null;
const parts = text.split(/(\$\$.*?\$\$|\$.*?\$)/gs);
return ( return (
<> <>
{parts.map((part, index) => { {parts.map((part, index) => {
if (part.startsWith("$$")) { if (part.startsWith("$$")) {
return <BlockMath key={index}>{part.slice(2, -2)}</BlockMath>; const latex = part.slice(2, -2);
return (
<MathErrorBoundary key={index} raw={part}>
<BlockMath>{latex}</BlockMath>
</MathErrorBoundary>
);
} }
if (part.startsWith("$")) { if (part.startsWith("$")) {
return <InlineMath key={index}>{part.slice(1, -1)}</InlineMath>; const latex = part.slice(1, -1);
return (
<MathErrorBoundary key={index} raw={part}>
<InlineMath>{latex}</InlineMath>
</MathErrorBoundary>
);
} }
return <span key={index}>{part}</span>; return <span key={index}>{part}</span>;
})} })}

View File

@ -28,7 +28,7 @@ interface Props {
// ─── Nav items ──────────────────────────────────────────────────────────────── // ─── Nav items ────────────────────────────────────────────────────────────────
const NAV_ITEMS: (SearchItem & { const NAV_ITEMS: (SearchItem & {
icon: React.ElementType; icon: React.ComponentType<any>;
color: string; color: string;
bg: string; bg: string;
})[] = [ })[] = [
@ -490,6 +490,7 @@ export const SearchOverlay = ({
className="so-item-icon" className="so-item-icon"
style={{ background: bg }} style={{ background: bg }}
> >
{/* @ts-ignore */}
<Icon size={16} color={color} /> <Icon size={16} color={color} />
</div> </div>
<div className="so-item-body"> <div className="so-item-body">
@ -517,6 +518,7 @@ export const SearchOverlay = ({
className="so-quick-chip" className="so-quick-chip"
onClick={() => handleSelect(item)} onClick={() => handleSelect(item)}
> >
{/* @ts-ignore */}
<item.icon size={13} color={item.color} /> <item.icon size={13} color={item.color} />
{item.title} {item.title}
</button> </button>
@ -533,6 +535,7 @@ export const SearchOverlay = ({
.filter((s) => s.user_status === "IN_PROGRESS") .filter((s) => s.user_status === "IN_PROGRESS")
.slice(0, 3) .slice(0, 3)
.map((sheet) => { .map((sheet) => {
// @ts-ignore
const item: SearchItem = { const item: SearchItem = {
type: "sheet", type: "sheet",
title: sheet.title, title: sheet.title,
@ -602,8 +605,9 @@ export const SearchOverlay = ({
const Icon = navMeta?.icon ?? BookOpen; const Icon = navMeta?.icon ?? BookOpen;
const iconColor = navMeta?.color ?? "#a855f7"; const iconColor = navMeta?.color ?? "#a855f7";
const iconBg = navMeta?.bg ?? "#fdf4ff"; const iconBg = navMeta?.bg ?? "#fdf4ff";
const statusMeta = item.status const statusMeta = item.status
? STATUS_META[item.status as keyof typeof STATUS_META] ? STATUS_META[item?.status as keyof typeof STATUS_META]
: null; : null;
return ( return (

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,185 @@
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;
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,171 @@
import React, { useState, useRef } 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 };
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,239 @@
import { 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,217 @@
import { 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 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,266 @@
import React, { useRef, useState } from "react";
import {
scaleToSvg,
scaleFromSvg,
round,
calculateDistanceSquared,
} from "../../utils/math";
import { type CircleState, type Point } from "../../types/lesson";
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 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,651 @@
import { 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((_, 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,278 @@
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) {
// @ts-ignore
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];
// 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"
}`}
>
{
// @ts-ignore
step.node.question.length > 40
? // @ts-ignore
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-35 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-35 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,158 @@
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 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,103 @@
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) => {
// 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,504 @@
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 lg:bg-transparent lg:shadow-none lg:border-transparent",
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 lg:bg-transparent lg:shadow-none lg:border-transparent",
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 lg:bg-transparent lg:shadow-none lg:border-transparent",
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 lg:bg-transparent lg:shadow-none lg:border-transparent",
dotBg: "bg-emerald-100",
dotText: "text-emerald-500",
glassClass: "glass-emerald",
},
};
export default function LessonShell({
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">
{/* ── 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 lg:bg-transparent">
{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 lg:hover:bg-transparent"
}`}
>
<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 lg:ml-64 mx-auto md:p-12 max-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,178 @@
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 = 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,150 @@
import React, { useState } from "react";
const MultiStepPercentWidget: React.FC = () => {
const start = 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,147 @@
import React, { useState } 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;
// @ts-ignore
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) => {
// @ts-ignore
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,146 @@
import React, { useState } from "react";
const PolynomialBehaviorWidget: React.FC = () => {
const [degreeType, setDegreeType] = useState<"even" | "odd">("odd");
const [lcSign, setLcSign] = useState<"pos" | "neg">("pos");
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,432 @@
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 = (
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("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("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("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("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("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("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,230 @@
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 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,114 @@
import React, { useState, useEffect, useRef } from 'react';
export interface RevealCard {
label: string;
sublabel?: string;
content: string;
}
const COLORS: Record<string, {
text: string; activeBg: string; activeBorder: string;
hoverBorder: string; btn: string;
}> = {
fuchsia: { text: 'text-fuchsia-700', activeBg: 'bg-fuchsia-50', activeBorder: 'border-fuchsia-300', hoverBorder: 'hover:border-fuchsia-300', btn: 'text-fuchsia-600' },
teal: { text: 'text-teal-700', activeBg: 'bg-teal-50', activeBorder: 'border-teal-300', hoverBorder: 'hover:border-teal-300', btn: 'text-teal-600' },
purple: { text: 'text-purple-700', activeBg: 'bg-purple-50', activeBorder: 'border-purple-300', hoverBorder: 'hover:border-purple-300', btn: 'text-purple-600' },
rose: { text: 'text-rose-700', activeBg: 'bg-rose-50', activeBorder: 'border-rose-300', hoverBorder: 'hover:border-rose-300', btn: 'text-rose-600' },
indigo: { text: 'text-indigo-700', activeBg: 'bg-indigo-50', activeBorder: 'border-indigo-300', hoverBorder: 'hover:border-indigo-300', btn: 'text-indigo-600' },
amber: { text: 'text-amber-700', activeBg: 'bg-amber-50', activeBorder: 'border-amber-300', hoverBorder: 'hover:border-amber-300', btn: 'text-amber-600' },
};
interface Props {
cards: RevealCard[];
columns?: 2 | 3 | 4 | 5;
accentColor?: string;
}
const RevealCardGrid: React.FC<Props> = ({ cards, columns = 3, accentColor = 'fuchsia' }) => {
const [revealed, setRevealed] = useState<Set<number>>(new Set());
const [visible, setVisible] = useState(false);
const [entered, setEntered] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const c = COLORS[accentColor] || COLORS.fuchsia;
/* Entrance trigger — animate cards when grid scrolls into view */
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
entries => { if (entries[0]?.isIntersecting) { setVisible(true); obs.disconnect(); } },
{ threshold: 0.1 },
);
obs.observe(el);
return () => obs.disconnect();
}, []);
/* Clear stagger delays after entrance finishes so tap interactions are instant */
useEffect(() => {
if (!visible) return;
const t = setTimeout(() => setEntered(true), Math.min(cards.length * 50, 600) + 400);
return () => clearTimeout(t);
}, [visible, cards.length]);
const toggle = (i: number) =>
setRevealed(prev => { const s = new Set(prev); s.has(i) ? s.delete(i) : s.add(i); return s; });
const allDone = revealed.size === cards.length;
const colCls =
columns === 2 ? 'grid-cols-1 sm:grid-cols-2'
: columns === 4 ? 'grid-cols-2 sm:grid-cols-4'
: columns === 5 ? 'grid-cols-2 sm:grid-cols-5'
: 'grid-cols-2 sm:grid-cols-3';
return (
<div ref={ref} className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 font-medium">
{revealed.size}/{cards.length} revealed
</p>
<button
onClick={() => setRevealed(allDone ? new Set() : new Set(cards.map((_, i) => i)))}
className={`text-xs font-bold ${c.btn} hover:underline transition-colors`}
>
{allDone ? 'Hide All' : 'Reveal All'}
</button>
</div>
<div className={`grid ${colCls} gap-2`}>
{cards.map((card, i) => {
const open = revealed.has(i);
return (
<button
key={i}
onClick={() => toggle(i)}
aria-expanded={open}
className={[
'text-left rounded-xl p-3 border transition-all duration-300 ease-out',
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-3',
open
? `${c.activeBg} ${c.activeBorder} shadow-sm`
: `bg-slate-50 border-slate-200 ${c.hoverBorder} hover:shadow-sm`,
].join(' ')}
style={{ transitionDelay: !entered ? `${Math.min(i * 50, 600)}ms` : '0ms' }}
>
<p className={`font-bold ${c.text} text-xs mb-0.5`}>{card.label}</p>
{card.sublabel && (
<p className="text-[10px] text-slate-500 font-medium mb-1">{card.sublabel}</p>
)}
<div className={`transition-opacity duration-200 ${open ? 'opacity-100' : 'opacity-50'}`}>
{open ? (
<p className="text-xs text-slate-600 leading-relaxed">{card.content}</p>
) : (
<p className="text-xs text-slate-400 italic">tap to reveal</p>
)}
</div>
</button>
);
})}
</div>
</div>
);
};
export default RevealCardGrid;

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,645 @@
import React, { useState, useRef } 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 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,201 @@
import React, { useState, useRef } 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,36 @@
import { useEffect } from 'react';
/**
* Observes all `.scroll-reveal` elements in the DOM and adds the `revealed`
* class when they scroll into view. Works with the stagger-1 … stagger-10
* utility classes defined in index.css for sequenced entrance animations.
*
* Call once at the top of a lesson component:
* useScrollReveal();
*/
export default function useScrollReveal() {
useEffect(() => {
const sel = [
'.scroll-reveal:not(.revealed)',
'.scroll-reveal-left:not(.revealed)',
'.scroll-reveal-right:not(.revealed)',
'.scroll-reveal-scale:not(.revealed)',
].join(',');
const els = document.querySelectorAll(sel);
if (!els.length) return;
const obs = new IntersectionObserver(
entries =>
entries.forEach(e => {
if (e.isIntersecting) {
e.target.classList.add('revealed');
obs.unobserve(e.target);
}
}),
{ threshold: 0.12, rootMargin: '0px 0px -60px 0px' },
);
els.forEach(el => obs.observe(el));
return () => obs.disconnect();
}, []);
}

View File

@ -1,8 +1,7 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
import { cn } from "@/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none 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 transition-[color,box-shadow] overflow-hidden", "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none 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 transition-[color,box-shadow] overflow-hidden",
@ -22,8 +21,8 @@ const badgeVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function Badge({ function Badge({
className, className,
@ -32,7 +31,7 @@ function Badge({
...props ...props
}: React.ComponentProps<"span"> & }: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span" const Comp = asChild ? Slot : "span";
return ( return (
<Comp <Comp
@ -40,7 +39,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)} className={cn(badgeVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };

View File

@ -2,7 +2,7 @@ import * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "../../lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none 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", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none 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",

View File

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)} className={cn("leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)} className={cn("px-6", className)}
{...props} {...props}
/> />
) );
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -89,4 +89,4 @@ export {
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
} };

View File

@ -1,43 +1,43 @@
import * as React from "react" import * as React from "react";
import useEmblaCarousel, { import useEmblaCarousel, {
type UseEmblaCarouselType, type UseEmblaCarouselType,
} from "embla-carousel-react" } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react" import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "./button";
type CarouselApi = UseEmblaCarouselType[1] type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel> type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0] type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1] type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = { type CarouselProps = {
opts?: CarouselOptions opts?: CarouselOptions;
plugins?: CarouselPlugin plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical" orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void setApi?: (api: CarouselApi) => void;
} };
type CarouselContextProps = { type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0] carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1] api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void scrollPrev: () => void;
scrollNext: () => void scrollNext: () => void;
canScrollPrev: boolean canScrollPrev: boolean;
canScrollNext: boolean canScrollNext: boolean;
} & CarouselProps } & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null) const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() { function useCarousel() {
const context = React.useContext(CarouselContext) const context = React.useContext(CarouselContext);
if (!context) { if (!context) {
throw new Error("useCarousel must be used within a <Carousel />") throw new Error("useCarousel must be used within a <Carousel />");
} }
return context return context;
} }
function Carousel({ function Carousel({
@ -54,53 +54,53 @@ function Carousel({
...opts, ...opts,
axis: orientation === "horizontal" ? "x" : "y", axis: orientation === "horizontal" ? "x" : "y",
}, },
plugins plugins,
) );
const [canScrollPrev, setCanScrollPrev] = React.useState(false) const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false) const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => { const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return if (!api) return;
setCanScrollPrev(api.canScrollPrev()) setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext()) setCanScrollNext(api.canScrollNext());
}, []) }, []);
const scrollPrev = React.useCallback(() => { const scrollPrev = React.useCallback(() => {
api?.scrollPrev() api?.scrollPrev();
}, [api]) }, [api]);
const scrollNext = React.useCallback(() => { const scrollNext = React.useCallback(() => {
api?.scrollNext() api?.scrollNext();
}, [api]) }, [api]);
const handleKeyDown = React.useCallback( const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => { (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") { if (event.key === "ArrowLeft") {
event.preventDefault() event.preventDefault();
scrollPrev() scrollPrev();
} else if (event.key === "ArrowRight") { } else if (event.key === "ArrowRight") {
event.preventDefault() event.preventDefault();
scrollNext() scrollNext();
} }
}, },
[scrollPrev, scrollNext] [scrollPrev, scrollNext],
) );
React.useEffect(() => { React.useEffect(() => {
if (!api || !setApi) return if (!api || !setApi) return;
setApi(api) setApi(api);
}, [api, setApi]) }, [api, setApi]);
React.useEffect(() => { React.useEffect(() => {
if (!api) return if (!api) return;
onSelect(api) onSelect(api);
api.on("reInit", onSelect) api.on("reInit", onSelect);
api.on("select", onSelect) api.on("select", onSelect);
return () => { return () => {
api?.off("select", onSelect) api?.off("select", onSelect);
} };
}, [api, onSelect]) }, [api, onSelect]);
return ( return (
<CarouselContext.Provider <CarouselContext.Provider
@ -127,11 +127,11 @@ function Carousel({
{children} {children}
</div> </div>
</CarouselContext.Provider> </CarouselContext.Provider>
) );
} }
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel() const { carouselRef, orientation } = useCarousel();
return ( return (
<div <div
@ -143,16 +143,16 @@ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn( className={cn(
"flex", "flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className className,
)} )}
{...props} {...props}
/> />
</div> </div>
) );
} }
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel() const { orientation } = useCarousel();
return ( return (
<div <div
@ -162,11 +162,11 @@ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
className={cn( className={cn(
"min-w-0 shrink-0 grow-0 basis-full", "min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4", orientation === "horizontal" ? "pl-4" : "pt-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CarouselPrevious({ function CarouselPrevious({
@ -175,7 +175,7 @@ function CarouselPrevious({
size = "icon", size = "icon",
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel() const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return ( return (
<Button <Button
@ -187,7 +187,7 @@ function CarouselPrevious({
orientation === "horizontal" orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2" ? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className className,
)} )}
disabled={!canScrollPrev} disabled={!canScrollPrev}
onClick={scrollPrev} onClick={scrollPrev}
@ -196,7 +196,7 @@ function CarouselPrevious({
<ArrowLeft /> <ArrowLeft />
<span className="sr-only">Previous slide</span> <span className="sr-only">Previous slide</span>
</Button> </Button>
) );
} }
function CarouselNext({ function CarouselNext({
@ -205,7 +205,7 @@ function CarouselNext({
size = "icon", size = "icon",
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel() const { orientation, scrollNext, canScrollNext } = useCarousel();
return ( return (
<Button <Button
@ -217,7 +217,7 @@ function CarouselNext({
orientation === "horizontal" orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2" ? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className className,
)} )}
disabled={!canScrollNext} disabled={!canScrollNext}
onClick={scrollNext} onClick={scrollNext}
@ -226,7 +226,7 @@ function CarouselNext({
<ArrowRight /> <ArrowRight />
<span className="sr-only">Next slide</span> <span className="sr-only">Next slide</span>
</Button> </Button>
) );
} }
export { export {
@ -236,4 +236,4 @@ export {
CarouselItem, CarouselItem,
CarouselPrevious, CarouselPrevious,
CarouselNext, CarouselNext,
} };

View File

@ -1,32 +1,32 @@
import * as React from "react" import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "./button";
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({
@ -38,11 +38,11 @@ function DialogOverlay({
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@ -51,7 +51,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
@ -60,7 +60,7 @@ function DialogContent({
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className className,
)} )}
{...props} {...props}
> >
@ -76,7 +76,7 @@ function DialogContent({
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -86,7 +86,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ function DialogFooter({
@ -95,14 +95,14 @@ function DialogFooter({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
> >
@ -113,7 +113,7 @@ function DialogFooter({
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</div> </div>
) );
} }
function DialogTitle({ function DialogTitle({
@ -126,7 +126,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@ -139,7 +139,7 @@ function DialogDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -153,4 +153,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };

View File

@ -1,30 +1,30 @@
import * as React from "react" import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul" import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Drawer({ function Drawer({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) { }: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} /> return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
} }
function DrawerTrigger({ function DrawerTrigger({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) { }: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} /> return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
} }
function DrawerPortal({ function DrawerPortal({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) { }: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} /> return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
} }
function DrawerClose({ function DrawerClose({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) { }: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} /> return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
} }
function DrawerOverlay({ function DrawerOverlay({
@ -36,11 +36,11 @@ function DrawerOverlay({
data-slot="drawer-overlay" data-slot="drawer-overlay"
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function DrawerContent({ function DrawerContent({
@ -59,7 +59,7 @@ function DrawerContent({
"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=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=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", "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 className,
)} )}
{...props} {...props}
> >
@ -67,7 +67,7 @@ function DrawerContent({
{children} {children}
</DrawerPrimitive.Content> </DrawerPrimitive.Content>
</DrawerPortal> </DrawerPortal>
) );
} }
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -76,11 +76,11 @@ function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="drawer-header" data-slot="drawer-header"
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -90,7 +90,7 @@ function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function DrawerTitle({ function DrawerTitle({
@ -103,7 +103,7 @@ function DrawerTitle({
className={cn("text-foreground font-semibold", className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DrawerDescription({ function DrawerDescription({
@ -116,7 +116,7 @@ function DrawerDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -130,4 +130,4 @@ export {
DrawerFooter, DrawerFooter,
DrawerTitle, DrawerTitle,
DrawerDescription, DrawerDescription,
} };

View File

@ -1,13 +1,13 @@
import * as React from "react" import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function DropdownMenu({ function DropdownMenu({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
@ -15,7 +15,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
) );
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
@ -26,7 +26,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger" data-slot="dropdown-menu-trigger"
{...props} {...props}
/> />
) );
} }
function DropdownMenuContent({ function DropdownMenuContent({
@ -41,12 +41,12 @@ function DropdownMenuContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) );
} }
function DropdownMenuGroup({ function DropdownMenuGroup({
@ -54,7 +54,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return ( return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
) );
} }
function DropdownMenuItem({ function DropdownMenuItem({
@ -63,8 +63,8 @@ function DropdownMenuItem({
variant = "default", variant = "default",
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: "default" | "destructive" variant?: "default" | "destructive";
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@ -73,11 +73,11 @@ function DropdownMenuItem({
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@ -91,7 +91,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
@ -103,7 +103,7 @@ function DropdownMenuCheckboxItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
) );
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
@ -114,7 +114,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group" data-slot="dropdown-menu-radio-group"
{...props} {...props}
/> />
) );
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@ -127,7 +127,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
@ -138,7 +138,7 @@ function DropdownMenuRadioItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
) );
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@ -146,7 +146,7 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
@ -154,11 +154,11 @@ function DropdownMenuLabel({
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
@ -171,7 +171,7 @@ function DropdownMenuSeparator({
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({
@ -183,17 +183,17 @@ function DropdownMenuShortcut({
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest", "text-muted-foreground ml-auto text-xs tracking-widest",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSub({ function DropdownMenuSub({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
@ -202,7 +202,7 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@ -210,14 +210,14 @@ function DropdownMenuSubTrigger({
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
@ -229,11 +229,11 @@ function DropdownMenuSubContent({
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@ -252,4 +252,4 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} };

View File

@ -1,9 +1,9 @@
import { useMemo } from "react" import { useMemo } from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
import { Label } from "@/components/ui/label" import { Label } from "./label";
import { Separator } from "@/components/ui/separator" import { Separator } from "./separator";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return ( return (
@ -12,11 +12,11 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
className={cn( className={cn(
"flex flex-col gap-6", "flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldLegend({ function FieldLegend({
@ -32,11 +32,11 @@ function FieldLegend({
"mb-3 font-medium", "mb-3 font-medium",
"data-[variant=legend]:text-base", "data-[variant=legend]:text-base",
"data-[variant=label]:text-sm", "data-[variant=label]:text-sm",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
@ -44,12 +44,12 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="field-group" data-slot="field-group"
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
const fieldVariants = cva( const fieldVariants = cva(
@ -73,8 +73,8 @@ const fieldVariants = cva(
defaultVariants: { defaultVariants: {
orientation: "vertical", orientation: "vertical",
}, },
} },
) );
function Field({ function Field({
className, className,
@ -89,7 +89,7 @@ function Field({
className={cn(fieldVariants({ orientation }), className)} className={cn(fieldVariants({ orientation }), className)}
{...props} {...props}
/> />
) );
} }
function FieldContent({ className, ...props }: React.ComponentProps<"div">) { function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
@ -98,11 +98,11 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="field-content" data-slot="field-content"
className={cn( className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug", "group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldLabel({ function FieldLabel({
@ -114,13 +114,13 @@ function FieldLabel({
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50", "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-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", "has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
@ -129,11 +129,11 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50", "flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
@ -141,14 +141,14 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
<p <p
data-slot="field-description" data-slot="field-description"
className={cn( className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance", "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", "last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldSeparator({ function FieldSeparator({
@ -156,7 +156,7 @@ function FieldSeparator({
className, className,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
children?: React.ReactNode children?: React.ReactNode;
}) { }) {
return ( return (
<div <div
@ -164,7 +164,7 @@ function FieldSeparator({
data-content={!!children} data-content={!!children}
className={cn( className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className className,
)} )}
{...props} {...props}
> >
@ -178,7 +178,7 @@ function FieldSeparator({
</span> </span>
)} )}
</div> </div>
) );
} }
function FieldError({ function FieldError({
@ -187,37 +187,37 @@ function FieldError({
errors, errors,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined> errors?: Array<{ message?: string } | undefined>;
}) { }) {
const content = useMemo(() => { const content = useMemo(() => {
if (children) { if (children) {
return children return children;
} }
if (!errors?.length) { if (!errors?.length) {
return null return null;
} }
const uniqueErrors = [ const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(), ...new Map(errors.map((error) => [error?.message, error])).values(),
] ];
if (uniqueErrors?.length == 1) { if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message return uniqueErrors[0]?.message;
} }
return ( return (
<ul className="ml-4 flex list-disc flex-col gap-1"> <ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map( {uniqueErrors.map(
(error, index) => (error, index) =>
error?.message && <li key={index}>{error.message}</li> error?.message && <li key={index}>{error.message}</li>,
)} )}
</ul> </ul>
) );
}, [children, errors]) }, [children, errors]);
if (!content) { if (!content) {
return null return null;
} }
return ( return (
@ -229,7 +229,7 @@ function FieldError({
> >
{content} {content}
</div> </div>
) );
} }
export { export {
@ -243,4 +243,4 @@ export {
FieldSet, FieldSet,
FieldContent, FieldContent,
FieldTitle, FieldTitle,
} };

View File

@ -1,6 +1,5 @@
import * as React from "react" import * as React from "react";
import { cn } from "../../lib/utils";
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
@ -11,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
"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", "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]", "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", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui" import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Label({ function Label({
className, className,
@ -12,11 +12,11 @@ function Label({
data-slot="label" data-slot="label"
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };

View File

@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui" import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Separator({ function Separator({
className, className,
@ -18,11 +18,11 @@ function Separator({
orientation={orientation} orientation={orientation}
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };

View File

@ -1,31 +1,31 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui" import { Dialog as SheetPrimitive } from "radix-ui";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />;
} }
function SheetTrigger({ function SheetTrigger({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
} }
function SheetClose({ function SheetClose({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) { }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
} }
function SheetPortal({ function SheetPortal({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) { }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
} }
function SheetOverlay({ function SheetOverlay({
@ -37,11 +37,11 @@ function SheetOverlay({
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function SheetContent({ function SheetContent({
@ -51,8 +51,8 @@ function SheetContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
@ -69,7 +69,7 @@ function SheetContent({
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", "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" && 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", "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className className,
)} )}
{...props} {...props}
> >
@ -82,7 +82,7 @@ function SheetContent({
)} )}
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
) );
} }
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -92,7 +92,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)} className={cn("flex flex-col gap-1.5 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -102,7 +102,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetTitle({ function SheetTitle({
@ -115,7 +115,7 @@ function SheetTitle({
className={cn("text-foreground font-semibold", className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function SheetDescription({ function SheetDescription({
@ -128,7 +128,7 @@ function SheetDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -140,4 +140,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };

View File

@ -1,56 +1,56 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react" import { PanelLeftIcon } from "lucide-react";
import { Slot } from "radix-ui" import { Slot } from "radix-ui";
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "../../hooks/use-mobile";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "./button";
import { Input } from "@/components/ui/input" import { Input } from "./input";
import { Separator } from "@/components/ui/separator" import { Separator } from "./separator";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet" } from "./sheet";
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "./skeleton";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "./tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b" const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = { type SidebarContextProps = {
state: "expanded" | "collapsed" state: "expanded" | "collapsed";
open: boolean open: boolean;
setOpen: (open: boolean) => void setOpen: (open: boolean) => void;
openMobile: boolean openMobile: boolean;
setOpenMobile: (open: boolean) => void setOpenMobile: (open: boolean) => void;
isMobile: boolean isMobile: boolean;
toggleSidebar: () => void toggleSidebar: () => void;
} };
const SidebarContext = React.createContext<SidebarContextProps | null>(null) const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() { function useSidebar() {
const context = React.useContext(SidebarContext) const context = React.useContext(SidebarContext);
if (!context) { if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.") throw new Error("useSidebar must be used within a SidebarProvider.");
} }
return context return context;
} }
function SidebarProvider({ function SidebarProvider({
@ -62,36 +62,36 @@ function SidebarProvider({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
defaultOpen?: boolean defaultOpen?: boolean;
open?: boolean open?: boolean;
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void;
}) { }) {
const isMobile = useIsMobile() const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false) const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar. // This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component. // We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen) const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open const open = openProp ?? _open;
const setOpen = React.useCallback( const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => { (value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) { if (setOpenProp) {
setOpenProp(openState) setOpenProp(openState);
} else { } else {
_setOpen(openState) _setOpen(openState);
} }
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}, },
[setOpenProp, open] [setOpenProp, open],
) );
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => { const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]) }, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar. // Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => { React.useEffect(() => {
@ -100,18 +100,18 @@ function SidebarProvider({
event.key === SIDEBAR_KEYBOARD_SHORTCUT && event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey) (event.metaKey || event.ctrlKey)
) { ) {
event.preventDefault() event.preventDefault();
toggleSidebar() toggleSidebar();
}
} }
};
window.addEventListener("keydown", handleKeyDown) window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]) }, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed". // 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. // This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed" const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>( const contextValue = React.useMemo<SidebarContextProps>(
() => ({ () => ({
@ -123,8 +123,8 @@ function SidebarProvider({
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}), }),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
) );
return ( return (
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
@ -140,7 +140,7 @@ function SidebarProvider({
} }
className={cn( className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className className,
)} )}
{...props} {...props}
> >
@ -148,7 +148,7 @@ function SidebarProvider({
</div> </div>
</TooltipProvider> </TooltipProvider>
</SidebarContext.Provider> </SidebarContext.Provider>
) );
} }
function Sidebar({ function Sidebar({
@ -159,11 +159,11 @@ function Sidebar({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
side?: "left" | "right" side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset" variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none" collapsible?: "offcanvas" | "icon" | "none";
}) { }) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar() const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") { if (collapsible === "none") {
return ( return (
@ -171,13 +171,13 @@ function Sidebar({
data-slot="sidebar" data-slot="sidebar"
className={cn( className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
) );
} }
if (isMobile) { if (isMobile) {
@ -202,7 +202,7 @@ function Sidebar({
<div className="flex h-full w-full flex-col">{children}</div> <div className="flex h-full w-full flex-col">{children}</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) );
} }
return ( return (
@ -223,7 +223,7 @@ function Sidebar({
"group-data-[side=right]:rotate-180", "group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)" : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)} )}
/> />
<div <div
@ -237,20 +237,27 @@ function Sidebar({
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" ? "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", : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className className,
)} )}
{...props} {...props}
> >
<div <div
data-sidebar="sidebar" data-sidebar="sidebar"
data-slot="sidebar-inner" 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" className={cn(
"flex h-full w-full flex-col",
// For the custom floating pill sidebar we render our own card,
// so keep this container visually transparent.
variant === "floating"
? "bg-transparent"
: "bg-sidebar group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm",
)}
> >
{children} {children}
</div> </div>
</div> </div>
</div> </div>
) );
} }
function SidebarTrigger({ function SidebarTrigger({
@ -258,7 +265,7 @@ function SidebarTrigger({
onClick, onClick,
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<Button <Button
@ -268,19 +275,19 @@ function SidebarTrigger({
size="icon" size="icon"
className={cn("size-7", className)} className={cn("size-7", className)}
onClick={(event) => { onClick={(event) => {
onClick?.(event) onClick?.(event);
toggleSidebar() toggleSidebar();
}} }}
{...props} {...props}
> >
<PanelLeftIcon /> <PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">Toggle Sidebar</span>
</Button> </Button>
) );
} }
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<button <button
@ -291,17 +298,17 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
onClick={toggleSidebar} onClick={toggleSidebar}
title="Toggle Sidebar" title="Toggle Sidebar"
className={cn( 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", "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-0.5 sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", "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", "[[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", "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=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
@ -311,11 +318,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
className={cn( className={cn(
"bg-background relative flex w-full flex-1 flex-col", "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", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInput({ function SidebarInput({
@ -329,7 +336,7 @@ function SidebarInput({
className={cn("bg-background h-8 w-full shadow-none", className)} className={cn("bg-background h-8 w-full shadow-none", className)}
{...props} {...props}
/> />
) );
} }
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -340,7 +347,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -351,7 +358,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarSeparator({ function SidebarSeparator({
@ -365,7 +372,7 @@ function SidebarSeparator({
className={cn("bg-sidebar-border mx-2 w-auto", className)} className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props} {...props}
/> />
) );
} }
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
@ -375,11 +382,11 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
@ -390,7 +397,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
className={cn("relative flex w-full min-w-0 flex-col p-2", className)} className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarGroupLabel({ function SidebarGroupLabel({
@ -398,7 +405,7 @@ function SidebarGroupLabel({
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) { }: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div" const Comp = asChild ? Slot.Root : "div";
return ( return (
<Comp <Comp
@ -407,11 +414,11 @@ function SidebarGroupLabel({
className={cn( 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", "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", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroupAction({ function SidebarGroupAction({
@ -419,7 +426,7 @@ function SidebarGroupAction({
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) { }: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : "button";
return ( return (
<Comp <Comp
@ -430,11 +437,11 @@ function SidebarGroupAction({
// Increases the hit area of the button on mobile. // Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden", "after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroupContent({ function SidebarGroupContent({
@ -448,7 +455,7 @@ function SidebarGroupContent({
className={cn("w-full text-sm", className)} className={cn("w-full text-sm", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
@ -459,7 +466,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
className={cn("flex w-full min-w-0 flex-col gap-1", className)} className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
@ -470,7 +477,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
className={cn("group/menu-item relative", className)} className={cn("group/menu-item relative", className)}
{...props} {...props}
/> />
) );
} }
const sidebarMenuButtonVariants = cva( const sidebarMenuButtonVariants = cva(
@ -492,8 +499,8 @@ const sidebarMenuButtonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
function SidebarMenuButton({ function SidebarMenuButton({
asChild = false, asChild = false,
@ -504,12 +511,12 @@ function SidebarMenuButton({
className, className,
...props ...props
}: React.ComponentProps<"button"> & { }: React.ComponentProps<"button"> & {
asChild?: boolean asChild?: boolean;
isActive?: boolean isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent> tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) { } & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : "button";
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar();
const button = ( const button = (
<Comp <Comp
@ -520,16 +527,16 @@ function SidebarMenuButton({
className={cn(sidebarMenuButtonVariants({ variant, size }), className)} className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props} {...props}
/> />
) );
if (!tooltip) { if (!tooltip) {
return button return button;
} }
if (typeof tooltip === "string") { if (typeof tooltip === "string") {
tooltip = { tooltip = {
children: tooltip, children: tooltip,
} };
} }
return ( return (
@ -542,7 +549,7 @@ function SidebarMenuButton({
{...tooltip} {...tooltip}
/> />
</Tooltip> </Tooltip>
) );
} }
function SidebarMenuAction({ function SidebarMenuAction({
@ -551,10 +558,10 @@ function SidebarMenuAction({
showOnHover = false, showOnHover = false,
...props ...props
}: React.ComponentProps<"button"> & { }: React.ComponentProps<"button"> & {
asChild?: boolean asChild?: boolean;
showOnHover?: boolean showOnHover?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : "button";
return ( return (
<Comp <Comp
@ -570,11 +577,11 @@ function SidebarMenuAction({
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
showOnHover && 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", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuBadge({ function SidebarMenuBadge({
@ -592,11 +599,11 @@ function SidebarMenuBadge({
"peer-data-[size=default]/menu-button:top-1.5", "peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5", "peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSkeleton({ function SidebarMenuSkeleton({
@ -604,12 +611,12 @@ function SidebarMenuSkeleton({
showIcon = false, showIcon = false,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showIcon?: boolean showIcon?: boolean;
}) { }) {
// Random width between 50 to 90%. // Random width between 50 to 90%.
const width = React.useMemo(() => { const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%` return `${Math.floor(Math.random() * 40) + 50}%`;
}, []) }, []);
return ( return (
<div <div
@ -634,7 +641,7 @@ function SidebarMenuSkeleton({
} }
/> />
</div> </div>
) );
} }
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
@ -645,11 +652,11 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
className={cn( 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", "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", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubItem({ function SidebarMenuSubItem({
@ -663,7 +670,7 @@ function SidebarMenuSubItem({
className={cn("group/menu-sub-item relative", className)} className={cn("group/menu-sub-item relative", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubButton({ function SidebarMenuSubButton({
@ -673,11 +680,11 @@ function SidebarMenuSubButton({
className, className,
...props ...props
}: React.ComponentProps<"a"> & { }: React.ComponentProps<"a"> & {
asChild?: boolean asChild?: boolean;
size?: "sm" | "md" size?: "sm" | "md";
isActive?: boolean isActive?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "a" const Comp = asChild ? Slot.Root : "a";
return ( return (
<Comp <Comp
@ -691,11 +698,11 @@ function SidebarMenuSubButton({
size === "sm" && "text-xs", size === "sm" && "text-xs",
size === "md" && "text-sm", size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@ -723,4 +730,4 @@ export {
SidebarSeparator, SidebarSeparator,
SidebarTrigger, SidebarTrigger,
useSidebar, useSidebar,
} };

View File

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

View File

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({ className, ...props }: React.ComponentProps<"table">) {
return ( return (
@ -14,7 +14,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
{...props} {...props}
/> />
</div> </div>
) );
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
@ -24,7 +24,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
className={cn("[&_tr]:border-b", className)} className={cn("[&_tr]:border-b", className)}
{...props} {...props}
/> />
) );
} }
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
@ -34,7 +34,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
className={cn("[&_tr:last-child]:border-0", className)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} {...props}
/> />
) );
} }
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
@ -43,11 +43,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
data-slot="table-footer" data-slot="table-footer"
className={cn( className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@ -56,11 +56,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableHead({ className, ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@ -69,11 +69,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@ -82,11 +82,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCaption({ function TableCaption({
@ -99,7 +99,7 @@ function TableCaption({
className={cn("text-muted-foreground mt-4 text-sm", className)} className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -111,4 +111,4 @@ export {
TableRow, TableRow,
TableCell, TableCell,
TableCaption, TableCaption,
} };

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui" import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
@ -13,19 +13,19 @@ function TooltipProvider({
delayDuration={delayDuration} delayDuration={delayDuration}
{...props} {...props}
/> />
) );
} }
function Tooltip({ function Tooltip({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) { }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} /> return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
} }
function TooltipTrigger({ function TooltipTrigger({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
} }
function TooltipContent({ function TooltipContent({
@ -41,7 +41,7 @@ function TooltipContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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", "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 className,
)} )}
{...props} {...props}
> >
@ -49,7 +49,7 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> <TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 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

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