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