Compare commits
40 Commits
feat/ui
...
35cc0f9a47
| Author | SHA1 | Date | |
|---|---|---|---|
| 35cc0f9a47 | |||
| 8d86da05b5 | |||
| d80111a9b7 | |||
| c8f2259154 | |||
| b3cf462228 | |||
| a429b1c0b1 | |||
| 21dbe336ca | |||
| bd35f6a852 | |||
| 121cc2bf71 | |||
| f00aad2bbd | |||
| 575d392afc | |||
| c09ecd7926 | |||
| a1295a0eb3 | |||
| 59e601052f | |||
| b5edb3554f | |||
| 8dbadae58c | |||
| 980eb130e2 | |||
| bd3974e2f0 | |||
| a08476ec53 | |||
| 437c7a517f | |||
| c35f328e30 | |||
| e75233929a | |||
| 79fc2eacdc | |||
| 9074b17a83 | |||
| f154ebf033 | |||
| 634c67b741 | |||
| 2a00c44157 | |||
| 2eaf77e13c | |||
| 4df5707ebd | |||
| e7db0a5d31 | |||
| 7dfa73c397 | |||
| c7f0183956 | |||
| f64d2cac4a | |||
| 894863c196 | |||
| d56ea14a22 | |||
| be63ca5ed2 | |||
| a48a50ae77 | |||
| f054c7179b | |||
| 65dbe99647 | |||
| 76d2108aec |
@ -5,6 +5,12 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="https://www.geogebra.org/apps/deployggb.js"></script>
|
||||
|
||||
<script
|
||||
defer
|
||||
src="https://alt.omukk.dev/script.js"
|
||||
data-website-id="e4aa7582-260a-4861-b363-eb1815d8b232"
|
||||
></script>
|
||||
<title>Edbridge Scholars</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
8044
package-lock.json
generated
Normal file
8044
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,8 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
@ -23,6 +25,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.30.0",
|
||||
"katex": "^0.16.28",
|
||||
"leva": "^0.10.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
@ -31,6 +34,8 @@
|
||||
"react-router-dom": "^7.12.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"three": "^0.183.2",
|
||||
"vaul": "^1.1.2",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -38,6 +43,7 @@
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.183.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
|
||||
712
pnpm-lock.yaml
generated
712
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
12
src/App.tsx
12
src/App.tsx
@ -19,7 +19,8 @@ import { StudentLayout } from "./pages/student/StudentLayout";
|
||||
import { TargetedPractice } from "./pages/student/targeted-practice/page";
|
||||
import { Drills } from "./pages/student/drills/page";
|
||||
import { HardTestModules } from "./pages/student/hard-test-modules/page";
|
||||
import { Analytics } from "./pages/student/Analytics";
|
||||
import { QuestMap } from "./pages/student/QuestMap";
|
||||
import { Register } from "./pages/auth/Register";
|
||||
|
||||
function App() {
|
||||
const router = createBrowserRouter([
|
||||
@ -27,12 +28,17 @@ function App() {
|
||||
path: "/login",
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
path: "/register",
|
||||
element: <Register />,
|
||||
},
|
||||
{
|
||||
path: "/student",
|
||||
element: <ProtectedRoute />,
|
||||
children: [
|
||||
{
|
||||
element: <StudentLayout />,
|
||||
|
||||
children: [
|
||||
{
|
||||
path: "home",
|
||||
@ -55,8 +61,8 @@ function App() {
|
||||
element: <Profile />,
|
||||
},
|
||||
{
|
||||
path: "analytics",
|
||||
element: <Analytics />,
|
||||
path: "quests",
|
||||
element: <QuestMap />,
|
||||
},
|
||||
{
|
||||
path: "practice/:sheetId",
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarHeader,
|
||||
SidebarFooter,
|
||||
@ -15,202 +14,561 @@ import {
|
||||
ChevronDown,
|
||||
BookOpen,
|
||||
Home,
|
||||
Video,
|
||||
User,
|
||||
Target,
|
||||
Zap,
|
||||
Trophy,
|
||||
LayoutGrid,
|
||||
Map,
|
||||
SquareLibrary,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useState } from "react";
|
||||
import logo from "../assets/ed_logo1.png";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { NavLink, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||
|
||||
export function AppSidebar() {
|
||||
const [open, setOpen] = useState(true);
|
||||
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 (
|
||||
<Sidebar className="border-r bg-black text-white">
|
||||
<>
|
||||
<style>{STYLES}</style>
|
||||
<div
|
||||
className={`as-sidebar-container${isQuestPage ? " quest-mode" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`as-gradient-overlay ${isQuestPage ? "quest" : "default"}`}
|
||||
/>
|
||||
|
||||
<div className="as-sidebar-inner">
|
||||
{/* HEADER */}
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center justify-between px-2 py-2 rounded-lg hover:bg-white/10 cursor-pointer">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex rounded-md w-10 h-10 border overflow-hidden">
|
||||
<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="w-full h-full object-cover object-left"
|
||||
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-black">
|
||||
<span className="font-satoshi-medium text-slate-900">
|
||||
Edbridge Scholars
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 font-satoshi">
|
||||
<span className="font-satoshi text-xs text-slate-400">
|
||||
Student
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown size={16} />
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
{/* CONTENT */}
|
||||
<SidebarContent>
|
||||
<SidebarContent className="px-1">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-gray-400 font-satoshi">
|
||||
Platform
|
||||
<SidebarGroupLabel className="px-2 text-[0.7rem] font-satoshi tracking-[0.16em] text-slate-400">
|
||||
PLATFORM
|
||||
</SidebarGroupLabel>
|
||||
|
||||
<SidebarMenu>
|
||||
<SidebarMenu className="mt-1 space-y-1.5">
|
||||
{/* HOME */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<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
|
||||
? "bg-zinc-800 text-white"
|
||||
: "text-zinc-400 hover:bg-zinc-800"
|
||||
? "text-slate-900"
|
||||
: "text-slate-500 group-hover:text-slate-900"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Home size={18} className="text-black" />
|
||||
<span className="font-satoshi text-black">Home</span>
|
||||
{({ 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>
|
||||
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
className="cursor-pointer"
|
||||
asChild
|
||||
onClick={() => setOpen(!open)}
|
||||
{/* PRACTICE */}
|
||||
<SidebarMenuItem
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<div>
|
||||
<BookOpen size={18} className="text-black" />
|
||||
<span className="font-satoshi text-black">Practice</span>
|
||||
|
||||
<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}
|
||||
className={`ml-auto transition-transform ${
|
||||
strokeWidth={3}
|
||||
className={`ml-auto text-slate-400 transition-transform ${
|
||||
open ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
{open && (
|
||||
<SidebarMenuSub className="space-y-3 mt-2">
|
||||
<NavLink
|
||||
to="/student/practice"
|
||||
className="text-black text-sm flex items-center gap-3"
|
||||
>
|
||||
<LayoutGrid size={18} className="text-black" />
|
||||
<span className="font-satoshi text-black">
|
||||
Practice your way
|
||||
</span>
|
||||
</NavLink>
|
||||
<SidebarMenuSub className="mt-2 space-y-1.5 pl-3">
|
||||
<NavLink
|
||||
to="/student/practice/targeted-practice"
|
||||
className="text-black text-sm flex items-center gap-3"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
|
||||
isActive
|
||||
? "bg-white text-slate-900"
|
||||
: "text-slate-500 hover:bg-white hover:text-slate-900"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Target size={18} className="text-black" />
|
||||
<span className="font-satoshi text-black">
|
||||
Targeted Practice
|
||||
</span>
|
||||
<Target
|
||||
size={18}
|
||||
strokeWidth={3}
|
||||
className="text-slate-400"
|
||||
/>
|
||||
<span>Targeted Practice</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/student/practice/drills"
|
||||
className="text-black text-sm flex items-center gap-3"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
|
||||
isActive
|
||||
? "bg-white text-slate-900"
|
||||
: "text-slate-500 hover:bg-white hover:text-slate-900"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Zap size={18} className="text-black" />
|
||||
<span className="font-satoshi text-black">Drills</span>
|
||||
<Zap
|
||||
size={18}
|
||||
strokeWidth={3}
|
||||
className="text-slate-400"
|
||||
/>
|
||||
<span>Drills</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/student/practice/hard-test-modules"
|
||||
className="text-black text-sm flex items-center gap-3"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
|
||||
isActive
|
||||
? "bg-white text-slate-900"
|
||||
: "text-slate-500 hover:bg-white hover:text-slate-900"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Trophy size={18} className="text-black" />
|
||||
<span className="font-satoshi text-black">
|
||||
Hard Test Modules
|
||||
</span>
|
||||
<Trophy
|
||||
size={18}
|
||||
strokeWidth={3}
|
||||
className="text-slate-400"
|
||||
/>
|
||||
<span>Hard Test Modules</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSub>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
|
||||
{/* DOCS */}
|
||||
{/* QUESTS */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
|
||||
>
|
||||
<NavLink
|
||||
to={`/student/lessons`}
|
||||
className={({ isActive }) =>
|
||||
isActive
|
||||
? "bg-zinc-800 text-white"
|
||||
: "text-zinc-400 hover:bg-zinc-800"
|
||||
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)",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<SidebarMenuButton className="cursor-pointer">
|
||||
<Video size={18} className="text-black" />
|
||||
<span className="text-black font-satoshi">Lessons</span>
|
||||
</SidebarMenuButton>
|
||||
Quests
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
{/* SETTINGS */}
|
||||
{/* LESSONS */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
|
||||
>
|
||||
<NavLink
|
||||
to={`/student/rewards`}
|
||||
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
|
||||
? "bg-zinc-800 text-white"
|
||||
: "text-zinc-400 hover:bg-zinc-800"
|
||||
? "text-slate-900"
|
||||
: "text-slate-500 group-hover:text-slate-900"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<SidebarMenuButton className="cursor-pointer">
|
||||
<Trophy size={18} className="text-black" />
|
||||
<span className="text-black font-satoshi">Rewards</span>
|
||||
</SidebarMenuButton>
|
||||
{({ 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/profile`}
|
||||
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
|
||||
? "bg-zinc-800 text-white"
|
||||
: "text-zinc-400 hover:bg-zinc-800"
|
||||
? "text-slate-900"
|
||||
: "text-slate-500 group-hover:text-slate-900"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<SidebarMenuButton className="cursor-pointer">
|
||||
<User size={18} className="text-black" />
|
||||
<span className="text-black font-satoshi">Profile</span>
|
||||
</SidebarMenuButton>
|
||||
{({ 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 */}
|
||||
<SidebarFooter>
|
||||
<div className="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-white/10 cursor-pointer">
|
||||
{/* 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="font-satoshi-bold bg-linear-to-br from-purple-400 to-purple-500 uppercase">
|
||||
<AvatarFallback className="bg-linear-to-br from-purple-400 to-purple-500 font-satoshi-bold uppercase text-white">
|
||||
{user?.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="font-medium text-black">{user?.name}</span>
|
||||
<span className="text-xs text-gray-400">{user?.email}</span>
|
||||
</div>
|
||||
<ChevronDown size={16} className="ml-auto" />
|
||||
<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>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, Calculator, Maximize2, Minimize2 } from "lucide-react";
|
||||
|
||||
|
||||
750
src/components/ChestOpenModal.tsx
Normal file
750
src/components/ChestOpenModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -1,4 +1,126 @@
|
||||
import { Badge } from "./ui/badge";
|
||||
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,
|
||||
@ -12,23 +134,51 @@ export const ChoiceCard = ({
|
||||
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={`rounded-2xl border p-4 text-left transition flex flex-col
|
||||
${selected ? "border-indigo-600 bg-indigo-50" : "hover:border-gray-300"}`}
|
||||
className={`cc-btn${selected ? " cc-selected" : ""}`}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-satoshi-bold text-lg">{label}</span>
|
||||
{section && (
|
||||
<Badge
|
||||
variant={"secondary"}
|
||||
className={`font-satoshi text-sm ${section === "EBRW" ? "bg-blue-400 text-blue-100" : "bg-red-400 text-red-100"}`}
|
||||
>
|
||||
{section}
|
||||
</Badge>
|
||||
{/* 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>
|
||||
{subLabel && <span className="font-satoshi text-md">{subLabel}</span>}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -11,26 +11,187 @@ type Props = {
|
||||
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 = ({
|
||||
size = 300,
|
||||
strokeWidth = 16,
|
||||
previousXP,
|
||||
gainedXP,
|
||||
levelMinXP,
|
||||
levelMaxXP,
|
||||
level,
|
||||
}: Props) => {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const levelRange = levelMaxXP - levelMinXP;
|
||||
|
||||
const normalize = (xp: number) =>
|
||||
Math.min(Math.max(xp - levelMinXP, 0), levelRange) / levelRange;
|
||||
|
||||
const [progress, setProgress] = useState(normalize(previousXP));
|
||||
const [barProgress, setBarProgress] = useState(normalize(previousXP));
|
||||
const [currentLevel, setCurrentLevel] = useState(level);
|
||||
const [showLevelUp, setShowLevelUp] = useState(false);
|
||||
const [showThresholdText, setShowThresholdText] = useState(false);
|
||||
const [showXPTotal, setShowXPTotal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let animationFrame: number;
|
||||
@ -38,28 +199,23 @@ export const CircularLevelProgress = ({
|
||||
|
||||
const availableXP = previousXP + gainedXP;
|
||||
const crossesLevel = availableXP >= levelMaxXP;
|
||||
|
||||
const phase1Target = crossesLevel ? 1 : normalize(previousXP + gainedXP);
|
||||
|
||||
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);
|
||||
|
||||
setProgress(
|
||||
setBarProgress(
|
||||
normalize(previousXP) + t * (phase1Target - normalize(previousXP)),
|
||||
);
|
||||
|
||||
if (t < 1) {
|
||||
animationFrame = requestAnimationFrame(animatePhase1);
|
||||
} else if (crossesLevel) {
|
||||
setShowLevelUp(true);
|
||||
setTimeout(startPhase2, 1200);
|
||||
} else {
|
||||
setShowThresholdText(true);
|
||||
setShowXPTotal(true);
|
||||
}
|
||||
};
|
||||
|
||||
@ -67,78 +223,77 @@ export const CircularLevelProgress = ({
|
||||
start = null;
|
||||
setShowLevelUp(false);
|
||||
setCurrentLevel((l) => l + 1);
|
||||
setProgress(0);
|
||||
|
||||
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);
|
||||
|
||||
setProgress(t * target);
|
||||
|
||||
setBarProgress(t * target);
|
||||
if (t < 1) {
|
||||
animationFrame = requestAnimationFrame(animatePhase2);
|
||||
} else {
|
||||
setShowThresholdText(true);
|
||||
setShowXPTotal(true);
|
||||
}
|
||||
};
|
||||
|
||||
animationFrame = requestAnimationFrame(animatePhase2);
|
||||
};
|
||||
|
||||
animationFrame = requestAnimationFrame(animatePhase1);
|
||||
|
||||
return () => cancelAnimationFrame(animationFrame);
|
||||
}, []);
|
||||
|
||||
const offset = circumference * (1 - progress);
|
||||
const barPct = Math.round(barProgress * 100);
|
||||
const totalXP = previousXP + gainedXP;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center gap-2">
|
||||
<div className="clp-wrap">
|
||||
<style>{STYLES}</style>
|
||||
{showLevelUp && <ConfettiBurst />}
|
||||
<div
|
||||
className="relative flex items-center justify-center"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg width={size} height={size}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="oklch(94.6% 0.033 307.174)"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="oklch(62.7% 0.265 303.9)"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span className="absolute text-[100px] font-satoshi-bold flex flex-col items-center">
|
||||
{currentLevel}
|
||||
<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>
|
||||
|
||||
{showThresholdText && (
|
||||
<span className="text-xl font-satoshi-medium text-gray-500 animate-fade-in">
|
||||
Total XP: {previousXP + gainedXP}
|
||||
</span>
|
||||
)}
|
||||
<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 && (
|
||||
<span className="text-xl font-satoshi-medium text-purple-600 animate-fade-in">
|
||||
🎉 You leveled up!
|
||||
</span>
|
||||
<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>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
178
src/components/FetchLessonPage.tsx
Normal file
178
src/components/FetchLessonPage.tsx
Normal 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,
|
||||
};
|
||||
@ -1,93 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
GGBApplet: any;
|
||||
}
|
||||
}
|
||||
|
||||
interface GraphProps {
|
||||
width?: string;
|
||||
height?: string;
|
||||
commands?: string[];
|
||||
defaultZoom?: number;
|
||||
}
|
||||
|
||||
export function Graph({
|
||||
width = "w-full",
|
||||
height = "h-30",
|
||||
commands = [],
|
||||
defaultZoom = 1,
|
||||
}: GraphProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const appRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(window as any).GGBApplet) {
|
||||
console.error("GeoGebra library not loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
const applet = new window.GGBApplet(
|
||||
{
|
||||
appName: "graphing",
|
||||
width: 480,
|
||||
height: 320,
|
||||
scale: 1.4,
|
||||
|
||||
showToolBar: false,
|
||||
showAlgebraInput: false,
|
||||
showMenuBar: false,
|
||||
showResetIcon: false,
|
||||
|
||||
enableRightClick: false,
|
||||
enableLabelDrags: false,
|
||||
enableShiftDragZoom: true,
|
||||
showZoomButtons: true,
|
||||
|
||||
appletOnLoad(api: any) {
|
||||
appRef.current = api;
|
||||
|
||||
api.setPerspective("G");
|
||||
api.setMode(0);
|
||||
api.setAxesVisible(true, true);
|
||||
api.setGridVisible(true);
|
||||
|
||||
api.setCoordSystem(-5, 5, -5, 5);
|
||||
|
||||
commands.forEach((command, i) => {
|
||||
const name = `f${i}`;
|
||||
api.evalCommand(`${name}: ${command}`);
|
||||
api.setFixed(name, true);
|
||||
});
|
||||
|
||||
// Inside appletOnLoad:
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
applet.inject("ggb-container");
|
||||
}, [commands, defaultZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
const resize = () => {
|
||||
if (!containerRef.current || !appRef.current) return;
|
||||
appRef.current.setSize(
|
||||
containerRef.current.offsetWidth,
|
||||
containerRef.current.offsetHeight,
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", resize);
|
||||
resize(); // initial resize
|
||||
|
||||
return () => window.removeEventListener("resize", resize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-[480] w-[320]">
|
||||
<div id="ggb-container" className="w-full h-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,634 +0,0 @@
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from "react";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Equation {
|
||||
/** A JS math expression in terms of x. e.g. "Math.sin(x)", "x**2 - 3" */
|
||||
fn: string;
|
||||
/** Hex or CSS color string */
|
||||
color?: string;
|
||||
/** Display label e.g. "y = x²" */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface GraphPlotterProps {
|
||||
equations: Equation[];
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
}
|
||||
|
||||
interface Intersection {
|
||||
x: number;
|
||||
y: number;
|
||||
eqA: number;
|
||||
eqB: number;
|
||||
}
|
||||
|
||||
interface TooltipState {
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
mathX: number;
|
||||
mathY: number;
|
||||
eqA: number;
|
||||
eqB: number;
|
||||
}
|
||||
|
||||
// ─── Palette ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
"#e05263", // crimson-rose
|
||||
"#3b82f6", // blue
|
||||
"#10b981", // emerald
|
||||
"#f59e0b", // amber
|
||||
"#a855f7", // violet
|
||||
"#06b6d4", // cyan
|
||||
"#f97316", // orange
|
||||
];
|
||||
|
||||
// ─── Safe function evaluator ──────────────────────────────────────────────────
|
||||
|
||||
const buildFn = (expr: string): ((x: number) => number) => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
return new Function(
|
||||
"x",
|
||||
`"use strict"; try { return ${expr}; } catch(e) { return NaN; }`,
|
||||
) as (x: number) => number;
|
||||
} catch {
|
||||
return () => NaN;
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Intersection finder (bisection on sign changes) ─────────────────────────
|
||||
|
||||
const findIntersections = (
|
||||
fns: Array<(x: number) => number>,
|
||||
xMin: number,
|
||||
xMax: number,
|
||||
steps = 800,
|
||||
): Intersection[] => {
|
||||
const results: Intersection[] = [];
|
||||
const dx = (xMax - xMin) / steps;
|
||||
|
||||
for (let a = 0; a < fns.length; a++) {
|
||||
for (let b = a + 1; b < fns.length; b++) {
|
||||
const diff = (x: number) => fns[a](x) - fns[b](x);
|
||||
let prev = diff(xMin);
|
||||
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const x1 = xMin + i * dx;
|
||||
const cur = diff(x1);
|
||||
|
||||
if (isFinite(prev) && isFinite(cur) && prev * cur < 0) {
|
||||
// Bisect
|
||||
let lo = x1 - dx,
|
||||
hi = x1;
|
||||
for (let k = 0; k < 42; k++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
const m = diff(mid);
|
||||
if (Math.abs(m) < 1e-10) {
|
||||
lo = hi = mid;
|
||||
break;
|
||||
}
|
||||
if (m * diff(lo) < 0) hi = mid;
|
||||
else lo = mid;
|
||||
}
|
||||
const rx = (lo + hi) / 2;
|
||||
const ry = fns[a](rx);
|
||||
if (isFinite(rx) && isFinite(ry)) {
|
||||
// Dedupe
|
||||
const dupe = results.some(
|
||||
(p) => p.eqA === a && p.eqB === b && Math.abs(p.x - rx) < 1e-4,
|
||||
);
|
||||
if (!dupe) results.push({ x: rx, y: ry, eqA: a, eqB: b });
|
||||
}
|
||||
}
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export const GraphPlotter = ({
|
||||
equations,
|
||||
width = "100%",
|
||||
height = 480,
|
||||
}: GraphPlotterProps) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Viewport state: origin in math coords + pixels-per-unit
|
||||
const [viewport, setViewport] = useState({ cx: 0, cy: 0, scale: 60 });
|
||||
const [canvasSize, setCanvasSize] = useState({ w: 600, h: 480 });
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const [activeIntersections, setActiveIntersections] = useState<
|
||||
Intersection[]
|
||||
>([]);
|
||||
|
||||
// Pan state
|
||||
const isPanning = useRef(false);
|
||||
const lastPointer = useRef({ x: 0, y: 0 });
|
||||
const lastPinchDist = useRef<number | null>(null);
|
||||
|
||||
// Build compiled functions
|
||||
const compiledFns = useMemo(
|
||||
() => equations.map((eq) => buildFn(eq.fn)),
|
||||
[equations],
|
||||
);
|
||||
|
||||
// Math → screen
|
||||
const toScreen = useCallback(
|
||||
(mx: number, my: number, vp = viewport, cs = canvasSize) => ({
|
||||
sx: cs.w / 2 + (mx - vp.cx) * vp.scale,
|
||||
sy: cs.h / 2 - (my - vp.cy) * vp.scale,
|
||||
}),
|
||||
[viewport, canvasSize],
|
||||
);
|
||||
|
||||
// Screen → math
|
||||
const toMath = useCallback(
|
||||
(sx: number, sy: number, vp = viewport, cs = canvasSize) => ({
|
||||
mx: vp.cx + (sx - cs.w / 2) / vp.scale,
|
||||
my: vp.cy - (sy - cs.h / 2) / vp.scale,
|
||||
}),
|
||||
[viewport, canvasSize],
|
||||
);
|
||||
|
||||
// Resize observer
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
const { width: w, height: h } = e.contentRect;
|
||||
setCanvasSize({ w: Math.floor(w), h: Math.floor(h) });
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Compute intersections when fns or viewport changes
|
||||
useEffect(() => {
|
||||
if (compiledFns.length < 2) {
|
||||
setActiveIntersections([]);
|
||||
return;
|
||||
}
|
||||
const xMin = viewport.cx - canvasSize.w / (2 * viewport.scale);
|
||||
const xMax = viewport.cx + canvasSize.w / (2 * viewport.scale);
|
||||
const its = findIntersections(compiledFns, xMin, xMax);
|
||||
setActiveIntersections(its);
|
||||
}, [compiledFns, viewport, canvasSize]);
|
||||
|
||||
// ── Draw ──────────────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const { w, h } = canvasSize;
|
||||
canvas.width = w * devicePixelRatio;
|
||||
canvas.height = h * devicePixelRatio;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
|
||||
const vp = viewport;
|
||||
const { sx: ox, sy: oy } = toScreen(0, 0, vp, canvasSize);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = "#fafaf9";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Grid
|
||||
const drawGrid = (
|
||||
unit: number,
|
||||
alpha: number,
|
||||
lineWidth: number,
|
||||
textSize?: number,
|
||||
) => {
|
||||
ctx.strokeStyle = `rgba(180,180,175,${alpha})`;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.beginPath();
|
||||
|
||||
const xStart = Math.floor((0 - ox) / (unit * vp.scale)) - 1;
|
||||
const xEnd = Math.ceil((w - ox) / (unit * vp.scale)) + 1;
|
||||
for (let i = xStart; i <= xEnd; i++) {
|
||||
const sx = ox + i * unit * vp.scale;
|
||||
ctx.moveTo(sx, 0);
|
||||
ctx.lineTo(sx, h);
|
||||
}
|
||||
|
||||
const yStart = Math.floor((oy - h) / (unit * vp.scale)) - 1;
|
||||
const yEnd = Math.ceil(oy / (unit * vp.scale)) + 1;
|
||||
for (let j = yStart; j <= yEnd; j++) {
|
||||
const sy = oy - j * unit * vp.scale;
|
||||
ctx.moveTo(0, sy);
|
||||
ctx.lineTo(w, sy);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Labels
|
||||
if (textSize) {
|
||||
ctx.fillStyle = "#a8a29e";
|
||||
ctx.font = `${textSize}px ui-monospace, monospace`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
for (let i = xStart; i <= xEnd; i++) {
|
||||
if (i === 0) continue;
|
||||
const sx = ox + i * unit * vp.scale;
|
||||
const val = i * unit;
|
||||
const label = Number.isInteger(val) ? val.toString() : val.toFixed(1);
|
||||
ctx.fillText(label, sx, oy + 4);
|
||||
}
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
for (let j = yStart; j <= yEnd; j++) {
|
||||
if (j === 0) continue;
|
||||
const sy = oy - j * unit * vp.scale;
|
||||
const val = j * unit;
|
||||
const label = Number.isInteger(val) ? val.toString() : val.toFixed(1);
|
||||
ctx.fillText(label, ox - 6, sy);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Adaptive grid unit
|
||||
const rawUnit = 1;
|
||||
const targetPixels = 50;
|
||||
const exp = Math.floor(Math.log10(targetPixels / vp.scale));
|
||||
const unit = rawUnit * Math.pow(10, exp);
|
||||
const subUnit = unit / 5;
|
||||
|
||||
drawGrid(subUnit, 0.35, 0.5);
|
||||
drawGrid(unit, 0.7, 0.8, 10);
|
||||
|
||||
// Axes
|
||||
ctx.strokeStyle = "#57534e";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, oy);
|
||||
ctx.lineTo(w, oy);
|
||||
ctx.moveTo(ox, 0);
|
||||
ctx.lineTo(ox, h);
|
||||
ctx.stroke();
|
||||
|
||||
// Arrow heads
|
||||
const arrow = (x: number, y: number, dir: "r" | "u") => {
|
||||
ctx.fillStyle = "#57534e";
|
||||
ctx.beginPath();
|
||||
if (dir === "r") {
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x - 8, y - 4);
|
||||
ctx.lineTo(x - 8, y + 4);
|
||||
} else {
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x - 4, y + 8);
|
||||
ctx.lineTo(x + 4, y + 8);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
};
|
||||
arrow(w, oy, "r");
|
||||
arrow(ox, 0, "u");
|
||||
|
||||
// Origin label
|
||||
ctx.fillStyle = "#a8a29e";
|
||||
ctx.font = "10px ui-monospace, monospace";
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText("0", ox - 5, oy + 4);
|
||||
|
||||
// ── Plot each equation ────────────────────────────────────────────────────
|
||||
|
||||
const xMin = vp.cx - w / (2 * vp.scale);
|
||||
const xMax = vp.cx + w / (2 * vp.scale);
|
||||
const steps = w * 2;
|
||||
const dx = (xMax - xMin) / steps;
|
||||
|
||||
equations.forEach((eq, idx) => {
|
||||
const fn = compiledFns[idx];
|
||||
const color = eq.color ?? DEFAULT_COLORS[idx % DEFAULT_COLORS.length];
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
|
||||
let penDown = false;
|
||||
let prevY = NaN;
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const mx = xMin + i * dx;
|
||||
const my = fn(mx);
|
||||
|
||||
if (!isFinite(my)) {
|
||||
penDown = false;
|
||||
prevY = NaN;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Break line on discontinuities (asymptotes)
|
||||
if (Math.abs(my - prevY) > (h / vp.scale) * 2) {
|
||||
penDown = false;
|
||||
}
|
||||
|
||||
const { sx, sy } = toScreen(mx, my, vp, canvasSize);
|
||||
if (!penDown) {
|
||||
ctx.moveTo(sx, sy);
|
||||
penDown = true;
|
||||
} else {
|
||||
ctx.lineTo(sx, sy);
|
||||
}
|
||||
prevY = my;
|
||||
}
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
// ── Intersection dots ─────────────────────────────────────────────────────
|
||||
|
||||
activeIntersections.forEach((pt) => {
|
||||
const { sx, sy } = toScreen(pt.x, pt.y, vp, canvasSize);
|
||||
if (sx < 0 || sx > w || sy < 0 || sy > h) return;
|
||||
|
||||
// Outer glow ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, 9, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "rgba(255,255,255,0.8)";
|
||||
ctx.fill();
|
||||
|
||||
// Dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, 5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#1c1917";
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#fafaf9";
|
||||
ctx.fill();
|
||||
});
|
||||
}, [
|
||||
viewport,
|
||||
canvasSize,
|
||||
equations,
|
||||
compiledFns,
|
||||
activeIntersections,
|
||||
toScreen,
|
||||
]);
|
||||
|
||||
// ── Event handlers ────────────────────────────────────────────────────────
|
||||
|
||||
const zoom = useCallback(
|
||||
(factor: number, pivotSx: number, pivotSy: number) => {
|
||||
setViewport((vp) => {
|
||||
const { mx, my } = toMath(pivotSx, pivotSy, vp, canvasSize);
|
||||
const newScale = Math.max(5, Math.min(2000, vp.scale * factor));
|
||||
return {
|
||||
scale: newScale,
|
||||
cx: mx - (pivotSx - canvasSize.w / 2) / newScale,
|
||||
cy: my + (pivotSy - canvasSize.h / 2) / newScale,
|
||||
};
|
||||
});
|
||||
},
|
||||
[toMath, canvasSize],
|
||||
);
|
||||
|
||||
const onWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = canvasRef.current!.getBoundingClientRect();
|
||||
const sx = e.clientX - rect.left;
|
||||
const sy = e.clientY - rect.top;
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
zoom(factor, sx, sy);
|
||||
},
|
||||
[zoom],
|
||||
);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
isPanning.current = true;
|
||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, []);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!isPanning.current) return;
|
||||
const dx = e.clientX - lastPointer.current.x;
|
||||
const dy = e.clientY - lastPointer.current.y;
|
||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
||||
setViewport((vp) => ({
|
||||
...vp,
|
||||
cx: vp.cx - dx / vp.scale,
|
||||
cy: vp.cy + dy / vp.scale,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
isPanning.current = false;
|
||||
}, []);
|
||||
|
||||
// Touch pinch-to-zoom
|
||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
lastPinchDist.current = Math.hypot(dx, dy);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2 && lastPinchDist.current !== null) {
|
||||
e.preventDefault();
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
const factor = dist / lastPinchDist.current;
|
||||
lastPinchDist.current = dist;
|
||||
|
||||
const rect = canvasRef.current!.getBoundingClientRect();
|
||||
const pivotX =
|
||||
(e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
|
||||
const pivotY =
|
||||
(e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
||||
zoom(factor, pivotX, pivotY);
|
||||
}
|
||||
},
|
||||
[zoom],
|
||||
);
|
||||
|
||||
// Tap to find nearest intersection
|
||||
const onCanvasClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const rect = canvasRef.current!.getBoundingClientRect();
|
||||
const sx = e.clientX - rect.left;
|
||||
const sy = e.clientY - rect.top;
|
||||
const { mx, my } = toMath(sx, sy, viewport, canvasSize);
|
||||
|
||||
// Find closest intersection within 20px
|
||||
let best: Intersection | null = null;
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (const pt of activeIntersections) {
|
||||
const { sx: px, sy: py } = toScreen(pt.x, pt.y, viewport, canvasSize);
|
||||
const d = Math.hypot(px - sx, py - sy);
|
||||
if (d < 24 && d < bestDist) {
|
||||
best = pt;
|
||||
bestDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
if (best) {
|
||||
const { sx: px, sy: py } = toScreen(
|
||||
best.x,
|
||||
best.y,
|
||||
viewport,
|
||||
canvasSize,
|
||||
);
|
||||
setTooltip({
|
||||
screenX: px,
|
||||
screenY: py,
|
||||
mathX: best.x,
|
||||
mathY: best.y,
|
||||
eqA: best.eqA,
|
||||
eqB: best.eqB,
|
||||
});
|
||||
} else {
|
||||
setTooltip(null);
|
||||
}
|
||||
},
|
||||
[activeIntersections, toMath, toScreen, viewport, canvasSize],
|
||||
);
|
||||
|
||||
const fmt = (n: number) => {
|
||||
if (Math.abs(n) < 1e-9) return "0";
|
||||
if (Math.abs(n) >= 1e4 || (Math.abs(n) < 1e-3 && n !== 0))
|
||||
return n.toExponential(3);
|
||||
return parseFloat(n.toFixed(4)).toString();
|
||||
};
|
||||
|
||||
const resetView = () => setViewport({ cx: 0, cy: 0, scale: 60 });
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ width, height, position: "relative", userSelect: "none" }}
|
||||
className="rounded-2xl overflow-hidden border border-stone-200 shadow-md bg-stone-50 font-mono pt-32"
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ display: "block", cursor: "crosshair", touchAction: "none" }}
|
||||
onWheel={onWheel}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onClick={onCanvasClick}
|
||||
/>
|
||||
|
||||
{/* Equation legend */}
|
||||
<div className="absolute top-3 left-3 flex flex-col gap-1.5">
|
||||
{equations.map((eq, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-2 px-2.5 py-1 rounded-lg bg-white/80 backdrop-blur-sm border border-stone-200 shadow-sm"
|
||||
>
|
||||
<span
|
||||
className="block w-3 h-3 rounded-full shrink-0"
|
||||
style={{
|
||||
backgroundColor:
|
||||
eq.color ?? DEFAULT_COLORS[idx % DEFAULT_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span className="text-[11px] text-stone-600 leading-none">
|
||||
{eq.label ?? `y = ${eq.fn}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="absolute top-3 right-3 flex flex-col gap-1.5">
|
||||
<button
|
||||
onClick={() => zoom(1.25, canvasSize.w / 2, canvasSize.h / 2)}
|
||||
className="w-8 h-8 rounded-lg bg-white/90 border border-stone-200 shadow-sm text-stone-600 hover:bg-stone-100 transition text-lg flex items-center justify-center"
|
||||
title="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => zoom(1 / 1.25, canvasSize.w / 2, canvasSize.h / 2)}
|
||||
className="w-8 h-8 rounded-lg bg-white/90 border border-stone-200 shadow-sm text-stone-600 hover:bg-stone-100 transition text-lg flex items-center justify-center"
|
||||
title="Zoom out"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={resetView}
|
||||
className="w-8 h-8 rounded-lg bg-white/90 border border-stone-200 shadow-sm text-stone-500 hover:bg-stone-100 transition text-[10px] flex items-center justify-center font-sans"
|
||||
title="Reset view"
|
||||
>
|
||||
⌂
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Intersection tooltip */}
|
||||
{tooltip && (
|
||||
<div
|
||||
className="absolute z-10 pointer-events-none"
|
||||
style={{
|
||||
left: tooltip.screenX,
|
||||
top: tooltip.screenY,
|
||||
transform: "translate(-50%, -130%)",
|
||||
}}
|
||||
>
|
||||
<div className="bg-stone-900 text-stone-100 text-[11px] px-3 py-2 rounded-xl shadow-xl border border-stone-700 whitespace-nowrap">
|
||||
<div className="font-semibold mb-0.5 text-stone-300 text-[10px] tracking-wide uppercase">
|
||||
Intersection
|
||||
</div>
|
||||
<div>
|
||||
x ={" "}
|
||||
<span className="text-amber-300 font-bold">
|
||||
{fmt(tooltip.mathX)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
y ={" "}
|
||||
<span className="text-amber-300 font-bold">
|
||||
{fmt(tooltip.mathY)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-stone-500 text-[9px] mt-1">
|
||||
eq {tooltip.eqA + 1} ∩ eq {tooltip.eqB + 1}
|
||||
</div>
|
||||
</div>
|
||||
{/* Arrow */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-2 h-2 bg-stone-900 rotate-45 -mt-1 border-r border-b border-stone-700" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dismiss tooltip on background click hint */}
|
||||
{tooltip && (
|
||||
<button
|
||||
className="absolute inset-0 w-full h-full bg-transparent"
|
||||
onClick={() => setTooltip(null)}
|
||||
style={{ zIndex: 5 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
904
src/components/InfoHeader.tsx
Normal file
904
src/components/InfoHeader.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
216
src/components/InventoryButton.tsx
Normal file
216
src/components/InventoryButton.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
615
src/components/InventoryModal.tsx
Normal file
615
src/components/InventoryModal.tsx
Normal 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
1047
src/components/Island3D.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,87 +1,344 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState, Suspense } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "../components/ui/dialog";
|
||||
import { api } from "../utils/api";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
import { Loader, X } from "lucide-react";
|
||||
import { LESSON_COMPONENT_MAP } from "./FetchLessonPage";
|
||||
import type { LessonId } from "./FetchLessonPage";
|
||||
import type { LessonDetails } from "../types/lesson";
|
||||
|
||||
interface LessonModalProps {
|
||||
lessonId: string | null;
|
||||
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 = ({
|
||||
lessonId,
|
||||
selectedLessonData,
|
||||
open,
|
||||
|
||||
onOpenChange,
|
||||
}: LessonModalProps) => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lesson, setLesson] = useState<any>(null);
|
||||
const [lesson, setLesson] = useState<LessonDetails | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const fetchingForId = useRef<string | null>(null);
|
||||
const lessonId = selectedLessonData.id;
|
||||
|
||||
const LocalLessonComponent =
|
||||
lessonId && !isVideoLesson(lessonId)
|
||||
? LESSON_COMPONENT_MAP[lessonId as LessonId]
|
||||
: null;
|
||||
|
||||
// const modalTitle = LocalLessonComponent
|
||||
// ? getLocalLessonTitle(lessonId!)
|
||||
// : loading
|
||||
// ? "Loading..."
|
||||
// : (lesson?.title ?? "Lesson");
|
||||
|
||||
const modalTitle =
|
||||
selectedLessonData.name || selectedLessonData.id || "Lesson";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !lessonId || !user) return;
|
||||
if (!open) {
|
||||
setLesson(null);
|
||||
setLoading(false);
|
||||
setError(false);
|
||||
fetchingForId.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lessonId || !user || LocalLessonComponent) return;
|
||||
if (fetchingForId.current === lessonId) return;
|
||||
|
||||
const fetchLesson = async () => {
|
||||
try {
|
||||
fetchingForId.current = lessonId;
|
||||
setLesson(null);
|
||||
setError(false);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const authStorage = localStorage.getItem("auth-storage");
|
||||
if (!authStorage) return;
|
||||
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");
|
||||
|
||||
const parsed = JSON.parse(authStorage) as {
|
||||
state?: { token?: string };
|
||||
};
|
||||
// @ts-ignore
|
||||
const response: LessonDetails = await api.fetchLessonById(
|
||||
token,
|
||||
lessonId,
|
||||
);
|
||||
|
||||
const token = parsed.state?.token;
|
||||
if (!token) return;
|
||||
|
||||
const response = 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 {
|
||||
setLoading(false);
|
||||
if (fetchingForId.current === lessonId) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLesson();
|
||||
}, [open, lessonId, user]);
|
||||
}, [open, lessonId, user, LocalLessonComponent]);
|
||||
|
||||
// topic on LessonDetails is Topic[] — use the first entry
|
||||
const topicName = Array.isArray(lesson?.topic)
|
||||
? lesson.topic[0]?.name
|
||||
: ((lesson?.topic as any)?.name ?? null);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
{loading && (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
Loading lesson...
|
||||
</div>
|
||||
)}
|
||||
<DialogHeader>
|
||||
<DialogTitle>{lesson ? lesson.title : "Lesson details"}</DialogTitle>
|
||||
<style>{STYLES}</style>
|
||||
<DialogContent className="lm-content" showCloseButton={false}>
|
||||
<DialogHeader className="lm-dialog-header-hidden">
|
||||
<DialogTitle>{modalTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{!loading && lesson && (
|
||||
<div className="space-y-4">
|
||||
<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="w-full rounded-lg"
|
||||
className="lm-video"
|
||||
/>
|
||||
)}
|
||||
<h2 className="font-satoshi-bold text-xl">
|
||||
{lesson ? lesson.title : "Lesson details"}
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{lesson.description}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{lesson.content}</p>
|
||||
{/* 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>
|
||||
|
||||
93
src/components/LevelBar.tsx
Normal file
93
src/components/LevelBar.tsx
Normal 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
25
src/components/Math.tsx
Normal 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>
|
||||
);
|
||||
@ -1,11 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/card";
|
||||
import { api } from "../utils/api";
|
||||
import { useAuthToken } from "../hooks/useAuthToken";
|
||||
import {
|
||||
@ -36,34 +29,38 @@ interface PredictedScoreResponse {
|
||||
|
||||
const confidenceConfig: Record<
|
||||
string,
|
||||
{ label: string; color: string; bg: string; dot: string }
|
||||
{ label: string; color: string; bg: string; border: string; dot: string }
|
||||
> = {
|
||||
high: {
|
||||
label: "High confidence",
|
||||
color: "text-emerald-700",
|
||||
bg: "bg-emerald-50 border-emerald-200",
|
||||
dot: "bg-emerald-500",
|
||||
color: "#16a34a",
|
||||
bg: "#f0fdf4",
|
||||
border: "#bbf7d0",
|
||||
dot: "#22c55e",
|
||||
},
|
||||
medium: {
|
||||
label: "Medium confidence",
|
||||
color: "text-amber-700",
|
||||
bg: "bg-amber-50 border-amber-200",
|
||||
dot: "bg-amber-400",
|
||||
color: "#d97706",
|
||||
bg: "#fffbeb",
|
||||
border: "#fde68a",
|
||||
dot: "#f59e0b",
|
||||
},
|
||||
low: {
|
||||
label: "Low confidence",
|
||||
color: "text-rose-700",
|
||||
bg: "bg-rose-50 border-rose-200",
|
||||
dot: "bg-rose-400",
|
||||
color: "#e11d48",
|
||||
bg: "#fff1f2",
|
||||
border: "#fecdd3",
|
||||
dot: "#f43f5e",
|
||||
},
|
||||
};
|
||||
|
||||
const getConfidenceStyle = (confidence: string) =>
|
||||
confidenceConfig[confidence.toLowerCase()] ?? {
|
||||
label: confidence,
|
||||
color: "text-gray-600",
|
||||
bg: "bg-gray-50 border-gray-200",
|
||||
dot: "bg-gray-400",
|
||||
color: "#6b7280",
|
||||
bg: "#f9fafb",
|
||||
border: "#f3f4f6",
|
||||
dot: "#9ca3af",
|
||||
};
|
||||
|
||||
const useCountUp = (target: number, duration = 900) => {
|
||||
@ -83,77 +80,266 @@ const useCountUp = (target: number, duration = 900) => {
|
||||
return value;
|
||||
};
|
||||
|
||||
// ─── Expanded section detail ──────────────────────────────────────────────────
|
||||
// ─── 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,
|
||||
accentClass,
|
||||
iconBg,
|
||||
barColor,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
prediction: SectionPrediction;
|
||||
accentClass: string;
|
||||
iconBg: string;
|
||||
barColor: string;
|
||||
}) => {
|
||||
const conf = getConfidenceStyle(prediction.confidence);
|
||||
const pct = (v: number) => ((v - 200) / (800 - 200)) * 100;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-gray-100 bg-gray-50 px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-1.5 rounded-lg ${accentClass}`}>
|
||||
<Icon size={14} className="text-white" />
|
||||
<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="font-satoshi-medium text-sm text-gray-700">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full border font-satoshi ${conf.bg} ${conf.color}`}
|
||||
<span className="psc-detail-label">{label}</span>
|
||||
<div
|
||||
className="psc-conf-badge"
|
||||
style={{
|
||||
background: conf.bg,
|
||||
borderColor: conf.border,
|
||||
color: conf.color,
|
||||
}}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${conf.dot}`} />
|
||||
<div className="psc-conf-dot" style={{ background: conf.dot }} />
|
||||
{conf.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between mt-1">
|
||||
<span className="font-satoshi-bold text-2xl text-gray-900">
|
||||
{prediction.score}
|
||||
</span>
|
||||
<span className="font-satoshi text-xs text-gray-400 mb-1">
|
||||
Range:{" "}
|
||||
<span className="text-gray-600 font-satoshi-medium">
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Range bar */}
|
||||
<div className="relative h-1.5 rounded-full bg-gray-200 mt-1">
|
||||
<div>
|
||||
<div className="psc-bar-wrap">
|
||||
<div
|
||||
className={`absolute h-1.5 rounded-full ${accentClass} opacity-60`}
|
||||
className="psc-bar-fill"
|
||||
style={{
|
||||
left: `${((prediction.range_min - 200) / (800 - 200)) * 100}%`,
|
||||
right: `${100 - ((prediction.range_max - 200) / (800 - 200)) * 100}%`,
|
||||
left: `${pct(prediction.range_min)}%`,
|
||||
right: `${100 - pct(prediction.range_max)}%`,
|
||||
background: barColor,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`absolute w-2.5 h-2.5 rounded-full border-2 border-white ${accentClass} -top-0.5 shadow-sm`}
|
||||
className="psc-bar-dot"
|
||||
style={{
|
||||
left: `calc(${((prediction.score - 200) / (800 - 200)) * 100}% - 5px)`,
|
||||
left: `${pct(prediction.score)}%`,
|
||||
background: barColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-gray-300 font-satoshi mt-0.5">
|
||||
<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);
|
||||
@ -177,129 +363,113 @@ export const PredictedScoreCard = () => {
|
||||
})();
|
||||
}, [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 (
|
||||
<Card className="w-full border border-gray-200 shadow-sm overflow-hidden">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="font-satoshi-bold text-lg text-gray-900">
|
||||
Predicted SAT Score
|
||||
</CardTitle>
|
||||
<CardDescription className="font-satoshi text-sm text-gray-400 mt-0.5">
|
||||
Based on your practice performance
|
||||
</CardDescription>
|
||||
<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="p-2 rounded-xl bg-purple-50 border border-purple-100">
|
||||
<TrendingUp size={18} className="text-purple-500" />
|
||||
<div className="psc-header-icon">
|
||||
<TrendingUp size={17} color="white" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Body */}
|
||||
<div className="psc-body">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={26} className="animate-spin text-purple-400" />
|
||||
<div className="psc-loading">
|
||||
<Loader2 size={20} color="#a855f7" className="psc-spinner" />
|
||||
Calculating your score...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<p className="font-satoshi text-sm text-rose-500 text-center py-4">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{error && !loading && <div className="psc-error">⚠️ {error}</div>}
|
||||
|
||||
{data && !loading && (
|
||||
<>
|
||||
{/* ── Collapsed view: big numbers only ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Score cells */}
|
||||
<div className="psc-scores-row">
|
||||
{/* Total */}
|
||||
<div className="flex flex-col">
|
||||
<span className="font-satoshi text-lg text-gray-400 mb-0.5">
|
||||
Total
|
||||
</span>
|
||||
<span className="font-satoshi-bold text-6xl text-gray-900 leading-none">
|
||||
{animatedTotal}
|
||||
</span>
|
||||
<span className="font-satoshi text-[18px] text-gray-300 mt-1">
|
||||
out of 1600
|
||||
</span>
|
||||
<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>
|
||||
|
||||
<div className="h-12 w-px bg-gray-100" />
|
||||
|
||||
{/* Math */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
<Calculator size={16} className="text-violet-400" />
|
||||
<span className="font-satoshi text-sm text-gray-400">
|
||||
Math
|
||||
</span>
|
||||
<div className="psc-score-cell">
|
||||
<div className="psc-cell-label">
|
||||
<Calculator size={10} color="#7c3aed" /> Math
|
||||
</div>
|
||||
<span className="font-satoshi-bold text-3xl text-gray-900 leading-none">
|
||||
<span className="psc-cell-score medium">
|
||||
{data.math_prediction.score}
|
||||
</span>
|
||||
<span className="font-satoshi text-[12px] text-gray-300 mt-1">
|
||||
out of 800
|
||||
</span>
|
||||
<span className="psc-cell-out">/ 800</span>
|
||||
</div>
|
||||
|
||||
<div className="h-12 w-px bg-gray-100" />
|
||||
|
||||
{/* R&W */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
<BookOpen size={16} className="text-sky-400" />
|
||||
<span className="font-satoshi text-sm text-gray-400">
|
||||
R&W
|
||||
</span>
|
||||
<div className="psc-score-cell">
|
||||
<div className="psc-cell-label">
|
||||
<BookOpen size={10} color="#0891b2" /> R&W
|
||||
</div>
|
||||
<span className="font-satoshi-bold text-3xl text-gray-900 leading-none">
|
||||
<span className="psc-cell-score medium">
|
||||
{data.rw_prediction.score}
|
||||
</span>
|
||||
<span className="font-satoshi text-[12px] text-gray-300 mt-1">
|
||||
out of 800
|
||||
</span>
|
||||
<span className="psc-cell-out">/ 800</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Expand toggle ── */}
|
||||
{/* Toggle */}
|
||||
<button
|
||||
className="psc-toggle-btn"
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2 text-xs font-satoshi-medium text-gray-400 hover:text-purple-500 transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp size={14} /> Less detail
|
||||
<ChevronUp size={13} /> Less detail
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown size={14} /> More detail
|
||||
<ChevronDown size={13} /> Score breakdown
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* ── Expanded: range bars + confidence ── */}
|
||||
{/* Expanded */}
|
||||
{expanded && (
|
||||
<div className="space-y-3 pt-1">
|
||||
<div className="psc-expanded-wrap">
|
||||
<SectionDetail
|
||||
label="Math"
|
||||
label="Mathematics"
|
||||
icon={Calculator}
|
||||
prediction={data.math_prediction}
|
||||
accentClass="bg-violet-500"
|
||||
iconBg="#fdf4ff"
|
||||
barColor="#a855f7"
|
||||
/>
|
||||
<SectionDetail
|
||||
label="Reading & Writing"
|
||||
icon={BookOpen}
|
||||
prediction={data.rw_prediction}
|
||||
accentClass="bg-sky-500"
|
||||
iconBg="#ecfeff"
|
||||
barColor="#0891b2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
1043
src/components/QuestNodeModal.tsx
Normal file
1043
src/components/QuestNodeModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
36
src/components/RenderLessonIcon.tsx
Normal file
36
src/components/RenderLessonIcon.tsx
Normal 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} />;
|
||||
};
|
||||
@ -1,16 +1,77 @@
|
||||
import { Component, type ReactNode } from "react";
|
||||
// @ts-ignore
|
||||
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) => {
|
||||
const parts = text.split(/(\$\$.*?\$\$|\$.*?\$)/g);
|
||||
if (!text) return null;
|
||||
const parts = text.split(/(\$\$.*?\$\$|\$.*?\$)/gs);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
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("$")) {
|
||||
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>;
|
||||
})}
|
||||
|
||||
@ -1,6 +1,18 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
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";
|
||||
@ -13,20 +25,32 @@ interface Props {
|
||||
setSearchQuery: (value: string) => void;
|
||||
}
|
||||
|
||||
const navigationItems: SearchItem[] = [
|
||||
// ─── Nav items ────────────────────────────────────────────────────────────────
|
||||
|
||||
const NAV_ITEMS: (SearchItem & {
|
||||
icon: React.ComponentType<any>;
|
||||
color: string;
|
||||
bg: string;
|
||||
})[] = [
|
||||
{
|
||||
type: "route",
|
||||
title: "Hard Test Modules",
|
||||
description: "Access advanced SAT 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 what matters",
|
||||
description: "Focus on your weak spots",
|
||||
route: "/student/practice/targeted-practice",
|
||||
group: "Pages",
|
||||
icon: Target,
|
||||
color: "#ef4444",
|
||||
bg: "#fff5f5",
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
@ -34,64 +58,291 @@ const navigationItems: SearchItem[] = [
|
||||
description: "Train speed and accuracy",
|
||||
route: "/student/practice/drills",
|
||||
group: "Pages",
|
||||
icon: Zap,
|
||||
color: "#0891b2",
|
||||
bg: "#ecfeff",
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
title: "Leaderboard",
|
||||
description: "View student rankings",
|
||||
description: "See how you rank against others",
|
||||
route: "/student/rewards",
|
||||
group: "Pages",
|
||||
icon: Trophy,
|
||||
color: "#f97316",
|
||||
bg: "#fff7ed",
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
title: "Practice",
|
||||
description: "See how you can practice",
|
||||
description: "Browse all practice modes",
|
||||
route: "/student/practice",
|
||||
group: "Pages",
|
||||
icon: BookOpen,
|
||||
color: "#a855f7",
|
||||
bg: "#fdf4ff",
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
title: "Lessons",
|
||||
description: "Watch detailed lessons on SAT techniques",
|
||||
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",
|
||||
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 escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||
|
||||
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, index) => {
|
||||
const isMatch = part.toLowerCase() === query.toLowerCase();
|
||||
|
||||
return isMatch ? (
|
||||
<motion.span
|
||||
key={index}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
className="bg-purple-200 text-purple-900 px-1 rounded-md"
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
part.toLowerCase() === query.toLowerCase() ? (
|
||||
<mark
|
||||
key={i}
|
||||
style={{
|
||||
background: "#e9d5ff",
|
||||
color: "#6b21a8",
|
||||
borderRadius: 4,
|
||||
padding: "0 2px",
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</motion.span>
|
||||
</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,
|
||||
@ -99,132 +350,335 @@ export const SearchOverlay = ({
|
||||
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",
|
||||
type: "sheet" as const,
|
||||
id: sheet.id,
|
||||
title: sheet.title,
|
||||
description: sheet.description,
|
||||
description: sheet.description ?? "Practice sheet",
|
||||
route: `/student/practice/${sheet.id}`,
|
||||
group: formatGroupTitle(sheet.user_status), // 👈 reuse your grouping
|
||||
group: formatGroupTitle(sheet.user_status),
|
||||
status: sheet.user_status,
|
||||
}));
|
||||
|
||||
return [...navigationItems, ...sheetItems];
|
||||
return [...NAV_ITEMS, ...sheetItems];
|
||||
}, [sheets]);
|
||||
|
||||
// Close on ESC
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Filtered + grouped results
|
||||
const groupedResults = useMemo(() => {
|
||||
if (!searchQuery.trim()) return {};
|
||||
|
||||
const q = searchQuery.toLowerCase();
|
||||
|
||||
const filtered = searchItems.filter((item) => {
|
||||
const title = item.title?.toLowerCase() || "";
|
||||
const description = item.description?.toLowerCase() || "";
|
||||
|
||||
return title.includes(q) || description.includes(q);
|
||||
});
|
||||
|
||||
const filtered = searchItems.filter(
|
||||
(item) =>
|
||||
item.title?.toLowerCase().includes(q) ||
|
||||
item.description?.toLowerCase().includes(q),
|
||||
);
|
||||
return filtered.reduce<Record<string, SearchItem[]>>((acc, item) => {
|
||||
if (!acc[item.group]) {
|
||||
acc[item.group] = [];
|
||||
}
|
||||
acc[item.group].push(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="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm flex flex-col items-center pt-24 px-4"
|
||||
className="so-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Search Box */}
|
||||
<style>{STYLES}</style>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: -40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -40, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300 }}
|
||||
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()}
|
||||
className="w-full max-w-2xl bg-white rounded-3xl shadow-2xl p-6"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Search size={20} />
|
||||
{/* 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..."
|
||||
className="flex-1 outline-none font-satoshi text-lg"
|
||||
placeholder="Search sheets, pages, topics..."
|
||||
/>
|
||||
<button onClick={onClose}>
|
||||
<X size={20} />
|
||||
{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="mt-6 max-h-96 overflow-y-auto space-y-6">
|
||||
{/* {!searchQuery && (
|
||||
<p className="font-satoshi text-gray-500">
|
||||
Start typing to search...
|
||||
<div className="so-results">
|
||||
{/* ── Empty query: recent + quick nav ── */}
|
||||
{!searchQuery && (
|
||||
<>
|
||||
{recent.length > 0 && (
|
||||
<div>
|
||||
<p className="so-section-label">
|
||||
<Clock size={10} /> Recent
|
||||
</p>
|
||||
)} */}
|
||||
|
||||
{searchQuery.length === 0 ? (
|
||||
<p className="text-gray-400 font-satoshi">
|
||||
Start typing to search...
|
||||
</p>
|
||||
) : Object.keys(groupedResults).length === 0 ? (
|
||||
<p className="text-gray-400 font-satoshi">No results found.</p>
|
||||
) : (
|
||||
Object.entries(groupedResults).map(([group, items]) => (
|
||||
<div key={group}>
|
||||
<p className="text-xs uppercase tracking-wider text-gray-400 font-satoshi mb-3">
|
||||
{group}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
{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={index}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate(item.route!);
|
||||
}}
|
||||
className="p-4 rounded-2xl hover:bg-gray-100 cursor-pointer transition"
|
||||
key={i}
|
||||
className="so-item"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
<p className="font-satoshi-medium">
|
||||
{highlightText(item.title, searchQuery)}
|
||||
</p>
|
||||
|
||||
<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="text-sm text-gray-500">
|
||||
{highlightText(item.description, searchQuery)}
|
||||
</p>
|
||||
<p className="so-item-desc">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRight size={15} className="so-item-arrow" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-purple-500 mt-1">
|
||||
{item.type === "route" ? "" : "Practice Sheet"}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
87
src/components/lessons/BoxPlotAnatomyWidget.tsx
Normal file
87
src/components/lessons/BoxPlotAnatomyWidget.tsx
Normal 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;
|
||||
185
src/components/lessons/BoxPlotComparisonWidget.tsx
Normal file
185
src/components/lessons/BoxPlotComparisonWidget.tsx
Normal 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;
|
||||
171
src/components/lessons/CircleTheoremsWidget.tsx
Normal file
171
src/components/lessons/CircleTheoremsWidget.tsx
Normal 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;
|
||||
239
src/components/lessons/ClauseBreakdownWidget.tsx
Normal file
239
src/components/lessons/ClauseBreakdownWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
src/components/lessons/CompletingSquareWidget.tsx
Normal file
166
src/components/lessons/CompletingSquareWidget.tsx
Normal 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;
|
||||
227
src/components/lessons/CompositeAreaWidget.tsx
Normal file
227
src/components/lessons/CompositeAreaWidget.tsx
Normal 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;
|
||||
255
src/components/lessons/CompositeSolidsWidget.tsx
Normal file
255
src/components/lessons/CompositeSolidsWidget.tsx
Normal 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;
|
||||
98
src/components/lessons/ConfidenceIntervalWidget.tsx
Normal file
98
src/components/lessons/ConfidenceIntervalWidget.tsx
Normal 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;
|
||||
217
src/components/lessons/ContextEliminationWidget.tsx
Normal file
217
src/components/lessons/ContextEliminationWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
266
src/components/lessons/CoordinatePlane.tsx
Normal file
266
src/components/lessons/CoordinatePlane.tsx
Normal 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;
|
||||
651
src/components/lessons/DataClaimWidget.tsx
Normal file
651
src/components/lessons/DataClaimWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/components/lessons/DataModifierWidget.tsx
Normal file
127
src/components/lessons/DataModifierWidget.tsx
Normal 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">x̄</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;
|
||||
278
src/components/lessons/DecisionTreeWidget.tsx
Normal file
278
src/components/lessons/DecisionTreeWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/components/lessons/DiscriminantWidget.tsx
Normal file
80
src/components/lessons/DiscriminantWidget.tsx
Normal 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 Δ > 0: Crosses axis twice</p>
|
||||
<p>If Δ = 0: Touches axis once (Vertex on axis)</p>
|
||||
<p>If Δ < 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;
|
||||
255
src/components/lessons/EvidenceHunterWidget.tsx
Normal file
255
src/components/lessons/EvidenceHunterWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
src/components/lessons/ExponentialExplorer.tsx
Normal file
158
src/components/lessons/ExponentialExplorer.tsx
Normal 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;
|
||||
116
src/components/lessons/FactoringWidget.tsx
Normal file
116
src/components/lessons/FactoringWidget.tsx
Normal 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;
|
||||
114
src/components/lessons/FrequencyMeanWidget.tsx
Normal file
114
src/components/lessons/FrequencyMeanWidget.tsx
Normal 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;
|
||||
84
src/components/lessons/GrowthComparisonWidget.tsx
Normal file
84
src/components/lessons/GrowthComparisonWidget.tsx
Normal 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;
|
||||
103
src/components/lessons/HistogramBuilderWidget.tsx
Normal file
103
src/components/lessons/HistogramBuilderWidget.tsx
Normal 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;
|
||||
173
src/components/lessons/InequalityRegionWidget.tsx
Normal file
173
src/components/lessons/InequalityRegionWidget.tsx
Normal 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;
|
||||
232
src/components/lessons/InteractiveSectorWidget.tsx
Normal file
232
src/components/lessons/InteractiveSectorWidget.tsx
Normal 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;
|
||||
186
src/components/lessons/InteractiveTransversal.tsx
Normal file
186
src/components/lessons/InteractiveTransversal.tsx
Normal 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;
|
||||
236
src/components/lessons/InteractiveTriangle.tsx
Normal file
236
src/components/lessons/InteractiveTriangle.tsx
Normal 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;
|
||||
23
src/components/lessons/LessonRenderer.tsx
Normal file
23
src/components/lessons/LessonRenderer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
504
src/components/lessons/LessonShell.tsx
Normal file
504
src/components/lessons/LessonShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/components/lessons/LinearQuadraticSystemWidget.tsx
Normal file
111
src/components/lessons/LinearQuadraticSystemWidget.tsx
Normal 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;
|
||||
140
src/components/lessons/LinearSolutionsWidget.tsx
Normal file
140
src/components/lessons/LinearSolutionsWidget.tsx
Normal 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;
|
||||
178
src/components/lessons/LinearTransformationWidget.tsx
Normal file
178
src/components/lessons/LinearTransformationWidget.tsx
Normal 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;
|
||||
201
src/components/lessons/LiteralEquationWidget.tsx
Normal file
201
src/components/lessons/LiteralEquationWidget.tsx
Normal 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;
|
||||
150
src/components/lessons/MultiStepPercentWidget.tsx
Normal file
150
src/components/lessons/MultiStepPercentWidget.tsx
Normal 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;
|
||||
124
src/components/lessons/MultiplicityWidget.tsx
Normal file
124
src/components/lessons/MultiplicityWidget.tsx
Normal 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;
|
||||
96
src/components/lessons/ParabolaWidget.tsx
Normal file
96
src/components/lessons/ParabolaWidget.tsx
Normal 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;
|
||||
113
src/components/lessons/ParallelPerpendicularWidget.tsx
Normal file
113
src/components/lessons/ParallelPerpendicularWidget.tsx
Normal 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;
|
||||
69
src/components/lessons/PercentChangeWidget.tsx
Normal file
69
src/components/lessons/PercentChangeWidget.tsx
Normal 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;
|
||||
147
src/components/lessons/PolygonWidget.tsx
Normal file
147
src/components/lessons/PolygonWidget.tsx
Normal 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;
|
||||
146
src/components/lessons/PolynomialBehaviorWidget.tsx
Normal file
146
src/components/lessons/PolynomialBehaviorWidget.tsx
Normal 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;
|
||||
364
src/components/lessons/PowerOfPointWidget.tsx
Normal file
364
src/components/lessons/PowerOfPointWidget.tsx
Normal 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;
|
||||
110
src/components/lessons/ProbabilityTableWidget.tsx
Normal file
110
src/components/lessons/ProbabilityTableWidget.tsx
Normal 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;
|
||||
432
src/components/lessons/ProbabilityTreeWidget.tsx
Normal file
432
src/components/lessons/ProbabilityTreeWidget.tsx
Normal 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;
|
||||
150
src/components/lessons/Quiz.tsx
Normal file
150
src/components/lessons/Quiz.tsx
Normal 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;
|
||||
230
src/components/lessons/RadicalSolutionWidget.tsx
Normal file
230
src/components/lessons/RadicalSolutionWidget.tsx
Normal 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;
|
||||
71
src/components/lessons/RadicalWidget.tsx
Normal file
71
src/components/lessons/RadicalWidget.tsx
Normal 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;
|
||||
80
src/components/lessons/RatioVisualizerWidget.tsx
Normal file
80
src/components/lessons/RatioVisualizerWidget.tsx
Normal 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;
|
||||
109
src/components/lessons/RationalExplorer.tsx
Normal file
109
src/components/lessons/RationalExplorer.tsx
Normal 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;
|
||||
86
src/components/lessons/RemainderTheoremWidget.tsx
Normal file
86
src/components/lessons/RemainderTheoremWidget.tsx
Normal 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;
|
||||
114
src/components/lessons/RevealCardGrid.tsx
Normal file
114
src/components/lessons/RevealCardGrid.tsx
Normal 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;
|
||||
166
src/components/lessons/SamplingVisualizerWidget.tsx
Normal file
166
src/components/lessons/SamplingVisualizerWidget.tsx
Normal 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;
|
||||
133
src/components/lessons/ScaleFactorWidget.tsx
Normal file
133
src/components/lessons/ScaleFactorWidget.tsx
Normal 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;
|
||||
125
src/components/lessons/ScatterplotInteractiveWidget.tsx
Normal file
125
src/components/lessons/ScatterplotInteractiveWidget.tsx
Normal 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;
|
||||
645
src/components/lessons/SimilarityTestsWidget.tsx
Normal file
645
src/components/lessons/SimilarityTestsWidget.tsx
Normal 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;
|
||||
201
src/components/lessons/SimilarityWidget.tsx
Normal file
201
src/components/lessons/SimilarityWidget.tsx
Normal 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;
|
||||
93
src/components/lessons/SlopeInterceptWidget.tsx
Normal file
93
src/components/lessons/SlopeInterceptWidget.tsx
Normal 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;
|
||||
121
src/components/lessons/StandardFormWidget.tsx
Normal file
121
src/components/lessons/StandardFormWidget.tsx
Normal 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;
|
||||
88
src/components/lessons/StudyDesignWidget.tsx
Normal file
88
src/components/lessons/StudyDesignWidget.tsx
Normal 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;
|
||||
108
src/components/lessons/SystemVisualizerWidget.tsx
Normal file
108
src/components/lessons/SystemVisualizerWidget.tsx
Normal 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;
|
||||
179
src/components/lessons/TangentPropertiesWidget.tsx
Normal file
179
src/components/lessons/TangentPropertiesWidget.tsx
Normal 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;
|
||||
233
src/components/lessons/UnitCircleWidget.tsx
Normal file
233
src/components/lessons/UnitCircleWidget.tsx
Normal 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;
|
||||
71
src/components/lessons/UnitConversionWidget.tsx
Normal file
71
src/components/lessons/UnitConversionWidget.tsx
Normal 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;
|
||||
36
src/components/lessons/useScrollReveal.ts
Normal file
36
src/components/lessons/useScrollReveal.ts
Normal 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();
|
||||
}, []);
|
||||
}
|
||||
@ -1,8 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
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",
|
||||
@ -22,8 +21,8 @@ const badgeVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
@ -32,7 +31,7 @@ function Badge({
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@ -40,7 +39,7 @@ function Badge({
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
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",
|
||||
|
||||
@ -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">) {
|
||||
return (
|
||||
@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-header"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@ -89,4 +89,4 @@ export {
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,43 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
} from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
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({
|
||||
@ -54,53 +54,53 @@ function Carousel({
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
if (!api) return;
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
if (!api || !setApi) return;
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
if (!api) return;
|
||||
onSelect(api);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
@ -127,11 +127,11 @@ function Carousel({
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -143,16 +143,16 @@ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel()
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -162,11 +162,11 @@ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
@ -175,7 +175,7 @@ function CarouselPrevious({
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@ -187,7 +187,7 @@ function CarouselPrevious({
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
@ -196,7 +196,7 @@ function CarouselPrevious({
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
@ -205,7 +205,7 @@ function CarouselNext({
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@ -217,7 +217,7 @@ function CarouselNext({
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
@ -226,7 +226,7 @@ function CarouselNext({
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@ -236,4 +236,4 @@ export {
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,32 +1,32 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
@ -38,11 +38,11 @@ function DialogOverlay({
|
||||
data-slot="dialog-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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
@ -51,7 +51,7 @@ function DialogContent({
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
@ -60,7 +60,7 @@ function DialogContent({
|
||||
data-slot="dialog-content"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@ -76,7 +76,7 @@ function DialogContent({
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
@ -95,14 +95,14 @@ function DialogFooter({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@ -113,7 +113,7 @@ function DialogFooter({
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
@ -126,7 +126,7 @@ function DialogTitle({
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
@ -139,7 +139,7 @@ function DialogDescription({
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@ -153,4 +153,4 @@ export {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
};
|
||||
|
||||
133
src/components/ui/drawer.tsx
Normal file
133
src/components/ui/drawer.tsx
Normal 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,
|
||||
};
|
||||
@ -1,13 +1,13 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
@ -15,7 +15,7 @@ function DropdownMenuPortal({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
@ -26,7 +26,7 @@ function DropdownMenuTrigger({
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
@ -41,12 +41,12 @@ function DropdownMenuContent({
|
||||
sideOffset={sideOffset}
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
@ -54,7 +54,7 @@ function DropdownMenuGroup({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
@ -63,8 +63,8 @@ function DropdownMenuItem({
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
@ -73,11 +73,11 @@ function DropdownMenuItem({
|
||||
data-variant={variant}
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
@ -91,7 +91,7 @@ function DropdownMenuCheckboxItem({
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@ -103,7 +103,7 @@ function DropdownMenuCheckboxItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
@ -114,7 +114,7 @@ function DropdownMenuRadioGroup({
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
@ -127,7 +127,7 @@ function DropdownMenuRadioItem({
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@ -138,7 +138,7 @@ function DropdownMenuRadioItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
@ -146,7 +146,7 @@ function DropdownMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
@ -154,11 +154,11 @@ function DropdownMenuLabel({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
@ -171,7 +171,7 @@ function DropdownMenuSeparator({
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
@ -183,17 +183,17 @@ function DropdownMenuShortcut({
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: 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({
|
||||
@ -202,7 +202,7 @@ function DropdownMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
@ -210,14 +210,14 @@ function DropdownMenuSubTrigger({
|
||||
data-inset={inset}
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
@ -229,11 +229,11 @@ function DropdownMenuSubContent({
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@ -252,4 +252,4 @@ export {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { useMemo } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Label } from "./label";
|
||||
import { Separator } from "./separator";
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
@ -12,11 +12,11 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
@ -32,11 +32,11 @@ function FieldLegend({
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@ -44,12 +44,12 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<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
|
||||
"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(
|
||||
@ -73,8 +73,8 @@ const fieldVariants = cva(
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Field({
|
||||
className,
|
||||
@ -89,7 +89,7 @@ function Field({
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@ -98,11 +98,11 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
@ -114,13 +114,13 @@ function FieldLabel({
|
||||
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-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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@ -129,11 +129,11 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
@ -141,14 +141,14 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"text-muted-foreground text-sm leading-normal font-normal group-has-data-[orientation=horizontal]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
@ -156,7 +156,7 @@ function FieldSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@ -164,7 +164,7 @@ function FieldSeparator({
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@ -178,7 +178,7 @@ function FieldSeparator({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
@ -187,37 +187,37 @@ function FieldError({
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
errors?: Array<{ message?: string } | undefined>;
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
];
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
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>
|
||||
error?.message && <li key={index}>{error.message}</li>,
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
);
|
||||
}, [children, errors]);
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@ -229,7 +229,7 @@ function FieldError({
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@ -243,4 +243,4 @@ export {
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
@ -11,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import { Label as LabelPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
@ -12,11 +12,11 @@ function Label({
|
||||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
@ -18,11 +18,11 @@ function Separator({
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
@ -1,31 +1,31 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
@ -37,11 +37,11 @@ function SheetOverlay({
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
@ -51,8 +51,8 @@ function SheetContent({
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
@ -69,7 +69,7 @@ function SheetContent({
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@ -82,7 +82,7 @@ function SheetContent({
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@ -92,7 +92,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@ -102,7 +102,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
@ -115,7 +115,7 @@ function SheetTitle({
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
@ -128,7 +128,7 @@ function SheetDescription({
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@ -140,4 +140,4 @@ export {
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,56 +1,56 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
import { Slot } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { PanelLeftIcon } from "lucide-react";
|
||||
import { Slot } from "radix-ui";
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { useIsMobile } from "../../hooks/use-mobile";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "./button";
|
||||
import { Input } from "./input";
|
||||
import { Separator } from "./separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
} from "./sheet";
|
||||
import { Skeleton } from "./skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
} from "./tooltip";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context
|
||||
return context;
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
@ -62,36 +62,36 @@ function SidebarProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
@ -100,18 +100,18 @@ function SidebarProvider({
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
@ -123,8 +123,8 @@ function SidebarProvider({
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
@ -140,7 +140,7 @@ function SidebarProvider({
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@ -148,7 +148,7 @@ function SidebarProvider({
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
@ -159,11 +159,11 @@ function Sidebar({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
@ -171,13 +171,13 @@ function Sidebar({
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
@ -202,7 +202,7 @@ function Sidebar({
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -223,7 +223,7 @@ function Sidebar({
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
@ -237,20 +237,27 @@ function Sidebar({
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col",
|
||||
// For the custom floating pill sidebar we render our own card,
|
||||
// so keep this container visually transparent.
|
||||
variant === "floating"
|
||||
? "bg-transparent"
|
||||
: "bg-sidebar group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
@ -258,7 +265,7 @@ function SidebarTrigger({
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@ -268,19 +275,19 @@ function SidebarTrigger({
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -291,17 +298,17 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
@ -311,11 +318,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
@ -329,7 +336,7 @@ function SidebarInput({
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@ -340,7 +347,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@ -351,7 +358,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
@ -365,7 +372,7 @@ function SidebarSeparator({
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@ -375,11 +382,11 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@ -390,7 +397,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
@ -398,7 +405,7 @@ function SidebarGroupLabel({
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
const Comp = asChild ? Slot.Root : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@ -407,11 +414,11 @@ function SidebarGroupLabel({
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
@ -419,7 +426,7 @@ function SidebarGroupAction({
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@ -430,11 +437,11 @@ function SidebarGroupAction({
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
@ -448,7 +455,7 @@ function SidebarGroupContent({
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
@ -459,7 +466,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
@ -470,7 +477,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
@ -492,8 +499,8 @@ const sidebarMenuButtonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
@ -504,12 +511,12 @@ function SidebarMenuButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
@ -520,16 +527,16 @@ function SidebarMenuButton({
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
@ -542,7 +549,7 @@ function SidebarMenuButton({
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
@ -551,10 +558,10 @@ function SidebarMenuAction({
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@ -570,11 +577,11 @@ function SidebarMenuAction({
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
@ -592,11 +599,11 @@ function SidebarMenuBadge({
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
@ -604,12 +611,12 @@ function SidebarMenuSkeleton({
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -634,7 +641,7 @@ function SidebarMenuSkeleton({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
@ -645,11 +652,11 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
@ -663,7 +670,7 @@ function SidebarMenuSubItem({
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
@ -673,11 +680,11 @@ function SidebarMenuSubButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
const Comp = asChild ? Slot.Root : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@ -691,11 +698,11 @@ function SidebarMenuSubButton({
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@ -723,4 +730,4 @@ export {
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
export { Skeleton };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user