feat: 항적분석(구역 검색) 기능 구현

구역 기반 선박 항적 검색 기능 추가. 사용자가 지도에 최대 3개 구역을
그리고 ANY/ALL/SEQUENTIAL 조건으로 해당 구역을 통과한 선박의 항적을
조회·재생할 수 있다.

신규 패키지 (src/areaSearch/):
- stores: areaSearchStore, areaSearchAnimationStore (재생 제어)
- services: areaSearchApi (REST API + hitDetails 타임스탬프/위치 보간)
- components: AreaSearchPage, ZoneDrawPanel, AreaSearchTimeline, AreaSearchTooltip
- hooks: useAreaSearchLayer (Deck.gl 레이어), useZoneDraw (OL Draw)
- utils: areaSearchLayerRegistry, csvExport (BOM+UTF-8 엑셀 호환)
- types: areaSearch.types (상수, 색상, 모드)

주요 기능:
- 폴리곤/사각형/원 구역 그리기 + 드래그 순서 변경
- 구역별 색상 구분 (빨강/청록/황색)
- 시간 기반 애니메이션 재생 (TripsLayer 궤적 + 가상선박 이동)
- 선종/개별 선박 필터링, 항적 표시/궤적 표시 토글
- 호버 툴팁 (국기 SVG, 구역별 진입/진출 시각·위치)
- CSV 내보내기 (신호원, 식별번호, 국적 ISO 변환, 구역 통과 정보)

기존 파일 수정:
- SideNav/Sidebar: gnb8 '항적분석' 메뉴 활성화
- useShipLayer: areaSearch 레이어 병합
- MapContainer: useAreaSearchLayer 훅 + 호버 핸들러 + 타임라인 렌더링
- trackLayer: layerIds 파라미터 추가 (area search/track query 레이어 ID 분리)
- ShipLegend: 항적분석 모드 선종 카운트 지원
- countryCodeUtils: MMSI MID→ISO alpha-2 매핑 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
LHT 2026-02-10 12:29:31 +09:00
부모 e45be93e71
커밋 dcf24e96d2
23개의 변경된 파일3134개의 추가작업 그리고 27개의 파일을 삭제

파일 보기

