Compare commits
30 Commits
4df5707ebd
...
web
| Author | SHA1 | Date | |
|---|---|---|---|
| b435b656e9 | |||
| d7fb618e6f | |||
| 25cfd2383b | |||
| d24ab8f9cf | |||
| c4d3988f8c | |||
| 4c83fcf7b4 | |||
| 8d86da05b5 | |||
| c8f2259154 | |||
| a429b1c0b1 | |||
| bd35f6a852 | |||
| 121cc2bf71 | |||
| f00aad2bbd | |||
| 575d392afc | |||
| c09ecd7926 | |||
| a1295a0eb3 | |||
| 59e601052f | |||
| b5edb3554f | |||
| 8dbadae58c | |||
| 980eb130e2 | |||
| bd3974e2f0 | |||
| a08476ec53 | |||
| 437c7a517f | |||
| c35f328e30 | |||
| e75233929a | |||
| 79fc2eacdc | |||
| 9074b17a83 | |||
| f154ebf033 | |||
| 634c67b741 | |||
| 2a00c44157 | |||
| 2eaf77e13c |
@ -5,6 +5,12 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<script src="https://www.geogebra.org/apps/deployggb.js"></script>
|
<script src="https://www.geogebra.org/apps/deployggb.js"></script>
|
||||||
|
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="https://alt.omukk.dev/script.js"
|
||||||
|
data-website-id="e4aa7582-260a-4861-b363-eb1815d8b232"
|
||||||
|
></script>
|
||||||
<title>Edbridge Scholars</title>
|
<title>Edbridge Scholars</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
1102
package-lock.json
generated
1102
package-lock.json
generated
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-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@react-three/drei": "^10.7.7",
|
||||||
|
"@react-three/fiber": "^9.5.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
@ -23,6 +25,7 @@
|
|||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.30.0",
|
"framer-motion": "^12.30.0",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
|
"leva": "^0.10.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
@ -31,6 +34,9 @@
|
|||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"three": "^0.183.2",
|
||||||
|
"troika-three-text": "^0.52.4",
|
||||||
|
"troika-worker-utils": "^0.52.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
@ -39,6 +45,7 @@
|
|||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/three": "^0.183.1",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
|||||||
700
pnpm-lock.yaml
generated
700
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
17
src/App.tsx
17
src/App.tsx
@ -19,9 +19,9 @@ import { StudentLayout } from "./pages/student/StudentLayout";
|
|||||||
import { TargetedPractice } from "./pages/student/targeted-practice/page";
|
import { TargetedPractice } from "./pages/student/targeted-practice/page";
|
||||||
import { Drills } from "./pages/student/drills/page";
|
import { Drills } from "./pages/student/drills/page";
|
||||||
import { HardTestModules } from "./pages/student/hard-test-modules/page";
|
import { HardTestModules } from "./pages/student/hard-test-modules/page";
|
||||||
import { Analytics } from "./pages/student/Analytics";
|
|
||||||
import { QuestMap } from "./pages/student/QuestMap";
|
import { QuestMap } from "./pages/student/QuestMap";
|
||||||
import ErrorPage from "./pages/ErrorPage";
|
import { Register } from "./pages/auth/Register";
|
||||||
|
import { PracticeSheetList } from "./pages/student/practice-sheet/page";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
@ -29,12 +29,17 @@ function App() {
|
|||||||
path: "/login",
|
path: "/login",
|
||||||
element: <Login />,
|
element: <Login />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/register",
|
||||||
|
element: <Register />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/student",
|
path: "/student",
|
||||||
element: <ProtectedRoute />,
|
element: <ProtectedRoute />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
element: <StudentLayout />,
|
element: <StudentLayout />,
|
||||||
|
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "home",
|
path: "home",
|
||||||
@ -56,10 +61,6 @@ function App() {
|
|||||||
path: "profile",
|
path: "profile",
|
||||||
element: <Profile />,
|
element: <Profile />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "analytics",
|
|
||||||
element: <Analytics />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "quests",
|
path: "quests",
|
||||||
element: <QuestMap />,
|
element: <QuestMap />,
|
||||||
@ -80,6 +81,10 @@ function App() {
|
|||||||
path: "practice/hard-test-modules",
|
path: "practice/hard-test-modules",
|
||||||
element: <HardTestModules />,
|
element: <HardTestModules />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "practice/practice-sheet",
|
||||||
|
element: <PracticeSheetList />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
@ -15,15 +14,17 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Home,
|
Home,
|
||||||
Video,
|
|
||||||
Target,
|
Target,
|
||||||
Zap,
|
Zap,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
Map,
|
||||||
|
SquareLibrary,
|
||||||
|
ListIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import logo from "../assets/ed_logo1.png";
|
import logo from "../assets/ed_logo1.png";
|
||||||
import { NavLink, useNavigate } from "react-router-dom";
|
import { NavLink, useNavigate, useLocation } from "react-router-dom";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||||
|
|
||||||
@ -31,16 +32,199 @@ export function AppSidebar() {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const isQuestPage = location.pathname.startsWith("/student/quests");
|
||||||
|
|
||||||
|
const STYLES = `
|
||||||
|
/* ══ DEFAULT sidebar (cream frosted glass) ══ */
|
||||||
|
.as-sidebar-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0.5rem;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
width: 16rem;
|
||||||
|
z-index: 10;
|
||||||
|
border-radius: 1.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: rgba(255,251,244,0.72);
|
||||||
|
backdrop-filter: blur(24px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||||
|
border: 1.5px solid rgba(255,255,255,0.7);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0,0,0,0.12),
|
||||||
|
0 2px 8px rgba(0,0,0,0.06),
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.8);
|
||||||
|
transition:
|
||||||
|
background 0.4s ease,
|
||||||
|
border-color 0.4s ease,
|
||||||
|
box-shadow 0.4s ease,
|
||||||
|
z-index 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══ QUEST mode sidebar (dark navy pirate + gold) ══ */
|
||||||
|
.as-sidebar-container.quest-mode {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(251,191,36,0.12) 0%,
|
||||||
|
rgba(251,191,36,0.08) 50%,
|
||||||
|
rgba(251,191,36,0.1) 100%
|
||||||
|
);
|
||||||
|
background-size: 100% 200%;
|
||||||
|
animation: asSweepDown 3s linear infinite;
|
||||||
|
border-color: rgba(251,191,36,0.28);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0,0,0,0.25),
|
||||||
|
0 2px 8px rgba(0,0,0,0.15),
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shimmer animation from top to bottom */
|
||||||
|
@keyframes asSweepDown {
|
||||||
|
0% { background-position: 0% 200%; }
|
||||||
|
100% { background-position: 0% -200%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On quest page, sidebar stays visible above content */
|
||||||
|
.as-sidebar-container.quest-mode {
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sidebar-inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-gradient-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-gradient-overlay.default {
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at top,
|
||||||
|
rgba(168,85,247,0.18),
|
||||||
|
transparent 55%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
circle at bottom,
|
||||||
|
rgba(249,115,22,0.1),
|
||||||
|
transparent 55%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-gradient-overlay.quest {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure Sidebar sub-components are transparent */
|
||||||
|
.as-sidebar-inner > * {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sidebar-inner [class*="SidebarHeader"],
|
||||||
|
.as-sidebar-inner [class*="SidebarContent"],
|
||||||
|
.as-sidebar-inner [class*="SidebarFooter"],
|
||||||
|
.as-sidebar-inner [class*="SidebarGroup"],
|
||||||
|
.as-sidebar-inner [class*="SidebarMenu"] {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quest mode text visibility */
|
||||||
|
.as-sidebar-container.quest-mode [class*="SidebarGroupLabel"] {
|
||||||
|
color: rgba(255, 255, 255, 0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sidebar-container.quest-mode a {
|
||||||
|
color: rgba(255, 255, 255, 0.6) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sidebar-container.quest-mode a:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.85) !important;
|
||||||
|
background: rgba(255, 255, 255, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sidebar-container.quest-mode a.active {
|
||||||
|
color: #fbbf24 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sidebar-container.quest-mode span {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quest header text */
|
||||||
|
.as-sidebar-container.quest-mode .text-slate-900 {
|
||||||
|
color: rgba(255, 255, 255, 0.9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sidebar-container.quest-mode .text-slate-400 {
|
||||||
|
color: rgba(255, 255, 255, 0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quest mode removes white hover background from menu buttons */
|
||||||
|
.as-sidebar-container.quest-mode [class*="SidebarMenuButton"]:hover {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sidebar-container.quest-mode [class*="SidebarMenuButton"] {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent group-hover from adding white background in quest mode */
|
||||||
|
.as-sidebar-container.quest-mode .group:hover {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quest mode footer button styling */
|
||||||
|
.as-sidebar-container.quest-mode [class*="SidebarFooter"] button {
|
||||||
|
background: rgba(255, 255, 255, 0.08) !important;
|
||||||
|
--tw-ring-color: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sidebar-container.quest-mode [class*="SidebarFooter"] button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override Tailwind bg-white and ring classes for quest mode */
|
||||||
|
.as-sidebar-container.quest-mode button[class*="bg-white"] {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sidebar-container.quest-mode button[class*="ring-white"]:not(:hover) {
|
||||||
|
--tw-ring-color: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sidebar-container.quest-mode button:hover[class*="hover:bg-white"] {
|
||||||
|
background-color: rgba(255, 255, 255, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sidebar-container.quest-mode a:hover {
|
||||||
|
--tw-ring-color: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar
|
<>
|
||||||
variant="floating"
|
<style>{STYLES}</style>
|
||||||
className="pointer-events-none border-0 bg-transparent px-0 py-0"
|
<div
|
||||||
>
|
className={`as-sidebar-container${isQuestPage ? " quest-mode" : ""}`}
|
||||||
<div className="pointer-events-auto fixed inset-y-2 left-2 flex w-64 flex-col overflow-hidden rounded-[1.75rem] bg-[rgba(249,240,255,0.82)] shadow-[0_18px_45px_rgba(15,23,42,0.16)] transition-colors duration-300">
|
>
|
||||||
<div className="pointer-events-none absolute inset-0 bg-[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%)]" />
|
<div
|
||||||
|
className={`as-gradient-overlay ${isQuestPage ? "quest" : "default"}`}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="relative flex h-full flex-col">
|
<div className="as-sidebar-inner">
|
||||||
{/* HEADER */}
|
{/* HEADER */}
|
||||||
<SidebarHeader className="px-3 pb-4 pt-1">
|
<SidebarHeader className="px-3 pb-4 pt-1">
|
||||||
<div className="flex items-center justify-start gap-2">
|
<div className="flex items-center justify-start gap-2">
|
||||||
@ -76,12 +260,12 @@ export function AppSidebar() {
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
className="group cursor-pointer rounded-2xl px-2 py-2.5 transition-colors duration-200 hover:bg-white"
|
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/student/home"
|
to="/student/home"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-2.5 text-sm font-satoshi ${
|
`flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 ${
|
||||||
isActive
|
isActive
|
||||||
? "text-slate-900"
|
? "text-slate-900"
|
||||||
: "text-slate-500 group-hover:text-slate-900"
|
: "text-slate-500 group-hover:text-slate-900"
|
||||||
@ -92,11 +276,20 @@ export function AppSidebar() {
|
|||||||
<>
|
<>
|
||||||
<Home
|
<Home
|
||||||
size={18}
|
size={18}
|
||||||
|
strokeWidth={3}
|
||||||
className={
|
className={
|
||||||
isActive ? "text-orange-400" : "text-slate-400"
|
isActive ? "text-orange-400" : "text-slate-400"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>Home</span>
|
<span
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "text-orange-400 font-extrabold"
|
||||||
|
: "text-slate-400 font-bold"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@ -109,13 +302,13 @@ export function AppSidebar() {
|
|||||||
onMouseLeave={() => setOpen(false)}
|
onMouseLeave={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
className="group cursor-pointer rounded-2xl px-2 py-2.5 transition-colors duration-200 hover:bg-white"
|
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/student/practice"
|
to="/student/practice"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-2.5 text-sm font-satoshi ${
|
`flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 ${
|
||||||
isActive
|
isActive
|
||||||
? "text-slate-900"
|
? "text-slate-900"
|
||||||
: "text-slate-500 group-hover:text-slate-900"
|
: "text-slate-500 group-hover:text-slate-900"
|
||||||
@ -126,13 +319,23 @@ export function AppSidebar() {
|
|||||||
<>
|
<>
|
||||||
<BookOpen
|
<BookOpen
|
||||||
size={18}
|
size={18}
|
||||||
|
strokeWidth={3}
|
||||||
className={
|
className={
|
||||||
isActive ? "text-purple-500" : "text-slate-400"
|
isActive ? "text-purple-500" : "text-slate-400"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>Practice</span>
|
<span
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "text-purple-500 font-extrabold"
|
||||||
|
: "text-slate-400 font-bold"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Practice
|
||||||
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={16}
|
size={16}
|
||||||
|
strokeWidth={3}
|
||||||
className={`ml-auto text-slate-400 transition-transform ${
|
className={`ml-auto text-slate-400 transition-transform ${
|
||||||
open ? "rotate-180" : ""
|
open ? "rotate-180" : ""
|
||||||
}`}
|
}`}
|
||||||
@ -153,9 +356,14 @@ export function AppSidebar() {
|
|||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Target size={18} className="text-slate-400" />
|
<Target
|
||||||
|
size={18}
|
||||||
|
strokeWidth={3}
|
||||||
|
className="text-slate-400"
|
||||||
|
/>
|
||||||
<span>Targeted Practice</span>
|
<span>Targeted Practice</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/student/practice/drills"
|
to="/student/practice/drills"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
@ -166,7 +374,11 @@ export function AppSidebar() {
|
|||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Zap size={18} className="text-slate-400" />
|
<Zap
|
||||||
|
size={18}
|
||||||
|
strokeWidth={3}
|
||||||
|
className="text-slate-400"
|
||||||
|
/>
|
||||||
<span>Drills</span>
|
<span>Drills</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
@ -179,23 +391,104 @@ export function AppSidebar() {
|
|||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trophy size={18} className="text-slate-400" />
|
<Trophy
|
||||||
|
size={18}
|
||||||
|
strokeWidth={3}
|
||||||
|
className="text-slate-400"
|
||||||
|
/>
|
||||||
<span>Hard Test Modules</span>
|
<span>Hard Test Modules</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/student/practice/practice-sheet"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
|
||||||
|
isActive
|
||||||
|
? "bg-white text-slate-900"
|
||||||
|
: "text-slate-500 hover:bg-white hover:text-slate-900"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListIcon
|
||||||
|
size={18}
|
||||||
|
strokeWidth={3}
|
||||||
|
className="text-slate-400"
|
||||||
|
/>
|
||||||
|
<span>Practice Sheet</span>
|
||||||
|
</NavLink>
|
||||||
</SidebarMenuSub>
|
</SidebarMenuSub>
|
||||||
)}
|
)}
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|
||||||
|
{/* QUESTS */}
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<NavLink
|
||||||
|
to="/student/quests"
|
||||||
|
className={({ isActive }) => {
|
||||||
|
if (isActive && isQuestPage) {
|
||||||
|
return "flex items-center gap-2.5 text-sm rounded-2xl px-2 py-2.5 transition-all duration-200";
|
||||||
|
}
|
||||||
|
if (isActive) {
|
||||||
|
return "flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 text-slate-900";
|
||||||
|
}
|
||||||
|
return "flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 text-slate-500 group-hover:text-slate-900";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<Map
|
||||||
|
size={18}
|
||||||
|
strokeWidth={3}
|
||||||
|
className={
|
||||||
|
isActive && isQuestPage
|
||||||
|
? "text-amber-400"
|
||||||
|
: isActive
|
||||||
|
? "text-blue-500"
|
||||||
|
: "text-slate-400"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isActive && isQuestPage
|
||||||
|
? ""
|
||||||
|
: isActive
|
||||||
|
? "text-blue-500 font-extrabold"
|
||||||
|
: "text-slate-400 font-bold"
|
||||||
|
}
|
||||||
|
style={
|
||||||
|
isActive && isQuestPage
|
||||||
|
? {
|
||||||
|
fontFamily: "'Sorts Mill Goudy', serif",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
fontWeight: 900,
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "#fbbf24",
|
||||||
|
textShadow: "0 0 12px rgba(251,191,36,0.5)",
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Quests
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
|
||||||
{/* LESSONS */}
|
{/* LESSONS */}
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
className="group cursor-pointer rounded-2xl px-2 py-2.5 transition-colors duration-200 hover:bg-white"
|
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/student/lessons"
|
to="/student/lessons"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-2.5 text-sm font-satoshi ${
|
`flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 ${
|
||||||
isActive
|
isActive
|
||||||
? "text-slate-900"
|
? "text-slate-900"
|
||||||
: "text-slate-500 group-hover:text-slate-900"
|
: "text-slate-500 group-hover:text-slate-900"
|
||||||
@ -204,13 +497,22 @@ export function AppSidebar() {
|
|||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
<Video
|
<SquareLibrary
|
||||||
size={18}
|
size={18}
|
||||||
|
strokeWidth={3}
|
||||||
className={
|
className={
|
||||||
isActive ? "text-cyan-500" : "text-slate-400"
|
isActive ? "text-cyan-500" : "text-slate-400"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>Lessons</span>
|
<span
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "text-cyan-500 font-extrabold"
|
||||||
|
: "text-slate-400 font-bold"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Lessons
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@ -221,12 +523,12 @@ export function AppSidebar() {
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
className="group cursor-pointer rounded-2xl px-2 py-2.5 transition-colors duration-200 hover:bg-white"
|
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/student/rewards"
|
to="/student/rewards"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-2.5 text-sm font-satoshi ${
|
`flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 ${
|
||||||
isActive
|
isActive
|
||||||
? "text-slate-900"
|
? "text-slate-900"
|
||||||
: "text-slate-500 group-hover:text-slate-900"
|
: "text-slate-500 group-hover:text-slate-900"
|
||||||
@ -237,11 +539,20 @@ export function AppSidebar() {
|
|||||||
<>
|
<>
|
||||||
<Trophy
|
<Trophy
|
||||||
size={18}
|
size={18}
|
||||||
|
strokeWidth={3}
|
||||||
className={
|
className={
|
||||||
isActive ? "text-emerald-500" : "text-slate-400"
|
isActive ? "text-emerald-500" : "text-slate-400"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>Rewards</span>
|
<span
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "text-emerald-500 font-extrabold"
|
||||||
|
: "text-slate-400 font-bold"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Rewards
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@ -268,11 +579,15 @@ export function AppSidebar() {
|
|||||||
<span className="font-medium text-slate-900">{user?.name}</span>
|
<span className="font-medium text-slate-900">{user?.name}</span>
|
||||||
<span className="text-xs text-slate-400">{user?.email}</span>
|
<span className="text-xs text-slate-400">{user?.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown size={16} className="ml-auto text-slate-400" />
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
strokeWidth={3}
|
||||||
|
className="ml-auto text-slate-400"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Sidebar>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { X, Calculator, Maximize2, Minimize2 } from "lucide-react";
|
import { X, Calculator, Maximize2, Minimize2 } from "lucide-react";
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import type { QuestNode } from "../types/quest";
|
import type { QuestNode, ClaimedRewardResponse } from "../types/quest";
|
||||||
|
|
||||||
// ─── Styles ───────────────────────────────────────────────────────────────────
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||||
const S = `
|
const S = `
|
||||||
@ -327,6 +327,14 @@ const S = `
|
|||||||
background:rgba(251,191,36,0.06);
|
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 ── */
|
/* ── CTA button ── */
|
||||||
.com-cta {
|
.com-cta {
|
||||||
width:100%; padding:1rem;
|
width:100%; padding:1rem;
|
||||||
@ -369,14 +377,12 @@ const PARTICLE_COLORS = [
|
|||||||
const COIN_EMOJIS = ["🪙", "💰", "✨", "⭐", "💎", "🌟", "💫", "🏅"];
|
const COIN_EMOJIS = ["🪙", "💰", "✨", "⭐", "💎", "🌟", "💫", "🏅"];
|
||||||
const SPARKLE_EMOJIS = ["✨", "⭐", "💫", "🌟"];
|
const SPARKLE_EMOJIS = ["✨", "⭐", "💫", "🌟"];
|
||||||
|
|
||||||
// Rays at evenly spaced angles
|
|
||||||
const RAYS = Array.from({ length: 12 }, (_, i) => ({
|
const RAYS = Array.from({ length: 12 }, (_, i) => ({
|
||||||
id: i,
|
id: i,
|
||||||
angle: `${(i / 12) * 360}deg`,
|
angle: `${(i / 12) * 360}deg`,
|
||||||
delay: `${i * 0.04}s`,
|
delay: `${i * 0.04}s`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Burst rings
|
|
||||||
const BURST_RINGS = [
|
const BURST_RINGS = [
|
||||||
{ id: 0, size: "3", dur: "0.7s", delay: "0s" },
|
{ id: 0, size: "3", dur: "0.7s", delay: "0s" },
|
||||||
{ id: 1, size: "5", dur: "0.9s", delay: "0.1s" },
|
{ id: 1, size: "5", dur: "0.9s", delay: "0.1s" },
|
||||||
@ -384,7 +390,6 @@ const BURST_RINGS = [
|
|||||||
{ id: 3, size: "12", dur: "1.4s", delay: "0.3s" },
|
{ id: 3, size: "12", dur: "1.4s", delay: "0.3s" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Stars in background — stable between renders
|
|
||||||
const STARS = Array.from({ length: 40 }, (_, i) => ({
|
const STARS = Array.from({ length: 40 }, (_, i) => ({
|
||||||
id: i,
|
id: i,
|
||||||
w: 1 + ((i * 7) % 3),
|
w: 1 + ((i * 7) % 3),
|
||||||
@ -394,7 +399,6 @@ const STARS = Array.from({ length: 40 }, (_, i) => ({
|
|||||||
delay: `${(i * 7) % 3}s`,
|
delay: `${(i * 7) % 3}s`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Sparkles floating around the revealed card
|
|
||||||
const SPARKLES = Array.from({ length: 8 }, (_, i) => ({
|
const SPARKLES = Array.from({ length: 8 }, (_, i) => ({
|
||||||
id: i,
|
id: i,
|
||||||
emoji: SPARKLE_EMOJIS[i % 4],
|
emoji: SPARKLE_EMOJIS[i % 4],
|
||||||
@ -409,10 +413,11 @@ type Phase = "idle" | "shaking" | "opening" | "revealed";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
node: QuestNode;
|
node: QuestNode;
|
||||||
|
claimResult: ClaimedRewardResponse | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChestOpenModal = ({ node, onClose }: Props) => {
|
export const ChestOpenModal = ({ claimResult, onClose }: Props) => {
|
||||||
const [phase, setPhase] = useState<Phase>("idle");
|
const [phase, setPhase] = useState<Phase>("idle");
|
||||||
const [showXP, setShowXP] = useState(false);
|
const [showXP, setShowXP] = useState(false);
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@ -464,40 +469,55 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const rewards = [
|
// ── Build reward rows from ClaimedRewardResponse ──────────────────────────
|
||||||
{
|
// claimResult may be null while the API call is in flight; we show a loading
|
||||||
key: "xp",
|
// state in that case rather than crashing or showing stale data.
|
||||||
cls: "xp-row",
|
const xpAwarded = claimResult?.xp_awarded ?? 0;
|
||||||
icon: "⚡",
|
|
||||||
lbl: "XP Gained",
|
// Defensively coerce to arrays — the API may return null, a single object,
|
||||||
val: `+${node.reward.xp} XP`,
|
// or omit these fields entirely rather than returning an empty array.
|
||||||
delay: "0.05s",
|
const titlesAwarded = Array.isArray(claimResult?.title_unlocked)
|
||||||
},
|
? claimResult!.title_unlocked
|
||||||
...(node.reward.title
|
: claimResult?.title_unlocked
|
||||||
? [
|
? [claimResult.title_unlocked]
|
||||||
{
|
: [];
|
||||||
key: "title",
|
const itemsAwarded = Array.isArray(claimResult?.items_awarded)
|
||||||
cls: "",
|
? claimResult!.items_awarded
|
||||||
icon: "🏴☠️",
|
: claimResult?.items_awarded
|
||||||
lbl: "Crew Title",
|
? [claimResult.items_awarded]
|
||||||
val: node.reward.title,
|
: [];
|
||||||
delay: "0.15s",
|
|
||||||
},
|
const rewards = claimResult
|
||||||
]
|
? [
|
||||||
: []),
|
// XP row — always present
|
||||||
...(node.reward.itemLabel
|
{
|
||||||
? [
|
key: "xp",
|
||||||
{
|
cls: "xp-row",
|
||||||
key: "item",
|
icon: "⚡",
|
||||||
cls: "",
|
lbl: "XP Gained",
|
||||||
icon: "🎁",
|
val: `+${xpAwarded} XP`,
|
||||||
lbl: "Item",
|
delay: "0.05s",
|
||||||
val: node.reward.itemLabel,
|
},
|
||||||
delay: "0.25s",
|
// 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 =
|
const chestClass =
|
||||||
phase === "idle"
|
phase === "idle"
|
||||||
@ -534,7 +554,7 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Crepuscular rays (appear on open) */}
|
{/* Crepuscular rays */}
|
||||||
{(phase === "opening" || phase === "revealed") && (
|
{(phase === "opening" || phase === "revealed") && (
|
||||||
<div className="com-rays">
|
<div className="com-rays">
|
||||||
{RAYS.map((r) => (
|
{RAYS.map((r) => (
|
||||||
@ -639,8 +659,12 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* XP blast */}
|
{/* XP blast — uses xp_awarded from claimResult */}
|
||||||
{showXP && <div className="com-xp-blast">+{node.reward.xp} XP</div>}
|
{showXP && (
|
||||||
|
<div className="com-xp-blast">
|
||||||
|
{xpAwarded > 0 ? `+${xpAwarded} XP` : "✨"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Card */}
|
{/* Card */}
|
||||||
<div className="com-card" onClick={(e) => e.stopPropagation()}>
|
<div className="com-card" onClick={(e) => e.stopPropagation()}>
|
||||||
@ -671,16 +695,24 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
|
|||||||
<p className="com-tap-sub">YOUR HARD WORK HAS PAID OFF, PIRATE</p>
|
<p className="com-tap-sub">YOUR HARD WORK HAS PAID OFF, PIRATE</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === "shaking" && (
|
{phase === "shaking" && (
|
||||||
<>
|
<>
|
||||||
<p className="com-shake-text">The chest stirs...</p>
|
<p className="com-shake-text">The chest stirs...</p>
|
||||||
<p className="com-shake-dots">⚡ ⚡ ⚡</p>
|
<p className="com-shake-dots">⚡ ⚡ ⚡</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === "revealed" && (
|
{phase === "revealed" && (
|
||||||
<>
|
<>
|
||||||
<p className="com-rewards-title">⚓ Spoils of Victory</p>
|
<p className="com-rewards-title">⚓ Spoils of Victory</p>
|
||||||
<div className="com-rewards">
|
<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) => (
|
{rewards.map((r) => (
|
||||||
<div
|
<div
|
||||||
key={r.key}
|
key={r.key}
|
||||||
@ -697,7 +729,7 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="com-cta"
|
className="com-cta"
|
||||||
style={{ animationDelay: rewards.length * 0.1 + "s" }}
|
style={{ animationDelay: `${rewards.length * 0.1}s` }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
⚓ Set Sail
|
⚓ Set Sail
|
||||||
@ -707,7 +739,7 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skip link for impatient pirates */}
|
{/* Skip link */}
|
||||||
{phase === "revealed" && (
|
{phase === "revealed" && (
|
||||||
<p className="com-skip" onClick={onClose}>
|
<p className="com-skip" onClick={onClose}>
|
||||||
tap anywhere to continue
|
tap anywhere to continue
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
@ -6,14 +6,40 @@ import {
|
|||||||
useQuestStore,
|
useQuestStore,
|
||||||
getQuestSummary,
|
getQuestSummary,
|
||||||
getCrewRank,
|
getCrewRank,
|
||||||
getEarnedXP,
|
|
||||||
} from "../stores/useQuestStore";
|
} from "../stores/useQuestStore";
|
||||||
import type { QuestNode, QuestArc } from "../types/quest";
|
import type {
|
||||||
|
QuestNode,
|
||||||
|
QuestArc,
|
||||||
|
ClaimedRewardResponse,
|
||||||
|
} from "../types/quest";
|
||||||
import { CREW_RANKS } from "../types/quest";
|
import { CREW_RANKS } from "../types/quest";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||||
import { Drawer, DrawerContent, DrawerTrigger } from "./ui/drawer";
|
import { Drawer, DrawerContent, DrawerTrigger } from "./ui/drawer";
|
||||||
import { PredictedScoreCard } from "./PredictedScoreCard";
|
import { PredictedScoreCard } from "./PredictedScoreCard";
|
||||||
import { ChestOpenModal } from "./ChestOpenModal";
|
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 ───────────────────────────────────────────────────────────────────
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||||
const STYLES = `
|
const STYLES = `
|
||||||
@ -196,8 +222,6 @@ const STYLES = `
|
|||||||
animation: hcIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
|
animation: hcIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animated sea shimmer */
|
|
||||||
.hc-ext::before {
|
.hc-ext::before {
|
||||||
content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
|
content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
|
||||||
background:
|
background:
|
||||||
@ -210,16 +234,12 @@ const STYLES = `
|
|||||||
0% { background-position: 0% 0%, 100% 0%; }
|
0% { background-position: 0% 0%, 100% 0%; }
|
||||||
100% { background-position: 100% 100%, 0% 100%; }
|
100% { background-position: 100% 100%, 0% 100%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gold orb */
|
|
||||||
.hc-ext::after {
|
.hc-ext::after {
|
||||||
content: ''; position: absolute; top: -40px; right: -30px; z-index: 0;
|
content: ''; position: absolute; top: -40px; right: -30px; z-index: 0;
|
||||||
width: 180px; height: 180px; border-radius: 50%;
|
width: 180px; height: 180px; border-radius: 50%;
|
||||||
background: radial-gradient(circle, rgba(251,191,36,0.1), transparent 70%);
|
background: radial-gradient(circle, rgba(251,191,36,0.1), transparent 70%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.hc-ext-header {
|
.hc-ext-header {
|
||||||
position: relative; z-index: 2;
|
position: relative; z-index: 2;
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
@ -236,50 +256,92 @@ const STYLES = `
|
|||||||
padding: 0.2rem 0.6rem;
|
padding: 0.2rem 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollable track container */
|
/* ── 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 {
|
.hc-ext-scroll {
|
||||||
position: relative; z-index: 2;
|
overflow-x: auto;
|
||||||
overflow-x: auto; overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
-webkit-overflow-scrolling: touch; scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
cursor: grab; padding: 1.0rem 1.0rem 0.8rem;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
cursor: grab;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
.hc-ext-scroll::-webkit-scrollbar { display: none; }
|
.hc-ext-scroll::-webkit-scrollbar { display: none; }
|
||||||
.hc-ext-scroll:active { cursor: grabbing; }
|
.hc-ext-scroll:active { cursor: grabbing; }
|
||||||
|
|
||||||
/* Track inner wrapper — the thing that actually lays out rank nodes */
|
/* ── 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 {
|
.hc-ext-inner {
|
||||||
display: flex; align-items: flex-end;
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
position: relative;
|
position: relative;
|
||||||
/* height: ship(28px) + gap(14px) + node(52px) + label(36px) = ~130px */
|
|
||||||
height: 110px;
|
height: 110px;
|
||||||
/* width set inline per node count */
|
/* Mobile: fixed width so nodes have room and scroll kicks in */
|
||||||
|
width: 520px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Baseline connector line — full width, dim */
|
@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 {
|
.hc-ext-baseline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 56px; /* ship(28) + gap(14) + half of node(26) — sits at node centre */
|
top: 56px;
|
||||||
left: 26px; right: 26px; height: 2px;
|
/* Inset matches half the outer col width relative to inner width */
|
||||||
|
left: 4%;
|
||||||
|
right: 4%;
|
||||||
|
height: 2px;
|
||||||
background: rgba(255,255,255,0.07);
|
background: rgba(255,255,255,0.07);
|
||||||
border-radius: 2px; z-index: 0;
|
border-radius: 2px;
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gold progress line — width set inline */
|
|
||||||
.hc-ext-progress-line {
|
.hc-ext-progress-line {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 56px; left: 26px; height: 2px;
|
top: 56px;
|
||||||
|
left: 4%;
|
||||||
|
height: 2px;
|
||||||
background: linear-gradient(90deg, #fbbf24, #f59e0b);
|
background: linear-gradient(90deg, #fbbf24, #f59e0b);
|
||||||
box-shadow: 0 0 10px rgba(251,191,36,0.5);
|
box-shadow: 0 0 10px rgba(251,191,36,0.5);
|
||||||
border-radius: 2px; z-index: 1;
|
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);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/* Ship — absolutely positioned, transition on 'left' */
|
|
||||||
.hc-ext-ship-wrap {
|
.hc-ext-ship-wrap {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 25px; /* sits at top of inner, ship 28px + gap 14px = 42px to node top (56px centre) */
|
top: 20px; z-index: 10; pointer-events: none;
|
||||||
z-index: 10; pointer-events: none;
|
display: flex; flex-direction: column; align-items: center;
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 0px;
|
|
||||||
transition: left 1.2s cubic-bezier(0.34,1.56,0.64,1);
|
transition: left 1.2s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
@ -298,22 +360,22 @@ const STYLES = `
|
|||||||
background: linear-gradient(to bottom, rgba(251,191,36,0.5), transparent);
|
background: linear-gradient(to bottom, rgba(251,191,36,0.5), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Each rank column */
|
/* ── 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 {
|
.hc-ext-col {
|
||||||
display: flex; flex-direction: column; align-items: center;
|
flex: 1;
|
||||||
position: relative; z-index: 2;
|
display: flex;
|
||||||
width: 88px; flex-shrink: 0;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
/* Narrow first/last columns so line extends correctly */
|
|
||||||
.hc-ext-col:first-child,
|
|
||||||
.hc-ext-col:last-child { width: 52px; }
|
|
||||||
|
|
||||||
/* Node circle */
|
|
||||||
.hc-ext-node {
|
.hc-ext-node {
|
||||||
width: 52px; height: 52px; border-radius: 50%; flex-shrink: 0;
|
width: 52px; height: 52px; border-radius: 50%; flex-shrink: 0;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
font-size: 1.4rem; position: relative; z-index: 2;
|
font-size: 1.4rem; position: relative; z-index: 2;
|
||||||
margin-top: 42px; /* push down below ship zone */
|
margin-top: 30px;
|
||||||
}
|
}
|
||||||
.hc-ext-node.reached {
|
.hc-ext-node.reached {
|
||||||
background: linear-gradient(145deg, #1e0e4a, #3730a3);
|
background: linear-gradient(145deg, #1e0e4a, #3730a3);
|
||||||
@ -334,12 +396,10 @@ const STYLES = `
|
|||||||
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); }
|
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 {
|
.hc-ext-node.locked {
|
||||||
background: rgba(0,0,0);
|
background: rgba(0,0,0,0.4);
|
||||||
border: 2px solid rgba(255,255,255,0.09);
|
border: 2px solid rgba(255,255,255,0.09);
|
||||||
filter: grayscale(0.7) opacity(0.45);
|
filter: grayscale(0.7) opacity(0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Labels below node */
|
|
||||||
.hc-ext-label {
|
.hc-ext-label {
|
||||||
margin-top: 7px;
|
margin-top: 7px;
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||||||
@ -358,8 +418,6 @@ const STYLES = `
|
|||||||
.hc-ext-label-xp.reached { color: rgba(251,191,36,0.4); }
|
.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.current { color: rgba(192,132,252,0.6); }
|
||||||
.hc-ext-label-xp.locked { color: rgba(255,255,255,0.15); }
|
.hc-ext-label-xp.locked { color: rgba(255,255,255,0.15); }
|
||||||
|
|
||||||
/* Footer link */
|
|
||||||
.hc-ext-footer {
|
.hc-ext-footer {
|
||||||
position: relative; z-index: 2;
|
position: relative; z-index: 2;
|
||||||
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
|
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
|
||||||
@ -372,7 +430,7 @@ const STYLES = `
|
|||||||
.hc-ext-footer:hover { opacity: 0.75; }
|
.hc-ext-footer:hover { opacity: 0.75; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
function getActiveQuests(arcs: QuestArc[]) {
|
function getActiveQuests(arcs: QuestArc[]) {
|
||||||
const out: { node: QuestNode; arc: QuestArc }[] = [];
|
const out: { node: QuestNode; arc: QuestArc }[] = [];
|
||||||
for (const arc of arcs)
|
for (const arc of arcs)
|
||||||
@ -389,20 +447,9 @@ function getActiveQuests(arcs: QuestArc[]) {
|
|||||||
return out.slice(0, 2);
|
return out.slice(0, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Segment width for nodes that aren't first/last
|
|
||||||
const SEG_W = 88;
|
|
||||||
const EDGE_W = 52;
|
|
||||||
// Centre x of node at index i (0-based, total N nodes)
|
|
||||||
function nodeX(i: number, total: number): number {
|
|
||||||
if (i === 0) return EDGE_W / 2;
|
|
||||||
if (i === total - 1) return EDGE_W / 2 + SEG_W * (total - 2) + EDGE_W / 2;
|
|
||||||
return EDGE_W + SEG_W * (i - 1) + SEG_W / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── QUEST_EXTENDED sub-component ────────────────────────────────────────────
|
// ─── QUEST_EXTENDED sub-component ────────────────────────────────────────────
|
||||||
const RankLadder = ({
|
const RankLadder = ({
|
||||||
earnedXP,
|
earnedXP,
|
||||||
onViewAll,
|
|
||||||
}: {
|
}: {
|
||||||
earnedXP: number;
|
earnedXP: number;
|
||||||
onViewAll: () => void;
|
onViewAll: () => void;
|
||||||
@ -411,7 +458,6 @@ const RankLadder = ({
|
|||||||
const ladder = [...CREW_RANKS] as typeof CREW_RANKS;
|
const ladder = [...CREW_RANKS] as typeof CREW_RANKS;
|
||||||
const N = ladder.length;
|
const N = ladder.length;
|
||||||
|
|
||||||
// Which rank the user is currently on (0-based)
|
|
||||||
let currentIdx = 0;
|
let currentIdx = 0;
|
||||||
for (let i = N - 1; i >= 0; i--) {
|
for (let i = N - 1; i >= 0; i--) {
|
||||||
if (earnedXP >= ladder[i].xpRequired) {
|
if (earnedXP >= ladder[i].xpRequired) {
|
||||||
@ -430,19 +476,26 @@ const RankLadder = ({
|
|||||||
)
|
)
|
||||||
: 1;
|
: 1;
|
||||||
|
|
||||||
// Ship x position: interpolate between current node and next node
|
// ── Geometry ────────────────────────────────────────────────────────────────
|
||||||
const shipX = nextRank
|
// Nodes are evenly distributed via flex (each col = flex:1).
|
||||||
? nodeX(currentIdx, N) +
|
// The centre of node[i] sits at: leftInset + (i / (N-1)) * usableSpan
|
||||||
(nodeX(currentIdx + 1, N) - nodeX(currentIdx, N)) * progressToNext
|
// where leftInset = 4% and usableSpan = 92% (100% - 4% left - 4% right).
|
||||||
: nodeX(currentIdx, N);
|
// 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
|
||||||
|
|
||||||
// Gold progress line width: from left edge to ship position
|
// Ship position as % of the inner container width
|
||||||
const progressLineW = shipX;
|
const nodePosPct = (i: number) => LEFT_INSET_PCT + (i / (N - 1)) * USABLE_PCT;
|
||||||
|
|
||||||
// Total scroll width
|
const shipPct = nextRank
|
||||||
const totalW = EDGE_W + SEG_W * (N - 2) + EDGE_W;
|
? 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;
|
||||||
|
|
||||||
// Animate ship in after mount
|
|
||||||
const [animated, setAnimated] = useState(false);
|
const [animated, setAnimated] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = requestAnimationFrame(() =>
|
const id = requestAnimationFrame(() =>
|
||||||
@ -451,14 +504,36 @@ const RankLadder = ({
|
|||||||
return () => cancelAnimationFrame(id);
|
return () => cancelAnimationFrame(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-scroll to ship position on mount
|
// Mouse-drag scroll for mobile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scrollRef.current) return;
|
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
const containerW = el.offsetWidth;
|
if (!el) return;
|
||||||
const targetScroll = shipX - containerW / 2;
|
let isDown = false,
|
||||||
el.scrollTo({ left: Math.max(0, targetScroll), behavior: "smooth" });
|
startX = 0,
|
||||||
}, [shipX]);
|
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 rankPct = nextRank ? Math.round(progressToNext * 100) : 100;
|
||||||
const nextLabel = nextRank
|
const nextLabel = nextRank
|
||||||
@ -467,13 +542,11 @@ const RankLadder = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hc-ext">
|
<div className="hc-ext">
|
||||||
{/* Header */}
|
|
||||||
<div className="hc-ext-header">
|
<div className="hc-ext-header">
|
||||||
<span className="hc-ext-title">⚓ Crew Rank</span>
|
<span className="hc-ext-title">⚓ Crew Rank</span>
|
||||||
<span className="hc-ext-earned">{earnedXP.toLocaleString()} XP</span>
|
<span className="hc-ext-earned">{earnedXP.toLocaleString()} XP</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current rank label */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
@ -507,22 +580,21 @@ const RankLadder = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable rank track */}
|
|
||||||
<div className="hc-ext-scroll" ref={scrollRef}>
|
<div className="hc-ext-scroll" ref={scrollRef}>
|
||||||
<div className="hc-ext-inner" style={{ width: totalW }}>
|
<div className="hc-ext-inner">
|
||||||
{/* Baseline dim line */}
|
{/* Baseline — left: 4%, right: 4% (set in CSS) */}
|
||||||
<div className="hc-ext-baseline" />
|
<div className="hc-ext-baseline" />
|
||||||
|
|
||||||
{/* Gold progress line */}
|
{/* Progress line — starts at left: 4%, width grows to ship position */}
|
||||||
<div
|
<div
|
||||||
className="hc-ext-progress-line"
|
className="hc-ext-progress-line"
|
||||||
style={{ width: animated ? progressLineW : 26 }}
|
style={{ width: animated ? `${progressLinePct}%` : "0%" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Ship marker */}
|
{/* Ship — positioned as % of container */}
|
||||||
<div
|
<div
|
||||||
className="hc-ext-ship-wrap"
|
className="hc-ext-ship-wrap"
|
||||||
style={{ left: animated ? shipX : nodeX(0, N) }}
|
style={{ left: animated ? `${shipPct}%` : `${nodePosPct(0)}%` }}
|
||||||
>
|
>
|
||||||
<span className="hc-ext-ship" role="img" aria-label="ship">
|
<span className="hc-ext-ship" role="img" aria-label="ship">
|
||||||
⛵
|
⛵
|
||||||
@ -530,7 +602,7 @@ const RankLadder = ({
|
|||||||
<div className="hc-ext-ship-tether" />
|
<div className="hc-ext-ship-tether" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rank nodes */}
|
{/* Nodes — evenly spaced via flex:1 on each col */}
|
||||||
{ladder.map((r, i) => {
|
{ladder.map((r, i) => {
|
||||||
const state =
|
const state =
|
||||||
i < currentIdx
|
i < currentIdx
|
||||||
@ -556,19 +628,12 @@ const RankLadder = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
{/* <div className="hc-ext-footer" onClick={onViewAll}>
|
|
||||||
<Map size={12} />
|
|
||||||
View quest map
|
|
||||||
</div> */}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
type Mode = "DEFAULT" | "LEVEL" | "QUEST_COMPACT" | "QUEST_EXTENDED";
|
type Mode = "DEFAULT" | "LEVEL" | "QUEST_COMPACT" | "QUEST_EXTENDED";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onViewAll?: () => void;
|
onViewAll?: () => void;
|
||||||
mode?: Mode;
|
mode?: Mode;
|
||||||
@ -578,17 +643,19 @@ interface Props {
|
|||||||
export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
const arcs = useQuestStore((s) => s.arcs);
|
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 claimNode = useQuestStore((s) => s.claimNode);
|
||||||
|
|
||||||
const summary = getQuestSummary(arcs);
|
const summary = getQuestSummary(arcs, earnedXP, earnedTitles);
|
||||||
const rank = getCrewRank(arcs);
|
const rank = getCrewRank(earnedXP);
|
||||||
const earnedXP = getEarnedXP(arcs);
|
|
||||||
const activeQuests = getActiveQuests(arcs);
|
const activeQuests = getActiveQuests(arcs);
|
||||||
|
|
||||||
const u = user as any;
|
const u = user as any;
|
||||||
const level = u?.current_level ?? u?.level ?? 1;
|
const level = u?.current_level ?? 1;
|
||||||
const totalXP = u?.total_xp ?? u?.xp ?? 0;
|
const totalXP = u?.total_xp ?? 5;
|
||||||
const levelStart = u?.current_level_start ?? u?.level_min_xp ?? 0;
|
const levelStart = u?.current_level_start ?? u?.level_min_xp ?? 0;
|
||||||
const levelEnd =
|
const levelEnd =
|
||||||
u?.next_level_threshold ?? u?.level_max_xp ?? levelStart + 1000;
|
u?.next_level_threshold ?? u?.level_max_xp ?? levelStart + 1000;
|
||||||
@ -621,17 +688,30 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|||||||
node: QuestNode;
|
node: QuestNode;
|
||||||
arcId: string;
|
arcId: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [claimResult, setClaimResult] = useState<ClaimedRewardResponse | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const handleViewAll = () => {
|
const handleViewAll = () => {
|
||||||
if (onViewAll) onViewAll();
|
if (onViewAll) onViewAll();
|
||||||
else navigate("/student/quests");
|
else navigate("/student/quests");
|
||||||
};
|
};
|
||||||
const handleClaim = (node: QuestNode, arcId: string) =>
|
|
||||||
|
const handleClaim = (node: QuestNode, arcId: string) => {
|
||||||
|
setClaimResult(null);
|
||||||
setClaimingNode({ node, arcId });
|
setClaimingNode({ node, arcId });
|
||||||
|
};
|
||||||
|
|
||||||
const handleChestClose = () => {
|
const handleChestClose = () => {
|
||||||
if (!claimingNode) return;
|
if (!claimingNode) return;
|
||||||
claimNode(claimingNode.arcId, claimingNode.node.id);
|
claimNode(
|
||||||
|
claimingNode.arcId,
|
||||||
|
claimingNode.node.node_id,
|
||||||
|
claimResult?.xp_awarded ?? 0,
|
||||||
|
claimResult?.title_unlocked.map((t) => t.name) ?? [],
|
||||||
|
);
|
||||||
setClaimingNode(null);
|
setClaimingNode(null);
|
||||||
|
setClaimResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rankProgress = Math.round(rank.progressToNext * 100);
|
const rankProgress = Math.round(rank.progressToNext * 100);
|
||||||
@ -644,14 +724,17 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|||||||
const showQuestCompact = mode === "DEFAULT" || mode === "QUEST_COMPACT";
|
const showQuestCompact = mode === "DEFAULT" || mode === "QUEST_COMPACT";
|
||||||
const showQuestExtended = mode === "QUEST_EXTENDED";
|
const showQuestExtended = mode === "QUEST_EXTENDED";
|
||||||
|
|
||||||
// QUEST_EXTENDED renders its own standalone dark card — no .hc-card wrapper
|
|
||||||
if (showQuestExtended) {
|
if (showQuestExtended) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{STYLES}</style>
|
<style>{STYLES}</style>
|
||||||
<RankLadder earnedXP={earnedXP} onViewAll={handleViewAll} />
|
<RankLadder earnedXP={earnedXP} onViewAll={handleViewAll} />
|
||||||
{claimingNode && (
|
{claimingNode && (
|
||||||
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} />
|
<ChestOpenModal
|
||||||
|
node={claimingNode.node}
|
||||||
|
claimResult={claimResult}
|
||||||
|
onClose={handleChestClose}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -662,7 +745,6 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|||||||
<style>{STYLES}</style>
|
<style>{STYLES}</style>
|
||||||
|
|
||||||
<div className="hc-card">
|
<div className="hc-card">
|
||||||
{/* Identity — DEFAULT only */}
|
|
||||||
{showIdentity && (
|
{showIdentity && (
|
||||||
<>
|
<>
|
||||||
<div className="hc-top">
|
<div className="hc-top">
|
||||||
@ -691,10 +773,12 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|||||||
<p className="hc-role">{roleLabel}</p>
|
<p className="hc-role">{roleLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<InventoryButton label="Inventory" />
|
||||||
<Drawer direction="top">
|
<Drawer direction="top">
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>
|
||||||
<button className="hc-score-btn">
|
<button className="hc-score-btn">
|
||||||
<Gauge size={14} /> Score
|
<Gauge size={14} />
|
||||||
</button>
|
</button>
|
||||||
</DrawerTrigger>
|
</DrawerTrigger>
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
@ -706,7 +790,6 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* XP bar — DEFAULT + LEVEL */}
|
|
||||||
{showLevel && (
|
{showLevel && (
|
||||||
<div className="hc-xp-row">
|
<div className="hc-xp-row">
|
||||||
<span className="hc-lvl-tag">Lv {level}</span>
|
<span className="hc-lvl-tag">Lv {level}</span>
|
||||||
@ -722,7 +805,6 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rank + collapsible quests — DEFAULT + QUEST_COMPACT */}
|
|
||||||
{showQuestCompact && (
|
{showQuestCompact && (
|
||||||
<>
|
<>
|
||||||
<div className="hc-rank-row" onClick={() => setOpen((o) => !o)}>
|
<div className="hc-rank-row" onClick={() => setOpen((o) => !o)}>
|
||||||
@ -755,33 +837,32 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|||||||
activeQuests.map(({ node, arc }) => {
|
activeQuests.map(({ node, arc }) => {
|
||||||
const pct = Math.min(
|
const pct = Math.min(
|
||||||
100,
|
100,
|
||||||
Math.round(
|
Math.round((node.current_value / node.req_target) * 100),
|
||||||
(node.progress / node.requirement.target) * 100,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const isClaimable = node.status === "claimable";
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={node.id}
|
key={node.node_id}
|
||||||
className="hc-quest-row"
|
className="hc-quest-row"
|
||||||
style={
|
style={{ "--ac": accentColor } as React.CSSProperties}
|
||||||
{ "--ac": arc.accentColor } as React.CSSProperties
|
|
||||||
}
|
|
||||||
onClick={() => !isClaimable && handleViewAll()}
|
onClick={() => !isClaimable && handleViewAll()}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`hc-q-icon${isClaimable ? " claimable" : ""}`}
|
className={`hc-q-icon${isClaimable ? " claimable" : ""}`}
|
||||||
>
|
>
|
||||||
{isClaimable ? "📦" : node.emoji}
|
{isClaimable ? "📦" : nodeEmoji}
|
||||||
</div>
|
</div>
|
||||||
<div className="hc-q-body">
|
<div className="hc-q-body">
|
||||||
<p className="hc-q-name">{node.title}</p>
|
<p className="hc-q-name">{node.name ?? "—"}</p>
|
||||||
{isClaimable ? (
|
{isClaimable ? (
|
||||||
<p className="hc-q-claimable">✨ Ready to claim!</p>
|
<p className="hc-q-claimable">✨ Ready to claim!</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="hc-q-sub">
|
<p className="hc-q-sub">
|
||||||
{node.progress}/{node.requirement.target}{" "}
|
{node.current_value}/{node.req_target} {reqLabel}{" "}
|
||||||
{node.requirement.label} · {pct}%
|
· {pct}%
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -804,8 +885,7 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="hc-map-link" onClick={handleViewAll}>
|
<div className="hc-map-link" onClick={handleViewAll}>
|
||||||
<Map size={13} />
|
<Map size={13} /> View quest map
|
||||||
View quest map
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -813,7 +893,11 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{claimingNode && (
|
{claimingNode && (
|
||||||
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} />
|
<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,19 +1,31 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState, Suspense } from "react";
|
||||||
import { Dialog, DialogContent, DialogHeader } from "../components/ui/dialog";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../components/ui/dialog";
|
||||||
import { api } from "../utils/api";
|
import { api } from "../utils/api";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
import { Loader, X } from "lucide-react";
|
import { Loader, X } from "lucide-react";
|
||||||
|
import { LESSON_COMPONENT_MAP } from "./FetchLessonPage";
|
||||||
|
import type { LessonId } from "./FetchLessonPage";
|
||||||
|
import type { LessonDetails } from "../types/lesson";
|
||||||
|
|
||||||
interface LessonModalProps {
|
interface LessonModalProps {
|
||||||
lessonId: string | null;
|
selectedLessonData: { id: string | null; name: string | null };
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UUIDs are video lessons; local lessons use readable keys like "ebrw-main-idea"
|
||||||
|
const UUID_REGEX =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
const isVideoLesson = (id: string) => UUID_REGEX.test(id);
|
||||||
|
|
||||||
const STYLES = `
|
const STYLES = `
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||||
|
|
||||||
/* Override Dialog defaults */
|
|
||||||
.lm-content {
|
.lm-content {
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: 'Nunito', sans-serif;
|
||||||
background: #fffbf4;
|
background: #fffbf4;
|
||||||
@ -29,14 +41,21 @@ const STYLES = `
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header bar */
|
@media (min-width: 1024px) {
|
||||||
|
.lm-content {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.lm-dialog-header-hidden {
|
||||||
|
position: absolute; width: 1px; height: 1px;
|
||||||
|
padding: 0; margin: -1px; overflow: hidden;
|
||||||
|
clip: rect(0,0,0,0); white-space: nowrap; border: 0;
|
||||||
|
}
|
||||||
.lm-header {
|
.lm-header {
|
||||||
display: flex; align-items: flex-start; justify-content: space-between;
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
padding: 1.25rem 1.5rem 0;
|
padding: 1.25rem 1.5rem 0; flex-shrink: 0; gap: 1rem;
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
.lm-title-wrap { display:flex;flex-direction:column;gap:0.2rem; flex:1; }
|
.lm-title-wrap { display:flex; flex-direction:column; gap:0.2rem; flex:1; }
|
||||||
.lm-eyebrow {
|
.lm-eyebrow {
|
||||||
font-size: 0.62rem; font-weight: 800; letter-spacing: 0.16em;
|
font-size: 0.62rem; font-weight: 800; letter-spacing: 0.16em;
|
||||||
text-transform: uppercase; color: #a855f7;
|
text-transform: uppercase; color: #a855f7;
|
||||||
@ -50,38 +69,26 @@ const STYLES = `
|
|||||||
border-radius: 50%; border: 2.5px solid #f3f4f6;
|
border-radius: 50%; border: 2.5px solid #f3f4f6;
|
||||||
background: white; cursor: pointer;
|
background: white; cursor: pointer;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.06); transition: all 0.15s ease;
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
}
|
||||||
.lm-close-btn:hover { border-color: #fecdd3; background: #fff1f2; }
|
.lm-close-btn:hover { border-color: #fecdd3; background: #fff1f2; }
|
||||||
|
|
||||||
/* Scrollable body */
|
|
||||||
.lm-body {
|
.lm-body {
|
||||||
overflow-y: auto;
|
overflow-y: auto; flex: 1;
|
||||||
flex: 1;
|
|
||||||
padding: 1rem 1.5rem 1.5rem;
|
padding: 1rem 1.5rem 1.5rem;
|
||||||
display: flex; flex-direction: column; gap: 1rem;
|
display: flex; flex-direction: column; gap: 1rem;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Video player */
|
|
||||||
.lm-video {
|
.lm-video {
|
||||||
width: 100%; border-radius: 18px;
|
width: 100%; border-radius: 18px;
|
||||||
aspect-ratio: 16/9; background: #1e1b4b;
|
aspect-ratio: 16/9; background: #1e1b4b; display: block;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Topic chip */
|
|
||||||
.lm-topic-chip {
|
.lm-topic-chip {
|
||||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||||
background: #f3e8ff; border: 2px solid #e9d5ff;
|
background: #f3e8ff; border: 2px solid #e9d5ff;
|
||||||
border-radius: 100px; padding: 0.3rem 0.8rem;
|
border-radius: 100px; padding: 0.3rem 0.8rem;
|
||||||
font-size: 0.7rem; font-weight: 800;
|
font-size: 0.7rem; font-weight: 800; letter-spacing: 0.08em;
|
||||||
letter-spacing: 0.08em; text-transform: uppercase;
|
text-transform: uppercase; color: #9333ea; width: fit-content;
|
||||||
color: #9333ea; width: fit-content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Description & content cards */
|
|
||||||
.lm-card {
|
.lm-card {
|
||||||
background: white; border: 2.5px solid #f3f4f6;
|
background: white; border: 2.5px solid #f3f4f6;
|
||||||
border-radius: 18px; padding: 1rem 1.1rem;
|
border-radius: 18px; padding: 1rem 1.1rem;
|
||||||
@ -93,119 +100,246 @@ const STYLES = `
|
|||||||
}
|
}
|
||||||
.lm-card-text {
|
.lm-card-text {
|
||||||
font-family: 'Nunito Sans', sans-serif;
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
font-size: 0.88rem; font-weight: 600; color: #374151;
|
font-size: 0.88rem; font-weight: 600; color: #374151; line-height: 1.6;
|
||||||
line-height: 1.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading state */
|
|
||||||
.lm-loading {
|
.lm-loading {
|
||||||
display: flex; flex-direction: column; align-items: center;
|
display: flex; flex-direction: column; align-items: center;
|
||||||
justify-content: center; gap: 0.75rem;
|
justify-content: center; gap: 0.75rem; padding: 3rem 1.5rem; flex: 1;
|
||||||
padding: 3rem 1.5rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
.lm-loading-spinner { animation: lmSpin 0.8s linear infinite; }
|
.lm-loading-spinner { animation: lmSpin 0.8s linear infinite; }
|
||||||
@keyframes lmSpin { to { transform: rotate(360deg); } }
|
@keyframes lmSpin { to { transform: rotate(360deg); } }
|
||||||
.lm-loading-text {
|
.lm-loading-text { font-size: 0.85rem; font-weight: 700; color: #9ca3af; }
|
||||||
font-size: 0.85rem; font-weight: 700; color: #9ca3af;
|
.lm-error {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; gap: 0.5rem;
|
||||||
|
padding: 3rem 1.5rem; text-align: center; flex: 1;
|
||||||
|
}
|
||||||
|
.lm-error-emoji { font-size: 2rem; }
|
||||||
|
.lm-error-text { font-size: 0.85rem; font-weight: 700; color: #9ca3af; }
|
||||||
|
|
||||||
|
/* Resources list */
|
||||||
|
.lm-resources { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.lm-resource-link {
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
padding: 0.6rem 0.8rem; border-radius: 12px;
|
||||||
|
background: #f5f3ff; border: 1.5px solid #e9d5ff;
|
||||||
|
color: #7c3aed; font-size: 0.8rem; font-weight: 700;
|
||||||
|
text-decoration: none; transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.lm-resource-link:hover { background: #ede9fe; }
|
||||||
|
|
||||||
|
/* Creator badge */
|
||||||
|
.lm-creator {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.75rem; font-weight: 600; color: #9ca3af;
|
||||||
|
}
|
||||||
|
.lm-creator-avatar {
|
||||||
|
width: 24px; height: 24px; border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #a855f7, #3b82f6);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 0.65rem; font-weight: 900; color: white; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const LoadingSpinner = () => (
|
||||||
|
<div className="lm-loading">
|
||||||
|
<Loader size={28} color="#a855f7" className="lm-loading-spinner" />
|
||||||
|
<p className="lm-loading-text">Loading lesson...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export const LessonModal = ({
|
export const LessonModal = ({
|
||||||
lessonId,
|
selectedLessonData,
|
||||||
open,
|
open,
|
||||||
|
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: LessonModalProps) => {
|
}: LessonModalProps) => {
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [lesson, setLesson] = useState<any>(null);
|
const [lesson, setLesson] = useState<LessonDetails | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const fetchingForId = useRef<string | null>(null);
|
||||||
|
const lessonId = selectedLessonData.id;
|
||||||
|
|
||||||
|
const LocalLessonComponent =
|
||||||
|
lessonId && !isVideoLesson(lessonId)
|
||||||
|
? LESSON_COMPONENT_MAP[lessonId as LessonId]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// const modalTitle = LocalLessonComponent
|
||||||
|
// ? getLocalLessonTitle(lessonId!)
|
||||||
|
// : loading
|
||||||
|
// ? "Loading..."
|
||||||
|
// : (lesson?.title ?? "Lesson");
|
||||||
|
|
||||||
|
const modalTitle =
|
||||||
|
selectedLessonData.name || selectedLessonData.id || "Lesson";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !lessonId || !user) return;
|
if (!open) {
|
||||||
|
setLesson(null);
|
||||||
|
setLoading(false);
|
||||||
|
setError(false);
|
||||||
|
fetchingForId.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lessonId || !user || LocalLessonComponent) return;
|
||||||
|
if (fetchingForId.current === lessonId) return;
|
||||||
|
|
||||||
const fetchLesson = async () => {
|
const fetchLesson = async () => {
|
||||||
|
fetchingForId.current = lessonId;
|
||||||
|
setLesson(null);
|
||||||
|
setError(false);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
|
||||||
const authStorage = localStorage.getItem("auth-storage");
|
const authStorage = localStorage.getItem("auth-storage");
|
||||||
if (!authStorage) return;
|
if (!authStorage) throw new Error("No auth storage");
|
||||||
const {
|
const {
|
||||||
|
// @ts-ignore
|
||||||
state: { token },
|
state: { token },
|
||||||
} = JSON.parse(authStorage) as { state?: { token?: string } };
|
} = JSON.parse(authStorage) as { state?: { token?: string } };
|
||||||
if (!token) return;
|
if (!token) throw new Error("No token");
|
||||||
const response = await api.fetchLessonById(token, lessonId);
|
|
||||||
|
// @ts-ignore
|
||||||
|
const response: LessonDetails = await api.fetchLessonById(
|
||||||
|
token,
|
||||||
|
lessonId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fetchingForId.current !== lessonId) return;
|
||||||
setLesson(response);
|
setLesson(response);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch lesson", err);
|
console.error("Failed to fetch lesson", err);
|
||||||
|
if (fetchingForId.current === lessonId) setError(true);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (fetchingForId.current === lessonId) setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchLesson();
|
fetchLesson();
|
||||||
}, [open, lessonId, user]);
|
}, [open, lessonId, user, LocalLessonComponent]);
|
||||||
|
|
||||||
|
// topic on LessonDetails is Topic[] — use the first entry
|
||||||
|
const topicName = Array.isArray(lesson?.topic)
|
||||||
|
? lesson.topic[0]?.name
|
||||||
|
: ((lesson?.topic as any)?.name ?? null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<style>{STYLES}</style>
|
<style>{STYLES}</style>
|
||||||
<DialogContent className="lm-content" showCloseButton={false}>
|
<DialogContent className="lm-content" showCloseButton={false}>
|
||||||
<DialogHeader style={{ display: "none" }} />
|
<DialogHeader className="lm-dialog-header-hidden">
|
||||||
|
<DialogTitle>{modalTitle}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="lm-header">
|
<div className="lm-header">
|
||||||
<div className="lm-title-wrap">
|
<div className="lm-title-wrap">
|
||||||
<span className="lm-eyebrow">📖 Lesson</span>
|
<span className="lm-eyebrow">📖 Lesson</span>
|
||||||
<h2 className="lm-title">
|
<h2 className="lm-title">{modalTitle}</h2>
|
||||||
{loading ? "Loading..." : (lesson?.title ?? "Lesson details")}
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<button className="lm-close-btn" onClick={() => onOpenChange(false)}>
|
<button className="lm-close-btn" onClick={() => onOpenChange(false)}>
|
||||||
<X size={16} color="#6b7280" />
|
<X size={16} color="#6b7280" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="lm-loading">
|
<LoadingSpinner />
|
||||||
<Loader size={28} color="#a855f7" className="lm-loading-spinner" />
|
) : error ? (
|
||||||
<p className="lm-loading-text">Loading lesson...</p>
|
<div className="lm-error">
|
||||||
|
<span className="lm-error-emoji">😕</span>
|
||||||
|
<p className="lm-error-text">
|
||||||
|
Couldn't load this lesson. Please try again.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
lesson && (
|
<div className="lm-body">
|
||||||
<div className="lm-body">
|
{LocalLessonComponent ? (
|
||||||
{lesson.video_url && (
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
<video src={lesson.video_url} controls className="lm-video" />
|
<LocalLessonComponent />
|
||||||
)}
|
</Suspense>
|
||||||
|
) : (
|
||||||
{lesson.topic?.name && (
|
lesson && (
|
||||||
<div>
|
<>
|
||||||
<span className="lm-topic-chip">
|
{/* Video */}
|
||||||
<span
|
{lesson.video_url && (
|
||||||
style={{
|
<video
|
||||||
width: 6,
|
src={lesson.video_url}
|
||||||
height: 6,
|
controls
|
||||||
borderRadius: "50%",
|
className="lm-video"
|
||||||
background: "#a855f7",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{lesson.topic.name}
|
)}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{lesson.description && (
|
{/* Topic chip */}
|
||||||
<div className="lm-card">
|
{topicName && (
|
||||||
<p className="lm-card-label">About this lesson</p>
|
<div>
|
||||||
<p className="lm-card-text">{lesson.description}</p>
|
<span className="lm-topic-chip">
|
||||||
</div>
|
<span
|
||||||
)}
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#a855f7",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{topicName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{lesson.content && (
|
{/* Description */}
|
||||||
<div className="lm-card">
|
{lesson.description && (
|
||||||
<p className="lm-card-label">Content</p>
|
<div className="lm-card">
|
||||||
<p className="lm-card-text">{lesson.content}</p>
|
<p className="lm-card-label">About this lesson</p>
|
||||||
</div>
|
<p className="lm-card-text">{lesson.description}</p>
|
||||||
)}
|
</div>
|
||||||
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
@ -281,6 +281,7 @@ const SectionDetail = ({
|
|||||||
<div className="psc-detail-card">
|
<div className="psc-detail-card">
|
||||||
<div className="psc-detail-top">
|
<div className="psc-detail-top">
|
||||||
<div className="psc-detail-icon-wrap" style={{ background: iconBg }}>
|
<div className="psc-detail-icon-wrap" style={{ background: iconBg }}>
|
||||||
|
{/* @ts-ignore */}
|
||||||
<Icon size={15} color={barColor} />
|
<Icon size={15} color={barColor} />
|
||||||
</div>
|
</div>
|
||||||
<span className="psc-detail-label">{label}</span>
|
<span className="psc-detail-label">{label}</span>
|
||||||
|
|||||||
@ -1,6 +1,30 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { X, Lock } from "lucide-react";
|
import { X, Lock } from "lucide-react";
|
||||||
import type { QuestNode } from "../types/quest";
|
import type { QuestNode, QuestArc } from "../types/quest";
|
||||||
|
// Re-use the same theme generator as QuestMap so island colours are consistent
|
||||||
|
import { generateArcTheme } from "../pages/student/QuestMap";
|
||||||
|
|
||||||
|
// ─── Requirement helpers (mirrors QuestMap / InfoHeader) ──────────────────────
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
const reqIcon = (type: string): string =>
|
||||||
|
({
|
||||||
|
questions: "❓",
|
||||||
|
accuracy: "🎯",
|
||||||
|
streak: "🔥",
|
||||||
|
sessions: "📚",
|
||||||
|
topics: "🗺️",
|
||||||
|
xp: "⚡",
|
||||||
|
leaderboard: "🏆",
|
||||||
|
})[type] ?? "⭐";
|
||||||
|
|
||||||
// ─── Styles ───────────────────────────────────────────────────────────────────
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||||
const STYLES = `
|
const STYLES = `
|
||||||
@ -30,11 +54,9 @@ const STYLES = `
|
|||||||
}
|
}
|
||||||
@keyframes qnmUp { from{transform:translateY(100%);opacity:0} to{transform:translateY(0);opacity:1} }
|
@keyframes qnmUp { from{transform:translateY(100%);opacity:0} to{transform:translateY(0);opacity:1} }
|
||||||
|
|
||||||
/* Handle */
|
|
||||||
.qnm-handle-row { display:flex; justify-content:center; padding:0.8rem 0 0.3rem; flex-shrink:0; }
|
.qnm-handle-row { display:flex; justify-content:center; padding:0.8rem 0 0.3rem; flex-shrink:0; }
|
||||||
.qnm-handle { width:38px; height:4px; border-radius:100px; background:rgba(255,255,255,0.12); }
|
.qnm-handle { width:38px; height:4px; border-radius:100px; background:rgba(255,255,255,0.12); }
|
||||||
|
|
||||||
/* Close btn */
|
|
||||||
.qnm-close {
|
.qnm-close {
|
||||||
position:absolute; top:0.9rem; right:1.1rem; z-index:10;
|
position:absolute; top:0.9rem; right:1.1rem; z-index:10;
|
||||||
width:30px; height:30px; border-radius:50%;
|
width:30px; height:30px; border-radius:50%;
|
||||||
@ -50,8 +72,6 @@ const STYLES = `
|
|||||||
height: 200px; overflow: hidden;
|
height: 200px; overflow: hidden;
|
||||||
background: linear-gradient(180deg, var(--sky-top) 0%, var(--sky-bot) 55%, var(--sea-col) 100%);
|
background: linear-gradient(180deg, var(--sky-top) 0%, var(--sky-bot) 55%, var(--sea-col) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sea waves */
|
|
||||||
.qnm-sea {
|
.qnm-sea {
|
||||||
position:absolute; bottom:0; left:0; right:0; height:52px;
|
position:absolute; bottom:0; left:0; right:0; height:52px;
|
||||||
background: var(--sea-col); overflow:hidden;
|
background: var(--sea-col); overflow:hidden;
|
||||||
@ -69,25 +89,19 @@ const STYLES = `
|
|||||||
50% { transform: translateX(15%) scaleY(1.08);}
|
50% { transform: translateX(15%) scaleY(1.08);}
|
||||||
100%{ transform: translateX(0) scaleY(1); }
|
100%{ transform: translateX(0) scaleY(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Floating clouds */
|
|
||||||
.qnm-cloud {
|
.qnm-cloud {
|
||||||
position:absolute; border-radius:50px;
|
position:absolute; border-radius:50px;
|
||||||
background: rgba(255,255,255,0.18);
|
background: rgba(255,255,255,0.18); filter: blur(4px);
|
||||||
filter: blur(4px);
|
|
||||||
animation: qnmDrift var(--cdur,18s) linear infinite;
|
animation: qnmDrift var(--cdur,18s) linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes qnmDrift {
|
@keyframes qnmDrift {
|
||||||
0% { transform: translateX(-120px); opacity:0; }
|
0% { transform: translateX(-120px); opacity:0; }
|
||||||
10% { opacity:1; }
|
10% { opacity:1; }
|
||||||
90% { opacity:1; }
|
90% { opacity:1; }
|
||||||
100%{ transform: translateX(calc(100vw + 120px)); opacity:0; }
|
100%{ transform: translateX(calc(100vw + 120px)); opacity:0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── The 3D island container ── */
|
|
||||||
.qnm-island-3d-wrap {
|
.qnm-island-3d-wrap {
|
||||||
position: absolute;
|
position: absolute; left: 50%; bottom: 40px;
|
||||||
left: 50%; bottom: 40px;
|
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
perspective: 420px;
|
perspective: 420px;
|
||||||
width: 220px; height: 140px;
|
width: 220px; height: 140px;
|
||||||
@ -102,16 +116,12 @@ const STYLES = `
|
|||||||
0% { transform: rotateX(22deg) rotateY(0deg); }
|
0% { transform: rotateX(22deg) rotateY(0deg); }
|
||||||
100% { transform: rotateX(22deg) rotateY(360deg); }
|
100% { transform: rotateX(22deg) rotateY(360deg); }
|
||||||
}
|
}
|
||||||
|
.qnm-il {
|
||||||
/* Island layers — stacked in 3D */
|
|
||||||
.qnm-il { /* island layer base class */
|
|
||||||
position: absolute; left: 50%; bottom: 0;
|
position: absolute; left: 50%; bottom: 0;
|
||||||
transform-origin: bottom center;
|
transform-origin: bottom center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transform-style: preserve-3d;
|
transform-style: preserve-3d;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Water base disc */
|
|
||||||
.qnm-il-water {
|
.qnm-il-water {
|
||||||
width: 200px; height: 44px; margin-left: -100px;
|
width: 200px; height: 44px; margin-left: -100px;
|
||||||
background: radial-gradient(ellipse 80% 100% at 50% 40%, var(--sea-hi), var(--sea-col));
|
background: radial-gradient(ellipse 80% 100% at 50% 40%, var(--sea-hi), var(--sea-col));
|
||||||
@ -120,12 +130,7 @@ const STYLES = `
|
|||||||
box-shadow: 0 0 40px var(--sea-col);
|
box-shadow: 0 0 40px var(--sea-col);
|
||||||
animation: qnmWaterShimmer 3s ease-in-out infinite;
|
animation: qnmWaterShimmer 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@keyframes qnmWaterShimmer {
|
@keyframes qnmWaterShimmer { 0%,100%{ opacity:1; } 50%{ opacity:0.82; } }
|
||||||
0%,100%{ opacity:1; }
|
|
||||||
50% { opacity:0.82; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ripple rings on water */
|
|
||||||
.qnm-ripple {
|
.qnm-ripple {
|
||||||
position:absolute; left:50%; top:50%;
|
position:absolute; left:50%; top:50%;
|
||||||
border-radius:50%; border:1.5px solid rgba(255,255,255,0.25);
|
border-radius:50%; border:1.5px solid rgba(255,255,255,0.25);
|
||||||
@ -136,8 +141,6 @@ const STYLES = `
|
|||||||
0% { width:60px; height:20px; margin-left:-30px; margin-top:-10px; opacity:0.7; }
|
0% { width:60px; height:20px; margin-left:-30px; margin-top:-10px; opacity:0.7; }
|
||||||
100%{ width:180px; height:60px; margin-left:-90px; margin-top:-30px; opacity:0; }
|
100%{ width:180px; height:60px; margin-left:-90px; margin-top:-30px; opacity:0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Island ground */
|
|
||||||
.qnm-il-ground {
|
.qnm-il-ground {
|
||||||
width: 160px; height: 36px; margin-left: -80px;
|
width: 160px; height: 36px; margin-left: -80px;
|
||||||
background: radial-gradient(ellipse at 40% 30%, var(--terr-hi), var(--terr-mid) 55%, var(--terr-lo));
|
background: radial-gradient(ellipse at 40% 30%, var(--terr-hi), var(--terr-mid) 55%, var(--terr-lo));
|
||||||
@ -145,8 +148,6 @@ const STYLES = `
|
|||||||
transform: translateZ(14px);
|
transform: translateZ(14px);
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.55), inset 0 -4px 8px rgba(0,0,0,0.25);
|
box-shadow: 0 8px 24px rgba(0,0,0,0.55), inset 0 -4px 8px rgba(0,0,0,0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Island side face — gives the 3D depth illusion */
|
|
||||||
.qnm-il-side {
|
.qnm-il-side {
|
||||||
width: 158px; height: 22px; margin-left: -79px;
|
width: 158px; height: 22px; margin-left: -79px;
|
||||||
bottom: -12px;
|
bottom: -12px;
|
||||||
@ -154,8 +155,6 @@ const STYLES = `
|
|||||||
clip-path: ellipse(79px 100% at 50% 0%);
|
clip-path: ellipse(79px 100% at 50% 0%);
|
||||||
transform: translateZ(8px) rotateX(-8deg);
|
transform: translateZ(8px) rotateX(-8deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Peak */
|
|
||||||
.qnm-il-peak {
|
.qnm-il-peak {
|
||||||
width: 80px; height: 60px; margin-left: -40px;
|
width: 80px; height: 60px; margin-left: -40px;
|
||||||
bottom: 26px;
|
bottom: 26px;
|
||||||
@ -169,28 +168,18 @@ const STYLES = `
|
|||||||
0%,100%{ transform: translateZ(26px) translateY(0); }
|
0%,100%{ transform: translateZ(26px) translateY(0); }
|
||||||
50% { transform: translateZ(26px) translateY(-4px); }
|
50% { transform: translateZ(26px) translateY(-4px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Floating decoration layer (trees, cactus, cloud orb, etc.) */
|
|
||||||
.qnm-il-deco {
|
.qnm-il-deco {
|
||||||
position: absolute; bottom: 56px; left: 50%;
|
position: absolute; bottom: 56px; left: 50%;
|
||||||
transform: translateZ(42px);
|
transform: translateZ(42px);
|
||||||
animation: qnmDecoFloat 3s ease-in-out infinite;
|
animation: qnmDecoFloat 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@keyframes qnmDecoFloat {
|
@keyframes qnmDecoFloat {
|
||||||
0%,100%{ transform: translateZ(42px) translateY(0) rotate(0deg); }
|
0%,100%{ transform: translateZ(42px) translateY(0) rotate(0deg); }
|
||||||
50% { transform: translateZ(42px) translateY(-7px) rotate(3deg); }
|
50% { transform: translateZ(42px) translateY(-7px) rotate(3deg); }
|
||||||
}
|
}
|
||||||
.qnm-deco-emoji { font-size:1.4rem; filter:drop-shadow(0 4px 8px rgba(0,0,0,0.5)); }
|
.qnm-deco-emoji { font-size:1.4rem; filter:drop-shadow(0 4px 8px rgba(0,0,0,0.5)); }
|
||||||
|
.qnm-il-flag { position:absolute; bottom:56px; left:50%; transform: translateZ(50px) translateX(12px); }
|
||||||
/* Flag pole on active */
|
.qnm-flag-pole { width:2px; height:26px; background:#7c4a1e; border-radius:2px; }
|
||||||
.qnm-il-flag {
|
|
||||||
position:absolute; bottom:56px; left:50%;
|
|
||||||
transform: translateZ(50px) translateX(12px);
|
|
||||||
}
|
|
||||||
.qnm-flag-pole {
|
|
||||||
width:2px; height:26px; background:#7c4a1e;
|
|
||||||
border-radius:2px;
|
|
||||||
}
|
|
||||||
.qnm-flag-cloth {
|
.qnm-flag-cloth {
|
||||||
position:absolute; top:2px; left:2px;
|
position:absolute; top:2px; left:2px;
|
||||||
width:16px; height:11px;
|
width:16px; height:11px;
|
||||||
@ -198,19 +187,14 @@ const STYLES = `
|
|||||||
animation: qnmFlagWave 1.2s ease-in-out infinite;
|
animation: qnmFlagWave 1.2s ease-in-out infinite;
|
||||||
transform-origin:left center;
|
transform-origin:left center;
|
||||||
}
|
}
|
||||||
@keyframes qnmFlagWave {
|
@keyframes qnmFlagWave { 0%,100%{ transform:skewY(0deg); } 50%{ transform:skewY(-10deg); } }
|
||||||
0%,100%{ transform:skewY(0deg); }
|
|
||||||
50% { transform:skewY(-10deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stars / sparkles above completed island */
|
|
||||||
.qnm-star {
|
.qnm-star {
|
||||||
position:absolute; font-size:1rem;
|
position:absolute; font-size:1rem;
|
||||||
animation: qnmStarPop var(--sdur,2s) ease-in-out infinite;
|
animation: qnmStarPop var(--sdur,2s) ease-in-out infinite;
|
||||||
animation-delay: var(--sdel,0s);
|
animation-delay: var(--sdel,0s);
|
||||||
}
|
}
|
||||||
@keyframes qnmStarPop {
|
@keyframes qnmStarPop {
|
||||||
0%,100%{ transform:scale(1) translateY(0); opacity:0.8; }
|
0%,100%{ transform:scale(1) translateY(0); opacity:0.8; }
|
||||||
50% { transform:scale(1.4) translateY(-8px); opacity:1; }
|
50% { transform:scale(1.4) translateY(-8px); opacity:1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,8 +205,6 @@ const STYLES = `
|
|||||||
padding:1.1rem 1.25rem 0.5rem;
|
padding:1.1rem 1.25rem 0.5rem;
|
||||||
}
|
}
|
||||||
.qnm-body::-webkit-scrollbar { display:none; }
|
.qnm-body::-webkit-scrollbar { display:none; }
|
||||||
|
|
||||||
/* Title block */
|
|
||||||
.qnm-title-block { position:relative; }
|
.qnm-title-block { position:relative; }
|
||||||
.qnm-arc-tag {
|
.qnm-arc-tag {
|
||||||
display:inline-flex; align-items:center; gap:0.3rem;
|
display:inline-flex; align-items:center; gap:0.3rem;
|
||||||
@ -240,8 +222,6 @@ const STYLES = `
|
|||||||
font-family:'Nunito Sans',sans-serif;
|
font-family:'Nunito Sans',sans-serif;
|
||||||
font-size:0.72rem; font-weight:700; color:rgba(255,255,255,0.38);
|
font-size:0.72rem; font-weight:700; color:rgba(255,255,255,0.38);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Flavour quote */
|
|
||||||
.qnm-flavour {
|
.qnm-flavour {
|
||||||
background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07);
|
background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07);
|
||||||
border-left:3px solid var(--ac);
|
border-left:3px solid var(--ac);
|
||||||
@ -253,8 +233,6 @@ const STYLES = `
|
|||||||
font-size:0.82rem; color:rgba(255,255,255,0.55);
|
font-size:0.82rem; color:rgba(255,255,255,0.55);
|
||||||
font-style:italic; line-height:1.6;
|
font-style:italic; line-height:1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Objective card */
|
|
||||||
.qnm-obj-card {
|
.qnm-obj-card {
|
||||||
background:rgba(255,255,255,0.04);
|
background:rgba(255,255,255,0.04);
|
||||||
border:1px solid rgba(255,255,255,0.08);
|
border:1px solid rgba(255,255,255,0.08);
|
||||||
@ -271,9 +249,7 @@ const STYLES = `
|
|||||||
font-family:'Nunito',sans-serif;
|
font-family:'Nunito',sans-serif;
|
||||||
font-size:0.78rem; font-weight:900; color:var(--ac);
|
font-size:0.78rem; font-weight:900; color:var(--ac);
|
||||||
}
|
}
|
||||||
.qnm-obj-row {
|
.qnm-obj-row { display:flex; align-items:center; gap:0.65rem; margin-bottom:0.7rem; }
|
||||||
display:flex; align-items:center; gap:0.65rem; margin-bottom:0.7rem;
|
|
||||||
}
|
|
||||||
.qnm-obj-icon {
|
.qnm-obj-icon {
|
||||||
width:38px; height:38px; border-radius:12px; flex-shrink:0;
|
width:38px; height:38px; border-radius:12px; flex-shrink:0;
|
||||||
background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.08);
|
background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.08);
|
||||||
@ -287,8 +263,6 @@ const STYLES = `
|
|||||||
font-family:'Nunito Sans',sans-serif;
|
font-family:'Nunito Sans',sans-serif;
|
||||||
font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.35); margin-top:0.05rem;
|
font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.35); margin-top:0.05rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Progress bar */
|
|
||||||
.qnm-bar-track {
|
.qnm-bar-track {
|
||||||
height:9px; background:rgba(255,255,255,0.07);
|
height:9px; background:rgba(255,255,255,0.07);
|
||||||
border-radius:100px; overflow:hidden; margin-bottom:0.3rem;
|
border-radius:100px; overflow:hidden; margin-bottom:0.3rem;
|
||||||
@ -305,21 +279,16 @@ const STYLES = `
|
|||||||
font-size:0.65rem; font-weight:800; color:rgba(255,255,255,0.28);
|
font-size:0.65rem; font-weight:800; color:rgba(255,255,255,0.28);
|
||||||
}
|
}
|
||||||
.qnm-bar-nums span:first-child { color:var(--ac); }
|
.qnm-bar-nums span:first-child { color:var(--ac); }
|
||||||
|
|
||||||
/* ── HOW TO COMPLETE section ── */
|
|
||||||
.qnm-howto-label {
|
.qnm-howto-label {
|
||||||
font-size:0.58rem; font-weight:800; letter-spacing:0.14em;
|
font-size:0.58rem; font-weight:800; letter-spacing:0.14em;
|
||||||
text-transform:uppercase; color:rgba(255,255,255,0.3);
|
text-transform:uppercase; color:rgba(255,255,255,0.3);
|
||||||
margin-bottom:0.55rem; margin-top:0.3rem;
|
margin-bottom:0.55rem; margin-top:0.3rem;
|
||||||
}
|
}
|
||||||
.qnm-howto-badges {
|
.qnm-howto-badges { display:flex; flex-wrap:wrap; gap:0.4rem; }
|
||||||
display:flex; flex-wrap:wrap; gap:0.4rem;
|
|
||||||
}
|
|
||||||
.qnm-howto-badge {
|
.qnm-howto-badge {
|
||||||
display:flex; align-items:center; gap:0.3rem;
|
display:flex; align-items:center; gap:0.3rem;
|
||||||
padding:0.38rem 0.75rem;
|
padding:0.38rem 0.75rem;
|
||||||
background:rgba(255,255,255,0.06);
|
background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.1);
|
||||||
border:1px solid rgba(255,255,255,0.1);
|
|
||||||
border-radius:100px;
|
border-radius:100px;
|
||||||
font-family:'Nunito',sans-serif;
|
font-family:'Nunito',sans-serif;
|
||||||
font-size:0.72rem; font-weight:800; color:rgba(255,255,255,0.7);
|
font-size:0.72rem; font-weight:800; color:rgba(255,255,255,0.7);
|
||||||
@ -332,19 +301,14 @@ const STYLES = `
|
|||||||
to { opacity:1; transform:scale(1) translateY(0); }
|
to { opacity:1; transform:scale(1) translateY(0); }
|
||||||
}
|
}
|
||||||
.qnm-howto-badge:hover {
|
.qnm-howto-badge:hover {
|
||||||
background:rgba(255,255,255,0.1);
|
background:rgba(255,255,255,0.1); border-color:rgba(255,255,255,0.2);
|
||||||
border-color:rgba(255,255,255,0.2);
|
color:white; transform:translateY(-1px);
|
||||||
color:white;
|
|
||||||
transform:translateY(-1px);
|
|
||||||
}
|
}
|
||||||
/* Highlight badge = accent coloured */
|
|
||||||
.qnm-howto-badge.hi {
|
.qnm-howto-badge.hi {
|
||||||
background:color-mix(in srgb, var(--ac) 18%, transparent);
|
background:color-mix(in srgb, var(--ac) 18%, transparent);
|
||||||
border-color:color-mix(in srgb, var(--ac) 45%, transparent);
|
border-color:color-mix(in srgb, var(--ac) 45%, transparent);
|
||||||
color:var(--ac);
|
color:var(--ac);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Locked banner */
|
|
||||||
.qnm-locked-banner {
|
.qnm-locked-banner {
|
||||||
display:flex; align-items:center; gap:0.7rem;
|
display:flex; align-items:center; gap:0.7rem;
|
||||||
background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07);
|
background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07);
|
||||||
@ -362,11 +326,8 @@ const STYLES = `
|
|||||||
font-family:'Nunito Sans',sans-serif;
|
font-family:'Nunito Sans',sans-serif;
|
||||||
font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.22); margin-top:0.1rem;
|
font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.22); margin-top:0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reward card */
|
|
||||||
.qnm-reward-card {
|
.qnm-reward-card {
|
||||||
background:rgba(251,191,36,0.07);
|
background:rgba(251,191,36,0.07); border:1px solid rgba(251,191,36,0.22);
|
||||||
border:1px solid rgba(251,191,36,0.22);
|
|
||||||
border-radius:18px; padding:0.9rem 1rem;
|
border-radius:18px; padding:0.9rem 1rem;
|
||||||
}
|
}
|
||||||
.qnm-reward-label {
|
.qnm-reward-label {
|
||||||
@ -409,71 +370,12 @@ const STYLES = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ─── Per-arc terrain themes ───────────────────────────────────────────────────
|
// ─── How-to badges ────────────────────────────────────────────────────────────
|
||||||
interface Terrain {
|
|
||||||
skyTop: string;
|
|
||||||
skyBot: string;
|
|
||||||
seaCol: string;
|
|
||||||
seaHi: string;
|
|
||||||
terrHi: string;
|
|
||||||
terrMid: string;
|
|
||||||
terrLo: string;
|
|
||||||
peakHi: string;
|
|
||||||
peakMid: string;
|
|
||||||
peakLo: string;
|
|
||||||
decos: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const TERRAIN: Record<string, Terrain> = {
|
|
||||||
east_blue: {
|
|
||||||
skyTop: "#0a1628",
|
|
||||||
skyBot: "#0d2240",
|
|
||||||
seaCol: "#0a3d5c",
|
|
||||||
seaHi: "#1a6a8a",
|
|
||||||
terrHi: "#5eead4",
|
|
||||||
terrMid: "#0d9488",
|
|
||||||
terrLo: "#0f5c55",
|
|
||||||
peakHi: "#a7f3d0",
|
|
||||||
peakMid: "#34d399",
|
|
||||||
peakLo: "#065f46",
|
|
||||||
decos: ["🌴", "🌿"],
|
|
||||||
},
|
|
||||||
alabasta: {
|
|
||||||
skyTop: "#1c0a00",
|
|
||||||
skyBot: "#3d1a00",
|
|
||||||
seaCol: "#7c3a00",
|
|
||||||
seaHi: "#c26010",
|
|
||||||
terrHi: "#fde68a",
|
|
||||||
terrMid: "#d97706",
|
|
||||||
terrLo: "#78350f",
|
|
||||||
peakHi: "#fef3c7",
|
|
||||||
peakMid: "#fbbf24",
|
|
||||||
peakLo: "#92400e",
|
|
||||||
decos: ["🌵", "🏺"],
|
|
||||||
},
|
|
||||||
skypiea: {
|
|
||||||
skyTop: "#1a0033",
|
|
||||||
skyBot: "#2e0050",
|
|
||||||
seaCol: "#4c1d95",
|
|
||||||
seaHi: "#7c3aed",
|
|
||||||
terrHi: "#e9d5ff",
|
|
||||||
terrMid: "#a855f7",
|
|
||||||
terrLo: "#581c87",
|
|
||||||
peakHi: "#f5d0fe",
|
|
||||||
peakMid: "#d946ef",
|
|
||||||
peakLo: "#701a75",
|
|
||||||
decos: ["☁️", "✨"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const DEFAULT_TERRAIN = TERRAIN.east_blue;
|
|
||||||
|
|
||||||
// ─── Per-requirement how-to badges ───────────────────────────────────────────
|
|
||||||
interface Badge {
|
interface Badge {
|
||||||
emoji: string;
|
emoji: string;
|
||||||
label: string;
|
label: string;
|
||||||
highlight?: boolean;
|
highlight?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOW_TO: Record<string, { title: string; badges: Badge[] }> = {
|
const HOW_TO: Record<string, { title: string; badges: Badge[] }> = {
|
||||||
questions: {
|
questions: {
|
||||||
title: "How to complete this",
|
title: "How to complete this",
|
||||||
@ -540,12 +442,7 @@ const HOW_TO: Record<string, { title: string; badges: Badge[] }> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Island shape configs (mirrors the 6 clip-path shapes in QuestMap) ────────
|
// ─── Island shape configs (mirrors QuestMap SHAPES[0..5]) ─────────────────────
|
||||||
// groundClip = clip-path for the flat top disc of the island
|
|
||||||
// peakClip = clip-path for the hill/feature rising above it
|
|
||||||
// groundW/H = pixel size of the ground layer
|
|
||||||
// peakW/H = pixel size of the peak layer
|
|
||||||
// sideClip = clip-path for the side-face depth layer
|
|
||||||
interface ShapeConfig {
|
interface ShapeConfig {
|
||||||
groundClip: string;
|
groundClip: string;
|
||||||
peakClip: string;
|
peakClip: string;
|
||||||
@ -554,12 +451,9 @@ interface ShapeConfig {
|
|||||||
groundH: number;
|
groundH: number;
|
||||||
peakW: number;
|
peakW: number;
|
||||||
peakH: number;
|
peakH: number;
|
||||||
peakBottom: number; // translateZ bottom offset in px
|
peakBottom: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// These correspond 1-to-1 with SHAPES[0..5] in QuestMap.tsx
|
|
||||||
const ISLAND_SHAPES: ShapeConfig[] = [
|
const ISLAND_SHAPES: ShapeConfig[] = [
|
||||||
// 0: fat round atoll
|
|
||||||
{
|
{
|
||||||
groundClip: "ellipse(50% 50% at 50% 50%)",
|
groundClip: "ellipse(50% 50% at 50% 50%)",
|
||||||
peakClip: "ellipse(50% 50% at 50% 55%)",
|
peakClip: "ellipse(50% 50% at 50% 55%)",
|
||||||
@ -570,7 +464,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
|
|||||||
peakH: 38,
|
peakH: 38,
|
||||||
peakBottom: 26,
|
peakBottom: 26,
|
||||||
},
|
},
|
||||||
// 1: tall mountain — narrow diamond ground, sharp triangular peak
|
|
||||||
{
|
{
|
||||||
groundClip: "polygon(50% 5%, 92% 50%, 50% 95%, 8% 50%)",
|
groundClip: "polygon(50% 5%, 92% 50%, 50% 95%, 8% 50%)",
|
||||||
peakClip: "polygon(50% 0%, 82% 52%, 100% 100%, 0% 100%, 18% 52%)",
|
peakClip: "polygon(50% 0%, 82% 52%, 100% 100%, 0% 100%, 18% 52%)",
|
||||||
@ -581,7 +474,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
|
|||||||
peakH: 72,
|
peakH: 72,
|
||||||
peakBottom: 24,
|
peakBottom: 24,
|
||||||
},
|
},
|
||||||
// 2: wide flat shoal — extra-wide squashed ellipse, low dome
|
|
||||||
{
|
{
|
||||||
groundClip: "ellipse(50% 40% at 50% 58%)",
|
groundClip: "ellipse(50% 40% at 50% 58%)",
|
||||||
peakClip: "ellipse(50% 38% at 50% 60%)",
|
peakClip: "ellipse(50% 38% at 50% 60%)",
|
||||||
@ -592,7 +484,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
|
|||||||
peakH: 28,
|
peakH: 28,
|
||||||
peakBottom: 22,
|
peakBottom: 22,
|
||||||
},
|
},
|
||||||
// 3: jagged rocky reef — star-burst polygon
|
|
||||||
{
|
{
|
||||||
groundClip:
|
groundClip:
|
||||||
"polygon(50% 2%, 63% 35%, 98% 35%, 71% 56%, 80% 92%, 50% 72%, 20% 92%, 29% 56%, 2% 35%, 37% 35%)",
|
"polygon(50% 2%, 63% 35%, 98% 35%, 71% 56%, 80% 92%, 50% 72%, 20% 92%, 29% 56%, 2% 35%, 37% 35%)",
|
||||||
@ -605,7 +496,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
|
|||||||
peakH: 66,
|
peakH: 66,
|
||||||
peakBottom: 24,
|
peakBottom: 24,
|
||||||
},
|
},
|
||||||
// 4: crescent — lopsided asymmetric bean
|
|
||||||
{
|
{
|
||||||
groundClip:
|
groundClip:
|
||||||
"path('M 80 10 C 120 5, 150 30, 145 55 C 140 78, 110 88, 80 85 C 55 82, 38 70, 42 55 C 46 42, 62 40, 68 50 C 74 60, 65 70, 55 68 C 38 62, 30 42, 42 28 C 55 12, 70 12, 80 10 Z')",
|
"path('M 80 10 C 120 5, 150 30, 145 55 C 140 78, 110 88, 80 85 C 55 82, 38 70, 42 55 C 46 42, 62 40, 68 50 C 74 60, 65 70, 55 68 C 38 62, 30 42, 42 28 C 55 12, 70 12, 80 10 Z')",
|
||||||
@ -617,7 +507,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
|
|||||||
peakH: 58,
|
peakH: 58,
|
||||||
peakBottom: 22,
|
peakBottom: 22,
|
||||||
},
|
},
|
||||||
// 5: teardrop/pear — narrow top, wide rounded base
|
|
||||||
{
|
{
|
||||||
groundClip:
|
groundClip:
|
||||||
"path('M 50 4 C 72 4, 95 28, 95 55 C 95 78, 76 94, 50 94 C 24 94, 5 78, 5 55 C 5 28, 28 4, 50 4 Z')",
|
"path('M 50 4 C 72 4, 95 28, 95 55 C 95 78, 76 94, 50 94 C 24 94, 5 78, 5 55 C 5 28, 28 4, 50 4 Z')",
|
||||||
@ -632,29 +521,104 @@ const ISLAND_SHAPES: ShapeConfig[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Terrain type (mirrors ArcTheme.terrain from QuestMap) ────────────────────
|
||||||
const reqIcon = (type: string): string =>
|
interface StageTerrain {
|
||||||
({
|
skyTop: string;
|
||||||
questions: "❓",
|
skyBot: string;
|
||||||
accuracy: "🎯",
|
seaCol: string;
|
||||||
streak: "🔥",
|
seaHi: string;
|
||||||
sessions: "📚",
|
terrHi: string;
|
||||||
topics: "🗺️",
|
terrMid: string;
|
||||||
xp: "⚡",
|
terrLo: string;
|
||||||
leaderboard: "🏆",
|
peakHi: string;
|
||||||
})[type] ?? "⭐";
|
peakMid: string;
|
||||||
|
peakLo: string;
|
||||||
|
decos: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the ArcTheme colours produced by generateArcTheme into the
|
||||||
|
* StageTerrain shape the 3D stage needs. For the three known arcs we keep
|
||||||
|
* hand-tuned sky/sea values; for unknown arcs we derive them from the theme.
|
||||||
|
*/
|
||||||
|
const KNOWN_STAGE_TERRAIN: Record<string, StageTerrain> = {
|
||||||
|
east_blue: {
|
||||||
|
skyTop: "#0a1628",
|
||||||
|
skyBot: "#0d2240",
|
||||||
|
seaCol: "#0a3d5c",
|
||||||
|
seaHi: "#1a6a8a",
|
||||||
|
terrHi: "#5eead4",
|
||||||
|
terrMid: "#0d9488",
|
||||||
|
terrLo: "#0f5c55",
|
||||||
|
peakHi: "#a7f3d0",
|
||||||
|
peakMid: "#34d399",
|
||||||
|
peakLo: "#065f46",
|
||||||
|
decos: ["🌴", "🌿"],
|
||||||
|
},
|
||||||
|
alabasta: {
|
||||||
|
skyTop: "#1c0a00",
|
||||||
|
skyBot: "#3d1a00",
|
||||||
|
seaCol: "#7c3a00",
|
||||||
|
seaHi: "#c26010",
|
||||||
|
terrHi: "#fde68a",
|
||||||
|
terrMid: "#d97706",
|
||||||
|
terrLo: "#78350f",
|
||||||
|
peakHi: "#fef3c7",
|
||||||
|
peakMid: "#fbbf24",
|
||||||
|
peakLo: "#92400e",
|
||||||
|
decos: ["🌵", "🏺"],
|
||||||
|
},
|
||||||
|
skypiea: {
|
||||||
|
skyTop: "#1a0033",
|
||||||
|
skyBot: "#2e0050",
|
||||||
|
seaCol: "#4c1d95",
|
||||||
|
seaHi: "#7c3aed",
|
||||||
|
terrHi: "#e9d5ff",
|
||||||
|
terrMid: "#a855f7",
|
||||||
|
terrLo: "#581c87",
|
||||||
|
peakHi: "#f5d0fe",
|
||||||
|
peakMid: "#d946ef",
|
||||||
|
peakLo: "#701a75",
|
||||||
|
decos: ["☁️", "✨"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Derive a StageTerrain from a generated arc theme for unknown arc ids. */
|
||||||
|
const terrainFromTheme = (arcId: string, arc: QuestArc): StageTerrain => {
|
||||||
|
if (KNOWN_STAGE_TERRAIN[arcId]) return KNOWN_STAGE_TERRAIN[arcId];
|
||||||
|
const theme = generateArcTheme(arc);
|
||||||
|
return {
|
||||||
|
// Sky: very dark version of the theme bg colours
|
||||||
|
skyTop: theme.bgFrom,
|
||||||
|
skyBot: theme.bgTo,
|
||||||
|
// Sea: use accentDark as the deep sea colour, accent as the highlight
|
||||||
|
seaCol: theme.accentDark,
|
||||||
|
seaHi: theme.accent,
|
||||||
|
// Terrain: map terrain colours directly
|
||||||
|
terrHi: theme.terrain.l,
|
||||||
|
terrMid: theme.terrain.m,
|
||||||
|
terrLo: theme.terrain.d,
|
||||||
|
// Peak: lighten accent for highlights, use terrain dark for shadow
|
||||||
|
peakHi: theme.accent,
|
||||||
|
peakMid: theme.terrain.m,
|
||||||
|
peakLo: theme.terrain.d,
|
||||||
|
decos: theme.decos.slice(0, 2),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// ─── 3D Island Stage ──────────────────────────────────────────────────────────
|
// ─── 3D Island Stage ──────────────────────────────────────────────────────────
|
||||||
const IslandStage = ({
|
const IslandStage = ({
|
||||||
|
arc,
|
||||||
arcId,
|
arcId,
|
||||||
status,
|
status,
|
||||||
nodeIndex,
|
nodeIndex,
|
||||||
}: {
|
}: {
|
||||||
|
arc: QuestArc;
|
||||||
arcId: string;
|
arcId: string;
|
||||||
status: QuestNode["status"];
|
status: string;
|
||||||
nodeIndex: number;
|
nodeIndex: number;
|
||||||
}) => {
|
}) => {
|
||||||
const t = TERRAIN[arcId] ?? DEFAULT_TERRAIN;
|
const t = terrainFromTheme(arcId, arc);
|
||||||
const shp = ISLAND_SHAPES[nodeIndex % ISLAND_SHAPES.length];
|
const shp = ISLAND_SHAPES[nodeIndex % ISLAND_SHAPES.length];
|
||||||
|
|
||||||
const isCompleted = status === "completed";
|
const isCompleted = status === "completed";
|
||||||
@ -715,7 +679,7 @@ const IslandStage = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ripple rings on water surface */}
|
{/* Ripple rings */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@ -737,15 +701,9 @@ const IslandStage = ({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="qnm-island-3d"
|
className="qnm-island-3d"
|
||||||
style={{
|
style={{ animationPlayState: isLocked ? "paused" : "running" }}
|
||||||
// Pause rotation when locked
|
|
||||||
animationPlayState: isLocked ? "paused" : "running",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Water base */}
|
|
||||||
<div className="qnm-il qnm-il-water" />
|
<div className="qnm-il qnm-il-water" />
|
||||||
|
|
||||||
{/* Island side face */}
|
|
||||||
<div
|
<div
|
||||||
className="qnm-il qnm-il-side"
|
className="qnm-il qnm-il-side"
|
||||||
style={{
|
style={{
|
||||||
@ -754,8 +712,6 @@ const IslandStage = ({
|
|||||||
clipPath: shp.sideClip,
|
clipPath: shp.sideClip,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Island ground — shaped to match QuestMap */}
|
|
||||||
<div
|
<div
|
||||||
className="qnm-il qnm-il-ground"
|
className="qnm-il qnm-il-ground"
|
||||||
style={{
|
style={{
|
||||||
@ -767,7 +723,6 @@ const IslandStage = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Peak / hill — shaped to match QuestMap */}
|
|
||||||
{!isLocked && (
|
{!isLocked && (
|
||||||
<div
|
<div
|
||||||
className="qnm-il qnm-il-peak"
|
className="qnm-il qnm-il-peak"
|
||||||
@ -796,15 +751,12 @@ const IslandStage = ({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Pirate flag on active */}
|
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div className="qnm-il-flag">
|
<div className="qnm-il-flag">
|
||||||
<div className="qnm-flag-pole" />
|
<div className="qnm-flag-pole" />
|
||||||
<div className="qnm-flag-cloth" />
|
<div className="qnm-flag-cloth" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chest bouncing on claimable */}
|
|
||||||
{isClaimable && (
|
{isClaimable && (
|
||||||
<div className="qnm-il-deco" style={{ marginLeft: "-12px" }}>
|
<div className="qnm-il-deco" style={{ marginLeft: "-12px" }}>
|
||||||
<span
|
<span
|
||||||
@ -818,8 +770,6 @@ const IslandStage = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Lock icon on locked */}
|
|
||||||
{isLocked && (
|
{isLocked && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -838,31 +788,28 @@ const IslandStage = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sparkles for completed */}
|
{/* Sparkles for completed */}
|
||||||
{isCompleted && (
|
{isCompleted &&
|
||||||
<>
|
[
|
||||||
{[
|
{ left: "30%", top: "18%", sdur: "2s", sdel: "0s" },
|
||||||
{ left: "30%", top: "18%", sdur: "2s", sdel: "0s" },
|
{ left: "62%", top: "12%", sdur: "2.4s", sdel: "0.6s" },
|
||||||
{ left: "62%", top: "12%", sdur: "2.4s", sdel: "0.6s" },
|
{ left: "20%", top: "38%", sdur: "1.8s", sdel: "1.1s" },
|
||||||
{ left: "20%", top: "38%", sdur: "1.8s", sdel: "1.1s" },
|
{ left: "74%", top: "32%", sdur: "2.2s", sdel: "0.3s" },
|
||||||
{ left: "74%", top: "32%", sdur: "2.2s", sdel: "0.3s" },
|
].map((s, i) => (
|
||||||
].map((s, i) => (
|
<span
|
||||||
<span
|
key={i}
|
||||||
key={i}
|
className="qnm-star"
|
||||||
className="qnm-star"
|
style={
|
||||||
style={
|
{
|
||||||
{
|
left: s.left,
|
||||||
left: s.left,
|
top: s.top,
|
||||||
top: s.top,
|
"--sdur": s.sdur,
|
||||||
"--sdur": s.sdur,
|
"--sdel": s.sdel,
|
||||||
"--sdel": s.sdel,
|
} as React.CSSProperties
|
||||||
} as React.CSSProperties
|
}
|
||||||
}
|
>
|
||||||
>
|
✨
|
||||||
✨
|
</span>
|
||||||
</span>
|
))}
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Lock overlay tint */}
|
{/* Lock overlay tint */}
|
||||||
{isLocked && (
|
{isLocked && (
|
||||||
@ -886,6 +833,7 @@ const IslandStage = ({
|
|||||||
// ─── Main component ───────────────────────────────────────────────────────────
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
interface Props {
|
interface Props {
|
||||||
node: QuestNode;
|
node: QuestNode;
|
||||||
|
arc: QuestArc; // full arc object needed for theme generation
|
||||||
arcAccent: string;
|
arcAccent: string;
|
||||||
arcDark: string;
|
arcDark: string;
|
||||||
arcId?: string;
|
arcId?: string;
|
||||||
@ -896,8 +844,8 @@ interface Props {
|
|||||||
|
|
||||||
export const QuestNodeModal = ({
|
export const QuestNodeModal = ({
|
||||||
node,
|
node,
|
||||||
|
arc,
|
||||||
arcAccent,
|
arcAccent,
|
||||||
arcDark,
|
|
||||||
arcId = "east_blue",
|
arcId = "east_blue",
|
||||||
nodeIndex = 0,
|
nodeIndex = 0,
|
||||||
onClose,
|
onClose,
|
||||||
@ -908,15 +856,19 @@ export const QuestNodeModal = ({
|
|||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ── New field names ──────────────────────────────────────────────────────
|
||||||
const progress = Math.min(
|
const progress = Math.min(
|
||||||
100,
|
100,
|
||||||
Math.round((node.progress / node.requirement.target) * 100),
|
Math.round((node.current_value / node.req_target) * 100),
|
||||||
);
|
);
|
||||||
|
const reqLabel = REQ_LABEL[node.req_type] ?? node.req_type;
|
||||||
|
const howTo = HOW_TO[node.req_type];
|
||||||
|
const remaining = Math.max(0, node.req_target - node.current_value);
|
||||||
|
|
||||||
const isClaimable = node.status === "claimable";
|
const isClaimable = node.status === "claimable";
|
||||||
const isLocked = node.status === "locked";
|
const isLocked = node.status === "locked";
|
||||||
const isCompleted = node.status === "completed";
|
const isCompleted = node.status === "completed";
|
||||||
const isActive = node.status === "active";
|
const isActive = node.status === "active";
|
||||||
const howTo = HOW_TO[node.requirement.type];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -934,24 +886,32 @@ export const QuestNodeModal = ({
|
|||||||
<X size={13} color="rgba(255,255,255,0.5)" />
|
<X size={13} color="rgba(255,255,255,0.5)" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 3D island stage */}
|
{/* 3D island stage — now receives full arc for theme generation */}
|
||||||
<IslandStage arcId={arcId} status={node.status} nodeIndex={nodeIndex} />
|
<IslandStage
|
||||||
|
arc={arc}
|
||||||
|
arcId={arcId}
|
||||||
|
status={node.status}
|
||||||
|
nodeIndex={nodeIndex}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Scrollable content */}
|
{/* Scrollable content */}
|
||||||
<div className="qnm-body">
|
<div className="qnm-body">
|
||||||
{/* Title */}
|
{/* Title block */}
|
||||||
<div className="qnm-title-block">
|
<div className="qnm-title-block">
|
||||||
<div className="qnm-arc-tag">
|
{/* req_type replaces node.requirement.type */}
|
||||||
{reqIcon(node.requirement.type)} Quest
|
<div className="qnm-arc-tag">{reqIcon(node.req_type)} Quest</div>
|
||||||
</div>
|
{/* node.name replaces node.title */}
|
||||||
<h2 className="qnm-quest-title">{node.title}</h2>
|
<h2 className="qnm-quest-title">{node.name ?? "—"}</h2>
|
||||||
<p className="qnm-island-name">📍 {node.islandName}</p>
|
{/* node.islandName removed — reuse node.name as location label */}
|
||||||
|
<p className="qnm-island-name">📍 {node.name ?? "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Flavour */}
|
{/* Flavour — node.description replaces node.flavourText */}
|
||||||
<div className="qnm-flavour">
|
{node.description && (
|
||||||
<p className="qnm-flavour-text">{node.flavourText}</p>
|
<div className="qnm-flavour">
|
||||||
</div>
|
<p className="qnm-flavour-text">{node.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Objective */}
|
{/* Objective */}
|
||||||
<div className="qnm-obj-card">
|
<div className="qnm-obj-card">
|
||||||
@ -964,19 +924,18 @@ export const QuestNodeModal = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="qnm-obj-row">
|
<div className="qnm-obj-row">
|
||||||
<div className="qnm-obj-icon">
|
<div className="qnm-obj-icon">{reqIcon(node.req_type)}</div>
|
||||||
{reqIcon(node.requirement.type)}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
|
{/* req_target + derived label replace node.requirement.target/label */}
|
||||||
<p className="qnm-obj-text">
|
<p className="qnm-obj-text">
|
||||||
{node.requirement.target} {node.requirement.label}
|
{node.req_target} {reqLabel}
|
||||||
</p>
|
</p>
|
||||||
<p className="qnm-obj-sub">
|
<p className="qnm-obj-sub">
|
||||||
{isCompleted
|
{isCompleted
|
||||||
? "✅ Completed — treasure claimed!"
|
? "✅ Completed — treasure claimed!"
|
||||||
: isLocked
|
: isLocked
|
||||||
? "🔒 Complete previous quests first"
|
? "🔒 Complete previous quests first"
|
||||||
: `${node.progress} / ${node.requirement.target} done`}
|
: `${node.current_value} / ${node.req_target} done`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -990,14 +949,15 @@ export const QuestNodeModal = ({
|
|||||||
style={{ width: mounted ? `${progress}%` : "0%" }}
|
style={{ width: mounted ? `${progress}%` : "0%" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* current_value / req_target replace old progress / requirement.target */}
|
||||||
<div className="qnm-bar-nums">
|
<div className="qnm-bar-nums">
|
||||||
<span>{node.progress}</span>
|
<span>{node.current_value}</span>
|
||||||
<span>{node.requirement.target}</span>
|
<span>{node.req_target}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* How-to badges — show when active or claimable */}
|
{/* How-to badges */}
|
||||||
{(isActive || isClaimable) && howTo && (
|
{(isActive || isClaimable) && howTo && (
|
||||||
<>
|
<>
|
||||||
<p className="qnm-howto-label" style={{ marginTop: "0.75rem" }}>
|
<p className="qnm-howto-label" style={{ marginTop: "0.75rem" }}>
|
||||||
@ -1036,19 +996,26 @@ export const QuestNodeModal = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reward */}
|
{/* Reward — sources from flat node reward fields */}
|
||||||
<div className="qnm-reward-card">
|
<div className="qnm-reward-card">
|
||||||
<p className="qnm-reward-label">📦 Treasure Chest</p>
|
<p className="qnm-reward-label">📦 Treasure Chest</p>
|
||||||
<div className="qnm-reward-row">
|
<div className="qnm-reward-row">
|
||||||
<div className="qnm-reward-pill">⚡ +{node.reward.xp} XP</div>
|
{/* reward_coins replaces node.reward.xp */}
|
||||||
{node.reward.title && (
|
{node.reward_coins > 0 && (
|
||||||
<div className="qnm-reward-pill">🏴☠️ {node.reward.title}</div>
|
<div className="qnm-reward-pill">🪙 +{node.reward_coins}</div>
|
||||||
)}
|
)}
|
||||||
{node.reward.itemLabel && (
|
{/* reward_title is now a nested object, not a string */}
|
||||||
|
{node.reward_title?.name && (
|
||||||
<div className="qnm-reward-pill">
|
<div className="qnm-reward-pill">
|
||||||
🎁 {node.reward.itemLabel}
|
🏴☠️ {node.reward_title.name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* reward_items is now an array — show one pill per item */}
|
||||||
|
{node.reward_items?.map((inv) => (
|
||||||
|
<div key={inv.id} className="qnm-reward-pill">
|
||||||
|
🎁 {inv.item.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1064,9 +1031,9 @@ export const QuestNodeModal = ({
|
|||||||
) : isLocked ? (
|
) : isLocked ? (
|
||||||
<p className="qnm-note">🔒 Locked — keep sailing</p>
|
<p className="qnm-note">🔒 Locked — keep sailing</p>
|
||||||
) : (
|
) : (
|
||||||
|
/* remaining replaces node.requirement.target - node.progress */
|
||||||
<p className="qnm-note">
|
<p className="qnm-note">
|
||||||
{progress}% complete · {node.requirement.target - node.progress}{" "}
|
{progress}% complete · {remaining} {reqLabel} remaining
|
||||||
{node.requirement.label} remaining
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,507 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import type { QuestNode, QuestArc } from "../types/quest";
|
|
||||||
import { CREW_RANKS } from "../types/quest";
|
|
||||||
import {
|
|
||||||
useQuestStore,
|
|
||||||
getQuestSummary,
|
|
||||||
getCrewRank,
|
|
||||||
} from "../stores/useQuestStore";
|
|
||||||
import { ChestOpenModal } from "./ChestOpenModal";
|
|
||||||
|
|
||||||
// ─── Styles ───────────────────────────────────────────────────────────────────
|
|
||||||
const STYLES = `
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700;900&family=Sorts+Mill+Goudy:ital@0;1&family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
|
||||||
|
|
||||||
/* ══ CARD SHELL ══ */
|
|
||||||
.qpc2-card {
|
|
||||||
position: relative; overflow: hidden;
|
|
||||||
border-radius: 24px;
|
|
||||||
background: linear-gradient(160deg, #0b1a35 0%, #060e1f 55%, #0d1530 100%);
|
|
||||||
border: 1.5px solid rgba(251,191,36,0.2);
|
|
||||||
box-shadow:
|
|
||||||
0 8px 32px rgba(0,0,0,0.35),
|
|
||||||
0 0 0 1px rgba(255,255,255,0.04) inset,
|
|
||||||
0 1px 0 rgba(255,255,255,0.08) inset;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animated sea shimmer behind everything */
|
|
||||||
.qpc2-sea {
|
|
||||||
position: absolute; inset: 0; pointer-events: none; z-index: 0;
|
|
||||||
background:
|
|
||||||
repeating-linear-gradient(105deg, transparent 0%, transparent 55%,
|
|
||||||
rgba(56,189,248,0.022) 56%, transparent 57%),
|
|
||||||
repeating-linear-gradient(75deg, transparent 0%, transparent 70%,
|
|
||||||
rgba(56,189,248,0.014) 71%, transparent 72%);
|
|
||||||
background-size: 300% 300%, 250% 250%;
|
|
||||||
animation: qpc2Sea 12s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
@keyframes qpc2Sea {
|
|
||||||
0% { background-position: 0% 0%, 100% 0%; }
|
|
||||||
100% { background-position: 100% 100%, 0% 100%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Faint gold orb top-right */
|
|
||||||
.qpc2-orb {
|
|
||||||
position: absolute; top: -40px; right: -30px;
|
|
||||||
width: 160px; height: 160px; border-radius: 50%;
|
|
||||||
background: radial-gradient(circle, rgba(251,191,36,0.14) 0%, transparent 70%);
|
|
||||||
pointer-events: none; z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══ RANK HERO (always visible) ══ */
|
|
||||||
.qpc2-hero {
|
|
||||||
position: relative; z-index: 2;
|
|
||||||
padding: 1rem 1.1rem 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.18s ease;
|
|
||||||
}
|
|
||||||
.qpc2-hero:hover { background: rgba(255,255,255,0.025); }
|
|
||||||
|
|
||||||
.qpc2-hero-row {
|
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.qpc2-hero-left { display: flex; align-items: center; gap: 0.75rem; flex: 1; min-width: 0; }
|
|
||||||
.qpc2-hero-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
|
||||||
|
|
||||||
/* Rank badge icon */
|
|
||||||
.qpc2-rank-icon {
|
|
||||||
width: 44px; height: 44px; border-radius: 14px; flex-shrink: 0;
|
|
||||||
background: linear-gradient(135deg, #1e0e4a, #3730a3);
|
|
||||||
border: 1.5px solid rgba(251,191,36,0.35);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 1.35rem;
|
|
||||||
box-shadow: 0 4px 0 rgba(30,14,74,0.7), 0 0 16px rgba(251,191,36,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qpc2-rank-label {
|
|
||||||
font-family: 'Cinzel', serif;
|
|
||||||
font-size: 0.78rem; font-weight: 700;
|
|
||||||
color: rgba(255,255,255,0.45); letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase; margin-bottom: 0.1rem;
|
|
||||||
}
|
|
||||||
.qpc2-rank-name {
|
|
||||||
font-family: 'Sorts Mill Goudy', serif;
|
|
||||||
font-size: 1.05rem; font-weight: 700;
|
|
||||||
color: #fbbf24;
|
|
||||||
text-shadow: 0 0 18px rgba(251,191,36,0.45);
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Rank progress bar */
|
|
||||||
.qpc2-rank-bar-wrap {
|
|
||||||
margin-top: 0.55rem;
|
|
||||||
display: flex; align-items: center; gap: 0.6rem;
|
|
||||||
}
|
|
||||||
.qpc2-rank-bar-track {
|
|
||||||
flex: 1; height: 5px; border-radius: 100px;
|
|
||||||
background: rgba(255,255,255,0.1); overflow: hidden;
|
|
||||||
}
|
|
||||||
.qpc2-rank-bar-fill {
|
|
||||||
height: 100%; border-radius: 100px;
|
|
||||||
background: linear-gradient(90deg, #fbbf24, #f59e0b);
|
|
||||||
box-shadow: 0 0 8px rgba(251,191,36,0.5);
|
|
||||||
transition: width 0.7s cubic-bezier(0.34,1.56,0.64,1);
|
|
||||||
}
|
|
||||||
.qpc2-rank-bar-label {
|
|
||||||
font-family: 'Nunito Sans', sans-serif;
|
|
||||||
font-size: 0.6rem; font-weight: 700;
|
|
||||||
color: rgba(255,255,255,0.35); white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stats row */
|
|
||||||
.qpc2-stats {
|
|
||||||
display: flex; gap: 0.5rem; margin-top: 0.75rem;
|
|
||||||
padding-top: 0.7rem;
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.07);
|
|
||||||
}
|
|
||||||
.qpc2-stat {
|
|
||||||
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 0.1rem;
|
|
||||||
}
|
|
||||||
.qpc2-stat-val {
|
|
||||||
font-family: 'Nunito', sans-serif;
|
|
||||||
font-size: 0.95rem; font-weight: 900; color: #fbbf24;
|
|
||||||
}
|
|
||||||
.qpc2-stat-lbl {
|
|
||||||
font-family: 'Nunito Sans', sans-serif;
|
|
||||||
font-size: 0.56rem; font-weight: 700;
|
|
||||||
color: rgba(255,255,255,0.35); text-align: center;
|
|
||||||
letter-spacing: 0.06em; text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.qpc2-stat-div {
|
|
||||||
width: 1px; background: rgba(255,255,255,0.08); margin: 0.1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chest badge */
|
|
||||||
.qpc2-chest-badge {
|
|
||||||
display: flex; align-items: center; gap: 0.22rem;
|
|
||||||
padding: 0.22rem 0.6rem;
|
|
||||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
|
||||||
border-radius: 100px;
|
|
||||||
font-family: 'Nunito', sans-serif;
|
|
||||||
font-size: 0.65rem; font-weight: 900; color: #1a0800;
|
|
||||||
box-shadow: 0 2px 0 #d97706, 0 0 10px rgba(251,191,36,0.35);
|
|
||||||
animation: qpc2ChestPop 1.8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes qpc2ChestPop {
|
|
||||||
0%,100%{ transform: scale(1); }
|
|
||||||
50% { transform: scale(1.07); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Expand chevron */
|
|
||||||
.qpc2-chevron {
|
|
||||||
color: rgba(255,255,255,0.35);
|
|
||||||
transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), color 0.2s;
|
|
||||||
}
|
|
||||||
.qpc2-chevron.open { transform: rotate(180deg); color: #fbbf24; }
|
|
||||||
|
|
||||||
/* ══ COLLAPSIBLE BODY ══ */
|
|
||||||
.qpc2-body {
|
|
||||||
position: relative; z-index: 2;
|
|
||||||
overflow: hidden;
|
|
||||||
max-height: 0;
|
|
||||||
transition: max-height 0.4s cubic-bezier(0.4,0,0.2,1);
|
|
||||||
}
|
|
||||||
.qpc2-body.open { max-height: 600px; }
|
|
||||||
|
|
||||||
.qpc2-divider {
|
|
||||||
height: 1px; background: rgba(255,255,255,0.07); margin: 0 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══ QUEST ROWS ══ */
|
|
||||||
.qpc2-quest-list { display: flex; flex-direction: column; padding: 0.5rem 0; }
|
|
||||||
|
|
||||||
.qpc2-quest-row {
|
|
||||||
display: flex; align-items: center; gap: 0.7rem;
|
|
||||||
padding: 0.75rem 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.qpc2-quest-row:hover { background: rgba(255,255,255,0.03); }
|
|
||||||
|
|
||||||
/* Left accent line = arc colour */
|
|
||||||
.qpc2-quest-row::before {
|
|
||||||
content: ''; position: absolute; left: 0; top: 16%; bottom: 16%;
|
|
||||||
width: 3px; border-radius: 0 3px 3px 0;
|
|
||||||
background: var(--ac);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qpc2-quest-icon {
|
|
||||||
width: 38px; height: 38px; border-radius: 12px; flex-shrink: 0;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
border: 1.5px solid rgba(255,255,255,0.08);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
.qpc2-quest-row:hover .qpc2-quest-icon { transform: scale(1.1) rotate(-5deg); }
|
|
||||||
.qpc2-quest-icon.claimable {
|
|
||||||
background: rgba(251,191,36,0.12);
|
|
||||||
border-color: rgba(251,191,36,0.4);
|
|
||||||
animation: qpc2Wiggle 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes qpc2Wiggle {
|
|
||||||
0%,100%{ transform: rotate(0deg); }
|
|
||||||
25% { transform: rotate(-8deg) scale(1.06); }
|
|
||||||
75% { transform: rotate(8deg) scale(1.06); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.qpc2-quest-body { flex: 1; min-width: 0; }
|
|
||||||
.qpc2-quest-arc {
|
|
||||||
font-size: 0.57rem; font-weight: 800; letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase; color: var(--ac);
|
|
||||||
margin-bottom: 0.08rem;
|
|
||||||
}
|
|
||||||
.qpc2-quest-title {
|
|
||||||
font-family: 'Sorts Mill Goudy', serif;
|
|
||||||
font-size: 0.82rem; font-weight: 700; color: rgba(255,255,255,0.9);
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
margin-bottom: 0.28rem;
|
|
||||||
}
|
|
||||||
.qpc2-mini-track {
|
|
||||||
height: 4px; background: rgba(255,255,255,0.08);
|
|
||||||
border-radius: 100px; overflow: hidden; margin-bottom: 0.18rem;
|
|
||||||
}
|
|
||||||
.qpc2-mini-fill {
|
|
||||||
height: 100%; border-radius: 100px;
|
|
||||||
background: var(--ac);
|
|
||||||
box-shadow: 0 0 5px color-mix(in srgb, var(--ac) 55%, transparent);
|
|
||||||
transition: width 0.5s cubic-bezier(0.34,1.56,0.64,1);
|
|
||||||
}
|
|
||||||
.qpc2-mini-label {
|
|
||||||
font-family: 'Nunito Sans', sans-serif;
|
|
||||||
font-size: 0.58rem; font-weight: 700; color: rgba(255,255,255,0.3);
|
|
||||||
}
|
|
||||||
.qpc2-claimable-label {
|
|
||||||
font-family: 'Nunito Sans', sans-serif;
|
|
||||||
font-size: 0.62rem; font-weight: 700; color: #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Claim button */
|
|
||||||
.qpc2-claim-btn {
|
|
||||||
padding: 0.32rem 0.7rem; border: none; border-radius: 100px; cursor: pointer;
|
|
||||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
|
||||||
font-family: 'Nunito', sans-serif;
|
|
||||||
font-size: 0.65rem; font-weight: 900; color: #1a0800;
|
|
||||||
box-shadow: 0 2px 0 #d97706, 0 3px 8px rgba(251,191,36,0.25);
|
|
||||||
flex-shrink: 0; white-space: nowrap;
|
|
||||||
transition: all 0.12s ease;
|
|
||||||
}
|
|
||||||
.qpc2-claim-btn:hover { transform: translateY(-1px); box-shadow: 0 3px 0 #d97706; }
|
|
||||||
.qpc2-claim-btn:active { transform: translateY(1px); }
|
|
||||||
|
|
||||||
/* ══ FOOTER LINK ══ */
|
|
||||||
.qpc2-footer {
|
|
||||||
position: relative; z-index: 2;
|
|
||||||
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
|
|
||||||
padding: 0.65rem 1.1rem;
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.07);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
}
|
|
||||||
.qpc2-footer:hover { background: rgba(255,255,255,0.03); }
|
|
||||||
.qpc2-footer-label {
|
|
||||||
font-family: 'Nunito', sans-serif;
|
|
||||||
font-size: 0.72rem; font-weight: 800;
|
|
||||||
color: rgba(251,191,36,0.7);
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.qpc2-footer:hover .qpc2-footer-label { color: #fbbf24; }
|
|
||||||
|
|
||||||
/* ══ EMPTY STATE ══ */
|
|
||||||
.qpc2-empty {
|
|
||||||
padding: 1.25rem 1.1rem; text-align: center;
|
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 0.35rem;
|
|
||||||
}
|
|
||||||
.qpc2-empty-title {
|
|
||||||
font-family: 'Sorts Mill Goudy', serif;
|
|
||||||
font-size: 0.88rem; font-weight: 700; color: rgba(255,255,255,0.55);
|
|
||||||
}
|
|
||||||
.qpc2-empty-sub {
|
|
||||||
font-family: 'Nunito Sans', sans-serif;
|
|
||||||
font-size: 0.68rem; font-weight: 600; color: rgba(255,255,255,0.25);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
function getActiveQuests(arcs: QuestArc[]) {
|
|
||||||
const results: { node: QuestNode; arc: QuestArc }[] = [];
|
|
||||||
for (const arc of arcs) {
|
|
||||||
for (const node of arc.nodes) {
|
|
||||||
if (node.status === "claimable" || node.status === "active") {
|
|
||||||
results.push({ node, arc });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Claimable first, then active; max 2 shown
|
|
||||||
results.sort((a, b) => {
|
|
||||||
if (a.node.status === "claimable" && b.node.status !== "claimable")
|
|
||||||
return -1;
|
|
||||||
if (b.node.status === "claimable" && a.node.status !== "claimable")
|
|
||||||
return 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
return results.slice(0, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Component ────────────────────────────────────────────────────────────────
|
|
||||||
interface Props {
|
|
||||||
onViewAll?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const QuestProgressCard = ({ onViewAll }: Props) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const arcs = useQuestStore((s) => s.arcs);
|
|
||||||
const claimNode = useQuestStore((s) => s.claimNode);
|
|
||||||
|
|
||||||
const summary = getQuestSummary(arcs);
|
|
||||||
const rank = getCrewRank(arcs);
|
|
||||||
const activeQuests = getActiveQuests(arcs);
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [claimingNode, setClaimingNode] = useState<{
|
|
||||||
node: QuestNode;
|
|
||||||
arcId: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const handleViewAll = () => {
|
|
||||||
if (onViewAll) onViewAll();
|
|
||||||
else navigate("/student/quests");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClaim = (node: QuestNode, arcId: string) => {
|
|
||||||
setClaimingNode({ node, arcId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChestClose = () => {
|
|
||||||
if (!claimingNode) return;
|
|
||||||
claimNode(claimingNode.arcId, claimingNode.node.id);
|
|
||||||
setClaimingNode(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Next rank label
|
|
||||||
const nextRankLabel = rank.next
|
|
||||||
? `${Math.round(rank.progressToNext * 100)}% to ${rank.next.label}`
|
|
||||||
: "Max rank reached";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<style>{STYLES}</style>
|
|
||||||
|
|
||||||
<div className="qpc2-card">
|
|
||||||
{/* Atmosphere layers */}
|
|
||||||
<div className="qpc2-sea" />
|
|
||||||
<div className="qpc2-orb" />
|
|
||||||
|
|
||||||
{/* ── Rank hero (always visible, tap to expand) ── */}
|
|
||||||
<div className="qpc2-hero" onClick={() => setOpen((o) => !o)}>
|
|
||||||
<div className="qpc2-hero-row">
|
|
||||||
<div className="qpc2-hero-left">
|
|
||||||
<div className="qpc2-rank-icon">{rank.emoji}</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<p className="qpc2-rank-label">Crew Rank</p>
|
|
||||||
<p className="qpc2-rank-name">{rank.label}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="qpc2-hero-right">
|
|
||||||
{summary.claimableNodes > 0 && (
|
|
||||||
<div className="qpc2-chest-badge">
|
|
||||||
📦 {summary.claimableNodes}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ChevronDown
|
|
||||||
size={18}
|
|
||||||
className={`qpc2-chevron${open ? " open" : ""}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rank progress bar */}
|
|
||||||
<div className="qpc2-rank-bar-wrap">
|
|
||||||
<div className="qpc2-rank-bar-track">
|
|
||||||
<div
|
|
||||||
className="qpc2-rank-bar-fill"
|
|
||||||
style={{ width: `${Math.round(rank.progressToNext * 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="qpc2-rank-bar-label">{nextRankLabel}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats strip */}
|
|
||||||
<div className="qpc2-stats">
|
|
||||||
{[
|
|
||||||
{ val: `${summary.earnedXP}`, lbl: "XP Earned" },
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
val: `${summary.completedNodes}/${summary.totalNodes}`,
|
|
||||||
lbl: "Quests Done",
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
val: `${summary.arcsCompleted}/${summary.totalArcs}`,
|
|
||||||
lbl: "Arcs",
|
|
||||||
},
|
|
||||||
].map((item, i) =>
|
|
||||||
item === null ? (
|
|
||||||
<div key={i} className="qpc2-stat-div" />
|
|
||||||
) : (
|
|
||||||
<div key={i} className="qpc2-stat">
|
|
||||||
<span className="qpc2-stat-val">{item.val}</span>
|
|
||||||
<span className="qpc2-stat-lbl">{item.lbl}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Collapsible quest list ── */}
|
|
||||||
<div className={`qpc2-body${open ? " open" : ""}`}>
|
|
||||||
<div className="qpc2-divider" />
|
|
||||||
<div className="qpc2-quest-list">
|
|
||||||
{activeQuests.length === 0 ? (
|
|
||||||
<div className="qpc2-empty">
|
|
||||||
<span style={{ fontSize: "1.75rem" }}>⚓</span>
|
|
||||||
<p className="qpc2-empty-title">All caught up, Captain!</p>
|
|
||||||
<p className="qpc2-empty-sub">
|
|
||||||
No active quests — keep sailing
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
activeQuests.map(({ node, arc }) => {
|
|
||||||
const pct = Math.min(
|
|
||||||
100,
|
|
||||||
Math.round((node.progress / node.requirement.target) * 100),
|
|
||||||
);
|
|
||||||
const isClaimable = node.status === "claimable";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={node.id}
|
|
||||||
className="qpc2-quest-row"
|
|
||||||
style={{ "--ac": arc.accentColor } as React.CSSProperties}
|
|
||||||
onClick={() => !isClaimable && handleViewAll()}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`qpc2-quest-icon${isClaimable ? " claimable" : ""}`}
|
|
||||||
>
|
|
||||||
{isClaimable ? "📦" : node.emoji}
|
|
||||||
</div>
|
|
||||||
<div className="qpc2-quest-body">
|
|
||||||
<p className="qpc2-quest-arc">
|
|
||||||
{arc.emoji} {arc.name}
|
|
||||||
</p>
|
|
||||||
<p className="qpc2-quest-title">{node.title}</p>
|
|
||||||
{isClaimable ? (
|
|
||||||
<p className="qpc2-claimable-label">
|
|
||||||
✨ Chest ready to open!
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="qpc2-mini-track">
|
|
||||||
<div
|
|
||||||
className="qpc2-mini-fill"
|
|
||||||
style={{ width: `${pct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="qpc2-mini-label">
|
|
||||||
{node.progress} / {node.requirement.target}{" "}
|
|
||||||
{node.requirement.label}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isClaimable ? (
|
|
||||||
<button
|
|
||||||
className="qpc2-claim-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleClaim(node, arc.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open 📦
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<ChevronRight size={14} color="rgba(255,255,255,0.2)" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer — navigate to full map */}
|
|
||||||
<div className="qpc2-footer" onClick={handleViewAll}>
|
|
||||||
<span className="qpc2-footer-label">View full quest map</span>
|
|
||||||
<ChevronRight size={14} color="rgba(251,191,36,0.7)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{claimingNode && (
|
|
||||||
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
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";
|
import { BlockMath, InlineMath } from "react-katex";
|
||||||
|
|
||||||
|
// ─── Error boundary ───────────────────────────────────────────────────────────
|
||||||
|
// react-katex throws synchronously during render for invalid LaTeX, so a class
|
||||||
|
// error boundary is the only reliable way to catch it.
|
||||||
|
|
||||||
|
interface MathErrorBoundaryProps {
|
||||||
|
raw: string; // the original LaTeX string, shown as fallback
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MathErrorBoundaryState {
|
||||||
|
failed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MathErrorBoundary extends Component<
|
||||||
|
MathErrorBoundaryProps,
|
||||||
|
MathErrorBoundaryState
|
||||||
|
> {
|
||||||
|
state: MathErrorBoundaryState = { failed: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): MathErrorBoundaryState {
|
||||||
|
return { failed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.failed) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={`Could not render: ${this.props.raw}`}
|
||||||
|
style={{
|
||||||
|
fontFamily: "monospace",
|
||||||
|
background: "rgba(239,68,68,0.08)",
|
||||||
|
border: "1px solid rgba(239,68,68,0.3)",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "0 4px",
|
||||||
|
color: "#f87171",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.props.raw}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Renderer ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const renderQuestionText = (text: string) => {
|
export const renderQuestionText = (text: string) => {
|
||||||
const parts = text.split(/(\$\$.*?\$\$|\$.*?\$)/g);
|
if (!text) return null;
|
||||||
|
const parts = text.split(/(\$\$.*?\$\$|\$.*?\$)/gs);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{parts.map((part, index) => {
|
{parts.map((part, index) => {
|
||||||
if (part.startsWith("$$")) {
|
if (part.startsWith("$$")) {
|
||||||
return <BlockMath key={index}>{part.slice(2, -2)}</BlockMath>;
|
const latex = part.slice(2, -2);
|
||||||
|
return (
|
||||||
|
<MathErrorBoundary key={index} raw={part}>
|
||||||
|
<BlockMath>{latex}</BlockMath>
|
||||||
|
</MathErrorBoundary>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (part.startsWith("$")) {
|
if (part.startsWith("$")) {
|
||||||
return <InlineMath key={index}>{part.slice(1, -1)}</InlineMath>;
|
const latex = part.slice(1, -1);
|
||||||
|
return (
|
||||||
|
<MathErrorBoundary key={index} raw={part}>
|
||||||
|
<InlineMath>{latex}</InlineMath>
|
||||||
|
</MathErrorBoundary>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return <span key={index}>{part}</span>;
|
return <span key={index}>{part}</span>;
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -28,7 +28,7 @@ interface Props {
|
|||||||
// ─── Nav items ────────────────────────────────────────────────────────────────
|
// ─── Nav items ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const NAV_ITEMS: (SearchItem & {
|
const NAV_ITEMS: (SearchItem & {
|
||||||
icon: React.ElementType;
|
icon: React.ComponentType<any>;
|
||||||
color: string;
|
color: string;
|
||||||
bg: string;
|
bg: string;
|
||||||
})[] = [
|
})[] = [
|
||||||
@ -490,6 +490,7 @@ export const SearchOverlay = ({
|
|||||||
className="so-item-icon"
|
className="so-item-icon"
|
||||||
style={{ background: bg }}
|
style={{ background: bg }}
|
||||||
>
|
>
|
||||||
|
{/* @ts-ignore */}
|
||||||
<Icon size={16} color={color} />
|
<Icon size={16} color={color} />
|
||||||
</div>
|
</div>
|
||||||
<div className="so-item-body">
|
<div className="so-item-body">
|
||||||
@ -517,6 +518,7 @@ export const SearchOverlay = ({
|
|||||||
className="so-quick-chip"
|
className="so-quick-chip"
|
||||||
onClick={() => handleSelect(item)}
|
onClick={() => handleSelect(item)}
|
||||||
>
|
>
|
||||||
|
{/* @ts-ignore */}
|
||||||
<item.icon size={13} color={item.color} />
|
<item.icon size={13} color={item.color} />
|
||||||
{item.title}
|
{item.title}
|
||||||
</button>
|
</button>
|
||||||
@ -533,6 +535,7 @@ export const SearchOverlay = ({
|
|||||||
.filter((s) => s.user_status === "IN_PROGRESS")
|
.filter((s) => s.user_status === "IN_PROGRESS")
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((sheet) => {
|
.map((sheet) => {
|
||||||
|
// @ts-ignore
|
||||||
const item: SearchItem = {
|
const item: SearchItem = {
|
||||||
type: "sheet",
|
type: "sheet",
|
||||||
title: sheet.title,
|
title: sheet.title,
|
||||||
@ -602,8 +605,9 @@ export const SearchOverlay = ({
|
|||||||
const Icon = navMeta?.icon ?? BookOpen;
|
const Icon = navMeta?.icon ?? BookOpen;
|
||||||
const iconColor = navMeta?.color ?? "#a855f7";
|
const iconColor = navMeta?.color ?? "#a855f7";
|
||||||
const iconBg = navMeta?.bg ?? "#fdf4ff";
|
const iconBg = navMeta?.bg ?? "#fdf4ff";
|
||||||
|
|
||||||
const statusMeta = item.status
|
const statusMeta = item.status
|
||||||
? STATUS_META[item.status as keyof typeof STATUS_META]
|
? STATUS_META[item?.status as keyof typeof STATUS_META]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
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 * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
@ -22,8 +21,8 @@ const badgeVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Badge({
|
function Badge({
|
||||||
className,
|
className,
|
||||||
@ -32,7 +31,7 @@ function Badge({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span"> &
|
}: React.ComponentProps<"span"> &
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot : "span"
|
const Comp = asChild ? Slot : "span";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@ -40,7 +39,7 @@ function Badge({
|
|||||||
className={cn(badgeVariants({ variant }), className)}
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import * as React from "react";
|
|||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("leading-none font-semibold", className)}
|
className={cn("leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
className={cn(
|
className={cn(
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("px-6", className)}
|
className={cn("px-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -89,4 +89,4 @@ export {
|
|||||||
CardAction,
|
CardAction,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -1,43 +1,43 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import useEmblaCarousel, {
|
import useEmblaCarousel, {
|
||||||
type UseEmblaCarouselType,
|
type UseEmblaCarouselType,
|
||||||
} from "embla-carousel-react"
|
} from "embla-carousel-react";
|
||||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "../../lib/utils";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "./button";
|
||||||
|
|
||||||
type CarouselApi = UseEmblaCarouselType[1]
|
type CarouselApi = UseEmblaCarouselType[1];
|
||||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||||
type CarouselOptions = UseCarouselParameters[0]
|
type CarouselOptions = UseCarouselParameters[0];
|
||||||
type CarouselPlugin = UseCarouselParameters[1]
|
type CarouselPlugin = UseCarouselParameters[1];
|
||||||
|
|
||||||
type CarouselProps = {
|
type CarouselProps = {
|
||||||
opts?: CarouselOptions
|
opts?: CarouselOptions;
|
||||||
plugins?: CarouselPlugin
|
plugins?: CarouselPlugin;
|
||||||
orientation?: "horizontal" | "vertical"
|
orientation?: "horizontal" | "vertical";
|
||||||
setApi?: (api: CarouselApi) => void
|
setApi?: (api: CarouselApi) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
type CarouselContextProps = {
|
type CarouselContextProps = {
|
||||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||||
scrollPrev: () => void
|
scrollPrev: () => void;
|
||||||
scrollNext: () => void
|
scrollNext: () => void;
|
||||||
canScrollPrev: boolean
|
canScrollPrev: boolean;
|
||||||
canScrollNext: boolean
|
canScrollNext: boolean;
|
||||||
} & CarouselProps
|
} & CarouselProps;
|
||||||
|
|
||||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||||
|
|
||||||
function useCarousel() {
|
function useCarousel() {
|
||||||
const context = React.useContext(CarouselContext)
|
const context = React.useContext(CarouselContext);
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("useCarousel must be used within a <Carousel />")
|
throw new Error("useCarousel must be used within a <Carousel />");
|
||||||
}
|
}
|
||||||
|
|
||||||
return context
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Carousel({
|
function Carousel({
|
||||||
@ -54,53 +54,53 @@ function Carousel({
|
|||||||
...opts,
|
...opts,
|
||||||
axis: orientation === "horizontal" ? "x" : "y",
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
},
|
},
|
||||||
plugins
|
plugins,
|
||||||
)
|
);
|
||||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||||
|
|
||||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
if (!api) return
|
if (!api) return;
|
||||||
setCanScrollPrev(api.canScrollPrev())
|
setCanScrollPrev(api.canScrollPrev());
|
||||||
setCanScrollNext(api.canScrollNext())
|
setCanScrollNext(api.canScrollNext());
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const scrollPrev = React.useCallback(() => {
|
const scrollPrev = React.useCallback(() => {
|
||||||
api?.scrollPrev()
|
api?.scrollPrev();
|
||||||
}, [api])
|
}, [api]);
|
||||||
|
|
||||||
const scrollNext = React.useCallback(() => {
|
const scrollNext = React.useCallback(() => {
|
||||||
api?.scrollNext()
|
api?.scrollNext();
|
||||||
}, [api])
|
}, [api]);
|
||||||
|
|
||||||
const handleKeyDown = React.useCallback(
|
const handleKeyDown = React.useCallback(
|
||||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (event.key === "ArrowLeft") {
|
if (event.key === "ArrowLeft") {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
scrollPrev()
|
scrollPrev();
|
||||||
} else if (event.key === "ArrowRight") {
|
} else if (event.key === "ArrowRight") {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
scrollNext()
|
scrollNext();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[scrollPrev, scrollNext]
|
[scrollPrev, scrollNext],
|
||||||
)
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!api || !setApi) return
|
if (!api || !setApi) return;
|
||||||
setApi(api)
|
setApi(api);
|
||||||
}, [api, setApi])
|
}, [api, setApi]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!api) return
|
if (!api) return;
|
||||||
onSelect(api)
|
onSelect(api);
|
||||||
api.on("reInit", onSelect)
|
api.on("reInit", onSelect);
|
||||||
api.on("select", onSelect)
|
api.on("select", onSelect);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
api?.off("select", onSelect)
|
api?.off("select", onSelect);
|
||||||
}
|
};
|
||||||
}, [api, onSelect])
|
}, [api, onSelect]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CarouselContext.Provider
|
<CarouselContext.Provider
|
||||||
@ -127,11 +127,11 @@ function Carousel({
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</CarouselContext.Provider>
|
</CarouselContext.Provider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
const { carouselRef, orientation } = useCarousel()
|
const { carouselRef, orientation } = useCarousel();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -143,16 +143,16 @@ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex",
|
"flex",
|
||||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
const { orientation } = useCarousel()
|
const { orientation } = useCarousel();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -162,11 +162,11 @@ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 shrink-0 grow-0 basis-full",
|
"min-w-0 shrink-0 grow-0 basis-full",
|
||||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CarouselPrevious({
|
function CarouselPrevious({
|
||||||
@ -175,7 +175,7 @@ function CarouselPrevious({
|
|||||||
size = "icon",
|
size = "icon",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Button>) {
|
}: React.ComponentProps<typeof Button>) {
|
||||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@ -187,7 +187,7 @@ function CarouselPrevious({
|
|||||||
orientation === "horizontal"
|
orientation === "horizontal"
|
||||||
? "top-1/2 -left-12 -translate-y-1/2"
|
? "top-1/2 -left-12 -translate-y-1/2"
|
||||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
disabled={!canScrollPrev}
|
disabled={!canScrollPrev}
|
||||||
onClick={scrollPrev}
|
onClick={scrollPrev}
|
||||||
@ -196,7 +196,7 @@ function CarouselPrevious({
|
|||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
<span className="sr-only">Previous slide</span>
|
<span className="sr-only">Previous slide</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CarouselNext({
|
function CarouselNext({
|
||||||
@ -205,7 +205,7 @@ function CarouselNext({
|
|||||||
size = "icon",
|
size = "icon",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Button>) {
|
}: React.ComponentProps<typeof Button>) {
|
||||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@ -217,7 +217,7 @@ function CarouselNext({
|
|||||||
orientation === "horizontal"
|
orientation === "horizontal"
|
||||||
? "top-1/2 -right-12 -translate-y-1/2"
|
? "top-1/2 -right-12 -translate-y-1/2"
|
||||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
disabled={!canScrollNext}
|
disabled={!canScrollNext}
|
||||||
onClick={scrollNext}
|
onClick={scrollNext}
|
||||||
@ -226,7 +226,7 @@ function CarouselNext({
|
|||||||
<ArrowRight />
|
<ArrowRight />
|
||||||
<span className="sr-only">Next slide</span>
|
<span className="sr-only">Next slide</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -236,4 +236,4 @@ export {
|
|||||||
CarouselItem,
|
CarouselItem,
|
||||||
CarouselPrevious,
|
CarouselPrevious,
|
||||||
CarouselNext,
|
CarouselNext,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -1,32 +1,32 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "../../lib/utils";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "./button";
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({
|
function DialogTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogPortal({
|
function DialogPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({
|
function DialogClose({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogOverlay({
|
function DialogOverlay({
|
||||||
@ -38,11 +38,11 @@ function DialogOverlay({
|
|||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogContent({
|
function DialogContent({
|
||||||
@ -51,7 +51,7 @@ function DialogContent({
|
|||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
@ -60,7 +60,7 @@ function DialogContent({
|
|||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@ -76,7 +76,7 @@ function DialogContent({
|
|||||||
)}
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -86,7 +86,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogFooter({
|
function DialogFooter({
|
||||||
@ -95,14 +95,14 @@ function DialogFooter({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@ -113,7 +113,7 @@ function DialogFooter({
|
|||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTitle({
|
function DialogTitle({
|
||||||
@ -126,7 +126,7 @@ function DialogTitle({
|
|||||||
className={cn("text-lg leading-none font-semibold", className)}
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDescription({
|
function DialogDescription({
|
||||||
@ -139,7 +139,7 @@ function DialogDescription({
|
|||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -153,4 +153,4 @@ export {
|
|||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -1,30 +1,30 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Drawer as DrawerPrimitive } from "vaul"
|
import { Drawer as DrawerPrimitive } from "vaul";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function Drawer({
|
function Drawer({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawerTrigger({
|
function DrawerTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawerPortal({
|
function DrawerPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawerClose({
|
function DrawerClose({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawerOverlay({
|
function DrawerOverlay({
|
||||||
@ -36,11 +36,11 @@ function DrawerOverlay({
|
|||||||
data-slot="drawer-overlay"
|
data-slot="drawer-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawerContent({
|
function DrawerContent({
|
||||||
@ -59,7 +59,7 @@ function DrawerContent({
|
|||||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@ -67,7 +67,7 @@ function DrawerContent({
|
|||||||
{children}
|
{children}
|
||||||
</DrawerPrimitive.Content>
|
</DrawerPrimitive.Content>
|
||||||
</DrawerPortal>
|
</DrawerPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -76,11 +76,11 @@ function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="drawer-header"
|
data-slot="drawer-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -90,7 +90,7 @@ function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawerTitle({
|
function DrawerTitle({
|
||||||
@ -103,7 +103,7 @@ function DrawerTitle({
|
|||||||
className={cn("text-foreground font-semibold", className)}
|
className={cn("text-foreground font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawerDescription({
|
function DrawerDescription({
|
||||||
@ -116,7 +116,7 @@ function DrawerDescription({
|
|||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -130,4 +130,4 @@ export {
|
|||||||
DrawerFooter,
|
DrawerFooter,
|
||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
DrawerDescription,
|
DrawerDescription,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function DropdownMenu({
|
function DropdownMenu({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
function DropdownMenuPortal({
|
||||||
@ -15,7 +15,7 @@ function DropdownMenuPortal({
|
|||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
function DropdownMenuTrigger({
|
||||||
@ -26,7 +26,7 @@ function DropdownMenuTrigger({
|
|||||||
data-slot="dropdown-menu-trigger"
|
data-slot="dropdown-menu-trigger"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuContent({
|
function DropdownMenuContent({
|
||||||
@ -41,12 +41,12 @@ function DropdownMenuContent({
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
function DropdownMenuGroup({
|
||||||
@ -54,7 +54,7 @@ function DropdownMenuGroup({
|
|||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuItem({
|
function DropdownMenuItem({
|
||||||
@ -63,8 +63,8 @@ function DropdownMenuItem({
|
|||||||
variant = "default",
|
variant = "default",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
variant?: "default" | "destructive"
|
variant?: "default" | "destructive";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
@ -73,11 +73,11 @@ function DropdownMenuItem({
|
|||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
function DropdownMenuCheckboxItem({
|
||||||
@ -91,7 +91,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
@ -103,7 +103,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
function DropdownMenuRadioGroup({
|
||||||
@ -114,7 +114,7 @@ function DropdownMenuRadioGroup({
|
|||||||
data-slot="dropdown-menu-radio-group"
|
data-slot="dropdown-menu-radio-group"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
function DropdownMenuRadioItem({
|
||||||
@ -127,7 +127,7 @@ function DropdownMenuRadioItem({
|
|||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@ -138,7 +138,7 @@ function DropdownMenuRadioItem({
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
function DropdownMenuLabel({
|
||||||
@ -146,7 +146,7 @@ function DropdownMenuLabel({
|
|||||||
inset,
|
inset,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
@ -154,11 +154,11 @@ function DropdownMenuLabel({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
function DropdownMenuSeparator({
|
||||||
@ -171,7 +171,7 @@ function DropdownMenuSeparator({
|
|||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
function DropdownMenuShortcut({
|
||||||
@ -183,17 +183,17 @@ function DropdownMenuShortcut({
|
|||||||
data-slot="dropdown-menu-shortcut"
|
data-slot="dropdown-menu-shortcut"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSub({
|
function DropdownMenuSub({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
function DropdownMenuSubTrigger({
|
||||||
@ -202,7 +202,7 @@ function DropdownMenuSubTrigger({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
@ -210,14 +210,14 @@ function DropdownMenuSubTrigger({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
function DropdownMenuSubContent({
|
||||||
@ -229,11 +229,11 @@ function DropdownMenuSubContent({
|
|||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -252,4 +252,4 @@ export {
|
|||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { useMemo } from "react"
|
import { useMemo } from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "../../lib/utils";
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "./label";
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "./separator";
|
||||||
|
|
||||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||||
return (
|
return (
|
||||||
@ -12,11 +12,11 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-6",
|
"flex flex-col gap-6",
|
||||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldLegend({
|
function FieldLegend({
|
||||||
@ -32,11 +32,11 @@ function FieldLegend({
|
|||||||
"mb-3 font-medium",
|
"mb-3 font-medium",
|
||||||
"data-[variant=legend]:text-base",
|
"data-[variant=legend]:text-base",
|
||||||
"data-[variant=label]:text-sm",
|
"data-[variant=label]:text-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -44,12 +44,12 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="field-group"
|
data-slot="field-group"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldVariants = cva(
|
const fieldVariants = cva(
|
||||||
@ -73,8 +73,8 @@ const fieldVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
orientation: "vertical",
|
orientation: "vertical",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Field({
|
function Field({
|
||||||
className,
|
className,
|
||||||
@ -89,7 +89,7 @@ function Field({
|
|||||||
className={cn(fieldVariants({ orientation }), className)}
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -98,11 +98,11 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="field-content"
|
data-slot="field-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldLabel({
|
function FieldLabel({
|
||||||
@ -114,13 +114,13 @@ function FieldLabel({
|
|||||||
data-slot="field-label"
|
data-slot="field-label"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border *:data-[slot=field]:p-4",
|
||||||
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -129,11 +129,11 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="field-label"
|
data-slot="field-label"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
@ -141,14 +141,14 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|||||||
<p
|
<p
|
||||||
data-slot="field-description"
|
data-slot="field-description"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
"text-muted-foreground text-sm leading-normal font-normal group-has-data-[orientation=horizontal]/field:text-balance",
|
||||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldSeparator({
|
function FieldSeparator({
|
||||||
@ -156,7 +156,7 @@ function FieldSeparator({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -164,7 +164,7 @@ function FieldSeparator({
|
|||||||
data-content={!!children}
|
data-content={!!children}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@ -178,7 +178,7 @@ function FieldSeparator({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldError({
|
function FieldError({
|
||||||
@ -187,37 +187,37 @@ function FieldError({
|
|||||||
errors,
|
errors,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
errors?: Array<{ message?: string } | undefined>
|
errors?: Array<{ message?: string } | undefined>;
|
||||||
}) {
|
}) {
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
if (children) {
|
if (children) {
|
||||||
return children
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!errors?.length) {
|
if (!errors?.length) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueErrors = [
|
const uniqueErrors = [
|
||||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||||
]
|
];
|
||||||
|
|
||||||
if (uniqueErrors?.length == 1) {
|
if (uniqueErrors?.length == 1) {
|
||||||
return uniqueErrors[0]?.message
|
return uniqueErrors[0]?.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
{uniqueErrors.map(
|
{uniqueErrors.map(
|
||||||
(error, index) =>
|
(error, index) =>
|
||||||
error?.message && <li key={index}>{error.message}</li>
|
error?.message && <li key={index}>{error.message}</li>,
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
);
|
||||||
}, [children, errors])
|
}, [children, errors]);
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -229,7 +229,7 @@ function FieldError({
|
|||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -243,4 +243,4 @@ export {
|
|||||||
FieldSet,
|
FieldSet,
|
||||||
FieldContent,
|
FieldContent,
|
||||||
FieldTitle,
|
FieldTitle,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
return (
|
return (
|
||||||
@ -11,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Label as LabelPrimitive } from "radix-ui"
|
import { Label as LabelPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function Label({
|
function Label({
|
||||||
className,
|
className,
|
||||||
@ -12,11 +12,11 @@ function Label({
|
|||||||
data-slot="label"
|
data-slot="label"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Label }
|
export { Label };
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function Separator({
|
function Separator({
|
||||||
className,
|
className,
|
||||||
@ -18,11 +18,11 @@ function Separator({
|
|||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Separator }
|
export { Separator };
|
||||||
|
|||||||
@ -1,31 +1,31 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react";
|
||||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTrigger({
|
function SheetTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetClose({
|
function SheetClose({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetPortal({
|
function SheetPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetOverlay({
|
function SheetOverlay({
|
||||||
@ -37,11 +37,11 @@ function SheetOverlay({
|
|||||||
data-slot="sheet-overlay"
|
data-slot="sheet-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetContent({
|
function SheetContent({
|
||||||
@ -51,8 +51,8 @@ function SheetContent({
|
|||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
@ -69,7 +69,7 @@ function SheetContent({
|
|||||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
side === "bottom" &&
|
side === "bottom" &&
|
||||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@ -82,7 +82,7 @@ function SheetContent({
|
|||||||
)}
|
)}
|
||||||
</SheetPrimitive.Content>
|
</SheetPrimitive.Content>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -92,7 +92,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -102,7 +102,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTitle({
|
function SheetTitle({
|
||||||
@ -115,7 +115,7 @@ function SheetTitle({
|
|||||||
className={cn("text-foreground font-semibold", className)}
|
className={cn("text-foreground font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetDescription({
|
function SheetDescription({
|
||||||
@ -128,7 +128,7 @@ function SheetDescription({
|
|||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -140,4 +140,4 @@ export {
|
|||||||
SheetFooter,
|
SheetFooter,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -1,56 +1,56 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { PanelLeftIcon } from "lucide-react"
|
import { PanelLeftIcon } from "lucide-react";
|
||||||
import { Slot } from "radix-ui"
|
import { Slot } from "radix-ui";
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "../../hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "../../lib/utils";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "./button";
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "./input";
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "./separator";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet"
|
} from "./sheet";
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "./skeleton";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "./tooltip";
|
||||||
|
|
||||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||||
const SIDEBAR_WIDTH = "16rem"
|
const SIDEBAR_WIDTH = "16rem";
|
||||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||||
|
|
||||||
type SidebarContextProps = {
|
type SidebarContextProps = {
|
||||||
state: "expanded" | "collapsed"
|
state: "expanded" | "collapsed";
|
||||||
open: boolean
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void;
|
||||||
openMobile: boolean
|
openMobile: boolean;
|
||||||
setOpenMobile: (open: boolean) => void
|
setOpenMobile: (open: boolean) => void;
|
||||||
isMobile: boolean
|
isMobile: boolean;
|
||||||
toggleSidebar: () => void
|
toggleSidebar: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||||
|
|
||||||
function useSidebar() {
|
function useSidebar() {
|
||||||
const context = React.useContext(SidebarContext)
|
const context = React.useContext(SidebarContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return context
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarProvider({
|
function SidebarProvider({
|
||||||
@ -62,36 +62,36 @@ function SidebarProvider({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean;
|
||||||
open?: boolean
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile();
|
||||||
const [openMobile, setOpenMobile] = React.useState(false)
|
const [openMobile, setOpenMobile] = React.useState(false);
|
||||||
|
|
||||||
// This is the internal state of the sidebar.
|
// This is the internal state of the sidebar.
|
||||||
// We use openProp and setOpenProp for control from outside the component.
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||||
const open = openProp ?? _open
|
const open = openProp ?? _open;
|
||||||
const setOpen = React.useCallback(
|
const setOpen = React.useCallback(
|
||||||
(value: boolean | ((value: boolean) => boolean)) => {
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
const openState = typeof value === "function" ? value(open) : value
|
const openState = typeof value === "function" ? value(open) : value;
|
||||||
if (setOpenProp) {
|
if (setOpenProp) {
|
||||||
setOpenProp(openState)
|
setOpenProp(openState);
|
||||||
} else {
|
} else {
|
||||||
_setOpen(openState)
|
_setOpen(openState);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This sets the cookie to keep the sidebar state.
|
// This sets the cookie to keep the sidebar state.
|
||||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||||
},
|
},
|
||||||
[setOpenProp, open]
|
[setOpenProp, open],
|
||||||
)
|
);
|
||||||
|
|
||||||
// Helper to toggle the sidebar.
|
// Helper to toggle the sidebar.
|
||||||
const toggleSidebar = React.useCallback(() => {
|
const toggleSidebar = React.useCallback(() => {
|
||||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||||
}, [isMobile, setOpen, setOpenMobile])
|
}, [isMobile, setOpen, setOpenMobile]);
|
||||||
|
|
||||||
// Adds a keyboard shortcut to toggle the sidebar.
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -100,18 +100,18 @@ function SidebarProvider({
|
|||||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
(event.metaKey || event.ctrlKey)
|
(event.metaKey || event.ctrlKey)
|
||||||
) {
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
toggleSidebar()
|
toggleSidebar();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [toggleSidebar])
|
}, [toggleSidebar]);
|
||||||
|
|
||||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
// This makes it easier to style the sidebar with Tailwind classes.
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
const state = open ? "expanded" : "collapsed"
|
const state = open ? "expanded" : "collapsed";
|
||||||
|
|
||||||
const contextValue = React.useMemo<SidebarContextProps>(
|
const contextValue = React.useMemo<SidebarContextProps>(
|
||||||
() => ({
|
() => ({
|
||||||
@ -123,8 +123,8 @@ function SidebarProvider({
|
|||||||
setOpenMobile,
|
setOpenMobile,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
}),
|
}),
|
||||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarContext.Provider value={contextValue}>
|
<SidebarContext.Provider value={contextValue}>
|
||||||
@ -140,7 +140,7 @@ function SidebarProvider({
|
|||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@ -148,7 +148,7 @@ function SidebarProvider({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</SidebarContext.Provider>
|
</SidebarContext.Provider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sidebar({
|
function Sidebar({
|
||||||
@ -159,11 +159,11 @@ function Sidebar({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
side?: "left" | "right"
|
side?: "left" | "right";
|
||||||
variant?: "sidebar" | "floating" | "inset"
|
variant?: "sidebar" | "floating" | "inset";
|
||||||
collapsible?: "offcanvas" | "icon" | "none"
|
collapsible?: "offcanvas" | "icon" | "none";
|
||||||
}) {
|
}) {
|
||||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||||
|
|
||||||
if (collapsible === "none") {
|
if (collapsible === "none") {
|
||||||
return (
|
return (
|
||||||
@ -171,13 +171,13 @@ function Sidebar({
|
|||||||
data-slot="sidebar"
|
data-slot="sidebar"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
@ -202,7 +202,7 @@ function Sidebar({
|
|||||||
<div className="flex h-full w-full flex-col">{children}</div>
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -223,7 +223,7 @@ function Sidebar({
|
|||||||
"group-data-[side=right]:rotate-180",
|
"group-data-[side=right]:rotate-180",
|
||||||
variant === "floating" || variant === "inset"
|
variant === "floating" || variant === "inset"
|
||||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@ -237,7 +237,7 @@ function Sidebar({
|
|||||||
variant === "floating" || variant === "inset"
|
variant === "floating" || variant === "inset"
|
||||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@ -250,14 +250,14 @@ function Sidebar({
|
|||||||
// so keep this container visually transparent.
|
// so keep this container visually transparent.
|
||||||
variant === "floating"
|
variant === "floating"
|
||||||
? "bg-transparent"
|
? "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"
|
: "bg-sidebar group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarTrigger({
|
function SidebarTrigger({
|
||||||
@ -265,7 +265,7 @@ function SidebarTrigger({
|
|||||||
onClick,
|
onClick,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Button>) {
|
}: React.ComponentProps<typeof Button>) {
|
||||||
const { toggleSidebar } = useSidebar()
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@ -275,19 +275,19 @@ function SidebarTrigger({
|
|||||||
size="icon"
|
size="icon"
|
||||||
className={cn("size-7", className)}
|
className={cn("size-7", className)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
onClick?.(event)
|
onClick?.(event);
|
||||||
toggleSidebar()
|
toggleSidebar();
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<PanelLeftIcon />
|
<PanelLeftIcon />
|
||||||
<span className="sr-only">Toggle Sidebar</span>
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||||
const { toggleSidebar } = useSidebar()
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -298,17 +298,17 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
|||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
title="Toggle Sidebar"
|
title="Toggle Sidebar"
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 sm:flex",
|
||||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||||
@ -318,11 +318,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"bg-background relative flex w-full flex-1 flex-col",
|
"bg-background relative flex w-full flex-1 flex-col",
|
||||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarInput({
|
function SidebarInput({
|
||||||
@ -336,7 +336,7 @@ function SidebarInput({
|
|||||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -347,7 +347,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-2 p-2", className)}
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -358,7 +358,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-2 p-2", className)}
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarSeparator({
|
function SidebarSeparator({
|
||||||
@ -372,7 +372,7 @@ function SidebarSeparator({
|
|||||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -382,11 +382,11 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-sidebar="content"
|
data-sidebar="content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@ -397,7 +397,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroupLabel({
|
function SidebarGroupLabel({
|
||||||
@ -405,7 +405,7 @@ function SidebarGroupLabel({
|
|||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot.Root : "div"
|
const Comp = asChild ? Slot.Root : "div";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@ -414,11 +414,11 @@ function SidebarGroupLabel({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroupAction({
|
function SidebarGroupAction({
|
||||||
@ -426,7 +426,7 @@ function SidebarGroupAction({
|
|||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot.Root : "button"
|
const Comp = asChild ? Slot.Root : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@ -437,11 +437,11 @@ function SidebarGroupAction({
|
|||||||
// Increases the hit area of the button on mobile.
|
// Increases the hit area of the button on mobile.
|
||||||
"after:absolute after:-inset-2 md:after:hidden",
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
"group-data-[collapsible=icon]:hidden",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroupContent({
|
function SidebarGroupContent({
|
||||||
@ -455,7 +455,7 @@ function SidebarGroupContent({
|
|||||||
className={cn("w-full text-sm", className)}
|
className={cn("w-full text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
@ -466,7 +466,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
|||||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
@ -477,7 +477,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
|||||||
className={cn("group/menu-item relative", className)}
|
className={cn("group/menu-item relative", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebarMenuButtonVariants = cva(
|
const sidebarMenuButtonVariants = cva(
|
||||||
@ -499,8 +499,8 @@ const sidebarMenuButtonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function SidebarMenuButton({
|
function SidebarMenuButton({
|
||||||
asChild = false,
|
asChild = false,
|
||||||
@ -511,12 +511,12 @@ function SidebarMenuButton({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> & {
|
}: React.ComponentProps<"button"> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
isActive?: boolean
|
isActive?: boolean;
|
||||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||||
const Comp = asChild ? Slot.Root : "button"
|
const Comp = asChild ? Slot.Root : "button";
|
||||||
const { isMobile, state } = useSidebar()
|
const { isMobile, state } = useSidebar();
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
<Comp
|
<Comp
|
||||||
@ -527,16 +527,16 @@ function SidebarMenuButton({
|
|||||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
|
|
||||||
if (!tooltip) {
|
if (!tooltip) {
|
||||||
return button
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof tooltip === "string") {
|
if (typeof tooltip === "string") {
|
||||||
tooltip = {
|
tooltip = {
|
||||||
children: tooltip,
|
children: tooltip,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -549,7 +549,7 @@ function SidebarMenuButton({
|
|||||||
{...tooltip}
|
{...tooltip}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuAction({
|
function SidebarMenuAction({
|
||||||
@ -558,10 +558,10 @@ function SidebarMenuAction({
|
|||||||
showOnHover = false,
|
showOnHover = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> & {
|
}: React.ComponentProps<"button"> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
showOnHover?: boolean
|
showOnHover?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot.Root : "button"
|
const Comp = asChild ? Slot.Root : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@ -577,11 +577,11 @@ function SidebarMenuAction({
|
|||||||
"group-data-[collapsible=icon]:hidden",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
showOnHover &&
|
showOnHover &&
|
||||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuBadge({
|
function SidebarMenuBadge({
|
||||||
@ -599,11 +599,11 @@ function SidebarMenuBadge({
|
|||||||
"peer-data-[size=default]/menu-button:top-1.5",
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
"group-data-[collapsible=icon]:hidden",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSkeleton({
|
function SidebarMenuSkeleton({
|
||||||
@ -611,12 +611,12 @@ function SidebarMenuSkeleton({
|
|||||||
showIcon = false,
|
showIcon = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
showIcon?: boolean
|
showIcon?: boolean;
|
||||||
}) {
|
}) {
|
||||||
// Random width between 50 to 90%.
|
// Random width between 50 to 90%.
|
||||||
const width = React.useMemo(() => {
|
const width = React.useMemo(() => {
|
||||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -641,7 +641,7 @@ function SidebarMenuSkeleton({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
@ -652,11 +652,11 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||||
"group-data-[collapsible=icon]:hidden",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSubItem({
|
function SidebarMenuSubItem({
|
||||||
@ -670,7 +670,7 @@ function SidebarMenuSubItem({
|
|||||||
className={cn("group/menu-sub-item relative", className)}
|
className={cn("group/menu-sub-item relative", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSubButton({
|
function SidebarMenuSubButton({
|
||||||
@ -680,11 +680,11 @@ function SidebarMenuSubButton({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"a"> & {
|
}: React.ComponentProps<"a"> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
size?: "sm" | "md"
|
size?: "sm" | "md";
|
||||||
isActive?: boolean
|
isActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot.Root : "a"
|
const Comp = asChild ? Slot.Root : "a";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@ -698,11 +698,11 @@ function SidebarMenuSubButton({
|
|||||||
size === "sm" && "text-xs",
|
size === "sm" && "text-xs",
|
||||||
size === "md" && "text-sm",
|
size === "md" && "text-sm",
|
||||||
"group-data-[collapsible=icon]:hidden",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -730,4 +730,4 @@ export {
|
|||||||
SidebarSeparator,
|
SidebarSeparator,
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Skeleton }
|
export { Skeleton };
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
return (
|
return (
|
||||||
@ -14,7 +14,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
@ -24,7 +24,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
|||||||
className={cn("[&_tr]:border-b", className)}
|
className={cn("[&_tr]:border-b", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
@ -34,7 +34,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
|||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
@ -43,11 +43,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
|||||||
data-slot="table-footer"
|
data-slot="table-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
@ -56,11 +56,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|||||||
data-slot="table-row"
|
data-slot="table-row"
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
@ -69,11 +69,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|||||||
data-slot="table-head"
|
data-slot="table-head"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
@ -82,11 +82,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|||||||
data-slot="table-cell"
|
data-slot="table-cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCaption({
|
function TableCaption({
|
||||||
@ -99,7 +99,7 @@ function TableCaption({
|
|||||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -111,4 +111,4 @@ export {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableCaption,
|
TableCaption,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function TooltipProvider({
|
function TooltipProvider({
|
||||||
delayDuration = 0,
|
delayDuration = 0,
|
||||||
@ -13,19 +13,19 @@ function TooltipProvider({
|
|||||||
delayDuration={delayDuration}
|
delayDuration={delayDuration}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tooltip({
|
function Tooltip({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipTrigger({
|
function TooltipTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipContent({
|
function TooltipContent({
|
||||||
@ -41,7 +41,7 @@ function TooltipContent({
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@ -49,7 +49,7 @@ function TooltipContent({
|
|||||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
</TooltipPrimitive.Content>
|
</TooltipPrimitive.Content>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
|
|||||||
670
src/data/math/area-volume.ts
Normal file
670
src/data/math/area-volume.ts
Normal file
@ -0,0 +1,670 @@
|
|||||||
|
import { type PracticeQuestion } from "../../types/lesson";
|
||||||
|
|
||||||
|
export const AREA_VOL_EASY: PracticeQuestion[] = [
|
||||||
|
{
|
||||||
|
id: "02b02213",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"What is the perimeter, in inches, of a rectangle with a length of <strong>4</strong> inches and a width of <strong>9</strong> inches?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>13</strong>" },
|
||||||
|
{ label: "B", text: "<strong>17</strong>" },
|
||||||
|
{ label: "C", text: "<strong>22</strong>" },
|
||||||
|
{ label: "D", text: "<strong>26</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "D",
|
||||||
|
explanation:
|
||||||
|
"Choice D is correct. The perimeter of a figure is equal to the sum of the measurements of the sides of the figure. It’s given that the rectangle has a length of <strong>4</strong> inches and a width of <strong>9</strong> inches. Since a rectangle has <strong>4</strong> sides, of which opposite sides are parallel and equal, it follows that the rectangle has two sides with a length of <strong>4</strong> inches and two sides with a width of <strong>9</strong> inches. Therefore, the perimeter of this rectangle is <strong>4 + 4 + 9 + 9</strong>, or <strong>26</strong> inches.<br>Choice A is incorrect. This is the sum, in inches, of the length and the width of the rectangle.<br>Choice B is incorrect. This is the sum, in inches, of the two lengths and the width of the rectangle.<br>Choice C is incorrect. This is the sum, in inches, of the length and the two widths of the rectangle.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "0837c3b9",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"Triangle ABC and triangle DEF are similar triangles, where <strong>A B</strong> and <strong>D E</strong> are corresponding sides. If <strong>the length of D E = 2 · the length of A B</strong> and the perimeter of triangle ABC is 20, what is the perimeter of triangle DEF ?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "10" },
|
||||||
|
{ label: "B", text: "40" },
|
||||||
|
{ label: "C", text: "80" },
|
||||||
|
{ label: "D", text: "120" },
|
||||||
|
],
|
||||||
|
correctAnswer: "B",
|
||||||
|
explanation:
|
||||||
|
"Choice B is correct. Since triangles ABC and DEF are similar and <strong>the length of side D E = 2 · the length of side A B</strong>, the length of each side of triangle DEF is two times the length of its corresponding side in triangle ABC. Therefore, the perimeter of triangle DEF is two times the perimeter of triangle ABC. Since the perimeter of triangle ABC is 20, the perimeter of triangle DEF is 40.Choice A is incorrect. This is half, not two times, the perimeter of triangle ABC. Choice C is incorrect. This is two times the perimeter of triangle DEF rather than two times the perimeter of triangle ABC. Choice D is incorrect. This is six times, not two times, the perimeter of triangle ABC.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "165c30c4",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"A rectangle has a length of <strong>64</strong> inches and a width of <strong>32</strong> inches. What is the area, in square inches, of the rectangle?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "2048",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>2, 048</strong>. The area <strong>A</strong>, in square inches, of a rectangle is equal to the product of its length <strong>script l</strong>, in inches, and its width <strong>w</strong>, in inches, or <strong>A = script l w</strong>. It's given that the rectangle has a length of <strong>64</strong> inches and a width of <strong>32</strong> inches. Substituting <strong>64</strong> for <strong>script l</strong> and <strong>32</strong> for <strong>w</strong> in the equation <strong>A = script l w</strong> yields <strong>A = (64) (32)</strong>, or <strong>A = 2, 048</strong>. Therefore, the area, in square inches, of the rectangle is <strong>2, 048</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "29e9b28c",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"The lengths of the sides are x, y, and z.<br>A note indicates the figure is not drawn to scale.<br><br> <br>The triangle shown has a perimeter of <strong>22</strong> units. If <strong>x = 9</strong> units and <strong>y = 7</strong> units, what is the value of <strong>z</strong>, in units?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>6</strong>" },
|
||||||
|
{ label: "B", text: "<strong>7</strong>" },
|
||||||
|
{ label: "C", text: "<strong>9</strong>" },
|
||||||
|
{ label: "D", text: "<strong>16</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "A",
|
||||||
|
explanation:
|
||||||
|
"Choice A is correct. The perimeter of a triangle is the sum of the lengths of its three sides. The triangle shown has side lengths <strong>x</strong>, <strong>y</strong>, and <strong>z</strong>. It's given that the triangle has a perimeter of <strong>22</strong> units. Therefore, <strong>x + y + z = 22</strong>. If <strong>x = 9</strong> units and <strong>y = 7</strong> units, the value of <strong>z</strong>, in units, can be found by substituting <strong>9</strong> for <strong>x</strong> and <strong>7</strong> for <strong>y</strong> in the equation <strong>x + y + z = 22</strong>, which yields <strong>9 + 7 + z = 22</strong>, or <strong>16 + z = 22</strong>. Subtracting <strong>16</strong> from both sides of this equation yields <strong>z = 6</strong>. Therefore, if <strong>x = 9</strong> units and <strong>y = 7</strong> units, the value of <strong>z</strong>, in units, is <strong>6</strong>.<br>Choice B is incorrect. This is the value of <strong>y</strong>, in units, not the value of <strong>z</strong>, in units.<br>Choice C is incorrect. This is the value of <strong>x</strong>, in units, not the value of <strong>z</strong>, in units.<br>Choice D is incorrect. This is the value of <strong>x + y</strong>, in units, not the value of <strong>z</strong>, in units.",
|
||||||
|
hasFigure: true,
|
||||||
|
figureUrl: "/practice-images/29e9b28c_svg1.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3453aafc",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"What is the area, in square centimeters, of a rectangle with a length of <strong>36</strong> centimeters and a width of <strong>34</strong> centimeters?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>70</strong>" },
|
||||||
|
{ label: "B", text: "<strong>140</strong>" },
|
||||||
|
{ label: "C", text: "<strong>1, 156</strong>" },
|
||||||
|
{ label: "D", text: "<strong>1, 224</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "D",
|
||||||
|
explanation:
|
||||||
|
"Choice D is correct. The area <strong>A</strong>, in square centimeters, of a rectangle can be found using the formula <strong>A = script l w</strong>, where <strong>script l</strong> is the length, in centimeters, of the rectangle and <strong>w</strong> is its width, in centimeters. It's given that the rectangle has a length of <strong>36</strong> centimeters and a width of <strong>34</strong> centimeters. Substituting <strong>36</strong> for <strong>script l</strong> and <strong>34</strong> for <strong>w</strong> in the formula <strong>A = script l w</strong> yields <strong>A = 36 (34)</strong>, or <strong>A = 1, 224</strong>. Therefore, the area, in square centimeters, of this rectangle is <strong>1, 224</strong>.<br>Choice A is incorrect and may result from conceptual or calculation errors.<br>Choice B is incorrect. This is the perimeter, in centimeters, not the area, in square centimeters, of the rectangle.<br>Choice C is incorrect and may result from conceptual or calculation errors.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4420e500",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"What is the area of a rectangle with a length of <strong>4 centimeters (cm)</strong> and a width of <strong>2 cm</strong>?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>6 cm²</strong>" },
|
||||||
|
{ label: "B", text: "<strong>8 cm²</strong>" },
|
||||||
|
{ label: "C", text: "<strong>12 cm²</strong>" },
|
||||||
|
{ label: "D", text: "<strong>36 cm²</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "B",
|
||||||
|
explanation:
|
||||||
|
"Choice B is correct. The area of a rectangle with length <strong>script l</strong> and width <strong>w</strong> can be found using the formula <strong>A = script l w</strong>. It’s given that the rectangle has a length of <strong>4 cm</strong> and a width of <strong>2 cm</strong>. Therefore, the area of this rectangle is <strong>(4 cm) (2 cm)</strong>, or <strong>8 cm²</strong>.<br>Choice A is incorrect. This is the sum, <strong>in cm</strong>, of the length and width of the rectangle, not the area, <strong>in cm²</strong>.<br>Choice C is incorrect. This is the perimeter, <strong>in cm</strong>, of the rectangle, not the area, <strong>in cm²</strong>.<br>Choice D is incorrect. This is the sum of the length and width of the rectangle squared, not the area.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5252e606",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"The side length of a square is <strong>55 centimeters (cm)</strong>. What is the area, <strong>in cm²</strong>, of the square?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>110</strong>" },
|
||||||
|
{ label: "B", text: "<strong>220</strong>" },
|
||||||
|
{ label: "C", text: "<strong>3, 025</strong>" },
|
||||||
|
{ label: "D", text: "<strong>12, 100</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "C",
|
||||||
|
explanation:
|
||||||
|
"Choice C is correct. The area <strong>A</strong>, <strong>in square centimeters (cm²)</strong>, of a square with side length <strong>s</strong>, <strong>in cm</strong>, is given by the formula <strong>A = s²</strong>. It’s given that the square has a side length of <strong>55 cm</strong>. Substituting <strong>55</strong> for <strong>s</strong> in the formula <strong>A = s²</strong> yields <strong>A = 55²</strong>, or <strong>A = 3, 025</strong>. Therefore, the area, <strong>in cm²</strong>, of the square is <strong>3, 025</strong>.<br>Choice A is incorrect and may result from conceptual or calculation errors.<br>Choice B is incorrect. This is the perimeter, <strong>in cm</strong>, of the square, not its area, <strong>in cm²</strong>.<br>Choice D is incorrect and may result from conceptual or calculation errors.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "575f1e12",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"What is the area, in square centimeters, of a rectangle with a length of <strong>34 centimeters (cm)</strong> and a width of <strong>29 cm</strong>?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "986",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>986</strong>. The area, <strong>A</strong>, of a rectangle is given by <strong>A = script l w</strong>, where <strong>script l</strong> is the length of the rectangle and <strong>w</strong> is its width. It’s given that the length of the rectangle is <strong>34</strong> centimeters (cm) and the width is <strong>29</strong> cm. Substituting <strong>34</strong> for <strong>script l</strong> and <strong>29</strong> for <strong>w</strong> in the equation <strong>A = script l w</strong> yields <strong>A = (34) (29)</strong>, or <strong>A = 986</strong>. Therefore, the area, in square centimeters, of this rectangle is <strong>986</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "59cb654c",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"The area of a square is <strong>64</strong> square inches. What is the side length, in inches, of this square?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>8</strong>" },
|
||||||
|
{ label: "B", text: "<strong>16</strong>" },
|
||||||
|
{ label: "C", text: "<strong>64</strong>" },
|
||||||
|
{ label: "D", text: "<strong>128</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "A",
|
||||||
|
explanation:
|
||||||
|
"Choice A is correct. It's given that the area of a square is <strong>64</strong> square inches. The area <strong>A</strong>, in square inches, of a square is given by the formula <strong>A = s²</strong>, where <strong>s</strong> is the side length, in inches, of the square. Substituting <strong>64</strong> for <strong>A</strong> in this formula yields <strong>64 = s²</strong>. Taking the positive square root of both sides of this equation yields <strong>8 = s</strong>. Thus, the side length, in inches, of this square is <strong>8</strong>.<br>Choice B is incorrect and may result from conceptual or calculation errors.<br>Choice C is incorrect. This is the area, in square inches, of the square, not the side length, in inches, of the square.<br>Choice D is incorrect and may result from conceptual or calculation errors.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "76670c80",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"Each side of a square has a length of <strong>45</strong>. What is the perimeter of this square?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "180",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>180</strong>. The perimeter of a polygon is equal to the sum of the lengths of the sides of the polygon. It’s given that each side of the square has a length of <strong>45</strong>. Since a square is a polygon with <strong>4</strong> sides, the perimeter of this square is <strong>45 + 45 + 45 + 45</strong>, or <strong>180</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c88183f7",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A rectangle has a length of <strong>13</strong> and a width of <strong>6</strong>. What is the perimeter of the rectangle?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>12</strong>" },
|
||||||
|
{ label: "B", text: "<strong>26</strong>" },
|
||||||
|
{ label: "C", text: "<strong>38</strong>" },
|
||||||
|
{ label: "D", text: "<strong>52</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "C",
|
||||||
|
explanation:
|
||||||
|
"Choice C is correct. The perimeter of a quadrilateral is the sum of the lengths of its four sides. It's given that the rectangle has a length of <strong>13</strong> and a width of <strong>6</strong>. It follows that the rectangle has two sides with length <strong>13</strong> and two sides with length <strong>6</strong>. Therefore, the perimeter of the rectangle is <strong>13 + 13 + 6 + 6</strong>, or <strong>38</strong>.<br>Choice A is incorrect. This is the sum of the lengths of the two sides with length <strong>6</strong>, not the sum of the lengths of all four sides of the rectangle.<br>Choice B is incorrect. This is the sum of the lengths of the two sides with length <strong>13</strong>, not the sum of the lengths of all four sides of the rectangle.<br>Choice D is incorrect. This is the perimeter of a rectangle that has four sides with length <strong>13</strong>, not two sides with length <strong>13</strong> and two sides with length <strong>6</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "d0b6d927",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A rectangle has an area of <strong>63</strong> square meters and a length of <strong>9</strong> meters. What is the width, in meters, of the rectangle?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>7</strong>" },
|
||||||
|
{ label: "B", text: "<strong>54</strong>" },
|
||||||
|
{ label: "C", text: "<strong>81</strong>" },
|
||||||
|
{ label: "D", text: "<strong>567</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "A",
|
||||||
|
explanation:
|
||||||
|
"Choice A is correct. The area <strong>A</strong>, in square meters, of a rectangle is the product of its length <strong>script l</strong>, in meters, and its width <strong>w</strong>, in meters; thus, <strong>A = script l w</strong>. It's given that a rectangle has an area of <strong>63</strong> square meters and a length of <strong>9</strong> meters. Substituting <strong>63</strong> for <strong>A</strong> and <strong>9</strong> for <strong>script l</strong> in the equation <strong>A = script l w</strong> yields <strong>63 = 9 w</strong>. Dividing both sides of this equation by <strong>9</strong> yields <strong>7 = w</strong>. Therefore, the width, in meters, of the rectangle is <strong>7</strong>.<br>Choice B is incorrect. This is the difference between the area, in square meters, and the length, in meters, of the rectangle, not the width, in meters, of the rectangle.<br>Choice C is incorrect. This is the square of the length, in meters, not the width, in meters, of the rectangle.<br>Choice D is incorrect. This is the product of the area, in square meters, and the length, in meters, of the rectangle, not the width, in meters, of the rectangle.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "d2047497",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"What is the area of a rectangle with a length of <strong>17 centimeters (cm)</strong> and a width of <strong>7 cm</strong>?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>24 cm²</strong>" },
|
||||||
|
{ label: "B", text: "<strong>48 cm²</strong>" },
|
||||||
|
{ label: "C", text: "<strong>119 cm²</strong>" },
|
||||||
|
{ label: "D", text: "<strong>576 cm²</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "C",
|
||||||
|
explanation:
|
||||||
|
"Choice C is correct. The area of a rectangle with length <strong>l</strong> and width <strong>w</strong> can be found using the formula <strong>A = l w</strong>. It’s given that the rectangle has a length of <strong>17 cm</strong> and a width of <strong>7 cm</strong>. Therefore, the area of this rectangle is <strong>A = 17 (7)</strong>, or <strong>119 cm²</strong>.<br>Choice A is incorrect. This is the sum of the length and width of the rectangle, not the area.<br>Choice B is incorrect. This is the perimeter of the rectangle, not the area.<br>Choice D is incorrect. This is the sum of the length and width of the rectangle squared, not the area.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "d683a9cc",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"The figure shows the lengths, in centimeters (cm), of the edges of a right rectangular prism. The volume V of a right rectangular prism is <strong>l w h</strong>, where <strong>l</strong> is the length of the prism, w is the width of the prism, and h is the height of the prism. What is the volume, in cubic centimeters, of the prism?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "36" },
|
||||||
|
{ label: "B", text: "24" },
|
||||||
|
{ label: "C", text: "12" },
|
||||||
|
{ label: "D", text: "11" },
|
||||||
|
],
|
||||||
|
correctAnswer: "A",
|
||||||
|
explanation:
|
||||||
|
"Choice A is correct. It’s given that the volume of a right rectangular prism is <strong>l w h</strong>. The prism shown has a length of 6 cm, a width of 2 cm, and a height of 3 cm. Thus, <strong>l w h = 6 · 2 · 3</strong>, or 36 cubic centimeters.Choice B is incorrect. This is the volume of a rectangular prism with edge lengths of 6, 2, and 2. Choice C is incorrect and may result from only finding the product of the length and width of the base of the prism. Choice D is incorrect and may result from finding the sum, not the product, of the edge lengths of the prism.",
|
||||||
|
hasFigure: true,
|
||||||
|
figureUrl: "/practice-images/d683a9cc_img1.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "f60bb551",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"The area of a rectangle is <strong>630</strong> square inches. The length of the rectangle is <strong>70</strong> inches. What is the width, in inches, of this rectangle?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>9</strong>" },
|
||||||
|
{ label: "B", text: "<strong>70</strong>" },
|
||||||
|
{ label: "C", text: "<strong>315</strong>" },
|
||||||
|
{ label: "D", text: "<strong>560</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "A",
|
||||||
|
explanation:
|
||||||
|
"Choice A is correct. The area <strong>A</strong>, in square inches, of a rectangle is the product of its length <strong>script l</strong>, in inches, and its width <strong>w</strong>, in inches; thus, <strong>A = script l w</strong>. It's given that the area of a rectangle is <strong>630</strong> square inches and the length of the rectangle is <strong>70</strong> inches. Substituting <strong>630</strong> for <strong>A</strong> and <strong>70</strong> for <strong>script l</strong> in the equation <strong>A = script l w</strong> yields <strong>630 = 70 w</strong>. Dividing both sides of this equation by <strong>70</strong> yields <strong>9 = w</strong>. Therefore, the width, in inches, of this rectangle is <strong>9</strong>.<br>Choice B is incorrect. This is the length, not the width, in inches, of the rectangle.<br>Choice C is incorrect. This is half the area, in square inches, not the width, in inches, of the rectangle.<br>Choice D is incorrect. This is the difference between the area, in square inches, and the length, in inches, of the rectangle, not the width, in inches, of the rectangle.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AREA_VOL_MEDIUM: PracticeQuestion[] = [
|
||||||
|
{
|
||||||
|
id: "08b7a3f5",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"A triangular prism has a height of <strong>8 centimeters (cm)</strong> and a volume of <strong>216 cm³</strong>. What is the area, <strong>in cm²</strong>, of the base of the prism? (The volume of a triangular prism is equal to <strong>B h</strong>, where <strong>B</strong> is the area of the base and <strong>h</strong> is the height of the prism.)",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "27",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>27</strong>. It's given that a triangular prism has a volume of <strong>216 cubic centimeters (cm³)</strong> and the volume of a triangular prism is equal to <strong>B h</strong>, where <strong>B</strong> is the area of the base and <strong>h</strong> is the height of the prism. Therefore, <strong>216 = B h</strong>. It's also given that the triangular prism has a height of <strong>8 cm</strong>. Therefore, <strong>h = 8</strong>. Substituting <strong>8</strong> for <strong>h</strong> in the equation <strong>216 = B h</strong> yields <strong>216 = B (8)</strong>. Dividing both sides of this equation by <strong>8</strong> yields <strong>27 = B</strong>. Therefore, the area, <strong>in cm²</strong>, of the base of the prism is <strong>27</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "151eda3c",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A manufacturing company produces two sizes of cylindrical containers that each have a height of 50 centimeters. The radius of container A is 16 centimeters, and the radius of container B is 25% longer than the radius of container A. What is the volume, in cubic centimeters, of container B?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>16, 000 π</strong>" },
|
||||||
|
{ label: "B", text: "<strong>20, 000 π</strong>" },
|
||||||
|
{ label: "C", text: "<strong>25, 000 π</strong>" },
|
||||||
|
{ label: "D", text: "<strong>31, 250 π</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "B",
|
||||||
|
explanation:
|
||||||
|
"Choice B is correct. If the radius of container A is 16 centimeters and the radius of container B is 25% longer than the radius of container A, then the radius of container B is <strong>16 + 0 . 2 5 · 16 = 20</strong> centimeters. The volume of a cylinder is <strong>π · r² · h</strong>, where r is the radius of the cylinder and h is its height. Substituting <strong>r = 20</strong> and <strong>h = 50</strong> into <strong>π · r² · h</strong> yields that the volume of cylinder B is <strong>π · (20, ), ² · 50 = 20, 000 π</strong> cubic centimeters.Choice A is incorrect and may result from multiplying the radius of cylinder B by the radius of cylinder A rather than squaring the radius of cylinder B. Choice C is incorrect and may result from multiplying the radius of cylinder B by 25 rather than squaring it. Choice D is incorrect and may result from taking the radius of cylinder B to be 25 centimeters rather than 20 centimeters.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "1f0b582e",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"Square X has a side length of <strong>12</strong> centimeters. The perimeter of square Y is <strong>2</strong> times the perimeter of square X. What is the length, in centimeters, of one side of square Y?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>6</strong>" },
|
||||||
|
{ label: "B", text: "<strong>10</strong>" },
|
||||||
|
{ label: "C", text: "<strong>14</strong>" },
|
||||||
|
{ label: "D", text: "<strong>24</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "D",
|
||||||
|
explanation:
|
||||||
|
"Choice D is correct. The perimeter, <strong>P</strong>, of a square can be found using the formula <strong>P = 4 s</strong>, where <strong>s</strong> is the length of each side of the square. It's given that square X has a side length of <strong>12</strong> centimeters. Substituting <strong>12</strong> for <strong>s</strong> in the formula for the perimeter of a square yields <strong>P = 4 (12)</strong>, or <strong>P = 48</strong>. Therefore, the perimeter of square X is <strong>48</strong> centimeters. It’s also given that the perimeter of square Y is <strong>2</strong> times the perimeter of square X. Therefore, the perimeter of square Y is <strong>2 (48)</strong>, or <strong>96</strong>, centimeters. Substituting <strong>96</strong> for <strong>P</strong> in the formula <strong>P = 4 s</strong> gives <strong>96 = 4 s</strong>. Dividing both sides of this equation by <strong>4</strong> gives <strong>24 = s</strong>. Therefore, the length of one side of square Y is <strong>24</strong> centimeters.<br>Choice A is incorrect and may result from conceptual or calculation errors.<br>Choice B is incorrect and may result from conceptual or calculation errors.<br>Choice C is incorrect and may result from conceptual or calculation errors.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "37dde49f",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"<strong>The figure presents a cylindrical shape with a circular base and a larger circular top. The diameter of the circular base is labeled “k over 2, ” the diameter of the circular top is labeled “k, ” and the height is labeled “k.” The volume of the figure = the fraction with numerator 7 π k³, and denominator 48</strong>The glass pictured above can hold a maximum volume of 473 cubic centimeters, which is approximately 16 fluid ounces. What is the value of k, in centimeters?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "2.52" },
|
||||||
|
{ label: "B", text: "7.67" },
|
||||||
|
{ label: "C", text: "7.79" },
|
||||||
|
{ label: "D", text: "10.11" },
|
||||||
|
],
|
||||||
|
correctAnswer: "D",
|
||||||
|
explanation:
|
||||||
|
"Choice D is correct. Using the volume formula <strong>V = the fraction with numerator 7 π · k³, and denominator 48</strong> and the given information that the volume of the glass is 473 cubic centimeters, the value of k can be found as follows:<br> <strong>473 = the fraction with numerator 7 π · k³, and denominator 48</strong><br><br> <strong>k³ = the fraction with numerator 473 · 48, and denominator 7 π, end fraction</strong><br><br> <strong>k = the cube root of the fraction with numerator 473 · 48, and denominator 7 π, end fraction, end root, which is ≈ 10 . 1 0 6 9 0</strong><br>Therefore, the value of k is approximately 10.11 centimeters.<br>Choices A, B, and C are incorrect. Substituting the values of k from these choices in the formula results in volumes of approximately 7 cubic centimeters, 207 cubic centimeters, and 217 cubic centimeters, respectively, all of which contradict the given information that the volume of the glass is 473 cubic centimeters.",
|
||||||
|
hasFigure: true,
|
||||||
|
figureUrl: "/practice-images/37dde49f_img1.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "38517165",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"A circle has a circumference of <strong>31 π</strong> centimeters. What is the diameter, in centimeters, of the circle?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "31",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>31</strong>. The circumference of a circle is equal to <strong>2 π r</strong> centimeters, where <strong>r</strong> represents the radius, in centimeters, of the circle, and the diameter of the circle is equal to <strong>2 r</strong> centimeters. It's given that a circle has a circumference of <strong>31 π</strong> centimeters. Therefore, <strong>31 π = 2 π r</strong>. Dividing both sides of this equation by <strong>π</strong> yields <strong>31 = 2 r</strong>. Since the diameter of the circle is equal to <strong>2 r</strong> centimeters, it follows that the diameter, in centimeters, of the circle is <strong>31</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5afbdc8e",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"What is the length of one side of a square that has the same area as a circle with radius 2 ?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "2" },
|
||||||
|
{ label: "B", text: "<strong>the √ 2 π, end root</strong>" },
|
||||||
|
{ label: "C", text: "<strong>2 · the √ π</strong>" },
|
||||||
|
{ label: "D", text: "<strong>2 π</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "C",
|
||||||
|
explanation:
|
||||||
|
"Choice C is correct. The area A of a circle with radius r is given by the formula <strong>A = π · r²</strong>. Thus, a circle with radius 2 has area <strong>π · 2²</strong>, which can be rewritten as <strong>4 π</strong>. The area of a square with side length s is given by the formula <strong>A = s²</strong>. Thus, if a square has the same area as a circle with radius 2, then <strong>s² = 4 π</strong>. Since the side length of a square must be a positive number, taking the square root of both sides of <strong>s² = 4 π</strong> gives <strong>s = the √ 4 π, end root</strong>. Using the properties of square roots, <strong>the √ 4 π, end root</strong> can be rewritten as <strong>(the √ 4, ) · (the √ π, )</strong>, which is equivalent to <strong>2 · the √ π</strong>. Therefore, <strong>s = 2 · the √ π</strong>.Choice A is incorrect. The side length of the square isn’t equal to the radius of the circle. Choices B and D are incorrect and may result from incorrectly simplifying the expression <strong>the √ 4 π, end root</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "a2e76b60",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A cylindrical can containing pieces of fruit is filled to the top with syrup before being sealed. The base of the can has an area of <strong>75 centimeters²</strong>, and the height of the can is 10 cm. If <strong>110 centimeters³</strong> of syrup is needed to fill the can to the top, which of the following is closest to the total volume of the pieces of fruit in the can?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>7 . 5 centimeters³</strong>" },
|
||||||
|
{ label: "B", text: "<strong>185 centimeters³</strong>" },
|
||||||
|
{ label: "C", text: "<strong>640 centimeters³</strong>" },
|
||||||
|
{ label: "D", text: "<strong>750 centimeters³</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "C",
|
||||||
|
explanation:
|
||||||
|
"Choice C is correct. The total volume of the cylindrical can is found by multiplying the area of the base of the can, <strong>75 square centimeters</strong>, by the height of the can, 10 cm, which yields <strong>750 cubic centimeters</strong>. If the syrup needed to fill the can has a volume of <strong>110 cubic centimeters</strong>, then the remaining volume for the pieces of<br><br>fruit is <strong>750 − 110 = 640 cubic centimeters</strong>.Choice A is incorrect because if the fruit had a volume of <strong>7 . 5 cubic centimeters</strong>, there would be <strong>750 − 7 . 5 = 742 . 5 cubic centimeters</strong> of syrup needed to fill the can to the top. Choice B is incorrect because if the fruit had a volume of <strong>185 cubic centimeters</strong>, there would be <strong>750 − 185 = 565 cubic centimeters</strong> of syrup needed to fill the can to the top. Choice D is incorrect because it is the total volume of the can, not just of the pieces of fruit.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c0586eb5",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A cylinder has a diameter of <strong>8</strong> inches and a height of <strong>12</strong> inches. What is the volume, in cubic inches, of the cylinder?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>16 π</strong>" },
|
||||||
|
{ label: "B", text: "<strong>96 π</strong>" },
|
||||||
|
{ label: "C", text: "<strong>192 π</strong>" },
|
||||||
|
{ label: "D", text: "<strong>768 π</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "C",
|
||||||
|
explanation:
|
||||||
|
"Choice C is correct. The base of a cylinder is a circle with a diameter equal to the diameter of the cylinder. The volume, <strong>V</strong>, of a cylinder can be found by multiplying the area of the circular base, <strong>A</strong>, by the height of the cylinder, <strong>h</strong>, or <strong>V = A h</strong>. The area of a circle can be found using the formula <strong>A = π r²</strong>, where <strong>r</strong> is the radius of the circle. It’s given that the diameter of the cylinder is <strong>8</strong> inches. Thus, the radius of this circle is <strong>4</strong> inches. Therefore, the area of the circular base of the cylinder is <strong>A = π (4)²</strong>, or <strong>16 π</strong> square inches. It’s given that the height <strong>h</strong> of the cylinder is <strong>12</strong> inches. Substituting <strong>16 π</strong> for <strong>A</strong> and <strong>12</strong> for <strong>h</strong> in the formula <strong>V = A h</strong> gives <strong>V = 16 π (12)</strong>, or <strong>192 π</strong> cubic inches.<br>Choice A is incorrect. This is the area of the circular base of the cylinder.<br>Choice B is incorrect and may result from using <strong>8</strong>, instead of <strong>16</strong>, as the value of <strong>r²</strong> in the formula for the area of a circle.<br>Choice D is incorrect and may result from using <strong>8</strong>, instead of <strong>4</strong>, for the radius of the circular base.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cf53cb56",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"In the xy-plane shown, square ABCD has its diagonals on the x- and y-axes. What is the area, in square units, of the square?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "20" },
|
||||||
|
{ label: "B", text: "25" },
|
||||||
|
{ label: "C", text: "50" },
|
||||||
|
{ label: "D", text: "100" },
|
||||||
|
],
|
||||||
|
correctAnswer: "C",
|
||||||
|
explanation:
|
||||||
|
"Choice C is correct. The two diagonals of square ABCD divide the square into 4 congruent right triangles, where each triangle has a vertex at the origin of the graph shown. The formula for the area of a triangle is <strong>A = one half · b h</strong>, where b is the base length of the triangle and h is the height of the triangle. Each of the 4 congruent right triangles has a height of 5 units and a base length of 5 units. Therefore, the area of each triangle is <strong>A = one half · 5 · 5</strong>, or 12.5 square units. Since the 4 right triangles are congruent, the area of each is <strong>one fourth</strong> of the area of square ABCD. It follows that the area of the square ABCD is equal to <strong>4 · 12 . 5</strong>, or 50 square units.Choices A and D are incorrect and may result from using 5 or 25, respectively, as the area of one of the 4 congruent right triangles formed by diagonals of square ABCD. However, the area of these triangles is 12.5. Choice B is incorrect and may result from using 5 as the length of one side of square ABCD. However, the length of a side of square ABCD is <strong>5 · the √ 2</strong>.",
|
||||||
|
hasFigure: true,
|
||||||
|
figureUrl: "/practice-images/cf53cb56_img1.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e336a1d2",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A cube has an edge length of <strong>41</strong> inches. What is the volume, in cubic inches, of the cube?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>164</strong>" },
|
||||||
|
{ label: "B", text: "<strong>1, 681</strong>" },
|
||||||
|
{ label: "C", text: "<strong>10, 086</strong>" },
|
||||||
|
{ label: "D", text: "<strong>68, 921</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "D",
|
||||||
|
explanation:
|
||||||
|
"Choice D is correct. The volume, <strong>V</strong>, of a cube can be found using the formula <strong>V = s³</strong>, where <strong>s</strong> is the edge length of the cube. It's given that a cube has an edge length of <strong>41</strong> inches. Substituting <strong>41</strong> inches for <strong>s</strong> in this equation yields <strong>V = 41³</strong> cubic inches, or <strong>V = 68, 921</strong> cubic inches. Therefore, the volume of the cube is <strong>68, 921</strong> cubic inches.<br>Choice A is incorrect. This is the perimeter, in inches, of the cube.<br>Choice B is incorrect. This is the area, in square inches, of a face of the cube.<br>Choice C is incorrect. This is the surface area, in square inches, of the cube.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ec5d4823",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"What is the volume, in cubic centimeters, of a right rectangular prism that has a length of 4 centimeters, a width of 9 centimeters, and a height of 10 centimeters?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is 360. The volume of a right rectangular prism is calculated by multiplying its dimensions: length, width, and height. Multiplying the values given for these dimensions yields a volume of <strong>4 · 9 · 10 = 360</strong> cubic centimeters.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "f67e4efc",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A right circular cylinder has a volume of <strong>45 π</strong>. If the height of the cylinder is 5, what is the radius of the cylinder?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "3" },
|
||||||
|
{ label: "B", text: "4.5" },
|
||||||
|
{ label: "C", text: "9" },
|
||||||
|
{ label: "D", text: "40" },
|
||||||
|
],
|
||||||
|
correctAnswer: "A",
|
||||||
|
explanation:
|
||||||
|
"Choice A is correct. The volume of a right circular cylinder with a radius of r is the product of the area of the base, <strong>π, r²</strong>, and the height, h. The volume of the right circular cylinder described is <strong>45 π</strong> and its height is 5. If the radius is r, it follows that <strong>45 π = π · r, ² · 5</strong>. Dividing both sides of this equation by <strong>5 π</strong> yields <strong>9 = r²</strong>. Taking the square root of both sides yields <strong>r = 3</strong> or <strong>r = −3</strong>. Since r represents the radius, the value must be positive. Therefore, the radius is 3.Choice B is incorrect and may result from finding that the square of the radius is 9, but then from dividing 9 by 2, rather than taking the square root of 9. Choice C is incorrect. This represents the square of the radius. Choice D is incorrect and may result from solving the equation <strong>45 π = π · r, ² · 5</strong> for <strong>r²</strong>, not r, by dividing by <strong>π</strong> on both sides and then by subtracting, not dividing, 5 from both sides.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AREA_VOL_HARD: PracticeQuestion[] = [
|
||||||
|
{
|
||||||
|
id: "306264ab",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A right triangle has sides of length <strong>2 √(2)</strong>, <strong>6 √(2)</strong>, and <strong>√(80)</strong> units. What is the area of the triangle, in square units?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>8 √(2) + √(80)</strong>" },
|
||||||
|
{ label: "B", text: "<strong>12</strong>" },
|
||||||
|
{ label: "C", text: "<strong>24 √(80)</strong>" },
|
||||||
|
{ label: "D", text: "<strong>24</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "B",
|
||||||
|
explanation:
|
||||||
|
"Choice B is correct. The area, <strong>A</strong>, of a triangle can be found using the formula <strong>A = one half b h</strong>, where <strong>b</strong> is the length of the base of the triangle and <strong>h</strong> is the height of the triangle. It's given that the triangle is a right triangle. Therefore, its base and height can be represented by the two legs. It’s also given that the triangle has sides of length <strong>2 √(2)</strong>, <strong>6 √(2)</strong>, and <strong>√(80)</strong> units. Since <strong>√(80)</strong> units is the greatest of these lengths, it's the length of the hypotenuse. Therefore, the two legs have lengths <strong>2 √(2)</strong> and <strong>6 √(2)</strong> units. Substituting these values for <strong>b</strong> and <strong>h</strong> in the formula <strong>A = one half b h</strong> gives <strong>A = one half (2 √(2)) (6 √(2))</strong>, which is equivalent to <strong>A = 6 √(4)</strong> square units, or <strong>A = 12</strong> square units.<br>Choice A is incorrect. This expression represents the perimeter, rather than the area, of the triangle.<br>Choice C is incorrect and may result from conceptual or calculation errors. <br>Choice D is incorrect and may result from conceptual or calculation errors.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "310c87fe",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A cube has a surface area of 54 square meters. What is the volume, in cubic meters, of the cube?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "18" },
|
||||||
|
{ label: "B", text: "27" },
|
||||||
|
{ label: "C", text: "36" },
|
||||||
|
{ label: "D", text: "81" },
|
||||||
|
],
|
||||||
|
correctAnswer: "B",
|
||||||
|
explanation:
|
||||||
|
"Choice B is correct. The surface area of a cube with side length s is equal to <strong>6 s²</strong>. Since the surface area is given as 54 square meters, the equation <strong>54 = 6 s²</strong> can be used to solve for s. Dividing both sides of the equation by 6 yields <strong>9 = s²</strong>. Taking the square root of both sides of this equation yields <strong>3 = s</strong> and <strong>−3 = s</strong>. Since the side length of a cube must be a positive value, <strong>s = −3</strong> can be discarded as a possible solution, leaving <strong>s = 3</strong>. The volume of a cube with side length s is equal to <strong>s³</strong>. Therefore, the volume of this cube, in cubic meters, is <strong>3³</strong>, or 27.Choices A, C, and D are incorrect and may result from calculation errors.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "459dd6c5",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"Triangles <strong>italic A italic B italic C</strong> and <strong>italic D italic E italic F</strong> are similar. Each side length of triangle <strong>italic A italic B italic C</strong> is <strong>4</strong> times the corresponding side length of triangle <strong>italic D italic E italic F</strong>. The area of triangle <strong>italic A italic B italic C</strong> is <strong>270</strong> square inches. What is the area, in square inches, of triangle <strong>italic D italic E italic F</strong>?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "135/8, 16.87, 16.88",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>(135) / (8)</strong>. It's given that triangles <strong>italic A italic B italic C</strong> and <strong>italic D italic E italic F</strong> are similar and each side length of triangle <strong>italic A italic B italic C</strong> is <strong>4</strong> times the corresponding side length of triangle <strong>italic D italic E italic F</strong>. For two similar triangles, if each side length of the first triangle is <strong>k</strong> times the corresponding side length of the second triangle, then the area of the first triangle is <strong>k²</strong> times the area of the second triangle. Therefore, the area of triangle <strong>italic A italic B italic C</strong> is <strong>4²</strong>, or <strong>16</strong>, times the area of triangle <strong>italic D italic E italic F</strong>. It's given that the area of triangle <strong>italic A italic B italic C</strong> is <strong>270</strong> square inches. Let <strong>a</strong> represent the area, in square inches, of triangle <strong>italic D italic E italic F</strong>. It follows that <strong>270</strong> is <strong>16</strong> times <strong>a</strong>, or <strong>270 = 16 a</strong>. Dividing both sides of this equation by <strong>16</strong> yields <strong>(270) / (16) = a</strong>, which is equivalent to <strong>(135) / (8) = a</strong>. Thus, the area, in square inches, of triangle <strong>italic D italic E italic F</strong> is <strong>(135) / (8)</strong>. Note that 135/8, 16.87, and 16.88 are examples of ways to enter a correct answer.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5b2b8866",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"A rectangular poster has an area of <strong>360</strong> square inches. A copy of the poster is made in which the length and width of the original poster are each increased by <strong>20 % sign</strong>. What is the area of the copy, in square inches?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "2592/5, 518.4",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>518.4</strong>. It's given that the area of the original poster is <strong>360</strong> square inches. Let <strong>script l</strong> represent the length, in inches, of the original poster, and let <strong>w</strong> represent the width, in inches, of the original poster. Since the area of a rectangle is equal to its length times its width, it follows that <strong>360 = script l w</strong>. It's also given that a copy of the poster is made in which the length and width of the original poster are each increased by <strong>20 % sign</strong>. It follows that the length of the copy is the length of the original poster plus <strong>20 % sign</strong> of the length of the original poster, which is equivalent to <strong>script l + (20) / (100) script l</strong> inches. This length can be rewritten as <strong>script l + 0.2 script l</strong> inches, or <strong>1.2 script l</strong> inches. Similarly, the width of the copy is the width of the original poster plus <strong>20 % sign</strong> of the width of the original poster, which is equivalent to <strong>w + (20) / (100) w</strong> inches. This width can be rewritten as <strong>w + 0.2 w</strong> inches, or <strong>1.2 w</strong> inches. Since the area of a rectangle is equal to its length times its width, it follows that the area, in square inches, of the copy is equal to <strong>(1.2 script l) (1.2 w)</strong>, which can be rewritten as <strong>(1.2) (1.2) (script l w)</strong>. Since <strong>360 = script l w</strong>, the area, in square inches, of the copy can be found by substituting <strong>360</strong> for <strong>script l w</strong> in the expression <strong>(1.2) (1.2) (script l w)</strong>, which yields <strong>(1.2) (1.2) (360)</strong>, or <strong>518.4</strong>. Therefore, the area of the copy, in square inches, is <strong>518.4</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "899c6042",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"A right circular cone has a height of <strong>22 centimeters (cm)</strong> and a base with a diameter of <strong>6 cm</strong>. The volume of this cone is <strong>n π cm³</strong>. What is the value of <strong>n</strong>?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "66",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>66</strong>. It’s given that the right circular cone has a height of <strong>22</strong> centimeters <strong>(cm)</strong> and a base with a diameter of <strong>6 cm</strong>. Since the diameter of the base of the cone is <strong>6 cm</strong>, the radius of the base is <strong>3 cm</strong>. The volume <strong>V</strong>, <strong>in cm³</strong>, of a right circular cone can be found using the formula <strong>V = one third π r² h</strong>, where <strong>h</strong> is the height, <strong>in cm</strong>, and <strong>r</strong> is the radius, <strong>in cm</strong>, of the base of the cone. Substituting <strong>22</strong> for <strong>h</strong> and <strong>3</strong> for <strong>r</strong> in this formula yields <strong>V = one third π (3)² (22)</strong>, or <strong>V = 66 π</strong>. Therefore, the volume of the cone is <strong>66 π italic cm³</strong>. It’s given that the volume of the cone is <strong>n π italic cm³</strong>. Therefore, the value of <strong>n</strong> is <strong>66</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "93de3f84",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"The volume of right circular cylinder A is 22 cubic centimeters. What is the volume, in cubic centimeters, of a right circular cylinder with twice the radius and half the height of cylinder A?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "11" },
|
||||||
|
{ label: "B", text: "22" },
|
||||||
|
{ label: "C", text: "44" },
|
||||||
|
{ label: "D", text: "66" },
|
||||||
|
],
|
||||||
|
correctAnswer: "C",
|
||||||
|
explanation:
|
||||||
|
"Choice C is correct. The volume of right circular cylinder A is given by the expression <strong>π r² · h</strong>, where r is the radius of its circular base and h is its height. The volume of a cylinder with twice the radius and half the height of cylinder A is given by <strong>π · (2 r, ), ² · one half h</strong>, which is equivalent to <strong>4 π r² · one half h, and = 2 π r² · h</strong>. Therefore, the volume is twice the volume of cylinder A, or <strong>2 · 22 = 44</strong>.Choice A is incorrect and likely results from not multiplying the radius of cylinder A by 2. Choice B is incorrect and likely results from not squaring the 2 in 2r when applying the volume formula. Choice D is incorrect and likely results from a conceptual error.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "9966235e",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A cube has an edge length of <strong>68</strong> inches. A solid sphere with a radius of <strong>34</strong> inches is inside the cube, such that the sphere touches the center of each face of the cube. To the nearest cubic inch, what is the volume of the space in the cube not taken up by the sphere?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>149, 796</strong>" },
|
||||||
|
{ label: "B", text: "<strong>164, 500</strong>" },
|
||||||
|
{ label: "C", text: "<strong>190, 955</strong>" },
|
||||||
|
{ label: "D", text: "<strong>310, 800</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "A",
|
||||||
|
explanation:
|
||||||
|
"Choice A is correct. The volume of a cube can be found by using the formula <strong>V = s³</strong>, where <strong>V</strong> is the volume and <strong>s</strong> is the edge length of the cube. Therefore, the volume of the given cube is <strong>V = 68³</strong>, or <strong>314, 432</strong> cubic inches. The volume of a sphere can be found by using the formula <strong>V = four thirds π r³</strong> , where <strong>V</strong> is the volume and <strong>r</strong> is the radius of the sphere. Therefore, the volume of the given sphere is <strong>V = four thirds π (34)³</strong>, or approximately <strong>164, 636</strong> cubic inches. The volume of the space in the cube not taken up by the sphere is the difference between the volume of the cube and volume of the sphere. Subtracting the approximate volume of the sphere from the volume of the cube gives <strong>314, 432 − 164, 636 = 149, 796</strong> cubic inches.<br>Choice B is incorrect and may result from conceptual or calculation errors.<br>Choice C is incorrect and may result from conceptual or calculation errors.<br>Choice D is incorrect and may result from conceptual or calculation errors.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "9f934297",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"A right rectangular prism has a length of <strong>28 centimeters (cm)</strong>, a width of <strong>15 cm</strong>, and a height of <strong>16 cm</strong>. What is the surface area, <strong>in cm²</strong>, of the right rectangular prism?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "2216",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>2, 216</strong>. The surface area of a prism is the sum of the areas of all its faces. A right rectangular prism consists of six rectangular faces, where opposite faces are congruent. It's given that this prism has a length of <strong>28 cm</strong>, a width of <strong>15 cm</strong>, and a height of <strong>16 cm</strong>. Thus, for this prism, there are two faces with area <strong>(28) (15) cm²</strong>, two faces with area <strong>(28) (16) cm²</strong>, and two faces with area <strong>(15) (16) cm²</strong>. Therefore, the surface area, <strong>in cm²</strong>, of the right rectangular prism is <strong>2 (28) (15) + 2 (28) (16) + 2 (15) (16)</strong>, or <strong>2, 216</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "a07ed090",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"The figure shown is a right circular cylinder with a radius of <strong>r</strong> and height of <strong>h</strong>. A second right circular cylinder (not shown) has a volume that is <strong>392</strong> times as large as the volume of the cylinder shown. Which of the following could represent the radius <strong>R</strong>, in terms of <strong>r</strong>, and the height <strong>H</strong>, in terms of <strong>h</strong>, of the second cylinder?",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
label: "A",
|
||||||
|
text: "<strong>R = 8 r</strong> and <strong>H = 7 h</strong>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "B",
|
||||||
|
text: "<strong>R = 8 r</strong> and <strong>H = 49 h</strong>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "C",
|
||||||
|
text: "<strong>R = 7 r</strong> and <strong>H = 8 h</strong>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "D",
|
||||||
|
text: "<strong>R = 49 r</strong> and <strong>H = 8 h</strong>",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
correctAnswer: "C",
|
||||||
|
explanation:
|
||||||
|
"Choice C is correct. The volume of a right circular cylinder is equal to <strong>π a² b</strong>, where <strong>a</strong> is the radius of a base of the cylinder and <strong>b</strong> is the height of the cylinder. It’s given that the cylinder shown has a radius of <strong>r</strong> and a height of <strong>h</strong>. It follows that the volume of the cylinder shown is equal to <strong>π r² h</strong>. It’s given that the second right circular cylinder has a radius of <strong>R</strong> and a height of <strong>H</strong>. It follows that the volume of the second cylinder is equal to <strong>π R² H</strong>. Choice C gives <strong>R = 7 r</strong> and <strong>H = 8 h</strong>. Substituting <strong>7 r</strong> for <strong>R</strong> and <strong>8 h</strong> for <strong>H</strong> in the expression that represents the volume of the second cylinder yields <strong>π (7 r)² (8 h)</strong>, or <strong>π (49 r²) (8 h)</strong>, which is equivalent to <strong>π (392 r² h)</strong>, or <strong>392 (π r² h)</strong>. This expression is equal to <strong>392</strong> times the volume of the cylinder shown, <strong>π r² h</strong>. Therefore, <strong>R = 7 r</strong> and <strong>H = 8 h</strong> could represent the radius <strong>R</strong>, in terms of <strong>r</strong>, and the height <strong>H</strong>, in terms of <strong>h</strong>, of the second cylinder.<br>Choice A is incorrect. Substituting <strong>8 r</strong> for <strong>R</strong> and <strong>7 h</strong> for <strong>H</strong> in the expression that represents the volume of the second cylinder yields <strong>π (8 r)² (7 h)</strong>, or <strong>π (64 r²) (7 h)</strong>, which is equivalent to <strong>π (448 r² h)</strong>, or <strong>448 (π r² h)</strong>. This expression is equal to <strong>448</strong>, not <strong>392</strong>, times the volume of the cylinder shown. <br>Choice B is incorrect. Substituting <strong>8 r</strong> for <strong>R</strong> and <strong>49 h</strong> for <strong>H</strong> in the expression that represents the volume of the second cylinder yields <strong>π (8 r)² (49 h)</strong>, or <strong>π (64 r²) (49 h)</strong>, which is equivalent to <strong>π (3, 136 r² h)</strong>, or <strong>3, 136 (π r² h)</strong>. This expression is equal to <strong>3, 136</strong>, not <strong>392</strong>, times the volume of the cylinder shown.<br>Choice D is incorrect. Substituting <strong>49 r</strong> for <strong>R</strong> and <strong>8 h</strong> for <strong>H</strong> in the expression that represents the volume of the second cylinder yields <strong>π (49 r)² (8 h)</strong>, or <strong>π (2, 401 r²) (8 h)</strong>, which is equivalent to <strong>π (19, 208 r² h)</strong>, or <strong>19, 208 (π r² h)</strong>. This expression is equal to <strong>19, 208</strong>, not <strong>392</strong>, times the volume of the cylinder shown.",
|
||||||
|
hasFigure: true,
|
||||||
|
figureUrl: "/practice-images/a07ed090_svg1.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b0dc920d",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A manufacturer determined that right cylindrical containers with a height that is 4 inches longer than the radius offer the optimal number of containers to be displayed on a shelf. Which of the following expresses the volume, V, in cubic inches, of such containers, where r is the radius, in inches?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>V = 4 π r³</strong>" },
|
||||||
|
{ label: "B", text: "<strong>V = π · (2 r, ), ³</strong>" },
|
||||||
|
{ label: "C", text: "<strong>V = π r² + 4 π r</strong>" },
|
||||||
|
{ label: "D", text: "<strong>V = π r³ + 4 π r²</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "D",
|
||||||
|
explanation:
|
||||||
|
"Choice D is correct. The volume, V, of a right cylinder is given by the formula <strong>V = π r² · h</strong>, where r represents the radius of the base of the cylinder and h represents the height. Since the height is 4 inches longer than the radius, the expression <strong>r + 4</strong> represents the height of each cylindrical container. It follows that the volume of each container is represented by the equation <strong>V = π r² · (r + 4, )</strong>. Distributing the expression <strong>π r²</strong> into each term in the parentheses yields <strong>V = π r³ + 4 π r²</strong>.Choice A is incorrect and may result from representing the height as <strong>4 r</strong> instead of <strong>r + 4</strong>. Choice B is incorrect and may result from representing the height as <strong>2 r</strong> instead of <strong>r + 4</strong>. Choice C is incorrect and may result from representing the volume of a right cylinder as <strong>V = π r h</strong> instead of <strong>V = π r² · h</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ba8ca563",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"A cube has a volume of <strong>474, 552</strong> cubic units. What is the surface area, in square units, of the cube?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "36504",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>36, 504</strong>. The volume of a cube can be found using the formula <strong>V = s³</strong>, where <strong>s</strong> represents the edge length of a cube. It’s given that this cube has a volume of <strong>474, 552</strong> cubic units. Substituting <strong>474, 552</strong> for <strong>V</strong> in <strong>V = s³</strong> yields <strong>474, 552 = s³</strong>. Taking the cube root of both sides of this equation yields <strong>78 = s</strong>. Thus, the edge length of the cube is <strong>78</strong> units. Since each face of a cube is a square, it follows that each face has an edge length of <strong>78</strong> units. The area of a square can be found using the formula <strong>A = s²</strong>. Substituting <strong>78</strong> for <strong>s</strong> in this formula yields <strong>A = 78²</strong>, or <strong>A = 6, 084</strong>. Therefore, the area of one face of this cube is <strong>6, 084</strong> square units. Since a cube has <strong>6</strong> faces, the surface area, in square units, of this cube is <strong>6 (6, 084)</strong>, or <strong>36, 504</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dc71597b",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A right circular cone has a volume of <strong>one third, π</strong> cubic feet and a height of 9 feet. What is the radius, in feet, of the base of the cone?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>one third</strong>" },
|
||||||
|
{
|
||||||
|
label: "B",
|
||||||
|
text: "<strong>the fraction 1 over the √ 3, end fraction</strong>",
|
||||||
|
},
|
||||||
|
{ label: "C", text: "<strong>the √ 3</strong>" },
|
||||||
|
{ label: "D", text: "<strong>3</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "A",
|
||||||
|
explanation:
|
||||||
|
"Choice A is correct. The equation for the volume of a right circular cone is <strong>V = one third π r² · h</strong>. It’s given that the volume of the right circular cone is <strong>one third π</strong> cubic feet and the height is 9 feet. Substituting these values for V and h, respectively, gives <strong>one third π = one third π r² · 9</strong>. Dividing both sides of the equation by <strong>one third π</strong> gives <strong>1 = r² · 9</strong>. Dividing both sides of the equation by 9 gives <strong>one ninth = r²</strong>. Taking the square root of both sides results in two possible values for the radius, <strong>the √ one ninth</strong> or <strong>the − of the √ one ninth</strong>. Since the radius can’t have a negative value, that leaves <strong>the √ one ninth</strong> as the only possibility. Applying the quotient property of square roots, <strong>the √ the fraction a, over b = the fraction the √ a, over the √ b</strong>, results in <strong>r = the fraction the √ 1 over the √ 9</strong>, or <strong>r = one third</strong>.Choices B and C are incorrect and may result from incorrectly evaluating <strong>the √ one ninth</strong>. Choice D is incorrect and may result from solving <strong>r² = 9</strong> instead of <strong>r² = one ninth</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e5c57163",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"Square A has side lengths that are <strong>166</strong> times the side lengths of square B. The area of square A is <strong>k</strong> times the area of square B. What is the value of <strong>k</strong>?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "27556",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>27, 556</strong>. The area of a square is <strong>s²</strong>, where <strong>s</strong> is the side length of the square. Let <strong>x</strong> represent the length of each side of square B. Substituting <strong>x</strong> for <strong>s</strong> in <strong>s²</strong> yields <strong>x²</strong>. It follows that the area of square B is <strong>x²</strong>. It’s given that square A has side lengths that are <strong>166</strong> times the side lengths of square B. Since <strong>x</strong> represents the length of each side of square B, the length of each side of square A can be represented by the expression <strong>166 x</strong>. It follows that the area of square A is <strong>(166 x)²</strong>, or <strong>27, 556 x²</strong>. It’s given that the area of square A is <strong>k</strong> times the area of square B. Since the area of square A is equal to <strong>27, 556 x²</strong>, and the area of square B is equal to <strong>x²</strong>, an equation representing the given statement is <strong>27, 556 x² = k x²</strong>. Since <strong>x</strong> represents the length of each side of square B, the value of <strong>x</strong> must be positive. Therefore, the value of <strong>x²</strong> is also positive, so it does not equal <strong>0</strong>. Dividing by <strong>x²</strong> on both sides of the equation <strong>27, 556 x² = k x²</strong> yields <strong>27, 556 = k</strong>. Therefore, the value of <strong>k</strong> is <strong>27, 556</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "eb70d2d0",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"Moving from left to right, the points have the following coordinates:<br><br>(negative 3 comma 4)<br>(4 comma negative 3)<br>(5 comma 3)<br><br>What is the area, in square units, of the triangle formed by connecting the three points shown?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "24.5, 49/2",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>24.5</strong>. It's given that a triangle is formed by connecting the three points shown, which are <strong>(−3, 4)</strong>, <strong>(5, 3)</strong>, and <strong>(4 −3)</strong>. Let this triangle be triangle A. The area of triangle A can be found by calculating the area of the rectangle that circumscribes it and subtracting the areas of the three triangles that are inside the rectangle but outside triangle A. The rectangle formed by the points <strong>(−3, 4)</strong>, <strong>(5, 4)</strong>, <strong>(5 −3)</strong>, and <strong>(−3 −3)</strong> circumscribes triangle A. The width, in units, of this rectangle can be found by calculating the distance between the points <strong>(5, 4)</strong> and <strong>(5 −3)</strong>. This distance is <strong>4 − (−3)</strong>, or <strong>7</strong>. The length, in units, of this rectangle can be found by calculating the distance between the points <strong>(5, 4)</strong> and <strong>(−3, 4)</strong>. This distance is <strong>5 − (−3)</strong>, or <strong>8</strong>. It follows that the area, in square units, of the rectangle is <strong>(7) (8)</strong>, or <strong>56</strong>. One of the triangles that lies inside the rectangle but outside triangle A is formed by the points <strong>(−3, 4)</strong>, <strong>(5, 4)</strong>, and <strong>(5, 3)</strong>. The length, in units, of a base of this triangle can be found by calculating the distance between the points <strong>(5, 4)</strong> and <strong>(5, 3)</strong>. This distance is <strong>4 − 3</strong>, or <strong>1</strong>. The corresponding height, in units, of this triangle can be found by calculating the distance between the points <strong>(5, 4)</strong> and <strong>(−3, 4)</strong>. This distance is <strong>5 − (−3)</strong>, or <strong>8</strong>. It follows that the area, in square units, of this triangle is <strong>one half (8) (1)</strong>, or <strong>4</strong>. A second triangle that lies inside the rectangle but outside triangle A is formed by the points <strong>(4 −3)</strong>, <strong>(5, 3)</strong>, and <strong>(5 −3)</strong>. The length, in units, of a base of this triangle can be found by calculating the distance between the points <strong>(5, 3)</strong> and <strong>(5 −3)</strong>. This distance is <strong>3 − (−3)</strong> , or <strong>6</strong>. The corresponding height, in units, of this triangle can be found by calculating the distance between the points <strong>(5 −3)</strong> and <strong>(4 −3)</strong>. This distance is <strong>5 − 4</strong>, or <strong>1</strong>. It follows that the area, in square units, of this triangle is <strong>one half (1) (6)</strong>, or <strong>3</strong>. The third triangle that lies inside the rectangle but outside triangle A is formed by the points <strong>(−3, 4)</strong>, <strong>(−3 −3)</strong>, and <strong>(4 −3)</strong>. The length, in units, of a base of this triangle can be found by calculating the distance between the points <strong>(4 −3)</strong> and <strong>(−3 −3)</strong>. This distance is <strong>4 − (−3)</strong>, or <strong>7</strong>. The corresponding height, in units, of this triangle can be found by calculating the distance between the points <strong>(−3, 4)</strong> and <strong>(−3 −3)</strong>. This distance is <strong>4 − (−3)</strong>, or <strong>7</strong>. It follows that the area, in square units, of this triangle is <strong>one half (7) (7)</strong>, or <strong>24.5</strong>. Thus, the area, in square units, of the triangle formed by connecting the three points shown is <strong>56 − 4 − 3 − 24.5</strong>, or <strong>24.5</strong>. Note that 24.5 and 49/2 are examples of ways to enter a correct answer.",
|
||||||
|
hasFigure: true,
|
||||||
|
figureUrl: "/practice-images/eb70d2d0_svg1.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "f243c383",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"Two identical rectangular prisms each have a height of <strong>90 centimeters (cm)</strong>. The base of each prism is a square, and the surface area of each prism is <strong>K cm²</strong>. If the prisms are glued together along a square base, the resulting prism has a surface area of <strong>(92) / (47) K cm²</strong>. What is the side length, in <strong>cm</strong>, of each square base?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>4</strong>" },
|
||||||
|
{ label: "B", text: "<strong>8</strong>" },
|
||||||
|
{ label: "C", text: "<strong>9</strong>" },
|
||||||
|
{ label: "D", text: "<strong>16</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "B",
|
||||||
|
explanation:
|
||||||
|
"Choice B is correct. Let <strong>x</strong> represent the side length, in <strong>cm</strong>, of each square base. If the two prisms are glued together along a square base, the resulting prism has a surface area equal to twice the surface area of one of the prisms, minus the area of the two square bases that are being glued together, which yields <strong>2 K − 2 x² cm²</strong> . It’s given that this resulting surface area is equal to <strong>(92) / (47) K cm²</strong>, so <strong>2 K − 2 x² = (92) / (47) K</strong>. Subtracting <strong>(92) / (47) K</strong> from both sides of this equation yields <strong>2 K − (92) / (47) K − 2 x² = 0</strong>. This equation can be rewritten by multiplying <strong>2 K</strong> on the left-hand side by <strong>(47) / (47)</strong>, which yields <strong>(94) / (47) K − (92) / (47) K − 2 x² = 0</strong>, or <strong>two forty sevenths K − 2 x² = 0</strong>. Adding <strong>2 x²</strong> to both sides of this equation yields <strong>two forty sevenths K = 2 x²</strong>. Multiplying both sides of this equation by <strong>(47) / (2)</strong> yields <strong>K = 47 x²</strong>. The surface area <strong>K</strong>, in <strong>cm²</strong>, of each rectangular prism is equivalent to the sum of the areas of the two square bases and the areas of the four lateral faces. Since the height of each rectangular prism is <strong>90 cm</strong> and the side length of each square base is <strong>x cm</strong>, it follows that the area of each square base is <strong>x² cm²</strong> and the area of each lateral face is <strong>90 x cm²</strong>. Therefore, the surface area of each rectangular prism can be represented by the expression <strong>2 x² + 4 (90 x)</strong>, or <strong>2 x² + 360 x</strong>. Substituting this expression for <strong>K</strong> in the equation <strong>K = 47 x²</strong> yields <strong>2 x² + 360 x = 47 x²</strong>. Subtracting <strong>2 x²</strong> and <strong>360 x</strong> from both sides of this equation yields <strong>0 = 45 x² − 360 x</strong>. Factoring <strong>x</strong> from the right-hand side of this equation yields <strong>0 = x (45 x − 360)</strong>. Applying the zero product property, it follows that <strong>x = 0</strong> and <strong>45 x − 360 = 0</strong>. Adding <strong>360</strong> to both sides of the equation <strong>45 x − 360 = 0</strong> yields <strong>45 x = 360</strong>. Dividing both sides of this equation by <strong>45</strong> yields <strong>x = 8</strong>. Since a side length of a rectangular prism can’t be <strong>0</strong>, the length of each square base is <strong>8</strong> <strong>cm</strong>.<br>Choice A is incorrect and may result from conceptual or calculation errors.<br>Choice C is incorrect and may result from conceptual or calculation errors.<br>Choice D is incorrect and may result from conceptual or calculation errors.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "f329442c",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"Circle <strong>A</strong> has a radius of <strong>3 n</strong> and circle <strong>B</strong> has a radius of <strong>129 n</strong>, where <strong>n</strong> is a positive constant. The area of circle <strong>B</strong> is how many times the area of circle <strong>A</strong>?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>43</strong>" },
|
||||||
|
{ label: "B", text: "<strong>86</strong>" },
|
||||||
|
{ label: "C", text: "<strong>129</strong>" },
|
||||||
|
{ label: "D", text: "<strong>1, 849</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "D",
|
||||||
|
explanation:
|
||||||
|
"Choice D is correct. The area of a circle can be found by using the formula <strong>A = π r²</strong>, where <strong>A</strong> is the area and <strong>r</strong> is the radius of the circle. It’s given that the radius of circle A is <strong>3 n</strong>. Substituting this value for <strong>r</strong> into the formula <strong>A = π r²</strong> gives <strong>A = π (3 n)²</strong>, or <strong>9 π n²</strong>. It’s also given that the radius of circle B is <strong>129 n</strong>. Substituting this value for <strong>r</strong> into the formula <strong>A = π r²</strong> gives <strong>A = π (129 n)²</strong>, or <strong>16, 641 π n²</strong>. Dividing the area of circle B by the area of circle A gives <strong>(16, 641 π n²) / (9 π n²)</strong>, which simplifies to <strong>1, 849</strong>. Therefore, the area of circle B is <strong>1, 849</strong> times the area of circle A.<br>Choice A is incorrect. This is how many times greater the radius of circle B is than the radius of circle A.<br>Choice B is incorrect and may result from conceptual or calculation errors.<br>Choice C is incorrect. This is the coefficient on the term that describes the radius of circle B.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "f7e626b2",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"The dimensions of a right rectangular prism are 4 inches by 5 inches by 6 inches. What is the surface area, in square inches, of the prism?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "30" },
|
||||||
|
{ label: "B", text: "74" },
|
||||||
|
{ label: "C", text: "120" },
|
||||||
|
{ label: "D", text: "148" },
|
||||||
|
],
|
||||||
|
correctAnswer: "",
|
||||||
|
explanation:
|
||||||
|
"Choice D is correct. The surface area is found by summing the area of each face. A right rectangular prism consists of three pairs of congruent rectangles, so the surface area is found by multiplying the areas of three adjacent rectangles by 2 and adding these products. For this prism, the surface area is equal to <strong>2 · (4 · 5, ) + 2 · (5 · 6, ) + 2 · (4 · 6, )</strong>, or <strong>2 · 20 + 2 · 30 + 2 · 24</strong>, which is equal to 148.Choice A is incorrect. This is the area of one of the faces of the prism. Choice B is incorrect and may result from adding the areas of three adjacent rectangles without multiplying by 2. Choice C is incorrect. This is the volume, in cubic inches, of the prism.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
424
src/data/math/circles.ts
Normal file
424
src/data/math/circles.ts
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
import { type PracticeQuestion } from "../../types/lesson";
|
||||||
|
|
||||||
|
export const CIRCLES_EASY: PracticeQuestion[] = [
|
||||||
|
{
|
||||||
|
id: "23c5fcce",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"The circle above with center O has a circumference of 36. What is the length of minor arc <strong>A, C</strong>?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "9" },
|
||||||
|
{ label: "B", text: "12" },
|
||||||
|
{ label: "C", text: "18" },
|
||||||
|
{ label: "D", text: "36" },
|
||||||
|
],
|
||||||
|
correctAnswer: "A",
|
||||||
|
explanation:
|
||||||
|
"Choice A is correct. A circle has 360 degrees of arc. In the circle shown, O is the center of the circle and <strong>angle A, O C</strong> is a central angle of the circle. From the figure, the two diameters that meet to form <strong>angle A, O C</strong> are perpendicular, so the measure of <strong>angle A, O C</strong> is <strong>90 °</strong>. Therefore, the length of minor arc <strong>A, C</strong> is <strong>the fraction 90 over 360</strong> of the circumference of the circle. Since the circumference of the circle is 36, the length of minor arc <strong>A, C</strong> is <strong>the fraction 90 over 360, end fraction · 36 = 9</strong>.Choices B, C, and D are incorrect. The perpendicular diameters divide the circumference of the circle into four equal arcs; therefore, minor arc <strong>A, C</strong> is <strong>one fourth</strong> of the circumference. However, the lengths in choices B and C are, respectively, <strong>one third</strong> and <strong>one half</strong> the circumference of the circle, and the length in choice D is the length of the entire circumference. None of these lengths is <strong>one fourth</strong> the circumference.",
|
||||||
|
hasFigure: true,
|
||||||
|
figureUrl: "/practice-images/23c5fcce_img1.png",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CIRCLES_MEDIUM: PracticeQuestion[] = [
|
||||||
|
{
|
||||||
|
id: "0815a5af",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"The center of the circle is point upper O.<br>Points upper S, upper R, upper Q, and upper P are on the circle.<br>Line segment upper P upper R is a diameter of the circle.<br>Line segment upper Q upper S is a diameter of the circle.<br>Diameters upper P upper R and upper Q upper S intersect at point upper O.<br>A note indicates the figure is not drawn to scale.<br><br>The circle shown has center <strong>O</strong>, circumference <strong>144 π</strong>, and diameters <strong>P R</strong> and <strong>Q S</strong>. The length of arc <strong>P S</strong> is twice the length of arc <strong>P Q</strong>. What is the length of arc <strong>Q R</strong>?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>24 π</strong>" },
|
||||||
|
{ label: "B", text: "<strong>48 π</strong>" },
|
||||||
|
{ label: "C", text: "<strong>72 π</strong>" },
|
||||||
|
{ label: "D", text: "<strong>96 π</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "B",
|
||||||
|
explanation:
|
||||||
|
"Choice B is correct. Since <strong>P R</strong> and <strong>Q S</strong> are diameters of the circle shown, <strong>O S</strong>, <strong>O R</strong>, <strong>O P</strong>, and <strong>O Q</strong> are radii of the circle and are therefore congruent. Since <strong>angle S O P</strong> and <strong>angle R O Q</strong> are vertical angles, they are congruent. Therefore, arc <strong>P S</strong> and arc <strong>Q R</strong> are formed by congruent radii and have the same angle measure, so they are congruent arcs. Similarly, <strong>angle S O R</strong> and <strong>angle P O Q</strong> are vertical angles, so they are congruent. Therefore, arc <strong>S R</strong> and arc <strong>P Q</strong> are formed by congruent radii and have the same angle measure, so they are congruent arcs. Let <strong>x</strong> represent the length of arc <strong>S R</strong>. Since arc <strong>S R</strong> and arc <strong>P Q</strong> are congruent arcs, the length of arc <strong>P Q</strong> can also be represented by <strong>x</strong>. It’s given that the length of arc <strong>P S</strong> is twice the length of arc <strong>P Q</strong>. Therefore, the length of arc <strong>P S</strong> can be represented by the expression <strong>2 x</strong>. Since arc <strong>P S</strong> and arc <strong>Q R</strong> are congruent arcs, the length of arc <strong>Q R</strong> can also be represented by <strong>2 x</strong>. This gives the expression <strong>x + x + 2 x + 2 x</strong>. Since it's given that the circumference is <strong>144 π</strong>, the expression <strong>x + x + 2 x + 2 x</strong> is equal to <strong>144 π</strong>. Thus <strong>x + x + 2 x + 2 x = 144 π</strong>, or <strong>6 x = 144 π</strong>. Dividing both sides of this equation by <strong>6</strong> yields <strong>x = 24 π</strong>. Therefore, the length of arc <strong>Q R</strong> is <strong>2 (24 π)</strong>, or <strong>48 π</strong>.<br>Choice A is incorrect. This is the length of arc <strong>P Q</strong>, not arc <strong>Q R</strong>.<br>Choice C is incorrect and may result from conceptual or calculation errors.<br>Choice D is incorrect and may result from conceptual or calculation errors.",
|
||||||
|
hasFigure: true,
|
||||||
|
figureUrl: "/practice-images/0815a5af_svg1.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "74d8b897",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"An angle has a measure of <strong>(9 π) / (20)</strong> radians. What is the measure of the angle in degrees?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "81",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>81</strong>. The measure of an angle, in degrees, can be found by multiplying its measure, in radians, by <strong>(180 °) / (π radians)</strong>. Multiplying the given angle measure, <strong>(9 π) / (20)</strong> radians, by <strong>(180 °) / (π radians)</strong> yields <strong>((9 π) / (20) radians) ((180 °) / (π radians))</strong>, which is equivalent to <strong>81</strong> degrees.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "82c8325f",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A circle in the xy-plane has its center at <strong>(−4, 5)</strong> and the point <strong>(−8, 8)</strong> lies on the circle. Which equation represents this circle?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>(x − 4)² + (y + 5)² = 5</strong>" },
|
||||||
|
{ label: "B", text: "<strong>(x + 4)² + (y − 5)² = 5</strong>" },
|
||||||
|
{ label: "C", text: "<strong>(x − 4)² + (y + 5)² = 25</strong>" },
|
||||||
|
{ label: "D", text: "<strong>(x + 4)² + (y − 5)² = 25</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "D",
|
||||||
|
explanation:
|
||||||
|
"Choice D is correct. A circle in the xy-plane can be represented by an equation of the form <strong>(x − h)² + (y − k)² = r²</strong>, where <strong>(h, k)</strong> is the center of the circle and <strong>r</strong> is the length of a radius of the circle. It's given that the circle has its center at <strong>(−4, 5)</strong>. Therefore, <strong>h = −4</strong> and <strong>k = 5</strong>. Substituting <strong>−4</strong> for <strong>h</strong> and <strong>5</strong> for <strong>k</strong> in the equation <strong>(x − h)² + (y − k)² = r²</strong> yields <strong>(x − (−4))² + (y − 5)² = r²</strong>, or <strong>(x + 4)² + (y − 5)² = r²</strong>. It's also given that the point <strong>(−8, 8)</strong> lies on the circle. Substituting <strong>−8</strong> for <strong>x</strong> and <strong>8</strong> for <strong>y</strong> in the equation <strong>(x + 4)² + (y − 5)² = r²</strong> yields <strong>(−8 + 4)² + (8 − 5)² = r²</strong>, or <strong>(−4)² + (3)² = r²</strong>, which is equivalent to <strong>16 + 9 = r²</strong>, or <strong>25 = r²</strong>. Substituting <strong>25</strong> for <strong>r²</strong> in the equation <strong>(x + 4)² + (y − 5)² = r²</strong> yields <strong>(x + 4)² + (y − 5)² = 25</strong>. Thus, the equation <strong>(x + 4)² + (y − 5)² = 25</strong> represents the circle.<br>Choice A is incorrect. The circle represented by this equation has its center at <strong>(4 −5)</strong>, not <strong>(−4, 5)</strong>, and the point <strong>(−8, 8)</strong> doesn't lie on the circle.<br>Choice B is incorrect. The point <strong>(−8, 8)</strong> doesn't lie on the circle represented by this equation.<br>Choice C is incorrect. The circle represented by this equation has its center at <strong>(4 −5)</strong>, not <strong>(−4, 5)</strong>, and the point <strong>(−8, 8)</strong> doesn't lie on the circle.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "856372ca",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"In the xy-plane, a circle with radius 5 has center <strong>with coordinates − 8, 6</strong>. Which of the following is an equation of the circle?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>(x − 8, ), ² + (y + 6, ), ² = 25</strong>" },
|
||||||
|
{ label: "B", text: "<strong>(x + 8, ), ² + (y − 6, ), ² = 25</strong>" },
|
||||||
|
{ label: "C", text: "<strong>(x − 8, ), ² + (y + 6, ), ² = 5</strong>" },
|
||||||
|
{ label: "D", text: "<strong>(x + 8, ), ² + (y − 6, ), ² = 5</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "B",
|
||||||
|
explanation:
|
||||||
|
"Choice B is correct. An equation of a circle is <strong>(x − h, ), ² + (y − k, ), ² = r²</strong>, where the center of the circle is <strong>h, k</strong> and the radius is r. It’s given that the center of this circle is <strong>−8, 6</strong> and the radius is 5. Substituting these values into the equation gives <strong>(x − −8, ), ² + (y − 6, ), ² = 5²</strong>, or <strong>(x + 8, ), ² + (y − 6, ), ² = 25</strong>.Choice A is incorrect. This is an equation of a circle that has center <strong>8 −6</strong>. Choice C is incorrect. This is an equation of a circle that has center <strong>8 −6</strong> and radius <strong>the √ 5</strong>. Choice D is incorrect. This is an equation of a circle that has radius <strong>the √ 5</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "8e7689e0",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"The number of radians in a 720-degree angle can be written as <strong>a · π</strong>, where a is a constant. What is the value of a ?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is 4. There are <strong>π</strong> radians in a <strong>180 °</strong> angle. An angle measure of <strong>720 °</strong> is 4 times greater than an angle measure of <strong>180 °</strong>. Therefore, the number of radians in a <strong>720 °</strong> angle is <strong>4 π</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "95ba2d09",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"In the xy-plane above, points P, Q, R, and T lie on the circle with center O. The degree measures of angles <strong>P O Q</strong> and <strong>R O T</strong> are each 30°. What is the radian measure of angle <strong>Q O R</strong> ?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>five sixths, π</strong>" },
|
||||||
|
{ label: "B", text: "<strong>three fourths, π</strong>" },
|
||||||
|
{ label: "C", text: "<strong>two thirds, π</strong>" },
|
||||||
|
{ label: "D", text: "<strong>one third, π</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "C",
|
||||||
|
explanation:
|
||||||
|
"Choice C is correct. Because points T, O, and P all lie on the x-axis, they form a line. Since the angles on a line add up to <strong>180 °</strong>, and it’s given that angles POQ and ROT each measure <strong>30 °</strong>, it follows that the measure of angle QOR is <strong>180 ° − 30 ° − 30 ° = 120 °</strong>. Since the arc of a complete circle is <strong>360 °</strong> or <strong>2 π</strong> radians, a proportion can be set up to convert the measure of angle QOR from degrees to radians: <strong>the fraction 360 ° over 2 π radians = the fraction 120 ° over x radians</strong>, where x is the radian measure of angle QOR. Multiplying each side of the proportion by <strong>2 π x</strong> gives <strong>360 x = 240 π</strong>. Solving for x gives <strong>the fraction 240 over 360 · π</strong>, or <strong>two thirds π</strong>.Choice A is incorrect and may result from subtracting only angle POQ from <strong>180 °</strong>to get a value of <strong>150 °</strong>and then finding the radian measure equivalent to that value. Choice B is incorrect and may result from a calculation error. Choice D is incorrect and may result from calculating the sum of the angle measures, in radians, of angles POQ and ROT.",
|
||||||
|
hasFigure: true,
|
||||||
|
figureUrl: "/practice-images/95ba2d09_img1.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "a0cacec1",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"An angle has a measure of <strong>(16 π) / (15)</strong> radians. What is the measure of the angle, in degrees?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "192",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>192</strong>. The measure of an angle, in degrees, can be found by multiplying its measure, in radians, by <strong>(180 °) / (π radians)</strong>. Multiplying the given angle measure, <strong>(16 π) / (15) radians</strong>, by <strong>(180 °) / (π radians)</strong> yields <strong>((16 π) / (15) radians) ((180 °) / (π r a d i a n s))</strong>, which simplifies to <strong>192</strong> degrees.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "f1c1e971",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"The measure of angle <strong>R</strong> is <strong>(2 π) / (3)</strong> radians. The measure of angle <strong>T</strong> is <strong>(5 π) / (12)</strong> radians greater than the measure of angle <strong>R</strong>. What is the measure of angle <strong>T</strong>, in degrees?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>75</strong>" },
|
||||||
|
{ label: "B", text: "<strong>120</strong>" },
|
||||||
|
{ label: "C", text: "<strong>195</strong>" },
|
||||||
|
{ label: "D", text: "<strong>390</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "C",
|
||||||
|
explanation:
|
||||||
|
"Choice C is correct. It’s given that the measure of angle <strong>R</strong> is <strong>(2 π) / (3)</strong> radians, and the measure of angle <strong>T</strong> is <strong>(5 π) / (12)</strong> radians greater than the measure of angle <strong>R</strong>. Therefore, the measure of angle <strong>T</strong> is equal to <strong>(2 π) / (3) + (5 π) / (12)</strong> radians. Multiplying <strong>(2 π) / (3)</strong> by <strong>four fourths</strong> to get a common denominator with <strong>(5 π) / (12)</strong> yields <strong>(8 π) / (12)</strong>. Therefore, <strong>(2 π) / (3) + (5 π) / (12)</strong> is equivalent to <strong>(8 π) / (12) + (5 π) / (12)</strong>, or <strong>(13 π) / (12)</strong>. Therefore, the measure of angle <strong>T</strong> is <strong>(13 π) / (12)</strong> radians. The measure of angle <strong>T</strong>, in degrees, can be found by multiplying its measure, in radians, by <strong>(180) / (π)</strong>. This yields <strong>(13 π) / (12) · (180) / (π)</strong>, which is equivalent to <strong>195</strong> degrees. Therefore, the measure of angle <strong>T</strong> is <strong>195</strong> degrees.<br>Choice A is incorrect. This is the number of degrees that the measure of angle <strong>T</strong> is greater than the measure of angle <strong>R</strong>.<br>Choice B is incorrect. This is the measure of angle <strong>R</strong>, in degrees.<br>Choice D is incorrect and may result from conceptual or calculation errors.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CIRCLES_HARD: PracticeQuestion[] = [
|
||||||
|
{
|
||||||
|
id: "2266984b",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"The equation above defines a circle in the xy-plane. What are the coordinates of the center of the circle?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>−20 −16</strong>" },
|
||||||
|
{ label: "B", text: "<strong>−10 −8</strong>" },
|
||||||
|
{ label: "C", text: "<strong>10, 8</strong>" },
|
||||||
|
{ label: "D", text: "<strong>20, 16</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "B",
|
||||||
|
explanation:
|
||||||
|
"Choice B is correct. The standard equation of a circle in the xy-plane is of the form <strong>(x − h, ), ² + (y − k, ), ² = r²</strong>, where <strong>the ordered pair h, k</strong> are the coordinates of the center of the circle and r is the radius. The given equation can be rewritten in standard form by completing the squares. So the sum of the first two terms, <strong>x² + 20 x</strong>, needs a 100 to complete the square, and the sum of the second two terms, <strong>y² + 16 y</strong>, needs a 64 to complete the square. Adding 100 and 64 to both sides of the given equation yields <strong>(x² + 20 x + 100, ) + (y² + 16 y + 64, ) = −20 + 100 + 64</strong>, which is equivalent to <strong>(x + 10, ), ² + (y + 8, ), ² = 144</strong>. Therefore, the coordinates of the center of the circle are <strong>−10 −8</strong>.Choices A, C, and D are incorrect and may result from computational errors made when attempting to complete the squares or when identifying the coordinates of the center.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "249d3f80",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"Point <strong>O</strong> is the center of a circle. The measure of arc <strong>R S</strong> on this circle is <strong>100 °</strong>. What is the measure, in degrees, of its associated angle <strong>R O S</strong>?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "100",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>100</strong>. It's given that point <strong>O</strong> is the center of a circle and the measure of arc <strong>R S</strong> on the circle is <strong>100 °</strong>. It follows that points <strong>R</strong> and <strong>S</strong> lie on the circle. Therefore, <strong>ModifyingAbove O R With bar</strong> and <strong>ModifyingAbove O S With bar</strong> are radii of the circle. A central angle is an angle formed by two radii of a circle, with its vertex at the center of the circle. Therefore, <strong>angle R O S</strong> is a central angle. Because the degree measure of an arc is equal to the measure of its associated central angle, it follows that the measure, in degrees, of <strong>angle R O S</strong> is <strong>100</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "24cec8d1",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"A circle has center <strong>O</strong>, and points <strong>R</strong> and <strong>S</strong> lie on the circle. In triangle <strong>O R S</strong>, the measure of <strong>angle R O S</strong> is <strong>88 °</strong>. What is the measure of <strong>angle R S O</strong>, in degrees? (Disregard the degree symbol when entering your answer.)",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "46",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>46</strong>. It's given that <strong>O</strong> is the center of a circle and that points <strong>R</strong> and <strong>S</strong> lie on the circle. Therefore, <strong>ModifyingAbove O R With bar</strong> and <strong>ModifyingAbove O S With bar</strong> are radii of the circle. It follows that <strong>O R = O S</strong>. If two sides of a triangle are congruent, then the angles opposite them are congruent. It follows that the angles <strong>angle R S O</strong> and <strong>angle O R S</strong>, which are across from the sides of equal length, are congruent. Let <strong>x °</strong> represent the measure of <strong>angle R S O</strong>. It follows that the measure of <strong>angle O R S</strong> is also <strong>x °</strong>. It's given that the measure of <strong>angle R O S</strong> is <strong>88 °</strong>. Because the sum of the measures of the interior angles of a triangle is <strong>180 °</strong>, the equation <strong>x ° + x ° + 88 ° = 180 °</strong>, or <strong>2 x + 88 = 180</strong>, can be used to find the measure of <strong>angle R S O</strong>. Subtracting <strong>88</strong> from both sides of this equation yields <strong>2 x = 92</strong>. Dividing both sides of this equation by <strong>2</strong> yields <strong>x = 46</strong>. Therefore, the measure of <strong>angle R S O</strong>, in degrees, is <strong>46</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3e577e4a",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A circle in the xy-plane has its center at <strong>(−4 −6)</strong>. Line <strong>k</strong> is tangent to this circle at the point <strong>(−7 −7)</strong>. What is the slope of line <strong>k</strong>?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>−3</strong>" },
|
||||||
|
{ label: "B", text: "<strong>−one third</strong>" },
|
||||||
|
{ label: "C", text: "<strong>one third</strong>" },
|
||||||
|
{ label: "D", text: "<strong>3</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "A",
|
||||||
|
explanation:
|
||||||
|
"Choice A is correct. A line that's tangent to a circle is perpendicular to the radius of the circle at the point of tangency. It's given that the circle has its center at <strong>(−4 −6)</strong> and line <strong>k</strong> is tangent to the circle at the point <strong>(−7 −7)</strong>. The slope of a radius defined by the points <strong>(q, r)</strong> and <strong>(s, t)</strong> can be calculated as <strong>(t − r) / (s − q)</strong>. The points <strong>(−7 −7)</strong> and <strong>(−4 −6)</strong> define the radius of the circle at the point of tangency. Therefore, the slope of this radius can be calculated as <strong>((−6) − (−7)) / ((−4) − (−7))</strong>, or <strong>one third</strong>. If a line and a radius are perpendicular, the slope of the line must be the negative reciprocal of the slope of the radius. The negative reciprocal of <strong>one third</strong> is <strong>−3</strong>. Thus, the slope of line <strong>k</strong> is <strong>−3</strong>.<br>Choice B is incorrect and may result from conceptual or calculation errors.<br>Choice C is incorrect. This is the slope of the radius of the circle at the point of tangency, not the slope of line <strong>k</strong>.<br>Choice D is incorrect and may result from conceptual or calculation errors.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "69b0d79d",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"Point O is the center of the circle above, and the measure of <strong>angle O A, B</strong> is <strong>30 °</strong>. If the length of <strong>O C</strong> is 18, what is the length of arc <strong>A, B</strong>?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>9 π</strong>" },
|
||||||
|
{ label: "B", text: "<strong>12 π</strong>" },
|
||||||
|
{ label: "C", text: "<strong>15 π</strong>" },
|
||||||
|
{ label: "D", text: "<strong>18 π</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "B",
|
||||||
|
explanation:
|
||||||
|
"Choice B is correct. Because segments OA and OB are radii of the circle centered at point O, these segments have equal lengths. Therefore, triangle AOB is an isosceles triangle, where angles OAB and OBA are congruent base angles of the triangle. It’s given that angle OAB measures <strong>30 °</strong>. Therefore, angle OBA also measures <strong>30 °</strong>. Let <strong>x °</strong> represent the measure of angle AOB. Since the sum of the measures of the three angles of any triangle is <strong>180 °</strong>, it follows that <strong>30 ° + 30 ° + x ° = 180 °</strong>, or <strong>60 ° + x ° = 180 °</strong>. Subtracting <strong>60 °</strong> from both sides of this equation yields <strong>x ° = 120 °</strong>, or <strong>the fraction 2 π over 3</strong> radians. Therefore, the measure of angle AOB, and thus the measure of arc <strong>A, B</strong>, is <strong>the fraction 2 π over 3</strong> radians. Since <strong>the O C</strong> is a radius of the given circle and its length is 18, the length of the radius of the circle is 18. Therefore, the length of arc <strong>A, B</strong> can be calculated as <strong>the fraction 2 π over 3, end fraction · 18</strong>, or <strong>12 π</strong>.Choices A, C, and D are incorrect and may result from conceptual or computational errors.",
|
||||||
|
hasFigure: true,
|
||||||
|
figureUrl: "/practice-images/69b0d79d_img1.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "76c73dbf",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"The graph of <strong>x² + x + y² + y = (199) / (2)</strong> in the xy-plane is a circle. What is the length of the circle’s radius?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "10",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>10</strong>. It's given that the graph of <strong>x² + x + y² + y = (199) / (2)</strong> in the xy-plane is a circle. The equation of a circle in the xy-plane can be written in the form <strong>(x − h)² + (y − k)² = r²</strong>, where the coordinates of the center of the circle are <strong>(h, k)</strong> and the length of the radius of the circle is <strong>r</strong>. The term <strong>(x − h)²</strong> in this equation can be obtained by adding the square of half the coefficient of <strong>x</strong> to both sides of the given equation to complete the square. The coefficient of <strong>x</strong> is <strong>1</strong>. Half the coefficient of <strong>x</strong> is <strong>one half</strong>. The square of half the coefficient of <strong>x</strong> is <strong>one fourth</strong>. Adding <strong>one fourth</strong> to each side of <strong>(x² + x) + (y² + y) = (199) / (2)</strong> yields <strong>(x² + x + one fourth) + (y² + y) = (199) / (2) + one fourth</strong>, or <strong>(x + one half)² + (y² + y) = (199) / (2) + one fourth</strong>. Similarly, the term <strong>(y − k)²</strong> can be obtained by adding the square of half the coefficient of <strong>y</strong> to both sides of this equation, which yields <strong>(x + one half)² + (y² + y + one fourth) = (199) / (2) + one fourth + one fourth</strong>, or <strong>(x + one half)² + (y + one half)² = (199) / (2) + one fourth + one fourth</strong>. This equation is equivalent to <strong>(x + one half)² + (y + one half)² = 100</strong>, or <strong>(x + one half)² + (y + one half)² = 10²</strong>. Therefore, the length of the circle's radius is <strong>10</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "89661424",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"A circle in the xy-plane has its center at <strong>(−5, 2)</strong> and has a radius of <strong>9</strong>. An equation of this circle is <strong>x² + y² + a x + b y + c = 0</strong>, where <strong>a</strong>, <strong>b</strong>, and <strong>c</strong> are constants. What is the value of <strong>c</strong>?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "-52",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>−52</strong>. The equation of a circle in the xy-plane with its center at <strong>(h, k)</strong> and a radius of <strong>r</strong> can be written in the form <strong>(x − h)² + (y − k)² = r²</strong>. It's given that a circle in the xy-plane has its center at <strong>(−5, 2)</strong> and has a radius of <strong>9</strong>. Substituting <strong>−5</strong> for <strong>h</strong>, <strong>2</strong> for <strong>k</strong>, and <strong>9</strong> for <strong>r</strong> in the equation <strong>(x − h)² + (y − k)² = r²</strong> yields <strong>(x − (−5))² + (y − 2)² = 9²</strong>, or <strong>(x + 5)² + (y − 2)² = 81</strong>. It's also given that an equation of this circle is <strong>x² + y² + a x + b y + c = 0</strong>, where <strong>a</strong>, <strong>b</strong>, and <strong>c</strong> are constants. Therefore, <strong>(x + 5)² + (y − 2)² = 81</strong> can be rewritten in the form <strong>x² + y² + a x + b y + c = 0</strong>. The equation <strong>(x + 5)² + (y − 2)² = 81</strong>, or <strong>(x + 5) (x + 5) + (y − 2) (y − 2) = 81</strong>, can be rewritten as <strong>x² + 5 x + 5 x + 25 + y² − 2 y − 2 y + 4 = 81</strong>. Combining like terms on the left-hand side of this equation yields <strong>x² + y² + 10 x − 4 y + 29 = 81</strong>. Subtracting <strong>81</strong> from both sides of this equation yields <strong>x² + y² + 10 x − 4 y − 52 = 0</strong>, which is equivalent to <strong>x² + y² + 10 x + (−4) y + (−52) = 0</strong>. This equation is in the form <strong>x² + y² + a x + b y + c = 0</strong>. Therefore, the value of <strong>c</strong> is <strong>−52</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "981275d2",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"In the xy-plane, the graph of the equation above is a circle. Point P is on the circle and has coordinates <strong>10 −5</strong>. If <strong>P Q</strong> is a diameter of the circle, what are the coordinates of point Q ?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>2 −5</strong>" },
|
||||||
|
{ label: "B", text: "<strong>6 −1</strong>" },
|
||||||
|
{ label: "C", text: "<strong>6 −5</strong>" },
|
||||||
|
{ label: "D", text: "<strong>6 −9</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "A",
|
||||||
|
explanation:
|
||||||
|
"Choice A is correct. The standard form for the equation of a circle is <strong>(x − h, ), ² + (y − k, ), ² = r²</strong>, where <strong>the ordered pair h, k</strong> are the coordinates of the center and r is the length of the radius. According to the given equation, the center of the circle is <strong>6 −5</strong>. Let <strong>x sub 1, y sub 1</strong> represent the coordinates of point Q. Since point P <strong>10 −5</strong> and point Q <strong>x sub 1, y sub 1</strong> are the endpoints of a diameter of the circle, the center <strong>with coordinates 6 −5</strong> lies on the diameter, halfway between P and Q. Therefore, the following relationships hold: <strong>the fraction with numerator x sub 1 + 10, and denominator 2 = 6</strong> and <strong>the fraction with numerator y sub 1 + −5, and denominator 2 = −5</strong>. Solving the equations for <strong>x sub 1</strong> and <strong>y sub 1</strong>, respectively, yields <strong>x sub 1 = 2</strong> and <strong>y sub 1 = −5</strong>. Therefore, the coordinates of point Q are <strong>2 −5</strong>.Alternate approach: Since point P <strong>10 −5</strong> on the circle and the center of the circle <strong>6 −5</strong> have the same y-coordinate, it follows that the radius of the circle is <strong>10 − 6 = 4</strong>. In addition, the opposite end of the diameter <strong>P Q</strong> must have the same y-coordinate as P and be 4 units away from the center. Hence, the coordinates of point Q must be <strong>2 −5</strong>.<br>Choices B and D are incorrect because the points given in these choices lie on a diameter that is perpendicular to the diameter <strong>P Q</strong>. If either of these points were point Q, then <strong>P Q</strong> would not be the diameter of the circle. Choice C is incorrect because <strong>6 −5</strong> is the center of the circle and does not lie on the circle.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "9acd101f",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"The equation <strong>x² + (y − 1)² = 49</strong> represents circle A. Circle B is obtained by shifting circle A down <strong>2</strong> units in the xy-plane. Which of the following equations represents circle B?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>(x − 2)² + (y − 1)² = 49</strong>" },
|
||||||
|
{ label: "B", text: "<strong>x² + (y − 3)² = 49</strong>" },
|
||||||
|
{ label: "C", text: "<strong>(x + 2)² + (y − 1)² = 49</strong>" },
|
||||||
|
{ label: "D", text: "<strong>x² + (y + 1)² = 49</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "D",
|
||||||
|
explanation:
|
||||||
|
"Choice D is correct. The graph in the xy-plane of an equation of the form <strong>(x − h)² + (y − k)² = r²</strong> is a circle with center <strong>(h, k)</strong> and a radius of length <strong>r</strong>. It's given that circle A is represented by <strong>x² + (y − 1)² = 49</strong>, which can be rewritten as <strong>x² + (y − 1)² = 7²</strong>. Therefore, circle A has center <strong>(0, 1)</strong> and a radius of length <strong>7</strong>. Shifting circle A down two units is a rigid vertical translation of circle A that does not change its size or shape. Since circle B is obtained by shifting circle A down two units, it follows that circle B has the same radius as circle A, and for each point <strong>(x, y)</strong> on circle A, the point <strong>(x, y − 2)</strong> lies on circle B. Moreover, if <strong>(h, k)</strong> is the center of circle A, then <strong>(h, k − 2)</strong> is the center of circle B. Therefore, circle B has a radius of <strong>7</strong> and the center of circle B is <strong>(0, 1 − 2)</strong>, or <strong>(0 −1)</strong>. Thus, circle B can be represented by the equation <strong>x² + (y + 1)² = 7²</strong>, or <strong>x² + (y + 1)² = 49</strong>.<br>Choice A is incorrect. This is the equation of a circle obtained by shifting circle A right <strong>2</strong> units.<br>Choice B is incorrect. This is the equation of a circle obtained by shifting circle A up <strong>2</strong> units.<br>Choice C is incorrect. This is the equation of a circle obtained by shifting circle A left <strong>2</strong> units.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "9d159400",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"Which of the following equations represents a circle in the xy-plane that intersects the y-axis at exactly one point?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>(x − 8)² + (y − 8)² = 16</strong>" },
|
||||||
|
{ label: "B", text: "<strong>(x − 8)² + (y − 4)² = 16</strong>" },
|
||||||
|
{ label: "C", text: "<strong>(x − 4)² + (y − 9)² = 16</strong>" },
|
||||||
|
{ label: "D", text: "<strong>x² + (y − 9)² = 16</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "C",
|
||||||
|
explanation:
|
||||||
|
"Choice C is correct. The graph of the equation <strong>(x − h)² + (y − k)² = r²</strong> in the xy-plane is a circle with center <strong>(h, k)</strong> and a radius of length <strong>r</strong>. The radius of a circle is the distance from the center of the circle to any point on the circle. If a circle in the xy-plane intersects the y-axis at exactly one point, then the perpendicular distance from the center of the circle to this point on the y-axis must be equal to the length of the circle's radius. It follows that the x-coordinate of the circle's center must be equivalent to the length of the circle's radius. In other words, if the graph of <strong>(x − h)² + (y − k)² = r²</strong> is a circle that intersects the y-axis at exactly one point, then <strong>r = |h|</strong> must be true. The equation in choice C is <strong>(x − 4)² + (y − 9)² = 16</strong>, or <strong>(x − 4)² + (y − 9)² = 4²</strong>. This equation is in the form <strong>(x − h)² + (y − k)² = r²</strong>, where <strong>h = 4</strong>, <strong>k = 9</strong>, and <strong>r = 4</strong>, and represents a circle in the xy-plane with center <strong>(4, 9)</strong> and radius of length <strong>4</strong>. Substituting <strong>4</strong> for <strong>r</strong> and <strong>4</strong> for <strong>h</strong> in the equation <strong>r = |h|</strong> yields <strong>4 = |4|</strong>, or <strong>4 = 4</strong>, which is true. Therefore, the equation in choice C represents a circle in the xy-plane that intersects the y-axis at exactly one point. <br>Choice A is incorrect. This is the equation of a circle that does not intersect the y-axis at any point.<br>Choice B is incorrect. This is an equation of a circle that intersects the x-axis, not the y-axis, at exactly one point.<br>Choice D is incorrect. This is the equation of a circle with the center located on the y-axis and thus intersects the y-axis at exactly two points, not exactly one point.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "9e44284b",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"In the xy-plane, the graph of <strong>2 x² − 6 x + 2 y² + 2 y = 45</strong> is a circle. What is the radius of the circle?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "5" },
|
||||||
|
{ label: "B", text: "6.5" },
|
||||||
|
{ label: "C", text: "<strong>√ 40</strong>" },
|
||||||
|
{ label: "D", text: "<strong>√ 50</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "A",
|
||||||
|
explanation:
|
||||||
|
"Choice A is correct. One way to find the radius of the circle is to rewrite the given equation in standard form, <strong>(x − h, ), ² + (y − k, ), ² = r²</strong>, where <strong>the ordered pair h, k</strong> is the center of the circle and the radius of the circle is r. To do this, divide the original equation, <strong>2 x² − 6 x + 2 y² + 2 y = 45</strong>, by 2 to make the leading coefficients of <strong>x²</strong> and <strong>y²</strong> each equal to 1: <strong>as follows: x² − 3 x + y² + y = 22 . 5</strong>. Then complete the square to put the equation in standard form. To do so, first rewrite <strong>x² − 3 x + y² + y = 22 . 5</strong> as <strong>(x² − 3 x + 2 . 2 5, ) − 2 . 2 5 + (y² + y + 0 . 2 5, ) − 0 . 2 5 = 22 . 5</strong>. Second, add 2.25 and 0.25 to both sides of the equation: <strong>(x² − 3 x + 2 . 2 5, ) + (y² + y + 0 . 2 5, ) = 25</strong>. Since <strong>x² − 3 x + 2 . 2 5 = (x − 1 . 5, ), ²</strong>, <strong>y² + y + 0 . 2 5 = (y + 0 . 5, ), ²</strong>, and <strong>25 = 5²</strong>, it follows that <strong>(x − 1 . 5, ), ² + (y + 0 . 5, ), ² = 5²</strong>. Therefore, the radius of the circle is 5.Choices B, C, and D are incorrect and may be the result of errors in manipulating the equation or of a misconception about the standard form of the equation of a circle in the xy-plane.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ab176ad6",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"The equation <strong>(x + 6, ), ² + (y + 3, ), ² = 121</strong> defines a circle in the xy‑plane. What is the radius of the circle?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is 11. A circle with equation <strong>(x − a, ), ² + (y − b, ), ² = r²</strong>, where a, b, and r are constants, has center <strong>with coordinates a, , b</strong> and radius r. Therefore, the radius of the given circle is <strong>the √ 121</strong>, or 11.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "acd30391",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A circle in the xy-plane has equation <strong>(x + 3, ), ² + (y − 1, ), ² = 25</strong>. Which of the following points does NOT lie in the interior of the circle?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>−7, 3</strong>" },
|
||||||
|
{ label: "B", text: "<strong>−3, 1</strong>" },
|
||||||
|
{ label: "C", text: "<strong>zero, zero</strong>" },
|
||||||
|
{ label: "D", text: "<strong>3, 2</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "D",
|
||||||
|
explanation:
|
||||||
|
"Choice D is correct. The circle with equation <strong>(x + 3, ), ² + (y − 1, ), ² = 25</strong> has center <strong>with coordinates − 3, 1</strong> and radius 5. For a point to be inside of the circle, the distance from that point to the center must be less than the radius, 5. The distance between <strong>3, 2</strong> and <strong>−3, 1</strong> is <strong>the √, (−3 − 3, ), ² + (1 − 2, ), ², end root = the √, (−6, ), ² + (−1, ), ², end root, which = the √ 37</strong>, which is greater than 5. Therefore, <strong>3, 2</strong> does NOT lie in the interior of the circle.Choice A is incorrect. The distance between <strong>−7, 3</strong> and <strong>−3, 1</strong> is <strong>the √, (−7 + 3, ), ² + (3 − 1, ), ², end root = the √, (−4, ), ² + (2, ), ², end root, which = the √ 20</strong>, which is less than 5, and therefore <strong>−7, 3</strong> lies in the interior of the circle. Choice B is incorrect because it is the center of the circle. Choice C is incorrect because the distance between <strong>0, 0</strong> and <strong>−3, 1</strong> is <strong>the √, (0 + 3, ), ² + (0 − 1, ), ², end root = the √, (3, ), ² + (1, ), ², end root, which = the √ 8</strong>, which is less than 5, and therefore <strong>0, 0</strong> in the interior of the circle.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b0a72bdc",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"What is the diameter of the circle in the xy-plane with equation <strong>(x − 5)² + (y − 3)² = 16</strong>?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>4</strong>" },
|
||||||
|
{ label: "B", text: "<strong>8</strong>" },
|
||||||
|
{ label: "C", text: "<strong>16</strong>" },
|
||||||
|
{ label: "D", text: "<strong>32</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "B",
|
||||||
|
explanation:
|
||||||
|
"Choice B is correct. The standard form of an equation of a circle in the xy-plane is <strong>(x − h)² + (y − k)² = r²</strong>, where the coordinates of the center of the circle are <strong>(h, k)</strong> and the length of the radius of the circle is <strong>r</strong>. For the circle in the xy-plane with equation <strong>(x − 5)² + (y − 3)² = 16</strong>, it follows that <strong>r² = 16</strong>. Taking the square root of both sides of this equation yields <strong>r = 4</strong> or <strong>r = −4</strong>. Because <strong>r</strong> represents the length of the radius of the circle and this length must be positive, <strong>r = 4</strong>. Therefore, the radius of the circle is <strong>4</strong>. The diameter of a circle is twice the length of the radius of the circle. Thus, <strong>2 (4)</strong> yields <strong>8</strong>. Therefore, the diameter of the circle is <strong>8</strong>.<br>Choice A is incorrect. This is the radius of the circle. <br>Choice C is incorrect. This is the square of the radius of the circle. <br>Choice D is incorrect and may result from conceptual or calculation errors.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b8a225ff",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"Circle A in the xy-plane has the equation <strong>(x + 5)² + (y − 5)² = 4</strong>. Circle B has the same center as circle A. The radius of circle B is two times the radius of circle A. The equation defining circle B in the xy-plane is <strong>(x + 5)² + (y − 5)² = k</strong>, where <strong>k</strong> is a constant. What is the value of <strong>k</strong>?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "16",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>16</strong>. An equation of a circle in the xy-plane can be written as <strong>(x − t)² + (y − u)² = r²</strong>, where the center of the circle is <strong>(t, u)</strong> , the radius of the circle is <strong>r</strong>, and where <strong>t</strong>, <strong>u</strong>, and <strong>r</strong> are constants. It’s given that the equation of circle A is <strong>(x + 5)² + (y − 5)² = 4</strong>, which is equivalent to <strong>(x + 5)² + (y − 5)² = 2²</strong>. Therefore, the center of circle A is <strong>(−5, 5)</strong> and the radius of circle A is <strong>2</strong>. It’s given that circle B has the same center as circle A and that the radius of circle B is two times the radius of circle A. Therefore, the center of circle B is <strong>(−5, 5)</strong> and the radius of circle B is <strong>2 (2)</strong>, or <strong>4</strong>. Substituting <strong>−5</strong> for <strong>t</strong>, <strong>5</strong> for <strong>u</strong>, and <strong>4</strong> for <strong>r</strong> into the equation <strong>(x − t)² + (y − u)² = r²</strong> yields <strong>(x + 5)² + (y − 5)² = 4²</strong>, which is equivalent to <strong>(x + 5)² + (y − 5)² = 16</strong>. It follows that the equation of circle B in the xy-plane is <strong>(x + 5)² + (y − 5)² = 16</strong>. Therefore, the value of <strong>k</strong> is <strong>16</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c8345903",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"The circle above has center O, the length of arc <strong>A, D C</strong> is <strong>5 π</strong>, and <strong>x = 100</strong>. What is the length of arc <strong>A, B C</strong> ?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>9 π</strong>" },
|
||||||
|
{ label: "B", text: "<strong>13 π</strong>" },
|
||||||
|
{ label: "C", text: "<strong>18 π</strong>" },
|
||||||
|
{ label: "D", text: "<strong>13 halves π</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "B",
|
||||||
|
explanation:
|
||||||
|
"Choice B is correct. The ratio of the lengths of two arcs of a circle is equal to the ratio of the measures of the central angles that subtend the arcs. It’s given that arc <strong>A D C</strong> is subtended by a central angle with measure 100°. Since the sum of the measures of the angles about a point is 360°, it follows that arc <strong>A B C</strong> is subtended by a central angle with measure <strong>360 ° − 100 ° = 260 °</strong>. If s is the length of arc <strong>A B C</strong>, then s must satisfy the ratio <strong>the fraction s over 5 π, end fraction = the fraction 260 over 100</strong>. Reducing the fraction <strong>260 over 100</strong> to its simplest form gives <strong>the fraction 13 over 5</strong>. Therefore, <strong>the fraction s over 5 π, end fraction = the fraction 13 over 5</strong>. Multiplying both sides of <strong>the fraction s over 5 π, end fraction = the fraction 13 over 5</strong> by <strong>5 π</strong> yields <strong>s = 13 π</strong>.Choice A is incorrect. This is the length of an arc consisting of exactly half of the circle, but arc <strong>A B C</strong> is greater than half of the circle. Choice C is incorrect. This is the total circumference of the circle. Choice D is incorrect. This is half the length of arc <strong>A B C</strong>, not its full length.",
|
||||||
|
hasFigure: true,
|
||||||
|
figureUrl: "/practice-images/c8345903_img1.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ca2235f6",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"A circle has center <em>(expression)</em>, and points <em>(expression)</em> and <em>(expression)</em> lie on the circle. The measure of arc <em>(expression)</em> is <em>(expression)</em> and the length of arc <em>(expression)</em> is <em>(expression)</em> inches. What is the circumference, in inches, of the circle?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<em>(expression)</em>" },
|
||||||
|
{ label: "B", text: "<em>(expression)</em>" },
|
||||||
|
{ label: "C", text: "<em>(expression)</em>" },
|
||||||
|
{ label: "D", text: "<em>(expression)</em>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "D",
|
||||||
|
explanation:
|
||||||
|
"Choice D is correct. It’s given that the measure of arc <strong>A B</strong> is <strong>45 °</strong> and the length of arc <strong>A B</strong> is <strong>3 inches</strong>. The arc measure of the full circle is <strong>360 °</strong>. If <strong>x</strong> represents the circumference, in inches, of the circle, it follows that <strong>(45 °) / (360 °) = (3 inches) / (x inches)</strong>. This equation is equivalent to <strong>(45) / (360) = (3) / (x)</strong>, or <strong>one eighth = (3) / (x)</strong>. Multiplying both sides of this equation by <strong>8 x</strong> yields <strong>1 (x) = 3 (8)</strong>, or <strong>x = 24</strong>. Therefore, the circumference of the circle is <strong>24 inches</strong>.<br>Choice A is incorrect. This is the length of arc <strong>A B</strong>.<br>Choice B is incorrect and may result from multiplying the length of arc <strong>A B</strong> by <strong>2</strong>.<br>Choice C is incorrect and may result from squaring the length of arc <strong>A B</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e80d62c6",
|
||||||
|
type: "mcq",
|
||||||
|
questionHtml:
|
||||||
|
"The equation <strong>x² + (y − 2)² = 36</strong> represents circle A. Circle B is obtained by shifting circle A down <strong>4</strong> units in the xy-plane. Which of the following equations represents circle B?",
|
||||||
|
choices: [
|
||||||
|
{ label: "A", text: "<strong>x² + (y + 2)² = 36</strong>" },
|
||||||
|
{ label: "B", text: "<strong>x² + (y − 6)² = 36</strong>" },
|
||||||
|
{ label: "C", text: "<strong>(x − 4)² + (y − 2)² = 36</strong>" },
|
||||||
|
{ label: "D", text: "<strong>(x + 4)² + (y − 2)² = 36</strong>" },
|
||||||
|
],
|
||||||
|
correctAnswer: "A",
|
||||||
|
explanation:
|
||||||
|
"Choice A is correct. The standard form of an equation of a circle in the xy-plane is <strong>(x − h)² + (y − k)² = r²</strong>, where the coordinates of the center of the circle are <strong>(h, k)</strong> and the length of the radius of the circle is <strong>r</strong>. The equation of circle A, <strong>x² + (y − 2)² = 36</strong>, can be rewritten as <strong>(x − 0)² + (y − 2)² = 6²</strong>. Therefore, the center of circle A is at <strong>(0, 2)</strong> and the length of the radius of circle A is <strong>6</strong>. If circle A is shifted down <strong>4</strong> units, the y-coordinate of its center will decrease by <strong>4</strong>; the radius of the circle and the x-coordinate of its center will not change. Therefore, the center of circle B is at <strong>(0, 2 − 4)</strong>, or <strong>(0 −2)</strong>, and its radius is <strong>6</strong>. Substituting <strong>0</strong> for <strong>h</strong>, <strong>−2</strong> for <strong>k</strong>, and <strong>6</strong> for <strong>r</strong> in the equation <strong>(x − h)² + (y − k)² = r²</strong> yields <strong>(x − 0)² + (y − (−2))² = (6)²</strong>, or <strong>x² + (y + 2)² = 36</strong>. Therefore, the equation <strong>x² + (y + 2)² = 36</strong> represents circle B.<br>Choice B is incorrect. This equation represents a circle obtained by shifting circle A up, rather than down, <strong>4</strong> units.<br>Choice C is incorrect. This equation represents a circle obtained by shifting circle A right, rather than down, <strong>4</strong> units.<br>Choice D is incorrect. This equation represents a circle obtained by shifting circle A left, rather than down, <strong>4</strong> units.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ebbf23ae",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"A circle in the xy-plane has a diameter with endpoints <strong>(2, 4)</strong> and <strong>(2, 14)</strong>. An equation of this circle is <strong>(x − 2)² + (y − 9)² = r²</strong>, where <strong>r</strong> is a positive constant. What is the value of <strong>r</strong>?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "5",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>5</strong>. The standard form of an equation of a circle in the xy-plane is <strong>(x − h)² + (y − k)² = r²</strong>, where <strong>h</strong>, <strong>k</strong>, and <strong>r</strong> are constants, the coordinates of the center of the circle are <strong>(h, k)</strong>, and the length of the radius of the circle is <strong>r</strong>. It′s given that an equation of the circle is <strong>(x − 2)² + (y − 9)² = r²</strong>. Therefore, the center of this circle is <strong>(2, 9)</strong>. It’s given that the endpoints of a diameter of the circle are <strong>(2, 4)</strong> and <strong>(2, 14)</strong>. The length of the radius is the distance from the center of the circle to an endpoint of a diameter of the circle, which can be found using the distance formula, <strong>√((x 1 − x 2)² + (y 1 − y 2)²)</strong>. Substituting the center of the circle <strong>(2, 9)</strong> and one endpoint of the diameter <strong>(2, 4)</strong> in this formula gives a distance of <strong>√((2 − 2)² + (9 − 4)²)</strong>, or <strong>√(0² + 5²)</strong>, which is equivalent to <strong>5</strong>. Since the distance from the center of the circle to an endpoint of a diameter is <strong>5</strong>, the value of <strong>r</strong> is <strong>5</strong>.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fb58c0db",
|
||||||
|
type: "spr",
|
||||||
|
questionHtml:
|
||||||
|
"Points A and B lie on a circle with radius 1, and arc <strong>A, B</strong> has length <strong>π over 3</strong>. What fraction of the circumference of the circle is the length of arc <strong>A, B</strong> ?",
|
||||||
|
choices: [],
|
||||||
|
correctAnswer: "",
|
||||||
|
explanation:
|
||||||
|
"The correct answer is <strong>one sixth</strong>. The circumference, C, of a circle is <strong>C = 2 π, r</strong>, where r is the length of the radius of the circle. For the given circle with a radius of 1, the circumference is <strong>C = 2 π · 1</strong>, or <strong>C = 2 π</strong>. To find what fraction of the circumference the length of arc <strong>A, B</strong> is, divide the length of the arc by the circumference, which gives <strong>the fraction π over 3, end fraction ÷ 2 π</strong>. This division can be represented by <strong>the fraction π over 3, end fraction · the fraction 1 over 2 π, end fraction = one sixth</strong>. Note that 1/6, .1666, .1667, 0.166, and 0.167 are examples of ways to enter a correct answer.",
|
||||||
|
hasFigure: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
1293
src/data/math/equivalent-expressions.ts
Normal file
1293
src/data/math/equivalent-expressions.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user