358 lines
13 KiB
TypeScript
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; |