2025-12-03 07:13:31 +00:00
|
|
|
import { useState, useEffect } from 'react';
|
2025-11-30 15:04:04 +00:00
|
|
|
import { ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Search, Globe, Rocket, Moon, Asterisk, Sparkles } from 'lucide-react';
|
2025-11-27 05:16:19 +00:00
|
|
|
import type { CelestialBody } from '../types';
|
|
|
|
|
|
|
|
|
|
interface ProbeListProps {
|
|
|
|
|
probes: CelestialBody[];
|
|
|
|
|
planets: CelestialBody[];
|
|
|
|
|
onBodySelect: (body: CelestialBody | null) => void;
|
|
|
|
|
selectedBody: CelestialBody | null;
|
2025-11-30 02:43:47 +00:00
|
|
|
onResetCamera: () => void;
|
2025-11-27 05:16:19 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-30 02:43:47 +00:00
|
|
|
export function ProbeList({ probes, planets, onBodySelect, selectedBody, onResetCamera }: ProbeListProps) {
|
2025-11-30 15:04:04 +00:00
|
|
|
const [isCollapsed, setIsCollapsed] = useState(true); // 默认关闭
|
2025-11-29 16:58:58 +00:00
|
|
|
const [searchTerm, setSearchTerm] = useState('');
|
2025-11-30 05:25:41 +00:00
|
|
|
const [expandedGroup, setExpandedGroup] = useState<string | null>(null); // 只允许一个分组展开
|
2025-11-27 05:16:19 +00:00
|
|
|
|
2025-12-03 07:13:31 +00:00
|
|
|
// Auto-collapse when a body is selected (focus mode)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (selectedBody) {
|
|
|
|
|
setIsCollapsed(true);
|
|
|
|
|
}
|
|
|
|
|
}, [selectedBody]);
|
|
|
|
|
|
2025-11-29 16:58:58 +00:00
|
|
|
// Calculate distance for sorting
|
|
|
|
|
const calculateDistance = (body: CelestialBody) => {
|
|
|
|
|
const pos = body.positions[0];
|
|
|
|
|
return Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2);
|
|
|
|
|
};
|
2025-11-27 05:16:19 +00:00
|
|
|
|
2025-11-29 16:58:58 +00:00
|
|
|
const processBodies = (list: CelestialBody[]) => {
|
|
|
|
|
return list
|
2025-11-30 05:25:41 +00:00
|
|
|
.filter(b =>
|
2025-11-29 16:58:58 +00:00
|
|
|
(b.name_zh || b.name).toLowerCase().includes(searchTerm.toLowerCase()) &&
|
|
|
|
|
b.type !== 'star' // Exclude Sun from list
|
|
|
|
|
)
|
|
|
|
|
.map(body => ({
|
|
|
|
|
body,
|
|
|
|
|
distance: calculateDistance(body)
|
|
|
|
|
}))
|
|
|
|
|
.sort((a, b) => a.distance - b.distance);
|
|
|
|
|
};
|
2025-11-27 05:16:19 +00:00
|
|
|
|
2025-11-30 05:25:41 +00:00
|
|
|
// Group bodies by type
|
|
|
|
|
const allBodies = [...planets, ...probes];
|
|
|
|
|
const processedBodies = processBodies(allBodies);
|
|
|
|
|
|
|
|
|
|
const planetList = processedBodies.filter(({ body }) => body.type === 'planet');
|
|
|
|
|
const dwarfPlanetList = processedBodies.filter(({ body }) => body.type === 'dwarf_planet');
|
|
|
|
|
const satelliteList = processedBodies.filter(({ body }) => body.type === 'satellite');
|
|
|
|
|
const probeList = processedBodies.filter(({ body }) => body.type === 'probe');
|
2025-11-30 15:04:04 +00:00
|
|
|
const cometList = processedBodies.filter(({ body }) => body.type === 'comet');
|
2025-11-30 05:25:41 +00:00
|
|
|
|
|
|
|
|
const toggleGroup = (groupName: string) => {
|
|
|
|
|
// 如果点击的是当前展开的分组,则收起;否则切换到新分组
|
|
|
|
|
setExpandedGroup(prev => prev === groupName ? null : groupName);
|
|
|
|
|
};
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
|
|
|
return (
|
2025-11-29 16:58:58 +00:00
|
|
|
<div
|
|
|
|
|
className={`
|
|
|
|
|
absolute top-24 left-4 bottom-8 z-40
|
|
|
|
|
transition-all duration-300 ease-in-out flex
|
|
|
|
|
${isCollapsed ? 'w-12' : 'w-64'} // Adjusted width
|
|
|
|
|
`}
|
|
|
|
|
>
|
|
|
|
|
{/* Main Content Panel */}
|
|
|
|
|
<div className={`
|
|
|
|
|
flex-1 bg-black/80 backdrop-blur-md border border-white/10 rounded-2xl overflow-hidden flex flex-col
|
|
|
|
|
transition-opacity duration-300
|
|
|
|
|
${isCollapsed ? 'opacity-0 pointer-events-none' : 'opacity-100'}
|
|
|
|
|
`}>
|
|
|
|
|
{/* Header & Search */}
|
|
|
|
|
<div className="p-4 border-b border-white/10 space-y-3">
|
|
|
|
|
<div className="flex items-center justify-between text-white">
|
2025-11-30 02:43:47 +00:00
|
|
|
<h2 className="font-bold text-base tracking-wide">天体导航</h2>
|
2025-11-29 16:58:58 +00:00
|
|
|
<button
|
2025-11-30 02:43:47 +00:00
|
|
|
onClick={() => {
|
|
|
|
|
onBodySelect(null);
|
|
|
|
|
onResetCamera();
|
|
|
|
|
}}
|
2025-11-29 16:58:58 +00:00
|
|
|
className="text-xs bg-white/10 hover:bg-white/20 px-2 py-1 rounded transition-colors text-gray-300"
|
|
|
|
|
>
|
|
|
|
|
重置视角
|
|
|
|
|
</button>
|
2025-11-27 05:16:19 +00:00
|
|
|
</div>
|
2025-11-29 16:58:58 +00:00
|
|
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={14} />
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="搜索天体..."
|
|
|
|
|
value={searchTerm}
|
|
|
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
2025-12-03 05:32:57 +00:00
|
|
|
className="w-full bg-white/5 border border-white/10 rounded-lg pl-9 pr-3 py-2 text-xs text-white placeholder-gray-500 focus:outline-none focus:border-[#238636]/50 transition-colors"
|
2025-11-29 16:58:58 +00:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-11-27 05:16:19 +00:00
|
|
|
|
2025-11-29 16:58:58 +00:00
|
|
|
{/* List Content */}
|
2025-11-30 05:25:41 +00:00
|
|
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-2">
|
2025-11-29 16:58:58 +00:00
|
|
|
{/* Planets Group */}
|
2025-11-30 05:25:41 +00:00
|
|
|
{planetList.length > 0 && (
|
|
|
|
|
<BodyGroup
|
|
|
|
|
title="行星"
|
|
|
|
|
icon={<Globe size={12} />}
|
|
|
|
|
count={planetList.length}
|
|
|
|
|
bodies={planetList}
|
|
|
|
|
isExpanded={expandedGroup === 'planet'}
|
|
|
|
|
onToggle={() => toggleGroup('planet')}
|
|
|
|
|
selectedBody={selectedBody}
|
|
|
|
|
onBodySelect={onBodySelect}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Dwarf Planets Group */}
|
|
|
|
|
{dwarfPlanetList.length > 0 && (
|
|
|
|
|
<BodyGroup
|
|
|
|
|
title="矮行星"
|
|
|
|
|
icon={<Asterisk size={12} />}
|
|
|
|
|
count={dwarfPlanetList.length}
|
|
|
|
|
bodies={dwarfPlanetList}
|
|
|
|
|
isExpanded={expandedGroup === 'dwarf_planet'}
|
|
|
|
|
onToggle={() => toggleGroup('dwarf_planet')}
|
|
|
|
|
selectedBody={selectedBody}
|
|
|
|
|
onBodySelect={onBodySelect}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Satellites Group */}
|
|
|
|
|
{satelliteList.length > 0 && (
|
|
|
|
|
<BodyGroup
|
|
|
|
|
title="卫星"
|
|
|
|
|
icon={<Moon size={12} />}
|
|
|
|
|
count={satelliteList.length}
|
|
|
|
|
bodies={satelliteList}
|
|
|
|
|
isExpanded={expandedGroup === 'satellite'}
|
|
|
|
|
onToggle={() => toggleGroup('satellite')}
|
|
|
|
|
selectedBody={selectedBody}
|
|
|
|
|
onBodySelect={onBodySelect}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-11-29 15:10:00 +00:00
|
|
|
|
2025-11-29 16:58:58 +00:00
|
|
|
{/* Probes Group */}
|
2025-11-30 05:25:41 +00:00
|
|
|
{probeList.length > 0 && (
|
|
|
|
|
<BodyGroup
|
|
|
|
|
title="探测器"
|
|
|
|
|
icon={<Rocket size={12} />}
|
|
|
|
|
count={probeList.length}
|
|
|
|
|
bodies={probeList}
|
|
|
|
|
isExpanded={expandedGroup === 'probe'}
|
|
|
|
|
onToggle={() => toggleGroup('probe')}
|
|
|
|
|
selectedBody={selectedBody}
|
|
|
|
|
onBodySelect={onBodySelect}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-11-30 15:04:04 +00:00
|
|
|
{/* Comets Group */}
|
|
|
|
|
{cometList.length > 0 && (
|
|
|
|
|
<BodyGroup
|
|
|
|
|
title="彗星"
|
|
|
|
|
icon={<Sparkles size={12} />}
|
|
|
|
|
count={cometList.length}
|
|
|
|
|
bodies={cometList}
|
|
|
|
|
isExpanded={expandedGroup === 'comet'}
|
|
|
|
|
onToggle={() => toggleGroup('comet')}
|
|
|
|
|
selectedBody={selectedBody}
|
|
|
|
|
onBodySelect={onBodySelect}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-11-30 05:25:41 +00:00
|
|
|
{/* No results message */}
|
|
|
|
|
{processedBodies.length === 0 && (
|
|
|
|
|
<div className="text-center py-8 text-gray-500 text-xs">
|
|
|
|
|
未找到匹配的天体
|
2025-11-29 16:58:58 +00:00
|
|
|
</div>
|
2025-11-30 05:25:41 +00:00
|
|
|
)}
|
2025-11-29 16:58:58 +00:00
|
|
|
</div>
|
2025-11-27 05:16:19 +00:00
|
|
|
</div>
|
|
|
|
|
|
2025-11-29 16:58:58 +00:00
|
|
|
{/* Toggle Button (Attached to the side) */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
|
|
|
className={`
|
2025-11-30 05:25:41 +00:00
|
|
|
absolute top-0 ${isCollapsed ? 'left-0' : '-right-4'}
|
2025-11-29 16:58:58 +00:00
|
|
|
w-8 h-8 flex items-center justify-center
|
|
|
|
|
bg-black/80 backdrop-blur-md border border-white/10 rounded-full
|
2025-12-03 05:32:57 +00:00
|
|
|
text-white hover:bg-[#238636] transition-all shadow-lg z-50
|
2025-11-29 16:58:58 +00:00
|
|
|
${!isCollapsed && 'translate-x-1/2'}
|
|
|
|
|
`}
|
|
|
|
|
style={{ top: '20px' }}
|
|
|
|
|
>
|
|
|
|
|
{isCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
|
|
|
|
</button>
|
2025-11-27 05:16:19 +00:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-11-29 16:58:58 +00:00
|
|
|
|
2025-11-30 05:25:41 +00:00
|
|
|
// Group component for collapsible body lists
|
|
|
|
|
function BodyGroup({
|
|
|
|
|
title,
|
|
|
|
|
icon,
|
|
|
|
|
count,
|
|
|
|
|
bodies,
|
|
|
|
|
isExpanded,
|
|
|
|
|
onToggle,
|
|
|
|
|
selectedBody,
|
|
|
|
|
onBodySelect
|
|
|
|
|
}: {
|
|
|
|
|
title: string;
|
|
|
|
|
icon: React.ReactNode;
|
|
|
|
|
count: number;
|
|
|
|
|
bodies: Array<{ body: CelestialBody; distance: number }>;
|
|
|
|
|
isExpanded: boolean;
|
|
|
|
|
onToggle: () => void;
|
|
|
|
|
selectedBody: CelestialBody | null;
|
|
|
|
|
onBodySelect: (body: CelestialBody) => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="border border-white/5 rounded-lg overflow-hidden bg-white/5">
|
|
|
|
|
{/* Group Header */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={onToggle}
|
|
|
|
|
className="w-full px-2 py-2 flex items-center justify-between hover:bg-white/5 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-2 text-[10px] font-bold text-gray-300 uppercase tracking-wider">
|
|
|
|
|
{icon}
|
|
|
|
|
{title}
|
|
|
|
|
<span className="text-gray-500">({count})</span>
|
|
|
|
|
</div>
|
|
|
|
|
{isExpanded ? <ChevronUp size={14} className="text-gray-400" /> : <ChevronDown size={14} className="text-gray-400" />}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Group Content */}
|
|
|
|
|
{isExpanded && (
|
|
|
|
|
<div className="px-1 pb-1 space-y-1">
|
|
|
|
|
{bodies.map(({ body, distance }) => (
|
|
|
|
|
<BodyItem
|
|
|
|
|
key={body.id}
|
|
|
|
|
body={body}
|
|
|
|
|
distance={distance}
|
|
|
|
|
isSelected={selectedBody?.id === body.id}
|
|
|
|
|
onClick={() => onBodySelect(body)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 16:58:58 +00:00
|
|
|
function BodyItem({ body, distance, isSelected, onClick }: {
|
|
|
|
|
body: CelestialBody,
|
|
|
|
|
distance: number,
|
|
|
|
|
isSelected: boolean,
|
|
|
|
|
onClick: () => void
|
|
|
|
|
}) {
|
|
|
|
|
const isInactive = body.is_active === false;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
onClick={isInactive ? undefined : onClick}
|
|
|
|
|
disabled={isInactive}
|
|
|
|
|
className={`
|
|
|
|
|
w-full flex items-center justify-between p-2 rounded-lg text-left transition-all duration-200 group
|
2025-12-03 05:32:57 +00:00
|
|
|
${isSelected
|
|
|
|
|
? 'bg-[#238636]/20 border border-[#238636]/50 shadow-[0_0_15px_rgba(35,134,54,0.2)]'
|
|
|
|
|
: isInactive
|
|
|
|
|
? 'opacity-40 cursor-not-allowed'
|
2025-11-29 16:58:58 +00:00
|
|
|
: 'hover:bg-white/10 border border-transparent'
|
|
|
|
|
}
|
|
|
|
|
`}
|
|
|
|
|
>
|
|
|
|
|
<div>
|
2025-12-03 05:32:57 +00:00
|
|
|
<div className={`text-xs font-medium ${isSelected ? 'text-[#4ade80]' : 'text-gray-200 group-hover:text-white'}`}> {/* text-sm -> text-xs */}
|
2025-11-29 16:58:58 +00:00
|
|
|
{body.name_zh || body.name}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-[9px] text-gray-500 font-mono"> {/* text-[10px] -> text-[9px] */}
|
|
|
|
|
{distance.toFixed(2)} AU
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{isSelected && (
|
2025-12-03 05:32:57 +00:00
|
|
|
<div className="w-1.5 h-1.5 rounded-full bg-[#4ade80] shadow-[0_0_8px_rgba(74,222,128,0.8)] animate-pulse" />
|
2025-11-29 16:58:58 +00:00
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|