@ -0,0 +1,383 @@
import { useState, useEffect, useCallback } from 'react';
import './AreaSearchPage.scss';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
import { fetchAreaSearch } from '../services/areaSearchApi';
import {
SEARCH_MODES,
SEARCH_MODE_LABELS,
QUERY_MAX_DAYS,
getQueryDateRange,
} from '../types/areaSearch.types';
import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
import { showToast } from '../../components/common/Toast';
import { hideLiveShips, showLiveShips } from '../../utils/liveControl';
import ZoneDrawPanel from './ZoneDrawPanel';
import { exportSearchResultToCSV } from '../utils/csvExport';
const DAYS_TO_MS = 24 * 60 * 60 * 1000;
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())}`;
}
export default function AreaSearchPage({ isOpen, onToggle }) {
const [startDate, setStartDate] = useState('');
const [startTime, setStartTime] = useState('00:00');
const [endDate, setEndDate] = useState('');
const [endTime, setEndTime] = useState('23:59');
const [errorMessage, setErrorMessage] = useState('');
const zones = useAreaSearchStore((s) => s.zones);
const searchMode = useAreaSearchStore((s) => s.searchMode);
const tracks = useAreaSearchStore((s) => s.tracks);
const hitDetails = useAreaSearchStore((s) => s.hitDetails);
const summary = useAreaSearchStore((s) => s.summary);
const isLoading = useAreaSearchStore((s) => s.isLoading);
const queryCompleted = useAreaSearchStore((s) => s.queryCompleted);
const disabledVesselIds = useAreaSearchStore((s) => s.disabledVesselIds);
const highlightedVesselId = useAreaSearchStore((s) => s.highlightedVesselId);
const setSearchMode = useAreaSearchStore((s) => s.setSearchMode);
const setTimeRange = useAreaSearchAnimationStore((s) => s.setTimeRange);
// (D-7 ~ D-1)
useEffect(() => {
const { startDate: sDate, endDate: eDate } = getQueryDateRange();
setStartDate(sDate.toISOString().split('T')[0]);
setStartTime('00:00');
setEndDate(eDate.toISOString().split('T')[0]);
setEndTime('23:59');
}, []);
//
useEffect(() => {
return () => {
const { queryCompleted: completed } = useAreaSearchStore.getState();
if (completed) {
useAreaSearchStore.getState().reset();
useAreaSearchAnimationStore.getState().reset();
showLiveShips();
}
};
}, []);
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 adjusted = new Date(start.getTime() + QUERY_MAX_DAYS * DAYS_TO_MS);
setEndDate(adjusted.toISOString().split('T')[0]);
setEndTime(`${pad(adjusted.getHours())}:${pad(adjusted.getMinutes())}`);
showToast('종료일이 시작일보다 앞서 자동 조정되었습니다.');
} else if (diffDays > QUERY_MAX_DAYS) {
const adjusted = new Date(start.getTime() + QUERY_MAX_DAYS * DAYS_TO_MS);
setEndDate(adjusted.toISOString().split('T')[0]);
setEndTime(`${pad(adjusted.getHours())}:${pad(adjusted.getMinutes())}`);
showToast(`최대 조회기간 ${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 adjusted = new Date(end.getTime() - QUERY_MAX_DAYS * DAYS_TO_MS);
setStartDate(adjusted.toISOString().split('T')[0]);
setStartTime(`${pad(adjusted.getHours())}:${pad(adjusted.getMinutes())}`);
showToast('시작일이 종료일보다 뒤서 자동 조정되었습니다.');
} else if (diffDays > QUERY_MAX_DAYS) {
const adjusted = new Date(end.getTime() - QUERY_MAX_DAYS * DAYS_TO_MS);
setStartDate(adjusted.toISOString().split('T')[0]);
setStartTime(`${pad(adjusted.getHours())}:${pad(adjusted.getMinutes())}`);
showToast(`최대 조회기간 ${QUERY_MAX_DAYS}일로 자동 설정됩니다.`);
}
}, [startDate, startTime, endTime]);
const executeQuery = useCallback(async () => {
const from = new Date(`${startDate}T${startTime}:00`);
const to = new Date(`${endDate}T${endTime}:00`);
try {
setErrorMessage('');
useAreaSearchStore.getState().setLoading(true);
const polygons = zones.map((z) => ({
id: z.id,
name: z.name,
coordinates: z.coordinates,
}));
const result = await fetchAreaSearch({
startTime: toKstISOString(from),
endTime: toKstISOString(to),
mode: searchMode,
polygons,
});
useAreaSearchStore.getState().setTracks(result.tracks);
useAreaSearchStore.getState().setHitDetails(result.hitDetails);
useAreaSearchStore.getState().setSummary(result.summary);
if (result.tracks.length > 0) {
let minTime = Infinity;
let maxTime = -Infinity;
result.tracks.forEach((t) => {
if (t.timestampsMs.length > 0) {
minTime = Math.min(minTime, t.timestampsMs[0]);
maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]);
}
});
setTimeRange(minTime, maxTime);
hideLiveShips();
}
useAreaSearchStore.getState().setLoading(false);
} catch (error) {
console.error('[AreaSearch] 조회 실패:', error);
useAreaSearchStore.getState().setLoading(false);
setErrorMessage(`조회 실패: ${error.message}`);
}
}, [startDate, startTime, endDate, endTime, zones, searchMode, setTimeRange]);
const handleQuery = useCallback(async () => {
if (!startDate || !endDate) {
showToast('조회 기간을 입력해주세요.');
return;
}
if (zones.length === 0) {
showToast('구역을 1개 이상 설정해주세요.');
return;
}
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;
}
//
const { queryCompleted: hasExisting } = useAreaSearchStore.getState();
if (hasExisting) {
const confirmed = window.confirm('이전 조회 정보가 초기화됩니다.\n새로운 조건으로 다시 조회하시겠습니까?');
if (!confirmed) return;
// (queryCompleted: false + )
useAreaSearchStore.getState().clearResults();
useAreaSearchAnimationStore.getState().reset();
// showLiveShips() -
}
executeQuery();
}, [startDate, startTime, endDate, endTime, zones, searchMode, executeQuery]);
const handleReset = useCallback(() => {
useAreaSearchStore.getState().reset();
useAreaSearchAnimationStore.getState().reset();
showLiveShips();
setErrorMessage('');
}, []);
const handleToggleVessel = useCallback((vesselId) => {
useAreaSearchStore.getState().toggleVesselEnabled(vesselId);
}, []);
const handleHighlightVessel = useCallback((vesselId) => {
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
}, []);
const handleExportCSV = useCallback(() => {
exportSearchResultToCSV(tracks, hitDetails, zones);
}, [tracks, hitDetails, zones]);
return (
<aside className={`slidePanel area-search-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>
{queryCompleted && (
<button type="button" className="btn-reset" onClick={handleReset}>초기화</button>
)}
</div>
<div className="panelBody">
{/* 조회 기간 */}
<div className="query-section">
<h3 className="section-title">조회 기간</h3>
<p className="section-desc">D-7 ~ D-1 (인메모리 캐시 기반)</p>
<div className="query-row">
<label htmlFor="area-start-date" className="query-label">시작</label>
<div className="datetime-inputs">
<input
id="area-start-date"
type="date"
value={startDate}
onChange={(e) => handleStartDateChange(e.target.value)}
disabled={isLoading}
className="input-date"
/>
<input
id="area-start-time"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
disabled={isLoading}
className="input-time"
/>
</div>
</div>
<div className="query-row">
<label htmlFor="area-end-date" className="query-label">종료</label>
<div className="datetime-inputs">
<input
id="area-end-date"
type="date"
value={endDate}
onChange={(e) => handleEndDateChange(e.target.value)}
disabled={isLoading}
className="input-date"
/>
<input
id="area-end-time"
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
disabled={isLoading}
className="input-time"
/>
</div>
</div>
</div>
{/* 구역 설정 */}
<ZoneDrawPanel disabled={isLoading} />
{/* 검색 모드 */}
<div className="mode-section">
<h3 className="section-title">검색 조건</h3>
<div className="mode-options">
{Object.entries(SEARCH_MODE_LABELS).map(([mode, label]) => (
<label key={mode} className="mode-radio">
<input
type="radio"
name="searchMode"
value={mode}
checked={searchMode === mode}
onChange={() => setSearchMode(mode)}
disabled={isLoading}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
{/* 조회 버튼 */}
<div className="btnBox">
<button
type="button"
className="btn btn-primary btn-query"
onClick={handleQuery}
disabled={isLoading || zones.length === 0}
>
{isLoading ? '조회 중...' : '조회'}
</button>
</div>
{/* 결과 영역 */}
<div className="result-section">
{errorMessage && <div className="error-message">{errorMessage}</div>}
{isLoading && <div className="loading-message">데이터를 불러오는 중입니다...</div>}
{queryCompleted && tracks.length > 0 && (
<div className="result-content">
{summary && (
<div className="result-summary">
<span>검색 결과: {summary.totalVessels ?? tracks.length}</span>
{summary.processingTimeMs != null && (
<span className="processing-time">
({(summary.processingTimeMs / 1000).toFixed(2)})
</span>
)}
</div>
)}
{!summary && (
<div className="result-summary">
<span>검색 결과: {tracks.length}</span>
</div>
)}
<button type="button" className="btn-csv" onClick={handleExportCSV}>
CSV 내보내기
</button>
<ul className="vessel-list">
{tracks.map((track) => {
const isDisabled = disabledVesselIds.has(track.vesselId);
const isHighlighted = highlightedVesselId === track.vesselId;
const color = getShipKindColor(track.shipKindCode);
const rgbStr = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
return (
<li
key={track.vesselId}
className={`vessel-item ${isDisabled ? 'disabled' : ''} ${isHighlighted ? 'highlighted' : ''}`}
onMouseEnter={() => handleHighlightVessel(track.vesselId)}
onMouseLeave={() => handleHighlightVessel(null)}
>
<button
type="button"
className="vessel-toggle"
onClick={() => handleToggleVessel(track.vesselId)}
>
<span className="vessel-color" style={{ backgroundColor: rgbStr }} />
<span className="vessel-name">
{track.shipName || track.targetId}
</span>
<span className="vessel-info">
{getShipKindName(track.shipKindCode)} / {getSignalSourceName(track.sigSrcCd)}
</span>
</button>
</li>
);
})}
</ul>
</div>
)}
{queryCompleted && tracks.length === 0 && !errorMessage && (
<div className="empty-message">조건에 맞는 선박이 없습니다.</div>
)}
{!isLoading && !queryCompleted && !errorMessage && (
<div className="empty-message">
구역을 설정하고 조회 버튼을 클릭하세요.
</div>
)}
</div>
</div>
</aside>
);
}

파일 보기

@ -0,0 +1,314 @@
.area-search-panel {
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
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);
}
.btn-reset {
padding: 0.4rem 1rem;
border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
border-radius: 0.4rem;
background: transparent;
color: var(--tertiary4, #ccc);
font-size: var(--fs-xs, 1.1rem);
cursor: pointer;
&:hover {
border-color: var(--primary1, rgba(255, 255, 255, 0.5));
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: 1.2rem;
.section-title {
font-size: var(--fs-m, 1.3rem);
font-weight: var(--fw-bold, 700);
color: var(--white, #fff);
margin-bottom: 0.4rem;
}
.section-desc {
font-size: var(--fs-xs, 1.1rem);
color: var(--tertiary4, #999);
margin-bottom: 1.2rem;
}
.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;
}
}
}
}
// 검색 모드
.mode-section {
background-color: var(--secondary1, rgba(255, 255, 255, 0.05));
border-radius: 0.6rem;
padding: 1.5rem;
margin-bottom: 1.2rem;
.section-title {
font-size: var(--fs-m, 1.3rem);
font-weight: var(--fw-bold, 700);
color: var(--white, #fff);
margin-bottom: 1rem;
}
.mode-options {
display: flex;
flex-direction: column;
gap: 0.8rem;
.mode-radio {
display: flex;
align-items: center;
gap: 0.8rem;
cursor: pointer;
font-size: var(--fs-s, 1.2rem);
color: var(--tertiary4, #ccc);
input[type='radio'] {
accent-color: var(--primary1, #4a9eff);
}
&:has(input:checked) span {
color: var(--white, #fff);
}
}
}
}
// 조회 버튼
.btnBox {
display: flex;
justify-content: flex-end;
margin-bottom: 1.2rem;
.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;
&:has(> .loading-message),
&:has(> .empty-message),
&:has(> .error-message) {
align-items: center;
justify-content: center;
}
.loading-message,
.empty-message,
.error-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);
}
.error-message {
color: #f87171;
}
.btn-csv {
margin-bottom: 1.2rem;
padding: 0.6rem 1.2rem;
border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
border-radius: 0.4rem;
background: transparent;
color: var(--tertiary4, #ccc);
font-size: var(--fs-xs, 1.1rem);
cursor: pointer;
&:hover {
border-color: var(--primary1, #4a9eff);
color: var(--white, #fff);
}
}
.result-content {
width: 100%;
.result-summary {
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 1.2rem;
font-size: var(--fs-m, 1.3rem);
font-weight: var(--fw-bold, 700);
color: var(--white, #fff);
.processing-time {
font-size: var(--fs-xs, 1.1rem);
font-weight: normal;
color: var(--tertiary4, #999);
}
}
.vessel-list {
list-style: none;
padding: 0;
margin: 0;
.vessel-item {
border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
&.highlighted {
background-color: rgba(255, 255, 255, 0.08);
}
&.disabled {
opacity: 0.4;
}
.vessel-toggle {
display: flex;
align-items: center;
gap: 0.8rem;
width: 100%;
padding: 1rem 0.4rem;
background: none;
border: none;
cursor: pointer;
text-align: left;
color: inherit;
.vessel-color {
width: 1rem;
height: 1rem;
border-radius: 50%;
flex-shrink: 0;
}
.vessel-name {
flex: 1;
font-size: var(--fs-s, 1.2rem);
color: var(--white, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vessel-info {
font-size: var(--fs-xs, 1.1rem);
color: var(--tertiary4, #999);
flex-shrink: 0;
}
}
}
}
}
}
}
}

파일 보기

@ -0,0 +1,245 @@
/**
* 항적분석 타임라인 재생 컨트롤
* 참조: src/replay/components/ReplayTimeline.jsx (간소화)
*
* - 재생/일시정지/정지
* - 배속 조절 (1x ~ 1000x)
* - 프로그레스 (range slider)
* - 드래그 가능한 헤더
*/
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { unregisterAreaSearchLayers } from '../utils/areaSearchLayerRegistry';
import { showLiveShips } from '../../utils/liveControl';
import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
import { PLAYBACK_SPEED_OPTIONS } from '../types/areaSearch.types';
import './AreaSearchTimeline.scss';
const PATH_LABEL = '항적';
const TRAIL_LABEL = '궤적';
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 AreaSearchTimeline() {
const isPlaying = useAreaSearchAnimationStore((s) => s.isPlaying);
const currentTime = useAreaSearchAnimationStore((s) => s.currentTime);
const startTime = useAreaSearchAnimationStore((s) => s.startTime);
const endTime = useAreaSearchAnimationStore((s) => s.endTime);
const playbackSpeed = useAreaSearchAnimationStore((s) => s.playbackSpeed);
const play = useAreaSearchAnimationStore((s) => s.play);
const pause = useAreaSearchAnimationStore((s) => s.pause);
const stop = useAreaSearchAnimationStore((s) => s.stop);
const setCurrentTime = useAreaSearchAnimationStore((s) => s.setCurrentTime);
const setPlaybackSpeed = useAreaSearchAnimationStore((s) => s.setPlaybackSpeed);
const showPaths = useAreaSearchStore((s) => s.showPaths);
const showTrail = useAreaSearchStore((s) => s.showTrail);
const setShowPaths = useAreaSearchStore((s) => s.setShowPaths);
const setShowTrail = useAreaSearchStore((s) => s.setShowTrail);
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 [isDragging, setIsDragging] = useState(false);
const [hasDragged, setHasDragged] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const containerRef = useRef(null);
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) return;
const rect = containerRef.current.getBoundingClientRect();
const parent = containerRef.current.parentElement;
if (!parent) return;
const parentRect = parent.getBoundingClientRect();
setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
if (!hasDragged) {
setPosition({ x: rect.left - parentRect.left, y: rect.top - parentRect.top });
setHasDragged(true);
}
setIsDragging(true);
}, [hasDragged]);
useEffect(() => {
const handleMouseMove = (e) => {
if (!isDragging || !containerRef.current) return;
const parent = containerRef.current.parentElement;
if (!parent) return;
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 handleSliderChange = useCallback((e) => {
setCurrentTime(parseFloat(e.target.value));
}, [setCurrentTime]);
const handleClose = useCallback(() => {
useAreaSearchStore.getState().reset();
useAreaSearchAnimationStore.getState().reset();
unregisterAreaSearchLayers();
showLiveShips();
shipBatchRenderer.immediateRender();
}, []);
const hasData = endTime > startTime && startTime > 0;
return (
<div
ref={containerRef}
className={`area-search-timeline ${isPlaying ? 'playing' : ''} ${isDragging ? 'dragging' : ''}`}
style={hasDragged ? {
left: `${position.x}px`,
top: `${position.y}px`,
bottom: 'auto',
transform: 'none',
} : undefined}
>
<div className="timeline-header" onMouseDown={handleMouseDown}>
<div className="header-content">
<span className="header-title">항적 분석</span>
</div>
<button type="button" className="header-close-btn" onClick={handleClose} title="닫기">
&#x2715;
</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 ? '\u275A\u275A' : '\u25B6'}
</button>
<button
type="button"
className="control-btn stop-btn"
onClick={handleStop}
disabled={!hasData}
title="정지"
>
&#x25A0;
</button>
<div className="timeline-slider-container">
<input
type="range"
className="timeline-slider"
min={startTime}
max={endTime}
step={(endTime - startTime) / 1000 || 1}
value={currentTime}
onChange={handleSliderChange}
disabled={!hasData}
style={{ '--progress': `${progress}%` }}
/>
</div>
<span className="current-time-display">
{hasData ? formatDateTime(currentTime) : '--:--:--'}
</span>
<label className="filter-toggle">
<input
type="checkbox"
checked={showPaths}
onChange={() => setShowPaths(!showPaths)}
disabled={!hasData}
/>
<span>{PATH_LABEL}</span>
</label>
<label className="filter-toggle">
<input
type="checkbox"
checked={showTrail}
onChange={() => setShowTrail(!showTrail)}
disabled={!hasData}
/>
<span>{TRAIL_LABEL}</span>
</label>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,362 @@
.area-search-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: area-search-pulse 1.5s infinite;
}
}
&.dragging {
cursor: grabbing;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
opacity: 0.95;
}
.timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 26px;
background: linear-gradient(135deg, rgba(255, 152, 0, 0.3), rgba(255, 183, 77, 0.2));
border-bottom: 1px solid rgba(255, 152, 0, 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-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;
}
}
}
.timeline-controls {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 26px;
}
.speed-selector {
position: relative;
z-index: 100;
.speed-btn {
background: rgba(255, 152, 0, 0.2);
border: 1px solid rgba(255, 152, 0, 0.4);
border-radius: 4px;
color: #ffb74d;
font-size: 11px;
font-weight: 600;
padding: 5px 10px;
cursor: pointer;
transition: all 0.2s ease;
min-width: 50px;
&:hover:not(:disabled) {
background: rgba(255, 152, 0, 0.3);
border-color: #ffb74d;
}
&: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(255, 152, 0, 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(255, 152, 0, 0.3);
border-color: rgba(255, 152, 0, 0.5);
}
&.active {
background: rgba(255, 152, 0, 0.5);
border-color: #ffb74d;
color: #fff;
}
}
}
}
.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);
}
}
}
.timeline-slider-container {
flex: 1;
position: relative;
height: 20px;
display: flex;
align-items: center;
min-width: 100px;
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,
#ffb74d 0%,
#ff9800 var(--progress),
transparent var(--progress),
transparent 100%
);
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #fff;
border: 2px solid #ff9800;
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,
#ffb74d 0%,
#ff9800 var(--progress),
transparent var(--progress),
transparent 100%
);
}
&::-moz-range-thumb {
width: 14px;
height: 14px;
background: #fff;
border: 2px solid #ff9800;
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;
}
}
}
}
.current-time-display {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 11px;
font-weight: 500;
color: #ffb74d;
min-width: 130px;
text-align: center;
white-space: nowrap;
}
.filter-toggle {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
font-size: 11px;
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid transparent;
transition: all 0.2s ease;
input[type='checkbox'] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: #ff9800;
&:disabled {
cursor: not-allowed;
}
}
&:hover {
color: #fff;
}
&: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);
}
}
&: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 area-search-pulse {
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);
}
}

파일 보기

@ -0,0 +1,115 @@
/**
* 항적분석 호버 툴팁 컴포넌트
* - 선박 기본 정보 (선종, 선명, 신호원)
* - 구역별 진입/진출 시간 위치
*/
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { ZONE_COLORS } from '../types/areaSearch.types';
import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
import './AreaSearchTooltip.scss';
const OFFSET_X = 14;
const OFFSET_Y = -20;
/** nationalCode → 국기 SVG URL */
function getNationalFlagUrl(nationalCode) {
if (!nationalCode) return null;
return `/ship/image/small/${nationalCode}.svg`;
}
function formatTimestamp(ms) {
if (!ms) return '-';
const d = new Date(ms);
const pad = (n) => String(n).padStart(2, '0');
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function formatPosition(pos) {
if (!pos || pos.length < 2) return null;
const lon = pos[0];
const lat = pos[1];
const latDir = lat >= 0 ? 'N' : 'S';
const lonDir = lon >= 0 ? 'E' : 'W';
return `${Math.abs(lat).toFixed(4)}\u00B0${latDir} ${Math.abs(lon).toFixed(4)}\u00B0${lonDir}`;
}
export default function AreaSearchTooltip() {
const tooltip = useAreaSearchStore((s) => s.areaSearchTooltip);
const tracks = useAreaSearchStore((s) => s.tracks);
const hitDetails = useAreaSearchStore((s) => s.hitDetails);
const zones = useAreaSearchStore((s) => s.zones);
if (!tooltip) return null;
const { vesselId, x, y } = tooltip;
const track = tracks.find((t) => t.vesselId === vesselId);
if (!track) return null;
const hits = hitDetails[vesselId] || [];
const kindName = getShipKindName(track.shipKindCode);
const sourceName = getSignalSourceName(track.sigSrcCd);
const flagUrl = getNationalFlagUrl(track.nationalCode);
return (
<div
className="area-search-tooltip"
style={{ left: x + OFFSET_X, top: y + OFFSET_Y }}
>
<div className="area-search-tooltip__header">
<span className="area-search-tooltip__kind">{kindName}</span>
{flagUrl && (
<span className="area-search-tooltip__flag">
<img
src={flagUrl}
alt="국기"
onError={(e) => { e.target.style.display = 'none'; }}
/>
</span>
)}
<span className="area-search-tooltip__name">
{track.shipName || track.targetId || '-'}
</span>
</div>
<div className="area-search-tooltip__info">
<span>{sourceName}</span>
</div>
{zones.length > 0 && hits.length > 0 && (
<div className="area-search-tooltip__zones">
{zones.map((zone) => {
const hit = hits.find((h) => h.polygonId === zone.id);
if (!hit) return null;
const zoneColor = ZONE_COLORS[zone.colorIndex]?.label || '#ffd43b';
const entryPos = formatPosition(hit.entryPosition);
const exitPos = formatPosition(hit.exitPosition);
return (
<div key={zone.id} className="area-search-tooltip__zone">
<span
className="area-search-tooltip__zone-name"
style={{ color: zoneColor }}
>
{zone.name}
</span>
<div className="area-search-tooltip__zone-row">
<span className="area-search-tooltip__zone-label">IN</span>
<span>{formatTimestamp(hit.entryTimestamp)}</span>
{entryPos && (
<span className="area-search-tooltip__pos">{entryPos}</span>
)}
</div>
<div className="area-search-tooltip__zone-row">
<span className="area-search-tooltip__zone-label">OUT</span>
<span>{formatTimestamp(hit.exitTimestamp)}</span>
{exitPos && (
<span className="area-search-tooltip__pos">{exitPos}</span>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}

파일 보기

@ -0,0 +1,101 @@
.area-search-tooltip {
position: fixed;
z-index: 200;
pointer-events: none;
background: rgba(20, 24, 32, 0.95);
border-radius: 6px;
padding: 10px 14px;
min-width: 180px;
max-width: 340px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
color: #fff;
font-size: 12px;
&__header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 3px;
}
&__kind {
display: inline-block;
padding: 1px 5px;
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
font-size: 10px;
color: #adb5bd;
}
&__flag {
display: inline-flex;
align-items: center;
img {
width: 16px;
height: 12px;
object-fit: contain;
vertical-align: middle;
}
}
&__name {
font-weight: 700;
font-size: 13px;
color: #fff;
}
&__info {
display: flex;
align-items: center;
gap: 4px;
color: #ced4da;
margin-bottom: 2px;
}
&__sep {
color: rgba(255, 255, 255, 0.2);
}
&__zones {
border-top: 1px solid rgba(255, 255, 255, 0.12);
margin-top: 4px;
padding-top: 5px;
display: flex;
flex-direction: column;
gap: 4px;
}
&__zone {
display: flex;
flex-direction: column;
gap: 1px;
}
&__zone-name {
font-weight: 700;
font-size: 11px;
margin-bottom: 1px;
}
&__zone-row {
display: flex;
align-items: center;
gap: 5px;
color: #ced4da;
font-size: 11px;
padding-left: 2px;
}
&__zone-label {
font-weight: 600;
font-size: 9px;
color: #868e96;
min-width: 24px;
}
&__pos {
color: #74b9ff;
font-size: 10px;
}
}

파일 보기

@ -0,0 +1,135 @@
import { useCallback, useRef, useState } from 'react';
import './ZoneDrawPanel.scss';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import {
MAX_ZONES,
ZONE_DRAW_TYPES,
ZONE_COLORS,
} from '../types/areaSearch.types';
export default function ZoneDrawPanel({ disabled }) {
const zones = useAreaSearchStore((s) => s.zones);
const activeDrawType = useAreaSearchStore((s) => s.activeDrawType);
const setActiveDrawType = useAreaSearchStore((s) => s.setActiveDrawType);
const removeZone = useAreaSearchStore((s) => s.removeZone);
const reorderZones = useAreaSearchStore((s) => s.reorderZones);
const canAddZone = zones.length < MAX_ZONES;
const handleDrawClick = useCallback((type) => {
if (!canAddZone || disabled) return;
setActiveDrawType(activeDrawType === type ? null : type);
}, [canAddZone, disabled, activeDrawType, setActiveDrawType]);
// (ref - dataTransfer )
const dragIndexRef = useRef(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
const handleDragStart = useCallback((e, index) => {
dragIndexRef.current = index;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
requestAnimationFrame(() => {
e.target.classList.add('dragging');
});
}, []);
const handleDragOver = useCallback((e, index) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragIndexRef.current !== null && dragIndexRef.current !== index) {
setDragOverIndex(index);
}
}, []);
const handleDrop = useCallback((e, toIndex) => {
e.preventDefault();
const fromIndex = dragIndexRef.current;
if (fromIndex !== null && fromIndex !== toIndex) {
reorderZones(fromIndex, toIndex);
}
dragIndexRef.current = null;
setDragOverIndex(null);
}, [reorderZones]);
const handleDragEnd = useCallback(() => {
dragIndexRef.current = null;
setDragOverIndex(null);
}, []);
return (
<div className="zone-draw-panel">
<h3 className="section-title">구역 설정</h3>
<p className="section-desc">{zones.length}/{MAX_ZONES} 설정됨</p>
{/* 그리기 버튼 */}
<div className="draw-buttons">
<button
type="button"
className={`draw-btn ${activeDrawType === ZONE_DRAW_TYPES.POLYGON ? 'active' : ''}`}
onClick={() => handleDrawClick(ZONE_DRAW_TYPES.POLYGON)}
disabled={!canAddZone || disabled}
title="폴리곤"
>
폴리곤
</button>
<button
type="button"
className={`draw-btn ${activeDrawType === ZONE_DRAW_TYPES.BOX ? 'active' : ''}`}
onClick={() => handleDrawClick(ZONE_DRAW_TYPES.BOX)}
disabled={!canAddZone || disabled}
title="사각형"
>
사각형
</button>
<button
type="button"
className={`draw-btn ${activeDrawType === ZONE_DRAW_TYPES.CIRCLE ? 'active' : ''}`}
onClick={() => handleDrawClick(ZONE_DRAW_TYPES.CIRCLE)}
disabled={!canAddZone || disabled}
title="원"
>
</button>
</div>
{activeDrawType && (
<p className="draw-hint">지도에서 구역을 그려주세요. (ESC: 취소)</p>
)}
{/* 구역 목록 */}
{zones.length > 0 && (
<ul className="zone-list">
{zones.map((zone, index) => {
const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0];
return (
<li
key={zone.id}
className={`zone-item${dragIndexRef.current === index ? ' dragging' : ''}${dragOverIndex === index ? ' drag-over' : ''}`}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
>
<span className="drag-handle" title="드래그하여 순서 변경">&#8801;</span>
<span className="zone-color" style={{ backgroundColor: color.label }} />
<span className="zone-name">구역 {zone.name}</span>
<span className="zone-type">{zone.type}</span>
<button
type="button"
className="zone-delete"
onClick={() => removeZone(zone.id)}
disabled={disabled}
title="구역 삭제"
>
&times;
</button>
</li>
);
})}
</ul>
)}
</div>
);
}

파일 보기

@ -0,0 +1,136 @@
.zone-draw-panel {
background-color: var(--secondary1, rgba(255, 255, 255, 0.05));
border-radius: 0.6rem;
padding: 1.5rem;
margin-bottom: 1.2rem;
.section-title {
font-size: var(--fs-m, 1.3rem);
font-weight: var(--fw-bold, 700);
color: var(--white, #fff);
margin-bottom: 0.4rem;
}
.section-desc {
font-size: var(--fs-xs, 1.1rem);
color: var(--tertiary4, #999);
margin-bottom: 1rem;
}
.draw-buttons {
display: flex;
gap: 0.6rem;
margin-bottom: 1rem;
.draw-btn {
flex: 1;
padding: 0.7rem 0;
border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
border-radius: 0.4rem;
background: transparent;
color: var(--tertiary4, #ccc);
font-size: var(--fs-s, 1.2rem);
cursor: pointer;
transition: all 0.15s;
&:hover:not(:disabled) {
border-color: var(--primary1, #4a9eff);
color: var(--white, #fff);
}
&.active {
background-color: var(--primary1, #4a9eff);
border-color: var(--primary1, #4a9eff);
color: var(--white, #fff);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
}
.draw-hint {
font-size: var(--fs-xs, 1.1rem);
color: var(--primary1, #4a9eff);
margin-bottom: 1rem;
}
.zone-list {
list-style: none;
padding: 0;
margin: 0;
.zone-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.8rem 0.4rem;
border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
cursor: grab;
transition: background-color 0.15s;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
&.dragging {
opacity: 0.4;
}
&.drag-over {
border-top: 2px solid var(--primary1, #4a9eff);
padding-top: calc(0.8rem - 2px);
}
&:last-child {
border-bottom: none;
}
.drag-handle {
color: var(--tertiary4, #999);
font-size: 1.4rem;
cursor: grab;
user-select: none;
}
.zone-color {
width: 1rem;
height: 1rem;
border-radius: 50%;
flex-shrink: 0;
}
.zone-name {
font-size: var(--fs-s, 1.2rem);
color: var(--white, #fff);
flex: 1;
}
.zone-type {
font-size: var(--fs-xs, 1.1rem);
color: var(--tertiary4, #999);
}
.zone-delete {
background: none;
border: none;
color: var(--tertiary4, #999);
font-size: 1.6rem;
cursor: pointer;
padding: 0 0.4rem;
line-height: 1;
&:hover:not(:disabled) {
color: #f87171;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
}
}
}

파일 보기

@ -0,0 +1,205 @@
/**
* 항적분석 Deck.gl 레이어 관리
* 구조: 리플레이(useReplayLayer) 패턴 적용
*
* - React hook으로 currentTime 구독 프레임 리렌더
* - immediateRender() 즉시 반영
* - TripsLayer GPU 기반 궤적 표시
* - 정적(PathLayer) / 동적(IconLayer, TextLayer) 레이어 분리
*/
import { useEffect, useRef, useCallback } from 'react';
import { TripsLayer } from '@deck.gl/geo-layers';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
import { AREA_SEARCH_LAYER_IDS } from '../types/areaSearch.types';
import {
registerAreaSearchLayers,
unregisterAreaSearchLayers,
} from '../utils/areaSearchLayerRegistry';
import { createStaticTrackLayers, createVirtualShipLayers } from '../../map/layers/trackLayer';
import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
import { SIGNAL_KIND_CODE_NORMAL } from '../../types/constants';
const TRAIL_LENGTH_MS = 3600000; // 궤적 길이 1시간
export default function useAreaSearchLayer() {
const tripsDataRef = useRef([]);
const startTimeRef = useRef(0);
// React hook 구독 (매 프레임 리렌더)
const queryCompleted = useAreaSearchStore((s) => s.queryCompleted);
const tracks = useAreaSearchStore((s) => s.tracks);
const disabledVesselIds = useAreaSearchStore((s) => s.disabledVesselIds);
const highlightedVesselId = useAreaSearchStore((s) => s.highlightedVesselId);
const showPaths = useAreaSearchStore((s) => s.showPaths);
const showTrail = useAreaSearchStore((s) => s.showTrail);
const shipKindCodeFilter = useAreaSearchStore((s) => s.shipKindCodeFilter);
const currentTime = useAreaSearchAnimationStore((s) => s.currentTime);
const startTime = useAreaSearchAnimationStore((s) => s.startTime);
/**
* 트랙 필터링 (선종 + 개별 비활성화)
*/
const getFilteredTracks = useCallback(() => {
return tracks.filter((t) =>
!disabledVesselIds.has(t.vesselId) && shipKindCodeFilter.has(t.shipKindCode),
);
}, [tracks, disabledVesselIds, shipKindCodeFilter]);
/**
* 위치 필터링 (선종 필터 적용)
*/
const getFilteredPositions = useCallback((positions) => {
return positions.filter((p) => shipKindCodeFilter.has(p.shipKindCode));
}, [shipKindCodeFilter]);
/**
* TripsLayer 데이터 빌드 (queryCompleted 1)
*/
const buildTripsData = useCallback(() => {
if (tracks.length === 0) {
tripsDataRef.current = [];
return;
}
const sTime = useAreaSearchAnimationStore.getState().startTime;
startTimeRef.current = sTime;
tripsDataRef.current = tracks
.filter((t) => t.geometry.length >= 2)
.map((track) => ({
vesselId: track.vesselId,
shipKindCode: track.shipKindCode,
path: track.geometry,
timestamps: track.timestampsMs.map((t) => t - sTime),
}));
}, [tracks]);
/**
* 레이어 렌더링 (리플레이 requestAnimatedRender 패턴)
*/
const requestAnimatedRender = useCallback(() => {
if (!queryCompleted || tracks.length === 0) return;
// 현재 위치 계산
const allPositions = useAreaSearchStore.getState().getCurrentPositions(currentTime);
const filteredPositions = getFilteredPositions(allPositions);
// 선종별 카운트 → ShipLegend용 (replayStore 패턴)
// ShipLegend는 areaSearchStore.tracks를 직접 참조하므로 별도 저장 불필요
const layers = [];
// 1. TripsLayer 궤적 표시
if (showTrail && tripsDataRef.current.length > 0) {
const iconVesselIds = new Set(filteredPositions.map((p) => p.vesselId));
const filteredTripsData = tripsDataRef.current.filter(
(d) => iconVesselIds.has(d.vesselId),
);
if (filteredTripsData.length > 0) {
const relativeCurrentTime = currentTime - startTimeRef.current;
layers.push(
new TripsLayer({
id: AREA_SEARCH_LAYER_IDS.TRIPS_TRAIL,
data: filteredTripsData,
getPath: (d) => d.path,
getTimestamps: (d) => d.timestamps,
getColor: [120, 120, 120, 180],
widthMinPixels: 2,
widthMaxPixels: 3,
jointRounded: true,
capRounded: true,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: relativeCurrentTime,
}),
);
}
}
// 2. 정적 항적 레이어 (PathLayer)
if (showPaths) {
const filteredTracks = getFilteredTracks();
const staticLayers = createStaticTrackLayers({
tracks: filteredTracks,
showPoints: false,
highlightedVesselId,
onPathHover: (vesselId) => {
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
},
layerIds: {
path: AREA_SEARCH_LAYER_IDS.PATH,
},
});
layers.push(...staticLayers);
}
// 3. 동적 가상 선박 레이어 (IconLayer + TextLayer)
const dynamicLayers = createVirtualShipLayers({
currentPositions: filteredPositions,
showVirtualShip: filteredPositions.length > 0,
showLabels: filteredPositions.length > 0,
onIconHover: (shipData, x, y) => {
if (shipData) {
useAreaSearchStore.getState().setHighlightedVesselId(shipData.vesselId);
} else {
useAreaSearchStore.getState().setHighlightedVesselId(null);
}
},
onPathHover: (vesselId) => {
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
},
layerIds: {
icon: AREA_SEARCH_LAYER_IDS.VIRTUAL_SHIP,
label: AREA_SEARCH_LAYER_IDS.VIRTUAL_SHIP_LABEL,
},
});
layers.push(...dynamicLayers);
registerAreaSearchLayers(layers);
shipBatchRenderer.immediateRender();
}, [queryCompleted, tracks, currentTime, showPaths, showTrail, highlightedVesselId, getFilteredTracks, getFilteredPositions]);
/**
* 쿼리 완료 TripsLayer 데이터 빌드
*/
useEffect(() => {
if (!queryCompleted) {
unregisterAreaSearchLayers();
tripsDataRef.current = [];
shipBatchRenderer.immediateRender();
return;
}
buildTripsData();
}, [queryCompleted, buildTripsData]);
/**
* currentTime 변경 애니메이션 렌더링 ( 프레임)
*/
useEffect(() => {
if (!queryCompleted) return;
requestAnimatedRender();
}, [currentTime, queryCompleted, requestAnimatedRender]);
/**
* 필터 변경 재렌더링
*/
useEffect(() => {
if (!queryCompleted) return;
requestAnimatedRender();
}, [showPaths, showTrail, shipKindCodeFilter, disabledVesselIds, highlightedVesselId, queryCompleted, requestAnimatedRender]);
/**
* 컴포넌트 언마운트 클린업
*/
useEffect(() => {
return () => {
unregisterAreaSearchLayers();
tripsDataRef.current = [];
};
}, []);
}

파일 보기

@ -0,0 +1,258 @@
/**
* 구역 그리기 OpenLayers Draw 인터랙션
*
* - activeDrawType 변경 Draw 인터랙션 활성화
* - Polygon / Box / Circle 그리기
* - drawend EPSG:38574326 변환 addZone()
* - ESC 키로 그리기 취소
* - 구역별 색상 스타일 (ZONE_COLORS)
*/
import { useEffect, useRef, useCallback } from 'react';
import VectorSource from 'ol/source/Vector';
import VectorLayer from 'ol/layer/Vector';
import { Draw } from 'ol/interaction';
import { createBox } from 'ol/interaction/Draw';
import { Style, Fill, Stroke } from 'ol/style';
import { transform } from 'ol/proj';
import { fromCircle } from 'ol/geom/Polygon';
import { useMapStore } from '../../stores/mapStore';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types';
/**
* 3857 좌표를 4326 좌표로 변환하고 폐곡선 보장
*/
function toWgs84Polygon(coords3857) {
const coords4326 = coords3857.map((c) => transform(c, 'EPSG:3857', 'EPSG:4326'));
// 폐곡선 보장 (첫점 == 끝점)
if (coords4326.length > 0) {
const first = coords4326[0];
const last = coords4326[coords4326.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) {
coords4326.push([...first]);
}
}
return coords4326;
}
/**
* 구역 인덱스에 맞는 OL 스타일 생성
*/
function createZoneStyle(index) {
const color = ZONE_COLORS[index] || ZONE_COLORS[0];
return new Style({
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }),
});
}
export default function useZoneDraw() {
const map = useMapStore((s) => s.map);
const sourceRef = useRef(null);
const layerRef = useRef(null);
const drawRef = useRef(null);
const mapRef = useRef(null);
// map ref 동기화 (클린업에서 사용)
useEffect(() => {
mapRef.current = map;
}, [map]);
// 맵 준비 시 레이어 설정
useEffect(() => {
if (!map) return;
const source = new VectorSource({ wrapX: false });
const layer = new VectorLayer({
source,
zIndex: 55,
});
map.addLayer(layer);
sourceRef.current = source;
layerRef.current = layer;
// 기존 zones가 있으면 동기화
const { zones } = useAreaSearchStore.getState();
zones.forEach((zone) => {
if (!zone.olFeature) return;
zone.olFeature.setStyle(createZoneStyle(zone.colorIndex));
source.addFeature(zone.olFeature);
});
return () => {
if (drawRef.current) {
map.removeInteraction(drawRef.current);
drawRef.current = null;
}
map.removeLayer(layer);
sourceRef.current = null;
layerRef.current = null;
};
}, [map]);
// 스토어의 zones 변경 → OL feature 동기화
useEffect(() => {
const unsub = useAreaSearchStore.subscribe(
(s) => s.zones,
(zones) => {
const source = sourceRef.current;
if (!source) return;
source.clear();
zones.forEach((zone) => {
if (!zone.olFeature) return;
zone.olFeature.setStyle(createZoneStyle(zone.colorIndex));
source.addFeature(zone.olFeature);
});
},
);
return unsub;
}, []);
// showZones 변경 → 레이어 표시/숨김
useEffect(() => {
const unsub = useAreaSearchStore.subscribe(
(s) => s.showZones,
(show) => {
if (layerRef.current) layerRef.current.setVisible(show);
},
);
return unsub;
}, []);
// Draw 인터랙션 생성 함수
const setupDraw = useCallback((currentMap, drawType) => {
// 기존 인터랙션 제거
if (drawRef.current) {
currentMap.removeInteraction(drawRef.current);
drawRef.current = null;
}
if (!drawType) return;
const source = sourceRef.current;
if (!source) return;
// source를 Draw에 전달하지 않음
// OL Draw는 drawend 이벤트 후에 source.addFeature()를 자동 호출하는데,
// 우리 drawend 핸들러의 addZone() → subscription → source.addFeature() 와 충돌하여
// "feature already added" 에러 발생. source를 제거하면 자동 추가가 비활성화됨.
let draw;
if (drawType === ZONE_DRAW_TYPES.BOX) {
draw = new Draw({ type: 'Circle', geometryFunction: createBox() });
} else if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
draw = new Draw({ type: 'Circle' });
} else {
draw = new Draw({ type: 'Polygon' });
}
draw.on('drawend', (evt) => {
const feature = evt.feature;
let geom = feature.getGeometry();
const typeName = drawType;
// Circle → Polygon 변환
if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
const polyGeom = fromCircle(geom, 64);
feature.setGeometry(polyGeom);
geom = polyGeom;
}
// EPSG:3857 → 4326 좌표 추출
const coords3857 = geom.getCoordinates()[0];
const coordinates = toWgs84Polygon(coords3857);
// 최소 4점 확인
if (coordinates.length < 4) {
return;
}
const { zones } = useAreaSearchStore.getState();
const index = zones.length;
const style = createZoneStyle(index);
feature.setStyle(style);
// source에 직접 추가 (즉시 표시, Draw의 자동 추가를 대체)
source.addFeature(feature);
// 상태 업데이트를 다음 틱으로 지연
// drawend 이벤트 처리 중에 Draw를 동기적으로 제거하면,
// OL 내부 이벤트 체인이 완료되기 전에 DragPan이 이벤트를 가로채서
// 지도가 마우스를 따라 움직이는 문제가 발생함.
// setTimeout으로 OL 이벤트 처리가 완료된 후 안전하게 제거.
setTimeout(() => {
useAreaSearchStore.getState().addZone({
type: typeName,
source: 'draw',
coordinates,
olFeature: feature,
});
// addZone → activeDrawType: null → subscription → removeInteraction
}, 0);
});
currentMap.addInteraction(draw);
drawRef.current = draw;
}, []);
// activeDrawType 변경 → Draw 인터랙션 설정
useEffect(() => {
if (!map) return;
const unsub = useAreaSearchStore.subscribe(
(s) => s.activeDrawType,
(drawType) => {
setupDraw(map, drawType);
},
);
// 현재 activeDrawType이 이미 설정되어 있으면 즉시 적용
const { activeDrawType } = useAreaSearchStore.getState();
if (activeDrawType) {
setupDraw(map, activeDrawType);
}
return () => {
unsub();
// 구독 해제 시 Draw 인터랙션도 제거
if (drawRef.current && mapRef.current) {
mapRef.current.removeInteraction(drawRef.current);
drawRef.current = null;
}
};
}, [map, setupDraw]);
// ESC 키로 그리기 취소
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
const { activeDrawType } = useAreaSearchStore.getState();
if (activeDrawType) {
useAreaSearchStore.getState().setActiveDrawType(null);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// 구역 삭제 시 OL feature도 source에서 제거 (zones 감소)
useEffect(() => {
const unsub = useAreaSearchStore.subscribe(
(s) => s.zones,
(zones, prevZones) => {
if (!prevZones || zones.length >= prevZones.length) return;
const source = sourceRef.current;
if (!source) return;
const currentIds = new Set(zones.map((z) => z.id));
prevZones.forEach((z) => {
if (!currentIds.has(z.id) && z.olFeature) {
try { source.removeFeature(z.olFeature); } catch { /* already removed */ }
}
});
},
);
return unsub;
}, []);
}

파일 보기

@ -0,0 +1,112 @@
/**
* 항적분석(구역 검색) REST API 서비스
*
* POST /api/v2/tracks/area-search
* 응답 변환: trackQueryApi.convertToProcessedTracks() 재사용
*/
import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi';
const API_ENDPOINT = '/api/v2/tracks/area-search';
/**
* 타임스탬프 기반 위치 보간 (이진 탐색)
* track의 timestampsMs/geometry에서 targetTime 시점의 [lon, lat] 계산
*/
function interpolatePositionAtTime(track, targetTime) {
const { timestampsMs, geometry } = track;
if (!timestampsMs || timestampsMs.length === 0 || !targetTime) return null;
const first = timestampsMs[0];
const last = timestampsMs[timestampsMs.length - 1];
if (targetTime <= first) return geometry[0];
if (targetTime >= last) return geometry[geometry.length - 1];
// 이진 탐색
let left = 0;
let right = timestampsMs.length - 1;
while (left < right) {
const mid = Math.floor((left + right) / 2);
if (timestampsMs[mid] < targetTime) left = mid + 1;
else right = mid;
}
const idx1 = Math.max(0, left - 1);
const idx2 = Math.min(timestampsMs.length - 1, left);
if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) {
return geometry[idx1];
}
const ratio = (targetTime - timestampsMs[idx1]) / (timestampsMs[idx2] - timestampsMs[idx1]);
return [
geometry[idx1][0] + (geometry[idx2][0] - geometry[idx1][0]) * ratio,
geometry[idx1][1] + (geometry[idx2][1] - geometry[idx1][1]) * ratio,
];
}
/**
* 구역 기반 항적 검색
*
* @param {Object} params
* @param {string} params.startTime ISO 8601 시작 시간
* @param {string} params.endTime ISO 8601 종료 시간
* @param {string} params.mode 'ANY' | 'ALL' | 'SEQUENTIAL'
* @param {Array<{id: string, name: string, coordinates: number[][]}>} params.polygons
* @returns {Promise<{tracks: Array, hitDetails: Object, summary: Object}>}
*/
export async function fetchAreaSearch(params) {
const request = {
startTime: params.startTime,
endTime: params.endTime,
mode: params.mode,
polygons: params.polygons,
};
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
const rawTracks = Array.isArray(result.tracks) ? result.tracks : [];
const tracks = convertToProcessedTracks(rawTracks);
// vesselId → track 빠른 조회용
const trackMap = new Map(tracks.map((t) => [t.vesselId, t]));
// hitDetails: timestamp 초→밀리초 변환 + 진입/진출 위치 보간
const rawHitDetails = result.hitDetails || {};
const hitDetails = {};
for (const [vesselId, hits] of Object.entries(rawHitDetails)) {
const track = trackMap.get(vesselId);
hitDetails[vesselId] = (Array.isArray(hits) ? hits : []).map((hit) => {
const toMs = (ts) => {
if (!ts) return null;
const num = typeof ts === 'number' ? ts : parseInt(ts, 10);
return num < 10000000000 ? num * 1000 : num;
};
const entryMs = toMs(hit.entryTimestamp);
const exitMs = toMs(hit.exitTimestamp);
return {
...hit,
entryTimestamp: entryMs,
exitTimestamp: exitMs,
entryPosition: track ? interpolatePositionAtTime(track, entryMs) : null,
exitPosition: track ? interpolatePositionAtTime(track, exitMs) : null,
};
});
}
return {
tracks,
hitDetails,
summary: result.summary || null,
};
}

파일 보기

@ -0,0 +1,117 @@
/**
* 항적분석 전용 애니메이션 스토어
* 참조: src/tracking/stores/trackQueryAnimationStore.js
*
* - 재생/일시정지/정지
* - 배속 조절 (1x ~ 1000x)
* - requestAnimationFrame 기반 애니메이션
*/
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
let animationFrameId = null;
let lastFrameTime = null;
export const useAreaSearchAnimationStore = create(subscribeWithSelector((set, get) => {
const animate = () => {
const state = get();
if (!state.isPlaying) return;
const now = performance.now();
if (lastFrameTime === null) {
lastFrameTime = now;
}
const delta = now - lastFrameTime;
lastFrameTime = now;
const newTime = state.currentTime + delta * state.playbackSpeed;
if (newTime >= state.endTime) {
set({ currentTime: state.endTime, isPlaying: false });
animationFrameId = null;
lastFrameTime = null;
return;
}
set({ currentTime: newTime });
animationFrameId = requestAnimationFrame(animate);
};
return {
isPlaying: false,
currentTime: 0,
startTime: 0,
endTime: 0,
playbackSpeed: 1,
play: () => {
const state = get();
if (state.endTime <= state.startTime) return;
lastFrameTime = null;
if (state.currentTime >= state.endTime) {
set({ isPlaying: true, currentTime: state.startTime });
} else {
set({ isPlaying: true });
}
animationFrameId = requestAnimationFrame(animate);
},
pause: () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
lastFrameTime = null;
set({ isPlaying: false });
},
stop: () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
lastFrameTime = null;
set({ isPlaying: false, currentTime: get().startTime });
},
setCurrentTime: (time) => {
const { startTime, endTime } = get();
set({ currentTime: Math.max(startTime, Math.min(endTime, time)) });
},
setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }),
setTimeRange: (start, end) => {
set({
startTime: start,
endTime: end,
currentTime: start,
});
},
getProgress: () => {
const { currentTime, startTime, endTime } = get();
if (endTime <= startTime) return 0;
return ((currentTime - startTime) / (endTime - startTime)) * 100;
},
reset: () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
lastFrameTime = null;
set({
isPlaying: false,
currentTime: 0,
startTime: 0,
endTime: 0,
playbackSpeed: 1,
});
},
};
}));

파일 보기

@ -0,0 +1,240 @@
/**
* 항적분석(구역 검색) 메인 상태 관리 스토어
*
* - 구역 관리 (추가/삭제/순서변경, 최대 3)
* - 검색 조건 (모드, 기간)
* - 결과 데이터 (항적, hitDetails, summary)
* - UI 상태
*/
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { SEARCH_MODES, MAX_ZONES, ZONE_NAMES, ALL_SHIP_KIND_CODES } from '../types/areaSearch.types';
/**
* 지점 사이 선박 위치를 시간 기반 보간
*/
function interpolatePosition(p1, p2, t1, t2, currentTime) {
if (t1 === t2) return p1;
if (currentTime <= t1) return p1;
if (currentTime >= t2) return p2;
const ratio = (currentTime - t1) / (t2 - t1);
return [p1[0] + (p2[0] - p1[0]) * ratio, p1[1] + (p2[1] - p1[1]) * ratio];
}
/**
* 지점 방향(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;
}
let zoneIdCounter = 0;
export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
// 검색 조건
zones: [],
searchMode: SEARCH_MODES.ANY,
// 검색 결과
tracks: [],
hitDetails: {},
summary: null,
// UI 상태
isLoading: false,
queryCompleted: false,
disabledVesselIds: new Set(),
highlightedVesselId: null,
showZones: true,
activeDrawType: null,
areaSearchTooltip: null,
// 필터 상태
showPaths: true,
showTrail: false,
shipKindCodeFilter: new Set(ALL_SHIP_KIND_CODES),
// ========== 구역 관리 ==========
addZone: (zone) => {
const { zones } = get();
if (zones.length >= MAX_ZONES) return;
const idx = zones.length;
const newZone = {
...zone,
id: `zone-${++zoneIdCounter}`,
name: ZONE_NAMES[idx] || `${idx + 1}`,
colorIndex: idx,
};
set({ zones: [...zones, newZone], activeDrawType: null });
},
removeZone: (zoneId) => {
const { zones } = get();
const filtered = zones.filter(z => z.id !== zoneId);
set({ zones: filtered });
},
clearZones: () => set({ zones: [] }),
reorderZones: (fromIndex, toIndex) => {
const { zones } = get();
if (fromIndex < 0 || fromIndex >= zones.length) return;
if (toIndex < 0 || toIndex >= zones.length) return;
const newZones = [...zones];
const [moved] = newZones.splice(fromIndex, 1);
newZones.splice(toIndex, 0, moved);
set({ zones: newZones });
},
// ========== 검색 조건 ==========
setSearchMode: (mode) => set({ searchMode: mode }),
setActiveDrawType: (type) => set({ activeDrawType: type }),
setShowZones: (show) => set({ showZones: show }),
// ========== 검색 결과 ==========
setTracks: (tracks) => {
if (tracks.length === 0) {
set({ tracks: [], queryCompleted: true });
return;
}
set({ tracks, queryCompleted: true });
},
setHitDetails: (hitDetails) => set({ hitDetails }),
setSummary: (summary) => set({ summary }),
setLoading: (loading) => set({ isLoading: loading }),
// ========== 선박 토글 ==========
toggleVesselEnabled: (vesselId) => {
const { disabledVesselIds } = get();
const newDisabled = new Set(disabledVesselIds);
if (newDisabled.has(vesselId)) {
newDisabled.delete(vesselId);
} else {
newDisabled.add(vesselId);
}
set({ disabledVesselIds: newDisabled });
},
setHighlightedVesselId: (vesselId) => set({ highlightedVesselId: vesselId }),
setAreaSearchTooltip: (tooltip) => set({ areaSearchTooltip: tooltip }),
// ========== 필터 토글 ==========
setShowPaths: (show) => set({ showPaths: show }),
setShowTrail: (show) => set({ showTrail: show }),
toggleShipKindCode: (code) => {
const { shipKindCodeFilter } = get();
const newFilter = new Set(shipKindCodeFilter);
if (newFilter.has(code)) newFilter.delete(code);
else newFilter.add(code);
set({ shipKindCodeFilter: newFilter });
},
getEnabledTracks: () => {
const { tracks, disabledVesselIds } = get();
return tracks.filter(t => !disabledVesselIds.has(t.vesselId));
},
/**
* 현재 시간의 모든 선박 위치 계산 (이진 탐색 + 선형 보간)
*/
getCurrentPositions: (currentTime) => {
const { tracks, disabledVesselIds } = get();
const positions = [];
tracks.forEach(track => {
if (disabledVesselIds.has(track.vesselId)) return;
const { timestampsMs, geometry, speeds, vesselId, targetId, sigSrcCd, shipName, shipKindCode, nationalCode } = track;
if (timestampsMs.length === 0) return;
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 position, heading, speed;
if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) {
position = geometry[idx1];
speed = speeds[idx1] || 0;
if (idx2 < geometry.length - 1) heading = calculateHeading(geometry[idx1], geometry[idx2 + 1]);
else if (idx1 > 0) heading = calculateHeading(geometry[idx1 - 1], geometry[idx1]);
else heading = 0;
} else {
position = interpolatePosition(geometry[idx1], geometry[idx2], timestampsMs[idx1], timestampsMs[idx2], currentTime);
heading = calculateHeading(geometry[idx1], geometry[idx2]);
const ratio = (currentTime - timestampsMs[idx1]) / (timestampsMs[idx2] - timestampsMs[idx1]);
speed = (speeds[idx1] || 0) + ((speeds[idx2] || 0) - (speeds[idx1] || 0)) * ratio;
}
positions.push({
vesselId, targetId, sigSrcCd, shipName, shipKindCode, nationalCode,
lon: position[0], lat: position[1],
heading, speed, timestamp: currentTime,
});
});
return positions;
},
// ========== 초기화 ==========
clearResults: () => {
set({
tracks: [],
hitDetails: {},
summary: null,
queryCompleted: false,
disabledVesselIds: new Set(),
highlightedVesselId: null,
areaSearchTooltip: null,
showPaths: true,
showTrail: false,
shipKindCodeFilter: new Set(ALL_SHIP_KIND_CODES),
});
},
reset: () => {
set({
zones: [],
searchMode: SEARCH_MODES.ANY,
tracks: [],
hitDetails: {},
summary: null,
isLoading: false,
queryCompleted: false,
disabledVesselIds: new Set(),
highlightedVesselId: null,
showZones: true,
activeDrawType: null,
areaSearchTooltip: null,
showPaths: true,
showTrail: false,
shipKindCodeFilter: new Set(ALL_SHIP_KIND_CODES),
});
},
})));
export default useAreaSearchStore;

파일 보기

@ -0,0 +1,94 @@
/**
* 항적분석(구역 검색) 상수 타입 정의
*/
// ========== 검색 모드 ==========
export const SEARCH_MODES = {
ANY: 'ANY',
ALL: 'ALL',
SEQUENTIAL: 'SEQUENTIAL',
};
export const SEARCH_MODE_LABELS = {
[SEARCH_MODES.ANY]: 'ANY (합집합)',
[SEARCH_MODES.ALL]: 'ALL (교집합)',
[SEARCH_MODES.SEQUENTIAL]: 'SEQUENTIAL (순차통과)',
};
// ========== 구역 설정 ==========
export const MAX_ZONES = 3;
export const ZONE_DRAW_TYPES = {
POLYGON: 'Polygon',
BOX: 'Box',
CIRCLE: 'Circle',
};
export const ZONE_NAMES = ['A', 'B', 'C'];
export const ZONE_COLORS = [
{ fill: [255, 59, 48, 50], stroke: [255, 59, 48, 200], label: '#FF3B30' },
{ fill: [0, 199, 190, 50], stroke: [0, 199, 190, 200], label: '#00C7BE' },
{ fill: [255, 204, 0, 50], stroke: [255, 204, 0, 200], label: '#FFCC00' },
];
// ========== 조회기간 제약 ==========
export const QUERY_MAX_DAYS = 7;
/**
* 조회 가능 기간 계산 (D-7 ~ D-1)
* 인메모리 캐시 기반, 오늘 데이터 없음
*/
export function getQueryDateRange() {
const now = new Date();
const endDate = new Date(now);
endDate.setDate(endDate.getDate() - 1);
endDate.setHours(23, 59, 59, 0);
const startDate = new Date(now);
startDate.setDate(startDate.getDate() - QUERY_MAX_DAYS);
startDate.setHours(0, 0, 0, 0);
return { startDate, endDate };
}
// ========== 배속 옵션 ==========
export const PLAYBACK_SPEED_OPTIONS = [1, 5, 10, 50, 100, 1000];
// ========== 선종 코드 전체 목록 (필터 초기값) ==========
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';
export const ALL_SHIP_KIND_CODES = [
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,
];
// ========== 레이어 ID ==========
export const AREA_SEARCH_LAYER_IDS = {
PATH: 'area-search-path-layer',
TRIPS_TRAIL: 'area-search-trips-trail',
VIRTUAL_SHIP: 'area-search-virtual-ship-layer',
VIRTUAL_SHIP_LABEL: 'area-search-virtual-ship-label-layer',
};

파일 보기

@ -0,0 +1,19 @@
/**
* 항적분석 레이어 전역 레지스트리
* 참조: src/replay/utils/replayLayerRegistry.js
*
* useAreaSearchLayer 훅이 레이어를 등록하면
* useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합
*/
export function registerAreaSearchLayers(layers) {
window.__areaSearchLayers__ = layers;
}
export function getAreaSearchLayers() {
return window.__areaSearchLayers__ || [];
}
export function unregisterAreaSearchLayers() {
window.__areaSearchLayers__ = [];
}

파일 보기

@ -0,0 +1,112 @@
/**
* 항적분석 검색 결과 CSV 내보내기
* BOM + UTF-8 인코딩 (한글 엑셀 호환)
*/
import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
import { getCountryIsoCode } from '../../replay/components/VesselListManager/utils/countryCodeUtils';
function formatTimestamp(ms) {
if (!ms) 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())}`;
}
function formatPosition(pos) {
if (!pos || pos.length < 2) return '';
const lon = pos[0];
const lat = pos[1];
const latDir = lat >= 0 ? 'N' : 'S';
const lonDir = lon >= 0 ? 'E' : 'W';
return `${Math.abs(lat).toFixed(4)}\u00B0${latDir} ${Math.abs(lon).toFixed(4)}\u00B0${lonDir}`;
}
function escapeCsvField(value) {
const str = String(value ?? '');
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
/**
* 검색 결과를 CSV로 내보내기
*
* @param {Array} tracks ProcessedTrack 배열
* @param {Object} hitDetails { vesselId: [{ polygonId, entryTimestamp, exitTimestamp }] }
* @param {Array} zones 구역 배열
*/
export function exportSearchResultToCSV(tracks, hitDetails, zones) {
const zoneNames = zones.map((z) => z.name);
// 헤더 구성
const baseHeaders = [
'신호원', '식별번호', '선박명', '선종', '국적',
'포인트수', '이동거리(NM)', '평균속도(kn)', '최대속도(kn)',
];
const zoneHeaders = [];
zoneNames.forEach((name) => {
zoneHeaders.push(
`구역${name}_진입시각`, `구역${name}_진입위치`,
`구역${name}_진출시각`, `구역${name}_진출위치`,
);
});
const headers = [...baseHeaders, ...zoneHeaders];
// 데이터 행 생성
const rows = tracks.map((track) => {
const baseRow = [
getSignalSourceName(track.sigSrcCd),
track.targetId || '',
track.shipName || '',
getShipKindName(track.shipKindCode),
track.nationalCode ? getCountryIsoCode(track.nationalCode) : '',
track.stats?.pointCount ?? track.geometry.length,
track.stats?.totalDistance != null ? track.stats.totalDistance.toFixed(2) : '',
track.stats?.avgSpeed != null ? track.stats.avgSpeed.toFixed(1) : '',
track.stats?.maxSpeed != null ? track.stats.maxSpeed.toFixed(1) : '',
];
const hits = hitDetails[track.vesselId] || [];
const zoneData = [];
zones.forEach((zone) => {
const hit = hits.find((h) => h.polygonId === zone.id);
if (hit) {
zoneData.push(
formatTimestamp(hit.entryTimestamp),
formatPosition(hit.entryPosition),
formatTimestamp(hit.exitTimestamp),
formatPosition(hit.exitPosition),
);
} else {
zoneData.push('', '', '', '');
}
});
return [...baseRow, ...zoneData];
});
// CSV 문자열 생성
const csvLines = [
headers.map(escapeCsvField).join(','),
...rows.map((row) => row.map(escapeCsvField).join(',')),
];
const csvContent = csvLines.join('\n');
// BOM + UTF-8 Blob 생성 및 다운로드
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
const filename = `항적분석_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.csv`;
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}

파일 보기

@ -11,7 +11,7 @@ const gnbList = [
{ key: 'gnb5', className: 'gnb5', label: '타임라인', path: 'timeline' }, { key: 'gnb5', className: 'gnb5', label: '타임라인', path: 'timeline' },
// { key: 'gnb6', className: 'gnb6', label: 'AI', path: 'ai' }, // { key: 'gnb6', className: 'gnb6', label: 'AI', path: 'ai' },
{ key: 'gnb7', className: 'gnb7', label: '리플레이', path: 'replay' }, { key: 'gnb7', className: 'gnb7', label: '리플레이', path: 'replay' },
// { key: 'gnb8', className: 'gnb8', label: '', path: 'tracking' }, { key: 'gnb8', className: 'gnb8', label: '항적분석', path: 'area-search' },
]; ];
// / (gnb1) DisplayComponent // / (gnb1) DisplayComponent
@ -67,7 +67,7 @@ export const keyToPath = {
gnb5: 'timeline', gnb5: 'timeline',
gnb6: 'ai', gnb6: 'ai',
gnb7: 'replay', gnb7: 'replay',
gnb8: 'tracking', gnb8: 'area-search',
filter: 'filter', filter: 'filter',
layer: 'layer', layer: 'layer',
}; };

