diff --git a/src/pages/ReplayPage.jsx b/src/pages/ReplayPage.jsx
new file mode 100644
index 00000000..f32ce75b
--- /dev/null
+++ b/src/pages/ReplayPage.jsx
@@ -0,0 +1,362 @@
+import { useState, useEffect, useCallback } from 'react';
+import './ReplayPage.scss';
+import { getReplayWebSocketService } from '../replay/services/ReplayWebSocketService';
+import useReplayStore from '../replay/stores/replayStore';
+import useMergedTrackStore from '../replay/stores/mergedTrackStore';
+import useAnimationStore from '../replay/stores/animationStore';
+import { ConnectionState, VesselState } from '../replay/types/replay.types';
+import VesselListManager from '../replay/components/VesselListManager';
+import ReplayControlV2 from '../replay/components/ReplayControlV2';
+import { TRACK_QUERY_MAX_DAYS, TRACK_QUERY_DEFAULT_DAYS } from '../types/constants';
+import { showToast } from '../components/common/Toast';
+
+/** 일 단위를 밀리초로 변환 */
+const DAYS_TO_MS = 24 * 60 * 60 * 1000;
+
+/** KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC이므로 사용하지 않음) */
+function toKstISOString(date) {
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
+}
+
+/**
+ * 리플레이 페이지
+ * 참조: mda-react-front/src/tracking/components/ReplayV2.tsx
+ */
+export default function ReplayPage({ isOpen, onToggle }) {
+ // 조회 기간
+ const [startDate, setStartDate] = useState('');
+ const [startTime, setStartTime] = useState('00:00');
+ const [endDate, setEndDate] = useState('');
+ const [endTime, setEndTime] = useState('23:59');
+
+ // 조회 중 상태
+ const [isQuerying, setIsQuerying] = useState(false);
+ const [errorMessage, setErrorMessage] = useState('');
+
+ // 스토어 구독
+ const connectionState = useReplayStore((s) => s.connectionState);
+ const queryCompleted = useReplayStore((s) => s.queryCompleted);
+ const progress = useReplayStore((s) => s.progress);
+ const highlightedVesselId = useReplayStore((s) => s.highlightedVesselId);
+ const deletedVesselIds = useReplayStore((s) => s.deletedVesselIds);
+ const selectedVesselIds = useReplayStore((s) => s.selectedVesselIds);
+
+ const setTimeRange = useAnimationStore((s) => s.setTimeRange);
+
+ /**
+ * 선박 상태 전환 핸들러
+ * DELETE: 일반/선택 → 삭제, 삭제 → 일반
+ * INSERT: 일반 → 선택, 삭제 → 선택, 선택 → 일반
+ */
+ const handleVesselStateTransition = useCallback(
+ (vesselId, action, isCurrentlyDeleted, isCurrentlySelected) => {
+ let targetState;
+
+ if (action === 'DELETE') {
+ if (isCurrentlyDeleted) {
+ targetState = VesselState.NORMAL;
+ } else {
+ targetState = VesselState.DELETED;
+ }
+ } else {
+ // action === 'INSERT'
+ if (isCurrentlyDeleted) {
+ targetState = VesselState.SELECTED;
+ } else if (isCurrentlySelected) {
+ targetState = VesselState.NORMAL;
+ } else {
+ targetState = VesselState.SELECTED;
+ }
+ }
+
+ useReplayStore.getState().setVesselState(vesselId, targetState);
+ },
+ [],
+ );
+
+ // 키보드 이벤트 리스너 (Delete/Insert 키로 항적 상태 변경)
+ useEffect(() => {
+ const handleKeyDown = (event) => {
+ if (!highlightedVesselId) return;
+
+ const { deletedVesselIds, selectedVesselIds } = useReplayStore.getState();
+ const isCurrentlyDeleted = deletedVesselIds.has(highlightedVesselId);
+ const isCurrentlySelected = selectedVesselIds.has(highlightedVesselId);
+
+ if (event.key === 'Delete') {
+ handleVesselStateTransition(highlightedVesselId, 'DELETE', isCurrentlyDeleted, isCurrentlySelected);
+ } else if (event.key === 'Insert') {
+ handleVesselStateTransition(highlightedVesselId, 'INSERT', isCurrentlyDeleted, isCurrentlySelected);
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [highlightedVesselId, handleVesselStateTransition]);
+
+ // 조회 기간 초기화 (시작: 기본조회기간 전, 종료: 현재)
+ useEffect(() => {
+ const now = new Date();
+ const defaultDaysAgo = new Date(now);
+ defaultDaysAgo.setDate(defaultDaysAgo.getDate() - TRACK_QUERY_DEFAULT_DAYS);
+
+ const pad = (n) => String(n).padStart(2, '0');
+ setStartDate(defaultDaysAgo.toISOString().split('T')[0]);
+ setStartTime('00:00');
+ setEndDate(now.toISOString().split('T')[0]);
+ setEndTime(`${pad(now.getHours())}:${pad(now.getMinutes())}`);
+ }, []);
+
+ // 시작일 변경 핸들러
+ const handleStartDateChange = useCallback((newStartDate) => {
+ setStartDate(newStartDate);
+
+ // 현재 종료일/시간과 비교
+ const start = new Date(`${newStartDate}T${startTime}:00`);
+ const end = new Date(`${endDate}T${endTime}:00`);
+ const diffDays = (end - start) / DAYS_TO_MS;
+ const pad = (n) => String(n).padStart(2, '0');
+
+ // 시작일이 종료일보다 뒤인 경우 → 종료일을 시작일 + 기본조회기간으로 조정
+ if (diffDays < 0) {
+ const adjustedEnd = new Date(start.getTime() + TRACK_QUERY_DEFAULT_DAYS * DAYS_TO_MS);
+ setEndDate(adjustedEnd.toISOString().split('T')[0]);
+ setEndTime(`${pad(adjustedEnd.getHours())}:${pad(adjustedEnd.getMinutes())}`);
+ showToast(`종료일이 시작일보다 앞서 기본 조회기간 ${TRACK_QUERY_DEFAULT_DAYS}일로 자동 설정됩니다.`);
+ }
+ // 최대 조회기간 초과 시 종료일 자동 조정
+ else if (diffDays > TRACK_QUERY_MAX_DAYS) {
+ const adjustedEnd = new Date(start.getTime() + TRACK_QUERY_MAX_DAYS * DAYS_TO_MS);
+ setEndDate(adjustedEnd.toISOString().split('T')[0]);
+ setEndTime(`${pad(adjustedEnd.getHours())}:${pad(adjustedEnd.getMinutes())}`);
+ showToast(`최대 조회기간 ${TRACK_QUERY_MAX_DAYS}일로 자동 설정됩니다.`);
+ }
+ }, [startTime, endDate, endTime]);
+
+ // 종료일 변경 핸들러
+ const handleEndDateChange = useCallback((newEndDate) => {
+ setEndDate(newEndDate);
+
+ // 현재 시작일/시간과 비교
+ const start = new Date(`${startDate}T${startTime}:00`);
+ const end = new Date(`${newEndDate}T${endTime}:00`);
+ const diffDays = (end - start) / DAYS_TO_MS;
+ const pad = (n) => String(n).padStart(2, '0');
+
+ // 종료일이 시작일보다 앞인 경우 → 시작일을 종료일 - 기본조회기간으로 조정
+ if (diffDays < 0) {
+ const adjustedStart = new Date(end.getTime() - TRACK_QUERY_DEFAULT_DAYS * DAYS_TO_MS);
+ setStartDate(adjustedStart.toISOString().split('T')[0]);
+ setStartTime(`${pad(adjustedStart.getHours())}:${pad(adjustedStart.getMinutes())}`);
+ showToast(`시작일이 종료일보다 뒤서 기본 조회기간 ${TRACK_QUERY_DEFAULT_DAYS}일로 자동 설정됩니다.`);
+ }
+ // 최대 조회기간 초과 시 시작일 자동 조정
+ else if (diffDays > TRACK_QUERY_MAX_DAYS) {
+ const adjustedStart = new Date(end.getTime() - TRACK_QUERY_MAX_DAYS * DAYS_TO_MS);
+ setStartDate(adjustedStart.toISOString().split('T')[0]);
+ setStartTime(`${pad(adjustedStart.getHours())}:${pad(adjustedStart.getMinutes())}`);
+ showToast(`최대 조회기간 ${TRACK_QUERY_MAX_DAYS}일로 자동 설정됩니다.`);
+ }
+ }, [startDate, startTime, endTime]);
+
+ // 쿼리 완료 감지
+ useEffect(() => {
+ if (queryCompleted) {
+ setIsQuerying(false);
+ }
+ }, [queryCompleted]);
+
+ // 연결 에러 감지
+ useEffect(() => {
+ if (connectionState === ConnectionState.ERROR && isQuerying) {
+ setIsQuerying(false);
+ setErrorMessage('연결 오류가 발생했습니다.');
+ }
+ }, [connectionState, isQuerying]);
+
+ // 컴포넌트 언마운트 시 정리
+ useEffect(() => {
+ return () => {
+ getReplayWebSocketService().disconnect();
+ };
+ }, []);
+
+ /**
+ * 조회 실행
+ */
+ const handleQuery = useCallback(async () => {
+ if (!startDate || !endDate) {
+ showToast('조회 기간을 입력해주세요.');
+ return;
+ }
+
+ // 시작/종료 시간 조합 (KST 기준)
+ const from = new Date(`${startDate}T${startTime}:00`);
+ const to = new Date(`${endDate}T${endTime}:00`);
+
+ if (isNaN(from.getTime()) || isNaN(to.getTime())) {
+ showToast('올바른 날짜/시간을 입력해주세요.');
+ return;
+ }
+
+ if (from >= to) {
+ showToast('종료 시간은 시작 시간보다 이후여야 합니다.');
+ return;
+ }
+
+ try {
+ setIsQuerying(true);
+ setErrorMessage('');
+
+ // 시간 범위 설정 (애니메이션 스토어)
+ setTimeRange(from.getTime(), to.getTime());
+
+ // WebSocket 쿼리 실행
+ const wsService = getReplayWebSocketService();
+ await wsService.executeQuery({
+ startTime: toKstISOString(from),
+ endTime: toKstISOString(to),
+ });
+ } catch (error) {
+ console.error('[ReplayPage] 쿼리 실패:', error);
+ setIsQuerying(false);
+ setErrorMessage(`조회 실패: ${error.message}`);
+ }
+ }, [startDate, startTime, endDate, endTime, setTimeRange]);
+
+ /**
+ * 조회 취소
+ */
+ const handleCancel = useCallback(() => {
+ getReplayWebSocketService().cancelQuery();
+ setIsQuerying(false);
+ }, []);
+
+ return (
+
+
+ {/* 본문 */}
+
+ {/* 조회 조건 */}
+
+
조회 기간
+
+ {/* 시작 시간 */}
+
+
+ {/* 종료 시간 */}
+
+
+ {/* 버튼 */}
+
+ {isQuerying ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* 조회 결과 영역 */}
+
+ {errorMessage && (
+
{errorMessage}
+ )}
+ {connectionState === ConnectionState.CONNECTING && (
+
서버 연결 중...
+ )}
+ {isQuerying && connectionState === ConnectionState.CONNECTED && (
+
+
데이터를 불러오는 중입니다...
+ {progress > 0 && (
+
{Math.min(progress, 100).toFixed(0)}% 완료
+ )}
+
+ )}
+ {queryCompleted && (
+ <>
+ {/* 필터 컨트롤 (선종, 선박목록, 항적, 라벨) */}
+
+ {/* 선박 분류 관리 패널 */}
+
+ >
+ )}
+ {!isQuerying && !queryCompleted && !errorMessage && connectionState !== ConnectionState.CONNECTING && (
+
+ 조회 버튼을 클릭하여 리플레이를 시작하세요.
+
+ )}
+
+
+
+ );
+}
diff --git a/src/pages/ReplayPage.scss b/src/pages/ReplayPage.scss
new file mode 100644
index 00000000..4232b7d0
--- /dev/null
+++ b/src/pages/ReplayPage.scss
@@ -0,0 +1,192 @@
+// 리플레이 패널 스타일
+// tracking 패키지 참조: src/tracking/components/ReplayV2.scss
+// 기존 패널 스타일 패밀리 적용: src/scss/SideComponent.scss
+
+.replay-panel {
+ // 패널 헤더
+ .panelHeader {
+ display: flex;
+ align-items: center;
+ padding: 0 2rem;
+
+ .panelTitle {
+ padding: 1.7rem 0;
+ font-size: var(--fs-ml, 1.4rem);
+ font-weight: var(--fw-bold, 700);
+ color: var(--white, #fff);
+ }
+ }
+
+ // 패널 본문
+ .panelBody {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ padding: 0 2rem 2rem 2rem;
+ overflow-y: auto;
+
+ // 조회 조건 영역
+ .query-section {
+ background-color: var(--secondary1, rgba(255, 255, 255, 0.05));
+ border-radius: 0.6rem;
+ padding: 1.5rem;
+ margin-bottom: 2rem;
+
+ .section-title {
+ font-size: var(--fs-m, 1.3rem);
+ font-weight: var(--fw-bold, 700);
+ color: var(--white, #fff);
+ margin-bottom: 1.5rem;
+ }
+
+ .query-row {
+ display: flex;
+ align-items: center;
+ margin-bottom: 1.2rem;
+
+ .query-label {
+ min-width: 5rem;
+ font-size: var(--fs-s, 1.2rem);
+ color: var(--tertiary4, #ccc);
+ }
+
+ .datetime-inputs {
+ display: flex;
+ gap: 0.8rem;
+ flex: 1;
+
+ .input-date,
+ .input-time {
+ flex: 1;
+ height: 3.2rem;
+ padding: 0.4rem 0.8rem;
+ background-color: var(--tertiary1, rgba(0, 0, 0, 0.3));
+ border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
+ border-radius: 0.4rem;
+ color: var(--white, #fff);
+ font-size: var(--fs-s, 1.2rem);
+ font-family: inherit;
+
+ &:focus {
+ outline: none;
+ border-color: var(--primary1, rgba(255, 255, 255, 0.5));
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ // 달력/시계 아이콘 색상 조정
+ &::-webkit-calendar-picker-indicator {
+ filter: invert(1);
+ cursor: pointer;
+ }
+ }
+
+ .input-date {
+ min-width: 14rem;
+ }
+
+ .input-time {
+ min-width: 10rem;
+ }
+ }
+ }
+
+ // 버튼 영역
+ .btnBox {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 2rem;
+ gap: 1rem;
+
+ .btn {
+ min-width: 12rem;
+ padding: 1rem 2rem;
+ border-radius: 0.4rem;
+ font-size: var(--fs-s, 1.2rem);
+ font-weight: var(--fw-semibold, 600);
+ cursor: pointer;
+ transition: all 0.2s;
+ border: none;
+
+ &.btn-primary {
+ background-color: var(--primary1, #4a9eff);
+ color: var(--white, #fff);
+
+ &:hover:not(:disabled) {
+ background-color: var(--primary2, #3a8eef);
+ }
+
+ &:disabled {
+ background-color: var(--secondary3, #555);
+ cursor: not-allowed;
+ opacity: 0.6;
+ }
+ }
+
+ &.btn-query {
+ min-width: 14rem;
+ }
+ }
+ }
+ }
+
+ // 조회 결과 영역
+ .result-section {
+ flex: 1;
+ min-height: 20rem;
+ display: flex;
+ flex-direction: column;
+ background-color: var(--tertiary1, rgba(0, 0, 0, 0.2));
+ border-radius: 0.6rem;
+ padding: 1.5rem;
+ overflow-y: auto;
+
+ // 컨텐츠가 없을 때만 중앙 정렬 (직접 자식만 선택)
+ &:empty,
+ &:has(> .loading-message),
+ &:has(> .empty-message),
+ &:has(> .error-message) {
+ align-items: center;
+ justify-content: center;
+ }
+
+ .loading-message,
+ .empty-message,
+ .error-message,
+ .success-message {
+ font-size: var(--fs-m, 1.3rem);
+ color: var(--tertiary4, #999);
+ text-align: center;
+ line-height: 1.6;
+ }
+
+ .loading-message {
+ color: var(--primary1, #4a9eff);
+
+ .progress-info {
+ margin-top: 1rem;
+ font-size: var(--fs-s, 1.2rem);
+ color: var(--tertiary4, #ccc);
+ }
+ }
+
+ .success-message {
+ color: #4ade80; // green
+ }
+
+ .error-message {
+ color: #f87171; // red
+ }
+
+ // 필터 및 목록 컨테이너 (수직 배치)
+ .replay-control-v2,
+ .vessel-list-manager {
+ width: 100%;
+ }
+ }
+ }
+}
diff --git a/src/replay/components/ReplayControlV2.jsx b/src/replay/components/ReplayControlV2.jsx
new file mode 100644
index 00000000..cc53f845
--- /dev/null
+++ b/src/replay/components/ReplayControlV2.jsx
@@ -0,0 +1,227 @@
+/**
+ * Replay 전용 통합 필터 컴포넌트
+ * TrackingControl 패턴을 확장하여 리플레이 전용 필터들을 통합 관리
+ * dark 프로젝트 스타일 적용
+ */
+import { useState, useCallback } from 'react';
+import useReplayStore from '../stores/replayStore';
+import useMergedTrackStore from '../stores/mergedTrackStore';
+import usePlaybackTrailStore from '../stores/playbackTrailStore';
+import './ReplayControlV2.scss';
+
+// 리플레이 필터 옵션
+const CUSTOM_FILTER_OPTIONS = [
+ { key: 'showNormal', label: '기본' },
+ { key: 'showSelected', label: '선택' },
+ { key: 'showDeleted', label: '삭제' },
+];
+
+// 선종 필터 옵션
+const SHIP_KIND_OPTIONS = [
+ { code: '000020', label: '어선', key: 'fishing' },
+ { code: '000021', label: '함정', key: 'naval' },
+ { code: '000022', label: '여객선', key: 'passenger' },
+ { code: '000023', label: '화물선', key: 'cargo' },
+ { code: '000024', label: '유조선', key: 'tanker' },
+ { code: '000025', label: '관공선', key: 'government' },
+ { code: '000027', label: '기타', key: 'other' },
+ { code: '000028', label: '부이', key: 'buoy' },
+];
+
+const ReplayControlV2 = () => {
+ // Replay Store 연결
+ const filterModules = useReplayStore(state => state.filterModules);
+ const shipKindCodeFilter = useReplayStore(state => state.shipKindCodeFilter);
+ const selectedVesselIds = useReplayStore(state => state.selectedVesselIds);
+ const deletedVesselIds = useReplayStore(state => state.deletedVesselIds);
+ const vesselChunks = useMergedTrackStore(state => state.vesselChunks);
+ const [isOpen, setIsOpen] = useState(true);
+
+ // Playback Trail Store 연결 (항적표시 토글)
+ const isTrailEnabled = usePlaybackTrailStore(state => state.isEnabled);
+ const setTrailEnabled = usePlaybackTrailStore(state => state.setEnabled);
+
+ // 전체 선박 수에서 선택/삭제된 선박을 제외한 기본 선박 수 계산
+ const totalVesselCount = vesselChunks.size;
+ const normalVesselCount = totalVesselCount - selectedVesselIds.size - deletedVesselIds.size;
+
+ // Actions
+ const updateFilterModule = useReplayStore(state => state.updateFilterModule);
+ const toggleShipKindCode = useReplayStore(state => state.toggleShipKindCode);
+
+ // 커스텀 필터 토글 핸들러
+ const handleCustomFilterToggle = (key) => {
+ const newValue = !filterModules.custom[key];
+ updateFilterModule('custom', { [key]: newValue });
+ };
+
+ // 항적 필터 토글 핸들러
+ const handlePathFilterToggle = (key) => {
+ const newValue = !filterModules.path[key];
+ updateFilterModule('path', { [key]: newValue });
+ };
+
+ // 라벨 필터 토글 핸들러
+ const handleLabelFilterToggle = (key) => {
+ const newValue = !filterModules.label[key];
+ updateFilterModule('label', { [key]: newValue });
+ };
+
+ // 항적표시 토글 핸들러
+ const handleTrailToggle = useCallback(() => {
+ setTrailEnabled(!isTrailEnabled);
+ }, [setTrailEnabled, isTrailEnabled]);
+
+ const openHandler = () => {
+ setIsOpen(!isOpen);
+ };
+
+ // 카운트 표시 헬퍼
+ const getCountLabel = (key) => {
+ if (key === 'showNormal') return ` (${normalVesselCount})`;
+ if (key === 'showSelected') return ` (${selectedVesselIds.size})`;
+ if (key === 'showDeleted') return ` (${deletedVesselIds.size})`;
+ return '';
+ };
+
+ return (
+
+ {/* 헤더 (접기/펼치기) */}
+
+
+ {isOpen && (
+ <>
+ {/* 커스텀 필터 (선박 목록) */}
+
+
+
+ {CUSTOM_FILTER_OPTIONS.map(option => (
+
+
{option.label}{getCountLabel(option.key)}
+
+ handleCustomFilterToggle(option.key)}
+ />
+
+
+
+ ))}
+
+
+
+ {/* 선종 필터 */}
+
+
+
선종
+
+
+ {SHIP_KIND_OPTIONS.map(option => (
+
+
{option.label}
+
+ toggleShipKindCode(option.code)}
+ />
+
+
+
+ ))}
+
+
+
+ {/* 항적 필터 */}
+
+
+
항적
+
+
+ {CUSTOM_FILTER_OPTIONS.map(option => (
+
+
{option.label}{getCountLabel(option.key)}
+
+ handlePathFilterToggle(option.key)}
+ />
+
+
+
+ ))}
+
+
+
+ {/* 라벨 필터 */}
+
+
+
라벨
+
+
+ {CUSTOM_FILTER_OPTIONS.map(option => (
+
+
{option.label}{getCountLabel(option.key)}
+
+ handleLabelFilterToggle(option.key)}
+ />
+
+
+
+ ))}
+
+
+
+ {/* 항적표시 토글 (애니메이션 재생 시 이동 궤적) */}
+
+ >
+ )}
+
+ );
+};
+
+export default ReplayControlV2;
diff --git a/src/replay/components/ReplayControlV2.scss b/src/replay/components/ReplayControlV2.scss
new file mode 100644
index 00000000..9796d45c
--- /dev/null
+++ b/src/replay/components/ReplayControlV2.scss
@@ -0,0 +1,161 @@
+// 리플레이 필터 컨트롤 스타일
+// dark 프로젝트 패밀리 스타일 적용
+
+.replay-control-v2 {
+ background: var(--secondary1, rgba(255, 255, 255, 0.05));
+ border-radius: 0.6rem;
+ margin-bottom: 1.5rem;
+
+ // 헤더 (접기/펼치기)
+ .filter-header-toggle {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.2rem 1.5rem;
+ border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
+ cursor: pointer;
+ transition: background-color 0.2s;
+
+ &:hover {
+ background-color: var(--tertiary1, rgba(255, 255, 255, 0.03));
+ }
+
+ .filter-title {
+ font-size: var(--fs-m, 1.3rem);
+ font-weight: var(--fw-bold, 700);
+ color: var(--white, #fff);
+ }
+
+ .toggle-icon {
+ transition: transform 0.2s;
+
+ &.is-open {
+ transform: rotate(180deg);
+ }
+ }
+ }
+
+ .mb-3 {
+ margin-bottom: 0;
+ }
+}
+
+// 필터 섹션 공통 스타일
+.sig-src-cd-filter {
+ display: flex;
+ border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .filter-header {
+ width: 6rem;
+ min-width: 6rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--tertiary1, rgba(0, 0, 0, 0.2));
+ padding: 1rem 0.5rem;
+
+ h3 {
+ margin: 0;
+ font-size: var(--fs-s, 1.2rem);
+ font-weight: var(--fw-semibold, 600);
+ color: var(--tertiary4, #ccc);
+ text-align: center;
+ line-height: 1.4;
+
+ div {
+ margin-top: 0.2rem;
+ }
+ }
+ }
+
+ .filter-list {
+ flex: 1;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.8rem 0;
+ padding: 1rem 1.2rem;
+ font-size: var(--fs-s, 1.2rem);
+
+ .filter-item-wrapper {
+ display: flex;
+ align-items: center;
+ width: 33.33%;
+ min-width: 10rem;
+
+ @media (max-width: 500px) {
+ width: 50%;
+ }
+
+ strong {
+ font-size: var(--fs-s, 1.2rem);
+ font-weight: var(--fw-medium, 500);
+ color: var(--white, #fff);
+ margin-right: 0.8rem;
+ white-space: nowrap;
+ }
+ }
+
+ // 토글 스위치 스타일
+ .toggle-area {
+ input[type="checkbox"] {
+ display: none;
+
+ &:checked + label {
+ background-color: var(--primary1, #4a9eff);
+
+ &::after {
+ transform: translate3d(1.2rem, 0, 0);
+ }
+ }
+ }
+
+ label {
+ cursor: pointer;
+ display: block;
+ position: relative;
+ width: 2.6rem;
+ height: 1.4rem;
+ background-color: var(--tertiary3, #555);
+ border-radius: 2rem;
+ transition: all 0.3s ease-out;
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: 0.2rem;
+ left: 0.2rem;
+ width: 1rem;
+ height: 1rem;
+ background-color: var(--white, #fff);
+ border-radius: 50%;
+ transition: all 0.3s ease-out;
+ }
+
+ &:hover {
+ background-color: var(--tertiary4, #666);
+ }
+ }
+
+ // 항적표시 토글 (주황색 액센트)
+ &.trail-toggle {
+ input[type="checkbox"]:checked + label {
+ background-color: #ff9800;
+ }
+ }
+ }
+ }
+
+ // 항적표시 필터 (단일 토글)
+ &.trail-filter {
+ .filter-list {
+ .filter-item-wrapper {
+ width: 100%;
+ }
+ }
+ }
+}
diff --git a/src/replay/components/ReplayTimeline.jsx b/src/replay/components/ReplayTimeline.jsx
new file mode 100644
index 00000000..29a5d573
--- /dev/null
+++ b/src/replay/components/ReplayTimeline.jsx
@@ -0,0 +1,294 @@
+/**
+ * 리플레이 타임라인 컨트롤
+ * 원본: src/tracking/components/TrackingTimeline.tsx (TS → JS 전환)
+ *
+ * animationStore를 사용하는 재생 컨트롤
+ * - 재생/일시정지/정지
+ * - 배속 조절 (1x ~ 1000x)
+ * - 프로그레스 바 (range slider)
+ * - 드래그 가능한 헤더
+ * - 항적표시 토글
+ */
+import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
+import useAnimationStore, { PlaybackState } from '../stores/animationStore';
+import usePlaybackTrailStore from '../stores/playbackTrailStore';
+import './ReplayTimeline.scss';
+
+// 배속 옵션
+const PLAYBACK_SPEED_OPTIONS = [1, 10, 50, 100, 500, 1000];
+
+/**
+ * 날짜 포맷팅 (YYYY-MM-DD HH:mm 형식)
+ */
+function formatDateRange(dateStr) {
+ if (!dateStr) return '';
+ try {
+ const date = new Date(dateStr);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ return `${year}-${month}-${day} ${hours}:${minutes}`;
+ } catch {
+ return dateStr;
+ }
+}
+
+/**
+ * ms → 날짜시간 문자열 (YYYY-MM-DD HH:mm:ss)
+ */
+function formatDateTime(ms) {
+ if (!ms || ms <= 0) return '--:--:--';
+ const d = new Date(ms);
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+}
+
+/**
+ * 리플레이 타임라인 컨트롤 컴포넌트
+ */
+export default function ReplayTimeline({ fromDate, toDate, onClose }) {
+ // animationStore 상태
+ const playbackState = useAnimationStore((s) => s.playbackState);
+ const currentTime = useAnimationStore((s) => s.currentTime);
+ const startTime = useAnimationStore((s) => s.startTime);
+ const endTime = useAnimationStore((s) => s.endTime);
+ const playbackSpeed = useAnimationStore((s) => s.playbackSpeed);
+
+ // 액션
+ const play = useAnimationStore((s) => s.play);
+ const pause = useAnimationStore((s) => s.pause);
+ const stop = useAnimationStore((s) => s.stop);
+ const seekTo = useAnimationStore((s) => s.seekTo);
+ const setPlaybackSpeed = useAnimationStore((s) => s.setPlaybackSpeed);
+
+ const isPlaying = playbackState === PlaybackState.PLAYING;
+
+ // 진행률
+ const progress = useMemo(() => {
+ if (endTime <= startTime || startTime <= 0) return 0;
+ return ((currentTime - startTime) / (endTime - startTime)) * 100;
+ }, [currentTime, startTime, endTime]);
+
+ // 배속 드롭다운 상태
+ const [showSpeedMenu, setShowSpeedMenu] = useState(false);
+ const speedMenuRef = useRef(null);
+ const sliderContainerRef = useRef(null);
+
+ // 드래그 상태
+ const [isDragging, setIsDragging] = useState(false);
+ const [position, setPosition] = useState({ x: 0, y: 0 });
+ const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
+ const containerRef = useRef(null);
+
+ // 항적표시 상태 (playbackTrailStore와 동기화)
+ const isTrailEnabled = usePlaybackTrailStore((s) => s.isEnabled);
+ const setTrailEnabled = usePlaybackTrailStore((s) => s.setEnabled);
+
+ // 외부 클릭 시 드롭다운 닫기
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (speedMenuRef.current && !speedMenuRef.current.contains(event.target)) {
+ setShowSpeedMenu(false);
+ }
+ };
+
+ if (showSpeedMenu) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [showSpeedMenu]);
+
+ // 드래그 핸들러
+ const handleMouseDown = useCallback((e) => {
+ if (containerRef.current) {
+ const rect = containerRef.current.getBoundingClientRect();
+ setDragOffset({
+ x: e.clientX - rect.left - position.x,
+ y: e.clientY - rect.top - position.y,
+ });
+ setIsDragging(true);
+ }
+ }, [position]);
+
+ useEffect(() => {
+ const handleMouseMove = (e) => {
+ if (isDragging && containerRef.current) {
+ const parent = containerRef.current.parentElement;
+ if (parent) {
+ const parentRect = parent.getBoundingClientRect();
+ const containerRect = containerRef.current.getBoundingClientRect();
+
+ let newX = e.clientX - parentRect.left - dragOffset.x;
+ let newY = e.clientY - parentRect.top - dragOffset.y;
+
+ // 경계 제한
+ newX = Math.max(0, Math.min(newX, parentRect.width - containerRect.width));
+ newY = Math.max(0, Math.min(newY, parentRect.height - containerRect.height));
+
+ setPosition({ x: newX, y: newY });
+ }
+ }
+ };
+
+ const handleMouseUp = () => {
+ setIsDragging(false);
+ };
+
+ if (isDragging) {
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ }
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [isDragging, dragOffset]);
+
+ // 재생/일시정지 토글
+ const handlePlayPause = useCallback(() => {
+ if (isPlaying) pause();
+ else play();
+ }, [isPlaying, play, pause]);
+
+ // 정지
+ const handleStop = useCallback(() => {
+ stop();
+ }, [stop]);
+
+ // 배속 변경
+ const handleSpeedChange = useCallback((speed) => {
+ setPlaybackSpeed(speed);
+ setShowSpeedMenu(false);
+ }, [setPlaybackSpeed]);
+
+ // 항적표시 토글
+ const handleTrailToggle = useCallback(() => {
+ setTrailEnabled(!isTrailEnabled);
+ }, [setTrailEnabled, isTrailEnabled]);
+
+ // 슬라이더로 시간 변경
+ const handleSliderChange = useCallback((e) => {
+ const newTime = parseFloat(e.target.value);
+ seekTo(newTime);
+ }, [seekTo]);
+
+ // 데이터 유효성 확인
+ const hasData = endTime > startTime && startTime > 0;
+
+ // 날짜 범위 표시 텍스트
+ const dateRangeText =
+ fromDate && toDate ? `${formatDateRange(fromDate)} ~ ${formatDateRange(toDate)}` : '';
+
+ return (
+
+ {/* 드래그 가능한 헤더 */}
+
+
+ 리플레이
+ {dateRangeText && {dateRangeText}}
+
+ {onClose && (
+
+ )}
+
+
+ {/* 컨트롤 영역 */}
+
+ {/* 배속 선택 */}
+
+
+ {showSpeedMenu && (
+
+ {PLAYBACK_SPEED_OPTIONS.map((speed) => (
+
+ ))}
+
+ )}
+
+
+ {/* 재생/일시정지 버튼 */}
+
+
+ {/* 정지 버튼 */}
+
+
+ {/* 슬라이더 */}
+
+
+
+
+ {/* 현재 시간 */}
+
+ {hasData ? formatDateTime(currentTime) : '--:--:--'}
+
+
+ {/* 항적표시 토글 */}
+
+
+
+ );
+}
diff --git a/src/replay/components/ReplayTimeline.scss b/src/replay/components/ReplayTimeline.scss
new file mode 100644
index 00000000..a04d4e73
--- /dev/null
+++ b/src/replay/components/ReplayTimeline.scss
@@ -0,0 +1,421 @@
+/**
+ * 리플레이 타임라인 스타일
+ * 원본: src/tracking/components/TrackingTimeline.scss
+ * 맵 위 플로팅 포지셔닝 추가
+ */
+
+.replay-timeline {
+ position: absolute;
+ bottom: 40px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 100;
+
+ background: rgba(30, 35, 55, 0.95);
+ border-radius: 6px;
+ overflow: visible;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ min-width: 400px;
+
+ &.playing {
+ .play-btn {
+ animation: pulse-glow 1.5s infinite;
+ }
+ }
+
+ &.dragging {
+ cursor: grabbing;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
+ opacity: 0.95;
+ }
+}
+
+// 드래그 가능한 헤더
+.replay-timeline .timeline-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 26px; // 좌우 간격 추가 확대
+ background: linear-gradient(135deg, rgba(79, 195, 247, 0.3), rgba(41, 182, 246, 0.2));
+ border-bottom: 1px solid rgba(79, 195, 247, 0.3);
+ border-radius: 6px 6px 0 0;
+ cursor: grab;
+ user-select: none;
+
+ &:active {
+ cursor: grabbing;
+ }
+
+ .header-content {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1;
+ }
+
+ .header-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: #fff;
+ letter-spacing: 0.5px;
+ }
+
+ .header-date-range {
+ font-family: 'Consolas', 'Monaco', monospace;
+ font-size: 11px;
+ font-weight: 500;
+ color: #4fc3f7;
+ background: rgba(0, 0, 0, 0.2);
+ padding: 2px 8px;
+ border-radius: 4px;
+ }
+
+ .header-close-btn {
+ background: transparent;
+ border: none;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 14px;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 4px;
+ transition: all 0.2s ease;
+ line-height: 1;
+
+ &:hover {
+ background: rgba(244, 67, 54, 0.3);
+ color: #fff;
+ }
+ }
+}
+
+.replay-timeline .timeline-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 26px; // 좌우 간격 추가 확대
+}
+
+// 배속 선택기
+.replay-timeline .speed-selector {
+ position: relative;
+ z-index: 100;
+
+ .speed-btn {
+ background: rgba(79, 195, 247, 0.2);
+ border: 1px solid rgba(79, 195, 247, 0.4);
+ border-radius: 4px;
+ color: #4fc3f7;
+ font-size: 11px;
+ font-weight: 600;
+ padding: 5px 10px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ min-width: 50px;
+
+ &:hover:not(:disabled) {
+ background: rgba(79, 195, 247, 0.3);
+ border-color: #4fc3f7;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+
+ .speed-menu {
+ position: absolute;
+ bottom: 100%;
+ left: 0;
+ margin-bottom: 4px;
+ background: rgba(40, 45, 70, 0.98);
+ border: 1px solid rgba(79, 195, 247, 0.4);
+ border-radius: 6px;
+ padding: 6px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ min-width: 180px;
+ box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
+ z-index: 101;
+
+ .speed-option {
+ flex: 0 0 calc(33.333% - 4px);
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid transparent;
+ border-radius: 4px;
+ color: #fff;
+ font-size: 11px;
+ font-weight: 500;
+ padding: 6px 8px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ text-align: center;
+
+ &:hover {
+ background: rgba(79, 195, 247, 0.3);
+ border-color: rgba(79, 195, 247, 0.5);
+ }
+
+ &.active {
+ background: rgba(79, 195, 247, 0.5);
+ border-color: #4fc3f7;
+ color: #fff;
+ }
+ }
+ }
+}
+
+// 컨트롤 버튼
+.replay-timeline .control-btn {
+ width: 32px;
+ height: 32px;
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ transition: all 0.2s ease;
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ &.play-btn {
+ background: linear-gradient(135deg, #4caf50, #45a049);
+ color: #fff;
+
+ &:hover:not(:disabled) {
+ transform: scale(1.1);
+ box-shadow: 0 0 12px rgba(76, 175, 80, 0.5);
+ }
+
+ &.playing {
+ background: linear-gradient(135deg, #ffc107, #ffb300);
+ }
+ }
+
+ &.stop-btn {
+ background: rgba(244, 67, 54, 0.8);
+ color: #fff;
+
+ &:hover:not(:disabled) {
+ background: rgba(244, 67, 54, 1);
+ transform: scale(1.1);
+ }
+ }
+}
+
+// 슬라이더 컨테이너
+.replay-timeline .timeline-slider-container {
+ flex: 1;
+ position: relative;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ min-width: 100px;
+ // thumb가 컨테이너 경계에 맞도록 padding 추가
+ padding: 0 7px;
+
+ // 배경 막대 (진행률 영역과 일치)
+ &::before {
+ content: '';
+ position: absolute;
+ left: 7px;
+ right: 7px;
+ top: 50%;
+ transform: translateY(-50%);
+ height: 6px;
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 3px;
+ pointer-events: none;
+ }
+
+ .timeline-slider {
+ --progress: 0%;
+ width: 100%;
+ height: 6px;
+ -webkit-appearance: none;
+ appearance: none;
+ border-radius: 3px;
+ cursor: pointer;
+ background: transparent;
+ position: relative;
+ z-index: 1;
+
+ &::-webkit-slider-runnable-track {
+ height: 6px;
+ border-radius: 3px;
+ background: linear-gradient(
+ to right,
+ #4fc3f7 0%,
+ #29b6f6 var(--progress),
+ transparent var(--progress),
+ transparent 100%
+ );
+ }
+
+ &::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 14px;
+ height: 14px;
+ background: #fff;
+ border: 2px solid #4fc3f7;
+ border-radius: 50%;
+ cursor: grab;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
+ transition: transform 0.1s ease;
+ margin-top: -4px;
+
+ &:hover {
+ transform: scale(1.2);
+ }
+
+ &:active {
+ cursor: grabbing;
+ }
+ }
+
+ &::-moz-range-track {
+ height: 6px;
+ border-radius: 3px;
+ background: linear-gradient(
+ to right,
+ #4fc3f7 0%,
+ #29b6f6 var(--progress),
+ transparent var(--progress),
+ transparent 100%
+ );
+ }
+
+ &::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ background: #fff;
+ border: 2px solid #4fc3f7;
+ border-radius: 50%;
+ cursor: grab;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+
+ &::-webkit-slider-thumb {
+ cursor: not-allowed;
+ }
+ }
+ }
+}
+
+// 현재 시간 표시
+.replay-timeline .current-time-display {
+ font-family: 'Consolas', 'Monaco', monospace;
+ font-size: 11px;
+ font-weight: 500;
+ color: #4fc3f7;
+ min-width: 130px;
+ text-align: center;
+ white-space: nowrap;
+}
+
+// 토글 스타일 공통
+.replay-timeline .loop-toggle,
+.replay-timeline .trail-toggle {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.8);
+ white-space: nowrap;
+
+ input[type='checkbox'] {
+ width: 14px;
+ height: 14px;
+ cursor: pointer;
+ accent-color: #4fc3f7;
+
+ &:disabled {
+ cursor: not-allowed;
+ }
+ }
+
+ &:hover {
+ color: #fff;
+ }
+}
+
+// 항적표시 토글 (명확한 on/off 상태 표시)
+.replay-timeline .trail-toggle {
+ padding: 4px 8px;
+ border-radius: 4px;
+ border: 1px solid transparent;
+ transition: all 0.2s ease;
+
+ input[type='checkbox'] {
+ accent-color: #ff9800;
+ }
+
+ // OFF 상태
+ &:has(input:not(:checked)) {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: rgba(255, 255, 255, 0.2);
+
+ span {
+ color: rgba(255, 255, 255, 0.5);
+ }
+ }
+
+ // ON 상태
+ &:has(input:checked) {
+ background: rgba(255, 152, 0, 0.2);
+ border-color: rgba(255, 152, 0, 0.6);
+
+ span {
+ color: #ff9800;
+ font-weight: 600;
+ }
+ }
+}
+
+// 재생 중 글로우 애니메이션
+@keyframes pulse-glow {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.4);
+ }
+ 70% {
+ box-shadow: 0 0 0 8px rgba(255, 193, 7, 0);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(255, 193, 7, 0);
+ }
+}
+
+// 반응형 대응
+@media (max-width: 768px) {
+ .replay-timeline {
+ min-width: 320px;
+
+ .control-btn {
+ width: 28px;
+ height: 28px;
+ font-size: 10px;
+ }
+
+ .speed-btn {
+ font-size: 10px;
+ padding: 4px 8px;
+ }
+
+ .current-time-display {
+ font-size: 10px;
+ min-width: 100px;
+ }
+ }
+}
diff --git a/src/replay/components/VesselListManager/VesselContextMenu.jsx b/src/replay/components/VesselListManager/VesselContextMenu.jsx
new file mode 100644
index 00000000..11cb3200
--- /dev/null
+++ b/src/replay/components/VesselListManager/VesselContextMenu.jsx
@@ -0,0 +1,82 @@
+/**
+ * 선박 아이템 우클릭 컨텍스트 메뉴 컴포넌트
+ */
+import React, { useCallback, useEffect } from 'react';
+import './VesselContextMenu.scss';
+
+const VesselContextMenu = ({
+ vessel,
+ position,
+ onClose,
+ onShowDetail,
+}) => {
+ // 메뉴 외부 클릭 시 닫기
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ const target = event.target;
+ if (!target.closest('.vessel-context-menu')) {
+ onClose();
+ }
+ };
+
+ const handleEscKey = (event) => {
+ if (event.key === 'Escape') {
+ onClose();
+ }
+ };
+
+ // 약간의 지연 후 이벤트 리스너 등록 (우클릭 이벤트와 충돌 방지)
+ const timer = setTimeout(() => {
+ document.addEventListener('click', handleClickOutside);
+ document.addEventListener('keydown', handleEscKey);
+ }, 100);
+
+ return () => {
+ clearTimeout(timer);
+ document.removeEventListener('click', handleClickOutside);
+ document.removeEventListener('keydown', handleEscKey);
+ };
+ }, [onClose]);
+
+ const handleShowDetail = useCallback(() => {
+ onShowDetail(vessel.vesselId);
+ onClose();
+ }, [vessel.vesselId, onShowDetail, onClose]);
+
+ // 화면 경계 체크 및 위치 조정
+ const adjustedPosition = {
+ x: Math.min(position.x, window.innerWidth - 200), // 메뉴 너비 고려
+ y: Math.min(position.y, window.innerHeight - 150), // 메뉴 높이 고려
+ };
+
+ return (
+
+
+ {vessel.shipName}
+ ID: {vessel.vesselId}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default React.memo(VesselContextMenu);
diff --git a/src/replay/components/VesselListManager/VesselContextMenu.scss b/src/replay/components/VesselListManager/VesselContextMenu.scss
new file mode 100644
index 00000000..61c40ccf
--- /dev/null
+++ b/src/replay/components/VesselListManager/VesselContextMenu.scss
@@ -0,0 +1,90 @@
+// 선박 컨텍스트 메뉴 스타일
+// dark 프로젝트 패밀리 스타일 적용
+
+.vessel-context-menu {
+ background: var(--bg-dark, #1a1a2e);
+ border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.15));
+ border-radius: 0.6rem;
+ box-shadow: 0 0.4rem 1.2rem rgba(0, 0, 0, 0.4);
+ min-width: 18rem;
+ user-select: none;
+ font-size: var(--fs-s, 1.2rem);
+ z-index: 10001;
+ animation: contextMenuFadeIn 0.15s ease-out;
+
+ .context-menu-header {
+ padding: 1rem 1.2rem;
+ background: var(--tertiary1, rgba(0, 0, 0, 0.3));
+ border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
+ border-radius: 0.6rem 0.6rem 0 0;
+
+ .vessel-name {
+ display: block;
+ font-weight: var(--fw-semibold, 600);
+ color: var(--white, #fff);
+ margin-bottom: 0.4rem;
+ word-break: break-word;
+ }
+
+ .vessel-id {
+ font-size: var(--fs-xs, 1.1rem);
+ color: var(--tertiary4, #888);
+ font-family: 'Courier New', monospace;
+ }
+ }
+
+ .context-menu-divider {
+ height: 1px;
+ background: var(--tertiary2, rgba(255, 255, 255, 0.1));
+ margin: 0;
+ }
+
+ .context-menu-items {
+ padding: 0.8rem 0;
+
+ .context-menu-item {
+ width: 100%;
+ background: none;
+ border: none;
+ padding: 0.8rem 1.2rem;
+ text-align: left;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ font-size: var(--fs-s, 1.2rem);
+ color: var(--tertiary4, #ccc);
+
+ &:hover {
+ background: var(--tertiary2, rgba(255, 255, 255, 0.08));
+ color: var(--white, #fff);
+ }
+
+ &:active {
+ background: var(--tertiary1, rgba(255, 255, 255, 0.12));
+ }
+
+ i {
+ width: 1.6rem;
+ color: var(--primary1, #4a9eff);
+ font-size: 1.2rem;
+ }
+
+ span {
+ flex: 1;
+ }
+ }
+ }
+
+ @keyframes contextMenuFadeIn {
+ from {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+}
diff --git a/src/replay/components/VesselListManager/VesselItem.jsx b/src/replay/components/VesselListManager/VesselItem.jsx
new file mode 100644
index 00000000..d70718cf
--- /dev/null
+++ b/src/replay/components/VesselListManager/VesselItem.jsx
@@ -0,0 +1,264 @@
+/**
+ * 개별 선박 아이템 컴포넌트
+ * HTML5 드래그앤드롭 API를 사용하여 드래그 가능하며 선종/국적 아이콘, 선박명을 표시
+ * dark 프로젝트 아이콘 경로 사용
+ */
+import React, { useCallback, useState } from 'react';
+import { getCountryNameFromCode } from './utils/countryCodeUtils';
+import './VesselItem.scss';
+
+// 선종 아이콘 import (dark 프로젝트 assets)
+import fishingIcon from '../../../assets/img/shipDetail/detailKindIcon/fishing.svg';
+import kcgvIcon from '../../../assets/img/shipDetail/detailKindIcon/kcgv.svg';
+import passengerIcon from '../../../assets/img/shipDetail/detailKindIcon/passenger.svg';
+import cargoIcon from '../../../assets/img/shipDetail/detailKindIcon/cargo.svg';
+import tankerIcon from '../../../assets/img/shipDetail/detailKindIcon/tanker.svg';
+import govIcon from '../../../assets/img/shipDetail/detailKindIcon/gov.svg';
+import etcIcon from '../../../assets/img/shipDetail/detailKindIcon/etc.svg';
+
+// 선종 코드 상수
+import {
+ SIGNAL_KIND_CODE_FISHING,
+ SIGNAL_KIND_CODE_KCGV,
+ SIGNAL_KIND_CODE_PASSENGER,
+ SIGNAL_KIND_CODE_CARGO,
+ SIGNAL_KIND_CODE_TANKER,
+ SIGNAL_KIND_CODE_GOV,
+ SIGNAL_KIND_CODE_NORMAL,
+ SIGNAL_KIND_CODE_BUOY,
+} from '../../../types/constants';
+
+// 선종코드 → 아이콘 매핑
+const SHIP_KIND_ICONS = {
+ [SIGNAL_KIND_CODE_FISHING]: fishingIcon, // 000020: 어선
+ [SIGNAL_KIND_CODE_KCGV]: kcgvIcon, // 000021: 함정
+ [SIGNAL_KIND_CODE_PASSENGER]: passengerIcon, // 000022: 여객선
+ [SIGNAL_KIND_CODE_CARGO]: cargoIcon, // 000023: 화물선
+ [SIGNAL_KIND_CODE_TANKER]: tankerIcon, // 000024: 유조선
+ [SIGNAL_KIND_CODE_GOV]: govIcon, // 000025: 관공선
+ [SIGNAL_KIND_CODE_NORMAL]: etcIcon, // 000027: 기타
+ [SIGNAL_KIND_CODE_BUOY]: etcIcon, // 000028: 부이
+};
+
+// 선종 코드별 표시명
+const SHIP_KIND_NAMES = {
+ '000020': '어선',
+ '000021': '함정',
+ '000022': '여객선',
+ '000023': '화물선',
+ '000024': '유조선',
+ '000025': '관공선',
+ '000027': '기타',
+ '000028': '부이',
+};
+
+// 신호원 코드별 표시명
+const SIGNAL_SOURCE_NAMES = {
+ '000001': 'AIS',
+ '000002': 'E-NAV',
+ '000003': 'V-PASS',
+ '000004': 'VTS-AIS',
+};
+
+/**
+ * 선종 아이콘 반환
+ */
+const getShipKindIcon = (shipKindCode) => {
+ return SHIP_KIND_ICONS[shipKindCode] || etcIcon;
+};
+
+/**
+ * 국기 이미지 URL 생성 (서버 API)
+ * 개발 환경에서는 Vite 프록시를 통해 API 서버로 전달됨
+ * @param {string} nationalCode - MID 숫자코드 (예: '440', '412')
+ * @returns {string} 국기 이미지 URL
+ */
+const getNationalFlagUrl = (nationalCode) => {
+ // 국적 코드가 없으면 기본값 '000' 사용
+ const code = nationalCode || '000';
+ // 상대 경로 사용 (Vite 프록시가 /ship/image를 API 서버로 전달)
+ return `/ship/image/small/${code}.svg`;
+};
+
+const VesselItem = ({
+ vessel,
+ index,
+ isDragDisabled = false,
+ isSelected = false,
+ onDragStart,
+ onDragEnd,
+ onMouseEnterVessel,
+ onMouseLeaveVessel,
+ onToggleSelection,
+ onShowVesselDetail,
+ onContextMenu,
+}) => {
+ const [showTooltip, setShowTooltip] = useState(false);
+ const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
+ const [isDragging, setIsDragging] = useState(false);
+
+ // 아이콘 및 정보
+ const shipKindIcon = getShipKindIcon(vessel.shipKindCode);
+ const countryFlagUrl = getNationalFlagUrl(vessel.nationalCode || '000');
+ const shipKindName = SHIP_KIND_NAMES[vessel.shipKindCode] || '기타';
+ const signalSourceName = SIGNAL_SOURCE_NAMES[vessel.sigSrcCd || '000001'] || vessel.sigSrcCd || '기타';
+ const countryName = getCountryNameFromCode(vessel.nationalCode || '000');
+
+ // 마우스 호버 이벤트 핸들러
+ const handleMouseEnter = useCallback(
+ (event) => {
+ if (isDragDisabled || isDragging) return;
+
+ const rect = event.currentTarget.getBoundingClientRect();
+ setTooltipPosition({
+ x: rect.left + rect.width / 2,
+ y: rect.top,
+ });
+ setShowTooltip(true);
+
+ // 지도 상의 항적 하이라이트
+ onMouseEnterVessel?.(vessel.vesselId);
+ },
+ [isDragDisabled, isDragging, onMouseEnterVessel, vessel.vesselId],
+ );
+
+ const handleMouseLeave = useCallback(() => {
+ setShowTooltip(false);
+
+ // 지도 상의 항적 하이라이트 제거
+ onMouseLeaveVessel?.(vessel.vesselId);
+ }, [onMouseLeaveVessel, vessel.vesselId]);
+
+ // HTML5 드래그앤드롭 이벤트 핸들러
+ const handleDragStart = useCallback(
+ (event) => {
+ if (isDragDisabled) {
+ event.preventDefault();
+ return;
+ }
+
+ setIsDragging(true);
+ setShowTooltip(false);
+
+ // 드래그 데이터 설정
+ event.dataTransfer.setData(
+ 'text/plain',
+ JSON.stringify({
+ vesselId: vessel.vesselId,
+ sourceState: vessel.state,
+ }),
+ );
+
+ event.dataTransfer.effectAllowed = 'move';
+
+ // 커스텀 드래그 이미지 설정 (선택적)
+ const dragImage = event.currentTarget.cloneNode(true);
+ dragImage.style.transform = 'rotate(5deg)';
+ dragImage.style.opacity = '0.8';
+ event.dataTransfer.setDragImage(dragImage, 50, 25);
+
+ onDragStart?.(vessel.vesselId, vessel.state);
+ },
+ [isDragDisabled, vessel.vesselId, vessel.state, onDragStart],
+ );
+
+ const handleDragEnd = useCallback(
+ (event) => {
+ setIsDragging(false);
+ onDragEnd?.();
+ },
+ [onDragEnd],
+ );
+
+ // 체크박스 토글 핸들러
+ const handleToggleSelection = useCallback(
+ (event) => {
+ event.stopPropagation();
+ onToggleSelection?.(vessel.vesselId, !isSelected);
+ },
+ [vessel.vesselId, isSelected, onToggleSelection],
+ );
+
+ // 우클릭 컨텍스트 메뉴 핸들러
+ const handleRightClick = useCallback(
+ (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ onContextMenu?.(vessel.vesselId, event);
+ },
+ [onContextMenu, vessel.vesselId],
+ );
+
+ return (
+ <>
+
+
+ {/* 선택 체크박스 */}
+ {onToggleSelection && (
+
+ {}} // onClick에서 처리
+ tabIndex={-1}
+ />
+
+
+ )}
+
+
{index + 1}
+
+ {/* 선박 정보 */}
+
+
+
{vessel.shipName || '-'}
+

{ e.target.style.display = 'none'; }}
+ />
+

{ e.target.style.display = 'none'; }}
+ />
+
({signalSourceName})
+
+
+
+ {/* 드래그 핸들 아이콘 */}
+ {!isDragDisabled && (
+
+
+
+ )}
+
+
+ {/* 드래그 중 오버레이 */}
+ {isDragging &&
}
+
+ >
+ );
+};
+
+export default React.memo(VesselItem);
diff --git a/src/replay/components/VesselListManager/VesselItem.scss b/src/replay/components/VesselListManager/VesselItem.scss
new file mode 100644
index 00000000..e6f503dc
--- /dev/null
+++ b/src/replay/components/VesselListManager/VesselItem.scss
@@ -0,0 +1,213 @@
+// 선박 아이템 스타일
+// dark 프로젝트 패밀리 스타일 적용
+
+.vessel-item {
+ display: flex;
+ background: var(--tertiary1, rgba(0, 0, 0, 0.15));
+ border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
+ border-radius: 0.6rem;
+ margin-bottom: 0.4rem;
+ padding: 0.8rem 1rem;
+ cursor: grab;
+ transition: all 0.2s ease;
+ overflow: hidden;
+
+ &:hover {
+ background: var(--tertiary2, rgba(255, 255, 255, 0.08));
+ border-color: var(--primary1, #4a9eff);
+ box-shadow: 0 0.2rem 0.4rem rgba(0, 0, 0, 0.2);
+ }
+
+ &.dragging {
+ cursor: grabbing;
+ background: var(--primary1-alpha, rgba(74, 158, 255, 0.15));
+ border-color: var(--primary1, #4a9eff);
+ box-shadow: 0 0.8rem 1.6rem rgba(74, 158, 255, 0.3);
+ transform: rotate(3deg) scale(1.02);
+ z-index: 1000;
+ opacity: 0.95;
+ }
+
+ &.disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+
+ &:hover {
+ background: var(--tertiary1, rgba(0, 0, 0, 0.15));
+ border-color: var(--tertiary2, rgba(255, 255, 255, 0.1));
+ box-shadow: none;
+ }
+ }
+
+ &.selected {
+ background: var(--primary1-alpha, rgba(74, 158, 255, 0.1));
+ border-color: var(--primary1, #4a9eff);
+ box-shadow: 0 0.2rem 0.8rem rgba(74, 158, 255, 0.15);
+
+ .vessel-name {
+ color: var(--primary1, #4a9eff);
+ font-weight: var(--fw-bold, 700);
+ }
+ }
+
+ .vessel-item-content {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ gap: 0 0.8rem;
+ }
+
+ .vessel-index {
+ min-width: 2rem;
+ font-size: var(--fs-xs, 1.1rem);
+ color: var(--tertiary4, #888);
+ text-align: center;
+ }
+
+ .selection-checkbox {
+ flex-shrink: 0;
+ cursor: pointer;
+ position: relative;
+ display: flex;
+ align-items: center;
+
+ input[type="checkbox"] {
+ display: none;
+ }
+
+ .checkmark {
+ position: relative;
+ width: 1.8rem;
+ height: 1.8rem;
+ border: 2px solid var(--tertiary3, #555);
+ border-radius: 0.3rem;
+ background: transparent;
+ transition: all 0.2s ease;
+ display: block;
+
+ &::after {
+ content: '';
+ position: absolute;
+ left: 0.5rem;
+ top: 0.2rem;
+ width: 0.5rem;
+ height: 0.9rem;
+ border: solid var(--white, #fff);
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ }
+ }
+
+ input[type="checkbox"]:checked + .checkmark {
+ border: solid 2px var(--primary1, #4a9eff);
+ background-color: var(--primary1, #4a9eff);
+
+ &::after {
+ opacity: 1;
+ }
+ }
+
+ &:hover .checkmark {
+ border-color: var(--primary1, #4a9eff);
+ }
+ }
+
+
+ .vessel-info {
+ flex: 1;
+ min-width: 0;
+
+ .vessel-name {
+ font-size: var(--fs-s, 1.2rem);
+ font-weight: var(--fw-semibold, 600);
+ color: var(--white, #fff);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+
+ .name-text {
+ flex-shrink: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .country-flag {
+ width: 2rem;
+ height: auto;
+ flex-shrink: 0;
+ }
+
+ .ship-kind-icon {
+ width: 1.8rem;
+ height: 1.8rem;
+ flex-shrink: 0;
+ }
+
+ .signal-source {
+ color: var(--tertiary4, #888);
+ font-size: var(--fs-xs, 1.1rem);
+ font-weight: var(--fw-normal, 400);
+ flex-shrink: 0;
+ }
+ }
+
+ .vessel-details {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ font-size: var(--fs-xs, 1.1rem);
+ color: var(--tertiary4, #999);
+ margin-top: 0.2rem;
+
+ .ship-kind {
+ background: var(--tertiary2, rgba(255, 255, 255, 0.1));
+ padding: 0.2rem 0.6rem;
+ border-radius: 1rem;
+ font-size: var(--fs-xxs, 1rem);
+ font-weight: var(--fw-medium, 500);
+ color: var(--tertiary4, #ccc);
+ }
+ }
+ }
+
+ .drag-handle {
+ flex-shrink: 0;
+ color: var(--tertiary4, #666);
+ font-size: 1.2rem;
+ cursor: grab;
+ padding: 0.4rem;
+
+ &:hover {
+ color: var(--white, #ccc);
+ }
+ }
+
+ .drag-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: var(--primary1-alpha, rgba(74, 158, 255, 0.1));
+ border-radius: 0.6rem;
+ pointer-events: none;
+ }
+}
+
+.vessel-item-placeholder {
+ background: var(--tertiary1, rgba(0, 0, 0, 0.1));
+ border: 2px dashed var(--tertiary3, #444);
+ border-radius: 0.6rem;
+ height: 5rem;
+ margin-bottom: 0.4rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--tertiary4, #666);
+ font-size: var(--fs-xs, 1.1rem);
+}
diff --git a/src/replay/components/VesselListManager/VesselListManager.jsx b/src/replay/components/VesselListManager/VesselListManager.jsx
new file mode 100644
index 00000000..79ff002e
--- /dev/null
+++ b/src/replay/components/VesselListManager/VesselListManager.jsx
@@ -0,0 +1,309 @@
+/**
+ * 선박 목록 관리자 컴포넌트
+ * HTML5 드래그앤드롭을 통한 선박 상태 전환 인터페이스
+ * dark 프로젝트 스타일 적용
+ */
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { VesselState } from '../../types/replay.types';
+import { useVesselClassification } from './hooks/useVesselClassification';
+import { useVesselActions } from './hooks/useVesselActions';
+import VesselListPanel from './VesselListPanel';
+import VesselSearchFilter from './VesselSearchFilter';
+import VesselContextMenu from './VesselContextMenu';
+import useReplayStore from '../../stores/replayStore';
+import './VesselListManager.scss';
+
+export const VesselListManager = ({ className = '' }) => {
+ const { vesselsByState, totalCount, hasVessels } = useVesselClassification();
+ const { handleDragDrop } = useVesselActions();
+
+ // 필터링 및 선택 상태
+ const [filteredVessels, setFilteredVessels] = useState([]);
+ const [selectedVesselIds, setSelectedVesselIds] = useState(new Set());
+
+ // 패널 상태 (기본 열림)
+ const [isOpen, setIsOpen] = useState(true);
+ const [tap, setTap] = useState(1);
+
+ // 컨텍스트 메뉴 상태
+ const [contextMenu, setContextMenu] = useState(null);
+
+ // 전체 선박 목록 (필터링 전)
+ const allVessels = useMemo(() => {
+ return [...vesselsByState.normal, ...vesselsByState.selected, ...vesselsByState.deleted];
+ }, [vesselsByState]);
+
+ // 상태별로 필터링된 선박 분류
+ const filteredVesselsByState = useMemo(() => {
+ return {
+ normal: filteredVessels.filter(v => v.state === VesselState.NORMAL),
+ selected: filteredVessels.filter(v => v.state === VesselState.SELECTED),
+ deleted: filteredVessels.filter(v => v.state === VesselState.DELETED),
+ };
+ }, [filteredVessels]);
+
+ /**
+ * 지도 상의 항적 하이라이트 핸들러
+ */
+ const handleMouseEnterVessel = useCallback((vesselId) => {
+ // replayStore의 하이라이트 시스템 사용
+ useReplayStore.getState().setHighlightedVesselId(vesselId);
+ }, []);
+
+ const handleMouseLeaveVessel = useCallback((vesselId) => {
+ // 하이라이트 제거
+ useReplayStore.getState().setHighlightedVesselId(null);
+ }, []);
+
+ /**
+ * HTML5 드래그앤드롭 완료 핸들러
+ */
+ const onDrop = useCallback(
+ (vesselId, sourceState, targetState) => {
+ // 드래그앤드롭 결과를 상태 전환으로 변환
+ handleDragDrop({
+ vesselId,
+ sourceState: sourceState,
+ targetState: targetState,
+ });
+ },
+ [handleDragDrop],
+ );
+
+ /**
+ * 선박 선택/해제 핸들러
+ */
+ const handleToggleSelection = useCallback((vesselId, isSelected) => {
+ setSelectedVesselIds(prev => {
+ const newSet = new Set(prev);
+ if (isSelected) {
+ newSet.add(vesselId);
+ } else {
+ newSet.delete(vesselId);
+ }
+ return newSet;
+ });
+ }, []);
+
+ const openHandler = () => {
+ setIsOpen(!isOpen);
+ };
+
+ /**
+ * 선박 상세보기 핸들러 (컨텍스트 메뉴에서 호출)
+ */
+ const handleShowVesselDetail = useCallback(
+ (vesselId) => {
+ // 선박 정보 찾기
+ const vessel = allVessels.find(v => v.vesselId === vesselId);
+ if (vessel) {
+ console.log('[VesselListManager] 상세보기:', vessel);
+ // TODO: 상세 모달 구현
+ }
+ },
+ [allVessels],
+ );
+
+ /**
+ * 컨텍스트 메뉴 열기
+ */
+ const handleContextMenu = useCallback(
+ (vesselId, event) => {
+ const vessel = allVessels.find(v => v.vesselId === vesselId);
+ if (vessel) {
+ setContextMenu({
+ vessel,
+ position: { x: event.clientX, y: event.clientY },
+ });
+ }
+ },
+ [allVessels],
+ );
+
+ /**
+ * 컨텍스트 메뉴 닫기
+ */
+ const handleCloseContextMenu = useCallback(() => {
+ setContextMenu(null);
+ }, []);
+
+ /**
+ * 선박 일괄 상태 변경 핸들러
+ */
+ const handleBulkStateChange = useCallback(
+ (selectedVesselIds, targetState) => {
+ // 각 선박에 대해 상태 변경 실행
+ selectedVesselIds.forEach(vesselId => {
+ const vessel = allVessels.find(v => v.vesselId === vesselId);
+ if (vessel && vessel.state !== targetState) {
+ handleDragDrop({
+ vesselId,
+ sourceState: vessel.state,
+ targetState,
+ });
+ }
+ });
+ },
+ [allVessels, handleDragDrop],
+ );
+
+ /**
+ * 패널별 전체선택/해제 핸들러
+ */
+ const handleSelectAllInPanel = useCallback((vesselIds, isSelected) => {
+ setSelectedVesselIds(prev => {
+ const newSet = new Set(prev);
+
+ if (isSelected) {
+ // 선택: 해당 패널의 모든 선박 추가
+ vesselIds.forEach(id => newSet.add(id));
+ } else {
+ // 해제: 해당 패널의 모든 선박 제거
+ vesselIds.forEach(id => newSet.delete(id));
+ }
+
+ return newSet;
+ });
+ }, []);
+
+ // 탭 스타일 헬퍼
+ const getTabStyle = (tabIndex) => ({
+ flex: 1,
+ height: '100%',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ fontSize: '1.2rem',
+ fontWeight: '600',
+ color: tap === tabIndex ? '#fff' : 'rgba(255, 255, 255, 0.6)',
+ backgroundColor: tap === tabIndex ? 'rgba(74, 158, 255, 0.3)' : 'transparent',
+ borderBottom: tap === tabIndex ? '2px solid #4a9eff' : '2px solid transparent',
+ cursor: 'pointer',
+ transition: 'all 0.2s ease',
+ });
+
+ return (
+
+ {/* 헤더 */}
+
+
+ 분석 결과 - 선박 목록 {`(총 ${totalCount.toLocaleString()}척)`}
+
+
+
+
+ {/* 드래그앤드롭 컨테이너 - 데이터 유무와 관계없이 표시 */}
+ {isOpen && (
+
+ {/* 검색/필터 영역 */}
+
+
+ {/* 탭 헤더 */}
+
+
setTap(1)}
+ >
+ 기본 ({filteredVesselsByState.normal.length})
+
+
setTap(2)}
+ >
+ 선택 ({filteredVesselsByState.selected.length})
+
+
setTap(3)}
+ >
+ 삭제 ({filteredVesselsByState.deleted.length})
+
+
+
+
+ {tap === 1 && (
+
+ )}
+
+ {tap === 2 && (
+
+ )}
+
+ {tap === 3 && (
+
+ )}
+
+
+ )}
+
+ {/* 컨텍스트 메뉴 */}
+ {contextMenu && (
+
+ )}
+
+ );
+};
+
+export default React.memo(VesselListManager);
diff --git a/src/replay/components/VesselListManager/VesselListManager.scss b/src/replay/components/VesselListManager/VesselListManager.scss
new file mode 100644
index 00000000..953db181
--- /dev/null
+++ b/src/replay/components/VesselListManager/VesselListManager.scss
@@ -0,0 +1,204 @@
+// 선박 목록 관리자 스타일
+// dark 프로젝트 패밀리 스타일 적용
+
+.vessel-list-manager {
+ width: 100%;
+ border-radius: 0.6rem;
+ transition: all 0.3s ease;
+ background: var(--secondary1, rgba(255, 255, 255, 0.05));
+
+ &.closed {
+ .manager-content {
+ display: none;
+ }
+ }
+
+ // 헤더 토글
+ .manager-header-toggle {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.2rem 1.5rem;
+ border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
+ cursor: pointer;
+ transition: background-color 0.2s;
+
+ &:hover {
+ background-color: var(--tertiary1, rgba(255, 255, 255, 0.03));
+ }
+
+ .header-title {
+ font-size: var(--fs-m, 1.3rem);
+ font-weight: var(--fw-bold, 700);
+ color: var(--white, #fff);
+ }
+
+ .toggle-icon {
+ transition: transform 0.2s;
+
+ &.is-open {
+ transform: rotate(180deg);
+ }
+ }
+ }
+
+ .manager-content {
+ max-height: 50rem;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+
+ // 탭 헤더
+ .tab-header {
+ display: flex;
+ border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
+ margin-top: 0;
+
+ .tab-item {
+ flex: 1;
+ height: 3.5rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-size: var(--fs-s, 1.2rem);
+ font-weight: var(--fw-semibold, 600);
+ color: var(--tertiary4, rgba(255, 255, 255, 0.6));
+ background-color: transparent;
+ border-bottom: 2px solid transparent;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ color: var(--white, #fff);
+ background-color: var(--tertiary1, rgba(255, 255, 255, 0.05));
+ }
+
+ &.active {
+ color: var(--white, #fff);
+ background-color: var(--primary1-alpha, rgba(74, 158, 255, 0.15));
+ border-bottom-color: var(--primary1, #4a9eff);
+ }
+ }
+ }
+
+ .vessel-lists-container {
+ display: flex;
+ flex-direction: column;
+ padding: 1.5rem;
+ height: 32rem; // 고정 높이 (VesselListPanel 30rem + padding)
+ min-height: 32rem;
+ overflow: hidden; // 부모는 스크롤 없음 (자식 패널에서 스크롤)
+ }
+
+ .usage-hint {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.6rem;
+ padding: 0.8rem 1.5rem;
+ background: var(--tertiary1, rgba(0, 0, 0, 0.2));
+ border-top: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
+ color: var(--tertiary4, #999);
+ font-size: var(--fs-xs, 1.1rem);
+ font-style: italic;
+
+ i {
+ color: var(--primary1, #4a9eff);
+ }
+ }
+ }
+
+ // 레거시 스타일 (호환성)
+ .manager-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem 1.5rem;
+ background: var(--tertiary1, rgba(0, 0, 0, 0.2));
+ border-radius: 0.6rem 0.6rem 0 0;
+ border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
+
+ .header-left {
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+ flex: 1;
+ }
+
+ .toggle-button {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ background: none;
+ border: none;
+ color: var(--white, #fff);
+ font-size: var(--fs-m, 1.3rem);
+ font-weight: var(--fw-bold, 700);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ padding: 0.4rem 0.8rem;
+ border-radius: 0.4rem;
+ flex: 1;
+
+ &:hover {
+ background: var(--tertiary2, rgba(255, 255, 255, 0.1));
+ }
+
+ i {
+ font-size: 1.2rem;
+ transition: transform 0.2s ease;
+ }
+
+ span {
+ white-space: nowrap;
+ }
+ }
+
+ .vessel-counts {
+ display: flex;
+ align-items: center;
+ gap: 1.2rem;
+
+ .count-item {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: var(--fs-s, 1.2rem);
+ font-weight: var(--fw-medium, 500);
+
+ .icon {
+ font-size: 1.1rem;
+ }
+
+ &.normal {
+ color: #a8e6a3;
+ }
+
+ &.selected {
+ color: #87ceeb;
+ }
+
+ &.deleted {
+ color: #ffb3ba;
+ }
+ }
+ }
+ }
+
+ &.open {
+ .manager-header .toggle-button i {
+ transform: rotate(0deg);
+ }
+ }
+
+ &.closed {
+ .manager-header .toggle-button i {
+ transform: rotate(-90deg);
+ }
+
+ .manager-header {
+ border-radius: 0.6rem;
+ }
+ }
+}
diff --git a/src/replay/components/VesselListManager/VesselListPanel.jsx b/src/replay/components/VesselListManager/VesselListPanel.jsx
new file mode 100644
index 00000000..53c4aa09
--- /dev/null
+++ b/src/replay/components/VesselListManager/VesselListPanel.jsx
@@ -0,0 +1,183 @@
+/**
+ * 개별 선박 목록 패널 컴포넌트
+ * HTML5 드래그앤드롭 API를 사용하여 상태별(일반/선택/삭제) 선박 목록을 드롭 영역으로 표시
+ */
+import React, { useCallback, useMemo, useState } from 'react';
+import { VesselState } from '../../types/replay.types';
+import VesselItem from './VesselItem';
+import VirtualVesselList from './VirtualVesselList';
+import './VesselListPanel.scss';
+
+// 상태별 설정
+const STATE_CONFIG = {
+ [VesselState.NORMAL]: {
+ title: '기본 선박',
+ color: '#28a745',
+ emptyMessage: '기본 상태 선박이 없습니다',
+ },
+ [VesselState.SELECTED]: {
+ title: '선택 선박',
+ color: '#007bff',
+ emptyMessage: '선택된 선박이 없습니다',
+ },
+ [VesselState.DELETED]: {
+ title: '삭제 선박',
+ color: '#dc3545',
+ emptyMessage: '삭제된 선박이 없습니다',
+ },
+};
+
+const VesselListPanel = ({
+ state,
+ vessels,
+ title,
+ color,
+ emptyMessage,
+ selectedVesselIds = new Set(),
+ onDrop,
+ onMouseEnterVessel,
+ onMouseLeaveVessel,
+ onToggleSelection,
+ onShowVesselDetail,
+ onContextMenu,
+ onSelectAllInPanel,
+}) => {
+ const [isDragOver, setIsDragOver] = useState(false);
+ const [dragData, setDragData] = useState(null);
+
+ const config = STATE_CONFIG[state];
+ const panelTitle = title || config.title;
+ const panelColor = color || config.color;
+ const panelEmptyMessage = emptyMessage || config.emptyMessage;
+
+ // HTML5 드롭 이벤트 핸들러
+ const handleDragOver = useCallback((event) => {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ setIsDragOver(true);
+ }, []);
+
+ const handleDragEnter = useCallback((event) => {
+ event.preventDefault();
+ setIsDragOver(true);
+ }, []);
+
+ const handleDragLeave = useCallback((event) => {
+ // 실제로 컨테이너를 벗어날 때만 drag over 상태 해제
+ const rect = event.currentTarget.getBoundingClientRect();
+ const x = event.clientX;
+ const y = event.clientY;
+
+ if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
+ setIsDragOver(false);
+ }
+ }, []);
+
+ const handleDrop = useCallback(
+ (event) => {
+ event.preventDefault();
+ setIsDragOver(false);
+
+ try {
+ const data = JSON.parse(event.dataTransfer.getData('text/plain'));
+ const { vesselId, sourceState } = data;
+
+ if (vesselId && sourceState && sourceState !== state) {
+ onDrop?.(vesselId, sourceState, state);
+ }
+ } catch (error) {
+ // 드롭 데이터 파싱 실패 시 무시 (외부 드래그 소스일 수 있음)
+ }
+ },
+ [state, onDrop],
+ );
+
+ const handleVesselDragStart = useCallback((vesselId, sourceState) => {
+ setDragData({ vesselId, sourceState });
+ }, []);
+
+ const handleVesselDragEnd = useCallback(() => {
+ setDragData(null);
+ }, []);
+
+ // 패널 내 선택 상태 계산
+ const vesselIds = useMemo(() => vessels.map(v => v.vesselId), [vessels]);
+ const selectedInPanel = useMemo(
+ () => vesselIds.filter(id => selectedVesselIds.has(id)).length,
+ [vesselIds, selectedVesselIds],
+ );
+ const isAllSelected = vesselIds.length > 0 && selectedInPanel === vesselIds.length;
+ const isPartiallySelected = selectedInPanel > 0 && selectedInPanel < vesselIds.length;
+
+ // 패널별 전체선택/해제
+ const handleSelectAllInPanel = useCallback(
+ (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (!onSelectAllInPanel || vesselIds.length === 0) return;
+
+ onSelectAllInPanel(vesselIds, !isAllSelected);
+ },
+ [vesselIds, isAllSelected, onSelectAllInPanel],
+ );
+
+ return (
+
+ {/* 드롭 영역 */}
+
+ {/* 선박 목록 - 대용량 데이터는 가상 스크롤 적용 */}
+ {vessels.length > 0 ? (
+ vessels.length > 100 ? (
+ // 100척 초과 시 가상 스크롤링 사용
+
+ ) : (
+ // 100척 이하는 기존 방식 사용
+
+ {vessels.map((vessel, index) => (
+
+ ))}
+
+ )
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default React.memo(VesselListPanel);
diff --git a/src/replay/components/VesselListManager/VesselListPanel.scss b/src/replay/components/VesselListManager/VesselListPanel.scss
new file mode 100644
index 00000000..cb9fe446
--- /dev/null
+++ b/src/replay/components/VesselListManager/VesselListPanel.scss
@@ -0,0 +1,257 @@
+// 선박 목록 패널 스타일
+// dark 프로젝트 패밀리 스타일 적용
+
+.vessel-list-panel {
+ background: var(--tertiary1, rgba(0, 0, 0, 0.2));
+ border-radius: 0.6rem;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 30rem; // 고정 높이
+ border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
+
+ .panel-header {
+ padding: 1rem 1.2rem;
+ border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
+ background: var(--tertiary1, rgba(0, 0, 0, 0.15));
+ border-radius: 0.6rem 0.6rem 0 0;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ .panel-title {
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+ font-weight: var(--fw-semibold, 600);
+ font-size: var(--fs-s, 1.2rem);
+ color: var(--white, #fff);
+
+ .panel-icon {
+ font-size: 1.4rem;
+ }
+
+ .panel-text {
+ flex: 1;
+ }
+
+ .panel-count {
+ color: var(--tertiary4, #999);
+ font-weight: var(--fw-medium, 500);
+ font-size: var(--fs-xs, 1.1rem);
+ background: var(--tertiary2, rgba(255, 255, 255, 0.1));
+ padding: 0.2rem 0.8rem;
+ border-radius: 1.2rem;
+ }
+ }
+
+ .panel-select-all {
+ cursor: pointer;
+ padding: 0.4rem;
+ border-radius: 0.4rem;
+ transition: background-color 0.2s ease;
+
+ &:hover {
+ background-color: var(--tertiary2, rgba(255, 255, 255, 0.1));
+ }
+
+ .panel-select-checkbox {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ cursor: pointer;
+ font-size: var(--fs-xs, 1.1rem);
+ font-weight: var(--fw-medium, 500);
+ color: var(--tertiary4, #ccc);
+ user-select: none;
+
+ input[type="checkbox"] {
+ display: none;
+ }
+
+ .checkmark {
+ position: relative;
+ width: 1.6rem;
+ height: 1.6rem;
+ border: 2px solid var(--tertiary3, #555);
+ border-radius: 0.3rem;
+ background: transparent;
+ transition: all 0.2s ease;
+
+ &::after {
+ content: '';
+ position: absolute;
+ left: 0.4rem;
+ top: 0.1rem;
+ width: 0.4rem;
+ height: 0.7rem;
+ border: solid var(--white, #fff);
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ }
+ }
+
+ input[type="checkbox"]:checked + .checkmark {
+ background: var(--primary1, #4a9eff);
+ border-color: var(--primary1, #4a9eff);
+
+ &::after {
+ opacity: 1;
+ }
+ }
+
+ input[type="checkbox"]:indeterminate + .checkmark {
+ background: var(--tertiary3, #555);
+ border-color: var(--tertiary3, #555);
+
+ &::after {
+ left: 0.3rem;
+ top: 0.6rem;
+ width: 0.8rem;
+ height: 0.2rem;
+ border: none;
+ background: var(--white, #fff);
+ transform: none;
+ opacity: 1;
+ }
+ }
+
+ .select-text {
+ white-space: nowrap;
+ }
+ }
+ }
+ }
+
+ .vessel-list-container {
+ flex: 1;
+ border-radius: 0.6rem;
+ min-height: 0; // flex 자식이 제대로 축소되도록
+ overflow-y: auto;
+ transition: all 0.2s ease;
+ position: relative;
+
+ &.drag-over {
+ border-style: solid;
+ box-shadow: inset 0 0 0.8rem var(--primary1-alpha, rgba(74, 158, 255, 0.1));
+ transform: scale(1.01);
+ }
+
+ &.drag-target {
+ border-color: var(--primary1, #4a9eff) !important;
+ background-color: var(--primary1-alpha, rgba(74, 158, 255, 0.05)) !important;
+ transform: scale(1.01);
+ box-shadow: 0 0.4rem 2rem var(--primary1-alpha, rgba(74, 158, 255, 0.15));
+ }
+
+ .vessel-list {
+ display: flex;
+ flex-direction: column;
+ padding: 0.8rem;
+ }
+
+ .virtual-vessel-list {
+ padding: 0.8rem;
+ }
+
+ .empty-message {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ color: var(--tertiary4, #666);
+ text-align: center;
+
+ .empty-icon {
+ font-size: 3rem;
+ margin-bottom: 0.8rem;
+ opacity: 0.5;
+ }
+
+ .empty-text {
+ font-size: var(--fs-s, 1.2rem);
+ font-weight: var(--fw-medium, 500);
+ }
+ }
+
+ .drop-hint {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: var(--tertiary1, rgba(0, 0, 0, 0.8));
+ padding: 1.6rem;
+ border-radius: 0.8rem;
+ box-shadow: 0 0.4rem 0.8rem rgba(0, 0, 0, 0.3);
+ color: var(--primary1, #4a9eff);
+ font-weight: var(--fw-semibold, 600);
+ font-size: var(--fs-s, 1.2rem);
+ pointer-events: none;
+ z-index: 10;
+
+ i {
+ font-size: 2rem;
+ margin-bottom: 0.8rem;
+ animation: bounce 1s infinite;
+ }
+
+ @keyframes bounce {
+ 0%, 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-0.5rem);
+ }
+ }
+ }
+ }
+
+ // 스크롤바 스타일
+ .vessel-list-container::-webkit-scrollbar {
+ width: 0.6rem;
+ }
+
+ .vessel-list-container::-webkit-scrollbar-track {
+ background: var(--tertiary1, rgba(0, 0, 0, 0.2));
+ border-radius: 0.3rem;
+ }
+
+ .vessel-list-container::-webkit-scrollbar-thumb {
+ background: var(--tertiary3, #555);
+ border-radius: 0.3rem;
+
+ &:hover {
+ background: var(--tertiary4, #666);
+ }
+ }
+}
+
+// 상태별 테두리 색상
+.vessel-list-panel[data-state="NORMAL"] {
+ .panel-header {
+ border-left: 3px solid #4ade80;
+ }
+}
+
+.vessel-list-panel[data-state="SELECTED"] {
+ .panel-header {
+ border-left: 3px solid #4a9eff;
+ }
+}
+
+.vessel-list-panel[data-state="DELETED"] {
+ .panel-header {
+ border-left: 3px solid #f87171;
+ }
+}
diff --git a/src/replay/components/VesselListManager/VesselSearchFilter.jsx b/src/replay/components/VesselListManager/VesselSearchFilter.jsx
new file mode 100644
index 00000000..ed1de482
--- /dev/null
+++ b/src/replay/components/VesselListManager/VesselSearchFilter.jsx
@@ -0,0 +1,225 @@
+/**
+ * 선박 검색/필터 컴포넌트
+ * 선박명, 선종, 국적별 검색 및 필터링 기능
+ * dark 프로젝트 스타일 적용
+ */
+import React, { useCallback, useMemo, useState, useEffect } from 'react';
+import { VesselState } from '../../types/replay.types';
+import { getSortedCountryOptions } from './utils/countryCodeUtils';
+import './VesselSearchFilter.scss';
+
+// 선종 코드별 표시명
+const SHIP_KIND_NAMES = {
+ '000020': '어선',
+ '000021': '함정',
+ '000022': '여객선',
+ '000023': '화물선',
+ '000024': '유조선',
+ '000025': '관공선',
+ '000027': '기타',
+ '000028': '부이',
+};
+
+export const VesselSearchFilter = ({
+ vessels,
+ onFilteredVesselsChange,
+ onSelectedVesselsChange,
+ selectedVesselIds = new Set(),
+ onBulkStateChange,
+}) => {
+ const [searchText, setSearchText] = useState('');
+ const [selectedShipKind, setSelectedShipKind] = useState('');
+ const [selectedCountryGroup, setSelectedCountryGroup] = useState(null);
+ const [selectedState, setSelectedState] = useState('');
+
+ // 고유 선종 목록 추출
+ const availableShipKinds = useMemo(() => {
+ const kinds = new Set(vessels.map(v => v.shipKindCode));
+ return Array.from(kinds).map(code => ({
+ code,
+ name: SHIP_KIND_NAMES[code] || '기타',
+ }));
+ }, [vessels]);
+
+ // 고유 국가 코드 목록 추출 및 한글명 매핑
+ const availableCountryOptions = useMemo(() => {
+ const codes = vessels.map(v => v.nationalCode).filter(Boolean);
+ const uniqueCodes = Array.from(new Set(codes));
+ return getSortedCountryOptions(uniqueCodes);
+ }, [vessels]);
+
+ /**
+ * 다중 필터링 로직
+ * 4가지 필터를 순차적으로 적용 (AND 조건)
+ */
+ const filteredVessels = useMemo(() => {
+ let filtered = vessels;
+
+ // 텍스트 검색 (선박명, targetId)
+ if (searchText.trim()) {
+ const search = searchText.toLowerCase().trim();
+ filtered = filtered.filter(vessel => {
+ const shipName = (vessel.shipName || '').toLowerCase();
+ const targetId = (vessel.targetId || '').toLowerCase();
+ return shipName.includes(search) || targetId.includes(search);
+ });
+ }
+
+ // 선종 필터
+ if (selectedShipKind) {
+ filtered = filtered.filter(vessel => vessel.shipKindCode === selectedShipKind);
+ }
+
+ // 국적 필터
+ if (selectedCountryGroup) {
+ filtered = filtered.filter(vessel => selectedCountryGroup.codes.includes(vessel.nationalCode || ''));
+ }
+
+ // 상태 필터
+ if (selectedState) {
+ filtered = filtered.filter(vessel => vessel.state === selectedState);
+ }
+
+ return filtered;
+ }, [vessels, searchText, selectedShipKind, selectedCountryGroup, selectedState]);
+
+ // 필터링 결과 전달
+ useEffect(() => {
+ onFilteredVesselsChange(filteredVessels);
+ }, [filteredVessels, onFilteredVesselsChange]);
+
+ /**
+ * 전체 선택/해제 토글 핸들러
+ */
+ const handleSelectAll = useCallback(() => {
+ const newSelectedIds = new Set(selectedVesselIds);
+
+ if (filteredVessels.every(vessel => selectedVesselIds.has(vessel.vesselId))) {
+ filteredVessels.forEach(vessel => {
+ newSelectedIds.delete(vessel.vesselId);
+ });
+ } else {
+ filteredVessels.forEach(vessel => {
+ newSelectedIds.add(vessel.vesselId);
+ });
+ }
+
+ onSelectedVesselsChange?.(newSelectedIds);
+ }, [filteredVessels, selectedVesselIds, onSelectedVesselsChange]);
+
+ // 필터 초기화
+ const handleClearFilters = useCallback(() => {
+ setSearchText('');
+ setSelectedShipKind('');
+ setSelectedCountryGroup(null);
+ setSelectedState('');
+ }, []);
+
+ // 선택된 선박들의 상태 일괄 변경
+ const handleBulkStateChange = useCallback(
+ (targetState) => {
+ const selectedIds = Array.from(selectedVesselIds);
+ if (selectedIds.length > 0 && onBulkStateChange) {
+ onBulkStateChange(selectedIds, targetState);
+ onSelectedVesselsChange?.(new Set());
+ }
+ },
+ [selectedVesselIds, onBulkStateChange, onSelectedVesselsChange],
+ );
+
+ const isAllSelected =
+ filteredVessels.length > 0 && filteredVessels.every(vessel => selectedVesselIds.has(vessel.vesselId));
+ const isPartiallySelected = filteredVessels.some(vessel => selectedVesselIds.has(vessel.vesselId)) && !isAllSelected;
+
+ return (
+
+
+ {/* 필터 옵션 */}
+
+
+
+
+
+
+
+
+
+
+ setSearchText(e.target.value)}
+ className="search-input"
+ />
+
+
+
+
+ {/* 필터 결과 요약 */}
+ {selectedVesselIds.size > 0 && (
+
+
+
{selectedVesselIds.size}척 선택됨
+
+ 일괄 이동:
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default React.memo(VesselSearchFilter);
diff --git a/src/replay/components/VesselListManager/VesselSearchFilter.scss b/src/replay/components/VesselListManager/VesselSearchFilter.scss
new file mode 100644
index 00000000..7f0d6125
--- /dev/null
+++ b/src/replay/components/VesselListManager/VesselSearchFilter.scss
@@ -0,0 +1,364 @@
+// 선박 검색 필터 스타일
+// dark 프로젝트 패밀리 스타일 적용
+
+.vessel-search-filter-wrapper {
+ background: var(--tertiary1, rgba(0, 0, 0, 0.2));
+ border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
+}
+
+.vessel-search-filter {
+ background: transparent;
+ padding: 1rem 1.2rem;
+
+ .search-row {
+ display: flex;
+ align-items: center;
+ gap: 1.2rem;
+ margin-bottom: 1rem;
+
+ .search-input-group {
+ position: relative;
+ flex: 1;
+ min-width: 15rem;
+
+ .search-icon {
+ position: absolute;
+ left: 1rem;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--tertiary4, #777);
+ font-size: 1.2rem;
+ pointer-events: none;
+ }
+
+ .search-input {
+ width: 100%;
+ padding: 0.8rem 1.2rem 0.8rem 3rem;
+ border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
+ border-radius: 0.6rem;
+ font-size: var(--fs-s, 1.2rem);
+ background: var(--tertiary1, rgba(0, 0, 0, 0.3));
+ color: var(--white, #fff);
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+
+ &:focus {
+ outline: none;
+ border-color: var(--primary1, #4a9eff);
+ box-shadow: 0 0 0 2px var(--primary1-alpha, rgba(74, 158, 255, 0.1));
+ }
+
+ &::placeholder {
+ color: var(--tertiary4, #666);
+ }
+ }
+
+ .clear-search-btn {
+ position: absolute;
+ right: 0.8rem;
+ top: 50%;
+ transform: translateY(-50%);
+ background: none;
+ border: none;
+ color: var(--tertiary4, #777);
+ cursor: pointer;
+ padding: 0.4rem;
+ border-radius: 0.4rem;
+ font-size: 1.1rem;
+
+ &:hover {
+ color: var(--white, #ccc);
+ background: var(--tertiary2, rgba(255, 255, 255, 0.1));
+ }
+ }
+ }
+
+ .select-all-group {
+ .select-all-checkbox {
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+ cursor: pointer;
+ font-size: var(--fs-s, 1.2rem);
+ font-weight: var(--fw-medium, 500);
+ color: var(--tertiary4, #ccc);
+
+ input[type="checkbox"] {
+ display: none;
+ }
+
+ .checkmark {
+ position: relative;
+ width: 1.8rem;
+ height: 1.8rem;
+ border: 2px solid var(--tertiary3, #555);
+ border-radius: 0.4rem;
+ background: transparent;
+ transition: all 0.2s ease;
+
+ &::after {
+ content: '';
+ position: absolute;
+ left: 0.5rem;
+ top: 0.2rem;
+ width: 0.5rem;
+ height: 0.8rem;
+ border: solid var(--white, #fff);
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ }
+ }
+
+ input[type="checkbox"]:checked + .checkmark {
+ background: var(--primary1, #4a9eff);
+ border-color: var(--primary1, #4a9eff);
+
+ &::after {
+ opacity: 1;
+ }
+ }
+
+ input[type="checkbox"]:indeterminate + .checkmark {
+ background: var(--tertiary3, #555);
+ border-color: var(--tertiary3, #555);
+
+ &::after {
+ left: 0.3rem;
+ top: 0.7rem;
+ width: 1rem;
+ height: 0.2rem;
+ border: none;
+ background: var(--white, #fff);
+ transform: none;
+ opacity: 1;
+ }
+ }
+
+ .select-all-text {
+ white-space: nowrap;
+ }
+ }
+ }
+ }
+
+ .filter-row {
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+ width: 100%;
+
+ .filter-group {
+ display: flex;
+ align-items: center;
+ flex: 1;
+
+ label {
+ font-size: var(--fs-s, 1.2rem);
+ font-weight: var(--fw-medium, 500);
+ color: var(--tertiary4, #ccc);
+ white-space: nowrap;
+ }
+
+ .filter-select {
+ width: 100%;
+ padding: 0.7rem 0.8rem;
+ border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
+ border-radius: 0.4rem;
+ font-size: var(--fs-s, 1.2rem);
+ background: var(--bg-dark, rgba(0, 0, 0, 0.4));
+ color: var(--white, #fff);
+ transition: border-color 0.2s ease;
+ cursor: pointer;
+
+ &:focus {
+ outline: none;
+ border-color: var(--primary1, #4a9eff);
+ }
+
+ option {
+ background: #1a1a2e;
+ color: var(--white, #fff);
+ }
+ }
+
+ .search-input {
+ width: 100%;
+ padding: 0.7rem 0.8rem;
+ border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
+ border-radius: 0.4rem;
+ font-size: var(--fs-s, 1.2rem);
+ background: var(--bg-dark, rgba(0, 0, 0, 0.4));
+ color: var(--white, #fff);
+ transition: border-color 0.2s ease;
+
+ &:focus {
+ outline: none;
+ border-color: var(--primary1, #4a9eff);
+ }
+
+ &::placeholder {
+ color: var(--tertiary4, #666);
+ }
+ }
+ }
+
+ .clear-filters-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.6rem 1rem;
+ background: var(--tertiary1, rgba(0, 0, 0, 0.3));
+ border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
+ border-radius: 0.4rem;
+ font-size: var(--fs-s, 1.2rem);
+ color: var(--tertiary4, #999);
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover:not(:disabled) {
+ background: var(--tertiary2, rgba(255, 255, 255, 0.1));
+ border-color: var(--tertiary3, #666);
+ color: var(--white, #ccc);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ i {
+ font-size: 1.1rem;
+ }
+ }
+ }
+
+ @media (max-width: 768px) {
+ padding: 1rem;
+
+ .search-row {
+ flex-direction: column;
+ gap: 1rem;
+
+ .search-input-group {
+ width: 100%;
+ }
+
+ .select-all-group {
+ width: 100%;
+ }
+ }
+
+ .filter-row {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.8rem;
+
+ .filter-group {
+ justify-content: space-between;
+ width: 100%;
+
+ .filter-select {
+ flex: 1;
+ max-width: 15rem;
+ }
+ }
+
+ .clear-filters-btn {
+ align-self: flex-end;
+ }
+ }
+ }
+}
+
+.filter-summary {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: var(--fs-xs, 1.1rem);
+ color: var(--tertiary4, #999);
+ padding: 0.8rem 1.2rem;
+ background: var(--tertiary1, rgba(0, 0, 0, 0.15));
+ border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
+
+ .result-count {
+ font-weight: var(--fw-medium, 500);
+ }
+
+ .selected-actions {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+
+ .selected-count {
+ background: var(--primary1, #4a9eff);
+ color: var(--white, #fff);
+ padding: 0.5rem 1.2rem;
+ border-radius: 1.2rem;
+ font-weight: var(--fw-medium, 500);
+ }
+
+ .bulk-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+
+ .action-label {
+ font-size: var(--fs-xs, 1.1rem);
+ color: var(--white, #ccc);
+ font-weight: var(--fw-medium, 500);
+ }
+
+ .bulk-action-btn {
+ min-width: 5.5rem;
+ height: 3rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
+ border-radius: 0.4rem;
+ font-size: var(--fs-s, 1.2rem);
+ font-weight: var(--fw-semibold, 600);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ background-color: var(--tertiary1, rgba(0, 0, 0, 0.2));
+ color: var(--tertiary4, #ccc);
+
+ .icon {
+ font-size: 1.1rem;
+ }
+
+ &:hover {
+ background: var(--tertiary2, rgba(255, 255, 255, 0.1));
+ border-color: var(--tertiary3, #666);
+ }
+
+ &.normal {
+ &:hover {
+ background: rgba(74, 222, 128, 0.15);
+ border-color: #4ade80;
+ color: #4ade80;
+ }
+ }
+
+ &.selected {
+ &:hover {
+ background: rgba(74, 158, 255, 0.15);
+ border-color: #4a9eff;
+ color: #4a9eff;
+ }
+ }
+
+ &.deleted {
+ &:hover {
+ background: rgba(248, 113, 113, 0.15);
+ border-color: #f87171;
+ color: #f87171;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/replay/components/VesselListManager/VirtualVesselList.jsx b/src/replay/components/VesselListManager/VirtualVesselList.jsx
new file mode 100644
index 00000000..71222cfd
--- /dev/null
+++ b/src/replay/components/VesselListManager/VirtualVesselList.jsx
@@ -0,0 +1,102 @@
+/**
+ * 가상 스크롤링을 적용한 선박 목록 컴포넌트
+ * 1000척 이상의 대용량 데이터 렌더링 최적화
+ */
+import React, { useMemo } from 'react';
+import VesselItem from './VesselItem';
+import { useVirtualScroll } from './hooks/useVirtualScroll';
+
+const VirtualVesselList = ({
+ vessels,
+ selectedVesselIds = new Set(),
+ onDragStart,
+ onDragEnd,
+ onMouseEnterVessel,
+ onMouseLeaveVessel,
+ onToggleSelection,
+ onShowVesselDetail,
+ onContextMenu,
+ containerHeight = 300,
+ itemHeight = 40, // VesselItem의 대략적인 높이
+}) => {
+ const { containerRef, scrollElementRef, handleScroll, visibleItems, totalHeight, offsetY, startIndex, endIndex } =
+ useVirtualScroll({
+ items: vessels,
+ itemHeight,
+ containerHeight,
+ overscan: 5,
+ });
+
+ // 성능 최적화를 위한 메모이제이션
+ const renderedItems = useMemo(() => {
+ return visibleItems.map((vessel) => (
+
+
+
+ ));
+ }, [
+ visibleItems,
+ itemHeight,
+ selectedVesselIds,
+ onDragStart,
+ onDragEnd,
+ onMouseEnterVessel,
+ onMouseLeaveVessel,
+ onToggleSelection,
+ onShowVesselDetail,
+ onContextMenu,
+ ]);
+
+ if (vessels.length === 0) {
+ return null; // 빈 상태는 상위 컴포넌트에서 처리
+ }
+
+ return (
+
+ {/* 가상 컨테이너 (전체 높이 유지) */}
+
+ {/* 실제 렌더링되는 아이템들 */}
+ {renderedItems}
+
+
+ );
+};
+
+export default React.memo(VirtualVesselList);
diff --git a/src/replay/components/VesselListManager/hooks/useVesselActions.js b/src/replay/components/VesselListManager/hooks/useVesselActions.js
new file mode 100644
index 00000000..ba130838
--- /dev/null
+++ b/src/replay/components/VesselListManager/hooks/useVesselActions.js
@@ -0,0 +1,158 @@
+/**
+ * 선박 액션 관리 커스텀 훅
+ *
+ * @description 선박의 상태 전환(일반/선택/삭제)을 관리하는 React 훅입니다.
+ * ReplayV2 컴포넌트의 상태 전환 로직을 재사용하며, 드래그앤드롭 기능을 지원합니다.
+ *
+ * 선박 상태:
+ * - NORMAL: 일반 항적 (기본 상태)
+ * - SELECTED: 선택된 항적 (리플레이에 포함)
+ * - DELETED: 삭제된 항적 (숨김 처리)
+ *
+ * 상태 전환 규칙:
+ * - 각 선박은 세 가지 상태 중 하나만 가질 수 있음 (상호 배타적)
+ * - DELETE 액션: 일반/선택 → 삭제, 삭제 → 일반
+ * - INSERT 액션: 일반 → 선택, 삭제 → 선택, 선택 → 일반
+ *
+ * @module hooks/useVesselActions
+ */
+import { useCallback } from 'react';
+import useReplayStore from '../../../stores/replayStore';
+import { VesselState } from '../../../types/replay.types';
+
+/**
+ * 선박 액션 관리 훅
+ *
+ * @returns {Object} 선박 상태 관리 함수들
+ * @returns {Function} handleDragDrop - 드래그앤드롭 결과 처리 함수
+ * @returns {Function} setVesselState - 선박 상태 직접 설정 함수
+ * @returns {Function} handleVesselStateTransition - 상태 전환 로직 (디버깅용)
+ *
+ * @example
+ * const { handleDragDrop, setVesselState } = useVesselActions();
+ * setVesselState('vessel_001', VesselState.SELECTED);
+ */
+export const useVesselActions = () => {
+ /**
+ * 선박 상태 전환 로직
+ *
+ * @description ReplayV2의 상태 전환 로직을 재현합니다.
+ * 선박은 일반/선택/삭제 중 하나의 상태만 가질 수 있으며, 상태 간 전환은 DELETE/INSERT 액션으로 수행됩니다.
+ * replayStore.setVesselState를 사용하여 Map/Set 동기화 보장
+ *
+ * @param {string} vesselId - 선박 ID
+ * @param {'DELETE' | 'INSERT'} action - 수행할 액션
+ * @param {boolean} isCurrentlyDeleted - 현재 삭제 상태 여부
+ * @param {boolean} isCurrentlySelected - 현재 선택 상태 여부
+ * @private
+ */
+ const handleVesselStateTransition = useCallback(
+ (vesselId, action, isCurrentlyDeleted, isCurrentlySelected) => {
+ // 상태 전환 로직 (상호 배타적)
+ let targetState;
+
+ if (action === 'DELETE') {
+ if (isCurrentlyDeleted) {
+ // 삭제된 항적 → 일반 항적
+ targetState = VesselState.NORMAL;
+ } else {
+ // 선택된 항적 또는 일반 항적 → 삭제된 항적
+ targetState = VesselState.DELETED;
+ }
+ } else {
+ // action === 'INSERT'
+ if (isCurrentlyDeleted) {
+ // 삭제된 항적 → 선택된 항적
+ targetState = VesselState.SELECTED;
+ } else if (isCurrentlySelected) {
+ // 선택된 항적 → 일반 항적
+ targetState = VesselState.NORMAL;
+ } else {
+ // 일반 항적 → 선택된 항적
+ targetState = VesselState.SELECTED;
+ }
+ }
+
+ // replayStore.setVesselState 사용 (Map/Set 모두 동기화)
+ useReplayStore.getState().setVesselState(vesselId, targetState);
+ },
+ [],
+ );
+
+ /**
+ * 드래그앤드롭 결과 처리
+ *
+ * @description 드래그앤드롭 이벤트 결과를 상태 전환 로직으로 변환하여 처리합니다
+ * @param {Object} result - 드래그앤드롭 결과 (vesselId, sourceState, targetState)
+ */
+ const handleDragDrop = useCallback(
+ (result) => {
+ const { vesselId, sourceState, targetState } = result;
+
+ // 같은 상태로 드롭하면 무시
+ if (sourceState === targetState) return;
+
+ // 드롭 결과를 액션으로 변환
+ if (targetState === VesselState.DELETED) {
+ // 목표가 삭제 상태 → DELETE 액션
+ handleVesselStateTransition(
+ vesselId,
+ 'DELETE',
+ false, // 삭제 상태가 아니었음 (다른 상태에서 왔으므로)
+ sourceState === VesselState.SELECTED,
+ );
+ } else if (targetState === VesselState.SELECTED) {
+ // 목표가 선택 상태 → INSERT 액션
+ handleVesselStateTransition(
+ vesselId,
+ 'INSERT',
+ sourceState === VesselState.DELETED,
+ false, // 선택 상태가 아니었음 (다른 상태에서 왔으므로)
+ );
+ } else if (targetState === VesselState.NORMAL) {
+ // 목표가 일반 상태
+ if (sourceState === VesselState.DELETED) {
+ // 삭제 → 일반: DELETE 액션 (토글)
+ handleVesselStateTransition(vesselId, 'DELETE', true, false);
+ } else if (sourceState === VesselState.SELECTED) {
+ // 선택 → 일반: INSERT 액션 (토글)
+ handleVesselStateTransition(vesselId, 'INSERT', false, true);
+ }
+ }
+ },
+ [handleVesselStateTransition],
+ );
+
+ /**
+ * 선박 상태 직접 설정
+ *
+ * @description 개별 선박의 상태를 특정 상태로 직접 설정합니다
+ * @param {string} vesselId - 선박 ID
+ * @param {string} targetState - 목표 상태 (NORMAL, SELECTED, DELETED)
+ */
+ const setVesselState = useCallback((vesselId, targetState) => {
+ const { deletedVesselIds, selectedVesselIds } = useReplayStore.getState();
+
+ const currentlyDeleted = deletedVesselIds.has(vesselId);
+ const currentlySelected = selectedVesselIds.has(vesselId);
+ const currentState = currentlyDeleted
+ ? VesselState.DELETED
+ : currentlySelected
+ ? VesselState.SELECTED
+ : VesselState.NORMAL;
+
+ if (currentState === targetState) return; // 이미 해당 상태
+
+ handleDragDrop({
+ vesselId,
+ sourceState: currentState,
+ targetState,
+ });
+ }, [handleDragDrop]);
+
+ return {
+ handleDragDrop,
+ setVesselState,
+ handleVesselStateTransition, // 디버깅용
+ };
+};
diff --git a/src/replay/components/VesselListManager/hooks/useVesselClassification.js b/src/replay/components/VesselListManager/hooks/useVesselClassification.js
new file mode 100644
index 00000000..1b8e89f9
--- /dev/null
+++ b/src/replay/components/VesselListManager/hooks/useVesselClassification.js
@@ -0,0 +1,128 @@
+/**
+ * 선박 분류 관리 커스텀 훅
+ *
+ * @description 항적 데이터를 상태별(일반/선택/삭제)로 분류하여 제공하는 React 훅입니다.
+ * mergedTrackStore의 캐시된 선박 데이터를 구독하고, 각 선박의 상태에 따라 분류합니다.
+ *
+ * 주요 기능:
+ * - 선박 데이터를 상태별로 분류 (NORMAL, SELECTED, DELETED)
+ * - 실시간 스토어 변경 감지 및 자동 업데이트
+ * - 선박명 기준 정렬
+ * - 상태별 카운트 제공
+ *
+ * @module hooks/useVesselClassification
+ */
+import { useEffect, useMemo, useState } from 'react';
+import useReplayStore from '../../../stores/replayStore';
+import useMergedTrackStore from '../../../stores/mergedTrackStore';
+import { VesselState } from '../../../types/replay.types';
+
+/**
+ * 선박 분류 훅
+ *
+ * @returns {Object} 선박 분류 결과
+ * @returns {Object} vesselsByState - 상태별로 분류된 선박 목록
+ * @returns {number} totalCount - 전체 선박 수
+ * @returns {boolean} hasVessels - 선박 존재 여부
+ * @returns {Object} totalCounts - 상태별 선박 수
+ *
+ * @example
+ * const { vesselsByState, totalCount, hasVessels } = useVesselClassification();
+ * console.log(vesselsByState.normal); // 일반 상태 선박 목록
+ */
+export const useVesselClassification = () => {
+ const deletedVesselIds = useReplayStore(state => state.deletedVesselIds);
+ const selectedVesselIds = useReplayStore(state => state.selectedVesselIds);
+ const vesselStates = useReplayStore(state => state.vesselStates);
+
+ // mergedTrackStore 상태를 구독하기 위한 더미 상태
+ const [storeUpdateTrigger, setStoreUpdateTrigger] = useState(0);
+
+ // mergedTrackStore 변경 감지를 위한 useEffect
+ useEffect(() => {
+ const unsubscribe = useMergedTrackStore.subscribe(() => {
+ setStoreUpdateTrigger(prev => prev + 1);
+ });
+ return unsubscribe;
+ }, []);
+
+ const vesselsByState = useMemo(() => {
+ const normal = [];
+ const selected = [];
+ const deleted = [];
+
+ const vesselChunks = useMergedTrackStore.getState().vesselChunks;
+
+ // vesselChunks가 존재하지 않으면 빈 결과 반환
+ if (!vesselChunks || vesselChunks.size === 0) {
+ return { normal, selected, deleted };
+ }
+
+ vesselChunks.forEach((vesselInfo, vesselId) => {
+ if (!vesselInfo) return;
+
+ // 상태 결정 (새로운 시스템 우선, 레거시 시스템 폴백)
+ let state;
+ if (vesselStates instanceof Map && vesselStates.has(vesselId)) {
+ state = vesselStates.get(vesselId);
+ } else if (deletedVesselIds.has(vesselId)) {
+ state = VesselState.DELETED;
+ } else if (selectedVesselIds.has(vesselId)) {
+ state = VesselState.SELECTED;
+ } else {
+ state = VesselState.NORMAL;
+ }
+
+ // 선박 정보 생성
+ const vesselItem = {
+ vesselId,
+ targetId: vesselInfo.targetId || vesselId.split('_')[1] || vesselId, // 원본 데이터 우선, fallback으로 split
+ shipName: vesselInfo.shipName || 'Unknown',
+ shipKindCode: vesselInfo.shipKindCode || '000027',
+ nationalCode: vesselInfo.nationalCode || '000',
+ sigSrcCd: vesselInfo.sigSrcCd || '000001',
+ state,
+ lastPosition: vesselInfo.chunks?.[0]?.geometry?.[0], // 첫 번째 위치
+ lastUpdateTime: Date.now(),
+ };
+
+ // 상태별로 분류
+ switch (state) {
+ case VesselState.NORMAL:
+ normal.push(vesselItem);
+ break;
+ case VesselState.SELECTED:
+ selected.push(vesselItem);
+ break;
+ case VesselState.DELETED:
+ deleted.push(vesselItem);
+ break;
+ }
+ });
+
+ // 선박명 기준으로 정렬
+ const sortByName = (a, b) =>
+ a.shipName.localeCompare(b.shipName);
+
+ return {
+ normal: normal.sort(sortByName),
+ selected: selected.sort(sortByName),
+ deleted: deleted.sort(sortByName),
+ };
+ }, [deletedVesselIds, selectedVesselIds, vesselStates, storeUpdateTrigger]);
+
+ const totalCount = vesselsByState.normal.length + vesselsByState.selected.length + vesselsByState.deleted.length;
+ const hasVessels = totalCount > 0;
+
+ return {
+ vesselsByState,
+ totalCount,
+ hasVessels,
+ totalCounts: {
+ normal: vesselsByState.normal.length,
+ selected: vesselsByState.selected.length,
+ deleted: vesselsByState.deleted.length,
+ total: totalCount,
+ },
+ };
+};
diff --git a/src/replay/components/VesselListManager/hooks/useVirtualScroll.js b/src/replay/components/VesselListManager/hooks/useVirtualScroll.js
new file mode 100644
index 00000000..dfb20622
--- /dev/null
+++ b/src/replay/components/VesselListManager/hooks/useVirtualScroll.js
@@ -0,0 +1,87 @@
+/**
+ * 가상 스크롤링 커스텀 훅
+ *
+ * @description 대용량 리스트의 렌더링 성능을 최적화하는 가상 스크롤링 훅입니다.
+ * 화면에 보이는 항목만 렌더링하여 메모리 사용량과 렌더링 비용을 줄입니다.
+ *
+ * 주요 기능:
+ * - 화면에 보이는 영역만 렌더링 (windowing)
+ * - overscan을 통한 부드러운 스크롤 경험
+ * - 스크롤 위치 기반 동적 항목 계산
+ * - 고정 높이 아이템 지원
+ *
+ * @module hooks/useVirtualScroll
+ */
+import React, { useCallback, useMemo, useState, useRef } from 'react';
+
+/**
+ * 가상 스크롤링 훅
+ *
+ * @param {Object} options - 가상 스크롤 설정
+ * @param {any[]} options.items - 전체 아이템 배열
+ * @param {number} options.itemHeight - 각 아이템의 고정 높이 (px)
+ * @param {number} options.containerHeight - 컨테이너 높이 (px)
+ * @param {number} [options.overscan=5] - 화면 밖에서 미리 렌더링할 아이템 수
+ * @returns {Object} 가상 스크롤 결과 및 핸들러
+ *
+ * @example
+ * const { visibleItems, totalHeight, offsetY, handleScroll } = useVirtualScroll({
+ * items: vessels,
+ * itemHeight: 60,
+ * containerHeight: 400,
+ * overscan: 5
+ * });
+ */
+export const useVirtualScroll = ({
+ items,
+ itemHeight,
+ containerHeight,
+ overscan = 5
+}) => {
+ const [scrollTop, setScrollTop] = useState(0);
+
+ // 가상 스크롤 계산
+ const virtualData = useMemo(() => {
+ const totalHeight = items.length * itemHeight;
+
+ // 보이는 범위 계산
+ const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
+ const endIndex = Math.min(
+ items.length - 1,
+ Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
+ );
+
+ // 실제 렌더링할 아이템들
+ const visibleItems = items.slice(startIndex, endIndex + 1).map((item, index) => ({
+ ...item,
+ index: startIndex + index
+ }));
+
+ // 스크롤 오프셋
+ const offsetY = startIndex * itemHeight;
+
+ return {
+ totalHeight,
+ visibleItems,
+ offsetY,
+ startIndex,
+ endIndex
+ };
+ }, [items, itemHeight, containerHeight, scrollTop, overscan]);
+
+ // 스크롤 이벤트 핸들러
+ const handleScroll = useCallback((event) => {
+ setScrollTop(event.currentTarget.scrollTop);
+ }, []);
+
+ // Ref 생성
+ const containerRef = useRef(null);
+ const scrollElementRef = useRef(null);
+
+ return {
+ containerRef,
+ scrollElementRef,
+ handleScroll,
+ ...virtualData
+ };
+};
diff --git a/src/replay/components/VesselListManager/index.js b/src/replay/components/VesselListManager/index.js
new file mode 100644
index 00000000..4a7c0fb2
--- /dev/null
+++ b/src/replay/components/VesselListManager/index.js
@@ -0,0 +1,10 @@
+/**
+ * VesselListManager 컴포넌트 모듈
+ * 선박 분류 관리 (기본/선택/삭제)
+ */
+
+export { VesselListManager, default } from './VesselListManager';
+export { useVesselClassification } from './hooks/useVesselClassification';
+export { useVesselActions } from './hooks/useVesselActions';
+export { useVirtualScroll } from './hooks/useVirtualScroll';
+export * from './utils/countryCodeUtils';
diff --git a/src/replay/components/VesselListManager/utils/countryCodeUtils.js b/src/replay/components/VesselListManager/utils/countryCodeUtils.js
new file mode 100644
index 00000000..8ce2ac72
--- /dev/null
+++ b/src/replay/components/VesselListManager/utils/countryCodeUtils.js
@@ -0,0 +1,396 @@
+/**
+ * MMSI MID 코드와 국가명 매핑 유틸리티
+ * 참조: https://www.vtexplorer.com/mmsi-mid-codes-en/
+ */
+
+// MMSI MID 코드와 한글 국가명 매핑
+export const MMSI_COUNTRY_NAMES = {
+ // 유럽
+ '201': '알바니아',
+ '202': '안도라',
+ '203': '오스트리아',
+ '204': '아조레스',
+ '205': '벨기에',
+ '206': '벨라루스',
+ '207': '불가리아',
+ '208': '바티칸',
+ '209': '키프로스',
+ '210': '키프로스',
+ '211': '독일',
+ '212': '키프로스',
+ '213': '조지아',
+ '214': '몰도바',
+ '215': '몰타',
+ '216': '아르메니아',
+ '218': '독일',
+ '219': '덴마크',
+ '220': '덴마크',
+ '224': '스페인',
+ '225': '스페인',
+ '226': '프랑스',
+ '227': '프랑스',
+ '228': '프랑스',
+ '230': '핀란드',
+ '231': '페로 제도',
+ '232': '영국',
+ '233': '영국',
+ '234': '영국',
+ '235': '영국',
+ '236': '지브롤터',
+ '237': '그리스',
+ '238': '크로아티아',
+ '239': '그리스',
+ '240': '그리스',
+ '241': '그리스',
+ '242': '모로코',
+ '243': '헝가리',
+ '244': '네덜란드',
+ '245': '네덜란드',
+ '246': '네덜란드',
+ '247': '이탈리아',
+ '248': '몰타',
+ '249': '몰타',
+ '250': '아일랜드',
+ '251': '아이슬란드',
+ '252': '리히텐슈타인',
+ '253': '룩셈부르크',
+ '254': '모나코',
+ '255': '마데이라',
+ '256': '몰타',
+ '257': '노르웨이',
+ '258': '노르웨이',
+ '259': '노르웨이',
+ '261': '폴란드',
+ '262': '몬테네그로',
+ '263': '포르투갈',
+ '264': '루마니아',
+ '265': '스웨덴',
+ '266': '스웨덴',
+ '267': '슬로바키아',
+ '268': '산마리노',
+ '269': '스위스',
+ '270': '체코',
+ '271': '터키',
+ '272': '우크라이나',
+ '273': '러시아',
+ '274': '북마케도니아',
+ '275': '라트비아',
+ '276': '에스토니아',
+ '277': '리투아니아',
+ '278': '슬로베니아',
+ '279': '세르비아',
+
+ // 아시아-태평양
+ '301': '안길라',
+ '303': '알래스카',
+ '304': '안티구아 바부다',
+ '305': '안티구아 바부다',
+ '306': '아루바',
+ '307': '아루바',
+ '308': '바하마',
+ '309': '바하마',
+ '310': '버뮤다',
+ '311': '바하마',
+ '312': '벨리즈',
+ '314': '바베이도스',
+ '316': '캐나다',
+ '319': '케이맨 제도',
+ '321': '코스타리카',
+ '323': '쿠바',
+ '325': '도미니카',
+ '327': '도미니카 공화국',
+ '329': '과들루프',
+ '330': '그레나다',
+ '331': '그린란드',
+ '332': '과테말라',
+ '334': '온두라스',
+ '336': '아이티',
+ '338': '미국',
+ '339': '자메이카',
+ '341': '세인트키츠 네비스',
+ '343': '세인트루시아',
+ '345': '멕시코',
+ '347': '마르티니크',
+ '348': '몬세라트',
+ '350': '니카라과',
+ '351': '파나마',
+ '352': '파나마',
+ '353': '파나마',
+ '354': '파나마',
+ '355': '파나마',
+ '356': '파나마',
+ '357': '파나마',
+ '358': '푸에르토리코',
+ '359': '엘살바도르',
+ '361': '생피에르 미클롱',
+ '362': '트리니다드 토바고',
+ '364': '터크스 케이커스',
+ '366': '미국',
+ '367': '미국',
+ '368': '미국',
+ '369': '미국',
+ '370': '파나마',
+ '371': '파나마',
+ '372': '파나마',
+ '373': '파나마',
+ '374': '파나마',
+ '375': '세인트빈센트',
+ '376': '세인트빈센트',
+ '377': '세인트빈센트',
+ '378': '영국령 버진아일랜드',
+
+ // 아시아
+ '401': '아프가니스탄',
+ '403': '사우디아라비아',
+ '405': '방글라데시',
+ '408': '바레인',
+ '410': '부탄',
+ '412': '중국',
+ '413': '중국',
+ '414': '중국',
+ '416': '대만',
+ '417': '스리랑카',
+ '419': '인도',
+ '422': '이란',
+ '423': '아제르바이잔',
+ '425': '이라크',
+ '428': '이스라엘',
+ '431': '일본',
+ '432': '일본',
+ '434': '투르크메니스탄',
+ '436': '카자흐스탄',
+ '437': '우즈베키스탄',
+ '438': '요단',
+ '440': '대한민국',
+ '441': '대한민국',
+ '443': '팔레스타인',
+ '445': '북한',
+ '447': '쿠웨이트',
+ '450': '레바논',
+ '451': '키르기스스탄',
+ '453': '마카오',
+ '455': '몰디브',
+ '457': '몽골',
+ '459': '네팔',
+ '461': '오만',
+ '463': '파키스탄',
+ '466': '카타르',
+ '468': '시리아',
+ '470': '아랍에미리트',
+ '472': '타지키스탄',
+ '473': '예멘',
+ '475': '예멘',
+ '477': '홍콩',
+
+ // 오세아니아
+ '503': '호주',
+ '506': '미얀마',
+ '508': '브루나이',
+ '510': '미크로네시아',
+ '511': '팔라우',
+ '512': '뉴질랜드',
+ '514': '캄보디아',
+ '515': '캄보디아',
+ '516': '크리스마스 섬',
+ '518': '쿡 제도',
+ '520': '피지',
+ '523': '코코스 제도',
+ '525': '인도네시아',
+ '529': '키리바시',
+ '531': '라오스',
+ '533': '말레이시아',
+ '536': '북마리아나',
+ '538': '마셜 제도',
+ '540': '뉴칼레도니아',
+ '542': '니우에',
+ '544': '나우루',
+ '546': '프랑스령 폴리네시아',
+ '548': '필리핀',
+ '553': '파푸아뉴기니',
+ '555': '피트케언',
+ '557': '솔로몬 제도',
+ '559': '아메리칸 사모아',
+ '561': '사모아',
+ '563': '싱가포르',
+ '564': '싱가포르',
+ '565': '싱가포르',
+ '566': '싱가포르',
+ '567': '타이',
+ '570': '통가',
+ '572': '투발루',
+ '574': '베트남',
+ '576': '바누아투',
+ '577': '바누아투',
+ '578': '월리스 푸투나',
+
+ // 아프리카
+ '601': '남아프리카공화국',
+ '603': '앙골라',
+ '605': '알제리',
+ '607': '생폴',
+ '608': '어센션 섬',
+ '609': '부룬디',
+ '610': '베냉',
+ '611': '보츠와나',
+ '612': '중앙아프리카공화국',
+ '613': '카메룬',
+ '615': '콩고민주공화국',
+ '616': '코모로',
+ '617': '카보베르데',
+ '618': '코트디부아르',
+ '619': '지부티',
+ '620': '이집트',
+ '621': '적도기니',
+ '622': '에티오피아',
+ '624': '가봉',
+ '625': '가나',
+ '627': '감비아',
+ '629': '기니비사우',
+ '630': '기니',
+ '631': '케냐',
+ '632': '라이베리아',
+ '633': '라이베리아',
+ '634': '라이베리아',
+ '635': '라이베리아',
+ '636': '라이베리아',
+ '637': '라이베리아',
+ '642': '리비아',
+ '644': '레소토',
+ '645': '모리타니',
+ '647': '마다가스카르',
+ '649': '말리',
+ '650': '모잠비크',
+ '654': '모리셔스',
+ '655': '말라위',
+ '656': '세이셸',
+ '657': '나이지리아',
+ '659': '니제르',
+ '660': '르완다',
+ '661': '수단',
+ '662': '세네갈',
+ '663': '차드',
+ '664': '토고',
+ '665': '튀니지',
+ '666': '탄자니아',
+ '667': '우간다',
+ '668': '남아프리카공화국',
+ '669': '남아프리카공화국',
+ '670': '에리트레아',
+ '671': '남수단',
+ '674': '잠비아',
+ '675': '짐바브웨',
+ '676': '소말리아',
+ '677': '탄자니아',
+ '678': '콩고',
+
+ // 남미
+ '701': '아르헨티나',
+ '710': '브라질',
+ '720': '볼리비아',
+ '725': '칠레',
+ '730': '콜롬비아',
+ '735': '에콰도르',
+ '740': '포클랜드 제도',
+ '745': '프랑스령 기아나',
+ '750': '가이아나',
+ '755': '파라과이',
+ '760': '페루',
+ '765': '수리남',
+ '770': '우루과이',
+ '775': '베네수엘라',
+
+ // 기타
+ '000': '알 수 없음',
+ '999': '기타',
+};
+
+/**
+ * MMSI MID 코드로부터 한글 국가명을 반환
+ * @param {string} nationalCode MMSI MID 코드 (3자리 문자열)
+ * @returns {string} 한글 국가명 또는 "알 수 없음"
+ */
+export const getCountryNameFromCode = (nationalCode) => {
+ if (!nationalCode || nationalCode.length !== 3) {
+ return '알 수 없음';
+ }
+
+ return MMSI_COUNTRY_NAMES[nationalCode] || `알 수 없음`;
+};
+
+/**
+ * 국가 코드와 한글명을 함께 표시하는 형식으로 반환
+ * @param {string} nationalCode MMSI MID 코드
+ * @returns {string} "한글국가명 (코드)" 형식 문자열
+ */
+export const getCountryDisplayName = (nationalCode) => {
+ if (!nationalCode || nationalCode.length !== 3) {
+ return '알 수 없음';
+ }
+
+ const countryName = MMSI_COUNTRY_NAMES[nationalCode];
+ if (countryName) {
+ return `${countryName} (${nationalCode})`;
+ }
+
+ return `알 수 없음`;
+};
+
+/**
+ * 검색 필터에서 사용할 국가 목록을 반환 (국가별로 그룹화)
+ * @param {string[]} availableCodes 현재 데이터에서 사용 중인 국가 코드들
+ * @returns {Array} 그룹화되고 정렬된 국가 목록 배열
+ */
+export const getSortedCountryOptions = (availableCodes) => {
+ // 국가명별로 코드들을 그룹화
+ const countryGroups = new Map();
+
+ availableCodes.forEach(code => {
+ const countryName = MMSI_COUNTRY_NAMES[code] || `알 수 없음`;
+ if (!countryGroups.has(countryName)) {
+ countryGroups.set(countryName, []);
+ }
+ countryGroups.get(countryName).push(code);
+ });
+
+ // CountryGroup 배열로 변환
+ const groupedCountries = Array.from(countryGroups.entries()).map(([countryName, codes]) => {
+ codes.sort(); // 코드 정렬
+
+ const displayName = codes.length === 1 ? `${countryName} (${codes[0]})` : `${countryName} (${codes.join(', ')})`;
+
+ // 긴 텍스트 처리 (30자 제한)
+ const truncatedName = displayName.length > 30 ? `${displayName.substring(0, 27)}...` : displayName;
+
+ return {
+ countryName,
+ codes,
+ displayName,
+ truncatedName,
+ };
+ });
+
+ // 정렬
+ return groupedCountries.sort((a, b) => {
+ // 한국을 맨 위에 배치
+ const aIsKorea = a.codes.some(code => code === '440' || code === '441');
+ const bIsKorea = b.codes.some(code => code === '440' || code === '441');
+
+ if (aIsKorea && !bIsKorea) return -1;
+ if (!aIsKorea && bIsKorea) return 1;
+
+ // 그 다음 알파벳 순으로 정렬
+ return a.countryName.localeCompare(b.countryName, 'ko');
+ });
+};
+
+/**
+ * 선택된 국가의 모든 코드가 포함된 선박을 필터링하는 헬퍼 함수
+ * @param {Array} vessels 선박 목록
+ * @param {Object|null} selectedCountryGroup 선택된 국가 그룹
+ * @returns {Array} 필터링된 선박 목록
+ */
+export const filterVesselsByCountryGroup = (vessels, selectedCountryGroup) => {
+ if (!selectedCountryGroup) return vessels;
+
+ return vessels.filter(vessel => selectedCountryGroup.codes.includes(vessel.nationalCode));
+};
diff --git a/src/replay/hooks/useReplayLayer.js b/src/replay/hooks/useReplayLayer.js
new file mode 100644
index 00000000..c0805dd2
--- /dev/null
+++ b/src/replay/hooks/useReplayLayer.js
@@ -0,0 +1,441 @@
+/**
+ * 리플레이 Deck.gl 레이어 관리 훅
+ * 참조: mda-react-front/src/tracking/components/ReplayV2.tsx
+ *
+ * 구조:
+ * 1. queryCompleted → 정적 항적 레이어 생성
+ * 2. animationStore.currentTime 변경 → requestAnimatedRender()
+ * 3. animationStore.getCurrentVesselPositions() → 현재 위치 계산
+ * 4. deck.gl 레이어 생성 → 전역 레지스트리 등록 → shipBatchRenderer.immediateRender()
+ *
+ * 필터링:
+ * - filterModules.custom: 선박 아이콘 표시
+ * - filterModules.path: 항적 라인 표시
+ * - filterModules.label: 선명 라벨 표시
+ * - shipKindCodeFilter: 선종 필터
+ * - vesselStates: 선박 상태 (NORMAL/SELECTED/DELETED)
+ */
+import { useEffect, useRef, useCallback } from 'react';
+import { ScatterplotLayer } from '@deck.gl/layers';
+import useMergedTrackStore from '../stores/mergedTrackStore';
+import useAnimationStore from '../stores/animationStore';
+import useReplayStore from '../stores/replayStore';
+import usePlaybackTrailStore from '../stores/playbackTrailStore';
+import useShipStore from '../../stores/shipStore';
+import { VesselState, FilterModuleType } from '../types/replay.types';
+import { createStaticTrackLayers, createVirtualShipLayers } from '../../map/layers/trackLayer';
+import { registerReplayLayers, unregisterReplayLayers } from '../utils/replayLayerRegistry';
+import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
+import { hideLiveShips, showLiveShips } from '../../utils/liveControl';
+
+/**
+ * 선박 상태에 따라 표시 여부 결정
+ * @param {string} vesselId - 선박 ID
+ * @param {Object} filterModule - 필터 모듈 설정 (showNormal, showSelected, showDeleted)
+ * @param {Map} vesselStates - 선박 상태 맵
+ * @param {Set} deletedVesselIds - 삭제된 선박 Set (레거시)
+ * @param {Set} selectedVesselIds - 선택된 선박 Set (레거시)
+ * @returns {boolean} 표시 여부
+ */
+function shouldShowVessel(vesselId, filterModule, vesselStates, deletedVesselIds, selectedVesselIds) {
+ // 선박 상태 확인
+ let state = VesselState.NORMAL;
+ if (vesselStates.has(vesselId)) {
+ state = vesselStates.get(vesselId);
+ } else if (deletedVesselIds.has(vesselId)) {
+ state = VesselState.DELETED;
+ } else if (selectedVesselIds.has(vesselId)) {
+ state = VesselState.SELECTED;
+ }
+
+ // 상태에 따라 필터 적용
+ switch (state) {
+ case VesselState.NORMAL:
+ return filterModule.showNormal;
+ case VesselState.SELECTED:
+ return filterModule.showSelected;
+ case VesselState.DELETED:
+ return filterModule.showDeleted;
+ default:
+ return filterModule.showNormal;
+ }
+}
+
+/**
+ * 리플레이 레이어 훅
+ */
+export default function useReplayLayer() {
+ const staticLayersRef = useRef([]);
+ const tracksRef = useRef([]);
+
+ const queryCompleted = useReplayStore((s) => s.queryCompleted);
+
+ // animationStore에서 currentTime, playbackSpeed 구독 (ReplayV2.tsx와 동일)
+ const currentTime = useAnimationStore((s) => s.currentTime);
+ const playbackSpeed = useAnimationStore((s) => s.playbackSpeed);
+
+ // 필터 상태 구독
+ const filterModules = useReplayStore((s) => s.filterModules);
+ const shipKindCodeFilter = useReplayStore((s) => s.shipKindCodeFilter);
+ const vesselStates = useReplayStore((s) => s.vesselStates);
+ const deletedVesselIds = useReplayStore((s) => s.deletedVesselIds);
+ const selectedVesselIds = useReplayStore((s) => s.selectedVesselIds);
+
+ // 하이라이트 상태 구독
+ const highlightedVesselId = useReplayStore((s) => s.highlightedVesselId);
+ const setHighlightedVesselId = useReplayStore((s) => s.setHighlightedVesselId);
+
+ /**
+ * 항적/아이콘 호버 시 하이라이트 설정
+ */
+ const handlePathHover = useCallback((vesselId) => {
+ setHighlightedVesselId(vesselId);
+ }, [setHighlightedVesselId]);
+
+ /**
+ * 가상 선박 아이콘 호버 시 툴팁 표시
+ */
+ const handleIconHover = useCallback((shipData, x, y) => {
+ if (shipData) {
+ // ShipTooltip 형식에 맞게 변환하여 shipStore에 저장
+ useShipStore.getState().setHoverInfo({
+ ship: {
+ shipName: shipData.shipName,
+ targetId: shipData.vesselId?.split('_').pop() || shipData.vesselId,
+ signalKindCode: shipData.shipKindCode,
+ sog: shipData.speed || 0,
+ cog: shipData.heading || 0,
+ },
+ x,
+ y,
+ });
+ // 하이라이트도 설정
+ setHighlightedVesselId(shipData.vesselId);
+ } else {
+ useShipStore.getState().setHoverInfo(null);
+ setHighlightedVesselId(null);
+ }
+ }, [setHighlightedVesselId]);
+
+ /**
+ * 트랙 필터링
+ * @param {Array} tracks - 전체 트랙 배열
+ * @returns {Array} 필터링된 트랙 배열
+ */
+ const filterTracks = useCallback((tracks) => {
+ const replayState = useReplayStore.getState();
+ const { filterModules, shipKindCodeFilter, vesselStates, deletedVesselIds, selectedVesselIds } = replayState;
+ const pathFilter = filterModules[FilterModuleType.PATH];
+
+ return tracks.filter((track) => {
+ // 1. 선종 필터
+ if (!shipKindCodeFilter.has(track.shipKindCode)) {
+ return false;
+ }
+
+ // 2. 상태 필터 (항적)
+ return shouldShowVessel(
+ track.vesselId,
+ pathFilter,
+ vesselStates,
+ deletedVesselIds,
+ selectedVesselIds
+ );
+ });
+ }, []);
+
+ /**
+ * 현재 위치 필터링
+ * @param {Array} positions - 전체 위치 배열
+ * @returns {Object} { iconPositions, labelPositions }
+ */
+ const filterPositions = useCallback((positions) => {
+ const replayState = useReplayStore.getState();
+ const { filterModules, shipKindCodeFilter, vesselStates, deletedVesselIds, selectedVesselIds } = replayState;
+ const customFilter = filterModules[FilterModuleType.CUSTOM];
+ const labelFilter = filterModules[FilterModuleType.LABEL];
+
+ const iconPositions = [];
+ const labelPositions = [];
+
+ positions.forEach((pos) => {
+ // 1. 선종 필터
+ if (!shipKindCodeFilter.has(pos.shipKindCode)) {
+ return;
+ }
+
+ // 2. 아이콘 필터 (CUSTOM)
+ if (shouldShowVessel(
+ pos.vesselId,
+ customFilter,
+ vesselStates,
+ deletedVesselIds,
+ selectedVesselIds
+ )) {
+ iconPositions.push(pos);
+ }
+
+ // 3. 라벨 필터 (LABEL)
+ if (shouldShowVessel(
+ pos.vesselId,
+ labelFilter,
+ vesselStates,
+ deletedVesselIds,
+ selectedVesselIds
+ )) {
+ labelPositions.push(pos);
+ }
+ });
+
+ return { iconPositions, labelPositions };
+ }, []);
+
+ /**
+ * 애니메이션 렌더링 요청
+ * animationStore.getCurrentVesselPositions() 사용 (메인 프로젝트 동일)
+ */
+ const requestAnimatedRender = useCallback(() => {
+ // getCurrentVesselPositions로 현재 시간의 선박 위치 계산
+ const currentPositions = useAnimationStore.getState().getCurrentVesselPositions();
+
+ if (currentPositions.length === 0 && tracksRef.current.length === 0) {
+ return;
+ }
+
+ // position → lon/lat 변환 (createVirtualShipLayers 형식에 맞춤)
+ const formattedPositions = currentPositions.map((pos) => ({
+ vesselId: pos.vesselId,
+ lon: pos.position[0],
+ lat: pos.position[1],
+ heading: pos.heading,
+ speed: pos.speed,
+ shipKindCode: pos.shipKindCode,
+ shipName: pos.shipName,
+ }));
+
+ // 위치 필터링
+ const { iconPositions, labelPositions } = filterPositions(formattedPositions);
+
+ // 트랙 필터링
+ const filteredTracks = filterTracks(tracksRef.current);
+
+ // ===== 항적표시 기능 (playbackTrailStore) =====
+ const trailStore = usePlaybackTrailStore.getState();
+ const layers = [];
+
+ // 항적표시가 활성화되어 있으면 프레임 기록
+ if (trailStore.isEnabled && iconPositions.length > 0) {
+ trailStore.recordFrame(
+ iconPositions.map((pos) => ({
+ vesselId: pos.vesselId,
+ lon: pos.lon,
+ lat: pos.lat,
+ }))
+ );
+ }
+
+ // 항적표시 레이어 생성 (가장 먼저 추가 - 맨 아래 레이어)
+ if (trailStore.isEnabled) {
+ const trailPoints = trailStore.getVisiblePoints();
+ if (trailPoints.length > 0) {
+ const trailLayer = new ScatterplotLayer({
+ id: 'replay-playback-trail',
+ data: trailPoints,
+ pickable: false,
+ stroked: false,
+ filled: true,
+ radiusUnits: 'pixels',
+ getPosition: (d) => [d.lon, d.lat],
+ getRadius: (d) => trailStore.getPointSize(d.frameIndex),
+ getFillColor: (d) => {
+ const opacity = trailStore.getOpacity(d.frameIndex);
+ return [60, 60, 60, Math.floor(opacity * 180)]; // 회색 점, 투명도 0-180
+ },
+ updateTriggers: {
+ getRadius: [trailStore.frameIndex],
+ getFillColor: [trailStore.frameIndex],
+ },
+ });
+ layers.push(trailLayer);
+ }
+ }
+
+ // 현재 하이라이트 상태 가져오기
+ const currentHighlightedVesselId = useReplayStore.getState().highlightedVesselId;
+
+ // 정적 레이어 재생성 (필터 적용 + 하이라이트)
+ const staticLayers = createStaticTrackLayers({
+ tracks: filteredTracks,
+ showPoints: false,
+ highlightedVesselId: currentHighlightedVesselId,
+ onPathHover: handlePathHover,
+ });
+
+ // 동적 레이어 생성 (아이콘/라벨 분리 + 호버 콜백)
+ const dynamicLayers = createVirtualShipLayers({
+ currentPositions: iconPositions,
+ showVirtualShip: iconPositions.length > 0,
+ showLabels: false,
+ onIconHover: handleIconHover,
+ onPathHover: handlePathHover,
+ });
+
+ // 라벨 레이어 별도 생성 (라벨 필터 적용)
+ const labelLayers = createVirtualShipLayers({
+ currentPositions: labelPositions,
+ showVirtualShip: false,
+ showLabels: labelPositions.length > 0,
+ });
+
+ registerReplayLayers([...layers, ...staticLayers, ...dynamicLayers, ...labelLayers]);
+ shipBatchRenderer.immediateRender();
+ }, [filterTracks, filterPositions, handlePathHover, handleIconHover]);
+
+ /**
+ * 쿼리 완료 시 라이브 선박 숨기기 + 정적 레이어 생성 + 초기 위치 렌더
+ */
+ useEffect(() => {
+ if (!queryCompleted) {
+ // queryCompleted가 false로 바뀌면 리플레이 종료 → 클린업
+ unregisterReplayLayers();
+ staticLayersRef.current = [];
+ tracksRef.current = [];
+ showLiveShips();
+ shipBatchRenderer.immediateRender();
+ return;
+ }
+
+ // 리플레이 모드 시작 → 라이브 선박 숨기기
+ hideLiveShips();
+
+ const vesselChunks = useMergedTrackStore.getState().vesselChunks;
+ if (vesselChunks.size === 0) return;
+
+ // vesselChunks → tracks 배열 변환
+ const tracks = [];
+ vesselChunks.forEach((vc, vesselId) => {
+ const path = useMergedTrackStore.getState().getMergedPath(vesselId);
+ if (!path || path.geometry.length < 2) return;
+
+ tracks.push({
+ vesselId,
+ geometry: path.geometry,
+ timestamps: path.timestamps,
+ timestampsMs: path.timestampsMs,
+ speeds: path.speeds,
+ shipKindCode: vc.shipKindCode || '000027',
+ shipName: vc.shipName || '',
+ sigSrcCd: vc.sigSrcCd,
+ });
+ });
+
+ tracksRef.current = tracks;
+
+ // 시간 범위 업데이트 (animationStore)
+ useAnimationStore.getState().updateTimeRange();
+
+ // 초기 렌더링
+ requestAnimatedRender();
+ }, [queryCompleted, requestAnimatedRender]);
+
+ /**
+ * currentTime 변경 시 애니메이션 렌더링 (ReplayV2.tsx와 동일한 패턴)
+ */
+ useEffect(() => {
+ if (!queryCompleted) return;
+ requestAnimatedRender();
+ }, [currentTime, queryCompleted, requestAnimatedRender]);
+
+ /**
+ * 필터 변경 시 재렌더링
+ */
+ useEffect(() => {
+ if (!queryCompleted) return;
+ requestAnimatedRender();
+ }, [filterModules, shipKindCodeFilter, vesselStates, deletedVesselIds, selectedVesselIds, queryCompleted, requestAnimatedRender]);
+
+ /**
+ * 배속 변경 시 항적 maxFrames 업데이트
+ */
+ useEffect(() => {
+ usePlaybackTrailStore.getState().updatePlaybackSpeed(playbackSpeed);
+ }, [playbackSpeed]);
+
+ /**
+ * 하이라이트 변경 시 레이어 재렌더링
+ */
+ useEffect(() => {
+ if (!queryCompleted) return;
+ requestAnimatedRender();
+ }, [highlightedVesselId, queryCompleted, requestAnimatedRender]);
+
+ /**
+ * 키보드 이벤트 리스너 (Delete/Insert 키로 선박 상태 변경)
+ */
+ useEffect(() => {
+ if (!queryCompleted) return;
+
+ const handleKeyDown = (event) => {
+ const currentHighlightedId = useReplayStore.getState().highlightedVesselId;
+ if (!currentHighlightedId) return;
+
+ const { vesselStates, deletedVesselIds, selectedVesselIds, setVesselState } = useReplayStore.getState();
+
+ // 현재 상태 확인
+ let currentState = VesselState.NORMAL;
+ if (vesselStates.has(currentHighlightedId)) {
+ currentState = vesselStates.get(currentHighlightedId);
+ } else if (deletedVesselIds.has(currentHighlightedId)) {
+ currentState = VesselState.DELETED;
+ } else if (selectedVesselIds.has(currentHighlightedId)) {
+ currentState = VesselState.SELECTED;
+ }
+
+ let targetState = null;
+
+ if (event.key === 'Delete') {
+ // Delete 키: NORMAL/SELECTED → DELETED, DELETED → NORMAL
+ if (currentState === VesselState.DELETED) {
+ targetState = VesselState.NORMAL;
+ } else {
+ targetState = VesselState.DELETED;
+ }
+ } else if (event.key === 'Insert') {
+ // Insert 키: NORMAL → SELECTED, SELECTED → NORMAL, DELETED → SELECTED
+ if (currentState === VesselState.DELETED) {
+ targetState = VesselState.SELECTED;
+ } else if (currentState === VesselState.SELECTED) {
+ targetState = VesselState.NORMAL;
+ } else {
+ targetState = VesselState.SELECTED;
+ }
+ }
+
+ if (targetState !== null) {
+ setVesselState(currentHighlightedId, targetState);
+ // 하이라이트 해제
+ useReplayStore.getState().setHighlightedVesselId(null);
+ // 레이어 재렌더링
+ requestAnimatedRender();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [queryCompleted, requestAnimatedRender]);
+
+ /**
+ * 컴포넌트 언마운트 시 클린업
+ */
+ useEffect(() => {
+ return () => {
+ unregisterReplayLayers();
+ staticLayersRef.current = [];
+ tracksRef.current = [];
+ showLiveShips();
+ shipBatchRenderer.immediateRender();
+ };
+ }, []);
+}
diff --git a/src/replay/services/ReplayWebSocketService.js b/src/replay/services/ReplayWebSocketService.js
new file mode 100644
index 00000000..428429af
--- /dev/null
+++ b/src/replay/services/ReplayWebSocketService.js
@@ -0,0 +1,627 @@
+/**
+ * 리플레이 항적 조회 WebSocket 서비스
+ * 참조: mda-react-front/src/tracking/services/TrackingWebSocketService.ts
+ *
+ * STOMP 프로토콜 기반 WebSocket으로 선박 항적 데이터를 청크 단위로 수신
+ * - brokerURL 직접 연결 (SockJS 미사용)
+ * - 청크 기반 대용량 데이터 수신
+ * - timestamp 기반 진행률 추적
+ * - 쿼리 취소 및 타임아웃 관리
+ *
+ * @singleton 애플리케이션 전체에서 하나의 인스턴스만 사용
+ */
+import { Client } from '@stomp/stompjs';
+import { transformExtent } from 'ol/proj';
+import {
+ ConnectionState,
+ isTrackChunkResponse,
+ isQueryStatusUpdate,
+ normalizeChunkResponse,
+ extractTracks,
+} from '../types/replay.types';
+import useReplayStore from '../stores/replayStore';
+import useMergedTrackStore from '../stores/mergedTrackStore';
+
+// WebSocket 엔드포인트 (환경 변수)
+const WS_ENDPOINT = import.meta.env.VITE_TRACKING_WS;
+
+// 타임아웃 설정
+const CONNECTION_TIMEOUT = 10000; // 10초
+const QUERY_TIMEOUT = 300000; // 5분
+
+/**
+ * ReplayWebSocketService
+ */
+class ReplayWebSocketService {
+ constructor() {
+ this.client = null;
+ this.subscriptions = [];
+ this.isConnecting = false;
+ this.currentQueryId = null;
+ this.connectionPromise = null;
+ this.queryTimeoutId = null;
+
+ // timestamp 기반 진행률 추적
+ this.queryStartTimestamp = 0;
+ this.queryEndTimestamp = 0;
+ this.maxReceivedTimestamp = 0;
+ this.estimatedProgress = 0;
+ }
+
+ // ===== 공개 API =====
+
+ /**
+ * 쿼리 실행 (메인 메서드)
+ *
+ * 1. WebSocket 연결 확인/생성
+ * 2. 채널 구독
+ * 3. 쿼리 전송
+ * 4. 완료 시 자동 정리
+ *
+ * @param {Object} request - 항적 조회 요청
+ * @param {string} request.startTime - 시작 시간 (ISO 형식, KST)
+ * @param {string} request.endTime - 종료 시간 (ISO 형식, KST)
+ * @param {string[]} [request.vesselIds] - 조회 대상 선박 ID (빈 배열이면 전체 조회)
+ */
+ async executeQuery(request) {
+ try {
+ // 이전 쿼리가 진행 중이면 취소
+ if (this.currentQueryId) {
+ await this.cancelQuery();
+ }
+
+ // 스토어 초기화
+ const replayStore = useReplayStore.getState();
+ replayStore.reset();
+ useMergedTrackStore.getState().clear();
+
+ replayStore.setCurrentQuery(request);
+ replayStore.setConnectionState(ConnectionState.CONNECTING);
+
+ // 연결 확보
+ await this._ensureConnected();
+
+ // 쿼리 ID 생성
+ this.currentQueryId = `query_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ replayStore.setQueryId(this.currentQueryId);
+
+ // 쿼리 타임아웃 설정
+ this._setQueryTimeout();
+
+ // 쿼리 전송
+ this._sendQuery(request);
+ } catch (error) {
+ console.error('[ReplayWS] 쿼리 실행 실패:', error);
+ this._handleError(error);
+ throw error;
+ }
+ }
+
+ /**
+ * 쿼리 취소
+ */
+ async cancelQuery() {
+ if (!this.currentQueryId) return;
+
+ try {
+ if (this.client?.connected) {
+ this.client.publish({
+ destination: `/app/tracks/cancel/${this.currentQueryId}`,
+ body: '',
+ });
+ }
+ } catch (error) {
+ console.error('[ReplayWS] 취소 요청 실패:', error);
+ } finally {
+ this._cleanup();
+ }
+ }
+
+ /**
+ * 연결 해제
+ */
+ disconnect() {
+ this._cleanup();
+ }
+
+ /**
+ * 연결 상태 확인
+ */
+ get connected() {
+ return this.client?.connected || false;
+ }
+
+ // ===== 연결 관리 (private) =====
+
+ /**
+ * 연결 확보 (기존 연결 재사용 또는 새 연결 생성)
+ */
+ async _ensureConnected() {
+ if (this.client?.connected) return;
+
+ if (this.isConnecting && this.connectionPromise) {
+ await this.connectionPromise;
+ return;
+ }
+
+ this.connectionPromise = this._createConnection();
+ await this.connectionPromise;
+ }
+
+ /**
+ * WebSocket 연결 생성
+ * 참조: mda-react-front TrackingWebSocketService.createConnection()
+ */
+ async _createConnection() {
+ this.isConnecting = true;
+
+ try {
+ this.client = new Client({
+ brokerURL: WS_ENDPOINT,
+
+ // 재연결: 수동 관리 (자동 재연결 비활성화)
+ reconnectDelay: 0,
+
+ // 연결 타임아웃
+ connectionTimeout: CONNECTION_TIMEOUT,
+
+ // 하트비트 (10초)
+ heartbeatIncoming: 10000,
+ heartbeatOutgoing: 10000,
+
+ onConnect: () => {
+ this.isConnecting = false;
+ this._updateConnectionState(ConnectionState.CONNECTED);
+ this._setupSubscriptions();
+ },
+
+ onStompError: (frame) => {
+ console.error('[ReplayWS] STOMP 에러:', frame);
+ this._handleError(new Error(`STOMP 에러: ${frame.headers?.message || '알 수 없는 오류'}`));
+ },
+
+ onDisconnect: (frame) => {
+ this._updateConnectionState(ConnectionState.DISCONNECTED);
+
+ // 비정상 종료 감지
+ if (this.currentQueryId && !frame?.headers?.['normal-close']) {
+ this._handleAbnormalClose();
+ }
+ },
+
+ onWebSocketError: (event) => {
+ console.error('[ReplayWS] WebSocket 에러:', event);
+ if (event.type === 'close' && event.code === 1006) {
+ this._handleAbnormalClose();
+ }
+ },
+ });
+
+ // 연결 시작
+ this._updateConnectionState(ConnectionState.CONNECTING);
+ this.client.activate();
+
+ // 연결 완료 대기 (폴링)
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error('연결 타임아웃'));
+ }, CONNECTION_TIMEOUT);
+
+ const checkInterval = setInterval(() => {
+ if (this.client?.connected) {
+ clearTimeout(timeout);
+ clearInterval(checkInterval);
+ resolve();
+ }
+ }, 100);
+ });
+ } catch (error) {
+ console.error('[ReplayWS] 연결 생성 실패:', error);
+ this.isConnecting = false;
+ this._updateConnectionState(ConnectionState.ERROR);
+ throw error;
+ }
+ }
+
+ // ===== 구독 관리 (private) =====
+
+ /**
+ * 구독 설정
+ * 참조: mda-react-front TrackingWebSocketService.setupSubscriptions()
+ */
+ _setupSubscriptions() {
+ if (!this.client?.connected) {
+ console.error('[ReplayWS] 구독 설정 실패: 연결되지 않음');
+ return;
+ }
+
+ // 이전 구독 정리
+ this._clearSubscriptions();
+
+ // 1. 청크 데이터 구독
+ const chunkSub = this.client.subscribe('/user/queue/tracks/chunk', (message) => {
+ this._handleChunkMessage(message);
+ });
+ this.subscriptions.push(chunkSub);
+
+ // 2. 상태 업데이트 구독
+ const statusSub = this.client.subscribe('/user/queue/tracks/status', (message) => {
+ this._handleStatusMessage(message);
+ });
+ this.subscriptions.push(statusSub);
+
+ // 3. 쿼리 응답 구독
+ const responseSub = this.client.subscribe('/user/queue/tracks/response', (message) => {
+ this._handleResponseMessage(message);
+ });
+ this.subscriptions.push(responseSub);
+
+ // 4. 에러 구독
+ const errorSub = this.client.subscribe('/user/queue/errors', (message) => {
+ this._handleErrorMessage(message);
+ });
+ this.subscriptions.push(errorSub);
+ }
+
+ _clearSubscriptions() {
+ this.subscriptions.forEach((sub) => {
+ try { sub.unsubscribe(); } catch (e) { /* ignore */ }
+ });
+ this.subscriptions = [];
+ }
+
+ // ===== 쿼리 전송 (private) =====
+
+ /**
+ * 쿼리 전송
+ * 참조: mda-react-front TrackingWebSocketService.sendQuery()
+ *
+ * - startTime을 HH:00:00 형식으로 정규화 (hourly 테이블 최적화)
+ * - chunkedMode: true, chunkSize: 20000 강제 설정
+ */
+ _sendQuery(request) {
+ if (!this.client?.connected) {
+ throw new Error('WebSocket이 연결되지 않았습니다');
+ }
+
+ // 시작 시간 정규화: HH:00:00 (hourly 테이블 최적화)
+ const startTimeStr = typeof request.startTime === 'string'
+ ? request.startTime
+ : request.startTime.toString();
+ const [datePart, timePart] = startTimeStr.split('T');
+ const [hour] = timePart ? timePart.split(':') : ['00'];
+ const normalizedStartTime = `${datePart}T${hour}:00:00`;
+
+ // vesselIds 정리
+ const vesselIds = request.vesselIds && Array.isArray(request.vesselIds) && request.vesselIds.length > 0
+ ? request.vesselIds
+ : undefined;
+
+ if (!vesselIds) {
+ console.warn('[ReplayWS] vesselIds 비어있음 → 전체 선박 조회');
+ }
+
+ // 지도 뷰포트 추출 (EPSG:3857 → EPSG:4326)
+ const viewport = this._getMapViewport();
+ const zoomLevel = this._getMapZoomLevel();
+
+ // 요청 객체 구성 (메인 프로젝트와 동일)
+ const enrichedRequest = {
+ ...request,
+ startTime: normalizedStartTime,
+ vesselIds,
+ chunkedMode: true,
+ chunkSize: 20000,
+ viewport,
+ simplificationMode: 'AUTO',
+ zoomLevel,
+ minDistance: 50,
+ isIntegration: '0',
+ };
+
+ // timestamp 기반 진행률 추적을 위한 시간 저장
+ try {
+ this.queryStartTimestamp = Math.floor(new Date(request.startTime).getTime() / 1000);
+ this.queryEndTimestamp = Math.floor(new Date(request.endTime).getTime() / 1000);
+ this.maxReceivedTimestamp = this.queryStartTimestamp;
+ this.estimatedProgress = 0;
+ } catch (error) {
+ console.error('[ReplayWS] timestamp 파싱 실패:', error);
+ }
+
+ this.client.publish({
+ destination: '/app/tracks/query',
+ body: JSON.stringify(enrichedRequest),
+ });
+ }
+
+ // ===== 메시지 핸들러 (private) =====
+
+ /**
+ * 청크 메시지 처리
+ * 참조: mda-react-front TrackingWebSocketService.handleChunkMessage()
+ */
+ _handleChunkMessage(message) {
+ try {
+ const chunk = JSON.parse(message.body);
+ const normalized = this._normalizeChunkResponse(chunk);
+
+ if (!isTrackChunkResponse(normalized)) {
+ console.error('[ReplayWS] 잘못된 청크 형식:', chunk);
+ return;
+ }
+
+ const tracks = extractTracks(normalized);
+ if (!tracks || tracks.length === 0) return;
+
+ // tracks를 정규화된 필드로 설정
+ normalized.tracks = tracks;
+
+ // 청크 저장
+ useMergedTrackStore.getState().addChunkOptimized(normalized);
+ useMergedTrackStore.getState().addRawChunk(normalized);
+
+ // timestamp 기반 진행률 계산
+ this._updateProgressByTimestamp(tracks, normalized.isLastChunk || false);
+
+ // 스토어 진행률 업데이트
+ const replayStore = useReplayStore.getState();
+ replayStore.updateProgress(
+ replayStore.receivedChunks + 1,
+ normalized.totalChunks || replayStore.totalChunks,
+ Date.now()
+ );
+ } catch (error) {
+ console.error('[ReplayWS] 청크 처리 오류:', error);
+ }
+ }
+
+ /**
+ * 상태 메시지 처리
+ * 참조: mda-react-front TrackingWebSocketService.handleStatusMessage()
+ */
+ _handleStatusMessage(message) {
+ try {
+ const status = JSON.parse(message.body);
+
+ if (!isQueryStatusUpdate(status)) {
+ console.error('[ReplayWS] 잘못된 상태 형식:', status);
+ return;
+ }
+
+ if (status.status === 'COMPLETED') {
+ useReplayStore.getState().setQueryCompleted(true);
+ this._handleQueryComplete();
+ }
+
+ if (status.status === 'ERROR') {
+ this._handleError(new Error(status.error || '쿼리 처리 중 오류 발생'));
+ }
+
+ // 서버에서 queryId를 반환하면 저장
+ if (status.queryId) {
+ useReplayStore.getState().setQueryId(status.queryId);
+ }
+ } catch (error) {
+ console.error('[ReplayWS] 상태 처리 오류:', error);
+ }
+ }
+
+ /**
+ * 응답 메시지 처리
+ */
+ _handleResponseMessage(message) {
+ try {
+ const response = JSON.parse(message.body);
+
+ if (response.queryId) {
+ useReplayStore.getState().setQueryId(response.queryId);
+ }
+ } catch (error) {
+ console.error('[ReplayWS] 응답 처리 오류:', error);
+ }
+ }
+
+ /**
+ * 에러 메시지 처리
+ */
+ _handleErrorMessage(message) {
+ console.error('[ReplayWS] 서버 에러:', message.body);
+ this._handleError(new Error(message.body));
+ }
+
+ // ===== 진행률 (private) =====
+
+ /**
+ * timestamp 기반 진행률 업데이트
+ * 참조: mda-react-front TrackingWebSocketService.updateProgressByTimestamp()
+ */
+ _updateProgressByTimestamp(tracks, isLastChunk) {
+ try {
+ tracks.forEach((track) => {
+ if (track.timestamps && Array.isArray(track.timestamps)) {
+ track.timestamps.forEach((ts) => {
+ const timestamp = typeof ts === 'number' ? ts : parseInt(ts, 10);
+ if (timestamp > this.maxReceivedTimestamp) {
+ this.maxReceivedTimestamp = timestamp;
+ }
+ });
+ }
+ });
+
+ const totalDuration = this.queryEndTimestamp - this.queryStartTimestamp;
+ if (totalDuration > 0) {
+ const progress = ((this.maxReceivedTimestamp - this.queryStartTimestamp) / totalDuration) * 100;
+ this.estimatedProgress = Math.min(progress, 99);
+ }
+
+ if (isLastChunk) {
+ this.estimatedProgress = 100;
+ useReplayStore.getState().setQueryCompleted(true);
+ }
+
+ // 스토어에 진행률 반영
+ useReplayStore.getState().setProgress(this.estimatedProgress);
+ } catch (error) {
+ console.error('[ReplayWS] 진행률 계산 오류:', error);
+ }
+ }
+
+ // ===== 완료/에러/정리 (private) =====
+
+ /**
+ * 쿼리 완료 처리
+ */
+ _handleQueryComplete() {
+ this._clearQueryTimeout();
+
+ useReplayStore.setState({ progress: 100 });
+
+ // 자동 연결 해제 및 정리
+ this._cleanup();
+ }
+
+ /**
+ * 에러 처리
+ */
+ _handleError(error) {
+ console.error('[ReplayWS] 에러:', error);
+
+ const userMessage = this._getUserFriendlyError(error);
+ useReplayStore.setState({
+ connectionState: ConnectionState.ERROR,
+ });
+
+ this._cleanup();
+ }
+
+ /**
+ * 비정상 종료 처리 (버퍼 오버플로우 등)
+ */
+ _handleAbnormalClose() {
+ console.error('[ReplayWS] 비정상 종료 (버퍼 오버플로우 가능)');
+ this._cleanup();
+ }
+
+ /**
+ * 사용자 친화적 에러 메시지
+ */
+ _getUserFriendlyError(error) {
+ const message = error.message.toLowerCase();
+ if (message.includes('timeout')) return '서버 응답 시간이 초과되었습니다.';
+ if (message.includes('network') || message.includes('connect')) return '네트워크 연결을 확인해주세요.';
+ return '데이터 조회 중 오류가 발생했습니다.';
+ }
+
+ // ===== 타임아웃 (private) =====
+
+ _setQueryTimeout() {
+ this._clearQueryTimeout();
+ this.queryTimeoutId = setTimeout(() => {
+ console.error('[ReplayWS] 쿼리 타임아웃');
+ this._handleError(new Error('쿼리 처리 시간이 초과되었습니다'));
+ }, QUERY_TIMEOUT);
+ }
+
+ _clearQueryTimeout() {
+ if (this.queryTimeoutId) {
+ clearTimeout(this.queryTimeoutId);
+ this.queryTimeoutId = null;
+ }
+ }
+
+ // ===== 연결 상태 (private) =====
+
+ _updateConnectionState(state) {
+ useReplayStore.getState().setConnectionState(state);
+ }
+
+ // ===== 정리 (private) =====
+
+ /**
+ * 전체 정리
+ * 참조: mda-react-front TrackingWebSocketService.cleanup()
+ */
+ _cleanup() {
+ this._clearQueryTimeout();
+ this._clearSubscriptions();
+
+ if (this.client?.connected) {
+ try { this.client.deactivate(); } catch (e) { /* ignore */ }
+ }
+
+ this.client = null;
+ this.currentQueryId = null;
+ this.connectionPromise = null;
+ this.isConnecting = false;
+
+ this._updateConnectionState(ConnectionState.DISCONNECTED);
+ }
+
+ // ===== 지도 뷰포트 (private) =====
+
+ /**
+ * OpenLayers 맵에서 현재 뷰포트 좌표 추출
+ * 참조: mda-react-front/src/tracking/components/ReplayV2.tsx getMapViewport()
+ * @returns {{ minLon: number, maxLon: number, minLat: number, maxLat: number } | undefined}
+ */
+ _getMapViewport() {
+ try {
+ const map = window.__mainMap__;
+ if (!map) {
+ console.warn('[ReplayWS] 맵 인스턴스 없음 (window.__mainMap__)');
+ return undefined;
+ }
+
+ const view = map.getView();
+ const extent3857 = view.calculateExtent(map.getSize());
+ const [minLon, minLat, maxLon, maxLat] = transformExtent(extent3857, 'EPSG:3857', 'EPSG:4326');
+
+ return { minLon, maxLon, minLat, maxLat };
+ } catch (error) {
+ console.error('[ReplayWS] 뷰포트 추출 실패:', error);
+ return undefined;
+ }
+ }
+
+ /**
+ * 현재 맵 줌 레벨
+ */
+ _getMapZoomLevel() {
+ try {
+ const map = window.__mainMap__;
+ if (!map) return 10;
+ return Math.round(map.getView().getZoom()) || 10;
+ } catch {
+ return 10;
+ }
+ }
+
+ // ===== 유틸 (private) =====
+
+ _normalizeChunkResponse(chunk) {
+ return {
+ queryId: chunk.queryId,
+ chunkId: chunk.chunkId || `chunk_${chunk.chunkIndex}`,
+ chunkIndex: chunk.chunkIndex,
+ totalChunks: chunk.totalChunks,
+ tracks: chunk.tracks,
+ mergedTracks: chunk.mergedTracks,
+ compactTracks: chunk.compactTracks,
+ isLastChunk: chunk.isLastChunk || false,
+ metadata: chunk.metadata,
+ };
+ }
+}
+
+// 싱글톤 인스턴스
+let instance = null;
+
+export function getReplayWebSocketService() {
+ if (!instance) {
+ instance = new ReplayWebSocketService();
+ }
+ return instance;
+}
+
+export default ReplayWebSocketService;
diff --git a/src/replay/stores/animationStore.js b/src/replay/stores/animationStore.js
new file mode 100644
index 00000000..3f7cd6ea
--- /dev/null
+++ b/src/replay/stores/animationStore.js
@@ -0,0 +1,381 @@
+/**
+ * 선박 항적 애니메이션 상태 관리 스토어
+ * 참조: mda-react-front/src/tracking/stores/animationStore.ts
+ *
+ * 시간 기반 애니메이션을 제어하고 현재 시간의 선박 위치를 계산
+ */
+
+import { create } from 'zustand';
+import { subscribeWithSelector } from 'zustand/middleware';
+import useMergedTrackStore from './mergedTrackStore';
+import useReplayStore from './replayStore';
+
+/**
+ * 재생 상태 (레거시 호환)
+ */
+export const PlaybackState = {
+ IDLE: 'IDLE',
+ PLAYING: 'PLAYING',
+ PAUSED: 'PAUSED',
+ STOPPED: 'STOPPED',
+};
+
+/**
+ * 두 지점 간의 방향(heading) 계산
+ */
+function calculateHeading(p1, p2) {
+ const [lon1, lat1] = p1;
+ const [lon2, lat2] = p2;
+ const dx = lon2 - lon1;
+ const dy = lat2 - lat1;
+ let angle = (Math.atan2(dx, dy) * 180) / Math.PI;
+ if (angle < 0) angle += 360;
+ return angle;
+}
+
+/**
+ * 두 지점 사이의 위치를 시간 기반으로 보간
+ */
+function interpolatePosition(p1, p2, t1, t2, currentTime) {
+ if (t1 === t2) return p1;
+ const ratio = (currentTime - t1) / (t2 - t1);
+ return [
+ p1[0] + (p2[0] - p1[0]) * ratio,
+ p1[1] + (p2[1] - p1[1]) * ratio,
+ ];
+}
+
+/**
+ * 청크 기반 데이터에서 시간 범위 추출
+ */
+function getTimeRangeFromVessels() {
+ const vesselChunks = useMergedTrackStore.getState().vesselChunks;
+
+ if (vesselChunks.size === 0) {
+ return null;
+ }
+
+ let minTime = Infinity;
+ let maxTime = -Infinity;
+
+ vesselChunks.forEach((vessel) => {
+ const mergedPath = useMergedTrackStore.getState().getMergedPath(vessel.vesselId);
+ if (mergedPath && mergedPath.timestampsMs.length > 0) {
+ minTime = Math.min(minTime, mergedPath.timestampsMs[0]);
+ maxTime = Math.max(maxTime, mergedPath.timestampsMs[mergedPath.timestampsMs.length - 1]);
+ }
+ });
+
+ if (minTime === Infinity || maxTime === -Infinity) {
+ return null;
+ }
+
+ return { start: minTime, end: maxTime };
+}
+
+/**
+ * 애니메이션 스토어
+ */
+const useAnimationStore = create(subscribeWithSelector((set, get) => ({
+ // ========== 재생 상태 ==========
+ isPlaying: false,
+ playbackState: PlaybackState.IDLE, // 레거시 호환
+ currentTime: 0,
+ startTime: 0,
+ endTime: 0,
+ playbackSpeed: 1,
+ loop: false,
+ loopEnabled: false, // 레거시 호환
+
+ // ========== 내부 상태 ==========
+ animationFrameId: null,
+ lastFrameTime: 0,
+
+ /**
+ * 재생 시작
+ */
+ play: () => {
+ const state = get();
+ if (state.isPlaying) return;
+
+ const vesselChunks = useMergedTrackStore.getState().vesselChunks;
+ if (vesselChunks.size === 0) return;
+
+ const timeRange = getTimeRangeFromVessels();
+ if (!timeRange) return;
+
+ // 시간 범위 설정
+ if (
+ state.startTime === 0 ||
+ state.endTime === 0 ||
+ state.currentTime < timeRange.start ||
+ state.currentTime > timeRange.end
+ ) {
+ set({
+ startTime: timeRange.start,
+ endTime: timeRange.end,
+ currentTime: timeRange.start,
+ });
+ }
+
+ // 끝에 도달했으면 처음부터
+ if (state.currentTime >= state.endTime) {
+ set({ currentTime: state.startTime });
+ }
+
+ set({
+ isPlaying: true,
+ playbackState: PlaybackState.PLAYING,
+ lastFrameTime: performance.now(),
+ });
+
+ // 애니메이션 루프 시작
+ const animate = (timestamp) => {
+ const state = get();
+ if (!state.isPlaying) return;
+
+ const deltaTime = timestamp - state.lastFrameTime;
+ const timeIncrement = (deltaTime / 1000) * state.playbackSpeed;
+ let newTime = state.currentTime + timeIncrement * 1000;
+
+ if (newTime > state.endTime) {
+ if (state.loop || state.loopEnabled) {
+ newTime = state.startTime;
+ } else {
+ newTime = state.endTime;
+ set({
+ isPlaying: false,
+ playbackState: PlaybackState.STOPPED,
+ });
+ }
+ }
+
+ set({
+ currentTime: newTime,
+ lastFrameTime: timestamp,
+ });
+
+ if (get().isPlaying) {
+ const frameId = requestAnimationFrame(animate);
+ set({ animationFrameId: frameId });
+ }
+ };
+
+ const frameId = requestAnimationFrame(animate);
+ set({ animationFrameId: frameId });
+ },
+
+ /**
+ * 일시정지
+ */
+ pause: () => {
+ const { animationFrameId } = get();
+ if (animationFrameId !== null) {
+ cancelAnimationFrame(animationFrameId);
+ }
+ set({
+ isPlaying: false,
+ playbackState: PlaybackState.PAUSED,
+ animationFrameId: null,
+ });
+ },
+
+ /**
+ * 정지 (처음으로)
+ */
+ stop: () => {
+ const { animationFrameId } = get();
+ if (animationFrameId !== null) {
+ cancelAnimationFrame(animationFrameId);
+ }
+
+ const timeRange = getTimeRangeFromVessels();
+ const startTime = timeRange?.start || 0;
+
+ set({
+ isPlaying: false,
+ playbackState: PlaybackState.STOPPED,
+ currentTime: startTime,
+ animationFrameId: null,
+ });
+ },
+
+ /**
+ * 특정 시간으로 이동
+ */
+ seekTo: (time) => {
+ const state = get();
+ const clampedTime = Math.max(state.startTime, Math.min(time, state.endTime));
+ set({ currentTime: clampedTime });
+ },
+
+ /**
+ * 현재 시간 설정 (레거시 호환)
+ */
+ setCurrentTime: (time) => {
+ set({ currentTime: time });
+ },
+
+ /**
+ * 재생 속도 설정
+ */
+ setPlaybackSpeed: (speed) => {
+ set({ playbackSpeed: speed });
+ },
+
+ /**
+ * 반복 재생 토글
+ */
+ toggleLoop: () => {
+ set((state) => ({
+ loop: !state.loop,
+ loopEnabled: !state.loopEnabled,
+ }));
+ },
+
+ /**
+ * 시간 범위 업데이트
+ */
+ updateTimeRange: () => {
+ const timeRange = getTimeRangeFromVessels();
+ if (timeRange) {
+ const state = get();
+ const newCurrentTime =
+ state.currentTime === 0 ||
+ state.currentTime < timeRange.start ||
+ state.currentTime > timeRange.end
+ ? timeRange.start
+ : state.currentTime;
+
+ set({
+ startTime: timeRange.start,
+ endTime: timeRange.end,
+ currentTime: newCurrentTime,
+ });
+ return timeRange;
+ }
+ return { start: 0, end: 0 };
+ },
+
+ /**
+ * 시간 범위 직접 설정
+ */
+ setTimeRange: (startTime, endTime) => {
+ set({
+ startTime,
+ endTime,
+ currentTime: startTime,
+ });
+ },
+
+ /**
+ * 진행률 계산 (0 ~ 100)
+ */
+ getProgress: () => {
+ const { currentTime, startTime, endTime } = get();
+ if (endTime === startTime) return 0;
+ return ((currentTime - startTime) / (endTime - startTime)) * 100;
+ },
+
+ /**
+ * 현재 시간의 선박 위치 계산 (핵심 메서드)
+ * 참조: mda-react-front/src/tracking/stores/animationStore.ts - getCurrentVesselPositions
+ */
+ getCurrentVesselPositions: () => {
+ const { currentTime } = get();
+ const vesselChunks = useMergedTrackStore.getState().vesselChunks;
+ const positions = [];
+
+ vesselChunks.forEach((vessel, vesselId) => {
+ if (!vessel) return;
+
+ const mergedPath = useMergedTrackStore.getState().getMergedPath(vesselId);
+ if (!mergedPath || mergedPath.timestampsMs.length === 0) return;
+
+ const timestampsMs = mergedPath.timestampsMs;
+ const firstTime = timestampsMs[0];
+ const lastTime = timestampsMs[timestampsMs.length - 1];
+
+ // 시간 범위 체크
+ if (currentTime < firstTime || currentTime > lastTime) {
+ return;
+ }
+
+ // 이진 탐색으로 현재 시간에 해당하는 인덱스 찾기
+ let left = 0;
+ let right = timestampsMs.length - 1;
+
+ while (left < right) {
+ const mid = Math.floor((left + right) / 2);
+ if (timestampsMs[mid] < currentTime) {
+ left = mid + 1;
+ } else {
+ right = mid;
+ }
+ }
+
+ const idx1 = Math.max(0, left - 1);
+ const idx2 = Math.min(timestampsMs.length - 1, left);
+
+ let finalPosition;
+ let heading;
+ let speed;
+
+ if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) {
+ finalPosition = mergedPath.geometry[idx1];
+ heading = 0;
+ speed = mergedPath.speeds[idx1] || 0;
+ } else {
+ finalPosition = interpolatePosition(
+ mergedPath.geometry[idx1],
+ mergedPath.geometry[idx2],
+ timestampsMs[idx1],
+ timestampsMs[idx2],
+ currentTime
+ );
+ heading = calculateHeading(mergedPath.geometry[idx1], mergedPath.geometry[idx2]);
+ const speedRatio = (currentTime - timestampsMs[idx1]) / (timestampsMs[idx2] - timestampsMs[idx1]);
+ speed = (mergedPath.speeds[idx1] || 0) + ((mergedPath.speeds[idx2] || 0) - (mergedPath.speeds[idx1] || 0)) * speedRatio;
+ }
+
+ positions.push({
+ vesselId,
+ position: finalPosition,
+ heading,
+ speed,
+ timestamp: currentTime,
+ // 추가 정보
+ shipKindCode: vessel.shipKindCode || '000027',
+ shipName: vessel.shipName || '',
+ sigSrcCd: vessel.sigSrcCd,
+ });
+ });
+
+ return positions;
+ },
+
+ /**
+ * 초기화
+ */
+ reset: () => {
+ const { animationFrameId } = get();
+ if (animationFrameId !== null) {
+ cancelAnimationFrame(animationFrameId);
+ }
+
+ set({
+ isPlaying: false,
+ playbackState: PlaybackState.IDLE,
+ currentTime: 0,
+ playbackSpeed: 1,
+ startTime: 0,
+ endTime: 0,
+ animationFrameId: null,
+ lastFrameTime: 0,
+ loop: false,
+ loopEnabled: false,
+ });
+ },
+})));
+
+export default useAnimationStore;
diff --git a/src/replay/stores/mergedTrackStore.js b/src/replay/stores/mergedTrackStore.js
new file mode 100644
index 00000000..bec13ea8
--- /dev/null
+++ b/src/replay/stores/mergedTrackStore.js
@@ -0,0 +1,200 @@
+/**
+ * 리플레이 데이터 저장소
+ * 참조: src/tracking/stores/mergedTrackStore.ts
+ *
+ * 청크 기반 선박 항적 데이터 저장 및 관리
+ */
+import { create } from 'zustand';
+import { parseTimestamp } from '../types/replay.types';
+
+/**
+ * 청크 기반 선박 데이터 병합
+ */
+function mergeVesselChunks(existingChunks, newChunk) {
+ if (!existingChunks) {
+ return {
+ vesselId: newChunk.vesselId,
+ sigSrcCd: newChunk.sigSrcCd,
+ targetId: newChunk.targetId,
+ shipName: newChunk.shipName,
+ shipKindCode: newChunk.shipKindCode,
+ chunks: [newChunk],
+ cachedPath: null,
+ totalDistance: newChunk.totalDistance || 0,
+ maxSpeed: newChunk.maxSpeed || 0,
+ avgSpeed: newChunk.avgSpeed || 0,
+ };
+ }
+
+ // 기존 청크에 새 청크 추가 (시간순 정렬)
+ const chunks = [...existingChunks.chunks, newChunk].sort((a, b) => {
+ const timeA = parseTimestamp(a.timestamps[0]);
+ const timeB = parseTimestamp(b.timestamps[0]);
+ return timeA - timeB;
+ });
+
+ return {
+ ...existingChunks,
+ chunks,
+ cachedPath: null, // 캐시 무효화
+ totalDistance: Math.max(existingChunks.totalDistance, newChunk.totalDistance || 0),
+ maxSpeed: Math.max(existingChunks.maxSpeed, newChunk.maxSpeed || 0),
+ };
+}
+
+/**
+ * 병합된 경로 생성 (캐싱)
+ */
+function buildCachedPath(chunks) {
+ const geometry = [];
+ const timestamps = [];
+ const timestampsMs = [];
+ const speeds = [];
+
+ chunks.forEach((chunk) => {
+ if (chunk.geometry) {
+ geometry.push(...chunk.geometry);
+ }
+ if (chunk.timestamps) {
+ timestamps.push(...chunk.timestamps);
+ chunk.timestamps.forEach((ts) => {
+ timestampsMs.push(parseTimestamp(ts));
+ });
+ }
+ if (chunk.speeds) {
+ speeds.push(...chunk.speeds);
+ }
+ });
+
+ return {
+ geometry,
+ timestamps,
+ timestampsMs,
+ speeds,
+ lastUpdated: Date.now(),
+ };
+}
+
+/**
+ * MergedTrackStore
+ */
+const useMergedTrackStore = create((set, get) => ({
+ // ===== 상태 =====
+
+ // 청크 기반 저장소 (메인)
+ vesselChunks: new Map(), // Map
+
+ // 원본 청크 (리플레이용)
+ rawChunks: [],
+
+ // 메타데이터
+ timeRange: null, // { start: number, end: number }
+ spatialBounds: null, // { minLon, maxLon, minLat, maxLat }
+
+ // ===== 액션 =====
+
+ /**
+ * 청크 추가 (최적화)
+ */
+ addChunkOptimized: (chunkResponse) => {
+ const tracks = chunkResponse.tracks || [];
+
+ set((state) => {
+ const newVesselChunks = new Map(state.vesselChunks);
+ let timeRange = state.timeRange;
+
+ tracks.forEach((track) => {
+ const vesselId = track.vesselId;
+ const existingChunks = newVesselChunks.get(vesselId);
+ const mergedChunks = mergeVesselChunks(existingChunks, track);
+
+ newVesselChunks.set(vesselId, mergedChunks);
+
+ // 시간 범위 업데이트
+ if (track.timestamps && track.timestamps.length > 0) {
+ const firstTime = parseTimestamp(track.timestamps[0]);
+ const lastTime = parseTimestamp(track.timestamps[track.timestamps.length - 1]);
+
+ if (!timeRange) {
+ timeRange = { start: firstTime, end: lastTime };
+ } else {
+ timeRange = {
+ start: Math.min(timeRange.start, firstTime),
+ end: Math.max(timeRange.end, lastTime),
+ };
+ }
+ }
+ });
+
+ return {
+ vesselChunks: newVesselChunks,
+ timeRange,
+ };
+ });
+ },
+
+ /**
+ * 병합된 경로 반환 (캐시 사용)
+ */
+ getMergedPath: (vesselId) => {
+ const vesselChunks = get().vesselChunks.get(vesselId);
+ if (!vesselChunks) return null;
+
+ // 캐시 확인
+ if (vesselChunks.cachedPath) {
+ return vesselChunks.cachedPath;
+ }
+
+ // 새로 생성
+ const cachedPath = buildCachedPath(vesselChunks.chunks);
+
+ // 캐시 저장
+ set((state) => {
+ const newVesselChunks = new Map(state.vesselChunks);
+ newVesselChunks.set(vesselId, {
+ ...vesselChunks,
+ cachedPath,
+ });
+ return { vesselChunks: newVesselChunks };
+ });
+
+ return cachedPath;
+ },
+
+ /**
+ * 원본 청크 추가
+ */
+ addRawChunk: (chunkResponse) => {
+ set((state) => ({
+ rawChunks: [...state.rawChunks, chunkResponse],
+ }));
+ },
+
+ /**
+ * 전체 초기화
+ */
+ clear: () => {
+ set({
+ vesselChunks: new Map(),
+ rawChunks: [],
+ timeRange: null,
+ spatialBounds: null,
+ });
+ },
+
+ /**
+ * 모든 선박 ID 반환
+ */
+ getAllVesselIds: () => {
+ return Array.from(get().vesselChunks.keys());
+ },
+
+ /**
+ * 선박 데이터 반환
+ */
+ getVesselChunks: (vesselId) => {
+ return get().vesselChunks.get(vesselId) || null;
+ },
+}));
+
+export default useMergedTrackStore;
diff --git a/src/replay/stores/playbackTrailStore.js b/src/replay/stores/playbackTrailStore.js
new file mode 100644
index 00000000..0887707f
--- /dev/null
+++ b/src/replay/stores/playbackTrailStore.js
@@ -0,0 +1,233 @@
+/**
+ * 재생 항적(Trail) 상태 관리 스토어
+ * 참조: mda-react-front/src/tracking/stores/playbackTrailStore.ts
+ *
+ * 애니메이션 재생 시 선박의 이동 궤적을 반투명 점으로 표시
+ * - 시간이 지나면 점 크기 축소, 투명도 증가
+ * - 기준 프레임 초과 시 제거
+ * - 배속에 따라 maxFrames 동적 조절
+ */
+import { create } from 'zustand';
+import { subscribeWithSelector } from 'zustand/middleware';
+
+// 기본 설정값 (75% 수준으로 조정 - 성능 최적화)
+const BASE_MAX_FRAMES = 45; // 기본 프레임 수 (1x 배속 기준)
+const MIN_MAX_FRAMES = 25; // 최소 프레임 수
+const MAX_MAX_FRAMES = 225; // 최대 프레임 수
+const DEFAULT_MAX_POINT_SIZE = 4; // 최대 4px
+const DEFAULT_MIN_POINT_SIZE = 1; // 최소 1px
+
+/**
+ * 배속에 따른 maxFrames 계산
+ * 배속이 빠르면 더 많은 프레임을 유지해서 비슷한 시간 길이의 항적 표시
+ * @param {number} playbackSpeed - 재생 배속 (0.5, 1, 2, 4, 8, 16 등)
+ * @returns {number} maxFrames
+ */
+const calculateMaxFrames = (playbackSpeed) => {
+ // 배속에 비례하여 프레임 수 증가
+ // 1x: 60프레임, 2x: 120프레임, 4x: 240프레임, 0.5x: 30프레임
+ const frames = Math.round(BASE_MAX_FRAMES * playbackSpeed);
+ return Math.max(MIN_MAX_FRAMES, Math.min(MAX_MAX_FRAMES, frames));
+};
+
+/**
+ * 재생 항적 스토어
+ *
+ * @typedef {Object} PlaybackTrailPoint
+ * @property {number} lon - 경도
+ * @property {number} lat - 위도
+ * @property {number} frameIndex - 기록된 프레임 인덱스
+ * @property {string} vesselId - 선박 ID
+ */
+const usePlaybackTrailStore = create(
+ subscribeWithSelector((set, get) => ({
+ // ========== 상태 ==========
+
+ /** 항적표시 토글 상태 */
+ isEnabled: false,
+
+ /** 선박별 항적 포인트 Map (vesselId -> PlaybackTrailPoint[]) */
+ trails: new Map(),
+
+ /** 현재 프레임 인덱스 (렌더링마다 증가) */
+ frameIndex: 0,
+
+ /** 현재 재생 배속 */
+ playbackSpeed: 1,
+
+ /** 유지할 최대 프레임 수 (배속에 따라 동적 계산) */
+ maxFrames: BASE_MAX_FRAMES,
+
+ /** 포인트 최대 크기 (px) */
+ maxPointSize: DEFAULT_MAX_POINT_SIZE,
+
+ /** 포인트 최소 크기 (px) */
+ minPointSize: DEFAULT_MIN_POINT_SIZE,
+
+ // ========== 액션 ==========
+
+ /**
+ * 토글 ON/OFF
+ * @param {boolean} enabled
+ */
+ setEnabled: (enabled) => {
+ if (!enabled) {
+ // OFF 시 전체 초기화
+ set({
+ isEnabled: false,
+ trails: new Map(),
+ frameIndex: 0,
+ });
+ } else {
+ set({ isEnabled: true });
+ }
+ },
+
+ /**
+ * 항적 전체 초기화 (정지, 슬라이더 탐색 시 호출)
+ */
+ clearTrails: () => {
+ set({
+ trails: new Map(),
+ frameIndex: 0,
+ });
+ },
+
+ /**
+ * 프레임 기록 (매 렌더링마다 호출)
+ * @param {Array<{vesselId: string, lon: number, lat: number}>} positions
+ */
+ recordFrame: (positions) => {
+ const state = get();
+ if (!state.isEnabled || positions.length === 0) return;
+
+ const newFrameIndex = state.frameIndex + 1;
+ const newTrailsMap = new Map(state.trails);
+ const { maxFrames } = state;
+
+ // 각 선박의 현재 위치를 항적에 추가
+ for (const pos of positions) {
+ const { vesselId, lon, lat } = pos;
+
+ // NaN 체크
+ if (isNaN(lon) || isNaN(lat)) continue;
+
+ const vesselTrails = newTrailsMap.get(vesselId) || [];
+
+ // 새 포인트 추가
+ vesselTrails.push({
+ lon,
+ lat,
+ frameIndex: newFrameIndex,
+ vesselId,
+ });
+
+ // maxFrames 초과 시 오래된 포인트 제거
+ while (vesselTrails.length > maxFrames) {
+ vesselTrails.shift();
+ }
+
+ newTrailsMap.set(vesselId, vesselTrails);
+ }
+
+ // 더 이상 위치가 없는 선박의 오래된 포인트 정리
+ const currentVesselIds = new Set(positions.map((p) => p.vesselId));
+ newTrailsMap.forEach((trails, vesselId) => {
+ if (!currentVesselIds.has(vesselId)) {
+ const validTrails = trails.filter(
+ (t) => newFrameIndex - t.frameIndex < maxFrames
+ );
+ if (validTrails.length === 0) {
+ newTrailsMap.delete(vesselId);
+ } else {
+ newTrailsMap.set(vesselId, validTrails);
+ }
+ }
+ });
+
+ set({
+ trails: newTrailsMap,
+ frameIndex: newFrameIndex,
+ });
+ },
+
+ /**
+ * 모든 가시 포인트 반환 (렌더링용)
+ * @returns {PlaybackTrailPoint[]}
+ */
+ getVisiblePoints: () => {
+ const state = get();
+ const result = [];
+
+ state.trails.forEach((points) => {
+ points.forEach((point) => {
+ const frameAge = state.frameIndex - point.frameIndex;
+ if (frameAge < state.maxFrames) {
+ result.push(point);
+ }
+ });
+ });
+
+ return result;
+ },
+
+ /**
+ * 포인트 투명도 계산 (0~1)
+ * @param {number} pointFrameIndex
+ * @returns {number}
+ */
+ getOpacity: (pointFrameIndex) => {
+ const state = get();
+ const frameAge = state.frameIndex - pointFrameIndex;
+
+ if (frameAge >= state.maxFrames) return 0; // 기준 초과 → 완전 투명
+ if (frameAge <= 0) return 1; // 최신 포인트 → 불투명
+
+ // 선형 감소: 최신(frameAge=0) = 1, 가장 오래된(frameAge=maxFrames) = 0
+ return 1 - frameAge / state.maxFrames;
+ },
+
+ /**
+ * 포인트 크기 계산 (px)
+ * @param {number} pointFrameIndex
+ * @returns {number}
+ */
+ getPointSize: (pointFrameIndex) => {
+ const state = get();
+ const frameAge = state.frameIndex - pointFrameIndex;
+
+ if (frameAge >= state.maxFrames) return state.minPointSize; // 기준 초과 → 최소 크기
+ if (frameAge <= 0) return state.maxPointSize; // 최신 → 최대 크기
+
+ // 선형 감소: 최신 = maxPointSize, 가장 오래된 = minPointSize
+ const ratio = 1 - frameAge / state.maxFrames;
+ return state.minPointSize + (state.maxPointSize - state.minPointSize) * ratio;
+ },
+
+ /**
+ * 재생 배속 업데이트 (maxFrames 자동 재계산)
+ * @param {number} speed - 재생 배속
+ */
+ updatePlaybackSpeed: (speed) => {
+ const newMaxFrames = calculateMaxFrames(speed);
+ set({
+ playbackSpeed: speed,
+ maxFrames: newMaxFrames,
+ });
+ },
+
+ /**
+ * 설정 변경
+ * @param {Object} config
+ */
+ setConfig: (config) => {
+ set({
+ maxFrames: config.maxFrames ?? get().maxFrames,
+ maxPointSize: config.maxPointSize ?? get().maxPointSize,
+ minPointSize: config.minPointSize ?? get().minPointSize,
+ });
+ },
+ }))
+);
+
+export default usePlaybackTrailStore;
diff --git a/src/replay/stores/replayStore.js b/src/replay/stores/replayStore.js
new file mode 100644
index 00000000..311e8b3b
--- /dev/null
+++ b/src/replay/stores/replayStore.js
@@ -0,0 +1,292 @@
+/**
+ * 리플레이 메인 스토어
+ * 참조: src/tracking/stores/trackingStore.ts
+ *
+ * 리플레이 상태 관리 (쿼리, 필터, 선박 상태 등)
+ */
+import { create } from 'zustand';
+import { subscribeWithSelector } from 'zustand/middleware';
+import {
+ ConnectionState,
+ VesselState,
+ FilterModuleType,
+ DEFAULT_FILTER_MODULE,
+} from '../types/replay.types';
+import useMergedTrackStore from './mergedTrackStore';
+import usePlaybackTrailStore from './playbackTrailStore';
+import {
+ SIGNAL_SOURCE_CODE_AIS,
+ SIGNAL_SOURCE_CODE_ENAV,
+ SIGNAL_SOURCE_CODE_VPASS,
+ SIGNAL_SOURCE_CODE_VTS_AIS,
+ SIGNAL_SOURCE_CODE_RADAR,
+ SIGNAL_SOURCE_CODE_D_MF_HF,
+ SIGNAL_KIND_CODE_FISHING,
+ SIGNAL_KIND_CODE_KCGV,
+ SIGNAL_KIND_CODE_PASSENGER,
+ SIGNAL_KIND_CODE_CARGO,
+ SIGNAL_KIND_CODE_TANKER,
+ SIGNAL_KIND_CODE_GOV,
+ SIGNAL_KIND_CODE_NORMAL,
+ SIGNAL_KIND_CODE_BUOY,
+} from '../../types/constants';
+
+/**
+ * 초기 신호원 필터 (모두 활성화)
+ */
+const initialSigSrcCdFilter = new Set([
+ SIGNAL_SOURCE_CODE_AIS,
+ SIGNAL_SOURCE_CODE_ENAV,
+ SIGNAL_SOURCE_CODE_VPASS,
+ SIGNAL_SOURCE_CODE_VTS_AIS,
+ SIGNAL_SOURCE_CODE_RADAR,
+ SIGNAL_SOURCE_CODE_D_MF_HF,
+]);
+
+/**
+ * 초기 선종 필터 (모두 활성화)
+ */
+const initialShipKindCodeFilter = new Set([
+ SIGNAL_KIND_CODE_FISHING,
+ SIGNAL_KIND_CODE_KCGV,
+ SIGNAL_KIND_CODE_PASSENGER,
+ SIGNAL_KIND_CODE_CARGO,
+ SIGNAL_KIND_CODE_TANKER,
+ SIGNAL_KIND_CODE_GOV,
+ SIGNAL_KIND_CODE_NORMAL,
+ SIGNAL_KIND_CODE_BUOY,
+]);
+
+/**
+ * ReplayStore
+ */
+const useReplayStore = create(
+ subscribeWithSelector((set, get) => ({
+ // ===== 쿼리/연결 상태 =====
+ currentQuery: null, // TrackQueryRequest
+ queryId: null,
+ connectionState: ConnectionState.DISCONNECTED,
+ queryCompleted: false,
+
+ // ===== 진행률 =====
+ progress: 0, // 0-100
+ receivedChunks: 0,
+ totalChunks: null,
+ lastReceivedTimestamp: null,
+
+ // ===== 필터 =====
+ sigSrcCdFilter: initialSigSrcCdFilter,
+ shipKindCodeFilter: initialShipKindCodeFilter,
+
+ // ===== 선박 상태 =====
+ vesselStates: new Map(), // Map
+ deletedVesselIds: new Set(), // 레거시 호환
+ selectedVesselIds: new Set(), // 레거시 호환
+
+ // ===== 필터 모듈 (3계층) =====
+ filterModules: {
+ [FilterModuleType.CUSTOM]: { ...DEFAULT_FILTER_MODULE }, // 아이콘
+ [FilterModuleType.PATH]: {
+ // 항적 라인
+ showNormal: false,
+ showSelected: false,
+ showDeleted: false,
+ },
+ [FilterModuleType.LABEL]: {
+ // 라벨
+ showNormal: true,
+ showSelected: false,
+ showDeleted: false,
+ },
+ },
+
+ // ===== 뷰포트 =====
+ currentViewport: null,
+ currentZoomLevel: 10,
+
+ // ===== 하이라이트 =====
+ highlightedVesselId: null,
+
+ // ===== 액션: 쿼리/연결 =====
+
+ setCurrentQuery: (query) => set({ currentQuery: query }),
+
+ setQueryId: (queryId) => set({ queryId }),
+
+ setConnectionState: (state) => set({ connectionState: state }),
+
+ setQueryCompleted: (completed) => set({ queryCompleted: completed }),
+
+ // ===== 액션: 진행률 =====
+
+ updateProgress: (received, total, timestamp) =>
+ set({
+ receivedChunks: received,
+ totalChunks: total,
+ lastReceivedTimestamp: timestamp,
+ progress: total > 0 ? (received / total) * 100 : 0,
+ }),
+
+ setProgress: (progress) => set({ progress }),
+
+ // ===== 액션: 필터 =====
+
+ toggleSigSrcCd: (code) => {
+ const filter = get().sigSrcCdFilter;
+ const newFilter = new Set(filter);
+ if (newFilter.has(code)) {
+ newFilter.delete(code);
+ } else {
+ newFilter.add(code);
+ }
+ set({ sigSrcCdFilter: newFilter });
+ },
+
+ toggleShipKindCode: (code) => {
+ const filter = get().shipKindCodeFilter;
+ const newFilter = new Set(filter);
+ if (newFilter.has(code)) {
+ newFilter.delete(code);
+ } else {
+ newFilter.add(code);
+ }
+ set({ shipKindCodeFilter: newFilter });
+ },
+
+ // ===== 액션: 선박 상태 =====
+
+ setVesselState: (vesselId, state) => {
+ const currentState = get();
+ const newVesselStates = new Map(currentState.vesselStates);
+
+ if (state === VesselState.NORMAL) {
+ newVesselStates.delete(vesselId);
+ } else {
+ newVesselStates.set(vesselId, state);
+ }
+
+ // 레거시 호환용 Set 업데이트
+ const newDeletedVesselIds = new Set(currentState.deletedVesselIds);
+ const newSelectedVesselIds = new Set(currentState.selectedVesselIds);
+
+ // 기존 상태 제거
+ newDeletedVesselIds.delete(vesselId);
+ newSelectedVesselIds.delete(vesselId);
+
+ // 새 상태 추가
+ if (state === VesselState.DELETED) {
+ newDeletedVesselIds.add(vesselId);
+ } else if (state === VesselState.SELECTED) {
+ newSelectedVesselIds.add(vesselId);
+ }
+
+ set({
+ vesselStates: newVesselStates,
+ deletedVesselIds: newDeletedVesselIds,
+ selectedVesselIds: newSelectedVesselIds,
+ });
+ },
+
+ getVesselState: (vesselId) => {
+ const state = get().vesselStates.get(vesselId);
+ if (state) return state;
+
+ // 레거시 호환
+ if (get().deletedVesselIds.has(vesselId)) return VesselState.DELETED;
+ if (get().selectedVesselIds.has(vesselId)) return VesselState.SELECTED;
+ return VesselState.NORMAL;
+ },
+
+ toggleVesselState: (vesselId, targetState) => {
+ const currentVesselState = get().getVesselState(vesselId);
+ if (currentVesselState === targetState) {
+ get().setVesselState(vesselId, VesselState.NORMAL);
+ } else {
+ get().setVesselState(vesselId, targetState);
+ }
+ },
+
+ clearVesselState: (vesselId) => {
+ get().setVesselState(vesselId, VesselState.NORMAL);
+ },
+
+ // ===== 액션: 필터 모듈 =====
+
+ updateFilterModule: (moduleId, config) => {
+ set((state) => ({
+ filterModules: {
+ ...state.filterModules,
+ [moduleId]: {
+ ...state.filterModules[moduleId],
+ ...config,
+ },
+ },
+ }));
+ },
+
+ setFilterModuleAll: (moduleId, enabled) => {
+ set((state) => ({
+ filterModules: {
+ ...state.filterModules,
+ [moduleId]: {
+ showNormal: enabled,
+ showSelected: enabled,
+ showDeleted: enabled,
+ },
+ },
+ }));
+ },
+
+ // ===== 액션: 하이라이트 =====
+
+ setHighlightedVesselId: (vesselId) => set({ highlightedVesselId: vesselId }),
+
+ // ===== 액션: 뷰포트 =====
+
+ setViewport: (viewport) => set({ currentViewport: viewport }),
+
+ setZoomLevel: (zoom) => set({ currentZoomLevel: zoom }),
+
+ // ===== 액션: 초기화 =====
+
+ reset: () => {
+ // mergedTrackStore 클리어
+ useMergedTrackStore.getState().clear();
+
+ // playbackTrailStore 초기화 (setEnabled(false)가 trails와 frameIndex도 클리어)
+ usePlaybackTrailStore.getState().setEnabled(false);
+
+ set({
+ currentQuery: null,
+ queryId: null,
+ connectionState: ConnectionState.DISCONNECTED,
+ queryCompleted: false,
+ progress: 0,
+ receivedChunks: 0,
+ totalChunks: null,
+ lastReceivedTimestamp: null,
+ vesselStates: new Map(),
+ deletedVesselIds: new Set(),
+ selectedVesselIds: new Set(),
+ sigSrcCdFilter: initialSigSrcCdFilter,
+ shipKindCodeFilter: initialShipKindCodeFilter,
+ highlightedVesselId: null,
+ filterModules: {
+ [FilterModuleType.CUSTOM]: { ...DEFAULT_FILTER_MODULE },
+ [FilterModuleType.PATH]: {
+ showNormal: false,
+ showSelected: false,
+ showDeleted: false,
+ },
+ [FilterModuleType.LABEL]: {
+ showNormal: true,
+ showSelected: false,
+ showDeleted: false,
+ },
+ },
+ });
+ },
+ }))
+);
+
+export default useReplayStore;
diff --git a/src/replay/types/replay.types.js b/src/replay/types/replay.types.js
new file mode 100644
index 00000000..30e9661e
--- /dev/null
+++ b/src/replay/types/replay.types.js
@@ -0,0 +1,207 @@
+/**
+ * 리플레이 타입 정의 및 상수
+ * 참조: src/tracking/types/tracking.types.ts
+ */
+
+// =====================================
+// 연결 상태
+// =====================================
+export const ConnectionState = {
+ DISCONNECTED: 'DISCONNECTED',
+ CONNECTING: 'CONNECTING',
+ CONNECTED: 'CONNECTED',
+ ERROR: 'ERROR',
+};
+
+// =====================================
+// 쿼리 상태
+// =====================================
+export const QueryStatus = {
+ PROCESSING: 'PROCESSING',
+ COMPLETED: 'COMPLETED',
+ ERROR: 'ERROR',
+ CANCELLED: 'CANCELLED',
+};
+
+// =====================================
+// 선박 상태 (기본/선택/삭제)
+// =====================================
+export const VesselState = {
+ NORMAL: 'NORMAL',
+ SELECTED: 'SELECTED',
+ DELETED: 'DELETED',
+};
+
+// =====================================
+// 간소화 모드
+// =====================================
+export const SimplificationMode = {
+ AUTO: 'AUTO', // 자동 (zoom 기반)
+ ADAPTIVE: 'ADAPTIVE', // 적응형
+ AGGRESSIVE: 'AGGRESSIVE', // 공격적 (최대 압축)
+};
+
+// =====================================
+// 배속 옵션
+// =====================================
+export const PLAYBACK_SPEEDS = [1, 10, 50, 100, 500, 1000];
+
+// =====================================
+// 필터 모듈 타입
+// =====================================
+export const FilterModuleType = {
+ CUSTOM: 'custom', // 선박 아이콘
+ PATH: 'path', // 항적 라인
+ LABEL: 'label', // 라벨
+};
+
+// =====================================
+// 기본 필터 설정
+// =====================================
+export const DEFAULT_FILTER_MODULE = {
+ showNormal: true,
+ showSelected: true,
+ showDeleted: false,
+};
+
+// =====================================
+// 요청/응답 타입 체크
+// =====================================
+
+/**
+ * TrackChunkResponse 타입 체크
+ */
+export function isTrackChunkResponse(data) {
+ return (
+ data &&
+ typeof data === 'object' &&
+ typeof data.queryId === 'string' &&
+ typeof data.chunkIndex === 'number' &&
+ (Array.isArray(data.tracks) ||
+ Array.isArray(data.mergedTracks) ||
+ Array.isArray(data.compactTracks))
+ );
+}
+
+/**
+ * QueryStatusUpdate 타입 체크
+ */
+export function isQueryStatusUpdate(data) {
+ return (
+ data &&
+ typeof data === 'object' &&
+ typeof data.queryId === 'string' &&
+ typeof data.status === 'string' &&
+ Object.values(QueryStatus).includes(data.status)
+ );
+}
+
+// =====================================
+// 청크 응답 정규화
+// =====================================
+
+/**
+ * 청크 응답에서 tracks 추출
+ * 다양한 필드명 지원 (tracks, mergedTracks, compactTracks)
+ */
+export function extractTracks(chunkResponse) {
+ return (
+ chunkResponse.tracks ||
+ chunkResponse.mergedTracks ||
+ chunkResponse.compactTracks ||
+ []
+ );
+}
+
+/**
+ * 청크 응답 정규화
+ */
+export function normalizeChunkResponse(raw) {
+ return {
+ queryId: raw.queryId,
+ chunkIndex: raw.chunkIndex,
+ totalChunks: raw.totalChunks || null,
+ tracks: extractTracks(raw),
+ estimatedSize: raw.estimatedSize || 0,
+ metadata: raw.metadata || null,
+ };
+}
+
+// =====================================
+// 유틸리티 함수
+// =====================================
+
+/**
+ * 타임스탬프 파싱 (밀리초로 변환)
+ * 참조: mda-react-front/src/tracking/stores/mergedTrackStore.ts - getMergedPath
+ *
+ * 지원 형식:
+ * - Unix timestamp (10자리 이상 숫자 문자열) → 초 단위 → * 1000
+ * - KST 문자열 ('YYYY-MM-DD HH:mm:ss') → 브라우저 로컬 시간대로 해석
+ * - ISO 형식 → Date.parse()
+ */
+export function parseTimestamp(timestamp) {
+ if (typeof timestamp === 'number') {
+ // 10억보다 작으면 초 단위로 간주 (2001년 9월 이전은 초 단위)
+ if (timestamp < 10000000000) {
+ return timestamp * 1000;
+ }
+ return timestamp;
+ }
+
+ if (typeof timestamp === 'string') {
+ // Unix timestamp 감지 (10자리 이상 숫자 문자열)
+ if (/^\d{10,}$/.test(timestamp)) {
+ // UTC Unix timestamp를 밀리초로 변환 (시간대 변환 없음)
+ return parseInt(timestamp, 10) * 1000;
+ }
+
+ // KST 문자열 처리 ('YYYY-MM-DD HH:mm:ss' 형식 - KST 시간)
+ if (timestamp.includes(' ') && !timestamp.includes('T')) {
+ const [datePart, timePart] = timestamp.split(' ');
+ // 브라우저가 로컬 시간대(KST)로 해석하여 UTC ms 반환
+ return new Date(`${datePart}T${timePart}`).getTime();
+ }
+
+ // ISO 형식 또는 기타
+ const parsed = new Date(timestamp).getTime();
+ return isNaN(parsed) ? 0 : parsed;
+ }
+
+ return 0;
+}
+
+/**
+ * 배속 라벨 생성
+ */
+export function getSpeedLabel(speed) {
+ if (speed === 1) return '1x';
+ if (speed < 1000) return `${speed}x`;
+ return `${speed / 1000}k`;
+}
+
+/**
+ * 선박 종류 한글 라벨
+ */
+export const SHIP_KIND_LABELS = {
+ '000020': '어선',
+ '000021': '경비함정',
+ '000022': '여객선',
+ '000023': '화물선',
+ '000024': '유조선',
+ '000025': '관공선',
+ '000027': '일반',
+ '000028': '부이',
+};
+
+/**
+ * 신호원 한글 라벨
+ */
+export const SIGNAL_SOURCE_LABELS = {
+ '000001': 'AIS',
+ '000002': 'E-NAV',
+ '000003': 'V-PASS',
+ '000004': 'VTS AIS',
+ '000005': 'RADAR',
+ '000016': 'D MF/HF',
+};
diff --git a/src/replay/utils/replayLayerRegistry.js b/src/replay/utils/replayLayerRegistry.js
new file mode 100644
index 00000000..399d5d4a
--- /dev/null
+++ b/src/replay/utils/replayLayerRegistry.js
@@ -0,0 +1,19 @@
+/**
+ * 리플레이 레이어 전역 레지스트리
+ * 참조: src/tracking/utils/trackQueryLayerUtils.ts (window.__trackQueryLayers__ 패턴)
+ *
+ * useReplayLayer 훅이 레이어를 등록하면
+ * useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합
+ */
+
+export function registerReplayLayers(layers) {
+ window.__replayLayers__ = layers;
+}
+
+export function getReplayLayers() {
+ return window.__replayLayers__ || [];
+}
+
+export function unregisterReplayLayers() {
+ window.__replayLayers__ = [];
+}
diff --git a/src/utils/liveControl.js b/src/utils/liveControl.js
new file mode 100644
index 00000000..477dcc1a
--- /dev/null
+++ b/src/utils/liveControl.js
@@ -0,0 +1,44 @@
+/**
+ * 라이브 선박 표시 제어 유틸리티
+ *
+ * 항적조회, 리플레이, 초기화 버튼 등에서 공통으로 사용
+ * trackQueryStore.hideLiveShips 상태를 기반으로 라이브 선박 표시를 제어
+ */
+import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
+import { shipBatchRenderer } from '../map/ShipBatchRenderer';
+
+/**
+ * 라이브 선박 숨기기
+ */
+export function hideLiveShips() {
+ useTrackQueryStore.getState().setHideLiveShips(true);
+ shipBatchRenderer.immediateRender();
+}
+
+/**
+ * 라이브 선박 표시
+ */
+export function showLiveShips() {
+ useTrackQueryStore.getState().setHideLiveShips(false);
+ shipBatchRenderer.immediateRender();
+}
+
+/**
+ * 라이브 선박 표시 토글
+ * @returns {boolean} 토글 후 hideLiveShips 상태
+ */
+export function toggleLiveShips() {
+ const currentState = useTrackQueryStore.getState().hideLiveShips;
+ const newState = !currentState;
+ useTrackQueryStore.getState().setHideLiveShips(newState);
+ shipBatchRenderer.immediateRender();
+ return newState;
+}
+
+/**
+ * 라이브 선박 숨김 상태 확인
+ * @returns {boolean} true면 라이브 선박 숨김 상태
+ */
+export function isLiveShipsHidden() {
+ return useTrackQueryStore.getState().hideLiveShips;
+}