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
221
CLAUDE.md
221
CLAUDE.md
@ -1,5 +1,8 @@
|
||||
# CLAUDE.md - GIS 함정용 프로젝트 가이드
|
||||
|
||||
## 응답 규칙
|
||||
- **모든 응답은 반드시 한글로 작성한다.**
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
| 항목 | 내용 |
|
||||
@ -7,7 +10,7 @@
|
||||
| 프로젝트명 | dark (GIS 함정용) |
|
||||
| 참조 프로젝트 | mda-react-front (메인 프로젝트) |
|
||||
| 목적 | 선박위치정보 전시 및 조회 기능 프론트엔드 |
|
||||
| 현재 단계 | 퍼블리싱 → 구현 전환 |
|
||||
| 현재 단계 | Phase 6 진행 중 - 리플레이 기능 구현 완료 |
|
||||
|
||||
---
|
||||
|
||||
@ -29,93 +32,74 @@
|
||||
|
||||
## 현재 프로젝트(dark) 기술 스택
|
||||
|
||||
| 항목 | 기술 | 변경 계획 |
|
||||
|------|------|----------|
|
||||
| 번들러 | CRA (react-scripts) | Vite 마이그레이션 검토 |
|
||||
| 언어 | JavaScript | TypeScript 도입 검토 |
|
||||
| 라우팅 | React Router 6.30.3 | 유지 |
|
||||
| 상태관리 | React useState | Zustand 도입 |
|
||||
| 스타일 | SCSS | 유지 |
|
||||
| 지도 | 미연동 | OpenLayers + Deck.gl |
|
||||
| 항목 | 기술 |
|
||||
|------|------|
|
||||
| 번들러 | Vite 5.2.10 |
|
||||
| 언어 | JavaScript (TypeScript 도입 검토) |
|
||||
| UI | React 18.2.0 |
|
||||
| 라우팅 | React Router 6.30.3 |
|
||||
| 상태관리 | Zustand 4.5.2 (subscribeWithSelector) |
|
||||
| 지도 엔진 | OpenLayers 9.2.4 |
|
||||
| 선박 렌더링 | Deck.gl 9.2.6 (core, layers, extensions) |
|
||||
| 실시간 통신 | @stomp/stompjs (STOMP WebSocket) |
|
||||
| HTTP | Axios 1.4.0 |
|
||||
| 스타일 | SCSS |
|
||||
|
||||
---
|
||||
|
||||
## 프로젝트 구조 설계
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.js # 앱 엔트리 포인트
|
||||
├── App.jsx # 라우트 정의
|
||||
│
|
||||
├── publish/ # [퍼블리싱 영역] - 기존 퍼블리시 파일
|
||||
│ ├── layouts/ # 퍼블리시 레이아웃
|
||||
│ ├── pages/ # 퍼블리시 페이지 (Panel1~8 등)
|
||||
│ └── components/ # 퍼블리시 컴포넌트
|
||||
├── publish/ # [퍼블리싱 원본] - 직접 수정 금지, 참조용
|
||||
│
|
||||
├── pages/ # [구현 영역] - 실제 페이지
|
||||
│ ├── Home.jsx # 메인 페이지
|
||||
│ ├── ship/ # 선박 관련 페이지
|
||||
│ ├── satellite/ # 위성 관련 페이지
|
||||
│ ├── weather/ # 기상 관련 페이지
|
||||
│ └── analysis/ # 분석 관련 페이지
|
||||
├── pages/ # [구현 페이지]
|
||||
│ └── HomePage.jsx # 메인 페이지 (지도 + 레이아웃)
|
||||
│
|
||||
├── components/ # [공통 컴포넌트]
|
||||
│ ├── common/ # 기본 UI 컴포넌트
|
||||
│ │ ├── Button/
|
||||
│ │ ├── Input/
|
||||
│ │ ├── Modal/
|
||||
│ │ └── ...
|
||||
│ ├── domain/ # 도메인별 컴포넌트
|
||||
│ │ ├── ship/ # 선박 관련
|
||||
│ │ ├── map/ # 지도 관련
|
||||
│ │ └── ...
|
||||
│ └── layout/ # 레이아웃 컴포넌트
|
||||
│ ├── Header/
|
||||
│ ├── Sidebar/
|
||||
│ └── ...
|
||||
├── components/
|
||||
│ ├── layout/ # Header, Sidebar, ToolBar, MainLayout, SideNav
|
||||
│ ├── ship/ # ShipLegend, ShipDetailModal, ShipContextMenu, TrackQueryModal
|
||||
│ └── map/ # TopBar (좌표/시간표시, 검색), PatrolShipSelector (함정선택)
|
||||
│
|
||||
├── map/ # [지도 모듈]
|
||||
│ ├── MapContext.jsx # OpenLayers 맵 Context
|
||||
│ ├── MapProvider.jsx # 맵 Provider
|
||||
│ ├── layers/ # 레이어 정의
|
||||
│ │ ├── baseLayer.js # 베이스맵
|
||||
│ │ ├── shipLayer.js # 선박 레이어
|
||||
│ │ └── ...
|
||||
│ ├── controls/ # 지도 컨트롤
|
||||
│ └── utils/ # 지도 유틸리티
|
||||
│ ├── MapContainer.jsx # OpenLayers + Deck.gl 통합 (window.__mainMap__)
|
||||
│ ├── ShipBatchRenderer.js # 배치 렌더러 (적응형 렌더링, 필터 캐시)
|
||||
│ └── layers/
|
||||
│ ├── baseLayer.js # 베이스맵 (worldMap, eastAsiaMap, korMap)
|
||||
│ ├── shipLayer.js # Deck.gl 선박 레이어 (아이콘, 라벨, 벡터, 신호상태)
|
||||
│ └── trackLayer.js # 항적 레이어
|
||||
│
|
||||
├── stores/ # [Zustand 스토어]
|
||||
│ ├── shipStore.js # 선박 데이터 (핵심 - mutable Map/Set + 버전 카운터)
|
||||
│ ├── mapStore.js # 지도 상태
|
||||
│ ├── shipStore.js # 선박 상태
|
||||
│ ├── filterStore.js # 필터 상태
|
||||
│ └── uiStore.js # UI 상태
|
||||
│ ├── trackStore.js # 항적 상태
|
||||
│ └── trackingModeStore.js # 추적 모드 (지도/선박 모드, 반경 설정)
|
||||
│
|
||||
├── api/ # [API 레이어]
|
||||
│ ├── client.js # API 클라이언트
|
||||
│ ├── ship.js # 선박 API
|
||||
│ └── ...
|
||||
├── tracking/ # [항적조회 패키지] - 메인 프로젝트 TS→JS 변환
|
||||
│ ├── stores/ # trackQueryStore, trackQueryAnimationStore
|
||||
│ ├── services/ # trackQueryApi (API + 통합선박 처리)
|
||||
│ ├── components/ # TrackQueryViewer, TrackQueryTimeline, GlobalTrackQueryViewer
|
||||
│ ├── hooks/ # useEquipmentFilter, useTrackHighlight
|
||||
│ ├── utils/ # trackQueryLayerUtils, TrackQueryBatchRenderer, shipIconUtil
|
||||
│ └── types/ # trackQuery.types.js
|
||||
│
|
||||
├── hooks/ # [커스텀 훅]
|
||||
│ ├── useMap.js
|
||||
│ ├── useShip.js
|
||||
│ └── ...
|
||||
├── replay/ # [리플레이 패키지] - 메인 프로젝트 TS→JS 변환
|
||||
│ ├── stores/ # replayStore, animationStore, mergedTrackStore, playbackTrailStore
|
||||
│ ├── services/ # ReplayWebSocketService (WebSocket 청크 수신)
|
||||
│ ├── components/ # ReplayTimeline, ReplayControlV2, VesselListManager
|
||||
│ ├── hooks/ # useReplayLayer (Deck.gl 레이어 관리)
|
||||
│ ├── utils/ # replayLayerRegistry
|
||||
│ └── types/ # replay.types.js
|
||||
│
|
||||
├── utils/ # [유틸리티]
|
||||
│ ├── format.js
|
||||
│ ├── coordinate.js
|
||||
│ └── ...
|
||||
│
|
||||
├── types/ # [타입 정의] (TS 도입 시)
|
||||
│ └── ...
|
||||
│
|
||||
├── assets/ # [정적 자원]
|
||||
│ ├── images/
|
||||
│ └── ...
|
||||
│
|
||||
└── scss/ # [스타일]
|
||||
├── base/
|
||||
├── components/
|
||||
└── pages/
|
||||
├── hooks/ # useShipData, useShipLayer, useShipSearch, useTrackingMode, useRadiusFilter
|
||||
├── api/ # signalApi
|
||||
├── common/ # stompClient (STOMP WebSocket)
|
||||
├── types/ # constants.js (신호원/선종/플래그 상수)
|
||||
├── assets/ # 이미지, 아이콘 아틀라스
|
||||
└── scss/ # 스타일
|
||||
```
|
||||
|
||||
---
|
||||
@ -147,31 +131,66 @@ src/
|
||||
|
||||
## 기능 구현 로드맵
|
||||
|
||||
### Phase 1: 기반 구축
|
||||
- [ ] 프로젝트 구조 재편 (퍼블리시/구현 분리)
|
||||
- [ ] OpenLayers 연동 (베이스맵)
|
||||
- [ ] Zustand 스토어 설정
|
||||
- [ ] 기본 레이아웃 구현
|
||||
### Phase 1: 기반 구축 (01-26 완료)
|
||||
- [x] CRA → Vite 마이그레이션
|
||||
- [x] 프로젝트 구조 재편 (퍼블리시/구현 분리)
|
||||
- [x] Zustand 스토어 설정 (mapStore, shipStore)
|
||||
- [x] 기본 레이아웃 구현 (MainLayout, Header, Sidebar, ToolBar)
|
||||
|
||||
### Phase 2: 지도 핵심 기능
|
||||
- [ ] 지도 컨트롤 (줌, 패닝)
|
||||
- [ ] 레이어 관리 (토글, 순서)
|
||||
- [ ] 좌표 표시
|
||||
- [ ] 축척 표시
|
||||
### Phase 2: 지도 및 선박 표시 (01-26 완료)
|
||||
- [x] OpenLayers 맵 컨테이너 + 베이스맵 레이어
|
||||
- [x] STOMP WebSocket 클라이언트
|
||||
- [x] Deck.gl 선박 아이콘 레이어 (IconLayer + 아틀라스)
|
||||
- [x] 선박 범례 컴포넌트 (ShipLegend)
|
||||
|
||||
### Phase 3: 선박 표시
|
||||
- [ ] Deck.gl 연동
|
||||
- [ ] 선박 아이콘 렌더링
|
||||
- [ ] 선박 클릭/호버 이벤트
|
||||
- [ ] 선박 정보 팝업
|
||||
### Phase 3: 선박 레이어 고도화 (01-27 완료)
|
||||
- [x] 신호상태(AVETDR) 아이콘, 속도벡터 좌표 투영
|
||||
- [x] 배치 렌더러 최적화 (ShipBatchRenderer)
|
||||
- [x] 선명표시 클러스터링, 선박통합 ON/OFF
|
||||
|
||||
### Phase 4: 데이터 연동
|
||||
- [ ] API 클라이언트 설정
|
||||
- [ ] 선박 데이터 조회
|
||||
- [ ] 필터링 기능
|
||||
### Phase 4: shipStore 성능 최적화 + 카운트 동기화 (01-28~30 완료)
|
||||
- [x] Map/Set mutable update + 버전 카운터 패턴
|
||||
- [x] 카운트 5초 쓰로틀 + targetId 중복 제거
|
||||
- [x] 레이더 카운트 제외 (통합 여부 무관)
|
||||
- [x] Ctrl+Drag 박스 선택 + 우클릭 컨텍스트 메뉴
|
||||
- [x] 통합모드 전환 시 selectedShipIds 동기화
|
||||
|
||||
### Phase 5: 추가 기능
|
||||
- [ ] 항적 조회
|
||||
### Phase 5: 항적조회 + 버그 수정 (02-02~03 완료)
|
||||
- [x] tracking 패키지 TS→JS 변환 (13개 파일)
|
||||
- [x] 모달 항적조회 + 우클릭 항적조회
|
||||
- [x] 라이브 연결선 (PathStyleExtension dash + 1초 인터벌)
|
||||
- [x] 레이더 타겟 통합 표시 버그 수정 (integrate 플래그 기반)
|
||||
|
||||
### Phase 6: 리플레이 기능 (02-03 완료)
|
||||
- [x] replay 패키지 TS→JS 변환 (stores, components, hooks, services, utils, types)
|
||||
- [x] WebSocket 기반 청크 데이터 수신 (ReplayWebSocketService)
|
||||
- [x] 시간 기반 애니메이션 (재생/일시정지/정지, 배속 1x~1000x)
|
||||
- [x] 프로그레스 바 슬라이더 (CSS 변수 방식, thumb/progress/배경 동기화)
|
||||
- [x] 항적 표시 토글 (playbackTrailStore - 프레임 기반 페이딩)
|
||||
- [x] 선박 상태 관리 (기본/선택/삭제) + 필터링 (선종, 신호원)
|
||||
- [x] 드래그 가능한 타임라인 컨트롤러
|
||||
- [x] 닫기 시 초기화 (라이브 선박 복원)
|
||||
|
||||
### Phase 6.5: TopBar 및 추적 모드 (02-04 완료)
|
||||
- [x] TopBar 컴포넌트 (좌표 표시, 시간 표시, 선박 검색)
|
||||
- [x] 좌표/시간 포맷 설정 드롭다운 (도분초/도, KST/UTC)
|
||||
- [x] 선박 검색 기능 (like 검색, 디바운싱, 통합모드 지원)
|
||||
- [x] 추적 모드 구현 (trackingModeStore)
|
||||
- 지도 모드 / 선박 모드 전환 (라디오 버튼)
|
||||
- 경비함정 선택 드롭다운 (PatrolShipSelector)
|
||||
- 반경 설정 (10/25/50/100/200 NM)
|
||||
- [x] 반경 필터링 (useRadiusFilter, ShipBatchRenderer)
|
||||
- Bounding Box 사전 필터 + Haversine 거리 계산
|
||||
- 렌더링, 카운트, 검색 결과 모두 반경 내 선박만 표시
|
||||
- [x] 추적 함정 지도 따라가기 (useTrackingMode)
|
||||
- 반경 원 표시 (OpenLayers VectorLayer)
|
||||
- 지도 중심 자동 이동
|
||||
- [x] 추적 함정 아이콘 강조 (ScatterplotLayer - 시안색 마커)
|
||||
- [x] 컨텍스트 메뉴 반경설정 (경비함정 우클릭 → 반경설정 서브메뉴)
|
||||
|
||||
### Phase 7: 미구현 (예정)
|
||||
- [ ] 동적 우선순위 (Dynamic Priority) - 계획 수립 완료 (`.claude/plans/`)
|
||||
- [ ] 퍼블리시 → 구현 전환 (Panel1~8, 기능 페이지)
|
||||
- [ ] 기상 레이어
|
||||
- [ ] 관심영역 설정
|
||||
- [ ] 위성 영상
|
||||
@ -199,14 +218,21 @@ src/
|
||||
## 개발 명령어
|
||||
|
||||
```bash
|
||||
# 개발 서버 실행
|
||||
npm start
|
||||
# 개발 서버 실행 (Vite, http://localhost:3000)
|
||||
npm run dev
|
||||
|
||||
# 프로덕션 빌드
|
||||
npm run build
|
||||
|
||||
# 테스트
|
||||
npm test
|
||||
# 환경별 빌드
|
||||
npm run build:dev
|
||||
npm run build:prod
|
||||
|
||||
# 빌드 미리보기
|
||||
npm run preview
|
||||
|
||||
# 린트
|
||||
npm run lint
|
||||
```
|
||||
|
||||
---
|
||||
@ -222,4 +248,9 @@ npm test
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-01-26 | 초기 프로젝트 분석 및 워크플로우 정의 |
|
||||
| 2026-02-04 | Phase 6.5 완료 (TopBar, 추적 모드, 반경 필터링) |
|
||||
| 2026-02-03 | Phase 6 완료 (리플레이 기능 구현) |
|
||||
| 2026-02-03 | Phase 5 완료 (항적조회, 레이더 통합 버그 수정), CLAUDE.md 현황 동기화 |
|
||||
| 2026-01-30 | Phase 4 완료 (shipStore 성능 최적화, 카운트 동기화) |
|
||||
| 2026-01-27 | Phase 2~3 완료 (지도, 선박 레이어 고도화) |
|
||||
| 2026-01-26 | 초기 프로젝트 분석 및 워크플로우 정의, Phase 1 완료 |
|
||||
|
||||
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 선택 후 우클릭: 선택된 선박 전체 메뉴
|
||||
*/
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import useShipStore from '../../stores/shipStore';
|
||||
import { useTrackQueryStore } from '../../tracking/stores/trackQueryStore';
|
||||
import useTrackingModeStore, { RADIUS_OPTIONS, isPatrolShip } from '../../stores/trackingModeStore';
|
||||
import {
|
||||
fetchVesselTracksV2,
|
||||
convertToProcessedTracks,
|
||||
buildVesselListForQuery,
|
||||
deduplicateVessels,
|
||||
} from '../../tracking/services/trackQueryApi';
|
||||
import './ShipContextMenu.scss';
|
||||
|
||||
/** KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC이므로 사용하지 않음) */
|
||||
function toKstISOString(date) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{ key: 'track', label: '항적조회' },
|
||||
{ key: 'analysis', label: '항적분석' },
|
||||
{ key: 'detail', label: '상세정보' },
|
||||
{ key: 'radius', label: '반경설정', hasSubmenu: true },
|
||||
];
|
||||
|
||||
export default function ShipContextMenu() {
|
||||
const contextMenu = useShipStore((s) => s.contextMenu);
|
||||
const closeContextMenu = useShipStore((s) => s.closeContextMenu);
|
||||
const setRadius = useTrackingModeStore((s) => s.setRadius);
|
||||
const selectTrackedShip = useTrackingModeStore((s) => s.selectTrackedShip);
|
||||
const currentRadius = useTrackingModeStore((s) => s.radiusNM);
|
||||
const menuRef = useRef(null);
|
||||
const [hoveredItem, setHoveredItem] = useState(null);
|
||||
|
||||
// 외부 클릭 시 닫기
|
||||
useEffect(() => {
|
||||
@ -32,31 +51,156 @@ export default function ShipContextMenu() {
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [contextMenu, closeContextMenu]);
|
||||
|
||||
// 메뉴 항목 클릭
|
||||
const handleAction = useCallback((key) => {
|
||||
// 반경 선택 핸들러
|
||||
const handleRadiusSelect = useCallback((radius) => {
|
||||
if (!contextMenu) return;
|
||||
const { ships } = contextMenu;
|
||||
|
||||
// TODO: 향후 API 연결
|
||||
// 단일 경비함정인 경우 해당 함정을 추적 대상으로 설정
|
||||
if (ships.length === 1) {
|
||||
const ship = ships[0];
|
||||
setRadius(radius);
|
||||
selectTrackedShip(ship.featureId, ship);
|
||||
}
|
||||
closeContextMenu();
|
||||
}, [contextMenu, setRadius, selectTrackedShip, closeContextMenu]);
|
||||
|
||||
// 메뉴 항목 클릭
|
||||
const handleAction = useCallback(async (key) => {
|
||||
if (!contextMenu) return;
|
||||
const { ships } = contextMenu;
|
||||
|
||||
// 반경설정은 서브메뉴에서 처리
|
||||
if (key === 'radius') return;
|
||||
|
||||
closeContextMenu();
|
||||
|
||||
switch (key) {
|
||||
case 'track': {
|
||||
const store = useTrackQueryStore.getState();
|
||||
store.reset();
|
||||
|
||||
const endTime = new Date();
|
||||
const startTime = new Date(endTime.getTime() - 3 * 24 * 60 * 60 * 1000); // 3일
|
||||
|
||||
const { isIntegrate, features } = useShipStore.getState();
|
||||
|
||||
const allVessels = [];
|
||||
const errors = [];
|
||||
ships.forEach(ship => {
|
||||
const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features);
|
||||
if (result.canQuery) allVessels.push(...result.vessels);
|
||||
else if (result.errorMessage) errors.push(result.errorMessage);
|
||||
});
|
||||
|
||||
// (sigSrcCd, targetId) 중복 제거
|
||||
const uniqueVessels = deduplicateVessels(allVessels);
|
||||
|
||||
if (uniqueVessels.length === 0) {
|
||||
store.setError(errors[0] || '조회 가능한 선박이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
store.setModalMode(false, null);
|
||||
store.setLoading(true);
|
||||
try {
|
||||
const rawTracks = await fetchVesselTracksV2({
|
||||
startTime: toKstISOString(startTime),
|
||||
endTime: toKstISOString(endTime),
|
||||
vessels: uniqueVessels,
|
||||
isIntegration: isIntegrate ? '1' : '0',
|
||||
});
|
||||
const processed = convertToProcessedTracks(rawTracks);
|
||||
if (processed.length === 0) {
|
||||
store.setError('항적 데이터가 없습니다.');
|
||||
} else {
|
||||
store.setTracks(processed, startTime.getTime());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ShipContextMenu] 항적 조회 실패:', e);
|
||||
store.setError('항적 조회 실패');
|
||||
}
|
||||
store.setLoading(false);
|
||||
break;
|
||||
}
|
||||
case 'analysis': {
|
||||
// 항적분석: 동일한 조회 후 showPlayback 활성화
|
||||
const store = useTrackQueryStore.getState();
|
||||
store.reset();
|
||||
|
||||
const endTime = new Date();
|
||||
const startTime = new Date(endTime.getTime() - 3 * 24 * 60 * 60 * 1000); // 3일
|
||||
|
||||
const { isIntegrate, features } = useShipStore.getState();
|
||||
|
||||
const allVessels = [];
|
||||
ships.forEach(ship => {
|
||||
const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features);
|
||||
if (result.canQuery) allVessels.push(...result.vessels);
|
||||
});
|
||||
|
||||
// (sigSrcCd, targetId) 중복 제거
|
||||
const uniqueVessels = deduplicateVessels(allVessels);
|
||||
|
||||
if (uniqueVessels.length === 0) return;
|
||||
|
||||
store.setModalMode(false, null);
|
||||
store.setLoading(true);
|
||||
try {
|
||||
const rawTracks = await fetchVesselTracksV2({
|
||||
startTime: toKstISOString(startTime),
|
||||
endTime: toKstISOString(endTime),
|
||||
vessels: uniqueVessels,
|
||||
isIntegration: isIntegrate ? '1' : '0',
|
||||
});
|
||||
const processed = convertToProcessedTracks(rawTracks);
|
||||
if (processed.length > 0) {
|
||||
store.setTracks(processed, startTime.getTime());
|
||||
// showPlayback 활성화 (재생 컨트롤 표시)
|
||||
useTrackQueryStore.setState({ showPlayback: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ShipContextMenu] 항적분석 조회 실패:', e);
|
||||
}
|
||||
store.setLoading(false);
|
||||
break;
|
||||
}
|
||||
case 'detail':
|
||||
if (ships.length === 1) {
|
||||
useShipStore.getState().openDetailModal(ships[0]);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.log(`[ContextMenu] action=${key}, ships=`, ships.map((s) => ({
|
||||
featureId: s.featureId,
|
||||
shipName: s.shipName,
|
||||
targetId: s.targetId,
|
||||
})));
|
||||
|
||||
closeContextMenu();
|
||||
}
|
||||
}, [contextMenu, closeContextMenu]);
|
||||
|
||||
if (!contextMenu) return null;
|
||||
|
||||
const { x, y, ships } = contextMenu;
|
||||
|
||||
// 단일 경비함정인지 확인 (반경설정 메뉴 표시 조건)
|
||||
const isSinglePatrolShip = ships.length === 1 && isPatrolShip(ships[0].originalTargetId);
|
||||
|
||||
// 표시할 메뉴 항목 필터링
|
||||
const visibleMenuItems = MENU_ITEMS.filter((item) => {
|
||||
if (item.key === 'radius') return isSinglePatrolShip;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 화면 밖 넘침 방지
|
||||
const menuWidth = 160;
|
||||
const menuHeight = MENU_ITEMS.length * 36 + 40; // 항목 + 헤더
|
||||
const menuHeight = visibleMenuItems.length * 36 + 40; // 항목 + 헤더
|
||||
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
||||
const adjustedY = y + menuHeight > window.innerHeight ? y - menuHeight : y;
|
||||
|
||||
// 서브메뉴 위치 (오른쪽 또는 왼쪽)
|
||||
const submenuOnLeft = adjustedX + menuWidth + 120 > window.innerWidth;
|
||||
|
||||
const title = ships.length === 1
|
||||
? (ships[0].shipName || ships[0].featureId)
|
||||
: `${ships.length}척 선택`;
|
||||
@ -68,13 +212,37 @@ export default function ShipContextMenu() {
|
||||
style={{ left: adjustedX, top: adjustedY }}
|
||||
>
|
||||
<div className="ship-context-menu__header">{title}</div>
|
||||
{MENU_ITEMS.map((item) => (
|
||||
{visibleMenuItems.map((item, index) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="ship-context-menu__item"
|
||||
className={`ship-context-menu__item ${item.hasSubmenu ? 'has-submenu' : ''}`}
|
||||
onClick={() => handleAction(item.key)}
|
||||
onMouseEnter={() => setHoveredItem(item.key)}
|
||||
onMouseLeave={() => setHoveredItem(null)}
|
||||
>
|
||||
{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>
|
||||
|
||||
@ -20,15 +20,63 @@
|
||||
}
|
||||
|
||||
&__item {
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
&:hover {
|
||||
background: #2a2a4e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.has-submenu {
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(0, 150, 255, 0.3);
|
||||
color: #fff;
|
||||
|
||||
&::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
font-size: 10px;
|
||||
color: #00a8ff;
|
||||
}
|
||||
}
|
||||
|
||||
.submenu-arrow {
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__submenu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #3a3a5e;
|
||||
border-radius: 4px;
|
||||
min-width: 100px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1001;
|
||||
|
||||
&.right {
|
||||
left: 100%;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
&.left {
|
||||
right: 100%;
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
* - 선박 종류별 필터 토글
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import useShipStore from '../../stores/shipStore';
|
||||
import {
|
||||
SIGNAL_KIND_CODE_FISHING,
|
||||
@ -84,15 +85,21 @@ const LegendItem = memo(({ code, label, count, icon, isVisible, onToggle }) => {
|
||||
* 선박 범례 컴포넌트
|
||||
*/
|
||||
const ShipLegend = memo(() => {
|
||||
const {
|
||||
kindCounts,
|
||||
kindVisibility,
|
||||
isShipVisible,
|
||||
totalCount,
|
||||
isConnected,
|
||||
toggleKindVisibility,
|
||||
toggleShipVisible,
|
||||
} = useShipStore();
|
||||
// 셀렉터 사용: 구독 중인 값이 실제로 바뀔 때만 리렌더
|
||||
// useShipStore() 전체 구독 → featuresVersion 변경마다 리렌더되는 문제 방지
|
||||
const { kindCounts, kindVisibility, isShipVisible, totalCount, isConnected } =
|
||||
useShipStore(
|
||||
(state) => ({
|
||||
kindCounts: state.kindCounts,
|
||||
kindVisibility: state.kindVisibility,
|
||||
isShipVisible: state.isShipVisible,
|
||||
totalCount: state.totalCount,
|
||||
isConnected: state.isConnected,
|
||||
}),
|
||||
shallow
|
||||
);
|
||||
const toggleKindVisibility = useShipStore((state) => state.toggleKindVisibility);
|
||||
const toggleShipVisible = useShipStore((state) => state.toggleShipVisible);
|
||||
|
||||
return (
|
||||
<article className="ship-legend">
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
* - OpenLayers 맵과 Deck.gl 레이어 통합
|
||||
* - 배치 렌더러 기반 최적화된 렌더링
|
||||
* - 선박 데이터 변경 시 레이어 업데이트
|
||||
* - 항적 레이어: 정적(경로/포인트) 캐싱 + 동적(가상선박) 경량 갱신
|
||||
*
|
||||
* 참조: mda-react-front/src/common/deck.ts
|
||||
*/
|
||||
@ -11,6 +12,10 @@ import { Deck } from '@deck.gl/core';
|
||||
import { toLonLat } from 'ol/proj';
|
||||
import { createShipLayers, clearClusterCache } from '../map/layers/shipLayer';
|
||||
import useShipStore from '../stores/shipStore';
|
||||
import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
|
||||
import useTrackingModeStore from '../stores/trackingModeStore';
|
||||
import { getTrackQueryLayers } from '../tracking/utils/trackQueryLayerUtils';
|
||||
import { getReplayLayers } from '../replay/utils/replayLayerRegistry';
|
||||
import { shipBatchRenderer } from '../map/ShipBatchRenderer';
|
||||
|
||||
/**
|
||||
@ -27,13 +32,15 @@ export default function useShipLayer(map) {
|
||||
const getSelectedShips = useShipStore((s) => s.getSelectedShips);
|
||||
const isShipVisible = useShipStore((s) => s.isShipVisible);
|
||||
|
||||
// 마지막 선박 레이어: 캐시용
|
||||
const lastShipLayersRef = useRef([]);
|
||||
|
||||
/**
|
||||
* Deck.gl 인스턴스 초기화
|
||||
*/
|
||||
const initDeck = useCallback((container) => {
|
||||
if (deckRef.current) return;
|
||||
|
||||
// Canvas 엘리먼트 생성
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = 'deck-canvas';
|
||||
canvas.style.position = 'absolute';
|
||||
@ -46,7 +53,6 @@ export default function useShipLayer(map) {
|
||||
container.appendChild(canvas);
|
||||
canvasRef.current = canvas;
|
||||
|
||||
// Deck.gl 인스턴스 생성
|
||||
deckRef.current = new Deck({
|
||||
canvas,
|
||||
controller: false,
|
||||
@ -77,7 +83,7 @@ export default function useShipLayer(map) {
|
||||
viewState: {
|
||||
longitude: lon,
|
||||
latitude: lat,
|
||||
zoom: zoom - 1, // OpenLayers와 Deck.gl 줌 레벨 차이 보정
|
||||
zoom: zoom - 1,
|
||||
bearing: (-rotation * 180) / Math.PI,
|
||||
pitch: 0,
|
||||
},
|
||||
@ -95,7 +101,6 @@ export default function useShipLayer(map) {
|
||||
if (!size) return null;
|
||||
|
||||
const extent = view.calculateExtent(size);
|
||||
// OpenLayers 좌표를 경위도로 변환
|
||||
const [minX, minY, maxX, maxY] = extent;
|
||||
const [minLon, minLat] = toLonLat([minX, minY]);
|
||||
const [maxLon, maxLat] = toLonLat([maxX, maxY]);
|
||||
@ -104,9 +109,7 @@ export default function useShipLayer(map) {
|
||||
}, [map]);
|
||||
|
||||
/**
|
||||
* 배치 렌더러 콜백 - 필터링된 선박으로 레이어 업데이트
|
||||
* @param {Array} ships - 밀도 제한 적용된 선박 (아이콘 + 라벨 공통)
|
||||
* @param {number} trigger - 렌더링 트리거
|
||||
* 배치 렌더러 콜백 - 선박 레이어 생성 + 캐싱된 항적 레이어 병합
|
||||
*/
|
||||
const handleBatchRender = useCallback((ships, trigger) => {
|
||||
if (!deckRef.current || !map) return;
|
||||
@ -115,15 +118,29 @@ export default function useShipLayer(map) {
|
||||
const zoom = view.getZoom() || 7;
|
||||
const selectedShips = getSelectedShips();
|
||||
|
||||
// 현재 스토어에서 showLabels, labelOptions, isIntegrate, darkSignalIds 가져오기
|
||||
const { showLabels: currentShowLabels, labelOptions: currentLabelOptions, isIntegrate: currentIsIntegrate, darkSignalIds } = useShipStore.getState();
|
||||
|
||||
// 레이어 생성 (밀도 제한 적용된 선박 = 아이콘 + 라벨 공통)
|
||||
// 아이콘이 표시되는 선박에만 라벨/신호상태도 표시
|
||||
const layers = createShipLayers(ships, selectedShips, zoom, currentShowLabels, currentLabelOptions, currentIsIntegrate, trigger, darkSignalIds);
|
||||
// 라이브 선박 숨김 상태 확인
|
||||
const { hideLiveShips } = useTrackQueryStore.getState();
|
||||
|
||||
// Deck.gl 레이어 업데이트
|
||||
deckRef.current.setProps({ layers });
|
||||
// 선박 레이어 생성 (hideLiveShips일 때는 빈 배열)
|
||||
const shipLayers = hideLiveShips
|
||||
? []
|
||||
: createShipLayers(ships, selectedShips, zoom, currentShowLabels, currentLabelOptions, currentIsIntegrate, trigger, darkSignalIds);
|
||||
|
||||
// 선박 레이어 캐시
|
||||
lastShipLayersRef.current = shipLayers;
|
||||
|
||||
// 항적 레이어 (tracking 패키지 전역 레지스트리에서 가져옴)
|
||||
const trackLayers = getTrackQueryLayers();
|
||||
|
||||
// 리플레이 레이어 (전역 레지스트리)
|
||||
const replayLayers = getReplayLayers();
|
||||
|
||||
// 병합: 선박 + 항적 + 리플레이 레이어
|
||||
deckRef.current.setProps({
|
||||
layers: [...shipLayers, ...trackLayers, ...replayLayers],
|
||||
});
|
||||
}, [map, getSelectedShips]);
|
||||
|
||||
/**
|
||||
@ -133,28 +150,23 @@ export default function useShipLayer(map) {
|
||||
if (!deckRef.current || !map) return;
|
||||
|
||||
if (!isShipVisible) {
|
||||
// 선박 표시 Off
|
||||
deckRef.current.setProps({ layers: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
// 줌 레벨 업데이트 (렌더링 간격 조정용)
|
||||
const view = map.getView();
|
||||
const zoom = view.getZoom() || 10;
|
||||
const zoomIntChanged = shipBatchRenderer.setZoom(zoom);
|
||||
|
||||
// 뷰포트 범위 업데이트
|
||||
const bounds = getViewportBounds();
|
||||
shipBatchRenderer.setViewportBounds(bounds);
|
||||
|
||||
// 줌 정수 레벨이 변경되면 클러스터 캐시 클리어 + 즉시 렌더링
|
||||
if (zoomIntChanged) {
|
||||
clearClusterCache();
|
||||
shipBatchRenderer.immediateRender();
|
||||
return;
|
||||
}
|
||||
|
||||
// 배치 렌더러에 렌더링 요청
|
||||
shipBatchRenderer.requestRender();
|
||||
}, [map, isShipVisible, getViewportBounds]);
|
||||
|
||||
@ -171,21 +183,15 @@ export default function useShipLayer(map) {
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
// 맵 컨테이너에 Deck.gl 캔버스 추가
|
||||
const viewport = map.getViewport();
|
||||
initDeck(viewport);
|
||||
|
||||
// 배치 렌더러 초기화 (1회만)
|
||||
if (!batchRendererInitialized.current) {
|
||||
shipBatchRenderer.initialize(handleBatchRender);
|
||||
batchRendererInitialized.current = true;
|
||||
}
|
||||
|
||||
// 맵 이동/줌 시 동기화
|
||||
const handleMoveEnd = () => {
|
||||
render();
|
||||
};
|
||||
|
||||
const handleMoveEnd = () => { render(); };
|
||||
const handlePostRender = () => {
|
||||
syncViewState();
|
||||
deckRef.current?.redraw();
|
||||
@ -194,12 +200,8 @@ export default function useShipLayer(map) {
|
||||
map.on('moveend', handleMoveEnd);
|
||||
map.on('postrender', handlePostRender);
|
||||
|
||||
// 초기 렌더링
|
||||
setTimeout(() => {
|
||||
render();
|
||||
}, 100);
|
||||
setTimeout(() => { render(); }, 100);
|
||||
|
||||
// 클린업
|
||||
return () => {
|
||||
map.un('moveend', handleMoveEnd);
|
||||
map.un('postrender', handlePostRender);
|
||||
@ -218,20 +220,16 @@ export default function useShipLayer(map) {
|
||||
canvasRef.current = null;
|
||||
}
|
||||
|
||||
// 배치 렌더러 정리
|
||||
shipBatchRenderer.dispose();
|
||||
batchRendererInitialized.current = false;
|
||||
};
|
||||
}, [map, initDeck, render, syncViewState, handleBatchRender]);
|
||||
|
||||
// 선박 데이터 변경 시 레이어 업데이트
|
||||
// ※ immutable 패턴: features/darkSignalIds 참조로 변경 감지
|
||||
useEffect(() => {
|
||||
// 스토어 구독하여 변경 감지
|
||||
const unsubscribe = useShipStore.subscribe(
|
||||
(state) => [state.features, state.kindVisibility, state.sourceVisibility, state.isShipVisible, state.detailModals, state.isIntegrate, state.nationalVisibility, state.showLabels, state.labelOptions, state.darkSignalVisible, state.darkSignalIds],
|
||||
(current, prev) => {
|
||||
// 필터 변경 감지 (kindVisibility, sourceVisibility, isShipVisible, isIntegrate, nationalVisibility, showLabels, labelOptions, darkSignalVisible, darkSignalIds)
|
||||
const filterChanged =
|
||||
current[1] !== prev[1] ||
|
||||
current[2] !== prev[2] ||
|
||||
@ -244,27 +242,56 @@ export default function useShipLayer(map) {
|
||||
current[10] !== prev[10];
|
||||
|
||||
if (filterChanged) {
|
||||
// 필터/선명표시 변경 시 즉시 렌더링 (사용자 인터랙션 응답성)
|
||||
shipBatchRenderer.clearCache();
|
||||
clearClusterCache();
|
||||
shipBatchRenderer.immediateRender();
|
||||
return; // 즉시 렌더링 후 추가 처리 불필요
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 변경 시 배치 렌더러에 렌더링 요청만 전달 (적응형 주기 적용)
|
||||
// ※ redraw() 호출 제거: 배치 렌더러가 executeRender → setProps({ layers }) 시
|
||||
// deck.gl이 자동으로 repaint하므로 별도 redraw() 불필요.
|
||||
// 매 features 참조 변경(~1초)마다 redraw() 호출 시 불필요한 repaint 발생.
|
||||
updateLayers();
|
||||
},
|
||||
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
return () => { unsubscribe(); };
|
||||
}, [updateLayers]);
|
||||
|
||||
// === trackQueryStore 변경 시 선박 레이어 리렌더 ===
|
||||
// tracking 패키지의 TrackQueryViewer가 레이어를 전역 레지스트리에 등록하면
|
||||
// 여기서 shipBatchRenderer를 트리거하여 deck.gl에 반영
|
||||
useEffect(() => {
|
||||
const unsubscribe = useTrackQueryStore.subscribe(
|
||||
(state) => [state.tracks, state.currentTime, state.showPoints, state.showVirtualShip, state.showLabels, state.disabledVesselIds, state.hideLiveShips],
|
||||
() => {
|
||||
if (deckRef.current && map) {
|
||||
shipBatchRenderer.requestRender();
|
||||
}
|
||||
},
|
||||
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
|
||||
);
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [map]);
|
||||
|
||||
// === trackingModeStore 변경 시 선박 레이어 리렌더 ===
|
||||
// 반경 필터링 상태가 변경되면 즉시 렌더링
|
||||
useEffect(() => {
|
||||
const unsubscribe = useTrackingModeStore.subscribe(
|
||||
(state) => [state.mode, state.trackedShipId, state.trackedShip, state.radiusNM],
|
||||
() => {
|
||||
if (deckRef.current && map) {
|
||||
// 반경/추적 모드 변경은 필터 변경이므로 캐시 클리어 후 즉시 렌더링
|
||||
shipBatchRenderer.clearCache();
|
||||
clearClusterCache();
|
||||
shipBatchRenderer.immediateRender();
|
||||
}
|
||||
},
|
||||
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
|
||||
);
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [map]);
|
||||
|
||||
return {
|
||||
deckCanvas: canvasRef.current,
|
||||
deckRef,
|
||||
|
||||
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,
|
||||
SIGNAL_SOURCE_RADAR,
|
||||
} from '../stores/shipStore';
|
||||
import useTrackingModeStore, { isWithinRadius, NM_TO_METERS } from '../stores/trackingModeStore';
|
||||
import {
|
||||
SIGNAL_KIND_CODE_FISHING,
|
||||
SIGNAL_KIND_CODE_KCGV,
|
||||
@ -341,6 +342,77 @@ function filterByViewport(ships, bounds) {
|
||||
});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 반경 필터링 (추적 모드)
|
||||
// =====================
|
||||
const LON_DEGREE_METERS = 91000; // 중위도(35도) 기준 경도 1도당 약 91km
|
||||
const LAT_DEGREE_METERS = 111000; // 위도 1도당 약 111km
|
||||
|
||||
/**
|
||||
* 반경 필터 상태 가져오기
|
||||
* @returns {Object} { isActive, center, radiusNM, boundingBox }
|
||||
*/
|
||||
function getRadiusFilterState() {
|
||||
const state = useTrackingModeStore.getState();
|
||||
const { mode, trackedShip, radiusNM } = state;
|
||||
|
||||
const isActive = mode === 'ship' && trackedShip !== null;
|
||||
|
||||
if (!isActive || !trackedShip || !trackedShip.longitude || !trackedShip.latitude) {
|
||||
return { isActive: false, center: null, radiusNM: 0, boundingBox: null };
|
||||
}
|
||||
|
||||
const center = { lon: trackedShip.longitude, lat: trackedShip.latitude };
|
||||
|
||||
// Bounding Box 계산 (빠른 사전 필터용)
|
||||
const radiusMeters = radiusNM * NM_TO_METERS;
|
||||
const lonDelta = radiusMeters / LON_DEGREE_METERS;
|
||||
const latDelta = radiusMeters / LAT_DEGREE_METERS;
|
||||
|
||||
const boundingBox = {
|
||||
minLon: center.lon - lonDelta,
|
||||
maxLon: center.lon + lonDelta,
|
||||
minLat: center.lat - latDelta,
|
||||
maxLat: center.lat + latDelta,
|
||||
};
|
||||
|
||||
return { isActive: true, center, radiusNM, boundingBox };
|
||||
}
|
||||
|
||||
/**
|
||||
* 반경 필터링 적용
|
||||
* - 1단계: Bounding Box 체크 (빠른 사각형 체크)
|
||||
* - 2단계: Haversine 거리 계산 (정확한 원형 체크)
|
||||
*
|
||||
* @param {Array} ships - 선박 배열
|
||||
* @param {Object} radiusState - 반경 필터 상태
|
||||
* @returns {Array} 반경 내 선박 배열
|
||||
*/
|
||||
function filterByRadius(ships, radiusState) {
|
||||
const { isActive, center, radiusNM, boundingBox } = radiusState;
|
||||
|
||||
if (!isActive || !center || !boundingBox) {
|
||||
return ships;
|
||||
}
|
||||
|
||||
return ships.filter((ship) => {
|
||||
if (!ship.longitude || !ship.latitude) return false;
|
||||
|
||||
// 1단계: Bounding Box 체크
|
||||
if (
|
||||
ship.longitude < boundingBox.minLon ||
|
||||
ship.longitude > boundingBox.maxLon ||
|
||||
ship.latitude < boundingBox.minLat ||
|
||||
ship.latitude > boundingBox.maxLat
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2단계: 정확한 원형 거리 체크
|
||||
return isWithinRadius(ship, center.lon, center.lat, radiusNM);
|
||||
});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 카운트 + 타임아웃 cleanup 통합 함수
|
||||
// 참조: mda-react-front/src/common/deck.ts (271-471)
|
||||
@ -376,6 +448,9 @@ function calculateAndCleanupLiveShips() {
|
||||
const { features, darkSignalIds, isIntegrate,
|
||||
kindVisibility, sourceVisibility, nationalVisibility } = state;
|
||||
|
||||
// 반경 필터 상태
|
||||
const radiusState = getRadiusFilterState();
|
||||
|
||||
const kindCounts = { ...initialKindCounts };
|
||||
let darkSignalCount = 0;
|
||||
const deleteIds = [];
|
||||
@ -392,6 +467,17 @@ function calculateAndCleanupLiveShips() {
|
||||
features.forEach((ship, featureId) => {
|
||||
// ① 이미 다크시그널 → 카운트만, 즉시 리턴
|
||||
if (darkSignalIds.has(featureId)) {
|
||||
// 반경 필터가 활성화된 경우, 반경 내에 있을 때만 카운트
|
||||
if (radiusState.isActive) {
|
||||
if (!ship.longitude || !ship.latitude) return;
|
||||
const inBounds = ship.longitude >= radiusState.boundingBox.minLon &&
|
||||
ship.longitude <= radiusState.boundingBox.maxLon &&
|
||||
ship.latitude >= radiusState.boundingBox.minLat &&
|
||||
ship.latitude <= radiusState.boundingBox.maxLat;
|
||||
if (!inBounds || !isWithinRadius(ship, radiusState.center.lon, radiusState.center.lat, radiusState.radiusNM)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
darkSignalCount++;
|
||||
return;
|
||||
}
|
||||
@ -426,7 +512,19 @@ function calculateAndCleanupLiveShips() {
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ 카운트 대상
|
||||
// ⑥ 반경 필터 체크 (카운트 전)
|
||||
if (radiusState.isActive) {
|
||||
if (!ship.longitude || !ship.latitude) return;
|
||||
const inBounds = ship.longitude >= radiusState.boundingBox.minLon &&
|
||||
ship.longitude <= radiusState.boundingBox.maxLon &&
|
||||
ship.latitude >= radiusState.boundingBox.minLat &&
|
||||
ship.latitude <= radiusState.boundingBox.maxLat;
|
||||
if (!inBounds || !isWithinRadius(ship, radiusState.center.lon, radiusState.center.lat, radiusState.radiusNM)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ⑦ 카운트 대상
|
||||
const targetId = ship.targetId;
|
||||
const isIntegratedShip = targetId && (targetId.includes('_') || ship.integrate);
|
||||
|
||||
@ -620,8 +718,12 @@ class ShipBatchRenderer {
|
||||
? filterByViewport(allShips, this.viewportBounds)
|
||||
: allShips;
|
||||
|
||||
// 3.5 반경 필터링 (추적 모드 활성화 시)
|
||||
const radiusState = getRadiusFilterState();
|
||||
const radiusFilteredShips = filterByRadius(viewportShips, radiusState);
|
||||
|
||||
// 4. 필터 적용 (캐시된 필터 사용 - O(1) lookup)
|
||||
const filteredShips = viewportShips.filter((ship) =>
|
||||
const filteredShips = radiusFilteredShips.filter((ship) =>
|
||||
applyFilterWithCache(ship, this.cache.filterCache)
|
||||
);
|
||||
|
||||
|
||||
@ -14,10 +14,20 @@ import {
|
||||
SIGNAL_FLAG_CONFIGS,
|
||||
} from '../../types/constants';
|
||||
import useShipStore from '../../stores/shipStore';
|
||||
import useTrackingModeStore from '../../stores/trackingModeStore';
|
||||
|
||||
// 아이콘 아틀라스 이미지
|
||||
import atlasImg from '../../assets/img/icon/atlas.png';
|
||||
|
||||
// 추적 선박 아이콘 (인라인 SVG data URL)
|
||||
const TRACKED_SHIP_SVG = `<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
|
||||
@ -1033,13 +1122,19 @@ export function createShipLayers(ships, selectedShips, zoom, showLabels = false,
|
||||
// 2. 선박 아이콘 레이어 (밀도 제한 적용된 전체 선박)
|
||||
layers.push(createShipIconLayer(ships, zoom, darkSignalIds));
|
||||
|
||||
// 3. 선명표시 레이어들 (밀도 제한된 선박 대상 → 자체 클러스터링)
|
||||
// 3. 추적 선박 레이어 (최상단 - 다른 아이콘 위에 표시)
|
||||
const trackedLayers = createTrackedShipLayers(zoom);
|
||||
if (trackedLayers.length > 0) {
|
||||
layers.push(...trackedLayers);
|
||||
}
|
||||
|
||||
// 4. 선명표시 레이어들 (밀도 제한된 선박 대상 → 자체 클러스터링)
|
||||
// 아이콘이 표시되는 선박에만 라벨/신호상태 표시
|
||||
if (showLabels) {
|
||||
// 라벨 클러스터링 (밀도 제한된 ships 대상)
|
||||
const clusteredShips = getClusteredShips(ships, zoom, isIntegrate, renderTrigger);
|
||||
|
||||
// 3-1. 속도벡터 레이어
|
||||
// 4-1. 속도벡터 레이어
|
||||
if (labelOptions.showSpeedVector) {
|
||||
const vectorLayer = createSpeedVectorLayer(clusteredShips, zoom);
|
||||
if (vectorLayer) {
|
||||
@ -1047,13 +1142,13 @@ export function createShipLayers(ships, selectedShips, zoom, showLabels = false,
|
||||
}
|
||||
}
|
||||
|
||||
// 3-2. 선박명 레이어
|
||||
// 4-2. 선박명 레이어
|
||||
const labelLayer = createShipLabelLayer(clusteredShips, zoom, labelOptions);
|
||||
if (labelLayer) {
|
||||
layers.push(labelLayer);
|
||||
}
|
||||
|
||||
// 3-3. 신호상태 레이어 (별도 클러스터링)
|
||||
// 4-3. 신호상태 레이어 (별도 클러스터링)
|
||||
if (labelOptions.showSignalStatus) {
|
||||
// 밀도 제한된 ships 대상으로 신호상태 클러스터링
|
||||
const signalClusteredShips = getSignalClusteredShips(ships, zoom, isIntegrate, renderTrigger);
|
||||
@ -1063,7 +1158,7 @@ export function createShipLayers(ships, selectedShips, zoom, showLabels = false,
|
||||
}
|
||||
}
|
||||
|
||||
// 3-4. 선박크기 폴리곤 레이어 (줌 11 이상에서만)
|
||||
// 4-4. 선박크기 폴리곤 레이어 (줌 11 이상에서만)
|
||||
// 뷰포트 내 선박만 대상으로 렌더링 (성능 최적화)
|
||||
if (labelOptions.showShipSize) {
|
||||
const dimLayer = createShipDimLayer(clusteredShips, zoom);
|
||||
|
||||
@ -1,186 +1,18 @@
|
||||
|
||||
@charset "utf-8";
|
||||
|
||||
/**
|
||||
* MainComponent 스타일
|
||||
* 메인 영역 레이아웃
|
||||
*
|
||||
* 참고: TopBar 스타일은 src/components/map/TopBar.scss로 분리됨
|
||||
*/
|
||||
|
||||
#wrap {
|
||||
//* main */
|
||||
#main {
|
||||
width: 100%;
|
||||
height: calc(100% - 4.4rem);
|
||||
position: relative;
|
||||
|
||||
//* top-bar */
|
||||
.topBar {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 5.4rem;
|
||||
box-sizing: border-box;
|
||||
z-index: 95;
|
||||
}
|
||||
|
||||
//* top-location */
|
||||
.locationInfo {
|
||||
flex: 0 0 auto;
|
||||
min-width: 71rem;
|
||||
height: 3.8rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: rgba(var(--secondary6-rgb), .9);
|
||||
overflow: hidden;
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 1rem;
|
||||
color: var(--white);
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: 1.3rem;
|
||||
|
||||
&:first-child,
|
||||
&:last-child {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
span + span {
|
||||
padding-right: .5rem;
|
||||
}
|
||||
|
||||
&.divider {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 1px;
|
||||
height: 1.8rem;
|
||||
background-color: rgba(255, 255, 255, .3);
|
||||
}
|
||||
}
|
||||
|
||||
.wgs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: .5rem;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
margin-right: .5rem;
|
||||
background: url(../assets/images/ico_globe.svg) no-repeat center / 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.kst {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.1rem;
|
||||
height: 1.8rem;
|
||||
margin-right: .5rem;
|
||||
border-radius: .3rem;
|
||||
background-color: var(--tertiary3);
|
||||
color: var(--white);
|
||||
font-size: var(--fs-s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
width: 4rem;
|
||||
height: 3.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--secondary3);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 3rem;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background-color: rgba(var(--primary1-rgb), .8);
|
||||
}
|
||||
|
||||
&.map {
|
||||
background-image: url(../assets/images/ico_map.svg);
|
||||
}
|
||||
|
||||
&.ship {
|
||||
background-image: url(../assets/images/ico_ship.svg);
|
||||
}
|
||||
|
||||
&.set {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-color: transparent;
|
||||
background-image: url(../assets/images/ico_set_s.svg);
|
||||
background-size: 2rem;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//* top-search */
|
||||
.topSchBox {
|
||||
flex: 0 0 auto;
|
||||
width: 20rem;
|
||||
height: 3.8rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
position: relative;
|
||||
|
||||
.tschInput {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-right: 4.4rem;
|
||||
border-radius: 2rem;
|
||||
background-color: rgba(var(--secondary6-rgb), .9);
|
||||
border: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
.mainSchBtn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
width: 4rem;
|
||||
height: 3.8rem;
|
||||
font-size: 0;
|
||||
text-indent: -999999em;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: url(../assets/images/ico_search_main.svg) no-repeat center left / 2.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
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