LMS-BGN/src/components/quiz/InteractiveImageHotspot.tsx

358 lines
13 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react';
import { Eye, Target, CheckCircle, XCircle, AlertTriangle, Lightbulb, Zap } from 'lucide-react';
import { cn } from '../../utils/cn';
interface InteractiveImageHotspotProps {
imageSrc: string;
title: string;
description: string;
hotspots: ImageHotspot[];
onComplete: (results: HotspotResult[]) => void;
className?: string;
showHints?: boolean;
allowMultipleAttempts?: boolean;
}
interface ImageHotspot {
id: string;
x: number; // percentage (0-100)
y: number; // percentage (0-100)
width: number; // percentage
height: number; // percentage
type: 'correct' | 'incorrect' | 'neutral' | 'warning';
title: string;
description: string;
feedback: string;
points: number;
hint?: string;
consequence?: string;
isRequired?: boolean;
}
interface HotspotResult {
hotspotId: string;
clicked: boolean;
timestamp: number;
attempts: number;
isCorrect: boolean;
points: number;
}
const InteractiveImageHotspot: React.FC<InteractiveImageHotspotProps> = ({
imageSrc,
title,
description,
hotspots,
onComplete,
className,
showHints = true,
allowMultipleAttempts = true
}) => {
const imageRef = useRef<HTMLImageElement>(null);
const [imageLoaded, setImageLoaded] = useState(false);
const [clickedHotspots, setClickedHotspots] = useState<Set<string>>(new Set());
const [hotspotResults, setHotspotResults] = useState<Map<string, HotspotResult>>(new Map());
const [showFeedback, setShowFeedback] = useState<string | null>(null);
const [currentFeedback, setCurrentFeedback] = useState<ImageHotspot | null>(null);
const [hintsVisible, setHintsVisible] = useState(false);
const [attempts, setAttempts] = useState<Map<string, number>>(new Map());
useEffect(() => {
// Check if all required hotspots are clicked
const requiredHotspots = hotspots.filter(h => h.isRequired);
const clickedRequired = requiredHotspots.filter(h => clickedHotspots.has(h.id));
if (requiredHotspots.length > 0 && clickedRequired.length === requiredHotspots.length) {
const results = Array.from(hotspotResults.values());
onComplete(results);
}
}, [clickedHotspots, hotspots, hotspotResults, onComplete]);
const handleHotspotClick = (hotspot: ImageHotspot, event: React.MouseEvent) => {
event.preventDefault();
const currentAttempts = attempts.get(hotspot.id) || 0;
const newAttempts = currentAttempts + 1;
// Update attempts
setAttempts(prev => new Map(prev.set(hotspot.id, newAttempts)));
// Check if already clicked and multiple attempts not allowed
if (clickedHotspots.has(hotspot.id) && !allowMultipleAttempts) {
return;
}
// Add to clicked hotspots
setClickedHotspots(prev => new Set([...Array.from(prev), hotspot.id]));
// Calculate points (reduce points for multiple attempts)
let points = hotspot.points;
if (newAttempts > 1) {
points = Math.max(0, hotspot.points - (newAttempts - 1) * 2);
}
// Create result
const result: HotspotResult = {
hotspotId: hotspot.id,
clicked: true,
timestamp: Date.now(),
attempts: newAttempts,
isCorrect: hotspot.type === 'correct',
points: hotspot.type === 'correct' ? points : 0
};
// Update results
setHotspotResults(prev => new Map(prev.set(hotspot.id, result)));
// Show feedback
setCurrentFeedback(hotspot);
setShowFeedback(hotspot.id);
// Auto-hide feedback after 3 seconds
setTimeout(() => {
setShowFeedback(null);
setCurrentFeedback(null);
}, 3000);
};
const getHotspotIcon = (type: string) => {
switch (type) {
case 'correct':
return <CheckCircle className="text-green-600" size={16} />;
case 'incorrect':
return <XCircle className="text-red-600" size={16} />;
case 'warning':
return <AlertTriangle className="text-yellow-600" size={16} />;
default:
return <Target className="text-blue-600" size={16} />;
}
};
const getHotspotColor = (hotspot: ImageHotspot) => {
const isClicked = clickedHotspots.has(hotspot.id);
if (isClicked) {
switch (hotspot.type) {
case 'correct':
return 'border-green-500 bg-green-500/20';
case 'incorrect':
return 'border-red-500 bg-red-500/20';
case 'warning':
return 'border-yellow-500 bg-yellow-500/20';
default:
return 'border-blue-500 bg-blue-500/20';
}
}
return showHints
? 'border-blue-400 bg-blue-400/10 animate-pulse'
: 'border-transparent bg-transparent hover:border-blue-400 hover:bg-blue-400/10';
};
const totalPoints = Array.from(hotspotResults.values()).reduce((sum, result) => sum + result.points, 0);
const maxPoints = hotspots.reduce((sum, hotspot) => sum + hotspot.points, 0);
const requiredHotspots = hotspots.filter(h => h.isRequired);
const clickedRequired = requiredHotspots.filter(h => clickedHotspots.has(h.id));
const progress = requiredHotspots.length > 0 ? (clickedRequired.length / requiredHotspots.length) * 100 : 0;
return (
<div className={cn("bg-white rounded-lg shadow-lg overflow-hidden", className)}>
{/* Header */}
<div className="p-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white">
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-blue-100 text-sm mt-1">{description}</p>
{/* Instructions */}
<div className="mt-3 p-3 bg-white/10 rounded-lg">
<p className="text-sm text-blue-50">
📍 <strong>Instruksi:</strong> Klik pada area-area bermasalah dalam gambar.
Area yang wajib ditemukan: <span className="font-bold">{requiredHotspots.length}</span>
</p>
</div>
<div className="flex items-center justify-between mt-3">
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Target size={16} />
{clickedRequired.length}/{requiredHotspots.length} Wajib
</span>
<span className="flex items-center gap-1">
<Eye size={16} />
{clickedHotspots.size}/{hotspots.length} Total
</span>
<span className="flex items-center gap-1">
<Zap size={16} />
{totalPoints}/{maxPoints} Poin
</span>
</div>
<button
onClick={() => setHintsVisible(!hintsVisible)}
className={cn(
"px-3 py-1 rounded-full text-xs font-medium transition-colors",
hintsVisible
? "bg-yellow-500 text-white"
: "bg-white/20 text-white hover:bg-white/30"
)}
>
<Lightbulb size={14} className="inline mr-1" />
{hintsVisible ? 'Sembunyikan Hint' : 'Tampilkan Hint'}
</button>
</div>
</div>
{/* Progress Bar */}
<div className="px-4 py-2 bg-gray-50">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-gray-700">Progress Area Wajib</span>
<span className="text-xs text-gray-600">{Math.round(progress)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={cn(
"h-2 rounded-full transition-all duration-300",
progress === 100 ? "bg-green-500" : "bg-blue-500"
)}
style={{ width: `${progress}%` }}
/>
</div>
{progress === 100 && (
<p className="text-xs text-green-600 mt-1 font-medium">
Semua area wajib telah ditemukan!
</p>
)}
</div>
{/* Interactive Image */}
<div className="relative">
<img
ref={imageRef}
src={imageSrc}
alt={title}
className="w-full h-auto"
onLoad={() => setImageLoaded(true)}
/>
{/* Hotspots Overlay */}
{imageLoaded && (
<div className="absolute inset-0">
{hotspots.map((hotspot) => (
<button
key={hotspot.id}
className={cn(
"absolute border-2 rounded-lg transition-all duration-200 cursor-pointer",
"flex items-center justify-center group hover:scale-105",
getHotspotColor(hotspot),
clickedHotspots.has(hotspot.id) && "animate-pulse"
)}
style={{
left: `${hotspot.x}%`,
top: `${hotspot.y}%`,
width: `${hotspot.width}%`,
height: `${hotspot.height}%`,
}}
onClick={(e) => handleHotspotClick(hotspot, e)}
title={hintsVisible ? hotspot.hint || hotspot.title : 'Klik untuk memeriksa area ini'}
>
{clickedHotspots.has(hotspot.id) && getHotspotIcon(hotspot.type)}
{/* Hotspot Number */}
<span className={cn(
"absolute -top-2 -right-2 w-6 h-6 rounded-full text-xs font-bold",
"flex items-center justify-center shadow-lg",
clickedHotspots.has(hotspot.id)
? hotspot.type === 'correct'
? "bg-green-500 text-white"
: "bg-red-500 text-white"
: hotspot.isRequired
? "bg-orange-500 text-white animate-bounce"
: "bg-blue-500 text-white"
)}>
{hotspots.indexOf(hotspot) + 1}
</span>
{/* Required indicator */}
{hotspot.isRequired && !clickedHotspots.has(hotspot.id) && (
<span className="absolute -top-1 -left-1 w-3 h-3 bg-red-500 rounded-full animate-ping" />
)}
{/* Hover tooltip */}
{!clickedHotspots.has(hotspot.id) && (
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{hotspot.isRequired ? '⚠️ Area Wajib' : '💡 Area Opsional'}
</div>
)}
</button>
))}
</div>
)}
{/* Feedback Overlay */}
{showFeedback && currentFeedback && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg max-w-md w-full p-6">
<div className="flex items-center gap-3 mb-4">
{getHotspotIcon(currentFeedback.type)}
<h4 className="text-lg font-semibold text-gray-800">
{currentFeedback.title}
</h4>
</div>
<p className="text-gray-700 mb-4">{currentFeedback.feedback}</p>
{currentFeedback.consequence && (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-4">
<p className="text-sm text-yellow-800">
<strong>Konsekuensi:</strong> {currentFeedback.consequence}
</p>
</div>
)}
<div className="flex items-center justify-between">
<span className={cn(
"px-3 py-1 rounded-full text-sm font-medium",
currentFeedback.type === 'correct'
? "bg-green-100 text-green-800"
: currentFeedback.type === 'incorrect'
? "bg-red-100 text-red-800"
: "bg-yellow-100 text-yellow-800"
)}>
{currentFeedback.type === 'correct' ? '+' : ''}{currentFeedback.points} poin
</span>
<button
onClick={() => {
setShowFeedback(null);
setCurrentFeedback(null);
}}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Lanjutkan
</button>
</div>
</div>
</div>
)}
</div>
{/* Summary */}
<div className="p-4 bg-gray-50">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Area Ditemukan:</span>
<span className="ml-2 font-semibold text-gray-800">
{clickedHotspots.size}/{hotspots.length}
</span>
</div>
<div>
<span className="text-gray-600">Total Poin:</span>
<span className="ml-2 font-semibold text-green-600">
{totalPoints}/{maxPoints}
</span>
</div>
</div>
</div>
</div>
);
};
export default InteractiveImageHotspot;