import React, { useState } from 'react'; import { motion } from 'framer-motion'; import { Resource, Agent, GridPosition, Polyline } from '../types'; import { BookOpen, Play, FileText, PenTool, RefreshCw, MapPin, Sparkles, Search, X, ChevronRight, ZoomIn, ZoomOut } from 'lucide-react'; import { nlpApi } from '../services/nlpApi'; const avatar = "https://api.dicebear.com/7.x/avataaars/svg?seed=Felix&backgroundColor=f1f5f9"; interface GridVisualizationProps { resources: Resource[]; agent: Agent; polylines: Polyline[]; onResourceClick: (resource: Resource) => void; onAgentMove: (position: GridPosition) => void; isSimulationRunning: boolean; dqnPathInfo: { resource: Resource | null, reward: number } | null; onRefreshDQNPath: () => void; isPlaying?: boolean; playbackPath?: GridPosition[]; onPlaybackComplete?: () => void; } const GRID_SIZE = 20; const ResourceIcon = ({ type }: { type: Resource['type'] }) => { const iconProps = { size: 14, className: "text-white drop-shadow-sm" }; switch (type) { case 'book': return ; case 'video': return ; case 'quiz': return ; case 'assignment': return ; default: return ; } }; const SocialAgent: React.FC<{ isMoving: boolean }> = ({ isMoving }) => { return ( {/* Tooltip */}
You are here
{/* Main Avatar Container */}
User Profile {/* Online Indicator */}
); }; export const GridVisualization: React.FC = ({ resources, agent, polylines, onResourceClick, onAgentMove, isSimulationRunning, dqnPathInfo, onRefreshDQNPath, isPlaying = false, playbackPath = [], onPlaybackComplete }) => { const [selectedResource, setSelectedResource] = useState(null); const [hoveredResource, setHoveredResource] = useState(null); const hoverTimeout = React.useRef(null); const [pathProgress, setPathProgress] = useState(0); const [isSearching, setIsSearching] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [zoomLevel, setZoomLevel] = useState(1.5); const gridRef = React.useRef(null); // Auto-scroll to agent on load or movement React.useEffect(() => { if (gridRef.current && agent.position) { const scrollY = (agent.position.y * (1000 / GRID_SIZE) * zoomLevel) - (gridRef.current.clientHeight / 2); const scrollX = (agent.position.x * (1000 / GRID_SIZE) * zoomLevel) - (gridRef.current.clientWidth / 2); gridRef.current.scrollTo({ top: scrollY, left: scrollX, behavior: 'smooth' }); } }, [agent.position.x, agent.position.y, zoomLevel]); const [videoResource, setVideoResource] = useState(null); const [chatMessages, setChatMessages] = useState<{ role: string; content: string }[]>([]); const [chatInput, setChatInput] = useState(''); const [chatLoading, setChatLoading] = useState(false); // @ts-ignore const chatEndRef = React.useRef(null); // Tracking agent movement for animation const [prevPosition, setPrevPosition] = useState(agent.position); const [showTravelArrow, setShowTravelArrow] = useState(false); React.useEffect(() => { if (agent.position.x !== prevPosition.x || agent.position.y !== prevPosition.y) { setShowTravelArrow(true); const timer = setTimeout(() => { setShowTravelArrow(false); setPrevPosition(agent.position); }, 1500); // Duration of arrow visibility return () => clearTimeout(timer); } }, [agent.position, prevPosition]); // Convert YouTube URL to embed URL const getYouTubeEmbedUrl = (url: string): string | null => { if (!url) return null; // Handle youtu.be/ID and youtube.com/watch?v=ID formats let videoId = ''; if (url.includes('youtu.be/')) { videoId = url.split('youtu.be/')[1]?.split(/[?&#]/)[0] || ''; } else if (url.includes('watch?v=')) { videoId = url.split('watch?v=')[1]?.split(/[?&#]/)[0] || ''; } else if (url.includes('youtube.com/embed/')) { return url; // Already embed URL } return videoId ? `https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0` : null; }; const handleMouseEnter = (id: string) => { if (hoverTimeout.current) clearTimeout(hoverTimeout.current); setHoveredResource(id); }; const handleMouseLeave = () => { hoverTimeout.current = setTimeout(() => { setHoveredResource(null); }, 200); }; // Handle Playback Animation React.useEffect(() => { if (isPlaying && playbackPath.length > 1) { setPathProgress(0); const totalDuration = playbackPath.length * 800; // 800ms per segment const startTime = Date.now(); const interval = setInterval(() => { const elapsed = Date.now() - startTime; const progress = Math.min(elapsed / totalDuration, 1); setPathProgress(progress); if (progress >= 1) { clearInterval(interval); if (onPlaybackComplete) onPlaybackComplete(); } }, 16); return () => clearInterval(interval); } }, [isPlaying, playbackPath, onPlaybackComplete]); const handleCellClick = (x: number, y: number) => { const resource = resources.find(r => r.position.x === x && r.position.y === y); if (resource) { setSelectedResource(resource); onResourceClick(resource); } else { onAgentMove({ x, y }); } }; const renderNeuralRoad = () => { return polylines.filter(p => p.isActive).map(polyline => ( `${i === 0 ? 'M' : 'L'} ${(pos.x + 0.5) * 50} ${(pos.y + 0.5) * 50}` ).join(' ')} fill="none" stroke={polyline.color} strokeWidth="12" strokeLinecap="round" strokeLinejoin="round" className="opacity-20 filter blur-[2px]" /> `${i === 0 ? 'M' : 'L'} ${(pos.x + 0.5) * 50} ${(pos.y + 0.5) * 50}` ).join(' ')} fill="none" stroke={polyline.color} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" strokeDasharray="1 8" className="opacity-40" /> )); }; const renderPlaybackOverlay = () => { if (!isPlaying || playbackPath.length < 2) return null; // Convert grid coordinates to percentages (assuming 1fr grid) // Grid matches 1000x1000px container const CELL_SIZE = 1000 / GRID_SIZE; const HALF_CELL = CELL_SIZE / 2; const pathSegments = []; for (let i = 0; i < playbackPath.length - 1; i++) { const start = playbackPath[i]; const end = playbackPath[i + 1]; const x1 = start.x * CELL_SIZE + HALF_CELL; const y1 = start.y * CELL_SIZE + HALF_CELL; const x2 = end.x * CELL_SIZE + HALF_CELL; const y2 = end.y * CELL_SIZE + HALF_CELL; // Calculate control point for arc // Midpoint const mx = (x1 + x2) / 2; const my = (y1 + y2) / 2; // Vector const dx = x2 - x1; const dy = y2 - y1; // Perpendicular vector (scaled) - Fixed arc height const arcHeight = 30; // Normalize const len = Math.sqrt(dx * dx + dy * dy) || 1; const px = -dy / len * arcHeight; const py = dx / len * arcHeight; const cx = mx + px; const cy = my + py; const d = `M ${x1} ${y1} Q ${cx} ${cy} ${x2} ${y2}`; pathSegments.push({ d, id: i }); // Add index to stagger animation } return (
{pathSegments.map((seg, idx) => { const segmentProgress = Math.max(0, Math.min(1, (pathProgress * playbackPath.length) - idx)); const dashLen = 1000; if (segmentProgress <= 0) return null; return ( {/* Background trace */} {/* Animated Glowing Line */} ); })}
); }; const renderGrid = () => { const cells = []; for (let y = 0; y < GRID_SIZE; y++) { for (let x = 0; x < GRID_SIZE; x++) { const resource = resources.find(r => r.position.x === x && r.position.y === y); const isAgent = agent.position.x === x && agent.position.y === y; // Dynamic Tooltip Positioning let tooltipClass = "absolute bottom-full left-1/2 -translate-x-1/2 mb-2"; let arrowClass = "absolute left-1/2 -translate-x-1/2 -bottom-2"; if (x < 4) { tooltipClass = "absolute bottom-full left-0 mb-2"; arrowClass = "absolute left-4 -bottom-2"; } else if (x > 15) { tooltipClass = "absolute bottom-full right-0 mb-2"; arrowClass = "absolute right-4 -bottom-2"; } if (y < 4) { if (x < 4) { tooltipClass = "absolute top-full left-0 mt-2"; arrowClass = "absolute left-4 -top-2"; } else if (x > 15) { tooltipClass = "absolute top-full right-0 mt-2"; arrowClass = "absolute right-4 -top-2"; } else { tooltipClass = "absolute top-full left-1/2 -translate-x-1/2 mt-2"; arrowClass = "absolute left-1/2 -translate-x-1/2 -top-2"; } } const showTooltip = resource && hoveredResource === resource.id; cells.push(
handleCellClick(x, y)} > {resource && (
handleMouseEnter(resource.id)} onMouseLeave={handleMouseLeave} > {/* Circular Reference Marker */}
{/* Outer Glow */}
{/* Pin Head — green when visited */}
handleMouseEnter(resource.id)} onMouseLeave={handleMouseLeave} >
e.stopPropagation()}>

{resource.title}

{resource.visited && ( Completed )}
{resource.type} Lesson {['Beginner', 'Intermediate', 'Advanced', 'Expert', 'Master'][resource.difficulty - 1] || 'Intermediate'}
{10 + (resource.title.length % 20)} mins
🎯 Target: {(resource.high_line ? resource.high_line * 100 : 80).toFixed(0)}%
{resource.base_points ?? 50} pts
)} {/* Resource cell placeholder */}
); } } return cells; }; const handleChatSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!chatInput.trim() || !videoResource || chatLoading) return; const userMessage = chatInput; setChatInput(''); setChatMessages(prev => [...prev, { role: 'user', content: userMessage }]); setChatLoading(true); try { const data = await nlpApi.chat(videoResource.title, userMessage, chatMessages); setChatMessages(prev => [...prev, { role: 'ai', content: data.answer }]); } catch (error) { console.error('Chat error:', error); setChatMessages(prev => [...prev, { role: 'ai', content: 'Sorry, I failed to process your request.' }]); } finally { setChatLoading(false); } }; return (

Learning Environment

Explore resources and track your journey

{isSimulationRunning && ( )}
Avatar
You (Agent)
Resource
{isSimulationRunning && dqnPathInfo?.resource && (
Recommendation

{dqnPathInfo.resource.title}

+{dqnPathInfo.reward}
)}
{/* Map Terrain: color-coded 0°-90° quarter-circle arcs with hover tooltips */}
{/* Color-coded concentric arcs — each arc belongs to a tier */} {(() => { // Tier boundaries based on radii 3,7,11,15 grid units × 50px/unit // Boundaries (midpoints between radii): 0-250, 250-450, 450-650, 650-900 const tierDefs = [ { name: 'Fundamentals', color: '#A855F7', from: 0, to: 250 }, { name: 'Intermediate', color: '#3B82F6', from: 250, to: 450 }, { name: 'Advance', color: '#F59E0B', from: 450, to: 650 }, { name: 'Mastery', color: '#EF4444', from: 650, to: 900 }, ]; const allArcs = [100,200,300,400,500,600,700,800,900]; return allArcs.map((r) => { const tier = tierDefs.find(t => r > t.from && r <= t.to) || tierDefs[tierDefs.length - 1]; return ( {tier.name} ); }); })()} {/* Radial spokes every 10° */} {[10,20,30,40,50,60,70,80].map((deg) => { const rad = (deg * Math.PI) / 180; return ; })} {/* X-axis (bottom) and Y-axis (left) */} {/* Tier boundary tick marks */} {[250,450,650].map(x => ( ))} {/* Bottom tier labels aligned to arc radii */}
{[ { label: 'FUNDAMENTALS', pct: '12.5%', color: 'text-purple-400' }, { label: 'INTERMEDIATE', pct: '35%', color: 'text-blue-400' }, { label: 'ADVANCE', pct: '55%', color: 'text-amber-400' }, { label: 'MASTERY', pct: '77.5%', color: 'text-red-400' }, ].map((t) => ( {t.label} ))}
{renderGrid()} {renderPlaybackOverlay()} {/* Animated Student Agent Marker */} {showTravelArrow && ( )}
{selectedResource && (

{selectedResource.title}

{selectedResource.type}
Reward +{selectedResource.reward}
)} {isSearching && (

Resource Navigator

setSearchQuery(e.target.value)} className="w-full pl-11 pr-4 py-3 bg-white border border-gray-200 rounded-2xl outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all font-medium text-gray-800" />
{resources .filter(r => r.title.toLowerCase().includes(searchQuery.toLowerCase())) .map(resource => ( ))}
)} {videoResource && videoResource.youtube_url && (
{ setVideoResource(null); setChatMessages([]); setChatInput(''); }} >
e.stopPropagation()} >

{videoResource.title}