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:
HeungTak Lee 2026-02-04 08:16:29 +09:00
부모 519f3b3fe2
커밋 8292251758
14개의 변경된 파일2531개의 추가작업 그리고 342개의 파일을 삭제

223
CLAUDE.md
파일 보기

@ -1,5 +1,8 @@
# CLAUDE.md - GIS 함정용 프로젝트 가이드 # CLAUDE.md - GIS 함정용 프로젝트 가이드
## 응답 규칙
- **모든 응답은 반드시 한글로 작성한다.**
## 프로젝트 개요 ## 프로젝트 개요
| 항목 | 내용 | | 항목 | 내용 |
@ -7,7 +10,7 @@
| 프로젝트명 | dark (GIS 함정용) | | 프로젝트명 | dark (GIS 함정용) |
| 참조 프로젝트 | mda-react-front (메인 프로젝트) | | 참조 프로젝트 | mda-react-front (메인 프로젝트) |
| 목적 | 선박위치정보 전시 및 조회 기능 프론트엔드 | | 목적 | 선박위치정보 전시 및 조회 기능 프론트엔드 |
| 현재 단계 | 퍼블리싱 → 구현 전환 | | 현재 단계 | Phase 6 진행 중 - 리플레이 기능 구현 완료 |
--- ---
@ -29,93 +32,74 @@
## 현재 프로젝트(dark) 기술 스택 ## 현재 프로젝트(dark) 기술 스택
| 항목 | 기술 | 변경 계획 | | 항목 | 기술 |
|------|------|----------| |------|------|
| 번들러 | CRA (react-scripts) | Vite 마이그레이션 검토 | | 번들러 | Vite 5.2.10 |
| 언어 | JavaScript | TypeScript 도입 검토 | | 언어 | JavaScript (TypeScript 도입 검토) |
| 라우팅 | React Router 6.30.3 | 유지 | | UI | React 18.2.0 |
| 상태관리 | React useState | Zustand 도입 | | 라우팅 | React Router 6.30.3 |
| 스타일 | SCSS | 유지 | | 상태관리 | Zustand 4.5.2 (subscribeWithSelector) |
| 지도 | 미연동 | OpenLayers + Deck.gl | | 지도 엔진 | OpenLayers 9.2.4 |
| 선박 렌더링 | Deck.gl 9.2.6 (core, layers, extensions) |
| 실시간 통신 | @stomp/stompjs (STOMP WebSocket) |
| HTTP | Axios 1.4.0 |
| 스타일 | SCSS |
--- ---
## 프로젝트 구조 설계 ## 프로젝트 구조
``` ```
src/ src/
├── index.js # 앱 엔트리 포인트 ├── index.js # 앱 엔트리 포인트
├── App.jsx # 라우트 정의 ├── App.jsx # 라우트 정의
├── publish/ # [퍼블리싱 영역] - 기존 퍼블리시 파일 ├── publish/ # [퍼블리싱 원본] - 직접 수정 금지, 참조용
│ ├── layouts/ # 퍼블리시 레이아웃
│ ├── pages/ # 퍼블리시 페이지 (Panel1~8 등)
│ └── components/ # 퍼블리시 컴포넌트
├── pages/ # [구현 영역] - 실제 페이지 ├── pages/ # [구현 페이지]
│ ├── Home.jsx # 메인 페이지 │ └── HomePage.jsx # 메인 페이지 (지도 + 레이아웃)
│ ├── ship/ # 선박 관련 페이지
│ ├── satellite/ # 위성 관련 페이지
│ ├── weather/ # 기상 관련 페이지
│ └── analysis/ # 분석 관련 페이지
├── components/ # [공통 컴포넌트] ├── components/
│ ├── common/ # 기본 UI 컴포넌트 │ ├── layout/ # Header, Sidebar, ToolBar, MainLayout, SideNav
│ │ ├── Button/ │ ├── ship/ # ShipLegend, ShipDetailModal, ShipContextMenu, TrackQueryModal
│ │ ├── Input/ │ └── map/ # TopBar (좌표/시간표시, 검색), PatrolShipSelector (함정선택)
│ │ ├── Modal/
│ │ └── ...
│ ├── domain/ # 도메인별 컴포넌트
│ │ ├── ship/ # 선박 관련
│ │ ├── map/ # 지도 관련
│ │ └── ...
│ └── layout/ # 레이아웃 컴포넌트
│ ├── Header/
│ ├── Sidebar/
│ └── ...
├── map/ # [지도 모듈] ├── map/ # [지도 모듈]
│ ├── MapContext.jsx # OpenLayers 맵 Context │ ├── MapContainer.jsx # OpenLayers + Deck.gl 통합 (window.__mainMap__)
│ ├── MapProvider.jsx # 맵 Provider │ ├── ShipBatchRenderer.js # 배치 렌더러 (적응형 렌더링, 필터 캐시)
│ ├── layers/ # 레이어 정의 │ └── layers/
│ │ ├── baseLayer.js # 베이스맵 │ ├── baseLayer.js # 베이스맵 (worldMap, eastAsiaMap, korMap)
│ │ ├── shipLayer.js # 선박 레이어 │ ├── shipLayer.js # Deck.gl 선박 레이어 (아이콘, 라벨, 벡터, 신호상태)
│ │ └── ... │ └── trackLayer.js # 항적 레이어
│ ├── controls/ # 지도 컨트롤
│ └── utils/ # 지도 유틸리티
├── stores/ # [Zustand 스토어] ├── stores/ # [Zustand 스토어]
│ ├── mapStore.js # 지도 상태 │ ├── shipStore.js # 선박 데이터 (핵심 - mutable Map/Set + 버전 카운터)
│ ├── shipStore.js # 선박 상태 │ ├── mapStore.js # 지도 상태
│ ├── filterStore.js # 필터 상태 │ ├── trackStore.js # 항적 상태
│ └── uiStore.js # UI 상태 │ └── trackingModeStore.js # 추적 모드 (지도/선박 모드, 반경 설정)
├── api/ # [API 레이어] ├── tracking/ # [항적조회 패키지] - 메인 프로젝트 TS→JS 변환
│ ├── client.js # API 클라이언트 │ ├── stores/ # trackQueryStore, trackQueryAnimationStore
│ ├── ship.js # 선박 API │ ├── services/ # trackQueryApi (API + 통합선박 처리)
│ └── ... │ ├── components/ # TrackQueryViewer, TrackQueryTimeline, GlobalTrackQueryViewer
│ ├── hooks/ # useEquipmentFilter, useTrackHighlight
│ ├── utils/ # trackQueryLayerUtils, TrackQueryBatchRenderer, shipIconUtil
│ └── types/ # trackQuery.types.js
├── hooks/ # [커스텀 훅] ├── replay/ # [리플레이 패키지] - 메인 프로젝트 TS→JS 변환
│ ├── useMap.js │ ├── stores/ # replayStore, animationStore, mergedTrackStore, playbackTrailStore
│ ├── useShip.js │ ├── services/ # ReplayWebSocketService (WebSocket 청크 수신)
│ └── ... │ ├── components/ # ReplayTimeline, ReplayControlV2, VesselListManager
│ ├── hooks/ # useReplayLayer (Deck.gl 레이어 관리)
│ ├── utils/ # replayLayerRegistry
│ └── types/ # replay.types.js
├── utils/ # [유틸리티] ├── hooks/ # useShipData, useShipLayer, useShipSearch, useTrackingMode, useRadiusFilter
│ ├── format.js ├── api/ # signalApi
│ ├── coordinate.js ├── common/ # stompClient (STOMP WebSocket)
│ └── ... ├── types/ # constants.js (신호원/선종/플래그 상수)
├── assets/ # 이미지, 아이콘 아틀라스
├── types/ # [타입 정의] (TS 도입 시) └── scss/ # 스타일
│ └── ...
├── assets/ # [정적 자원]
│ ├── images/
│ └── ...
└── scss/ # [스타일]
├── base/
├── components/
└── pages/
``` ```
--- ---
@ -147,31 +131,66 @@ src/
## 기능 구현 로드맵 ## 기능 구현 로드맵
### Phase 1: 기반 구축 ### Phase 1: 기반 구축 (01-26 완료)
- [ ] 프로젝트 구조 재편 (퍼블리시/구현 분리) - [x] CRA → Vite 마이그레이션
- [ ] OpenLayers 연동 (베이스맵) - [x] 프로젝트 구조 재편 (퍼블리시/구현 분리)
- [ ] Zustand 스토어 설정 - [x] Zustand 스토어 설정 (mapStore, shipStore)
- [ ] 기본 레이아웃 구현 - [x] 기본 레이아웃 구현 (MainLayout, Header, Sidebar, ToolBar)
### Phase 2: 지도 핵심 기능 ### Phase 2: 지도 및 선박 표시 (01-26 완료)
- [ ] 지도 컨트롤 (줌, 패닝) - [x] OpenLayers 맵 컨테이너 + 베이스맵 레이어
- [ ] 레이어 관리 (토글, 순서) - [x] STOMP WebSocket 클라이언트
- [ ] 좌표 표시 - [x] Deck.gl 선박 아이콘 레이어 (IconLayer + 아틀라스)
- [ ] 축척 표시 - [x] 선박 범례 컴포넌트 (ShipLegend)
### Phase 3: 선박 표시 ### Phase 3: 선박 레이어 고도화 (01-27 완료)
- [ ] Deck.gl 연동 - [x] 신호상태(AVETDR) 아이콘, 속도벡터 좌표 투영
- [ ] 선박 아이콘 렌더링 - [x] 배치 렌더러 최적화 (ShipBatchRenderer)
- [ ] 선박 클릭/호버 이벤트 - [x] 선명표시 클러스터링, 선박통합 ON/OFF
- [ ] 선박 정보 팝업
### Phase 4: 데이터 연동 ### Phase 4: shipStore 성능 최적화 + 카운트 동기화 (01-28~30 완료)
- [ ] API 클라이언트 설정 - [x] Map/Set mutable update + 버전 카운터 패턴
- [ ] 선박 데이터 조회 - [x] 카운트 5초 쓰로틀 + targetId 중복 제거
- [ ] 필터링 기능 - [x] 레이더 카운트 제외 (통합 여부 무관)
- [x] Ctrl+Drag 박스 선택 + 우클릭 컨텍스트 메뉴
- [x] 통합모드 전환 시 selectedShipIds 동기화
### Phase 5: 추가 기능 ### Phase 5: 항적조회 + 버그 수정 (02-02~03 완료)
- [ ] 항적 조회 - [x] tracking 패키지 TS→JS 변환 (13개 파일)
- [x] 모달 항적조회 + 우클릭 항적조회
- [x] 라이브 연결선 (PathStyleExtension dash + 1초 인터벌)
- [x] 레이더 타겟 통합 표시 버그 수정 (integrate 플래그 기반)
### Phase 6: 리플레이 기능 (02-03 완료)
- [x] replay 패키지 TS→JS 변환 (stores, components, hooks, services, utils, types)
- [x] WebSocket 기반 청크 데이터 수신 (ReplayWebSocketService)
- [x] 시간 기반 애니메이션 (재생/일시정지/정지, 배속 1x~1000x)
- [x] 프로그레스 바 슬라이더 (CSS 변수 방식, thumb/progress/배경 동기화)
- [x] 항적 표시 토글 (playbackTrailStore - 프레임 기반 페이딩)
- [x] 선박 상태 관리 (기본/선택/삭제) + 필터링 (선종, 신호원)
- [x] 드래그 가능한 타임라인 컨트롤러
- [x] 닫기 시 초기화 (라이브 선박 복원)
### Phase 6.5: TopBar 및 추적 모드 (02-04 완료)
- [x] TopBar 컴포넌트 (좌표 표시, 시간 표시, 선박 검색)
- [x] 좌표/시간 포맷 설정 드롭다운 (도분초/도, KST/UTC)
- [x] 선박 검색 기능 (like 검색, 디바운싱, 통합모드 지원)
- [x] 추적 모드 구현 (trackingModeStore)
- 지도 모드 / 선박 모드 전환 (라디오 버튼)
- 경비함정 선택 드롭다운 (PatrolShipSelector)
- 반경 설정 (10/25/50/100/200 NM)
- [x] 반경 필터링 (useRadiusFilter, ShipBatchRenderer)
- Bounding Box 사전 필터 + Haversine 거리 계산
- 렌더링, 카운트, 검색 결과 모두 반경 내 선박만 표시
- [x] 추적 함정 지도 따라가기 (useTrackingMode)
- 반경 원 표시 (OpenLayers VectorLayer)
- 지도 중심 자동 이동
- [x] 추적 함정 아이콘 강조 (ScatterplotLayer - 시안색 마커)
- [x] 컨텍스트 메뉴 반경설정 (경비함정 우클릭 → 반경설정 서브메뉴)
### Phase 7: 미구현 (예정)
- [ ] 동적 우선순위 (Dynamic Priority) - 계획 수립 완료 (`.claude/plans/`)
- [ ] 퍼블리시 → 구현 전환 (Panel1~8, 기능 페이지)
- [ ] 기상 레이어 - [ ] 기상 레이어
- [ ] 관심영역 설정 - [ ] 관심영역 설정
- [ ] 위성 영상 - [ ] 위성 영상
@ -199,14 +218,21 @@ src/
## 개발 명령어 ## 개발 명령어
```bash ```bash
# 개발 서버 실행 # 개발 서버 실행 (Vite, http://localhost:3000)
npm start npm run dev
# 프로덕션 빌드 # 프로덕션 빌드
npm run build npm run build
# 테스트 # 환경별 빌드
npm test npm run build:dev
npm run build:prod
# 빌드 미리보기
npm run preview
# 린트
npm run lint
``` ```
--- ---
@ -222,4 +248,9 @@ npm test
| 날짜 | 내용 | | 날짜 | 내용 |
|------|------| |------|------|
| 2026-01-26 | 초기 프로젝트 분석 및 워크플로우 정의 | | 2026-02-04 | Phase 6.5 완료 (TopBar, 추적 모드, 반경 필터링) |
| 2026-02-03 | Phase 6 완료 (리플레이 기능 구현) |
| 2026-02-03 | Phase 5 완료 (항적조회, 레이더 통합 버그 수정), CLAUDE.md 현황 동기화 |
| 2026-01-30 | Phase 4 완료 (shipStore 성능 최적화, 카운트 동기화) |
| 2026-01-27 | Phase 2~3 완료 (지도, 선박 레이어 고도화) |
| 2026-01-26 | 초기 프로젝트 분석 및 워크플로우 정의, Phase 1 완료 |

