forked from hazem/yslootahrobotics
195 lines
5.3 KiB
TypeScript
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}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
} |