From b209c9498c1221176a5e7c34cf2ec4f8b8061f1d Mon Sep 17 00:00:00 2001 From: HeungTak Lee Date: Thu, 5 Feb 2026 06:37:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A6=AC=ED=94=8C=EB=A0=88=EC=9D=B4=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - replay 패키지 (stores, components, hooks, services, utils, types) - WebSocket 기반 청크 데이터 수신 (ReplayWebSocketService) - 시간 기반 애니메이션 (재생/일시정지/정지, 배속 1x~1000x) - 항적 표시 토글 (playbackTrailStore - 프레임 기반 페이딩) - 선박 상태 관리 + 필터링 (선종, 신호원) - 드래그 가능한 타임라인 컨트롤러 - 라이브/리플레이 전환 (liveControl) Co-Authored-By: Claude Opus 4.5 --- src/pages/ReplayPage.jsx | 362 ++++++++++ src/pages/ReplayPage.scss | 192 ++++++ src/replay/components/ReplayControlV2.jsx | 227 +++++++ src/replay/components/ReplayControlV2.scss | 161 +++++ src/replay/components/ReplayTimeline.jsx | 294 ++++++++ src/replay/components/ReplayTimeline.scss | 421 ++++++++++++ .../VesselListManager/VesselContextMenu.jsx | 82 +++ .../VesselListManager/VesselContextMenu.scss | 90 +++ .../VesselListManager/VesselItem.jsx | 264 ++++++++ .../VesselListManager/VesselItem.scss | 213 ++++++ .../VesselListManager/VesselListManager.jsx | 309 +++++++++ .../VesselListManager/VesselListManager.scss | 204 ++++++ .../VesselListManager/VesselListPanel.jsx | 183 +++++ .../VesselListManager/VesselListPanel.scss | 257 +++++++ .../VesselListManager/VesselSearchFilter.jsx | 225 +++++++ .../VesselListManager/VesselSearchFilter.scss | 364 ++++++++++ .../VesselListManager/VirtualVesselList.jsx | 102 +++ .../hooks/useVesselActions.js | 158 +++++ .../hooks/useVesselClassification.js | 128 ++++ .../hooks/useVirtualScroll.js | 87 +++ .../components/VesselListManager/index.js | 10 + .../utils/countryCodeUtils.js | 396 +++++++++++ src/replay/hooks/useReplayLayer.js | 441 ++++++++++++ src/replay/services/ReplayWebSocketService.js | 627 ++++++++++++++++++ src/replay/stores/animationStore.js | 381 +++++++++++ src/replay/stores/mergedTrackStore.js | 200 ++++++ src/replay/stores/playbackTrailStore.js | 233 +++++++ src/replay/stores/replayStore.js | 292 ++++++++ src/replay/types/replay.types.js | 207 ++++++ src/replay/utils/replayLayerRegistry.js | 19 + src/utils/liveControl.js | 44 ++ 31 files changed, 7173 insertions(+) create mode 100644 src/pages/ReplayPage.jsx create mode 100644 src/pages/ReplayPage.scss create mode 100644 src/replay/components/ReplayControlV2.jsx create mode 100644 src/replay/components/ReplayControlV2.scss create mode 100644 src/replay/components/ReplayTimeline.jsx create mode 100644 src/replay/components/ReplayTimeline.scss create mode 100644 src/replay/components/VesselListManager/VesselContextMenu.jsx create mode 100644 src/replay/components/VesselListManager/VesselContextMenu.scss create mode 100644 src/replay/components/VesselListManager/VesselItem.jsx create mode 100644 src/replay/components/VesselListManager/VesselItem.scss create mode 100644 src/replay/components/VesselListManager/VesselListManager.jsx create mode 100644 src/replay/components/VesselListManager/VesselListManager.scss create mode 100644 src/replay/components/VesselListManager/VesselListPanel.jsx create mode 100644 src/replay/components/VesselListManager/VesselListPanel.scss create mode 100644 src/replay/components/VesselListManager/VesselSearchFilter.jsx create mode 100644 src/replay/components/VesselListManager/VesselSearchFilter.scss create mode 100644 src/replay/components/VesselListManager/VirtualVesselList.jsx create mode 100644 src/replay/components/VesselListManager/hooks/useVesselActions.js create mode 100644 src/replay/components/VesselListManager/hooks/useVesselClassification.js create mode 100644 src/replay/components/VesselListManager/hooks/useVirtualScroll.js create mode 100644 src/replay/components/VesselListManager/index.js create mode 100644 src/replay/components/VesselListManager/utils/countryCodeUtils.js create mode 100644 src/replay/hooks/useReplayLayer.js create mode 100644 src/replay/services/ReplayWebSocketService.js create mode 100644 src/replay/stores/animationStore.js create mode 100644 src/replay/stores/mergedTrackStore.js create mode 100644 src/replay/stores/playbackTrailStore.js create mode 100644 src/replay/stores/replayStore.js create mode 100644 src/replay/types/replay.types.js create mode 100644 src/replay/utils/replayLayerRegistry.js create mode 100644 src/utils/liveControl.js 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 ( + + ); +} 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 || '-'} + {shipKindName} { e.target.style.display = 'none'; }} + /> + {countryName} { 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) => ( + + ))} +
+ ) + ) : ( +
+
{panelEmptyMessage}
+
+ )} +
+
+ ); +}; + +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; +}