feat: TopBar 컴포넌트 및 추적 모드 기능 구현
[TopBar 구현] - 좌표 표시 (마우스 위치 실시간 표시, 도분초/도 토글) - 시간 표시 (UTC/KST 토글) - 선박 검색 기능 (like 검색, 디바운싱) - 지도/선박 모드 토글 버튼 [추적 모드 기능] - PatrolShipSelector: 경비함정 선택 패널 - 검색 기능 (함정명/ID like 검색) - 반경 설정 (10/25/50/100/200 NM) - 스크롤 가능한 함정 목록 - ShipContextMenu: 반경설정 서브메뉴 추가 - 단일 경비함정 우클릭 시 반경 선택 가능 - 화면 위치에 따른 서브메뉴 방향 자동 조정 [반경 필터링] - 선박 렌더링: 반경 내 선박만 표시 - 범례 카운트 계산: 반경 내 선박 수 표시 - 검색 결과: 추적 모드 시 반경 내 선박만 검색 - Haversine 거리 계산 + Bounding Box 사전 필터링 [추적 선박 표시] - ScatterplotLayer 3중 구조 (외곽링, 내부원, 중심점) - 추적 중인 경비함정 위치에 시각적 마커 표시
This commit is contained in:
부모
519f3b3fe2
커밋
8292251758
223
CLAUDE.md
223
CLAUDE.md
@ -1,5 +1,8 @@
|
|||||||
# CLAUDE.md - GIS 함정용 프로젝트 가이드
|
# CLAUDE.md - GIS 함정용 프로젝트 가이드
|
||||||
|
|
||||||
|
## 응답 규칙
|
||||||
|
- **모든 응답은 반드시 한글로 작성한다.**
|
||||||
|
|
||||||
## 프로젝트 개요
|
## 프로젝트 개요
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
@ -7,7 +10,7 @@
|
|||||||
| 프로젝트명 | dark (GIS 함정용) |
|
| 프로젝트명 | dark (GIS 함정용) |
|
||||||
| 참조 프로젝트 | mda-react-front (메인 프로젝트) |
|
| 참조 프로젝트 | mda-react-front (메인 프로젝트) |
|
||||||
| 목적 | 선박위치정보 전시 및 조회 기능 프론트엔드 |
|
| 목적 | 선박위치정보 전시 및 조회 기능 프론트엔드 |
|
||||||
| 현재 단계 | 퍼블리싱 → 구현 전환 |
|
| 현재 단계 | Phase 6 진행 중 - 리플레이 기능 구현 완료 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -29,93 +32,74 @@
|
|||||||
|
|
||||||
## 현재 프로젝트(dark) 기술 스택
|
## 현재 프로젝트(dark) 기술 스택
|
||||||
|
|
||||||
| 항목 | 기술 | 변경 계획 |
|
| 항목 | 기술 |
|
||||||
|------|------|----------|
|
|------|------|
|
||||||
| 번들러 | CRA (react-scripts) | Vite 마이그레이션 검토 |
|
| 번들러 | Vite 5.2.10 |
|
||||||
| 언어 | JavaScript | TypeScript 도입 검토 |
|
| 언어 | JavaScript (TypeScript 도입 검토) |
|
||||||
| 라우팅 | React Router 6.30.3 | 유지 |
|
| UI | React 18.2.0 |
|
||||||
| 상태관리 | React useState | Zustand 도입 |
|
| 라우팅 | React Router 6.30.3 |
|
||||||
| 스타일 | SCSS | 유지 |
|
| 상태관리 | Zustand 4.5.2 (subscribeWithSelector) |
|
||||||
| 지도 | 미연동 | OpenLayers + Deck.gl |
|
| 지도 엔진 | OpenLayers 9.2.4 |
|
||||||
|
| 선박 렌더링 | Deck.gl 9.2.6 (core, layers, extensions) |
|
||||||
|
| 실시간 통신 | @stomp/stompjs (STOMP WebSocket) |
|
||||||
|
| HTTP | Axios 1.4.0 |
|
||||||
|
| 스타일 | SCSS |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 프로젝트 구조 설계
|
## 프로젝트 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── index.js # 앱 엔트리 포인트
|
├── index.js # 앱 엔트리 포인트
|
||||||
├── App.jsx # 라우트 정의
|
├── App.jsx # 라우트 정의
|
||||||
│
|
│
|
||||||
├── publish/ # [퍼블리싱 영역] - 기존 퍼블리시 파일
|
├── publish/ # [퍼블리싱 원본] - 직접 수정 금지, 참조용
|
||||||
│ ├── layouts/ # 퍼블리시 레이아웃
|
|
||||||
│ ├── pages/ # 퍼블리시 페이지 (Panel1~8 등)
|
|
||||||
│ └── components/ # 퍼블리시 컴포넌트
|
|
||||||
│
|
│
|
||||||
├── pages/ # [구현 영역] - 실제 페이지
|
├── pages/ # [구현 페이지]
|
||||||
│ ├── Home.jsx # 메인 페이지
|
│ └── HomePage.jsx # 메인 페이지 (지도 + 레이아웃)
|
||||||
│ ├── ship/ # 선박 관련 페이지
|
|
||||||
│ ├── satellite/ # 위성 관련 페이지
|
|
||||||
│ ├── weather/ # 기상 관련 페이지
|
|
||||||
│ └── analysis/ # 분석 관련 페이지
|
|
||||||
│
|
│
|
||||||
├── components/ # [공통 컴포넌트]
|
├── components/
|
||||||
│ ├── common/ # 기본 UI 컴포넌트
|
│ ├── layout/ # Header, Sidebar, ToolBar, MainLayout, SideNav
|
||||||
│ │ ├── Button/
|
│ ├── ship/ # ShipLegend, ShipDetailModal, ShipContextMenu, TrackQueryModal
|
||||||
│ │ ├── Input/
|
│ └── map/ # TopBar (좌표/시간표시, 검색), PatrolShipSelector (함정선택)
|
||||||
│ │ ├── Modal/
|
|
||||||
│ │ └── ...
|
|
||||||
│ ├── domain/ # 도메인별 컴포넌트
|
|
||||||
│ │ ├── ship/ # 선박 관련
|
|
||||||
│ │ ├── map/ # 지도 관련
|
|
||||||
│ │ └── ...
|
|
||||||
│ └── layout/ # 레이아웃 컴포넌트
|
|
||||||
│ ├── Header/
|
|
||||||
│ ├── Sidebar/
|
|
||||||
│ └── ...
|
|
||||||
│
|
│
|
||||||
├── map/ # [지도 모듈]
|
├── map/ # [지도 모듈]
|
||||||
│ ├── MapContext.jsx # OpenLayers 맵 Context
|
│ ├── MapContainer.jsx # OpenLayers + Deck.gl 통합 (window.__mainMap__)
|
||||||
│ ├── MapProvider.jsx # 맵 Provider
|
│ ├── ShipBatchRenderer.js # 배치 렌더러 (적응형 렌더링, 필터 캐시)
|
||||||
│ ├── layers/ # 레이어 정의
|
│ └── layers/
|
||||||
│ │ ├── baseLayer.js # 베이스맵
|
│ ├── baseLayer.js # 베이스맵 (worldMap, eastAsiaMap, korMap)
|
||||||
│ │ ├── shipLayer.js # 선박 레이어
|
│ ├── shipLayer.js # Deck.gl 선박 레이어 (아이콘, 라벨, 벡터, 신호상태)
|
||||||
│ │ └── ...
|
│ └── trackLayer.js # 항적 레이어
|
||||||
│ ├── controls/ # 지도 컨트롤
|
|
||||||
│ └── utils/ # 지도 유틸리티
|
|
||||||
│
|
│
|
||||||
├── stores/ # [Zustand 스토어]
|
├── stores/ # [Zustand 스토어]
|
||||||
│ ├── mapStore.js # 지도 상태
|
│ ├── shipStore.js # 선박 데이터 (핵심 - mutable Map/Set + 버전 카운터)
|
||||||
│ ├── shipStore.js # 선박 상태
|
│ ├── mapStore.js # 지도 상태
|
||||||
│ ├── filterStore.js # 필터 상태
|
│ ├── trackStore.js # 항적 상태
|
||||||
│ └── uiStore.js # UI 상태
|
│ └── trackingModeStore.js # 추적 모드 (지도/선박 모드, 반경 설정)
|
||||||
│
|
│
|
||||||
├── api/ # [API 레이어]
|
├── tracking/ # [항적조회 패키지] - 메인 프로젝트 TS→JS 변환
|
||||||
│ ├── client.js # API 클라이언트
|
│ ├── stores/ # trackQueryStore, trackQueryAnimationStore
|
||||||
│ ├── ship.js # 선박 API
|
│ ├── services/ # trackQueryApi (API + 통합선박 처리)
|
||||||
│ └── ...
|
│ ├── components/ # TrackQueryViewer, TrackQueryTimeline, GlobalTrackQueryViewer
|
||||||
|
│ ├── hooks/ # useEquipmentFilter, useTrackHighlight
|
||||||
|
│ ├── utils/ # trackQueryLayerUtils, TrackQueryBatchRenderer, shipIconUtil
|
||||||
|
│ └── types/ # trackQuery.types.js
|
||||||
│
|
│
|
||||||
├── hooks/ # [커스텀 훅]
|
├── replay/ # [리플레이 패키지] - 메인 프로젝트 TS→JS 변환
|
||||||
│ ├── useMap.js
|
│ ├── stores/ # replayStore, animationStore, mergedTrackStore, playbackTrailStore
|
||||||
│ ├── useShip.js
|
│ ├── services/ # ReplayWebSocketService (WebSocket 청크 수신)
|
||||||
│ └── ...
|
│ ├── components/ # ReplayTimeline, ReplayControlV2, VesselListManager
|
||||||
|
│ ├── hooks/ # useReplayLayer (Deck.gl 레이어 관리)
|
||||||
|
│ ├── utils/ # replayLayerRegistry
|
||||||
|
│ └── types/ # replay.types.js
|
||||||
│
|
│
|
||||||
├── utils/ # [유틸리티]
|
├── hooks/ # useShipData, useShipLayer, useShipSearch, useTrackingMode, useRadiusFilter
|
||||||
│ ├── format.js
|
├── api/ # signalApi
|
||||||
│ ├── coordinate.js
|
├── common/ # stompClient (STOMP WebSocket)
|
||||||
│ └── ...
|
├── types/ # constants.js (신호원/선종/플래그 상수)
|
||||||
│
|
├── assets/ # 이미지, 아이콘 아틀라스
|
||||||
├── types/ # [타입 정의] (TS 도입 시)
|
└── scss/ # 스타일
|
||||||
│ └── ...
|
|
||||||
│
|
|
||||||
├── assets/ # [정적 자원]
|
|
||||||
│ ├── images/
|
|
||||||
│ └── ...
|
|
||||||
│
|
|
||||||
└── scss/ # [스타일]
|
|
||||||
├── base/
|
|
||||||
├── components/
|
|
||||||
└── pages/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -147,31 +131,66 @@ src/
|
|||||||
|
|
||||||
## 기능 구현 로드맵
|
## 기능 구현 로드맵
|
||||||
|
|
||||||
### Phase 1: 기반 구축
|
### Phase 1: 기반 구축 (01-26 완료)
|
||||||
- [ ] 프로젝트 구조 재편 (퍼블리시/구현 분리)
|
- [x] CRA → Vite 마이그레이션
|
||||||
- [ ] OpenLayers 연동 (베이스맵)
|
- [x] 프로젝트 구조 재편 (퍼블리시/구현 분리)
|
||||||
- [ ] Zustand 스토어 설정
|
- [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: 선박 표시
|
### Phase 3: 선박 레이어 고도화 (01-27 완료)
|
||||||
- [ ] Deck.gl 연동
|
- [x] 신호상태(AVETDR) 아이콘, 속도벡터 좌표 투영
|
||||||
- [ ] 선박 아이콘 렌더링
|
- [x] 배치 렌더러 최적화 (ShipBatchRenderer)
|
||||||
- [ ] 선박 클릭/호버 이벤트
|
- [x] 선명표시 클러스터링, 선박통합 ON/OFF
|
||||||
- [ ] 선박 정보 팝업
|
|
||||||
|
|
||||||
### Phase 4: 데이터 연동
|
### Phase 4: shipStore 성능 최적화 + 카운트 동기화 (01-28~30 완료)
|
||||||
- [ ] API 클라이언트 설정
|
- [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
|
```bash
|
||||||
# 개발 서버 실행
|
# 개발 서버 실행 (Vite, http://localhost:3000)
|
||||||
npm start
|
npm run dev
|
||||||
|
|
||||||
# 프로덕션 빌드
|
# 프로덕션 빌드
|
||||||
npm run build
|
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 완료 |
|
||||||
|
|||||||
203
src/components/map/PatrolShipSelector.jsx
Normal file
203
src/components/map/PatrolShipSelector.jsx
Normal file
@ -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 (
|
||||||
|
<div className="patrol-ship-selector" ref={containerRef}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="selector-header">
|
||||||
|
<span className="selector-title">경비함정 선택</span>
|
||||||
|
<button type="button" className="close-btn" onClick={handleCancel}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 영역 */}
|
||||||
|
<div className="selector-search">
|
||||||
|
<div className="search-input-wrapper">
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
className="search-input"
|
||||||
|
placeholder="함정명 또는 ID 검색"
|
||||||
|
value={searchValue}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
/>
|
||||||
|
{searchValue && (
|
||||||
|
<button type="button" className="search-clear-btn" onClick={handleClearSearch}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 반경 설정 */}
|
||||||
|
<div className="selector-radius">
|
||||||
|
<span className="radius-label">반경 설정</span>
|
||||||
|
<div className="radius-options">
|
||||||
|
{RADIUS_OPTIONS.map((radius) => (
|
||||||
|
<button
|
||||||
|
key={radius}
|
||||||
|
type="button"
|
||||||
|
className={`radius-btn ${selectedRadius === radius ? 'active' : ''}`}
|
||||||
|
onClick={() => handleRadiusChange(radius)}
|
||||||
|
>
|
||||||
|
{radius}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="radius-unit">NM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 함정 목록 */}
|
||||||
|
<div className="selector-content">
|
||||||
|
{filteredShips.length === 0 ? (
|
||||||
|
<div className="no-ships">
|
||||||
|
{searchValue ? '검색 결과가 없습니다' : '활성화된 경비함정이 없습니다'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="ship-list">
|
||||||
|
{filteredShips.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.featureId}
|
||||||
|
className="ship-item"
|
||||||
|
onClick={() => handleSelectShip(item)}
|
||||||
|
>
|
||||||
|
<span className="ship-name">{item.shipName}</span>
|
||||||
|
<span className="ship-id">{item.originalTargetId}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="selector-footer">
|
||||||
|
<span className="ship-count">
|
||||||
|
{searchValue ? `${filteredShips.length} / ${patrolShips.length}척` : `${patrolShips.length}척`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
258
src/components/map/PatrolShipSelector.scss
Normal file
258
src/components/map/PatrolShipSelector.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
426
src/components/map/TopBar.jsx
Normal file
426
src/components/map/TopBar.jsx
Normal file
@ -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 <span>---</span>;
|
||||||
|
|
||||||
|
if (coordFormat === 'dms') {
|
||||||
|
const dms = decimalToDMS(value, isLongitude);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span>{dms.degrees}°</span>
|
||||||
|
<span>{dms.minutes}'</span>
|
||||||
|
<span>{dms.seconds}"</span>
|
||||||
|
<span>{dms.direction}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <span>{formatDecimalDegrees(value, isLongitude)}</span>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 시간 포맷팅 (설정에 따라)
|
||||||
|
const timeData = timeFormat === 'kst' ? formatKST(currentTime) : formatUTC(currentTime);
|
||||||
|
const timeLabel = timeFormat === 'kst' ? 'KST' : 'UTC';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="topBar">
|
||||||
|
<div className="locationInfo">
|
||||||
|
<ul>
|
||||||
|
{/* 맵 모드 버튼 */}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`map ${mode === 'map' ? 'active' : ''}`}
|
||||||
|
onClick={setMapMode}
|
||||||
|
>
|
||||||
|
<span className="blind">지도</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{/* 경도 */}
|
||||||
|
<li className="divider">
|
||||||
|
<span className="wgs">경도</span>
|
||||||
|
{renderCoordinate(coordinates.lon, true)}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{/* 위도 */}
|
||||||
|
<li className="divider">
|
||||||
|
<span className="wgs">위도</span>
|
||||||
|
{renderCoordinate(coordinates.lat, false)}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{/* 현재 시간 */}
|
||||||
|
<li className="time-section">
|
||||||
|
<span className={`time-badge ${timeFormat}`}>{timeLabel}</span>
|
||||||
|
<span>{timeData.dateStr}({timeData.dayOfWeek})</span>
|
||||||
|
<span>{timeData.timeStr}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{/* 설정 버튼 */}
|
||||||
|
<li className="settings-container" ref={settingsRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`set ${showSettings ? 'active' : ''}`}
|
||||||
|
onClick={toggleSettings}
|
||||||
|
>
|
||||||
|
<span className="blind">설정</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 설정 드롭다운 */}
|
||||||
|
{showSettings && (
|
||||||
|
<div className="settings-dropdown">
|
||||||
|
<div className="settings-group">
|
||||||
|
<div className="settings-label">위경도 표기</div>
|
||||||
|
<div className="settings-options">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={coordFormat === 'dms' ? 'active' : ''}
|
||||||
|
onClick={() => setCoordFormat('dms')}
|
||||||
|
>
|
||||||
|
도분초
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={coordFormat === 'decimal' ? 'active' : ''}
|
||||||
|
onClick={() => setCoordFormat('decimal')}
|
||||||
|
>
|
||||||
|
도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="settings-group">
|
||||||
|
<div className="settings-label">시간 표기</div>
|
||||||
|
<div className="settings-options">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={timeFormat === 'kst' ? 'active' : ''}
|
||||||
|
onClick={() => setTimeFormat('kst')}
|
||||||
|
>
|
||||||
|
KST
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={timeFormat === 'utc' ? 'active' : ''}
|
||||||
|
onClick={() => setTimeFormat('utc')}
|
||||||
|
>
|
||||||
|
UTC
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{/* 선박 버튼 */}
|
||||||
|
<li className="ship-mode-container">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ship ${mode === 'ship' ? 'active' : ''}`}
|
||||||
|
onClick={setShipMode}
|
||||||
|
>
|
||||||
|
<span className="blind">선박</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 추적 중인 함정 정보 표시 */}
|
||||||
|
{mode === 'ship' && trackedShip && (
|
||||||
|
<div className="tracked-ship-info">
|
||||||
|
<span className="tracked-name">{trackedShip.shipName || trackedShip.originalTargetId}</span>
|
||||||
|
<span className="tracked-radius">{radiusNM}NM</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 함정 선택 드롭다운 */}
|
||||||
|
<PatrolShipSelector />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 박스 */}
|
||||||
|
<div className="topSchBox" ref={searchContainerRef}>
|
||||||
|
<div className="search-input-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="tschInput"
|
||||||
|
placeholder="선박 위치 검색"
|
||||||
|
value={searchValue}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => setIsSearchFocused(true)}
|
||||||
|
/>
|
||||||
|
{searchValue && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="search-clear-btn"
|
||||||
|
onClick={clearSearch}
|
||||||
|
title="검색어 지우기"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 결과 목록 */}
|
||||||
|
{isSearchFocused && searchValue && results.length > 0 && (
|
||||||
|
<ul className="search-results">
|
||||||
|
{results.map((result) => (
|
||||||
|
<li
|
||||||
|
key={result.featureId}
|
||||||
|
className="search-result-item"
|
||||||
|
onClick={() => handleResultClick(result)}
|
||||||
|
>
|
||||||
|
<div className="result-info">
|
||||||
|
<span className="result-name" title={result.shipName}>
|
||||||
|
{truncateString(result.shipName)}
|
||||||
|
</span>
|
||||||
|
<span className="result-id" title={isIntegrate ? result.targetId : result.originalTargetId}>
|
||||||
|
{truncateString(isIntegrate ? result.targetId : result.originalTargetId, 15)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="result-action">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 검색 안내 / 결과 없음 */}
|
||||||
|
{isSearchFocused && searchValue && results.length === 0 && (
|
||||||
|
<div className="search-no-results">
|
||||||
|
{searchValue.length < 2 ? (
|
||||||
|
'한글 2자 이상, 영문/숫자 3자 이상 입력'
|
||||||
|
) : (
|
||||||
|
'검색 결과가 없습니다'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
476
src/components/map/TopBar.scss
Normal file
476
src/components/map/TopBar.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -3,20 +3,39 @@
|
|||||||
* - 단일 선박 우클릭: 해당 선박 메뉴
|
* - 단일 선박 우클릭: 해당 선박 메뉴
|
||||||
* - Ctrl+Drag 선택 후 우클릭: 선택된 선박 전체 메뉴
|
* - Ctrl+Drag 선택 후 우클릭: 선택된 선박 전체 메뉴
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import useShipStore from '../../stores/shipStore';
|
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';
|
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 = [
|
const MENU_ITEMS = [
|
||||||
{ key: 'track', label: '항적조회' },
|
{ key: 'track', label: '항적조회' },
|
||||||
{ key: 'analysis', label: '항적분석' },
|
{ key: 'analysis', label: '항적분석' },
|
||||||
{ key: 'detail', label: '상세정보' },
|
{ key: 'detail', label: '상세정보' },
|
||||||
|
{ key: 'radius', label: '반경설정', hasSubmenu: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ShipContextMenu() {
|
export default function ShipContextMenu() {
|
||||||
const contextMenu = useShipStore((s) => s.contextMenu);
|
const contextMenu = useShipStore((s) => s.contextMenu);
|
||||||
const closeContextMenu = useShipStore((s) => s.closeContextMenu);
|
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 menuRef = useRef(null);
|
||||||
|
const [hoveredItem, setHoveredItem] = useState(null);
|
||||||
|
|
||||||
// 외부 클릭 시 닫기
|
// 외부 클릭 시 닫기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -32,31 +51,156 @@ export default function ShipContextMenu() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClick);
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
}, [contextMenu, closeContextMenu]);
|
}, [contextMenu, closeContextMenu]);
|
||||||
|
|
||||||
// 메뉴 항목 클릭
|
// 반경 선택 핸들러
|
||||||
const handleAction = useCallback((key) => {
|
const handleRadiusSelect = useCallback((radius) => {
|
||||||
if (!contextMenu) return;
|
if (!contextMenu) return;
|
||||||
const { ships } = contextMenu;
|
const { ships } = contextMenu;
|
||||||
|
|
||||||
// TODO: 향후 API 연결
|
// 단일 경비함정인 경우 해당 함정을 추적 대상으로 설정
|
||||||
console.log(`[ContextMenu] action=${key}, ships=`, ships.map((s) => ({
|
if (ships.length === 1) {
|
||||||
featureId: s.featureId,
|
const ship = ships[0];
|
||||||
shipName: s.shipName,
|
setRadius(radius);
|
||||||
targetId: s.targetId,
|
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();
|
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]);
|
}, [contextMenu, closeContextMenu]);
|
||||||
|
|
||||||
if (!contextMenu) return null;
|
if (!contextMenu) return null;
|
||||||
|
|
||||||
const { x, y, ships } = contextMenu;
|
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 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 adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
||||||
const adjustedY = y + menuHeight > window.innerHeight ? y - menuHeight : y;
|
const adjustedY = y + menuHeight > window.innerHeight ? y - menuHeight : y;
|
||||||
|
|
||||||
|
// 서브메뉴 위치 (오른쪽 또는 왼쪽)
|
||||||
|
const submenuOnLeft = adjustedX + menuWidth + 120 > window.innerWidth;
|
||||||
|
|
||||||
const title = ships.length === 1
|
const title = ships.length === 1
|
||||||
? (ships[0].shipName || ships[0].featureId)
|
? (ships[0].shipName || ships[0].featureId)
|
||||||
: `${ships.length}척 선택`;
|
: `${ships.length}척 선택`;
|
||||||
@ -68,13 +212,37 @@ export default function ShipContextMenu() {
|
|||||||
style={{ left: adjustedX, top: adjustedY }}
|
style={{ left: adjustedX, top: adjustedY }}
|
||||||
>
|
>
|
||||||
<div className="ship-context-menu__header">{title}</div>
|
<div className="ship-context-menu__header">{title}</div>
|
||||||
{MENU_ITEMS.map((item) => (
|
{visibleMenuItems.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={item.key}
|
key={item.key}
|
||||||
className="ship-context-menu__item"
|
className={`ship-context-menu__item ${item.hasSubmenu ? 'has-submenu' : ''}`}
|
||||||
onClick={() => handleAction(item.key)}
|
onClick={() => handleAction(item.key)}
|
||||||
|
onMouseEnter={() => setHoveredItem(item.key)}
|
||||||
|
onMouseLeave={() => setHoveredItem(null)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
{item.hasSubmenu && <span className="submenu-arrow">▶</span>}
|
||||||
|
|
||||||
|
{/* 반경설정 서브메뉴 */}
|
||||||
|
{item.key === 'radius' && hoveredItem === 'radius' && (
|
||||||
|
<div
|
||||||
|
className={`ship-context-menu__submenu ${submenuOnLeft ? 'left' : 'right'}`}
|
||||||
|
style={{ top: 0 }}
|
||||||
|
>
|
||||||
|
{RADIUS_OPTIONS.map((radius) => (
|
||||||
|
<div
|
||||||
|
key={radius}
|
||||||
|
className={`ship-context-menu__item ${currentRadius === radius ? 'active' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRadiusSelect(radius);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{radius} NM
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -20,15 +20,63 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__item {
|
&__item {
|
||||||
|
position: relative;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.15s;
|
transition: background-color 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #2a2a4e;
|
background: #2a2a4e;
|
||||||
color: #fff;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
* - 선박 종류별 필터 토글
|
* - 선박 종류별 필터 토글
|
||||||
*/
|
*/
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
import { shallow } from 'zustand/shallow';
|
||||||
import useShipStore from '../../stores/shipStore';
|
import useShipStore from '../../stores/shipStore';
|
||||||
import {
|
import {
|
||||||
SIGNAL_KIND_CODE_FISHING,
|
SIGNAL_KIND_CODE_FISHING,
|
||||||
@ -84,15 +85,21 @@ const LegendItem = memo(({ code, label, count, icon, isVisible, onToggle }) => {
|
|||||||
* 선박 범례 컴포넌트
|
* 선박 범례 컴포넌트
|
||||||
*/
|
*/
|
||||||
const ShipLegend = memo(() => {
|
const ShipLegend = memo(() => {
|
||||||
const {
|
// 셀렉터 사용: 구독 중인 값이 실제로 바뀔 때만 리렌더
|
||||||
kindCounts,
|
// useShipStore() 전체 구독 → featuresVersion 변경마다 리렌더되는 문제 방지
|
||||||
kindVisibility,
|
const { kindCounts, kindVisibility, isShipVisible, totalCount, isConnected } =
|
||||||
isShipVisible,
|
useShipStore(
|
||||||
totalCount,
|
(state) => ({
|
||||||
isConnected,
|
kindCounts: state.kindCounts,
|
||||||
toggleKindVisibility,
|
kindVisibility: state.kindVisibility,
|
||||||
toggleShipVisible,
|
isShipVisible: state.isShipVisible,
|
||||||
} = useShipStore();
|
totalCount: state.totalCount,
|
||||||
|
isConnected: state.isConnected,
|
||||||
|
}),
|
||||||
|
shallow
|
||||||
|
);
|
||||||
|
const toggleKindVisibility = useShipStore((state) => state.toggleKindVisibility);
|
||||||
|
const toggleShipVisible = useShipStore((state) => state.toggleShipVisible);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="ship-legend">
|
<article className="ship-legend">
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* - OpenLayers 맵과 Deck.gl 레이어 통합
|
* - OpenLayers 맵과 Deck.gl 레이어 통합
|
||||||
* - 배치 렌더러 기반 최적화된 렌더링
|
* - 배치 렌더러 기반 최적화된 렌더링
|
||||||
* - 선박 데이터 변경 시 레이어 업데이트
|
* - 선박 데이터 변경 시 레이어 업데이트
|
||||||
|
* - 항적 레이어: 정적(경로/포인트) 캐싱 + 동적(가상선박) 경량 갱신
|
||||||
*
|
*
|
||||||
* 참조: mda-react-front/src/common/deck.ts
|
* 참조: mda-react-front/src/common/deck.ts
|
||||||
*/
|
*/
|
||||||
@ -11,6 +12,10 @@ import { Deck } from '@deck.gl/core';
|
|||||||
import { toLonLat } from 'ol/proj';
|
import { toLonLat } from 'ol/proj';
|
||||||
import { createShipLayers, clearClusterCache } from '../map/layers/shipLayer';
|
import { createShipLayers, clearClusterCache } from '../map/layers/shipLayer';
|
||||||
import useShipStore from '../stores/shipStore';
|
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';
|
import { shipBatchRenderer } from '../map/ShipBatchRenderer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -27,13 +32,15 @@ export default function useShipLayer(map) {
|
|||||||
const getSelectedShips = useShipStore((s) => s.getSelectedShips);
|
const getSelectedShips = useShipStore((s) => s.getSelectedShips);
|
||||||
const isShipVisible = useShipStore((s) => s.isShipVisible);
|
const isShipVisible = useShipStore((s) => s.isShipVisible);
|
||||||
|
|
||||||
|
// 마지막 선박 레이어: 캐시용
|
||||||
|
const lastShipLayersRef = useRef([]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deck.gl 인스턴스 초기화
|
* Deck.gl 인스턴스 초기화
|
||||||
*/
|
*/
|
||||||
const initDeck = useCallback((container) => {
|
const initDeck = useCallback((container) => {
|
||||||
if (deckRef.current) return;
|
if (deckRef.current) return;
|
||||||
|
|
||||||
// Canvas 엘리먼트 생성
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.id = 'deck-canvas';
|
canvas.id = 'deck-canvas';
|
||||||
canvas.style.position = 'absolute';
|
canvas.style.position = 'absolute';
|
||||||
@ -46,7 +53,6 @@ export default function useShipLayer(map) {
|
|||||||
container.appendChild(canvas);
|
container.appendChild(canvas);
|
||||||
canvasRef.current = canvas;
|
canvasRef.current = canvas;
|
||||||
|
|
||||||
// Deck.gl 인스턴스 생성
|
|
||||||
deckRef.current = new Deck({
|
deckRef.current = new Deck({
|
||||||
canvas,
|
canvas,
|
||||||
controller: false,
|
controller: false,
|
||||||
@ -77,7 +83,7 @@ export default function useShipLayer(map) {
|
|||||||
viewState: {
|
viewState: {
|
||||||
longitude: lon,
|
longitude: lon,
|
||||||
latitude: lat,
|
latitude: lat,
|
||||||
zoom: zoom - 1, // OpenLayers와 Deck.gl 줌 레벨 차이 보정
|
zoom: zoom - 1,
|
||||||
bearing: (-rotation * 180) / Math.PI,
|
bearing: (-rotation * 180) / Math.PI,
|
||||||
pitch: 0,
|
pitch: 0,
|
||||||
},
|
},
|
||||||
@ -95,7 +101,6 @@ export default function useShipLayer(map) {
|
|||||||
if (!size) return null;
|
if (!size) return null;
|
||||||
|
|
||||||
const extent = view.calculateExtent(size);
|
const extent = view.calculateExtent(size);
|
||||||
// OpenLayers 좌표를 경위도로 변환
|
|
||||||
const [minX, minY, maxX, maxY] = extent;
|
const [minX, minY, maxX, maxY] = extent;
|
||||||
const [minLon, minLat] = toLonLat([minX, minY]);
|
const [minLon, minLat] = toLonLat([minX, minY]);
|
||||||
const [maxLon, maxLat] = toLonLat([maxX, maxY]);
|
const [maxLon, maxLat] = toLonLat([maxX, maxY]);
|
||||||
@ -104,9 +109,7 @@ export default function useShipLayer(map) {
|
|||||||
}, [map]);
|
}, [map]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 배치 렌더러 콜백 - 필터링된 선박으로 레이어 업데이트
|
* 배치 렌더러 콜백 - 선박 레이어 생성 + 캐싱된 항적 레이어 병합
|
||||||
* @param {Array} ships - 밀도 제한 적용된 선박 (아이콘 + 라벨 공통)
|
|
||||||
* @param {number} trigger - 렌더링 트리거
|
|
||||||
*/
|
*/
|
||||||
const handleBatchRender = useCallback((ships, trigger) => {
|
const handleBatchRender = useCallback((ships, trigger) => {
|
||||||
if (!deckRef.current || !map) return;
|
if (!deckRef.current || !map) return;
|
||||||
@ -115,15 +118,29 @@ export default function useShipLayer(map) {
|
|||||||
const zoom = view.getZoom() || 7;
|
const zoom = view.getZoom() || 7;
|
||||||
const selectedShips = getSelectedShips();
|
const selectedShips = getSelectedShips();
|
||||||
|
|
||||||
// 현재 스토어에서 showLabels, labelOptions, isIntegrate, darkSignalIds 가져오기
|
|
||||||
const { showLabels: currentShowLabels, labelOptions: currentLabelOptions, isIntegrate: currentIsIntegrate, darkSignalIds } = useShipStore.getState();
|
const { showLabels: currentShowLabels, labelOptions: currentLabelOptions, isIntegrate: currentIsIntegrate, darkSignalIds } = useShipStore.getState();
|
||||||
|
|
||||||
// 레이어 생성 (밀도 제한 적용된 선박 = 아이콘 + 라벨 공통)
|
// 라이브 선박 숨김 상태 확인
|
||||||
// 아이콘이 표시되는 선박에만 라벨/신호상태도 표시
|
const { hideLiveShips } = useTrackQueryStore.getState();
|
||||||
const layers = createShipLayers(ships, selectedShips, zoom, currentShowLabels, currentLabelOptions, currentIsIntegrate, trigger, darkSignalIds);
|
|
||||||
|
|
||||||
// Deck.gl 레이어 업데이트
|
// 선박 레이어 생성 (hideLiveShips일 때는 빈 배열)
|
||||||
deckRef.current.setProps({ layers });
|
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]);
|
}, [map, getSelectedShips]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -133,28 +150,23 @@ export default function useShipLayer(map) {
|
|||||||
if (!deckRef.current || !map) return;
|
if (!deckRef.current || !map) return;
|
||||||
|
|
||||||
if (!isShipVisible) {
|
if (!isShipVisible) {
|
||||||
// 선박 표시 Off
|
|
||||||
deckRef.current.setProps({ layers: [] });
|
deckRef.current.setProps({ layers: [] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 줌 레벨 업데이트 (렌더링 간격 조정용)
|
|
||||||
const view = map.getView();
|
const view = map.getView();
|
||||||
const zoom = view.getZoom() || 10;
|
const zoom = view.getZoom() || 10;
|
||||||
const zoomIntChanged = shipBatchRenderer.setZoom(zoom);
|
const zoomIntChanged = shipBatchRenderer.setZoom(zoom);
|
||||||
|
|
||||||
// 뷰포트 범위 업데이트
|
|
||||||
const bounds = getViewportBounds();
|
const bounds = getViewportBounds();
|
||||||
shipBatchRenderer.setViewportBounds(bounds);
|
shipBatchRenderer.setViewportBounds(bounds);
|
||||||
|
|
||||||
// 줌 정수 레벨이 변경되면 클러스터 캐시 클리어 + 즉시 렌더링
|
|
||||||
if (zoomIntChanged) {
|
if (zoomIntChanged) {
|
||||||
clearClusterCache();
|
clearClusterCache();
|
||||||
shipBatchRenderer.immediateRender();
|
shipBatchRenderer.immediateRender();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 배치 렌더러에 렌더링 요청
|
|
||||||
shipBatchRenderer.requestRender();
|
shipBatchRenderer.requestRender();
|
||||||
}, [map, isShipVisible, getViewportBounds]);
|
}, [map, isShipVisible, getViewportBounds]);
|
||||||
|
|
||||||
@ -171,21 +183,15 @@ export default function useShipLayer(map) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
// 맵 컨테이너에 Deck.gl 캔버스 추가
|
|
||||||
const viewport = map.getViewport();
|
const viewport = map.getViewport();
|
||||||
initDeck(viewport);
|
initDeck(viewport);
|
||||||
|
|
||||||
// 배치 렌더러 초기화 (1회만)
|
|
||||||
if (!batchRendererInitialized.current) {
|
if (!batchRendererInitialized.current) {
|
||||||
shipBatchRenderer.initialize(handleBatchRender);
|
shipBatchRenderer.initialize(handleBatchRender);
|
||||||
batchRendererInitialized.current = true;
|
batchRendererInitialized.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 맵 이동/줌 시 동기화
|
const handleMoveEnd = () => { render(); };
|
||||||
const handleMoveEnd = () => {
|
|
||||||
render();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePostRender = () => {
|
const handlePostRender = () => {
|
||||||
syncViewState();
|
syncViewState();
|
||||||
deckRef.current?.redraw();
|
deckRef.current?.redraw();
|
||||||
@ -194,12 +200,8 @@ export default function useShipLayer(map) {
|
|||||||
map.on('moveend', handleMoveEnd);
|
map.on('moveend', handleMoveEnd);
|
||||||
map.on('postrender', handlePostRender);
|
map.on('postrender', handlePostRender);
|
||||||
|
|
||||||
// 초기 렌더링
|
setTimeout(() => { render(); }, 100);
|
||||||
setTimeout(() => {
|
|
||||||
render();
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// 클린업
|
|
||||||
return () => {
|
return () => {
|
||||||
map.un('moveend', handleMoveEnd);
|
map.un('moveend', handleMoveEnd);
|
||||||
map.un('postrender', handlePostRender);
|
map.un('postrender', handlePostRender);
|
||||||
@ -218,20 +220,16 @@ export default function useShipLayer(map) {
|
|||||||
canvasRef.current = null;
|
canvasRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 배치 렌더러 정리
|
|
||||||
shipBatchRenderer.dispose();
|
shipBatchRenderer.dispose();
|
||||||
batchRendererInitialized.current = false;
|
batchRendererInitialized.current = false;
|
||||||
};
|
};
|
||||||
}, [map, initDeck, render, syncViewState, handleBatchRender]);
|
}, [map, initDeck, render, syncViewState, handleBatchRender]);
|
||||||
|
|
||||||
// 선박 데이터 변경 시 레이어 업데이트
|
// 선박 데이터 변경 시 레이어 업데이트
|
||||||
// ※ immutable 패턴: features/darkSignalIds 참조로 변경 감지
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 스토어 구독하여 변경 감지
|
|
||||||
const unsubscribe = useShipStore.subscribe(
|
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],
|
(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) => {
|
(current, prev) => {
|
||||||
// 필터 변경 감지 (kindVisibility, sourceVisibility, isShipVisible, isIntegrate, nationalVisibility, showLabels, labelOptions, darkSignalVisible, darkSignalIds)
|
|
||||||
const filterChanged =
|
const filterChanged =
|
||||||
current[1] !== prev[1] ||
|
current[1] !== prev[1] ||
|
||||||
current[2] !== prev[2] ||
|
current[2] !== prev[2] ||
|
||||||
@ -244,27 +242,56 @@ export default function useShipLayer(map) {
|
|||||||
current[10] !== prev[10];
|
current[10] !== prev[10];
|
||||||
|
|
||||||
if (filterChanged) {
|
if (filterChanged) {
|
||||||
// 필터/선명표시 변경 시 즉시 렌더링 (사용자 인터랙션 응답성)
|
|
||||||
shipBatchRenderer.clearCache();
|
shipBatchRenderer.clearCache();
|
||||||
clearClusterCache();
|
clearClusterCache();
|
||||||
shipBatchRenderer.immediateRender();
|
shipBatchRenderer.immediateRender();
|
||||||
return; // 즉시 렌더링 후 추가 처리 불필요
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 변경 시 배치 렌더러에 렌더링 요청만 전달 (적응형 주기 적용)
|
|
||||||
// ※ redraw() 호출 제거: 배치 렌더러가 executeRender → setProps({ layers }) 시
|
|
||||||
// deck.gl이 자동으로 repaint하므로 별도 redraw() 불필요.
|
|
||||||
// 매 features 참조 변경(~1초)마다 redraw() 호출 시 불필요한 repaint 발생.
|
|
||||||
updateLayers();
|
updateLayers();
|
||||||
},
|
},
|
||||||
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
|
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => { unsubscribe(); };
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, [updateLayers]);
|
}, [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 {
|
return {
|
||||||
deckCanvas: canvasRef.current,
|
deckCanvas: canvasRef.current,
|
||||||
deckRef,
|
deckRef,
|
||||||
|
|||||||
318
src/hooks/useShipSearch.js
Normal file
318
src/hooks/useShipSearch.js
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@ import useShipStore, {
|
|||||||
RADAR_TIMEOUT_MS,
|
RADAR_TIMEOUT_MS,
|
||||||
SIGNAL_SOURCE_RADAR,
|
SIGNAL_SOURCE_RADAR,
|
||||||
} from '../stores/shipStore';
|
} from '../stores/shipStore';
|
||||||
|
import useTrackingModeStore, { isWithinRadius, NM_TO_METERS } from '../stores/trackingModeStore';
|
||||||
import {
|
import {
|
||||||
SIGNAL_KIND_CODE_FISHING,
|
SIGNAL_KIND_CODE_FISHING,
|
||||||
SIGNAL_KIND_CODE_KCGV,
|
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 통합 함수
|
// 카운트 + 타임아웃 cleanup 통합 함수
|
||||||
// 참조: mda-react-front/src/common/deck.ts (271-471)
|
// 참조: mda-react-front/src/common/deck.ts (271-471)
|
||||||
@ -376,6 +448,9 @@ function calculateAndCleanupLiveShips() {
|
|||||||
const { features, darkSignalIds, isIntegrate,
|
const { features, darkSignalIds, isIntegrate,
|
||||||
kindVisibility, sourceVisibility, nationalVisibility } = state;
|
kindVisibility, sourceVisibility, nationalVisibility } = state;
|
||||||
|
|
||||||
|
// 반경 필터 상태
|
||||||
|
const radiusState = getRadiusFilterState();
|
||||||
|
|
||||||
const kindCounts = { ...initialKindCounts };
|
const kindCounts = { ...initialKindCounts };
|
||||||
let darkSignalCount = 0;
|
let darkSignalCount = 0;
|
||||||
const deleteIds = [];
|
const deleteIds = [];
|
||||||
@ -392,6 +467,17 @@ function calculateAndCleanupLiveShips() {
|
|||||||
features.forEach((ship, featureId) => {
|
features.forEach((ship, featureId) => {
|
||||||
// ① 이미 다크시그널 → 카운트만, 즉시 리턴
|
// ① 이미 다크시그널 → 카운트만, 즉시 리턴
|
||||||
if (darkSignalIds.has(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++;
|
darkSignalCount++;
|
||||||
return;
|
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 targetId = ship.targetId;
|
||||||
const isIntegratedShip = targetId && (targetId.includes('_') || ship.integrate);
|
const isIntegratedShip = targetId && (targetId.includes('_') || ship.integrate);
|
||||||
|
|
||||||
@ -620,8 +718,12 @@ class ShipBatchRenderer {
|
|||||||
? filterByViewport(allShips, this.viewportBounds)
|
? filterByViewport(allShips, this.viewportBounds)
|
||||||
: allShips;
|
: allShips;
|
||||||
|
|
||||||
|
// 3.5 반경 필터링 (추적 모드 활성화 시)
|
||||||
|
const radiusState = getRadiusFilterState();
|
||||||
|
const radiusFilteredShips = filterByRadius(viewportShips, radiusState);
|
||||||
|
|
||||||
// 4. 필터 적용 (캐시된 필터 사용 - O(1) lookup)
|
// 4. 필터 적용 (캐시된 필터 사용 - O(1) lookup)
|
||||||
const filteredShips = viewportShips.filter((ship) =>
|
const filteredShips = radiusFilteredShips.filter((ship) =>
|
||||||
applyFilterWithCache(ship, this.cache.filterCache)
|
applyFilterWithCache(ship, this.cache.filterCache)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -14,10 +14,20 @@ import {
|
|||||||
SIGNAL_FLAG_CONFIGS,
|
SIGNAL_FLAG_CONFIGS,
|
||||||
} from '../../types/constants';
|
} from '../../types/constants';
|
||||||
import useShipStore from '../../stores/shipStore';
|
import useShipStore from '../../stores/shipStore';
|
||||||
|
import useTrackingModeStore from '../../stores/trackingModeStore';
|
||||||
|
|
||||||
// 아이콘 아틀라스 이미지
|
// 아이콘 아틀라스 이미지
|
||||||
import atlasImg from '../../assets/img/icon/atlas.png';
|
import atlasImg from '../../assets/img/icon/atlas.png';
|
||||||
|
|
||||||
|
// 추적 선박 아이콘 (인라인 SVG data URL)
|
||||||
|
const TRACKED_SHIP_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<circle cx="16" cy="16" r="14" stroke="#00D4FF" stroke-width="2" fill="none" stroke-dasharray="4 2"/>
|
||||||
|
<circle cx="16" cy="16" r="10" fill="#00D4FF" fill-opacity="0.3"/>
|
||||||
|
<path d="M16 6 L22 20 L16 17 L10 20 Z" fill="#00D4FF"/>
|
||||||
|
<circle cx="16" cy="14" r="2" fill="#FFFFFF"/>
|
||||||
|
</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
|
* 참조: mda-react-front/src/common/deck.ts
|
||||||
@ -1033,13 +1122,19 @@ export function createShipLayers(ships, selectedShips, zoom, showLabels = false,
|
|||||||
// 2. 선박 아이콘 레이어 (밀도 제한 적용된 전체 선박)
|
// 2. 선박 아이콘 레이어 (밀도 제한 적용된 전체 선박)
|
||||||
layers.push(createShipIconLayer(ships, zoom, darkSignalIds));
|
layers.push(createShipIconLayer(ships, zoom, darkSignalIds));
|
||||||
|
|
||||||
// 3. 선명표시 레이어들 (밀도 제한된 선박 대상 → 자체 클러스터링)
|
// 3. 추적 선박 레이어 (최상단 - 다른 아이콘 위에 표시)
|
||||||
|
const trackedLayers = createTrackedShipLayers(zoom);
|
||||||
|
if (trackedLayers.length > 0) {
|
||||||
|
layers.push(...trackedLayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 선명표시 레이어들 (밀도 제한된 선박 대상 → 자체 클러스터링)
|
||||||
// 아이콘이 표시되는 선박에만 라벨/신호상태 표시
|
// 아이콘이 표시되는 선박에만 라벨/신호상태 표시
|
||||||
if (showLabels) {
|
if (showLabels) {
|
||||||
// 라벨 클러스터링 (밀도 제한된 ships 대상)
|
// 라벨 클러스터링 (밀도 제한된 ships 대상)
|
||||||
const clusteredShips = getClusteredShips(ships, zoom, isIntegrate, renderTrigger);
|
const clusteredShips = getClusteredShips(ships, zoom, isIntegrate, renderTrigger);
|
||||||
|
|
||||||
// 3-1. 속도벡터 레이어
|
// 4-1. 속도벡터 레이어
|
||||||
if (labelOptions.showSpeedVector) {
|
if (labelOptions.showSpeedVector) {
|
||||||
const vectorLayer = createSpeedVectorLayer(clusteredShips, zoom);
|
const vectorLayer = createSpeedVectorLayer(clusteredShips, zoom);
|
||||||
if (vectorLayer) {
|
if (vectorLayer) {
|
||||||
@ -1047,13 +1142,13 @@ export function createShipLayers(ships, selectedShips, zoom, showLabels = false,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3-2. 선박명 레이어
|
// 4-2. 선박명 레이어
|
||||||
const labelLayer = createShipLabelLayer(clusteredShips, zoom, labelOptions);
|
const labelLayer = createShipLabelLayer(clusteredShips, zoom, labelOptions);
|
||||||
if (labelLayer) {
|
if (labelLayer) {
|
||||||
layers.push(labelLayer);
|
layers.push(labelLayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3-3. 신호상태 레이어 (별도 클러스터링)
|
// 4-3. 신호상태 레이어 (별도 클러스터링)
|
||||||
if (labelOptions.showSignalStatus) {
|
if (labelOptions.showSignalStatus) {
|
||||||
// 밀도 제한된 ships 대상으로 신호상태 클러스터링
|
// 밀도 제한된 ships 대상으로 신호상태 클러스터링
|
||||||
const signalClusteredShips = getSignalClusteredShips(ships, zoom, isIntegrate, renderTrigger);
|
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) {
|
if (labelOptions.showShipSize) {
|
||||||
const dimLayer = createShipDimLayer(clusteredShips, zoom);
|
const dimLayer = createShipDimLayer(clusteredShips, zoom);
|
||||||
|
|||||||
@ -1,186 +1,18 @@
|
|||||||
|
|
||||||
@charset "utf-8";
|
@charset "utf-8";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MainComponent 스타일
|
||||||
|
* 메인 영역 레이아웃
|
||||||
|
*
|
||||||
|
* 참고: TopBar 스타일은 src/components/map/TopBar.scss로 분리됨
|
||||||
|
*/
|
||||||
|
|
||||||
#wrap {
|
#wrap {
|
||||||
//* main */
|
//* main */
|
||||||
#main {
|
#main {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: calc(100% - 4.4rem);
|
height: calc(100% - 4.4rem);
|
||||||
position: relative;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
198
src/stores/trackingModeStore.js
Normal file
198
src/stores/trackingModeStore.js
Normal file
@ -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;
|
||||||
불러오는 중...
Reference in New Issue
Block a user