Compare commits

..

13 커밋

작성자 SHA1 메시지 날짜
ac3c204843 refactor: 민간화 + 팀 프로젝트 구조 전환
- 해경 관련 코드/에셋 정리 (KCGV, 해경관할구역 FGB, PatrolShipSelector)
- 위성/기상/퍼블리시/레거시 모듈 전체 삭제
- STOMP WebSocket → AIS Target API HTTP 폴링 방식 전환
- 세션 인증 임시 비활성화 (VITE_DEV_SKIP_AUTH)
- 환경변수 민간 데모용으로 재구성
- 팀 워크플로우 v1.2.0 구조 적용 (.claude/rules, skills, settings)
- .githooks, .editorconfig, .node-version 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 06:13:08 +09:00
LHT
086599bb6d fix: class → className JSX 속성 수정
- publish/pages 및 component/wrap 하위 12개 컴포넌트의 class 속성을 className으로 변환

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 13:54:56 +09:00
LHT
8ccb261d65 feat: 해경관할구역 FGB 레이어 + 필터 개인설정 영속화 (AI모드/위험물)
- useCoastGuardLayer: flatgeobuf 해경관할구역 레이어 (테마별 스타일)
- userSettingApi: 필터 개인설정 저장/불러오기 API
- applyFilterSettings/buildFilterSettings에 AI모드(6개 서브) + 위험물 추가
- AI모드 전체 토글: 선종/국적/신호와 동일 every 패턴으로 통일
- DisplayComponent: AI모드/위험물/해경관할구역/관심구역 토글 바인딩
- 해경관할구역 기본값 ON

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 13:54:46 +09:00
LHT
059b0670fc feat: 관심선박 필터/강조 레이어 + 관심구역 폴리곤 표시
- favoriteApi: 관심선박/관심구역 API 연동
- favoriteStore: favoriteSet(O(1) lookup), realmList 상태 관리
- ShipBatchRenderer: 관심선박 필터 우선 통과 + 밀도 제한 최우선
- shipLayer: 관심선박 위치에 ico_favship.svg 강조 IconLayer 오버레이
- useRealmLayer: 관심구역 OpenLayers 폴리곤(이름/색상/윤곽선) 렌더링
- useShipLayer: favoriteStore 변경 시 즉시 리렌더 구독

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 13:54:32 +09:00
LHT
de2cd907f1 feat: 로그인 세션 통합 (인증 가드, fetchWithAuth, 환경변수)
- authStore: 메인 프로젝트 세션 쿠키 기반 인증 상태 관리
- fetchWithAuth: 401 응답 시 메인 프로젝트 로그인 페이지 리다이렉트
- SessionGuard: 앱 진입 시 세션 유효성 검증 래퍼 컴포넌트
- 기존 API 모듈 fetch → fetchWithAuth 전환
- 환경변수에 VITE_MAIN_APP_URL, VITE_DEV_SKIP_AUTH 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 13:54:21 +09:00
LHT
34d5f6ef9e Merge branch 'feature/area-search' into develop 2026-02-12 06:27:34 +09:00
LHT
1c36789612 Merge remote-tracking branch 'origin/feat/weather' into develop
# Conflicts:
#	src/components/layout/Sidebar.jsx
#	src/map/MapContainer.jsx
2026-02-12 06:27:26 +09:00
LHT
4945606c1c feat: STS 분석 기능 구현 및 항적분석 고도화
- STS(Ship-to-Ship) 접촉 분석 기능 전체 구현
  - API 연동 (vessel-contacts), 스토어, 레이어 훅, 레이어 레지스트리
  - 접촉 쌍 그룹핑, 그룹 카드 목록, 상세 모달 (그리드 레이아웃)
  - ScatterplotLayer 접촉 포인트 + 위험도 색상
- 항적분석 탭 UI 분리 (구역분석 / STS분석)
  - AreaSearchPage → AreaSearchTab, StsAnalysisTab 추출
  - 탭 전환 시 결과 초기화 확인, 구역 클리어
- 지도 호버 하이라이트 구현 (구역분석 + STS)
  - MapContainer pointermove에 STS 레이어 ID 핸들러 추가
  - STS 쌍 항적 동시 하이라이트 (vesselId → groupIndex 매핑)
  - 목록↔지도 호버 연동 자동 스크롤
  - pickingRadius 12→20 확대
- 재생 컨트롤러(AreaSearchTimeline) STS 지원
  - 항적/궤적 토글 activeTab 기반 스토어 분기
  - 닫기 시 양쪽 스토어 + 레이어 정리
- 패널 닫기 초기화 수정 (isOpen 감지, clearResults로 탭 보존)
- 조회 중 로딩 오버레이 (LoadingOverlay 공통 컴포넌트)
- 항적분석 다중 방문 대응, 선박 상세 모달, 구역 편집 기능
- trackLayer updateTriggers Set 직렬화, highlightedVesselIds 지원

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 06:20:46 +09:00
jeonghyo.K
81255c4839 추가 수정사항 반영
1. 페이지네이션, 스크롤, 날짜 선택 ui 추가
2. 공통코드 조회 api 적용 (위성 조회, 등록 팝업 등)
3. 필수값 입력 메세지 추가
2026-02-11 16:49:26 +09:00
jeonghyo.K
1c991d8229 위성 오타 수정 2026-02-11 13:51:15 +09:00
ecfc25edde Merge branch 'feature/area-search' into 'develop'
feat: 항적분석(구역 검색) 기능 구현

See merge request mda/kcgv-react-frontend!1
2026-02-10 03:31:28 +00:00
LHT
5d7a45984a chore: 환경변수 파일 초기 등록
빌드 환경별 .env 파일 1회성 추가 (gitignore 유지)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 12:30:28 +09:00
LHT
dcf24e96d2 feat: 항적분석(구역 검색) 기능 구현
구역 기반 선박 항적 검색 기능 추가. 사용자가 지도에 최대 3개 구역을
그리고 ANY/ALL/SEQUENTIAL 조건으로 해당 구역을 통과한 선박의 항적을
조회·재생할 수 있다.

신규 패키지 (src/areaSearch/):
- stores: areaSearchStore, areaSearchAnimationStore (재생 제어)
- services: areaSearchApi (REST API + hitDetails 타임스탬프/위치 보간)
- components: AreaSearchPage, ZoneDrawPanel, AreaSearchTimeline, AreaSearchTooltip
- hooks: useAreaSearchLayer (Deck.gl 레이어), useZoneDraw (OL Draw)
- utils: areaSearchLayerRegistry, csvExport (BOM+UTF-8 엑셀 호환)
- types: areaSearch.types (상수, 색상, 모드)

주요 기능:
- 폴리곤/사각형/원 구역 그리기 + 드래그 순서 변경
- 구역별 색상 구분 (빨강/청록/황색)
- 시간 기반 애니메이션 재생 (TripsLayer 궤적 + 가상선박 이동)
- 선종/개별 선박 필터링, 항적 표시/궤적 표시 토글
- 호버 툴팁 (국기 SVG, 구역별 진입/진출 시각·위치)
- CSV 내보내기 (신호원, 식별번호, 국적 ISO 변환, 구역 통과 정보)

기존 파일 수정:
- SideNav/Sidebar: gnb8 '항적분석' 메뉴 활성화
- useShipLayer: areaSearch 레이어 병합
- MapContainer: useAreaSearchLayer 훅 + 호버 핸들러 + 타임라인 렌더링
- trackLayer: layerIds 파라미터 추가 (area search/track query 레이어 ID 분리)
- ShipLegend: 항적분석 모드 선종 카운트 지원
- countryCodeUtils: MMSI MID→ISO alpha-2 매핑 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 12:29:31 +09:00
223개의 변경된 파일10082개의 추가작업 그리고 16463개의 파일을 삭제

파일 보기

@ -0,0 +1,69 @@
# TypeScript/React 코드 스타일 규칙
## TypeScript 일반
- strict 모드 필수 (`tsconfig.json`)
- `any` 사용 금지 (불가피한 경우 주석으로 사유 명시)
- 타입 정의: `interface` 우선 (type은 유니온/인터섹션에만)
- 들여쓰기: 2 spaces
- 세미콜론: 사용
- 따옴표: single quote
- trailing comma: 사용
## React 규칙
### 컴포넌트
- 함수형 컴포넌트 + hooks 패턴만 사용
- 클래스 컴포넌트 사용 금지
- 컴포넌트 파일 당 하나의 export default 컴포넌트
- Props 타입은 interface로 정의 (ComponentNameProps)
```tsx
interface UserCardProps {
name: string;
email: string;
onEdit?: () => void;
}
const UserCard = ({ name, email, onEdit }: UserCardProps) => {
return (
<div>
<h3>{name}</h3>
<p>{email}</p>
{onEdit && <button onClick={onEdit}>편집</button>}
</div>
);
};
export default UserCard;
```
### Hooks
- 커스텀 훅은 `use` 접두사 (예: `useAuth`, `useFetch`)
- 훅은 `src/hooks/` 디렉토리에 분리
- 복잡한 상태 로직은 커스텀 훅으로 추출
### 상태 관리
- 컴포넌트 로컬 상태: `useState`
- 공유 상태: Context API 또는 Zustand
- 서버 상태: React Query (TanStack Query) 권장
### 이벤트 핸들러
- `handle` 접두사: `handleClick`, `handleSubmit`
- Props로 전달 시 `on` 접두사: `onClick`, `onSubmit`
## 스타일링
- CSS Modules 또는 Tailwind CSS (프로젝트 설정에 따름)
- 인라인 스타일 지양
- !important 사용 금지
## API 호출
- API 호출 로직은 `src/services/`에 분리
- Axios 또는 fetch wrapper 사용
- 에러 처리: try-catch + 사용자 친화적 에러 메시지
- 환경별 API URL은 `.env`에서 관리
## 기타
- console.log 커밋 금지 (디버깅 후 제거)
- 매직 넘버/문자열 → 상수 파일로 추출
- 사용하지 않는 import, 변수 제거 (ESLint로 검증)
- 이미지/아이콘은 `src/assets/`에 관리

파일 보기

@ -0,0 +1,84 @@
# Git 워크플로우 규칙
## 브랜치 전략
### 브랜치 구조
```
main ← 배포 가능한 안정 브랜치 (보호됨)
└── develop ← 개발 통합 브랜치
├── feature/ISSUE-123-기능설명
├── bugfix/ISSUE-456-버그설명
└── hotfix/ISSUE-789-긴급수정
```
### 브랜치 네이밍
- feature 브랜치: `feature/ISSUE-번호-간단설명` (예: `feature/ISSUE-42-user-login`)
- bugfix 브랜치: `bugfix/ISSUE-번호-간단설명`
- hotfix 브랜치: `hotfix/ISSUE-번호-간단설명`
- 이슈 번호가 없는 경우: `feature/간단설명` (예: `feature/add-swagger-docs`)
### 브랜치 규칙
- main, develop 브랜치에 직접 커밋/푸시 금지
- feature 브랜치는 develop에서 분기
- hotfix 브랜치는 main에서 분기
- 머지는 반드시 MR(Merge Request)을 통해 수행
## 커밋 메시지 규칙
### Conventional Commits 형식
```
type(scope): subject
body (선택)
footer (선택)
```
### type (필수)
| type | 설명 |
|------|------|
| feat | 새로운 기능 추가 |
| fix | 버그 수정 |
| docs | 문서 변경 |
| style | 코드 포맷팅 (기능 변경 없음) |
| refactor | 리팩토링 (기능 변경 없음) |
| test | 테스트 추가/수정 |
| chore | 빌드, 설정 변경 |
| ci | CI/CD 설정 변경 |
| perf | 성능 개선 |
### scope (선택)
- 변경 범위를 나타내는 짧은 단어
- 한국어, 영어 모두 허용 (예: `feat(인증): 로그인 기능`, `fix(auth): token refresh`)
### subject (필수)
- 변경 내용을 간결하게 설명
- 한국어, 영어 모두 허용
- 72자 이내
- 마침표(.) 없이 끝냄
### 예시
```
feat(auth): JWT 기반 로그인 구현
fix(배치): 야간 배치 타임아웃 수정
docs: README에 빌드 방법 추가
refactor(user-service): 중복 로직 추출
test(결제): 환불 로직 단위 테스트 추가
chore: Gradle 의존성 버전 업데이트
```
## MR(Merge Request) 규칙
### MR 생성
- 제목: 커밋 메시지와 동일한 Conventional Commits 형식
- 본문: 변경 내용 요약, 테스트 방법, 관련 이슈 번호
- 라벨: 적절한 라벨 부착 (feature, bugfix, hotfix 등)
### MR 리뷰
- 최소 1명의 리뷰어 승인 필수
- CI 검증 통과 필수 (설정된 경우)
- 리뷰 코멘트 모두 해결 후 머지
### MR 머지
- Squash Merge 권장 (깔끔한 히스토리)
- 머지 후 소스 브랜치 삭제

53
.claude/rules/naming.md Normal file
파일 보기

@ -0,0 +1,53 @@
# TypeScript/React 네이밍 규칙
## 파일명
| 항목 | 규칙 | 예시 |
|------|------|------|
| 컴포넌트 | PascalCase | `UserCard.tsx`, `LoginForm.tsx` |
| 페이지 | PascalCase | `Dashboard.tsx`, `UserList.tsx` |
| 훅 | camelCase + use 접두사 | `useAuth.ts`, `useFetch.ts` |
| 서비스 | camelCase | `userService.ts`, `authApi.ts` |
| 유틸리티 | camelCase | `formatDate.ts`, `validation.ts` |
| 타입 정의 | camelCase | `user.types.ts`, `api.types.ts` |
| 상수 | camelCase | `routes.ts`, `constants.ts` |
| 스타일 | 컴포넌트명 + .module | `UserCard.module.css` |
| 테스트 | 대상 + .test | `UserCard.test.tsx` |
## 변수/함수
| 항목 | 규칙 | 예시 |
|------|------|------|
| 변수 | camelCase | `userName`, `isLoading` |
| 함수 | camelCase | `getUserList`, `formatDate` |
| 상수 | UPPER_SNAKE_CASE | `MAX_RETRY`, `API_BASE_URL` |
| boolean 변수 | is/has/can/should 접두사 | `isActive`, `hasPermission` |
| 이벤트 핸들러 | handle 접두사 | `handleClick`, `handleSubmit` |
| 이벤트 Props | on 접두사 | `onClick`, `onSubmit` |
## 타입/인터페이스
| 항목 | 규칙 | 예시 |
|------|------|------|
| interface | PascalCase | `UserProfile`, `ApiResponse` |
| Props | 컴포넌트명 + Props | `UserCardProps`, `ButtonProps` |
| 응답 타입 | 도메인 + Response | `UserResponse`, `LoginResponse` |
| 요청 타입 | 동작 + Request | `CreateUserRequest` |
| Enum | PascalCase | `UserStatus`, `HttpMethod` |
| Enum 값 | UPPER_SNAKE_CASE | `ACTIVE`, `PENDING` |
| Generic | 단일 대문자 | `T`, `K`, `V` |
## 디렉토리
- 모두 kebab-case 또는 camelCase (프로젝트 통일)
- 예: `src/components/common/`, `src/hooks/`, `src/services/`
## 컴포넌트 구조 예시
```
src/components/user-card/
├── UserCard.tsx # 컴포넌트
├── UserCard.module.css # 스타일
├── UserCard.test.tsx # 테스트
└── index.ts # re-export
```

파일 보기

@ -0,0 +1,34 @@
# 팀 정책 (Team Policy)
이 규칙은 조직 전체에 적용되는 필수 정책입니다.
프로젝트별 `.claude/rules/`에 추가 규칙을 정의할 수 있으나, 이 정책을 위반할 수 없습니다.
## 보안 정책
### 금지 행위
- `.env`, `.env.*`, `secrets/` 파일 읽기 및 내용 출력 금지
- 비밀번호, API 키, 토큰 등 민감 정보를 코드에 하드코딩 금지
- `git push --force`, `git reset --hard`, `git clean -fd` 실행 금지
- `rm -rf /`, `rm -rf ~`, `rm -rf .git` 등 파괴적 명령 실행 금지
- main/develop 브랜치에 직접 push 금지 (MR을 통해서만 머지)
### 인증 정보 관리
- 환경변수 또는 외부 설정 파일(`.env`, `application-local.yml`)로 관리
- 설정 파일은 `.gitignore`에 반드시 포함
- 예시 파일(`.env.example`, `application.yml.example`)만 커밋
## 코드 품질 정책
### 필수 검증
- 커밋 전 빌드(컴파일) 성공 확인
- 린트 경고 0개 유지 (CI에서도 검증)
- 테스트 코드가 있는 프로젝트는 테스트 통과 필수
### 코드 리뷰
- main 브랜치 머지 시 최소 1명 리뷰 필수
- 리뷰어 승인 없이 머지 불가
## 문서화 정책
- 공개 API(controller endpoint)에는 반드시 설명 주석 작성
- 복잡한 비즈니스 로직에는 의도를 설명하는 주석 작성
- README.md에 프로젝트 빌드/실행 방법 유지

64
.claude/rules/testing.md Normal file
파일 보기

@ -0,0 +1,64 @@
# TypeScript/React 테스트 규칙
## 테스트 프레임워크
- Vitest (Vite 프로젝트) 또는 Jest
- React Testing Library (컴포넌트 테스트)
- MSW (Mock Service Worker, API 모킹)
## 테스트 구조
### 단위 테스트
- 유틸리티 함수, 커스텀 훅 테스트
- 외부 의존성 없이 순수 로직 검증
```typescript
describe('formatDate', () => {
it('날짜를 YYYY-MM-DD 형식으로 변환한다', () => {
const result = formatDate(new Date('2026-02-14'));
expect(result).toBe('2026-02-14');
});
it('유효하지 않은 날짜는 빈 문자열을 반환한다', () => {
const result = formatDate(new Date('invalid'));
expect(result).toBe('');
});
});
```
### 컴포넌트 테스트
- React Testing Library 사용
- 사용자 관점에서 테스트 (구현 세부사항이 아닌 동작 테스트)
- `getByRole`, `getByText` 등 접근성 기반 쿼리 우선
```tsx
describe('UserCard', () => {
it('사용자 이름과 이메일을 표시한다', () => {
render(<UserCard name="홍길동" email="hong@test.com" />);
expect(screen.getByText('홍길동')).toBeInTheDocument();
expect(screen.getByText('hong@test.com')).toBeInTheDocument();
});
it('편집 버튼 클릭 시 onEdit 콜백을 호출한다', async () => {
const onEdit = vi.fn();
render(<UserCard name="홍길동" email="hong@test.com" onEdit={onEdit} />);
await userEvent.click(screen.getByRole('button', { name: '편집' }));
expect(onEdit).toHaveBeenCalledOnce();
});
});
```
### 테스트 패턴
- **Arrange-Act-Assert** 구조
- 테스트 설명은 한국어로 작성 (`it('사용자 이름을 표시한다')`)
- 하나의 테스트에 하나의 검증
## 테스트 커버리지
- 새로 작성하는 유틸리티 함수: 테스트 필수
- 컴포넌트: 주요 상호작용 테스트 권장
- API 호출: MSW로 모킹하여 에러/성공 시나리오 테스트
## 금지 사항
- 구현 세부사항 테스트 금지 (state 값 직접 확인 등)
- `getByTestId` 남용 금지 (접근성 쿼리 우선)
- 스냅샷 테스트 남용 금지 (변경에 취약)
- `setTimeout`으로 비동기 대기 금지 → `waitFor`, `findBy` 사용

78
.claude/settings.json Normal file
파일 보기

@ -0,0 +1,78 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(yarn *)",
"Bash(npm *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(git status)",
"Bash(git diff*)",
"Bash(git log*)",
"Bash(git branch*)",
"Bash(git checkout*)",
"Bash(git add*)",
"Bash(git commit*)",
"Bash(git pull*)",
"Bash(git fetch*)",
"Bash(git merge*)",
"Bash(git stash*)",
"Bash(git remote*)",
"Bash(git config*)",
"Bash(git rev-parse*)",
"Bash(git show*)",
"Bash(git tag*)",
"Bash(curl -s *)",
"Bash(fnm *)"
],
"deny": [
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"Bash(git clean -fd*)",
"Bash(git checkout -- .)",
"Bash(rm -rf /)",
"Bash(rm -rf ~)",
"Bash(rm -rf .git*)",
"Bash(rm -rf /*)",
"Read(./**/.env*)",
"Read(./**/secrets/**)"
]
},
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-post-compact.sh",
"timeout": 10
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-pre-compact.sh",
"timeout": 30
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-commit.sh",
"timeout": 15
}
]
}
]
}
}

파일 보기

@ -0,0 +1,65 @@
---
name: create-mr
description: 현재 브랜치에서 Gitea MR(Merge Request)을 생성합니다
allowed-tools: "Bash, Read, Grep"
argument-hint: "[target-branch: develop|main] (기본: develop)"
---
현재 브랜치의 변경 사항을 기반으로 Gitea에 MR을 생성합니다.
타겟 브랜치: $ARGUMENTS (기본: develop)
## 수행 단계
### 1. 사전 검증
- 현재 브랜치가 main/develop이 아닌지 확인
- 커밋되지 않은 변경 사항 확인 (있으면 경고)
- 리모트에 현재 브랜치가 push되어 있는지 확인 (안 되어 있으면 push)
### 2. 변경 내역 분석
```bash
git log develop..HEAD --oneline
git diff develop..HEAD --stat
```
- 커밋 목록과 변경된 파일 목록 수집
- 주요 변경 사항 요약 작성
### 3. MR 정보 구성
- **제목**: 브랜치의 첫 커밋 메시지 또는 브랜치명에서 추출
- `feature/ISSUE-42-user-login``feat: ISSUE-42 user-login`
- **본문**:
```markdown
## 변경 사항
- (커밋 기반 자동 생성)
## 관련 이슈
- closes #이슈번호 (브랜치명에서 추출)
## 테스트
- [ ] 빌드 성공 확인
- [ ] 기존 테스트 통과
```
### 4. Gitea API로 MR 생성
```bash
# Gitea remote URL에서 owner/repo 추출
REMOTE_URL=$(git remote get-url origin)
# Gitea API 호출
curl -X POST "GITEA_URL/api/v1/repos/{owner}/{repo}/pulls" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"title": "MR 제목",
"body": "MR 본문",
"head": "현재브랜치",
"base": "타겟브랜치"
}'
```
### 5. 결과 출력
- MR URL 출력
- 리뷰어 지정 안내
- 다음 단계: 리뷰 대기 → 승인 → 머지
## 필요 환경변수
- `GITEA_TOKEN`: Gitea API 접근 토큰 (없으면 안내)

파일 보기

@ -0,0 +1,49 @@
---
name: fix-issue
description: Gitea 이슈를 분석하고 수정 브랜치를 생성합니다
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
argument-hint: "<issue-number>"
---
Gitea 이슈 #$ARGUMENTS 를 분석하고 수정 작업을 시작합니다.
## 수행 단계
### 1. 이슈 조회
```bash
curl -s "GITEA_URL/api/v1/repos/{owner}/{repo}/issues/$ARGUMENTS" \
-H "Authorization: token ${GITEA_TOKEN}"
```
- 이슈 제목, 본문, 라벨, 담당자 정보 확인
- 이슈 내용을 사용자에게 요약하여 보여줌
### 2. 브랜치 생성
이슈 라벨에 따라 브랜치 타입 결정:
- `bug` 라벨 → `bugfix/ISSUE-번호-설명`
- 그 외 → `feature/ISSUE-번호-설명`
- 긴급 → `hotfix/ISSUE-번호-설명`
```bash
git checkout develop
git pull origin develop
git checkout -b {type}/ISSUE-{number}-{slug}
```
### 3. 이슈 분석
이슈 내용을 바탕으로:
- 관련 파일 탐색 (Grep, Glob 활용)
- 영향 범위 파악
- 수정 방향 제안
### 4. 수정 계획 제시
사용자에게 수정 계획을 보여주고 승인을 받은 후 작업 진행:
- 수정할 파일 목록
- 변경 내용 요약
- 예상 영향
### 5. 작업 완료 후
- 변경 사항 요약
- `/create-mr` 실행 안내
## 필요 환경변수
- `GITEA_TOKEN`: Gitea API 접근 토큰

파일 보기

@ -0,0 +1,90 @@
---
name: init-project
description: 팀 표준 워크플로우로 프로젝트를 초기화합니다
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
argument-hint: "[project-type: java-maven|java-gradle|react-ts|auto]"
---
팀 표준 워크플로우에 따라 프로젝트를 초기화합니다.
프로젝트 타입: $ARGUMENTS (기본: auto — 자동 감지)
## 프로젝트 타입 자동 감지
$ARGUMENTS가 "auto"이거나 비어있으면 다음 순서로 감지:
1. `pom.xml` 존재 → **java-maven**
2. `build.gradle` 또는 `build.gradle.kts` 존재 → **java-gradle**
3. `package.json` + `tsconfig.json` 존재 → **react-ts**
4. 감지 실패 → 사용자에게 타입 선택 요청
## 수행 단계
### 1. 프로젝트 분석
- 빌드 파일, 설정 파일, 디렉토리 구조 파악
- 사용 중인 프레임워크, 라이브러리 감지
- 기존 `.claude/` 디렉토리 존재 여부 확인
### 2. CLAUDE.md 생성
프로젝트 루트에 CLAUDE.md를 생성하고 다음 내용 포함:
- 프로젝트 개요 (이름, 타입, 주요 기술 스택)
- 빌드/실행 명령어 (감지된 빌드 도구 기반)
- 테스트 실행 명령어
- 프로젝트 디렉토리 구조 요약
- 팀 컨벤션 참조 (`.claude/rules/` 안내)
### 3. .claude/ 디렉토리 구성
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우:
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정
- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing)
- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow)
### 4. Git Hooks 설정
```bash
git config core.hooksPath .githooks
```
`.githooks/` 디렉토리에 실행 권한 부여:
```bash
chmod +x .githooks/*
```
### 5. 프로젝트 타입별 추가 설정
#### java-maven
- `.sdkmanrc` 생성 (java=17.0.18-amzn 또는 프로젝트에 맞는 버전)
- `.mvn/settings.xml` Nexus 미러 설정 확인
- `mvn compile` 빌드 성공 확인
#### java-gradle
- `.sdkmanrc` 생성
- `gradle.properties.example` Nexus 설정 확인
- `./gradlew compileJava` 빌드 성공 확인
#### react-ts
- `.node-version` 생성 (프로젝트에 맞는 Node 버전)
- `.npmrc` Nexus 레지스트리 설정 확인
- `npm install && npm run build` 성공 확인
### 6. .gitignore 확인
다음 항목이 .gitignore에 포함되어 있는지 확인하고, 없으면 추가:
```
.claude/settings.local.json
.claude/CLAUDE.local.md
.env
.env.*
*.local
```
### 7. workflow-version.json 생성
`.claude/workflow-version.json` 파일을 생성하여 현재 글로벌 워크플로우 버전 기록:
```json
{
"applied_global_version": "1.0.0",
"applied_date": "현재날짜",
"project_type": "감지된타입"
}
```
### 8. 검증 및 요약
- 생성/수정된 파일 목록 출력
- `git config core.hooksPath` 확인
- 빌드 명령 실행 가능 확인
- 다음 단계 안내 (개발 시작, 첫 커밋 방법 등)

파일 보기

@ -0,0 +1,73 @@
---
name: sync-team-workflow
description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
---
팀 글로벌 워크플로우의 최신 버전을 현재 프로젝트에 적용합니다.
## 수행 절차
### 1. 글로벌 버전 조회
Gitea API로 template-common 리포의 workflow-version.json 조회:
```bash
GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'http://211.208.115.83:3000'))" 2>/dev/null || echo "http://211.208.115.83:3000")
curl -sf "${GITEA_URL}/api/v1/repos/gcsc/template-common/raw/workflow-version.json"
```
### 2. 버전 비교
로컬 `.claude/workflow-version.json`과 비교:
- 버전 일치 → "최신 버전입니다" 안내 후 종료
- 버전 불일치 → 미적용 변경 항목 추출하여 표시
### 3. 프로젝트 타입 감지
자동 감지 순서:
1. `.claude/workflow-version.json``project_type` 필드 확인
2. 없으면: `pom.xml` → java-maven, `build.gradle` → java-gradle, `package.json` → react-ts
### 4. 파일 다운로드 및 적용
Gitea API로 해당 타입 + common 템플릿 파일 다운로드:
#### 4-1. 규칙 파일 (덮어쓰기)
팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체:
```
.claude/rules/team-policy.md
.claude/rules/git-workflow.md
.claude/rules/code-style.md (타입별)
.claude/rules/naming.md (타입별)
.claude/rules/testing.md (타입별)
```
#### 4-2. settings.json (부분 갱신)
- `deny` 목록: 글로벌 최신으로 교체
- `allow` 목록: 기존 사용자 커스텀 유지 + 글로벌 기본값 병합
- `hooks`: 글로벌 최신으로 교체
#### 4-3. 스킬 파일 (덮어쓰기)
```
.claude/skills/create-mr/SKILL.md
.claude/skills/fix-issue/SKILL.md
.claude/skills/sync-team-workflow/SKILL.md
```
#### 4-4. Git Hooks (덮어쓰기 + 실행 권한)
```bash
chmod +x .githooks/*
```
### 5. 로컬 버전 업데이트
`.claude/workflow-version.json` 갱신:
```json
{
"applied_global_version": "새버전",
"applied_date": "오늘날짜",
"project_type": "감지된타입"
}
```
### 6. 변경 보고
- `git diff`로 변경 내역 확인
- 업데이트된 파일 목록 출력
- 변경 로그(글로벌 workflow-version.json의 changes) 표시
- 필요한 추가 조치 안내 (빌드 확인, 의존성 업데이트 등)

파일 보기

@ -0,0 +1 @@
{"applied_global_version": "1.2.0"}

33
.editorconfig Normal file
파일 보기

@ -0,0 +1,33 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{java,kt}]
indent_style = space
indent_size = 4
[*.{js,jsx,ts,tsx,json,yml,yaml,css,scss,html}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.{sh,bash}]
indent_style = space
indent_size = 4
[Makefile]
indent_style = tab
[*.{gradle,groovy}]
indent_style = space
indent_size = 4
[*.xml]
indent_style = space
indent_size = 4

16
.env Normal file
파일 보기

@ -0,0 +1,16 @@
# ============================================
# 프로덕션 환경 (Production)
# - 빌드: yarn build:prod (또는 yarn build)
# ============================================
# 배포 경로
VITE_BASE_URL=/
# API 서버 (SNP-Batch API)
VITE_API_URL=http://211.208.115.83:8041/snp-api
# 선박 데이터 쓰로틀링 (ms, 0=무제한)
VITE_SHIP_THROTTLE=0
# 인증 우회 (민간 데모)
VITE_DEV_SKIP_AUTH=true

16
.env.development Normal file
파일 보기

@ -0,0 +1,16 @@
# ============================================
# 로컬 개발 환경 (Local Development)
# - 서버: yarn dev
# ============================================
# 배포 경로
VITE_BASE_URL=/
# API 서버 (SNP-Batch API)
VITE_API_URL=http://211.208.115.83:8041/snp-api
# 선박 데이터 쓰로틀링 (ms, 0=무제한)
VITE_SHIP_THROTTLE=0
# 인증 우회 (민간 데모)
VITE_DEV_SKIP_AUTH=true

3
.gitattributes vendored Normal file
파일 보기

@ -0,0 +1,3 @@
# 오프라인 캐시 바이너리 보호 (Windows autocrlf 변환 방지)
*.tgz binary

60
.githooks/commit-msg Executable file
파일 보기

@ -0,0 +1,60 @@
#!/bin/bash
#==============================================================================
# commit-msg hook
# Conventional Commits 형식 검증 (한/영 혼용 지원)
#==============================================================================
COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Merge 커밋은 검증 건너뜀
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Merge "; then
exit 0
fi
# Revert 커밋은 검증 건너뜀
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Revert "; then
exit 0
fi
# Conventional Commits 정규식
# type(scope): subject
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
# - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택)
# - subject: 1~72자, 한/영 혼용 허용 (필수)
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9가-힣._-]+\))?: .{1,72}$'
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo " 올바른 형식: type(scope): subject"
echo ""
echo " type (필수):"
echo " feat — 새로운 기능"
echo " fix — 버그 수정"
echo " docs — 문서 변경"
echo " style — 코드 포맷팅"
echo " refactor — 리팩토링"
echo " test — 테스트"
echo " chore — 빌드/설정 변경"
echo " ci — CI/CD 변경"
echo " perf — 성능 개선"
echo ""
echo " scope (선택): 한/영 모두 가능"
echo " subject (필수): 1~72자, 한/영 모두 가능"
echo ""
echo " 예시:"
echo " feat(auth): JWT 기반 로그인 구현"
echo " fix(배치): 야간 배치 타임아웃 수정"
echo " docs: README 업데이트"
echo " chore: Gradle 의존성 업데이트"
echo ""
echo " 현재 메시지: $FIRST_LINE"
echo ""
exit 1
fi

25
.githooks/post-checkout Executable file
파일 보기

@ -0,0 +1,25 @@
#!/bin/bash
#==============================================================================
# post-checkout hook
# 브랜치 체크아웃 시 core.hooksPath 자동 설정
# clone/checkout 후 .githooks 디렉토리가 있으면 자동으로 hooksPath 설정
#==============================================================================
# post-checkout 파라미터: prev_HEAD, new_HEAD, branch_flag
# branch_flag=1: 브랜치 체크아웃, 0: 파일 체크아웃
BRANCH_FLAG="$3"
# 파일 체크아웃은 건너뜀
if [ "$BRANCH_FLAG" = "0" ]; then
exit 0
fi
# .githooks 디렉토리 존재 확인
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -d "${REPO_ROOT}/.githooks" ]; then
CURRENT_HOOKS_PATH=$(git config core.hooksPath 2>/dev/null || echo "")
if [ "$CURRENT_HOOKS_PATH" != ".githooks" ]; then
git config core.hooksPath .githooks
chmod +x "${REPO_ROOT}/.githooks/"* 2>/dev/null
fi
fi

36
.githooks/pre-commit Executable file
파일 보기

