feat: 리플레이 모드 구현
- replay 패키지 (stores, components, hooks, services, utils, types) - WebSocket 기반 청크 데이터 수신 (ReplayWebSocketService) - 시간 기반 애니메이션 (재생/일시정지/정지, 배속 1x~1000x) - 항적 표시 토글 (playbackTrailStore - 프레임 기반 페이딩) - 선박 상태 관리 + 필터링 (선종, 신호원) - 드래그 가능한 타임라인 컨트롤러 - 라이브/리플레이 전환 (liveControl) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
부모
e74688a969
커밋
b209c9498c
362
src/pages/ReplayPage.jsx
Normal file
362
src/pages/ReplayPage.jsx
Normal file
@ -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 (
|
||||||
|
<aside className={`slidePanel replay-panel ${isOpen ? '' : 'is-closed'}`}>
|
||||||
|
{/* 토글 버튼 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="toogle"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-label="패널 토글"
|
||||||
|
>
|
||||||
|
<span className="blind">패널 토글</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="panelHeader">
|
||||||
|
<h2 className="panelTitle">리플레이</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="panelBody">
|
||||||
|
{/* 조회 조건 */}
|
||||||
|
<div className="query-section">
|
||||||
|
<h3 className="section-title">조회 기간</h3>
|
||||||
|
|
||||||
|
{/* 시작 시간 */}
|
||||||
|
<div className="query-row">
|
||||||
|
<label htmlFor="replay-start-date" className="query-label">시작</label>
|
||||||
|
<div className="datetime-inputs">
|
||||||
|
<input
|
||||||
|
id="replay-start-date"
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => handleStartDateChange(e.target.value)}
|
||||||
|
disabled={isQuerying}
|
||||||
|
className="input-date"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id="replay-start-time"
|
||||||
|
type="time"
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
|
disabled={isQuerying}
|
||||||
|
className="input-time"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 종료 시간 */}
|
||||||
|
<div className="query-row">
|
||||||
|
<label htmlFor="replay-end-date" className="query-label">종료</label>
|
||||||
|
<div className="datetime-inputs">
|
||||||
|
<input
|
||||||
|
id="replay-end-date"
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => handleEndDateChange(e.target.value)}
|
||||||
|
disabled={isQuerying}
|
||||||
|
className="input-date"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id="replay-end-time"
|
||||||
|
type="time"
|
||||||
|
value={endTime}
|
||||||
|
onChange={(e) => setEndTime(e.target.value)}
|
||||||
|
disabled={isQuerying}
|
||||||
|
className="input-time"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="btnBox">
|
||||||
|
{isQuerying ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-cancel btn-query"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-query"
|
||||||
|
onClick={handleQuery}
|
||||||
|
>
|
||||||
|
조회
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조회 결과 영역 */}
|
||||||
|
<div className="result-section">
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="error-message">{errorMessage}</div>
|
||||||
|
)}
|
||||||
|
{connectionState === ConnectionState.CONNECTING && (
|
||||||
|
<div className="loading-message">서버 연결 중...</div>
|
||||||
|
)}
|
||||||
|
{isQuerying && connectionState === ConnectionState.CONNECTED && (
|
||||||
|
<div className="loading-message">
|
||||||
|
<div>데이터를 불러오는 중입니다...</div>
|
||||||
|
{progress > 0 && (
|
||||||
|
<div className="progress-info">{Math.min(progress, 100).toFixed(0)}% 완료</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{queryCompleted && (
|
||||||
|
<>
|
||||||
|
{/* 필터 컨트롤 (선종, 선박목록, 항적, 라벨) */}
|
||||||
|
<ReplayControlV2 />
|
||||||
|
{/* 선박 분류 관리 패널 */}
|
||||||
|
<VesselListManager className="vessel-list-manager-replay" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isQuerying && !queryCompleted && !errorMessage && connectionState !== ConnectionState.CONNECTING && (
|
||||||
|
<div className="empty-message">
|
||||||
|
조회 버튼을 클릭하여 리플레이를 시작하세요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
src/pages/ReplayPage.scss
Normal file
192
src/pages/ReplayPage.scss
Normal file
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
227
src/replay/components/ReplayControlV2.jsx
Normal file
227
src/replay/components/ReplayControlV2.jsx
Normal file
@ -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 (
|
||||||
|
<div className="replay-control-v2">
|
||||||
|
{/* 헤더 (접기/펼치기) */}
|
||||||
|
<div className="filter-header-toggle" onClick={openHandler}>
|
||||||
|
<span className="filter-title">분석 결과 - 필터 설정</span>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="8"
|
||||||
|
viewBox="0 0 12 8"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={`toggle-icon ${isOpen ? 'is-open' : ''}`}
|
||||||
|
>
|
||||||
|
<path d="M11 1.5L6 6.5L1 1.5" stroke="#888888" strokeWidth="1.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
{/* 커스텀 필터 (선박 목록) */}
|
||||||
|
<div className="sig-src-cd-filter">
|
||||||
|
<div className="filter-header">
|
||||||
|
<h3>
|
||||||
|
<div>선박</div>
|
||||||
|
<div>목록</div>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="filter-list">
|
||||||
|
{CUSTOM_FILTER_OPTIONS.map(option => (
|
||||||
|
<div key={option.key} className="filter-item-wrapper">
|
||||||
|
<strong>{option.label}{getCountLabel(option.key)}</strong>
|
||||||
|
<div className="toggle-area">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`ship-${option.key}`}
|
||||||
|
checked={filterModules.custom[option.key]}
|
||||||
|
onChange={() => handleCustomFilterToggle(option.key)}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`ship-${option.key}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선종 필터 */}
|
||||||
|
<div className="sig-src-cd-filter">
|
||||||
|
<div className="filter-header">
|
||||||
|
<h3>선종</h3>
|
||||||
|
</div>
|
||||||
|
<div className="filter-list">
|
||||||
|
{SHIP_KIND_OPTIONS.map(option => (
|
||||||
|
<div key={option.code} className="filter-item-wrapper">
|
||||||
|
<strong>{option.label}</strong>
|
||||||
|
<div className="toggle-area">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={option.code}
|
||||||
|
checked={shipKindCodeFilter.has(option.code)}
|
||||||
|
onChange={() => toggleShipKindCode(option.code)}
|
||||||
|
/>
|
||||||
|
<label htmlFor={option.code} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 항적 필터 */}
|
||||||
|
<div className="sig-src-cd-filter">
|
||||||
|
<div className="filter-header">
|
||||||
|
<h3>항적</h3>
|
||||||
|
</div>
|
||||||
|
<div className="filter-list">
|
||||||
|
{CUSTOM_FILTER_OPTIONS.map(option => (
|
||||||
|
<div key={option.key} className="filter-item-wrapper">
|
||||||
|
<strong>{option.label}{getCountLabel(option.key)}</strong>
|
||||||
|
<div className="toggle-area">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`track-${option.key}`}
|
||||||
|
checked={filterModules.path[option.key]}
|
||||||
|
onChange={() => handlePathFilterToggle(option.key)}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`track-${option.key}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 라벨 필터 */}
|
||||||
|
<div className="sig-src-cd-filter">
|
||||||
|
<div className="filter-header">
|
||||||
|
<h3>라벨</h3>
|
||||||
|
</div>
|
||||||
|
<div className="filter-list">
|
||||||
|
{CUSTOM_FILTER_OPTIONS.map(option => (
|
||||||
|
<div key={option.key} className="filter-item-wrapper">
|
||||||
|
<strong>{option.label}{getCountLabel(option.key)}</strong>
|
||||||
|
<div className="toggle-area">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`label-${option.key}`}
|
||||||
|
checked={filterModules.label[option.key]}
|
||||||
|
onChange={() => handleLabelFilterToggle(option.key)}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`label-${option.key}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 항적표시 토글 (애니메이션 재생 시 이동 궤적) */}
|
||||||
|
<div className="sig-src-cd-filter trail-filter">
|
||||||
|
<div className="filter-header">
|
||||||
|
<h3>항적</h3>
|
||||||
|
</div>
|
||||||
|
<div className="filter-list">
|
||||||
|
<div className="filter-item-wrapper">
|
||||||
|
<strong>궤적 표시</strong>
|
||||||
|
<div className="toggle-area trail-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="trail-enabled"
|
||||||
|
checked={isTrailEnabled}
|
||||||
|
onChange={handleTrailToggle}
|
||||||
|
/>
|
||||||
|
<label htmlFor="trail-enabled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReplayControlV2;
|
||||||
161
src/replay/components/ReplayControlV2.scss
Normal file
161
src/replay/components/ReplayControlV2.scss
Normal file
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
294
src/replay/components/ReplayTimeline.jsx
Normal file
294
src/replay/components/ReplayTimeline.jsx
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`replay-timeline ${isPlaying ? 'playing' : ''} ${isDragging ? 'dragging' : ''}`}
|
||||||
|
style={{
|
||||||
|
transform: position.x !== 0 || position.y !== 0
|
||||||
|
? `translate(${position.x}px, ${position.y}px)`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 드래그 가능한 헤더 */}
|
||||||
|
<div className="timeline-header" onMouseDown={handleMouseDown}>
|
||||||
|
<div className="header-content">
|
||||||
|
<span className="header-title">리플레이</span>
|
||||||
|
{dateRangeText && <span className="header-date-range">{dateRangeText}</span>}
|
||||||
|
</div>
|
||||||
|
{onClose && (
|
||||||
|
<button type="button" className="header-close-btn" onClick={onClose} title="닫기">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컨트롤 영역 */}
|
||||||
|
<div className="timeline-controls">
|
||||||
|
{/* 배속 선택 */}
|
||||||
|
<div className="speed-selector" ref={speedMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="speed-btn"
|
||||||
|
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
|
||||||
|
disabled={!hasData}
|
||||||
|
>
|
||||||
|
{playbackSpeed}x
|
||||||
|
</button>
|
||||||
|
{showSpeedMenu && (
|
||||||
|
<div className="speed-menu">
|
||||||
|
{PLAYBACK_SPEED_OPTIONS.map((speed) => (
|
||||||
|
<button
|
||||||
|
key={speed}
|
||||||
|
type="button"
|
||||||
|
className={`speed-option ${playbackSpeed === speed ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSpeedChange(speed)}
|
||||||
|
>
|
||||||
|
{speed}x
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 재생/일시정지 버튼 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`control-btn play-btn ${isPlaying ? 'playing' : ''}`}
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
disabled={!hasData}
|
||||||
|
title={isPlaying ? '일시정지' : '재생'}
|
||||||
|
>
|
||||||
|
{isPlaying ? '❚❚' : '▶'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 정지 버튼 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="control-btn stop-btn"
|
||||||
|
onClick={handleStop}
|
||||||
|
disabled={!hasData}
|
||||||
|
title="정지"
|
||||||
|
>
|
||||||
|
■
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 슬라이더 */}
|
||||||
|
<div className="timeline-slider-container" ref={sliderContainerRef}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="timeline-slider"
|
||||||
|
min={startTime}
|
||||||
|
max={endTime}
|
||||||
|
step={(endTime - startTime) / 1000}
|
||||||
|
value={currentTime}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
disabled={!hasData}
|
||||||
|
style={{ '--progress': `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현재 시간 */}
|
||||||
|
<span className="current-time-display">
|
||||||
|
{hasData ? formatDateTime(currentTime) : '--:--:--'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 항적표시 토글 */}
|
||||||
|
<label className="trail-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isTrailEnabled}
|
||||||
|
onChange={handleTrailToggle}
|
||||||
|
disabled={!hasData}
|
||||||
|
/>
|
||||||
|
<span>항적</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
421
src/replay/components/ReplayTimeline.scss
Normal file
421
src/replay/components/ReplayTimeline.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<div
|
||||||
|
className="vessel-context-menu"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: adjustedPosition.y,
|
||||||
|
left: adjustedPosition.x,
|
||||||
|
zIndex: 10001, // VesselListManager보다 높게
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="context-menu-header">
|
||||||
|
<span className="vessel-name">{vessel.shipName}</span>
|
||||||
|
<span className="vessel-id">ID: {vessel.vesselId}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="context-menu-divider"></div>
|
||||||
|
|
||||||
|
<div className="context-menu-items">
|
||||||
|
<button
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={handleShowDetail}
|
||||||
|
>
|
||||||
|
<i className="fas fa-info-circle"></i>
|
||||||
|
<span>상세보기</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(VesselContextMenu);
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
264
src/replay/components/VesselListManager/VesselItem.jsx
Normal file
264
src/replay/components/VesselListManager/VesselItem.jsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`vessel-item ${isDragging ? 'dragging' : ''} ${isDragDisabled ? 'disabled' : ''} ${
|
||||||
|
isSelected ? 'selected' : ''
|
||||||
|
}`}
|
||||||
|
title={`${vessel.shipName} (${shipKindName})`}
|
||||||
|
draggable={!isDragDisabled}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onContextMenu={handleRightClick}
|
||||||
|
>
|
||||||
|
<div className="vessel-item-content">
|
||||||
|
{/* 선택 체크박스 */}
|
||||||
|
{onToggleSelection && (
|
||||||
|
<div className="selection-checkbox" onClick={handleToggleSelection}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => {}} // onClick에서 처리
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
<span className="checkmark"></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="vessel-index">{index + 1}</div>
|
||||||
|
|
||||||
|
{/* 선박 정보 */}
|
||||||
|
<div className="vessel-info">
|
||||||
|
<div className="vessel-name">
|
||||||
|
<span className="name-text">{vessel.shipName || '-'}</span>
|
||||||
|
<img
|
||||||
|
src={shipKindIcon}
|
||||||
|
alt={shipKindName}
|
||||||
|
className="ship-kind-icon"
|
||||||
|
onError={(e) => { e.target.style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src={countryFlagUrl}
|
||||||
|
alt={countryName}
|
||||||
|
className="country-flag"
|
||||||
|
onError={(e) => { e.target.style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
<span className="signal-source">({signalSourceName})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 드래그 핸들 아이콘 */}
|
||||||
|
{!isDragDisabled && (
|
||||||
|
<div className="drag-handle">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||||
|
<circle cx="3" cy="2" r="1.5" />
|
||||||
|
<circle cx="9" cy="2" r="1.5" />
|
||||||
|
<circle cx="3" cy="6" r="1.5" />
|
||||||
|
<circle cx="9" cy="6" r="1.5" />
|
||||||
|
<circle cx="3" cy="10" r="1.5" />
|
||||||
|
<circle cx="9" cy="10" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 드래그 중 오버레이 */}
|
||||||
|
{isDragging && <div className="drag-overlay" />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(VesselItem);
|
||||||
213
src/replay/components/VesselListManager/VesselItem.scss
Normal file
213
src/replay/components/VesselListManager/VesselItem.scss
Normal file
@ -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);
|
||||||
|
}
|
||||||
309
src/replay/components/VesselListManager/VesselListManager.jsx
Normal file
309
src/replay/components/VesselListManager/VesselListManager.jsx
Normal file
@ -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 (
|
||||||
|
<div className={`vessel-list-manager ${className}`}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="manager-header-toggle" onClick={openHandler}>
|
||||||
|
<span className="header-title">
|
||||||
|
분석 결과 - 선박 목록 {`(총 ${totalCount.toLocaleString()}척)`}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="8"
|
||||||
|
viewBox="0 0 12 8"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={`toggle-icon ${isOpen ? 'is-open' : ''}`}
|
||||||
|
>
|
||||||
|
<path d="M11 1.5L6 6.5L1 1.5" stroke="#888888" strokeWidth="1.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 드래그앤드롭 컨테이너 - 데이터 유무와 관계없이 표시 */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="manager-content">
|
||||||
|
{/* 검색/필터 영역 */}
|
||||||
|
<VesselSearchFilter
|
||||||
|
vessels={allVessels}
|
||||||
|
onFilteredVesselsChange={setFilteredVessels}
|
||||||
|
onSelectedVesselsChange={setSelectedVesselIds}
|
||||||
|
selectedVesselIds={selectedVesselIds}
|
||||||
|
onBulkStateChange={handleBulkStateChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 탭 헤더 */}
|
||||||
|
<div className="tab-header">
|
||||||
|
<div
|
||||||
|
className={`tab-item ${tap === 1 ? 'active' : ''}`}
|
||||||
|
onClick={() => setTap(1)}
|
||||||
|
>
|
||||||
|
기본 ({filteredVesselsByState.normal.length})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`tab-item ${tap === 2 ? 'active' : ''}`}
|
||||||
|
onClick={() => setTap(2)}
|
||||||
|
>
|
||||||
|
선택 ({filteredVesselsByState.selected.length})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`tab-item ${tap === 3 ? 'active' : ''}`}
|
||||||
|
onClick={() => setTap(3)}
|
||||||
|
>
|
||||||
|
삭제 ({filteredVesselsByState.deleted.length})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vessel-lists-container">
|
||||||
|
{tap === 1 && (
|
||||||
|
<VesselListPanel
|
||||||
|
state={VesselState.NORMAL}
|
||||||
|
vessels={filteredVesselsByState.normal}
|
||||||
|
title="기본 선박"
|
||||||
|
color="#4ade80"
|
||||||
|
emptyMessage="기본 상태 선박이 없습니다"
|
||||||
|
selectedVesselIds={selectedVesselIds}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onMouseEnterVessel={handleMouseEnterVessel}
|
||||||
|
onMouseLeaveVessel={handleMouseLeaveVessel}
|
||||||
|
onToggleSelection={handleToggleSelection}
|
||||||
|
onShowVesselDetail={handleShowVesselDetail}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
onSelectAllInPanel={handleSelectAllInPanel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tap === 2 && (
|
||||||
|
<VesselListPanel
|
||||||
|
state={VesselState.SELECTED}
|
||||||
|
vessels={filteredVesselsByState.selected}
|
||||||
|
title="선택 선박"
|
||||||
|
color="#4a9eff"
|
||||||
|
emptyMessage="선택된 선박이 없습니다"
|
||||||
|
selectedVesselIds={selectedVesselIds}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onMouseEnterVessel={handleMouseEnterVessel}
|
||||||
|
onMouseLeaveVessel={handleMouseLeaveVessel}
|
||||||
|
onToggleSelection={handleToggleSelection}
|
||||||
|
onShowVesselDetail={handleShowVesselDetail}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
onSelectAllInPanel={handleSelectAllInPanel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tap === 3 && (
|
||||||
|
<VesselListPanel
|
||||||
|
state={VesselState.DELETED}
|
||||||
|
vessels={filteredVesselsByState.deleted}
|
||||||
|
title="삭제 선박"
|
||||||
|
color="#f87171"
|
||||||
|
emptyMessage="삭제된 선박이 없습니다"
|
||||||
|
selectedVesselIds={selectedVesselIds}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onMouseEnterVessel={handleMouseEnterVessel}
|
||||||
|
onMouseLeaveVessel={handleMouseLeaveVessel}
|
||||||
|
onToggleSelection={handleToggleSelection}
|
||||||
|
onShowVesselDetail={handleShowVesselDetail}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
onSelectAllInPanel={handleSelectAllInPanel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컨텍스트 메뉴 */}
|
||||||
|
{contextMenu && (
|
||||||
|
<VesselContextMenu
|
||||||
|
vessel={contextMenu.vessel}
|
||||||
|
position={contextMenu.position}
|
||||||
|
onClose={handleCloseContextMenu}
|
||||||
|
onShowDetail={handleShowVesselDetail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(VesselListManager);
|
||||||
204
src/replay/components/VesselListManager/VesselListManager.scss
Normal file
204
src/replay/components/VesselListManager/VesselListManager.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/replay/components/VesselListManager/VesselListPanel.jsx
Normal file
183
src/replay/components/VesselListManager/VesselListPanel.jsx
Normal file
@ -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 (
|
||||||
|
<div className="vessel-list-panel" data-state={state}>
|
||||||
|
{/* 드롭 영역 */}
|
||||||
|
<div
|
||||||
|
className={`vessel-list-container ${isDragOver ? 'drag-over' : ''}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{/* 선박 목록 - 대용량 데이터는 가상 스크롤 적용 */}
|
||||||
|
{vessels.length > 0 ? (
|
||||||
|
vessels.length > 100 ? (
|
||||||
|
// 100척 초과 시 가상 스크롤링 사용
|
||||||
|
<VirtualVesselList
|
||||||
|
vessels={vessels}
|
||||||
|
selectedVesselIds={selectedVesselIds}
|
||||||
|
onDragStart={handleVesselDragStart}
|
||||||
|
onDragEnd={handleVesselDragEnd}
|
||||||
|
onMouseEnterVessel={onMouseEnterVessel}
|
||||||
|
onMouseLeaveVessel={onMouseLeaveVessel}
|
||||||
|
onToggleSelection={onToggleSelection}
|
||||||
|
onShowVesselDetail={onShowVesselDetail}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
containerHeight={320} // 컨테이너 높이 고정
|
||||||
|
itemHeight={50} // VesselItem 높이 (60px + margin 4px)
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// 100척 이하는 기존 방식 사용
|
||||||
|
<div className="vessel-list">
|
||||||
|
{vessels.map((vessel, index) => (
|
||||||
|
<VesselItem
|
||||||
|
key={vessel.vesselId}
|
||||||
|
vessel={vessel}
|
||||||
|
index={index}
|
||||||
|
isDragDisabled={false}
|
||||||
|
isSelected={selectedVesselIds.has(vessel.vesselId)}
|
||||||
|
onDragStart={handleVesselDragStart}
|
||||||
|
onDragEnd={handleVesselDragEnd}
|
||||||
|
onMouseEnterVessel={onMouseEnterVessel}
|
||||||
|
onMouseLeaveVessel={onMouseLeaveVessel}
|
||||||
|
onToggleSelection={onToggleSelection}
|
||||||
|
onShowVesselDetail={onShowVesselDetail}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="empty-message">
|
||||||
|
<div className="empty-text">{panelEmptyMessage}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(VesselListPanel);
|
||||||
257
src/replay/components/VesselListManager/VesselListPanel.scss
Normal file
257
src/replay/components/VesselListManager/VesselListPanel.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
225
src/replay/components/VesselListManager/VesselSearchFilter.jsx
Normal file
225
src/replay/components/VesselListManager/VesselSearchFilter.jsx
Normal file
@ -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 (
|
||||||
|
<div className="vessel-search-filter-wrapper">
|
||||||
|
<div className="vessel-search-filter">
|
||||||
|
{/* 필터 옵션 */}
|
||||||
|
<div className="filter-row">
|
||||||
|
<div className="filter-group">
|
||||||
|
<select
|
||||||
|
value={selectedShipKind}
|
||||||
|
onChange={e => setSelectedShipKind(e.target.value)}
|
||||||
|
className="filter-select"
|
||||||
|
>
|
||||||
|
<option value="">선종 전체</option>
|
||||||
|
{availableShipKinds.map(kind => (
|
||||||
|
<option key={kind.code} value={kind.code}>
|
||||||
|
{kind.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group">
|
||||||
|
<select
|
||||||
|
value={selectedCountryGroup?.countryName || ''}
|
||||||
|
onChange={e => {
|
||||||
|
const selectedCountryName = e.target.value;
|
||||||
|
const countryGroup = availableCountryOptions.find(c => c.countryName === selectedCountryName);
|
||||||
|
setSelectedCountryGroup(countryGroup || null);
|
||||||
|
}}
|
||||||
|
className="filter-select"
|
||||||
|
>
|
||||||
|
<option value="">국적 전체</option>
|
||||||
|
{availableCountryOptions.map(country => (
|
||||||
|
<option
|
||||||
|
key={country.countryName}
|
||||||
|
value={country.countryName}
|
||||||
|
title={country.displayName}
|
||||||
|
>
|
||||||
|
{country.truncatedName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group search-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="선박명 검색"
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
className="search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 결과 요약 */}
|
||||||
|
{selectedVesselIds.size > 0 && (
|
||||||
|
<div className="filter-summary">
|
||||||
|
<div className="selected-actions">
|
||||||
|
<span className="selected-count">{selectedVesselIds.size}척 선택됨</span>
|
||||||
|
<div className="bulk-actions">
|
||||||
|
<span className="action-label">일괄 이동:</span>
|
||||||
|
<button
|
||||||
|
className="bulk-action-btn normal"
|
||||||
|
onClick={() => handleBulkStateChange(VesselState.NORMAL)}
|
||||||
|
title="선택된 선박들을 일반 상태로 변경"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="bulk-action-btn selected"
|
||||||
|
onClick={() => handleBulkStateChange(VesselState.SELECTED)}
|
||||||
|
title="선택된 선박들을 선택 상태로 변경"
|
||||||
|
>
|
||||||
|
선택
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="bulk-action-btn deleted"
|
||||||
|
onClick={() => handleBulkStateChange(VesselState.DELETED)}
|
||||||
|
title="선택된 선박들을 삭제 상태로 변경"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(VesselSearchFilter);
|
||||||
364
src/replay/components/VesselListManager/VesselSearchFilter.scss
Normal file
364
src/replay/components/VesselListManager/VesselSearchFilter.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/replay/components/VesselListManager/VirtualVesselList.jsx
Normal file
102
src/replay/components/VesselListManager/VirtualVesselList.jsx
Normal file
@ -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) => (
|
||||||
|
<div
|
||||||
|
key={vessel.vesselId}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${vessel.index * itemHeight}px`,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: `${itemHeight}px`,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VesselItem
|
||||||
|
vessel={vessel}
|
||||||
|
index={vessel.index}
|
||||||
|
isDragDisabled={false}
|
||||||
|
isSelected={selectedVesselIds.has(vessel.vesselId)}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onMouseEnterVessel={onMouseEnterVessel}
|
||||||
|
onMouseLeaveVessel={onMouseLeaveVessel}
|
||||||
|
onToggleSelection={onToggleSelection}
|
||||||
|
onShowVesselDetail={onShowVesselDetail}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}, [
|
||||||
|
visibleItems,
|
||||||
|
itemHeight,
|
||||||
|
selectedVesselIds,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onMouseEnterVessel,
|
||||||
|
onMouseLeaveVessel,
|
||||||
|
onToggleSelection,
|
||||||
|
onShowVesselDetail,
|
||||||
|
onContextMenu,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (vessels.length === 0) {
|
||||||
|
return null; // 빈 상태는 상위 컴포넌트에서 처리
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollElementRef}
|
||||||
|
className="virtual-vessel-list"
|
||||||
|
style={{
|
||||||
|
height: containerHeight,
|
||||||
|
overflow: 'auto',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
{/* 가상 컨테이너 (전체 높이 유지) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: totalHeight,
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 실제 렌더링되는 아이템들 */}
|
||||||
|
{renderedItems}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(VirtualVesselList);
|
||||||
@ -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, // 디버깅용
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
10
src/replay/components/VesselListManager/index.js
Normal file
10
src/replay/components/VesselListManager/index.js
Normal file
@ -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';
|
||||||
@ -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));
|
||||||
|
};
|
||||||
441
src/replay/hooks/useReplayLayer.js
Normal file
441
src/replay/hooks/useReplayLayer.js
Normal file
@ -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();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
627
src/replay/services/ReplayWebSocketService.js
Normal file
627
src/replay/services/ReplayWebSocketService.js
Normal file
@ -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;
|
||||||
381
src/replay/stores/animationStore.js
Normal file
381
src/replay/stores/animationStore.js
Normal file
@ -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;
|
||||||
200
src/replay/stores/mergedTrackStore.js
Normal file
200
src/replay/stores/mergedTrackStore.js
Normal file
@ -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<vesselId, VesselChunks>
|
||||||
|
|
||||||
|
// 원본 청크 (리플레이용)
|
||||||
|
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;
|
||||||
233
src/replay/stores/playbackTrailStore.js
Normal file
233
src/replay/stores/playbackTrailStore.js
Normal file
@ -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;
|
||||||
292
src/replay/stores/replayStore.js
Normal file
292
src/replay/stores/replayStore.js
Normal file
@ -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<vesselId, VesselState>
|
||||||
|
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;
|
||||||
207
src/replay/types/replay.types.js
Normal file
207
src/replay/types/replay.types.js
Normal file
@ -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',
|
||||||
|
};
|
||||||
19
src/replay/utils/replayLayerRegistry.js
Normal file
19
src/replay/utils/replayLayerRegistry.js
Normal file
@ -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__ = [];
|
||||||
|
}
|
||||||
44
src/utils/liveControl.js
Normal file
44
src/utils/liveControl.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user