Compare commits

..

No commits in common. "ac3c2048432e402b372552b463c47bb40f73d6c9" and "e79c50baead73f9340e9dd8cfa6a49cc641cf2d4" have entirely different histories.

223개의 변경된 파일16464개의 추가작업 그리고 10083개의 파일을 삭제

파일 보기

@ -1,69 +0,0 @@
# 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/`에 관리

파일 보기

@ -1,84 +0,0 @@
# 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 권장 (깔끔한 히스토리)
- 머지 후 소스 브랜치 삭제

파일 보기

@ -1,53 +0,0 @@
# 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
```

파일 보기

@ -1,34 +0,0 @@
# 팀 정책 (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에 프로젝트 빌드/실행 방법 유지

파일 보기

@ -1,64 +0,0 @@
# 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` 사용

파일 보기

@ -1,78 +0,0 @@
{
"$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
}
]
}
]
}
}

파일 보기

@ -1,65 +0,0 @@
---
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 접근 토큰 (없으면 안내)

파일 보기

@ -1,49 +0,0 @@
---
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 접근 토큰

파일 보기

@ -1,90 +0,0 @@
---
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` 확인
- 빌드 명령 실행 가능 확인
- 다음 단계 안내 (개발 시작, 첫 커밋 방법 등)

파일 보기

@ -1,73 +0,0 @@
---
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 +0,0 @@
{"applied_global_version": "1.2.0"}

파일 보기

@ -1,33 +0,0 @@
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
파일 보기

@ -1,16 +0,0 @@
# ============================================
# 프로덕션 환경 (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

파일 보기

@ -1,16 +0,0 @@
# ============================================
# 로컬 개발 환경 (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
파일 보기

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

파일 보기

@ -1,60 +0,0 @@
#!/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

파일 보기

@ -1,25 +0,0 @@
#!/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

파일 보기

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

36
.gitignore vendored
파일 보기

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

파일 보기

@ -1 +0,0 @@
24

1
.nvmrc
파일 보기

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

파일 보기

@ -1,62 +0,0 @@
# 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,8 +23,6 @@
"@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,8 +71,6 @@
.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); }
/* =========================
@ -109,15 +107,6 @@
.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);}
@ -303,7 +292,7 @@
align-items: center;
z-index: 999;
}
.popupUtillWrap { position: fixed;top: 50%; left: 50%;transform: translate(-50%, -50%);z-index :100;}
.popupUtillWrap { position: absolute;top: 50%; left: 50%;transform: translate(-50%, -50%);z-index :85;}
.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);}

78
setup-windows.bat Normal file
파일 보기

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

파일 보기

@ -1,18 +1,51 @@
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';
import { AlertModalContainer } from './components/common/AlertModal';
// ( )
// tree-shaking
const PublishRouter = import.meta.env.DEV
? lazy(() =>
import('./publish').catch(() => ({
default: () => (
<div style={{ color: '#fff', padding: '2rem' }}>
publish 폴더가 없습니다. 퍼블리시 파일을 추가하면 자동으로 활성화됩니다.
</div>
),
}))
)
: null;
export default function App() {
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>
</>
);
}

파일 보기

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

파일 보기

@ -1,27 +0,0 @@
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 || [];
}

파일 보기

@ -1,41 +0,0 @@
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;
}

481
src/api/satelliteApi.js Normal file
파일 보기

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

파일 보기

@ -1,32 +0,0 @@
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();
}

309
src/api/weatherApi.js Normal file
파일 보기

@ -0,0 +1,309 @@
/**
* 기상해양 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;
}
}

파일 보기

@ -1,405 +0,0 @@
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>
);
}

파일 보기

@ -1,389 +0,0 @@
.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);
}
}
}
}
}
}
}
}

파일 보기

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

파일 보기

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

파일 보기

@ -1,362 +0,0 @@
.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);
}
}

파일 보기

@ -1,143 +0,0 @@
/**
* 항적분석 호버 툴팁 컴포넌트
* - 선박 기본 정보 (선종, 선명, 신호원)
* - 시간순 방문 이력 (구역 무관, 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>
);
}

파일 보기

@ -1,118 +0,0 @@
.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;
}
}

파일 보기

@ -1,140 +0,0 @@
/**
* 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}
/>
)}
</>
);
}

파일 보기

@ -1,94 +0,0 @@
// 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);
}
}

파일 보기

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

파일 보기

@ -1,319 +0,0 @@
.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;
}
}
}

파일 보기

@ -1,261 +0,0 @@
/**
* 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>
);
}

파일 보기

@ -1,276 +0,0 @@
.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;
}
}

파일 보기

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

파일 보기

@ -1,224 +0,0 @@
.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;
}
}
}

파일 보기

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

파일 보기

@ -1,148 +0,0 @@
.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;
}
}
}
}
}

파일 보기

@ -1,217 +0,0 @@
/**
* 항적분석 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 = [];
};
}, []);
}

파일 보기

@ -1,288 +0,0 @@
/**
* 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 = [];
};
}, []);
}

파일 보기

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

파일 보기

@ -1,513 +0,0 @@
/**
* 구역 편집 인터랙션
*
* - 클릭으로 구역 선택/해제
* - 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]);
}

파일 보기

@ -1,149 +0,0 @@
/**
* 사각형(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;
}
}

파일 보기

@ -1,90 +0,0 @@
/**
* (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;
}
}

파일 보기

@ -1,112 +0,0 @@
/**
* 항적분석(구역 검색) 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,
};
}

파일 보기

@ -1,80 +0,0 @@
/**
* 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,
};
}

파일 보기

@ -1,117 +0,0 @@
/**
* 항적분석 전용 애니메이션 스토어
* 참조: 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,
});
},
};
}));

파일 보기

@ -1,317 +0,0 @@
/**
* 항적분석(구역 검색) 메인 상태 관리 스토어
*
* - 구역 관리 (추가/삭제/순서변경, 최대 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;

파일 보기

@ -1,275 +0,0 @@
/**
* 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;

파일 보기

@ -1,101 +0,0 @@
/**
* 항적분석(구역 검색) 상수 타입 정의
*/
// ========== 분석 탭 ==========
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',
};

파일 보기

@ -1,106 +0,0 @@
/**
* 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];
}

파일 보기

@ -1,19 +0,0 @@
/**
* 항적분석 레이어 전역 레지스트리
* 참조: 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__ = [];
}

파일 보기

@ -1,140 +0,0 @@
/**
* 항적분석 검색 결과 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);
}

파일 보기

@ -1,19 +0,0 @@
/**
* 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__ = [];
}

파일 보기

@ -1,12 +0,0 @@
/**
* 구역 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; }

파일 보기

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  크기: 343 B

파일 보기

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  크기: 199 B

파일 보기

@ -0,0 +1,16 @@
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>
)
}

파일 보기

@ -0,0 +1,35 @@
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>
);
}

파일 보기

@ -0,0 +1,24 @@
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;

파일 보기

@ -0,0 +1,17 @@
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>
)
}

파일 보기

@ -0,0 +1,51 @@
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>
);
}

파일 보기

@ -0,0 +1,106 @@
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