55 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
a48a50ae77 fix(ui): fix minor ui bugs 2026-02-21 17:06:55 +06:00
f054c7179b refactor(search): refactor search ui for overall style coherence 2026-02-21 16:53:36 +06:00
65dbe99647 feat(ui): improve ui for test, drills and htm screens 2026-02-21 02:04:50 +06:00
76d2108aec feat(ui): add new ui 2026-02-20 19:10:13 +06:00
3c8f945539 fix(ui): change ui theme color
feat(calc): add geogebra based graph calculator for tests
2026-02-20 00:03:23 +06:00
626616c8b5 fix(ui): fix minor ui bugs 2026-02-17 16:41:24 +06:00
b56642fda8 fix(ui): fix minor ui bugs 2026-02-15 18:37:28 +06:00
e5305a1ca2 feat(ui): add sidebar navigation for desktop 2026-02-15 17:24:11 +06:00
96eb2c13b0 feat(home): add spotlight search functionality 2026-02-14 03:24:22 +06:00
7f82e640e0 feat(results): add resutls page
fix(leaderboard): fix leaderboard fetch logic

fix(test): fix navigation bug upon test quit
2026-02-10 19:32:46 +06:00
8cfcb11f0a feat(error): add error handling on test screen 2026-02-07 20:21:47 +06:00
c9db96f97f feat(leaderboard): add leaderboard functionaltiy 2026-02-07 18:58:50 +06:00
02419678b7 feat(test): add functionality for drill, hard test module testing 2026-02-07 15:28:43 +06:00
903653a212 feat(targeted): add targeted practice functionality
feat(analytics); add analytics page
2026-02-05 15:07:24 +06:00
2ac88835f9 feat(lesson): add lesson modal 2026-02-01 18:20:03 +06:00
62238cbf8f feat(pages): add targeted practice, drills, hard test modules pages 2026-02-01 17:35:35 +06:00
60e858c931 feat(test); add strikethrough functionality for mcq options 2026-02-01 17:10:38 +06:00
238 changed files with 115482 additions and 1767 deletions

View File

@ -4,6 +4,13 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<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
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,19 +15,29 @@
"@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",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.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",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-katex": "^3.1.0", "react-katex": "^3.1.0",
"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",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
@ -35,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",

1694
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,12 @@ import { Test } from "./pages/student/practice/Test";
import { Profile } from "./pages/student/Profile"; import { Profile } from "./pages/student/Profile";
import { Rewards } from "./pages/student/Rewards"; import { Rewards } from "./pages/student/Rewards";
import { StudentLayout } from "./pages/student/StudentLayout"; import { StudentLayout } from "./pages/student/StudentLayout";
import { TargetedPractice } from "./pages/student/targeted-practice/page";
import { Drills } from "./pages/student/drills/page";
import { HardTestModules } from "./pages/student/hard-test-modules/page";
import { 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([
@ -23,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",
@ -50,13 +61,32 @@ function App() {
path: "profile", path: "profile",
element: <Profile />, element: <Profile />,
}, },
{
path: "quests",
element: <QuestMap />,
},
{ {
path: "practice/:sheetId", path: "practice/:sheetId",
element: <Pretest />, element: <Pretest />,
}, },
{
path: "practice/targeted-practice",
element: <TargetedPractice />,
},
{
path: "practice/drills",
element: <Drills />,
},
{
path: "practice/hard-test-modules",
element: <HardTestModules />,
},
{
path: "practice/practice-sheet",
element: <PracticeSheetList />,
},
], ],
}, },
{ {
path: "practice/:sheetId/test", path: "practice/:sheetId/test",
element: <Test />, element: <Test />,

View File

@ -0,0 +1,593 @@
import {
SidebarContent,
SidebarHeader,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuSub,
} from "../components/ui/sidebar";
import {
ChevronDown,
BookOpen,
Home,
Target,
Zap,
Trophy,
Map,
SquareLibrary,
ListIcon,
} from "lucide-react";
import { useState } from "react";
import logo from "../assets/ed_logo1.png";
import { NavLink, useNavigate, useLocation } from "react-router-dom";
import { useAuthStore } from "../stores/authStore";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
export function AppSidebar() {
const [open, setOpen] = useState(false);
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 (
<>
<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 */}
<SidebarHeader className="px-3 pb-4 pt-1">
<div className="flex items-center justify-start gap-2">
<div className="flex items-center gap-3 rounded-2xl px-2 py-2">
<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
src={logo}
className="h-full w-full object-cover object-left"
alt="Logo"
/>
</div>
<div className="flex flex-col text-sm">
<span className="font-satoshi-medium text-slate-900">
Edbridge Scholars
</span>
<span className="font-satoshi text-xs text-slate-400">
Student
</span>
</div>
</div>
</div>
</SidebarHeader>
{/* CONTENT */}
<SidebarContent className="px-1">
<SidebarGroup>
<SidebarGroupLabel className="px-2 text-[0.7rem] font-satoshi tracking-[0.16em] text-slate-400">
PLATFORM
</SidebarGroupLabel>
<SidebarMenu className="mt-1 space-y-1.5">
{/* HOME */}
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
>
<NavLink
to="/student/home"
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 }) => (
<>
<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>
</SidebarMenuButton>
</SidebarMenuItem>
{/* PRACTICE */}
<SidebarMenuItem
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<SidebarMenuButton
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
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
size={16}
strokeWidth={3}
className={`ml-auto text-slate-400 transition-transform ${
open ? "rotate-180" : ""
}`}
/>
</>
)}
</NavLink>
</SidebarMenuButton>
{open && (
<SidebarMenuSub className="mt-2 space-y-1.5 pl-3">
<NavLink
to="/student/practice/targeted-practice"
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}
strokeWidth={3}
className="text-slate-400"
/>
<span>Targeted Practice</span>
</NavLink>
<NavLink
to="/student/practice/drills"
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}
strokeWidth={3}
className="text-slate-400"
/>
<span>Drills</span>
</NavLink>
<NavLink
to="/student/practice/hard-test-modules"
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}
strokeWidth={3}
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>
</SidebarMenuSub>
)}
</SidebarMenuItem>
{/* QUESTS */}
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
>
<NavLink
to="/student/quests"
className={({ isActive }) => {
if (isActive && isQuestPage) {
return "flex items-center gap-2.5 text-sm rounded-2xl px-2 py-2.5 transition-all duration-200";
}
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)",
}
: {}
}
>
Quests
</span>
</>
)}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
{/* LESSONS */}
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
>
<NavLink
to="/student/lessons"
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 }) => (
<>
<SquareLibrary
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>
</SidebarMenuButton>
</SidebarMenuItem>
{/* REWARDS */}
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
>
<NavLink
to="/student/rewards"
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 }) => (
<>
<Trophy
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>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
{/* FOOTER links to profile */}
<SidebarFooter className="mt-auto px-3 pb-3 pt-4">
<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>
<AvatarImage src={user?.avatar_url} />
<AvatarFallback className="bg-linear-to-br from-purple-400 to-purple-500 font-satoshi-bold uppercase text-white">
{user?.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col text-sm">
<span className="font-medium text-slate-900">{user?.name}</span>
<span className="text-xs text-slate-400">{user?.email}</span>
</div>
<ChevronDown
size={16}
strokeWidth={3}
className="ml-auto text-slate-400"
/>
</button>
</SidebarFooter>
</div>
</div>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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

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

View File

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

View File

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

View File

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,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

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

View File

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

View File

@ -0,0 +1,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

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

View File

@ -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

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

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,
} };

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

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

View File

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

View File

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

View File

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

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