Compare commits
1 커밋
develop
...
feature/oc
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 5591ed5504 |
69
.claude/rules/code-style.md
Normal file
69
.claude/rules/code-style.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# TypeScript/React 코드 스타일 규칙
|
||||||
|
|
||||||
|
## TypeScript 일반
|
||||||
|
- strict 모드 필수 (`tsconfig.json`)
|
||||||
|
- `any` 사용 금지 (불가피한 경우 주석으로 사유 명시)
|
||||||
|
- 타입 정의: `interface` 우선 (type은 유니온/인터섹션에만)
|
||||||
|
- 들여쓰기: 2 spaces
|
||||||
|
- 세미콜론: 사용
|
||||||
|
- 따옴표: single quote
|
||||||
|
- trailing comma: 사용
|
||||||
|
|
||||||
|
## React 규칙
|
||||||
|
|
||||||
|
### 컴포넌트
|
||||||
|
- 함수형 컴포넌트 + hooks 패턴만 사용
|
||||||
|
- 클래스 컴포넌트 사용 금지
|
||||||
|
- 컴포넌트 파일 당 하나의 export default 컴포넌트
|
||||||
|
- Props 타입은 interface로 정의 (ComponentNameProps)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface UserCardProps {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
onEdit?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserCard = ({ name, email, onEdit }: UserCardProps) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3>{name}</h3>
|
||||||
|
<p>{email}</p>
|
||||||
|
{onEdit && <button onClick={onEdit}>편집</button>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserCard;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hooks
|
||||||
|
- 커스텀 훅은 `use` 접두사 (예: `useAuth`, `useFetch`)
|
||||||
|
- 훅은 `src/hooks/` 디렉토리에 분리
|
||||||
|
- 복잡한 상태 로직은 커스텀 훅으로 추출
|
||||||
|
|
||||||
|
### 상태 관리
|
||||||
|
- 컴포넌트 로컬 상태: `useState`
|
||||||
|
- 공유 상태: Context API 또는 Zustand
|
||||||
|
- 서버 상태: React Query (TanStack Query) 권장
|
||||||
|
|
||||||
|
### 이벤트 핸들러
|
||||||
|
- `handle` 접두사: `handleClick`, `handleSubmit`
|
||||||
|
- Props로 전달 시 `on` 접두사: `onClick`, `onSubmit`
|
||||||
|
|
||||||
|
## 스타일링
|
||||||
|
- CSS Modules 또는 Tailwind CSS (프로젝트 설정에 따름)
|
||||||
|
- 인라인 스타일 지양
|
||||||
|
- !important 사용 금지
|
||||||
|
|
||||||
|
## API 호출
|
||||||
|
- API 호출 로직은 `src/services/`에 분리
|
||||||
|
- Axios 또는 fetch wrapper 사용
|
||||||
|
- 에러 처리: try-catch + 사용자 친화적 에러 메시지
|
||||||
|
- 환경별 API URL은 `.env`에서 관리
|
||||||
|
|
||||||
|
## 기타
|
||||||
|
- console.log 커밋 금지 (디버깅 후 제거)
|
||||||
|
- 매직 넘버/문자열 → 상수 파일로 추출
|
||||||
|
- 사용하지 않는 import, 변수 제거 (ESLint로 검증)
|
||||||
|
- 이미지/아이콘은 `src/assets/`에 관리
|
||||||
84
.claude/rules/git-workflow.md
Normal file
84
.claude/rules/git-workflow.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# Git 워크플로우 규칙
|
||||||
|
|
||||||
|
## 브랜치 전략
|
||||||
|
|
||||||
|
### 브랜치 구조
|
||||||
|
```
|
||||||
|
main ← 배포 가능한 안정 브랜치 (보호됨)
|
||||||
|
└── develop ← 개발 통합 브랜치
|
||||||
|
├── feature/ISSUE-123-기능설명
|
||||||
|
├── bugfix/ISSUE-456-버그설명
|
||||||
|
└── hotfix/ISSUE-789-긴급수정
|
||||||
|
```
|
||||||
|
|
||||||
|
### 브랜치 네이밍
|
||||||
|
- feature 브랜치: `feature/ISSUE-번호-간단설명` (예: `feature/ISSUE-42-user-login`)
|
||||||
|
- bugfix 브랜치: `bugfix/ISSUE-번호-간단설명`
|
||||||
|
- hotfix 브랜치: `hotfix/ISSUE-번호-간단설명`
|
||||||
|
- 이슈 번호가 없는 경우: `feature/간단설명` (예: `feature/add-swagger-docs`)
|
||||||
|
|
||||||
|
### 브랜치 규칙
|
||||||
|
- main, develop 브랜치에 직접 커밋/푸시 금지
|
||||||
|
- feature 브랜치는 develop에서 분기
|
||||||
|
- hotfix 브랜치는 main에서 분기
|
||||||
|
- 머지는 반드시 MR(Merge Request)을 통해 수행
|
||||||
|
|
||||||
|
## 커밋 메시지 규칙
|
||||||
|
|
||||||
|
### Conventional Commits 형식
|
||||||
|
```
|
||||||
|
type(scope): subject
|
||||||
|
|
||||||
|
body (선택)
|
||||||
|
|
||||||
|
footer (선택)
|
||||||
|
```
|
||||||
|
|
||||||
|
### type (필수)
|
||||||
|
| type | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| feat | 새로운 기능 추가 |
|
||||||
|
| fix | 버그 수정 |
|
||||||
|
| docs | 문서 변경 |
|
||||||
|
| style | 코드 포맷팅 (기능 변경 없음) |
|
||||||
|
| refactor | 리팩토링 (기능 변경 없음) |
|
||||||
|
| test | 테스트 추가/수정 |
|
||||||
|
| chore | 빌드, 설정 변경 |
|
||||||
|
| ci | CI/CD 설정 변경 |
|
||||||
|
| perf | 성능 개선 |
|
||||||
|
|
||||||
|
### scope (선택)
|
||||||
|
- 변경 범위를 나타내는 짧은 단어
|
||||||
|
- 한국어, 영어 모두 허용 (예: `feat(인증): 로그인 기능`, `fix(auth): token refresh`)
|
||||||
|
|
||||||
|
### subject (필수)
|
||||||
|
- 변경 내용을 간결하게 설명
|
||||||
|
- 한국어, 영어 모두 허용
|
||||||
|
- 72자 이내
|
||||||
|
- 마침표(.) 없이 끝냄
|
||||||
|
|
||||||
|
### 예시
|
||||||
|
```
|
||||||
|
feat(auth): JWT 기반 로그인 구현
|
||||||
|
fix(배치): 야간 배치 타임아웃 수정
|
||||||
|
docs: README에 빌드 방법 추가
|
||||||
|
refactor(user-service): 중복 로직 추출
|
||||||
|
test(결제): 환불 로직 단위 테스트 추가
|
||||||
|
chore: Gradle 의존성 버전 업데이트
|
||||||
|
```
|
||||||
|
|
||||||
|
## MR(Merge Request) 규칙
|
||||||
|
|
||||||
|
### MR 생성
|
||||||
|
- 제목: 커밋 메시지와 동일한 Conventional Commits 형식
|
||||||
|
- 본문: 변경 내용 요약, 테스트 방법, 관련 이슈 번호
|
||||||
|
- 라벨: 적절한 라벨 부착 (feature, bugfix, hotfix 등)
|
||||||
|
|
||||||
|
### MR 리뷰
|
||||||
|
- 최소 1명의 리뷰어 승인 필수
|
||||||
|
- CI 검증 통과 필수 (설정된 경우)
|
||||||
|
- 리뷰 코멘트 모두 해결 후 머지
|
||||||
|
|
||||||
|
### MR 머지
|
||||||
|
- Squash Merge 권장 (깔끔한 히스토리)
|
||||||
|
- 머지 후 소스 브랜치 삭제
|
||||||
53
.claude/rules/naming.md
Normal file
53
.claude/rules/naming.md
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# TypeScript/React 네이밍 규칙
|
||||||
|
|
||||||
|
## 파일명
|
||||||
|
|
||||||
|
| 항목 | 규칙 | 예시 |
|
||||||
|
|------|------|------|
|
||||||
|
| 컴포넌트 | PascalCase | `UserCard.tsx`, `LoginForm.tsx` |
|
||||||
|
| 페이지 | PascalCase | `Dashboard.tsx`, `UserList.tsx` |
|
||||||
|
| 훅 | camelCase + use 접두사 | `useAuth.ts`, `useFetch.ts` |
|
||||||
|
| 서비스 | camelCase | `userService.ts`, `authApi.ts` |
|
||||||
|
| 유틸리티 | camelCase | `formatDate.ts`, `validation.ts` |
|
||||||
|
| 타입 정의 | camelCase | `user.types.ts`, `api.types.ts` |
|
||||||
|
| 상수 | camelCase | `routes.ts`, `constants.ts` |
|
||||||
|
| 스타일 | 컴포넌트명 + .module | `UserCard.module.css` |
|
||||||
|
| 테스트 | 대상 + .test | `UserCard.test.tsx` |
|
||||||
|
|
||||||
|
## 변수/함수
|
||||||
|
|
||||||
|
| 항목 | 규칙 | 예시 |
|
||||||
|
|------|------|------|
|
||||||
|
| 변수 | camelCase | `userName`, `isLoading` |
|
||||||
|
| 함수 | camelCase | `getUserList`, `formatDate` |
|
||||||
|
| 상수 | UPPER_SNAKE_CASE | `MAX_RETRY`, `API_BASE_URL` |
|
||||||
|
| boolean 변수 | is/has/can/should 접두사 | `isActive`, `hasPermission` |
|
||||||
|
| 이벤트 핸들러 | handle 접두사 | `handleClick`, `handleSubmit` |
|
||||||
|
| 이벤트 Props | on 접두사 | `onClick`, `onSubmit` |
|
||||||
|
|
||||||
|
## 타입/인터페이스
|
||||||
|
|
||||||
|
| 항목 | 규칙 | 예시 |
|
||||||
|
|------|------|------|
|
||||||
|
| interface | PascalCase | `UserProfile`, `ApiResponse` |
|
||||||
|
| Props | 컴포넌트명 + Props | `UserCardProps`, `ButtonProps` |
|
||||||
|
| 응답 타입 | 도메인 + Response | `UserResponse`, `LoginResponse` |
|
||||||
|
| 요청 타입 | 동작 + Request | `CreateUserRequest` |
|
||||||
|
| Enum | PascalCase | `UserStatus`, `HttpMethod` |
|
||||||
|
| Enum 값 | UPPER_SNAKE_CASE | `ACTIVE`, `PENDING` |
|
||||||
|
| Generic | 단일 대문자 | `T`, `K`, `V` |
|
||||||
|
|
||||||
|
## 디렉토리
|
||||||
|
|
||||||
|
- 모두 kebab-case 또는 camelCase (프로젝트 통일)
|
||||||
|
- 예: `src/components/common/`, `src/hooks/`, `src/services/`
|
||||||
|
|
||||||
|
## 컴포넌트 구조 예시
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/user-card/
|
||||||
|
├── UserCard.tsx # 컴포넌트
|
||||||
|
├── UserCard.module.css # 스타일
|
||||||
|
├── UserCard.test.tsx # 테스트
|
||||||
|
└── index.ts # re-export
|
||||||
|
```
|
||||||
34
.claude/rules/team-policy.md
Normal file
34
.claude/rules/team-policy.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# 팀 정책 (Team Policy)
|
||||||
|
|
||||||
|
이 규칙은 조직 전체에 적용되는 필수 정책입니다.
|
||||||
|
프로젝트별 `.claude/rules/`에 추가 규칙을 정의할 수 있으나, 이 정책을 위반할 수 없습니다.
|
||||||
|
|
||||||
|
## 보안 정책
|
||||||
|
|
||||||
|
### 금지 행위
|
||||||
|
- `.env`, `.env.*`, `secrets/` 파일 읽기 및 내용 출력 금지
|
||||||
|
- 비밀번호, API 키, 토큰 등 민감 정보를 코드에 하드코딩 금지
|
||||||
|
- `git push --force`, `git reset --hard`, `git clean -fd` 실행 금지
|
||||||
|
- `rm -rf /`, `rm -rf ~`, `rm -rf .git` 등 파괴적 명령 실행 금지
|
||||||
|
- main/develop 브랜치에 직접 push 금지 (MR을 통해서만 머지)
|
||||||
|
|
||||||
|
### 인증 정보 관리
|
||||||
|
- 환경변수 또는 외부 설정 파일(`.env`, `application-local.yml`)로 관리
|
||||||
|
- 설정 파일은 `.gitignore`에 반드시 포함
|
||||||
|
- 예시 파일(`.env.example`, `application.yml.example`)만 커밋
|
||||||
|
|
||||||
|
## 코드 품질 정책
|
||||||
|
|
||||||
|
### 필수 검증
|
||||||
|
- 커밋 전 빌드(컴파일) 성공 확인
|
||||||
|
- 린트 경고 0개 유지 (CI에서도 검증)
|
||||||
|
- 테스트 코드가 있는 프로젝트는 테스트 통과 필수
|
||||||
|
|
||||||
|
### 코드 리뷰
|
||||||
|
- main 브랜치 머지 시 최소 1명 리뷰 필수
|
||||||
|
- 리뷰어 승인 없이 머지 불가
|
||||||
|
|
||||||
|
## 문서화 정책
|
||||||
|
- 공개 API(controller endpoint)에는 반드시 설명 주석 작성
|
||||||
|
- 복잡한 비즈니스 로직에는 의도를 설명하는 주석 작성
|
||||||
|
- README.md에 프로젝트 빌드/실행 방법 유지
|
||||||
64
.claude/rules/testing.md
Normal file
64
.claude/rules/testing.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# TypeScript/React 테스트 규칙
|
||||||
|
|
||||||
|
## 테스트 프레임워크
|
||||||
|
- Vitest (Vite 프로젝트) 또는 Jest
|
||||||
|
- React Testing Library (컴포넌트 테스트)
|
||||||
|
- MSW (Mock Service Worker, API 모킹)
|
||||||
|
|
||||||
|
## 테스트 구조
|
||||||
|
|
||||||
|
### 단위 테스트
|
||||||
|
- 유틸리티 함수, 커스텀 훅 테스트
|
||||||
|
- 외부 의존성 없이 순수 로직 검증
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('날짜를 YYYY-MM-DD 형식으로 변환한다', () => {
|
||||||
|
const result = formatDate(new Date('2026-02-14'));
|
||||||
|
expect(result).toBe('2026-02-14');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('유효하지 않은 날짜는 빈 문자열을 반환한다', () => {
|
||||||
|
const result = formatDate(new Date('invalid'));
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컴포넌트 테스트
|
||||||
|
- React Testing Library 사용
|
||||||
|
- 사용자 관점에서 테스트 (구현 세부사항이 아닌 동작 테스트)
|
||||||
|
- `getByRole`, `getByText` 등 접근성 기반 쿼리 우선
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
describe('UserCard', () => {
|
||||||
|
it('사용자 이름과 이메일을 표시한다', () => {
|
||||||
|
render(<UserCard name="홍길동" email="hong@test.com" />);
|
||||||
|
expect(screen.getByText('홍길동')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('hong@test.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('편집 버튼 클릭 시 onEdit 콜백을 호출한다', async () => {
|
||||||
|
const onEdit = vi.fn();
|
||||||
|
render(<UserCard name="홍길동" email="hong@test.com" onEdit={onEdit} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: '편집' }));
|
||||||
|
expect(onEdit).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 테스트 패턴
|
||||||
|
- **Arrange-Act-Assert** 구조
|
||||||
|
- 테스트 설명은 한국어로 작성 (`it('사용자 이름을 표시한다')`)
|
||||||
|
- 하나의 테스트에 하나의 검증
|
||||||
|
|
||||||
|
## 테스트 커버리지
|
||||||
|
- 새로 작성하는 유틸리티 함수: 테스트 필수
|
||||||
|
- 컴포넌트: 주요 상호작용 테스트 권장
|
||||||
|
- API 호출: MSW로 모킹하여 에러/성공 시나리오 테스트
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- 구현 세부사항 테스트 금지 (state 값 직접 확인 등)
|
||||||
|
- `getByTestId` 남용 금지 (접근성 쿼리 우선)
|
||||||
|
- 스냅샷 테스트 남용 금지 (변경에 취약)
|
||||||
|
- `setTimeout`으로 비동기 대기 금지 → `waitFor`, `findBy` 사용
|
||||||
14
.claude/scripts/on-commit.sh
Executable file
14
.claude/scripts/on-commit.sh
Executable file
@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")
|
||||||
|
if echo "$COMMAND" | grep -qE 'git commit'; then
|
||||||
|
cat <<RESP
|
||||||
|
{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"additionalContext": "커밋이 감지되었습니다. 다음을 수행하세요:\n1. docs/CHANGELOG.md에 변경 내역 추가\n2. memory/project-snapshot.md에서 변경된 부분 업데이트\n3. memory/project-history.md에 이번 변경사항 추가\n4. API 인터페이스 변경 시 memory/api-types.md 갱신\n5. 프로젝트에 lint 설정이 있다면 lint 결과를 확인하고 문제를 수정"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RESP
|
||||||
|
else
|
||||||
|
echo '{}'
|
||||||
|
fi
|
||||||
23
.claude/scripts/on-post-compact.sh
Executable file
23
.claude/scripts/on-post-compact.sh
Executable file
@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
CWD=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$CWD" ]; then
|
||||||
|
CWD=$(pwd)
|
||||||
|
fi
|
||||||
|
PROJECT_HASH=$(echo "$CWD" | sed 's|/|-|g')
|
||||||
|
MEMORY_DIR="$HOME/.claude/projects/$PROJECT_HASH/memory"
|
||||||
|
CONTEXT=""
|
||||||
|
if [ -f "$MEMORY_DIR/MEMORY.md" ]; then
|
||||||
|
SUMMARY=$(head -100 "$MEMORY_DIR/MEMORY.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="컨텍스트가 압축되었습니다.\\n\\n[세션 요약]\\n${SUMMARY}"
|
||||||
|
fi
|
||||||
|
if [ -f "$MEMORY_DIR/project-snapshot.md" ]; then
|
||||||
|
SNAP=$(head -50 "$MEMORY_DIR/project-snapshot.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n[프로젝트 최신 상태]\\n${SNAP}"
|
||||||
|
fi
|
||||||
|
if [ -n "$CONTEXT" ]; then
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n위 내용을 참고하여 작업을 이어가세요. 상세 내용은 memory/ 디렉토리의 각 파일을 참조하세요."
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"${CONTEXT}\"}}"
|
||||||
|
else
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"컨텍스트가 압축되었습니다. memory 파일이 없으므로 사용자에게 이전 작업 내용을 확인하세요.\"}}"
|
||||||
|
fi
|
||||||
8
.claude/scripts/on-pre-compact.sh
Executable file
8
.claude/scripts/on-pre-compact.sh
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가)
|
||||||
|
INPUT=$(cat)
|
||||||
|
cat <<RESP
|
||||||
|
{
|
||||||
|
"systemMessage": "컨텍스트 압축이 시작됩니다. 반드시 다음을 수행하세요:\n\n1. memory/MEMORY.md - 핵심 작업 상태 갱신 (200줄 이내)\n2. memory/project-snapshot.md - 변경된 패키지/타입 정보 업데이트\n3. memory/project-history.md - 이번 세션 변경사항 추가\n4. memory/api-types.md - API 인터페이스 변경이 있었다면 갱신\n5. 미완료 작업이 있다면 TodoWrite에 남기고 memory에도 기록"
|
||||||
|
}
|
||||||
|
RESP
|
||||||
@ -2,30 +2,30 @@
|
|||||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(curl -s *)",
|
"Bash(npm run *)",
|
||||||
"Bash(fnm *)",
|
|
||||||
"Bash(git add *)",
|
|
||||||
"Bash(git branch *)",
|
|
||||||
"Bash(git checkout *)",
|
|
||||||
"Bash(git commit *)",
|
|
||||||
"Bash(git config *)",
|
|
||||||
"Bash(git diff *)",
|
|
||||||
"Bash(git fetch *)",
|
|
||||||
"Bash(git log *)",
|
|
||||||
"Bash(git merge *)",
|
|
||||||
"Bash(git pull *)",
|
|
||||||
"Bash(git remote *)",
|
|
||||||
"Bash(git rev-parse *)",
|
|
||||||
"Bash(git show *)",
|
|
||||||
"Bash(git stash *)",
|
|
||||||
"Bash(git status)",
|
|
||||||
"Bash(git tag *)",
|
|
||||||
"Bash(node *)",
|
|
||||||
"Bash(npm -w *)",
|
"Bash(npm -w *)",
|
||||||
"Bash(npm install *)",
|
"Bash(npm install *)",
|
||||||
"Bash(npm run *)",
|
|
||||||
"Bash(npm test *)",
|
"Bash(npm test *)",
|
||||||
"Bash(npx *)"
|
"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": [
|
"deny": [
|
||||||
"Bash(git push --force*)",
|
"Bash(git push --force*)",
|
||||||
@ -81,8 +81,5 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"env": {
|
|
||||||
"CLAUDE_BOT_TOKEN": "ac15488ad66463bd5c4e3be1fa6dd5b2743813c5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
.claude/skills/create-mr/SKILL.md
Normal file
65
.claude/skills/create-mr/SKILL.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
name: create-mr
|
||||||
|
description: 현재 브랜치에서 Gitea MR(Merge Request)을 생성합니다
|
||||||
|
allowed-tools: "Bash, Read, Grep"
|
||||||
|
argument-hint: "[target-branch: develop|main] (기본: develop)"
|
||||||
|
---
|
||||||
|
|
||||||
|
현재 브랜치의 변경 사항을 기반으로 Gitea에 MR을 생성합니다.
|
||||||
|
타겟 브랜치: $ARGUMENTS (기본: develop)
|
||||||
|
|
||||||
|
## 수행 단계
|
||||||
|
|
||||||
|
### 1. 사전 검증
|
||||||
|
- 현재 브랜치가 main/develop이 아닌지 확인
|
||||||
|
- 커밋되지 않은 변경 사항 확인 (있으면 경고)
|
||||||
|
- 리모트에 현재 브랜치가 push되어 있는지 확인 (안 되어 있으면 push)
|
||||||
|
|
||||||
|
### 2. 변경 내역 분석
|
||||||
|
```bash
|
||||||
|
git log develop..HEAD --oneline
|
||||||
|
git diff develop..HEAD --stat
|
||||||
|
```
|
||||||
|
- 커밋 목록과 변경된 파일 목록 수집
|
||||||
|
- 주요 변경 사항 요약 작성
|
||||||
|
|
||||||
|
### 3. MR 정보 구성
|
||||||
|
- **제목**: 브랜치의 첫 커밋 메시지 또는 브랜치명에서 추출
|
||||||
|
- `feature/ISSUE-42-user-login` → `feat: ISSUE-42 user-login`
|
||||||
|
- **본문**:
|
||||||
|
```markdown
|
||||||
|
## 변경 사항
|
||||||
|
- (커밋 기반 자동 생성)
|
||||||
|
|
||||||
|
## 관련 이슈
|
||||||
|
- closes #이슈번호 (브랜치명에서 추출)
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
- [ ] 빌드 성공 확인
|
||||||
|
- [ ] 기존 테스트 통과
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Gitea API로 MR 생성
|
||||||
|
```bash
|
||||||
|
# Gitea remote URL에서 owner/repo 추출
|
||||||
|
REMOTE_URL=$(git remote get-url origin)
|
||||||
|
|
||||||
|
# Gitea API 호출
|
||||||
|
curl -X POST "GITEA_URL/api/v1/repos/{owner}/{repo}/pulls" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"title": "MR 제목",
|
||||||
|
"body": "MR 본문",
|
||||||
|
"head": "현재브랜치",
|
||||||
|
"base": "타겟브랜치"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 결과 출력
|
||||||
|
- MR URL 출력
|
||||||
|
- 리뷰어 지정 안내
|
||||||
|
- 다음 단계: 리뷰 대기 → 승인 → 머지
|
||||||
|
|
||||||
|
## 필요 환경변수
|
||||||
|
- `GITEA_TOKEN`: Gitea API 접근 토큰 (없으면 안내)
|
||||||
49
.claude/skills/fix-issue/SKILL.md
Normal file
49
.claude/skills/fix-issue/SKILL.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: fix-issue
|
||||||
|
description: Gitea 이슈를 분석하고 수정 브랜치를 생성합니다
|
||||||
|
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
|
||||||
|
argument-hint: "<issue-number>"
|
||||||
|
---
|
||||||
|
|
||||||
|
Gitea 이슈 #$ARGUMENTS 를 분석하고 수정 작업을 시작합니다.
|
||||||
|
|
||||||
|
## 수행 단계
|
||||||
|
|
||||||
|
### 1. 이슈 조회
|
||||||
|
```bash
|
||||||
|
curl -s "GITEA_URL/api/v1/repos/{owner}/{repo}/issues/$ARGUMENTS" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}"
|
||||||
|
```
|
||||||
|
- 이슈 제목, 본문, 라벨, 담당자 정보 확인
|
||||||
|
- 이슈 내용을 사용자에게 요약하여 보여줌
|
||||||
|
|
||||||
|
### 2. 브랜치 생성
|
||||||
|
이슈 라벨에 따라 브랜치 타입 결정:
|
||||||
|
- `bug` 라벨 → `bugfix/ISSUE-번호-설명`
|
||||||
|
- 그 외 → `feature/ISSUE-번호-설명`
|
||||||
|
- 긴급 → `hotfix/ISSUE-번호-설명`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout develop
|
||||||
|
git pull origin develop
|
||||||
|
git checkout -b {type}/ISSUE-{number}-{slug}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 이슈 분석
|
||||||
|
이슈 내용을 바탕으로:
|
||||||
|
- 관련 파일 탐색 (Grep, Glob 활용)
|
||||||
|
- 영향 범위 파악
|
||||||
|
- 수정 방향 제안
|
||||||
|
|
||||||
|
### 4. 수정 계획 제시
|
||||||
|
사용자에게 수정 계획을 보여주고 승인을 받은 후 작업 진행:
|
||||||
|
- 수정할 파일 목록
|
||||||
|
- 변경 내용 요약
|
||||||
|
- 예상 영향
|
||||||
|
|
||||||
|
### 5. 작업 완료 후
|
||||||
|
- 변경 사항 요약
|
||||||
|
- `/create-mr` 실행 안내
|
||||||
|
|
||||||
|
## 필요 환경변수
|
||||||
|
- `GITEA_TOKEN`: Gitea API 접근 토큰
|
||||||
@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: init-project
|
name: init-project
|
||||||
description: 팀 표준 워크플로우로 프로젝트를 초기화합니다
|
description: 팀 표준 워크플로우로 프로젝트를 초기화합니다
|
||||||
|
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
|
||||||
argument-hint: "[project-type: java-maven|java-gradle|react-ts|auto]"
|
argument-hint: "[project-type: java-maven|java-gradle|react-ts|auto]"
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -45,95 +46,73 @@ curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig"
|
|||||||
|
|
||||||
### 3. .claude/ 디렉토리 구성
|
### 3. .claude/ 디렉토리 구성
|
||||||
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드:
|
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드:
|
||||||
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + env(CLAUDE_BOT_TOKEN 등) + hooks 섹션 (4단계 참조)
|
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + hooks 섹션 (4단계 참조)
|
||||||
|
- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing)
|
||||||
|
- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow, init-project)
|
||||||
|
|
||||||
⚠️ 팀 규칙(.claude/rules/), 에이전트(.claude/agents/), 스킬 6종, 스크립트는 12단계(sync-team-workflow)에서 자동 다운로드된다. 여기서는 settings.json만 설정한다.
|
### 4. Hook 스크립트 생성
|
||||||
|
`.claude/scripts/` 디렉토리를 생성하고 다음 스크립트 파일 생성 (chmod +x):
|
||||||
|
|
||||||
### 3.5. Gitea 토큰 설정
|
- `.claude/scripts/on-pre-compact.sh`:
|
||||||
|
|
||||||
**CLAUDE_BOT_TOKEN** (팀 공용): `settings.json`의 `env` 필드에 이미 포함되어 있음 (3단계에서 설정됨). 별도 조치 불필요.
|
|
||||||
|
|
||||||
**GITEA_TOKEN** (개인): `/push`, `/mr`, `/release` 등 Git 스킬에 필요한 개인 토큰.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 현재 GITEA_TOKEN 설정 여부 확인
|
#!/bin/bash
|
||||||
if [ -z "$GITEA_TOKEN" ]; then
|
# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가)
|
||||||
echo "GITEA_TOKEN 미설정"
|
INPUT=$(cat)
|
||||||
|
cat <<RESP
|
||||||
|
{
|
||||||
|
"systemMessage": "컨텍스트 압축이 시작됩니다. 반드시 다음을 수행하세요:\n\n1. memory/MEMORY.md - 핵심 작업 상태 갱신 (200줄 이내)\n2. memory/project-snapshot.md - 변경된 패키지/타입 정보 업데이트\n3. memory/project-history.md - 이번 세션 변경사항 추가\n4. memory/api-types.md - API 인터페이스 변경이 있었다면 갱신\n5. 미완료 작업이 있다면 TodoWrite에 남기고 memory에도 기록"
|
||||||
|
}
|
||||||
|
RESP
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.claude/scripts/on-post-compact.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
CWD=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$CWD" ]; then
|
||||||
|
CWD=$(pwd)
|
||||||
|
fi
|
||||||
|
PROJECT_HASH=$(echo "$CWD" | sed 's|/|-|g')
|
||||||
|
MEMORY_DIR="$HOME/.claude/projects/$PROJECT_HASH/memory"
|
||||||
|
CONTEXT=""
|
||||||
|
if [ -f "$MEMORY_DIR/MEMORY.md" ]; then
|
||||||
|
SUMMARY=$(head -100 "$MEMORY_DIR/MEMORY.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="컨텍스트가 압축되었습니다.\\n\\n[세션 요약]\\n${SUMMARY}"
|
||||||
|
fi
|
||||||
|
if [ -f "$MEMORY_DIR/project-snapshot.md" ]; then
|
||||||
|
SNAP=$(head -50 "$MEMORY_DIR/project-snapshot.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n[프로젝트 최신 상태]\\n${SNAP}"
|
||||||
|
fi
|
||||||
|
if [ -n "$CONTEXT" ]; then
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n위 내용을 참고하여 작업을 이어가세요. 상세 내용은 memory/ 디렉토리의 각 파일을 참조하세요."
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"${CONTEXT}\"}}"
|
||||||
|
else
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"컨텍스트가 압축되었습니다. memory 파일이 없으므로 사용자에게 이전 작업 내용을 확인하세요.\"}}"
|
||||||
fi
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
**GITEA_TOKEN이 없는 경우**, 다음 안내를 **AskUserQuestion**으로 표시:
|
- `.claude/scripts/on-commit.sh`:
|
||||||
|
|
||||||
**질문**: "GITEA_TOKEN이 설정되지 않았습니다. Gitea 개인 토큰을 생성하시겠습니까?"
|
|
||||||
- 옵션 1: 토큰 생성 안내 보기 (추천)
|
|
||||||
- 옵션 2: 이미 있음 (토큰 입력)
|
|
||||||
- 옵션 3: 나중에 하기
|
|
||||||
|
|
||||||
**토큰 생성 안내 선택 시**, 다음 내용을 표시:
|
|
||||||
|
|
||||||
```
|
|
||||||
📋 Gitea 토큰 생성 방법:
|
|
||||||
|
|
||||||
1. 브라우저에서 접속:
|
|
||||||
https://gitea.gc-si.dev/user/settings/applications
|
|
||||||
|
|
||||||
2. "Manage Access Tokens" 섹션에서 "Generate New Token" 클릭
|
|
||||||
|
|
||||||
3. 입력:
|
|
||||||
- Token Name: "claude-code" (자유롭게 지정)
|
|
||||||
- Repository and Organization Access: ✅ All (public, private, and limited)
|
|
||||||
|
|
||||||
4. Select permissions (아래 4개만 설정, 나머지는 No Access 유지):
|
|
||||||
|
|
||||||
┌─────────────────┬──────────────────┬──────────────────────────────┐
|
|
||||||
│ 항목 │ 권한 │ 용도 │
|
|
||||||
├─────────────────┼──────────────────┼──────────────────────────────┤
|
|
||||||
│ issue │ Read and Write │ /fix-issue 이슈 조회/코멘트 │
|
|
||||||
│ organization │ Read │ gc 조직 리포 접근 │
|
|
||||||
│ repository │ Read and Write │ /push, /mr, /release API 호출 │
|
|
||||||
│ user │ Read │ API 사용자 인증 확인 │
|
|
||||||
└─────────────────┴──────────────────┴──────────────────────────────┘
|
|
||||||
|
|
||||||
5. "Generate Token" 클릭 → ⚠️ 토큰이 한 번만 표시됩니다! 반드시 복사하세요.
|
|
||||||
```
|
|
||||||
|
|
||||||
표시 후 **AskUserQuestion**: "생성한 토큰을 입력하세요"
|
|
||||||
- 옵션 1: 토큰 입력 (Other로 입력)
|
|
||||||
- 옵션 2: 나중에 하기
|
|
||||||
|
|
||||||
**토큰 입력 시**:
|
|
||||||
|
|
||||||
1. Gitea API로 유효성 검증:
|
|
||||||
```bash
|
```bash
|
||||||
curl -sf "https://gitea.gc-si.dev/api/v1/user" \
|
#!/bin/bash
|
||||||
-H "Authorization: token <입력된 토큰>"
|
INPUT=$(cat)
|
||||||
```
|
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")
|
||||||
- 성공: `✅ <login> (<full_name>) 인증 확인` 출력
|
if echo "$COMMAND" | grep -qE 'git commit'; then
|
||||||
- 실패: `❌ 토큰이 유효하지 않습니다. 다시 확인해주세요.` 출력 → 재입력 요청
|
cat <<RESP
|
||||||
|
|
||||||
2. `.claude/settings.local.json`에 저장 (이 파일은 .gitignore에 포함, 리포 커밋 안됨):
|
|
||||||
```json
|
|
||||||
{
|
{
|
||||||
"env": {
|
"hookSpecificOutput": {
|
||||||
"GITEA_TOKEN": "<입력된 토큰>"
|
"additionalContext": "커밋이 감지되었습니다. 다음을 수행하세요:\n1. docs/CHANGELOG.md에 변경 내역 추가\n2. memory/project-snapshot.md에서 변경된 부분 업데이트\n3. memory/project-history.md에 이번 변경사항 추가\n4. API 인터페이스 변경 시 memory/api-types.md 갱신\n5. 프로젝트에 lint 설정이 있다면 lint 결과를 확인하고 문제를 수정"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
RESP
|
||||||
|
else
|
||||||
|
echo '{}'
|
||||||
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
기존 `settings.local.json`이 있으면 `env.GITEA_TOKEN`만 추가/갱신.
|
|
||||||
|
|
||||||
**나중에 하기 선택 시**: 경고 표시 후 다음 단계로 진행:
|
|
||||||
```
|
|
||||||
⚠️ GITEA_TOKEN 없이는 /push, /mr, /release 스킬을 사용할 수 없습니다.
|
|
||||||
나중에 토큰을 생성하면 .claude/settings.local.json에 다음을 추가하세요:
|
|
||||||
{ "env": { "GITEA_TOKEN": "your-token-here" } }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Hook 스크립트 설정
|
|
||||||
|
|
||||||
⚠️ `.claude/scripts/` 스크립트 파일은 12단계(sync-team-workflow)에서 서버로부터 자동 다운로드된다.
|
|
||||||
여기서는 `settings.json`에 hooks 섹션만 설정한다.
|
|
||||||
|
|
||||||
`.claude/settings.json`에 hooks 섹션이 없으면 추가 (기존 settings.json의 내용에 병합):
|
`.claude/settings.json`에 hooks 섹션이 없으면 추가 (기존 settings.json의 내용에 병합):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@ -187,13 +166,6 @@ git config core.hooksPath .githooks
|
|||||||
chmod +x .githooks/*
|
chmod +x .githooks/*
|
||||||
```
|
```
|
||||||
|
|
||||||
**pre-commit 훅 검증**: `.githooks/pre-commit`을 실행하여 빌드 검증이 정상 동작하는지 확인.
|
|
||||||
에러 발생 시 (예: 모노레포가 아닌 특수 구조, 빌드 명령 불일치 등):
|
|
||||||
1. 프로젝트에 맞게 `.githooks/pre-commit`을 커스텀 수정
|
|
||||||
2. `.claude/workflow-version.json`에 `"custom_pre_commit": true` 추가
|
|
||||||
3. 이후 `/sync-team-workflow` 실행 시 pre-commit은 덮어쓰지 않고 보존됨
|
|
||||||
(`commit-msg`, `post-checkout`은 항상 팀 표준으로 동기화)
|
|
||||||
|
|
||||||
### 6. 프로젝트 타입별 추가 설정
|
### 6. 프로젝트 타입별 추가 설정
|
||||||
|
|
||||||
#### java-maven
|
#### java-maven
|
||||||
@ -221,20 +193,6 @@ chmod +x .githooks/*
|
|||||||
*.local
|
*.local
|
||||||
```
|
```
|
||||||
|
|
||||||
**팀 워크플로우 관리 경로** (sync로 생성/관리되는 파일, 리포에 커밋하지 않음):
|
|
||||||
```
|
|
||||||
# Team workflow (managed by /sync-team-workflow)
|
|
||||||
.claude/rules/
|
|
||||||
.claude/agents/
|
|
||||||
.claude/skills/push/
|
|
||||||
.claude/skills/mr/
|
|
||||||
.claude/skills/create-mr/
|
|
||||||
.claude/skills/release/
|
|
||||||
.claude/skills/version/
|
|
||||||
.claude/skills/fix-issue/
|
|
||||||
.claude/scripts/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Git exclude 설정
|
### 8. Git exclude 설정
|
||||||
`.git/info/exclude` 파일을 읽고, 기존 내용을 보존하면서 하단에 추가:
|
`.git/info/exclude` 파일을 읽고, 기존 내용을 보존하면서 하단에 추가:
|
||||||
|
|
||||||
@ -278,14 +236,7 @@ curl -sf --max-time 5 "https://gitea.gc-si.dev/gc/template-common/raw/branch/dev
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 12. 팀 워크플로우 최신화
|
### 12. 검증 및 요약
|
||||||
|
|
||||||
`/sync-team-workflow`를 자동으로 1회 실행하여 최신 팀 파일(rules, agents, skills 6종, scripts, hooks)을 서버에서 다운로드하고 로컬에 적용한다.
|
|
||||||
|
|
||||||
이 단계에서 `.claude/rules/`, `.claude/agents/`, `.claude/skills/push/` 등 팀 관리 파일이 생성된다.
|
|
||||||
(이 파일들은 7단계에서 .gitignore에 추가되었으므로 리포에 커밋되지 않음)
|
|
||||||
|
|
||||||
### 13. 검증 및 요약
|
|
||||||
- 생성/수정된 파일 목록 출력
|
- 생성/수정된 파일 목록 출력
|
||||||
- `git config core.hooksPath` 확인
|
- `git config core.hooksPath` 확인
|
||||||
- 빌드 명령 실행 가능 확인
|
- 빌드 명령 실행 가능 확인
|
||||||
|
|||||||
123
.claude/skills/mr/SKILL.md
Normal file
123
.claude/skills/mr/SKILL.md
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
name: mr
|
||||||
|
description: 커밋 + 푸시 + Gitea MR을 한 번에 생성합니다
|
||||||
|
user-invocable: true
|
||||||
|
argument-hint: "[target-branch: develop|main] (기본: develop)"
|
||||||
|
allowed-tools: "Bash, Read, Grep"
|
||||||
|
---
|
||||||
|
|
||||||
|
현재 브랜치의 변경 사항을 커밋+푸시하고, Gitea에 MR을 생성합니다.
|
||||||
|
타겟 브랜치: $ARGUMENTS (기본: develop)
|
||||||
|
|
||||||
|
## 수행 단계
|
||||||
|
|
||||||
|
### 1. 사전 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 현재 브랜치 확인 (main/develop이면 중단)
|
||||||
|
BRANCH=$(git branch --show-current)
|
||||||
|
|
||||||
|
# Gitea remote URL에서 owner/repo 추출
|
||||||
|
REMOTE_URL=$(git remote get-url origin)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 현재 브랜치가 `main` 또는 `develop`이면: "feature 브랜치에서 실행해주세요" 안내 후 종료
|
||||||
|
- GITEA_TOKEN 환경변수 확인 (없으면 설정 안내)
|
||||||
|
|
||||||
|
### 2. 커밋 + 푸시 (변경 사항이 있을 때만)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
**커밋되지 않은 변경이 있으면**:
|
||||||
|
- 변경 범위(파일 목록, 추가/수정/삭제) 요약 표시
|
||||||
|
- Conventional Commits 형식 커밋 메시지 자동 생성
|
||||||
|
- **사용자 확인** (AskUserQuestion): 커밋 메시지 수락/수정/취소
|
||||||
|
- 수락 시: `git add -A` → `git commit` → `git push`
|
||||||
|
|
||||||
|
**변경이 없으면**:
|
||||||
|
- 이미 커밋된 내용으로 MR 생성 진행
|
||||||
|
- 리모트에 push되지 않은 커밋이 있으면 `git push`
|
||||||
|
|
||||||
|
### 3. MR 대상 브랜치 결정
|
||||||
|
|
||||||
|
타겟 브랜치 후보를 분석하여 표시:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# develop과의 차이
|
||||||
|
git log develop..HEAD --oneline 2>/dev/null
|
||||||
|
# main과의 차이
|
||||||
|
git log main..HEAD --oneline 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
**사용자 확인** (AskUserQuestion):
|
||||||
|
- **질문**: "MR 타겟 브랜치를 선택하세요"
|
||||||
|
- 옵션 1: develop (추천, N건 커밋 차이)
|
||||||
|
- 옵션 2: main (N건 커밋 차이)
|
||||||
|
- 옵션 3: 취소
|
||||||
|
|
||||||
|
인자($ARGUMENTS)로 브랜치가 지정되었으면 확인 없이 바로 진행.
|
||||||
|
|
||||||
|
### 4. MR 정보 구성
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 커밋 목록
|
||||||
|
git log {target}..HEAD --oneline
|
||||||
|
# 변경 파일 통계
|
||||||
|
git diff {target}..HEAD --stat
|
||||||
|
```
|
||||||
|
|
||||||
|
- **제목**: 커밋이 1개면 커밋 메시지 사용, 여러 개면 브랜치명에서 추출
|
||||||
|
- `feature/ISSUE-42-user-login` → `feat: ISSUE-42 user-login`
|
||||||
|
- `bugfix/fix-timeout` → `fix: fix-timeout`
|
||||||
|
- **본문**:
|
||||||
|
```markdown
|
||||||
|
## 변경 사항
|
||||||
|
- (커밋 목록 기반 자동 생성)
|
||||||
|
|
||||||
|
## 관련 이슈
|
||||||
|
- closes #이슈번호 (브랜치명에서 추출, 없으면 생략)
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
- [ ] 빌드 성공 확인
|
||||||
|
- [ ] 기존 테스트 통과
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Gitea API로 MR 생성
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# remote URL에서 Gitea 호스트, owner, repo 파싱
|
||||||
|
# 예: https://gitea.gc-si.dev/gc/my-project.git → host=gitea.gc-si.dev, owner=gc, repo=my-project
|
||||||
|
|
||||||
|
curl -X POST "https://{host}/api/v1/repos/{owner}/{repo}/pulls" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"title": "MR 제목",
|
||||||
|
"body": "MR 본문",
|
||||||
|
"head": "현재브랜치",
|
||||||
|
"base": "타겟브랜치"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 결과 출력
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ MR 생성 완료
|
||||||
|
브랜치: feature/my-branch → develop
|
||||||
|
MR: https://gitea.gc-si.dev/gc/my-project/pulls/42
|
||||||
|
커밋: 3건, 파일: 5개 변경
|
||||||
|
|
||||||
|
다음 단계: 리뷰어 지정 → 승인 대기 → 머지
|
||||||
|
```
|
||||||
|
|
||||||
|
## 필요 환경변수
|
||||||
|
|
||||||
|
- `GITEA_TOKEN`: Gitea API 접근 토큰
|
||||||
|
- 없으면: "Gitea 토큰이 필요합니다. Settings → Applications에서 생성하세요" 안내
|
||||||
|
|
||||||
|
## 기존 /create-mr과의 차이
|
||||||
|
|
||||||
|
- `/mr`: 커밋+푸시 포함, 빠른 실행 (일상적 사용)
|
||||||
|
- `/create-mr`: MR 생성만, 세부 옵션 지원 (상세 제어)
|
||||||
92
.claude/skills/push/SKILL.md
Normal file
92
.claude/skills/push/SKILL.md
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
name: push
|
||||||
|
description: 변경 사항을 확인하고 커밋 + 푸시합니다
|
||||||
|
user-invocable: true
|
||||||
|
argument-hint: "[commit-message] (생략 시 자동 생성)"
|
||||||
|
allowed-tools: "Bash, Read, Grep"
|
||||||
|
---
|
||||||
|
|
||||||
|
현재 브랜치의 변경 사항을 확인하고, 사용자 승인 후 커밋 + 푸시합니다.
|
||||||
|
커밋 메시지 인자: $ARGUMENTS (생략 시 변경 내용 기반 자동 생성)
|
||||||
|
|
||||||
|
## 수행 단계
|
||||||
|
|
||||||
|
### 1. 현재 상태 수집
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 현재 브랜치
|
||||||
|
git branch --show-current
|
||||||
|
|
||||||
|
# 커밋되지 않은 변경 사항
|
||||||
|
git status --short
|
||||||
|
|
||||||
|
# 변경 통계
|
||||||
|
git diff --stat
|
||||||
|
git diff --cached --stat
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 변경 범위 표시
|
||||||
|
|
||||||
|
사용자에게 다음 정보를 **표 형태**로 요약하여 보여준다:
|
||||||
|
|
||||||
|
- 현재 브랜치명
|
||||||
|
- 변경된 파일 목록 (추가/수정/삭제 구분)
|
||||||
|
- staged vs unstaged 구분
|
||||||
|
- 변경 라인 수 요약
|
||||||
|
|
||||||
|
변경 사항이 없으면 "커밋할 변경 사항이 없습니다" 출력 후 종료.
|
||||||
|
|
||||||
|
### 3. 커밋 메시지 결정
|
||||||
|
|
||||||
|
**인자가 있는 경우** ($ARGUMENTS가 비어있지 않으면):
|
||||||
|
- 전달받은 메시지를 커밋 메시지로 사용
|
||||||
|
- Conventional Commits 형식인지 검증 (아니면 자동 보정 제안)
|
||||||
|
|
||||||
|
**인자가 없는 경우**:
|
||||||
|
- 변경 내용을 분석하여 Conventional Commits 형식 메시지 자동 생성
|
||||||
|
- 형식: `type(scope): 한국어 설명`
|
||||||
|
- type 판단 기준:
|
||||||
|
- 새 파일 추가 → `feat`
|
||||||
|
- 기존 파일 수정 → `fix` 또는 `refactor`
|
||||||
|
- 테스트 파일 → `test`
|
||||||
|
- 설정/빌드 파일 → `chore`
|
||||||
|
- 문서 파일 → `docs`
|
||||||
|
|
||||||
|
### 4. 사용자 확인
|
||||||
|
|
||||||
|
AskUserQuestion으로 다음을 확인:
|
||||||
|
|
||||||
|
**질문**: "다음 내용으로 커밋하시겠습니까?"
|
||||||
|
- 옵션 1: 제안된 메시지로 커밋 (추천)
|
||||||
|
- 옵션 2: 메시지 수정 (Other 입력)
|
||||||
|
- 옵션 3: 취소
|
||||||
|
|
||||||
|
### 5. 커밋 + 푸시 실행
|
||||||
|
|
||||||
|
사용자가 수락하면:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 모든 변경 사항 스테이징 (untracked 포함)
|
||||||
|
# 단, .env, secrets/ 등 민감 파일은 제외
|
||||||
|
git add -A
|
||||||
|
|
||||||
|
# 커밋 (.githooks/commit-msg가 형식 검증)
|
||||||
|
git commit -m "커밋메시지"
|
||||||
|
|
||||||
|
# 푸시 (리모트 트래킹 없으면 -u 추가)
|
||||||
|
git push origin $(git branch --show-current)
|
||||||
|
```
|
||||||
|
|
||||||
|
**주의사항**:
|
||||||
|
- `git add` 전에 `.env`, `*.key`, `secrets/` 등 민감 파일이 포함되어 있으면 경고
|
||||||
|
- pre-commit hook 실패 시 에러 메시지 표시 후 수동 해결 안내
|
||||||
|
- 리모트에 브랜치가 없으면 `git push -u origin {branch}` 사용
|
||||||
|
|
||||||
|
### 6. 결과 출력
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 푸시 완료
|
||||||
|
브랜치: feature/my-branch
|
||||||
|
커밋: abc1234 feat(auth): 로그인 검증 로직 추가
|
||||||
|
변경: 3 files changed, 45 insertions(+), 12 deletions(-)
|
||||||
|
```
|
||||||
134
.claude/skills/release/SKILL.md
Normal file
134
.claude/skills/release/SKILL.md
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
---
|
||||||
|
name: release
|
||||||
|
description: develop에서 main으로 릴리즈 MR을 생성합니다
|
||||||
|
user-invocable: true
|
||||||
|
argument-hint: ""
|
||||||
|
allowed-tools: "Bash, Read, Grep"
|
||||||
|
---
|
||||||
|
|
||||||
|
develop 브랜치와 원격 동기화를 확인하고, develop → main 릴리즈 MR을 생성합니다.
|
||||||
|
|
||||||
|
## 수행 단계
|
||||||
|
|
||||||
|
### 1. 사전 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Gitea remote URL에서 owner/repo 추출
|
||||||
|
REMOTE_URL=$(git remote get-url origin)
|
||||||
|
|
||||||
|
# GITEA_TOKEN 확인
|
||||||
|
echo $GITEA_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
- GITEA_TOKEN 환경변수 확인 (없으면 설정 안내 후 종료)
|
||||||
|
- 커밋되지 않은 변경 사항이 있으면 경고 ("먼저 /push로 커밋하세요")
|
||||||
|
|
||||||
|
### 2. develop 브랜치 동기화 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 최신 원격 상태 가져오기
|
||||||
|
git fetch origin
|
||||||
|
|
||||||
|
# 로컬 develop과 origin/develop 비교
|
||||||
|
LOCAL=$(git rev-parse develop 2>/dev/null)
|
||||||
|
REMOTE=$(git rev-parse origin/develop 2>/dev/null)
|
||||||
|
BASE=$(git merge-base develop origin/develop 2>/dev/null)
|
||||||
|
```
|
||||||
|
|
||||||
|
**동기화 상태 판단:**
|
||||||
|
|
||||||
|
| 상태 | 조건 | 행동 |
|
||||||
|
|------|------|------|
|
||||||
|
| 동일 | LOCAL == REMOTE | 바로 MR 생성 진행 |
|
||||||
|
| 로컬 뒤처짐 | LOCAL == BASE, LOCAL != REMOTE | "origin/develop에 새 커밋이 있습니다. `git pull origin develop` 후 다시 시도하세요" 안내 |
|
||||||
|
| 로컬 앞섬 | REMOTE == BASE, LOCAL != REMOTE | "로컬에 push되지 않은 커밋이 있습니다. `git push origin develop` 먼저 실행하시겠습니까?" 확인 |
|
||||||
|
| 분기됨 | 그 외 | "로컬과 원격 develop이 분기되었습니다. 수동으로 해결해주세요" 경고 후 종료 |
|
||||||
|
|
||||||
|
**로컬 앞섬 상태에서 사용자가 push 수락하면:**
|
||||||
|
```bash
|
||||||
|
git push origin develop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. develop → main 차이 분석
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# main 대비 develop의 새 커밋
|
||||||
|
git log main..origin/develop --oneline
|
||||||
|
|
||||||
|
# 변경 파일 통계
|
||||||
|
git diff main..origin/develop --stat
|
||||||
|
|
||||||
|
# 커밋 수
|
||||||
|
git rev-list --count main..origin/develop
|
||||||
|
```
|
||||||
|
|
||||||
|
차이가 없으면 "develop과 main이 동일합니다. 릴리즈할 변경이 없습니다" 출력 후 종료.
|
||||||
|
|
||||||
|
### 4. MR 정보 구성 + 사용자 확인
|
||||||
|
|
||||||
|
**제목 자동 생성:**
|
||||||
|
```
|
||||||
|
release: YYYY-MM-DD (N건 커밋)
|
||||||
|
```
|
||||||
|
|
||||||
|
**본문 자동 생성:**
|
||||||
|
```markdown
|
||||||
|
## 릴리즈 내용
|
||||||
|
- (develop→main 커밋 목록, Conventional Commits type별 그룹핑)
|
||||||
|
|
||||||
|
### 새 기능 (feat)
|
||||||
|
- feat(auth): 로그인 검증 로직 추가
|
||||||
|
- feat(batch): 배치 스케줄러 개선
|
||||||
|
|
||||||
|
### 버그 수정 (fix)
|
||||||
|
- fix(api): 타임아웃 처리 수정
|
||||||
|
|
||||||
|
### 기타
|
||||||
|
- chore: 의존성 업데이트
|
||||||
|
|
||||||
|
## 변경 파일
|
||||||
|
- N files changed, +M insertions, -K deletions
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
- [ ] develop 브랜치 빌드 성공 확인
|
||||||
|
- [ ] 주요 기능 동작 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
**사용자 확인** (AskUserQuestion):
|
||||||
|
- **질문**: "다음 내용으로 릴리즈 MR을 생성하시겠습니까?"
|
||||||
|
- 옵션 1: 생성 (추천)
|
||||||
|
- 옵션 2: 제목/본문 수정 (Other 입력)
|
||||||
|
- 옵션 3: 취소
|
||||||
|
|
||||||
|
### 5. Gitea API로 릴리즈 MR 생성
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://{host}/api/v1/repos/{owner}/{repo}/pulls" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"title": "release: 2026-02-19 (12건 커밋)",
|
||||||
|
"body": "릴리즈 본문",
|
||||||
|
"head": "develop",
|
||||||
|
"base": "main",
|
||||||
|
"labels": []
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 결과 출력
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 릴리즈 MR 생성 완료
|
||||||
|
브랜치: develop → main
|
||||||
|
MR: https://gitea.gc-si.dev/gc/my-project/pulls/50
|
||||||
|
커밋: 12건, 파일: 28개 변경
|
||||||
|
|
||||||
|
다음 단계:
|
||||||
|
1. 리뷰어 지정 (main 브랜치는 1명 이상 리뷰 필수)
|
||||||
|
2. 승인 후 머지
|
||||||
|
3. CI/CD 자동 배포 확인 (설정된 경우)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 필요 환경변수
|
||||||
|
|
||||||
|
- `GITEA_TOKEN`: Gitea API 접근 토큰
|
||||||
@ -1,165 +1,98 @@
|
|||||||
---
|
---
|
||||||
name: sync-team-workflow
|
name: sync-team-workflow
|
||||||
description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다
|
description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다
|
||||||
|
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
|
||||||
---
|
---
|
||||||
|
|
||||||
팀 글로벌 워크플로우의 최신 파일을 서버에서 다운로드하여 로컬에 적용합니다.
|
팀 글로벌 워크플로우의 최신 버전을 현재 프로젝트에 적용합니다.
|
||||||
호출 시 항상 서버 기준으로 전체 동기화합니다 (버전 비교 없음).
|
|
||||||
|
|
||||||
## 수행 절차
|
## 수행 절차
|
||||||
|
|
||||||
### 1. 사전 조건 확인
|
### 1. 글로벌 버전 조회
|
||||||
|
Gitea API로 template-common 리포의 workflow-version.json 조회:
|
||||||
`.claude/workflow-version.json` 존재 확인:
|
|
||||||
- 없으면 → "/init-project를 먼저 실행해주세요" 안내 후 종료
|
|
||||||
|
|
||||||
설정 읽기:
|
|
||||||
```bash
|
```bash
|
||||||
GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev")
|
GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev")
|
||||||
PROJECT_TYPE=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('project_type', ''))" 2>/dev/null || echo "")
|
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json"
|
||||||
```
|
```
|
||||||
|
|
||||||
프로젝트 타입이 비어있으면 자동 감지:
|
### 2. 버전 비교
|
||||||
1. `pom.xml` → java-maven
|
로컬 `.claude/workflow-version.json`의 `applied_global_version` 필드와 비교:
|
||||||
2. `build.gradle` / `build.gradle.kts` → java-gradle
|
- 버전 일치 → "최신 버전입니다" 안내 후 종료
|
||||||
3. `package.json` + `tsconfig.json` → react-ts
|
- 버전 불일치 → 미적용 변경 항목 추출하여 표시
|
||||||
4. 감지 실패 → 사용자에게 선택 요청
|
|
||||||
|
### 3. 프로젝트 타입 감지
|
||||||
|
자동 감지 순서:
|
||||||
|
1. `.claude/workflow-version.json`의 `project_type` 필드 확인
|
||||||
|
2. 없으면: `pom.xml` → java-maven, `build.gradle` → java-gradle, `package.json` → react-ts
|
||||||
|
|
||||||
### Gitea 파일 다운로드 URL 패턴
|
### Gitea 파일 다운로드 URL 패턴
|
||||||
⚠️ Gitea raw 파일은 반드시 **web raw URL** 사용:
|
⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가):
|
||||||
```bash
|
```bash
|
||||||
|
GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}"
|
||||||
# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로>
|
# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로>
|
||||||
# 타입별 파일: ${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/<파일경로>
|
# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로>
|
||||||
|
# 예시:
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md"
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 디렉토리 준비
|
### 4. 파일 다운로드 및 적용
|
||||||
|
위의 URL 패턴으로 해당 타입 + common 템플릿 파일 다운로드:
|
||||||
|
|
||||||
필요한 디렉토리가 없으면 생성:
|
#### 4-1. 규칙 파일 (덮어쓰기)
|
||||||
```bash
|
팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체:
|
||||||
mkdir -p .claude/rules .claude/agents .claude/scripts
|
|
||||||
mkdir -p .claude/skills/push .claude/skills/mr .claude/skills/create-mr
|
|
||||||
mkdir -p .claude/skills/release .claude/skills/version .claude/skills/fix-issue
|
|
||||||
mkdir -p .githooks
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 서버 파일 다운로드 + 적용
|
|
||||||
|
|
||||||
각 파일을 `curl -sf` 로 다운로드하여 프로젝트 루트의 동일 경로에 저장.
|
|
||||||
다운로드 실패한 파일은 경고 출력 후 건너뜀.
|
|
||||||
|
|
||||||
#### 3-1. template-common 파일 (덮어쓰기)
|
|
||||||
|
|
||||||
**규칙 파일**:
|
|
||||||
```
|
```
|
||||||
.claude/rules/team-policy.md
|
.claude/rules/team-policy.md
|
||||||
.claude/rules/git-workflow.md
|
.claude/rules/git-workflow.md
|
||||||
.claude/rules/release-notes-guide.md
|
.claude/rules/code-style.md (타입별)
|
||||||
.claude/rules/subagent-policy.md
|
.claude/rules/naming.md (타입별)
|
||||||
|
.claude/rules/testing.md (타입별)
|
||||||
```
|
```
|
||||||
|
|
||||||
**에이전트 파일**:
|
#### 4-2. settings.json (부분 갱신)
|
||||||
```
|
- `deny` 목록: 글로벌 최신으로 교체
|
||||||
.claude/agents/explorer.md
|
- `allow` 목록: 기존 사용자 커스텀 유지 + 글로벌 기본값 병합
|
||||||
.claude/agents/implementer.md
|
- `hooks`: init-project SKILL.md의 hooks JSON 블록을 참조하여 교체 (없으면 추가)
|
||||||
.claude/agents/reviewer.md
|
- SessionStart(compact) → on-post-compact.sh
|
||||||
```
|
- PreCompact → on-pre-compact.sh
|
||||||
|
- PostToolUse(Bash) → on-commit.sh
|
||||||
|
|
||||||
**스킬 파일 (6종)**:
|
#### 4-3. 스킬 파일 (덮어쓰기)
|
||||||
```
|
```
|
||||||
.claude/skills/push/SKILL.md
|
|
||||||
.claude/skills/mr/SKILL.md
|
|
||||||
.claude/skills/create-mr/SKILL.md
|
.claude/skills/create-mr/SKILL.md
|
||||||
.claude/skills/release/SKILL.md
|
|
||||||
.claude/skills/version/SKILL.md
|
|
||||||
.claude/skills/fix-issue/SKILL.md
|
.claude/skills/fix-issue/SKILL.md
|
||||||
|
.claude/skills/sync-team-workflow/SKILL.md
|
||||||
|
.claude/skills/init-project/SKILL.md
|
||||||
```
|
```
|
||||||
|
|
||||||
**Hook 스크립트**:
|
#### 4-4. Git Hooks (덮어쓰기 + 실행 권한)
|
||||||
|
```bash
|
||||||
|
chmod +x .githooks/*
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-5. Hook 스크립트 갱신
|
||||||
|
init-project SKILL.md의 코드 블록에서 최신 스크립트를 추출하여 덮어쓰기:
|
||||||
```
|
```
|
||||||
.claude/scripts/on-pre-compact.sh
|
.claude/scripts/on-pre-compact.sh
|
||||||
.claude/scripts/on-post-compact.sh
|
.claude/scripts/on-post-compact.sh
|
||||||
.claude/scripts/on-commit.sh
|
.claude/scripts/on-commit.sh
|
||||||
```
|
```
|
||||||
|
실행 권한 부여: `chmod +x .claude/scripts/*.sh`
|
||||||
|
|
||||||
**Git Hooks** (commit-msg, post-checkout은 항상 교체):
|
### 5. 로컬 버전 업데이트
|
||||||
```
|
`.claude/workflow-version.json` 갱신:
|
||||||
.githooks/commit-msg
|
|
||||||
.githooks/post-checkout
|
|
||||||
```
|
|
||||||
|
|
||||||
다운로드 예시:
|
|
||||||
```bash
|
|
||||||
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md" -o ".claude/rules/team-policy.md"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3-2. template-{type} 파일 (타입별 덮어쓰기)
|
|
||||||
|
|
||||||
```
|
|
||||||
.claude/rules/code-style.md
|
|
||||||
.claude/rules/naming.md
|
|
||||||
.claude/rules/testing.md
|
|
||||||
```
|
|
||||||
|
|
||||||
**pre-commit hook**:
|
|
||||||
`.claude/workflow-version.json`의 `custom_pre_commit` 플래그 확인:
|
|
||||||
- `"custom_pre_commit": true` → pre-commit 건너뜀, "⚠️ pre-commit은 프로젝트 커스텀 유지" 로그
|
|
||||||
- 플래그 없거나 false → `.githooks/pre-commit` 교체
|
|
||||||
|
|
||||||
다운로드 예시:
|
|
||||||
```bash
|
|
||||||
curl -sf "${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/.claude/rules/code-style.md" -o ".claude/rules/code-style.md"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3-3. 실행 권한 부여
|
|
||||||
```bash
|
|
||||||
chmod +x .githooks/* 2>/dev/null
|
|
||||||
chmod +x .claude/scripts/*.sh 2>/dev/null
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. settings.json 부분 머지
|
|
||||||
|
|
||||||
⚠️ settings.json은 **타입별 템플릿**에서 다운로드 (template-common에는 없음):
|
|
||||||
```bash
|
|
||||||
SERVER_SETTINGS=$(curl -sf "${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/.claude/settings.json")
|
|
||||||
```
|
|
||||||
|
|
||||||
다운로드한 최신 settings.json과 로컬 `.claude/settings.json`을 비교하여 부분 갱신:
|
|
||||||
- `env`: 서버 최신으로 교체
|
|
||||||
- `deny` 목록: 서버 최신으로 교체
|
|
||||||
- `allow` 목록: 기존 사용자 커스텀 유지 + 서버 기본값 병합
|
|
||||||
- `hooks`: 서버 최신으로 교체
|
|
||||||
|
|
||||||
### 5. workflow-version.json 갱신
|
|
||||||
|
|
||||||
서버의 최신 `workflow-version.json` 조회:
|
|
||||||
```bash
|
|
||||||
SERVER_VER=$(curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json")
|
|
||||||
SERVER_VERSION=$(echo "$SERVER_VER" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version',''))")
|
|
||||||
```
|
|
||||||
|
|
||||||
`.claude/workflow-version.json` 업데이트:
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"applied_global_version": "<서버 version>",
|
"applied_global_version": "새버전",
|
||||||
"applied_date": "<현재날짜>",
|
"applied_date": "오늘날짜",
|
||||||
"project_type": "<프로젝트타입>",
|
"project_type": "감지된타입",
|
||||||
"gitea_url": "<GITEA_URL>"
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
기존 필드(`custom_pre_commit` 등)는 보존.
|
|
||||||
|
|
||||||
### 6. 변경 보고
|
### 6. 변경 보고
|
||||||
|
- `git diff`로 변경 내역 확인
|
||||||
- 다운로드/갱신된 파일 목록 출력
|
- 업데이트된 파일 목록 출력
|
||||||
- 서버 `workflow-version.json`의 `changes` 중 최신 항목 표시
|
- 변경 로그(글로벌 workflow-version.json의 changes) 표시
|
||||||
- 결과 형태:
|
- 필요한 추가 조치 안내 (빌드 확인, 의존성 업데이트 등)
|
||||||
```
|
|
||||||
✅ 팀 워크플로우 동기화 완료
|
|
||||||
버전: v1.6.0
|
|
||||||
갱신 파일: 22개 (rules 7, agents 3, skills 6, scripts 3, hooks 3)
|
|
||||||
settings.json: 부분 갱신 (env, deny, hooks)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 필요 환경변수
|
|
||||||
|
|
||||||
없음 (Gitea raw URL은 인증 불필요)
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.2.0",
|
||||||
"applied_date": "2026-03-18",
|
"applied_date": "2026-02-15",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
"custom_pre_commit": true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,9 +20,10 @@ fi
|
|||||||
# Conventional Commits 정규식
|
# Conventional Commits 정규식
|
||||||
# type(scope): subject
|
# type(scope): subject
|
||||||
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
|
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
|
||||||
# - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택)
|
# - scope: 괄호 제외 모든 문자 허용 — 한/영/숫자/특수문자 (선택)
|
||||||
# - subject: 1~72자, 한/영 혼용 허용 (필수)
|
# - subject: 1자 이상 (길이는 바이트 기반 별도 검증)
|
||||||
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9가-힣._-]+\))?: .{1,72}$'
|
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([^)]+\))?: .+$'
|
||||||
|
MAX_SUBJECT_BYTES=200 # UTF-8 한글(3byte) 허용: 72문자 ≈ 최대 216byte
|
||||||
|
|
||||||
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
|
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
|
||||||
|
|
||||||
@ -58,3 +59,13 @@ if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
|
|||||||
echo ""
|
echo ""
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 길이 검증 (바이트 기반 — UTF-8 한글 허용)
|
||||||
|
MSG_LEN=$(echo -n "$FIRST_LINE" | wc -c | tr -d ' ')
|
||||||
|
if [ "$MSG_LEN" -gt "$MAX_SUBJECT_BYTES" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " ✗ 커밋 메시지가 너무 깁니다 (${MSG_LEN}바이트, 최대 ${MAX_SUBJECT_BYTES})"
|
||||||
|
echo " 현재 메시지: $FIRST_LINE"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@ -39,14 +39,3 @@ coverage/
|
|||||||
!.claude/
|
!.claude/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/CLAUDE.local.md
|
.claude/CLAUDE.local.md
|
||||||
|
|
||||||
# Team workflow (managed by /sync-team-workflow)
|
|
||||||
.claude/rules/
|
|
||||||
.claude/agents/
|
|
||||||
.claude/skills/push/
|
|
||||||
.claude/skills/mr/
|
|
||||||
.claude/skills/create-mr/
|
|
||||||
.claude/skills/release/
|
|
||||||
.claude/skills/version/
|
|
||||||
.claude/skills/fix-issue/
|
|
||||||
.claude/scripts/
|
|
||||||
|
|||||||
@ -19,4 +19,3 @@
|
|||||||
@import "./styles/components/weather.css";
|
@import "./styles/components/weather.css";
|
||||||
@import "./styles/components/weather-overlay.css";
|
@import "./styles/components/weather-overlay.css";
|
||||||
@import "./styles/components/announcement.css";
|
@import "./styles/components/announcement.css";
|
||||||
@import "./styles/components/vessel-select-modal.css";
|
|
||||||
|
|||||||
@ -141,79 +141,6 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── ENC Settings additions ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
.map-settings-panel .ms-title .ms-reset-btn {
|
|
||||||
float: right;
|
|
||||||
font-size: 9px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--card);
|
|
||||||
color: var(--muted);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-settings-panel .ms-title .ms-reset-btn:hover {
|
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-settings-panel .ms-toggle-all {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-settings-panel .ms-toggle-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 2px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-settings-panel .ms-toggle-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--muted);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-settings-panel .ms-toggle-item input[type="checkbox"] {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-settings-panel .ms-color-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 2px 0;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-settings-panel .ms-color-row input[type="color"] {
|
|
||||||
width: 28px;
|
|
||||||
height: 18px;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 3px;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-settings-panel .ms-color-row input[type="color"]::-webkit-color-swatch-wrapper {
|
|
||||||
padding: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-settings-panel .ms-color-row input[type="color"]::-webkit-color-swatch {
|
|
||||||
border: none;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Depth Legend ──────────────────────────────────────────────────── */
|
/* ── Depth Legend ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.depth-legend {
|
.depth-legend {
|
||||||
|
|||||||
@ -1,152 +0,0 @@
|
|||||||
/* ── Vessel select modal ─────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.vessel-select-modal {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1050;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__content {
|
|
||||||
background: rgba(15, 23, 42, 0.96);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
|
||||||
border-radius: 12px;
|
|
||||||
color: #e2e8f0;
|
|
||||||
width: 95vw;
|
|
||||||
max-width: 720px;
|
|
||||||
max-height: 85vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: 0 16px 48px rgba(2, 6, 23, 0.6);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
|
||||||
font-size: 14px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 6px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__close:hover {
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__back {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #94a3b8;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__back:hover {
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__filters {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__search {
|
|
||||||
padding: 8px 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__grid {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__grid::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__grid::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__grid::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(148, 163, 184, 0.3);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__body {
|
|
||||||
padding: 16px;
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
background: rgba(148, 163, 184, 0.12);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #cbd5e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal__presets {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-select-modal input[type='datetime-local'] {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
|
||||||
background: rgba(30, 41, 59, 0.8);
|
|
||||||
color: #e2e8f0;
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
@ -31,14 +31,14 @@ export function buildLegacyVesselIndex(vessels: LegacyVesselInfo[]): LegacyVesse
|
|||||||
if (score(v) > score(prev)) byName.set(k, v);
|
if (score(v) > score(prev)) byName.set(k, v);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof v.mmsi === 'number' && Number.isFinite(v.mmsi)) {
|
|
||||||
const prev = byMmsi.get(v.mmsi);
|
|
||||||
if (!prev || score(v) > score(prev)) byMmsi.set(v.mmsi, v);
|
|
||||||
}
|
|
||||||
for (const m of v.mmsiList || []) {
|
for (const m of v.mmsiList || []) {
|
||||||
if (!Number.isFinite(m)) continue;
|
if (!Number.isFinite(m)) continue;
|
||||||
if (byMmsi.has(m)) continue;
|
const prev = byMmsi.get(m);
|
||||||
byMmsi.set(m, v);
|
if (!prev) {
|
||||||
|
byMmsi.set(m, v);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (score(v) > score(prev)) byMmsi.set(m, v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,6 +57,19 @@ export function matchLegacyVessel(t: LegacyMatchable, idx: LegacyVesselIndex): L
|
|||||||
const hit = idx.byMmsi.get(mmsi);
|
const hit = idx.byMmsi.get(mmsi);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nameKey = t.name ? normalizeShipName(t.name) : "";
|
||||||
|
if (nameKey) {
|
||||||
|
const hit = idx.byName.get(nameKey);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const csKey = t.callsign ? normalizeShipName(t.callsign) : "";
|
||||||
|
if (csKey) {
|
||||||
|
const hit = idx.byName.get(csKey);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,6 @@ export type LegacyVesselInfo = {
|
|||||||
shipCode: string; // PT | PT-S | GN | OT | PS | FC | ...
|
shipCode: string; // PT | PT-S | GN | OT | PS | FC | ...
|
||||||
ton: number | null;
|
ton: number | null;
|
||||||
callSign: string;
|
callSign: string;
|
||||||
mmsi: number | null;
|
|
||||||
mmsiList: number[];
|
mmsiList: number[];
|
||||||
workSeaArea: string;
|
workSeaArea: string;
|
||||||
workTerm1: string;
|
workTerm1: string;
|
||||||
|
|||||||
@ -28,33 +28,6 @@ export const ANNOUNCEMENTS: Announcement[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'Wing Fleet Dashboard 업데이트',
|
|
||||||
date: '2026-03-08',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
icon: '🎯',
|
|
||||||
title: '대상선박 다중항적 조회',
|
|
||||||
description: '상단 "대상 선박 선택" 버튼으로 최대 20척을 한번에 선택하여 항적을 조회할 수 있습니다. 업종·상태 필터, 검색, 드래그 범위 선택을 지원합니다.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '📅',
|
|
||||||
title: '조회 기간 프리셋',
|
|
||||||
description: '7일·14일·21일·28일 프리셋 버튼으로 기간을 빠르게 설정할 수 있습니다. 최대 조회 범위는 28일이며 초과 시 자동 조정됩니다.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '🔄',
|
|
||||||
title: '항적 재조회 및 선박 목록 토글',
|
|
||||||
description: '리플레이 패널에서 기간을 수정하여 재조회하거나 CSV로 내보낼 수 있습니다. "선박 목록" 버튼으로 선택 화면을 열고 닫을 수 있습니다.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '🔔',
|
|
||||||
title: '경고 표시 개선',
|
|
||||||
description: '실시간 경고 효과가 테두리 링 형태로 변경되어 선박 아이콘과의 가독성이 향상되었습니다.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 현재 최신 공지 ID */
|
/** 현재 최신 공지 ID */
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
import { useEffect, useRef, type MutableRefObject } from 'react';
|
|
||||||
import type maplibregl from 'maplibre-gl';
|
|
||||||
import { applyEncVisibility, applyEncColors } from '../lib/encSettings';
|
|
||||||
import type { EncMapSettings } from '../model/types';
|
|
||||||
import type { BaseMapId } from '../../../widgets/map3d/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies ENC map settings changes at runtime (no style reload).
|
|
||||||
*/
|
|
||||||
export function useEncMapSettings(
|
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
|
||||||
baseMap: BaseMapId,
|
|
||||||
settings: EncMapSettings,
|
|
||||||
) {
|
|
||||||
const prevRef = useRef<EncMapSettings>(settings);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (baseMap !== 'enc') return;
|
|
||||||
const map = mapRef.current;
|
|
||||||
if (!map) return;
|
|
||||||
|
|
||||||
const prev = prevRef.current;
|
|
||||||
prevRef.current = settings;
|
|
||||||
|
|
||||||
const toggleKeys = [
|
|
||||||
'showBuoys', 'showBeacons', 'showLights', 'showDangers', 'showLandmarks',
|
|
||||||
'showSoundings', 'showPilot', 'showAnchorage', 'showRestricted',
|
|
||||||
'showDredged', 'showTSS', 'showContours',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
if (toggleKeys.some((k) => prev[k] !== settings[k])) {
|
|
||||||
applyEncVisibility(map, settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
const colorKeys = [
|
|
||||||
'landColor', 'coastlineColor', 'backgroundColor',
|
|
||||||
'depthDrying', 'depthVeryShallow', 'depthSafetyZone', 'depthMedium', 'depthDeep',
|
|
||||||
] as const;
|
|
||||||
if (colorKeys.some((k) => prev[k] !== settings[k])) {
|
|
||||||
applyEncColors(map, settings);
|
|
||||||
}
|
|
||||||
}, [baseMap, settings]);
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
export type { EncMapSettings } from './model/types';
|
|
||||||
export { DEFAULT_ENC_MAP_SETTINGS } from './model/types';
|
|
||||||
export { fetchEncStyle } from './lib/encStyle';
|
|
||||||
export { applyEncVisibility, applyEncColors } from './lib/encSettings';
|
|
||||||
export { useEncMapSettings } from './hooks/useEncMapSettings';
|
|
||||||
export { EncMapSettingsPanel } from './ui/EncMapSettingsPanel';
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
import type maplibregl from 'maplibre-gl';
|
|
||||||
import type { EncMapSettings } from '../model/types';
|
|
||||||
import { ENC_LAYER_CATEGORIES, ENC_COLOR_TARGETS, ENC_DEPTH_COLOR_TARGETS } from '../model/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply symbol category visibility toggles at runtime.
|
|
||||||
*/
|
|
||||||
export function applyEncVisibility(map: maplibregl.Map, settings: EncMapSettings): void {
|
|
||||||
for (const [key, layerIds] of Object.entries(ENC_LAYER_CATEGORIES)) {
|
|
||||||
const visible = settings[key as keyof EncMapSettings] as boolean;
|
|
||||||
const vis = visible ? 'visible' : 'none';
|
|
||||||
for (const layerId of layerIds) {
|
|
||||||
try {
|
|
||||||
if (map.getLayer(layerId)) {
|
|
||||||
map.setLayoutProperty(layerId, 'visibility', vis);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// layer may not exist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply runtime color changes to area/line layers.
|
|
||||||
*/
|
|
||||||
export function applyEncColors(map: maplibregl.Map, settings: EncMapSettings): void {
|
|
||||||
// 육지/해안선
|
|
||||||
for (const [layerId, prop, key] of ENC_COLOR_TARGETS) {
|
|
||||||
try {
|
|
||||||
if (map.getLayer(layerId)) {
|
|
||||||
map.setPaintProperty(layerId, prop, settings[key] as string);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 배경색
|
|
||||||
try {
|
|
||||||
if (map.getLayer('background')) {
|
|
||||||
map.setPaintProperty('background', 'background-color', settings.backgroundColor);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
// 수심별 색상
|
|
||||||
for (const { key, layerIds } of ENC_DEPTH_COLOR_TARGETS) {
|
|
||||||
const color = settings[key] as string;
|
|
||||||
for (const layerId of layerIds) {
|
|
||||||
try {
|
|
||||||
if (map.getLayer(layerId)) {
|
|
||||||
map.setPaintProperty(layerId, 'fill-color', color);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
import type { StyleSpecification } from 'maplibre-gl';
|
|
||||||
|
|
||||||
const NAUTICAL_STYLE_URL = 'https://tiles.gcnautical.com/styles/nautical.json';
|
|
||||||
|
|
||||||
/** Fonts available on the tile server */
|
|
||||||
const SERVER_FONTS = ['Noto Sans CJK KR Regular', 'Noto Sans Regular'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the nautical chart style from gcnautical tile server.
|
|
||||||
* Patches text-font arrays to use only server-supported fonts (avoids 404 on composite fontstack).
|
|
||||||
*/
|
|
||||||
export async function fetchEncStyle(signal: AbortSignal): Promise<StyleSpecification> {
|
|
||||||
const res = await fetch(NAUTICAL_STYLE_URL, { signal });
|
|
||||||
if (!res.ok) throw new Error(`ENC style fetch failed: ${res.status}`);
|
|
||||||
const style = (await res.json()) as StyleSpecification;
|
|
||||||
|
|
||||||
// Patch text-font to avoid composite fontstack 404 errors
|
|
||||||
for (const layer of style.layers) {
|
|
||||||
const layout = (layer as { layout?: Record<string, unknown> }).layout;
|
|
||||||
if (!layout) continue;
|
|
||||||
const tf = layout['text-font'];
|
|
||||||
if (Array.isArray(tf) && tf.every((v) => typeof v === 'string')) {
|
|
||||||
layout['text-font'] = SERVER_FONTS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return style;
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
export interface EncDepthColor {
|
|
||||||
label: string;
|
|
||||||
layerIds: string[];
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EncMapSettings {
|
|
||||||
// 심볼 카테고리별 표시 토글
|
|
||||||
showBuoys: boolean;
|
|
||||||
showBeacons: boolean;
|
|
||||||
showLights: boolean;
|
|
||||||
showDangers: boolean;
|
|
||||||
showLandmarks: boolean;
|
|
||||||
showSoundings: boolean;
|
|
||||||
showPilot: boolean;
|
|
||||||
showAnchorage: boolean;
|
|
||||||
showRestricted: boolean;
|
|
||||||
showDredged: boolean;
|
|
||||||
showTSS: boolean;
|
|
||||||
showContours: boolean;
|
|
||||||
|
|
||||||
// 영역 색상 (nautical.json 기본값 기준)
|
|
||||||
landColor: string;
|
|
||||||
coastlineColor: string;
|
|
||||||
backgroundColor: string;
|
|
||||||
|
|
||||||
// 수심별 색상
|
|
||||||
depthDrying: string;
|
|
||||||
depthVeryShallow: string;
|
|
||||||
depthSafetyZone: string;
|
|
||||||
depthMedium: string;
|
|
||||||
depthDeep: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** nautical.json 기본 색상 기준 */
|
|
||||||
export const DEFAULT_ENC_MAP_SETTINGS: EncMapSettings = {
|
|
||||||
showBuoys: true,
|
|
||||||
showBeacons: true,
|
|
||||||
showLights: true,
|
|
||||||
showDangers: true,
|
|
||||||
showLandmarks: true,
|
|
||||||
showSoundings: true,
|
|
||||||
showPilot: true,
|
|
||||||
showAnchorage: true,
|
|
||||||
showRestricted: true,
|
|
||||||
showDredged: true,
|
|
||||||
showTSS: true,
|
|
||||||
showContours: true,
|
|
||||||
|
|
||||||
landColor: '#BFBE8D',
|
|
||||||
coastlineColor: '#4C5B62',
|
|
||||||
backgroundColor: '#93AEBB',
|
|
||||||
|
|
||||||
depthDrying: '#58AF99',
|
|
||||||
depthVeryShallow: '#61B7FF',
|
|
||||||
depthSafetyZone: '#82CAFF',
|
|
||||||
depthMedium: '#A7D9FA',
|
|
||||||
depthDeep: '#C9EDFD',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 심볼 카테고리 → nautical.json 레이어 ID 매핑.
|
|
||||||
* 서버 스타일의 49개 레이어를 12개 카테고리로 그룹화.
|
|
||||||
*/
|
|
||||||
export const ENC_LAYER_CATEGORIES: Record<string, string[]> = {
|
|
||||||
showBuoys: ['boylat', 'boycar', 'boyisd', 'boysaw', 'boyspp'],
|
|
||||||
showBeacons: ['lndmrk'],
|
|
||||||
showLights: ['lights', 'lights-catlit'],
|
|
||||||
showDangers: ['uwtroc', 'obstrn', 'wrecks'],
|
|
||||||
showLandmarks: ['lndmrk'],
|
|
||||||
showSoundings: ['soundg', 'soundg-critical'],
|
|
||||||
showPilot: ['pilbop'],
|
|
||||||
showAnchorage: ['achare', 'achare-outline'],
|
|
||||||
showRestricted: ['resare-outline', 'resare-symbol', 'mipare'],
|
|
||||||
showDredged: [
|
|
||||||
'drgare-drying', 'drgare-very-shallow', 'drgare-safety-zone',
|
|
||||||
'drgare-medium', 'drgare-deep', 'drgare-pattern', 'drgare-outline', 'drgare-symbol',
|
|
||||||
],
|
|
||||||
showTSS: ['tsslpt', 'tsslpt-outline'],
|
|
||||||
showContours: ['depcnt', 'depare-safety-edge', 'depare-safety-edge-label'],
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 영역 색상 → 레이어 ID + paint 속성 매핑 */
|
|
||||||
export const ENC_COLOR_TARGETS: [layerId: string, prop: string, settingsKey: keyof EncMapSettings][] = [
|
|
||||||
['lndare', 'fill-color', 'landColor'],
|
|
||||||
['globe-lndare', 'fill-color', 'landColor'],
|
|
||||||
['coalne', 'line-color', 'coastlineColor'],
|
|
||||||
['globe-coalne', 'line-color', 'coastlineColor'],
|
|
||||||
];
|
|
||||||
|
|
||||||
/** 수심별 색상 → 레이어 ID 매핑 */
|
|
||||||
export const ENC_DEPTH_COLOR_TARGETS: { key: keyof EncMapSettings; label: string; layerIds: string[] }[] = [
|
|
||||||
{ key: 'depthDrying', label: '건출 (< 0m)', layerIds: ['depare-drying', 'drgare-drying'] },
|
|
||||||
{ key: 'depthVeryShallow', label: '극천 (0~2m)', layerIds: ['depare-very-shallow', 'drgare-very-shallow'] },
|
|
||||||
{ key: 'depthSafetyZone', label: '안전수심 (2~30m)', layerIds: ['depare-safety-zone', 'drgare-safety-zone'] },
|
|
||||||
{ key: 'depthMedium', label: '중간 (30m~)', layerIds: ['depare-medium', 'drgare-medium'] },
|
|
||||||
{ key: 'depthDeep', label: '심해', layerIds: ['depare-deep', 'drgare-deep'] },
|
|
||||||
];
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { EncMapSettings } from '../model/types';
|
|
||||||
import { DEFAULT_ENC_MAP_SETTINGS, ENC_DEPTH_COLOR_TARGETS } from '../model/types';
|
|
||||||
|
|
||||||
interface EncMapSettingsPanelProps {
|
|
||||||
value: EncMapSettings;
|
|
||||||
onChange: (next: EncMapSettings) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SYMBOL_TOGGLES: { key: keyof EncMapSettings; label: string }[] = [
|
|
||||||
{ key: 'showBuoys', label: '부표' },
|
|
||||||
{ key: 'showBeacons', label: '비콘' },
|
|
||||||
{ key: 'showLights', label: '등대' },
|
|
||||||
{ key: 'showDangers', label: '위험물' },
|
|
||||||
{ key: 'showLandmarks', label: '랜드마크' },
|
|
||||||
{ key: 'showSoundings', label: '수심' },
|
|
||||||
{ key: 'showPilot', label: '도선소' },
|
|
||||||
{ key: 'showAnchorage', label: '정박지' },
|
|
||||||
{ key: 'showRestricted', label: '제한구역' },
|
|
||||||
{ key: 'showDredged', label: '준설구역' },
|
|
||||||
{ key: 'showTSS', label: '통항분리대' },
|
|
||||||
{ key: 'showContours', label: '등심선' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const AREA_COLOR_INPUTS: { key: keyof EncMapSettings; label: string }[] = [
|
|
||||||
{ key: 'backgroundColor', label: '바다 배경' },
|
|
||||||
{ key: 'landColor', label: '육지' },
|
|
||||||
{ key: 'coastlineColor', label: '해안선' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function EncMapSettingsPanel({ value, onChange }: EncMapSettingsPanelProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const update = <K extends keyof EncMapSettings>(key: K, val: EncMapSettings[K]) => {
|
|
||||||
onChange({ ...value, [key]: val });
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDefault = JSON.stringify(value) === JSON.stringify(DEFAULT_ENC_MAP_SETTINGS);
|
|
||||||
|
|
||||||
const allChecked = SYMBOL_TOGGLES.every(({ key }) => value[key] as boolean);
|
|
||||||
const toggleAll = (checked: boolean) => {
|
|
||||||
const next = { ...value };
|
|
||||||
for (const { key } of SYMBOL_TOGGLES) {
|
|
||||||
(next as Record<string, unknown>)[key] = checked;
|
|
||||||
}
|
|
||||||
onChange(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className={`map-settings-gear${open ? ' open' : ''}`}
|
|
||||||
onClick={() => setOpen((p) => !p)}
|
|
||||||
title="ENC 설정"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
⚙
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="map-settings-panel">
|
|
||||||
<div className="ms-title">
|
|
||||||
ENC 설정
|
|
||||||
{!isDefault && (
|
|
||||||
<button
|
|
||||||
className="ms-reset-btn"
|
|
||||||
onClick={() => onChange(DEFAULT_ENC_MAP_SETTINGS)}
|
|
||||||
title="기본값 복원"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
초기화
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 레이어 토글 ── */}
|
|
||||||
<div className="ms-section">
|
|
||||||
<div className="ms-label">
|
|
||||||
레이어 표시
|
|
||||||
<label className="ms-toggle-item ms-toggle-all">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={allChecked}
|
|
||||||
onChange={(e) => toggleAll(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>전체</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="ms-toggle-grid">
|
|
||||||
{SYMBOL_TOGGLES.map(({ key, label }) => (
|
|
||||||
<label key={key} className="ms-toggle-item">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={value[key] as boolean}
|
|
||||||
onChange={(e) => update(key, e.target.checked as never)}
|
|
||||||
/>
|
|
||||||
<span>{label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 영역 색상 ── */}
|
|
||||||
<div className="ms-section">
|
|
||||||
<div className="ms-label">영역 색상</div>
|
|
||||||
{AREA_COLOR_INPUTS.map(({ key, label }) => (
|
|
||||||
<div key={key} className="ms-color-row">
|
|
||||||
<span>{label}</span>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={value[key] as string}
|
|
||||||
onChange={(e) => update(key, e.target.value as never)}
|
|
||||||
title={label}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 수심별 색상 ── */}
|
|
||||||
<div className="ms-section">
|
|
||||||
<div className="ms-label">수심 색상</div>
|
|
||||||
{ENC_DEPTH_COLOR_TARGETS.map(({ key, label }) => (
|
|
||||||
<div key={key} className="ms-color-row">
|
|
||||||
<span>{label}</span>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={value[key] as string}
|
|
||||||
onChange={(e) => update(key, e.target.value as never)}
|
|
||||||
title={label}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -58,7 +58,6 @@ function makeLegacy(
|
|||||||
shipNameCn: null,
|
shipNameCn: null,
|
||||||
ton: 100,
|
ton: 100,
|
||||||
callSign: '',
|
callSign: '',
|
||||||
mmsi: o.mmsiList[0] ?? null,
|
|
||||||
workSeaArea: '서해',
|
workSeaArea: '서해',
|
||||||
workTerm1: '2025-01-01',
|
workTerm1: '2025-01-01',
|
||||||
workTerm2: '2025-12-31',
|
workTerm2: '2025-12-31',
|
||||||
|
|||||||
@ -12,46 +12,14 @@ const OCEAN_DEPTH_FONT_SIZE: Record<OceanDepthLabelSize, unknown[]> = {
|
|||||||
large: ['interpolate', ['linear'], ['zoom'], 5, 12, 8, 15, 11, 18],
|
large: ['interpolate', ['linear'], ['zoom'], 5, 12, 8, 15, 11, 18],
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Original paint cache (Ocean 네이티브 색상 복원용) ─────────── */
|
|
||||||
const originalDepthPaint = new Map<string, { fillColor: unknown; fillOpacity: unknown }>();
|
|
||||||
|
|
||||||
function captureOriginalDepthPaint(map: maplibregl.Map, layers: string[]) {
|
|
||||||
if (originalDepthPaint.size > 0) return; // 이미 캡처됨
|
|
||||||
for (const id of layers) {
|
|
||||||
if (!map.getLayer(id)) continue;
|
|
||||||
try {
|
|
||||||
originalDepthPaint.set(id, {
|
|
||||||
fillColor: map.getPaintProperty(id, 'fill-color'),
|
|
||||||
fillOpacity: map.getPaintProperty(id, 'fill-opacity'),
|
|
||||||
});
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreOriginalDepthPaint(map: maplibregl.Map, layers: string[]) {
|
|
||||||
for (const id of layers) {
|
|
||||||
const orig = originalDepthPaint.get(id);
|
|
||||||
if (!orig || !map.getLayer(id)) continue;
|
|
||||||
try {
|
|
||||||
map.setPaintProperty(id, 'fill-color', orig.fillColor as never);
|
|
||||||
map.setPaintProperty(id, 'fill-opacity', orig.fillOpacity as never);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Apply functions (Ocean 전용 — enhanced 코드와 공유 없음) ────── */
|
/* ── Apply functions (Ocean 전용 — enhanced 코드와 공유 없음) ────── */
|
||||||
|
|
||||||
function applyOceanDepthColors(map: maplibregl.Map, layers: string[], stops: OceanDepthStop[], opacity: number) {
|
function applyOceanDepthColors(map: maplibregl.Map, layers: string[], stops: OceanDepthStop[], opacity: number) {
|
||||||
if (layers.length === 0) return;
|
if (layers.length === 0) return;
|
||||||
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
|
|
||||||
|
|
||||||
if (sorted.length < 2) {
|
|
||||||
// depthStops 비어있음 → Ocean 네이티브 색상 복원
|
|
||||||
restoreOriginalDepthPaint(map, layers);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const depth = ['to-number', ['get', 'depth']];
|
const depth = ['to-number', ['get', 'depth']];
|
||||||
|
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
|
||||||
|
if (sorted.length < 2) return;
|
||||||
|
|
||||||
const expr: unknown[] = ['interpolate', ['linear'], depth];
|
const expr: unknown[] = ['interpolate', ['linear'], depth];
|
||||||
for (const s of sorted) {
|
for (const s of sorted) {
|
||||||
expr.push(s.depth, s.color);
|
expr.push(s.depth, s.color);
|
||||||
@ -208,9 +176,6 @@ export function useOceanMapSettings(
|
|||||||
const stop = onMapStyleReady(map, () => {
|
const stop = onMapStyleReady(map, () => {
|
||||||
const oceanLayers = discoverOceanLayers(map);
|
const oceanLayers = discoverOceanLayers(map);
|
||||||
|
|
||||||
// 커스텀 적용 전에 원본 paint 캡처 (최초 1회)
|
|
||||||
captureOriginalDepthPaint(map, oceanLayers.depthFill);
|
|
||||||
|
|
||||||
applyOceanDepthColors(map, oceanLayers.depthFill, s.depthStops, s.depthOpacity);
|
applyOceanDepthColors(map, oceanLayers.depthFill, s.depthStops, s.depthOpacity);
|
||||||
applyOceanContourStyle(map, oceanLayers.contourLine, s.contourVisible, s.contourColor, s.contourOpacity, s.contourWidth);
|
applyOceanContourStyle(map, oceanLayers.contourLine, s.contourVisible, s.contourColor, s.contourOpacity, s.contourWidth);
|
||||||
applyOceanDepthLabels(map, oceanLayers.depthLabel, s.depthLabelsVisible, s.depthLabelColor, s.depthLabelSize);
|
applyOceanDepthLabels(map, oceanLayers.depthLabel, s.depthLabelsVisible, s.depthLabelColor, s.depthLabelSize);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
export type { OceanMapSettings, OceanDepthStop, OceanLabelLanguage, OceanDepthLabelSize } from './model/types';
|
export type { OceanMapSettings, OceanDepthStop, OceanLabelLanguage, OceanDepthLabelSize } from './model/types';
|
||||||
export { DEFAULT_OCEAN_MAP_SETTINGS, OCEAN_PRESET_DEPTH_STOPS } from './model/types';
|
export { DEFAULT_OCEAN_MAP_SETTINGS } from './model/types';
|
||||||
export { resolveOceanStyle } from './lib/resolveOceanStyle';
|
export { resolveOceanStyle } from './lib/resolveOceanStyle';
|
||||||
export { discoverOceanLayers } from './lib/oceanLayerIds';
|
export { discoverOceanLayers } from './lib/oceanLayerIds';
|
||||||
export { useOceanMapSettings } from './hooks/useOceanMapSettings';
|
export { useOceanMapSettings } from './hooks/useOceanMapSettings';
|
||||||
|
|||||||
@ -38,33 +38,14 @@ export interface OceanMapSettings {
|
|||||||
labelLanguage: OceanLabelLanguage;
|
labelLanguage: OceanLabelLanguage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ocean 커스텀 수심 색상 프리셋 (12구간).
|
|
||||||
* 사용자가 "커스텀" 버튼을 누르면 이 값으로 초기화된다.
|
|
||||||
* Ocean 스타일에 어울리는 심해 → 천해 블루 그라데이션.
|
|
||||||
*/
|
|
||||||
export const OCEAN_PRESET_DEPTH_STOPS: OceanDepthStop[] = [
|
|
||||||
{ depth: -11000, color: '#0a0e2a' },
|
|
||||||
{ depth: -8000, color: '#0c1836' },
|
|
||||||
{ depth: -6000, color: '#0e2444' },
|
|
||||||
{ depth: -4000, color: '#103252' },
|
|
||||||
{ depth: -2000, color: '#134060' },
|
|
||||||
{ depth: -1000, color: '#175070' },
|
|
||||||
{ depth: -200, color: '#1c6480' },
|
|
||||||
{ depth: -100, color: '#217890' },
|
|
||||||
{ depth: -50, color: '#288da0' },
|
|
||||||
{ depth: -20, color: '#30a2b0' },
|
|
||||||
{ depth: -10, color: '#3ab5be' },
|
|
||||||
{ depth: 0, color: '#48c8cc' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ocean 스타일 기본 설정.
|
* Ocean 스타일 기본 설정.
|
||||||
* depthStops에 프리셋 색상이 기본 적용되며, 사용자가 자유롭게 변경 가능.
|
* depthStops가 비어있으면 Ocean 스타일의 네이티브 수심 색상을 유지한다.
|
||||||
* "초기화" 시 프리셋 색상으로 복원된다.
|
* 사용자가 커스텀하면 depthStops에 값이 채워져 적용된다.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_OCEAN_MAP_SETTINGS: OceanMapSettings = {
|
export const DEFAULT_OCEAN_MAP_SETTINGS: OceanMapSettings = {
|
||||||
depthStops: OCEAN_PRESET_DEPTH_STOPS,
|
// 빈 배열 = Ocean 스타일 네이티브 색상 사용 (커스텀 안 함)
|
||||||
|
depthStops: [],
|
||||||
depthOpacity: 1,
|
depthOpacity: 1,
|
||||||
|
|
||||||
contourVisible: true,
|
contourVisible: true,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { OceanMapSettings, OceanLabelLanguage, OceanDepthLabelSize, OceanDepthStop } from '../model/types';
|
import type { OceanMapSettings, OceanLabelLanguage, OceanDepthLabelSize, OceanDepthStop } from '../model/types';
|
||||||
import { DEFAULT_OCEAN_MAP_SETTINGS, OCEAN_PRESET_DEPTH_STOPS } from '../model/types';
|
import { DEFAULT_OCEAN_MAP_SETTINGS } from '../model/types';
|
||||||
|
|
||||||
interface OceanMapSettingsPanelProps {
|
interface OceanMapSettingsPanelProps {
|
||||||
value: OceanMapSettings;
|
value: OceanMapSettings;
|
||||||
@ -114,30 +114,12 @@ export function OceanMapSettingsPanel({ value, onChange }: OceanMapSettingsPanel
|
|||||||
<div className="ms-section">
|
<div className="ms-section">
|
||||||
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
수심 구간 색상
|
수심 구간 색상
|
||||||
<span style={{ display: 'flex', gap: 4 }}>
|
<span
|
||||||
{value.depthStops.length > 0 && (
|
className={`ml-2 cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${autoGradient ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
||||||
<span
|
onClick={toggleAutoGradient}
|
||||||
className={`cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${autoGradient ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
|
||||||
onClick={toggleAutoGradient}
|
>
|
||||||
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
|
자동채우기
|
||||||
>
|
|
||||||
자동채우기
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={`cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${value.depthStops.length > 0 ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (value.depthStops.length > 0) {
|
|
||||||
update('depthStops', []);
|
|
||||||
setAutoGradient(false);
|
|
||||||
} else {
|
|
||||||
update('depthStops', OCEAN_PRESET_DEPTH_STOPS);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={value.depthStops.length > 0 ? 'Ocean 스타일 네이티브 색상으로 복원' : '수심 구간별 색상을 직접 지정합니다'}
|
|
||||||
>
|
|
||||||
{value.depthStops.length > 0 ? '기본값' : '커스텀'}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{value.depthStops.length === 0 ? (
|
{value.depthStops.length === 0 ? (
|
||||||
|
|||||||
@ -1,147 +0,0 @@
|
|||||||
import { DISPLAY_TZ } from '../../../shared/lib/datetime';
|
|
||||||
import { haversineNm } from '../../../shared/lib/geo/haversineNm';
|
|
||||||
import type { MultiTrackQueryContext, ProcessedTrack, TrackQueryContext } from '../model/track.types';
|
|
||||||
|
|
||||||
const BOM = '\uFEFF';
|
|
||||||
|
|
||||||
function escCsv(value: string | number | undefined | null): string {
|
|
||||||
if (value == null) return '';
|
|
||||||
const s = String(value);
|
|
||||||
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
|
||||||
return `"${s.replace(/"/g, '""')}"`;
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTimestamp(ms: number): string {
|
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return '';
|
|
||||||
return new Date(ms).toLocaleString('sv-SE', { timeZone: DISPLAY_TZ });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 두 포인트 간 거리(NM)·시간차(h)로 속력(knots) 계산 */
|
|
||||||
function calcSpeedKnots(track: ProcessedTrack, index: number): number {
|
|
||||||
if (index <= 0) return 0;
|
|
||||||
const [lon1, lat1] = track.geometry[index - 1];
|
|
||||||
const [lon2, lat2] = track.geometry[index];
|
|
||||||
const dtMs = track.timestampsMs[index] - track.timestampsMs[index - 1];
|
|
||||||
if (dtMs <= 0) return 0;
|
|
||||||
const distNm = haversineNm(lat1, lon1, lat2, lon2);
|
|
||||||
const hours = dtMs / 3_600_000;
|
|
||||||
return Math.round((distNm / hours) * 100) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 포인트별 1행: mmsi, longitude, latitude, timestamp, speedKnots */
|
|
||||||
export function buildDynamicCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): string {
|
|
||||||
const header = ['mmsi', 'longitude', 'latitude', 'timestamp', 'timestampMs', 'speedKnots'];
|
|
||||||
const rows: string[] = [header.join(',')];
|
|
||||||
|
|
||||||
const mmsi = ctx?.mmsi ?? '';
|
|
||||||
|
|
||||||
for (const track of tracks) {
|
|
||||||
const trackMmsi = multiCtx ? track.targetId : (mmsi || track.targetId);
|
|
||||||
for (let i = 0; i < track.geometry.length; i++) {
|
|
||||||
rows.push(
|
|
||||||
[
|
|
||||||
escCsv(trackMmsi),
|
|
||||||
escCsv(track.geometry[i][0]),
|
|
||||||
escCsv(track.geometry[i][1]),
|
|
||||||
escCsv(fmtTimestamp(track.timestampsMs[i])),
|
|
||||||
escCsv(track.timestampsMs[i]),
|
|
||||||
escCsv(calcSpeedKnots(track, i)),
|
|
||||||
].join(','),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return BOM + rows.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 선박별 1행: mmsi, 선명, 업종, 소유주, 선단, 허가번호 등 전체 메타 */
|
|
||||||
export function buildStaticCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): string {
|
|
||||||
const header = [
|
|
||||||
'mmsi',
|
|
||||||
'shipName',
|
|
||||||
'vesselType',
|
|
||||||
'ownerCn',
|
|
||||||
'ownerRoman',
|
|
||||||
'permitNo',
|
|
||||||
'pairPermitNo',
|
|
||||||
'ton',
|
|
||||||
'callSign',
|
|
||||||
'workSeaArea',
|
|
||||||
'nationalCode',
|
|
||||||
'totalDistanceNm',
|
|
||||||
'avgSpeed',
|
|
||||||
'maxSpeed',
|
|
||||||
'pointCount',
|
|
||||||
'startTime',
|
|
||||||
'endTime',
|
|
||||||
'chnPrmShipName',
|
|
||||||
'chnPrmVesselType',
|
|
||||||
'chnPrmCallsign',
|
|
||||||
'chnPrmImo',
|
|
||||||
];
|
|
||||||
const rows: string[] = [header.join(',')];
|
|
||||||
|
|
||||||
// Build per-mmsi lookup for multi-vessel mode
|
|
||||||
const multiVesselMap = multiCtx
|
|
||||||
? new Map(multiCtx.vessels.map((v) => [String(v.mmsi), v]))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
for (const track of tracks) {
|
|
||||||
const firstTs = track.timestampsMs[0] ?? 0;
|
|
||||||
const lastTs = track.timestampsMs[track.timestampsMs.length - 1] ?? 0;
|
|
||||||
const info = track.chnPrmShipInfo;
|
|
||||||
const mv = multiVesselMap?.get(track.targetId);
|
|
||||||
|
|
||||||
rows.push(
|
|
||||||
[
|
|
||||||
escCsv(mv?.mmsi ?? ctx?.mmsi ?? track.targetId),
|
|
||||||
escCsv(track.shipName),
|
|
||||||
escCsv(mv?.vesselType ?? ctx?.vesselType ?? ''),
|
|
||||||
escCsv(mv?.ownerCn ?? ctx?.ownerCn),
|
|
||||||
escCsv(mv?.ownerRoman ?? ctx?.ownerRoman),
|
|
||||||
escCsv(mv?.permitNo ?? ctx?.permitNo),
|
|
||||||
escCsv(mv?.pairPermitNo ?? ctx?.pairPermitNo),
|
|
||||||
escCsv(mv?.ton ?? ctx?.ton),
|
|
||||||
escCsv(mv?.callSign ?? ctx?.callSign),
|
|
||||||
escCsv(mv?.workSeaArea ?? ctx?.workSeaArea),
|
|
||||||
escCsv(track.nationalCode),
|
|
||||||
escCsv(track.stats.totalDistanceNm),
|
|
||||||
escCsv(track.stats.avgSpeed),
|
|
||||||
escCsv(track.stats.maxSpeed),
|
|
||||||
escCsv(track.stats.pointCount),
|
|
||||||
escCsv(fmtTimestamp(firstTs)),
|
|
||||||
escCsv(fmtTimestamp(lastTs)),
|
|
||||||
escCsv(info?.name),
|
|
||||||
escCsv(info?.vesselType),
|
|
||||||
escCsv(info?.callsign),
|
|
||||||
escCsv(info?.imo),
|
|
||||||
].join(','),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return BOM + rows.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadCsv(csvContent: string, filename: string): void {
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function exportTrackCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): void {
|
|
||||||
const now = new Date();
|
|
||||||
const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
||||||
|
|
||||||
downloadCsv(buildDynamicCsv(tracks, ctx, multiCtx), `track-points-${ts}.csv`);
|
|
||||||
setTimeout(() => {
|
|
||||||
downloadCsv(buildStaticCsv(tracks, ctx, multiCtx), `track-vessel-${ts}.csv`);
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
@ -7,13 +7,6 @@ export interface TrackStats {
|
|||||||
pointCount: number;
|
pointCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChnPrmShipInfo {
|
|
||||||
name?: string;
|
|
||||||
vesselType?: string;
|
|
||||||
callsign?: string;
|
|
||||||
imo?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcessedTrack {
|
export interface ProcessedTrack {
|
||||||
vesselId: string;
|
vesselId: string;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
@ -25,7 +18,6 @@ export interface ProcessedTrack {
|
|||||||
timestampsMs: number[];
|
timestampsMs: number[];
|
||||||
speeds: number[];
|
speeds: number[];
|
||||||
stats: TrackStats;
|
stats: TrackStats;
|
||||||
chnPrmShipInfo?: ChnPrmShipInfo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrentVesselPosition {
|
export interface CurrentVesselPosition {
|
||||||
@ -46,43 +38,6 @@ export interface TrackQueryRequest {
|
|||||||
minutes: number;
|
minutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrackQueryContext {
|
|
||||||
mmsi: number;
|
|
||||||
startTimeIso: string;
|
|
||||||
endTimeIso: string;
|
|
||||||
shipNameHint?: string;
|
|
||||||
shipKindCodeHint?: string;
|
|
||||||
nationalCodeHint?: string;
|
|
||||||
isPermitted: boolean;
|
|
||||||
vesselType?: string;
|
|
||||||
ownerCn?: string | null;
|
|
||||||
ownerRoman?: string | null;
|
|
||||||
permitNo?: string;
|
|
||||||
pairPermitNo?: string | null;
|
|
||||||
ton?: number | null;
|
|
||||||
callSign?: string;
|
|
||||||
workSeaArea?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MultiTrackQueryContext {
|
|
||||||
vessels: Array<{
|
|
||||||
mmsi: number;
|
|
||||||
shipNameHint?: string;
|
|
||||||
isPermitted: boolean;
|
|
||||||
vesselType?: string;
|
|
||||||
ownerCn?: string | null;
|
|
||||||
ownerRoman?: string | null;
|
|
||||||
permitNo?: string;
|
|
||||||
pairPermitNo?: string | null;
|
|
||||||
ton?: number | null;
|
|
||||||
callSign?: string;
|
|
||||||
workSeaArea?: string;
|
|
||||||
shipCode?: string;
|
|
||||||
}>;
|
|
||||||
startTimeIso: string;
|
|
||||||
endTimeIso: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReplayStreamQueryRequest {
|
export interface ReplayStreamQueryRequest {
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
|
|||||||
@ -9,16 +9,6 @@ type QueryTrackByMmsiParams = {
|
|||||||
isPermitted?: boolean;
|
isPermitted?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QueryTrackByDateRangeParams = {
|
|
||||||
mmsi: number;
|
|
||||||
startTimeIso: string;
|
|
||||||
endTimeIso: string;
|
|
||||||
shipNameHint?: string;
|
|
||||||
shipKindCodeHint?: string;
|
|
||||||
nationalCodeHint?: string;
|
|
||||||
isPermitted?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type V2TrackResponse = {
|
type V2TrackResponse = {
|
||||||
vesselId?: string;
|
vesselId?: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
@ -110,26 +100,23 @@ function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] {
|
|||||||
maxSpeed: row.maxSpeed || 0,
|
maxSpeed: row.maxSpeed || 0,
|
||||||
pointCount: row.pointCount || geometry.length,
|
pointCount: row.pointCount || geometry.length,
|
||||||
},
|
},
|
||||||
chnPrmShipInfo: row.chnPrmShipInfo ? { ...row.chnPrmShipInfo } : undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchV2Tracks(
|
async function queryV2Track(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
|
||||||
startTimeIso: string,
|
|
||||||
endTimeIso: string,
|
|
||||||
mmsis: number[],
|
|
||||||
hasPermitted: boolean,
|
|
||||||
): Promise<ProcessedTrack[]> {
|
|
||||||
const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '/signal-batch').trim();
|
const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '/signal-batch').trim();
|
||||||
|
|
||||||
|
const end = new Date();
|
||||||
|
const start = new Date(end.getTime() - params.minutes * 60_000);
|
||||||
|
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
startTime: startTimeIso,
|
startTime: start.toISOString(),
|
||||||
endTime: endTimeIso,
|
endTime: end.toISOString(),
|
||||||
vessels: mmsis.map(String),
|
vessels: [String(params.mmsi)],
|
||||||
includeChnPrmShip: hasPermitted,
|
includeChnPrmShip: params.isPermitted ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`;
|
const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`;
|
||||||
@ -154,22 +141,5 @@ async function fetchV2Tracks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
|
export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
|
||||||
const end = new Date();
|
return queryV2Track(params);
|
||||||
const start = new Date(end.getTime() - params.minutes * 60_000);
|
|
||||||
return fetchV2Tracks(start.toISOString(), end.toISOString(), [params.mmsi], params.isPermitted ?? false);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function queryTrackByDateRange(params: QueryTrackByDateRangeParams): Promise<ProcessedTrack[]> {
|
|
||||||
return fetchV2Tracks(params.startTimeIso, params.endTimeIso, [params.mmsi], params.isPermitted ?? false);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QueryMultiTrackParams = {
|
|
||||||
mmsis: number[];
|
|
||||||
startTimeIso: string;
|
|
||||||
endTimeIso: string;
|
|
||||||
hasPermitted: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function queryMultiTrack(params: QueryMultiTrackParams): Promise<ProcessedTrack[]> {
|
|
||||||
return fetchV2Tracks(params.startTimeIso, params.endTimeIso, params.mmsis, params.hasPermitted);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { getTracksTimeRange } from '../lib/adapters';
|
import { getTracksTimeRange } from '../lib/adapters';
|
||||||
import type { MultiTrackQueryContext, ProcessedTrack, TrackQueryContext } from '../model/track.types';
|
import type { ProcessedTrack } from '../model/track.types';
|
||||||
import { queryMultiTrack, queryTrackByDateRange } from '../services/trackQueryService';
|
|
||||||
import { useTrackPlaybackStore } from './trackPlaybackStore';
|
import { useTrackPlaybackStore } from './trackPlaybackStore';
|
||||||
|
|
||||||
export type TrackQueryStatus = 'idle' | 'loading' | 'ready' | 'error';
|
export type TrackQueryStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||||
@ -15,20 +14,16 @@ interface TrackQueryState {
|
|||||||
queryState: TrackQueryStatus;
|
queryState: TrackQueryStatus;
|
||||||
renderEpoch: number;
|
renderEpoch: number;
|
||||||
lastQueryKey: string | null;
|
lastQueryKey: string | null;
|
||||||
queryContext: TrackQueryContext | null;
|
|
||||||
multiQueryContext: MultiTrackQueryContext | null;
|
|
||||||
showPoints: boolean;
|
showPoints: boolean;
|
||||||
showVirtualShip: boolean;
|
showVirtualShip: boolean;
|
||||||
showLabels: boolean;
|
showLabels: boolean;
|
||||||
showTrail: boolean;
|
showTrail: boolean;
|
||||||
hideLiveShips: boolean;
|
hideLiveShips: boolean;
|
||||||
|
|
||||||
beginQuery: (queryKey: string, context?: TrackQueryContext) => void;
|
beginQuery: (queryKey: string) => void;
|
||||||
beginMultiQuery: (queryKey: string, ctx: MultiTrackQueryContext) => Promise<void>;
|
|
||||||
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => void;
|
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => void;
|
||||||
applyQueryError: (error: string, queryKey?: string | null) => void;
|
applyQueryError: (error: string, queryKey?: string | null) => void;
|
||||||
closeQuery: () => void;
|
closeQuery: () => void;
|
||||||
requery: (startTimeIso: string, endTimeIso: string) => Promise<void>;
|
|
||||||
|
|
||||||
setTracks: (tracks: ProcessedTrack[]) => void;
|
setTracks: (tracks: ProcessedTrack[]) => void;
|
||||||
clearTracks: () => void;
|
clearTracks: () => void;
|
||||||
@ -54,15 +49,13 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
|
|||||||
queryState: 'idle',
|
queryState: 'idle',
|
||||||
renderEpoch: 0,
|
renderEpoch: 0,
|
||||||
lastQueryKey: null,
|
lastQueryKey: null,
|
||||||
queryContext: null,
|
|
||||||
multiQueryContext: null,
|
|
||||||
showPoints: true,
|
showPoints: true,
|
||||||
showVirtualShip: true,
|
showVirtualShip: true,
|
||||||
showLabels: true,
|
showLabels: true,
|
||||||
showTrail: true,
|
showTrail: true,
|
||||||
hideLiveShips: false,
|
hideLiveShips: false,
|
||||||
|
|
||||||
beginQuery: (queryKey: string, context?: TrackQueryContext) => {
|
beginQuery: (queryKey: string) => {
|
||||||
useTrackPlaybackStore.getState().reset();
|
useTrackPlaybackStore.getState().reset();
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
tracks: [],
|
tracks: [],
|
||||||
@ -73,51 +66,14 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
|
|||||||
queryState: 'loading',
|
queryState: 'loading',
|
||||||
renderEpoch: state.renderEpoch + 1,
|
renderEpoch: state.renderEpoch + 1,
|
||||||
lastQueryKey: queryKey,
|
lastQueryKey: queryKey,
|
||||||
hideLiveShips: true,
|
hideLiveShips: false,
|
||||||
queryContext: context ?? state.queryContext,
|
|
||||||
multiQueryContext: null,
|
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
beginMultiQuery: async (queryKey: string, ctx: MultiTrackQueryContext) => {
|
|
||||||
useTrackPlaybackStore.getState().reset();
|
|
||||||
set((state) => ({
|
|
||||||
tracks: [],
|
|
||||||
disabledVesselIds: new Set<string>(),
|
|
||||||
highlightedVesselId: null,
|
|
||||||
isLoading: true,
|
|
||||||
error: null,
|
|
||||||
queryState: 'loading',
|
|
||||||
renderEpoch: state.renderEpoch + 1,
|
|
||||||
lastQueryKey: queryKey,
|
|
||||||
hideLiveShips: true,
|
|
||||||
queryContext: null,
|
|
||||||
multiQueryContext: ctx,
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mmsis = ctx.vessels.map((v) => v.mmsi);
|
|
||||||
const hasPermitted = ctx.vessels.some((v) => v.isPermitted);
|
|
||||||
const tracks = await queryMultiTrack({
|
|
||||||
mmsis,
|
|
||||||
startTimeIso: ctx.startTimeIso,
|
|
||||||
endTimeIso: ctx.endTimeIso,
|
|
||||||
hasPermitted,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tracks.length > 0) {
|
|
||||||
get().applyTracksSuccess(tracks, queryKey);
|
|
||||||
} else {
|
|
||||||
get().applyQueryError('항적 데이터가 없습니다.', queryKey);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
get().applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => {
|
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => {
|
||||||
const currentQueryKey = get().lastQueryKey;
|
const currentQueryKey = get().lastQueryKey;
|
||||||
if (queryKey != null && queryKey !== currentQueryKey) {
|
if (queryKey != null && queryKey !== currentQueryKey) {
|
||||||
|
// Ignore stale async responses from an older query.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,6 +113,7 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
|
|||||||
applyQueryError: (error: string, queryKey?: string | null) => {
|
applyQueryError: (error: string, queryKey?: string | null) => {
|
||||||
const currentQueryKey = get().lastQueryKey;
|
const currentQueryKey = get().lastQueryKey;
|
||||||
if (queryKey != null && queryKey !== currentQueryKey) {
|
if (queryKey != null && queryKey !== currentQueryKey) {
|
||||||
|
// Ignore stale async errors from an older query.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,51 +142,10 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
|
|||||||
queryState: 'idle',
|
queryState: 'idle',
|
||||||
renderEpoch: state.renderEpoch + 1,
|
renderEpoch: state.renderEpoch + 1,
|
||||||
lastQueryKey: null,
|
lastQueryKey: null,
|
||||||
queryContext: null,
|
|
||||||
multiQueryContext: null,
|
|
||||||
hideLiveShips: false,
|
hideLiveShips: false,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
requery: async (startTimeIso: string, endTimeIso: string) => {
|
|
||||||
const multiCtx = get().multiQueryContext;
|
|
||||||
if (multiCtx) {
|
|
||||||
const queryKey = `requery-multi:${Date.now()}`;
|
|
||||||
const updatedCtx: MultiTrackQueryContext = { ...multiCtx, startTimeIso, endTimeIso };
|
|
||||||
// Preserve multiQueryContext across beginMultiQuery
|
|
||||||
await get().beginMultiQuery(queryKey, updatedCtx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = get().queryContext;
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const queryKey = `requery:${ctx.mmsi}:${Date.now()}`;
|
|
||||||
const updatedContext: TrackQueryContext = { ...ctx, startTimeIso, endTimeIso };
|
|
||||||
|
|
||||||
get().beginQuery(queryKey, updatedContext);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tracks = await queryTrackByDateRange({
|
|
||||||
mmsi: updatedContext.mmsi,
|
|
||||||
startTimeIso: updatedContext.startTimeIso,
|
|
||||||
endTimeIso: updatedContext.endTimeIso,
|
|
||||||
shipNameHint: updatedContext.shipNameHint,
|
|
||||||
shipKindCodeHint: updatedContext.shipKindCodeHint,
|
|
||||||
nationalCodeHint: updatedContext.nationalCodeHint,
|
|
||||||
isPermitted: updatedContext.isPermitted,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tracks.length > 0) {
|
|
||||||
get().applyTracksSuccess(tracks, queryKey);
|
|
||||||
} else {
|
|
||||||
get().applyQueryError('항적 데이터가 없습니다.', queryKey);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
get().applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setTracks: (tracks: ProcessedTrack[]) => {
|
setTracks: (tracks: ProcessedTrack[]) => {
|
||||||
get().applyTracksSuccess(tracks, get().lastQueryKey);
|
get().applyTracksSuccess(tracks, get().lastQueryKey);
|
||||||
},
|
},
|
||||||
@ -284,8 +200,6 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
|
|||||||
queryState: 'idle',
|
queryState: 'idle',
|
||||||
renderEpoch: state.renderEpoch + 1,
|
renderEpoch: state.renderEpoch + 1,
|
||||||
lastQueryKey: null,
|
lastQueryKey: null,
|
||||||
queryContext: null,
|
|
||||||
multiQueryContext: null,
|
|
||||||
showPoints: true,
|
showPoints: true,
|
||||||
showVirtualShip: true,
|
showVirtualShip: true,
|
||||||
showLabels: true,
|
showLabels: true,
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
import { usePersistedState } from '../../../shared/hooks/usePersistedState';
|
|
||||||
import type { VesselGroup } from '../model/types';
|
|
||||||
import { MAX_VESSEL_GROUPS } from '../model/types';
|
|
||||||
|
|
||||||
export interface VesselGroupsState {
|
|
||||||
groups: VesselGroup[];
|
|
||||||
/** 동명 그룹 존재 시 갱신, 신규 시 추가. 10개 초과 시 경고 문자열 반환 */
|
|
||||||
saveGroup: (name: string, mmsis: number[]) => string | null;
|
|
||||||
deleteGroup: (id: string) => void;
|
|
||||||
applyGroup: (group: VesselGroup) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVesselGroups(
|
|
||||||
userId: number | null,
|
|
||||||
setMmsis: (mmsis: Set<number>) => void,
|
|
||||||
): VesselGroupsState {
|
|
||||||
const [rawGroups, setRawGroups] = usePersistedState<VesselGroup[]>(userId, 'vesselGroups', []);
|
|
||||||
|
|
||||||
const groups = useMemo(
|
|
||||||
() => [...rawGroups].sort((a, b) => b.updatedAt - a.updatedAt),
|
|
||||||
[rawGroups],
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveGroup = useCallback(
|
|
||||||
(name: string, mmsis: number[]): string | null => {
|
|
||||||
const trimmed = name.trim();
|
|
||||||
if (!trimmed) return '그룹명을 입력해주세요';
|
|
||||||
|
|
||||||
let warning: string | null = null;
|
|
||||||
|
|
||||||
setRawGroups((prev) => {
|
|
||||||
const existing = prev.find((g) => g.name === trimmed);
|
|
||||||
if (existing) {
|
|
||||||
return prev.map((g) =>
|
|
||||||
g.id === existing.id ? { ...g, mmsis, updatedAt: Date.now() } : g,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (prev.length >= MAX_VESSEL_GROUPS) {
|
|
||||||
warning = `최대 ${MAX_VESSEL_GROUPS}개까지 저장 가능합니다`;
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return [...prev, { id: Date.now().toString(36), name: trimmed, mmsis, updatedAt: Date.now() }];
|
|
||||||
});
|
|
||||||
|
|
||||||
return warning;
|
|
||||||
},
|
|
||||||
[setRawGroups],
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteGroup = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
setRawGroups((prev) => prev.filter((g) => g.id !== id));
|
|
||||||
},
|
|
||||||
[setRawGroups],
|
|
||||||
);
|
|
||||||
|
|
||||||
const applyGroup = useCallback(
|
|
||||||
(group: VesselGroup) => {
|
|
||||||
setMmsis(new Set(group.mmsis));
|
|
||||||
},
|
|
||||||
[setMmsis],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { groups, saveGroup, deleteGroup, applyGroup };
|
|
||||||
}
|
|
||||||
@ -1,286 +0,0 @@
|
|||||||
import { useCallback, useState } from 'react';
|
|
||||||
import type { DerivedLegacyVessel } from '../../legacyDashboard/model/types';
|
|
||||||
import type { MultiTrackQueryContext } from '../../trackReplay/model/track.types';
|
|
||||||
import { useTrackQueryStore } from '../../trackReplay/stores/trackQueryStore';
|
|
||||||
import { MAX_VESSEL_SELECT, MAX_QUERY_DAYS } from '../model/types';
|
|
||||||
import type { VesselGroup } from '../model/types';
|
|
||||||
import { useVesselGroups } from './useVesselGroups';
|
|
||||||
|
|
||||||
/** ms → datetime-local input value (KST = UTC+9) */
|
|
||||||
function toDateTimeLocalKST(ms: number): string {
|
|
||||||
const kstDate = new Date(ms + 9 * 3600_000);
|
|
||||||
return kstDate.toISOString().slice(0, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** datetime-local value (KST) → ISO string */
|
|
||||||
function fromDateTimeLocalKST(value: string): string {
|
|
||||||
return `${value}:00+09:00`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_DAYS = 7;
|
|
||||||
|
|
||||||
export interface VesselSelectModalState {
|
|
||||||
isOpen: boolean;
|
|
||||||
open: () => void;
|
|
||||||
reopen: () => void;
|
|
||||||
close: () => void;
|
|
||||||
|
|
||||||
selectedMmsis: Set<number>;
|
|
||||||
toggleMmsi: (mmsi: number) => void;
|
|
||||||
setMmsis: (mmsis: Set<number>) => void;
|
|
||||||
selectAllFiltered: (filtered: DerivedLegacyVessel[]) => void;
|
|
||||||
clearAll: () => void;
|
|
||||||
|
|
||||||
searchQuery: string;
|
|
||||||
setSearchQuery: (q: string) => void;
|
|
||||||
|
|
||||||
shipCodeFilter: Set<string>;
|
|
||||||
toggleShipCode: (code: string) => void;
|
|
||||||
toggleAllShipCodes: (allCodes: string[]) => void;
|
|
||||||
|
|
||||||
onlySailing: boolean;
|
|
||||||
setOnlySailing: (v: boolean) => void;
|
|
||||||
|
|
||||||
stateFilter: Set<string>;
|
|
||||||
toggleStateFilter: (label: string) => void;
|
|
||||||
toggleAllStates: (allLabels: string[]) => void;
|
|
||||||
|
|
||||||
startTime: string;
|
|
||||||
endTime: string;
|
|
||||||
setStartTime: (v: string) => void;
|
|
||||||
setEndTime: (v: string) => void;
|
|
||||||
applyPresetDays: (hours: number) => void;
|
|
||||||
|
|
||||||
isQuerying: boolean;
|
|
||||||
submitQuery: (allVessels: DerivedLegacyVessel[]) => void;
|
|
||||||
|
|
||||||
position: { x: number; y: number };
|
|
||||||
setPosition: (pos: { x: number; y: number }) => void;
|
|
||||||
|
|
||||||
selectionWarning: string | null;
|
|
||||||
|
|
||||||
groups: VesselGroup[];
|
|
||||||
saveGroup: (name: string, mmsis: number[]) => string | null;
|
|
||||||
deleteGroup: (id: string) => void;
|
|
||||||
applyGroup: (group: VesselGroup) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVesselSelectModal(userId: number | null = null): VesselSelectModalState {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [selectedMmsis, setSelectedMmsis] = useState<Set<number>>(new Set());
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [shipCodeFilter, setShipCodeFilter] = useState<Set<string>>(new Set());
|
|
||||||
const [onlySailing, setOnlySailing] = useState(false);
|
|
||||||
const [stateFilter, setStateFilter] = useState<Set<string>>(new Set());
|
|
||||||
const [selectionWarning, setSelectionWarning] = useState<string | null>(null);
|
|
||||||
const [isQuerying, setIsQuerying] = useState(false);
|
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
|
||||||
|
|
||||||
const [startTime, setStartTime] = useState(() => {
|
|
||||||
const n = Date.now();
|
|
||||||
return toDateTimeLocalKST(n - DEFAULT_DAYS * 86_400_000);
|
|
||||||
});
|
|
||||||
const [endTime, setEndTime] = useState(() => toDateTimeLocalKST(Date.now()));
|
|
||||||
|
|
||||||
const open = useCallback(() => {
|
|
||||||
setIsOpen(true);
|
|
||||||
setSelectedMmsis(new Set());
|
|
||||||
setSearchQuery('');
|
|
||||||
setShipCodeFilter(new Set());
|
|
||||||
setOnlySailing(false);
|
|
||||||
setStateFilter(new Set());
|
|
||||||
setSelectionWarning(null);
|
|
||||||
setIsQuerying(false);
|
|
||||||
setPosition({ x: 0, y: 0 });
|
|
||||||
const n = Date.now();
|
|
||||||
setStartTime(toDateTimeLocalKST(n - DEFAULT_DAYS * 86_400_000));
|
|
||||||
setEndTime(toDateTimeLocalKST(n));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reopen = useCallback(() => setIsOpen(true), []);
|
|
||||||
|
|
||||||
const close = useCallback(() => setIsOpen(false), []);
|
|
||||||
|
|
||||||
const toggleMmsi = useCallback((mmsi: number) => {
|
|
||||||
setSelectedMmsis((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(mmsi)) {
|
|
||||||
next.delete(mmsi);
|
|
||||||
setSelectionWarning(null);
|
|
||||||
} else {
|
|
||||||
if (next.size >= MAX_VESSEL_SELECT) {
|
|
||||||
setSelectionWarning(`최대 ${MAX_VESSEL_SELECT}척까지 선택 가능합니다`);
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
next.add(mmsi);
|
|
||||||
setSelectionWarning(null);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setMmsis = useCallback((mmsis: Set<number>) => {
|
|
||||||
if (mmsis.size > MAX_VESSEL_SELECT) {
|
|
||||||
setSelectionWarning(`최대 ${MAX_VESSEL_SELECT}척까지 선택 가능합니다`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedMmsis(mmsis);
|
|
||||||
setSelectionWarning(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { groups, saveGroup, deleteGroup, applyGroup } = useVesselGroups(userId, setMmsis);
|
|
||||||
|
|
||||||
const selectAllFiltered = useCallback((filtered: DerivedLegacyVessel[]) => {
|
|
||||||
const capped = filtered.slice(0, MAX_VESSEL_SELECT);
|
|
||||||
setSelectedMmsis(new Set(capped.map((v) => v.mmsi)));
|
|
||||||
if (filtered.length > MAX_VESSEL_SELECT) {
|
|
||||||
setSelectionWarning(`최대 ${MAX_VESSEL_SELECT}척까지 선택 가능합니다 (${filtered.length}척 중 ${MAX_VESSEL_SELECT}척 선택됨)`);
|
|
||||||
} else {
|
|
||||||
setSelectionWarning(null);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearAll = useCallback(() => {
|
|
||||||
setSelectedMmsis(new Set());
|
|
||||||
setSelectionWarning(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleShipCode = useCallback((code: string) => {
|
|
||||||
setShipCodeFilter((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(code)) next.delete(code);
|
|
||||||
else next.add(code);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleAllShipCodes = useCallback((allCodes: string[]) => {
|
|
||||||
setShipCodeFilter((prev) =>
|
|
||||||
prev.size === allCodes.length ? new Set() : new Set(allCodes),
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleStateFilter = useCallback((label: string) => {
|
|
||||||
setStateFilter((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(label)) next.delete(label);
|
|
||||||
else next.add(label);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleAllStates = useCallback((allLabels: string[]) => {
|
|
||||||
setStateFilter((prev) =>
|
|
||||||
prev.size === allLabels.length ? new Set() : new Set(allLabels),
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const applyPresetDays = useCallback(
|
|
||||||
(days: number) => {
|
|
||||||
const now = Date.now();
|
|
||||||
const spanMs = days * 86_400_000;
|
|
||||||
|
|
||||||
// datetime-local (KST) → ms (UTC)
|
|
||||||
const parseKST = (v: string) => new Date(`${v}:00+09:00`).getTime();
|
|
||||||
const curStart = parseKST(startTime);
|
|
||||||
const curEnd = parseKST(endTime);
|
|
||||||
const curGap = curEnd - curStart;
|
|
||||||
|
|
||||||
if (curGap > spanMs) {
|
|
||||||
// 현재 간격이 프리셋보다 넓으면 → 시작을 종료 기준으로 조정
|
|
||||||
const cappedEnd = Math.min(curEnd, now);
|
|
||||||
setEndTime(toDateTimeLocalKST(cappedEnd));
|
|
||||||
setStartTime(toDateTimeLocalKST(cappedEnd - spanMs));
|
|
||||||
} else {
|
|
||||||
// 시작 기준으로 종료 확장, 종료 max = 현재
|
|
||||||
const newEnd = Math.min(curStart + spanMs, now);
|
|
||||||
setEndTime(toDateTimeLocalKST(newEnd));
|
|
||||||
// 종료가 clamp 되었으면 시작도 조정
|
|
||||||
if (newEnd - curStart < spanMs) {
|
|
||||||
setStartTime(toDateTimeLocalKST(newEnd - spanMs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[startTime, endTime],
|
|
||||||
);
|
|
||||||
|
|
||||||
const submitQuery = useCallback(
|
|
||||||
(allVessels: DerivedLegacyVessel[]) => {
|
|
||||||
const selected = allVessels.filter((v) => selectedMmsis.has(v.mmsi));
|
|
||||||
if (selected.length === 0) return;
|
|
||||||
|
|
||||||
const maxMs = MAX_QUERY_DAYS * 86_400_000;
|
|
||||||
const sMs = new Date(fromDateTimeLocalKST(startTime)).getTime();
|
|
||||||
const eMs = new Date(fromDateTimeLocalKST(endTime)).getTime();
|
|
||||||
if (eMs - sMs > maxMs) {
|
|
||||||
const clampedEnd = toDateTimeLocalKST(sMs + maxMs);
|
|
||||||
setEndTime(clampedEnd);
|
|
||||||
setSelectionWarning(`최대 ${MAX_QUERY_DAYS}일 초과 — 종료일을 자동 조정했습니다`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vessels = selected.map((v) => ({
|
|
||||||
mmsi: v.mmsi,
|
|
||||||
shipNameHint: v.name,
|
|
||||||
isPermitted: true,
|
|
||||||
vesselType: v.shipCode,
|
|
||||||
ownerCn: v.ownerCn,
|
|
||||||
ownerRoman: v.ownerRoman,
|
|
||||||
permitNo: v.permitNo,
|
|
||||||
pairPermitNo: v.pairPermitNo,
|
|
||||||
ton: v.legacy.ton,
|
|
||||||
callSign: v.callsign ?? undefined,
|
|
||||||
workSeaArea: v.workSeaArea ?? undefined,
|
|
||||||
shipCode: v.shipCode,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ctx: MultiTrackQueryContext = {
|
|
||||||
vessels,
|
|
||||||
startTimeIso: fromDateTimeLocalKST(startTime),
|
|
||||||
endTimeIso: fromDateTimeLocalKST(endTime),
|
|
||||||
};
|
|
||||||
|
|
||||||
const queryKey = `multi:${selected.length}:${Date.now()}`;
|
|
||||||
useTrackQueryStore.getState().beginMultiQuery(queryKey, ctx);
|
|
||||||
setIsQuerying(true);
|
|
||||||
setIsOpen(false);
|
|
||||||
},
|
|
||||||
[selectedMmsis, startTime, endTime],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isOpen,
|
|
||||||
open,
|
|
||||||
reopen,
|
|
||||||
close,
|
|
||||||
selectedMmsis,
|
|
||||||
toggleMmsi,
|
|
||||||
setMmsis,
|
|
||||||
selectAllFiltered,
|
|
||||||
clearAll,
|
|
||||||
searchQuery,
|
|
||||||
setSearchQuery,
|
|
||||||
shipCodeFilter,
|
|
||||||
toggleShipCode,
|
|
||||||
toggleAllShipCodes,
|
|
||||||
onlySailing,
|
|
||||||
setOnlySailing,
|
|
||||||
stateFilter,
|
|
||||||
toggleStateFilter,
|
|
||||||
toggleAllStates,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
setStartTime,
|
|
||||||
setEndTime,
|
|
||||||
applyPresetDays,
|
|
||||||
isQuerying,
|
|
||||||
submitQuery,
|
|
||||||
position,
|
|
||||||
setPosition,
|
|
||||||
selectionWarning,
|
|
||||||
groups,
|
|
||||||
saveGroup,
|
|
||||||
deleteGroup,
|
|
||||||
applyGroup,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
export type { VesselDescriptor, VesselGroup } from './model/types';
|
|
||||||
export { MAX_VESSEL_SELECT, MAX_VESSEL_GROUPS } from './model/types';
|
|
||||||
export { useVesselSelectModal } from './hooks/useVesselSelectModal';
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
export interface VesselDescriptor {
|
|
||||||
mmsi: number;
|
|
||||||
shipNameHint?: string;
|
|
||||||
shipKindCodeHint?: string;
|
|
||||||
nationalCodeHint?: string;
|
|
||||||
isPermitted: boolean;
|
|
||||||
vesselType?: string;
|
|
||||||
ownerCn?: string | null;
|
|
||||||
ownerRoman?: string | null;
|
|
||||||
permitNo?: string;
|
|
||||||
pairPermitNo?: string | null;
|
|
||||||
ton?: number | null;
|
|
||||||
callSign?: string;
|
|
||||||
workSeaArea?: string;
|
|
||||||
shipCode?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VesselGroup {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
mmsis: number[];
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MAX_VESSEL_SELECT = 20;
|
|
||||||
export const MAX_QUERY_DAYS = 28;
|
|
||||||
export const MAX_VESSEL_GROUPS = 10;
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useAuth } from "../../shared/auth";
|
import { useAuth } from "../../shared/auth";
|
||||||
import { useTheme } from "../../shared/hooks";
|
import { useTheme } from "../../shared/hooks";
|
||||||
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
||||||
@ -22,7 +22,6 @@ import type { ShipImageInfo } from "../../entities/shipImage/model/types";
|
|||||||
import ShipImageModal from "../../widgets/shipImage/ShipImageModal";
|
import ShipImageModal from "../../widgets/shipImage/ShipImageModal";
|
||||||
import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService";
|
import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService";
|
||||||
import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore";
|
import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore";
|
||||||
import type { TrackQueryContext } from "../../features/trackReplay/model/track.types";
|
|
||||||
import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel";
|
import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel";
|
||||||
import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling";
|
import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling";
|
||||||
import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay";
|
import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay";
|
||||||
@ -30,11 +29,7 @@ import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
|
|||||||
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
|
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
|
||||||
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
||||||
import { OceanMapSettingsPanel } from "../../features/oceanMap/ui/OceanMapSettingsPanel";
|
import { OceanMapSettingsPanel } from "../../features/oceanMap/ui/OceanMapSettingsPanel";
|
||||||
import { EncMapSettingsPanel } from "../../features/encMap/ui/EncMapSettingsPanel";
|
|
||||||
import { useEncMapSettings } from "../../features/encMap/hooks/useEncMapSettings";
|
|
||||||
import { useAnnouncementPopup, AnnouncementModal } from "../../features/announcement";
|
import { useAnnouncementPopup, AnnouncementModal } from "../../features/announcement";
|
||||||
import { useVesselSelectModal } from "../../features/vesselSelect";
|
|
||||||
import { VesselSelectModal } from "../../widgets/vesselSelect/VesselSelectModal";
|
|
||||||
import {
|
import {
|
||||||
buildLegacyHitMap,
|
buildLegacyHitMap,
|
||||||
computeCountsByType,
|
computeCountsByType,
|
||||||
@ -71,9 +66,6 @@ export function DashboardPage() {
|
|||||||
// ── Announcement popup ──
|
// ── Announcement popup ──
|
||||||
const { hasUnread, unreadAnnouncements, acknowledge } = useAnnouncementPopup(uid);
|
const { hasUnread, unreadAnnouncements, acknowledge } = useAnnouncementPopup(uid);
|
||||||
|
|
||||||
// ── Vessel select modal (multi-track) ──
|
|
||||||
const vesselSelectModal = useVesselSelectModal(uid);
|
|
||||||
|
|
||||||
// ── Data fetching ──
|
// ── Data fetching ──
|
||||||
const { data: zones, error: zonesError } = useZones();
|
const { data: zones, error: zonesError } = useZones();
|
||||||
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
||||||
@ -111,14 +103,6 @@ export function DashboardPage() {
|
|||||||
alarmKindEnabled,
|
alarmKindEnabled,
|
||||||
} = state;
|
} = state;
|
||||||
|
|
||||||
// ── ENC map settings (runtime updates) ──
|
|
||||||
const mapRefForEnc = useRef<import('maplibre-gl').Map | null>(null);
|
|
||||||
const handleMapReadyWithRef = useCallback((map: import('maplibre-gl').Map) => {
|
|
||||||
mapRefForEnc.current = map;
|
|
||||||
handleMapReady(map);
|
|
||||||
}, [handleMapReady]);
|
|
||||||
useEncMapSettings(mapRefForEnc, baseMap, state.encMapSettings);
|
|
||||||
|
|
||||||
// ── Weather ──
|
// ── Weather ──
|
||||||
const weather = useWeatherPolling(zones);
|
const weather = useWeatherPolling(zones);
|
||||||
const weatherOverlay = useWeatherOverlay(mapInstance);
|
const weatherOverlay = useWeatherOverlay(mapInstance);
|
||||||
@ -150,33 +134,11 @@ export function DashboardPage() {
|
|||||||
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
|
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
|
||||||
const trackStore = useTrackQueryStore.getState();
|
const trackStore = useTrackQueryStore.getState();
|
||||||
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
|
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
|
||||||
|
trackStore.beginQuery(queryKey);
|
||||||
const target = targets.find((item) => item.mmsi === mmsi);
|
|
||||||
const isPermitted = legacyHits.has(mmsi);
|
|
||||||
|
|
||||||
const endDate = new Date();
|
|
||||||
const startDate = new Date(endDate.getTime() - minutes * 60_000);
|
|
||||||
const legacy = legacyHits.get(mmsi);
|
|
||||||
const context: TrackQueryContext = {
|
|
||||||
mmsi,
|
|
||||||
startTimeIso: startDate.toISOString(),
|
|
||||||
endTimeIso: endDate.toISOString(),
|
|
||||||
shipNameHint: target?.name,
|
|
||||||
shipKindCodeHint: target?.shipKindCode,
|
|
||||||
nationalCodeHint: target?.nationalCode,
|
|
||||||
isPermitted,
|
|
||||||
vesselType: legacy?.shipCode,
|
|
||||||
ownerCn: legacy?.ownerCn,
|
|
||||||
ownerRoman: legacy?.ownerRoman,
|
|
||||||
permitNo: legacy?.permitNo,
|
|
||||||
pairPermitNo: legacy?.pairPermitNo,
|
|
||||||
ton: legacy?.ton,
|
|
||||||
callSign: legacy?.callSign,
|
|
||||||
workSeaArea: legacy?.workSeaArea,
|
|
||||||
};
|
|
||||||
trackStore.beginQuery(queryKey, context);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const target = targets.find((item) => item.mmsi === mmsi);
|
||||||
|
const isPermitted = legacyHits.has(mmsi);
|
||||||
const tracks = await queryTrackByMmsi({
|
const tracks = await queryTrackByMmsi({
|
||||||
mmsi,
|
mmsi,
|
||||||
minutes,
|
minutes,
|
||||||
@ -366,7 +328,6 @@ export function DashboardPage() {
|
|||||||
onToggleTheme={toggleTheme}
|
onToggleTheme={toggleTheme}
|
||||||
isSidebarOpen={isSidebarOpen}
|
isSidebarOpen={isSidebarOpen}
|
||||||
onMenuToggle={() => setIsSidebarOpen((v) => !v)}
|
onMenuToggle={() => setIsSidebarOpen((v) => !v)}
|
||||||
onOpenMultiTrack={vesselSelectModal.open}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DashboardSidebar
|
<DashboardSidebar
|
||||||
@ -452,20 +413,13 @@ export function DashboardPage() {
|
|||||||
onRequestTrack={handleRequestTrack}
|
onRequestTrack={handleRequestTrack}
|
||||||
onCloseTrackMenu={handleCloseTrackMenu}
|
onCloseTrackMenu={handleCloseTrackMenu}
|
||||||
onOpenTrackMenu={handleOpenTrackMenu}
|
onOpenTrackMenu={handleOpenTrackMenu}
|
||||||
onMapReady={handleMapReadyWithRef}
|
onMapReady={handleMapReady}
|
||||||
alarmMmsiMap={alarmMmsiMap}
|
alarmMmsiMap={alarmMmsiMap}
|
||||||
onClickShipPhoto={handleOpenImageModal}
|
onClickShipPhoto={handleOpenImageModal}
|
||||||
freeCamera={state.freeCamera}
|
freeCamera={state.freeCamera}
|
||||||
oceanMapSettings={state.oceanMapSettings}
|
oceanMapSettings={state.oceanMapSettings}
|
||||||
encMapSettings={state.encMapSettings}
|
|
||||||
/>
|
|
||||||
<GlobalTrackReplayPanel
|
|
||||||
isVesselListOpen={vesselSelectModal.isOpen}
|
|
||||||
onToggleVesselList={() => {
|
|
||||||
if (vesselSelectModal.isOpen) vesselSelectModal.close();
|
|
||||||
else vesselSelectModal.reopen();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
<GlobalTrackReplayPanel />
|
||||||
<WeatherPanel
|
<WeatherPanel
|
||||||
snapshot={weather.snapshot}
|
snapshot={weather.snapshot}
|
||||||
isLoading={weather.isLoading}
|
isLoading={weather.isLoading}
|
||||||
@ -475,12 +429,10 @@ export function DashboardPage() {
|
|||||||
<WeatherOverlayPanel {...weatherOverlay} />
|
<WeatherOverlayPanel {...weatherOverlay} />
|
||||||
{baseMap === 'ocean' ? (
|
{baseMap === 'ocean' ? (
|
||||||
<OceanMapSettingsPanel value={state.oceanMapSettings} onChange={state.setOceanMapSettings} />
|
<OceanMapSettingsPanel value={state.oceanMapSettings} onChange={state.setOceanMapSettings} />
|
||||||
) : baseMap === 'enc' ? (
|
|
||||||
<EncMapSettingsPanel value={state.encMapSettings} onChange={state.setEncMapSettings} />
|
|
||||||
) : (
|
) : (
|
||||||
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
||||||
)}
|
)}
|
||||||
{baseMap !== 'ocean' && baseMap !== 'enc' && <DepthLegend depthStops={mapStyleSettings.depthStops} />}
|
{baseMap !== 'ocean' && <DepthLegend depthStops={mapStyleSettings.depthStops} />}
|
||||||
<MapLegend />
|
<MapLegend />
|
||||||
{selectedLegacyVessel ? (
|
{selectedLegacyVessel ? (
|
||||||
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} imo={selectedTarget && selectedTarget.imo > 0 ? selectedTarget.imo : undefined} shipImagePath={selectedTarget?.shipImagePath} shipImageCount={selectedTarget?.shipImageCount} onOpenImageModal={handlePanelOpenImageModal} />
|
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} imo={selectedTarget && selectedTarget.imo > 0 ? selectedTarget.imo : undefined} shipImagePath={selectedTarget?.shipImagePath} shipImageCount={selectedTarget?.shipImageCount} onOpenImageModal={handlePanelOpenImageModal} />
|
||||||
@ -490,9 +442,6 @@ export function DashboardPage() {
|
|||||||
{hasUnread && (
|
{hasUnread && (
|
||||||
<AnnouncementModal announcements={unreadAnnouncements} onConfirm={acknowledge} />
|
<AnnouncementModal announcements={unreadAnnouncements} onConfirm={acknowledge} />
|
||||||
)}
|
)}
|
||||||
{vesselSelectModal.isOpen && (
|
|
||||||
<VesselSelectModal modal={vesselSelectModal} vessels={legacyVesselsAll} />
|
|
||||||
)}
|
|
||||||
{imageModal && (
|
{imageModal && (
|
||||||
<ShipImageModal
|
<ShipImageModal
|
||||||
images={imageModal.images}
|
images={imageModal.images}
|
||||||
|
|||||||
@ -157,10 +157,10 @@ export function DashboardSidebar({
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
title="지도 설정"
|
title="지도 표시 설정"
|
||||||
className="md:shrink-0"
|
className="md:shrink-0"
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex gap-1">
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
on={freeCamera}
|
on={freeCamera}
|
||||||
onClick={toggleFreeCamera}
|
onClick={toggleFreeCamera}
|
||||||
@ -169,6 +169,14 @@ export function DashboardSidebar({
|
|||||||
>
|
>
|
||||||
자유 시점
|
자유 시점
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
|
<ToggleButton
|
||||||
|
on={baseMap === 'ocean'}
|
||||||
|
onClick={() => setBaseMap(baseMap === 'ocean' ? 'enhanced' : 'ocean')}
|
||||||
|
title="Ocean 전용 지도 (해양 정보 극대화)"
|
||||||
|
className="px-2 py-0.5 text-[9px]"
|
||||||
|
>
|
||||||
|
Ocean
|
||||||
|
</ToggleButton>
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
on={projection === 'globe'}
|
on={projection === 'globe'}
|
||||||
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))}
|
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))}
|
||||||
@ -177,31 +185,6 @@ export function DashboardSidebar({
|
|||||||
>
|
>
|
||||||
3D
|
3D
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<span className="mx-0.5 h-3 w-px bg-wing-border" />
|
|
||||||
<ToggleButton
|
|
||||||
on={baseMap === 'enhanced'}
|
|
||||||
onClick={() => setBaseMap('enhanced')}
|
|
||||||
title="기본 지도 (MapTiler Enhanced)"
|
|
||||||
className="px-2 py-0.5 text-[9px]"
|
|
||||||
>
|
|
||||||
Base
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton
|
|
||||||
on={baseMap === 'enc'}
|
|
||||||
onClick={() => setBaseMap('enc')}
|
|
||||||
title="ENC 전자해도"
|
|
||||||
className="px-2 py-0.5 text-[9px]"
|
|
||||||
>
|
|
||||||
ENC
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton
|
|
||||||
on={baseMap === 'ocean'}
|
|
||||||
onClick={() => setBaseMap('ocean')}
|
|
||||||
title="Ocean 전용 지도 (해양 정보 극대화)"
|
|
||||||
className="px-2 py-0.5 text-[9px]"
|
|
||||||
>
|
|
||||||
Ocean
|
|
||||||
</ToggleButton>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -10,8 +10,6 @@ import { DEFAULT_MAP_STYLE_SETTINGS } from '../../features/mapSettings/types';
|
|||||||
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
||||||
import { DEFAULT_OCEAN_MAP_SETTINGS } from '../../features/oceanMap/model/types';
|
import { DEFAULT_OCEAN_MAP_SETTINGS } from '../../features/oceanMap/model/types';
|
||||||
import type { OceanMapSettings } from '../../features/oceanMap/model/types';
|
import type { OceanMapSettings } from '../../features/oceanMap/model/types';
|
||||||
import { DEFAULT_ENC_MAP_SETTINGS } from '../../features/encMap/model/types';
|
|
||||||
import type { EncMapSettings } from '../../features/encMap/model/types';
|
|
||||||
import { fmtDateTimeFull } from '../../shared/lib/datetime';
|
import { fmtDateTimeFull } from '../../shared/lib/datetime';
|
||||||
|
|
||||||
export type Bbox = [number, number, number, number];
|
export type Bbox = [number, number, number, number];
|
||||||
@ -48,17 +46,14 @@ export function useDashboardState(uid: number | null) {
|
|||||||
const [projection, setProjection] = useState<MapProjectionId>('mercator');
|
const [projection, setProjection] = useState<MapProjectionId>('mercator');
|
||||||
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
|
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
|
||||||
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
|
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
|
||||||
pairLines: false, pairRange: false, fcLines: false, zones: true,
|
pairLines: true, pairRange: true, fcLines: true, zones: true,
|
||||||
fleetCircles: false, predictVectors: false, shipLabels: true, subcables: false,
|
fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false,
|
||||||
});
|
});
|
||||||
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
|
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
|
||||||
showShips: true, showDensity: false, showSeamark: false,
|
showShips: true, showDensity: false, showSeamark: false,
|
||||||
});
|
});
|
||||||
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
|
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
|
||||||
const [oceanMapSettings, setOceanMapSettings] = usePersistedState<OceanMapSettings>(uid, 'oceanMapSettings', DEFAULT_OCEAN_MAP_SETTINGS);
|
const [oceanMapSettings, setOceanMapSettings] = usePersistedState<OceanMapSettings>(uid, 'oceanMapSettings', DEFAULT_OCEAN_MAP_SETTINGS);
|
||||||
const [encMapSettingsRaw, setEncMapSettings] = usePersistedState<EncMapSettings>(uid, 'encMapSettings', DEFAULT_ENC_MAP_SETTINGS);
|
|
||||||
// Merge with defaults to fill missing fields from older localStorage entries
|
|
||||||
const encMapSettings: EncMapSettings = { ...DEFAULT_ENC_MAP_SETTINGS, ...encMapSettingsRaw };
|
|
||||||
|
|
||||||
// ── 자유 시점 (모드별 독립) ──
|
// ── 자유 시점 (모드별 독립) ──
|
||||||
const [freeCameraMercator, setFreeCameraMercator] = usePersistedState(uid, 'freeCameraMercator', true);
|
const [freeCameraMercator, setFreeCameraMercator] = usePersistedState(uid, 'freeCameraMercator', true);
|
||||||
@ -73,7 +68,7 @@ export function useDashboardState(uid: number | null) {
|
|||||||
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', 'count');
|
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', 'count');
|
||||||
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
|
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
|
||||||
uid, 'alarmKindEnabled',
|
uid, 'alarmKindEnabled',
|
||||||
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, false])) as Record<LegacyAlarmKind, boolean>,
|
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Fleet focus ──
|
// ── Fleet focus ──
|
||||||
@ -84,8 +79,8 @@ export function useDashboardState(uid: number | null) {
|
|||||||
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
||||||
|
|
||||||
// ── Track context menu ──
|
// ── Track context menu ──
|
||||||
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean } | null>(null);
|
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null);
|
||||||
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean }) => setTrackContextMenu(info), []);
|
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => setTrackContextMenu(info), []);
|
||||||
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
|
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
|
||||||
|
|
||||||
// ── Projection loading ──
|
// ── Projection loading ──
|
||||||
@ -147,9 +142,7 @@ export function useDashboardState(uid: number | null) {
|
|||||||
baseMap, setBaseMap, projection, setProjection,
|
baseMap, setBaseMap, projection, setProjection,
|
||||||
mapStyleSettings, setMapStyleSettings,
|
mapStyleSettings, setMapStyleSettings,
|
||||||
overlays, setOverlays, settings, setSettings,
|
overlays, setOverlays, settings, setSettings,
|
||||||
mapView, setMapView, freeCamera, toggleFreeCamera,
|
mapView, setMapView, freeCamera, toggleFreeCamera, oceanMapSettings, setOceanMapSettings,
|
||||||
oceanMapSettings, setOceanMapSettings,
|
|
||||||
encMapSettings, setEncMapSettings,
|
|
||||||
fleetRelationSortMode, setFleetRelationSortMode,
|
fleetRelationSortMode, setFleetRelationSortMode,
|
||||||
alarmKindEnabled, setAlarmKindEnabled,
|
alarmKindEnabled, setAlarmKindEnabled,
|
||||||
fleetFocus, setFleetFocus,
|
fleetFocus, setFleetFocus,
|
||||||
|
|||||||
@ -1,4 +1,18 @@
|
|||||||
// ── Shared map constants ──
|
// ── Shared map constants ──
|
||||||
|
// Moved from widgets/map3d/constants.ts to resolve FSD layer violation
|
||||||
|
// (features/ must not import from widgets/).
|
||||||
|
|
||||||
|
export const SHIP_ICON_MAPPING = {
|
||||||
|
ship: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
anchorX: 64,
|
||||||
|
anchorY: 64,
|
||||||
|
mask: true,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const DEPTH_DISABLED_PARAMS = {
|
export const DEPTH_DISABLED_PARAMS = {
|
||||||
depthCompare: 'always',
|
depthCompare: 'always',
|
||||||
|
|||||||
@ -1,187 +0,0 @@
|
|||||||
// ── 선종(Ship Kind) 상수 + SVG 아이콘 생성 ──
|
|
||||||
// gc-wing-simple의 SVG 기반 선종별 아이콘 시스템을 도입.
|
|
||||||
// 기타 AIS: 선종별 색상 + 이동(화살표)/정지(원형) 분리.
|
|
||||||
// 대상 선박: legacy code 색상 + 약간 더 큰 SVG + 흰색 테두리.
|
|
||||||
|
|
||||||
import { LEGACY_CODE_COLORS_RGB, rgbToHex, type Rgb } from './palette';
|
|
||||||
|
|
||||||
// ── 선종 상수 (8종) ──
|
|
||||||
|
|
||||||
export const SIGNAL_KIND = {
|
|
||||||
FISHING: '000020',
|
|
||||||
KCGV: '000021',
|
|
||||||
PASSENGER: '000022',
|
|
||||||
CARGO: '000023',
|
|
||||||
TANKER: '000024',
|
|
||||||
GOV: '000025',
|
|
||||||
NORMAL: '000027',
|
|
||||||
BUOY: '000028',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type SignalKindCode = (typeof SIGNAL_KIND)[keyof typeof SIGNAL_KIND] | string;
|
|
||||||
|
|
||||||
/** 선종별 한글 라벨 */
|
|
||||||
export const SHIP_KIND_LABELS: Record<string, string> = {
|
|
||||||
'000020': '어선',
|
|
||||||
'000021': '경비함정',
|
|
||||||
'000022': '여객선',
|
|
||||||
'000023': '화물선',
|
|
||||||
'000024': '유조선',
|
|
||||||
'000025': '관공선',
|
|
||||||
'000027': '일반',
|
|
||||||
'000028': '부이',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 선종별 범례/UI 색상 (hex) */
|
|
||||||
export const SHIP_KIND_COLORS: Record<string, string> = {
|
|
||||||
'000020': '#00C853',
|
|
||||||
'000021': '#FF5722',
|
|
||||||
'000022': '#2196F3',
|
|
||||||
'000023': '#9C27B0',
|
|
||||||
'000024': '#F44336',
|
|
||||||
'000025': '#FF9800',
|
|
||||||
'000027': '#607D8B',
|
|
||||||
'000028': '#795548',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 정렬된 선종 코드 목록 (범례 표시 순서) */
|
|
||||||
export const SHIP_KIND_ORDER = [
|
|
||||||
'000020', '000021', '000022', '000023',
|
|
||||||
'000024', '000025', '000027', '000028',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// ── SVG 아이콘 생성기 ──
|
|
||||||
|
|
||||||
const STROKE = 'rgba(0,0,0,0.6)';
|
|
||||||
const TARGET_STROKE = 'rgba(255,255,255,0.7)';
|
|
||||||
|
|
||||||
/** 이동 중 선박 SVG (화살표 형태, 32×48) */
|
|
||||||
export function makeMovingShipSvg(fill: string): string {
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48"><path d="M16 2 L8 13 L4 28 L7 45 L25 45 L28 28 L24 13 Z" fill="${fill}" stroke="${STROKE}" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 정지 선박 SVG (원형, 16×16) */
|
|
||||||
export function makeStoppedShipSvg(fill: string): string {
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><circle cx="8" cy="8" r="5.5" fill="${fill}" stroke="${STROKE}" stroke-width="1.2"/></svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 부이 SVG (다색, 32×44) */
|
|
||||||
export function makeBuoySvg(): string {
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="44" viewBox="0 0 32 44"><line x1="16" y1="10" x2="16" y2="38" stroke="#5D4037" stroke-width="2.5" stroke-linecap="round"/><line x1="10" y1="38" x2="22" y2="38" stroke="#5D4037" stroke-width="2" stroke-linecap="round"/><ellipse cx="16" cy="24" rx="9" ry="7" fill="#E53935" stroke="#333" stroke-width="1"/><rect x="8" y="22" width="16" height="4" rx="1" fill="#FDD835" opacity="0.85"/><rect x="14.5" y="8" width="3" height="10" fill="#666"/><circle cx="16" cy="7" r="3.5" fill="#FFC107" stroke="#444" stroke-width="0.8"/></svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 대상 선박 이동 SVG (36×52, 흰색 반투명 테두리) */
|
|
||||||
export function makeTargetMovingShipSvg(fill: string): string {
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="52" viewBox="0 0 36 52"><path d="M18 2 L9 14 L5 30 L8 49 L28 49 L31 30 L27 14 Z" fill="${fill}" stroke="${TARGET_STROKE}" stroke-width="2" stroke-linejoin="round"/></svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 대상 선박 정지 SVG (20×20, 흰색 반투명 테두리) */
|
|
||||||
export function makeTargetStoppedShipSvg(fill: string): string {
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="${fill}" stroke="${TARGET_STROKE}" stroke-width="1.5"/></svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDataUri(svg: string): string {
|
|
||||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deck.gl IconLayer getIcon 반환 타입 */
|
|
||||||
export interface ShipIconSpec {
|
|
||||||
url: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
anchorX: number;
|
|
||||||
anchorY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 기타 AIS 아이콘 스펙 사전 생성 (8종 × 2상태 + buoy) ──
|
|
||||||
|
|
||||||
const OTHER_ICON_SPECS: Record<string, ShipIconSpec> = {};
|
|
||||||
|
|
||||||
for (const code of SHIP_KIND_ORDER) {
|
|
||||||
const color = SHIP_KIND_COLORS[code] || '#607D8B';
|
|
||||||
|
|
||||||
if (code === '000028') {
|
|
||||||
OTHER_ICON_SPECS[`${code}-buoy`] = {
|
|
||||||
url: toDataUri(makeBuoySvg()),
|
|
||||||
width: 32, height: 44, anchorX: 16, anchorY: 22,
|
|
||||||
};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
OTHER_ICON_SPECS[`${code}-moving`] = {
|
|
||||||
url: toDataUri(makeMovingShipSvg(color)),
|
|
||||||
width: 32, height: 48, anchorX: 16, anchorY: 24,
|
|
||||||
};
|
|
||||||
OTHER_ICON_SPECS[`${code}-stopped`] = {
|
|
||||||
url: toDataUri(makeStoppedShipSvg(color)),
|
|
||||||
width: 16, height: 16, anchorX: 8, anchorY: 8,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallback
|
|
||||||
const FALLBACK_MOVING: ShipIconSpec = OTHER_ICON_SPECS['000027-moving'];
|
|
||||||
const FALLBACK_STOPPED: ShipIconSpec = OTHER_ICON_SPECS['000027-stopped'];
|
|
||||||
|
|
||||||
// ── 대상 선박 아이콘 스펙 사전 생성 (7 legacyCode × 2상태) ──
|
|
||||||
|
|
||||||
const LEGACY_CODES = ['PT', 'PT-S', 'GN', 'OT', 'PS', 'FC', 'C21'] as const;
|
|
||||||
|
|
||||||
const TARGET_ICON_SPECS: Record<string, ShipIconSpec> = {};
|
|
||||||
|
|
||||||
for (const code of LEGACY_CODES) {
|
|
||||||
const rgb: Rgb = LEGACY_CODE_COLORS_RGB[code] || [100, 116, 139];
|
|
||||||
const hex = rgbToHex(rgb);
|
|
||||||
|
|
||||||
TARGET_ICON_SPECS[`${code}-moving`] = {
|
|
||||||
url: toDataUri(makeTargetMovingShipSvg(hex)),
|
|
||||||
width: 36, height: 52, anchorX: 18, anchorY: 26,
|
|
||||||
};
|
|
||||||
TARGET_ICON_SPECS[`${code}-stopped`] = {
|
|
||||||
url: toDataUri(makeTargetStoppedShipSvg(hex)),
|
|
||||||
width: 20, height: 20, anchorX: 10, anchorY: 10,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallback (FC 색상)
|
|
||||||
const TARGET_FALLBACK_MOVING: ShipIconSpec = TARGET_ICON_SPECS['FC-moving'];
|
|
||||||
const TARGET_FALLBACK_STOPPED: ShipIconSpec = TARGET_ICON_SPECS['FC-stopped'];
|
|
||||||
|
|
||||||
// ── SOG 기준 이동/정지 판단 (kn) ──
|
|
||||||
|
|
||||||
export const SPEED_THRESHOLD_KN = 1;
|
|
||||||
|
|
||||||
// ── 조회 함수 ──
|
|
||||||
|
|
||||||
/** 기타 AIS 아이콘 스펙 조회 */
|
|
||||||
export function getShipIconSpec(
|
|
||||||
signalKindCode: string | undefined | null,
|
|
||||||
sog: number | undefined | null,
|
|
||||||
): ShipIconSpec {
|
|
||||||
const code = signalKindCode || '000027';
|
|
||||||
if (code === '000028') return OTHER_ICON_SPECS['000028-buoy'] || FALLBACK_STOPPED;
|
|
||||||
|
|
||||||
const isMoving = Number.isFinite(sog) && (sog as number) > SPEED_THRESHOLD_KN;
|
|
||||||
const key = `${code}-${isMoving ? 'moving' : 'stopped'}`;
|
|
||||||
return OTHER_ICON_SPECS[key] || (isMoving ? FALLBACK_MOVING : FALLBACK_STOPPED);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 대상 선박 아이콘 스펙 조회 */
|
|
||||||
export function getTargetShipIconSpec(
|
|
||||||
legacyShipCode: string | undefined | null,
|
|
||||||
sog: number | undefined | null,
|
|
||||||
): ShipIconSpec {
|
|
||||||
const code = legacyShipCode || 'FC';
|
|
||||||
const isMoving = Number.isFinite(sog) && (sog as number) > SPEED_THRESHOLD_KN;
|
|
||||||
const key = `${code}-${isMoving ? 'moving' : 'stopped'}`;
|
|
||||||
return TARGET_ICON_SPECS[key] || (isMoving ? TARGET_FALLBACK_MOVING : TARGET_FALLBACK_STOPPED);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 선박 아이콘 회전각 (부이는 0, 나머지는 -cog) */
|
|
||||||
export function getShipIconAngle(
|
|
||||||
signalKindCode: string | undefined | null,
|
|
||||||
cog: number | undefined | null,
|
|
||||||
): number {
|
|
||||||
const code = signalKindCode || '000027';
|
|
||||||
if (code === '000028') return 0;
|
|
||||||
return -(Number.isFinite(cog) ? (cog as number) : 0);
|
|
||||||
}
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
|
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
|
||||||
import { LEGACY_CODE_COLORS_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
|
import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
|
||||||
import { SHIP_KIND_ORDER, SHIP_KIND_LABELS, SHIP_KIND_COLORS } from "../../shared/lib/map/shipKind";
|
|
||||||
|
|
||||||
export function MapLegend() {
|
export function MapLegend() {
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
@ -26,13 +25,23 @@ export function MapLegend() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>기타 AIS 선박(선종)</div>
|
<div className="lt" style={{ marginTop: 8 }}>기타 AIS 선박(속도)</div>
|
||||||
{SHIP_KIND_ORDER.filter((c) => c !== '000028').map((code) => (
|
<div className="li">
|
||||||
<div key={code} className="li">
|
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
|
||||||
<div className="ls" style={{ background: SHIP_KIND_COLORS[code], borderRadius: 999 }} />
|
SOG ≥ 10 kt
|
||||||
{SHIP_KIND_LABELS[code]}
|
</div>
|
||||||
</div>
|
<div className="li">
|
||||||
))}
|
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
|
||||||
|
1 ≤ SOG < 10 kt
|
||||||
|
</div>
|
||||||
|
<div className="li">
|
||||||
|
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
|
||||||
|
SOG < 1 kt
|
||||||
|
</div>
|
||||||
|
<div className="li">
|
||||||
|
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, opacity: 0.55, borderRadius: 999 }} />
|
||||||
|
SOG unknown
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>CN Permit(업종)</div>
|
<div className="lt" style={{ marginTop: 8 }}>CN Permit(업종)</div>
|
||||||
<div className="li">
|
<div className="li">
|
||||||
|
|||||||
@ -29,7 +29,6 @@ import { useSubcablesLayer } from './hooks/useSubcablesLayer';
|
|||||||
import { useTrackReplayLayer } from './hooks/useTrackReplayLayer';
|
import { useTrackReplayLayer } from './hooks/useTrackReplayLayer';
|
||||||
import { useMapStyleSettings } from './hooks/useMapStyleSettings';
|
import { useMapStyleSettings } from './hooks/useMapStyleSettings';
|
||||||
import { useOceanMapSettings } from '../../features/oceanMap/hooks/useOceanMapSettings';
|
import { useOceanMapSettings } from '../../features/oceanMap/hooks/useOceanMapSettings';
|
||||||
import { useShipLabelColor } from './hooks/useShipLabelColor';
|
|
||||||
import { VesselContextMenu } from './components/VesselContextMenu';
|
import { VesselContextMenu } from './components/VesselContextMenu';
|
||||||
import { useLiveShipAdapter } from '../../features/liveRenderer/hooks/useLiveShipAdapter';
|
import { useLiveShipAdapter } from '../../features/liveRenderer/hooks/useLiveShipAdapter';
|
||||||
import { useLiveShipBatchRender } from '../../features/liveRenderer/hooks/useLiveShipBatchRender';
|
import { useLiveShipBatchRender } from '../../features/liveRenderer/hooks/useLiveShipBatchRender';
|
||||||
@ -87,7 +86,6 @@ export function Map3D({
|
|||||||
onClickShipPhoto,
|
onClickShipPhoto,
|
||||||
freeCamera = true,
|
freeCamera = true,
|
||||||
oceanMapSettings,
|
oceanMapSettings,
|
||||||
encMapSettings,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// ── Shared refs ──────────────────────────────────────────────────────
|
// ── Shared refs ──────────────────────────────────────────────────────
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@ -580,7 +578,6 @@ export function Map3D({
|
|||||||
|
|
||||||
useMapStyleSettings(mapRef, mapStyleSettings, { baseMap, mapSyncEpoch });
|
useMapStyleSettings(mapRef, mapStyleSettings, { baseMap, mapSyncEpoch });
|
||||||
useOceanMapSettings(mapRef, oceanMapSettings, { baseMap, mapSyncEpoch });
|
useOceanMapSettings(mapRef, oceanMapSettings, { baseMap, mapSyncEpoch });
|
||||||
const shipLabelColors = useShipLabelColor(mapRef, baseMap, mapSyncEpoch, encMapSettings);
|
|
||||||
|
|
||||||
useZonesLayer(
|
useZonesLayer(
|
||||||
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
|
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
|
||||||
@ -627,7 +624,7 @@ export function Map3D({
|
|||||||
useDeckLayers(
|
useDeckLayers(
|
||||||
mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef,
|
mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef,
|
||||||
{
|
{
|
||||||
projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipData,
|
projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
|
||||||
legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges,
|
legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges,
|
||||||
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
||||||
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
||||||
@ -638,7 +635,6 @@ export function Map3D({
|
|||||||
onDeckSelectOrHighlight, onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi,
|
onDeckSelectOrHighlight, onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi,
|
||||||
ensureMercatorOverlay, alarmMmsiMap,
|
ensureMercatorOverlay, alarmMmsiMap,
|
||||||
onClickShipPhoto,
|
onClickShipPhoto,
|
||||||
shipLabelColors,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -712,12 +708,11 @@ export function Map3D({
|
|||||||
if (hovered.length > 0) mmsi = hovered[0];
|
if (hovered.length > 0) mmsi = hovered[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mmsi == null) return;
|
if (mmsi == null || !legacyHits?.has(mmsi)) return;
|
||||||
|
|
||||||
const target = shipByMmsi.get(mmsi);
|
const target = shipByMmsi.get(mmsi);
|
||||||
const vesselName = (target?.name || '').trim() || `MMSI ${mmsi}`;
|
const vesselName = (target?.name || '').trim() || `MMSI ${mmsi}`;
|
||||||
const isPermitted = legacyHits?.has(mmsi) ?? false;
|
onOpenTrackMenu({ x: e.clientX, y: e.clientY, mmsi, vesselName });
|
||||||
onOpenTrackMenu({ x: e.clientX, y: e.clientY, mmsi, vesselName, isPermitted });
|
|
||||||
};
|
};
|
||||||
container.addEventListener('contextmenu', onContextMenu);
|
container.addEventListener('contextmenu', onContextMenu);
|
||||||
return () => container.removeEventListener('contextmenu', onContextMenu);
|
return () => container.removeEventListener('contextmenu', onContextMenu);
|
||||||
@ -739,14 +734,13 @@ export function Map3D({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||||
{trackContextMenu && onCloseTrackMenu && (
|
{trackContextMenu && onRequestTrack && onCloseTrackMenu && (
|
||||||
<VesselContextMenu
|
<VesselContextMenu
|
||||||
x={trackContextMenu.x}
|
x={trackContextMenu.x}
|
||||||
y={trackContextMenu.y}
|
y={trackContextMenu.y}
|
||||||
mmsi={trackContextMenu.mmsi}
|
mmsi={trackContextMenu.mmsi}
|
||||||
vesselName={trackContextMenu.vesselName}
|
vesselName={trackContextMenu.vesselName}
|
||||||
isPermitted={trackContextMenu.isPermitted}
|
onRequestTrack={onRequestTrack}
|
||||||
onRequestTrack={onRequestTrack ?? undefined}
|
|
||||||
onClose={onCloseTrackMenu}
|
onClose={onCloseTrackMenu}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
mmsi: number;
|
mmsi: number;
|
||||||
vesselName: string;
|
vesselName: string;
|
||||||
isPermitted: boolean;
|
onRequestTrack: (mmsi: number, minutes: number) => void;
|
||||||
onRequestTrack?: (mmsi: number, minutes: number) => void;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,40 +20,12 @@ const TRACK_OPTIONS = [
|
|||||||
const MENU_WIDTH = 180;
|
const MENU_WIDTH = 180;
|
||||||
const MENU_PAD = 8;
|
const MENU_PAD = 8;
|
||||||
|
|
||||||
const STYLE_ITEM: CSSProperties = {
|
export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onClose }: Props) {
|
||||||
display: 'block',
|
|
||||||
width: '100%',
|
|
||||||
padding: '5px 12px 5px 24px',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: '#e2e2e2',
|
|
||||||
fontSize: 12,
|
|
||||||
textAlign: 'left',
|
|
||||||
cursor: 'pointer',
|
|
||||||
lineHeight: 1.4,
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_SEPARATOR: CSSProperties = {
|
|
||||||
height: 1,
|
|
||||||
background: 'rgba(255,255,255,0.08)',
|
|
||||||
margin: '3px 0',
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleHover(e: React.MouseEvent) {
|
|
||||||
(e.target as HTMLElement).style.background = 'rgba(59,130,246,0.18)';
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLeave(e: React.MouseEvent) {
|
|
||||||
(e.target as HTMLElement).style.background = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VesselContextMenu({ x, y, mmsi, vesselName, isPermitted, onRequestTrack, onClose }: Props) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [copiedField, setCopiedField] = useState<'name' | 'mmsi' | null>(null);
|
|
||||||
|
|
||||||
const estimatedHeight = (isPermitted && onRequestTrack ? TRACK_OPTIONS.length * 30 + 56 : 0) + 90;
|
// 화면 밖 보정
|
||||||
const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_PAD);
|
const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_PAD);
|
||||||
const maxTop = window.innerHeight - estimatedHeight - MENU_PAD;
|
const maxTop = window.innerHeight - (TRACK_OPTIONS.length * 30 + 56) - MENU_PAD;
|
||||||
const top = Math.min(y, maxTop);
|
const top = Math.min(y, maxTop);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -76,18 +47,8 @@ export function VesselContextMenu({ x, y, mmsi, vesselName, isPermitted, onReque
|
|||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const handleCopy = async (text: string, field: 'name' | 'mmsi') => {
|
const handleSelect = (minutes: number) => {
|
||||||
try {
|
onRequestTrack(mmsi, minutes);
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
setCopiedField(field);
|
|
||||||
setTimeout(() => setCopiedField(null), 1200);
|
|
||||||
} catch {
|
|
||||||
// clipboard API 불가 시 무시
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectTrack = (minutes: number) => {
|
|
||||||
onRequestTrack?.(mmsi, minutes);
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -131,56 +92,44 @@ export function VesselContextMenu({ x, y, mmsi, vesselName, isPermitted, onReque
|
|||||||
{vesselName}
|
{vesselName}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 선명 복사 */}
|
{/* 항적조회 항목 */}
|
||||||
<button
|
<div
|
||||||
type="button"
|
style={{
|
||||||
style={STYLE_ITEM}
|
padding: '4px 12px 2px',
|
||||||
onClick={() => handleCopy(vesselName, 'name')}
|
fontSize: 11,
|
||||||
onMouseEnter={handleHover}
|
fontWeight: 600,
|
||||||
onMouseLeave={handleLeave}
|
color: 'rgba(255,255,255,0.6)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{copiedField === 'name' ? '복사됨' : '선명 복사'}
|
항적조회
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
{/* MMSI 복사 */}
|
{TRACK_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
key={opt.minutes}
|
||||||
style={STYLE_ITEM}
|
onClick={() => handleSelect(opt.minutes)}
|
||||||
onClick={() => handleCopy(String(mmsi), 'mmsi')}
|
style={{
|
||||||
onMouseEnter={handleHover}
|
display: 'block',
|
||||||
onMouseLeave={handleLeave}
|
width: '100%',
|
||||||
>
|
padding: '5px 12px 5px 24px',
|
||||||
{copiedField === 'mmsi' ? '복사됨' : 'MMSI 복사'}
|
background: 'none',
|
||||||
</button>
|
border: 'none',
|
||||||
|
color: '#e2e2e2',
|
||||||
{/* 항적조회 (대상선박만) */}
|
fontSize: 12,
|
||||||
{isPermitted && onRequestTrack && (
|
textAlign: 'left',
|
||||||
<>
|
cursor: 'pointer',
|
||||||
<div style={STYLE_SEPARATOR} />
|
lineHeight: 1.4,
|
||||||
<div
|
}}
|
||||||
style={{
|
onMouseEnter={(e) => {
|
||||||
padding: '4px 12px 2px',
|
(e.target as HTMLElement).style.background = 'rgba(59,130,246,0.18)';
|
||||||
fontSize: 11,
|
}}
|
||||||
fontWeight: 600,
|
onMouseLeave={(e) => {
|
||||||
color: 'rgba(255,255,255,0.6)',
|
(e.target as HTMLElement).style.background = 'none';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
항적조회
|
{opt.label}
|
||||||
</div>
|
</button>
|
||||||
{TRACK_OPTIONS.map((opt) => (
|
))}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={opt.minutes}
|
|
||||||
onClick={() => handleSelectTrack(opt.minutes)}
|
|
||||||
style={STYLE_ITEM}
|
|
||||||
onMouseEnter={handleHover}
|
|
||||||
onMouseLeave={handleLeave}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,10 @@ const OVERLAY_FC_TRANSFER_RGB = OVERLAY_RGB.fcTransfer;
|
|||||||
const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange;
|
const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange;
|
||||||
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
|
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
|
||||||
|
|
||||||
// Ship icon mapping removed — now using shipKind.ts SVG-based icons
|
// ── Ship icon mapping (Deck.gl IconLayer) ──
|
||||||
|
// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage)
|
||||||
|
|
||||||
|
export { SHIP_ICON_MAPPING } from '../../shared/lib/map/mapConstants';
|
||||||
|
|
||||||
// ── Ship constants ──
|
// ── Ship constants ──
|
||||||
|
|
||||||
@ -44,20 +47,14 @@ export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.55)';
|
|||||||
|
|
||||||
// ── Flat map icon sizes ──
|
// ── Flat map icon sizes ──
|
||||||
|
|
||||||
export const FLAT_OTHER_SHIP_SIZE = 20;
|
export const FLAT_SHIP_ICON_SIZE = 19;
|
||||||
export const FLAT_TARGET_SHIP_SIZE = 26;
|
export const FLAT_SHIP_ICON_SIZE_SELECTED = 28;
|
||||||
|
export const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25;
|
||||||
export const FLAT_LEGACY_HALO_RADIUS = 14;
|
export const FLAT_LEGACY_HALO_RADIUS = 14;
|
||||||
export const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18;
|
export const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18;
|
||||||
|
export const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16;
|
||||||
export const EMPTY_MMSI_SET = new Set<number>();
|
export const EMPTY_MMSI_SET = new Set<number>();
|
||||||
|
|
||||||
// ── 대상 선박 브리딩 애니메이션 ──
|
|
||||||
|
|
||||||
export const HALO_BREATHE_PERIOD_MS = 2000;
|
|
||||||
export const HALO_BREATHE_SELECTED_R_MIN = 16;
|
|
||||||
export const HALO_BREATHE_SELECTED_R_MAX = 22;
|
|
||||||
export const HALO_BREATHE_HIGHLIGHTED_R_MIN = 14;
|
|
||||||
export const HALO_BREATHE_HIGHLIGHTED_R_MAX = 19;
|
|
||||||
|
|
||||||
// ── Deck.gl view ID ──
|
// ── Deck.gl view ID ──
|
||||||
|
|
||||||
export const DECK_VIEW_ID = 'mapbox';
|
export const DECK_VIEW_ID = 'mapbox';
|
||||||
|
|||||||
@ -19,32 +19,26 @@ export function useBaseMapToggle(
|
|||||||
|
|
||||||
const showSeamarkRef = useRef(showSeamark);
|
const showSeamarkRef = useRef(showSeamark);
|
||||||
const bathyZoomProfileKeyRef = useRef<string>('');
|
const bathyZoomProfileKeyRef = useRef<string>('');
|
||||||
const initialLoadRef = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showSeamarkRef.current = showSeamark;
|
showSeamarkRef.current = showSeamark;
|
||||||
}, [showSeamark]);
|
}, [showSeamark]);
|
||||||
|
|
||||||
// Base map style toggle — skip first run (useMapInit handles initial style)
|
// Base map style toggle
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialLoadRef.current) {
|
|
||||||
initialLoadRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
let stop: (() => void) | null = null;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const style = await resolveMapStyle(baseMap, controller.signal);
|
const style = await resolveMapStyle(baseMap, controller.signal);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
map.setStyle(style, { diff: false });
|
map.setStyle(style, { diff: false });
|
||||||
|
stop = onMapStyleReady(map, () => {
|
||||||
map.once('style.load', () => {
|
|
||||||
if (cancelled) return;
|
|
||||||
kickRepaint(map);
|
kickRepaint(map);
|
||||||
requestAnimationFrame(() => kickRepaint(map));
|
requestAnimationFrame(() => kickRepaint(map));
|
||||||
pulseMapSync();
|
pulseMapSync();
|
||||||
@ -58,6 +52,7 @@ export function useBaseMapToggle(
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
controller.abort();
|
controller.abort();
|
||||||
|
stop?.();
|
||||||
};
|
};
|
||||||
}, [baseMap]);
|
}, [baseMap]);
|
||||||
|
|
||||||
@ -68,7 +63,6 @@ export function useBaseMapToggle(
|
|||||||
|
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
if (!map.isStyleLoaded()) return;
|
if (!map.isStyleLoaded()) return;
|
||||||
if (baseMap === 'enc') return;
|
|
||||||
const seaVisibility = 'visible' as const;
|
const seaVisibility = 'visible' as const;
|
||||||
const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { MapboxOverlay } from '@deck.gl/mapbox';
|
|||||||
import { type PickingInfo } from '@deck.gl/core';
|
import { type PickingInfo } from '@deck.gl/core';
|
||||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
import { IconLayer, ScatterplotLayer } from '@deck.gl/layers';
|
import { ScatterplotLayer } from '@deck.gl/layers';
|
||||||
import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
|
import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
|
||||||
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
||||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
@ -19,26 +19,11 @@ import {
|
|||||||
} from '../lib/tooltips';
|
} from '../lib/tooltips';
|
||||||
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
||||||
import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories';
|
import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories';
|
||||||
import {
|
|
||||||
FLAT_LEGACY_HALO_RADIUS,
|
|
||||||
FLAT_LEGACY_HALO_RADIUS_SELECTED,
|
|
||||||
HALO_BREATHE_PERIOD_MS,
|
|
||||||
HALO_BREATHE_SELECTED_R_MIN,
|
|
||||||
HALO_BREATHE_SELECTED_R_MAX,
|
|
||||||
HALO_BREATHE_HIGHLIGHTED_R_MIN,
|
|
||||||
HALO_BREATHE_HIGHLIGHTED_R_MAX,
|
|
||||||
} from '../constants';
|
|
||||||
// NOTE:
|
// NOTE:
|
||||||
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
|
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
|
||||||
// Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts.
|
// Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts.
|
||||||
const ENABLE_GLOBE_DECK_OVERLAYS = false;
|
const ENABLE_GLOBE_DECK_OVERLAYS = false;
|
||||||
|
|
||||||
/** 64×64 white ring SVG for alarm pulse IconLayer */
|
|
||||||
const ALARM_RING_ICON_URL = `data:image/svg+xml,${encodeURIComponent(
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="27" fill="none" stroke="white" stroke-width="5"/></svg>',
|
|
||||||
)}`;
|
|
||||||
const ALARM_RING_ICON_MAPPING = { ring: { x: 0, y: 0, width: 64, height: 64, mask: true } } as const;
|
|
||||||
|
|
||||||
|
|
||||||
export function useDeckLayers(
|
export function useDeckLayers(
|
||||||
mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
|
mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
|
||||||
@ -50,6 +35,7 @@ export function useDeckLayers(
|
|||||||
settings: Map3DSettings;
|
settings: Map3DSettings;
|
||||||
trackReplayDeckLayers: unknown[];
|
trackReplayDeckLayers: unknown[];
|
||||||
shipLayerData: AisTarget[];
|
shipLayerData: AisTarget[];
|
||||||
|
shipOverlayLayerData: AisTarget[];
|
||||||
shipData: AisTarget[];
|
shipData: AisTarget[];
|
||||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
pairLinks: PairLink[] | undefined;
|
pairLinks: PairLink[] | undefined;
|
||||||
@ -83,11 +69,10 @@ export function useDeckLayers(
|
|||||||
ensureMercatorOverlay: () => MapboxOverlay | null;
|
ensureMercatorOverlay: () => MapboxOverlay | null;
|
||||||
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||||
onClickShipPhoto?: (mmsi: number) => void;
|
onClickShipPhoto?: (mmsi: number) => void;
|
||||||
shipLabelColors?: import('../lib/labelColor').ShipLabelColors;
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
projection, settings, trackReplayDeckLayers, shipLayerData, shipData,
|
projection, settings, trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
|
||||||
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
|
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
|
||||||
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
||||||
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
||||||
@ -98,15 +83,12 @@ export function useDeckLayers(
|
|||||||
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
||||||
ensureMercatorOverlay, alarmMmsiMap,
|
ensureMercatorOverlay, alarmMmsiMap,
|
||||||
onClickShipPhoto,
|
onClickShipPhoto,
|
||||||
shipLabelColors,
|
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
// Use shipLayerData (clustered/visible) instead of shipData (all) so halo
|
|
||||||
// only appears for targets that are currently rendered after clustering.
|
|
||||||
const legacyTargets = useMemo(() => {
|
const legacyTargets = useMemo(() => {
|
||||||
if (!legacyHits) return [];
|
if (!legacyHits) return [];
|
||||||
return shipLayerData.filter((t) => legacyHits.has(t.mmsi));
|
return shipData.filter((t) => legacyHits.has(t.mmsi));
|
||||||
}, [shipLayerData, legacyHits]);
|
}, [shipData, legacyHits]);
|
||||||
|
|
||||||
const legacyTargetsOrdered = useMemo(() => {
|
const legacyTargetsOrdered = useMemo(() => {
|
||||||
if (legacyTargets.length === 0) return legacyTargets;
|
if (legacyTargets.length === 0) return legacyTargets;
|
||||||
@ -116,17 +98,14 @@ export function useDeckLayers(
|
|||||||
}, [legacyTargets]);
|
}, [legacyTargets]);
|
||||||
|
|
||||||
const legacyOverlayTargets = useMemo(() => {
|
const legacyOverlayTargets = useMemo(() => {
|
||||||
if (shipHighlightSet.size === 0 && selectedMmsi == null) return [];
|
if (shipHighlightSet.size === 0) return [];
|
||||||
return legacyTargets.filter((target) =>
|
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
|
||||||
shipHighlightSet.has(target.mmsi) ||
|
}, [legacyTargets, shipHighlightSet]);
|
||||||
(selectedMmsi != null && target.mmsi === selectedMmsi),
|
|
||||||
);
|
|
||||||
}, [legacyTargets, shipHighlightSet, selectedMmsi]);
|
|
||||||
|
|
||||||
const alarmTargets = useMemo(() => {
|
const alarmTargets = useMemo(() => {
|
||||||
if (!alarmMmsiMap || alarmMmsiMap.size === 0) return [];
|
if (!alarmMmsiMap || alarmMmsiMap.size === 0) return [];
|
||||||
return shipLayerData.filter((t) => alarmMmsiMap.has(t.mmsi));
|
return shipData.filter((t) => alarmMmsiMap.has(t.mmsi));
|
||||||
}, [shipLayerData, alarmMmsiMap]);
|
}, [shipData, alarmMmsiMap]);
|
||||||
|
|
||||||
const shipPhotoTargets = useMemo(() => {
|
const shipPhotoTargets = useMemo(() => {
|
||||||
return shipData.filter((t) => !!t.shipImagePath);
|
return shipData.filter((t) => !!t.shipImagePath);
|
||||||
@ -155,6 +134,7 @@ export function useDeckLayers(
|
|||||||
|
|
||||||
const layers = buildMercatorDeckLayers({
|
const layers = buildMercatorDeckLayers({
|
||||||
shipLayerData,
|
shipLayerData,
|
||||||
|
shipOverlayLayerData,
|
||||||
legacyTargetsOrdered,
|
legacyTargetsOrdered,
|
||||||
legacyOverlayTargets,
|
legacyOverlayTargets,
|
||||||
legacyHits,
|
legacyHits,
|
||||||
@ -188,7 +168,6 @@ export function useDeckLayers(
|
|||||||
alarmPulseHoverRadius: 12,
|
alarmPulseHoverRadius: 12,
|
||||||
shipPhotoTargets,
|
shipPhotoTargets,
|
||||||
onClickShipPhoto,
|
onClickShipPhoto,
|
||||||
shipLabelColors,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizedBaseLayers = sanitizeDeckLayerList(layers);
|
const normalizedBaseLayers = sanitizeDeckLayerList(layers);
|
||||||
@ -267,6 +246,7 @@ export function useDeckLayers(
|
|||||||
legacyTargetsOrdered,
|
legacyTargetsOrdered,
|
||||||
legacyHits,
|
legacyHits,
|
||||||
legacyOverlayTargets,
|
legacyOverlayTargets,
|
||||||
|
shipOverlayLayerData,
|
||||||
pairRangesInteractive,
|
pairRangesInteractive,
|
||||||
pairLinksInteractive,
|
pairLinksInteractive,
|
||||||
fcLinesInteractive,
|
fcLinesInteractive,
|
||||||
@ -293,15 +273,11 @@ export function useDeckLayers(
|
|||||||
alarmMmsiMap,
|
alarmMmsiMap,
|
||||||
shipPhotoTargets,
|
shipPhotoTargets,
|
||||||
onClickShipPhoto,
|
onClickShipPhoto,
|
||||||
shipLabelColors,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Mercator 브리딩 애니메이션 (rAF) — 알람 맥동 + 대상 선박 브리딩 합류
|
// Mercator alarm pulse breathing animation (rAF)
|
||||||
const hasAlarms = alarmMmsiMap && alarmMmsiMap.size > 0 && alarmTargets.length > 0;
|
|
||||||
const hasTargetOverlays = legacyOverlayTargets.length > 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projection !== 'mercator' || (!hasAlarms && !hasTargetOverlays)) {
|
if (projection !== 'mercator' || !alarmMmsiMap || alarmMmsiMap.size === 0 || alarmTargets.length === 0) {
|
||||||
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
||||||
alarmRafRef.current = 0;
|
alarmRafRef.current = 0;
|
||||||
return;
|
return;
|
||||||
@ -319,75 +295,34 @@ export function useDeckLayers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const t = (Math.sin(Date.now() / 1500 * Math.PI * 2) + 1) / 2;
|
||||||
let updated = mercatorLayersRef.current;
|
const normalR = 8 + t * 6;
|
||||||
|
const hoverR = 12 + t * 6;
|
||||||
|
|
||||||
// 1. 알람 맥동 — IconLayer + SVG ring, opacity/sizeScale uniform 애니메이션
|
const pulseLyr = new ScatterplotLayer<AisTarget>({
|
||||||
if (hasAlarms) {
|
id: 'alarm-pulse',
|
||||||
const tA = (Math.sin(now / 1500 * Math.PI * 2) + 1) / 2;
|
data: alarmTargets,
|
||||||
|
pickable: false,
|
||||||
|
billboard: false,
|
||||||
|
filled: true,
|
||||||
|
stroked: false,
|
||||||
|
radiusUnits: 'pixels',
|
||||||
|
getRadius: (d) => {
|
||||||
|
const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi);
|
||||||
|
return isHover ? hoverR : normalR;
|
||||||
|
},
|
||||||
|
getFillColor: (d) => {
|
||||||
|
const kind = alarmMmsiMap.get(d.mmsi);
|
||||||
|
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number];
|
||||||
|
},
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
updateTriggers: { getRadius: [normalR, hoverR] },
|
||||||
|
});
|
||||||
|
|
||||||
const pulseLyr = new IconLayer<AisTarget>({
|
const updated = mercatorLayersRef.current.map((l) =>
|
||||||
id: 'alarm-pulse',
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
data: alarmTargets,
|
(l as any)?.id === 'alarm-pulse' ? pulseLyr : l,
|
||||||
pickable: false,
|
);
|
||||||
billboard: true,
|
|
||||||
iconAtlas: ALARM_RING_ICON_URL,
|
|
||||||
iconMapping: ALARM_RING_ICON_MAPPING,
|
|
||||||
getIcon: () => 'ring',
|
|
||||||
sizeUnits: 'pixels',
|
|
||||||
sizeScale: 0.9 + tA * 0.2,
|
|
||||||
opacity: 0.2 + tA * 0.7,
|
|
||||||
getSize: (d) => {
|
|
||||||
const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi);
|
|
||||||
return isHover
|
|
||||||
? (FLAT_LEGACY_HALO_RADIUS_SELECTED + 8) * 2
|
|
||||||
: (FLAT_LEGACY_HALO_RADIUS + 8) * 2;
|
|
||||||
},
|
|
||||||
getColor: (d) => {
|
|
||||||
const kind = alarmMmsiMap!.get(d.mmsi);
|
|
||||||
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 200] as [number, number, number, number];
|
|
||||||
},
|
|
||||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
|
||||||
updateTriggers: {
|
|
||||||
getSize: [selectedMmsi],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
updated = updated.map((l) =>
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(l as any)?.id === 'alarm-pulse' ? pulseLyr : l,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 대상 선박 브리딩 링
|
|
||||||
if (hasTargetOverlays) {
|
|
||||||
const tH = (Math.sin(now / HALO_BREATHE_PERIOD_MS * Math.PI * 2) + 1) / 2;
|
|
||||||
const selR = HALO_BREATHE_SELECTED_R_MIN + tH * (HALO_BREATHE_SELECTED_R_MAX - HALO_BREATHE_SELECTED_R_MIN);
|
|
||||||
const hlR = HALO_BREATHE_HIGHLIGHTED_R_MIN + tH * (HALO_BREATHE_HIGHLIGHTED_R_MAX - HALO_BREATHE_HIGHLIGHTED_R_MIN);
|
|
||||||
const alpha = Math.round(155 + tH * 100);
|
|
||||||
|
|
||||||
const haloLyr = new ScatterplotLayer<AisTarget>({
|
|
||||||
id: 'legacy-halo-overlay',
|
|
||||||
data: legacyOverlayTargets,
|
|
||||||
pickable: false,
|
|
||||||
billboard: false,
|
|
||||||
filled: false,
|
|
||||||
stroked: true,
|
|
||||||
radiusUnits: 'pixels',
|
|
||||||
getRadius: (d) => (selectedMmsi != null && d.mmsi === selectedMmsi ? selR : hlR),
|
|
||||||
lineWidthUnits: 'pixels',
|
|
||||||
getLineWidth: 2.5,
|
|
||||||
getLineColor: (d) => {
|
|
||||||
if (selectedMmsi != null && d.mmsi === selectedMmsi) return [14, 234, 255, alpha] as [number, number, number, number];
|
|
||||||
return [245, 158, 11, alpha] as [number, number, number, number];
|
|
||||||
},
|
|
||||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
|
||||||
updateTriggers: { getRadius: [selR, hlR], getLineColor: [alpha, selectedMmsi] },
|
|
||||||
});
|
|
||||||
updated = updated.map((l) =>
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(l as any)?.id === 'legacy-halo-overlay' ? haloLyr : l,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
currentOverlay.setProps({ layers: updated } as never);
|
currentOverlay.setProps({ layers: updated } as never);
|
||||||
@ -401,7 +336,7 @@ export function useDeckLayers(
|
|||||||
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
||||||
alarmRafRef.current = 0;
|
alarmRafRef.current = 0;
|
||||||
};
|
};
|
||||||
}, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet, hasAlarms, hasTargetOverlays, legacyOverlayTargets]);
|
}, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet]);
|
||||||
|
|
||||||
// Globe Deck overlay
|
// Globe Deck overlay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -244,9 +244,11 @@ export function useGlobeFcFleetOverlay(
|
|||||||
: false;
|
: false;
|
||||||
|
|
||||||
// ── FC lines ──
|
// ── FC lines ──
|
||||||
const fcVisible = overlays.fcLines;
|
const pairActive = hoveredPairMmsiList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||||
|
const fcVisible = overlays.fcLines || pairActive;
|
||||||
// ── Fleet circles ──
|
// ── Fleet circles ──
|
||||||
const fleetVisible = overlays.fleetCircles;
|
const fleetActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||||
|
const fleetVisible = overlays.fleetCircles || fleetActive;
|
||||||
try {
|
try {
|
||||||
if (map.getLayer('fc-lines-ml')) {
|
if (map.getLayer('fc-lines-ml')) {
|
||||||
map.setPaintProperty('fc-lines-ml', 'line-opacity', fcVisible ? 0.9 : 0);
|
map.setPaintProperty('fc-lines-ml', 'line-opacity', fcVisible ? 0.9 : 0);
|
||||||
|
|||||||
@ -241,7 +241,7 @@ export function useGlobePairOverlay(
|
|||||||
: false;
|
: false;
|
||||||
|
|
||||||
// ── Pair lines: 가시성 + 하이라이트 ──
|
// ── Pair lines: 가시성 + 하이라이트 ──
|
||||||
const pairLinesVisible = overlays.pairLines;
|
const pairLinesVisible = overlays.pairLines || active;
|
||||||
try {
|
try {
|
||||||
if (map.getLayer('pair-lines-ml')) {
|
if (map.getLayer('pair-lines-ml')) {
|
||||||
map.setPaintProperty('pair-lines-ml', 'line-opacity', pairLinesVisible ? 0.9 : 0);
|
map.setPaintProperty('pair-lines-ml', 'line-opacity', pairLinesVisible ? 0.9 : 0);
|
||||||
@ -265,7 +265,7 @@ export function useGlobePairOverlay(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Pair range: 가시성 + 하이라이트 ──
|
// ── Pair range: 가시성 + 하이라이트 ──
|
||||||
const pairRangeVisible = overlays.pairRange;
|
const pairRangeVisible = overlays.pairRange || active;
|
||||||
try {
|
try {
|
||||||
if (map.getLayer('pair-range-ml')) {
|
if (map.getLayer('pair-range-ml')) {
|
||||||
map.setPaintProperty('pair-range-ml', 'line-opacity', pairRangeVisible ? 0.85 : 0);
|
map.setPaintProperty('pair-range-ml', 'line-opacity', pairRangeVisible ? 0.85 : 0);
|
||||||
|
|||||||
@ -123,7 +123,7 @@ export function useGlobeShipHover(
|
|||||||
sog: isFiniteNumber(t.sog) ? t.sog : 0,
|
sog: isFiniteNumber(t.sog) ? t.sog : 0,
|
||||||
shipColor: getGlobeBaseShipColor({
|
shipColor: getGlobeBaseShipColor({
|
||||||
legacy: legacy?.shipCode || null,
|
legacy: legacy?.shipCode || null,
|
||||||
signalKindCode: t.signalKindCode || t.shipKindCode || '000027',
|
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
||||||
}),
|
}),
|
||||||
iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45),
|
iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45),
|
||||||
iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7),
|
iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7),
|
||||||
|
|||||||
@ -9,8 +9,11 @@ import type { Map3DSettings, MapProjectionId } from '../types';
|
|||||||
import {
|
import {
|
||||||
ANCHORED_SHIP_ICON_ID,
|
ANCHORED_SHIP_ICON_ID,
|
||||||
GLOBE_ICON_HEADING_OFFSET_DEG,
|
GLOBE_ICON_HEADING_OFFSET_DEG,
|
||||||
|
GLOBE_OUTLINE_PERMITTED,
|
||||||
|
GLOBE_OUTLINE_OTHER,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { isFiniteNumber } from '../lib/setUtils';
|
import { isFiniteNumber } from '../lib/setUtils';
|
||||||
|
import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions';
|
||||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
import {
|
import {
|
||||||
isAnchoredShip,
|
isAnchoredShip,
|
||||||
@ -25,41 +28,12 @@ import { clampNumber } from '../lib/geometry';
|
|||||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||||
|
|
||||||
// ── Alarm pulse animation constants ──
|
// ── Alarm pulse animation constants ──
|
||||||
// Offset from outline radius so pulse ring never overlaps the outline stroke
|
const ALARM_PULSE_R_MIN = 8;
|
||||||
const ALARM_PULSE_OFFSET_MIN = 5; // px offset from base circle at rest
|
const ALARM_PULSE_R_MAX = 14;
|
||||||
const ALARM_PULSE_OFFSET_MAX = 11; // px offset at peak
|
const ALARM_PULSE_R_HOVER_MIN = 12;
|
||||||
const ALARM_PULSE_HOVER_OFFSET_MIN = 7;
|
const ALARM_PULSE_R_HOVER_MAX = 18;
|
||||||
const ALARM_PULSE_HOVER_OFFSET_MAX = 14;
|
|
||||||
const ALARM_PULSE_PERIOD_MS = 1500;
|
const ALARM_PULSE_PERIOD_MS = 1500;
|
||||||
|
|
||||||
// Base circle radii per zoom (from mlExpressions BASE_VALUES)
|
|
||||||
const BASE_R_BY_ZOOM = [
|
|
||||||
[3, 4], [7, 6], [10, 8], [14, 12], [18, 32],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** Build zoom-interpolated radius = base + offset for alarm pulse */
|
|
||||||
function buildAlarmPulseRadiusExpr(offset: number) {
|
|
||||||
const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
|
|
||||||
for (const [z, base] of BASE_R_BY_ZOOM) {
|
|
||||||
stops.push(z, base + offset);
|
|
||||||
}
|
|
||||||
return stops as never;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build zoom-interpolated radius with hover/normal case for alarm pulse */
|
|
||||||
function buildAlarmPulseRadiusCaseExpr(normalOffset: number, hoverOffset: number) {
|
|
||||||
const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
|
|
||||||
for (const [z, base] of BASE_R_BY_ZOOM) {
|
|
||||||
stops.push(z, [
|
|
||||||
'case',
|
|
||||||
['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]],
|
|
||||||
base + hoverOffset,
|
|
||||||
base + normalOffset,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
return stops as never;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label, alarm pulse, alarm badge) */
|
/** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label, alarm pulse, alarm badge) */
|
||||||
export function useGlobeShipLayers(
|
export function useGlobeShipLayers(
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
@ -97,7 +71,7 @@ export function useGlobeShipLayers(
|
|||||||
features: shipData.map((t) => {
|
features: shipData.map((t) => {
|
||||||
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
||||||
const alarmKind = alarmMmsiMap?.get(t.mmsi) ?? null;
|
const alarmKind = alarmMmsiMap?.get(t.mmsi) ?? null;
|
||||||
const baseName = (legacy?.shipNameRoman?.toUpperCase() || legacy?.shipNameCn || t.name?.toUpperCase() || '').trim();
|
const baseName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '';
|
||||||
const labelName = alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
|
const labelName = alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
|
||||||
const heading = getDisplayHeading({
|
const heading = getDisplayHeading({
|
||||||
cog: t.cog,
|
cog: t.cog,
|
||||||
@ -110,9 +84,9 @@ export function useGlobeShipLayers(
|
|||||||
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
|
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
|
||||||
50, 420,
|
50, 420,
|
||||||
);
|
);
|
||||||
const baseSizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
||||||
// 대상 선박은 1.3x 배율 적용
|
// 상호작용 스케일링 제거 — selected/highlighted는 feature-state로 처리
|
||||||
const sizeScale = legacy ? baseSizeScale * 1.3 : baseSizeScale;
|
// hover overlay 레이어가 확대 + z-priority를 담당
|
||||||
const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3);
|
const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3);
|
||||||
const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45);
|
const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45);
|
||||||
const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8);
|
const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8);
|
||||||
@ -132,7 +106,7 @@ export function useGlobeShipLayers(
|
|||||||
isAnchored: isAnchored ? 1 : 0,
|
isAnchored: isAnchored ? 1 : 0,
|
||||||
shipColor: getGlobeBaseShipColor({
|
shipColor: getGlobeBaseShipColor({
|
||||||
legacy: legacy?.shipCode || null,
|
legacy: legacy?.shipCode || null,
|
||||||
signalKindCode: t.signalKindCode || t.shipKindCode || '000027',
|
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
||||||
}),
|
}),
|
||||||
iconSize3,
|
iconSize3,
|
||||||
iconSize7,
|
iconSize7,
|
||||||
@ -303,7 +277,87 @@ export function useGlobeShipLayers(
|
|||||||
['==', ['to-number', ['get', 'alarmed'], 0], 0],
|
['==', ['to-number', ['get', 'alarmed'], 0], 0],
|
||||||
] as unknown as unknown[];
|
] as unknown as unknown[];
|
||||||
|
|
||||||
// Ship halo + outline circles — disabled (아이콘 본체만 표시)
|
if (!map.getLayer(haloId)) {
|
||||||
|
needReorder = true;
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: haloId,
|
||||||
|
type: 'circle',
|
||||||
|
source: srcId,
|
||||||
|
layout: {
|
||||||
|
visibility,
|
||||||
|
'circle-sort-key': [
|
||||||
|
'case',
|
||||||
|
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 112,
|
||||||
|
['==', ['get', 'permitted'], 1], 110,
|
||||||
|
['==', ['get', 'alarmed'], 1], 22,
|
||||||
|
20,
|
||||||
|
] as never,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||||
|
'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||||
|
'circle-opacity': [
|
||||||
|
'case',
|
||||||
|
['==', ['feature-state', 'selected'], 1], 0.38,
|
||||||
|
['==', ['feature-state', 'highlighted'], 1], 0.34,
|
||||||
|
['==', ['get', 'permitted'], 1], 0.16,
|
||||||
|
0.25,
|
||||||
|
] as never,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship halo layer add failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(outlineId)) {
|
||||||
|
needReorder = true;
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: outlineId,
|
||||||
|
type: 'circle',
|
||||||
|
source: srcId,
|
||||||
|
paint: {
|
||||||
|
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||||
|
'circle-color': 'rgba(0,0,0,0)',
|
||||||
|
'circle-stroke-color': [
|
||||||
|
'case',
|
||||||
|
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||||
|
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
||||||
|
['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED,
|
||||||
|
GLOBE_OUTLINE_OTHER,
|
||||||
|
] as never,
|
||||||
|
'circle-stroke-width': [
|
||||||
|
'case',
|
||||||
|
['==', ['feature-state', 'selected'], 1], 3.4,
|
||||||
|
['==', ['feature-state', 'highlighted'], 1], 2.7,
|
||||||
|
['==', ['get', 'permitted'], 1], 1.8,
|
||||||
|
1.2,
|
||||||
|
] as never,
|
||||||
|
'circle-stroke-opacity': 0.85,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
visibility,
|
||||||
|
'circle-sort-key': [
|
||||||
|
'case',
|
||||||
|
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 122,
|
||||||
|
['==', ['get', 'permitted'], 1], 120,
|
||||||
|
['==', ['get', 'alarmed'], 1], 32,
|
||||||
|
30,
|
||||||
|
] as never,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship outline layer add failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Alarm pulse circle (above outline, below ship icons)
|
// Alarm pulse circle (above outline, below ship icons)
|
||||||
// Uses separate alarm source for stable rendering
|
// Uses separate alarm source for stable rendering
|
||||||
@ -318,12 +372,10 @@ export function useGlobeShipLayers(
|
|||||||
filter: ['==', ['get', 'alarmed'], 1] as never,
|
filter: ['==', ['get', 'alarmed'], 1] as never,
|
||||||
layout: { visibility },
|
layout: { visibility },
|
||||||
paint: {
|
paint: {
|
||||||
'circle-radius': buildAlarmPulseRadiusExpr(ALARM_PULSE_OFFSET_MIN),
|
'circle-radius': ALARM_PULSE_R_MIN,
|
||||||
'circle-color': 'transparent',
|
'circle-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
|
||||||
'circle-opacity': 0,
|
'circle-opacity': 0.35,
|
||||||
'circle-stroke-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
|
'circle-stroke-width': 0,
|
||||||
'circle-stroke-opacity': 0.7,
|
|
||||||
'circle-stroke-width': 1.5,
|
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification,
|
} as unknown as LayerSpecification,
|
||||||
before,
|
before,
|
||||||
@ -341,7 +393,7 @@ export function useGlobeShipLayers(
|
|||||||
id: symbolLiteId,
|
id: symbolLiteId,
|
||||||
type: 'symbol',
|
type: 'symbol',
|
||||||
source: srcId,
|
source: srcId,
|
||||||
minzoom: 2,
|
minzoom: 6.5,
|
||||||
filter: nonPriorityFilter as never,
|
filter: nonPriorityFilter as never,
|
||||||
layout: {
|
layout: {
|
||||||
visibility,
|
visibility,
|
||||||
@ -356,12 +408,16 @@ export function useGlobeShipLayers(
|
|||||||
'interpolate',
|
'interpolate',
|
||||||
['linear'],
|
['linear'],
|
||||||
['zoom'],
|
['zoom'],
|
||||||
2, 0.5,
|
6.5,
|
||||||
5, 0.6,
|
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.45],
|
||||||
8, ['max', ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.806], 0.6],
|
8,
|
||||||
10, ['max', ['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.936], 0.7],
|
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.62],
|
||||||
14, ['*', ['to-number', ['get', 'iconSize14'], 0.85], 1.014],
|
10,
|
||||||
18, ['*', ['to-number', ['get', 'iconSize18'], 2.5], 1.014],
|
['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.72],
|
||||||
|
14,
|
||||||
|
['*', ['to-number', ['get', 'iconSize14'], 0.85], 0.78],
|
||||||
|
18,
|
||||||
|
['*', ['to-number', ['get', 'iconSize18'], 2.5], 0.78],
|
||||||
] as unknown as number[],
|
] as unknown as number[],
|
||||||
'icon-allow-overlap': true,
|
'icon-allow-overlap': true,
|
||||||
'icon-ignore-placement': true,
|
'icon-ignore-placement': true,
|
||||||
@ -381,14 +437,15 @@ export function useGlobeShipLayers(
|
|||||||
'interpolate',
|
'interpolate',
|
||||||
['linear'],
|
['linear'],
|
||||||
['zoom'],
|
['zoom'],
|
||||||
6.5, 0.6,
|
6.5,
|
||||||
8, 0.75,
|
0.28,
|
||||||
11, 0.9,
|
8,
|
||||||
14, 1,
|
0.45,
|
||||||
|
11,
|
||||||
|
0.65,
|
||||||
|
14,
|
||||||
|
0.78,
|
||||||
] as never,
|
] as never,
|
||||||
'icon-halo-color': 'rgba(0,0,0,0.5)',
|
|
||||||
'icon-halo-width': 0.8,
|
|
||||||
'icon-halo-blur': 0.3,
|
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification,
|
} as unknown as LayerSpecification,
|
||||||
before,
|
before,
|
||||||
@ -424,12 +481,11 @@ export function useGlobeShipLayers(
|
|||||||
] as never,
|
] as never,
|
||||||
'icon-size': [
|
'icon-size': [
|
||||||
'interpolate', ['linear'], ['zoom'],
|
'interpolate', ['linear'], ['zoom'],
|
||||||
2, 0.8,
|
3, ['to-number', ['get', 'iconSize3'], 0.35],
|
||||||
5, 0.9,
|
7, ['to-number', ['get', 'iconSize7'], 0.45],
|
||||||
7, ['max', ['*', ['to-number', ['get', 'iconSize7'], 0.45], 1.3], 0.9],
|
10, ['to-number', ['get', 'iconSize10'], 0.58],
|
||||||
10, ['max', ['*', ['to-number', ['get', 'iconSize10'], 0.58], 1.3], 0.9],
|
14, ['to-number', ['get', 'iconSize14'], 0.85],
|
||||||
14, ['*', ['to-number', ['get', 'iconSize14'], 0.85], 1.3],
|
18, ['to-number', ['get', 'iconSize18'], 2.5],
|
||||||
18, ['*', ['to-number', ['get', 'iconSize18'], 2.5], 1.3],
|
|
||||||
] as unknown as number[],
|
] as unknown as number[],
|
||||||
'icon-allow-overlap': true,
|
'icon-allow-overlap': true,
|
||||||
'icon-ignore-placement': true,
|
'icon-ignore-placement': true,
|
||||||
@ -444,20 +500,13 @@ export function useGlobeShipLayers(
|
|||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||||
'icon-opacity': 1,
|
'icon-opacity': [
|
||||||
'icon-halo-color': [
|
|
||||||
'case',
|
'case',
|
||||||
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
['==', ['feature-state', 'selected'], 1], 1,
|
||||||
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
['==', ['feature-state', 'highlighted'], 1], 0.95,
|
||||||
'rgba(0,0,0,0.7)',
|
['==', ['get', 'permitted'], 1], 0.93,
|
||||||
|
0.9,
|
||||||
] as never,
|
] as never,
|
||||||
'icon-halo-width': [
|
|
||||||
'case',
|
|
||||||
['==', ['feature-state', 'selected'], 1], 2.5,
|
|
||||||
['==', ['feature-state', 'highlighted'], 1], 2,
|
|
||||||
1,
|
|
||||||
] as never,
|
|
||||||
'icon-halo-blur': 0.5,
|
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification,
|
} as unknown as LayerSpecification,
|
||||||
before,
|
before,
|
||||||
@ -467,7 +516,33 @@ export function useGlobeShipLayers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Photo indicator circle — disabled (파란 원 아이콘 제거)
|
// Photo indicator circle (above ship icons, below labels)
|
||||||
|
if (!map.getLayer(photoId)) {
|
||||||
|
needReorder = true;
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: photoId,
|
||||||
|
type: 'circle',
|
||||||
|
source: srcId,
|
||||||
|
filter: ['==', ['get', 'hasPhoto'], 1] as never,
|
||||||
|
layout: { visibility: photoVisibility },
|
||||||
|
paint: {
|
||||||
|
'circle-radius': [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
3, 3, 7, 4, 10, 5, 14, 6,
|
||||||
|
] as never,
|
||||||
|
'circle-color': 'rgba(0, 188, 212, 0.7)',
|
||||||
|
'circle-stroke-color': 'rgba(255, 255, 255, 0.8)',
|
||||||
|
'circle-stroke-width': 1,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship photo indicator layer add failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const labelFilter = [
|
const labelFilter = [
|
||||||
'all',
|
'all',
|
||||||
@ -483,13 +558,13 @@ export function useGlobeShipLayers(
|
|||||||
id: labelId,
|
id: labelId,
|
||||||
type: 'symbol',
|
type: 'symbol',
|
||||||
source: srcId,
|
source: srcId,
|
||||||
minzoom: 4,
|
minzoom: 7,
|
||||||
filter: labelFilter as never,
|
filter: labelFilter as never,
|
||||||
layout: {
|
layout: {
|
||||||
visibility: labelVisibility,
|
visibility: labelVisibility,
|
||||||
'symbol-placement': 'point',
|
'symbol-placement': 'point',
|
||||||
'text-field': ['get', 'labelName'] as never,
|
'text-field': ['get', 'labelName'] as never,
|
||||||
'text-font': ['Noto Sans Regular'],
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
|
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
|
||||||
'text-anchor': 'top',
|
'text-anchor': 'top',
|
||||||
'text-offset': [0, 1.1],
|
'text-offset': [0, 1.1],
|
||||||
@ -504,9 +579,9 @@ export function useGlobeShipLayers(
|
|||||||
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
||||||
'rgba(226,232,240,0.92)',
|
'rgba(226,232,240,0.92)',
|
||||||
] as never,
|
] as never,
|
||||||
'text-halo-color': 'rgba(0,0,0,0.9)',
|
'text-halo-color': 'rgba(2,6,23,0.85)',
|
||||||
'text-halo-width': 0.8,
|
'text-halo-width': 1.2,
|
||||||
'text-halo-blur': 0.2,
|
'text-halo-blur': 0.8,
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification,
|
} as unknown as LayerSpecification,
|
||||||
undefined,
|
undefined,
|
||||||
@ -530,7 +605,7 @@ export function useGlobeShipLayers(
|
|||||||
layout: {
|
layout: {
|
||||||
visibility,
|
visibility,
|
||||||
'text-field': ['get', 'alarmBadgeLabel'] as never,
|
'text-field': ['get', 'alarmBadgeLabel'] as never,
|
||||||
'text-font': ['Noto Sans Regular'],
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
'text-size': 11,
|
'text-size': 11,
|
||||||
'text-allow-overlap': true,
|
'text-allow-overlap': true,
|
||||||
'text-ignore-placement': true,
|
'text-ignore-placement': true,
|
||||||
@ -626,16 +701,16 @@ export function useGlobeShipLayers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const t = (Math.sin(Date.now() / ALARM_PULSE_PERIOD_MS * Math.PI * 2) + 1) / 2;
|
const t = (Math.sin(Date.now() / ALARM_PULSE_PERIOD_MS * Math.PI * 2) + 1) / 2;
|
||||||
const normalOff = ALARM_PULSE_OFFSET_MIN + t * (ALARM_PULSE_OFFSET_MAX - ALARM_PULSE_OFFSET_MIN);
|
const normalR = ALARM_PULSE_R_MIN + t * (ALARM_PULSE_R_MAX - ALARM_PULSE_R_MIN);
|
||||||
const hoverOff = ALARM_PULSE_HOVER_OFFSET_MIN + t * (ALARM_PULSE_HOVER_OFFSET_MAX - ALARM_PULSE_HOVER_OFFSET_MIN);
|
const hoverR = ALARM_PULSE_R_HOVER_MIN + t * (ALARM_PULSE_R_HOVER_MAX - ALARM_PULSE_R_HOVER_MIN);
|
||||||
try {
|
try {
|
||||||
if (map.getLayer('ships-globe-alarm-pulse')) {
|
if (map.getLayer('ships-globe-alarm-pulse')) {
|
||||||
map.setPaintProperty(
|
map.setPaintProperty('ships-globe-alarm-pulse', 'circle-radius', [
|
||||||
'ships-globe-alarm-pulse',
|
'case',
|
||||||
'circle-radius',
|
['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]],
|
||||||
buildAlarmPulseRadiusCaseExpr(normalOff, hoverOff),
|
hoverR,
|
||||||
);
|
normalR,
|
||||||
map.setPaintProperty('ships-globe-alarm-pulse', 'circle-stroke-opacity', 0.4 + t * 0.5);
|
] as never);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|||||||
@ -163,8 +163,8 @@ export function useMapStyleSettings(
|
|||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
const s = settingsRef.current;
|
const s = settingsRef.current;
|
||||||
if (!map || !s) return;
|
if (!map || !s) return;
|
||||||
// Ocean/ENC 모드는 전용 훅에서 별도 처리
|
// Ocean 모드는 useOceanMapSettings에서 별도 처리
|
||||||
if (baseMap === 'ocean' || baseMap === 'enc') return;
|
if (baseMap === 'ocean') return;
|
||||||
|
|
||||||
const stop = onMapStyleReady(map, () => {
|
const stop = onMapStyleReady(map, () => {
|
||||||
applyLabelLanguage(map, s.labelLanguage);
|
applyLabelLanguage(map, s.labelLanguage);
|
||||||
|
|||||||
@ -1,55 +0,0 @@
|
|||||||
import { useEffect, useMemo, type MutableRefObject } from 'react';
|
|
||||||
import type maplibregl from 'maplibre-gl';
|
|
||||||
import type { BaseMapId } from '../types';
|
|
||||||
import { computeShipLabelColors, type ShipLabelColors } from '../lib/labelColor';
|
|
||||||
import type { EncMapSettings } from '../../../features/encMap/model/types';
|
|
||||||
import { DEFAULT_ENC_MAP_SETTINGS } from '../../../features/encMap/model/types';
|
|
||||||
|
|
||||||
/** Default colors for non-ENC basemaps (dark background) */
|
|
||||||
const DARK_BG_COLORS = computeShipLabelColors('#010610');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute ship label colors based on the current basemap background.
|
|
||||||
* Updates Globe MapLibre text-color paint property on style changes.
|
|
||||||
*/
|
|
||||||
export function useShipLabelColor(
|
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
|
||||||
baseMap: BaseMapId,
|
|
||||||
mapSyncEpoch: number,
|
|
||||||
encMapSettings?: EncMapSettings,
|
|
||||||
): ShipLabelColors {
|
|
||||||
const bgHex = baseMap === 'enc'
|
|
||||||
? (encMapSettings ?? DEFAULT_ENC_MAP_SETTINGS).backgroundColor
|
|
||||||
: '#010610';
|
|
||||||
|
|
||||||
const colors = useMemo(() => computeShipLabelColors(bgHex), [bgHex]);
|
|
||||||
|
|
||||||
// Update Globe label paint properties when colors change
|
|
||||||
useEffect(() => {
|
|
||||||
const map = mapRef.current;
|
|
||||||
if (!map) return;
|
|
||||||
|
|
||||||
const applyGlobeLabelColor = () => {
|
|
||||||
const labelLayerId = 'ships-globe-label';
|
|
||||||
try {
|
|
||||||
if (!map.getLayer(labelLayerId)) return;
|
|
||||||
|
|
||||||
// Preserve selected/highlighted colors, only change default
|
|
||||||
map.setPaintProperty(labelLayerId, 'text-color', [
|
|
||||||
'case',
|
|
||||||
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
|
||||||
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
|
||||||
colors.mlDefault,
|
|
||||||
] as never);
|
|
||||||
map.setPaintProperty(labelLayerId, 'text-halo-color', colors.mlHalo);
|
|
||||||
} catch {
|
|
||||||
// layer may not exist yet
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply immediately and also on next style ready
|
|
||||||
applyGlobeLabelColor();
|
|
||||||
}, [colors, mapSyncEpoch]);
|
|
||||||
|
|
||||||
return baseMap === 'enc' ? colors : DARK_BG_COLORS;
|
|
||||||
}
|
|
||||||
@ -101,7 +101,7 @@ const LAYER_SPECS: NativeLayerSpec[] = [
|
|||||||
'symbol-placement': 'line',
|
'symbol-placement': 'line',
|
||||||
'text-field': ['get', 'name'],
|
'text-field': ['get', 'name'],
|
||||||
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 11, 12, 13],
|
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 11, 12, 13],
|
||||||
'text-font': ['Noto Sans Regular'],
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
'text-allow-overlap': false,
|
'text-allow-overlap': false,
|
||||||
'text-padding': 8,
|
'text-padding': 8,
|
||||||
'text-rotation-alignment': 'map',
|
'text-rotation-alignment': 'map',
|
||||||
@ -123,7 +123,7 @@ const LAYER_SPECS: NativeLayerSpec[] = [
|
|||||||
'symbol-placement': 'line',
|
'symbol-placement': 'line',
|
||||||
'text-field': ['get', 'name'],
|
'text-field': ['get', 'name'],
|
||||||
'text-size': ['interpolate', ['linear'], ['zoom'], 2, 14, 6, 17, 10, 20],
|
'text-size': ['interpolate', ['linear'], ['zoom'], 2, 14, 6, 17, 10, 20],
|
||||||
'text-font': ['Noto Sans Bold'],
|
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
|
||||||
'text-allow-overlap': true,
|
'text-allow-overlap': true,
|
||||||
'text-padding': 2,
|
'text-padding': 2,
|
||||||
'text-rotation-alignment': 'map',
|
'text-rotation-alignment': 'map',
|
||||||
|
|||||||
@ -231,7 +231,7 @@ export function useZonesLayer(
|
|||||||
'symbol-placement': 'point',
|
'symbol-placement': 'point',
|
||||||
'text-field': zoneLabelExpr as never,
|
'text-field': zoneLabelExpr as never,
|
||||||
'text-size': 11,
|
'text-size': 11,
|
||||||
'text-font': ['Noto Sans Regular'],
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
'text-anchor': 'top',
|
'text-anchor': 'top',
|
||||||
'text-offset': [0, 0.35],
|
'text-offset': [0, 0.35],
|
||||||
'text-allow-overlap': false,
|
'text-allow-overlap': false,
|
||||||
|
|||||||
@ -224,7 +224,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
|||||||
layout: {
|
layout: {
|
||||||
'symbol-placement': 'line',
|
'symbol-placement': 'line',
|
||||||
'text-field': depthLabel,
|
'text-field': depthLabel,
|
||||||
'text-font': ['Noto Sans Regular'],
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 9, 12],
|
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 9, 12],
|
||||||
'text-allow-overlap': false,
|
'text-allow-overlap': false,
|
||||||
'text-padding': 4,
|
'text-padding': 4,
|
||||||
@ -249,7 +249,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
|||||||
layout: {
|
layout: {
|
||||||
'symbol-placement': 'line',
|
'symbol-placement': 'line',
|
||||||
'text-field': depthLabel,
|
'text-field': depthLabel,
|
||||||
'text-font': ['Noto Sans Regular'],
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
'text-size': ['interpolate', ['linear'], ['zoom'], 9, 12, 11, 14, 13, 16],
|
'text-size': ['interpolate', ['linear'], ['zoom'], 9, 12, 11, 14, 13, 16],
|
||||||
'text-allow-overlap': false,
|
'text-allow-overlap': false,
|
||||||
'text-padding': 4,
|
'text-padding': 4,
|
||||||
@ -272,7 +272,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
|||||||
filter: ['has', 'name'] as unknown as unknown[],
|
filter: ['has', 'name'] as unknown as unknown[],
|
||||||
layout: {
|
layout: {
|
||||||
'text-field': ['get', 'name'] as unknown as unknown[],
|
'text-field': ['get', 'name'] as unknown as unknown[],
|
||||||
'text-font': ['Noto Sans Regular'],
|
'text-font': ['Noto Sans Italic', 'Noto Sans Regular', 'Open Sans Italic', 'Open Sans Regular'],
|
||||||
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 8, 12, 10, 13, 12, 14],
|
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 8, 12, 10, 13, 12, 14],
|
||||||
'text-allow-overlap': false,
|
'text-allow-overlap': false,
|
||||||
'text-anchor': 'center',
|
'text-anchor': 'center',
|
||||||
@ -394,10 +394,6 @@ export async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal):
|
|||||||
const { resolveOceanStyle } = await import('../../../features/oceanMap/lib/resolveOceanStyle');
|
const { resolveOceanStyle } = await import('../../../features/oceanMap/lib/resolveOceanStyle');
|
||||||
return resolveOceanStyle(signal);
|
return resolveOceanStyle(signal);
|
||||||
}
|
}
|
||||||
if (baseMap === 'enc') {
|
|
||||||
const { fetchEncStyle } = await import('../../../features/encMap/lib/encStyle');
|
|
||||||
return fetchEncStyle(signal);
|
|
||||||
}
|
|
||||||
// 레거시 베이스맵 비활성 — 향후 위성/라이트 테마 등 추가 시 재활용
|
// 레거시 베이스맵 비활성 — 향후 위성/라이트 테마 등 추가 시 재활용
|
||||||
// if (baseMap === 'legacy') return '/map/styles/carto-dark.json';
|
// if (baseMap === 'legacy') return '/map/styles/carto-dark.json';
|
||||||
void baseMap;
|
void baseMap;
|
||||||
|
|||||||
@ -8,14 +8,19 @@ import type { FleetCircle, PairLink } from '../../../features/legacyDashboard/mo
|
|||||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
import type { DashSeg, PairRangeCircle } from '../types';
|
import type { DashSeg, PairRangeCircle } from '../types';
|
||||||
import {
|
import {
|
||||||
FLAT_OTHER_SHIP_SIZE,
|
SHIP_ICON_MAPPING,
|
||||||
FLAT_TARGET_SHIP_SIZE,
|
FLAT_SHIP_ICON_SIZE,
|
||||||
|
FLAT_SHIP_ICON_SIZE_SELECTED,
|
||||||
|
FLAT_SHIP_ICON_SIZE_HIGHLIGHTED,
|
||||||
FLAT_LEGACY_HALO_RADIUS,
|
FLAT_LEGACY_HALO_RADIUS,
|
||||||
FLAT_LEGACY_HALO_RADIUS_SELECTED,
|
FLAT_LEGACY_HALO_RADIUS_SELECTED,
|
||||||
|
FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED,
|
||||||
|
EMPTY_MMSI_SET,
|
||||||
DEPTH_DISABLED_PARAMS,
|
DEPTH_DISABLED_PARAMS,
|
||||||
GLOBE_OVERLAY_PARAMS,
|
GLOBE_OVERLAY_PARAMS,
|
||||||
HALO_OUTLINE_COLOR,
|
HALO_OUTLINE_COLOR,
|
||||||
HALO_OUTLINE_COLOR_SELECTED,
|
HALO_OUTLINE_COLOR_SELECTED,
|
||||||
|
HALO_OUTLINE_COLOR_HIGHLIGHTED,
|
||||||
PAIR_RANGE_NORMAL_DECK,
|
PAIR_RANGE_NORMAL_DECK,
|
||||||
PAIR_RANGE_WARN_DECK,
|
PAIR_RANGE_WARN_DECK,
|
||||||
PAIR_LINE_NORMAL_DECK,
|
PAIR_LINE_NORMAL_DECK,
|
||||||
@ -33,18 +38,8 @@ import {
|
|||||||
FLEET_RANGE_LINE_DECK_HL,
|
FLEET_RANGE_LINE_DECK_HL,
|
||||||
FLEET_RANGE_FILL_DECK_HL,
|
FLEET_RANGE_FILL_DECK_HL,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { getDisplayHeading } from './shipUtils';
|
import { getDisplayHeading, getShipColor } from './shipUtils';
|
||||||
import {
|
import { getCachedShipIcon } from './shipIconCache';
|
||||||
getShipIconSpec,
|
|
||||||
getTargetShipIconSpec,
|
|
||||||
getShipIconAngle,
|
|
||||||
SPEED_THRESHOLD_KN,
|
|
||||||
} from '../../../shared/lib/map/shipKind';
|
|
||||||
|
|
||||||
/** 64×64 white ring SVG — mask:true로 getColor 틴트 적용 */
|
|
||||||
const ALARM_RING_ICON_URL = `data:image/svg+xml,${encodeURIComponent(
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="27" fill="none" stroke="white" stroke-width="5"/></svg>',
|
|
||||||
)}`;
|
|
||||||
|
|
||||||
/* ── 공통 콜백 인터페이스 ─────────────────────────────── */
|
/* ── 공통 콜백 인터페이스 ─────────────────────────────── */
|
||||||
|
|
||||||
@ -69,6 +64,7 @@ interface DeckSelectCallbacks {
|
|||||||
|
|
||||||
export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks {
|
export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks {
|
||||||
shipLayerData: AisTarget[];
|
shipLayerData: AisTarget[];
|
||||||
|
shipOverlayLayerData: AisTarget[];
|
||||||
legacyTargetsOrdered: AisTarget[];
|
legacyTargetsOrdered: AisTarget[];
|
||||||
legacyOverlayTargets: AisTarget[];
|
legacyOverlayTargets: AisTarget[];
|
||||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
@ -91,7 +87,6 @@ export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelect
|
|||||||
alarmPulseHoverRadius?: number;
|
alarmPulseHoverRadius?: number;
|
||||||
shipPhotoTargets?: AisTarget[];
|
shipPhotoTargets?: AisTarget[];
|
||||||
onClickShipPhoto?: (mmsi: number) => void;
|
onClickShipPhoto?: (mmsi: number) => void;
|
||||||
shipLabelColors?: import('./labelColor').ShipLabelColors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] {
|
export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] {
|
||||||
@ -106,6 +101,10 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
|
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
|
||||||
else shipOtherData.push(t);
|
else shipOtherData.push(t);
|
||||||
}
|
}
|
||||||
|
const shipOverlayOtherData: AisTarget[] = [];
|
||||||
|
for (const t of ctx.shipOverlayLayerData) {
|
||||||
|
if (!isTargetShip(t.mmsi)) shipOverlayOtherData.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─ density ─ */
|
/* ─ density ─ */
|
||||||
if (ctx.showDensity) {
|
if (ctx.showDensity) {
|
||||||
@ -319,6 +318,26 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (shipOtherData.length > 0) {
|
if (shipOtherData.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<AisTarget>({
|
||||||
|
id: 'ships-other-halo',
|
||||||
|
data: shipOtherData,
|
||||||
|
pickable: false,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
radiusUnits: 'pixels',
|
||||||
|
getRadius: 10,
|
||||||
|
getFillColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET).slice(0, 3).concat(40) as unknown as [number, number, number, number],
|
||||||
|
getLineColor: (d) => {
|
||||||
|
const c = getShipColor(d, null, null, EMPTY_MMSI_SET);
|
||||||
|
return [c[0], c[1], c[2], 100] as [number, number, number, number];
|
||||||
|
},
|
||||||
|
stroked: true,
|
||||||
|
lineWidthUnits: 'pixels',
|
||||||
|
getLineWidth: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
layers.push(
|
layers.push(
|
||||||
new IconLayer<AisTarget>({
|
new IconLayer<AisTarget>({
|
||||||
id: 'ships-other',
|
id: 'ships-other',
|
||||||
@ -326,11 +345,14 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
pickable: true,
|
pickable: true,
|
||||||
billboard: false,
|
billboard: false,
|
||||||
parameters: overlayParams,
|
parameters: overlayParams,
|
||||||
getIcon: (d) => getShipIconSpec(d.signalKindCode ?? d.shipKindCode, d.sog),
|
iconAtlas: getCachedShipIcon(),
|
||||||
|
iconMapping: SHIP_ICON_MAPPING,
|
||||||
|
getIcon: () => 'ship',
|
||||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
getAngle: (d) => getShipIconAngle(d.signalKindCode ?? d.shipKindCode, d.cog),
|
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
||||||
sizeUnits: 'pixels',
|
sizeUnits: 'pixels',
|
||||||
getSize: FLAT_OTHER_SHIP_SIZE,
|
getSize: () => FLAT_SHIP_ICON_SIZE,
|
||||||
|
getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET),
|
||||||
onHover: shipOnHover,
|
onHover: shipOnHover,
|
||||||
onClick: shipOnClick,
|
onClick: shipOnClick,
|
||||||
alphaCutoff: 0.05,
|
alphaCutoff: 0.05,
|
||||||
@ -338,6 +360,31 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shipOverlayOtherData.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<AisTarget>({
|
||||||
|
id: 'ships-overlay-other',
|
||||||
|
data: shipOverlayOtherData,
|
||||||
|
pickable: false,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
iconAtlas: getCachedShipIcon(),
|
||||||
|
iconMapping: SHIP_ICON_MAPPING,
|
||||||
|
getIcon: () => 'ship',
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
||||||
|
sizeUnits: 'pixels',
|
||||||
|
getSize: (d) => {
|
||||||
|
if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED;
|
||||||
|
if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
getColor: (d) => getShipColor(d, ctx.selectedMmsi, null, ctx.shipHighlightSet),
|
||||||
|
alphaCutoff: 0.05,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (ctx.legacyTargetsOrdered.length > 0) {
|
if (ctx.legacyTargetsOrdered.length > 0) {
|
||||||
layers.push(
|
layers.push(
|
||||||
new ScatterplotLayer<AisTarget>({
|
new ScatterplotLayer<AisTarget>({
|
||||||
@ -366,14 +413,14 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
pickable: true,
|
pickable: true,
|
||||||
billboard: false,
|
billboard: false,
|
||||||
parameters: overlayParams,
|
parameters: overlayParams,
|
||||||
getIcon: (d) => getTargetShipIconSpec(ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, d.sog),
|
iconAtlas: getCachedShipIcon(),
|
||||||
|
iconMapping: SHIP_ICON_MAPPING,
|
||||||
|
getIcon: () => 'ship',
|
||||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
getAngle: (d) => {
|
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
||||||
const isMoving = Number.isFinite(d.sog) && (d.sog as number) > SPEED_THRESHOLD_KN;
|
|
||||||
return isMoving ? -getDisplayHeading({ cog: d.cog, heading: d.heading }) : 0;
|
|
||||||
},
|
|
||||||
sizeUnits: 'pixels',
|
sizeUnits: 'pixels',
|
||||||
getSize: FLAT_TARGET_SHIP_SIZE,
|
getSize: () => FLAT_SHIP_ICON_SIZE,
|
||||||
|
getColor: (d) => getShipColor(d, null, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET),
|
||||||
onHover: shipOnHover,
|
onHover: shipOnHover,
|
||||||
onClick: shipOnClick,
|
onClick: shipOnClick,
|
||||||
alphaCutoff: 0.05,
|
alphaCutoff: 0.05,
|
||||||
@ -382,41 +429,29 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─ interactive overlays (only when parent overlay is enabled) ─ */
|
/* ─ interactive overlays ─ */
|
||||||
if (ctx.overlays.pairRange && ctx.pairRangesInteractive.length > 0) {
|
if (ctx.pairRangesInteractive.length > 0) {
|
||||||
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: ctx.pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.8, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
|
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: ctx.pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.8, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
|
||||||
}
|
}
|
||||||
if (ctx.overlays.pairLines && ctx.pairLinksInteractive.length > 0) {
|
if (ctx.pairLinksInteractive.length > 0) {
|
||||||
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: ctx.pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 4.5, widthUnits: 'pixels' }));
|
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: ctx.pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 4.5, widthUnits: 'pixels' }));
|
||||||
}
|
}
|
||||||
if (ctx.overlays.fcLines && ctx.fcLinesInteractive.length > 0) {
|
if (ctx.fcLinesInteractive.length > 0) {
|
||||||
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: ctx.fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 3.2, widthUnits: 'pixels' }));
|
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: ctx.fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 3.2, widthUnits: 'pixels' }));
|
||||||
}
|
}
|
||||||
if (ctx.overlays.fleetCircles && ctx.fleetCirclesInteractive.length > 0) {
|
if (ctx.fleetCirclesInteractive.length > 0) {
|
||||||
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
|
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
|
||||||
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 3.0, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
|
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 3.0, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─ legacy overlay (highlight/selected — breathing ring, no icon enlargement) ─ */
|
/* ─ legacy overlay (highlight/selected) ─ */
|
||||||
if (ctx.showShips && ctx.legacyOverlayTargets.length > 0) {
|
if (ctx.showShips && ctx.legacyOverlayTargets.length > 0) {
|
||||||
layers.push(new ScatterplotLayer<AisTarget>({
|
layers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-overlay', data: ctx.legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
|
||||||
id: 'legacy-halo-overlay',
|
}
|
||||||
data: ctx.legacyOverlayTargets,
|
|
||||||
pickable: false,
|
if (ctx.showShips && ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi)).length > 0) {
|
||||||
billboard: false,
|
const shipOverlayTargetData2 = ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi));
|
||||||
parameters: overlayParams,
|
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!ctx.shipHighlightSet.has(d.mmsi) && !(ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, ctx.selectedMmsi, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, ctx.shipHighlightSet); } }));
|
||||||
filled: false,
|
|
||||||
stroked: true,
|
|
||||||
radiusUnits: 'pixels',
|
|
||||||
getRadius: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 18 : 16),
|
|
||||||
lineWidthUnits: 'pixels',
|
|
||||||
getLineWidth: 2.5,
|
|
||||||
getLineColor: (d) => {
|
|
||||||
if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return [14, 234, 255, 200];
|
|
||||||
return [245, 158, 11, 190];
|
|
||||||
},
|
|
||||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─ ship name labels (Mercator) ─ */
|
/* ─ ship name labels (Mercator) ─ */
|
||||||
@ -437,7 +472,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
billboard: true,
|
billboard: true,
|
||||||
getText: (d) => {
|
getText: (d) => {
|
||||||
const legacy = ctx.legacyHits?.get(d.mmsi);
|
const legacy = ctx.legacyHits?.get(d.mmsi);
|
||||||
const baseName = (legacy?.shipNameRoman?.toUpperCase() || legacy?.shipNameCn || d.name?.toUpperCase() || '').trim();
|
const baseName = (legacy?.shipNameCn || legacy?.shipNameRoman || d.name || '').trim();
|
||||||
if (!baseName) return '';
|
if (!baseName) return '';
|
||||||
const alarmKind = ctx.alarmMmsiMap?.get(d.mmsi) ?? null;
|
const alarmKind = ctx.alarmMmsiMap?.get(d.mmsi) ?? null;
|
||||||
return alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
|
return alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
|
||||||
@ -446,7 +481,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
getColor: (d) => {
|
getColor: (d) => {
|
||||||
if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return [14, 234, 255, 242];
|
if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return [14, 234, 255, 242];
|
||||||
if (ctx.shipHighlightSet.has(d.mmsi)) return [245, 158, 11, 242];
|
if (ctx.shipHighlightSet.has(d.mmsi)) return [245, 158, 11, 242];
|
||||||
return ctx.shipLabelColors?.deckDefault ?? [226, 232, 240, 234];
|
return [226, 232, 240, 234];
|
||||||
},
|
},
|
||||||
getSize: 11,
|
getSize: 11,
|
||||||
sizeUnits: 'pixels',
|
sizeUnits: 'pixels',
|
||||||
@ -454,40 +489,39 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
characterSet: 'auto',
|
characterSet: 'auto',
|
||||||
getPixelOffset: [0, 16],
|
getPixelOffset: [0, 16],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
outlineWidth: 1,
|
outlineWidth: 2,
|
||||||
outlineColor: ctx.shipLabelColors?.deckOutline ?? [0, 0, 0, 230],
|
outlineColor: [2, 6, 23, 217],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─ alarm pulse (IconLayer + SVG ring) + badge ─ */
|
/* ─ alarm pulse + badge ─ */
|
||||||
const alarmTargets = ctx.alarmTargets ?? [];
|
const alarmTargets = ctx.alarmTargets ?? [];
|
||||||
const alarmMap = ctx.alarmMmsiMap;
|
const alarmMap = ctx.alarmMmsiMap;
|
||||||
if (ctx.showShips && alarmMap && alarmMap.size > 0 && alarmTargets.length > 0) {
|
if (ctx.showShips && alarmMap && alarmMap.size > 0 && alarmTargets.length > 0) {
|
||||||
const pulseSize = ctx.alarmPulseRadius ?? 40;
|
const pulseR = ctx.alarmPulseRadius ?? 8;
|
||||||
const pulseHoverSize = ctx.alarmPulseHoverRadius ?? 48;
|
const pulseHR = ctx.alarmPulseHoverRadius ?? 12;
|
||||||
layers.push(
|
layers.push(
|
||||||
new IconLayer<AisTarget>({
|
new ScatterplotLayer<AisTarget>({
|
||||||
id: 'alarm-pulse',
|
id: 'alarm-pulse',
|
||||||
data: alarmTargets,
|
data: alarmTargets,
|
||||||
pickable: false,
|
pickable: false,
|
||||||
billboard: true,
|
billboard: false,
|
||||||
parameters: overlayParams,
|
parameters: overlayParams,
|
||||||
iconAtlas: ALARM_RING_ICON_URL,
|
filled: true,
|
||||||
iconMapping: { ring: { x: 0, y: 0, width: 64, height: 64, mask: true } },
|
stroked: false,
|
||||||
getIcon: () => 'ring',
|
radiusUnits: 'pixels',
|
||||||
sizeUnits: 'pixels',
|
getRadius: (d) => {
|
||||||
getSize: (d) => {
|
|
||||||
const isHover = (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) || ctx.shipHighlightSet.has(d.mmsi);
|
const isHover = (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) || ctx.shipHighlightSet.has(d.mmsi);
|
||||||
return isHover ? pulseHoverSize : pulseSize;
|
return isHover ? pulseHR : pulseR;
|
||||||
},
|
},
|
||||||
getColor: (d) => {
|
getFillColor: (d) => {
|
||||||
const kind = alarmMap.get(d.mmsi);
|
const kind = alarmMap.get(d.mmsi);
|
||||||
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 200] as [number, number, number, number];
|
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number];
|
||||||
},
|
},
|
||||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
updateTriggers: { getSize: [pulseSize, pulseHoverSize, ctx.selectedMmsi, ctx.shipHighlightSet] },
|
updateTriggers: { getRadius: [pulseR, pulseHR, ctx.selectedMmsi, ctx.shipHighlightSet] },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
layers.push(
|
layers.push(
|
||||||
@ -517,7 +551,30 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─ ship photo indicator — disabled (파란 원 아이콘 제거) ─ */
|
/* ─ ship photo indicator (사진 유무 표시) ─ */
|
||||||
|
const photoTargets = ctx.shipPhotoTargets ?? [];
|
||||||
|
if (ctx.showShips && photoTargets.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<AisTarget>({
|
||||||
|
id: 'ship-photo-indicator',
|
||||||
|
data: photoTargets,
|
||||||
|
pickable: true,
|
||||||
|
billboard: false,
|
||||||
|
filled: true,
|
||||||
|
stroked: true,
|
||||||
|
radiusUnits: 'pixels',
|
||||||
|
getRadius: 5,
|
||||||
|
getFillColor: [0, 188, 212, 180],
|
||||||
|
getLineColor: [255, 255, 255, 200],
|
||||||
|
lineWidthUnits: 'pixels',
|
||||||
|
getLineWidth: 1,
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
onClick: (info: PickingInfo) => {
|
||||||
|
if (info.object) ctx.onClickShipPhoto?.((info.object as AisTarget).mmsi);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return layers;
|
return layers;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
/**
|
|
||||||
* Compute a readable ship label color based on the map background luminance.
|
|
||||||
* Returns RGBA arrays for Deck.gl and CSS string for MapLibre.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function hexToRgb(hex: string): [number, number, number] {
|
|
||||||
const h = hex.replace('#', '');
|
|
||||||
return [
|
|
||||||
parseInt(h.slice(0, 2), 16),
|
|
||||||
parseInt(h.slice(2, 4), 16),
|
|
||||||
parseInt(h.slice(4, 6), 16),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Relative luminance (WCAG) */
|
|
||||||
function luminance(r: number, g: number, b: number): number {
|
|
||||||
const [rs, gs, bs] = [r, g, b].map((c) => {
|
|
||||||
const s = c / 255;
|
|
||||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
||||||
});
|
|
||||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShipLabelColors {
|
|
||||||
/** Deck.gl TextLayer default getColor [R,G,B,A] */
|
|
||||||
deckDefault: [number, number, number, number];
|
|
||||||
/** MapLibre text-color CSS string */
|
|
||||||
mlDefault: string;
|
|
||||||
/** MapLibre text-halo-color CSS string */
|
|
||||||
mlHalo: string;
|
|
||||||
/** Deck.gl outlineColor [R,G,B,A] */
|
|
||||||
deckOutline: [number, number, number, number];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a background hex color, compute label colors that contrast well.
|
|
||||||
*/
|
|
||||||
export function computeShipLabelColors(bgHex: string): ShipLabelColors {
|
|
||||||
const [r, g, b] = hexToRgb(bgHex);
|
|
||||||
const lum = luminance(r, g, b);
|
|
||||||
|
|
||||||
// Light background (lum > 0.4): dark labels with light halo
|
|
||||||
// Dark background (lum <= 0.4): light labels with dark halo
|
|
||||||
if (lum > 0.4) {
|
|
||||||
return {
|
|
||||||
deckDefault: [30, 30, 40, 234],
|
|
||||||
mlDefault: 'rgba(30,30,40,0.92)',
|
|
||||||
mlHalo: 'rgba(255,255,255,0.85)',
|
|
||||||
deckOutline: [255, 255, 255, 210],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
deckDefault: [226, 232, 240, 234],
|
|
||||||
mlDefault: 'rgba(226,232,240,0.92)',
|
|
||||||
mlHalo: 'rgba(0,0,0,0.9)',
|
|
||||||
deckOutline: [0, 0, 0, 230],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
30
apps/web/src/widgets/map3d/lib/shipIconCache.ts
Normal file
30
apps/web/src/widgets/map3d/lib/shipIconCache.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Ship SVG 아이콘을 미리 fetch하여 data URL로 캐시.
|
||||||
|
* Deck.gl IconLayer가 매번 iconAtlas URL을 fetch하지 않도록
|
||||||
|
* 인라인 data URL을 전달한다.
|
||||||
|
*/
|
||||||
|
const SHIP_SVG_URL = '/assets/ship.svg';
|
||||||
|
|
||||||
|
let _cachedDataUrl: string | null = null;
|
||||||
|
let _promise: Promise<string> | null = null;
|
||||||
|
|
||||||
|
function preloadShipIcon(): Promise<string> {
|
||||||
|
if (_cachedDataUrl) return Promise.resolve(_cachedDataUrl);
|
||||||
|
if (_promise) return _promise;
|
||||||
|
_promise = fetch(SHIP_SVG_URL)
|
||||||
|
.then((res) => res.text())
|
||||||
|
.then((svg) => {
|
||||||
|
_cachedDataUrl = `data:image/svg+xml;base64,${btoa(svg)}`;
|
||||||
|
return _cachedDataUrl;
|
||||||
|
})
|
||||||
|
.catch(() => SHIP_SVG_URL);
|
||||||
|
return _promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 캐시된 data URL 또는 폴백 URL 반환 */
|
||||||
|
export function getCachedShipIcon(): string {
|
||||||
|
return _cachedDataUrl ?? SHIP_SVG_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모듈 임포트 시 즉시 로드 시작
|
||||||
|
preloadShipIcon();
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
import { rgbToHex } from '../../../shared/lib/map/palette';
|
import { rgbToHex } from '../../../shared/lib/map/palette';
|
||||||
import { SHIP_KIND_COLORS } from '../../../shared/lib/map/shipKind';
|
|
||||||
import {
|
import {
|
||||||
ANCHOR_SPEED_THRESHOLD_KN,
|
ANCHOR_SPEED_THRESHOLD_KN,
|
||||||
LEGACY_CODE_COLORS,
|
LEGACY_CODE_COLORS,
|
||||||
|
MAP_SELECTED_SHIP_RGB,
|
||||||
|
MAP_HIGHLIGHT_SHIP_RGB,
|
||||||
|
MAP_DEFAULT_SHIP_RGB,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { isFiniteNumber } from './setUtils';
|
import { isFiniteNumber } from './setUtils';
|
||||||
import { normalizeAngleDeg } from './geometry';
|
import { normalizeAngleDeg } from './geometry';
|
||||||
@ -51,21 +53,44 @@ export function lightenColor(rgb: [number, number, number], ratio = 0.32) {
|
|||||||
|
|
||||||
export function getGlobeBaseShipColor({
|
export function getGlobeBaseShipColor({
|
||||||
legacy,
|
legacy,
|
||||||
signalKindCode,
|
sog,
|
||||||
}: {
|
}: {
|
||||||
legacy: string | null;
|
legacy: string | null;
|
||||||
signalKindCode?: string;
|
sog: number | null;
|
||||||
}) {
|
}) {
|
||||||
// 대상 선박: legacy code 색상 (밝게)
|
|
||||||
if (legacy) {
|
if (legacy) {
|
||||||
const rgb = LEGACY_CODE_COLORS[legacy];
|
const rgb = LEGACY_CODE_COLORS[legacy];
|
||||||
if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
|
if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기타 AIS: signalKindCode → 선종별 색상
|
// Keep alpha control in icon-opacity only to avoid double-multiplying transparency.
|
||||||
const kindColor = SHIP_KIND_COLORS[signalKindCode || '000027'];
|
if (!isFiniteNumber(sog)) return '#64748b';
|
||||||
if (kindColor) return kindColor;
|
if (sog >= 10) return '#94a3b8';
|
||||||
return '#607D8B';
|
if (sog >= 1) return '#64748b';
|
||||||
|
return '#475569';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShipColor(
|
||||||
|
t: AisTarget,
|
||||||
|
selectedMmsi: number | null,
|
||||||
|
legacyShipCode: string | null,
|
||||||
|
highlightedMmsis: Set<number>,
|
||||||
|
): [number, number, number, number] {
|
||||||
|
if (selectedMmsi && t.mmsi === selectedMmsi) {
|
||||||
|
return [MAP_SELECTED_SHIP_RGB[0], MAP_SELECTED_SHIP_RGB[1], MAP_SELECTED_SHIP_RGB[2], 255];
|
||||||
|
}
|
||||||
|
if (highlightedMmsis.has(t.mmsi)) {
|
||||||
|
return [MAP_HIGHLIGHT_SHIP_RGB[0], MAP_HIGHLIGHT_SHIP_RGB[1], MAP_HIGHLIGHT_SHIP_RGB[2], 235];
|
||||||
|
}
|
||||||
|
if (legacyShipCode) {
|
||||||
|
const rgb = LEGACY_CODE_COLORS[legacyShipCode];
|
||||||
|
if (rgb) return [rgb[0], rgb[1], rgb[2], 235];
|
||||||
|
return [245, 158, 11, 235];
|
||||||
|
}
|
||||||
|
if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175];
|
||||||
|
if (t.sog >= 10) return [148, 163, 184, 215];
|
||||||
|
if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 210];
|
||||||
|
return [71, 85, 105, 200];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGlobeShipFeature(
|
export function buildGlobeShipFeature(
|
||||||
@ -83,14 +108,11 @@ export function buildGlobeShipFeature(
|
|||||||
mmsi: t.mmsi,
|
mmsi: t.mmsi,
|
||||||
heading: getDisplayHeading({ cog: t.cog, heading: t.heading, offset }),
|
heading: getDisplayHeading({ cog: t.cog, heading: t.heading, offset }),
|
||||||
anchored,
|
anchored,
|
||||||
color: getGlobeBaseShipColor({
|
color: getGlobeBaseShipColor({ legacy: legacy?.shipCode ?? null, sog: t.sog ?? null }),
|
||||||
legacy: legacy?.shipCode ?? null,
|
|
||||||
signalKindCode: t.signalKindCode || t.shipKindCode || '000027',
|
|
||||||
}),
|
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
highlighted: isHighlighted,
|
highlighted: isHighlighted,
|
||||||
permitted: legacy ? 1 : 0,
|
permitted: legacy ? 1 : 0,
|
||||||
labelName: (legacy?.shipNameRoman?.toUpperCase() || legacy?.shipNameCn || t.name?.toUpperCase() || '').trim(),
|
labelName: (t.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || '',
|
||||||
legacyTag: legacy ? `${legacy.permitNo} (${legacy.shipCode})` : '',
|
legacyTag: legacy ? `${legacy.permitNo} (${legacy.shipCode})` : '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export function getTargetName(
|
|||||||
const legacy = legacyHits?.get(mmsi);
|
const legacy = legacyHits?.get(mmsi);
|
||||||
const target = targetByMmsi.get(mmsi);
|
const target = targetByMmsi.get(mmsi);
|
||||||
return (
|
return (
|
||||||
(legacy?.shipNameRoman?.toUpperCase() || legacy?.shipNameCn || target?.name?.toUpperCase() || '').trim() || `MMSI ${mmsi}`
|
(target?.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || `MMSI ${mmsi}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
|||||||
import type { FcLink, FleetCircle, LegacyAlarmKind, PairLink } from '../../features/legacyDashboard/model/types';
|
import type { FcLink, FleetCircle, LegacyAlarmKind, PairLink } from '../../features/legacyDashboard/model/types';
|
||||||
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
||||||
import type { OceanMapSettings } from '../../features/oceanMap/model/types';
|
import type { OceanMapSettings } from '../../features/oceanMap/model/types';
|
||||||
import type { EncMapSettings } from '../../features/encMap/model/types';
|
|
||||||
|
|
||||||
export type Map3DSettings = {
|
export type Map3DSettings = {
|
||||||
showSeamark: boolean;
|
showSeamark: boolean;
|
||||||
@ -15,7 +14,7 @@ export type Map3DSettings = {
|
|||||||
showDensity: boolean;
|
showDensity: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BaseMapId = 'enhanced' | 'enc' | 'ocean' | 'legacy';
|
export type BaseMapId = 'enhanced' | 'ocean' | 'legacy';
|
||||||
export type MapProjectionId = 'mercator' | 'globe';
|
export type MapProjectionId = 'mercator' | 'globe';
|
||||||
|
|
||||||
export interface MapViewState {
|
export interface MapViewState {
|
||||||
@ -67,10 +66,10 @@ export interface Map3DProps {
|
|||||||
onViewStateChange?: (view: MapViewState) => void;
|
onViewStateChange?: (view: MapViewState) => void;
|
||||||
onGlobeShipsReady?: (ready: boolean) => void;
|
onGlobeShipsReady?: (ready: boolean) => void;
|
||||||
activeTrack?: ActiveTrack | null;
|
activeTrack?: ActiveTrack | null;
|
||||||
trackContextMenu?: { x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean } | null;
|
trackContextMenu?: { x: number; y: number; mmsi: number; vesselName: string } | null;
|
||||||
onRequestTrack?: (mmsi: number, minutes: number) => void;
|
onRequestTrack?: (mmsi: number, minutes: number) => void;
|
||||||
onCloseTrackMenu?: () => void;
|
onCloseTrackMenu?: () => void;
|
||||||
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean }) => void;
|
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void;
|
||||||
/** MMSI → 가장 높은 우선순위 경고 종류. filteredAlarms 기반. */
|
/** MMSI → 가장 높은 우선순위 경고 종류. filteredAlarms 기반. */
|
||||||
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||||
/** 사진 있는 선박 클릭 시 콜백 (사진 표시자 or 선박 아이콘) */
|
/** 사진 있는 선박 클릭 시 콜백 (사진 표시자 or 선박 아이콘) */
|
||||||
@ -79,8 +78,6 @@ export interface Map3DProps {
|
|||||||
freeCamera?: boolean;
|
freeCamera?: boolean;
|
||||||
/** Ocean 지도 전용 설정 */
|
/** Ocean 지도 전용 설정 */
|
||||||
oceanMapSettings?: OceanMapSettings;
|
oceanMapSettings?: OceanMapSettings;
|
||||||
/** ENC 전자해도 전용 설정 */
|
|
||||||
encMapSettings?: EncMapSettings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DashSeg = {
|
export type DashSeg = {
|
||||||
|
|||||||
@ -15,7 +15,6 @@ interface Props {
|
|||||||
onToggleTheme?: () => void;
|
onToggleTheme?: () => void;
|
||||||
isSidebarOpen?: boolean;
|
isSidebarOpen?: boolean;
|
||||||
onMenuToggle?: () => void;
|
onMenuToggle?: () => void;
|
||||||
onOpenMultiTrack?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "total" | "fishing" | "transit" | "pairLinks" | "alarms">) {
|
function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "total" | "fishing" | "transit" | "pairLinks" | "alarms">) {
|
||||||
@ -40,7 +39,7 @@ function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle, onOpenMultiTrack }: Props) {
|
export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle }: Props) {
|
||||||
const [isStatsOpen, setIsStatsOpen] = useState(false);
|
const [isStatsOpen, setIsStatsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -84,15 +83,6 @@ export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, admi
|
|||||||
{/* 데스크톱: 인라인 통계 */}
|
{/* 데스크톱: 인라인 통계 */}
|
||||||
<div className="ml-auto hidden items-center gap-3.5 md:flex">
|
<div className="ml-auto hidden items-center gap-3.5 md:flex">
|
||||||
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
|
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
|
||||||
{onOpenMultiTrack && (
|
|
||||||
<button
|
|
||||||
className="cursor-pointer whitespace-nowrap rounded-md border border-blue-500/50 bg-blue-600/20 px-2.5 py-1 text-[11px] font-semibold text-blue-300 transition-all duration-150 hover:border-blue-400 hover:bg-blue-600/30 hover:text-blue-200"
|
|
||||||
onClick={onOpenMultiTrack}
|
|
||||||
title="다중 선박 항적 조회"
|
|
||||||
>
|
|
||||||
다중항적
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 항상 표시: 시계 + 테마 + 사용자 */}
|
{/* 항상 표시: 시계 + 테마 + 사용자 */}
|
||||||
|
|||||||
@ -1,10 +1,6 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import { useTrackPlaybackStore, TRACK_PLAYBACK_SPEED_OPTIONS } from '../../features/trackReplay/stores/trackPlaybackStore';
|
import { useTrackPlaybackStore, TRACK_PLAYBACK_SPEED_OPTIONS } from '../../features/trackReplay/stores/trackPlaybackStore';
|
||||||
import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore';
|
import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore';
|
||||||
import { exportTrackCsv } from '../../features/trackReplay/lib/csvExport';
|
|
||||||
import { SHIP_KIND_COLORS } from '../../shared/lib/map/shipKind';
|
|
||||||
import type { ProcessedTrack } from '../../features/trackReplay/model/track.types';
|
|
||||||
import { MAX_QUERY_DAYS } from '../../features/vesselSelect/model/types';
|
|
||||||
|
|
||||||
function formatDateTime(ms: number): string {
|
function formatDateTime(ms: number): string {
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return '--';
|
if (!Number.isFinite(ms) || ms <= 0) return '--';
|
||||||
@ -15,127 +11,146 @@ function formatDateTime(ms: number): string {
|
|||||||
)}:${pad(date.getSeconds())}`;
|
)}:${pad(date.getSeconds())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDateTimeLocalKST(ms: number): string {
|
export function GlobalTrackReplayPanel() {
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return '';
|
const PANEL_WIDTH = 420;
|
||||||
const kstDate = new Date(ms + 9 * 3600_000);
|
const PANEL_MARGIN = 12;
|
||||||
return kstDate.toISOString().slice(0, 16);
|
const PANEL_DEFAULT_TOP = 16;
|
||||||
}
|
const PANEL_RIGHT_RESERVED = 520;
|
||||||
|
|
||||||
function fromDateTimeLocalKST(value: string): string {
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
return `${value}:00+09:00`;
|
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(
|
||||||
}
|
null,
|
||||||
|
);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const clampPosition = useCallback(
|
||||||
flex: 1,
|
(x: number, y: number) => {
|
||||||
fontSize: 11,
|
if (typeof window === 'undefined') return { x, y };
|
||||||
padding: '3px 6px',
|
const viewportWidth = window.innerWidth;
|
||||||
borderRadius: 4,
|
const viewportHeight = window.innerHeight;
|
||||||
border: '1px solid rgba(148,163,184,0.35)',
|
const panelHeight = panelRef.current?.offsetHeight ?? 360;
|
||||||
background: 'rgba(30,41,59,0.8)',
|
return {
|
||||||
color: '#e2e8f0',
|
x: Math.min(Math.max(PANEL_MARGIN, x), Math.max(PANEL_MARGIN, viewportWidth - PANEL_WIDTH - PANEL_MARGIN)),
|
||||||
colorScheme: 'dark',
|
y: Math.min(Math.max(PANEL_MARGIN, y), Math.max(PANEL_MARGIN, viewportHeight - panelHeight - PANEL_MARGIN)),
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
[PANEL_MARGIN, PANEL_WIDTH],
|
||||||
|
);
|
||||||
|
|
||||||
const btnBase: React.CSSProperties = {
|
const [position, setPosition] = useState(() => {
|
||||||
padding: '6px 10px',
|
if (typeof window === 'undefined') {
|
||||||
borderRadius: 6,
|
return { x: PANEL_MARGIN, y: PANEL_DEFAULT_TOP };
|
||||||
border: '1px solid rgba(148,163,184,0.45)',
|
}
|
||||||
background: 'rgba(30,41,59,0.8)',
|
return {
|
||||||
color: '#e2e8f0',
|
x: Math.max(PANEL_MARGIN, window.innerWidth - PANEL_WIDTH - PANEL_RIGHT_RESERVED),
|
||||||
cursor: 'pointer',
|
y: PANEL_DEFAULT_TOP,
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|
||||||
interface GlobalTrackReplayPanelProps {
|
|
||||||
isVesselListOpen?: boolean;
|
|
||||||
onToggleVesselList?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GlobalTrackReplayPanel({ isVesselListOpen, onToggleVesselList }: GlobalTrackReplayPanelProps) {
|
|
||||||
const tracks = useTrackQueryStore((state) => state.tracks);
|
const tracks = useTrackQueryStore((state) => state.tracks);
|
||||||
const isLoading = useTrackQueryStore((state) => state.isLoading);
|
const isLoading = useTrackQueryStore((state) => state.isLoading);
|
||||||
const error = useTrackQueryStore((state) => state.error);
|
const error = useTrackQueryStore((state) => state.error);
|
||||||
const queryContext = useTrackQueryStore((state) => state.queryContext);
|
|
||||||
const multiQueryContext = useTrackQueryStore((state) => state.multiQueryContext);
|
|
||||||
const disabledVesselIds = useTrackQueryStore((state) => state.disabledVesselIds);
|
|
||||||
const showPoints = useTrackQueryStore((state) => state.showPoints);
|
const showPoints = useTrackQueryStore((state) => state.showPoints);
|
||||||
|
const showVirtualShip = useTrackQueryStore((state) => state.showVirtualShip);
|
||||||
const showLabels = useTrackQueryStore((state) => state.showLabels);
|
const showLabels = useTrackQueryStore((state) => state.showLabels);
|
||||||
const showTrail = useTrackQueryStore((state) => state.showTrail);
|
const showTrail = useTrackQueryStore((state) => state.showTrail);
|
||||||
const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips);
|
const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips);
|
||||||
const setShowPoints = useTrackQueryStore((state) => state.setShowPoints);
|
const setShowPoints = useTrackQueryStore((state) => state.setShowPoints);
|
||||||
|
const setShowVirtualShip = useTrackQueryStore((state) => state.setShowVirtualShip);
|
||||||
const setShowLabels = useTrackQueryStore((state) => state.setShowLabels);
|
const setShowLabels = useTrackQueryStore((state) => state.setShowLabels);
|
||||||
const setShowTrail = useTrackQueryStore((state) => state.setShowTrail);
|
const setShowTrail = useTrackQueryStore((state) => state.setShowTrail);
|
||||||
const setHideLiveShips = useTrackQueryStore((state) => state.setHideLiveShips);
|
const setHideLiveShips = useTrackQueryStore((state) => state.setHideLiveShips);
|
||||||
const closeTrackQuery = useTrackQueryStore((state) => state.closeQuery);
|
const closeTrackQuery = useTrackQueryStore((state) => state.closeQuery);
|
||||||
const requery = useTrackQueryStore((state) => state.requery);
|
|
||||||
const toggleVesselEnabled = useTrackQueryStore((state) => state.toggleVesselEnabled);
|
|
||||||
|
|
||||||
const isPlaying = useTrackPlaybackStore((state) => state.isPlaying);
|
const isPlaying = useTrackPlaybackStore((state) => state.isPlaying);
|
||||||
const currentTime = useTrackPlaybackStore((state) => state.currentTime);
|
const currentTime = useTrackPlaybackStore((state) => state.currentTime);
|
||||||
const startTime = useTrackPlaybackStore((state) => state.startTime);
|
const startTime = useTrackPlaybackStore((state) => state.startTime);
|
||||||
const endTime = useTrackPlaybackStore((state) => state.endTime);
|
const endTime = useTrackPlaybackStore((state) => state.endTime);
|
||||||
const playbackSpeed = useTrackPlaybackStore((state) => state.playbackSpeed);
|
const playbackSpeed = useTrackPlaybackStore((state) => state.playbackSpeed);
|
||||||
|
const loop = useTrackPlaybackStore((state) => state.loop);
|
||||||
const play = useTrackPlaybackStore((state) => state.play);
|
const play = useTrackPlaybackStore((state) => state.play);
|
||||||
const pause = useTrackPlaybackStore((state) => state.pause);
|
const pause = useTrackPlaybackStore((state) => state.pause);
|
||||||
const stop = useTrackPlaybackStore((state) => state.stop);
|
const stop = useTrackPlaybackStore((state) => state.stop);
|
||||||
const setCurrentTime = useTrackPlaybackStore((state) => state.setCurrentTime);
|
const setCurrentTime = useTrackPlaybackStore((state) => state.setCurrentTime);
|
||||||
const setPlaybackSpeed = useTrackPlaybackStore((state) => state.setPlaybackSpeed);
|
const setPlaybackSpeed = useTrackPlaybackStore((state) => state.setPlaybackSpeed);
|
||||||
|
const toggleLoop = useTrackPlaybackStore((state) => state.toggleLoop);
|
||||||
const timeSyncKey = `${startTime}:${endTime}`;
|
|
||||||
const [editState, setEditState] = useState({ start: '', end: '', syncKey: '' });
|
|
||||||
if (editState.syncKey !== timeSyncKey && startTime > 0 && endTime > 0) {
|
|
||||||
setEditState({
|
|
||||||
start: toDateTimeLocalKST(startTime),
|
|
||||||
end: toDateTimeLocalKST(endTime),
|
|
||||||
syncKey: timeSyncKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const editStartTime = editState.start;
|
|
||||||
const editEndTime = editState.end;
|
|
||||||
const setEditStartTime = (v: string) => setEditState((prev) => ({ ...prev, start: v }));
|
|
||||||
const setEditEndTime = (v: string) => setEditState((prev) => ({ ...prev, end: v }));
|
|
||||||
|
|
||||||
const [requeryWarning, setRequeryWarning] = useState<string | null>(null);
|
|
||||||
const handleRequery = useCallback(() => {
|
|
||||||
if (!editStartTime || !editEndTime) return;
|
|
||||||
const sMs = new Date(fromDateTimeLocalKST(editStartTime)).getTime();
|
|
||||||
const eMs = new Date(fromDateTimeLocalKST(editEndTime)).getTime();
|
|
||||||
const maxMs = MAX_QUERY_DAYS * 86_400_000;
|
|
||||||
if (eMs - sMs > maxMs) {
|
|
||||||
const clamped = toDateTimeLocalKST(sMs + maxMs);
|
|
||||||
setEditEndTime(clamped);
|
|
||||||
setRequeryWarning(`최대 ${MAX_QUERY_DAYS}일 초과 — 종료일을 자동 조정했습니다`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRequeryWarning(null);
|
|
||||||
requery(fromDateTimeLocalKST(editStartTime), fromDateTimeLocalKST(editEndTime));
|
|
||||||
}, [editStartTime, editEndTime, requery]);
|
|
||||||
|
|
||||||
const handleExportCsv = useCallback(() => {
|
|
||||||
if (tracks.length === 0) return;
|
|
||||||
exportTrackCsv(tracks, queryContext, multiQueryContext);
|
|
||||||
}, [tracks, queryContext, multiQueryContext]);
|
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
if (endTime <= startTime) return 0;
|
if (endTime <= startTime) return 0;
|
||||||
return ((currentTime - startTime) / (endTime - startTime)) * 100;
|
return ((currentTime - startTime) / (endTime - startTime)) * 100;
|
||||||
}, [startTime, endTime, currentTime]);
|
}, [startTime, endTime, currentTime]);
|
||||||
|
|
||||||
const isVisible = isLoading || tracks.length > 0 || !!error;
|
const isVisible = isLoading || tracks.length > 0 || !!error;
|
||||||
const isMultiMode = multiQueryContext != null;
|
|
||||||
const vesselCount = tracks.length;
|
useEffect(() => {
|
||||||
const [isVesselListExpanded, setIsVesselListExpanded] = useState(false);
|
if (!isVisible) return;
|
||||||
const hasRequeryContext = isMultiMode || !!queryContext;
|
if (typeof window === 'undefined') return;
|
||||||
|
const onResize = () => {
|
||||||
|
setPosition((prev) => clampPosition(prev.x, prev.y));
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
return () => window.removeEventListener('resize', onResize);
|
||||||
|
}, [clampPosition, isVisible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible) return;
|
||||||
|
const onPointerMove = (event: PointerEvent) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||||
|
setPosition(() => {
|
||||||
|
const nextX = drag.originX + (event.clientX - drag.startX);
|
||||||
|
const nextY = drag.originY + (event.clientY - drag.startY);
|
||||||
|
return clampPosition(nextX, nextY);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDrag = (event: PointerEvent) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||||
|
dragRef.current = null;
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', onPointerMove);
|
||||||
|
window.addEventListener('pointerup', stopDrag);
|
||||||
|
window.addEventListener('pointercancel', stopDrag);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', onPointerMove);
|
||||||
|
window.removeEventListener('pointerup', stopDrag);
|
||||||
|
window.removeEventListener('pointercancel', stopDrag);
|
||||||
|
};
|
||||||
|
}, [clampPosition, isVisible]);
|
||||||
|
|
||||||
|
const handleHeaderPointerDown = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
dragRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
originX: position.x,
|
||||||
|
originY: position.y,
|
||||||
|
};
|
||||||
|
setIsDragging(true);
|
||||||
|
try {
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[position.x, position.y],
|
||||||
|
);
|
||||||
|
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={panelRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 12,
|
left: position.x,
|
||||||
left: '50%',
|
top: position.y,
|
||||||
transform: 'translateX(-50%)',
|
width: PANEL_WIDTH,
|
||||||
width: 'min(95vw, 700px)',
|
|
||||||
background: 'rgba(15,23,42,0.94)',
|
background: 'rgba(15,23,42,0.94)',
|
||||||
border: '1px solid rgba(148,163,184,0.35)',
|
border: '1px solid rgba(148,163,184,0.35)',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
@ -146,165 +161,147 @@ export function GlobalTrackReplayPanel({ isVesselListOpen, onToggleVesselList }:
|
|||||||
boxShadow: '0 8px 24px rgba(2,6,23,0.45)',
|
boxShadow: '0 8px 24px rgba(2,6,23,0.45)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
<div
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
onPointerDown={handleHeaderPointerDown}
|
||||||
<strong style={{ fontSize: 13 }}>
|
style={{
|
||||||
Track Replay{vesselCount > 0 ? ` (${vesselCount}척)` : ''}
|
display: 'flex',
|
||||||
</strong>
|
alignItems: 'center',
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
justifyContent: 'space-between',
|
||||||
{onToggleVesselList && (
|
marginBottom: 8,
|
||||||
<button
|
cursor: isDragging ? 'grabbing' : 'grab',
|
||||||
type="button"
|
userSelect: 'none',
|
||||||
onClick={onToggleVesselList}
|
touchAction: 'none',
|
||||||
style={{
|
}}
|
||||||
fontSize: 11,
|
>
|
||||||
padding: '4px 8px',
|
<strong style={{ fontSize: 13 }}>Track Replay</strong>
|
||||||
borderRadius: 6,
|
<button
|
||||||
border: `1px solid ${isVesselListOpen ? 'rgba(96,165,250,0.7)' : 'rgba(96,165,250,0.35)'}`,
|
type="button"
|
||||||
background: isVesselListOpen ? 'rgba(37,99,235,0.35)' : 'rgba(37,99,235,0.12)',
|
onClick={() => closeTrackQuery()}
|
||||||
color: isVesselListOpen ? '#bfdbfe' : '#93c5fd',
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
cursor: 'pointer',
|
style={{
|
||||||
}}
|
fontSize: 11,
|
||||||
>
|
padding: '4px 8px',
|
||||||
선박 목록
|
borderRadius: 6,
|
||||||
</button>
|
border: '1px solid rgba(148,163,184,0.5)',
|
||||||
)}
|
background: 'rgba(30,41,59,0.7)',
|
||||||
<button
|
color: '#e2e8f0',
|
||||||
type="button"
|
cursor: 'pointer',
|
||||||
onClick={() => closeTrackQuery()}
|
}}
|
||||||
style={{
|
>
|
||||||
fontSize: 11,
|
닫기
|
||||||
padding: '4px 8px',
|
</button>
|
||||||
borderRadius: 6,
|
|
||||||
border: '1px solid rgba(148,163,184,0.5)',
|
|
||||||
background: 'rgba(30,41,59,0.7)',
|
|
||||||
color: '#e2e8f0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
닫기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <div style={{ marginBottom: 8, color: '#fca5a5', fontSize: 12 }}>{error}</div> : null}
|
{error ? (
|
||||||
{requeryWarning ? <div style={{ marginBottom: 8, color: '#fbbf24', fontSize: 12 }}>{requeryWarning}</div> : null}
|
<div style={{ marginBottom: 8, color: '#fca5a5', fontSize: 12 }}>{error}</div>
|
||||||
{isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}>항적 조회 중...</div> : null}
|
|
||||||
|
|
||||||
{/* Vessel list — dynamic layout */}
|
|
||||||
{isMultiMode && vesselCount > 0 ? (
|
|
||||||
<VesselListSection
|
|
||||||
tracks={tracks}
|
|
||||||
disabledVesselIds={disabledVesselIds}
|
|
||||||
toggleVesselEnabled={toggleVesselEnabled}
|
|
||||||
vesselCount={vesselCount}
|
|
||||||
isExpanded={isVesselListExpanded}
|
|
||||||
onToggleExpand={() => setIsVesselListExpanded((v) => !v)}
|
|
||||||
/>
|
|
||||||
) : vesselCount > 0 ? (
|
|
||||||
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 6 }}>선박 {vesselCount}척</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Date range editing */}
|
{isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}>항적 조회 중...</div> : null}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 8 }}>
|
||||||
<label style={{ fontSize: 11, minWidth: 28, color: '#94a3b8' }}>시작</label>
|
선박 {tracks.length}척 · {formatDateTime(startTime)} ~ {formatDateTime(endTime)}
|
||||||
<input type="datetime-local" title="시작 시각" value={editStartTime} onChange={(e) => setEditStartTime(e.target.value)} disabled={isLoading || !hasRequeryContext} style={inputStyle} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<label style={{ fontSize: 11, minWidth: 28, color: '#94a3b8' }}>종료</label>
|
|
||||||
<input type="datetime-local" title="종료 시각" value={editEndTime} onChange={(e) => setEditEndTime(e.target.value)} disabled={isLoading || !hasRequeryContext} style={inputStyle} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
|
||||||
<button type="button" onClick={handleRequery} disabled={isLoading || !hasRequeryContext || !editStartTime || !editEndTime} style={{ flex: 1, padding: '5px 8px', fontSize: 11, borderRadius: 6, border: '1px solid rgba(96,165,250,0.5)', background: 'rgba(37,99,235,0.25)', color: '#93c5fd', cursor: 'pointer' }}>
|
|
||||||
재조회
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={handleExportCsv} disabled={isLoading || tracks.length === 0} style={{ flex: 1, padding: '5px 8px', fontSize: 11, borderRadius: 6, border: '1px solid rgba(74,222,128,0.5)', background: 'rgba(22,163,74,0.2)', color: '#86efac', cursor: 'pointer' }}>
|
|
||||||
CSV 다운로드
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Playback controls */}
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
||||||
<button type="button" onClick={() => (isPlaying ? pause() : play())} disabled={tracks.length === 0} style={btnBase}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => (isPlaying ? pause() : play())}
|
||||||
|
disabled={tracks.length === 0}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid rgba(148,163,184,0.45)',
|
||||||
|
background: 'rgba(30,41,59,0.8)',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isPlaying ? '일시정지' : '재생'}
|
{isPlaying ? '일시정지' : '재생'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => stop()} disabled={tracks.length === 0} style={btnBase}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => stop()}
|
||||||
|
disabled={tracks.length === 0}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid rgba(148,163,184,0.45)',
|
||||||
|
background: 'rgba(30,41,59,0.8)',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
정지
|
정지
|
||||||
</button>
|
</button>
|
||||||
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
배속
|
배속
|
||||||
<select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value))} style={{ background: 'rgba(30,41,59,0.85)', border: '1px solid rgba(148,163,184,0.45)', borderRadius: 6, color: '#e2e8f0', fontSize: 12, padding: '4px 6px' }}>
|
<select
|
||||||
|
value={playbackSpeed}
|
||||||
|
onChange={(event) => setPlaybackSpeed(Number(event.target.value))}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(30,41,59,0.85)',
|
||||||
|
border: '1px solid rgba(148,163,184,0.45)',
|
||||||
|
borderRadius: 6,
|
||||||
|
color: '#e2e8f0',
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '4px 6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{TRACK_PLAYBACK_SPEED_OPTIONS.map((speed) => (
|
{TRACK_PLAYBACK_SPEED_OPTIONS.map((speed) => (
|
||||||
<option key={speed} value={speed}>{speed}x</option>
|
<option key={speed} value={speed}>
|
||||||
|
{speed}x
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline slider */}
|
|
||||||
<div style={{ marginBottom: 10 }}>
|
<div style={{ marginBottom: 10 }}>
|
||||||
<input type="range" title="타임라인" min={startTime} max={endTime || startTime + 1} value={currentTime} onChange={(event) => setCurrentTime(Number(event.target.value))} style={{ width: '100%' }} disabled={tracks.length === 0 || endTime <= startTime} />
|
<input
|
||||||
|
type="range"
|
||||||
|
min={startTime}
|
||||||
|
max={endTime || startTime + 1}
|
||||||
|
value={currentTime}
|
||||||
|
onChange={(event) => setCurrentTime(Number(event.target.value))}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
disabled={tracks.length === 0 || endTime <= startTime}
|
||||||
|
/>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#94a3b8' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#94a3b8' }}>
|
||||||
<span>{formatDateTime(currentTime)}</span>
|
<span>{formatDateTime(currentTime)}</span>
|
||||||
<span>{Math.max(0, Math.min(100, progress)).toFixed(1)}%</span>
|
<span>{Math.max(0, Math.min(100, progress)).toFixed(1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display toggles */}
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 6, fontSize: 12 }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 6, fontSize: 12 }}>
|
<label>
|
||||||
<label><input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} /> 포인트</label>
|
<input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} /> 포인트
|
||||||
<label><input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} /> 선명</label>
|
</label>
|
||||||
<label><input type="checkbox" checked={showTrail} onChange={(event) => setShowTrail(event.target.checked)} /> 잔상</label>
|
<label>
|
||||||
<label><input type="checkbox" checked={hideLiveShips} onChange={(event) => setHideLiveShips(event.target.checked)} /> 라이브 숨김</label>
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showVirtualShip}
|
||||||
|
onChange={(event) => setShowVirtualShip(event.target.checked)}
|
||||||
|
/>{' '}
|
||||||
|
가상선박
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} /> 선명
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={showTrail} onChange={(event) => setShowTrail(event.target.checked)} /> 잔상
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideLiveShips}
|
||||||
|
onChange={(event) => setHideLiveShips(event.target.checked)}
|
||||||
|
/>{' '}
|
||||||
|
라이브 숨김
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={loop} onChange={() => toggleLoop()} /> 반복
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Vessel list sub-component ── */
|
|
||||||
|
|
||||||
interface VesselListSectionProps {
|
|
||||||
tracks: ProcessedTrack[];
|
|
||||||
disabledVesselIds: Set<string>;
|
|
||||||
toggleVesselEnabled: (vesselId: string) => void;
|
|
||||||
vesselCount: number;
|
|
||||||
isExpanded: boolean;
|
|
||||||
onToggleExpand: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function VesselListSection({ tracks, disabledVesselIds, toggleVesselEnabled, vesselCount, isExpanded, onToggleExpand }: VesselListSectionProps) {
|
|
||||||
const showExpandToggle = vesselCount >= 5;
|
|
||||||
const alwaysShow = vesselCount <= 4;
|
|
||||||
const isListVisible = alwaysShow || isExpanded;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ marginBottom: 8 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
|
||||||
<span style={{ fontSize: 11, color: '#93c5fd' }}>선박 {vesselCount}척</span>
|
|
||||||
{showExpandToggle && (
|
|
||||||
<button type="button" onClick={onToggleExpand} style={{ fontSize: 10, color: '#94a3b8', background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px' }}>
|
|
||||||
{isExpanded ? '▴ 접기' : '▾ 펼치기'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isListVisible && (
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: showExpandToggle ? 120 : undefined, overflowY: showExpandToggle ? 'auto' : undefined }}>
|
|
||||||
{tracks.map((track) => {
|
|
||||||
const isEnabled = !disabledVesselIds.has(track.vesselId);
|
|
||||||
const kindColor = SHIP_KIND_COLORS[track.shipKindCode] || '#607D8B';
|
|
||||||
return (
|
|
||||||
<label key={track.vesselId} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 6px', borderRadius: 999, background: isEnabled ? 'rgba(148,163,184,0.12)' : 'rgba(148,163,184,0.04)', fontSize: 10, color: isEnabled ? '#cbd5e1' : '#64748B', cursor: 'pointer', opacity: isEnabled ? 1 : 0.5 }}>
|
|
||||||
<input type="checkbox" checked={isEnabled} onChange={() => toggleVesselEnabled(track.vesselId)} style={{ width: 10, height: 10, accentColor: kindColor }} />
|
|
||||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: kindColor, flexShrink: 0 }} />
|
|
||||||
{track.shipName}
|
|
||||||
<span style={{ color: '#64748B' }}>({track.targetId.slice(-5)})</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,214 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, type CSSProperties } from 'react';
|
|
||||||
import type { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types';
|
|
||||||
import { VESSEL_TYPES } from '../../entities/vessel/model/meta';
|
|
||||||
import type { SortKey, SortDir } from './VesselSelectModal';
|
|
||||||
|
|
||||||
interface VesselSelectGridProps {
|
|
||||||
vessels: DerivedLegacyVessel[];
|
|
||||||
selectedMmsis: Set<number>;
|
|
||||||
toggleMmsi: (mmsi: number) => void;
|
|
||||||
setMmsis: (mmsis: Set<number>) => void;
|
|
||||||
sortKey: SortKey | null;
|
|
||||||
sortDir: SortDir;
|
|
||||||
onSort: (key: SortKey) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DragState {
|
|
||||||
startIdx: number;
|
|
||||||
endIdx: number;
|
|
||||||
direction: 'check' | 'uncheck';
|
|
||||||
}
|
|
||||||
|
|
||||||
const STYLE_TABLE: CSSProperties = {
|
|
||||||
width: '100%',
|
|
||||||
borderCollapse: 'collapse',
|
|
||||||
fontSize: 11,
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_TH: CSSProperties = {
|
|
||||||
position: 'sticky',
|
|
||||||
top: 0,
|
|
||||||
background: 'rgba(15,23,42,0.98)',
|
|
||||||
color: '#94a3b8',
|
|
||||||
textAlign: 'left',
|
|
||||||
padding: '6px 8px',
|
|
||||||
borderBottom: '1px solid rgba(148,163,184,0.2)',
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
userSelect: 'none',
|
|
||||||
};
|
|
||||||
|
|
||||||
function getSortIndicator(col: SortKey, sortKey: SortKey | null, sortDir: SortDir): string {
|
|
||||||
if (sortKey !== col) return ' –';
|
|
||||||
return sortDir === 'asc' ? ' ▲' : ' ▼';
|
|
||||||
}
|
|
||||||
|
|
||||||
const STYLE_TH_CHECKBOX: CSSProperties = {
|
|
||||||
...STYLE_TH,
|
|
||||||
width: 28,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getTdStyle(isSelected: boolean): CSSProperties {
|
|
||||||
return {
|
|
||||||
padding: '5px 8px',
|
|
||||||
borderBottom: '1px solid rgba(148,163,184,0.08)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: isSelected ? 'rgba(59,130,246,0.12)' : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStateBadgeStyle(isFishing: boolean, isTransit: boolean): CSSProperties {
|
|
||||||
const color = isFishing ? '#22C55E' : isTransit ? '#3B82F6' : '#64748B';
|
|
||||||
return {
|
|
||||||
background: `${color}22`,
|
|
||||||
color,
|
|
||||||
borderRadius: 3,
|
|
||||||
padding: '1px 4px',
|
|
||||||
fontSize: 10,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDotStyle(color: string): CSSProperties {
|
|
||||||
return {
|
|
||||||
display: 'inline-block',
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: color,
|
|
||||||
marginRight: 4,
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInDragRange(idx: number, drag: DragState): boolean {
|
|
||||||
const min = Math.min(drag.startIdx, drag.endIdx);
|
|
||||||
const max = Math.max(drag.startIdx, drag.endIdx);
|
|
||||||
return idx >= min && idx <= max;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDragHighlight(direction: 'check' | 'uncheck'): string {
|
|
||||||
return direction === 'check' ? 'rgba(59,130,246,0.18)' : 'rgba(148,163,184,0.12)';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VesselSelectGrid({ vessels, selectedMmsis, toggleMmsi, setMmsis, sortKey, sortDir, onSort }: VesselSelectGridProps) {
|
|
||||||
const [dragState, setDragState] = useState<DragState | null>(null);
|
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
|
||||||
(idx: number, e: React.MouseEvent) => {
|
|
||||||
// 체크박스 직접 클릭은 무시 (기존 onChange 처리)
|
|
||||||
if ((e.target as HTMLElement).tagName === 'INPUT') return;
|
|
||||||
e.preventDefault();
|
|
||||||
const isSelected = selectedMmsis.has(vessels[idx].mmsi);
|
|
||||||
setDragState({ startIdx: idx, endIdx: idx, direction: isSelected ? 'uncheck' : 'check' });
|
|
||||||
},
|
|
||||||
[vessels, selectedMmsis],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(
|
|
||||||
(idx: number) => {
|
|
||||||
if (!dragState) return;
|
|
||||||
setDragState((prev) => (prev ? { ...prev, endIdx: idx } : null));
|
|
||||||
},
|
|
||||||
[dragState],
|
|
||||||
);
|
|
||||||
|
|
||||||
// document-level mouseup: 드래그 종료
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dragState) return;
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
const { startIdx, endIdx, direction } = dragState;
|
|
||||||
if (startIdx === endIdx) {
|
|
||||||
// 단일 클릭
|
|
||||||
toggleMmsi(vessels[startIdx].mmsi);
|
|
||||||
} else {
|
|
||||||
// 범위 선택
|
|
||||||
const min = Math.min(startIdx, endIdx);
|
|
||||||
const max = Math.max(startIdx, endIdx);
|
|
||||||
const newSet = new Set(selectedMmsis);
|
|
||||||
for (let i = min; i <= max; i++) {
|
|
||||||
const mmsi = vessels[i].mmsi;
|
|
||||||
if (direction === 'check') newSet.add(mmsi);
|
|
||||||
else newSet.delete(mmsi);
|
|
||||||
}
|
|
||||||
setMmsis(newSet);
|
|
||||||
}
|
|
||||||
setDragState(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
|
||||||
return () => document.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
}, [dragState, vessels, selectedMmsis, toggleMmsi, setMmsis]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<table style={{ ...STYLE_TABLE, userSelect: dragState ? 'none' : undefined }}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={STYLE_TH_CHECKBOX} />
|
|
||||||
<th style={STYLE_TH} onClick={() => onSort('shipCode')}>업종{getSortIndicator('shipCode', sortKey, sortDir)}</th>
|
|
||||||
<th style={STYLE_TH} onClick={() => onSort('permitNo')}>등록번호{getSortIndicator('permitNo', sortKey, sortDir)}</th>
|
|
||||||
<th style={STYLE_TH} onClick={() => onSort('name')}>선명{getSortIndicator('name', sortKey, sortDir)}</th>
|
|
||||||
<th style={STYLE_TH} onClick={() => onSort('mmsi')}>MMSI{getSortIndicator('mmsi', sortKey, sortDir)}</th>
|
|
||||||
<th style={STYLE_TH} onClick={() => onSort('sog')}>속력{getSortIndicator('sog', sortKey, sortDir)}</th>
|
|
||||||
<th style={STYLE_TH} onClick={() => onSort('state')}>상태{getSortIndicator('state', sortKey, sortDir)}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{vessels.map((v, idx) => {
|
|
||||||
const isSelected = selectedMmsis.has(v.mmsi);
|
|
||||||
const meta = VESSEL_TYPES[v.shipCode];
|
|
||||||
const inRange = dragState ? isInDragRange(idx, dragState) : false;
|
|
||||||
|
|
||||||
// 드래그 중 범위 내 행 → 예상 상태 미리보기
|
|
||||||
let rowBg: string | undefined;
|
|
||||||
if (inRange && dragState) {
|
|
||||||
rowBg = getDragHighlight(dragState.direction);
|
|
||||||
} else if (isSelected) {
|
|
||||||
rowBg = 'rgba(59,130,246,0.12)';
|
|
||||||
}
|
|
||||||
|
|
||||||
const tdStyle = getTdStyle(false); // 배경은 tr에서 관리
|
|
||||||
const stateBadgeStyle = getStateBadgeStyle(v.state.isFishing, v.state.isTransit);
|
|
||||||
const mmsiDisplay = String(v.mmsi);
|
|
||||||
const sogDisplay = v.sog !== null ? `${v.sog.toFixed(1)} kt` : '–';
|
|
||||||
|
|
||||||
// 드래그 중 범위 내 체크 상태 미리보기
|
|
||||||
const previewChecked = inRange && dragState
|
|
||||||
? dragState.direction === 'check'
|
|
||||||
: isSelected;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={v.mmsi}
|
|
||||||
style={{ cursor: 'pointer', background: rowBg }}
|
|
||||||
onMouseDown={(e) => handleMouseDown(idx, e)}
|
|
||||||
onMouseEnter={() => handleMouseEnter(idx)}
|
|
||||||
>
|
|
||||||
<td style={tdStyle}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
title="선택"
|
|
||||||
checked={previewChecked}
|
|
||||||
onChange={() => toggleMmsi(v.mmsi)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td style={tdStyle}>
|
|
||||||
<span style={getDotStyle(meta.color)} />
|
|
||||||
{v.shipCode}
|
|
||||||
</td>
|
|
||||||
<td style={tdStyle}>{v.permitNo}</td>
|
|
||||||
<td style={tdStyle}>{v.name}</td>
|
|
||||||
<td style={tdStyle}>{mmsiDisplay}</td>
|
|
||||||
<td style={tdStyle}>{sogDisplay}</td>
|
|
||||||
<td style={tdStyle}>
|
|
||||||
<span style={stateBadgeStyle}>{v.state.label}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,577 +0,0 @@
|
|||||||
import { useMemo, useEffect, useCallback, useState, useRef, type CSSProperties } from 'react';
|
|
||||||
import type { VesselSelectModalState } from '../../features/vesselSelect/hooks/useVesselSelectModal';
|
|
||||||
import type { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types';
|
|
||||||
import { VESSEL_TYPE_ORDER, VESSEL_TYPES } from '../../entities/vessel/model/meta';
|
|
||||||
import { MAX_VESSEL_GROUPS } from '../../features/vesselSelect/model/types';
|
|
||||||
import { VesselSelectGrid } from './VesselSelectGrid';
|
|
||||||
import { ToggleButton, TextInput, Button } from '@wing/ui';
|
|
||||||
|
|
||||||
export type SortKey = 'shipCode' | 'permitNo' | 'name' | 'mmsi' | 'sog' | 'state';
|
|
||||||
export type SortDir = 'asc' | 'desc';
|
|
||||||
|
|
||||||
interface VesselSelectModalProps {
|
|
||||||
modal: VesselSelectModalState;
|
|
||||||
vessels: DerivedLegacyVessel[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const STRIP_RE = /[\s\-.,_]/g;
|
|
||||||
|
|
||||||
function normalize(s: string): string {
|
|
||||||
return s.replace(STRIP_RE, '').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesQuery(v: DerivedLegacyVessel, nq: string): boolean {
|
|
||||||
if (normalize(v.permitNo).includes(nq)) return true;
|
|
||||||
if (normalize(v.name).includes(nq)) return true;
|
|
||||||
if (v.legacy.shipNameRoman && normalize(v.legacy.shipNameRoman).includes(nq)) return true;
|
|
||||||
if (v.legacy.shipNameCn && normalize(v.legacy.shipNameCn).includes(nq)) return true;
|
|
||||||
if (normalize(String(v.mmsi)).includes(nq)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATE_LABELS = ['조업', '항해', '정지', '저속', '미상'] as const;
|
|
||||||
const STATE_COLORS: Record<string, string> = {
|
|
||||||
조업: '#22C55E',
|
|
||||||
항해: '#3B82F6',
|
|
||||||
정지: '#64748B',
|
|
||||||
저속: '#EAB308',
|
|
||||||
미상: '#6B7280',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_OVERLAY: CSSProperties = {
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 1050,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_BACKDROP: CSSProperties = {
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
background: 'rgba(0,0,0,0.35)',
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_CONTENT: CSSProperties = {
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
maxWidth: 720,
|
|
||||||
width: '95vw',
|
|
||||||
maxHeight: '57vh',
|
|
||||||
background: 'rgba(15,23,42,0.96)',
|
|
||||||
backdropFilter: 'blur(12px)',
|
|
||||||
border: '1px solid rgba(148,163,184,0.25)',
|
|
||||||
borderRadius: 12,
|
|
||||||
color: '#e2e8f0',
|
|
||||||
overflow: 'hidden',
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_HEADER: CSSProperties = {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '10px 16px',
|
|
||||||
borderBottom: '1px solid rgba(148,163,184,0.15)',
|
|
||||||
flexShrink: 0,
|
|
||||||
cursor: 'grab',
|
|
||||||
userSelect: 'none',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_CLOSE_BTN: CSSProperties = {
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
color: '#94a3b8',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 1,
|
|
||||||
padding: '2px 6px',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_FILTERS: CSSProperties = {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 6,
|
|
||||||
padding: '8px 16px',
|
|
||||||
borderBottom: '1px solid rgba(148,163,184,0.1)',
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_FILTER_ROW: CSSProperties = {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_FILTER_LABEL: CSSProperties = {
|
|
||||||
fontSize: 11,
|
|
||||||
color: '#64748b',
|
|
||||||
minWidth: 28,
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_DOT = (color: string): CSSProperties => ({
|
|
||||||
display: 'inline-block',
|
|
||||||
width: 7,
|
|
||||||
height: 7,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: color,
|
|
||||||
marginRight: 3,
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
});
|
|
||||||
|
|
||||||
const STYLE_GROUP_BAR: CSSProperties = {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
padding: '6px 16px',
|
|
||||||
borderBottom: '1px solid rgba(148,163,184,0.1)',
|
|
||||||
flexShrink: 0,
|
|
||||||
overflowX: 'auto',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_GROUP_BTN: CSSProperties = {
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
padding: '3px 8px',
|
|
||||||
fontSize: 11,
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid rgba(148,163,184,0.3)',
|
|
||||||
background: 'rgba(30,41,59,0.6)',
|
|
||||||
color: '#cbd5e1',
|
|
||||||
cursor: 'pointer',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_GROUP_DELETE: CSSProperties = {
|
|
||||||
fontSize: 9,
|
|
||||||
color: '#94a3b8',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '0 2px',
|
|
||||||
lineHeight: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_GROUP_INPUT: CSSProperties = {
|
|
||||||
fontSize: 11,
|
|
||||||
padding: '3px 6px',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid rgba(59,130,246,0.5)',
|
|
||||||
background: 'rgba(30,41,59,0.8)',
|
|
||||||
color: '#e2e8f0',
|
|
||||||
outline: 'none',
|
|
||||||
width: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_SEARCH: CSSProperties = {
|
|
||||||
padding: '6px 16px',
|
|
||||||
borderBottom: '1px solid rgba(148,163,184,0.1)',
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_GRID: CSSProperties = {
|
|
||||||
flex: 1,
|
|
||||||
overflowY: 'auto',
|
|
||||||
minHeight: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_DATE_BAR: CSSProperties = {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 6,
|
|
||||||
padding: '8px 16px',
|
|
||||||
borderTop: '1px solid rgba(148,163,184,0.1)',
|
|
||||||
flexShrink: 0,
|
|
||||||
fontSize: 11,
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_DATETIME_INPUT: CSSProperties = {
|
|
||||||
fontSize: 11,
|
|
||||||
padding: '3px 6px',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid rgba(148,163,184,0.35)',
|
|
||||||
background: 'rgba(30,41,59,0.8)',
|
|
||||||
color: '#e2e8f0',
|
|
||||||
colorScheme: 'dark',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_FOOTER: CSSProperties = {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 10,
|
|
||||||
padding: '8px 16px',
|
|
||||||
borderTop: '1px solid rgba(148,163,184,0.15)',
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const STYLE_FOOTER_SPACER: CSSProperties = { flex: 1 };
|
|
||||||
|
|
||||||
const STYLE_SEPARATOR: CSSProperties = {
|
|
||||||
width: 1,
|
|
||||||
height: 14,
|
|
||||||
background: 'rgba(148,163,184,0.2)',
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) {
|
|
||||||
const {
|
|
||||||
isOpen,
|
|
||||||
close,
|
|
||||||
selectedMmsis,
|
|
||||||
toggleMmsi,
|
|
||||||
setMmsis,
|
|
||||||
selectAllFiltered,
|
|
||||||
clearAll,
|
|
||||||
searchQuery,
|
|
||||||
setSearchQuery,
|
|
||||||
shipCodeFilter,
|
|
||||||
toggleShipCode,
|
|
||||||
toggleAllShipCodes,
|
|
||||||
onlySailing,
|
|
||||||
setOnlySailing,
|
|
||||||
stateFilter,
|
|
||||||
toggleStateFilter,
|
|
||||||
toggleAllStates,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
setStartTime,
|
|
||||||
setEndTime,
|
|
||||||
applyPresetDays,
|
|
||||||
isQuerying,
|
|
||||||
submitQuery,
|
|
||||||
position,
|
|
||||||
setPosition,
|
|
||||||
selectionWarning,
|
|
||||||
groups,
|
|
||||||
saveGroup,
|
|
||||||
deleteGroup,
|
|
||||||
applyGroup,
|
|
||||||
} = modal;
|
|
||||||
|
|
||||||
// ── 그룹 저장 입력 ──
|
|
||||||
const [isSavingGroup, setIsSavingGroup] = useState(false);
|
|
||||||
const [groupNameDraft, setGroupNameDraft] = useState('');
|
|
||||||
const [groupWarning, setGroupWarning] = useState<string | null>(null);
|
|
||||||
const groupInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const handleSaveGroup = useCallback(() => {
|
|
||||||
if (!groupNameDraft.trim()) {
|
|
||||||
setIsSavingGroup(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const warn = saveGroup(groupNameDraft, [...selectedMmsis]);
|
|
||||||
if (warn) {
|
|
||||||
setGroupWarning(warn);
|
|
||||||
} else {
|
|
||||||
setGroupWarning(null);
|
|
||||||
}
|
|
||||||
setIsSavingGroup(false);
|
|
||||||
setGroupNameDraft('');
|
|
||||||
}, [groupNameDraft, saveGroup, selectedMmsis]);
|
|
||||||
|
|
||||||
// ── 정렬 ──
|
|
||||||
const [sortKey, setSortKey] = useState<SortKey | null>(null);
|
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
|
||||||
|
|
||||||
const handleSort = useCallback((key: SortKey) => {
|
|
||||||
setSortKey((prev) => {
|
|
||||||
if (prev === key) {
|
|
||||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
setSortDir('asc');
|
|
||||||
return key;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── 필터 ──
|
|
||||||
const filteredVessels = useMemo(() => {
|
|
||||||
let list = vessels;
|
|
||||||
if (shipCodeFilter.size > 0) {
|
|
||||||
list = list.filter((v) => shipCodeFilter.has(v.shipCode));
|
|
||||||
}
|
|
||||||
if (stateFilter.size > 0) {
|
|
||||||
list = list.filter((v) => stateFilter.has(v.state.label));
|
|
||||||
}
|
|
||||||
if (onlySailing) {
|
|
||||||
list = list.filter((v) => v.state.isFishing || v.state.isTransit);
|
|
||||||
}
|
|
||||||
const nq = searchQuery.length >= 2 ? normalize(searchQuery) : '';
|
|
||||||
if (nq) {
|
|
||||||
list = list.filter((v) => matchesQuery(v, nq));
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
}, [vessels, shipCodeFilter, stateFilter, onlySailing, searchQuery]);
|
|
||||||
|
|
||||||
const sortedVessels = useMemo(() => {
|
|
||||||
if (!sortKey) return filteredVessels;
|
|
||||||
const arr = [...filteredVessels];
|
|
||||||
arr.sort((a, b) => {
|
|
||||||
let cmp = 0;
|
|
||||||
switch (sortKey) {
|
|
||||||
case 'shipCode': cmp = a.shipCode.localeCompare(b.shipCode); break;
|
|
||||||
case 'permitNo': cmp = a.permitNo.localeCompare(b.permitNo); break;
|
|
||||||
case 'name': cmp = a.name.localeCompare(b.name, 'ko'); break;
|
|
||||||
case 'mmsi': cmp = a.mmsi - b.mmsi; break;
|
|
||||||
case 'sog': cmp = (a.sog ?? -1) - (b.sog ?? -1); break;
|
|
||||||
case 'state': cmp = a.state.label.localeCompare(b.state.label, 'ko'); break;
|
|
||||||
}
|
|
||||||
return sortDir === 'asc' ? cmp : -cmp;
|
|
||||||
});
|
|
||||||
return arr;
|
|
||||||
}, [filteredVessels, sortKey, sortDir]);
|
|
||||||
|
|
||||||
// ── Escape 닫기 ──
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') close();
|
|
||||||
},
|
|
||||||
[close],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [isOpen, handleKeyDown]);
|
|
||||||
|
|
||||||
// ── 드래그 ──
|
|
||||||
const handlePointerDown = useCallback(
|
|
||||||
(e: React.PointerEvent) => {
|
|
||||||
if ((e.target as HTMLElement).closest('button')) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const startX = e.clientX - position.x;
|
|
||||||
const startY = e.clientY - position.y;
|
|
||||||
document.body.style.cursor = 'grabbing';
|
|
||||||
|
|
||||||
const handleMove = (ev: PointerEvent) => {
|
|
||||||
setPosition({ x: ev.clientX - startX, y: ev.clientY - startY });
|
|
||||||
};
|
|
||||||
const handleUp = () => {
|
|
||||||
document.body.style.cursor = '';
|
|
||||||
document.removeEventListener('pointermove', handleMove);
|
|
||||||
document.removeEventListener('pointerup', handleUp);
|
|
||||||
};
|
|
||||||
document.addEventListener('pointermove', handleMove);
|
|
||||||
document.addEventListener('pointerup', handleUp);
|
|
||||||
},
|
|
||||||
[position, setPosition],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── 전체 선택 ──
|
|
||||||
const isAllSelected = filteredVessels.length > 0 && filteredVessels.every((v) => selectedMmsis.has(v.mmsi));
|
|
||||||
|
|
||||||
const handleSelectAllChange = useCallback(() => {
|
|
||||||
if (isAllSelected) clearAll();
|
|
||||||
else selectAllFiltered(filteredVessels);
|
|
||||||
}, [isAllSelected, clearAll, selectAllFiltered, filteredVessels]);
|
|
||||||
|
|
||||||
const handleContentClick = useCallback((e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={STYLE_BACKDROP} onClick={close} />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
...STYLE_OVERLAY,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
...STYLE_CONTENT,
|
|
||||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
|
||||||
}}
|
|
||||||
onClick={handleContentClick}
|
|
||||||
>
|
|
||||||
{/* 헤더 (드래그 핸들) */}
|
|
||||||
<div
|
|
||||||
style={STYLE_HEADER}
|
|
||||||
onPointerDown={handlePointerDown}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<span style={{ color: '#64748b', fontSize: 12 }}>≡</span>
|
|
||||||
<strong style={{ fontSize: 13 }}>대상 선박 선택</strong>
|
|
||||||
<span style={{ color: '#64748b', fontSize: 11 }}>({vessels.length}척)</span>
|
|
||||||
</div>
|
|
||||||
<button style={STYLE_CLOSE_BTN} onClick={close}>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 업종 + 상태 필터 */}
|
|
||||||
<div style={STYLE_FILTERS}>
|
|
||||||
<div style={STYLE_FILTER_ROW}>
|
|
||||||
<span style={STYLE_FILTER_LABEL}>업종</span>
|
|
||||||
<ToggleButton on={shipCodeFilter.size === VESSEL_TYPE_ORDER.length} onClick={() => toggleAllShipCodes(VESSEL_TYPE_ORDER)}>
|
|
||||||
전체
|
|
||||||
</ToggleButton>
|
|
||||||
{VESSEL_TYPE_ORDER.map((code) => {
|
|
||||||
const meta = VESSEL_TYPES[code];
|
|
||||||
return (
|
|
||||||
<ToggleButton key={code} on={shipCodeFilter.has(code)} onClick={() => toggleShipCode(code)}>
|
|
||||||
<span style={STYLE_DOT(meta.color)} />
|
|
||||||
{code}
|
|
||||||
</ToggleButton>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div style={STYLE_FILTER_ROW}>
|
|
||||||
<span style={STYLE_FILTER_LABEL}>상태</span>
|
|
||||||
<ToggleButton on={stateFilter.size === STATE_LABELS.length} onClick={() => toggleAllStates([...STATE_LABELS])}>
|
|
||||||
전체
|
|
||||||
</ToggleButton>
|
|
||||||
{STATE_LABELS.map((label) => (
|
|
||||||
<ToggleButton key={label} on={stateFilter.has(label)} onClick={() => toggleStateFilter(label)}>
|
|
||||||
<span style={STYLE_DOT(STATE_COLORS[label])} />
|
|
||||||
{label}
|
|
||||||
</ToggleButton>
|
|
||||||
))}
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
fontSize: 11,
|
|
||||||
color: '#94a3b8',
|
|
||||||
cursor: 'pointer',
|
|
||||||
marginLeft: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={onlySailing}
|
|
||||||
onChange={(e) => setOnlySailing(e.target.checked)}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
/>
|
|
||||||
운항중
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 그룹 바 */}
|
|
||||||
{(groups.length > 0 || selectedMmsis.size > 0) && (
|
|
||||||
<div style={STYLE_GROUP_BAR}>
|
|
||||||
{isSavingGroup ? (
|
|
||||||
<input
|
|
||||||
ref={groupInputRef}
|
|
||||||
style={STYLE_GROUP_INPUT}
|
|
||||||
placeholder="그룹명 입력"
|
|
||||||
value={groupNameDraft}
|
|
||||||
onChange={(e) => setGroupNameDraft(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') handleSaveGroup();
|
|
||||||
if (e.key === 'Escape') { setIsSavingGroup(false); setGroupNameDraft(''); }
|
|
||||||
}}
|
|
||||||
onBlur={handleSaveGroup}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
disabled={selectedMmsis.size === 0 || (groups.length >= MAX_VESSEL_GROUPS && !groups.some((g) => g.mmsis.length === selectedMmsis.size))}
|
|
||||||
title={groups.length >= MAX_VESSEL_GROUPS ? `최대 ${MAX_VESSEL_GROUPS}개` : undefined}
|
|
||||||
onClick={() => { setIsSavingGroup(true); setGroupWarning(null); }}
|
|
||||||
>
|
|
||||||
그룹 저장
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{groupWarning && <span style={{ color: '#fca5a5', fontSize: 10 }}>{groupWarning}</span>}
|
|
||||||
{groups.map((g) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={g.id}
|
|
||||||
style={STYLE_GROUP_BTN}
|
|
||||||
onClick={() => applyGroup(g)}
|
|
||||||
title={`${g.mmsis.length}척`}
|
|
||||||
>
|
|
||||||
{g.name}
|
|
||||||
<span
|
|
||||||
style={STYLE_GROUP_DELETE}
|
|
||||||
onClick={(e) => { e.stopPropagation(); deleteGroup(g.id); }}
|
|
||||||
title="삭제"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 검색 */}
|
|
||||||
<div style={STYLE_SEARCH}>
|
|
||||||
<TextInput placeholder="검색: 등록번호 / 선박명 / MMSI" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 그리드 */}
|
|
||||||
<div style={STYLE_GRID}>
|
|
||||||
<VesselSelectGrid
|
|
||||||
vessels={sortedVessels}
|
|
||||||
selectedMmsis={selectedMmsis}
|
|
||||||
toggleMmsi={toggleMmsi}
|
|
||||||
setMmsis={setMmsis}
|
|
||||||
sortKey={sortKey}
|
|
||||||
sortDir={sortDir}
|
|
||||||
onSort={handleSort}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 기간 설정 바 */}
|
|
||||||
<div style={STYLE_DATE_BAR}>
|
|
||||||
{[7, 14, 21, 28].map((d) => (
|
|
||||||
<Button key={d} variant="ghost" size="sm" onClick={() => applyPresetDays(d)}>
|
|
||||||
{d}일
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
<div style={STYLE_SEPARATOR} />
|
|
||||||
<label style={{ color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
시작
|
|
||||||
<input type="datetime-local" value={startTime} onChange={(e) => setStartTime(e.target.value)} style={STYLE_DATETIME_INPUT} />
|
|
||||||
</label>
|
|
||||||
<label style={{ color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
종료
|
|
||||||
<input type="datetime-local" value={endTime} onChange={(e) => setEndTime(e.target.value)} style={STYLE_DATETIME_INPUT} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 푸터 */}
|
|
||||||
<div style={STYLE_FOOTER}>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
fontSize: 11,
|
|
||||||
color: '#cbd5e1',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="checkbox" checked={isAllSelected} onChange={handleSelectAllChange} style={{ cursor: 'pointer' }} />
|
|
||||||
전체 선택 ({filteredVessels.length}척)
|
|
||||||
</label>
|
|
||||||
<Button variant="ghost" size="sm" disabled={selectedMmsis.size === 0} onClick={clearAll}>
|
|
||||||
초기화
|
|
||||||
</Button>
|
|
||||||
{selectionWarning && <span style={{ color: '#fca5a5', fontSize: 11 }}>{selectionWarning}</span>}
|
|
||||||
<div style={STYLE_FOOTER_SPACER} />
|
|
||||||
<span style={{ color: '#93c5fd', fontSize: 12 }}>선택 {selectedMmsis.size}척</span>
|
|
||||||
<Button variant="primary" size="md" disabled={selectedMmsis.size === 0} onClick={() => submitQuery(vessels)}>
|
|
||||||
{isQuerying ? '재조회' : '조회 시작'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
# Release Notes
|
|
||||||
|
|
||||||
이 문서는 [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/) 형식을 따릅니다.
|
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
## [2026-03-25]
|
|
||||||
|
|
||||||
### 추가
|
|
||||||
- ENC 전자해도 베이스맵 (gcnautical 타일 서버, S-52 49개 레이어 + 73개 스프라이트)
|
|
||||||
- ENC 설정 패널 — 12개 레이어 토글, 영역 색상 3종, 수심 색상 5단계 커스텀
|
|
||||||
- 배경색 밝기 기반 선박 라벨 색상 자동 전환
|
|
||||||
- Globe 선박 아이콘 SDF 테두리 (icon-halo)
|
|
||||||
|
|
||||||
### 변경
|
|
||||||
- Globe 선박 원형 halo/outline 제거 → 아이콘 본체만 표시
|
|
||||||
- Globe 선박 아이콘 1.3배 스케일, 줌아웃 최소 크기 보장 (minzoom 2)
|
|
||||||
- 선박명 영문 우선 표시 (영문 → 한자 → AIS 순), 대문자 변환
|
|
||||||
- 연결선/범위/선단 토글 off 시 인터랙티브 오버레이 완전 차단
|
|
||||||
- 강조 링/알람 링 클러스터링 연동 (줌아웃 시 미표시 선박 제거)
|
|
||||||
- 기타 AIS 투명도 상향, Globe 줌아웃 시 가시성 개선
|
|
||||||
- 폰트 Open Sans 폴백 전면 제거 → Noto Sans 단독
|
|
||||||
|
|
||||||
### 기타
|
|
||||||
- 경고 필터 초기값 false, 연결선/범위/선단 초기 비활성
|
|
||||||
- 사진 파란 원 아이콘 제거 (Globe + Mercator)
|
|
||||||
|
|
||||||
## [2026-03-18]
|
|
||||||
|
|
||||||
### 추가
|
|
||||||
- 다중 항적 선박 그룹 저장/불러오기 (계정별 localStorage, 최대 10개)
|
|
||||||
- 선박 목록 컬럼별 정렬 (업종/등록번호/선명/MMSI/속력/상태)
|
|
||||||
- 선박 선택 초기화 버튼
|
|
||||||
- 모든 선박 우클릭 컨텍스트 메뉴 — 선명 복사, MMSI 복사
|
|
||||||
|
|
||||||
### 변경
|
|
||||||
- 우클릭 항적조회를 대상선박 외 모든 선박으로 컨텍스트 메뉴 확장 (항적조회는 대상선박만 유지)
|
|
||||||
|
|
||||||
## [2026-03-10]
|
|
||||||
|
|
||||||
### 추가
|
|
||||||
- OSM 베이스맵 추가 + Base/OSM/Ocean 3-way 라디오 그룹 전환
|
|
||||||
|
|
||||||
### 기타
|
|
||||||
- 팀 워크플로우 v1.6.1 동기화 + 관리 파일 .gitignore 전환
|
|
||||||
@ -84,7 +84,6 @@ function readPermittedListXlsx(filePath) {
|
|||||||
const prev = byPermitNo.get(permitNo);
|
const prev = byPermitNo.get(permitNo);
|
||||||
if (prev) {
|
if (prev) {
|
||||||
if (mmsi && !prev.mmsiList.includes(mmsi)) prev.mmsiList.push(mmsi);
|
if (mmsi && !prev.mmsiList.includes(mmsi)) prev.mmsiList.push(mmsi);
|
||||||
if (!prev.mmsi && mmsi) prev.mmsi = mmsi;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,7 +101,6 @@ function readPermittedListXlsx(filePath) {
|
|||||||
workTerm2: toStr(r.work_term2),
|
workTerm2: toStr(r.work_term2),
|
||||||
quota: toStr(r.quota),
|
quota: toStr(r.quota),
|
||||||
shipCode: toStr(r.ship_code),
|
shipCode: toStr(r.ship_code),
|
||||||
mmsi: mmsi,
|
|
||||||
mmsiList: mmsi ? [mmsi] : [],
|
mmsiList: mmsi ? [mmsi] : [],
|
||||||
sources: { permittedList: true, checklist: false, fleet906: false },
|
sources: { permittedList: true, checklist: false, fleet906: false },
|
||||||
ownerCn: null,
|
ownerCn: null,
|
||||||
@ -226,7 +224,6 @@ async function main() {
|
|||||||
workTerm2: "",
|
workTerm2: "",
|
||||||
quota: "",
|
quota: "",
|
||||||
shipCode: c.shipCode,
|
shipCode: c.shipCode,
|
||||||
mmsi: null,
|
|
||||||
mmsiList: [],
|
mmsiList: [],
|
||||||
sources: { permittedList: false, checklist: true, fleet906: false },
|
sources: { permittedList: false, checklist: true, fleet906: false },
|
||||||
ownerCn: c.ownerCn || null,
|
ownerCn: c.ownerCn || null,
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user