README / app /(dashboard) /map /[courseId] /MapClient.tsx
kaigiii's picture
Deploy Learn8 Demo Space
5c920e9
Raw
History Blame Contribute Delete
42.1 kB
"use client";
import React, { useMemo, useState, useEffect, useRef, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Link from "next/link";
import { useParams } from "next/navigation";
import TopStatsBar from "@/components/shared/TopStatsBar";
import useCourseStore from "@/stores/useCourseStore";
import type { RemedyNode } from "@/stores/useCourseStore";
import useUserStore from "@/stores/useUserStore";
/* ═══════════════════ Fallback mock nodes (for demo courseIds with no mock data) ═══════════════════ */
interface MapNode {
id: string;
title: string;
status: "completed" | "available" | "locked";
x: number;
y: number;
}
/* ── Per-library-course metadata: each course has its own independent title + nodes ── */
const LIBRARY_COURSE_META: Record<string, { title: string; nodes: { id: string; title: string; status: "completed" | "available" | "locked" }[] }> = {
ja: {
title: "Japanese",
nodes: [
{ id: "ja-n1", title: "Hiragana Basics", status: "completed" },
{ id: "ja-n2", title: "Katakana Basics", status: "available" },
{ id: "ja-n3", title: "Greetings & Self-Introduction", status: "locked" },
{ id: "ja-n4", title: "Numbers & Counting", status: "locked" },
{ id: "ja-n5", title: "Basic Particles (は・が・を)", status: "locked" },
{ id: "ja-n6", title: "Daily Conversation", status: "locked" },
{ id: "ja-n7", title: "Verb Conjugation", status: "locked" },
{ id: "ja-n8", title: "Adjectives & Descriptions", status: "locked" },
{ id: "ja-n9", title: "Kanji Level 1", status: "locked" },
{ id: "ja-n10", title: "Reading Comprehension", status: "locked" },
{ id: "ja-n11", title: "Listening Practice", status: "locked" },
{ id: "ja-n12", title: "Travel Japanese", status: "locked" },
{ id: "ja-n13", title: "Polite vs Casual Speech", status: "locked" },
{ id: "ja-n14", title: "Culture & Etiquette", status: "locked" },
{ id: "ja-n15", title: "Final Challenge", status: "locked" },
],
},
es: {
title: "Spanish",
nodes: [
{ id: "es-n1", title: "Alphabet & Pronunciation", status: "completed" },
{ id: "es-n2", title: "Greetings & Introductions", status: "available" },
{ id: "es-n3", title: "Articles & Gender", status: "locked" },
{ id: "es-n4", title: "Basic Verbs (Ser/Estar)", status: "locked" },
{ id: "es-n5", title: "Numbers & Time", status: "locked" },
{ id: "es-n6", title: "Food & Ordering", status: "locked" },
{ id: "es-n7", title: "Present Tense Conjugation", status: "locked" },
{ id: "es-n8", title: "Prepositions & Directions", status: "locked" },
{ id: "es-n9", title: "Past Tense (Pretérito)", status: "locked" },
{ id: "es-n10", title: "Shopping & Money", status: "locked" },
{ id: "es-n11", title: "Subjunctive Mood", status: "locked" },
{ id: "es-n12", title: "Travel Conversation", status: "locked" },
{ id: "es-n13", title: "Reading Practice", status: "locked" },
{ id: "es-n14", title: "Culture & Idioms", status: "locked" },
{ id: "es-n15", title: "Final Challenge", status: "locked" },
],
},
py: {
title: "Python",
nodes: [
{ id: "py-n1", title: "Variables & Data Types", status: "completed" },
{ id: "py-n2", title: "Control Flow (if/else)", status: "available" },
{ id: "py-n3", title: "Loops (for/while)", status: "locked" },
{ id: "py-n4", title: "Functions & Scope", status: "locked" },
{ id: "py-n5", title: "Lists & Tuples", status: "locked" },
{ id: "py-n6", title: "Dictionaries & Sets", status: "locked" },
{ id: "py-n7", title: "String Manipulation", status: "locked" },
{ id: "py-n8", title: "File I/O", status: "locked" },
{ id: "py-n9", title: "Error Handling", status: "locked" },
{ id: "py-n10", title: "Modules & Packages", status: "locked" },
{ id: "py-n11", title: "List Comprehensions", status: "locked" },
{ id: "py-n12", title: "OOP Basics", status: "locked" },
{ id: "py-n13", title: "Decorators & Generators", status: "locked" },
{ id: "py-n14", title: "Testing with pytest", status: "locked" },
{ id: "py-n15", title: "Final Project", status: "locked" },
],
},
py2: {
title: "Python Advanced",
nodes: [
{ id: "py2-n1", title: "Async & Concurrency", status: "completed" },
{ id: "py2-n2", title: "Metaprogramming", status: "available" },
{ id: "py2-n3", title: "Design Patterns", status: "locked" },
{ id: "py2-n4", title: "Type Hints & Mypy", status: "locked" },
{ id: "py2-n5", title: "Performance Optimization", status: "locked" },
{ id: "py2-n6", title: "Context Managers", status: "locked" },
{ id: "py2-n7", title: "Networking & APIs", status: "locked" },
{ id: "py2-n8", title: "Data Processing (Pandas)", status: "locked" },
{ id: "py2-n9", title: "Database Integration", status: "locked" },
{ id: "py2-n10", title: "Web Scraping", status: "locked" },
{ id: "py2-n11", title: "CLI Tools (Click/Typer)", status: "locked" },
{ id: "py2-n12", title: "Package Publishing", status: "locked" },
{ id: "py2-n13", title: "CI/CD Pipelines", status: "locked" },
{ id: "py2-n14", title: "Security Best Practices", status: "locked" },
{ id: "py2-n15", title: "Capstone Project", status: "locked" },
],
},
gen: {
title: "Study Skills",
nodes: [
{ id: "gen-n1", title: "Active Recall", status: "completed" },
{ id: "gen-n2", title: "Spaced Repetition", status: "available" },
{ id: "gen-n3", title: "Note-Taking Methods", status: "locked" },
{ id: "gen-n4", title: "Mind Mapping", status: "locked" },
{ id: "gen-n5", title: "Pomodoro Technique", status: "locked" },
{ id: "gen-n6", title: "Goal Setting (SMART)", status: "locked" },
{ id: "gen-n7", title: "Critical Thinking", status: "locked" },
{ id: "gen-n8", title: "Speed Reading", status: "locked" },
{ id: "gen-n9", title: "Memory Palace", status: "locked" },
{ id: "gen-n10", title: "Group Study Strategies", status: "locked" },
{ id: "gen-n11", title: "Test Preparation", status: "locked" },
{ id: "gen-n12", title: "Time Management", status: "locked" },
{ id: "gen-n13", title: "Self-Assessment", status: "locked" },
{ id: "gen-n14", title: "Growth Mindset", status: "locked" },
{ id: "gen-n15", title: "Final Review", status: "locked" },
],
},
ml: {
title: "ML Basics",
nodes: [
{ id: "ml-n1", title: "What is Machine Learning?", status: "completed" },
{ id: "ml-n2", title: "Supervised vs Unsupervised", status: "available" },
{ id: "ml-n3", title: "Linear Regression", status: "locked" },
{ id: "ml-n4", title: "Logistic Regression", status: "locked" },
{ id: "ml-n5", title: "Decision Trees", status: "locked" },
{ id: "ml-n6", title: "K-Nearest Neighbors", status: "locked" },
{ id: "ml-n7", title: "Support Vector Machines", status: "locked" },
{ id: "ml-n8", title: "Neural Network Intro", status: "locked" },
{ id: "ml-n9", title: "Training & Validation", status: "locked" },
{ id: "ml-n10", title: "Overfitting & Regularization", status: "locked" },
{ id: "ml-n11", title: "Feature Engineering", status: "locked" },
{ id: "ml-n12", title: "Model Evaluation Metrics", status: "locked" },
{ id: "ml-n13", title: "Ensemble Methods", status: "locked" },
{ id: "ml-n14", title: "Deploying Models", status: "locked" },
{ id: "ml-n15", title: "Final Challenge", status: "locked" },
],
},
ds: {
title: "Data Science",
nodes: [
{ id: "ds-n1", title: "Data Science Overview", status: "completed" },
{ id: "ds-n2", title: "Data Collection & Cleaning", status: "available" },
{ id: "ds-n3", title: "Exploratory Data Analysis", status: "locked" },
{ id: "ds-n4", title: "Statistical Foundations", status: "locked" },
{ id: "ds-n5", title: "Data Visualization", status: "locked" },
{ id: "ds-n6", title: "Hypothesis Testing", status: "locked" },
{ id: "ds-n7", title: "Regression Analysis", status: "locked" },
{ id: "ds-n8", title: "Clustering Techniques", status: "locked" },
{ id: "ds-n9", title: "Time Series Analysis", status: "locked" },
{ id: "ds-n10", title: "Natural Language Processing", status: "locked" },
{ id: "ds-n11", title: "Big Data Tools", status: "locked" },
{ id: "ds-n12", title: "Dashboard & Reporting", status: "locked" },
{ id: "ds-n13", title: "A/B Testing", status: "locked" },
{ id: "ds-n14", title: "Ethics in Data Science", status: "locked" },
{ id: "ds-n15", title: "Capstone Project", status: "locked" },
],
},
};
const FALLBACK_NODES: { id: string; title: string; status: "completed" | "available" | "locked" }[] = [
{ id: "demo-n1", title: "Introduction", status: "completed" },
{ id: "demo-n2", title: "Core Concepts", status: "available" },
{ id: "demo-n3", title: "Deep Dive", status: "locked" },
{ id: "demo-n4", title: "Practice Lab", status: "locked" },
{ id: "demo-n5", title: "Advanced Topics", status: "locked" },
{ id: "demo-n6", title: "Connections", status: "locked" },
{ id: "demo-n7", title: "Analysis", status: "locked" },
{ id: "demo-n8", title: "Synthesis", status: "locked" },
{ id: "demo-n9", title: "Application", status: "locked" },
{ id: "demo-n10", title: "Case Study", status: "locked" },
{ id: "demo-n11", title: "Research", status: "locked" },
{ id: "demo-n12", title: "Review", status: "locked" },
{ id: "demo-n13", title: "Mastery", status: "locked" },
{ id: "demo-n14", title: "Integration", status: "locked" },
{ id: "demo-n15", title: "Final Challenge", status: "locked" },
];
/* ═══════════════════ Page ═══════════════════ */
export default function MapClient() {
const params = useParams();
const courseId = params.courseId as string;
const loadMockCourses = useCourseStore((s) => s.loadMockCourses);
const getCourse = useCourseStore((s) => s.getCourse);
const getNodesForCourse = useCourseStore((s) => s.getNodesForCourse);
const getRemedyNodesForCourse = useCourseStore((s) => s.getRemedyNodesForCourse);
const setLastActiveCourse = useUserStore((s) => s.setLastActiveCourse);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
loadMockCourses();
setLoaded(true);
}, [loadMockCourses]);
/* Lock body scroll while map page is mounted (zoom 1.05 causes overflow) */
useEffect(() => {
const prev = document.documentElement.style.overflow;
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
return () => {
document.documentElement.style.overflow = prev;
document.body.style.overflow = "";
};
}, []);
useEffect(() => {
if (courseId) setLastActiveCourse(courseId);
}, [courseId, setLastActiveCourse]);
const course = getCourse(courseId);
const storeNodes = getNodesForCourse(courseId);
// Build MapNode array – use store data if available, otherwise fallback
// Dynamic positions: zigzag pattern, each node spaced vertically
const NODES: MapNode[] = useMemo(() => {
const X_PATTERN = [50, 28, 68, 32, 58, 40, 65, 30, 55, 42, 62, 35, 58, 45, 50];
const buildPositions = (nodes: { id: string; title: string; status: "completed" | "available" | "locked" }[]) => {
const count = nodes.length;
const spacing = 120; // px per node
return nodes.map((n, i) => ({
id: n.id,
title: n.title,
status: n.status,
x: X_PATTERN[i % X_PATTERN.length],
y: (count - 1 - i) * spacing + 60, // bottom-to-top, first node at bottom
}));
};
if (storeNodes.length > 0) {
return buildPositions(storeNodes);
}
if (!loaded) return [];
// Use per-course fallback nodes if available
const libMeta = LIBRARY_COURSE_META[courseId];
if (libMeta) return buildPositions(libMeta.nodes);
return buildPositions(FALLBACK_NODES);
}, [storeNodes, loaded, courseId]);
// Total height for scrollable map
const mapHeight = Math.max(560, NODES.length * 120 + 120);
/* ── Scroll to bottom on load ── */
const mapContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (NODES.length > 0 && mapContainerRef.current) {
mapContainerRef.current.scrollTop = mapContainerRef.current.scrollHeight;
}
}, [NODES.length]);
/* ── Mouse drag to pan ── */
const isDragging = useRef(false);
const dragStart = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
const handleMouseDown = useCallback((e: React.MouseEvent) => {
const container = mapContainerRef.current;
if (!container) return;
isDragging.current = true;
dragStart.current = {
x: e.clientX,
y: e.clientY,
scrollLeft: container.scrollLeft,
scrollTop: container.scrollTop,
};
container.style.cursor = "grabbing";
container.style.userSelect = "none";
}, []);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging.current) return;
const container = mapContainerRef.current;
if (!container) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
container.scrollTop = dragStart.current.scrollTop - dy;
container.scrollLeft = dragStart.current.scrollLeft - dx;
}, []);
const handleMouseUp = useCallback(() => {
isDragging.current = false;
const container = mapContainerRef.current;
if (container) {
container.style.cursor = "grab";
container.style.userSelect = "";
}
}, []);
const pageTitle = course?.unitTitle ?? LIBRARY_COURSE_META[courseId]?.title ?? "Course Map";
const remedyNodes = getRemedyNodesForCourse(courseId);
return (
<div className="relative overflow-hidden bg-gradient-to-br from-[#edf7fb] via-[#c9e6f2] to-[#a3d5e8]" style={{ zoom: 1.05, height: "calc(100vh / 1.05)" }}>
<TopStatsBar backHref="/home" pageTitle={pageTitle} />
{/* Background effects */}
<MapBg />
<FloatingParticles />
<div className="relative z-10 flex gap-6 px-6 max-w-7xl mx-auto" style={{ height: "calc(100vh / 1.05 - 56px)" }}>
{/* ── Center: Map canvas (drag to pan, hidden scrollbar) ── */}
<div
ref={mapContainerRef}
className="flex-1 min-w-0 overflow-y-auto rounded-2xl scrollbar-hide"
style={{ cursor: "grab" }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div className="relative" style={{ height: mapHeight, minHeight: 560 }}>
{/* SVG path connecting nodes */}
<svg
className="absolute inset-0 w-full h-full pointer-events-none z-0"
viewBox={`0 0 100 ${mapHeight}`}
preserveAspectRatio="none"
fill="none"
>
<defs>
<linearGradient id="pathGrad" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor="#4a9e9b" stopOpacity="0.8" />
<stop offset="100%" stopColor="#8a7a60" stopOpacity="0.5" />
</linearGradient>
</defs>
{NODES.slice(0, -1).map((node, i) => {
const next = NODES[i + 1];
const nx = node.x;
const ny = node.y;
const nnx = next.x;
const nny = next.y;
const mx = (nx + nnx) / 2;
const my = (ny + nny) / 2;
const isActive =
node.status !== "locked" || next.status !== "locked";
return (
<path
key={i}
d={`M ${nx} ${ny} Q ${mx + (i % 2 === 0 ? 12 : -12)} ${my} ${nnx} ${nny}`}
stroke={isActive ? "url(#pathGrad)" : "#999"}
strokeWidth="1.8"
strokeDasharray="4 3"
opacity={isActive ? 0.85 : 0.45}
vectorEffect="non-scaling-stroke"
/>
);
})}
</svg>
{/* Nodes */}
{NODES.map((node, i) => {
const remedy = remedyNodes.find((r) => r.sourceNodeId === node.id);
return (
<MapNodeCircle key={node.id} node={node} index={i} remedyNode={remedy} />
);
})}
</div>
</div>
{/* ── Right: AI Chat Assistant ── */}
<div className="w-[340px] flex-shrink-0 pt-8">
<AIChatAssistant />
</div>
</div>
</div>
);
}
/* ═══════════════════ AI Chat Assistant ═══════════════════ */
interface ChatMessage {
id: number;
role: "user" | "assistant";
text: string;
}
function AIChatAssistant() {
const [input, setInput] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>([]);
const chatEndRef = React.useRef<HTMLDivElement>(null);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = () => {
const trimmed = input.trim();
if (!trimmed) return;
const userMsg: ChatMessage = { id: Date.now(), role: "user", text: trimmed };
const botMsg: ChatMessage = { id: Date.now() + 1, role: "assistant", text: trimmed };
setMessages((prev) => [...prev, userMsg, botMsg]);
setInput("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<motion.div
initial={{ opacity: 0, x: 40 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3, type: "spring", damping: 18 }}
className="rounded-3xl bg-white/60 backdrop-blur-xl border border-white/50 shadow-lg shadow-teal-200/20 overflow-hidden flex flex-col"
style={{ height: "78vh", minHeight: 560 }}
>
{/* Header */}
<div className="px-6 pt-6 pb-4 flex items-center gap-3">
{/* Owl avatar */}
<div className="relative w-14 h-14 flex-shrink-0">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-amber-300 to-amber-500 flex items-center justify-center shadow-md">
<svg viewBox="0 0 40 40" className="w-9 h-9" fill="none">
<ellipse cx="20" cy="24" rx="12" ry="10" fill="#C47F17" />
<circle cx="15" cy="20" r="5" fill="white" />
<circle cx="25" cy="20" r="5" fill="white" />
<circle cx="15" cy="20" r="2.5" fill="#2D2D2D" />
<circle cx="25" cy="20" r="2.5" fill="#2D2D2D" />
<circle cx="16" cy="19" r="1" fill="white" />
<circle cx="26" cy="19" r="1" fill="white" />
<polygon points="20,22 18,25 22,25" fill="#FF9500" />
<polygon points="10,16 8,8 15,14" fill="#C47F17" />
<polygon points="30,16 32,8 25,14" fill="#C47F17" />
</svg>
</div>
</div>
<div>
<h3 className="font-heading font-bold text-[15px] text-brand-gray-700">
AI Chat Assistant
</h3>
<p className="text-xs text-brand-gray-400 mt-0.5">
Ask me anything about this course!
</p>
</div>
</div>
{/* Chat messages area */}
<div className="flex-1 overflow-y-auto px-5 pb-3 space-y-3">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 gap-3 text-center">
<div className="w-12 h-12 rounded-full bg-brand-teal/10 flex items-center justify-center">
<svg viewBox="0 0 24 24" className="w-6 h-6 text-brand-teal" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</div>
<p className="text-xs text-brand-gray-400 leading-relaxed max-w-[200px]">
Type a message to start chatting with your AI study companion!
</p>
</div>
)}
{messages.map((msg) => (
<motion.div
key={msg.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[85%] rounded-2xl px-4 py-2.5 text-[13px] leading-relaxed ${
msg.role === "user"
? "bg-gradient-to-r from-brand-teal to-[#5fb3af] text-white rounded-br-md"
: "bg-white/70 border border-white/60 text-brand-gray-600 rounded-bl-md shadow-sm"
}`}
>
{msg.text}
</div>
</motion.div>
))}
<div ref={chatEndRef} />
</div>
{/* Divider */}
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-brand-teal/20 to-transparent" />
{/* Input area */}
<div className="px-5 py-4 flex items-end gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message..."
rows={1}
className="flex-1 resize-none rounded-xl bg-white/70 border border-white/60 px-4 py-3 text-[13px] text-brand-gray-700 placeholder-brand-gray-300 focus:outline-none focus:ring-2 focus:ring-brand-teal/30 focus:border-brand-teal/40 transition-all"
style={{ maxHeight: 80 }}
/>
<motion.button
onClick={handleSend}
whileTap={{ scale: 0.9 }}
className="flex-shrink-0 w-11 h-11 rounded-xl bg-gradient-to-r from-brand-teal to-[#5fb3af] text-white flex items-center justify-center shadow-md shadow-teal-300/30 hover:shadow-lg hover:shadow-teal-300/40 transition-all"
>
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
</motion.button>
</div>
</motion.div>
);
}
/* ═══════════════════ Node Circle ═══════════════════ */
function MapNodeCircle({
node,
index,
remedyNode,
}: {
node: MapNode;
index: number;
remedyNode?: RemedyNode;
}) {
const isClickable =
node.status === "available" || node.status === "completed";
const content = (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ delay: 0.15 * index, type: "spring", damping: 14 }}
className="flex flex-col items-center gap-2 cursor-pointer"
whileHover={isClickable ? { scale: 1.1 } : {}}
whileTap={isClickable ? { scale: 0.92 } : {}}
>
{/* Outer glow ring for available */}
{node.status === "available" && (
<>
<motion.div
className="absolute w-[88px] h-[88px] rounded-full"
style={{
background:
"radial-gradient(circle, rgba(122,199,196,0.25) 0%, transparent 70%)",
}}
animate={{ scale: [1, 1.4, 1], opacity: [0.4, 0.7, 0.4] }}
transition={{ repeat: Infinity, duration: 2.5, ease: "easeInOut" }}
/>
{/* Petal / scallop border */}
<motion.div
className="absolute w-[76px] h-[76px]"
animate={{ rotate: [0, 360] }}
transition={{ repeat: Infinity, duration: 20, ease: "linear" }}
>
<svg viewBox="0 0 76 76" className="w-full h-full" fill="none">
{Array.from({ length: 12 }).map((_, i) => {
const angle = (i * 30 * Math.PI) / 180;
const cx = 38 + 32 * Math.cos(angle);
const cy = 38 + 32 * Math.sin(angle);
return (
<circle
key={i}
cx={cx}
cy={cy}
r="8"
fill="none"
stroke="#7AC7C4"
strokeWidth="1"
opacity="0.3"
/>
);
})}
</svg>
</motion.div>
</>
)}
{/* Completed outer ring decoration */}
{node.status === "completed" && (
<motion.div
className="absolute w-[78px] h-[78px]"
animate={{ rotate: [0, -360] }}
transition={{ repeat: Infinity, duration: 25, ease: "linear" }}
>
<svg viewBox="0 0 78 78" className="w-full h-full" fill="none">
{Array.from({ length: 10 }).map((_, i) => {
const angle = (i * 36 * Math.PI) / 180;
const cx = 39 + 33 * Math.cos(angle);
const cy = 39 + 33 * Math.sin(angle);
return (
<circle
key={i}
cx={cx}
cy={cy}
r="7"
fill="none"
stroke="#F5C842"
strokeWidth="1"
opacity="0.35"
/>
);
})}
</svg>
</motion.div>
)}
{/* Main circle */}
<div
className={`relative z-10 h-[64px] w-[64px] rounded-full flex items-center justify-center transition-all ${
node.status === "completed"
? "shadow-lg shadow-amber-300/30"
: node.status === "available"
? "shadow-lg shadow-teal-400/30"
: "shadow-md"
}`}
>
{/* Background with double border effect */}
<div
className={`absolute inset-0 rounded-full ${
node.status === "completed"
? "bg-gradient-to-br from-yellow-300 via-amber-400 to-yellow-500"
: node.status === "available"
? "bg-gradient-to-br from-[#7AC7C4] via-[#5fb3af] to-[#4da8a4]"
: "bg-gradient-to-br from-[#e0ddd8] via-[#d4d0ca] to-[#c8c4be]"
}`}
/>
{/* Inner ring */}
<div
className={`absolute inset-[3px] rounded-full border-2 ${
node.status === "completed"
? "border-yellow-200/50"
: node.status === "available"
? "border-white/30"
: "border-white/20"
}`}
/>
{/* Specular highlight */}
<div className="absolute inset-0 rounded-full overflow-hidden">
<div
className="absolute -top-1 left-1/2 -translate-x-1/2 w-[70%] h-[40%] rounded-[50%]"
style={{
background:
node.status === "locked"
? "linear-gradient(180deg, rgba(255,255,255,0.25) 0%, transparent 100%)"
: "linear-gradient(180deg, rgba(255,255,255,0.35) 0%, transparent 100%)",
}}
/>
</div>
{/* Icon */}
<div className="relative z-10">
{node.status === "completed" ? (
<svg
viewBox="0 0 24 24"
className="h-7 w-7 text-white drop-shadow-sm"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6L9 17l-5-5" />
</svg>
) : node.status === "available" ? (
<motion.div
animate={{ scale: [1, 1.15, 1] }}
transition={{
repeat: Infinity,
duration: 2,
ease: "easeInOut",
}}
>
<svg
viewBox="0 0 24 24"
className="h-7 w-7 text-white drop-shadow-sm"
fill="currentColor"
>
<polygon points="12,2 15,9 22,9 16,14 18,22 12,17 6,22 8,14 2,9 9,9" />
</svg>
</motion.div>
) : (
<svg
viewBox="0 0 24 24"
className="h-6 w-6 text-[#a09a92]"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
)}
</div>
</div>
{/* Label */}
<span
className={`text-[11px] font-heading font-bold text-center max-w-[100px] leading-tight drop-shadow-sm ${
node.status === "completed"
? "text-amber-700"
: node.status === "available"
? "text-teal-700"
: "text-brand-gray-400"
}`}
>
{node.title}
</span>
</motion.div>
);
return (
<div
className="absolute z-10"
style={{
left: `${node.x}%`,
top: `${node.y}px`,
transform: "translate(-50%, -50%)",
}}
>
{/* ── Remedy circle node on the LEFT ── */}
<AnimatePresence>
{remedyNode && (
<motion.div
initial={{ opacity: 0, scale: 0.5, x: 30 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.5, x: 30 }}
transition={{ type: "spring", damping: 16, stiffness: 180 }}
className="absolute z-20"
style={{ right: "calc(100% + 60px)", top: "50%", transform: "translateY(-50%)" }}
>
{/* Dashed curve connecting to main node */}
<svg
className="absolute pointer-events-none"
style={{ left: "calc(100% + 4px)", top: "50%", transform: "translateY(-50%)" }}
width="56" height="20" viewBox="0 0 56 20" fill="none"
>
<path
d="M0 10 Q28 0 56 10"
stroke="#f59e0b"
strokeWidth="2"
strokeDasharray="4 3"
opacity="0.7"
fill="none"
/>
</svg>
{remedyNode.status === "available" ? (
<Link href={`/play/${remedyNode.sourceNodeId}`}>
<motion.div
className="flex flex-col items-center gap-2 cursor-pointer"
whileHover={{ scale: 1.12 }}
whileTap={{ scale: 0.92 }}
>
{/* Outer glow */}
<motion.div
className="absolute w-[80px] h-[80px] rounded-full"
style={{ background: "radial-gradient(circle, rgba(245,158,11,0.25) 0%, transparent 70%)" }}
animate={{ scale: [1, 1.35, 1], opacity: [0.4, 0.7, 0.4] }}
transition={{ repeat: Infinity, duration: 2.5, ease: "easeInOut" }}
/>
{/* Petal ring */}
<motion.div
className="absolute w-[70px] h-[70px]"
animate={{ rotate: [0, 360] }}
transition={{ repeat: Infinity, duration: 18, ease: "linear" }}
>
<svg viewBox="0 0 70 70" className="w-full h-full" fill="none">
{Array.from({ length: 10 }).map((_, i) => {
const angle = (i * 36 * Math.PI) / 180;
const cx = 35 + 29 * Math.cos(angle);
const cy = 35 + 29 * Math.sin(angle);
return <circle key={i} cx={cx} cy={cy} r="7" fill="none" stroke="#f59e0b" strokeWidth="1" opacity="0.3" />;
})}
</svg>
</motion.div>
{/* Main circle */}
<div className="relative z-10 h-[58px] w-[58px] rounded-full flex items-center justify-center shadow-lg shadow-amber-400/30">
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-300 via-orange-400 to-amber-500" />
<div className="absolute inset-[3px] rounded-full border-2 border-amber-200/50" />
<div className="absolute inset-0 rounded-full overflow-hidden">
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-[70%] h-[40%] rounded-[50%]" style={{ background: "linear-gradient(180deg, rgba(255,255,255,0.35) 0%, transparent 100%)" }} />
</div>
<div className="relative z-10">
<motion.div animate={{ scale: [1, 1.12, 1] }} transition={{ repeat: Infinity, duration: 2, ease: "easeInOut" }}>
<svg viewBox="0 0 24 24" className="h-6 w-6 text-white drop-shadow-sm" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z" />
<path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z" />
</svg>
</motion.div>
</div>
</div>
<span className="text-[10px] font-heading font-bold text-amber-700 text-center max-w-[90px] leading-tight drop-shadow-sm whitespace-nowrap">補強練習</span>
</motion.div>
</Link>
) : (
<div className="flex flex-col items-center gap-2">
{/* Completed ring – amber style */}
<motion.div
className="absolute w-[70px] h-[70px]"
animate={{ rotate: [0, -360] }}
transition={{ repeat: Infinity, duration: 25, ease: "linear" }}
>
<svg viewBox="0 0 70 70" className="w-full h-full" fill="none">
{Array.from({ length: 10 }).map((_, i) => {
const angle = (i * 36 * Math.PI) / 180;
const cx = 35 + 29 * Math.cos(angle);
const cy = 35 + 29 * Math.sin(angle);
return <circle key={i} cx={cx} cy={cy} r="7" fill="none" stroke="#f59e0b" strokeWidth="1" opacity="0.35" />;
})}
</svg>
</motion.div>
{/* Main circle – completed amber */}
<div className="relative z-10 h-[58px] w-[58px] rounded-full flex items-center justify-center shadow-lg shadow-amber-400/30">
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-300 via-orange-400 to-amber-500" />
<div className="absolute inset-[3px] rounded-full border-2 border-amber-200/50" />
<div className="absolute inset-0 rounded-full overflow-hidden">
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-[70%] h-[40%] rounded-[50%]" style={{ background: "linear-gradient(180deg, rgba(255,255,255,0.35) 0%, transparent 100%)" }} />
</div>
<div className="relative z-10">
<svg viewBox="0 0 24 24" className="h-7 w-7 text-white drop-shadow-sm" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6L9 17l-5-5" />
</svg>
</div>
</div>
<span className="text-[10px] font-heading font-bold text-amber-700 text-center max-w-[90px] leading-tight drop-shadow-sm whitespace-nowrap">補強完成</span>
</div>
)}
</motion.div>
)}
</AnimatePresence>
{isClickable ? (
<Link href={`/play/${node.id}`}>{content}</Link>
) : (
content
)}
</div>
);
}
/* ═══════════════════ Floating Particles ═══════════════════ */
function FloatingParticles() {
const particles = useMemo(
() =>
Array.from({ length: 24 }, (_, i) => ({
id: i,
x: 5 + Math.random() * 90,
size: 3 + Math.random() * 5,
delay: Math.random() * 8,
duration: 10 + Math.random() * 12,
opacity: 0.08 + Math.random() * 0.15,
type: i % 4, // 0=dot, 1=ring, 2=diamond, 3=star
})),
[]
);
return (
<div
className="pointer-events-none absolute inset-0 z-[1] overflow-hidden"
aria-hidden="true"
>
{particles.map((p) => (
<motion.div
key={p.id}
className="absolute"
style={{ left: `${p.x}%`, bottom: -20 }}
animate={{
y: [0, -(typeof window !== "undefined" ? window.innerHeight + 40 : 900)],
x: [0, (p.id % 2 === 0 ? 1 : -1) * (15 + Math.random() * 25)],
rotate: [0, 180 + Math.random() * 180],
}}
transition={{
duration: p.duration,
delay: p.delay,
repeat: Infinity,
ease: "linear",
}}
>
{p.type === 0 && (
<div
className="rounded-full bg-brand-teal"
style={{
width: p.size,
height: p.size,
opacity: p.opacity,
}}
/>
)}
{p.type === 1 && (
<div
className="rounded-full border border-brand-teal"
style={{
width: p.size * 1.5,
height: p.size * 1.5,
opacity: p.opacity,
}}
/>
)}
{p.type === 2 && (
<div
className="bg-amber-400"
style={{
width: p.size,
height: p.size,
opacity: p.opacity,
transform: "rotate(45deg)",
borderRadius: 1,
}}
/>
)}
{p.type === 3 && (
<svg
viewBox="0 0 24 24"
fill="#7AC7C4"
style={{
width: p.size * 1.8,
height: p.size * 1.8,
opacity: p.opacity,
}}
>
<polygon points="12,2 15,9 22,9 16,14 18,22 12,17 6,22 8,14 2,9 9,9" />
</svg>
)}
</motion.div>
))}
{/* Sparkle shimmer dots */}
{Array.from({ length: 14 }, (_, i) => (
<motion.div
key={`sparkle-${i}`}
className="absolute rounded-full bg-white"
style={{
width: 2 + Math.random() * 3,
height: 2 + Math.random() * 3,
left: `${8 + Math.random() * 84}%`,
top: `${10 + Math.random() * 80}%`,
}}
animate={{
opacity: [0, 0.4, 0],
scale: [0.5, 1.2, 0.5],
}}
transition={{
duration: 2 + Math.random() * 3,
delay: Math.random() * 5,
repeat: Infinity,
ease: "easeInOut",
}}
/>
))}
</div>
);
}
/* ═══════════════════ Background ═══════════════════ */
function MapBg() {
return (
<div
className="pointer-events-none absolute inset-0 z-0 overflow-hidden"
aria-hidden="true"
>
{/* Runic circle — bottom-right */}
<svg
viewBox="0 0 700 700"
className="absolute -right-[160px] -bottom-[160px] w-[520px] h-[520px] opacity-[0.08]"
fill="none"
>
<defs>
<radialGradient id="mapPeach" cx="50%" cy="45%" r="50%">
<stop offset="0%" stopColor="#D4EEE8" />
<stop offset="60%" stopColor="#B5DDD4" />
<stop offset="100%" stopColor="#9ACEC3" />
</radialGradient>
<path
id="mapRune"
d="M 350,350 m -260,0 a 260,260 0 1,1 520,0 a 260,260 0 1,1 -520,0"
/>
</defs>
<circle cx="350" cy="350" r="300" fill="url(#mapPeach)" opacity="0.5" />
<circle
cx="350"
cy="350"
r="290"
fill="none"
stroke="#7AC7C4"
strokeWidth="1"
opacity="0.3"
/>
<circle
cx="350"
cy="350"
r="240"
fill="none"
stroke="#7AC7C4"
strokeWidth="0.5"
opacity="0.2"
strokeDasharray="8 6"
/>
<text
fill="#7AC7C4"
fontSize="18"
fontWeight="500"
letterSpacing="4"
opacity="0.3"
>
<textPath href="#mapRune">
ᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇᛈᛉᛊᛏᛒᛖᛗᛚᛜᛝᛞᛟᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇᛈᛉᛊᛏᛒᛖᛗᛚᛜᛝᛞᛟ
</textPath>
</text>
</svg>
{/* Subtle top-left gradient orb */}
<div
className="absolute -left-[100px] -top-[100px] w-[400px] h-[400px] rounded-full opacity-[0.06]"
style={{
background:
"radial-gradient(circle, rgba(122,199,196,0.6) 0%, transparent 70%)",
}}
/>
{/* Mid-right soft glow */}
<div
className="absolute right-[5%] top-[35%] w-[200px] h-[200px] rounded-full opacity-[0.05]"
style={{
background:
"radial-gradient(circle, rgba(245,200,66,0.5) 0%, transparent 70%)",
}}
/>
</div>
);
}