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:
HeungTak Lee 2026-02-05 06:37:20 +09:00
부모 e74688a969
커밋 b209c9498c
31개의 변경된 파일7173개의 추가작업 그리고 0개의 파일을 삭제

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
파일 보기

@ -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%;
}
}
}
}

파일 보기

@ -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;

파일 보기

@ -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%;
}
}
}
}

파일 보기

@ -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>
);
}

파일 보기

@ -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);
}
}
}

파일 보기

@ -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);

파일 보기

@ -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);
}

파일 보기

@ -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);

파일 보기

@ -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;
}
}
}

파일 보기

@ -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);

파일 보기

@ -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;
}
}

파일 보기

@ -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);

파일 보기

@ -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;
}
}
}
}
}
}

파일 보기

@ -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
};
};

파일 보기

@ -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));
};

파일 보기

@ -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();
};
}, []);
}

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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',
};

파일 보기

@ -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
파일 보기

@ -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;
}