diff --git a/CLAUDE.md b/CLAUDE.md
index 31dc317a..426ddd6e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,5 +1,8 @@
# CLAUDE.md - GIS 함정용 프로젝트 가이드
+## 응답 규칙
+- **모든 응답은 반드시 한글로 작성한다.**
+
## 프로젝트 개요
| 항목 | 내용 |
@@ -7,7 +10,7 @@
| 프로젝트명 | dark (GIS 함정용) |
| 참조 프로젝트 | mda-react-front (메인 프로젝트) |
| 목적 | 선박위치정보 전시 및 조회 기능 프론트엔드 |
-| 현재 단계 | 퍼블리싱 → 구현 전환 |
+| 현재 단계 | Phase 6 진행 중 - 리플레이 기능 구현 완료 |
---
@@ -29,93 +32,74 @@
## 현재 프로젝트(dark) 기술 스택
-| 항목 | 기술 | 변경 계획 |
-|------|------|----------|
-| 번들러 | CRA (react-scripts) | Vite 마이그레이션 검토 |
-| 언어 | JavaScript | TypeScript 도입 검토 |
-| 라우팅 | React Router 6.30.3 | 유지 |
-| 상태관리 | React useState | Zustand 도입 |
-| 스타일 | SCSS | 유지 |
-| 지도 | 미연동 | OpenLayers + Deck.gl |
+| 항목 | 기술 |
+|------|------|
+| 번들러 | Vite 5.2.10 |
+| 언어 | JavaScript (TypeScript 도입 검토) |
+| UI | React 18.2.0 |
+| 라우팅 | React Router 6.30.3 |
+| 상태관리 | Zustand 4.5.2 (subscribeWithSelector) |
+| 지도 엔진 | OpenLayers 9.2.4 |
+| 선박 렌더링 | Deck.gl 9.2.6 (core, layers, extensions) |
+| 실시간 통신 | @stomp/stompjs (STOMP WebSocket) |
+| HTTP | Axios 1.4.0 |
+| 스타일 | SCSS |
---
-## 프로젝트 구조 설계
+## 프로젝트 구조
```
src/
├── index.js # 앱 엔트리 포인트
├── App.jsx # 라우트 정의
│
-├── publish/ # [퍼블리싱 영역] - 기존 퍼블리시 파일
-│ ├── layouts/ # 퍼블리시 레이아웃
-│ ├── pages/ # 퍼블리시 페이지 (Panel1~8 등)
-│ └── components/ # 퍼블리시 컴포넌트
+├── publish/ # [퍼블리싱 원본] - 직접 수정 금지, 참조용
│
-├── pages/ # [구현 영역] - 실제 페이지
-│ ├── Home.jsx # 메인 페이지
-│ ├── ship/ # 선박 관련 페이지
-│ ├── satellite/ # 위성 관련 페이지
-│ ├── weather/ # 기상 관련 페이지
-│ └── analysis/ # 분석 관련 페이지
+├── pages/ # [구현 페이지]
+│ └── HomePage.jsx # 메인 페이지 (지도 + 레이아웃)
│
-├── components/ # [공통 컴포넌트]
-│ ├── common/ # 기본 UI 컴포넌트
-│ │ ├── Button/
-│ │ ├── Input/
-│ │ ├── Modal/
-│ │ └── ...
-│ ├── domain/ # 도메인별 컴포넌트
-│ │ ├── ship/ # 선박 관련
-│ │ ├── map/ # 지도 관련
-│ │ └── ...
-│ └── layout/ # 레이아웃 컴포넌트
-│ ├── Header/
-│ ├── Sidebar/
-│ └── ...
+├── components/
+│ ├── layout/ # Header, Sidebar, ToolBar, MainLayout, SideNav
+│ ├── ship/ # ShipLegend, ShipDetailModal, ShipContextMenu, TrackQueryModal
+│ └── map/ # TopBar (좌표/시간표시, 검색), PatrolShipSelector (함정선택)
│
├── map/ # [지도 모듈]
-│ ├── MapContext.jsx # OpenLayers 맵 Context
-│ ├── MapProvider.jsx # 맵 Provider
-│ ├── layers/ # 레이어 정의
-│ │ ├── baseLayer.js # 베이스맵
-│ │ ├── shipLayer.js # 선박 레이어
-│ │ └── ...
-│ ├── controls/ # 지도 컨트롤
-│ └── utils/ # 지도 유틸리티
+│ ├── MapContainer.jsx # OpenLayers + Deck.gl 통합 (window.__mainMap__)
+│ ├── ShipBatchRenderer.js # 배치 렌더러 (적응형 렌더링, 필터 캐시)
+│ └── layers/
+│ ├── baseLayer.js # 베이스맵 (worldMap, eastAsiaMap, korMap)
+│ ├── shipLayer.js # Deck.gl 선박 레이어 (아이콘, 라벨, 벡터, 신호상태)
+│ └── trackLayer.js # 항적 레이어
│
├── stores/ # [Zustand 스토어]
-│ ├── mapStore.js # 지도 상태
-│ ├── shipStore.js # 선박 상태
-│ ├── filterStore.js # 필터 상태
-│ └── uiStore.js # UI 상태
+│ ├── shipStore.js # 선박 데이터 (핵심 - mutable Map/Set + 버전 카운터)
+│ ├── mapStore.js # 지도 상태
+│ ├── trackStore.js # 항적 상태
+│ └── trackingModeStore.js # 추적 모드 (지도/선박 모드, 반경 설정)
│
-├── api/ # [API 레이어]
-│ ├── client.js # API 클라이언트
-│ ├── ship.js # 선박 API
-│ └── ...
+├── tracking/ # [항적조회 패키지] - 메인 프로젝트 TS→JS 변환
+│ ├── stores/ # trackQueryStore, trackQueryAnimationStore
+│ ├── services/ # trackQueryApi (API + 통합선박 처리)
+│ ├── components/ # TrackQueryViewer, TrackQueryTimeline, GlobalTrackQueryViewer
+│ ├── hooks/ # useEquipmentFilter, useTrackHighlight
+│ ├── utils/ # trackQueryLayerUtils, TrackQueryBatchRenderer, shipIconUtil
+│ └── types/ # trackQuery.types.js
│
-├── hooks/ # [커스텀 훅]
-│ ├── useMap.js
-│ ├── useShip.js
-│ └── ...
+├── replay/ # [리플레이 패키지] - 메인 프로젝트 TS→JS 변환
+│ ├── stores/ # replayStore, animationStore, mergedTrackStore, playbackTrailStore
+│ ├── services/ # ReplayWebSocketService (WebSocket 청크 수신)
+│ ├── components/ # ReplayTimeline, ReplayControlV2, VesselListManager
+│ ├── hooks/ # useReplayLayer (Deck.gl 레이어 관리)
+│ ├── utils/ # replayLayerRegistry
+│ └── types/ # replay.types.js
│
-├── utils/ # [유틸리티]
-│ ├── format.js
-│ ├── coordinate.js
-│ └── ...
-│
-├── types/ # [타입 정의] (TS 도입 시)
-│ └── ...
-│
-├── assets/ # [정적 자원]
-│ ├── images/
-│ └── ...
-│
-└── scss/ # [스타일]
- ├── base/
- ├── components/
- └── pages/
+├── hooks/ # useShipData, useShipLayer, useShipSearch, useTrackingMode, useRadiusFilter
+├── api/ # signalApi
+├── common/ # stompClient (STOMP WebSocket)
+├── types/ # constants.js (신호원/선종/플래그 상수)
+├── assets/ # 이미지, 아이콘 아틀라스
+└── scss/ # 스타일
```
---
@@ -147,31 +131,66 @@ src/
## 기능 구현 로드맵
-### Phase 1: 기반 구축
-- [ ] 프로젝트 구조 재편 (퍼블리시/구현 분리)
-- [ ] OpenLayers 연동 (베이스맵)
-- [ ] Zustand 스토어 설정
-- [ ] 기본 레이아웃 구현
+### Phase 1: 기반 구축 (01-26 완료)
+- [x] CRA → Vite 마이그레이션
+- [x] 프로젝트 구조 재편 (퍼블리시/구현 분리)
+- [x] Zustand 스토어 설정 (mapStore, shipStore)
+- [x] 기본 레이아웃 구현 (MainLayout, Header, Sidebar, ToolBar)
-### Phase 2: 지도 핵심 기능
-- [ ] 지도 컨트롤 (줌, 패닝)
-- [ ] 레이어 관리 (토글, 순서)
-- [ ] 좌표 표시
-- [ ] 축척 표시
+### Phase 2: 지도 및 선박 표시 (01-26 완료)
+- [x] OpenLayers 맵 컨테이너 + 베이스맵 레이어
+- [x] STOMP WebSocket 클라이언트
+- [x] Deck.gl 선박 아이콘 레이어 (IconLayer + 아틀라스)
+- [x] 선박 범례 컴포넌트 (ShipLegend)
-### Phase 3: 선박 표시
-- [ ] Deck.gl 연동
-- [ ] 선박 아이콘 렌더링
-- [ ] 선박 클릭/호버 이벤트
-- [ ] 선박 정보 팝업
+### Phase 3: 선박 레이어 고도화 (01-27 완료)
+- [x] 신호상태(AVETDR) 아이콘, 속도벡터 좌표 투영
+- [x] 배치 렌더러 최적화 (ShipBatchRenderer)
+- [x] 선명표시 클러스터링, 선박통합 ON/OFF
-### Phase 4: 데이터 연동
-- [ ] API 클라이언트 설정
-- [ ] 선박 데이터 조회
-- [ ] 필터링 기능
+### Phase 4: shipStore 성능 최적화 + 카운트 동기화 (01-28~30 완료)
+- [x] Map/Set mutable update + 버전 카운터 패턴
+- [x] 카운트 5초 쓰로틀 + targetId 중복 제거
+- [x] 레이더 카운트 제외 (통합 여부 무관)
+- [x] Ctrl+Drag 박스 선택 + 우클릭 컨텍스트 메뉴
+- [x] 통합모드 전환 시 selectedShipIds 동기화
-### Phase 5: 추가 기능
-- [ ] 항적 조회
+### Phase 5: 항적조회 + 버그 수정 (02-02~03 완료)
+- [x] tracking 패키지 TS→JS 변환 (13개 파일)
+- [x] 모달 항적조회 + 우클릭 항적조회
+- [x] 라이브 연결선 (PathStyleExtension dash + 1초 인터벌)
+- [x] 레이더 타겟 통합 표시 버그 수정 (integrate 플래그 기반)
+
+### Phase 6: 리플레이 기능 (02-03 완료)
+- [x] replay 패키지 TS→JS 변환 (stores, components, hooks, services, utils, types)
+- [x] WebSocket 기반 청크 데이터 수신 (ReplayWebSocketService)
+- [x] 시간 기반 애니메이션 (재생/일시정지/정지, 배속 1x~1000x)
+- [x] 프로그레스 바 슬라이더 (CSS 변수 방식, thumb/progress/배경 동기화)
+- [x] 항적 표시 토글 (playbackTrailStore - 프레임 기반 페이딩)
+- [x] 선박 상태 관리 (기본/선택/삭제) + 필터링 (선종, 신호원)
+- [x] 드래그 가능한 타임라인 컨트롤러
+- [x] 닫기 시 초기화 (라이브 선박 복원)
+
+### Phase 6.5: TopBar 및 추적 모드 (02-04 완료)
+- [x] TopBar 컴포넌트 (좌표 표시, 시간 표시, 선박 검색)
+- [x] 좌표/시간 포맷 설정 드롭다운 (도분초/도, KST/UTC)
+- [x] 선박 검색 기능 (like 검색, 디바운싱, 통합모드 지원)
+- [x] 추적 모드 구현 (trackingModeStore)
+ - 지도 모드 / 선박 모드 전환 (라디오 버튼)
+ - 경비함정 선택 드롭다운 (PatrolShipSelector)
+ - 반경 설정 (10/25/50/100/200 NM)
+- [x] 반경 필터링 (useRadiusFilter, ShipBatchRenderer)
+ - Bounding Box 사전 필터 + Haversine 거리 계산
+ - 렌더링, 카운트, 검색 결과 모두 반경 내 선박만 표시
+- [x] 추적 함정 지도 따라가기 (useTrackingMode)
+ - 반경 원 표시 (OpenLayers VectorLayer)
+ - 지도 중심 자동 이동
+- [x] 추적 함정 아이콘 강조 (ScatterplotLayer - 시안색 마커)
+- [x] 컨텍스트 메뉴 반경설정 (경비함정 우클릭 → 반경설정 서브메뉴)
+
+### Phase 7: 미구현 (예정)
+- [ ] 동적 우선순위 (Dynamic Priority) - 계획 수립 완료 (`.claude/plans/`)
+- [ ] 퍼블리시 → 구현 전환 (Panel1~8, 기능 페이지)
- [ ] 기상 레이어
- [ ] 관심영역 설정
- [ ] 위성 영상
@@ -199,14 +218,21 @@ src/
## 개발 명령어
```bash
-# 개발 서버 실행
-npm start
+# 개발 서버 실행 (Vite, http://localhost:3000)
+npm run dev
# 프로덕션 빌드
npm run build
-# 테스트
-npm test
+# 환경별 빌드
+npm run build:dev
+npm run build:prod
+
+# 빌드 미리보기
+npm run preview
+
+# 린트
+npm run lint
```
---
@@ -222,4 +248,9 @@ npm test
| 날짜 | 내용 |
|------|------|
-| 2026-01-26 | 초기 프로젝트 분석 및 워크플로우 정의 |
+| 2026-02-04 | Phase 6.5 완료 (TopBar, 추적 모드, 반경 필터링) |
+| 2026-02-03 | Phase 6 완료 (리플레이 기능 구현) |
+| 2026-02-03 | Phase 5 완료 (항적조회, 레이더 통합 버그 수정), CLAUDE.md 현황 동기화 |
+| 2026-01-30 | Phase 4 완료 (shipStore 성능 최적화, 카운트 동기화) |
+| 2026-01-27 | Phase 2~3 완료 (지도, 선박 레이어 고도화) |
+| 2026-01-26 | 초기 프로젝트 분석 및 워크플로우 정의, Phase 1 완료 |
diff --git a/src/components/map/PatrolShipSelector.jsx b/src/components/map/PatrolShipSelector.jsx
new file mode 100644
index 00000000..ec804bb5
--- /dev/null
+++ b/src/components/map/PatrolShipSelector.jsx
@@ -0,0 +1,203 @@
+/**
+ * 경비함정 선택 드롭다운
+ * 선박 모드에서 추적할 함정을 선택
+ * - 검색 기능 (like 검색)
+ * - 반경 설정
+ */
+import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
+import useShipStore from '../../stores/shipStore';
+import useTrackingModeStore, { isPatrolShip, RADIUS_OPTIONS } from '../../stores/trackingModeStore';
+import './PatrolShipSelector.scss';
+
+/**
+ * 검색어 정규화 (공백/특수문자 제거, 소문자 변환)
+ */
+function normalizeText(text) {
+ if (!text) return '';
+ return text.toLowerCase().replace(/[\s\-_.,:;!@#$%^&*()+=\[\]{}|\\/<>?'"]/g, '');
+}
+
+export default function PatrolShipSelector() {
+ const features = useShipStore((s) => s.features);
+ const showShipSelector = useTrackingModeStore((s) => s.showShipSelector);
+ const closeShipSelector = useTrackingModeStore((s) => s.closeShipSelector);
+ const selectTrackedShip = useTrackingModeStore((s) => s.selectTrackedShip);
+ const setMapMode = useTrackingModeStore((s) => s.setMapMode);
+ const setRadius = useTrackingModeStore((s) => s.setRadius);
+ const currentRadius = useTrackingModeStore((s) => s.radiusNM);
+
+ const [searchValue, setSearchValue] = useState('');
+ const [selectedRadius, setSelectedRadius] = useState(currentRadius);
+ const containerRef = useRef(null);
+ const searchInputRef = useRef(null);
+
+ // 패널 열릴 때 검색창 포커스
+ useEffect(() => {
+ if (showShipSelector && searchInputRef.current) {
+ searchInputRef.current.focus();
+ }
+ }, [showShipSelector]);
+
+ // 외부 클릭 시 닫기
+ useEffect(() => {
+ if (!showShipSelector) return;
+
+ const handleClickOutside = (e) => {
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
+ // 선박 버튼 클릭은 제외 (TopBar에서 처리)
+ if (e.target.closest('.ship')) return;
+ closeShipSelector();
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [showShipSelector, closeShipSelector]);
+
+ // 패널 닫힐 때 검색어 초기화
+ useEffect(() => {
+ if (!showShipSelector) {
+ setSearchValue('');
+ }
+ }, [showShipSelector]);
+
+ // 경비함정 목록 필터링
+ const patrolShips = useMemo(() => {
+ const ships = [];
+ features.forEach((ship, featureId) => {
+ if (isPatrolShip(ship.originalTargetId)) {
+ ships.push({
+ featureId,
+ ship,
+ shipName: ship.shipName || ship.originalTargetId || '-',
+ originalTargetId: ship.originalTargetId,
+ });
+ }
+ });
+
+ // 선박명 정렬
+ ships.sort((a, b) => a.shipName.localeCompare(b.shipName, 'ko'));
+ return ships;
+ }, [features]);
+
+ // 검색 필터링된 목록
+ const filteredShips = useMemo(() => {
+ const normalizedSearch = normalizeText(searchValue);
+ if (!normalizedSearch) return patrolShips;
+
+ return patrolShips.filter((item) => {
+ const normalizedName = normalizeText(item.shipName);
+ const normalizedId = normalizeText(item.originalTargetId);
+ return normalizedName.includes(normalizedSearch) || normalizedId.includes(normalizedSearch);
+ });
+ }, [patrolShips, searchValue]);
+
+ // 함정 선택 핸들러
+ const handleSelectShip = useCallback((item) => {
+ setRadius(selectedRadius);
+ selectTrackedShip(item.featureId, item.ship);
+ setSearchValue('');
+ }, [selectTrackedShip, setRadius, selectedRadius]);
+
+ // 취소 (지도 모드로 복귀)
+ const handleCancel = useCallback(() => {
+ setMapMode();
+ setSearchValue('');
+ }, [setMapMode]);
+
+ // 검색어 변경
+ const handleSearchChange = useCallback((e) => {
+ setSearchValue(e.target.value);
+ }, []);
+
+ // 검색어 초기화
+ const handleClearSearch = useCallback(() => {
+ setSearchValue('');
+ searchInputRef.current?.focus();
+ }, []);
+
+ // 반경 선택
+ const handleRadiusChange = useCallback((radius) => {
+ setSelectedRadius(radius);
+ }, []);
+
+ if (!showShipSelector) return null;
+
+ return (
+
+ {/* 헤더 */}
+
+ 경비함정 선택
+
+
+
+ {/* 검색 영역 */}
+
+
+
+ {searchValue && (
+
+ )}
+
+
+
+ {/* 반경 설정 */}
+
+
반경 설정
+
+ {RADIUS_OPTIONS.map((radius) => (
+
+ ))}
+ NM
+
+
+
+ {/* 함정 목록 */}
+
+ {filteredShips.length === 0 ? (
+
+ {searchValue ? '검색 결과가 없습니다' : '활성화된 경비함정이 없습니다'}
+
+ ) : (
+
+ {filteredShips.map((item) => (
+ - handleSelectShip(item)}
+ >
+ {item.shipName}
+ {item.originalTargetId}
+
+ ))}
+
+ )}
+
+
+ {/* 푸터 */}
+
+
+ {searchValue ? `${filteredShips.length} / ${patrolShips.length}척` : `${patrolShips.length}척`}
+
+
+
+ );
+}
diff --git a/src/components/map/PatrolShipSelector.scss b/src/components/map/PatrolShipSelector.scss
new file mode 100644
index 00000000..b0e0ccee
--- /dev/null
+++ b/src/components/map/PatrolShipSelector.scss
@@ -0,0 +1,258 @@
+/**
+ * 경비함정 선택 드롭다운 스타일
+ */
+.patrol-ship-selector {
+ position: absolute;
+ top: calc(100% + 0.5rem);
+ right: 0;
+ width: 32rem;
+ max-height: 50rem;
+ background-color: rgba(var(--secondary6-rgb), 0.95);
+ border-radius: 0.8rem;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
+ z-index: 200;
+ display: flex;
+ flex-direction: column;
+
+ // 헤더
+ .selector-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem 1.2rem;
+ border-bottom: 1px solid rgba(var(--white-rgb), 0.1);
+ flex-shrink: 0;
+
+ .selector-title {
+ color: var(--white);
+ font-size: 1.4rem;
+ font-weight: var(--fw-bold);
+ }
+
+ .close-btn {
+ width: 2.4rem;
+ height: 2.4rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ background: transparent;
+ color: rgba(var(--white-rgb), 0.6);
+ font-size: 2rem;
+ cursor: pointer;
+ border-radius: 0.4rem;
+ transition: all 0.15s ease;
+
+ &:hover {
+ color: var(--white);
+ background-color: rgba(var(--white-rgb), 0.1);
+ }
+ }
+ }
+
+ // 검색 영역
+ .selector-search {
+ padding: 0.8rem 1.2rem;
+ border-bottom: 1px solid rgba(var(--white-rgb), 0.1);
+ flex-shrink: 0;
+
+ .search-input-wrapper {
+ position: relative;
+ width: 100%;
+ }
+
+ .search-input {
+ width: 100%;
+ height: 3.2rem;
+ padding: 0 3rem 0 1rem;
+ border: 1px solid rgba(var(--white-rgb), 0.2);
+ border-radius: 0.4rem;
+ background-color: rgba(var(--white-rgb), 0.05);
+ color: var(--white);
+ font-size: 1.3rem;
+ outline: none;
+ transition: all 0.15s ease;
+
+ &::placeholder {
+ color: rgba(var(--white-rgb), 0.4);
+ }
+
+ &:focus {
+ border-color: var(--primary1);
+ background-color: rgba(var(--white-rgb), 0.1);
+ }
+ }
+
+ .search-clear-btn {
+ position: absolute;
+ top: 50%;
+ right: 0.6rem;
+ transform: translateY(-50%);
+ width: 2rem;
+ height: 2rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ background: transparent;
+ color: rgba(var(--white-rgb), 0.5);
+ font-size: 1.6rem;
+ cursor: pointer;
+ border-radius: 0.3rem;
+
+ &:hover {
+ color: var(--white);
+ background-color: rgba(var(--white-rgb), 0.1);
+ }
+ }
+ }
+
+ // 반경 설정
+ .selector-radius {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.8rem 1.2rem;
+ border-bottom: 1px solid rgba(var(--white-rgb), 0.1);
+ flex-shrink: 0;
+
+ .radius-label {
+ color: rgba(var(--white-rgb), 0.7);
+ font-size: 1.2rem;
+ flex-shrink: 0;
+ }
+
+ .radius-options {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ }
+
+ .radius-btn {
+ min-width: 3.6rem;
+ height: 2.6rem;
+ padding: 0 0.6rem;
+ border: 1px solid rgba(var(--white-rgb), 0.2);
+ border-radius: 0.4rem;
+ background-color: transparent;
+ color: rgba(var(--white-rgb), 0.7);
+ font-size: 1.2rem;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover {
+ border-color: rgba(var(--white-rgb), 0.4);
+ color: var(--white);
+ }
+
+ &.active {
+ border-color: var(--primary1);
+ background-color: var(--primary1);
+ color: var(--white);
+ }
+ }
+
+ .radius-unit {
+ color: rgba(var(--white-rgb), 0.5);
+ font-size: 1.1rem;
+ margin-left: 0.3rem;
+ }
+ }
+
+ // 함정 목록 영역
+ .selector-content {
+ flex: 1;
+ overflow-x: hidden;
+ overflow-y: auto;
+ min-height: 10rem;
+ max-height: 28rem;
+
+ // 스크롤바 스타일
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: rgba(var(--white-rgb), 0.3);
+ border-radius: 3px;
+
+ &:hover {
+ background: rgba(var(--white-rgb), 0.5);
+ }
+ }
+ }
+
+ .no-ships {
+ padding: 3rem 1.2rem;
+ text-align: center;
+ color: rgba(var(--white-rgb), 0.5);
+ font-size: 1.3rem;
+ }
+
+ .ship-list {
+ list-style: none;
+ padding: 0.5rem 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+
+ // TopBar li 스타일 상속 초기화
+ li {
+ height: auto;
+ padding: 0;
+ }
+ }
+
+ .ship-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.9rem 1.2rem !important; // TopBar li:first-child, li:last-child 오버라이드
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+ width: 100%;
+ box-sizing: border-box;
+ height: auto !important; // TopBar li height: 100% 오버라이드
+
+ &:hover {
+ background-color: rgba(var(--primary1-rgb), 0.3);
+ }
+
+ .ship-name {
+ color: var(--white);
+ font-size: 1.3rem;
+ font-weight: var(--fw-bold);
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .ship-id {
+ color: rgba(var(--white-rgb), 0.5);
+ font-size: 1.1rem;
+ margin-left: 1rem;
+ flex-shrink: 0;
+ }
+ }
+
+ // 푸터
+ .selector-footer {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding: 0.8rem 1.2rem;
+ border-top: 1px solid rgba(var(--white-rgb), 0.1);
+ flex-shrink: 0;
+
+ .ship-count {
+ color: rgba(var(--white-rgb), 0.6);
+ font-size: 1.2rem;
+ }
+ }
+}
diff --git a/src/components/map/TopBar.jsx b/src/components/map/TopBar.jsx
new file mode 100644
index 00000000..81147b94
--- /dev/null
+++ b/src/components/map/TopBar.jsx
@@ -0,0 +1,426 @@
+/**
+ * TopBar 컴포넌트
+ * 지도 상단 중앙에 배치되는 정보/검색 바
+ * - 마우스 커서 좌표 (경도/위도) - 도분초/도 선택 가능
+ * - 현재 시간 (KST/UTC 선택 가능)
+ * - 선박 검색 (통합모드 ON/OFF에 따른 검색 조건 분기)
+ */
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { toLonLat } from 'ol/proj';
+import { useMapStore } from '../../stores/mapStore';
+import useTrackingModeStore from '../../stores/trackingModeStore';
+import useShipSearch from '../../hooks/useShipSearch';
+import PatrolShipSelector from './PatrolShipSelector';
+import './TopBar.scss';
+
+/**
+ * 십진도를 도분초(DMS) 형식으로 변환
+ * @param {number} decimal - 십진도
+ * @param {boolean} isLongitude - 경도 여부 (false면 위도)
+ * @returns {{ degrees: number, minutes: number, seconds: string, direction: string }}
+ */
+function decimalToDMS(decimal, isLongitude) {
+ const absolute = Math.abs(decimal);
+ const degrees = Math.floor(absolute);
+ const minutesFloat = (absolute - degrees) * 60;
+ const minutes = Math.floor(minutesFloat);
+ const seconds = ((minutesFloat - minutes) * 60).toFixed(3);
+
+ let direction;
+ if (isLongitude) {
+ direction = decimal >= 0 ? 'E' : 'W';
+ } else {
+ direction = decimal >= 0 ? 'N' : 'S';
+ }
+
+ return { degrees, minutes, seconds, direction };
+}
+
+/**
+ * 십진도를 도(Decimal Degrees) 형식 문자열로 변환
+ * @param {number} decimal - 십진도
+ * @param {boolean} isLongitude - 경도 여부
+ * @returns {string}
+ */
+function formatDecimalDegrees(decimal, isLongitude) {
+ const direction = isLongitude
+ ? (decimal >= 0 ? 'E' : 'W')
+ : (decimal >= 0 ? 'N' : 'S');
+ return `${Math.abs(decimal).toFixed(6)}° ${direction}`;
+}
+
+/**
+ * 현재 시간을 KST 형식으로 포맷
+ * @param {Date} date
+ * @returns {{ dateStr: string, timeStr: string, dayOfWeek: string }}
+ */
+function formatKST(date) {
+ const days = ['일', '월', '화', '수', '목', '금', '토'];
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ const seconds = String(date.getSeconds()).padStart(2, '0');
+ const dayOfWeek = days[date.getDay()];
+
+ return {
+ dateStr: `${year}-${month}-${day}`,
+ timeStr: `${hours}:${minutes}:${seconds}`,
+ dayOfWeek,
+ };
+}
+
+/**
+ * 현재 시간을 UTC 형식으로 포맷
+ * @param {Date} date
+ * @returns {{ dateStr: string, timeStr: string, dayOfWeek: string }}
+ */
+function formatUTC(date) {
+ const days = ['일', '월', '화', '수', '목', '금', '토'];
+ const year = date.getUTCFullYear();
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
+ const day = String(date.getUTCDate()).padStart(2, '0');
+ const hours = String(date.getUTCHours()).padStart(2, '0');
+ const minutes = String(date.getUTCMinutes()).padStart(2, '0');
+ const seconds = String(date.getUTCSeconds()).padStart(2, '0');
+ const dayOfWeek = days[date.getUTCDay()];
+
+ return {
+ dateStr: `${year}-${month}-${day}`,
+ timeStr: `${hours}:${minutes}:${seconds}`,
+ dayOfWeek,
+ };
+}
+
+export default function TopBar() {
+ const map = useMapStore((s) => s.map);
+
+ // 추적 모드 상태
+ const mode = useTrackingModeStore((s) => s.mode);
+ const setMapMode = useTrackingModeStore((s) => s.setMapMode);
+ const setShipMode = useTrackingModeStore((s) => s.setShipMode);
+ const trackedShip = useTrackingModeStore((s) => s.trackedShip);
+ const radiusNM = useTrackingModeStore((s) => s.radiusNM);
+
+ // 마우스 좌표 상태
+ const [coordinates, setCoordinates] = useState({ lon: null, lat: null });
+
+ // 현재 시간 상태
+ const [currentTime, setCurrentTime] = useState(new Date());
+
+ // 설정 상태
+ const [coordFormat, setCoordFormat] = useState('dms'); // 'dms' | 'decimal'
+ const [timeFormat, setTimeFormat] = useState('kst'); // 'kst' | 'utc'
+ const [showSettings, setShowSettings] = useState(false);
+ const settingsRef = useRef(null);
+
+ // 검색 훅
+ const {
+ searchValue,
+ setSearchValue,
+ results,
+ handleClickResult,
+ handleSelectFirst,
+ clearSearch,
+ isIntegrate,
+ } = useShipSearch();
+
+ // 검색창 포커스 상태
+ const [isSearchFocused, setIsSearchFocused] = useState(false);
+ const searchContainerRef = useRef(null);
+
+ // 좌표 업데이트 쓰로틀 ref
+ const throttleRef = useRef(null);
+
+ // 마우스 이동 시 좌표 업데이트
+ useEffect(() => {
+ if (!map) return;
+
+ const handlePointerMove = (evt) => {
+ // 쓰로틀: 100ms
+ if (throttleRef.current) return;
+
+ throttleRef.current = setTimeout(() => {
+ throttleRef.current = null;
+ }, 100);
+
+ const pixel = evt.pixel;
+ const coord3857 = map.getCoordinateFromPixel(pixel);
+ if (coord3857) {
+ const [lon, lat] = toLonLat(coord3857);
+ setCoordinates({ lon, lat });
+ }
+ };
+
+ map.on('pointermove', handlePointerMove);
+
+ return () => {
+ map.un('pointermove', handlePointerMove);
+ if (throttleRef.current) {
+ clearTimeout(throttleRef.current);
+ throttleRef.current = null;
+ }
+ };
+ }, [map]);
+
+ // 1초마다 시간 업데이트
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setCurrentTime(new Date());
+ }, 1000);
+
+ return () => clearInterval(timer);
+ }, []);
+
+ // 검색창/설정 외부 클릭 시 닫기
+ useEffect(() => {
+ const handleClickOutside = (e) => {
+ if (searchContainerRef.current && !searchContainerRef.current.contains(e.target)) {
+ setIsSearchFocused(false);
+ }
+ if (settingsRef.current && !settingsRef.current.contains(e.target)) {
+ setShowSettings(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ // 검색어 변경 핸들러
+ const handleSearchChange = useCallback((e) => {
+ setSearchValue(e.target.value);
+ }, [setSearchValue]);
+
+ // 엔터키로 첫 번째 결과 선택
+ const handleKeyDown = useCallback((e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleSelectFirst();
+ } else if (e.key === 'Escape') {
+ clearSearch();
+ setIsSearchFocused(false);
+ }
+ }, [handleSelectFirst, clearSearch]);
+
+ // 검색 결과 클릭
+ const handleResultClick = useCallback((result) => {
+ handleClickResult(result);
+ setIsSearchFocused(false);
+ }, [handleClickResult]);
+
+ // 설정 토글
+ const toggleSettings = useCallback(() => {
+ setShowSettings((prev) => !prev);
+ }, []);
+
+ /**
+ * 문자열 길이 제한 (말줄임)
+ */
+ const truncateString = (str, maxLength = 20) => {
+ if (!str) return '-';
+ return str.length > maxLength ? str.slice(0, maxLength) + '...' : str;
+ };
+
+ // 좌표 포맷팅 (설정에 따라)
+ const renderCoordinate = (value, isLongitude) => {
+ if (value === null) return ---;
+
+ if (coordFormat === 'dms') {
+ const dms = decimalToDMS(value, isLongitude);
+ return (
+ <>
+ {dms.degrees}°
+ {dms.minutes}'
+ {dms.seconds}"
+ {dms.direction}
+ >
+ );
+ } else {
+ return {formatDecimalDegrees(value, isLongitude)};
+ }
+ };
+
+ // 시간 포맷팅 (설정에 따라)
+ const timeData = timeFormat === 'kst' ? formatKST(currentTime) : formatUTC(currentTime);
+ const timeLabel = timeFormat === 'kst' ? 'KST' : 'UTC';
+
+ return (
+
+
+
+ {/* 검색 박스 */}
+
+
+ setIsSearchFocused(true)}
+ />
+ {searchValue && (
+
+ )}
+
+
+ {/* 검색 결과 목록 */}
+ {isSearchFocused && searchValue && results.length > 0 && (
+
+ )}
+
+ {/* 검색 안내 / 결과 없음 */}
+ {isSearchFocused && searchValue && results.length === 0 && (
+
+ {searchValue.length < 2 ? (
+ '한글 2자 이상, 영문/숫자 3자 이상 입력'
+ ) : (
+ '검색 결과가 없습니다'
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/map/TopBar.scss b/src/components/map/TopBar.scss
new file mode 100644
index 00000000..dd1db6a5
--- /dev/null
+++ b/src/components/map/TopBar.scss
@@ -0,0 +1,476 @@
+/**
+ * TopBar 스타일
+ * 지도 상단 중앙 정보/검색 바
+ */
+
+.topBar {
+ position: absolute;
+ top: 1.5rem;
+ left: 0;
+ right: 0;
+ height: 4.4rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 5.4rem;
+ box-sizing: border-box;
+ z-index: 95;
+ pointer-events: none; // 빈 영역은 지도 이벤트 통과
+
+ > * {
+ pointer-events: auto; // 내부 요소는 클릭 가능
+ }
+}
+
+// 위치 정보 바
+.locationInfo {
+ flex: 0 0 auto;
+ min-width: 71rem;
+ height: 3.8rem;
+ margin: 0 auto;
+ display: flex;
+ align-items: center;
+ background-color: rgba(var(--secondary6-rgb), 0.9);
+ // overflow: hidden 제거 - 설정 드롭다운 표시를 위해
+
+ ul {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ height: 100%;
+
+ li {
+ display: flex;
+ align-items: center;
+ height: 100%;
+ padding: 0 1rem;
+ color: var(--white);
+ font-weight: var(--fw-bold);
+ font-size: 1.3rem;
+
+ &:first-child,
+ &:last-child {
+ padding: 0;
+ }
+
+ span + span {
+ padding-right: 0.5rem;
+ }
+
+ // 구분선 (좌표 표시)
+ &.divider {
+ position: relative;
+ width: 20rem; // 고정 너비로 아이콘 위치 고정
+ flex-shrink: 0;
+ font-variant-numeric: tabular-nums; // 숫자 고정폭
+ font-feature-settings: "tnum"; // 숫자 고정폭 (fallback)
+
+ &::after {
+ content: '';
+ position: absolute;
+ right: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 1px;
+ height: 1.8rem;
+ background-color: rgba(255, 255, 255, 0.3);
+ }
+ }
+
+ // WGS84 좌표 라벨
+ .wgs {
+ display: flex;
+ align-items: center;
+ padding-right: 0.5rem;
+
+ &::before {
+ content: '';
+ display: block;
+ width: 2rem;
+ height: 2rem;
+ margin-right: 0.5rem;
+ background: url(../../assets/images/ico_globe.svg) no-repeat center / 2rem;
+ }
+ }
+
+ // 시간 영역
+ &.time-section {
+ width: 22rem; // 고정 너비
+ flex-shrink: 0;
+ font-variant-numeric: tabular-nums;
+ font-feature-settings: "tnum";
+ }
+
+ // 시간 뱃지 (KST/UTC)
+ .time-badge {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 3.1rem;
+ height: 1.8rem;
+ margin-right: 0.5rem;
+ border-radius: 0.3rem;
+ color: var(--white);
+ font-size: var(--fs-s);
+ font-weight: var(--fw-bold);
+ flex-shrink: 0;
+
+ &.kst {
+ background-color: var(--tertiary3);
+ }
+
+ &.utc {
+ background-color: var(--primary1);
+ }
+ }
+
+ // 설정 컨테이너
+ &.settings-container {
+ position: relative;
+ padding: 0 0.5rem;
+ flex-shrink: 0;
+ }
+
+ // 첫번째(지도) 버튼 컨테이너
+ &:first-child {
+ flex-shrink: 0;
+ }
+
+ // 선박 모드 컨테이너
+ &.ship-mode-container {
+ position: relative;
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ padding: 0;
+ }
+
+ // 추적 중인 함정 정보
+ .tracked-ship-info {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0 1rem;
+ height: 100%;
+ background-color: rgba(var(--primary1-rgb), 0.3);
+ border-left: 1px solid rgba(var(--white-rgb), 0.2);
+
+ .tracked-name {
+ color: var(--white);
+ font-size: 1.2rem;
+ font-weight: var(--fw-bold);
+ max-width: 12rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .tracked-radius {
+ color: var(--tertiary3);
+ font-size: 1.1rem;
+ font-weight: var(--fw-bold);
+ padding: 0.2rem 0.5rem;
+ background-color: rgba(var(--tertiary3-rgb), 0.2);
+ border-radius: 0.3rem;
+ }
+ }
+ }
+ }
+
+ // 설정 드롭다운
+ .settings-dropdown {
+ position: absolute;
+ top: calc(100% + 0.5rem);
+ right: 0;
+ min-width: 16rem;
+ padding: 1rem;
+ background-color: rgba(var(--secondary6-rgb), 0.95);
+ border-radius: 0.8rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ z-index: 100;
+
+ .settings-group {
+ & + .settings-group {
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid rgba(var(--white-rgb), 0.1);
+ }
+ }
+
+ .settings-label {
+ color: rgba(var(--white-rgb), 0.7);
+ font-size: 1.1rem;
+ margin-bottom: 0.6rem;
+ }
+
+ .settings-options {
+ display: flex;
+ gap: 0.5rem;
+
+ button {
+ flex: 1;
+ padding: 0.6rem 1rem;
+ border: 1px solid rgba(var(--white-rgb), 0.2);
+ border-radius: 0.4rem;
+ background-color: transparent;
+ color: rgba(var(--white-rgb), 0.7);
+ font-size: 1.2rem;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ width: auto;
+ height: auto;
+
+ &:hover {
+ border-color: rgba(var(--white-rgb), 0.4);
+ color: var(--white);
+ background-color: transparent;
+ }
+
+ &.active {
+ border-color: var(--primary1);
+ background-color: var(--primary1);
+ color: var(--white);
+ }
+ }
+ }
+ }
+
+ // 버튼 공통
+ button {
+ width: 4rem;
+ height: 3.8rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ box-sizing: border-box;
+ background-color: var(--secondary3);
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: 3rem;
+ border: none;
+ transition: background-color 0.2s ease;
+
+ &:hover,
+ &.active {
+ background-color: rgba(var(--primary1-rgb), 0.8);
+ }
+
+ // 지도 모드 버튼
+ &.map {
+ background-image: url(../../assets/images/ico_map.svg);
+ }
+
+ // 선박 버튼
+ &.ship {
+ background-image: url(../../assets/images/ico_ship.svg);
+ }
+
+ // 설정 버튼
+ &.set {
+ width: 2rem;
+ height: 2rem;
+ background-color: transparent;
+ background-image: url(../../assets/images/ico_set_s.svg);
+ background-size: 2rem;
+
+ &:hover,
+ &.active {
+ background-color: transparent;
+ }
+ }
+ }
+}
+
+// 검색 박스
+.topSchBox {
+ position: absolute;
+ right: 5.4rem;
+ flex: 0 0 auto;
+ width: 26rem;
+ display: flex;
+ flex-direction: column;
+
+ // 입력창 래퍼
+ .search-input-wrapper {
+ position: relative;
+ width: 100%;
+ height: 3.8rem;
+ }
+
+ .tschInput {
+ width: 100%;
+ height: 100%;
+ padding: 0 4.4rem 0 1.2rem;
+ border-radius: 2rem;
+ background-color: rgba(var(--secondary6-rgb), 0.9);
+ border: 0;
+ color: var(--white);
+ font-size: var(--fs-m);
+
+ &::placeholder {
+ color: rgba(var(--white-rgb), 0.6);
+ }
+
+ &:focus {
+ outline: none;
+ background-color: rgba(var(--secondary6-rgb), 1);
+ }
+ }
+
+ // 검색어 지우기 버튼
+ .search-clear-btn {
+ position: absolute;
+ top: 50%;
+ right: 3rem;
+ transform: translateY(-50%);
+ width: 2rem;
+ height: 2rem;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: rgba(var(--white-rgb), 0.6);
+ font-size: 1.6rem;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:hover {
+ color: var(--white);
+ }
+ }
+
+ .mainSchBtn {
+ position: absolute;
+ top: 50%;
+ right: 0;
+ transform: translateY(-50%);
+ width: 3rem;
+ height: 3.8rem;
+ font-size: 0;
+ text-indent: -999999em;
+ border: none;
+ cursor: pointer;
+ background: url(../../assets/images/ico_search_main.svg) no-repeat center / 2rem;
+ background-color: transparent;
+
+ &:hover {
+ opacity: 0.8;
+ }
+ }
+
+ // 검색 결과 목록
+ .search-results {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ margin-top: 0.5rem;
+ padding: 0.5rem 0;
+ background-color: rgba(var(--secondary6-rgb), 0.95);
+ border-radius: 1rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ max-height: 30rem;
+ overflow-y: auto;
+ z-index: 100;
+ list-style: none;
+
+ // 스크롤바 스타일
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: rgba(var(--white-rgb), 0.3);
+ border-radius: 3px;
+ }
+ }
+
+ // 검색 결과 항목
+ .search-result-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.8rem 1.2rem;
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+
+ &:hover {
+ background-color: rgba(var(--primary1-rgb), 0.3);
+ }
+
+ .result-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ overflow: hidden;
+ }
+
+ .result-name {
+ color: var(--white);
+ font-size: 1.3rem;
+ font-weight: var(--fw-bold);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .result-id {
+ color: rgba(var(--white-rgb), 0.6);
+ font-size: 1.1rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .result-action {
+ flex-shrink: 0;
+ width: 2.4rem;
+ height: 2.4rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--tertiary3);
+
+ &:hover {
+ color: var(--white);
+ }
+ }
+ }
+
+ // 검색 결과 없음
+ .search-no-results {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ margin-top: 0.5rem;
+ padding: 1.2rem;
+ background-color: rgba(var(--secondary6-rgb), 0.95);
+ border-radius: 1rem;
+ color: rgba(var(--white-rgb), 0.6);
+ font-size: 1.2rem;
+ text-align: center;
+ z-index: 100;
+ }
+}
+
+// 접근성: 스크린리더 전용 텍스트
+.blind {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
diff --git a/src/components/ship/ShipContextMenu.jsx b/src/components/ship/ShipContextMenu.jsx
index 71ad5cd1..038d6cef 100644
--- a/src/components/ship/ShipContextMenu.jsx
+++ b/src/components/ship/ShipContextMenu.jsx
@@ -3,20 +3,39 @@
* - 단일 선박 우클릭: 해당 선박 메뉴
* - Ctrl+Drag 선택 후 우클릭: 선택된 선박 전체 메뉴
*/
-import { useEffect, useRef, useCallback } from 'react';
+import { useState, useEffect, useRef, useCallback } from 'react';
import useShipStore from '../../stores/shipStore';
+import { useTrackQueryStore } from '../../tracking/stores/trackQueryStore';
+import useTrackingModeStore, { RADIUS_OPTIONS, isPatrolShip } from '../../stores/trackingModeStore';
+import {
+ fetchVesselTracksV2,
+ convertToProcessedTracks,
+ buildVesselListForQuery,
+ deduplicateVessels,
+} from '../../tracking/services/trackQueryApi';
import './ShipContextMenu.scss';
+/** KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC이므로 사용하지 않음) */
+function toKstISOString(date) {
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
+}
+
const MENU_ITEMS = [
{ key: 'track', label: '항적조회' },
{ key: 'analysis', label: '항적분석' },
{ key: 'detail', label: '상세정보' },
+ { key: 'radius', label: '반경설정', hasSubmenu: true },
];
export default function ShipContextMenu() {
const contextMenu = useShipStore((s) => s.contextMenu);
const closeContextMenu = useShipStore((s) => s.closeContextMenu);
+ const setRadius = useTrackingModeStore((s) => s.setRadius);
+ const selectTrackedShip = useTrackingModeStore((s) => s.selectTrackedShip);
+ const currentRadius = useTrackingModeStore((s) => s.radiusNM);
const menuRef = useRef(null);
+ const [hoveredItem, setHoveredItem] = useState(null);
// 외부 클릭 시 닫기
useEffect(() => {
@@ -32,31 +51,156 @@ export default function ShipContextMenu() {
return () => document.removeEventListener('mousedown', handleClick);
}, [contextMenu, closeContextMenu]);
- // 메뉴 항목 클릭
- const handleAction = useCallback((key) => {
+ // 반경 선택 핸들러
+ const handleRadiusSelect = useCallback((radius) => {
if (!contextMenu) return;
const { ships } = contextMenu;
- // TODO: 향후 API 연결
- console.log(`[ContextMenu] action=${key}, ships=`, ships.map((s) => ({
- featureId: s.featureId,
- shipName: s.shipName,
- targetId: s.targetId,
- })));
+ // 단일 경비함정인 경우 해당 함정을 추적 대상으로 설정
+ if (ships.length === 1) {
+ const ship = ships[0];
+ setRadius(radius);
+ selectTrackedShip(ship.featureId, ship);
+ }
+ closeContextMenu();
+ }, [contextMenu, setRadius, selectTrackedShip, closeContextMenu]);
+
+ // 메뉴 항목 클릭
+ const handleAction = useCallback(async (key) => {
+ if (!contextMenu) return;
+ const { ships } = contextMenu;
+
+ // 반경설정은 서브메뉴에서 처리
+ if (key === 'radius') return;
closeContextMenu();
+
+ switch (key) {
+ case 'track': {
+ const store = useTrackQueryStore.getState();
+ store.reset();
+
+ const endTime = new Date();
+ const startTime = new Date(endTime.getTime() - 3 * 24 * 60 * 60 * 1000); // 3일
+
+ const { isIntegrate, features } = useShipStore.getState();
+
+ const allVessels = [];
+ const errors = [];
+ ships.forEach(ship => {
+ const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features);
+ if (result.canQuery) allVessels.push(...result.vessels);
+ else if (result.errorMessage) errors.push(result.errorMessage);
+ });
+
+ // (sigSrcCd, targetId) 중복 제거
+ const uniqueVessels = deduplicateVessels(allVessels);
+
+ if (uniqueVessels.length === 0) {
+ store.setError(errors[0] || '조회 가능한 선박이 없습니다.');
+ return;
+ }
+
+ store.setModalMode(false, null);
+ store.setLoading(true);
+ try {
+ const rawTracks = await fetchVesselTracksV2({
+ startTime: toKstISOString(startTime),
+ endTime: toKstISOString(endTime),
+ vessels: uniqueVessels,
+ isIntegration: isIntegrate ? '1' : '0',
+ });
+ const processed = convertToProcessedTracks(rawTracks);
+ if (processed.length === 0) {
+ store.setError('항적 데이터가 없습니다.');
+ } else {
+ store.setTracks(processed, startTime.getTime());
+ }
+ } catch (e) {
+ console.error('[ShipContextMenu] 항적 조회 실패:', e);
+ store.setError('항적 조회 실패');
+ }
+ store.setLoading(false);
+ break;
+ }
+ case 'analysis': {
+ // 항적분석: 동일한 조회 후 showPlayback 활성화
+ const store = useTrackQueryStore.getState();
+ store.reset();
+
+ const endTime = new Date();
+ const startTime = new Date(endTime.getTime() - 3 * 24 * 60 * 60 * 1000); // 3일
+
+ const { isIntegrate, features } = useShipStore.getState();
+
+ const allVessels = [];
+ ships.forEach(ship => {
+ const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features);
+ if (result.canQuery) allVessels.push(...result.vessels);
+ });
+
+ // (sigSrcCd, targetId) 중복 제거
+ const uniqueVessels = deduplicateVessels(allVessels);
+
+ if (uniqueVessels.length === 0) return;
+
+ store.setModalMode(false, null);
+ store.setLoading(true);
+ try {
+ const rawTracks = await fetchVesselTracksV2({
+ startTime: toKstISOString(startTime),
+ endTime: toKstISOString(endTime),
+ vessels: uniqueVessels,
+ isIntegration: isIntegrate ? '1' : '0',
+ });
+ const processed = convertToProcessedTracks(rawTracks);
+ if (processed.length > 0) {
+ store.setTracks(processed, startTime.getTime());
+ // showPlayback 활성화 (재생 컨트롤 표시)
+ useTrackQueryStore.setState({ showPlayback: true });
+ }
+ } catch (e) {
+ console.error('[ShipContextMenu] 항적분석 조회 실패:', e);
+ }
+ store.setLoading(false);
+ break;
+ }
+ case 'detail':
+ if (ships.length === 1) {
+ useShipStore.getState().openDetailModal(ships[0]);
+ }
+ break;
+ default:
+ console.log(`[ContextMenu] action=${key}, ships=`, ships.map((s) => ({
+ featureId: s.featureId,
+ shipName: s.shipName,
+ targetId: s.targetId,
+ })));
+ }
}, [contextMenu, closeContextMenu]);
if (!contextMenu) return null;
const { x, y, ships } = contextMenu;
+ // 단일 경비함정인지 확인 (반경설정 메뉴 표시 조건)
+ const isSinglePatrolShip = ships.length === 1 && isPatrolShip(ships[0].originalTargetId);
+
+ // 표시할 메뉴 항목 필터링
+ const visibleMenuItems = MENU_ITEMS.filter((item) => {
+ if (item.key === 'radius') return isSinglePatrolShip;
+ return true;
+ });
+
// 화면 밖 넘침 방지
const menuWidth = 160;
- const menuHeight = MENU_ITEMS.length * 36 + 40; // 항목 + 헤더
+ const menuHeight = visibleMenuItems.length * 36 + 40; // 항목 + 헤더
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
const adjustedY = y + menuHeight > window.innerHeight ? y - menuHeight : y;
+ // 서브메뉴 위치 (오른쪽 또는 왼쪽)
+ const submenuOnLeft = adjustedX + menuWidth + 120 > window.innerWidth;
+
const title = ships.length === 1
? (ships[0].shipName || ships[0].featureId)
: `${ships.length}척 선택`;
@@ -68,13 +212,37 @@ export default function ShipContextMenu() {
style={{ left: adjustedX, top: adjustedY }}
>
{title}
- {MENU_ITEMS.map((item) => (
+ {visibleMenuItems.map((item, index) => (
handleAction(item.key)}
+ onMouseEnter={() => setHoveredItem(item.key)}
+ onMouseLeave={() => setHoveredItem(null)}
>
{item.label}
+ {item.hasSubmenu &&
▶}
+
+ {/* 반경설정 서브메뉴 */}
+ {item.key === 'radius' && hoveredItem === 'radius' && (
+
+ {RADIUS_OPTIONS.map((radius) => (
+
{
+ e.stopPropagation();
+ handleRadiusSelect(radius);
+ }}
+ >
+ {radius} NM
+
+ ))}
+
+ )}
))}
diff --git a/src/components/ship/ShipContextMenu.scss b/src/components/ship/ShipContextMenu.scss
index cd648cab..e60b1ce2 100644
--- a/src/components/ship/ShipContextMenu.scss
+++ b/src/components/ship/ShipContextMenu.scss
@@ -20,15 +20,63 @@
}
&__item {
+ position: relative;
padding: 8px 12px;
font-size: 13px;
color: #ddd;
cursor: pointer;
transition: background-color 0.15s;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
&:hover {
background: #2a2a4e;
color: #fff;
}
+
+ &.has-submenu {
+ padding-right: 24px;
+ }
+
+ &.active {
+ background: rgba(0, 150, 255, 0.3);
+ color: #fff;
+
+ &::before {
+ content: '✓';
+ position: absolute;
+ left: 4px;
+ font-size: 10px;
+ color: #00a8ff;
+ }
+ }
+
+ .submenu-arrow {
+ font-size: 10px;
+ color: #888;
+ margin-left: 8px;
+ }
+ }
+
+ &__submenu {
+ position: absolute;
+ top: 0;
+ background: #1a1a2e;
+ border: 1px solid #3a3a5e;
+ border-radius: 4px;
+ min-width: 100px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
+ z-index: 1001;
+
+ &.right {
+ left: 100%;
+ margin-left: 2px;
+ }
+
+ &.left {
+ right: 100%;
+ margin-right: 2px;
+ }
}
}
diff --git a/src/components/ship/ShipLegend.jsx b/src/components/ship/ShipLegend.jsx
index 5601f92b..1bb15649 100644
--- a/src/components/ship/ShipLegend.jsx
+++ b/src/components/ship/ShipLegend.jsx
@@ -5,6 +5,7 @@
* - 선박 종류별 필터 토글
*/
import { memo } from 'react';
+import { shallow } from 'zustand/shallow';
import useShipStore from '../../stores/shipStore';
import {
SIGNAL_KIND_CODE_FISHING,
@@ -84,15 +85,21 @@ const LegendItem = memo(({ code, label, count, icon, isVisible, onToggle }) => {
* 선박 범례 컴포넌트
*/
const ShipLegend = memo(() => {
- const {
- kindCounts,
- kindVisibility,
- isShipVisible,
- totalCount,
- isConnected,
- toggleKindVisibility,
- toggleShipVisible,
- } = useShipStore();
+ // 셀렉터 사용: 구독 중인 값이 실제로 바뀔 때만 리렌더
+ // useShipStore() 전체 구독 → featuresVersion 변경마다 리렌더되는 문제 방지
+ const { kindCounts, kindVisibility, isShipVisible, totalCount, isConnected } =
+ useShipStore(
+ (state) => ({
+ kindCounts: state.kindCounts,
+ kindVisibility: state.kindVisibility,
+ isShipVisible: state.isShipVisible,
+ totalCount: state.totalCount,
+ isConnected: state.isConnected,
+ }),
+ shallow
+ );
+ const toggleKindVisibility = useShipStore((state) => state.toggleKindVisibility);
+ const toggleShipVisible = useShipStore((state) => state.toggleShipVisible);
return (
diff --git a/src/hooks/useShipLayer.js b/src/hooks/useShipLayer.js
index 042306ba..da2679db 100644
--- a/src/hooks/useShipLayer.js
+++ b/src/hooks/useShipLayer.js
@@ -3,6 +3,7 @@
* - OpenLayers 맵과 Deck.gl 레이어 통합
* - 배치 렌더러 기반 최적화된 렌더링
* - 선박 데이터 변경 시 레이어 업데이트
+ * - 항적 레이어: 정적(경로/포인트) 캐싱 + 동적(가상선박) 경량 갱신
*
* 참조: mda-react-front/src/common/deck.ts
*/
@@ -11,6 +12,10 @@ import { Deck } from '@deck.gl/core';
import { toLonLat } from 'ol/proj';
import { createShipLayers, clearClusterCache } from '../map/layers/shipLayer';
import useShipStore from '../stores/shipStore';
+import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
+import useTrackingModeStore from '../stores/trackingModeStore';
+import { getTrackQueryLayers } from '../tracking/utils/trackQueryLayerUtils';
+import { getReplayLayers } from '../replay/utils/replayLayerRegistry';
import { shipBatchRenderer } from '../map/ShipBatchRenderer';
/**
@@ -27,13 +32,15 @@ export default function useShipLayer(map) {
const getSelectedShips = useShipStore((s) => s.getSelectedShips);
const isShipVisible = useShipStore((s) => s.isShipVisible);
+ // 마지막 선박 레이어: 캐시용
+ const lastShipLayersRef = useRef([]);
+
/**
* Deck.gl 인스턴스 초기화
*/
const initDeck = useCallback((container) => {
if (deckRef.current) return;
- // Canvas 엘리먼트 생성
const canvas = document.createElement('canvas');
canvas.id = 'deck-canvas';
canvas.style.position = 'absolute';
@@ -46,7 +53,6 @@ export default function useShipLayer(map) {
container.appendChild(canvas);
canvasRef.current = canvas;
- // Deck.gl 인스턴스 생성
deckRef.current = new Deck({
canvas,
controller: false,
@@ -77,7 +83,7 @@ export default function useShipLayer(map) {
viewState: {
longitude: lon,
latitude: lat,
- zoom: zoom - 1, // OpenLayers와 Deck.gl 줌 레벨 차이 보정
+ zoom: zoom - 1,
bearing: (-rotation * 180) / Math.PI,
pitch: 0,
},
@@ -95,7 +101,6 @@ export default function useShipLayer(map) {
if (!size) return null;
const extent = view.calculateExtent(size);
- // OpenLayers 좌표를 경위도로 변환
const [minX, minY, maxX, maxY] = extent;
const [minLon, minLat] = toLonLat([minX, minY]);
const [maxLon, maxLat] = toLonLat([maxX, maxY]);
@@ -104,9 +109,7 @@ export default function useShipLayer(map) {
}, [map]);
/**
- * 배치 렌더러 콜백 - 필터링된 선박으로 레이어 업데이트
- * @param {Array} ships - 밀도 제한 적용된 선박 (아이콘 + 라벨 공통)
- * @param {number} trigger - 렌더링 트리거
+ * 배치 렌더러 콜백 - 선박 레이어 생성 + 캐싱된 항적 레이어 병합
*/
const handleBatchRender = useCallback((ships, trigger) => {
if (!deckRef.current || !map) return;
@@ -115,15 +118,29 @@ export default function useShipLayer(map) {
const zoom = view.getZoom() || 7;
const selectedShips = getSelectedShips();
- // 현재 스토어에서 showLabels, labelOptions, isIntegrate, darkSignalIds 가져오기
const { showLabels: currentShowLabels, labelOptions: currentLabelOptions, isIntegrate: currentIsIntegrate, darkSignalIds } = useShipStore.getState();
- // 레이어 생성 (밀도 제한 적용된 선박 = 아이콘 + 라벨 공통)
- // 아이콘이 표시되는 선박에만 라벨/신호상태도 표시
- const layers = createShipLayers(ships, selectedShips, zoom, currentShowLabels, currentLabelOptions, currentIsIntegrate, trigger, darkSignalIds);
+ // 라이브 선박 숨김 상태 확인
+ const { hideLiveShips } = useTrackQueryStore.getState();
- // Deck.gl 레이어 업데이트
- deckRef.current.setProps({ layers });
+ // 선박 레이어 생성 (hideLiveShips일 때는 빈 배열)
+ const shipLayers = hideLiveShips
+ ? []
+ : createShipLayers(ships, selectedShips, zoom, currentShowLabels, currentLabelOptions, currentIsIntegrate, trigger, darkSignalIds);
+
+ // 선박 레이어 캐시
+ lastShipLayersRef.current = shipLayers;
+
+ // 항적 레이어 (tracking 패키지 전역 레지스트리에서 가져옴)
+ const trackLayers = getTrackQueryLayers();
+
+ // 리플레이 레이어 (전역 레지스트리)
+ const replayLayers = getReplayLayers();
+
+ // 병합: 선박 + 항적 + 리플레이 레이어
+ deckRef.current.setProps({
+ layers: [...shipLayers, ...trackLayers, ...replayLayers],
+ });
}, [map, getSelectedShips]);
/**
@@ -133,28 +150,23 @@ export default function useShipLayer(map) {
if (!deckRef.current || !map) return;
if (!isShipVisible) {
- // 선박 표시 Off
deckRef.current.setProps({ layers: [] });
return;
}
- // 줌 레벨 업데이트 (렌더링 간격 조정용)
const view = map.getView();
const zoom = view.getZoom() || 10;
const zoomIntChanged = shipBatchRenderer.setZoom(zoom);
- // 뷰포트 범위 업데이트
const bounds = getViewportBounds();
shipBatchRenderer.setViewportBounds(bounds);
- // 줌 정수 레벨이 변경되면 클러스터 캐시 클리어 + 즉시 렌더링
if (zoomIntChanged) {
clearClusterCache();
shipBatchRenderer.immediateRender();
return;
}
- // 배치 렌더러에 렌더링 요청
shipBatchRenderer.requestRender();
}, [map, isShipVisible, getViewportBounds]);
@@ -171,21 +183,15 @@ export default function useShipLayer(map) {
useEffect(() => {
if (!map) return;
- // 맵 컨테이너에 Deck.gl 캔버스 추가
const viewport = map.getViewport();
initDeck(viewport);
- // 배치 렌더러 초기화 (1회만)
if (!batchRendererInitialized.current) {
shipBatchRenderer.initialize(handleBatchRender);
batchRendererInitialized.current = true;
}
- // 맵 이동/줌 시 동기화
- const handleMoveEnd = () => {
- render();
- };
-
+ const handleMoveEnd = () => { render(); };
const handlePostRender = () => {
syncViewState();
deckRef.current?.redraw();
@@ -194,12 +200,8 @@ export default function useShipLayer(map) {
map.on('moveend', handleMoveEnd);
map.on('postrender', handlePostRender);
- // 초기 렌더링
- setTimeout(() => {
- render();
- }, 100);
+ setTimeout(() => { render(); }, 100);
- // 클린업
return () => {
map.un('moveend', handleMoveEnd);
map.un('postrender', handlePostRender);
@@ -218,20 +220,16 @@ export default function useShipLayer(map) {
canvasRef.current = null;
}
- // 배치 렌더러 정리
shipBatchRenderer.dispose();
batchRendererInitialized.current = false;
};
}, [map, initDeck, render, syncViewState, handleBatchRender]);
// 선박 데이터 변경 시 레이어 업데이트
- // ※ immutable 패턴: features/darkSignalIds 참조로 변경 감지
useEffect(() => {
- // 스토어 구독하여 변경 감지
const unsubscribe = useShipStore.subscribe(
(state) => [state.features, state.kindVisibility, state.sourceVisibility, state.isShipVisible, state.detailModals, state.isIntegrate, state.nationalVisibility, state.showLabels, state.labelOptions, state.darkSignalVisible, state.darkSignalIds],
(current, prev) => {
- // 필터 변경 감지 (kindVisibility, sourceVisibility, isShipVisible, isIntegrate, nationalVisibility, showLabels, labelOptions, darkSignalVisible, darkSignalIds)
const filterChanged =
current[1] !== prev[1] ||
current[2] !== prev[2] ||
@@ -244,27 +242,56 @@ export default function useShipLayer(map) {
current[10] !== prev[10];
if (filterChanged) {
- // 필터/선명표시 변경 시 즉시 렌더링 (사용자 인터랙션 응답성)
shipBatchRenderer.clearCache();
clearClusterCache();
shipBatchRenderer.immediateRender();
- return; // 즉시 렌더링 후 추가 처리 불필요
+ return;
}
- // 데이터 변경 시 배치 렌더러에 렌더링 요청만 전달 (적응형 주기 적용)
- // ※ redraw() 호출 제거: 배치 렌더러가 executeRender → setProps({ layers }) 시
- // deck.gl이 자동으로 repaint하므로 별도 redraw() 불필요.
- // 매 features 참조 변경(~1초)마다 redraw() 호출 시 불필요한 repaint 발생.
updateLayers();
},
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
);
- return () => {
- unsubscribe();
- };
+ return () => { unsubscribe(); };
}, [updateLayers]);
+ // === trackQueryStore 변경 시 선박 레이어 리렌더 ===
+ // tracking 패키지의 TrackQueryViewer가 레이어를 전역 레지스트리에 등록하면
+ // 여기서 shipBatchRenderer를 트리거하여 deck.gl에 반영
+ useEffect(() => {
+ const unsubscribe = useTrackQueryStore.subscribe(
+ (state) => [state.tracks, state.currentTime, state.showPoints, state.showVirtualShip, state.showLabels, state.disabledVesselIds, state.hideLiveShips],
+ () => {
+ if (deckRef.current && map) {
+ shipBatchRenderer.requestRender();
+ }
+ },
+ { equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
+ );
+
+ return () => unsubscribe();
+ }, [map]);
+
+ // === trackingModeStore 변경 시 선박 레이어 리렌더 ===
+ // 반경 필터링 상태가 변경되면 즉시 렌더링
+ useEffect(() => {
+ const unsubscribe = useTrackingModeStore.subscribe(
+ (state) => [state.mode, state.trackedShipId, state.trackedShip, state.radiusNM],
+ () => {
+ if (deckRef.current && map) {
+ // 반경/추적 모드 변경은 필터 변경이므로 캐시 클리어 후 즉시 렌더링
+ shipBatchRenderer.clearCache();
+ clearClusterCache();
+ shipBatchRenderer.immediateRender();
+ }
+ },
+ { equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
+ );
+
+ return () => unsubscribe();
+ }, [map]);
+
return {
deckCanvas: canvasRef.current,
deckRef,
diff --git a/src/hooks/useShipSearch.js b/src/hooks/useShipSearch.js
new file mode 100644
index 00000000..c769ad21
--- /dev/null
+++ b/src/hooks/useShipSearch.js
@@ -0,0 +1,318 @@
+/**
+ * 선박 검색 훅
+ * 참조: mda-react-front/src/hooks/useBottomSearch.ts
+ *
+ * 검색 조건:
+ * - 통합모드 ON: targetId, shipName에서 검색
+ * - 통합모드 OFF: originalTargetId (레이더 제외), shipName에서 검색
+ * - 공백/특수문자 제거, 대소문자 무시
+ * - 최대 10개 결과 반환
+ *
+ * 성능 최적화:
+ * - 한글: 최소 2자, shipName만 검색
+ * - 영문/숫자: 최소 3자
+ * - 디바운싱 (200ms)
+ * - 조기 종료 (결과 10개 도달 시)
+ */
+import { useState, useCallback, useRef, useEffect } from 'react';
+import { fromLonLat } from 'ol/proj';
+import useShipStore from '../stores/shipStore';
+import { useMapStore } from '../stores/mapStore';
+import useTrackingModeStore, { isWithinRadius, NM_TO_METERS } from '../stores/trackingModeStore';
+
+// 레이더 신호원 코드
+const SIGNAL_SOURCE_CODE_RADAR = '000005';
+
+// 최대 검색 결과 수
+const MAX_RESULTS = 10;
+
+// 디바운스 시간 (ms)
+const DEBOUNCE_MS = 200;
+
+// 한글 정규식
+const KOREAN_REGEX = /[가-힣ㄱ-ㅎㅏ-ㅣ]/;
+
+/**
+ * 검색어가 한글을 포함하는지 확인
+ * @param {string} text
+ * @returns {boolean}
+ */
+function containsKorean(text) {
+ return KOREAN_REGEX.test(text);
+}
+
+/**
+ * 검색어 정규화
+ * - 공백 제거
+ * - 특수문자 제거 (알파벳, 숫자, 한글만 유지)
+ * - 소문자 변환
+ * @param {string} text
+ * @returns {string}
+ */
+function normalizeSearchText(text) {
+ if (!text) return '';
+ return text
+ .toLowerCase()
+ .replace(/[\s\-_.,:;!@#$%^&*()+=\[\]{}|\\/<>?'"]/g, '')
+ .trim();
+}
+
+/**
+ * 최소 검색 길이 확인
+ * - 한글 포함: 최소 2자
+ * - 영문/숫자만: 최소 3자
+ * @param {string} originalText - 원본 입력값
+ * @param {string} normalizedText - 정규화된 검색어
+ * @returns {boolean}
+ */
+function meetsMinLength(originalText, normalizedText) {
+ if (!normalizedText) return false;
+
+ const hasKorean = containsKorean(originalText);
+ const minLength = hasKorean ? 2 : 3;
+
+ return normalizedText.length >= minLength;
+}
+
+/**
+ * 선박 검색 훅
+ * @returns {Object} { searchValue, setSearchValue, results, handleSearch, handleClickResult, clearSearch }
+ */
+export default function useShipSearch() {
+ const [searchValue, setSearchValueState] = useState('');
+ const [results, setResults] = useState([]);
+
+ const map = useMapStore((s) => s.map);
+ const features = useShipStore((s) => s.features);
+ const isIntegrate = useShipStore((s) => s.isIntegrate);
+ const darkSignalIds = useShipStore((s) => s.darkSignalIds);
+ const setSelectedShipIds = useShipStore((s) => s.setSelectedShipIds);
+ const openDetailModal = useShipStore((s) => s.openDetailModal);
+
+ // 추적 모드 상태
+ const trackingMode = useTrackingModeStore((s) => s.mode);
+ const trackedShip = useTrackingModeStore((s) => s.trackedShip);
+ const radiusNM = useTrackingModeStore((s) => s.radiusNM);
+
+ // 디바운스 타이머 ref
+ const debounceTimerRef = useRef(null);
+
+ // 컴포넌트 언마운트 시 타이머 정리
+ useEffect(() => {
+ return () => {
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ }
+ };
+ }, []);
+
+ /**
+ * 실제 검색 실행 (디바운스 후 호출)
+ * @param {string} keyword - 검색어
+ */
+ const executeSearch = useCallback((keyword) => {
+ const normalizedKeyword = normalizeSearchText(keyword);
+
+ // 최소 길이 미달 시 결과 초기화
+ if (!meetsMinLength(keyword, normalizedKeyword)) {
+ setResults([]);
+ return;
+ }
+
+ // 반경 필터 상태 확인
+ const isRadiusFilterActive = trackingMode === 'ship' && trackedShip !== null;
+ let radiusBoundingBox = null;
+ let radiusCenter = null;
+
+ if (isRadiusFilterActive && trackedShip?.longitude && trackedShip?.latitude) {
+ radiusCenter = { lon: trackedShip.longitude, lat: trackedShip.latitude };
+ const LON_DEGREE_METERS = 91000;
+ const LAT_DEGREE_METERS = 111000;
+ const radiusMeters = radiusNM * NM_TO_METERS;
+ const lonDelta = radiusMeters / LON_DEGREE_METERS;
+ const latDelta = radiusMeters / LAT_DEGREE_METERS;
+ radiusBoundingBox = {
+ minLon: radiusCenter.lon - lonDelta,
+ maxLon: radiusCenter.lon + lonDelta,
+ minLat: radiusCenter.lat - latDelta,
+ maxLat: radiusCenter.lat + latDelta,
+ };
+ }
+
+ const hasKorean = containsKorean(keyword);
+ const matchedShips = [];
+
+ // Map을 배열로 변환하여 for...of로 조기 종료 가능하게
+ const featuresArray = Array.from(features.entries());
+
+ for (const [featureId, ship] of featuresArray) {
+ // 최대 결과 수 도달 시 조기 종료
+ if (matchedShips.length >= MAX_RESULTS) break;
+
+ // 다크시그널은 검색 대상에서 제외
+ if (darkSignalIds.has(featureId)) continue;
+
+ // 좌표가 없는 선박 제외
+ if (!ship.longitude || !ship.latitude) continue;
+
+ // 반경 필터 적용 (추적 모드일 때만)
+ if (isRadiusFilterActive && radiusBoundingBox && radiusCenter) {
+ const inBounds =
+ ship.longitude >= radiusBoundingBox.minLon &&
+ ship.longitude <= radiusBoundingBox.maxLon &&
+ ship.latitude >= radiusBoundingBox.minLat &&
+ ship.latitude <= radiusBoundingBox.maxLat;
+
+ if (!inBounds || !isWithinRadius(ship, radiusCenter.lon, radiusCenter.lat, radiusNM)) {
+ continue;
+ }
+ }
+
+ const shipName = ship.shipName || '';
+ const normalizedShipName = normalizeSearchText(shipName);
+
+ let matched = false;
+
+ if (isIntegrate) {
+ // 통합모드 ON: targetId, shipName에서 검색
+ // isPriority가 있는 선박만 검색 (통합선박 대표)
+ if (ship.integrate && !ship.isPriority) continue;
+
+ if (hasKorean) {
+ // 한글 검색: shipName만
+ matched = normalizedShipName.includes(normalizedKeyword);
+ } else {
+ // 영문/숫자: targetId, shipName 모두
+ const targetId = ship.targetId || '';
+ const normalizedTargetId = normalizeSearchText(targetId);
+ matched =
+ normalizedTargetId.includes(normalizedKeyword) ||
+ normalizedShipName.includes(normalizedKeyword);
+ }
+ } else {
+ // 통합모드 OFF: originalTargetId (레이더 제외), shipName에서 검색
+ // 레이더 신호원 제외
+ if (ship.signalSourceCode === SIGNAL_SOURCE_CODE_RADAR) continue;
+
+ if (hasKorean) {
+ // 한글 검색: shipName만
+ matched = normalizedShipName.includes(normalizedKeyword);
+ } else {
+ // 영문/숫자: originalTargetId, shipName 모두
+ const originalTargetId = ship.originalTargetId || '';
+ const normalizedOriginalTargetId = normalizeSearchText(originalTargetId);
+ matched =
+ normalizedOriginalTargetId.includes(normalizedKeyword) ||
+ normalizedShipName.includes(normalizedKeyword);
+ }
+ }
+
+ if (matched) {
+ matchedShips.push({
+ featureId,
+ targetId: ship.targetId || '',
+ originalTargetId: ship.originalTargetId || '',
+ shipName: ship.shipName || '-',
+ signalSourceCode: ship.signalSourceCode,
+ longitude: ship.longitude,
+ latitude: ship.latitude,
+ ship,
+ });
+ }
+ }
+
+ setResults(matchedShips);
+ }, [features, isIntegrate, darkSignalIds, trackingMode, trackedShip, radiusNM]);
+
+ /**
+ * 검색어 변경 핸들러 (디바운스 적용)
+ * @param {string} keyword - 검색어
+ */
+ const setSearchValue = useCallback((keyword) => {
+ setSearchValueState(keyword);
+
+ // 빈 입력 시 즉시 결과 초기화
+ if (!keyword || keyword.trim().length === 0) {
+ setResults([]);
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ debounceTimerRef.current = null;
+ }
+ return;
+ }
+
+ // 디바운스: 타이핑 중에는 검색 지연
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ }
+
+ debounceTimerRef.current = setTimeout(() => {
+ executeSearch(keyword);
+ debounceTimerRef.current = null;
+ }, DEBOUNCE_MS);
+ }, [executeSearch]);
+
+ /**
+ * 검색 결과 클릭 핸들러
+ * - 선박 선택 (하이라이트)
+ * - 상세 모달 열기
+ * - 지도 중심 이동
+ * @param {Object} result - 검색 결과 항목
+ */
+ const handleClickResult = useCallback((result) => {
+ if (!map || !result) return;
+
+ const { featureId, longitude, latitude, ship } = result;
+
+ // 선박 선택 (하이라이트 표시)
+ setSelectedShipIds([featureId]);
+
+ // 상세 모달 열기
+ if (ship) {
+ openDetailModal(ship);
+ }
+
+ // 지도 중심 이동 (애니메이션)
+ const position = fromLonLat([longitude, latitude]);
+ map.getView().animate({
+ center: position,
+ zoom: 14,
+ duration: 500,
+ });
+
+ // 검색창 초기화
+ setSearchValueState('');
+ setResults([]);
+ }, [map, setSelectedShipIds, openDetailModal]);
+
+ /**
+ * 검색 초기화
+ */
+ const clearSearch = useCallback(() => {
+ setSearchValueState('');
+ setResults([]);
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ debounceTimerRef.current = null;
+ }
+ }, []);
+
+ /**
+ * 엔터키로 첫 번째 결과 선택
+ */
+ const handleSelectFirst = useCallback(() => {
+ if (results.length > 0) {
+ handleClickResult(results[0]);
+ }
+ }, [results, handleClickResult]);
+
+ return {
+ searchValue,
+ setSearchValue,
+ results,
+ handleClickResult,
+ handleSelectFirst,
+ clearSearch,
+ isIntegrate,
+ };
+}
diff --git a/src/map/ShipBatchRenderer.js b/src/map/ShipBatchRenderer.js
index 9666d80c..b0250b96 100644
--- a/src/map/ShipBatchRenderer.js
+++ b/src/map/ShipBatchRenderer.js
@@ -22,6 +22,7 @@ import useShipStore, {
RADAR_TIMEOUT_MS,
SIGNAL_SOURCE_RADAR,
} from '../stores/shipStore';
+import useTrackingModeStore, { isWithinRadius, NM_TO_METERS } from '../stores/trackingModeStore';
import {
SIGNAL_KIND_CODE_FISHING,
SIGNAL_KIND_CODE_KCGV,
@@ -341,6 +342,77 @@ function filterByViewport(ships, bounds) {
});
}
+// =====================
+// 반경 필터링 (추적 모드)
+// =====================
+const LON_DEGREE_METERS = 91000; // 중위도(35도) 기준 경도 1도당 약 91km
+const LAT_DEGREE_METERS = 111000; // 위도 1도당 약 111km
+
+/**
+ * 반경 필터 상태 가져오기
+ * @returns {Object} { isActive, center, radiusNM, boundingBox }
+ */
+function getRadiusFilterState() {
+ const state = useTrackingModeStore.getState();
+ const { mode, trackedShip, radiusNM } = state;
+
+ const isActive = mode === 'ship' && trackedShip !== null;
+
+ if (!isActive || !trackedShip || !trackedShip.longitude || !trackedShip.latitude) {
+ return { isActive: false, center: null, radiusNM: 0, boundingBox: null };
+ }
+
+ const center = { lon: trackedShip.longitude, lat: trackedShip.latitude };
+
+ // Bounding Box 계산 (빠른 사전 필터용)
+ const radiusMeters = radiusNM * NM_TO_METERS;
+ const lonDelta = radiusMeters / LON_DEGREE_METERS;
+ const latDelta = radiusMeters / LAT_DEGREE_METERS;
+
+ const boundingBox = {
+ minLon: center.lon - lonDelta,
+ maxLon: center.lon + lonDelta,
+ minLat: center.lat - latDelta,
+ maxLat: center.lat + latDelta,
+ };
+
+ return { isActive: true, center, radiusNM, boundingBox };
+}
+
+/**
+ * 반경 필터링 적용
+ * - 1단계: Bounding Box 체크 (빠른 사각형 체크)
+ * - 2단계: Haversine 거리 계산 (정확한 원형 체크)
+ *
+ * @param {Array} ships - 선박 배열
+ * @param {Object} radiusState - 반경 필터 상태
+ * @returns {Array} 반경 내 선박 배열
+ */
+function filterByRadius(ships, radiusState) {
+ const { isActive, center, radiusNM, boundingBox } = radiusState;
+
+ if (!isActive || !center || !boundingBox) {
+ return ships;
+ }
+
+ return ships.filter((ship) => {
+ if (!ship.longitude || !ship.latitude) return false;
+
+ // 1단계: Bounding Box 체크
+ if (
+ ship.longitude < boundingBox.minLon ||
+ ship.longitude > boundingBox.maxLon ||
+ ship.latitude < boundingBox.minLat ||
+ ship.latitude > boundingBox.maxLat
+ ) {
+ return false;
+ }
+
+ // 2단계: 정확한 원형 거리 체크
+ return isWithinRadius(ship, center.lon, center.lat, radiusNM);
+ });
+}
+
// =====================
// 카운트 + 타임아웃 cleanup 통합 함수
// 참조: mda-react-front/src/common/deck.ts (271-471)
@@ -376,6 +448,9 @@ function calculateAndCleanupLiveShips() {
const { features, darkSignalIds, isIntegrate,
kindVisibility, sourceVisibility, nationalVisibility } = state;
+ // 반경 필터 상태
+ const radiusState = getRadiusFilterState();
+
const kindCounts = { ...initialKindCounts };
let darkSignalCount = 0;
const deleteIds = [];
@@ -392,6 +467,17 @@ function calculateAndCleanupLiveShips() {
features.forEach((ship, featureId) => {
// ① 이미 다크시그널 → 카운트만, 즉시 리턴
if (darkSignalIds.has(featureId)) {
+ // 반경 필터가 활성화된 경우, 반경 내에 있을 때만 카운트
+ if (radiusState.isActive) {
+ if (!ship.longitude || !ship.latitude) return;
+ const inBounds = ship.longitude >= radiusState.boundingBox.minLon &&
+ ship.longitude <= radiusState.boundingBox.maxLon &&
+ ship.latitude >= radiusState.boundingBox.minLat &&
+ ship.latitude <= radiusState.boundingBox.maxLat;
+ if (!inBounds || !isWithinRadius(ship, radiusState.center.lon, radiusState.center.lat, radiusState.radiusNM)) {
+ return;
+ }
+ }
darkSignalCount++;
return;
}
@@ -426,7 +512,19 @@ function calculateAndCleanupLiveShips() {
}
}
- // ⑥ 카운트 대상
+ // ⑥ 반경 필터 체크 (카운트 전)
+ if (radiusState.isActive) {
+ if (!ship.longitude || !ship.latitude) return;
+ const inBounds = ship.longitude >= radiusState.boundingBox.minLon &&
+ ship.longitude <= radiusState.boundingBox.maxLon &&
+ ship.latitude >= radiusState.boundingBox.minLat &&
+ ship.latitude <= radiusState.boundingBox.maxLat;
+ if (!inBounds || !isWithinRadius(ship, radiusState.center.lon, radiusState.center.lat, radiusState.radiusNM)) {
+ return;
+ }
+ }
+
+ // ⑦ 카운트 대상
const targetId = ship.targetId;
const isIntegratedShip = targetId && (targetId.includes('_') || ship.integrate);
@@ -620,8 +718,12 @@ class ShipBatchRenderer {
? filterByViewport(allShips, this.viewportBounds)
: allShips;
+ // 3.5 반경 필터링 (추적 모드 활성화 시)
+ const radiusState = getRadiusFilterState();
+ const radiusFilteredShips = filterByRadius(viewportShips, radiusState);
+
// 4. 필터 적용 (캐시된 필터 사용 - O(1) lookup)
- const filteredShips = viewportShips.filter((ship) =>
+ const filteredShips = radiusFilteredShips.filter((ship) =>
applyFilterWithCache(ship, this.cache.filterCache)
);
diff --git a/src/map/layers/shipLayer.js b/src/map/layers/shipLayer.js
index 9874c249..a6aa40bc 100644
--- a/src/map/layers/shipLayer.js
+++ b/src/map/layers/shipLayer.js
@@ -14,10 +14,20 @@ import {
SIGNAL_FLAG_CONFIGS,
} from '../../types/constants';
import useShipStore from '../../stores/shipStore';
+import useTrackingModeStore from '../../stores/trackingModeStore';
// 아이콘 아틀라스 이미지
import atlasImg from '../../assets/img/icon/atlas.png';
+// 추적 선박 아이콘 (인라인 SVG data URL)
+const TRACKED_SHIP_SVG = ``;
+const TRACKED_SHIP_ICON_URL = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(TRACKED_SHIP_SVG)}`;
+
// =====================
// 도 → 라디안 변환 상수
// =====================
@@ -1002,6 +1012,85 @@ export function createSelectedShipLayer(selectedShips) {
});
}
+/**
+ * 추적 선박 레이어 (최상단 표시)
+ * 선박 모드에서 추적 중인 함정을 특별 아이콘으로 표시
+ * ScatterplotLayer로 원형 마커 + IconLayer로 아이콘 표시
+ * @param {number} zoom - 현재 줌 레벨
+ * @returns {Array} Deck.gl 레이어 배열
+ */
+export function createTrackedShipLayers(zoom) {
+ const { mode, trackedShip } = useTrackingModeStore.getState();
+
+ // 선박 모드가 아니거나 추적 중인 함정이 없으면 빈 배열
+ if (mode !== 'ship' || !trackedShip || !trackedShip.longitude || !trackedShip.latitude) {
+ return [];
+ }
+
+ const layers = [];
+
+ // 위치 기반 업데이트 트리거 (좌표 변경 시 레이어 갱신)
+ const positionKey = `${trackedShip.longitude.toFixed(6)}_${trackedShip.latitude.toFixed(6)}`;
+
+ // 1. 외곽 원형 마커 (강조 효과)
+ layers.push(new ScatterplotLayer({
+ id: 'tracked-ship-outer-ring',
+ data: [trackedShip],
+ pickable: false,
+ stroked: true,
+ filled: false,
+ radiusScale: 1,
+ radiusMinPixels: 18,
+ radiusMaxPixels: 35,
+ lineWidthMinPixels: 2,
+ getPosition: (d) => [d.longitude, d.latitude],
+ getRadius: 22,
+ getLineColor: [0, 212, 255, 180], // #00D4FF with alpha
+ getLineWidth: 2,
+ updateTriggers: {
+ getPosition: positionKey,
+ },
+ }));
+
+ // 2. 내부 원형 마커 (반투명)
+ layers.push(new ScatterplotLayer({
+ id: 'tracked-ship-inner-circle',
+ data: [trackedShip],
+ pickable: false,
+ stroked: false,
+ filled: true,
+ radiusScale: 1,
+ radiusMinPixels: 12,
+ radiusMaxPixels: 25,
+ getPosition: (d) => [d.longitude, d.latitude],
+ getRadius: 15,
+ getFillColor: [0, 212, 255, 60], // #00D4FF with more transparency
+ updateTriggers: {
+ getPosition: positionKey,
+ },
+ }));
+
+ // 3. 중심점 (흰색)
+ layers.push(new ScatterplotLayer({
+ id: 'tracked-ship-center-dot',
+ data: [trackedShip],
+ pickable: true,
+ stroked: false,
+ filled: true,
+ radiusScale: 1,
+ radiusMinPixels: 3,
+ radiusMaxPixels: 7,
+ getPosition: (d) => [d.longitude, d.latitude],
+ getRadius: 4,
+ getFillColor: [255, 255, 255, 200], // white with slight transparency
+ updateTriggers: {
+ getPosition: positionKey,
+ },
+ }));
+
+ return layers;
+}
+
/**
* 모든 선박 레이어 생성 (통합)
* 참조: mda-react-front/src/common/deck.ts
@@ -1033,13 +1122,19 @@ export function createShipLayers(ships, selectedShips, zoom, showLabels = false,
// 2. 선박 아이콘 레이어 (밀도 제한 적용된 전체 선박)
layers.push(createShipIconLayer(ships, zoom, darkSignalIds));
- // 3. 선명표시 레이어들 (밀도 제한된 선박 대상 → 자체 클러스터링)
+ // 3. 추적 선박 레이어 (최상단 - 다른 아이콘 위에 표시)
+ const trackedLayers = createTrackedShipLayers(zoom);
+ if (trackedLayers.length > 0) {
+ layers.push(...trackedLayers);
+ }
+
+ // 4. 선명표시 레이어들 (밀도 제한된 선박 대상 → 자체 클러스터링)
// 아이콘이 표시되는 선박에만 라벨/신호상태 표시
if (showLabels) {
// 라벨 클러스터링 (밀도 제한된 ships 대상)
const clusteredShips = getClusteredShips(ships, zoom, isIntegrate, renderTrigger);
- // 3-1. 속도벡터 레이어
+ // 4-1. 속도벡터 레이어
if (labelOptions.showSpeedVector) {
const vectorLayer = createSpeedVectorLayer(clusteredShips, zoom);
if (vectorLayer) {
@@ -1047,13 +1142,13 @@ export function createShipLayers(ships, selectedShips, zoom, showLabels = false,
}
}
- // 3-2. 선박명 레이어
+ // 4-2. 선박명 레이어
const labelLayer = createShipLabelLayer(clusteredShips, zoom, labelOptions);
if (labelLayer) {
layers.push(labelLayer);
}
- // 3-3. 신호상태 레이어 (별도 클러스터링)
+ // 4-3. 신호상태 레이어 (별도 클러스터링)
if (labelOptions.showSignalStatus) {
// 밀도 제한된 ships 대상으로 신호상태 클러스터링
const signalClusteredShips = getSignalClusteredShips(ships, zoom, isIntegrate, renderTrigger);
@@ -1063,7 +1158,7 @@ export function createShipLayers(ships, selectedShips, zoom, showLabels = false,
}
}
- // 3-4. 선박크기 폴리곤 레이어 (줌 11 이상에서만)
+ // 4-4. 선박크기 폴리곤 레이어 (줌 11 이상에서만)
// 뷰포트 내 선박만 대상으로 렌더링 (성능 최적화)
if (labelOptions.showShipSize) {
const dimLayer = createShipDimLayer(clusteredShips, zoom);
diff --git a/src/scss/MainComponent.scss b/src/scss/MainComponent.scss
index 68b033a1..7035774d 100644
--- a/src/scss/MainComponent.scss
+++ b/src/scss/MainComponent.scss
@@ -1,186 +1,18 @@
@charset "utf-8";
+/**
+ * MainComponent 스타일
+ * 메인 영역 레이아웃
+ *
+ * 참고: TopBar 스타일은 src/components/map/TopBar.scss로 분리됨
+ */
+
#wrap {
//* main */
#main {
width: 100%;
height: calc(100% - 4.4rem);
position: relative;
-
- //* top-bar */
- .topBar {
- position: absolute;
- top: 1.5rem;
- left: 0;
- right: 0;
- height: 4.4rem;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 5.4rem;
- box-sizing: border-box;
- z-index: 95;
- }
-
- //* top-location */
- .locationInfo {
- flex: 0 0 auto;
- min-width: 71rem;
- height: 3.8rem;
- margin: 0 auto;
- display: flex;
- align-items: center;
- background-color: rgba(var(--secondary6-rgb), .9);
- overflow: hidden;
-
- ul {
- display: flex;
- align-items: center;
- width: 100%;
- height: 100%;
-
- li {
- display: flex;
- align-items: center;
- height: 100%;
- padding: 0 1rem;
- color: var(--white);
- font-weight: var(--fw-bold);
- font-size: 1.3rem;
-
- &:first-child,
- &:last-child {
- padding: 0;
- }
-
- span + span {
- padding-right: .5rem;
- }
-
- &.divider {
- position: relative;
-
- &::after {
- content: "";
- position: absolute;
- right: 0;
- top: 50%;
- transform: translateY(-50%);
- width: 1px;
- height: 1.8rem;
- background-color: rgba(255, 255, 255, .3);
- }
- }
-
- .wgs {
- display: flex;
- align-items: center;
- padding-right: .5rem;
-
- &::before {
- content: "";
- display: block;
- width: 2rem;
- height: 2rem;
- margin-right: .5rem;
- background: url(../assets/images/ico_globe.svg) no-repeat center / 2rem;
- }
- }
-
- .kst {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 3.1rem;
- height: 1.8rem;
- margin-right: .5rem;
- border-radius: .3rem;
- background-color: var(--tertiary3);
- color: var(--white);
- font-size: var(--fs-s);
- }
- }
- }
-
- button {
- width: 4rem;
- height: 3.8rem;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- box-sizing: border-box;
- background-color: var(--secondary3);
- background-repeat: no-repeat;
- background-position: center;
- background-size: 3rem;
-
- &:hover,
- &.active {
- background-color: rgba(var(--primary1-rgb), .8);
- }
-
- &.map {
- background-image: url(../assets/images/ico_map.svg);
- }
-
- &.ship {
- background-image: url(../assets/images/ico_ship.svg);
- }
-
- &.set {
- width: 2rem;
- height: 2rem;
- background-color: transparent;
- background-image: url(../assets/images/ico_set_s.svg);
- background-size: 2rem;
-
- &:hover,
- &.active {
- background-color: transparent;
- }
- }
- }
- }
-
- //* top-search */
- .topSchBox {
- flex: 0 0 auto;
- width: 20rem;
- height: 3.8rem;
- display: flex;
- justify-content: flex-end;
- position: relative;
-
- .tschInput {
- width: 100%;
- height: 100%;
- padding-right: 4.4rem;
- border-radius: 2rem;
- background-color: rgba(var(--secondary6-rgb), .9);
- border: 0;
-
- &::placeholder {
- color: var(--white);
- }
- }
-
- .mainSchBtn {
- position: absolute;
- top: 50%;
- right: 0;
- transform: translateY(-50%);
- width: 4rem;
- height: 3.8rem;
- font-size: 0;
- text-indent: -999999em;
- border: none;
- cursor: pointer;
- background: url(../assets/images/ico_search_main.svg) no-repeat center left / 2.4rem;
- }
- }
-
}
}
\ No newline at end of file
diff --git a/src/stores/trackingModeStore.js b/src/stores/trackingModeStore.js
new file mode 100644
index 00000000..2c18416b
--- /dev/null
+++ b/src/stores/trackingModeStore.js
@@ -0,0 +1,198 @@
+/**
+ * 추적 모드 스토어
+ * - 지도 모드: 일반 지도 뷰
+ * - 선박 모드: 특정 함정 중심 추적 + 반경 필터링
+ */
+import { create } from 'zustand';
+import { subscribeWithSelector } from 'zustand/middleware';
+
+// 반경 옵션 (NM)
+export const RADIUS_OPTIONS = [10, 25, 50, 100, 200];
+
+// NM to meters 변환 (1 NM = 1852m)
+export const NM_TO_METERS = 1852;
+
+/**
+ * 경비함정 여부 판별
+ * - originalTargetId가 '#'으로 시작
+ * - 또는 IP 형태 (10.xxx.xxx.xxx)
+ * @param {string} originalTargetId
+ * @returns {boolean}
+ */
+export function isPatrolShip(originalTargetId) {
+ if (!originalTargetId) return false;
+ // '#'으로 시작
+ if (originalTargetId.startsWith('#')) return true;
+ // IP 형태 (10.xxx.xxx.xxx)
+ if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(originalTargetId)) return true;
+ return false;
+}
+
+/**
+ * 두 좌표 간 거리 계산 (Haversine, 미터 단위)
+ * @param {number} lon1
+ * @param {number} lat1
+ * @param {number} lon2
+ * @param {number} lat2
+ * @returns {number} 거리 (미터)
+ */
+export function calculateDistance(lon1, lat1, lon2, lat2) {
+ const R = 6371000; // 지구 반지름 (미터)
+ const toRad = (deg) => (deg * Math.PI) / 180;
+
+ const dLat = toRad(lat2 - lat1);
+ const dLon = toRad(lon2 - lon1);
+ const a =
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+
+ return R * c;
+}
+
+/**
+ * 선박이 반경 내에 있는지 확인
+ * @param {Object} ship - 선박 데이터 (longitude, latitude)
+ * @param {number} centerLon - 중심 경도
+ * @param {number} centerLat - 중심 위도
+ * @param {number} radiusNM - 반경 (NM)
+ * @returns {boolean}
+ */
+export function isWithinRadius(ship, centerLon, centerLat, radiusNM) {
+ if (!ship.longitude || !ship.latitude) return false;
+ const radiusMeters = radiusNM * NM_TO_METERS;
+ const distance = calculateDistance(centerLon, centerLat, ship.longitude, ship.latitude);
+ return distance <= radiusMeters;
+}
+
+/**
+ * 추적 모드 스토어
+ */
+const useTrackingModeStore = create(subscribeWithSelector((set, get) => ({
+ // =====================
+ // 상태 (State)
+ // =====================
+
+ /** 현재 모드: 'map' | 'ship' */
+ mode: 'map',
+
+ /** 추적 중인 함정 featureId */
+ trackedShipId: null,
+
+ /** 추적 중인 함정 데이터 (실시간 업데이트용 캐시) */
+ trackedShip: null,
+
+ /** 반경 (NM) */
+ radiusNM: 25,
+
+ /** 함정 선택 드롭다운 표시 여부 */
+ showShipSelector: false,
+
+ // =====================
+ // 액션 (Actions)
+ // =====================
+
+ /**
+ * 지도 모드로 전환
+ */
+ setMapMode: () => {
+ set({
+ mode: 'map',
+ trackedShipId: null,
+ trackedShip: null,
+ showShipSelector: false,
+ });
+ },
+
+ /**
+ * 선박 모드로 전환 (함정 선택 드롭다운 표시)
+ */
+ setShipMode: () => {
+ set({
+ mode: 'ship',
+ showShipSelector: true,
+ });
+ },
+
+ /**
+ * 함정 선택
+ * @param {string} featureId
+ * @param {Object} ship - 선박 데이터
+ */
+ selectTrackedShip: (featureId, ship) => {
+ set({
+ mode: 'ship',
+ trackedShipId: featureId,
+ trackedShip: ship,
+ showShipSelector: false,
+ });
+ },
+
+ /**
+ * 추적 중인 함정 데이터 업데이트 (실시간)
+ * @param {Object} ship
+ */
+ updateTrackedShip: (ship) => {
+ set({ trackedShip: ship });
+ },
+
+ /**
+ * 반경 설정
+ * @param {number} radiusNM
+ */
+ setRadius: (radiusNM) => {
+ set({ radiusNM });
+ },
+
+ /**
+ * 함정 선택 드롭다운 토글
+ */
+ toggleShipSelector: () => {
+ set((state) => ({ showShipSelector: !state.showShipSelector }));
+ },
+
+ /**
+ * 함정 선택 드롭다운 닫기
+ */
+ closeShipSelector: () => {
+ set({ showShipSelector: false });
+ },
+
+ /**
+ * 모드 토글 (지도 <-> 선박)
+ */
+ toggleMode: () => {
+ const { mode } = get();
+ if (mode === 'map') {
+ get().setShipMode();
+ } else {
+ get().setMapMode();
+ }
+ },
+
+ // =====================
+ // 셀렉터 (Selectors)
+ // =====================
+
+ /**
+ * 선박 모드 활성화 여부
+ */
+ isShipMode: () => get().mode === 'ship',
+
+ /**
+ * 추적 중인 함정이 있는지
+ */
+ hasTrackedShip: () => get().trackedShipId !== null,
+
+ /**
+ * 추적 중인 함정의 중심 좌표
+ * @returns {{ lon: number, lat: number } | null}
+ */
+ getTrackedCenter: () => {
+ const { trackedShip } = get();
+ if (!trackedShip || !trackedShip.longitude || !trackedShip.latitude) return null;
+ return { lon: trackedShip.longitude, lat: trackedShip.latitude };
+ },
+})));
+
+export default useTrackingModeStore;