파일 보기

@ -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>
);
}

파일 보기

@ -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;
}
}
}

파일 보기

@ -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>
);
}

파일 보기

@ -0,0 +1,476 @@
/**
* TopBar 스타일
* 지도 상단 중앙 정보/검색
*/
.topBar {
position: absolute;
top: 1.5rem;
left: 0;
right: 0;
height: 4.4rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0 5.4rem;
box-sizing: border-box;
z-index: 95;
pointer-events: none; // 영역은 지도 이벤트 통과
> * {
pointer-events: auto; // 내부 요소는 클릭 가능
}
}
// 위치 정보
.locationInfo {
flex: 0 0 auto;
min-width: 71rem;
height: 3.8rem;
margin: 0 auto;
display: flex;
align-items: center;
background-color: rgba(var(--secondary6-rgb), 0.9);
// overflow: hidden 제거 - 설정 드롭다운 표시를 위해
ul {
display: flex;
align-items: center;
width: 100%;
height: 100%;
li {
display: flex;
align-items: center;
height: 100%;
padding: 0 1rem;
color: var(--white);
font-weight: var(--fw-bold);
font-size: 1.3rem;
&:first-child,
&:last-child {
padding: 0;
}
span + span {
padding-right: 0.5rem;
}
// 구분선 (좌표 표시)
&.divider {
position: relative;
width: 20rem; // 고정 너비로 아이콘 위치 고정
flex-shrink: 0;
font-variant-numeric: tabular-nums; // 숫자 고정폭
font-feature-settings: "tnum"; // 숫자 고정폭 (fallback)
&::after {
content: '';
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 1px;
height: 1.8rem;
background-color: rgba(255, 255, 255, 0.3);
}
}
// WGS84 좌표 라벨
.wgs {
display: flex;
align-items: center;
padding-right: 0.5rem;
&::before {
content: '';
display: block;
width: 2rem;
height: 2rem;
margin-right: 0.5rem;
background: url(../../assets/images/ico_globe.svg) no-repeat center / 2rem;
}
}
// 시간 영역
&.time-section {
width: 22rem; // 고정 너비
flex-shrink: 0;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
// 시간 뱃지 (KST/UTC)
.time-badge {
display: flex;
align-items: center;
justify-content: center;
width: 3.1rem;
height: 1.8rem;
margin-right: 0.5rem;
border-radius: 0.3rem;
color: var(--white);
font-size: var(--fs-s);
font-weight: var(--fw-bold);
flex-shrink: 0;
&.kst {
background-color: var(--tertiary3);
}
&.utc {
background-color: var(--primary1);
}
}
// 설정 컨테이너
&.settings-container {
position: relative;
padding: 0 0.5rem;
flex-shrink: 0;
}
// 첫번째(지도) 버튼 컨테이너
&:first-child {
flex-shrink: 0;
}
// 선박 모드 컨테이너
&.ship-mode-container {
position: relative;
display: flex;
align-items: center;
flex-shrink: 0;
padding: 0;
}
// 추적 중인 함정 정보
.tracked-ship-info {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0 1rem;
height: 100%;
background-color: rgba(var(--primary1-rgb), 0.3);
border-left: 1px solid rgba(var(--white-rgb), 0.2);
.tracked-name {
color: var(--white);
font-size: 1.2rem;
font-weight: var(--fw-bold);
max-width: 12rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tracked-radius {
color: var(--tertiary3);
font-size: 1.1rem;
font-weight: var(--fw-bold);
padding: 0.2rem 0.5rem;
background-color: rgba(var(--tertiary3-rgb), 0.2);
border-radius: 0.3rem;
}
}
}
}
// 설정 드롭다운
.settings-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
min-width: 16rem;
padding: 1rem;
background-color: rgba(var(--secondary6-rgb), 0.95);
border-radius: 0.8rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 100;
.settings-group {
& + .settings-group {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(var(--white-rgb), 0.1);
}
}
.settings-label {
color: rgba(var(--white-rgb), 0.7);
font-size: 1.1rem;
margin-bottom: 0.6rem;
}
.settings-options {
display: flex;
gap: 0.5rem;
button {
flex: 1;
padding: 0.6rem 1rem;
border: 1px solid rgba(var(--white-rgb), 0.2);
border-radius: 0.4rem;
background-color: transparent;
color: rgba(var(--white-rgb), 0.7);
font-size: 1.2rem;
cursor: pointer;
transition: all 0.15s ease;
width: auto;
height: auto;
&:hover {
border-color: rgba(var(--white-rgb), 0.4);
color: var(--white);
background-color: transparent;
}
&.active {
border-color: var(--primary1);
background-color: var(--primary1);
color: var(--white);
}
}
}
}
// 버튼 공통
button {
width: 4rem;
height: 3.8rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
box-sizing: border-box;
background-color: var(--secondary3);
background-repeat: no-repeat;
background-position: center;
background-size: 3rem;
border: none;
transition: background-color 0.2s ease;
&:hover,
&.active {
background-color: rgba(var(--primary1-rgb), 0.8);
}
// 지도 모드 버튼
&.map {
background-image: url(../../assets/images/ico_map.svg);
}
// 선박 버튼
&.ship {
background-image: url(../../assets/images/ico_ship.svg);
}
// 설정 버튼
&.set {
width: 2rem;
height: 2rem;
background-color: transparent;
background-image: url(../../assets/images/ico_set_s.svg);
background-size: 2rem;
&:hover,
&.active {
background-color: transparent;
}
}
}
}
// 검색 박스
.topSchBox {
position: absolute;
right: 5.4rem;
flex: 0 0 auto;
width: 26rem;
display: flex;
flex-direction: column;
// 입력창 래퍼
.search-input-wrapper {
position: relative;
width: 100%;
height: 3.8rem;
}
.tschInput {
width: 100%;
height: 100%;
padding: 0 4.4rem 0 1.2rem;
border-radius: 2rem;
background-color: rgba(var(--secondary6-rgb), 0.9);
border: 0;
color: var(--white);
font-size: var(--fs-m);
&::placeholder {
color: rgba(var(--white-rgb), 0.6);
}
&:focus {
outline: none;
background-color: rgba(var(--secondary6-rgb), 1);
}
}
// 검색어 지우기 버튼
.search-clear-btn {
position: absolute;
top: 50%;
right: 3rem;
transform: translateY(-50%);
width: 2rem;
height: 2rem;
padding: 0;
border: none;
background: transparent;
color: rgba(var(--white-rgb), 0.6);
font-size: 1.6rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: var(--white);
}
}
.mainSchBtn {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
width: 3rem;
height: 3.8rem;
font-size: 0;
text-indent: -999999em;
border: none;
cursor: pointer;
background: url(../../assets/images/ico_search_main.svg) no-repeat center / 2rem;
background-color: transparent;
&:hover {
opacity: 0.8;
}
}
// 검색 결과 목록
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.5rem;
padding: 0.5rem 0;
background-color: rgba(var(--secondary6-rgb), 0.95);
border-radius: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
max-height: 30rem;
overflow-y: auto;
z-index: 100;
list-style: none;
// 스크롤바 스타일
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(var(--white-rgb), 0.3);
border-radius: 3px;
}
}
// 검색 결과 항목
.search-result-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.8rem 1.2rem;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background-color: rgba(var(--primary1-rgb), 0.3);
}
.result-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
overflow: hidden;
}
.result-name {
color: var(--white);
font-size: 1.3rem;
font-weight: var(--fw-bold);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-id {
color: rgba(var(--white-rgb), 0.6);
font-size: 1.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-action {
flex-shrink: 0;
width: 2.4rem;
height: 2.4rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--tertiary3);
&:hover {
color: var(--white);
}
}
}
// 검색 결과 없음
.search-no-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.5rem;
padding: 1.2rem;
background-color: rgba(var(--secondary6-rgb), 0.95);
border-radius: 1rem;
color: rgba(var(--white-rgb), 0.6);
font-size: 1.2rem;
text-align: center;
z-index: 100;
}
}
// 접근성: 스크린리더 전용 텍스트
.blind {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

파일 보기

@ -3,20 +3,39 @@
* - 단일 선박 우클릭: 해당 선박 메뉴 * - 단일 선박 우클릭: 해당 선박 메뉴
* - Ctrl+Drag 선택 우클릭: 선택된 선박 전체 메뉴 * - Ctrl+Drag 선택 우클릭: 선택된 선박 전체 메뉴
*/ */
import { useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import useShipStore from '../../stores/shipStore'; import useShipStore from '../../stores/shipStore';
import { useTrackQueryStore } from '../../tracking/stores/trackQueryStore';
import useTrackingModeStore, { RADIUS_OPTIONS, isPatrolShip } from '../../stores/trackingModeStore';
import {
fetchVesselTracksV2,
convertToProcessedTracks,
buildVesselListForQuery,
deduplicateVessels,
} from '../../tracking/services/trackQueryApi';
import './ShipContextMenu.scss'; import './ShipContextMenu.scss';
/** KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC이므로 사용하지 않음) */
function toKstISOString(date) {
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
const MENU_ITEMS = [ const MENU_ITEMS = [
{ key: 'track', label: '항적조회' }, { key: 'track', label: '항적조회' },
{ key: 'analysis', label: '항적분석' }, { key: 'analysis', label: '항적분석' },
{ key: 'detail', label: '상세정보' }, { key: 'detail', label: '상세정보' },
{ key: 'radius', label: '반경설정', hasSubmenu: true },
]; ];
export default function ShipContextMenu() { export default function ShipContextMenu() {
const contextMenu = useShipStore((s) => s.contextMenu); const contextMenu = useShipStore((s) => s.contextMenu);
const closeContextMenu = useShipStore((s) => s.closeContextMenu); const closeContextMenu = useShipStore((s) => s.closeContextMenu);
const setRadius = useTrackingModeStore((s) => s.setRadius);
const selectTrackedShip = useTrackingModeStore((s) => s.selectTrackedShip);
const currentRadius = useTrackingModeStore((s) => s.radiusNM);
const menuRef = useRef(null); const menuRef = useRef(null);
const [hoveredItem, setHoveredItem] = useState(null);
// //
useEffect(() => { useEffect(() => {
@ -32,31 +51,156 @@ export default function ShipContextMenu() {
return () => document.removeEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick);
}, [contextMenu, closeContextMenu]); }, [contextMenu, closeContextMenu]);
// //
const handleAction = useCallback((key) => { const handleRadiusSelect = useCallback((radius) => {
if (!contextMenu) return; if (!contextMenu) return;
const { ships } = contextMenu; const { ships } = contextMenu;
// TODO: API //
console.log(`[ContextMenu] action=${key}, ships=`, ships.map((s) => ({ if (ships.length === 1) {
featureId: s.featureId, const ship = ships[0];
shipName: s.shipName, setRadius(radius);
targetId: s.targetId, selectTrackedShip(ship.featureId, ship);
}))); }
closeContextMenu();
}, [contextMenu, setRadius, selectTrackedShip, closeContextMenu]);
//
const handleAction = useCallback(async (key) => {
if (!contextMenu) return;
const { ships } = contextMenu;
//
if (key === 'radius') return;
closeContextMenu(); closeContextMenu();
switch (key) {
case 'track': {
const store = useTrackQueryStore.getState();
store.reset();
const endTime = new Date();
const startTime = new Date(endTime.getTime() - 3 * 24 * 60 * 60 * 1000); // 3
const { isIntegrate, features } = useShipStore.getState();
const allVessels = [];
const errors = [];
ships.forEach(ship => {
const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features);
if (result.canQuery) allVessels.push(...result.vessels);
else if (result.errorMessage) errors.push(result.errorMessage);
});
// (sigSrcCd, targetId)
const uniqueVessels = deduplicateVessels(allVessels);
if (uniqueVessels.length === 0) {
store.setError(errors[0] || '조회 가능한 선박이 없습니다.');
return;
}
store.setModalMode(false, null);
store.setLoading(true);
try {
const rawTracks = await fetchVesselTracksV2({
startTime: toKstISOString(startTime),
endTime: toKstISOString(endTime),
vessels: uniqueVessels,
isIntegration: isIntegrate ? '1' : '0',
});
const processed = convertToProcessedTracks(rawTracks);
if (processed.length === 0) {
store.setError('항적 데이터가 없습니다.');
} else {
store.setTracks(processed, startTime.getTime());
}
} catch (e) {
console.error('[ShipContextMenu] 항적 조회 실패:', e);
store.setError('항적 조회 실패');
}
store.setLoading(false);
break;
}
case 'analysis': {
// : showPlayback
const store = useTrackQueryStore.getState();
store.reset();
const endTime = new Date();
const startTime = new Date(endTime.getTime() - 3 * 24 * 60 * 60 * 1000); // 3
const { isIntegrate, features } = useShipStore.getState();
const allVessels = [];
ships.forEach(ship => {
const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features);
if (result.canQuery) allVessels.push(...result.vessels);
});
// (sigSrcCd, targetId)
const uniqueVessels = deduplicateVessels(allVessels);
if (uniqueVessels.length === 0) return;
store.setModalMode(false, null);
store.setLoading(true);
try {
const rawTracks = await fetchVesselTracksV2({
startTime: toKstISOString(startTime),
endTime: toKstISOString(endTime),
vessels: uniqueVessels,
isIntegration: isIntegrate ? '1' : '0',
});
const processed = convertToProcessedTracks(rawTracks);
if (processed.length > 0) {
store.setTracks(processed, startTime.getTime());
// showPlayback ( )
useTrackQueryStore.setState({ showPlayback: true });
}
} catch (e) {
console.error('[ShipContextMenu] 항적분석 조회 실패:', e);
}
store.setLoading(false);
break;
}
case 'detail':
if (ships.length === 1) {
useShipStore.getState().openDetailModal(ships[0]);
}
break;
default:
console.log(`[ContextMenu] action=${key}, ships=`, ships.map((s) => ({
featureId: s.featureId,
shipName: s.shipName,
targetId: s.targetId,
})));
}
}, [contextMenu, closeContextMenu]); }, [contextMenu, closeContextMenu]);
if (!contextMenu) return null; if (!contextMenu) return null;
const { x, y, ships } = contextMenu; const { x, y, ships } = contextMenu;
// ( )
const isSinglePatrolShip = ships.length === 1 && isPatrolShip(ships[0].originalTargetId);
//
const visibleMenuItems = MENU_ITEMS.filter((item) => {
if (item.key === 'radius') return isSinglePatrolShip;
return true;
});
// //
const menuWidth = 160; const menuWidth = 160;
const menuHeight = MENU_ITEMS.length * 36 + 40; // + const menuHeight = visibleMenuItems.length * 36 + 40; // +
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x; const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
const adjustedY = y + menuHeight > window.innerHeight ? y - menuHeight : y; const adjustedY = y + menuHeight > window.innerHeight ? y - menuHeight : y;
// ( )
const submenuOnLeft = adjustedX + menuWidth + 120 > window.innerWidth;
const title = ships.length === 1 const title = ships.length === 1
? (ships[0].shipName || ships[0].featureId) ? (ships[0].shipName || ships[0].featureId)
: `${ships.length}척 선택`; : `${ships.length}척 선택`;
@ -68,13 +212,37 @@ export default function ShipContextMenu() {
style={{ left: adjustedX, top: adjustedY }} style={{ left: adjustedX, top: adjustedY }}
> >
<div className="ship-context-menu__header">{title}</div> <div className="ship-context-menu__header">{title}</div>
{MENU_ITEMS.map((item) => ( {visibleMenuItems.map((item, index) => (
<div <div
key={item.key} key={item.key}
className="ship-context-menu__item" className={`ship-context-menu__item ${item.hasSubmenu ? 'has-submenu' : ''}`}
onClick={() => handleAction(item.key)} onClick={() => handleAction(item.key)}
onMouseEnter={() => setHoveredItem(item.key)}
onMouseLeave={() => setHoveredItem(null)}
> >
{item.label} {item.label}
{item.hasSubmenu && <span className="submenu-arrow"></span>}
{/* 반경설정 서브메뉴 */}
{item.key === 'radius' && hoveredItem === 'radius' && (
<div
className={`ship-context-menu__submenu ${submenuOnLeft ? 'left' : 'right'}`}
style={{ top: 0 }}
>
{RADIUS_OPTIONS.map((radius) => (
<div
key={radius}
className={`ship-context-menu__item ${currentRadius === radius ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
handleRadiusSelect(radius);
}}
>
{radius} NM
</div>
))}
</div>
)}
</div> </div>
))} ))}
</div> </div>

파일 보기

@ -20,15 +20,63 @@
} }
&__item { &__item {
position: relative;
padding: 8px 12px; padding: 8px 12px;
font-size: 13px; font-size: 13px;
color: #ddd; color: #ddd;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s; transition: background-color 0.15s;
display: flex;
align-items: center;
justify-content: space-between;
&:hover { &:hover {
background: #2a2a4e; background: #2a2a4e;
color: #fff; color: #fff;
} }
&.has-submenu {
padding-right: 24px;
}
&.active {
background: rgba(0, 150, 255, 0.3);
color: #fff;
&::before {
content: '';
position: absolute;
left: 4px;
font-size: 10px;
color: #00a8ff;
}
}
.submenu-arrow {
font-size: 10px;
color: #888;
margin-left: 8px;
}
}
&__submenu {
position: absolute;
top: 0;
background: #1a1a2e;
border: 1px solid #3a3a5e;
border-radius: 4px;
min-width: 100px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
z-index: 1001;
&.right {
left: 100%;
margin-left: 2px;
}
&.left {
right: 100%;
margin-right: 2px;
}
} }
} }

파일 보기

@ -5,6 +5,7 @@
* - 선박 종류별 필터 토글 * - 선박 종류별 필터 토글
*/ */
import { memo } from 'react'; import { memo } from 'react';
import { shallow } from 'zustand/shallow';
import useShipStore from '../../stores/shipStore'; import useShipStore from '../../stores/shipStore';
import { import {
SIGNAL_KIND_CODE_FISHING, SIGNAL_KIND_CODE_FISHING,
@ -84,15 +85,21 @@ const LegendItem = memo(({ code, label, count, icon, isVisible, onToggle }) => {
* 선박 범례 컴포넌트 * 선박 범례 컴포넌트
*/ */
const ShipLegend = memo(() => { const ShipLegend = memo(() => {
const { // :
kindCounts, // useShipStore() featuresVersion
kindVisibility, const { kindCounts, kindVisibility, isShipVisible, totalCount, isConnected } =
isShipVisible, useShipStore(
totalCount, (state) => ({
isConnected, kindCounts: state.kindCounts,
toggleKindVisibility, kindVisibility: state.kindVisibility,
toggleShipVisible, isShipVisible: state.isShipVisible,
} = useShipStore(); totalCount: state.totalCount,
isConnected: state.isConnected,
}),
shallow
);
const toggleKindVisibility = useShipStore((state) => state.toggleKindVisibility);
const toggleShipVisible = useShipStore((state) => state.toggleShipVisible);
return ( return (
<article className="ship-legend"> <article className="ship-legend">

파일 보기

@ -3,6 +3,7 @@
* - OpenLayers 맵과 Deck.gl 레이어 통합 * - OpenLayers 맵과 Deck.gl 레이어 통합
* - 배치 렌더러 기반 최적화된 렌더링 * - 배치 렌더러 기반 최적화된 렌더링
* - 선박 데이터 변경 레이어 업데이트 * - 선박 데이터 변경 레이어 업데이트
* - 항적 레이어: 정적(경로/포인트) 캐싱 + 동적(가상선박) 경량 갱신
* *
* 참조: mda-react-front/src/common/deck.ts * 참조: mda-react-front/src/common/deck.ts
*/ */
@ -11,6 +12,10 @@ import { Deck } from '@deck.gl/core';
import { toLonLat } from 'ol/proj'; import { toLonLat } from 'ol/proj';
import { createShipLayers, clearClusterCache } from '../map/layers/shipLayer'; import { createShipLayers, clearClusterCache } from '../map/layers/shipLayer';
import useShipStore from '../stores/shipStore'; import useShipStore from '../stores/shipStore';
import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
import useTrackingModeStore from '../stores/trackingModeStore';
import { getTrackQueryLayers } from '../tracking/utils/trackQueryLayerUtils';
import { getReplayLayers } from '../replay/utils/replayLayerRegistry';
import { shipBatchRenderer } from '../map/ShipBatchRenderer'; import { shipBatchRenderer } from '../map/ShipBatchRenderer';
/** /**
@ -27,13 +32,15 @@ export default function useShipLayer(map) {
const getSelectedShips = useShipStore((s) => s.getSelectedShips); const getSelectedShips = useShipStore((s) => s.getSelectedShips);
const isShipVisible = useShipStore((s) => s.isShipVisible); const isShipVisible = useShipStore((s) => s.isShipVisible);
// 마지막 선박 레이어: 캐시용
const lastShipLayersRef = useRef([]);
/** /**
* Deck.gl 인스턴스 초기화 * Deck.gl 인스턴스 초기화
*/ */
const initDeck = useCallback((container) => { const initDeck = useCallback((container) => {
if (deckRef.current) return; if (deckRef.current) return;
// Canvas 엘리먼트 생성
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = 'deck-canvas'; canvas.id = 'deck-canvas';
canvas.style.position = 'absolute'; canvas.style.position = 'absolute';
@ -46,7 +53,6 @@ export default function useShipLayer(map) {
container.appendChild(canvas); container.appendChild(canvas);
canvasRef.current = canvas; canvasRef.current = canvas;
// Deck.gl 인스턴스 생성
deckRef.current = new Deck({ deckRef.current = new Deck({
canvas, canvas,
controller: false, controller: false,
@ -77,7 +83,7 @@ export default function useShipLayer(map) {
viewState: { viewState: {
longitude: lon, longitude: lon,
latitude: lat, latitude: lat,
zoom: zoom - 1, // OpenLayers와 Deck.gl 줌 레벨 차이 보정 zoom: zoom - 1,
bearing: (-rotation * 180) / Math.PI, bearing: (-rotation * 180) / Math.PI,
pitch: 0, pitch: 0,
}, },
@ -95,7 +101,6 @@ export default function useShipLayer(map) {
if (!size) return null; if (!size) return null;
const extent = view.calculateExtent(size); const extent = view.calculateExtent(size);
// OpenLayers 좌표를 경위도로 변환
const [minX, minY, maxX, maxY] = extent; const [minX, minY, maxX, maxY] = extent;
const [minLon, minLat] = toLonLat([minX, minY]); const [minLon, minLat] = toLonLat([minX, minY]);
const [maxLon, maxLat] = toLonLat([maxX, maxY]); const [maxLon, maxLat] = toLonLat([maxX, maxY]);
@ -104,9 +109,7 @@ export default function useShipLayer(map) {
}, [map]); }, [map]);
/** /**
* 배치 렌더러 콜백 - 필터링된 선박으로 레이어 업데이트 * 배치 렌더러 콜백 - 선박 레이어 생성 + 캐싱된 항적 레이어 병합
* @param {Array} ships - 밀도 제한 적용된 선박 (아이콘 + 라벨 공통)
* @param {number} trigger - 렌더링 트리거
*/ */
const handleBatchRender = useCallback((ships, trigger) => { const handleBatchRender = useCallback((ships, trigger) => {
if (!deckRef.current || !map) return; if (!deckRef.current || !map) return;
@ -115,15 +118,29 @@ export default function useShipLayer(map) {
const zoom = view.getZoom() || 7; const zoom = view.getZoom() || 7;
const selectedShips = getSelectedShips(); const selectedShips = getSelectedShips();
// 현재 스토어에서 showLabels, labelOptions, isIntegrate, darkSignalIds 가져오기
const { showLabels: currentShowLabels, labelOptions: currentLabelOptions, isIntegrate: currentIsIntegrate, darkSignalIds } = useShipStore.getState(); const { showLabels: currentShowLabels, labelOptions: currentLabelOptions, isIntegrate: currentIsIntegrate, darkSignalIds } = useShipStore.getState();
// 레이어 생성 (밀도 제한 적용된 선박 = 아이콘 + 라벨 공통) // 라이브 선박 숨김 상태 확인
// 아이콘이 표시되는 선박에만 라벨/신호상태도 표시 const { hideLiveShips } = useTrackQueryStore.getState();
const layers = createShipLayers(ships, selectedShips, zoom, currentShowLabels, currentLabelOptions, currentIsIntegrate, trigger, darkSignalIds);
// Deck.gl 레이어 업데이트 // 선박 레이어 생성 (hideLiveShips일 때는 빈 배열)
deckRef.current.setProps({ layers }); const shipLayers = hideLiveShips
? []
: createShipLayers(ships, selectedShips, zoom, currentShowLabels, currentLabelOptions, currentIsIntegrate, trigger, darkSignalIds);
// 선박 레이어 캐시
lastShipLayersRef.current = shipLayers;
// 항적 레이어 (tracking 패키지 전역 레지스트리에서 가져옴)
const trackLayers = getTrackQueryLayers();
// 리플레이 레이어 (전역 레지스트리)
const replayLayers = getReplayLayers();
// 병합: 선박 + 항적 + 리플레이 레이어
deckRef.current.setProps({
layers: [...shipLayers, ...trackLayers, ...replayLayers],
});
}, [map, getSelectedShips]); }, [map, getSelectedShips]);
/** /**
@ -133,28 +150,23 @@ export default function useShipLayer(map) {
if (!deckRef.current || !map) return; if (!deckRef.current || !map) return;
if (!isShipVisible) { if (!isShipVisible) {
// 선박 표시 Off
deckRef.current.setProps({ layers: [] }); deckRef.current.setProps({ layers: [] });
return; return;
} }
// 줌 레벨 업데이트 (렌더링 간격 조정용)
const view = map.getView(); const view = map.getView();
const zoom = view.getZoom() || 10; const zoom = view.getZoom() || 10;
const zoomIntChanged = shipBatchRenderer.setZoom(zoom); const zoomIntChanged = shipBatchRenderer.setZoom(zoom);
// 뷰포트 범위 업데이트
const bounds = getViewportBounds(); const bounds = getViewportBounds();
shipBatchRenderer.setViewportBounds(bounds); shipBatchRenderer.setViewportBounds(bounds);
// 줌 정수 레벨이 변경되면 클러스터 캐시 클리어 + 즉시 렌더링
if (zoomIntChanged) { if (zoomIntChanged) {
clearClusterCache(); clearClusterCache();
shipBatchRenderer.immediateRender(); shipBatchRenderer.immediateRender();
return; return;
} }
// 배치 렌더러에 렌더링 요청
shipBatchRenderer.requestRender(); shipBatchRenderer.requestRender();
}, [map, isShipVisible, getViewportBounds]); }, [map, isShipVisible, getViewportBounds]);
@ -171,21 +183,15 @@ export default function useShipLayer(map) {
useEffect(() => { useEffect(() => {
if (!map) return; if (!map) return;
// 맵 컨테이너에 Deck.gl 캔버스 추가
const viewport = map.getViewport(); const viewport = map.getViewport();
initDeck(viewport); initDeck(viewport);
// 배치 렌더러 초기화 (1회만)
if (!batchRendererInitialized.current) { if (!batchRendererInitialized.current) {
shipBatchRenderer.initialize(handleBatchRender); shipBatchRenderer.initialize(handleBatchRender);
batchRendererInitialized.current = true; batchRendererInitialized.current = true;
} }
// 맵 이동/줌 시 동기화 const handleMoveEnd = () => { render(); };
const handleMoveEnd = () => {
render();
};
const handlePostRender = () => { const handlePostRender = () => {
syncViewState(); syncViewState();
deckRef.current?.redraw(); deckRef.current?.redraw();
@ -194,12 +200,8 @@ export default function useShipLayer(map) {
map.on('moveend', handleMoveEnd); map.on('moveend', handleMoveEnd);
map.on('postrender', handlePostRender); map.on('postrender', handlePostRender);
// 초기 렌더링 setTimeout(() => { render(); }, 100);
setTimeout(() => {
render();
}, 100);
// 클린업
return () => { return () => {
map.un('moveend', handleMoveEnd); map.un('moveend', handleMoveEnd);
map.un('postrender', handlePostRender); map.un('postrender', handlePostRender);
@ -218,20 +220,16 @@ export default function useShipLayer(map) {
canvasRef.current = null; canvasRef.current = null;
} }
// 배치 렌더러 정리
shipBatchRenderer.dispose(); shipBatchRenderer.dispose();
batchRendererInitialized.current = false; batchRendererInitialized.current = false;
}; };
}, [map, initDeck, render, syncViewState, handleBatchRender]); }, [map, initDeck, render, syncViewState, handleBatchRender]);
// 선박 데이터 변경 시 레이어 업데이트 // 선박 데이터 변경 시 레이어 업데이트
// ※ immutable 패턴: features/darkSignalIds 참조로 변경 감지
useEffect(() => { useEffect(() => {
// 스토어 구독하여 변경 감지
const unsubscribe = useShipStore.subscribe( const unsubscribe = useShipStore.subscribe(
(state) => [state.features, state.kindVisibility, state.sourceVisibility, state.isShipVisible, state.detailModals, state.isIntegrate, state.nationalVisibility, state.showLabels, state.labelOptions, state.darkSignalVisible, state.darkSignalIds], (state) => [state.features, state.kindVisibility, state.sourceVisibility, state.isShipVisible, state.detailModals, state.isIntegrate, state.nationalVisibility, state.showLabels, state.labelOptions, state.darkSignalVisible, state.darkSignalIds],
(current, prev) => { (current, prev) => {
// 필터 변경 감지 (kindVisibility, sourceVisibility, isShipVisible, isIntegrate, nationalVisibility, showLabels, labelOptions, darkSignalVisible, darkSignalIds)
const filterChanged = const filterChanged =
current[1] !== prev[1] || current[1] !== prev[1] ||
current[2] !== prev[2] || current[2] !== prev[2] ||
@ -244,27 +242,56 @@ export default function useShipLayer(map) {
current[10] !== prev[10]; current[10] !== prev[10];
if (filterChanged) { if (filterChanged) {
// 필터/선명표시 변경 시 즉시 렌더링 (사용자 인터랙션 응답성)
shipBatchRenderer.clearCache(); shipBatchRenderer.clearCache();
clearClusterCache(); clearClusterCache();
shipBatchRenderer.immediateRender(); shipBatchRenderer.immediateRender();
return; // 즉시 렌더링 후 추가 처리 불필요 return;
} }
// 데이터 변경 시 배치 렌더러에 렌더링 요청만 전달 (적응형 주기 적용)
// ※ redraw() 호출 제거: 배치 렌더러가 executeRender → setProps({ layers }) 시
// deck.gl이 자동으로 repaint하므로 별도 redraw() 불필요.
// 매 features 참조 변경(~1초)마다 redraw() 호출 시 불필요한 repaint 발생.
updateLayers(); updateLayers();
}, },
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) } { equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
); );
return () => { return () => { unsubscribe(); };
unsubscribe();
};
}, [updateLayers]); }, [updateLayers]);
// === trackQueryStore 변경 시 선박 레이어 리렌더 ===
// tracking 패키지의 TrackQueryViewer가 레이어를 전역 레지스트리에 등록하면
// 여기서 shipBatchRenderer를 트리거하여 deck.gl에 반영
useEffect(() => {
const unsubscribe = useTrackQueryStore.subscribe(
(state) => [state.tracks, state.currentTime, state.showPoints, state.showVirtualShip, state.showLabels, state.disabledVesselIds, state.hideLiveShips],
() => {
if (deckRef.current && map) {
shipBatchRenderer.requestRender();
}
},
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
);
return () => unsubscribe();
}, [map]);
// === trackingModeStore 변경 시 선박 레이어 리렌더 ===
// 반경 필터링 상태가 변경되면 즉시 렌더링
useEffect(() => {
const unsubscribe = useTrackingModeStore.subscribe(
(state) => [state.mode, state.trackedShipId, state.trackedShip, state.radiusNM],
() => {
if (deckRef.current && map) {
// 반경/추적 모드 변경은 필터 변경이므로 캐시 클리어 후 즉시 렌더링
shipBatchRenderer.clearCache();
clearClusterCache();
shipBatchRenderer.immediateRender();
}
},
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
);
return () => unsubscribe();
}, [map]);
return { return {
deckCanvas: canvasRef.current, deckCanvas: canvasRef.current,
deckRef, deckRef,

318
src/hooks/useShipSearch.js Normal file
파일 보기

@ -0,0 +1,318 @@
/**
* 선박 검색
* 참조: mda-react-front/src/hooks/useBottomSearch.ts
*
* 검색 조건:
* - 통합모드 ON: targetId, shipName에서 검색
* - 통합모드 OFF: originalTargetId (레이더 제외), shipName에서 검색
* - 공백/특수문자 제거, 대소문자 무시
* - 최대 10 결과 반환
*
* 성능 최적화:
* - 한글: 최소 2, shipName만 검색
* - 영문/숫자: 최소 3
* - 디바운싱 (200ms)
* - 조기 종료 (결과 10 도달 )
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { fromLonLat } from 'ol/proj';
import useShipStore from '../stores/shipStore';
import { useMapStore } from '../stores/mapStore';
import useTrackingModeStore, { isWithinRadius, NM_TO_METERS } from '../stores/trackingModeStore';
// 레이더 신호원 코드
const SIGNAL_SOURCE_CODE_RADAR = '000005';
// 최대 검색 결과 수
const MAX_RESULTS = 10;
// 디바운스 시간 (ms)
const DEBOUNCE_MS = 200;
// 한글 정규식
const KOREAN_REGEX = /[가-힣ㄱ-ㅎㅏ-ㅣ]/;
/**
* 검색어가 한글을 포함하는지 확인
* @param {string} text
* @returns {boolean}
*/
function containsKorean(text) {
return KOREAN_REGEX.test(text);
}
/**
* 검색어 정규화
* - 공백 제거
* - 특수문자 제거 (알파벳, 숫자, 한글만 유지)
* - 소문자 변환
* @param {string} text
* @returns {string}
*/
function normalizeSearchText(text) {
if (!text) return '';
return text
.toLowerCase()
.replace(/[\s\-_.,:;!@#$%^&*()+=\[\]{}|\\/<>?'"]/g, '')
.trim();
}
/**
* 최소 검색 길이 확인
* - 한글 포함: 최소 2
* - 영문/숫자만: 최소 3
* @param {string} originalText - 원본 입력값
* @param {string} normalizedText - 정규화된 검색어
* @returns {boolean}
*/
function meetsMinLength(originalText, normalizedText) {
if (!normalizedText) return false;
const hasKorean = containsKorean(originalText);
const minLength = hasKorean ? 2 : 3;
return normalizedText.length >= minLength;
}
/**
* 선박 검색
* @returns {Object} { searchValue, setSearchValue, results, handleSearch, handleClickResult, clearSearch }
*/
export default function useShipSearch() {
const [searchValue, setSearchValueState] = useState('');
const [results, setResults] = useState([]);
const map = useMapStore((s) => s.map);
const features = useShipStore((s) => s.features);
const isIntegrate = useShipStore((s) => s.isIntegrate);
const darkSignalIds = useShipStore((s) => s.darkSignalIds);
const setSelectedShipIds = useShipStore((s) => s.setSelectedShipIds);
const openDetailModal = useShipStore((s) => s.openDetailModal);
// 추적 모드 상태
const trackingMode = useTrackingModeStore((s) => s.mode);
const trackedShip = useTrackingModeStore((s) => s.trackedShip);
const radiusNM = useTrackingModeStore((s) => s.radiusNM);
// 디바운스 타이머 ref
const debounceTimerRef = useRef(null);
// 컴포넌트 언마운트 시 타이머 정리
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
/**
* 실제 검색 실행 (디바운스 호출)
* @param {string} keyword - 검색어
*/
const executeSearch = useCallback((keyword) => {
const normalizedKeyword = normalizeSearchText(keyword);
// 최소 길이 미달 시 결과 초기화
if (!meetsMinLength(keyword, normalizedKeyword)) {
setResults([]);
return;
}
// 반경 필터 상태 확인
const isRadiusFilterActive = trackingMode === 'ship' && trackedShip !== null;
let radiusBoundingBox = null;
let radiusCenter = null;
if (isRadiusFilterActive && trackedShip?.longitude && trackedShip?.latitude) {
radiusCenter = { lon: trackedShip.longitude, lat: trackedShip.latitude };
const LON_DEGREE_METERS = 91000;
const LAT_DEGREE_METERS = 111000;
const radiusMeters = radiusNM * NM_TO_METERS;
const lonDelta = radiusMeters / LON_DEGREE_METERS;
const latDelta = radiusMeters / LAT_DEGREE_METERS;
radiusBoundingBox = {
minLon: radiusCenter.lon - lonDelta,
maxLon: radiusCenter.lon + lonDelta,
minLat: radiusCenter.lat - latDelta,
maxLat: radiusCenter.lat + latDelta,
};
}
const hasKorean = containsKorean(keyword);
const matchedShips = [];
// Map을 배열로 변환하여 for...of로 조기 종료 가능하게
const featuresArray = Array.from(features.entries());
for (const [featureId, ship] of featuresArray) {
// 최대 결과 수 도달 시 조기 종료
if (matchedShips.length >= MAX_RESULTS) break;
// 다크시그널은 검색 대상에서 제외
if (darkSignalIds.has(featureId)) continue;
// 좌표가 없는 선박 제외
if (!ship.longitude || !ship.latitude) continue;
// 반경 필터 적용 (추적 모드일 때만)
if (isRadiusFilterActive && radiusBoundingBox && radiusCenter) {
const inBounds =
ship.longitude >= radiusBoundingBox.minLon &&
ship.longitude <= radiusBoundingBox.maxLon &&
ship.latitude >= radiusBoundingBox.minLat &&
ship.latitude <= radiusBoundingBox.maxLat;
if (!inBounds || !isWithinRadius(ship, radiusCenter.lon, radiusCenter.lat, radiusNM)) {
continue;
}
}
const shipName = ship.shipName || '';
const normalizedShipName = normalizeSearchText(shipName);
let matched = false;
if (isIntegrate) {
// 통합모드 ON: targetId, shipName에서 검색
// isPriority가 있는 선박만 검색 (통합선박 대표)
if (ship.integrate && !ship.isPriority) continue;
if (hasKorean) {
// 한글 검색: shipName만
matched = normalizedShipName.includes(normalizedKeyword);
} else {
// 영문/숫자: targetId, shipName 모두
const targetId = ship.targetId || '';
const normalizedTargetId = normalizeSearchText(targetId);
matched =
normalizedTargetId.includes(normalizedKeyword) ||
normalizedShipName.includes(normalizedKeyword);
}
} else {
// 통합모드 OFF: originalTargetId (레이더 제외), shipName에서 검색
// 레이더 신호원 제외
if (ship.signalSourceCode === SIGNAL_SOURCE_CODE_RADAR) continue;
if (hasKorean) {
// 한글 검색: shipName만
matched = normalizedShipName.includes(normalizedKeyword);
} else {
// 영문/숫자: originalTargetId, shipName 모두
const originalTargetId = ship.originalTargetId || '';
const normalizedOriginalTargetId = normalizeSearchText(originalTargetId);
matched =
normalizedOriginalTargetId.includes(normalizedKeyword) ||
normalizedShipName.includes(normalizedKeyword);
}
}
if (matched) {
matchedShips.push({
featureId,
targetId: ship.targetId || '',
originalTargetId: ship.originalTargetId || '',
shipName: ship.shipName || '-',
signalSourceCode: ship.signalSourceCode,
longitude: ship.longitude,
latitude: ship.latitude,
ship,
});
}
}
setResults(matchedShips);
}, [features, isIntegrate, darkSignalIds, trackingMode, trackedShip, radiusNM]);
/**
* 검색어 변경 핸들러 (디바운스 적용)
* @param {string} keyword - 검색어
*/
const setSearchValue = useCallback((keyword) => {
setSearchValueState(keyword);
// 빈 입력 시 즉시 결과 초기화
if (!keyword || keyword.trim().length === 0) {
setResults([]);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
return;
}
// 디바운스: 타이핑 중에는 검색 지연
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
executeSearch(keyword);
debounceTimerRef.current = null;
}, DEBOUNCE_MS);
}, [executeSearch]);
/**
* 검색 결과 클릭 핸들러
* - 선박 선택 (하이라이트)
* - 상세 모달 열기
* - 지도 중심 이동
* @param {Object} result - 검색 결과 항목
*/
const handleClickResult = useCallback((result) => {
if (!map || !result) return;
const { featureId, longitude, latitude, ship } = result;
// 선박 선택 (하이라이트 표시)
setSelectedShipIds([featureId]);
// 상세 모달 열기
if (ship) {
openDetailModal(ship);
}
// 지도 중심 이동 (애니메이션)
const position = fromLonLat([longitude, latitude]);
map.getView().animate({
center: position,
zoom: 14,
duration: 500,
});
// 검색창 초기화
setSearchValueState('');
setResults([]);
}, [map, setSelectedShipIds, openDetailModal]);
/**
* 검색 초기화
*/
const clearSearch = useCallback(() => {
setSearchValueState('');
setResults([]);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
}, []);
/**
* 엔터키로 번째 결과 선택
*/
const handleSelectFirst = useCallback(() => {
if (results.length > 0) {
handleClickResult(results[0]);
}
}, [results, handleClickResult]);
return {
searchValue,
setSearchValue,
results,
handleClickResult,
handleSelectFirst,
clearSearch,
isIntegrate,
};
}

파일 보기

@ -22,6 +22,7 @@ import useShipStore, {
RADAR_TIMEOUT_MS, RADAR_TIMEOUT_MS,
SIGNAL_SOURCE_RADAR, SIGNAL_SOURCE_RADAR,
} from '../stores/shipStore'; } from '../stores/shipStore';
import useTrackingModeStore, { isWithinRadius, NM_TO_METERS } from '../stores/trackingModeStore';
import { import {
SIGNAL_KIND_CODE_FISHING, SIGNAL_KIND_CODE_FISHING,
SIGNAL_KIND_CODE_KCGV, SIGNAL_KIND_CODE_KCGV,
@ -341,6 +342,77 @@ function filterByViewport(ships, bounds) {
}); });
} }
// =====================
// 반경 필터링 (추적 모드)
// =====================
const LON_DEGREE_METERS = 91000; // 중위도(35도) 기준 경도 1도당 약 91km
const LAT_DEGREE_METERS = 111000; // 위도 1도당 약 111km
/**
* 반경 필터 상태 가져오기
* @returns {Object} { isActive, center, radiusNM, boundingBox }
*/
function getRadiusFilterState() {
const state = useTrackingModeStore.getState();
const { mode, trackedShip, radiusNM } = state;
const isActive = mode === 'ship' && trackedShip !== null;
if (!isActive || !trackedShip || !trackedShip.longitude || !trackedShip.latitude) {
return { isActive: false, center: null, radiusNM: 0, boundingBox: null };
}
const center = { lon: trackedShip.longitude, lat: trackedShip.latitude };
// Bounding Box 계산 (빠른 사전 필터용)
const radiusMeters = radiusNM * NM_TO_METERS;
const lonDelta = radiusMeters / LON_DEGREE_METERS;
const latDelta = radiusMeters / LAT_DEGREE_METERS;
const boundingBox = {
minLon: center.lon - lonDelta,
maxLon: center.lon + lonDelta,
minLat: center.lat - latDelta,
maxLat: center.lat + latDelta,
};
return { isActive: true, center, radiusNM, boundingBox };
}
/**
* 반경 필터링 적용
* - 1단계: Bounding Box 체크 (빠른 사각형 체크)
* - 2단계: Haversine 거리 계산 (정확한 원형 체크)
*
* @param {Array} ships - 선박 배열
* @param {Object} radiusState - 반경 필터 상태
* @returns {Array} 반경 선박 배열
*/
function filterByRadius(ships, radiusState) {
const { isActive, center, radiusNM, boundingBox } = radiusState;
if (!isActive || !center || !boundingBox) {
return ships;
}
return ships.filter((ship) => {
if (!ship.longitude || !ship.latitude) return false;
// 1단계: Bounding Box 체크
if (
ship.longitude < boundingBox.minLon ||
ship.longitude > boundingBox.maxLon ||
ship.latitude < boundingBox.minLat ||
ship.latitude > boundingBox.maxLat
) {
return false;
}
// 2단계: 정확한 원형 거리 체크
return isWithinRadius(ship, center.lon, center.lat, radiusNM);
});
}
// ===================== // =====================
// 카운트 + 타임아웃 cleanup 통합 함수 // 카운트 + 타임아웃 cleanup 통합 함수
// 참조: mda-react-front/src/common/deck.ts (271-471) // 참조: mda-react-front/src/common/deck.ts (271-471)
@ -376,6 +448,9 @@ function calculateAndCleanupLiveShips() {
const { features, darkSignalIds, isIntegrate, const { features, darkSignalIds, isIntegrate,
kindVisibility, sourceVisibility, nationalVisibility } = state; kindVisibility, sourceVisibility, nationalVisibility } = state;
// 반경 필터 상태
const radiusState = getRadiusFilterState();
const kindCounts = { ...initialKindCounts }; const kindCounts = { ...initialKindCounts };
let darkSignalCount = 0; let darkSignalCount = 0;
const deleteIds = []; const deleteIds = [];
@ -392,6 +467,17 @@ function calculateAndCleanupLiveShips() {
features.forEach((ship, featureId) => { features.forEach((ship, featureId) => {
// ① 이미 다크시그널 → 카운트만, 즉시 리턴 // ① 이미 다크시그널 → 카운트만, 즉시 리턴
if (darkSignalIds.has(featureId)) { if (darkSignalIds.has(featureId)) {
// 반경 필터가 활성화된 경우, 반경 내에 있을 때만 카운트
if (radiusState.isActive) {
if (!ship.longitude || !ship.latitude) return;
const inBounds = ship.longitude >= radiusState.boundingBox.minLon &&
ship.longitude <= radiusState.boundingBox.maxLon &&
ship.latitude >= radiusState.boundingBox.minLat &&
ship.latitude <= radiusState.boundingBox.maxLat;
if (!inBounds || !isWithinRadius(ship, radiusState.center.lon, radiusState.center.lat, radiusState.radiusNM)) {
return;
}
}
darkSignalCount++; darkSignalCount++;
return; return;
} }
@ -426,7 +512,19 @@ function calculateAndCleanupLiveShips() {
} }
} }
// ⑥ 카운트 대상 // ⑥ 반경 필터 체크 (카운트 전)
if (radiusState.isActive) {
if (!ship.longitude || !ship.latitude) return;
const inBounds = ship.longitude >= radiusState.boundingBox.minLon &&
ship.longitude <= radiusState.boundingBox.maxLon &&
ship.latitude >= radiusState.boundingBox.minLat &&
ship.latitude <= radiusState.boundingBox.maxLat;
if (!inBounds || !isWithinRadius(ship, radiusState.center.lon, radiusState.center.lat, radiusState.radiusNM)) {
return;
}
}
// ⑦ 카운트 대상
const targetId = ship.targetId; const targetId = ship.targetId;
const isIntegratedShip = targetId && (targetId.includes('_') || ship.integrate); const isIntegratedShip = targetId && (targetId.includes('_') || ship.integrate);
@ -620,8 +718,12 @@ class ShipBatchRenderer {
? filterByViewport(allShips, this.viewportBounds) ? filterByViewport(allShips, this.viewportBounds)
: allShips; : allShips;
// 3.5 반경 필터링 (추적 모드 활성화 시)
const radiusState = getRadiusFilterState();
const radiusFilteredShips = filterByRadius(viewportShips, radiusState);
// 4. 필터 적용 (캐시된 필터 사용 - O(1) lookup) // 4. 필터 적용 (캐시된 필터 사용 - O(1) lookup)
const filteredShips = viewportShips.filter((ship) => const filteredShips = radiusFilteredShips.filter((ship) =>
applyFilterWithCache(ship, this.cache.filterCache) applyFilterWithCache(ship, this.cache.filterCache)
); );

파일 보기

@ -14,10 +14,20 @@ import {
SIGNAL_FLAG_CONFIGS, SIGNAL_FLAG_CONFIGS,
} from '../../types/constants'; } from '../../types/constants';
import useShipStore from '../../stores/shipStore'; import useShipStore from '../../stores/shipStore';
import useTrackingModeStore from '../../stores/trackingModeStore';
// 아이콘 아틀라스 이미지 // 아이콘 아틀라스 이미지
import atlasImg from '../../assets/img/icon/atlas.png'; import atlasImg from '../../assets/img/icon/atlas.png';
// 추적 선박 아이콘 (인라인 SVG data URL)
const TRACKED_SHIP_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<circle cx="16" cy="16" r="14" stroke="#00D4FF" stroke-width="2" fill="none" stroke-dasharray="4 2"/>
<circle cx="16" cy="16" r="10" fill="#00D4FF" fill-opacity="0.3"/>
<path d="M16 6 L22 20 L16 17 L10 20 Z" fill="#00D4FF"/>
<circle cx="16" cy="14" r="2" fill="#FFFFFF"/>
</svg>`;
const TRACKED_SHIP_ICON_URL = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(TRACKED_SHIP_SVG)}`;
// ===================== // =====================
// 도 → 라디안 변환 상수 // 도 → 라디안 변환 상수
// ===================== // =====================
@ -1002,6 +1012,85 @@ export function createSelectedShipLayer(selectedShips) {
}); });
} }
/**
* 추적 선박 레이어 (최상단 표시)
* 선박 모드에서 추적 중인 함정을 특별 아이콘으로 표시
* ScatterplotLayer로 원형 마커 + IconLayer로 아이콘 표시
* @param {number} zoom - 현재 레벨
* @returns {Array} Deck.gl 레이어 배열
*/
export function createTrackedShipLayers(zoom) {
const { mode, trackedShip } = useTrackingModeStore.getState();
// 선박 모드가 아니거나 추적 중인 함정이 없으면 빈 배열
if (mode !== 'ship' || !trackedShip || !trackedShip.longitude || !trackedShip.latitude) {
return [];
}
const layers = [];
// 위치 기반 업데이트 트리거 (좌표 변경 시 레이어 갱신)
const positionKey = `${trackedShip.longitude.toFixed(6)}_${trackedShip.latitude.toFixed(6)}`;
// 1. 외곽 원형 마커 (강조 효과)
layers.push(new ScatterplotLayer({
id: 'tracked-ship-outer-ring',
data: [trackedShip],
pickable: false,
stroked: true,
filled: false,
radiusScale: 1,
radiusMinPixels: 18,
radiusMaxPixels: 35,
lineWidthMinPixels: 2,
getPosition: (d) => [d.longitude, d.latitude],
getRadius: 22,
getLineColor: [0, 212, 255, 180], // #00D4FF with alpha
getLineWidth: 2,
updateTriggers: {
getPosition: positionKey,
},
}));
// 2. 내부 원형 마커 (반투명)
layers.push(new ScatterplotLayer({
id: 'tracked-ship-inner-circle',
data: [trackedShip],
pickable: false,
stroked: false,
filled: true,
radiusScale: 1,
radiusMinPixels: 12,
radiusMaxPixels: 25,
getPosition: (d) => [d.longitude, d.latitude],
getRadius: 15,
getFillColor: [0, 212, 255, 60], // #00D4FF with more transparency
updateTriggers: {
getPosition: positionKey,
},
}));
// 3. 중심점 (흰색)
layers.push(new ScatterplotLayer({
id: 'tracked-ship-center-dot',
data: [trackedShip],
pickable: true,
stroked: false,
filled: true,
radiusScale: 1,
radiusMinPixels: 3,
radiusMaxPixels: 7,
getPosition: (d) => [d.longitude, d.latitude],
getRadius: 4,
getFillColor: [255, 255, 255, 200], // white with slight transparency
updateTriggers: {
getPosition: positionKey,
},
}));
return layers;
}
/** /**
* 모든 선박 레이어 생성 (통합) * 모든 선박 레이어 생성 (통합)
* 참조: mda-react-front/src/common/deck.ts * 참조: mda-react-front/src/common/deck.ts
@ -1033,13 +1122,19 @@ export function createShipLayers(ships, selectedShips, zoom, showLabels = false,
// 2. 선박 아이콘 레이어 (밀도 제한 적용된 전체 선박) // 2. 선박 아이콘 레이어 (밀도 제한 적용된 전체 선박)
layers.push(createShipIconLayer(ships, zoom, darkSignalIds)); layers.push(createShipIconLayer(ships, zoom, darkSignalIds));
// 3. 선명표시 레이어들 (밀도 제한된 선박 대상 → 자체 클러스터링) // 3. 추적 선박 레이어 (최상단 - 다른 아이콘 위에 표시)
const trackedLayers = createTrackedShipLayers(zoom);
if (trackedLayers.length > 0) {
layers.push(...trackedLayers);
}
// 4. 선명표시 레이어들 (밀도 제한된 선박 대상 → 자체 클러스터링)
// 아이콘이 표시되는 선박에만 라벨/신호상태 표시 // 아이콘이 표시되는 선박에만 라벨/신호상태 표시
if (showLabels) { if (showLabels) {
// 라벨 클러스터링 (밀도 제한된 ships 대상) // 라벨 클러스터링 (밀도 제한된 ships 대상)
const clusteredShips = getClusteredShips(ships, zoom, isIntegrate, renderTrigger); const clusteredShips = getClusteredShips(ships, zoom, isIntegrate, renderTrigger);
// 3-1. 속도벡터 레이어 // 4-1. 속도벡터 레이어
if (labelOptions.showSpeedVector) { if (labelOptions.showSpeedVector) {
const vectorLayer = createSpeedVectorLayer(clusteredShips, zoom); const vectorLayer = createSpeedVectorLayer(clusteredShips, zoom);
if (vectorLayer) { if (vectorLayer) {
@ -1047,13 +1142,13 @@ export function createShipLayers(ships, selectedShips, zoom, showLabels = false,
} }
} }
// 3-2. 선박명 레이어 // 4-2. 선박명 레이어
const labelLayer = createShipLabelLayer(clusteredShips, zoom, labelOptions); const labelLayer = createShipLabelLayer(clusteredShips, zoom, labelOptions);
if (labelLayer) { if (labelLayer) {
layers.push(labelLayer); layers.push(labelLayer);
} }
// 3-3. 신호상태 레이어 (별도 클러스터링) // 4-3. 신호상태 레이어 (별도 클러스터링)
if (labelOptions.showSignalStatus) { if (labelOptions.showSignalStatus) {
// 밀도 제한된 ships 대상으로 신호상태 클러스터링 // 밀도 제한된 ships 대상으로 신호상태 클러스터링
const signalClusteredShips = getSignalClusteredShips(ships, zoom, isIntegrate, renderTrigger); const signalClusteredShips = getSignalClusteredShips(ships, zoom, isIntegrate, renderTrigger);
@ -1063,7 +1158,7 @@ export function createShipLayers(ships, selectedShips, zoom, showLabels = false,
} }
} }
// 3-4. 선박크기 폴리곤 레이어 (줌 11 이상에서만) // 4-4. 선박크기 폴리곤 레이어 (줌 11 이상에서만)
// 뷰포트 내 선박만 대상으로 렌더링 (성능 최적화) // 뷰포트 내 선박만 대상으로 렌더링 (성능 최적화)
if (labelOptions.showShipSize) { if (labelOptions.showShipSize) {
const dimLayer = createShipDimLayer(clusteredShips, zoom); const dimLayer = createShipDimLayer(clusteredShips, zoom);

파일 보기

@ -1,186 +1,18 @@
@charset "utf-8"; @charset "utf-8";
/**
* MainComponent 스타일
* 메인 영역 레이아웃
*
* 참고: TopBar 스타일은 src/components/map/TopBar.scss로 분리됨
*/
#wrap { #wrap {
//* main */ //* main */
#main { #main {
width: 100%; width: 100%;
height: calc(100% - 4.4rem); height: calc(100% - 4.4rem);
position: relative; position: relative;
//* top-bar */
.topBar {
position: absolute;
top: 1.5rem;
left: 0;
right: 0;
height: 4.4rem;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 5.4rem;
box-sizing: border-box;
z-index: 95;
}
//* top-location */
.locationInfo {
flex: 0 0 auto;
min-width: 71rem;
height: 3.8rem;
margin: 0 auto;
display: flex;
align-items: center;
background-color: rgba(var(--secondary6-rgb), .9);
overflow: hidden;
ul {
display: flex;
align-items: center;
width: 100%;
height: 100%;
li {
display: flex;
align-items: center;
height: 100%;
padding: 0 1rem;
color: var(--white);
font-weight: var(--fw-bold);
font-size: 1.3rem;
&:first-child,
&:last-child {
padding: 0;
}
span + span {
padding-right: .5rem;
}
&.divider {
position: relative;
&::after {
content: "";
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 1px;
height: 1.8rem;
background-color: rgba(255, 255, 255, .3);
}
}
.wgs {
display: flex;
align-items: center;
padding-right: .5rem;
&::before {
content: "";
display: block;
width: 2rem;
height: 2rem;
margin-right: .5rem;
background: url(../assets/images/ico_globe.svg) no-repeat center / 2rem;
}
}
.kst {
display: flex;
align-items: center;
justify-content: center;
width: 3.1rem;
height: 1.8rem;
margin-right: .5rem;
border-radius: .3rem;
background-color: var(--tertiary3);
color: var(--white);
font-size: var(--fs-s);
}
}
}
button {
width: 4rem;
height: 3.8rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
box-sizing: border-box;
background-color: var(--secondary3);
background-repeat: no-repeat;
background-position: center;
background-size: 3rem;
&:hover,
&.active {
background-color: rgba(var(--primary1-rgb), .8);
}
&.map {
background-image: url(../assets/images/ico_map.svg);
}
&.ship {
background-image: url(../assets/images/ico_ship.svg);
}
&.set {
width: 2rem;
height: 2rem;
background-color: transparent;
background-image: url(../assets/images/ico_set_s.svg);
background-size: 2rem;
&:hover,
&.active {
background-color: transparent;
}
}
}
}
//* top-search */
.topSchBox {
flex: 0 0 auto;
width: 20rem;
height: 3.8rem;
display: flex;
justify-content: flex-end;
position: relative;
.tschInput {
width: 100%;
height: 100%;
padding-right: 4.4rem;
border-radius: 2rem;
background-color: rgba(var(--secondary6-rgb), .9);
border: 0;
&::placeholder {
color: var(--white);
}
}
.mainSchBtn {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
width: 4rem;
height: 3.8rem;
font-size: 0;
text-indent: -999999em;
border: none;
cursor: pointer;
background: url(../assets/images/ico_search_main.svg) no-repeat center left / 2.4rem;
}
}
} }
} }

파일 보기

@ -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;