kcg-monitoring/frontend/src/components/korea/GearGroupSection.tsx
htlee bbbc326e38 refactor: FleetClusterLayer 10파일 분리 + deck.gl 리플레이 기반 구축
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>
2026-03-31 07:44:07 +09:00

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;