@ -0,0 +1,36 @@
#!/bin/bash
#==============================================================================
# pre-commit hook (React JavaScript)
# ESLint 검증 — 실패 시 커밋 차단
#==============================================================================
# npm 확인
if ! command -v npx &>/dev/null; then
echo "경고: npx가 설치되지 않았습니다. 검증을 건너뜁니다."
exit 0
fi
# node_modules 확인
if [ ! -d "node_modules" ]; then
echo "경고: node_modules가 없습니다. 'yarn install' 실행 후 다시 시도하세요."
exit 0
fi
# ESLint 검증 (설정 파일이 있는 경우만)
if [ -f ".eslintrc.js" ] || [ -f ".eslintrc.json" ] || [ -f ".eslintrc.cjs" ] || [ -f "eslint.config.js" ] || [ -f "eslint.config.mjs" ]; then
echo "pre-commit: ESLint 검증 중..."
npx eslint src/ --ext .js,.jsx --quiet 2>&1
LINT_RESULT=$?
if [ $LINT_RESULT -ne 0 ]; then
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ ESLint 에러! 커밋이 차단되었습니다. ║"
echo "║ 'npx eslint src/ --fix'로 자동 수정을 시도해보세요. ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
exit 1
fi
echo "pre-commit: ESLint 통과"
fi

36
.gitignore vendored
파일 보기

@ -2,18 +2,13 @@
node_modules node_modules
package-lock.json package-lock.json
# Offline cache - git에 포함 (폐쇄망 배포용)
!.yarn-offline-cache/
docs
# Build # Build
dist/ dist/
build/ build/
# Environment # Environment (production secrets)
.env .env.local
.env.* .env.*.local
# IDE # IDE
.idea/ .idea/
@ -24,25 +19,20 @@ build/
# Logs # Logs
npm-debug.log* npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Claude # OS
.claude/ Thumbs.db
ehthumbs.db
Desktop.ini
*.md # Claude Code (개인 파일만 무시, 팀 파일은 추적)
!README.md .claude/settings.local.json
.claude/scripts/
# TypeScript files (메인 프로젝트 참조용, 빌드/커밋 제외) # TypeScript files (메인 프로젝트 참조용, 빌드/커밋 제외)
**/*.ts **/*.ts
**/*.tsx **/*.tsx
# tracking VesselListManager (참조용 전체 제외 - replay 패키지에서 별도 구현) # tracking VesselListManager (참조용)
src/tracking/components/VesselListManager/ src/tracking/components/VesselListManager/
# 단, d.ts 타입 선언 파일은 필요시 포함 가능
# !**/*.d.ts
# Publish 폴더 (퍼블리시 원본 포함, 없어도 빌드 가능)
nul
확인요청.txt
# Build artifacts
*.zip
httpd.conf

1
.node-version Normal file
파일 보기

@ -0,0 +1 @@
24

1
.nvmrc Normal file
파일 보기

@ -0,0 +1 @@
v22.22.0

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

62
CLAUDE.md Normal file
파일 보기

@ -0,0 +1,62 @@
# Ship GIS - 해양 선박위치정보 GIS 프론트엔드
## 프로젝트 개요
해양 선박위치정보를 지도 위에 실시간으로 시각화하는 GIS 프론트엔드 애플리케이션.
민간용 데모 버전으로, OSM + OpenSeaMap 기반 지도와 AIS API 폴링 방식의 선박 데이터를 사용.
## 기술 스택
- **프레임워크**: React 18 + Vite 5
- **지도 엔진**: OpenLayers 9 + Deck.gl 9 (MapLibre 전환 예정)
- **상태관리**: Zustand 4
- **HTTP**: Axios
- **스타일**: SASS
- **라우팅**: React Router DOM 6
## 빌드 / 실행
```bash
yarn install # 의존성 설치
yarn dev # 로컬 개발 서버 (port 3000)
yarn build # 프로덕션 빌드
```
## 데이터 소스
- **선박 위치**: SNP-Batch API (`/api/ais-target/search`) HTTP 폴링
- 초기: 최근 60분 전체 로드
- 이후: 1분마다 최근 2분 증분 조회
- **인증**: 임시 비활성화 (`VITE_DEV_SKIP_AUTH=true`)
## 환경변수
```env
VITE_BASE_URL=/ # 배포 경로
VITE_API_URL=http://211.208.115.83:8041/snp-api # API 서버
VITE_DEV_SKIP_AUTH=true # 인증 우회
```
## 핵심 디렉토리 구조
```
src/
├── api/ # API 클라이언트 (aisTargetApi, signalApi, trackApi)
├── areaSearch/ # 항적분석 모듈
├── assets/ # 이미지, 아이콘 에셋
├── common/ # STOMP 클라이언트 (비활성화)
├── components/ # 공통 컴포넌트 (layout, ship, map, auth, common)
├── hooks/ # 커스텀 훅 (useShipData, useShipLayer 등)
├── map/ # 지도 컨테이너, 레이어, 측정 도구
├── pages/ # 페이지 (Home, Replay)
├── replay/ # 리플레이 모듈
├── scss/ # 글로벌 SCSS
├── stores/ # Zustand 스토어 (ship, map, auth, tracking 등)
├── tracking/ # 항적조회 모듈
├── types/ # 상수 정의
├── utils/ # 유틸리티
└── workers/ # Web Worker (signalWorker)
```
## Git 저장소
- **Remote**: https://gitea.gc-si.dev/gc/ship-gis.git
- **브랜치**: main (보호), develop (작업 브랜치)
## 팀 워크플로우
- 버전: v1.2.0
- 커밋 형식: Conventional Commits (한/영 혼용)
- 브랜치 전략: main ← develop ← feature/*

파일 보기

@ -23,6 +23,8 @@
"@stomp/stompjs": "^7.2.1", "@stomp/stompjs": "^7.2.1",
"axios": "^1.4.0", "axios": "^1.4.0",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"flatgeobuf": "^4.4.0",
"html2canvas": "^1.4.1",
"ol": "^9.2.4", "ol": "^9.2.4",
"ol-ext": "^4.0.10", "ol-ext": "^4.0.10",
"react": "^18.2.0", "react": "^18.2.0",

파일 보기

@ -71,6 +71,8 @@
.schInput{height: 3.5rem; font-family: 'NanumSquare', sans-serif;font-size: var(--fs-m); color: var(--white);background-color: var(--tertiary1);padding: 0 1.2rem; border: 0;} .schInput{height: 3.5rem; font-family: 'NanumSquare', sans-serif;font-size: var(--fs-m); color: var(--white);background-color: var(--tertiary1);padding: 0 1.2rem; border: 0;}
.schInput::placeholder { color:rgba(var(--white-rgb),.3); } .schInput::placeholder { color:rgba(var(--white-rgb),.3); }
.dateInput {background:url(../images/ico_input_cal.svg) no-repeat center right .5rem/2.4rem; padding-right: 3rem; cursor: pointer;} .dateInput {background:url(../images/ico_input_cal.svg) no-repeat center right .5rem/2.4rem; padding-right: 3rem; cursor: pointer;}
.dateInput { position: relative; }
.dateInput::-webkit-calendar-picker-indicator { opacity: 0; position: absolute; right: 0; width: 3rem; height: 100%;cursor: pointer; }
.dateInput::placeholder { color:var(--white); } .dateInput::placeholder { color:var(--white); }
/* ========================= /* =========================
@ -107,6 +109,15 @@
.colList.lineSB li a .title {font-size: var(--fs-m); font-weight: var(--fw-bold);} .colList.lineSB li a .title {font-size: var(--fs-m); font-weight: var(--fw-bold);}
.colList.lineSB li a .meta {font-size: var(--fs-s); font-weight: var(--fw-regular); color:rgba(var(--white-rgb),.5);} .colList.lineSB li a .meta {font-size: var(--fs-s); font-weight: var(--fw-regular); color:rgba(var(--white-rgb),.5);}
/* 페이지네이션 */
.pagination {display: flex; align-items: center; justify-content: center; gap: .5rem; padding: 1.4rem 0;}
.pagination button {min-width: 2.8rem; height: 2.8rem; padding: 0 .6rem; border-radius: .4rem; background-color: var(--secondary1); color: var(--white); font-size: var(--fs-m); font-weight: var(--fw-bold); border: 1px solid var(--secondary3); cursor: pointer; transition: background-color .15s ease, border-color .15s ease;}
.pagination button:hover {background-color: var(--secondary3); border-color: var(--secondary4);}
.pagination button.on {background-color: var(--primary1); border-color: var(--primary1);}
.pagination button.on:hover {background-color: var(--primary2); border-color: var(--primary2);}
.pagination button.disabled {opacity: 0.4; cursor: default; pointer-events: none;}
.pagination .ellipsis {color: rgba(var(--white-rgb), .4); font-size: var(--fs-m); padding: 0 .2rem; user-select: none;}
/* 아코디언리스트 */ /* 아코디언리스트 */
.accordionWrap {display: flex;flex-direction: column;transition: max-height 0.3s ease;} .accordionWrap {display: flex;flex-direction: column;transition: max-height 0.3s ease;}
.accordionWrap .acdHeader {display: flex; justify-content: space-between; align-items: center; height: 4rem; background-color: var(--secondary1); padding: 1rem; border-bottom: .1rem solid var(--secondary3);} .accordionWrap .acdHeader {display: flex; justify-content: space-between; align-items: center; height: 4rem; background-color: var(--secondary1); padding: 1rem; border-bottom: .1rem solid var(--secondary3);}
@ -292,7 +303,7 @@
align-items: center; align-items: center;
z-index: 999; z-index: 999;
} }
.popupUtillWrap { position: absolute;top: 50%; left: 50%;transform: translate(-50%, -50%);z-index :85;} .popupUtillWrap { position: fixed;top: 50%; left: 50%;transform: translate(-50%, -50%);z-index :100;}
.popupUtill {display: flex; flex-direction: column; width: 52.5rem; height:auto;max-height: 80vh;overflow: hidden; background-color: var(--secondary2); border: .1rem solid var(--secondary3); padding:2.5rem 3rem;} .popupUtill {display: flex; flex-direction: column; width: 52.5rem; height:auto;max-height: 80vh;overflow: hidden; background-color: var(--secondary2); border: .1rem solid var(--secondary3); padding:2.5rem 3rem;}
.popupUtill > .puHeader {display: flex; justify-content: space-between; align-items: center; padding-bottom: 2rem;} .popupUtill > .puHeader {display: flex; justify-content: space-between; align-items: center; padding-bottom: 2rem;}
.popupUtill > .puHeader > .title {font-weight: var(--fw-bold); font-size: var(--fs-xl);} .popupUtill > .puHeader > .title {font-weight: var(--fw-bold); font-size: var(--fs-xl);}

파일 보기

@ -1,78 +0,0 @@
@echo off
chcp 65001 >nul
echo ============================================
echo dark 프로젝트 - 폐쇄망 Windows 초기 세팅
echo ============================================
echo.
:: 1. 사전 조건 확인
echo [1/4] 사전 조건 확인 중...
where node >nul 2>&1
if %errorlevel% neq 0 (
echo [오류] Node.js가 설치되어 있지 않습니다.
echo Node.js 18 이상을 설치해주세요.
pause
exit /b 1
)
where yarn >nul 2>&1
if %errorlevel% neq 0 (
echo [오류] Yarn이 설치되어 있지 않습니다.
echo npm install -g yarn 으로 설치해주세요.
pause
exit /b 1
)
for /f "tokens=*" %%i in ('node -v') do set NODE_VER=%%i
for /f "tokens=*" %%i in ('yarn -v') do set YARN_VER=%%i
echo Node.js: %NODE_VER%
echo Yarn: %YARN_VER%
echo [확인 완료]
echo.
:: 2. 기존 node_modules 정리
echo [2/4] 기존 node_modules 정리 중...
if exist node_modules (
rmdir /s /q node_modules
echo 기존 node_modules 삭제 완료
) else (
echo node_modules 없음 (정상)
)
echo.
:: 3. 오프라인 캐시에서 의존성 설치
echo [3/4] 오프라인 캐시에서 의존성 설치 중...
echo (.yarn-offline-cache 폴더 사용)
yarn install --offline
if %errorlevel% neq 0 (
echo.
echo [오류] yarn install 실패.
echo .yarn-offline-cache 폴더가 존재하는지 확인해주세요.
echo 폴더가 없으면 인터넷 가능 환경에서 프로젝트를 다시 받아주세요.
pause
exit /b 1
)
echo [설치 완료]
echo.
:: 4. 설치 검증
echo [4/4] 설치 검증 중...
if not exist node_modules\.bin\vite.cmd (
echo [경고] vite.cmd가 생성되지 않았습니다.
echo yarn install이 정상 완료되었는지 확인해주세요.
pause
exit /b 1
)
echo vite.cmd 확인 완료
echo.
echo ============================================
echo 세팅 완료!
echo ============================================
echo.
echo 사용 가능한 명령어:
echo yarn dev - 로컬 개발 서버 (localhost:3000)
echo yarn build:dev - 개발서버 배포 빌드 (BASE_URL=/kcgv/)
echo yarn build:qa - QA서버 빌드
echo yarn build - 프로덕션 빌드
echo.
pause

파일 보기

@ -1,51 +1,18 @@
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import { lazy, Suspense } from 'react';
// -
import MainLayout from './components/layout/MainLayout'; import MainLayout from './components/layout/MainLayout';
import SessionGuard from './components/auth/SessionGuard';
import { ToastContainer } from './components/common/Toast'; import { ToastContainer } from './components/common/Toast';
import { AlertModalContainer } from './components/common/AlertModal';
// ( )
// tree-shaking
const PublishRouter = import.meta.env.DEV
? lazy(() =>
import('./publish').catch(() => ({
default: () => (
<div style={{ color: '#fff', padding: '2rem' }}>
publish 폴더가 없습니다. 퍼블리시 파일을 추가하면 자동으로 활성화됩니다.
</div>
),
}))
)
: null;
export default function App() { export default function App() {
return ( return (
<> <SessionGuard>
<ToastContainer /> <ToastContainer />
<AlertModalContainer />
<Routes> <Routes>
{/* =====================
구현 영역 (메인)
- 모든 메뉴 경로를 MainLayout으로 처리
===================== */}
<Route path="/*" element={<MainLayout />} /> <Route path="/*" element={<MainLayout />} />
{/* =====================
퍼블리시 영역 (개발 환경 전용)
/publish/* 접근하여 퍼블리시 결과물 미리보기
프로덕션 빌드 라우트와 관련 모듈이 제외됨
===================== */}
{/*{import.meta.env.DEV && PublishRouter && (*/}
{/* <Route*/}
{/* path="/publish/*"*/}
{/* element={*/}
{/* <Suspense fallback={<div style={{ color: '#fff', padding: '2rem' }}>Loading publish...</div>}>*/}
{/* <PublishRouter />*/}
{/* </Suspense>*/}
{/* }*/}
{/* />*/}
{/*)}*/}
</Routes> </Routes>
</> </SessionGuard>
); );
} }

134
src/api/aisTargetApi.js Normal file
파일 보기

@ -0,0 +1,134 @@
/**
* AIS Target API 클라이언트
* SNP-Batch 서버의 AIS 데이터를 HTTP 폴링으로 조회
*/
import axios from 'axios';
const BASE_URL = import.meta.env.VITE_API_URL || '';
/**
* AIS 타겟 검색 (최근 N분 데이터)
* @param {number} minutes - 조회 기간 ()
* @returns {Promise<Array>} AIS 타겟 데이터 배열
*/
export async function searchAisTargets(minutes = 60) {
const res = await axios.get(`${BASE_URL}/api/ais-target/search`, {
params: { minutes },
timeout: 30000,
});
return res.data.data || [];
}
/**
* AIS API 응답 shipStore feature 객체로 변환
*
* API 응답 필드:
* mmsi, imo, name, callsign, vesselType, lat, lon,
* heading, sog, cog, rot, length, width, draught,
* destination, eta, status, messageTimestamp, receivedDate,
* source, classType
*
* @param {Object} aisTarget - API 응답 단건
* @returns {Object} shipStore 호환 feature 객체
*/
export function aisTargetToFeature(aisTarget) {
const mmsi = String(aisTarget.mmsi || '');
const signalKindCode = mapVesselTypeToKindCode(aisTarget.vesselType);
return {
// 고유 식별자 (AIS 신호원 코드 + MMSI)
featureId: `000001${mmsi}`,
// 기본 식별 정보
targetId: mmsi,
originalTargetId: mmsi,
signalSourceCode: '000001', // AIS
shipName: aisTarget.name || '',
shipType: aisTarget.vesselType || '',
// 위치 정보
longitude: aisTarget.lon || 0,
latitude: aisTarget.lat || 0,
// 항해 정보
sog: aisTarget.sog || 0,
cog: aisTarget.cog || 0,
// 시간 정보
receivedTime: formatTimestamp(aisTarget.messageTimestamp),
// 선종 코드
signalKindCode,
// 상태 플래그
lost: false,
integrate: false,
isPriority: true,
// 위험물 카테고리
hazardousCategory: '',
// 국적 코드
nationalCode: '',
// IMO 번호
imo: String(aisTarget.imo || ''),
// 흘수
draught: String(aisTarget.draught || ''),
// 선박 크기
dimA: '',
dimB: '',
dimC: '',
dimD: '',
// AVETDR 신호장비 플래그
ais: '1',
vpass: '',
enav: '',
vtsAis: '',
dMfHf: '',
vtsRadar: '',
// 추가 메타데이터
callsign: aisTarget.callsign || '',
heading: aisTarget.heading || 0,
destination: aisTarget.destination || '',
status: aisTarget.status || '',
length: aisTarget.length || 0,
width: aisTarget.width || 0,
_raw: null,
};
}
/**
* vesselType 문자열 선종 코드 매핑
*/
function mapVesselTypeToKindCode(vesselType) {
if (!vesselType) return '000027'; // 일반
const vt = vesselType.toLowerCase();
if (vt.includes('fishing')) return '000020'; // 어선
if (vt.includes('passenger')) return '000022'; // 여객선
if (vt.includes('cargo')) return '000023'; // 화물선
if (vt.includes('tanker')) return '000024'; // 유조선
if (vt.includes('military') || vt.includes('law enforcement')) return '000025'; // 관공선
if (vt.includes('tug') || vt.includes('pilot') || vt.includes('search')) return '000025'; // 관공선
return '000027'; // 일반
}
/**
* ISO 타임스탬프 "YYYYMMDDHHmmss" 형식 변환
*/
function formatTimestamp(isoString) {
if (!isoString) return '';
try {
const d = new Date(isoString);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
} catch {
return '';
}
}

파일 보기

