yslootahrobotics/src/components/InteractiveHotspot.tsx
2026-04-10 15:31:59 +04:00

195 lines
5.3 KiB
TypeScript

'use client';
import { useRef, useState, useCallback } from 'react';
import { Html } from '@react-three/drei';
import { useFrame, ThreeEvent } from '@react-three/fiber';
import * as THREE from 'three';
import { SocketName, getSocketTransform } from './SocketPoints';
interface InteractiveHotspotProps {
socketName: SocketName;
label: string;
icon?: string;
onClick?: (socketName: SocketName) => void;
visible?: boolean;
}
export function InteractiveHotspot({
socketName,
label,
icon = '+',
onClick,
visible = true,
}: InteractiveHotspotProps) {
const meshRef = useRef<THREE.Mesh>(null);
const ringRef = useRef<THREE.Mesh>(null);
const [hovered, setHovered] = useState(false);
const [showTooltip, setShowTooltip] = useState(false);
const transform = getSocketTransform(socketName);
// Pulsing animation for the hotspot
useFrame((state) => {
if (meshRef.current) {
const scale = 1 + Math.sin(state.clock.elapsedTime * 3) * 0.1;
meshRef.current.scale.setScalar(hovered ? 1.3 : scale);
}
if (ringRef.current) {
const ringScale = 1 + Math.sin(state.clock.elapsedTime * 2) * 0.15;
ringRef.current.scale.setScalar(ringScale);
const opacity = 0.3 + Math.sin(state.clock.elapsedTime * 2) * 0.2;
if (ringRef.current.material instanceof THREE.MeshBasicMaterial) {
ringRef.current.material.opacity = opacity;
}
}
});
const handleClick = useCallback(
(e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
onClick?.(socketName);
},
[onClick, socketName]
);
const handlePointerEnter = useCallback(() => {
setHovered(true);
setShowTooltip(true);
document.body.style.cursor = 'pointer';
}, []);
const handlePointerLeave = useCallback(() => {
setHovered(false);
setShowTooltip(false);
document.body.style.cursor = 'auto';
}, []);
return (
<group position={transform.position}>
{/* 3D pulsing sphere */}
<mesh
ref={meshRef}
onClick={handleClick}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<sphereGeometry args={[0.025, 16, 16]} />
<meshStandardMaterial
color={hovered ? '#3b82f6' : '#f59e0b'}
emissive={hovered ? '#3b82f6' : '#f59e0b'}
emissiveIntensity={hovered ? 0.8 : 0.4}
transparent
opacity={0.9}
/>
</mesh>
{/* Outer ring */}
<mesh ref={ringRef} rotation={[0, 0, 0]}>
<ringGeometry args={[0.03, 0.04, 32]} />
<meshBasicMaterial
color="#f59e0b"
transparent
opacity={0.5}
side={THREE.DoubleSide}
/>
</mesh>
{/* HTML label */}
<Html
position={[0, 0.06, 0]}
center
distanceFactor={8}
style={{
transition: 'all 0.2s',
opacity: 1,
pointerEvents: 'none',
}}
>
{visible && showTooltip && (
<div
style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
padding: '8px 12px',
whiteSpace: 'nowrap',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSize: '12px',
color: '#1a1a2e',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
animation: 'fadeIn 0.15s ease-out',
}}
>
<div style={{ fontWeight: 600, marginBottom: 2 }}>{label}</div>
<div
style={{
fontSize: '10px',
color: '#94a3b8',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<span
style={{
background: '#3b82f6',
color: 'white',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '10px',
}}
>
{icon}
</span>
Click to customize
</div>
</div>
)}
</Html>
</group>
);
}
// Hotspot panel component to manage visibility of multiple hotspots
interface HotspotPanelProps {
onSocketClick?: (socketName: SocketName) => void;
visibleSockets?: SocketName[];
}
export function HotspotPanel({ onSocketClick, visibleSockets }: HotspotPanelProps) {
const defaultSockets: SocketName[] = ['head', 'chest', 'left_shoulder', 'right_shoulder', 'back', 'base'];
const socketsToShow = visibleSockets || defaultSockets;
const socketLabels: Record<SocketName, string> = {
head: 'Head Mount',
chest: 'Chest Plate',
left_shoulder: 'Left Shoulder',
right_shoulder: 'Right Shoulder',
back: 'Back Pack',
base: 'Base Unit',
};
const socketIcons: Record<SocketName, string> = {
head: 'H',
chest: 'C',
left_shoulder: 'L',
right_shoulder: 'R',
back: 'B',
base: 'U',
};
return (
<>
{socketsToShow.map((socket) => (
<InteractiveHotspot
key={socket}
socketName={socket}
label={socketLabels[socket]}
icon={socketIcons[socket]}
onClick={onSocketClick}
/>
))}
</>
);
}