파일 보기

@ -18,6 +18,7 @@ const Panel8Component = getPanel('Panel8Component');
import DisplayComponent from '../../component/wrap/side/DisplayComponent'; import DisplayComponent from '../../component/wrap/side/DisplayComponent';
// //
import ReplayPage from '../../pages/ReplayPage'; import ReplayPage from '../../pages/ReplayPage';
import AreaSearchPage from '../../areaSearch/components/AreaSearchPage';
/** /**
* 사이드바 컴포넌트 * 사이드바 컴포넌트
@ -69,7 +70,7 @@ export default function Sidebar() {
gnb5: Panel5Component ? <Panel5Component {...panelProps} /> : null, gnb5: Panel5Component ? <Panel5Component {...panelProps} /> : null,
gnb6: Panel6Component ? <Panel6Component {...panelProps} /> : null, gnb6: Panel6Component ? <Panel6Component {...panelProps} /> : null,
gnb7: <ReplayPage {...panelProps} />, gnb7: <ReplayPage {...panelProps} />,
gnb8: Panel8Component ? <Panel8Component {...panelProps} /> : null, gnb8: <AreaSearchPage {...panelProps} />,
filter: DisplayComponent ? <DisplayComponent {...panelProps} initialTab="filter" /> : null, filter: DisplayComponent ? <DisplayComponent {...panelProps} initialTab="filter" /> : null,
layer: DisplayComponent ? <DisplayComponent {...panelProps} initialTab="layer" /> : null, layer: DisplayComponent ? <DisplayComponent {...panelProps} initialTab="layer" /> : null,
}; };

파일 보기

@ -3,10 +3,12 @@
* - 선박 종류별 아이콘 카운트 표시 * - 선박 종류별 아이콘 카운트 표시
* - 선박 표시 On/Off 토글 * - 선박 표시 On/Off 토글
* - 선박 종류별 필터 토글 * - 선박 종류별 필터 토글
* - 항적분석 활성 결과 카운트 표시
*/ */
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import useShipStore from '../../stores/shipStore'; import useShipStore from '../../stores/shipStore';
import { useAreaSearchStore } from '../../areaSearch/stores/areaSearchStore';
import { import {
SIGNAL_KIND_CODE_FISHING, SIGNAL_KIND_CODE_FISHING,
SIGNAL_KIND_CODE_KCGV, SIGNAL_KIND_CODE_KCGV,
@ -101,22 +103,58 @@ const ShipLegend = memo(() => {
const toggleKindVisibility = useShipStore((state) => state.toggleKindVisibility); const toggleKindVisibility = useShipStore((state) => state.toggleKindVisibility);
const toggleShipVisible = useShipStore((state) => state.toggleShipVisible); const toggleShipVisible = useShipStore((state) => state.toggleShipVisible);
//
const areaSearchCompleted = useAreaSearchStore((s) => s.queryCompleted);
const areaSearchTracks = useAreaSearchStore((s) => s.tracks);
const areaSearchDisabledIds = useAreaSearchStore((s) => s.disabledVesselIds);
const areaSearchKindFilter = useAreaSearchStore((s) => s.shipKindCodeFilter);
const toggleAreaSearchKind = useAreaSearchStore((s) => s.toggleShipKindCode);
const areaSearchCounts = useMemo(() => {
if (!areaSearchCompleted || areaSearchTracks.length === 0) return null;
const counts = {};
let total = 0;
areaSearchTracks.forEach((track) => {
if (areaSearchDisabledIds.has(track.vesselId)) return;
if (!areaSearchKindFilter.has(track.shipKindCode)) return;
const code = track.shipKindCode || SIGNAL_KIND_CODE_NORMAL;
counts[code] = (counts[code] || 0) + 1;
total += 1;
});
return { counts, total };
}, [areaSearchCompleted, areaSearchTracks, areaSearchDisabledIds, areaSearchKindFilter]);
const isAreaSearchMode = areaSearchCounts !== null;
const displayCounts = isAreaSearchMode ? areaSearchCounts.counts : kindCounts;
const displayTotal = isAreaSearchMode ? areaSearchCounts.total : totalCount;
return ( return (
<article className="ship-legend"> <article className="ship-legend">
{/* 헤더 - 전체 On/Off */} {/* 헤더 - 전체 On/Off */}
<div className="legend-header"> <div className="legend-header">
<div className="legend-title"> <div className="legend-title">
<span className={`connection-status ${isConnected ? 'connected' : ''}`} /> {isAreaSearchMode ? (
<span>선박 현황</span> <>
<span className="connection-status connected" style={{ backgroundColor: '#ff9800' }} />
<span>항적 분석</span>
</>
) : (
<>
<span className={`connection-status ${isConnected ? 'connected' : ''}`} />
<span>선박 현황</span>
</>
)}
</div> </div>
<label className="toggle-switch"> {!isAreaSearchMode && (
<input <label className="toggle-switch">
type="checkbox" <input
checked={isShipVisible} type="checkbox"
onChange={toggleShipVisible} checked={isShipVisible}
/> onChange={toggleShipVisible}
<span className="slider" /> />
</label> <span className="slider" />
</label>
)}
</div> </div>
{/* 선박 종류별 목록 */} {/* 선박 종류별 목록 */}
@ -126,10 +164,10 @@ const ShipLegend = memo(() => {
key={item.code} key={item.code}
code={item.code} code={item.code}
label={item.label} label={item.label}
count={kindCounts[item.code] || 0} count={displayCounts[item.code] || 0}
icon={SHIP_KIND_ICONS[item.code]} icon={SHIP_KIND_ICONS[item.code]}
isVisible={kindVisibility[item.code]} isVisible={isAreaSearchMode ? areaSearchKindFilter.has(item.code) : kindVisibility[item.code]}
onToggle={toggleKindVisibility} onToggle={isAreaSearchMode ? toggleAreaSearchKind : toggleKindVisibility}
/> />
))} ))}
</ul> </ul>
@ -137,7 +175,7 @@ const ShipLegend = memo(() => {
{/* 푸터 - 전체 카운트 */} {/* 푸터 - 전체 카운트 */}
<div className="legend-footer"> <div className="legend-footer">
<span>전체</span> <span>전체</span>
<span className="total-count">{totalCount}</span> <span className="total-count">{displayTotal}</span>
</div> </div>
</article> </article>
); );

파일 보기

@ -17,6 +17,7 @@ import useTrackingModeStore from '../stores/trackingModeStore';
import { useMapStore } from '../stores/mapStore'; import { useMapStore } from '../stores/mapStore';
import { getTrackQueryLayers } from '../tracking/utils/trackQueryLayerUtils'; import { getTrackQueryLayers } from '../tracking/utils/trackQueryLayerUtils';
import { getReplayLayers } from '../replay/utils/replayLayerRegistry'; import { getReplayLayers } from '../replay/utils/replayLayerRegistry';
import { getAreaSearchLayers } from '../areaSearch/utils/areaSearchLayerRegistry';
import { shipBatchRenderer } from '../map/ShipBatchRenderer'; import { shipBatchRenderer } from '../map/ShipBatchRenderer';
/** /**
@ -139,9 +140,12 @@ export default function useShipLayer(map) {
// 리플레이 레이어 (전역 레지스트리) // 리플레이 레이어 (전역 레지스트리)
const replayLayers = getReplayLayers(); const replayLayers = getReplayLayers();
// 병합: 선박 + 항적 + 리플레이 레이어 // 항적분석 레이어 (전역 레지스트리)
const areaSearchLayers = getAreaSearchLayers();
// 병합: 선박 + 항적 + 리플레이 + 항적분석 레이어
deckRef.current.setProps({ deckRef.current.setProps({
layers: [...shipLayers, ...trackLayers, ...replayLayers], layers: [...shipLayers, ...trackLayers, ...replayLayers, ...areaSearchLayers],
}); });
}, [map, getSelectedShips]); }, [map, getSelectedShips]);

파일 보기

@ -29,6 +29,14 @@ import { showLiveShips } from '../utils/liveControl';
import { useTrackQueryStore } from '../tracking/stores/trackQueryStore'; import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
import { LAYER_IDS as TRACK_QUERY_LAYER_IDS } from '../tracking/utils/trackQueryLayerUtils'; import { LAYER_IDS as TRACK_QUERY_LAYER_IDS } from '../tracking/utils/trackQueryLayerUtils';
import useAreaSearchLayer from '../areaSearch/hooks/useAreaSearchLayer';
import useZoneDraw from '../areaSearch/hooks/useZoneDraw';
import { useAreaSearchStore } from '../areaSearch/stores/areaSearchStore';
import { useAreaSearchAnimationStore } from '../areaSearch/stores/areaSearchAnimationStore';
import { unregisterAreaSearchLayers } from '../areaSearch/utils/areaSearchLayerRegistry';
import { AREA_SEARCH_LAYER_IDS } from '../areaSearch/types/areaSearch.types';
import AreaSearchTimeline from '../areaSearch/components/AreaSearchTimeline';
import AreaSearchTooltip from '../areaSearch/components/AreaSearchTooltip';
import useMeasure from './measure/useMeasure'; import useMeasure from './measure/useMeasure';
import useTrackingMode from '../hooks/useTrackingMode'; import useTrackingMode from '../hooks/useTrackingMode';
import './measure/measure.scss'; import './measure/measure.scss';
@ -64,6 +72,12 @@ export default function MapContainer() {
// //
useReplayLayer(); useReplayLayer();
// +
useAreaSearchLayer();
useZoneDraw();
const areaSearchCompleted = useAreaSearchStore((s) => s.queryCompleted);
// //
useMeasure(); useMeasure();
@ -133,6 +147,8 @@ export default function MapContainer() {
useShipStore.getState().setHoverInfo(null); useShipStore.getState().setHoverInfo(null);
useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().setHighlightedVesselId(null);
useReplayStore.getState().setHighlightedVesselId(null); useReplayStore.getState().setHighlightedVesselId(null);
useAreaSearchStore.getState().setAreaSearchTooltip(null);
useAreaSearchStore.getState().setHighlightedVesselId(null);
return; return;
} }
@ -152,12 +168,21 @@ export default function MapContainer() {
useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().setHighlightedVesselId(null);
useTrackQueryStore.getState().clearHoveredPoint(); useTrackQueryStore.getState().clearHoveredPoint();
useReplayStore.getState().setHighlightedVesselId(null); useReplayStore.getState().setHighlightedVesselId(null);
useAreaSearchStore.getState().setAreaSearchTooltip(null);
useAreaSearchStore.getState().setHighlightedVesselId(null);
return; return;
} }
const layerId = pickResult.layer.id; const layerId = pickResult.layer.id;
const obj = pickResult.object; const obj = pickResult.object;
// area search area search
if (layerId !== AREA_SEARCH_LAYER_IDS.PATH &&
layerId !== AREA_SEARCH_LAYER_IDS.VIRTUAL_SHIP) {
useAreaSearchStore.getState().setAreaSearchTooltip(null);
useAreaSearchStore.getState().setHighlightedVesselId(null);
}
// //
if (layerId === 'ship-icon-layer') { if (layerId === 'ship-icon-layer') {
useShipStore.getState().setHoverInfo({ ship: obj, x: clientX, y: clientY }); useShipStore.getState().setHoverInfo({ ship: obj, x: clientX, y: clientY });
@ -212,6 +237,32 @@ export default function MapContainer() {
return; return;
} }
// (PathLayer)
if (layerId === AREA_SEARCH_LAYER_IDS.PATH) {
useShipStore.getState().setHoverInfo(null);
useTrackQueryStore.getState().setHighlightedVesselId(null);
useTrackQueryStore.getState().clearHoveredPoint();
useReplayStore.getState().setHighlightedVesselId(null);
useAreaSearchStore.getState().setHighlightedVesselId(obj?.vesselId || null);
useAreaSearchStore.getState().setAreaSearchTooltip(
obj ? { vesselId: obj.vesselId, x: clientX, y: clientY } : null,
);
return;
}
//
if (layerId === AREA_SEARCH_LAYER_IDS.VIRTUAL_SHIP) {
useShipStore.getState().setHoverInfo(null);
useTrackQueryStore.getState().setHighlightedVesselId(null);
useTrackQueryStore.getState().clearHoveredPoint();
useReplayStore.getState().setHighlightedVesselId(null);
useAreaSearchStore.getState().setHighlightedVesselId(obj?.vesselId || null);
useAreaSearchStore.getState().setAreaSearchTooltip(
obj ? { vesselId: obj.vesselId, x: clientX, y: clientY } : null,
);
return;
}
// (PathLayer) // (PathLayer)
if (layerId === 'track-path-layer') { if (layerId === 'track-path-layer') {
useShipStore.getState().setHoverInfo(null); useShipStore.getState().setHoverInfo(null);
@ -266,6 +317,8 @@ export default function MapContainer() {
useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().setHighlightedVesselId(null);
useTrackQueryStore.getState().clearHoveredPoint(); useTrackQueryStore.getState().clearHoveredPoint();
useReplayStore.getState().setHighlightedVesselId(null); useReplayStore.getState().setHighlightedVesselId(null);
useAreaSearchStore.getState().setAreaSearchTooltip(null);
useAreaSearchStore.getState().setHighlightedVesselId(null);
}, []); }, []);
/** /**
@ -452,6 +505,8 @@ export default function MapContainer() {
<ShipContextMenu /> <ShipContextMenu />
<GlobalTrackQueryViewer /> <GlobalTrackQueryViewer />
<ReplayLoadingOverlay /> <ReplayLoadingOverlay />
{areaSearchCompleted && <AreaSearchTooltip />}
{areaSearchCompleted && <AreaSearchTimeline />}
{replayCompleted && ( {replayCompleted && (
<ReplayTimeline <ReplayTimeline
fromDate={replayQuery?.startTime} fromDate={replayQuery?.startTime}

파일 보기

@ -40,10 +40,13 @@ const MAX_POINTS_PER_TRACK = 800;
* @param {string} [params.highlightedVesselId] - 하이라이트할 선박 ID * @param {string} [params.highlightedVesselId] - 하이라이트할 선박 ID
* @param {Function} [params.onPathHover] - 항적 호버 콜백 * @param {Function} [params.onPathHover] - 항적 호버 콜백
*/ */
export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselId, onPathHover }) { export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselId, onPathHover, layerIds }) {
const layers = []; const layers = [];
if (!tracks || tracks.length === 0) return layers; if (!tracks || tracks.length === 0) return layers;
const pathId = layerIds?.path || 'track-path-layer';
const pointId = layerIds?.point || 'track-point-layer';
// 1. PathLayer - 전체 경로 (시간 무관) // 1. PathLayer - 전체 경로 (시간 무관)
const pathData = tracks.map((track) => ({ const pathData = tracks.map((track) => ({
path: track.geometry, path: track.geometry,
@ -53,7 +56,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
layers.push( layers.push(
new PathLayer({ new PathLayer({
id: 'track-path-layer', id: pathId,
data: pathData, data: pathData,
getPath: (d) => d.path, getPath: (d) => d.path,
getColor: (d) => { getColor: (d) => {
@ -71,7 +74,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
return 2; return 2;
}, },
widthUnits: 'pixels', widthUnits: 'pixels',
widthMinPixels: 1, widthMinPixels: 4,
widthMaxPixels: 8, widthMaxPixels: 8,
jointRounded: true, jointRounded: true,
capRounded: true, capRounded: true,
@ -118,7 +121,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
layers.push( layers.push(
new ScatterplotLayer({ new ScatterplotLayer({
id: 'track-point-layer', id: pointId,
data: pointData, data: pointData,
getPosition: (d) => d.position, getPosition: (d) => d.position,
getFillColor: (d) => d.color, getFillColor: (d) => d.color,
@ -146,16 +149,19 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
* @param {Function} [params.onPathHover] - 하이라이트 설정용 콜백 * @param {Function} [params.onPathHover] - 하이라이트 설정용 콜백
* @returns {Array} Deck.gl Layer 배열 * @returns {Array} Deck.gl Layer 배열
*/ */
export function createVirtualShipLayers({ currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover }) { export function createVirtualShipLayers({ currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover, layerIds }) {
const layers = []; const layers = [];
if (!currentPositions || currentPositions.length === 0) return layers; if (!currentPositions || currentPositions.length === 0) return layers;
const iconId = layerIds?.icon || 'track-virtual-ship-layer';
const labelId = layerIds?.label || 'track-label-layer';
// 1. IconLayer - 가상 선박 아이콘 // 1. IconLayer - 가상 선박 아이콘
if (showVirtualShip) { if (showVirtualShip) {
layers.push( layers.push(
new IconLayer({ new IconLayer({
id: 'track-virtual-ship-layer', id: iconId,
data: currentPositions, data: currentPositions,
iconAtlas: atlasImg, iconAtlas: atlasImg,
iconMapping: ICON_ATLAS_MAPPING, iconMapping: ICON_ATLAS_MAPPING,
@ -197,7 +203,7 @@ export function createVirtualShipLayers({ currentPositions, showVirtualShip, sho
layers.push( layers.push(
new TextLayer({ new TextLayer({
id: 'track-label-layer', id: labelId,
data: labelData, data: labelData,
getPosition: (d) => [d.lon, d.lat], getPosition: (d) => [d.lon, d.lat],
getText: (d) => d.shipName, getText: (d) => d.shipName,

파일 보기

@ -304,6 +304,61 @@ export const MMSI_COUNTRY_NAMES = {
'999': '기타', '999': '기타',
}; };
// MMSI MID → ISO 3166-1 alpha-2 매핑
const MMSI_TO_ISO = {
'201': 'AL', '205': 'BE', '206': 'BY', '207': 'BG', '209': 'CY',
'210': 'CY', '211': 'DE', '212': 'CY', '213': 'GE', '214': 'MD',
'215': 'MT', '216': 'AM', '218': 'DE', '219': 'DK', '220': 'DK',
'224': 'ES', '225': 'ES', '226': 'FR', '227': 'FR', '228': 'FR',
'230': 'FI', '232': 'GB', '233': 'GB', '234': 'GB', '235': 'GB',
'237': 'GR', '238': 'HR', '239': 'GR', '240': 'GR', '241': 'GR',
'242': 'MA', '243': 'HU', '244': 'NL', '245': 'NL', '246': 'NL',
'247': 'IT', '248': 'MT', '249': 'MT', '250': 'IE', '251': 'IS',
'256': 'MT', '257': 'NO', '258': 'NO', '259': 'NO', '261': 'PL',
'263': 'PT', '264': 'RO', '265': 'SE', '266': 'SE', '271': 'TR',
'272': 'UA', '273': 'RU', '275': 'LV', '276': 'EE', '277': 'LT',
'278': 'SI', '279': 'RS',
'304': 'AG', '305': 'AG', '308': 'BS', '309': 'BS', '311': 'BS',
'312': 'BZ', '314': 'BB', '316': 'CA', '319': 'KY', '321': 'CR',
'323': 'CU', '325': 'DM', '327': 'DO', '330': 'GD', '332': 'GT',
'334': 'HN', '336': 'HT', '338': 'US', '339': 'JM', '345': 'MX',
'351': 'PA', '352': 'PA', '353': 'PA', '354': 'PA', '355': 'PA',
'356': 'PA', '357': 'PA', '359': 'SV', '362': 'TT',
'366': 'US', '367': 'US', '368': 'US', '369': 'US',
'370': 'PA', '371': 'PA', '372': 'PA', '373': 'PA', '374': 'PA',
'375': 'VC', '376': 'VC', '377': 'VC',
'401': 'AF', '403': 'SA', '405': 'BD', '408': 'BH', '410': 'BT',
'412': 'CN', '413': 'CN', '414': 'CN', '416': 'TW', '417': 'LK',
'419': 'IN', '422': 'IR', '425': 'IQ', '428': 'IL',
'431': 'JP', '432': 'JP', '436': 'KZ',
'438': 'JO', '440': 'KR', '441': 'KR', '445': 'KP',
'447': 'KW', '450': 'LB', '453': 'MO', '455': 'MV', '457': 'MN',
'461': 'OM', '463': 'PK', '466': 'QA', '468': 'SY',
'470': 'AE', '473': 'YE', '475': 'YE', '477': 'HK',
'503': 'AU', '506': 'MM', '508': 'BN', '512': 'NZ', '514': 'KH',
'515': 'KH', '525': 'ID', '533': 'MY', '538': 'MH',
'548': 'PH', '563': 'SG', '564': 'SG', '565': 'SG', '566': 'SG',
'567': 'TH', '574': 'VN', '576': 'VU', '577': 'VU',
'601': 'ZA', '605': 'DZ', '612': 'CF', '613': 'CM', '620': 'EG',
'625': 'GH', '632': 'LR', '633': 'LR', '634': 'LR',
'635': 'LR', '636': 'LR', '637': 'LR', '657': 'NG',
'668': 'ZA', '669': 'ZA',
'701': 'AR', '710': 'BR', '720': 'BO', '725': 'CL', '730': 'CO',
'735': 'EC', '750': 'GY', '755': 'PY', '760': 'PE',
'770': 'UY', '775': 'VE',
};
/**
* MMSI MID 코드 ISO alpha-2 국가코드 변환
* @param {string} nationalCode MMSI MID 코드 (3자리)
* @returns {string} ISO alpha-2 코드 또는 원본 코드
*/
export const getCountryIsoCode = (nationalCode) => {
if (!nationalCode) return '';
const code = String(nationalCode);
return MMSI_TO_ISO[code] || code;
};
/** /**
* MMSI MID 코드로부터 한글 국가명을 반환 * MMSI MID 코드로부터 한글 국가명을 반환
* @param {string} nationalCode MMSI MID 코드 (3자리 문자열) * @param {string} nationalCode MMSI MID 코드 (3자리 문자열)