diff --git a/src/areaSearch/components/AreaSearchPage.jsx b/src/areaSearch/components/AreaSearchPage.jsx
new file mode 100644
index 00000000..f42111b1
--- /dev/null
+++ b/src/areaSearch/components/AreaSearchPage.jsx
@@ -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 (
+
+
+
+ {/* 조회 기간 */}
+
+
조회 기간
+
D-7 ~ D-1 (인메모리 캐시 기반)
+
+
+
+
+
+
+ {/* 구역 설정 */}
+
+
+ {/* 검색 모드 */}
+
+
검색 조건
+
+ {Object.entries(SEARCH_MODE_LABELS).map(([mode, label]) => (
+
+ ))}
+
+
+
+ {/* 조회 버튼 */}
+
+
+
+
+ {/* 결과 영역 */}
+
+ {errorMessage &&
{errorMessage}
}
+
+ {isLoading &&
데이터를 불러오는 중입니다...
}
+
+ {queryCompleted && tracks.length > 0 && (
+
+ {summary && (
+
+ 검색 결과: {summary.totalVessels ?? tracks.length}척
+ {summary.processingTimeMs != null && (
+
+ ({(summary.processingTimeMs / 1000).toFixed(2)}초)
+
+ )}
+
+ )}
+ {!summary && (
+
+ 검색 결과: {tracks.length}척
+
+ )}
+
+
+
+
+ {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 (
+ - handleHighlightVessel(track.vesselId)}
+ onMouseLeave={() => handleHighlightVessel(null)}
+ >
+
+
+ );
+ })}
+
+
+ )}
+
+ {queryCompleted && tracks.length === 0 && !errorMessage && (
+
조건에 맞는 선박이 없습니다.
+ )}
+
+ {!isLoading && !queryCompleted && !errorMessage && (
+
+ 구역을 설정하고 조회 버튼을 클릭하세요.
+
+ )}
+
+
+
+ );
+}
diff --git a/src/areaSearch/components/AreaSearchPage.scss b/src/areaSearch/components/AreaSearchPage.scss
new file mode 100644
index 00000000..5445e152
--- /dev/null
+++ b/src/areaSearch/components/AreaSearchPage.scss
@@ -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;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/areaSearch/components/AreaSearchTimeline.jsx b/src/areaSearch/components/AreaSearchTimeline.jsx
new file mode 100644
index 00000000..193aa682
--- /dev/null
+++ b/src/areaSearch/components/AreaSearchTimeline.jsx
@@ -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 (
+
+
+
+
+
+
+ {showSpeedMenu && (
+
+ {PLAYBACK_SPEED_OPTIONS.map((speed) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {hasData ? formatDateTime(currentTime) : '--:--:--'}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/areaSearch/components/AreaSearchTimeline.scss b/src/areaSearch/components/AreaSearchTimeline.scss
new file mode 100644
index 00000000..dd734b98
--- /dev/null
+++ b/src/areaSearch/components/AreaSearchTimeline.scss
@@ -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);
+ }
+}
diff --git a/src/areaSearch/components/AreaSearchTooltip.jsx b/src/areaSearch/components/AreaSearchTooltip.jsx
new file mode 100644
index 00000000..82b6b063
--- /dev/null
+++ b/src/areaSearch/components/AreaSearchTooltip.jsx
@@ -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 (
+
+
+
{kindName}
+ {flagUrl && (
+
+
{ e.target.style.display = 'none'; }}
+ />
+
+ )}
+
+ {track.shipName || track.targetId || '-'}
+
+
+
+ {sourceName}
+
+
+ {zones.length > 0 && hits.length > 0 && (
+
+ {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 (
+
+
+ {zone.name}
+
+
+ IN
+ {formatTimestamp(hit.entryTimestamp)}
+ {entryPos && (
+ {entryPos}
+ )}
+
+
+ OUT
+ {formatTimestamp(hit.exitTimestamp)}
+ {exitPos && (
+ {exitPos}
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/areaSearch/components/AreaSearchTooltip.scss b/src/areaSearch/components/AreaSearchTooltip.scss
new file mode 100644
index 00000000..1dc07409
--- /dev/null
+++ b/src/areaSearch/components/AreaSearchTooltip.scss
@@ -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;
+ }
+}
diff --git a/src/areaSearch/components/ZoneDrawPanel.jsx b/src/areaSearch/components/ZoneDrawPanel.jsx
new file mode 100644
index 00000000..f642309c
--- /dev/null
+++ b/src/areaSearch/components/ZoneDrawPanel.jsx
@@ -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 (
+
+
구역 설정
+
{zones.length}/{MAX_ZONES}개 설정됨
+
+ {/* 그리기 버튼 */}
+
+
+
+
+
+
+ {activeDrawType && (
+
지도에서 구역을 그려주세요. (ESC: 취소)
+ )}
+
+ {/* 구역 목록 */}
+ {zones.length > 0 && (
+
+ {zones.map((zone, index) => {
+ const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0];
+ return (
+ - handleDragStart(e, index)}
+ onDragOver={(e) => handleDragOver(e, index)}
+ onDrop={(e) => handleDrop(e, index)}
+ onDragEnd={handleDragEnd}
+ >
+ ≡
+
+ 구역 {zone.name}
+ {zone.type}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/areaSearch/components/ZoneDrawPanel.scss b/src/areaSearch/components/ZoneDrawPanel.scss
new file mode 100644
index 00000000..519ff885
--- /dev/null
+++ b/src/areaSearch/components/ZoneDrawPanel.scss
@@ -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;
+ }
+ }
+ }
+ }
+}
diff --git a/src/areaSearch/hooks/useAreaSearchLayer.js b/src/areaSearch/hooks/useAreaSearchLayer.js
new file mode 100644
index 00000000..3637ddc5
--- /dev/null
+++ b/src/areaSearch/hooks/useAreaSearchLayer.js
@@ -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 = [];
+ };
+ }, []);
+}
diff --git a/src/areaSearch/hooks/useZoneDraw.js b/src/areaSearch/hooks/useZoneDraw.js
new file mode 100644
index 00000000..147a50cb
--- /dev/null
+++ b/src/areaSearch/hooks/useZoneDraw.js
@@ -0,0 +1,258 @@
+/**
+ * 구역 그리기 OpenLayers Draw 인터랙션 훅
+ *
+ * - activeDrawType 변경 시 Draw 인터랙션 활성화
+ * - Polygon / Box / Circle 그리기
+ * - drawend → EPSG:3857→4326 변환 → 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;
+ }, []);
+}
diff --git a/src/areaSearch/services/areaSearchApi.js b/src/areaSearch/services/areaSearchApi.js
new file mode 100644
index 00000000..94e393da
--- /dev/null
+++ b/src/areaSearch/services/areaSearchApi.js
@@ -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,
+ };
+}
diff --git a/src/areaSearch/stores/areaSearchAnimationStore.js b/src/areaSearch/stores/areaSearchAnimationStore.js
new file mode 100644
index 00000000..ed542c1a
--- /dev/null
+++ b/src/areaSearch/stores/areaSearchAnimationStore.js
@@ -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,
+ });
+ },
+ };
+}));
diff --git a/src/areaSearch/stores/areaSearchStore.js b/src/areaSearch/stores/areaSearchStore.js
new file mode 100644
index 00000000..0d3ce9b6
--- /dev/null
+++ b/src/areaSearch/stores/areaSearchStore.js
@@ -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;
diff --git a/src/areaSearch/types/areaSearch.types.js b/src/areaSearch/types/areaSearch.types.js
new file mode 100644
index 00000000..820b3216
--- /dev/null
+++ b/src/areaSearch/types/areaSearch.types.js
@@ -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',
+};
diff --git a/src/areaSearch/utils/areaSearchLayerRegistry.js b/src/areaSearch/utils/areaSearchLayerRegistry.js
new file mode 100644
index 00000000..1ac952e4
--- /dev/null
+++ b/src/areaSearch/utils/areaSearchLayerRegistry.js
@@ -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__ = [];
+}
diff --git a/src/areaSearch/utils/csvExport.js b/src/areaSearch/utils/csvExport.js
new file mode 100644
index 00000000..9b01f486
--- /dev/null
+++ b/src/areaSearch/utils/csvExport.js
@@ -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);
+}
diff --git a/src/components/layout/SideNav.jsx b/src/components/layout/SideNav.jsx
index 5611e1ab..2c46f1e7 100644
--- a/src/components/layout/SideNav.jsx
+++ b/src/components/layout/SideNav.jsx
@@ -11,7 +11,7 @@ const gnbList = [
{ key: 'gnb5', className: 'gnb5', label: '타임라인', path: 'timeline' },
// { key: 'gnb6', className: 'gnb6', label: 'AI모드', path: 'ai' },
{ key: 'gnb7', className: 'gnb7', label: '리플레이', path: 'replay' },
- // { key: 'gnb8', className: 'gnb8', label: '항적조회', path: 'tracking' },
+ { key: 'gnb8', className: 'gnb8', label: '항적분석', path: 'area-search' },
];
// 필터/레이어 버튼 비활성화 — 선박(gnb1) 버튼에서 DisplayComponent로 통합
@@ -67,7 +67,7 @@ export const keyToPath = {
gnb5: 'timeline',
gnb6: 'ai',
gnb7: 'replay',
- gnb8: 'tracking',
+ gnb8: 'area-search',
filter: 'filter',
layer: 'layer',
};
diff --git a/src/components/layout/Sidebar.jsx b/src/components/layout/Sidebar.jsx
index 1702c55a..c20389d5 100644
--- a/src/components/layout/Sidebar.jsx
+++ b/src/components/layout/Sidebar.jsx
@@ -18,6 +18,7 @@ const Panel8Component = getPanel('Panel8Component');
import DisplayComponent from '../../component/wrap/side/DisplayComponent';
// 구현된 페이지
import ReplayPage from '../../pages/ReplayPage';
+import AreaSearchPage from '../../areaSearch/components/AreaSearchPage';
/**
* 사이드바 컴포넌트
@@ -69,7 +70,7 @@ export default function Sidebar() {
gnb5: Panel5Component ? : null,
gnb6: Panel6Component ? : null,
gnb7: ,
- gnb8: Panel8Component ? : null,
+ gnb8: ,
filter: DisplayComponent ? : null,
layer: DisplayComponent ? : null,
};
diff --git a/src/components/ship/ShipLegend.jsx b/src/components/ship/ShipLegend.jsx
index 1bb15649..237f184d 100644
--- a/src/components/ship/ShipLegend.jsx
+++ b/src/components/ship/ShipLegend.jsx
@@ -3,10 +3,12 @@
* - 선박 종류별 아이콘 및 카운트 표시
* - 선박 표시 On/Off 토글
* - 선박 종류별 필터 토글
+ * - 항적분석 활성 시 결과 카운트 표시
*/
-import { memo } from 'react';
+import { memo, useMemo } from 'react';
import { shallow } from 'zustand/shallow';
import useShipStore from '../../stores/shipStore';
+import { useAreaSearchStore } from '../../areaSearch/stores/areaSearchStore';
import {
SIGNAL_KIND_CODE_FISHING,
SIGNAL_KIND_CODE_KCGV,
@@ -101,22 +103,58 @@ const ShipLegend = memo(() => {
const toggleKindVisibility = useShipStore((state) => state.toggleKindVisibility);
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 (
{/* 헤더 - 전체 On/Off */}
{/* 선박 종류별 목록 */}
@@ -126,10 +164,10 @@ const ShipLegend = memo(() => {
key={item.code}
code={item.code}
label={item.label}
- count={kindCounts[item.code] || 0}
+ count={displayCounts[item.code] || 0}
icon={SHIP_KIND_ICONS[item.code]}
- isVisible={kindVisibility[item.code]}
- onToggle={toggleKindVisibility}
+ isVisible={isAreaSearchMode ? areaSearchKindFilter.has(item.code) : kindVisibility[item.code]}
+ onToggle={isAreaSearchMode ? toggleAreaSearchKind : toggleKindVisibility}
/>
))}
@@ -137,7 +175,7 @@ const ShipLegend = memo(() => {
{/* 푸터 - 전체 카운트 */}
전체
- {totalCount}
+ {displayTotal}
);
diff --git a/src/hooks/useShipLayer.js b/src/hooks/useShipLayer.js
index 80c05716..10c36678 100644
--- a/src/hooks/useShipLayer.js
+++ b/src/hooks/useShipLayer.js
@@ -17,6 +17,7 @@ import useTrackingModeStore from '../stores/trackingModeStore';
import { useMapStore } from '../stores/mapStore';
import { getTrackQueryLayers } from '../tracking/utils/trackQueryLayerUtils';
import { getReplayLayers } from '../replay/utils/replayLayerRegistry';
+import { getAreaSearchLayers } from '../areaSearch/utils/areaSearchLayerRegistry';
import { shipBatchRenderer } from '../map/ShipBatchRenderer';
/**
@@ -139,9 +140,12 @@ export default function useShipLayer(map) {
// 리플레이 레이어 (전역 레지스트리)
const replayLayers = getReplayLayers();
- // 병합: 선박 + 항적 + 리플레이 레이어
+ // 항적분석 레이어 (전역 레지스트리)
+ const areaSearchLayers = getAreaSearchLayers();
+
+ // 병합: 선박 + 항적 + 리플레이 + 항적분석 레이어
deckRef.current.setProps({
- layers: [...shipLayers, ...trackLayers, ...replayLayers],
+ layers: [...shipLayers, ...trackLayers, ...replayLayers, ...areaSearchLayers],
});
}, [map, getSelectedShips]);
diff --git a/src/map/MapContainer.jsx b/src/map/MapContainer.jsx
index 5aefd676..36da42d5 100644
--- a/src/map/MapContainer.jsx
+++ b/src/map/MapContainer.jsx
@@ -29,6 +29,14 @@ import { showLiveShips } from '../utils/liveControl';
import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
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 useTrackingMode from '../hooks/useTrackingMode';
import './measure/measure.scss';
@@ -64,6 +72,12 @@ export default function MapContainer() {
// 리플레이 레이어
useReplayLayer();
+ // 항적분석 레이어 + 구역 그리기
+ useAreaSearchLayer();
+ useZoneDraw();
+
+ const areaSearchCompleted = useAreaSearchStore((s) => s.queryCompleted);
+
// 측정 도구
useMeasure();
@@ -133,6 +147,8 @@ export default function MapContainer() {
useShipStore.getState().setHoverInfo(null);
useTrackQueryStore.getState().setHighlightedVesselId(null);
useReplayStore.getState().setHighlightedVesselId(null);
+ useAreaSearchStore.getState().setAreaSearchTooltip(null);
+ useAreaSearchStore.getState().setHighlightedVesselId(null);
return;
}
@@ -152,12 +168,21 @@ export default function MapContainer() {
useTrackQueryStore.getState().setHighlightedVesselId(null);
useTrackQueryStore.getState().clearHoveredPoint();
useReplayStore.getState().setHighlightedVesselId(null);
+ useAreaSearchStore.getState().setAreaSearchTooltip(null);
+ useAreaSearchStore.getState().setHighlightedVesselId(null);
return;
}
const layerId = pickResult.layer.id;
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') {
useShipStore.getState().setHoverInfo({ ship: obj, x: clientX, y: clientY });
@@ -212,6 +237,32 @@ export default function MapContainer() {
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)
if (layerId === 'track-path-layer') {
useShipStore.getState().setHoverInfo(null);
@@ -266,6 +317,8 @@ export default function MapContainer() {
useTrackQueryStore.getState().setHighlightedVesselId(null);
useTrackQueryStore.getState().clearHoveredPoint();
useReplayStore.getState().setHighlightedVesselId(null);
+ useAreaSearchStore.getState().setAreaSearchTooltip(null);
+ useAreaSearchStore.getState().setHighlightedVesselId(null);
}, []);
/**
@@ -452,6 +505,8 @@ export default function MapContainer() {
+ {areaSearchCompleted && }
+ {areaSearchCompleted && }
{replayCompleted && (
({
path: track.geometry,
@@ -53,7 +56,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
layers.push(
new PathLayer({
- id: 'track-path-layer',
+ id: pathId,
data: pathData,
getPath: (d) => d.path,
getColor: (d) => {
@@ -71,7 +74,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
return 2;
},
widthUnits: 'pixels',
- widthMinPixels: 1,
+ widthMinPixels: 4,
widthMaxPixels: 8,
jointRounded: true,
capRounded: true,
@@ -118,7 +121,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
layers.push(
new ScatterplotLayer({
- id: 'track-point-layer',
+ id: pointId,
data: pointData,
getPosition: (d) => d.position,
getFillColor: (d) => d.color,
@@ -146,16 +149,19 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
* @param {Function} [params.onPathHover] - 하이라이트 설정용 콜백
* @returns {Array} Deck.gl Layer 배열
*/
-export function createVirtualShipLayers({ currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover }) {
+export function createVirtualShipLayers({ currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover, layerIds }) {
const 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 - 가상 선박 아이콘
if (showVirtualShip) {
layers.push(
new IconLayer({
- id: 'track-virtual-ship-layer',
+ id: iconId,
data: currentPositions,
iconAtlas: atlasImg,
iconMapping: ICON_ATLAS_MAPPING,
@@ -197,7 +203,7 @@ export function createVirtualShipLayers({ currentPositions, showVirtualShip, sho
layers.push(
new TextLayer({
- id: 'track-label-layer',
+ id: labelId,
data: labelData,
getPosition: (d) => [d.lon, d.lat],
getText: (d) => d.shipName,
diff --git a/src/replay/components/VesselListManager/utils/countryCodeUtils.js b/src/replay/components/VesselListManager/utils/countryCodeUtils.js
index 8ce2ac72..1275c173 100644
--- a/src/replay/components/VesselListManager/utils/countryCodeUtils.js
+++ b/src/replay/components/VesselListManager/utils/countryCodeUtils.js
@@ -304,6 +304,61 @@ export const MMSI_COUNTRY_NAMES = {
'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 코드로부터 한글 국가명을 반환
* @param {string} nationalCode MMSI MID 코드 (3자리 문자열)