FleetClusterLayer.tsx 2357줄 → 10개 파일 분리: - fleetClusterTypes/Utils/Constants: 타입, 기하 함수, 모델 상수 - useFleetClusterGeoJson: 27개 useMemo GeoJSON 훅 - FleetClusterMapLayers: MapLibre Source/Layer JSX - CorrelationPanel/HistoryReplayController: 패널 서브컴포넌트 - GearGroupSection/FleetGearListPanel: 좌측 목록 (DRY) - FleetClusterLayer: 오케스트레이터 524줄 deck.gl + Zustand 리플레이 기반 (Phase 0~2): - zustand 5.0.12, @deck.gl/geo-layers 9.2.11 설치 - gearReplayStore: Zustand + rAF 애니메이션 루프 - gearReplayPreprocess: TripsLayer 전처리 + cursor O(1) 보간 - useGearReplayLayers: deck.gl 레이어 빌더 (10fps 스로틀) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
212 lines
7.3 KiB
TypeScript
212 lines
7.3 KiB
TypeScript
import type { GroupPolygonDto } from '../../services/vesselAnalysis';
|
|
import { FONT_MONO } from '../../styles/fonts';
|
|
import { headerStyle, toggleButtonStyle } from './fleetClusterConstants';
|
|
|
|
interface GearGroupSectionProps {
|
|
groups: GroupPolygonDto[];
|
|
sectionKey: string;
|
|
sectionLabel: string;
|
|
accentColor: string;
|
|
hoverBgColor: string;
|
|
isActive: boolean;
|
|
expandedGroup: string | null;
|
|
onToggleSection: () => void;
|
|
onToggleGroup: (name: string) => void;
|
|
onGroupZoom: (name: string) => void;
|
|
onShipSelect: (mmsi: string) => void;
|
|
}
|
|
|
|
const GearGroupSection = ({
|
|
groups,
|
|
sectionKey,
|
|
sectionLabel,
|
|
accentColor,
|
|
hoverBgColor,
|
|
isActive,
|
|
expandedGroup,
|
|
onToggleSection,
|
|
onToggleGroup,
|
|
onGroupZoom,
|
|
onShipSelect,
|
|
}: GearGroupSectionProps) => {
|
|
const isInZoneSection = sectionKey === 'inZone';
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
style={{
|
|
...headerStyle,
|
|
borderTop: `1px solid ${accentColor}40`,
|
|
cursor: 'pointer',
|
|
}}
|
|
onClick={onToggleSection}
|
|
>
|
|
<span style={{ fontWeight: 700, color: accentColor, letterSpacing: 0.3, fontFamily: FONT_MONO }}>
|
|
{sectionLabel} ({groups.length}개)
|
|
</span>
|
|
<button
|
|
type="button"
|
|
style={toggleButtonStyle}
|
|
aria-label={`${sectionLabel} 접기/펴기`}
|
|
>
|
|
{isActive ? '▲' : '▼'}
|
|
</button>
|
|
</div>
|
|
|
|
{isActive && (
|
|
<div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}>
|
|
{groups.map(g => {
|
|
const name = g.groupKey;
|
|
const isOpen = expandedGroup === name;
|
|
const parentMember = g.members.find(m => m.isParent);
|
|
const gearMembers = g.members.filter(m => !m.isParent);
|
|
const zoneName = g.zoneName ?? '';
|
|
|
|
return (
|
|
<div key={name} id={`gear-row-${name}`}>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
padding: '3px 10px',
|
|
cursor: 'pointer',
|
|
borderLeft: isOpen ? `2px solid ${accentColor}` : '2px solid transparent',
|
|
transition: 'background-color 0.1s',
|
|
fontFamily: FONT_MONO,
|
|
}}
|
|
onMouseEnter={e => {
|
|
(e.currentTarget as HTMLDivElement).style.backgroundColor = hoverBgColor;
|
|
}}
|
|
onMouseLeave={e => {
|
|
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent';
|
|
}}
|
|
>
|
|
<span
|
|
onClick={() => onToggleGroup(name)}
|
|
style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}
|
|
>
|
|
{isOpen ? '▾' : '▸'}
|
|
</span>
|
|
<span style={{
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: '50%',
|
|
backgroundColor: accentColor,
|
|
flexShrink: 0,
|
|
}} />
|
|
<span
|
|
onClick={() => onToggleGroup(name)}
|
|
style={{
|
|
flex: 1,
|
|
color: '#e2e8f0',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
cursor: 'pointer',
|
|
}}
|
|
title={isInZoneSection ? `${name} — ${zoneName}` : name}
|
|
>
|
|
{name}
|
|
</span>
|
|
{parentMember && (
|
|
<span
|
|
style={{ color: '#fbbf24', fontSize: 8, flexShrink: 0 }}
|
|
title={`모선: ${parentMember.name}`}
|
|
>
|
|
⚓
|
|
</span>
|
|
)}
|
|
{isInZoneSection && zoneName && (
|
|
<span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zoneName}</span>
|
|
)}
|
|
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
|
|
({gearMembers.length}{isInZoneSection ? '' : '개'})
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
onGroupZoom(name);
|
|
}}
|
|
style={{
|
|
background: 'none',
|
|
border: `1px solid ${accentColor}80`,
|
|
borderRadius: 3,
|
|
color: accentColor,
|
|
fontSize: 9,
|
|
cursor: 'pointer',
|
|
padding: '1px 4px',
|
|
flexShrink: 0,
|
|
}}
|
|
title="이 어구 그룹으로 지도 이동"
|
|
>
|
|
zoom
|
|
</button>
|
|
</div>
|
|
|
|
{isOpen && (
|
|
<div style={{
|
|
paddingLeft: 24,
|
|
paddingRight: 10,
|
|
paddingBottom: 4,
|
|
fontSize: 9,
|
|
color: '#94a3b8',
|
|
borderLeft: `2px solid ${accentColor}40`,
|
|
marginLeft: 10,
|
|
fontFamily: FONT_MONO,
|
|
}}>
|
|
{parentMember && (
|
|
<div style={{ color: '#fbbf24', marginBottom: 2 }}>
|
|
모선: {parentMember.name || parentMember.mmsi}
|
|
</div>
|
|
)}
|
|
<div style={{ color: '#64748b', marginBottom: 2 }}>어구 목록:</div>
|
|
{gearMembers.map(m => (
|
|
<div key={m.mmsi} style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
marginBottom: 1,
|
|
}}>
|
|
<span style={{
|
|
flex: 1,
|
|
color: '#475569',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}>
|
|
{m.name || m.mmsi}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => onShipSelect(m.mmsi)}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
color: accentColor,
|
|
fontSize: 10,
|
|
cursor: 'pointer',
|
|
padding: '0 2px',
|
|
flexShrink: 0,
|
|
}}
|
|
title="어구 위치로 이동"
|
|
aria-label={`${m.name || m.mmsi} 위치로 이동`}
|
|
>
|
|
▶
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default GearGroupSection;
|