@ -1,6 +1,7 @@
/** /**
* 공통코드 API * 공통코드 API
*/ */
import { fetchWithAuth } from './fetchWithAuth';
const COMMON_CODE_LIST_ENDPOINT = '/api/cmn/code/list/search'; const COMMON_CODE_LIST_ENDPOINT = '/api/cmn/code/list/search';
@ -12,10 +13,9 @@ const COMMON_CODE_LIST_ENDPOINT = '/api/cmn/code/list/search';
*/ */
export async function fetchCommonCodeList(commonCodeTypeNumber) { export async function fetchCommonCodeList(commonCodeTypeNumber) {
try { try {
const response = await fetch(COMMON_CODE_LIST_ENDPOINT, { const response = await fetchWithAuth(COMMON_CODE_LIST_ENDPOINT, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ commonCodeTypeNumber }), body: JSON.stringify({ commonCodeTypeNumber }),
}); });

27
src/api/favoriteApi.js Normal file
파일 보기

@ -0,0 +1,27 @@
import { fetchWithAuth } from './fetchWithAuth';
/**
* 관심선박 목록 조회
* @returns {Promise<Array>} 관심선박 목록
*/
export async function fetchFavoriteShips() {
const response = await fetchWithAuth('/api/gis/my/dashboard/ship/attention/static/search');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
return result?.data || [];
}
/**
* 관심구역 목록 조회
* @returns {Promise<Array>} 관심구역 목록
*/
export async function fetchRealms() {
const response = await fetchWithAuth('/api/gis/sea-relm/manage/show', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
return result?.seaRelmManageShowDtoList || [];
}

41
src/api/fetchWithAuth.js Normal file
파일 보기

@ -0,0 +1,41 @@
import { useAuthStore } from '../stores/authStore';
import { SESSION_TIMEOUT_MS } from '../types/constants';
/**
* 인증 래퍼 fetch
* - 사전 체크: loginDate 기반 타임아웃
* - 사후 체크: 4011 응답 감지 (세션 만료)
* - credentials: 'include' 자동 설정
*/
export async function fetchWithAuth(url, options = {}) {
// 로컬 개발: 세션 타임아웃 체크 우회
if (import.meta.env.VITE_DEV_SKIP_AUTH !== 'true') {
const loginDate = localStorage.getItem('loginDate');
if (!loginDate || Date.now() - Number(loginDate) > SESSION_TIMEOUT_MS) {
useAuthStore.getState().handleSessionExpired();
throw new Error('Session expired');
}
localStorage.setItem('loginDate', String(Date.now()));
}
const response = await fetch(url, { ...options, credentials: 'include' });
// JSON 응답에서 4011 체크
if (response.ok) {
const ct = response.headers.get('content-type');
if (ct && ct.includes('application/json')) {
const cloned = response.clone();
try {
const data = await cloned.json();
if (data?.code === 4011) {
useAuthStore.getState().handleSessionExpired();
throw new Error('Session expired (4011)');
}
} catch (e) {
if (e.message.includes('Session expired')) throw e;
}
}
}
return response;
}

파일 보기

@ -1,481 +0,0 @@
/**
* 위성 API
*/
const SATELLITE_VIDEO_SEARCH_ENDPOINT = '/api/gis/satlit/search';
const SATELLITE_CSV_ENDPOINT = '/api/gis/satlit/excelToJson';
const SATELLITE_DETAIL_ENDPOINT = '/api/gis/satlit/id/search';
const SATELLITE_UPDATE_ENDPOINT = '/api/gis/satlit/update';
const SATELLITE_COMPANY_LIST_ENDPOINT = '/api/gis/satlit/sat-bz/all/search';
const SATELLITE_MANAGE_LIST_ENDPOINT = '/api/gis/satlit/sat-mng/bz/search';
const SATELLITE_SAVE_ENDPOINT = '/api/gis/satlit/save';
const SATELLITE_COMPANY_SEARCH_ENDPOINT = '/api/gis/satlit/sat-bz/search';
const SATELLITE_COMPANY_SAVE_ENDPOINT = '/api/gis/satlit/sat-bz/save';
const SATELLITE_COMPANY_DETAIL_ENDPOINT = '/api/gis/satlit/sat-bz/id/search';
const SATELLITE_COMPANY_UPDATE_ENDPOINT = '/api/gis/satlit/sat-bz/update';
const SATELLITE_MANAGE_SEARCH_ENDPOINT = '/api/gis/satlit/sat-mng/search';
const SATELLITE_MANAGE_SAVE_ENDPOINT = '/api/gis/satlit/sat-mng/save';
const SATELLITE_MANAGE_DETAIL_ENDPOINT = '/api/gis/satlit/sat-mng/id/search';
const SATELLITE_MANAGE_UPDATE_ENDPOINT = '/api/gis/satlit/sat-mng/update';
/**
* 위성영상 목록 조회
*
* @param {Object} params
* @param {number} params.page - 페이지 번호
* @param {string} [params.startDate] - 촬영 시작일
* @param {string} [params.endDate] - 촬영 종료일
* @param {string} [params.satelliteVideoName] - 위성영상명
* @param {string} [params.satelliteVideoTransmissionCycle] - 전송주기
* @param {string} [params.satelliteVideoKind] - 영상 종류
* @param {string} [params.satelliteVideoOrbit] - 위성 궤도
* @param {string} [params.satelliteVideoOrigin] - 영상 출처
* @returns {Promise<{ list: Array, totalPage: number }>}
*/
export async function fetchSatelliteVideoList({
page,
startDate,
endDate,
satelliteVideoName,
satelliteVideoTransmissionCycle,
satelliteVideoKind,
satelliteVideoOrbit,
satelliteVideoOrigin,
}) {
try {
const response = await fetch(SATELLITE_VIDEO_SEARCH_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
page,
startDate,
endDate,
satelliteVideoName,
satelliteVideoTransmissionCycle,
satelliteVideoKind,
satelliteVideoOrbit,
satelliteVideoOrigin,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return {
list: result?.satelliteVideoInfoList || [],
totalPage: result?.totalPage || 0,
};
} catch (error) {
console.error('[fetchSatelliteVideoList] Error:', error);
throw error;
}
}
/**
* 위성영상 CSV JSON 변환 (선박 좌표 추출)
*
* @param {string} csvFileName - CSV 파일명
* @returns {Promise<Array<{ coordinates: [number, number] }>>}
*/
export async function fetchSatelliteCsvFeatures(csvFileName) {
try {
const response = await fetch(SATELLITE_CSV_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ csvFileName }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
const data = result?.jsonData;
if (!data) return [];
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
return parsed.map(({ lon, lat }) => ({
coordinates: [parseFloat(lon), parseFloat(lat)],
}));
} catch (error) {
console.error('[fetchSatelliteCsvFeatures] Error:', error);
throw error;
}
}
/**
* 위성영상 상세조회
*
* @param {number} satelliteId - 위성 ID
* @returns {Promise<Object>} SatelliteVideoInfoOneDto
*/
export async function fetchSatelliteVideoDetail(satelliteId) {
try {
const response = await fetch(SATELLITE_DETAIL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ satelliteId }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result?.satelliteVideoInfoById || null;
} catch (error) {
console.error('[fetchSatelliteVideoDetail] Error:', error);
throw error;
}
}
/**
* 위성영상 수정
*
* @param {Object} params
* @param {number} params.satelliteId
* @param {number} params.satelliteManageId
* @param {string} [params.photographDate]
* @param {string} [params.satelliteVideoName]
* @param {string} [params.satelliteVideoTransmissionCycle]
* @param {string} [params.satelliteVideoKind]
* @param {string} [params.satelliteVideoOrbit]
* @param {string} [params.satelliteVideoOrigin]
* @param {string} [params.photographPurpose]
* @param {string} [params.photographMode]
* @param {string} [params.purchaseCode]
* @param {number} [params.purchasePrice]
*/
export async function updateSatelliteVideo(params) {
try {
const response = await fetch(SATELLITE_UPDATE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
} catch (error) {
console.error('[updateSatelliteVideo] Error:', error);
throw error;
}
}
/**
* 사업자 목록 조회
*
* @returns {Promise<Array<{ companyNo: number, companyName: string }>>}
*/
export async function fetchSatelliteCompanyList() {
try {
const response = await fetch(SATELLITE_COMPANY_LIST_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result?.satelliteCompanyNameList || [];
} catch (error) {
console.error('[fetchSatelliteCompanyList] Error:', error);
throw error;
}
}
/**
* 사업자별 위성명 목록 조회
*
* @param {number} companyNo - 사업자 번호
* @returns {Promise<Array<{ satelliteManageId: number, satelliteName: string }>>}
*/
export async function fetchSatelliteManageList(companyNo) {
try {
const response = await fetch(SATELLITE_MANAGE_LIST_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ companyNo }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result?.satelliteManageInfoList || [];
} catch (error) {
console.error('[fetchSatelliteManageList] Error:', error);
throw error;
}
}
/**
* 위성영상 등록 (multipart/form-data)
*
* @param {FormData} formData - 파일(tifFile, csvFile, cloudMaskFile) + 필드
*/
export async function saveSatelliteVideo(formData) {
try {
const response = await fetch(SATELLITE_SAVE_ENDPOINT, {
method: 'POST',
credentials: 'include',
body: formData,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
} catch (error) {
console.error('[saveSatelliteVideo] Error:', error);
throw error;
}
}
/**
* 위성 사업자 목록 검색
*
* @param {Object} params
* @param {string} [params.companyTypeCode] - 사업자 분류 코드
* @param {string} [params.companyName] - 사업자명
* @returns {Promise<{ list: Array, totalPage: number }>}
*/
export async function searchSatelliteCompany({ companyTypeCode, companyName, page, limit }) {
try {
const response = await fetch(SATELLITE_COMPANY_SEARCH_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ companyTypeCode, companyName, page, limit }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return {
list: result?.satelliteCompanySearchList || [],
totalPage: result?.totalPage || 0,
};
} catch (error) {
console.error('[searchSatelliteCompany] Error:', error);
throw error;
}
}
/**
* 위성 사업자 등록
*
* @param {Object} params
* @param {string} params.companyTypeCode - 사업자 분류 코드
* @param {string} params.companyName - 사업자명
* @param {string} params.nationalCode - 국가코드
* @param {string} [params.location] - 소재지
* @param {string} [params.companyDetail] - 상세내역
*/
export async function saveSatelliteCompany(params) {
try {
const response = await fetch(SATELLITE_COMPANY_SAVE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
} catch (error) {
console.error('[saveSatelliteCompany] Error:', error);
throw error;
}
}
/**
* 위성 사업자 상세조회
*
* @param {number} companyNo - 사업자 번호
* @returns {Promise<Object>} SatelliteCompanySearchDto
*/
export async function fetchSatelliteCompanyDetail(companyNo) {
try {
const response = await fetch(SATELLITE_COMPANY_DETAIL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ companyNo }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result?.satelliteCompany || null;
} catch (error) {
console.error('[fetchSatelliteCompanyDetail] Error:', error);
throw error;
}
}
/**
* 위성 사업자 수정
*
* @param {Object} params
* @param {number} params.companyNo - 사업자 번호
* @param {string} params.companyTypeCode - 사업자 분류 코드
* @param {string} params.companyName - 사업자명
* @param {string} params.nationalCode - 국가코드
* @param {string} [params.location] - 소재지
* @param {string} [params.companyDetail] - 상세내역
*/
export async function updateSatelliteCompany(params) {
try {
const response = await fetch(SATELLITE_COMPANY_UPDATE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
} catch (error) {
console.error('[updateSatelliteCompany] Error:', error);
throw error;
}
}
/**
* 위성관리 목록 검색
*
* @param {Object} params
* @param {number} [params.companyNo] - 사업자 번호
* @param {string} [params.satelliteName] - 위성명
* @param {string} [params.sensorType] - 센서 타입
* @returns {Promise<{ list: Array, totalPage: number }>}
*/
export async function searchSatelliteManage({ companyNo, satelliteName, sensorType, page, limit }) {
try {
const response = await fetch(SATELLITE_MANAGE_SEARCH_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ companyNo, satelliteName, sensorType, page, limit }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return {
list: result?.satelliteManageInfoSearchList || [],
totalPage: result?.totalPage || 0,
};
} catch (error) {
console.error('[searchSatelliteManage] Error:', error);
throw error;
}
}
/**
* 위성 관리 등록
*
* @param {Object} params
* @param {number} params.companyNo - 사업자 번호
* @param {string} params.satelliteName - 위성명
* @param {string} [params.sensorType] - 센서 타입
* @param {string} [params.photoResolution] - 촬영 해상도
* @param {string} [params.frequency] - 주파수
* @param {string} [params.photoDetail] - 상세내역
*/
export async function saveSatelliteManage(params) {
try {
const response = await fetch(SATELLITE_MANAGE_SAVE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
} catch (error) {
console.error('[saveSatelliteManage] Error:', error);
throw error;
}
}
/**
* 위성 관리 상세조회
*
* @param {number} satelliteManageId - 위성 관리 ID
* @returns {Promise<Object>} SatelliteManageInfoDto
*/
export async function fetchSatelliteManageDetail(satelliteManageId) {
try {
const response = await fetch(SATELLITE_MANAGE_DETAIL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ satelliteManageId }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result?.satelliteManageInfo || null;
} catch (error) {
console.error('[fetchSatelliteManageDetail] Error:', error);
throw error;
}
}
/**
* 위성 관리 수정
*
* @param {Object} params
* @param {number} params.satelliteManageId - 위성 관리 ID
* @param {number} params.companyNo - 사업자 번호
* @param {string} params.satelliteName - 위성명
* @param {string} [params.sensorType] - 센서 타입
* @param {string} [params.photoResolution] - 촬영 해상도
* @param {string} [params.frequency] - 주파수
* @param {string} [params.photoDetail] - 상세내역
*/
export async function updateSatelliteManage(params) {
try {
const response = await fetch(SATELLITE_MANAGE_UPDATE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
} catch (error) {
console.error('[updateSatelliteManage] Error:', error);
throw error;
}
}

파일 보기

@ -8,6 +8,7 @@
* - 응답 데이터 가공 (ProcessedTrack 형태로 변환) * - 응답 데이터 가공 (ProcessedTrack 형태로 변환)
*/ */
import useShipStore from '../stores/shipStore'; import useShipStore from '../stores/shipStore';
import { fetchWithAuth } from './fetchWithAuth';
/** API 엔드포인트 (메인 프로젝트와 동일) */ /** API 엔드포인트 (메인 프로젝트와 동일) */
const API_ENDPOINT = '/api/v2/tracks/vessels'; const API_ENDPOINT = '/api/v2/tracks/vessels';
@ -31,10 +32,9 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati
isIntegration: isIntegration ? '1' : '0', isIntegration: isIntegration ? '1' : '0',
}; };
const response = await fetch(API_ENDPOINT, { const response = await fetchWithAuth(API_ENDPOINT, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body), body: JSON.stringify(body),
}); });

32
src/api/userSettingApi.js Normal file
파일 보기

@ -0,0 +1,32 @@
import { fetchWithAuth } from './fetchWithAuth';
import { USER_SETTING_FILTER } from '../types/constants';
const SEARCH_ENDPOINT = '/api/cmn/personal/settings/search';
const SAVE_ENDPOINT = '/api/cmn/personal/settings/save';
/**
* 필터 설정 조회
* @returns {Promise<Array|null>} 설정 배열 또는 null (저장된 설정 없음)
*/
export async function fetchUserFilter() {
const url = `${SEARCH_ENDPOINT}?type=${USER_SETTING_FILTER}`;
const response = await fetchWithAuth(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
if (result?.code === 4006) return null;
return result?.data?.[USER_SETTING_FILTER] || null;
}
/**
* 필터 설정 저장
* @param {Array<{code: string, value: string}>} settings
*/
export async function saveUserFilter(settings) {
const response = await fetchWithAuth(SAVE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}

파일 보기

@ -1,309 +0,0 @@
/**
* 기상해양 API
*/
const SPECIAL_NEWS_ENDPOINT = '/api/gis/weather/special-news/search';
/**
* 기상특보 목록 조회
*
* @param {Object} params
* @param {string} params.startPresentationDate - 조회 시작일 (e.g. '2026-01-01')
* @param {string} params.endPresentationDate - 조회 종료일 (e.g. '2026-01-31')
* @param {number} params.page - 페이지 번호
* @param {number} params.limit - 페이지당 항목
* @returns {Promise<{ list: Array, totalPage: number }>}
*/
export async function fetchWeatherAlerts({ startPresentationDate, endPresentationDate, page, limit }) {
try {
const response = await fetch(SPECIAL_NEWS_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
startPresentationDate,
endPresentationDate,
page,
limit,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return {
list: result?.specialNewsDetailList || [],
totalPage: result?.totalPage || 0,
};
} catch (error) {
console.error('[fetchWeatherAlerts] Error:', error);
throw error;
}
}
const TYPHOON_LIST_ENDPOINT = '/api/gis/weather/typhoon/list/search';
const TYPHOON_DETAIL_ENDPOINT = '/api/gis/weather/typhoon/search';
/**
* 태풍 목록 조회
*
* @param {Object} params
* @param {string} params.typhoonBeginningYear - 조회 연도
* @param {string} params.typhoonBeginningMonth - 조회 ( 문자열이면 전체)
* @param {number} params.page - 페이지 번호
* @param {number} params.limit - 페이지당 항목
* @returns {Promise<{ list: Array, totalPage: number }>}
*/
export async function fetchTyphoonList({ typhoonBeginningYear, typhoonBeginningMonth, page, limit }) {
try {
const response = await fetch(TYPHOON_LIST_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
typhoonBeginningYear,
typhoonBeginningMonth,
page,
limit,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
const grouped = result?.typhoonList || [];
const list = grouped.flatMap((group) => group.typhoonList || []);
return {
list,
totalPage: result?.totalPage || 0,
};
} catch (error) {
console.error('[fetchTyphoonList] Error:', error);
throw error;
}
}
/**
* 태풍 상세(진행정보) 조회
*
* @param {Object} params
* @param {string} params.typhoonSequence - 태풍 순번
* @param {string} params.year - 연도
* @returns {Promise<Array>}
*/
export async function fetchTyphoonDetail({ typhoonSequence, year }) {
try {
const response = await fetch(TYPHOON_DETAIL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
typhoonSequence,
year,
page: 1,
limit: 10000,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result?.typhoonSelectDto || [];
} catch (error) {
console.error('[fetchTyphoonDetail] Error:', error);
throw error;
}
}
const TIDE_INFORMATION_ENDPOINT = '/api/gis/weather/tide-information/search';
const SUNRISE_SUNSET_DETAIL_ENDPOINT = '/api/gis/weather/tide-information/observatory/detail/search';
/**
* 조석정보 통합 조회 (조위관측소 + 일출몰관측지역)
*
* @returns {Promise<{ observatories: Array, sunriseSunsets: Array }>}
*/
export async function fetchTideInformation() {
try {
const response = await fetch(TIDE_INFORMATION_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return {
observatories: result?.observatorySearchDto || [],
sunriseSunsets: result?.sunriseSunsetSearchDto || [],
};
} catch (error) {
console.error('[fetchTideInformation] Error:', error);
throw error;
}
}
/**
* 일출일몰 상세 조회
*
* @param {Object} params - SunriseSunsetSearchDto
* @param {string} params.locationName - 지역명
* @param {string} params.locationType - 지역 유형
* @param {Object} params.coordinate - 좌표
* @param {boolean} params.isChecked - 체크 여부
* @param {Array} params.locationCoordinates - 좌표 배열
* @returns {Promise<Object|null>} SunriseSunsetSelectDetailDto 또는 null
*/
export async function fetchSunriseSunsetDetail(params) {
try {
const response = await fetch(SUNRISE_SUNSET_DETAIL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result?.sunriseSunsetSelectDetailDto?.[0] || null;
} catch (error) {
console.error('[fetchSunriseSunsetDetail] Error:', error);
throw error;
}
}
const OBSERVATORY_ENDPOINT = '/api/gis/weather/observatory/search';
const OBSERVATORY_DETAIL_ENDPOINT = '/api/gis/weather/observatory/select/detail/search';
/**
* 관측소 목록 조회
*
* @returns {Promise<Array>} ObservatorySearchDto 배열
*/
export async function fetchObservatoryList() {
try {
const response = await fetch(OBSERVATORY_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result?.dtoList || [];
} catch (error) {
console.error('[fetchObservatoryList] Error:', error);
throw error;
}
}
/**
* 관측소 상세정보 조회
*
* @param {Object} params
* @param {string} params.observatoryId - 관측소 ID
* @param {string} params.toDate - 조회 기준일 (e.g. '2026-02-10')
* @returns {Promise<Object|null>} ObservatorySelectDetailDto 또는 null
*/
const AIRPORT_ENDPOINT = '/api/gis/weather/airport/search';
const AIRPORT_DETAIL_ENDPOINT = '/api/gis/weather/airport/select';
/**
* 공항 목록 조회
*
* @returns {Promise<Array>} AirportSearchDto 배열
*/
export async function fetchAirportList() {
try {
const response = await fetch(AIRPORT_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result?.airportSearchDto || [];
} catch (error) {
console.error('[fetchAirportList] Error:', error);
throw error;
}
}
/**
* 공항 상세정보 조회
*
* @param {Object} params
* @param {string} params.airportId - 공항 ID
* @returns {Promise<Object|null>} AirportSelectDto 또는 null
*/
export async function fetchAirportDetail({ airportId }) {
try {
const response = await fetch(AIRPORT_DETAIL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ airportId }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result?.airportSelectDto || null;
} catch (error) {
console.error('[fetchAirportDetail] Error:', error);
throw error;
}
}
export async function fetchObservatoryDetail({ observatoryId, toDate }) {
try {
const response = await fetch(OBSERVATORY_DETAIL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ observatoryId, toDate }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result?.observatorySelectDetail?.[0] || null;
} catch (error) {
console.error('[fetchObservatoryDetail] Error:', error);
throw error;
}
}

파일 보기

@ -0,0 +1,405 @@
import { useState, useEffect, useCallback } from 'react';
import './AreaSearchPage.scss';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { useStsStore } from '../stores/stsStore';
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
import { fetchAreaSearch } from '../services/areaSearchApi';
import { fetchVesselContacts } from '../services/stsApi';
import { QUERY_MAX_DAYS, getQueryDateRange, ANALYSIS_TABS } from '../types/areaSearch.types';
import { showToast } from '../../components/common/Toast';
import { hideLiveShips, showLiveShips } from '../../utils/liveControl';
import { unregisterAreaSearchLayers } from '../utils/areaSearchLayerRegistry';
import { unregisterStsLayers } from '../utils/stsLayerRegistry';
import LoadingOverlay from '../../components/common/LoadingOverlay';
import AreaSearchTab from './AreaSearchTab';
import StsAnalysisTab from './StsAnalysisTab';
const DAYS_TO_MS = 24 * 60 * 60 * 1000;
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())}`;
}
export default function AreaSearchPage({ isOpen, onToggle }) {
const [startDate, setStartDate] = useState('');
const [startTime, setStartTime] = useState('00:00');
const [endDate, setEndDate] = useState('');
const [endTime, setEndTime] = useState('23:59');
const [errorMessage, setErrorMessage] = useState('');
const zones = useAreaSearchStore((s) => s.zones);
const activeTab = useAreaSearchStore((s) => s.activeTab);
const areaLoading = useAreaSearchStore((s) => s.isLoading);
const areaQueryCompleted = useAreaSearchStore((s) => s.queryCompleted);
const stsLoading = useStsStore((s) => s.isLoading);
const stsQueryCompleted = useStsStore((s) => s.queryCompleted);
const setTimeRange = useAreaSearchAnimationStore((s) => s.setTimeRange);
const isLoading = activeTab === ANALYSIS_TABS.AREA ? areaLoading : stsLoading;
const queryCompleted = activeTab === ANALYSIS_TABS.AREA ? areaQueryCompleted : stsQueryCompleted;
// (D-7 ~ D-1)
useEffect(() => {
const { startDate: sDate, endDate: eDate } = getQueryDateRange();
setStartDate(sDate.toISOString().split('T')[0]);
setStartTime('00:00');
setEndDate(eDate.toISOString().split('T')[0]);
setEndTime('23:59');
}, []);
// (isOpen=false , activeTab )
useEffect(() => {
if (isOpen) return;
const areaState = useAreaSearchStore.getState();
const stsState = useStsStore.getState();
if (areaState.queryCompleted || stsState.queryCompleted) {
areaState.clearResults();
stsState.clearResults();
useAreaSearchAnimationStore.getState().reset();
unregisterAreaSearchLayers();
unregisterStsLayers();
showLiveShips();
}
}, [isOpen]);
// ========== ==========
const handleTabChange = useCallback((newTab) => {
if (newTab === activeTab) return;
const areaState = useAreaSearchStore.getState();
const stsState = useStsStore.getState();
//
const hasResults = activeTab === ANALYSIS_TABS.AREA
? areaState.queryCompleted
: stsState.queryCompleted;
if (hasResults) {
const confirmed = window.confirm('탭을 전환하면 현재 결과가 초기화됩니다.\n계속하시겠습니까?');
if (!confirmed) return;
if (activeTab === ANALYSIS_TABS.AREA) {
areaState.clearResults();
unregisterAreaSearchLayers();
} else {
stsState.clearResults();
unregisterStsLayers();
}
useAreaSearchAnimationStore.getState().reset();
showLiveShips();
}
// zones ( )
areaState.clearZones();
setErrorMessage('');
areaState.setActiveTab(newTab);
}, [activeTab]);
// ========== ==========
const handleStartDateChange = useCallback((newStartDate) => {
setStartDate(newStartDate);
const start = new Date(`${newStartDate}T${startTime}:00`);
const end = new Date(`${endDate}T${endTime}:00`);
const diffDays = (end - start) / DAYS_TO_MS;
const pad = (n) => String(n).padStart(2, '0');
if (diffDays < 0) {
const adjusted = new Date(start.getTime() + QUERY_MAX_DAYS * DAYS_TO_MS);
setEndDate(adjusted.toISOString().split('T')[0]);
setEndTime(`${pad(adjusted.getHours())}:${pad(adjusted.getMinutes())}`);
showToast('종료일이 시작일보다 앞서 자동 조정되었습니다.');
} else if (diffDays > QUERY_MAX_DAYS) {
const adjusted = new Date(start.getTime() + QUERY_MAX_DAYS * DAYS_TO_MS);
setEndDate(adjusted.toISOString().split('T')[0]);
setEndTime(`${pad(adjusted.getHours())}:${pad(adjusted.getMinutes())}`);
showToast(`최대 조회기간 ${QUERY_MAX_DAYS}일로 자동 설정됩니다.`);
}
}, [startTime, endDate, endTime]);
const handleEndDateChange = useCallback((newEndDate) => {
setEndDate(newEndDate);
const start = new Date(`${startDate}T${startTime}:00`);
const end = new Date(`${newEndDate}T${endTime}:00`);
const diffDays = (end - start) / DAYS_TO_MS;
const pad = (n) => String(n).padStart(2, '0');
if (diffDays < 0) {
const adjusted = new Date(end.getTime() - QUERY_MAX_DAYS * DAYS_TO_MS);
setStartDate(adjusted.toISOString().split('T')[0]);
setStartTime(`${pad(adjusted.getHours())}:${pad(adjusted.getMinutes())}`);
showToast('시작일이 종료일보다 뒤서 자동 조정되었습니다.');
} else if (diffDays > QUERY_MAX_DAYS) {
const adjusted = new Date(end.getTime() - QUERY_MAX_DAYS * DAYS_TO_MS);
setStartDate(adjusted.toISOString().split('T')[0]);
setStartTime(`${pad(adjusted.getHours())}:${pad(adjusted.getMinutes())}`);
showToast(`최대 조회기간 ${QUERY_MAX_DAYS}일로 자동 설정됩니다.`);
}
}, [startDate, startTime, endTime]);
// ========== ==========
const executeAreaSearch = useCallback(async () => {
const from = new Date(`${startDate}T${startTime}:00`);
const to = new Date(`${endDate}T${endTime}:00`);
const searchMode = useAreaSearchStore.getState().searchMode;
try {
setErrorMessage('');
useAreaSearchStore.getState().setLoading(true);
const polygons = zones.map((z) => ({
id: z.id,
name: z.name,
coordinates: z.coordinates,
}));
const result = await fetchAreaSearch({
startTime: toKstISOString(from),
endTime: toKstISOString(to),
mode: searchMode,
polygons,
});
if (result.tracks.length === 0) {
useAreaSearchStore.getState().setLoading(false);
showToast('조회 결과가 없습니다.');
return;
}
useAreaSearchStore.getState().setTracks(result.tracks);
useAreaSearchStore.getState().setHitDetails(result.hitDetails);
useAreaSearchStore.getState().setSummary(result.summary);
let minTime = Infinity;
let maxTime = -Infinity;
result.tracks.forEach((t) => {
if (t.timestampsMs.length > 0) {
minTime = Math.min(minTime, t.timestampsMs[0]);
maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]);
}
});
setTimeRange(minTime, maxTime);
hideLiveShips();
useAreaSearchStore.getState().setLoading(false);
} catch (error) {
console.error('[AreaSearch] 조회 실패:', error);
useAreaSearchStore.getState().setLoading(false);
setErrorMessage(`조회 실패: ${error.message}`);
}
}, [startDate, startTime, endDate, endTime, zones, setTimeRange]);
const executeStsSearch = useCallback(async () => {
const from = new Date(`${startDate}T${startTime}:00`);
const to = new Date(`${endDate}T${endTime}:00`);
const stsState = useStsStore.getState();
try {
setErrorMessage('');
stsState.setLoading(true);
const zone = zones[0];
const polygon = {
id: zone.id,
name: zone.name,
coordinates: zone.coordinates,
};
const result = await fetchVesselContacts({
startTime: toKstISOString(from),
endTime: toKstISOString(to),
polygon,
minContactDurationMinutes: stsState.minContactDurationMinutes,
maxContactDistanceMeters: stsState.maxContactDistanceMeters,
});
if (result.contacts.length === 0) {
stsState.setLoading(false);
showToast('접촉 의심 쌍이 없습니다.');
return;
}
stsState.setResults(result);
let minTime = Infinity;
let maxTime = -Infinity;
result.tracks.forEach((t) => {
if (t.timestampsMs.length > 0) {
minTime = Math.min(minTime, t.timestampsMs[0]);
maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]);
}
});
setTimeRange(minTime, maxTime);
hideLiveShips();
stsState.setLoading(false);
} catch (error) {
console.error('[STS] 조회 실패:', error);
useStsStore.getState().setLoading(false);
setErrorMessage(`조회 실패: ${error.message}`);
}
}, [startDate, startTime, endDate, endTime, zones, setTimeRange]);
const handleQuery = useCallback(async () => {
if (!startDate || !endDate) {
showToast('조회 기간을 입력해주세요.');
return;
}
if (zones.length === 0) {
showToast('구역을 1개 이상 설정해주세요.');
return;
}
const from = new Date(`${startDate}T${startTime}:00`);
const to = new Date(`${endDate}T${endTime}:00`);
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
showToast('올바른 날짜/시간을 입력해주세요.');
return;
}
if (from >= to) {
showToast('종료 시간은 시작 시간보다 이후여야 합니다.');
return;
}
//
if (queryCompleted) {
const confirmed = window.confirm('이전 조회 정보가 초기화됩니다.\n새로운 조건으로 다시 조회하시겠습니까?');
if (!confirmed) return;
if (activeTab === ANALYSIS_TABS.AREA) {
useAreaSearchStore.getState().clearResults();
} else {
useStsStore.getState().clearResults();
}
useAreaSearchAnimationStore.getState().reset();
}
if (activeTab === ANALYSIS_TABS.AREA) {
executeAreaSearch();
} else {
executeStsSearch();
}
}, [startDate, startTime, endDate, endTime, zones, activeTab, queryCompleted, executeAreaSearch, executeStsSearch]);
const handleReset = useCallback(() => {
useAreaSearchStore.getState().reset();
useStsStore.getState().reset();
useAreaSearchAnimationStore.getState().reset();
unregisterAreaSearchLayers();
unregisterStsLayers();
showLiveShips();
setErrorMessage('');
}, []);
return (
<aside className={`slidePanel area-search-panel ${isOpen ? '' : 'is-closed'}`}>
<button type="button" className="toogle" onClick={onToggle} aria-label="패널 토글">
<span className="blind">패널 토글</span>
</button>
<div className="panelHeader">
<h2 className="panelTitle">항적 분석</h2>
{queryCompleted && (
<button type="button" className="btn-reset" onClick={handleReset}>초기화</button>
)}
</div>
<div className="panelBody">
{/* 조회 기간 (공유) */}
<div className="query-section">
<h3 className="section-title">조회 기간</h3>
<div className="query-row">
<label htmlFor="area-start-date" className="query-label">시작</label>
<div className="datetime-inputs">
<input
id="area-start-date"
type="date"
value={startDate}
onChange={(e) => handleStartDateChange(e.target.value)}
disabled={isLoading}
className="input-date"
/>
<input
id="area-start-time"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
disabled={isLoading}
className="input-time"
/>
</div>
</div>
<div className="query-row">
<label htmlFor="area-end-date" className="query-label">종료</label>
<div className="datetime-inputs">
<input
id="area-end-date"
type="date"
value={endDate}
onChange={(e) => handleEndDateChange(e.target.value)}
disabled={isLoading}
className="input-date"
/>
<input
id="area-end-time"
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
disabled={isLoading}
className="input-time"
/>
</div>
</div>
<div className="btnBox">
<button
type="button"
className="btn btn-primary btn-query"
onClick={handleQuery}
disabled={isLoading || zones.length === 0}
>
{isLoading ? '조회 중...' : '조회'}
</button>
</div>
</div>
{/* 탭 바 */}
<div className="analysis-tab-bar">
<button
type="button"
className={`analysis-tab ${activeTab === ANALYSIS_TABS.AREA ? 'active' : ''}`}
onClick={() => handleTabChange(ANALYSIS_TABS.AREA)}
disabled={isLoading}
>
구역분석
</button>
<button
type="button"
className={`analysis-tab ${activeTab === ANALYSIS_TABS.STS ? 'active' : ''}`}
onClick={() => handleTabChange(ANALYSIS_TABS.STS)}
disabled={isLoading}
>
STS분석
</button>
</div>
{/* 탭 컨텐츠 */}
{activeTab === ANALYSIS_TABS.AREA ? (
<AreaSearchTab isLoading={areaLoading} errorMessage={errorMessage} />
) : (
<StsAnalysisTab isLoading={stsLoading} errorMessage={errorMessage} />
)}
</div>
{isLoading && <LoadingOverlay message="조회중..." />}
</aside>
);
}

파일 보기

@ -0,0 +1,389 @@
.area-search-panel {
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
.panelTitle {
padding: 1.7rem 0;
font-size: var(--fs-ml, 1.4rem);
font-weight: var(--fw-bold, 700);
color: var(--white, #fff);
}
.btn-reset {
padding: 0.4rem 1rem;
border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
border-radius: 0.4rem;
background: transparent;
color: var(--tertiary4, #ccc);
font-size: var(--fs-xs, 1.1rem);
cursor: pointer;
&:hover {
border-color: var(--primary1, rgba(255, 255, 255, 0.5));
color: var(--white, #fff);
}
}
}
.panelBody {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
padding: 0 2rem 2rem 2rem;
overflow-y: auto;
// 조회 기간 (리플레이와 동일)
.query-section {
background-color: var(--secondary1, rgba(255, 255, 255, 0.05));
border-radius: 0.6rem;
padding: 1.5rem;
margin-bottom: 1.2rem;
.section-title {
font-size: var(--fs-m, 1.3rem);
font-weight: var(--fw-bold, 700);
color: var(--white, #fff);
margin-bottom: 1.5rem;
}
.query-row {
display: flex;
align-items: center;
margin-bottom: 1.2rem;
.query-label {
min-width: 5rem;
font-size: var(--fs-s, 1.2rem);
color: var(--tertiary4, #ccc);
}
.datetime-inputs {
display: flex;
gap: 0.8rem;
flex: 1;
.input-date,
.input-time {
flex: 1;
height: 3.2rem;
padding: 0.4rem 0.8rem;
background-color: var(--tertiary1, rgba(0, 0, 0, 0.3));
border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
border-radius: 0.4rem;
color: var(--white, #fff);
font-size: var(--fs-s, 1.2rem);
font-family: inherit;
&:focus {
outline: none;
border-color: var(--primary1, rgba(255, 255, 255, 0.5));
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&::-webkit-calendar-picker-indicator {
filter: invert(1);
cursor: pointer;
}
}
.input-date {
min-width: 14rem;
}
.input-time {
min-width: 10rem;
}
}
}
.btnBox {
display: flex;
justify-content: flex-end;
margin-top: 2rem;
gap: 1rem;
.btn {
min-width: 12rem;
padding: 1rem 2rem;
border-radius: 0.4rem;
font-size: var(--fs-s, 1.2rem);
font-weight: var(--fw-semibold, 600);
cursor: pointer;
transition: all 0.2s;
border: none;
&.btn-primary {
background-color: var(--primary1, #4a9eff);
color: var(--white, #fff);
&:hover:not(:disabled) {
background-color: var(--primary2, #3a8eef);
}
&:disabled {
background-color: var(--secondary3, #555);
cursor: not-allowed;
opacity: 0.6;
}
}
&.btn-query {
min-width: 14rem;
}
}
}
}
// (segmented control)
.analysis-tab-bar {
display: flex;
margin-bottom: 1.2rem;
background-color: var(--tertiary1, rgba(0, 0, 0, 0.3));
border-radius: 0.6rem;
padding: 0.3rem;
gap: 0.3rem;
.analysis-tab {
flex: 1;
padding: 0.7rem 0;
border: none;
border-radius: 0.4rem;
background: transparent;
color: var(--tertiary4, #999);
font-size: var(--fs-s, 1.2rem);
font-weight: var(--fw-semibold, 600);
cursor: pointer;
transition: all 0.15s;
&:hover:not(:disabled) {
color: var(--white, #fff);
}
&.active {
background-color: rgba(74, 158, 255, 0.2);
color: var(--white, #fff);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
}
// 검색 모드 ( 스타일 + 구역 설정과 동일 배경)
.search-mode {
background-color: var(--secondary1, rgba(255, 255, 255, 0.05));
border-radius: 0.6rem;
padding: 1.2rem 1.5rem;
margin-bottom: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
.mode-chip {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1.2rem;
border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
border-radius: 2rem;
background: transparent;
cursor: pointer;
font-size: var(--fs-xs, 1.1rem);
color: var(--tertiary4, #ccc);
transition: all 0.15s;
input[type='radio'] {
display: none;
}
&:hover {
border-color: var(--primary1, rgba(255, 255, 255, 0.4));
color: var(--white, #fff);
}
&.active {
border-color: var(--primary1, #4a9eff);
background-color: rgba(74, 158, 255, 0.15);
color: var(--white, #fff);
}
}
}
// 결과 영역
.result-section {
flex: 1;
min-height: 20rem;
display: flex;
flex-direction: column;
background-color: var(--tertiary1, rgba(0, 0, 0, 0.2));
border-radius: 0.6rem;
padding: 1.2rem;
overflow-y: auto;
&:has(> .loading-message),
&:has(> .empty-message),
&:has(> .error-message) {
align-items: center;
justify-content: center;
}
.loading-message,
.empty-message,
.error-message {
font-size: var(--fs-m, 1.3rem);
color: var(--tertiary4, #999);
text-align: center;
line-height: 1.6;
}
.loading-message {
color: var(--primary1, #4a9eff);
}
.error-message {
color: #f87171;
}
.result-content {
width: 100%;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
.result-summary {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.8rem;
flex-shrink: 0;
.result-summary-text {
font-size: var(--fs-m, 1.3rem);
font-weight: var(--fw-bold, 700);
color: var(--white, #fff);
.processing-time {
font-size: var(--fs-xs, 1.1rem);
font-weight: normal;
color: var(--tertiary4, #999);
margin-left: 0.4rem;
}
}
.btn-csv {
padding: 0.4rem 1rem;
border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
border-radius: 0.4rem;
background: transparent;
color: var(--tertiary4, #ccc);
font-size: var(--fs-xs, 1.1rem);
cursor: pointer;
flex-shrink: 0;
&:hover {
border-color: var(--primary1, #4a9eff);
color: var(--white, #fff);
}
}
}
.vessel-list {
list-style: none;
padding: 0;
margin: 0;
flex: 1;
overflow-y: auto;
.vessel-item {
display: flex;
align-items: center;
border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
&.highlighted {
background-color: rgba(255, 255, 255, 0.08);
}
&.disabled {
opacity: 0.4;
}
.vessel-toggle {
display: flex;
align-items: center;
gap: 0.8rem;
flex: 1;
min-width: 0;
padding: 0.8rem 0.4rem;
background: none;
border: none;
cursor: pointer;
text-align: left;
color: inherit;
.vessel-color {
width: 1rem;
height: 1rem;
border-radius: 50%;
flex-shrink: 0;
}
.vessel-name {
flex: 1;
font-size: var(--fs-s, 1.2rem);
color: var(--white, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vessel-info {
font-size: var(--fs-xs, 1.1rem);
color: var(--tertiary4, #999);
flex-shrink: 0;
.visit-count {
margin-left: 0.4rem;
color: var(--primary1, #4a9eff);
font-weight: var(--fw-semibold, 600);
}
}
}
.vessel-detail-btn {
flex-shrink: 0;
width: 2.4rem;
height: 2.4rem;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--tertiary4, #999);
font-size: 1rem;
cursor: pointer;
border-radius: 0.3rem;
transition: all 0.15s;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
color: var(--white, #fff);
}
}
}
}
}
}
}
}

파일 보기

@ -0,0 +1,188 @@
/**
* 구역분석 컴포넌트
*
* AreaSearchPage에서 추출된 구역분석 전용 UI:
* - ZoneDrawPanel (구역 설정)
* - 검색 모드 (ANY / ALL / SEQUENTIAL)
* - 결과 영역 (선박 리스트, 요약, CSV 내보내기)
* - VesselDetailModal
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import {
SEARCH_MODE_LABELS,
} from '../types/areaSearch.types';
import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
import ZoneDrawPanel from './ZoneDrawPanel';
import VesselDetailModal from './VesselDetailModal';
import { exportSearchResultToCSV } from '../utils/csvExport';
export default function AreaSearchTab({ isLoading, errorMessage }) {
const [detailVesselId, setDetailVesselId] = useState(null);
const zones = useAreaSearchStore((s) => s.zones);
const searchMode = useAreaSearchStore((s) => s.searchMode);
const tracks = useAreaSearchStore((s) => s.tracks);
const hitDetails = useAreaSearchStore((s) => s.hitDetails);
const summary = useAreaSearchStore((s) => s.summary);
const queryCompleted = useAreaSearchStore((s) => s.queryCompleted);
const disabledVesselIds = useAreaSearchStore((s) => s.disabledVesselIds);
const highlightedVesselId = useAreaSearchStore((s) => s.highlightedVesselId);
const setSearchMode = useAreaSearchStore((s) => s.setSearchMode);
const handleToggleVessel = useCallback((vesselId) => {
useAreaSearchStore.getState().toggleVesselEnabled(vesselId);
}, []);
const handleHighlightVessel = useCallback((vesselId) => {
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
}, []);
const handleExportCSV = useCallback(() => {
exportSearchResultToCSV(tracks, hitDetails, zones);
}, [tracks, hitDetails, zones]);
const listRef = useRef(null);
useEffect(() => {
if (!highlightedVesselId || !listRef.current) return;
const el = listRef.current.querySelector('.vessel-item.highlighted');
if (!el) return;
const container = listRef.current;
const elRect = el.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (elRect.top >= containerRect.top && elRect.bottom <= containerRect.bottom) return;
container.scrollTop += (elRect.top - containerRect.top);
}, [highlightedVesselId]);
return (
<>
{/* 구역 설정 */}
<ZoneDrawPanel disabled={isLoading} />
{/* 검색 모드 */}
<div className="search-mode">
{Object.entries(SEARCH_MODE_LABELS).map(([mode, label]) => (
<label key={mode} className={`mode-chip ${searchMode === mode ? 'active' : ''}`}>
<input
type="radio"
name="searchMode"
value={mode}
checked={searchMode === mode}
onChange={() => {
if (!useAreaSearchStore.getState().confirmAndClearResults()) return;
setSearchMode(mode);
}}
disabled={isLoading}
/>
<span>{label}</span>
</label>
))}
</div>
{/* 결과 영역 */}
<div className="result-section">
{errorMessage && <div className="error-message">{errorMessage}</div>}
{isLoading && <div className="loading-message">데이터를 불러오는 중입니다...</div>}
{queryCompleted && tracks.length > 0 && (
<div className="result-content">
<div className="result-summary">
<span className="result-summary-text">
검색결과: {summary?.totalVessels ?? tracks.length}
{summary?.processingTimeMs != null && (
<span className="processing-time">
({(summary.processingTimeMs / 1000).toFixed(2)})
</span>
)}
</span>
<button type="button" className="btn-csv" onClick={handleExportCSV}>
CSV 내보내기
</button>
</div>
<ul className="vessel-list" ref={listRef}>
{tracks.map((track) => {
const isDisabled = disabledVesselIds.has(track.vesselId);
const isHighlighted = highlightedVesselId === track.vesselId;
const color = getShipKindColor(track.shipKindCode);
const rgbStr = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
const vesselHits = hitDetails[track.vesselId] || [];
const totalVisits = vesselHits.length;
const hasRevisits = totalVisits > zones.length;
return (
<li
key={track.vesselId}
className={`vessel-item ${isDisabled ? 'disabled' : ''} ${isHighlighted ? 'highlighted' : ''}`}
onMouseEnter={(e) => {
handleHighlightVessel(track.vesselId);
const rect = e.currentTarget.getBoundingClientRect();
useAreaSearchStore.getState().setAreaSearchTooltip({
vesselId: track.vesselId,
x: rect.right + 8,
y: rect.top,
});
}}
onMouseLeave={() => {
handleHighlightVessel(null);
useAreaSearchStore.getState().setAreaSearchTooltip(null);
}}
>
<button
type="button"
className="vessel-toggle"
onClick={() => handleToggleVessel(track.vesselId)}
>
<span className="vessel-color" style={{ backgroundColor: rgbStr }} />
<span className="vessel-name">
{track.shipName || track.targetId}
</span>
<span className="vessel-info">
{getShipKindName(track.shipKindCode)} / {getSignalSourceName(track.sigSrcCd)}
{hasRevisits && (
<span className="visit-count">{totalVisits}</span>
)}
</span>
</button>
<button
type="button"
className="vessel-detail-btn"
onClick={(e) => {
e.stopPropagation();
setDetailVesselId(track.vesselId);
}}
title="상세 보기"
>
&#9654;
</button>
</li>
);
})}
</ul>
</div>
)}
{queryCompleted && tracks.length === 0 && !errorMessage && (
<div className="empty-message">조건에 맞는 선박이 없습니다.</div>
)}
{!isLoading && !queryCompleted && !errorMessage && (
<div className="empty-message">
구역을 설정하고 조회 버튼을 클릭하세요.
</div>
)}
</div>
{detailVesselId && (
<VesselDetailModal
vesselId={detailVesselId}
onClose={() => setDetailVesselId(null)}
/>
)}
</>
);
}

파일 보기

@ -0,0 +1,265 @@
/**
* 항적분석 타임라인 재생 컨트롤
* 참조: src/replay/components/ReplayTimeline.jsx (간소화)
*
* - 재생/일시정지/정지
* - 배속 조절 (1x ~ 1000x)
* - 프로그레스 (range slider)
* - 드래그 가능한 헤더
*/
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { useStsStore } from '../stores/stsStore';
import { unregisterAreaSearchLayers } from '../utils/areaSearchLayerRegistry';
import { unregisterStsLayers } from '../utils/stsLayerRegistry';
import { showLiveShips } from '../../utils/liveControl';
import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
import { PLAYBACK_SPEED_OPTIONS, ANALYSIS_TABS } from '../types/areaSearch.types';
import './AreaSearchTimeline.scss';
const PATH_LABEL = '항적';
const TRAIL_LABEL = '궤적';
function formatDateTime(ms) {
if (!ms || ms <= 0) return '--:--:--';
const d = new Date(ms);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
export default function AreaSearchTimeline() {
const isPlaying = useAreaSearchAnimationStore((s) => s.isPlaying);
const currentTime = useAreaSearchAnimationStore((s) => s.currentTime);
const startTime = useAreaSearchAnimationStore((s) => s.startTime);
const endTime = useAreaSearchAnimationStore((s) => s.endTime);
const playbackSpeed = useAreaSearchAnimationStore((s) => s.playbackSpeed);
const play = useAreaSearchAnimationStore((s) => s.play);
const pause = useAreaSearchAnimationStore((s) => s.pause);
const stop = useAreaSearchAnimationStore((s) => s.stop);
const setCurrentTime = useAreaSearchAnimationStore((s) => s.setCurrentTime);
const setPlaybackSpeed = useAreaSearchAnimationStore((s) => s.setPlaybackSpeed);
const activeTab = useAreaSearchStore((s) => s.activeTab);
const isSts = activeTab === ANALYSIS_TABS.STS;
const areaShowPaths = useAreaSearchStore((s) => s.showPaths);
const areaShowTrail = useAreaSearchStore((s) => s.showTrail);
const stsShowPaths = useStsStore((s) => s.showPaths);
const stsShowTrail = useStsStore((s) => s.showTrail);
const showPaths = isSts ? stsShowPaths : areaShowPaths;
const showTrail = isSts ? stsShowTrail : areaShowTrail;
const handleTogglePaths = useCallback(() => {
if (isSts) useStsStore.getState().setShowPaths(!stsShowPaths);
else useAreaSearchStore.getState().setShowPaths(!areaShowPaths);
}, [isSts, stsShowPaths, areaShowPaths]);
const handleToggleTrail = useCallback(() => {
if (isSts) useStsStore.getState().setShowTrail(!stsShowTrail);
else useAreaSearchStore.getState().setShowTrail(!areaShowTrail);
}, [isSts, stsShowTrail, areaShowTrail]);
const progress = useMemo(() => {
if (endTime <= startTime || startTime <= 0) return 0;
return ((currentTime - startTime) / (endTime - startTime)) * 100;
}, [currentTime, startTime, endTime]);
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
const speedMenuRef = useRef(null);
//
const [isDragging, setIsDragging] = useState(false);
const [hasDragged, setHasDragged] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const containerRef = useRef(null);
useEffect(() => {
const handleClickOutside = (event) => {
if (speedMenuRef.current && !speedMenuRef.current.contains(event.target)) {
setShowSpeedMenu(false);
}
};
if (showSpeedMenu) document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showSpeedMenu]);
const handleMouseDown = useCallback((e) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const parent = containerRef.current.parentElement;
if (!parent) return;
const parentRect = parent.getBoundingClientRect();
setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
if (!hasDragged) {
setPosition({ x: rect.left - parentRect.left, y: rect.top - parentRect.top });
setHasDragged(true);
}
setIsDragging(true);
}, [hasDragged]);
useEffect(() => {
const handleMouseMove = (e) => {
if (!isDragging || !containerRef.current) return;
const parent = containerRef.current.parentElement;
if (!parent) return;
const parentRect = parent.getBoundingClientRect();
const containerRect = containerRef.current.getBoundingClientRect();
let newX = e.clientX - parentRect.left - dragOffset.x;
let newY = e.clientY - parentRect.top - dragOffset.y;
newX = Math.max(0, Math.min(newX, parentRect.width - containerRect.width));
newY = Math.max(0, Math.min(newY, parentRect.height - containerRect.height));
setPosition({ x: newX, y: newY });
};
const handleMouseUp = () => setIsDragging(false);
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, dragOffset]);
const handlePlayPause = useCallback(() => {
if (isPlaying) pause();
else play();
}, [isPlaying, play, pause]);
const handleStop = useCallback(() => { stop(); }, [stop]);
const handleSpeedChange = useCallback((speed) => {
setPlaybackSpeed(speed);
setShowSpeedMenu(false);
}, [setPlaybackSpeed]);
const handleSliderChange = useCallback((e) => {
setCurrentTime(parseFloat(e.target.value));
}, [setCurrentTime]);
const handleClose = useCallback(() => {
useAreaSearchStore.getState().clearResults();
useStsStore.getState().clearResults();
useAreaSearchAnimationStore.getState().reset();
unregisterAreaSearchLayers();
unregisterStsLayers();
showLiveShips();
shipBatchRenderer.immediateRender();
}, []);
const hasData = endTime > startTime && startTime > 0;
return (
<div
ref={containerRef}
className={`area-search-timeline ${isPlaying ? 'playing' : ''} ${isDragging ? 'dragging' : ''}`}
style={hasDragged ? {
left: `${position.x}px`,
top: `${position.y}px`,
bottom: 'auto',
transform: 'none',
} : undefined}
>
<div className="timeline-header" onMouseDown={handleMouseDown}>
<div className="header-content">
<span className="header-title">항적 분석</span>
</div>
<button type="button" className="header-close-btn" onClick={handleClose} title="닫기">
&#x2715;
</button>
</div>
<div className="timeline-controls">
<div className="speed-selector" ref={speedMenuRef}>
<button
type="button"
className="speed-btn"
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
disabled={!hasData}
>
{playbackSpeed}x
</button>
{showSpeedMenu && (
<div className="speed-menu">
{PLAYBACK_SPEED_OPTIONS.map((speed) => (
<button
key={speed}
type="button"
className={`speed-option ${playbackSpeed === speed ? 'active' : ''}`}
onClick={() => handleSpeedChange(speed)}
>
{speed}x
</button>
))}
</div>
)}
</div>
<button
type="button"
className={`control-btn play-btn ${isPlaying ? 'playing' : ''}`}
onClick={handlePlayPause}
disabled={!hasData}
title={isPlaying ? '일시정지' : '재생'}
>
{isPlaying ? '\u275A\u275A' : '\u25B6'}
</button>
<button
type="button"
className="control-btn stop-btn"
onClick={handleStop}
disabled={!hasData}
title="정지"
>
&#x25A0;
</button>
<div className="timeline-slider-container">
<input
type="range"
className="timeline-slider"
min={startTime}
max={endTime}
step={(endTime - startTime) / 1000 || 1}
value={currentTime}
onChange={handleSliderChange}
disabled={!hasData}
style={{ '--progress': `${progress}%` }}
/>
</div>
<span className="current-time-display">
{hasData ? formatDateTime(currentTime) : '--:--:--'}
</span>
<label className="filter-toggle">
<input
type="checkbox"
checked={showPaths}
onChange={handleTogglePaths}
disabled={!hasData}
/>
<span>{PATH_LABEL}</span>
</label>
<label className="filter-toggle">
<input
type="checkbox"
checked={showTrail}
onChange={handleToggleTrail}
disabled={!hasData}
/>
<span>{TRAIL_LABEL}</span>
</label>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,362 @@
.area-search-timeline {
position: absolute;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
background: rgba(30, 35, 55, 0.95);
border-radius: 6px;
overflow: visible;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
min-width: 400px;
&.playing {
.play-btn {
animation: area-search-pulse 1.5s infinite;
}
}
&.dragging {
cursor: grabbing;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
opacity: 0.95;
}
.timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 26px;
background: linear-gradient(135deg, rgba(255, 152, 0, 0.3), rgba(255, 183, 77, 0.2));
border-bottom: 1px solid rgba(255, 152, 0, 0.3);
border-radius: 6px 6px 0 0;
cursor: grab;
user-select: none;
&:active {
cursor: grabbing;
}
.header-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.header-title {
font-size: 13px;
font-weight: 600;
color: #fff;
letter-spacing: 0.5px;
}
.header-close-btn {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.2s ease;
line-height: 1;
&:hover {
background: rgba(244, 67, 54, 0.3);
color: #fff;
}
}
}
.timeline-controls {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 26px;
}
.speed-selector {
position: relative;
z-index: 100;
.speed-btn {
background: rgba(255, 152, 0, 0.2);
border: 1px solid rgba(255, 152, 0, 0.4);
border-radius: 4px;
color: #ffb74d;
font-size: 11px;
font-weight: 600;
padding: 5px 10px;
cursor: pointer;
transition: all 0.2s ease;
min-width: 50px;
&:hover:not(:disabled) {
background: rgba(255, 152, 0, 0.3);
border-color: #ffb74d;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.speed-menu {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 4px;
background: rgba(40, 45, 70, 0.98);
border: 1px solid rgba(255, 152, 0, 0.4);
border-radius: 6px;
padding: 6px;
display: flex;
flex-wrap: wrap;
gap: 4px;
min-width: 180px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
z-index: 101;
.speed-option {
flex: 0 0 calc(33.333% - 4px);
background: rgba(255, 255, 255, 0.1);
border: 1px solid transparent;
border-radius: 4px;
color: #fff;
font-size: 11px;
font-weight: 500;
padding: 6px 8px;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
&:hover {
background: rgba(255, 152, 0, 0.3);
border-color: rgba(255, 152, 0, 0.5);
}
&.active {
background: rgba(255, 152, 0, 0.5);
border-color: #ffb74d;
color: #fff;
}
}
}
}
.control-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: all 0.2s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.play-btn {
background: linear-gradient(135deg, #4caf50, #45a049);
color: #fff;
&:hover:not(:disabled) {
transform: scale(1.1);
box-shadow: 0 0 12px rgba(76, 175, 80, 0.5);
}
&.playing {
background: linear-gradient(135deg, #ffc107, #ffb300);
}
}
&.stop-btn {
background: rgba(244, 67, 54, 0.8);
color: #fff;
&:hover:not(:disabled) {
background: rgba(244, 67, 54, 1);
transform: scale(1.1);
}
}
}
.timeline-slider-container {
flex: 1;
position: relative;
height: 20px;
display: flex;
align-items: center;
min-width: 100px;
padding: 0 7px;
&::before {
content: '';
position: absolute;
left: 7px;
right: 7px;
top: 50%;
transform: translateY(-50%);
height: 6px;
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
pointer-events: none;
}
.timeline-slider {
--progress: 0%;
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
border-radius: 3px;
cursor: pointer;
background: transparent;
position: relative;
z-index: 1;
&::-webkit-slider-runnable-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(
to right,
#ffb74d 0%,
#ff9800 var(--progress),
transparent var(--progress),
transparent 100%
);
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #fff;
border: 2px solid #ff9800;
border-radius: 50%;
cursor: grab;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: transform 0.1s ease;
margin-top: -4px;
&:hover {
transform: scale(1.2);
}
&:active {
cursor: grabbing;
}
}
&::-moz-range-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(
to right,
#ffb74d 0%,
#ff9800 var(--progress),
transparent var(--progress),
transparent 100%
);
}
&::-moz-range-thumb {
width: 14px;
height: 14px;
background: #fff;
border: 2px solid #ff9800;
border-radius: 50%;
cursor: grab;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&::-webkit-slider-thumb {
cursor: not-allowed;
}
}
}
}
.current-time-display {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 11px;
font-weight: 500;
color: #ffb74d;
min-width: 130px;
text-align: center;
white-space: nowrap;
}
.filter-toggle {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
font-size: 11px;
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid transparent;
transition: all 0.2s ease;
input[type='checkbox'] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: #ff9800;
&:disabled {
cursor: not-allowed;
}
}
&:hover {
color: #fff;
}
&:has(input:not(:checked)) {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.2);
span {
color: rgba(255, 255, 255, 0.5);
}
}
&:has(input:checked) {
background: rgba(255, 152, 0, 0.2);
border-color: rgba(255, 152, 0, 0.6);
span {
color: #ff9800;
font-weight: 600;
}
}
}
}
@keyframes area-search-pulse {
0% {
box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.4);
}
70% {
box-shadow: 0 0 0 8px rgba(255, 193, 7, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 193, 7, 0);
}
}

파일 보기

@ -0,0 +1,143 @@
/**
* 항적분석 호버 툴팁 컴포넌트
* - 선박 기본 정보 (선종, 선명, 신호원)
* - 시간순 방문 이력 (구역 무관, entryTimestamp 정렬)
*/
import { useMemo } from 'react';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { ZONE_COLORS } from '../types/areaSearch.types';
import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
import './AreaSearchTooltip.scss';
const OFFSET_X = 14;
const OFFSET_Y = -20;
/** nationalCode → 국기 SVG URL */
function getNationalFlagUrl(nationalCode) {
if (!nationalCode) return null;
return `/ship/image/small/${nationalCode}.svg`;
}
export function formatTimestamp(ms) {
if (!ms) return '-';
const d = new Date(ms);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
export function formatPosition(pos) {
if (!pos || pos.length < 2) return null;
const lon = pos[0];
const lat = pos[1];
const latDir = lat >= 0 ? 'N' : 'S';
const lonDir = lon >= 0 ? 'E' : 'W';
return `${Math.abs(lat).toFixed(4)}\u00B0${latDir} ${Math.abs(lon).toFixed(4)}\u00B0${lonDir}`;
}
export default function AreaSearchTooltip() {
const tooltip = useAreaSearchStore((s) => s.areaSearchTooltip);
const tracks = useAreaSearchStore((s) => s.tracks);
const hitDetails = useAreaSearchStore((s) => s.hitDetails);
const zones = useAreaSearchStore((s) => s.zones);
const zoneMap = useMemo(() => {
const map = new Map();
zones.forEach((z, idx) => {
map.set(z.id, z);
map.set(z.name, z);
map.set(idx, z);
map.set(String(idx), z);
});
return map;
}, [zones]);
if (!tooltip) return null;
const { vesselId, x, y } = tooltip;
const track = tracks.find((t) => t.vesselId === vesselId);
if (!track) return null;
const hits = hitDetails[vesselId] || [];
const kindName = getShipKindName(track.shipKindCode);
const sourceName = getSignalSourceName(track.sigSrcCd);
const flagUrl = getNationalFlagUrl(track.nationalCode);
// ( )
const sortedHits = [...hits].sort((a, b) => a.entryTimestamp - b.entryTimestamp);
return (
<div
className="area-search-tooltip"
style={{ left: x + OFFSET_X, top: y + OFFSET_Y }}
>
<div className="area-search-tooltip__header">
<span className="area-search-tooltip__kind">{kindName}</span>
{flagUrl && (
<span className="area-search-tooltip__flag">
<img
src={flagUrl}
alt="국기"
onError={(e) => { e.target.style.display = 'none'; }}
/>
</span>
)}
<span className="area-search-tooltip__name">
{track.shipName || track.targetId || '-'}
</span>
</div>
<div className="area-search-tooltip__info">
<span>{sourceName}</span>
</div>
{sortedHits.length > 0 && (
<div className="area-search-tooltip__zones">
{sortedHits.map((hit, idx) => {
const zone = zoneMap.get(hit.polygonId);
const zoneColor = zone
? (ZONE_COLORS[zone.colorIndex]?.label || '#ffd43b')
: '#adb5bd';
const zoneName = zone
? `${zone.name}구역`
: (hit.polygonName ? `${hit.polygonName}구역` : '구역');
const visitLabel = hit.visitIndex > 1 || sortedHits.filter((h) => h.polygonId === hit.polygonId).length > 1
? `(${hit.visitIndex}차)`
: '';
const entryPos = formatPosition(hit.entryPosition);
const exitPos = formatPosition(hit.exitPosition);
return (
<div key={`${hit.polygonId}-${hit.visitIndex}-${idx}`} className="area-search-tooltip__zone">
<div className="area-search-tooltip__zone-header">
<span className="area-search-tooltip__visit-seq">{idx + 1}.</span>
<span
className="area-search-tooltip__zone-name"
style={{ color: zoneColor }}
>
{zoneName}
</span>
{visitLabel && (
<span className="area-search-tooltip__visit-label">{visitLabel}</span>
)}
</div>
<div className="area-search-tooltip__zone-row">
<span className="area-search-tooltip__zone-label">{idx + 1}-IN</span>
<span>{formatTimestamp(hit.entryTimestamp)}</span>
{entryPos && (
<span className="area-search-tooltip__pos">{entryPos}</span>
)}
</div>
<div className="area-search-tooltip__zone-row">
<span className="area-search-tooltip__zone-label">{idx + 1}-OUT</span>
<span>{formatTimestamp(hit.exitTimestamp)}</span>
{exitPos && (
<span className="area-search-tooltip__pos">{exitPos}</span>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}

파일 보기

@ -0,0 +1,118 @@
.area-search-tooltip {
position: fixed;
z-index: 200;
pointer-events: none;
background: rgba(20, 24, 32, 0.95);
border-radius: 6px;
padding: 10px 14px;
min-width: 180px;
max-width: 340px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
color: #fff;
font-size: 12px;
&__header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 3px;
}
&__kind {
display: inline-block;
padding: 1px 5px;
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
font-size: 10px;
color: #adb5bd;
}
&__flag {
display: inline-flex;
align-items: center;
img {
width: 16px;
height: 12px;
object-fit: contain;
vertical-align: middle;
}
}
&__name {
font-weight: 700;
font-size: 13px;
color: #fff;
}
&__info {
display: flex;
align-items: center;
gap: 4px;
color: #ced4da;
margin-bottom: 2px;
}
&__sep {
color: rgba(255, 255, 255, 0.2);
}
&__zones {
border-top: 1px solid rgba(255, 255, 255, 0.12);
margin-top: 4px;
padding-top: 5px;
display: flex;
flex-direction: column;
gap: 4px;
}
&__zone {
display: flex;
flex-direction: column;
gap: 1px;
}
&__zone-header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 1px;
}
&__visit-seq {
font-size: 10px;
color: #868e96;
min-width: 14px;
}
&__zone-name {
font-weight: 700;
font-size: 11px;
}
&__visit-label {
font-size: 10px;
color: #868e96;
}
&__zone-row {
display: flex;
align-items: center;
gap: 5px;
color: #ced4da;
font-size: 11px;
padding-left: 18px;
}
&__zone-label {
font-weight: 600;
font-size: 9px;
color: #868e96;
min-width: 34px;
}
&__pos {
color: #74b9ff;
font-size: 10px;
}
}

파일 보기

@ -0,0 +1,140 @@
/**
* STS(Ship-to-Ship) 분석 컴포넌트
*
* - ZoneDrawPanel (maxZones=1)
* - STS 파라미터 슬라이더 (최소 접촉 시간, 최대 접촉 거리)
* - 결과: StsContactList
*/
import { useCallback, useState } from 'react';
import './StsAnalysisTab.scss';
import { useStsStore } from '../stores/stsStore';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { STS_LIMITS } from '../types/sts.types';
import ZoneDrawPanel from './ZoneDrawPanel';
import StsContactList from './StsContactList';
import StsContactDetailModal from './StsContactDetailModal';
export default function StsAnalysisTab({ isLoading, errorMessage }) {
const queryCompleted = useStsStore((s) => s.queryCompleted);
const groupedContacts = useStsStore((s) => s.groupedContacts);
const summary = useStsStore((s) => s.summary);
const minContactDuration = useStsStore((s) => s.minContactDurationMinutes);
const maxContactDistance = useStsStore((s) => s.maxContactDistanceMeters);
const handleDurationChange = useCallback((e) => {
useStsStore.getState().setMinContactDuration(Number(e.target.value));
}, []);
const handleDistanceChange = useCallback((e) => {
useStsStore.getState().setMaxContactDistance(Number(e.target.value));
}, []);
const [detailGroupIndex, setDetailGroupIndex] = useState(null);
const handleDetailClick = useCallback((idx) => {
setDetailGroupIndex(idx);
}, []);
const handleDetailClose = useCallback(() => {
setDetailGroupIndex(null);
}, []);
return (
<>
{/* 구역 설정 (1개만) */}
<ZoneDrawPanel disabled={isLoading} maxZones={1} />
{/* STS 파라미터 */}
<div className="sts-params">
<div className="sts-param">
<div className="sts-param__header">
<span className="sts-param__label">최소 접촉 시간</span>
<span className="sts-param__value">{minContactDuration}</span>
</div>
<input
type="range"
className="sts-param__slider"
min={STS_LIMITS.DURATION_MIN}
max={STS_LIMITS.DURATION_MAX}
step={10}
value={minContactDuration}
onChange={handleDurationChange}
disabled={isLoading}
/>
<div className="sts-param__range">
<span>{STS_LIMITS.DURATION_MIN}</span>
<span>{STS_LIMITS.DURATION_MAX}</span>
</div>
</div>
<div className="sts-param">
<div className="sts-param__header">
<span className="sts-param__label">최대 접촉 거리</span>
<span className="sts-param__value">{maxContactDistance}m</span>
</div>
<input
type="range"
className="sts-param__slider"
min={STS_LIMITS.DISTANCE_MIN}
max={STS_LIMITS.DISTANCE_MAX}
step={50}
value={maxContactDistance}
onChange={handleDistanceChange}
disabled={isLoading}
/>
<div className="sts-param__range">
<span>{STS_LIMITS.DISTANCE_MIN}m</span>
<span>{STS_LIMITS.DISTANCE_MAX}m</span>
</div>
</div>
</div>
{/* 결과 영역 */}
<div className="result-section">
{errorMessage && <div className="error-message">{errorMessage}</div>}
{isLoading && <div className="loading-message">데이터를 불러오는 중입니다...</div>}
{queryCompleted && groupedContacts.length > 0 && (
<div className="result-content">
{summary && (
<div className="sts-summary">
<span>접촉 {summary.totalContactPairs}</span>
<span className="sts-summary__sep">|</span>
<span>관련 {summary.totalVesselsInvolved}</span>
<span className="sts-summary__sep">|</span>
<span>구역 {summary.totalVesselsInPolygon}</span>
{summary.processingTimeMs != null && (
<>
<span className="sts-summary__sep">|</span>
<span className="sts-summary__time">
{(summary.processingTimeMs / 1000).toFixed(1)}
</span>
</>
)}
</div>
)}
<StsContactList onDetailClick={handleDetailClick} />
</div>
)}
{queryCompleted && groupedContacts.length === 0 && !errorMessage && (
<div className="empty-message">접촉 의심 쌍이 없습니다.</div>
)}
{!isLoading && !queryCompleted && !errorMessage && (
<div className="empty-message">
구역을 설정하고 조회 버튼을 클릭하세요.
</div>
)}
</div>
{detailGroupIndex !== null && (
<StsContactDetailModal
groupIndex={detailGroupIndex}
onClose={handleDetailClose}
/>
)}
</>
);
}

파일 보기

@ -0,0 +1,94 @@
// STS 분석 전용 스타일
.sts-params {
background-color: var(--secondary1, rgba(255, 255, 255, 0.05));
border-radius: 0.6rem;
padding: 1.2rem 1.5rem;
margin-bottom: 1.2rem;
display: flex;
flex-direction: column;
gap: 1.2rem;
.sts-param {
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.6rem;
}
&__label {
font-size: var(--fs-s, 1.2rem);
color: var(--tertiary4, #ccc);
}
&__value {
font-size: var(--fs-s, 1.2rem);
font-weight: var(--fw-bold, 700);
color: var(--primary1, #4a9eff);
min-width: 5rem;
text-align: right;
}
&__slider {
width: 100%;
height: 0.4rem;
-webkit-appearance: none;
appearance: none;
background: var(--tertiary2, rgba(255, 255, 255, 0.2));
border-radius: 0.2rem;
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
-webkit-appearance: none;
width: 1.4rem;
height: 1.4rem;
background: var(--primary1, #4a9eff);
border-radius: 50%;
cursor: pointer;
transition: transform 0.1s;
&:hover {
transform: scale(1.2);
}
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
&__range {
display: flex;
justify-content: space-between;
margin-top: 0.3rem;
font-size: var(--fs-xxs, 1rem);
color: var(--tertiary3, #666);
}
}
}
.sts-summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0;
margin-bottom: 0.8rem;
font-size: var(--fs-s, 1.2rem);
font-weight: var(--fw-semibold, 600);
color: var(--white, #fff);
flex-shrink: 0;
&__sep {
color: var(--tertiary3, #555);
font-weight: normal;
}
&__time {
color: var(--tertiary4, #999);
font-weight: normal;
font-size: var(--fs-xs, 1.1rem);
}
}

파일 보기

@ -0,0 +1,489 @@
/**
* STS 접촉 상세 모달 임베디드 OL 지도 + 그리드 레이아웃 + 이미지 저장
* 그룹 기반: 동일 선박 쌍의 여러 접촉을 리스트로 표시
*/
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import Map from 'ol/Map';
import View from 'ol/View';
import { XYZ } from 'ol/source';
import TileLayer from 'ol/layer/Tile';
import VectorSource from 'ol/source/Vector';
import VectorLayer from 'ol/layer/Vector';
import { Feature } from 'ol';
import { Point, LineString, Polygon } from 'ol/geom';
import { fromLonLat } from 'ol/proj';
import { Style, Fill, Stroke, Circle as CircleStyle, Text } from 'ol/style';
import { defaults as defaultControls, ScaleLine } from 'ol/control';
import { defaults as defaultInteractions } from 'ol/interaction';
import html2canvas from 'html2canvas';
import { useStsStore } from '../stores/stsStore';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { ZONE_COLORS } from '../types/areaSearch.types';
import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
import {
getIndicatorDetail,
formatDistance,
formatDuration,
getContactRiskColor,
} from '../types/sts.types';
import { mapLayerConfig } from '../../map/layers/baseLayer';
import './StsContactDetailModal.scss';
function getNationalFlagUrl(nationalCode) {
if (!nationalCode) return null;
return `/ship/image/small/${nationalCode}.svg`;
}
function createZoneFeatures(zones) {
const features = [];
zones.forEach((zone) => {
const coords3857 = zone.coordinates.map((c) => fromLonLat(c));
const polygon = new Polygon([coords3857]);
const feature = new Feature({ geometry: polygon });
const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0];
feature.setStyle([
new Style({
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }),
}),
new Style({
geometry: () => {
const ext = polygon.getExtent();
const center = [(ext[0] + ext[2]) / 2, (ext[1] + ext[3]) / 2];
return new Point(center);
},
text: new Text({
text: `${zone.name}구역`,
font: 'bold 12px sans-serif',
fill: new Fill({ color: color.label || '#fff' }),
stroke: new Stroke({ color: 'rgba(0,0,0,0.7)', width: 3 }),
}),
}),
]);
features.push(feature);
});
return features;
}
function createTrackFeature(track) {
const coords3857 = track.geometry.map((c) => fromLonLat(c));
const line = new LineString(coords3857);
const feature = new Feature({ geometry: line });
const color = getShipKindColor(track.shipKindCode);
feature.setStyle(new Style({
stroke: new Stroke({
color: `rgba(${color[0]},${color[1]},${color[2]},0.8)`,
width: 2,
}),
}));
return feature;
}
function createContactMarkers(contacts) {
const features = [];
contacts.forEach((contact, idx) => {
if (!contact.contactCenterPoint) return;
const pos3857 = fromLonLat(contact.contactCenterPoint);
const riskColor = getContactRiskColor(contact.indicators);
const f = new Feature({ geometry: new Point(pos3857) });
f.setStyle(new Style({
image: new CircleStyle({
radius: 10,
fill: new Fill({ color: `rgba(${riskColor[0]},${riskColor[1]},${riskColor[2]},0.6)` }),
stroke: new Stroke({ color: '#fff', width: 2 }),
}),
text: new Text({
text: contacts.length > 1 ? `#${idx + 1}` : '접촉 중심',
font: 'bold 11px sans-serif',
fill: new Fill({ color: '#fff' }),
stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }),
offsetY: -18,
}),
}));
features.push(f);
if (contact.contactStartTimestamp) {
const startLabel = `시작 ${formatTimestamp(contact.contactStartTimestamp)}`;
const endLabel = `종료 ${formatTimestamp(contact.contactEndTimestamp)}`;
const labelF = new Feature({ geometry: new Point(pos3857) });
labelF.setStyle(new Style({
text: new Text({
text: `${startLabel}\n${endLabel}`,
font: '10px sans-serif',
fill: new Fill({ color: '#ced4da' }),
stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }),
offsetY: 24,
}),
}));
features.push(labelF);
}
});
return features;
}
const MODAL_WIDTH = 680;
const MODAL_APPROX_HEIGHT = 780;
export default function StsContactDetailModal({ groupIndex, onClose }) {
const groupedContacts = useStsStore((s) => s.groupedContacts);
const tracks = useStsStore((s) => s.tracks);
const zones = useAreaSearchStore((s) => s.zones);
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
const contentRef = useRef(null);
const [position, setPosition] = useState(() => ({
x: (window.innerWidth - MODAL_WIDTH) / 2,
y: Math.max(20, (window.innerHeight - MODAL_APPROX_HEIGHT) / 2),
}));
const posRef = useRef(position);
const dragging = useRef(false);
const dragStart = useRef({ x: 0, y: 0 });
const handleMouseDown = useCallback((e) => {
dragging.current = true;
dragStart.current = {
x: e.clientX - posRef.current.x,
y: e.clientY - posRef.current.y,
};
e.preventDefault();
}, []);
useEffect(() => {
const handleMouseMove = (e) => {
if (!dragging.current) return;
const newPos = {
x: e.clientX - dragStart.current.x,
y: e.clientY - dragStart.current.y,
};
posRef.current = newPos;
setPosition(newPos);
};
const handleMouseUp = () => {
dragging.current = false;
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, []);
const group = useMemo(() => groupedContacts[groupIndex], [groupedContacts, groupIndex]);
const vessel1Track = useMemo(
() => tracks.find((t) => t.vesselId === group?.vessel1?.vesselId),
[tracks, group],
);
const vessel2Track = useMemo(
() => tracks.find((t) => t.vesselId === group?.vessel2?.vesselId),
[tracks, group],
);
// OL
useEffect(() => {
if (!mapContainerRef.current || !group || !vessel1Track || !vessel2Track) return;
const tileSource = new XYZ({
url: mapLayerConfig.darkLayer.source.getUrls()[0],
minZoom: 6,
maxZoom: 11,
});
const tileLayer = new TileLayer({ source: tileSource, preload: Infinity });
const zoneSource = new VectorSource({ features: createZoneFeatures(zones) });
const zoneLayer = new VectorLayer({ source: zoneSource });
const trackSource = new VectorSource({
features: [createTrackFeature(vessel1Track), createTrackFeature(vessel2Track)],
});
const trackLayer = new VectorLayer({ source: trackSource });
const markerFeatures = createContactMarkers(group.contacts);
const markerSource = new VectorSource({ features: markerFeatures });
const markerLayer = new VectorLayer({ source: markerSource });
const map = new Map({
target: mapContainerRef.current,
layers: [tileLayer, zoneLayer, trackLayer, markerLayer],
view: new View({ center: [0, 0], zoom: 7 }),
controls: defaultControls({ attribution: false, zoom: false, rotate: false })
.extend([new ScaleLine({ units: 'nautical' })]),
interactions: defaultInteractions({ doubleClickZoom: false }),
});
const allSource = new VectorSource();
[...zoneSource.getFeatures(), ...trackSource.getFeatures()].forEach((f) => allSource.addFeature(f.clone()));
const extent = allSource.getExtent();
if (extent && extent[0] !== Infinity) {
map.getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 14 });
}
mapRef.current = map;
return () => {
map.setTarget(null);
map.dispose();
mapRef.current = null;
};
}, [group, vessel1Track, vessel2Track, zones]);
const handleSaveImage = useCallback(async () => {
const el = contentRef.current;
if (!el) return;
const modal = el.parentElement;
const saved = {
elOverflow: el.style.overflow,
modalMaxHeight: modal.style.maxHeight,
modalOverflow: modal.style.overflow,
};
el.style.overflow = 'visible';
modal.style.maxHeight = 'none';
modal.style.overflow = 'visible';
try {
const canvas = await html2canvas(el, {
backgroundColor: '#141820',
useCORS: true,
scale: 2,
});
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const pad = (n) => String(n).padStart(2, '0');
const now = new Date();
const v1Name = group?.vessel1?.vesselName || 'V1';
const v2Name = group?.vessel2?.vesselName || 'V2';
link.href = url;
link.download = `STS분석_${v1Name}_${v2Name}_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.png`;
link.click();
URL.revokeObjectURL(url);
}, 'image/png');
} catch (err) {
console.error('[StsContactDetailModal] 이미지 저장 실패:', err);
} finally {
el.style.overflow = saved.elOverflow;
modal.style.maxHeight = saved.modalMaxHeight;
modal.style.overflow = saved.modalOverflow;
}
}, [group]);
if (!group || !vessel1Track || !vessel2Track) return null;
const { vessel1, vessel2, indicators } = group;
const riskColor = getContactRiskColor(indicators);
const primaryContact = group.contacts[0];
const lastContact = group.contacts[group.contacts.length - 1];
const activeIndicators = Object.entries(indicators || {})
.filter(([, val]) => val)
.map(([key]) => ({ key, detail: getIndicatorDetail(key, primaryContact) }));
return createPortal(
<div className="sts-detail-overlay" onClick={onClose}>
<div
className="sts-detail-modal"
style={{ left: position.x, top: position.y }}
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="sts-detail-modal__header" onMouseDown={handleMouseDown}>
<div className="sts-detail-modal__title">
<VesselBadge vessel={vessel1} track={vessel1Track} />
<span className="sts-detail-modal__arrow"></span>
<VesselBadge vessel={vessel2} track={vessel2Track} />
</div>
<button type="button" className="sts-detail-modal__close" onClick={onClose}>
&times;
</button>
</div>
{/* 콘텐츠 */}
<div className="sts-detail-modal__content" ref={contentRef}>
<div className="sts-detail-modal__map" ref={mapContainerRef} />
<div
className="sts-detail-modal__risk-bar"
style={{ backgroundColor: `rgba(${riskColor.join(',')})` }}
/>
{/* 접촉 요약 — 그리드 2열 */}
<div className="sts-detail-modal__section">
<h4 className="sts-detail-modal__section-title">접촉 요약</h4>
<div className="sts-detail-modal__summary-grid">
<div className="sts-detail-modal__stat-item">
<span className="stat-label">접촉 기간</span>
<span className="stat-value">{formatTimestamp(primaryContact.contactStartTimestamp)} ~ {formatTimestamp(lastContact.contactEndTimestamp)}</span>
</div>
<div className="sts-detail-modal__stat-item">
<span className="stat-label"> 접촉 시간</span>
<span className="stat-value">{formatDuration(group.totalDurationMinutes)}</span>
</div>
<div className="sts-detail-modal__stat-item">
<span className="stat-label">평균 거리</span>
<span className="stat-value">{formatDistance(group.avgDistanceMeters)}</span>
</div>
{group.contacts.length > 1 && (
<div className="sts-detail-modal__stat-item">
<span className="stat-label">접촉 횟수</span>
<span className="stat-value">{group.contacts.length}</span>
</div>
)}
</div>
</div>
{/* 특이사항 */}
{activeIndicators.length > 0 && (
<div className="sts-detail-modal__section">
<h4 className="sts-detail-modal__section-title">특이사항</h4>
<div className="sts-detail-modal__indicators">
{activeIndicators.map(({ key, detail }) => (
<span key={key} className={`sts-detail-modal__badge sts-detail-modal__badge--${key}`}>
{detail}
</span>
))}
</div>
</div>
)}
{/* 접촉 이력 (2개 이상) */}
{group.contacts.length > 1 && (
<div className="sts-detail-modal__section">
<h4 className="sts-detail-modal__section-title">접촉 이력 ({group.contacts.length})</h4>
<div className="sts-detail-modal__contact-list">
{group.contacts.map((c, ci) => (
<div key={ci} className="sts-detail-modal__contact-item">
<span className="sts-detail-modal__contact-num">#{ci + 1}</span>
<span>{formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}</span>
<span className="sts-detail-modal__contact-sep">|</span>
<span>{formatDuration(c.contactDurationMinutes)}</span>
<span className="sts-detail-modal__contact-sep">|</span>
<span>평균 {formatDistance(c.avgDistanceMeters)}</span>
</div>
))}
</div>
</div>
)}
{/* 거리 통계 — 3열 그리드 */}
<div className="sts-detail-modal__section">
<h4 className="sts-detail-modal__section-title">거리 통계</h4>
<div className="sts-detail-modal__stats-grid">
<div className="sts-detail-modal__stat-item">
<span className="stat-label">최소</span>
<span className="stat-value">{formatDistance(group.minDistanceMeters)}</span>
</div>
<div className="sts-detail-modal__stat-item">
<span className="stat-label">평균</span>
<span className="stat-value">{formatDistance(group.avgDistanceMeters)}</span>
</div>
<div className="sts-detail-modal__stat-item">
<span className="stat-label">최대</span>
<span className="stat-value">{formatDistance(group.maxDistanceMeters)}</span>
</div>
</div>
<div className="sts-detail-modal__stats-grid" style={{ marginTop: 6 }}>
<div className="sts-detail-modal__stat-item">
<span className="stat-label">측정</span>
<span className="stat-value">{group.totalContactPointCount} 포인트</span>
</div>
{group.contactCenterPoint && (
<div className="sts-detail-modal__stat-item" style={{ gridColumn: 'span 2' }}>
<span className="stat-label">중심 좌표</span>
<span className="stat-value sts-detail-modal__pos">{formatPosition(group.contactCenterPoint)}</span>
</div>
)}
</div>
</div>
{/* 선박 상세 — 2열 그리드 */}
<VesselDetailSection label="선박 1" vessel={vessel1} track={vessel1Track} />
<VesselDetailSection label="선박 2" vessel={vessel2} track={vessel2Track} />
</div>
<div className="sts-detail-modal__footer">
<button type="button" className="sts-detail-modal__save-btn" onClick={handleSaveImage}>
이미지 저장
</button>
</div>
</div>
</div>,
document.body,
);
}
function VesselBadge({ vessel, track }) {
const kindName = getShipKindName(track.shipKindCode);
const flagUrl = getNationalFlagUrl(vessel.nationalCode);
return (
<span className="sts-detail-modal__vessel-badge">
<span className="sts-detail-modal__kind">{kindName}</span>
{flagUrl && (
<img
className="sts-detail-modal__flag"
src={flagUrl}
alt=""
onError={(e) => { e.target.style.display = 'none'; }}
/>
)}
<span className="sts-detail-modal__name">
{vessel.vesselName || vessel.vesselId || '-'}
</span>
</span>
);
}
function VesselDetailSection({ label, vessel, track }) {
const kindName = getShipKindName(track.shipKindCode);
const sourceName = getSignalSourceName(track.sigSrcCd);
const color = getShipKindColor(track.shipKindCode);
return (
<div className="sts-detail-modal__section">
<h4 className="sts-detail-modal__section-title">
<span
className="sts-detail-modal__track-dot"
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
/>
{label} {vessel.vesselName || vessel.vesselId}
</h4>
<div className="sts-detail-modal__vessel-grid">
<div className="sts-detail-modal__vessel-grid-item">
<span className="vessel-item-label">선종</span>
<span className="vessel-item-value">{kindName}</span>
</div>
<div className="sts-detail-modal__vessel-grid-item">
<span className="vessel-item-label">신호원</span>
<span className="vessel-item-value">{sourceName}</span>
</div>
<div className="sts-detail-modal__vessel-grid-item">
<span className="vessel-item-label">구역 체류</span>
<span className="vessel-item-value">{formatDuration(vessel.insidePolygonDurationMinutes)}</span>
</div>
<div className="sts-detail-modal__vessel-grid-item">
<span className="vessel-item-label">평균 속력</span>
<span className="vessel-item-value">{vessel.estimatedAvgSpeedKnots?.toFixed(1) ?? '-'} kn</span>
</div>
<div className="sts-detail-modal__vessel-grid-item">
<span className="vessel-item-label">진입 시각</span>
<span className="vessel-item-value">{formatTimestamp(vessel.insidePolygonStartTs)}</span>
</div>
<div className="sts-detail-modal__vessel-grid-item">
<span className="vessel-item-label">퇴출 시각</span>
<span className="vessel-item-value">{formatTimestamp(vessel.insidePolygonEndTs)}</span>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,319 @@
.sts-detail-overlay {
position: fixed;
inset: 0;
z-index: 300;
background: rgba(0, 0, 0, 0.6);
}
.sts-detail-modal {
position: fixed;
z-index: 301;
width: 680px;
max-height: 90vh;
display: flex;
flex-direction: column;
background: rgba(20, 24, 32, 0.98);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
color: #fff;
overflow: hidden;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
cursor: move;
user-select: none;
}
&__title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
&__arrow {
color: #4a9eff;
font-weight: 700;
font-size: 14px;
flex-shrink: 0;
}
&__vessel-badge {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
}
&__kind {
padding: 2px 6px;
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
font-size: 10px;
color: #adb5bd;
flex-shrink: 0;
}
&__flag {
width: 18px;
height: 13px;
object-fit: contain;
flex-shrink: 0;
}
&__name {
font-weight: 700;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: #868e96;
font-size: 20px;
cursor: pointer;
border-radius: 4px;
flex-shrink: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
&__content {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
&__map {
width: 100%;
height: 480px;
flex-shrink: 0;
background: #0d1117;
.ol-scale-line {
bottom: 8px;
left: 8px;
}
}
&__risk-bar {
height: 3px;
flex-shrink: 0;
}
&__section {
padding: 10px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
&:last-child {
border-bottom: none;
}
}
&__section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #ced4da;
margin: 0 0 8px 0;
}
&__track-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
&__row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #ced4da;
padding: 2px 0;
}
&__label {
font-weight: 600;
font-size: 11px;
color: #868e96;
min-width: 60px;
flex-shrink: 0;
}
&__pos {
color: #74b9ff;
font-size: 11px;
}
// ========== 그리드 레이아웃 ==========
&__summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
&__stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
&__stat-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.04);
border-radius: 4px;
.stat-label {
font-size: 10px;
font-weight: 600;
color: #868e96;
}
.stat-value {
font-size: 12px;
color: #ced4da;
}
}
&__vessel-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
&__vessel-grid-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.04);
border-radius: 4px;
.vessel-item-label {
font-size: 10px;
font-weight: 600;
color: #868e96;
}
.vessel-item-value {
font-size: 12px;
color: #ced4da;
}
}
// ========== 접촉 이력 리스트 ==========
&__contact-list {
display: flex;
flex-direction: column;
gap: 4px;
}
&__contact-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 11px;
color: #ced4da;
background: rgba(255, 255, 255, 0.03);
border-radius: 3px;
}
&__contact-num {
font-weight: 700;
color: #4a9eff;
min-width: 20px;
}
&__contact-sep {
color: #495057;
font-size: 10px;
}
&__indicators {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
&__badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
&--lowSpeedContact {
background: rgba(46, 204, 113, 0.15);
color: #2ecc71;
}
&--differentVesselTypes {
background: rgba(243, 156, 18, 0.15);
color: #f39c12;
}
&--differentNationalities {
background: rgba(52, 152, 219, 0.15);
color: #3498db;
}
&--nightTimeContact {
background: rgba(155, 89, 182, 0.15);
color: #9b59b6;
}
}
&__footer {
display: flex;
justify-content: flex-end;
padding: 10px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
}
&__save-btn {
padding: 6px 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: transparent;
color: #ced4da;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: #4a9eff;
color: #fff;
}
}
}

