Compare commits
13 커밋
e79c50baea
...
ac3c204843
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| ac3c204843 | |||
|
|
086599bb6d | ||
|
|
8ccb261d65 | ||
|
|
059b0670fc | ||
|
|
de2cd907f1 | ||
|
|
34d5f6ef9e | ||
|
|
1c36789612 | ||
|
|
4945606c1c | ||
|
|
81255c4839 | ||
|
|
1c991d8229 | ||
| ecfc25edde | |||
|
|
5d7a45984a | ||
|
|
dcf24e96d2 |
69
.claude/rules/code-style.md
Normal file
69
.claude/rules/code-style.md
Normal file
@ -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/`에 관리
|
||||
84
.claude/rules/git-workflow.md
Normal file
84
.claude/rules/git-workflow.md
Normal file
@ -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
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
|
||||
```
|
||||
34
.claude/rules/team-policy.md
Normal file
34
.claude/rules/team-policy.md
Normal file
@ -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
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
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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
65
.claude/skills/create-mr/SKILL.md
Normal file
65
.claude/skills/create-mr/SKILL.md
Normal file
@ -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 접근 토큰 (없으면 안내)
|
||||
49
.claude/skills/fix-issue/SKILL.md
Normal file
49
.claude/skills/fix-issue/SKILL.md
Normal file
@ -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 접근 토큰
|
||||
90
.claude/skills/init-project/SKILL.md
Normal file
90
.claude/skills/init-project/SKILL.md
Normal file
@ -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` 확인
|
||||
- 빌드 명령 실행 가능 확인
|
||||
- 다음 단계 안내 (개발 시작, 첫 커밋 방법 등)
|
||||
73
.claude/skills/sync-team-workflow/SKILL.md
Normal file
73
.claude/skills/sync-team-workflow/SKILL.md
Normal file
@ -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) 표시
|
||||
- 필요한 추가 조치 안내 (빌드 확인, 의존성 업데이트 등)
|
||||
1
.claude/workflow-version.json
Normal file
1
.claude/workflow-version.json
Normal file
@ -0,0 +1 @@
|
||||
{"applied_global_version": "1.2.0"}
|
||||
33
.editorconfig
Normal file
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
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
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
3
.gitattributes
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# 오프라인 캐시 바이너리 보호 (Windows autocrlf 변환 방지)
|
||||
*.tgz binary
|
||||
|
||||
60
.githooks/commit-msg
Executable file
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
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
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
36
.gitignore
vendored
@ -2,18 +2,13 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
|
||||
# Offline cache - git에 포함 (폐쇄망 배포용)
|
||||
!.yarn-offline-cache/
|
||||
|
||||
docs
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
# Environment (production secrets)
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@ -24,25 +19,20 @@ build/
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
# OS
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
*.md
|
||||
!README.md
|
||||
# Claude Code (개인 파일만 무시, 팀 파일은 추적)
|
||||
.claude/settings.local.json
|
||||
.claude/scripts/
|
||||
|
||||
# TypeScript files (메인 프로젝트 참조용, 빌드/커밋 제외)
|
||||
**/*.ts
|
||||
**/*.tsx
|
||||
# tracking VesselListManager (참조용 전체 제외 - replay 패키지에서 별도 구현)
|
||||
# tracking VesselListManager (참조용)
|
||||
src/tracking/components/VesselListManager/
|
||||
# 단, d.ts 타입 선언 파일은 필요시 포함 가능
|
||||
# !**/*.d.ts
|
||||
|
||||
# Publish 폴더 (퍼블리시 원본 포함, 없어도 빌드 가능)
|
||||
nul
|
||||
확인요청.txt
|
||||
|
||||
# Build artifacts
|
||||
*.zip
|
||||
httpd.conf
|
||||
1
.node-version
Normal file
1
.node-version
Normal file
@ -0,0 +1 @@
|
||||
24
|
||||
BIN
.yarn-offline-cache/@repeaterjs-repeater-3.0.6.tgz
Normal file
BIN
.yarn-offline-cache/@repeaterjs-repeater-3.0.6.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/@types-rbush-4.0.0.tgz
Normal file
BIN
.yarn-offline-cache/@types-rbush-4.0.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/@zarrita-storage-0.1.4.tgz
Normal file
BIN
.yarn-offline-cache/@zarrita-storage-0.1.4.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/base64-arraybuffer-1.0.2.tgz
Normal file
BIN
.yarn-offline-cache/base64-arraybuffer-1.0.2.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/css-line-break-2.1.0.tgz
Normal file
BIN
.yarn-offline-cache/css-line-break-2.1.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/earcut-3.0.2.tgz
Normal file
BIN
.yarn-offline-cache/earcut-3.0.2.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/fflate-0.8.2.tgz
Normal file
BIN
.yarn-offline-cache/fflate-0.8.2.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/flatbuffers-25.9.23.tgz
Normal file
BIN
.yarn-offline-cache/flatbuffers-25.9.23.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/flatgeobuf-4.4.0.tgz
Normal file
BIN
.yarn-offline-cache/flatgeobuf-4.4.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/geotiff-3.0.2.tgz
Normal file
BIN
.yarn-offline-cache/geotiff-3.0.2.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/html2canvas-1.4.1.tgz
Normal file
BIN
.yarn-offline-cache/html2canvas-1.4.1.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/numcodecs-0.3.2.tgz
Normal file
BIN
.yarn-offline-cache/numcodecs-0.3.2.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/ol-10.8.0.tgz
Normal file
BIN
.yarn-offline-cache/ol-10.8.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/pbf-4.0.1.tgz
Normal file
BIN
.yarn-offline-cache/pbf-4.0.1.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/quickselect-3.0.0.tgz
Normal file
BIN
.yarn-offline-cache/quickselect-3.0.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/rbush-4.0.1.tgz
Normal file
BIN
.yarn-offline-cache/rbush-4.0.1.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/reference-spec-reader-0.2.0.tgz
Normal file
BIN
.yarn-offline-cache/reference-spec-reader-0.2.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/slice-source-0.4.1.tgz
Normal file
BIN
.yarn-offline-cache/slice-source-0.4.1.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/text-segmentation-1.0.3.tgz
Normal file
BIN
.yarn-offline-cache/text-segmentation-1.0.3.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/unzipit-1.4.3.tgz
Normal file
BIN
.yarn-offline-cache/unzipit-1.4.3.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/utrie-1.0.2.tgz
Normal file
BIN
.yarn-offline-cache/utrie-1.0.2.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/uzip-module-1.0.3.tgz
Normal file
BIN
.yarn-offline-cache/uzip-module-1.0.3.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/zarrita-0.6.1.tgz
Normal file
BIN
.yarn-offline-cache/zarrita-0.6.1.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/zstddec-0.2.0.tgz
Normal file
BIN
.yarn-offline-cache/zstddec-0.2.0.tgz
Normal file
Binary file not shown.
62
CLAUDE.md
Normal file
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",
|
||||
"axios": "^1.4.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"flatgeobuf": "^4.4.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"ol": "^9.2.4",
|
||||
"ol-ext": "^4.0.10",
|
||||
"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::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 { position: relative; }
|
||||
.dateInput::-webkit-calendar-picker-indicator { opacity: 0; position: absolute; right: 0; width: 3rem; height: 100%;cursor: pointer; }
|
||||
.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 .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 .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;
|
||||
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 > .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);}
|
||||
|
||||
@ -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
|
||||
43
src/App.jsx
43
src/App.jsx
@ -1,51 +1,18 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
// 구현 영역 - 레이아웃
|
||||
import MainLayout from './components/layout/MainLayout';
|
||||
import SessionGuard from './components/auth/SessionGuard';
|
||||
import { ToastContainer } from './components/common/Toast';
|
||||
|
||||
// 퍼블리시 영역 (개발 환경에서만 동적 로드)
|
||||
// 프로덕션 빌드 시 tree-shaking으로 제외됨
|
||||
const PublishRouter = import.meta.env.DEV
|
||||
? lazy(() =>
|
||||
import('./publish').catch(() => ({
|
||||
default: () => (
|
||||
<div style={{ color: '#fff', padding: '2rem' }}>
|
||||
publish 폴더가 없습니다. 퍼블리시 파일을 추가하면 자동으로 활성화됩니다.
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
)
|
||||
: null;
|
||||
import { AlertModalContainer } from './components/common/AlertModal';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<SessionGuard>
|
||||
<ToastContainer />
|
||||
<AlertModalContainer />
|
||||
<Routes>
|
||||
{/* =====================
|
||||
구현 영역 (메인)
|
||||
- 모든 메뉴 경로를 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>
|
||||
</>
|
||||
</SessionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
134
src/api/aisTargetApi.js
Normal file
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
|
||||
*/
|
||||
import { fetchWithAuth } from './fetchWithAuth';
|
||||
|
||||
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) {
|
||||
try {
|
||||
const response = await fetch(COMMON_CODE_LIST_ENDPOINT, {
|
||||
const response = await fetchWithAuth(COMMON_CODE_LIST_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ commonCodeTypeNumber }),
|
||||
});
|
||||
|
||||
|
||||
27
src/api/favoriteApi.js
Normal file
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
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 형태로 변환)
|
||||
*/
|
||||
import useShipStore from '../stores/shipStore';
|
||||
import { fetchWithAuth } from './fetchWithAuth';
|
||||
|
||||
/** API 엔드포인트 (메인 프로젝트와 동일) */
|
||||
const API_ENDPOINT = '/api/v2/tracks/vessels';
|
||||
@ -31,10 +32,9 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati
|
||||
isIntegration: isIntegration ? '1' : '0',
|
||||
};
|
||||
|
||||
const response = await fetch(API_ENDPOINT, {
|
||||
const response = await fetchWithAuth(API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
|
||||
32
src/api/userSettingApi.js
Normal file
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;
|
||||
}
|
||||
}
|
||||
405
src/areaSearch/components/AreaSearchPage.jsx
Normal file
405
src/areaSearch/components/AreaSearchPage.jsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
389
src/areaSearch/components/AreaSearchPage.scss
Normal file
389
src/areaSearch/components/AreaSearchPage.scss
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
188
src/areaSearch/components/AreaSearchTab.jsx
Normal file
188
src/areaSearch/components/AreaSearchTab.jsx
Normal file
@ -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="상세 보기"
|
||||
>
|
||||
▶
|
||||
</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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
265
src/areaSearch/components/AreaSearchTimeline.jsx
Normal file
265
src/areaSearch/components/AreaSearchTimeline.jsx
Normal file
@ -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="닫기">
|
||||
✕
|
||||
</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="정지"
|
||||
>
|
||||
■
|
||||
</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>
|
||||
);
|
||||
}
|
||||
362
src/areaSearch/components/AreaSearchTimeline.scss
Normal file
362
src/areaSearch/components/AreaSearchTimeline.scss
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
143
src/areaSearch/components/AreaSearchTooltip.jsx
Normal file
143
src/areaSearch/components/AreaSearchTooltip.jsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
118
src/areaSearch/components/AreaSearchTooltip.scss
Normal file
118
src/areaSearch/components/AreaSearchTooltip.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
140
src/areaSearch/components/StsAnalysisTab.jsx
Normal file
140
src/areaSearch/components/StsAnalysisTab.jsx
Normal file
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
94
src/areaSearch/components/StsAnalysisTab.scss
Normal file
94
src/areaSearch/components/StsAnalysisTab.scss
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
489
src/areaSearch/components/StsContactDetailModal.jsx
Normal file
489
src/areaSearch/components/StsContactDetailModal.jsx
Normal file
@ -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}>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
319
src/areaSearch/components/StsContactDetailModal.scss
Normal file
319
src/areaSearch/components/StsContactDetailModal.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
261
src/areaSearch/components/StsContactList.jsx
Normal file
261
src/areaSearch/components/StsContactList.jsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
276
src/areaSearch/components/StsContactList.scss
Normal file
276
src/areaSearch/components/StsContactList.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
459
src/areaSearch/components/VesselDetailModal.jsx
Normal file
459
src/areaSearch/components/VesselDetailModal.jsx
Normal file
@ -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;
|
||||
// 시퀀스 번호 순 정렬 후 IN→OUT 순서
|
||||
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}>
|
||||
×
|
||||
</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,
|
||||
);
|
||||
}
|
||||
224
src/areaSearch/components/VesselDetailModal.scss
Normal file
224
src/areaSearch/components/VesselDetailModal.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
166
src/areaSearch/components/ZoneDrawPanel.jsx
Normal file
166
src/areaSearch/components/ZoneDrawPanel.jsx
Normal file
@ -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="드래그하여 순서 변경">≡</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="구역 삭제"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
src/areaSearch/components/ZoneDrawPanel.scss
Normal file
148
src/areaSearch/components/ZoneDrawPanel.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
217
src/areaSearch/hooks/useAreaSearchLayer.js
Normal file
217
src/areaSearch/hooks/useAreaSearchLayer.js
Normal file
@ -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 = [];
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
288
src/areaSearch/hooks/useStsLayer.js
Normal file
288
src/areaSearch/hooks/useStsLayer.js
Normal file
@ -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 = [];
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
261
src/areaSearch/hooks/useZoneDraw.js
Normal file
261
src/areaSearch/hooks/useZoneDraw.js
Normal file
@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 구역 그리기 OpenLayers Draw 인터랙션 훅
|
||||
*
|
||||
* - activeDrawType 변경 시 Draw 인터랙션 활성화
|
||||
* - Polygon / Box / Circle 그리기
|
||||
* - drawend → EPSG:3857→4326 변환 → 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;
|
||||
}, []);
|
||||
}
|
||||
513
src/areaSearch/hooks/useZoneEdit.js
Normal file
513
src/areaSearch/hooks/useZoneEdit.js
Normal file
@ -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]);
|
||||
}
|
||||
149
src/areaSearch/interactions/BoxResizeInteraction.js
Normal file
149
src/areaSearch/interactions/BoxResizeInteraction.js
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
90
src/areaSearch/interactions/CircleResizeInteraction.js
Normal file
90
src/areaSearch/interactions/CircleResizeInteraction.js
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
112
src/areaSearch/services/areaSearchApi.js
Normal file
112
src/areaSearch/services/areaSearchApi.js
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
80
src/areaSearch/services/stsApi.js
Normal file
80
src/areaSearch/services/stsApi.js
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
117
src/areaSearch/stores/areaSearchAnimationStore.js
Normal file
117
src/areaSearch/stores/areaSearchAnimationStore.js
Normal file
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}));
|
||||
317
src/areaSearch/stores/areaSearchStore.js
Normal file
317
src/areaSearch/stores/areaSearchStore.js
Normal file
@ -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;
|
||||
275
src/areaSearch/stores/stsStore.js
Normal file
275
src/areaSearch/stores/stsStore.js
Normal file
@ -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;
|
||||
101
src/areaSearch/types/areaSearch.types.js
Normal file
101
src/areaSearch/types/areaSearch.types.js
Normal file
@ -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',
|
||||
};
|
||||
106
src/areaSearch/types/sts.types.js
Normal file
106
src/areaSearch/types/sts.types.js
Normal file
@ -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];
|
||||
}
|
||||
19
src/areaSearch/utils/areaSearchLayerRegistry.js
Normal file
19
src/areaSearch/utils/areaSearchLayerRegistry.js
Normal file
@ -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__ = [];
|
||||
}
|
||||
140
src/areaSearch/utils/csvExport.js
Normal file
140
src/areaSearch/utils/csvExport.js
Normal file
@ -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);
|
||||
}
|
||||
19
src/areaSearch/utils/stsLayerRegistry.js
Normal file
19
src/areaSearch/utils/stsLayerRegistry.js
Normal file
@ -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__ = [];
|
||||
}
|
||||
12
src/areaSearch/utils/zoneLayerRefs.js
Normal file
12
src/areaSearch/utils/zoneLayerRefs.js
Normal file
@ -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
불러오는 중...
Reference in New Issue
Block a user