cosmo/frontend/src/components/ProbeList.tsx

336 lines
12 KiB
TypeScript

import { useState, useEffect, useMemo } from 'react';
import { ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Search, Globe, Rocket, Moon, Asterisk, Sparkles, Star, X } from 'lucide-react';
import type { CelestialBody } from '../types';
interface ProbeListProps {
probes: CelestialBody[];
planets: CelestialBody[];
onBodySelect: (body: CelestialBody | null) => void;
selectedBody: CelestialBody | null;
onResetCamera: () => void;
}
export function ProbeList({ probes, planets, onBodySelect, selectedBody, onResetCamera }: ProbeListProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [expandedGroup, setExpandedGroup] = useState<string | null>('planet'); // Default expand planets
// Auto-collapse when a body is selected (focus mode)
useEffect(() => {
if (selectedBody) {
setIsCollapsed(true);
}
}, [selectedBody]);
// Calculate distance for sorting
const calculateDistance = (body: CelestialBody) => {
if (!body.positions || body.positions.length === 0) {
return Infinity; // Bodies without positions go to the end
}
const pos = body.positions[0];
return Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2);
};
// Process and sort bodies
const allBodies = useMemo(() => {
const list = [...planets, ...probes];
return list
.filter(b => {
// Filter out bodies without positions
if (!b.positions || b.positions.length === 0) {
return false;
}
// Filter by search term - include all types including stars
if (!searchTerm) return true;
return (b.name_zh || b.name).toLowerCase().includes(searchTerm.toLowerCase());
})
.map(body => ({
body,
distance: calculateDistance(body)
}))
.sort((a, b) => a.distance - b.distance);
}, [planets, probes, searchTerm]);
// Group bodies by type
const groups = useMemo(() => {
const starList = allBodies.filter(({ body }) => body.type === 'star');
const planetList = allBodies.filter(({ body }) => body.type === 'planet');
const dwarfPlanetList = allBodies.filter(({ body }) => body.type === 'dwarf_planet');
const satelliteList = allBodies.filter(({ body }) => body.type === 'satellite');
const probeList = allBodies.filter(({ body }) => body.type === 'probe');
const cometList = allBodies.filter(({ body }) => body.type === 'comet');
return {
star: starList,
planet: planetList,
dwarf_planet: dwarfPlanetList,
satellite: satelliteList,
probe: probeList,
comet: cometList
};
}, [allBodies]);
const toggleGroup = (groupName: string) => {
setExpandedGroup(prev => prev === groupName ? null : groupName);
};
return (
<div
className={`
absolute top-24 left-6 bottom-8 z-40
transition-all duration-300 ease-in-out flex flex-col
${isCollapsed ? 'w-12 h-12' : 'w-72 max-h-[calc(100vh-120px)]'}
`}
>
{/* Toggle Button (Attached to the side or floating when collapsed) */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className={`
absolute top-0 z-50 flex items-center justify-center
w-8 h-8 rounded-full
bg-black/80 backdrop-blur-md border border-white/10
text-white hover:bg-[#238636] transition-all shadow-lg
${isCollapsed ? 'left-0' : 'right-2 top-3'}
`}
title={isCollapsed ? "展开列表" : "收起列表"}
>
{isCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</button>
{/* Main Content Panel */}
<div className={`
flex-1 bg-black/80 backdrop-blur-md border border-white/10 rounded-xl overflow-hidden flex flex-col shadow-2xl
transition-all duration-300
${isCollapsed ? 'opacity-0 pointer-events-none scale-95' : 'opacity-100 scale-100'}
`}>
{/* Header & Search */}
<div className="p-4 border-b border-white/10 space-y-3">
<div className="flex items-center justify-between text-white pr-8">
<h2 className="font-bold text-base tracking-wide flex items-center gap-2">
<span className="w-1 h-4 bg-[#238636] rounded-full"></span>
</h2>
<button
onClick={() => {
onBodySelect(null);
onResetCamera();
}}
className="text-[10px] bg-white/5 hover:bg-white/10 px-2 py-1 rounded text-gray-400 hover:text-white transition-colors border border-white/5"
>
</button>
</div>
<div className="relative group">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 group-focus-within:text-[#238636] transition-colors" size={14} />
<input
type="text"
placeholder="搜索天体..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (e.target.value && !expandedGroup) setExpandedGroup('planet');
}}
className="w-full bg-black/40 border border-white/10 rounded-lg pl-9 pr-8 py-2.5 text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-[#238636] focus:shadow-[0_0_15px_rgba(35,134,54,0.2)] transition-all duration-300 backdrop-blur-sm"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white cursor-pointer transition-colors p-0.5 rounded-full hover:bg-white/10"
>
<X size={14} />
</button>
)}
</div>
</div>
{/* List Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-2">
{/* Stars Group */}
{groups.star.length > 0 && (
<BodyGroup
title="恒星"
icon={<Star size={14} />}
count={groups.star.length}
bodies={groups.star}
isExpanded={expandedGroup === 'star'}
onToggle={() => toggleGroup('star')}
selectedBody={selectedBody}
onBodySelect={onBodySelect}
/>
)}
{/* Planets Group */}
{groups.planet.length > 0 && (
<BodyGroup
title="行星"
icon={<Globe size={14} />}
count={groups.planet.length}
bodies={groups.planet}
isExpanded={expandedGroup === 'planet'}
onToggle={() => toggleGroup('planet')}
selectedBody={selectedBody}
onBodySelect={onBodySelect}
/>
)}
{/* Dwarf Planets Group */}
{groups.dwarf_planet.length > 0 && (
<BodyGroup
title="矮行星"
icon={<Asterisk size={14} />}
count={groups.dwarf_planet.length}
bodies={groups.dwarf_planet}
isExpanded={expandedGroup === 'dwarf_planet'}
onToggle={() => toggleGroup('dwarf_planet')}
selectedBody={selectedBody}
onBodySelect={onBodySelect}
/>
)}
{/* Satellites Group */}
{groups.satellite.length > 0 && (
<BodyGroup
title="卫星"
icon={<Moon size={14} />}
count={groups.satellite.length}
bodies={groups.satellite}
isExpanded={expandedGroup === 'satellite'}
onToggle={() => toggleGroup('satellite')}
selectedBody={selectedBody}
onBodySelect={onBodySelect}
/>
)}
{/* Probes Group */}
{groups.probe.length > 0 && (
<BodyGroup
title="探测器"
icon={<Rocket size={14} />}
count={groups.probe.length}
bodies={groups.probe}
isExpanded={expandedGroup === 'probe'}
onToggle={() => toggleGroup('probe')}
selectedBody={selectedBody}
onBodySelect={onBodySelect}
/>
)}
{/* Comets Group */}
{groups.comet.length > 0 && (
<BodyGroup
title="彗星"
icon={<Sparkles size={14} />}
count={groups.comet.length}
bodies={groups.comet}
isExpanded={expandedGroup === 'comet'}
onToggle={() => toggleGroup('comet')}
selectedBody={selectedBody}
onBodySelect={onBodySelect}
/>
)}
{/* No results message */}
{allBodies.length === 0 && (
<div className="text-center py-8 text-gray-500 text-xs">
</div>
)}
</div>
</div>
</div>
);
}
// 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-3 py-2.5 flex items-center justify-between hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2 text-xs font-bold text-gray-300 uppercase tracking-wider">
<span className="text-[#238636]">{icon}</span>
{title}
<span className="text-gray-500 text-[10px]">({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-0.5">
{bodies.map(({ body, distance }) => (
<BodyItem
key={body.id}
body={body}
distance={distance}
isSelected={selectedBody?.id === body.id}
onClick={() => onBodySelect(body)}
/>
))}
</div>
)}
</div>
);
}
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 px-3 py-2 rounded-md text-left transition-all duration-200 group relative overflow-hidden
${isSelected
? 'bg-[#238636]/20 text-white'
: isInactive
? 'opacity-40 cursor-not-allowed'
: 'hover:bg-white/5 text-gray-400 hover:text-gray-200'
}
`}
>
{isSelected && (
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-[#4ade80] shadow-[0_0_8px_rgba(74,222,128,0.8)]" />
)}
<div className="flex items-center gap-2 pl-1">
<div className={`text-xs font-medium ${isSelected ? 'text-[#4ade80]' : ''}`}>
{body.name_zh || body.name}
</div>
</div>
<div className={`text-[10px] font-mono ${isSelected ? 'text-[#4ade80]/70' : 'text-gray-600 group-hover:text-gray-500'}`}>
{distance.toFixed(2)} AU
</div>
</button>
);
}