파일 보기

@ -0,0 +1,261 @@
/**
* STS 접촉 결과 리스트 (그룹 기반)
*
* - 동일 선박 쌍의 여러 접촉을 하나의 카드로 그룹핑
* - 카드 클릭 on/off 토글
* - / 버튼 하단 정보 확장
* - 버튼 모달 팝업
* - 호버 지도 하이라이트
*/
import { useCallback, useEffect, useRef } from 'react';
import './StsContactList.scss';
import { useStsStore } from '../stores/stsStore';
import { getShipKindName } from '../../tracking/types/trackQuery.types';
import {
getIndicatorDetail,
formatDistance,
formatDuration,
getContactRiskColor,
} from '../types/sts.types';
import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
function getNationalFlagUrl(nationalCode) {
if (!nationalCode) return null;
return `/ship/image/small/${nationalCode}.svg`;
}
function GroupCard({ group, index, onDetailClick }) {
const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex);
const expandedGroupIndex = useStsStore((s) => s.expandedGroupIndex);
const disabledGroupIndices = useStsStore((s) => s.disabledGroupIndices);
const isHighlighted = highlightedGroupIndex === index;
const isExpanded = expandedGroupIndex === index;
const isDisabled = disabledGroupIndices.has(index);
const riskColor = getContactRiskColor(group.indicators);
const handleMouseEnter = useCallback(() => {
useStsStore.getState().setHighlightedGroupIndex(index);
}, [index]);
const handleMouseLeave = useCallback(() => {
useStsStore.getState().setHighlightedGroupIndex(null);
}, []);
// on/off
const handleClick = useCallback(() => {
useStsStore.getState().toggleGroupEnabled(index);
}, [index]);
// /
const handleExpand = useCallback((e) => {
e.stopPropagation();
useStsStore.getState().setExpandedGroupIndex(index);
}, [index]);
//
const handleDetail = useCallback((e) => {
e.stopPropagation();
onDetailClick?.(index);
}, [index, onDetailClick]);
const { vessel1, vessel2, indicators } = group;
const v1Kind = getShipKindName(vessel1.shipKindCode);
const v2Kind = getShipKindName(vessel2.shipKindCode);
const v1Flag = getNationalFlagUrl(vessel1.nationalCode);
const v2Flag = getNationalFlagUrl(vessel2.nationalCode);
const activeIndicators = Object.entries(indicators || {})
.filter(([, val]) => val)
.map(([key]) => ({
key,
detail: getIndicatorDetail(key, group.contacts[0]),
}));
// : ~
const firstContact = group.contacts[0];
const lastContact = group.contacts[group.contacts.length - 1];
return (
<li
className={`sts-card ${isHighlighted ? 'highlighted' : ''} ${isDisabled ? 'disabled' : ''}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
>
<div
className="sts-card__risk-bar"
style={{ backgroundColor: `rgba(${riskColor.join(',')})` }}
/>
<div className="sts-card__body">
{/* vessel1 */}
<div className="sts-card__vessel">
<span className="sts-card__kind">{v1Kind}</span>
{v1Flag && (
<img
className="sts-card__flag"
src={v1Flag}
alt=""
onError={(e) => { e.target.style.display = 'none'; }}
/>
)}
<span className="sts-card__name">{vessel1.vesselName || vessel1.vesselId}</span>
</div>
{/* 접촉 요약 (그룹 합산) */}
<div className="sts-card__contact-summary">
<span className="sts-card__arrow"></span>
<span>{formatDuration(group.totalDurationMinutes)}</span>
<span className="sts-card__sep">|</span>
<span>평균 {formatDistance(group.avgDistanceMeters)}</span>
{group.contacts.length > 1 && (
<span className="sts-card__count">{group.contacts.length}</span>
)}
</div>
{/* vessel2 + 버튼들 */}
<div className="sts-card__vessel">
<span className="sts-card__kind">{v2Kind}</span>
{v2Flag && (
<img
className="sts-card__flag"
src={v2Flag}
alt=""
onError={(e) => { e.target.style.display = 'none'; }}
/>
)}
<span className="sts-card__name">{vessel2.vesselName || vessel2.vesselId}</span>
<button
type="button"
className="sts-card__expand-btn"
onClick={handleExpand}
title="상세 정보"
>
{isExpanded ? '▲' : '▼'}
</button>
<button
type="button"
className="sts-card__detail-btn"
onClick={handleDetail}
title="상세 모달"
>
</button>
</div>
{/* 접촉 시간대 */}
<div className="sts-card__time">
{formatTimestamp(firstContact.contactStartTimestamp)} ~ {formatTimestamp(lastContact.contactEndTimestamp)}
</div>
{/* Indicator 뱃지 */}
{activeIndicators.length > 0 && (
<div className="sts-card__indicators">
{activeIndicators.map(({ key, detail }) => (
<span key={key} className={`sts-card__badge sts-card__badge--${key}`}>
{detail}
</span>
))}
</div>
)}
{/* 확장 상세 */}
{isExpanded && (
<div className="sts-card__detail">
{/* 그룹 내 개별 접촉 목록 (2개 이상) */}
{group.contacts.length > 1 && (
<div className="sts-card__sub-contacts">
<span className="sts-card__sub-title">접촉 이력 ({group.contacts.length})</span>
{group.contacts.map((c, ci) => (
<div key={ci} className="sts-card__sub-contact">
<span className="sts-card__sub-num">#{ci + 1}</span>
<span>{formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}</span>
<span className="sts-card__sep">|</span>
<span>{formatDuration(c.contactDurationMinutes)}</span>
<span className="sts-card__sep">|</span>
<span>평균 {formatDistance(c.avgDistanceMeters)}</span>
</div>
))}
</div>
)}
<div className="sts-card__detail-section">
<div className="sts-card__detail-row">
<span className="sts-card__detail-label">거리</span>
<span>
최소 {formatDistance(group.minDistanceMeters)} / 평균 {formatDistance(group.avgDistanceMeters)} / 최대 {formatDistance(group.maxDistanceMeters)}
</span>
</div>
<div className="sts-card__detail-row">
<span className="sts-card__detail-label">측정</span>
<span>{group.totalContactPointCount} 포인트</span>
</div>
{group.contactCenterPoint && (
<div className="sts-card__detail-row">
<span className="sts-card__detail-label">중심</span>
<span className="sts-card__pos">{formatPosition(group.contactCenterPoint)}</span>
</div>
)}
</div>
<VesselDetail label="선박1" vessel={group.vessel1} />
<VesselDetail label="선박2" vessel={group.vessel2} />
</div>
)}
</div>
</li>
);
}
function VesselDetail({ label, vessel }) {
return (
<div className="sts-card__vessel-detail">
<div className="sts-card__vessel-detail-header">
<span className="sts-card__detail-label">{label}</span>
<span>{vessel.vesselName || vessel.vesselId}</span>
</div>
<div className="sts-card__detail-row">
<span className="sts-card__detail-sublabel">구역체류</span>
<span>{formatDuration(vessel.insidePolygonDurationMinutes)}</span>
<span className="sts-card__sep">|</span>
<span>평균 {vessel.estimatedAvgSpeedKnots?.toFixed(1) ?? '-'}kn</span>
</div>
<div className="sts-card__detail-row">
<span className="sts-card__detail-sublabel">진입</span>
<span>{formatTimestamp(vessel.insidePolygonStartTs)}</span>
</div>
<div className="sts-card__detail-row">
<span className="sts-card__detail-sublabel">퇴출</span>
<span>{formatTimestamp(vessel.insidePolygonEndTs)}</span>
</div>
</div>
);
}
export default function StsContactList({ onDetailClick }) {
const groupedContacts = useStsStore((s) => s.groupedContacts);
const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex);
const listRef = useRef(null);
useEffect(() => {
if (highlightedGroupIndex === null || !listRef.current) return;
const el = listRef.current.querySelector('.sts-card.highlighted');
if (!el) return;
const container = listRef.current;
const elRect = el.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (elRect.top >= containerRect.top && elRect.bottom <= containerRect.bottom) return;
container.scrollTop += (elRect.top - containerRect.top);
}, [highlightedGroupIndex]);
return (
<ul className="sts-contact-list" ref={listRef}>
{groupedContacts.map((group, idx) => (
<GroupCard key={group.pairKey} group={group} index={idx} onDetailClick={onDetailClick} />
))}
</ul>
);
}

파일 보기

@ -0,0 +1,276 @@
.sts-contact-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
flex: 1;
overflow-y: auto;
}
.sts-card {
display: flex;
border-radius: 0.6rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
transition: all 0.15s;
overflow: hidden;
&:hover,
&.highlighted {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
}
&.disabled {
opacity: 0.35;
}
&__risk-bar {
width: 3px;
flex-shrink: 0;
}
&__body {
flex: 1;
min-width: 0;
padding: 0.8rem 1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
&__vessel {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
&__kind {
padding: 0.1rem 0.4rem;
background: rgba(255, 255, 255, 0.12);
border-radius: 0.2rem;
font-size: var(--fs-xxs, 1rem);
color: var(--tertiary4, #adb5bd);
flex-shrink: 0;
}
&__flag {
width: 1.4rem;
height: 1rem;
object-fit: contain;
flex-shrink: 0;
}
&__name {
font-size: var(--fs-s, 1.2rem);
font-weight: var(--fw-semibold, 600);
color: var(--white, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
&__expand-btn {
flex-shrink: 0;
width: 2.4rem;
height: 2.4rem;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--tertiary4, #999);
font-size: 0.8rem;
cursor: pointer;
border-radius: 0.3rem;
transition: all 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--white, #fff);
}
}
&__detail-btn {
flex-shrink: 0;
width: 2.4rem;
height: 2.4rem;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid rgba(255, 255, 255, 0.15);
color: var(--primary1, #4a9eff);
font-size: 0.8rem;
cursor: pointer;
border-radius: 0.3rem;
transition: all 0.15s;
&:hover {
background: rgba(74, 158, 255, 0.15);
border-color: var(--primary1, #4a9eff);
}
}
&__contact-summary {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: var(--fs-xs, 1.1rem);
color: var(--tertiary4, #ccc);
padding-left: 0.2rem;
}
&__arrow {
color: var(--primary1, #4a9eff);
font-weight: bold;
}
&__sep {
color: var(--tertiary3, #555);
}
&__count {
padding: 0.1rem 0.4rem;
background: rgba(74, 158, 255, 0.15);
border-radius: 0.2rem;
font-size: var(--fs-xxs, 1rem);
color: var(--primary1, #4a9eff);
font-weight: var(--fw-semibold, 600);
}
&__time {
font-size: var(--fs-xxs, 1rem);
color: var(--tertiary3, #868e96);
padding-left: 0.2rem;
}
&__indicators {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.2rem;
}
&__badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 0.2rem;
font-size: var(--fs-xxs, 1rem);
font-weight: var(--fw-semibold, 600);
&--lowSpeedContact {
background: rgba(46, 204, 113, 0.15);
color: #2ecc71;
}
&--differentVesselTypes {
background: rgba(243, 156, 18, 0.15);
color: #f39c12;
}
&--differentNationalities {
background: rgba(52, 152, 219, 0.15);
color: #3498db;
}
&--nightTimeContact {
background: rgba(155, 89, 182, 0.15);
color: #9b59b6;
}
}
// 확장 상세
&__detail {
margin-top: 0.6rem;
padding-top: 0.6rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
gap: 0.6rem;
}
&__detail-section {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
&__detail-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: var(--fs-xxs, 1rem);
color: var(--tertiary4, #ced4da);
}
&__detail-label {
font-weight: var(--fw-semibold, 600);
font-size: var(--fs-xxs, 1rem);
color: var(--tertiary3, #868e96);
min-width: 2.8rem;
flex-shrink: 0;
}
&__detail-sublabel {
font-size: var(--fs-xxs, 1rem);
color: var(--tertiary3, #868e96);
min-width: 3.2rem;
flex-shrink: 0;
padding-left: 0.6rem;
}
&__pos {
color: #74b9ff;
}
&__vessel-detail {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
&__vessel-detail-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: var(--fs-xs, 1.1rem);
color: var(--white, #fff);
font-weight: var(--fw-semibold, 600);
}
// 그룹 접촉 이력
&__sub-contacts {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
&__sub-title {
font-size: var(--fs-xxs, 1rem);
font-weight: var(--fw-semibold, 600);
color: var(--tertiary3, #868e96);
}
&__sub-contact {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: var(--fs-xxs, 1rem);
color: var(--tertiary4, #ced4da);
padding: 0.15rem 0;
}
&__sub-num {
font-weight: 700;
color: var(--primary1, #4a9eff);
min-width: 1.6rem;
}
}

파일 보기

@ -0,0 +1,459 @@
/**
* 선박 상세 모달 임베디드 OL 지도 + 시간순 방문 이력 + 이미지 저장
*/
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import Map from 'ol/Map';
import View from 'ol/View';
import { XYZ } from 'ol/source';
import TileLayer from 'ol/layer/Tile';
import VectorSource from 'ol/source/Vector';
import VectorLayer from 'ol/layer/Vector';
import { Feature } from 'ol';
import { Point, LineString, Polygon } from 'ol/geom';
import { fromLonLat } from 'ol/proj';
import { Style, Fill, Stroke, Circle as CircleStyle, Text } from 'ol/style';
import { defaults as defaultControls, ScaleLine } from 'ol/control';
import { defaults as defaultInteractions } from 'ol/interaction';
import html2canvas from 'html2canvas';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { ZONE_COLORS } from '../types/areaSearch.types';
import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
import { mapLayerConfig } from '../../map/layers/baseLayer';
import './VesselDetailModal.scss';
function getNationalFlagUrl(nationalCode) {
if (!nationalCode) return null;
return `/ship/image/small/${nationalCode}.svg`;
}
function createZoneFeatures(zones) {
const features = [];
zones.forEach((zone) => {
const coords3857 = zone.coordinates.map((c) => fromLonLat(c));
const polygon = new Polygon([coords3857]);
const feature = new Feature({ geometry: polygon });
const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0];
feature.setStyle([
new Style({
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }),
}),
new Style({
geometry: () => {
const ext = polygon.getExtent();
const center = [(ext[0] + ext[2]) / 2, (ext[1] + ext[3]) / 2];
return new Point(center);
},
text: new Text({
text: `${zone.name}구역`,
font: 'bold 12px sans-serif',
fill: new Fill({ color: color.label || '#fff' }),
stroke: new Stroke({ color: 'rgba(0,0,0,0.7)', width: 3 }),
}),
}),
]);
features.push(feature);
});
return features;
}
function createTrackFeature(track) {
const coords3857 = track.geometry.map((c) => fromLonLat(c));
const line = new LineString(coords3857);
const feature = new Feature({ geometry: line });
const color = getShipKindColor(track.shipKindCode);
feature.setStyle(new Style({
stroke: new Stroke({
color: `rgba(${color[0]},${color[1]},${color[2]},0.8)`,
width: 2,
}),
}));
return feature;
}
function createMarkerFeatures(sortedHits) {
const features = [];
sortedHits.forEach((hit, idx) => {
const seqNum = idx + 1;
if (hit.entryPosition) {
const pos3857 = fromLonLat(hit.entryPosition);
const f = new Feature({ geometry: new Point(pos3857) });
const timeStr = formatTimestamp(hit.entryTimestamp);
f.set('_markerType', 'in');
f.set('_seqNum', seqNum);
f.setStyle(new Style({
image: new CircleStyle({
radius: 7,
fill: new Fill({ color: '#2ecc71' }),
stroke: new Stroke({ color: '#fff', width: 2 }),
}),
text: new Text({
text: `${seqNum}-IN ${timeStr}`,
font: 'bold 10px sans-serif',
fill: new Fill({ color: '#2ecc71' }),
stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }),
offsetY: -16,
textAlign: 'left',
offsetX: 10,
}),
}));
features.push(f);
}
if (hit.exitPosition) {
const pos3857 = fromLonLat(hit.exitPosition);
const f = new Feature({ geometry: new Point(pos3857) });
const timeStr = formatTimestamp(hit.exitTimestamp);
f.set('_markerType', 'out');
f.set('_seqNum', seqNum);
f.setStyle(new Style({
image: new CircleStyle({
radius: 7,
fill: new Fill({ color: '#e74c3c' }),
stroke: new Stroke({ color: '#fff', width: 2 }),
}),
text: new Text({
text: `${seqNum}-OUT ${timeStr}`,
font: 'bold 10px sans-serif',
fill: new Fill({ color: '#e74c3c' }),
stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }),
offsetY: 16,
textAlign: 'left',
offsetX: 10,
}),
}));
features.push(f);
}
});
return features;
}
/**
* 마커 텍스트 겹침 보정 포인트() 그대로, 텍스트 offsetY만 조정
* 해상도 기반으로 근접 마커를 감지하고 텍스트를 수직 분산 배치
*/
function adjustOverlappingLabels(features, resolution) {
if (!resolution || features.length < 2) return;
const PROXIMITY_PX = 40;
const proximityMap = resolution * PROXIMITY_PX;
const LINE_HEIGHT_PX = 16;
//
const items = features.map((f) => {
const coord = f.getGeometry().getCoordinates();
return { feature: f, x: coord[0], y: coord[1] };
});
// (Union-Find )
const parent = items.map((_, i) => i);
const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; };
const union = (a, b) => { parent[find(a)] = find(b); };
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) {
const dx = items[i].x - items[j].x;
const dy = items[i].y - items[j].y;
if (Math.sqrt(dx * dx + dy * dy) < proximityMap) {
union(i, j);
}
}
}
// offsetY (ol/Map import plain object )
const groups = {};
items.forEach((item, i) => {
const root = find(i);
if (!groups[root]) groups[root] = [];
groups[root].push(item);
});
Object.values(groups).forEach((group) => {
if (group.length < 2) return;
// 퀀 INOUT
group.sort((a, b) => {
const seqA = a.feature.get('_seqNum');
const seqB = b.feature.get('_seqNum');
if (seqA !== seqB) return seqA - seqB;
const typeA = a.feature.get('_markerType') === 'in' ? 0 : 1;
const typeB = b.feature.get('_markerType') === 'in' ? 0 : 1;
return typeA - typeB;
});
const totalHeight = group.length * LINE_HEIGHT_PX;
const startY = -totalHeight / 2 - 8;
group.forEach((item, idx) => {
const style = item.feature.getStyle();
const textStyle = style.getText();
if (textStyle) {
textStyle.setOffsetY(startY + idx * LINE_HEIGHT_PX);
}
});
});
}
const MODAL_WIDTH = 680;
const MODAL_APPROX_HEIGHT = 780;
export default function VesselDetailModal({ vesselId, onClose }) {
const tracks = useAreaSearchStore((s) => s.tracks);
const hitDetails = useAreaSearchStore((s) => s.hitDetails);
const zones = useAreaSearchStore((s) => s.zones);
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
const contentRef = useRef(null);
//
const [position, setPosition] = useState(() => ({
x: (window.innerWidth - MODAL_WIDTH) / 2,
y: Math.max(20, (window.innerHeight - MODAL_APPROX_HEIGHT) / 2),
}));
const posRef = useRef(position);
const dragging = useRef(false);
const dragStart = useRef({ x: 0, y: 0 });
const handleMouseDown = useCallback((e) => {
dragging.current = true;
dragStart.current = {
x: e.clientX - posRef.current.x,
y: e.clientY - posRef.current.y,
};
e.preventDefault();
}, []);
useEffect(() => {
const handleMouseMove = (e) => {
if (!dragging.current) return;
const newPos = {
x: e.clientX - dragStart.current.x,
y: e.clientY - dragStart.current.y,
};
posRef.current = newPos;
setPosition(newPos);
};
const handleMouseUp = () => {
dragging.current = false;
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, []);
const track = useMemo(
() => tracks.find((t) => t.vesselId === vesselId),
[tracks, vesselId],
);
const hits = useMemo(() => hitDetails[vesselId] || [], [hitDetails, vesselId]);
const zoneMap = useMemo(() => {
const lookup = {};
zones.forEach((z, idx) => {
lookup[z.id] = z;
lookup[z.name] = z;
lookup[idx] = z;
lookup[String(idx)] = z;
});
return lookup;
}, [zones]);
const sortedHits = useMemo(
() => [...hits].sort((a, b) => a.entryTimestamp - b.entryTimestamp),
[hits],
);
// OL
useEffect(() => {
if (!mapContainerRef.current || !track) return;
const tileSource = new XYZ({
url: mapLayerConfig.darkLayer.source.getUrls()[0],
minZoom: 6,
maxZoom: 11,
});
const tileLayer = new TileLayer({ source: tileSource, preload: Infinity });
const zoneSource = new VectorSource({ features: createZoneFeatures(zones) });
const zoneLayer = new VectorLayer({ source: zoneSource });
const trackSource = new VectorSource({ features: [createTrackFeature(track)] });
const trackLayer = new VectorLayer({ source: trackSource });
const markerFeatures = createMarkerFeatures(sortedHits);
const markerSource = new VectorSource({ features: markerFeatures });
const markerLayer = new VectorLayer({ source: markerSource });
const map = new Map({
target: mapContainerRef.current,
layers: [tileLayer, zoneLayer, trackLayer, markerLayer],
view: new View({ center: [0, 0], zoom: 7 }),
controls: defaultControls({ attribution: false, zoom: false, rotate: false })
.extend([new ScaleLine({ units: 'nautical' })]),
interactions: defaultInteractions({ doubleClickZoom: false }),
});
// extent
const allSource = new VectorSource();
[...zoneSource.getFeatures(), ...trackSource.getFeatures()].forEach((f) => allSource.addFeature(f.clone()));
const extent = allSource.getExtent();
if (extent && extent[0] !== Infinity) {
map.getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 14 });
}
// view fit
const resolution = map.getView().getResolution();
adjustOverlappingLabels(markerFeatures, resolution);
mapRef.current = map;
return () => {
map.setTarget(null);
map.dispose();
mapRef.current = null;
};
}, [track, zones, sortedHits, zoneMap]);
const handleSaveImage = useCallback(async () => {
const el = contentRef.current;
if (!el) return;
const modal = el.parentElement;
const saved = {
elOverflow: el.style.overflow,
modalMaxHeight: modal.style.maxHeight,
modalOverflow: modal.style.overflow,
};
//
el.style.overflow = 'visible';
modal.style.maxHeight = 'none';
modal.style.overflow = 'visible';
try {
const canvas = await html2canvas(el, {
backgroundColor: '#141820',
useCORS: true,
scale: 2,
});
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const pad = (n) => String(n).padStart(2, '0');
const now = new Date();
const name = track?.shipName || track?.targetId || 'vessel';
link.href = url;
link.download = `항적분석_${name}_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.png`;
link.click();
URL.revokeObjectURL(url);
}, 'image/png');
} catch (err) {
console.error('[VesselDetailModal] 이미지 저장 실패:', err);
} finally {
el.style.overflow = saved.elOverflow;
modal.style.maxHeight = saved.modalMaxHeight;
modal.style.overflow = saved.modalOverflow;
}
}, [track]);
if (!track) return null;
const kindName = getShipKindName(track.shipKindCode);
const sourceName = getSignalSourceName(track.sigSrcCd);
const flagUrl = getNationalFlagUrl(track.nationalCode);
return createPortal(
<div className="vessel-detail-overlay" onClick={onClose}>
<div
className="vessel-detail-modal"
style={{ left: position.x, top: position.y }}
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 (드래그 핸들) */}
<div className="vessel-detail-modal__header" onMouseDown={handleMouseDown}>
<div className="vessel-detail-modal__title">
<span className="vessel-detail-modal__kind">{kindName}</span>
{flagUrl && (
<span className="vessel-detail-modal__flag">
<img src={flagUrl} alt="국기" onError={(e) => { e.target.style.display = 'none'; }} />
</span>
)}
<span className="vessel-detail-modal__name">
{track.shipName || track.targetId || '-'}
</span>
<span className="vessel-detail-modal__source">{sourceName}</span>
</div>
<button type="button" className="vessel-detail-modal__close" onClick={onClose}>
&times;
</button>
</div>
{/* 콘텐츠 (이미지 캡처 영역) */}
<div className="vessel-detail-modal__content" ref={contentRef}>
{/* OL 지도 */}
<div className="vessel-detail-modal__map" ref={mapContainerRef} />
{/* 방문 이력 */}
<div className="vessel-detail-modal__visits">
<h4 className="vessel-detail-modal__visits-title">방문 이력 (시간순)</h4>
<div className="vessel-detail-modal__visits-list">
{sortedHits.map((hit, idx) => {
const zone = zoneMap[hit.polygonId];
const zoneColor = zone
? (ZONE_COLORS[zone.colorIndex]?.label || '#ffd43b')
: '#adb5bd';
const zoneName = zone
? `${zone.name}구역`
: (hit.polygonName ? `${hit.polygonName}구역` : '구역');
const visitLabel = hit.visitIndex > 1 || hits.filter((h) => h.polygonId === hit.polygonId).length > 1
? `${hit.visitIndex}`
: '';
const entryPos = formatPosition(hit.entryPosition);
const exitPos = formatPosition(hit.exitPosition);
return (
<div key={`${hit.polygonId}-${hit.visitIndex}-${idx}`} className="vessel-detail-modal__visit">
<span className="vessel-detail-modal__visit-seq">{idx + 1}.</span>
<div className="vessel-detail-modal__visit-body">
<div className="vessel-detail-modal__visit-zone">
<span className="vessel-detail-modal__visit-dot" style={{ backgroundColor: zoneColor }} />
<span style={{ color: zoneColor, fontWeight: 700 }}>{zoneName}</span>
{visitLabel && <span className="vessel-detail-modal__visit-idx">{visitLabel}</span>}
</div>
<div className="vessel-detail-modal__visit-row">
<span className="vessel-detail-modal__visit-label in">{idx + 1}-IN</span>
<span>{formatTimestamp(hit.entryTimestamp)}</span>
{entryPos && <span className="vessel-detail-modal__visit-pos">{entryPos}</span>}
</div>
<div className="vessel-detail-modal__visit-row">
<span className="vessel-detail-modal__visit-label out">{idx + 1}-OUT</span>
<span>{formatTimestamp(hit.exitTimestamp)}</span>
{exitPos && <span className="vessel-detail-modal__visit-pos">{exitPos}</span>}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* 하단 버튼 */}
<div className="vessel-detail-modal__footer">
<button type="button" className="vessel-detail-modal__save-btn" onClick={handleSaveImage}>
이미지 저장
</button>
</div>
</div>
</div>,
document.body,
);
}

파일 보기

@ -0,0 +1,224 @@
.vessel-detail-overlay {
position: fixed;
inset: 0;
z-index: 300;
background: rgba(0, 0, 0, 0.6);
}
.vessel-detail-modal {
position: fixed;
z-index: 301;
width: 680px;
max-height: 90vh;
display: flex;
flex-direction: column;
background: rgba(20, 24, 32, 0.98);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
color: #fff;
overflow: hidden;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
cursor: move;
user-select: none;
}
&__title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
&__kind {
padding: 2px 6px;
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
font-size: 10px;
color: #adb5bd;
flex-shrink: 0;
}
&__flag {
display: inline-flex;
align-items: center;
flex-shrink: 0;
img {
width: 18px;
height: 13px;
object-fit: contain;
}
}
&__name {
font-weight: 700;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__source {
font-size: 11px;
color: #868e96;
flex-shrink: 0;
}
&__close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: #868e96;
font-size: 20px;
cursor: pointer;
border-radius: 4px;
flex-shrink: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
&__content {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
&__map {
width: 100%;
height: 480px;
flex-shrink: 0;
background: #0d1117;
.ol-scale-line {
bottom: 8px;
left: 8px;
}
}
&__visits {
padding: 12px 16px;
flex-shrink: 0;
}
&__visits-title {
font-size: 12px;
font-weight: 700;
color: #ced4da;
margin: 0 0 8px 0;
}
&__visits-list {
display: flex;
flex-direction: column;
gap: 6px;
}
&__visit {
display: flex;
gap: 6px;
align-items: flex-start;
}
&__visit-seq {
font-size: 11px;
color: #868e96;
min-width: 18px;
padding-top: 1px;
}
&__visit-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
}
&__visit-zone {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
margin-bottom: 2px;
}
&__visit-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
&__visit-idx {
font-size: 10px;
color: #868e96;
}
&__visit-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #ced4da;
padding-left: 12px;
}
&__visit-label {
font-weight: 600;
font-size: 9px;
min-width: 34px;
&.in {
color: #2ecc71;
}
&.out {
color: #e74c3c;
}
}
&__visit-pos {
color: #74b9ff;
font-size: 10px;
}
&__footer {
display: flex;
justify-content: flex-end;
padding: 10px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
}
&__save-btn {
padding: 6px 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: transparent;
color: #ced4da;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: #4a9eff;
color: #fff;
}
}
}

파일 보기

@ -0,0 +1,166 @@
import { useCallback, useRef, useState } from 'react';
import './ZoneDrawPanel.scss';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import {
MAX_ZONES,
ZONE_DRAW_TYPES,
ZONE_COLORS,
} from '../types/areaSearch.types';
export default function ZoneDrawPanel({ disabled, maxZones }) {
const effectiveMaxZones = maxZones ?? MAX_ZONES;
const zones = useAreaSearchStore((s) => s.zones);
const activeDrawType = useAreaSearchStore((s) => s.activeDrawType);
const selectedZoneId = useAreaSearchStore((s) => s.selectedZoneId);
const setActiveDrawType = useAreaSearchStore((s) => s.setActiveDrawType);
const removeZone = useAreaSearchStore((s) => s.removeZone);
const reorderZones = useAreaSearchStore((s) => s.reorderZones);
const selectZone = useAreaSearchStore((s) => s.selectZone);
const deselectZone = useAreaSearchStore((s) => s.deselectZone);
const confirmAndClearResults = useAreaSearchStore((s) => s.confirmAndClearResults);
const canAddZone = zones.length < effectiveMaxZones;
const handleDrawClick = useCallback((type) => {
if (!canAddZone || disabled) return;
if (!confirmAndClearResults()) return;
setActiveDrawType(activeDrawType === type ? null : type);
}, [canAddZone, disabled, activeDrawType, setActiveDrawType, confirmAndClearResults]);
const handleZoneClick = useCallback((zoneId) => {
if (disabled) return;
if (selectedZoneId === zoneId) {
deselectZone();
} else {
if (!confirmAndClearResults()) return;
selectZone(zoneId);
}
}, [disabled, selectedZoneId, deselectZone, selectZone, confirmAndClearResults]);
const handleRemoveZone = useCallback((e, zoneId) => {
e.stopPropagation();
if (!confirmAndClearResults()) return;
removeZone(zoneId);
}, [removeZone, confirmAndClearResults]);
// (ref - dataTransfer )
const dragIndexRef = useRef(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
const handleDragStart = useCallback((e, index) => {
dragIndexRef.current = index;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
requestAnimationFrame(() => {
e.target.classList.add('dragging');
});
}, []);
const handleDragOver = useCallback((e, index) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragIndexRef.current !== null && dragIndexRef.current !== index) {
setDragOverIndex(index);
}
}, []);
const handleDrop = useCallback((e, toIndex) => {
e.preventDefault();
const fromIndex = dragIndexRef.current;
if (fromIndex !== null && fromIndex !== toIndex) {
if (!confirmAndClearResults()) {
dragIndexRef.current = null;
setDragOverIndex(null);
return;
}
reorderZones(fromIndex, toIndex);
}
dragIndexRef.current = null;
setDragOverIndex(null);
}, [reorderZones, confirmAndClearResults]);
const handleDragEnd = useCallback(() => {
dragIndexRef.current = null;
setDragOverIndex(null);
}, []);
return (
<div className="zone-draw-panel">
<h3 className="section-title">구역 설정</h3>
<p className="section-desc">{zones.length}/{MAX_ZONES} 설정됨</p>
{/* 그리기 버튼 */}
<div className="draw-buttons">
<button
type="button"
className={`draw-btn ${activeDrawType === ZONE_DRAW_TYPES.POLYGON ? 'active' : ''}`}
onClick={() => handleDrawClick(ZONE_DRAW_TYPES.POLYGON)}
disabled={!canAddZone || disabled}
title="폴리곤"
>
폴리곤
</button>
<button
type="button"
className={`draw-btn ${activeDrawType === ZONE_DRAW_TYPES.BOX ? 'active' : ''}`}
onClick={() => handleDrawClick(ZONE_DRAW_TYPES.BOX)}
disabled={!canAddZone || disabled}
title="사각형"
>
사각형
</button>
<button
type="button"
className={`draw-btn ${activeDrawType === ZONE_DRAW_TYPES.CIRCLE ? 'active' : ''}`}
onClick={() => handleDrawClick(ZONE_DRAW_TYPES.CIRCLE)}
disabled={!canAddZone || disabled}
title="원"
>
</button>
</div>
{activeDrawType && (
<p className="draw-hint">지도에서 구역을 그려주세요. (ESC: 취소)</p>
)}
{/* 구역 목록 */}
{zones.length > 0 && (
<ul className="zone-list">
{zones.map((zone, index) => {
const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0];
return (
<li
key={zone.id}
className={`zone-item${dragIndexRef.current === index ? ' dragging' : ''}${dragOverIndex === index ? ' drag-over' : ''}${selectedZoneId === zone.id ? ' selected' : ''}`}
draggable
onClick={() => handleZoneClick(zone.id)}
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
>
<span className="drag-handle" title="드래그하여 순서 변경">&#8801;</span>
<span className="zone-color" style={{ backgroundColor: color.label }} />
<span className="zone-name">구역 {zone.name}</span>
<span className="zone-type">{zone.type}</span>
{selectedZoneId === zone.id && (
<span className="edit-hint">편집 </span>
)}
<button
type="button"
className="zone-delete"
onClick={(e) => handleRemoveZone(e, zone.id)}
disabled={disabled}
title="구역 삭제"
>
&times;
</button>
</li>
);
})}
</ul>
)}
</div>
);
}

파일 보기

@ -0,0 +1,148 @@
.zone-draw-panel {
background-color: var(--secondary1, rgba(255, 255, 255, 0.05));
border-radius: 0.6rem;
padding: 1.5rem;
margin-bottom: 1.2rem;
.section-title {
font-size: var(--fs-m, 1.3rem);
font-weight: var(--fw-bold, 700);
color: var(--white, #fff);
margin-bottom: 0.4rem;
}
.section-desc {
font-size: var(--fs-xs, 1.1rem);
color: var(--tertiary4, #999);
margin-bottom: 1rem;
}
.draw-buttons {
display: flex;
gap: 0.6rem;
margin-bottom: 1rem;
.draw-btn {
flex: 1;
padding: 0.7rem 0;
border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
border-radius: 0.4rem;
background: transparent;
color: var(--tertiary4, #ccc);
font-size: var(--fs-s, 1.2rem);
cursor: pointer;
transition: all 0.15s;
&:hover:not(:disabled) {
border-color: var(--primary1, #4a9eff);
color: var(--white, #fff);
}
&.active {
background-color: var(--primary1, #4a9eff);
border-color: var(--primary1, #4a9eff);
color: var(--white, #fff);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
}
.draw-hint {
font-size: var(--fs-xs, 1.1rem);
color: var(--primary1, #4a9eff);
margin-bottom: 1rem;
}
.zone-list {
list-style: none;
padding: 0;
margin: 0;
.zone-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.8rem 0.4rem;
border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
cursor: grab;
transition: background-color 0.15s;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
&.dragging {
opacity: 0.4;
}
&.drag-over {
border-top: 2px solid var(--primary1, #4a9eff);
padding-top: calc(0.8rem - 2px);
}
&.selected {
background-color: rgba(74, 158, 255, 0.1);
border-left: 3px solid var(--primary1, #4a9eff);
padding-left: calc(0.4rem - 3px);
}
&:last-child {
border-bottom: none;
}
.drag-handle {
color: var(--tertiary4, #999);
font-size: 1.4rem;
cursor: grab;
user-select: none;
}
.zone-color {
width: 1rem;
height: 1rem;
border-radius: 50%;
flex-shrink: 0;
}
.zone-name {
font-size: var(--fs-s, 1.2rem);
color: var(--white, #fff);
flex: 1;
}
.zone-type {
font-size: var(--fs-xs, 1.1rem);
color: var(--tertiary4, #999);
}
.edit-hint {
font-size: var(--fs-xs, 1.1rem);
color: var(--primary1, #4a9eff);
font-weight: var(--fw-bold, 700);
}
.zone-delete {
background: none;
border: none;
color: var(--tertiary4, #999);
font-size: 1.6rem;
cursor: pointer;
padding: 0 0.4rem;
line-height: 1;
&:hover:not(:disabled) {
color: #f87171;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
}
}
}

파일 보기

@ -0,0 +1,217 @@
/**
* 항적분석 Deck.gl 레이어 관리
*
* 성능 최적화:
* - currentTime은 zustand.subscribe로 React 렌더 바이패스
* - 정적 레이어(PathLayer) 캐싱 필터 변경 시에만 재생성
* - 동적 레이어(IconLayer, TextLayer, TripsLayer) 프레임 갱신
*/
import { useEffect, useRef, useCallback } from 'react';
import { TripsLayer } from '@deck.gl/geo-layers';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
import { AREA_SEARCH_LAYER_IDS } from '../types/areaSearch.types';
import {
registerAreaSearchLayers,
unregisterAreaSearchLayers,
} from '../utils/areaSearchLayerRegistry';
import { createStaticTrackLayers, createVirtualShipLayers } from '../../map/layers/trackLayer';
import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
const TRAIL_LENGTH_MS = 3600000;
const RENDER_INTERVAL_MS = 100; // 재생 중 렌더링 쓰로틀 (~10fps)
export default function useAreaSearchLayer() {
const tripsDataRef = useRef([]);
const startTimeRef = useRef(0);
// 정적 레이어 캐시 (필터/하이라이트 변경 시에만 갱신)
const staticLayerCacheRef = useRef({ layers: [], deps: null });
// React 구독: 필터/상태 (비빈번 변경만)
const queryCompleted = useAreaSearchStore((s) => s.queryCompleted);
const tracks = useAreaSearchStore((s) => s.tracks);
const disabledVesselIds = useAreaSearchStore((s) => s.disabledVesselIds);
const highlightedVesselId = useAreaSearchStore((s) => s.highlightedVesselId);
const showPaths = useAreaSearchStore((s) => s.showPaths);
const showTrail = useAreaSearchStore((s) => s.showTrail);
const shipKindCodeFilter = useAreaSearchStore((s) => s.shipKindCodeFilter);
// currentTime — React 구독 제거, zustand.subscribe로 대체
/**
* 프레임 렌더링 (zustand.subscribe에서 직접 호출, React 리렌더 없음)
* currentTime은 getState() 읽어 useCallback deps 안정화
*/
const renderFrame = useCallback(() => {
if (!queryCompleted || tracks.length === 0) return;
const ct = useAreaSearchAnimationStore.getState().currentTime;
const allPositions = useAreaSearchStore.getState().getCurrentPositions(ct);
const filteredPositions = allPositions.filter(
(p) => shipKindCodeFilter.has(p.shipKindCode),
);
const layers = [];
// 1. TripsLayer 궤적 (동적 — currentTime 의존)
if (showTrail && tripsDataRef.current.length > 0) {
const iconVesselIds = new Set(filteredPositions.map((p) => p.vesselId));
const filteredTripsData = tripsDataRef.current.filter(
(d) => iconVesselIds.has(d.vesselId),
);
if (filteredTripsData.length > 0) {
layers.push(
new TripsLayer({
id: AREA_SEARCH_LAYER_IDS.TRIPS_TRAIL,
data: filteredTripsData,
getPath: (d) => d.path,
getTimestamps: (d) => d.timestamps,
getColor: [120, 120, 120, 180],
widthMinPixels: 2,
widthMaxPixels: 3,
jointRounded: true,
capRounded: true,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - startTimeRef.current,
}),
);
}
}
// 2. 정적 PathLayer (캐싱 — 필터/하이라이트 변경 시에만 재생성)
if (showPaths) {
const deps = staticLayerCacheRef.current.deps;
const needsRebuild = !deps
|| deps.tracks !== tracks
|| deps.disabledVesselIds !== disabledVesselIds
|| deps.shipKindCodeFilter !== shipKindCodeFilter
|| deps.highlightedVesselId !== highlightedVesselId;
if (needsRebuild) {
const filteredTracks = tracks.filter((t) =>
!disabledVesselIds.has(t.vesselId) && shipKindCodeFilter.has(t.shipKindCode),
);
staticLayerCacheRef.current = {
layers: createStaticTrackLayers({
tracks: filteredTracks,
showPoints: false,
highlightedVesselId,
onPathHover: (vesselId) => {
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
},
layerIds: { path: AREA_SEARCH_LAYER_IDS.PATH },
}),
deps: { tracks, disabledVesselIds, shipKindCodeFilter, highlightedVesselId },
};
}
layers.push(...staticLayerCacheRef.current.layers);
}
// 3. 동적 가상 선박 레이어 (IconLayer + TextLayer)
const dynamicLayers = createVirtualShipLayers({
currentPositions: filteredPositions,
showVirtualShip: filteredPositions.length > 0,
showLabels: filteredPositions.length > 0,
onIconHover: (shipData, x, y) => {
if (shipData) {
useAreaSearchStore.getState().setHighlightedVesselId(shipData.vesselId);
} else {
useAreaSearchStore.getState().setHighlightedVesselId(null);
}
},
onPathHover: (vesselId) => {
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
},
layerIds: {
icon: AREA_SEARCH_LAYER_IDS.VIRTUAL_SHIP,
label: AREA_SEARCH_LAYER_IDS.VIRTUAL_SHIP_LABEL,
},
});
layers.push(...dynamicLayers);
registerAreaSearchLayers(layers);
shipBatchRenderer.immediateRender();
}, [queryCompleted, tracks, showPaths, showTrail, highlightedVesselId, disabledVesselIds, shipKindCodeFilter]);
/**
* 쿼리 완료 TripsLayer 데이터 빌드 (1)
*/
useEffect(() => {
if (!queryCompleted) {
unregisterAreaSearchLayers();
tripsDataRef.current = [];
staticLayerCacheRef.current = { layers: [], deps: null };
shipBatchRenderer.immediateRender();
return;
}
if (tracks.length === 0) {
tripsDataRef.current = [];
return;
}
const sTime = useAreaSearchAnimationStore.getState().startTime;
startTimeRef.current = sTime;
tripsDataRef.current = tracks
.filter((t) => t.geometry.length >= 2)
.map((track) => ({
vesselId: track.vesselId,
shipKindCode: track.shipKindCode,
path: track.geometry,
timestamps: track.timestampsMs.map((t) => t - sTime),
}));
}, [queryCompleted, tracks]);
/**
* currentTime 구독 (zustand.subscribe React 리렌더 바이패스)
* 재생 : ~10fps 쓰로틀 (RENDER_INTERVAL_MS)
* seek/정지: 즉시 렌더 (슬라이더 조작 반응성 유지)
*/
useEffect(() => {
if (!queryCompleted) return;
renderFrame();
let lastRenderTime = 0;
let pendingRafId = null;
const unsub = useAreaSearchAnimationStore.subscribe(
(s) => s.currentTime,
() => {
const isPlaying = useAreaSearchAnimationStore.getState().isPlaying;
if (!isPlaying) {
renderFrame();
return;
}
const now = performance.now();
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
lastRenderTime = now;
renderFrame();
} else if (!pendingRafId) {
pendingRafId = requestAnimationFrame(() => {
pendingRafId = null;
lastRenderTime = performance.now();
renderFrame();
});
}
},
);
return () => {
unsub();
if (pendingRafId) cancelAnimationFrame(pendingRafId);
};
}, [queryCompleted, renderFrame]);
/**
* 언마운트 클린업
*/
useEffect(() => {
return () => {
unregisterAreaSearchLayers();
tripsDataRef.current = [];
};
}, []);
}

파일 보기

@ -0,0 +1,288 @@
/**
* STS 분석 Deck.gl 레이어 관리
*
* 정적 레이어: PathLayer (항적), ScatterplotLayer (접촉 포인트)
* 동적 레이어: TripsLayer (궤적), IconLayer (가상 선박), TextLayer (라벨)
*
* 성능 최적화:
* - currentTime은 zustand.subscribe로 React 렌더 바이패스
* - 정적 레이어 캐싱
*/
import { useEffect, useRef, useCallback } from 'react';
import { TripsLayer } from '@deck.gl/geo-layers';
import { ScatterplotLayer } from '@deck.gl/layers';
import { useStsStore } from '../stores/stsStore';
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
import { STS_LAYER_IDS } from '../types/sts.types';
import { getContactRiskColor } from '../types/sts.types';
import {
registerStsLayers,
unregisterStsLayers,
} from '../utils/stsLayerRegistry';
import { createStaticTrackLayers, createVirtualShipLayers } from '../../map/layers/trackLayer';
import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
const TRAIL_LENGTH_MS = 3600000;
const RENDER_INTERVAL_MS = 100;
export default function useStsLayer() {
const tripsDataRef = useRef([]);
const startTimeRef = useRef(0);
const staticLayerCacheRef = useRef({ layers: [], deps: null });
const contactLayerCacheRef = useRef({ layers: [], deps: null });
// React 구독: 그룹 기반
const queryCompleted = useStsStore((s) => s.queryCompleted);
const tracks = useStsStore((s) => s.tracks);
const groupedContacts = useStsStore((s) => s.groupedContacts);
const disabledGroupIndices = useStsStore((s) => s.disabledGroupIndices);
const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex);
const showPaths = useStsStore((s) => s.showPaths);
const showTrail = useStsStore((s) => s.showTrail);
/**
* 접촉 포인트 레이어 (그룹 기반, disabled/highlight 변경 재빌드)
*/
const buildContactLayers = useCallback(() => {
const deps = contactLayerCacheRef.current.deps;
const needsRebuild = !deps
|| deps.groupedContacts !== groupedContacts
|| deps.disabledGroupIndices !== disabledGroupIndices
|| deps.highlightedGroupIndex !== highlightedGroupIndex;
if (!needsRebuild) return contactLayerCacheRef.current.layers;
const layers = [];
// disabled가 아닌 그룹의 모든 하위 contacts를 flat
const enabledContacts = [];
groupedContacts.forEach((group, gIdx) => {
if (disabledGroupIndices.has(gIdx)) return;
group.contacts.forEach((c) => {
enabledContacts.push({
...c,
_groupIdx: gIdx,
});
});
});
if (enabledContacts.length === 0) {
contactLayerCacheRef.current = {
layers: [],
deps: { groupedContacts, disabledGroupIndices, highlightedGroupIndex },
};
return layers;
}
// ScatterplotLayer — 접촉 발생 지점
layers.push(
new ScatterplotLayer({
id: STS_LAYER_IDS.CONTACT_POINT,
data: enabledContacts.filter((c) => c.contactCenterPoint),
getPosition: (d) => d.contactCenterPoint,
getRadius: (d) => d._groupIdx === highlightedGroupIndex ? 800 : 500,
getFillColor: (d) => getContactRiskColor(d.indicators),
radiusMinPixels: 4,
radiusMaxPixels: 12,
pickable: true,
updateTriggers: {
getRadius: highlightedGroupIndex,
},
}),
);
contactLayerCacheRef.current = {
layers,
deps: { groupedContacts, disabledGroupIndices, highlightedGroupIndex },
};
return layers;
}, [groupedContacts, disabledGroupIndices, highlightedGroupIndex]);
/**
* 프레임 렌더링
*/
const renderFrame = useCallback(() => {
if (!queryCompleted || tracks.length === 0) return;
const ct = useAreaSearchAnimationStore.getState().currentTime;
const allPositions = useStsStore.getState().getCurrentPositions(ct);
const layers = [];
// 1. TripsLayer 궤적
if (showTrail && tripsDataRef.current.length > 0) {
const iconVesselIds = new Set(allPositions.map((p) => p.vesselId));
const filteredTripsData = tripsDataRef.current.filter(
(d) => iconVesselIds.has(d.vesselId),
);
if (filteredTripsData.length > 0) {
layers.push(
new TripsLayer({
id: STS_LAYER_IDS.TRIPS_TRAIL,
data: filteredTripsData,
getPath: (d) => d.path,
getTimestamps: (d) => d.timestamps,
getColor: [120, 120, 120, 180],
widthMinPixels: 2,
widthMaxPixels: 3,
jointRounded: true,
capRounded: true,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - startTimeRef.current,
}),
);
}
}
// 2. 정적 PathLayer (캐싱)
if (showPaths) {
const disabledVesselIds = useStsStore.getState().getDisabledVesselIds();
// 접촉 쌍의 양쪽 선박 항적 하이라이트
let stsHighlightedVesselIds = null;
if (highlightedGroupIndex !== null && groupedContacts[highlightedGroupIndex]) {
const g = groupedContacts[highlightedGroupIndex];
stsHighlightedVesselIds = new Set([g.vessel1.vesselId, g.vessel2.vesselId]);
}
const deps = staticLayerCacheRef.current.deps;
const needsRebuild = !deps
|| deps.tracks !== tracks
|| deps.disabledGroupIndices !== disabledGroupIndices
|| deps.highlightedGroupIndex !== highlightedGroupIndex;
if (needsRebuild) {
const filteredTracks = tracks.filter((t) => !disabledVesselIds.has(t.vesselId));
staticLayerCacheRef.current = {
layers: createStaticTrackLayers({
tracks: filteredTracks,
showPoints: false,
highlightedVesselIds: stsHighlightedVesselIds,
layerIds: { path: STS_LAYER_IDS.TRACK_PATH },
onPathHover: (vesselId) => {
if (!vesselId) {
useStsStore.getState().setHighlightedGroupIndex(null);
return;
}
const groups = useStsStore.getState().groupedContacts;
const idx = groups.findIndex(
(g) => g.vessel1.vesselId === vesselId || g.vessel2.vesselId === vesselId,
);
useStsStore.getState().setHighlightedGroupIndex(idx >= 0 ? idx : null);
},
}),
deps: { tracks, disabledGroupIndices, highlightedGroupIndex },
};
}
layers.push(...staticLayerCacheRef.current.layers);
}
// 3. 접촉 포인트 레이어 (캐싱)
layers.push(...buildContactLayers());
// 4. 동적 가상 선박 레이어
const dynamicLayers = createVirtualShipLayers({
currentPositions: allPositions,
showVirtualShip: allPositions.length > 0,
showLabels: allPositions.length > 0,
layerIds: {
icon: STS_LAYER_IDS.VIRTUAL_SHIP,
label: STS_LAYER_IDS.VIRTUAL_SHIP_LABEL,
},
});
layers.push(...dynamicLayers);
registerStsLayers(layers);
shipBatchRenderer.immediateRender();
}, [queryCompleted, tracks, groupedContacts, showPaths, showTrail, highlightedGroupIndex, disabledGroupIndices, buildContactLayers]);
/**
* 쿼리 완료 TripsLayer 데이터 빌드
*/
useEffect(() => {
if (!queryCompleted) {
unregisterStsLayers();
tripsDataRef.current = [];
staticLayerCacheRef.current = { layers: [], deps: null };
contactLayerCacheRef.current = { layers: [], deps: null };
shipBatchRenderer.immediateRender();
return;
}
if (tracks.length === 0) {
tripsDataRef.current = [];
return;
}
const sTime = useAreaSearchAnimationStore.getState().startTime;
startTimeRef.current = sTime;
tripsDataRef.current = tracks
.filter((t) => t.geometry.length >= 2)
.map((track) => ({
vesselId: track.vesselId,
shipKindCode: track.shipKindCode,
path: track.geometry,
timestamps: track.timestampsMs.map((t) => t - sTime),
}));
}, [queryCompleted, tracks]);
/**
* currentTime 구독 (zustand.subscribe)
*/
useEffect(() => {
if (!queryCompleted) return;
renderFrame();
let lastRenderTime = 0;
let pendingRafId = null;
const unsub = useAreaSearchAnimationStore.subscribe(
(s) => s.currentTime,
() => {
const isPlaying = useAreaSearchAnimationStore.getState().isPlaying;
if (!isPlaying) {
renderFrame();
return;
}
const now = performance.now();
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
lastRenderTime = now;
renderFrame();
} else if (!pendingRafId) {
pendingRafId = requestAnimationFrame(() => {
pendingRafId = null;
lastRenderTime = performance.now();
renderFrame();
});
}
},
);
return () => {
unsub();
if (pendingRafId) cancelAnimationFrame(pendingRafId);
};
}, [queryCompleted, renderFrame]);
/**
* 하이라이트/비활성 상태 변경 즉시 리렌더
* currentTime subscribe와 별도로, UI 인터랙션 반응 보장
*/
useEffect(() => {
if (!queryCompleted) return;
renderFrame();
}, [queryCompleted, highlightedGroupIndex, disabledGroupIndices, showPaths, buildContactLayers]); // eslint-disable-line react-hooks/exhaustive-deps
/**
* 언마운트 클린업
*/
useEffect(() => {
return () => {
unregisterStsLayers();
tripsDataRef.current = [];
};
}, []);
}

파일 보기

@ -0,0 +1,261 @@
/**
* 구역 그리기 OpenLayers Draw 인터랙션
*
* - activeDrawType 변경 Draw 인터랙션 활성화
* - Polygon / Box / Circle 그리기
* - drawend EPSG:38574326 변환 addZone()
* - ESC 키로 그리기 취소
* - 구역별 색상 스타일 (ZONE_COLORS)
*/
import { useEffect, useRef, useCallback } from 'react';
import VectorSource from 'ol/source/Vector';
import VectorLayer from 'ol/layer/Vector';
import { Draw } from 'ol/interaction';
import { createBox } from 'ol/interaction/Draw';
import { Style, Fill, Stroke } from 'ol/style';
import { transform } from 'ol/proj';
import { fromCircle } from 'ol/geom/Polygon';
import { useMapStore } from '../../stores/mapStore';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types';
import { setZoneSource, getZoneSource, setZoneLayer, getZoneLayer } from '../utils/zoneLayerRefs';
/**
* 3857 좌표를 4326 좌표로 변환하고 폐곡선 보장
*/
function toWgs84Polygon(coords3857) {
const coords4326 = coords3857.map((c) => transform(c, 'EPSG:3857', 'EPSG:4326'));
// 폐곡선 보장 (첫점 == 끝점)
if (coords4326.length > 0) {
const first = coords4326[0];
const last = coords4326[coords4326.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) {
coords4326.push([...first]);
}
}
return coords4326;
}
/**
* 구역 인덱스에 맞는 OL 스타일 생성
*/
function createZoneStyle(index) {
const color = ZONE_COLORS[index] || ZONE_COLORS[0];
return new Style({
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }),
});
}
export default function useZoneDraw() {
const map = useMapStore((s) => s.map);
const drawRef = useRef(null);
const mapRef = useRef(null);
// map ref 동기화 (클린업에서 사용)
useEffect(() => {
mapRef.current = map;
}, [map]);
// 맵 준비 시 레이어 설정
useEffect(() => {
if (!map) return;
const source = new VectorSource({ wrapX: false });
const layer = new VectorLayer({
source,
zIndex: 55,
});
map.addLayer(layer);
setZoneSource(source);
setZoneLayer(layer);
// 기존 zones가 있으면 동기화
const { zones } = useAreaSearchStore.getState();
zones.forEach((zone) => {
if (!zone.olFeature) return;
zone.olFeature.setStyle(createZoneStyle(zone.colorIndex));
source.addFeature(zone.olFeature);
});
return () => {
if (drawRef.current) {
map.removeInteraction(drawRef.current);
drawRef.current = null;
}
map.removeLayer(layer);
setZoneSource(null);
setZoneLayer(null);
};
}, [map]);
// 스토어의 zones 변경 → OL feature 동기화
useEffect(() => {
const unsub = useAreaSearchStore.subscribe(
(s) => s.zones,
(zones) => {
const source = getZoneSource();
if (!source) return;
source.clear();
zones.forEach((zone) => {
if (!zone.olFeature) return;
zone.olFeature.setStyle(createZoneStyle(zone.colorIndex));
source.addFeature(zone.olFeature);
});
},
);
return unsub;
}, []);
// showZones 변경 → 레이어 표시/숨김
useEffect(() => {
const unsub = useAreaSearchStore.subscribe(
(s) => s.showZones,
(show) => {
const layer = getZoneLayer();
if (layer) layer.setVisible(show);
},
);
return unsub;
}, []);
// Draw 인터랙션 생성 함수
const setupDraw = useCallback((currentMap, drawType) => {
// 기존 인터랙션 제거
if (drawRef.current) {
currentMap.removeInteraction(drawRef.current);
drawRef.current = null;
}
if (!drawType) return;
const source = getZoneSource();
if (!source) return;
// source를 Draw에 전달하지 않음
// OL Draw는 drawend 이벤트 후에 source.addFeature()를 자동 호출하는데,
// 우리 drawend 핸들러의 addZone() → subscription → source.addFeature() 와 충돌하여
// "feature already added" 에러 발생. source를 제거하면 자동 추가가 비활성화됨.
let draw;
if (drawType === ZONE_DRAW_TYPES.BOX) {
draw = new Draw({ type: 'Circle', geometryFunction: createBox() });
} else if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
draw = new Draw({ type: 'Circle' });
} else {
draw = new Draw({ type: 'Polygon' });
}
draw.on('drawend', (evt) => {
const feature = evt.feature;
let geom = feature.getGeometry();
const typeName = drawType;
// Circle → Polygon 변환 (center/radius 보존)
let circleMeta = null;
if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
circleMeta = { center: geom.getCenter(), radius: geom.getRadius() };
const polyGeom = fromCircle(geom, 64);
feature.setGeometry(polyGeom);
geom = polyGeom;
}
// EPSG:3857 → 4326 좌표 추출
const coords3857 = geom.getCoordinates()[0];
const coordinates = toWgs84Polygon(coords3857);
// 최소 4점 확인
if (coordinates.length < 4) {
return;
}
const { zones } = useAreaSearchStore.getState();
const index = zones.length;
const style = createZoneStyle(index);
feature.setStyle(style);
// source에 직접 추가 (즉시 표시, Draw의 자동 추가를 대체)
source.addFeature(feature);
// 상태 업데이트를 다음 틱으로 지연
// drawend 이벤트 처리 중에 Draw를 동기적으로 제거하면,
// OL 내부 이벤트 체인이 완료되기 전에 DragPan이 이벤트를 가로채서
// 지도가 마우스를 따라 움직이는 문제가 발생함.
// setTimeout으로 OL 이벤트 처리가 완료된 후 안전하게 제거.
setTimeout(() => {
useAreaSearchStore.getState().addZone({
type: typeName,
source: 'draw',
coordinates,
olFeature: feature,
circleMeta,
});
// addZone → activeDrawType: null → subscription → removeInteraction
}, 0);
});
currentMap.addInteraction(draw);
drawRef.current = draw;
}, []);
// activeDrawType 변경 → Draw 인터랙션 설정
useEffect(() => {
if (!map) return;
const unsub = useAreaSearchStore.subscribe(
(s) => s.activeDrawType,
(drawType) => {
setupDraw(map, drawType);
},
);
// 현재 activeDrawType이 이미 설정되어 있으면 즉시 적용
const { activeDrawType } = useAreaSearchStore.getState();
if (activeDrawType) {
setupDraw(map, activeDrawType);
}
return () => {
unsub();
// 구독 해제 시 Draw 인터랙션도 제거
if (drawRef.current && mapRef.current) {
mapRef.current.removeInteraction(drawRef.current);
drawRef.current = null;
}
};
}, [map, setupDraw]);
// ESC 키로 그리기 취소
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
const { activeDrawType } = useAreaSearchStore.getState();
if (activeDrawType) {
useAreaSearchStore.getState().setActiveDrawType(null);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// 구역 삭제 시 OL feature도 source에서 제거 (zones 감소)
useEffect(() => {
const unsub = useAreaSearchStore.subscribe(
(s) => s.zones,
(zones, prevZones) => {
if (!prevZones || zones.length >= prevZones.length) return;
const source = getZoneSource();
if (!source) return;
const currentIds = new Set(zones.map((z) => z.id));
prevZones.forEach((z) => {
if (!currentIds.has(z.id) && z.olFeature) {
try { source.removeFeature(z.olFeature); } catch { /* already removed */ }
}
});
},
);
return unsub;
}, []);
}

파일 보기

@ -0,0 +1,513 @@
/**
* 구역 편집 인터랙션
*
* - 클릭으로 구역 선택/해제
* - Polygon: OL Modify (꼭짓점 드래그, 중점 삽입) + 우클릭 꼭짓점 삭제
* - Box: BoxResizeInteraction (모서리 드래그, 직사각형 유지)
* - Circle: CircleResizeInteraction (테두리 드래그, 원형 재생성)
* - 모든 유형: OL Translate (내부 드래그 전체 이동)
* - ESC: 선택 해제, Delete: 구역 삭제
* - 편집 완료 store 좌표 동기화
*/
import { useEffect, useRef, useCallback } from 'react';
import { Modify, Translate } from 'ol/interaction';
import Collection from 'ol/Collection';
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style';
import { transform } from 'ol/proj';
import { useMapStore } from '../../stores/mapStore';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types';
import { getZoneSource } from '../utils/zoneLayerRefs';
import BoxResizeInteraction from '../interactions/BoxResizeInteraction';
import CircleResizeInteraction from '../interactions/CircleResizeInteraction';
/** 3857 좌표를 4326으로 변환 + 폐곡선 보장 */
function toWgs84Polygon(coords3857) {
const coords4326 = coords3857.map((c) => transform(c, 'EPSG:3857', 'EPSG:4326'));
if (coords4326.length > 0) {
const first = coords4326[0];
const last = coords4326[coords4326.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) {
coords4326.push([...first]);
}
}
return coords4326;
}
/** 선택된 구역의 하이라이트 스타일 */
function createSelectedStyle(colorIndex) {
const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0];
return new Style({
fill: new Fill({ color: `rgba(${color.fill[0]},${color.fill[1]},${color.fill[2]},0.25)` }),
stroke: new Stroke({
color: `rgba(${color.stroke[0]},${color.stroke[1]},${color.stroke[2]},1)`,
width: 3,
lineDash: [8, 4],
}),
});
}
/** Modify 인터랙션의 꼭짓점 핸들 스타일 */
const MODIFY_STYLE = new Style({
image: new CircleStyle({
radius: 6,
fill: new Fill({ color: '#ffffff' }),
stroke: new Stroke({ color: '#4a9eff', width: 2 }),
}),
});
/** 기본 구역 스타일 복원 */
function createNormalStyle(colorIndex) {
const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0];
return new Style({
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }),
});
}
/** 호버 스타일 (스트로크 강조) */
function createHoverStyle(colorIndex) {
const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0];
return new Style({
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
stroke: new Stroke({
color: `rgba(${color.stroke[0]},${color.stroke[1]},${color.stroke[2]},1)`,
width: 3,
}),
});
}
/** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */
function pointToSegmentDist(p, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
const HANDLE_TOLERANCE = 12;
/** Polygon 꼭짓점/변 근접 검사 */
function isNearPolygonHandle(map, pixel, feature) {
const coords = feature.getGeometry().getCoordinates()[0];
const n = coords.length - 1;
for (let i = 0; i < n; i++) {
const vp = map.getPixelFromCoordinate(coords[i]);
if (Math.hypot(pixel[0] - vp[0], pixel[1] - vp[1]) < HANDLE_TOLERANCE) {
return true;
}
}
for (let i = 0; i < n; i++) {
const p1 = map.getPixelFromCoordinate(coords[i]);
const p2 = map.getPixelFromCoordinate(coords[(i + 1) % n]);
if (pointToSegmentDist(pixel, p1, p2) < HANDLE_TOLERANCE) {
return true;
}
}
return false;
}
/** Feature에서 좌표를 추출하여 store에 동기화 */
function syncZoneToStore(zoneId, feature, zone) {
const geom = feature.getGeometry();
const coords3857 = geom.getCoordinates()[0];
const coords4326 = toWgs84Polygon(coords3857);
let circleMeta;
if (zone.type === ZONE_DRAW_TYPES.CIRCLE && zone.circleMeta) {
// 폴리곤 중심에서 첫 번째 점까지의 거리로 반지름 재계산
const center = computeCentroid(coords3857);
const dx = coords3857[0][0] - center[0];
const dy = coords3857[0][1] - center[1];
circleMeta = { center, radius: Math.sqrt(dx * dx + dy * dy) };
}
useAreaSearchStore.getState().updateZoneGeometry(zoneId, coords4326, circleMeta);
}
/** 다각형 중심점 계산 */
function computeCentroid(coords) {
let sumX = 0, sumY = 0;
const n = coords.length - 1; // 마지막(닫힘) 좌표 제외
for (let i = 0; i < n; i++) {
sumX += coords[i][0];
sumY += coords[i][1];
}
return [sumX / n, sumY / n];
}
export default function useZoneEdit() {
const map = useMapStore((s) => s.map);
const mapRef = useRef(null);
const modifyRef = useRef(null);
const translateRef = useRef(null);
const customResizeRef = useRef(null);
const selectedCollectionRef = useRef(new Collection());
const clickListenerRef = useRef(null);
const contextMenuRef = useRef(null);
const keydownRef = useRef(null);
const hoveredZoneIdRef = useRef(null);
useEffect(() => { mapRef.current = map; }, [map]);
/** 인터랙션 모두 제거 */
const removeInteractions = useCallback(() => {
const m = mapRef.current;
if (!m) return;
if (modifyRef.current) { m.removeInteraction(modifyRef.current); modifyRef.current = null; }
if (translateRef.current) { m.removeInteraction(translateRef.current); translateRef.current = null; }
if (customResizeRef.current) { m.removeInteraction(customResizeRef.current); customResizeRef.current = null; }
selectedCollectionRef.current.clear();
}, []);
/** 선택된 구역에 대해 인터랙션 설정 */
const setupInteractions = useCallback((currentMap, zone) => {
removeInteractions();
if (!zone || !zone.olFeature) return;
const feature = zone.olFeature;
const collection = selectedCollectionRef.current;
collection.push(feature);
// 선택 스타일 적용
feature.setStyle(createSelectedStyle(zone.colorIndex));
// Translate (모든 유형 공통 — 내부 드래그로 이동)
const translate = new Translate({ features: collection });
translate.on('translateend', () => {
// Circle의 경우 center 업데이트
if (zone.type === ZONE_DRAW_TYPES.CIRCLE && customResizeRef.current) {
const coords = feature.getGeometry().getCoordinates()[0];
const newCenter = computeCentroid(coords);
customResizeRef.current.setCenter(newCenter);
}
syncZoneToStore(zone.id, feature, zone);
});
currentMap.addInteraction(translate);
translateRef.current = translate;
// 형상별 편집 인터랙션
if (zone.type === ZONE_DRAW_TYPES.POLYGON) {
const modify = new Modify({
features: collection,
style: MODIFY_STYLE,
deleteCondition: () => false, // 기본 삭제 비활성화 (우클릭으로 대체)
});
modify.on('modifyend', () => {
syncZoneToStore(zone.id, feature, zone);
});
currentMap.addInteraction(modify);
modifyRef.current = modify;
} else if (zone.type === ZONE_DRAW_TYPES.BOX) {
const boxResize = new BoxResizeInteraction({
feature,
onResize: () => syncZoneToStore(zone.id, feature, zone),
});
currentMap.addInteraction(boxResize);
customResizeRef.current = boxResize;
} else if (zone.type === ZONE_DRAW_TYPES.CIRCLE) {
const center = zone.circleMeta?.center || computeCentroid(feature.getGeometry().getCoordinates()[0]);
const circleResize = new CircleResizeInteraction({
feature,
center,
onResize: (f) => {
// 리사이즈 후 circleMeta 업데이트
const coords = f.getGeometry().getCoordinates()[0];
const newCenter = computeCentroid(coords);
const dx = coords[0][0] - newCenter[0];
const dy = coords[0][1] - newCenter[1];
const newRadius = Math.sqrt(dx * dx + dy * dy);
const coords4326 = toWgs84Polygon(coords);
useAreaSearchStore.getState().updateZoneGeometry(zone.id, coords4326, { center: newCenter, radius: newRadius });
},
});
currentMap.addInteraction(circleResize);
customResizeRef.current = circleResize;
}
}, [removeInteractions]);
/** 구역 선택 해제 시 스타일 복원 */
const restoreStyle = useCallback((zoneId) => {
const { zones } = useAreaSearchStore.getState();
const zone = zones.find(z => z.id === zoneId);
if (zone && zone.olFeature) {
zone.olFeature.setStyle(createNormalStyle(zone.colorIndex));
}
}, []);
// selectedZoneId 변경 구독 → 인터랙션 설정/해제
useEffect(() => {
if (!map) return;
let prevSelectedId = null;
const unsub = useAreaSearchStore.subscribe(
(s) => s.selectedZoneId,
(zoneId) => {
// 이전 선택 스타일 복원
if (prevSelectedId) restoreStyle(prevSelectedId);
prevSelectedId = zoneId;
if (!zoneId) {
removeInteractions();
return;
}
const { zones } = useAreaSearchStore.getState();
const zone = zones.find(z => z.id === zoneId);
if (zone) {
setupInteractions(map, zone);
}
},
);
return () => {
unsub();
if (prevSelectedId) restoreStyle(prevSelectedId);
removeInteractions();
};
}, [map, setupInteractions, removeInteractions, restoreStyle]);
// Drawing 모드 진입 시 편집 해제
useEffect(() => {
const unsub = useAreaSearchStore.subscribe(
(s) => s.activeDrawType,
(drawType) => {
if (drawType) {
useAreaSearchStore.getState().deselectZone();
}
},
);
return unsub;
}, []);
// 맵 singleclick → 구역 선택/해제
useEffect(() => {
if (!map) return;
const handleClick = (evt) => {
// Drawing 중이면 무시
if (useAreaSearchStore.getState().activeDrawType) return;
// 구역 그리기 직후 singleclick 방지 (OL singleclick 250ms 지연 레이스 컨디션)
if (Date.now() - useAreaSearchStore.getState()._lastZoneAddedAt < 500) return;
const source = getZoneSource();
if (!source) return;
// 클릭 지점의 feature 탐색
let clickedZone = null;
const { zones } = useAreaSearchStore.getState();
map.forEachFeatureAtPixel(evt.pixel, (feature) => {
if (clickedZone) return; // 이미 찾았으면 무시
const zone = zones.find(z => z.olFeature === feature);
if (zone) clickedZone = zone;
}, { layerFilter: (layer) => layer.getSource() === source });
const { selectedZoneId } = useAreaSearchStore.getState();
if (clickedZone) {
if (clickedZone.id === selectedZoneId) return; // 이미 선택됨
// 결과 표시 중이면 confirmAndClearResults
if (!useAreaSearchStore.getState().confirmAndClearResults()) return;
useAreaSearchStore.getState().selectZone(clickedZone.id);
} else {
// 빈 영역 클릭 → 선택 해제
if (selectedZoneId) {
useAreaSearchStore.getState().deselectZone();
}
}
};
map.on('singleclick', handleClick);
clickListenerRef.current = handleClick;
return () => {
map.un('singleclick', handleClick);
clickListenerRef.current = null;
};
}, [map]);
// 우클릭 꼭짓점 삭제 (Polygon 전용)
useEffect(() => {
if (!map) return;
const handleContextMenu = (e) => {
const { selectedZoneId, zones } = useAreaSearchStore.getState();
if (!selectedZoneId) return;
const zone = zones.find(z => z.id === selectedZoneId);
if (!zone || zone.type !== ZONE_DRAW_TYPES.POLYGON) return;
const feature = zone.olFeature;
if (!feature) return;
const geom = feature.getGeometry();
const coords = geom.getCoordinates()[0];
const vertexCount = coords.length - 1; // 마지막 닫힘 좌표 제외
if (vertexCount <= 3) return; // 최소 삼각형 유지
const pixel = map.getEventPixel(e);
const clickCoord = map.getCoordinateFromPixel(pixel);
// 가장 가까운 꼭짓점 탐색
let minDist = Infinity;
let minIdx = -1;
for (let i = 0; i < vertexCount; i++) {
const dx = coords[i][0] - clickCoord[0];
const dy = coords[i][1] - clickCoord[1];
const dist = dx * dx + dy * dy;
if (dist < minDist) { minDist = dist; minIdx = i; }
}
// 픽셀 거리 검증 (10px 이내)
const vPixel = map.getPixelFromCoordinate(coords[minIdx]);
if (Math.hypot(pixel[0] - vPixel[0], pixel[1] - vPixel[1]) > 10) return;
e.preventDefault();
const newCoords = [...coords];
newCoords.splice(minIdx, 1);
if (minIdx === 0) {
newCoords[newCoords.length - 1] = [...newCoords[0]];
}
geom.setCoordinates([newCoords]);
syncZoneToStore(zone.id, feature, zone);
};
const viewport = map.getViewport();
viewport.addEventListener('contextmenu', handleContextMenu);
contextMenuRef.current = handleContextMenu;
return () => {
viewport.removeEventListener('contextmenu', handleContextMenu);
contextMenuRef.current = null;
};
}, [map]);
// 키보드: ESC → 선택 해제, Delete → 구역 삭제
useEffect(() => {
const handleKeyDown = (e) => {
const { selectedZoneId, activeDrawType } = useAreaSearchStore.getState();
if (e.key === 'Escape' && selectedZoneId && !activeDrawType) {
useAreaSearchStore.getState().deselectZone();
}
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedZoneId && !activeDrawType) {
useAreaSearchStore.getState().deselectZone();
useAreaSearchStore.getState().removeZone(selectedZoneId);
}
};
window.addEventListener('keydown', handleKeyDown);
keydownRef.current = handleKeyDown;
return () => {
window.removeEventListener('keydown', handleKeyDown);
keydownRef.current = null;
};
}, []);
// pointermove → 호버 피드백 (커서 + 스타일)
useEffect(() => {
if (!map) return;
const viewport = map.getViewport();
const handlePointerMove = (evt) => {
if (evt.dragging) return;
// Drawing 중이면 호버 해제
if (useAreaSearchStore.getState().activeDrawType) {
if (hoveredZoneIdRef.current) {
restoreStyle(hoveredZoneIdRef.current);
hoveredZoneIdRef.current = null;
}
viewport.style.cursor = '';
return;
}
const source = getZoneSource();
if (!source) return;
const { selectedZoneId, zones } = useAreaSearchStore.getState();
// 1. 선택된 구역 — 리사이즈 핸들 / 내부 커서
if (selectedZoneId) {
const zone = zones.find(z => z.id === selectedZoneId);
if (zone && zone.olFeature) {
// Box/Circle: isOverHandle
if (customResizeRef.current && customResizeRef.current.isOverHandle) {
const handle = customResizeRef.current.isOverHandle(map, evt.pixel);
if (handle) {
viewport.style.cursor = handle.cursor;
return;
}
}
// Polygon: 꼭짓점/변 근접
if (zone.type === ZONE_DRAW_TYPES.POLYGON) {
if (isNearPolygonHandle(map, evt.pixel, zone.olFeature)) {
viewport.style.cursor = 'crosshair';
return;
}
}
// 선택된 구역 내부 → move
let overSelected = false;
map.forEachFeatureAtPixel(evt.pixel, (feature) => {
if (feature === zone.olFeature) overSelected = true;
}, { layerFilter: (l) => l.getSource() === source });
if (overSelected) {
viewport.style.cursor = 'move';
return;
}
}
}
// 2. 비선택 구역 호버
let hoveredZone = null;
map.forEachFeatureAtPixel(evt.pixel, (feature) => {
if (hoveredZone) return;
const zone = zones.find(z => z.olFeature === feature && z.id !== selectedZoneId);
if (zone) hoveredZone = zone;
}, { layerFilter: (l) => l.getSource() === source });
if (hoveredZone) {
viewport.style.cursor = 'pointer';
if (hoveredZoneIdRef.current !== hoveredZone.id) {
if (hoveredZoneIdRef.current) restoreStyle(hoveredZoneIdRef.current);
hoveredZoneIdRef.current = hoveredZone.id;
hoveredZone.olFeature.setStyle(createHoverStyle(hoveredZone.colorIndex));
}
} else {
viewport.style.cursor = '';
if (hoveredZoneIdRef.current) {
restoreStyle(hoveredZoneIdRef.current);
hoveredZoneIdRef.current = null;
}
}
};
map.on('pointermove', handlePointerMove);
return () => {
map.un('pointermove', handlePointerMove);
if (hoveredZoneIdRef.current) {
restoreStyle(hoveredZoneIdRef.current);
hoveredZoneIdRef.current = null;
}
viewport.style.cursor = '';
};
}, [map, restoreStyle]);
}

파일 보기

@ -0,0 +1,149 @@
/**
* 사각형(Box) 리사이즈 커스텀 인터랙션
*
* OL Modify는 사각형 제약을 지원하지 않으므로 PointerInteraction을 확장.
* - 모서리 드래그: 대각 꼭짓점 고정, 자유 리사이즈
* - 드래그: 반대쪽 고정, 1 리사이즈
*/
import PointerInteraction from 'ol/interaction/Pointer';
const CORNER_TOLERANCE = 16;
const EDGE_TOLERANCE = 12;
/** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */
function pointToSegmentDist(p, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
export default class BoxResizeInteraction extends PointerInteraction {
constructor(options) {
super({
handleDownEvent: (evt) => BoxResizeInteraction.prototype._handleDown.call(this, evt),
handleDragEvent: (evt) => BoxResizeInteraction.prototype._handleDrag.call(this, evt),
handleUpEvent: (evt) => BoxResizeInteraction.prototype._handleUp.call(this, evt),
});
this.feature_ = options.feature;
this.onResize_ = options.onResize || null;
// corner mode
this.mode_ = null; // 'corner' | 'edge'
this.draggedIndex_ = null;
this.anchorCoord_ = null;
// edge mode
this.edgeIndex_ = null;
this.bbox_ = null;
}
_handleDown(evt) {
const pixel = evt.pixel;
const coords = this.feature_.getGeometry().getCoordinates()[0];
// 1. 모서리 감지 (우선)
for (let i = 0; i < 4; i++) {
const vp = evt.map.getPixelFromCoordinate(coords[i]);
if (Math.hypot(pixel[0] - vp[0], pixel[1] - vp[1]) < CORNER_TOLERANCE) {
this.mode_ = 'corner';
this.draggedIndex_ = i;
this.anchorCoord_ = coords[(i + 2) % 4];
return true;
}
}
// 2. 변 감지
for (let i = 0; i < 4; i++) {
const j = (i + 1) % 4;
const p1 = evt.map.getPixelFromCoordinate(coords[i]);
const p2 = evt.map.getPixelFromCoordinate(coords[j]);
if (pointToSegmentDist(pixel, p1, p2) < EDGE_TOLERANCE) {
this.mode_ = 'edge';
this.edgeIndex_ = i;
const xs = coords.slice(0, 4).map(c => c[0]);
const ys = coords.slice(0, 4).map(c => c[1]);
this.bbox_ = {
minX: Math.min(...xs), maxX: Math.max(...xs),
minY: Math.min(...ys), maxY: Math.max(...ys),
};
return true;
}
}
return false;
}
_handleDrag(evt) {
const coord = evt.coordinate;
if (this.mode_ === 'corner') {
const anchor = this.anchorCoord_;
const minX = Math.min(coord[0], anchor[0]);
const maxX = Math.max(coord[0], anchor[0]);
const minY = Math.min(coord[1], anchor[1]);
const maxY = Math.max(coord[1], anchor[1]);
this.feature_.getGeometry().setCoordinates([[
[minX, maxY], [maxX, maxY], [maxX, minY], [minX, minY], [minX, maxY],
]]);
} else if (this.mode_ === 'edge') {
let { minX, maxX, minY, maxY } = this.bbox_;
// Edge 0: top(TL→TR), 1: right(TR→BR), 2: bottom(BR→BL), 3: left(BL→TL)
switch (this.edgeIndex_) {
case 0: maxY = coord[1]; break;
case 1: maxX = coord[0]; break;
case 2: minY = coord[1]; break;
case 3: minX = coord[0]; break;
}
const x1 = Math.min(minX, maxX), x2 = Math.max(minX, maxX);
const y1 = Math.min(minY, maxY), y2 = Math.max(minY, maxY);
this.feature_.getGeometry().setCoordinates([[
[x1, y2], [x2, y2], [x2, y1], [x1, y1], [x1, y2],
]]);
}
}
_handleUp() {
if (this.mode_) {
this.mode_ = null;
this.draggedIndex_ = null;
this.anchorCoord_ = null;
this.edgeIndex_ = null;
this.bbox_ = null;
if (this.onResize_) this.onResize_(this.feature_);
return true;
}
return false;
}
/**
* 호버 감지: 픽셀이 리사이즈 핸들 위인지 확인
* @returns {{ cursor: string }} | null
*/
isOverHandle(map, pixel) {
const coords = this.feature_.getGeometry().getCoordinates()[0];
// 모서리 감지
const cornerCursors = ['nwse-resize', 'nesw-resize', 'nwse-resize', 'nesw-resize'];
for (let i = 0; i < 4; i++) {
const vp = map.getPixelFromCoordinate(coords[i]);
if (Math.hypot(pixel[0] - vp[0], pixel[1] - vp[1]) < CORNER_TOLERANCE) {
return { cursor: cornerCursors[i] };
}
}
// 변 감지
const edgeCursors = ['ns-resize', 'ew-resize', 'ns-resize', 'ew-resize'];
for (let i = 0; i < 4; i++) {
const j = (i + 1) % 4;
const p1 = map.getPixelFromCoordinate(coords[i]);
const p2 = map.getPixelFromCoordinate(coords[j]);
if (pointToSegmentDist(pixel, p1, p2) < EDGE_TOLERANCE) {
return { cursor: edgeCursors[i] };
}
}
return null;
}
}

파일 보기

@ -0,0 +1,90 @@
/**
* (Circle) 리사이즈 커스텀 인터랙션
*
* 원은 64 Polygon으로 저장되어 있으므로, 테두리 드래그
* 중심에서 드래그 좌표까지의 거리를 반지름으로 계산하고
* fromCircle() Polygon을 재생성.
*
* 감지 방식: 개별 꼭짓점이 아닌, 중심~포인터 거리와 반지름 비교 (테두리 전체 감지)
*/
import PointerInteraction from 'ol/interaction/Pointer';
import { fromCircle } from 'ol/geom/Polygon';
import OlCircle from 'ol/geom/Circle';
const PIXEL_TOLERANCE = 16;
const MIN_RADIUS = 100; // 최소 반지름 (미터)
export default class CircleResizeInteraction extends PointerInteraction {
constructor(options) {
super({
handleDownEvent: (evt) => CircleResizeInteraction.prototype._handleDown.call(this, evt),
handleDragEvent: (evt) => CircleResizeInteraction.prototype._handleDrag.call(this, evt),
handleUpEvent: (evt) => CircleResizeInteraction.prototype._handleUp.call(this, evt),
});
this.feature_ = options.feature;
this.center_ = options.center; // EPSG:3857 [x, y]
this.onResize_ = options.onResize || null;
this.dragging_ = false;
}
/** 중심~포인터 픽셀 거리와 표시 반지름 비교 */
_isNearEdge(map, pixel) {
const centerPixel = map.getPixelFromCoordinate(this.center_);
const coords = this.feature_.getGeometry().getCoordinates()[0];
const edgePixel = map.getPixelFromCoordinate(coords[0]);
const radiusPixels = Math.hypot(
edgePixel[0] - centerPixel[0],
edgePixel[1] - centerPixel[1],
);
const distFromCenter = Math.hypot(
pixel[0] - centerPixel[0],
pixel[1] - centerPixel[1],
);
return Math.abs(distFromCenter - radiusPixels) < PIXEL_TOLERANCE;
}
_handleDown(evt) {
if (this._isNearEdge(evt.map, evt.pixel)) {
this.dragging_ = true;
return true;
}
return false;
}
_handleDrag(evt) {
if (!this.dragging_) return;
const coord = evt.coordinate;
const dx = coord[0] - this.center_[0];
const dy = coord[1] - this.center_[1];
const newRadius = Math.max(Math.sqrt(dx * dx + dy * dy), MIN_RADIUS);
const circleGeom = new OlCircle(this.center_, newRadius);
const polyGeom = fromCircle(circleGeom, 64);
this.feature_.setGeometry(polyGeom);
}
_handleUp() {
if (this.dragging_) {
this.dragging_ = false;
if (this.onResize_) this.onResize_(this.feature_);
return true;
}
return false;
}
/** 외부에서 center 업데이트 (Translate 후) */
setCenter(center) {
this.center_ = center;
}
/**
* 호버 감지: 픽셀이 리사이즈 핸들(테두리) 위인지 확인
* @returns {{ cursor: string }} | null
*/
isOverHandle(map, pixel) {
if (this._isNearEdge(map, pixel)) {
return { cursor: 'nesw-resize' };
}
return null;
}
}

파일 보기

@ -0,0 +1,112 @@
/**
* 항적분석(구역 검색) REST API 서비스
*
* POST /api/v2/tracks/area-search
* 응답 변환: trackQueryApi.convertToProcessedTracks() 재사용
*/
import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi';
import { fetchWithAuth } from '../../api/fetchWithAuth';
const API_ENDPOINT = '/api/v2/tracks/area-search';
/**
* 타임스탬프 기반 위치 보간 (이진 탐색)
* track의 timestampsMs/geometry에서 targetTime 시점의 [lon, lat] 계산
*/
function interpolatePositionAtTime(track, targetTime) {
const { timestampsMs, geometry } = track;
if (!timestampsMs || timestampsMs.length === 0 || !targetTime) return null;
const first = timestampsMs[0];
const last = timestampsMs[timestampsMs.length - 1];
if (targetTime <= first) return geometry[0];
if (targetTime >= last) return geometry[geometry.length - 1];
// 이진 탐색
let left = 0;
let right = timestampsMs.length - 1;
while (left < right) {
const mid = Math.floor((left + right) / 2);
if (timestampsMs[mid] < targetTime) left = mid + 1;
else right = mid;
}
const idx1 = Math.max(0, left - 1);
const idx2 = Math.min(timestampsMs.length - 1, left);
if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) {
return geometry[idx1];
}
const ratio = (targetTime - timestampsMs[idx1]) / (timestampsMs[idx2] - timestampsMs[idx1]);
return [
geometry[idx1][0] + (geometry[idx2][0] - geometry[idx1][0]) * ratio,
geometry[idx1][1] + (geometry[idx2][1] - geometry[idx1][1]) * ratio,
];
}
/**
* 구역 기반 항적 검색
*
* @param {Object} params
* @param {string} params.startTime ISO 8601 시작 시간
* @param {string} params.endTime ISO 8601 종료 시간
* @param {string} params.mode 'ANY' | 'ALL' | 'SEQUENTIAL'
* @param {Array<{id: string, name: string, coordinates: number[][]}>} params.polygons
* @returns {Promise<{tracks: Array, hitDetails: Object, summary: Object}>}
*/
export async function fetchAreaSearch(params) {
const request = {
startTime: params.startTime,
endTime: params.endTime,
mode: params.mode,
polygons: params.polygons,
};
const response = await fetchWithAuth(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
const rawTracks = Array.isArray(result.tracks) ? result.tracks : [];
const tracks = convertToProcessedTracks(rawTracks);
// vesselId → track 빠른 조회용
const trackMap = new Map(tracks.map((t) => [t.vesselId, t]));
// hitDetails: timestamp 초→밀리초 변환 + 진입/진출 위치 보간
const rawHitDetails = result.hitDetails || {};
const hitDetails = {};
for (const [vesselId, hits] of Object.entries(rawHitDetails)) {
const track = trackMap.get(vesselId);
hitDetails[vesselId] = (Array.isArray(hits) ? hits : []).map((hit) => {
const toMs = (ts) => {
if (!ts) return null;
const num = typeof ts === 'number' ? ts : parseInt(ts, 10);
return num < 10000000000 ? num * 1000 : num;
};
const entryMs = toMs(hit.entryTimestamp);
const exitMs = toMs(hit.exitTimestamp);
return {
...hit,
entryTimestamp: entryMs,
exitTimestamp: exitMs,
entryPosition: track ? interpolatePositionAtTime(track, entryMs) : null,
exitPosition: track ? interpolatePositionAtTime(track, exitMs) : null,
};
});
}
return {
tracks,
hitDetails,
summary: result.summary || null,
};
}

파일 보기

@ -0,0 +1,80 @@
/**
* STS(Ship-to-Ship) 접촉 검출 REST API 서비스
*
* POST /api/v2/tracks/vessel-contacts
* 응답 변환: trackQueryApi.convertToProcessedTracks() 재사용
*/
import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi';
import { fetchWithAuth } from '../../api/fetchWithAuth';
const API_ENDPOINT = '/api/v2/tracks/vessel-contacts';
/**
* Unix /밀리초 밀리초 변환
*/
function toMs(ts) {
if (!ts) return null;
const num = typeof ts === 'number' ? ts : parseInt(ts, 10);
return num < 10000000000 ? num * 1000 : num;
}
/**
* STS 접촉 검출 API 호출
*
* @param {Object} params
* @param {string} params.startTime ISO 8601
* @param {string} params.endTime ISO 8601
* @param {{id: string, name: string, coordinates: number[][]}} params.polygon 단일 폴리곤
* @param {number} params.minContactDurationMinutes 30~360
* @param {number} params.maxContactDistanceMeters 50~5000
* @returns {Promise<{contacts: Array, tracks: Array, summary: Object}>}
*/
export async function fetchVesselContacts(params) {
const request = {
startTime: params.startTime,
endTime: params.endTime,
polygon: params.polygon,
minContactDurationMinutes: params.minContactDurationMinutes,
maxContactDistanceMeters: params.maxContactDistanceMeters,
};
const response = await fetchWithAuth(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
// tracks 변환
const rawTracks = Array.isArray(result.tracks) ? result.tracks : [];
const tracks = convertToProcessedTracks(rawTracks);
// contacts: timestamp 초→밀리초 변환
const rawContacts = Array.isArray(result.contacts) ? result.contacts : [];
const contacts = rawContacts.map((c) => ({
...c,
contactStartTimestamp: toMs(c.contactStartTimestamp),
contactEndTimestamp: toMs(c.contactEndTimestamp),
vessel1: {
...c.vessel1,
insidePolygonStartTs: toMs(c.vessel1?.insidePolygonStartTs),
insidePolygonEndTs: toMs(c.vessel1?.insidePolygonEndTs),
},
vessel2: {
...c.vessel2,
insidePolygonStartTs: toMs(c.vessel2?.insidePolygonStartTs),
insidePolygonEndTs: toMs(c.vessel2?.insidePolygonEndTs),
},
}));
return {
contacts,
tracks,
summary: result.summary || null,
};
}

파일 보기

@ -0,0 +1,117 @@
/**
* 항적분석 전용 애니메이션 스토어
* 참조: src/tracking/stores/trackQueryAnimationStore.js
*
* - 재생/일시정지/정지
* - 배속 조절 (1x ~ 1000x)
* - requestAnimationFrame 기반 애니메이션
*/
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
let animationFrameId = null;
let lastFrameTime = null;
export const useAreaSearchAnimationStore = create(subscribeWithSelector((set, get) => {
const animate = () => {
const state = get();
if (!state.isPlaying) return;
const now = performance.now();
if (lastFrameTime === null) {
lastFrameTime = now;
}
const delta = now - lastFrameTime;
lastFrameTime = now;
const newTime = state.currentTime + delta * state.playbackSpeed;
if (newTime >= state.endTime) {
set({ currentTime: state.endTime, isPlaying: false });
animationFrameId = null;
lastFrameTime = null;
return;
}
set({ currentTime: newTime });
animationFrameId = requestAnimationFrame(animate);
};
return {
isPlaying: false,
currentTime: 0,
startTime: 0,
endTime: 0,
playbackSpeed: 1,
play: () => {
const state = get();
if (state.endTime <= state.startTime) return;
lastFrameTime = null;
if (state.currentTime >= state.endTime) {
set({ isPlaying: true, currentTime: state.startTime });
} else {
set({ isPlaying: true });
}
animationFrameId = requestAnimationFrame(animate);
},
pause: () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
lastFrameTime = null;
set({ isPlaying: false });
},
stop: () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
lastFrameTime = null;
set({ isPlaying: false, currentTime: get().startTime });
},
setCurrentTime: (time) => {
const { startTime, endTime } = get();
set({ currentTime: Math.max(startTime, Math.min(endTime, time)) });
},
setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }),
setTimeRange: (start, end) => {
set({
startTime: start,
endTime: end,
currentTime: start,
});
},
getProgress: () => {
const { currentTime, startTime, endTime } = get();
if (endTime <= startTime) return 0;
return ((currentTime - startTime) / (endTime - startTime)) * 100;
},
reset: () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
lastFrameTime = null;
set({
isPlaying: false,
currentTime: 0,
startTime: 0,
endTime: 0,
playbackSpeed: 1,
});
},
};
}));

파일 보기

@ -0,0 +1,317 @@
/**
* 항적분석(구역 검색) 메인 상태 관리 스토어
*
* - 구역 관리 (추가/삭제/순서변경, 최대 3)
* - 검색 조건 (모드, 기간)
* - 결과 데이터 (항적, hitDetails, summary)
* - UI 상태
*/
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { SEARCH_MODES, MAX_ZONES, ZONE_NAMES, ALL_SHIP_KIND_CODES, ANALYSIS_TABS } from '../types/areaSearch.types';
import { showLiveShips } from '../../utils/liveControl';
/**
* 지점 사이 선박 위치를 시간 기반 보간
*/
function interpolatePosition(p1, p2, t1, t2, currentTime) {
if (t1 === t2) return p1;
if (currentTime <= t1) return p1;
if (currentTime >= t2) return p2;
const ratio = (currentTime - t1) / (t2 - t1);
return [p1[0] + (p2[0] - p1[0]) * ratio, p1[1] + (p2[1] - p1[1]) * ratio];
}
/**
* 지점 방향(heading) 계산
*/
function calculateHeading(p1, p2) {
const [lon1, lat1] = p1;
const [lon2, lat2] = p2;
const dx = lon2 - lon1;
const dy = lat2 - lat1;
let angle = (Math.atan2(dx, dy) * 180) / Math.PI;
if (angle < 0) angle += 360;
return angle;
}
let zoneIdCounter = 0;
// 커서 기반 선형 탐색용 (vesselId → lastIndex)
// 재생 중 시간은 단조 증가 → O(1~2) 전진, seek 시 이진탐색 fallback
const positionCursors = new Map();
export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
// 탭 상태
activeTab: ANALYSIS_TABS.AREA,
// 검색 조건
zones: [],
searchMode: SEARCH_MODES.SEQUENTIAL,
// 검색 결과
tracks: [],
hitDetails: {},
summary: null,
// UI 상태
isLoading: false,
queryCompleted: false,
disabledVesselIds: new Set(),
highlightedVesselId: null,
showZones: true,
activeDrawType: null,
areaSearchTooltip: null,
selectedZoneId: null,
_lastZoneAddedAt: 0,
// 필터 상태
showPaths: true,
showTrail: false,
shipKindCodeFilter: new Set(ALL_SHIP_KIND_CODES),
// ========== 구역 관리 ==========
addZone: (zone) => {
const { zones } = get();
if (zones.length >= MAX_ZONES) return;
// 사용 중인 colorIndex를 피해 첫 번째 빈 인덱스 할당
const usedColors = new Set(zones.map(z => z.colorIndex));
let colorIndex = 0;
while (usedColors.has(colorIndex)) colorIndex++;
const newZone = {
...zone,
id: `zone-${++zoneIdCounter}`,
name: ZONE_NAMES[colorIndex] || `${colorIndex + 1}`,
colorIndex,
circleMeta: zone.circleMeta || null,
};
set({ zones: [...zones, newZone], activeDrawType: null, _lastZoneAddedAt: Date.now() });
},
removeZone: (zoneId) => {
const { zones, selectedZoneId } = get();
const filtered = zones.filter(z => z.id !== zoneId);
const updates = { zones: filtered };
if (selectedZoneId === zoneId) updates.selectedZoneId = null;
set(updates);
},
clearZones: () => set({ zones: [] }),
reorderZones: (fromIndex, toIndex) => {
const { zones } = get();
if (fromIndex < 0 || fromIndex >= zones.length) return;
if (toIndex < 0 || toIndex >= zones.length) return;
const newZones = [...zones];
const [moved] = newZones.splice(fromIndex, 1);
newZones.splice(toIndex, 0, moved);
set({ zones: newZones });
},
// ========== 탭 전환 ==========
setActiveTab: (tab) => set({ activeTab: tab }),
// ========== 검색 조건 ==========
setSearchMode: (mode) => set({ searchMode: mode }),
setActiveDrawType: (type) => set({ activeDrawType: type }),
setShowZones: (show) => set({ showZones: show }),
// ========== 구역 편집 ==========
selectZone: (zoneId) => set({ selectedZoneId: zoneId }),
deselectZone: () => set({ selectedZoneId: null }),
updateZoneGeometry: (zoneId, coordinates4326, circleMeta) => {
const { zones } = get();
const updated = zones.map(z => {
if (z.id !== zoneId) return z;
const patch = { ...z, coordinates: coordinates4326 };
if (circleMeta !== undefined) patch.circleMeta = circleMeta;
return patch;
});
set({ zones: updated });
},
/**
* 조회 조건 변경 결과 초기화 확인
* 결과가 없으면 true 반환, 있으면 confirm 초기화
* @returns {boolean} 진행 허용 여부
*/
confirmAndClearResults: () => {
const { queryCompleted } = get();
if (!queryCompleted) return true;
const ok = window.confirm('조회 조건을 변경하면 기존 결과가 초기화됩니다.\n계속하시겠습니까?');
if (!ok) return false;
get().clearResults();
showLiveShips();
return true;
},
// ========== 검색 결과 ==========
setTracks: (tracks) => {
if (tracks.length === 0) {
set({ tracks: [], queryCompleted: true, selectedZoneId: null });
return;
}
set({ tracks, queryCompleted: true, selectedZoneId: null });
},
setHitDetails: (hitDetails) => set({ hitDetails }),
setSummary: (summary) => set({ summary }),
setLoading: (loading) => set({ isLoading: loading }),
// ========== 선박 토글 ==========
toggleVesselEnabled: (vesselId) => {
const { disabledVesselIds } = get();
const newDisabled = new Set(disabledVesselIds);
if (newDisabled.has(vesselId)) {
newDisabled.delete(vesselId);
} else {
newDisabled.add(vesselId);
}
set({ disabledVesselIds: newDisabled });
},
setHighlightedVesselId: (vesselId) => set({ highlightedVesselId: vesselId }),
setAreaSearchTooltip: (tooltip) => set({ areaSearchTooltip: tooltip }),
// ========== 필터 토글 ==========
setShowPaths: (show) => set({ showPaths: show }),
setShowTrail: (show) => set({ showTrail: show }),
toggleShipKindCode: (code) => {
const { shipKindCodeFilter } = get();
const newFilter = new Set(shipKindCodeFilter);
if (newFilter.has(code)) newFilter.delete(code);
else newFilter.add(code);
set({ shipKindCodeFilter: newFilter });
},
getEnabledTracks: () => {
const { tracks, disabledVesselIds } = get();
return tracks.filter(t => !disabledVesselIds.has(t.vesselId));
},
/**
* 현재 시간의 모든 선박 위치 계산 (커서 기반 선형 탐색 + 보간)
* 재생 : 커서에서 선형 전진 O(1~2)
* seek/역방향: 이진 탐색 fallback O(log n)
*/
getCurrentPositions: (currentTime) => {
const { tracks, disabledVesselIds } = get();
const positions = [];
tracks.forEach(track => {
if (disabledVesselIds.has(track.vesselId)) return;
const { timestampsMs, geometry, speeds, vesselId, targetId, sigSrcCd, shipName, shipKindCode, nationalCode } = track;
if (timestampsMs.length === 0) return;
const firstTime = timestampsMs[0];
const lastTime = timestampsMs[timestampsMs.length - 1];
if (currentTime < firstTime || currentTime > lastTime) return;
// 커서 기반 탐색: 이전 인덱스에서 선형 전진 시도
let cursor = positionCursors.get(vesselId);
if (cursor === undefined
|| cursor >= timestampsMs.length
|| (cursor > 0 && timestampsMs[cursor - 1] > currentTime)) {
// 커서 없음 or 무효 or 시간 역행 → 이진 탐색 fallback
let left = 0;
let right = timestampsMs.length - 1;
while (left < right) {
const mid = (left + right) >> 1;
if (timestampsMs[mid] < currentTime) left = mid + 1;
else right = mid;
}
cursor = left;
} else {
// 선형 전진 (재생 중 1~2칸)
while (cursor < timestampsMs.length - 1 && timestampsMs[cursor] < currentTime) {
cursor++;
}
}
positionCursors.set(vesselId, cursor);
const idx1 = Math.max(0, cursor - 1);
const idx2 = Math.min(timestampsMs.length - 1, cursor);
let position, heading, speed;
if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) {
position = geometry[idx1];
speed = speeds[idx1] || 0;
if (idx2 < geometry.length - 1) heading = calculateHeading(geometry[idx1], geometry[idx2 + 1]);
else if (idx1 > 0) heading = calculateHeading(geometry[idx1 - 1], geometry[idx1]);
else heading = 0;
} else {
position = interpolatePosition(geometry[idx1], geometry[idx2], timestampsMs[idx1], timestampsMs[idx2], currentTime);
heading = calculateHeading(geometry[idx1], geometry[idx2]);
const ratio = (currentTime - timestampsMs[idx1]) / (timestampsMs[idx2] - timestampsMs[idx1]);
speed = (speeds[idx1] || 0) + ((speeds[idx2] || 0) - (speeds[idx1] || 0)) * ratio;
}
positions.push({
vesselId, targetId, sigSrcCd, shipName, shipKindCode, nationalCode,
lon: position[0], lat: position[1],
heading, speed, timestamp: currentTime,
});
});
return positions;
},
// ========== 초기화 ==========
clearResults: () => {
positionCursors.clear();
set({
tracks: [],
hitDetails: {},
summary: null,
queryCompleted: false,
disabledVesselIds: new Set(),
highlightedVesselId: null,
areaSearchTooltip: null,
showPaths: true,
showTrail: false,
shipKindCodeFilter: new Set(ALL_SHIP_KIND_CODES),
});
},
reset: () => {
positionCursors.clear();
set({
activeTab: ANALYSIS_TABS.AREA,
zones: [],
searchMode: SEARCH_MODES.SEQUENTIAL,
tracks: [],
hitDetails: {},
summary: null,
isLoading: false,
queryCompleted: false,
disabledVesselIds: new Set(),
highlightedVesselId: null,
showZones: true,
activeDrawType: null,
areaSearchTooltip: null,
selectedZoneId: null,
showPaths: true,
showTrail: false,
shipKindCodeFilter: new Set(ALL_SHIP_KIND_CODES),
});
},
})));
export default useAreaSearchStore;

파일 보기

@ -0,0 +1,275 @@
/**
* STS(Ship-to-Ship) 분석 상태 관리 스토어
*
* - STS 파라미터 (최소 접촉 시간, 최대 접촉 거리)
* - 결과 데이터 (contacts, groupedContacts, tracks, summary)
* - 그룹핑: 동일 선박 쌍의 여러 접촉을 하나의 그룹으로
* - 커서 기반 위치 보간 (getCurrentPositions)
*/
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { STS_DEFAULTS } from '../types/sts.types';
function interpolatePosition(p1, p2, t1, t2, currentTime) {
if (t1 === t2) return p1;
if (currentTime <= t1) return p1;
if (currentTime >= t2) return p2;
const ratio = (currentTime - t1) / (t2 - t1);
return [p1[0] + (p2[0] - p1[0]) * ratio, p1[1] + (p2[1] - p1[1]) * ratio];
}
function calculateHeading(p1, p2) {
const [lon1, lat1] = p1;
const [lon2, lat2] = p2;
const dx = lon2 - lon1;
const dy = lat2 - lat1;
let angle = (Math.atan2(dx, dy) * 180) / Math.PI;
if (angle < 0) angle += 360;
return angle;
}
/**
* contacts 배열을 선박 기준으로 그룹핑
*/
function groupContactsByPair(contacts) {
const groupMap = new Map();
contacts.forEach((contact) => {
const v1Id = contact.vessel1.vesselId;
const v2Id = contact.vessel2.vesselId;
const pairKey = v1Id < v2Id ? `${v1Id}_${v2Id}` : `${v2Id}_${v1Id}`;
if (!groupMap.has(pairKey)) {
groupMap.set(pairKey, {
pairKey,
vessel1: v1Id < v2Id ? contact.vessel1 : contact.vessel2,
vessel2: v1Id < v2Id ? contact.vessel2 : contact.vessel1,
contacts: [],
});
}
groupMap.get(pairKey).contacts.push(contact);
});
return [...groupMap.values()].map((group) => {
group.contacts.sort((a, b) => a.contactStartTimestamp - b.contactStartTimestamp);
// 합산 통계
group.totalDurationMinutes = group.contacts.reduce(
(s, c) => s + (c.contactDurationMinutes || 0), 0,
);
// 가중 평균 거리
let totalWeight = 0;
let weightedSum = 0;
group.contacts.forEach((c) => {
const w = c.contactPointCount || 1;
weightedSum += (c.avgDistanceMeters || 0) * w;
totalWeight += w;
});
group.avgDistanceMeters = totalWeight > 0 ? weightedSum / totalWeight : 0;
group.minDistanceMeters = Math.min(...group.contacts.map((c) => c.minDistanceMeters));
group.maxDistanceMeters = Math.max(...group.contacts.map((c) => c.maxDistanceMeters));
group.contactCenterPoint = group.contacts[0].contactCenterPoint;
group.totalContactPointCount = group.contacts.reduce(
(s, c) => s + (c.contactPointCount || 0), 0,
);
// indicators OR 합산
group.indicators = {};
group.contacts.forEach((c) => {
if (c.indicators) {
Object.entries(c.indicators).forEach(([k, v]) => {
if (v) group.indicators[k] = true;
});
}
});
return group;
});
}
const positionCursors = new Map();
export const useStsStore = create(subscribeWithSelector((set, get) => ({
// STS 파라미터
minContactDurationMinutes: STS_DEFAULTS.MIN_CONTACT_DURATION,
maxContactDistanceMeters: STS_DEFAULTS.MAX_CONTACT_DISTANCE,
// 결과
contacts: [],
groupedContacts: [],
tracks: [],
summary: null,
// UI
isLoading: false,
queryCompleted: false,
highlightedGroupIndex: null,
disabledGroupIndices: new Set(),
expandedGroupIndex: null,
// 필터 상태
showPaths: true,
showTrail: false,
// ========== 파라미터 설정 ==========
setMinContactDuration: (val) => set({ minContactDurationMinutes: val }),
setMaxContactDistance: (val) => set({ maxContactDistanceMeters: val }),
// ========== 결과 설정 ==========
setResults: ({ contacts, tracks, summary }) => {
positionCursors.clear();
const groupedContacts = groupContactsByPair(contacts);
set({
contacts,
groupedContacts,
tracks,
summary,
queryCompleted: true,
disabledGroupIndices: new Set(),
highlightedGroupIndex: null,
expandedGroupIndex: null,
});
},
setLoading: (loading) => set({ isLoading: loading }),
// ========== 그룹 UI ==========
setHighlightedGroupIndex: (idx) => set({ highlightedGroupIndex: idx }),
setExpandedGroupIndex: (idx) => {
const { expandedGroupIndex } = get();
set({ expandedGroupIndex: expandedGroupIndex === idx ? null : idx });
},
toggleGroupEnabled: (idx) => {
const { disabledGroupIndices } = get();
const newDisabled = new Set(disabledGroupIndices);
if (newDisabled.has(idx)) newDisabled.delete(idx);
else newDisabled.add(idx);
set({ disabledGroupIndices: newDisabled });
},
// ========== 필터 ==========
setShowPaths: (show) => set({ showPaths: show }),
setShowTrail: (show) => set({ showTrail: show }),
getDisabledVesselIds: () => {
const { groupedContacts, disabledGroupIndices } = get();
const ids = new Set();
disabledGroupIndices.forEach((idx) => {
const g = groupedContacts[idx];
if (g) {
ids.add(g.vessel1.vesselId);
ids.add(g.vessel2.vesselId);
}
});
return ids;
},
getCurrentPositions: (currentTime) => {
const { tracks } = get();
const disabledVesselIds = get().getDisabledVesselIds();
const positions = [];
tracks.forEach((track) => {
if (disabledVesselIds.has(track.vesselId)) return;
const { timestampsMs, geometry, speeds, vesselId, targetId, sigSrcCd, shipName, shipKindCode, nationalCode } = track;
if (timestampsMs.length === 0) return;
const firstTime = timestampsMs[0];
const lastTime = timestampsMs[timestampsMs.length - 1];
if (currentTime < firstTime || currentTime > lastTime) return;
let cursor = positionCursors.get(vesselId);
if (cursor === undefined
|| cursor >= timestampsMs.length
|| (cursor > 0 && timestampsMs[cursor - 1] > currentTime)) {
let left = 0;
let right = timestampsMs.length - 1;
while (left < right) {
const mid = (left + right) >> 1;
if (timestampsMs[mid] < currentTime) left = mid + 1;
else right = mid;
}
cursor = left;
} else {
while (cursor < timestampsMs.length - 1 && timestampsMs[cursor] < currentTime) {
cursor++;
}
}
positionCursors.set(vesselId, cursor);
const idx1 = Math.max(0, cursor - 1);
const idx2 = Math.min(timestampsMs.length - 1, cursor);
let position, heading, speed;
if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) {
position = geometry[idx1];
speed = speeds[idx1] || 0;
if (idx2 < geometry.length - 1) heading = calculateHeading(geometry[idx1], geometry[idx2 + 1]);
else if (idx1 > 0) heading = calculateHeading(geometry[idx1 - 1], geometry[idx1]);
else heading = 0;
} else {
position = interpolatePosition(geometry[idx1], geometry[idx2], timestampsMs[idx1], timestampsMs[idx2], currentTime);
heading = calculateHeading(geometry[idx1], geometry[idx2]);
const ratio = (currentTime - timestampsMs[idx1]) / (timestampsMs[idx2] - timestampsMs[idx1]);
speed = (speeds[idx1] || 0) + ((speeds[idx2] || 0) - (speeds[idx1] || 0)) * ratio;
}
positions.push({
vesselId, targetId, sigSrcCd, shipName, shipKindCode, nationalCode,
lon: position[0], lat: position[1],
heading, speed, timestamp: currentTime,
});
});
return positions;
},
// ========== 초기화 ==========
clearResults: () => {
positionCursors.clear();
set({
contacts: [],
groupedContacts: [],
tracks: [],
summary: null,
queryCompleted: false,
disabledGroupIndices: new Set(),
highlightedGroupIndex: null,
expandedGroupIndex: null,
showPaths: true,
showTrail: false,
});
},
reset: () => {
positionCursors.clear();
set({
minContactDurationMinutes: STS_DEFAULTS.MIN_CONTACT_DURATION,
maxContactDistanceMeters: STS_DEFAULTS.MAX_CONTACT_DISTANCE,
contacts: [],
groupedContacts: [],
tracks: [],
summary: null,
isLoading: false,
queryCompleted: false,
disabledGroupIndices: new Set(),
highlightedGroupIndex: null,
expandedGroupIndex: null,
showPaths: true,
showTrail: false,
});
},
})));
export default useStsStore;

파일 보기

@ -0,0 +1,101 @@
/**
* 항적분석(구역 검색) 상수 타입 정의
*/
// ========== 분석 탭 ==========
export const ANALYSIS_TABS = {
AREA: 'area',
STS: 'sts',
};
// ========== 검색 모드 ==========
export const SEARCH_MODES = {
ANY: 'ANY',
ALL: 'ALL',
SEQUENTIAL: 'SEQUENTIAL',
};
export const SEARCH_MODE_LABELS = {
[SEARCH_MODES.ANY]: 'ANY (합집합)',
[SEARCH_MODES.ALL]: 'ALL (교집합)',
[SEARCH_MODES.SEQUENTIAL]: 'SEQUENTIAL (순차통과)',
};
// ========== 구역 설정 ==========
export const MAX_ZONES = 3;
export const ZONE_DRAW_TYPES = {
POLYGON: 'Polygon',
BOX: 'Box',
CIRCLE: 'Circle',
};
export const ZONE_NAMES = ['A', 'B', 'C'];
export const ZONE_COLORS = [
{ fill: [255, 59, 48, 0.15], stroke: [255, 59, 48, 0.8], label: '#FF3B30' },
{ fill: [0, 199, 190, 0.15], stroke: [0, 199, 190, 0.8], label: '#00C7BE' },
{ fill: [255, 204, 0, 0.15], stroke: [255, 204, 0, 0.8], label: '#FFCC00' },
];
// ========== 조회기간 제약 ==========
export const QUERY_MAX_DAYS = 7;
/**
* 조회 가능 기간 계산 (D-7 ~ D-1)
* 인메모리 캐시 기반, 오늘 데이터 없음
*/
export function getQueryDateRange() {
const now = new Date();
const endDate = new Date(now);
endDate.setDate(endDate.getDate() - 1);
endDate.setHours(23, 59, 59, 0);
const startDate = new Date(now);
startDate.setDate(startDate.getDate() - QUERY_MAX_DAYS);
startDate.setHours(0, 0, 0, 0);
return { startDate, endDate };
}
// ========== 배속 옵션 ==========
export const PLAYBACK_SPEED_OPTIONS = [1, 10, 50, 100, 500, 1000];
// ========== 선종 코드 전체 목록 (필터 초기값) ==========
import {
SIGNAL_KIND_CODE_FISHING,
SIGNAL_KIND_CODE_KCGV,
SIGNAL_KIND_CODE_PASSENGER,
SIGNAL_KIND_CODE_CARGO,
SIGNAL_KIND_CODE_TANKER,
SIGNAL_KIND_CODE_GOV,
SIGNAL_KIND_CODE_NORMAL,
SIGNAL_KIND_CODE_BUOY,
} from '../../types/constants';
export const ALL_SHIP_KIND_CODES = [
SIGNAL_KIND_CODE_FISHING,
SIGNAL_KIND_CODE_KCGV,
SIGNAL_KIND_CODE_PASSENGER,
SIGNAL_KIND_CODE_CARGO,
SIGNAL_KIND_CODE_TANKER,
SIGNAL_KIND_CODE_GOV,
SIGNAL_KIND_CODE_NORMAL,
SIGNAL_KIND_CODE_BUOY,
];
// ========== 레이어 ID ==========
export const AREA_SEARCH_LAYER_IDS = {
PATH: 'area-search-path-layer',
TRIPS_TRAIL: 'area-search-trips-trail',
VIRTUAL_SHIP: 'area-search-virtual-ship-layer',
VIRTUAL_SHIP_LABEL: 'area-search-virtual-ship-label-layer',
};

파일 보기

@ -0,0 +1,106 @@
/**
* STS(Ship-to-Ship) 분석 상수 유틸리티
*/
import { getShipKindName } from '../../tracking/types/trackQuery.types';
// ========== 파라미터 기본값 / 범위 ==========
export const STS_DEFAULTS = {
MIN_CONTACT_DURATION: 60,
MAX_CONTACT_DISTANCE: 500,
};
export const STS_LIMITS = {
DURATION_MIN: 30,
DURATION_MAX: 360,
DISTANCE_MIN: 50,
DISTANCE_MAX: 5000,
};
// ========== 레이어 ID ==========
export const STS_LAYER_IDS = {
TRACK_PATH: 'sts-track-path-layer',
CONTACT_LINE: 'sts-contact-line-layer',
CONTACT_POINT: 'sts-contact-point-layer',
TRIPS_TRAIL: 'sts-trips-trail-layer',
VIRTUAL_SHIP: 'sts-virtual-ship-layer',
VIRTUAL_SHIP_LABEL: 'sts-virtual-ship-label-layer',
};
// ========== Indicator 라벨 ==========
export const INDICATOR_LABELS = {
lowSpeedContact: '저속',
differentVesselTypes: '이종',
differentNationalities: '외국적',
nightTimeContact: '야간',
};
/**
* indicator 뱃지에 맥락 정보를 포함한 텍스트 생성
* : "저속 1.2/0.8kn", "이종 어선↔화물선"
*/
export function getIndicatorDetail(key, contact) {
const { vessel1, vessel2 } = contact;
switch (key) {
case 'lowSpeedContact': {
const s1 = vessel1.estimatedAvgSpeedKnots;
const s2 = vessel2.estimatedAvgSpeedKnots;
if (s1 != null && s2 != null) {
return `저속 ${s1.toFixed(1)}/${s2.toFixed(1)}kn`;
}
return '저속';
}
case 'differentVesselTypes': {
const name1 = getShipKindName(vessel1.shipKindCode);
const name2 = getShipKindName(vessel2.shipKindCode);
return `이종 ${name1}\u2194${name2}`;
}
case 'differentNationalities': {
const n1 = vessel1.nationalCode || '?';
const n2 = vessel2.nationalCode || '?';
return `외국적 ${n1}\u2194${n2}`;
}
case 'nightTimeContact':
return '야간';
default:
return INDICATOR_LABELS[key] || key;
}
}
/**
* 거리 포맷 (미터 읽기 좋은 형태)
*/
export function formatDistance(meters) {
if (meters == null) return '-';
if (meters >= 1000) return `${(meters / 1000).toFixed(1)}km`;
return `${Math.round(meters)}m`;
}
/**
* 시간 포맷 ( 시분)
*/
export function formatDuration(minutes) {
if (minutes == null) return '-';
if (minutes < 60) return `${minutes}`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h}시간 ${m}` : `${h}시간`;
}
// ========== Indicator 위험도 색상 ==========
/**
* contact의 indicators 활성 개수에 따라 위험도 색상 반환
* 3+: 빨강, 2: 주황, 1: 노랑, 0: 회색
*/
export function getContactRiskColor(indicators) {
if (!indicators) return [150, 150, 150, 200];
const count = Object.values(indicators).filter(Boolean).length;
if (count >= 3) return [231, 76, 60, 220];
if (count === 2) return [243, 156, 18, 220];
if (count === 1) return [241, 196, 15, 200];
return [150, 150, 150, 200];
}

파일 보기

@ -0,0 +1,19 @@
/**
* 항적분석 레이어 전역 레지스트리
* 참조: src/replay/utils/replayLayerRegistry.js
*
* useAreaSearchLayer 훅이 레이어를 등록하면
* useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합
*/
export function registerAreaSearchLayers(layers) {
window.__areaSearchLayers__ = layers;
}
export function getAreaSearchLayers() {
return window.__areaSearchLayers__ || [];
}
export function unregisterAreaSearchLayers() {
window.__areaSearchLayers__ = [];
}

파일 보기

@ -0,0 +1,140 @@
/**
* 항적분석 검색 결과 CSV 내보내기
* BOM + UTF-8 인코딩 (한글 엑셀 호환)
*/
import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
import { getCountryIsoCode } from '../../replay/components/VesselListManager/utils/countryCodeUtils';
function formatTimestamp(ms) {
if (!ms) return '';
const d = new Date(ms);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function formatPosition(pos) {
if (!pos || pos.length < 2) return '';
const lon = pos[0];
const lat = pos[1];
const latDir = lat >= 0 ? 'N' : 'S';
const lonDir = lon >= 0 ? 'E' : 'W';
return `${Math.abs(lat).toFixed(4)}\u00B0${latDir} ${Math.abs(lon).toFixed(4)}\u00B0${lonDir}`;
}
function escapeCsvField(value) {
const str = String(value ?? '');
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
/**
* 검색 결과를 CSV로 내보내기 (다중 방문 동적 컬럼 지원)
*
* @param {Array} tracks ProcessedTrack 배열
* @param {Object} hitDetails { vesselId: [{ polygonId, visitIndex, entryTimestamp, exitTimestamp }] }
* @param {Array} zones 구역 배열
*/
export function exportSearchResultToCSV(tracks, hitDetails, zones) {
// 구역별 최대 방문 횟수 계산
const maxVisitsPerZone = {};
zones.forEach((z) => { maxVisitsPerZone[z.id] = 1; });
Object.values(hitDetails).forEach((hits) => {
const countByZone = {};
(Array.isArray(hits) ? hits : []).forEach((h) => {
countByZone[h.polygonId] = (countByZone[h.polygonId] || 0) + 1;
});
for (const [zoneId, count] of Object.entries(countByZone)) {
maxVisitsPerZone[zoneId] = Math.max(maxVisitsPerZone[zoneId] || 0, count);
}
});
// 헤더 구성
const baseHeaders = [
'신호원', '식별번호', '선박명', '선종', '국적',
'포인트수', '이동거리(NM)', '평균속도(kn)', '최대속도(kn)',
];
const zoneHeaders = [];
zones.forEach((zone) => {
const max = maxVisitsPerZone[zone.id] || 1;
if (max === 1) {
zoneHeaders.push(
`구역${zone.name}_진입시각`, `구역${zone.name}_진입위치`,
`구역${zone.name}_진출시각`, `구역${zone.name}_진출위치`,
);
} else {
for (let v = 1; v <= max; v++) {
zoneHeaders.push(
`구역${zone.name}_${v}차_진입시각`, `구역${zone.name}_${v}차_진입위치`,
`구역${zone.name}_${v}차_진출시각`, `구역${zone.name}_${v}차_진출위치`,
);
}
}
});
const headers = [...baseHeaders, ...zoneHeaders];
// 데이터 행 생성
const rows = tracks.map((track) => {
const baseRow = [
getSignalSourceName(track.sigSrcCd),
track.targetId || '',
track.shipName || '',
getShipKindName(track.shipKindCode),
track.nationalCode ? getCountryIsoCode(track.nationalCode) : '',
track.stats?.pointCount ?? track.geometry.length,
track.stats?.totalDistance != null ? track.stats.totalDistance.toFixed(2) : '',
track.stats?.avgSpeed != null ? track.stats.avgSpeed.toFixed(1) : '',
track.stats?.maxSpeed != null ? track.stats.maxSpeed.toFixed(1) : '',
];
const hits = hitDetails[track.vesselId] || [];
const zoneData = [];
zones.forEach((zone) => {
const max = maxVisitsPerZone[zone.id] || 1;
const zoneHits = hits
.filter((h) => h.polygonId === zone.id)
.sort((a, b) => (a.visitIndex || 1) - (b.visitIndex || 1));
for (let v = 0; v < max; v++) {
const hit = zoneHits[v];
if (hit) {
zoneData.push(
formatTimestamp(hit.entryTimestamp),
formatPosition(hit.entryPosition),
formatTimestamp(hit.exitTimestamp),
formatPosition(hit.exitPosition),
);
} else {
zoneData.push('', '', '', '');
}
}
});
return [...baseRow, ...zoneData];
});
// CSV 문자열 생성
const csvLines = [
headers.map(escapeCsvField).join(','),
...rows.map((row) => row.map(escapeCsvField).join(',')),
];
const csvContent = csvLines.join('\n');
// BOM + UTF-8 Blob 생성 및 다운로드
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
const filename = `항적분석_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.csv`;
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}

파일 보기

@ -0,0 +1,19 @@
/**
* STS 분석 레이어 전역 레지스트리
* 참조: src/areaSearch/utils/areaSearchLayerRegistry.js
*
* useStsLayer 훅이 레이어를 등록하면
* useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합
*/
export function registerStsLayers(layers) {
window.__stsLayers__ = layers;
}
export function getStsLayers() {
return window.__stsLayers__ || [];
}
export function unregisterStsLayers() {
window.__stsLayers__ = [];
}

파일 보기

@ -0,0 +1,12 @@
/**
* 구역 VectorSource/VectorLayer 모듈 스코프 참조
* useZoneDraw와 useZoneEdit 공유
*/
let _source = null;
let _layer = null;
export function setZoneSource(source) { _source = source; }
export function getZoneSource() { return _source; }
export function setZoneLayer(layer) { _layer = layer; }
export function getZoneLayer() { return _layer; }

파일 보기

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#8B98C5" d="M0 0h24v24H0z"/><g stroke="#fff"><path d="M9.904 6.718 10.997 2h2.899l1.073 4.718M6.667 11.875v-5.05h11.541V12"/><path d="M6.8 21.538 4 13.303l8.343-3.865 8.531 3.73-3.146 8.37M12.437 9.704v11.488M4.507 21.698h15.86M8.14 3.92h8.596"/></g></svg>

Before

Width:  |  Height:  |  크기: 343 B

파일 보기

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" fill="none"><path fill="#0029FF" stroke="#000A62" stroke-linejoin="round" d="m16.985 2.84-6.717 25.482 7.299-7.327 7.753 6.853z"/></svg>

Before

Width:  |  Height:  |  크기: 199 B

파일 보기

@ -1,16 +0,0 @@
import HeaderComponent from "./wrap/HeaderComponent";
import MainComponent from "./wrap/MainComponent";
import SideComponent from "./wrap/SideComponent";
import ToolComponent from "./wrap/ToolComponent";
import { Routes, Route} from 'react-router-dom';
export default function WrapComponent(){
return(
<div id="wrap" className="wrap">
<HeaderComponent />
<SideComponent />
<MainComponent />
<ToolComponent />
</div>
)
}

파일 보기

@ -1,35 +0,0 @@
import React, { useState } from 'react';
export default function FileUpload({ label = "파일 선택", inputId, maxLength = 25, placeholder = "선택된 파일 없음" }) {
const [fileName, setFileName] = useState('');
//
const truncateMiddle = (str, maxLen) => {
if (!str) return '';
if (str.length <= maxLen) return str;
const keep = Math.floor((maxLen - 3) / 2);
return str.slice(0, keep) + '...' + str.slice(str.length - keep);
};
const handleChange = (e) => {
const name = e.target.files[0]?.name || '';
setFileName(name);
};
return (
<div className="fileWrap">
<input
type="file"
id={inputId}
className="fileInput"
onChange={handleChange}
/>
<label htmlFor={inputId} className="fileLabel">
{label}
</label>
<span className="fileName">
{fileName ? truncateMiddle(fileName, maxLength) : placeholder}
</span>
</div>
);
}

파일 보기

@ -1,24 +0,0 @@
import { useState } from "react";
function Slider({ label = "", min = 0, max = 100, defaultValue = 50 }) {
const [value, setValue] = useState(defaultValue);
const percent = ((value - min) / (max - min)) * 100;
return (
<label className="rangeWrap">
<span className="rangeLabel">{label}</span>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => setValue(Number(e.target.value))}
style={{ "--percent": `${percent}%` }}
aria-label={label}
/>
</label>
);
}
export default Slider;

파일 보기

@ -1,17 +0,0 @@
import { Link } from "react-router-dom";
export default function HeaderComponent() {
return(
<header id="header">
<div className="logoArea"><Link to="/" className="logo"><span className="blind">GIS 함정용</span></Link> <span className="logoTxt">GIS 함정용</span></div>
<aside>
<ul>
<li><Link to="/" className="alram" title="알람"><i className="badge"></i><span className="blind">알람</span></Link></li>
<li><Link to="/signal/custom" className="set" title="설정"><span className="blind">설정</span></Link></li>
<li><Link to="/mypage" className="user" title="마이페이지"><span className="blind">마이페이지</span></Link></li>
</ul>
</aside>
</header>
)
}

파일 보기

@ -1,51 +0,0 @@
import { Routes, Route } from "react-router-dom";
import TopComponent from "./main/TopComponent"; //
import ShipComponent from "./main/ShipComponent"; //
import Satellite1Component from "./main/Satellite1Component"; //
import Satellite2Component from "./main/Satellite2Component"; //
import Satellite3Component from "./main/Satellite3Component"; //
import Satellite4Component from "./main/Satellite4Component"; //
import WeatherComponent from "./main/WeatherComponent"; //
import Analysis1Component from "./main/Analysis1Component"; // -
import Analysis2Component from "./main/Analysis2Component"; // -
import Analysis3Component from "./main/Analysis3Component"; // -
// import Analysis4Component from "./main/Analysis4Component"; // -
import LayerComponent from "./main/LayerComponent"; //
import SignalComponent from "./main/Signal1Component"; //
import Signal2Component from "./main/Signal2Component"; //
import MyPageComponent from "./main/MyPageComponent"; //
export default function MainComponent() {
return (
<main id="main">
<TopComponent />
<Routes>
{/* 기본 화면 */}
<Route path="*" element={<ShipComponent />} />
<Route path="panel1/ship" element={<ShipComponent />} />
<Route path="panel2/satellite/add" element={<Satellite1Component />} />
<Route path="panel2/satellite/provider" element={<Satellite2Component />} />
<Route path="panel2/satellite/manage" element={<Satellite3Component />} />
<Route path="panel2/satellite/delete" element={<Satellite4Component />} />
<Route path="panel3/weather" element={<WeatherComponent />} />
<Route path="panel4/analysis/area" element={<Analysis1Component />} />
<Route path="panel4/analysis/result" element={<Analysis2Component />} />
<Route path="panel4/analysis/register" element={<Analysis3Component />} />
{/* <Route path="panel4/analysis/trench" element={<Analysis4Component />} /> */}
<Route path="display/layer/register" element={<LayerComponent />} />
<Route path="signal" element={<SignalComponent />} />
<Route path="signal/custom" element={<Signal2Component />} />
<Route path="mypage" element={<MyPageComponent />} />
</Routes>
</main>
);
}

파일 보기

@ -1,106 +0,0 @@
import { useState } from 'react';
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom';
import NavComponent from "./side/NavComponent";
import Panel1Component from "./side/Panel1Component"; //
import Panel2Component from "./side/Panel2Component"; //
import Panel3Component from "./side/Panel3Component"; //
import Panel4Component from "./side/Panel4Component"; //
import Panel5Component from "./side/Panel5Component"; //
import Panel6Component from "./side/Panel6Component"; // AI
import Panel7Component from "./side/Panel7Component"; //
import Panel8Component from "./side/Panel8Component"; //
import DisplayComponent from "./side/DisplayComponent"; //
export default function SideComponent() {
const navigate = useNavigate();
const location = useLocation();
/* =========================
패널 열림 상태 (단일 관리)
========================= */
const [isPanelOpen, setIsPanelOpen] = useState(true);
const handleTogglePanel = () => {
setIsPanelOpen(prev => !prev);
};
/* =========================
URL activeKey 매핑
========================= */
const getActiveKey = () => {
const path = location.pathname.split('/')[1];
switch (path) {
case 'panel1': return 'gnb1';
case 'panel2': return 'gnb2';
case 'panel3': return 'gnb3';
case 'panel4': return 'gnb4';
case 'panel5': return 'gnb5';
case 'panel6': return 'gnb6';
case 'panel7': return 'gnb7';
case 'panel8': return 'gnb8';
case 'filter': return 'filter';
case 'layer': return 'layer';
default: return 'gnb1';
}
};
const activeKey = getActiveKey();
/* =========================
네비 클릭 라우트 이동
패널은 닫지 않음
========================= */
const handleChangePanel = (key) => {
//
setIsPanelOpen(true);
switch (key) {
case 'gnb1': navigate('/panel1'); break;
case 'gnb2': navigate('/panel2'); break;
case 'gnb3': navigate('/panel3'); break;
case 'gnb4': navigate('/panel4'); break;
case 'gnb5': navigate('/panel5'); break;
case 'gnb6': navigate('/panel6'); break;
case 'gnb7': navigate('/panel7'); break;
case 'gnb8': navigate('/panel8'); break;
case 'filter': navigate('/filter'); break;
case 'layer': navigate('/layer'); break;
default: navigate('/panel1'); break;
}
};
/* =========================
공통 패널 props
========================= */
const panelProps = {
isOpen: isPanelOpen,
onToggle: handleTogglePanel,
};
return (
<section id="sidePanel">
<NavComponent
activeKey={activeKey}
onChange={handleChangePanel}
/>
<div className="sidePanelContent">
<Routes>
{/* 초기 진입 시 Panel1 */}
<Route index element={<Navigate to="/panel1" replace />} />
<Route path="panel1/*" element={<Panel1Component {...panelProps} />} />
<Route path="panel2/*" element={<Panel2Component {...panelProps} />} />
<Route path="panel3/*" element={<Panel3Component {...panelProps} />} />
<Route path="panel4/*" element={<Panel4Component {...panelProps} />} />
<Route path="panel5/*" element={<Panel5Component {...panelProps} />} />
<Route path="panel6/*" element={<Panel6Component {...panelProps} />} />
<Route path="panel7/*" element={<Panel7Component {...panelProps} />} />
<Route path="panel8/*" element={<Panel8Component {...panelProps} />} />
<Route path="filter/*" element={<DisplayComponent {...panelProps} />} />
<Route path="layer/*" element={<DisplayComponent {...panelProps} />} />
</Routes>
</div>
</section>
);
}

Some files were not shown because too many files have changed in this diff Show More