NavigatedLearning / src /components /GridVisualization.tsx
Prabhas Jupalli
Deployment: High-Fidelity Dashboard & Native Storage Integration
74626f2
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 <BookOpen {...iconProps} />;
case 'video':
return <Play {...iconProps} />;
case 'quiz':
return <FileText {...iconProps} />;
case 'assignment':
return <PenTool {...iconProps} />;
default:
return <BookOpen {...iconProps} />;
}
};
const SocialAgent: React.FC<{ isMoving: boolean }> = ({ isMoving }) => {
return (
<motion.div
className="relative flex flex-col items-center justify-center -translate-y-4"
animate={isMoving ? {
scale: [1, 1.1, 1],
rotateY: 0
} : { rotateY: 0 }}
transition={{ duration: 0.3 }}
>
{/* Tooltip */}
<div className="absolute -bottom-8 bg-[#1A1F2E] text-white text-[10px] font-medium px-2 py-0.5 rounded shadow-lg whitespace-nowrap z-50">
You are here
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-l-transparent border-r-4 border-r-transparent border-b-4 border-b-[#1A1F2E]" />
</div>
{/* Main Avatar Container */}
<div className="relative w-12 h-12 rounded-full border-2 border-white shadow-xl overflow-visible p-0.5 bg-white">
<img
src={avatar}
alt="User Profile"
className="w-full h-full rounded-full object-cover"
/>
{/* Online Indicator */}
<div className="absolute top-0 right-0 w-3 h-3 bg-green-500 border-2 border-white rounded-full shadow-sm animate-pulse" />
</div>
</motion.div>
);
};
export const GridVisualization: React.FC<GridVisualizationProps> = ({
resources,
agent,
polylines,
onResourceClick,
onAgentMove,
isSimulationRunning,
dqnPathInfo,
onRefreshDQNPath,
isPlaying = false,
playbackPath = [],
onPlaybackComplete
}) => {
const [selectedResource, setSelectedResource] = useState<Resource | null>(null);
const [hoveredResource, setHoveredResource] = useState<string | null>(null);
const hoverTimeout = React.useRef<NodeJS.Timeout | null>(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<HTMLDivElement>(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<Resource | null>(null);
const [chatMessages, setChatMessages] = useState<{ role: string; content: string }[]>([]);
const [chatInput, setChatInput] = useState('');
const [chatLoading, setChatLoading] = useState(false);
// @ts-ignore
const chatEndRef = React.useRef<HTMLDivElement | null>(null);
// Tracking agent movement for animation
const [prevPosition, setPrevPosition] = useState<GridPosition>(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 => (
<svg key={polyline.id} className="absolute inset-0 w-full h-full pointer-events-none z-10">
<path
d={polyline.path.map((pos, i) =>
`${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]"
/>
<path
d={polyline.path.map((pos, i) =>
`${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"
/>
</svg>
));
};
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 (
<div className="absolute inset-0 z-50 pointer-events-none">
<svg width="100%" height="100%" viewBox="0 0 1000 1000" className="overflow-visible">
<defs>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="2" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
<linearGradient id="pathGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="50%" stopColor="#8b5cf6" />
<stop offset="100%" stopColor="#d946ef" />
</linearGradient>
<marker id="arrowhead-swallowtail" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto">
<path d="M 0 0 L 6 3 L 0 6 L 2 3 z" fill="#d946ef" />
</marker>
</defs>
{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 (
<g key={idx}>
{/* Background trace */}
<path d={seg.d} fill="none" stroke="rgba(139, 92, 246, 0.1)" strokeWidth="4" strokeLinecap="round" />
{/* Animated Glowing Line */}
<path
d={seg.d}
fill="none"
stroke="url(#pathGradient)"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="8 6"
markerEnd="url(#arrowhead-swallowtail)"
filter="url(#glow)"
strokeDashoffset={dashLen * (1 - segmentProgress)}
/>
</g>
);
})}
</svg>
</div>
);
};
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(
<div
key={`${x}-${y}`}
className={`
relative transition-all duration-300
${isAgent ? 'z-40' : 'z-30'}
${showTooltip ? 'z-[100]' : ''}
`}
style={{
gridColumn: x + 1,
gridRow: y + 1,
}}
onClick={() => handleCellClick(x, y)}
>
{resource && (
<div
className="absolute inset-0 m-auto flex items-center justify-center w-full h-full"
onMouseEnter={() => handleMouseEnter(resource.id)}
onMouseLeave={handleMouseLeave}
>
{/* Circular Reference Marker */}
<div className={`
relative flex items-center justify-center
w-10 h-10 transform transition-all duration-300
${selectedResource?.id === resource.id ? 'scale-110' : 'hover:scale-110 hover:-translate-y-1'}
`}>
{/* Outer Glow */}
<div className={`absolute inset-0 rounded-full blur-[8px] opacity-25 ${
resource.visited ? 'bg-green-400' :
resource.difficulty <= 2 ? 'bg-purple-400' :
resource.difficulty <= 4 ? 'bg-blue-400' :
resource.difficulty <= 6 ? 'bg-amber-400' : 'bg-red-400'
}`} />
{/* Pin Head — green when visited */}
<div className={`
w-8 h-8 rounded-full shadow-lg flex items-center justify-center border-2 border-white
transition-colors duration-500
${resource.visited ? 'bg-emerald-500' :
resource.difficulty <= 2 ? 'bg-[#A855F7]' :
resource.difficulty <= 4 ? 'bg-[#3B82F6]' :
resource.difficulty <= 6 ? 'bg-[#F59E0B]' : 'bg-[#EF4444]'}
`}>
<ResourceIcon type={resource.type} />
</div>
<div
className={`${tooltipClass} w-72 transition-opacity duration-200 z-50 ${showTooltip ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`}
onMouseEnter={() => handleMouseEnter(resource.id)}
onMouseLeave={handleMouseLeave}
>
<div className="bg-white rounded-xl shadow-2xl border border-gray-100 overflow-hidden cursor-default" onClick={(e) => e.stopPropagation()}>
<div className={`h-32 w-full relative ${resource.visited ? 'bg-gradient-to-tr from-emerald-500 to-teal-400' : 'bg-gradient-to-tr from-blue-600 to-indigo-500'}`}>
<div className="absolute inset-0 opacity-20" style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '16px 16px' }}></div>
<div className="absolute inset-0 flex items-center justify-center">
<ResourceIcon type={resource.type} />
<div className="absolute transform scale-[5] opacity-10"><ResourceIcon type={resource.type} /></div>
</div>
</div>
<div className="p-4 text-left">
<div className="flex justify-between items-start mb-2">
<h4 className="font-bold text-gray-900 text-lg leading-tight line-clamp-2">{resource.title}</h4>
{resource.visited && (
<span className="bg-green-100 text-green-700 text-[10px] font-bold px-2 py-0.5 rounded uppercase tracking-wide shrink-0 ml-2">
Completed
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 mb-3">
<span className="capitalize">{resource.type} Lesson</span>
<span></span>
<span>{['Beginner', 'Intermediate', 'Advanced', 'Expert', 'Master'][resource.difficulty - 1] || 'Intermediate'}</span>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 font-medium mb-4">
<div className="flex items-center gap-1">
<span className="text-gray-400"></span>
<span>{10 + (resource.title.length % 20)} mins</span>
</div>
<div className="flex items-center gap-1">
<span className="text-emerald-400">🎯</span>
<span>Target: {(resource.high_line ? resource.high_line * 100 : 80).toFixed(0)}%</span>
</div>
<div className="flex items-center gap-1">
<span className="text-amber-400"></span>
<span>{resource.base_points ?? 50} pts</span>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
if (isAgent && resource.youtube_url) {
setVideoResource(resource);
setHoveredResource(null);
} else {
handleCellClick(x, y);
}
}}
className={`w-full py-2.5 ${isAgent ? 'bg-indigo-600 hover:bg-indigo-700 border-indigo-500/20 shadow-indigo-500/20' : 'bg-blue-600 hover:bg-blue-700 border-blue-500/20 shadow-blue-500/10'} text-white text-sm font-semibold rounded-xl transition-all flex items-center justify-center gap-2 shadow-lg active:scale-95 transform duration-100 border`}
>
{isAgent ? '▶ Start Lesson' : 'Travel to Lesson'}
<span className="text-lg leading-none"></span>
</button>
</div>
</div>
<div className={`w-4 h-4 bg-white transform rotate-45 shadow-lg z-[-1] ${arrowClass}`}></div>
</div>
</div>
</div>
)}
{/* Resource cell placeholder */}
</div>
);
}
}
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 (
<div className="flex flex-col h-full bg-gray-50/30 overflow-hidden">
<div className="flex-none px-6 py-4 bg-white/90 backdrop-blur-sm border-b border-gray-100 flex justify-between items-center z-10">
<div>
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
<MapPin className="w-5 h-5 text-blue-600" />
Learning Environment
</h2>
<p className="text-xs text-gray-500 mt-0.5">Explore resources and track your journey</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center bg-white border border-gray-100 rounded-lg shadow-sm p-1">
<button
onClick={() => setZoomLevel(prev => Math.max(1, prev - 0.25))}
className="p-1.5 hover:bg-gray-50 text-gray-500 transition-colors rounded-md"
title="Zoom Out"
>
<ZoomOut size={16} />
</button>
<div className="w-px h-4 bg-gray-100 mx-1"></div>
<button
onClick={() => setZoomLevel(1.5)}
className="px-2 py-1 hover:bg-gray-50 text-gray-400 text-[10px] font-bold transition-colors rounded-md"
title="Reset Zoom"
>
{Math.round(zoomLevel * 100)}%
</button>
<div className="w-px h-4 bg-gray-100 mx-1"></div>
<button
onClick={() => setZoomLevel(prev => Math.min(3, prev + 0.25))}
className="p-1.5 hover:bg-gray-50 text-gray-500 transition-colors rounded-md"
title="Zoom In"
>
<ZoomIn size={16} />
</button>
</div>
<button
onClick={() => setIsSearching(true)}
className="flex items-center gap-2 px-3 py-1.5 bg-white border border-blue-200 hover:bg-blue-50 text-blue-600 text-xs font-semibold rounded-lg transition-all shadow-sm"
>
<Search className="w-3.5 h-3.5" />
<span>Search</span>
</button>
{isSimulationRunning && (
<button
onClick={onRefreshDQNPath}
className="flex items-center gap-2 px-3 py-1.5 bg-white border border-red-200 hover:bg-red-50 text-red-600 text-xs font-semibold rounded-lg transition-all shadow-sm group"
>
<RefreshCw className="w-3.5 h-3.5 group-hover:rotate-180 transition-transform duration-500" />
<span>Optimize Path</span>
</button>
)}
<div className="flex items-center gap-3 bg-gray-50 px-3 py-1.5 rounded-lg border border-gray-100">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full border border-blue-200 overflow-hidden bg-white shadow-sm">
<img src={avatar} alt="Avatar" className="w-full h-full object-cover" />
</div>
<span className="text-xs font-medium text-gray-600">You (Agent)</span>
</div>
<div className="w-px h-3 bg-gray-200"></div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 bg-[#A855F7] rounded-full"></div>
<span className="text-xs font-medium text-gray-600">Resource</span>
</div>
</div>
</div>
</div>
{isSimulationRunning && dqnPathInfo?.resource && (
<div className="absolute top-20 right-6 z-20 animate-in slide-in-from-top-4 fade-in duration-300 pointer-events-none">
<div className="bg-white/95 backdrop-blur-md px-4 py-3 rounded-xl shadow-lg border border-red-100 ring-1 ring-red-50 w-64 pointer-events-auto">
<div className="flex items-start justify-between gap-3">
<div>
<span className="text-[10px] font-bold tracking-wider text-red-500 uppercase">Recommendation</span>
<h3 className="text-sm font-semibold text-gray-900 mt-0.5 line-clamp-1">{dqnPathInfo.resource.title}</h3>
</div>
<div className="flex flex-col items-end">
<span className="text-lg font-bold text-red-600 leading-none">+{dqnPathInfo.reward}</span>
</div>
</div>
</div>
</div>
)}
<div className="flex-1 w-full relative overflow-auto bg-gray-50/30" ref={gridRef}>
<div className="min-w-full min-h-full flex items-center justify-center p-8">
<div
className="grid gap-0 bg-[#F8FAFC] rounded-3xl shadow-2xl p-4 shrink-0 relative overflow-hidden"
style={{
width: `${1000 * zoomLevel}px`,
height: `${1000 * zoomLevel}px`,
gridTemplateColumns: `repeat(${GRID_SIZE}, 1fr)`,
gridTemplateRows: `repeat(${GRID_SIZE}, 1fr)`,
backgroundImage: `radial-gradient(#E2E8F0 1.5px, transparent 1.5px)`,
backgroundSize: `${24 * zoomLevel}px ${24 * zoomLevel}px`,
transition: 'width 0.3s ease-out, height 0.3s ease-out'
}}
>
{/* Map Terrain: color-coded 0°-90° quarter-circle arcs with hover tooltips */}
<div className="absolute inset-0 pointer-events-none z-0">
<svg className="absolute inset-0 w-full h-full" viewBox="0 0 1000 1000" preserveAspectRatio="none">
{/* 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 (
<path key={`arc-${r}`}
d={`M ${r} 1000 A ${r} ${r} 0 0 0 0 ${1000 - r}`}
fill="none"
stroke={tier.color}
strokeWidth="1.5"
strokeDasharray="5 7"
opacity="0.5"
style={{ pointerEvents: 'stroke', cursor: 'default' }}
>
<title>{tier.name}</title>
</path>
);
});
})()}
{/* Radial spokes every 10° */}
{[10,20,30,40,50,60,70,80].map((deg) => {
const rad = (deg * Math.PI) / 180;
return <line key={`sp-${deg}`}
x1={0} y1={1000}
x2={950 * Math.cos(rad)}
y2={1000 - 950 * Math.sin(rad)}
stroke="#94A3B8" strokeWidth="0.8"
strokeDasharray="4 7" opacity="0.35"
/>;
})}
{/* X-axis (bottom) and Y-axis (left) */}
<line x1="0" y1="999" x2="980" y2="999" stroke="#94A3B8" strokeWidth="1.5"/>
<line x1="1" y1="1000" x2="1" y2="20" stroke="#94A3B8" strokeWidth="1.5"/>
{/* Tier boundary tick marks */}
{[250,450,650].map(x => (
<line key={`tick-${x}`} x1={x} y1={992} x2={x} y2={1000} stroke="#94A3B8" strokeWidth="1.5"/>
))}
</svg>
{/* Bottom tier labels aligned to arc radii */}
<div className="absolute bottom-1 left-0 w-full pointer-events-none">
{[
{ 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) => (
<span key={t.label}
className={`absolute text-[9px] font-black uppercase tracking-[0.15em] -translate-x-1/2 ${t.color}`}
style={{ left: t.pct, bottom: 0 }}>
{t.label}
</span>
))}
</div>
</div>
{renderGrid()}
{renderPlaybackOverlay()}
{/* Animated Student Agent Marker */}
<motion.div
key={`agent-at-${agent.position.x}-${agent.position.y}`}
className="absolute pointer-events-none z-40 flex items-center justify-center"
initial={false}
transition={{
left: { type: "spring", damping: 25, stiffness: 120 },
top: { type: "spring", damping: 25, stiffness: 120 },
scale: { duration: 0.4 },
rotate: { duration: 0.4 }
}}
animate={{
left: `${(agent.position.x + 0.5) * (100 / GRID_SIZE)}%`,
top: `${(agent.position.y + 0.5) * (100 / GRID_SIZE)}%`,
scale: isPlaying ? [1, 1.15, 1] : 1, // Subtle bounce effect during playback
rotateY: 0 // Force orientation reset to clear potential mirrored state
}}
style={{
width: `${100 / GRID_SIZE}%`,
height: `${100 / GRID_SIZE}%`,
x: '-50%',
y: '-50%',
}}
>
<SocialAgent isMoving={isPlaying} />
</motion.div>
{showTravelArrow && (
<svg className="absolute inset-0 w-full h-full pointer-events-none z-30">
<defs>
<linearGradient id="travelGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#ef4444" stopOpacity="0.8" />
<stop offset="100%" stopColor="#ec4899" stopOpacity="0.8" />
</linearGradient>
<marker id="travel-arrowhead" markerWidth="12" markerHeight="8" refX="10" refY="4" orient="auto">
<path d="M0,0 L12,4 L0,8 Z" fill="#ef4444" />
</marker>
</defs>
<line
x1={`${(prevPosition.x + 0.5) * (100 / GRID_SIZE)}%`}
y1={`${(prevPosition.y + 0.5) * (100 / GRID_SIZE)}%`}
x2={`${(agent.position.x + 0.5) * (100 / GRID_SIZE)}%`}
y2={`${(agent.position.y + 0.5) * (100 / GRID_SIZE)}%`}
stroke="url(#travelGradient)"
strokeWidth="4"
strokeDasharray="12 6"
className="animate-pulse"
markerEnd="url(#travel-arrowhead)"
opacity="0.8"
>
<animate attributeName="stroke-dashoffset" from="100" to="0" dur="2s" repeatCount="indefinite" />
</line>
</svg>
)}
</div>
</div>
</div>
{selectedResource && (
<div className="flex-none w-full bg-white/95 backdrop-blur-md p-4 border-t border-blue-100 z-10 animate-in slide-in-from-bottom-4 fade-in duration-300">
<div className="max-w-4xl mx-auto flex items-center gap-4">
<div className={`p-3 rounded-lg ${selectedResource.visited ? 'bg-green-100 text-green-600' : 'bg-blue-100 text-blue-600'}`}>
<ResourceIcon type={selectedResource.type} />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900">{selectedResource.title}</h3>
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
<span className="px-2 py-0.5 bg-gray-100 rounded text-xs font-medium uppercase tracking-wide">{selectedResource.type}</span>
</div>
</div>
<div className="flex flex-col items-end border-l border-gray-100 pl-4">
<span className="text-xs text-gray-400 uppercase font-semibold">Reward</span>
<span className="text-xl font-bold text-gray-900">+{selectedResource.reward}</span>
</div>
</div>
</div>
)}
{isSearching && (
<div className="fixed inset-0 bg-gray-950/40 backdrop-blur-md flex items-center justify-center z-[250] p-4 animate-in fade-in duration-300">
<div className="bg-white/95 backdrop-blur-2xl rounded-[2rem] shadow-2xl w-full max-w-lg overflow-hidden border border-white/20 animate-in zoom-in-95 duration-300 flex flex-col max-h-[80vh]">
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between bg-white/50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center text-white shadow-lg shadow-blue-500/20">
<Search className="w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">Resource Navigator</h3>
</div>
</div>
<button
onClick={() => setIsSearching(false)}
className="p-2 rounded-full hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4 border-b border-gray-100 bg-gray-50/50">
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
autoFocus
type="text"
placeholder="Filter resources..."
value={searchQuery}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{resources
.filter(r => r.title.toLowerCase().includes(searchQuery.toLowerCase()))
.map(resource => (
<button
key={resource.id}
onClick={() => {
onAgentMove(resource.position);
onResourceClick(resource);
setIsSearching(false);
setSearchQuery('');
}}
className="w-full flex items-center gap-4 p-3 rounded-2xl hover:bg-blue-50/50 border border-transparent hover:border-blue-100 transition-all group text-left"
>
<div className={`p-2.5 rounded-xl ${resource.visited ? 'bg-green-100 text-green-600' : 'bg-blue-100 text-blue-600'} group-hover:scale-110 transition-transform`}>
<ResourceIcon type={resource.type} />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-900 text-sm truncate">{resource.title}</h4>
</div>
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-blue-500 group-hover:translate-x-1 transition-all" />
</button>
))}
</div>
</div>
</div>
)}
{videoResource && videoResource.youtube_url && (
<div
className="fixed inset-0 bg-gray-950/80 backdrop-blur-3xl flex items-center justify-center z-[200] p-4 sm:p-8"
onClick={() => { setVideoResource(null); setChatMessages([]); setChatInput(''); }}
>
<div
className="bg-[#0f111a]/95 backdrop-blur-md rounded-[2.5rem] shadow-[0_0_80px_-15px_rgba(79,70,229,0.25)] w-full max-w-7xl h-[90vh] overflow-hidden flex flex-col border border-white/10 animate-in zoom-in-90 fade-in duration-500 ease-out relative z-10"
onClick={(e) => e.stopPropagation()}
>
<div className="bg-[#131620]/80 backdrop-blur-3xl px-8 py-5 flex items-center justify-between border-b border-white/5 flex-shrink-0 relative z-20">
<div className="flex items-center gap-5 text-white min-w-0">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-red-500 via-rose-500 to-pink-600 flex items-center justify-center flex-shrink-0 shadow-lg shadow-red-500/30 border border-white/10">
<Play size={20} className="text-white fill-current ml-0.5" />
</div>
<div className="min-w-0">
<h3 className="font-bold text-lg truncate text-white tracking-tight drop-shadow-sm">{videoResource.title}</h3>
</div>
</div>
<button
onClick={() => { setVideoResource(null); setChatMessages([]); setChatInput(''); }}
className="w-12 h-12 rounded-full bg-white/5 hover:bg-red-500/20 hover:border-red-500/30 text-gray-400 hover:text-red-400 transition-all flex items-center justify-center border border-white/5 group"
>
<X size={22} className="group-hover:rotate-90 transition-transform duration-300" />
</button>
</div>
<div className="flex flex-1 min-h-0 bg-[#090b10]">
<div className="flex-[65] relative bg-black overflow-hidden z-10">
<iframe
className="w-full h-full"
src={getYouTubeEmbedUrl(videoResource.youtube_url) || ''}
title={videoResource.title}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
</div>
<div className="flex-[35] flex flex-col bg-[#0f111a] border-l border-white/5 relative overflow-hidden z-20">
<div className="bg-gradient-to-r from-indigo-500/10 to-purple-500/10 backdrop-blur-2xl px-6 py-5 flex items-center gap-4 border-b border-white/5 flex-shrink-0 relative z-30">
<div className="w-10 h-10 rounded-[14px] bg-gradient-to-tr from-indigo-500 via-purple-500 to-fuchsia-500 flex items-center justify-center shadow-lg border border-white/10 ring-2 ring-white/5">
<Sparkles size={18} className="text-white" />
</div>
<div>
<h4 className="font-extrabold text-white text-[15px] tracking-tight">Learning Assistant</h4>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-5 relative z-20">
{chatMessages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] px-5 py-3.5 rounded-2xl text-[15px] leading-relaxed relative ${msg.role === 'user'
? 'bg-indigo-600 text-white rounded-br-sm'
: 'bg-white/5 border border-white/10 text-gray-200 rounded-bl-sm'
}`}>
{msg.content}
</div>
</div>
))}
{chatLoading && <div className="text-gray-500 text-xs animate-pulse">Thinking...</div>}
</div>
<form onSubmit={handleChatSubmit} className="p-4 border-t border-white/5 relative z-30">
<div className="relative">
<input
type="text"
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
placeholder="Ask Sider AI..."
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white text-sm outline-none focus:border-indigo-500/50 transition-all"
/>
<button type="submit" className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-indigo-400 hover:text-indigo-300">
<ChevronRight size={18} />
</button>
</div>
</form>
</div>
</div>
</div>
</div>
)}
</div>
);
};